schiller/Documentation

Structural colour for the web, and how to put it on your artwork

Dwija PatelReference for the 2D and 3D entry points

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.

  • wing Argope rosea — From an uploaded imagewants contour

    Organic subjects and soft transitions — anything that should read as grown rather than manufactured. The subtlest in the set. See it ↗

  • dichroic Mirrored goggles — Interference filter on polycarbonatewants contour

    Strong saturated colour that survives being looked at off-angle: coated glass, lenses, premium packaging. See it ↗

  • nacre Pearl button — Cut blank, four holeswants luminance

    Quiet and expensive, close to white. Cosmetics, stationery, jewellery — where a rainbow would be vulgar. See it ↗

  • brushed Key — Brushed brasswants angle

    Metal with a machining direction: panels, hardware, anything milled or drawn. Wants grain: "angle". See it ↗

  • foil Sweet wrapper — Metallised film, flow-wrappedwants contour

    A fast rainbow on a cheap-and-cheerful surface — confectionery, gift wrap, party packaging. See it ↗

  • disc Vinyl record — Pressed vinyl, spiral cutwants contour

    The loudest spectrum available, second order. Grooved or spiral subjects, and anything that should read as a CD. See it ↗

  • micaDust Cold can — Frost on lacquered aluminiumwants particle

    A barely-there sparkle. Automotive pearl paint, frost, powder finishes. See it ↗

  • glitter Mirror ball — Silvered glass, on a carcasswants particle

    Actual glitter: sequins, craft stickers, anything with discrete flakes you can pick out individually. See it ↗

  • pearl Strand of pearls — Nacreous, culturedwants particle

    Pearlescent paint and nail polish — smooth and continuous, where glitter is discrete. See it ↗

  • holoFlake Curled ribbon — Metallised film, embossedwants particle

    Holographic vinyl, trading cards, security stickers. A grating, but broken into platelets. See it ↗

  • carapace Mirrored goggles — Interference filter on polycarbonate

    An alias for dichroic, kept because the README named it. Same material. See it ↗

Four imports

ImportExportsWhat it is
schillercreateSchillerStructural colour on 2D artwork
schiller/threepatchMicaThe same optics on a MeshPhysicalMaterial
schiller/driversattachPointer…Pointer, scroll, gyroscope, visibility
schiller/glslshader chunksThe 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 — idempotent

createSchiller 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.

OptionDefault
sourcerequiredURL, SVG markup, <img>, <canvas> or ImageBitmap. Required.
fieldSize1024Resolution 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.
grainSmooth0Rounds 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.
liquid0A slow warp of the artwork itself, in uv. 0 is off. Past about 0.02 a shape stops being itself.
liquidScale1.4Wave cycles across the artwork: low is a whole-subject roll, high a ripple.
push0How 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.
pushRadius0.16How wide that disturbance is, in uv.
pushCell0Dot 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.
lightHeight0.34How far the moving light stands off the artwork. Raise it to soften: it broadens the highlight and lowers its peak without desaturating anything.
follow0.18How hard the light chases the pointer, per frame. Lower is gentler; it is a rate, so it never overshoots.
angle0.7853981633974483Direction, for grain: "angle". Radians.
finish'wing'Which material. See the FINISHES table below.
ambient0.3A 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.
bloomThreshold1Luminance above which a pixel blooms.
maxPixels4600000Cost 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.
respectReducedMotiontrueFreeze 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. frames is 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. grainAngle rotates 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.

FinishOpticSheenGrainFilmVarReliefAniso
wingfilm0.851300.340.0950
dichroicfilm0.8900.280.1490.55
nacrefilm0.81200.340.0630.25
brushedfilm14200.620.0220
foilgrating1.52100.40.0540.1
discgrating ×21.71500.50.021.50
micaDustfilm1.12000.360.0430.3
glitterfilm1.62000.40.0420.2
pearlfilm0.92000.480.033.50.45
holoFlakegrating1.152000.420.0320.15
carapacefilm0.8900.280.1490.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.

FinishOpticStructureSheenFlakeFilmFresnelModifiers
carapacefilmplatelet1.3180.31—
discgratingcontinuous3.480.380.55through 0.06wash 0.05grain 0.55continuous
holoGlassgrating ×2continuous1.330.41.6through 0.1wash 0.3grain 0.8continuous
brushedfilmcontinuous0.260.520.85through 0.3wash 0.06grain 0.78continuous
machinedfilmcontinuous0.1660.330.9through 0.35wash 0.08grain 1continuous
dichroicSheetfilmcontinuous1.0550.462.1wash 0.05continuous
dichroicCrystalfilmcontinuous0.0950.3350.55wash 0.03continuous
nacrefilmcontinuous0.3110.3751.05through 0.15wash 0.16continuous
nacreousfilmcontinuous0.1110.291.15through 0.1wash 0.6continuous
pearlGlazefilmcontinuous0.0370.3550.45through 0.75continuous
dichroicfilmcontinuous0.2260.3151.35through 0.05wash 0.04continuous
micaDustfilmplatelet0.263400.360.28through 0.2wash 0.06
glitterfilmplatelet1.051300.40.3through 0.1wash 0.05
pearlfilmcontinuous1.2580.480.35through 0.15wash 0.1grain 0.72continuous
foilgratingplatelet1.551600.41.1through 0.1wash 0.06
holoFlakegrating ×2platelet2.3950.460.9through 0.16wash 0.08
frostfilmplatelet0.441500.3650.8through 0.3
pressedfilmplatelet0.091200.3350.25through 0.72
tarnishfilmcontinuous0.32.20.3450.18occlude -0.95continuous
retrogratingplatelet0.041300.440.12retro 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.