目标:打通 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 怎么解释?增量更新会瞬间起作用吗?
- 第二个参数
0是bufferOffset(字节偏移量),表示从这块显存的最开头位置开始写入数据。 - 增量更新:如果只想更新第三个顶点的位置,可以算出偏移量(如 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 警报:如果以地球球心为原点 ,卫星距离可能有 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、时间、光照等多个参数,没有结构体你的代码将是一场灾难。