《游戏引擎实现原理》18.1 渲染器装配与场景接入

前十七章把 RHI、资源、场景、剔除、排序、光照、帧图、后处理、2D 与多线程各自做成了零件。本节是综合章的第一节,把这些零件装配成一条「纯数据场景 → 帧计划」的流水线,并在真实 WebGPU 设备上把这份帧计划画出来、回读像素,验证整条链路能跑出真实结果。

18.1 渲染器装配与场景接入

到第 17 章为止,helio 的渲染器已经有了全套零件:RHI 与三后端、资源与管线、场景图与相机、剔除与批次、材质与着色器变体、光照与阴影、帧图、后处理、2D/UI、多线程录制。但它们大多是各自可跑的孤立模块,谁也没有把谁串起来。综合章要做的第一件事,就是给这些零件定一条装配线:输入是纯数据场景,输出是一份可以直接提交的帧计划(frame plan)。

这一节不讲新算法,只讲接口:场景进、帧计划出。这也是整卷从「一堆能跑的模块」变成「一个能跑的渲染器」的那一步。

本章与站内既有内容的分工

综合章最容易写成一篇「渲染管线全景综述」,先划清边界:

既有文章它负责什么本节只做什么
/graphics-gpu-profiling-debugging/各类 GPU 计时、抓帧工具的通用原理只把 helio 的装配流水线接到 GPU 时间戳上(见 18.3)
/game-engine-architecture-ecs/引擎分层的通用架构与 ECS 数据布局只做「场景纯数据 → 渲染器帧计划」这一段接口
/posts/books/引擎内核/卷一 helio 内核:调度、内存、作业系统渲染器只消费它,不重复实现内核

一句话:别人讲「渲染管线由哪些阶段组成」,本章讲「在 helio 上怎么把它们接起来、跑出什么」。

装配流水线:从纯数据场景到帧计划

装配线把前三章的成果首尾相接:先用第 7 章的变换 把实例摆到世界空间,再用第 8 章的视锥剔除 丢掉看不见的,接着用第 9 章的排序键 与实例化合批 把剩下的排好序、并成批。产物 AssembledFrame 只含两样东西:画什么(可见实例列表)与按什么顺序画(排序后的下标),这正是第 14 章帧图 编译前的输入。

关键设计:装配层是纯计算,不碰任何 GPU 对象。它读的是 SceneInstance(纯数据),产出的是数组与整数键——所以它能在 Node 里直接跑、直接测。

// file: src/render/app/renderer-assembly.ts
// tier: L1
// verify: npx vitest run src/render/app/renderer-assembly.test.ts
//
// helio 渲染卷 18.1:渲染器装配与场景接入。把第 7 章(变换)→ 第 8 章(视锥
// 剔除)→ 第 9 章(排序 / 合批)串成一条「纯数据场景 → 帧计划」的装配流水线。
// 纯计算(L1),Node 26 直跑。

import { extractFrustumPlanes, aabbInFrustum, type Aabb, type Frustum } from '../culling/frustum.ts';
import { packKey, radixSortKeys, SortMode, type RenderItem } from '../sort/sort-key.ts';

export type Vec3 = readonly [number, number, number];

/** 场景里一个可渲染实例:纯数据,不含任何 GPU 概念。 */
export interface SceneInstance {
  readonly id: number;
  readonly meshId: number;
  readonly materialId: number;
  readonly position: Vec3;
  readonly scale: number;
  readonly alpha: boolean; // true 走透明通道(后画、远→近)
  readonly visible: boolean; // 应用层可见性开关
}

/** 实例的世界空间包围盒:以 position 为中心、半边长 = scale 的立方体。 */
export function instanceBounds(inst: SceneInstance): Aabb {
  const s = inst.scale;
  const [x, y, z] = inst.position;
  return { min: [x - s, y - s, z - s], max: [x + s, y + s, z + s] };
}

