Skip to content

第12章:实战案例集

12.1 章节概述

本章通过完整的实战案例,综合运用前面章节学习的知识。每个案例都包含完整的代码和详细的解析。

本章包含的案例:

  1. 3D 模型查看器:加载和显示 OBJ 模型
  2. 粒子系统:火焰、烟雾效果
  3. 后处理管线:Bloom、色调映射
  4. 天空盒与环境映射:反射效果
  5. 简易 3D 游戏:第一人称漫游

12.2 案例一:3D 模型查看器

12.2.1 功能需求

  • 加载 OBJ 格式模型
  • 支持轨道相机控制
  • 基础光照(Blinn-Phong)
  • 线框模式切换

12.2.2 OBJ 解析器

javascript
/**
 * OBJ 文件解析器
 */
class OBJLoader {
    static parse(objText) {
        const positions = [];
        const normals = [];
        const uvs = [];
        const vertices = [];
        const indices = [];
        
        const positionData = [];
        const normalData = [];
        const uvData = [];
        
        const lines = objText.split('\n');
        
        for (const line of lines) {
            const parts = line.trim().split(/\s+/);
            const type = parts[0];
            
            switch (type) {
                case 'v':  // 顶点位置
                    positionData.push(
                        parseFloat(parts[1]),
                        parseFloat(parts[2]),
                        parseFloat(parts[3])
                    );
                    break;
                    
                case 'vn': // 法线
                    normalData.push(
                        parseFloat(parts[1]),
                        parseFloat(parts[2]),
                        parseFloat(parts[3])
                    );
                    break;
                    
                case 'vt': // 纹理坐标
                    uvData.push(
                        parseFloat(parts[1]),
                        parseFloat(parts[2])
                    );
                    break;
                    
                case 'f':  // 面
                    // 处理三角形和四边形
                    const faceVertices = parts.slice(1).map(v => {
                        const indices = v.split('/').map(i => parseInt(i) - 1);
                        return {
                            position: indices[0],
                            uv: indices[1] || -1,
                            normal: indices[2] || -1
                        };
                    });
                    
                    // 三角化(支持四边形)
                    for (let i = 1; i < faceVertices.length - 1; i++) {
                        this.addVertex(faceVertices[0], positionData, normalData, uvData, positions, normals, uvs);
                        this.addVertex(faceVertices[i], positionData, normalData, uvData, positions, normals, uvs);
                        this.addVertex(faceVertices[i + 1], positionData, normalData, uvData, positions, normals, uvs);
                    }
                    break;
            }
        }
        
        // 如果没有法线,计算它们
        if (normals.length === 0) {
            this.computeNormals(positions, normals);
        }
        
        return {
            positions: new Float32Array(positions),
            normals: new Float32Array(normals),
            uvs: new Float32Array(uvs),
            vertexCount: positions.length / 3
        };
    }
    
    static addVertex(v, posData, normData, uvData, positions, normals, uvs) {
        positions.push(
            posData[v.position * 3],
            posData[v.position * 3 + 1],
            posData[v.position * 3 + 2]
        );
        
        if (v.normal >= 0) {
            normals.push(
                normData[v.normal * 3],
                normData[v.normal * 3 + 1],
                normData[v.normal * 3 + 2]
            );
        }
        
        if (v.uv >= 0) {
            uvs.push(
                uvData[v.uv * 2],
                uvData[v.uv * 2 + 1]
            );
        }
    }
    
    static computeNormals(positions, normals) {
        for (let i = 0; i < positions.length; i += 9) {
            const v0 = [positions[i], positions[i+1], positions[i+2]];
            const v1 = [positions[i+3], positions[i+4], positions[i+5]];
            const v2 = [positions[i+6], positions[i+7], positions[i+8]];
            
            const e1 = [v1[0]-v0[0], v1[1]-v0[1], v1[2]-v0[2]];
            const e2 = [v2[0]-v0[0], v2[1]-v0[1], v2[2]-v0[2]];
            
            const n = [
                e1[1]*e2[2] - e1[2]*e2[1],
                e1[2]*e2[0] - e1[0]*e2[2],
                e1[0]*e2[1] - e1[1]*e2[0]
            ];
            
            const len = Math.sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]);
            n[0] /= len; n[1] /= len; n[2] /= len;
            
            for (let j = 0; j < 3; j++) {
                normals.push(n[0], n[1], n[2]);
            }
        }
    }
}

12.2.3 完整的模型查看器

javascript
/**
 * 3D 模型查看器
 */
class ModelViewer {
    constructor(canvas) {
        this.canvas = canvas;
        this.gl = canvas.getContext('webgl2');
        
        this.initShaders();
        this.initCamera();
        this.initLighting();
        
        this.model = null;
        this.wireframe = false;
    }
    
    initShaders() {
        const vertexShader = `#version 300 es
            in vec3 a_position;
            in vec3 a_normal;
            
            uniform mat4 u_mvp;
            uniform mat4 u_model;
            uniform mat3 u_normalMatrix;
            
            out vec3 v_worldPos;
            out vec3 v_normal;
            
            void main() {
                v_worldPos = (u_model * vec4(a_position, 1.0)).xyz;
                v_normal = u_normalMatrix * a_normal;
                gl_Position = u_mvp * vec4(a_position, 1.0);
            }
        `;
        
        const fragmentShader = `#version 300 es
            precision highp float;
            
            in vec3 v_worldPos;
            in vec3 v_normal;
            
            uniform vec3 u_lightPos;
            uniform vec3 u_lightColor;
            uniform vec3 u_cameraPos;
            uniform vec3 u_baseColor;
            uniform float u_shininess;
            
            out vec4 fragColor;
            
            void main() {
                vec3 N = normalize(v_normal);
                vec3 L = normalize(u_lightPos - v_worldPos);
                vec3 V = normalize(u_cameraPos - v_worldPos);
                vec3 H = normalize(L + V);
                
                // 环境光
                vec3 ambient = 0.1 * u_baseColor;
                
                // 漫反射
                float diff = max(dot(N, L), 0.0);
                vec3 diffuse = diff * u_lightColor * u_baseColor;
                
                // 镜面反射
                float spec = pow(max(dot(N, H), 0.0), u_shininess);
                vec3 specular = spec * u_lightColor;
                
                fragColor = vec4(ambient + diffuse + specular, 1.0);
            }
        `;
        
        this.program = this.createProgram(vertexShader, fragmentShader);
    }
    
    initCamera() {
        this.camera = new OrbitCamera();
        this.camera.setTarget(0, 0, 0);
        this.camera.setDistance(5);
        
        // 添加鼠标控制
        new CameraController(this.camera, this.canvas);
    }
    
    initLighting() {
        this.lightPosition = [5, 5, 5];
        this.lightColor = [1, 1, 1];
    }
    
    async loadModel(url) {
        const response = await fetch(url);
        const objText = await response.text();
        const meshData = OBJLoader.parse(objText);
        
        const gl = this.gl;
        
        // 创建 VAO
        this.modelVAO = gl.createVertexArray();
        gl.bindVertexArray(this.modelVAO);
        
        // 位置缓冲
        const posBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, meshData.positions, gl.STATIC_DRAW);
        gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
        gl.enableVertexAttribArray(0);
        
