让三维浏览有手感:双摇杆、阻尼与多指输入

0 阅读6分钟

磁刻技术实录 · 第 25 篇 · 工程实践 · 技术基线 2026-10-31

我们正在做的「磁刻(Cike)」,是一款鸿蒙原生的生活记录与个性化创作应用。用户可以把照片做成数字冰箱贴,在洞洞板上自由摆放、组合自己的生活片段,也可以把喜欢的内容做成桌面卡片,用来回看、互动和分享。

在空间记录上,我们引入了 3D Gaussian Splatting(3DGS,三维高斯泼溅),把拍摄的视频重建为可以转动、靠近观察的三维场景。为此,我们重构了视频重建链路,实现自己的 C++ 训练核心,并结合鸿蒙原生的媒体处理、模型查看和分享能力,希望让用户保存一段经历时,也能留下当时的空间关系。

三维记忆需要被用户轻松探索,手指的细小移动不应该让视角突然失控。我们分享摇杆、阻尼和多指输入,是想说明磁刻怎样把数学上的相机变换做成可操作的界面,并让不同帧率和观察距离下的手感更一致。

三维模型能够转动,只是交互的起点。真正的浏览手感来自细微输入是否可控、松手后是否自然停止、双手同时操作是否稳定,以及用户迷失时能否回到熟悉视角。磁刻把输入映射、阻尼和坐标转换拆成独立逻辑,再连接原生查看器。

以下代码按当前实现整理,保留关键运算与控制顺序;省略导入、日志和完整类型,部分辅助函数采用说明性命名,片段需结合上下文阅读。

查看页同时提供摇杆、画面拖动、缩放和高度调整。多种入口应共享同一套方向语义,否则摇杆向上与画面上拖产生相反效果,会让用户无法建立稳定心智模型。

从触摸到稳定相机运动

图 1:输入目标与实际运动分离,按时间推进

死区解决手指难以绝对静止的问题

摇杆中心附近的微小偏移可能来自触摸抖动或手指接触面积变化。当前映射对归一化幅度设置 0.10 的死区,超过后重新缩放,并采用二次响应。这样小幅操作更细腻,大幅推动仍能到达完整速度。

曲线不是越复杂越好。它需要与模型尺度、移动速度和可操作区域一起调整。若死区过大,用户会觉得开始拖动没反应;若曲线过陡,细调又会变成突然跳动。用输入幅度与输出速度曲线讨论手感,比只说“更丝滑”更具体。

阻尼应使用时间,而不是固定帧比例

每帧直接向目标靠近固定百分比,会让不同帧率产生不同响应。当前函数使用 1-exp(-dt/τ) 作为插值系数,其中 dt 是经过时间,τ 控制响应速度。这样同样经过一段时间,状态会以相近方式靠近目标。

next = current + (target-current) * (1-exp(-dt/tau))

小残差直接吸附到目标,避免松手后长期留下不可见的漂移。验证时可以在不同更新间隔下运行同一输入序列,比较相同时间点的状态,而不是只比较每帧数值。

缩放与平移需要考虑观察距离

屏幕上的固定拖动距离,对近处和远处模型对应的世界位移不同。当前转换结合观察距离、画面高度和视场角,把屏幕拖动换算成逻辑空间移动。双指缩放使用幂次响应,并设置最小最大范围,避免一次异常 scale 把模型推到不可见位置。

这些约束应在输入边界处理。非有限值、过小距离和无效画面高度都需要保护,不能让一个触摸异常进入投影矩阵。

摇杆中间留死区,边缘提供更大的响应

function joystickAxis(value: number): number {
  const magnitude = Math.min(1, Math.abs(value));
  if (magnitude <= 0.10) return 0;
  const scaled = (magnitude - 0.10) / 0.90;
  return (value < 0 ? -1 : 1) * scaled * scaled;
}
function damp(current: number, target: number, dt: number, seconds: number): number {
  if (Math.abs(target - current) < 0.0001) return target;
  return current + (target - current) * (1 - Math.exp(-Math.max(0, dt) / seconds));
}

死区处理手指在中心附近的小抖动,平方曲线让小幅输入更细腻,同时保留最大输入到一的范围。直接线性映射容易让精细观察变得困难,尤其当模型尺度较小、相机已经靠近主体时,微小拖动也会产生明显位移。

阻尼使用真实时间 dt,因此不同帧率下趋近目标的速度更一致。seconds 应是正值时间常数,不能用零;接近目标时直接归位则避免无限残余漂移。它只平滑响应,不负责处理坐标方向,方向映射仍应在统一边界完成。

三种控制承担不同意图

图 2:避免让所有手势同时修改同一个模糊变量

多指操作需要保留身份

左右摇杆同时使用时,抬起一只手不应终止另一只手。实现需要根据触点身份维护各自状态,处理按下、移动、抬起和取消,而不能只用“当前是否有触摸”的全局布尔值。

页面消失或引导切换时,也要清空对应输入,避免离开后仍残留速度。操作引导可以按步骤开放控件,但关闭引导后应恢复全部控制,不能留下隐藏的交互锁。

把坐标反转集中在边界

逻辑控制保持向上为正,原生显示若使用不同方向,则在统一适配函数中转换。这样按钮、拖动、相机和枢轴可以共享语义。到处添加负号会让某个场景修好、另一个入口反向。

把屏幕拖动换成与观察距离有关的位移

function dragWorldY(offsetY: number, distance: number, height: number): number {
  const visibleHeight = 2 * Math.max(0.002, Math.abs(distance)) * Math.tan(0.873 / 2);
  return -offsetY * visibleHeight / Math.max(1, height) * 0.65;
}
function toNativeY(logicalY: number): number {
  return -logicalY;
}

visibleHeight 根据约五十度垂直视场估算当前距离下可见的世界高度。再除以画布高度,就得到每个屏幕像素大致对应的世界位移。这样相机远近变化时,拖动速度会更接近用户对屏幕运动的预期,而不是固定一个与场景无关的单位。

逻辑层约定向上为正,原生呈现方向在边界统一翻转。若手势、按钮和摇杆各自临时取反,多个入口就容易出现相互矛盾的方向。多指处理还要保持同一手势会话的起始状态,避免一根手指离开时把缩放误解释成突然的平移。

验证既需要函数级曲线测试,也需要真机双手操作与长时间浏览。参考相机提供可恢复起点,阻尼让运动连续,明确的控制分工则让用户知道自己改变了什么。三者结合,模型才从一个演示画面变成可探索的空间内容。