toastsim/src/sim/slice.ts
monster ff006e65db M8: crunchy vs smooth, and the jar that separates
Natural peanut butter stratifies while it sits. The jar carries the state:
strat 0..1, set per order (smooth 0.75, crunchy 0.9 — chunks sink). The first
unstirred dip takes the oil slick off the top; the ones after it dig into what
the oil left behind. Verified dip trace, no stirring: 0.875, 0.669, 0.462,
0.256. Swirl the knife in the jar (~2.5 circles of angular sweep; holding still
stirs nothing) and every dip is 0.5. Stirring costs time, and time costs toast
warmth — the mechanic pays the same tax as everything else.

Consistency pushes back through the physics that already exist: an oily load
multiplies the yield down (glides at any angle, goes on thin), a dry one
multiplies it up (fights like cold butter, tears). The shader shows it — slick
is dark and shiny, grout is pale and matte.

The judge reads what LANDED, not what the jar was: every deposit remembers the
oil it landed at, and the criterion scores the mass-weighted mean AND stdev.
The stdev is the design catch, found by verification: a slick strip and a grout
strip average to 0.54 — "just right" — and the first cut scored the lazy play
as if the jar had been stirred. Nobody eats the average:

  never stirred, strips   oil 0.54 ±0.23  "slick here, grout there"  C
  one slick dip, whole    oil 0.87 ±0.00  "a slick"
  stirred first           oil 0.50 ±0.00  "just right"

