3D

Models from .glb files, with a sun, shadows and lights

A cart can put 3D models behind its UI: load a .glb, spawn copies of it, move and animate them. The scene draws behind everything the cart renders, so a score or a nameplate sits on top of the world with no extra work.

import { useEffect } from "react"
import { mount, useScene, useModel } from "oj"

function Haunt() {
    const scene = useScene()
    const ghost = useModel("ghost.glb")
    useEffect(() => {
        if (!scene || !ghost) return
        for (let i = 0; i < 5; i++) scene.spawn(ghost, { position: [i * 1.5 - 3, 0, 0], play: "idle" })
    }, [scene, ghost])
    return null
}

mount(<Haunt />)

useScene() and useModel() are both null on the first render and fill in a frame or two later, which is why the spawning waits for both.

A cart has one scene. Call useScene once, near the top of the cart, and pass the scene down to whatever spawns into it; a second useScene while one is live throws and says so. Each model file loads once however many components ask for it, and the copies share it.

What you get for writing nothing

A scene with no options already looks right:

Camera At [0, 4, -8], looking at the origin, 50 degree field of view
Sun Warm white, from above and behind the camera, casting soft shadows
Ambient Light from everywhere: a cool sky from above, a dark ground from below
Models Cast shadows and receive them
Background The stage's dark grey

Materials come from the file as it was exported: base colour, metallic and roughness, normal, occlusion and emission maps, alpha (masked and blended), double sided and unlit. Positive y is up and the units are the file's, which for most exports means metres.

Tuning the scene

Every option turns one default off or adjusts it. A colour is anything oj takes as one: "#ffe9c4", "rgb(255, 233, 196)", "orange" or a Color.

const scene = useScene({
    camera: { position: [0, 6, -6], lookAt: [0, 0, 1], fov: 45 },
    sun: { direction: [-0.3, -1, 0.5], color: "#ffe9c4", intensity: 1.4 },
    ambient: { sky: "#9fb4d6", ground: "#3a3028", intensity: 0.8 },
    background: "#1a1626",
    fog: { near: 8, far: 30 },
})
Option Default
camera { position: [0, 4, -8], lookAt: [0, 0, 0], fov: 50 } scene.camera(...) moves it later
sun { direction: [-0.32, -0.77, 0.56], color: "#fff4d6", intensity: 1.2, shadows: true } false for no sun at all; shadows: false keeps the light
ambient { sky: "#b8c6d9", ground: "#4a443d", intensity: 0.9 } Like three.js's HemisphereLight
background "#14181d"
fog off Thickens from near to far, in the background colour unless given a color

All of it can change while the scene runs, and each call changes only what it names:

scene.sun({ intensity: 0.3 })               // dusk
scene.ambient({ sky: "#3b4a6b" })
scene.background("#0b0d16")                 // the fog follows unless it has its own colour
scene.fog({ near: 4, far: 18 })             // or scene.fog(false) to clear it
scene.sun(false)                            // or put the sun out

Models

const boo = scene.spawn(ghost, { position: [0, 0, 2], yaw: 90, scale: 1.2, play: "idle" })

boo.position = [1, 0, 2]                    // move it
boo.yaw = 180                               // turn it, in degrees
boo.rotation = { yaw: 180, pitch: -10 }     // or on all three axes: yaw, pitch and roll
boo.scale = 2                               // twice the model's own size
boo.play("run", { fade: 0.2 })              // crossfade to another clip
boo.play("dissolve", { loop: false })       // play once and hold the last frame
boo.speed = 0.5                             // its clips and dissolve at half speed; 0 holds them
await boo.dissolve()                        // fade out with a glowing edge, in 0.8 seconds
await boo.dissolve({ to: 0, seconds: 1.2 }) // and back in, slower
boo.destroy()

ghost.clips lists the animation names the file has, and ghost.height is how tall it is, which is handy for a label above it.

Shadows are per model: spawn(ghost, { castShadows: false }), or boo.receiveShadows = false later.

Pausing

scene.paused = true holds every clip and every dissolve where it is, for a pause menu, and a dissolve's promise waits with it. Each model keeps its own speed for when the scene goes again. The camera and lights are untouched, so a pause screen can still move the camera.

Clicking and labels

<View style={{ position: "absolute", left: 0, top: 0, right: 0, bottom: 0 }}
      onPointerDown={(e) => scene?.pick(e.x, e.y)?.dissolve()} />

scene.pick(x, y) is the model under a point, or null. boo.screenPoint() is where the top of a model is on screen, in window pixels like a pointer event's, so a label with position: "absolute" at that left and top follows it. Read it in useFrame while things move. It is null when the model is behind the camera.

Lights

const lamp = scene.pointLight({ position: [0, 2, 0], color: "#ffb36b", intensity: 2, range: 6 })
lamp.position = [1, 2, 0]
lamp.destroy()

Point lights do not cast shadows; the sun does.

Files

Add .glb files like any other: they sit in the cart's files and load by name, so useModel("ghost.glb") or useModel("models/ghost.glb"). A .glb carries its own textures and animations, so one file is one model. The file limits apply: 16 MB for one file.

A model bought from a store may come with a licence that lets you use it in what you make but not hand out the file, and anyone can read, clone and fork a public cart's files. Check the licence before you make such a cart public.

In Unity

npx ojplay add brings a cart with models into a Unity project as it does any other, and adds the glTFast package (com.unity.cloud.gltfast) to the project if it is not there yet. The models draw with the same shader in both places, so the scene looks the same in URP. In the built-in render pipeline they use Unity's Standard lighting.

In Play mode the cart's scene takes over: its camera and sun replace the scene's own until Play stops. Without Play, in the Game view preview, the cart adds its camera and models but leaves your scene's lights and settings alone, so the preview is lit by your scene's sun.