๐ŸŽฒ RealityKit

์‚ฌ์‹ค์ ์ธ 3D ๋ Œ๋”๋ง๊ณผ AR ๊ฒฝํ—˜

iOS 13+visionOS ์ตœ์ ํ™”

โœจ RealityKit์ด๋ž€?

RealityKit์€ Apple์˜ ๊ณ ์„ฑ๋Šฅ 3D ๋ Œ๋”๋ง ์—”์ง„์œผ๋กœ, ARKit๊ณผ ํ†ตํ•ฉ๋˜์–ด ์‚ฌ์‹ค์ ์ธ AR ๊ฒฝํ—˜์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ๋ฌผ๋ฆฌ ์—”์ง„, ์• ๋‹ˆ๋ฉ”์ด์…˜, ๊ณต๊ฐ„ ์˜ค๋””์˜ค, ์‚ฌ์‹ค์ ์ธ ๋ Œ๋”๋ง ๋“ฑ์„ ์ง€์›ํ•˜๋ฉฐ, SwiftUI์™€ ์™„๋ฒฝํ•˜๊ฒŒ ํ†ตํ•ฉ๋ฉ๋‹ˆ๋‹ค.

๐Ÿ’ก ํ•ต์‹ฌ ๊ธฐ๋Šฅ: PBR ๋ Œ๋”๋ง ยท ๋ฌผ๋ฆฌ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ยท ์• ๋‹ˆ๋ฉ”์ด์…˜ ยท ๊ณต๊ฐ„ ์˜ค๋””์˜ค ยท Entity Component System ยท USD ์ง€์› ยท Reality Composer ยท visionOS ์ง€์›

๐ŸŽฏ 1. ๊ธฐ๋ณธ ์”ฌ ์„ค์ •

RealityKit์œผ๋กœ 3D ์”ฌ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

RealityKitView.swift โ€” ๊ธฐ๋ณธ ์„ค์ •
import SwiftUI
import RealityKit

struct RealityKitView: View {
    var body: some View {
        RealityView { content in
            // 3D ๋ฐ•์Šค ์ƒ์„ฑ
            let mesh = MeshResource.generateBox(size: 0.3)

            // PBR ๋จธํ‹ฐ๋ฆฌ์–ผ (Physically Based Rendering)
            var material = PhysicallyBasedMaterial()
            material.baseColor = .init(tint: .blue)
            material.roughness = .init(floatLiteral: 0.3)
            material.metallic = .init(floatLiteral: 0.8)

            // ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ
            let entity = ModelEntity(mesh: mesh, materials: [material])

            // ์œ„์น˜ ์„ค์ •
            entity.position = [0, 0, -0.5]

            // ์”ฌ์— ์ถ”๊ฐ€
            content.add(entity)
        }
    }
}

๐ŸŽจ 2. ๋จธํ‹ฐ๋ฆฌ์–ผ๊ณผ ํ…์Šค์ฒ˜

๋‹ค์–‘ํ•œ ๋จธํ‹ฐ๋ฆฌ์–ผ๋กœ ์‚ฌ์‹ค์ ์ธ ํ‘œ๋ฉด์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

Materials.swift โ€” ๋จธํ‹ฐ๋ฆฌ์–ผ ์„ค์ •
import RealityKit

class MaterialsManager {
    // 1. ๋ฌผ๋ฆฌ ๊ธฐ๋ฐ˜ ๋จธํ‹ฐ๋ฆฌ์–ผ (PBR)
    func createMetalMaterial() -> PhysicallyBasedMaterial {
        var material = PhysicallyBasedMaterial()

        // ๊ธฐ๋ณธ ์ƒ‰์ƒ
        material.baseColor = .init(tint: .gray)

        // ๊ธˆ์†์„ฑ (0.0 = ๋น„๊ธˆ์†, 1.0 = ์™„์ „ ๊ธˆ์†)
        material.metallic = .init(floatLiteral: 1.0)

        // ๊ฑฐ์น ๊ธฐ (0.0 = ๋งค๋„๋Ÿฌ์›€, 1.0 = ๊ฑฐ์นจ)
        material.roughness = .init(floatLiteral: 0.2)

        return material
    }

