axiom-spritekit by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-spritekit目的:通过掌握场景图、物理引擎、动作系统和渲染管线,构建可靠的 SpriteKit 游戏 iOS 版本:iOS 13+(SwiftUI 集成),iOS 11+(SKRenderer) Xcode:Xcode 15+
在以下情况使用此技能:
不要将此技能用于:
axiom-scenekit)axiom-metal-migration-ref)axiom-swiftui-layout)SpriteKit 使用左下角原点,Y 轴向上。这与 UIKit(左上角,Y 轴向下)不同。
SpriteKit: UIKit:
┌─────────┐ ┌─────────┐
│ +Y │ │ (0,0) │
│ ↑ │ │ ↓ │
│ │ │ │ +Y │
│(0,0)──→+X│ │ │ │
└─────────┘ └─────────┘
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
锚点定义了精灵的哪个点映射到其 position。默认值为 (0.5, 0.5)(中心)。
// 常见锚点陷阱:
// 锚点 (0, 0) = 精灵的左下角位于 position
// 锚点 (0.5, 0.5) = 精灵的中心位于 position(默认)
// 锚点 (0.5, 0) = 底部中心(适用于站立在地面上的角色)
sprite.anchorPoint = CGPoint(x: 0.5, y: 0)
场景锚点将视图的 frame 映射到场景坐标:
(0, 0) — 场景原点位于视图左下角(默认)(0.5, 0.5) — 场景原点位于视图中心SpriteKit 中的所有内容都是树形层次结构中的 SKNode。父节点的变换会传播到子节点。
SKScene
├── SKCameraNode(视口控制)
├── SKNode "world"(游戏内容层)
│ ├── SKSpriteNode "player"
│ ├── SKSpriteNode "enemy"
│ └── SKNode "platforms"
│ ├── SKSpriteNode "platform1"
│ └── SKSpriteNode "platform2"
└── SKNode "hud"(UI 层,附加到摄像机)
├── SKLabelNode "score"
└── SKSpriteNode "healthBar"
zPosition 控制绘制顺序。值越大,渲染越靠上。相同 zPosition 的节点按照子节点数组顺序渲染(除非 ignoresSiblingOrder 为 true)。
// 建立清晰的 Z 轴层级
enum ZLayer {
static let background: CGFloat = -100
static let platforms: CGFloat = 0
static let items: CGFloat = 10
static let player: CGFloat = 20
static let effects: CGFloat = 30
static let hud: CGFloat = 100
}
| 模式 | 行为 | 使用场景 |
|---|---|---|
.aspectFill | 填充视图,裁剪边缘 | 全屏游戏(大多数游戏) |
.aspectFit | 适应视图,留黑边 | 需要精确布局的解谜游戏 |
.resizeFill | 拉伸以填充 | 几乎从不使用 — 会导致变形 |
.fill | 精确匹配视图大小 | 场景适应任何宽高比 |
class GameScene: SKScene {
override func sceneDidLoad() {
scaleMode = .aspectFill
// 为参考尺寸设计,让 aspectFill 裁剪边缘
}
}
始终使用 SKCameraNode 进行视口控制。将 HUD 元素附加到摄像机,使其不随场景滚动。
let camera = SKCameraNode()
camera.name = "mainCamera"
addChild(camera)
self.camera = camera
// HUD 自动跟随摄像机
let scoreLabel = SKLabelNode(text: "Score: 0")
scoreLabel.position = CGPoint(x: 0, y: size.height / 2 - 50)
camera.addChild(scoreLabel)
// 移动摄像机以跟随玩家
let follow = SKConstraint.distance(SKRange(constantValue: 0), to: playerNode)
camera.constraints = [follow]
// 为组织创建图层节点
let worldNode = SKNode()
worldNode.name = "world"
addChild(worldNode)
let hudNode = SKNode()
hudNode.name = "hud"
camera?.addChild(hudNode)
// 所有游戏对象放入 worldNode
worldNode.addChild(playerSprite)
worldNode.addChild(enemySprite)
// 所有 UI 放入 hudNode(随摄像机移动)
hudNode.addChild(scoreLabel)
// 预加载下一个场景以实现平滑过渡
guard let nextScene = LevelScene(fileNamed: "Level2") else { return }
nextScene.scaleMode = .aspectFill
let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(nextScene, transition: transition)
场景间数据传递:使用共享的游戏状态对象,而不是节点属性。
class GameState {
static let shared = GameState()
var score = 0
var currentLevel = 1
var playerHealth = 100
}
// 在场景过渡中:
let nextScene = LevelScene(size: size)
// GameState.shared 已经可访问
view?.presentScene(nextScene, transition: .fade(withDuration: 0.5))
注意:对于简单游戏,单例模式可行。对于需要测试的大型项目,考虑通过场景初始化器传递 GameState 实例,以避免隐藏的全局状态。
在willMove(from:)中进行清理:
override func willMove(from view: SKView) {
removeAllActions()
removeAllChildren()
physicsWorld.contactDelegate = nil
}
这是 SpriteKit 错误的首要来源。 物理位掩码使用 32 位系统,其中每一位代表一个类别。
struct PhysicsCategory {
static let none: UInt32 = 0
static let player: UInt32 = 0b0001 // 1
static let enemy: UInt32 = 0b0010 // 2
static let ground: UInt32 = 0b0100 // 4
static let projectile: UInt32 = 0b1000 // 8
static let powerUp: UInt32 = 0b10000 // 16
}
三个位掩码属性(全部默认为 0xFFFFFFFF — 与所有类别交互):
| 属性 | 用途 | 默认值 |
|---|---|---|
categoryBitMask | 此刚体属于哪个类别 | 0xFFFFFFFF |
collisionBitMask | 它与哪些类别发生碰撞并反弹 | 0xFFFFFFFF |
contactTestBitMask | 与哪些类别接触会触发委托回调 | 0x00000000 |
默认的collisionBitMask为 0xFFFFFFFF 意味着所有物体都会与所有物体发生碰撞。 这是意外物理行为的最常见来源。
// 正确:显式位掩码设置
player.physicsBody?.categoryBitMask = PhysicsCategory.player
player.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.enemy
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy | PhysicsCategory.powerUp
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy
enemy.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.player
enemy.physicsBody?.contactTestBitMask = PhysicsCategory.player | PhysicsCategory.projectile
对于每个物理刚体,验证:
categoryBitMask 设置为恰好一个类别collisionBitMask 仅设置为它应该反弹的类别(不是 0xFFFFFFFF)contactTestBitMask 设置为应触发委托回调的类别physicsWorld.contactDelegate = selfclass GameScene: SKScene, SKPhysicsContactDelegate {
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
}
func didBegin(_ contact: SKPhysicsContact) {
// 对刚体排序,使 bodyA 具有较低的类别值
let (first, second): (SKPhysicsBody, SKPhysicsBody)
if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
(first, second) = (contact.bodyA, contact.bodyB)
} else {
(first, second) = (contact.bodyB, contact.bodyA)
}
// 现在根据类别进行分发处理
if first.categoryBitMask == PhysicsCategory.player &&
second.categoryBitMask == PhysicsCategory.enemy {
guard let playerNode = first.node, let enemyNode = second.node else { return }
playerHitEnemy(player: playerNode, enemy: enemyNode)
}
}
}
修改规则:不能在 didBegin/didEnd 内部修改物理世界。设置标志并在 update(_:) 中应用更改。
var enemiesToRemove: [SKNode] = []
func didBegin(_ contact: SKPhysicsContact) {
// 标记为待移除 — 不要在此处移除
if let enemy = contact.bodyB.node {
enemiesToRemove.append(enemy)
}
}
override func update(_ currentTime: TimeInterval) {
for enemy in enemiesToRemove {
enemy.removeFromParent()
}
enemiesToRemove.removeAll()
}
| 类型 | 创建方式 | 响应力 | 用途 |
|---|---|---|---|
| 动态体积 | init(circleOfRadius:), init(rectangleOf:), init(texture:size:) | 是 | 玩家、敌人、抛射物 |
| 静态体积 | 动态刚体 + isDynamic = false | 否(但会发生碰撞) | 平台、墙壁 |
| 边缘 | init(edgeLoopFrom:), init(edgeFrom:to:) | 否(仅作为边界) | 屏幕边界、地形 |
// 使用边缘循环创建屏幕边界
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
// 基于纹理的刚体,用于不规则形状
guard let texture = enemy.texture else { return }
enemy.physicsBody = SKPhysicsBody(texture: texture, size: enemy.size)
// 圆形刚体,性能最佳(碰撞检测成本最低)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 5)
快速移动的物体可能会穿过薄墙。修复方法:
// 为快速移动的物体启用精确碰撞检测
bullet.physicsBody?.usesPreciseCollisionDetection = true
// 使墙壁足够厚(至少与最快物体每帧移动的距离一样宽)
// 在 60fps 下,速度为 600pt/s 的物体每帧移动 10pt
// 力:持续的(每帧施加,累积)
body.applyForce(CGVector(dx: 0, dy: 100))
// 冲量:瞬时速度变化(一次性,如跳跃)
body.applyImpulse(CGVector(dx: 0, dy: 50))
// 扭矩:持续旋转
body.applyTorque(0.5)
// 角冲量:瞬时旋转变化
body.applyAngularImpulse(1.0)
// 移动
let move = SKAction.move(to: CGPoint(x: 200, y: 300), duration: 1.0)
let moveBy = SKAction.moveBy(x: 100, y: 0, duration: 0.5)
// 旋转
let rotate = SKAction.rotate(byAngle: .pi * 2, duration: 1.0)
// 缩放
let scale = SKAction.scale(to: 2.0, duration: 0.3)
// 淡入淡出
let fadeOut = SKAction.fadeOut(withDuration: 0.5)
let fadeIn = SKAction.fadeIn(withDuration: 0.5)
// 序列:一个接一个执行
let moveAndFade = SKAction.sequence([
SKAction.move(to: target, duration: 1.0),
SKAction.fadeOut(withDuration: 0.3),
SKAction.removeFromParent()
])
// 组合:同时执行
let spinAndGrow = SKAction.group([
SKAction.rotate(byAngle: .pi * 2, duration: 1.0),
SKAction.scale(to: 2.0, duration: 1.0)
])
// 重复
let pulse = SKAction.repeatForever(SKAction.sequence([
SKAction.scale(to: 1.2, duration: 0.3),
SKAction.scale(to: 1.0, duration: 0.3)
]))
// 使用命名动作以便取消/替换
node.run(pulse, withKey: "pulse")
// 稍后,停止脉冲动作:
node.removeAction(forKey: "pulse")
// 检查是否正在运行:
if node.action(forKey: "pulse") != nil {
// 仍在脉冲中
}
// 错误:存在循环引用风险
node.run(SKAction.run {
self.score += 1 // 强捕获 self
})
// 正确:弱捕获
node.run(SKAction.run { [weak self] in
self?.score += 1
})
// 对于重复动作,始终使用弱引用 self
let spawn = SKAction.repeatForever(SKAction.sequence([
SKAction.run { [weak self] in self?.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
]))
scene.run(spawn, withKey: "enemySpawner")
action.timingMode = .linear // 恒定速度(默认)
action.timingMode = .easeIn // 从静止加速
action.timingMode = .easeOut // 减速到静止
action.timingMode = .easeInEaseOut // 平滑开始和结束
切勿使用动作来移动受物理控制的节点。 动作会覆盖物理模拟,导致抖动和错过碰撞。
// 错误:动作与物理冲突
playerNode.run(SKAction.moveTo(x: 200, duration: 0.5))
// 正确:对物理刚体使用力/冲量
playerNode.physicsBody?.applyImpulse(CGVector(dx: 50, dy: 0))
// 正确:对非物理节点使用动作(UI、特效、装饰)
hudLabel.run(SKAction.scale(to: 1.5, duration: 0.2))
// 关键:响应节点上的 isUserInteractionEnabled 必须为 true
// SKScene 默认启用;其他节点默认为 false
class Player: SKSpriteNode {
init() {
super.init(texture: SKTexture(imageNamed: "player"), color: .clear, size: CGSize(width: 50, height: 50))
isUserInteractionEnabled = true // 必需!
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 处理此特定节点上的触摸
}
}
// 触摸位置在场景坐标中(最常见)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let locationInScene = touch.location(in: self)
// 触摸位置在特定节点的坐标中
let locationInWorld = touch.location(in: worldNode)
// 命中测试:触摸了哪个节点?
let touchedNodes = nodes(at: locationInScene)
}
常见错误:使用 touch.location(in: self.view) 返回 UIKit 坐标(Y 轴翻转)。始终使用 touch.location(in: self) 获取场景坐标。
import GameController
func setupControllers() {
NotificationCenter.default.addObserver(
self, selector: #selector(controllerConnected),
name: .GCControllerDidConnect, object: nil
)
// 检查已连接的控制器
for controller in GCController.controllers() {
configureController(controller)
}
}
有关详细的性能诊断,请参阅 axiom-spritekit-diag 症状 3。关键优先级:
usesPreciseCollisionDetectionif let view = self.view as? SKView {
view.showsFPS = true
view.showsNodeCount = true
view.showsDrawCount = true
view.showsPhysics = true // 显示刚体轮廓
// 性能:渲染顺序优化
view.ignoresSiblingOrder = true
}
使用同一图集中纹理的精灵可以在一次绘制调用中渲染。
// 在 Xcode 中创建图集:Assets → New Sprite Atlas
// 或在项目中使用 .atlas 文件夹
let atlas = SKTextureAtlas(named: "Characters")
let texture = atlas.textureNamed("player_idle")
let sprite = SKSpriteNode(texture: texture)
// 预加载图集以避免帧率下降
SKTextureAtlas.preloadTextureAtlases([atlas]) {
// 图集准备就绪 — 呈现场景
}
每个 SKShapeNode 实例都会生成一次绘制调用。 它无法进行批处理。仅将其用于原型设计和调试可视化。
// 错误:100 个 SKShapeNode = 100 次绘制调用
for _ in 0..<100 {
let dot = SKShapeNode(circleOfRadius: 5)
addChild(dot)
}
// 正确:预渲染到纹理,使用 SKSpriteNode
let shape = SKShapeNode(circleOfRadius: 5)
shape.fillColor = .red
guard let texture = view?.texture(from: shape) else { return }
for _ in 0..<100 {
let dot = SKSpriteNode(texture: texture)
addChild(dot)
}
对于频繁生成/销毁的对象(子弹、粒子、敌人):
class BulletPool {
private var available: [SKSpriteNode] = []
private let texture: SKTexture
init(texture: SKTexture, initialSize: Int = 20) {
self.texture = texture
for _ in 0..<initialSize {
available.append(createBullet())
}
}
private func createBullet() -> SKSpriteNode {
let bullet = SKSpriteNode(texture: texture)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 3)
bullet.physicsBody?.categoryBitMask = PhysicsCategory.projectile
bullet.physicsBody?.collisionBitMask = PhysicsCategory.none
bullet.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
return bullet
}
func spawn() -> SKSpriteNode {
if available.isEmpty {
available.append(createBullet())
}
let bullet = available.removeLast()
bullet.isHidden = false
bullet.physicsBody?.isDynamic = true
return bullet
}
func recycle(_ bullet: SKSpriteNode) {
bullet.removeAllActions()
bullet.removeFromParent()
bullet.physicsBody?.isDynamic = false
bullet.physicsBody?.velocity = .zero
bullet.isHidden = true
available.append(bullet)
}
}
// 手动移除比 shouldCullNonVisibleNodes 更快
override func update(_ currentTime: TimeInterval) {
enumerateChildNodes(withName: "bullet") { node, _ in
if !self.frame.intersects(node.frame) {
self.bulletPool.recycle(node as! SKSpriteNode)
}
}
}
1. update(_:) ← 在此处编写游戏逻辑
2. didEvaluateActions() ← 动作完成
3. [Physics simulation] ← SpriteKit 运行物理模拟
4. didSimulatePhysics() ← 物理模拟完成,调整结果
5. [Constraint evaluation] ← 应用 SKConstraints
6. didApplyConstraints() ← 约束应用完成
7. didFinishUpdate() ← 渲染前的最后机会
8. [Rendering] ← 帧绘制
private var lastUpdateTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
let dt: TimeInterval
if lastUpdateTime == 0 {
dt = 0
} else {
dt = currentTime - lastUpdateTime
}
lastUpdateTime = currentTime
// 限制增量时间以防止死亡螺旋
// (当应用从后台返回时,dt 可能非常大)
let clampedDt = min(dt, 1.0 / 30.0)
updatePlayer(deltaTime: clampedDt)
updateEnemies(deltaTime: clampedDt)
}
// 暂停场景(停止动作、物理、更新循环)
scene.isPaused = true
// 仅暂停特定子树
worldNode.isPaused = true // 游戏暂停但 HUD 仍可动画
// 处理应用进入后台
NotificationCenter.default.addObserver(
self, selector: #selector(pauseGame),
name: UIApplication.willResignActiveNotification, object: nil
)
// 从 .sks 文件加载(在 Xcode 粒子编辑器中设计)
guard let emitter = SKEmitterNode(fileNamed: "Explosion") else { return }
emitter.position = explosionPoint
addChild(emitter)
// 关键:发射完成后自动移除
let duration = TimeInterval(emitter.numParticlesToEmit) / TimeInterval(emitter.particleBirthRate)
+ TimeInterval(emitter.particleLifetime + emitter.particleLifetimeRange / 2)
emitter.run(SKAction.sequence([
SKAction.wait(forDuration: duration),
SKAction.removeFromParent()
]))
如果没有 targetNode,粒子会随发射器移动。对于轨迹(如火箭尾焰),将 targetNode 设置为场景:
let trail = SKEmitterNode(fileNamed: "RocketTrail")!
trail.targetNode = scene // 粒子停留在发射位置
rocketNode.addChild(trail)
// 错误:无限发射器永远不会被清理
let fire = SKEmitterNode(fileNamed: "Fire")!
fire.numParticlesToEmit = 0 // 0 = 无限
addChild(fire)
// 内存泄漏 — 粒子永久累积
// 正确:设置发射限制或在完成后移除
fire.numParticlesToEmit = 200 // 发射 200 个粒子后停止
// 或手动停止并移除:
fire.particleBirthRate = 0 // 停止生成新粒子
fire.run(SKAction.sequence([
SKAction.wait(forDuration: TimeInterval(fire.particleLifetime)),
SKAction.removeFromParent()
]))
在 SwiftUI 中嵌入 SpriteKit 的最简单方法。除非需要自定义 SKView 配置,否则使用此方法。
import SpriteKit
import SwiftUI
struct GameView: View {
var body: some View {
SpriteView(scene: {
let scene = GameScene(size: CGSize(width: 390, height: 844))
scene.scaleMode = .aspectFill
return scene
}(), debugOptions: [.showsFPS, .showsNodeCount])
.ignoresSafeArea()
}
}
当需要完全控制 SKView 配置(自定义帧率、透明度或多个场景)时使用。
import SwiftUI
import SpriteKit
struct SpriteKitView: UIViewRepresentable {
let scene: SKScene
func makeUIView(context: Context) -> SKView {
let view = SKView()
view.showsFPS = true
view.showsNodeCount = true
view.ignoresSiblingOrder = true
return view
}
func updateUIView(_ view: SKView, context: Context) {
if view.scene == nil {
view.presentScene(scene)
}
}
}
当 SpriteKit 是 Metal 管线中的一个图层时,使用 SKRenderer:
let renderer = SKRenderer(device: metalDevice)
renderer.scene = gameScene
// 在 Metal 渲染循环中:
renderer.update(atTime: currentTime)
renderer.render(
withViewport: viewport,
commandBuffer: commandBuffer,
renderPassDescriptor: renderPassDescriptor
)
时间成本:调试幽灵碰撞 30-120 分钟
// 错误:默认 collisionBitMask 是 0xFFFFFFFF
let body = SKPhysicsBody(circleOfRadius: 10)
node.physicsBody = body
// 与所有物体碰撞 — 甚至包括不应该碰撞的物体
// 正确:始终显式设置所有三个掩码
body.categoryBitMask = PhysicsCategory.player
body.collisionBitMask = PhysicsCategory.ground
body.contactTestBitMask = PhysicsCategory.enemy
时间成本:疑惑为什么 didBegin 从不触发 30-60 分钟
// 错误:contactTestBitMask 默认为 0 — 永远不会触发接触
player.physicsBody?.categoryBitMask = PhysicsCategory.player
// 忘记了 contactTestBitMask!
// 正确:两个刚体都需要兼容的掩码
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy
时间成本:抖动和错过碰撞 1-3 小时
// 错误:SKAction.move 每帧覆盖物理位置
playerNode.run(SKAction.moveTo(x: 200, duration: 1.0))
// 刚体位置由动作设置,忽略力/碰撞
// 正确:对受物理控制的节点使用物理
playerNode.physicsBody?.applyForce(CGVector(dx: 100, dy: 0))
时间成本:诊断帧率下降数小时
每个 SKShapeNode 都是一个单独的绘制调用,无法批处理。50 个形状节点 = 50 次绘制调用。有关修复方法,请参阅第 6 节(SKShapeNode 陷阱)中的预渲染到纹理模式。
时间成本:内存泄漏,最终崩溃
// 错误:在重复动作中强捕获
node.run(SKAction.repeatForever(SKAction.sequence([
SKAction.run { self.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
])))
// 正确:弱捕获
node.run(SKAction.repeatForever(SKAction.sequence([
SKAction.run { [weak self] in self?.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
])))
categoryBitMask(非默认值)collisionBitMask(非 0xFFFFFFFF)contactTestBitMaskphysicsWorld.contactDelegatedidBegin/didEnd 回调内部没有修改世界usesPreciseCollisionDetectionSKAction.move/rotatewithKey: 以便取消SKAction.run 闭包使用 [weak self]ignoresSiblingOrder = truewillMove(from:) 清理动作、子节点、委托压力:截止日期压力,想要跳过系统化调试
错误方法:随机更改位掩码值,到处添加 0xFFFFFFFF,或禁用物理
正确方法(2-5 分钟):
showsPhysics — 验证刚体存在且重叠contactTestBitMask 包含 body B 的类别(或反之)physicsWorld.contactDelegate反驳模板:"让我运行 5 步位掩码检查清单。这需要 2 分钟,能发现 90% 的接触问题。随机更改会让情况更糟。"
压力:权威人士说"对我来说是 60fps,发布吧"
错误方法:未在最低规格设备上分析就发布
正确方法:
showsFPS, showsNodeCount, showsDrawCount反驳模板:"性能因设备而异。让我检查节点数和绘制调用 — 使用调试覆盖层只需 30 秒。如果计数低,我们就可以安全发布。"
压力:沉没成本 — 已经用 SKShapeNode 构建,不想重做
错误方法:发布带有 100 多个 SKShapeNode 导致帧率下降
正确方法:
showsDrawCount — 每个 SKShapeNode 增加一次绘制调用view.texture(from:) 转换一次,作为 SKSpriteNode 重用反驳模板:"每个 SKShapeNode 都是一个单独的绘制调用。转换为预渲染纹理是一个 15 分钟的重构,可以使帧率翻倍。来自图集的 SKSpriteNode = 所有精灵只需 1 次绘制调用。"
WWDC : 2014-608, 2016-610, 2017-609, 2013-502
文档 : /spritekit, /spritekit/skscene, /spritekit/skphysicsbody, /spritekit/maximizing-node-drawing-performance
技能 : axiom-spritekit-ref, axiom-spritekit-diag
每周安装次数
86
代码仓库
GitHub 星标数
601
首次出现
2026年2月5日
安全审计
已安装于
opencode79
gemini-cli78
codex74
github-copilot70
cursor70
kimi-cli67
Purpose : Build reliable SpriteKit games by mastering the scene graph, physics engine, action system, and rendering pipeline iOS Version : iOS 13+ (SwiftUI integration), iOS 11+ (SKRenderer) Xcode : Xcode 15+
Use this skill when:
Do NOT use this skill for:
axiom-scenekit)axiom-metal-migration-ref)axiom-swiftui-layout)SpriteKit uses a bottom-left origin with Y pointing up. This differs from UIKit (top-left, Y down).
SpriteKit: UIKit:
┌─────────┐ ┌─────────┐
│ +Y │ │ (0,0) │
│ ↑ │ │ ↓ │
│ │ │ │ +Y │
│(0,0)──→+X│ │ │ │
└─────────┘ └─────────┘
Anchor Points define which point on a sprite maps to its position. Default is (0.5, 0.5) (center).
// Common anchor point trap:
// Anchor (0, 0) = bottom-left of sprite is at position
// Anchor (0.5, 0.5) = center of sprite is at position (DEFAULT)
// Anchor (0.5, 0) = bottom-center (useful for characters standing on ground)
sprite.anchorPoint = CGPoint(x: 0.5, y: 0)
Scene anchor point maps the view's frame to scene coordinates:
(0, 0) — scene origin at bottom-left of view (default)(0.5, 0.5) — scene origin at center of viewEverything in SpriteKit is an SKNode in a tree hierarchy. Parent transforms propagate to children.
SKScene
├── SKCameraNode (viewport control)
├── SKNode "world" (game content layer)
│ ├── SKSpriteNode "player"
│ ├── SKSpriteNode "enemy"
│ └── SKNode "platforms"
│ ├── SKSpriteNode "platform1"
│ └── SKSpriteNode "platform2"
└── SKNode "hud" (UI layer, attached to camera)
├── SKLabelNode "score"
└── SKSpriteNode "healthBar"
zPosition controls draw order. Higher values render on top. Nodes at the same zPosition render in child array order (unless ignoresSiblingOrder is true).
// Establish clear z-order layers
enum ZLayer {
static let background: CGFloat = -100
static let platforms: CGFloat = 0
static let items: CGFloat = 10
static let player: CGFloat = 20
static let effects: CGFloat = 30
static let hud: CGFloat = 100
}
| Mode | Behavior | Use When |
|---|---|---|
.aspectFill | Fills view, crops edges | Full-bleed games (most games) |
.aspectFit | Fits in view, letterboxes | Puzzle games needing exact layout |
.resizeFill | Stretches to fill | Almost never — distorts |
.fill | Matches view size exactly | Scene adapts to any ratio |
class GameScene: SKScene {
override func sceneDidLoad() {
scaleMode = .aspectFill
// Design for a reference size, let aspectFill crop edges
}
}
Always use SKCameraNode for viewport control. Attach HUD elements to the camera so they don't scroll.
let camera = SKCameraNode()
camera.name = "mainCamera"
addChild(camera)
self.camera = camera
// HUD follows camera automatically
let scoreLabel = SKLabelNode(text: "Score: 0")
scoreLabel.position = CGPoint(x: 0, y: size.height / 2 - 50)
camera.addChild(scoreLabel)
// Move camera to follow player
let follow = SKConstraint.distance(SKRange(constantValue: 0), to: playerNode)
camera.constraints = [follow]
// Create layer nodes for organization
let worldNode = SKNode()
worldNode.name = "world"
addChild(worldNode)
let hudNode = SKNode()
hudNode.name = "hud"
camera?.addChild(hudNode)
// All gameplay objects go in worldNode
worldNode.addChild(playerSprite)
worldNode.addChild(enemySprite)
// All UI goes in hudNode (moves with camera)
hudNode.addChild(scoreLabel)
// Preload next scene for smooth transitions
guard let nextScene = LevelScene(fileNamed: "Level2") else { return }
nextScene.scaleMode = .aspectFill
let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(nextScene, transition: transition)
Data passing between scenes : Use a shared game state object, not node properties.
class GameState {
static let shared = GameState()
var score = 0
var currentLevel = 1
var playerHealth = 100
}
// In scene transition:
let nextScene = LevelScene(size: size)
// GameState.shared is already accessible
view?.presentScene(nextScene, transition: .fade(withDuration: 0.5))
Note : A singleton works for simple games. For larger projects with testing needs, consider passing a GameState instance through scene initializers to avoid hidden global state.
Cleanup inwillMove(from:):
override func willMove(from view: SKView) {
removeAllActions()
removeAllChildren()
physicsWorld.contactDelegate = nil
}
This is the #1 source of SpriteKit bugs. Physics bitmasks use a 32-bit system where each bit represents a category.
struct PhysicsCategory {
static let none: UInt32 = 0
static let player: UInt32 = 0b0001 // 1
static let enemy: UInt32 = 0b0010 // 2
static let ground: UInt32 = 0b0100 // 4
static let projectile: UInt32 = 0b1000 // 8
static let powerUp: UInt32 = 0b10000 // 16
}
Three bitmask properties (all default to 0xFFFFFFFF — everything):
| Property | Purpose | Default |
|---|---|---|
categoryBitMask | What this body IS | 0xFFFFFFFF |
collisionBitMask | What it BOUNCES off | 0xFFFFFFFF |
contactTestBitMask | What TRIGGERS delegate | 0x00000000 |
The defaultcollisionBitMask of 0xFFFFFFFF means everything collides with everything. This is the most common source of unexpected physics behavior.
// CORRECT: Explicit bitmask setup
player.physicsBody?.categoryBitMask = PhysicsCategory.player
player.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.enemy
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy | PhysicsCategory.powerUp
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy
enemy.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.player
enemy.physicsBody?.contactTestBitMask = PhysicsCategory.player | PhysicsCategory.projectile
For every physics body, verify:
categoryBitMask set to exactly one categorycollisionBitMask set to only categories it should bounce off (NOT 0xFFFFFFFF)contactTestBitMask set to categories that should trigger delegate callbacksphysicsWorld.contactDelegate = selfclass GameScene: SKScene, SKPhysicsContactDelegate {
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
}
func didBegin(_ contact: SKPhysicsContact) {
// Sort bodies so bodyA has the lower category
let (first, second): (SKPhysicsBody, SKPhysicsBody)
if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
(first, second) = (contact.bodyA, contact.bodyB)
} else {
(first, second) = (contact.bodyB, contact.bodyA)
}
// Now dispatch based on categories
if first.categoryBitMask == PhysicsCategory.player &&
second.categoryBitMask == PhysicsCategory.enemy {
guard let playerNode = first.node, let enemyNode = second.node else { return }
playerHitEnemy(player: playerNode, enemy: enemyNode)
}
}
}
Modification rule : You cannot modify the physics world inside didBegin/didEnd. Set flags and apply changes in update(_:).
var enemiesToRemove: [SKNode] = []
func didBegin(_ contact: SKPhysicsContact) {
// Flag for removal — don't remove here
if let enemy = contact.bodyB.node {
enemiesToRemove.append(enemy)
}
}
override func update(_ currentTime: TimeInterval) {
for enemy in enemiesToRemove {
enemy.removeFromParent()
}
enemiesToRemove.removeAll()
}
| Type | Created With | Responds to Forces | Use For |
|---|---|---|---|
| Dynamic volume | init(circleOfRadius:), init(rectangleOf:), init(texture:size:) | Yes | Players, enemies, projectiles |
| Static volume | Dynamic body + isDynamic = false | No (but collides) | Platforms, walls |
| Edge | init(edgeLoopFrom:), init(edgeFrom:to:) |
// Screen boundary using edge loop
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
// Texture-based body for irregular shapes
guard let texture = enemy.texture else { return }
enemy.physicsBody = SKPhysicsBody(texture: texture, size: enemy.size)
// Circle for performance (cheapest collision detection)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 5)
Fast-moving objects can pass through thin walls. Fix:
// Enable precise collision detection for fast objects
bullet.physicsBody?.usesPreciseCollisionDetection = true
// Make walls thick enough (at least as wide as fastest object moves per frame)
// At 60fps, an object at velocity 600pt/s moves 10pt/frame
// Force: continuous (applied per frame, accumulates)
body.applyForce(CGVector(dx: 0, dy: 100))
// Impulse: instant velocity change (one-time, like a jump)
body.applyImpulse(CGVector(dx: 0, dy: 50))
// Torque: continuous rotation
body.applyTorque(0.5)
// Angular impulse: instant rotation change
body.applyAngularImpulse(1.0)
// Movement
let move = SKAction.move(to: CGPoint(x: 200, y: 300), duration: 1.0)
let moveBy = SKAction.moveBy(x: 100, y: 0, duration: 0.5)
// Rotation
let rotate = SKAction.rotate(byAngle: .pi * 2, duration: 1.0)
// Scale
let scale = SKAction.scale(to: 2.0, duration: 0.3)
// Fade
let fadeOut = SKAction.fadeOut(withDuration: 0.5)
let fadeIn = SKAction.fadeIn(withDuration: 0.5)
// Sequence: one after another
let moveAndFade = SKAction.sequence([
SKAction.move(to: target, duration: 1.0),
SKAction.fadeOut(withDuration: 0.3),
SKAction.removeFromParent()
])
// Group: all at once
let spinAndGrow = SKAction.group([
SKAction.rotate(byAngle: .pi * 2, duration: 1.0),
SKAction.scale(to: 2.0, duration: 1.0)
])
// Repeat
let pulse = SKAction.repeatForever(SKAction.sequence([
SKAction.scale(to: 1.2, duration: 0.3),
SKAction.scale(to: 1.0, duration: 0.3)
]))
// Use named actions so you can cancel/replace them
node.run(pulse, withKey: "pulse")
// Later, stop the pulse:
node.removeAction(forKey: "pulse")
// Check if running:
if node.action(forKey: "pulse") != nil {
// Still pulsing
}
// WRONG: Retain cycle risk
node.run(SKAction.run {
self.score += 1 // Strong capture of self
})
// CORRECT: Weak capture
node.run(SKAction.run { [weak self] in
self?.score += 1
})
// For repeating actions, always use weak self
let spawn = SKAction.repeatForever(SKAction.sequence([
SKAction.run { [weak self] in self?.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
]))
scene.run(spawn, withKey: "enemySpawner")
action.timingMode = .linear // Constant speed (default)
action.timingMode = .easeIn // Accelerate from rest
action.timingMode = .easeOut // Decelerate to rest
action.timingMode = .easeInEaseOut // Smooth start and end
Never use actions to move physics-controlled nodes. Actions override the physics simulation, causing jittering and missed collisions.
// WRONG: Action fights physics
playerNode.run(SKAction.moveTo(x: 200, duration: 0.5))
// CORRECT: Use forces/impulses for physics bodies
playerNode.physicsBody?.applyImpulse(CGVector(dx: 50, dy: 0))
// CORRECT: Use actions for non-physics nodes (UI, effects, decorations)
hudLabel.run(SKAction.scale(to: 1.5, duration: 0.2))
// CRITICAL: isUserInteractionEnabled must be true on the responding node
// SKScene has it true by default; other nodes default to false
class Player: SKSpriteNode {
init() {
super.init(texture: SKTexture(imageNamed: "player"), color: .clear, size: CGSize(width: 50, height: 50))
isUserInteractionEnabled = true // Required!
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Handle touch on this specific node
}
}
// Touch location in SCENE coordinates (most common)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let locationInScene = touch.location(in: self)
// Touch location in a SPECIFIC NODE's coordinates
let locationInWorld = touch.location(in: worldNode)
// Hit test: what node was touched?
let touchedNodes = nodes(at: locationInScene)
}
Common mistake : Using touch.location(in: self.view) returns UIKit coordinates (Y-flipped). Always use touch.location(in: self) for scene coordinates.
import GameController
func setupControllers() {
NotificationCenter.default.addObserver(
self, selector: #selector(controllerConnected),
name: .GCControllerDidConnect, object: nil
)
// Check already-connected controllers
for controller in GCController.controllers() {
configureController(controller)
}
}
For detailed performance diagnosis, see axiom-spritekit-diag Symptom 3. Key priorities:
usesPreciseCollisionDetectionif let view = self.view as? SKView {
view.showsFPS = true
view.showsNodeCount = true
view.showsDrawCount = true
view.showsPhysics = true // Shows physics body outlines
// Performance: render order optimization
view.ignoresSiblingOrder = true
}
Sprites using textures from the same atlas render in a single draw call.
// Create atlas in Xcode: Assets → New Sprite Atlas
// Or use .atlas folder in project
let atlas = SKTextureAtlas(named: "Characters")
let texture = atlas.textureNamed("player_idle")
let sprite = SKSpriteNode(texture: texture)
// Preload atlas to avoid frame drops
SKTextureAtlas.preloadTextureAtlases([atlas]) {
// Atlas ready — present scene
}
SKShapeNode generates one draw call per instance. It cannot be batched. Use it for prototyping and debug visualization only.
// WRONG: 100 SKShapeNodes = 100 draw calls
for _ in 0..<100 {
let dot = SKShapeNode(circleOfRadius: 5)
addChild(dot)
}
// CORRECT: Pre-render to texture, use SKSpriteNode
let shape = SKShapeNode(circleOfRadius: 5)
shape.fillColor = .red
guard let texture = view?.texture(from: shape) else { return }
for _ in 0..<100 {
let dot = SKSpriteNode(texture: texture)
addChild(dot)
}
For frequently spawned/destroyed objects (bullets, particles, enemies):
class BulletPool {
private var available: [SKSpriteNode] = []
private let texture: SKTexture
init(texture: SKTexture, initialSize: Int = 20) {
self.texture = texture
for _ in 0..<initialSize {
available.append(createBullet())
}
}
private func createBullet() -> SKSpriteNode {
let bullet = SKSpriteNode(texture: texture)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 3)
bullet.physicsBody?.categoryBitMask = PhysicsCategory.projectile
bullet.physicsBody?.collisionBitMask = PhysicsCategory.none
bullet.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
return bullet
}
func spawn() -> SKSpriteNode {
if available.isEmpty {
available.append(createBullet())
}
let bullet = available.removeLast()
bullet.isHidden = false
bullet.physicsBody?.isDynamic = true
return bullet
}
func recycle(_ bullet: SKSpriteNode) {
bullet.removeAllActions()
bullet.removeFromParent()
bullet.physicsBody?.isDynamic = false
bullet.physicsBody?.velocity = .zero
bullet.isHidden = true
available.append(bullet)
}
}
// Manual removal is faster than shouldCullNonVisibleNodes
override func update(_ currentTime: TimeInterval) {
enumerateChildNodes(withName: "bullet") { node, _ in
if !self.frame.intersects(node.frame) {
self.bulletPool.recycle(node as! SKSpriteNode)
}
}
}
1. update(_:) ← Your game logic here
2. didEvaluateActions() ← Actions completed
3. [Physics simulation] ← SpriteKit runs physics
4. didSimulatePhysics() ← Physics done, adjust results
5. [Constraint evaluation] ← SKConstraints applied
6. didApplyConstraints() ← Constraints done
7. didFinishUpdate() ← Last chance before render
8. [Rendering] ← Frame drawn
private var lastUpdateTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
let dt: TimeInterval
if lastUpdateTime == 0 {
dt = 0
} else {
dt = currentTime - lastUpdateTime
}
lastUpdateTime = currentTime
// Clamp delta time to prevent spiral of death
// (when app returns from background, dt can be huge)
let clampedDt = min(dt, 1.0 / 30.0)
updatePlayer(deltaTime: clampedDt)
updateEnemies(deltaTime: clampedDt)
}
// Pause the scene (stops actions, physics, update loop)
scene.isPaused = true
// Pause specific subtree only
worldNode.isPaused = true // Game paused but HUD still animates
// Handle app backgrounding
NotificationCenter.default.addObserver(
self, selector: #selector(pauseGame),
name: UIApplication.willResignActiveNotification, object: nil
)
// Load from .sks file (designed in Xcode Particle Editor)
guard let emitter = SKEmitterNode(fileNamed: "Explosion") else { return }
emitter.position = explosionPoint
addChild(emitter)
// CRITICAL: Auto-remove after emission completes
let duration = TimeInterval(emitter.numParticlesToEmit) / TimeInterval(emitter.particleBirthRate)
+ TimeInterval(emitter.particleLifetime + emitter.particleLifetimeRange / 2)
emitter.run(SKAction.sequence([
SKAction.wait(forDuration: duration),
SKAction.removeFromParent()
]))
Without targetNode, particles move with the emitter. For trails (like rocket exhaust), set targetNode to the scene:
let trail = SKEmitterNode(fileNamed: "RocketTrail")!
trail.targetNode = scene // Particles stay where emitted
rocketNode.addChild(trail)
// WRONG: Infinite emitter never cleaned up
let fire = SKEmitterNode(fileNamed: "Fire")!
fire.numParticlesToEmit = 0 // 0 = infinite
addChild(fire)
// Memory leak — particles accumulate forever
// CORRECT: Set emission limit or remove when done
fire.numParticlesToEmit = 200 // Stops after 200 particles
// Or manually stop and remove:
fire.particleBirthRate = 0 // Stop new particles
fire.run(SKAction.sequence([
SKAction.wait(forDuration: TimeInterval(fire.particleLifetime)),
SKAction.removeFromParent()
]))
The simplest way to embed SpriteKit in SwiftUI. Use this unless you need custom SKView configuration.
import SpriteKit
import SwiftUI
struct GameView: View {
var body: some View {
SpriteView(scene: {
let scene = GameScene(size: CGSize(width: 390, height: 844))
scene.scaleMode = .aspectFill
return scene
}(), debugOptions: [.showsFPS, .showsNodeCount])
.ignoresSafeArea()
}
}
Use when you need full control over SKView configuration (custom frame rate, transparency, or multiple scenes).
import SwiftUI
import SpriteKit
struct SpriteKitView: UIViewRepresentable {
let scene: SKScene
func makeUIView(context: Context) -> SKView {
let view = SKView()
view.showsFPS = true
view.showsNodeCount = true
view.ignoresSiblingOrder = true
return view
}
func updateUIView(_ view: SKView, context: Context) {
if view.scene == nil {
view.presentScene(scene)
}
}
}
Use SKRenderer when SpriteKit is one layer in a Metal pipeline:
let renderer = SKRenderer(device: metalDevice)
renderer.scene = gameScene
// In your Metal render loop:
renderer.update(atTime: currentTime)
renderer.render(
withViewport: viewport,
commandBuffer: commandBuffer,
renderPassDescriptor: renderPassDescriptor
)
Time cost : 30-120 minutes debugging phantom collisions
// WRONG: Default collisionBitMask is 0xFFFFFFFF
let body = SKPhysicsBody(circleOfRadius: 10)
node.physicsBody = body
// Collides with EVERYTHING — even things it shouldn't
// CORRECT: Always set all three masks explicitly
body.categoryBitMask = PhysicsCategory.player
body.collisionBitMask = PhysicsCategory.ground
body.contactTestBitMask = PhysicsCategory.enemy
Time cost : 30-60 minutes wondering why didBegin never fires
// WRONG: contactTestBitMask defaults to 0 — no contacts ever fire
player.physicsBody?.categoryBitMask = PhysicsCategory.player
// Forgot contactTestBitMask!
// CORRECT: Both bodies need compatible masks
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy
Time cost : 1-3 hours of jittering and missed collisions
// WRONG: SKAction.move overrides physics position each frame
playerNode.run(SKAction.moveTo(x: 200, duration: 1.0))
// Physics body position is set by action, ignoring forces/collisions
// CORRECT: Use physics for physics-controlled nodes
playerNode.physicsBody?.applyForce(CGVector(dx: 100, dy: 0))
Time cost : Hours diagnosing frame drops
Each SKShapeNode is a separate draw call that cannot be batched. 50 shape nodes = 50 draw calls. See the pre-render-to-texture pattern in Section 6 (SKShapeNode Trap) for the fix.
Time cost : Memory leaks, eventual crash
// WRONG: Strong capture in repeating action
node.run(SKAction.repeatForever(SKAction.sequence([
SKAction.run { self.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
])))
// CORRECT: Weak capture
node.run(SKAction.repeatForever(SKAction.sequence([
SKAction.run { [weak self] in self?.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
])))
categoryBitMask (not default)collisionBitMask (not 0xFFFFFFFF)contactTestBitMask setphysicsWorld.contactDelegate is assigneddidBegin/didEnd callbacksusesPreciseCollisionDetectionSKAction.move/rotate on physics-controlled nodeswithKey: for cancellationSKAction.run closures use [weak self]ignoresSiblingOrder = true on SKViewwillMove(from:) cleans up actions, children, delegatesPressure : Deadline pressure to skip systematic debugging
Wrong approach : Randomly changing bitmask values, adding 0xFFFFFFFF everywhere, or disabling physics
Correct approach (2-5 minutes):
showsPhysics — verify bodies exist and overlapcontactTestBitMask on body A includes category of body B (or vice versa)physicsWorld.contactDelegate is setPush-back template : "Let me run the 5-step bitmask checklist. It takes 2 minutes and catches 90% of contact issues. Random changes will make it worse."
Pressure : Authority says "it runs at 60fps for me, ship it"
Wrong approach : Shipping without profiling on minimum-spec device
Correct approach :
showsFPS, showsNodeCount, showsDrawCountPush-back template : "Performance varies by device. Let me check node count and draw calls — takes 30 seconds with debug overlays. If counts are low, we're safe to ship."
Pressure : Sunk cost — already built with SKShapeNode, don't want to redo
Wrong approach : Shipping with 100+ SKShapeNodes causing frame drops
Correct approach :
showsDrawCount — each SKShapeNode adds a draw callview.texture(from:) to convert once, reuse as SKSpriteNodePush-back template : "Each SKShapeNode is a separate draw call. Converting to pre-rendered textures is a 15-minute refactor that can double frame rate. SKSpriteNode from atlas = 1 draw call for all of them."
WWDC : 2014-608, 2016-610, 2017-609, 2013-502
Docs : /spritekit, /spritekit/skscene, /spritekit/skphysicsbody, /spritekit/maximizing-node-drawing-performance
Skills : axiom-spritekit-ref, axiom-spritekit-diag
Weekly Installs
86
Repository
GitHub Stars
601
First Seen
Feb 5, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode79
gemini-cli78
codex74
github-copilot70
cursor70
kimi-cli67
LinkedIn广告健康度审计工具 - 25项检查清单,优化B2B广告效果与ROI
191 周安装
Markdown转Overseer任务工具:自动分解规划文档为可追踪开发任务
112 周安装
Pulumi TypeScript 技能:使用 TypeScript 和 Pulumi ESC 实现云基础设施即代码
134 周安装
Google Ads 账户深度分析与健康度审计工具 - 74项检查,自动生成优化报告
208 周安装
阿里云CDN OpenAPI自动化操作指南 - 域名管理、缓存刷新、HTTPS证书配置
129 周安装
SpriteKit 常见问题诊断指南:物理接触、帧率优化与内存泄漏排查
134 周安装
| No (boundary only) |
| Screen boundaries, terrain |