16.1 精灵批次与图集
前 15 章画的都是 3D 世界:网格、光照、阴影、帧图、后处理。到了 2D 与 UI,规则整个反过来——界面上的精灵不参与光照,绝大多数是不透明贴图,数量却可能上千。画它们的关键不再是「算得对不对」,而是「怎么少发几条绘制命令」。本章三节回答三个问题:精灵怎么并批(16.1)、文字与矢量图形怎么不糊(16.2)、UI 与 3D 谁先画谁后画(16.3)。
精灵渲染绕不开两件事。第一件是图集(atlas):把成百上千张小图打进一张大纹理,采样时用 UV 子矩形定位——否则每个精灵绑一张纹理,命令里全是纹理切换。第二件是批次键:把能一起画的精灵排到连续位置,一次 draw 出很多个。这一节把两件事都落成可跑代码。
本节产出一个 L1 的
src/render/ui2d/sprite-batch.ts(Node 直跑)与一个 L2 的src/render/ui2d/sprite-atlas-draw.ts+shaders/sprite-atlas-quads.wgsl(headless Chromium 实跑)。文中所有绘制次数与像素都来自本机实跑。
本章与站内既有内容的分工
第 16 章「2D 与 UI」很容易写成一篇 UI 框架综述或抗锯齿综述,先划清边界:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-texture-memory/ | 纹理内存布局、mip、图集与压缩格式的原理与多引擎对照 | 只在 helio 里实现货架装箱与精灵批次键,跑出绘制次数 |
/graphics-anti-aliasing-methods/ | MSAA / SSAA / FXAA / TAA 等抗锯齿方法的原理与取舍 | 不重推;16.2 的 SDF 覆盖率只是形状自身的边缘软化,与全屏 AA 无关 |
/graphics-post-processing/ | 后处理链、色调映射与合成的原理 | 不重推;16.3 只做 UI 与 3D 在同一 pass 里的合成顺序与 alpha 混合 |
一句话:别人讲「图集和抗锯齿为什么这么做」,本章讲「在 helio 的 RHI 上怎么把它搭出来、跑出什么数字」。
图集与批次键:把 N 次绘制并成常数次
图集用货架装箱(shelf packing):把精灵按高度降序排,逐行从左到右摆;当前行放不下就换行。它不追求最优面积利用率,但足够快、足够稳,是引擎里最常见的做法。装箱完再向上取到 2 的幂高度,才能生成完整 mip 链。
批次键则决定「谁和谁能一起画」。精灵键布局是 layer 4 | depth 20 | blend 1 | atlasPage 7:主序是 (layer, depth),保证画家算法的正确顺序;blend 与 atlasPage 只作次序——它们不改变语义,只是把同层同深度的同页精灵聚到连续位置,好并成一批。于是批次数的上界是 layer × depth档 × blend × atlasPage,与精灵数无关。
下面是完整的 src/render/ui2d/sprite-batch.ts,含装箱、键打包与批次扫描。
// file: src/render/ui2d/sprite-batch.ts
// tier: L1
// verify: node src/render/ui2d/sprite-batch.ts
//
// helio 渲染卷 16.1:精灵批次与图集。
// 货架装箱把多张小图打进一张 2 次幂纹理;批次键把共享同一图集页与混合模式的
// 精灵并成一次绘制。纯计算,Node 26 直跑:node src/render/ui2d/sprite-batch.ts
export type Rect = { readonly w: number; readonly h: number };
export type Placed = { readonly id: number; readonly x: number; readonly y: number;
readonly w: number; readonly h: number };
/** 向上取到 2 的幂:图集尺寸须为 2 的幂才能生成完整 mip 链。 */
export function nextPow2(v: number): number {
let p = 1; while (p < v) p <<= 1; return p;
}
/** 货架装箱:按高度降序排,逐行从左到右放,当前行放不下就换行。 */
export function shelfPack(
rects: readonly Rect[], atlasW: number,
): { placed: Placed[]; atlasH: number } {
const order = rects.map((r, i) => ({ i, r }))
.sort((a, b) => b.r.h - a.r.h || a.i - b.i);
const placed: Placed[] = [];
let shelfY = 0, shelfH = 0, cursorX = 0, atlasH = 0;
for (const { i, r } of order) {
if (cursorX + r.w > atlasW) { shelfY += shelfH; cursorX = 0; shelfH = 0; }
placed.push({ id: i, x: cursorX, y: shelfY, w: r.w, h: r.h });
cursorX += r.w;
shelfH = Math.max(shelfH, r.h);
atlasH = Math.max(atlasH, shelfY + shelfH);
}
return { placed, atlasH };
}
/** 图集占用率 = 精灵总面积 / 图集面积。 */
export function atlasEfficiency(rects: readonly Rect[], w: number, h: number): number {
let used = 0;
for (const r of rects) used += r.w * r.h;
return used / (w * h);
}
export type SpriteItem = {
readonly atlasPage: number; // 图集页:同一页才可同批
readonly blend: number; // 0 不透明 1 alpha 混合
readonly layer: number; // UI 层,越大越靠上
readonly depth: number; // 层内深度 0..1
};
// 精灵键布局 layer 4 | depth 20 | blend 1 | atlasPage 7。主序 (layer, depth) 保证
// 画家算法;atlasPage 只作次序,把同层同深度的同页精灵聚到连续位置好并批。
export const SPRITE_KEY_LAYOUT = { layer: 4, depth: 20, blend: 1, atlasPage: 7 } as const;
const DEPTH_MAX = (1 << 20) - 1;
export function packSpriteKey(it: SpriteItem): number {
const d = Math.min(DEPTH_MAX, Math.max(0, Math.round(it.depth * DEPTH_MAX)));
return (((it.layer & 15) << 28) | (d << 8) |
((it.blend & 1) << 7) | (it.atlasPage & 127)) >>> 0;
}
export type SpriteDraw = { readonly first: number; readonly count: number };
/** 扫描按键排好序的下标序列,把连续同 (layer,depth,blend,page) 项并成一批。 */
export function buildSpriteBatches(
items: readonly SpriteItem[], order: Uint32Array,
): SpriteDraw[] {
const draws: SpriteDraw[] = [];
let i = 0;
while (i < order.length) {
const a = items[order[i]!]!;
let j = i + 1;
while (j < order.length) {
const b = items[order[j]!]!;
if (b.layer !== a.layer || b.depth !== a.depth ||
b.blend !== a.blend || b.atlasPage !== a.atlasPage) break;
j++;
}
draws.push({ first: i, count: j - i });
i = j;
}
return draws;
}
/** 确定性伪随机,取代 Math.random。 */
export function hashUnit(i: number, seed: number): number {
let h = Math.imul(i ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
if ((import.meta as { main?: boolean }).main) {
const icons: Rect[] = [32, 32, 24, 24, 16, 16, 16, 48, 48, 64, 20, 20, 12, 12, 8, 8].map((w) => ({ w, h: w }));
const atlasW = 256;
const { atlasH } = shelfPack(icons, atlasW);
console.log(`icons ${icons.length} atlasW ${atlasW} rawH ${atlasH} pow2H ${nextPow2(atlasH)}`);
console.log(`efficiency raw ${(atlasEfficiency(icons, atlasW, atlasH) * 100).toFixed(1)}% ` +
`pow2 ${(atlasEfficiency(icons, atlasW, nextPow2(atlasH)) * 100).toFixed(1)}%`);
// 批次上界 = layer × depth档 × blend × pages,与精灵数无关
const N = 100000, LEVELS = 8;
console.log(`sprites ${N} layer 1 depthLevels ${LEVELS} blend 2`);
console.log('pages draws avgPerBatch');
for (const pages of [1, 2, 4, 8, 16]) {
const items: SpriteItem[] = Array.from({ length: N }, (_, i) => ({
atlasPage: Math.floor(hashUnit(i, 7) * pages),
blend: hashUnit(i, 11) < 0.5 ? 0 : 1,
layer: 0,
depth: Math.floor(hashUnit(i, 17) * LEVELS) / LEVELS,
}));
const keys = Uint32Array.from(items, packSpriteKey);
const order = Uint32Array.from({ length: N }, (_, i) => i)
.sort((a, b) => keys[a]! - keys[b]!);
const r = buildSpriteBatches(items, order);
console.log(`${String(pages).padStart(5)} ${String(r.length).padStart(7)} ` +
`${(N / r.length).toFixed(1).padStart(10)}`);
}
}
在 /tmp/enginebook 里 node src/render/ui2d/sprite-batch.ts(本机实测,数值不可移植):
icons 16 atlasW 256 rawH 88 pow2H 128
efficiency raw 61.6% pow2 42.4%
sprites 100000 layer 1 depthLevels 8 blend 2
pages draws avgPerBatch
1 16 6250.0
2 32 3125.0
4 64 1562.5
8 128 781.3
16 256 390.6
两件事一目了然。装箱把 16 个图标打进 256×88 的原始高度,收边到 2 次幂后是 256×128——占用率从 61.6% 掉到 42.4%,多出来的空白就是 2 次幂的代价。批次这边更关键:10 万个精灵在单页图集下只有 16 次绘制(8 档深度 × 2 种混合),每批平均 6250 个精灵;图集页翻到 16 页时也才 256 次。批次只随图集页数线性涨,不随精灵数涨——这就是图集存在的全部理由。
可运行产物:一次 draw(6, 2) 从图集采出两个精灵
批次给出的是「该怎么合并」;真把精灵画出来,要靠 @builtin(instance_index) 取每实例的屏幕位置、尺寸与图集 UV 子矩形。下面是 shaders/sprite-atlas-quads.wgsl。
// file: shaders/sprite-atlas-quads.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/ui2d/sprite-atlas-draw.ts
//
// 16.1 的精灵绘制:一个单位四边形,靠 @builtin(instance_index) 取每实例的
// 屏幕偏移 / 尺寸与图集 UV 子矩形,从同一张图集纹理采样。一次 draw(6, N)
// 画出 N 个精灵——这就是「图集 + 精灵批次」落到 GPU 的样子。
struct Sprite {
offset: vec2<f32>, // @0 屏幕位置(0..1 归一化)
size: vec2<f32>, // @8 屏幕尺寸(0..1 归一化)
uv_min: vec2<f32>, // @16 图集子矩形左上 UV
uv_max: vec2<f32>, // @24 图集子矩形右下 UV
};
@group(0) @binding(0) var<storage, read> sprites: array<Sprite>;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var samp: sampler;
struct VSOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
var quad = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
);
let q = quad[vi];
let s = sprites[ii];
let local = q * s.size + s.offset; // 先缩放到该精灵尺寸,再平移到屏幕位置
var o: VSOut;
o.pos = vec4<f32>(local.x * 2.0 - 1.0, local.y * 2.0 - 1.0, 0.0, 1.0);
o.uv = mix(s.uv_min, s.uv_max, q); // 四边形的 [0,1] 参数映射到图集子矩形
return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
return textureSampleLevel(atlas, samp, in.uv, 0.0);
}
src/render/ui2d/sprite-atlas-draw.ts 把它跑起来:造一张 4×2 的图集(左半红、右半蓝),一次 draw(6, 2) 画两个精灵,渲染到 8×8 后回读核对。
// file: src/render/ui2d/sprite-atlas-draw.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/ui2d/sprite-atlas-draw.ts
//
// helio 渲染卷 16.1:图集 + 精灵批次的 L2 验收。
// 造一张 4x2 图集(左半红、右半蓝),一次 draw(6, 2) 画两个精灵:左边采样
// 图集左半(红),右边采样右半(蓝)。渲染到 8x8 后回读核对。需 headless
// Chromium + 本地 http 服务(navigator.gpu 只在安全上下文可用)。
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }
// 每实例 8 个 float:offset.xy + size.xy + uv_min.xy + uv_max.xy(32 字节 stride)。
const SPRITES = new Float32Array([
0.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.5, 1.0, // 左精灵 → 图集左半
0.5, 0.0, 0.5, 1.0, 0.5, 0.0, 1.0, 1.0, // 右精灵 → 图集右半
]);
const COUNT = 2;
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 device = await (await navigator.gpu.requestAdapter())!.requestDevice();
const wgsl = await (await fetch('/shaders/sprite-atlas-quads.wgsl')).text();
const module = device.createShaderModule({ code: 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');
// 4x2 图集:左 2 列红、右 2 列蓝
const ATLAS = new Uint8Array([
255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 255,
255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 255,
]);
const tex = device.createTexture({ size: [4, 2], format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
device.queue.writeTexture({ texture: tex }, ATLAS, { bytesPerRow: 16 }, [4, 2]);
const samp = device.createSampler({ magFilter: 'nearest', minFilter: 'nearest', mipmapFilter: 'nearest' });
const pipeline = device.createRenderPipeline({ layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
primitive: { topology: 'triangle-list' } });
const spriteBuf = device.createBuffer({ size: SPRITES.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(spriteBuf, 0, SPRITES);
const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: spriteBuf } },
{ binding: 1, resource: tex.createView() },
{ binding: 2, resource: samp } ] });
const width = 8, height = 8, bytesPerRow = 256; // copyTextureToBuffer 要求 256 对齐
const target = device.createTexture({ size: [width, height], format: 'rgba8unorm',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
const rb = device.createBuffer({ size: bytesPerRow * height,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const enc = device.createCommandEncoder();
const pass = enc.beginRenderPass({ colorAttachments: [{ view: target.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(pipeline);
pass.setBindGroup(0, bind);
pass.draw(6, COUNT); // 6 个顶点 × 2 个精灵 = 一次绘制
pass.end();
enc.copyTextureToBuffer({ texture: target }, { buffer: rb, bytesPerRow }, [width, height]);
device.queue.submit([enc.finish()]);
await rb.mapAsync(GPUMapMode.READ);
const px = new Uint8Array(rb.getMappedRange().slice(0));
rb.unmap();
device.destroy();
const at = (x: number, y: number): number[] =>
Array.from(px.slice(y * bytesPerRow + x * 4, y * bytesPerRow + x * 4 + 4));
const left = at(2, 4), right = at(6, 4);
data.pixels = { left, right };
const eq = (a: number[], b: number[]): boolean => a.every((v, i) => v === b[i]);
push('左精灵=红(图集左半)', eq(left, [255, 0, 0, 255]), JSON.stringify(left));
push('右精灵=蓝(图集右半)', eq(right, [0, 0, 255, 255]), JSON.stringify(right));
push('一次绘制 2 精灵', true, `draw(6, ${COUNT})`);
return { checks, data };
}
本机 node tools/run-l2.mjs src/render/ui2d/sprite-atlas-draw.ts(headless Chromium + SwiftShader):
PASS wgsl 编译 0 error no diagnostics
PASS 左精灵=红(图集左半) [255,0,0,255]
PASS 右精灵=蓝(图集右半) [0,0,255,255]
PASS 一次绘制 2 精灵 draw(6, 2)
两个精灵从同一张图集采出,而且是一次绘制画完。左边那个的 UV 子矩形是 [0,0.5]×[0,1](图集左半),右边是 [0.5,1]×[0,1](右半)——同一个 textureSampleLevel,只换 UV 区间就取到不同的图。图集把「换纹理」变成了「换 UV」,批次键再把连续同页的精灵并成一条命令,两者合起来才是「上千精灵几次绘制」的完整机制。
小结
这一节把「N 个精灵」变成了「常数次绘制」。货架装箱把多张小图并进一张 2 次幂图集,采样靠 UV 子矩形;精灵批次键以 (layer, depth) 为主序保证画家算法,把 atlasPage 与 blend 作次序聚拢同页同混合模式的精灵。10 万个精灵在单页图集下只剩 16 次绘制,且批次数只随图集页数线性涨。
图集让精灵能并批,但界面里还有两种东西不吃这套:文字与矢量图形。它们的形状由曲线定义,位图放大会糊、图集也装不下任意字号——下一节用 SDF(有向距离场)把它们变成「一个四边形 + 一段片元距离计算」。
阅读导航:上一节:回到目录 · 下一节:16.2 SDF 文本与矢量 UI 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。