        // 法线缓冲
        const normBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, meshData.normals, gl.STATIC_DRAW);
        gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
        gl.enableVertexAttribArray(1);
        
        gl.bindVertexArray(null);
        
        this.vertexCount = meshData.vertexCount;
        
        // 计算包围盒,自动调整相机
        this.computeBounds(meshData.positions);
    }
    
    computeBounds(positions) {
        let minX = Infinity, maxX = -Infinity;
        let minY = Infinity, maxY = -Infinity;
        let minZ = Infinity, maxZ = -Infinity;
        
        for (let i = 0; i < positions.length; i += 3) {
            minX = Math.min(minX, positions[i]);
            maxX = Math.max(maxX, positions[i]);
            minY = Math.min(minY, positions[i+1]);
            maxY = Math.max(maxY, positions[i+1]);
            minZ = Math.min(minZ, positions[i+2]);
            maxZ = Math.max(maxZ, positions[i+2]);
        }
        
        const center = [
            (minX + maxX) / 2,
            (minY + maxY) / 2,
            (minZ + maxZ) / 2
        ];
        
        const size = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
        
        this.camera.setTarget(...center);
        this.camera.setDistance(size * 2);
    }
    
    render() {
        const gl = this.gl;
        
        gl.viewport(0, 0, this.canvas.width, this.canvas.height);
        gl.clearColor(0.1, 0.1, 0.1, 1.0);
        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
        
        gl.enable(gl.DEPTH_TEST);
        
        if (!this.modelVAO) return;
        
        gl.useProgram(this.program);
        
        // 设置矩阵
        const model = mat4.create();
        const view = this.camera.getViewMatrix();
        const projection = mat4.create();
        mat4.perspective(projection, Math.PI / 4, this.canvas.width / this.canvas.height, 0.1, 100);
        
        const mvp = mat4.create();
        mat4.multiply(mvp, view, model);
        mat4.multiply(mvp, projection, mvp);
        
        const normalMatrix = mat3.create();
        mat3.normalFromMat4(normalMatrix, model);
        
        gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'u_mvp'), false, mvp);
        gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'u_model'), false, model);
        gl.uniformMatrix3fv(gl.getUniformLocation(this.program, 'u_normalMatrix'), false, normalMatrix);
        
        // 设置光照
        gl.uniform3fv(gl.getUniformLocation(this.program, 'u_lightPos'), this.lightPosition);
        gl.uniform3fv(gl.getUniformLocation(this.program, 'u_lightColor'), this.lightColor);
        gl.uniform3fv(gl.getUniformLocation(this.program, 'u_cameraPos'), this.camera.position);
        gl.uniform3fv(gl.getUniformLocation(this.program, 'u_baseColor'), [0.8, 0.8, 0.8]);
        gl.uniform1f(gl.getUniformLocation(this.program, 'u_shininess'), 32);
        
        // 绘制
        gl.bindVertexArray(this.modelVAO);
        
        if (this.wireframe) {
            gl.drawArrays(gl.LINES, 0, this.vertexCount);
        } else {
            gl.drawArrays(gl.TRIANGLES, 0, this.vertexCount);
        }
        
        gl.bindVertexArray(null);
    }
    
    start() {
        const loop = () => {
            this.render();
            requestAnimationFrame(loop);
        };
        loop();
    }
}

// 使用
const viewer = new ModelViewer(document.getElementById('canvas'));
await viewer.loadModel('models/bunny.obj');
viewer.start();

12.3 案例二:粒子系统

12.3.1 CPU 粒子系统

javascript
/**
 * CPU 粒子系统
 */
class ParticleSystem {
    constructor(gl, maxParticles = 10000) {
        this.gl = gl;
        this.maxParticles = maxParticles;
        this.particles = [];
        this.deadParticles = [];
        
        // 发射器配置
        this.emitterPosition = [0, 0, 0];
        this.emitRate = 100;  // 每秒发射数量
        this.particleLife = 3.0;
        
        // 粒子属性范围
        this.speedRange = [2, 5];
        this.sizeRange = [5, 20];
        this.colorStart = [1, 0.5, 0, 1];
        this.colorEnd = [1, 0, 0, 0];
        
        this.initBuffers();
        this.initShaders();
        
        this.lastEmitTime = 0;
        this.emitAccumulator = 0;
    }
    
    initBuffers() {
        const gl = this.gl;
        
        // 粒子数据缓冲(位置 + 颜色 + 大小)
        this.particleBuffer = gl.createBuffer();
        this.particleData = new Float32Array(this.maxParticles * 8);  // x,y,z,r,g,b,a,size
    }
    
