Procedural cache and performance

Let the engine cache assets, keep frames allocation-free and spread heavy work over frames.

Automatic asset caching

You don't need an app cache: each file downloads and decodes once per session, equal geometry shares one registration, and prepared meshes are saved for later launches. Records never expire and aren't evicted; full or denied storage falls back to memory, and corrupt records rebuild.

Disposing a world keeps loaded handles valid. Change the URL (for example ?v=2) when a file's contents change.

Automatic asset caching reference
StorageBehavior
React NativeApp-private files (Application Support / filesDir), written by Rust.
WebBinary IndexedDB records; Rust validates keys and checksums.
Web warm-upUp to 64 MiB / 1,024 recent geometry records restored into Rust.
LifetimeUntil clearAssetCache, clearing app or site data, or uninstall.

Just call the normal APIs

React and React Native · shared engine API

Just call the normal APIs
import { block, createShape, loadModel, type World } from "@nayan-ui/engine";
export async function setupLevel(world: World, heroUrl: string) {
const hero = await loadModel(heroUrl, { fit: 2 }); // decoded once, saved for next launch
const crate = createShape(block(1, 1, 1)); // equal geometry reuses one mesh id
world.spawn({ mesh: hero, position: [0, 0, 0] });
world.spawn({ mesh: crate, position: [2, 0.5, 0], color: "#C8763A" });
}

Clear saved assets

clearAssetCache() removes saved geometry, original files and temporary download bytes; call it only from a user action. It rejects when storage can't be cleared, live worlds and handles keep working, and custom ProceduralStorage adapters are left alone.

ClearAssetsButton.tsx

React · browser

ClearAssetsButton.tsx
import { useState } from "react";
import { clearAssetCache } from "@nayan-ui/engine";
export function ClearAssetsButton() {
const [message, setMessage] = useState("");
const clear = () =>
clearAssetCache().then(
() => setMessage("Saved assets cleared."),
() => setMessage("Couldn't clear saved assets."),
);
return (
<div>
<button onClick={clear}>Clear saved game assets</button>
<p>{message}</p>
</div>
);
}

ClearAssetsButton.tsx

React Native · iOS and Android

ClearAssetsButton.tsx
import { useState } from "react";
import { Button, Text, View } from "react-native";
import { clearAssetCache } from "@nayan-ui/engine";
export function ClearAssetsButton() {
const [message, setMessage] = useState("");
const clear = () =>
clearAssetCache().then(
() => setMessage("Saved assets cleared."),
() => setMessage("Couldn't clear saved assets."),
);
return (
<View>
<Button title="Clear saved game assets" onPress={clear} />
<Text>{message}</Text>
</View>
);
}

Cache expensive factories

new ProceduralCache(revision) saves what a deterministic factory returns (models, arrays and JSON values): get(key, build) restores or builds and saves, and peek(key) restores without building (undefined when missing). Use one revision per game and prefix keys with its folder, so an update rebuilds only that game's records.

The engine's store checksums records in Rust (ProceduralStorage.verified), so restores skip the JS checksum; a custom storage without verified keeps it. stats counts hits and builds, flush() awaits pending writes, and worlds, entities, textures and imported rigs never belong in it.

Five cached tree models

React and React Native · shared engine API

Five cached tree models
import { ProceduralCache, Solid, createSolid } from "@nayan-ui/engine";
const cache = new ProceduralCache("forest:3"); // bump when this game's recipes change
// After warmUpEngine. Later launches restore the saved vertices without tessellating.
export const trees = () =>
cache.get("forest/trees:5", () =>
[0, 1, 2, 3, 4].map((i) => {
const trunk = Solid.cylinder(0.15, 2 + i * 0.3).translate([0, 1 + i * 0.15, 0]).paint([0.4, 0.26, 0.13]);
const crown = Solid.sphere(0.8 + i * 0.1).translate([0, 2.4 + i * 0.3, 0]).paint([0.3, 0.55, 0.25]);
return createSolid(trunk.blend(crown, 0.1), { resolution: 32, center: false });
}),
);

Move many entities with world.transforms

Queue per-frame moves in onUpdate with the allocation-free position, rotation, scale, color and transform methods; the world's step applies them all in one native call. Call flush() only when the same onUpdate must read positions, animate or query afterwards.

createTransformBatch() makes an extra batch, such as a cancelable drag preview, that is never flushed for you: flush() commits it and clear() discards it. set(entity, TransformOptions) handles occasional mixed updates but allocates more.

Orbit a swarm

React and React Native · shared engine API

Orbit a swarm
import { quat, type Entity, type MutableQuat, type World } from "@nayan-ui/engine";
// Pass the result to GameView's onUpdate; GameView steps the world right after.
export function orbit(world: World, bees: readonly Entity[]) {
const turn: MutableQuat = [0, 0, 0, 1];
let time = 0;
return (dt: number) => {
time += dt;
bees.forEach((bee, i) => {
const angle = time + i;
quat.y(-angle, turn);
world.transforms.transform(bee, Math.sin(angle) * 3, 1, Math.cos(angle) * 3, turn[0], turn[1], turn[2], turn[3]);
});
};
}

Zero-allocation frames

New arrays every frame cause garbage-collection stutter on phones, so allocate scratch values once and pass them as out arguments. The returned value is that out object (or null); copy it to keep it past the frame.

