3D performance
Frame budgets, what actually costs time in a 3D view, how to measure it, and the optimisations that pay. Every 3D page in these notes ends here, because a 3D feature that drops frames is worse than no 3D feature.
Contents
- Budgets
- Where the time goes
- Measuring
- Geometry
- Draw calls
- Textures
- Overdraw and transparency
- Shaders
- Memory
- Frame loop hygiene
- Battery and heat
- Checklist
Budgets
| Refresh rate | Frame budget | Realistic target for scene rendering |
|---|---|---|
| 60 Hz | 16.6 ms | 8 ms, leaving room for the rest of the interface |
| 90 Hz | 11.1 ms | 5 ms |
| 120 Hz | 8.3 ms | 4 ms |
The budget covers both threads. The UI thread runs your Dart — scene graph updates, gesture handling, matrix maths. The raster thread issues GPU work. Exceeding either drops the frame, and the fixes are different, so always identify which one first.
Where the time goes
In roughly the order they cause trouble on mobile:
- Fill rate — how many pixels are shaded, multiplied by how expensive each one is. Full-screen effects and heavy fragment shaders dominate here.
- Draw calls and state changes — each material switch costs CPU time on both threads.
- Vertex count — matters less than people expect on modern GPUs, until it is large or skinned.
- Texture bandwidth — oversized or uncompressed textures thrash memory.
- Per-frame Dart work — rebuilding the scene graph, allocating matrices, or recomputing transforms every frame.
Measuring
Always in profile mode on a real device:
flutter run --profile
- DevTools frame chart shows UI and raster time per frame and flags jank. See DevTools and performance.
- Performance overlay gives two live graphs on the device; useful while you tune, not for precise numbers.
- Platform GPU tools are where real answers live for GPU-bound work: Xcode Instruments (Metal System Trace) on iOS, Android GPU Inspector on Android. They show fill rate, overdraw, and shader cost, which Flutter's own tooling cannot.
- Timeline events — wrap your own scene work so it appears in the trace:
Timeline.startSync('scene.update');
_updateSceneGraph(elapsed);
Timeline.finishSync();
Measure one change at a time, and record the numbers. "It feels smoother" is not a measurement.
Geometry
- Budget triangles per model, not per scene. Tens of thousands is a reasonable starting point for a phone; hundreds of thousands is not.
- Decimate in the modelling tool, not at runtime. A retopologised model with baked normal maps looks better at a fraction of the cost.
- Level of detail. Swap a simplified mesh in when the model is small on screen. Even two levels help.
- Back-face culling on, with consistent winding — you skip half the triangles for a closed model.
- Skinning is expensive. Limit bone counts and the number of influences per vertex.
Draw calls
Each mesh primitive with a distinct material is at least one draw call. On mobile, a few hundred is already a problem.
- Merge meshes that share a material.
- Combine textures into an atlas so several parts can share one material.
- Avoid per-frame material creation; build materials once and mutate their parameters.
- If the renderer supports instancing, use it for repeated geometry rather than duplicating nodes.
Textures
Texture memory is usually the largest allocation in a 3D feature.
- Size to the screen. A model that occupies 300 logical pixels does not need a 4096-pixel albedo map. 1024 is often enough; 2048 is generous.
- Use compressed formats — ASTC on modern Android and iOS, ETC2 as a fallback. An uncompressed 2048 texture is about 16 MB; compressed it is a fraction of that.
- Generate mipmaps. Without them, minified textures shimmer and sample inefficiently.
- Strip unused channels. A greyscale roughness map does not need three colour channels.
- Fewer, larger textures beat many small ones when they let you cut materials.
Overdraw and transparency
Opaque geometry can be depth-tested and rejected early. Transparent geometry cannot: it must be sorted back to front and every layer shades every pixel it covers.
- Keep transparency to what genuinely needs it — glass, particles.
- Use alpha cut-out rather than alpha blending for foliage-style detail.
- Avoid stacking full-screen transparent effects.
- Check the overdraw view in the platform GPU tools; it is usually a surprise.
Shaders
- Cost is per pixel. Halving the rendered resolution quarters the shader work — render into a smaller texture and let Flutter scale it when the content is soft or heavily blurred.
- Keep raymarching loops bounded and break early, as in Fragment shaders.
- Move whatever you can to the vertex stage or precompute it into a texture.
- Prefer medium precision on mobile where quality allows.
- Impeller precompiles shaders, which removes most first-run compilation jank — but verify the first run of your 3D route specifically, since that is where any remaining pipeline setup will show.
Memory
- Dispose GPU resources when the route is popped: models, textures, render targets, and the renderer itself if you own it.
- Watch for the classic leak: navigating into and out of the 3D screen ten times and never releasing the scene. Use the DevTools memory view to confirm the allocations come back down.
- Respond to memory pressure (
didHaveMemoryPressure) by dropping cached textures. - Mobile devices kill the app rather than swapping. A 3D feature that works on a fresh device can fail on a device with twenty apps in the background.
Frame loop hygiene
- Drive rendering from a
Tickerand aListenable, not fromsetState, so the frame loop does not run the build phase — see Custom painting. - Do not rebuild the scene graph every frame; mutate transforms.
- Reuse
Matrix4andVector3instances; allocating vector maths objects per frame produces steady garbage-collection pressure. - Wrap the 3D view in a
RepaintBoundaryso it does not drag the rest of the screen into its repaints. - Stop the ticker when the view is not visible: route not on top, app backgrounded, or the widget scrolled off screen.
Battery and heat
A 3D view can hold the GPU at full clock indefinitely, which is a different problem from frame time:
- Cap the frame rate when nothing is moving. An idle model does not need 120 frames a second — render on demand after interaction, then stop.
- Sustained load causes thermal throttling: the first thirty seconds look fine and then the frame rate halves. Test for two minutes, not five seconds.
- Offer a reduced-quality or static-image mode for low-end devices and for users with reduced-motion enabled.
Checklist
Before shipping a 3D feature:
- Profiled on a mid-range device, in profile mode, with the numbers written down.
- UI and raster thread times both inside budget, with headroom.
- Triangle count, texture sizes, and draw calls all deliberately chosen.
- Textures compressed and mipmapped.
- Idle frame rate capped; ticker stopped when not visible.
- Resources disposed — verified by navigating in and out repeatedly while watching memory.
- Sustained-load test run for at least two minutes to catch throttling.
- Cold model load time measured separately and acceptable, with a loading state in the interface.
- A fallback path for devices that cannot render it.
Next: DevTools and performance.