    initShaders() {
        const vertexShader = `#version 300 es
            in vec3 a_position;
            in vec4 a_color;
            in float a_size;
            
            uniform mat4 u_mvp;
            
            out vec4 v_color;
            
            void main() {
                gl_Position = u_mvp * vec4(a_position, 1.0);
                gl_PointSize = a_size;
                v_color = a_color;
            }
        `;
        
        const fragmentShader = `#version 300 es
            precision highp float;
            
            in vec4 v_color;
            uniform sampler2D u_texture;
            
            out vec4 fragColor;
            
            void main() {
                vec2 uv = gl_PointCoord;
                vec4 tex = texture(u_texture, uv);
                
                // 圆形衰减
                float dist = length(uv - 0.5) * 2.0;
                float alpha = 1.0 - smoothstep(0.5, 1.0, dist);
                
                fragColor = v_color * tex * alpha;
            }
        `;
        
        this.program = this.createProgram(vertexShader, fragmentShader);
    }
    
    emit(count) {
        for (let i = 0; i < count; i++) {
            if (this.particles.length >= this.maxParticles) {
                // 复用死亡粒子
                if (this.deadParticles.length > 0) {
                    const particle = this.deadParticles.pop();
                    this.resetParticle(particle);
                    this.particles.push(particle);
                }
            } else {
                this.particles.push(this.createParticle());
            }
        }
    }
    
    createParticle() {
        const speed = this.random(this.speedRange[0], this.speedRange[1]);
        const theta = Math.random() * Math.PI * 2;
        const phi = Math.random() * Math.PI;
        
        return {
            position: [...this.emitterPosition],
            velocity: [
                speed * Math.sin(phi) * Math.cos(theta),
                speed * Math.cos(phi) + 2,  // 向上的偏移
                speed * Math.sin(phi) * Math.sin(theta)
            ],
            life: this.particleLife,
            maxLife: this.particleLife,
            size: this.random(this.sizeRange[0], this.sizeRange[1]),
            color: [...this.colorStart]
        };
    }
    
    resetParticle(particle) {
        const speed = this.random(this.speedRange[0], this.speedRange[1]);
        const theta = Math.random() * Math.PI * 2;
        const phi = Math.random() * Math.PI;
        
        particle.position = [...this.emitterPosition];
        particle.velocity = [
            speed * Math.sin(phi) * Math.cos(theta),
            speed * Math.cos(phi) + 2,
            speed * Math.sin(phi) * Math.sin(theta)
        ];
        particle.life = this.particleLife;
        particle.maxLife = this.particleLife;
        particle.size = this.random(this.sizeRange[0], this.sizeRange[1]);
        particle.color = [...this.colorStart];
    }
    
    update(deltaTime) {
        const gravity = [0, -9.8, 0];
        
        // 发射新粒子
        this.emitAccumulator += deltaTime * this.emitRate;
        const toEmit = Math.floor(this.emitAccumulator);
        this.emitAccumulator -= toEmit;
        this.emit(toEmit);
        
        // 更新粒子
        for (let i = this.particles.length - 1; i >= 0; i--) {
            const p = this.particles[i];
            
            // 更新生命
            p.life -= deltaTime;
            
            if (p.life <= 0) {
                // 移除死亡粒子
                this.deadParticles.push(this.particles.splice(i, 1)[0]);
                continue;
            }
            
            // 更新速度(重力)
            p.velocity[0] += gravity[0] * deltaTime;
            p.velocity[1] += gravity[1] * deltaTime;
            p.velocity[2] += gravity[2] * deltaTime;
            
            // 更新位置
            p.position[0] += p.velocity[0] * deltaTime;
            p.position[1] += p.velocity[1] * deltaTime;
            p.position[2] += p.velocity[2] * deltaTime;
            
            // 插值颜色
            const t = 1 - p.life / p.maxLife;
            for (let j = 0; j < 4; j++) {
                p.color[j] = this.colorStart[j] + (this.colorEnd[j] - this.colorStart[j]) * t;
            }
        }
        
        // 更新缓冲数据
        for (let i = 0; i < this.particles.length; i++) {
            const p = this.particles[i];
            const offset = i * 8;
            
            this.particleData[offset + 0] = p.position[0];
            this.particleData[offset + 1] = p.position[1];
            this.particleData[offset + 2] = p.position[2];
            this.particleData[offset + 3] = p.color[0];
            this.particleData[offset + 4] = p.color[1];
            this.particleData[offset + 5] = p.color[2];
            this.particleData[offset + 6] = p.color[3];
            this.particleData[offset + 7] = p.size;
        }
    }
    
