# Sfuma — guide for coding agents Sfuma turns a JSON document into an animated, grainy gradient rendered on one canvas with WebGL2. It is meant for hero backgrounds and abstract decorative surfaces. This page is the complete reference. Everything below is current for the shipped package — the layer and effect tables are generated from the renderer's own capability metadata. - Site: https://sfuma.dev - Live editor: https://sfuma.dev/editor - Examples: https://sfuma.dev/examples ## How to use this document To build a composition for someone's project: 1. Take their brand colours. 2. Start from the closest recipe below. 3. Replace the colours, keeping the blend rule in "Ground and blend modes" — this is the single most common way to end up with a blank canvas. 4. Validate with `validateSfumaConfig` before shipping. It returns path-aware errors. ## Install ```bash npm install sfuma ``` The package has no framework dependency. It needs a WebGL2-capable canvas and nothing else. ## Minimal integration ```ts import { createSfuma } from "sfuma"; import composition from "./hero.json"; const canvas = document.querySelector("#hero") as HTMLCanvasElement; const sfuma = createSfuma({ canvas, config: composition }); sfuma.play(); new ResizeObserver(() => sfuma.resize()).observe(canvas); ``` The canvas should be sized by CSS; Sfuma reads that box and manages the drawing buffer itself. Give it `display: block; width: 100%; height: 100%` inside a positioned container. ### React ```tsx import { useEffect, useRef } from "react"; import { createSfuma, type SfumaConfig } from "sfuma"; export function SfumaBackground({ config }: { config: SfumaConfig }) { const canvas = useRef(null); useEffect(() => { if (!canvas.current) return; const sfuma = createSfuma({ canvas: canvas.current, config }); sfuma.play(); const observer = new ResizeObserver(() => sfuma.resize()); observer.observe(canvas.current); return () => { observer.disconnect(); sfuma.destroy(); }; }, [config]); return ; } ``` ### Vue ```vue ``` ## Composition shape ```json { "version": 1, "seed": 4821, "duration": 12, "speed": 1, "canvas": { "background": "#0b0b12", "fit": "cover" }, "renderer": { "quality": "auto", "maxPixelRatio": 2 }, "layers": [], "effects": [], "animations": [] } ``` | field | type | default | meaning | | --- | --- | --- | --- | | `version` | `1` | **required** | Schema version. Always `1`. | | `seed` | number | `1` | Integer. Every procedural element derives from it plus its own id, so the same seed always renders the same composition. | | `duration` | number | none | Seconds, for editors and transports. Drivers carry their own durations, so playback does not need it. | | `speed` | number | `1` | Multiplier for everything time-driven: animations, temporal noise, animated grain. `0` freezes without pausing. Scales all rates together, preserving their ratios. | | `loop` | number | none | Period, in the same scaled seconds as driver durations, after which animated grain repeats. Only needed when capturing to video: grain is re-rolled from the time step, so without a wrap the last frame and the first are unrelated and the loop point pops. Leave it out for a canvas on a page — it runs forever and has no loop point. | | `canvas.background` | `#RGB` \| `#RRGGBB` \| `#RRGGBBAA` | transparent | Painted behind every layer. Its lightness decides whether colour layers want `screen` or `multiply`. | | `canvas.fit` | `cover` \| `contain` \| `stretch` | `cover` | How the unit-square composition maps to the canvas. `cover` crops, `contain` letterboxes, `stretch` distorts. | | `renderer.quality` | `auto` \| `low` \| `medium` \| `high` | `auto` | `auto` picks `medium` above 2x device pixel ratio and `high` otherwise. Changes sampling cost only, never the composition. | | `renderer.pixelRatio` | `auto` \| number | `auto` | `auto` follows the device, then the tier's cap applies. | | `renderer.maxPixelRatio` | number | tier | Overrides the tier's cap: `1` at low, `1.5` at medium, `2` at high. | | `renderer.resolutionScale` | number | tier | Multiplies the drawing buffer before the cap: `0.65` at low, `0.85` at medium, `1` at high. | | `renderer.fps` | number | uncapped | Ceiling on rendered frames per second. These compositions drift slowly, so a background at `30` is usually indistinguishable from one at the display refresh and costs half as much. Composition time still advances in real seconds, so this changes cost, not pace. | | `layers` | Layer[] | **required** | Ordered, **bottom first**. May be empty, which renders just the background. | | `effects` | Effect[] | `[]` | Global chain, applied to the composited scene in array order. | | `animations` | Animation[] | `[]` | Property animations addressed by stable ID. | Positions and sizes are normalised: `0`–`1` across the composition, `0.5, 0.5` is the centre. Composition space is square, so shapes stay circular on any viewport. ### Ranges are guidance, not limits The `range` column below is an authoring range — sensible bounds for a slider, not a validation limit. Values outside it are accepted and render: a blob half off-canvas is a normal effect, so `x` beyond `0`–`1` is legitimate. Where a value is genuinely constrained, validation enforces it — `opacity` within `0`–`1`, `kaleidoscope.segments` at least `2`, colours must parse. Do not rely on `validateSfumaConfig` to catch a number that is merely implausible. It catches what is invalid. ### Fields every layer accepts | field | type | default | meaning | | --- | --- | --- | --- | | `id` | string | **required** | Unique across layers. Animation targets and the seed both depend on it. | | `type` | `solid` \| `linear-gradient` \| `radial-gradient` \| `ellipse` \| `aurora` \| `blob` | **required** | Decides which parameters apply. | | `enabled` | boolean | `true` | `false` skips the layer without removing it. | | `opacity` | number | `1` | `0`–`1`, applied before the blend. | | `blend` | `normal` \| `screen` \| `multiply` \| `overlay` | `normal` | See the ground rule below. | | `effects` | Effect[] | `[]` | Applied to this layer alone, before it is composited. | ### Fields every effect accepts | field | type | default | meaning | | --- | --- | --- | --- | | `id` | string | none | Only needed to animate the effect or refer to it. Unique within its chain. | | `type` | see the effect catalogue below | **required** | Decides which parameters apply. | | `enabled` | boolean | `true` | `false` skips the effect without removing it. | ## Ground and blend modes **This is the rule to get right.** Blend modes available: normal, screen, multiply, overlay. - `screen` computes `1 - (1 - a)(1 - b)`. On a **white or light background it can only produce white** — the composition disappears. - `multiply` has the mirror problem: on a **black or dark background it produces black**. So: | background | use | | --- | --- | | dark (lightness < 0.5) | `screen` for luminous colour masses | | light (lightness > 0.5) | `multiply` for ink-like colour masses | If you change the background from dark to light, you **must** flip the accent layers' blend mode too. `overlay` works on either but is higher contrast. `normal` never disappears but gives flat, non-luminous overlaps. ## Layers ### `solid` — Solid | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `fill` | color | — | **required** | no | | `opacity` | number | 0 – 1 | `1` | yes | ### `linear-gradient` — Linear gradient | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `from` | { x, y } | — | **required** | no | | `to` | { x, y } | — | **required** | no | | `stops` | gradient stops | — | **required** | no | | `opacity` | number | 0 – 1 | `1` | yes | ### `radial-gradient` — Radial gradient | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `x` | number | -0.5 – 1.5 | **required** | yes | | `y` | number | -0.5 – 1.5 | **required** | yes | | `radius` | number | 0.05 – 2 | **required** | yes | | `stops` | gradient stops | — | **required** | no | | `opacity` | number | 0 – 1 | `1` | yes | ### `ellipse` — Ellipse | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `x` | number | -0.5 – 1.5 | **required** | yes | | `y` | number | -0.5 – 1.5 | **required** | yes | | `width` | number | 0.02 – 2 | **required** | yes | | `height` | number | 0.02 – 2 | **required** | yes | | `rotation` | number | 0 – 360 | `0` | yes | | `fill` | color | — | **required** | no | | `opacity` | number | 0 – 1 | `1` | yes | ### `aurora` — Aurora | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `bands` | number | 1 – 8 | **required** | yes | | `spread` | number | 0.02 – 0.6 | **required** | yes | | `waviness` | number | 0.2 – 8 | **required** | yes | | `sharpness` | number | 0.01 – 0.3 | **required** | yes | | `height` | number | 0.1 – 1.2 | **required** | yes | | `speed` | number | 0 – 1 | `0.05` | yes | | `x` | number | -0.5 – 1.5 | `0.5` | yes | | `y` | number | -0.5 – 1.5 | `0.5` | yes | | `rotation` | number | -45 – 45 | `0` | yes | | `stops` | gradient stops | — | **required** | no | | `opacity` | number | 0 – 1 | `1` | yes | ### `blob` — Blob | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `x` | number | -0.5 – 1.5 | **required** | yes | | `y` | number | -0.5 – 1.5 | **required** | yes | | `size` | number | 0.1 – 1.6 | **required** | yes | | `irregularity` | number | 0 – 0.6 | **required** | yes | | `complexity` | number | 1 – 8 | **required** | yes | | `rotation` | number | 0 – 360 | `0` | yes | | `fill` | color | — | **required** | no | | `opacity` | number | 0 – 1 | `1` | yes | Every layer also accepts `id` (required, unique), `enabled`, `opacity`, `blend`, and an optional `effects` array that applies before the layer is composited. ## Effects ### `blur` — Blur | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `radius` | number | 0 – 0.4 | **required** | yes | ### `displace` — Displace | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 0.2 | **required** | yes | | `noise.type` | white \| simplex \| fbm \| turbulence | — | `"fbm"` | no | | `noise.scale` | number | 0.2 – 8 | `1` | no | | `noise.octaves` | number | 1 – 8 | `4` | no | | `noise.gain` | number | 0.1 – 0.9 | `0.5` | no | | `noise.lacunarity` | number | 1.2 – 3 | `2` | no | | `noise.speed` | number | 0 – 1 | `0` | no | ### `domain-warp` — Domain warp | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 0.4 | **required** | yes | | `scale` | number | 0.2 – 5 | **required** | yes | | `iterations` | number | 1 – 4 | **required** | no | | `noise.type` | white \| simplex \| fbm \| turbulence | — | `"fbm"` | no | | `noise.scale` | number | 0.2 – 8 | `1` | no | | `noise.octaves` | number | 1 – 8 | `4` | no | | `noise.gain` | number | 0.1 – 0.9 | `0.5` | no | | `noise.lacunarity` | number | 1.2 – 3 | `2` | no | | `noise.speed` | number | 0 – 1 | `0` | no | ### `hue` — Hue | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | -180 – 180 | **required** | yes | ### `saturation` — Saturation | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 2.5 | **required** | yes | ### `contrast` — Contrast | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 2.5 | **required** | yes | ### `brightness` — Brightness | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 2.5 | **required** | yes | ### `swirl` — Swirl | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | -1.5 – 1.5 | **required** | yes | | `radius` | number | 0.05 – 1.5 | `0.5` | yes | | `x` | number | 0 – 1 | `0.5` | yes | | `y` | number | 0 – 1 | `0.5` | yes | ### `ripple` — Ripple | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 0.15 | **required** | yes | | `frequency` | number | 1 – 24 | `6` | yes | | `speed` | number | -2 – 2 | `0.3` | yes | | `x` | number | 0 – 1 | `0.5` | yes | | `y` | number | 0 – 1 | `0.5` | yes | ### `pinch` — Pinch / bulge | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | -1 – 1 | **required** | yes | | `radius` | number | 0.05 – 1.5 | `0.5` | yes | | `x` | number | 0 – 1 | `0.5` | yes | | `y` | number | 0 – 1 | `0.5` | yes | ### `chromatic-aberration` — Chromatic aberration | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 0.1 | **required** | yes | ### `vignette` — Vignette (finishing pass) | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 1 | **required** | yes | | `radius` | number | 0.05 – 1.2 | `0.5` | yes | | `softness` | number | 0.01 – 1 | `0.35` | yes | | `x` | number | 0 – 1 | `0.5` | yes | | `y` | number | 0 – 1 | `0.5` | yes | ### `flow` — Flow | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 0.5 | **required** | yes | | `scale` | number | 0.2 – 6 | `1.6` | yes | | `steps` | number | 1 – 8 | `4` | no | | `noise.type` | white \| simplex \| fbm \| turbulence | — | `"fbm"` | no | | `noise.scale` | number | 0.2 – 8 | `1` | no | | `noise.octaves` | number | 1 – 8 | `4` | no | | `noise.gain` | number | 0.1 – 0.9 | `0.5` | no | | `noise.lacunarity` | number | 1.2 – 3 | `2` | no | | `noise.speed` | number | 0 – 1 | `0` | no | ### `kaleidoscope` — Kaleidoscope | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `segments` | number | 2 – 24 | **required** | yes | | `amount` | number | -1 – 1 | **required** | yes | | `x` | number | 0 – 1 | `0.5` | yes | | `y` | number | 0 – 1 | `0.5` | yes | ### `pixelate` — Pixelate | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `size` | number | 1 – 120 | **required** | yes | ### `ascii` — ASCII | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `size` | number | 6 – 80 | **required** | yes | | `charset` | dots \| blocks \| bars \| cross \| rings | — | `"dots"` | no | | `background` | number | 0 – 1 | `0.12` | yes | | `invert` | boolean | — | `false` | no | | `monochrome` | boolean | — | `false` | no | ### `moire` — Moire | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 1.2 | **required** | yes | | `frequency` | number | 2 – 200 | `6` | yes | | `angle` | number | -45 – 45 | `12` | yes | | `speed` | number | -0.2 – 0.2 | `0.3` | yes | | `pattern` | linear \| radial | — | `"linear"` | no | | `x` | number | 0 – 1 | `0.5` | yes | | `y` | number | 0 – 1 | `0.5` | yes | ### `halftone` — Halftone | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0.2 – 2 | **required** | yes | | `frequency` | number | 5 – 200 | `6` | yes | | `angle` | number | 0 – 90 | `12` | yes | | `background` | number | 0 – 1 | `0.12` | yes | | `x` | number | 0 – 1 | `0.5` | yes | | `y` | number | 0 – 1 | `0.5` | yes | ### `grain` — Grain (finishing pass) | property | type | range | default | animatable | | --- | --- | --- | --- | --- | | `amount` | number | 0 – 0.4 | **required** | yes | | `size` | number | 0.5 – 4 | **required** | yes | | `kind` | mono \| rgb | — | **required** | no | | `animated` | boolean | — | `false` | no | Every effect also accepts `id` (needed if you want to animate it) and `enabled`. ### Units and traps, per effect - **blur** `radius` is normalised to the shorter edge, so `0.16` blurs over 16% of the frame. Useful range 0.05–0.30. It is not pixels. - **grain** `amount` is peak-to-peak intensity applied to straight alpha. Subtle is 0.02–0.06; 0.12–0.30 is the defining "grainy gradient" look. `kind: "rgb"` gives coloured film speckle and is usually what you want; `mono` shifts luminance only. `size` is in pixels. - **pixelate** and **ascii** `size` are in **rendered pixels**, not fractions. `8` is an 8px block. An ASCII glyph is 5x5, so a cell below about 10px cannot resolve one. - **ascii** `charset` picks a glyph family. `dots` and `cross` keep a gutter between cells; `blocks` and `bars` join up into continuous bars, which is their look. - **displace** and **domain-warp** `amount` is normalised UV displacement. Keep under ~0.15 for displace or the image tears. - **flow** advects along a curl-noise field and produces long silky filaments. `amount` around 0.08–0.25 with 4–6 `steps`. - **kaleidoscope** only ever shows **one wedge, mirrored**. Spread your colours by *radius* inside a single wedge, not by angle, or every segment shows the same flat patch. - **vignette** and **grain** are finishing passes: put them last in the chain. ## Animation Animations target a property by stable ID, never by array position, so reordering layers cannot silently retarget one. Target forms: - `layers..` - `effects..` - `layers..effects..` A target that does not resolve is a **validation error**, not a silent no-op. Drivers: sine, ping-pong, noise, keyframes. Each takes its own fields; every driver also accepts `id`, `enabled` (default `true`) and `delay` (default `0`). | driver | fields | default | behaviour | | --- | --- | --- | --- | | `sine` | `from`, `to`, `duration`, `phase` | `phase` `0` | One full cycle per `duration`. `phase` offsets the start, in cycles. | | `ping-pong` | `from`, `to`, `duration` | **all required** | Travels out over `duration` and back over another, so its period is twice `duration`. | | `noise` | `base`, `amplitude`, `speed`, `seed` | `seed` `0` | Smooth wander around `base`, never repeating. `seed` decorrelates it from other noise drivers. | | `keyframes` | `keyframes`, `interpolation` | `"linear"` | Each keyframe is `{ time, value }`, in seconds. | ```json { "animations": [ { "target": "layers.coral.x", "driver": "sine", "from": 0.3, "to": 0.6, "duration": 10, "phase": 0.25 }, { "target": "effects.warp.amount", "driver": "ping-pong", "from": 0.04, "to": 0.12, "duration": 7 }, { "target": "layers.coral.size", "driver": "noise", "base": 0.8, "amplitude": 0.1, "speed": 0.4 }, { "target": "layers.coral.irregularity", "driver": "keyframes", "keyframes": [{ "time": 0, "value": 0.1 }, { "time": 6, "value": 0.4 }] } ] } ``` `phase` is in **cycles**: `1.0` is a full period, `0.25` a quarter turn. For motion to read as motion, keep periods around 8–16 seconds and let positions travel at least ~0.2 of the frame. Smaller or slower than that under a heavy blur looks like a still. ## API ```ts const sfuma = createSfuma({ canvas, config }); sfuma.play(); // real-time playback, idempotent, one rAF loop sfuma.pause(); // stop, keep current time sfuma.seek(4); // jump to a time and draw it sfuma.render(6.5); // draw one frame without playing sfuma.setConfig(next); // replace the composition, validated first sfuma.set("layers.coral.x", 0.7); // one numeric property, same target syntax as animations sfuma.resize(); // after the canvas box changes sfuma.destroy(); // release GPU resources and listeners sfuma.getState(); // { playing, time, duration, quality, width, height } ``` Also exported: `validateSfumaConfig(input)` (path-aware errors, no WebGL needed), `parseSfumaConfig(input)` (throws `SfumaConfigError`), `getSfumaCapabilities()`, `createSfumaLayer(type, id)` and `createSfumaEffect(type, id)` for valid defaults. Errors are typed: `SfumaConfigError`, `SfumaUnsupportedError` (no WebGL2), `SfumaTargetError`. ## Determinism `render(t)` draws the exact frame for that time without needing previous frames. The same config, seed, time, viewport and quality always give the same result. There is no `Math.random()` anywhere in the renderer. This makes stills, poster frames and snapshot tests straightforward. ## Recipe: aurora An aurora is the `aurora` layer type — do not try to fake it with stretched ellipses. Bands are soft ridges that fold along an FBM field; `spread` is how far they wander, `waviness` how crinkled they are, `sharpness` how tight each ribbon is. ```json { "version": 1, "seed": 7731, "duration": 20, "speed": 1, "canvas": { "background": "#03060f", "fit": "cover" }, "renderer": { "quality": "auto", "maxPixelRatio": 2 }, "layers": [ { "id": "night", "type": "solid", "fill": "#03060f" }, { "id": "curtain", "type": "aurora", "bands": 3, "spread": 0.1, "waviness": 2.2, "sharpness": 0.045, "height": 0.6, "x": 0.46, "y": 0.6, "speed": 0.07, "rotation": -4, "blend": "screen", "opacity": 0.95, "stops": [ { "offset": 0, "color": "#0b3d2e" }, { "offset": 0.4, "color": "#3ff0a8" }, { "offset": 0.75, "color": "#49c9ff" }, { "offset": 1, "color": "#8b5cf6" } ] } ], "effects": [ { "id": "soften", "type": "blur", "radius": 0.045 }, { "id": "lift", "type": "contrast", "amount": 1.1 }, { "id": "texture", "type": "grain", "kind": "rgb", "amount": 0.15, "size": 1, "animated": true } ], "animations": [ { "target": "layers.curtain.x", "driver": "sine", "from": 0.36, "to": 0.58, "duration": 18 }, { "target": "layers.curtain.spread", "driver": "sine", "from": 0.07, "to": 0.14, "duration": 15, "phase": 0.3 } ] } ``` To give it "a twist", add one of these to the global chain **before** the grain: - `{ "id": "twist", "type": "swirl", "amount": 0.35, "radius": 0.8, "x": 0.5, "y": 0.55 }` - `{ "id": "twist", "type": "flow", "amount": 0.12, "scale": 1.8, "steps": 5, "noise": { "type": "fbm", "scale": 1.8, "octaves": 4, "gain": 0.5, "lacunarity": 2, "speed": 0.05 } }` - `{ "id": "twist", "type": "domain-warp", "amount": 0.12, "scale": 1.6, "iterations": 2, "noise": { "type": "fbm", "scale": 1.4, "octaves": 4, "gain": 0.55, "lacunarity": 2, "speed": 0.05 } }` Animate the twist for extra life, e.g. `{ "target": "effects.twist.amount", "driver": "sine", "from": 0.1, "to": 0.4, "duration": 16 }`. ## Recipe: soft mesh with brand colours The default choice for a hero on a **light** page. Replace the two `fill` values with brand colours and keep `blend: "multiply"`. ```json { "version": 1, "seed": 1204, "duration": 13, "speed": 1, "canvas": { "background": "#f6f2ea", "fit": "cover" }, "renderer": { "quality": "auto", "maxPixelRatio": 2 }, "layers": [ { "id": "paper", "type": "solid", "fill": "#f6f2ea" }, { "id": "brand-a", "type": "blob", "x": 0.28, "y": 0.34, "size": 0.95, "irregularity": 0.18, "complexity": 3, "rotation": 0, "fill": "#ff7aa8", "blend": "multiply", "opacity": 0.95 }, { "id": "brand-b", "type": "blob", "x": 0.72, "y": 0.4, "size": 0.9, "irregularity": 0.16, "complexity": 3, "rotation": 0, "fill": "#5b9bf5", "blend": "multiply", "opacity": 0.95 } ], "effects": [ { "id": "soften", "type": "blur", "radius": 0.24 }, { "id": "texture", "type": "grain", "kind": "rgb", "amount": 0.1, "size": 1, "animated": false } ], "animations": [ { "target": "layers.brand-a.x", "driver": "sine", "from": 0.18, "to": 0.55, "duration": 11 }, { "target": "layers.brand-b.y", "driver": "sine", "from": 0.28, "to": 0.6, "duration": 10, "phase": 0.7 } ] } ``` For a **dark** page, set `canvas.background` and the `solid` layer to a near-black tint of the brand hue, and change every `multiply` to `screen`. ## Checklist before shipping a composition - [ ] `validateSfumaConfig` returns no errors - [ ] Blend mode matches the ground (screen on dark, multiply on light) - [ ] There is a `solid` layer at the bottom matching `canvas.background` - [ ] Grain is the last effect in the chain - [ ] Every animation target resolves to a real layer or effect id - [ ] Motion periods are 8–16s and travel is visible under the blur - [ ] `seed` is set, so the render is reproducible