前言
前几篇文章讲的东西,对于刚接触的同学来说,还是比较多的,希望不熟悉的同学学习这篇文章前,先熟悉一下之前的内容,整个文章的先后顺序,也是我学习的顺序,跟着顺序来,也许,对你们有帮助。
回归正题,我们学习WebGPU,实际上都是为了3D效果,但是,之前的例子,都是2D的,明显不符合我们的预期。那么,这篇文章我们就开启3D的世界。这篇文章需要一些概念基础,需要这些概念才能比较顺利地理解。
最终效果
gif,略卡。
概述
我们要绘制3D,基本都要经过以下步骤:
- 在世界空间构建需要的物体
- 根据平移、旋转、缩放、近远平面等 ,计算MVP矩阵(是什么)
- 将MVP矩阵,与shader中的顶点相乘,得到空间图形在显示器上的正确投影位置
- 调整投影的选后顺序(不能让后面的内容先投影,导致视觉错误)
是不是很简单,就只有4个步骤。
值得注意的是:计算顺序有要求,因为顺序颠倒结果不一样,代表的几何意义不同(当然,数学大佬自行忽略)
- 平移、旋转、缩放计算的先后顺序不能颠倒
- 投影矩阵 * modelView矩阵 ,先后顺序不能颠倒
- 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);

描述:
- 相机的初始位置位于世界坐标原点。如果,显示异常,注意相机与物体的位置关系
- fov是指视角的弧度(不是角度),查看上图
- aspect是canvas的宽高比
- 近远平面......
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 一起使用,当改变着色器时,默认布局的结构可能会改变,从而导致意外的绑定组创建错误
大致步骤:
- 创建绑定组的layout
- 创建绑定组,关联 步骤1 创建的layout
- 创建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中直接获取就行了。
动起来
采用requestAnimationFrame
API,每次执行,让旋转角度添加即可。
注意:该API的执行是根据屏幕的刷新频率 来执行的,整体会比
setTimeout
更适合一些,但是,在高刷新率的屏幕下,也会导致执行过快。例如:每次+1的操作,
setTimeout
是非常稳定的,但是,requestAnimationFrame
会导致在相同时间内执行更多+1的操作
渲染优先级
我们看到的3D效果,实际上还是平面形变投影而来的,而我们绘制的正方体,是由6个面 ,12个三角形 ,36个点 组成。这里的话有个渲染优先级的问题:如果正方体背面先渲染,就会导致我们视觉上错乱的效果。 如何解决呢?
triangle-strip
是非常考验先后顺序的,感兴趣可以自行改造
细心的同学应该发现创建pipeline时,有部分代码与之前不同,多了cullMode
属性:
js
primitive: { topology: 'triangle-list', cullMode: 'back' }
每个面都有正反面,虽然经过了旋转,但是,依然有正反面的区别,该代码的作用是:背面剔除 。 这里涉及另一个知识点:深度剔除 ,深度剔除才是我们真正理解中的遮挡效果。
这篇文章内容稍微有些多,就不分享深度剔除,留给下一篇文章。
其他
代码已推送github,感兴趣的同学,先跑起来看看效果吧。
部分资源引用自网络,侵删。