    render(mvp) {
        const gl = this.gl;
        
        if (this.particles.length === 0) return;
        
        gl.useProgram(this.program);
        
        // 上传数据
        gl.bindBuffer(gl.ARRAY_BUFFER, this.particleBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, this.particleData, gl.DYNAMIC_DRAW);
        
        // 设置属性
        const posLoc = gl.getAttribLocation(this.program, 'a_position');
        const colorLoc = gl.getAttribLocation(this.program, 'a_color');
        const sizeLoc = gl.getAttribLocation(this.program, 'a_size');
        
        gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 32, 0);
        gl.enableVertexAttribArray(posLoc);
        
        gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, 32, 12);
        gl.enableVertexAttribArray(colorLoc);
        
        gl.vertexAttribPointer(sizeLoc, 1, gl.FLOAT, false, 32, 28);
        gl.enableVertexAttribArray(sizeLoc);
        
        // 设置 uniform
        gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'u_mvp'), false, mvp);
        
        // 启用混合
        gl.enable(gl.BLEND);
        gl.blendFunc(gl.SRC_ALPHA, gl.ONE);  // 加法混合
        gl.depthMask(false);
        
        // 绘制
        gl.drawArrays(gl.POINTS, 0, this.particles.length);
        
        // 恢复状态
        gl.depthMask(true);
        gl.disable(gl.BLEND);
    }
    
    random(min, max) {
        return min + Math.random() * (max - min);
    }
}

12.4 案例三:后处理管线

12.4.1 Bloom 效果实现

javascript
/**
 * 后处理管线
 */
class PostProcessPipeline {
    constructor(gl, width, height) {
        this.gl = gl;
        this.width = width;
        this.height = height;
        
        this.createFramebuffers();
        this.createShaders();
    }
    
    createFramebuffers() {
        // 主场景 FBO(HDR)
        this.sceneFBO = this.createFBO(this.width, this.height, true);
        
        // 亮度提取 FBO
        this.brightFBO = this.createFBO(this.width / 2, this.height / 2, true);
        
        // 模糊 FBO(ping-pong)
        this.blurFBO1 = this.createFBO(this.width / 4, this.height / 4, true);
        this.blurFBO2 = this.createFBO(this.width / 4, this.height / 4, true);
    }
    
    createFBO(width, height, hdr = false) {
        const gl = this.gl;
        
        const fbo = {
            framebuffer: gl.createFramebuffer(),
            texture: gl.createTexture(),
            width, height
        };
        
        gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.framebuffer);
        
