# phoria2 - reference for app writers
Small 3D for the web, for business apps: charts, labelled scenes, diagrams, dashboards, explainers,
editors and product viewers. WebGL 2, plain ES modules, no dependencies.
This is the whole API in one page, written for people and coding agents building with phoria2. Demos:
https://kevs3d.co.uk/phoria2/ - source: https://github.com/kevinroast/phoria2
## When to use it
phoria2 is for 3D that shows something: 3D charts from a data spec (bar, scatter, line, area,
surface, network), labelled diagrams and floor plans, cutaway explainers with callouts, animated
infographics on a timeline, dashboards of many small views, product viewers for glTF models, and
editors that drag, snap and undo. Picking, tooltips, hover highlights, HTML labels, shadows and
image-based light are built in, and a view draws only when something changes, so idle pages cost
nothing. A dashboard of many small views just works - each tile its own `new View({ canvas })`,
with no limit on WebGL contexts to manage (after the first four, views share one). Apps come out short: typically a few dozen lines. For games, custom shaders or
post-processing pipelines, three.js is the better fit: phoria2 has no shader hook (no custom
effects), no refraction, and no shadow-softness setting.
## Install
```sh
npm install phoria2
```
```js
import { View, Mesh, box } from 'phoria2'
import { chart } from 'phoria2/charts' // 3D charts from a spec
import { loadGLTF } from 'phoria2/gltf' // glTF 2.0 models
import { Timeline, follow, reveal } from 'phoria2/motion' // steps on a timeline, moving along curves
import { Emitter, Body } from 'phoria2/particles' // particles and simple physics
import { TransformHandle, History } from 'phoria2/edit' // a move/turn handle, and undo
```
Without a build step, map the imports to a CDN in the page, pinning the version (they share one
copy of the core):
```html
```
The other sub-paths follow the same pattern: `dist/gltf.js`, `dist/motion.js`, `dist/particles.js`,
`dist/edit.js`. TypeScript types ship with the package.
When this page doesn't settle something, read the source with its comments in
`node_modules/phoria2/src` (`src/View.js`, `src/scene/`, `src/geometry/`, `src/charts/`...), not the
minified `dist/` files - but import from the entry points, not from `src/`.
**Versions** follow semver from 1.0: everything on this page and in the types keeps working through
1.x, defaults included. Exact pixels and error wording can change; nothing is removed without a
release that warns first, naming the replacement. A warning that something is deprecated says what
to write instead - do that.
## Conventions
- **Coordinates**: right-handed, **+Y up**, +Z towards the viewer, units are metres.
- **Angles** are radians, except a perspective camera's `fov`, which is degrees.
- **Vectors in**: `[x, y, z]` arrays (objects `{ x, y, z }` also work). On objects, `position`,
`rotation`, `scale` are small vector classes: `node.position.set(1, 2, 3)`, `node.rotation.y += 0.1`,
`node.scale.x = 2` and `node.position = [1, 2, 3]` (copied in) all work.
- **Colours**: `'#ff8800'`, `'#f80'`, `0xff8800`, CSS names (`'orange'`), `'rgb(255 136 0)'`, or
`[r, g, b]` 0-1. Inputs are sRGB.
- **Time** is seconds. Handlers get what they act on as an argument (arrow functions are fine).
- **The orbit**: `view.controls.spherical` is `{ yaw, pitch, distance }` about `camera.target` - yaw 0
looks from +Z (the front), `Math.PI / 2` from +X (the right); pitch is above the horizon. Setting it
moves the camera, within the limits. Move the camera or its target yourself at any time - the orbit
turns about wherever they are; `camera.target = [x, y, z]` copies the point in.
- **One options object per constructor.** Unknown option names throw an error that says what to use
instead - read it, it usually names the fix.
## The minimal app
```html
```
```js
import { View, Mesh, box, DirectionalLight } from 'phoria2'
const view = new View({ canvas: '#c', orbit: true, background: '#1c1c20' })
const cube = view.scene.add(new Mesh({ geometry: box(2), material: { color: 'orange' } }))
view.scene.add(new DirectionalLight({ direction: [-1, -2, -1] }))
view.start((dt) => cube.rotateY(0.6 * dt))
```
The canvas needs a size from CSS; the View matches its drawing buffer to it and follows resizes
itself. A scene with no lights is lit automatically (with a console note).
## View
```js
const view = new View({ canvas: '#c', orbit: true })
```
| Option | Default | |
|---|---|---|
| `canvas` | a new full-window canvas | element or CSS selector |
| `camera` | `'perspective'`: a three-quarter view from the front right and above, fitted to the scene on the first frame | a preset - `'perspective'`, `'isometric'`, `'top'`, `'front'`, `'side'` (fitted to the scene on the first frame, unless you place or frame the camera first) - camera options (`{ position, target, fov }`), or a camera |
| `orbit` | `false` | `true`, or options: `{ autoRotate, inertia, minDistance, maxDistance, minPitch, maxPitch, pan, zoom, mode }`. `autoRotate`: `true` (a slow turn) or radians per second; it pauses while the user drags. `mode`: `'orbit'` (the default) or `'map'` - drags pan and the view never turns, for plans. `minDistance`/`maxDistance` limit zooming: the distance to the target, or for an orthographic camera the height of the view. Left-drag orbits, right-drag pans, wheel zooms |
| `background` | transparent | a colour |
| `highlight` | none | a colour mixed into whatever is under the pointer |
| `tooltip` | none | `(hit) => content` - see Interaction |
| `framing` | `'height'` | what stays in view as the canvas changes shape: `'height'`, `'width'`, or `'both'` (nothing framed is ever cut off - good for tiles). Also `{ keep, aspect }` |
| `renderMode` | `'auto'` | `'auto'` draws only when something changed (idle views cost nothing); `'always'`; `'manual'` |
| `toneMapping`, `exposure` | `'neutral'`, `1` | `'neutral'` \| `'aces'` \| `'none'` |
| `bloom` | off | glow round bright parts (emissive, additive points and lines): `true` or `{ strength, radius, threshold }`; also `view.bloom` |
| `autoLight` | `true` | light a scene that has no lights (a chart needs none of its own); `false` to leave it dark |
| `pixelRatio` | `min(devicePixelRatio, 2)` | |
| `context` | `'auto'` | WebGL context: own for the first four views on a page, then one shared context - so a page can hold any number of views |
| `keyboard` | `false` | `true` or `{ label: (hit) => text }`: use without a pointer, with a screen reader - see Interaction |
| `description` | none | what the view shows, for screen readers (the canvas's label); made from the scene when `keyboard` is on |
| `reducedMotion` | `'auto'` | as the system's reduced-motion setting: `frame()` jumps rather than glides, auto-rotation holds, charts change at once. `true` or `false` to choose |
Members: `view.scene`, `view.camera` (assign a camera or a preset name to switch), `view.controls`
(the orbit control), `view.renderer`, `view.width`, `view.height`, `view.description`, `view.reducedMotion`
(true or false as it stands - check it for motion of your own, such as a timeline; set `'auto'`, true or false).
Methods:
- `start(fn?)` - `fn(dt, t)` every frame. Time and node update handlers run anyway; you only need
`start` for your own per-frame code.
- `frame(node?, { padding, direction, duration })` - move the camera so a node (default: the whole
scene) fills the view, keeping the viewing direction - centred across, and a little below the
middle so it stands rather than hangs. It fits what the camera draws - not hidden nodes, nor a
chart's axis labels for the edges it isn't showing - so a chart fills the view. `padding` is the
margin around it as a fraction of its size: `0` is a tight fit, `0.2` (the default) leaves a
fifth, `0.5` half. `direction` looks from a preset's direction (`'perspective'`, `'top'`,
`'front'`, `'side'`, `'isometric'` - the camera stays the kind it is) or a vector from the
target towards the camera; `duration` (seconds) glides there - a press or the wheel stops it.
A Reset button is `view.frame(undefined, { direction: 'perspective', duration: 0.6 })`; a guided
tour flies from one node to the next with `view.frame(node, { duration: 1 })`.
- `on('click' | 'hover', (hit, event) => ...)` - see Interaction. Returns an unsubscribe function.
- `tooltip(fn)` - set the tooltip function later.
- `pick(x, y)` / `pick(event)` - the hit under a canvas position; `pickAll` for every hit.
- `rayAt(x, y)` / `rayAt(event)` - the world `Ray` through a canvas position, for placing things:
`ray.at(ray.intersectPlane([0, 1, 0], [0, 0, 0]))` is the point on the floor under the pointer
(`intersectPlane` returns null when the ray runs along the plane or away from it).
- `await view.toImage()` - a PNG `Blob` of the picture, labels included.
- `await view.explain()` - a plain-text report of what is drawn, with warnings ("nothing in view",
"no lights", labels hidden...). Use it to debug a blank or wrong picture.
- `render()`, `invalidate()` (redraw after changing data the view can't see), `dispose()`.
If a page shows nothing or the wrong thing, `console.log(await view.explain())` before anything else.
Errors thrown for bad options name the option and usually the fix.
## Changing things later
Everything can be changed after it is made, and the next frame shows it (the View redraws when
something changes):
- Nodes: `position`, `rotation`, `scale`, `visible`, `pickable`, `add`/`remove` children. `node.bounds`
is always current - read it straight after adding or moving things.
- Materials: every property - `color.set(...)`, `opacity` (going below 1 makes it transparent;
back to 1, opaque again), `emissive`, `roughness`, `width` on Lines, `size` on Points, maps.
- Instances: `setInstance`, `setColor`. Labels: `text`, `className`, `visible`.
- Lights: `intensity`, `color`, `direction`, `castShadow`. Scene: `background`, `environment`.
- View: `view.camera = 'top'` switches camera (a preset is fitted to the scene); `view.controls.mode`,
`view.controls.autoRotate`, `view.controls.enabled`; `view.highlight`; `view.tooltip(fn)`.
- Charts: `chart.update(newRows)` or `chart.update({ color: 'other' })` - animated.
## Scene graph
Everything in a scene is a node. Node options (all optional): `name`, `position`, `rotation`
(Euler XYZ radians, `[x, y, z]`), `quaternion` (`[x, y, z, w]` or `{ x, y, z, w }`), `scale` (number or `[x, y, z]`), `visible`,
`pickable` (default true), `userData`, `children`, `onUpdate`, `clip` (a section plane - see
Cutaways), plus the interaction options below.
```js
const group = view.scene.add(new Group({ position: [0, 1, 0] })) // add() returns what it added
group.add(new Mesh({ geometry: sphere(), material: { color: 'teal' } }))
node.onUpdate((node, dt, t) => { node.rotation.y += dt })
scene.find('lamp'); scene.findAll((n) => n.name.startsWith('rack')); scene.traverse((n) => {})
node.translate(x, y, z); node.rotateY(rad); node.lookAt([0, 0, 0]) // a camera with a target moves its target; a light shines there
node.remove(child); node.removeFromParent(); node.dispose()
node.bounds // world-space Box3: .min, .max, .getCenter(), .getSize()
node.boundsFrom(camera) // the same, for only what that camera draws (hidden and showFrom-off parts left out)
node.worldPosition // read only
node.updateWorld() // world matrices up to date now - ancestors, the node, everything under it -
// to read worldPosition or bounds after moving things, before the next frame
node.updateWorldTree() // the same for the node and everything under it, when its parent is already current
```
**Cutaways**: `clip: { normal, offset, cap }` on any node cuts away everything in its subtree on the
`normal` side of a plane, `offset` along the normal from the node's origin, in the node's own space
(so it moves with the node). `cap` fills the cut faces of closed shapes with a colour, as a surface
lying on the plane - parts that touch join cleanly, and what is inside them stays hidden; without it
they show their hollow insides. Caps need closed solids: keep open surfaces (planes, open tubes) out
of a capped clip. The nearest clip above a mesh is the one that cuts it. Move the cut
by changing it in place: `engine.clip.offset = 0.5`. Cut-away parts cast no shadow and can't be
picked; points and lines aren't cut. A clip is one plane: there is no cutting out an octant or a
wedge - build the cut-open shape itself for that.
```js
const block = view.scene.add(new Group({ clip: { normal: [0, 0, 1], offset: 0, cap: '#c0392b' } }))
```
`Scene` (also `view.scene`) adds: `background` (colour, or a panorama texture), `environment`
(`'studio'` - built in, no download - `'none'`, or an `.hdr` url: image-based light for every lit
material), `environmentIntensity`, `backgroundBlur` (0-1, for a panorama background), `fog`.
**Fog**: `scene.fog = { color, density }` - exponential, thickening with distance like mist (try 0.02 -
0.1) - or `{ color, near, far }` for linear. Give `background` the same colour, so distant things melt
into it. Change it in place (`scene.fog.density = 0.08`); `null` turns it off. Additive glows fade
out with distance rather than turning fog-coloured; a material with `fog: false` (a sky dome, a far
backdrop) ignores it.
## Drawables
All drawables take `geometry`, `material`, `castShadow`, `receiveShadow`, `renderOrder` and `data`.
```js
new Mesh({ geometry: box(1), material: { color: '#c33', roughness: 0.3 } })
new Points({ geometry, material: { color: 'white', size: 4, sizeAttenuation: false } }) // size in pixels when not attenuated
new Lines({ geometry: edges(box(2)), material: { color: '#333', width: 2 } }) // segments: pairs of vertices; width in pixels
new Label({ text: 'Rack A1', position: [0, 2, 0], className: 'my-label', place: 'above' })
```
**InstancedMesh** - many copies of one geometry in one draw call, for bars, racks, nodes:
```js
const bars = view.scene.add(new InstancedMesh({ geometry: box(1), material: { roughness: 0.5 }, data: rows })) // count = rows.length
rows.forEach((r, i) => bars.setInstance(i, { position: [r.x, r.h / 2, r.z], scale: [0.8, r.h, 0.8], color: r.color }))
bars.setColor(3, 'red')
```
`setInstance(i, { position, rotation, quaternion, scale, color })`. An instance's `color` multiplies the
material's colour, which is white unless you set one - so the instance colour shows as given. `count` defaults to the number
of rows in `data`. For very large data, write `bars.instanceMatrices` (16 numbers per instance,
column-major) directly and call `bars.needsUpdate()`. An instance scaled to 0 is hidden: it draws
nothing, and bounds, framing, shadows and labels leave it out - so a pool can keep spare instances.
Positions and quaternions also take `{ x, y, z }` and `{ x, y, z, w }`, as physics engines return
them, so a physics bridge is one line a body:
`bricks.setInstance(i, { position: body.translation(), quaternion: body.rotation() })`.
**Labels** are HTML elements pinned to 3D points (style them with CSS). Options: `text`, `position`,
`className`, `place` (`'center'` | `'above'` | `'below'` | `'left'` | `'right'`), `offset` ([x, y]
pixels), `priority` (higher wins when labels overlap), `overlap` (`'hide'` | `'dim'` | `'show'` - what
a label does when covered), `occlude` (true: counts as covered when geometry is in front), `leader`
(true: a line from the point to the label, with a dot on the point - for callouts), `edge` (`'left'` |
`'right'`: set the label at that edge of the view, level with its point - callouts in a column, with
`leader: true`; `offset` then moves it in and up or down), `minScale` (show it only when zoomed in this
far - below).
A label answers the pointer when it has handlers: a Label with its own `onClick` or `onHover` is
clicked and hovered like a mesh (`new Label({ text: 'Mantle', onClick: () => show('mantle') })` - a
callout that opens its layer), and a label of an instance or point is part of it - clicking it is
clicking that bar, with the same hit. Other labels let the pointer through to the scene.
**Labels per instance or point**, without a Label node each:
```js
new InstancedMesh({ geometry: box(1), data: rows, labels: { text: (row) => row.name, place: 'above', priority: (row) => row.alert ? 2 : 0 } })
```
**Labels by zoom level**: `minScale` on any label shows it only when the view is zoomed in at least that
far - that many CSS pixels to one world unit, at the label's point (perspective or orthographic alike).
Until then it takes no part in the layout, so it can't push other labels out. `labels` can also be a
list, several labels per instance - the short one always, the details as you come closer:
```js
new InstancedMesh({ geometry: box(1), data: elements, labels: [
{ text: (e) => e.symbol, place: 'center', className: 'symbol' },
{ text: (e) => e.name, place: 'below', className: 'name', minScale: 45 },
] })
```
Pick `minScale` by looking: at the zoom where the details should appear, a world unit spans that many
pixels (a 1-unit tile drawn 60 px wide is 60). `view.explain()` counts the labels still waiting.
## Geometry
Generators return a `Geometry`; each takes one options object (a number is the main size):
| | Options |
|---|---|
| `box(size \| { width, height, depth })` | centred on the origin |
| `sphere({ radius, segments, rings })` | |
| `plane(size \| { width, depth })` | in the x-z plane, facing up (+Y) |
| `cylinder({ radius, radiusTop, height, segments })` | along Y, centred; `radiusTop: 0` is a cone, its tip at the + end of the axis (up) |
| `torus({ radius, tube })`, `icosphere({ radius, detail })` | |
| `lathe({ points, segments, axis })` | a profile of `[radius, y]` pairs, bottom up, turned about Y - pistons, bottles, bolts (listed top down, it faces inward) |
| `extrude({ shape, holes, depth, axis })` | an outline of `[x, y]` pairs (and hole outlines) pushed along Z, centred - plates, brackets, logos |
| `grid({ size, divisions })` | floor grid, for `Lines` |
| `edges(geometry, { angle })` | the edges of any geometry, for `Lines`: where faces turn more than `angle` degrees (default 1) - 30 keeps round surfaces clean, outlining a part as a drawing does |
`axis: 'x' | 'y' | 'z'` lays `cylinder`, `lathe` and `extrude` along another axis - no rotating the mesh
to suit. An extruded outline is drawn as seen from the axis's + end, centred along the axis: with
`'y'`, from above - the outline's x is the world's x and its y runs towards -z, away from the front.
**Outlines** for `extrude` and `lathe` - lists of `[x, y]` points, anticlockwise, to spread together or
cut as holes: `outline.circle(radius | { radius, segments, center })`, `outline.rect({ width, height,
radius, center })` (`radius` rounds the corners), `outline.polygon({ radius, sides, angle })`,
`outline.arc({ radius, from, to, center })` (angles in radians from +x; both ends included, for joining
into a shape), `outline.gear({ radius, teeth, depth })`.
```js
// a connecting rod: two eyes joined by their sides, each bored through, lying across x
extrude({
shape: [...outline.arc({ radius: 0.15, from: -0.4, to: Math.PI + 0.4, center: [0, 1.4] }), ...outline.arc({ radius: 0.3, from: Math.PI - 0.4, to: 2 * Math.PI + 0.4 })],
holes: [outline.circle(0.2), outline.circle({ radius: 0.09, center: [0, 1.4] })],
depth: 0.16, axis: 'x',
})
```
Your own: `new Geometry({ positions, normals?, uvs?, uvs2?, colors?, indices? })` - flat arrays, xyz per
vertex; `colors` are rgb 0-1 per vertex (use `material: { vertexColors: true }`); `uvs2` is a second UV
set, for baked occlusion and light maps (below). To animate one - cloth, a wave, a morph - write
`geometry.positions` in place and call `geometry.needsUpdate()` each frame; normals you didn't give are
worked out again to follow the shape.
## Materials
One material type, given as a plain object:
```js
{ color: 'white' } // lit, physically based
{ color: '#c33', roughness: 0.2, metalness: 1 } // metal
{ color: 'orange', unlit: true } // flat colour, ignores lights
{ color: '#8cf', opacity: 0.4 } // transparent: fades the whole surface
{ transmission: 1, roughness: 0.05 } // clear glass
{ color: '#a3121f', metalness: 0.6, clearcoat: 1 } // car paint: a glossy coat over it
{ color: '#1f2d6e', roughness: 0.9, sheen: 1 } // velvet: cloth's bright rim
{ color: '#ccc', metalness: 1, anisotropy: 0.8 } // brushed metal
{ color: '#f2e4c4', translucency: 0.8 } // paper: lit from behind too
{ matcap: 'clay.png' } // the look from a picture of a lit sphere
{ color: '#e4572e', toon: 3 } // cartoon: three flat tones
{ emissive: '#ff0', emissiveIntensity: 3 } // glows
{ map: 'wood.jpg', normalMap: 'wood_n.png' } // textures from urls
```
**Exact colour**: an `unlit` material draws exactly the colour it is given - `'#e15759'` is
`#e15759` on screen and in `toImage()`, whatever the tone mapping - and so do instance colours,
vertex colours, colour maps, lines and points. Use it for swatches, legends, status colours and
brand colours. A lit surface doesn't keep its colour: light and tone mapping are meant to change
it. Vertex colours blend across each face in linear light, so a ramp between two vertices differs
slightly from a 2D tool's; subdivide for a closer match.
Properties: `color`, `opacity`, `roughness` (0.5), `metalness` (0), `emissive`,
`emissiveIntensity`, `unlit`, `flatShading`, `doubleSided`, `vertexColors`, `fog` (true), `blending`
(`'normal'`, `'additive'` - adds light, for glows and sparks - or `'multiply'` - darkens what is behind, as a colour
filter does: cyan over magenta is blue), `alphaTest` (cut-outs: drop pixels with less alpha - leaves, fences),
`map`, `normalMap`/`normalScale`, `bumpMap`/`bumpScale`, `roughnessMap`, `metalnessMap`, `aoMap`,
`emissiveMap`, `lightMap`/`lightMapIntensity`, `environmentIntensity` (1), `transmission`, `clearcoat`/`clearcoatRoughness`, `sheen`/`sheenColor`/`sheenRoughness`, `ior`/
`specularIntensity`/`specularColor`, `anisotropy`/`anisotropyRotation`, `translucency`/`translucencyColor`, `matcap`, `toon` (below); `size`/`sizeAttenuation` for Points, `width` for Lines. Change later through
`mesh.material.color.set('#0f0')`, `mesh.material.opacity = 0.5`, or a whole new one: `mesh.material = { color: 'red' }`
(options become a Material, as in the constructor; `mesh.geometry = box(2)` likewise).
A **bump map** is a height texture (red channel) shown as bumps - no normal map to make, so it suits
heights drawn in code. `bumpScale` is how high a height of 1 stands, in the mesh's units. Textures
take `anisotropy` (default 8, up to the GPU's limit, 1 for off), which keeps floors and roads sharp
into the distance. A cut-out (`alphaTest`) casts the shadow of what is left of it. A Texture given to a
data map (`normalMap`, `bumpMap`, `roughnessMap`, `metalnessMap`, `aoMap`) is read as data, not colour,
unless its `colorSpace` was set on purpose - `normalMap: await loadTexture('n.png')` just works.
**Canvas and video textures**: a texture's `image` can be a canvas - draw on it, then call
`texture.needsUpdate()` - or a `