《游戏引擎实现原理》4.2 上传、暂存与 mipmap 生成

上一节把资源绑进了管线,但资源本身还没进 GPU。本节讲 writeTexture 与 copyBufferToTexture 两条上传路径,撞上 WebGPU 最著名的 bytesPerRow 必须对齐 256 字节的坑并抓出真实报错,再用一条 textureLoad 的 box filter 通道逐级生成 mipmap 并回读断言。

4.2 上传、暂存与 mipmap 生成

上一节我们把 uniform、texture、sampler 绑进了管线,但那张 2x2 的贴图是怎么进到显存里的?一句 writeTexture 就搞定了,看起来平淡无奇。可真实引擎里的贴图是几百 KB 到几 MB 的 PNG/DDS,顶点数据是几十万个三角形,它们既不在 CPU 可直接读的显存里,也不能简单地 memcpy 过去——中间隔着一层驱动、一层地址空间、一层对齐规则。

这一节把上传这条路走通,并且故意撞一次 WebGPU 最著名的坑:copyBufferToTexture 要求 bytesPerRow 是 256 的倍数。很多人第一次写上传代码,都是被这条校验拦下来、对着一句没头没脑的报错发懵。我们会把它抓出来、打印清楚,再给出正确的对齐算术。

两条上传路径:writeTexture 与 copyBufferToTexture

WebGPU 给了两条把像素送进 texture 的路:

  • queue.writeTexture:直接从 CPU 内存写。适合小数据、一次性写入,实现简单,不需要额外的 staging buffer。代价是它走的是「立即拷贝」路径,数据量大时占用 CPU 时间。
  • copyBufferToTexture:先建一块 CPU 可写的 buffer(staging buffer),把数据填进去,再用一条命令把 buffer 拷进 texture。适合大数据、批量上传——staging buffer 可以被驱动优化成异步 DMA 传输,而且能和别的命令一起排队。

两条路的差别不在「能不能用」,而在「数据怎么到达 GPU」。writeTexture 是同步拷贝,copyBufferToTexture 是命令式的异步传输。helio 的规则是:小于一页的小数据用 writeTexture,其余一律走 staging buffer + copyBufferToTexture。

但 copyBufferToTexture 有个硬约束:bytesPerRow 必须是 256 的倍数。原因在硬件层——GPU 的 buffer→texture 拷贝单元按固定粒度搬运,256 是几乎所有 GPU 都支持的最小行对齐。这就意味着:一张 3 像素宽、RGBA8 的贴图,一行其实只有 12 字节,但你必须把每行补齐到 256 字节再传,多出来的 244 字节是纯浪费的填充。

256 字节对齐:先算清楚,再传

对齐这件事不该散落在上传代码里,helio 把它收进一个纯 TS 模块。下面这个文件既做「算对齐行距 / 校验行距」,也做「软件 mipmap 生成」,可 Node 直跑、可断言。

// file: src/render/upload/mipmap.ts
// tier: L1
// verify: npx vitest run src/render/upload/mipmap.test.ts
//
// 上传路径的对齐算术 + 软件 mipmap 生成:copyBufferToTexture 要求
// bytesPerRow 是 256 的倍数(WebGPU 的著名坑),这里把「算对齐行距」
// 「校验行距」「按 box filter 逐级降采样」收进一个可 Node 直跑的模块。

export interface MipLevel {
  readonly level: number;
  readonly width: number;
  readonly height: number;
  /** 紧凑排列(无行填充)的字节,长度 = width*height*bytesPerPixel。 */
  readonly data: Uint8Array;
}

/** 向上对齐到 align(align 必须是 2 的幂)。 */
export function alignTo(n: number, align: number): number {
  if (!Number.isInteger(n) || n < 0) throw new RangeError(`n 必须是非负整数,得到 ${n}`);
  if (align <= 0 || (align & (align - 1)) !== 0) throw new RangeError(`align 必须是 2 的幂,得到 ${align}`);
  return (n + align - 1) & ~(align - 1);
}