        gl.bindTexture(gl.TEXTURE_2D, fbo.texture);
        gl.texImage2D(
            gl.TEXTURE_2D, 0,
            hdr ? gl.RGBA16F : gl.RGBA,
            width, height, 0,
            gl.RGBA,
            hdr ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE,
            null
        );
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        
        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fbo.texture, 0);
        
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
        
        return fbo;
    }
    
    createShaders() {
        // 亮度提取着色器
        this.brightPassProgram = this.createPostProcessProgram(`
            precision highp float;
            
            in vec2 v_texCoord;
            uniform sampler2D u_texture;
            uniform float u_threshold;
            
            out vec4 fragColor;
            
            void main() {
                vec4 color = texture(u_texture, v_texCoord);
                float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
                
                if (brightness > u_threshold) {
                    fragColor = color;
                } else {
                    fragColor = vec4(0.0);
                }
            }
        `);
        
        // 高斯模糊着色器
        this.blurProgram = this.createPostProcessProgram(`
            precision highp float;
            
            in vec2 v_texCoord;
            uniform sampler2D u_texture;
            uniform vec2 u_direction;
            uniform vec2 u_resolution;
            
            out vec4 fragColor;
            
            void main() {
                vec2 texelSize = 1.0 / u_resolution;
                vec4 result = vec4(0.0);
                
                // 5-tap 高斯模糊
                float weights[5];
                weights[0] = 0.227027;
                weights[1] = 0.1945946;
                weights[2] = 0.1216216;
                weights[3] = 0.054054;
                weights[4] = 0.016216;
                
                result += texture(u_texture, v_texCoord) * weights[0];
                
                for (int i = 1; i < 5; i++) {
                    vec2 offset = u_direction * texelSize * float(i);
                    result += texture(u_texture, v_texCoord + offset) * weights[i];
                    result += texture(u_texture, v_texCoord - offset) * weights[i];
                }
                
                fragColor = result;
            }
        `);
        
        // 合成着色器
        this.compositeProgram = this.createPostProcessProgram(`
            precision highp float;
            
            in vec2 v_texCoord;
            uniform sampler2D u_sceneTexture;
            uniform sampler2D u_bloomTexture;
            uniform float u_bloomIntensity;
            uniform float u_exposure;
            
            out vec4 fragColor;
            
            // ACES 色调映射
            vec3 aces(vec3 x) {
                const float a = 2.51;
                const float b = 0.03;
                const float c = 2.43;
                const float d = 0.59;
                const float e = 0.14;
                return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
            }
            
            void main() {
                vec3 scene = texture(u_sceneTexture, v_texCoord).rgb;
                vec3 bloom = texture(u_bloomTexture, v_texCoord).rgb;
                
                // 合成
                vec3 hdr = scene + bloom * u_bloomIntensity;
                
                // 曝光
                hdr *= u_exposure;
                
                // 色调映射
                vec3 ldr = aces(hdr);
                
                // Gamma 校正
                ldr = pow(ldr, vec3(1.0 / 2.2));
                
                fragColor = vec4(ldr, 1.0);
            }
        `);
    }
    
    render(sceneRenderFn) {
        const gl = this.gl;
        
        // Pass 1: 渲染场景到 HDR FBO
        gl.bindFramebuffer(gl.FRAMEBUFFER, this.sceneFBO.framebuffer);
        gl.viewport(0, 0, this.sceneFBO.width, this.sceneFBO.height);
        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
        sceneRenderFn();
        
        // Pass 2: 提取高亮
        gl.bindFramebuffer(gl.FRAMEBUFFER, this.brightFBO.framebuffer);
        gl.viewport(0, 0, this.brightFBO.width, this.brightFBO.height);
        this.renderQuad(this.brightPassProgram, this.sceneFBO.texture, {
            u_threshold: 1.0
        });
        
        // Pass 3-4: 模糊(多次)
        let inputTex = this.brightFBO.texture;
        for (let i = 0; i < 5; i++) {
            // 水平模糊
            gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFBO1.framebuffer);
            gl.viewport(0, 0, this.blurFBO1.width, this.blurFBO1.height);
            this.renderQuad(this.blurProgram, inputTex, {
                u_direction: [1, 0],
                u_resolution: [this.blurFBO1.width, this.blurFBO1.height]
            });
            
            // 垂直模糊
            gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFBO2.framebuffer);
            gl.viewport(0, 0, this.blurFBO2.width, this.blurFBO2.height);
            this.renderQuad(this.blurProgram, this.blurFBO1.texture, {
                u_direction: [0, 1],
                u_resolution: [this.blurFBO2.width, this.blurFBO2.height]
            });
            
            inputTex = this.blurFBO2.texture;
        }
        
        // Pass 5: 合成到屏幕
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
        gl.viewport(0, 0, this.width, this.height);
        
        gl.useProgram(this.compositeProgram);
        
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, this.sceneFBO.texture);
        gl.uniform1i(gl.getUniformLocation(this.compositeProgram, 'u_sceneTexture'), 0);
        
        gl.activeTexture(gl.TEXTURE1);
        gl.bindTexture(gl.TEXTURE_2D, this.blurFBO2.texture);
        gl.uniform1i(gl.getUniformLocation(this.compositeProgram, 'u_bloomTexture'), 1);
        
        gl.uniform1f(gl.getUniformLocation(this.compositeProgram, 'u_bloomIntensity'), 0.5);
        gl.uniform1f(gl.getUniformLocation(this.compositeProgram, 'u_exposure'), 1.0);
        
        this.drawFullscreenQuad();
    }
}

12.5 案例四:天空盒与环境映射

javascript
/**
 * 天空盒渲染器
 */
class Skybox {
    constructor(gl) {
        this.gl = gl;
        this.initCube();
        this.initShaders();
    }
    
