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 |
|---|---|---|---|
| Buffer | size、usage | mappedAtCreation | Vertex | CopyDst |
| Texture | width、height、format、usage | mipLevelCount、sampleCount | TextureBinding | 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 管线状态对象与着色器模块 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。