Zero-allocation frames reference
Instead ofUse
world.position(e) / worldPosition(e) / velocity(e)Same call with a reused [x, y, z] out.
world.raycast(o, d, max)world.raycast(o, d, max, mask, hit)
world.pick(x, y)world.pick(x, y, hit)
world.toScreen(p) / screenToWorld(x, y, plane)Pass a reused [x, y] or [x, y, z] out.
world.set(e, { rotation: quat.y(a) })quat.y(a, q), then world.transforms.rotation(e, ...q).
world.set(e, { position }) for many entitiesworld.transforms.position(e, x, y, z)

A ground probe

React and React Native · shared engine API

A ground probe
import type { Entity, RaycastHit, Vec3, World } from "@nayan-ui/engine";
const DOWN: Vec3 = [0, -1, 0];
export function groundProbe(world: World, player: Entity, floorLayer: number) {
const at: [number, number, number] = [0, 0, 0];
const hit: RaycastHit = { entity: -1 as Entity, distance: 0, normal: [0, 0, 0], point: [0, 0, 0] };
return () => world.position(player, at) !== null && world.raycast(at, DOWN, 1.05, floorLayer, hit) !== null;
}

Frames and threads

On iOS and Android the display's clock asks for each 60 Hz frame: onUpdate and the world's step run on the JS thread, then the engine's render thread culls, submits and presents a copy. JS never waits on the GPU, a frame still waiting is replaced rather than queued, and the clock stops in the background.

On the web, requestAnimationFrame drives the same loop. Never add your own requestAnimationFrame or setInterval game loop.

Watch frame timing

React and React Native · shared engine API

Watch frame timing
import { GameView, useWorld } from "@nayan-ui/engine";
export function Scene() {
const world = useWorld(64, (w) => {
w.spawn({ mesh: "cube", color: "#33B3FF" });
});
// No loop of your own: GameView runs onUpdate (if any), then steps the world, every frame.
return (
world && (
<GameView
source={world}
onStats={({ fps, updateMs, renderMs, backend }) => console.log(fps, updateMs, renderMs, backend)}
style={{ flex: 1 }}
/>
)
);
}

Culling and background decoding

Rust frustum-culls every instance against the camera, and shadow casters against the light's box, every frame; skinned meshes are never culled. Don't cull scenery in JS.

loadTexture, loadModel, loadFont and audio.load decode on native background threads, and loaded meshes register with the renderer inside Rust. Start loads together so they decode in parallel.

Load everything in parallel

React and React Native · shared engine API

Load everything in parallel
import { audio, loadFont, loadModel, loadTexture, type AssetSource } from "@nayan-ui/engine";
export async function loadAssets(files: { car: AssetSource; road: AssetSource; font: AssetSource; horn: AssetSource }) {
const sfx = audio.load({ horn: files.horn });
const [car, road, font] = await Promise.all([
loadModel(files.car, { fit: 2 }),
loadTexture(files.road),
loadFont(files.font),
sfx.ready,
]);
return { car, road, font, sfx };
}

Slice long setup with afterNextFrame

afterNextFrame(fn) runs fn right after the next frame leaves and returns a cancel function; with no GameView running it falls back to requestAnimationFrame. Do one slice per frame behind a loader, and check ProceduralCache.peek first so a saved result skips the work.

Build a level one row per frame

React and React Native · shared engine API

Build a level one row per frame
import { afterNextFrame, ProceduralCache } from "@nayan-ui/engine";
const cache = new ProceduralCache("levels:1");
type Level = { heights: number[] };
export function buildLevel(seed: number, onReady: (level: Level) => void): () => void {
const key = `levels/terrain:${seed}`;
const saved = cache.peek<Level>(key);
if (saved) {
onReady(saved); // restored from an earlier launch, nothing to build
return () => {};
}
const heights: number[] = [];
let cancel = () => {};
const step = (row: number) => {
for (let x = 0; x < 64; x++) heights.push(Math.sin(seed + row * 0.3 + x * 0.2));
if (row < 63) cancel = afterNextFrame(() => step(row + 1));
else onReady(cache.get(key, () => ({ heights })));
};
cancel = afterNextFrame(() => step(0));
return () => cancel(); // call on unmount
}

Practical budgets

Mesh ids are app-wide and never freed, so build meshes once at module scope or behind a cache and spawn many entities from them. One long job stalls Android's Hermes far longer than desktop JavaScript suggests.

Practical budgets reference
BudgetRule
MAX_MESHES4096 ids including 16 built-ins: about 4,080 custom meshes per session.
getMeshIdLimit()One past the highest mesh id in use.
MeshesNever create one per spawn, per size or per frame.
warmUpEngine()Await before procedural meshes and web worlds; repeat calls are free.
World capacityText letters and particles use entity slots too.
Long jobsKeep out of gameplay frames; slice with afterNextFrame.
ProfilingUse onStats in a production build on real target devices.
DrawingsA DrawingDocument texture costs one texture, not a mesh id.

Report budgets

React and React Native · shared engine API

Report budgets
import { MAX_MESHES, getMeshIdLimit, type World } from "@nayan-ui/engine";
export function budgets(world: World) {
return {
meshIdsLeft: MAX_MESHES - getMeshIdLimit(),
entitySlotsLeft: world.capacity - world.count,
};
}