16.3 UI 与 3D 的合成顺序
16.1 画出了精灵,16.2 画出了矢量图形,但它们还只是「能画」,没回答「怎么排」。一帧里同时有 3D 世界和 UI:远处的山、近处的人物、半透明的玻璃、屏幕上层的血条与按钮。谁先画谁后画,直接决定画面对不对——半透明的玻璃画早了会被后面的东西盖住,UI 画早了会被 3D 遮住。
这一节把一帧的绘制定成三段固定顺序,再把它们收进一个 render pass。合成顺序是引擎里最容易被忽视、又最容易出 bug 的地方:顺序错一帧,画面就穿帮。
本节产出一个 L1 的
src/render/ui2d/composite-order.ts(Node 直跑)与一个 L2 的src/render/ui2d/ui-3d-composite-draw.ts+shaders/ui-composite.wgsl(headless Chromium 实跑)。文中所有排序统计与像素都来自本机实跑。
三段合成顺序:不透明、透明、UI
一帧的绘制分成三段,段界由 stage 决定,段内规则各不相同:
- 3D 不透明:按深度升序(近→远)。近的物体先写深度,远的被深度测试挡掉,省下大量片元着色——这就是 early-z。
- 3D 透明:按深度降序(远→近)。透明要混合,必须先画后面的,否则前一层会把后一层挡住,混合结果就错了。这是画家算法。
- UI:按
(layer, depth)升序。UI 永远压在所有 3D 之上,层内再按画家算法从下往上叠。
三段之间不能交错——一旦透明段里插进一个不透明物体,early-z 的收益就没了。所以比较器先比 stage,再比段内规则。下面是完整的 src/render/ui2d/composite-order.ts。
// file: src/render/ui2d/composite-order.ts
// tier: L1
// verify: node src/render/ui2d/composite-order.ts
//
// helio 渲染卷 16.3:UI 与 3D 的合成顺序。
// 一帧的绘制分三段:3D 不透明(近→远,靠深度缓冲)、3D 透明(远→近,画家
// 算法)、UI(按层再按深度,永远压在 3D 之上)。纯计算,Node 26 直跑。
export const QueueStage = { opaque3d: 0, transparent3d: 1, ui: 2 } as const;
export type QueueStage = (typeof QueueStage)[keyof typeof QueueStage];
export type CompositeItem = {
readonly stage: number; // QueueStage
readonly depth: number; // 0 近 1 远(相机空间归一化深度)
readonly layer: number; // UI 层,越大越靠上;仅 stage=ui 有意义
readonly pipelineId: number; // 用于统计管线切换
};
/**
* 合成排序比较器。段界由 stage 决定,段内规则各异:不透明按深度升序
* (近→远,早写深度好做 early-z),透明按深度降序(远→近,先画后面才能
* 正确混合),UI 按 (layer, depth) 升序(画家算法,上层最后画)。
*/
export function compareComposite(a: CompositeItem, b: CompositeItem): number {
if (a.stage !== b.stage) return a.stage - b.stage;
if (a.stage === QueueStage.opaque3d) return a.depth - b.depth;
if (a.stage === QueueStage.transparent3d) return b.depth - a.depth;
if (a.layer !== b.layer) return a.layer - b.layer;
return a.depth - b.depth;
}
/** 合成顺序不变量:段不能回退,段内规则不能违反。 */
export function assertCompositeOrder(items: readonly CompositeItem[]): boolean {
for (let i = 1; i < items.length; i++) {
const p = items[i - 1]!, c = items[i]!;
if (c.stage < p.stage) return false;
if (c.stage === p.stage && c.stage === QueueStage.opaque3d && c.depth < p.depth) return false;
if (c.stage === p.stage && c.stage === QueueStage.transparent3d && c.depth > p.depth) return false;
if (c.stage === p.stage && c.stage === QueueStage.ui) {
if (c.layer < p.layer || (c.layer === p.layer && c.depth < p.depth)) return false;
}
}
return true;
}
/** 统计:每段的项数、管线切换次数、段切换次数。 */
export function summarizeOrder(items: readonly CompositeItem[]):
{ opaque: number; transparent: number; ui: number; pipelineSwitches: number; stageSwitches: number } {
let opaque = 0, transparent = 0, ui = 0, pipelineSwitches = 0, stageSwitches = 0;
for (let i = 0; i < items.length; i++) {
const it = items[i]!;
if (it.stage === QueueStage.opaque3d) opaque++;
else if (it.stage === QueueStage.transparent3d) transparent++;
else ui++;
if (i > 0) {
const prev = items[i - 1]!;
if (it.pipelineId !== prev.pipelineId) pipelineSwitches++;
if (it.stage !== prev.stage) stageSwitches++;
}
}
return { opaque, transparent, ui, pipelineSwitches, stageSwitches };
}
/** 确定性伪随机,取代 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 items: CompositeItem[] = [];
const push = (stage: number, n: number, seed: number, layers: number): void => {
for (let i = 0; i < n; i++) items.push({
stage, depth: hashUnit(i, seed), layer: Math.floor(hashUnit(i, seed + 1) * layers),
pipelineId: Math.floor(hashUnit(i, seed + 2) * 8),
});
};
push(QueueStage.opaque3d, 300, 1, 1);
push(QueueStage.transparent3d, 200, 3, 1);
push(QueueStage.ui, 120, 5, 4);
const sorted = items.slice().sort(compareComposite);
const s = summarizeOrder(sorted);
console.log(`items ${items.length} order-valid ${assertCompositeOrder(sorted)}`);
console.log(`opaque ${s.opaque} transparent ${s.transparent} ui ${s.ui}`);
console.log(`pipelineSwitches ${s.pipelineSwitches} stageSwitches ${s.stageSwitches}`);
// 反例:把首个 UI 项挪到最前,不变量应被打破
const bad = sorted.slice();
bad.unshift(bad.splice(sorted.findIndex((x) => x.stage === QueueStage.ui), 1)[0]!);
console.log(`counter-example (UI 提前) order-valid ${assertCompositeOrder(bad)}`);
}
在 /tmp/enginebook 里 node src/render/ui2d/composite-order.ts(本机实测,数值不可移植):
items 620 order-valid true
opaque 300 transparent 200 ui 120
pipelineSwitches 543 stageSwitches 2
counter-example (UI 提前) order-valid false
620 个绘制项排完序后 order-valid true——三段顺序与段内规则全部满足,且整帧只发生 2 次段切换(不透明→透明、透明→UI),正是三段结构。把第一个 UI 项强行挪到队首,不变量立刻被打破(order-valid false),这就是排序器的守门作用。顺带看 pipelineSwitches 543:合成排序优化的是正确性(混合顺序),不是管线切换——那要靠 9.2 的状态缓存,两者是不同维度的事。
可运行产物:一个 pass 里先 3D 后 UI
顺序落到代码,就是在同一个 render pass 里按序发绘制命令,靠混合状态区分「不透明覆盖」与「alpha 混合」。下面是 shaders/ui-composite.wgsl:一个全屏三角形,颜色来自 uniform,同一条管线既可以当 3D 底色、也可以当 UI。
// file: shaders/ui-composite.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/ui2d/ui-3d-composite-draw.ts
//
// 16.3 的 UI 与 3D 合成:同一个全屏三角形 + 同一份片元,靠两条管线区分
// 「3D 不透明」与「UI 混合」。3D 先画(写满屏幕),UI 再用 alpha 混合叠上,
// 只覆盖左半(靠 setScissorRect 限域)。这演示了合成顺序与 alpha 混合的叠加。
struct Params {
color: vec4<f32>, // 该次绘制的常量色(含 alpha)
};
@group(0) @binding(0) var<uniform> params: Params;
struct VSOut {
@builtin(position) pos: vec4<f32>,
};
@vertex
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0),
);
var o: VSOut;
o.pos = vec4<f32>(p[i], 0.0, 1.0);
return o;
}
@fragment
fn fs() -> @location(0) vec4<f32> {
return params.color;
}
src/render/ui2d/ui-3d-composite-draw.ts 用它跑两段:先画 3D 全屏绿底(不透明管线),再画 UI 左半屏红(alpha 0.5 混合管线,setScissorRect 限域),回读左右两处像素。
// file: src/render/ui2d/ui-3d-composite-draw.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/ui2d/ui-3d-composite-draw.ts
//
// helio 渲染卷 16.3:UI 与 3D 合成的 L2 验收。
// 同一个 render pass 里:先画 3D 全屏四边形(绿、不透明),再画 UI 左半屏
// (红、alpha 0.5、开混合),两次绘制用不同管线。回读像素:左半应是绿底叠
// 红(0.5 红 + 0.5 绿 = 灰黄),右半只有绿。需 headless Chromium + 本地 http。
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }
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/ui-composite.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');
const fmt = 'rgba8unorm' as const;
const blend: GPUBlendState = {
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
alpha: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
};
const mkPipe = (withBlend: boolean): GPURenderPipeline => device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs',
targets: [withBlend ? { format: fmt, blend } : { format: fmt }] },
primitive: { topology: 'triangle-list' } });
const pipeOpaque = mkPipe(false);
const pipeBlend = mkPipe(true);
const mkColor = (r: number, g: number, b: number, a: number): GPUBuffer => {
const buf = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(buf, 0, new Float32Array([r, g, b, a]));
return buf;
};
const bgGreen = device.createBindGroup({ layout: pipeOpaque.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: mkColor(0, 1, 0, 1) } }] });
const bgRed = device.createBindGroup({ layout: pipeBlend.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: mkColor(1, 0, 0, 0.5) } }] });
const width = 8, height = 8, bytesPerRow = 256;
const target = device.createTexture({ size: [width, height], format: fmt,
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(pipeOpaque); // 第一段:3D 不透明,铺满目标
pass.setBindGroup(0, bgGreen);
pass.draw(3);
pass.setPipeline(pipeBlend); // 第二段:UI,scissor 到左半,开混合叠上
pass.setBindGroup(0, bgRed);
pass.setScissorRect(0, 0, 4, height);
pass.draw(3);
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 near = (a: number, b: number, tol = 3): boolean => Math.abs(a - b) <= tol;
push('右半=纯绿(仅 3D)', right[0] === 0 && right[1] === 255 && right[2] === 0, JSON.stringify(right));
push('左半=红0.5 叠绿', near(left[0], 128) && near(left[1], 128) && left[2] === 0, JSON.stringify(left));
push('一次 render pass 两段绘制', true, 'draw(3) 3D + draw(3) UI(blend, scissor)');
return { checks, data };
}
本机 node tools/run-l2.mjs src/render/ui2d/ui-3d-composite-draw.ts(headless Chromium + SwiftShader):
PASS wgsl 编译 0 error no diagnostics
PASS 右半=纯绿(仅 3D) [0,255,0,255]
PASS 左半=红0.5 叠绿 [128,128,0,191]
PASS 一次 render pass 两段绘制 draw(3) 3D + draw(3) UI(blend, scissor)
像素把两段顺序讲清楚了。右半只经过 3D 那段,是纯绿 [0,255,0,255];左半先被 3D 铺成绿、再叠上半透明红,按 src-alpha / one-minus-src-alpha 混合得到 [128,128,0,191]——正好是 0.5×红 + 0.5×绿(127.5 四舍五入成 128),alpha 也混成 0.5×0.5 + 1×0.5 = 0.75 → 191。同一帧、同一个 render pass,先 3D 后 UI,UI 用混合管线叠上去——这就是合成顺序落到 GPU 命令上的样子。真实引擎里 UI 段通常排在色调映射之后、以 LDR 输出,避免被 HDR 的曝光影响。
小结
这一节给一帧立了规矩:3D 不透明近到远(early-z)、3D 透明远到近(画家算法)、UI 按层再按深度永远在最上。排序器先比 stage 再比段内规则,620 个绘制项排完不变量成立、只发生 2 次段切换。L2 在单个 render pass 里先画 3D 绿底、再用 alpha 混合叠上左半屏红 UI,回读出 [128,128,0,191] 的混合像素。
第 16 章到此收束:精灵并批(16.1)、矢量图形与文字(16.2)、UI 与 3D 合成(16.3)——界面这条线补齐了。但它们全都挤在渲染线程上顺序录制命令,界面一复杂就成了瓶颈。下一章转向多线程渲染:把命令录制拆到多个线程,用命令缓冲把工作并行起来。
阅读导航:上一节:16.2 SDF 文本与矢量 UI · 下一节:17.1 渲染线程模型与命令缓冲 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。