无框架手写WebGPU代码
通过框架,我们可以迅速地跟上技术的前沿。但是,框架的封装也容易让我们迷失对于技术本质的把握。
现在我们来看看如何手写WebGPU代码。
1、从Canvas说起
不管是WebGL还是WebGPU,都是对于Canvas的扩展。做为HTML 5的重要新增功能,大家对于2D的Canvas应该都不陌生。
比如我们要画一个三角形,就可以调用lineTo API来实现:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Canvas</title> </head> <body> <canvas id="webcanvas" width="200" height="200" style="background-color: #eee"></canvas> <script> const canvas=document.getElementById('webcanvas'); const ctx=canvas.getContext('2d'); ctx.beginPath(); ctx.moveTo(75,50); ctx.lineTo(100,75); ctx.lineTo(100,25); ctx.fill(); </script> </body>
画出来的结果如下:
我们要修改画出来的图的颜色怎么办?
ctx有fillStyle属性,支持CSS的颜色字符串。
比如我们设成红色,可以这么写:
ctx.fillStyle = 'red';
也可以这么写:
ctx.fillStyle = '#F00';
还可以这么写:
ctx.fillStyle = 'rgb(255,0,0,1)';
2、从2D到3D
从2D Canvas到3D WebGL的最大跨越,就是从调用API,到完全不同于JavaScript的新语言GLSL的出场。
第一步的步子我们迈得小一点,不画三角形了,只画一个点。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Test OpenGL for a point</title> </head> <body> <canvas id="webgl" width="500" height="500" style="background-color: blue"></canvas> <script> const canvas = document.getElementById('webgl'); const gl = canvas.getContext('webgl'); const program = gl.createProgram(); const vertexShaderSource = ` void main(){ gl_PointSize=sqrt(20.0); gl_Position =vec4(0.0,0.0,0.0,1.0); }`; const vertexShader = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(vertexShader, vertexShaderSource); gl.compileShader(vertexShader); gl.attachShader(program, vertexShader); const fragShaderSource = ` void main(){ gl_FragColor = vec4(1.0,0.0,0.0,1.0); } `; const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(fragmentShader, fragShaderSource); gl.compileShader(fragmentShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); gl.useProgram(program); gl.drawArrays(gl.POINTS, 0, 1); </script> </body> </html>
getContext时将2d换成webgl。
我们可以加一行console.log(gl)
来看下gl是什么东西:
我们可以看到,它是一个WebGLRenderingContext对象。
顺便说一句,之前我们拿到的2D的Context是CanvasRenderingContext2D。
下面就引入了两段程序中的程序,第一段叫做顶点着色器,用于顶点的坐标信息。第二段叫做片元着色器,用于配置如何进行一些属性的操作,在本例中我们做一个最基本的操作,改颜色。
我们先看顶点着色器的代码:
void main(){ gl_PointSize=sqrt(20.0); gl_Position =vec4(0.0,0.0,0.0,1.0); }
像其他语言一样,glsl中的代码也需要一个入口函数。
gl_PointSize是一个系统变量,用于存储点的大小。我特意给大小加个了sqrt函数,给大家展示glsl的库函数。
gl_Position用于存储起点的位置。vec4是由4个元素构成的向量。
GLSL的数据类型很丰富,包括标量、向量、数组、矩阵、结构体和采样器等。
标量有布尔型bool, 有符号整数int, 无符号整数uint和浮点数float 4种类型。
类型的使用方式跟C语言一样,比如我们用float来定义浮点变量。
float pointSize = sqrt(20.0); gl_PointSize=pointSize;
GLSL没有double这样表示双精度的类型。在顶点着色器中是没有精度设置的。
但是在片元着色器中有精度的设置,需要指定低精度lowp, 中精度mediump和高精度highp. 一般采用中精度:
void main(){ mediump vec4 pointColor; pointColor.r = 1.0; pointColor.a = 1.0; gl_FragColor = pointColor; }
GLSL因为是基于C语言设计的,不支持泛型,所以每种向量同时有4种子类型的。
以四元组vec4为例,有4种类型:
- vec4: 浮点型向量
- ivec4: 整数型向量
- uvec4: 无符号整数向量
- bvec4: 布尔型向量。
另外还有vec2, vec3各有4种子类型,以此类推。
在GLSL里面,四元向量最常用的用途有两种,在顶点着色器里充当坐标,和在片元着色器里充当颜色。
当vec4作为坐标使用时,我们可以用x,y,z,w属性来对应4个维度。
我们来看个例子:
vec4 pos; pos.x = 0.0; pos.y = 0.0; pos.z = 0.0; pos.w = 1.0; gl_Position = pos;
同样,我们在片元着色器里面表示红色的时候只用指令r和a两个属性,g,b让它们默认是0:
void main(){ mediump vec4 pointColor; pointColor.r = 1.0; pointColor.a = 1.0; gl_FragColor = pointColor; }
有了顶点着色器和片元着色器的GLSL代码之后,我们将其进行编程,并attach到program上面。
最后再link和use这个program,就可以调用drawArrays来进行绘制了。
3、更现代的GPU编程方法
跨越了从 Canvas API到GLSL的鸿沟了之后,最后到WebGPU这一步相对就容易一些了。
我们要熟悉的是以Vulkan为代表的更现代的GPU的编程方法。
渲染管线不再是唯一,我们可以使用更通用的计算管线了。也不再有顶点着色器和片元着色器那么严格的限制。
另外最重要的一点是,为了提升GPU执行效率,WebGPU不再是像WebGL一样基本每一步都要由CPU来控制,我们使用commandEncoder将所有GPU指令打包在一起,一次性执行。
我们先看一下完整代码有个印象:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Test WebGPU</title> </head> <body> <canvas id="webgpu" width="500" height="500" style="background-color: blue"></canvas> <script> async function testGPU() { const canvas = document.getElementById('webgpu'); const gpuContext = canvas.getContext('webgpu'); const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); presentationFormat = gpuContext.getPreferredFormat(adapter); gpuContext.configure({ device, format: presentationFormat }); const triangleVertWGSL = ` @stage(vertex) fn main(@builtin(vertex_index) VertexIndex : u32) -> @builtin(position) vec4<f32> { var pos = array<vec2<f32>, 3>( vec2<f32>(0.0, 0.5), vec2<f32>(-0.5, -0.5), vec2<f32>(0.5, -0.5)); return vec4<f32>(pos[VertexIndex], 0.0, 1.0); } `; const redFragWGSL = ` @stage(fragment) fn main() -> @location(0) vec4<f32> { return vec4<f32>(1.0, 0.0, 0.0, 1.0); } ` const commandEncoder = device.createCommandEncoder(); const textureView = gpuContext.getCurrentTexture().createView(); const pipeline = device.createRenderPipeline({ vertex: { module: device.createShaderModule({ code: triangleVertWGSL, }), entryPoint: 'main', }, fragment: { module: device.createShaderModule({ code: redFragWGSL, }), entryPoint: 'main', targets: [ { format: presentationFormat, }, ], }, primitive: { topology: 'triangle-list', }, }); const renderPassDescriptor = { colorAttachments: [ { view: textureView, loadValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, storeOp: 'store', }, ], }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); console.log(passEncoder); passEncoder.setPipeline(pipeline); passEncoder.draw(3, 1, 0, 0); passEncoder.end(); device.queue.submit([commandEncoder.finish()]); } testGPU(); </script> </body> </html>
因为浏览器还没有支持,所以我们需要像Chrome Canary这样的支持最新技术的浏览器。而且还要打开支持的开关,比如在Chrome Canary里是enable-unsafe-webgpu.
三角形画出来的结果如下:
现在的Context从WebGL的WebGLRenderingContext变成了GPUCanvasContext。
WGSL语言的语法更像Rust,vec4这样的容器可以用泛型的写法绑定类型:
@stage(vertex) fn main(@builtin(vertex_index) VertexIndex : u32) -> @builtin(position) vec4<f32> { var pos = array<vec2<f32>, 3>( vec2<f32>(0.0, 0.5), vec2<f32>(-0.5, -0.5), vec2<f32>(0.5, -0.5)); return vec4<f32>(pos[VertexIndex], 0.0, 1.0); }
对比下Rust的代码看看像不像:
fn fib2(n: i32) -> i64 { if n <= 2 { return 1i64 } else { return fib2(n - 1) + fib2(n - 2) } }
WGSL是为了规避知识产权问题发明的新语言,本质上它和GLSL,HLSL等语言一样,都可以编译成Vulkan的SPIR-V二进制格式:
.
Vulkan不限制使用什么样的语言,既可以使用GLSL, HLSL,也可以使用Open CL或者是Open CL的高级封装SYCL。
转换成SPIR-V格式之后,可以转成iOS上的Metal Shading Language,也可以转成Windows Direct 12上用的DXIL。
WebGPU没有这么自由,发明了一门新语言WGSL,不过其思想都是基于SPIR-V的。
在WebGPU和WGSL还未定版,资料还比较缺乏的情况下,我们可以先学习Vulkan相关的知识,然后迁移到WebGPU上来。本质上是同样的东西,只是封装略有不同。
我们之前学习的GLSL的知识同样用得上,而且在这种类Rust风格中可以写得更爽一些。
比如同样是给片元用的颜色值,在保留了vec4可以继续使用r,g,b,a分量的好处之外,因为指定了f32的精度,就不需要mediump了。而且,类型可以自动推断,我们直接给个var就好了:
@stage(fragment) fn main() -> @location(0) vec4<f32> { var triColor = vec4<f32>(0.0,0.0,0.0,0.0); triColor.r = 1.0; triColor.a = 1.0; return triColor; }
有了作为功能核心的WGSL,剩下的工作主要就是组装了。
我们把指令打包在 CommandEncoder中,然后通过beginRenderPass来创建一个渲染Pass,再给这个Pass设置一个渲染的流水线,添加相应的draw操作,最后提交到GPU设备的队列中,就大功告成了。
小结
相对于基于OpenGL ES 2.0的WebGL 1.0,WebGPU更接近于Vulkan这样更能发挥GPU能力的新API,可以更有效地发挥出新的GPU的能力。就像渲染上Three.js和Babylon.js给我们展示的那样和计算上Tensorflow.js的飞跃一样。
虽然浏览器还不支持,但是不成熟的主要是封装,底层的Vulkan和Metal技术已经非常成熟,并且广泛被客户端所使用了。
WebGPU这个能力暴露给H5和小程序之后,将给元宇宙等热门应用插上性能倍增的翅膀。结合WebXR等支持率更成问题的新技术一起,成为未来几年前端的主要工具。