Web 高级着色语言(WHLSL) - 为WebGPU设计的Web图形着色语言


原文做者:Myles Maxfield @Litherum
译者:UC 国际研发 Jothy

写在最前:欢迎你来到“UC国际技术”公众号,咱们将为你们提供与客户端、服务端、算法、测试、数据、前端等相关的高质量技术文章,不限于原创与翻译。

本文介绍了一种新的 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 的着色语言以保证浏览器之间的互操做性。 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) 操做。 咱们但愿将括号放在类型后面而不是变量名后面,缘由有两个:
  1. 将全部类型信息放在一个地方使得解析器更简单(避免顺时针 / 螺旋规则)
  2. 在单个语句中声明多个变量时避免歧义(例如 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 实现安全性的另外一种方式是执行数组/指针访问的边界检查。这些边界检查可能有三种方式:

1. Trapping。当程序中出现陷阱时,着色器阶段会当即退出,为全部着色器阶段的输出填充 0。绘制调用继续,图形管道的下一个阶段将运行。
由于陷印引入了新的控制流程,因此它对程序的一致性有影响。Trap 在边界检查内发出,这意味着它们必然存在于非均匀控制流中。对于某些不使用均匀性的程序可能没问题,但通常来讲这会使 trap 难以使用。

2. 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 和 Metal Shading Language(经过 [[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 <float4>。这些尖括号的存在并不意味着模板或泛型;该语言没有那些设施(为简单起见)。容许使用它们的惟一类型是一组有限的内置类型。这种设计是容许这些类型(介于 HLSL 中)之间的中间地带,但也容许以社区组可使用尖括号字符的方式进一步开发语言。

深度 textures 与非深度 textures 不一样,由于它们是 Metal Shading Language 中的不一样类型,所以编译器须要知道在发出 Metal Shading Language时要发出哪个。由于 WHLSL 不支持成员函数,因此 textures 采样不像 texture.Sample(...) ;相反,它是使用像 Sample(texture,...) 这样的自由函数完成的。

采样器不专业;全部用例都有一个采样器类型。你能够将此采样器用于深度 textures 和非深度 textures。深度 textures 支持采样器中的比较操做等内容。若是采样器配置为包含深度比较而且它与非深度 textures 一块儿使用,则忽略深度操做。

WebGPU API 将在特定位置自动发出一些资源障碍,这意味着 API 须要知道着色器中使用了哪些资源。所以,不能使用 “无约束” 的资源模型。这意味着全部资源都被列为着色器的显式输入。相似地,API 想知道哪些资源用于读取以及哪些资源用于写入;编译器经过检查程序来静态地知道这一点。 “const” 没有语言级支持,或者 StructuredBuffer 和 RWStructuredBuffer 之间没有区别,由于该信息已经存在于程序中。


当前进展

WebGPU 社区小组正在研究用 OTT 编写的正式语言规范,该规范描述了 WHLSL 与其余 Web 语言采用的严格程度。 咱们还在研究能够生成 Metal Shading Language,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 项目上作这项工做。咱们一直在研究语言的正式规范,发出 Metal Shading Language和 SPIR-V 的参考编译器,以及用于验证正确性的 CPU 端解释器。咱们欢迎你们尝试一下,让咱们知道它是怎么回事!

欲了解更多信息,您能够经过 mmaxfield@apple.com 或 @Litherum 与我联系,或者您能够联系咱们的布道者 Jonathan Davis。

英文原文:https://webkit.org/blog/8482/web-high-level-shading-language/

“UC国际技术”致力于与你共享高质量的技术文章
欢迎微信搜索 UC国际技术 关注咱们的公众号,或者将文章分享给你的好友
相关文章
相关标签/搜索