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的创建分成几步:

  1. 编译Shader模块
  2. 配置管线布局(这里先不用管,用默认的)
  3. 配置顶点和片元阶段
  4. 配置颜色输出格式

看代码:

// 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里解放出来。这样就能画更复杂的图形了。