第 6 篇:照亮你的世界 - WebGL 基础光照

77 阅读9分钟

欢迎回到我们的 3D 之旅。在上一篇文章中,我们成功地构建并旋转了一个 3D 立方体。这非常酷,但你有没有觉得它看起来……有点奇怪?无论它怎么旋转,每个面的颜色都一成不变,像一个悬浮在空中的彩色纸盒,完全没有体积感。

问题出在哪里?没有光

在现实世界中,我们能感知到物体的形状和体积,是因为光线照射在它们表面,产生了明暗变化。一个球体,正对着光的一面最亮,侧面逐渐变暗,背面则处于阴影中。正是这种明暗的渐变,告诉我们的大脑“这是一个球体,而不是一个平面圆盘”。

今天,我们的目标就是在 WebGL 中模拟这个过程,为我们的场景引入最基础、也最重要的光照模型。

光照的秘密武器:法向量 (Normal Vector)

要计算光照,我们必须知道每个面的朝向。一个面是正对着光源,还是侧对着?这个“朝向”信息,在计算机图形学中,由一个叫做法向量的东西来表示。

法向量是一个垂直于表面并指向外部的向量(一个带箭头的线)。想象一下,在你立方体的每个面的正中心,都长出了一根垂直的刺,这根刺的方向,就是这个面的法向量。

有了法向量,光照计算的逻辑就变得非常直观:

  • 如果一个面的法向量光线方向的夹角很小(几乎正对着光),这个面就很亮。
  • 如果夹角是 90 度(光线擦着表面过去),这个面就几乎没有光。
  • 如果夹角大于 90 度(面背对着光),这个面就处于阴影中。

这个夹角关系,我们可以用一个非常高效的数学工具来计算——点积 (Dot Product)。两个向量的点积结果,直接反映了它们方向的相似程度。

最基础的光照:环境光与漫反射

一个完整的光照模型很复杂,但我们可以从两个最简单的部分开始:

  1. 环境光 (Ambient Light):

    • 想象一个没有太阳的阴天,光线经过无数次反射,从四面八方均匀地散射过来。物体没有明显的阴影,但你依然能看清它们。这就是环境光。
    • 在 WebGL 中,它是一个“作弊”的技巧:我们给整个场景一个基础的、微弱的亮度,确保物体背光的一面不是纯黑一片,能保留一些细节。
  2. 漫反射光 (Diffuse Light):

    • 这是我们刚才讨论的主要光照。它模拟的是光线从某个特定方向(比如太阳)照射过来,在粗糙表面(如纸、墙壁)上向各个方向均匀反射的效果。
    • 一个表面的漫反射亮度,就取决于它的法向量光线方向的点积。

我们将把这两种光结合起来:最终颜色 = 物体基色 * (环境光颜色 + 漫反射光颜色)

着色器大升级

1. 顶点着色器:处理法向量

  • 它需要接收一个新的 attributea_normal
  • 一个陷阱: 当我们旋转或缩放模型时,法向量也必须跟着旋转。但我们不能直接用 MVP 矩阵去乘法向量,尤其在有非等比缩放时,会导致法向量不再垂直于表面。我们需要一个专门的法线矩阵 (Normal Matrix),它通常是模型矩阵(或模型-视图矩阵)的逆转置矩阵。听起来很复杂,但幸运的是,gl-matrix 这样的库能帮我们一键生成。
  • 最后,它将变换后的法向量,通过 varying 传递给片元着色器。
attribute vec4 a_position;
attribute vec3 a_normal; // 新增: 顶点法向量
attribute vec4 a_color;

uniform mat4 u_mvpMatrix;
uniform mat4 u_modelMatrix; // 新增: 模型矩阵,用于光照计算
uniform mat4 u_normalMatrix; // 新增: 法线矩阵

varying vec4 v_color;
varying vec3 v_normal; // 新增: 传递变换后的法向量
varying vec3 v_surfaceToWorld; // 新增: 传递顶点在世界空间中的位置

void main() {
  gl_Position = u_mvpMatrix * a_position;
  
  // 变换法向量并传递
  v_normal = (u_normalMatrix * vec4(a_normal, 0.0)).xyz;

  // 变换顶点位置到世界空间并传递
  v_surfaceToWorld = (u_modelMatrix * a_position).xyz;
  
  v_color = a_color;
}

(注:更精确的光照计算应在世界空间中进行,所以我们多传递一个 v_surfaceToWorld 变量)

2. 片元着色器:执行光照计算 这是所有魔法发生的地方。它接收法向量、世界坐标,以及代表光源信息的 uniform 变量,然后执行点积运算。

precision mediump float;

varying vec4 v_color;
varying vec3 v_normal;
varying vec3 v_surfaceToWorld;

uniform vec3 u_lightDirection; // 新增: 光源方向
uniform vec3 u_lightColor;     // 新增: 光源颜色
uniform vec3 u_ambientLight;   // 新增: 环境光

