Портал, манулы и мячи: опыт разработки для Apple Vision Pro. Часть 1

В статье описан мой опыт разработки мини-игр для Apple Vision Pro в условиях жёсткого ограничения во времени. Расскажу, с какими сложностями я столкнулся в ходе работы с 3D-моделями, и поделюсь способами их преодоления. Лайфхаки для упрощения работы с RealityViewContent и Reality Composer Pro прилагаются.

Об авторе

Илья Проскуряков — iOS-разработчик в компании Effective, опыт работы 1,5 года. Участник конференций KODE Waves и DevFest.

Предыстория: хакатон

13–14 апреля 2024 г. Омск принял участие в 55-м Ludum Dare — всемирном двухдневном хакатоне по разработке игр. Мы с коллегами выбрали для работы нетипичный объект — очки Apple Vision Pro, которые незадолго до этого появились у нас в компании. Очки одни, нас трое — позже объясню, почему это уточнение важно.

Технология для рынка новая, информации для разработчика о ней немного –, но тем интереснее!

У нас было полторы недели, чтобы придумать, с какой идеей мы придём на хакатон. В итоге остановились на ОСУ, но для глаз. ОСУ — это тип игр на скорость, в которых пользователь кликает по простой движущейся цели. Вместо мышки у нас были глаза, потому что Apple Vision Pro умеет отслеживать их движение. 

Однако нашу идею нужно было связать и с общей идеей хакатона, которая становится известна только в день старта. В этот раз ей стал summoning — призыв. Нас вдохновил один из конкурентов, решивший сделать игру о коте, которого нужно звать к миске. Что бы мы делали без котов (на самом деле, о таком варианте событий я тоже расскажу)!  

Мы запланировали создать мини-игру, в центре которой будет — хороший, я считаю, маркетинговый ход! — мемный манул. Стек: Swift, фреймворки SwiftUI, ARKit и RealityKit. 

  • RealityKit позволяет рендерить 3D-объекты и взаимодействовать с их физикой, геометрией и другими свойствами;

  • ARKit помогает отслеживать всё происходящее вокруг: движения рук пользователя, различные плоскости и мир в целом. ARKit можно назвать подспорьем RealityKit на visionOS.

Начинаем с меню

Дизайн главного меню скромный, но примерно так выглядит любой 2D-экран под visionOS. Зато его можно менять в размерах или перемещать в пространстве. Например, перенести из комнаты в комнату, где экран и останется даже в следующих сеансах.

