Using Trillium EXTENSION

This page covers loading the extension, creating a view, drawing your data into it, sizing it, and removing it. For what the extension is, start with Trillium. The pointer, labels and occluders are in Picking and Labels.

Loading it

Load the extension after the framework, and bring your own three.js. The extension never imports it; you hand it over once with wildflower.three.use(THREE).

<script defer src="/js/wildflower.min.js"></script>
<script defer src="/js/three.wf.min.js"></script>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js" } }
</script>
<script type="module" src="/js/globe.js"></script>

It runs on the tiers with pools: mini-pool, lite, core, spa and full. The frame loop is part of the pool module, so nano and mini have none to draw from. It works with three.js 0.160 and later.

It also works with three.js's WebGPURenderer, on WebGPU or its WebGL2 fallback, and with node materials written in TSL. Map three/webgpu (build/three.webgpu.js) in the import map and import from it. The view draws each frame with render(), which this renderer skips until it has started, so wait for init() before creating the view:

import * as THREE from 'three/webgpu';

wildflower.three.use(THREE);
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
const globe = wildflower.three.view('globe', { renderer, scene, camera, fit: container });

A view

Create the renderer, scene and camera as you normally would, then give them to wildflower.three.view().

// globe.js
import * as THREE from 'three';

wildflower.three.use(THREE);

const container = document.getElementById('globe');
const renderer = new THREE.WebGLRenderer({ antialias: true });
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100);
camera.position.set(0, 0, 3);

const globe = wildflower.three.view('globe', { renderer, scene, camera, fit: container });

view('globe', ...) registers a store called globe and returns it. The view draws the scene once per frame, after every tick() and pool update. With fit, the renderer, the pixel ratio and the camera's aspect follow the container's size. before is called at the start of each frame, after every tick() and pool update and right before the render. Change data in a store's tick(dt), and use before for work on three.js objects, such as () => controls.update() or moving one mesh to follow the selection. draw replaces the view's own renderer.render(scene, camera) call with a function of the renderer, the scene and the camera, so a post-processing pass, such as an EffectComposer with a depth-of-field pass, draws the frame instead.

Store names are shared across the app, so give the view a name no other store or thread uses. A name that another store already has throws 3D-103.

The live examples on these pages run with three.js loaded as the global THREE and already handed to use(). In this one the view's size and stats are bound in markup; resize the window to see the size follow.

<div data-component="knot-demo">
    <div id="knot-stage" style="height: 240px"></div>
    <p class="small mt-2">
        <span data-bind="$knot.size.width"></span> by
        <span data-bind="$knot.size.height"></span> px,
        <span data-bind="$knot.stats.fps"></span> fps
    </p>
</div>
const container = document.getElementById('knot-stage');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 4);

const knot = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.25, 128, 16),
    new THREE.MeshNormalMaterial()
);
scene.add(knot);

wildflower.three.view('knot', {
    renderer, scene, camera,
    fit: container,
    before: () => { knot.rotation.y += 0.01; }
});

wildflower.component('knot-demo', {
    destroy() {
        wildflower.unregister('knot');
        renderer.dispose();
    }
});
Live Preview

Drawing data

A binding writes a source into a three.js InstancedMesh every frame, one instance per entity. The source is a pool, or a function that returns an array, such as a query's rows.

const dots = new THREE.InstancedMesh(
    new THREE.CircleGeometry(0.01, 12),
    new THREE.MeshBasicMaterial(),
    5000                                   // the most entities it can draw
);
scene.add(dots);

const quakes = wildflower.getQuery('quakes');   // getQuery also starts the query fetching
globe.instanced(() => quakes.rows, dots, {
    name: 'quakes',
    position: ['x', 'y', 'z'],
    color: 'color',
    scale: (q) => 0.5 + q.mag / 4
});

position lists the three fields that hold the entity's coordinates. color and scale take a field name or a function of the entity. A color is a 0xRRGGBB number, or [r, g, b] in linear space. scale also takes three field names, ['sx', 'sy', 'sz'], which scale each instance along its own axes. name identifies the binding in picking results.

Four ways set an instance's rotation, and a spec uses one of them. quaternion takes four field names, ['qx', 'qy', 'qz', 'qw'], the rotation as a quaternion, such as a physics engine gives; it is normalized when drawn. rotation takes three field names, ['rx', 'ry', 'rz'], the rotation in radians about each axis, in three.js's 'XYZ' order. direction takes three field names and points the instance along them, and with a per-axis scale the third value stretches the instance along the direction. rotationY turns the instance about Y. quaternion or rotation beside another of the four throws 3D-104, and so does a quaternion, rotation or per-axis scale that is not four, three or three field names.

An array source is read again only when the function returns a different array. A query replaces its rows when new data arrives, so that happens once per fetch. For an array you change in place, add live: true. A pool is read every frame.

