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>
690 lines
24 KiB
TypeScript
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 },
|
|
},
|
|
});
|
|
}
|