Skip to content

copper3d — 相机取景与位姿过渡

把相机对准它要展示的内容,以及让它从一个位姿移动到另一个。全部从包入口导出 (copper3d)。

3.8.0 新增

纯新增。computeFramingresolveViewPoseresolveFarPlaneCopper3dOrbitControlsloadView 以及 loadGltf 自己的相机处理均未改动——只要不调用下面这些函数,相机行为与 3.7.x 完全一致。


1. fitDistance()

透视相机要离物体中心多远,整个物体才能装进画面。

ts
fitDistance(bounds: FitBounds, aspect: number, fovDeg: number, margin?: number): number
fitDistance(bounds: FitBounds, aspect: number, fovDeg: number, margin?: number): number
参数类型默认含义
boundsFitBounds物体的轴对齐尺寸与中心,场景单位。
aspectnumber视口宽 / 高。传 0 或非有限值时按 1 处理。
fovDegnumber相机的垂直视场角,度。
marginnumber0.85最后乘上去的系数。越小,物体在屏幕上越大。

computeFraming 的区别

computeFraming(未改动,仍由 Copper3dOrbitControls 使用)只用包围盒对角线和垂直视场角。 fitDistance 有两处不同。

它考虑 aspect。 aspect 小于 1 时,水平视场才是较窄的那个,而必须装下物体的正是较窄 的那个。只按垂直视场取景的相机,在任何"高而窄"的视口里都会取景过小——一旦把视图拆成并排的 多个面板,这个问题立刻出现。

它拟合包围球,而不是包围盒。 用"朝向相机的那个盒面 + 一半深度"来取景,等于让每个物体 由恰好朝向相机的那一维决定大小。一块薄片(33 层的乳腺 X 线体数据)几乎不需要把相机推远, 于是铺满整个面板;而体量相当、接近立方体的 MRI 体数据会被推得很远,并排看上去只有前者一半 大。球没有朝向,所以体量相当的物体会得到相当的屏幕尺寸——这也是旋转时角落再也不会被推出画面 的原因。

关于 margin

故意小于 1。包围球外接于物体——立方体的外接球半径是其半边长的 1.73 倍——所以正好装下 球,等于物体本身只占不到半屏。让球稍微溢出一点,物体本身才能读起来大小合适。

这是一个调参旋钮,不是正确性阈值。在真实面板上试过 1.351.05,都偏小;0.75 又偏大, 体数据的包围框会紧贴面板边缘。


2. fitView()

按内容重新取景,并保留视图预设的方向。

ts
// 自由函数
fitView(
  scene: FitViewScene,
  preset: CameraViewPreset,
  aspect: number,
  bounds: FitBounds,
  margin?: number
): boolean

// 或作为方法
copperSceneOnDemond.fitView(preset, aspect, bounds, margin?)
// 自由函数
fitView(
  scene: FitViewScene,
  preset: CameraViewPreset,
  aspect: number,
  bounds: FitBounds,
  margin?: number
): boolean

// 或作为方法
copperSceneOnDemond.fitView(preset, aspect, bounds, margin?)

以下两种情况返回 false:正交相机(没有视场角可供拟合),或预设的 eye 正好落在 target 上。 不会触发渲染——何时绘制由调用方决定,这在按需渲染下很重要。

ts
const { width, height } = container.getBoundingClientRect();
const [x, y, z] = volume.RASDimensions;

scene.fitView(preset, width / height, {
  width: x, height: y, depth: z,
  center: [0, 0, 0],
});
renderer.render();
const { width, height } = container.getBoundingClientRect();
const [x, y, z] = volume.RASDimensions;

scene.fitView(preset, width / height, {
  width: x, height: y, depth: z,
  center: [0, 0, 0],
});
renderer.render();

它保留什么、替换什么

一个视图预设里手写的 eyePosition 同时编码了两件事:读者应该从哪个方向看这份数据,以及看 多远。只有前者是决策。 后者撑不过一个只有原设计三分之一宽的视口,而且手写的值常常让内容 只占画面的四分之一。

所以预设的视线方向和 up 向量原样保留——它们往往是临床判断——只有距离由 fitDistance 重新算出。

它瞄准物体自身中心,而不是预设的 target

预设通常 target 原点。这对 NRRD 体数据是对的,RASDimensions 描述的就是以原点为中心的盒子; 对 GLB 就是错的——它的包围盒不在原点,从原点取景会把模型的一部分推出画面,视口一窄就特别明显。 把真实中心通过 bounds.center 传进来即可;体数据传 [0, 0, 0],这一步就是空操作。

它会同步 controls.target

这是关键的一半。 没有任何东西会把 controls 的旋转中心与 camera.lookAt() 同步,所以少了 这一步,用户下一次拖拽会触发 controls.update(),把相机重新对准 target 里那个旧值——刚做完 的取景被无声地撤销。同时也会调 handleResize(),供那些会缓存 canvas 页面坐标的 trackball 变体 使用。


2a. setCameraPose() 3.9.0

ts
setCameraPose(scene: PosableScene, pose: Pose): void

// 或者用方法形式
copperSceneOnDemond.setCameraPose(pose)
setCameraPose(scene: PosableScene, pose: Pose): void

// 或者用方法形式
copperSceneOnDemond.setCameraPose(pose)

