Tool

Build cross-platform 3D and AR apps with declarative UI

Compose-native 3D and AR SDK for Android, iOS, Web, Desktop and more - opens glTF, USDZ, and 3MF files, with an official MCP server for AI coding.

Works with androidiosfilamentarcorerealitykit

88
Spark score
out of 100
Updated last month
Source checked Oct 4, 2026
Version 4.52.0

Add to Favorites

Why it matters

Developers hire SceneView to build 3D and augmented reality experiences across Android, iOS, Web, Desktop, TV, Flutter, and React Native using the same declarative UI concepts they already know from Jetpack Compose and SwiftUI, eliminating the need to learn low-level graphics engines or write platform-specific rendering code.

Outcomes

What it gets done

01

Render 3D models and scenes using Filament engine with Jetpack Compose on Android

02

Create AR experiences with ARCore plane detection, anchors, and geospatial features

03

Load and display glTF/GLB 3D models with automatic animation and scaling

04

Deploy the same 3D scene code across iOS (RealityKit), Web (Filament.js WASM), and native platforms

Source

Get it from source

Spark does not host a copy of it.

Open source

Reports

Agent outcome reports

No reports yet

Overview

SceneView is an open-source, Compose-native 3D and AR SDK spanning Android, iOS/macOS/visionOS, Web, Desktop, Android TV, Flutter, and React Native, built on ARCore and Filament (Android) or RealityKit (Apple). It opens glTF/GLB, USDZ, and - via a dependency-free Kotlin parser unique to Android - 3MF, STL, OBJ, and PLY files, and ships an official MCP server with 32 tools for AI-generated 3D/AR code. Use it to add 3D/AR to an app without a full game engine (roughly 5MB versus 50-100MB+ for Unity/Unreal) or to open arbitrary 3D files, including the .3mf an AI print flow emits; Android and the MCP server are Stable, other platforms are Alpha.

What it does

SceneView is an open-source, Compose-native 3D and AR SDK spanning Android, iOS/macOS/visionOS, Web, Desktop, Android TV, Flutter, and React Native - the same node-based API concepts (ModelNode, LightNode, CameraNode, and 20+ others) on every platform. On Android it descends from the maintained Sceneform community fork, filling the gap Google left when it archived Sceneform in 2021: ARCore handles perception (planes, anchors, depth, geospatial), Filament handles rendering, and Jetpack Compose is the API surface, with nodes as composables and automatic lifecycle. It can open glTF/GLB, USDZ/Reality (Apple, via RealityKit), and - uniquely on Android via a dependency-free Kotlin parser - 3MF, STL, OBJ+MTL, and PLY files, deciding the format from the file's bytes rather than its extension.

When to use - and when NOT to

Use it to build 3D or AR features with the UI framework you already use, rather than adopting a full external engine: its own comparison cites a roughly 5MB footprint against 50-100MB+ for Unity or Unreal. It is specifically useful for opening arbitrary 3D files a user receives - a chat attachment, a 3D-printing site export, or (its most distinctive case) the .3mf file an AI print-generation flow hands back, which nothing else on Android could view in 3D or AR before this. Stability varies by platform: Android (Stable) and the MCP Server (Stable) are furthest along; iOS/macOS/visionOS, Web, Desktop, Flutter, React Native, and Compose Multiplatform are all Alpha. Its AR feature set (ARCore-backed: geospatial anchors, cloud anchors, augmented faces/images) is Android-only; iOS AR is a separate, smaller RealityKit-based API.

Inputs and outputs

Input is a 3D model file (glTF/GLB, USDZ, 3MF, STL, OBJ+MTL, or PLY) referenced from code, or - for AI-driven use - a natural-language request handled through its MCP server. Output is a rendered 3D/AR scene: model nodes with skeletal/morph animation, procedural geometry, physics, HDR lighting, post-processing, or even arbitrary Compose UI rendered and touch-interactive inside the 3D scene via ViewNode. For 3MF specifically, conversion honors the file's declared millimetre units (so a 60mm print renders life-size in AR), rotates the printer's Z-up to glTF's Y-up, and maps basematerials/colorgroup to one glTF material per colour.

Integrations

The official MCP server (npx sceneview-mcp) exposes 32 tools (29 free) plus 38 compilable samples, a code validator, and recipes for AR, physics, geometry, and Compose-in-3D:

claude mcp add sceneview -- npx sceneview-mcp

It's listed on the official MCP Registry, ships a Claude Code plugin (marketplace-installed, adding slash commands and cross-platform-sync hooks), a ChatGPT/Codex plugin (.codex-plugin/plugin.json), and rules files for GitHub Copilot, Cursor, and Windsurf. Three specialty MCP servers extend the same engine into automotive (9 tools), healthcare (7 tools), and Rerun.io AR-debug (5 tools) domains.

Who it's for

Android, iOS, and web developers who want declarative 3D/AR without hand-rolling OpenGL or bringing in a full game engine, and AI coding assistants generating 3D/AR code against a machine-readable API reference (llms.txt) and a compile-time validator - the project explicitly designs its docs and samples for AI-assisted, first-try-compilable code generation. License details are in the project's LICENSE file in its GitHub repository. It also supports built-in touch gestures (drag, pinch-to-scale, rotate) per node, collision detection and raycasting, and picture-in-picture-style multiple cameras.

