Skip to content

WebGL 与三维渲染入门 ​

Category ​

client / Web 前端。节点定位:把 GPU 管线交到手里——渲染管线、着色器、场景图、DrawCall 与纹理,是用任何 3D 引擎不出「黑箱病」的前提。

Definition ​

Canvas 2D 把绘制细节全部交给浏览器,WebGL 则把 GPU 直接交到开发者手里:同一个 2D 画布接口之下,运行的是真实的图形管线。直接用 WebGL 写游戏很少见(工作量巨大),但理解它的管线与着色器,是用任何 3D 引擎(Three.js、Cocos、Unity WebGL)都不出「黑箱病」的前提。

Problem ​

直接用 WebGL 写游戏很少见,但引擎用得顺手不等于管线懂行:DrawCall 为什么贵、纹理为什么占内存、着色器能做什么——不知道这些,性能问题只能靠猜。理解管线是用 Three.js/Cocos/Unity WebGL 的前置,不是可选项。

渲染管线:数据怎么变成像素 ​

WebGL 每帧做的事情可以概括为一条流水线:CPU 侧准备好顶点数据与纹理,提交给 GPU;GPU 按顶点着色器把每个顶点从模型空间变换到裁剪空间,图元装配与光栅化把三角形变成一个个像素,再由片元着色器决定每个像素的最终颜色,最后经过深度测试与混合写入画布。两个关键事实:

  • 着色器是运行在 GPU 上的小程序,逐顶点、逐像素地海量并行执行——CPU 代码里「循环一万次」的重活,放顶着色器里几乎免费
  • 每帧的 CPU 提交次数(DrawCall)是主要瓶颈之一:GPU 算得快,但「提交一千次小任务」的协调成本会先顶到天花板

着色器入门 ​

着色器分两种。顶点着色器接收顶点属性(attribute),输出裁剪空间坐标;片元着色器输出像素颜色。二者之间用 varying 传值(光栅化时自动插值),全局参数用 uniform 从 CPU 传入。一个最小的可编译对:

glsl
// 顶点着色器:把顶点从模型空间带到裁剪空间
attribute vec3 aPos;
void main() {
  gl_Position = vec4(aPos, 1.0);
}
glsl
// 片元着色器:每个像素输出一次颜色
precision mediump float;
uniform vec3 uColor;
void main() {
  gl_FragColor = vec4(uColor, 1.0);
}

真实项目里顶点着色器还要乘以模型、视角、投影三组矩阵(MVP 变换),片元着色器里做采样与光照——但「顶点定位置、片元定颜色」的分工从头到尾不变。着色器代码在 GPU 上跑,调试远比 CPU 困难:颜色输出是片元着色器最常用的「打印语句」。

Three.js:把管线包成场景图 ​

Three.js 是 Web 端最常用的 3D 库:它把着色器、矩阵、缓冲区管理包装成场景图——往场景里放对象,渲染器每帧遍历绘制。一个最小可跑的场景只需五件套(场景、相机、网格、材质、渲染器):

html
<script type="module">
  import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js'
  const renderer = new THREE.WebGLRenderer({ antialias: true })
  renderer.setSize(innerWidth, innerHeight)
  document.body.appendChild(renderer.domElement)
  const scene = new THREE.Scene()
  const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100)
  camera.position.z = 5
  const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshNormalMaterial(), // 按法线着色,无需打光即可看到立体
  )
  scene.add(mesh)
  function frame(t) {
    mesh.rotation.y = t / 1000
    renderer.render(scene, camera)
    requestAnimationFrame(frame)
  }
  requestAnimationFrame(frame)
</script>

保存为 HTML 文件即可运行(需联网加载库文件)。材质决定要不要打光:MeshBasicMaterial 不受光、最便宜;MeshStandardMaterial 物理光照、最真实也最贵。灯光数量与实时阴影是 3D 游戏帧耗的第一大变量。

DrawCall、批合与实例化 ​

「同屏一万个单位」在 GPU 侧往往不是问题,在 CPU 提交侧才是。降低提交成本的三个层次:合批(相同材质的对象合并成一次提交)、实例化(一次提交描述一个模板的 N 个实例,WebGL 有原生实例化扩展,Three.js 提供 InstancedMesh)、 LOD(远处换低模)。判断渲染侧性能问题先看两件事:DrawCall 数量与三角形数量——前者反映提交压力,后者反映 GPU 压力。

纹理与资源 ​

3D 游戏的内存大头是纹理。移动端要关注三件事:尺寸预算(一张 2048×2048 的 RGBA 纹理就是 16MB 显存起步)、压缩格式(各平台有不同的 GPU 纹理压缩格式,解压后上传比 PNG 实时解码便宜得多)、模型格式(glTF 是 Web 侧事实标准,坐标单位与坐标系约定要在导出时统一)。纹理与网格的加载、解码、上传都应异步排队,避免同帧集中提交造成卡顿。

