Android XR

Jetpack XR SDK のカスタム 3D ジオメトリで 2D を超える

スピーカー

  • Egor Yusov

    ソフトウェア エンジニア

  • Jay Abi-Saad

    ソフトウェア エンジニア

概要

講演について

Android アプリを従来の 2D 画面から空間環境に移行するには、シェイプを構築するための新しいアプローチが必要です。

このセッションでは、Jetpack XR SDK の Custom Mesh API について説明します。この API を使用すると、実行時にプロシージャル 3D ジオメトリをプログラムで直接生成できます。頂点レイアウトの定義から、ユーザーの物理空間内での動的オブジェクトのシームレスなレンダリングまで、コア ワークフローを順を追って説明します。

このセッションを最後までお聞きいただくことで、インタラクティブなカスタム 3D ジオメトリを完全にコードから構築するための実践的なスキルを習得できます。

概要

  • Jetpack SceneCore の glTF イントロスペクション API は、実行時にモデルのメタデータを公開します。埋め込みアニメーションの再生、サブノードの変換、マテリアルとテクスチャによるメッシュのオーバーライドが可能です。
  • Jetpack SceneCore には、Kotlin でカスタム 3D ジオメトリを生成するためのメッシュ API が含まれています。VertexLayout や MeshBuffer などの 4 つのビルディング ブロックは、3D ツールを使用せずに三角形ごとにモデルを構築します。
  • 動的メッシュは、ユーザーがパスを描画したり、サーフェスをアニメーション化したりする際のリアルタイム更新をサポートします。変更された頂点データのみを更新すると、メッシュやエンティティを再作成することなく、変更が GPU に直接ストリーミングされます。
  • Gemini in Android Studio は、メッシュ API 用の複雑な頂点アニメーションとライティングの計算を記述できます。1 つのプロンプトで、頂点グリッドをアニメーション化し、フレームごとに法線を再計算する動作コードが生成されます。

リソース

Gemini Notebook

トピック

  • jetpack-xr
  • jetpack-scenecore
  • android-xr
  • 3d-geometry
  • mesh-api
  • gltf

文字起こし

Android XR と SceneCore の概要

  • 皆さん、こんにちは。

  • ジェイです。

  • 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 について説明します。

glTF 3D モデルの操作

  • まずは 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 について詳しく説明してもらいます。

メッシュ 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 の構成要素

  • 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 を使用してアニメーション メッシュを生成する

  • 頂点アニメーションの計算は複雑な部分なので、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 をお楽しみください。

コード

コード スニペット

「Working with glTF 3D models」の 1 分 22 秒の JSON スニペット
{
    "asset" : {...},
    "nodes" : [
        {
            "name" : "Right_Arm",
            "rotation" : [...],
            "scale" : [...],
            "translation" : [...],
        },
    ],
    "animations" : [
        {
            "name" : "Wave",
        },
    ],
    "meshes" : [
        {
            "name" : "Torso",
            "primitives" : [ ... ],
        },
    ],

    // Way way more!

}
「glTF 3D モデルの操作」の 2 分 28 秒の Kotlin スニペット
// 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
)
「モデルのアニメーションとサブノードの操作」の 2 分 50 秒の Kotlin スニペット
// 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()
「モデルのアニメーションとサブノードの操作」の 3 分 12 秒の Kotlin スニペット
// 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) }
}
「マテリアルとテクスチャの適用」の 3 分 51 秒の Kotlin スニペット
// 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)
「Introduction to mesh APIs」の 05:05 の Kotlin スニペット
// 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
)
「Building blocks of the mesh API」の 5 分 45 秒の Kotlin スニペット
// 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()
「Building blocks of the mesh API」の 06:10 の Kotlin スニペット
// 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)
「メッシュ API の構成要素」の 06:37 の Kotlin スニペット
// 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)
「Building blocks of the mesh API」の 7:04 の Kotlin スニペット
// 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))
「カスタム メッシュと動的メッシュの構築」の 07:31 の Kotlin スニペット
// 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)
「カスタム メッシュと動的メッシュの構築」の 8 分 18 秒の Kotlin スニペット
// 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)
「Gemini を使用したアニメーション メッシュの生成」の 9 分 34 秒の Kotlin スニペット
// 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 を使用したアニメーション メッシュの生成」の 9 分 56 秒の Kotlin スニペット
// 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))
}