Source README

SceneView

The AI-first 3D & AR SDK for Jetpack Compose, SwiftUI and the web.

Declarative 3D and AR for app developers who want a model on screen, or in the room, without a game engine: Filament + ARCore on Android, RealityKit + ARKit on Apple, Filament.js + WebXR in the browser - with Flutter, React Native and Compose Multiplatform bridges on top. Open source, Apache 2.0.

AI-first means one thing here: an assistant that reads llms.txt or the MCP server writes SceneView code that works on the first try. When it doesn't, the API or the doc gets fixed.

Android 3D Android AR iOS / macOS / visionOS sceneview-web Flutter React Native MCP Server

CI License GitHub Stars Discord Open Collective


Try it

Get it on Google PlayDownload on the App StoreOpen the Web Playground

The demo apps are built from samples/. Any demo opens straight from a link: https://sceneview.github.io/open?demo=<id>. Start with Cosmos, the galaxy the catalogue opens on, or Rerun AR Replay, a real AR session rebuilt in 3D.


Quick start

One minimal, working example per platform. The full reference for each is in llms.txt.

Android
// build.gradle.kts
implementation("io.github.sceneview:sceneview:4.52.0")
@Composable
fun ModelScreen() {
    SceneView(modifier = Modifier.fillMaxSize()) {          // orbit camera + default lighting
        rememberModelInstance(modelLoader, "models/helmet.glb")?.let { instance ->
            ModelNode(modelInstance = instance, scaleToUnits = 1.0f, autoAnimate = true)
        }
    }
}

models/helmet.glb lives in src/main/assets/. rememberModelInstance returns null until the model is loaded, then recomposes. Never call modelLoader.createModel* from a background coroutine: Filament calls must run on the main thread, and rememberModelInstance handles that.

AR (Android)
// build.gradle.kts
implementation("io.github.sceneview:arsceneview:4.52.0")   // includes the 3D module
@Composable
fun ARScreen() {
    var anchor by remember { mutableStateOf<Anchor?>(null) }

    ARSceneView(
        modifier = Modifier.fillMaxSize(),
        planeRenderer = true,
        onSessionUpdated = { _, frame ->
            if (anchor == null) {
                anchor = frame.getUpdatedPlanes()
                    .firstOrNull { it.type == Plane.Type.HORIZONTAL_UPWARD_FACING }
                    ?.let { frame.createAnchorOrNull(it.centerPose) }
            }
        }
    ) {
        val helmet = rememberModelInstance(modelLoader, "models/helmet.glb")
        anchor?.let { a ->
            AnchorNode(anchor = a) {
                helmet?.let { ModelNode(modelInstance = it, scaleToUnits = 0.5f) }
            }
        }
    }
}

Plane detected → anchor set → Compose recomposes → the model appears. AR state is just Kotlin state. For placement without a tap, grounded and gesture-ready, start from AutoPlacementScene (see llms.txt).

iOS / macOS / visionOS (SwiftUI)

Swift Package Manager: https://github.com/sceneview/sceneview.git, from 4.39.0.

import SwiftUI
import SceneViewSwift

struct ModelScreen: View {
    @State private var model: ModelNode?

    var body: some View {
        SceneView { root in
            if let model { root.addChild(model.entity) }
        }
        .contentID(model != nil)      // re-run the builder once the model has loaded
        .environment(.studio)
        .cameraControls(.orbit)
        .task { model = try? await ModelNode.load("helmet.usdz") }
    }
}

The content closure runs once unless its .contentID(_:) changes: without that line, a model that finishes loading after the scene appears is never added.

Web
<canvas id="viewer" style="width: 100%; height: 480px"></canvas>
<script src="https://cdn.jsdelivr.net/gh/sceneview/sceneview@v4.52.0/website-static/js/filament/filament.js"></script>
<script src="https://cdn.jsdelivr.net/gh/sceneview/sceneview@v4.52.0/website-static/js/sceneview.js"></script>
<script> SceneView.modelViewer("viewer", "model.glb") </script>
Compose Multiplatform (Android · iOS · desktop)
// commonMain dependencies
implementation("io.github.sceneview:sceneview-compose:4.52.0")
SceneViewer(model = ModelSource.Asset("models/helmet.glb"), modifier = Modifier.fillMaxSize())

A viewer by design - load, orbit, light, tap; no AR. Scope and the one-time iOS setup: sceneview-compose/.

Flutter
// pubspec.yaml → flutter_sceneview: ^4.52.0
final controller = SceneViewController();

SceneView(
  controller: controller,
  onViewCreated: () => controller.loadModel(
    const ModelNode(modelPath: 'models/helmet.glb'),
  ),
)
React Native
// npm install @sceneview-sdk/react-native
import { SceneView } from '@sceneview-sdk/react-native';

<SceneView
  style={{ flex: 1 }}
  modelNodes={[{ src: 'models/helmet.glb', position: [0, 0, -2] }]}
  cameraControlMode="orbit"
/>

Flutter and React Native bridge a subset of the native API - see flutter/ and react-native/ for what is covered.

Your AI assistant
claude mcp add sceneview -- npx -y sceneview-mcp   # Claude Code
codex  mcp add sceneview -- npx -y sceneview-mcp   # Codex

Then ask: "Add a 3D model viewer to my Compose screen." Every other client is in Use with AI.


