《游戏引擎实现原理》11.2 场景视口与渲染接入

编辑器视口要同时画场景、网格与选择描边,还要支持多相机切换。本节把卷二渲染器接进编辑器:用同一份实例数据分别喂给主相机与顶视相机、在片元里给选中实例描边、再单独叠一层世界网格,并在 headless Chromium 里回读像素逐条核对是否生效。

11.2 场景视口与渲染接入

11.1 解决了「点哪」,这一节解决「看到什么」。编辑器的视口不是游戏画面——它要额外画网格、把选中对象描边,还可能同时开着好几个相机(主视角、顶视图、前视图)。这些都不该让卷二渲染器长出新分支。

这一节把卷二最后装配好的渲染器接进编辑器视口,并且验证三件事:同一批实例换相机能不能正确重画、选中实例能不能描边、世界网格能不能单独叠一层。全部在 headless Chromium 里回读像素核对。

把渲染器接进视口

18.1 渲染器装配与场景 已经把渲染器定成「读场景 → 录命令 → 提交」的一层,它是唯一同时认识场景与 RHI 的角色。编辑器视口要做的事很克制:把同一份场景实例喂给它,再按当前相机渲一遍。编辑器不复制渲染逻辑,它只是换一台相机、加几条叠加通道——网格、描边、gizmo。

真正属于编辑器的差异只有两点:一是相机由用户操作而非游戏逻辑驱动(所以 viewProj 每帧都可能变);二是要画「不属于游戏世界」的叠加物(网格、gizmo、选中框)。这两点都被挡在渲染器之外,渲染器拿到的仍是一批普通实例。

视口着色器:一份 shader,两对入口

下面 shaders/editor-viewport.wgsl 层级 L2。它把场景实例与网格线放进同一个 storage,用两对入口区分:vs_scene/fs_scene 画场景,fs_scene 里对 flags.x > 0.5(选中)的实例按局部坐标画橙色描边;vs_grid/fs_grid 画世界网格。

// file: shaders/editor-viewport.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/editor/viewport/viewport-render.ts
//
// 编辑器视口着色器:一份 shader 里放两对入口。
//   vs_scene / fs_scene —— 画场景实例;instance.flags.x > 0.5 的实例在片元里
//                          按局部坐标画出选择描边(内部保持原色,边缘换描边色)。
//   vs_grid  / fs_grid  —— 画世界网格线,只是一批很薄的方片,颜色固定。
struct Camera { viewProj: mat4x4<f32>, };
@group(0) @binding(0) var<uniform> camera: Camera;

struct Instance {
  model: mat4x4<f32>,
  color: vec4<f32>,
  flags: vec4<f32>,
};
@group(0) @binding(1) var<storage, read> instances: array<Instance>;

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

var<private> QUAD: array<vec2<f32>, 6> = 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),
);

fn project(model: mat4x4<f32>, local: vec2<f32>) -> vec4<f32> {
  return camera.viewProj * (model * vec4<f32>(local.x, 0.0, local.y, 1.0));
}

@vertex
fn vs_scene(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
  let local = QUAD[vi];
  var o: VSOut;
  o.pos = project(instances[ii].model, local);
  o.color = instances[ii].color;
  o.local = local;
  o.flags = instances[ii].flags.x;
  return o;
}

@fragment
fn fs_scene(in: VSOut) -> @location(0) vec4<f32> {
  if (in.flags > 0.5) {
    // 选择描边:局部坐标的棋盘距离超过 0.7 的部分换成橙色描边。
    let d = max(abs(in.local.x), abs(in.local.y));
    if (d > 0.7) { return vec4<f32>(1.0, 0.5, 0.0, 1.0); }
  }
  return in.color;
}

@vertex
fn vs_grid(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
  let local = QUAD[vi];
  var o: VSOut;
  o.pos = project(instances[ii].model, local);
  o.color = instances[ii].color;
  o.local = local;
  o.flags = 0.0;
  return o;
}

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

描边在片元里做,而不是额外一次 pass:实例是平的方片,局部坐标 [-1,1]² 直接给出「离边缘多近」,棋盘距离超过 0.7 的部分换成描边色即可。真实引擎里更常见的是反壳法(把模型放大一圈、反面剔除)或模板缓冲,但对编辑器里的选择提示,片元描边足够且零额外 draw。

可运行产物:双相机、描边与网格的真跑

下面 src/editor/viewport/viewport-render.ts 层级 L2,命令 node tools/run-l2.mjs src/editor/viewport/viewport-render.ts——它在 headless Chromium 里把一批实例编成 storage,用主相机与顶视相机各渲一遍并回读中心像素,再把岛标记为选中统计橙色描边像素,最后单独画一层网格。

// file: src/editor/viewport/viewport-render.ts
// tier: L2
// verify: node tools/run-l2.mjs src/editor/viewport/viewport-render.ts
//
// helio 编辑器 11.2:把卷二渲染器接进编辑器视口。
// 同一个场景分别用「主相机」与「顶视相机」各渲一遍;选中实例在片元里描边;
// 世界网格单独一遍叠加。全部回读像素核对。需 headless Chromium + 本地 http 服务。

