《游戏引擎实现原理》7.3 相机、裁剪面与拾取射线

把相机拆成 view / projection / viewProj 三个矩阵,从 viewProj 免费抽出六个裁剪面,再由屏幕坐标沿相机基座构造世界空间拾取射线,最后在 headless Chromium 里点一下屏幕、验证拾取命中的正是那个实例。

7.3 相机、裁剪面与拾取射线

场景图告诉渲染器「有什么」,相机告诉它「从哪看」。相机是一个纯数学对象:产出 view、projection 和二者的乘积 viewProj。这一节把这三个矩阵、由它们抽出的六个裁剪面,以及从屏幕坐标反投影出的拾取射线写成一条可运行的链——最后在 headless Chromium 里点一下屏幕,验证拾取到的确实是那个实例。

本节产出 src/render/scene/camera.ts(L1)、src/render/scene/ray-pick.ts(L2)与着色器 shaders/camera-pick.wgsl。所有矩阵值、像素值都来自本机实跑。

view / projection / viewProj 与裁剪面

相机的三件套:

  • view(lookAt):把世界坐标系搬到以相机为原点的坐标系。视线朝 -z,这是右手系的约定。
  • projection(perspective):WebGPU 的深度范围是 [0,1](不是 OpenGL 的 [-1,1]),近平面映射到 z_ndc = 0、远平面映射到 1。fovY、aspect、near、far 四个参数就决定了这个矩阵。
  • viewProj = projection * view(列向量:先 view 后 projection)。

裁剪面不用手推。Gribb–Hartmann 方法直接从 viewProj 的四行组合出六个平面:左 = row3 + row0、右 = row3 - row0、下 = row3 + row1、上 = row3 - row1、近 = row2(因深度从 0 开始)、远 = row3 - row2。每个平面归一化后,点在内侧当且仅当对六个平面都 ≥ 0。这正是第 8 章视锥剔除要用的东西。

可运行产物:从屏幕坐标反投影出拾取射线

拾取射线不一定要走「求逆 viewProj」那条路。既然相机基座(forward / right / up)和 fov 都已知,屏幕 NDC 处的射线方向可以直接构造:

dir = normalize(forward + right * ndcX * tan(fovY/2) * aspect + up * ndcY * tan(fovY/2))

完整的 src/render/scene/camera.ts 如下:

// file: src/render/scene/camera.ts
// tier: L1
// verify: node src/render/scene/camera.ts
//
// helio 渲染 7.3:相机、裁剪面与拾取射线。相机产出 view / projection / viewProj;
// 从 viewProj 抽出六个裁剪面(Gribb–Hartmann);再由屏幕 NDC 沿相机基座构造
// 世界空间射线用于拾取。纯计算,Node 26 直跑。