Runs on

Each row is a published package plus an app or page you can open today, backed by a real capture of it.

Platform Status Install Open it Capture
Android Shipped · Stable Maven Central Google Play SceneView Android demo: Model Viewer rendering a helmet with Filament
iOS Shipped · Alpha Swift Package App Store SceneView iOS demo: Model Viewer rendering a helmet with RealityKit
Web Shipped · Alpha npm sceneview-web Live web demo SceneView web demo: Toy Car rendered by Filament.js in a browser

Shipped means released and publicly reachable; the second word is the API maturity. The other platforms in the table below join this section once a capture backs them.


Platforms

Platform Renderer Framework Status
Android Filament Jetpack Compose Stable
Android TV Filament Compose TV Alpha
iOS / macOS / visionOS RealityKit SwiftUI Alpha
Web Filament.js (WebGL2 / WASM) JavaScript + Kotlin/JS Alpha
Compose Multiplatform Filament (Android, desktop) · RealityKit (iOS) sceneview-compose Alpha - viewer subset
Desktop (JVM) Filament, via filament-kmp Compose Desktop (sceneview-compose) Alpha
Flutter Native per platform PlatformView Alpha
React Native Native per platform Fabric Alpha
AI assistants - llms.txt, MCP server, skills Stable

Install

Platform Coordinate
Android 3D io.github.sceneview:sceneview:4.52.0
Android AR io.github.sceneview:arsceneview:4.52.0
Compose Multiplatform io.github.sceneview:sceneview-compose:4.52.0
KMP core only (math, collision, physics) io.github.sceneview:sceneview-core:4.52.0
Apple (SPM) https://github.com/sceneview/sceneview.git, from 4.39.0
Web, script tag the two <script> tags in Web
Web, bundler (Kotlin/JS) npm install sceneview-web - see SceneView Web
Flutter flutter_sceneview on pub.dev
React Native @sceneview-sdk/react-native on npm
AI assistants npx -y sceneview-mcp - see Use with AI

Use with AI

Everything an assistant needs to write SceneView code ships with the SDK:

  • llms.txt - the complete API reference in one file: composables, every node type, threading rules, recipes. Its Kotlin snippets are compiled in CI. Served at https://sceneview.github.io/llms.txt for tools without MCP support.
  • Rules files - AGENTS.md (Codex, Cursor, GitHub Copilot, Gemini in Android Studio and others), CLAUDE.md (Claude Code), .github/copilot-instructions.md, and .cursorrules for older Cursor versions.
  • The MCP server - free, no API key. The tools assistants reach for most: validate_code (checks a snippet against the real public API before you run it), get_node_reference (the exact node signature, not an invented one), list_samples / get_sample (38 samples to start from), and get_setup / get_ar_setup (project wiring).
MCP setup
claude mcp add sceneview -- npx -y sceneview-mcp   # Claude Code
codex  mcp add sceneview -- npx -y sceneview-mcp   # Codex
copilot mcp add sceneview -- npx -y sceneview-mcp  # GitHub Copilot CLI
// Cursor (.cursor/mcp.json), Cline, JetBrains AI Assistant
{ "mcpServers": { "sceneview": { "command": "npx", "args": ["-y", "sceneview-mcp"] } } }
// VS Code (.vscode/mcp.json) uses the "servers" key instead
{ "servers": { "sceneview": { "type": "stdio", "command": "npx", "args": ["-y", "sceneview-mcp"] } } }

Clients that only speak HTTP (Gemini in Android Studio, ChatGPT) use the hosted endpoint https://mcp.sceneview.dev/mcp, or run their own with npx sceneview-mcp --http. Per-client snippets: sceneview.github.io/#ai-setup. Listed on the MCP Registry.

ChatGPT / Codex plugin

This repository is also an OpenAI plugin: .codex-plugin/plugin.json points at the three skills under agents/ (sceneview, sceneview-ios, sceneview-web). From a checkout:

codex plugin marketplace add "$PWD"    # absolute path — a relative one does not resolve
codex plugin add sceneview@sceneview-local

Codex also discovers the skills from .agents/skills/ on its own. Over HTTP the MCP server carries an inline view_3d_model widget that renders a public GLB/glTF URL in the conversation. Listing copy and test prompts: agents/OPENAI-PLUGIN.md.

Claude Code plugin

/plugin marketplace add sceneview/claude-marketplace, then /plugin install sceneview@sceneview, installs the same three skills as the Codex plugin together with the MCP server. The commands used to work on this repository are a separate plugin, sceneview-contrib@sceneview - see sceneview/claude-marketplace.

Vertical MCP servers (Rerun AR debugging and others) are listed in the MCP README.


Android in depth

3D scene

SceneView is a composable that renders a Filament viewport. Nodes are composables inside it.