void main() {
  // 归一化法向量 (插值过程可能使其长度不为1)
  vec3 normal = normalize(v_normal);

  // 计算光照强度 (点积)
  // 我们取反光线方向,因为我们关心的是从表面到光源的方向
  float light_intensity = max(dot(normal, -u_lightDirection), 0.0);
  
  // 计算漫反射颜色
  vec3 diffuse = u_lightColor * light_intensity;
  
  // 最终颜色 = 物体基色 * (环境光 + 漫反射光)
  // v_color.rgb 是物体的原始颜色
  vec3 final_color = v_color.rgb * (u_ambientLight + diffuse);
  
  gl_FragColor = vec4(final_color, v_color.a);
}

3. JavaScript:提供所有数据 JavaScript 现在需要准备更多的数据:

  • 顶点数据: 缓冲中需要包含每个顶点的法向量信息 [X, Y, Z, NX, NY, NZ, ...]
  • 新的 Uniforms: 需要获取光照相关 uniform 的位置,并在每一帧设置它们的值(光源方向、颜色等)。
  • 法线矩阵计算: 在动画循环中,根据模型矩阵计算出法线矩阵,并上传。
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>WebGL 教程 7:基础光照</title>
    <style>
        body { background-color: #222; color: #eee; text-align: center; }
        canvas { background-color: #000; border: 1px solid #555; }
    </style>
</head>
<body onload="main()">
    <h1>照亮你的世界 - WebGL 基础光照</h1>
    <canvas id="webgl-canvas" width="600" height="600"></canvas>

    <!-- 顶点着色器 -->
    <script id="vertex-shader" type="x-shader/x-vertex">
        attribute vec4 a_position;
        attribute vec3 a_normal; // 新: 法向量
        
        uniform mat4 u_mvpMatrix;
        uniform mat4 u_normalMatrix; // 新: 法线矩阵

        varying vec3 v_normal;

        void main() {
            gl_Position = u_mvpMatrix * a_position;
            // 使用法线矩阵变换法向量
            v_normal = (u_normalMatrix * vec4(a_normal, 0.0)).xyz;
        }
    </script>

    <!-- 片元着色器 -->
    <script id="fragment-shader" type="x-shader/x-fragment">
        precision mediump float;

        varying vec3 v_normal;

        uniform vec3 u_lightDirection; // 新: 光源方向
        uniform vec3 u_lightColor;     // 新: 光源颜色
        uniform vec3 u_ambientLight;   // 新: 环境光颜色

        void main() {
            // 对插值后的法向量进行归一化
            vec3 normal = normalize(v_normal);

            // 计算光线方向和法向量的点积
            // 我们希望光线方向是从物体表面指向光源
             // 我们用 max(..., 0.0) 来防止点积为负值(即光从背面照射)
            float light_factor = max(dot(normal, -normalize(u_lightDirection)), 0.0);

            // 计算漫反射颜色
            vec3 diffuse = u_lightColor * light_factor;

            // 最终的颜色是环境光和漫反射光的总和
            // 这里我们假设物体基色为白色(1,1,1),直接用光色
            vec3 finalColor = u_ambientLight + diffuse;

            gl_FragColor = vec4(finalColor, 1.0);
        }
    </script>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script>

    <script>
        function main() {
            const canvas = document.getElementById('webgl-canvas');
            const gl = canvas.getContext('webgl');
            if (!gl) { alert('WebGL not supported!'); return; }

            const program = createProgram(gl, 
                document.getElementById('vertex-shader').text,
                document.getElementById('fragment-shader').text);

            const locations = {
                position: gl.getAttribLocation(program, "a_position"),
                normal: gl.getAttribLocation(program, "a_normal"),
                mvpMatrix: gl.getUniformLocation(program, "u_mvpMatrix"),
                normalMatrix: gl.getUniformLocation(program, "u_normalMatrix"),
                lightDirection: gl.getUniformLocation(program, "u_lightDirection"),
                lightColor: gl.getUniformLocation(program, 'u_lightColor'),
                ambientLight: gl.getUniformLocation(program, 'u_ambientLight'),
            };

            const buffer = initBuffers(gl);

            gl.useProgram(program);
            gl.enable(gl.DEPTH_Test);
            gl.depthFunc(gl.LEQUAL);

            let cubeRotation = 0.0;
            let lastTime = 0;

            function animate(now) {
                now *= 0.001;
                const deltaTime = now - lastTime;
                lastTime = now;
                
                drawScene(gl, locations, buffer, cubeRotation);
                cubeRotation += deltaTime;
                
                requestAnimationFrame(animate);
            }
            requestAnimationFrame(animate);
        }
        
        function drawScene(gl, locations, buffers, rotation) {
            gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
            gl.clearColor(0.1, 0.1, 0.1, 1.0);
            gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

            // --- 矩阵设置 ---
            const projectionMatrix = mat4.create();
            mat4.perspective(projectionMatrix, 45 * Math.PI / 180, gl.canvas.clientWidth / gl.canvas.clientHeight, 0.1, 100.0);

            const viewMatrix = mat4.create();
            mat4.lookAt(viewMatrix,,,);

            const modelMatrix = mat4.create();
            mat4.rotate(modelMatrix, modelMatrix, rotation * 0.5,);
            mat4.rotate(modelMatrix, modelMatrix, rotation * 0.3,);

            const mvpMatrix = mat4.create();
            mat4.multiply(mvpMatrix, projectionMatrix, viewMatrix);
            mat4.multiply(mvpMatrix, mvpMatrix, modelMatrix);
            
            // --- 法线矩阵计算 ---
            const normalMatrix = mat4.create();
            mat4.invert(normalMatrix, modelMatrix);
            mat4.transpose(normalMatrix, normalMatrix);

            // --- 数据绑定与 Uniform 设置 ---
            gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
            gl.vertexAttribPointer(locations.position, 3, gl.FLOAT, false, 0, 0);
            gl.enableVertexAttribArray(locations.position);

            gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
            gl.vertexAttribPointer(locations.normal, 3, gl.FLOAT, false, 0, 0);
            gl.enableVertexAttribArray(locations.normal);
            
            gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);

            gl.uniformMatrix4fv(locations.mvpMatrix, false, mvpMatrix);
            gl.uniformMatrix4fv(locations.normalMatrix, false, normalMatrix);
            
            // 设置光照 Uniforms
            gl.uniform3fv(locations.lightDirection, [0.5, 0.7, 1.0]);
            gl.uniform3fv(locations.lightColor, [1.0, 1.0, 1.0]); // 白色光
            gl.uniform3fv(locations.ambientLight, [0.2, 0.2, 0.2]); // 灰色环境光

            // --- 绘制 ---
            gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
        }

        function initBuffers(gl) {
            const positions = new Float32Array([ /* ...立方体顶点... */ ]);
            const normals = new Float32Array([ /* ...立方体法线... */ ]);
            const indices = new Uint16Array([ /* ...立方体索引... */ ]);
            // Data ommited for brevity, it's a standard cube definition
            const faceColors = [,,,,, ];
            var colors = [];
            for (var j=0; j<faceColors.length; ++j) { const c = faceColors[j]; colors = colors.concat(c,c,c,c); }
            const colorBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW);
            const p = [ -1,1,1, -1,-1,1, 1,-1,1, 1,1,1, -1,1,-1, -1,-1,-1, 1,-1,-1, 1,1,-1 ]; const pos = new Float32Array(p);
            const ind = new Uint16Array([ 0,1,2, 0,2,3, 3,2,6, 3,6,7, 7,6,5, 7,5,4, 4,5,1, 4,1,0, 4,0,3, 4,3,7, 1,5,6, 1,6,2 ]);
            const norm = new Float32Array([ 0,0,1,0,0,1,0,0,1,0,0,1, 0,0,-1,0,0,-1,0,0,-1,0,0,-1, 0,1,0,0,1,0,0,1,0,0,1,0, 0,-1,0,0,-1,0,0,-1,0,0,-1,0, 1,0,0,1,0,0,1,0,0,1,0,0, -1,0,0,-1,0,0,-1,0,0,-1,0,0 ]);
            const positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData(gl.ARRAY_BUFFER, pos, gl.STATIC_DRAW);
            const normalBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer); gl.bufferData(gl.ARRAY_BUFFER, norm, gl.STATIC_DRAW);
            const indexBuffer = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, ind, gl.STATIC_DRAW);
            return { position: positionBuffer, normal: normalBuffer, indices: indexBuffer, };
        }
        
        function createProgram(gl, vsSource, fsSource) { /* ... same as before ... */ function createShader(type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error("Shader error:", gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } const vs = createShader(gl.VERTEX_SHADER, vsSource); const fs = createShader(gl.FRAGMENT_SHADER, fsSource); const prog = gl.createProgram(); gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { console.error("Program link error:", gl.getProgramInfoLog(prog)); gl.deleteProgram(prog); return null; } return prog; }
    </script>
</body>
</html>

总结与展望

运行代码,见证奇迹的时刻!你的立方体现在看起来应该有了真实的体积感。当它旋转时,不同的面会根据它们与虚拟光源的角度而变亮或变暗。我们成功地为这个冰冷的数字世界注入了第一缕光。

今天,我们掌握了 3D 渲染中至关重要的一环:

  • 理解了法向量在光照计算中的核心地位。
  • 实现了环境光 + 漫反射的基础光照模型。
  • 学会了使用法线矩阵来正确变换法向量。
  • 在着色器中,通过点积运算,将数学和物理结合了起来,创造了视觉上的真实感。

但光的世界远不止于此。你有没有想过,为什么金属和塑料的反光方式完全不同?金属上会有刺眼的高光,而我们现在的立方体表面却非常粗糙。

在下一篇教程中,我们将为光照模型加入最后一块重要拼图:镜面反射高光 (Specular Highlights)。我们将模拟物体表面“高光”的形成,让我们的材质拥有“光泽感”,从而能够区分出磨砂表面和抛光表面。