export interface FrameStats {
  readonly total: number;
  readonly culled: number;
  readonly drawn: number;
  readonly opaque: number;
  readonly transparent: number;
  readonly batches: number;
}

export interface AssembledFrame {
  /** 绘制顺序:元素是「可见实例数组 visible 里的下标」,已按键非降序。 */
  readonly order: Uint32Array;
  /** 每个可见实例的 32 位排序键(与 visible 同序)。 */
  readonly keys: Uint32Array;
  readonly visible: readonly SceneInstance[];
  readonly stats: FrameStats;
}

/** 装配一帧:剔除 → 视深度归一化 → 打包排序键 → 基数排序 → 统计批次。 */
export function assembleFrame(
  instances: readonly SceneInstance[], viewProj: readonly number[], cameraPos: Vec3, far: number,
): AssembledFrame {
  const frustum: Frustum = extractFrustumPlanes(viewProj);
  const visible: SceneInstance[] = [];
  let culled = 0;
  for (const inst of instances) {
    if (!inst.visible || aabbInFrustum(instanceBounds(inst), frustum) === 'outside') { culled++; continue; }
    visible.push(inst);
  }
  const n = visible.length;
  const keys = new Uint32Array(n);
  const items: RenderItem[] = new Array(n);
  for (let i = 0; i < n; i++) {
    const inst = visible[i]!;
    const dx = inst.position[0] - cameraPos[0];
    const dy = inst.position[1] - cameraPos[1];
    const dz = inst.position[2] - cameraPos[2];
    const depth = Math.min(1, Math.max(0, Math.hypot(dx, dy, dz) / far));
    const item: RenderItem = {
      pipelineId: inst.meshId & 255, materialId: inst.materialId & 255, meshId: inst.meshId & 255,
      mode: inst.alpha ? SortMode.transparent : SortMode.opaque, depth,
    };
    items[i] = item;
    keys[i] = packKey(item);
  }
  const order = radixSortKeys(keys, n);
  let opaque = 0, transparent = 0, batches = 0, lastHigh = -1;
  for (let i = 0; i < n; i++) {
    if (items[order[i]!]!.mode === SortMode.transparent) transparent++; else opaque++;
    const high = keys[order[i]!]! >>> 14; // mode | pipeline | material 决定「能否同一批」
    if (high !== lastHigh) { batches++; lastHigh = high; }
  }
  return { order, keys, visible, stats: { total: instances.length, culled, drawn: n, opaque, transparent, batches } };
}

它复用的全是前几章已经跑过的函数:extractFrustumPlanes / aabbInFrustum 来自第 8 章,packKey / radixSortKeys 来自第 9 章。装配层自己一行渲染算法都没写,只是把它们按正确顺序接起来。配套单测验证四件事:视锥外与应用层隐藏的实例被剔除、可见实例按键非降序、透明通道排在所有不透明之后、两次装配逐位一致。

可运行产物:把装配出的帧计划画到 GPU 上

装配产出的 AssembledFrame 能不能真的变成像素?下面这块 WGSL 是「帧计划」的消费者:每条实例记录含中心 xz、半边长与颜色,一次 draw(6, N) 把整份绘制列表画成俯视方块。世界 xz 经正交映射到裁剪空间(clip = center / extent),与 18.1 装配时用的正交视锥是同一套坐标。

// file: shaders/scene-assembly.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/app/scene-assembly-l2.ts
//
// helio 渲染卷 18.1:把装配流水线产出的可见实例列表画成俯视方块。
// 每实例一条记录(中心 xz、半边长、颜色),一次 draw(6, N) 全部画出。
// 世界 xz 经正交映射到裁剪空间:clip = center / extent。
struct Instance {
  center: vec2<f32>, // @0 世界 xz
  half: f32,         // @8 半边长(世界单位)
  _pad: f32,         // @12 补齐 16 字节
  color: vec4<f32>,  // @16
};

