WGSL 着色器语言
WGSL 的来由
WebGPU 的目标是要在各个现代底层图形 API 之上抽象出一套统一的图形 API,而每个底层图形 API 后端都有自己的着色语言:
- DirectX 使用 HLSL (High Level Shading Language)
- Metal 使用 MSL (Metal Shading Language)
- OpenGL 使用 GLSL (OpenGL Shading Language)
- Vulkan 使用的着色语言又跟之前的图形 API 都不同,它的着色器必须以 SPIR-V 这种二进制字节码的格式提供(有一些库能提供将其它语言编写的着色器编译为 SPIR-V 的能力,比如 shaderc )。
在 WGSL (WebGPU Shading Language) 出现之前,很多开发者或团队是通过宏及各种转译工具来将自己的着色器编译到不同目标平台的,他们自然是希望有一个标准化的统一语言。
WebGPU 成员花了 2 年半的时间来争论 WebGPU 是否应该有自己的着色语言。kvark 将这场争论中的核心论点组成了 一张流图 ,它是 SVG 格式的,支持在网页中无损放大查看。
WGSL 的目标不是要与 GLSL 兼容,它是对现代着色器语言的重新设计。
2020 年 4 月 27 日,WGSL 标准有了第一次提交。自此开始,wgpu 和 dawn 都摆脱了对 shaderc 之类复杂繁重的着色器转译工具的依赖。wgpu 里使用的 WGSL 转译工具叫 naga , kvark 有一篇博客( Shader translation benchmark )对比了 naga 相比于其它转译工具的性能优化,总体来说,有 10 倍以上的性能优势。
2023 年之前,WGSL 的学习资源不多,唯一好的参考是 WGSL 规范 ,但它是对语言实现细节的规范,对普通用户来说有点难以理解。 我从 2018 年开始使用 wgpu (那时还是 使用 GLSL 做为着色器语言),2021 年底完成了个人作品 字习 Pro 及其他几个练手作品从 GLSL 到 WGSL 的 100 多个着色器的移植工作,在这个过程中对这两个着色器语言有了比较深入的了解。这个增补章节旨在介绍 WGSL 的一些基础知识,希望这对从 OpenGL / WebGL 迁移到 WebGPU 的朋友带来一点有益的经验(下边的所有 GLSL 代码均是按照 GLSL450 标准编写的)。
增补两个网上新出现的学习资源:
一个简单的绘制着色器:对比 GLSL
GLSL 的绘制着色器:
下边是使用 WGSL 的等价实现,在 WGSL 中,我们通常将顶点着色器与片元着色器写在同一个文件中:
计算着色器:继续对比 GLSL
GLSL 的计算着色器, 实现在 x 轴上的高斯模糊:
WGSL 版本的对等实现:
你应该注意到了很多差异,比如:
-
顶点、片元、计算着色器的
入口函数
(WebGPU 中叫
入口点
Entry Point
)声明方式差异; - 计算着色器 工作组 (Workgroup)大小的声明方式差异;
- 许多细节必须硬编码,例如输入和输出的特定位置;
- 结构体的使用差异;
- ...
总体上 WGSL 代码要比 GLSL 明晰得多。这是 WGSL 的一大优点,几乎所有内容都具有明确的 自说明 特性。 下边我们来深入了解一些关键区别。
入口点
WGSL 没有强制使用固定的
main()
函数作为
入口点
(
Entry Point
),它通过
@vertex
、
@fragment
、
@compute
三个
着色器阶段
(Shader State)标记提供了足够的灵活性让开发人员能更好的组织着色器代码。你可以给入口点取任意函数名,只要不重名,还能将所有阶段(甚至是不同着色器的同一个阶段)的代码组织同一个文件中:
工作组
计算着色器中,一个 工作组 (Workgroup)就是一组调用,它们同时执行一个计算着色器阶段 入口点 ,并共享对工作组地址空间中着色器变量的访问。可以将 工作组 理解为一个三维网格,我们通过(x, y, z)三个维度来声明当前计算着色器的工作组大小,每个维度上的默认值都是 1。
WGSL 声明工作组大小的语法相比 GLSL 简洁明了:
Group 与 Binding 属性
WGSL 中每个资源都使用了
@group(X)
和
@binding(X)
属性标记,例如
@group(0) @binding(0) var<uniform> params: UniformParams
它表示的是 Uniform buffer 对应于哪个绑定组中的哪个绑定槽(对应于 wgpu API 调用)。这与 GLSL 中的
layout(set = X, binding = X)
布局标记类似。WGSL 的属性非常明晰,描述了着色器阶段到结构的精确二进制布局的所有内容。
变量声明
WGSL 对于基于显式类型的 var 的变量声明有不同的语法。
WGSL 没有像
lowp
这样的精度说明符, 而是显式指定具体类型,例如
f32
(32 位浮点数)。如果要使用
f16
类型,需要在你的 WebGPU 程序中开启
shader-f16
扩展(wgpu 中目前已经加入了此扩展,但是 naga 中还没有完全实现对
f16
的支持)。
WGSL 支持自动类型推断。因此,如果在声明变量的同时进行赋值,就不必指定类型:
WGSL 中的
var
let
关键字与 Swift 语言一样:
-
var
表示变量可变或可被重新赋值(与 Rust 中的let mut
一样); -
let
表示变量不可变,不能重新赋值;
结构体
在 WGSL 中, 结构体 (struct)用于表示 Unoform 及 Storage 缓冲区 以及着色器的输入和输出。Unoform 缓冲区与 GLSL 类似,Storage 缓冲区虽然也在 GLSL 中存在等价物,但是 WebGL 2.0 并不支持。
WGSL 结构体字段对齐规则也与 GLSL 几乎一致,想要了解更多细节,可查看 WGSL 规范中的字节对齐规则示例 :
注意到上面 Unoform 缓冲区在声明及使用上的两个区别了吗?
- WGSL 需要先定义结构体然后才能声明绑定,而 GLSL 可以在声明绑定的同时定义(当然也支持先定义);
- WGSL 里需要用声明的变量来访问结构体字段,而 GLSL 里是直接使用结构体中的字段;
WGSL 的
输入和输出结构体
比较独特,在 GLSL 中没有对应物。
入口函数
接受输入结构,返回输出结构,并且结构体的所有字段都有
location(X)
属性注释。 如果只有单个输入或输出,那使用结构体就是可选的。
这种明确定义输入和输出的方式,使得 WGSL 的代码逻辑更加清晰,明显优于在 GLSL 中给魔法变量赋值的方式。
下边是一个顶点着色器的输出结构体(同时它也是对应的片元着色器的输入结构体):
-
@builtin(position)
内建属性 标记的字段对应着 GLSL 顶点着色器中的gl_Position
内建字段。 -
@location(X)
属性标记的字段对应着 GLSL 顶点着色器中的layout(location = X) out ...
以及片元着色中的layout(location = X) in ...
;
WGSL 不再需要像 GLSL 一样,在顶点着色器中定义完输出字段后,再到片元着色器中定义相应的输入字段。
函数语法
WGSL 函数语法与 Rust 一致, 而 GLSL 是类 C 语法。一个简单的
add
函数如下:
纹理
采样纹理
WGSL 中
采样纹理
总是要指定
纹素
(Texel)的数据类型
texture_2d<T>
、
texture_3d<T>
、
texture_cube<T>
、
texture_cube_array<T>
(T 必须是 f32、i32、u32 这三种类型之一),而 GLSL 中是没有纹素类型信息的,只有查看使用此着色器的程序源码才能知道:
Storage 纹理
WGSL 中
存储纹理
的数据类型为
texture_storage_XX<T, access>
, 而 GLSL 中没有明确的存储纹理类型,如果需要当做存储纹理使用,就需要在
layout(...)
中标记出
纹素
格式:
在目前的 WebGPU 标准中, 存储纹理的
access
只能为
write
(只写), wgpu 能在 native 中支持
read_write
(可读可写)。
更多 WGSL 语法细节
三元运算符
GLSL 支持三元运算符
? :
, WGSL 并不直接支持,但提供了内置函数
select(falseValue,trueValue,condition)
:
花括号
WGSL 中的 if else 语法不能省略大括号(与 Rust 及 Swift 语言一样):
求模运算
GLSL 中我们使用
mod
函数做求模运算,WGSL 中有一个长得类似的函数
modf
, 但它的功能是将传入参数分割为小数与整数两部分。在 WGSL 中需要使用
%
运算符来求模, 且
mod
与
%
的工作方式还略有不同,
mod
内部使用的是 floor (
x - y * floor(x / y)
), 而
%
内部使用的是 trunc (
x - y * trunc(x / y)
):
着色器预处理
听到过很多人抱怨 WGSL 不提供预处理器,但其实所有的着色器语言都不自己提供预处理,只是我们可能已经习惯了使用已经封装好预处理逻辑的框架。
其实自己写一个预处理逻辑也是非常简单的事,有两种实现预处理的机制:
- 着色器被调用时实时预处理(对运行时性能会产生负影响);
-
利用
build.rs
在程序编译阶段预处理,并磁盘上生成预处理后的文件;
这两种实现方式的代码逻辑其实是一样的,仅仅是预处理的时机不同。
下边是一个需要预处理的实现了边缘检测的片元着色器:
///#include
后面的路径分指向的是
common
及
func
目录下已经实现好的通用顶点着色器与边缘检测函数,我们现在按第 2 种机制实现一个简单的预处理来自动将顶点着色器及边缘检测函数包含进来: