移动开发
SpriteKit、SceneKit 与 Unity:用三个引擎做手机游戏学到的事
阅读需 9 分钟
SpriteKit、SceneKit 与 Unity:用三个引擎做手机游戏学到的事
读移动开发硕士时,我花了一个学期用三种工具做小游戏:iOS 上做 2D 的 SpriteKit、iOS 上做 3D 的 SceneKit,以及跨平台的 Unity。其中一些练习至今还放在本站的“归档”栏目里:一个空气曲棍球的仿作、一个迷宫、一个塔防。它们都不是商业游戏,但把同类东西用三种引擎各做一遍,让我对游戏的运作方式理解得比只学一个引擎深得多。
下面按每个游戏都要解决的问题,整理我的收获。
三套 API,同一副骨架
类名各不相同,但三个引擎背后是同一个模型。
- 场景是一棵节点树。 每个可见对象都是一个节点,带有位置、缩放、旋转和子节点。绘制顺序由 z 值决定,类似网页里的 z-index。
- 游戏循环分两个阶段。 Update 读取输入、移动角色、处理交互;render 把结果画出来。引擎通常以每秒 60 帧为目标;对于负载较重的游戏,稳定的 30 帧往往比不稳定的 60 帧体验更好。
- 时间必须显式处理。 移动量要乘以距上一帧经过的时间,否则游戏在性能更强的设备上会跑得更快。在 SceneKit 里,这个时间差要在渲染器代理中自己计算:
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
let deltaTime = time - (previousUpdateTime ?? time)
previousUpdateTime = time
// move nodes using deltaTime
}
看清这副骨架之后,换引擎基本上就只是换一套术语。
场景与切换
SpriteKit 让切换场景几乎毫不费力:加载下一个场景,再交给视图一个过渡效果即可。
let transition = SKTransition.flipVertical(withDuration: 1)
if let scene = SKScene(fileNamed: "GameScene"), let view = self.view {
view.presentScene(scene, transition: transition)
}
Unity 中对应的是 SceneManager.LoadScene("GameScene"),我很早就踩过一个坑:场景必须先在 Build Settings 里登记,否则运行时加载会失败。另外,Unity 切换场景时会销毁所有对象,所以要在场景之间保留数据(金币、分数、设置),我用了一个标记为 DontDestroyOnLoad 的小型单例。
物理:选能用的最简单形状
各引擎的物理术语是一致的:刚体(质量、摩擦、速度)、用于碰撞的形状、刚体之间的关节,以及带重力的物理世界。刚体分三类:动态刚体受物理规律支配,静态刚体永远不动,运动学刚体由代码驱动,但仍会推动其他物体。
我学到的最有用的一条规则是:碰撞形状不必和视觉网格一致。在 SceneKit 中,球体比盒子开销小,盒子又比胶囊体开销小,自定义的凹多面体网格最好完全避免。一个外观精细的球,完全可以按完美球体来做碰撞。
Unity 还有两个设置,值得在排查其他问题之前先了解:Interpolate 可以消除摄像机跟随物理对象时的抖动;Collision Detection 对高速物体要调成连续检测,这样它们才不会穿墙而过。
碰撞与射线检测
两个引擎都通过回调通知接触事件。在 SceneKit 中要实现接触代理;由于事先不知道哪个是 A、哪个是 B,需要两边都检查:
func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
if contact.nodeA.name == "projectile" || contact.nodeB.name == "projectile" {
let damage = contact.collisionImpulse
// apply damage
}
}
Unity 使用 OnCollisionEnter、OnCollisionStay 和 OnCollisionExit,另外还有用于不可见区域的触发器版本——“球掉出了棋盘”其实就是一个 OnTriggerExit。要用手指选中物体,就从摄像机穿过触点发射一条射线:
Ray ray = Camera.main.ScreenPointToRay(touchPosition);
if (Physics.Raycast(ray, out RaycastHit hit, 100f)) {
hit.rigidbody?.AddForce(-hit.normal * 10f, ForceMode.Impulse);
}
在 Apple 的两个框架里,“谁和谁碰撞”的分类是用位掩码表示的:每一位代表一个类别,每个刚体声明自己会与哪些类别发生碰撞。
界面是差异最大的地方
SpriteKit 没有为场景内容提供界面系统。按钮得自己做:一个精灵节点,记录触摸是否从它内部开始,并且只有在触摸也在内部结束时才触发。写这个小类让我意识到,平时 UI 框架替我们做了多少事。
Unity 则有一套建立在 Canvas 上的真正界面层。让布局终于听话的两个概念是:锚点——元素相对于父级挂在哪里;以及轴心(pivot)——元素自身的哪个点放在那个位置上。Canvas 还可以设为 world space,让界面直接存在于 3D 场景之中。
输入:触摸与加速度计
触摸输入是一组当前活动的触点,每个触点都有位置和阶段。加速度计是一个三维向量(Unity 中为 Input.acceleration),足以实现倾斜控制,比如在迷宫里滚动的小球。Unity 的虚拟按键(“Fire1”等)便于先在桌面上做原型,再迁移到触屏。
屏幕尺寸:先决定哪些内容可以被裁掉
手机和平板的宽高比差别很大,所以每个游戏都需要明确的适配策略:
- Aspect fill 适合二维滚动的游戏,同时要确保重要内容始终位于可见区域内。
- 纵向滚动的游戏用固定宽度,横向滚动的游戏用固定高度。
在 SpriteKit 中,我写了一个小扩展:保持场景高度不变,根据设备比例算出宽度。Unity 中同样的思路通过摄像机的视野来实现。此外,HUD 元素要锚定在屏幕边缘;菜单居中、纵向排列,尺寸要适合指尖点按。
资源:精灵图集与瓦片地图
每个 2D 游戏都会用到两项优化。精灵图集把一段动画的所有帧打包进一张纹理(尺寸最好是 2 的幂),可以用 TexturePacker 之类的工具生成。瓦片地图用可复用的小图块拼出大背景,而不是使用一张巨大的图片;我用 Tiled 来设计,用图案层负责视觉,用对象层标记出生点和不可见区域。再加上视差滚动——远处的图层比近处的移动得慢——几乎不费力就能营造出纵深感。
成就与排行榜
iOS 上的 Game Center 和 Android 上的 Google Play Games 都提供成就与排行榜。让我印象最深的设计建议是:区分进度型成就(推动玩家通关)和额外型成就(提升重玩价值),并且不要在第一个版本里就把成就点数用完——先观察玩家实际怎么玩,再逐步补充成就。
顺带一提:虚拟现实
课程的最后一部分是在 Unity 中做 VR。Google Cardboard 只需要它的 SDK 和一台立体摄像机。Gear VR 则要求先针对具体的测试设备签名构建包(设备通过 adb devices 识别),头显才会接受——这也提醒我:移动开发有一半的工作在于工具链和配置,而不是代码。
开始之前我想告诉自己的话
- 先掌握通用模型——节点树、游戏循环、时间差、刚体与形状——之后学哪个引擎都会更轻松。
- 碰撞形状按性能选,而不是按外观选。
- 在画第一张素材之前,就定好屏幕适配策略。
- 按项目规模选引擎:专注的 iOS 2D 游戏用 SpriteKit,Apple 平台上的轻量 3D 用 SceneKit,需要多平台、更完整的编辑器或 VR 时用 Unity。