fitView 是"根据内容重新取景"。这个是通用情况:你已经有的一个位姿 —— interpolateFlightPose 飞行的终点、一个保存下来的书签、viewPointToPose(preset) —— 把它写到相机上。

ts
scene.setCameraPose(Copper.viewPointToPose(preset));
renderer.render();
scene.setCameraPose(Copper.viewPointToPose(preset));
renderer.render();

它是四个赋值,而第四个是所有人都会漏的那个:

ts
camera.position.set(...)
camera.up.set(...)       // 要在 lookAt 之前,lookAt 是用 up 建基的
camera.lookAt(...)
controls.target.set(...) // <- 这一行
camera.position.set(...)
camera.up.set(...)       // 要在 lookAt 之前,lookAt 是用 up 建基的
camera.lookAt(...)
controls.target.set(...) // <- 这一行

漏掉最后一行,一切看起来都对,直到用户碰鼠标:第一次拖拽会调 controls.update(),把相机 重新对准那个没更新的 target。而症状 ——"我一拖相机就跳回去了"—— 指向的是控制器,而不是 真正导致它的那段代码。

渲染。什么时候画由调用方决定 —— 在按需渲染下这尤其重要,因为它可能只是一帧里的若干 变更之一。


3. 位姿插值 —— Controls/cameraTransitions

一组用于移动相机的纯数值工具。零依赖:进出都是普通数字和 [x, y, z] 元组,不涉及任何 three.js 对象。这让它们无需 WebGL 即可单元测试,也让自带一份 three 的使用方可以直接用,不必 让任何对象跨越那个边界。

ts
interface Pose {
  position: [number, number, number];
  up: [number, number, number];
  /** 注视点——即 controls 的旋转中心,不是相机自身位置。 */
  target: [number, number, number];
}
interface Pose {
  position: [number, number, number];
  up: [number, number, number];
  /** 注视点——即 controls 的旋转中心,不是相机自身位置。 */
  target: [number, number, number];
}
函数签名用途
easeInOutCubic(t: number) => number标准缓动,超出范围的输入会被钳制。
viewPointToPose(vp: CameraViewPreset) => Pose把视图预设读成 Pose
interpolateFlightPose(from: Pose, to: Pose, t: number) => Pose在两个位姿之间插值。t 应当已经缓动过。
orbitStepPose(pose: Pose, yawRad: number, pitchRad: number) => Pose绕自身旋转中心转动一步。
zoomPose(pose: Pose, factor: number, minDistance?: number) => Pose缩放到旋转中心的距离。
poseDistance(pose: Pose) => number轨道半径。
rotateAroundAxis(v, axis, angleRad) => [number, number, number]罗德里格旋转公式。
orbitSwingAngle(t: number, turns: number) => number入场环绕的摆动角。

interpolateFlightPose 为什么用四元数而不是两次 lerp

旋转中心从一个 target 线性插值到另一个,而相机的朝向——包括它相对中心的方向和它的 up 向量——由一个旋转承载,这个旋转是从两个位姿完整的正交基构造出来的,而不是把 dirup 各自独立插值。

这是正确性要求,不是风格选择。真实的预设数据里经常出现同一条视轴上方向相反的 up 向量 ——同一个体数据的两个视图按相反约定写成,例如 eye [0,0,2000]up: [0,-1,0],eye [0,0,650]up: [0,1,0]。朴素的 lerp(upFrom, upTo, t) 会在 t = 0.5 处正好穿过零向量, 此时 normalize 没有正确答案,只能退化成一条平行于视线方向的轴:相机在前半段一直是倒过来 的,然后在中点一帧之内翻转 180 度。

把两组基合成一个相对旋转矩阵、再转成四元数(最大对角元法)就没有这种退化——对一个合法的旋转 矩阵,总有某个分支的除数被下界隔开。同样这个情形会变成绕共享轴的、定义良好的 180 度滚转: 平滑地穿过中点。同一套构造也覆盖了绕同一 target 的两个相反相机位置——在那里,基于向量对 的 slerp 会在 t = 1 附近来回翻。

t = 0 时精确复现 from.positiont = 1 时精确复现 to.positionup 同样精确,前提是 每个位姿自身的 up 已经垂直于自身的视线方向;如果某个 up 偏向视轴,它会被无声地正交化—— 这是有意为之,因为那本来就不是一个有意义的相机滚转。

orbitStepPose 注意事项

旋转中心和轨道半径都不变,所以连续按键永远不会把相机从它注视的对象上"走开"。俯仰是绕偏航 之后的 right 向量施加的,不是原来那个——绕过时的轴旋转,会让"左 + 上"这种斜向组合键偏离球 面而不是沿球面走。

orbitSwingAngle 注意事项

turns摆动幅度,不是净旋转量sin(t·π) 荡出去再荡回零,所以无论 turns 多大,相机 最终都精确停在取景后的预设视角上(t = 1 → 角度 0)。不要把它"修"成净旋转,那会让入场动画 停在预设之外。


类型

ts
interface FitBounds {
  width: number;
  height: number;
  depth: number;
  /** 包围盒中心,场景单位。NRRD 体数据传 `[0, 0, 0]`。 */
  center: [number, number, number];
}
interface FitBounds {
  width: number;
  height: number;
  depth: number;
  /** 包围盒中心,场景单位。NRRD 体数据传 `[0, 0, 0]`。 */
  center: [number, number, number];
}