val engine = rememberEngine()
val modelLoader = rememberModelLoader(engine)
val environmentLoader = rememberEnvironmentLoader(engine)

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader,
    environment = rememberEnvironment(environmentLoader) {
        environmentLoader.createHDREnvironment("envs/studio.hdr")
            ?: createEnvironment(environmentLoader)
    },
    cameraManipulator = rememberCameraManipulator()
) {
    // Model — async loaded, appears when ready
    rememberModelInstance(modelLoader, "models/helmet.glb")?.let {
        ModelNode(modelInstance = it, scaleToUnits = 1.0f, autoAnimate = true)
    }

    // Geometry — procedural shapes
    CubeNode(size = Size(0.2f))
    SphereNode(radius = 0.1f, position = Position(x = 0.5f))

    // Nesting — same as Column { Row { } }
    Node(position = Position(y = 1.0f)) {
        LightNode(apply = { type(LightManager.Type.POINT); intensity(50_000f) })
        CubeNode(size = Size(0.05f))
    }
}
Node composables - 27 in 3D, 15 more in AR
Category Nodes What they do
Models ModelNode glTF/GLB with skeletal/morph animations. isEditable = true for gestures.
Primitives CubeNode · SphereNode · CylinderNode · ConeNode · TorusNode · CapsuleNode · TubeNode · PlaneNode Procedural geometry, parametric size/segments
Curves & shapes LineNode · PathNode · ShapeNode Single segments, polylines, extruded 2D polygons
Custom geometry MeshNode Your own Filament VertexBuffer / IndexBuffer
Surfaces ImageNode · VideoNode · BillboardNode PNG/JPG plane, video plane (MediaPlayer), camera-facing sprite
3D text TextNode World-space text label that always faces the camera
Compose-in-3D ViewNode Any Compose UI rendered as a 3D surface, fully touch-interactive
Gaussian splats SplatNode Render a Gaussian-splat capture
Lighting & sky LightNode · ReflectionProbeNode · DynamicSkyNode · FogNode Sun/dir/point/spot lights, local IBL, time-of-day sky, atmospheric fog
Physics PhysicsNode Simple rigid-body simulation (gravity, collisions)
Cameras CameraNode · SecondaryCamera Main and picture-in-picture cameras
Group Node Empty pivot for nesting and transform inheritance
AR scene

ARSceneView is SceneView with ARCore: the camera follows real-world tracking. See the AR quick start above.

Node What it does
AnchorNode Pin a node to a real-world ARCore Anchor
HitResultNode · DepthHitResultNode Live surface cursor from each frame's hit-test (planes or depth)
PoseNode Position a node at any ARCore Pose
PlaneNode · ReticleNode Render a detected plane, or a placement reticle
PointCloudNode · DepthMeshNode · SceneMeshNode Feature points, depth mesh, classified geospatial scene mesh
AugmentedImageNode Image tracking - pose + 2D extent of a detected image
AugmentedFaceNode Face mesh overlay (front camera)
CloudAnchorNode Persistent cross-device anchor (host + resolve)
StreetscapeGeometryNode Geospatial - semantic city mesh (buildings, terrain)
TerrainAnchorNode Geospatial - anchor pinned to ground at a lat/lng
RooftopAnchorNode Geospatial - anchor pinned to a building rooftop
Feature API surface
Automatic placement AutoPlacementScene + AutoPlacementModel - grounded, gesture-ready, no tap
Plane / depth / instant placement ARSceneView(planeRenderer = …, depthMode = …, instantPlacementMode = …)
Geospatial (VPS) Streetscape + Terrain + Rooftop anchors via Earth session
Cloud Anchors CloudAnchorNode.host(ttlDays = N) + .resolve(id)
Augmented Faces & Images AugmentedFaceNode, AugmentedImageDatabase, runtime image add
Image Stabilization (EIS) ARSceneView(imageStabilizationMode = ImageStabilizationMode.EIS)
Camera exposure & focus ARSceneView(cameraConfig = …), ARSceneScope.exposureCompensation
Record & Replay rememberARRecorder() to capture, ARSceneView(playbackDataset = file) to replay 1:1 - see AR debugging
Rerun.io live debug rememberRerunBridge() streams poses, planes and point clouds to the Rerun viewer
Permission flow ARPermissionHandler - auto-detected from ComponentActivity
Capabilities
Capability What it gives you Where it lives
Gestures Drag, pinch-to-scale, two-finger rotate, elevate, tap. Per-node opt-in via isEditable. NodeGestureDelegate, OnGestureListener
Animations Skeletal/morph from glTF, plus per-node spring/property/smooth-transform. ModelNode.playAnimation(), NodeAnimationDelegate
Physics Rigid-body dynamics - gravity, collisions, impulses. Pure Kotlin Multiplatform, no JNI. PhysicsNode, sceneview-core
Collision & raycasting Ray vs box / sphere intersections, hit-testing, frustum culling. CollisionSystem, Ray, Box, Sphere
Procedural geometry Cube/sphere/cylinder/cone/torus/capsule generators, extrusion from 2D shapes (Earcut + Delaunator). sceneview-core geometry + triangulation
HDR environment IBL lighting + skybox from .hdr / .ktx. Async load + reactive swap. EnvironmentLoader, rememberEnvironment
Custom materials Filament .filamat materials with parameters, plus built-in unlit / lit / overlay variants. MaterialLoader
Post-processing Bloom, depth of field, SSAO, vignette, color grading, tone mapping. View.bloomOptions, dynamicResolutionOptions, …
Compose UI in 3D Any @Composable as a textured plane in world space. Touches are forwarded, so Button.onClick, ripples and inner scrolling work. ViewNode + ViewNode.WindowManager
Multiple cameras Picture-in-picture, mini-map, security-camera views. SecondaryCamera
Reactive scene graph Change state → the tree updates. No imperative parent.addChild(). SceneScope / ARSceneScope DSL