@group(0) @binding(0) var<storage, read> instances: array<Instance>;

struct Uniforms { extent: f32 };
@group(0) @binding(1) var<uniform> u: Uniforms;

struct VSOut {
  @builtin(position) pos: vec4<f32>,
  @location(0) color: vec4<f32>,
};

@vertex
fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
  var quad = array<vec2<f32>, 6>(
    vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(-1.0, 1.0),
    vec2<f32>(-1.0, 1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
  );
  let inst = instances[ii];
  let c = inst.center / u.extent;
  let h = inst.half / u.extent;
  var o: VSOut;
  o.pos = vec4<f32>(c + quad[vi] * h, 0.0, 1.0);
  o.color = inst.color;
  return o;
}

@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
  return in.color;
}

L2 验收脚本造一个 5×5 的《微光岛》俯视网格场景(外加一个远在正交盒外的实例),把它交给 assembleFrame,再按装配出的绘制顺序打包实例记录、上传、绘制、回读。每个可见实例的中心像素必须等于它的颜色;远实例必须根本不在绘制列表里。

// file: src/render/app/scene-assembly-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/app/scene-assembly-l2.ts
//
// helio 渲染卷 18.1:在真实 WebGPU 设备上验收装配流水线。造一个俯视的
// 《微光岛》网格场景,交给 assembleFrame 剔除 + 排序,把产出的可见实例列表
// 上传、一次实例化绘制,再回读像素:每个可见实例的中心像素必须等于它的颜色;
// 一个远在正交盒外的实例必须被剔除。需在浏览器中经本地 http 服务运行,命令行不可复现。

import { assembleFrame, type SceneInstance } from './renderer-assembly.ts';

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

const W = 64;
const H = 64;
const EXTENT = 6; // 正交盒半边长:世界 x,z ∈ [-6, 6] 映射到整幅画布
const HALF = 0.8;
const FAR = 100;

/** 俯视正交投影:clip = (x/extent, z/extent),深度取 y。列主序 4x4。 */
const VIEW_PROJ = [1 / EXTENT, 0, 0, 0, 0, 0, -1 / 20, 0, 0, 1 / EXTENT, 0, 0, 0, 0, 1, 1];

/** 确定性场景:5×5 网格(间距 2)+ 一个远在正交盒外的实例。 */
function scene(): SceneInstance[] {
  const out: SceneInstance[] = [];
  let id = 0;
  for (let r = -2; r <= 2; r++) {
    for (let c = -2; c <= 2; c++) {
      out.push({ id: id++, meshId: 0, materialId: 0, position: [c * 2, 0, r * 2], scale: HALF, alpha: false, visible: true });
    }
  }
  out.push({ id, meshId: 0, materialId: 0, position: [40, 0, 0], scale: HALF, alpha: false, visible: true });
  return out;
}

/** 每个实例的颜色由网格坐标决定,落在 [0.1, 0.9]。 */
function colorOf(x: number, z: number): number[] {
  return [(x / 4 + 1) * 0.4 + 0.1, (z / 4 + 1) * 0.4 + 0.1, 0.7, 1];
}

