Webkit推出新的着色语言whlsl

本文将介绍一门叫作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语言一样,你可以使用\u0026amp;运算符创建指向lvalue的指针,并可以使用*运算符取消引用。与C语言不同的是,你不能像数组那样对指针进行索引。你不能将其与标量之间进行转换,也不能使用特定的位模式表示。因此,它不能出现在缓冲区中或作为着色器输入/输出。

WHLSL有4种不同的堆:device、constant、threadgroup和thread。所有的引用类型都必须使用它们指向的地址空间进行标记。

device地址空间对应于设备上的大部分内存。内存是可读写的,对应于Direct3D中的无序访问视图以及Metal Shading Language中的device内存。constant地址空间对应于内存的只读区域,通常针对广播到每个线程的数据进行优化。最后,threadgroup地址空间对应于可读写的内存区域,该区域被线程组的每个线程共享。它只能用于计算着色器。

默认情况下,值存在于thread地址空间中:

int i = 4;thread int* j = \u0026amp;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 = \u0026amp;i;thread int[] k = @j;k[0] = 7;// i is 7// k.length is 1

在数组上使用@使数组引用指向该数组:

int3] i = int[3;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 \u0026lt; 0 || n_dot_h \u0026lt; 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 \u0026amp;value-\u0026gt;x;}

Anders比Getter或Setter更强大,因为编译器可以使用Ander来实现读取或赋值。当通过Ander读取生成属性时,编译器调用Ander,然后取消对结果的引用。在写入时,编译器也调用Ander,取消对结果的引用,并将结果分配给它。任何用户定义的类型都可以包含Getter、Setter、Ander和Indexer的任意组合。如果相同类型具有Ander以及Getter或Setter,编译器将首选Ander。

Indexers

在大多数实时着色语言中,不会使用与其列或行对应的成员来访问矩阵。相反,它们使用数组语法来访问,例如myMatrix的3 1。矢量类型通常也有这种语法:

float operator {    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\u0026lt;\u0026lt;()。

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,而在其他设备上,却是反过来的。这些结果表明,编译器应该能够为特定设备选择更合适的方法,而不是*选择一种给定的方法。

Webkit推出新的着色语言whlsl

目前的工作

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/

更多内容,可关注前端之巅公众号(ID:frontshow)。

Webkit推出新的着色语言whlsl