当前位置:首页 »“秋了秋”个人博客 » 前端编程 » WebGPU教程(说人话)绘制一个3D立体动画(三)

WebGPU教程(说人话)绘制一个3D立体动画(三)

作者:秋了秋 发表时间:2024年03月19日

    前面的章节用人话讲述了我们怎么使用WebGPU绘制2D图形,总结下它跟canvas 2D画图有什么区别,canvas 2D是JS在CPU里面绘图,CPU是单线程的,WebGPU是在GPU里面画图的,有成千上万的线程,速度上它会非常快。WebGPU除了绘制图形快之外,它还能轻松地画3D立体图形,这是canvas 2D所不具备的,所以3D渲染才是WebGPU的终极武器。


    还记得前面第一章节说的3D绘图原理吗,今天复习一下:


    "3D其实质在屏幕上都是2D像素,GPU只是把部分顶点通过矩阵运算调整坐标,每一个角度看过去不同的坐标布局,让整体看上去近大远小,再通过远近着不同的深浅明暗颜色,看起来具有纵深感,在人眼视觉上呈现3D的感觉(说人话:相机原理)"


    明白原理后,结合之前绘制2D图形的经验,我们只需要稍微改造下代码,有几个额外的步骤需要补上即可:


        1. 提供立体图形的所有顶点,上、下、左、右、前、后六个面看过去的构成面的顶点数据。

        2. 使用矩阵运算重新计算需要展示的角度看过去的各个顶点的坐标。

        3. 配置管线配置primitive的cullMode模式为’back’;


    其中1和2是关键步骤,3只是视觉上的优化,背对视角的顶点不渲染。接下来对这三个步骤逐一讲解。


    一:3D顶点数据


    本文以一个最简单的立方体为例子,告诉你立方体的顶点数据怎么获取,比如我们要使用triangle-list模式绘制,这意味着我们需要提供三角形化的顶点给GPU,一个四方体有6个平面,每个平面由一个矩形构成,每个矩形有两个三角形构成,每个三角形有三个顶点构成,所以一个立方体有6*2*3=36个顶点,每个顶点包含三个值x、y、z,所以一共有36*3 = 108个数据。之前绘制2D不用提供z值,都用1默认补上,3D则必须提供每个顶点的z值才能体现这个点是在前面还是后面(正数、负数)和在后面有多远(0~1)。


    复杂的立体图形的顶点是脑补不出来的,需要借助建模软件导出来或者从网上下载,简单的还是可以的,现在我们来脑补一个四方体的顶点,其实构成四方体的2*6=18个三角形的所有顶点坐标只需要8个(正面矩形的4个角和背面矩形的4个角,本文定义从左顶点开始逆时针)顶点,所有三角形只需要复用这8个顶点即可,我们先把这8个顶点定义好:

const vertexMap = [
    // 正面
    [-0.2,0.5,0.3],
    [-0.2,-0.5,0.3],
    [0.2,-0.5,0.3],
    [0.2,0.5,0.3],
    // 背面
    [0.2,0.5,-0.3],
    [0.2,-0.5,-0.3],
    [-0.2,-0.5,-0.3],
    [-0.2,0.5,-0.3]
];

    再来找出每个面的三角形的顶点排列顺序,这个顶点我们先用vertexMap的索引代替,按照逆时针的顺序:

立方体顶点排列.jpg

const triangleIndex = [
    1,2,3,1,3,0, //正面
    5,6,7,5,7,4, //背面
    6,1,0,6,0,7, //左面
    2,5,4,2,4,3, //右面
    0,3,4,0,4,7, //上面
    6,5,2,6,2,1 //下面
];

    最后把它们组装成一个完整的顶点数据并写入vertexBuffer:

let allVertex = triangleIndex.map(index => {
    return vertexMap[index];
}).reduce((arr, v)=>{
    return arr.concat(v);
});
allVertex = new Float32Array(allVertex);
const vertexBuffer = device.createBuffer({
    size: allVertex.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(vertexBuffer, 0, allVertex);

至此,顶点数据就全部准备好了!


    二:矩阵调整坐标


    我们需要先引入一个工具函数,这个工具函数通过传入不同的角度可以算出基于x或y或z不同角度的4x4矩阵出来:

// 创建旋转矩阵函数
function createRotationMatrix(radians, axis) {
    const sin = Math.sin(radians);
    const cos = Math.cos(radians);
    const omcos = 1.0 - cos;
 
    const x = axis[0], y = axis[1], z = axis[2];
 
    return [
        cos + x * x * omcos, y * x * omcos + z * sin, x * z * omcos - y * sin, 0,
        x * y * omcos - z * sin, cos + y * y * omcos, y * z * omcos + x * sin, 0,
        x * z * omcos + y * sin, y * z * omcos - x * sin, cos + z * z * omcos, 0,
        0, 0, 0, 1
    ];
}

    函数计算的原理不在本文探讨,一般也不会手写,直接引入进来就好,有兴趣的可以深入研究,原理就是三角函数的利用。

比如我们需要看绕y轴旋转0.5弧度的立体图形,那它对应的矩阵就是:

let rotateMatrix = createRotationMatrix(0.5, [0,1,0]);

    之后所有顶点乘以这个矩阵就是视觉应该看到的新的坐标。很显然这个rotateMatrix 是在CPU定义的,我们需要传入到GPU,变量写入GPU跟顶点数据写入GPU是相类似的,但是它多了一些步骤,具体有:

        1. CPU改变变量的值;

        2. Device里面createBuffer一个uniform类型的存储空间;

        3. 把变量的值writeBuffer进存储空间;

        4. createBindGroup创建绑定组;

        5. setBindGroup绑定组到GPU;

        6. WGSL里面读取使用变量;

    第二步:

const matrixBuffer = device.createBuffer({
    size: 4 * 16, // matrix 4x4
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});

    第三步:

rotateMatrix = new Float32Array(matrix);
device.queue.writeBuffer(matrixBuffer , 0, rotateMatrix .buffer, 0, rotateMatrix.byteLength);

    需要把rotateMatrix 转成float32格式数据,然后把它的buffer偏移0从头写入matrixBuffer里面。writeBuffer第一个参数是容器,第二个参数是容器存储的偏移量,只存一个数据无特殊需求就是0,第三个参数是要写入的数据,第四个参数是数据本身的偏移值,如果要整个数据,无特殊需求就是0,第五个参数是要写入数据的长度。

    第四步:

设置绑定组的作用就是传递数据到GPU,支持批量:

const bindGroup = device.createBindGroup({
    layout: pipeLine.getBindGroupLayout(0), // 这个0对应第五步的第一个参数和第六步里面的@group(0)
    entries: [
        {
            binding: 0, // 这个0对应第六步里面的@binding(0)
            resource: {
                buffer: matrixBuffer, // buffer容器
                offset: 0, // 数据偏移
                size: 4 * 16 //数据大小
            }
        }
    ]
});

    至此我们把数据捆成一个组了,接下来要传递到GPU里面

 

    第五步:

pass.setBindGroup(0, bindGroup); //在每次draw之前调用

    第六步:

const shadeCode = `
    @binding(0) @group(0) var<uniform> matrix:mat4x4<f32>;
    struct VertexOut {
        @builtin(position) pos:vec4f,
        @location(0) color:vec4f
    }
    @vertex
    fn v_main(@location(0) pos:vec3f, @builtin(vertex_index) index:u32) -> VertexOut {
        var out:VertexOut;
        let i = f32(index);
        out.pos = vec4f(pos, 1) * matrix;
        let faceIndex = floor(i / 6);
        var color:vec4f;
        // 四方体不同面使用不同颜色绘制
        if (faceIndex == 0) {
            color = vec4f(1,0,0,1);
        } else if(faceIndex == 1) {
            color = vec4f(0,1,0,1);
        } else if(faceIndex == 2) {
            color = vec4f(0,0,1,1);
        } else if(faceIndex == 3) {
            color = vec4f(0.5,0.5,0,1);
        } else if(faceIndex == 4) {
            color = vec4f(0,0.5,0.5,1);
        } else if(faceIndex == 5) {
            color = vec4f(0.5,0,0.5,1);
        }
        out.color = color;
        return out;
    }
    @fragment
    fn f_main(@location(0) c:vec4f) -> @location(0) vec4f {
        return c;
    }
`

    着色器代码的第一行就是读取了uniform变量,通过@binding和@group来取值,跟前面章节最大的逻辑区别是顶点着色器,其核心代码是out.pos = vec4f(pos, 1) * matrix,把坐标乘以矩阵得到新的坐标,也即是视角坐标(相机坐标)。vertex_index是WGSL的内部变量,表示顶点索引。我们通过顶点索引可以计算得出该顶点位于第几个面 floor(i / 6),因为每个面有六个顶点。之所以用if else是因为WGSL里面没有switch,也暂时没找到替代方法~还是if大法好走到哪都通用!。另外注意下WGSL里面没有三等,只有双等。片元着色器不变。


    有个灵魂拷问:为什么matrix不直接在WGSL里面赋值,而要通过复杂流程绑定进来?因为matrix是个变量,只是暂时还没变,后面会让它变起来。你也可以在WGSL里面通过字符串拼接的形式动态写入变量,这样带来的后果是整个WGSL字符串为一个变量了。但是通过绑定的形式可以直接在CPU里面改变matrix的值再重新setBindGroup和draw即可改变图像而不需要重新解析WGSL字符串以提高性能。


    三:配置管线

const pipeLineConfig = {
    ...
    vertex: {
        ...
        buffers:[
            {
                arrayStride: 12,
                ...
            }
        ]
    },
    primitive: {
        topology: 'triangle-list',
        cullMode: 'back', // 背面不绘制
    }
}

因为顶点坐标增加了z值这里arrayStride要加4个字节,从8变成12。topology使用三角形列表绘制模式(triangle-list)以及cullMode不渲染背面的顶点(back),其它配置不变,我用...省略了。


    四:绘制动态3D


以上是绘制一个带有角度视角的立体图形,如果要绘制动态的,比如不断旋转的立体图,原理就是不断地改变视角计算得出该视角的matrix矩阵传入GPU,然后不断地draw就行了:

let rotation = 0;
function render() {
    rotation += 0.02;
    let rotateMatrix = createRotationMatrix(rotation, [-0.15, -0.95, 0]);
    rotateMatrix = new Float32Array(rotateMatrix);
    device.queue.writeBuffer(matrixBuffer,0,rotateMatrix.buffer,0,rotateMatrix.byteLength);
    const encode = device.createCommandEncoder();
    const pass = encode.beginRenderPass({
        colorAttachments: [{
            loadOp: "clear", //渲染前清空画布
            clearValue: {r: 0, g: 0,b:0, a:1}, // 清空画布使用黑色覆盖
            storeOp: "store", // 渲染后存储颜色配置
            view: context.getCurrentTexture().createView() // 视图,使用此方法返回视图即可
        }]
    });
    pass.setPipeline(pipeline); // 设置渲染管线
    pass.setVertexBuffer(0, vertexBuffer); // 设置顶点buffer,偏移0,即从头到尾完整的数据
    pass.setBindGroup(0, bindGroup);
    pass.draw(allVertex.length / 3);
    pass.end(); // 绘制完了要手动结束
    device.queue.submit([encode.finish()]); // 指令编码器完成,提交到设备队列
    requestAnimationFrame(render);
}
requestAnimationFrame(render);

    这是自旋动画,你也可以通过鼠标事件开发人机交互的3D预览图。以下就是本章节的效果图,你学废了吗?

webgpu 3D动画.gif

    如果阅读本文有点吃力,建议先读通前面两章节文章,尤其是第一篇,包含了众多前置概念:


    WebGPU教程(说人话)绘制任意形状的2D图形(一)

    WebGPU教程(说人话)批量绘制几何图形(二)

0
文章作者: “秋了秋”个人博客,本站鼓励原创。
转载请注明本文地址:http://netblog.cn/blog/509.html
目录: 前端编程标签: WebGPU,3D图形渲染 604次阅读

请求播放音乐,请点击播放

登 录
点击获取验证码
还没账号?点击这里