axiom-spritekit について
SpriteKit のゲーム開発者向け実践ガイドです。シーン構成、物理演算、アクション、入力、パフォーマンスを扱い、よくある問題と対処法も説明します。
主な機能
- 01シーングラフと座標系
- 02シーン遷移と状態の受け渡し
- 03物理接触とビットマスク
- 04アクションとタッチ入力
- 05フレームレートと描画負荷の最適化
活用シーン
- 01ゲームシーンとカメラの構成
- 02物理接触が検出されない問題の調査
- 03タッチ入力とシーン遷移の実装
- 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
}
3 つのビットマスクプロパティ(すべてのデフォルト値は 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
ビットマスクのチェックリスト
すべての物理ボディについて、次の点を確認します:
categoryBitMaskが正確に 1 つのカテゴリに設定されているcollisionBitMaskが衝突させるカテゴリだけに設定されている(0xFFFFFFFFは使用しない)contactTestBitMaskがデリゲートのコールバックを呼び出すカテゴリに設定されている- デリゲートが設定されている:
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
}
weak 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 を参照してください。主な優先事項は次のとおりです。
- ノード数 — 画面外のノードを削除し、オブジェクトプーリングを使用します
- 描画コール — テクスチャアトラスを使用し、SKShapeNode を事前レンダリング済みテクスチャに置き換えます
- 物理演算の負荷 — シンプルなボディ形状を優先し、
usesPreciseCollisionDetectionの使用を制限します - パーティクル — 発生率を制限し、有限の放出数を設定します
デバッグオーバーレイ(開発中は常に有効にする)
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
}
テクスチャアトラスによるバッチ処理
同じアトラスのテクスチャを使うスプライトは、1 回の描画コールでレンダリングされます。
// 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 はインスタンスごとに 1 回の描画コールを生成します。 バッチ処理できません。プロトタイピングとデバッグ表示にのみ使用してください。
// 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 以降)
SpriteKit を SwiftUI に埋め込む最も簡単な方法です。カスタムの 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 パイプラインの 1 レイヤーである場合は、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 の設定漏れ
時間的コスト:didBegin が一度も呼び出されない理由を調べるのに 30〜60 分
// 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:アクションクロージャでの strong 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. コードレビューのチェックリスト
物理演算
- すべての物理ボディで
categoryBitMaskが明示的に設定されている(デフォルト値ではない) - すべての物理ボディで
collisionBitMaskが明示的に設定されている(0xFFFFFFFFではない) - 接触検出が必要なボディで
contactTestBitMaskが設定されている -
physicsWorld.contactDelegateが割り当てられている -
didBegin/didEndコールバック内でワールドを変更していない - 高速で移動するオブジェクトで
usesPreciseCollisionDetectionを使用している
アクション
- 物理演算で制御するノードに
SKAction.move/rotateを使用していない - 繰り返し実行するアクションにキャンセル用の
withKey:を使用している -
SKAction.runのクロージャで[weak self]を使用している - 1回限りのエミッターを放出後に削除している
パフォーマンス
- 開発中にデバッグオーバーレイを有効にしている
- SKView で
ignoresSiblingOrder = trueを設定している - ゲームプレイ用スプライトに SKShapeNode を使用していない(事前レンダリングしたテクスチャを使用する)
- 関連するスプライトにテクスチャアトラスを使用している
- 画面外のノードを手動で削除している
シーン管理
-
willMove(from:)でアクション、子ノード、デリゲートをクリーンアップしている - シーンデータをノードのプロパティではなく、共有状態を介して渡している
- ビューポートの制御にカメラを使用している
12. プレッシャーのかかる状況
シナリオ 1:「物理演算の接触が機能しない — 今夜リリース」
プレッシャー:体系的なデバッグを省略したくなる締め切りのプレッシャー
誤った対応:ビットマスクの値を手当たり次第に変更したり、あちこちに 0xFFFFFFFF を追加したり、物理演算を無効にしたりする
正しい対応(2~5分):
showsPhysicsを有効にし、ボディが存在して重なっていることを確認する- 両方のボディについて、3つすべてのビットマスクを出力する
- ボディ A の
contactTestBitMaskにボディ B のカテゴリが含まれている(またはその逆)ことを確認する physicsWorld.contactDelegateが設定されていることを確認する- コールバック内でワールドを変更していないことを確認する
異議を唱える際のテンプレート:「5段階のビットマスクチェックリストを実行させてください。2分で終わり、接触の問題の90%を特定できます。手当たり次第に変更すると、状況が悪化します。」
シナリオ 2:「自分のデバイスではフレームレートに問題ない」
プレッシャー:責任者が「自分の環境では60fpsで動いているから、リリースして」と言う
誤った対応:最小スペックのデバイスでプロファイリングせずにリリースする
正しい対応:
showsFPS、showsNodeCount、showsDrawCountを有効にする- 対応対象の中で最も古いデバイスでテストする
- ノードが200個超、またはドローコールが30回超の場合は調査する
- ゲームプレイで SKShapeNode を使用していないか確認する
- 画面外のノードが削除されていることを確認する
異議を唱える際のテンプレート:「パフォーマンスはデバイスによって異なります。ノード数とドローコールを確認させてください。デバッグオーバーレイがあれば30秒で済みます。数値が低ければ、安心してリリースできます。」
シナリオ 3:「SKShapeNode を使えばいい。コードを書くのが速いから」
プレッシャー:すでに SKShapeNode で構築しており、やり直したくないというサンクコスト
誤った対応:フレーム落ちの原因となる SKShapeNode を100個以上使ったままリリースする
正しい対応:
showsDrawCountを確認する — SKShapeNode ごとにドローコールが1回追加される- ゲームプレイでシェイプノードが20個を超える場合は、テクスチャに事前レンダリングする
view.texture(from:)を使って一度変換し、SKSpriteNode として再利用する- SKShapeNode はデバッグ表示にのみ使用する
異議を唱える際のテンプレート:「SKShapeNode ごとにドローコールが1回発生します。事前レンダリングしたテクスチャへの変換は15分程度のリファクタリングで、フレームレートが2倍になる可能性があります。アトラスの 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