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/uniform 到 struct + @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 对应 |
|---|---|
attribute | struct 成员 + @location(N) |
varying | 顶点和片段各定义一个 struct,同名位置 @location 对应 |
gl_Position | @builtin(position) 标注的 struct 成员 |
gl_FragColor | @location(0) 标注的 struct 成员 |
uniform X | var<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 里必须显式传 sampler。
textureSampleLevel是带 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 速查
下面是常见语法的对照表,存一份备用:
| 用途 | GLSL | WGSL |
|---|---|---|
| 顶点属性 | 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_FragColor | struct FragmentOut { @location(0) color: vec4<f32> } |
| Uniform | uniform mat4 mvp; | @group(0) @binding(0) var<uniform> mvp: Matrices; |
| 纹理采样 | texture2D(t, uv) | textureSample(t, s, uv) |
| 浮点常量 | 1.0 | 1.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_FragCoord、gl_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 篇
思考与练习
- 把 05 篇的波浪 Shader 改写成 WGSL 入口函数 + struct 输入输出,不要借助 TSL。
- 用 WGSL 写一个
@compute入口,把array<f32>的所有元素乘以 2。 - 解释为什么 WGSL 要求
var<uniform>的成员按 16 字节对齐,这和 GLSLstd140有什么关系? - 调研题:阅读 WGSL Spec 的 Memory Model 章节,找出 WGSL 是怎么处理“同一计算着色器里两个 workgroup 写同一块 storage”这种竞态的。