《游戏引擎实现原理》2.2 资源对象:缓冲、纹理、采样器

渲染层的一切最终都是三类 GPU 资源:缓冲、纹理、采样器。本节给出 helio 的资源描述符与使用标志(与 WebGPU 逐位一致),用纯逻辑校验行距对齐与 mip 链长度,再在真实 WebGPU 上创建一块 buffer、写入、读回,逐字节断言两条上传路径的结果一致。

2.2 资源对象:缓冲、纹理、采样器

上一节把后端的差异收敛成了一张能力表。这一节开始往 RhiDevice 上挂真正的资源——但你会发现,无论 WebGPU、WebGL2 还是 NullRHI,能创建的东西翻来覆去只有三类:**缓冲(Buffer)**存线性数据,**纹理(Texture)**存二维/三维像素,**采样器(Sampler)**描述怎么从纹理里取像素。

把这三类钉死,上层的材质、网格、帧图就有了统一的积木。

三类资源对象与它们的描述符

先看词汇表。它是 L3(仅类型):只有常量与描述符接口,没有创建逻辑,命令行不可复现,需在浏览器或编辑器里手动核对。

// file: src/render/rhi/resource.ts
// tier: L3
//
// 资源对象的三类抽象:Buffer / Texture / Sampler 的创建描述符与使用标志。
// 标志位取值与 WebGPU 逐位一致,于是 WebGPU 后端是恒等映射,
// WebGL2 后端负责把它们翻译成 GL 的绑定点。仅类型,命令行不可复现。

import type { RhiTextureFormatName } from './rhi.ts';

export const RhiBufferUsage = {
  MapRead: 1, MapWrite: 2, CopySrc: 4, CopyDst: 8, Index: 16,
  Vertex: 32, Uniform: 64, Storage: 128, Indirect: 256,
} as const;

export const RhiTextureUsage = {
  CopySrc: 1, CopyDst: 2, TextureBinding: 4, StorageBinding: 8, RenderAttachment: 16,
} as const;

export interface RhiBufferDescriptor {
  readonly label?: string;
  readonly size: number;
  readonly usage: number;
  readonly mappedAtCreation?: boolean;
}

export interface RhiTextureDescriptor {
  readonly label?: string;
  readonly width: number;
  readonly height: number;
  readonly format: RhiTextureFormatName;
  readonly usage: number;
  readonly mipLevelCount?: number;
  readonly sampleCount?: number;
}

export type RhiAddressMode = 'clamp-to-edge' | 'repeat' | 'mirror-repeat';
export type RhiFilterMode = 'nearest' | 'linear';

export interface RhiSamplerDescriptor {
  readonly label?: string;
  readonly addressModeU?: RhiAddressMode;
  readonly addressModeV?: RhiAddressMode;
  readonly magFilter?: RhiFilterMode;
  readonly minFilter?: RhiFilterMode;
  readonly mipmapFilter?: RhiFilterMode;
  readonly maxAnisotropy?: number;
}

三个描述符各有各的必填项:buffer 只认 size + usage;texture 还要 width、height、format;sampler 全是可选项,缺省即 clamp-to-edge + nearest。usage 是三者里唯一「位运算」的字段——它决定了这块资源能被谁用、能被怎么用。

把三者的必填项并排看更清楚:

资源必填可选典型 usage
Buffersize、usagemappedAtCreationVertex | CopyDst
Texturewidth、height、format、usagemipLevelCount、sampleCountTextureBinding | CopyDst
Sampler无寻址、过滤、各向异性无(不参与位运算)

还有一个容易忽略的点:usage 是创建期就锁死的。一块 buffer 建的时候没带 CopyDst,后面就再也不能往里写;一张纹理没带 RenderAttachment,就不能当渲染目标。想加用法只能重建——这正是描述符要一次写全的原因。

使用标志:一份描述符里的三条约束

