WebGPU 矩阵栈(Matrix Stacks)
英文原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-matrix-stacks.html

正文
本文是 3D 数学系列文章的第 8 篇。每一篇都建立在上一篇的基础上,因此按顺序阅读会更容易理解。
矩阵栈(matrix stack)顾名思义,就是由矩阵组成的栈。它对于将物体相对彼此进行定位和定向非常有用。为了演示,我们来做一组文件柜,使用矩阵栈会让这变得十分轻松。
为了保持简单,我们将从上一篇文章的最后一个示例开始,用单位立方体替代原来的字母“F”。
-function createFVertices() {
+function createCubeVertices() {
* // left
* 0, 0, 0,
* 0, 0, -1,
* 0, 1, 0,
* 0, 1, -1,
*
* // right
* 1, 0, 0,
* 1, 0, -1,
* 1, 1, 0,
* 1, 1, -1,
* ];
*
* const indices = [
* 0, 2, 1, 2, 3, 1, // left
* 4, 5, 6, 6, 5, 7, // right
* 0, 4, 2, 2, 4, 6, // front
* 1, 3, 5, 5, 3, 7, // back
* 0, 1, 4, 4, 1, 5, // bottom
* 2, 6, 3, 3, 6, 7, // top
* ];
*
* const quadColors = [
* 200, 70, 120, // left column front
* 80, 70, 200, // left column back
* 70, 200, 210, // top
* 160, 160, 220, // top rung right
* 90, 130, 110, // top rung bottom
* 200, 200, 70, // between top and middle rung
* ];
...
上面的数据会生成一个如下所示的立方体。

旧代码预先创建了 26 个“objectInfos”,每个“objectInfo”都包含一组 uniform buffer 和 bindGroup,用于每个要绘制的物体。现在我们把代码改为按需创建这些资源,这样我们就可以根据需要绘制任意数量的物体。
- const numFs = 5 * 5 + 1;
const objectInfos = [];
- for (let i = 0; i < numFs; ++i) {
function createObjectInfo() {
// matrix
const uniformBufferSize = (16) * 4;
const uniformBuffer = device.createBuffer({
...
- objectInfos.push({
+ return {
uniformBuffer,
uniformValues,
matrixValue,
bindGroup,
- });
+ };
}
为了让每个立方体可以区分,我们需要一种稍微改变颜色的方法。我们将更新片元着色器,让它通过 uniform buffer 接收一个颜色,并将顶点颜色乘以这个 uniform 颜色。这样我们就可以为每个立方体略微更改顶点颜色。
struct Uniforms {
matrix: mat4x4f,
+ color: vec4f,
};
struct Vertex {
@location(0) position: vec4f,
@location(1) color: vec4f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = uni.matrix * vert.position;
vsOut.color = vert.color;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
- return vsOut.color;
+ return vsOut.color * uni.color;
}
我们需要更新 uniform buffer 的创建逻辑,为新颜色留出空间。
function createObjectInfo() {
- // matrix
- const uniformBufferSize = (16) * 4;
+ // matrix and color
+ const uniformBufferSize = (16 + 4) * 4;
const uniformBuffer = device.createBuffer({
label: 'uniforms',
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const uniformValues = new Float32Array(uniformBufferSize / 4);
// offsets to the various uniform values in float32 indices
const kMatrixOffset = 0;
+ const kColorOffset = 16;
const matrixValue = uniformValues.subarray(kMatrixOffset, kMatrixOffset + 16);
+ const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
const bindGroup = device.createBindGroup({
label: 'bind group for object',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: uniformBuffer },
],
});
return {
uniformBuffer,
uniformValues,
+ colorValue,
matrixValue,
bindGroup,
};
}
接下来,我们将“绘制物体”的代码提取到一个函数中。
let depthTexture;
+ let objectNdx = 0;
+ function drawObject(ctx, matrix, color) {
+ const { pass, viewProjectionMatrix } = ctx;
+ if (objectNdx === objectInfos.length) {
+ objectInfos.push(createObjectInfo());
+ }
+ const {
+ matrixValue,
+ colorValue,
+ uniformBuffer,
+ uniformValues,
+ bindGroup,
+ } = objectInfos[objectNdx++];
+
+ mat4.multiply(viewProjectionMatrix, matrix, matrixValue);
+ colorValue.set(color);
+
+ // upload the uniform values to the uniform buffer
+ device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
+
+ pass.setBindGroup(0, bindGroup);
+ pass.draw(numVertices);
+ }
function render() {
+ objectNdx = 0;
...
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
- // update target X,Z based on angle
- settings.target[0] = Math.cos(settings.targetAngle) * radius;
- settings.target[2] = Math.sin(settings.targetAngle) * radius;
...
+ objectNdx = 0;
- objectInfos.forEach(({
- matrixValue,
- uniformBuffer,
- uniformValues,
- bindGroup,
- }, i) => {
- const deep = 5;
- const across = 5;
- if (i < 25) {
- // compute grid positions
- const gridX = i % across;
- const gridZ = i / across | 0;
-
- // compute 0 to 1 positions
- const u = gridX / (across - 1);
- const v = gridZ / (deep - 1);
-
- // center and spread out
- const x = (u - 0.5) * across * 150;
- const z = (v - 0.5) * deep * 150;
-
- // aim this F from it's position toward the target F
- const aimMatrix = mat4.aim([x, 0, z], settings.target, up);
- mat4.multiply(viewProjectionMatrix, aimMatrix, matrixValue);
- } else {
- mat4.translate(viewProjectionMatrix, settings.target, matrixValue);
- }
-
- // upload the uniform values to the uniform buffer
- device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
-
- pass.setBindGroup(0, bindGroup);
- pass.draw(numVertices);
- });
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
我们添加了一个函数 drawObject,它会在需要时创建一个新的“objectInfo”(包含 uniform buffer 和类型化数组视图)。drawObject 接收一个名为 ctx 的上下文,其中包含 pass encoder 和当前的 viewProjectionMatrix。它还接收一个矩阵和一个颜色。该函数将传入的矩阵与 viewProjectionMatrix 相乘,填充此对象的 uniform buffer,然后设置 bind group 并调用 draw。
现在,让我们添加一些代码来用它绘制立方体。
function render() {
...
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
...
objectNdx = 0;
+ const ctx = { pass, viewProjectionMatrix };
+ drawObject(ctx, mat4.rotationY(settings.baseRotation), [1, 1, 1, 1]);
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
上面我们传入了一个绕 Y 轴旋转的矩阵和白色。这意味着该立方体将以其原始顶点颜色绘制。
我们还需要对 GUI 和相机进行一些调整。
- const radius = 200;
const settings = {
- target: [0, 200, 300],
- targetAngle: 0,
+ baseRotation: 0,
};
const radToDegOptions = { min: -360, max: 360, step: 1, converters: GUI.converters.radToDeg };
const gui = new GUI();
gui.onChange(render);
- gui.add(settings.target, '1', -100, 300).name('target height');
- gui.add(settings, 'targetAngle', radToDegOptions).name('target angle');
+ gui.add(settings, 'baseRotation', radToDegOptions);
...
function render() {
...
- const eye = [-500, 300, -500];
- const target = [0, -100, 0];
+ const eye = [0, 2, 3];
+ const target = [0, 1, 0];
const up = [0, 1, 0];
// Compute a view matrix
const viewMatrix = mat4.lookAt(eye, target, up);
现在有一个立方体了。
既然我们已经可以渲染立方体了,那就使用矩阵栈来帮助我们制作一组文件柜。
首先,创建一个矩阵栈类。
class MatrixStack {
#matrix;
#stack;
constructor() {
this.reset();
}
reset() {
this.#matrix = mat4.identity();
this.#stack = [];
return this;
}
save() {
this.#stack.push(this.#matrix);
this.#matrix = mat4.copy(this.#matrix);
return this;
}
restore() {
this.#matrix = this.#stack.pop();
return this;
}
get() {
return this.#matrix;
}
set(matrix) {
return this.#matrix.set(matrix);
}
translate(translation) {
mat4.translate(this.#matrix, translation, this.#matrix);
return this;
}
rotateX(angle) {
mat4.rotateX(this.#matrix, angle, this.#matrix);
return this;
}
rotateY(angle) {
mat4.rotateY(this.#matrix, angle, this.#matrix);
return this;
}
rotateZ(angle) {
mat4.rotateZ(this.#matrix, angle, this.#matrix);
return this;
}
scale(scale) {
mat4.scale(this.#matrix, scale, this.#matrix);
return this;
}
}
上面的类非常简单。它维护了一个 #stack(矩阵数组),以及一个 #matrix,它实际上是栈顶的矩阵。它还添加了一些方法,使用我们之前编写的 mat4 函数来操作栈顶矩阵。
注意:虽然它是一个栈,但我特意使用了 save 和 restore 作为方法名,而不是更传统的 push 和 pop,因为 save 和 restore 与 Canvas 2D API 中的 save 和 restore 相匹配,后者也用于操作自身的矩阵栈。
上面代码中引用了一个尚不存在的 mat4.copy 函数,我们来补充它。
const mat4 = {
+ copy(src, dst) {
+ dst = dst || new Float32Array(16);
+ dst.set(src);
+ return dst;
+ },
...
有了这个,我们就可以绘制一个带把手的文件柜抽屉了。抽屉是一个大立方体,把手是一个小立方体。
+ const kHandleColor = [0.5, 0.5, 0.5, 1];
+ const kDrawerColor = [1, 1, 1, 1];
+
+ const kDrawerSize = [40, 30, 50];
+ const kHandleSize = [10, 2, 2];
+
+ const [kWidth, kHeight, kDepth] = [0, 1, 2];
+
+ const kHandlePosition = [
+ (kDrawerSize[kWidth] - kHandleSize[kWidth]) / 2,
+ kDrawerSize[kHeight] * 2 / 3,
+ kHandleSize[kDepth],
+ ];
+
+ function drawDrawer(ctx) {
+ const { stack } = ctx;
+ stack.save();
+ stack.scale(kDrawerSize);
+ drawObject(ctx, stack.get(), kDrawerColor);
+ stack.restore();
+
+ stack.save();
+ stack.translate(kHandlePosition);
+ stack.scale(kHandleSize);
+ drawObject(ctx, stack.get(), kHandleColor);
+ stack.restore();
+ }
+
+ const stack = new MatrixStack();
...
function render() {
...
// combine the view and projection matrixes
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);
+ stack.save();
+ stack.rotateY(settings.baseRotation);
+ stack.translate([(kDrawerSize[kWidth] * -0.5), 0, 0]);
objectNdx = 0;
- const ctx = { pass, stack, viewProjectionMatrix };
- drawObject(ctx, mat4.rotationY(settings.baseRotation), [1, 1, 1, 1]);
+ const ctx = { stack, viewProjectionMatrix };
+ drawDrawer(ctx);
+ stack.restore();
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
上面的代码创建了一个 MatrixStack 并将其添加到传入 drawDrawer 的上下文 ctx 中。它使用这个栈来帮助我们计算矩阵。我们没有直接创建旋转矩阵,而是在栈上执行旋转,然后平移抽屉一半的宽度,以便将其居中。我们将栈传入 drawDrawer,它会绘制两个立方体:一个被缩放到 kDrawerSize 的大小,另一个被定位到 kHandlePosition 并缩放到 kHandleSize 的大小。因为使用了矩阵栈,两者都将相对于栈上已有的旋转和平移。
抽屉立方体使用颜色 kDrawerColor(白色)绘制,因此顶点颜色不变。手柄使用 kHandleColor(50% 灰色)绘制,因此立方体会更暗。
对相机位置做一点小调整:
- const eye = [0, 2, 3];
- const target = [0, 1, 0];
+ const eye = [0, 20, 100];
+ const target = [0, 20, 0];
const up = [0, 1, 0];
// Compute a view matrix
const viewMatrix = mat4.lookAt(eye, target, up);
这样我们就得到了一个文件柜抽屉。
你可能会问,为什么要大费周章使用矩阵栈?让我们绘制一个包含 4 个抽屉的文件柜,看看原因。
const kHandleColor = [0.5, 0.5, 0.5, 1];
const kDrawerColor = [1, 1, 1, 1];
+ const kCabinetColor = [0.75, 0.75, 0.75, 0.75];
+ const kNumDrawersPerCabinet = 4;
const kDrawerSize = [40, 30, 50];
const kHandleSize = [10, 2, 2];
const [kWidth, kHeight, kDepth] = [0, 1, 2];
const kHandlePosition = [
(kDrawerSize[kWidth] - kHandleSize[kWidth]) / 2,
kDrawerSize[kHeight] * 2 / 3,
kHandleSize[kDepth],
];
+ const kDrawerSpacing = kDrawerSize[kHeight] + 3;
function drawDrawer(ctx) {
const { stack } = ctx;
stack.save();
stack.scale(kDrawerSize);
drawObject(ctx, stack.get(), kDrawerColor);
stack.restore();
stack.save();
stack.translate(kHandlePosition);
stack.scale(kHandleSize);
drawObject(ctx, stack.get(), kHandleColor);
stack.restore();
}
+ function drawCabinet(ctx, numDrawersPerCabinet) {
+ const { stack } = ctx;
+
+ const kCabinetSize = [
+ kDrawerSize[kWidth] + 6,
+ kDrawerSpacing * numDrawersPerCabinet + 6,
+ kDrawerSize[kDepth] + 4,
+ ];
+
+ stack.save();
+ stack.scale(kCabinetSize);
+ drawObject(ctx, stack.get(), kCabinetColor);
+ stack.restore();
+
+ for (let i = 0; i < numDrawersPerCabinet; ++i) {
+ stack.save();
+ stack.translate([3, i * kDrawerSpacing + 5, 1]);
+ drawDrawer(ctx);
+ stack.restore();
+ }
+ }
function render() {
...
- const eye = [0, 20, 100];
- const target = [0, 20, 0];
+ const eye = [0, 80, 200];
+ const target = [0, 80, 0];
const up = [0, 1, 0];
// Compute a view matrix
const viewMatrix = mat4.lookAt(eye, target, up);
// combine the view and projection matrixes
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);
// combine the view and projection matrixes
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);
stack.save();
stack.rotateY(settings.baseRotation);
stack.translate([(kDrawerSize[kWidth] * -0.5), 0, 0]);
objectNdx = 0;
const ctx = { pass, stack, viewProjectionMatrix };
- drawDrawer(ctx);
+ drawCabinet(ctx, kNumDrawersPerCabinet);
stack.restore();
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
在上面的代码中,drawCabinet 绘制了一个与 kCabinetSize 大小相同的立方体,该立方体比我们要求绘制的抽屉数量略高。然后它仅使用矩阵栈将每个抽屉平移到正确的位置,并稍微位于文件柜立方体的前方。
我们完全不需要修改 drawDrawer。由于有了矩阵栈,我们可以直接原样使用它。
让我们继续,绘制多个文件柜。
const kHandleColor = [0.5, 0.5, 0.5, 1];
const kDrawerColor = [1, 1, 1, 1];
const kCabinetColor = [0.75, 0.75, 0.75, 0.75];
const kNumDrawersPerCabinet = 4;
+ const kNumCabinets = 5;
const kDrawerSize = [40, 30, 50];
const kHandleSize = [10, 2, 2];
const [kWidth, kHeight, kDepth] = [0, 1, 2];
const kHandlePosition = [
(kDrawerSize[kWidth] - kHandleSize[kWidth]) / 2,
kDrawerSize[kHeight] * 2 / 3,
kHandleSize[kDepth],
];
const kDrawerSpacing = kDrawerSize[kHeight] + 3;
+ const kCabinetSpacing = kDrawerSize[kWidth] + 10;
...
function drawCabinet(ctx, numDrawersPerCabinet) {
const { stack } = ctx;
const kCabinetSize = [
kDrawerSize[kWidth] + 6,
kDrawerSpacing * numDrawersPerCabinet + 6,
kDrawerSize[kDepth] + 4,
];
stack.save();
stack.scale(kCabinetSize);
drawObject(ctx, stack.get(), kCabinetColor);
stack.restore();
for (let i = 0; i < numDrawersPerCabinet; ++i) {
stack.save();
stack.translate([3, i * kDrawerSpacing + 5, 1]);
drawDrawer(ctx);
stack.restore();
}
}
+ function drawCabinets(ctx, numCabinets) {
+ const { stack } = ctx;
+ for (let i = 0; i < numCabinets; ++i) {
+ stack.save();
+ stack.translate([i * kCabinetSpacing, 0, 0]);
+ drawCabinet(ctx, kNumDrawersPerCabinet);
+ stack.restore();
+ }
+ }
function render() {
...
// combine the view and projection matrixes
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);
stack.save();
stack.rotateY(settings.baseRotation);
- stack.translate([(kDrawerSize[kWidth] * -0.5), 0, 0]);
+ stack.translate([(kNumCabinets - 0.5) * kCabinetSpacing * -0.5, 0, 0]);
objectNdx = 0;
const ctx = { pass, stack, viewProjectionMatrix };
- drawCabinet(ctx, kNumDrawersPerCabinet);
+ drawCabinets(ctx, kNumCabinets);
stack.restore();
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
现在我们有 drawCabinets,它只是使用 drawCabinet 来绘制我们指定数量的文件柜。在 render 中,我们平移了文件柜宽度的一半,以将它们居中。
希望这能让你感受到矩阵栈的用处。它使我们能够轻松地复用物体,以及对它们进行定位、定向和缩放。
递归树
让我们再做一个示例。我们使用立方体创建一棵递归树。为此,我们需要一个函数来添加树的“树枝”。我们将它写成递归函数,并传入 treeDepth。如果深度大于 0,则递归地添加两个更小的分支,并传入低一级的深度。
const degToRad = d => d * Math.PI / 180;
const settings = {
baseRotation: 0,
+ scale: 0.9,
+ rotationX: degToRad(20),
+ rotationY: degToRad(10),
};
const radToDegOptions = { min: -180, max: 180, step: 1, converters: GUI.converters.radToDeg };
+ const treeRadToDegOptions = { min: 0, max: 90, step: 1, converters: GUI.converters.radToDeg };
const gui = new GUI();
gui.onChange(render);
+ gui.add(settings, 'scale', 0.1, 1.2);
+ gui.add(settings, 'rotationX', treeRadToDegOptions);
+ gui.add(settings, 'rotationY', treeRadToDegOptions);
gui.add(settings, 'baseRotation', radToDegOptions);
+ const kTreeDepth = 6;
+ const [/*kWidth*/, kHeight, /*kDepth*/] = [0, 1, 2];
+ // Moves the 1 unit cube so it's center above the origin so that when it scales
+ // it scales out in x and z and up (y) from the origin
+ const kBranchPosition = [-0.5, 0, 0.5];
+ const kBranchSize = [20, 150, 20];
+
+ const kWhite = [1, 1, 1, 1];
+
+ function drawBranch(ctx) {
+ const { stack } = ctx;
+ stack
+ .save()
+ .scale(kBranchSize)
+ .translate(kBranchPosition);
+ drawObject(ctx, stack.get(), kWhite);
+ stack.restore();
+ }
+
+ function drawTreeLevel(ctx, offset, treeDepth) {
+ const { stack } = ctx;
+ const s = offset ? settings.scale : 1;
+ const y = offset ? kBranchSize[kHeight] : 0;
+ stack
+ .save()
+ .translate([0, y, 0])
+ .rotateZ(offset * settings.rotationX)
+ .rotateY(Math.abs(offset) * settings.rotationY)
+ .scale([s, s, s]);
+
+ drawBranch(ctx);
+
+ if (treeDepth > 0) {
+ drawTreeLevel(ctx, -1, treeDepth - 1);
+ drawTreeLevel(ctx, +1, treeDepth - 1);
+ }
+
+ stack.restore();
+ }
function render() {
...
- const eye = [0, 80, 200];
- const target = [0, 80, 0];
+ const eye = [0, 450, 1000];
+ const target = [0, 450, 0];
const up = [0, 1, 0];
// Compute a view matrix
const viewMatrix = mat4.lookAt(eye, target, up);
// combine the view and projection matrixes
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);
stack.save();
stack.rotateY(settings.baseRotation);
- stack.translate([(kNumCabinets - 0.5) * kCabinetSpacing * -0.5, 0, 0]);
objectNdx = 0;
const ctx = { pass, stack, viewProjectionMatrix };
- drawCabinets(ctx, kNumCabinets);
+ drawTreeLevel(ctx, 0, kTreeDepth);
stack.restore();
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
drawTreeLevel 使用了我们的矩阵栈。首先它调用 save 保存当前矩阵。然后 translate 将树枝移动到当前树枝的末端。如果 offset 为 0,则为根节点,无需平移。
offset 随后用于 rotateZ,使当前树枝顺时针或逆时针旋转。由于有了矩阵栈,它将相对于父树枝旋转。
offset 再次用于 rotateY。这次我们使用 offset 的绝对值。你可以去掉 Math.abs 来看看区别。
最后我们 scale 树枝,使每个树枝比其父树枝更小(或更大),根节点(offset 为 0)除外。
然后我们调用 drawBranch。drawBranch 绘制一个大小为 kBranchSize 的立方体。它还会平移原始单位立方体,使立方体中心位于原点的上方,这样当缩放时,它将沿 +Y 轴向上生长。
然后,如果深度大于 0,我们递归调用 drawTreeLevel 添加两个分支,一个偏移为 -1,一个偏移为 +1。每个分支都从栈上的矩阵开始,因此将相对于其父分支进行定位和定向。
最后我们 restore 栈。
调整 “rotationX”,你会看到树枝散开或聚拢。调整 “rotationY”,你会看到树枝从 x 平面散开。你可能需要调整 “baseRotation” 来观察效果。调整 “scale”,你会看到每个树枝相对于其父分支变小或变大。
也许这会给你一些灵感来制作一个算法树生成器[1]。
让我们在每个分支上添加一个装饰物。我们不使用立方体,而是使用圆锥体作为装饰物。下面是生成圆锥顶点的一些代码。
// tip is at origin, base is below
function createConeVertices({radius = 1, height = 1, subdivisions = 6} = {}) {
const positions = [];
const colors = [];
function addVertex(angle, radius, height, color) {
const c = Math.cos(angle);
const s = Math.sin(angle);
positions.push(c * radius, height, s * radius);
colors.push(...color);
}
for (let i = 0; i < subdivisions; ++i) {
const angle0 = (i + 0) / subdivisions * Math.PI * 2;
const angle1 = (i + 1) / subdivisions * Math.PI * 2;
const u = (i + 1) / subdivisions;
const color = [u * 128 + 127, 0, 0];
// add side
addVertex(angle0, 0, 0, color);
addVertex(angle1, radius, -height, color);
addVertex(angle0, radius, -height, color);
// add top
addVertex(angle0, radius, -height, color);
addVertex(angle1, radius, -height, color);
addVertex(angle0, 0, -height, color);
}
const numVertices = positions.length / 3;
const vertexData = new Float32Array(numVertices * 4); // xyz + color
const colorData = new Uint8Array(vertexData.buffer);
for (let i = 0; i < numVertices; ++i) {
const position = positions.slice(i * 3, i * 3 + 3);
vertexData.set(position, i * 4);
const color = colors.slice(i * 3, i * 3 + 3);
colorData.set(color, i * 16 + 12);
colorData[i * 16 + 15] = 255;
}
return {
vertexData,
numVertices,
};
}
上面的代码绕圆行走,在每一侧添加一个三角形,并在顶部添加相应的三角形。它为每个面赋予红色阴影。与立方体函数一样,它返回 vertexData 和 numVertices。我们将在另一篇文章中介绍如何制作各种几何图元。
让我们将创建顶点缓冲区的代码包装成一个函数,以便我们可以调用两次:一次用于立方体,一次用于圆锥体。
- const { vertexData, numVertices } = createCubeVertices();
+ function createVertices({vertexData, numVertices}, name) {
* const vertexBuffer = device.createBuffer({
- label: `vertex buffer vertices`,
+ label: `${name}: vertex buffer vertices`,
size: vertexData.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertexData);
+ return {
+ vertexBuffer,
+ numVertices,
+ };
* }
+ const cubeVertices = createVertices(createCubeVertices(), 'cube');
+ const ornamentVertices = createVertices(createConeVertices({
+ radius: 20,
+ height: 60,
+ }), 'ornament');
然后更新 drawObject 函数,使其接收一个顶点参数。
- function drawObject(ctx, matrix, color) {
+ function drawObject(ctx, vertices, matrix, color) {
const { pass, viewProjectionMatrix } = ctx;
+ const { vertexBuffer, numVertices } = vertices;
if (objectNdx === objectInfos.length) {
objectInfos.push(createObjectInfo());
}
const {
matrixValue,
colorValue,
uniformBuffer,
uniformValues,
bindGroup,
} = objectInfos[objectNdx++];
mat4.multiply(viewProjectionMatrix, matrix, matrixValue);
colorValue.set(color);
// upload the uniform values to the uniform buffer
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
+ pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup);
pass.draw(numVertices);
}
更新绘制树枝的代码,传入立方体顶点。
function drawBranch(ctx) {
const { stack } = ctx;
stack
.save()
.scale(kBranchSize)
.translate(kBranchPosition);
- drawObject(ctx, stack.get(), kWhite);
+ drawObject(ctx, cubeVertices, stack.get(), kWhite);
stack.restore();
}
同时,我们不再需要提前设置顶点缓冲区。
function render() {
...
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
- pass.setVertexBuffer(0, vertexBuffer);
...
接下来,在 drawTreeLevel 中添加代码,在深度为零时绘制装饰物。
function drawTreeLevel(ctx, offset, treeDepth) {
const { stack } = ctx;
const s = offset ? settings.scale : 1;
const y = offset ? kBranchSize[kHeight] : 0;
stack
.save()
.translate([0, y, 0])
.rotateZ(offset * settings.rotationX)
.rotateY(Math.abs(offset) * settings.rotationY)
.scale([s, s, s]);
drawBranch(ctx);
if (treeDepth > 0) {
drawTreeLevel(ctx, -1, treeDepth - 1);
drawTreeLevel(ctx, +1, treeDepth - 1);
}
+ if (treeDepth === 0 && offset > 0) {
+ const position = vec3.getTranslation(stack.get());
+ drawObject(ctx, ornamentVertices, mat4.translation(position), kWhite);
+ }
stack.restore();
}
我们使用了一个函数 vec3.getTranslation,需要补充它。
const vec3 = {
...
getTranslation(m, dst) {
dst = dst || new Float32Array(3);
dst[0] = m[12];
dst[1] = m[13];
dst[2] = m[14];
return dst;
},
};
getTranslation 从矩阵中获取当前的平移分量,正如我们在关于 3D 数学的文章中所讨论的那样。
上面的代码调用 getTranslation 来获取矩阵栈的当前平移量。这是最后一个树枝的底部位置。我们不能直接使用矩阵栈来绘制装饰物,因为它会随树枝一起定向和缩放,而我们希望装饰物自然下垂。因此,我们从栈中获取平移量,然后传入一个仅包含该平移的矩阵。因为平移量位于树枝底部,我们只需要绘制一个,这就是为什么我们仅在 offset > 0 时才绘制,否则我们会在完全相同的位置绘制两个装饰物。
下一篇文章:场景图。
3 个核心优化要点
-
按需创建 uniform buffer 与 bind group(资源惰性分配)
原代码预先生成固定数量的objectInfos(每个物体一组 uniform buffer 和 bind group),而新代码改为在drawObject中当需要绘制更多对象时才动态创建。这避免了为未使用的对象预留 GPU 内存,提高了资源利用率。 -
利用矩阵栈实现层级变换复用(减少重复计算与状态管理)
通过MatrixStack的save/restore维护父子对象间的相对变换。例如文件柜的柜体、抽屉、把手以及递归树的分支,都只需在父变换基础上追加局部变换,无需手动计算绝对矩阵,减少了重复计算和嵌套变换的复杂度。 -
通过 uniform 颜色调制顶点颜色以复用几何数据
所有对象(抽屉、把手、柜体、树枝等)都使用相同的单位立方体/圆锥体顶点数据,仅通过 uniform buffer 传入不同的颜色并乘以顶点颜色来区分外观。这避免了为每个对象创建不同颜色的顶点缓冲区副本,节省了显存带宽和存储空间。
GLSL/WebGL 专业术语检查说明
本文代码基于 WebGPU 及 WGSL(WebGPU Shading Language),而非 GLSL/OpenGL/WebGL。翻译时已注意以下术语使用规范:
- uniform buffer → 统一缓冲区
- bind group → 绑定组
- render pass encoder → 渲染通道编码器
- vertex buffer → 顶点缓冲区
- vertex shader / fragment shader → 顶点着色器 / 片元着色器
- mat4x4f / vec4f → WGSL 中的 4×4 浮点矩阵 / 4 分量浮点向量
- @group / @binding → WGSL 的资源绑定注解,对应 WebGPU 的 bind group 与 binding 编号
- @location → 顶点属性位置注解
- @builtin(position) → 内建输出位置(相当于 GLSL 中
gl_Position,但属于 WGSL 内置变量) - GPUBufferUsage.UNIFORM / COPY_DST / VERTEX → WebGPU API 中缓冲区用途标志
- writeBuffer / setBindGroup / setVertexBuffer / draw → WebGPU 命令编码器方法
全文未使用 GLSL 中常见的 attribute、varying、gl_Position 等术语,已确保术语与 WebGPU/WGSL 规范一致。
脚注:
[1] 通常不会通过逐个绘制立方体或圆柱体来生成树,但递归和矩阵栈的技术会被使用——只是不再绘制单个立方体,而是使用这些矩阵来帮助生成顶点,并为整棵树构建一个单一的网格。 返回