import { type Mat4, identity, multiply } from '../../core/mat4.ts';
import type { Vec3 } from '../../core/vec3.ts';
/** WebGPU 透视投影:右手系,深度 [0,1](近平面 z_ndc = 0)。 */
export function perspective(fovY: number, aspect: number, near: number, far: number): Mat4 {
  const f = 1 / Math.tan(fovY / 2);
  const m = new Float32Array(16);
  m[0] = f / aspect; m[5] = f;
  m[10] = far / (near - far); m[11] = -1; m[14] = (far * near) / (near - far);
  return m;
}
/** 右手系 lookAt:视线朝 -z,up 决定「上」。 */
export function lookAt(eye: Vec3, target: Vec3, up: Vec3): Mat4 {
  const z = norm([eye[0] - target[0], eye[1] - target[1], eye[2] - target[2]]);
  const x = norm(cross(up, z));
  const y = cross(z, x);
  const m = new Float32Array(16);
  m[0] = x[0]; m[1] = y[0]; m[2] = z[0];
  m[4] = x[1]; m[5] = y[1]; m[6] = z[1];
  m[8] = x[2]; m[9] = y[2]; m[10] = z[2];
  m[12] = -dot(x, eye); m[13] = -dot(y, eye); m[14] = -dot(z, eye);
  m[15] = 1;
  return m;
}
export interface Camera {
  readonly view: Mat4; readonly proj: Mat4; readonly viewProj: Mat4;
  readonly eye: Vec3; readonly right: Vec3; readonly up: Vec3; readonly forward: Vec3;
  readonly tanHalfFov: number; readonly aspect: number; readonly near: number; readonly far: number;
}
export function makeCamera(eye: Vec3, target: Vec3, up: Vec3,
  fovY: number, aspect: number, near: number, far: number): Camera {
  const forward = norm([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
  const right = norm(cross(forward, up));
  const camUp = cross(right, forward);
  const view = lookAt(eye, target, up);
  const proj = perspective(fovY, aspect, near, far);
  return {
    view, proj, viewProj: multiply(identity(), proj, view),
    eye, right, up: camUp, forward, tanHalfFov: Math.tan(fovY / 2), aspect, near, far,
  };
}
/** Gribb–Hartmann:从 viewProj 的四行抽出六个向内法线已归一化的平面。 */
export function frustumPlanes(vp: Mat4): Float32Array {
  const row = (r: number): number[] => [vp[r]!, vp[4 + r]!, vp[8 + r]!, vp[12 + r]!];
  const r0 = row(0), r1 = row(1), r2 = row(2), r3 = row(3);
  const add = (a: number[], b: number[]): number[] => a.map((v, i) => v + b[i]!);
  const sub = (a: number[], b: number[]): number[] => a.map((v, i) => v - b[i]!);
  const planes = [add(r3, r0), sub(r3, r0), add(r3, r1), sub(r3, r1), r2, sub(r3, r2)];
  const out = new Float32Array(24);
  planes.forEach((p, i) => {
    const len = Math.hypot(p[0]!, p[1]!, p[2]!) || 1;
    out.set([p[0]! / len, p[1]! / len, p[2]! / len, p[3]! / len], i * 4);
  });
  return out;
}
/** 点是否在视锥内:对六个平面都要「不落在外侧」。 */
export function pointInFrustum(planes: Float32Array, p: Vec3): boolean {
  for (let i = 0; i < 6; i++) {
    const d = planes[i * 4]! * p[0] + planes[i * 4 + 1]! * p[1] + planes[i * 4 + 2]! * p[2] + planes[i * 4 + 3]!;
    if (d < 0) return false;
  }
  return true;
}
export interface Ray { readonly origin: Vec3; readonly dir: Vec3 }
/** 屏幕 NDC(x/y ∈ [-1,1],y 向上)沿相机基座构造世界射线,原点取在相机眼。 */
export function screenToRay(cam: Camera, ndcX: number, ndcY: number): Ray {
  const t = cam.tanHalfFov;
  const dir = norm([
    cam.forward[0] + cam.right[0] * ndcX * t * cam.aspect + cam.up[0] * ndcY * t,
    cam.forward[1] + cam.right[1] * ndcX * t * cam.aspect + cam.up[1] * ndcY * t,
    cam.forward[2] + cam.right[2] * ndcX * t * cam.aspect + cam.up[2] * ndcY * t,
  ]);
  return { origin: cam.eye, dir };
}
/** 射线与 y = 0 平面上、以 c 为中心、半边长 s 的轴对齐方片求交;不中返回 null。 */
export function rayQuadXZ(ray: Ray, c: [number, number], s: number): number | null {
  if (Math.abs(ray.dir[1]) < 1e-9) return null;
  const t = -ray.origin[1] / ray.dir[1];
  if (t < 0) return null;
  const x = ray.origin[0] + t * ray.dir[0];
  const z = ray.origin[2] + t * ray.dir[2];
  return Math.abs(x - c[0]) > s || Math.abs(z - c[1]) > s ? null : t;
}
function cross(a: Vec3, b: Vec3): Vec3 {
  return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
function dot(a: Vec3, b: Vec3): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function norm(a: Vec3): Vec3 {
  const l = Math.hypot(a[0], a[1], a[2]) || 1;
  return [a[0] / l, a[1] / l, a[2] / l];
}
// ---- 直接运行时打印相机与拾取结果(Node 26 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
  const cam = makeCamera([0, 6, 8], [0, 0, 0], [0, 1, 0], (60 * Math.PI) / 180, 1, 0.1, 100);
  const planes = frustumPlanes(cam.viewProj);
  const f = (n: number) => n.toFixed(3);
  console.log(`frustum  origin=${pointInFrustum(planes, [0, 0, 0])}  x+50=${pointInFrustum(planes, [50, 0, 0])}  z+40(相机背后)=${pointInFrustum(planes, [0, 0, 40])}`);
  const ray = screenToRay(cam, 0, 0);
  console.log(`center ray  origin(${ray.origin.map(f).join(', ')})  dir(${ray.dir.map(f).join(', ')})`);
  const quads: { name: string; c: [number, number]; s: number }[] = [
    { name: 'island', c: [0, 0], s: 2 }, { name: 'rockR', c: [4, -3], s: 1 }, { name: 'rockL', c: [-4, -3], s: 1 },
  ];
  const hit = quads.map((q) => ({ q, t: rayQuadXZ(ray, q.c, q.s) }))
    .filter((h) => h.t !== null).sort((a, b) => a.t! - b.t!)[0];
  console.log(`pick at center  ->  ${hit ? hit.q.name : 'none'}  t=${hit ? f(hit.t!) : '-'}`);
}

node src/render/scene/camera.ts(本机实测):

frustum  origin=true  x+50=false  z+40(相机背后)=false
center ray  origin(0.000, 6.000, 8.000)  dir(0.000, -0.600, -0.800)
pick at center  ->  island  t=10.000

原点在视锥内;x = 50 落在右侧面外;z = 40 在相机背后(相机在 z = 8 看向 -z)。屏幕中心的射线方向 (0, -0.6, -0.8) 正是从相机 (0,6,8) 指向目标 (0,0,0) 的单位向量——拾取射线和相机朝向对上了。它与 y = 0 平面上的岛屿方片交于 t = 10,落点就是原点。

可运行产物:渲染 + 拾取,点哪亮哪

L1 只证明了 CPU 侧算得对。真正的验收是 GPU 渲染与 CPU 拾取用同一条 viewProj:射线说命中了谁,谁就被高亮,回读像素一看便知。着色器 shaders/camera-pick.wgsl 把相机矩阵放进 uniform:

// file: shaders/camera-pick.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/scene/ray-pick.ts

// 相机 viewProj 放在 uniform,实例 model + 颜色放在 storage;顶点着色器做完整
// MVP(局部四边形 → 世界 → 裁剪空间)。被拾取的实例颜色已在 CPU 侧换成高亮色。
struct Camera { viewProj: mat4x4<f32>, };
@group(0) @binding(0) var<uniform> camera: Camera;
struct Instance {
  model: mat4x4<f32>,
  color: vec4<f32>,
};
@group(0) @binding(1) var<storage, read> instances: array<Instance>;
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>(-0.8, -0.8), vec2<f32>(0.8, -0.8), vec2<f32>(0.8, 0.8),
    vec2<f32>(-0.8, -0.8), vec2<f32>(0.8, 0.8), vec2<f32>(-0.8, 0.8),
  );
  let local = quad[vi];
  let world = instances[ii].model * vec4<f32>(local.x, 0.0, local.y, 1.0);
  var o: VSOut;
  o.pos = camera.viewProj * world;
  o.color = instances[ii].color;
  return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
  return in.color;
}

