Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 63 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,69 @@ change carries its speed as an index into that list, and it meant both
durations at once. `checks.py` guards the constant the way it guarded
the menu.

## Four orthogonal controls drive animation
## Ripple moves cells and density, not the field

Loop, Drift and Wind move where the field is sampled. Pulse and Ripple
leave the field alone, which is why both work with no field strength
and why `buildGrid` reads the field at phase 0 for them: an identical
field sampled every frame was most of a Pulse frame's cost before that.

Ripple went through the field first and was invisible. A radial
displacement of the sampling coordinates, at any amplitude short of
mush, changes almost nothing a dither shows, because the dither stays
put and only the shading under it shifts. A lateral wave and a zoom
failed the same way. What reads is either changing how many cells fill
(the crest's swell, as Pulse does) or moving the finished cells
themselves (`rippleWarp`, which pulls each drawn cell along the
water's slope, dither and all). Ripple does both, and the warp is what
makes every texture move under it, traced ones included, so it needs
no per-texture answer.

Ripple is rain: several seeded drops, each born at its own moment and
living one cycle, summed where the rings cross. Each is periodic on its
own so the sum closes, but rings are in flight at phase 0, so Ripple's
first frame is not the still, as Drift's is not. The rings are
evaluated on a lattice `RIPPLE_STEP` cells apart and interpolated;
evaluating every drop at every cell put a frame far over the 80ms
budget. Both layers share A's drops, B at 70% of the swell and pull.

## Tide is the one motion where the layers touch

Every other motion builds a layer from its own settings alone. Tide
reads the other layer too: `generate()` hands each `buildGrid` a
`deform`, the other layer's normalised field gradient (`tidePush`,
cached per composition, symmetry applied so it is the field the canvas
shows), scaled by a raised-cosine gate, and `getFlowField` reads the
field from the pushed coordinates. The hook is null for every other
motion and every still, which is what keeps those bit-identical; a
field read with a deform is never cached, since its phase is in the
scale. A layer with no field strength has a flat field and pushes
nothing, so the other layer holds still under it. The push is away
from the other's ridges; toward was tried and gathers both layers onto
the same lines.

## Contours' edge is thin on purpose

The line was a linear falloff across its whole width, and under any
motion it sparkled at every speed: a line shifting a quarter of a cell
per frame re-rolled the dither across its whole width, and the flips
landed as scatter. Cells flipping per frame scale with speed either
way, so the slider worked and looked dead. The edge is now dithered
over `CONTOUR_FEATHER` of a level and no more, so the same shift moves
the line. Don't widen it to soften the look without watching it move.

## Six orthogonal controls drive animation

Don't re-entangle them. **Motion** (General tab) is what a pass looks like,
**Speed** beneath it is how long an animated pass lasts, **Reroll every** is
how long a still one is held, and the **GIF** menu is how many passes to
record. A GIF export is capped at `GIF_FRAME_BUDGET` (240) frames; smoothness
**Speed** beneath it is how long an animated pass lasts, **Depth** is how
far it goes (every kind but Drift, whose travel is its period and cannot
change), **Direction** runs the phase backwards, **Reroll every** is how
long a still one is held, and the **GIF** menu is how many passes to
record. Depth is applied inside the generator as `options.depth`, a
multiplier whose 1 leaves every constant's arithmetic exactly as it was,
and Direction as `options.direction`, which maps the phase to one minus
itself. Both are tail fields whose zero is the default, so a link from
before them opens as it did. A GIF export is capped at `GIF_FRAME_BUDGET` (240) frames; smoothness
gives way before file size does, and at the default settings that cap is not
what binds, the per-pass smoothness is.

Expand Down Expand Up @@ -214,7 +271,8 @@ only, and every tail field's zero must mean "what this was before the
field existed", because base64 pads the last byte with zeros and an old
link's tail is read straight out of that padding. A tail that is all
zeros is not written at all, though nothing leaves it empty today: the
two durations are always written, so every new link is 82 characters.
two durations are always written, so every new link is the same length
(83 characters since depth and direction joined the tail).

The escape hatch is `COMPACT_VERSION`. Bumping it says the break is
deliberate, and both checks stand down: the decoder refuses a version it
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,16 @@ mechanic it runs on.

## Examples

Four exported GIFs, one per motion, each a single 3-second cycle at half the
Six exported GIFs, one per motion, each a single 3-second cycle at half the
app's own frame rate to keep the files small. Every setting and both seeds
hold; only the phase moves, and the last frame runs back into the first with
no cut.

<!-- HTML rather than a markdown table so the columns can be given equal
widths; a markdown table sizes them by caption length, and the GIFs
scale to whatever each column ends up with. Two columns here against
the three below: four kinds split evenly, and each clip renders about
half again as large, which these need since the motion is the whole
point of them. -->
the three below: each clip renders about half again as large, which
these need since the motion is the whole point of them. -->
<table>
<tr>
<th width="50%">Loop</th>
Expand All @@ -58,6 +57,18 @@ no cut.
<td width="50%">The yellow and magenta streaks travel the way Drift's do, but a much broader gust bends the field as they go, rising and settling once across the cycle, so the fine wisps run ahead of the bands they belong to.</td>
<td width="50%">Density swings on one sine wave, Layer B at 70% of the depth, so the streaks swell and thin without moving.</td>
</tr>
<tr>
<th width="50%">Ripple</th>
<th width="50%">Tide</th>
</tr>
<tr>
<td width="50%"><img src="examples/ripple.gif" width="100%" alt="Ripple"></td>
<td width="50%"><img src="examples/tide.gif" width="100%" alt="Tide"></td>
</tr>
<tr>
<td width="50%">Rain: seven drops land at their own moments, and each ring bends and swells what it crosses, thins as it spreads, and adds where it meets another, while the field beneath never moves.</td>
<td width="50%">Each layer's field is pushed along the other's ridges, rising and settling once per cycle, so the blue and pink bands bend around each other and let go.</td>
</tr>
</table>

Three more, each thirteen Auto-randomize passes at a two-second interval,
Expand Down Expand Up @@ -121,9 +132,10 @@ of the sidebar or the end of the settings panel on a phone.

### Motion

- Loop, Drift, Wind, or Pulse move a composition instead of replacing it, cycling back with no cut
- Loop, Drift, Wind, Pulse, Ripple, or Tide move a composition instead of replacing it, cycling back with no cut
- Plays on the canvas the moment you pick one, which is exactly what a GIF records
- Separate sliders for how fast it moves and how often it rerolls, both from one second to sixteen, with the reroll stepping in doublings
- Depth scales how far a motion goes, and Direction runs it backwards, so Drift and Wind can travel against the flow

### Exporting and sharing

Expand Down Expand Up @@ -153,7 +165,7 @@ rather than showing a screenshot of it:
on why a 4-way mirror guarantees a tile repeats with no seam, and what
breaks it.
- [How to make a looping animated background](https://flield.com/animated-backgrounds/),
on the four motions, why they return to their first frame exactly, and
on the six motions, why they return to their first frame exactly, and
which file format a hero background actually wants.

## Running locally
Expand Down
49 changes: 33 additions & 16 deletions animated-backgrounds/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
</script>
<title>How to make a looping animated background for a website</title>
<meta name="description" content="Four kinds of motion that return to their first frame with no cut, when a seamless loop is worth it, and which file format a hero background wants." />
<meta name="description" content="Six kinds of motion that return to their first frame with no cut, when a seamless loop is worth it, and which file format a hero background wants." />
<link rel="canonical" href="https://flield.com/animated-backgrounds/" />
<link rel="icon" type="image/svg+xml" href="../favicon.svg" />
<link rel="apple-touch-icon" sizes="192x192" href="../icon-192.png" />
Expand All @@ -26,14 +26,14 @@
<meta property="og:url" content="https://flield.com/animated-backgrounds/" />
<meta property="og:site_name" content="Flield" />
<meta property="og:title" content="How to make a looping animated background for a website" />
<meta property="og:description" content="Four kinds of motion that return to their first frame with no cut, when a seamless loop is worth it, and which file format a hero background wants." />
<meta property="og:description" content="Six kinds of motion that return to their first frame with no cut, when a seamless loop is worth it, and which file format a hero background wants." />
<meta property="og:image" content="https://flield.com/screenshot.png?v=8" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="900" />
<meta property="og:image:alt" content="A generated three-color composition in Flield" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="How to make a looping animated background for a website" />
<meta name="twitter:description" content="Four kinds of motion that return to their first frame with no cut, when a seamless loop is worth it, and which file format a hero background wants." />
<meta name="twitter:description" content="Six kinds of motion that return to their first frame with no cut, when a seamless loop is worth it, and which file format a hero background wants." />
<meta name="twitter:image" content="https://flield.com/screenshot.png?v=8" />
<meta name="theme-color" content="#09090b" />

Expand All @@ -46,7 +46,7 @@
"@type": "TechArticle",
"@id": "https://flield.com/animated-backgrounds/#article",
"headline": "How to make a looping animated background for a website",
"description": "Four kinds of motion that return to their first frame with no cut, when a seamless loop is worth it, and which file format a hero background wants.",
"description": "Six kinds of motion that return to their first frame with no cut, when a seamless loop is worth it, and which file format a hero background wants.",
"url": "https://flield.com/animated-backgrounds/",
"mainEntityOfPage": "https://flield.com/animated-backgrounds/",
"about": { "@id": "https://flield.com/#app" },
Expand Down Expand Up @@ -87,16 +87,23 @@
</script>
<link rel="stylesheet" href="../guide.css?v=4" />
<style>
/* The four motions sit in a 2x2 rather than the shared .demo-canvases
pair, since they are four peers being compared rather than a
before/after. Moves into guide.css if a fourth page ever wants it. */
/* The six motions sit in a two-column grid rather than the shared
.demo-canvases pair, since they are peers being compared rather
than a before/after. Should the count go odd again, the last one
takes a full row; draw its canvas twice as wide then, so the
cells stay the same size. Moves into guide.css if a fourth page
ever wants it. */
.motion-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--border);
}

.motion-grid .demo-pane:last-child:nth-child(odd) {
grid-column: 1 / -1;
}

@media (max-width: 520px) {
.motion-grid { grid-template-columns: 1fr; }
}
Expand Down Expand Up @@ -135,11 +142,11 @@ <h2 id="whether">When a loop is worth it</h2>

<p>What follows assumes you want the repeat, because that is the case this tool was built for. If you do not, most of it still applies except the part about closing the cycle.</p>

<h2 id="motions">The four motions</h2>
<h2 id="motions">The six motions</h2>

<p>Loop, Drift, Wind and Pulse are Flield's names for four ways of moving a composition. The names are the app's own. The idea underneath, motion built from something periodic, is not.</p>
<p>Loop, Drift, Wind, Pulse, Ripple and Tide are Flield's names for six ways of moving a composition. The names are the app's own. The idea underneath, motion built from something periodic, is not.</p>

<p>All four below are running live, in this page, on the same composition. Only the kind of movement differs. Each completes one full cycle and starts over, and the point is that you can't tell where.</p>
<p>All six below are running live, in this page, on the same composition. Only the kind of movement differs. Each completes one full cycle and starts over, and the point is that you can't tell where.</p>

<div class="demo">
<div class="motion-grid">
Expand All @@ -159,12 +166,20 @@ <h2 id="motions">The four motions</h2>
<p class="demo-label">Pulse</p>
<canvas id="motion-pulse" width="260" height="170"></canvas>
</div>
<div class="demo-pane">
<p class="demo-label">Ripple</p>
<canvas id="motion-ripple" width="260" height="170"></canvas>
</div>
<div class="demo-pane">
<p class="demo-label">Tide</p>
<canvas id="motion-tide" width="260" height="170"></canvas>
</div>
</div>
<div class="demo-controls">
<button type="button" class="btn" id="playBtn">Pause</button>
<button type="button" class="btn" id="rerollBtn">Reroll</button>
</div>
<p class="demo-caption">Loop breathes around a closed path and returns where it started. Drift travels one way and wraps into itself. Wind travels and bends as it goes, like a gust crossing the frame. Pulse holds still and swells.</p>
<p class="demo-caption">Loop breathes around a closed path and returns where it started. Drift travels one way and wraps into itself. Wind travels and bends as it goes, like a gust crossing the frame. Pulse holds still and swells. Ripple drops rain on the surface and lets the rings cross. Tide bends each layer around the other's ridges and lets go.</p>
</div>