    // 2. ๋‹จ์ˆœ ๋จธํ‹ฐ๋ฆฌ์–ผ
    func createSimpleMaterial() -> SimpleMaterial {
        var material = SimpleMaterial()
        material.color = .init(tint: .red, texture: nil)
        return material
    }

    // 3. ํ…์Šค์ฒ˜ ์ ์šฉ
    func createTexturedMaterial() async throws -> PhysicallyBasedMaterial {
        var material = PhysicallyBasedMaterial()

        // ์ด๋ฏธ์ง€์—์„œ ํ…์Šค์ฒ˜ ๋กœ๋“œ
        if let texture = try? await TextureResource(named: "wood.jpg") {
            material.baseColor = .init(texture: .init(texture))
        }

        return material
    }

    // 4. ๋ฐœ๊ด‘ ๋จธํ‹ฐ๋ฆฌ์–ผ
    func createEmissiveMaterial() -> PhysicallyBasedMaterial {
        var material = PhysicallyBasedMaterial()

        // ๋ฐœ๊ด‘ ์ƒ‰์ƒ ๋ฐ ๊ฐ•๋„
        material.emissiveColor = .init(color: .yellow)
        material.emissiveIntensity = 2.0

        return material
    }

    // 5. ํˆฌ๋ช… ๋จธํ‹ฐ๋ฆฌ์–ผ
    func createTransparentMaterial() -> PhysicallyBasedMaterial {
        var material = PhysicallyBasedMaterial()
        material.baseColor = .init(tint: .blue.withAlphaComponent(0.5))
        material.blending = .transparent
        return material
    }
}

๐ŸŽฌ 3. ์• ๋‹ˆ๋ฉ”์ด์…˜

์—”ํ‹ฐํ‹ฐ์— ์›€์ง์ž„์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

Animations.swift โ€” ์• ๋‹ˆ๋ฉ”์ด์…˜
import RealityKit

extension Entity {
    // 1. ํšŒ์ „ ์• ๋‹ˆ๋ฉ”์ด์…˜
    func addRotationAnimation() {
        // 360๋„ ํšŒ์ „
        let rotation = Transform(
            pitch: 0,
            yaw: .Float.pi * 2,
            roll: 0
        )

        // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ƒ์„ฑ
        let rotationAnimation = FromToByAnimation(
            to: rotation,
            duration: 3.0,
            timing: .linear,
            isAdditive: true
        )

        // ๋ฌดํ•œ ๋ฐ˜๋ณต
        let animation = try? AnimationResource.generate(with: rotationAnimation)
        if let animation {
            playAnimation(animation.repeat())
        }
    }

    // 2. ์ด๋™ ์• ๋‹ˆ๋ฉ”์ด์…˜
    func moveAnimation(to position: SIMD3<Float>, duration: TimeInterval) {
        var transform = self.transform
        transform.translation = position

        move(
            to: transform,
            relativeTo: parent,
            duration: duration,
            timingFunction: .easeInOut
        )
    }

    // 3. ์Šค์ผ€์ผ ์• ๋‹ˆ๋ฉ”์ด์…˜
    func pulseAnimation() {
        let originalScale = scale
        let targetScale = originalScale * 1.2

        let scaleUp = FromToByAnimation(
            to: Transform(scale: targetScale),
            duration: 0.5,
            timing: .easeInOut
        )

        let scaleDown = FromToByAnimation(
            to: Transform(scale: originalScale),
            duration: 0.5,
            timing: .easeInOut
        )

        // ์‹œํ€€์Šค ์• ๋‹ˆ๋ฉ”์ด์…˜
        if let animation = try? AnimationResource.sequence([
            .generate(with: scaleUp),
            .generate(with: scaleDown)
        ].compactMap { $0 }) {
            playAnimation(animation.repeat())
        }
    }

    // 4. ๋ฐ”์šด์Šค ์• ๋‹ˆ๋ฉ”์ด์…˜
    func bounceAnimation() {
        let originalY = position.y
        let jumpHeight: Float = 0.3

        var upTransform = transform
        upTransform.translation.y += jumpHeight

        // ์œ„๋กœ
        move(to: upTransform, relativeTo: parent, duration: 0.5, timingFunction: .easeOut)

        // ์ž ์‹œ ํ›„ ์•„๋ž˜๋กœ
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
            guard let self else { return }
            var downTransform = self.transform
            downTransform.translation.y = originalY
            self.move(to: downTransform, relativeTo: parent, duration: 0.5, timingFunction: .easeIn)
        }
    }
}

