网络上的三角形 Ch1 画一些东西
本系列介绍 WebGPU 和一般的计算机图形学。
首先让我们看看要构建什么,
人生游戏

3D 渲染

3D 渲染,但带有灯光

渲染 3D 模型

除了 JS 的基础知识外,不需要任何预备知识。
本教程已经在我的 github 上完成,并附带源代码。
WebGPU 是一种相对较新的 GPU API。尽管名为 WebGPU,但实际上可以将其视为 Vulkan、DirectX 12 和 Metal、OpenGL 和 WebGL 之上的一层。它被设计为低级 API,旨在用于高性能应用程序,例如游戏和模拟。
在本章中,我们将在屏幕上绘制一些东西。第一部分将参考 Google Codelabs 教程。我们将在屏幕上创建一个生命游戏。
起点
我们将在 vite 中创建一个空的 vanilla JS 项目并启用 typescript。然后清除所有多余的代码,只留下 `main.ts`。
const main = async () => { console.log('Hello, world!') } main()
在实际编码之前,请检查您的浏览器是否启用了 WebGPU。您可以在 WebGPU 示例中进行检查。
Chrome 现在默认启用。在 Safari 上,您应该转到开发人员设置、标志设置并启用 WebGPU。
我们还需要为 WebGPU 启用类型,安装 `@webgpu/types`,并在 tsc 编译器选项中添加 `"types": ["@webgpu/types"]`。
此外,我们替换了“ ` 与 `在 `index.html` 中。
绘制三角形
WebGPU 有很多样板代码,如下所示。
请求设备
首先,我们需要访问 GPU。在 WebGPU 中,这是通过“适配器”的概念来实现的,它是 GPU 和浏览器之间的桥梁。
const adapter = await navigator.gpu.requestAdapter();
然后我们需要从适配器请求一个设备。
const device = await adapter.requestDevice(); console.log(device);
配置画布
我们在画布上绘制三角形。我们需要获取画布元素并对其进行配置。
const canvas = document.getElementById('app') as HTMLCanvasElement; const context = canvas.getContext("webgpu")!; const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device: device, format: canvasFormat, });
这里我们使用 getContext 来获取画布的相关信息,通过指定 webgpu ,我们将获得一个负责使用 WebGPU 进行渲染的上下文。
`CanvasFormat` 其实就是颜色模式,比如 `srgb`,我们一般就用自己喜欢的格式就行。
最后,我们使用设备和格式配置上下文。
理解 GPU 渲染管道
在进一步了解工程细节之前,我们首先必须了解 GPU 如何处理渲染。
GPU 渲染管道是 GPU 渲染图像所采取的一系列步骤。
在 GPU 上运行的应用程序称为着色器。着色器是在 GPU 上运行的程序。着色器有一种特殊的编程语言,我们将在后面讨论。
渲染管道包含以下步骤,
根据图元(GPU 可以渲染的最小单位),管道可能具有不同的步骤。通常,我们使用三角形,这向 GPU 发出信号,将每 3 组顶点视为一个三角形。
创建渲染过程
渲染通道是完整 GPU 渲染的一个步骤。创建渲染通道后,GPU 将开始渲染场景,渲染完成后,GPU 将开始渲染场景。
为了创建渲染过程,我们需要创建一个编码器,负责将渲染过程编译为 GPU 代码。
const encoder = device.createCommandEncoder();
然后我们创建一个渲染过程。
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(), loadOp: "clear", storeOp: "store", }] });
在这里,我们创建一个带有颜色附件的渲染通道。附件是 GPU 中的一个概念,表示要渲染的图像。图像可能有许多方面需要 GPU 处理,每个方面都是一个附件。
这里我们只有一个附件,即颜色附件。视图是 GPU 将在其上进行渲染的面板,这里我们将其设置为画布的纹理。
`loadOp` 是 GPU 在渲染过程之前将执行的操作,`clear` 表示 GPU 将首先清除上一帧的所有数据,而 `storeOp` 是 GPU 在渲染过程之后将执行的操作,`store` 表示 GPU 会将数据存储到纹理中。
`loadOp` 可以是 `load`,它保留上一帧的数据,也可以是 `clear`,它清除上一帧的数据。`storeOp` 可以是 `store`,它把数据存储到纹理中,也可以是 `discard`,它丢弃数据。
现在,只需调用“pass.end()”即可结束渲染过程。现在,命令已保存在 GPU 的命令缓冲区中。
要获取已编译的命令,请使用以下代码,
const commandBuffer = encoder.finish();
最后将命令提交给GPU的渲染队列。
device.queue.submit([commandBuffer]);
现在,您应该看到一块丑陋的黑色画布。
根据我们对 3D 的刻板概念,我们期望空白区域是蓝色。我们可以通过设置清除颜色来实现这一点。
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(), loadOp: "clear", clearValue: { r: 0.1, g: 0.3, b: 0.8, a: 1.0 }, storeOp: "store", }] });
使用着色器绘制三角形
现在,我们将在画布上绘制一个三角形。我们将使用着色器来实现这一点。着色器语言将是 wgsl(WebGPU 着色语言)。
现在,假设我们要绘制一个具有以下坐标的三角形,
(-0.5, -0.5), (0.5, -0.5), (0.0, 0.5)
正如我们之前所说,为了完成渲染管道,我们需要一个顶点着色器和一个片段着色器。
顶点着色器
使用以下代码创建着色器模块。
const cellShaderModule = device.createShaderModule({ label: "shader", code: ` // Shaders ` });
这里的“label”只是一个名称,用于调试。“code”是实际的着色器代码。
顶点着色器是一个接受任意参数并返回顶点位置的函数。然而,与我们的预期相反,顶点着色器返回的是一个四维向量,而不是三维向量。第四维是“w”维,用于透视除法。我们稍后会讨论它。
现在,你可以简单地将四维向量 (x, y, z, w) 视为三维向量 (x / w, y / w, z / w) 。
但是,还有另一个问题——如何将数据传递给着色器,以及如何从着色器中获取数据。
为了将数据传递给着色器,我们使用“vertexBuffer”,这是一个包含顶点数据的缓冲区。我们可以使用以下代码创建一个缓冲区,
const vertexBuffer = device.createBuffer({ size: 24, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, mappedAtCreation: true, });
这里我们创建一个大小为 24 字节、6 个浮点数的缓冲区,这是顶点的大小。
`usage` 是缓冲区的使用情况,对于顶点数据,它是 `VERTEX`。`GPUBufferUsage.COPY_DST` 表示此缓冲区可作为复制目标。对于所有数据由 CPU 写入的缓冲区,我们都需要设置此标志。
这里的 map 表示将缓冲区映射到 CPU,这意味着 CPU 可以读写缓冲区。unmap 表示取消缓冲区的映射,这意味着 CPU 不能再读写缓冲区,因此内容可供 GPU 使用。
现在,我们可以将数据写入缓冲区。
new Float32Array(vertexBuffer.getMappedRange()).set([ -0.5, -0.5, 0.5, -0.5, 0.0, 0.5, ]); vertexBuffer.unmap();
在这里,我们将缓冲区映射到 CPU,并将数据写入缓冲区。然后我们取消映射缓冲区。
`vertexBuffer.getMappedRange()` 将返回映射到 CPU 的缓冲区的范围。我们可以使用它来将数据写入缓冲区。
但是这些只是原始数据,GPU 不知道如何解释它们。我们需要定义缓冲区的布局。
const vertexBufferLayout: GPUVertexBufferLayout = { arrayStride: 8, attributes: [{ format: "float32x2", offset: 0, shaderLocation: 0, }], };
这里,arrayStride 是 GPU 在寻找下一个输入时需要在缓冲区中向前跳过的字节数。例如,如果 arrayStride 为 8,则 GPU 将跳过 8 个字节以获取下一个输入。
由于这里我们使用了“float32x2”,所以步幅为 8 个字节,每个浮点数 4 个字节,每个顶点 2 个浮点数。
现在我们可以编写顶点着色器了。
const shaderModule = device.createShaderModule({ label: "shader", code: ` @vertex fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f { return vec4f(pos, 0, 1); } ` });
这里,“@vertex”表示这是一个顶点着色器。“@location(0)”表示属性的位置,如前所述,为 0。请注意,在着色器语言中,您正在处理缓冲区的布局,因此每当您传递一个值时,您都需要传递一个结构体(其字段已定义“@location”),或者只是一个带有“@location”的值。
`vec2f` 是二维浮点向量,而 `vec4f` 是四维浮点向量。由于顶点着色器需要返回 vec4f 位置,因此我们需要使用 `@builtin(position)` 对其进行注释。
片段着色器
类似地,片段着色器是接收插值顶点输出并输出附件(在本例中为颜色)的东西。插值意味着尽管顶点上只有某些像素具有确定的值,但对于其他每个像素,值都是插值的,可以是线性的、平均的或其他方式。片段的颜色是一个四维向量,它是片段的颜色,分别是红色、绿色、蓝色和 alpha。
请注意颜色范围是 0 到 1,而不是 0 到 255。此外,片段着色器定义每个顶点的颜色,而不是三角形的颜色。三角形的颜色由顶点颜色通过插值确定。
由于我们目前不想控制片段的颜色,我们可以简单地返回一个常量颜色。
const shaderModule = device.createShaderModule({ label: "shader", code: ` @vertex fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f { return vec4f(pos, 0, 1); } @fragment fn fragmentMain() -> vec4{ return vec4 (1.0, 1.0, 0.0, 1.0); } ` });
渲染管道
然后我们通过替换顶点和片段着色器来定义定制的渲染管道。
const pipeline = device.createRenderPipeline({ label: "pipeline", layout: "auto", vertex: { module: shaderModule, entryPoint: "vertexMain", buffers: [vertexBufferLayout] }, fragment: { module: shaderModule, entryPoint: "fragmentMain", targets: [{ format: canvasFormat }] } });
注意,在片段着色器中,我们需要指定目标的格式,也就是画布的格式。
绘制调用
在渲染过程结束之前,我们添加了绘制调用。
pass.setPipeline(pipeline); pass.setVertexBuffer(0, vertexBuffer); pass.draw(3);
这里,在`setVertexBuffer`中,第一个参数是缓冲区的索引,在管道定义字段`buffers`中,第二个参数是缓冲区本身。
调用 draw 时,参数是要绘制的顶点数。由于我们有 3 个顶点,因此我们绘制 3 个。
现在,您应该在画布上看到一个黄色三角形。
绘制生命游戏细胞
现在我们稍微调整一下代码——因为我们想要构建一个生活游戏,所以我们需要绘制正方形而不是三角形。
正方形其实就是两个三角形,所以我们需要画 6 个顶点。这里的变化很简单,不需要详细解释。
const main = async () => { const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { console.error('WebGPU not supported'); return; } const device = await adapter.requestDevice(); console.log(device); const canvas = document.getElementById('app') as HTMLCanvasElement; const context = canvas.getContext("webgpu")!; const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device: device, format: canvasFormat, }); const vertices = [ 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, ] const vertexBuffer = device.createBuffer({ size: vertices.length * 4, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, mappedAtCreation: true, }); new Float32Array(vertexBuffer.getMappedRange()).set(vertices); const vertexBufferLayout: GPUVertexBufferLayout = { arrayStride: 8, attributes: [{ format: "float32x2", offset: 0, shaderLocation: 0, }], }; const shaderModule = device.createShaderModule({ label: "shader", code: ` @vertex fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f { return vec4f(pos, 0, 1); } @fragment fn fragmentMain() -> vec4{ return vec4 (1.0, 1.0, 0.0, 1.0); } ` }); vertexBuffer.unmap(); const pipeline = device.createRenderPipeline({ label: "Cell pipeline", layout: "auto", vertex: { module: shaderModule, entryPoint: "vertexMain", buffers: [vertexBufferLayout] }, fragment: { module: shaderModule, entryPoint: "fragmentMain", targets: [{ format: canvasFormat }] } }); const encoder = device.createCommandEncoder(); const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(), loadOp: "clear", clearValue: { r: 0.1, g: 0.3, b: 0.8, a: 1.0 }, storeOp: "store", }] }); pass.setPipeline(pipeline); pass.setVertexBuffer(0, vertexBuffer); pass.draw(vertices.length / 2); pass.end(); const commandBuffer = encoder.finish(); device.queue.submit([commandBuffer]); }
现在,您应该在画布上看到一个黄色的方块。
坐标系
我们没有讨论 GPU 的坐标系。它相当简单。GPU 的实际坐标系是右手坐标系,这意味着 x 轴指向右侧,y 轴指向上方,z 轴指向屏幕外。
坐标系的范围是-1~1,原点在屏幕中心,z轴的范围是0~1,0是近平面,1是远平面。不过z轴是代表深度的,做3D渲染的时候不能只用z轴来判断物体的位置,需要用透视除法,这个叫NDC,即规范化设备坐标。
比如你想在屏幕左上角画一个正方形,它的顶点是(-1,1),(-1,0),(0,1),(0,0),但你需要用两个三角形来绘制它。