<h2 id="length">How long a cycle lasts</h2>
Expand All @@ -173,13 +188,15 @@ <h2 id="length">How long a cycle lasts</h2>

<p>An export uses that same number. A GIF records whole cycles. Set four seconds and you get a four second file. Drop it into a page and let it repeat.</p>

<p>Two more controls sit beside it. <strong>Depth</strong> is how far the motion goes, from a quarter of its usual reach to twice it. <strong>Direction</strong> runs the cycle backwards, which is how Drift and Wind travel against the flow without the still changing.</p>

<p>The slider starts at one second rather than lower. A cycle is one change of brightness across the whole frame, and one a second stays well under the three that <a href="https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html" target="_blank" rel="noopener">WCAG 2.3.1</a> allows. Worth keeping in mind for whatever you build with the file, too.</p>

<p><strong>Reroll every</strong>, just below it, does something else. That slider says how often Auto-randomize throws the composition away and builds a new one. Speed says how fast the one you have moves. Most backgrounds want one of these and not the other, so they are two separate sliders.</p>

<p>What moves depends on the layer's <a href="../flow-fields/">texture</a> too. Four of the six shade the grid. For those, a motion moves the field and the pattern follows along.</p>
<p>What moves depends on the layer's <a href="../flow-fields/">texture</a> too. Four of the six shade the grid. For those, Loop, Drift and Wind move the field and the pattern follows along. Pulse and Ripple leave the field alone and swing the density over it. Tide bends each layer's field with the other layer's.</p>

