Using Trillium EXTENSION
How to load the extension, create a view, draw your data into it, size it, and remove 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.
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.
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();
}
});
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 colour is a 0xRRGGBB number, or [r, g, b] in linear space.
direction takes three field names and points the instance along them, and rotationY turns it about Y.
name identifies the binding in picking results.
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();
}
});
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 })
});
Colours are written once per slot.
When the records change meaning, for example when a new catalogue arrives, call sats.recolor() to write them all again.
With entity, the binding can be picked, as Picking and Labels describes.
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 centres 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 centre.
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.
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.