По факту, вы пишете на обычном фреймворке SwiftUI. Кодить в нём под visionOS — всё равно что писать под iOS: такие же VStack, модификаторы, паддинги и спейсеры.

    var body: some View {
        NavigationStack {
            VStack {
                CenteredTitle("Summon a Cat!")
                    .padding(.vertical, 10)
                Spacer()
                VStack {
                     CatTypeSelection(viewModel: viewModel)
                     PlayButton(showImmersiveSpace: $showImmersiveSpace)
                     AboutButton {
                         isShowingAboutView = true
                     }
                 }
                 .padding(.bottom, 20)
                 .frame(maxHeight: 540)
                Spacer()
            }
struct PlayButton: View {
    @Binding var showImmersiveSpace: Bool
    var body: some View {
        VStack {
            Text(showImmersiveSpace ? "Stop" : "Play")
                .font(.title)
                .fontWeight(.bold)
                .foregroundColor(.white)
                .frame(width: 340, height: 110)
                .background(showImmersiveSpace ? Color.red : Color.green)
                .cornerRadius(20)
        }
        .onTapGesture {
            showImmersiveSpace.toggle()
        }
        .hoverEffect()
        .clipShape(RoundedRectangle(cornerRadius: 20))
        .padding(.top, 50)
    }
}

Чтобы окончательно убедиться в верности своих наблюдений, я запустил этот код на iOS — и ничего не сломалось! Получается, если написать один код под разные платформы, можно получить функционально одинаковый результат.

Меню игры на iPhone и на Apple Vision Pro.

Меню игры на iPhone и на Apple Vision Pro.

Мини-игра № 1 «Впылесось манула»

Надев гарнитуру, пользователь обнаруживает себя с пылесосом в руке. Вокруг него вокруг своей оси вращается множество манулов — их-то и нужно втянуть в пылесос.

Счёт идёт до десяти манулов, после чего перед пользователем из ниоткуда возникает гигантский — и очень недовольный — манул. 

Вообще, изначально мы хотели, чтобы манул появлялся из портала, но на реализацию этой идеи немного не хватило времени. Дальше объясню почему.

Создаём и наполняем пространство

Разработку мини-игры мы начали с создания пространства для дополненной реальности.

Для этого в первую очередь нужно объявить ImmersiveSpace, дополненное пространство, и задать ему ID.

struct LudumDare55App: App {
    
    @StateObject private var foodEncounterViewModel = FoodEncounterView.ViewModel()
    @StateObject private var viewModel = AppViewModel()
    @State private var immersionStyle: ImmersionStyle = .mixed
    @State var audioPlayer: AVAudioPlayer!
    var body: some Scene {
        WindowGroup {
            ScrollView {
                ContentView(viewModel: viewModel)
                    .frame(minWidth: 640, minHeight: 500)
                    .onAppear() {
                        let sound = Bundle.main.path(forResource: "ДИКИЕ РЫСИ[music]", ofType: "mp3")
                        self.audioPlayer = try! AVAudioPlayer(contentsOf: URL(fileURLWithPath: sound!))
                        self.audioPlayer.numberOfLoops = -1
                        self.audioPlayer.volume = 0.3 // Set the volume to half the maximum volume
                        self.audioPlayer.play()
                    }
                FoodEncounterView()
                    .environmentObject(foodEncounterViewModel)
            }
        }
        ImmersiveSpace(id: "ImmersiveSpace") {
            ImmersiveView(viewModel: viewModel)
        }

За время работы в visionOS я сделал следующее наблюдение: единовременно в приложении может быть отображено только одно ImmersiveSpace. Environment-переменные — openImmersiveSpace и dismissImmersiveSpace — открывают и закрывают это пространство. Эти функции асинхронные, потому их нужно вызывать через await. Также в функцию нужно передать ID — и готово!

@Environment(\.openImmersiveSpace) private var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
.onChange(of: showImmersiveSpace) { _, newValue in
                Task {
                    if newValue {
                        switch await openImmersiveSpace(id: "PortalSpace") {
                        case .opened:
                            immersiveSpaceIsShown = true
                        case .error, .userCancelled:
                            fallthrough
                        @unknown default:
                            immersiveSpaceIsShown = false
                            showImmersiveSpace = false
                        }
                    } else if immersiveSpaceIsShown {
                        await dismissImmersiveSpace()
                        immersiveSpaceIsShown = false
                    }
                }
            }

Следующий шаг — наполнить созданное пространство контентом.

В closure ImmersiveSpace находим ImmersiveView — стандартную вьюшку SwiftUI, в которую нужно положить RealityView. У RealityView в closure есть content — inout-параметр типа RealityViewContent (в него помещаются 3D-модели), а также attachments — 2D-вьюшки, которые прикрепляются к 3D-объектам. Так, счётчик впылесошенных манулов, расположенный на ручке пылесоса, сделан через attachments.

Здесь же находится важная функция update, которая вызывается на смену кадра, позволяя изменять пространство с течением времени.

RealityView { content, attachments in
            await realityKitSceneController.firstInit(&content, attachments: attachments, catType: viewModel.catType)
        } update: { content, attachments in
            realityKitSceneController.updateView(&content, attachments: attachments)
        } placeholder: {
            ProgressView()
        } attachments: {
            let _ = print("--attachments")
            Attachment(id: "score") {
                let goodScore = forTrailingZero(realityKitSceneController.score)
                Text("\(goodScore)")
                    .font(.system(size: 100))
                    .foregroundColor(.white)
                    .fontWeight(.bold)
            }
        }

RealityViewContent — это структура, которая может отвечать за всё наполнение вашего пространства. Наполнять её приходится из разных частей кода, что неудобно: для этого нужно передавать эту структуру как inout.

Я нашёл решение, позволяющее вынести логику заполнения в отдельную сущность и упростить процесс.

В realityKit есть class Entity, который выполняет похожие функции по заполнению пространства контентом. Алгоритм такой:

  1. Создать корневую пустую 3D-вьюшку rootEntity;

  2. Положить её в контент с помощью метода add(content.add(rootEntity));

  3. Положить в rootEntity непустые Entity, которые будут содержать ваши 3D-модели:

rootEntity.addChild(entity)

Заполнение дополненной реальности контентом.

Заполнение дополненной реальности контентом.

Сотворение мира

Прежде чем создать портал, мы создали мир, который будет виден за ним. Это первый элемент, а всего их три:

  1. Мир, который будет отображаться внутри портала;

  2. Сущность портала — чёрный круг;

  3. Якорь — сущность, к которой прикрепляются все объекты. Им могут быть руки пользователя, стены помещения, пол, столы и т.д. 

Дальше как в сказке: разработчик кладёт мир в портал, портал — в якорь, а потом все три сущности кладёт в контент.

Чтобы появился мир, нужно создать сущность и задать ей соответствующее свойство. За него отвечает компонент World Component. Он отделяет всё, что лежит снаружи портала, от того, что находится у него внутри. С этим компонентом мир будет лежать именно в портале. 

public func makeWorld() -> Entity {
    let world = Entity()
    world.components[WorldComponent.self] = .init()
    let earth = try! Entity.load(named: "solarSystem", in: realityKitContentBundle)
    world.addChild(earth)
    return world
}

Чтобы добавить контент, функция load загружает ассет Solar System, заранее настроенный  в Reality Composer Pro.

У Apple есть инструмент Reality Composer Pro, помогающий упростить подготовку 3D-контента для приложений под visionOS. Он напоминает редактор сцен Unity.

Работа в Reality Composer Pro.

Работа в Reality Composer Pro.

Сначала нам понадобятся объекты, которые нужно отобразить: 3D-модели в формате USD. 

На этом этапе мы столкнулись с одной из существенных проблем при разработке игр под visionOS — с поиском 3D-моделей. Вариантов их получения немного: купить готовую (диапазон цен от 2 до 2000 долларов), создать самому (если умеешь) или поискать бесплатные. В Reality Composer Pro есть небольшой набор бесплатных ассетов, но я сосредоточил поиск на сообществах, сайте TurboSqiud и телеграм-чатах.

Когда 3D-модели найдены, их нужно импортировать в сцену: просто перетащить либо на панель слева, либо прямо на сцену. Затем — расположить на сцене. 

224292c3721e63d2852c08f1c1b9cde6.png

Положение объекта задается координатами на трех осях — x, y, z.

Неочевидный момент: чтобы понять, куда направлена каждая из координат, сделайте такой жест: большой палец — ось x, указательный — ось y, и средний — ось z.

ЗДЕСЬ БУДЕТ РУКА

Объект передвигается по этим осям относительно наблюдателя: при перемещении вправо — по оси х, при перемещении вверх — по оси у и так далее.

Я не знал об этом жесте, поэтому поначалу действовал наугад, постоянно перезапуская проект для проверки.

Также в Reality Composer Pro можно менять размер 3D-модели, вращать и разворачивать её. Можно добавлять к объекту компоненты: освещение, тени, коллизии, физику, звуки. Например, от большого манула может идти рычание, а от портала — трансовая музыка.

Открываем портал

Чтобы создать портал, нужно сделать сущность и настроить у неё Portal Component, в который мы поместим мир, и Model Component — внешний вид этой сущности, то есть чёрный круг. Этого достаточно для его работы. 

public func makePortal(world: Entity) -> Entity {
    let portal = Entity()
    let emitters = try! Entity.load(named: "Particle", in: realityKitContentBundle)
    emitters.scale = SIMD3(x: 1, y: 1, z: 1)
    portal.components[ModelComponent.self] = .init(mesh: .generatePlane(width: 1,
                                                                        height: 1,
                                                                        cornerRadius: 0.5),
                                                   materials: [PortalMaterial()])
    portal.components[PortalComponent.self] = .init(target: world)
    portal.addChild(emitters)
    return portal
}

Портал, созданный Apple. Под видео даже есть секция Code, однако у меня он не сработал. Пришлось всё делать с нуля.

Портал, созданный Apple. Под видео даже есть секция Code, однако у меня он не сработал. Пришлось всё делать с нуля.

Изначально портал был горизонтальным, а потом я повернул его на 90 градусов. Это повлекло за собой разворот всей системы координат для мира внутри портала, поэтому для «правила трёх пальцев»‎ положение руки изменилось соответственно повороту портала. 

Однако просто чёрный портал — это скучно. Нам хотелось красоты, и через Reality Composer Pro мы добавили её с помощью Particles.

В Particles можно настроить, как часто будут пульсировать частицы, их вид, количество, форму и цвет.

Вручение пылесоса

Ещё одна интересная задача в этой мини-игре — прикрепление пылесоса к руке пользователя. Также пылесос должен взаимодействовать с вращающимися манулами.

Начинаем с загрузки ассетов. Настраиваем коллизии через Collision-компоненту — маску и группу. Маска отвечает за то, с какими группами будет взаимодействовать пылесос, а группа — за то, к какой группе относится объект. Collision-компонента задается битовой маской. 

if let handlePart = scene.findEntity(named: "handlePart") {
                handlePart.components[CollisionComponent.self]?.filter.mask = manulCollisionGroup
                handlePart.components[CollisionComponent.self]?.filter.group = vacuumCollisionGroup
  
if let headPart = scene.findEntity(named: "headPart") {
                headPart.components[CollisionComponent.self]?.filter.mask = manulCollisionGroup
                headPart.components[CollisionComponent.self]?.filter.group = vacuumCollisionGroup

Создаём Collision Group, куда передаётся битовая операция, — и получается битовая маска.

    private var manulCollisionGroup = CollisionGroup(rawValue: 1 << 0)
    private var vacuumCollisionGroup = CollisionGroup(rawValue: 1 << 1)

Чтобы рука игрока могла »‎взять»‎ пылесос, первым делом нужно научиться следить за руками пользователя. Для этого создаём сессию ARKit session.

  private var worldTracking = WorldTrackingProvider()
  private var handTracking = HandTrackingProvider()
  private var sceneReconstruction = SceneReconstructionProvider(modes: [.classification])
  private var session = ARKitSession()

Следите за руками!  

У сессии есть метод run, который в качестве параметра принимает массив DataProvider. Для отслеживания движений рук используется Hand Tracker Provider.

 setupTask = Task {
            do {
                try await session.run([worldTracking, handTracking, sceneReconstruction])
            } catch {
                print("Error Can't start ARKit \(error)")
            }
        }

Выбираем правую руку и получаем её якорь с параметром originFromAnchorTransform — локацию руки относительно мира. Её мы присвоили ручке пылесоса. 

        if handTracking.state == .running,
           let rightHand = handTracking.latestAnchors.rightHand,
           rightHand.isTracked,

let transform = Transform(matrix: rightHand.originFromAnchorTransform)
handlePartModel?.position = transform.translation

Также через метод Look нужно настроить, куда будет смотреть ручка пылесоса: позиция объекта задаётся через поле position, а метод look настраивает то, куда будет направлен объект.

handlePartModel?.look(at: globalDirectionPoint3, from: transform.translation, relativeTo: controllerRoot)

Кот, который не гуляет сам по себе

И вообще не гуляет, а вращается вокруг своей оси. Как мы закрутили манулов?

  1. Создали собственный кастомный компонент — структуру, которая будет конформить протокол Component, — задали в нём нужные поля и зарегистрировали. 

struct RotateComponent: Component {
    var isCollecting: Bool = false
    var animationProgress: Float  = 0.0
    var startPositionY: Float?
    var endPositionY: Float?
}
  1. Для взаимодействия с этим компонентом нужна система. Поэтому мы создали класс, законформили протокол System, — и у него появилась возможность переопределить метод Update.
    Update вызывается каждый раз на обновление фрейма, а частота его вызова зависит от частоты обновления кадров в visionOS. Для очков это ~90 Гц, соответственно, обновление будет происходить 90 раз в секунду. 

  1. Нужно найти сущность, которая соответствует определённому параметру (у нас это та, у которой есть компонент Rotate Component), изменить значение её поля orientation — и объект начнёт вращаться.

 func update(context: SceneUpdateContext) {
        let results = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
        for result in results {
            if var component = result.components[RotateComponent.self] {
                let speedMultiplier: Float = component.isCollecting ? 10.0 : 1.0
                result.orientation = result.orientation * simd_quatf(angle: speedMultiplier * Float(context.deltaTime), axis: .init(x: 0.0, y: 0.0, z: 1.0))

Важно не забыть зарегистрировать и компонент, и систему! Для этого нужно где-нибудь вызвать соотвествующие функции.

RotateSystem.registerSystem()
RotateComponent.registerComponent()

На этом наша работа над первой мини-игрой завершилась.

Мини-игра № 2: «Покорми манула (не собой)!» 

Во второй игре пользователю нужно задобрить большого манула. Сделать это несложно, ведь манул, как и все коты, любит вкусно поесть. 

Механика простая: в пространстве вокруг игрока летают бургеры и помидоры, и он специальным жестом ловит бургеры. Помидоры ловить нельзя, иначе манул разозлится и съест игрока.

С точки зрения кода эта мини-игра проще игры про манулов и пылесос. Чтобы наполнить мир вокруг игрока бургерами и помидорами, нужно вызвать функции Add Burger и Add Tomatoes столько раз, сколько бургеров или помидоров в пространстве мы хотим.

var body: some View {
        // RealityView to display augmented reality content
        RealityView { content in
            // Add immersive scene to the content
            if let scene = try? await Entity(named: "ImmersiveScene", in: realityKitContentBundle) {
                content.add(scene)
            }
            
            // Add content entities and food
            content.add(foodModel.setupContentEntity())
            
            // Add food  based on foodMax
            for index in 0..

Также нужно задать стандартный жест для Apple Vision Pro, при котором указательный и большой пальцы касаются друг друга. Это легко делается через SpatialTapGesture () (строка 23). Его модификатор .targetedToAnyEntity () позволяет этому жесту взаимодействовать с любыми объектами, которые находятся в Immersive View.

Функция Add Burger простая: грузим ассет с моделью бургера и добавляем компоненты:

  • Input target component, который позволяет пользователю взаимодействовать с объектом, у которого есть этот компонент;

  • Hover эффект, выделяющий объект, на который смотрит пользователь. Что-то вроде кнопки, на которую наведён курсор.

func addBurger(name: String) -> Entity {
        do {
            let entity = try ModelEntity.load(named: "burger.usdz", in: realityKitContentBundle)
            entity.generateCollisionShapes(recursive: true)
            entity.name = name
            entity.components.set(InputTargetComponent(allowedInputTypes: .indirect))
            entity.components.set(HoverEffectComponent())
            
            entity.position = getRandomPosition()
            
            contentEntity.addChild(entity)
            
            return entity
        } catch {
            return Entity()
        }
    }

Также объекту нужно задать позицию. Её можно сгенерировать рандомно — через функцию, которая возвращает структуру SIMD3, отвечающую за координатную сетку. В ней и генерируются рандомные значения.

    private func getRandomPosition() -> SIMD3 {
        return SIMD3(
            x: Float.random(in: -10...10),
            y: Float.random(in: -10...10),
            z: Float.random(in: -10...10)
        )
    }

На этом этапе работа над мини-игрой была завершена. Вообще, запылесошивание маленьких манулов и задабривание большого изначально предполагались этапами одной игры. Но нам не хватило времени, чтобы собрать их в один сценарий, поэтому мы сделали две отдельные мини-игры, которые можно запустить из общего меню.

Звуковое сопровождение

Во время первой мини-игры звучит синтезированный голос, подсчитывающий котов, — почти как в знаменитом десятичасовом меме!    

private var manulSounds: [AudioFileResource?] = []

Для этого заполняем массив аудиоресурсами.

manulSounds.append(try? await AudioFileResource.load(named: "1 манул.mp3", in: Bundle.main))
manulSounds.append(try? await AudioFileResource.load(named: "2 манула.mp3", in: Bundle.main))
manulSounds.append(try? await AudioFileResource.load(named: "3 манула.mp3", in: Bundle.main))
manulSounds.append(try? await AudioFileResource.load(named: "4 манула.mp3", in: Bundle.main))

У каждой сущности есть готовая функция playAudio, в которую нужно прокинуть аудиоресурс.

        case 1:
            event.entityA.playAudio(manulSounds[0]!)
        case 2:
            event.entityA.playAudio(manulSounds[1]!)
        case 3:
            event.entityA.playAudio(manulSounds[2]!)

Такими были главные этапы работы нашей команды на хакатоне. Готов обсудить их и ответить на ваши вопросы. А во второй части статьи я расскажу об опыте разработки игры, требующей тщательной работы с физикой.

© Habrahabr.ru