Repository navigation
Shader Contract
Deep dive. The narrative companion to the shader. When docs/SHADER-CONTRACT.md lands with the extraction it becomes the canonical specification; this page stays as the explanation of why the contract has the shape it does. Where the two disagree, the document wins — and the disagreement is a bug.
The renderer's stability claim is simple: a foil pattern is a pure GLSL function with a fixed signature, and everything else is the shared program's job. A recipe knows nothing about three.js, nothing about React, nothing about the application it is embedded in. That is what makes 45 patterns maintainable and what makes the library extractable at all.
A material is built by concatenating three strings:
PREAMBLE + pattern.glsl + MAIN
-
PREAMBLEdeclares the precision, the constants, every uniform, and the helper library. Nothing else may declare a uniform. -
pattern.glslis the recipe, verbatim. A template literal in the pattern library; pure GLSL text. -
MAINis the singlevoid main(). It samples the card scan, builds the mask, the art gate and the ink estimates, calls the recipe, and composites.
There is exactly one main() in the program and no recipe supplies it. The
vertex shader passes card UV and is not extensible.
At build time the material seeds its uniforms from the recipe's defaults, then
from each declared parameter's default.
vec3 foilPattern(vec2 uv, vec2 tilt)uv is card UV, 0..1, y up. tilt is the current card tilt, -1..1 per
axis. The return value is the foil light layer in linear-ish RGB — later
masked, gained, and composited by the shared main().
Honest output range is 0 .. ~1.5, but only the metalness path preserves
anything above 1.0; the additive path clamps to 1.0.
A surface may opt into tangent-space view direction while recipes retain the same vec2 tilt entry point. The vector runs surface-to-camera with x/y on the transformed UV/card axes and +z on the inverse-transpose front normal, including reflections. Hierarchy-induced shear uses a documented Gram-Schmidt frame; singular/non-finite frames use +z. Direct uniform writers must provide a finite normalized vector because helper sanitization does not cover their writes. The Three implementation is a planar-center approximation, not per-fragment perspective parallax or a physical-fidelity claim; the default assembly is unchanged. See the repository's canonical docs/SHADER-CONTRACT.md for the precise API and bounds. Conceptual reference: Cyanilux's stylized holofoil breakdown.
| Uniform | Type | Meaning | Notes |
|---|---|---|---|
uFace |
sampler2D |
The card scan | Uploaded with no colour-space conversion — the whole blend model is authored in display space |
uTilt |
vec2 |
Card tilt, −1..1 per axis | The primary animator. Driven by pointer, gyroscope or manual control, then eased |
uTime |
float |
Seconds since mount | Ambient drift only. Never animate a foil from time alone |
uIntensity |
float |
Overall foil gain | Applied by main(). Recipes must not self-apply it. 0 renders the plain scan — a hard contract |
uScale |
float |
Global pattern scale | Multiply your spatial frequencies by it |
uHueShift |
float |
Base hue offset around the rainbow ramp | |
uHueSpread |
float |
Hue variation width across pattern and tilt | |
uSat |
float |
Rainbow saturation |
0 = silver, 1 = full spectrum. Applied inside the shared hue ramp |
uArtGate |
float |
Luminance gate: let foil show in dark scan areas | The licensed exception to "text is sacred" — on a WOTC holo window, dark means foil, not ink |
uSpecular |
float |
Shared white sheen-band gain | Applied by main()
|
uTint |
float |
How much the flash carries the printed ink's own colour | The double-ink-pass model: flash × (normalized chroma)²
|
uInkGuard |
float |
Scan-composite engagement | A switch, not a dial. Leave at 1 |
uInkPop |
float |
Metallic chroma pop under the flash | Luminance-neutral by construction |
uMetal |
float |
The law selector |
0 = additive law. >0 = metalness law, and that is mirror-only |
uSheen |
float |
Pattern-light gain in both laws |
1 = the authored blank-canon strength |
uSheenTint |
float |
How far the flash takes the ink's colour rather than the foil's own | 0 on every non-mirror family |
uDepth |
float |
Substrate darkness on a real scan | The trap — see below |
uGrain |
float |
Metalness only: how much pattern structure perturbs the surface | |
uDarken |
float |
Blank-base mirror-substrate attenuation | Inert on real scans |
uScanBase |
float |
Surface-owned mode switch: 1 = uFace is a real scan, 0 = a synthetic blank base |
Never a slider. Never stored in a canon file |
Set from the resolver and the era layout data. Patterns never mask
themselves; main() owns the mask entirely.
| Uniform | Type | Meaning |
|---|---|---|
uMaskRect |
vec4 |
Art-window rect x, y, w, h in UV, y up |
uMaskRadius |
float |
Corner radius, as a fraction of card width |
uMaskFeather |
float |
Edge softness |
uMaskInvert |
float |
0 = inside the rect (window, full), 1 = outside (reverse-holo sheet) |
uMaskView |
float |
Debug: tint the masked zone red |
A hand-drawn mask beats the layout tier.
| Uniform | Type | Meaning |
|---|---|---|
uMaskTex |
sampler2D |
The hand mask. Alpha is foil coverage; RGB is display tint only. Falls back to a 1×1 opaque white texture so the sampler is always valid |
uMaskTexOn |
float |
1 = sample the texture instead of the rect |
Exactly one V flip, ever. The shader samples the mask with y inverted and
the source texture is uploaded unflipped. Two flips is the classic failure and it
is silent — a symmetric mask looks fine and an asymmetric one is upside down.
For patterns whose identity is a real emblem rather than a procedural shape. Real artwork drops into a registered slot and is rasterized into an atlas; the uniforms carry the atlas, its count and its grid. When no artwork is present the recipe falls back to its procedural form, bit-for-bit the shipped look.
This exists so that supplying genuine glyph art needs zero code change — and
so that shipping without it is a supported state rather than a broken one. See
NOTICE-CONVENTIONS.md for what an asset has to carry before it may land there.
The second, orthogonal layer of a reverse holo, added by R8-INK (2026-09-07). The FOIL is embossed micro-texture in aluminium — physical, procedural, one of the 43 types. The DESIGN is opaque ink printed over that sheet, blocking it: an artist's layout from TPCi, which procedural will never converge on because there is nothing to converge to. A dozen visually different reverses share one vertical-sheen sheet.
So it is data, not a recipe: data/ink-designs.json + data/ink-tiles/,
resolved per printing by @foilkit/resolver's resolveInk on
(scope, type, variantKind) + rarity, resolving card > subset > set > era.
It enters main() where the mask tiers already live — as COVERAGE, scoped to
the mask before the mask is reduced by it, because ink printed on a foil sheet
cannot exist where there is no sheet.
| Uniform | Type | Meaning |
|---|---|---|
uInkTex |
sampler2D |
ONE lattice CELL. Alpha is ink coverage; colour is ignored. Falls back to a transparent 1x1 so the sampler is always valid |
uInkOn |
float |
1 = the design tier OWNS this card's design layer, so recipes carrying a procedural stand-in must yield |
uInkDraw |
float |
1 = actually draw the tile. 0 with uInkOn 1 means the IMAGE ALREADY SHOWS the reverse printing |
uInkTile |
vec4 |
x = tiles across the card WIDTH, y/z = lattice phase in cells, w = rotation in turns |
uInkJitter |
float |
Per-cell positional jitter, in cell fractions |
uInkStagger |
float |
Odd-row x offset in cells: 0 = square grid, 0.5 = brick |
uInkStrength |
float |
How completely the ink blocks the foil beneath it: m *= 1 - uInkStrength * inkCoverage(uv) * m
|
uInkTone |
float |
How far the blocked field lifts toward paper white. 0 — the default — leaves the design visible purely as ABSENCE of foil, which is all a scan of the normal printing can honestly support |
A tile and six numbers, not a card-sized raster. Overprints are periodic, so
this scales by construction where a per-card image would not. A tile is one cell
and the shader repeats it, so its edges must meet their own opposites;
tools/build-ink-index.mjs measures every tile's seam on every build and only 0
passes. (pinstripe-diagonal.svg shipped at 66/255 across its own wrap — a grid
line down every card it was used on, in a file whose own desc said "tiles
seamlessly".)
uInkOn 0 is the whole no-op argument. Surface-owned and STRUCTURAL —
never a slider, never in a canon file — defaulting to 0, and every added
instruction sits inside its branch. That is why this was NOT a
composite-contract bump, and it is proven rather than argued: tools/parity
rendered all 45 recipes on the blank base before and after, 45/45
byte-identical, against a control pair that was itself 45/45.
The five states the resolver reports, and the two gates each drives:
state |
uInkOn |
uInkDraw |
What it means |
|---|---|---|---|
none |
0 | 0 | No row keys this printing. Nothing is drawn — an unkeyed series inherits no other era's design |
queued |
0 | 0 | A row matched, but its tile is a trademarked mark we may not trace. The slot is empty ON PURPOSE and the recipe keeps its procedural fallback |
no-ink |
0 | 0 | A row matched and RECORDS that no tile is wanted (Legendary Collection). A decision, not queued work |
design |
1 | 1 | A tile resolved; the recipe stops guessing and the tile is drawn |
in-scan |
1 | 0 | The scan is MEASURED as already carrying the reverse printing. The recipe still stops guessing; we simply do not draw ours on top |
That last row is the point of splitting the gates. Leaving uInkOn at 0 there
would put the ring-and-dot grid back on top of a printing that already has one.
Only a measured reverse in data/frames.json suppresses; unknown draws.
Contract document: docs/INK-DESIGN.md.
Two structural additions, both no-ops until used — proven 45/45 byte-identical
through tools/parity. All are surface-owned: never a slider, never in a
canon file.
| Uniform | Type | Meaning |
|---|---|---|
uTiltMax |
float |
How far the card can rotate, in radians. The three stage overwrites it every frame; the default matches STAGE_DEFAULTS.maxTiltDeg (16°) |
uCamDist |
float |
Camera distance in card widths, also written by the stage each frame |
uEtchTex |
sampler2D |
The etch plate: a raster separate from the foil mask carrying an etched card's printed texture. Linear, 1 = etched; sampled in image space with the hand mask's one-flip convention |
uEtchOn |
float |
1 = a plate is supplied. 0 — the default, every card today — executes nothing |
uEtchTexel |
vec2 |
One texel of the plate, for the slope taps |
uEtchRelief |
float |
Strength of the relief glint: the difference between a bumped and a flat facet under a key light above the camera |
uEtchBump |
float |
Bump height applied to the plate's slope |
main() fills three per-fragment globals before foilPattern() runs, so a
recipe may read them; recipes written before R9 never do and render unchanged:
gViewTS (unit vector from the fragment to the eye in card tangent space —
x right, y up, z out of the face, (0,0,1) head-on), gEtch (the plate value,
0 with no plate) and gEtchSlope (its gradient per texel step).
uP0 … uP5, all float, meaning defined per recipe and surfaced as labelled
sliders. Convention: at most four, each with a real label, range and default;
unused slots labelled as unused. Two more exist for recipes that genuinely
outgrew four.
Deterministic hash noise (hash21, hash22, vnoise, three-octave fnoise),
the saturation-aware cosine hueRamp, screenBlend, a rounded-rect SDF, the
rect mask, the card alpha, the shared sheen, and the glyph-atlas sampler. Plus
PI, TAU and CARD_ASPECT. For an isotropic pattern, multiply uv by
vec2(1.0, CARD_ASPECT).
The order of operations, which is the part most easily got wrong:
- Card alpha, then discard outside it — real cards have rounded corners.
- Sample the scan.
- Mask — the hand tier if present (absolute, never inverted), else the layout rect with the scope's invert applied.
-
Art gate — a luminance gate scaled by
uArtGate. - Ink estimates — an 8-tap, two-ring local contrast measurement at fixed card-space radii. Crucially it is relative: a flat tone yields exactly zero. "Dark pixel means ink" would have been simpler and would have killed every render on a dark base. From it come the glyph-ink term (sacred), the detail-ink term, and the chroma term.
-
The classic composite — always computed, and the only path when
uScanBaseis 0. -
The scan path, one of two mutually exclusive laws selected by
uMetal:- Metalness (mirror only): the pattern perturbs a metal surface around a pivot; where the surface turns away it darkens the body, where it turns toward you it adds highlight.
- Additive (everything else): the pattern is light added over the scan, with the substrate darkening where the pattern is not.
- The shared tail — the light is tinted, then passed through a compressive soft knee against the per-pixel luminance headroom, then screen-blended or added with a per-channel no-clip cap.
(a) The plain print is always recoverable. uIntensity at zero, or the ink
guard off, renders exactly the scan. Both substrate terms are gated on intensity
so that an all-zero pattern field never reads as "the whole card turned away".
(b) Text is sacred at every angle, under both laws. Added light — the
pattern and the shared specular, on one shared budget — is bounded by the
per-pixel luminance headroom, applied as a compressive knee rather than a hard
clamp, plus a per-channel cap that cannot clip by construction. Glyph-dark pixels
are exempt from the flash and from every substrate term. uArtGate is the single
licensed exception.
(c) Printed chroma is preserved. The light rides its own hue, the chroma pop is luminance-neutral, and every substrate darkening is multiplicative rather than subtractive. This invariant is the fix for a long-standing "the reverse holos look dull and grayish" complaint, which turned out to be the shared composite and not any recipe: achromatic light screen-blended over coloured art raises all three channels equally and compresses chroma.
(d) uScanBase 0 runs the classic composite textually unchanged. The entire
scan section lives inside a branch. That is what lets every saved canon file
render bit-identically after a composite change, and it is how a major re-scoping
of the metalness law was proven to have moved no canon's blank appearance at all.
Keyed on duty cycle — how much of the face the recipe's own light covers.
| Family | Duty cycle | uSheen |
uSheenTint |
uDepth |
|---|---|---|---|---|
| flash | under ~20% | 3.4 | 0 | 0 |
| line | ~20–45% | 4.2 | 0 | 0.45 |
| stamp | sheet, sparse stamps | 3.6 | 0 | 0.22 |
| field | over ~45% | 3.0 | 0 | 0.6 |
| pearl | any | 1.2 | 0 | 0.2 |
| metal | mirror only | its own canon | 0.5 | — (metalness law) |
uDepth is the trap. It darkens where the pattern is not, so its cost
scales with 1 − duty. Applying a field family's depth to a flash pattern
darkens almost the whole card.
uSheenTint is zero everywhere except mirror because tinting a highlight with
the ink underneath it was, on inspection, exactly the behaviour that had been
rejected.
There is no contract: version field on a recipe, and no mismatch handler.
That was considered and is not what the code does. Worth stating plainly, because
a reader expecting a version field will look for one and conclude it was
forgotten.
The contract is enforced structurally instead, in four ways:
- The family ↔ defaults pairing is the contract. A recipe declares a composite family, and its defaults must spread that family's constants. That pairing is what a reviewer checks. It is not machine-checked, which is a known gap.
- Unknown keys in data are inert; unknown keys in a recipe are fatal. The runtime applies only uniforms it knows, so a canon file carrying a uniform that was later removed silently does nothing and a canon predating a new uniform inherits the code default. But a recipe declaring a default for a nonexistent uniform is a hard error at material build, and a recipe body that fails to define the entry point is a shader compile error. Strict for code, tolerant for data — deliberately.
- Slug versioning, not contract versioning. Renamed patterns keep an alias from the old id, permanently. Old ids resolve forever; an id is never repurposed. That is the migration discipline that replaces a version number.
- Canon files are full uniform snapshots, not deltas. So changing a recipe's default never reaches a pattern that has been canon'd — you migrate the canon value in place and log it. This is also why splitting the dataset out of the renderer's repository would have let them drift silently.
Real version constants do exist elsewhere and are recorded in every artifact they touch: the resolver version, the mask sidecar version, the training and archive manifest versions. See Provenance Model.
-
The signature exactly.
vec3 foilPattern(vec2 uv, vec2 tilt), uv y-up. - Pure GLSL. No three.js, no React, no JavaScript. The recipe never sees the renderer.
- Deterministic. Only the provided hash noise. No frame counters, no external state, no side channels.
- Tilt is the primary animator. Time is ambient drift; a pattern that animates from time alone is wrong even when it looks right.
-
Respect the output range.
0..1under the additive law,0..1.5under metalness. Do not rely on values above 1 unless you are mirror. -
Do not self-apply the shared terms. Intensity, specular, darkening, the
art gate, the masks and the substrate are all
main()'s job. If your printing needs a zone the layout tier cannot express, that is a mask work item — not a pattern hack. -
Scale with
uScale; feed the shared hue ramp. KeepuSatsemantics: 0 is silver. -
Do not sample
uFace. One documented exception exists, ever, and it isdetective-pikachu, whose identity is beam × photograph. - At most four parameters, each with a real label, range, step and default.
- Match your family's constants, and set the art gate by physics: window and full foils want it, mirror and reverse sheets do not. Start from full saturation and a wide hue spread — full spectral character is the baseline. Only override a family constant after looking at two or more assigned cards.
- Structural locks belong in GLSL; slider-reachable styling belongs in uniforms. A gold pattern gets a private two-stop ramp function rather than a pinned hue-spread uniform.
- Platform constraints. GLSL ES 1.00 under WebGL2: no fragment-stage LOD sampling, so no mip tricks. Use fixed UV taps in resolution-independent card-space units. This runs per fragment, every frame, on a phone, and the ink estimate already costs eight texture taps.
- Shared GLSL is banked-verdict territory. Before touching a shared body, list which patterns' verification verdicts it invalidates and either re-judge them or leave it alone. Cosmetic sharpening of a passing pattern is deliberately not taken.
- Foil Taxonomy — what the recipes are modelling
- Provenance Model — the mask tier that beats the layout tier
- Pre-History — rounds R0–R7, where this contract was argued into shape
Last updated by Claude Opus 5.5 on behalf of @cheyras — 2026-10-18