WebGPU 矩阵栈(Matrix Stacks)

英文原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-matrix-stacks.html


WebGPU Matrix Stacks

正文

本文是 3D 数学系列文章的第 8 篇。每一篇都建立在上一篇的基础上,因此按顺序阅读会更容易理解。

  1. 平移
  2. 旋转
  3. 缩放
  4. 矩阵数学
  5. 正交投影
  6. 透视投影
  7. 相机
  8. 矩阵栈 ⬅ 你在这里
  9. 场景图

矩阵栈(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 个核心优化要点

  1. 按需创建 uniform buffer 与 bind group(资源惰性分配)
    原代码预先生成固定数量的 objectInfos(每个物体一组 uniform buffer 和 bind group),而新代码改为在 drawObject 中当需要绘制更多对象时才动态创建。这避免了为未使用的对象预留 GPU 内存,提高了资源利用率。

  2. 利用矩阵栈实现层级变换复用(减少重复计算与状态管理)
    通过 MatrixStack 的 save/restore 维护父子对象间的相对变换。例如文件柜的柜体、抽屉、把手以及递归树的分支,都只需在父变换基础上追加局部变换,无需手动计算绝对矩阵,减少了重复计算和嵌套变换的复杂度。

  3. 通过 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] 通常不会通过逐个绘制立方体或圆柱体来生成树,但递归和矩阵栈的技术会被使用——只是不再绘制单个立方体,而是使用这些矩阵来帮助生成顶点,并为整棵树构建一个单一的网格。 返回