使用taichi.js进行WebGPU编程
作者:互联网
模拟
让我们使用taichi.js
深入了解生命游戏的实现。首先,我们在速记ti
下导入taichi.js
库,并定义一个async main()
函数,该函数将包含我们所有的逻辑。在main()
我们从调用ti.init()
开始,它初始化库及其WebGPU上下文。
从“path/to/taichi.js”导入*作为ti let main = async () => { 等待ti.init(); ... }; 主()
ti.init()
之后,让我们定义“生命游戏”模拟所需的数据结构:
让 N = 128; 让活力 = ti.field(ti.i32, [N, N]) let numNeighbors = ti.field(ti.i32, [N, N]) ti.addToKernelScope({ N, liveness, numNeighbors });
在这里,我们定义了两个变量,liveness
和numNeighbors
,两者都是ti.field
。在taichi.js
中,“字段”本质上是一个n维数组,其维度在ti.field()
的第二个参数中提供了。数组的元素类型在第一个参数中定义。在这种情况下,我们有ti.i32
,表示32位整数。然而,场元素也可能是更复杂的类型,包括向量、矩阵和结构。
下一行代码ti.addToKernelScope({...})
确保变量N
、liveness
和numNeighbors
在taichi.js
“内核”中可见,这些内核是以JavaScript函数形式定义的GPU计算和/或渲染管道。例如,以下init
内核用于用初始活力谷填充我们的网格单元格,其中每个单元最初有20%的几率存活:
let init = ti.kernel(() => { 对于(让I of ti.ndrange(N,N)){ 活力[I] = 0 让f = ti.random() 如果 (f < 0.2) { 活力[I] = 1 } } }) init()
init()
内核是通过调用以JavaScript lambda作为参数的ti.kernel()
来创建的。在引擎盖下,taichi.js
将查看此lambda的JavaScript字符串表示,并将其逻辑编译为WebGPU代码。在这里,lambda包含一个for
-loop,其循环索引I
迭代throughtiti.ndrange(N, N)
这意味着我将采用N
不同的值,从[0, 0]
到[N-1, N-1]
。
神奇的部分来了——在taichi.js
中,内核中的所有顶级for
-loop都将被并行化。更具体地说,对于循环索引的每个可能值,taichi.js
将分配一个WebGPU计算着色器线程来执行它。在这种情况下,我们在“生命游戏”模拟中为每个单元格分配一个GPU线程,将其初始化为随机的活力状态。随机性来自ti.random()
函数,这是taichi.js
库中为内核使用提供的众多函数之一。这些内置实用程序的完整列表可在taichi.js
文档中找到。
在创建了游戏的初始状态后,让我们继续定义游戏是如何演变的。这些是定义这种演变的两个taichi.js
内核:
let countNeighbors = ti.kernel(() => { 对于(让I of ti.ndrange(N,N)){ 让邻居 = 0 对于(让ti.ndrange的三角洲(3,3)){ let J = (I + 增量 - 1) % N 如果((J.x!= I.x || J.y != I.y) && liveness[J] == 1) { 邻居 = 邻居 + 1; } } numNeighbors[I] = 邻居 } }); let updateLiveness = ti.kernel(() => { 对于(让I of ti.ndrange(N,N)){ 让邻居 = num邻居[I] 如果(活力[I] == 1){ 如果(邻居<2 ||邻居>3){ 活力[I] = 0; } } 其他{ 如果(邻居== 3){ 活力[I] = 1; } } } })
与我们之前看到的init()
内核一样,这两个内核也有顶级的循环for
在每个网格单元格上迭代,这些单元格由编译器并行化。在countNeighbors()
对于每个单元格,我们查看八个相邻的单元格,并计算这些邻居中有多少是“在”的。
活邻居的数量存储在numNeighbors
字段中。请注意,当通过邻居迭代时,for (let delta of ti.ndrange(3, 3)) {...}
的循环不是并行的,因为它不是顶级循环。循环索引delta
范围从[0, 0]
到[2, 2]
,用于偏移原始单元格索引I
。我们通过在N
上采取模组来避免越界访问。(对于拓扑倾斜的读者来说,这本质上意味着游戏具有环形边界条件)。
在计算了每个单元格的邻居数量后,我们在updateLiveness()
内核中更新了它们的活力状态。这是一个简单的问题,即读取每个单元格的活力状态及其当前活邻居的数量,并根据游戏规则回写一个新的活力值。像往常一样,这个过程并行适用于所有细胞。
这基本上结束了游戏模拟逻辑的实现。接下来,我们将看到如何定义WebGPU渲染管道,以将游戏的演变绘制到网页上。
透视图
在taichi.js
编写渲染代码比编写通用计算内核要复杂一些,它确实需要对顶点着色器、片段着色器和光栅化管道有一定的了解。然而,你会发现taichi.js
的简单编程模型使这些概念非常容易处理和推理。
在绘制任何东西之前,我们需要访问我们正在绘制的一块画布。假设HTML中存在名为result_canvas
的画布,以下代码行会创建一个ti.CanvasTexture
对象,该对象表示可以通过taichi.js
渲染管道渲染的纹理。
let htmlCanvas = document.getElementById('result_canvas'); htmlCanvas.width = 512; htmlCanvas.height = 512; let renderTarget = ti.canvasTexture(htmlCanvas);
在我们的画布上,我们将渲染一个正方形,并将游戏的2D网格绘制到这个正方形上。在GPU中,要渲染的几何形状表示为三角形。在这种情况下,我们试图渲染的正方形将表示为两个三角形。这两个三角形在ti.field
中定义,该字段存储两个三角形六个顶点的坐标:
let vertices = ti.field(ti.types.vector(ti.f32, 2), [6]); await vertices.fromArray([ [-1, -1], [1, -1], [-1,1], [1, -1], [1,1], [-1,1], ]);
正如我们对liveness
和numNeighbors
字段所做的那样,我们需要明确声明在taichi.js
的GPU内核中可见的renderTarget
和vertices
变量:
ti.addToKernelScope({ vertices, renderTarget });
现在我们拥有了实现渲染管道所需的所有数据。以下是管道本身的实现:
let render = ti.kernel(() => { ti.clearColor(renderTarget,[0.0,0.0,0.0,1.0]); 对于(let v of ti.inputVertices(vertices)){ ti.outputPosition([v.x,v.y,0.0,1.0]); ti.outputVertex(v); } for (let f of ti.inputFragments()) { let coord = (f + 1) / 2.0; let texelIndex = ti.i32(coord *(liveness.dimensions - 1); let live = ti.f32(活力[texelIndex]); ti.outputColor(renderTarget,[直播,直播,直播,1.0]); } });
接下来,我们定义了两个顶级for
-loop,如您所知,它们是在WebGPU中并行化的循环。然而,与我们之前迭代ti.ndrange
对象的循环不同,这些循环分别迭代overtiti.inputVertices(vertices)
和ti.inputFragments()
这表明这些循环将被编译为WebGPU“顶点着色器”和“片段着色器”,它们作为渲染管道一起工作。
顶点着色器有两个责任:
- 对于每个三角形顶点,计算其在屏幕上的最终位置(或者更准确地说,其“剪辑空间”坐标)。在3D渲染管道中,这通常涉及一堆矩阵乘法,这些乘法将顶点的模型坐标转换为世界空间,然后转换为相机空间,最后转换为“剪辑空间”。然而,对于我们简单的2D正方形,顶点的输入坐标在剪辑空间中已经处于正确的值,因此我们可以避免所有这些。我们所要做的就是附加一个0.0的固定
z
值和1.0
的固定w
值(如果你不知道这些是什么,别担心——这里并不重要!)。
ti.outputPosition([v.x,v.y,0.0,1.0]);
- 对于每个顶点,生成要插值的数据,然后传递到片段着色器中。在渲染管道中,在执行顶点着色器后,在所有三角形上执行一个称为“光栅化”的内置进程。这是一个硬件加速的过程,为每个三角形计算哪个像素被这个三角形覆盖。这些像素也被称为“碎片”。
对于每个三角形,程序员可以在三个顶点中的每个顶点生成额外的数据,这些数据将在光栅化阶段进行插值。对于像素中的每个片段,其相应的片段着色器线程将根据其在三角形中的位置接收插值。在我们的案例中,片段着色器只需要知道片段在2D正方形中的位置,这样它就可以获取游戏的相应活力值。
为此,将2D顶点坐标传递到光栅化器中就足够了,这意味着片段着色器将接收像素本身的插值2D位置:
ti.outputVertex(v);
以下是片段着色器的代码的样子:
for (let f of ti.inputFragments()) { let coord = (f + 1) / 2.0; let cellIndex = ti.i32(coord *(liveness.dimensions - 1); let live = ti.f32(liveness[cellIndex]); ti.outputColor(renderTarget,[直播,直播,直播,1.0]); }
值f
是从顶点着色器传递的插值像素位置。使用此值,片段着色器将在覆盖此像素的游戏中查找细胞的活跃状态。这是通过首先将像素坐标f
转换为[0, 0] ~ [1, 1]
范围,并将此坐标存储到coord
变量中来完成的。然后,这乘以liveness
场的尺寸,产生覆盖单元格的指数。
最后,我们获取此单元格的live
值,如果它死了,则为0
如果它活着,则为1
。它将此像素的RGBA值输出到renderTarget
,其中R、G、B组件都等于live
,A组件等于1,以获得完全不透明度。
定义了渲染管道,剩下的就是通过调用模拟内核和每个帧的渲染管道来将所有内容放在一起:
异步函数frame() { countNeighbors() 更新活力() 等待渲染(); requestAnimationFrame(框架); } 等待框架();
就是这样!我们在taichi.js
中完成了基于WebGPU的“生命游戏”实现。
如果您运行该程序,您应该会看到以下动画,其中128x128细胞进化了大约1400代,然后汇聚成几种稳定的生物体。
标签:WebGPU, JavaScript, GPU 来源: