Orillusion

    • 注册
    • 登录
    • 搜索
    • 版块
    • 最新
    • 标签

    WebGL 与 WebGPU比对[3] - 顶点缓冲

    中文社区
    vao vbo vertexbuffer
    1
    1
    116
    正在加载更多帖子
    • 从旧到新
    • 从新到旧
    • 最多赞同
    回复
    • 在新帖中回复
    登录后回复
    此主题已被删除。只有拥有主题管理权限的用户可以查看。
    • 寻风觅迹
      寻风觅迹 最后由 编辑

      1. WebGL 中的 VBO

      1.1. 创建 WebGLBuffer

      WebGL 使用 TypedArray 进行数据传递,这点 WebGPU 也是一样的。

      下面的代码是 WebGL 1.0 常规的 VertexBuffer 创建、赋值、配置过程。

      const positions = [
        0, 0,
        0, 0.5,
        0.7, 0,
      ]
      
      /*
      创建着色器程序 program...
      */
      
      // 获取 vertex attribute 在着色器中的位置
      const positionAttributeLocation = gl.getAttribLocation(program, "a_position")
      
      //#region 创建 WebGLBuffer 并绑定,随即写入数据
      const positionBuffer = gl.createBuffer()
      gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW)
      //#endregion
      
      //#region 启用顶点着色器中对应的 attribute,再次绑定数据,并告知 WebGL 如何读取 VertexBuffer
      gl.enableVertexAttribArray(positionAttributeLocation)
      gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
      gl.vertexAttribPointer(positionAttributeLocation, size, type, normalize, stride, offset)
      //#endregion
      

      WebGL 通过 gl 变量的 createBuffer、bindBuffer、bufferData 方法来创建缓冲、绑定当前要用什么缓冲及缓冲的用途、向缓冲传递 CPU 端的 TypedArray 数据并指明绘制模式,通过 gl 变量的 enableVertexAttribArray、vertexAttribPointer 方法来启用着色器中 attribute 的坑位、告诉着色器如何从 VertexBuffer 中获取顶点数据。

      1.2. 顶点着色器

      一个非常简单的顶点着色器:

      precision mediump float;
      attribute vec2 a_position;
      
      void main() {
        gl_Position = vec4(a_position, 0.0, 0.0);
      }
      

      如果用高版本的语法(譬如 WebGL 2.0 中用更高版本的 glsl 语法),你可以这样写:

      #version 300 es
      precision mediump float;
      layout(location = 0) in vec2 a_position;
      
      void main() {
        gl_Position = vec4(a_position, 0.0, 0.0);
      }
      

      2. WebGPU

      2.1. 创建 GPUBuffer 与传递数据

      const verticesData = [
        // 坐标 xy      // 颜色 RGBA
        -0.5, 0.0,     1.0, 0.0, 0.0, 1.0, // ← 顶点 1
        0.0, 0.5,      0.0, 1.0, 0.0, 1.0, // ← 顶点 2
        0.5, 0.0,      0.0, 0.0, 1.0, 1.0  // ← 顶点 3
      ])
      const verticesBuffer = device.createBuffer({
        size: vbodata.byteLength,
        usage: GPUBufferUsage.VERTEX,
        mappedAtCreation: true // 创建时立刻映射,让 CPU 端能读写数据
      })
      
      // 让 GPUBuffer 映射出一块 CPU 端的内存,即 ArrayBuffer,此时这个 Float32Array 仍是空的
      const verticesBufferArray = new Float32Array(verticesBuffer.getMappedRange())
      
      // 将数据传入这个 Float32Array
      verticesBufferArray.set(verticesData)
      // 令 GPUBuffer 解除映射,此时 verticesBufferArray 那块内存才能被 GPU 访问
      verticesBuffer.unmap()
      

      WebGPU 创建 VertexBuffer 是调取设备对象的 createBuffer 方法,返回一个 GPUBuffer 对象,它所需要的是指定 GPUBuffer 的类型以及缓冲的大小。如何写入这块缓冲呢?那还要提到“映射”这个概念。

      映射简单的说就是让 CPU/GPU 单边访问。此处创建 GPUBuffer 的参数中有一个 mappedAtCreation 表示创建时就映射。

      关于 WebGPU 中 Buffer 的映射、解映射,我有一篇专门的文章介绍,这里不展开过多了。

      上面代码中 verticesBuffer.getMappedRange() 返回的是一个 ArrayBuffer,随后才进行 set 操作来填充数据。数据填充完毕后,还需要 unmap 来解映射,以供后续 GPU 能访问。

      2.2. 将顶点缓冲的格式信息传递给顶点着色器

      顶点着色阶段是 渲染管线(GPURenderPipeline) 的一个组成部分,管线需要知道顶点缓冲的数据规格,由着色器模块告知。

      创建渲染管线需要 着色器模块对象(GPUShaderModule),顶点着色器模块的创建参数就有一个 buffers 属性,是一个数组,用于描述顶点着色器中访问到的顶点数据规格:

      const vsShaderModule = device.createShaderModule({
        // ...
        buffers: [
          {
            // 2 个 float32 代表 xy 坐标
            shaderLocation: 0,
            offset: 0,
            format: 'float32x2'
          }, {
            // 4 个 float32 代表 rgba 色值
            shaderLocation: 1,
            offset: 2 * verticesData.BYTES_PER_ELEMENT,
            format: 'float32x4'
          }
        ]
      })
      

      详细资料可查阅官方 API 文档中关于设备对象的 createShaderModule 方法的要求。

      2.3. 在渲染通道中设置顶点缓冲

      使用 渲染通道编码器(GPURenderPassEncoder) 来编码单个渲染通道的全流程,其中有一步要设置该通道的顶点缓冲。这个比较简单:

      // ...
      renderPassEncoder.setVertexBuffer(0, verticesBuffer)
      // ...
      

      2.4. 顶点着色器

      struct PositionColorInput {
        @location(0) in_position_2d: vec2<f32>;
        @location(1) in_color_rgba: vec4<f32>;
      };
      
      struct PositionColorOutput {
        @builtin(position) coords_output: vec4<f32>;
        @location(0) color_output: vec4<f32>;
      };
      
      @stage(vertex)
      fn main(input: PositionColorInput) 
          -> PositionColorOutput {
        var output: PositionColorOutput;
        output.color_output = input.in_color_rgba;
        output.coords_output = vec4<f32>(input.in_position_2d, 0.0, 1.0);
        return output;
      }
      

      WGSL 着色器代码可以自定义顶点着色器的入口函数名称、传入参数的结构,也可以自定义向下一阶段输出(即返回值)的结构。

      可以看到,为了接收来自 WebGPU API 传递进来的顶点属性,即自定义结构中的 PositionColorInput 结构体中的 xy 坐标 in_position_2d,以及颜色值 in_color_rgba,需要有一个“特性”,叫做 location,它括号里的值与着色器模块对象中的 shaderLocation 必须对应上。

      而对于输出,代码中则对应了结构体 PositionColorOutput,其中向下一阶段(即片段着色阶段)输出用到了内置特性(builtin),叫做 position,以及自定义的一个 vec4:color_output,它是片段着色器中光栅化后的颜色,这两个输出,类似 glsl 中的 varying(或者out)作用。

      2.5. 关于缓冲数据在内存与显存中的申请、传递与销毁

      创建 GPUBuffer 的时候,如果没有 mappedAtCreation: true,那么内存、显存都没有被申请。

      经过代码测试,当执行映射请求且成功映射后,内存就会占用掉对应的 GPUBuffer 的 size,此时完成了 ArrayBuffer 的创建,是要占空间的。

      那么什么时候显存会被申请呢?猜测是 device.queue.commit() 时,指令缓冲携带着各种通道、各种 Buffer 一并传递给 GPU,执行指令缓冲,希望有高手测试我的猜测。

      至于销毁,我使用 destory 方法测试 CPU 的内存情况,发现两分钟内并未回收,这一点待测试 ArrayBuffer 的回收情况。

      3. 比对

      gl.vertexAttribPointer() 方法的作用类似于 device.createShaderModule() 中 buffers 的作用,告诉着色器顶点缓冲单个顶点的数据规格。

      gl.createBuffer() 和 device.createBuffer() 是类似的,都是创建一个 CPU 端内存中的 Buffer 对象,但实际并没有传入数据。

      数据传递则不大一致了,WebGL 同一时刻只能指定一个 VertexBuffer,所以 gl.bindBuffer()、gl.bufferData() 一系列函数调用下来都沿着逻辑走;而 WebGPU 则需要经过映射和解映射。

      在 WebGPU 中最重要的是,在 renderPassEncoder 记录发出 draw 指令之前,要调用 renderPassEncoder.setVertexBuffer() 方法显式指定用哪一个 VertexBuffer。

      着色器代码请读者自行比对研究,只是语法上的差异。

      4. VertexArrayObject

      VAO 我也写过一篇《WebGPU 中消失的 VAO》,这里就不详细展开了,有兴趣的读者请移步我的博客列表找找。

      WebGPU 中已经不需要 VAO 了,源于 WebGPU 的机制与 WebGL 不同,VAO 本身是 OpenGL 体系提出的概念,它能节约 WebGL 切换顶点相关状态时的负担,也就是帮你缓存下来一个 VBO 的设定状态,而无需再 gl.bindBuffer()、gl.bufferData()、gl.vertexAttribPointer() 等再来一遍。

      WebGPU 的装配式思想天然就与 VAO 是一致的。VAO 的职能转交给 GPURenderPipeline 完成,其创建参数 GPURenderPipelineDescriptor.vertex.buffers 属性是 GPUVertexBufferLayout[] 类型的,这每一个 GPUVertexBufferLayout 对象就有一部分 VAO 的职能。

      1 条回复 最后回复 回复 引用 0
      • First post
        Last post

      Recent Post

      • 请问有没有本次测试所使用的threejs、babylon和orillusion的源码?

        • 阅读更多
      • B

        哪位大佬知道,纹理闪烁是怎么回事吗?.

        • 阅读更多
      • @StephenChips 同求

        • 阅读更多
      • A

        请问这个入门系列有 demo 代码没有

        • 阅读更多
      • O

        经过几次的学习已经能够构建出一个空间(场景),并在空间中创建物体(几何体),物体可以有不同的外观(材质),与现实的效果足够逼真(光照),终于把最重要的相关性最强的几部分3D功能用起来了。不过面对这块空间想做点什么,又感觉缺少了点什么,是的,只能观看不能操作,如果我要通过键盘、鼠标对场景进行实时的干预该如何做呢,经过了解输入系统可以满足我们的要求。

        输入系统

        输入系统是个比较杂乱的部分,不同平台都有对应的封装,我们可以回忆一下Win32编程将键盘和鼠标的输入集成到了事件系统,用户操作按键或操作鼠标会触发对应的消息码,指示消息,附带参数包含具体的按键信息或鼠标信息,按键信息一般包含按键码或鼠标键位。再回忆一下DOM的事件系统,使用addEventListener将click或mouse类的事件挂载,然后在回调函数中获得结果……
        回忆结束我们可以总结出来几个输入系统的特点:1、挂载感兴趣的事件;2、回调函数得到触发时处理业务逻辑。需要注意的是,键盘需要有按键表进行区分按键,对应的是鼠标需要区分不同按键,以及屏幕坐标,辅助键等一些附属信息。
        出于好奇orilluson的输入系统如何实现的,找来源码进行了一个大体的了解,可以看到输入系统的核心类是InputSystem,该类继承于CEventDispatcher类,CEventDispatcher类是可调度事件的所有类的基类,包含了事件的注册,注销,分发和清理等功能实现。内部保存了监听对象列表,当有消息需要处理时通过遍历监听器列表触发回调函数。InputSystem继承了CEventDispatcher类的事件处理能力外着重实现了键盘鼠标的事件处理。
        具体执行步骤如下:

        Engine3D.init:初始化引擎后,实例化了InputSystem类,并将canvas实例传入InputSystem类; InputSystem.initCanvas:InputSystem监听了画布的键盘与鼠标事件; addEventListener:引擎或对象通过addEventListener函数来挂载用户监听; dispatchEvent:当有挂载的监听事件响应时,回调函数会得执行。
        在输入系统的支持下,可以很轻松的使用键盘和鼠标与触控。
        输入系统的回调事件在类CEvent中,先熟悉一下这个类的常用定义: type:事件类型对应的一个字符串常量; param:注册事件时传递的参数,在注册事件时写入的参数在这里可以读出; ctrlKey:事件发生时 Ctrl 是否被按下,通过查询该键的值来判断Ctrl键的状态; altKey:事件发生时 Alt 是否被按下,通过查询该键的值来判断Alt键的状态; shiftKey:事件发生时 Shift 是否被按下,通过查询该键的值来判断Shift键的状态; 关于坐标

        一直以来的学习路径是以实用为主,但是现在必须要接触一点点不能称之为理论的理论了,那就是坐标系统。

        世界坐标

        首先要解决一个困惑的地方,过去在3D空间中的所有坐标都可以称为世界坐标,世界坐标是三维的,有三个维度(x,y,z),一般在引擎中创建可以由系统使用,开发用户程序需要遵守引擎对于世界的规划,相当于场景作为一个空间,世界坐标是对这个空间制定的规则。这里歪个楼,骇客帝国之所以叫矩阵,是不是因为在3D引擎中对空间世界的处理也是以矩阵为基础的。再拉回来,世界坐标一般以(0,0,0)为中心,我们创建的物体默认的位置也是在这里的,这里是世界的中心,一般分为右手或左手坐标系,好了关于世界坐标系这里已经够用了。

        屏幕坐标

        说回到屏幕坐标是我们过去所熟悉的,首先屏幕坐标是一个二维坐标,以像素为单位,屏幕的左下角为起点,向屏幕的左侧和上方依次是x和y坐标的正向。在网页开发中我们通过DOM事件系统获得的当前坐标一般都是指的屏幕坐标。在网页开发中并不是绝对的没有z轴,CSS中的z-index属性是否可以理解成一种z轴坐标呢。

        相互转换

        屏幕坐标是我们最终渲染到屏的最终展现形式,世界坐标是在三维空间内的标识,两者经常需要相互转换,例如今天需要讨论的输入系统的使用。假设在屏幕上点击了一个位置,需要转换到世界坐标,相似的在世界坐标内的位置或距离也需要转换为屏幕坐标。
        坐标转换有标准的算法,这里我们不必如此费力,完全可以借助引擎的工具,经过一翻查找,在相机组件的实现类Camera3D,有坐标转换的工具,可以一起熟悉一下

        object3DToScreenRay:世界坐标转换屏幕坐标; ScreenRayToObject3D:屏幕坐标转换为世界坐标; 键盘输入

        使用键盘输入,首先需要熟悉两种键盘事件:

        KEY_DOWN:键盘按下事件,使用输入系统挂载该事件,将会得到按下键盘事件通知; KEY_UP:键盘弹起事件,使用输入系统挂载该事件,将会得到弹起键盘事件通知;
        下面来一起梳理一下使用流程: 初始化:必要的引擎初始化; 输入挂载:使用键盘挂载系统指定事件和回调; 处理回调:在回调中获取参数。 基础示例

        这里写了一个最基本的示例,只将键盘的事件打印了出来。

        import { Engine3D, Scene3D, Object3D, Camera3D, HoverCameraController, ForwardRenderJob, DirectLight, KeyEvent } from "@orillusion/core"; export default class Keyboard { cameraObj: Object3D; camera: Camera3D; scene: Scene3D; boxObj: Object3D; async run() { await this.init(); await this.setup(); await this.start(); } /*** * 配置并初始化引擎 */ private async init() { // 初始化引擎 await Engine3D.init(); // 创建一个场景 this.scene = new Scene3D(); // 创建一个相机 this.cameraObj = new Object3D(); this.camera = this.cameraObj.addComponent(Camera3D); // 设置相机类型 this.camera.perspective(60, window.innerWidth / window.innerHeight, 1, 5000.0); // 设置相机控制器 let controller = this.cameraObj.addComponent(HoverCameraController); controller.setCamera(20, -20, 25); // 添加相机至场景 this.scene.addChild(this.cameraObj); } /** * 引擎功能代码 */ private async setup() { Engine3D.inputSystem.addEventListener(KeyEvent.KEY_UP, this.keyUp, this); Engine3D.inputSystem.addEventListener(KeyEvent.KEY_DOWN, this.keyDown, this); } /** * 启动渲染 */ private async start() { // 创建前向渲染 let renderJob: ForwardRenderJob = new ForwardRenderJob(this.scene); // 开始渲染 Engine3D.startRender(renderJob); } private keyDown(e: KeyEvent) { console.log('keyDown:', e.keyCode, e); } private keyUp(e: KeyEvent) { console.log('keyUp:', e.keyCode, e); } }

        运行这个示例后,在场景中按下或弹起键盘,在控制台能够看到输出。

        KeyEvent

        在回调函数中获得的参数类型是KeyEvent,KeyEvent是CEvent的子类,除了CEvent类的参数外,对于键盘事件的使用主要在于对该类型的解析,这里需要详细的了解事件的参数细节,常用到的需要进行一个了解:

        keyCode:按键code值,枚举类型可以参考官方文档的KeyCode定义。 鼠标与触控

        电脑端的鼠标操作与移动端的触控操作有许多共同的地方,在具体用法时如果能够合并为一,是可以节省一半的事件挂载操作的,不过需要留意触控与鼠标的事件对应关系。
        有了前面键盘操作的基础,鼠标与触控使用类型,我们先看支持的事件类型:

        POINTER_CLICK:触摸点击事件,对应鼠标的单击事件; POINTER_MOVE:触摸滑动事件,对应鼠标的移动事件 POINTER_DOWN:触摸开始事件, POINTER_UP:触摸结束事件 POINTER_OUT:触摸滑出事件
        既然已经合并了,后面鼠标与触控用触控来说明吧。 基础示例

        先实现一个最基础的触控功能,与键盘类似,先注册事件,然后响应事件。

        import { Engine3D, Scene3D, Object3D, Camera3D, HoverCameraController, ForwardRenderJob, PointerEvent3D } from "@orillusion/core"; export default class Mouse { cameraObj: Object3D; camera: Camera3D; scene: Scene3D; async run() { await this.init(); await this.setup(); await this.start(); } /*** * 配置并初始化引擎 */ private async init() { // 初始化引擎 await Engine3D.init(); // 创建一个场景 this.scene = new Scene3D(); // 创建一个相机 this.cameraObj = new Object3D(); this.camera = this.cameraObj.addComponent(Camera3D); // 设置相机类型 this.camera.perspective(60, window.innerWidth / window.innerHeight, 1, 5000.0); // 设置相机控制器 let controller = this.cameraObj.addComponent(HoverCameraController); controller.setCamera(20, -20, 25); // 添加相机至场景 this.scene.addChild(this.cameraObj); } /** * 引擎功能代码 */ private async setup() { Engine3D.inputSystem.addEventListener(PointerEvent3D.POINTER_UP, this.onUp, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.POINTER_DOWN, this.onDown, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.POINTER_CLICK, this.onPick, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.POINTER_OVER, this.onOver, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.POINTER_OUT, this.onOut, this); Engine3D.inputSystem.addEventListener(PointerEvent3D.POINTER_MOVE, this.onMove, this); } /** * 启动渲染 */ private async start() { // 创建前向渲染 let renderJob: ForwardRenderJob = new ForwardRenderJob(this.scene); // 开始渲染 Engine3D.startRender(renderJob); } private onUp(e: PointerEvent3D) { console.log('onUp:',e); } private onDown(e: PointerEvent3D) { console.log('onDown:',e); } private onPick(e: PointerEvent3D) { console.log('onPick:',e); } private onOver(e: PointerEvent3D) { console.log('onOver:',e); } private onOut(e: PointerEvent3D) { console.log('onOut:',e); } private onMove(e: PointerEvent3D) { console.log('onMove:',e); } } PointerEvent3D

        触控的参数是以PointerEvent3D类型作为回调函数的参数传递到应用,PointerEvent3D是CEvent的子类,除了CEvent类的参数外,需要熟悉一下这个类型的关键字段。

        mouseX:当前鼠标所在位置的X坐标; mouseY:当前鼠标所在位置的Y坐标; movementX:当前事件和上一个鼠标事件之间鼠标在水平方向上的移动值; movementY:当前事件和上一个鼠标事件之间鼠标在垂直方向上的移动值;
        坐标系列的数值请注意,可以使用前面相机组件提供的转换函数进行转换,不必自己写算法进行转换。 由对象挂载

        前面的挂载直接由引擎的输入系统挂载,这样在整个场景中都会响应,如果只需要在一个物体中响应鼠标的事件,我们可以将事件挂在物体上,为什么可以这么做呢,找出来代码可以看到,物体的容器是Object3D类,而Object3D类是Entiry的子类,Entity的父类是CEventDispatcher类,正是因为Object3D通过CEventDispatcher,继承了事件的能力。这一套继承加组件式的结构,实在是太好用了,有没有。
        这样就有了以下的代码:

        // 创建一个对象 this.boxObj = new Object3D(); this.boxObj.localPosition = new Vector3(0,0,0); // 创建渲染组件 let mr: MeshRenderer = this.boxObj.addComponent(MeshRenderer); // 设置形状 mr.geometry = new BoxGeometry(5, 5, 5); // 设置材质 mr.material = new HDRLitMaterial(); // 添加到场景 this.scene.addChild(this.boxObj); boxObj.addEventListener(PointerEvent3D.PICK_CLICK, this.onClick, this); onClick(e: PointerEvent3D) { console.log('onPick:',e); }

        运行后可以在控制台看到输出

        小结

        经过前面几次的学习,已经能够完事的构建出一个空间了,但是这块空间仍然缺乏灵动的能力,不能随时响应我们的操控,输入系统是一个随时干预系统的大杀器,可以让我们获得掌控感,是否控制欲获得了满足。
        今天只是一些最基础的用法,发挥想象力可以使这个空间好玩起来了。
        作为3D新手,后续会不断的记录学习过程,期待与你一起学习一起飞!

        • 阅读更多

      Copyright © 2022 Orillusion | Contact US