第12章:WebGL 与着色器模式
目标读者:初级程序员(Junior Developer) 前置知识:第11章《Shader 深度入门》的 GLSL 基础语法 预计学习时间:3 天 配套 Demo:
akira-graphics/webgl/hello_world.html、akira-graphics/repeat-and-random/
本章导读
在第11章,我们学会了 GLSL 语法和 Shader 在渲染管线中的位置。但纸上得来终觉浅——真正理解 WebGL 的方式,是亲手从零搭建一整个渲染流程。
本章我们将:
- 用原生 WebGL API 画一个彩色三角形(不借助任何框架)
- 理解 UV 坐标——Shader 里最重要的"地址系统"
- 用
fract()和step()做图案重复与网格切割 - 在 GPU 里生成伪随机数(hash 函数)
- 用 Shader 迭代计算曼德勃罗特分形(Mandelbrot)
- 用 Truchet 图案生成随机迷宫
- 最后理解
gl-renderer.js为什么能帮我们省掉 80% 的样板代码
核心原则:先懂 raw,再用框架。跳过 raw 直接上框架的人,调试时永远不知道 bug 在哪一层。
12.1 原生 WebGL Hello World:从零画一个三角形
12.1.1 生活类比:WebGL 像一家手工披萨店
想象你开了一家手工披萨店:
- 厨房(GPU):真正烤披萨的地方,你进不去,只能递单子
- 菜单(Shader 代码):告诉厨房"我要什么配料、怎么烤"
- 原料(顶点数据):面团、芝士、火腿的坐标位置
- 传菜口(Canvas):烤好的披萨从这里端出来给顾客
- 店长(JavaScript):你站在前台,负责接单、写菜单、递原料
WebGL 的繁琐之处在于:店长必须亲自做每一件事——和面、写菜单、递原料、按烤箱按钮。而 Three.js / Cocos 这样的引擎,相当于雇了一个全自动机器人店长。
但如果你不知道披萨怎么烤出来的,机器人坏了你就只能干瞪眼。
12.1.2 本质:WebGL 是一个状态机
WebGL 的 API 设计非常"古老"(继承自 OpenGL ES 2.0)。它的核心特征:全局状态机。
什么意思?想象一个老式的机械开关面板:
- 你拨动开关 A,整个系统的状态就变了
- 下一个操作依赖之前拨了哪些开关
- 如果你忘了某个开关的状态,结果就会莫名其妙出错
所以写 WebGL 代码的感觉是:每一步都在设置全局状态,然后执行一个命令。
12.1.3 完整代码:原生 WebGL 彩色三角形
配套文件:akira-graphics/webgl/hello_world.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebGL Hello World</title>
</head>
<body>
<canvas width="300" height="300"></canvas>
<script>
// ========== 第1步:获取 WebGL 上下文 ==========
// 类比:打开厨房的门,拿到对讲机
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
// ========== 第2步:写 Shader 源码 ==========
// 顶点着色器:决定每个顶点在哪里、是什么颜色
const vertex = `
attribute vec2 position; // 从 JS 传入的顶点坐标
varying vec3 color; // 传给片元着色器的颜色
void main() {
gl_PointSize = 1.0;
// 把 [-1,1] 的坐标映射为 RGB 颜色,再传给 fragment
color = vec3(0.5 + position * 0.5, 0.0);
// gl_Position 是内置变量,必须赋值(xyzw)
gl_Position = vec4(position * 0.5, 1.0, 1.0);
}
`;
// 片元着色器:决定每个像素是什么颜色
const fragment = `
precision mediump float; // 声明浮点精度(WebGL 必须)
varying vec3 color; // 从顶点着色器接收的颜色
void main() {
gl_FragColor = vec4(color, 1.0); // RGBA 输出
}
`;
// ========== 第3步:编译 Shader ==========
// 类比:把菜单草稿交给印刷厂,印成正式菜单
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertex);
gl.compileShader(vertexShader);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);
// 检查编译错误(实际项目中必须做!)
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.error('顶点着色器编译失败:', gl.getShaderInfoLog(vertexShader));
}
// ========== 第4步:链接 Program ==========
// 类比:把"配料单"和"烤制方法"装订成一本完整的操作手册
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
// ========== 第5步:创建顶点缓冲区 ==========
// 类比:把原料按顺序摆在传送带上
const points = new Float32Array([
-1, -1, // 左下角
0, 1, // 顶部
1, -1, // 右下角
]);
const bufferId = gl.createBuffer(); // 申请一个缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId); // 把它设为"当前"缓冲区
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW); // 塞数据
// ========== 第6步:告诉 Shader 怎么读取缓冲区 ==========
// 类比:告诉厨师"传送带上第1个是面团,第2个是芝士..."
const vPosition = gl.getAttribLocation(program, 'position');
gl.vertexAttribPointer(
vPosition, // 属性位置
2, // 每个顶点有2个分量(x, y)
gl.FLOAT, // 数据类型是浮点数
false, // 不归一化
0, // 步长(0 = 紧密排列)
0 // 偏移量
);
gl.enableVertexAttribArray(vPosition); // 启用这个属性
// ========== 第7步:绘制! ==========
gl.clear(gl.COLOR_BUFFER_BIT); // 清空画布
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2); // 画三角形
</script>
</body>
</html>
12.1.4 逐行拆解:7 步流水线
| 步骤 | API 调用 | 生活类比 | 常见错误 |
|---|---|---|---|
| 1 | getContext('webgl') |
打开厨房门 | 浏览器不支持 WebGL |
| 2 | 写 GLSL 字符串 | 手写菜单 | 语法错误导致编译失败 |
| 3 | createShader + compileShader |
印刷菜单 | 忘记检查 COMPILE_STATUS |
| 4 | createProgram + linkProgram |
装订手册 | 顶点/片元不兼容导致链接失败 |
| 5 | createBuffer + bufferData |
摆原料 | 数据类型错用 Array 而非 Float32Array |
| 6 | vertexAttribPointer |
告诉厨师规则 | 参数填错导致画面错乱 |
| 7 | drawArrays |
按烤箱按钮 | 忘记 clear 导致残影 |
12.1.5 常见误区
误区1:"gl_Position = vec4(position, 1.0, 1.0) 里的两个 1.0 是干什么用的?"
答:这是齐次坐标(Homogeneous Coordinates)。vec4(x, y, z, w) 中:
x, y, z是三维坐标w是缩放因子,GPU 最后会做x/w, y/w, z/w
对于 2D 绘制,我们设 z=1.0, w=1.0,这样坐标不会被改变。z=1.0 把三角形放在近裁剪面上,w=1.0 表示"不透视缩放"。
误区2:"为什么坐标范围是 [-1, 1]?"
答:这是标准化设备坐标(NDC)。WebGL 的视口永远是一个边长为 2 的立方体:
- X 轴:左 -1,右 +1
- Y 轴:下 -1,上 +1
- Z 轴:前 -1,后 +1(注意:WebGL 是右手系,但 NDC 的 Z 是 0~1)
无论 Canvas 是 300x300 还是 1920x1080,NDC 范围永远不变。
误区3:"varying 是怎么工作的?"
答:顶点着色器输出 3 个顶点的颜色,GPU 在光栅化阶段自动插值。
想象你在三角形的三个角分别涂了红、绿、蓝三种颜料。varying 就是 GPU 帮你把中间的颜料渐变混合好了。
12.1.6 Try it yourself
- 把三角形的三个顶点改成不同的坐标,观察颜色如何变化
- 把
gl.drawArrays(gl.TRIANGLES, ...)改成gl.LINE_LOOP,看看会发生什么 - 尝试画两个三角形(需要 6 个顶点),理解为什么需要索引缓冲区(
ELEMENT_ARRAY_BUFFER)
12.2 Shader 编译、Program 链接与缓冲区创建
12.2.1 生活类比:开餐厅的标准化流程
上一节我们画了三角形,但代码里有些步骤你可能还是懵的。让我们用"开餐厅"再梳理一遍:
编译 Shader = 培训厨师
- 顶点着色器:培训配菜师(处理原料的位置和属性)
- 片元着色器:培训烤炉师傅(决定最终成品颜色)
- 每个厨师都要单独培训(单独编译),培训完要考试(检查
COMPILE_STATUS)
链接 Program = 组建班组
- 配菜师和烤炉师傅必须能配合工作
varying就是他们的交接单:配菜师写什么,烤炉师傅必须能读- 如果配菜师写
varying vec3 vColor,烤炉师傅写varying vec2 vColor,班组就组建失败(链接错误)
缓冲区 = 食材仓库
createBuffer():租一个仓库bindBuffer():打开仓库大门(之后所有对ARRAY_BUFFER的操作都指向这个仓库)bufferData():把货运进仓库
12.2.2 本质:为什么需要这么多步骤?
WebGL 的设计遵循"显式优于隐式"原则。每一个对象(Shader、Program、Buffer)都需要:
- 创建(create)
- 绑定(bind)——设为当前操作目标
- 配置(configure)
- 使用(use)
这种设计让 GPU 驱动可以精确知道你要干什么,从而做最大优化。代价就是代码很啰嗦。
12.2.3 代码模板:带错误检查的健壮版本
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
console.error(`${type === gl.VERTEX_SHADER ? 'Vertex' : 'Fragment'} shader compile error:\n${info}`);
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl, vertexShader, fragmentShader) {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
// 使用
const vs = createShader(gl, gl.VERTEX_SHADER, vertexSource);
const fs = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
const program = createProgram(gl, vs, fs);
gl.useProgram(program);
12.2.4 常见误区
误区:"编译通过了,链接却失败,为什么?"
最常见的原因:
varying/attribute/uniform的类型不匹配(一边vec3,一边vec2)varying的名字拼写不一致- 顶点着色器没有给
gl_Position赋值 - 片元着色器没有给
gl_FragColor赋值
调试技巧:链接失败后,调用 gl.getProgramInfoLog(program),它会告诉你具体哪里不兼容。
12.2.5 Try it yourself
- 故意把片元着色器里的
varying vec3 color改成varying vec2 color,观察链接错误信息 - 写一个工具函数
createBuffer(gl, data),封装缓冲区创建流程 - 尝试用
gl.ELEMENT_ARRAY_BUFFER画一个正方形(4 个顶点 + 6 个索引)
12.3 UV 坐标与纹理映射基础
12.3.1 生活类比:给盒子贴包装纸
想象你要给一个长方体礼盒贴包装纸:
- 3D 模型 = 礼盒的形状
- 纹理图片 = 包装纸上的图案
- UV 坐标 = 包装纸上的"地址"
UV 坐标是一个二维坐标系:
U轴:水平方向,范围[0, 1](左 → 右)V轴:垂直方向,范围[0, 1](下 → 上,注意不是上 → 下!)
礼盒的每个顶点都要标注"这个点对应包装纸的哪个位置"。比如:
- 左下角顶点 → UV
(0, 0) - 左上角顶点 → UV
(0, 1) - 右上角顶点 → UV
(1, 1) - 右下角顶点 → UV
(1, 0)
12.3.2 本质:UV 是"纹理空间地址"
UV 坐标回答的问题是:"这个顶点对应的像素,在纹理图片的哪里?"
为什么用 [0, 1] 而不是 [0, 图片宽度]?
因为归一化后,同样的 UV 可以适配任意尺寸的纹理。一张 256x256 和一张 4096x4096 的图,UV 坐标是一样的。
数学表达:
纹理像素坐标 = UV * 纹理尺寸
x_pixel = u * texture_width
y_pixel = v * texture_height
12.3.3 代码:带 UV 的正方形
// 顶点坐标(NDC 空间) UV 坐标(纹理空间)
// (-1, 1) ┌──────┐ (1, 1)
// │ │
// (-1,-1) └──────┘ (1,-1)
const positions = new Float32Array([
-1, -1, // 左下
-1, 1, // 左上
1, 1, // 右上
1, -1, // 右下
]);
const uvs = new Float32Array([
0, 0, // 左下 → 纹理左下
0, 1, // 左上 → 纹理左上
1, 1, // 右上 → 纹理右上
1, 0, // 右下 → 纹理右下
]);
// 顶点着色器
const vertex = `
attribute vec2 a_vertexPosition;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv; // 把 UV 传给片元着色器
gl_Position = vec4(a_vertexPosition, 1, 1);
}
`;
// 片元着色器(暂时不用纹理,用 UV 直接可视化)
const fragment = `
precision mediump float;
varying vec2 vUv;
void main() {
// 把 UV [0,1] 映射为颜色 [0,1]
gl_FragColor = vec4(vUv, 0.0, 1.0);
// 左下 = 黑,右下 = 红,左上 = 绿,右上 = 黄
}
`;
12.3.4 常见误区
误区1:"V 轴为什么是下 → 上,而图片坐标是左上原点?"
答:这是历史遗留问题。OpenGL 的纹理坐标原点在左下角,而大多数图片格式(PNG/JPG)的像素数据是从左上角开始存储的。
后果:如果你直接把图片数据传给 GPU,图像是上下颠倒的。解决方案:
- 加载图片后翻转 Y 轴
- 或者在 Shader 里用
vUv.y = 1.0 - vUv.y - 或者设置
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
误区2:"UV 可以超出 [0, 1] 吗?"
答:可以!超出后的行为由纹理环绕模式决定:
gl.REPEAT:重复平铺(1.2→0.2)gl.CLAMP_TO_EDGE:边缘拉伸(1.2→1.0)gl.MIRRORED_REPEAT:镜像重复
这是下一节"UV 重复"的基础。
12.3.5 Try it yourself
- 把 UV 可视化代码跑起来,确认四个角的颜色符合预期
- 交换 UV 的 X 和 Y:
gl_FragColor = vec4(vUv.yx, 0.0, 1.0),观察变化 - 尝试
gl_FragColor = vec4(vUv.x, 0.0, 0.0, 1.0),理解为什么画面是水平渐变
12.4 UV 重复:fract() 与 step()
12.4.1 生活类比:铺地砖
想象你要铺一个房间的地板:
- 你只有一块 1米 x 1米 的花砖图案
- 房间是 4米 x 4米
- 你需要把这块砖重复 16 次
在 Shader 里,这块"花砖"就是一个 UV 单元 [0, 1],而"房间"是更大的 UV 范围 [0, 4]。
12.4.2 本质:用数学做"取小数"
fract(x) 函数返回 x 的小数部分:
fract(0.3) = 0.3
fract(1.3) = 0.3
fract(2.3) = 0.3
fract(3.3) = 0.3
所以 fract(uv * rows) 的效果是:
- 先把 UV 放大
rows倍(比如 4 倍,UV 变成[0, 4]) - 再取小数部分(把
[0, 4]折叠回[0, 1],重复 4 次)
数学推导:
输入 UV: u ∈ [0, 1]
放大后: u' = u * rows ∈ [0, rows]
取小数: st = fract(u') ∈ [0, 1]
结果:原来的 [0, 1] 被重复了 rows 次
12.4.3 代码:网格重复与切割
配套文件:akira-graphics/repeat-and-random/grids.html
// 顶点着色器(省略,和标准 UV 传递一样)
// 片元着色器
#ifdef GL_ES
precision mediump float;
#endif
varying vec2 vUv;
uniform float rows; // 从 JS 传入:每行/列重复多少次
void main() {
// Step 1: UV 重复
vec2 st = fract(vUv * rows);
// 现在 st 在 [0,1] 内重复了 rows 次
// Step 2: 用 step() 画网格线
// step(edge, x): x >= edge 返回 1.0,否则返回 0.0
float d1 = step(st.x, 0.9); // st.x > 0.9 时返回 0(右边留缝)
float d2 = step(0.1, st.y); // st.y < 0.1 时返回 0(下边留缝)
// Step 3: 混合颜色
// d1 * d2:只有当两个条件都满足时才亮
gl_FragColor.rgb = mix(vec3(0.8), vec3(1.0), d1 * d2);
gl_FragColor.a = 1.0;
}
step() 详解:
step(edge, x) = { 1.0, if x >= edge
{ 0.0, if x < edge
它是阶跃函数,输出只有 0 或 1,没有中间态。用它做硬边切割非常高效(没有分支)。
mix() 详解:
mix(a, b, t) = a * (1 - t) + b * t
当 t=0 时输出 a,t=1 时输出 b。这里 t = d1 * d2:
- 如果在网格内部(远离边缘),
d1=1, d2=1,t=1,输出白色vec3(1.0) - 如果在网格线上,
d1=0或d2=0,t=0,输出灰色vec3(0.8)
12.4.4 常见误区
误区:"fract() 和取模 % 有什么区别?"
答:fract(x) = x - floor(x),只对正数有效(在 Shader 里 UV 通常是非负的)。
对于负数,fract(-1.3) = 0.7(向 0 取小数),而 -1.3 % 1 在 JS 里是 -0.3。在 GLSL 里要处理负数重复时,通常用 mod(x, 1.0) 而不是 fract()。
误区:"为什么不用 if (st.x > 0.9) 而用 step()?"
答:GPU 的并行架构极度厌恶分支。if/else 会导致线程组(warp/wavefront)内的线程走不同路径,性能急剧下降。
step() 是纯数学运算,所有线程执行同样的指令,只是输入数据不同。这是 Shader 编程的核心思维:用数学代替逻辑分支。
12.4.5 Try it yourself
- 修改
rows的值(1, 4, 16, 32, 64),观察网格密度的变化 - 把
step(st.x, 0.9)改成smoothstep(0.85, 0.9, st.x),观察边缘是否变软 - 尝试画一个棋盘格:
(floor(st.x * 2.0) + floor(st.y * 2.0)) % 2.0
12.5 网格图案与平铺
12.5.1 生活类比:瓷砖展厅
上一节我们铺了单色地砖。现在我们要做更复杂的图案:
- 每块砖的图案不同(随机花纹)
- 砖与砖之间有灰缝
- 某些砖是"特殊砖"(比如花砖)
这需要我们能够独立处理每一块砖。
12.5.2 本质:floor() 获取"砖的编号"
floor(x) 返回不大于 x 的最大整数:
floor(0.3) = 0
floor(1.3) = 1
floor(2.3) = 2
配合 fract(),我们可以把坐标拆成两部分:
vec2 st = vUv * float(rows); // 放大到 [0, rows]
vec2 ipos = floor(st); // 整数部分 = "第几块砖"(网格编号)
vec2 fpos = fract(st); // 小数部分 = "砖内的局部坐标"
这就像是地址系统:
ipos= 楼号和单元号(哪一块砖)fpos= 房间内的坐标(砖内的具体位置)
12.5.3 代码:独立处理每块砖
void main() {
vec2 st = vUv * 10.0; // 10x10 网格
vec2 ipos = floor(st); // 每块砖的编号 (0~9, 0~9)
vec2 fpos = fract(st); // 每块砖内的坐标 [0,1]
// 在砖内画一个圆
float d = distance(fpos, vec2(0.5));
float circle = step(d, 0.4);
// 每隔一块砖换一种颜色
float checker = mod(ipos.x + ipos.y, 2.0);
vec3 colorA = vec3(1.0, 0.5, 0.3); // 橙
vec3 colorB = vec3(0.3, 0.5, 1.0); // 蓝
gl_FragColor.rgb = mix(colorA, colorB, checker) * circle;
gl_FragColor.a = 1.0;
}
12.5.4 常见误区
误区:"floor() 和 int() 有什么区别?"
答:floor() 返回 float 类型(1.0),int() 返回整数类型(1)。在 GLSL 中,尽量保持浮点运算,因为 GPU 的整数运算能力通常较弱(尤其在旧设备上)。
误区:"网格编号 ipos 在 Shader 里能用来干什么?"
答:ipos 是程序化生成的核心。你可以:
- 用
ipos作为随机数种子,让每块砖不同 - 用
ipos查表,实现瓦片地图(Tilemap) - 用
ipos做区域判断,实现"选中高亮"
12.5.5 Try it yourself
- 把上面的圆改成菱形:
float d = abs(fpos.x - 0.5) + abs(fpos.y - 0.5); - 实现"同心圆"效果:
float d = distance(fpos, vec2(0.5)); float ring = step(0.2, d) * step(d, 0.4); - 让每块砖的图案根据
ipos.x的大小渐变
12.6 随机数生成:Shader 中的 Hash 函数
12.6.1 生活类比:掷骰子
CPU 上生成随机数很简单:Math.random()。但 GPU 是并行处理器,成千上万个像素同时执行,它们不能共享一个"全局骰子"。
每个像素需要独立计算自己的随机数,而且:
- 输入相同 → 输出必须相同(确定性)
- 输入微小变化 → 输出应该剧烈变化(伪随机性)
- 不能依赖外部状态(无全局变量)
12.6.2 本质:Hash 函数 = 确定性的"混乱制造机"
Shader 中的"随机数"其实是 Hash 函数:给定一个输入(通常是坐标),输出一个看起来像随机的数。
最常用的经典 Hash:
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
逐步拆解:
Step 1: dot(st.xy, vec2(12.9898, 78.233))
点乘把二维坐标投影到一个方向上。12.9898 和 78.233 是"魔法数字",选它们是因为能让结果在整数网格上分布均匀。
dot([x, y], [a, b]) = x*a + y*b
Step 2: sin(...)
正弦函数把输入值"打散"到 [-1, 1] 的波浪中。因为正弦是周期函数,微小的输入变化会导致输出剧烈震荡。
Step 3: * 43758.5453123
放大振幅,让 sin 的结果跨越更多周期。
Step 4: fract(...)
取小数部分,把结果映射到 [0, 1]。
12.6.3 代码:随机灰度网格
配套文件:akira-graphics/repeat-and-random/random.html
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
void main() {
vec2 st = vUv * 10.0; // 10x10 网格
float rnd = random(floor(st)); // 每块砖一个随机数
gl_FragColor.rgb = vec3(rnd); // 灰度显示
gl_FragColor.a = 1.0;
}
12.6.4 常见误区
误区1:"这些魔法数字可以随便改吗?"
答:可以改,但效果会变差。12.9898 和 78.233 是图形学社区多年实践筛选出来的,它们能让 Hash 结果在视觉上有良好的"白噪声"特性。
如果你改成 vec2(1.0, 2.0),可能会看到明显的条纹或重复模式。
误区2:"random() 真的随机吗?"
答:不是。它是完全确定性的:同样的输入永远得到同样的输出。它只是在视觉上"看起来像随机"。
真正的随机需要硬件熵源,GPU Shader 里没有这个能力。但在图形渲染中,"看起来像随机"通常就够了。
误区3:"为什么不能用 fract(sin(x)) 单独做 Hash?"
答:可以,但二维 Hash 比一维 Hash 质量好得多。因为:
- 一维 Hash 在
x方向变化快,但如果有二维结构,容易出现 aliasing - 二维 Hash 通过
dot把两个维度混合在一起,分布更均匀
12.6.5 进阶:动画随机(随机雨/星空)
配套文件:akira-graphics/repeat-and-random/random2.html
uniform float uTime;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
void main() {
vec2 st = vUv * vec2(100.0, 50.0); // 100x50 的网格
// 让整行向左移动,每行速度不同(由 random(floor(st.y)) 决定)
st.x -= (1.0 + 10.0 * random(vec2(floor(st.y)))) * uTime;
vec2 ipos = floor(st);
vec2 fpos = fract(st);
// 70% 的格子有"雨点"
vec3 color = vec3(step(random(ipos), 0.7));
// 雨点只在格子上部 80% 区域显示(留出空隙)
color *= step(0.2, fpos.y);
gl_FragColor.rgb = color;
gl_FragColor.a = 1.0;
}
这段代码模拟了** Matrix 数字雨**的效果:
- 每行有独立的随机速度
- 每个格子有 70% 概率"亮"
- 亮的位置在格子上部,形成竖条
12.6.6 Try it yourself
- 修改 Hash 的魔法数字,观察噪声质量的变化
- 实现一个"星空"效果:随机点亮某些像素,亮度也随机
- 用
random()生成随机颜色(三个通道分别 Hash)
12.7 曼德勃罗特集:复数迭代分形
12.7.1 生活类比:回声山谷
想象你站在一个特殊的山谷里大喊:
- 正常情况下,声音传出去后会越来越弱,最终消失
- 但在某些"共振点",回声会越传越强,最终"爆炸"
曼德勃罗特集(Mandelbrot Set)就是这个山谷的地图:
- 每个像素 = 山谷里的一个位置
- 迭代公式 = 声音传播规则
- 爆炸与否 = 这个位置是否属于"共振区"
- 爆炸速度 = 颜色
12.7.2 本质:复数迭代与逃逸时间
曼德勃罗特集的核心公式惊人地简单:
z_{n+1} = z_n^2 + c
其中 z 和 c 都是复数(有实部和虚部)。
复数乘法规则:
如果 z = a + bi,c = d + ei
那么 z^2 = (a^2 - b^2) + (2ab)i
在 GLSL 里,我们用 vec2 表示复数:
z.x= 实部z.y= 虚部
所以 z^2 的代码是:
vec2 z2 = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y);
迭代过程:
- 从
z = 0开始 - 计算
z = z^2 + c - 检查
|z|是否大于 2 - 如果大于 2,"逃逸"了,记录迭代次数
- 如果一直没逃逸,达到最大迭代次数,认为属于集合(黑色)
12.7.3 代码:Mandelbrot Shader
配套文件:akira-graphics/repeat-and-random/mandelbrot.html
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform vec2 center; // 观察中心(复平面坐标)
uniform float scale; // 缩放倍数
uniform int iterations; // 最大迭代次数
// 调色板函数:把 [0,1] 映射为渐变色
vec3 palette(float t, vec3 c1, vec3 c2, vec3 c3, vec3 c4) {
float x = 1.0 / 3.0;
if (t < x) return mix(c1, c2, t/x);
else if (t < 2.0 * x) return mix(c2, c3, (t - x)/x);
else if (t < 3.0 * x) return mix(c3, c4, (t - 2.0*x)/x);
return c4;
}
// 复数迭代:z = z^2 + c
// 用矩阵乘法优雅实现:
// z^2 = [z.x, -z.y] * [z.x, z.y] = mat2(z, -z.y, z.x) * z
vec2 f(vec2 z, vec2 c) {
return mat2(z, -z.y, z.x) * z + c;
}
void main() {
vec2 uv = vUv;
// 把 UV [0,1] 映射到复平面
// center 是中心,scale 控制缩放
// (uv - 0.5) 把原点移到画面中心
// * 4.0 / scale 控制视野范围
vec2 c = center + 4.0 * (uv - vec2(0.5)) / scale;
vec2 z = vec2(0.0); // 从 0 开始迭代
bool escaped = false;
int j;
// GLSL 的 for 循环上限必须在编译时确定
// 所以我们写一个很大的上限,用 if 提前 break
for (int i = 0; i < 65536; i++) {
if (i > iterations) break;
j = i;
z = f(z, c);
if (length(z) > 2.0) {
escaped = true;
break;
}
}
if (escaped) {
// 根据逃逸速度着色
float t = float(j) / float(iterations);
gl_FragColor.rgb = palette(t,
vec3(0.02, 0.02, 0.03), // 深蓝黑
vec3(0.1, 0.2, 0.3), // 蓝灰
vec3(0.0, 0.3, 0.2), // 青绿
vec3(0.0, 0.5, 0.8) // 亮蓝
);
} else {
// 属于集合,黑色
gl_FragColor.rgb = vec3(0.0);
}
gl_FragColor.a = 1.0;
}
12.7.4 数学推导:为什么逃逸半径是 2?
证明:如果 |z| > 2,那么 z 一定会趋向无穷大。
假设 |z| = r > 2,且 |c| <= 2(我们只关心复平面上 [-2, 2] 范围内的 c)。
由三角不等式:
|z_{n+1}| = |z_n^2 + c| >= |z_n^2| - |c| = r^2 - |c| >= r^2 - 2
因为 r > 2:
r^2 - 2 > 2r - 2 = 2(r - 1) > r (当 r > 2 时)
所以 |z_{n+1}| > |z_n|,且增长越来越快,必然逃逸。
这就是为什么我们只需要检查 length(z) > 2.0。
12.7.5 常见误区
误区1:"Mandelbrot 是无限精度的吗?"
答:不是。GPU 的 float 只有 32 位,大约 7 位有效数字。当你放大到 scale > 10^6 时,浮点精度不够,边缘会变得模糊、块状。
要渲染更深处的细节,需要:
- 双精度浮点(
double,WebGL 2.0 支持,但性能差) - 或者 CPU 高精度计算 + GPU 显示
误区2:"为什么循环上限要写 65536 而不是 iterations?"
答:GLSL ES 2.0 要求 for 循环的迭代次数在编译时就能确定。iterations 是 uniform,运行时才能知道。
workaround:写一个足够大的常数(如 65536),然后在循环体内用 if break。
误区3:"mat2(z, -z.y, z.x) * z 是什么意思?"
答:这是复数乘法的矩阵表示。复数 z = a + bi 乘以另一个复数 w = c + di:
z * w = (ac - bd) + (ad + bc)i
可以写成矩阵乘法:
[ a -b ] [ c ] [ ac - bd ]
[ b a ] * [ d ] = [ ad + bc ]
所以 mat2(z.x, z.y, -z.y, z.x) 就是"乘以 z"的矩阵。mat2(z, -z.y, z.x) 是 GLSL 的列优先构造语法。
12.7.6 Try it yourself
- 修改
center到[0.367, 0.303](一个漂亮的局部),慢慢增加scale - 改变调色板的颜色,做出"火焰"或"极光"效果
- 实现 Julia 集:固定
c,让z从uv开始迭代
12.8 Truchet 图案与迷宫生成
12.8.1 生活类比:拼花地板
Truchet 图案源于 18 世纪法国数学家 Sebastien Truchet 研究的瓷砖拼花:
- 你只有一种基本瓷砖:一个正方形,里面有两条弧线(像括号一样)
- 但这种瓷砖有 4 种旋转方向
- 随机铺在地上,就能形成复杂的迷宫-like 图案
关键洞察:复杂的整体图案 = 简单的局部规则 + 随机旋转
12.8.2 本质:局部图案 + 随机变换
Truchet 图案的核心算法:
- 把画面分成网格
- 每个格子随机选择一种变换(旋转/翻转)
- 在格子内绘制基础图案
- 相邻格子的图案自然衔接,形成整体纹理
12.8.3 代码:Truchet 迷宫
配套文件:akira-graphics/repeat-and-random/maze.html
#define PI 3.14159265358979323846
varying vec2 vUv;
uniform int rows;
// Hash 函数:给每个格子一个随机数
float random(in vec2 _st) {
return fract(sin(dot(_st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
// Truchet 变换:根据随机索引旋转/翻转局部坐标
vec2 truchetPattern(in vec2 _st, in float _index) {
_index = fract(((_index - 0.5) * 2.0));
if (_index > 0.75) {
_st = vec2(1.0) - _st; // 180度旋转
} else if (_index > 0.5) {
_st = vec2(1.0 - _st.x, _st.y); // 水平翻转
} else if (_index > 0.25) {
_st = 1.0 - vec2(1.0 - _st.x, _st.y); // 垂直翻转
}
// _index <= 0.25:不做变换(原始方向)
return _st;
}
void main() {
vec2 st = vUv * float(rows); // 放大到网格
vec2 ipos = floor(st); // 格子编号
vec2 fpos = fract(st); // 格子内坐标
// 根据格子编号获取随机变换
vec2 tile = truchetPattern(fpos, random(ipos));
float color = 0.0;
// 绘制对角线:smoothstep 制造平滑的线条
// tile.x - 0.3 到 tile.x 是"上线"
// tile.x 到 tile.x + 0.3 是"下线"
// 两者相减得到一条宽约 0.3 的线
color = smoothstep(tile.x - 0.3, tile.x, tile.y) -
smoothstep(tile.x, tile.x + 0.3, tile.y);
gl_FragColor = vec4(vec3(color), 1.0);
}
12.8.4 逐步拆解
truchetPattern 函数:
输入:
_st:格子内坐标[0, 1]_index:随机数[0, 1],决定用哪种变换
变换规则:
[0.00, 0.25]:原始方向(╱)[0.25, 0.50]:垂直翻转(╲)[0.50, 0.75]:水平翻转(╲)[0.75, 1.00]:180度旋转(╱)
实际上只有两种基本形状(╱ 和 ╲),但通过翻转和旋转,四种变体让图案更随机。
smoothstep 画线:
smoothstep(a, b, x) = { 0, x <= a
{ 平滑过渡, a < x < b
{ 1, x >= b
smoothstep(tile.x - 0.3, tile.x, tile.y):
- 当
tile.y < tile.x - 0.3时,输出 0 - 当
tile.y > tile.x时,输出 1 - 中间平滑过渡
smoothstep(tile.x, tile.x + 0.3, tile.y):
- 当
tile.y < tile.x时,输出 0 - 当
tile.y > tile.x + 0.3时,输出 1
两者相减,得到一条"从 0 上升到 1 再下降回 0"的脉冲,也就是一条线。
12.8.5 常见误区
误区:"Truchet 只能画迷宫吗?"
答:完全不是。Truchet 是一种方法论:
- 基础图案可以是任何形状(弧线、直线、圆、文字)
- 变换可以是旋转、翻转、缩放、颜色替换
- 随机种子可以是
ipos、时间、噪声函数
经典变体:
- Smith Truchet:弧线改成四分之一圆,形成连续的管道
- Wang Tiles:边缘匹配约束,确保图案无缝衔接
- Truchet 纹理:用 Truchet 图案做表面纹理(地板、织物、电路板)
12.8.6 Try it yourself
- 把线条改成圆:
color = step(distance(tile, vec2(0.5)), 0.3); - 让
rows随时间变化,观察图案的涌现 - 尝试把
random(ipos)改成random(ipos + floor(uTime)),让图案动态变化
12.9 为什么 gl-renderer.js 能简化 WebGL
12.9.1 生活类比:从手工作坊到流水线
学完前面的内容,你应该感受到了原生 WebGL 的繁琐:
| 操作 | 原生 WebGL | gl-renderer.js |
|---|---|---|
| 创建 Shader | createShader + shaderSource + compileShader x2 |
renderer.compileSync(fragment, vertex) |
| 链接 Program | createProgram + attachShader + linkProgram |
自动完成 |
| 设置顶点数据 | createBuffer + bindBuffer + bufferData + vertexAttribPointer x N |
renderer.setMeshData([{positions, attributes, cells}]) |
| 设置 Uniform | getUniformLocation + gl.uniformXxx |
renderer.uniforms.foo = value |
| 绘制 | clear + drawArrays/drawElements |
renderer.render() |
12.9.2 本质:封装了样板代码
gl-renderer.js 做的事情并不神秘,它只是把我们反复写的样板代码封装了起来:
// 原生 WebGL:20+ 行
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, 'position');
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(loc);
// gl-renderer.js:1 行配置
renderer.setMeshData([{ positions: [[-1,-1], ...], attributes: {uv: [...]}, cells: [...] }]);
但请注意:gl-renderer.js 并没有创造新的渲染能力,它只是让同样的能力更容易使用。
12.9.3 你必须先懂 raw 的原因
场景1:调试黑屏
用框架时画面全黑,如果你不懂 raw,你只能:
- 检查参数是否传对
- 祈祷框架没有 bug
如果你懂 raw,你可以:
- 检查 Shader 是否编译成功(
getShaderParameter) - 检查 Program 是否链接成功(
getProgramParameter) - 检查 Uniform 位置是否正确(
getUniformLocation) - 检查顶点数据是否正确绑定(
vertexAttribPointer参数)
场景2:性能优化
框架为了通用性,往往做了额外的抽象开销。懂 raw 的人才能:
- 知道什么时候该用
STATIC_DRAWvsDYNAMIC_DRAW - 理解为什么
Instanced Drawing能减少 DrawCall - 知道
VAO(Vertex Array Object)能省多少状态切换
场景3:跨平台/跨引擎
Three.js、Babylon.js、Cocos、Unity、Unreal——底层都是 OpenGL/WebGL/Vulkan/Metal。
懂 raw 的人学任何引擎都很快,因为底层概念是通用的。只懂框架的人,换引擎就要重新学习。
12.9.4 gl-renderer.js 的核心设计
const renderer = new GlRenderer(canvas);
// 1. 编译 Shader(封装了 createShader + compile + createProgram + link)
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
// 2. 设置 Uniform(自动推断类型,封装了 getUniformLocation + gl.uniformXxx)
renderer.uniforms.rows = 20;
renderer.uniforms.uTime = 0.0;
// 3. 设置网格数据(自动处理 Buffer 创建和属性绑定)
renderer.setMeshData([{
positions: [[-1, -1], [-1, 1], [1, 1], [1, -1]],
attributes: {
uv: [[0, 0], [0, 1], [1, 1], [1, 0]],
},
cells: [[0, 1, 2], [2, 0, 3]], // 两个三角形组成正方形
}]);
// 4. 渲染(封装了 clear + drawElements)
renderer.render();
12.9.5 常见误区
误区:"用了框架就不用学 raw WebGL 了?"
答:绝对错误。框架是工具,raw 是根基。就像:
- 你可以用 Excel 不用懂二进制,但 Excel 卡顿时懂底层的人能定位问题
- 你可以用 React 不用懂 DOM API,但遇到诡异 bug 时懂 DOM 的人能更快解决
建议的学习路径:
- 先用 raw WebGL 画三角形(本章 12.1)
- 再用
gl-renderer.js做复杂效果(本章后续 Demo) - 遇到问题时,能想象出框架底层在调用哪些 WebGL API
12.9.6 Try it yourself
- 对比
hello_world.html(raw)和grids.html(gl-renderer),列出 API 调用数量的差异 - 尝试用 raw WebGL 重写
grids.html的网格效果 - 阅读
gl-renderer.js的源码,找到它封装setMeshData的具体实现
12.10 本章总结
| 概念 | 核心要点 | 配套 Demo |
|---|---|---|
| 原生 WebGL | 7 步流水线:上下文 → Shader → 编译 → 链接 → Buffer → 属性 → 绘制 | webgl/hello_world.html |
| Shader 编译链接 | 编译检查 COMPILE_STATUS,链接检查 LINK_STATUS |
webgl/hello_world.html |
| UV 坐标 | 纹理空间的归一化地址 [0,1],原点左下角 |
repeat-and-random/grids.html |
| UV 重复 | fract(uv * n) 把 [0,1] 重复 n 次 |
repeat-and-random/grids.html |
| 网格切割 | step(edge, x) 做硬边,smoothstep 做软边 |
repeat-and-random/grids.html |
| 随机数 | fract(sin(dot(st, vec2(12.9898,78.233))) * 43758.5) |
repeat-and-random/random.html |
| Mandelbrot | z = z^2 + c,逃逸时间着色 |
repeat-and-random/mandelbrot.html |
| Truchet | 基础图案 + 随机旋转/翻转 = 复杂纹理 | repeat-and-random/maze.html |
| gl-renderer.js | 封装样板代码,但底层原理必须懂 | 全部 Demo |
Q&A 问答
Q1:WebGL 和 OpenGL 是什么关系?
A:WebGL 是 OpenGL ES 2.0 的 JavaScript 绑定。OpenGL ES 是移动/嵌入式设备的 OpenGL 子集。所以 WebGL 的 API 设计比较"古老",但兼容性极好。WebGL 2.0 对应 OpenGL ES 3.0,支持更多现代特性(如 3D 纹理、多重渲染目标、整数纹理等)。
Q2:为什么我的 Shader 修改后刷新页面没有变化?
A:浏览器会缓存 Shader 源码(如果通过 <script> 标签或外部文件加载)。解决方案:
- 强制刷新:
Ctrl + F5 - 在 Shader 字符串里加一个版本注释,每次修改时改版本号
- 用内联字符串(如本章示例)而不是外部文件
Q3:precision mediump float 是什么意思?可以省略吗?
A:声明浮点数的精度。WebGL 强制要求片元着色器声明精度,顶点着色器有默认值(highp)。
highp:高精度,范围大,但某些移动设备不支持mediump:中等精度,兼容性最好lowp:低精度,适合颜色计算
Q4:为什么 Mandelbrot 在手机上跑得很慢?
A:两个原因:
- 片元着色器里的
for循环迭代次数太高(256+),导致每个像素都要做大量计算 - 移动 GPU 的浮点性能通常只有桌面 GPU 的 1/10~1/5
优化方案:降低 iterations、降低分辨率、或者把计算移到 Worker/CPU。
Q5:varying 和 uniform 有什么区别?
A:
attribute:每个顶点不同的数据(如位置、UV),只能从顶点着色器读取varying:顶点着色器输出、片元着色器输入,GPU 自动插值uniform:每个 DrawCall 都相同的数据(如时间、鼠标位置),两个着色器都能读
Q6:UV 的 V 轴为什么和屏幕 Y 轴方向相反?
A:OpenGL 的纹理坐标系原点在左下角(笛卡尔坐标习惯),而大多数图像文件(PNG/JPG)的像素数据从左上角开始存储。这个历史遗留问题导致加载纹理时需要翻转 Y 轴。
Q7:如何调试 Shader?
A:Shader 调试很困难,因为不能在 GPU 里打断点。常用技巧:
- 颜色调试:把中间变量直接输出为颜色,用眼睛观察
- 简化场景:先用固定值测试,确认每一步输出正确
- Spector.js:浏览器插件,可以捕获 WebGL 调用和纹理状态
- GLSL 验证器:在线工具检查语法错误
Q8:fract() 和 mod() 在负数时行为不同吗?
A:是的。
fract(-1.3)=0.7(向 0 取小数)mod(-1.3, 1.0)=0.7(GLSL 的 mod 行为)
但在 JS 里:-1.3 % 1 = -0.3。跨语言时要注意差异。
Q9:Truchet 图案和 Wang Tiles 有什么区别?
A:Truchet 图案的变换是完全随机的,依赖相邻格子的图案自然衔接。Wang Tiles 则要求边缘匹配:每块瓷砖的边缘图案必须与相邻瓷砖匹配,这需要用约束满足算法来铺砖,而不是纯随机。
Q10:学了这些能做什么实际项目?
A:直接应用:
- 游戏背景:用 UV 重复 + 随机生成无限星空、草地、水面
- UI 特效:用 Mandelbrot/Truchet 做加载动画、转场效果
- 程序化纹理:用 Shader 实时生成材质,减少贴图内存
- 数据可视化:用 Shader 并行计算,渲染百万级数据点
课后练习
- 基础:用 raw WebGL 画一个彩色正方形(两个三角形),每个顶点不同颜色
- 进阶:实现一个"下雨"效果,用
random()+uTime让雨滴从上往下落 - 挑战:用 raw WebGL(不用 gl-renderer.js)实现
grids.html的网格效果 - 探索:修改 Mandelbrot 的调色板,实现"热力图"配色(黑 → 红 → 黄 → 白)
- 创意:设计你自己的 Truchet 基础图案(比如字母、几何图形),生成独特纹理
本章寄语:WebGL 的繁琐不是缺陷,而是 GPU 编程的本质——显式控制每一寸状态。当你能徒手写出 Hello World,再看任何框架都觉得透明。继续加油,下一章我们将进入仿射变换与坐标变换的数学世界!