Android アプリを従来の 2D 画面から空間環境に移行するには、シェイプを構築するための新しいアプローチが必要です。
このセッションでは、Jetpack XR SDK の Custom Mesh API について説明します。この API を使用すると、実行時にプロシージャル 3D ジオメトリをプログラムで直接生成できます。頂点レイアウトの定義から、ユーザーの物理空間内での動的オブジェクトのシームレスなレンダリングまで、コア ワークフローを順を追って説明します。
このセッションを最後までお聞きいただくことで、インタラクティブなカスタム 3D ジオメトリを完全にコードから構築するための実践的なスキルを習得できます。
MeshBuffer などの 4 つのビルディング ブロックは、3D ツールを使用せずに三角形ごとにモデルを構築します。文字起こし
皆さん、こんにちは。
ジェイです。
Egor です。
Google で Jetpack XR に取り組んでいるソフトウェア エンジニアです。
XREAL Aura のリリースを間近に控え、このプラットフォームで優れた空間体験を構築するために必要なツールをすべて揃えていただきたいと考えています。
さらに、XR ハードウェアがなくても開発を始められます。
今回ご紹介する機能はすべて、Android Studio の Android XR エミュレータで今すぐご利用いただけます。
この Codelab の目標は、Jetpack SceneCore ライブラリを使用すれば、3D に関する深い知識がなくても、プラットフォーム上でリッチな空間エクスペリエンスを構築できることを示すことです。
表示されるものはすべて、Android Studio 内で慣用的な Kotlin で記述された Android アプリで実行されています。
最近、Jetpack SceneCore が安定版に昇格しました。本日は、3D モデル API の優れた進歩と、カスタム 3D ジオメトリをオンザフライで構築できる新しいメッシュ API について説明します。
まずは 3D モデルから始めましょう。
3D モデルの場合、まず必要なのはファイル形式です。
オープン標準は、Android XR の DNA の中核をなすものです。
そのため、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 形式の基本について説明したので、次はアプリで 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 を使用すると、アニメーションから 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)次に、マテリアル API を使用して、KhronosPbrMaterial に直接プラグインできます。
3D に不慣れな場合は、メッシュをテーブルトップ、マテリアルをテーブルクロス、テクスチャをそのクロスにプリントされたパターンと考えるとよいでしょう。
ただし、ここではテーブルトップの代わりに、作成したマテリアル オブジェクトを使用して、車のボンネットとドアのサブノードに関連付けられたメッシュをオーバーライドします。
わずか数行のコードで、車の外観と雰囲気を変えることができました。これもすべて実行時に行われます。
これは素晴らしいアイデアですが、もっと良いアイデアがあります。
車が走るレーストラックを描けたら、とても楽しいと思いませんか?
この場合、既製の glTF モデルは適していません。
代わりに、3D ジオメトリをオンザフライで生成する機能が必要です。
それでは、Egor にバトンタッチして、新しい Jetpack SceneCore Mesh API について詳しく説明してもらいます。
Jay さん、ありがとうございました。
ユーザーがリビングルームで指を使ってレーストラックを描けるようにしましょう。
トラックは描画されるまで存在しないため、事前にモデル化することはできません。
コードで 1 つずつ三角形を作成します。
メッシュは、空間内の点である頂点から始まります。
// 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
)各頂点には、位置、ライティングの法線、色、テクスチャ座標などの属性があります。
インデックスは点と点をつなぐ。
3 つごとに三角形を作ります。
2 つの三角形で 1 つの正方形を構成します。インデックスは 6 つですが、2 つの頂点を共有しているため、頂点は 4 つだけです。
さらに進むと、立方体になります。
Jay の車を含むすべての 3D モデルは、三角形の数が多いだけで、同じ方法で作成されています。
Mesh API は、VertexLayout、MeshBuffer、CustomMesh、MeshEntity の 4 つのビルディング ブロックにマッピングされます。
1 つずつ見ていきましょう。
1 つ目は 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 バイトです。
属性を個別のバッファに格納して、変更された属性のみを更新することもできます。
2 つ目は、元データのコンテナである 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 によって固定容量が割り当てられます。この容量は後で更新できます。
3 つ目は 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 でメッシュとバッファを 1 ステップで作成します。
最後に、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))メッシュは「何」に相当します。
エンティティは「どこ」に該当します。
パネルやジェイの車と同様に、これもエンティティであるため、位置と親(アタッチされているもの)があります。
親を移動すると、道路も一緒に移動します。
また、サブセットごとに 1 つのマテリアルを指定するため、同じメッシュが異なるエンティティで異なるマテリアルで表示されることがあります。
では、これらをすべて組み合わせて、道路のメッシュを作成しましょう。
// 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、テクスチャ座標)を定義します。
小さなヘルパー関数がユーザーパスをリボンに変換します。
パス上の各ポイントについて、両側に 2 つの頂点と、それらを次のペアに接続する 2 つの三角形を追加します。
これは、ByteBuffer にバイトを書き込む、プレーンな Kotlin です。
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 を使用して、チェッカーボード UV を持つ 20x12 のフラググリッドを構築し、updateVertexData でフレームごとに風波をアニメーション化し、ライティングが折り目に沿うように法線を再計算する」というプロンプトを 1 つ入力しました。
数秒後、動作するコードが生成されました。
何が行われたか見てみましょう。
20x12 セル(1 セルあたり 2 つの三角形)のグリッドとしてフラグメッシュを構築し、レイアウトを適切に選択しました。
// 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 Studio 内で、慣用的な Kotlin で記述された Android アプリで実行されていました。
本日、会場にはノートパソコンをお持ちの方がたくさんいらっしゃいます。Egor と私の話を聞いて、Android Studio を開いて Android XR エミュレータを起動してみようと思っていただければ幸いです。
想像力を解き放ち、既存の Android スキルと、本日ご紹介した内容を組み合わせて、素晴らしい XR エクスペリエンスを構築してください。
Egor と私から、本日はご参加いただき誠にありがとうございました。引き続き Android Dev Summit をお楽しみください。
{
"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))
}