Model formats

Format Android Apple Web
glTF / GLB ✅ - convert to USDZ (tools/convert-usdz.sh) ✅
USDZ / Reality / USD - ✅ RealityKit -
STL (binary + ASCII) ✅ converted to GLB in memory ✅ ModelIO, millimetres by default -
OBJ + MTL ✅ converted to GLB, material colours ✅ ModelIO -
PLY (binary + ASCII) ✅ converted to GLB, vertex colours ✅ ModelIO, vertex colours -
3MF ✅ converted to GLB, declared units honoured ✅ SceneViewSwift parser, declared units honoured -

There is no per-format API on Android: rememberModelInstance(modelLoader, path) accepts all of them. On Apple, RealityKit reads no glTF, so .gltf / .glb are deliberately not in ModelFormat; USD/Reality load through RealityKit and the mesh formats through MeshAsset. The format is decided by the file's bytes, not its extension, so a file shared into your app as application/octet-stream with no name still opens. The converters are dependency-free Kotlin in sceneview-core. Tracked next: one ModelFormat entry point and every format on the web. Details: Model formats.


Apple (iOS / macOS / visionOS)

Native Swift Package built on RealityKit, with a node set mirroring the Android API. Content you can build synchronously uses the @NodeBuilder form:

SceneView {
    GeometryNode.cube(size: 0.1, color: .blue)
        .position(.init(x: 0.5, y: 0, z: 0))
    LightNode.directional(intensity: 1000)
}
.environment(.studio)
.cameraControls(.orbit)

AR on iOS - tap a detected plane to place content:

ARSceneView(
    planeDetection: .horizontal,
    onTapOnPlane: { position, arView in
        let anchor = AnchorNode.world(position: position)
        anchor.add(GeometryNode.cube(size: 0.1, color: .blue).entity)
        arView.scene.addAnchor(anchor.entity)
    }
)

Nodes - ModelNode · GeometryNode (cube/sphere/cylinder/cone/torus/capsule/plane) · LightNode · ImageNode · VideoNode · TextNode · ViewNode · BillboardNode · MeshNode · LineNode · PathNode · ShapeNode · PhysicsNode · ReflectionProbeNode · DynamicSkyNode · FogNode · CameraNode · AnchorNode · AugmentedImageNode · SceneReconstructionNode (visionOS scene mesh). Plus an iOS RerunBridge with the same wire format as Android.

Full guide: SceneViewSwift/README.md.


SceneView Web

Two <script> tags and one call (see Web above). The wrapper is 24 KB gzipped; the engine it drives is Filament - the same renderer as Android SceneView - compiled to WebAssembly (2.3 MB gzipped).

JavaScript API (script tag):

  • SceneView.modelViewer(canvasOrId, url, options?) - all-in-one viewer with orbit + auto-rotate
  • SceneView.create(canvasOrId, options?) - empty viewer, load a model later
  • viewer.loadModel(url) - load or replace a glTF/GLB model
  • viewer.setAutoRotate(enabled) - toggle rotation
  • viewer.dispose() - release resources

Kotlin/JS (sceneview-web, npm only) - the power-user API: OrbitCameraController, the geometry DSL, reactive node updates, and WebXR through ARSceneView (immersive-ar, hit-test, anchors, light estimation), VRSceneView (immersive-vr, controllers) and the low-level WebXRSession. The module builds a webpack bundle, so it has no Maven coordinate:

npm install sceneview-web

The package expects a Filament global and does not include the SceneView.modelViewer script helpers. A Kotlin Multiplatform project that only needs the shared core (collision, math, geometry, animation, physics - no renderer) uses implementation("io.github.sceneview:sceneview-core-js:4.52.0").

Landing page · Playground · npm


The Compose-native successor to Sceneform

Google archived Sceneform in 2021 and ships no first-party declarative AR renderer. SceneView descends from the maintained Sceneform community fork: ARCore for perception, Filament for rendering, Jetpack Compose for the API, and glTF (.glb / .gltf) instead of the deprecated .sfb format.

Coming from Sceneform? The migration guide maps it concept by concept (ArFragment → ARSceneView { }, ModelRenderable → rememberModelInstance, and so on).


AR debugging

  • Record & Replay - capture an ARCore session once with rememberARRecorder(), replay it 1:1 at your desk with ARSceneView(playbackDataset = file). See docs/docs/ar-recording.md and the Record & Playback demo.
  • Hosted Rerun viewer - tap Save & Share in the AR Rerun demo, host the .rrd file on any public URL, and open https://sceneview.github.io/rerun/?url=<encoded-url> to scrub the session frame by frame in a browser, with no local install. Architecture and the Kotlin API (RerunBridge.requestSaveAndShare) are in the AR Debug - Rerun.io section of llms.txt.

Architecture

Each platform uses its native renderer. Shared logic lives in Kotlin Multiplatform.

sceneview-core (Kotlin Multiplatform)
├── math, collision, geometry, physics, animation, model-format converters
│
├── sceneview (Android)          → Filament + Jetpack Compose
├── arsceneview (Android)        → ARCore
├── sceneview-compose (KMP)      → one SceneViewer for Android, iOS and desktop
├── SceneViewSwift (Apple)       → RealityKit + SwiftUI
├── sceneview-web (Web)          → Filament.js + WebXR
└── flutter/ · react-native/     → bridges to the native views

