๊ฐœ์š”

์ด ๋ชจ๋“ˆ์€ SwiftUI ์œ„์— ๋ Œ๋”๋ง ๋ฃจํ”„๋ฅผ ๊ฐ€์ง„ ์ž์‹ ๋ทฐ๋ฅผ ์–น๋Š” ์„ธ ๊ฐ€์ง€ ํŒจํ„ด์„ ํ•œ ๊ณณ์—์„œ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค.

Framework ํ†ตํ•ฉ ๋ฐฉ๋ฒ• ๋‹ค๋ฃจ๋Š” ๋Œ€์ƒ
SpriteKit SpriteView(scene:) 2D ๋…ธ๋“œ + ๋ฌผ๋ฆฌ
SceneKit SceneView(scene:) 3D ์”ฌ ๊ทธ๋ž˜ํ”„
CoreAnimation (CAEmitterLayer) UIViewRepresentable 2D ํŒŒํ‹ฐํด

์„ธ ํ”„๋ ˆ์ž„์›Œํฌ์˜ ๊ณตํ†ต์ ์€ ์”ฌ์„ ํ•œ ๋ฒˆ ์ƒ์„ฑํ•ด ์žฌ์‚ฌ์šฉํ•˜๊ณ , SwiftUI๋Š” ๊ทธ ์”ฌ์„ ํ™”๋ฉด์— ๋‹ด๋Š” ์ปจํ…Œ์ด๋„ˆ ์—ญํ• ๋งŒ ํ•œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. SwiftUI์˜ @State๋กœ ์”ฌ์„ ๋ณด์œ ํ•˜๋ฉด ๋ทฐ๊ฐ€ ๋‹ค์‹œ ๊ทธ๋ ค์ ธ๋„ ๊ฐ™์€ ์”ฌ์„ ์œ ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

@State private var scene: GravityScene = {
    let scene = GravityScene()
    scene.scaleMode = .resizeFill
    return scene
}()

var body: some View {
    SpriteView(scene: scene)
}

์ด ๋ชจ๋“ˆ์˜ 4๊ฐœ ๋ฐ๋ชจ๋Š” ์œ„ ์„ธ ํ”„๋ ˆ์ž„์›Œํฌ์˜ ๋Œ€ํ‘œ์  ํ™œ์šฉ์„ ํ•˜๋‚˜์”ฉ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.


1. Gravity Balls (SpriteKit)

๋ฌด์—‡์„ ๋ฐฐ์šฐ๋‚˜ โ€” SKPhysicsBody์™€ physicsWorld.gravity๋ฅผ ์ด์šฉํ•œ 2D ๋ฌผ๋ฆฌ ์‹œ๋ฎฌ๋ ˆ์ด์…˜, ๊ทธ๋ฆฌ๊ณ  CoreMotion์œผ๋กœ ๋””๋ฐ”์ด์Šค ๊ธฐ์šธ๊ธฐ์— ๋”ฐ๋ผ ์ค‘๋ ฅ ๋ฐฉํ–ฅ์„ ํšŒ์ „์‹œํ‚ค๋Š” ํ†ตํ•ฉ.

final class GravityScene: SKScene {
    override func didMove(to view: SKView) {
        // ํ™”๋ฉด ๊ฒฝ๊ณ„๋ฅผ ์ถฉ๋Œ ๋ฒฝ์œผ๋กœ
        physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
        physicsWorld.gravity = CGVector(dx: 0, dy: -9.8)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let t = touches.first else { return }
        let ball = SKShapeNode(circleOfRadius: 20)
        ball.position = t.location(in: self)
        let body = SKPhysicsBody(circleOfRadius: 20)
        body.restitution = 0.7        // ํƒ„์„ฑ
        ball.physicsBody = body
        addChild(ball)
    }
}

CoreMotion ํ†ตํ•ฉ โ€” ๋””๋ฐ”์ด์Šค ์ขŒํ‘œ โ†’ ํ™”๋ฉด ์ขŒํ‘œ ๋งคํ•‘์ด ํ•ต์‹ฌ์ž…๋‹ˆ๋‹ค. SpriteKit์€ lower-left origin์ด๋ผ ์ธํ„ฐํŽ˜์ด์Šค ํšŒ์ „์— ๋”ฐ๋ผ ๋ณ€ํ™˜์ด ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค.

motionManager.startAccelerometerUpdates(to: .main) { data, _ in
    guard let d = data else { return }
    let v = OrientationAwareGravity.spriteKitVector(
        deviceX: d.acceleration.x,
        deviceY: d.acceleration.y
    )
    self.physicsWorld.gravity = CGVector(dx: v.dx * 9.8, dy: v.dy * 9.8)
}

