如要將 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,可讓您即時建構自訂 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 專案。
您可以看到空間面板,其中有活動浮動在使用者空間中。
你可以抓取、移動及調整大小。
雖然這場講座著重於 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,您現在可以存取這項功能,首先是動畫。
// 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 introspection 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 模型 (包括阿傑的車) 都是以相同方式建構,只是三角形數量更多。
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))網格是「什麼」。
實體是地點。
就像面板和阿傑的車一樣,這也是一個實體,因此有位置和父項 (也就是附加的物件)。
移動父項時,道路也會隨之移動。
您也可以為每個子集提供一種材質,這樣同一個網格在不同實體中,就能以不同材質顯示。
現在,我們來統整一下,建構網格,也就是道路網格。
// 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 每幀會將約 6.5 KB 的資料上傳至 GPU。
我們不會重新建立網格或實體。
現在來看看整體效果。
我們將車輛模型放在桌上,然後使用 ARCore 繪製賽道。
我們剛才建立的旗幟網格會出現在目的地。
車子沿著軌道行駛,我們使用 glTF Introspection API 操控車子和轉動車輪。
網格會顯示瀝青材質,但我們也可以搭配使用網格 API 和材質 API,變更道路的外觀和風格,如影片所示。
這就像是動動手指就能使用強大的功能。
今天我們介紹了許多方法,協助您將應用程式從 2D 轉換為空間體驗。
有了 glTF Introspection API,您現在可以直接存取 glTF 模型的內部中繼資料,在執行階段觸發內嵌動畫、轉換個別子節點,甚至更換材質來改變外觀和風格。
接著,我們發現預製 3D 模型不夠用時,可以利用全新的 Mesh API 建構自訂幾何圖形。
無論是從頭開始建構靜態形狀,還是即時串流動態形狀,現在都有合適的工具。
正如 Egor 剛才所示,將這些建構模塊與 Gemini 搭配使用,您甚至不需要自己計算。
現在,我要重申先前說過的話:您看到的所有內容都是在 Android 應用程式中執行,並使用慣用的 Kotlin 編寫,就在 Android Studio 中。
今天現場有許多人帶著筆電,希望我和 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))
}