#02:按步解析 —— 彩色三角形与顶点缓冲区

2 阅读6分钟

目标:打通 CPU 到 GPU 的数据高铁,将包含位置和颜色的顶点数组存入显存,并通过管线布局和插槽绑定,渲染出一个拥有完美渐变色的彩色三角形。

前置准备:新建 index.html,必须使用本地服务器(如 VSCode 的 Live Server)启动。

Step 1: 唤醒显卡与基础环境搭建

这一步与固定三角形完全一致,我们直接获取设备和画布上下文。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>WebGPU 调试实战 - 顶点缓冲区与彩色三角形</title>
    <style>
        body { margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #222; }
        canvas { width: 800px; height: 600px; background-color: #000; }
    </style>
</head>
<body>
    <canvas id="gpuCanvas" width="800" height="600"></canvas>
    <script>
    async function main() {
        const adapter = await navigator.gpu.requestAdapter();
        if (!adapter) throw new Error("无法获取 GPU Adapter");
        const device = await adapter.requestDevice();

        const canvas = document.getElementById('gpuCanvas');
        const context = canvas.getContext('webgpu');
        const format = navigator.gpu.getPreferredCanvasFormat();

        context.configure({ device, format, alphaMode: 'premultiplied' });

Step 2: 准备原石并装入显存 (Buffer & writeBuffer)

我们要使用“交错布局(Interleaved)”,把位置(X,Y)和颜色(R,G,B)打包在同一个一维数组中。

        // 1. 在 CPU 端准备二进制数据 (Float32Array)
        // 数据格式:[X, Y, R, G, B]
        const vertexData = new Float32Array([
             0.0,  0.5,   1.0, 0.0, 0.0, // 顶点 0:正上方,纯红
            -0.5, -0.5,   0.0, 1.0, 0.0, // 顶点 1:左下角,纯绿
             0.5, -0.5,   0.0, 0.0, 1.0  // 顶点 2:右下角,纯蓝
        ]);

        // 2. 向显卡申请一块专属的顶点缓冲区 (Warehouse)
        const vertexBuffer = device.createBuffer({
            size: vertexData.byteLength, // 总字节数 (15 个浮点数 * 4 字节 = 60 字节)
            usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, 
        });

        // 3. 呼叫异步快递员,把数据写进显存
        device.queue.writeBuffer(vertexBuffer, 0, vertexData);

        // 【调试节点 1】
        // 此时,数据已经开始在总线上传输了。你可以尝试在 writeBuffer 下面写一行:
        // vertexData[0] = 99.0; 
        // 结果是:画出来的画面不受影响。因为 writeBuffer 在执行瞬间就对内存做了快照。

Step 3: 编写带数据出入口的着色器 (WGSL)

现在着色器不能自己捏造数据了,必须打开“大门”接收 Buffer 传来的数据。

        // 4. 编写 WGSL 着色器
        const wgslCode = `
            // 桥梁结构体:负责把顶点阶段的数据运送到片元阶段
            struct VertexOutput {
                @builtin(position) position: vec4<f32>,
                @location(0) color: vec3<f32>, // 这里的 0 是片元着色器的接收通道
            };

            @vertex
            fn vs_main(
                @location(0) pos: vec2<f32>,   // 从 Buffer 接收位置
                @location(1) inColor: vec3<f32> // 从 Buffer 接收颜色
            ) -> VertexOutput {
                
                var out: VertexOutput;
                out.position = vec4<f32>(pos, 0.0, 1.0);
                out.color = inColor;
                return out;
            }

            @fragment
            fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
                // 此时的 in.color 已经被 GPU 硬件做了线性插值处理,变成了渐变色
                return vec4<f32>(in.color, 1.0); 
            }
        `;
        const shaderModule = device.createShaderModule({ code: wgslCode });

        // 【调试节点 2】
        // 观察 `vs_main` 的参数,有两个 `@location(0)` 和 `@location(1)`。
        // 这俩是专门为了对应下一步 JavaScript 管线中的 `shaderLocation` 的,写错编号屏幕就全黑。

Step 4: 打造带有插槽布局的模具 (Pipeline)

在这一步,我们必须给显卡一份“说明书”,告诉它那 60 字节的 Buffer 该怎么切分。

        // 5. 创建渲染管线
        const pipeline = device.createRenderPipeline({
            layout: 'auto',
            vertex: {
                module: shaderModule,
                entryPoint: 'vs_main',
                // 重点:显存数据读取说明书
                buffers: [{
                    arrayStride: 20, // 步长:每读 20 个字节(5个 float),就是一个新顶点
                    attributes: [
                        {
                            shaderLocation: 0, // 对应 vs_main 的 @location(0)
                            offset: 0,         // X, Y 从第 0 个字节开始
                            format: 'float32x2'// 连读 2 个 float
                        },
                        {
                            shaderLocation: 1, // 对应 vs_main 的 @location(1)
                            offset: 8,         // R, G, B 从第 8 个字节开始 (跳过前面的 2 个 float)
                            format: 'float32x3'// 连读 3 个 float
                        }
                    ]
                }]
            },
            fragment: {
                module: shaderModule,
                entryPoint: 'fs_main',
                targets: [{ format: format }] 
            },
            primitive: { topology: 'triangle-list' }
        });

        // 【调试节点 3】全网最高频报错点
        // 如果把 offset: 8 算错了写成了 offset: 4,控制台会立刻报 Validation Error。
        // GPU 会严格检查 offset 是否符合数据对齐规则。

Step 5: 在绘制时插上数据线 (Render Pass)

模具造好了,数据也在显存里了。最后一步:在录制绘制指令时,把它们拼在一起。

        // 6. 渲染循环
        function render() {
            const currentView = context.getCurrentTexture().createView();
            const encoder = device.createCommandEncoder();
            
            const pass = encoder.beginRenderPass({
                colorAttachments: [{
                    view: currentView,
                    clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 },
                    loadOp: 'clear', storeOp: 'store'
                }]
            });

            pass.setPipeline(pipeline);
            
            // 【核心动作】把写好数据的 vertexBuffer 插到 pipeline 的第 0 号插槽上
            pass.setVertexBuffer(0, vertexBuffer); 
            
            pass.draw(3); 
            pass.end(); 

            device.queue.submit([encoder.finish()]);
            requestAnimationFrame(render);
        }
        render();
    }
    main();
    </script>
</body>
</html>

💡 核心机制解惑 (基于今日进阶知识点)

Q1:device.queue.writeBuffer(vertexBuffer, 0, vertexData) 第二个参数 0 怎么解释?增量更新会瞬间起作用吗?

  • 第二个参数 0bufferOffset(字节偏移量),表示从这块显存的最开头位置开始写入数据。
  • 增量更新:如果只想更新第三个顶点的位置,可以算出偏移量(如 40 字节),并填入 writeBuffer(vertexBuffer, 40, newData),实现极低带宽占用的局部刷新。
  • 执行时机:JS 执行 writeBuffer 仅做快照并进入队列。但 WebGPU 调度器保证了严格的指令顺序,只要 writeBuffer 发生在 submit() 之前,GPU 就绝对不会画出旧数据,增量更新在下一帧画面上是“瞬间”无闪烁生效的。

Q2:pass.setVertexBuffer(0, vertexBuffer) 这个 0 是怎么确定绑定到哪个插槽的?怎么知道对应的是我们创建的那个 pipeline?

  • 对应关系:这个 0 对应的是 pipeline 创建时,vertex.buffers 数组的索引 (Index) 。我们在数组的第一个位置(索引为 0)写了那份步长为 20 的布局说明书,所以渲染时就要插在 0 号槽位。
  • 无主绑定:Buffer 本身不属于任何 Pipeline。是你在 beginRenderPass 后,先 setPipeline(挂上模具),紧接着 setVertexBuffer(0, ...),在这一刻真正完成了数据源与管线的动态对接。

Q3:createBuffer 开销大不大?位置颜色写一起(交错布局)好,还是单独创建好?

  • 开销极大createBuffer 涉及内核态切换和显存分配,严禁在渲染循环(render)中高频调用。
  • 写在一起(交错布局) :GPU 缓存命中率最高,读取极快。适合你的卫星 3D 模型实体这种数据结构固定、一变全变的静态网格。
  • 单独存放(非交错布局) :灵活性强,省总线带宽。如果针对的是海量动态卫星,其中坐标每秒都在变,但归属国颜色不变,那就分两个 Buffer,每帧只 writeBuffer 坐标,颜色 Buffer 不动,性能收益远大于交错布局。

Q4:着色器里的 f32 意味着单精度吗?如果以后画高精度卫星系统精度够不够?

  • 是单精度。在日常的 3D 模型或者你的这个 Demo 中完全够用。
  • GIS 警报:如果以地球球心为原点 (0,0,0)(0,0,0),卫星距离可能有 6000 多公里,此时 f32 的 7 位有效数字会耗尽,导致在屏幕上放大查看时发生强烈的坐标抖动(Jittering) 。在后续项目中,必须在 JS 端用双精度算出相对于当前相机的局部偏移(RTC),再将缩小的数值传给着色器。

Q5:不使用 VertexOutput 结构体,直接传多个参数怎么写?

你可以直接让顶点着色器返回一个元组(Tuple):

fn vs_main(...) -> (@builtin(position) vec4<f32>, @location(0) vec3<f32>) { return (pos, color); }

片元着色器直接接收:fn fs_main(@location(0) color: vec3<f32>)

避坑:这种写法省代码,但强烈不推荐在开发复杂的 GIS 项目时使用。一旦引入法线、UV、时间、光照等多个参数,没有结构体你的代码将是一场灾难。