Performance Guide¶
Ship smooth 60fps 3D and AR experiences on Android. This guide covers profiling, asset optimization, scene tuning, Compose best practices, and device-tier strategies for the SceneView SDK.
Measuring Performance¶
Before optimizing, measure. Guessing where time is spent leads to wasted effort.
Frame budget¶
At 60fps your app has 16.6ms per frame for everything: CPU logic, GPU rendering, Compose layout, and ARCore tracking. Anything over that budget causes dropped frames and visible jank.
The 16.6ms rule
60fps = 1000ms / 60 = 16.6ms per frame. That includes CPU work, GPU rendering, and any Compose recomposition. Aim for headroom — target 12ms so spikes don't push you over.
Android Studio Profiler¶
Use the built-in profiler in Android Studio to identify bottlenecks:
- CPU Profiler — look for long
onFrameoronSessionUpdatedcalls, excessive allocations, or blocking I/O on the main thread. - GPU Profiler — check for overdraw (red = 4x overdraw), long fragment shader times, or GPU-bound frames.
- Memory Profiler — watch for repeated allocations each frame (GC pauses cause
jank). Look for leaked
ModelInstanceorMaterialobjects.
Filament debug stats¶
Enable Filament's built-in frame statistics to see draw calls, triangle counts, and GPU timing without leaving your app:
Scene(
engine = engine,
modelLoader = modelLoader,
// ...
) {
// Access the Filament view for debug options
}
Quick debug overlay
Use adb shell dumpsys gfxinfo <package> for a quick frame-time histogram
without any code changes.
Model Optimization¶
Models are usually the biggest performance lever. A poorly optimized model can single-handedly destroy your frame rate.
Polygon count¶
| Target | Triangle budget |
|---|---|
| Interactive objects | < 100K triangles |
| Hero/showcase models | < 200K triangles (high-end only) |
| Background/environment | < 50K triangles |
Triangles add up fast
A single model might be 50K triangles, but if you have 10 in the scene that is 500K — well beyond mobile budgets. Always count total scene triangles.
Reduction tools:
- Blender — Decimate modifier (collapse or un-subdivide)
- meshoptimizer —
meshopt_simplifyfor automated LOD generation - gltfpack — CLI tool that simplifies, compresses, and optimizes glTF/GLB files
Use LOD (Level of Detail) when available: show high-poly when the camera is close, swap to low-poly at distance. This can cut triangle count by 50-80% for complex scenes.
Textures¶
Textures consume the most GPU memory and bandwidth on mobile.
| Rule | Recommendation |
|---|---|
| Format | KTX2 with Basis Universal compression |
| Max size | 2048x2048 for mobile (1024x1024 for low-end) |
| Mipmaps | Always enable for objects viewed at varying distances |
| Channels | Use single-channel textures for roughness/metallic, not full RGBA |
KTX2 saves memory and load time
KTX2 with Basis Universal (ETC1S or UASTC) compresses textures 4-8x compared to
raw PNG/JPEG, and they stay compressed in GPU memory. Convert with
toktx --t2 --bcmp input.png output.ktx2.
File size¶
Smaller files mean faster loading and less memory pressure:
| Target | Size |
|---|---|
| Interactive models | < 10MB |
| Hero/showcase models | < 50MB |
| Quick-load previews | < 2MB |
Optimization checklist:
- [x] Use GLB (binary glTF) instead of glTF + separate .bin/.png files
- [x] Enable Draco geometry compression for mesh data
- [x] Strip unused animations, blend shapes, and extra UV sets
- [x] Run
gltfpackas a final optimization pass
# Example: optimize a model with gltfpack
gltfpack -i model.glb -o model_optimized.glb -tc -cc -si 0.5
# -tc = texture compression, -cc = codec compression, -si = simplification ratio
Scene Optimization¶
Limit draw calls¶
Each visible node typically generates one or more draw calls. On mobile, aim for fewer than 100 draw calls per frame.
- Fewer separate nodes = fewer draw calls. Merge static geometry in your 3D tool before export.
- Use instancing for repeated objects (trees, rocks, particles). Filament supports GPU instancing for identical meshes.
- Frustum culling is automatic in Filament — objects outside the camera view are not rendered. But they still cost CPU time if they exist as nodes.
Merge before export
If you have 50 static building meshes in Blender, join them into one object before exporting to GLB. This turns 50 draw calls into 1.
Lights¶
Lights are one of the most expensive parts of a scene. Each additional light increases per-fragment shading cost.
| Light type | Cost | Recommendation |
|---|---|---|
| Directional (sun) | Low | Use 1 as your main light |
| Point / Spot | Medium | Limit to 2-3 total |
| Shadow-casting | High | Limit to 1-2 lights with shadows |
val mainLight = rememberMainLightNode(engine) {
intensity = 100_000f
// Shadows on the main light only
}
Use IBL instead of many point lights
Image-Based Lighting (IBL) from an HDR environment map provides realistic ambient lighting at nearly zero per-frame cost. One directional light + IBL covers most use cases better than 5+ point lights.
val environment = rememberEnvironment(environmentLoader) {
createHDREnvironment("environments/studio.hdr")!!
}
Post-processing¶
Post-processing effects look great but eat into your frame budget:
| Effect | Typical cost | Notes |
|---|---|---|
| Bloom | ~1-2ms | Acceptable on mid-tier and above |
| Depth of Field | ~1-2ms | Use sparingly, mainly for screenshots |
| SSAO | ~2-3ms | Most expensive — skip on low-end devices |
| Anti-aliasing (FXAA) | ~0.5ms | Cheap, usually worth enabling |
SSAO on budget devices
Screen-Space Ambient Occlusion is the most expensive post-process effect. On low-end devices it can take 3ms+ alone — nearly 20% of your frame budget. Disable it on devices below your mid-tier threshold.
Enable effects selectively based on device tier (see Device Tiers below).
Compose Integration¶
SceneView is a Jetpack Compose library, so Compose performance rules apply directly.
Avoid unnecessary recompositions¶
Recomposition during rendering can cause frame drops. Follow these rules:
// BAD — creates new Position every recomposition, triggering node updates
Scene(/* ... */) {
ModelNode(
modelInstance = model,
position = Position(0f, 1f, 0f) // new object every recomposition!
)
}
// GOOD — stable reference, no unnecessary updates
val position = remember { Position(0f, 1f, 0f) }
Scene(/* ... */) {
ModelNode(
modelInstance = model,
position = position
)
}
No allocations in the composition body
Never create new Position, Rotation, Scale, or Quaternion objects
directly inside Scene { } without remember. Each recomposition creates
a new instance, causing the node to update every frame.
Key rules:
- Use
rememberfor stablePosition,Rotation, andScalereferences - Use
keyon model instances to avoid unnecessary reload when list order changes - Use
derivedStateOfwhen computing values from other state
Share the Engine¶
The Filament Engine is expensive to create. Never create more than one.
// At the app/activity level
val engine = rememberEngine()
val modelLoader = rememberModelLoader(engine)
val materialLoader = rememberMaterialLoader(engine)
val environmentLoader = rememberEnvironmentLoader(engine)
// Share across all scenes via CompositionLocal or parameter passing
Scene(engine = engine, modelLoader = modelLoader, /* ... */) { }
One Engine per app — not per screen
Creating multiple Engine instances wastes GPU memory and can cause crashes
on devices with limited resources. Create one at the top level and pass it down.
Lazy loading¶
Load models on-demand rather than all at startup:
// rememberModelInstance loads asynchronously and returns null while loading
val model = rememberModelInstance(modelLoader, "models/character.glb")
Scene(/* ... */) {
if (model != null) {
ModelNode(modelInstance = model)
} else {
// Show a placeholder while loading
CubeNode(materialInstance = placeholderMaterial)
}
}
rememberModelInstancehandles async loading and main-thread marshalling correctly- Show placeholder geometry (a simple cube or spinner) while the model loads
- For imperative code outside Compose, use
modelLoader.loadModelInstanceAsync
Threading: Filament calls must be on the main thread
Never call modelLoader.createModel* or materialLoader.* from a background
coroutine. rememberModelInstance handles this automatically. For imperative
code, use loadModelInstanceAsync.
AR-Specific Optimization¶
AR adds the camera feed and ARCore tracking to your frame budget, leaving less room for rendering.
Camera frame processing¶
onSessionUpdated runs every single frame. Any work you do here directly
impacts frame rate.
ARScene(
// ...
onSessionUpdated = { session, frame ->
// FAST: read a cached value
val planes = frame.getUpdatedPlanes()
// SLOW: don't do this!
// val bitmap = frame.acquireCameraImage().toBitmap() // allocation + conversion
}
)
No allocations in onSessionUpdated
This callback runs 30-60 times per second. Allocating objects here causes GC pressure and frame drops. Cache references outside the callback and reuse them.
Rules for onSessionUpdated:
- Do not allocate objects (no
listOf,map,filteron every frame) - Cache plane and anchor references outside the callback
- If you need heavy processing (image analysis, ML), dispatch to a background thread and read results on the next frame
Plane rendering¶
The AR plane renderer draws detected surfaces with a semi-transparent overlay. This causes GPU overdraw — especially problematic when multiple planes overlap.
ARScene(
planeRenderer = planeRenderer,
// ...
)
// Disable after the user has placed their object
LaunchedEffect(objectPlaced) {
if (objectPlaced) {
planeRenderer.isVisible = false // reduces overdraw
}
}
Disable planes after placement
Once the user has placed an object, disable planeRenderer. This removes
overdraw from plane visualization and saves 1-2ms per frame on most devices.
Device Tiers¶
Not all Android devices are equal. Adapt your scene complexity based on hardware capability.
| Tier | Example devices | Triangle budget | Post-processing | Shadows |
|---|---|---|---|---|
| High | Pixel 8 Pro, Samsung S24, OnePlus 12 | 200K triangles | Full (Bloom, SSAO, DoF) | 2 shadow-casting lights |
| Mid | Pixel 6a, Samsung A54, Pixel 7 | 100K triangles | Basic (Bloom only) | 1 shadow-casting light |
| Low | Older budget phones, 2GB RAM devices | 50K triangles | None | No shadows |
Detecting device tier¶
fun getDeviceTier(context: Context): DeviceTier {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE)
as ActivityManager
val memInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memInfo)
val totalRamGb = memInfo.totalMem / (1024.0 * 1024.0 * 1024.0)
return when {
totalRamGb >= 8.0 -> DeviceTier.HIGH
totalRamGb >= 4.0 -> DeviceTier.MID
else -> DeviceTier.LOW
}
}
enum class DeviceTier { HIGH, MID, LOW }
RAM is a proxy, not a guarantee
Total RAM is a rough proxy for device capability. For more accurate tiering,
also consider GPU model (GLES20.glGetString(GLES20.GL_RENDERER)), Android
version, and the Android Performance Tuner
library.
Applying tiers¶
val tier = remember { getDeviceTier(context) }
Scene(
engine = engine,
modelLoader = modelLoader,
// ...
) {
// Adjust quality based on tier
val modelPath = when (tier) {
DeviceTier.HIGH -> "models/character_high.glb"
DeviceTier.MID -> "models/character_mid.glb"
DeviceTier.LOW -> "models/character_low.glb"
}
val model = rememberModelInstance(modelLoader, modelPath)
if (model != null) {
ModelNode(modelInstance = model)
}
}
Performance Checklist¶
Use this checklist before shipping:
- [ ] Profile first — measured with Android Studio Profiler, not guessing
- [ ] Models — all models under 100K triangles (per model), total scene under 200K
- [ ] Textures — KTX2 compressed, max 2048x2048, mipmaps enabled
- [ ] File size — GLB format, Draco compressed, under 10MB for interactive models
- [ ] Draw calls — under 100 per frame, static geometry merged
- [ ] Lights — 1 directional + IBL, max 2-3 additional point/spot lights
- [ ] Shadows — limited to 1-2 shadow-casting lights
- [ ] Post-processing — adapted to device tier, SSAO disabled on low-end
- [ ] Compose — no allocations in composition body,
rememberfor stable refs - [ ] Engine — single shared Engine, ModelLoader, and MaterialLoader
- [ ] AR callbacks — no allocations in
onSessionUpdated - [ ] Plane renderer — disabled after object placement
- [ ] Device tiers — different asset quality levels for high/mid/low devices