import { makeCamera, type Camera } from '../../render/scene/camera.ts';
import { type Mat4, identity, fromTranslation, fromScale, multiply } from '../../core/mat4.ts';

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

/** 每个实例的 storage 记录:model mat4(64B) + color vec4(16B) + flags vec4(16B) = 96B。 */
export const INSTANCE_FLOATS = 24;

/** 实例:世界矩阵 + 颜色 + 标志(flags.x>0.5 表示选中,要描边)。 */
export interface EditorInstance { readonly model: Mat4; readonly color: readonly number[]; readonly selected: boolean }

/** 把一批实例编成 storage 数组。 */
export function encodeInstances(list: readonly EditorInstance[]): Float32Array {
  const data = new Float32Array(list.length * INSTANCE_FLOATS);
  list.forEach((it, i) => {
    const o = i * INSTANCE_FLOATS;
    data.set(it.model, o);
    data.set(it.color, o + 16);
    data[o + 20] = it.selected ? 1 : 0;
  });
  return data;
}

/** 中心 c、半边长 s 的方片(单位四边形局部 ±1,落在 y=0 平面)。 */
export function quad(cx: number, cz: number, sx: number, sz: number): Mat4 {
  return multiply(identity(), fromTranslation(cx, 0, cz), fromScale(sx, 1, sz));
}

async function loadWgsl(path: string): Promise<string> {
  const res = await fetch(path);
  if (!res.ok) throw new Error(`fetch ${path} -> ${res.status}`);
  return res.text();
}

