schiller/Documentation
Structural colour for the web, and how to put it on your artwork
- v0.2.0
- MIT licence
- npm ↗
- Playground ↗
A zero-dependency WebGL library for structural colour, on flat artwork or on a Three.js material
Structural colour is the kind that comes from a surface built at the scale of light’s own wavelength, rather than from any pigment in it. The name is the mineralogist’s word for the same thing: the sheen inside labradorite and moonstone, which is not a colour the stone contains but one its layers make.
npm install schiller
three is an optional peer, needed only for the 3D entry point.
Two optics, two structures
Everything in the library is one small model. Pick an optic, pick a structure, then tune about a dozen numbers.
Thin film
Light reflects off the top and bottom of a layer a few hundred nanometres thick and the two copies interfere, cancelling some wavelengths and reinforcing others. filmBase is that thickness, and it is the most consequential number here: it sets how fast hue cycles with viewing angle.
Diffraction grating
Fine parallel grooves split light by wavelength across angle, throwing a spectral fan rather than a soft bloom. order multiplies the sweep, which is why a CD fans wider than a soap bubble.
Platelet field
A jittered Voronoi giving every flake its own rotation, tilt and thickness. That is literally what mica powder, metallic car paint and a retroreflector’s bead bed are.
Continuous film
One unbroken layer, for tarnish, a fired lustre glaze, nacre, or passivated steel. Set with continuous: true, and it changes the compiled program rather than just a uniform.
The two structures are not interchangeable. A platelet field uses a tight pow(ndh, 220) mirror lobe at gain ×62; a continuous film uses a broad pow(ndh, 11) lobe at ×5.5. Copying a sheen value from one to the other gives you either a white blank or nothing at all.
Which finish, and on what
Every finish is named after a real material and the catalogue shows each one on the object it imitates — so the fastest way to pick is to find the thing you already have a picture of in your head. Each one links to that specimen, running.
Choose from your artwork first, not from the finish. The right pairing of grain mode to artwork matters more than which material you pick: a finish on the wrong grain reads as foil laid on top of a picture rather than as the picture being made of it. wants is the grain each artefact is actually drawn for.
wingArgope rosea — From an uploaded imagewantscontourOrganic subjects and soft transitions — anything that should read as grown rather than manufactured. The subtlest in the set. See it ↗
dichroicMirrored goggles — Interference filter on polycarbonatewantscontourStrong saturated colour that survives being looked at off-angle: coated glass, lenses, premium packaging. See it ↗
nacrePearl button — Cut blank, four holeswantsluminanceQuiet and expensive, close to white. Cosmetics, stationery, jewellery — where a rainbow would be vulgar. See it ↗
brushedKey — Brushed brasswantsangleMetal with a machining direction: panels, hardware, anything milled or drawn. Wants grain: "angle". See it ↗
foilSweet wrapper — Metallised film, flow-wrappedwantscontourA fast rainbow on a cheap-and-cheerful surface — confectionery, gift wrap, party packaging. See it ↗
discVinyl record — Pressed vinyl, spiral cutwantscontourThe loudest spectrum available, second order. Grooved or spiral subjects, and anything that should read as a CD. See it ↗
micaDustCold can — Frost on lacquered aluminiumwantsparticleA barely-there sparkle. Automotive pearl paint, frost, powder finishes. See it ↗
glitterMirror ball — Silvered glass, on a carcasswantsparticleActual glitter: sequins, craft stickers, anything with discrete flakes you can pick out individually. See it ↗
pearlStrand of pearls — Nacreous, culturedwantsparticlePearlescent paint and nail polish — smooth and continuous, where glitter is discrete. See it ↗
holoFlakeCurled ribbon — Metallised film, embossedwantsparticleHolographic vinyl, trading cards, security stickers. A grating, but broken into platelets. See it ↗
carapaceMirrored goggles — Interference filter on polycarbonateAn alias for dichroic, kept because the README named it. Same material. See it ↗
Four imports
| Import | Exports | What it is |
|---|---|---|
schiller | createSchiller | Structural colour on 2D artwork |
schiller/three | patchMica | The same optics on a MeshPhysicalMaterial |
schiller/drivers | attachPointer… | Pointer, scroll, gyroscope, visibility |
schiller/glsl | shader chunks | The raw GLSL, for your own pipeline |
On flat artwork
Give it an image, a canvas, or SVG markup and it puts iridescence on it that answers a light you move.
import { createSchiller } from 'schiller';
const mica = await createSchiller(canvas, {
source: svgString, // URL, SVG markup, <img>, canvas or ImageBitmap
finish: 'foil',
grain: 'contour', // contour | luminance | angle | particle
});
canvas.addEventListener('pointermove', (e) => mica.pointerMove(e));
canvas.addEventListener('pointerleave', () => mica.pointerLeave());
mica.dispose(); // on unmount — idempotentcreateSchiller is async because it rasterises the source and runs a distance transform before the first frame — roughly 350 ms at fieldSize: 1024. Cache the instance; do not create one per render.
Options
Read from the library’s own DEFAULTS, so these figures are the ones your copy actually uses.
| Option | Default | |
|---|---|---|
source | required | URL, SVG markup, <img>, <canvas> or ImageBitmap. Required. |
fieldSize | 1024 | Resolution the source is rasterised and distance-transformed at. The cost is a distance transform, so this is the dial that decides first-paint time. |
grain | 'contour' | How the structure is derived from the artwork. |
grainSmooth | 0 | Rounds the creases out of the distance field, in field texels. 0 is off. For artwork with straight edges and square corners — see the note below. |
liquid | 0 | A slow warp of the artwork itself, in uv. 0 is off. Past about 0.02 a shape stops being itself. |
liquidScale | 1.4 | Wave cycles across the artwork: low is a whole-subject roll, high a ripple. |
push | 0 | How far the pointer shoves the artwork aside, in uv. 0 is off. Samples near the pointer are taken from nearer it still, so the artwork spreads outward and opens a clearing; it eases in and relaxes out with the light. |
pushRadius | 0.16 | How wide that disturbance is, in uv. |
pushCell | 0 | Dot pitch in uv, for artwork that is a grid of discrete elements. Quantises the push to the grid so each dot translates whole rather than stretching. 0 keeps the continuous behaviour. |
lightHeight | 0.34 | How far the moving light stands off the artwork. Raise it to soften: it broadens the highlight and lowers its peak without desaturating anything. |
follow | 0.18 | How hard the light chases the pointer, per frame. Lower is gentler; it is a rate, so it never overshoots. |
angle | 0.7853981633974483 | Direction, for grain: "angle". Radians. |
finish | 'wing' | Which material. See the FINISHES table below. |
ambient | 0.3 | A fixed key light, on top of the one you move. Raise it on a wide subject: a point light on something five times wider than it is tall lights about a third of it. |
bloomThreshold | 1 | Luminance above which a pixel blooms. |
maxPixels | 4600000 | Cost ceiling. The device pixel ratio is clamped so width × height never exceeds it. |
idle | { amount: 0.34, period: 14 } | The drift when nobody is interacting. amount is a floor on the light’s weight at rest, not a multiplier. |
respectReducedMotion | true | Freeze the drift and the wake for those who ask for it. |
Straight edges need grainSmooth
A distance field is not smooth. It creases wherever the nearest edge changes — along what is called the medial axis — and 'contour' grain takes its direction from that field’s gradient, so the direction flips across the crease rather than turning.
On a curve the crease is short and reads as a highlight. On a straight stroke meeting a square corner — lettering, a logo, a UI shape — it runs the whole length with 45° spurs into each corner, and shows up as a hard chevron cutting the shape in half. It looks like a rendering fault because it is one.
Blurring the field is very nearly the exact inverse: away from the axis the field is linear, and blurring a linear ramp returns the same ramp — so the outline band, which is the whole point of contour grain, is untouched, while the crease becomes a rounded ridge the gradient can turn through. 8–12 at fieldSize: 2048 clears it.
The handle
- setFinish(name)
- Wholesale swap. Dials the preset omits return to their defaults.
- setMaterial(patch)
- Change only the dials you name.
- setGrain(mode, rad)
- How the surface texture is derived from the artwork.
- setLight(u, v)
- uv in 0–1, v up. Moves the light’s position only.
- setEngagement(v)
- 0 rests on the idle path, 1 is fully driven by your input.
- pause() / resume()
- Stop the loop.
framesis the honest way to assert it paused. - dispose()
- Idempotent. Browsers cap WebGL contexts near sixteen.
On a Three.js material
patchMica extends a material in place through onBeforeCompile, injecting at dithering_fragment so it layers on top of whatever lighting the material already does. clearcoat, transmission, anisotropy and every map keep working.
import * as THREE from 'three';
import { patchMica } from 'schiller/three';
const material = new THREE.MeshPhysicalMaterial({
color: 0x1b1a18, roughness: 0.2, metalness: 0,
});
const mica = patchMica(material, { finish: 'nacreous' });
// each frame — light direction in VIEW space, z toward the camera
mica.setLight(lx, ly, 0.9);Needs vNormal and vViewPosition, so a lit material — not MeshBasicMaterial. An environment map is effectively mandatory: interference is added to what the surface already returns, and a surface returning nothing has nothing to interfere with. It overwrites onBeforeCompile rather than chaining, so it will clobber another patch.
Because it reads the real shading normal and the real view vector, hue moves when the object turns — not only when the light does. That is the difference between goniochromism and a noise texture.
Six dials, all off by default
- retro
- A lobe peaking when the view meets the light rather than the half-vector — the definition of a retroreflector, and the opposite of a mirror. Three has no model for it.
- occlude
- Signed. Positive puts sheen on the bright ground; negative puts it in dark crevices, where tarnish collects because a cloth cannot reach.
- through
- The sheen takes its hue from the surface it returns through.
- wash
- The diffuse half of a film. Without it a film appears only where the highlight falls and the rest of the object stays grey.
- grain
- A Ward anisotropic lobe, so a highlight can be a streak rather than a spot. Also gives a grating its groove direction, which it otherwise lacks.
grainAnglerotates it. - continuous
- One unbroken film instead of a bed of platelets. Changes the compiled program.
FINISHES
11 presets for createSchiller. filmBase and filmVar are fractions of a colour cycle; grainFreq is cycles across the surface.
aniso reads backwards. 0 is not isotropic — it is maximally anisotropic, an 8:1 specular lobe stretched along the grain direction. 1 is the round one. It is public API, so it stays as it is.
This matters beyond the naming, because the lobe is oriented by the grain field’s gradient — and on 'contour' that gradient flips across the medial axis. So at low aniso your artwork’s outline shapes the highlight even with grainDepth: 0. If a shape shows a bowtie down the middle of every straight part, that is what you are looking at: raise aniso toward 1, or switch to grain: 'angle', which gives the lobe one fixed direction that cannot flip.
| Finish | Optic | Sheen | Grain | Film | Var | Relief | Aniso |
|---|---|---|---|---|---|---|---|
wing | film | 0.85 | 130 | 0.34 | 0.09 | 5 | 0 |
dichroic | film | 0.8 | 90 | 0.28 | 0.14 | 9 | 0.55 |
nacre | film | 0.8 | 120 | 0.34 | 0.06 | 3 | 0.25 |
brushed | film | 1 | 420 | 0.62 | 0.02 | 2 | 0 |
foil | grating | 1.5 | 210 | 0.4 | 0.05 | 4 | 0.1 |
disc | grating ×2 | 1.7 | 150 | 0.5 | 0.02 | 1.5 | 0 |
micaDust | film | 1.1 | 200 | 0.36 | 0.04 | 3 | 0.3 |
glitter | film | 1.6 | 200 | 0.4 | 0.04 | 2 | 0.2 |
pearl | film | 0.9 | 200 | 0.48 | 0.03 | 3.5 | 0.45 |
holoFlake | grating | 1.15 | 200 | 0.42 | 0.03 | 2 | 0.15 |
carapace | film | 0.8 | 90 | 0.28 | 0.14 | 9 | 0.55 |
THREE_FINISHES
A different table with different units. flakeScale here is cells per object-space unit, not cells across the surface, and optic is a number rather than a string. Do not copy values between the two tables.
| Finish | Optic | Structure | Sheen | Flake | Film | Fresnel | Modifiers |
|---|---|---|---|---|---|---|---|
carapace | film | platelet | 1.3 | 18 | 0.3 | 1 | — |
disc | grating | continuous | 3.4 | 8 | 0.38 | 0.55 | through 0.06wash 0.05grain 0.55continuous |
holoGlass | grating ×2 | continuous | 1.3 | 3 | 0.4 | 1.6 | through 0.1wash 0.3grain 0.8continuous |
brushed | film | continuous | 0.2 | 6 | 0.52 | 0.85 | through 0.3wash 0.06grain 0.78continuous |
machined | film | continuous | 0.16 | 6 | 0.33 | 0.9 | through 0.35wash 0.08grain 1continuous |
dichroicSheet | film | continuous | 1.05 | 5 | 0.46 | 2.1 | wash 0.05continuous |
dichroicCrystal | film | continuous | 0.09 | 5 | 0.335 | 0.55 | wash 0.03continuous |
nacre | film | continuous | 0.3 | 11 | 0.375 | 1.05 | through 0.15wash 0.16continuous |
nacreous | film | continuous | 0.1 | 11 | 0.29 | 1.15 | through 0.1wash 0.6continuous |
pearlGlaze | film | continuous | 0.03 | 7 | 0.355 | 0.45 | through 0.75continuous |
dichroic | film | continuous | 0.22 | 6 | 0.315 | 1.35 | through 0.05wash 0.04continuous |
micaDust | film | platelet | 0.26 | 340 | 0.36 | 0.28 | through 0.2wash 0.06 |
glitter | film | platelet | 1.05 | 130 | 0.4 | 0.3 | through 0.1wash 0.05 |
pearl | film | continuous | 1.25 | 8 | 0.48 | 0.35 | through 0.15wash 0.1grain 0.72continuous |
foil | grating | platelet | 1.55 | 160 | 0.4 | 1.1 | through 0.1wash 0.06 |
holoFlake | grating ×2 | platelet | 2.3 | 95 | 0.46 | 0.9 | through 0.16wash 0.08 |
frost | film | platelet | 0.44 | 150 | 0.365 | 0.8 | through 0.3 |
pressed | film | platelet | 0.09 | 120 | 0.335 | 0.25 | through 0.72 |
tarnish | film | continuous | 0.3 | 2.2 | 0.345 | 0.18 | occlude -0.95continuous |
retro | grating | platelet | 0.04 | 130 | 0.44 | 0.12 | retro 1.05occlude 1through 1 |
Sheen spans roughly fifty to one across this table. It is the dial that decides what an object is, and it moves further than any other.
What it needs
WebGL2 with EXT_color_buffer_float — Chrome, Edge, Firefox, Safari 17+. createSchiller throws FloatUnsupportedError when the extension is missing, so you can fall back rather than render nothing.
The package ships as plain ES modules with no build step. That keeps the GLSL readable and tree-shakes cleanly, and there is no dist to drift out of step with the source — but it does assume a bundler that handles modern syntax.
SVG sources are passed as text, not as an image element, so they rasterise at field resolution and stay sharp instead of being upscaled from whatever size the file declared.