Android XR

Jetpack XR SDK의 맞춤 3D 도형으로 2D를 뛰어넘는 환경 구현

스피커

  • 에고르 유소프

    소프트웨어 엔지니어

  • 제이 아비사드

    소프트웨어 엔지니어

정보

강연 소개

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로 바로 스트리밍됩니다.
  • Android 스튜디오의 Gemini는 메시 API를 위한 복잡한 정점 애니메이션 및 조명 수학을 작성할 수 있습니다. 프롬프트 하나로 꼭짓점 그리드를 애니메이션화하고 프레임마다 법선을 다시 계산하는 작동 코드를 생성합니다.

리소스

Gemini Notebook

주제

  • jetpack-xr
  • jetpack-scenecore
  • android-xr
  • 3D 도형
  • mesh-api
  • gltf

스크립트

Android XR 및 SceneCore 소개

  • 여러분, 안녕하세요.

  • 저는 제이입니다.

  • 저는 이고르입니다.

  • Google에서 Jetpack XR을 담당하는 소프트웨어 엔지니어입니다.

  • XREAL Aura 출시가 임박한 만큼, 플랫폼에서 우수한 공간 경험을 구축하는 데 필요한 모든 도구를 제공해 드리고자 합니다.

  • 무엇보다도 XR 하드웨어가 없어도 시작할 수 있습니다.

  • 오늘 보여드릴 모든 기능은 Android 스튜디오의 Android XR 에뮬레이터에서 지금 바로 사용하실 수 있습니다.

  • 이 Codelab의 목표는 Jetpack SceneCore 라이브러리를 사용하면 깊이 있는 3D 지식이 없어도 플랫폼에서 풍부한 공간 경험을 빌드할 수 있다는 것을 보여주는 것입니다.

  • 표시되는 모든 항목은 Android 스튜디오 내에서 관용적인 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 형식의 기본사항을 살펴봤으니 이제 앱에서 사용해 보겠습니다.

  • 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 모델의 메시입니다.

  • 잠시 후 에고르가 메시에 대해 더 자세히 설명해 줄 것입니다.

  • 하지만 지금은 자동차를 꾸며 보겠습니다.

  • 텍스처 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 지오메트리를 즉석에서 생성하는 기능이 필요합니다.

  • 이제 이고르가 새로운 Jetpack SceneCore 메시 API에 대해 자세히 설명해 드리겠습니다.

메시 API 소개

  • 제이, 고마워요.

  • 사용자가 거실에서 손가락으로 레이싱 트랙을 그릴 수 있도록 해 보겠습니다.

  • 트랙은 그려지기 전까지는 존재하지 않으므로 아무도 미리 모델링할 수 없습니다.

  • 코드로 삼각형을 하나씩 빌드합니다.

  • 메시는 공간의 점인 꼭짓점으로 시작합니다.

    // 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개마다 삼각형을 만듭니다.

  • 두 개의 삼각형이 하나의 정사각형을 만들고, 색인은 6개이지만 꼭짓점은 4개입니다. 두 개의 꼭짓점을 공유하기 때문입니다.

  • 계속 진행하면 큐브가 표시됩니다.

  • 제이의 자동차를 비롯한 모든 3D 모델은 동일한 방식으로 제작되며, 삼각형이 더 많을 뿐입니다.

메시 API의 구성요소

  • 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는 나중에 업데이트할 수 있는 고정 용량을 할당합니다.

  • 세 번째는 MeshBuffer의 뷰인 CustomMesh입니다.

    // 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, 텍스처 좌표 등 꼭짓점을 정의합니다.

  • 작은 도우미 함수가 사용자 경로를 리본으로 변환합니다.

  • 경로의 각 지점에 대해 각 측면에 두 개의 꼭짓점을 추가하고 이를 다음 쌍에 연결하는 두 개의 삼각형을 추가합니다.

  • 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 스튜디오에서 다음과 같은 프롬프트를 입력했습니다. "Jetpack SceneCore Mesh API를 사용하여 격자 UV가 있는 20x12 플래그 그리드를 빌드하고, updateVertexData를 사용하여 프레임마다 바람 파도를 애니메이션화하고, 조명이 주름을 따라가도록 법선을 다시 계산해 줘."

  • 몇 초 후 작동하는 코드를 얻었습니다.

  • 어떤 작업을 했는지 살펴보겠습니다.

  • 20x12 셀 그리드로 플래그 메시를 만들고 셀당 삼각형 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.5KB의 데이터를 GPU에 업로드합니다.

  • 메시나 항목을 다시 만들지 않습니다.

  • 이제 모두 함께 살펴보겠습니다.

  • 테이블 위에 자동차 모델을 배치한 다음 ARCore를 사용하여 트랙을 그립니다.

  • 방금 빌드한 플래그 메시가 목적지에 나타납니다.

  • 자동차가 트랙을 따라 주행하며 glTF Introspection API를 사용하여 자동차의 바퀴를 조향하고 회전합니다.

  • 메시는 아스팔트 재질로 표시되지만, 동영상에서 볼 수 있듯이 메시 API를 재질 API와 함께 사용하여 도로의 디자인과 분위기를 변경할 수도 있습니다.

  • 손끝에서 바로 강력한 기능을 사용할 수 있는 거죠.

요약 및 시작하기

  • 오늘 우리는 앱을 2D에서 공간으로 전환하는 다양한 방법을 살펴보았습니다.

  • 이제 glTF Introspection API를 사용하면 glTF 모델의 내부 메타데이터를 직접 활용하여 삽입된 애니메이션을 트리거하고, 개별 하위 노드를 변환하고, 런타임에 머티리얼을 교체하여 디자인과 분위기를 변경할 수 있습니다.

  • 그런 다음 미리 만들어진 3D 모델이 충분하지 않은 경우 새로운 Mesh API를 활용하여 맞춤 도형을 빌드할 수 있다는 것을 확인했습니다.

  • 정적 도형을 처음부터 빌드해야 하든, 동적 도형을 실시간으로 스트리밍해야 하든, 이제 이를 위한 도구를 사용할 수 있습니다.

  • 이고르가 보여준 것처럼 이러한 구성 요소를 Gemini와 결합하면 계산을 혼자서 할 필요가 없습니다.

  • 앞서 말씀드린 내용을 다시 한번 강조하자면, 지금까지 보여드린 모든 것은 Android 스튜디오에서 관용적인 Kotlin으로 작성된 Android 앱에서 실행되었습니다.

  • 오늘 많은 분이 노트북을 가져오셨는데요. 저와 이고르가 여러분께 충분한 흥미를 드렸기를 바라며, 오늘 Android 스튜디오를 열고 Android XR 에뮬레이터를 실행해 보시기 바랍니다.

  • 상상력을 마음껏 발휘하고 기존 Android 기술과 오늘 보여드린 내용을 활용하여 놀라운 XR 환경을 구축해 보세요.

  • 이고르와 저를 대신해 오늘 함께해 주셔서 감사드리며, 남은 Android Dev Summit도 즐겁게 보내시기 바랍니다.

코드

코드 스니펫

'glTF 3D 모델 작업'의 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) }
}
'Applying materials and textures'의 03: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)
'메시 API 소개'의 5분 5초 지점에 있는 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
)
'메시 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()
'메시 API의 구성요소'에서 6분 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의 구성요소'에서 6분 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)
'메시 API의 구성요소'에서 7분 4초 지점의 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))
'맞춤 및 동적 메시 빌드'의 7분 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))
}