В статье описан мой опыт разработки мини-игр для 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 – и ничего не сломалось! Получается, если написать один код под разные платформы, можно получить функционально одинаковый результат.
Мини-игра № 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, который выполняет похожие функции по заполнению пространства контентом. Алгоритм такой:
Создать корневую пустую 3D-вьюшку rootEntity;
Положить её в контент с помощью метода
add(content.add(rootEntity));
Положить в rootEntity непустые Entity, которые будут содержать ваши 3D-модели:
rootEntity.addChild(entity)
Сотворение мира
Прежде чем создать портал, мы создали мир, который будет виден за ним. Это первый элемент, а всего их три:
Мир, который будет отображаться внутри портала;
Сущность портала – чёрный круг;
Якорь – сущность, к которой прикрепляются все объекты. Им могут быть руки пользователя, стены помещения, пол, столы и т.д.
Дальше как в сказке: разработчик кладёт мир в портал, портал – в якорь, а потом все три сущности кладёт в контент.
Чтобы появился мир, нужно создать сущность и задать ей соответствующее свойство. За него отвечает компонент 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.
Сначала нам понадобятся объекты, которые нужно отобразить: 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
}
Изначально портал был горизонтальным, а потом я повернул его на 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)
Кот, который не гуляет сам по себе
И вообще не гуляет, а вращается вокруг своей оси. Как мы закрутили манулов?
Создали собственный кастомный компонент – структуру, которая будет конформить протокол Component, – задали в нём нужные поля и зарегистрировали.
struct RotateComponent: Component {
var isCollecting: Bool = false
var animationProgress: Float = 0.0
var startPositionY: Float?
var endPositionY: Float?
}
Для взаимодействия с этим компонентом нужна система. Поэтому мы создали класс, законформили протокол System, – и у него появилась возможность переопределить метод Update.
Update вызывается каждый раз на обновление фрейма, а частота его вызова зависит от частоты обновления кадров в visionOS. Для очков это ~90 Гц, соответственно, обновление будет происходить 90 раз в секунду.
Нужно найти сущность, которая соответствует определённому параметру (у нас это та, у которой есть компонент 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]!)
Такими были главные этапы работы нашей команды на хакатоне. Готов обсудить их и ответить на ваши вопросы. А во второй части статьи я расскажу об опыте разработки игры, требующей тщательной работы с физикой.