Scanat 的 3D 场景里,用户需要移动、旋转和定位虚拟物体。负责这些操作的是 Gizmo:轴向箭头用于移动,弧线用于旋转,中心区域用于平移。
这套交互在桌面端很成熟,但直接搬到手机和 iPad 上会出现明显问题。Gizmo 的视觉手柄通常很细,适合高精度鼠标,却不适合手指。触屏上更容易点不中、误触相邻手柄,或者在滑动画面时意外触发操作。
这次设计的目标不是重新发明一套移动端 3D 控件,而是保留桌面端已有的操作语义,同时让它适应触屏输入。
触屏和鼠标最大的差异不在视觉,而在输入能力。
| 维度 | 触屏 | 鼠标 |
|---|---|---|
| 命中精度 | 低,手指接触面大 | 高,可精确指向 |
| Hover | 没有稳定的悬停状态 | 可以提前高亮目标 |
| 操作意图 | 滑动画面和拖拽手柄容易混淆 | 按下目标通常已经足够明确 |
| 多点输入 | 双指缩放等手势并存 | 滚轮、按键等输入可以分工 |
因此,移动端真正需要解决的是两件事:让目标更容易命中,以及降低"只是划过"和"真的想拖"之间的歧义。
测试过几种方向后,最终保留了最接近桌面版的操作方式:长按目标手柄 260ms,确认操作意图后直接拖拽。
相比"先选中,再到屏幕任意位置拖动"或额外增加一套控制盘,这个方案的优势是学习成本低。用户仍然是在操作眼前那个 Gizmo,只是移动端多了一层短暂的意图确认。
这里没有为了触屏重新定义一整套 3D 操作语言。Scanat 不是专业建模工具,保持跨设备的一致性,比增加一套更复杂但更专门的移动端控制方式更重要。
移动端最关键的改动不是把 Gizmo 画得更大,而是把视觉尺寸和可交互范围分开设计。
视觉手柄继续保持原来的比例,但每个手柄周围增加不可见的命中区域。手指点击时,系统判断的是更大的交互区域,而不是细小的视觉几何体。
这样可以同时保留两件事:
多个命中区域发生重叠时,再通过优先级决定响应对象。例如轴末端箭头的优先级高于轴中段和平面区域,避免扩大热区后反而增加歧义。
260ms 不是预先规定的标准值,而是在真机上反复比较后确定的。
时间太短,用户滑过 Gizmo 时容易误触;时间太长,拖拽开始前会产生明显等待感。最终的判断标准不是某个通用规范,而是在真实操作中能否兼顾这两个问题。
这层延迟只发生在触屏。桌面端不需要用长按来区分意图,因此仍然可以直接按下并拖动。
设备判断也不依赖 UA 字符串,而优先根据输入设备能力区分,例如 pointer: fine。这里判断的是输入精度,而不是简单判断"这是不是 iPad"。
可交互 demo 的价值主要体现在两个后续发现上。
第一,边长编辑原本使用轻点触发。在手机上测试时,手指无意划过就可能打开编辑框。把它改成长按后,误触明显减少。
第二,边长标签在拖拽过程中的显示和隐藏时机,也是在连续操作 demo 时才逐步明确。静态稿可以定义大致规则,但很难完整覆盖真实拖拽过程里的状态切换。
这类问题都不是视觉稿画得更详细就一定能解决。触屏交互里的热区、等待时间和误触,本身就依赖真实输入设备。
这次原型并不是为了证明一个已经确定的方案,而是为了把几个只能在真实触屏里判断的问题提前暴露出来。
对于 3D 操作来说,设计稿更适合定义结构、状态和视觉关系;而误触、长按延迟、拖拽手感这些问题,只有真正运行以后才能验证。原型在这里是设计过程的一部分,而不是最后的展示物。
命中区域。 视觉手柄之外增加不可见的几何体作为 Raycaster 的实际命中对象,并通过 priority 处理重叠区域。
const hitSphere = new THREE.Mesh(
new THREE.SphereGeometry(0.2, 16, 16),
new THREE.MeshBasicMaterial({
transparent: true,
opacity: 0.001
})
)
hitSphere.userData.priority = 80输入设备判断。
if (window.matchMedia('(pointer: fine)').matches) {
// high-precision pointer
} else {
// touch-first interaction
}状态管理。 交互状态使用 selected、active、hover、operationStart 等明确字段,而不是多个相互独立的布尔值。取消操作时,可以直接恢复 operationStart 快照,不需要额外维护撤销栈。
React / Three.js 边界。 Demo 使用一层命令式 API 连接 React UI 和 Three.js 场景,让声明式 UI 状态和命令式 3D 操作保持各自边界。