Web 高级着色语言

本文介绍了一种新的 Web 图形着色语言,名为 Web 高级着色语言(WHLSL,发音为“whistle”)。该语言受 HLSL 启发,HLSL 是图形应用开发人员中主要的着色语言。它扩展了 HLSL,使其适用于 Web 平台,从而实现安全可靠。它易于阅读和编写,并使用正式技术进行了良好规范。

背景

在过去的几十年里,3D 图形技术发生了显著变化,程序员用于编写 3D 应用程序的 API 也相应地发生了变化。五年前,最先进的图形应用程序会使用 OpenGL 来执行渲染。然而,过去几年中,3D 图形行业出现了一种趋势,转向与真实硬件行为更匹配的更新、更底层的图形框架。2014 年,苹果公司创建了 Metal 框架,让 iOS 和 macOS 应用程序能够充分利用 GPU 的强大功能。2015 年,微软公司创建了 Direct3D 12,这是 Direct3D 的一次重大更新,它允许控制台级别的渲染和计算效率。2016 年,Khronos Group 发布了 Vulkan API,主要用于 Android 平台,提供了类似的优势。

正如 WebGLOpenGL 带到 Web 平台一样,Web 社区也正在寻求将这种新型的底层 3D 图形 API 引入该平台。去年,苹果公司在 W3C 内部成立了 WebGPU 社区组,旨在标准化一种新的 3D 图形 API,该 API 既能提供这些原生 API 的优势,又适用于 Web 环境。这种新的 Web API 可以在 Metal、Direct3D 和 Vulkan 之上实现。所有主要的浏览器厂商都在参与并为标准化工作做出贡献。

这些现代 3D 图形 API 都使用着色器(shaders),WebGPU 也不例外。着色器是利用 GPU 专用架构的程序。特别是,GPU 在繁重的并行数值处理方面优于 CPU。为了利用这两种架构的优势,现代 3D 应用程序采用混合设计,同时使用 CPU 和 GPU 完成不同的任务。通过发挥各自的最佳特性,现代图形 API 为开发者创建复杂、丰富和快速的 3D 应用程序提供了强大的框架。为 Metal 设计的应用程序使用 Metal 着色语言,为 Direct3D 12 设计的应用程序使用 HLSL,为 Vulkan 设计的应用程序使用 SPIR-VGLSL

语言要求

就像它的原生对应物一样,WebGPU 需要一种着色语言。这种语言需要满足使其非常适合 Web 平台的几个要求。

该语言需要是安全的。无论应用程序做什么,着色器必须只能读取或写入来自网页域的数据。如果没有这项保证,恶意网站可能会运行一个着色器,从你屏幕的其他部分甚至原生应用程序中读取像素。

该语言需要有完善的规范。语言规范必须明确规定每一个可能的字符序列是否都是有效程序。与所有其他 Web 格式一样,Web 的着色语言必须精确指定,以保证浏览器之间的互操作性。

该语言还需要有完善的规范,以便它可以作为编译目标。许多渲染团队用自己的内部自定义语言编写着色器,然后交叉编译到所需的任何语言。因此,该语言应该有一套相对较小且明确的语法和类型检查规则,供编译器编写者在生成此语言时参考。

该语言需要可翻译为 Metal 着色语言、HLSL(或 DXIL)和 SPIR-V。这是因为 WebGPU 旨在运行在 Metal、Direct3D 12 和 Vulkan 之上,因此着色器需要能够以这些 API 都能接受的形式表示。

该语言需要是高性能的。开发者之所以想使用 GPU,根本原因就是为了性能。编译器本身需要运行迅速,并且编译器生成的程序需要在真实的 GPU 上高效运行。

该语言需要与 WebGPU API 共同演进。WebGPU 的功能,例如绑定模型和曲面细分模型,与着色语言之间存在深度交互。尽管语言可以独立于 API 进行开发,但在同一论坛中开发 WebGPU API 和着色语言可以确保目标一致,并使开发更加顺畅。

该语言需要易于开发者阅读和编写。这包含两个方面:首先,该语言应该对 GPU 程序员和 CPU 程序员都熟悉。GPU 程序员是重要的客户,因为他们有编写着色器的经验。CPU 程序员也很重要,因为 GPU 越来越多地用于渲染之外的目的,包括机器学习、计算机视觉和神经网络。对于他们来说,该语言应该与熟悉的编程语言概念和语法兼容。

其次,该语言应该是人类可读的。Web 的文化是任何人都可以仅用文本编辑器和浏览器就开始编写网页。这种内容的民主化是 Web 最伟大的优势之一。这种文化创造了丰富的工具和检查器生态系统,修补者只需通过查看源代码(View-Source)即可研究任何网页的工作原理。拥有一个单一的规范的、人类可读的语言将极大地有助于社区采用 WebGPU API。

如今 Web 上使用的所有主要语言都是人类可读的,只有一个例外。WebAssembly 社区组曾预期解析字节码会比解析文本语言性能更好。然而,事实并非如此;对于许多用例,作为 JavaScript 源代码的 Asm.js 仍然比 WebAssembly 更快

同样,使用像 WebAssembly 这样的字节码格式并不能免除浏览器对源代码运行优化遍的需要。每个主要的浏览器在执行前都会对字节码运行优化遍。不幸的是,对更简单编译器的期望从未实现。

