《游戏引擎实现原理》11.1 gizmo 与拾取射线

视口要回答的是「鼠标点在画面上究竟选中了谁、拖动了多少」。本节把拾取射线与变换 gizmo 的几何抽成纯数学:像素→NDC→世界射线、射线与有限轴线的最短距离、轴手柄与面手柄判定、沿轴拖拽位移的求解,并在本机真跑 12 条 vitest 用例;真实鼠标拖拽属 L4,需在浏览器中手动验证。

11.1 gizmo 与拾取射线

前四章的编辑器都在处理数据:8 章的文档模型、9 章的命令栈、10 章的属性面板。这一章换到编辑器的另一半——视口。视口要回答两个问题:「鼠标点在画面上,选中了哪个对象」,以及「拖动时移动了多少」。这两个都不是 UI 问题,而是几何问题:屏幕像素怎么变成世界射线,射线又怎么和 gizmo 的轴、面求交。

这一节先把视口交互里最硬的那层——拾取射线与 gizmo 判定——抽成纯数学并真跑。真正接鼠标的拖拽是 L4,只能在浏览器里手动验证;但它的每一步几何计算,都能在命令行里被钉死。

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

站内已经有不少讲编辑器用法与子系统设计的文章,本卷不重推,只做「从零实现 + 每节真跑」。

主题站内既有内容本章这一节做什么
关卡编辑器Defold 关卡编辑器:场景编辑与 tilemap不重推编辑器操作,把视口拾取的几何写成可跑的 TS
相机与拾取射线7.3 相机、视锥与拾取射线复用卷二相机,补上编辑器侧的像素→NDC→世界射线
场景图7.1 场景图与变换层级拾取命中的对象来自场景图展平的实例
变换的复合与逆8.3 变换复合与逆拖拽位移的求解建立在 TRS 之上
属性面板10.1 反射驱动的属性面板拖拽改的是同一个被面板显示的字段

屏幕像素到世界射线

视口给的是像素(左上原点、y 向下),而射线要从相机出发。中间要跨两段:像素 → NDC,NDC → 世界射线。前者是一次线性映射,后者卷二已经实现好了(screenToRay)。helio 不重复造这条射线,编辑器的 pickRay 只是把像素换算成 NDC 再转交。

下面 src/editor/viewport/picking-ray.ts 层级 L1,完整可编译。它含五件事:像素→NDC、像素→射线、射线与平面求交、射线与有限轴线的最短距离,以及由它们搭出的轴/面手柄判定与沿轴拖拽求解。

// file: src/editor/viewport/picking-ray.ts
// tier: L1
// verify: npx vitest run src/editor/viewport/picking-ray.test.ts
//
// helio 编辑器 11.1:视口拾取与变换 gizmo 的纯数学。
// 视口像素 → NDC → 世界射线;射线与平面/轴线求交;gizmo 的轴手柄与面手柄判定;
// 拖拽时「沿轴位移」的求解。全部是纯计算,Node 26 直跑,不含任何 DOM。

import { makeCamera, screenToRay, type Camera, type Ray } from '../../render/scene/camera.ts';
import type { Vec3 } from '../../core/vec3.ts';

/** 视口像素坐标(左上原点,y 向下)→ NDC([-1,1],y 向上)。 */
export function viewportToNdc(px: number, py: number, width: number, height: number): [number, number] {
  return [(px / width) * 2 - 1, 1 - (py / height) * 2];
}

/** 视口像素处射出的世界射线:先转 NDC,再交给渲染侧同一条 screenToRay。 */
export function pickRay(cam: Camera, px: number, py: number, width: number, height: number): Ray {
  const [x, y] = viewportToNdc(px, py, width, height);
  return screenToRay(cam, x, y);
}

/** 射线与平面求交:返回沿射线的参数 t,无正向交点返回 null。 */
export function rayPlane(ray: Ray, planePoint: Vec3, planeNormal: Vec3): number | null {
  const denom = dot(ray.dir, planeNormal);
  if (Math.abs(denom) < 1e-9) return null;
  const t = dot(sub(planePoint, ray.origin), planeNormal) / denom;
  return t < 0 ? null : t;
}

export interface RayAxisHit { readonly t: number; readonly s: number; readonly dist: number }

