六、WebGPU 绘制立方体

141 阅读7分钟

前面我们绘制了二维的一个正方形,现在我们来绘制一个立方体,进一步了解WebGPU 的基本使用。

数据准备

开始之前我们需要主备一些数据,包括顶点数据,顶点着色器,片元着色器,以及透视投影、旋转、视图等矩阵。

// 顶点着色器,片元着色器
import { basicVert, positionFrag } from './shaders/rotatingCube.js'
//顶点数据,uv坐标数据
import * as cube from './util/cube.js'
//透视投影、旋转、视图等功能
import { getMvpMatrix } from './util/math.js'

初始化

初始化WebGPU,与前面一样,用之前封装的initWebGPU函数来初始化。

async function initWebGPU(canvas) {
    if (!navigator.gpu)
        throw new Error('Not Support WebGPU')
    const adapter = await navigator.gpu.requestAdapter({
        powerPreference: 'high-performance'
    })
    if (!adapter)
        throw new Error('No Adapter Found')
    const device = await adapter.requestDevice()
    const context = canvas.getContext('webgpu')
    const format = navigator.gpu.getPreferredCanvasFormat()

    const size = { width: canvas.width, height: canvas.height }
    context.configure({
        device: device,
        format: format,
    })
    return { device, context, format, size }
}

初始化渲染管线

渲染管线,包括顶点着色器,片元着色器,顶点数据,顶点格式,渲染模式,颜色格式,深度格式,以及视图矩阵等。在下面代码中,有详细说明。

// 管线组装
async function initPipeline(device, format, size) {
    const pipeline = await device.createRenderPipelineAsync({
        label: 'Basic Pipline',
        layout: 'auto',// 渲染管线的布局
        vertex: {
            module: device.createShaderModule({
                code: basicVert,
            }),
            entryPoint: 'main',
            buffers: [{// 这里的 buffers 属性就是缓冲区集合,其中一个元素对应一个缓冲对象
                arrayStride: 5 * 4,// 一个顶点长度 以字节为单位
                attributes: [
                    {
                        shaderLocation: 0,// 遍历索引,这里的索引值就对应的是着色器语言中 @location(0) 的数字
                        offset: 0,// 偏移
                        format: 'float32x3',// 参数格式
                    },
                    {
                        shaderLocation: 1,// 这里的索引值就对应的是着色器语言中 @location(1) 的数字
                        offset: 3 * 4,
                        format: 'float32x2',
                    }
                ]
            }]
        },
        fragment: {
            module: device.createShaderModule({
                code: positionFrag,
            }),
            entryPoint: 'main',
            targets: [
                {
                    format: format
                }
            ]
        },
        primitive: {//用于描述图元的状态
            topology: 'triangle-list',
            cullMode: 'back',//剔除背面
            frontFace: 'ccw'
        },
        //depthWriteEnabled指定是否写入深度缓冲区。depthCompare指定如何比较深度测试。format指定深度缓冲区格式。
        depthStencil: {
            depthWriteEnabled: true,// 开启深度测试
            depthCompare: 'less',// 设置比较函数为 less
            format: 'depth24plus',// 设置depth为24bit
        }
    })
    //深度缓冲区
    const depthTexture = device.createTexture({
        size, format: 'depth24plus',
        usage: GPUTextureUsage.RENDER_ATTACHMENT,
    })
    const depthView = depthTexture.createView()//深度视图
    //创建 VBO,顶点数据缓冲区
    // 获取一块状态为映射了的显存,以及一个对应的 arrayBuffer 对象来写数据
    // 建立顶点缓冲区
    // 它就是一块存储空间,先让 js 把顶点数据放进去,然后再让着色器从里面读取
    // 至于为什么 js 不通过一个简单的方法直接把顶点数据传递给着色器,这是因为 js 和 着色器用的是两种不一样的语言
    // 它们无法直接对话,因此需要一个缓冲地带,也就是缓冲区对象
    const vertexBuffer = device.createBuffer({
        label: 'GPUBuffer store vertex',
        size: cube.vertex.byteLength,
        usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
    })
    device.queue.writeBuffer(vertexBuffer, 0, cube.vertex)

    //mvp矩阵缓冲区
    // COPY_DST 通常就意味着有数据会复制到此 GPUBuffer 上,这种 GPUBuffer 可以通过 queue.writeBuffer 方法写入数据
    const mvpBuffer = device.createBuffer({
        label: 'GPUBuffer store 4x4 matrix',
        size: 4 * 4 * 4,
        usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    })
    //mvp矩阵缓冲区,绑定uniform
    // 通过打组,可以很方便地将某种条件下的一组 uniform 资源分别传入着色器进行 WebGPU 渲染编程。
    // GPUBindGroup 的最大作用,就是隔离不相关的 uniform,把相关的资源摆在一块。
    const uniformGroup = device.createBindGroup({
        label: 'Uniform Group with Matrix',
        layout: pipeline.getBindGroupLayout(0),// 指定绑定组的布局对象,渲染管线的布局
        entries: [
            {
                binding: 0,
                resource: {
                    buffer: mvpBuffer
                }
            }
        ]
    })

    return { pipeline, vertexBuffer, mvpBuffer, uniformGroup, depthTexture, depthView }
}