For a pool that changes now and then, such as a structure loaded once, add sync: 'change'. The binding then writes the pool only after its version goes up, which push(), remove(), clear(), update() and markDirty() do, and a pool that sits still costs nothing. A field changed in place does not raise the version, so report it with pool.markDirty(key) or pool.update(key, props). The development build checks once a second and warns (3D-111) when a change was not reported. Keep the default, sync: 'frame', for a pool that a tick() moves every frame.

wildflower.store('swarm', { pools: { bees: {} } });

const bees = wildflower.getStore('swarm').pools.bees;
globe.instanced(bees, beeMesh, { name: 'bees', position: ['x', 'y', 'z'] });

Here a store's tick() moves a pool of bees and the view draws them. The buttons add and remove entities; the view draws whatever the pool holds on the next frame.

<div data-component="hive-demo">
    <div id="hive-stage" style="height: 220px"></div>
    <p class="mt-2">
        <button class="btn btn-sm btn-primary" data-action="addBees">
            Add 200
        </button>
        <button class="btn btn-sm btn-secondary" data-action="removeBees">
            Remove 200
        </button>
        <span data-bind="$swarm.count"></span> bees
    </p>
</div>
wildflower.store('swarm', {
    state: { nextId: 0 },
    pools: { bees: {} },
    computed: { count() { return this.pools.bees.length; } },
    addBees(n) {
        const r = (size) => (Math.random() - 0.5) * size;
        const color = new THREE.Color();
        for (let i = 0; i < n; i++) {
            color.setHSL(0.08 + Math.random() * 0.1, 0.9, 0.55);
            this.pools.bees.push({
                id: ++this.nextId,
                x: r(3), y: r(1.6), z: r(1),
                vx: r(1), vy: r(1), vz: r(1),
                color: color.getHex()
            });
        }
    },
    removeBees(n) {
        const ids = this.pools.bees.map(b => b.id).slice(0, n);
        for (const id of ids) this.pools.bees.remove(id);
    },
    tick(dt) {
        const s = dt / 1000;
        for (const b of this.pools.bees) {
            b.x += b.vx * s; b.y += b.vy * s; b.z += b.vz * s;
            if (Math.abs(b.x) > 1.6) b.vx = -b.vx;
            if (Math.abs(b.y) > 0.9) b.vy = -b.vy;
            if (Math.abs(b.z) > 0.9) b.vz = -b.vz;
        }
    }
});

const container = document.getElementById('hive-stage');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 4);

const beeMesh = new THREE.InstancedMesh(
    new THREE.SphereGeometry(0.03, 8, 6),
    new THREE.MeshBasicMaterial(),
    2000                                   // the most bees it can draw
);
scene.add(beeMesh);

const hive = wildflower.three.view('hive', { renderer, scene, camera, fit: container });
const swarm = wildflower.getStore('swarm');
hive.instanced(swarm.pools.bees, beeMesh, {
    name: 'bees',
    position: ['x', 'y', 'z'],
    color: 'color'
});
swarm.addBees(400);

wildflower.component('hive-demo', {
    addBees() { swarm.addBees(200); },
    removeBees() { swarm.removeBees(200); },
    destroy() {
        wildflower.unregister('hive');
        wildflower.unregister('swarm');
        renderer.dispose();
    }
});
Live Preview

attributes writes per-instance values for your own shaders, such as { heat: 'temperature' }. A source with more entities than the mesh holds draws the ones that fit, and the development build warns once (3D-106).

A bound mesh has frustum culling turned off, because its instances move every frame and a bounding sphere computed once would be out of date. Leave it off: with culling on, instances that moved outside the old sphere disappear while they are still on screen. Picking recomputes the sphere itself after each write.

Frames from a worker

A Threads worker can compute positions and send them as one flat array per frame. buffer() draws that array directly, with no objects in between.

// The worker sends [count, x, y, z, x, y, z, ...] in orbits._frame
const orbits = wildflower.getStore('orbits');
const sats = globe.buffer(() => orbits._frame, satMesh, {
    name: 'sats',
    stride: 3, offset: 1,
    count: (a) => a[0],
    position: [0, 1, 2],
    color: (i) => colorOf(i),
    entity: (i) => ({ index: i })
});

Colors are written once per slot. When the records change meaning, for example when a new catalog arrives, call sats.recolor() to write them all again. With entity, the binding can be picked, as Picking and Labels describes.

Each record can carry its rotation as direction, three offsets the instance points along, or as quaternion, four offsets within the record for x, y, z and w. A quaternion that is not four offsets, or one given with direction, throws 3D-105.

Sizing and framing

fit sizes the view to an element. The pixel ratio is capped at 2, or at maxPixelRatio. When part of the canvas sits under something fixed, such as a side panel, inset centers the scene in the area that is left.