/**
 * 射线与「从 axisOrigin 沿 axisDir 延伸 axisLen」的有限轴线的最短距离。
 * t 是射线参数、s 是轴线参数(夹到 [0, axisLen]),dist 是最短距离。
 */
export function closestRayAxis(ray: Ray, axisOrigin: Vec3, axisDir: Vec3, axisLen: number): RayAxisHit {
  const d = normalize(ray.dir);
  const a = normalize(axisDir);
  const w0 = sub(ray.origin, axisOrigin);
  const b = dot(d, a);
  const denom = 1 - b * b;
  let s: number;
  if (Math.abs(denom) < 1e-9) {
    s = 0; // 射线与轴平行:退化为取轴起点
  } else {
    s = (dot(a, w0) - b * dot(d, w0)) / denom;
  }
  s = Math.min(Math.max(s, 0), axisLen);
  const onAxis = add(axisOrigin, scale(a, s));
  const t = Math.max(0, dot(sub(onAxis, ray.origin), d));
  const onRay = add(ray.origin, scale(d, t));
  return { t, s, dist: length(sub(onAxis, onRay)) };
}

/** gizmo 以固定屏幕尺寸绘制时,它的世界尺寸:正比于到相机的距离与像素长度。 */
export function constantScreenScale(cam: Camera, point: Vec3, pixelLength: number, viewportHeight: number): number {
  const dist = length(sub(point, cam.eye));
  return (2 * cam.tanHalfFov * dist * pixelLength) / viewportHeight;
}

export interface Gizmo { readonly origin: Vec3; readonly scale: number }

/**
 * 轴手柄拾取:在三条世界轴里取「与射线最近、且距离小于世界容差」的那条。
 * 返回轴索引(0=x,1=y,2=z),都不中返回 -1。
 */
export function pickAxis(ray: Ray, g: Gizmo, axes: readonly Vec3[], tol: number): number {
  let best = -1;
  let bestD = tol;
  const deadZone = 0.15 * g.scale; // 原点附近是三条轴共用的底座,不参与拾取
  for (let i = 0; i < axes.length; i++) {
    const hit = closestRayAxis(ray, g.origin, axes[i]!, g.scale);
    if (hit.s < deadZone) continue;
    if (hit.dist < bestD) { bestD = hit.dist; best = i; }
  }
  return best;
}

/** 面手柄的三条法线(XY / YZ / XZ 三块方片)。 */
export const PLANE_NORMALS: readonly Vec3[] = [[0, 0, 1], [1, 0, 0], [0, 1, 0]];
/** 面手柄方片的两个切向,对应 (u, v) 局部坐标。 */
export const PLANE_TANGENTS: readonly [Vec3, Vec3][] = [
  [[1, 0, 0], [0, 1, 0]], [[0, 1, 0], [0, 0, 1]], [[1, 0, 0], [0, 0, 1]],
];

/**
 * 面手柄拾取:射线先与该面的法向平面求交,交点再判断是否落在 gizmo 局部
 * [0, scale] × [0, scale] 的方片内。命中返回面索引,否则 -1。
 */
export function pickPlane(ray: Ray, g: Gizmo): number {
  for (let i = 0; i < PLANE_NORMALS.length; i++) {
    const t = rayPlane(ray, g.origin, PLANE_NORMALS[i]!);
    if (t === null) continue;
    const p = add(ray.origin, scale(ray.dir, t));
    const local = sub(p, g.origin);
    const [u, v] = PLANE_TANGENTS[i]!;
    const du = dot(local, u);
    const dv = dot(local, v);
    if (du >= 0 && du <= g.scale && dv >= 0 && dv <= g.scale) return i;
  }
  return -1;
}

/**
 * 拖拽时「沿单轴位移」的求解:把上一帧与当前帧两条射线都打到「包含该轴且最朝向
 * 相机」的平面上,取两个交点之差在该轴上的投影。这是平移 gizmo 的核心。
 */