2. Solar System (SceneKit)

๋ฌด์—‡์„ ๋ฐฐ์šฐ๋‚˜ โ€” SCNScene/SCNNode์˜ ๊ณ„์ธต์  ํŠธ๋žœ์Šคํผ์œผ๋กœ ๋ถ€๋ชจ-์ž์‹ ๊ด€๊ณ„๋ฅผ ๋งŒ๋“ค๊ณ , SCNAction์œผ๋กœ ๊ณต์ „ยท์ž์ „์„ ํ‘œํ˜„ํ•˜๋Š” ํŒจํ„ด.

let scene = SCNScene()

// ํƒœ์–‘
let sun = SCNNode(geometry: SCNSphere(radius: 1.0))
sun.geometry?.firstMaterial?.diffuse.contents = UIColor.yellow
scene.rootNode.addChildNode(sun)

// ์ง€๊ตฌ์˜ "๊ณต์ „ ์ถ•" โ€” ํƒœ์–‘์— ๋ถ€์ฐฉ๋œ ๋นˆ ๋…ธ๋“œ
let earthOrbit = SCNNode()
sun.addChildNode(earthOrbit)

// ์ง€๊ตฌ ๋ณธ์ฒด โ€” ๊ณต์ „ ์ถ•์˜ ์ž์‹
let earth = SCNNode(geometry: SCNSphere(radius: 0.3))
earth.position = SCNVector3(3, 0, 0)         // ๊ณต์ „ ์ถ•์—์„œ ๊ฑฐ๋ฆฌ 3
earthOrbit.addChildNode(earth)

// ๊ณต์ „ ์ถ•์ด ํšŒ์ „ โ†’ ์ง€๊ตฌ๊ฐ€ ํƒœ์–‘ ์ฃผ๋ณ€์„ ๋ˆ๋‹ค
let orbit = SCNAction.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 6)
earthOrbit.runAction(.repeatForever(orbit))

// ์ž์ „
let spin = SCNAction.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 1)
earth.runAction(.repeatForever(spin))

ํ•ต์‹ฌ ๊ฐœ๋…: ๋นˆ ๋…ธ๋“œ(SCNNode())๋ฅผ ํšŒ์ „ ์ถ•์œผ๋กœ ์‚ฌ์šฉํ•ด, ์ž์‹ ๋…ธ๋“œ์˜ ์œ„์น˜๋ฅผ ์˜ฎ๊ธฐ๋ฉด ๊ทธ ๊ฑฐ๋ฆฌ์—์„œ ๊ณต์ „์ด ๋ฉ๋‹ˆ๋‹ค. ๋‹ฌ๋„ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ์ง€๊ตฌ์˜ ์ž์‹ ๋…ธ๋“œ โ†’ ์ง€๊ตฌ ์ž์ฒด๊ฐ€ ์›€์ง์—ฌ๋„ ๋‹ฌ์€ ๋”ฐ๋ผ์˜ต๋‹ˆ๋‹ค.


3. Weather (CAEmitterLayer)

๋ฌด์—‡์„ ๋ฐฐ์šฐ๋‚˜ โ€” CAEmitterLayer + CAEmitterCell๋กœ GPU ๊ฐ€์† ํŒŒํ‹ฐํด์„ ์„ค์ • ๊ธฐ๋ฐ˜์œผ๋กœ ๋งŒ๋“œ๋Š” ํŒจํ„ด.

let emitter = CAEmitterLayer()
emitter.emitterShape    = .line
emitter.emitterPosition = CGPoint(x: bounds.midX, y: 0)
emitter.emitterSize     = CGSize(width: bounds.width, height: 1)

let snow = CAEmitterCell()
snow.contents       = UIImage(named: "snowflake")?.cgImage
snow.birthRate      = 30          // ์ดˆ๋‹น 30๊ฐœ
snow.lifetime       = 8           // 8์ดˆ ์‚ด์•„ ์žˆ์Œ
snow.scale          = 0.05
snow.scaleRange     = 0.03
snow.velocity       = 80
snow.velocityRange  = 30
snow.yAcceleration  = 20          // ๋–จ์–ด์ง€๋Š” ๊ฐ€์†๋„
snow.spinRange      = .pi
snow.emissionRange  = .pi / 8     // ์‚ด์ง ํ”๋“ค๋ฆผ

emitter.emitterCells = [snow]
view.layer.addSublayer(emitter)

๊ธฐ์–ตํ•ด์•ผ ํ•  ๊ณต์‹:

ํ™”๋ฉด์— ํ‰๊ท ์ ์œผ๋กœ ์กด์žฌํ•˜๋Š” ํŒŒํ‹ฐํด ์ˆ˜ โ‰ˆ birthRate ร— lifetime

