网络上的三角形 Ch1 画一些东西

本系列介绍 WebGPU 和一般的计算机图形学。

首先让我们看看要构建什么,

人生游戏

Life Game

3D 渲染

3D Rendering

3D 渲染,但带有灯光

3D Rendering, but with lighting

渲染 3D 模型

Rendering 3D Model

除了 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 上运行的程序。着色器有一种特殊的编程语言,我们将在后面讨论。

渲染管道包含以下步骤,

  • CPU 将数据加载到 GPU。CPU 可能会删除一些不可见的对象以节省 GPU 资源。
  • CPU 设置 GPU 渲染场景所需的所有颜色、纹理和其他数据。
  • CPU 触发对 GPU 的绘制调用。
  • GPU 从 CPU 获取数据并开始渲染场景。
  • 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),但你需要用两个三角形来绘制它。