<p><strong>Streamlines</strong> and <strong>Weave</strong> trace particles instead. Drift and Wind send the particles running along the field. Pulse grows and shrinks their trails. The cycle still closes. Each particle lives for exactly one cycle, then starts again from where it began.</p>
<p><strong>Streamlines</strong> and <strong>Weave</strong> trace particles instead. Drift and Wind send the particles running along the field. Pulse grows and shrinks their trails. Ripple bends them as its rings pass. Tide sweeps them along the other layer's ridges. The cycle still closes. Each particle lives for exactly one cycle, then starts again from where it began.</p>

<h2 id="why">Why these close on their own</h2>

Expand Down Expand Up @@ -278,7 +295,7 @@ <h2 id="try">Try it</h2>
</footer>
</main>

<script src="../generator.js?v=12"></script>
<script src="../generator.js?v=13"></script>
<script>
// Phosphor's own path data, the same two glyphs and the same
// flield-theme key the app and the guide use, so a choice made on any
Expand All @@ -296,12 +313,12 @@ <h2 id="try">Try it</h2>
const themeToggleBtn = document.getElementById("themeToggleBtn");
const playBtn = document.getElementById("playBtn");

const MODES = ["circle", "drift", "wind", "pulse"];
const MODES = ["circle", "drift", "wind", "pulse", "ripple", "tide"];
const BLOCK = 2;
// One cycle per this many milliseconds, matching the app's own slowest
// comfortable reading of a loop rather than racing through it.
const CYCLE_MS = 6000;
// Regenerating four grids per frame is the expensive part, so the demos
// Regenerating six grids per frame is the expensive part, so the demos
// step at a fixed rate well below the display's, which reads as smooth
// for motion this slow and leaves the page responsive.
const FPS = 20;
Expand Down
Binary file added examples/ripple.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/tide.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion flow-fields/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ <h2 id="try">Try it</h2>
</footer>
</main>

<script src="../generator.js?v=12"></script>
<script src="../generator.js?v=13"></script>
<script>
// Phosphor's own path data, the same two glyphs and the same
// flield-theme key the app and the guide use, so a choice made on any
Expand Down
Loading
Loading