Skip to content
风起
风起

从 JavaScript 到 WGSL:用渐变渲染理解 GPU 编程思维

本文通过一个真实的渐变渲染案例,帮助习惯 JavaScript/TypeScript 的 CPU 侧程序员快速建立 GPU Shader 编程的心智模型。

引言:为什么要学 Shader?

作为前端或后端开发者,我们习惯了"顺序执行"的编程思维——代码从上到下一行行执行,循环遍历数组,逐个处理数据。但当你需要渲染成千上万个像素时,这种方式太慢了。

GPU 的核心优势是并行:它可以同时处理数千个像素,每个像素独立运行相同的代码(Shader)。理解这一点,是从 CPU 编程迁移到 GPU 编程的关键。


1. 思维转换:从"循环"到"并行"

JavaScript 思维(CPU)

假设你要给一个 200×200 的矩形填充渐变色,在 JS 中你可能会这样写:

javascript
// 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 会自动为每个像素启动一个独立的"线程",每个线程只负责计算自己那一个像素的颜色:

wgsl
@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 等价实现

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 可以优雅地替代某些条件判断:

wgsl
// ❌ 有分支的写法
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 次
数据访问可以访问任意像素只知道"我自己"的坐标
返回值调用 setPixelreturn 颜色值

💡 类比:想象你是工厂里的一个工人(GPU 线程),你只负责给传送带上经过你面前的那一个产品上色。你不知道也不关心其他工人在干什么,你只需要知道"我这个产品应该是什么颜色"。


2. Shader 的两个阶段:Vertex 和 Fragment

GPU 渲染管线主要分两步,对应两种 Shader:

2.1 Vertex Shader(顶点着色器)—— "形状在哪里?"

wgsl
@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 类比

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);  // 组合变换

核心优势

  1. 顶点数据可复用 —— 同一个正方形的顶点数据可以被缓存,画 1000 个正方形只需要传 1000 个不同的矩阵
  2. 变换可组合 —— 父子节点的变换通过矩阵乘法自动传递
  3. GPU 友好 —— 矩阵乘法是 GPU 最擅长的运算

2.2 Fragment Shader(片元着色器)—— "像素是什么颜色?"

wgsl
@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:全局只读数据

wgsl
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)

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 数据结构

wgsl
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 字节对齐?

这是硬件架构决定的,主要原因有三:

  1. 内存读取效率:GPU 的内存控制器按 16 字节(128 位)为单位读取数据。如果数据跨越两个 16 字节边界,需要两次内存访问,性能直接减半。

  2. SIMD 指令集:GPU 使用 SIMD(单指令多数据)架构,一条指令同时处理 4 个 float(正好 16 字节)。对齐的数据可以直接加载到寄存器,不对齐则需要额外的移位操作。

  3. 缓存行优化:GPU 缓存行通常是 128 字节或 256 字节。16 字节对齐确保数据不会横跨缓存行,避免缓存失效。

JavaScript 类比

javascript
// 想象你有一个只能每次搬 4 瓶水的托盘(16 字节)
// ❌ 不对齐:3 瓶水放第一托盘,1 瓶放第二托盘 → 搬 4 瓶要跑两趟
// ✅ 对齐:4 瓶水放一个托盘,空位用泡沫填充 → 一趟搞定

std140 布局规则速记

类型实际大小对齐到说明
f3244
vec288
vec31216浪费 4 字节
vec41616
struct字段之和最大字段对齐 × 整数倍向上取整

4.2 坐标变换

从像素坐标到渐变参数 t 的转换是渐变的核心:

wgsl
// 将像素坐标 (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/widtht = gradPos.x
垂直(上→下)旋转 90° + 归一化t = gradPos.x(原来的 y 变成了 x)
45° 斜向旋转 45° + 归一化t = gradPos.x
镜像渐变缩放 x 为 2 倍 + 偏移t = abs(gradPos.x) 或矩阵实现

JavaScript 类比理解

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 逻辑

wgsl
// 将 t 从 [0, 1] 映射为 [0, 1, 0](三角波)
let t_mirror = 1.0 - abs(gradPos.x * 2.0 - 1.0);

方法 2:通过矩阵实现(更灵活)

javascript
// CPU 侧:构造一个"折叠"矩阵
// 将 [0, 0.5] 映射到 [0, 1],[0.5, 1] 映射到 [1, 0]