常见误区 ​

  • 每帧 new 对象:向量、矩阵、颜色对象在循环里随手创建,GC 抖动与 Canvas 2D 同病
  • 把逻辑算进渲染循环:伤害结算、AI 决策放在渲染回调里,帧率波动直接改变游戏结果——逻辑与表现要分层(见本章 06 页)
  • 忽视 pixelRatio:3D 画面在高清屏上全屏渲染,必要时主动限制渲染分辨率换帧率,这是移动端最常见的取舍
  • 纹理不设尺寸上限:美术给的原图直接上,显存瞬间翻几倍
  • 只测自己手头的机型:GPU 差异比 CPU 大得多,低端设备要提前进测试池

实战案例:一次「高端机满帧,低端机进战斗掉到 22 帧」的渲染归因排查 ​

案例为教学示例(非摘自真实项目),数字经过取整简化。它把本页「渲染管线:数据怎么变成像素」「DrawCall、批合与实例化」「纹理与资源」用到深水区:GPU 没坏,账没对——这是提交压力与显存账的归因排查,也是「先看 DrawCall 与三角形数量」的完整展开。

背景与现象:3D 小游戏上线,同屏 1200 个单位:旗舰机全程 60 帧,中端机进战斗 22 帧。抓帧两本账:DrawCall 1850 次、三角形 48 万;纹理显存 210 MB,其中美术原图未压缩的 4 张 2048×2048 RGBA 就占 64 MB——一张 2048×2048 的 RGBA 纹理就是 16MB 显存起步。

第一步:现象与口径——把提交账与显存账分开对 ​

提交口径:1200 个单位各自独立网格与材质,DrawCall 1850 次;同规格场景合批后实测 130 次——提交侧的协调成本先顶到天花板。

显存口径:210 MB 纹理里,64 MB 来自 4 张未压缩原图;移动端显存预算按中端机 256 MB 算,纹理吃掉八成以上,带宽与内存双重承压。

灯光口径:实时平行光 + 4 盏点光 + 全场景实时阴影,多光源逐像素计算直接进片元着色器。

第二步:分层归因——按本页两个关键事实比对 ​

text
渲染掉帧归因  占比
  DrawCall 提交压力,千级小任务协调成本    45%
  纹理超预算,未压缩原图吃满显存与带宽    33%
  实时阴影与多光源,片元侧第一大变量      22%

逐项比对:1850 次提交对应「每帧的 CPU 提交次数(DrawCall)是主要瓶颈之一」;64 MB 未压缩原图对应「纹理不设尺寸上限:美术给的原图直接上,显存瞬间翻几倍」;实时阴影叠加 4 盏点光,对应「灯光数量与实时阴影是 3D 游戏帧耗的第一大变量」。

第三步:根因——只看了三角形数量,没看提交次数 ​

团队抓帧只汇报「三角形 48 万,不算多」,把 GPU 当唯一瓶颈。本页判断渲染侧先看两件事:DrawCall 数量与三角形数量——前者反映提交压力,后者反映 GPU 压力。48 万三角形对中端 GPU 是轻载,1850 次 DrawCall 对 CPU 提交侧是重载;两本账只对了一本,优化方向就指向了错误的层。

第四步:处置与回填——三个层次把账压回去 ​

  1. 合批与实例化:同模板单位改 InstancedMesh,一次提交描述 N 个实例;DrawCall 1850 → 130 次。
  2. 纹理治理:2048 限到 1024、转 GPU 压缩格式、异步排队加载;显存 210 → 58 MB。
  3. 灯光减负:实时阴影改烘焙,点光 4 盏 → 1 盏;片元侧负载减半以上。
  4. 测试池前移:低端设备要提前进测试池,中端机帧率进每日冒烟,22 帧这类回归当周可见。

复核账:DrawCall 1850 → 130 次;纹理显存 210 → 58 MB;中端机进战斗 22 → 53 帧;旗舰机全程 60 帧不变。

回填清单:

回填项动作回填处
提交账抓帧先报 DrawCall 与三角形两项,分开归因DrawCall、批合与实例化
显存账纹理设尺寸上限与压缩格式,原图不入包纹理与资源
灯光预算实时阴影与光源数量进性能预算,超限走烘焙渲染管线:数据怎么变成像素
机型测试池中低端机型提前进冒烟,帧率门槛进流水线常见误区

案例的三个教训 ​

  1. DrawCall 与三角形是两本账——前者反映提交压力,后者反映 GPU 压力,只对一本就会优化错层。
  2. 显存是预算不是余量——一张 2048 的 RGBA 纹理 16MB 起步,原图直接上就是翻倍。
  3. 机型差异比想象大——旗舰机的满帧证明不了管线健康,低端设备要提前进测试池。

Used By ​

3D Web 游戏与 3D 小游戏的性能归因;引擎渲染设置(批合、实例化、纹理压缩)的理解与调参;着色器特效的可行域评估。

关系链:渲染管线 → 着色器 → 场景图(Three.js)→ DrawCall 与批合 → 纹理与资源。上游:Canvas 2D(web/01,帧循环同构)。下游:游戏引擎(web/03,管线被引擎封装后的样子)。

Reference ​

WebGL 规范与 MDN 文档为准;Three.js 用法以官方文档为准;本页其余为工程经验归纳;页内实战案例为教学示例(数字取整简化)。

游戏知识体系