原文作者:Myles Maxfield @Litherum
译者:UC 国际研发 Jothy
----
本文介绍了一种新的 Web 图形着色语言:Web 高级着色语言(WHLSL,发音为 “whistle”)。 这种语言受 HLSL 的启发,HLSL 是图形应用开发人员用的主要着色语言。 它扩展了 Web 平台的 HLSL,使其安全可靠。 它易于阅读和编写,使用了正式技术而可以很好地指定。
背景
在过去的几十年中,3D 图形已经发生了重大变化,程序员用来编写 3D 应用的 API 也发生了相应的变化。五年前,最先进的图形应用使用 OpenGL 来执行渲染。然而,在过去几年中,3D 图形行业正朝着更新,更低级别的图形框架转变,这种框架更符合真实硬件的行为。 2014 年,Apple 创建了 Metal 框架,让 iOS 和 macOS 应用可以充分利用 GPU。 2015 年,微软创建了 Direct3D 12,这是 Direct3D 的一个重大更新,它允许控制台级的渲染和计算效率。 2016 年,Khronos Group 发布了 Vulkan API,主要用于 Android,具有类似的优势。
就像 WebGL 将 OpenGL 引入 Web 一样,Web 社区正在寻求将这种类型的新型低级 3D 图形 API 引入平台。去年,Apple 在 W3C 内部建立了 WebGPU 社区组,以使新的 3D 图形 API 标准化,该 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。
语言要求
就像它的原生同行一样,WebGPU 也需要一种着色器语言。这种语言需要满足几个要求,以适合 Web 平台。
它需要是安全的。无论应用做什么,着色器必须只能从网页的域中读取或写入数据。如果没有这种保证,恶意网站可以运行着色器,从屏幕的其他部分读取像素,甚至是本机应用。
它需要明确指定语言规范。语言规范必须明确是否每个可能的字符串都是有效的程序。与所有其他 Web 格式一样,必须精确指定 Web 的着色语言以保证浏览器之间的互操作性。
它也需要明确指定编译规范,以便它可以用作编译目标。许多渲染团队使用内部自定义语言编写着色器,然后交叉编译为必要的语言。出于这个原因,语言应该有一组相当少的明确的语法和类型检查规则,编译器编写者在发出这种语言时可以参考这些规则。
它需要翻译成 Metal Shading Language,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。
当今网络上使用的所有主要语言都是人类可读的,但有一个例外。 WebAssembly 社区组希望解析字节码比解析文本语言更有效。但事实证明并非如此; Asm.js 是 JavaScript 源代码,在许多用例中仍然比 WebAssembly 快。
类似地,使用诸如 WebAssembly 之类的字节码格式并不能避免浏览器对源代码进行优化的需要。每个主要浏览器在执行之前都会在字节码上运行优化。不幸的是,追求更简单的编译器的愿望从未结束。
社区小组正在积极讨论这种人类可读的语言是否应该是 API 本身接受的语言,但该小组同意着色器编写的语言应该易于读写。
一种新语言?真的吗?
虽然有许多现有语言,但没有一种语言设计时考虑到 Web 和现代图形应用程序,并且没有一种语言符合上面列出的要求。在我们描述 WHLSL 之前,让我们看看一些现有的语言。
Metal Shading Language 与 C++ 非常相似,这意味着它具有位转换和原始指针的所有功能。它非常强大; 甚至可以为 CPU 和 GPU 编译相同的源代码。将现有的 CPU 端代码移植到 Metal Shading Language 非常容易。不幸的是,所有这些能力都有一些缺点。例如,在 Metal Shading Language 中,你可以编写一个着色器,将指针转换为整数,添加 17,将其强制转换回指针,然后取消引用它。这是一个安全问题,因为它意味着着色器可以访问恰好位于应用程序地址空间中的任何资源,这与 Web 的安全模型相反。从理论上讲,可以指定一个没有原始指针的 Metal Shading Language,但指针对于 C 和 C++ 语言来说是如此基础,结果将完全陌生。 C++ 也严重依赖于未定义的行为,因此任何完全指定 C++ 众多功能的努力都不太可能成功。
HLSL 是便携 Direct3D 着色器的受支持语言。它是目前世界上最流行的实时着色语言,因此是图形程序员最熟悉的语言。有多种实现,但没有正式的规范,因此很难创建一致的,可互操作的实现。尽管如此,鉴于 HLSL 无处不在,在 WHLSL 的设计中尽可能采用其语法是很有价值的。
GLSL 是 WebGL 使用的语言,并被 WebGL 用于 Web 平台。但是,由于 GLSL 编译器不兼容,达到跨浏览器的互操作性极其困难。由于仍然存在长期的安全性和可移植性错误,GLSL 仍处于调研中。此外,GLSL 到年纪了。它的局限性在于它没有类似指针的对象,或者具有可变长度数组的能力。它的输入和输出是具有硬编码名称的全局变量。
SPIR-V 被设计为开发人员将使用的实际着色语言的低级通用中间格式。人们不写作 SPIR-V; 它们使用人类可读的语言,然后使用工具将其转换为 SPIR-V 字节码。
在 Web 上采用 SPIR-V 存在一些挑战。首先,SPIR-V 不是以安全性作为第一原则编写的,并且不清楚是否可以对其进行修改以满足 Web 的安全性要求。Fork SPIR-V 语言意味着开发人员必须重新编译着色器,可能还是被迫重写他们的源代码。此外,浏览器仍然无法信任传入的字节码,并且需要验证程序以确保它们没有做任何不安全的事情。由于 Windows 和 macOS/iOS 不支持 Vulkan,因此传入的 SPIR-V 仍需要翻译/编译成另一种语言。奇怪的是,这意味着在这两个平台上,起点和终点都是人类可读的,但介于两者之间的位被混淆而没有任何好处。
其次,SPIR-V 包含 50 多个可选功能,它们的实现是选择性支持的,因此使用 SPIR-V 的着色器作者不知道它们的着色器是否可以在 WebGPU 实现上工作。这与 Web 的一次写入运行特性相反。
第三,许多图形应用程序(如 Babylon.js)需要在运行时动态修改着色器。使用字节码格式意味着这些应用程序必须包含用 JavaScript 编写的编译器,该编译器在浏览器中运行以从动态创建的着色器生成字节码。这将显着增加这些网站的膨胀,并将导致更差的性能。
尽管 JavaScript 是 Web 的规范语言,但它的属性使其成为着色语言的不良候选者。它的优势之一是它的灵活性,但这种动态导致许多条件和不同的控制流程,而 GPU 不能有效地执行。它也是垃圾收集的,这是一个绝对不适合 GPU 硬件的程序。
WebAssembly 是另一种熟悉的可能性,但它也不能很好地映射到 GPU 的体系结构。例如,WebAssembly 假设一个动态大小的堆,但 GPU 程序可以访问多个动态大小的缓冲区。没有重新编译,没有一种高性能的方法可以在两个模型之间进行映射。
因此,在对相应语言进行相当详尽的搜索之后,我们找不到足以满足项目要求的语言。因此,社区小组正在制作一种新语言。创建一门新语言是一项艰巨的任务,但我们认为有机会制作一些使用现代编程语言设计原则并满足我们要求的新东西。
WHLSL
WHLSL 是一种适合 Web 平台的新着色语言。它由 W3C 的 WebGPU 社区组开发,该组正在研究规范,编译器和 CPU 端口解释器以彰显它的正确性。
该语言基于 HLSL,但简化并扩展了它。我们真的希望现有的 HLSL 着色器能作为 WHLSL 着色器运行。由于 WHLSL 是一种功能强大且富有表现力的着色语言,因此一些 HLSL 着色器需要调整才行,因此,WHLSL 可以保证上述安全性和其他好处。
例如,以下是 Microsoft 的 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 中一样,原始数据类型是 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 风格的隐式转换。我们发现隐式转换是着色器中常见的错误来源,并且迫使程序员明确转换发生的位置,这消除了这种经常令人沮丧和神秘的错误。这是一种类似于 Swift 等语言的方法。此外,缺少隐式转换使规范和编译器变得简单。
就像在 HLSL 中一样,WHLSL 有矢量类型和矩阵类型,例如 float4 和 int3x4。我们选择保持标准库简单,而不是添加一堆 “x1” 单元素向量和矩阵,因为单元素向量已经可以表示为标量,单元素矩阵已经可以表示为向量。这与消除隐式转换的愿望一致,并且要求 float1 和 float 之间的显式转换,float 是麻烦且不必要的冗长的。
因此,以下是着色器的有效片段:
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(a) 5 时,它说 “好吧,我知道第一个参数是一个浮点数,这意味着我必须使用(浮点数,浮点数)重载,所以让我们用第二个参数统一 5,因此 5 变为浮点数。“即使两个参数都是文字,这也有效,因为文字有一个首选类型。因此,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,但你可以覆盖类型,例如,枚举 Weekday:uint。 类似地,枚举值可以具有基础值,例如 Tuesday = 72. 因为枚举已经定义了类型和值,因此它们可以在缓冲区中使用,并且它们可以在基础类型和枚举类型之间进行转换。 当你想在代码中引用一个值时,你可以像在 C++ 中使用枚举一样直接使用 Weekday.PizzaDay。 这意味着枚举值不会污染全局命名空间,独立枚举的值也不会发生冲突。
结构
WHLSL 中的结构与 HLSL 和 C 类似。
struct Foo {
int x;
float y;
}
设计简单,它们可以避免继承,虚拟方法和访问控制。 拥有结构的 “私有” 成员是不可能的。 由于结构体没有访问控制,因此结构体不需要具有成员函数。 自由函数可以看到每个结构的每个成员。
数组
与其他着色语言一样,数组是通过值传递和返回函数的值类型(也称为 “copy-in copy-out”,类似于常规标量)。 使用以下语法可以创建一个:
int[3] x;
就像任何变量声明一样,这将零填充数组的内容,因此是 O(n) 操作。 我们希望将括号放在类型后面而不是变量名后面,原因有两个:
将所有类型信息放在一个地方使得解析器更简单(避免顺时针 / 螺旋规则)
在单个语句中声明多个变量时避免歧义(例如 int [10] x,y;)
我们确保语言安全的一个关键方法是对每个阵列访问执行边界检查。 我们通过多种方式使这种潜在的昂贵操作变得高效。 数组索引是 uint,它将检查减少到单个比较。 数组没有稀疏实现,并且包含一个在编译时可用的长度成员,使访问成本接近于零。
数组是值类型,而 WHLSL 使用另外两种类型实现引用语义:安全指针和数组引用。
安全指针
第一个是安全指针。某些形式的引用语义(行为指针允许)几乎用于每种 CPU 端编程语言。在 WHLSL 中包含指针将使开发人员更容易将现有的 CPU 端代码迁移到 GPU,从而可以轻松移植诸如机器学习,计算机视觉和信号处理应用之类的东西。
为了满足安全要求,WHLSL 使用安全指针,保证指向有效或无效的指针。与 C 一样,你可以使用&运算符创建指向左值的指针,并可以使用 * 运算符取消引用。与 C 不同,你不能通过指针索引 - 如果它是一个数组。您不能将其转换为标量值,也不能使用特定的位模式表示。因此,它不能存在于缓冲区中或作为着色器输入/输出。
就像在 OpenCL 和 Metal Shading Language 中一样,GPU 具有不同的堆,或者可以存值的地址空间。 WHLSL 有 4 种不同的堆:设备,常量,线程组和线程。所有引用类型都必须使用它们指向的地址空间进行标记。
设备地址空间对应于设备上的大部分内存。该存储器是可读写的,对应于 Direct3D 中的无序访问视图和 Metal Shading Language 中的设备存储器。常量地址空间对应于存储器的只读区域,通常针对广播到每个线程的数据进行优化。因此,写入存在于常量地址空间中的左值是编译错误。最后,线程组地址空间对应于可读写的内存区域,该区域在线程组中的每个线程之间共享。它只能用于计算着色器。
默认情况下,值存在于线程地址空间中:
int i = 4;
thread int* j = &i;
*j = 7;
// i is now 7
因为所有变量都是零初始化的,所以指针是空初始化的。 因此,以下内容有效:
thread int* i;
尝试取消引用此指针将导致陷阱或钳位,如稍后所述。
数组引用
数组引用类似于指针,但它们可以与下标运算符一起使用,以访问数组引用中的多个元素。 虽然数组的长度在编译时是已知的,并且必须在类型声明中声明,但数组引用的长度仅在运行时已知。 就像指针一样,它们必须与地址空间相关联,并且它们可能是 nullptr。 就像数组一样,它们使用 uint 进行索引以进行单比较边界检查,并且它们不能是稀疏的。
它们对应于 SPIR-V 中的 OpTypeRuntimeArray 类型以及 HLSL 中的 Buffer,RWBuffer,StructuredBuffer 或 RWStructuredBuffer 之一。 在 Metal 中,它表示为指针和长度的元组。 就像数组访问一样,所有操作都是根据数组引用的长度进行检查的。 缓冲区通过数组引用或指针传递到 API 的入口点。
你可以使用 @ 运算符从左值进行数组引用:
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() 函数,并教 WHLSL 如何将你自己的类型相乘。
这甚至适用于像 ++ 这样的操作。 虽然前后增量有不同的行为,但它们都被重载到同一个函数:operator++()。 以下是标准库中的示例:
int operator++(int value) {
return value + 1;
}
将调用此操作符以进行预增量和后增量,并且编译器足够智能以对结果执行正确的操作。 这解决了 C++ 运行到这些运算符不同的地方的问题,并使用额外的伪 int 参数进行区分。 对于后递增,编译器将发出代码以将值保存到匿名变量,调用 operator++(),赋值结果,并使用保存的值进行进一步处理。
整个语言都使用了操作符重载。 这就是实现向量和矩阵乘法的方式。 这是数组索引的方式。 这是混合运算符的工作方式。 运算符重载提供了功能和简单性; 核心语言不必直接了解每个操作,因为它们是由重载的运算符实现的。
生成属性
但是,WHLSL 并不仅仅停留在运算符的超载上。 前面的例子包括 b.xxy,其中 b 是 float3。 这是一个表达式,意思是 “制作一个 3 元素向量,其中前两个元素具有与 bx 相同的值,第三个元素具有相同的值”,所以它有点像向量的成员,除了它不是 ' 实际上与任何存储相关联; 相反,它是在访问期间计算的。 这些 “混合操作符” 存在于每种实时着色语言中,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**
getter 和 setter 的泛化是 ander,它与指针一起使用。 它作为性能优化存在,因此 setter 不必创建对象的副本。 这是一个例子:
thread float* operator.r(thread Foo* value) {
return &value->x;
}
Anders 比 getter 或 setter 更强大,因为编译器可以使用 anders 来实现读取或赋值。 当通过 ander 从生成的属性读取时,编译器调用 ander 然后取消引用结果。 写入时,编译器调用 ander,取消引用结果,并把结果赋值给它。 任何用户定义的类型都可以包含 getter,setter,anders 和 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;
}
如你所见,索引也使用运算符,因此可能会过载。 向量也获得这些 “索引器”,因此 myVector.x 和 myVector [0] 是彼此的同义词。
标准库
我们基于描述 HLSL 标准库的 Microsoft Docs 设计了标准库。 WHLSL 标准库主要包括数学运算,它既可以处理标量值,也可以处理矢量和矩阵的元素。 定义了您期望的所有标准运算符,包括逻辑运算和按位运算,如 operator*() 和 operator<<()。 在适用的情况下,为矢量和矩阵定义所有混合运算符,getter 和 setter。
WHLSL 的设计原则之一是保持语言本身很小,以便尽可能在标准库中定义。 当然,并非标准库中的所有函数都可以用 WHLSL 表示(如 bool 运算符 *(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 中。因此,对 HLSL 的 WHLSL 标准库的汇编将选择忽略这些函数,而是使用内置版本。例如,对于所有矢量/矩阵索引器都会发生这种情况 - GPU 永远不会真正看到上面的代码; 编译器中的代码生成步骤应该使用内在代替。但是,不同的着色语言具有不同的内置函数,因此每个函数都被定义以允许正确性测试。类似地,WHLSL 包括一个 CPU 端解释器,它在执行 WHLSL 程序时使用这些函数的 WHLSL 实现。对于包括纹理采样函数在内的每个 WHLSL 函数都是如此。
并非 WHLSL 中存在 HLSL 标准库中的每个功能。例如,HLSL 支持 printf()。但是,在 Metal Shading Language 或 SPIR-V 中实现这样的功能将非常困难。我们在 HLSL 标准库中包含尽可能多的函数,这在 Web 环境中是合理的。
可变寿命(Variable Lifetime)
但如果语言中有指针,我们应该如何处理自由使用后的问题? 例如,请考虑以下代码段:
thread int* foo() {
int a;
return &a;
}
…
int b = *foo();
在像 C 这样的语言中,此代码具有未定义的行为。因此,一种解决方案是 WHLSL 只是禁止这种结构,并在看到类似这样的东西时抛出编译错误。但是,这需要跟踪每个指针可能指向的值,这在存在循环和函数调用时是一个困难的分析。相反,WHLSL 使每个变量的行为就像它具有全局生命周期一样。
这意味着此 WHLSL 代码段完全有效并且定义明确,原因有两个:
声明没有初始值设定项将对其进行零填充。因此,a 的值是明确定义的。每次调用 foo() 时都会发生这种零填充。所有变量都具有全局生命周期(类似于 C 的静态关键字)。因此,永远不会超出范围。
这种全局生命周期是唯一可能的,因为不允许递归(这对于着色语言来说很常见),这意味着不存在任何重入问题。类似地,着色器无法分配或释放内存,因此编译器在编译时知道着色器可能访问的每个内存块。
所以,例如:
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 通过使用特化常量来允许此用例。此外,预处理器的许多变体以微妙的方式不兼容,因此对 WHLSL 来说,一个预处理器的好处不会超过为它创建规范的复杂性。
WHLSL 专为两阶段编译而设计。在我们的研究中,我们发现许多 3D 引擎想要编译大型着色器,每个编译包括在不同编译之间重复的大型函数库。不是多次编译这些支持函数,更好的解决方案是一次编译整个库,然后允许第二阶段选择应该一起使用库中的哪些入口点。
这个两阶段编译意味着尽可能多地在第一遍中完成编译,因此对于着色器系列不会多次运行。这就是 WHLSL 中的入口点被标记为顶点,片段或计算的原因。让编译的第一阶段知道哪些函数是哪种类型的入口点让更多的编译发生在第一阶段而不是第二阶段。
第二个编译阶段还提供了指定特化常量的便利位置。回想一下,WHLSL 没有预处理器,这是在 HLSL 中启用和禁用功能的传统方式。引擎通常通过启用渲染效果或通过翻转开关切换 BRDF 来为特定情况定制单个着色器。将每个渲染选项包含在单个着色器中的技术,以及基于启用哪种效果来专门设置单个着色器的技术是如此常见,它有一个名称:ubershaders。 WHLSL 程序员可以使用特殊化常量而不是预处理器宏,它们的工作方式与 SPIR-V 的特化常量相同。从语言的角度来看,它们只是标量常量。但是,在第二个编译阶段提供了这些常量的值,这使得在运行时配置程序变得非常容易。
由于单个 WHLSL 程序可以包含多个着色器,因此着色器的输入和输出不会像其他着色语言那样由全局变量表示。相反,特定着色器的输入和输出与该着色器本身相关联。输入表示为着色器入口点的参数,输出表示为入口点的返回值。
以下显示了如何描述计算着色器入口点:
compute void ComputeKernel(device uint[] b : register(u0)) {
…
}
安全性
WHLSL 是一种安全的语言。这意味着无法访问网站原点以外的信息。 WHLSL 实现此目的的方法之一是消除未定义的行为,如上文关于均匀性所述。
WHLSL 实现安全性的另一种方式是执行数组/指针访问的边界检查。这些边界检查可能有三种方式:
- Trapping。当程序中出现陷阱时,着色器阶段会立即退出,为所有着色器阶段的输出填充 0。绘制调用继续,图形管道的下一个阶段将运行。
因为陷印引入了新的控制流程,所以它对程序的一致性有影响。Trap 在边界检查内发出,这意味着它们必然存在于非均匀控制流中。对于某些不使用均匀性的程序可能没问题,但一般来说这会使 trap 难以使用。
- Clamping。数组索引操作可以将索引限制为数组的大小。这不涉及新的控制流程,因此它对均匀性没有任何影响。甚至可以通过忽略写入并为读取返回 0 来 “clap” 指针访问或零长度阵列访问。这是可能的,因为你可以用 WHLSL 中的指针做的事情是有限的,所以我们可以简单地让每个操作用一个 “clamped” 指针做一些明确定义的事情。硬件和驱动程序支持。某些硬件和驱动程序已经包含一种不会发生越界访问的模式。使用此方法,硬件禁止越界访问的机制是实现定义的。一个例子是 ARB_robustness OpenGL 扩展。不幸的是,WHLSL 应该可以在几乎所有现代硬件上运行,而且没有足够的 API / 设备支持这些模式。
无论编译器使用哪种方法,都不应影响着色器的均匀性; 换句话说,它不能将其他有效的程序变成无效的程序。
为了确定边界检查的最佳行为,我们进行了一些性能实验。我们采用了 Metal Performance Shaders 框架中使用的一些内核,并创建了两个新版本:一个使用 clamp,另一个使用 trap。我们选择的内核是那些进行大量数组访问的内核:例如,乘以大型矩阵。我们在不同数据大小的各种设备上运行此基准测试。我们确保没有任何 trap 实际被击中,并且没有任何 clamp 实际上有任何影响,因此我们可以确定我们正在测量正确编写的程序的常见情况。
我们期望 trap 更快,因为下游编译器可以消除冗余 trap。但是,我们发现没有一个明显的赢家。在某些器件上,trap 明显快于 clamp,而在其他器件上,clamp 明显快于 trap。这些结果表明编译器应该能够选择哪种方法最适合它运行的特定设备,而不是被迫总是选择一种方法。
Shader 标识
WHLSL 支持 HLSL 的语言特性,称为 “语义”。它们用于标识着色器阶段和 WebGPU API 之间的变量。语义有四种类型:
内置变量,例如 uint vertexID:SV_VertexID
专精常数,例如 uint numlights:专门的
阶段输入 / 输出语义,例如 float2 坐标:属性(0)
资源语义,例如 device float [] 坐标:寄存器(u0)
如上所述,WHLSL 程序以函数参数的形式接受其输入和输出,而不是全局变量。
但是,着色器通常具有多个输出。最常见的例子是顶点着色器将多个输出值传递给插值器,以作为输入提供给片段着色器。
为了适应这种情况,着色器的返回值可以是结构,并且各个字段是独立处理的。实际上,这是递归工作的 - 结构可以包含另一个结构,其成员也可以独立处理。嵌套的结构被展平,并且所有非结构化的字段都被收集并视为着色器输出。
着色器参数的工作方式相同。单个参数可以是着色器输入,也可以是具有着色器输入集合的结构。结构也可以包含其他结构。这些结构中的变量是独立处理的,就好像它们是着色器的附加参数一样。
在将所有这些结构扁平化为一组输入和一组输出之后,集合中的每个项目都必须具有语义。每个内置变量必须具有特定类型,并且只能在特定着色器阶段使用。专精常量必须只有简单的标量类型。
阶段输入/输出变量具有属性语义而不是传统的 HLSL 语义,因为许多着色器传递的数据与 HLSL 提供的预设语义不匹配。在 HLSL 中,通常会将通用数据打包到 COLOR 语义中,因为 COLOR 是 float4,数据适合 float4。相反,SPIR-V 和金属着色语言(通过 [[user(n)]])的方法是为每个阶段输入 / 输出变量分配一个标识符,并使用赋值来匹配着色器阶段之间的变量。
HLSL 程序员应该熟悉资源语义。 WHLSL 包括资源语义和地址空间,但这两者具有不同的用途。变量的地址空间用于确定应在其中访问哪个缓存和内存层次结构。地址空间是必要的,因为它甚至通过指针操作仍然存在;设备指针不能设置为指向线程变量。在 WHLSL 中,资源语义仅用于标识 WebGPU API 中的变量。但是,为了与 HLSL 保持一致,资源语义必须 “匹配” 它所放置的变量的地址空间。例如,你不能在 texture 上放置寄存器(s0)。你不能将寄存器(u0)放在常量资源上。 WHLSL 中的数组没有地址空间(因为它们是值类型,而不是引用类型),因此如果数组显示为着色器参数,则将其视为用于匹配语义的设备资源。
就像 Direct3D 一样,WebGPU 有一个两级绑定模型。资源描述符聚合成集,并且可以在 WebGPU API 中切换集。 WHLSL 通过在资源语义内部通过可选空间参数对其进行建模来匹配 HLSL:register(u0,space1)。
“逻辑模式”限制
WHLSL 的设计要求可以与 Metal Shading Language,SPIR-V 和 HLSL(或 DXIL)兼容。 SPIR-V 具有许多不同的操作模式,以不同的嵌入 API 为目标。具体来说,我们对 Vulkan 所针对的 SPIR-V 的味道感兴趣。
这种 SPIR-V 的味道是 SPIR-V 的味道,称为逻辑寻址模式。在 SPIR-V 逻辑模式中,变量不能具有指针类型。类似地,指针不能用于 Phi 操作。结果是每个指针必须始终指向一件事;指针只是值的名称。
因为 WHLSL 需要与 SPIR-V 兼容,所以 WHLSL 必须比 SPIR-V 更具表现力。因此,WHLSL 在 SPIR-V 逻辑模式中有一些限制使其可以表达。这些限制并未作为 WHLSL 的可选模式浮出水面;相反,它们是语言本身的一部分。最终,我们希望在将来的语言版本中可以解除这些限制,但在此之前,语言受到限制。
这些限制是:
指针和数组引用不得出现在设备,常量或线程组内存中指针和数组引用不得出现在数组或数组引用中指针和数组引用不得在其初始化程序之外分配(在其声明中)返回指针或数组引用的函数只能有一个返回点三元表达式不能产生指针有了这些限制,编译器就会确切地知道每个指针指向的内容。
但不是那么快!回想一下,线程变量具有全局生命周期,这意味着它们的行为就像它们是在入口点的开头声明的那样。如果运行时将所有这些局部变量收集在一起,按类型排序,并将具有相同类型的所有变量聚合到数组中,该怎么办?然后,指针可以简单地是适当数组的偏移量。在 WHLSL 中,指针不能重新指向不同的类型,这意味着编译器会静态确定相应的数组。因此,线程指针不需要遵守上述限制。但是,这种技术不适用于其他地址空间中的指针;它只适用于线程指针。
资源
WHLSL 支持缓冲区的 texture,采样器和数组引用。就像在 HLSL 中一样,WHLSL 中的纹理类型看起来像 Texture2D 。这些尖括号的存在并不意味着模板或泛型;该语言没有那些设施(为简单起见)。允许使用它们的唯一类型是一组有限的内置类型。这种设计是允许这些类型(介于 HLSL 中)之间的中间地带,但也允许以社区组可以使用尖括号字符的方式进一步开发语言。
深度 textures 与非深度 textures 不同,因为它们是 Metal Shading Language 中的不同类型,因此编译器需要知道在发出金属着色语言时要发出哪一个。因为 WHLSL 不支持成员函数,所以 textures 采样不像 texture.Sample(...) ;相反,它是使用像 Sample(texture,...) 这样的自由函数完成的。
采样器不专业;所有用例都有一个采样器类型。你可以将此采样器用于深度 textures 和非深度 textures。深度 textures 支持采样器中的比较操作等内容。如果采样器配置为包含深度比较并且它与非深度 textures 一起使用,则忽略深度操作。
WebGPU API 将在特定位置自动发出一些资源障碍,这意味着 API 需要知道着色器中使用了哪些资源。因此,不能使用 “无约束” 的资源模型。这意味着所有资源都被列为着色器的显式输入。类似地,API 想知道哪些资源用于读取以及哪些资源用于写入;编译器通过检查程序来静态地知道这一点。 “const” 没有语言级支持,或者 StructuredBuffer 和 RWStructuredBuffer 之间没有区别,因为该信息已经存在于程序中。
当前进展
WebGPU 社区小组正在研究用 OTT 编写的正式语言规范,该规范描述了 WHLSL 与其他 Web 语言采用的严格程度。 我们还在研究可以生成金属着色语言,SPIR-V 和 HLSL 的编译器。 此外,编译器还包括一个 CPU 端解释器,以显示实现的正确性。 请试一试!
未来方向
WHLSL 还处于初级阶段,在语言设计完成之前还有很长的路要走。我们很乐意听取您的意见,疑虑和用例!请随时在我们的 GitHub 存储库中提出有关您的想法和想法的问题!
对于第一个提案,我们希望满足本文开头概述的约束,同时为扩展语言提供充分的机会。语言的一种自然演变可以为类型的抽象添加设施,例如协议或接口。 WHLSL 包含没有访问控制或继承的简单结构。其他着色语言如 Slang 模型类型抽象作为必须存在于结构内的一组方法。但是,Slang 遇到了一个问题,即无法使现有类型遵循新接口。定义结构后,就无法向其中添加新方法;花括号永远关闭了结构。这个问题通过扩展来解决,类似于 Objective-C 或 Swift,它可以在定义结构后追溯地将方法添加到结构中。 Java 通过鼓励作者添加新类(称为适配器)来解决这个问题,这些类只存在于实现接口,并将每个调用连接到实现类型。
WHLSL 方法简单得多;通过使用自由函数而不是结构方法,我们可以使用像 Haskell 类型类这样的系统。这里,类型类定义了一组必须存在的任意函数,类型通过实现它们来遵守类型类。这样的解决方案可能会在未来添加到该语言中。
总结
这描述了 W3C 的 WebGPU 社区组拥有的名为 WHLSL 的新着色语言。它熟悉的基于 HLSL 的语法,安全保证和简单,可扩展的设计满足了该语言的目标。因此,它代表了编写在 WebGPU API 中使用的着色器的最佳支持方式。但是,WebGPU 社区组不确定是否应直接向 WebGPU API 提供 WHLSL 程序,或者是否应在交付给 API 之前将它们编译为中间形式。无论哪种方式,WebGPU 程序员都应该使用 WHLSL 编写,因为它最适合 API。
请加入!我们正在 WebGPU GitHub 项目上做这项工作。我们一直在研究语言的正式规范,发出 MSL语言和 SPIR-V 的参考编译器,以及用于验证正确性的 CPU 端解释器。我们欢迎大家尝试一下,让我们知道它是怎么回事!
欲了解更多信息,您可以通过 mmaxfield@apple.com 或 @Litherum 与我联系,或者您可以联系我们的布道者 Jonathan Davis。
英文原文:
https://webkit.org/blog/8482/web-high-level-shading-language/