3、第一个三角形:从零开始绘制一个彩色三角形,理解Pipeline、Shader、RenderPass的核心概念
说实话,每次带新人入门WebGPU,我都会让他们先画一个三角形。
为什么?因为三角形虽小,但五脏俱全。你想想看,一个三角形背后,Pipeline、Shader、RenderPass这三个核心概念全齐了。我当年学OpenGL的时候,光搞懂这几个概念就花了一周。现在有了WebGPU,其实思路更清晰了。
3.1 先别急着写代码,理清三个概念
在动手之前,咱们先聊聊这三个东西到底是干嘛的。我习惯用一个比喻:
- Shader(着色器):就像画家的画笔。它决定了顶点怎么摆放、像素什么颜色。
- Pipeline(管线):就像画家的作画流程。先勾线、再上色、最后调整细节。WebGPU把这一整套流程固定下来,叫Pipeline。
- RenderPass(渲染通道):就像画家的画布和颜料盘。它告诉GPU往哪里画、画完怎么保存。
嗯,这个比喻虽然糙,但道理不糙。你记住这三个角色,后面的代码就好理解了。
核心要点:Shader定义"画什么",Pipeline定义"怎么画",RenderPass定义"画在哪"。
3.2 编写第一个Shader:顶点与片元
WebGPU的Shader用WGSL语言编写。我第一次看到WGSL时觉得有点怪,但用顺手了发现它比GLSL更严谨。
咱们的三角形需要两个Shader:
- 顶点着色器:处理三个顶点的位置
- 片元着色器:给三角形内部填颜色
直接上代码:
// 顶点着色器
@vertex
fn vertexMain(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
// 三个顶点的位置,构成一个三角形
let positions = array<vec2<f32>, 3>(
vec2(0.0, 0.5), // 上顶点
vec2(-0.5, -0.5), // 左下顶点
vec2(0.5, -0.5) // 右下顶点
);
return vec4(positions[idx], 0.0, 1.0);
}
// 片元着色器
@fragment
fn fragmentMain() -> @location(0) vec4<f32> {
return vec4(1.0, 0.0, 0.0, 1.0); // 红色
}
这里有个小细节:@builtin(vertex_index)会自动传入当前是第几个顶点(0、1、2)。我刚开始学的时候老想着自己传数据进去,其实内置的就够用了。
小技巧:WGSL里vec4的最后一个分量是w,透视除法用的。2D场景里直接设成1.0就行。
3.3 创建Pipeline:把Shader串起来
有了Shader代码,接下来要创建一个Pipeline。Pipeline说白了就是告诉GPU:你用这个顶点着色器、那个片元着色器,颜色输出格式是这样的……
我习惯把Pipeline的创建分成几步:
- 编译Shader模块
- 配置管线布局(这里先不用管,用默认的)
- 配置顶点和片元阶段
- 配置颜色输出格式
看代码:
// 1. 编译Shader
const shaderModule = device.createShaderModule({
code: shaderCode // 上面那段WGSL代码
});
// 2. 创建Pipeline
const pipeline = device.createRenderPipeline({
layout: 'auto', // 简单场景用auto就行
vertex: {
module: shaderModule,
entryPoint: 'vertexMain'
},
fragment: {
module: shaderModule,
entryPoint: 'fragmentMain',
targets: [{
format: navigator.gpu.getPreferredCanvasFormat()
}]
},
primitive: {
topology: 'triangle-list' // 三角形列表模式
}
});
注意那个primitive.topology。我遇到过有人设成point-list,结果屏幕上啥也没有,找了半天bug。三角形就用triangle-list,三个顶点一组。
避坑指南:getPreferredCanvasFormat()返回的是当前浏览器最合适的纹理格式。我曾经硬编码成'bgra8unorm',结果在某个浏览器上颜色全乱了。用这个API最保险。
3.4 创建RenderPass:准备画布
Pipeline准备好了,接下来要告诉GPU往哪画。RenderPass就是干这个的。
你需要先拿到Canvas的纹理,然后创建一个RenderPass描述:
// 获取Canvas纹理
const context = canvas.getContext('webgpu');
const textureFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: textureFormat
});
// 创建命令编码器
const commandEncoder = device.createCommandEncoder();
// 创建RenderPass
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 }, // 深灰色背景
loadOp: 'clear',
storeOp: 'store'
}]
});
这里clearValue是清屏颜色。我习惯用深灰色,因为白色太刺眼,黑色又看不出效果。0.1的灰度刚刚好。
3.5 绘制三角形:把一切串起来
现在Pipeline有了,RenderPass也有了,就差最后一步——画!
// 设置管线
renderPass.setPipeline(pipeline);
// 绘制3个顶点
renderPass.draw(3, 1, 0, 0);
// 结束RenderPass
renderPass.end();
// 提交命令
device.queue.submit([commandEncoder.finish()]);
draw(3, 1, 0, 0)的意思是:画3个顶点,1个实例,从第0个顶点开始,第0个实例开始。对于单个三角形,这些参数基本固定。
嗯,到这里你应该能在屏幕上看到一个红色的三角形了。
3.6 加点颜色:让三角形更生动
纯红色太单调了。咱们给三个顶点分别指定颜色,让三角形有个渐变效果。
修改顶点着色器,输出颜色数据:
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) color: vec3<f32>
};
@vertex
fn vertexMain(@builtin(vertex_index) idx: u32) -> VertexOutput {
let positions = array<vec2<f32>, 3>(
vec2(0.0, 0.5),
vec2(-0.5, -0.5),
vec2(0.5, -0.5)
);
let colors = array<vec3<f32>, 3>(
vec3(1.0, 0.0, 0.0), // 红
vec3(0.0, 1.0, 0.0), // 绿
vec3(0.0, 0.0, 1.0) // 蓝
);
var output: VertexOutput;
output.position = vec4(positions[idx], 0.0, 1.0);
output.color = colors[idx];
return output;
}
@fragment
fn fragmentMain(@location(0) color: vec3<f32>) -> @location(0) vec4<f32> {
return vec4(color, 1.0);
}
你看,顶点着色器把颜色传给片元着色器,GPU会自动在三角形内部做插值。红绿蓝三个顶点之间,颜色会平滑过渡。这就是所谓的Gouraud着色,说白了就是颜色插值。
关键理解:顶点着色器输出的是每个顶点的数据,片元着色器收到的是插值后的数据。这个机制是GPU渲染的基石。
3.7 完整代码示例
把上面所有代码拼起来,就是一个完整的三角形绘制程序。我建议你亲手敲一遍,不要复制粘贴。敲代码的过程,其实就是理解的过程。
// 完整的主循环
function frame() {
const commandEncoder = device.createCommandEncoder();
const textureView = context.getCurrentTexture().createView();
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [{
view: textureView,
clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 },
loadOp: 'clear',
storeOp: 'store'
}]
});
renderPass.setPipeline(pipeline);
renderPass.draw(3, 1, 0, 0);
renderPass.end();
device.queue.submit([commandEncoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
3.8 小结与思考
回顾一下,咱们今天干了三件事:
- 写了两个Shader(顶点+片元)
- 创建了一个Pipeline(把Shader串起来)
- 创建了一个RenderPass(准备画布并绘制)
这三个概念,是WebGPU的骨架。后面的所有内容,都是在这个骨架上添砖加瓦。
我建议你试着改改颜色值,或者把三角形的位置挪一挪。比如把vec2(0.0, 0.5)改成vec2(0.0, 0.8),看看三角形怎么移动。动手试一下,比看十遍代码都管用。
下节预告:下一个章节,咱们会引入顶点缓冲区,把顶点数据从Shader里解放出来。这样就能画更复杂的图形了。