Skip to content

feat(registry): cocoa powder dissolving in milk, with an optional maths view - #4352

Closed
miguel-heygen wants to merge 7 commits into
mainfrom
feat/registry-powder-dissolve
Closed

miguel-heygen wants to merge 7 commits into
mainfrom
feat/registry-powder-dissolve

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

What this adds

A new catalog block, powder-dissolve: a spoonful of cocoa lands on milk seen from above, floats dry and pale, wets through from its edges and along cracks in the crust, then its colour reaches out in dark fingers and blooms into marbled clouds until the milk is one even chocolate tint. The motion slows to near stillness, so it works as a calm background.

What you can change:

  • Powder colour and milk colour (matcha on oat milk works too). The powder tints the milk by absorbing light, so pick a powder darker than the milk; a pale powder barely shows once dissolved, as in real life.
  • Dissolve duration, 3 to 30 s. A longer dissolve plays out in full when the clip is longer than the default 10 s.
  • Show the maths (off by default). A panel with the equations that drive each phase: landing, heap and noise field, wetting front, diffusion, crust cracks, fingers, tint mix. The phase happening now is in full ink, finished phases are dimmed, later ones stay hidden, and each shows its live value (landing progress, wet %, front radius in px, finger strength, tint %). Every line is something the code computes.

Why every frame renders on its own

Every frame is a pure function of time: the clump layout comes from a fixed seed, grain and noise from position hashes, and the spreading colour from the closed-form solution of diffusion (a Gaussian convolved with a Gaussian stays a Gaussian), so frame N never reads frame N-1.

Checked on a Linux box with the repo CLI (--format png-sequence, 1 worker vs 4 workers, byte for byte):

  • Default look: 100 of 100 frames identical, and 30 of 30 again after the review fixes.
  • Maths on: 30 of 30 identical in each of three runs.
  • The same check on a copy with Math.random() injected: 20 of 20 frames differ, so the check can fail.
  • Rendered frames against a Studio-style preview (the same font injection and the renderer's Chrome text flags): the effect is identical pixel for pixel; panel text matches in layout, with small antialiasing differences at glyph edges that an existing text-only catalog block shows too.
  • lint-registry-items: 0 errors. hyperframes lint: 0 errors, 1 warning (file length). oxfmt --check: clean.

The maths panel uses only fonts the renderer supplies itself (EB Garamond and Inter), with no web-font link: a link declared after the renderer's own font faces can override them whenever it loads in time, which made early frames differ between workers. Every character in the panel was checked against the font files the renderer actually embeds.

Source files only

This PR adds only registry/blocks/powder-dissolve/{powder-dissolve.html, registry-item.json}. The generated catalog files (registry index, search vectors, payload, docs page) are left to the catalog automation in #4337. The preview video and poster use the standard CDN paths and still need uploading; the preview should be the maths version.

Pattern: raw WebGL1 full-screen fragment shader like registry/blocks/domain-warp-dissolve, with the repaint on a GSAP-tweened property setter like registry/blocks/spiral-galaxy, so seeking repaints too.

Before

Searching the catalog for "powder dissolve" finds only unrelated dissolve transitions:

Before: catalog search for powder dissolve

After

Default look (0.3 s landing, 1.2 s floating, 2.5 s wetting and cracks, 3.5 s fingers, 4.5 s bloom, 9 s even tint):

After: default look over time

after-clean.mp4

Maths on:

After: maths on over time

after-maths.mp4

Review

An independent adversarial review ran against this branch. Findings and their fixes:

  • Blocker, fixed: with the maths panel on, 1-worker and 4-worker renders could differ in the first frames' text, because the Google Fonts link overrode the renderer's own font faces when it loaded in time. The link is gone and the panel uses fonts the renderer supplies.
  • Major, fixed: the panel left out terms the code computes (the extra wetting paths and the marbling step). It now shows them.
  • Major, fixed: an empty dissolve value (null) was read as 0 and clamped to 3 s instead of the default 8 s.
  • Major, documented: a pale powder barely tints the milk; the powder colour's description says to pick one darker than the milk.
  • Major, out of scope here: wrong-typed values given to hyperframes add --vars are not checked for boolean, colour or string variables. Follow-up in its own PR.
  • Minors, fixed: 3-digit hex colours, defaults kept in one place in the file, missing constants in the panel text.
Audited Trusting Not exercised
Shader edge cases (pow, log, division, loop bounds), variable parsing, schema and consumers of every variable, 1 vs 4 workers with maths on and off, forward vs reverse seeking, NaN and infinite seeks, lint and format That the font copies the renderer downloads stay the same between days (the fetch happens once per render, before the work is split across workers) Real GPUs (all rendering here used software GL)

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at d1594c3. Source only: powder-dissolve.html and registry-item.json. Approving.

Checked against the body

  • Frames depend only on time: draw(t) depends on t and on values fixed at init: the clump layout from mulberry32(SEED), the variables, and hash-based noise in the shader. Nothing carries over from one frame to the next, and nothing calls Math.random, Date or performance.now. Repainting hangs off the tweened driver.t setter, so tl.seek() repaints as well (same pattern as spiral-galaxy). preserveDrawingBuffer: true covers captures taken after a seek.
  • Review fixes are in: clampNum uses parseFloat, so null, "" and booleans fall back to the 8 s default instead of reading as 0. hexRgb accepts #rgb and #rrggbb and falls back to the declared default. There's no web-font <link>, and the panel only asks for EB Garamond and Inter.
  • Variables: the four declarations in data-composition-variables match registry-item.json field for field. Defaults are read from the root attribute only, so there's a single source.
  • Maths panel innerHTML: it only ever gets the static EQS strings. The live values that depend on variables go through textContent, so no variable value reaches HTML.
  • Shader: loop bounds are constant, and indexing uBatchSpread[b] with the loop index is valid in GLSL ES 1.0. log / pow are guarded (max(uPowder, 0.02)), and atan(m.y, m.x) only runs when along > 0, so it never sees the zero vector.
  • Catalog files: leaving the generated files to #4337 matches how it works now (#4337 merged 16:09Z today).

Nits, non-blocking

  1. MATHS = vars.maths === true only accepts a real boolean. Anything that hands the string "true" (the untyped --vars path the body defers) silently shows no panel. The siblings are more forgiving: halftone-field.html:99 accepts true|1|on|yes and cuboid-carousel.html:122 accepts "true"/"false". Matching one of them would make the flag work from the CLI today, whatever happens in the follow-up.
  2. The preview .mp4/.png point at CDN paths that aren't uploaded yet, so the catalog card shows no preview until someone uploads them. The body already says so; this is a note to track it, not a change request.

— Rames

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants