写在最前:欢迎你来到“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 的着色语言以保证浏览器之间的互操做性。 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;复制代码
咱们确保语言安全的一个关键方法是对每一个阵列访问执行边界检查。 咱们经过多种方式使这种潜在的昂贵操做变得高效。 数组索引是 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 实际上有任何影响,所以咱们能够肯定咱们正在测量正确编写的程序的常见状况。
为了适应这种状况,着色器的返回值能够是结构,而且各个字段是独立处理的。实际上,这是递归工做的 - 结构能够包含另外一个结构,其成员也能够独立处理。嵌套的结构被展平,而且全部非结构化的字段都被收集并视为着色器输出。
在将全部这些结构扁平化为一组输入和一组输出以后,集合中的每一个项目都必须具备语义。每一个内置变量必须具备特定类型,而且只能在特定着色器阶段使用。专精常量必须只有简单的标量类型。
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/