โš™๏ธ 4. ๋ฌผ๋ฆฌ ์—”์ง„

ํ˜„์‹ค์ ์ธ ๋ฌผ๋ฆฌ ์‹œ๋ฎฌ๋ ˆ์ด์…˜์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

Physics.swift โ€” ๋ฌผ๋ฆฌ ์‹œ๋ฎฌ๋ ˆ์ด์…˜
import RealityKit

class PhysicsManager {
    // 1. ๋™์  ๋ฌผ๋ฆฌ (์ค‘๋ ฅ ์ ์šฉ)
    func addDynamicPhysics(to entity: Entity) {
        // ์ถฉ๋Œ ํ˜•ํƒœ ์ƒ์„ฑ
        entity.generateCollisionShapes(recursive: true)

        // ๋™์  ๋ฌผ๋ฆฌ ๋ฐ”๋”” (์ค‘๋ ฅ ์ ์šฉ)
        entity.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
            massProperties: .default,
            mode: .dynamic
        )
    }

    // 2. ์ •์  ๋ฌผ๋ฆฌ (๊ณ ์ •๋œ ๊ฐ์ฒด)
    func addStaticPhysics(to entity: Entity) {
        entity.generateCollisionShapes(recursive: true)

        // ์ •์  ๋ฌผ๋ฆฌ ๋ฐ”๋”” (์›€์ง์ด์ง€ ์•Š์Œ)
        entity.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
            mode: .static
        )
    }

    // 3. ์ปค์Šคํ…€ ์งˆ๋Ÿ‰๊ณผ ๋งˆ์ฐฐ๋ ฅ
    func createCustomPhysicsBody() -> PhysicsBodyComponent {
        // ์งˆ๋Ÿ‰ ์†์„ฑ
        let mass: Float = 2.0
        let massProperties = PhysicsMassProperties(mass: mass)

        // ๋ฌผ๋ฆฌ ๋จธํ‹ฐ๋ฆฌ์–ผ (๋งˆ์ฐฐ, ํƒ„์„ฑ)
        let physicsMaterial = PhysicsMaterialResource.generate(
            friction: .init(staticFriction: 0.5, dynamicFriction: 0.3),
            restitution: 0.8 // ํƒ„์„ฑ (0 = ํก์ˆ˜, 1 = ์™„์ „ ๋ฐ˜๋ฐœ)
        )

        return PhysicsBodyComponent(
            massProperties: massProperties,
            material: physicsMaterial,
            mode: .dynamic
        )
    }

    // 4. ํž˜ ์ ์šฉ
    func applyForce(to entity: Entity, force: SIMD3<Float>) {
        guard var physicsBody = entity.components[PhysicsBodyComponent.self] else { return }

        // ํž˜ ์ ์šฉ
        physicsBody.applyImpulse(force, relativeTo: nil)
        entity.components[PhysicsBodyComponent.self] = physicsBody
    }

    // 5. ์ถฉ๋Œ ๊ฐ์ง€
    func setupCollisionDetection(for entity: Entity) {
        // ์ถฉ๋Œ ๊ทธ๋ฃน ์„ค์ •
        entity.components[CollisionComponent.self] = CollisionComponent(
            shapes: [.generateBox(size: [0.1, 0.1, 0.1])],
            mode: .trigger,
            filter: .init(group: .all, mask: .all)
        )
    }
}

๐Ÿ”Š 5. ๊ณต๊ฐ„ ์˜ค๋””์˜ค

3D ๊ณต๊ฐ„์—์„œ ์‚ฌ์‹ค์ ์ธ ์˜ค๋””์˜ค๋ฅผ ์žฌ์ƒํ•ฉ๋‹ˆ๋‹ค.

SpatialAudio.swift โ€” ๊ณต๊ฐ„ ์˜ค๋””์˜ค
import RealityKit

class SpatialAudioManager {
    // ๊ณต๊ฐ„ ์˜ค๋””์˜ค ์ถ”๊ฐ€
    func addSpatialAudio(to entity: Entity, audioFile: String) async {
        // ์˜ค๋””์˜ค ๋ฆฌ์†Œ์Šค ๋กœ๋“œ
        guard let audioResource = try? await AudioFileResource(named: audioFile) else {
            print("์˜ค๋””์˜ค ๋กœ๋“œ ์‹คํŒจ")
            return
        }

        // ๊ณต๊ฐ„ ์˜ค๋””์˜ค ์ปจํŠธ๋กค๋Ÿฌ
        let audioController = entity.prepareAudio(audioResource)

        // ์žฌ์ƒ ์˜ต์…˜
        audioController.gain = 1.0
        audioController.fade(to: .max, duration: 1.0)

        // ์žฌ์ƒ
        audioController.play()
    }

    // ๋ฐ˜๋ณต ์žฌ์ƒ
    func playLoopingAudio(entity: Entity, audioFile: String) async {
        guard let resource = try? await AudioFileResource(named: audioFile) else { return }

        let controller = entity.playAudio(resource)
        controller.isLooping = true
    }
}

๐Ÿ“ฑ SwiftUI ํ†ตํ•ฉ ์˜ˆ์ œ

RealityKitDemoView.swift โ€” ์ข…ํ•ฉ ๋ฐ๋ชจ
import SwiftUI
import RealityKit

struct RealityKitDemoView: View {
    @State private var selectedShape: Shape = .box

    enum Shape: String, CaseIterable {
        case box = "๋ฐ•์Šค"
        case sphere = "๊ตฌ"
        case cylinder = "์›๊ธฐ๋‘ฅ"
    }

    var body: some View {
        VStack {
            // 3D ๋ทฐ
            RealityView { content in
                // ์ดˆ๊ธฐ ์”ฌ ์„ค์ •
                setupScene(content)
            } update: { content in
                // ์„ ํƒ๋œ ๋„ํ˜•์œผ๋กœ ์—…๋ฐ์ดํŠธ
                updateShape(content, shape: selectedShape)
            }

            // ์ปจํŠธ๋กค
            Picker("๋„ํ˜• ์„ ํƒ", selection: $selectedShape) {
                ForEach(Shape.allCases, id: \.self) { shape in
                    Text(shape.rawValue).tag(shape)
                }
            }
            .pickerStyle(.segmented)
            .padding()
        }
    }

    func setupScene(_ content: RealityViewContent) {
        // ์กฐ๋ช… ์ถ”๊ฐ€
        let light = PointLight()
        light.position = [0, 0.5, 0]
        light.light.intensity = 1000
        content.add(light)

        // ์ดˆ๊ธฐ ๋„ํ˜•
        let entity = createShape(.box)
        content.add(entity)
    }

    func updateShape(_ content: RealityViewContent, shape: Shape) {
        // ๊ธฐ์กด ์—”ํ‹ฐํ‹ฐ ์ œ๊ฑฐ
        content.entities.forEach { entity in
            if entity is ModelEntity {
                content.remove(entity)
            }
        }

        // ์ƒˆ ๋„ํ˜• ์ถ”๊ฐ€
        let entity = createShape(shape)
        content.add(entity)
    }

    func createShape(_ shape: Shape) -> ModelEntity {
        let mesh: MeshResource

        switch shape {
        case .box:
            mesh = .generateBox(size: 0.2)
        case .sphere:
            mesh = .generateSphere(radius: 0.1)
        case .cylinder:
            mesh = .generateCylinder(height: 0.2, radius: 0.1)
        }

        var material = PhysicallyBasedMaterial()
        material.baseColor = .init(tint: .blue)
        material.metallic = 0.8
        material.roughness = 0.2

        let entity = ModelEntity(mesh: mesh, materials: [material])
        entity.position = [0, 0, -0.5]

        // ํšŒ์ „ ์• ๋‹ˆ๋ฉ”์ด์…˜
        entity.addRotationAnimation()

        return entity
    }
}

๐Ÿ’ก HIG ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค์ „ ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ Reality Composer: ์ฝ”๋“œ ์—†์ด AR ์”ฌ์„ ๋””์ž์ธํ•  ์ˆ˜ ์žˆ๋Š” Apple์˜ ํˆด์ž…๋‹ˆ๋‹ค. Reality Composer๋กœ ๋งŒ๋“  .rcproject ํŒŒ์ผ์„ RealityKit์—์„œ ๋ฐ”๋กœ ๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.