《WebGL 编程指南》学习总结:渲染基础、核心数学与游戏引擎落地(新人友好版)
- 约 25 分钟读完 - 全文 9601 字《WebGL 编程指南》学习总结
目标读者:会一点 JavaScript、想搞懂"3D 画面到底是怎么画出来的"的新人,或从 Cocos Creator 上层 API 往下挖原理的引擎使用者。 参考书籍:Kouichi Matsuda / Rodger Lea《WebGL 编程指南》(WebGL Programming Guide) 关键词:渲染管线、顶点/片元着色器、MVP 矩阵、纹理、深度测试
写在前面:这本书在讲什么?
《WebGL 编程指南》是 WebGL 入门最经典的一本书。它不依赖任何三方库(不用 Three.js),从 canvas 上一个三角形开始,一步步讲到多物体场景、光照、纹理、雾化。全书的知识结构可以概括成一张图:
mindmap
root((WebGL 编程指南))
基础准备
Canvas 与 WebGL 上下文
WebGL 是状态机
清空颜色缓冲区
着色器
GLSL ES 语言
顶点着色器
片元着色器
attribute / uniform / varying
几何绘制
缓冲区对象 Buffer
三角形与图元装配
索引绘制 IBO
数学核心
向量运算
矩阵变换
模型/视图/投影矩阵
进阶效果
颜色与纹理
光照模型
深度测试与混合
雾化与三维交互
学这本书最大的收获不是"会用 WebGL API",而是理解所有 3D 引擎(包括 Cocos Creator、Unity)底层都在做的同一件事:把一堆顶点数据,经过数学变换和逐像素计算,画到屏幕上。理解了这套流程,引擎里的材质、Shader、渲染设置对你来说就再也不是黑盒。
一、渲染基础知识:画面是怎么画出来的?
1.1 一句话理解渲染管线
渲染(Rendering)就是把"三维场景的数学描述"转换成"屏幕上一个个像素颜色"的过程。
WebGL 渲染管线是一条固定流水线,数据从 CPU 流向 GPU,每个阶段各司其职:
flowchart LR
A[CPU: JavaScript<br/>准备顶点数据] -->|gl.bufferData| B[GPU 缓冲区<br/>顶点坐标/颜色/UV]
B --> C[顶点着色器<br/>Vertex Shader]
C --> D[图元装配<br/>Primitive Assembly]
D --> E[光栅化<br/>Rasterization]
E --> F[片元着色器<br/>Fragment Shader]
F --> G[逐片元操作<br/>深度测试/混合]
G --> H[帧缓冲区<br/>Framebuffer]
H --> I[显示器画面]
style C fill:#e1f5ff
style F fill:#e1f5ff
逐个阶段用大白话解释:
- 准备数据(CPU 侧):JavaScript 把顶点坐标、颜色、纹理坐标(UV)打包成一块"数组"(缓冲区对象 Buffer),传给 GPU。
- 顶点着色器:对每一个顶点执行一次你写的 GLSL 程序,核心任务是把顶点坐标变换到最终位置(乘 MVP 矩阵),输出到内置变量
gl_Position。 - 图元装配:GPU 按照
gl.drawArrays指定的模式(TRIANGLES等),把顶点三个三个地拼成三角形。 - 光栅化:把三角形这个"几何图形"打碎成屏幕上实际的一个个片元(可以近似理解为像素候选)。
- 片元着色器:对每一个片元执行一次 GLSL 程序,算出这个像素最终的颜色,输出到
gl_FragColor。纹理采样、光照计算都在这里。 - 逐片元操作:深度测试(谁挡谁)、模板测试、颜色混合(半透明),最后写进帧缓冲区显示出来。
关键点:顶点着色器和片元着色器是你唯一能编程的两个环节,其余都是 GPU 固定管线。WebGL 开发的本质就是"喂数据 + 写这两段 Shader"。
1.2 WebGL 是一台"状态机"
WebGL API 没有"对象"概念,它是一台巨大的状态机。你做的所有事都是:
flowchart TD
S1[创建着色器程序<br/>gl.createProgram] --> S2[绑定到当前状态<br/>gl.useProgram]
S2 --> S3[创建并绑定缓冲区<br/>gl.bindBuffer]
S3 --> S4[把缓冲区接到 attribute 上<br/>gl.vertexAttribPointer]
S4 --> S5[设置 uniform 值<br/>gl.uniformMatrix4fv]
S5 --> S6[发出绘制命令<br/>gl.drawArrays / drawElements]
S6 --> S7{还要画别的物体?}
S7 -->|是| S2
S7 -->|否| Done[本帧结束]
新人最容易犯的错就是忘记"绑定":改了缓冲区 A 的数据,结果绘制时绑着的还是缓冲区 B。记住口诀:先绑定,再设置,后绘制。
1.3 第一个三角形的最小代码骨架
书里第 2、3 章的核心内容,浓缩起来就是这几步:
// 1. 获取 WebGL 上下文
const gl = canvas.getContext('webgl');
// 2. 编译着色器、链接成程序(顶点 + 片元着色器)
const program = createProgram(gl, VSHADER_SOURCE, FSHADER_SOURCE);
gl.useProgram(program);
// 3. 准备顶点数据并写入缓冲区
const vertices = new Float32Array([0.0, 0.5, -0.5, -0.5, 0.5, -0.5]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 4. 把缓冲区数据"接"到着色器的 attribute 变量上
const a_Position = gl.getAttribLocation(program, 'a_Position');
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(a_Position);
// 5. 清空 + 绘制
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);对应的着色器(GLSL ES):
// 顶点着色器:接收每个顶点的位置
attribute vec4 a_Position;
void main() {
gl_Position = a_Position; // 必须写的内置输出
}
// 片元着色器:给每个片元上色
precision mediump float;
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 红色
}1.4 三种变量:数据怎么流进着色器
| 变量类型 | 数据流向 | 粒度 | 典型用途 |
|---|---|---|---|
attribute |
JS → 顶点着色器 | 每个顶点不同 | 顶点坐标、颜色、UV、法线 |
uniform |
JS → 两个着色器 | 一次绘制内不变 | MVP 矩阵、光照参数、时间 |
varying |
顶点着色器 → 片元着色器 | 被光栅化插值 | 把顶点颜色渐变到三角形内部 |
varying 特别值得理解:顶点着色器给三个顶点各设了一个颜色,片元着色器拿到的却是插值后的颜色——三角形内部的颜色渐变(渐变三角形)就是这么来的。
二、核心数学知识:向量、矩阵与坐标变换
这是全书最重要、也是新人最容易劝退的部分。其实只要抓住一条主线:
flowchart LR
A[局部坐标<br/>模型自己的坐标系] -->|模型矩阵 M| B[世界坐标<br/>摆放在场景里]
B -->|视图矩阵 V| C[观察坐标<br/>以相机为原点]
C -->|投影矩阵 P| D[裁剪坐标<br/>齐次坐标]
D -->|透视除法| E[标准化设备坐标 NDC<br/>-1 到 1]
E -->|视口变换| F[屏幕像素坐标]
著名的 MVP 矩阵就是上面三个矩阵的乘积。顶点着色器里一行代码搞定:
gl_Position = u_ProjMatrix * u_ViewMatrix * u_ModelMatrix * a_Position;2.1 向量:方向与位置
向量就是一个有方向的箭头,如 $\vec{v} = (x, y, z)$。渲染里有两个运算用最多:
点积(Dot Product):衡量两个向量"有多同向"。
$$ \vec{a} \cdot \vec{b} = a_x b_x + a_y b_y + a_z b_z = |\vec{a}||\vec{b}|\cos\theta $$
光照计算全靠它:光线方向和表面法线的夹角越接近 $0$,$\cos\theta$ 越大,表面越亮。这就是漫反射光照的核心公式:
$$ \text{漫反射颜色} = \text{光源颜色} \times \text{表面基底色} \times \max(\vec{n} \cdot \vec{l},\ 0) $$
其中 $\vec{n}$ 是表面法线,$\vec{l}$ 是指向光源的方向,$\max(\cdot, 0)$ 保证背光面不会出现"负亮度"。
叉积(Cross Product):求垂直于两个向量的法线,长度为平行四边形面积。
$$ \vec{a} \times \vec{b} = (a_y b_z - a_z b_y,\ a_z b_x - a_x b_z,\ a_x b_y - a_y b_x) $$
知道三角形三个顶点,用两条边叉积就能求出面的法线方向;判断三角形朝向(正面/背面剔除)也靠它。
2.2 齐次坐标:为什么坐标是 4 维的?
三维点为什么要写成 $(x, y, z, w)$?因为平移没法用 $3 \times 3$ 矩阵乘法表示,升到 4 维后,平移、旋转、缩放、投影全部统一成一次矩阵乘法。约定:点的 $w = 1$,方向的 $w = 0$(方向不受平移影响,这个约定天然正确)。
2.3 三种基本变换矩阵
平移矩阵:
$$ T = \begin{bmatrix} 1 & 0 & 0 & T_x \\ 0 & 1 & 0 & T_y \\ 0 & 0 & 1 & T_z \\ 0 & 0 & 0 & 1 \end{bmatrix} $$
绕 z 轴旋转矩阵:
$$ R_z(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta & 0 & 0 \\ \sin\theta & \cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} $$
缩放矩阵:
$$ S = \begin{bmatrix} S_x & 0 & 0 & 0 \\ 0 & S_y & 0 & 0 \\ 0 & 0 & S_z & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} $$
矩阵相乘的顺序非常重要:矩阵从右往左作用于顶点。$M = T \cdot R$ 表示"先旋转再平移",和"先平移再旋转"结果完全不同。书里的 cuon-matrix.js 提供了 setTranslate / rotate / scale 等方法,实际项目中 Cocos Creator、gl-matrix 也提供同样的能力,不需要手写。
2.4 视图矩阵与投影矩阵
视图矩阵:把整个世界"挪"到相机视角下。由三个参数决定——视点(相机位置)、观察目标点、上方向,即 lookAt:
$$ V = \text{lookAt}(\text{eye},\ \text{center},\ \text{up}) $$
投影矩阵有两种:
- 正射投影(Orthographic):物体远近一样大,像工程图纸,适合 2D 游戏、UI。
- 透视投影(Perspective):近大远小,符合人眼,适合 3D 场景。由一个视锥体(near、far、fov、aspect)定义。
2.5 法线矩阵:一个容易被忽略的细节
对模型做了非等比缩放(比如 x 方向压扁、y 方向拉长)后,法线不能直接乘模型矩阵,要用模型矩阵的逆转置矩阵:
$$ \vec{n}’ = (M^{-1})^{T} \cdot \vec{n} $$
书里第 8 章专门讲了这个问题。等比缩放 + 旋转时法线矩阵等于模型矩阵,所以简单场景可以偷懒,但骨骼动画、非均匀缩放时漏掉这步,光照就是错的。
三、纹理、光照与隐藏面消除
3.1 纹理贴图:把图片"贴"到三角形上
流程五步:
- JS 加载图片(
Image对象)。 - 创建并绑定纹理对象(
gl.createTexture+gl.bindTexture)。 - 上传图片数据到 GPU(
gl.texImage2D)。 - 把纹理单元连接到着色器的
uniform sampler2D。 - 顶点传 UV 坐标,片元着色器里
texture2D(u_Sampler, v_UV)采样取色。
新人第一坑:图片在 WebGL 里是上下颠倒的,需要 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) 翻转 Y 轴。原因:图片坐标系原点在左上,纹理坐标系原点在左下。
3.2 光照:环境光 + 漫反射
书里的光照模型是最经典的 Phong 简化版,新人记住两项就够:
$$ \text{最终颜色} = \underbrace{\text{环境光} \times \text{基底色}}{\text{照亮背光面,防止死黑}} + \underbrace{\text{平行光颜色} \times \text{基底色} \times \max(\vec{n}\cdot\vec{l}, 0)}{\text{漫反射}} $$
3.3 隐藏面消除:深度缓冲区
画多个三角形时,谁挡住谁?WebGL 用深度缓冲区(Z-Buffer):每个像素除了存颜色,还存一个深度值。新片元要写入时先和已有深度比较,更近的才覆盖。
gl.enable(gl.DEPTH_TEST); // 开启深度测试
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // 每帧连深度一起清
忘记清深度缓冲区,画面会越画越乱——这是新人第二坑。
四、典型问题排查(踩坑指南)
4.1 “我画的东西是黑屏/什么都不显示”
这是 WebGL 新人 90% 的问题。按这个流程排查,命中率极高:
flowchart TD
Start[画面黑屏/空白] --> Q1{着色器编译链接<br/>报错了吗?}
Q1 -->|是| A1[看 gl.getShaderInfoLog<br/>通常是 GLSL 语法错误<br/>或精度限定符缺失]
Q1 -->|否| Q2{顶点坐标在<br/>-1 到 1 的 NDC 范围内吗?}
Q2 -->|否| A2[检查 MVP 矩阵<br/>是否被设置/乘反了顺序]
Q2 -->|是| Q3{三角形顶点顺序<br/>是逆时针 CCW 吗?}
Q3 -->|否| A3[被背面剔除干掉了<br/>gl.disable CULL_FACE 验证]
Q3 -->|是| Q4{深度测试开启且<br/>每帧清深度缓冲了吗?}
Q4 -->|否| A4[gl.clear 加上<br/>DEPTH_BUFFER_BIT]
Q4 -->|是| Q5{相机在物体<br/>正确的一侧吗?}
Q5 -->|否| A5[物体在相机背后<br/>或在视锥体 near/far 之外]
A1 --> OK[问题解决]
A2 --> OK
A3 --> OK
A4 --> OK
A5 --> OK
4.2 深度冲突(Z-Fighting)
两个面片距离极近时,深度缓冲精度不够,会出现闪烁的"花纹"(两个面片抢着显示)。
解决方案(书里第 10 章的方法):多边形偏移
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(1.0, 1.0); // 让后画的面片深度"向后推"一点
本质原因也值得一提:透视投影的深度值不是线性的,精度集中在 near 平面附近。所以near 不要设得过小(如 0.0001),越大精度越好。
4.3 半透明物体显示错误
开启混合 gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) 后,半透明物体仍然错乱?因为混合依赖绘制顺序:必须先画不透明物体,再按从远到近排序画半透明物体,且半透明物体之间往往要关闭深度写入(gl.depthMask(false))只保留深度测试。
4.4 性能问题:Draw Call 太多
每个 gl.drawArrays/drawElements 都有一次 CPU→GPU 的状态切换开销。几千个小物体各画一次,帧率直接崩。思路:
- 合批(Batching):把使用同一材质的小物体顶点合并到一个大缓冲区,一次画完。
- 纹理图集(Texture Atlas):小图合成大图,减少纹理切换。
- 状态排序:按材质/着色器排序绘制顺序,减少状态切换。
五、在 Cocos Creator 中落地这些渲染知识
学 WebGL 不是为了手写引擎,而是为了看得懂 Cocos Creator 在干什么。下面对照来看。
5.1 概念对照表
| WebGL 原生概念 | Cocos Creator 对应物 |
|---|---|
| 顶点/片元着色器程序 | Effect(.effect 文件,Cocos Effect 语法,本质还是 GLSL) |
| 着色器 + uniform 参数集合 | Material(材质):一个 Effect 实例 + 具体参数值 |
| attribute 顶点数据 | Mesh / MeshRenderer 的顶点缓冲区 |
| uniform 矩阵(MVP) | 引擎自动填充:模型矩阵来自节点 Transform,视图/投影矩阵来自 Camera |
| 纹理 + sampler | Material 上的 mainTexture 等贴图槽 |
| 深度测试/混合状态 | Effect 中 Pass 的 depthStencilState / blendState |
| gl.drawElements | 引擎渲染管线(Forward Pipeline)自动按材质合批后提交 |
5.2 引擎帮你做的事
flowchart TB
subgraph 你写的部分
N1[节点树 + Transform]
N2[自定义 Effect / Shader]
N3[材质参数]
end
subgraph 引擎自动处理
E1[遍历场景收集可渲染对象]
E2[计算模型矩阵<br/>节点世界变换]
E3[相机视图/投影矩阵]
E4[按材质排序 + 合批<br/>Draw Call 优化]
E5[设置 uniform 常量缓冲]
E6[调用底层图形 API]
end
N1 --> E1
N2 --> E5
N3 --> E5
E1 --> E2 --> E3 --> E4 --> E5 --> E6
E6 --> API[WebGL / WebGPU / Vulkan / Metal]
你 WebGL 手写时代要做的"传 MVP 矩阵、绑缓冲区、开深度测试、手动合批",引擎全部代劳了。但底层语义完全没变:
- 节点的
position/rotation/scale就是在拼第 2 节的 $T \cdot R \cdot S$ 模型矩阵; - Camera 的
fov、near、far就是第 2.4 节的透视投影参数(所以 Cocos 里 near 设太小同样会 Z-Fighting); - 2D 游戏的
Sprite本质是"两张三角形拼的矩形 + 纹理采样"; SpriteFrame图集就是第 4.4 节的纹理合批优化。
5.3 自定义 Shader:知识直接迁移
Cocos Creator 里写一个自定义 Effect,核心片段和手写 WebGL 几乎一一对应:
// Cocos Effect 的顶点着色器片段
CCProgram vs %{
vec4 vert() {
// CC_MAT_MV_PROJ 就是引擎帮你算好的 MVP 矩阵
return CC_MAT_MV_PROJ * vec4(In.position, 1.0);
}
}%
// 片元着色器片段
CCProgram fs %{
vec4 frag() {
// mainTexture 就是 uniform sampler2D
return texture(mainTexture, In.uv);
}
}%看懂前面四章的内容后,Cocos 的 Effect 文档里那些 CC_MAT_MV_PROJ、CC_HANDLE_UNIFORM、depthStencilState 全部都能对上号。遇到"自定义描边、溶解、UV 动画"这类需求时,你写的就是片元着色器里的逐像素计算。
5.4 性能优化落地
第 4.4 节的合批思想在 Cocos 里的具体做法:
- UI 用自动图集(Auto Atlas):减少纹理切换,让 2D 合批生效;
- 避免跨材质穿插:合批会被不同材质打断,注意节点层级排序;
- 3D 场景用 GPU 实例化(Instancing):同网格同材质的大量物体(草地、树木)一次 Draw Call 画完,相当于手动版的
gl.drawElementsInstanced; - 用引擎的渲染调试工具看 Draw Call 数,和当年数
gl.drawElements调用次数是同一件事。
六、经典特效 Demo 与实现分析
游戏里的特效看起来五花八门,拆开看几乎都是四种基本手段的组合:
mindmap
root((特效的四板斧))
顶点动画
波浪/旗帜飘动
草和树摇摆
顶点挤出描边
片元计算
溶解/燃烧
UV 扭曲(热浪/水波纹)
菲涅尔边缘光
粒子系统
烟雾/火焰
雨雪/魔法拖尾
后处理
泛光 Bloom
模糊/暗角
下面挑三个最经典的特效,从原理、数学、Shader 代码到 Cocos Creator 落地完整走一遍。
6.1 烟雾:粒子系统(Billboard + Alpha 混合)
一句话原理:烟雾不是"一个模型",而是几百个始终面向摄像机的半透明小方片(粒子),各自经历出生、上升、变大、变淡、死亡的生命周期,叠加起来就是一蓬烟。
flowchart LR
A[发射器<br/>按速率生成粒子] --> B[初始化属性<br/>位置/速度/大小/寿命]
B --> C[每帧更新<br/>位置 += 速度·dt<br/>透明度随寿命衰减]
C --> D{寿命耗尽?}
D -->|否| E[渲染: Billboard 面片<br/>采样烟雾纹理 + Alpha 混合]
D -->|是| F[回收粒子<br/>进入对象池]
E --> C
核心技巧一:Billboard(公告板)。粒子面片必须永远正对相机,否则侧着看就露馅。做法是从视图矩阵里取出相机的右方向和上方向向量(视图矩阵第一、二列就是它们),在顶点着色器里现场拼面片:
// 顶点着色器:粒子中心位置 + 相机坐标系下的二维偏移
uniform vec3 u_CameraRight; // 视图矩阵第一列
uniform vec3 u_CameraUp; // 视图矩阵第二列
attribute vec3 a_Center; // 粒子中心(每粒子数据)
attribute vec2 a_Corner; // 面片角点偏移 (-1~1),每顶点数据
attribute float a_Size;
void main() {
vec3 pos = a_Center
+ u_CameraRight * a_Corner.x * a_Size
+ u_CameraUp * a_Corner.y * a_Size;
gl_Position = u_ProjMatrix * u_ViewMatrix * vec4(pos, 1.0);
}核心技巧二:透明度随生命周期衰减。用一个 0~1 的生命进度 $t_{\text{life}}$ 控制淡出,片元着色器:
vec4 tex = texture2D(u_SmokeTex, v_UV);
// 淡入淡出:两头为 0,中间最浓,一条简单的抛物线
float fade = 4.0 * v_Life * (1.0 - v_Life);
gl_FragColor = vec4(tex.rgb, tex.a * fade * u_Opacity);渲染状态设置(对照 4.3 节的半透明坑):
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // 烟雾用普通混合
// gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // 火焰改用加法混合,越叠越亮
gl.enable(gl.DEPTH_TEST);
gl.depthMask(false); // 关键:只测深度、不写深度,粒子之间不互相遮挡
性能坑:烟雾最大的开销不是粒子数量,而是 Overdraw——大面积半透明面片层层叠叠,同一个像素被反复着色。优化方向:控制单粒子最大尺寸、用更低分辨率渲染粒子再合成(引擎做法)。
Cocos Creator 落地:ParticleSystem(3D)/ ParticleSystem2D 组件就是这套东西的完整实现——发射速率、生命周期、颜色/大小随时间曲线、Billboard 渲染模式、材质混合模式,面板上的每个参数都能在上面找到对应原理。想自定义就用 renderMode 配合自定义材质。
6.2 逼真波浪:顶点动画(正弦叠加 → Gerstner 波)
一句话原理:水面网格的几何形状本身不动,每帧在顶点着色器里用波形函数把每个顶点的 y 坐标"抬"起来,再重新算法线做光照——看到的就是流动的波浪。
入门版:正弦波叠加。单个正弦波:
$$ y = A \sin(\omega, x + \varphi, t) $$
其中 $A$ 是振幅(波多高),$\omega$ 是频率(波多密),$\varphi$ 控制波速,$t$ 是时间。多个不同方向、频率、振幅的正弦波相加,水面就"乱"得自然了:
float wave(vec2 p, vec2 dir, float freq, float amp, float speed) {
float phase = dot(dir, p) * freq + u_Time * speed;
return amp * sin(phase);
}
// 顶点着色器里:y = wave(p, d1,...) + wave(p, d2,...) + ...进阶版:Gerstner 波。正弦波的波峰是圆滚滚的,真实海浪的波峰是尖锐的。Gerstner 波在抬升 y 的同时把顶点水平拉向波峰,模拟出尖峰效果:
$$ \begin{aligned} x’ &= x + Q A, D_x \cos!\big(\omega(\vec{D}\cdot\vec{p}) + \varphi t\big) \\ y’ &= A \sin!\big(\omega(\vec{D}\cdot\vec{p}) + \varphi t\big) \\ z’ &= z + Q A, D_z \cos!\big(\omega(\vec{D}\cdot\vec{p}) + \varphi t\big) \end{aligned} $$
其中 $\vec{D}$ 是波传播方向,$Q$ 是"尖锐度"参数:$Q = 0$ 退化为正弦波,$Q$ 越大波峰越尖(超过上限会自我穿插,一般取 $0 \le QA\omega \le 1$)。
关键细节:法线必须跟着重算。顶点位移后,原来的法线就不对了,光照会露馅。严谨做法是对波形函数求偏导,用解析法得到切线 $\vec{T}$ 和副切线 $\vec{B}$,法线就是 $\vec{n} = \vec{B} \times \vec{T}$。GAMES101 等课程里有完整推导,新人知道"顶点位移后要重算法线"这个坑即可。
片元着色器里的水面质感:靠菲涅尔(Fresnel)效应——视线越贴近水面(掠射角),反射越强、越像镜子;垂直看下去则越透。Schlick 近似公式:
$$ F(\theta) = F_0 + (1 - F_0),(1 - \cos\theta)^5 $$
水的 $F_0 \approx 0.02$。用 $F$ 在"水体本色"和"天空/环境反射色"之间插值,水面瞬间就有真实感。
坑提醒:顶点动画的前提是网格顶点足够密——一块只有 4 个顶点的平面怎么抬都是平的。要么建模时细分网格,要么用 tessellation。
Cocos Creator 落地:水面是自定义 Effect 的经典练手项目——cc.time(引擎内置 uniform)提供时间,顶点着色器里写 Gerstner 位移,材质面板上把振幅、频率、波速、方向暴露成可调参数。社区商城里大量水面 Shader 都是这个套路的变体。
6.3 溶解:噪声纹理 + discard
一句话原理:片元着色器里采样一张噪声图,噪声值低于阈值的像素直接丢弃(discard),阈值随时间从 0 推到 1,物体就"一点点消失";在阈值边缘画一条发光边,就是燃烧/消散效果。
flowchart LR
A[采样噪声纹理<br/>得到 noise 值 0~1] --> B{noise < 阈值 threshold?}
B -->|是| C[discard<br/>该像素不画]
B -->|否| D{noise < threshold + 边缘宽度?}
D -->|是| E[输出边缘发光色<br/>如橙红火焰色]
D -->|否| F[正常输出原纹理颜色]
核心代码极简:
float noise = texture2D(u_NoiseTex, v_UV).r;
if (noise < u_Threshold) discard;
// 边缘宽度内混入燃烧色,smoothstep 让过渡自然
float edge = 1.0 - smoothstep(u_Threshold, u_Threshold + u_EdgeWidth, noise);
vec3 color = mix(texture2D(u_MainTex, v_UV).rgb, u_BurnColor.rgb, edge);
gl_FragColor = vec4(color, 1.0);discard 就是 WebGL 里的"这个片元不要了"——对照第 1.1 节的管线,它在片元着色器阶段直接终止了后续写入。噪声图的选择决定溶解风格:Perlin 噪声 = 烟雾状消散,碎裂纹理 = 破碎消失,斜向渐变 = 扫描式消失。
Cocos Creator 落地:这是新手写自定义材质的最佳起点——抄一个 builtin Effect,加 noiseTexture、threshold、edgeColor 三个材质属性,JS 里用 tween 把 threshold 从 0 推到 1,就是角色死亡消散、传送门出现等常见需求。
6.4 更多特效的快速索引
同样套路能覆盖的常见需求,新人可以先建立"这个效果属于哪一板斧"的直觉:
| 特效 | 属于 | 核心技术点 |
|---|---|---|
| 火焰 | 粒子系统 | 加法混合 + 颜色随生命从黄到红 |
| 旗帜/头发飘动 | 顶点动画 | 正弦波,振幅随 UV 或骨骼权重衰减 |
| 模型描边 | 顶点动画 | 沿法线挤出背面 + 正面剔除 |
| 热浪/水下扭曲 | 片元计算 | 噪声扰动 UV 后再采样 |
| 全息/护盾 | 片元计算 | 菲涅尔边缘光 + 扫描线(fract(uv.y - time)) |
| 径向模糊/泛光 | 后处理 | 帧缓冲区(FBO)离屏渲染 + 二次全屏绘制 |
七、总结与学习路线建议
回顾全文,WebGL 学习的核心脉络是:
- 管线:数据从 CPU 缓冲区 → 顶点着色器 → 光栅化 → 片元着色器 → 帧缓冲,能编程的只有两个着色器。
- 数学:向量点积(光照)、叉积(法线)、$4\times4$ 矩阵(MVP 变换)是必须过关的三块;齐次坐标是为了统一平移。
- 状态机思维:先绑定、再设置、后绘制;忘了绑定或忘了清缓冲区是最高频 bug。
- 排查有套路:黑屏先看着色器日志,再查 NDC 范围、背面剔除、深度缓冲、相机位置。
- 落地到引擎:Cocos Creator 的 Effect/材质/合批,全部是这套原理的封装——学透 WebGL,引擎文档读起来就是"老朋友换新名字"。
- 特效有套路:再炫的效果也逃不出四板斧——顶点动画、片元计算、粒子系统、后处理,看懂烟雾、波浪、溶解三个例子就能举一反三。
建议的动手顺序(对应原书章节):
flowchart LR
S1[Ch2-3<br/>画三角形<br/>理解缓冲区] --> S2[Ch4<br/>矩阵变换<br/>让图形动起来]
S2 --> S3[Ch5<br/>颜色与纹理]
S3 --> S4[Ch7<br/>三维世界<br/>视图+投影矩阵]
S4 --> S5[Ch8<br/>光照]
S5 --> S6[Ch9-10<br/>多物体/高级技巧<br/>雾化/帧缓冲/选中物体]
S6 --> S7[读 Cocos Creator<br/>Effect 源码<br/>自定义 Shader 实战]
S7 --> S8[经典特效实战<br/>粒子烟雾/Gerstner 波浪<br/>噪声溶解]
下一步推荐:
- 跟着原书把每个示例亲手敲一遍,改参数观察变化(比如把投影矩阵的 near 改大改小,直观感受 Z-Fighting)。
- 在 Cocos Creator 里打开一个官方材质,逐行读懂它的 Effect 文件。
- 有余力再看《Fundamentals of Computer Graphics》(虎书)补全光栅化、光线追踪的理论体系。