社区组内部正在积极辩论这种人类可读的语言是否应该被 API 原生接受,但小组一致认为着色器所用的语言应该易于阅读和编写。

一种新语言?真的吗?

虽然存在许多现有语言,但没有一种是同时考虑到 Web 和现代图形应用程序而设计的,也没有一种能够满足上述要求。在我们描述 WHLSL 之前,让我们先看看一些现有语言。

Metal 着色语言C++ 非常相似,这意味着它拥有位转换和原始指针的所有强大功能。它极其强大;相同的源代码甚至可以为 CPU 和 GPU 编译。将现有的 CPU 端代码移植到 Metal 着色语言非常容易。不幸的是,所有这些强大功能也有一些缺点。在 Metal 着色语言中,例如,你可以编写一个着色器,将指针转换为整数,加上 17,再将其转换回指针,然后解引用。这是一个安全问题,因为它意味着着色器可以访问应用程序地址空间中碰巧存在的任何资源,这与 Web 的安全模型相悖。理论上,有可能指定一种没有原始指针的 Metal 着色语言方言,但指针对于 C 和 C++ 语言来说是如此基础,以至于结果将完全陌生。C++ 也严重依赖未定义行为,因此任何完全指定 C++ 众多特性的努力都不太可能成功。

HLSL 是 Direct3D 着色器所支持的语言。它是目前世界上最流行的实时着色语言,因此也是图形程序员最熟悉的语言。尽管有多种实现,但没有正式规范,这使得创建一致、可互操作的实现变得困难。尽管如此,鉴于 HLSL 的普及性,在 WHLSL 的设计中尽可能地采用其语法是很有价值的。

GLSL 是 WebGL 使用的语言,并被 WebGL 采纳用于 Web 平台。然而,由于 GLSL 编译器之间的不兼容性,实现跨浏览器互操作性极其困难。GLSL 仍有大量安全性和可移植性方面的 Bug 正在调查中。此外,GLSL 正在显现其年代感。它的局限性在于它没有类似指针的对象,也没有可变长度数组的能力。它的输入和输出是带有硬编码名称的全局变量。

SPIR-V 旨在成为开发者实际使用的着色语言的低级通用中间格式。人们不直接编写 SPIR-V;他们使用人类可读的语言,然后使用工具将其转换为 SPIR-V 字节码。

在 Web 上采用 SPIR-V 面临一些挑战。首先,SPIR-V 在设计时并未将安全性作为首要原则,因此不清楚它是否可以修改以满足 Web 的安全要求。分叉 SPIR-V 语言意味着开发者将不得不重新编译他们的着色器,甚至可能被迫重写源代码。此外,浏览器仍然无法信任传入的字节码,并且需要验证程序以确保它们没有执行任何不安全的操作。而且由于 Windows 和 macOS/iOS 不支持 Vulkan,传入的 SPIR-V 仍需要被翻译/编译成另一种语言。奇怪的是,这意味着在这两个平台上,起点和终点都是人类可读的,但中间部分却被混淆,没有任何好处。

其次,SPIR-V 规范的很大一部分存在于被称为“执行环境”的独立文档中。目前 Web 没有 SPIR-V 执行环境,如果没有这些执行环境之一,SPIR-V 的许多关键部分都是未定义的,例如 50 多个可选功能中哪些是受支持的。

第三,许多图形应用程序,例如 Babylon.js,需要在运行时动态修改着色器。使用字节码格式意味着这些应用程序必须包含一个用 JavaScript 编写的编译器,该编译器在浏览器中运行,以便从动态创建的着色器生成字节码。这将显著增加这些网站的体积,并导致更差的性能。

尽管 JavaScript 是 Web 的规范语言,但其特性使其不适合作为着色语言。它的优点之一是灵活性,但这种动态性导致了许多条件判断和分支控制流,而 GPU 并非为高效执行这些而设计。它还具有垃圾回收机制,这对于 GPU 硬件来说绝对不适用。

WebAssembly 是另一种熟悉的可能性,但它也与 GPU 的架构不符。例如,WebAssembly 假设一个单一的动态大小堆,但 GPU 程序操作时需要访问多个动态大小的缓冲区。在不重新编译的情况下,没有一种高性能的方法可以在这两种模型之间进行映射。

因此,在对合适语言进行了相当详尽的搜索之后,我们未能找到一种能够充分满足项目要求的语言。所以,社区组正在创建一种新语言。创建一种新语言是一项艰巨的任务,但我们认为有机会创造出一种采用现代编程语言设计原则并满足我们要求的新事物。

WHLSL

WHLSL 是一种新的着色语言,适用于 Web 平台。它由 W3C 的 WebGPU 社区组开发,该小组正在制定一份规范、一个编译器以及一个CPU 端解释器,以验证其正确性。

该语言基于 HLSL,但对其进行了简化和扩展。我们非常希望现有的 HLSL 着色器能够直接作为 WHLSL 着色器运行。由于 WHLSL 是一种规范良好、功能强大且富有表现力的着色语言,因此一些 HLSL 着色器需要进行调整才能工作,但因此,WHLSL 可以保证上述的安全性和其他好处。

例如,这里是来自微软 DirectX-Graphics-Samples 仓库的一个顶点着色器示例。它无需任何更改即可作为 WHLSL 着色器运行

