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 #include } `; 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 }, }, }); }