Hotspots
Put small clickable dots on your model, for example to open a panel about a soft-close lid. A hotspot can stand off the surface on a thin line, open and close with an animation, and light up the model around it. Keep it in your viewport only, or show it in renders and videos too.
import { createHotspot, attachHotspots } from '@oeave/bakery3/hotspots';
const lid = createHotspot({
id: 'lid',
label: 'Soft-close lid',
anchor: [0.12, 0.28, 0], // a point on the model's surface
direction: [0, 1, 0], // which way the dot stands off it
distance: 0.06, // how far, in meters
parent: product, // follows the model
});
const knob = createHotspot({ id: 'knob', position: [-0.2, 0.15, 0.1] });
scene.add(knob.object); // no parent: add it yourself
const ui = attachHotspots({
hotspots: [lid, knob],
camera,
domElement: renderer.domElement,
onSelect: (hotspot) => showPanel(hotspot?.label ?? null),
});
renderer.setAnimationLoop((time) => {
ui.update(time / 1000);
renderer.render(scene, camera);
});A hotspot pulses gently, highlights on hover and opens on click. With an
anchor, a thin line runs from a small dot on the surface out to the hotspot.
It is plain Three.js, so it works with WebGLRenderer and WebGPURenderer.
Tie a hotspot to the model, or let it float
A hotspot tied to the model has two points: the anchor on the surface and
the position where the dot is. A thin line joins them, with a small dot on
the surface. Give it the model as its parent, and it turns and moves with
the model.
createHotspot({
id: 'lid',
anchor: [0.12, 0.28, 0], // on the surface
position: [0.2, 0.42, 0.05], // the dot, anywhere you like
parent: product, // turns with the model
});A free hotspot has only a position: no line, no surface dot. Add it to the
scene rather than the model, and it stays where it is while the model turns.
const note = createHotspot({ id: 'note', position: [0.6, 0.9, 0.3] });
scene.add(note.object);Both points are in the space of the parent (or the scene, without one). Move
the dot later with hotspot.setPosition([x, y, z]), and the surface point
with hotspot.setAnchor([x, y, z]), which keeps the dot where it is.
hotspot.position and hotspot.anchor read them back.
Place a hotspot where the user clicks
hotspotFromHit() turns a raycast hit on your model into an anchor and a
direction. The direction follows the surface normal, or points away from
the middle of the model with along: 'center', which keeps dots on a bumpy
surface from pointing every which way.
import { createHotspot, hotspotFromHit } from '@oeave/bakery3/hotspots';
const hit = raycaster.intersectObject(model, true)[0];
if (hit) {
const { anchor, direction } = hotspotFromHit(hit, { space: product });
createHotspot({ id: 'new', anchor, direction, distance: 0.08, parent: product });
}Change the distance later with hotspot.setDistance(meters), and the
direction with hotspot.setDirection([x, y, z]).
Open and close a hotspot
A hotspot is hidden, closed or open. setState() moves it there with
an animation: appearing, the dot lands on the surface, the line grows out and
the hotspot pops in at its end; opening, it swells and brightens.
lid.setState('hidden', { instant: true });
lid.setState('closed'); // grows out of the surface
lid.setState('open'); // what a click does
lid.phase; // 'entering', 'closed', 'opening', 'open', 'closing' or 'leaving'Set how long each move takes with timing: { appear: 0.8, open: 0.45 }.
Change how a hotspot looks
createHotspot({
id: 'lid',
anchor,
direction,
style: 'ring', // 'orb', 'ring', 'glass' or 'solid'
color: '#33d1ff',
openColor: '#ff2d95', // the color it turns while open
intensity: 3, // how bright it glows
glow: 0.05, // how much it lights the model around it
});For your own look, pass material: one material for the dot itself, or a
function asked once per part (core, halo, ring, shell, tether,
dot) that returns a material or undefined to keep the default. A TSL node
material works too. Multiply its emissiveNode by bakeryEmissiveIntensity()
so it brightens as the hotspot opens in your viewport, as it does in the
render.
import { MeshStandardNodeMaterial } from 'three/webgpu';
import { color } from 'three/tsl';
import { bakeryEmissiveIntensity, bakeryFresnel } from '@oeave/bakery3/tsl';
createHotspot({
id: 'lid',
anchor,
direction,
render: true,
material: ({ name }) => {
if (name !== 'core') return undefined;
const rim = new MeshStandardNodeMaterial();
rim.emissiveNode = color('#33d1ff')
.mul(bakeryFresnel(1.45).mul(4).add(0.1))
.mul(bakeryEmissiveIntensity());
return rim;
},
});Show a label beside a hotspot
createHotspot({
id: 'lid',
label: 'Soft-close lid',
anchor,
direction,
showLabel: 'hover', // 'never', 'hover', 'open' or 'always'
labelSide: 'auto', // or 'left' or 'right'
labelFont: '600 40px Inter, sans-serif',
});The label fades in and slides out from the dot. With labelSide: 'auto' it
sits on the right, and moves to the left when the hotspot is too close to the
right edge of the screen. hover shows it while the pointer is over the dot
and while the hotspot is open; a phone has no hover, so there it shows when
the hotspot is open. Change the text later with hotspot.setLabel().
The label is drawn by the browser in the font you give, so a render shows the
same letters you see. Set the color with labelColor and labelBackground,
and the size with labelSize. The font's pixel size only sets how sharp the
label is: the label is sized to the dot. A hotspot and its label always face
the camera.
Show hotspots in a render
By default a hotspot is for your viewport only and never shows up in a render.
Pass render: true to make it part of the scene: it is traced like everything
else, and its glow lights your model.
Use sizing: 'world' with a size in meters if the hotspot should have a
fixed size in the render. The default, screen, keeps it the same size on
screen at any distance, and a render uses its size at the moment you render.
Play hotspots in a video
animateHotspots() puts state changes on a timeline. Scrub
the timeline and you see in your viewport what the video will show.
import { animateHotspots } from '@oeave/bakery3/hotspots';
const timeline = bakery3.timeline({ duration: 6 });
timeline.push({ object: 'camera', orbitY: 30 }); // the camera first
animateHotspots(timeline, [lid, knob], {
events: [
{ hotspot: 'lid', at: 0.3, state: 'closed' },
{ hotspot: 'knob', at: 0.6, state: 'closed' },
{ hotspot: 'lid', at: 2, state: 'open' },
{ hotspot: 'lid', at: 3.5, state: 'closed' },
],
});
await bakery3.renderVideo({ timeline });An event can hover a hotspot instead of changing its state, as the pointer
would: { hotspot: 'lid', at: 1.5, hover: true }, and hover: false to move
off it. A hotspot with showLabel: 'hover' shows its label in the video
then.
Every hotspot starts hidden unless you pass initial. Each must be made with
render: true. Call it again to replace what it wrote, and call release()
on what it returns to hand the hotspots back to clicks. The pulse is rounded
to whole breaths over the clip, so a looping video has no jump.
The glow's light stays where the hotspot ends up, so it lights the model from there while the hotspot grows in. A hotspot on a model that the timeline moves keeps its light where it was when you rendered.
Options
| Option | Default | What it does |
|---|---|---|
id | required | A stable name. It travels with hotspotSpecOf(). |
label | none | The label's text, and a caption for your app to show. |
anchor | none | A point on the model, in the parent's space. Draws the line and the dot. |
direction | up | Which way the hotspot stands off the anchor. |
distance | 0.1 | How far it stands off the anchor, in meters. |
position | none | Where the dot is, in the parent's space. With an anchor, instead of direction and distance. |
parent | none | The object the hotspot follows. Without one, add hotspot.object to the scene yourself. |
render | false | Show it in renders and videos. |
style | 'orb' | orb, ring, glass or solid. |
color | '#33d1ff' | Any CSS color. |
openColor | none | The color it turns while open. |
intensity | 3 | How bright it glows. 1 when it does not render, so the color on screen is exactly color. |
glow | 0 | How much it lights the model around it while open, in candela. A third of it while closed. |
material | none | Your own material, or a function that picks one per part. |
sizing | 'screen' | screen keeps it the same size on screen at any distance. world is a fixed radius in meters. |
size | 0.015 | The radius: a fraction of the viewport height for screen, or meters for world (default 0.03). |
pulse | true | Whether it pulses while closed, or how fast, in cycles a second. |
tether | true | Draw the line to the anchor. |
dot | true | Draw the dot on the anchor. |
showLabel | 'never' | When the label shows: never, hover, open or always. |
labelSide | 'auto' | Which side the label is on: auto, left or right. |
labelFont | system UI | A CSS font for the label. |
labelSize | 1 | The label's height, as a multiple of the default. |
state | 'closed' | The state it starts in. |
timing | see above | Seconds to appear and to open. |
Every hotspot has id, label, object (the group to add or move), state,
phase, selected, hovered, position, anchor, distance, labelSide,
setState(), setSelected(), setHovered(), setLabel(), setPosition(),
setAnchor(), setDistance(), setDirection(), update(), fit() and
dispose().
attachHotspots() returns update(), selected, select() and dispose().
Without attachHotspots(), call fit(camera) and update(seconds) on each
hotspot every frame.
A hotspot that does not render is marked userData.bakery3 = { exclude: true },
the same flag you can put on any object of your own.
Save the hotspots with a render
hotspotSpecOf() returns a hotspot as plain data in world space. Put it in
productState, which is stored with the render and never rendered. Your app
can then place the hotspots over the finished image.
import { hotspotSpecOf } from '@oeave/bakery3/hotspots';
await bakery3.render({
productState: {
finish: 'walnut',
hotspots: [lid, knob].map(hotspotSpecOf),
},
});
// hotspotSpecOf(lid):
// { id: 'lid', label: 'Soft-close lid', position: [x, y, z], anchor: [x, y, z] }