VSParticleDrawOut output;
output.pos = g_bufPosVelo[input.id].pos.xyz;
float mag = g_bufPosVelo[input.id].velo.w / 9;
output.color = lerp(float4(1.0f, 0.1f, 0.1f, 1.0f), input.color, mag);
return output;

这是相关的像素着色器,它作为 WHLSL 着色器完全未经修改即可运行

float intensity = 0.5f - length(float2(0.5f, 0.5f) - input.tex);
intensity = clamp(intensity, 0.0f, 0.5f) * 2.0f;
return float4(input.color.xyz, intensity);

基础

我们来谈谈语言本身。

与 HLSL 类似,WHLSL 的基本数据类型是 boolintuintfloathalf。不支持 double 类型,因为它们在 Metal 中不存在,并且软件模拟会太慢。bool 类型没有特定的位表示,因此不能出现在着色器输入/输出或资源中。SPIR-V 中也存在相同的限制,我们希望能够在生成的 SPIR-V 代码中使用 OpTypeBool。WHLSL 还包括更小的整数类型:charucharshortushort,这些类型在 Metal 着色语言中直接可用,在 SPIR-V 中可以通过在 OpTypeFloat 中指定 16 来指定,并且可以在 HLSL 中模拟。模拟这些类型比模拟双精度浮点数更快,因为这些类型更小,其位表示也更简单。

WHLSL 不提供 C 风格的隐式类型转换。我们发现隐式类型转换是着色器中常见的错误来源,强制程序员明确指出转换发生的位置可以消除这类经常令人沮丧和神秘的 Bug。这与 Swift 等语言所采取的方法类似。此外,缺乏隐式类型转换使规范和编译器保持简单。

与 HLSL 类似,WHLSL 中也有向量类型和矩阵类型,例如 float4int3x4。我们没有添加一堆“x1”单元素向量和矩阵,而是选择保持标准库的简洁性,因为单元素向量已经可以表示为标量,而单元素矩阵已经可以表示为向量。这与消除隐式转换的愿望是一致的,并且要求在 float1float 之间进行显式转换会很麻烦且不必要地冗长。

因此,以下是一个有效的着色器代码片段

int a = 7;
a += 3;
float3 b = float3(float(a) * 5, 6, 7);
float3 c = b.xxy;
float3 d = b * c;

我前面提到不允许隐式类型转换,但你可能在上面的代码片段中注意到,5 并未写成 5.0。这是因为字面量被表示为一种特殊类型,可以与其他数字类型统一。当编译器看到上述代码时,它知道乘法运算符要求参数类型相同,并且第一个参数明显是 float。因此,当编译器看到 float(a) * 5 时,它会说“嗯,我知道第一个参数是 float,那意味着我必须使用 (float, float) 重载,所以让我们将 5 与第二个参数统一,这样 5 就变成了 float。”即使两个参数都是字面量,这也适用,因为字面量有首选类型。因此,5 * 5 将获得 (int, int) 重载,5u * 5u 将获得 (uint, uint) 重载,而 5.0 * 5.0 将获得 (float, float) 重载。

WHLSL 和 C 之间的一个区别是,WHLSL 在变量声明处会将所有未初始化的变量零初始化。这可以防止跨操作系统和驱动程序的不兼容行为,或者更糟糕的是,读取着色器开始执行之前碰巧存在的值。这也意味着 WHLSL 中所有可构造的类型都有一个零值。

枚举

由于枚举不产生任何运行时开销且非常有用,WHLSL 对其提供了原生支持。

enum Weekday {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    PizzaDay
}

枚举的底层类型默认为 int,但你可以覆盖该类型,例如 enum Weekday : uint。同样,枚举值可以有一个底层值,例如 Tuesday = 72。因为枚举具有定义的类型和值,所以它们可以在缓冲区中使用,并且可以在其底层类型和枚举类型之间进行转换。当你想在代码中引用一个值时,你可以像 Weekday.PizzaDay 这样限定它,类似于 C++ 中枚举类的工作方式。这意味着枚举值不会污染全局命名空间,并且独立枚举的值不会冲突。

结构体

WHLSL 中的结构体与 HLSL 和 C 中的结构体工作方式类似。

struct Foo {
    int x;
    float y;
}

它们设计简单,避免了继承、虚方法和访问控制。结构体不可能有“私有”成员。由于结构体没有访问控制,因此结构体不需要成员函数。自由函数可以查看每个结构体的每个成员。

数组

像其他着色语言一样,数组是值类型,通过值传递和从函数返回(即“复制入复制出”,类似于普通标量)。你可以使用以下语法创建一个数组

int[3] x;

