本文通过一个真实的渐变渲染案例,帮助习惯 JavaScript/TypeScript 的 CPU 侧程序员快速建立 GPU Shader 编程的心智模型。
引言:为什么要学 Shader?
作为前端或后端开发者,我们习惯了"顺序执行"的编程思维——代码从上到下一行行执行,循环遍历数组,逐个处理数据。但当你需要渲染成千上万个像素时,这种方式太慢了。
GPU 的核心优势是并行:它可以同时处理数千个像素,每个像素独立运行相同的代码(Shader)。理解这一点,是从 CPU 编程迁移到 GPU 编程的关键。
1. 思维转换:从"循环"到"并行"
JavaScript 思维(CPU)
假设你要给一个 200×200 的矩形填充渐变色,在 JS 中你可能会这样写:
// CPU 思维:顺序遍历每个像素
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const t = x / 200; // 计算渐变位置 [0, 1]
const color = interpolateColor(startColor, endColor, t);
setPixel(x, y, color);
}
}
这段代码需要执行 40,000 次循环,每次调用 setPixel。
WGSL 思维(GPU)
在 Shader 中,你不需要写循环。GPU 会自动为每个像素启动一个独立的"线程",每个线程只负责计算自己那一个像素的颜色:
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
// 我是谁?GPU 告诉我我正在处理的像素坐标
let localPos = input.localPos; // 比如 (100, 50)
// 计算我这个像素的渐变位置
let t = localPos.x / 200.0;
// 返回我这个像素应该显示的颜色
return mix(startColor, endColor, t);
}
💡 mix 函数详解
mix(a, b, t) 是 GPU 内置的线性插值函数,等价于 a * (1 - t) + b * t。
JavaScript 等价实现:
function mix(a, b, t) {
return a * (1 - t) + b * t;
}
// 当 t=0 时返回 a,t=1 时返回 b,t=0.5 时返回 a 和 b 的中间值
mix 的妙用:替代 if 语句
在 GPU 中,if 分支会导致性能问题(后文详述)。mix 可以优雅地替代某些条件判断:
// ❌ 有分支的写法
if (isHovered) { color = hoverColor; } else { color = normalColor; }
// ✅ 无分支的写法(isHovered 为 0.0 或 1.0)
color = mix(normalColor, hoverColor, f32(isHovered));
核心区别:
| 对比项 |
JavaScript (CPU) |
WGSL (GPU) |
| 执行模式 |
一个线程,循环 40,000 次 |
40,000 个线程,每个执行 1 次 |
| 数据访问 |
可以访问任意像素 |
只知道"我自己"的坐标 |
| 返回值 |
调用 setPixel
|
return 颜色值 |
💡 类比:想象你是工厂里的一个工人(GPU 线程),你只负责给传送带上经过你面前的那一个产品上色。你不知道也不关心其他工人在干什么,你只需要知道"我这个产品应该是什么颜色"。
2. Shader 的两个阶段:Vertex 和 Fragment
GPU 渲染管线主要分两步,对应两种 Shader:
2.1 Vertex Shader(顶点着色器)—— "形状在哪里?"
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
// 1. 将顶点从模型空间变换到屏幕空间
let pos = uniforms.transform * vec3<f32>(input.position, 1.0);
output.position = vec4<f32>(pos.xy, 0.0, 1.0);
// 2. 把原始坐标传给 Fragment Shader
output.localPos = input.position;
return output;
}
职责:处理几何图形的顶点(三角形的三个角),决定它们在屏幕上的位置。
类比:如果把渲染比作"填色游戏",Vertex Shader 就是"画轮廓线"的步骤。
为什么需要空间变换?
你可能会问: "为什么不能直接用顶点坐标画图?" 答案是:可以,但只能画固定位置、固定大小的图形。
举个例子:假设你定义了一个 100×100 的正方形,顶点坐标是 (0,0), (100,0), (100,100), (0,100)。
| 场景 |
不用变换 |
用变换矩阵 |
| 移动到 (200, 150) |
重新计算 4 个顶点坐标 |
只需修改矩阵的平移分量 |
| 放大 2 倍 |
重新计算 4 个顶点坐标 |
只需修改矩阵的缩放分量 |
| 旋转 45° |
三角函数重算所有顶点 |
只需修改矩阵的旋转分量 |
| 同时移动+缩放+旋转 |
代码爆炸 💥 |
矩阵相乘,一行搞定 |
JavaScript 类比:
// ❌ 不用变换:每次都要重新算坐标
function drawSquare(x, y, size, rotation) {
const cos = Math.cos(rotation), sin = Math.sin(rotation);
const points = [
[x + 0 * cos - 0 * sin, y + 0 * sin + 0 * cos],
[x + size * cos - 0 * sin, y + size * sin + 0 * cos],
// ... 太复杂了
];
}
// ✅ 用变换矩阵:顶点数据不变,只改矩阵
const vertices = [[0,0], [100,0], [100,100], [0,100]]; // 永远不变
const transform = mat3.multiply(translate, rotate, scale); // 组合变换
核心优势:
-
顶点数据可复用 —— 同一个正方形的顶点数据可以被缓存,画 1000 个正方形只需要传 1000 个不同的矩阵
-
变换可组合 —— 父子节点的变换通过矩阵乘法自动传递
-
GPU 友好 —— 矩阵乘法是 GPU 最擅长的运算
2.2 Fragment Shader(片元着色器)—— "像素是什么颜色?"
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
// 根据位置计算颜色
if (uniforms.paintType == 0u) {
return uniforms.color; // 纯色填充
}
// 渐变填充...
let gradPos = (uniforms.gradientTransform * vec3<f32>(input.localPos, 1.0)).xy;
let t = gradPos.x;
return interpolateGradient(t);
}
职责:对三角形内部的每个像素计算颜色。这是 GPU 并行威力最大的地方。
类比:Fragment Shader 就是"填色"步骤,为轮廓线内的每个格子决定颜色。
3. 数据传递:CPU 如何与 GPU 通信?
在 JavaScript 中,函数之间通过参数和返回值通信。但 GPU 是一个独立的硬件,数据需要显式地"打包发送"。
3.1 Uniform:全局只读数据
struct Uniforms {
transform: mat3x3<f32>, // 变换矩阵
color: vec4<f32>, // 颜色
gradientTransform: mat3x3<f32>,
paintType: u32, // 0: 纯色, 1: 线性渐变, 2: 径向渐变
stopCount: u32,
stops: array<GradientStop, 8>,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
特点:
- 所有线程共享同一份数据(所有像素看到的
uniforms.color 是一样的)
-
只读——Shader 不能修改它
JavaScript 对应概念:类似于"全局常量"或"配置对象"。
3.2 CPU 侧打包数据(TypeScript)
// 创建一个 ArrayBuffer,按照 GPU 要求的内存布局填充数据
const uniformData = new Float32Array(96); // 384 bytes
// 填充变换矩阵 (mat3x3 在 GPU 中占 48 bytes)
uniformData.set(transformMatrix, 0);
// 填充颜色 (vec4 占 16 bytes)
uniformData.set([r, g, b, a], 12);
// 上传到 GPU
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
⚠️ 大坑预警:内存对齐 (std140)
GPU 对数据布局有严格要求。vec3 不是 12 字节,而是 16 字节!mat3x3 不是 36 字节,而是 48 字节!
这是 CPU 程序员最容易踩的坑。详见后文"调试技巧"。
4. 渐变实现:完整案例解析
让我们用一个真实的渐变渲染来串联上述知识。
4.1 数据结构
struct GradientStop {
color: vec4<f32>, // RGBA 颜色
position: f32, // 位置 [0, 1]
_pad0: f32, // 填充对齐
_pad1: f32,
_pad2: f32,
}
为什么要 _pad? 因为 GPU 要求结构体按 16 字节对齐。vec4 是 16 字节,f32 是 4 字节,总共 20 字节,需要填充到 32 字节。
为什么 GPU 要求 16 字节对齐?
这是硬件架构决定的,主要原因有三:
-
内存读取效率:GPU 的内存控制器按 16 字节(128 位)为单位读取数据。如果数据跨越两个 16 字节边界,需要两次内存访问,性能直接减半。
-
SIMD 指令集:GPU 使用 SIMD(单指令多数据)架构,一条指令同时处理 4 个 float(正好 16 字节)。对齐的数据可以直接加载到寄存器,不对齐则需要额外的移位操作。
-
缓存行优化:GPU 缓存行通常是 128 字节或 256 字节。16 字节对齐确保数据不会横跨缓存行,避免缓存失效。
JavaScript 类比:
// 想象你有一个只能每次搬 4 瓶水的托盘(16 字节)
// ❌ 不对齐:3 瓶水放第一托盘,1 瓶放第二托盘 → 搬 4 瓶要跑两趟
// ✅ 对齐:4 瓶水放一个托盘,空位用泡沫填充 → 一趟搞定
std140 布局规则速记:
| 类型 |
实际大小 |
对齐到 |
说明 |
f32 |
4 |
4 |
|
vec2 |
8 |
8 |
|
vec3 |
12 |
16 |
浪费 4 字节 |
vec4 |
16 |
16 |
|
struct |
字段之和 |
最大字段对齐 × 整数倍 |
向上取整 |
4.2 坐标变换
从像素坐标到渐变参数 t 的转换是渐变的核心:
// 将像素坐标 (0~200) 变换到渐变空间 (0~1)
let gradPos = (uniforms.gradientTransform * vec3<f32>(input.localPos, 1.0)).xy;
// 线性渐变:水平方向的 x 坐标就是 t
let t = gradPos.x;
为什么需要渐变空间变换?
问题:假设一个 200×100 的矩形,像素坐标范围是 x: 0~200, y: 0~100。如何计算每个像素的渐变位置 t?
最简单的方法:t = x / 200,即 x=0 时 t=0(起始色),x=200 时 t=1(结束色)。
但这只能实现从左到右的水平渐变。如果设计师想要:
- 从上到下的垂直渐变?
- 45° 斜向渐变?
- 从中心向外的径向渐变?
答案就是空间变换矩阵。通过矩阵,我们可以把任意方向的渐变统一为"从左到右"的计算:
| 渐变类型 |
变换矩阵的作用 |
最终 t 的计算 |
| 水平(左→右) |
归一化 x: x/width
|
t = gradPos.x |
| 垂直(上→下) |
旋转 90° + 归一化 |
t = gradPos.x(原来的 y 变成了 x) |
| 45° 斜向 |
旋转 45° + 归一化 |
t = gradPos.x |
| 镜像渐变 |
缩放 x 为 2 倍 + 偏移 |
t = abs(gradPos.x) 或矩阵实现 |
JavaScript 类比理解:
// 不用矩阵:每种渐变写一套逻辑
function getGradientT_Horizontal(x, y, w, h) { return x / w; }
function getGradientT_Vertical(x, y, w, h) { return y / h; }
function getGradientT_Diagonal(x, y, w, h) {
// 45° 对角线...复杂的三角函数计算
}
// 用矩阵:统一为一套逻辑
function getGradientT(x, y, matrix) {
const [gx, gy] = applyMatrix(matrix, [x, y]);
return gx; // 所有渐变都取变换后的 x
}
镜像渐变怎么做?
镜像渐变(如 红→蓝→红)可以通过两种方式实现:
方法 1:修改 Shader 逻辑
// 将 t 从 [0, 1] 映射为 [0, 1, 0](三角波)
let t_mirror = 1.0 - abs(gradPos.x * 2.0 - 1.0);
方法 2:通过矩阵实现(更灵活)
// CPU 侧:构造一个"折叠"矩阵
// 将 [0, 0.5] 映射到 [0, 1],[0.5, 1] 映射到 [1, 0]
这样设计的优势
-
Shader 代码简洁 —— 无论什么方向的渐变,Shader 里永远是
t = gradPos.x
-
设计工具友好 —— Figma 导出的
gradientTransform 可以直接使用
-
可组合 —— 旋转、缩放、镜像可以通过矩阵乘法任意组合
4.3 颜色插值
// 线性渐变取 x,径向渐变取距离
var t: f32 = 0.0;
if (uniforms.paintType == 1u) {
t = gradPos.x; // 线性:只看水平位置
} else if (uniforms.paintType == 2u) {
t = length(gradPos); // 径向:计算到中心的距离
}
为什么线性渐变取 x,不取 y?
因为我们已经通过 gradientTransform 把任意方向的渐变都"旋转"成了水平方向。
| 渐变方向 |
矩阵变换后的效果 |
t 的计算 |
| 从左到右 |
x: 0→1, y: 不变 |
t = x |
| 从上到下 |
原来的 y 变成新的 x |
t = x(实际是原来的 y) |
| 45° 对角 |
对角线方向变成新的 x |
t = x(实际是对角距离) |
如果同时用 x 和 y 会怎样?
// 实验:不同的 t 计算方式
t = gradPos.x; // 水平渐变
t = gradPos.y; // 垂直渐变
t = (gradPos.x + gradPos.y) / 2.0; // 45° 对角渐变(简化版)
t = length(gradPos); // 径向渐变(圆形)
t = max(abs(gradPos.x), abs(gradPos.y)); // "方形"径向渐变
t = gradPos.x * gradPos.y; // 双曲线渐变(艺术效果)
JavaScript 可视化理解:
// 想象一个 10×10 的网格,计算每个格子的 t 值
for (let y = 0; y < 10; y++) {
let row = '';
for (let x = 0; x < 10; x++) {
const t_horizontal = x / 9; // 0, 0.11, 0.22, ... 1
const t_radial = Math.sqrt(x*x + y*y) / 12.7; // 圆形扩散
row += t_horizontal.toFixed(1) + ' ';
}
console.log(row);
}
// 在 stops 数组中找到 t 所在的区间,进行线性插值
for (var i: u32 = 0u; i < 7u; i = i + 1u) {
if (i >= lastIdx) { break; }
let s0 = uniforms.stops[i];
let s1 = uniforms.stops[i+1];
if (t >= s0.position && t <= s1.position) {
let factor = (t - s0.position) / (s1.position - s0.position);
return mix(s0.color, s1.color, factor); // GPU 内置的线性插值
}
}
JavaScript 等价代码:
function interpolate(t, stops) {
for (let i = 0; i < stops.length - 1; i++) {
if (t >= stops[i].position && t <= stops[i+1].position) {
const factor = (t - stops[i].position) / (stops[i+1].position - stops[i].position);
return lerpColor(stops[i].color, stops[i+1].color, factor);
}
}
}
GPU 中的控制语句
WGSL 支持常见的控制语句,但性能特性与 JavaScript 完全不同:
| JavaScript |
WGSL |
说明 |
if (cond) { A } else { B } |
if (cond) { A } else { B } |
语法相同,但性能代价大 |
for (let i = 0; i < n; i++) |
for (var i: u32 = 0u; i < n; i = i + 1u) |
需要显式类型 |
while (cond) { } |
while (cond) { } |
相同 |
switch (x) { case 1: ... } |
switch (x) { case 1: { ... } default: { } } |
每个分支必须有 {}
|
break / continue
|
break / continue
|
相同 |
return |
return |
相同 |
⚠️ 为什么 if 在 GPU 中代价大?
GPU 的并行模型要求同一组线程(Warp/Wave)执行相同的指令。当遇到分支时:
if (condition) {
A(); // 部分线程执行这里
} else {
B(); // 部分线程执行这里
}
实际发生的是:所有线程都执行 A 和 B,但结果被掩码丢弃。这叫做 Thread Divergence(线程分化) 。
性能优化替代方案:
// ❌ 分支写法(两组线程各等待对方)
if (isHovered) {
color = hoverColor;
} else {
color = normalColor;
}
// ✅ 无分支写法(所有线程同时完成)
color = mix(normalColor, hoverColor, f32(isHovered));
// ✅ step 函数(阶跃函数,常用于边界判断)
// step(edge, x): x < edge 返回 0.0,否则返回 1.0
let mask = step(0.5, t); // t < 0.5 时 mask=0,否则 mask=1
color = mix(colorA, colorB, mask);
// ✅ clamp + smoothstep(平滑过渡)
let t_clamped = clamp(t, 0.0, 1.0); // 限制 t 在 [0, 1] 范围
let t_smooth = smoothstep(0.0, 1.0, t); // 平滑的 S 曲线插值
何时可以用 if?
- 条件对所有像素相同(如
if (uniforms.paintType == 1u))—— 无分化,放心用
- 分支内代码很短 —— 分化代价可接受
- 无法用数学替代的复杂逻辑 —— 只能用
if
5. GPU 编程的"反直觉"特性
5.1 没有 console.log
在 Shader 中,你不能打印日志。这是最让 CPU 程序员抓狂的地方。
调试核心思路:将关键变量的值或分支执行情况编码成可识别的颜色输出到屏幕上。
调试技巧:用颜色编码变量值
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
// ===== 技巧 1:输出坐标值 =====
// 将坐标映射为颜色,检查坐标是否正确
// return vec4<f32>(input.localPos.x / 200.0, input.localPos.y / 200.0, 0.0, 1.0);
// 预期:左上角黑色,右下角黄色,形成渐变
// ===== 技巧 2:检查分支执行 =====
// 用不同颜色标记代码是否进入了某个分支
// if (uniforms.paintType == 0u) { return vec4<f32>(1.0, 0.0, 0.0, 1.0); } // 纯色 → 红
// if (uniforms.paintType == 1u) { return vec4<f32>(0.0, 1.0, 0.0, 1.0); } // 线性 → 绿
// if (uniforms.paintType == 2u) { return vec4<f32>(0.0, 0.0, 1.0, 1.0); } // 径向 → 蓝
// ===== 技巧 3:检查变量范围 =====
// 将 t 值输出为灰度,检查是否在 [0, 1] 范围内
// let t = gradPos.x;
// return vec4<f32>(t, t, t, 1.0); // 预期:从黑到白的渐变
// 如果全白/全黑 → t 超出范围,坐标变换有问题
// ===== 技巧 4:二分法定位问题 =====
// 在代码中间插入 return,逐步缩小问题范围
// return vec4<f32>(1.0, 0.0, 0.0, 1.0); // 红色检查点
// ... 后续代码
// 正常逻辑...
}
| 输出颜色 |
含义 |
| 纯红色 |
几何体正确绘制,或进入了"纯色"分支 |
| 纯绿色 |
进入了"线性渐变"分支 |
| 纯蓝色 |
进入了"径向渐变"分支 |
| 从黑到白渐变 |
t 值从 0 到 1 正常变化 |
| 全白 |
t 值始终 ≥ 1,坐标变换可能缩放错误 |
| 全黑 |
t 值始终 ≤ 0,坐标变换可能偏移错误 |
| 紫色(红+蓝) |
进入了未知分支或错误路径 |
5.2 数据传输:理解"批量"与"Draw Call"
在 JavaScript 中,你可以随时修改变量:
color = 'red';
draw();
color = 'blue';
draw();
在 GPU 编程中,这涉及两个层面的理解:
层面 1:单次 Draw Call 内的数据共享
一次 draw() 调用会绘制一个(或一批)图形。在这次绘制中,所有像素共享同一份 Uniform 数据。
如果你想画两个不同颜色的矩形,最简单的方式是:
// 方式 1:两次 Draw Call(伪代码)
setUniform({ color: 'red' }); // 内部调用 device.queue.writeBuffer()
draw(rect1Vertices); // 第一次绘制
setUniform({ color: 'blue' }); // 再次调用 device.queue.writeBuffer()
draw(rect2Vertices); // 第二次绘制
💡 setUniform 的本质
setUniform 不是 WebGPU 的原生 API,而是对以下操作的封装:
function setUniform(data) {
// 1. 将 JS 对象转换为二进制数据(按 std140 对齐)
const buffer = packToFloat32Array(data);
// 2. 通过 WebGPU API 将数据从 CPU 内存拷贝到 GPU 内存
device.queue.writeBuffer(uniformBuffer, 0, buffer);
}
device.queue.writeBuffer() 是真正触发 CPU→GPU 数据传输的 API。每次调用都会产生一定的开销(内存拷贝 + 驱动调用),这就是为什么要尽量减少调用次数。
层面 2:批量绘制优化
多个图形当然可以批量发送! 这正是性能优化的关键。常见方案:
方案 A:Dynamic Uniform Buffer(动态偏移)
// 将所有图形的数据打包到一个大 Buffer
const bigBuffer = new Float32Array([
...rect1Transform, ...rect1Color, // 偏移 0
...rect2Transform, ...rect2Color, // 偏移 384
...rect3Transform, ...rect3Color, // 偏移 768
]);
device.queue.writeBuffer(uniformBuffer, 0, bigBuffer);
// 一次性发送,通过偏移切换数据
for (let i = 0; i < 3; i++) {
passEncoder.setBindGroup(0, bindGroup, [i * 384]); // 动态偏移
passEncoder.draw(...);
}
方案 B:实例化渲染(Instancing)—— 终极批量
// 将变换矩阵放入 Storage Buffer
const transforms = new Float32Array([...所有图形的矩阵]);
// 一次 Draw Call 绘制 1000 个图形!
passEncoder.draw(vertexCount, instanceCount: 1000);
// Shader 中通过 instance_index 获取自己的数据
@vertex
fn vs_main(@builtin(instance_index) instanceIdx: u32, ...) {
let myTransform = transforms[instanceIdx];
}
性能对比:
| 方式 |
1000 个矩形的 Draw Call 数 |
适用场景 |
| 朴素方式 |
1000 |
原型开发 |
| Dynamic Uniform |
1000(但切换更快) |
不同形状、不同材质 |
| Instancing |
1 |
大量相同形状 |
5.3 矩阵是"列主序"
JavaScript 思维(行主序):
const matrix = [
[a, b, c], // 第一行
[d, e, f], // 第二行
[g, h, i], // 第三行
];
GPU/WebGPU 思维(列主序):
const buffer = new Float32Array([
a, d, g, // 第一列
b, e, h, // 第二列
c, f, i, // 第三列
]);
🔥 这是最常见的坑:如果你的图形位置完全错误或消失,80% 是矩阵存储顺序的问题。
6. 快速参考:类型对照表
| JavaScript |
WGSL |
大小(字节) |
对齐要求 |
number |
f32 |
4 |
4 |
number (整数) |
u32 / i32
|
4 |
4 |
[x, y] |
vec2<f32> |
8 |
8 |
[x, y, z] |
vec3<f32> |
12 |
16 ⚠️ |
[r, g, b, a] |
vec4<f32> |
16 |
16 |
| 3x3 矩阵 |
mat3x3<f32> |
36 |
48 ⚠️ |
| 4x4 矩阵 |
mat4x4<f32> |
64 |
64 |
7. 总结:心智模型迁移清单
| CPU 思维 |
GPU 思维 |
| 循环遍历所有像素 |
每个像素独立运行相同代码 |
console.log 调试 |
用颜色输出变量值 |
| 随意访问全局变量 |
数据打包成 Buffer 发送 |
| 结构体大小 = 字段大小之和 |
必须考虑对齐(16 字节边界) |
| 行主序矩阵 |
列主序矩阵 |
if/else 分支随意写 |
分支会降低性能(所有线程等待) |
掌握这些核心差异,你就能从 JavaScript 程序员平滑过渡到 Shader 开发者。接下来,建议你动手修改 shader.wgsl 中的代码,用"颜色调试法"亲身体验 GPU 编程的独特魅力!
更多精彩内容可关注风起的博客,微信公众号:听风说图