usage 用起来像一锅位标志,但踩坑的地方都在算术上:行距必须是 256 的倍数、mip 链不能超长、buffer 大小得是 4 的倍数。这些都能在 Node 里纯逻辑验证,是 L1。

// file: src/render/rhi/resource-desc.ts
// tier: L1
// verify: npx vitest run src/render/rhi/resource-desc.test.ts
//
// 描述符的纯逻辑:标志位拼装、行距对齐、mip 链长度、创建前校验。
// 这些都能在 Node 里跑,不需要 GPU——把「会踩坑的算术」从后端里拎出来单测。

import { alignUp } from '../../core/vertex-layout.ts';
import type { RhiBufferDescriptor, RhiTextureDescriptor } from './resource.ts';

/** 把若干使用标志按位或起来,得到最终 usage。 */
export function usage(...flags: readonly number[]): number {
  return flags.reduce((acc, f) => acc | f, 0);
}

/** 判断 usage 是否包含某个标志位。 */
export function hasUsage(value: number, flag: number): boolean {
  return (value & flag) === flag;
}

/**
 * copyBufferToTexture / copyTextureToBuffer 要求 bytesPerRow 是 256 的倍数。
 * 传入一行真实字节数,返回补齐后的行距。
 */
export function rowPitch(widthBytes: number): number {
  return alignUp(widthBytes, 256);
}

/** 完整 mip 链的层数:从 2^n 一路降到 1x1。 */
export function fullMipLevelCount(width: number, height: number): number {
  return Math.floor(Math.log2(Math.max(width, height))) + 1;
}

/** 一张纹理去掉行对齐后的理论字节数(用于预算估算)。 */
export function textureByteSize(width: number, height: number, bytesPerPixel: number): number {
  return width * height * bytesPerPixel;
}

/** 校验 buffer 描述符,返回人类可读的错误列表(空数组即通过)。 */
export function validateBufferDescriptor(desc: RhiBufferDescriptor): string[] {
  const errors: string[] = [];
  if (!Number.isInteger(desc.size) || desc.size <= 0) errors.push('size 必须是正整数');
  else if (desc.size % 4 !== 0) errors.push(`size ${desc.size} 必须是 4 的倍数`);
  if (desc.usage === 0) errors.push('usage 不能为 0');
  return errors;
}

/** 校验 texture 描述符:mip 层数不能超过完整链、采样数只允许 1 或 4。 */
export function validateTextureDescriptor(desc: RhiTextureDescriptor): string[] {
  const errors: string[] = [];
  if (desc.width <= 0 || desc.height <= 0) errors.push('width/height 必须为正');
  const maxMips = fullMipLevelCount(desc.width, desc.height);
  const mips = desc.mipLevelCount ?? 1;
  if (mips < 1 || mips > maxMips) errors.push(`mipLevelCount ${mips} 超出 [1, ${maxMips}]`);
  const samples = desc.sampleCount ?? 1;
  if (samples !== 1 && samples !== 4) errors.push(`sampleCount ${samples} 只允许 1 或 4`);
  return errors;
}

本机 npx vitest run src/render/rhi/resource-desc.test.ts 的结果:

✓ resource-desc 纯逻辑 > usage 按位或拼装,hasUsage 精确判断
✓ resource-desc 纯逻辑 > 行距补齐到 256 的倍数
✓ resource-desc 纯逻辑 > 完整 mip 链层数
✓ resource-desc 纯逻辑 > 纹理字节数按 bpp 计算
✓ resource-desc 纯逻辑 > buffer 描述符:size 必须是 4 的倍数
✓ resource-desc 纯逻辑 > texture 描述符:mip 与 sampleCount 越界会被拦下
 Test Files  1 passed (1)
      Tests  6 passed (6)

注意 rowPitch 复用了卷一内核的 alignUp——内存对齐的算术只写一遍,从内核到渲染层共用。256 这个数字来自 WebGPU 对 bytesPerRow 的硬性要求,不是随手取的。

