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,感兴趣的同学,先跑起来看看效果吧。

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

相关推荐
汪子熙22 分钟前
Angular 服务器端应用 ng-state tag 的作用介绍
前端·javascript·angular.js
Envyᥫᩣ30 分钟前
《ASP.NET Web Forms 实现视频点赞功能的完整示例》
前端·asp.net·音视频·视频点赞
Мартин.4 小时前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
昨天;明天。今天。6 小时前
案例-表白墙简单实现
前端·javascript·css
数云界6 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd6 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常6 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer6 小时前
Vite:为什么选 Vite
前端
小御姐@stella6 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing6 小时前
【React】增量传输与渲染
前端·javascript·面试