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

发布时间:2026/10/13 22:52:27
WebGL 3D 方向性光照:从点积、法线到逆转置矩阵的完整实战 图形学【免费下载链接】webgl-fundamentalsWebGL 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_BUFFERARRAY_BUFFER colorBuffer -gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); -// 把几何数据写入缓冲区 -setColors(gl); // 创建存放法线的缓冲区 var normalBuffer gl.createBuffer(); // 绑定到 ARRAY_BUFFERARRAY_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; }两个关键细节normalize(v_normal)不可省略。varying变量在三角形内部被逐像素插值插值结果一般不再是单位向量而dot()得到夹角余弦的前提是两个单位向量。只乘rgb不乘alphagl_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 轴旋转FfRotation取值 -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 矩阵 w0v_normal (u_worldInverseTranspose * vec4(a_normal, 0)).xyz;作者认为这更通用因为乘法前把w设为 0矩阵中的平移分量被 0 乘掉、事实上被移除所以无需额外构造 3x3 矩阵。为什么教程仍选择 mat4 而非直接把u_worldInverseTranspose声明为mat3作者给了两条理由u_worldInverseTranspose作为 4x4 整体可能被其他需求复用传完整mat4保留了这种灵活性JavaScript 侧的 m4 库所有矩阵函数都产出 4x4 矩阵专门为 3x3 再维护一套函数、或加一层 4x4→3x3 的转换在没有更充分理由时不值得。8. 小结与下一步把整条链路串起来方向性光照的完整实现包含五步数据为每个顶点提供与面垂直的单位法线setNormals上传法线缓冲区 gl.vertexAttribPointergl.FLOAT、normalizefalse顶点着色器v_normal传递法线并乘mat3(u_worldInverseTranspose)进入世界空间片元着色器normalize(v_normal)抵消插值畸变dot(normal, u_reverseLightDirection)得光强gl_FragColor.rgb * lightJS 侧m4.inversem4.transpose计算并上传逆转置矩阵。两个常见错误——不乘世界矩阵导致光照不随物体旋转、乘了世界矩阵但在缩放时法线失真——分别对应本文第 5、6 节的两处修正。掌握这套基础后下一篇教程点光源Point Lighting会在此基础上引入光源位置概念与距离衰减教程的起点可回顾 WebGL 基础 与 WebGL 3D 相机。赞分享图形学【免费下载链接】webgl-fundamentalsWebGL lessons that start with the basics项目地址https://gitcode.com/gh_mirrors/we/webgl-fundamentals点击查看免费下载相关推荐WebGL 方向光照明实战从点积公式到法线逆转置矩阵的完整实现WebGL 方向光照明实战从点积公式到法线逆转置矩阵的完整实现 本篇技术指南基于 webgl fundamentals 教程中“方向光照明Direction图形学WebGL 3D 指向性光源Directional Lighting完全指南从点积原理到法线矩阵变换WebGL 3D 指向性光源Directional Lighting完全指南从点积原理到法线矩阵变换 本篇是 webgl fundamentals htt图形学WebGL 三维相机逆矩阵视图变换与 lookAt 朝向矩阵实战WebGL 三维相机逆矩阵视图变换与 lookAt 朝向矩阵实战 本篇是 webgl fundamentals 系列教程中讲解三维相机的一课。前文 三维透视投图形学创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考