三个后端对 usage 的处理并不一样,这恰恰是 RHI 存在的理由:

  • WebGPU:usage 原样透传,位标志就是设备理解的位标志。
  • WebGL2:没有 usage 概念,后端把它翻译成「绑定到哪个 GL 绑定点」,Storage、MapRead 这类会被直接拒绝。
  • NullRHI:全部接受,只记录不绑定,让上层逻辑能在无 GPU 环境里跑完整条路径。

可运行产物:真机建 buffer、写入、读回

描述符再漂亮,也得在真设备上跑通才算数。下面这段是 L2:在 WebGPU 上创建一块 buffer,用两条路径写入同样的字节,再各自读回逐字节断言。

// file: src/render/res/buffer-roundtrip.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/res/buffer-roundtrip.ts
//
// 在真实 WebGPU 上创建 buffer、写入、读回,逐字节断言。
// 两条写入路径:A) queue.writeBuffer;B) mappedAtCreation 创建即映射。

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

const SRC = new Uint32Array([0x11223344, 0xdeadbeef, 0xcafebabe, 0xabad1dea]);
const hex = (a: Uint32Array): string[] => Array.from(a, (x) => '0x' + x.toString(16));

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();

  // 把一块 src buffer 拷进 MAP_READ buffer 并读回。
  const readBack = async (src: GPUBuffer): Promise<Uint32Array> => {
    const rb = device.createBuffer({
      size: SRC.byteLength,
      usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
    });
    const enc = device.createCommandEncoder();
    enc.copyBufferToBuffer(src, 0, rb, 0, SRC.byteLength);
    device.queue.submit([enc.finish()]);
    await rb.mapAsync(GPUMapMode.READ);
    const got = new Uint32Array(rb.getMappedRange().slice(0));
    rb.unmap();
    rb.destroy();
    return got;
  };

  // 路径 A:queue.writeBuffer —— 最常用的异步上传。
  const a = device.createBuffer({
    label: 'glimmer-isle/vertex',
    size: SRC.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(a, 0, SRC);
  const gotA = await readBack(a);
  data.pathA = hex(gotA);
  push('writeBuffer 往返字节一致', gotA.every((v, i) => v === SRC[i]), data.pathA);

  // 路径 B:mappedAtCreation —— 创建时直接写进映射区间,首帧零拷贝。
  const b = device.createBuffer({
    size: SRC.byteLength,
    usage: GPUBufferUsage.COPY_SRC,
    mappedAtCreation: true,
  });
  new Uint32Array(b.getMappedRange()).set(SRC);
  b.unmap();
  const gotB = await readBack(b);
  data.pathB = hex(gotB);
  push('mappedAtCreation 往返字节一致', gotB.every((v, i) => v === SRC[i]), data.pathB);

  data.bytes = SRC.byteLength;
  a.destroy();
  b.destroy();
  device.destroy();
  return { checks, data };
}

在 headless Chromium(本地 http 服务 + --enable-unsafe-webgpu)里实跑的结果:

PASS  writeBuffer 往返字节一致  0x11223344,0xdeadbeef,0xcafebabe,0xabad1dea
PASS  mappedAtCreation 往返字节一致  0x11223344,0xdeadbeef,0xcafebabe,0xabad1dea

两条路径回读到的 16 个字节与源数组完全一致,说明「写进去」和「读回来」都没丢位。差别只在时机:queue.writeBuffer 把数据排进队列异步上传,适合每帧都在变的动态数据;mappedAtCreation 在创建那一刻就把一块 CPU 可写的映射区间交给你,写完 unmap 即用,适合一次性上传的静态几何。

到这里,RhiDevice 已经能造 buffer 了。下一步要让它能画东西——那需要把顶点布局、图元拓扑、深度与混合状态打包成一条管线状态对象。

阅读导航:上一节:2.1 为什么要 RHI · 下一节:2.3 管线状态对象与着色器模块 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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