当前位置:首页 »“秋了秋”个人博客 » 前端编程 » WebGPU教程(说人话)绘制任意形状的2D图形(一)

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

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

前言:


    关于WebGPU的里程碑本文不再赘述,有兴趣的可以查阅相关文档,本文是着重实战的应用篇,你只需要知道它是一款新时代高性能图形渲染API,是继webGL,webGL2.0的下一代3D渲染产物,支持最新的GPU API如微软的 Direct3D 12、苹果的 Metal 以及科纳斯组织的 Vulkan。如果你从来没接触过这些,建议直接学WebGPU,一步到位,它具有更好的兼容性,更快的速度,更易理解的API,本文的每一句话都有价值,希望每一个字都认真读,否则理解会有困难。


    WebGPU提供的API都是比较底层的,越底层越麻烦,越灵活,性能越好,所以你需要非常繁琐的代码组织和配置,请提前做好心理准备。它不像Javascript运行在CPU,而是GPU(说人话:显卡)上,CPU单核它是单线程,而GPU单核它是成千上万多线程的,它的计算速度非常快,它能同时达到惊人的像素级运算,在大型3D游戏界面中每秒能输出上百几百的画面(说人话:帧率)。画面中每一帧所有图形,包括人物、树木、火车等都是通过百万级顶点(说人话:坐标数据)组成,而GPU就是负责把这些顶点数据栅格化(说人话:找出覆盖每每三个坐标点形成的三角形覆盖的屏幕像素)再加以着色(说人话:每个像素点打上定义的颜色), 如下图每一个方格为屏幕上的像素块,像素块里面有一个像素点,像素点在三角形内,那这个像素块就会被着色。

webGPU栅格化.jpg

    渲染任何物体都是通过把物体三角形化(说人话:分割成无数个三角形),每个三角形可以自定义颜色,所以能渲染出彩色物体。

blog_b4b0f2fa42dd2964_856.png

    从上图可以看出,任意一个矩形块都可以两个或两个以上的三角形组成,就如图所示分割成最少的两个三角形,一共有6个顶点(说人话:坐标点),其中有两个顶点是两个三角形共用的,虽然重复但在顶点数据里面都要写上。顶点数据的排列是逆时针的,所以以上矩形的顶点数据是

[
-0.8, -0.8,
0.8, 0.8,
 -0.8, 0.8,
0.8, 0.8,
 -0.8, -0.8,
0.8, -0.8
]

    WebGPU里面xy坐标都是在-1~1之间的数,CANVAS中心点为0,0坐标,左下角为-1,-1左上角为-1,1右上角为1,1右下角为1,-1。注意:z坐标是0~1的值(0在平面上,1在远离视角的地方)


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

3D皮卡丘.jpg

    而3D图形就是有若干个720度(说人话:上下左右前后六个面)三角形顶点组成,当物体处于正面时,GPU渲染正面的顶点,当物体旋转一定角度时,GPU渲染该角度下所呈现的顶点,具体是哪些顶点,旋转后这些顶点的坐标是什么都是GPU计算得出,无需我们操心,程序只需要把所有顶点数据给GPU,然后告诉GPU该怎么进行矩阵运算

三角形3D布局.jpg

    3D图形渲染本文不做深入了解,预知后事请看下文。本文实战2D渲染,2D掌握了,3D也就锦上添花了。


    要想更好的理解WebGPU,首先要具备”外挂设备”的思想,它不像CPU,直接写Javascript代码操作。它需要使用Javascript通过画布CANVAS桥连到显卡设备,然后把数据写到显卡里面,然后使用GPU专用的语言WGSL去处理数据。


    以前的2D canvas初始化是这样的:


    Html:

<canvas width="600" height="400" id="my-canvas"></canvas>

    JS:

const canvas = document.getElementById('my-canvas');
const context = canvas.getContext('2d'); // 2D上下文

    而WebGPU的初始化:

    Js:

const canvas = document.getElementById('my-canvas');
const context = canvas.getContext('webgpu'); // webgpu上下文
const gpu = navigator.gpu; // 获取电脑的显卡对象
if (!gpu) {
    throw Error("Not support WebGPU! Please buy new computer!");
}
const adapter = await gpu.requestAdapter(); // 获取电脑的显卡适配器
if (!adapter ) {
    throw Error("GPUAdapter not found!");
}
const device = await adapter .requestDevice(); // 获取电脑的显卡设备, 获取到此device后,GPU大部分接口都挂载在这个对象上
// 接下来配置我们的画布canvas, 让它与显卡绑定
context.configure({
    device: device,
    format:  gpu.getPreferredCanvasFormat() //画布格式,大部分时候使用gpu提供的方法获取,它会自动返回合适的格式
    alphaMode: "premultiplied", // 支持透明颜色
});

    可以看到,webgpu的前期配置工作是很繁琐的,除了前期初始化配置繁琐,CPU与GPU数据通信也很繁琐,通常在Javascript写的数据都是在CPU里面直接读写,比如:

    CPU:

const vertexData = [-0.8, -0.8, 0.8, 0.8,-0.8, 0.8, 0.8, 0.8,-0.8, -0.8, 0.8, -0.8]; // 写
console.log(vertexData ); //读

    GPU:

const vertexData = new Float32Array( [-0.8, -0.8, 0.8, 0.8,-0.8, 0.8, 0.8, 0.8,-0.8, -0.8, 0.8, -0.8]);
const vertexBuffer = device.createBuffer({
    size: vertexData.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(vertexBuffer, 0, vertexData);

    解释下这段代码,跟CPU不同的是必须指定数据类型,才能往GPU中存数据,这里指定为32位浮点数组类型数据new Float32Array。

这个是在CPU(JS)里面定义的,GPU不能读写CPU的资源,需要把CPU的数据复制到GPU里面,首先要在GPU里面创建一个buffer缓冲器(说人话:存储空间),使用device.createBuffer来创建,必须指定两个参数,1.缓冲器的大小size  2. 用途usage。size必须大于等于要写入数据的大小,为了节约空间,我们设置刚好等于数据的大小vertexData.byteLength,单位是字节,你也可以手动写入一个数字,回归到大学C语言类型考点,一个有符号32位浮点数占据4个字节,有符号整型数字占据2个字节。有符号表示有正负的数据,其中正数可以省略+号。以上定义的是有符号32位浮点数,所以手写数据大小即是4*12 = 48。还需指定这个缓冲器的使用用途为GPUBufferUsage.VETEX表示用于存储顶点或者数据拷贝的目的地GPUBufferUsage.COPY_DST,两者缺一不可,用竖线分割。


    然后通过显卡队列写入数据device.queue.writeBuffer,第一个参数是我们创建的容器,就是指定数据写入的目的地,第二个参数是偏移量,显卡里面的存储是线性的,说人话:创建的空间它像一个管道,每个数据都有卡槽位,只能一个一个排队放在卡槽内,但是你前面可以留空卡槽,这个空位数就是offset偏移量,这里我们不需要偏移(0)直接从头开始存。

线性存储.jpg

    如果你创建的buffer大小大于数据大小就如上图所示,后边会有空位,你也可以用后面的空位存储另一个数据,同样通过偏移量控制,之后使用数据的时候也要偏移量来读数据。第三个参数就是我们要写入的数据。


现在GPU里面数据有了,接下来该怎么在GPU里面接收和处理这些数据,就需要GPU语言WGSL代码,它用字符串定义,为了更好换行阅读,我们用反引号包裹。这里涉及JS以外的新语法和概念,请坐好板凳。 它跟TS有点像,如果了解TS对它理解可能会轻松一些。GPU里面包含三个程序:


    1.  顶点处理程序(@vertex),

    2. 片元着色处理程序(@fragment),

    3. 计算程序(@compute)。


    我们绘图一般只用到1和2就够了。为了更全面的介绍WGSL代码,我们先改造下顶点数据,让其把颜色也包含进去:

const vertexData = new Float32Array( [
    0.2,0.5,1,0,0, //第一个顶点
    0.6,0.4,0,1,0, //第二个顶点
    -0.3,-0.2,0,1,1, //第三个顶点
    -0.5,-0.1,1,1,1, //第四个顶点
    0.2,0.5,1,0,0 //第五个顶点
]);

    一个顶点有5个数据,为什么会这样???不是xy两个值就够了吗,对于2D坐标确实是的,所以前面两个是xy,后面三个是顶点的颜色,对应rgb三个颜色的三个值,分别是红绿蓝。对于三维坐标和四维坐标可不止xy还有z和w,所以单纯坐标它有可能2, 3, 4个值,最终到GPU里面都要转换为四维矢量,不足的按缺省补位,比如z为0, w为1(注:在3维里w表示透视)。同理颜色四维矢量rgb还有一个alpha(透明度),缺省补位就是1,不透明。至于怎么处理这些数据,怎么识别哪个是坐标值,哪个是颜色值,怎么补位,也是接下来要讲的。


    你先看看着色器代码,着色器代码从上到下执行,代码如下:

const shaderCode = `
    struct VertexOut {
        @builtin(position) pos: vec4f,
        @location(0) color: vec4f
    }
    @vertex
    fn v_main(@location(0) pos:vec2f, @location(1) color:vec3f) -> VertexOut {
        var out: VertexOut;
        out.pos = vec4f(pos, 0, 1);
        out.color= vec4f(color, 1);
        return out;
    }
    @fragment
    fn f_main(@location(0) c:vec4f) -> @location(0) vec4f {
        return c;
    }
 `;

    接下来详细解读此代码每一个字符,让你理解透彻。


    Struct是声明一种类型,这个类型名字叫VertexOut,可随意取名,但是一般隐形约定首字母大写,类似TS的interface。这个类型有两个属性,一个是pos, 它表示的WGSL里面的内部(@builtin)变量position,这个属性的类型为四维矢量vec4f。另一个属性color表示的是传递到下一个阶段函数的第0个参数(@location(0)),也是一个四维矢量。


    @vertex表示以下函数为顶点处理函数,fn类比js的function声明,函数名可以任意,只要不重名即可,我们这里叫它v_main,它有两个参数pos(二维矢量)和color(三维矢量),这两个参数从何而来?从传过来的顶点数据解析而来。还记得刚才定义的顶点数据吗,它是一个大数组,怎么把这个大数组数据分割传到这个函数来?后面渲染配置里面会进行配置。跟JS不同的是这里的参数可以没有顺序,全靠@location来指定,这个location后面跟的数字与后续的渲染配置是一一呼应的,所以这里函数参数的顺序不重要,只要指明@location就好。返回值类型为上面声明的类型VertexOut, 用->符号指明,类似TS函数后面的冒号,符号后面跟着的就是返回值类型。


    注意:WGSL语言的let相当于JS的const定义了就不能修改,var相当于JS的let表示可以修改。


    因为传过来的数据并非四维矢量,所以函数内把这些数据变成四维矢量,调用四维矢量函数 vec4f转换,后面参数为补齐缺省:


    坐标:out.pos = vec4f(pos, 0 , 1);

    颜色:out.color= vec4f(color , 1);


很显然如果传过来的坐标是三维数据,就是out.pos = vec4f(pos, 1)如果传过来的是四维矢量即可以out.pos = pos;

函数输出一个数据供GPU和下一个函数使用。下一个函数的参数就可以访问上一个函数输出的数据。


    片元着色程序顾名思义就是颜色处理器,跟顶点处理函数一样,每一个顶点都会执行一遍这个函数,返回一个四维矢量的颜色值告诉GPU要使用什么颜色渲染该顶点。如果两个连续的顶点颜色不一样,将会以渐变的颜色填充过去。前面的@location(0) c指明是上一个函数返回值里面的location(0)位置的属性,也就是参数c对应out.color。后面的返回值的@location(0)表示此返回颜色应用到第几个纹理上,具体对应后面代码里面的fragment配置里面的targets数组的第几项,同样对应后面代码里面的colorAttachments数组的第几项。


    也可以直接读上一个函数返回的整个数据:

@fragment
fn f_main(o:VertexOut) -> @location(0) vec4f {
    return o.color;
}

    我们使用的颜色是数据里面定义的颜色,每一个顶点颜色都不一样,所以是动态的,它最终渲染出来的就是彩色的,如果所有顶点都是用同一种颜色渲染,只需要在该函数返回一个固定值就行,比如红色return vec4f(1,0,0,1)。


    目前我们有了WebGPU的WGSL代码,这是在CPU里面定义的字符串,还记上面说的话吗,GPU不能直接执行CPU里面的资源,怎么把它丢到GPU里面,我们通过显卡创建着色模块,再把代码绑定到着色模块上即可:

const shaderModule = device.createShaderModule({
    code: shaderCode
});

至此,我们数据有了,处理数据的程序有了,还差什么?是不是还差数据怎么传递到处理程序里面?我们立即来定义渲染管线的配置:

const pipeLineConfig = {
    layout: "auto", // 布局,一般用auto就行
    primitive: {
        topology: "line-strip" // 渲染模式,支持线条和三角形模式,和填充和非填充,有以下几个常用值line-strip(线条带),triangle-stip(三角形带), line-list(线条), triangle-list(三角形), point-list(点),可以自己试一下都是什么效果
    },
    //顶点处理程序的配置
    vertex: {
        module: shaderModule, // 使用哪个模块
        entryPoint: "v_main", // 入口函数,说人话:执行函数
        buffers: [
            {
            arrayStride: 20, // 4*5 每个顶点包含信息的长度,理解为读数据步进的步幅,说人话:读下一个顶点数据的时候需要跳多少个字节去读。
            // 顶点处理程序函数的参数配置,告诉程序怎么读数据和分割数据
            attributes: [
                {
                    shaderLocation: 0, //对应顶点程序里面的location(0),也即是坐标信息
                    offset: 0, // 坐标在每个顶点数据的开头位置,所以偏移量为0
                    format: "float32x2" // 数据格式为2个32位浮点数
                },
                {
                    shaderLocation: 1, //对应顶点程序里面的location(1),也即是颜色信息
                    offset: 8, // 颜色跟在坐标后面,所以偏移量为坐标信息的长度2*4 = 8
                    format: "float32x3" // 数据格式为3个32位浮点数
                },
            ]
          }
        ]
    },
    // 片元着色器函数配置
    fragment: {
        module: shaderModule, // 因为顶点程序和着色程序都都写在一个模块里面,所以使用的模块相同,从此可以看出顶点程序和着色程序可以分开写。
        entryPoint: "f_main", // 入口函数,说人话:执行函数
        targets: [{
            format: gpu.getPreferredCanvasFormat() // 渲染格式,跟上面的canvas格式一致,所以可以抽成一个常量
        }]
    }
};

    外挂设备思想还记得吗?我们定义的这个配置是在CPU里面定义的,我们要把它挂载到显卡设备里面去,我们使用设备的方法和我们的配置创建渲染管线:

const pipeline = device.createRenderPipeline(pipeLineConfig);

    数据有了,处理数据的程序有了,数据绑定有了,渲染管线也有了,我们还差一步之遥就能渲染出图形。渲染管线需要在显卡设备什么地方渲染,还需要一个指令编码器,再把数据和渲染管线推到这个指令编码器执行绘制,绘制完提交到GPU队列完成绘制:

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表示的是渲染管线配置里面vertex的buffers数组的第一个元素
pass.draw(5); // 绘制5次,几个顶点数据绘制几次
pass.end(); // 绘制完了要手动结束
device.queue.submit([encode.finish()]); // 指令编码器完成,提交到设备队列

    现在看看CANVAS是不是有图像输出了,而且是个四边形。试试调整顶点数据,修改topology参数绘制其它图形吧,顶点数据调整后记得调整渲染管线的配置pipeLineConfig,如果改了数据格式还需要调整shaderCode,最后还要注意pass.draw的次数是否需要调整。

webgpu 绘制一个四边形.jpg

    你学废了吗?

    至于绘制多个图形和踩坑经验,建议阅读下一篇文章《WebGPU教程(说人话)批量绘制几何图形(二)》。

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

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

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