写在最前:欢迎你来到“UC国际技术”公众号,我们将为大家提供与客户端、服务端、算法、测试、数据、前端等相关的高质量技术文章,不限于原创与翻译。
2016 年,Khronos Group 发布了 Vulkan API,主要用于 Android,具有类似的优势。
这些现代 3D 图形 API 中的每一个都使用着色器,WebGPU 也不例外。着色器是利用 GPU 专用架构的程序。特别是,在重型并行数值处理中,GPU 要优于 CPU。为了利用这两种架构,现代 3D 应用使用混合设计,使用 CPU 和 GPU 来完成不同的任务。通过利用每个架构的最佳特性,现代图形 API 为开发人员提供了一个强大的框架,可以创建复杂,丰富,快速的 3D 应用程序。专为 Metal 设计的应用使用 Metal Shading Language,为 Direct3D 12 设计的应用使用 HLSL,为 Vulkan 设计的应用使用 SPIR-V 或 GLSL。
它需要明确指定语言规范。语言规范必须明确是否每个可能的字符串都是有效的程序。与所有其他 Web 格式一样,必须精确指定 Web 的着色语言以保证浏览器之间的互操作性。
它需要翻译成 Metal Shading Language,HLSL(或 DXIL)和 SPIR-V。这是因为 WebGPU 被设计为能同时在 Metal,Direct3D 12 和 Vulkan 之上工作,因此着色器需要能够以以上每个 API 都可以接受的形式表示。
它需要使用 WebGPU API 进行演变。 WebGPU 功能(如绑定模型和曲面细分模型)与着色语言深度交互。尽管使用独立于 API 开发的语言是可行的,但在同一论坛中使用 WebGPU API 和着色语言可确保共享目标,并使开发更加简化。
第二部分是语言应该是人类可读的。 Web 的文化是任何人都可以用文本编辑器和浏览器开始编写网页。内容的民主化是 Web 最大的优势之一。这种文化创造了一个丰富的工具和审查员生态系统,修补者可以通过 View-Source 调查任何网页的工作方式。使用单一规范的人类可读语言将极大地帮助社区采用 WebGPU API。
类似地,使用诸如 WebAssembly 之类的字节码格式并不能避免浏览器对源代码进行优化的需要。每个主要浏览器在执行之前都会在字节码上运行优化。不幸的是,追求更简单的编译器的愿望从未结束。
Metal Shading Language 与 C++ 非常相似,这意味着它具有位转换和原始指针的所有功能。它非常强大; 甚至可以为 CPU 和 GPU 编译相同的源代码。将现有的 CPU 端代码移植到 Metal Shading Language 非常容易。不幸的是,所有这些能力都有一些缺点。例如,在 Metal Shading Language 中,你可以编写一个着色器,将指针转换为整数,添加 17,将其强制转换回指针,然后取消引用它。这是一个安全问题,因为它意味着着色器可以访问恰好位于应用程序地址空间中的任何资源,这与 Web 的安全模型相反。从理论上讲,可以指定一个没有原始指针的 Metal Shading Language,但指针对于 C 和 C++ 语言来说是如此基础,结果将完全陌生。 C++ 也严重依赖于未定义的行为,因此任何完全指定 C++ 众多功能的努力都不太可能成功。
GLSL 是 WebGL 使用的语言,并被 WebGL 用于 Web 平台。但是,由于 GLSL 编译器不兼容,达到跨浏览器的互操作性极其困难。由于仍然存在长期的安全性和可移植性错误,GLSL 仍处于调研中。此外,GLSL 到年纪了。它的局限性在于它没有类似指针的对象,或者具有可变长度数组的能力。它的输入和输出是具有硬编码名称的全局变量。
其次,SPIR-V 包含 50 多个可选功能,它们的实现是选择性支持的,因此使用 SPIR-V 的着色器作者不知道它们的着色器是否可以在 WebGPU 实现上工作。这与 Web 的一次写入运行特性相反。
WebAssembly 是另一种熟悉的可能性,但它也不能很好地映射到 GPU 的体系结构。例如,WebAssembly 假设一个动态大小的堆,但 GPU 程序可以访问多个动态大小的缓冲区。没有重新编译,没有一种高性能的方法可以在两个模型之间进行映射。
- 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;复制代码
- 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 类型的仿真更快,因为类型更小并且它们的位表示不那么复杂。
就像在 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;复制代码
WHLSL 和 C 之间的一个区别是 WHLSL 在其声明站点对所有未初始化的变量进行零初始化。这可以防止跨操作系统和驱动程序的不可移植行为——甚至更糟糕的是,在着色器开始执行之前读取页面的任何值。这也意味着 WHLSL 中的所有可构造类型都具有零值。
- enum Weekday {
- Monday,
- Tuesday,
- Wednesday,
- Thursday,
- PizzaDay
- }复制代码
- struct Foo {
- int x;
- float y;
- }复制代码
与其他着色语言一样,数组是通过值传递和返回函数的值类型(也称为 “copy-in copy-out”,类似于常规标量)。 使用以下语法可以创建一个:
int[3] x;复制代码
-
将所有类型信息放在一个地方使得解析器更简单(避免顺时针 / 螺旋规则)
-
在单个语句中声明多个变量时避免歧义(例如 int [10] x,y;)
我们确保语言安全的一个关键方法是对每个阵列访问执行边界检查。 我们通过多种方式使这种潜在的昂贵操作变得高效。 数组索引是 uint,它将检查减少到单个比较。 数组没有稀疏实现,并且包含一个在编译时可用的长度成员,使访问成本接近于零。
为了满足安全要求,WHLSL 使用安全指针,保证指向有效或无效的指针。与 C 一样,你可以使用&运算符创建指向左值的指针,并可以使用 * 运算符取消引用。与 C 不同,你不能通过指针索引 - 如果它是一个数组。您不能将其转换为标量值,也不能使用特定的位模式表示。因此,它不能存在于缓冲区中或作为着色器输入/输出。
设备地址空间对应于设备上的大部分内存。该存储器是可读写的,对应于 Direct3D 中的无序访问视图和 Metal Shading Language 中的设备存储器。常量地址空间对应于存储器的只读区域,通常针对广播到每个线程的数据进行优化。因此,写入存在于常量地址空间中的左值是编译错误。最后,线程组地址空间对应于可读写的内存区域,该区域在线程组中的每个线程之间共享。它只能用于计算着色器。
- int i = 4;
- thread int* j = &i;
- *j = 7;
- // i is now 7复制代码
thread int* i;复制代码
它们对应于 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复制代码
- 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复制代码
- 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*() 函数,并教 WHLSL 如何将你自己的类型相乘。
- int operator++(int value) {
- return value + 1;
- }复制代码
整个语言都使用了操作符重载。 这就是实现向量和矩阵乘法的方式。 这是数组索引的方式。 这是混合运算符的工作方式。 运算符重载提供了功能和简单性; 核心语言不必直接了解每个操作,因为它们是由重载的运算符实现的。
- float3 operator.xxy(float3 v) {
- float3 result;
- result.x = v.x;
- result.y = v.x;
- result.z = v.y;
- return result;
- }复制代码
- 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);复制代码
- thread float* operator.r(thread Foo* value) {
- return &value->x;
- }复制代码
- 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;
- }复制代码
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);
- }复制代码
并非 WHLSL 中存在 HLSL 标准库中的每个功能。例如,HLSL 支持 printf()。但是,在 Metal Shading Language 或 SPIR-V 中实现这样的功能将非常困难。我们在 HLSL 标准库中包含尽可能多的函数,这在 Web 环境中是合理的。
- thread int* foo() {
- int a;
- return &a;
- }
- …
- int b = *foo();复制代码
这意味着此 WHLSL 代码段完全有效并且定义明确,原因有两个:
这种全局生命周期是唯一可能的,因为不允许递归(这对于着色语言来说很常见),这意味着不存在任何重入问题。类似地,着色器无法分配或释放内存,因此编译器在编译时知道着色器可能访问的每个内存块。
- 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 专为两阶段编译而设计。在我们的研究中,我们发现许多 3D 引擎想要编译大型着色器,每个编译包括在不同编译之间重复的大型函数库。不是多次编译这些支持函数,更好的解决方案是一次编译整个库,然后允许第二阶段选择应该一起使用库中的哪些入口点。
第二个编译阶段还提供了指定特化常量的便利位置。回想一下,WHLSL 没有预处理器,这是在 HLSL 中启用和禁用功能的传统方式。引擎通常通过启用渲染效果或通过翻转开关切换 BRDF 来为特定情况定制单个着色器。将每个渲染选项包含在单个着色器中的技术,以及基于启用哪种效果来专门设置单个着色器的技术是如此常见,它有一个名称:ubershaders。 WHLSL 程序员可以使用特殊化常量而不是预处理器宏,它们的工作方式与 SPIR-V 的特化常量相同。从语言的角度来看,它们只是标量常量。但是,在第二个编译阶段提供了这些常量的值,这使得在运行时配置程序变得非常容易。
- compute void ComputeKernel(device uint[] b : register(u0)) {
- …
- }复制代码
WHLSL 实现安全性的另一种方式是执行数组/指针访问的边界检查。这些边界检查可能有三种方式:
2. Clamping。数组索引操作可以将索引限制为数组的大小。这不涉及新的控制流程,因此它对均匀性没有任何影响。甚至可以通过忽略写入并为读取返回 0 来 “clap” 指针访问或零长度阵列访问。这是可能的,因为你可以用 WHLSL 中的指针做的事情是有限的,所以我们可以简单地让每个操作用一个 “clamped” 指针做一些明确定义的事情。硬件和驱动程序支持。某些硬件和驱动程序已经包含一种不会发生越界访问的模式。使用此方法,硬件禁止越界访问的机制是实现定义的。一个例子是 ARB_robustness OpenGL 扩展。不幸的是,WHLSL 应该可以在几乎所有现代硬件上运行,而且没有足够的 API / 设备支持这些模式。
为了确定边界检查的最佳行为,我们进行了一些性能实验。我们采用了 Metal Performance Shaders 框架中使用的一些内核,并创建了两个新版本:一个使用 clamp,另一个使用 trap。我们选择的内核是那些进行大量数组访问的内核:例如,乘以大型矩阵。我们在不同数据大小的各种设备上运行此基准测试。我们确保没有任何 trap 实际被击中,并且没有任何 clamp 实际上有任何影响,因此我们可以确定我们正在测量正确编写的程序的常见情况。
-
内置变量,例如 uint vertexID:SV_VertexID
-
专精常数,例如 uint numlights:专门的
-
阶段输入 / 输出语义,例如 float2 坐标:属性(0)
-
资源语义,例如 device float [] 坐标:寄存器(u0)
为了适应这种情况,着色器的返回值可以是结构,并且各个字段是独立处理的。实际上,这是递归工作的 - 结构可以包含另一个结构,其成员也可以独立处理。嵌套的结构被展平,并且所有非结构化的字段都被收集并视为着色器输出。
在将所有这些结构扁平化为一组输入和一组输出之后,集合中的每个项目都必须具有语义。每个内置变量必须具有特定类型,并且只能在特定着色器阶段使用。专精常量必须只有简单的标量类型。
HLSL 程序员应该熟悉资源语义。 WHLSL 包括资源语义和地址空间,但这两者具有不同的用途。变量的地址空间用于确定应在其中访问哪个缓存和内存层次结构。地址空间是必要的,因为它甚至通过指针操作仍然存在;设备指针不能设置为指向线程变量。在 WHLSL 中,资源语义仅用于标识 WebGPU API 中的变量。但是,为了与 HLSL 保持一致,资源语义必须 “匹配” 它所放置的变量的地址空间。例如,你不能在 texture 上放置寄存器(s0)。你不能将寄存器(u0)放在常量资源上。 WHLSL 中的数组没有地址空间(因为它们是值类型,而不是引用类型),因此如果数组显示为着色器参数,则将其视为用于匹配语义的设备资源。
“逻辑模式”限制 WHLSL 的设计要求可以与 Metal Shading Language,SPIR-V 和 HLSL(或 DXIL)兼容。 SPIR-V 具有许多不同的操作模式,以不同的嵌入 API 为目标。具体来说,我们对 Vulkan 所针对的 SPIR-V 的味道感兴趣。
因为 WHLSL 需要与 SPIR-V 兼容,所以 WHLSL 必须比 SPIR-V 更具表现力。因此,WHLSL 在 SPIR-V 逻辑模式中有一些限制使其可以表达。这些限制并未作为 WHLSL 的可选模式浮出水面;相反,它们是语言本身的一部分。最终,我们希望在将来的语言版本中可以解除这些限制,但在此之前,语言受到限制。
但不是那么快!回想一下,线程变量具有全局生命周期,这意味着它们的行为就像它们是在入口点的开头声明的那样。如果运行时将所有这些局部变量收集在一起,按类型排序,并将具有相同类型的所有变量聚合到数组中,该怎么办?然后,指针可以简单地是适当数组的偏移量。在 WHLSL 中,指针不能重新指向不同的类型,这意味着编译器会静态确定相应的数组。因此,线程指针不需要遵守上述限制。但是,这种技术不适用于其他地址空间中的指针;它只适用于线程指针。
深度 textures 与非深度 textures 不同,因为它们是 Metal Shading Language 中的不同类型,因此编译器需要知道在发出 Metal Shading Language时要发出哪一个。因为 WHLSL 不支持成员函数,所以 textures 采样不像 texture.Sample(...) ;相反,它是使用像 Sample(texture,...) 这样的自由函数完成的。
WebGPU API 将在特定位置自动发出一些资源障碍,这意味着 API 需要知道着色器中使用了哪些资源。因此,不能使用 “无约束” 的资源模型。这意味着所有资源都被列为着色器的显式输入。类似地,API 想知道哪些资源用于读取以及哪些资源用于写入;编译器通过检查程序来静态地知道这一点。 “const” 没有语言级支持,或者 StructuredBuffer 和 RWStructuredBuffer 之间没有区别,因为该信息已经存在于程序中。
对于第一个提案,我们希望满足本文开头概述的约束,同时为扩展语言提供充分的机会。语言的一种自然演变可以为类型的抽象添加设施,例如协议或接口。 WHLSL 包含没有访问控制或继承的简单结构。其他着色语言如 Slang 模型类型抽象作为必须存在于结构内的一组方法。但是,Slang 遇到了一个问题,即无法使现有类型遵循新接口。定义结构后,就无法向其中添加新方法;花括号永远关闭了结构。这个问题通过扩展来解决,类似于 Objective-C 或 Swift,它可以在定义结构后追溯地将方法添加到结构中。 Java 通过鼓励作者添加新类(称为适配器)来解决这个问题,这些类只存在于实现接口,并将每个调用连接到实现类型。
请加入!我们正在 WebGPU GitHub 项目上做这项工作。我们一直在研究语言的正式规范,发出 Metal Shading Language和 SPIR-V 的参考编译器,以及用于验证正确性的 CPU 端解释器。我们欢迎大家尝试一下,让我们知道它是怎么回事!
英文原文:https://webkit.org/blog/8482/web-high-level-shading-language/