export function dragAxisDelta(
  prevRay: Ray, currRay: Ray, axisOrigin: Vec3, axisDir: Vec3, camForward: Vec3,
): number {
  const side = cross(camForward, axisDir);
  const n = normalize(cross(axisDir, side)); // 轴 × (相机前向 × 轴),朝相机的平面法向
  const t0 = rayPlane(prevRay, axisOrigin, n);
  const t1 = rayPlane(currRay, axisOrigin, n);
  if (t0 === null || t1 === null) return 0;
  const p0 = add(prevRay.origin, scale(prevRay.dir, t0));
  const p1 = add(currRay.origin, scale(currRay.dir, t1));
  return dot(sub(p1, p0), normalize(axisDir));
}

// ---- 小向量工具(本地,避免与 core/vec3 的语义纠缠)----
function add(a: Vec3, b: Vec3): Vec3 { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; }
function sub(a: Vec3, b: Vec3): Vec3 { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
function scale(a: Vec3, s: number): Vec3 { return [a[0] * s, a[1] * s, a[2] * s]; }
function dot(a: Vec3, b: Vec3): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function length(a: Vec3): number { return Math.hypot(a[0], a[1], a[2]); }
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 normalize(a: Vec3): Vec3 {
  const l = length(a) || 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, 0, 8], [0, 0, 0], [0, 1, 0], (60 * Math.PI) / 180, 1, 0.1, 100);
  const axes: Vec3[] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
  const g: Gizmo = { origin: [0, 0, 0], scale: 1 };
  for (const [name, target] of [['X', [1, 0, 0]], ['Y', [0, 1, 0]], ['Z', [0, 0, 1]]] as const) {
    const [px, py] = projectToPixel(cam, target, 400, 400);
    const ray = pickRay(cam, px, py, 400, 400);
    console.log(`aim ${name}  pixel(${px.toFixed(0)}, ${py.toFixed(0)})  -> axis ${pickAxis(ray, g, axes, 0.2)}`);
  }
  const [cx, cy] = projectToPixel(cam, [0.5, 0.5, 0], 400, 400);
  console.log(`aim XY-face  -> plane ${pickPlane(pickRay(cam, cx, cy, 400, 400), g)}`);
}

/** 世界点投影到视口像素(供运行时打印与调试)。 */
function projectToPixel(cam: Camera, p: Vec3, w: number, h: number): [number, number] {
  const vp = cam.viewProj;
  const wc = vp[3]! * p[0] + vp[7]! * p[1] + vp[11]! * p[2] + vp[15]!;
  const ndcX = (vp[0]! * p[0] + vp[4]! * p[1] + vp[8]! * p[2] + vp[12]!) / wc;
  const ndcY = (vp[1]! * p[0] + vp[5]! * p[1] + vp[9]! * p[2] + vp[13]!) / wc;
  return [((ndcX + 1) / 2) * w, ((1 - ndcY) / 2) * h];
}

三点值得注意。第一,viewportToNdc 的 y 轴要翻转:屏幕 y 向下,NDC y 向上,漏掉这个负号,gizmo 会上下颠倒。第二,constantScreenScale 让 gizmo 在屏幕上大小恒定——世界尺寸正比于到相机的距离,这样拉远相机时手柄不会缩成一个点。第三,pickAxis 有一个 deadZone:三条轴都过原点,若不排除底座附近,瞄准原点会同时命中三条轴。

轴手柄、面手柄与沿轴拖拽

轴手柄判定用的是「射线与有限轴线的最短距离」:把轴线当成一条有端点的线段,算出射线上最近点与轴线上最近点,距离小于容差就算命中。斜视时三条轴都能分辨;但当视线正好与某条轴重合(比如正对 +Z 轴),这条轴退化成不可拾取——这不是 bug,而是所有编辑器都有的现象,此时应改用面手柄。

面手柄先和该面的法向平面求交,再看交点是否落在 gizmo 局部的 [0, scale]² 方片里。最后是拖拽的核心 dragAxisDelta:把上一帧与当前帧两条射线都打到「包含该轴且最朝向相机」的平面上,两个交点之差在该轴上的投影,就是这一帧该移动的距离。为什么用这个平面而不是屏幕平面?因为轴是斜的,屏幕上横移一段,对应的世界位移在不同深度下并不相同。

可运行产物:L1 真跑

上面这些全是纯计算,可以真跑。测试覆盖 12 个用例:像素到 NDC 的边界、逐轴拾取、正对 +Z 轴时的退化、死区、面手柄命中与落空、平行射线无交点、两条拖拽位移,以及固定屏幕尺寸的世界缩放。