/** copyBufferToTexture / copyTextureToBuffer 要求 256 字节行距对齐。 */
export const TEXTURE_COPY_ALIGN = 256;

export function tightBytesPerRow(width: number, bytesPerPixel = 4): number {
  return width * bytesPerPixel;
}

/** 上传用的对齐行距:紧凑行距向上取到 256 的倍数。 */
export function alignedBytesPerRow(width: number, bytesPerPixel = 4): number {
  return alignTo(tightBytesPerRow(width, bytesPerPixel), TEXTURE_COPY_ALIGN);
}

/** 校验行距;不合法时抛出与 WebGPU 语义一致的错误。 */
export function assertBytesPerRow(bytesPerRow: number): void {
  if (bytesPerRow % TEXTURE_COPY_ALIGN !== 0) {
    throw new RangeError(`bytesPerRow (${bytesPerRow}) must be a multiple of ${TEXTURE_COPY_ALIGN}`);
  }
}

/** 把紧凑像素拷进「行距 = alignedBytesPerRow」的缓冲区,可直接喂给 copyBufferToTexture。 */
export function padToAligned(level: MipLevel, bytesPerPixel = 4): Uint8Array {
  const bpr = alignedBytesPerRow(level.width, bytesPerPixel);
  const tight = tightBytesPerRow(level.width, bytesPerPixel);
  const out = new Uint8Array(bpr * level.height);
  for (let y = 0; y < level.height; y++) {
    out.set(level.data.subarray(y * tight, y * tight + tight), y * bpr);
  }
  return out;
}

/** box filter 逐级降采样:每级取上一级 2x2 的平均,直到 1x1。 */
export function generateMipChain(
  base: Uint8Array,
  width: number,
  height: number,
  bytesPerPixel = 4,
): MipLevel[] {
  if (base.length !== width * height * bytesPerPixel) {
    throw new RangeError(`base 长度 ${base.length} != ${width}x${height}x${bytesPerPixel}`);
  }
  const levels: MipLevel[] = [{ level: 0, width, height, data: base }];
  let w = width;
  let h = height;
  let cur = base;
  while (w > 1 || h > 1) {
    const nw = Math.max(1, w >> 1);
    const nh = Math.max(1, h >> 1);
    const next = new Uint8Array(nw * nh * bytesPerPixel);
    for (let y = 0; y < nh; y++) {
      for (let x = 0; x < nw; x++) {
        for (let c = 0; c < bytesPerPixel; c++) {
          let sum = 0;
          let cnt = 0;
          for (let dy = 0; dy < 2; dy++) {
            for (let dx = 0; dx < 2; dx++) {
              const sx = x * 2 + dx;
              const sy = y * 2 + dy;
              if (sx < w && sy < h) {
                sum += cur[(sy * w + sx) * bytesPerPixel + c]!;
                cnt++;
              }
            }
          }
          next[(y * nw + x) * bytesPerPixel + c] = Math.round(sum / cnt);
        }
      }
    }
    levels.push({ level: levels.length, width: nw, height: nh, data: next });
    w = nw;
    h = nh;
    cur = next;
  }
  return levels;
}

两个要点。第一,对齐用的是位运算 (n + align - 1) & ~(align - 1),前提是 align 是 2 的幂——这也是为什么 assertBytesPerRow 之外还要有 alignTo 里的 align 检查。第二,generateMipChain 用 box filter 逐级取 2x2 平均,直到 1x1。注意 4x4→2x2 用的是 w >> 1(整除 2),所以非 2 的幂的尺寸会丢掉最右边/最下边不足一格的像素,这是 box filter 的固有行为,不是 bug。

本机实测 npx vitest run src/render/upload/mipmap.test.ts:Test Files 1 passed (1)、Tests 8 passed (8)。断言覆盖了:3 像素宽 RGBA 的紧凑行距 12 被对齐到 256;2x2 四色 (100,60,20,4) 的 mip 均值是 46;4x1 的 mip 链是 4x1 → 2x1 → 1x1,第二级为 (15,35)、第三级为 25。

可运行产物:真跑上传 + 生成 mip 链 + 回读

