3D mini-apps
How to build a real-time 3D mini-app of the active car — load the GLB via `client.car.asset`, animate it with the catalog signals, and switch paint/wheels/glass variants.
Overview
A 3D mini-app is a regular i99dash mini-app whose UI is a Three.js / Babylon.js / R3F scene of the actual car the user is sitting in. The car visually reacts to live signals — a door opens when the physical door opens, indicators blink with the real turn signals, wheels spin proportional to vehicle speed, paint changes when the user picks a swatch. The whole loop is bridge-driven; the mini-app never touches Android, the framework, or BYD's feature ids.
Three bridge calls power the loop. client.car.identity() tells you
which car is active (brand, model, GLB asset path, the canonical
animation-clip names the artist authored, and the per-channel
variant catalog). client.car.asset(path) loads the bundle-resident
GLB bytes — already base64-decoded by the SDK, ready to hand to
GLTFLoader.parseAsync. client.car.subscribe({names, onEvent})
delivers live signal events you bind to animation actions and
material toggles.
The catalog signals that drive 3D animations are marked with
threeD: true on
the catalog page — today the BYD catalog
exposes door / window / sunroof / light / turn-signal / wheel-speed
names. Read them by name, animate the matching node, done.
GLB authoring contract
The GLB is authored once per car model under a strict spec — the
contract lives in
car-i99dash/docs/features/dashboard/3d/FREELANCER_BRIEF.md and
INTEGRATION_GUIDE.md. The host enforces it at boot, then the
runtime bridge exposes the contract via car.identity().clips and
car.identity().variants. The summary below is what a mini-app
author needs in order to consume the asset.
File format and limits
- Format: glTF 2.0 binary (
.glb). No external image files — textures embedded. - Size: ≤ 25 MB hard ceiling. The host rejects oversized
payloads with
asset_too_largefromcar.asset. - Compression: Draco for meshes is encouraged; KTX2 or PNG for textures.
- Coordinate system: Y-up, -Z forward, units = metres, origin at vehicle centre with wheels on Y = 0.
Required animation clips
The host guarantees these exact clip names are present when the
artist follows the brief. Use them as THREE.AnimationAction keys
against the loaded glTF — anything outside this set is undefined
across cars.
Door_FL_Open / Door_FL_Close
Door_FR_Open / Door_FR_Close
Door_RL_Open / Door_RL_Close
Door_RR_Open / Door_RR_Close
Hood_Open / Hood_Close
Trunk_Open / Trunk_Close
Sunroof_Open / Sunroof_Close
Window_FL_Open / Window_FL_Close
Window_FR_Open / Window_FR_Close
Window_RL_Open / Window_RL_Close
Window_RR_Open / Window_RR_Close
Headlights_On / Headlights_Off
Indicator_Left_Blink (loop)
Indicator_Right_Blink (loop)
Wheel_Spin (loop — 1 sec = 1 revolution)car.identity().clips returns the canonical 16-name set the runtime
counts on. Each opening clip has a paired closing clip; loop clips
are explicitly marked above.
Required material variants
The renderer cannot recolour materials at runtime — it switches between
pre-baked KHR_materials_variants. Three channels are
mandatory:
- Paint —
paint_defaultplus onepaint_<slug>per swatch (typical:paint_lava_black,paint_lunar_white,paint_coral_red,paint_ice_blue,paint_titanium_grey). - Wheels — three styles:
wheels_silver,wheels_black,wheels_bronze. - Glass — three tints:
glass_clear,glass_smoke,glass_dark.
car.identity().variants returns these as
{paint: string[], wheels: string[], glass: string[]} for the active
model.
Required nodes
The clips animate named nodes — your scene-graph queries should work against:
Body,Body_Lower,Hood,Trunk,SunroofDoor_FL,Door_FR,Door_RL,Door_RRWheel_FL,Wheel_FR,Wheel_RL,Wheel_RRWindow_FL..Window_RRMirror_L,Mirror_RLight_Head_L/R,Light_Tail_L/R,Light_Brake_L/R,Light_Indicator_FL/FR/RL/RR,Light_DRL_L/R,Light_Reverse_L/R- Decal quads:
Decal_Door_L,Decal_Door_R,Decal_Hood,Decal_Roof
Wrapper node: everything sits under a single Car root so
scene.getObjectByName('Car') finds the whole model.
Bridge surface
The two host-side calls that 3D mini-apps lean on:
// car.identity — memoised per car for the lifetime of the controller.
const identity = await client.car.identity();
// {
// brand: 'byd',
// modelCode: 'leopard8',
// modelDisplay: 'BYD Leopard 8',
// modelAssetPath: 'assets/3d/leopard8/leopard8.glb',
// clips: ['Door_FL_Open', 'Door_FR_Open', /* … 14 more */],
// variants: {
// paint: ['paint_default', 'paint_lava_black', /* … */],
// wheels: ['wheels_silver', 'wheels_black', 'wheels_bronze'],
// glass: ['glass_clear', 'glass_smoke', 'glass_dark'],
// },
// }
// car.asset — base64 round-trips on the wire, bytes on the SDK side.
const asset = await client.car.asset(identity.modelAssetPath!);
// {
// path: 'assets/3d/leopard8/leopard8.glb',
// contentType: 'model/gltf-binary',
// size: 18_400_000,
// bytes: Uint8Array, // already decoded by the SDK
// }car.identity may return modelAssetPath: null if the active car
doesn't yet have a 3D model authored — handle that case before
calling car.asset. The cache is automatically invalidated when the
connection-state listener observes 'disconnected' (a car swap
flow picks up the new identity on the next call).
car.asset rejects paths outside the bundle allowlist
(assets/3d/, assets/textures/) with error: 'disallowed_path'
and oversized payloads with error: 'asset_too_large'. Both surface
as ordinary thrown errors on the SDK side — no special handling
beyond the usual try/catch.
Subscribe + animation drivers
The pattern: subscribe once for every signal your scene cares about,
keep a THREE.AnimationAction for each animatable name, and toggle
in onEvent. Below, a self-contained Three.js binding for door /
turn-signal / wheel-spin signals. Drop into a module, call
bindCarSignals(mixer, scene, client) after the glTF has loaded.
import { AnimationAction, AnimationMixer, LoopOnce, LoopRepeat, Scene } from 'three';
import { MiniAppClient, CarSignalEvent } from 'i99dash';
export async function bindCarSignals(
mixer: AnimationMixer,
scene: Scene,
client: MiniAppClient,
): Promise<() => void> {
// One AnimationAction per clip — look them up once.
const action = (name: string): AnimationAction | null =>
mixer.clipAction(scene.animations.find((c) => c.name === name) ?? null!);
const doorClips: Record<string, [AnimationAction | null, AnimationAction | null]> = {
door_lf: [action('Door_FL_Open'), action('Door_FL_Close')],
door_rf: [action('Door_FR_Open'), action('Door_FR_Close')],
door_lr: [action('Door_RL_Open'), action('Door_RL_Close')],
door_rr: [action('Door_RR_Open'), action('Door_RR_Close')],
};
const turnLeft = action('Indicator_Left_Blink');
const turnRight = action('Indicator_Right_Blink');
const wheelSpin = action('Wheel_Spin');
[turnLeft, turnRight, wheelSpin].forEach((a) => a?.setLoop(LoopRepeat, Infinity));
return client.car.subscribe({
names: ['door_lf', 'door_rf', 'door_lr', 'door_rr',
'turn_left', 'turn_right', 'speed_kmh'],
onEvent: (e: CarSignalEvent) => {
if (e.name in doorClips) {
const [open, close] = doorClips[e.name];
const next = e.value === 1 ? open : close;
next?.reset().setLoop(LoopOnce, 1).play();
} else if (e.name === 'turn_left') {
if (e.value === 1) turnLeft?.reset().play();
else turnLeft?.stop();
} else if (e.name === 'turn_right') {
if (e.value === 1) turnRight?.reset().play();
else turnRight?.stop();
} else if (e.name === 'speed_kmh' && wheelSpin) {
// Wheel_Spin clip is 1 rev / sec by spec — set timescale
// from km/h via wheel circumference. Real binding scales
// by `(speed_kmh * 1000 / 3600) / wheelCircumference`.
wheelSpin.timeScale = Math.max(0, (e.value ?? 0) / 30);
if ((e.value ?? 0) > 0 && !wheelSpin.isRunning()) wheelSpin.play();
if ((e.value ?? 0) === 0 && wheelSpin.isRunning()) wheelSpin.stop();
}
},
});
}The unsubscribe closure returned by subscribe tears down the
host-side route on disposal — call it from your component's
cleanup. Holding the subscription open across navigations is fine;
the host keeps a token bucket per subscription so a chatty signal
can't starve other names.
End-to-end walkthrough
The full mount flow from a cold render: identity → asset → parse →
subscribe → bind. Wraps the binder above and renders into a <canvas>.
import {
AnimationMixer,
PerspectiveCamera,
Scene,
WebGLRenderer,
Clock,
Color,
} from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MiniAppClient } from 'i99dash';
import { bindCarSignals } from './bind-car-signals';
export async function mountCarScene(canvas: HTMLCanvasElement): Promise<() => void> {
const client = MiniAppClient.fromWindow();
const identity = await client.car.identity();
if (!identity.modelAssetPath) {
throw new Error(`car ${identity.brand}/${identity.modelCode} has no 3D model yet`);
}
// Load the GLB bytes through the bridge — the SDK has already
// base64-decoded for us; hand the `Uint8Array.buffer` straight to
// GLTFLoader.parse so we don't pay a re-encode round-trip.
const asset = await client.car.asset(identity.modelAssetPath);
const loader = new GLTFLoader();
const gltf = await loader.parseAsync(
asset.bytes.buffer.slice(
asset.bytes.byteOffset,
asset.bytes.byteOffset + asset.bytes.byteLength,
),
/* path */ '',
);
// Standard Three.js scene boilerplate.
const scene = new Scene();
scene.background = new Color(0x07090e);
scene.animations = gltf.animations;
scene.add(gltf.scene);
const camera = new PerspectiveCamera(35, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
camera.position.set(5, 1.8, 5);
camera.lookAt(0, 0.8, 0);
const renderer = new WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
const mixer = new AnimationMixer(gltf.scene);
const clock = new Clock();
let rafHandle = 0;
const tick = (): void => {
mixer.update(clock.getDelta());
renderer.render(scene, camera);
rafHandle = requestAnimationFrame(tick);
};
rafHandle = requestAnimationFrame(tick);
// Live-signal binding — the same subscription drives every clip
// and the variant switches you trigger from your UI controls.
const offSignals = await bindCarSignals(mixer, scene, client);
// Connection banner — pause animations on disconnect so the user
// doesn't see a frozen-but-spinning scene.
const offConn = await client.car.connectionSubscribe((state) => {
if (state === 'disconnected' && rafHandle) cancelAnimationFrame(rafHandle);
});
return () => {
offSignals();
offConn();
cancelAnimationFrame(rafHandle);
renderer.dispose();
};
}identity.variants powers the paint / wheels / glass picker UI —
iterate the array, render a swatch per name, and call the GLTF's
functions.selectVariant(name) (from KHR_materials_variants_three)
on click. The variant set is brand-/model-specific; never hardcode
it on the mini-app side.
Related
- The brand-neutral catalog — every signal
name, with the
3Dcolumn highlighting the ones markedthreeD: true. MIGRATING.mdini99dash-sdk— the v4 → v5 cutover, including the death ofclient.carStatusand the per-family controllers.
The brand-neutral catalog
Every signal name the host's v2 bridge exposes — read by name, write by actionId. Filter, search, and copy into a `client.car.read(...)` call.
Auto-discover
How AutoCarRegistry turns the BYD framework catalog into a per-car live feature index — once at boot, push-driven thereafter, with zero per-trim curation.