第12章:WebGL 与着色器模式

目标读者:初级程序员(Junior Developer) 前置知识:第11章《Shader 深度入门》的 GLSL 基础语法 预计学习时间:3 天 配套 Demo:akira-graphics/webgl/hello_world.htmlakira-graphics/repeat-and-random/


本章导读

在第11章,我们学会了 GLSL 语法和 Shader 在渲染管线中的位置。但纸上得来终觉浅——真正理解 WebGL 的方式,是亲手从零搭建一整个渲染流程。

本章我们将:

  1. 原生 WebGL API 画一个彩色三角形(不借助任何框架)
  2. 理解 UV 坐标——Shader 里最重要的"地址系统"
  3. fract()step() 做图案重复与网格切割
  4. 在 GPU 里生成伪随机数(hash 函数)
  5. 用 Shader 迭代计算曼德勃罗特分形(Mandelbrot)
  6. 用 Truchet 图案生成随机迷宫
  7. 最后理解 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

  1. 把三角形的三个顶点改成不同的坐标,观察颜色如何变化
  2. gl.drawArrays(gl.TRIANGLES, ...) 改成 gl.LINE_LOOP,看看会发生什么
  3. 尝试画两个三角形(需要 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)都需要:

  1. 创建(create)
  2. 绑定(bind)——设为当前操作目标
  3. 配置(configure)
  4. 使用(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 常见误区

误区:"编译通过了,链接却失败,为什么?"

最常见的原因:

  1. varying / attribute / uniform类型不匹配(一边 vec3,一边 vec2
  2. varying名字拼写不一致
  3. 顶点着色器没有给 gl_Position 赋值
  4. 片元着色器没有给 gl_FragColor 赋值

调试技巧:链接失败后,调用 gl.getProgramInfoLog(program),它会告诉你具体哪里不兼容。

12.2.5 Try it yourself

  1. 故意把片元着色器里的 varying vec3 color 改成 varying vec2 color,观察链接错误信息
  2. 写一个工具函数 createBuffer(gl, data),封装缓冲区创建流程
  3. 尝试用 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,图像是上下颠倒的。解决方案:

  1. 加载图片后翻转 Y 轴
  2. 或者在 Shader 里用 vUv.y = 1.0 - vUv.y
  3. 或者设置 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)

误区2:"UV 可以超出 [0, 1] 吗?"

答:可以!超出后的行为由纹理环绕模式决定:

  • gl.REPEAT:重复平铺(1.20.2
  • gl.CLAMP_TO_EDGE:边缘拉伸(1.21.0
  • gl.MIRRORED_REPEAT:镜像重复

这是下一节"UV 重复"的基础。

12.3.5 Try it yourself

  1. 把 UV 可视化代码跑起来,确认四个角的颜色符合预期
  2. 交换 UV 的 X 和 Y:gl_FragColor = vec4(vUv.yx, 0.0, 1.0),观察变化
  3. 尝试 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) 的效果是:

  1. 先把 UV 放大 rows 倍(比如 4 倍,UV 变成 [0, 4]
  2. 再取小数部分(把 [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 时输出 at=1 时输出 b。这里 t = d1 * d2

  • 如果在网格内部(远离边缘),d1=1, d2=1t=1,输出白色 vec3(1.0)
  • 如果在网格线上,d1=0d2=0t=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

  1. 修改 rows 的值(1, 4, 16, 32, 64),观察网格密度的变化
  2. step(st.x, 0.9) 改成 smoothstep(0.85, 0.9, st.x),观察边缘是否变软
  3. 尝试画一个棋盘格:(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

  1. 把上面的圆改成菱形:float d = abs(fpos.x - 0.5) + abs(fpos.y - 0.5);
  2. 实现"同心圆"效果:float d = distance(fpos, vec2(0.5)); float ring = step(0.2, d) * step(d, 0.4);
  3. 让每块砖的图案根据 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.989878.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.989878.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 数字雨**的效果:

  1. 每行有独立的随机速度
  2. 每个格子有 70% 概率"亮"
  3. 亮的位置在格子上部,形成竖条

12.6.6 Try it yourself

  1. 修改 Hash 的魔法数字,观察噪声质量的变化
  2. 实现一个"星空"效果:随机点亮某些像素,亮度也随机
  3. random() 生成随机颜色(三个通道分别 Hash)

12.7 曼德勃罗特集:复数迭代分形

12.7.1 生活类比:回声山谷

想象你站在一个特殊的山谷里大喊:

  • 正常情况下,声音传出去后会越来越弱,最终消失
  • 但在某些"共振点",回声会越传越强,最终"爆炸"

曼德勃罗特集(Mandelbrot Set)就是这个山谷的地图:

  • 每个像素 = 山谷里的一个位置
  • 迭代公式 = 声音传播规则
  • 爆炸与否 = 这个位置是否属于"共振区"
  • 爆炸速度 = 颜色

12.7.2 本质:复数迭代与逃逸时间

曼德勃罗特集的核心公式惊人地简单:

z_{n+1} = z_n^2 + c

其中 zc 都是复数(有实部和虚部)。

复数乘法规则:

如果 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);

迭代过程:

  1. z = 0 开始
  2. 计算 z = z^2 + c
  3. 检查 |z| 是否大于 2
  4. 如果大于 2,"逃逸"了,记录迭代次数
  5. 如果一直没逃逸,达到最大迭代次数,认为属于集合(黑色)

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

  1. 修改 center[0.367, 0.303](一个漂亮的局部),慢慢增加 scale
  2. 改变调色板的颜色,做出"火焰"或"极光"效果
  3. 实现 Julia 集:固定 c,让 zuv 开始迭代

12.8 Truchet 图案与迷宫生成

12.8.1 生活类比:拼花地板

Truchet 图案源于 18 世纪法国数学家 Sebastien Truchet 研究的瓷砖拼花:

  • 你只有一种基本瓷砖:一个正方形,里面有两条弧线(像括号一样)
  • 但这种瓷砖有 4 种旋转方向
  • 随机铺在地上,就能形成复杂的迷宫-like 图案

关键洞察:复杂的整体图案 = 简单的局部规则 + 随机旋转

12.8.2 本质:局部图案 + 随机变换

Truchet 图案的核心算法:

  1. 把画面分成网格
  2. 每个格子随机选择一种变换(旋转/翻转)
  3. 在格子内绘制基础图案
  4. 相邻格子的图案自然衔接,形成整体纹理

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

  1. 把线条改成圆:color = step(distance(tile, vec2(0.5)), 0.3);
  2. rows 随时间变化,观察图案的涌现
  3. 尝试把 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_DRAW vs DYNAMIC_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 的人能更快解决

建议的学习路径

  1. 先用 raw WebGL 画三角形(本章 12.1)
  2. 再用 gl-renderer.js 做复杂效果(本章后续 Demo)
  3. 遇到问题时,能想象出框架底层在调用哪些 WebGL API

12.9.6 Try it yourself

  1. 对比 hello_world.html(raw)和 grids.html(gl-renderer),列出 API 调用数量的差异
  2. 尝试用 raw WebGL 重写 grids.html 的网格效果
  3. 阅读 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> 标签或外部文件加载)。解决方案:

  1. 强制刷新:Ctrl + F5
  2. 在 Shader 字符串里加一个版本注释,每次修改时改版本号
  3. 用内联字符串(如本章示例)而不是外部文件

Q3:precision mediump float 是什么意思?可以省略吗?

A:声明浮点数的精度。WebGL 强制要求片元着色器声明精度,顶点着色器有默认值(highp)。

  • highp:高精度,范围大,但某些移动设备不支持
  • mediump:中等精度,兼容性最好
  • lowp:低精度,适合颜色计算

Q4:为什么 Mandelbrot 在手机上跑得很慢?

A:两个原因:

  1. 片元着色器里的 for 循环迭代次数太高(256+),导致每个像素都要做大量计算
  2. 移动 GPU 的浮点性能通常只有桌面 GPU 的 1/10~1/5

优化方案:降低 iterations、降低分辨率、或者把计算移到 Worker/CPU。

Q5:varyinguniform 有什么区别?

A:

  • attribute:每个顶点不同的数据(如位置、UV),只能从顶点着色器读取
  • varying:顶点着色器输出、片元着色器输入,GPU 自动插值
  • uniform:每个 DrawCall 都相同的数据(如时间、鼠标位置),两个着色器都能读

Q6:UV 的 V 轴为什么和屏幕 Y 轴方向相反?

A:OpenGL 的纹理坐标系原点在左下角(笛卡尔坐标习惯),而大多数图像文件(PNG/JPG)的像素数据从左上角开始存储。这个历史遗留问题导致加载纹理时需要翻转 Y 轴。

Q7:如何调试 Shader?

A:Shader 调试很困难,因为不能在 GPU 里打断点。常用技巧:

  1. 颜色调试:把中间变量直接输出为颜色,用眼睛观察
  2. 简化场景:先用固定值测试,确认每一步输出正确
  3. Spector.js:浏览器插件,可以捕获 WebGL 调用和纹理状态
  4. 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 并行计算,渲染百万级数据点

课后练习

  1. 基础:用 raw WebGL 画一个彩色正方形(两个三角形),每个顶点不同颜色
  2. 进阶:实现一个"下雨"效果,用 random() + uTime 让雨滴从上往下落
  3. 挑战:用 raw WebGL(不用 gl-renderer.js)实现 grids.html 的网格效果
  4. 探索:修改 Mandelbrot 的调色板,实现"热力图"配色(黑 → 红 → 黄 → 白)
  5. 创意:设计你自己的 Truchet 基础图案(比如字母、几何图形),生成独特纹理

本章寄语:WebGL 的繁琐不是缺陷,而是 GPU 编程的本质——显式控制每一寸状态。当你能徒手写出 Hello World,再看任何框架都觉得透明。继续加油,下一章我们将进入仿射变换与坐标变换的数学世界!