schiller
v0.1.0
Published
Structural colour for arbitrary artwork. Thin-film and diffraction-grating iridescence on any image, canvas or SVG, with a light you move.
Downloads
146
Maintainers
Readme
schiller
Structural colour for the web. Give it an image, an SVG or a Three.js material, and it puts real iridescence on it — the kind that shifts as the light moves, because it is computed from angles rather than painted on.
npm install schillerZero runtime dependencies. No build step. Plain ES modules.
What "structural colour" means
A red shirt is red because the dye swallows every other colour. Structural colour has no dye in it at all: the surface is built with features about as small as a light wave is long, and that structure sorts white light into colours. Grind it to powder and the colour disappears, because you have destroyed the shape.
You have seen it all your life — a soap bubble, a puddle with oil on it, the back of a CD, a butterfly's wing, the inside of a shell.
The important part, and the reason this is a shader rather than a texture: the answer depends on the angle you are looking from. Tilt a soap bubble and a different colour survives. So the colour is not a property of the object, it is a property of the object and where you are standing. Move, and it changes.
schiller is the mineralogist's word for exactly this — the blue flash inside labradorite, which is not a colour the stone contains but one its layers make.
Quick start
On an image, a canvas or an SVG
import { createSchiller } from 'schiller';
const canvas = document.querySelector('canvas');
const mica = await createSchiller(canvas, {
source: '/butterfly.png', // a URL, an SVG string, an <img>, or a <canvas>
finish: 'wing', // which material it is
grain: 'contour', // which way the microstructure runs
});
// Let the viewer move the light.
canvas.addEventListener('pointermove', (e) => mica.pointerMove(e));
canvas.addEventListener('pointerleave', () => mica.pointerLeave());That is the whole thing. It animates on its own when nobody is touching it, and
it respects prefers-reduced-motion.
Remember to mica.dispose() when the element goes away. Each instance holds
a WebGL context and browsers only allow about sixteen.
On a Three.js material
import { MeshPhysicalMaterial } from 'three';
import { patchMica } from 'schiller/three';
const material = new MeshPhysicalMaterial({ color: 0x1b1d23, metalness: 1, roughness: 0.2 });
const handle = patchMica(material, { finish: 'dichroic' });
// Later, to move the light:
handle.setLight(x, y, z);patchMica takes a material you already made and rewrites its shader in place.
The library never imports Three — Three is an optional peer dependency, and
you only need it for this entry point.
The finishes
Pick one by name. Each is a set of tuned numbers, not a texture.
| 2D — createSchiller | looks like |
| --- | --- |
| wing | a butterfly's wing |
| nacre | mother-of-pearl |
| pearl | a pearl's soft lustre |
| dichroic | coated dichroic glass |
| foil | a sweet wrapper |
| disc | the back of a CD |
| brushed | brushed metal |
| micaDust | pearlescent powder |
| glitter | coarse glitter |
| holoFlake | holographic sticker |
| carapace | a beetle's shell |
schiller/three has twenty, including nacreous, pearlGlaze, frost,
tarnish, machined, holoGlass, dichroicCrystal, dichroicSheet,
pressed and retro (retroreflective, like a number plate).
import { FINISHES } from 'schiller';
import { THREE_FINISHES } from 'schiller/three';Grain — which way the structure runs
Structural colour needs a direction, and that direction is the second half of what makes something look like itself. Same finish, different grain, different material.
| grain | the microstructure follows | good for |
| --- | --- | --- |
| 'contour' | the shape's own outline | wings, shells, wrappers |
| 'luminance' | the artwork's light and shade | anything already shaded |
| 'angle' | one fixed direction (set angle) | brushed metal, machined parts |
| 'particle' | nothing — discrete flakes | glitter, pearlescent powder |
Tuning it
Every finish is a starting point. setMaterial patches individual numbers.
mica.setMaterial({ sheen: 1.2, grainFreq: 180 });The ones worth knowing:
| | what it does |
| --- | --- |
| sheen | how strong the effect is overall |
| optic | 'film' (soap bubble) or 'grating' (CD) |
| grainFreq | how fine the microstructure is |
| grainDepth | how pronounced it is |
| filmBase | which colour sits at the centre of the sweep |
| filmVar | how far the hue travels |
| aniso | how directional the highlight is |
| tint | pulls everything toward one hue |
| bloom | glow around the bright parts |
The one trap worth stating up front. The interference is added to whatever
is underneath. A bright or already-saturated base leaves it nowhere to go, and
the result is a white blur. If a finish looks washed out, darken the artwork
before reaching for sheen.
Drivers
Optional helpers that wire the light to something. All of them return a detach function.
import { attachAll } from 'schiller/drivers';
const detach = attachAll(mica, canvas);| | |
| --- | --- |
| attachPointer | the mouse or a finger |
| attachScroll | scroll position |
| attachTilt | the device gyroscope, with the iOS permission flow |
| attachVisibility | pauses when off-screen — worth having |
| attachAll | all of the above |
Browser support
Needs WebGL2 with EXT_color_buffer_float — Chrome, Edge, Firefox, and
Safari 17+.
createSchiller throws FloatUnsupportedError when it is missing, so you can
fall back to the plain artwork rather than showing nothing:
import { createSchiller, FloatUnsupportedError } from 'schiller';
try {
mica = await createSchiller(canvas, { source });
} catch (err) {
if (err instanceof FloatUnsupportedError) showStaticImage();
else throw err;
}API
createSchiller(canvas, options)
| option | default | |
| --- | --- | --- |
| source | required | URL, SVG string, <img>, <canvas>, ImageBitmap |
| finish | 'wing' | which material |
| grain | 'contour' | which way the structure runs |
| angle | π/4 | direction, for grain: 'angle' |
| ambient | 0.3 | fill light |
| fieldSize | 1024 | resolution of the internal structure field |
| idle | { amount: 0.34, period: 14 } | the drift when nobody is interacting |
| respectReducedMotion | true | freeze the drift for those who ask |
Returns a handle:
mica.setLight(u, v) // move the light; 0..1, v up
mica.setEngagement(0..1) // 0 = resting drift, 1 = fully driven
mica.setFinish(name)
mica.setMaterial(patch)
mica.getMaterial()
mica.setGrain(mode, radians)
mica.setFold(0..1) // fold about the vertical centre, for wings
mica.pointerMove(event)
mica.pointerLeave()
mica.pause() / mica.resume()
mica.dispose() // idempotent; call on unmount
mica.frames // frames rendered, for tests
mica.running
mica.reducedMotionpatchMica(material, options)
Patches a Three material in place. Returns { setLight, setFinish, set, uniforms }.
schiller/glsl
The raw shader chunks, if you are assembling your own pipeline.
See it working
useschiller.com — nineteen specimens, each on the object its finish imitates.
useschiller.com/try — drop in your own image and put any of the finishes on it. Runs entirely in the browser.
A note on where this is developed
The source is published here in full, and it is MIT licensed — use it in anything, commercial included, modify your copy, fork it if you want to.
What there is not is a public repository. This is one person's library rather than a community project, so there is nowhere to file an issue and no pull requests to open. If something is broken, the honest answer is that a fork is your fastest route, and the licence explicitly allows it.
Licence
MIT. See LICENSE.