/** 用一条相机把实例画进 size×size 纹理并回读 RGBA8。 */
async function renderPass(
  device: GPUDevice, wgsl: string, cam: Camera, list: readonly EditorInstance[], size: number, entry: 'scene' | 'grid',
): Promise<Uint8Array> {
  const data = encodeInstances(list);
  const camBuf = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(camBuf, 0, cam.viewProj);
  const instBuf = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(instBuf, 0, data);
  const module = device.createShaderModule({ code: wgsl });
  const pipeline = device.createRenderPipeline({ layout: 'auto',
    vertex: { module, entryPoint: `vs_${entry}` },
    fragment: { module, entryPoint: `fs_${entry}`, targets: [{ format: 'rgba8unorm' }] } });
  const bg = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
    { binding: 0, resource: { buffer: camBuf } }, { binding: 1, resource: { buffer: instBuf } }] });
  const target = device.createTexture({ size: [size, size], format: 'rgba8unorm',
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
  const enc = device.createCommandEncoder();
  const pass = enc.beginRenderPass({ colorAttachments: [{ view: target.createView(), loadOp: 'clear',
    storeOp: 'store', clearValue: { r: 0.05, g: 0.05, b: 0.1, a: 1 } }] });
  pass.setPipeline(pipeline); pass.setBindGroup(0, bg); pass.draw(6, list.length); pass.end();
  const rb = device.createBuffer({ size: size * 4 * size, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  enc.copyTextureToBuffer({ texture: target }, { buffer: rb, bytesPerRow: size * 4 }, [size, size]);
  device.queue.submit([enc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const out = new Uint8Array(rb.getMappedRange().slice(0));
  rb.unmap();
  return out;
}

const BLUE = [0, 0, 1, 1];
const GREEN = [0, 1, 0, 1];
const GRID = [0.35, 0.35, 0.4, 1];
const ORANGE = [255, 128, 0, 255];

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 loadWgsl('/shaders/editor-viewport.wgsl');
  const info = await device.createShaderModule({ code: wgsl }).getCompilationInfo();
  push('wgsl.0error', info.messages.filter((m) => m.type === 'error').length === 0, 'errors=0');

  const SIZE = 128;
  const sample = (px: Uint8Array, x: number, y: number): number[] => { const o = (y * SIZE + x) * 4; return [px[o]!, px[o + 1]!, px[o + 2]!, px[o + 3]!]; };
  const near = (a: number[], b: number[], tol = 24): boolean => a.every((v, i) => Math.abs(v - b[i]!) <= tol);

  // 场景:小岛(大蓝片)+ 右礁石(小绿片)。俯视相机下两者都可见。
  const scene: EditorInstance[] = [
    { model: quad(0, 0, 2, 2), color: BLUE, selected: false },
    { model: quad(3, -2, 1, 1), color: GREEN, selected: false },
  ];
  const main = makeCamera([0, 6, 8], [0, 0, 0], [0, 1, 0], (60 * Math.PI) / 180, 1, 0.1, 100);
  const top = makeCamera([0, 10, 0], [0, 0, 0], [0, 0, -1], (60 * Math.PI) / 180, 1, 0.1, 100);

  // 1) 双相机:各自回读中心像素,都应是「岛」的蓝色。
  const mainPx = await renderPass(device, wgsl, main, scene, SIZE, 'scene');
  const topPx = await renderPass(device, wgsl, top, scene, SIZE, 'scene');
  const mainCenter = sample(mainPx, 64, 64);
  const topCenter = sample(topPx, 64, 64);
  push('主相机中心 = 岛蓝色', near(mainCenter, [0, 0, 255, 255]), JSON.stringify(mainCenter));
  push('顶视相机中心 = 岛蓝色', near(topCenter, [0, 0, 255, 255]), JSON.stringify(topCenter));

  // 2) 选择描边:把岛标记为选中,中心仍是蓝色,边缘出现橙色描边像素。
  const selScene: EditorInstance[] = [
    { model: quad(0, 0, 2, 2), color: BLUE, selected: true },
    { model: quad(3, -2, 1, 1), color: GREEN, selected: false },
  ];
  const selPx = await renderPass(device, wgsl, top, selScene, SIZE, 'scene');
  let orange = 0;
  let blue = 0;
  for (let i = 0; i < selPx.length; i += 4) {
    if (near([selPx[i]!, selPx[i + 1]!, selPx[i + 2]!, selPx[i + 3]!], ORANGE, 30)) orange++;
    else if (near([selPx[i]!, selPx[i + 1]!, selPx[i + 2]!, selPx[i + 3]!], [0, 0, 255, 255], 30)) blue++;
  }
  data.selection = { orange, blue, center: sample(selPx, 64, 64) };
  push('选中实例:中心仍是岛蓝', near(sample(selPx, 64, 64), [0, 0, 255, 255]), JSON.stringify(sample(selPx, 64, 64)));
  push('选中实例:存在橙色描边像素', orange > 0, `orange=${orange}`);
  push('选中实例:内部仍为蓝色', blue > 0, `blue=${blue}`);

  // 3) 网格叠加:只画网格线,回读应出现网格色(背景是深蓝灰)。
  const grid: EditorInstance[] = [];
  for (let z = -4; z <= 4; z += 2) grid.push({ model: quad(0, z, 6, 0.03), color: GRID, selected: false });
  for (let x = -4; x <= 4; x += 2) grid.push({ model: quad(x, 0, 0.03, 6), color: GRID, selected: false });
  const gridPx = await renderPass(device, wgsl, top, grid, SIZE, 'grid');
  let gridPixels = 0;
  let bgPixels = 0;
  for (let i = 0; i < gridPx.length; i += 4) {
    const c = [gridPx[i]!, gridPx[i + 1]!, gridPx[i + 2]!, gridPx[i + 3]!];
    if (near(c, [89, 89, 102, 255], 20)) gridPixels++;
    else if (near(c, [13, 13, 26, 255], 20)) bgPixels++;
  }
  data.grid = { gridPixels, bgPixels };
  push('网格线像素存在', gridPixels > 0, `gridPixels=${gridPixels}`);
  push('网格背景像素存在', bgPixels > 0, `bgPixels=${bgPixels}`);

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

本机真实输出(节选):

PASS  wgsl.0error  errors=0
PASS  主相机中心 = 岛蓝色  [0,0,255,255]
PASS  顶视相机中心 = 岛蓝色  [0,0,255,255]
PASS  选中实例:中心仍是岛蓝  [0,0,255,255]
PASS  选中实例:存在橙色描边像素  orange=912
PASS  选中实例:内部仍为蓝色  blue=1024
PASS  网格线像素存在  gridPixels=759
PASS  网格背景像素存在  bgPixels=15625
=== run-l2 src/editor/viewport/viewport-render.ts: ALL PASS ===

三件事被钉死了:同一批实例换相机后中心像素仍是岛的蓝色(说明 viewProj 确实换了、实例没换);选中的岛出现 912 个橙色描边像素、内部仍有 1024 个蓝色像素;网格单独一遍里既有 759 个网格色像素也有背景像素。wgsl.0error 那一行则确认着色器本身没有编译错误。

面板与多相机切换:L3,命令行不可复现

把 canvas 尺寸随面板变化、主/顶/前相机切换、requestAnimationFrame 循环接起来,是纯 DOM 胶水。src/editor/viewport/viewport-panel.ts 层级 L3——依赖 ResizeObserver、HTMLCanvasElement、requestAnimationFrame,本节需在浏览器中手动验证,命令行不可复现(仅通过 tsc 类型检查)。

它用 ResizeObserver 同步后备缓冲尺寸(乘上 devicePixelRatio),避免画面被拉伸;setView 只改一个枚举,下一帧渲染就换相机。注意这里有个易漏的点:后备缓冲要按设备像素比放大,否则在高分屏上画面是糊的——但 canvas 的 CSS 尺寸不能跟着放大,否则面板布局会被撑开。

小结

  • 编辑器视口不复制渲染逻辑:换一台相机 + 加叠加通道,场景实例数据是同一份。
  • 选择描边在片元里按局部坐标画,零额外 draw;世界网格单独一遍叠加。
  • 双相机用同一批实例各渲一遍,回读中心像素即可核对 viewProj 生效。
  • 面板尺寸同步与相机切换是 L3,需在浏览器中手动验证,命令行不可复现。

下一节离开视口,回到编辑器的另一块面板:资产浏览器怎么把磁盘上的文件摆成一棵树,关卡数据又怎么装进来。

阅读导航:上一节:11.1 gizmo 与拾取射线 · 下一节:11.3 资产浏览器与关卡编辑 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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