从点击到选中座位:Shinkansen 3D Seat Booking Prototype 的 SceneKit HitTest 与位掩码交互技巧
【免费下载链接】shinkansen-3d-seat-booking-prototypeExample of 3D Integration to Visualize Train Seat Map in iOS项目地址: https://gitcode.com/gh_mirrors/sh/shinkansen-3d-seat-booking-prototype
Shinkansen 3D Seat Booking Prototype是一个用苹果原生SceneKit3D 引擎打造的 iOS 开源示例,把日本新干线的车厢座椅图渲染成实时 3D 场景。这个项目最精彩的部分,就是它如何让手指"点中座位":通过hitTest命中测试精确定位按下的 3D 对象,并用categoryBitMask位掩码给不同对象划分"通道",优雅地解决了两个棘手问题——点错对象,以及透视视角下屏幕拖动如何换算成深度位移。本文面向新手,拆解这条"点击 → 高亮 → 选中 → 震动反馈"的完整交互链路。
3D 座位图交互:为什么 3D 场景里触摸很"难"
听到"移动端 3D",大多数人会想到 3D 游戏。而本项目的目标更朴素:把真实空间可视化,让用户看着真实的 3D 车厢来决定"选哪个座位"。在 README.md 中,作者提到 SceneKit 的SCNView本身就是UIView的子类,因此 3D 场景能无缝嵌入原生 UIKit 流程。
座位图页面有两个核心交互:
- 点按座位:高亮 → 选中 → 震动 → 自动推近到座位位置 🎯
- 上下拖动屏幕:整节车厢沿深度方向"流动",带惯性与软边界
难点在第一个交互:3D 场景里有成百上千个节点(座架、坐垫、扶手、地板、阴影……),手指按到的往往只是模型的某个小零件,而不是"座位"对象本身。
HitTest 命中测试快速上手:如何找到手指按下的对象
SceneKit 提供了hitTest(_:options:)方法:给它一个屏幕二维坐标,它返回按距相机由近到远排序的SCNHitTestResult列表——第一个元素就是手指按到的最上层对象。
新手陷阱:一个座位模型是几十个小几何节点组成的树,不做过滤直接命中测试,返回的可能是"坐垫"子节点,根本判断不出用户点的是哪个座位。
本项目用两层设计解决这个问题:位掩码过滤 + 递归找父节点🔍
categoryBitMask 位掩码:给节点分"频道"
SceneKit 中每个节点都有一个categoryBitMask(32 位整数)。做命中测试时可以传入.categoryBitMask选项,只命中"位"匹配上的节点——相当于给对象类型分频道,各取所需。
项目里定义了关键的两类频道:
| 频道 | 值 | 分配给 | 用途 |
|---|---|---|---|
1 << 1(2) | 隐形地板节点 | HitTestFloorNode.swift | 拖动时屏幕坐标 → 深度坐标换算 |
1 << 2(4) | 所有可交互座位 | InteractiveNode.swift | 座位点选的命中测试 |
两个关键细节:
- 地板看不见,却参与命中测试。HitTestFloorNode.swift 把
SCNFloor材质transparency设为 0(完全透明)、reflectivity设为 0(无反射),放在y = 1.3的高度——用户看不见它,但它仍是命中测试的"标尺"。 - 位掩码要递归打满。NodeFactory.swift 中的
applyInteractiveCategoryBitmask会递归遍历子节点树,把同一个掩码盖到座位模型的每一片几何上,确保点中哪个零件都能被过滤命中。
从点击到选中:三步搭出精确选座链路
交互核心在 SeatMapSceneView.swift,它直接继承SCNView,自己接管触摸事件。
第 1 步:手指按下,先"高亮"候选座位
touchesBegan中对每个手指调用filterReservationNodeFrom(SeatMapSceneView.swift#L543-L555),核心逻辑就一行:
let firstHitTestResult = hitTest(touch.location(in: self), options: [.categoryBitMask: SeatNode.defaultBitMask]).first- 掩码选项
SeatNode.defaultBitMask(即1 << 2)保证只命中"座位"这一频道; - 只取
.first,即离相机最近的对象——手指按到的是最上层。
命中的座位被加入highlightedSeatNodes集合,集合的didSet只更新"差集"(新增的高亮、移除的取消高亮),多点触控也不会闪烁。
第 2 步:手指抬起,确认选中 + 震动反馈
touchesEnded时再次把手指和"高亮集合"做匹配:只有起点就按在高亮座位上的那次点击,才算有效选中。选中成功后,项目一口气做三件事:
- 触发
UIImpactFeedbackGenerator轻震动反馈 📳 - 屏幕顶部弹出 HeadsUp 提示:"Seat X in car Y has been selected."
- 平滑推近动画,把选中座位移动到屏幕中心
第 3 步:findParent 递归——从"零件"找回"座位"
即使加了位掩码,命中的也可能是座位模型的叶子节点(坐垫、靠背……)。项目用一个递归小工具兜底:
func findParent(of node: SCNNode?) -> SeatNode? { guard let node = node else { return nil } if let node = node as? SeatNode { return node } return findParent(of: node.parent) }从命中节点一路向上爬,直到找到SeatNode容器为止。无论你点在座位的哪个部位,最终都会得到携带座位数据(reservableEntity)的完整座位对象。
透视相机 trick:用隐形地板"测量"拖动距离
这是项目里位掩码最巧妙的一处用法。
常见错误:拖动时把手指屏幕位移(点/像素)直接换算成场景 Z 位移。正交相机下没问题,但本项目用的是透视相机(CameraNode.swift,FOV 64°)——同一屏幕位移在深度近处和远处对应完全不同的世界位移,手感会"忽快忽慢"。
项目方案:拿隐形地板(频道1 << 1)当标尺:
- 拖动开始时,对地板做一次命中测试,记录起点世界坐标;
- 拖动过程中,对当前位置再命中一次地板,取当前世界坐标;
- 两者的
z差值就是当前深度层应有的场景位移,手感恒定。
对应实现positionOfFloorHitTest(SeatMapSceneView.swift#L563-L566):
let hitTests = hitTest(point, options: [.categoryBitMask : 1 << 1]) return hitTests.first?.worldCoordinates随后zPositionClamp用对数函数做"软边界"修正:接近边界时位移逐渐衰减,拖不出去却还能带一点,手感自然;手指抬起后,SCNAction.customAction配合衰减函数实现惯性漂移,并在越界时自动回弹到合法区间。
五态状态机:normal、highlighted、selected、disabled、focus
座位的视觉状态由 InteractiveNode.swift 中的State状态机管理,共五种:normal / highlighted / selected / disabled / focus。巧妙之处在于:
- 初始化时把座位模型的所有
SCNMaterial递归收集进materialMap,材质命名遵循xxx-normal、xxx-highlighted、xxx-selected等约定; - 状态切换时只按状态名替换材质并播放一次微动画,无需重建节点;
- 暗色模式还额外处理了
-darkMode后缀材质。
"状态"与"模型"彻底解耦:同一份座位模型,靠材质命名约定获得 5 套外观,交互代码只改状态即可。
复用这套思路的 6 条清单
想把"命中测试 + 位掩码"模式搬进自己的 SceneKit 项目,照着做:
- 按交互类型给节点分类:可交互对象、测量用辅助几何、纯装饰,各占一个独立的位(
1 << n); - 位掩码必须递归应用到整棵节点树——SceneKit 的命中测试发生在几何体粒度;
- 命中结果永远取
.first,最近的对象才是最上层; - 用递归
findParent把叶子节点映射回业务对象; - 透视场景里,用隐形平面(地板)命中测试做屏幕 → 世界坐标换算,别直接乘系数硬算;
- 采用"按下高亮 → 抬起确认"两阶段触摸,并叠加震动反馈,选座成功率与质感都会明显提升 ✅
相关源码索引
- 3D 场景视图、手势与选座主链路:SeatMapSceneView.swift
- 隐形命中测试地板:HitTestFloorNode.swift
- 交互节点与五态状态机:InteractiveNode.swift
- 节点工厂与位掩码递归应用:NodeFactory.swift
- 透视相机参数设置:CameraNode.swift
小结
Shinkansen 3D Seat Booking Prototype 用一个小巧的项目证明:好的 3D 交互不依赖重型框架,而在于把基础工具用巧。hitTest命中测试 +categoryBitMask位掩码 + 一块隐形命中地板,就把"点一下座位"拆成了一条清晰、可维护的交互流水线。对想入门 SceneKit 交互开发的新手来说,这个项目堪称"实用而非炫技"的教科书案例。
【免费下载链接】shinkansen-3d-seat-booking-prototypeExample of 3D Integration to Visualize Train Seat Map in iOS项目地址: https://gitcode.com/gh_mirrors/sh/shinkansen-3d-seat-booking-prototype
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考