Портал, манулы и мячи: опыт разработки для 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-модели найдены, их нужно импортировать в сцену: просто перетащить либо на панель слева, либо прямо на сцену. Затем – расположить на сцене. 

Положение объекта задается координатами на трех осях – 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..<viewModel.foodMax {                 cubeList.append(foodModel.addBurger(name: "Burger\(index + 1)"))             }                          for index in 0..<50 {                 cubeList.append(foodModel.addTomato(name: "Tomato\(index + 1)"))             }         }         // Add tap gesture to interact with entities         .gesture(             SpatialTapGesture()                 .targetedToAnyEntity()                 .onEnded { value in                     print(value.entity.name)                     if value.entity.name.hasPrefix("Object_0") {                         incorrect()                     } else {                         correct()                     }                     foodModel.removeModel(entity: value.entity)                                      }         )

Также нужно задать стандартный жест для 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<Float> {         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]!)

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


ссылка на оригинал статьи https://habr.com/ru/articles/860194/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *