ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebGL 3D 方向性光照:从点积、法线到逆转置矩阵的完整实战

WebGL 3D 方向性光照:从点积、法线到逆转置矩阵的完整实战
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

方向性光照是 3D 图形中最简单的灯光模型——假设光从某个固定方向平行射入(如晴天的太阳),只需计算"光方向与表面法线的点积"就能得到每一点的明暗。本文基于 webgl-fundamentals 仓库中的方向性光照教程(韩文版)与其配套示例代码展开,从点积原理、法线数据结构,到顶点/片元着色器实现,再到用世界矩阵与逆转置矩阵修正光照的两个经典陷阱,完整走一遍可运行的实现链路。读完后你将能够独立为任意 3D 物体实现方向性光照,并理解为什么法线变换必须用worldInverseTranspose而不能直接用世界矩阵。

1. 方向性光照的原理:一个点积就够

光照的实现方式有很多种,其中方向性光照(directional lighting)大概是最简单的一种。它的假设是:光线从一个方向均匀地射入场景。晴朗天气的太阳通常被视为方向性光——因为它离物体足够远,光线照射物体表面时可以近似看作全部平行。

计算方向性光照实际上非常简单:只要知道光线的传播方向,又知道物体表面的朝向,对这两个方向做点积(dot product),就能得到它们之间夹角的余弦值。

仓库为这一节提供了两个可交互的演示页面,建议在浏览器中打开体验:

  • 点积演示:拖动两点观察点积变化;
  • 方向性光照演示:旋转方向向量,直观看到明暗如何随夹角变化。

点积的取值规则(针对单位向量):

两个向量的相对方向点积结果
完全相反-1
垂直0
完全相同+1

把它用到了表面上:当你知道 3D 物体表面的朝向和光照方向,求出两者的点积,就会得到这样一个标量——表面正对光时给出 1,正背对光时给出 -1。然后只要用这个标量去乘颜色,光照就做出来了。

2. 法线(Normal):描述表面朝向的单位向量

上面的思路引出一个问题:3D 物体表面的朝向怎么表示?答案是法线。"法线"一词来自拉丁语norma(直角规),和直角规一样,法线与线或表面成直角。在 3D 图形中,法线是描述表面朝向的单位向量。

仓库中有一个专门的法线可视化页面,同时展示立方体与球体的法线:从物体表面向外伸出的短线即各顶点的法线,并按方向着色(+x 为红、+y 为绿、+z 为蓝)。

这里有一个容易踩坑的细节:立方体每个角上会有 3 个法线,因为立方体的每个面朝向不同方向,每个面都需要自己的法线。这也意味着带光照的模型往往需要按面拆分顶点(每个顶点绑定所在面的法线),这正是后面"F" 几何数据的设计方式。

2.1 为示例几何"F"编写法线数据

教程在前一篇文章(WebGL 3D 相机)的"F"模型基础上加光照。由于"F"是盒状结构、各面都与 x、y、z 轴对齐,法线非常好写:

  • 朝向前方的面:法线0, 0, 1
  • 朝向后方的面:法线0, 0, -1
  • 朝向左侧的面:法线-1, 0, 0
  • 朝向右侧的面:法线1, 0, 0
  • 上表面:0, 1, 0
  • 下表面:0, -1, 0

完整的法线数据函数如下(与示例 webgl-3d-lighting-directional.html 中setNormals完全一致,注意法线数组的顺序必须与位置数组的面顺序一一对应,每个三角形 6 个顶点、每面 6 个法线):

