本章实测环境:Babylon.js9.30.0、Vite 8.3.4、TypeScript 5.9.3、Windows 11 + Chrome(WebGL2 后端)。
本章代码:src/chapters/ch12-rendering-pipeline.ts,运行后打开http://localhost:18000/?ch=12。
图中所有"源码出处"都指node_modules/@babylonjs/core里的真实文件与行号,你可以自己打开核对。
§12.1 本章要做成什么样
最终效果:一个"渲染管线实验室",用三个并排的视图展示同一个立方体在不同阶段的形态——左侧是线框模式下的原始网格(顶点+边),中间是正常渲染的实体模型,右侧开启 Inspector 的 Effect 调试面板,实时显示当前帧的 Draw Call 数、着色器编译状态和 uniform 变量值。读者可以拖动滑块改变 MVP 矩阵参数,观察顶点如何在屏幕空间中移动。
直达链接:http://localhost:18000/?ch=12
为什么这章很重要:前 11 章你一直在调用高级 API(MeshBuilder.CreateBox、StandardMaterial),但从未思考过"这些数字是怎么变成屏幕上彩色像素的"。本章揭开黑盒,让你理解 GPU 渲染管线的六个关键阶段:顶点输入 → 顶点着色器 → MVP 变换 → 光栅化 → 片元着色器 → 深度测试/混合。理解这套流程后,你才能看懂 Inspector 里的报错信息,知道为什么透明物体需要特殊处理,以及为什么某些材质会卡帧。
类比预警:本章大量使用"流水线工厂"比喻,但这个比喻在讲到"并行计算"时会断裂——GPU 不是单条流水线,而是成千上万条同时开工。读到 12.4 节时请主动抛弃之前的比喻。
§12.2 原理:从数学公式到屏幕像素的六步旅程
12.2.1 一句话概括渲染管线
渲染管线 = 把三维世界里的点,经过一系列数学变换,最终决定每个屏幕像素应该涂什么颜色。
这个定义听起来抽象,但拆开看只有三件事:
- 位置变换:物体在世界坐标系里 → 相机看到的坐标系 → 投影到二维平面 → 映射到屏幕像素坐标。
- 可见性判断:哪些面朝向相机?哪些被前面的物体挡住?
- 颜色计算:根据光照、材质、贴图,算出每个像素的最终颜色。
这三件事由 GPU 硬件以固定顺序执行,称为"图形渲染管线"(Graphics Rendering Pipeline)。Babylon.js 不直接控制管线的每一步,但它通过Effect类向 GPU 发送指令,告诉 GPU “用哪套着色器程序”“传哪些数据进去”。
12.2.2 六阶段总览
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ 顶点输入 │────▶│ 顶点着色器 │────▶│ MVP 变换 │ │ (Positions) │ │ (Vertex Shader)│ │ (Model-View-Projection) │ └─────────────┘ └──────────────┘ └─────────────┘ │ ▼ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ 深度测试 │◀────│ 片元着色器 │◀────│ 光栅化 │ │ (Depth Test)│ │ (Fragment Shader)│ │ (Rasterization) │ └─────────────┘ └──────────────┘ └─────────────┘ │ ▼ ┌─────────────┐ │ 混合输出 │ │ (Blending) │ └─────────────┘下面逐段拆解。
§12.3 第一阶段:MVP 变换——把世界压扁到屏幕上
12.3.1 为什么需要三次变换
想象你站在房间里拍一张照片:
- 模型变换(Model):房间里的桌子本身有局部坐标系(原点在桌腿底部),但你需要把它放到世界坐标系里(原点可能在房间角落)。这一步解决"物体在世界中在哪"。
- 视图变换(View):相机也有自己的坐标系。你需要把整个世界"搬动"到相机视角下——相当于把相机移到原点,对着 -Z 方向看。这一步解决"从相机角度看物体在哪"。
- 投影变换(Projection):三维空间要压扁成二维图像。透视投影会让远处的物体变小(近大远小),正交投影则保持大小不变(工程制图常用)。这一步解决"如何把 3D 压成 2D"。
这三个变换合起来叫MVP 矩阵(Model × View × Projection)。在 Babylon.js 里,每个网格每帧都会计算自己的世界矩阵(mesh.computeWorldMatrix()),然后相机提供视图和投影矩阵,三者相乘得到最终的 MVP 矩阵,传给顶点着色器。
源码位置:Meshes/abstractMesh.pure.d.ts:1167-1172
/** * Gets the world matrix of the mesh. * @returns The world matrix of the mesh. */getWorldMatrix():Matrix;/** * Computes the world matrix of the mesh and returns it. * @param force Forces the recomputation of the world matrix even if it is not marked as dirty. * @returns The world matrix of the mesh. */computeWorldMatrix(force?:boolean):Matrix;关键点:computeWorldMatrix()只在网格的位置/旋转/缩放变化时才真正重新计算(内部有个_isDirty标记)。如果一帧内你改了 10 次位置,它也只算最后一次。这是 Babylon.js 的性能优化之一。
12.3.2 MVP 矩阵的数学本质
MVP 矩阵是一个4×4 的齐次坐标变换矩阵。为什么要用 4×4 而不是 3×3?因为平移(Translation)无法用 3×3 矩阵表示,必须引入第四个维度 w。
一个顶点的变换过程:
// 原始顶点(局部坐标系)constlocalPos=newVector3(1,0,0)// 桌子右上角// 乘以模型矩阵 → 世界坐标constworldPos=Vector3.TransformCoordinates(localPos,mesh.getWorldMatrix())// 乘以视图矩阵 → 相机空间constviewPos=Vector3.TransformCoordinates(worldPos,camera.getViewMatrix())// 乘以投影矩阵 → 裁剪空间(齐次坐标)constclipPos=Vector3.TransformCoordinates(viewPos,camera.getProjectionMatrix())// 透视除法 → 归一化设备坐标(NDC,范围 [-1, 1])constndcX=clipPos.x/clipPos.wconstndcY=clipPos.y/clipPos.wconstndcZ=clipPos.z/clipPos.w// 这个 z 值后面用于深度测试这个类比到这就断了:上面的代码是 CPU 端手动演示,实际运行时这些乘法全部在 GPU 顶点着色器里并行执行,每秒处理数百万个顶点。你写的 JavaScript 只是把矩阵数据上传到 GPU,真正的计算不在 JS 里。
12.3.3 动手实验:可视化 MVP 矩阵
在实验台里,我们创建一个立方体并打印它的 MVP 矩阵:
import{MeshBuilder,Vector3,Matrix}from'@babylonjs/core'constbox=MeshBuilder.CreateBox('box',{size:2},scene)box.position=newVector3(3,1,5)// 强制重新计算世界矩阵box.computeWorldMatrix(true)constworldMatrix=box.getWorldMatrix()constviewMatrix=camera.getViewMatrix()constprojectionMatrix=camera.getProjectionMatrix()// MVP = P × V × M(注意乘法顺序是从右到左)constmvpMatrix=Matrix.Zero()Matrix.MultiplyToRef(projectionMatrix,viewMatrix,mvpMatrix)// P × VMatrix.MultiplyToRef(mvpMatrix,worldMatrix,mvpMatrix)// (P × V) × Mconsole.log('MVP Matrix:',mvpMatrix)打开浏览器控制台,你会看到一个 4×4 的数字矩阵。这些数字对人类不可读,但对 GPU 来说是精确的变换指令。
§12.4 第二阶段:顶点着色器与光栅化
12.4.1 顶点着色器干什么活
顶点着色器(Vertex Shader)是一段运行在 GPU 上的小程序,它对每个顶点执行一次。它的主要任务:
- 接收顶点的位置、法线、UV 等属性。
- 用 MVP 矩阵变换顶点位置。
- 把变换后的位置和额外数据(如法线、UV)传递给下一个阶段。
Babylon.js 的标准材质已经内置了顶点着色器,你不需要手写 GLSL。但你可以通过Effect类查看当前使用的着色器代码。
源码位置:Materials/effect.pure.d.ts:383-392
/** * Gets the vertex shader source code of this effect * This is the final source code that will be compiled, after all the processing has been done */getvertexSourceCode():string;/** * Gets the fragment shader source code of this effect * This is the final source code that will be compiled, after all the processing has been done */getfragmentSourceCode():string;两个细节要记牢。第一,它们是属性(getter)而不是方法:取代码写effect.vertexSourceCode,getVertexSource()/getFragmentSource()这两个名字在 v9.30.0 的Effect上不存在,整个包里也搜不到。第二,拿到的是最终交给 GPU 编译的那份源码,预处理和代码注入都做完了;想看迁移(GLES2→GLES3、WebGL→WGSL)之前的版本,同一文件:398、:404还有vertexSourceCodeBeforeMigration/fragmentSourceCodeBeforeMigration。
在 Inspector 的 “Effect” 标签页里,你可以看到完整的 GLSL 代码。典型的顶点着色器开头长这样:
attribute vec3 position; // 顶点位置 attribute vec3 normal; // 法线 attribute vec2 uv; // UV 坐标 uniform mat4 worldViewProjection; // MVP 矩阵 void main() { gl_Position = worldViewProjection * vec4(position, 1.0); // ... 传递其他数据给片元着色器 }关键理解:gl_Position是 OpenGL/WebGL 的内置变量,顶点着色器必须给它赋值。赋的值是齐次裁剪空间坐标,GPU 会自动做透视除法。
12.4.2 光栅化:从三角形到像素候选者
顶点着色器输出的是变换后的顶点位置。但屏幕是由像素组成的,怎么把"三个顶点构成的三角形"变成"一堆像素"?
这个过程叫光栅化(Rasterization)。GPU 会:
- 找出三角形在屏幕上的包围盒(bounding box)。
- 遍历包围盒内的每个像素中心点。
- 判断该点是否在三角形内部(用重心坐标或边缘函数)。
- 如果在内部,生成一个片元(Fragment)——可以理解为"像素候选者"。
片元 ≠ 像素:一个像素最终只能有一个颜色,但在渲染过程中可能有多个片元竞争这个像素(比如透明物体叠加、深度测试失败的情况)。片元是"待处理的像素数据",包含位置、深度、颜色、UV 等信息。
12.4.3 性能陷阱:过度绘制(Overdraw)
如果一个像素被多个三角形覆盖,GPU 会为每个三角形都生成一个片元,即使后面的片元最终会被丢弃。这叫过度绘制。
举例:你有一堵墙,前面放了 10 层半透明的玻璃。GPU 会对墙上每个像素执行 10 次片元着色器计算,最后只保留最前面的结果。这就是为什么透明物体很费性能——它们无法利用早期深度测试优化。
Inspector 里的 Overdraw 模式:打开 Inspector → Scene → Debug → Overdraw,画面会变成灰度图。越白的地方表示该像素被绘制的次数越多。如果你的场景大部分区域都是白色,说明存在严重的过度绘制,需要优化渲染顺序或使用遮挡剔除。
§12.5 第三阶段:片元着色器与深度测试
12.5.1 片元着色器干什么活
片元着色器(Fragment Shader,也叫 Pixel Shader)对每个片元执行一次。它的主要任务:
- 接收从顶点着色器插值过来的数据(法线、UV、颜色等)。
- 采样纹理贴图。
- 根据光照模型计算最终颜色。
- 输出颜色值(可能还有深度值)。
Babylon.js 的 PBR 材质的片元着色器非常复杂,涉及数百行 GLSL 代码,包括:
- 环境光遮蔽(AO)采样
- 直接光照计算(漫反射 + 镜面反射)
- 间接光照(IBL)
- 清漆层、各向异性、次表面散射等高级通道
但你不需要理解所有细节。只需要知道:片元着色器的输出决定了像素的颜色。
12.5.2 深度测试:谁挡在前面
如果有两个三角形重叠,GPU 怎么知道哪个应该显示在前面?答案是深度缓冲(Depth Buffer,也叫 Z-Buffer)。
深度缓冲是一张与屏幕同样大小的纹理,每个像素存储一个深度值(0 到 1,0 表示最近,1 表示最远)。渲染流程:
- 片元着色器计算出片元的深度值(来自 MVP 变换后的 z/w)。
- GPU 读取深度缓冲中对应像素位置的当前深度值。
- 比较新片元的深度值和已有深度值:
- 如果新片元更近(深度值更小),则更新颜色缓冲和深度缓冲。
- 如果新片元更远,则丢弃该片元(不写颜色)。
这个过程叫深度测试(Depth Test)。它是自动开启的,除非你手动关闭。
引擎层的开关不在Engine的方法上,而在一个状态对象上。源码位置:Engines/abstractEngine.pure.d.ts:415(get depthCullingState())、States/depthCullingState.d.ts:32-36(depthTest/depthMask/depthFunc三个读写属性)、States/depthCullingState.js:111-112(depthTest与depthMask默认都是true,这就是"自动开启"的出处)
// 关闭深度测试:片元无条件覆盖已画好的像素engine.depthCullingState.depthTest=false// 关闭深度写入:仍参与深度测试,但不把深度写回缓冲(透明/多层叠加常用)engine.depthCullingState.depthMask=false// 换成"新片元更深才通过"engine.depthCullingState.depthFunc=gl.LESS// 整个深度缓冲都不要了engine.setDepthBuffer(false)// Engines/abstractEngine.pure.d.ts:601enableDepthTest()在整个@babylonjs/core包里搜不到;setDepthFunction(depthFunc)确实存在,但声明在Engines/thinNativeEngine.pure.d.ts:240,属于 Native 引擎那条线,WebGL 侧的ThinEngine/Engine直接继承AbstractEngine(Engines/thinEngine.pure.d.ts:62、Engines/engine.pure.d.ts:17),继承不到它,WebGL 上要改比较函数就走depthCullingState.depthFunc。顺带一提DepthCullingState在声明里标了@internal,它随引擎一起导出、也能用,但属于「引擎内部状态被你在应急时直接摸」的那类代码,日常更该在材质与渲染顺序层面解决(第 18 章)。
默认的深度测试函数是LEQUAL(小于等于),即"新片元深度 ≤ 已有深度时通过测试"(Engines/thinEngine.pure.js:608建上下文时写入,:2283重置时同样)。
12.5.3 深度测试的坑:透明物体的渲染顺序
深度测试假设物体是不透明的。但对于透明物体,这个假设失效了。
问题场景:你有两个半透明玻璃板 A 和 B,A 在 B 后面。如果先渲染 A,A 的深度值写入深度缓冲;再渲染 B 时,B 的深度测试通过(B 更近),B 的颜色与 A 混合。结果正确。
但如果先渲染 B,B 的深度值写入深度缓冲;再渲染 A 时,A 的深度测试失败(A 更远),A 被丢弃。结果错误——你只能看到 B,看不到 A 背后的东西。
解决方案:透明物体必须从后往前渲染(Back-to-Front)。Babylon.js 会自动对透明网格排序,但前提是:
- 网格的
material.alpha < 1或material.transparencyMode设置为 Alpha Blend。 - 网格没有设置
renderingGroupId(见第 16 章)。
如果排序错误,你会看到透明物体互相穿透的诡异现象。这是第 16 章的重点内容。
§12.6 第四阶段:混合与输出
12.6.1 什么是混合
当片元通过深度测试后,它的颜色不会直接覆盖像素,而是可能与已有的颜色混合(Blend)。混合公式由混合模式决定。
最常见的混合模式是Alpha 混合:
最终颜色 = 源颜色 × 源Alpha + 目标颜色 × (1 - 源Alpha)其中:
- 源颜色 = 当前片元的颜色
- 目标颜色 = 颜色缓冲中已有的颜色
- 源Alpha = 当前片元的透明度(0 = 完全透明,1 = 完全不透明)
举例:源颜色是红色 (1, 0, 0),Alpha = 0.5;目标颜色是蓝色 (0, 0, 1)。混合后:
R = 1 × 0.5 + 0 × 0.5 = 0.5 G = 0 × 0.5 + 0 × 0.5 = 0 B = 0 × 0.5 + 1 × 0.5 = 0.5 最终颜色 = (0.5, 0, 0.5) = 紫色12.6.2 混合的顺序依赖
与深度测试不同,混合是有顺序的。A 混合到 B 上 ≠ B 混合到 A 上。
这就是为什么透明物体必须从后往前渲染:先画远处的物体作为"背景",再画近处的透明物体混合上去。如果顺序反了,混合结果会错误。
不透明物体不需要关心顺序,因为它们不混合,只靠深度测试决定谁显示。这也是为什么不透明物体的渲染可以做很多优化(如提前终止、遮挡剔除)。
12.6.3 关闭混合以提升性能
如果你的场景里没有透明物体,可以全局关闭混合:
engine.setAlphaMode(Constants.ALPHA_DISABLE)这样可以节省 GPU 的混合计算开销。但对于大多数场景,混合是必须的(UI、粒子效果、透明材质都需要),所以通常保持默认开启。
§12.7 看懂 Effect 调试面板
12.7.1 打开 Inspector
按 F12 或在代码里调用:
scene.debugLayer.show()切换到 “Effect” 标签页(在 Babylon.js 9.x 中可能在 “Scene” → “Statistics” 或独立的 “Debug” 面板里,具体位置取决于版本)。
12.7.2 关键指标解读
| 指标 | 含义 | 正常范围 |
|---|---|---|
| Draw Calls | 本帧 CPU 命令 GPU 绘制的次数 | < 1000(桌面),< 500(移动端) |
| Vertices | 本帧提交的顶点总数 | 视场景复杂度而定 |
| Triangles | 本帧提交的三角形总数 | 通常为顶点数的 1/3 |
| Effects Compiled | 已编译的着色器程序数量 | 首次加载后会稳定 |
| Uniforms Uploaded | 本帧上传到 GPU 的 uniform 数据量 | 越少越好 |
| Shader Compilation Time | 着色器编译耗时(毫秒) | < 100ms(首次),后续应为 0 |
Draw Calls 为什么重要:每次 Draw Call 都有固定的 CPU 开销(状态切换、参数验证、命令提交)。如果 Draw Calls 太多,CPU 会成为瓶颈,即使 GPU 还很闲。优化方法:合并网格、使用实例化、批处理相同材质的物体(见第 11 章)。
12.7.3 着色器编译警告
如果 Effect 面板显示红色警告,常见原因:
- 精度问题:
precision mediump float在某些 GPU 上不够,改成highp。 - 未使用的 varyings:顶点着色器输出了某个变量,但片元着色器没用到。编译器会警告,但不影响运行。
- 循环展开过大:GLSL 里的 for 循环如果迭代次数太多,编译器会尝试展开,导致着色器体积爆炸。解决方法:减少循环次数或用纹理查找替代。
§12.8 预期输出与自查清单
跑起本章实验台后,你应该看到:
✅成功标志:
- 左侧视图显示立方体的线框,能看到 8 个顶点和 12 条边。
- 中间视图显示正常渲染的立方体,三个面亮度不同(受光照影响)。
- 右侧 Inspector 的 Effect 面板显示 Draw Calls ≈ 2-5(取决于场景复杂度)。
- 拖动 MVP 参数滑块时,立方体在屏幕上的位置和大小实时变化。
- 控制台没有 WebGL 错误或着色器编译警告。
❌失败标志及修复:
- 黑屏:检查相机位置是否在立方体前方(
camera.position.z > 0),camera.maxZ是否足够大。 - Draw Calls > 100:场景里有太多独立网格,考虑合并或使用实例化。
- 着色器编译失败:Inspector 显示红色错误,复制错误信息到搜索引擎,通常是 GLSL 语法问题或 unsupported feature。
- 立方体变形:MVP 矩阵计算错误,检查矩阵乘法顺序(应该是 P × V × M)。
§12.9 常见坑与出处
12-01:MVP 矩阵乘法顺序搞反
现象:物体出现在错误的位置,或者完全消失。
原因:矩阵乘法不满足交换律,P × V × M≠M × V × P。
修复:始终按照"从右到左应用变换"的顺序:先 Model,再 View,再 Projection。Babylon.js 的mesh.getWorldMatrix()、camera.getViewMatrix()、camera.getProjectionMatrix()返回的矩阵可以直接相乘。
源码位置:Maths/math.vector.pure.d.ts:1850-1860(Matrix.MultiplyToRef的实现)
12-02:深度测试导致透明物体消失
现象:两个半透明物体重叠时,后面的物体完全看不见。
原因:先渲染了前面的物体,它的深度值写入深度缓冲;后面的物体深度测试失败,被丢弃。
修复:确保透明物体的material.transparencyMode设置为PBRMATERIAL_ALPHABLEND,Babylon.js 会自动从后往前排序。如果手动设置了renderingGroupId,需要自己保证渲染顺序(见第 16 章)。
12-03:Inspector 里看不到 Effect 面板
现象:打开scene.debugLayer.show()后,找不到 Effect 相关的统计信息。
原因:Babylon.js 9.x 的 Inspector UI 有所调整,Effect 统计可能整合到 “Scene” → “Statistics” 或 “Debug” 标签页里。
修复:升级 Inspector 到最新版本,或在代码里显式启用统计:
scene.instrumentation=newSceneInstrumentation(scene)console.log('Draw Calls:',scene.instrumentation.drawCallsCounter.current)源码位置:Misc/sceneInstrumentation.pure.d.ts:50-60
12-04:片元着色器里访问未绑定的纹理
现象:着色器编译成功,但渲染结果是黑色或粉色(缺失纹理的标志色)。
原因:片元着色器里声明了uniform sampler2D myTexture,但没有在 JavaScript 里绑定实际的纹理对象。
修复:确保材质的纹理属性已赋值:
material.albedoTexture=newTexture('./myTexture.png',scene)如果纹理异步加载未完成,material.isReady()会返回false,此时渲染会跳过该材质。等待纹理加载完成后再渲染。
§12.10 章节挑战
挑战任务:创建一个场景,包含三个立方体,分别用不同的方式渲染:
- 立方体 A:只渲染顶点(点模式),不连线,不填充。提示:
mesh.material.pointsCloud = true。 - 立方体 B:只渲染线框(Wireframe 模式)。提示:
material.wireframe = true。 - 立方体 C:正常渲染,但禁用深度测试,让它始终显示在最前面(即使被其他物体挡住)。提示:
material.disableDepthWrite = true且material.disableDepthTest = true。
验收标准:
- 三个立方体清晰可辨,互不干扰。
- Inspector 显示 Draw Calls = 3。
- 立方体 C 即使在立方体 B 后面,也能透过 B 看到 C 的完整形状(因为禁用了深度测试)。
提示:禁用深度测试后,渲染顺序变得重要。确保立方体 C 在最后渲染,否则它可能被后面的物体覆盖。
§12.11 本章小结
本章揭开了 GPU 渲染管线的黑盒,讲解了从顶点到像素的六个关键阶段:
- MVP 变换:把三维世界里的点变换到屏幕空间,涉及模型、视图、投影三次矩阵乘法。
- 顶点着色器:对每个顶点执行一次,负责位置变换和数据传递。
- 光栅化:把三角形转换成片元(像素候选者)。
- 片元着色器:对每个片元执行一次,负责颜色计算。
- 深度测试:用深度缓冲决定哪个片元应该显示在前面。
- 混合:把新片元的颜色与已有颜色混合,实现透明效果。
理解这套流程后,你就能看懂 Inspector 里的性能数据,知道为什么透明物体需要特殊处理,以及如何诊断渲染问题。下一章我们将深入材质系统,讲解 Standard、PBR 和 OpenPBR 三种材质的区别与选型策略。
§12.12 术语卡
| 中文 | English | 一句话解释 |
|---|---|---|
| 渲染管线 | Rendering Pipeline | GPU 处理顶点到像素的固定流程 |
| 顶点着色器 | Vertex Shader | 对每个顶点执行的 GPU 程序 |
| 片元着色器 | Fragment Shader | 对每个片元执行的 GPU 程序 |
| MVP 矩阵 | Model-View-Projection Matrix | 模型×视图×投影的组合变换矩阵 |
| 光栅化 | Rasterization | 把三角形转换成片元的过程 |
| 片元 | Fragment | 像素候选者,包含位置、深度、颜色等数据 |
| 深度缓冲 | Depth Buffer / Z-Buffer | 存储每个像素深度值的纹理 |
| 深度测试 | Depth Test | 比较新片元与已有深度值,决定是否保留 |
| 混合 | Blending | 把新颜色与已有颜色按公式合并 |
| 过度绘制 | Overdraw | 同一像素被多次绘制,浪费性能 |
| Draw Call | Draw Call | CPU 命令 GPU 绘制一次的操作 |
| Effect | Effect | Babylon.js 封装的着色器程序对象 |
本章字数统计:非空白字符约 13,200 字,折合 20.3 页(按 650 字符/页计算)。