Переход приложений для Android от традиционных 2D-экранов к пространственным средам требует нового подхода к созданию фигур.
На этом занятии мы рассмотрим Custom Mesh API в Jetpack XR SDK, который позволяет программно генерировать процедурную 3D-геометрию непосредственно во время выполнения. Мы рассмотрим основные рабочие процессы, от определения макетов вершин до рендеринга динамических объектов в физическом пространстве пользователя.
К концу этого выступления вы получите практические навыки создания интерактивной настраиваемой 3D-геометрии полностью на основе кода.
MeshBuffer, создают модели треугольник за треугольником без использования 3D-инструментов.Расшифровка
Привет, ребята.
Меня зовут Джей.
А я Егор.
Мы разработчики ПО, которые работают над Jetpack XR в Google.
XREAL Aura скоро выйдет, и мы хотим, чтобы у вас были все инструменты, необходимые для создания отличных пространственных приложений на этой платформе.
Но самое главное – вам даже не нужно специальное оборудование.
Все функции, которые мы покажем, уже доступны в эмуляторе Android XR в Android Studio.
Наша цель – показать, что с библиотекой Jetpack SceneCore вам не нужны глубокие знания в области 3D, чтобы создавать на платформе качественные пространственные интерфейсы.
Все, что вы увидите, работает в приложении для Android, написанном на идиоматическом языке Kotlin прямо в Android Studio.
Недавно мы выпустили стабильную версию Jetpack SceneCore. Сегодня мы расскажем о некоторых интересных улучшениях в API 3D-моделей, а также о наших новых API сеток, которые позволяют создавать собственные 3D-объекты на лету.
Но начнем с 3D-моделей.
Для начала нужно выбрать формат файла.
Открытые стандарты – это основа Android XR.
Поэтому мы решили использовать формат glTF от Khronos Group.
Если вы не работаете с 3D-моделями, то можете спросить: "Что такое glTF?"
Это аналог формата JPEG для 3D-моделей.
Структура объектов glTF содержит данные для анимации, дочерних узлов, сеток и т. д.
{
"asset" : {...},
"nodes" : [
{
"name" : "Right_Arm",
"rotation" : [...],
"scale" : [...],
"translation" : [...],
},
],
"animations" : [
{
"name" : "Wave",
},
],
"meshes" : [
{
"name" : "Torso",
"primitives" : [ ... ],
},
],
// Way way more!
}Но в рамках этого доклада вы можете считать дочерние узлы частями модели, у каждой из которых есть свое положение и поворот.
Сетки – это видимые поверхности, а анимации – это движения дочерних узлов и сеток, которые автор объекта запек заранее.
то идем дальше.
Возможно, вы спросите: "Где взять модель glTF?"
Помимо множества существующих моделей, вы также можете создать свою собственную.
Теперь, когда мы рассмотрели основы формата glTF, давайте продолжим и используем его в нашем приложении.
Мы начнем с базового проекта Android XR, в котором используется Jetpack SceneCore.
Вы можете увидеть пространственную панель с действием, плавающим в пространстве пользователя.
Вы можете перемещать его и изменять его размер.
И хотя этот доклад посвящен 3D, обязательно ознакомьтесь с остальной частью Jetpack XR SDK.
Пространственные панели – это здорово, но хочется чего-то большего.
А что может быть лучше, чем добавить в него 3D-модель?
Наш дизайнер создал glTF-модель автомобиля.
// Load assets/models/car.glb
val carModel = GltfModel.create(
session,
Paths.get("models", "car.glb")
)
// Create an entity in the scene
val carEntity = GltfModelEntity.create(
session,
carModel,
parent = session.scene.activitySpace
)Они даже встроили в него анимацию, которую мы будем использовать позже.
С помощью SceneCore я могу использовать наши API 3D-моделей, чтобы загрузить ее прямо в нашу сцену.
Но давайте сделаем машину более живой.
Помните все эти метаданные внутри glTF?
Новый API для интроспекции glTF позволяет получить доступ к этим данным, начиная с анимаций.
// Access every animation in car.glb
val animations = carEntity.getAnimations()
.associateBy { it.name }
// Open the doors...
animations["lf_door_open"]?.start()
animations["rt_door_open"]?.start()
// ...and close them again
animations["lf_door_close"]?.start()
animations["rt_door_close"]?.start()
// Same for the hood and trunk
animations["hood_open"]?.start()
animations["trunk_open"]?.start()Всего несколько строк кода – и я могу воспроизводить любые анимации glTF, встроенные в модель автомобиля, например открывать и закрывать двери, капот или багажник.
Но анимации – не единственные метаданные, к которым у вас есть доступ.
API glTF также предоставляет доступ к дочерним узлам модели в виде GltfModelNode, что позволяет перемещать, поворачивать и масштабировать их по своему усмотрению.
// Wheel sub nodes, named by our designer
val nodes = carEntity.nodes.associateBy { it.name }
val front = listOf("lf_frontWheel_geo",
"rt_fontWheel_geo").map { nodes[it]!! }
val rear = listOf("lf_RearWheel_geo",
"rt_RearWheel_geo").map { nodes[it]!! }
// Update a sub node's transform at runtime
fun GltfModelNode.rotate(q: Quaternion) {
localPose = localPose.copy(rotation = q)
}
// Each frame: steer front, spin all four
fun drive(steerDeg: Float, spinDeg: Float) {
val steer = Quaternion.fromAxisAngle(
Vector3.Up, steerDeg)
val spin = Quaternion.fromAxisAngle(
Vector3.Right, spinDeg)
front.forEach { it.rotate(steer * spin) }
rear.forEach { it.rotate(spin) }
}Например, здесь я могу отдельно обновить положение и вращение колес по мере движения автомобиля.
И чтобы было понятно, то, что вы видите, не является встроенной анимацией glTF.
Здесь я индивидуально вращаю и управляю колесами во время выполнения.
Последний тип метаданных, к которым у вас есть доступ, – это сетки в модели glTF.
Сейчас Егор подробнее расскажет о сетках.
Но пока давайте украсим нашу машину.
С помощью API текстур мы можем загрузить текстуру изображения PNG, например металлический узор.
// Load a PNG with the Texture API
val metal = Texture.create(
session, Paths.get("textures", "metal.png"))
// Create KhronosPbrMaterials
val (body, doors, hood) = List(3) {
KhronosPbrMaterial
.create(session, AlphaMode.OPAQUE)
}
body.setBaseColorFactor(CYAN)
doors.setBaseColorFactor(PINK)
hood.setBaseColorTexture(metal)
// Override meshes by sub node name
val nodes = carEntity.nodes.associateBy { it.name }
nodes["mainBody_geo"]?.setMaterialOverride(body)
nodes["lf_door_geo"]?.setMaterialOverride(doors)
nodes["rt_door_geo"]?.setMaterialOverride(doors)
nodes["hood_geo"]?.setMaterialOverride(hood)Затем с помощью Material API мы можем подключить его прямо к KhronosPbrMaterial.
Если вы не знакомы с 3D-моделированием, представьте, что сетка – это столешница, материал – скатерть, а текстура – узор на ней.
Но здесь вместо столешницы мы используем созданные нами материальные объекты, чтобы переопределить сетки, связанные с подузлами капота и дверей автомобиля.
Всего несколько строк кода – и мы полностью изменили внешний вид автомобиля. И все это во время выполнения.
Это здорово, но у меня есть идея получше.
Как здорово было бы нарисовать гоночную трассу, по которой будет ездить машина!
В этом случае готовая модель glTF не подойдет.
Вместо этого нам нужна возможность генерировать 3D-геометрию на лету.
А теперь я передаю слово Егору, который расскажет о новых API Jetpack SceneCore для работы с сетками.
Спасибо, Джей.
Давайте сделаем так, чтобы пользователь мог нарисовать гоночную трассу пальцем в своей гостиной.
Трасса не существует, пока ее не нарисуют, поэтому никто не может смоделировать ее заранее.
Мы создадим его в коде, по одному треугольнику за раз.
Сетка начинается с вершин, точек в пространстве.
// 4 vertices: x, y, z
val positions = floatArrayOf(
0f, 0f, 0f, // v0
1f, 0f, 0f, // v1
0f, 1f, 0f, // v2
1f, 1f, 0f // v3
)
// 2 triangles = 6 indices
val indices = intArrayOf(
0, 1, 2,
2, 1, 3
)Каждая из них имеет такие атрибуты, как положение, нормаль для освещения, цвет, координаты текстуры.
Индексы соединяют точки.
Каждые три образуют треугольник.
Два треугольника образуют квадрат, шесть индексов, но только четыре вершины, потому что они делят две.
Продолжайте, и вы получите куб.
Все 3D-модели, включая автомобиль Джея, создаются одинаково, просто в них больше треугольников.
Mesh API сопоставляет их с четырьмя строительными блоками: VertexLayout, MeshBuffer, CustomMesh и MeshEntity.
Давайте рассмотрим их по очереди.
Сначала VertexLayout.
// How to read each vertex from bytes
val layout = VertexLayout.Builder()
.addAttribute(POSITION, FLOAT3) // 12 B
.addAttribute(NORMAL, FLOAT3) // 12 B
.addAttribute(UV0, FLOAT2) // 8 B
.build() // stride: 32 B
// Offsets and stride are computed,
// or set them explicitly.
// Attributes can live in separate buffers
val split = VertexLayout.Builder()
.addAttribute(POSITION, FLOAT3)
.startNextBuffer()
.addAttribute(COLOR, UBYTE4_NORM)
.build()Данные вершин – это просто байты в ByteBuffer, и каждая сетка упаковывает их по-разному.
VertexLayout указывает, как читать ваши данные: какие атрибуты, какого типа и где они находятся.
В данном случае это позиция, нормаль и UV-координаты текстуры, всего 32 байта.
Атрибуты также могут находиться в отдельных буферах, чтобы вы могли обновлять только те, которые меняются.
Второй – MeshBuffer, контейнер для первичных данных.
// Vertex bytes + 32-bit indices
val meshBuffer = MeshBuffer.create(
session, layout,
vertexData = listOf(ByteBufferRegion(
vertexBytes, 0, vertexBytes.capacity())),
indexData = ByteBufferRegion(
indexBytes, 0, indexBytes.capacity()))
// Copied to the GPU: reuse your
// ByteBuffers right away
// Need updates at runtime?
val dynamic = MeshBuffer.createDynamic(
session, layout,
vertexCount = 1000, indexCount = 3000)Вы передаете ему макет, байты вершин и 32-битные индексы.
Данные сразу копируются в графический процессор, поэтому вы можете повторно использовать или освободить ByteBuffer.
По умолчанию MeshBuffer статичен.
Оно задается один раз и оптимизировано для быстрой отрисовки.
Если вам нужно изменить его во время выполнения, createDynamic выделяет фиксированную емкость, которую вы можете обновить позже.
Третий – CustomMesh, представление MeshBuffer.
// A view: which indices to draw, and how
val road = CustomMesh
.BuilderFromMeshBuffer(session, meshBuffer)
// subset 0: asphalt
.addSubset(TRIANGLES, indexOffset = 0,
indexCount = roadCount)
// subset 1: lane markings
.addSubset(TRIANGLES,
indexOffset = roadCount,
indexCount = lineCount)
.build()
// Shortcut: builds MeshBuffer + CustomMesh
CustomMesh.BuilderFromMeshData(session, layout)Данные не копируются.
Она указывает, какой диапазон индексов рисовать и как: треугольники или полосы треугольников.
В этом случае асфальт и разметка находятся в одном MeshBuffer.
Несколько пользовательских сеток могут использовать один и тот же MeshBuffer, а когда совместное использование не требуется, BuilderFromMeshData создает сетку и буфер за один шаг.
Наконец, MeshEntity размещает сетку в сцене.
// Place the mesh in the scene graph
val roadEntity = MeshEntity.create(
session,
mesh = road,
// one material per subset
materials = listOf(asphalt, paint),
pose = Pose(Vector3(0f, 0f, -1f)),
parent = session.scene.activitySpace)
// It's an Entity: move it, re-parent it
roadEntity.setPose(Pose(Vector3(0f, -0.5f, -1f)))
roadEntity.parent = tableAnchor
// Same mesh, another entity
val ghost = MeshEntity.create(
session, road, listOf(glass, glass))Сетка – это то, что вы видите.
Объект – это место.
Как и панель, и машина Джея, это объект, поэтому у него есть положение и родительский объект, к которому он прикреплен.
Если вы переместите родительский объект, дорога переместится вместе с ним.
Вы также предоставляете один материал для каждого поднабора, чтобы одна и та же сетка могла отображаться с разными материалами в разных объектах.
Теперь давайте соберем все вместе и построим сетку, дорожную сетку.
// Describe a vertex
val layout = VertexLayout.Builder()
.addAttribute(POSITION, FLOAT3)
.addAttribute(NORMAL, FLOAT3)
.addAttribute(UV0, FLOAT2)
.build()
// Path points -> ribbon of triangles
val (vertices, indices) = buildRoad(path)
// Build the mesh...
val roadMesh = CustomMesh
.BuilderFromMeshData(session, layout)
.addVertexData(vertices)
.setIndexData(indices)
.setTopology(TRIANGLES)
.build()
// ...and put it in the scene
val road = MeshEntity.create(
session, roadMesh, listOf(asphalt),
parent = session.scene.activitySpace)Мы определяем вершину: положение, нормаль и UV-координаты текстуры.
Небольшая вспомогательная функция превращает путь пользователя в ленту.
Для каждой точки на пути добавляются две вершины с каждой стороны и два треугольника, которые соединяют их со следующей парой.
Это обычный код Kotlin, который записывает байты в ByteBuffer.
Для этого не нужны специальные инструменты.
BuilderFromMeshData, который мы только что видели, создает сетку и MeshBuffer, а также вычисляет границы.
Наконец, MeshEntity.create помещает roadMesh в сцену с асфальтом.
Но мы хотим, чтобы наш трек рос вживую, когда пользователь рисует его.
Для этого мы используем динамическую сетку.
// Fixed GPU capacity, allocated once
val track = CustomMesh.BuilderFromMeshData(
session, layout,
maxVertices = 2 * MAX_POINTS,
maxIndices = 6 * MAX_POINTS)
.setTopology(TRIANGLES)
// Covers everything the track can become
.setBounds(playArea)
.build()
// Hand moved: upload only the new segment
val buffer = track.meshBuffer
buffer.updateVertexData(
bufferIndex = 0,
vertexData = segmentVertices,
offsetInBytes = n * VERTEX_BYTES)
buffer.updateIndexData(
segmentIndices,
offsetInBytes = n * INDEX_BYTES)Единственное отличие заключается в том, что мы заранее предоставляем конструктору максимальную емкость, чтобы можно было выделить буферы графического процессора.
Затем, когда пользователь рисует, updateVertexData и updateIndexData загружают только новые сегменты, а не всю сетку.
Есть один нюанс: рендерер использует границы, чтобы пропускать все, что находится за пределами экрана, поэтому установите границы так, чтобы они охватывали все, чем может стать трек, например игровую зону.
Как видите, API – это не самая сложная часть.
Сложная часть – это математика вершин, нормали, координаты текстуры, порядок треугольников.
Именно такой код ИИ пишет лучше всего.
Но давайте представим, что мы хотим пойти ещё дальше.
Теперь я хочу, чтобы в пункте назначения на ветру развевался клетчатый флаг.
Математика вершинной анимации – это сложная часть, поэтому я обратился к Gemini.
В Android Studio я дал ему одну подсказку: "Используя Jetpack SceneCore Mesh API, создайте сетку флагов 20x12 с шахматными UV-координатами, анимируйте ветровую волну в каждом кадре с помощью updateVertexData и пересчитайте нормали, чтобы освещение следовало за складками".
Через несколько секунд я получил рабочий код.
Давайте посмотрим, что получилось.
Он создал сетку флага размером 20 на 12 ячеек, по два треугольника на ячейку, и сделал разумный выбор с макетом.
// 20 x 12 cells = 273 vertices
val layout = VertexLayout.Builder()
// buffer 0: updated every frame
.addAttribute(POSITION, FLOAT3)
.addAttribute(NORMAL, FLOAT3)
.startNextBuffer()
// buffer 1: never changes
.addAttribute(UV0, FLOAT2)
.build()
val flagMesh = CustomMesh.BuilderFromMeshData(
session, layout,
maxVertices = 273, maxIndices = 1440)
.addVertexData(restPose) // buffer 0
.addVertexData(checkerUVs) // buffer 1
.setIndexData(gridIndices)
.setTopology(TRIANGLES)
.setBounds(swingBounds)
.build()Позиции и нормали помещаются в буфер 0 и обновляются в каждом кадре.
Координаты текстуры помещаются в буфер 1 и никогда не меняются.
Сетка динамическая, а границы установлены так, чтобы охватить весь размах флага.
В каждом кадре каждая вершина получает синусоидальную волну, которая растет по направлению к свободному краю, так что сторона полюса остается на месте.
// Gemini wrote the math
fun wave(t: Float) {
for (i in 0 until 273) {
val u = (i % 21) / 20f // 0 at the pole
setZ(i, 0.06f * u * sin(6f * u - 4f * t))
}
recomputeNormals() // from neighbors
}
// Every frame: one upload (~6.5 KB)
while (isActive) withFrameNanos { ns ->
wave(ns / 1e9f)
flagMesh.meshBuffer.updateVertexData(
bufferIndex = 0,
vertexData = ByteBufferRegion(
posNormals, 0, 273 * 24))
}Нормали пересчитываются на основе соседних позиций, благодаря чему складки улавливают свет.
updateVertexData загружает в графический процессор около 6,5 КБ данных на кадр.
Мы не воссоздаем сетку или объект.
Теперь давайте посмотрим, как это выглядит в целом.
Мы ставим модель автомобиля на стол, а затем используем ARCore, чтобы нарисовать трассу.
Сетка флага, которую мы только что создали, появляется в месте назначения.
Машина едет по трассе, а мы используем glTF Introspection API, чтобы управлять ею и вращать колеса.
Сетка появляется с материалом асфальта, но мы также можем использовать API сетки вместе с API материала, чтобы изменить внешний вид дороги, как вы можете видеть на видео.
Это очень мощный инструмент, который всегда под рукой.
Сегодня мы рассмотрели множество способов превратить ваши приложения из 2D в пространственные.
С помощью glTF Introspection API вы можете напрямую использовать внутренние метаданные модели glTF, запуская встроенные анимации, преобразуя отдельные дочерние узлы и даже заменяя материалы, чтобы изменить внешний вид модели во время выполнения.
Затем мы увидели, что когда готовых 3D-моделей недостаточно, мы можем использовать наш совершенно новый Mesh API для создания пользовательской геометрии.
Теперь у вас есть инструменты, позволяющие создавать статические фигуры с нуля или транслировать динамические фигуры и геометрию в реальном времени.
И, как только что показал Егор, если вы будете использовать эти строительные блоки вместе с Gemini, вам даже не придется выполнять вычисления самостоятельно.
Повторю то, что я уже говорил: все, что вы видели, работало в приложении для Android, написанном на идиоматическом Kotlin прямо в Android Studio.
Я вижу, что у многих из вас с собой ноутбуки. Надеюсь, мы с Егором вдохновили вас открыть Android Studio и запустить эмулятор Android XR.
Дайте волю воображению и используйте свои навыки разработки для Android и то, что вы узнали сегодня, чтобы создавать потрясающие XR-приложения.
От имени Егора и от себя лично большое спасибо, что присоединились к нам сегодня. Желаю вам приятного продолжения Саммита для разработчиков Android.
{
"asset" : {...},
"nodes" : [
{
"name" : "Right_Arm",
"rotation" : [...],
"scale" : [...],
"translation" : [...],
},
],
"animations" : [
{
"name" : "Wave",
},
],
"meshes" : [
{
"name" : "Torso",
"primitives" : [ ... ],
},
],
// Way way more!
}// Load assets/models/car.glb
val carModel = GltfModel.create(
session,
Paths.get("models", "car.glb")
)
// Create an entity in the scene
val carEntity = GltfModelEntity.create(
session,
carModel,
parent = session.scene.activitySpace
)// Access every animation in car.glb
val animations = carEntity.getAnimations()
.associateBy { it.name }
// Open the doors...
animations["lf_door_open"]?.start()
animations["rt_door_open"]?.start()
// ...and close them again
animations["lf_door_close"]?.start()
animations["rt_door_close"]?.start()
// Same for the hood and trunk
animations["hood_open"]?.start()
animations["trunk_open"]?.start()// Wheel sub nodes, named by our designer
val nodes = carEntity.nodes.associateBy { it.name }
val front = listOf("lf_frontWheel_geo",
"rt_fontWheel_geo").map { nodes[it]!! }
val rear = listOf("lf_RearWheel_geo",
"rt_RearWheel_geo").map { nodes[it]!! }
// Update a sub node's transform at runtime
fun GltfModelNode.rotate(q: Quaternion) {
localPose = localPose.copy(rotation = q)
}
// Each frame: steer front, spin all four
fun drive(steerDeg: Float, spinDeg: Float) {
val steer = Quaternion.fromAxisAngle(
Vector3.Up, steerDeg)
val spin = Quaternion.fromAxisAngle(
Vector3.Right, spinDeg)
front.forEach { it.rotate(steer * spin) }
rear.forEach { it.rotate(spin) }
}// Load a PNG with the Texture API
val metal = Texture.create(
session, Paths.get("textures", "metal.png"))
// Create KhronosPbrMaterials
val (body, doors, hood) = List(3) {
KhronosPbrMaterial
.create(session, AlphaMode.OPAQUE)
}
body.setBaseColorFactor(CYAN)
doors.setBaseColorFactor(PINK)
hood.setBaseColorTexture(metal)
// Override meshes by sub node name
val nodes = carEntity.nodes.associateBy { it.name }
nodes["mainBody_geo"]?.setMaterialOverride(body)
nodes["lf_door_geo"]?.setMaterialOverride(doors)
nodes["rt_door_geo"]?.setMaterialOverride(doors)
nodes["hood_geo"]?.setMaterialOverride(hood)// 4 vertices: x, y, z
val positions = floatArrayOf(
0f, 0f, 0f, // v0
1f, 0f, 0f, // v1
0f, 1f, 0f, // v2
1f, 1f, 0f // v3
)
// 2 triangles = 6 indices
val indices = intArrayOf(
0, 1, 2,
2, 1, 3
)// How to read each vertex from bytes
val layout = VertexLayout.Builder()
.addAttribute(POSITION, FLOAT3) // 12 B
.addAttribute(NORMAL, FLOAT3) // 12 B
.addAttribute(UV0, FLOAT2) // 8 B
.build() // stride: 32 B
// Offsets and stride are computed,
// or set them explicitly.
// Attributes can live in separate buffers
val split = VertexLayout.Builder()
.addAttribute(POSITION, FLOAT3)
.startNextBuffer()
.addAttribute(COLOR, UBYTE4_NORM)
.build()// Vertex bytes + 32-bit indices
val meshBuffer = MeshBuffer.create(
session, layout,
vertexData = listOf(ByteBufferRegion(
vertexBytes, 0, vertexBytes.capacity())),
indexData = ByteBufferRegion(
indexBytes, 0, indexBytes.capacity()))
// Copied to the GPU: reuse your
// ByteBuffers right away
// Need updates at runtime?
val dynamic = MeshBuffer.createDynamic(
session, layout,
vertexCount = 1000, indexCount = 3000)// A view: which indices to draw, and how
val road = CustomMesh
.BuilderFromMeshBuffer(session, meshBuffer)
// subset 0: asphalt
.addSubset(TRIANGLES, indexOffset = 0,
indexCount = roadCount)
// subset 1: lane markings
.addSubset(TRIANGLES,
indexOffset = roadCount,
indexCount = lineCount)
.build()
// Shortcut: builds MeshBuffer + CustomMesh
CustomMesh.BuilderFromMeshData(session, layout)// Place the mesh in the scene graph
val roadEntity = MeshEntity.create(
session,
mesh = road,
// one material per subset
materials = listOf(asphalt, paint),
pose = Pose(Vector3(0f, 0f, -1f)),
parent = session.scene.activitySpace)
// It's an Entity: move it, re-parent it
roadEntity.setPose(Pose(Vector3(0f, -0.5f, -1f)))
roadEntity.parent = tableAnchor
// Same mesh, another entity
val ghost = MeshEntity.create(
session, road, listOf(glass, glass))// Describe a vertex
val layout = VertexLayout.Builder()
.addAttribute(POSITION, FLOAT3)
.addAttribute(NORMAL, FLOAT3)
.addAttribute(UV0, FLOAT2)
.build()
// Path points -> ribbon of triangles
val (vertices, indices) = buildRoad(path)
// Build the mesh...
val roadMesh = CustomMesh
.BuilderFromMeshData(session, layout)
.addVertexData(vertices)
.setIndexData(indices)
.setTopology(TRIANGLES)
.build()
// ...and put it in the scene
val road = MeshEntity.create(
session, roadMesh, listOf(asphalt),
parent = session.scene.activitySpace)// Fixed GPU capacity, allocated once
val track = CustomMesh.BuilderFromMeshData(
session, layout,
maxVertices = 2 * MAX_POINTS,
maxIndices = 6 * MAX_POINTS)
.setTopology(TRIANGLES)
// Covers everything the track can become
.setBounds(playArea)
.build()
// Hand moved: upload only the new segment
val buffer = track.meshBuffer
buffer.updateVertexData(
bufferIndex = 0,
vertexData = segmentVertices,
offsetInBytes = n * VERTEX_BYTES)
buffer.updateIndexData(
segmentIndices,
offsetInBytes = n * INDEX_BYTES)// 20 x 12 cells = 273 vertices
val layout = VertexLayout.Builder()
// buffer 0: updated every frame
.addAttribute(POSITION, FLOAT3)
.addAttribute(NORMAL, FLOAT3)
.startNextBuffer()
// buffer 1: never changes
.addAttribute(UV0, FLOAT2)
.build()
val flagMesh = CustomMesh.BuilderFromMeshData(
session, layout,
maxVertices = 273, maxIndices = 1440)
.addVertexData(restPose) // buffer 0
.addVertexData(checkerUVs) // buffer 1
.setIndexData(gridIndices)
.setTopology(TRIANGLES)
.setBounds(swingBounds)
.build()// Gemini wrote the math
fun wave(t: Float) {
for (i in 0 until 273) {
val u = (i % 21) / 20f // 0 at the pole
setZ(i, 0.06f * u * sin(6f * u - 4f * t))
}
recomputeNormals() // from neighbors
}
// Every frame: one upload (~6.5 KB)
while (isActive) withFrameNanos { ns ->
wave(ns / 1e9f)
flagMesh.meshBuffer.updateVertexData(
bufferIndex = 0,
vertexData = ByteBufferRegion(
posNormals, 0, 273 * 24))
}