// file: src/editor/viewport/picking-ray.test.ts
// tier: L1
// verify: npx vitest run src/editor/viewport/picking-ray.test.ts
import { describe, expect, it } from 'vitest';
import { makeCamera, type Camera } from '../../render/scene/camera.ts';
import type { Vec3 } from '../../core/vec3.ts';
import {
  constantScreenScale, dragAxisDelta, pickAxis, pickPlane, pickRay, rayPlane, viewportToNdc, type Gizmo,
} from './picking-ray.ts';

const W = 400;
const H = 400;
const cam: Camera = makeCamera([0, 0, 8], [0, 0, 0], [0, 1, 0], (60 * Math.PI) / 180, 1, 0.1, 100);
// 斜视相机:三条轴都不与视线重合,用来验证逐轴拾取。
const camA: Camera = makeCamera([6, 6, 8], [0, 0, 0], [0, 1, 0], (60 * Math.PI) / 180, 1, 0.1, 100);
const AXES: readonly Vec3[] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
const gizmo: Gizmo = { origin: [0, 0, 0], scale: 1 };

/** 把世界点投影到视口像素——测试里用来「瞄准」某个手柄。 */
function pixelOf(c: Camera, p: Vec3): [number, number] {
  const vp = c.viewProj;
  const wc = vp[3]! * p[0] + vp[7]! * p[1] + vp[11]! * p[2] + vp[15]!;
  const x = (vp[0]! * p[0] + vp[4]! * p[1] + vp[8]! * p[2] + vp[12]!) / wc;
  const y = (vp[1]! * p[0] + vp[5]! * p[1] + vp[9]! * p[2] + vp[13]!) / wc;
  return [((x + 1) / 2) * W, ((1 - y) / 2) * H];
}

describe('视口像素与射线', () => {
  it('像素中心是 NDC 原点,左上角是 (-1, 1)', () => {
    expect(viewportToNdc(200, 200, W, H)).toEqual([0, 0]);
    expect(viewportToNdc(0, 0, W, H)).toEqual([-1, 1]);
    expect(viewportToNdc(400, 400, W, H)).toEqual([1, -1]);
  });

  it('瞄准 +X 手柄的像素,射线确实指向 +X 轴', () => {
    const [px, py] = pixelOf(cam, [1, 0, 0]);
    const ray = pickRay(cam, px, py, W, H);
    expect(pickAxis(ray, gizmo, AXES, 0.2)).toBe(0);
  });

  it('斜视相机下三条轴各自被正确命中', () => {
    for (const [i, target] of [[0, [1, 0, 0]], [1, [0, 1, 0]], [2, [0, 0, 1]]] as const) {
      const [px, py] = pixelOf(camA, target as Vec3);
      expect(pickAxis(pickRay(camA, px, py, W, H), gizmo, AXES, 0.2)).toBe(i);
    }
  });

  it('正对 +Z 轴时视线与轴重合,该轴退化为不可拾取', () => {
    const [px, py] = pixelOf(cam, [0, 0, 1]);
    expect(pickAxis(pickRay(cam, px, py, W, H), gizmo, AXES, 0.2)).toBe(-1);
  });

  it('指向原点底座:落在死区内,不命中任何轴', () => {
    const [px, py] = pixelOf(camA, [0, 0, 0]);
    expect(pickAxis(pickRay(camA, px, py, W, H), gizmo, AXES, 0.2)).toBe(-1);
  });

  it('远离所有轴的点不命中', () => {
    const [px, py] = pixelOf(camA, [3, 3, 0]);
    expect(pickAxis(pickRay(camA, px, py, W, H), gizmo, AXES, 0.2)).toBe(-1);
  });
});

describe('面手柄拾取', () => {
  it('瞄准 XY 面中心命中 XY 面(索引 0)', () => {
    const [px, py] = pixelOf(cam, [0.5, 0.5, 0]);
    expect(pickPlane(pickRay(cam, px, py, W, H), gizmo)).toBe(0);
  });

  it('瞄准 XY 面之外(超出方片)不命中', () => {
    const [px, py] = pixelOf(cam, [1.5, 1.5, 0]);
    expect(pickPlane(pickRay(cam, px, py, W, H), gizmo)).toBe(-1);
  });
});

describe('射线与平面', () => {
  it('平行于平面的射线无交点', () => {
    const ray = pickRay(cam, 200, 200, W, H); // 朝 -z 的射线
    expect(rayPlane(ray, [0, 0, 0], [0, 0, 1])).not.toBeNull();
    expect(rayPlane(ray, [0, 0, 0], [0, 1, 0])).toBeNull(); // 该射线无 y 分量
  });
});

describe('沿轴拖拽求解', () => {
  it('从瞄准 X=1 拖到 X=2,位移约等于 1', () => {
    const [ax, ay] = pixelOf(cam, [1, 0, 0]);
    const [bx, by] = pixelOf(cam, [2, 0, 0]);
    const prev = pickRay(cam, ax, ay, W, H);
    const curr = pickRay(cam, bx, by, W, H);
    const delta = dragAxisDelta(prev, curr, [0, 0, 0], [1, 0, 0], cam.forward);
    expect(delta).toBeCloseTo(1, 5);
  });

  it('沿 Y 轴拖动得到 Y 方向位移', () => {
    const [ax, ay] = pixelOf(cam, [0, 1, 0]);
    const [bx, by] = pixelOf(cam, [0, 2, 0]);
    const delta = dragAxisDelta(pickRay(cam, ax, ay, W, H), pickRay(cam, bx, by, W, H),
      [0, 0, 0], [0, 1, 0], cam.forward);
    expect(delta).toBeCloseTo(1, 5);
  });
});

describe('固定屏幕尺寸的世界缩放', () => {
  it('距离 8、像素长 100、视口高 400 时约 2.309', () => {
    const s = constantScreenScale(cam, [0, 0, 0], 100, 400);
    expect(s).toBeCloseTo((2 * Math.tan((60 * Math.PI) / 180 / 2) * 8 * 100) / 400, 6);
    expect(s).toBeCloseTo(2.3094, 3);
  });
});

本机 npx vitest run src/editor/viewport/picking-ray.test.ts:12 个用例全过(tsc --noEmit 零错误)。直接 node src/editor/viewport/picking-ray.ts 也会打印一次真实拾取(400×400 视口、相机在 [0,0,8]):

aim X  pixel(243, 200)  -> axis 0
aim Y  pixel(200, 157)  -> axis 1
aim Z  pixel(200, 200)  -> axis -1
aim XY-face  -> plane 0

注意第三行:正对 +Z 轴时返回 -1,正是上面说的退化。

真实拖拽:L4,命令行不可复现

把上面这套几何接到鼠标,是另一层。src/editor/viewport/gizmo-drag.ts 层级 L4——它依赖 PointerEvent、setPointerCapture、getBoundingClientRect,本节需在浏览器中手动验证,命令行不可复现(仅通过 tsc 类型检查)。它做的事很直接:pointerdown 时用同一套 pickAxis/pickPlane 判定命中哪个手柄,把「命中轴、起始射线、起始平移」锁进一个 session;pointermove 时对每一帧算 dragAxisDelta,再把新的平移写回。

这里有个关键取舍:拖拽不是直接改对象,而是每帧产出一个新的平移交给命令栈——一次拖拽因此是一条「设置平移」命令,能被 9 章的命令栈整体撤销。指针捕获(setPointerCapture)保证鼠标移出 canvas 时拖拽不丢。至于「按下时到底点没点中手柄」,用的就是本节那套纯数学,L4 只是把它的输入换成真实指针事件。

小结

  • 像素 → NDC 要翻转 y;世界射线复用卷二的 screenToRay,编辑器只补换算。
  • 轴手柄 = 射线与有限轴线的最短距离;原点底座要留死区;视线与轴重合时该轴退化,改用面手柄。
  • 面手柄 = 射线与法向平面求交 + 局部方片范围判定。
  • 沿轴拖拽 = 两条射线打到「朝相机的含轴平面」,交点之差投影到轴。
  • 真实鼠标拖拽是 L4,需在浏览器中手动验证,命令行不可复现。

下一节把这套几何接进真正的视口:把卷二渲染器装进编辑器,多相机、网格与选择描边一起画出来。

阅读导航:上一节:回到目录 · 下一节:11.2 场景视口与渲染接入 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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