将 Android 应用从传统的 2D 屏幕过渡到空间环境,需要采用新的形状构建方法。
在本课程中,我们将探索 Jetpack XR SDK 中的 Custom Mesh API,该 API 可让您在运行时直接以编程方式生成程序化 3D 几何图形。我们将逐步介绍核心工作流程,从定义顶点布局到在用户的物理空间中无缝渲染动态对象。
讲座结束后,您将掌握完全通过代码构建交互式自定义 3D 几何图形的实用技能。
MeshBuffer 等四个构建块无需 3D 工具即可逐个三角形地构建模型。转写文稿
大家好!
我是 Jay。
我是 Egor。
我们是 Google 的软件工程师,负责 Jetpack XR 的开发工作。
XREAL Aura 即将推出,我们希望确保您拥有在平台上打造出色空间体验所需的所有工具。
最棒的是,您甚至不需要 XR 硬件即可开始使用。
我们展示的所有功能现在都可以在 Android Studio 的 Android XR 模拟器中使用。
我们的目标是向您展示,借助 Jetpack SceneCore 库,您无需具备深厚的 3D 知识,即可在该平台上构建丰富的空间体验。
您将看到的所有内容都在 Android 应用中运行,该应用是在 Android Studio 中使用惯用 Kotlin 编写的。
我们最近将 Jetpack SceneCore 升级到了稳定版,今天我们将介绍 3D 模型 API 的一些重大进展,以及全新的网格 API,这些 API 可让您即时构建自定义 3D 几何图形。
我们先从 3D 模型开始。
对于 3D 模型,我们首先需要的是文件格式。
开放标准是 Android XR 的核心要素。
因此,我们自然而然地全力支持 Khronos Group 的 glTF 格式。
现在,如果您不熟悉 3D 领域,可能会问:“什么是 glTF?”
您可以将其视为 3D 模型的 JPEG。
glTF 资源结构可为动画、子节点和网格等内容提供数据。
{
"asset" : {...},
"nodes" : [
{
"name" : "Right_Arm",
"rotation" : [...],
"scale" : [...],
"translation" : [...],
},
],
"animations" : [
{
"name" : "Wave",
},
],
"meshes" : [
{
"name" : "Torso",
"primitives" : [ ... ],
},
],
// Way way more!
}但就本次演讲而言,您可以将子节点视为模型的一部分,每个子节点都有自己的位置和旋转。
网格是您看到的表面,动画是这些子节点和网格的运动,由素材资源的作者提前烘焙。
很好
现在,您可能会问:“我首先可以在哪里获取 glTF 模型?”
除了众多现有模型之外,您还可以创建自己的模型。
现在,我们已经介绍了 glTF 格式的基础知识,接下来我们就在应用中使用它。
我们首先使用 Jetpack SceneCore 创建一个基本的 Android XR 项目。
您可以看到一个空间面板,其中包含在用户空间中浮动的 activity。
您可以抓取、移动和调整其大小,使其符合您的喜好。
虽然本次演讲主要围绕 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,我可以使用 3D 模型 API 将其直接加载到场景中。
不过,我们来让汽车看起来更生动一些。
还记得 glTF 中那些丰富的元数据吗?
借助新的 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 动画,例如打开和关闭车门、引擎盖或后备箱。
不过,动画并不是您可以访问的唯一元数据。
glTF 自检 API 还会将模型中的子节点公开为 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 模型中的任何网格。
稍后,Egor 将深入探讨网格。
但现在,我们先来为汽车设计样式。
使用我们的纹理 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 几何图形。
接下来,我将把时间交给 Egor,让他为大家详细介绍我们全新的 Jetpack SceneCore 网格 API。
谢谢,Jay。
我们来让用户可以在客厅里用手指画出赛道。
赛道在绘制出来之前是不存在的,因此没人能提前建模。
我们将通过代码逐个构建三角形。
网格从顶点(空间中的点)开始。
// 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 模型(包括 Jay 的汽车)都是以同样的方式构建的,只是使用了更多三角形。
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 位索引。
数据会立即复制到 GPU,以便您重复使用或释放 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))网格是“什么”。
实体是“地点”。
与面板和 Jay 的汽车一样,它也是一个实体,因此有位置和父级(即它所附着的对象)。
移动父级,道路也会随之移动。
您还需要为每个子集提供一种材质,以便同一网格在不同的实体中以不同的材质显示。
现在,让我们把所有这些内容整合到一起,构建网格,即道路网格。
// 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。
您无需使用任何 3D 工具。
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)唯一的区别在于,我们会预先为构建器提供最大容量,以便分配 GPU 缓冲区。
然后,当用户绘制时,updateVertexData 和 updateIndexData 仅上传新片段,而不是整个网格。
需要注意的是:渲染器会使用边界来跳过屏幕外的任何内容,因此请设置边界,使其涵盖轨道可能变成的所有内容,例如此处的游戏区域。
您可能会注意到,API 并不是这里的难点。
难点在于顶点数学、法线、纹理坐标和三角形顺序。
而这正是 AI 擅长编写的代码。
但假设我们想更进一步。
现在,我希望在终点处有一面方格旗在风中飘扬。
顶点动画数学是棘手的部分,所以我问问 Gemini。
在 Android Studio 中,我给它输入了一条提示:“使用 Jetpack SceneCore Mesh API 构建一个 20x12 的旗帜网格,采用棋盘格 UV,使用 updateVertexData 逐帧添加风浪动画,并重新计算法线,使光照效果跟随褶皱变化。”
几秒钟后,我就得到了可用的代码。
我们来看看它做了什么。
它构建了一个旗帜网格,网格由 20x12 个单元格组成,每个单元格包含两个三角形,并且在布局上做出了明智的选择。
// 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 每帧向 GPU 上传大约 6.5 KB 的数据。
我们不会重新创建网格或实体。
现在,我们来整体看一下。
我们把汽车模型放在桌子上,然后用 ARCore 绘制赛道。
我们刚刚构建的旗帜网格会出现在目标位置。
汽车沿着赛道行驶,我们使用 glTF Introspection API 来控制汽车转向和车轮旋转。
网格会显示为沥青材质,但我们也可以将网格 API 与材质 API 结合使用,以更改道路的外观和风格,如视频中所示。
这就像是把强大的工具放在了你的指尖。
今天,我们介绍了许多将应用从 2D 转换为空间体验的方法。
借助 glTF Introspection API,您现在可以直接访问 glTF 模型的内部元数据,从而在运行时触发嵌入式动画、转换单个子节点,甚至交换材质以改变其外观和风格。
然后,我们了解到,当预制 3D 模型不够用时,我们可以利用全新的 Mesh API 构建自定义几何图形。
无论您是需要从头开始构建静态形状,还是需要实时流式传输动态形状和动态几何图形,现在都有相应的工具可以实现。
正如 Egor 刚才所展示的,将这些基础组件与 Gemini 结合使用,您甚至不需要独自进行计算。
现在,我再重复一遍之前说过的话:您看到的所有内容都是在 Android 应用中运行的,该应用是在 Android Studio 中使用惯用 Kotlin 编写的。
我看到今天台下有很多笔记本电脑,希望我和 Egor 的分享能让大家兴奋起来,今天就打开 Android Studio,启动 Android XR 模拟器。
尽情发挥想象力,运用您现有的 Android 技能,结合我们今天展示的内容,打造令人惊叹的 XR 体验。
我谨代表 Egor 和我自己,感谢大家今天前来参会,祝大家在 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))
}