就像任何变量声明一样,这将对数组内容进行零填充,因此是一个 O(n) 操作。我们希望将方括号放在类型之后而不是变量名之后,原因有二

  1. 将所有类型信息放在一个位置使解析器更简单(避免顺时针/螺旋规则
  2. 避免在单个语句中声明多个变量时的歧义(例如 int[10] x, y;

我们确保语言安全的一个关键方法是对每次数组访问执行边界检查。我们通过多种方式使这种潜在昂贵的操作变得高效。数组索引是 uint 类型,这使得检查简化为一次单一比较。数组并非稀疏实现,并且包含一个在编译时可用的长度成员,这使得访问的开销接近于零。

尽管数组是值类型,但 WHLSL 通过另外两种类型实现了引用语义:安全指针和数组引用。

安全指针

首先是安全指针。某种形式的引用语义(即指针所允许的行为)几乎在所有 CPU 端编程语言中都有使用。在 WHLSL 中包含指针将使开发人员更容易将现有 CPU 端代码迁移到 GPU,从而允许轻松移植机器学习、计算机视觉和信号处理等应用程序。

为了满足安全要求,WHLSL 使用安全指针,这些指针保证要么指向有效内容,要么为 null。与 C 类似,你可以使用 & 运算符创建指向左值(lvalue)的指针,并使用 * 运算符解引用。与 C 不同的是,你不能像操作数组那样通过指针进行索引。你不能将其与标量值之间进行转换,并且它没有特定的位模式表示。因此,它不能存在于缓冲区中或作为着色器输入/输出。

就像 OpenCL 和 Metal 着色语言中一样,GPU 有不同的堆,或者说值可以存在的地址空间。WHLSL 有 4 种不同的堆:deviceconstantthreadgroupthread。所有引用类型都必须标记它们所指向的地址空间。

device 地址空间对应于设备上的大部分内存。此内存可读写,对应于 Direct3D 中的无序访问视图(Unordered Access Views)和 Metal 着色语言中的 device 内存。constant 地址空间对应于内存的只读区域,通常针对广播到每个线程的数据进行优化。因此,写入位于 constant 地址空间中的左值是编译错误。最后,threadgroup 地址空间对应于线程组中每个线程之间共享的可读写内存区域。它只能在计算着色器中使用。

默认情况下,值存在于 thread 地址空间中

int i = 4;
thread int* j = &i;
*j = 7;
// i is now 7

由于所有变量都经过零初始化,指针也经过 null 初始化。因此,以下是有效的

thread int* i;

尝试解引用此指针将导致陷阱(trapping)或钳位(clamping),具体描述见后。

数组引用

数组引用类似于指针,但它们可以与下标运算符一起使用,以访问数组引用中的多个元素。数组的 length 在编译时已知,并且必须在类型声明中指明,而数组引用的 length 仅在运行时已知。就像指针一样,它们必须与地址空间关联,并且可以是 nullptr。就像数组一样,它们使用 uint 进行索引以进行单次比较的边界检查,并且它们不能是稀疏的。

它们对应于 SPIR-V 中的 OpTypeRuntimeArray 类型,以及 HLSL 中的 BufferRWBufferStructuredBufferRWStructuredBuffer 之一。在 Metal 中,它表示为指针和长度的元组。与数组访问类似,所有操作都会根据数组引用的长度进行检查。缓冲区通过数组引用或指针从 API 传递到入口点。

你可以使用 @ 运算符从左值(lvalue)创建数组引用

int i = 4;
thread int[] j = @i;
j[0] = 7;
// i is 7
// j.length is 1

正如你所预料的,对指针 j 使用 @ 会创建一个指向与 j 相同内容的数组引用

int i = 4;
thread int* j = &i;
thread int[] k = @j;
k[0] = 7;
// i is 7
// k.length is 1

对数组使用 @ 会使数组引用指向该数组

int[3] i = int[3](4, 5, 6);
thread int[] j = @i;
j[1] = 7;
// i[1] is 7
// j.length is 3

函数

函数看起来与 C 语言中的函数非常相似。例如,这是标准库中的一个函数

float4 lit(float n_dot_l, float n_dot_h, float m) {
    float ambient = 1;
    float diffuse = max(0, n_dot_l);
    float specular = n_dot_l < 0 || n_dot_h < 0 ? 0 : n_dot_h * m;
    float4 result;
    result.x = ambient;
    result.y = diffuse;
    result.z = specular;
    result.w = 1;
    return result;
}

这个例子展示了 WHLSL 函数与 C 语言的相似之处:函数声明和调用(例如对 max() 的调用)具有相似的语法,参数和形参按顺序成对匹配,并且支持三元表达式。

运算符和运算符重载

然而,这里还有其他事情正在发生。当编译器看到 n_dot_h * m 时,它并不知道如何执行这个乘法运算。相反,编译器会将其转换为对 operator*() 的调用。然后,通过标准函数重载解析算法选择特定的 operator*()。这很重要,因为它意味着你可以编写自己的 operator*() 函数,并教导 WHLSL 如何对你自己的类型进行乘法运算。

这甚至适用于 ++ 这样的操作。尽管前置和后置自增具有不同的行为,但它们都被重载到同一个函数:operator++()。这是标准库中的一个例子

int operator++(int value) {
    return value + 1;
}

该运算符将同时用于前置自增和后置自增,并且编译器足够智能,可以正确处理结果。这解决了 C++ 遇到的问题,即这些运算符是不同的,并使用一个额外的虚拟 int 参数来区分。对于后置自增,编译器将发出代码,将值保存到匿名变量,调用 operator++(),赋值结果,并使用保存的值进行进一步处理。

运算符重载在整个语言中都有使用。它是向量和矩阵乘法的实现方式。它是数组索引的方式。它是混合(swizzle)运算符的工作方式。运算符重载提供了强大功能和简洁性;核心语言无需直接了解这些操作,因为它们由重载运算符实现。

生成属性

然而,WHLSL 的特性不止于运算符重载。早前的示例中包含 b.xxy,其中 bfloat3 类型。这个表达式表示“创建一个 3 元素向量,其中前两个元素的值与 b.x 相同,第三个元素的值与 b.y 相同。”所以它有点像向量的成员,但它实际上不与任何存储关联;相反,它是在访问时计算的。这些“混合(swizzle)运算符”存在于每种实时着色语言中,WHLSL 也不例外。它们通过像 Swift 中那样,被标记为生成属性来支持。

Getter(取值器)

标准库包含许多以下形式的函数

float3 operator.xxy(float3 v) {
    float3 result;
    result.x = v.x;
    result.y = v.x;
    result.z = v.y;
    return result;
}

当编译器看到对不存在的成员进行属性访问时,它可以调用运算符并将对象作为第一个参数传递。通俗地说,我们称之为取值器(getter)

Setter(设置器)

同样的方法也适用于设置器(setters)

float4 operator.xyz=(float4 v, float3 c) {
    float4 result = v;
    result.x = c.x;
    result.y = c.y;
    result.z = c.z;
    return result;
}

使用设置器非常自然

float4 a = float4(1, 2, 3, 4);
a.xyz = float3(7, 8, 9);

设置器的实现会创建对象的一个副本,其中包含新数据。当编译器看到对生成属性的赋值时,它会调用设置器并将结果赋给原始变量。

Anders

取值器和设置器的一种推广是安德器(ander),它与指针一起工作。它作为一种性能优化而存在,因此设置器无需创建对象的副本。这是一个例子

thread float* operator.r(thread Foo* value) {
    return &value->x;
}

安德器比取值器或设置器更强大,因为编译器可以使用安德器来实现读取或赋值。通过安德器从生成属性读取时,编译器会调用安德器,然后解引用结果。写入时,编译器会调用安德器,解引用结果,并赋值给该结果。任何用户定义的类型都可以拥有取值器、设置器、安德器和索引器的任意组合;如果同一类型同时拥有安德器和取值器或设置器,编译器将优先使用安德器。

索引器

那么矩阵呢?在大多数实时着色语言中,矩阵不是通过对应其列或行的成员来访问的。相反,它们使用数组语法访问,例如 myMatrix[3][1]。向量类型通常也有这种语法。那么这是如何工作的呢?更多的是运算符重载!

float operator[](float2 v, uint index) {
    switch (index) {
        case 0:
            return v.x;
        case 1:
            return v.y;
        default:
            /* trap or clamp, more on this below */
    }
}

float2 operator[]=(float2 v, uint index, float a) {
    switch (index) {
        case 0:
            v.x = a;
            break;
        case 1:
            v.y = a;
            break;
        default:
            /* trap or clamp, more on this below */
    }
    return v;
}

如你所见,索引也使用运算符,因此可以被重载。向量也获得了这些“索引器”,因此 myVector.xmyVector[0] 是彼此的同义词。

标准库

我们根据描述 HLSL 标准库的微软文档设计了标准库。WHLSL 标准库主要包含数学运算,这些运算既适用于标量值,也适用于向量和矩阵的逐元素操作。所有你期望的标准运算符都已定义,包括逻辑和位运算,例如 operator*()operator<<()。所有混合(swizzle)运算符、取值器和设置器都已为向量和矩阵(适用时)定义。

WHLSL 的设计原则之一是保持语言本身的小巧,以便尽可能多的内容可以在标准库中定义。当然,并非标准库中所有的函数都可以在 WHLSL 中表达(例如 bool operator*(float, float)),但几乎所有其他内容都用 WHLSL 实现。例如,这个函数是标准库的一部分

float smoothstep(float edge0, float edge1, float x) {
    float t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
    return t * t * (3 - 2 * t);
}

由于标准库旨在尽可能与 HLSL 匹配,因此其中大多数函数在 HLSL 中已经直接存在。因此,将 WHLSL 的标准库编译到 HLSL 时,会选择省略这些函数,转而使用内置版本。例如,对于所有向量/矩阵索引器,都会发生这种情况——GPU 实际上不应该看到上面的代码;编译器中的代码生成步骤应该使用内在函数(intrinsic)代替。然而,不同的着色语言有不同的内在函数,因此每个函数都经过定义以允许进行正确性测试。同样,WHLSL 包含一个 CPU 端解释器,在执行 WHLSL 程序时,它使用这些函数的 WHLSL 实现。这适用于每个 WHLSL 函数,包括纹理采样函数。

并非 HLSL 标准库中的每个函数都存在于 WHLSL 中。例如,HLSL 支持 printf()。然而,在 Metal 着色语言或 SPIR-V 中实现这样的函数将相当困难。我们包含了 HLSL 标准库中在 Web 环境下合理可用的尽可能多的函数。

变量生命周期

但是,如果语言中有指针,我们应该如何处理使用已释放内存(use-after-free)的问题呢?例如,考虑以下代码片段

thread int* foo() {
    int a;
    return &a;
}
…
int b = *foo();

在像 C 这样的语言中,这段代码的行为是未定义的。因此,一个解决方案是让 WHLSL 直接禁止这种结构,并在看到类似情况时抛出编译错误。然而,这需要跟踪每个指针可能指向的值,这在存在循环和函数调用的情况下是一种困难的分析。相反,WHLSL 让每个变量的行为如同它具有全局生命周期一样。

这意味着此 WHLSL 代码片段完全有效且定义明确,原因有二

  1. 声明 a 而不带初始化器会将其零填充。因此,a 的值是明确定义的。每次调用 foo() 时都会发生这种零填充。

  2. 所有变量都具有全局生命周期(类似于 C 语言的 static 关键字)。因此,a 永远不会超出作用域。

这种全局生命周期只有在禁止递归(这在着色语言中很常见)的情况下才可能实现,这意味着不会有任何重入问题。同样,着色器不能分配或释放内存,因此编译器在编译时就知道着色器可能访问的每一块内存。

例如

thread int* foo() {
    int a;
    return &a;
}
…
thread int* x = foo();
*x = 7;
thread int* y = foo();
// *x equals 0, because the variable got zero-filled again
*y = 8;
// *x equals 8, because x and y point to the same variable

大多数变量不需要真正全局化,因此对性能影响不大。如果编译器可以证明一个特定变量是否具有全局生命周期是不可观察的,那么编译器可以自由地将该变量保持为局部变量。由于在其他语言中(实际上,许多其他着色语言甚至没有指针)不鼓励返回指向局部变量的指针模式,因此像这样的例子将相对罕见。

编译阶段

WHLSL 不像其他语言那样使用预处理器。在其他语言中,预处理器主要用于将多个源文件包含在一起。然而,在 Web 上,没有直接的文件访问,并且通常整个着色器都呈现在一个下载资源中。在许多着色语言中,预处理器用于有条件地启用大型超着色器(ubershader)内部的渲染功能,但 WHLSL 允许通过使用特化常量(specialization constants)来实现此用例。此外,预处理器的许多变体以微妙的方式不兼容,因此对于 WHLSL 而言,预处理器的益处并不能 outweigh 编写其规范的复杂性。

WHLSL 旨在进行两阶段编译。在我们的研究中,我们发现许多 3D 引擎希望编译大量的着色器,并且每次编译都包含在不同编译之间重复的大型函数库。与其多次编译这些支持函数,一个更好的解决方案是只编译整个库一次,然后允许第二阶段选择库中的哪些入口点应该一起使用。

这种两阶段编译意味着尽可能多的编译应该在第一阶段完成,这样就不会为同一系列着色器多次运行。这就是 WHLSL 中入口点被标记为 vertex(顶点)、fragment(片段)或 compute(计算)的原因。让编译的第一阶段知道哪些函数是哪种类型的入口点,可以使更多的编译发生在第一阶段而不是第二阶段。

这第二个编译阶段也提供了一个方便的地方来指定特化常量(specialization constants)。回想一下,WHLSL 没有预处理器,而预处理器是 HLSL 中启用和禁用功能的传统方式。引擎通常通过启用渲染效果或通过开关切换BRDF来针对特定情况定制单个着色器。将每个渲染选项包含在一个着色器中,并根据要启用的效果对该着色器进行特化的技术非常常见,它有一个名称:超着色器(ubershaders)。WHLSL 程序员可以使用特化常量,它们的工作方式与 SPIR-V 的特化常量相同。从语言的角度来看,它们只是标量常量。然而,这些常量的值是在第二个编译阶段提供的,这使得在运行时配置程序变得非常容易。

由于单个 WHLSL 程序可以包含多个着色器,因此着色器的输入和输出不像其他着色语言那样由全局变量表示。相反,特定着色器的输入和输出与该着色器本身关联。输入表示为着色器入口点的参数,输出表示为入口点的返回值。

下面展示如何描述一个计算着色器入口点

compute void ComputeKernel(device uint[] b : register(u0)) {
   …
}

安全性

WHLSL 是一种安全的语言。这意味着不可能访问网站源之外的信息。WHLSL 实现这一目标的方法之一是消除未定义行为,如上文关于统一性所述。

WHLSL 实现安全的另一种方式是对数组/指针访问执行边界检查。这些边界检查可能以三种方式发生

  1. 陷阱(Trapping)。当程序中发生陷阱时,着色器阶段会立即退出,并用 0 填充着色器阶段的所有输出。绘制调用继续进行,图形管线的下一个阶段将运行。
    由于陷阱引入了新的控制流,它会对程序的统一性产生影响。陷阱在边界检查内部发出,这意味着它们必然存在于非统一控制流中。对于一些不使用统一性的程序来说这可能没问题,但总的来说,这使得陷阱难以使用。
  2. 钳位(Clamping)。数组索引操作可以将索引钳位到数组的大小。这不涉及新的控制流,因此对统一性没有任何影响。甚至可以通过忽略写入并对读取返回 0 来“钳位”指针访问或零长度数组访问。这是可能的,因为在 WHLSL 中对指针可以执行的操作集是有限的,所以我们只需让每个操作对“钳位”指针执行一些明确定义的事情即可。
  3. 硬件和驱动程序支持。一些硬件和驱动程序已经包含一种模式,在这种模式下不会发生越界访问。通过这种方法,硬件禁止越界访问的机制是由实现定义的。一个例子是 ARB_robustness OpenGL 扩展。不幸的是,WHLSL 应该能在几乎所有现代硬件上运行,而目前支持这类模式的 API/设备还不够多。

无论编译器使用哪种方法,它都不应该影响着色器的统一性;换句话说,它不能将一个原本有效的程序变成一个无效的程序。

为了确定边界检查的最佳行为,我们进行了一些性能实验。我们选取了 Metal Performance Shaders 框架中使用的一些内核,并制作了两个新版本:一个使用钳位,一个使用陷阱。我们选择的内核是那些进行大量数组访问的内核:例如,大型矩阵乘法。我们在各种设备上以不同的数据大小运行了此基准测试。我们确保没有实际触发任何陷阱,也没有任何钳位实际产生任何影响,因此我们可以确信我们正在测量正确编写程序的常见情况。

我们预期陷阱通常会更快,因为下游编译器可以消除冗余陷阱。然而,我们发现并没有一个明确的赢家。在某些设备上,陷阱明显快于钳位,而在其他设备上,钳位明显快于陷阱。这些结果表明,编译器应该能够选择最适合其运行设备的最佳方法,而不是被迫总是选择一种方法。

Chart of iPhone 6 vs iPhone X runtime scores

着色器签名

WHLSL 支持 HLSL 中的一种语言特性,称为“语义(semantics)”。它们用于标识着色器阶段之间以及来自 WebGPU API 的变量。语义有四种类型

  • 内置变量,例如 uint vertexID : SV_VertexID
  • 特化常量,例如 uint numlights : specialized
  • 阶段输入/输出语义,例如 float2 coordinate : attribute(0)
  • 资源语义,例如 device float[] coordinate : register(u0)

如上所述,WHLSL 程序以函数参数的形式接受其输入和输出,而不是全局变量。

然而,着色器通常有多个输出。最常见的例子是顶点着色器将多个输出值传递给插值器,作为片段着色器的输入。

为了适应这一点,着色器的返回值可以是一个结构体,并且各个字段被独立处理。事实上,这可以递归工作——结构体可以包含另一个结构体,其成员也被独立处理。嵌套结构体会被展平,所有非结构体字段都会被收集并视为着色器输出。

着色器参数的工作方式相同。单个参数可以是着色器输入,也可以是一个包含一组着色器输入的结构体。结构体也可以包含其他结构体。这些结构体内部的变量被独立处理,如同它们是着色器的额外参数一样。

在所有这些结构体被展平为一组输入和一组输出之后,集合中的每个项都必须具有语义。每个内置变量必须具有特定类型,并且只能在特定着色器阶段使用。特化常量必须只具有简单的标量类型。

阶段输入/输出变量使用 attribute 语义而不是传统的 HLSL 语义,因为许多着色器传递的数据与 HLSL 提供的固定语义不匹配。在 HLSL 中,常见做法是将通用数据打包到COLOR 语义中,因为COLOR是一个float4并且数据适合于一个float4。相反,SPIR-V 和 Metal 着色语言(通过 [[user(n)]])都采用的方法是为每个阶段输入/输出变量分配一个标识符,并使用这些分配来匹配着色器阶段之间的变量。

资源语义对于 HLSL 程序员来说应该很熟悉。WHLSL 包含资源语义和地址空间,但两者有不同的用途。变量的地址空间用于确定它应该在哪个缓存和内存层次结构中访问。地址空间是必需的,因为它甚至通过指针操作也能持续存在;device 指针不能设置为指向 thread 变量。在 WHLSL 中,资源语义仅用于从 WebGPU API 识别变量。然而,为了与 HLSL 保持一致,资源语义必须与其所放置变量的地址空间“匹配”。例如,你不能将 register(s0) 放在纹理上。你不能将 register(u0) 放在 constant 资源上。WHLSL 中的数组没有地址空间(因为它们是值类型,而不是引用类型),所以如果一个数组作为着色器参数出现,为了匹配语义,它被视为 device 资源。

就像 Direct3D 一样,WebGPU 也有一个两级绑定模型。资源描述符被聚合到集合中,并且这些集合可以在 WebGPU API 中进行切换。WHLSL 通过在资源语义内部使用一个可选的 space 参数来模拟这一点:register(u0, space1),从而与 HLSL 匹配。

“逻辑模式”限制

WHLSL 的设计要求是能够编译为 Metal 着色语言、SPIR-V 和 HLSL(或 DXIL)。SPIR-V 有许多不同的操作模式,由不同的嵌入式 API 作为目标。具体来说,我们关注的是 Vulkan 所针对的 SPIR-V 版本。

这种 SPIR-V 版本被称为逻辑寻址模式(Logical Addressing Mode)。在 SPIR-V 逻辑模式下,变量不能具有指针类型。同样,指针不能用于 Phi 操作。结果是每个指针必须始终只指向一个事物;指针只是一个值的名称。

由于 WHLSL 需要能够编译为 SPIR-V,因此 WHLSL 不能比 SPIR-V 更具表达性。因此,WHLSL 有一些限制,使其能够在 SPIR-V 逻辑模式中表达。这些限制并非以 WHLSL 的可选模式形式出现;相反,它们是语言本身的一部分。最终,我们希望这些限制能够在语言的未来版本中解除,但在此之前,该语言仍受限制。

这些限制是

  • 指针和数组引用不得出现在 deviceconstantthreadgroup 内存中
  • 指针和数组引用不得出现在数组或数组引用中
  • 指针和数组引用不得在其初始化器(在其声明中)之外被赋值
  • 返回指针或数组引用的函数必须只有一个返回点
  • 三元表达式不得产生指针

有了这些限制,编译器就能精确地知道每个指针指向什么。

但别急!回想一下前面提到的,thread 变量具有全局生命周期,这意味着它们的行为如同在入口点开始时声明一样。如果运行时将所有这些局部变量收集在一起,按类型排序,并将所有相同类型的变量聚合到数组中呢?那么,指针就可能只是相应数组中的一个偏移量。在 WHLSL 中,指针不能被重铸以指向不同类型,这意味着适当的数组由编译器静态确定。因此,thread 指针不需要遵守上述限制。但是,这种技术不适用于其他地址空间中的指针;它只适用于 thread 指针。

资源

WHLSL 支持纹理、采样器以及缓冲区的数组引用。就像 HLSL 中一样,WHLSL 中的纹理类型看起来像 Texture2D<float4>。这些尖括号的存在并不意味着模板或泛型;语言(为简单起见)不提供这些功能。唯一允许拥有它们的类型是有限的内置类型集合。这种设计是一种折衷,既允许这些存在于 HLSL 中的类型,又允许以社区组可以使用尖括号字符的方式进一步开发语言。

深度纹理与非深度纹理是不同的,因为它们在 Metal 着色语言中是不同的类型,因此编译器在生成 Metal 着色语言时需要知道要发出哪种类型。由于 WHLSL 不支持成员函数,纹理采样不是像 texture.Sample(…); 这样完成的;相反,它是通过像 Sample(texture, …) 这样的自由函数完成的。

采样器没有特化;所有用例都只有一种采样器类型。你可以将此采样器用于深度纹理和非深度纹理。深度纹理支持采样器中的比较操作等功能。如果采样器配置为包含深度比较并与非深度纹理一起使用,则深度操作将被忽略。

WebGPU API 会在特定位置自动发出一些资源屏障,这意味着 API 需要知道着色器中使用了哪些资源。因此,不能使用资源的“无绑定(bindless)”模型。这意味着所有资源都作为着色器的显式输入列出。同样,API 希望知道哪些资源用于读取,哪些用于写入;编译器通过检查程序可以静态地知道这些信息。语言层面不支持“const”或区分 StructuredBufferRWStructuredBuffer,因为这些信息已经存在于程序中。

当前工作

WebGPU 社区组正在使用 OTT 编写一份正式的语言规范,以与其他 Web 语言相同的严谨程度描述 WHLSL。我们还在开发一个编译器,它可以生成 Metal 着色语言、SPIR-V 和 HLSL。此外,该编译器还包括一个 CPU 端解释器,以展示实现的正确性。请尝试一下!

未来方向

WHLSL 仍处于初期阶段,距离语言设计完成还有很长的路要走。我们非常乐意听取您关于您的愿望、顾虑和用例的意见!请随时在我们的 GitHub 仓库中提出您关于想法和思考的问题!

对于第一个提案,我们希望满足本文开头概述的限制,同时为语言的扩展提供充足的机会。语言的一个自然演变可能是在类型抽象方面增加功能,例如协议或接口。WHLSL 包含简单的结构体,没有访问控制或继承。其他着色语言,例如 Slang,将类型抽象建模为一组必须存在于结构体内部的方法。然而,Slang 遇到了一个问题,即无法使现有类型遵守新的接口。一旦结构体定义完成,就不能向其添加新方法;花括号已经永远关闭了结构体。这个问题可以通过扩展来解决,类似于 Objective-C 或 Swift,它们可以在结构体定义之后追溯地向结构体中添加方法。Java 通过鼓励作者添加新的类(称为适配器)来解决这个问题,这些适配器只为实现接口而存在,并将每个调用传递给实现类型。

WHLSL 的方法要简单得多;通过使用自由函数而不是结构体方法,我们可以使用类似于 Haskell 的类型类系统。在这里,类型类定义了一组必须存在的任意函数,并且一个类型通过实现这些函数来遵守该类型类。未来这种解决方案可能会被添加到语言中。

总结

本文描述了一种名为 WHLSL 的新着色语言,由 W3C 的 WebGPU 社区组拥有。该语言的目标通过其熟悉的、基于 HLSL 的语法、安全保证以及简单可扩展的设计得到满足。因此,它代表了在 WebGPU API 中编写着色器的最佳支持方式。然而,WebGPU 社区组不确定 WHLSL 程序是否应该直接提供给 WebGPU API,或者在交付给 API 之前是否应该编译成中间形式。无论哪种方式,WebGPU 程序员都应该使用 WHLSL 编写程序,因为它最适合该 API。

请参与进来!我们正在 WebGPU GitHub 项目上进行这项工作。我们一直在为该语言制定正式规范,开发一个可生成 Metal 着色语言和 SPIR-V 的参考编译器,以及一个用于验证正确性的 CPU 端解释器。我们欢迎大家试用,并告诉我们您的体验如何!

欲了解更多信息,您可以通过 mmaxfield@apple.com@Litherum 联系我,或者您也可以联系我们的推广者 Jonathan Davis