๋ฐ๋ชจ๋Š” segmented picker๋กœ ๋ˆˆ / ๋น„ / ๋ฒš๊ฝƒ ์„ธ ๋ชจ๋“œ๋ฅผ ์ „ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ™์€ API์— cell์˜ contentsยทvelocityยทyAcceleration๋งŒ ๋ฐ”๊พธ๋ฉด ๋‹ค๋ฅธ ํšจ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค.


4. Confetti (CAEmitterLayer)

๋ฌด์—‡์„ ๋ฐฐ์šฐ๋‚˜ โ€” ๋ฒ„ํŠผ ํŠธ๋ฆฌ๊ฑฐ๋กœ ์ผํšŒ์„ฑ ํญ๋ฐœ์„ ๋งŒ๋“ค๊ณ , 3D ํ…€๋ธ”๋ง ํšจ๊ณผ๋ฅผ ์œ„ํ•ด ํšŒ์ „ ์†์„ฑ์„ ์„ค์ •ํ•˜๋Š” ํŒจํ„ด.

func fire() {
    cell.birthRate = 200       // ์ž ๊น ํญ๋ฐœ
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
        cell.birthRate = 0     // ๋” ์ด์ƒ ์ƒ์„ฑ ์•ˆ ํ•จ (์ด๋ฏธ ๋– ์žˆ๋Š” ์ž…์ž๋Š” lifetime๊นŒ์ง€ ์œ ์ง€)
    }
}

cell.spin       = 0
cell.spinRange  = .pi * 4    // ๋‹ค์–‘ํ•œ ๊ฐ์†๋„๋กœ ํšŒ์ „ โ†’ 3D ํ…€๋ธ”๋ง ๋А๋‚Œ
cell.color      = UIColor.systemPink.cgColor
cell.redRange   = 0.6        // ์ƒ‰์ƒ ๋ณ€์ด
cell.greenRange = 0.6
cell.blueRange  = 0.6

continuous ๋ชจ๋“œ ํ† ๊ธ€์€ birthRate๋ฅผ ์ผ์ • ๊ฐ’์œผ๋กœ ์œ ์ง€/0์œผ๋กœ ํ† ๊ธ€ํ•˜๋Š” ์ฐจ์ด์ž…๋‹ˆ๋‹ค.


ํ•™์ƒ ํŒ

  1. ์”ฌ์€ @State 1๋ฒˆ ์ƒ์„ฑ โ€” body ์•ˆ์—์„œ ๋งค๋ฒˆ SKScene() ๋งŒ๋“ค๋ฉด ๋งค ํ”„๋ ˆ์ž„ ์ƒˆ๋กœ ๋งŒ๋“ค์–ด์ง‘๋‹ˆ๋‹ค. ๋ฐ˜๋“œ์‹œ @State ํ”„๋กœํผํ‹ฐ์— ํ•œ ๋ฒˆ๋งŒ ๋ณด๊ด€.
  2. ์ขŒํ‘œ๊ณ„ ํ•จ์ •:
    • SpriteKit: ์ขŒํ•˜๋‹จ ์›์ 
    • SceneKit: ์šฐ์† ์ขŒํ‘œ๊ณ„ (Y ์œ„, Z ํ™”๋ฉด ์•ž)
    • CAEmitterLayer: UIKit๊ณผ ๋™์ผํ•œ ์ขŒ์ƒ๋‹จ ์›์ 
  3. ์„ฑ๋Šฅ ํ•œ๊ณ„:
    • CAEmitterLayer โ€” GPU์—์„œ ์ˆ˜์ฒœ ๊ฐœ OK
    • SpriteKit โ€” ์ˆ˜๋ฐฑ ๊ฐœ ๋ฌผ๋ฆฌ body๋ถ€ํ„ฐ CPU ๋ณ‘๋ชฉ ์‹œ์ž‘
    • SceneKit โ€” ๋‹จ์ˆœ ์”ฌ์€ ๊ฐ€๋ณ์ง€๋งŒ ๊ทธ๋ฆผ์žยท๋ฐ˜์‚ฌ ์ผœ๋ฉด ๋น ๋ฅด๊ฒŒ ๋ฌด๊ฑฐ์›Œ์ง
  4. ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ํ•œ๊ณ„ โ€” SceneKit์˜ Metal ์…ฐ์ด๋” ๋ชจ๋””ํŒŒ์ด์–ด๋Š” ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ ์ž˜ ์•ˆ ๋ณด์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์‹ค๊ธฐ๊ธฐ ๊ฒ€์ฆ ํ•„์ˆ˜.