<Model> shows a .glb file and lets you change it without re-exporting: paint any part with a material preset, turn or slide moving parts, play its animations, and know which part was tapped.
It lives in a separate entry point, because three's glTF loader adds about 146 KB that apps without models should not carry:
import { CameraRig, Scene } from "@apricot/react";
import { Model } from "@apricot/react/model";
export function FirstModel() {
return (
<Scene style={{ flex: 1 }} look="none" environment="studio" background="#0e0f12">
<Model src={require("./assets/models/chair.glb")} ground>
<CameraRig view="three-quarter" orbit />
</Model>
</Scene>
);
}srcis a bundled asset (require("./x.glb")) or a URL string.groundstands the model on the floor (its lowest point at y = 0), wherever its origin was put in Blender.- The model loads asynchronously. Until it has loaded,
<Model>renders nothing, including its children: a<CameraRig>inside it starts once the model appears. UseonLoadandonErrorto react. - A model used by several components loads once and is shared, then freed when its last user unmounts.
1. Prepare the file#
Ship .glb files (not .gltf with separate files), optimised with the apricot-models CLI from @apricot/metro:
npx apricot-models ./models -o ./assets/modelsIt removes duplicates and unused data, welds and quantizes meshes (smaller files with no runtime decoder), and decodes Draco and meshopt input on your computer. Hermes on the phone has no WebAssembly, so Draco, meshopt and KTX2/Basis cannot be decoded there; KTX2 textures are rejected with a clear message. It warns when a model is heavy for a phone: more than 150,000 triangles, more than 8 MB of textures, or textures larger than 2048 px.
Then list what the model contains:
npx apricot-models inspect ./assets/models/car.glbinspect prints the part (node) names and material names: the names the selectors below match. Add --json for machine-readable output. In the app, <Model inspect> does the same on device: tap a part to highlight it and see its name and material.
Make the model face +z (its front towards the default camera) with y up, in metres.
2. Select parts#
Models arrive named by whoever made them (ruota_ant_sx, Object_123, car_paint.003). Selectors pick parts by those names:
| Selector | Picks |
|---|---|
"wheel_*" |
Parts or materials whose name matches. * matches any characters, ? one character. Case-insensitive, any language. |
"node:Lid" |
Parts (nodes) with that name, and everything under them |
"material:car_paint*" |
Only the material slots with that name (the name in the file, even after a preset replaced it) |
"tag:door" |
Parts tagged in Blender with the custom property apricot = door |
["glass", "window_*"] |
Any of several selectors |
3. Name parts once with a model kit#
A kit gives parts names in your words, once. App code then uses those names, and swapping the model means changing only the kit. Kit names autocomplete in <Model kit materials joints>.
import { defineModelKit } from "@apricot/react/model";
export const sedan = defineModelKit(require("./assets/models/sedan.glb"), {
paint: "material:vernice",
glass: "material:vetro",
rims: "material:cerchio",
tyres: "material:gomma",
lights: "material:luce",
doorFrontLeft: { select: "porta_ant_sx", hinge: "front", axis: "y", range: [0, -65] },
doorFrontRight: { select: "porta_ant_dx", hinge: "front", axis: "y", range: [0, 65] },
hood: { select: "cofano", hinge: "back", axis: "x", range: [0, -40] },
trunk: { select: "bagagliaio", hinge: "front", axis: "x", range: [0, 50] },
});A kit part is either a selector (a name for something to paint) or a joint (a moving part, described below).
4. Paint parts#
materials maps a kit name or a selector to a material: a preset name, a preset with options, or a three.js material. Colour changes animate.
import { StudioFloor } from "@apricot/assets";
import { CameraRig, Scene } from "@apricot/react";
import { Model } from "@apricot/react/model";
import { sedan } from "./sedan";
export function Showroom({ color }: { color: string }) {
return (
<Scene style={{ flex: 1 }} look="none" environment="studio" background="#0e0f12" camera={{ fov: 32 }}>
<StudioFloor radius={8} />
<Model
kit={sedan}
ground
materials={{
paint: { preset: "car-paint", color, transition: 0.6 },
glass: "tinted-glass",
rims: "chrome",
tyres: "rubber",
lights: "light",
}}
>
<CameraRig view="three-quarter" orbit={{ elevation: [2, 70], zoom: [0.6, 1.8] }} />
</Model>
</Scene>
);
}Without a kit, use selectors directly: materials={{ "material:car_paint*": "car-paint", "glass*": "tinted-glass" }}. A key that matches nothing logs a warning. Unmounting the model (or removing a key) puts the file's original materials back.
5. Moving parts: joints#
A joint turns a part around a hinge, or slides it along a line, driven by a value from 0 (rest, closed) to 1 (open). The hinge is found from the part's bounds at rest, so you describe it in words.
| Joint field | Meaning (default) |
|---|---|
select |
The part(s) that move together (in a kit) |
hinge |
Where it turns: an edge or corner of the part's bounds, by words: front, back, top, bottom, left, right, center, combined as "bottom-back". Or an exact point [x, y, z] in the part's parent space ("center") |
axis |
"x", "y", "z" or a vector, in the parent's space ("y") |
range |
Degrees at 0 and at 1 ([0, 90]) |
slide |
Slide instead of turn: distance along the axis at 0 and at 1 |
transition |
Seconds a plain value change takes (0.6) |
The model faces +z: front is +z, top is +y, left is +x. A door hinged at its front edge turns around y; a hood hinged at its back edge turns around x.
Drive joints with joints, by kit name:
import { Scene } from "@apricot/react";
import { Model } from "@apricot/react/model";
import { useState } from "react";
import { sedan } from "./sedan";
export function Doors() {
const [open, setOpen] = useState<Record<string, boolean>>({});
return (
<Scene style={{ flex: 1 }} look="studio">
<Model
kit={sedan}
ground
joints={{
doorFrontLeft: open.doorFrontLeft ? 1 : 0,
doorFrontRight: open.doorFrontRight ? 1 : 0,
hood: open.hood ? 1 : 0,
}}
onPartPress={({ part }) => part && setOpen((o) => ({ ...o, [part]: !o[part] }))}
/>
</Scene>
);
}- A plain number animates to the new value over
transition. - A shared value is followed exactly, frame by frame: drive it with a gesture or
withSpring. - Without a kit, describe the joint inline:
joints={{ "node:Lid": { value: lid, hinge: "bottom-back", axis: "x", range: [0, -110] } }}.
A lid that follows the finger and springs shut:
import { CameraRig, Scene } from "@apricot/react";
import { defineModelKit, Model } from "@apricot/react/model";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cancelAnimation, useSharedValue, withSpring } from "react-native-reanimated";
const laptop = defineModelKit(require("./assets/models/laptop.glb"), {
lid: { select: "lid", hinge: "bottom-back", axis: "x", range: [0, -110] },
});
export function Laptop() {
const open = useSharedValue(0);
const start = useSharedValue(0);
const drag = Gesture.Pan()
.onBegin(() => {
cancelAnimation(open);
start.value = open.value;
})
.onUpdate((e) => {
open.value = Math.min(1, Math.max(0, start.value - e.translationY / 260));
})
.onFinalize(() => {
open.value = withSpring(0, { damping: 16, stiffness: 140, overshootClamping: true });
});
return (
<GestureDetector gesture={drag}>
<Scene style={{ flex: 1 }} look="none" environment="studio" background="#0e0f12">
<Model kit={laptop} joints={{ lid: open }}>
<CameraRig view={{ azimuth: 20, elevation: 22, distance: 7.5, target: [0, 0.7, 0] }} subject={null} />
</Model>
</Scene>
</GestureDetector>
);
}6. Animations made in Blender#
animations drives the file's animation clips by name:
| Value | Effect |
|---|---|
| A number 0–1 | Shows that point of the clip. Plain numbers animate there; shared values scrub exactly |
"once" |
Plays it once |
"loop" |
Plays it on repeat |
import { Model } from "@apricot/react/model";
export const Opening = ({ open }: { open: boolean }) => (
<Model src={require("./assets/models/box.glb")} animations={{ LidOpen: open ? 1 : 0 }} />
);
export const Idle = () => <Model src={require("./assets/models/robot.glb")} animations={{ Idle: "loop" }} />;7. Shape keys#
morphs sets shape keys (morph targets) by name, from 0 (off) to 1 (full): a smile, a squash, a lid closing. Plain numbers animate; shared values follow exactly.
import { Model } from "@apricot/react/model";
export const Face = ({ happy }: { happy: boolean }) => (
<Model src={require("./assets/models/face.glb")} morphs={{ Smile: happy ? 1 : 0 }} />
);Copies of rigged (skinned) models get their own skeleton, so each copy animates on its own.
Taps on parts#
onPartPress receives a PartPressEvent:
| Field | Meaning |
|---|---|
part |
The kit name of the tapped part: a joint if one includes it, else the most specific kit name. undefined without a kit, or for parts the kit does not name |
parts |
Every kit name whose selector includes the tapped mesh |
name |
The tapped mesh's name in the file |
material |
The tapped material's name in the file (what material: selectors match) |
wearing |
The preset it wears now, if replaced |
mesh, point |
The three.js mesh and the world-space point hit |
Placing, moving and following a model#
position,rotationandscalework as on any object, and accept shared values.refgives aSceneObjectfor<FollowCamera target>,<Label attach>or<CameraRig follow>.- Inside
<Vehicle>a model drives like a toy car. Name its wheel nodeswheel_fl,wheel_fr,wheel_rl,wheel_rr(see Vehicles). - With
shadowson, every mesh of the model casts and receives; override withcastShadow/receiveShadow.
Many copies of a model#
useModel(src) loads a model and returns { object, animations, error } (object is null while loading). Pass object to <Instances> to draw hundreds of copies in one call each:
import { Instances, type InstanceItem } from "@apricot/react";
import { useModel } from "@apricot/react/model";
export function Forest({ trees }: { trees: InstanceItem[] }) {
const { object } = useModel(require("./assets/models/pine.glb"));
return <Instances object={object} items={trees} />;
}Related#
- Model reference.
- Tooling:
apricot-models. - Camera.