Samples

Sample Platform Run
samples/android-demo Android - 3D & AR ./gradlew :samples:android-demo:assembleDebug
samples/android-tv-demo Android TV ./gradlew :samples:android-tv-demo:assembleDebug
samples/ios-demo iOS - 3D & AR Open in Xcode
samples/web-demo Web ./gradlew :samples:web-demo:jsBrowserRun
samples/desktop-demo Desktop (JDK 22+) ./gradlew :samples:desktop-demo:run
samples/flutter-demo Flutter cd samples/flutter-demo && flutter run
samples/react-native-demo React Native See its README

Built with SceneView

  • 3D AR Model Viewer - open a 3D file from any app or link (GLB, glTF, STL, OBJ, PLY, 3MF) and see it in your room at real size.
  • Will It Fit - enter a piece of furniture's dimensions and see whether it fits before you buy.

Links

tools/convert-usdz.sh

#!/usr/bin/env bash
#
# convert-usdz.sh — Convert glTF/GLB models to USDZ for iOS/RealityKit bundling.
#
# SceneView's Apple targets (SceneViewSwift / RealityKit) can only load
# `.usdz` / `.reality` (see `ModelSource.swift`'s own comment: RealityKit's
# `Entity(contentsOf:)` loads only those two formats — it does not transcode
# glTF). Every other platform bundles the source `.glb` directly, so any GLB
# model that should also ship on iOS needs a one-time USDZ conversion. This
# script is that conversion pipeline (issue #2806).
#
# ── Why Blender, not usdzconvert/USDPython ──────────────────────────────────
#
# Apple's own `usdzconvert` (part of the USDPython / Reality Converter
# tooling) is NOT installed on this host and pulling it in means a multi-
# hundred-MB Apple download (Reality Converter.app or the USD Python tools)
# for a single conversion script. Blender is already installed
# (`/Applications/Blender.app`) and ships, out of the box:
#   - a glTF 2.0 importer (`bpy.ops.import_scene.gltf`, handles Draco)
#   - a native USD/USDZ exporter (`bpy.ops.wm.usd_export`) that zips straight
#     to `.usdz` when the output path ends in that extension
#   - full headless scripting (`blender --background --python <script>`)
# So Blender is the zero-extra-install path and the one this script uses.
# If a future contributor prefers `usdzconvert`, this script's CLI surface
# (glb in, usdz out) is a drop-in replacement target — nothing else in the
# pipeline depends on Blender specifically.
#
# ── What this script does NOT do ────────────────────────────────────────────
#
# It only converts. It never touches the repo working tree — every output
# lands under `--out-dir` (default: a fresh `mktemp -d` under `${TMPDIR:-/tmp}`,
# printed at the end). Copying a verified `.usdz` into
# `samples/ios-demo/SceneViewDemo/Models/`, registering it in
# `SceneViewDemo.xcodeproj/project.pbxproj`, and declaring it in
# `assets/catalog.json` are separate, deliberate steps performed by hand
# (or by a caller script) AFTER visually verifying the output — never
# automatically. That split is what makes a failed run leave the working
# tree clean: there is nothing in the repo for a failure to dirty.
#
# ── Usage ────────────────────────────────────────────────────────────────
#
#   bash tools/convert-usdz.sh <input.glb> [<input2.glb> ...] [options]
#
# Options:
#   --out-dir <dir>   Where to write the .usdz files (default: a fresh
#                      mktemp -d under ${TMPDIR:-/tmp}; printed on exit)
#   --force            Reconvert even if a same-named, newer output already
#                      exists in --out-dir (default: skip — idempotent reruns)
#   --blender <path>   Explicit Blender executable (default: $BLENDER_BIN,
#                      then /Applications/Blender.app/…, then `blender` in PATH)
#   --transmission-as-alpha
#                      Map glTF KHR_materials_transmission to alpha-blended
#                      opacity (UsdPreviewSurface has no transmission). Off by
#                      default so existing conversions are byte-for-byte
#                      unchanged; used for the HD pack's Flight Helmet lenses.
#   -h, --help         Show this help
#
# Example — the 4 Khronos sample models this script was written for:
#
#   bash tools/convert-usdz.sh \
#     samples/android-demo/src/main/assets/models/khronos_lantern.glb \
#     samples/android-demo/src/main/assets/models/khronos_toy_car.glb \
#     samples/android-demo/src/main/assets/models/khronos_fox.glb \
#     samples/android-demo/src/main/assets/models/khronos_damaged_helmet.glb \
#     --out-dir /tmp/usdz-out
#
# ── Verifying the output ────────────────────────────────────────────────────
#
# A conversion that "succeeds" but produces a black / textureless / absurdly
# -scaled model is a failure — always look at the result, never trust the
# exit code alone. Two options, in order of preference:
#
#   1. `qlmanage -p /tmp/usdz-out/foo.usdz` (or `qlmanage -t -s 1200 -o dir
#      file.usdz` for a headless thumbnail) opens the real macOS Quick Look
#      renderer. NOTE: in a sandboxed / non-interactive exec context (no
#      WindowServer session attached to the calling process) this has been
#      observed to hang indefinitely for `.usdz` specifically — even though
#      it works fine for ordinary files in the same shell. If it hangs,
#      don't fight it; use option 2 or drag the file onto a booted iOS
#      Simulator window (or serve it over local HTTP and open the URL in
#      the Simulator's Safari — the correct MIME type, `model/vnd.usdz+zip`,
#      is required for Safari to offer the "View 3D Object" AR Quick Look
#      prompt instead of just downloading the bytes).
#   2. Headless structural sanity check with no GUI dependency at all —
#      re-import the produced file and print mesh/material/texture/bounds:
#
#      blender --background --factory-startup --python-expr "
#      import bpy
#      bpy.ops.wm.usd_import(filepath='/tmp/usdz-out/foo.usdz')
#      for o in bpy.data.objects:
#          print(o.name, o.type)
#      for m in bpy.data.materials:
#          print('material', m.name)
#      for i in bpy.data.images:
#          print('image', i.name, i.size[:])
#      "
#
#      An empty image list, or an image with size (0, 0), means textures did
#      not come through — that is the "black / textureless" failure mode.
#
# Exit codes:
#   0  every input converted (or already up to date) — see per-file PASS/SKIP
#   1  at least one input failed to convert, was missing, or Blender itself
#      could not be found
#   2  invalid arguments
#
set -euo pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