const panel = document.querySelector('.panel');
const globe = wildflower.three.view('globe', {
    renderer, scene, camera,
    fit: container,
    inset: () => (innerWidth >= 900
        ? { right: innerWidth - panel.getBoundingClientRect().left }
        : null)
});

inset takes { left, right, top, bottom } in CSS pixels, or a function that returns one, or null for the plain center. The canvas still fills the container, so the scene shows under a translucent panel. Picking and projection use the same camera, so they stay correct. The view reads the inset again whenever the container resizes; call globe.resize() when it changes on its own.

The store's size holds the canvas's { width, height } in CSS pixels, kept current with or without fit, and stats holds the average syncMs, renderMs and fps over the last second.

<p class="meta"><span data-bind="$globe.stats.fps"></span> fps</p>

Drawing only when something changes

By default a view draws every frame, even when nothing in the scene has moved. The GPU then redraws the same picture sixty times a second, and on a laptop that shows up as heat and battery use. With render: 'change', the view draws a frame only when something has changed since the last one it drew: a binding wrote, the camera moved, or the canvas was resized. Camera moves are found by comparing the camera with the last drawn frame, so orbit controls, their damping and auto-rotation need no extra code.

const globe = wildflower.three.view('globe', {
    renderer, scene, camera, fit: container,
    render: 'change'
});

// A change the view cannot see, made by your own code:
halo.position.copy(target);
globe.invalidate();                    // draw the next frame

The view sees its own bindings and the camera, and nothing else in the scene. A mesh you move, a material you change, or an object you show or hide needs invalidate() after it. A scene with its own animation in before(), such as a mesh that turns every frame, keeps the default.

For a scene that really does move all the time, maxFps caps how often the whole frame runs, before() included. Satellites that cross a pixel a second look the same at 15 frames a second and cost a quarter as much to draw. The two options combine.

stats.fps counts the frames drawn, so a still view under render: 'change' reads 0.

Inside a Kinetic scene the view is drawn once per seek by Kinetic, whatever render and maxFps say, so the canvas holds the frame being captured.

Your own code can do the same: view.draw() draws one frame now, whatever render and maxFps say, for code that needs the canvas to hold the current frame before it reads or captures it. view.canvas() returns the renderer's canvas, and wildflower.three.views() lists the live views as their stores. Called while the view is drawing, from its draw option or before(), draw() does nothing, and the development build warns once (3D-115).

for (const view of wildflower.three.views()) view.draw();
const still = globe.canvas().toDataURL();

Shader parameters

A shader parameter such as a glow amount, a cut-plane height or a color can follow your app's state. view.uniform(target, source) takes anything with a value, which covers a TSL uniform() node and an entry in a ShaderMaterial's uniforms, and a function that returns the value. The view reads the function each frame and writes the value when it changes, so a still view under render: 'change' draws once for each change and needs no invalidate().

import { uniform, mix, color } from 'three/tsl';

const reveal = uniform(0);
material.colorNode = mix(color(0x223355), color(0xffcc66), reveal);

const ui = wildflower.getStore('ui');   // a slider writes ui.reveal through data-model
house.uniform(reveal, () => ui.reveal);

A Color, a vector or an array is copied into the target's own object, which the material keeps, so the function may return a new object each frame. A 0xRRGGBB number is set into a Color target the same way. A typed array cannot change length, so a typed-array target takes as much of the returned array as fits. A function that throws keeps the last value, and the development build warns once (3D-113).

uniform() returns a function that stops keeping the target in step.

Video textures

three.js uploads a VideoTexture when the browser presents a new frame of its video. A paused video that was sought, by a scrubber or by Kinetic, need not present its frame before the view draws, so the texture can show an old one. view.video(texture) follows the video's currentTime instead: on the next frame after it moves, the texture is uploaded, and a still view under render: 'change' draws once for it. A video that is still seeking, or has no frame yet, waits until it has one.

const screen = new THREE.VideoTexture(video);
tv.material.map = screen;
view.video(screen);

It returns a function that stops it. Anything but a three.js texture of a <video> throws 3D-114.

Removing a view

wildflower.unregister('globe') stops the view. It removes the pointer listeners, and frees the GPU memory held by the geometries, materials and textures in the scene. Anything another view's scene still uses is left alone. The renderer and any camera controls are yours to dispose, because three.js cannot use them again afterwards.

wildflower.component('globe-page', {
    init() { startGlobe(); },
    destroy() { wildflower.unregister('globe'); }
});

If you will show the same scene again, pass dispose: false to view(). three.js recreates freed resources when they are used again, so the default is safe, but the first frame after that uploads every texture and compiles every shader again.

Diagnostics

The extension's codes start with 3D- and are listed in the error code reference. Wrong arguments to view(), instanced() and buffer() throw on every build. Warnings, such as a mesh too small for its source, are printed by the development build only.