Crunchy reuses the particle system wholesale — chunks ride the knife, shed per
stroke-distance, get judged with the same Clark–Evans index ("Peanut chunks —
21 bits, evenly strewn (R 1.20)"), and the rind line bank is genericised via a
{bits} placeholder so he complains correctly about either.

Day 6 is crunchy now ("the PROPER kind"), the ticket warns when a jar has been
sitting, and the JAR bar shows how mixed it is. Five new judge lines, including
"You did not stir the jar. The jar knows. I know."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:03:57 +10:00

690 lines
24 KiB
TypeScript

import * as THREE from 'three';
import { Field } from '../core/field';
import { Rng, valueNoise2D } from '../core/rng';
import type { Bread } from './bread';
import type { SpreadDef } from './spreads';
export const FIELD_N = 128;
/** Past this, the surface has stopped being toast and started being charcoal. */
export const CHAR_THRESHOLD = 0.85;
/** Hard cap on rind bits — also the instanced mesh's allocation. */
export const RIND_MAX = 96;
/**
* One slice of bread: the mesh, the four scalar fields the whole game reads and
* writes, and the shader that turns them into something appetising (or not).
*
* Fields are packed into a single RGBA8 texture — one small upload per frame
* instead of four, and the shader gets free bilinear smoothing over the 128x128
* sim grid, which is what makes knife strokes look continuous.
*/
export class Slice {
readonly bread: Bread;
readonly mesh: THREE.Mesh;
readonly material: THREE.ShaderMaterial;
/** 0 = raw, 0.85 = char threshold, 1 = carbon. */
readonly browning: Field;
/** Water that must boil off before browning gets going. */
readonly dryness: Field;
/** Spread thickness. ~0.12 thin, ~0.4 normal, ~0.8 thick. */
readonly spread: Field;
/** Torn/gouged surface. Permanent. */
readonly damage: Field;
/** 1 inside the bread silhouette. Every judged statistic is masked by this. */
readonly mask: Field;
/** Per-run heat bias — why no two slices toast identically. */
readonly heatBias: Float32Array;
/** 1 right out of the toaster, decays to 0. Softens butter. */
warmth = 0;
/** Which spread is currently on the slice (for judging + shading). */
spreadDef: SpreadDef | null = null;
/** Extent of the UV projection, so world hits can be turned back into texels. */
readonly sizeX: number;
readonly sizeZ: number;
readonly halfThickness: number;
/**
* Solid bits sitting on the spread (marmalade rind), in UV space. Discrete
* points rather than a fifth field: the judge scores their *distribution*, and
* nearest-neighbour statistics want points, not a raster.
*/
readonly rind: { u: number; v: number }[] = [];
private rindMesh: THREE.InstancedMesh | null = null;
private rindDirty = false;
/**
* Consistency bookkeeping for spreads that separate: every deposit remembers
* the oil level it landed at, and the judge reads the mass-weighted mean.
* 0.5 is right; 1 is a slick; 0 is grout.
*/
private spreadMassSum = 0;
private oilMassSum = 0;
private oilSqSum = 0;
private tex: THREE.DataTexture;
private texData: Uint8Array;
private dirty = true;
constructor(bread: Bread, rng: Rng) {
this.bread = bread;
this.browning = new Field(FIELD_N);
this.dryness = new Field(FIELD_N);
this.spread = new Field(FIELD_N);
this.damage = new Field(FIELD_N);
this.mask = new Field(FIELD_N);
const shape = breadShape(bread);
buildMask(this.mask, shape, bread);
this.heatBias = valueNoise2D(rng, FIELD_N, FIELD_N, 3);
this.texData = new Uint8Array(FIELD_N * FIELD_N * 4);
this.tex = new THREE.DataTexture(this.texData, FIELD_N, FIELD_N, THREE.RGBAFormat);
this.tex.minFilter = THREE.LinearFilter;
this.tex.magFilter = THREE.LinearFilter;
this.tex.wrapS = THREE.ClampToEdgeWrapping;
this.tex.wrapT = THREE.ClampToEdgeWrapping;
this.tex.needsUpdate = true;
const geo = buildGeometry(shape, bread);
const bb = geo.boundingBox!;
this.sizeX = bb.max.x - bb.min.x;
this.sizeZ = bb.max.z - bb.min.z;
this.halfThickness = bb.max.y;
this.material = buildMaterial(bread, this.tex);
this.mesh = new THREE.Mesh(geo, this.material);
this.mesh.castShadow = true;
this.mesh.receiveShadow = true;
this.sync();
}
/**
* World point -> field UV. The UVs were planar-projected from the shape's own
* XY before the slice was laid flat, which makes shape +y become world -z —
* hence the flip on v.
*/
uvAt(worldPoint: THREE.Vector3, out: THREE.Vector2): THREE.Vector2 {
const p = this.mesh.worldToLocal(worldPoint.clone());
return out.set(p.x / this.sizeX + 0.5, 0.5 - p.z / this.sizeZ);
}
/** Height of the top face in world space (the slice lies flat when spreading). */
get topY(): number {
return this.mesh.position.y + this.halfThickness;
}
recordDeposit(mass: number, oil: number): void {
this.spreadMassSum += mass;
this.oilMassSum += mass * oil;
this.oilSqSum += mass * oil * oil;
}
/** Scraping takes spread off at whatever the current blend is. */
recordRemoval(mass: number): void {
const mean = this.oilMean;
this.spreadMassSum = Math.max(0, this.spreadMassSum - mass);
this.oilMassSum = Math.max(0, this.oilMassSum - mass * mean);
this.oilSqSum = Math.max(0, this.oilSqSum - mass * mean * mean);
}
/** Mass-weighted consistency of everything on the toast. 0.5 until proven otherwise. */
get oilMean(): number {
return this.spreadMassSum < 1 ? 0.5 : this.oilMassSum / this.spreadMassSum;
}
/**
* Mass-weighted spread of consistencies. This is what catches the lazy play:
* a slick strip and a grout strip AVERAGE to "just right" — but nobody wants
* to eat the average, they eat one bite at a time.
*/
get oilStdev(): number {
if (this.spreadMassSum < 1) return 0;
const mean = this.oilMean;
return Math.sqrt(Math.max(0, this.oilSqSum / this.spreadMassSum - mean * mean));
}
/** Drop a bit of rind at (u,v). It rides the slice from then on. */
addRind(u: number, v: number): void {
if (this.rind.length >= RIND_MAX) return;
this.rind.push({ u, v });
this.rindDirty = true;
}
/** Scrape rind off within `radius` (UV units) of (u,v). Returns how many went. */
removeRindNear(u: number, v: number, radius: number): number {
const r2 = radius * radius;
let removed = 0;
for (let i = this.rind.length - 1; i >= 0; i--) {
const p = this.rind[i];
const du = p.u - u;
const dv = p.v - v;
if (du * du + dv * dv <= r2) {
this.rind.splice(i, 1);
removed++;
}
}
if (removed) this.rindDirty = true;
return removed;
}
clearRind(): void {
if (this.rind.length) this.rindDirty = true;
this.rind.length = 0;
}
/**
* Rebuild the rind instances. The instanced mesh is a child of the slice mesh
* and positioned in its local space, so rind rides along when the toast flies,
* lands, and turns on the judge's pedestal.
*/
private syncRind(): void {
if (!this.rindDirty) return;
this.rindDirty = false;
if (!this.rindMesh) {
if (this.rind.length === 0) return;
const def = this.spreadDef?.particles;
const s = def?.size ?? 0.05;
const geo = new THREE.BoxGeometry(s * 1.6, s * 0.55, s * 0.9);
// The palette is authored in sRGB; passing the floats raw would have the
// material read them as linear and render candied orange as pale butter —
// the same trap as the slice shader's albedo.
const col = new THREE.Color().setRGB(
...(def?.color ?? ([0.6, 0.25, 0.05] as [number, number, number])),
THREE.SRGBColorSpace,
);
const mat = new THREE.MeshStandardMaterial({ color: col, roughness: 0.38 });
this.rindMesh = new THREE.InstancedMesh(geo, mat, RIND_MAX);
this.rindMesh.castShadow = true;
this.mesh.add(this.rindMesh);
}
const m = new THREE.Matrix4();
const q = new THREE.Quaternion();
const pos = new THREE.Vector3();
const scl = new THREE.Vector3();
const axis = new THREE.Vector3(0, 1, 0);
for (let i = 0; i < this.rind.length; i++) {
const p = this.rind[i];
pos.set(
(p.u - 0.5) * this.sizeX,
this.halfThickness + 0.008,
(0.5 - p.v) * this.sizeZ,
);
// Deterministic per-index jitter so the pieces read as strewn, not stamped.
const h = Math.sin(i * 127.1) * 43758.5453;
const r = h - Math.floor(h);
q.setFromAxisAngle(axis, r * Math.PI * 2);
scl.setScalar(0.75 + r * 0.6);
m.compose(pos, q, scl);
this.rindMesh.setMatrixAt(i, m);
}
this.rindMesh.count = this.rind.length;
this.rindMesh.instanceMatrix.needsUpdate = true;
}
get uniforms() {
return this.material.uniforms;
}
/** Push the sim fields into the GPU texture. Cheap: 64KB. */
sync(): void {
this.syncRind();
if (!this.dirty) return;
const d = this.texData;
const b = this.browning.data;
const s = this.spread.data;
const g = this.damage.data;
const m = this.mask.data;
for (let i = 0, j = 0; i < b.length; i++, j += 4) {
d[j] = clamp255(b[i] * 255);
d[j + 1] = clamp255(s[i] * 255);
d[j + 2] = clamp255(g[i] * 255);
d[j + 3] = clamp255(m[i] * 255);
}
this.tex.needsUpdate = true;
this.dirty = false;
}
touch(): void {
this.dirty = true;
}
setSpread(def: SpreadDef | null): void {
this.spreadDef = def;
const u = this.material.uniforms;
if (def) {
u.uSpreadColor.value.setRGB(def.color[0], def.color[1], def.color[2]);
u.uSpreadGloss.value = def.gloss;
u.uSpreadOpaqueAt.value = def.opaqueAt;
u.uSpreadBump.value = def.bump;
} else {
u.uSpreadOpaqueAt.value = 1e9;
}
}
setHeatGlow(v: number): void {
this.material.uniforms.uHeatGlow.value = v;
}
setHeatmap(mode: 0 | 1 | 2): void {
this.material.uniforms.uHeatmap.value = mode;
}
/**
* The slice rolls its own lighting, so scene lights don't touch it — which
* means the judge's spotlight would do nothing at all. Swap the shader's own
* rig instead: hard key, near-black ambient.
*/
setPresentation(on: boolean): void {
const u = this.material.uniforms;
if (on) {
u.uLightDir.value.set(0.4, 0.86, 0.52).normalize();
u.uLightColor.value.setRGB(1.35, 1.24, 1.05);
u.uAmbientSky.value.setRGB(0.1, 0.11, 0.15);
u.uAmbientGround.value.setRGB(0.035, 0.03, 0.03);
} else {
u.uLightDir.value.set(0.5, 0.9, 0.42).normalize();
u.uLightColor.value.setRGB(1.0, 0.95, 0.86);
u.uAmbientSky.value.setRGB(0.26, 0.27, 0.31);
u.uAmbientGround.value.setRGB(0.14, 0.11, 0.09);
}
}
dispose(): void {
if (this.rindMesh) {
this.rindMesh.geometry.dispose();
(this.rindMesh.material as THREE.Material).dispose();
}
this.mesh.geometry.dispose();
this.material.dispose();
this.tex.dispose();
}
}
function clamp255(v: number): number {
return v < 0 ? 0 : v > 255 ? 255 : v | 0;
}
/** The classic sandwich-loaf silhouette: square-ish body, domed top. */
function breadShape(bread: Bread): THREE.Shape {
const hw = bread.width / 2;
const hh = bread.height / 2;
const dome = bread.domeH;
const r = 0.09;
const bodyTop = hh - dome;
const s = new THREE.Shape();
s.moveTo(-hw + r, -hh);
s.lineTo(hw - r, -hh);
s.quadraticCurveTo(hw, -hh, hw, -hh + r);
s.lineTo(hw, bodyTop);
s.bezierCurveTo(hw, bodyTop + dome * 0.86, hw * 0.62, hh, 0, hh);
s.bezierCurveTo(-hw * 0.62, hh, -hw, bodyTop + dome * 0.86, -hw, bodyTop);
s.lineTo(-hw, -hh + r);
s.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
s.closePath();
return s;
}
/**
* Extrude the silhouette, then planar-project UVs from the shape's own XY before
* we lay the slice flat — so the field grid lines up with the bread exactly, and
* both faces share it.
*/
function buildGeometry(shape: THREE.Shape, bread: Bread): THREE.BufferGeometry {
const bevel = Math.min(0.03, bread.thickness * 0.28);
const geo = new THREE.ExtrudeGeometry(shape, {
depth: bread.thickness - bevel * 2,
bevelEnabled: true,
bevelThickness: bevel,
bevelSize: bevel,
bevelSegments: 3,
curveSegments: 32,
});
geo.computeBoundingBox();
const bb = geo.boundingBox!;
const w = bb.max.x - bb.min.x;
const h = bb.max.y - bb.min.y;
const pos = geo.attributes.position;
const uv = new Float32Array(pos.count * 2);
for (let i = 0; i < pos.count; i++) {
uv[i * 2] = (pos.getX(i) - bb.min.x) / w;
uv[i * 2 + 1] = (pos.getY(i) - bb.min.y) / h;
}
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
// Lay it flat: the far extrude cap becomes the top face (+Y).
geo.rotateX(-Math.PI / 2);
geo.center();
geo.computeVertexNormals();
geo.computeBoundingBox();
return geo;
}
/** Rasterise the silhouette into the mask field (point-in-polygon per texel). */
function buildMask(mask: Field, shape: THREE.Shape, bread: Bread): void {
const pts = shape.getPoints(96);
const hw = bread.width / 2;
const hh = bread.height / 2;
// Same normalisation the geometry uses (shape bbox ≈ the slice bbox).
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y);
maxY = Math.max(maxY, p.y);
}
void hw;
void hh;
const n = mask.n;
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const px = minX + ((x + 0.5) / n) * (maxX - minX);
const py = minY + ((y + 0.5) / n) * (maxY - minY);
mask.data[y * n + x] = pointInPoly(px, py, pts) ? 1 : 0;
}
}
// Pull the mask in by ~1 texel so brushes can't paint into the crust seam.
erode(mask);
}
function pointInPoly(x: number, y: number, pts: THREE.Vector2[]): boolean {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const xi = pts[i].x;
const yi = pts[i].y;
const xj = pts[j].x;
const yj = pts[j].y;
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
function erode(mask: Field): void {
const n = mask.n;
const src = mask.data.slice();
// Anything off the grid counts as outside, so texels on the very border erode
// away too — clamping the lookups instead would let them survive.
const at = (x: number, y: number) => (x < 0 || y < 0 || x >= n || y >= n ? 0 : src[y * n + x]);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (src[y * n + x] < 0.5) continue;
if (at(x - 1, y) < 0.5 || at(x + 1, y) < 0.5 || at(x, y - 1) < 0.5 || at(x, y + 1) < 0.5) {
mask.data[y * n + x] = 0;
}
}
}
}
const VERT = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormalW;
varying vec3 vPosW;
varying vec3 vPosL;
void main() {
vUv = uv;
vPosL = position;
vNormalW = normalize(mat3(modelMatrix) * normal);
vec4 wp = modelMatrix * vec4(position, 1.0);
vPosW = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uField; // R browning, G spread, B damage, A mask
uniform float uFieldN;
uniform vec3 uCrumb;
uniform vec3 uCrust;
uniform float uCrumbScale;
uniform float uCrumbContrast;
uniform float uInclusions;
uniform vec3 uInclusionColor;
uniform vec3 uSpreadColor;
uniform float uSpreadGloss;
uniform float uSpreadOpaqueAt; // spread thickness at which it fully hides the toast
uniform float uSpreadBump;
uniform float uOil;
uniform vec3 uLightDir;
uniform vec3 uLightColor;
uniform vec3 uAmbientSky;
uniform vec3 uAmbientGround;
uniform float uHeatGlow;
uniform float uWarmth;
uniform int uHeatmap;
varying vec2 vUv;
varying vec3 vNormalW;
varying vec3 vPosW;
varying vec3 vPosL;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * vnoise(p);
p *= 2.03;
a *= 0.5;
}
return v;
}
vec3 brownRamp(float b) {
vec3 c0 = vec3(0.95, 0.91, 0.79);
vec3 c1 = vec3(0.91, 0.77, 0.48);
vec3 c2 = vec3(0.79, 0.54, 0.24);
vec3 c3 = vec3(0.50, 0.28, 0.11);
vec3 c4 = vec3(0.19, 0.11, 0.06);
vec3 c5 = vec3(0.05, 0.04, 0.04);
if (b < 0.20) return mix(c0, c1, b / 0.20);
if (b < 0.45) return mix(c1, c2, (b - 0.20) / 0.25);
if (b < 0.70) return mix(c2, c3, (b - 0.45) / 0.25);
if (b < 0.88) return mix(c3, c4, (b - 0.70) / 0.18);
return mix(c4, c5, clamp((b - 0.88) / 0.12, 0.0, 1.0));
}
vec3 heatRamp(float t) {
vec3 a = vec3(0.10, 0.05, 0.35);
vec3 b = vec3(0.15, 0.55, 0.75);
vec3 c = vec3(0.95, 0.85, 0.25);
vec3 d = vec3(0.85, 0.15, 0.10);
if (t < 0.33) return mix(a, b, t / 0.33);
if (t < 0.66) return mix(b, c, (t - 0.33) / 0.33);
return mix(c, d, clamp((t - 0.66) / 0.34, 0.0, 1.0));
}
void main() {
vec4 f = texture2D(uField, vUv);
float browning = f.r;
float spread = f.g;
float damage = f.b;
vec3 N = normalize(vNormalW);
// 1 on the two flat faces, 0 around the crust edge.
float faceness = smoothstep(0.45, 0.85, abs(N.y));
// Spread only ever lands on the top.
float topFace = smoothstep(0.45, 0.85, N.y);
// --- crumb ---
float crumbN = fbm(vUv * uCrumbScale);
vec3 crumb = uCrumb * (1.0 + (crumbN - 0.5) * uCrumbContrast * 2.0);
// holes in the crumb
float holes = smoothstep(0.62, 0.78, fbm(vUv * uCrumbScale * 0.55 + 11.0));
crumb *= 1.0 - holes * 0.35;
// seeds / raisins
float inc = smoothstep(0.80, 0.92, vnoise(vUv * uCrumbScale * 0.9 + 37.0));
crumb = mix(crumb, uInclusionColor, inc * uInclusions);
// --- browning ---
vec3 toastFace = brownRamp(browning) * (0.86 + crumbN * 0.28);
// inclusions scorch first and stay dark
toastFace = mix(toastFace, toastFace * 0.45, inc * uInclusions * smoothstep(0.15, 0.6, browning));
toastFace = mix(crumb, toastFace, smoothstep(0.0, 0.06, browning));
// char blotches, not a clean gradient
float charN = fbm(vUv * 46.0 + 5.0);
float char = smoothstep(0.82, 1.0, browning + (charN - 0.5) * 0.22);
toastFace = mix(toastFace, vec3(0.035, 0.03, 0.03), char * 0.92);
// crust: always browner than the face, and it browns too
vec3 crust = uCrust * (0.75 + fbm(vUv * 26.0 + 3.0) * 0.5);
crust = mix(crust, crust * 0.35, smoothstep(0.4, 1.0, browning));
vec3 albedo = mix(crust, toastFace, faceness);
// --- damage: torn/gouged surface exposes pale raw crumb in a dark crevice ---
float dmgN = fbm(vUv * 60.0 + 21.0);
float dmg = clamp(damage * (0.7 + dmgN * 0.6), 0.0, 1.0);
albedo = mix(albedo, uCrumb * 0.88, dmg * 0.8);
albedo *= 1.0 - dmg * 0.28; // crevice shading
// --- spread ---
float sTex = 1.0 / uFieldN;
float sL = texture2D(uField, vUv - vec2(sTex, 0.0)).g;
float sR = texture2D(uField, vUv + vec2(sTex, 0.0)).g;
float sD = texture2D(uField, vUv - vec2(0.0, sTex)).g;
float sU = texture2D(uField, vUv + vec2(0.0, sTex)).g;
// Wetting. This is what actually makes a spread readable: long before a film is
// thick enough to hide the toast, it soaks in and saturates it, the way oil
// darkens paper. Without this, yellow butter on pale crumb is invisible.
// Must be a gamma-style darkening — a multiply would *brighten* pale crumb.
float wet = clamp(spread / max(uSpreadOpaqueAt * 0.35, 0.0001), 0.0, 1.0) * topFace;
albedo = mix(albedo, pow(albedo, vec3(1.65)) * 0.88, wet * 0.8);
float cover = clamp(spread / max(uSpreadOpaqueAt, 0.0001), 0.0, 1.0);
cover *= topFace;
// a little grain in the film so thin spread doesn't look like flat paint
float filmN = fbm(vUv * 70.0 + 13.0);
vec3 spreadCol = uSpreadColor * (0.85 + filmN * 0.3);
// Consistency tint: an oily slick is darker and saturated; dried-out paste
// goes pale and grey. uOil sits at 0.5 for anything that doesn't separate.
float oilT = (uOil - 0.5) * 2.0;
spreadCol *= 1.0 - max(oilT, 0.0) * 0.28;
spreadCol = mix(spreadCol, vec3(dot(spreadCol, vec3(0.333))) * 1.45, max(-oilT, 0.0) * 0.45);
albedo = mix(albedo, spreadCol, cover);
// Thickness relief. Central differences in *UV* units, not texels — a smooth
// smear only changes ~0.01 per texel, so the raw difference is far too small
// to bend a normal with.
vec2 grad = vec2(sR - sL, sU - sD) / (2.0 * sTex);
N = normalize(N + vec3(-grad.x, 0.0, grad.y) * uSpreadBump * topFace);
// torn bread is rough
N = normalize(N + vec3(dmgN - 0.5, 0.0, fbm(vUv * 60.0 + 44.0) - 0.5) * dmg * 0.9);
// Every colour above — the ramp, the crumb, the crust, the spread — is authored
// the way a human picks colours: as sRGB. The lighting below is linear. Without
// this line those values are read as if they were already linear, which lifts
// everything: near-black MITEY renders as tan (linear 0.14 encodes back out to
// sRGB 0.4) and saturated butter washes to pale cream. Mixing happens in sRGB
// on purpose — that's the space the palette was chosen in.
albedo = pow(albedo, vec3(2.2));
// --- lighting ---
vec3 L = normalize(uLightDir);
vec3 V = normalize(cameraPosition - vPosW);
vec3 H = normalize(L + V);
float ndl = max(dot(N, L), 0.0);
float wrap = (ndl + 0.35) / 1.35; // soft claymation falloff
vec3 ambient = mix(uAmbientGround, uAmbientSky, N.y * 0.5 + 0.5);
// Gloss: spread is shiny, bread is not; melting butter is shinier still.
// A tight lobe puts one hotspot somewhere off the slice and reads as nothing,
// so pair a broad sheen with a fresnel rim — that's the cue that says "wet".
float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
gloss *= 1.0 + uWarmth * 0.6;
// Oil is what shines. Dry paste barely does.
gloss *= 0.55 + uOil * 0.9;
// The lobe has to be TIGHT. The slice is flat and both the light and the camera
// are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe
// blankets it in white and lifts near-black MITEY to tan. Tight, and the only
// thing that catches is a ridge tilted into the light, which is the actual look
// of a spread: dark film, bright knife marks.
float shin = mix(40.0, 170.0, uSpreadGloss);
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.9;
float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
spec += fres * gloss * 0.22;
spec *= 1.0 - char * 0.7;
spec *= 1.0 - dmg * 0.6;
vec3 color = albedo * (ambient + uLightColor * wrap * 0.68) + uLightColor * spec;
// element glow while it's down in the toaster
if (uHeatGlow > 0.001) {
float coil = 0.5 + 0.5 * sin(vPosL.x * 34.0);
color += vec3(1.0, 0.28, 0.06) * uHeatGlow * (0.16 + coil * 0.16) * faceness;
}
// a hot slice keeps a faint warmth in the crumb
color += vec3(0.05, 0.015, 0.0) * uWarmth * faceness;
// Judge-screen data views: flat, unlit, deliberately not appetising.
if (uHeatmap == 1) {
color = mix(vec3(0.10), heatRamp(clamp(browning, 0.0, 1.0)), faceness * 0.88 + 0.12);
} else if (uHeatmap == 2) {
color = mix(vec3(0.10), heatRamp(clamp(spread * 2.0, 0.0, 1.0)), topFace * 0.88 + 0.12);
}
gl_FragColor = vec4(color, 1.0);
// Match the tone mapping + output colour space the standard materials get,
// otherwise the toast reads as a different render from everything around it.
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
function buildMaterial(bread: Bread, tex: THREE.DataTexture): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms: {
uField: { value: tex },
uFieldN: { value: FIELD_N },
uCrumb: { value: new THREE.Color(...bread.crumb) },
uCrust: { value: new THREE.Color(...bread.crust) },
uCrumbScale: { value: bread.crumbScale },
uCrumbContrast: { value: bread.crumbContrast },
uInclusions: { value: bread.inclusions },
uInclusionColor: { value: new THREE.Color(...bread.inclusionColor) },
uSpreadColor: { value: new THREE.Color(1, 0.85, 0.3) },
uSpreadGloss: { value: 0.5 },
uSpreadOpaqueAt: { value: 1e9 },
uSpreadBump: { value: 0.1 },
uOil: { value: 0.5 },
uLightDir: { value: new THREE.Vector3(0.45, 0.85, 0.35).normalize() },
uLightColor: { value: new THREE.Color(1.0, 0.95, 0.86) },
uAmbientSky: { value: new THREE.Color(0.26, 0.27, 0.31) },
uAmbientGround: { value: new THREE.Color(0.14, 0.11, 0.09) },
uHeatGlow: { value: 0 },
uWarmth: { value: 0 },
uHeatmap: { value: 0 },
},
});
}