渲染

渲染函数,这里主要就是把数据写入缓冲区,然后把缓冲区绑定到管线上,然后调用 draw 方法进行渲染。

    // 创建一个i名为 commandEncoder 的指令编码器,用来复制显存
    // 我们不能直接操作command buffer,需要创建command encoder,使用它将多个commands(如render pass的draw)设置到一个command buffer中,然后执行submit,把command buffer提交到gpu driver的队列中。
    //     command buffer有
    // creation, recording,ready,executing,done五种状态。

    // 根据该文档,结合代码来分析command buffer的操作流程:
    // const commandEncoder = device.createCommandEncoder()这个语句:创建command encoder时,应该是创建了command buffer,它的状态为creation;
    // const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor)这个语句:开始render pass(webgpu还支持compute pass,不过这里没用到),command buffer的状态变为recording;
    // passEncoder.setPipeline(pipeline) 这个语句:将“设置pipeline”、“绘制”的commands设置到command buffer中;
    // passEncoder.end() 这个语句:(可以设置下一个pass,如compute pass,不过这里只用了一个pass);
    // commandEncoder.finish() 这个语句:将command buffer的状态变为ready;
    // device.queue.submit 这个语句:command buffer状态变为executing,被提交到gpu driver的队列中,不能再在cpu端被操作;
    // 如果提交成功,gpu会决定在某个时间处理它。
function draw(device, context, pipelineObj) {
    const commandEncoder = device.createCommandEncoder()
    const renderPassDescriptor = {
        colorAttachments: [
            {
                view: context.getCurrentTexture().createView(),
                clearValue: { r: 0, g: 0, b: 0, a: 1.0 },
                // loadOp和storeOp决定渲染前和渲染后怎样处理attachment中的数据。
                loadOp: 'clear',// load 的意思是渲染前保留attachment中的数据,clear 意思是渲染前清除
                storeOp: 'store'// 如果为“store”,意思是渲染后保存被渲染的内容到内存中,后面可以被读取;如果为“clear”,意思是渲染后清空内容。
            }
        ],
        depthStencilAttachment: {
            view: pipelineObj.depthView,
            // 在深度测试时,gpu会将fragment的z值(范围为[0.0-1.0])与这里设置的depthClearValue值(这里为1.0)比较。其中使用depthCompare定义的函数(这里为less,意思是所有z值大于等于1.0的fragment会被剔除)进行比较。
            depthClearValue: 1.0,
            depthLoadOp: 'clear',
            depthStoreOp: 'store',
        }
    }
    // 建立渲染通道,类似图层
    const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor)
    // 传入渲染管线
    passEncoder.setPipeline(pipelineObj.pipeline)
    // VBO 要通过 passEncoder 的 setVertexBuffer 方法写入数据
    // 有时还要配合 GPUBufferUsage.INDEX 即 索引缓存 来使用
    // 通道编码器中指定坐标缓存、颜色缓存
    // 写入顶点缓冲区
    passEncoder.setVertexBuffer(0, pipelineObj.vertexBuffer)
    // draw 之前设置绑定的组
    passEncoder.setBindGroup(0, pipelineObj.uniformGroup)
    // 绘图:指定绘制的顶点个数
    passEncoder.draw(cube.vertexCount)
    passEncoder.end()
    // 提交写好的复制功能的命令
    // commandEncoder.finish(): 结束指令编写,并返回 GPU 指令缓冲区
    // device.queue.submit:向 GPU 提交绘图指令,所有指令将在提交后执行
    device.queue.submit([commandEncoder.finish()])
}

运行

async function run() {
    const canvas = document.querySelector('canvas')
    if (!canvas)
        throw new Error('No Canvas')
    const { device, context, format, size } = await initWebGPU(canvas)
    const pipelineObj = await initPipeline(device, format, size)

    // 模型、投影、视图矩阵初始数据 
    let aspect = size.width / size.height
    const position = { x: 0, y: 0, z: -5 }
    const scale = { x: 1, y: 1, z: 1 }
    const rotation = { x: 0, y: 0, z: 0 }
    // 帧循环
    function frame() {
        // 按时间旋转,并更新变换矩阵
        const now = Date.now() / 1000
        rotation.x = Math.sin(now)
        rotation.y = Math.cos(now)
        //矩阵处理方法
        const mvpMatrix = getMvpMatrix(aspect, position, rotation, scale)
        // 写入:从 CPU 到  GPU
        // 将颜色数据/旋转数据 写入到缓冲区对象
        device.queue.writeBuffer(
            pipelineObj.mvpBuffer,// 传给谁
            0,
            mvpMatrix.buffer// 传递 ArrayBuffer
            // mvpMatrix.byteOffset, // 从哪里开始
            // mvpMatrix.byteLength // 取多长
        )
        // 开始绘制
        draw(device, context, pipelineObj)
        // requestAnimationFrame(frame)
    }
    frame()
}
run()