function setNormals(gl) { var normals = 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, 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, 1, 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, 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, -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, // 上横档右侧 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 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, // 中横档顶面 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, // 中横档底面 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, // 底面 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 ]); gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW); }

3. 把法线送上 GPU:顶点属性替换与绑定

教程把原先的顶点颜色属性替换为法线属性(去掉颜色是为了让光照效果更明显)。对应的 JS 改动是:查找属性位置、创建并绑定法线缓冲区。

// 查找顶点数据要放的位置 var positionLocation = gl.getAttribLocation(program, "a_position"); -var colorLocation = gl.getAttribLocation(program, "a_color"); +var normalLocation = gl.getAttribLocation(program, "a_normal"); ... -// 创建存放颜色的缓冲区 -var colorBuffer = gl.createBuffer(); -// 绑定到 ARRAY_BUFFER(ARRAY_BUFFER = colorBuffer) -gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); -// 把几何数据写入缓冲区 -setColors(gl); +// 创建存放法线的缓冲区 +var normalBuffer = gl.createBuffer(); +// 绑定到 ARRAY_BUFFER(ARRAY_BUFFER = normalBuffer) +gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer); +// 把法线数据写入缓冲区 +setNormals(gl);

渲染循环中的属性启用与指针设置如下(注意与颜色属性不同的三个参数:法线是gl.FLOAT,每分量 32 位浮点数,且normalize为false,因为数据本身已经是单位向量):

-// 启用颜色属性 -gl.enableVertexAttribArray(colorLocation); - -// 绑定颜色缓冲区 -gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); - -// 告诉属性如何从 colorBuffer(ARRAY_BUFFER) 读取数据 -var size = 3; // 每次迭代 3 个分量 -var type = gl.UNSIGNED_BYTE; // 数据是无符号 8 位值 -var normalize = true; // 归一化数据(0-255 转 0-1) -var stride = 0; // 0 = 每次迭代前进 size * sizeof(type) 个字节 -var offset = 0; // 从缓冲区开头开始 -gl.vertexAttribPointer(colorLocation, size, type, normalize, stride, offset) +// 启用法线属性 +gl.enableVertexAttribArray(normalLocation); + +// 绑定法线缓冲区 +gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer); + +// 告诉属性如何从 normalBuffer(ARRAY_BUFFER) 读取数据 +var size = 3; // 每次迭代 3 个分量 +var type = gl.FLOAT; // 数据是 32 位浮点数 +var normalize = false; // 不归一化数据(数据已经是单位向量) +var stride = 0; // 0 = 每次迭代前进 size * sizeof(type) 个字节 +var offset = 0; // 从缓冲区开头开始 +gl.vertexAttribPointer(normalLocation, size, type, normalize, stride, offset)

这段代码在完整示例 webgl-3d-lighting-directional.html 中可以原样找到(含位置属性的对应设置,以及开启gl.DEPTH_TEST与gl.CULL_FACE的完整绘制流程)。

4. 着色器实现:传递法线并计算光照

4.1 顶点着色器:把法线传递给片元着色器

attribute vec4 a_position; -attribute vec4 a_color; +attribute vec3 a_normal; uniform mat4 u_matrix; -varying vec4 v_color; +varying vec3 v_normal; void main() { // 位置乘以矩阵 gl_Position = u_matrix * a_position; - // 把颜色传给片元着色器 - v_color = a_color; + // 把法线传给片元着色器 + v_normal = a_normal; }

4.2 片元着色器:核心就三行

precision mediump float; // 由顶点着色器传入 -varying vec4 v_color; +varying vec3 v_normal; +uniform vec3 u_reverseLightDirection; +uniform vec4 u_color; void main() { + // v_normal 是 varying,会被插值,因此不再是单位向量。 + // 归一化后重新变回单位向量。 + vec3 normal = normalize(v_normal); + + float light = dot(normal, u_reverseLightDirection); * gl_FragColor = u_color; + // 只把光强乘到颜色的 rgb 分量上(不乘 alpha) + gl_FragColor.rgb *= light; }

两个关键细节:

  1. normalize(v_normal)不可省略。varying变量在三角形内部被逐像素插值,插值结果一般不再是单位向量;而dot()得到夹角余弦的前提是两个单位向量。
  2. 只乘rgb不乘alpha(gl_FragColor.rgb *= light),否则透明通道也会被"照亮",混合结果会跑偏。

4.3 查找并设置 Uniform

// 查找 uniform var matrixLocation = gl.getUniformLocation(program, "u_matrix"); +var colorLocation = gl.getUniformLocation(program, "u_color"); +var reverseLightDirectionLocation = + gl.getUniformLocation(program, "u_reverseLightDirection");
// 设置矩阵 gl.uniformMatrix4fv(matrixLocation, false, worldViewProjectionMatrix); +// 设置要用的颜色 +gl.uniform4fv(colorLocation, [0.2, 1, 0.2, 1]); // 绿色 + +// 设置光的方向 +gl.uniform3fv(reverseLightDirectionLocation, m4.normalize([0.5, 0.7, 1]));

关于u_reverseLightDirection的语义值得展开(这是本教程命名里最容易误解的一点):

  • 这里传入的是**"从物体指向光源"的方向**(即光照传播方向的反方向),而不是光线行进方向。之所以这样取,是因为dot(normal, lightDir)在法线正对光源时取最大值 1。
  • 示例中先给[0.5, 0.7, 1]再经m4.normalize归一化。m4.normalize的实现见 m4.js#L200-L210:先求长度,长度大于0.00001时才逐分量除以长度,避免零向量除零。
  • 各分量含义:x = 0.5(正值)表示光源在右侧、指向左方;y = 0.7(正值)表示光源在上方、指向下方;z = 1(正值)表示光源在前方、指向场景内部。相对大小意味着光整体指向场景、且比指向右方更强烈地指向下方。

运行效果对应示例 webgl-3d-lighting-directional.html:页面上有一个滑动条可以绕 y 轴旋转"F"(fRotation,取值 -360 ~ 360 度),着色器与矩阵逻辑位于 该文件的 L22-L62 与 L183-L220。

旋转一下"F",你会发现一个明显的问题:F 在转,但光照纹丝不动——因为法线还停留在模型局部空间,没有被世界变换带动。

5. 让光照跟随旋转:法线乘以世界矩阵

修法是:当物体方向改变时,同时改变法线的方向。和位置一样,法线也可以乘一个矩阵,最直接的矩阵就是world(世界)矩阵。

目前着色器只接收一个矩阵u_matrix(实为 worldViewProjection),需要改成传两个:u_world是世界矩阵;u_worldViewProjection是原来u_matrix里的那一个。

attribute vec4 a_position; attribute vec3 a_normal; *uniform mat4 u_worldViewProjection; +uniform mat4 u_world; varying vec3 v_normal; void main() { // 位置乘以矩阵 * gl_Position = u_worldViewProjection * a_position; * // 设定法线方向并传给片元着色器 * v_normal = mat3(u_world) * a_normal; }

注意这里用mat3(u_world) * a_normal而不是完整 4x4 矩阵:法线是方向,不参与平移,而行列式中与平移相关的部分在 4x4 矩阵的最后一列,方向变换只需要 3x3 的旋转/缩放部分。

JS 侧相应地查找并设置两个矩阵 uniform:

// 查找 uniform *var worldViewProjectionLocation = * gl.getUniformLocation(program, "u_worldViewProjection"); +var worldLocation = gl.getUniformLocation(program, "u_world");
*// 设置矩阵 *gl.uniformMatrix4fv( * worldViewProjectionLocation, false, worldViewProjectionMatrix); *gl.uniformMatrix4fv(worldLocation, false, worldMatrix);

对应示例 webgl-3d-lighting-directional-world.html。该文件中世界矩阵由滑块驱动的绕 y 轴旋转生成(m4.yRotation(fRotationRadians),见 L204-L213),旋转"F"即可看到:现在朝向光的那一面始终最亮,光照正确跟随了物体的旋转。

6. 为什么缩放会破坏法线:逆转置矩阵登场

用u_world变换法线还有一个隐患,仓库提供了交互演示 normals-scaled.html 来展示(点击可切换不同变换方式):

  • 紫色球:不改变大小(基准);
  • 红色球(左):改变了大小,法线用world矩阵乘;
  • 蓝色球(右):改变了大小,法线用worldInverseTranspose(逆转置)矩阵乘。

问题:当 world 矩阵包含缩放时,直接用它变换法线会得到错误的法线——法线不再垂直于缩放后的表面。把缩放调到极端时,对比非常明显:左侧(world)的法线歪离球面,右侧(worldInverseTranspose)的法线始终垂直于球面。最后一个对比模式把三个球都渲染成带光照的样子,可以看到两种矩阵下外部两个球的明暗差异巨大。

解法:取 world 矩阵的逆矩阵,再对它做转置(inverse transpose),用这个矩阵变换法线就正确了。

6.1 着色器改动

attribute vec4 a_position; attribute vec3 a_normal; uniform mat4 u_worldViewProjection; *uniform mat4 u_worldInverseTranspose; varying vec3 v_normal; void main() { // 位置乘以矩阵 gl_Position = u_worldViewProjection * a_position; // 设定法线方向并传给片元着色器 * v_normal = mat3(u_worldInverseTranspose) * a_normal; }

(教程原文特意说明:技术上也可以只更新u_world的值,但把 uniform 改名为真实含义的u_worldInverseTranspose更不容易混淆。)

6.2 JS 侧:求逆、转置、上传

var worldViewProjectionMatrix = m4.multiply(viewProjectionMatrix, worldMatrix); var worldInverseMatrix = m4.inverse(worldMatrix); var worldInverseTransposeMatrix = m4.transpose(worldInverseMatrix); // 设置矩阵 gl.uniformMatrix4fv( worldViewProjectionLocation, false, worldViewProjectionMatrix); -gl.uniformMatrix4fv( - worldLocation, false, worldMatrix); +gl.uniformMatrix4fv( + worldInverseTransposeLocation, false, worldInverseTransposeMatrix);

uniform 查找处同步替换:

-var worldLocation = gl.getUniformLocation(program, "u_world"); +var worldInverseTransposeLocation = + gl.getUniformLocation(program, "u_worldInverseTranspose");

6.3 源码印证:m4 库里的转置与求逆

上面的m4.transpose在仓库矩阵库中的实现(m4.js#L317-L336)与教程中给出的行转列逻辑一致,采用列主序(column-major)存储的 16 元素数组:

function transpose(m, dst) { dst[ 0] = m[0]; dst[ 1] = m[4]; dst[ 2] = m[8]; dst[ 3] = m[12]; dst[ 4] = m[1]; dst[ 5] = m[5]; // ... 依此类推,行列互换 dst[15] = m[15]; return dst; }

对应的inverse实现在 m4.js#L1215。此外,从源码结构看,m4 库还提供了 transformNormal 函数:它内部先用inverse(m)求逆,再只取逆矩阵的 3x3 部分(第 0、4、8、1、5、9、2、6、10 个元素)与法线相乘,注释明确说明"效果等同于用 m 的逆转置把 v 作为方向变换"——这进一步印证了逆转置就是法线变换的标准做法,而且实际只需 3x3 部分。

完整示例见 webgl-3d-lighting-directional-worldinversetranspose.html:着色器中 L36 为v_normal = mat3(u_worldInverseTranspose) * a_normal;,JS 中 L208-L213 依次执行m4.inverse(worldMatrix)→m4.transpose(...)并上传。由于该示例本身没有缩放,画面差异很微妙,但代码已经对缩放做好了准备。

7. 备选写法与工程取舍

教程末尾给出了mat3(u_worldInverseTranspose) * a_normal这一行的替代方案,值得单独梳理:

写法一(4x4 矩阵 + w=0):

v_normal = (u_worldInverseTranspose * vec4(a_normal, 0)).xyz;

作者认为这更通用:因为乘法前把w设为 0,矩阵中的平移分量被 0 乘掉、事实上被移除,所以无需额外构造 3x3 矩阵。

为什么教程仍选择 mat4 而非直接把u_worldInverseTranspose声明为mat3,作者给了两条理由:

  1. u_worldInverseTranspose(作为 4x4 整体)可能被其他需求复用,传完整mat4保留了这种灵活性;
  2. JavaScript 侧的 m4 库所有矩阵函数都产出 4x4 矩阵,专门为 3x3 再维护一套函数、或加一层 4x4→3x3 的转换,在没有更充分理由时不值得。

8. 小结与下一步

把整条链路串起来,方向性光照的完整实现包含五步:

  1. 数据:为每个顶点提供与面垂直的单位法线(setNormals);
  2. 上传:法线缓冲区 +gl.vertexAttribPointer(gl.FLOAT、normalize=false);
  3. 顶点着色器:v_normal传递法线,并乘mat3(u_worldInverseTranspose)进入世界空间;
  4. 片元着色器:normalize(v_normal)抵消插值畸变,dot(normal, u_reverseLightDirection)得光强,gl_FragColor.rgb *= light;
  5. JS 侧:m4.inverse+m4.transpose计算并上传逆转置矩阵。

两个常见错误——不乘世界矩阵导致"光照不随物体旋转"、乘了世界矩阵但在缩放时法线失真——分别对应本文第 5、6 节的两处修正。掌握这套基础后,下一篇教程点光源(Point Lighting)会在此基础上引入"光源位置"概念与距离衰减;教程的起点可回顾 WebGL 基础 与 WebGL 3D 相机。

  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进