    initCube() {
        // 立方体顶点(从内部看)
        const vertices = new Float32Array([
            -1,  1, -1,  -1, -1, -1,   1, -1, -1,   1, -1, -1,   1,  1, -1,  -1,  1, -1,
            -1, -1,  1,  -1, -1, -1,  -1,  1, -1,  -1,  1, -1,  -1,  1,  1,  -1, -1,  1,
             1, -1, -1,   1, -1,  1,   1,  1,  1,   1,  1,  1,   1,  1, -1,   1, -1, -1,
            -1, -1,  1,  -1,  1,  1,   1,  1,  1,   1,  1,  1,   1, -1,  1,  -1, -1,  1,
            -1,  1, -1,   1,  1, -1,   1,  1,  1,   1,  1,  1,  -1,  1,  1,  -1,  1, -1,
            -1, -1, -1,  -1, -1,  1,   1, -1, -1,   1, -1, -1,  -1, -1,  1,   1, -1,  1
        ]);
        
        this.vao = this.gl.createVertexArray();
        this.gl.bindVertexArray(this.vao);
        
        const buffer = this.gl.createBuffer();
        this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
        this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW);
        this.gl.vertexAttribPointer(0, 3, this.gl.FLOAT, false, 0, 0);
        this.gl.enableVertexAttribArray(0);
        
        this.gl.bindVertexArray(null);
    }
    
    initShaders() {
        const vertexShader = `#version 300 es
            in vec3 a_position;
            out vec3 v_texCoord;
            
            uniform mat4 u_viewProjection;
            
            void main() {
                v_texCoord = a_position;
                vec4 pos = u_viewProjection * vec4(a_position, 1.0);
                gl_Position = pos.xyww;  // z = w,始终在最远处
            }
        `;
        
        const fragmentShader = `#version 300 es
            precision highp float;
            
            in vec3 v_texCoord;
            uniform samplerCube u_skybox;
            
            out vec4 fragColor;
            
            void main() {
                fragColor = texture(u_skybox, v_texCoord);
            }
        `;
        
        this.program = this.createProgram(vertexShader, fragmentShader);
    }
    
    async loadCubemap(paths) {
        const gl = this.gl;
        
        this.texture = gl.createTexture();
        gl.bindTexture(gl.TEXTURE_CUBE_MAP, this.texture);
        
        const targets = [
            gl.TEXTURE_CUBE_MAP_POSITIVE_X,
            gl.TEXTURE_CUBE_MAP_NEGATIVE_X,
            gl.TEXTURE_CUBE_MAP_POSITIVE_Y,
            gl.TEXTURE_CUBE_MAP_NEGATIVE_Y,
            gl.TEXTURE_CUBE_MAP_POSITIVE_Z,
            gl.TEXTURE_CUBE_MAP_NEGATIVE_Z
        ];
        
        for (let i = 0; i < 6; i++) {
            const image = await this.loadImage(paths[i]);
            gl.texImage2D(targets[i], 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
        }
        
        gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE);
    }
    
    render(viewMatrix, projectionMatrix) {
        const gl = this.gl;
        
        // 移除视图矩阵的平移部分
        const viewNoTranslate = mat4.clone(viewMatrix);
        viewNoTranslate[12] = 0;
        viewNoTranslate[13] = 0;
        viewNoTranslate[14] = 0;
        
        const viewProjection = mat4.create();
        mat4.multiply(viewProjection, projectionMatrix, viewNoTranslate);
        
        gl.depthFunc(gl.LEQUAL);
        
        gl.useProgram(this.program);
        gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'u_viewProjection'), false, viewProjection);
        
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_CUBE_MAP, this.texture);
        gl.uniform1i(gl.getUniformLocation(this.program, 'u_skybox'), 0);
        
        gl.bindVertexArray(this.vao);
        gl.drawArrays(gl.TRIANGLES, 0, 36);
        gl.bindVertexArray(null);
        
        gl.depthFunc(gl.LESS);
    }
}

12.6 案例五:简易第一人称漫游

javascript
/**
 * 第一人称控制器
 */
