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 场景视口与渲染接入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。