M0: scaffold — Vite+TS+Three+Rapier, slice sim core, asset pipeline
The bread slice is the whole game's canvas, so it's built first and properly: - Field: a 128x128 scalar field with bilinear sample, a mass-conserving brush, self-levelling relax, and the masked statistics the judge will read. - Slice: extruded loaf silhouette (domed top), planar-projected UVs so the sim grid lines up with the bread, per-texel silhouette mask, and four fields (browning/dryness/spread/damage) packed into one RGBA8 texture per frame. - Custom shader: browning ramp with blotchy char, crumb/crust/inclusions, spread wetting + opacity + thickness relief, gloss, gouge exposure, and flat data views for the judge screen. - bread.ts / spreads.ts: the archetypes and the rheology the difficulty curve will live in. - scripts/gen-assets.sh: reproducible fixed-seed asset generation through MODELBEAST (flux_local -> bg_remove_local -> hunyuan3d_mlx). All on-device. Legibility work that mattered: butter rendered invisible at first. Fixes were a gamma-style wetting darkening (a multiply *brightens* pale crumb), a normal perturbation taken in UV units rather than raw texel deltas (~30x too small), and pulling the lighting back under 1.0 so ACES stops desaturating yellow to cream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
10adb268a0
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "toastsim",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.vite/
|
||||||
144
OPUS-BUILD-BRIEF.md
Normal file
144
OPUS-BUILD-BRIEF.md
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
# TOASTSIM — Build Brief
|
||||||
|
|
||||||
|
**For: Claude Opus 4.8, executing in Claude Code on this Mac (M3 Ultra, cwd `/Users/m3ultra/Documents/toastsim`).**
|
||||||
|
**Approved by the owner. Build the whole thing; commit and push per milestone. Asset generation is free and pre-authorized — see §3.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What we're building
|
||||||
|
|
||||||
|
**TOASTSIM** — a browser game about making toast, where the *inputs are analog* and the *output is judged*. Two connected activities:
|
||||||
|
|
||||||
|
1. **The Toast Round** — toast bread (timing + power), spread it (pressure/angle physics per spread type), scrape burnt bits (risk/reward), get scored by a deadpan judge.
|
||||||
|
2. **The Cutlery Drawer** — a 3D physics minigame: a tangled drawer of *similar-but-not-identical* metal cutlery; fish out the specific requested item against a timer, **while your toast cools** (cold toast = hard butter = harder spread round).
|
||||||
|
|
||||||
|
Tone: dry, characterful, slightly absurd. PowerWash-Simulator-style satisfaction with a scoring screen that makes you go "one more toast."
|
||||||
|
|
||||||
|
**Arcade loop:** a customer order appears ("dark toast, thin vegemite, NO burnt bits") → drawer → toast → spread/scrape → judgment → next order, escalating.
|
||||||
|
|
||||||
|
## 2. Stack & repo
|
||||||
|
|
||||||
|
- **Vite + TypeScript + Three.js** for everything (both scenes 3D — the toast slice is a real GLB you paint on). **@dimforge/rapier3d-compat** for drawer physics. No React needed; DOM overlay for UI is fine. Target 60fps in Chrome.
|
||||||
|
- Repo: the folder is empty. `git init`, build in it, remote is `https://gitea.partly.party/monster/toastsim.git` (exists, empty). Push `main` after each milestone with a clear message.
|
||||||
|
- Create `.claude/launch.json` with the Vite dev server so the app is previewable via the Browser pane. Verify visually with screenshots as you go — don't trust "it compiles."
|
||||||
|
- Generated assets go in `public/assets/` (committed — they're small GLBs/PNGs and reproducibility matters less than a working repo).
|
||||||
|
|
||||||
|
## 3. Asset factory — MODELBEAST (free, local, pre-authorized)
|
||||||
|
|
||||||
|
This Mac runs MODELBEAST, a job server wrapping local ML tools. **You are explicitly allowed to use it. Everything below is $0 (on-device).** Full reference: `~/Documents/MODELBEAST/AGENTS.md` — read it before your first job.
|
||||||
|
|
||||||
|
### Auth & basics
|
||||||
|
```bash
|
||||||
|
cd ~/Documents/MODELBEAST
|
||||||
|
source data/agent.env # sets MB_HOST + MB_TOKEN (guest: local ops only, free by design)
|
||||||
|
./mb ops -v # live operator catalog — trust this over docs
|
||||||
|
```
|
||||||
|
If the server is down (`curl http://localhost:8777/api/health` fails): `~/Documents/MODELBEAST/scripts/serve.sh`.
|
||||||
|
|
||||||
|
Guest tokens **cannot** run paid cloud ops (server-enforced 403) — never try to work around that; the local chain below is the intended path.
|
||||||
|
|
||||||
|
### The two pipelines you'll use
|
||||||
|
|
||||||
|
**Prompt → 2D image** (UI art, judge portraits, backdrops, labels, title screen):
|
||||||
|
```bash
|
||||||
|
./mb run flux_local -p prompt="..." -p model=flux2-klein-4b -p steps=4 -p seed=7 --wait --download out/
|
||||||
|
```
|
||||||
|
Fixed seeds = reproducible. 1024×1024 default; set `width`/`height` for backdrops.
|
||||||
|
|
||||||
|
**Prompt → 3D GLB** (bread, toaster, cutlery, jars, plate):
|
||||||
|
```bash
|
||||||
|
# 1. generate a clean "product shot" image of the object
|
||||||
|
./mb run flux_local -p prompt="<object>, single object centered on plain light grey background, soft studio lighting, 3/4 view, stylized chunky game asset" -p seed=<n> --wait
|
||||||
|
# 2. cut it out (biggest quality lever for image→3D)
|
||||||
|
./mb run bg_remove_local --asset <IMG_ID> --wait
|
||||||
|
# 3. mesh it (GLB + PBR texture, ~4-5 min, no login needed)
|
||||||
|
./mb run hunyuan3d_mlx --asset <CUT_ID> -p octree_resolution=256 -p texture_size=1024 --wait --download public/assets/models/
|
||||||
|
```
|
||||||
|
`blender_convert` exists if you need format conversion. `mflux_image_edit` for prompt-based image fixes.
|
||||||
|
|
||||||
|
### Job etiquette (hard rules)
|
||||||
|
- **GPU jobs run one at a time** (serial lane). Queue asset jobs early, then *keep writing code while they run* — check with `./mb jobs` / `./mb log <ID> -f`. Never spam retries on `queued`.
|
||||||
|
- First run of an operator may download weights (10–30 min). Be patient; don't cancel.
|
||||||
|
- Don't delete assets you didn't create. `./mb rm-asset` your own intermediates when done.
|
||||||
|
|
||||||
|
### Art direction & asset manifest
|
||||||
|
|
||||||
|
Pick ONE style token and use it in every prompt for coherence. Recommended: **"chunky stylized claymation kitchen, soft matte plastic look, warm colors"** — chunky geometry hides image→3D artifacts, reads well at game scale, and gives cutlery thicker (physics-friendlier) forms.
|
||||||
|
|
||||||
|
Generate (3D via the pipeline above, one flux seed noted per asset so it's re-runnable):
|
||||||
|
|
||||||
|
| Asset | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Bread slices ×4: white, thick-cut sourdough, multigrain, raisin | These are the paint canvases — need a clear flat top face. If Hunyuan's top-face UVs are unusable for painting, **fallback: procedural slice** (rounded-box extrusion in Three.js) with a flux-generated bread-crust texture — the paint tech matters more than the mesh source. |
|
||||||
|
| Toaster (2-slot, retro) | Hero prop of the toast scene |
|
||||||
|
| Cutlery: butter knife, dinner knife, steak knife, spreader; dinner fork, dessert fork; teaspoon, dessert spoon, soup spoon | The "similar but not identical" cast. If thin tines/blades come out mangled from image→3D, **model cutlery procedurally** (extruded 2D profiles) — silhouettes must be hand-controlled anyway for fair gameplay. Decide after one test generation. |
|
||||||
|
| Butter dish, peanut-butter jar, vegemite jar (fictional brand: "MITEY") | Bench props / selection UI |
|
||||||
|
| Plate, kitchen bench surface | Bench = one big flux texture on a plane is fine |
|
||||||
|
| Judge portraits ×5 expressions (neutral, intrigued, impressed, disappointed, horrified) | 2D, flux + bg_remove. Same character every time: describe them exactly, fix the seed, vary only the expression phrase. A stern older health-inspector type with a clipboard works. |
|
||||||
|
| Title art, order-ticket paper texture, UI icons | 2D |
|
||||||
|
|
||||||
|
**Normalize every GLB on load in code** (bounding-box → unit scale, recenter pivot) — don't hand-fix meshes.
|
||||||
|
|
||||||
|
## 4. Game design spec
|
||||||
|
|
||||||
|
### 4.1 Toasting
|
||||||
|
- Browning is a **2D scalar field** (~64×64) per slice face, advanced each tick by heat input; render it by compositing into the slice's diffuse texture (white → golden → brown → black, with char speckle past 0.85).
|
||||||
|
- Heat input = toaster power dial (1–10) × coil proximity map (hotspots near coil lines, cooler edges, slight random per-run bias so no two runs identical).
|
||||||
|
- Per-bread params: `thickness` (thermal lag), `moisture` (delays browning then accelerates — sourdough), `sugar` (raisin browns fast and burns faster).
|
||||||
|
- Player controls: power dial + lever down; **no timer readout** — you judge by smell cues (text wisps: "smells warm… toasty… SMOKY") and can pop early anytime. Popping early and re-toasting is allowed but re-toasting browns unevenly (field already has gradient).
|
||||||
|
|
||||||
|
### 4.2 Spreading & scraping — the star mechanic
|
||||||
|
- Knife follows the mouse over the slice's top face (raycast → UV → paint into spread field, a second 2D field with `amount` per texel).
|
||||||
|
- **Scroll wheel = knife angle.** Flat (low angle) = wide contact, gentle pressure, spreads. Steep = narrow contact, high pressure: scrapes spread *off* — or drags burnt char off (reduces browning field where >0.85, spawns crumb particles). Too steep + too fast on clean bread = **gouge** (permanent damage decal, integrity penalty). One unified control, three behaviors.
|
||||||
|
- Dip into the butter/jar to load the knife (visible blob on blade, depletes as you spread).
|
||||||
|
- **Spread rheology** per type: `{ yieldPressure, viscosity, pickupAmount, tempSoftening }`
|
||||||
|
- **Butter**: high yieldPressure when cold — below it the butter doesn't spread, it *tears the bread* (drags the surface, integrity damage). `effectiveHardness = f(butterTemp, toastWarmth)`; toast warmth decays in real time (drawer dawdling matters). Soft butter = dream mode.
|
||||||
|
- **Peanut butter**: high viscosity, big pickup — goes on thick, hard to get even, drags.
|
||||||
|
- **Vegemite**: low viscosity, tiny correct dose — the skill is spreading then **scraping most of it back off**. Judge wants a translucent film; opaque = massive penalty ("This is a hate crime").
|
||||||
|
- Wrong tool from the drawer applies modifiers: spoon = blotchy coverage, fork = tears, steak knife = gouge-prone.
|
||||||
|
|
||||||
|
### 4.3 The drawer
|
||||||
|
- Rapier scene: drawer box, 10–14 cutlery pieces spawned tangled (drop-settle at load, cached). Order card shows a **silhouette** of the required item.
|
||||||
|
- Mouse grabs via spring joint (raycast to nearest piece, joint to grab point); pieces clatter and snag realistically. Lift the right item fully clear of the drawer rim to win.
|
||||||
|
- Timer = your toast cooling. Grabbing the *wrong* item and committing (button) lets you proceed with the wrong-tool modifier — sometimes the smart play.
|
||||||
|
- **Colliders: hand-authored compound primitives** (capsules/boxes) per archetype, NOT trimesh — stability and speed. Visual mesh can be the generated GLB.
|
||||||
|
- Start with 8 pieces; only raise the count once tangling feels good.
|
||||||
|
- Metal-on-metal clatter sounds are non-negotiable for feel (see §4.5).
|
||||||
|
|
||||||
|
### 4.4 Judging
|
||||||
|
Score 0–10, itemized so the player sees exactly why:
|
||||||
|
| Criterion | Measures |
|
||||||
|
|---|---|
|
||||||
|
| Browning match | mean browning vs. order target |
|
||||||
|
| Evenness | browning field variance |
|
||||||
|
| Coverage | % of face with spread in the target thickness band |
|
||||||
|
| Uniformity | spread field variance |
|
||||||
|
| Char | texels >0.85 remaining (scraping fixed it? no penalty) |
|
||||||
|
| Integrity | gouges, tears, over-scrape |
|
||||||
|
| Order compliance | right spread, right amount class (thin/normal/thick) |
|
||||||
|
| Time bonus | small — never dominant |
|
||||||
|
|
||||||
|
Judge delivers 1–2 dry lines keyed to the worst and best criterion (write a bank of ~40; e.g. "The coverage is admirable. The crater is not."). Show the toast rotating on a pedestal with a heatmap toggle. Grade stamp (S/A/B/C/F). **This screen is the game — make it excellent.**
|
||||||
|
|
||||||
|
### 4.5 Sound & feel
|
||||||
|
- Web Audio, synthesized where possible: toaster tick, lever clunk, pop (with slice launch physics!), knife scrape (pitch ∝ pressure), butter-tear rip, cutlery clatter (Rapier collision events → filtered noise bursts, velocity-scaled), judge stamp thunk.
|
||||||
|
- Screen-shake on pop, crumb particles on scrape, steam wisps off warm toast.
|
||||||
|
|
||||||
|
## 5. Milestones (commit + push each; verify in browser before moving on)
|
||||||
|
|
||||||
|
- **M0 — Scaffold**: Vite+TS+Three+Rapier boots, launch.json, placeholder cube scene, pushed. *Also: fire off the first MODELBEAST asset jobs now so weights download while you build M1.*
|
||||||
|
- **M1 — Toasting**: procedural slice + browning field + toaster controls + pop. Playable: make toast of any darkness.
|
||||||
|
- **M2 — Spreading**: knife, angle control, butter + vegemite + PB rheology, scraping, gouges. This milestone is where the fun lives — iterate until dragging cold butter genuinely feels bad in a funny way.
|
||||||
|
- **M3 — Judging**: full rubric, judge lines, grade screen, "next order" loop with 5 handwritten orders.
|
||||||
|
- **M4 — Drawer**: physics drawer with 8 pieces, silhouette orders, spring grab, toast-cooling link, wrong-tool modifiers.
|
||||||
|
- **M5 — Content & escalation**: 4 breads, order generator with difficulty curve (day 1: butter on white → day 7: thin vegemite on warm crumpet-thick sourdough with a steak knife because the drawer's a mess), persistent day/score.
|
||||||
|
- **M6 — Art & polish pass**: swap placeholders for generated assets, judge portraits, title screen, sound polish, perf check (60fps with drawer active).
|
||||||
|
|
||||||
|
At each milestone: run it, screenshot it, check the console is clean, then commit. If a milestone's feel is wrong, fixing feel beats advancing.
|
||||||
|
|
||||||
|
## 6. Judgment calls you're pre-authorized to make
|
||||||
|
|
||||||
|
- Procedural vs. generated meshes per asset (test generation first, choose per §3 fallbacks).
|
||||||
|
- Any tuning of the rheology/browning constants — the spec's numbers are starting points.
|
||||||
|
- Cutting scope inside a milestone to protect feel (e.g., 2 spreads that feel great > 3 that don't).
|
||||||
|
- Do **not** add paid/cloud dependencies, accounts, or external services. Do not touch anything in `~/Documents/MODELBEAST` except via the `mb` CLI as documented.
|
||||||
15
index.html
Normal file
15
index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TOASTSIM</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<canvas id="c"></canvas>
|
||||||
|
<div id="ui"></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1353
package-lock.json
generated
Normal file
1353
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "toastsim",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "^0.19.3",
|
||||||
|
"three": "^0.185.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.185.1",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/assets/models/butter_dish.glb
Normal file
BIN
public/assets/models/butter_dish.glb
Normal file
Binary file not shown.
BIN
public/assets/models/toaster.glb
Normal file
BIN
public/assets/models/toaster.glb
Normal file
Binary file not shown.
99
scripts/gen-assets.sh
Executable file
99
scripts/gen-assets.sh
Executable file
@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# TOASTSIM asset generation — 100% local, 100% free, via MODELBEAST on the M3 Ultra.
|
||||||
|
#
|
||||||
|
# ./scripts/gen-assets.sh 3d # product shot -> cutout -> GLB (slow: ~5 min each, gpu lane is serial)
|
||||||
|
# ./scripts/gen-assets.sh 2d # flux images (judge portraits, backdrops, ui)
|
||||||
|
# ./scripts/gen-assets.sh all
|
||||||
|
#
|
||||||
|
# Seeds are fixed so every asset is re-generatable. Style token is shared for coherence.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
MB_DIR="$HOME/Documents/MODELBEAST"
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
OUT_MODELS="$REPO/public/assets/models"
|
||||||
|
OUT_IMG="$REPO/public/assets/img"
|
||||||
|
mkdir -p "$OUT_MODELS" "$OUT_IMG"
|
||||||
|
|
||||||
|
cd "$MB_DIR" || exit 1
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source data/agent.env
|
||||||
|
|
||||||
|
STYLE="chunky stylized claymation kitchen prop, soft matte plastic look, warm colors"
|
||||||
|
SHOT="single object centered on plain light grey background, soft studio lighting, 3/4 view, chunky stylized game asset"
|
||||||
|
|
||||||
|
# run_flux <seed> <prompt> [w] [h] -> asset id on stdout
|
||||||
|
run_flux() {
|
||||||
|
local seed="$1" prompt="$2" w="${3:-1024}" h="${4:-1024}"
|
||||||
|
./mb run flux_local -p prompt="$prompt" -p model=flux2-klein-4b -p steps=4 \
|
||||||
|
-p seed="$seed" -p width="$w" -p height="$h" --wait 2>/dev/null \
|
||||||
|
| awk '/image/{print $1}' | tail -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# gen_3d <name> <seed> <prompt>
|
||||||
|
gen_3d() {
|
||||||
|
local name="$1" seed="$2" prompt="$3"
|
||||||
|
if [ -f "$OUT_MODELS/$name.glb" ]; then echo " = $name.glb exists, skipping"; return; fi
|
||||||
|
echo "→ [$name] flux..."
|
||||||
|
local img cut
|
||||||
|
img=$(run_flux "$seed" "$prompt, $STYLE, $SHOT")
|
||||||
|
[ -z "$img" ] && { echo " ! $name: flux failed"; return 1; }
|
||||||
|
echo "→ [$name] cutout ($img)..."
|
||||||
|
cut=$(./mb run bg_remove_local --asset "$img" -p resolution=2048 --wait 2>/dev/null | awk '/image/{print $1}' | tail -1)
|
||||||
|
[ -z "$cut" ] && { echo " ! $name: bg_remove failed"; return 1; }
|
||||||
|
echo "→ [$name] mesh ($cut) — ~5 min..."
|
||||||
|
local tmp; tmp=$(mktemp -d)
|
||||||
|
./mb run hunyuan3d_mlx --asset "$cut" -p octree_resolution=256 -p texture_size=1024 \
|
||||||
|
-p max_num_view=6 --wait --download "$tmp" >/dev/null 2>&1
|
||||||
|
local glb; glb=$(find "$tmp" -iname '*.glb' | head -1)
|
||||||
|
if [ -n "$glb" ]; then mv "$glb" "$OUT_MODELS/$name.glb"; echo " ✓ $name.glb"; else echo " ! $name: no glb"; fi
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# gen_2d <name> <seed> <prompt> [w] [h] [cutout]
|
||||||
|
gen_2d() {
|
||||||
|
local name="$1" seed="$2" prompt="$3" w="${4:-1024}" h="${5:-1024}" cutout="${6:-no}"
|
||||||
|
if [ -f "$OUT_IMG/$name.png" ]; then echo " = $name.png exists, skipping"; return; fi
|
||||||
|
echo "→ [$name] flux..."
|
||||||
|
local img; img=$(run_flux "$seed" "$prompt" "$w" "$h")
|
||||||
|
[ -z "$img" ] && { echo " ! $name: flux failed"; return 1; }
|
||||||
|
if [ "$cutout" = "cut" ]; then
|
||||||
|
echo "→ [$name] cutout..."
|
||||||
|
local c; c=$(./mb run bg_remove_local --asset "$img" -p resolution=2048 --wait 2>/dev/null | awk '/image/{print $1}' | tail -1)
|
||||||
|
[ -n "$c" ] && img="$c"
|
||||||
|
fi
|
||||||
|
local tmp; tmp=$(mktemp -d)
|
||||||
|
./mb get "$img" -o "$tmp" >/dev/null 2>&1
|
||||||
|
local f; f=$(find "$tmp" -iname '*.png' -o -iname '*.jpg' | head -1)
|
||||||
|
[ -n "$f" ] && { mv "$f" "$OUT_IMG/$name.png"; echo " ✓ $name.png"; }
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- the judge: same person every time, seed fixed, only the expression phrase varies ----
|
||||||
|
JUDGE="portrait of a stern elderly australian food inspector, deep wrinkles, bushy grey eyebrows, thin wire glasses, grey moustache, wearing a brown cardigan over a shirt and tie, holding a clipboard, plain light grey background, head and shoulders, claymation stop-motion character, soft matte clay texture, warm studio light"
|
||||||
|
|
||||||
|
do_3d() {
|
||||||
|
gen_3d toaster 11 "a retro two-slot pop-up toaster, cream and chrome, with a browning dial"
|
||||||
|
gen_3d butter_dish 23 "a ceramic butter dish with a thick block of yellow butter on it, lid off"
|
||||||
|
gen_3d pb_jar 31 "an open glass jar of peanut butter, chunky brown spread visible, cream label"
|
||||||
|
gen_3d mitey_jar 37 "a small squat glass jar of dark brown yeast extract spread with a bright red and yellow label reading MITEY, lid off"
|
||||||
|
gen_3d plate 43 "a simple round white ceramic side plate, empty, seen at an angle"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_2d() {
|
||||||
|
gen_2d judge_neutral 101 "$JUDGE, neutral unimpressed expression" 768 768 cut
|
||||||
|
gen_2d judge_intrigued 101 "$JUDGE, one eyebrow raised, mildly intrigued expression" 768 768 cut
|
||||||
|
gen_2d judge_impressed 101 "$JUDGE, small approving smile, impressed expression" 768 768 cut
|
||||||
|
gen_2d judge_disappointed 101 "$JUDGE, deeply disappointed frowning expression, looking down at clipboard" 768 768 cut
|
||||||
|
gen_2d judge_horrified 101 "$JUDGE, horrified appalled expression, eyes wide, recoiling" 768 768 cut
|
||||||
|
gen_2d bench_wood 202 "seamless top-down texture of a warm honey-toned wooden kitchen benchtop, subtle grain and knife marks, claymation soft matte look, flat even lighting, no objects" 1024 1024
|
||||||
|
gen_2d kitchen_backdrop 205 "blurry cosy 1970s australian kitchen interior background, warm cream tiles, soft bokeh, claymation set, no people, no text" 1280 768
|
||||||
|
gen_2d ticket_paper 210 "seamless texture of a plain cream paper order docket, faint blue lines, slightly crumpled, flat even lighting, no text" 512 512
|
||||||
|
gen_2d title_art 220 "a single slice of golden toast with a pat of butter melting on it, dramatic hero shot, claymation stop-motion, soft matte clay, warm rim light, plain dark background" 1024 768 cut
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-all}" in
|
||||||
|
3d) do_3d ;;
|
||||||
|
2d) do_2d ;;
|
||||||
|
*) do_3d; do_2d ;;
|
||||||
|
esac
|
||||||
|
echo "done."
|
||||||
190
src/core/app.ts
Normal file
190
src/core/app.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/** Shared key light direction — the slice shader rolls its own lighting, so it
|
||||||
|
* has to be told the same thing the real lights are doing. */
|
||||||
|
export const LIGHT_DIR = new THREE.Vector3(0.5, 0.9, 0.42).normalize();
|
||||||
|
|
||||||
|
export interface View {
|
||||||
|
readonly root: THREE.Object3D;
|
||||||
|
enter(): void;
|
||||||
|
exit(): void;
|
||||||
|
update(dt: number): void;
|
||||||
|
resize?(w: number, h: number): void;
|
||||||
|
dispose?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Input {
|
||||||
|
/** Normalised device coords (-1..1). */
|
||||||
|
readonly ndc = new THREE.Vector2();
|
||||||
|
/** Pixel coords. */
|
||||||
|
readonly px = new THREE.Vector2();
|
||||||
|
/** Pixel delta since last frame. */
|
||||||
|
readonly delta = new THREE.Vector2();
|
||||||
|
down = false;
|
||||||
|
rightDown = false;
|
||||||
|
/** Accumulated wheel since last frame (normalised-ish). */
|
||||||
|
wheel = 0;
|
||||||
|
private prev = new THREE.Vector2();
|
||||||
|
private first = true;
|
||||||
|
private keys = new Set<string>();
|
||||||
|
private pressed = new Set<string>();
|
||||||
|
|
||||||
|
constructor(el: HTMLElement) {
|
||||||
|
el.addEventListener('pointermove', (e) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
this.px.set(e.clientX - r.left, e.clientY - r.top);
|
||||||
|
this.ndc.set((this.px.x / r.width) * 2 - 1, -(this.px.y / r.height) * 2 + 1);
|
||||||
|
if (this.first) {
|
||||||
|
this.prev.copy(this.px);
|
||||||
|
this.first = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.button === 0) this.down = true;
|
||||||
|
if (e.button === 2) this.rightDown = true;
|
||||||
|
});
|
||||||
|
window.addEventListener('pointerup', (e) => {
|
||||||
|
if (e.button === 0) this.down = false;
|
||||||
|
if (e.button === 2) this.rightDown = false;
|
||||||
|
});
|
||||||
|
el.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
el.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.wheel += Math.sign(e.deltaY) * Math.min(1, Math.abs(e.deltaY) / 50);
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (!this.keys.has(e.code)) this.pressed.add(e.code);
|
||||||
|
this.keys.add(e.code);
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', (e) => this.keys.delete(e.code));
|
||||||
|
window.addEventListener('blur', () => {
|
||||||
|
this.keys.clear();
|
||||||
|
this.down = false;
|
||||||
|
this.rightDown = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
held(code: string): boolean {
|
||||||
|
return this.keys.has(code);
|
||||||
|
}
|
||||||
|
justPressed(code: string): boolean {
|
||||||
|
return this.pressed.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
endFrame(): void {
|
||||||
|
this.delta.subVectors(this.px, this.prev);
|
||||||
|
this.prev.copy(this.px);
|
||||||
|
this.wheel = 0;
|
||||||
|
this.pressed.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class App {
|
||||||
|
readonly renderer: THREE.WebGLRenderer;
|
||||||
|
readonly scene = new THREE.Scene();
|
||||||
|
readonly camera: THREE.PerspectiveCamera;
|
||||||
|
readonly input: Input;
|
||||||
|
readonly timer = new THREE.Timer();
|
||||||
|
/** Additive screen shake, applied after the camera is positioned. */
|
||||||
|
shake = 0;
|
||||||
|
|
||||||
|
private view: View | null = null;
|
||||||
|
private tickers: ((dt: number) => void)[] = [];
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement) {
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
||||||
|
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
this.renderer.shadowMap.enabled = true;
|
||||||
|
this.renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||||
|
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
this.renderer.toneMappingExposure = 1.05;
|
||||||
|
this.camera = new THREE.PerspectiveCamera(42, 1, 0.05, 100);
|
||||||
|
this.input = new Input(canvas);
|
||||||
|
// Pauses the clock while the tab is hidden, so you don't come back to charcoal.
|
||||||
|
this.timer.connect(document);
|
||||||
|
this.scene.fog = new THREE.Fog(0x1b1512, 8, 22);
|
||||||
|
window.addEventListener('resize', () => this.resize());
|
||||||
|
this.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTick(fn: (dt: number) => void): void {
|
||||||
|
this.tickers.push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(v: View | null): void {
|
||||||
|
if (this.view === v) return;
|
||||||
|
if (this.view) {
|
||||||
|
this.view.exit();
|
||||||
|
this.view.root.visible = false;
|
||||||
|
}
|
||||||
|
this.view = v;
|
||||||
|
if (v) {
|
||||||
|
v.root.visible = true;
|
||||||
|
v.enter();
|
||||||
|
const s = this.renderer.getSize(new THREE.Vector2());
|
||||||
|
v.resize?.(s.x, s.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentView(): View | null {
|
||||||
|
return this.view;
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(): void {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
this.renderer.setSize(w, h, false);
|
||||||
|
this.camera.aspect = w / h;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.view?.resize?.(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
const loop = (ts?: number) => {
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
this.timer.update(ts);
|
||||||
|
// Clamp so a single frame hitch can't teleport the sim forward.
|
||||||
|
const dt = Math.min(this.timer.getDelta(), 1 / 20);
|
||||||
|
for (const t of this.tickers) t(dt);
|
||||||
|
this.view?.update(dt);
|
||||||
|
if (this.shake > 0.0001) {
|
||||||
|
const s = this.shake;
|
||||||
|
this.camera.position.x += (Math.random() - 0.5) * s;
|
||||||
|
this.camera.position.y += (Math.random() - 0.5) * s;
|
||||||
|
this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
|
||||||
|
this.shake *= Math.pow(0.0015, dt);
|
||||||
|
if (this.shake < 0.0005) this.shake = 0;
|
||||||
|
}
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
this.input.endFrame();
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard kitchen lighting rig, shared by every view. */
|
||||||
|
export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
|
||||||
|
const key = new THREE.DirectionalLight(0xfff2dd, 2.6);
|
||||||
|
key.position.copy(LIGHT_DIR).multiplyScalar(6);
|
||||||
|
key.castShadow = true;
|
||||||
|
key.shadow.mapSize.set(2048, 2048);
|
||||||
|
key.shadow.camera.near = 0.5;
|
||||||
|
key.shadow.camera.far = 18;
|
||||||
|
const d = 3.2;
|
||||||
|
key.shadow.camera.left = -d;
|
||||||
|
key.shadow.camera.right = d;
|
||||||
|
key.shadow.camera.top = d;
|
||||||
|
key.shadow.camera.bottom = -d;
|
||||||
|
key.shadow.bias = -0.0016;
|
||||||
|
key.shadow.normalBias = 0.02;
|
||||||
|
scene.add(key);
|
||||||
|
scene.add(new THREE.HemisphereLight(0x9fb4d6, 0x3a2a20, 0.75));
|
||||||
|
const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45);
|
||||||
|
fill.position.set(-3, 2.2, -2.5);
|
||||||
|
scene.add(fill);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
130
src/core/field.ts
Normal file
130
src/core/field.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* A square scalar field over the slice's UV space. Everything the player does to
|
||||||
|
* the toast — browning, spread, damage — is one of these, and everything the judge
|
||||||
|
* measures is a statistic over one of these.
|
||||||
|
*/
|
||||||
|
export class Field {
|
||||||
|
readonly n: number;
|
||||||
|
readonly data: Float32Array;
|
||||||
|
|
||||||
|
constructor(n: number, fill = 0) {
|
||||||
|
this.n = n;
|
||||||
|
this.data = new Float32Array(n * n);
|
||||||
|
if (fill !== 0) this.data.fill(fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
idx(x: number, y: number): number {
|
||||||
|
return y * this.n + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bilinear sample in UV space (0..1). */
|
||||||
|
sample(u: number, v: number): number {
|
||||||
|
const n = this.n;
|
||||||
|
const fx = Math.min(Math.max(u * (n - 1), 0), n - 1);
|
||||||
|
const fy = Math.min(Math.max(v * (n - 1), 0), n - 1);
|
||||||
|
const x0 = Math.floor(fx);
|
||||||
|
const y0 = Math.floor(fy);
|
||||||
|
const x1 = Math.min(x0 + 1, n - 1);
|
||||||
|
const y1 = Math.min(y0 + 1, n - 1);
|
||||||
|
const tx = fx - x0;
|
||||||
|
const ty = fy - y0;
|
||||||
|
const d = this.data;
|
||||||
|
const a = d[y0 * n + x0] * (1 - tx) + d[y0 * n + x1] * tx;
|
||||||
|
const b = d[y1 * n + x0] * (1 - tx) + d[y1 * n + x1] * tx;
|
||||||
|
return a * (1 - ty) + b * ty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply `fn` to every texel within `radius` (UV units) of (u,v), weighted by a
|
||||||
|
* smooth falloff. Returns the total weight touched — callers use it to conserve
|
||||||
|
* mass (a knife can only deposit what it's carrying).
|
||||||
|
*/
|
||||||
|
brush(u: number, v: number, radius: number, fn: (i: number, w: number) => void): number {
|
||||||
|
const n = this.n;
|
||||||
|
const cx = u * (n - 1);
|
||||||
|
const cy = v * (n - 1);
|
||||||
|
const r = radius * (n - 1);
|
||||||
|
const r2 = r * r;
|
||||||
|
const x0 = Math.max(0, Math.floor(cx - r));
|
||||||
|
const x1 = Math.min(n - 1, Math.ceil(cx + r));
|
||||||
|
const y0 = Math.max(0, Math.floor(cy - r));
|
||||||
|
const y1 = Math.min(n - 1, Math.ceil(cy + r));
|
||||||
|
let total = 0;
|
||||||
|
for (let y = y0; y <= y1; y++) {
|
||||||
|
for (let x = x0; x <= x1; x++) {
|
||||||
|
const dx = x - cx;
|
||||||
|
const dy = y - cy;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 > r2) continue;
|
||||||
|
const t = 1 - Math.sqrt(d2) / r;
|
||||||
|
const w = t * t * (3 - 2 * t); // smoothstep falloff
|
||||||
|
fn(y * n + x, w);
|
||||||
|
total += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blur toward the neighbour average — how a spread self-levels. */
|
||||||
|
relax(amount: number, mask?: Field): void {
|
||||||
|
if (amount <= 0) return;
|
||||||
|
const n = this.n;
|
||||||
|
const d = this.data;
|
||||||
|
const src = d.slice();
|
||||||
|
for (let y = 0; y < n; y++) {
|
||||||
|
for (let x = 0; x < n; x++) {
|
||||||
|
const i = y * n + x;
|
||||||
|
if (mask && mask.data[i] < 0.5) continue;
|
||||||
|
const xm = x > 0 ? x - 1 : x;
|
||||||
|
const xp = x < n - 1 ? x + 1 : x;
|
||||||
|
const ym = y > 0 ? y - 1 : y;
|
||||||
|
const yp = y < n - 1 ? y + 1 : y;
|
||||||
|
const avg =
|
||||||
|
(src[y * n + xm] + src[y * n + xp] + src[ym * n + x] + src[yp * n + x]) * 0.25;
|
||||||
|
d[i] = src[i] + (avg - src[i]) * amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mean / stdev / max over the masked region. */
|
||||||
|
stats(mask: Field): { mean: number; stdev: number; max: number; count: number } {
|
||||||
|
const d = this.data;
|
||||||
|
const m = mask.data;
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
let max = 0;
|
||||||
|
for (let i = 0; i < d.length; i++) {
|
||||||
|
if (m[i] < 0.5) continue;
|
||||||
|
sum += d[i];
|
||||||
|
if (d[i] > max) max = d[i];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (count === 0) return { mean: 0, stdev: 0, max: 0, count: 0 };
|
||||||
|
const mean = sum / count;
|
||||||
|
let acc = 0;
|
||||||
|
for (let i = 0; i < d.length; i++) {
|
||||||
|
if (m[i] < 0.5) continue;
|
||||||
|
const dv = d[i] - mean;
|
||||||
|
acc += dv * dv;
|
||||||
|
}
|
||||||
|
return { mean, stdev: Math.sqrt(acc / count), max, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fraction of the masked region for which `pred` holds. */
|
||||||
|
fraction(mask: Field, pred: (v: number) => boolean): number {
|
||||||
|
const d = this.data;
|
||||||
|
const m = mask.data;
|
||||||
|
let hit = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < d.length; i++) {
|
||||||
|
if (m[i] < 0.5) continue;
|
||||||
|
if (pred(d[i])) hit++;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count === 0 ? 0 : hit / count;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(v = 0): void {
|
||||||
|
this.data.fill(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/core/rng.ts
Normal file
58
src/core/rng.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/** Small deterministic RNG (mulberry32) — seeded so a run can be replayed. */
|
||||||
|
export class Rng {
|
||||||
|
private s: number;
|
||||||
|
constructor(seed = 1) {
|
||||||
|
this.s = seed >>> 0;
|
||||||
|
}
|
||||||
|
next(): number {
|
||||||
|
this.s = (this.s + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = this.s;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
range(a: number, b: number): number {
|
||||||
|
return a + this.next() * (b - a);
|
||||||
|
}
|
||||||
|
int(a: number, b: number): number {
|
||||||
|
return Math.floor(this.range(a, b + 1));
|
||||||
|
}
|
||||||
|
pick<T>(arr: readonly T[]): T {
|
||||||
|
return arr[Math.floor(this.next() * arr.length)];
|
||||||
|
}
|
||||||
|
chance(p: number): boolean {
|
||||||
|
return this.next() < p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap 2D value noise, used to give each toasting run its own character. */
|
||||||
|
export function valueNoise2D(rng: Rng, w: number, h: number, octaves = 3): Float32Array {
|
||||||
|
const out = new Float32Array(w * h);
|
||||||
|
let amp = 1;
|
||||||
|
let total = 0;
|
||||||
|
for (let o = 0; o < octaves; o++) {
|
||||||
|
const cells = 2 << o;
|
||||||
|
const grid = new Float32Array((cells + 1) * (cells + 1));
|
||||||
|
for (let i = 0; i < grid.length; i++) grid[i] = rng.next();
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const fx = (x / (w - 1)) * cells;
|
||||||
|
const fy = (y / (h - 1)) * cells;
|
||||||
|
const x0 = Math.floor(fx);
|
||||||
|
const y0 = Math.floor(fy);
|
||||||
|
const tx = fx - x0;
|
||||||
|
const ty = fy - y0;
|
||||||
|
const sx = tx * tx * (3 - 2 * tx);
|
||||||
|
const sy = ty * ty * (3 - 2 * ty);
|
||||||
|
const g = (gx: number, gy: number) => grid[gy * (cells + 1) + gx];
|
||||||
|
const a = g(x0, y0) * (1 - sx) + g(x0 + 1, y0) * sx;
|
||||||
|
const b = g(x0, y0 + 1) * (1 - sx) + g(x0 + 1, y0 + 1) * sx;
|
||||||
|
out[y * w + x] += (a * (1 - sy) + b * sy) * amp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total += amp;
|
||||||
|
amp *= 0.5;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < out.length; i++) out[i] /= total;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
64
src/main.ts
Normal file
64
src/main.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { App, makeLights } from './core/app';
|
||||||
|
import { Slice } from './sim/slice';
|
||||||
|
import { BREADS } from './sim/bread';
|
||||||
|
import { Rng } from './core/rng';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||||
|
const app = new App(canvas);
|
||||||
|
makeLights(app.scene);
|
||||||
|
app.scene.background = new THREE.Color(0x1d1613);
|
||||||
|
|
||||||
|
const bench = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(9, 0.3, 5),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.85 }),
|
||||||
|
);
|
||||||
|
bench.position.y = -0.15;
|
||||||
|
bench.receiveShadow = true;
|
||||||
|
app.scene.add(bench);
|
||||||
|
|
||||||
|
const slice = new Slice(BREADS.white, new Rng(7));
|
||||||
|
slice.mesh.position.y = 0.12;
|
||||||
|
app.scene.add(slice.mesh);
|
||||||
|
|
||||||
|
// M0 smoke test: a browning gradient + a smear of butter, so the shader gets exercised.
|
||||||
|
for (let y = 0; y < slice.browning.n; y++) {
|
||||||
|
for (let x = 0; x < slice.browning.n; x++) {
|
||||||
|
const i = y * slice.browning.n + x;
|
||||||
|
slice.browning.data[i] = (x / slice.browning.n) * 1.0;
|
||||||
|
const dx = x / slice.spread.n - 0.5;
|
||||||
|
const dy = y / slice.spread.n - 0.55;
|
||||||
|
slice.spread.data[i] = Math.max(0, 0.45 - Math.hypot(dx, dy) * 1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slice.setSpread({
|
||||||
|
id: 'butter',
|
||||||
|
name: 'Butter',
|
||||||
|
blurb: '',
|
||||||
|
color: [0.97, 0.72, 0.19],
|
||||||
|
gloss: 0.85,
|
||||||
|
opaqueAt: 0.5,
|
||||||
|
bump: 0.1,
|
||||||
|
baseYield: 0.72,
|
||||||
|
tempSoftening: 0.78,
|
||||||
|
viscosity: 0.72,
|
||||||
|
pickup: 0.55,
|
||||||
|
transferRate: 1.5,
|
||||||
|
drag: 0.5,
|
||||||
|
scrapeEase: 1,
|
||||||
|
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
|
||||||
|
});
|
||||||
|
slice.touch();
|
||||||
|
slice.sync();
|
||||||
|
|
||||||
|
(window as unknown as { __t: unknown }).__t = { app, slice, THREE };
|
||||||
|
|
||||||
|
app.camera.position.set(0, 1.5, 1.6);
|
||||||
|
app.camera.lookAt(0, 0.1, 0);
|
||||||
|
|
||||||
|
app.onTick((dt) => {
|
||||||
|
slice.mesh.rotation.y += dt * 0.4;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.start();
|
||||||
101
src/sim/bread.ts
Normal file
101
src/sim/bread.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Bread archetypes. The three numbers that matter for the toasting sim:
|
||||||
|
* thickness — thermal lag (slow to brown) but holds warmth longer, which
|
||||||
|
* matters later because warm toast is what softens hard butter.
|
||||||
|
* moisture — water must boil off before the crust can brown, so a wet crumb
|
||||||
|
* stalls, then catches up fast once it dries. Sourdough's trap.
|
||||||
|
* sugar — browns early and burns early. Raisin bread is a countdown.
|
||||||
|
*/
|
||||||
|
export interface Bread {
|
||||||
|
id: BreadId;
|
||||||
|
name: string;
|
||||||
|
blurb: string;
|
||||||
|
thickness: number; // world units
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
domeH: number; // how much of the top is the loaf's dome
|
||||||
|
moisture: number; // 0..1
|
||||||
|
sugar: number; // 0..1
|
||||||
|
crumb: [number, number, number];
|
||||||
|
crust: [number, number, number];
|
||||||
|
crumbScale: number; // crumb noise frequency
|
||||||
|
crumbContrast: number;
|
||||||
|
/** Speckles baked into the crumb: raisins, grain kibble. */
|
||||||
|
inclusions: number;
|
||||||
|
inclusionColor: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BreadId = 'white' | 'sourdough' | 'multigrain' | 'raisin';
|
||||||
|
|
||||||
|
export const BREADS: Record<BreadId, Bread> = {
|
||||||
|
white: {
|
||||||
|
id: 'white',
|
||||||
|
name: 'Supermarket White',
|
||||||
|
blurb: 'Forgiving. Browns like it wants to be liked.',
|
||||||
|
thickness: 0.13,
|
||||||
|
width: 1.0,
|
||||||
|
height: 1.0,
|
||||||
|
domeH: 0.26,
|
||||||
|
moisture: 0.18,
|
||||||
|
sugar: 0.22,
|
||||||
|
crumb: [0.95, 0.9, 0.78],
|
||||||
|
crust: [0.78, 0.6, 0.36],
|
||||||
|
crumbScale: 34,
|
||||||
|
crumbContrast: 0.07,
|
||||||
|
inclusions: 0,
|
||||||
|
inclusionColor: [0, 0, 0],
|
||||||
|
},
|
||||||
|
sourdough: {
|
||||||
|
id: 'sourdough',
|
||||||
|
name: 'Thick-Cut Sourdough',
|
||||||
|
blurb: 'Wet, dense, and stubborn. Stalls, then catches you out.',
|
||||||
|
thickness: 0.23,
|
||||||
|
width: 1.06,
|
||||||
|
height: 1.02,
|
||||||
|
domeH: 0.3,
|
||||||
|
moisture: 0.72,
|
||||||
|
sugar: 0.1,
|
||||||
|
crumb: [0.93, 0.87, 0.72],
|
||||||
|
crust: [0.62, 0.42, 0.22],
|
||||||
|
crumbScale: 16,
|
||||||
|
crumbContrast: 0.16,
|
||||||
|
inclusions: 0,
|
||||||
|
inclusionColor: [0, 0, 0],
|
||||||
|
},
|
||||||
|
multigrain: {
|
||||||
|
id: 'multigrain',
|
||||||
|
name: 'Multigrain',
|
||||||
|
blurb: 'The seeds catch first. They always catch first.',
|
||||||
|
thickness: 0.15,
|
||||||
|
width: 1.0,
|
||||||
|
height: 0.98,
|
||||||
|
domeH: 0.24,
|
||||||
|
moisture: 0.34,
|
||||||
|
sugar: 0.3,
|
||||||
|
crumb: [0.82, 0.72, 0.55],
|
||||||
|
crust: [0.55, 0.4, 0.24],
|
||||||
|
crumbScale: 30,
|
||||||
|
crumbContrast: 0.13,
|
||||||
|
inclusions: 0.55,
|
||||||
|
inclusionColor: [0.36, 0.26, 0.15],
|
||||||
|
},
|
||||||
|
raisin: {
|
||||||
|
id: 'raisin',
|
||||||
|
name: 'Raisin Loaf',
|
||||||
|
blurb: 'Sugar. Everywhere. You have less time than you think.',
|
||||||
|
thickness: 0.14,
|
||||||
|
width: 0.98,
|
||||||
|
height: 0.98,
|
||||||
|
domeH: 0.26,
|
||||||
|
moisture: 0.3,
|
||||||
|
sugar: 0.92,
|
||||||
|
crumb: [0.93, 0.84, 0.66],
|
||||||
|
crust: [0.7, 0.48, 0.26],
|
||||||
|
crumbScale: 26,
|
||||||
|
crumbContrast: 0.09,
|
||||||
|
inclusions: 0.85,
|
||||||
|
inclusionColor: [0.22, 0.11, 0.12],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BREAD_ORDER: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
|
||||||
497
src/sim/slice.ts
Normal file
497
src/sim/slice.ts
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.material = buildMaterial(bread, this.tex);
|
||||||
|
this.mesh = new THREE.Mesh(geo, this.material);
|
||||||
|
this.mesh.castShadow = true;
|
||||||
|
this.mesh.receiveShadow = true;
|
||||||
|
this.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
get uniforms() {
|
||||||
|
return this.material.uniforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push the sim fields into the GPU texture. Cheap: 64KB. */
|
||||||
|
sync(): void {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
for (let y = 0; y < n; y++) {
|
||||||
|
for (let x = 0; x < n; x++) {
|
||||||
|
if (src[y * n + x] < 0.5) continue;
|
||||||
|
const xm = Math.max(0, x - 1);
|
||||||
|
const xp = Math.min(n - 1, x + 1);
|
||||||
|
const ym = Math.max(0, y - 1);
|
||||||
|
const yp = Math.min(n - 1, y + 1);
|
||||||
|
if (
|
||||||
|
src[y * n + xm] < 0.5 ||
|
||||||
|
src[y * n + xp] < 0.5 ||
|
||||||
|
src[ym * n + x] < 0.5 ||
|
||||||
|
src[yp * n + x] < 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 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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
// Keep the lobe tight: a broad one just washes the whole slice white and drowns
|
||||||
|
// the colour. Tight + the thickness ridges = glints along the knife marks.
|
||||||
|
float shin = mix(18.0, 52.0, uSpreadGloss);
|
||||||
|
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.6;
|
||||||
|
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 },
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
125
src/sim/spreads.ts
Normal file
125
src/sim/spreads.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Spread rheology. The whole difficulty curve of this game lives in these numbers.
|
||||||
|
*
|
||||||
|
* The central tension: you need PRESSURE to make a stiff spread flow, but pressure
|
||||||
|
* comes from tilting the knife steeper — and past `SCRAPE_ANGLE` a steep knife stops
|
||||||
|
* spreading and starts scraping. So cold butter can want more pressure than you are
|
||||||
|
* physically allowed to apply without flipping into scrape mode. The way out isn't a
|
||||||
|
* better technique, it's warmer toast: don't dawdle in the drawer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SpreadId = 'butter' | 'peanut' | 'mitey';
|
||||||
|
export type AmountClass = 'thin' | 'normal' | 'thick';
|
||||||
|
|
||||||
|
export interface SpreadDef {
|
||||||
|
id: SpreadId;
|
||||||
|
name: string;
|
||||||
|
blurb: string;
|
||||||
|
color: [number, number, number];
|
||||||
|
gloss: number;
|
||||||
|
/** Thickness at which the film fully hides the toast underneath. */
|
||||||
|
opaqueAt: number;
|
||||||
|
/** Surface relief strength. */
|
||||||
|
bump: number;
|
||||||
|
|
||||||
|
/** Pressure needed to make it flow, before any warming. */
|
||||||
|
baseYield: number;
|
||||||
|
/** How much heat (soft butter + hot toast) reduces that. */
|
||||||
|
tempSoftening: number;
|
||||||
|
/** 0 self-levels instantly, 1 stays exactly where you put it. */
|
||||||
|
viscosity: number;
|
||||||
|
/** How much the knife picks up per dip. */
|
||||||
|
pickup: number;
|
||||||
|
/** How fast it leaves the knife once it IS flowing. */
|
||||||
|
transferRate: number;
|
||||||
|
/** How much the spread resists the knife (drag on the cursor). */
|
||||||
|
drag: number;
|
||||||
|
/** How readily it comes back off when you scrape. */
|
||||||
|
scrapeEase: number;
|
||||||
|
|
||||||
|
amounts: Record<AmountClass, [number, number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPREADS: Record<SpreadId, SpreadDef> = {
|
||||||
|
butter: {
|
||||||
|
id: 'butter',
|
||||||
|
name: 'Butter',
|
||||||
|
blurb: 'Straightforward — as long as something in this scene is warm.',
|
||||||
|
color: [0.97, 0.72, 0.19],
|
||||||
|
gloss: 0.85,
|
||||||
|
opaqueAt: 0.5,
|
||||||
|
bump: 0.10,
|
||||||
|
baseYield: 0.72,
|
||||||
|
tempSoftening: 0.78,
|
||||||
|
viscosity: 0.72,
|
||||||
|
pickup: 0.55,
|
||||||
|
transferRate: 1.5,
|
||||||
|
drag: 0.5,
|
||||||
|
scrapeEase: 1.0,
|
||||||
|
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
|
||||||
|
},
|
||||||
|
peanut: {
|
||||||
|
id: 'peanut',
|
||||||
|
name: 'Peanut Butter',
|
||||||
|
blurb: 'Goes on thick, drags like wet sand, never evens itself out.',
|
||||||
|
color: [0.64, 0.41, 0.18],
|
||||||
|
gloss: 0.3,
|
||||||
|
opaqueAt: 0.16,
|
||||||
|
bump: 0.28,
|
||||||
|
baseYield: 0.38,
|
||||||
|
tempSoftening: 0.3,
|
||||||
|
viscosity: 0.96,
|
||||||
|
pickup: 0.95,
|
||||||
|
transferRate: 2.2,
|
||||||
|
drag: 1.0,
|
||||||
|
scrapeEase: 0.75,
|
||||||
|
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
|
||||||
|
},
|
||||||
|
mitey: {
|
||||||
|
id: 'mitey',
|
||||||
|
name: 'MITEY',
|
||||||
|
blurb: 'Spreads like nothing. The skill is taking almost all of it back off.',
|
||||||
|
color: [0.14, 0.08, 0.05],
|
||||||
|
gloss: 0.62,
|
||||||
|
opaqueAt: 0.26,
|
||||||
|
bump: 0.03,
|
||||||
|
baseYield: 0.14,
|
||||||
|
tempSoftening: 0.5,
|
||||||
|
viscosity: 0.34,
|
||||||
|
pickup: 0.5,
|
||||||
|
transferRate: 2.6,
|
||||||
|
drag: 0.25,
|
||||||
|
scrapeEase: 1.25,
|
||||||
|
amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Knife angle past which contact narrows enough to scrape instead of spread. */
|
||||||
|
export const SCRAPE_ANGLE = 0.62;
|
||||||
|
|
||||||
|
/** Angle (0 flat .. 1 on-edge) -> contact pressure. */
|
||||||
|
export function pressureFromAngle(angle: number): number {
|
||||||
|
return 0.1 + 0.9 * Math.pow(Math.max(0, Math.min(1, angle)), 1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Angle -> contact patch radius in UV units. Flat knife = wide, steep = a line. */
|
||||||
|
export function contactRadius(angle: number): number {
|
||||||
|
return 0.115 - 0.078 * Math.max(0, Math.min(1, angle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What it actually takes to make this spread flow right now. Butter on a cold
|
||||||
|
* slice with fridge-hard butter is the worst case in the game, by design.
|
||||||
|
*/
|
||||||
|
export function effectiveYield(def: SpreadDef, softness: number, toastWarmth: number): number {
|
||||||
|
const heat = Math.min(1, softness * 0.55 + toastWarmth * 0.65);
|
||||||
|
return def.baseYield * (1 - def.tempSoftening * heat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function amountClassOf(def: SpreadDef, mean: number): AmountClass | 'none' | 'obscene' {
|
||||||
|
if (mean < def.amounts.thin[0] * 0.5) return 'none';
|
||||||
|
if (mean <= def.amounts.thin[1]) return 'thin';
|
||||||
|
if (mean <= def.amounts.normal[1]) return 'normal';
|
||||||
|
if (mean <= def.amounts.thick[1]) return 'thick';
|
||||||
|
return 'obscene';
|
||||||
|
}
|
||||||
51
src/style.css
Normal file
51
src/style.css
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #f3e9dc;
|
||||||
|
--ink-dim: #a9998a;
|
||||||
|
--paper: #efe4cf;
|
||||||
|
--char: #16110e;
|
||||||
|
--gold: #e8a53a;
|
||||||
|
--red: #c0392b;
|
||||||
|
--font: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--char);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas#c {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ui {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ui > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: { port: 5173, strictPort: true },
|
||||||
|
// rapier3d-compat ships wasm inlined as base64, but it still wants to be pre-bundled
|
||||||
|
optimizeDeps: { exclude: [] },
|
||||||
|
build: { target: 'es2022', chunkSizeWarningLimit: 1500 },
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user