/** 世界 xz → 像素 (col, row),与着色器的 clip = center/extent 一一对应。 */
function worldToPixel(x: number, z: number): [number, number] {
  return [Math.round((x / EXTENT * 0.5 + 0.5) * W - 0.5), Math.round((0.5 - z / EXTENT * 0.5) * H - 0.5)];
}

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

  const wgsl = await (await fetch('/shaders/scene-assembly.wgsl')).text();
  const module = device.createShaderModule({ code: wgsl });
  const info = await module.getCompilationInfo();
  const errors = info.messages.filter((m) => m.type === 'error').map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
  data.wgslErrors = errors;
  push('wgsl 0 error', errors.length === 0, errors.length);

  const frame = assembleFrame(scene(), VIEW_PROJ, [0, 20, 0], FAR);
  data.stats = frame.stats;
  push('绘制 25 个可见实例', frame.stats.drawn === 25, `drawn=${frame.stats.drawn}`);
  push('剔除正交盒外 1 个实例', frame.stats.culled === 1, `culled=${frame.stats.culled}`);

  // 按装配出的绘制顺序打包实例记录:center(8) + half(4) + pad(4) + color(16) = 32B
  const rec = new Float32Array(frame.order.length * 8);
  frame.order.forEach((srcIdx, slot) => {
    const inst = frame.visible[srcIdx]!;
    const o = slot * 8;
    rec.set([inst.position[0], inst.position[2], inst.scale, 0], o);
    rec.set(colorOf(inst.position[0], inst.position[2]), o + 4);
  });

  const sBuf = device.createBuffer({ size: rec.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(sBuf, 0, rec);
  const uBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(uBuf, 0, new Float32Array([EXTENT, 0, 0, 0]));

  const fmt: GPUTextureFormat = 'rgba8unorm';
  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', targets: [{ format: fmt }] },
    primitive: { topology: 'triangle-list' },
  });
  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: sBuf } }, { binding: 1, resource: { buffer: uBuf } }],
  });
  const target = device.createTexture({ size: [W, H], format: fmt, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
  const bpr = 256; // W*4 = 256,正好是 256 的倍数
  const readback = device.createBuffer({ size: bpr * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  const enc = device.createCommandEncoder();
  const pass = enc.beginRenderPass({
    colorAttachments: [{ view: target.createView(), clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.draw(6, frame.order.length);
  pass.end();
  enc.copyTextureToBuffer({ texture: target }, { buffer: readback, bytesPerRow: bpr }, [W, H]);
  device.queue.submit([enc.finish()]);
  await readback.mapAsync(GPUMapMode.READ);
  const px = new Uint8Array(readback.getMappedRange().slice(0));
  readback.unmap();

  const to8 = (x: number): number => Math.round(Math.min(1, Math.max(0, x)) * 255);
  let maxDiff = 0;
  for (const inst of frame.visible) {
    const [col, row] = worldToPixel(inst.position[0], inst.position[2]);
    const off = row * bpr + col * 4;
    const want = colorOf(inst.position[0], inst.position[2]).slice(0, 3).map(to8);
    for (let k = 0; k < 3; k++) maxDiff = Math.max(maxDiff, Math.abs(px[off + k]! - want[k]!));
  }
  data.maxDiff = maxDiff;
  push('每个可见实例中心像素 = 其颜色(±3)', maxDiff <= 3, `maxDiff=${maxDiff}`);
  const ids = new Set(frame.visible.map((v) => v.id));
  push('远实例 25 不在绘制列表', !ids.has(25), `visibleIds=${frame.visible.length}`);

  device.destroy();
  return { checks, data };
}

在 headless Chromium(本地 http 服务、带 --enable-unsafe-webgpu)里跑,本机实测:

PASS  wgsl 0 error  0
PASS  绘制 25 个可见实例  drawn=25
PASS  剔除正交盒外 1 个实例  culled=1
PASS  每个可见实例中心像素 = 其颜色(±3)  maxDiff=1
PASS  远实例 25 不在绘制列表  visibleIds=25

装配流水线统计出 26 个实例里 25 个可见、1 个被剔除,合并成 1 个批次(同材质同网格)。25 个可见实例的中心像素与各自颜色最大只差 1/255(差在 rgba8unorm 的舍入),说明「场景 → 帧计划 → 像素」这条链路是通的:装配层算出的每一格,GPU 都照原样画了出来。

(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/app/scene-assembly-l2.ts 需在浏览器中运行,命令行不可复现。)

场景能画出来了,但「什么时候画、光从哪来」还没定。下一节把一天的时辰映射成一组光照参数,让《微光岛》有昼夜。

阅读导航:上一节:回到目录 · 下一节:18.2 昼夜循环与光照表现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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