usage() {
    sed -n '2,72p' "$0" | sed 's/^# \{0,1\}//'
}

# ---- Args ----
inputs=()
out_dir=""
force=false
blender_bin="${BLENDER_BIN:-}"
transmission_as_alpha=0

while [ $# -gt 0 ]; do
    case "$1" in
        --out-dir)
            [ $# -ge 2 ] || { echo -e "${RED}--out-dir requires a value${NC}" >&2; exit 2; }
            out_dir="$2"; shift 2 ;;
        --force)
            force=true; shift ;;
        --transmission-as-alpha)
            transmission_as_alpha=1; shift ;;
        --blender)
            [ $# -ge 2 ] || { echo -e "${RED}--blender requires a value${NC}" >&2; exit 2; }
            blender_bin="$2"; shift 2 ;;
        -h|--help)
            usage; exit 0 ;;
        --*)
            echo -e "${RED}Unknown option: $1${NC}" >&2; exit 2 ;;
        *)
            inputs+=("$1"); shift ;;
    esac
done

if [ ${#inputs[@]} -eq 0 ]; then
    echo -e "${RED}No input .glb/.gltf files given.${NC}" >&2
    usage
    exit 2
fi

# ---- Locate Blender ----
if [ -z "$blender_bin" ]; then
    if [ -x "/Applications/Blender.app/Contents/MacOS/Blender" ]; then
        blender_bin="/Applications/Blender.app/Contents/MacOS/Blender"
    elif command -v blender >/dev/null 2>&1; then
        blender_bin="$(command -v blender)"
    fi
fi

if [ -z "$blender_bin" ] || [ ! -x "$blender_bin" ]; then
    echo -e "${RED}Blender not found.${NC} Install it (https://www.blender.org/download/)," >&2
    echo "or point --blender / \$BLENDER_BIN at an existing install." >&2
    exit 1
fi

# ---- Output dir — always under /tmp unless the caller overrides it ----
# Intermediate conversion state (Blender scene, unpacked textures before the
# usdz zip step) never touches the repo — see the header comment.
if [ -z "$out_dir" ]; then
    out_dir="$(mktemp -d "${TMPDIR:-/tmp}/convert-usdz.XXXXXX")"
else
    mkdir -p "$out_dir"
fi

# ---- The Blender driver — written once per run to a /tmp work dir ----
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/convert-usdz-work.XXXXXX")"
trap 'rm -rf "$work_dir"' EXIT

driver="$work_dir/driver.py"
cat > "$driver" << 'PYEOF'
import sys
import bpy

argv = sys.argv[sys.argv.index("--") + 1:]
src_glb, dst_usdz = argv[0], argv[1]
transmission_as_alpha = len(argv) > 2 and argv[2] == "1"

# Start from a truly empty scene so nothing from Blender's factory startup
# file (default cube/camera/light) leaks into the export.
bpy.ops.wm.read_factory_settings(use_empty=True)

import_result = bpy.ops.import_scene.gltf(filepath=src_glb)
if import_result != {'FINISHED'}:
    print(f"IMPORT FAILED: {import_result}", file=sys.stderr)
    sys.exit(1)

# glTF `KHR_materials_transmission` (clear glass) has no UsdPreviewSurface
# equivalent: exported as-is, a transmissive material lands in the USDZ with
# `opacity = 1` and RealityKit draws the glass as an opaque slab (the Flight
# Helmet's goggle lenses, HD pack v1). Fall back to alpha blending, which
# UsdPreviewSurface does carry: the base-colour texture's own alpha when it has
# one (the Flight Helmet authored its lens opacity there before the asset moved
# to transmission), otherwise a constant derived from the transmission factor.
# Opt-in (--transmission-as-alpha) so other conversions stay exactly as before.
for mat in (bpy.data.materials if transmission_as_alpha else []):
    if not mat.use_nodes:
        continue
    nodes, links = mat.node_tree.nodes, mat.node_tree.links
    for bsdf in (n for n in nodes if n.type == 'BSDF_PRINCIPLED'):
        tw_socket = bsdf.inputs.get('Transmission Weight') or bsdf.inputs.get('Transmission')
        if tw_socket is None or tw_socket.is_linked or tw_socket.default_value <= 0:
            continue
        alpha = bsdf.inputs['Alpha']
        if alpha.is_linked:
            continue
        base = bsdf.inputs['Base Color']
        tex = base.links[0].from_node if base.is_linked else None
        if tex is not None and tex.type == 'TEX_IMAGE' and tex.image is not None \
                and tex.image.channels == 4:
            links.new(tex.outputs['Alpha'], alpha)
            how = f"base-colour alpha of {tex.image.name}"
        else:
            alpha.default_value = max(0.1, 1.0 - 0.8 * tw_socket.default_value)
            how = f"constant {alpha.default_value:.2f}"
        tw_socket.default_value = 0.0
        if hasattr(mat, 'surface_render_method'):
            mat.surface_render_method = 'BLENDED'
        print(f"TRANSMISSION {mat.name}: opacity from {how}")

export_result = bpy.ops.wm.usd_export(
    filepath=dst_usdz,
    export_textures=True,          # pack textures into the .usdz zip
    generate_preview_surface=True, # UsdPreviewSurface shader RealityKit reads
    export_materials=True,
    export_animation=True,
    export_armatures=True,
    export_shapekeys=True,
    triangulate_meshes=False,
    overwrite_textures=True,
)
if export_result != {'FINISHED'}:
    print(f"EXPORT FAILED: {export_result}", file=sys.stderr)
    sys.exit(1)

# Structural sanity report — cheap, no render, but catches the "black /
# textureless" failure mode: a converted model with zero image textures, or
# an image that decoded to 0x0, did not actually bring its material along.
mesh_objs = [o for o in bpy.data.objects if o.type == 'MESH']
mats = list(bpy.data.materials)
imgs = list(bpy.data.images)

import mathutils
min_co = mathutils.Vector((float("inf"),) * 3)
max_co = mathutils.Vector((float("-inf"),) * 3)
for o in mesh_objs:
    for corner in o.bound_box:
        world_co = o.matrix_world @ mathutils.Vector(corner)
        min_co = mathutils.Vector(min(a, b) for a, b in zip(min_co, world_co))
        max_co = mathutils.Vector(max(a, b) for a, b in zip(max_co, world_co))
size = (max_co - min_co) if mesh_objs else mathutils.Vector((0, 0, 0))

zero_size_images = [i.name for i in imgs if i.size[0] == 0 or i.size[1] == 0]

print(
    "REPORT "
    f"meshes={len(mesh_objs)} materials={len(mats)} textures={len(imgs)} "
    f"zero_size_textures={len(zero_size_images)} "
    f"bbox={size.x:.3f}x{size.y:.3f}x{size.z:.3f}"
)
if not mesh_objs:
    print("WARNING: no mesh objects after import — output usdz has no geometry", file=sys.stderr)
if imgs and zero_size_images:
    print(f"WARNING: {len(zero_size_images)} zero-size texture(s): {zero_size_images}", file=sys.stderr)
PYEOF

# ---- Convert each input ----
fail_count=0
skip_count=0
ok_count=0

for src in "${inputs[@]}"; do
    if [ ! -f "$src" ]; then
        echo -e "  ${RED}MISSING${NC} $src"
        fail_count=$((fail_count + 1))
        continue
    fi
    case "$src" in
        *.glb|*.gltf) ;;
        *)
            echo -e "  ${RED}SKIP${NC} $src (not a .glb/.gltf)"
            fail_count=$((fail_count + 1))
            continue
            ;;
    esac

    base="$(basename "$src")"
    base="${base%.glb}"
    base="${base%.gltf}"
    dst="$out_dir/$base.usdz"

    if [ "$force" != true ] && [ -f "$dst" ] && [ "$dst" -nt "$src" ]; then
        echo -e "  ${YELLOW}SKIP${NC} $base.usdz (up to date — use --force to reconvert)"
        skip_count=$((skip_count + 1))
        continue
    fi

    echo -e "${BLUE}==${NC} $base.glb -> $base.usdz"
    if log="$("$blender_bin" --background --factory-startup --python "$driver" -- \
                "$(cd "$(dirname "$src")" && pwd)/$(basename "$src")" "$dst" "$transmission_as_alpha" 2>&1)"; then
        echo "$log" | grep -E "^REPORT|^WARNING|^TRANSMISSION" || true
        if [ -s "$dst" ]; then
            size_h="$(du -h "$dst" | cut -f1)"
            echo -e "  ${GREEN}OK${NC} $dst ($size_h)"
            ok_count=$((ok_count + 1))
        else
            echo -e "  ${RED}FAIL${NC} $base — Blender exited 0 but produced no/empty output"
            echo "$log" | tail -20
            fail_count=$((fail_count + 1))
        fi
    else
        echo -e "  ${RED}FAIL${NC} $base — Blender conversion failed"
        echo "$log" | tail -20
        fail_count=$((fail_count + 1))
    fi
done

echo
echo -e "${BLUE}== Summary ==${NC}  ok=$ok_count skip=$skip_count fail=$fail_count"
echo "Output directory: $out_dir"
echo "(Nothing was written to the repo — copy the verified .usdz files in by hand.)"

[ "$fail_count" -eq 0 ]

FAQ

Common questions

Discussion

Questions & comments · 0

Sign In Sign in to leave a comment.