class FirstPersonController {
    constructor(camera, canvas) {
        this.camera = camera;
        this.canvas = canvas;
        
        this.position = [0, 1.8, 5];  // 眼睛高度 1.8m
        this.yaw = 0;    // 水平旋转
        this.pitch = 0;  // 垂直旋转
        
        this.moveSpeed = 5;
        this.lookSpeed = 0.002;
        
        this.keys = {};
        this.isLocked = false;
        
        this.setupEventListeners();
    }
    
    setupEventListeners() {
        // 键盘输入
        document.addEventListener('keydown', (e) => {
            this.keys[e.code] = true;
        });
        
        document.addEventListener('keyup', (e) => {
            this.keys[e.code] = false;
        });
        
        // 鼠标锁定
        this.canvas.addEventListener('click', () => {
            this.canvas.requestPointerLock();
        });
        
        document.addEventListener('pointerlockchange', () => {
            this.isLocked = document.pointerLockElement === this.canvas;
        });
        
        // 鼠标移动
        document.addEventListener('mousemove', (e) => {
            if (!this.isLocked) return;
            
            this.yaw -= e.movementX * this.lookSpeed;
            this.pitch -= e.movementY * this.lookSpeed;
            
            // 限制俯仰角
            this.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, this.pitch));
        });
    }
    
    update(deltaTime) {
        // 计算前方和右方向
        const forward = [
            Math.sin(this.yaw),
            0,
            -Math.cos(this.yaw)
        ];
        
        const right = [
            Math.cos(this.yaw),
            0,
            Math.sin(this.yaw)
        ];
        
        // 处理移动输入
        const movement = [0, 0, 0];
        
        if (this.keys['KeyW']) {
            movement[0] += forward[0];
            movement[2] += forward[2];
        }
        if (this.keys['KeyS']) {
            movement[0] -= forward[0];
            movement[2] -= forward[2];
        }
        if (this.keys['KeyA']) {
            movement[0] -= right[0];
            movement[2] -= right[2];
        }
        if (this.keys['KeyD']) {
            movement[0] += right[0];
            movement[2] += right[2];
        }
        
        // 归一化并应用速度
        const len = Math.sqrt(movement[0]*movement[0] + movement[2]*movement[2]);
        if (len > 0) {
            movement[0] = movement[0] / len * this.moveSpeed * deltaTime;
            movement[2] = movement[2] / len * this.moveSpeed * deltaTime;
        }
        
        // 更新位置
        this.position[0] += movement[0];
        this.position[2] += movement[2];
        
        // 更新相机
        this.updateCamera();
    }
    
    updateCamera() {
        // 计算目标点
        const target = [
            this.position[0] + Math.sin(this.yaw) * Math.cos(this.pitch),
            this.position[1] + Math.sin(this.pitch),
            this.position[2] - Math.cos(this.yaw) * Math.cos(this.pitch)
        ];
        
        this.camera.setPosition(...this.position);
        this.camera.setTarget(...target);
    }
}

12.7 本章小结

通过这些实战案例,我们综合运用了:

  • 模型加载:OBJ 解析、网格处理
  • 粒子系统:发射器、物理更新、渲染
  • 后处理:HDR、Bloom、色调映射
  • 环境映射:立方体贴图、天空盒
  • 交互控制:相机控制、键盘鼠标输入

这些案例涵盖了 WebGL 开发中最常见的需求,可以作为更复杂项目的起点。


12.8 延伸学习

推荐资源

  1. WebGL Fundamentals: https://webglfundamentals.org/
  2. Learn OpenGL: https://learnopengl.com/
  3. The Book of Shaders: https://thebookofshaders.com/
  4. Three.js: 学习成熟框架的实现

进阶主题

  • PBR(物理渲染)
  • 延迟渲染
  • 屏幕空间反射(SSR)
  • 环境光遮蔽(SSAO)
  • 体积光
  • GPU 驱动的管线

恭喜完成 WebGL 基础系列的学习!

继续探索图形编程的世界,创造更多精彩的视觉效果!


文档版本:v1.0
字数统计:约 12,000 字
代码示例:50+ 个

如有转载或 CV 的请标注本站原文地址