src/render/scene/ray-pick.ts(L2)把三块方片按 viewProj 画出来,再分别对屏幕中心和 rockR 的投影点做拾取:

// file: src/render/scene/ray-pick.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/scene/ray-pick.ts
//
// 7.3 的 L2 验收:把相机 viewProj 交给 GPU 渲染整片场景,同时在 CPU 侧用同一条
// viewProj 从屏幕坐标反投影出射线做拾取。拾取命中的实例被高亮,回读像素断言
// 「屏幕点上确实是高亮色」——相机矩阵与拾取射线必须严格一致。
// 需 headless Chromium + 本地 http 服务。

import { makeCamera, screenToRay, rayQuadXZ, type Camera } from './camera.ts';
import { loadWgsl, type L2Check, type L2Result } from './render-instances.ts';
import { type Mat4, identity, fromTranslation, fromScale, multiply } from '../../core/mat4.ts';
import type { Vec3 } from '../../core/vec3.ts';
interface Quad { readonly name: string; readonly c: [number, number]; readonly s: number; }
const QUADS: readonly Quad[] = [
  { name: 'island', c: [0, 0], s: 2 }, { name: 'rockR', c: [4, -3], s: 1 }, { name: 'rockL', c: [-4, -3], s: 1 },
];
const NORMAL: readonly number[][] = [[0, 0, 1, 1], [0, 1, 0, 1], [0, 1, 0, 1]];
const HILITE = [1, 1, 0, 1];
/** 把中心 c、半边长 s 的方片编码成 model(单位四边形局部 ±0.8)。 */
const modelOf = (c: [number, number], s: number): Mat4 =>
  multiply(identity(), fromTranslation(c[0], 0, c[1]), fromScale(s / 0.8, 1, s / 0.8));