下面这个 L2 模块把上面两条路真跑一遍:用 copyBufferToTexture 传一张 4x4 的贴图(行距对齐到 256),用 error scope 抓出「行距不是 256 倍数」的真实报错,再用一条 textureLoad 的 box filter 通道逐级降采样、回读 level1 断言像素。

// file: src/render/upload/webgpu-upload.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/upload/webgpu-upload.ts
//
// 真跑一次「对齐行距上传 + 逐级生成 mip 链 + 回读某级」:
// 1) copyBufferToTexture 把 4x4 的 level0 传上去(bytesPerRow 对齐到 256);
// 2) 用 error scope 抓出「行距不是 256 倍数」的真实校验错误;
// 3) 用一条 textureLoad 的 box filter 通道逐级降采样,回读 level1 断言像素。

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string; }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown>; }

const DOWNSAMPLE_WGSL = `
// file: shaders/mip-downsample.wgsl
// tier: L2
@group(0) @binding(0) var src: texture_2d<f32>;

@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
  var p = array<vec2<f32>, 3>(vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0));
  return vec4<f32>(p[i], 0.0, 1.0);
}

@fragment fn fs(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
  let s = vec2<i32>(frag.xy) * 2;
  let c0 = textureLoad(src, s + vec2<i32>(0, 0), 0);
  let c1 = textureLoad(src, s + vec2<i32>(1, 0), 0);
  let c2 = textureLoad(src, s + vec2<i32>(0, 1), 0);
  let c3 = textureLoad(src, s + vec2<i32>(1, 1), 0);
  return (c0 + c1 + c2 + c3) * 0.25;
}
`;

const ALIGN = 256;

