i99dash docs
BYD API

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_large from car.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:

  • Paintpaint_default plus one paint_<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, Sunroof
  • Door_FL, Door_FR, Door_RL, Door_RR
  • Wheel_FL, Wheel_FR, Wheel_RL, Wheel_RR
  • Window_FL .. Window_RR
  • Mirror_L, Mirror_R
  • Light_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.

  • The brand-neutral catalog — every signal name, with the 3D column highlighting the ones marked threeD: true.
  • MIGRATING.md in i99dash-sdk — the v4 → v5 cutover, including the death of client.carStatus and the per-family controllers.

On this page