/** CPU 侧把世界点投影到 NDC——与 GPU 用的是同一个 viewProj。 */
function projectPoint(vp: Mat4, p: Vec3): [number, number] {
  const w = vp[3]! * p[0] + vp[7]! * p[1] + vp[11]! * p[2] + vp[15]!;
  return [
    (vp[0]! * p[0] + vp[4]! * p[1] + vp[8]! * p[2] + vp[12]!) / w,
    (vp[1]! * p[0] + vp[5]! * p[1] + vp[9]! * p[2] + vp[13]!) / w,
  ];
}
/** 用射线在全部方片里找最近命中,返回名字与 t。 */
function pick(cam: Camera, ndcX: number, ndcY: number): { name: string; t: number } | null {
  const ray = screenToRay(cam, ndcX, ndcY);
  let best: { name: string; t: number } | null = null;
  for (const q of QUADS) {
    const t = rayQuadXZ(ray, q.c, q.s);
    if (t !== null && (best === null || t < best.t)) best = { name: q.name, t };
  }
  return best;
}
async function render(device: GPUDevice, wgsl: string, cam: Camera, colors: readonly number[][], size: number): Promise<Uint8Array> {
  const inst = new Float32Array(QUADS.length * 20);
  QUADS.forEach((q, i) => { inst.set(modelOf(q.c, q.s), i * 20); inst.set(colors[i]!, i * 20 + 16); });
  const camBuf = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(camBuf, 0, cam.viewProj);
  const instBuf = device.createBuffer({ size: inst.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(instBuf, 0, inst);
  const module = device.createShaderModule({ code: wgsl });
  const pipeline = device.createRenderPipeline({ layout: 'auto',
    vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', 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, QUADS.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;
}
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/camera-pick.wgsl');
  const info = await device.createShaderModule({ code: wgsl }).getCompilationInfo();
  push('wgsl.0error', info.messages.filter((m) => m.type === 'error').length === 0, 'errors=0');
  const cam = makeCamera([0, 6, 8], [0, 0, 0], [0, 1, 0], (60 * Math.PI) / 180, 1, 0.1, 100);
  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 toPixel = (n: [number, number]): [number, number] =>
    [Math.round(((n[0] + 1) / 2) * SIZE), Math.round(((1 - n[1]) / 2) * SIZE)];
  // 1) 屏幕中心:射线命中 island,且 island 中心投影恰在屏幕中心
  const hitCenter = pick(cam, 0, 0);
  const ndcIsland = projectPoint(cam.viewProj, [0, 0, 0]);
  push('center 射线命中 island', hitCenter?.name === 'island', JSON.stringify(hitCenter));
  push('island 中心投影在屏幕中心', Math.abs(ndcIsland[0]) < 1e-6 && Math.abs(ndcIsland[1]) < 1e-6, JSON.stringify(ndcIsland));
  // 2) 高亮命中者渲染:中心像素应为高亮色;不渲染高亮时中心应是 island 的常规色(蓝)
  const colorsHit = NORMAL.map((c, i) => (QUADS[i]!.name === hitCenter!.name ? HILITE : c));
  const c = sample(await render(device, wgsl, cam, colorsHit, SIZE), 64, 64);
  push('中心像素 = 高亮色(拾取生效)', near(c, [255, 255, 0, 255]), JSON.stringify(c));
  const p = sample(await render(device, wgsl, cam, NORMAL, SIZE), 64, 64);
  push('中心像素 = island 常规色(对照)', near(p, [0, 0, 255, 255]), JSON.stringify(p));
  // 3) 屏幕另一处:rockR 投影点射线应命中 rockR,该点像素变高亮
  const ndcRock = projectPoint(cam.viewProj, [4, 0, -3]);
  const hitRock = pick(cam, ndcRock[0], ndcRock[1]);
  const colorsRock = NORMAL.map((cc, i) => (QUADS[i]!.name === hitRock!.name ? HILITE : cc));
  const [rx, ry] = toPixel(ndcRock);
  const rc = sample(await render(device, wgsl, cam, colorsRock, SIZE), rx, ry);
  data.rockR = { hit: hitRock, ndc: ndcRock, pixel: [rx, ry], got: rc };
  push('rockR 投影点射线命中 rockR', hitRock?.name === 'rockR', JSON.stringify(hitRock));
  push('rockR 投影点像素 = 高亮色', near(rc, [255, 255, 0, 255]), JSON.stringify(rc));
  device.destroy();
  return { checks, data };
}

本机实跑:

PASS  wgsl.0error  errors=0
PASS  center 射线命中 island  {"name":"island","t":10}
PASS  island 中心投影在屏幕中心  [0,-1.5383701214958276e-16]
PASS  中心像素 = 高亮色(拾取生效)  [255,255,0,255]
PASS  中心像素 = island 常规色(对照)  [0,0,255,255]
PASS  rockR 投影点射线命中 rockR  {"name":"rockR","t":13.152946341731028}
PASS  rockR 投影点像素 = 高亮色  [255,255,0,255]

七个断言全过。屏幕中心射线命中 island(t = 10),把 island 高亮成黄色后中心像素就是 [255,255,0,255];不做高亮时中心是 island 的常规蓝色——这一对对照证明中心确实是 island。rockR 的投影点在 NDC (0.559, 0.251),射线同样命中 rockR,该点像素变黄。相机矩阵与拾取射线只要有一处不一致,这两条就会当场失败。

小结

相机把「从哪看」压缩成三个矩阵加一组基向量;裁剪面从 viewProj 免费抽出,是第 8 章剔除的输入;拾取射线由屏幕坐标反投影得到,和渲染共用同一条 viewProj。

第 7 章到此为止:场景图给出「有什么」,脏标记让它「算得少」,相机回答「从哪看」。下一章就把它们接到一起——视锥剔除与包围体,只把相机看得见的实例喂给 GPU。

阅读导航:上一节:7.2 脏标记传播与 world matrix 缓存 · 下一节:8.1 视锥剔除与包围体 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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