Skip to main content

16. WGSL vs GLSL 思维模型差异

GLSL 学完后,WGSL 是绕不开的下一站。但很多教程一上来就甩语法对比表,忽略了 WGSL 和 GLSL 真正不同的不是语法,而是思维模型——绑定方式、内存布局、入口函数、纹理抽象都换了。这篇不讲 WGSL 完整语法(那是 spec 的事),只讲“换语言时脑子要转的弯”

读完应当能读懂任意 WGSL 着色器代码,并理解它和 GLSL 端代码的对应关系。

1. 入口函数:从 void main()@vertex / @fragment

GLSL 通过 Shader 类型隐式选择入口:

// GLSL:靠文件位置决定是顶点还是片段着色器
attribute vec3 position;
varying vec2 vUv;
void main() {
gl_Position = vec4(position, 1.0);
vUv = uv;
}

WGSL 通过属性标注显式声明:

@vertex
fn vs_main(input: VertexIn) -> VertexOut {
// ...
}

@fragment
fn fs_main(input: VertexIn) -> FragmentOut {
// ...
}

@compute
fn cs_main(@builtin(global_invocation_id) id: vec3<u32>) {
// ...
}

思维差异:GLSL 一个文件 = 一个 Shader,靠材质类型挂载。WGSL 一个文件可以同时包含顶点/片段/计算三个入口,通过 @vertex / @fragment / @compute 标注区分。Three.js 的 WGSL 输出文件就是这种结构。

2. 数据传递:从 attribute/varying/uniformstruct + @location

这是反直觉点 Top 1。GLSL 时代你熟悉的:

// GLSL
attribute vec3 position; // 顶点属性
attribute vec2 uv; // 顶点属性
varying vec2 vUv; // 顶→片
uniform mat4 mvp; // 全局 uniform

在 WGSL 里全部不存在。取而代之的是 struct + 注解

struct VertexIn {
@location(0) position: vec3<f32>, // 顶点属性
@location(1) uv: vec2<f32>, // 顶点属性
}

struct VertexOut {
@builtin(position) clip_pos: vec4<f32>, // 屏幕位置(顶→片内置)
@location(0) uv: vec2<f32>, // 顶→片自定义
}

@group(0) @binding(0) var<uniform> mvp: Matrices; // 全局 uniform

思维差异

GLSL 概念WGSL 对应
attributestruct 成员 + @location(N)
varying顶点和片段各定义一个 struct同名位置 @location 对应
gl_Position@builtin(position) 标注的 struct 成员
gl_FragColor@location(0) 标注的 struct 成员
uniform Xvar<uniform> X: TypeStruct; + @group/@binding

ASCII 对应关系:

GLSL                              WGSL
─────────────────────────────────────────────────
attribute vec3 position → struct VertexIn { @location(0) position: vec3<f32> }
varying vec2 vUv → struct VertexOut { @location(0) uv: vec2<f32> }
uniform mat4 mvp → @group(0) @binding(0) var<uniform> mvp: Matrices
gl_Position → @builtin(position) clip_pos: vec4<f32>

避坑@location(N)顶点 struct片段 struct 里的 N 是各自编号。例如顶点 struct 写 @location(0) uv,片段 struct 也要写 @location(0) uv不代表它们用了同一个寄存器,只是表达了“我想传过去”的语义。真正的数据拼接由渲染管线(render pipeline)按 location 编号自动配对。

3. 绑定模型:从“推数据”到“资源槽”

GLSL 是“我需要什么 uniform 就在 Shader 里声明什么”:

uniform sampler2D uTexture;
uniform vec3 uColor;

WGSL 走“资源槽位”模型,所有外部资源通过 @group/@binding 显式占据一个槽:

@group(0) @binding(0) var<uniform>        mvp:     Matrices;
@group(0) @binding(1) var u_sampler: sampler; // 采样器
@group(0) @binding(2) var u_texture: texture_2d<f32>; // 纹理

为什么这么设计:WebGPU 把“资源”和“采样器彻底解耦。一个纹理可以配不同的 sampler(线性/最近邻),Sampler 可以跨 Shader 复用。GLSL 里 sampler2D 是绑定死的,想换采样方式得重新创建纹理。

Three.js 怎么用

// Three.js WebGPU 端典型用法
import { texture, uniform, vec3 } from 'three/tsl';

const myTexture = texture(loader.load('foo.png')); // 自动分配 binding
material.colorNode = vec3(1, 0, 0).mul(myTexture);

texture() 这个工厂函数内部自动分配 @group(0) @binding(N)你不用管 binding 编号

4. 内存模型:显式 var<uniform> vs var<storage>

GLSL 没有显式内存模型概念,uniform 块、SSBO 都是"声明时告诉驱动怎么布局":

layout(std140) uniform Globals {
mat4 mvp;
float time;
};

WGSL 把内存类型写进语法:

@group(0) @binding(0) var<uniform> globals: Globals;   // 只读,高速
@group(1) @binding(0) var<storage> positions: array<vec3<f32>>; // 可读写,计算着色器用
@group(2) @binding(0) var<storage, read_write> velocities: array<vec3<f32>>;

关键差异

  • var<uniform>:只读,对齐严格(16 字节),适合每帧更新的 MVP/相机参数
  • var<storage>:可读写,计算着色器专用,是 GPGPU 的核心
  • 纹理不属于 storage/uniform,是独立类别

ASCII 内存视图:

GPU 内存
┌─────────────────────────────────────┐
│ var<uniform> │ ← 高速缓存友好,CPU 每帧 update
├─────────────────────────────────────┤
│ var<storage> read_write │ ← 计算着色器自由读写(GPGPU)
├─────────────────────────────────────┤
│ texture / sampler │ ← 独立寻址
└─────────────────────────────────────┘

5. 纹理采样:函数名和参数顺序

GLSL 时代 texture2D(sampler2D, uv) 一个函数搞定:

vec4 c = texture2D(uTexture, vUv);

WGSL 拆成两步:textureSample(t, s, uv)纹理和采样器作为两个参数

@group(0) @binding(0) var t_diffuse: texture_2d<f32>;
@group(0) @binding(1) var s_linear: sampler;

@fragment
fn fs_main(in: FragmentIn) -> @location(0) vec4<f32> {
return textureSample(t_diffuse, s_linear, in.uv);
}

避坑:WGSL 里必须显式传 samplertextureSampleLevel 是带 mipmap 等级的版本,参数再多一个 f32 level。GLSL 的 texture2DLod 对应它。

6. 类型:泛型 <f32> 后缀

GLSL 类型是单一精度的:

vec3 a;        // 默认 float
ivec3 b; // int

WGSL 必须显式标注精度:

var a: vec3<f32>;       // float
var b: vec3<i32>; // int
var c: vec3<u32>; // uint

这一改动看起来啰嗦,但好处是显式可控——在移动端可以选 f16 节省带宽(虽然目前 WGSL 编译器支持还在完善)。

7. 控制流:不能动态数组长度

WGSL 相比 GLSL 多了几条硬约束

// 错误:动态数组长度
fn bad(n: i32) {
var arr: array<f32, n>; // 编译错误
}

// 正确:常量或 uniform 长度
fn good(@builtin(num_workgroups) n: vec3<u32>) {
var arr: array<f32, 16>;
}
// 错误:不能把可变变量索引当作常量
var idx: i32 = 0;
arr[idx] = 1.0; // OK

// 错误:循环边界必须是常量表达式
for (var i: i32 = 0; i < dynamicN; i = i + 1) { // 错误
...
}

为什么这么严:WGSL 在编译期就要知道所有内存访问模式,好让 GPU 做并行优化。GLSL 因为不限制,编译器在某些情况下只能放弃优化。

8. 对照表:GLSL → WGSL 速查

下面是常见语法的对照表,存一份备用

用途GLSLWGSL
顶点属性attribute vec3 position;struct VertexIn { @location(0) position: vec3<f32> }
顶→片varying vec2 vUv;顶点/片段 struct 都写 @location(0) uv: vec2<f32>
屏幕位置gl_Position@builtin(position) clip_pos: vec4<f32>
片段输出gl_FragColorstruct FragmentOut { @location(0) color: vec4<f32> }
Uniformuniform mat4 mvp;@group(0) @binding(0) var<uniform> mvp: Matrices;
纹理采样texture2D(t, uv)textureSample(t, s, uv)
浮点常量1.01.0(必须带小数点)
入口函数void main() {}@vertex fn main(in: VertexIn) -> VertexOut {}
计算入口N/A(要 WebGL2 + SSBO)@compute @workgroup_size(64) fn main(...)
类型标注vec3 a;var a: vec3<f32>;
const 限定符const float PI = 3.14;const PI: f32 = 3.14;

9. WebGPU 的另一面:没有 gl_FragCoord.z 那一套

WebGL 里 gl_FragCoordgl_FrontFacing 等内置变量是默认存在的,WGSL 全部要走 @builtin(...) 显式申领

struct FragmentIn {
@builtin(position) coord: vec4<f32>, // 对应 gl_FragCoord
@builtin(front_facing) is_front: bool, // 对应 gl_FrontFacing
@builtin(sample_index) sample_idx: u32, // MSAA 用
}

避坑:忘了写 @builtin(position) 想拿屏幕坐标,会得到“screen 上位置未定义”的奇怪结果,而不是编译错误。Three.js 在大多数场景下已经把 positionNode 帮你处理好了,自己写原生 WGSL 时要小心

10. 总结

  • WGSL 和 GLSL 的真正差异不在语法,而在绑定模型内存模型入口函数标注
  • attribute/varying/uniform 全部消失,统一替换为 struct + @location/@builtin + var<uniform>/var<storage> + @group/@binding
  • 纹理和采样器解耦,函数 textureSample(t, s, uv) 显式传两个
  • 精度必须显式标注(vec3<f32> 而不是 vec3
  • 控制流比 GLSL 更严格(动态数组长度禁止),这是为并行优化服务的
  • Three.js 用户基本不直接写 WGSL,TSL 抽象了这一切——见 17 篇

思考与练习

  1. 把 05 篇的波浪 Shader 改写成 WGSL 入口函数 + struct 输入输出,不要借助 TSL
  2. 用 WGSL 写一个 @compute 入口,把 array<f32> 的所有元素乘以 2。
  3. 解释为什么 WGSL 要求 var<uniform> 的成员按 16 字节对齐,这和 GLSL std140 有什么关系
  4. 调研题:阅读 WGSL Spec 的 Memory Model 章节,找出 WGSL 是怎么处理“同一计算着色器里两个 workgroup 写同一块 storage”这种竞态的。