Webkit推出新的着色语言whlsl
作者|Myles Maxfield 译者|无明 本文将介绍一门叫作 Web High Level Shading Language(WHLSL,发音为“whistle”)的新 Web 图形着色语言,它对 HLSL 进行了扩展,变得更安全、更可靠。
背景
在过去的几十年中,3D 图形已经发生了重大变化,程序员用来编写 3D 应用程序的 API 也发生了相应的变化。 五年前,最先进的图形应用程序使用 OpenGL 来执行渲染。然而,在过去几年中,3D 图形行业正朝着更新、更低级别的图形框架转变,这些框架与真实硬件的行为更加贴合。2014 年,Apple 推出了 Metal 框架,让 iOS 和 macOS 应用程序可以充分利用 GPU。2015 年,微软推出了 Direct3D 12,这是 Direct3D 的一个重大更新,带来了控制台级的渲染和计算效率。2016 年,Khronos Group 发布了 Vulkan API,主要用于 Android,也具备了类似的优势。 去年,Apple 在 W3C 内部成立了 WebGPU 社区组,致力于标准化新的 3D 图形 API,既要提供原生 API 的优势,同时也适用于 Web 环境。这个新的 Web API 可以在 Metal、Direct3D 和 Vulkan 上实现。所有主要的浏览器厂商都参与了标准化工作。 这些现代 3D 图形 API 中的每一个都使用了着色器,WebGPU 也不例外。着色器是一种利用 GPU 专门架构的程序。在并行数值处理方面,GPU 优于 CPU。为了利用这两种架构,现代 3D 应用程序使用了混合设计,使用 CPU 和 GPU 来完成不同的任务。通过利用每种架构的最佳特性,现代图形 API 为开发人员提供了一个强大的框架,可以创建复杂、丰富、快速的 3D 应用程序。专为 Metal 设计的应用程序使用的是 Metal Shading Language,为 Direct3D 12 设计的应用程序使用的是 HLSL,为 Vulkan 设计的应用程序使用的是 SPIR-V 或 GLSL。
WHLSL
WHLSL 是一门适用于 Web 平台的新着色语言。它由 W3C 的 WebGPU 社区组开发,这个开发组正忙于制定规范、开发编译器和 CPU 端解释器。 WHLSL 以 HLSL 为基础,并对其进行了简化和扩展。WHLSL 是一门功能强大且富有表现力的着色语言,带来了安全性和其他好处。
语言基础
与 HLSL 中一样,WHLSL 的原始数据类型包括 bool、int、uint、float 和 half。不支持 Double,因为它在 Metal 中也不存在,并且会导致软件模拟变慢。Bool 没有特定的位表示,因此不能出现在着色器输入 / 输出或资源中。SPIR-V 中也存在同样的限制,我们希望能够在生成的 SPIR-V 代码中使用 OpTypeBool。WHLSL 还提供了较小的整数类型 char、uchar、short 和 ushort,可以直接在 Metal Shading Language 中使用,在 SPIR-V 中可以将 OpTypeFloat 指定为 16,并且可以在 HLSL 中进行模拟。模拟这些类型比模拟 Double 更快,因为这些类型更小并且它们的位表示不那么复杂。 WHLSL 不提供 C 语言风格的隐式转换。 我们发现隐式转换是着色器中常见的错误来源。此外,避免隐式转换使规范和编译器变得更简单。 与 HLSL 中一样,WHLSL 也有矢量类型和矩阵类型,例如 float4 和 int3x4。我们尽量保持标准库简单,所以没有添加一堆“x1”单元素向量和矩阵,因为单元素向量已经可以表示为标量,单元素矩阵已经可以表示为向量。这符合我们消除隐式转换的愿望,在 float1 和 float 之间进行显式转换是件麻烦且不必要的事情。 以下是有效的着色器片段:
int a = 7;
a += 3;
float3 b = float3(float(a) * 5, 6, 7);
float3 c = b.xxy;
float3 d = b * c;
我之前提到过,WHLSL 不支持隐式转换,但你可能已经注意到,在上面的代码片段中,5 并未写为 5.0。这是因为字面量表示为可与其他数字类型统一的特殊类型。当编译器看到上面的代码时,它知道乘法运算符要求参数类型相同,第一个参数显然是浮点数。所以,当编译器看到float(a) * 5时,它说“好吧,我知道第一个参数是一个浮点数,我必须使用 (float, 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 这样。这意味着枚举值不会污染全局命名空间,枚举的值也不会发生冲突。
结构体
WHLSL 中的结构与 HLSL 和 C 语言类似。
struct Foo {
int x;
float y;
}
结构体设计简单,避免了继承、虚拟方法和访问控制。结构体没有“私有”成员。因为结构体没有访问控制,所以不需要成员函数。
数组
与其他着色语言一样,数组是可以传给函数或从函数中返回的值类型。你可以使用以下语法创建一个数组:
int[3] x;
与任何变量声明一样,数组内容将使用零进行填充。我们将括号放在类型后面而不是变量名后面,有两个原因:
- 将所有类型信息放在一个地方可以让解析更简单(避免顺时针 / 螺旋规则);
- 在单个语句中声明多个变量时可以避免歧义(例如 int[10] x,y;)。 数组是值类型,而 WHLSL 使用另外两种类型实现引用语义:安全指针和数组引用。
安全指针
某种形式的引用语义,几乎被用在每一种 CPU 端编程语言中。在 WHLSL 中包含指针将使开发人员更容易将现有的 CPU 端代码迁移到 GPU,从而可以轻松移植机器学习、计算机视觉和信号处理应用程序之类的东西。 为了满足安全要求,WHLSL 使用了安全指针,保证指向有效的东西,或者为 null。与 C 语言一样,你可以使用 & 运算符创建指向 lvalue 的指针,并可以使用 * 运算符取消引用。与 C 语言不同的是,你不能像数组那样对指针进行索引。你不能将其与标量之间进行转换,也不能使用特定的位模式表示。因此,它不能出现在缓冲区中或作为着色器输入 / 输出。 WHLSL 有 4 种不同的堆:device、constant、threadgroup 和 thread。所有的引用类型都必须使用它们指向的地址空间进行标记。 device 地址空间对应于设备上的大部分内存。内存是可读写的,对应于 Direct3D 中的无序访问视图以及 Metal Shading Language 中的 device 内存。constant 地址空间对应于内存的只读区域,通常针对广播到每个线程的数据进行优化。最后,threadgroup 地址空间对应于可读写的内存区域,该区域被线程组的每个线程共享。它只能用于计算着色器。 默认情况下,值存在于 thread 地址空间中:
int i = 4;
thread int* j = &i;
*j = 7;
// i is now 7
因为所有变量都使用零值初始化,所以指针是 null 初始化的。因此,以下的声明是有效的: thread int* i;
数组引用
数组引用类似于指针,但它们可以与下标运算符一起使用,以访问数组引用中的多个元素。虽然数组的 length 在编译时是已知的,并且必须在类型声明中指明,但数组引用的 length 要在运行时才能知道。与指针一样,它们必须与地址空间相关联,并且可能会是 nullptr。与数组一样,它们使用 uint 进行索引,以进行单比较边界检查,并且不能是稀疏的。 你可以使用 @运算符为 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
函数
WHLSL 的函数与 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;
}
运算符和运算符重载
当编译器看到 n_dot_h * m时,它并不知道如何执行这个乘法。编译器会将其转换为对operator*()的调用。然后,通过标准函数重载决策算法选择特定的operator*()。这意味着你可以编写自己的operator*()函数,告诉 WHLSL 如何执行自定义类型的乘法。 这同样适用于像 ++ 这样的操作。以下是标准库中的一个示例:
int operator++(int value) {
return value + 1;
}
生成属性
但 WHLSL 并不仅仅停留在运算符重载上。最开始的例子中有个 b.xxy,其中 b 是 float3。这是一个表达式,意思是“创建一个包含 3 个元素的向量,其中前两个元素具有与 b.x 相同的值,第三个元素具有与 b.y 相同的值”。这有点像是向量的成员,只是没有与任何存储相关联。相反,它是在访问期间计算生成的。这些“混合运算符”存在于每种实时着色语言中,WHLSL 也不例外。这是通过生成属性来实现的,就像在 Swift 中一样。 ** Getters** 标准库包含了很多以下形式的函数:
float3 operator.xxy(float3 v) {
float3 result;
result.x = v.x;
result.y = v.x;
result.z = v.y;
return result;
}
当编译器遇到访问不存在的成员的属性时,它可以调用这个运算符,并将对象作为第一个参数传递进去。通俗地说,我们称之为 Getter。 ** Setters** 同样的方法适用于 Setter:
float4 operator.xyz=(float4 v, float3 c) {
float4 result = v;
result.x = c.x;
result.y = c.y;
result.z = c.z;
return result;
}
Setter 使用起来非常自然:
float4 a = float4(1, 2, 3, 4);
a.xyz = float3(7, 8, 9);
Setter 使用新数据创建对象的副本。当编译器遇到对生成属性进行赋值时,它会调用 Setter,并将结果赋给原始变量。 ** Anders** Ander 是 Getter 和 Setter 的泛化,可以与指针一起使用。它是对性能的一种优化,这样 Setter 就不必创建对象的副本。这是一个例子:
thread float* operator.r(thread Foo* value) {
return &value->x;
}
Anders 比 Getter 或 Setter 更强大,因为编译器可以使用 Ander 来实现读取或赋值。当通过 Ander 读取生成属性时,编译器调用 Ander,然后取消对结果的引用。在写入时,编译器也调用 Ander,取消对结果的引用,并将结果分配给它。任何用户定义的类型都可以包含 Getter、Setter、Ander 和 Indexer 的任意组合。如果相同类型具有 Ander 以及 Getter 或 Setter,编译器将首选 Ander。 ** Indexers** 在大多数实时着色语言中,不会使用与其列或行对应的成员来访问矩阵。相反,它们使用数组语法来访问,例如 myMatrix 的 3。矢量类型通常也有这种语法:
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;
}
可见,索引也使用了运算符,因此可以被重载。向量也有“Indexer”,因此 myVector.x 和 myVector[0] 是互为同义词。
标准库
我们基于描述 HLSL 标准库的 Microsoft Docs 设计了 WHLSL 标准库。WHLSL 标准库主要包括数学运算,既可以处理标量值,也可以处理矢量和矩阵的元素。标准款定义了你期望的所有标准运算符,包括逻辑运算和按位运算,如 operator*() 和 operator<<()。 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 包含了一个 CPU 端解释器,在执行 WHLSL 程序时将使用这些函数的 WHLSL 实现。 当然,并非出现在 HLSL 标准库中的每个函数在 WHLSL 中也都会有。例如,HLSL 支持 printf(),但要在 Metal Shading Language 或 SPIR-V 中实现这样的函数会非常困难。
安全性
WHLSL 是一门安全的语言,这意味着访问网站以外的信息是不可能的。WHLSL 通过消除未定义的行为来达到这个目的。 WHLSL 实现安全性的另一种方式是进行数组 / 指针访问边界检查。边界检查有三种方式:
- Trapping。当程序中出现 trap 时,着色器阶段会立即退出,将所有着色器阶段的输出填充为 0。绘制调用会继续,并运行图形管道的下一个阶段。因为 Trapping 引入了新的控制流程,所以对程序的一致性有一定影响。trap 是在边界检查内发出的,这意味着它们必然存在于非一致的控制流程中。对于某些不使用一致性的程序可能没问题,但一般来说这会导致 trap 难以使用。
- Clamping。数组索引操作可以将索引限制为数组大小。这不涉及新的控制流程,因此它对一致性没有任何影响。甚至可以通过忽略写入并为读取返回 0 来“clamp”指针访问或零长度阵列访问。这是可能的,因为你可以用 WHLSL 中的指针做的事情是有限的,所以我们可以简单地让每个操作用一个“clamp”指针做一些明确定义的事情。
- 硬件和驱动程序支持。某些硬件和驱动程序已经包含一种不会发生越界访问的模式。ARB_robustness OpenGL 扩展就是一个很好的例子。可惜的是,WHLSL 要在几乎所有现代硬件上运行,所以没有足够的 API/ 设备支持这些模式。 无论编译器使用哪种方法,都不应影响着色器的一致性。换句话说,它不可能能将有效的程序变成无效的程序。 为了确定边界检查的最佳行为,我们进行了一些性能实验。我们采用了 Metal Performance Shaders 框架中的一些内核,并创建了两个新版本:一个使用 clamping,另一个使用 traping。我们选择的内核是那些进行大量数组访问的内核:例如,大型矩阵相乘。我们在各种设备上运行这个基准测试。 我们希望 trapping 能够更快,因为下游编译器可以消除冗余的 trap。但我们发现,在某些设备上,trapping 明显快于 clamping,而在其他设备上,却是反过来的。这些结果表明,编译器应该能够为特定设备选择更合适的方法,而不是被迫选择一种给定的方法。
目前的工作
WebGPU 社区小组正在使用 OTT 编写正式语言规范。我们还在开发一个可以生成 Metal Shading Language、SPIR-V 和 HLSL 的编译器。此外,编译器还包括了一个 CPU 端解释器,可用于验证实现的正确性。
未来的发展方向
WHLSL 还处于初级阶段,在语言设计完成之前还有很长的路要走。请随时在我们的 GitHub 存储库(https://github.com/gpuweb/WHLSL)中提出你的想法和问题! 更多内容,请参看原文。 英文原文: https://webkit.org/blog/8482/web-high-level-shading-language/