Apricot is built to run at 60 fps on an iPhone 12 or Pixel 6a class phone, and at 120 fps on high-refresh screens. These are the rules that keep it there, with the measured costs behind them.
The rules#
- Let idle scenes sleep. Keep
frameloop="demand"(the default). ReturnfalsefromuseFramewhen an animation ends. An idle scene costs nothing. - No React renders per frame. Animate with shared values,
useFrameor behaviors (Animation). Keep components that re-render often (a countdown, a status line) out of the component that renders the scene. - Draw repeated things once. Use
<Instances>for seats, trees, posts, coins and products on a shelf. Each separate mesh costs about 80 µs per frame on a mid-range Android phone (Galaxy A55): 200 separate meshes take a whole 60 fps frame. Copies in one<Instances>cost almost nothing more each. - Share materials. Identical presets are already shared (forty
material="matte"pillars build one shader). Avoid giving every object a slightly different custom material: each distinct material is a shader build of tens of milliseconds on a phone. - Change colours, not materials. Changing
color,roughnessormetalnessupdates a value on the GPU. Changing the preset, an image or the engraving builds a new shader. - Keep models in budget. About 150,000 triangles and 8 MB of textures (2048 px per side at most) for a hero model on a mid-range phone.
apricot-modelswarns when a model is heavier (Loading models). - Shadows cost a second draw. With
shadows, each casting object is drawn again whenever the shadow map is redrawn (only while something that casts moves). Instance repeated casters, and keepsizeat 1024 unless shadows look blocky. - Measure in release builds on a real device. Debug builds and simulators are several times slower.
Built-in optimisations#
- On-demand frames. Shared values, prop changes, taps and behaviors wake the loop; nothing else does.
- Shader warm-up. Shaders compile in the background before the first frame and after you add new materials, so the app does not freeze. The first frames show only the background.
- Fast shared values. Bound shared values are mirrored to the render loop for about 30 µs per read, instead of about 276 µs for reading
.valuefrom the JS thread. - Pixel ratio cap. The canvas renders at up to 2× the screen's points (
maxPixelRatio, default 2). - Shadow maps are redrawn only when needed. Turning the camera around a still scene does not redraw them.
- Reference-counted assets. Ten components showing the same model load it once and share its geometry, materials and textures.
- Procedural lighting.
environment="studio"(and the other environments) is evaluated in the shader: there is no environment map to generate, which took 0.7–1.9 s on a phone.
Measuring#
The debug HUD#
import { Box, Scene } from "@apricot/react";
export function Measure() {
return (
<Scene style={{ flex: 1 }} debug>
<Box />
</Scene>
);
}debug shows frames per second, render time and present time, refreshed twice a second without touching the scene. When the scene sleeps, the numbers stop changing. The Share button opens the share sheet with the full performance report.
The performance report#
scene.report() returns a JSON report: frame times, the slowest frames and what happened in them (shader builds, shadow redraws, environment changes), scene contents (meshes, draw calls, triangles) and GPU details. Get it from useScene(), onCreated or onReady:
import { Box, Scene, shareDiagnostics } from "@apricot/react";
import type { ApricotScene } from "@apricot/react";
import { useRef } from "react";
import { Button, View } from "react-native";
export function Report() {
const scene = useRef<ApricotScene | null>(null);
return (
<View style={{ flex: 1 }}>
<Scene style={{ flex: 1 }} onCreated={(s) => (scene.current = s)}>
<Box />
</Scene>
<Button title="Share report" onPress={() => scene.current && shareDiagnostics(scene.current, { screen: "Report" })} />
</View>
);
}Budgets at a glance#
| Item | Comfortable on a mid-range phone |
|---|---|
| Separate meshes | Roughly 100 (each costs ~80 µs per frame; beyond that use <Instances>) |
Copies in one <Instances> |
Thousands |
| One hero model | ~150,000 triangles, ≤ 8 MB textures, ≤ 2048 px per side |
| Distinct materials | A few dozen (each one is a shader build) |
Bodies in a <Pile> |
About 50: ~0.4 ms per frame while falling (measured on a desktop with the JIT off), 0 once settled |
Shadow map size |
1024 (2048 is sharper and uses four times the memory) |