export async function runL2(): Promise<L2Result> {
  const checks: L2Check[] = [];
  const data: Record<string, unknown> = {};
  const push = (name: string, ok: boolean, detail: unknown): void => {
    checks.push({ name, ok, detail: String(detail) });
  };
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('requestAdapter() returned null');
  const device = await adapter.requestDevice();
  const format = 'rgba8unorm' as const;

  const module = device.createShaderModule({ code: DOWNSAMPLE_WGSL });
  const info = await module.getCompilationInfo();
  const errs = info.messages.filter((m) => m.type === 'error')
    .map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
  push('wgsl 编译 0 error', errs.length === 0, errs.join('; ') || 'no diagnostics');

  // level0:4x4,左两列 =100、右两列 =200(RGB 同值,A=255)。
  const base = new Uint8Array(4 * 4 * 4);
  for (let y = 0; y < 4; y++) {
    for (let x = 0; x < 4; x++) {
      const v = x < 2 ? 100 : 200;
      const o = (y * 4 + x) * 4;
      base[o] = v; base[o + 1] = v; base[o + 2] = v; base[o + 3] = 255;
    }
  }
  const padded = new Uint8Array(ALIGN * 4);
  for (let y = 0; y < 4; y++) padded.set(base.subarray(y * 16, y * 16 + 16), y * ALIGN);
  data.tightBytesPerRow = 16;
  data.alignedBytesPerRow = ALIGN;

  const texture = device.createTexture({
    size: [4, 4], format, mipLevelCount: 3,
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
      | GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT,
  });
  const staging = device.createBuffer({
    size: padded.length, usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(staging, 0, padded);
  const enc = device.createCommandEncoder();
  enc.copyBufferToTexture(
    { buffer: staging, bytesPerRow: ALIGN, rowsPerImage: 4 }, { texture }, [4, 4]);
  device.queue.submit([enc.finish()]);

  // 抓真实校验错误:行距必须是 256 的倍数。
  device.pushErrorScope('validation');
  const badEnc = device.createCommandEncoder();
  badEnc.copyBufferToTexture(
    { buffer: staging, bytesPerRow: 16, rowsPerImage: 4 }, { texture }, [4, 4]);
  device.queue.submit([badEnc.finish()]);
  const err = await device.popErrorScope();
  data.alignmentError = err ? err.message : null;
  push('紧凑行距 16 触发校验错误', err !== null, err ? err.message : 'no error raised');

  // 逐级降采样:level i 用 level i-1 生成。
  const layout = device.createBindGroupLayout({
    entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }],
  });
  const pipeline = device.createRenderPipeline({
    layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
    vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', targets: [{ format }] },
    primitive: { topology: 'triangle-list' },
  });
  const mipEnc = device.createCommandEncoder();
  for (let level = 1; level < 3; level++) {
    const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 });
    const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 });
    const bg = device.createBindGroup({ layout, entries: [{ binding: 0, resource: srcView }] });
    const pass = mipEnc.beginRenderPass({ colorAttachments: [
      { view: dstView, loadOp: 'clear', storeOp: 'store', clearValue: { r: 0, g: 0, b: 0, a: 1 } },
    ] });
    pass.setPipeline(pipeline);
    pass.setBindGroup(0, bg);
    pass.draw(3);
    pass.end();
  }
  device.queue.submit([mipEnc.finish()]);

  // 回读 level1(2x2)。
  const rb = device.createBuffer({ size: ALIGN * 2, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  const rbEnc = device.createCommandEncoder();
  rbEnc.copyTextureToBuffer(
    { texture, mipLevel: 1, origin: { x: 0, y: 0, z: 0 } },
    { buffer: rb, bytesPerRow: ALIGN, rowsPerImage: 2 }, [2, 2]);
  device.queue.submit([rbEnc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const px = new Uint8Array(rb.getMappedRange().slice(0));
  rb.unmap(); rb.destroy(); device.destroy();

  const at = (x: number, y: number): number[] => Array.from(px.slice(y * ALIGN + x * 4, y * ALIGN + x * 4 + 4));
  const left = at(0, 0), right = at(1, 0);
  data.mip1Left = left; data.mip1Right = right;
  push('level1 左 texel ≈ [100,100,100,255]', Math.abs(left[0]! - 100) <= 2, JSON.stringify(left));
  push('level1 右 texel ≈ [200,200,200,255]', Math.abs(right[0]! - 200) <= 2, JSON.stringify(right));
  push('左右 texel 不同(证明 box filter 生效)', left[0] !== right[0], `L=${left[0]} R=${right[0]}`);
  return { checks, data };
}

本机实测 node tools/run-l2.mjs src/render/upload/webgpu-upload.ts,5 条检查全部 PASS,其中那条「坑」的真实报错是:

bytesPerRow (16) is not a multiple of 256.

这正是 4x4 RGBA8 贴图的紧凑行距(16 字节)——它不是 256 的倍数,所以 copyBufferToTexture 直接拒绝,即使这张贴图小到一页都装不满。改成对齐行距 256 后,上传成功。回读 level1 得到左 texel [100,100,100,255]、右 texel [200,200,200,255]——level0 左两列是 100、右两列是 200,box filter 每级取 2x2 平均,左半仍然 100、右半仍然 200,证明 mip 生成真的按区域降采样,而不是简单复制。

小结

  • 两条上传路径:小数据用 queue.writeTexture,大数据用 staging buffer + copyBufferToTexture(可被驱动优化成异步传输)。
  • copyBufferToTexture / copyTextureToBuffer 要求 bytesPerRow 是 256 的倍数,真实报错就是 bytesPerRow (16) is not a multiple of 256.;helio 用 alignedBytesPerRow 把紧凑行距向上对齐。
  • mipmap 用 box filter 逐级取 2x2 平均,直到 1x1;4x4→2x2→1x1 每级减半,非 2 的幂的尺寸会丢掉边缘不足一格的像素。
  • 回读 level1 得到左右两个不同 texel,证明 mip 是按区域降采样,而不是复制 level0。

上传和 mip 都通了,但资源一多,问题就从「怎么传」变成「传多少、留多久」:一帧里会创建和销毁大量 buffer/texture,反复向驱动申请显存既慢又碎。下一节我们给 GPU 资源做一套内存池与预算,把复用率和峰值占用都统计出来。

阅读导航:上一节:4.1 资源绑定与 bind group · 下一节:4.3 GPU 内存池与预算 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径