4.3D效果:矩阵变换

前言

前几篇文章讲的东西,对于刚接触的同学来说,还是比较多的,希望不熟悉的同学学习这篇文章前,先熟悉一下之前的内容,整个文章的先后顺序,也是我学习的顺序,跟着顺序来,也许,对你们有帮助。

回归正题,我们学习WebGPU,实际上都是为了3D效果,但是,之前的例子,都是2D的,明显不符合我们的预期。那么,这篇文章我们就开启3D的世界。这篇文章需要一些概念基础,需要这些概念才能比较顺利地理解。

最终效果

gif,略卡。

概述

我们要绘制3D,基本都要经过以下步骤:

  1. 世界空间构建需要的物体
  2. 根据平移、旋转、缩放、近远平面等 ,计算MVP矩阵(是什么
  3. 将MVP矩阵,与shader中的顶点相乘,得到空间图形在显示器上的正确投影位置
  4. 调整投影的选后顺序(不能让后面的内容先投影,导致视觉错误

是不是很简单,就只有4个步骤。

值得注意的是:计算顺序有要求,因为顺序颠倒结果不一样,代表的几何意义不同(当然,数学大佬自行忽略)

  1. 平移、旋转、缩放计算的先后顺序不能颠倒
  2. 投影矩阵 * modelView矩阵 ,先后顺序不能颠倒
  3. MVP * 顶点,这个顺序也不能颠倒

数学库

这里我就不自己计算平移、旋转、缩放等对应的矩阵了。这里引入相应的数学库,WebGPU也有自己专属的矩阵变换数学库。其他语言也有相应的数学矩阵计算库,为了避免后续有版本升级之类的问题,建议,对应的图形API使用对应的数学库。

wgpu-matrix

文档中有比较全面的描述,这里就不再赘述,大致描述一些比较细节点:

投影矩阵:

js 复制代码
const fov = 60 * Math.PI / 180 // fov,如下图
const aspect = width / height; // NDC的宽高比
const near = 0.1; // 近平面
const far = 1000; // 远平面
const perspective = mat4.perspective(fov, aspect, near, far);

描述:

  1. 相机的初始位置位于世界坐标原点。如果,显示异常,注意相机与物体的位置关系
  2. fov是指视角的弧度(不是角度),查看上图
  3. aspect是canvas的宽高比
  4. 近远平面......

modelView:

js 复制代码
const m = mat4.create();            // m = new mat4
mat4.identity(m);                   // m = identity
mat4.translate(m, [1, 2, 3], m);    // m *= translation([1, 2, 3])
mat4.rotateX(m, Math.PI * 0.5, m);  // m *= rotationX(Math.PI * 0.5)
mat4.scale(m, [1, 2, 3], m);        // m *= scaling([1, 2, 3])

注释描述得十分清楚,这里也不赘述

MVP矩阵

mvp矩阵是指:投影矩阵 * modelView 。得到的 mvp矩阵*顶点坐标,就是最终渲染物体在NDC设备上的投影。

代码

代码基于上一篇文章改造

shader代码

js 复制代码
// 矩阵的类型,数据依然是Float32,但是,WebGPU对矩阵有单独的类型
@group(0) @binding(0) var<uniform> matMatrix: mat4x4<f32>;

struct VertexObj { // 结构
  @builtin(position) position : vec4<f32>,
  @location(0) color : vec4<f32>
}

@vertex
fn vertex_main(
  // js中分配的是vec3<f32>,这里声明vec4<f32>会自动补充齐次坐标
  // @location(0) pos: vec4<f32>
  @location(0) pos: vec3<f32>
) -> VertexObj {
  var vertexOutput: VertexObj;
  // 采用vec4 的方式
  // vertexOutput.position = pos;
  // vertexOutput.color = 0.5 * (pos + vec4<f32>(1.0, 1.0, 1.0, 1.0));

  vertexOutput.position = matMatrix * vec4<f32>(pos, 1.0);
  // 让每个顶点的颜色保持一致,数据是-1到1,2个单位,所以,需要+1再乘0.5,保证在0-1范围 
  vertexOutput.color = 0.5 * (vec4<f32>(pos, 1.0) + vec4<f32>(1.0, 1.0, 1.0, 1.0));

  return vertexOutput; 
}

@fragment
fn fragment_main(fragData: VertexObj) -> @location(0) vec4<f32> {
  return fragData.color;
}

js代码

上一篇文章分享了两种数据传递的方式:顶点插槽资源绑定。顶点插槽:可以理解为函数传参的形式,资源绑定:可以理解为全局变量。这里我两种方式都使用,顺便回忆一下相关代码。

正方体顶点数据

pipeline中设置获取参数方式
js 复制代码
......
vertex: {
  module: device.createShaderModule({ code: wgsl }),
  entryPoint: 'vertex_main',
  buffers: [{
    arrayStride: 4 * 3,
    attributes: [{
      shaderLocation: 0,
      format: 'float32x3',
      offset: 0
    }]
  }]
}
......
创建buffer,设置数据,并写入pipeline
js 复制代码
  const modelData = new Float32Array([ // 正方体vertex数据, 这里不使用齐次坐标,在shader中补充就行了
  // face1
    +1, -1, +1,
    -1, -1, +1,
    -1, -1, -1,
    +1, -1, -1,
    +1, -1, +1,
    -1, -1, -1,
    // face2
    +1, +1, +1,
    +1, -1, +1,
    +1, -1, -1,
    +1, +1, -1,
    +1, +1, +1,
    +1, -1, -1,
    // face3
    -1, +1, +1,
    +1, +1, +1,
    +1, +1, -1,
    -1, +1, -1,
    -1, +1, +1,
    +1, +1, -1,
    // face4
    -1, -1, +1,
    -1, +1, +1,
    -1, +1, -1,
    -1, -1, -1,
    -1, -1, +1,
    -1, +1, -1,
    // face5
    +1, +1, +1,
    -1, +1, +1,
    -1, -1, +1,
    -1, -1, +1,
    +1, -1, +1,
    +1, +1, +1,
    // face6
    +1, -1, -1,
    -1, -1, -1,
    -1, +1, -1,
    +1, +1, -1,
    +1, -1, -1,
    -1, +1, -1
  ])
  
  const modelBuffer = device.createBuffer({
    size: modelData.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
  })
  device.queue.writeBuffer(modelBuffer, 0, modelData, 0, modelData.length)
  
  ......
  passEncoder.setVertexBuffer(0, modelStore.modelBuffer)
  ......

MVP矩阵数据

绑定组 - 显式布局

与上一篇文章的代码有所不同,上篇文章采用的是:默认布局 ,而这里采用的是:显式布局

默认布局是为了简单 pipeline 提供的便利,但在大多数情况下推荐使用显式布局。从默认布局创建的绑定组不能与其他 pipeline 一起使用,当改变着色器时,默认布局的结构可能会改变,从而导致意外的绑定组创建错误

大致步骤:

  1. 创建绑定组的layout
  2. 创建绑定组,关联 步骤1 创建的layout
  3. 创建pipeline,设置pipeline的layout为:步骤1 的layout

代码:

js 复制代码
// 创建buffer
  const mvpBuffer = device.createBuffer({
    size: 4 * 4 * 4,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
  })

  // 创建绑定组的布局
  const groupLayout = device.createBindGroupLayout({
    entries: [{
      binding: 0,
      visibility: GPUShaderStage.VERTEX,
      buffer: {} // 不能删除
    }]
  })

  // 创建组
  const mvpGroup = device.createBindGroup({
    layout: groupLayout, // 关联创建组布局
    entries: [{
      binding: 0,
      resource: { buffer: mvpBuffer }
    }]
  })

  const pipeline = await device.createRenderPipelineAsync({
    // pipeline 设置对应的布局
    layout: device.createPipelineLayout({ bindGroupLayouts: [groupLayout] }),
    vertex: {
      module: device.createShaderModule({ code: wgsl }),
      entryPoint: 'vertex_main',
      buffers: [{
        arrayStride: 4 * 3,
        attributes: [{
          shaderLocation: 0,
          format: 'float32x3',
          offset: 0
        }]
      }]
    },
    fragment: {
      module: device.createShaderModule({ code: wgsl }),
      entryPoint: 'fragment_main',
      targets: [{ format }]
    },
    primitive: {
      topology: 'triangle-list',
      cullMode: 'back'
    }
  })
计算MVP矩阵

这里主要是API的使用,具体用法参考:wgpu-matrix

js 复制代码
// 相机的默认位置是世界坐标原点,如果不更改正方体的z坐标,正方体将包裹相机,仅能看到一个面
// 如果:cullMode: 'back'的话,那么,将什么都看不到
const pos = { x: 0, y: 0, z: -4 } // NDC坐标
const rotation = { x: -0.5, y: 0.5, z: 0 } // 弧度
const scale = { x: 1, y: 1, z: 1 } // NDC坐标
  
const modelView = mat4.create() // modelView = new mat4
mat4.identity(modelView) // modelView = identity

mat4.translate(modelView, [pos.x, pos.y, pos.z], modelView) // modelView *= translation([1, 2, 3])

mat4.rotateX(modelView, rotation.x, modelView) // modelView *= rotationX(rotation.x)
mat4.rotateY(modelView, rotation.y, modelView)
mat4.rotateZ(modelView, rotation.z, modelView)

mat4.scale(modelView, [scale.x, scale.y, scale.z], modelView) // modelView *= scaling([1, 2, 3])

const mvpData = mat4.multiply(perspective, modelView) // 投影矩阵 * modelView
设置数据 & passEncoder设置绑定组
js 复制代码
device.queue.writeBuffer(mvpStore.mvpBuffer, 0, mvpData as Float32Array, 0, mvpData.length)
 
......
passEncoder.setBindGroup(0, mvpStore.mvpGroup)
......

经过以上代码,mvp矩阵数据就绑定到了group上,我们在shader中直接获取就行了。

动起来

采用requestAnimationFrameAPI,每次执行,让旋转角度添加即可。

注意:该API的执行是根据屏幕的刷新频率 来执行的,整体会比setTimeout更适合一些,但是,在高刷新率的屏幕下,也会导致执行过快。

例如:每次+1的操作,setTimeout是非常稳定的,但是,requestAnimationFrame会导致在相同时间内执行更多+1的操作

渲染优先级

我们看到的3D效果,实际上还是平面形变投影而来的,而我们绘制的正方体,是由6个面12个三角形36个点 组成。这里的话有个渲染优先级的问题:如果正方体背面先渲染,就会导致我们视觉上错乱的效果。 如何解决呢?

triangle-strip是非常考验先后顺序的,感兴趣可以自行改造

细心的同学应该发现创建pipeline时,有部分代码与之前不同,多了cullMode属性:

js 复制代码
primitive: { topology: 'triangle-list', cullMode: 'back' }

每个面都有正反面,虽然经过了旋转,但是,依然有正反面的区别,该代码的作用是:背面剔除 。 这里涉及另一个知识点:深度剔除 ,深度剔除才是我们真正理解中的遮挡效果

这篇文章内容稍微有些多,就不分享深度剔除,留给下一篇文章。

其他

代码已推送github,感兴趣的同学,先跑起来看看效果吧。

部分资源引用自网络,侵删。

相关推荐
wearegogog1237 小时前
基于 MATLAB 的卡尔曼滤波器实现,用于消除噪声并估算信号
前端·算法·matlab
Drawing stars7 小时前
JAVA后端 前端 大模型应用 学习路线
java·前端·学习
品克缤7 小时前
Element UI MessageBox 增加第三个按钮(DOM Hack 方案)
前端·javascript·vue.js
小二·7 小时前
Python Web 开发进阶实战:性能压测与调优 —— Locust + Prometheus + Grafana 构建高并发可观测系统
前端·python·prometheus
小沐°7 小时前
vue-设置不同环境的打包和运行
前端·javascript·vue.js
qq_419854058 小时前
CSS动效
前端·javascript·css
烛阴8 小时前
3D字体TextGeometry
前端·webgl·three.js
桜吹雪8 小时前
markstream-vue实战踩坑笔记
前端
C_心欲无痕9 小时前
nginx - 实现域名跳转的几种方式
运维·前端·nginx
花哥码天下9 小时前
恢复网站console.log的脚本
前端·javascript·vue.js