这样设计的优势

  1. Shader 代码简洁 —— 无论什么方向的渐变,Shader 里永远是 t = gradPos.x
  2. 设计工具友好 —— Figma 导出的 gradientTransform 可以直接使用
  3. 可组合 —— 旋转、缩放、镜像可以通过矩阵乘法任意组合

4.3 颜色插值

wgsl
// 线性渐变取 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 变成新的 xt = x(实际是原来的 y)
45° 对角对角线方向变成新的 xt = x(实际是对角距离)

如果同时用 x 和 y 会怎样?

wgsl
// 实验:不同的 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 可视化理解

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);
}
wgsl
// 在 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 等价代码

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 完全不同

JavaScriptWGSL说明
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 / continuebreak / continue相同
returnreturn相同

⚠️ 为什么 if 在 GPU 中代价大?

GPU 的并行模型要求同一组线程(Warp/Wave)执行相同的指令。当遇到分支时:

wgsl
if (condition) {
    A();  // 部分线程执行这里
} else {
    B();  // 部分线程执行这里
}

实际发生的是:所有线程都执行 A 和 B,但结果被掩码丢弃。这叫做 Thread Divergence(线程分化)

性能优化替代方案

wgsl
// ❌ 分支写法(两组线程各等待对方)
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 程序员抓狂的地方。

调试核心思路:将关键变量的值分支执行情况编码成可识别的颜色输出到屏幕上。

调试技巧:用颜色编码变量值

wgsl
@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 中,你可以随时修改变量:

javascript
color = 'red';
draw();
color = 'blue';
draw();

在 GPU 编程中,这涉及两个层面的理解:

层面 1:单次 Draw Call 内的数据共享

一次 draw() 调用会绘制一个(或一批)图形。在这次绘制中,所有像素共享同一份 Uniform 数据

如果你想画两个不同颜色的矩形,最简单的方式是:

javascript
// 方式 1:两次 Draw Call(伪代码)
setUniform({ color: 'red' });   // 内部调用 device.queue.writeBuffer()
draw(rect1Vertices);            // 第一次绘制

setUniform({ color: 'blue' });  // 再次调用 device.queue.writeBuffer()
draw(rect2Vertices);            // 第二次绘制

💡 setUniform 的本质

setUniform 不是 WebGPU 的原生 API,而是对以下操作的封装:

javascript
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(动态偏移)

javascript
// 将所有图形的数据打包到一个大 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)—— 终极批量

javascript
// 将变换矩阵放入 Storage Buffer
const transforms = new Float32Array([...所有图形的矩阵]);

// 一次 Draw Call 绘制 1000 个图形!
passEncoder.draw(vertexCount, instanceCount: 1000);
wgsl
// Shader 中通过 instance_index 获取自己的数据
@vertex
fn vs_main(@builtin(instance_index) instanceIdx: u32, ...) {
    let myTransform = transforms[instanceIdx];
}

性能对比

方式1000 个矩形的 Draw Call 数适用场景
朴素方式1000原型开发
Dynamic Uniform1000(但切换更快)不同形状、不同材质
Instancing1大量相同形状

5.3 矩阵是"列主序"

JavaScript 思维(行主序):

javascript
const matrix = [
    [a, b, c],  // 第一行
    [d, e, f],  // 第二行
    [g, h, i],  // 第三行
];

GPU/WebGPU 思维(列主序):

javascript
const buffer = new Float32Array([
    a, d, g,  // 第一列
    b, e, h,  // 第二列
    c, f, i,  // 第三列
]);

🔥 这是最常见的坑:如果你的图形位置完全错误或消失,80% 是矩阵存储顺序的问题。


6. 快速参考:类型对照表

JavaScriptWGSL大小(字节)对齐要求
numberf3244
number (整数)u32 / i3244
[x, y]vec2<f32>88
[x, y, z]vec3<f32>1216 ⚠️
[r, g, b, a]vec4<f32>1616
3x3 矩阵mat3x3<f32>3648 ⚠️
4x4 矩阵mat4x4<f32>6464

7. 总结:心智模型迁移清单

CPU 思维GPU 思维
循环遍历所有像素每个像素独立运行相同代码
console.log 调试用颜色输出变量值
随意访问全局变量数据打包成 Buffer 发送
结构体大小 = 字段大小之和必须考虑对齐(16 字节边界)
行主序矩阵列主序矩阵
if/else 分支随意写分支会降低性能(所有线程等待)

掌握这些核心差异,你就能从 JavaScript 程序员平滑过渡到 Shader 开发者。接下来,建议你动手修改 shader.wgsl 中的代码,用"颜色调试法"亲身体验 GPU 编程的独特魅力!