《游戏引擎实现原理》16.3 UI 与 3D 的合成顺序

精灵和矢量图形都能画了,但它们和 3D 世界还没有规矩:谁先画、谁压在谁上面、半透明怎么混。本节把一帧的绘制定成三段——3D 不透明近到远、3D 透明远到近、UI 永远在最上——再在同一个 render pass 里先画 3D 绿底、再用 alpha 混合叠上左半屏的红色 UI,回读出 0.5 红加 0.5 绿的真实像素。

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 渲染线程模型与命令缓冲 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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