mcpField
A
axiom-spritekit

axiom-spritekit

github.com/charleswiltgen开发1.2k191 次安装今天更新
代码仓库

关于 axiom-spritekit

面向 SpriteKit 游戏开发者的实践指南,涵盖场景架构、物理、动作、输入与性能调优。重点说明常见错误及其处理方式。

主要功能

  1. 01场景图与坐标系
  2. 02场景转换与状态传递
  3. 03物理碰撞与位掩码
  4. 04动作和触控输入
  5. 05帧率与绘制优化

适用场景

  1. 01搭建游戏场景与摄像机
  2. 02排查物理接触未触发
  3. 03实现触控和场景切换
  4. 04优化游戏帧率

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-spritekit

安装后在 Claude Code 中输入/axiom-spritekit确认已加载。

详细介绍

SpriteKit 游戏开发指南

目的:通过掌握场景图、物理引擎、动作系统和渲染管线,构建可靠的 SpriteKit 游戏 iOS 版本:iOS 13+(SwiftUI 集成),iOS 11+(SKRenderer) Xcode:Xcode 15+

何时使用此技能

在以下情况下使用此技能:

  • 构建新的 SpriteKit 游戏或交互式模拟
  • 实现物理效果(碰撞、接触、力、关节)
  • 设置游戏架构(场景、图层、摄像机)
  • 优化帧率或减少绘制调用
  • 在游戏中实现触摸/输入处理
  • 管理场景切换和数据传递
  • 将 SpriteKit 与 SwiftUI 或 Metal 集成
  • 调试未触发的物理接触
  • 解决坐标系混淆问题

以下情况请勿使用此技能:

  • SceneKit 3D 渲染(axiom-scenekit)
  • GameplayKit 实体-组件系统
  • Metal 着色器编程(axiom-metal-migration-ref)
  • 通用 SwiftUI 布局(axiom-swiftui-layout)

1. 心智模型

坐标系

SpriteKit 使用左下角原点,Y 轴向上。这与 UIKit(左上角原点,Y 轴向下)不同。

SpriteKit:          UIKit:
┌─────────┐         ┌─────────┐
│    +Y    │         │  (0,0)  │
│    ↑     │         │    ↓    │
│    │     │         │    +Y   │
│(0,0)──→+X│        │    │    │
└─────────┘         └─────────┘

锚点定义精灵上与其 position 对应的点。默认值为 (0.5, 0.5)(中心)。

// 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)

场景锚点将视图的框架映射到场景坐标:

  • (0, 0) — 场景原点位于视图左下角(默认)
  • (0.5, 0.5) — 场景原点位于视图中心
节点树

SpriteKit 中的所有内容都是树形层级中的一个 SKNode。父节点的变换会传递给子节点。

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"
Z 轴顺序

zPosition 控制绘制顺序。值越高,渲染位置越靠上。同一 zPosition 上的节点按子节点数组中的顺序渲染(除非 ignoresSiblingOrder 为 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
}

2. 场景架构

缩放模式选择
模式行为适用场景
.aspectFill填满视图,裁切边缘全屏游戏(大多数游戏)
.aspectFit适应视图,留黑边需要精确布局的益智游戏
.resizeFill拉伸以填满视图几乎不会使用——会造成变形
.fill精确匹配视图大小场景需适应任意宽高比
class GameScene: SKScene {
    override func sceneDidLoad() {
        scaleMode = .aspectFill
        // Design for a reference size, let aspectFill crop edges
    }
}
摄像机节点模式

始终使用 SKCameraNode 控制视口。将 HUD 元素附加到摄像机上,使它们不会随画面滚动。

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)

场景间的数据传递:使用共享的游戏状态对象,而不是节点属性。

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))

注意:对于简单游戏,使用单例即可。对于需要测试的较大型项目,可以考虑通过场景初始化器传递 GameState 实例,以避免隐藏的全局状态。

在 willMove(from:) 中清理:

override func willMove(from view: SKView) {
    removeAllActions()
    removeAllChildren()
    physicsWorld.contactDelegate = nil
}

3. 物理引擎

位掩码规范

这是 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,意味着所有物体都会彼此碰撞。 这是最常见的意外物理行为来源。

// 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
位掩码检查清单

对于每个物理体,请确认:

  1. categoryBitMask 只设置为一个类别
  2. collisionBitMask 只设置为它应与之发生弹跳碰撞的类别(不要设为 0xFFFFFFFF)
  3. contactTestBitMask 设置为应触发代理回调的类别
  4. 已指定代理:physicsWorld.contactDelegate = self
接触检测
class 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)
        }
    }
}

修改规则:不能在 didBegin/didEnd 中修改物理世界。请设置标记,并在 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()
}
刚体类型
类型创建方式是否响应力用途
动态体积init(circleOfRadius:)、init(rectangleOf:)、init(texture:size:)是玩家、敌人、投射物
静态体积动态刚体 + isDynamic = false否(但会发生碰撞)平台、墙壁
边缘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)
防止穿透

高速移动的物体可能会穿过薄墙。解决方法:

// 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)

4. 动作系统

核心模式
// 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
}
使用弱引用 self 的自定义动作
// 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
动作与物理模拟

切勿使用动作移动由物理模拟控制的节点。 动作会覆盖物理模拟,导致抖动和漏判碰撞。

// 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))

5. 输入处理

触摸处理
// 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)
}

常见错误:使用 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
    )

    // Check already-connected controllers
    for controller in GCController.controllers() {
        configureController(controller)
    }
}

6. 性能

性能优化优先事项

如需详细的性能诊断,请参阅 axiom-spritekit-diag 的症状 3。主要优先事项:

  1. 节点数量 — 移除屏幕外节点,使用对象池
  2. 绘制调用 — 使用纹理图集,以预渲染纹理替换 SKShapeNode
  3. 物理开销 — 优先使用简单的刚体形状,限制 usesPreciseCollisionDetection
  4. 粒子 — 限制生成速率,设置有限的发射数量
调试叠加层(开发期间始终启用)
if 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
}
纹理图集批处理

使用同一图集纹理的精灵会在一次绘制调用中渲染。

// 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 的陷阱

每个 SKShapeNode 实例都会产生一次绘制调用。 它无法进行批处理。仅将其用于原型开发和调试可视化。

// 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)
}
对象池

对于频繁生成和销毁的对象(子弹、粒子、敌人):

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)
        }
    }
}

7. 游戏循环

帧周期(8 个阶段)
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
)

8. 粒子效果

发射器最佳实践
// 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()
]))
拖尾的目标节点

如果不设置 targetNode,粒子会随发射器移动。对于拖尾效果(如火箭尾焰),将 targetNode 设置为场景:

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()
]))

9. SwiftUI 集成

SpriteView(推荐,iOS 14+)

在 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()
    }
}
UIViewRepresentable(高级)

需要完全控制 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)
        }
    }
}
用于 Metal 混合渲染的 SKRenderer

当 SpriteKit 是 Metal 渲染管线中的一个图层时,使用 SKRenderer:

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
)

10. 反模式

反模式 1:默认位掩码

耗时:调试幽灵碰撞需 30-120 分钟

// 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
反模式 2:缺少 contactTestBitMask

耗时:花 30-60 分钟疑惑为什么 didBegin 从未触发

// 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
反模式 3:对物理刚体使用动作

耗时:花 1-3 小时排查抖动和漏判碰撞

// 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))
反模式 4:将 SKShapeNode 用于游戏逻辑

耗时:花数小时诊断帧率下降

每个 SKShapeNode 都会产生独立的绘制调用,无法进行批处理。50 个形状节点 = 50 次绘制调用。解决方法请参阅第 6 节中的预渲染到纹理模式(SKShapeNode 的陷阱)。

反模式 5:在动作闭包中强引用 self

耗时:内存泄漏,最终导致崩溃

// 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)
])))

11. 代码审查清单

Physics
  • 每个物理体都显式设置了 categoryBitMask(非默认值)
  • 每个物理体都显式设置了 collisionBitMask(非 0xFFFFFFFF)
  • 需要检测接触的物理体已设置 contactTestBitMask
  • 已指定 physicsWorld.contactDelegate
  • 不在 didBegin/didEnd 回调中修改世界
  • 快速移动的对象使用 usesPreciseCollisionDetection
Actions
  • 不在受物理系统控制的节点上使用 SKAction.move/rotate
  • 重复执行的动作使用 withKey:,以便取消
  • SKAction.run 闭包使用 [weak self]
  • 发射一次后移除单次发射器
Performance
  • 开发期间启用调试叠加层
  • 在 SKView 上设置 ignoresSiblingOrder = true
  • 游戏精灵中不使用 SKShapeNode(改用预渲染纹理)
  • 相关精灵使用纹理图集
  • 手动移除屏幕外节点
Scene Management
  • willMove(from:) 会清理动作、子节点和代理
  • 场景数据通过共享状态传递,而非节点属性
  • 使用摄像机控制视口

12. 压力场景

场景 1:“物理接触检测不起作用——今晚就要发布”

压力:截止日期带来的压力,导致跳过系统化调试

错误做法:随机更改位掩码值、到处添加 0xFFFFFFFF,或禁用物理系统

正确做法(2-5 分钟):

  1. 启用 showsPhysics——确认物理体存在且彼此重叠
  2. 打印两个物理体的全部三个位掩码
  3. 确认物理体 A 的 contactTestBitMask 包含物理体 B 的类别(反之亦然)
  4. 确认已设置 physicsWorld.contactDelegate
  5. 确认没有在回调中修改世界

回应模板:“让我按 5 步位掩码检查清单排查一下。这只需 2 分钟,就能找出 90% 的接触检测问题。随机更改只会让情况更糟。”

场景 2:“我的设备上帧率没问题”

压力:权威人士说:“我这里能跑到 60fps,直接发布吧。”

错误做法:未在最低规格设备上进行性能分析就发布

正确做法:

  1. 启用 showsFPS、showsNodeCount、showsDrawCount
  2. 在支持的最老设备上测试
  3. 如果节点数 >200 或绘制调用 >30,则进行调查
  4. 检查游戏过程中是否使用了 SKShapeNode
  5. 确认正在移除屏幕外节点

回应模板:“性能会因设备而异。让我检查一下节点数和绘制调用——启用调试叠加层只需 30 秒。如果数量不高,发布就没问题。”

场景 3:“直接用 SKShapeNode 吧,写代码更快”

压力:沉没成本——已经用 SKShapeNode 构建完成,不想返工

错误做法:发布包含 100 多个 SKShapeNode、导致帧率下降的版本

正确做法:

  1. 检查 showsDrawCount——每个 SKShapeNode 都会增加一次绘制调用
  2. 如果游戏过程中有 >20 个形状节点,则预渲染为纹理
  3. 使用 view.texture(from:) 转换一次,再作为 SKSpriteNode 重复使用
  4. 仅将 SKShapeNode 用于调试可视化

回应模板:“每个 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

评论

支持 Markdown