立方体数据

const vertex = new Float32Array([
    // float3 点坐标, float2 uv
    // face1
    +1, -1, +1, 1, 1,
    -1, -1, +1, 0, 1,
    -1, -1, -1, 0, 0,
    +1, -1, -1, 1, 0,
    +1, -1, +1, 1, 1,
    -1, -1, -1, 0, 0,
    // face2
]);

const vertexCount = 36

export { vertex, vertexCount }    

WGSL 的一些介绍

入口点

WGSL 没有强制使用固定的 main() 函数作为入口点(Entry Point),它通过 @vertex、@fragment、@compute 三个着色器阶段(Shader State)标记提供了足够的灵活性让开发人员能更好的组织着色器代码。你可以给入口点取任意函数名,只要不重名,还能将所有阶段(甚至是不同着色器的同一个阶段)的代码组织同一个文件中

Group 与 Binding 属性

WGSL 中每个资源都使用了 @group(X) 和 @binding(X) 属性标记,例如 @group(0) @binding(0) var params: Uniforms。 params 它表示的是 Uniform buffer 对应于哪个绑定组中的哪个绑定槽。这与 GLSL 中的 layout(set = X, binding = X) 布局标记类似。WGSL 的属性非常明晰,描述了着色器阶段到结构的精确二进制布局的所有内容。

export const basicVert = `@binding(0) @group(0) var<uniform> mvpMatrix : mat4x4<f32>;
struct VertexOutput {
    @builtin(position) Position : vec4<f32>,
    @location(0) fragUV : vec2<f32>,
    @location(1) fragPosition: vec4<f32>
};

@vertex
fn main(
    @location(0) position : vec4<f32>,
    @location(1) uv : vec2<f32>
) -> VertexOutput {
    var output : VertexOutput;
    output.Position = mvpMatrix * position;
    output.fragUV = uv;
    output.fragPosition = 0.5 * (position + vec4<f32>(1.0, 1.0, 1.0, 1.0));
    return output;
}
`
export const positionFrag = `@fragment
fn main(
    @location(0) fragUV: vec2<f32>,
    @location(1) fragPosition: vec4<f32>
) -> @location(0) vec4<f32> {
    return fragPosition;
}
` 

mvp 矩阵

import { mat4, vec3 } from 'gl-matrix'
// 从给定的比例、位置、旋转角度、缩放值返回mvp矩阵
function getMvpMatrix(
    aspect,
    position,
    rotation,
    scale
) {
    // 模型视图矩阵
    const modelViewMatrix = getModelViewMatrix(position, rotation, scale)
    // 投影矩阵
    const projectionMatrix = getProjectionMatrix(aspect)
    // mvp 矩阵
    const mvpMatrix = mat4.create()
    mat4.multiply(mvpMatrix, projectionMatrix, modelViewMatrix)

    return mvpMatrix
}
function getModelViewMatrix(
    position = { x: 0, y: 0, z: 0 },
    rotation = { x: 0, y: 0, z: 0 },
    scale = { x: 1, y: 1, z: 1 }
) {
    const modelViewMatrix = mat4.create()
    mat4.translate(modelViewMatrix, modelViewMatrix, vec3.fromValues(position.x, position.y, position.z))
    // rotate
    mat4.rotateX(modelViewMatrix, modelViewMatrix, rotation.x)
    mat4.rotateY(modelViewMatrix, modelViewMatrix, rotation.y)
    mat4.rotateZ(modelViewMatrix, modelViewMatrix, rotation.z)
    // scale
    mat4.scale(modelViewMatrix, modelViewMatrix, vec3.fromValues(scale.x, scale.y, scale.z))
    return modelViewMatrix
}

const center = vec3.fromValues(0, 0, 0)
const up = vec3.fromValues(0, 1, 0)

function getProjectionMatrix(
    aspect,
    fov = 60 / 180 * Math.PI,
    near = 0.1,
    far = 100.0,
    position = { x: 0, y: 0, z: 0 }
) {
    // create cameraview
    const cameraView = mat4.create()
    const eye = vec3.fromValues(position.x, position.y, position.z)
    mat4.translate(cameraView, cameraView, eye)
    mat4.lookAt(cameraView, eye, center, up)
    // get a perspective Matrix
    const projectionMatrix = mat4.create()
    mat4.perspective(projectionMatrix, fov, aspect, near, far)
    mat4.multiply(projectionMatrix, projectionMatrix, cameraView)
    return projectionMatrix
}

export { getMvpMatrix, getModelViewMatrix, getProjectionMatrix }