Skip to main content

Composing a pipeline

A chain is not a list of passes. Every node contributes GLSL to one of five stages, and the stages always run in the same order regardless of how you wrote them:

field → warp → cell → displace → color

So this:

[ { op: "duotone", colors: [...] }, { op: "offset", strength: 10 } ]

behaves the same as the reverse — offset is a warp and runs first either way. Order matters within a stage, not across stages.

Because everything is emitted into a single shader, a chain costs one pass no matter how long it is.

Fields: mask and noise

mask and noise write a scalar field instead of drawing. Any later node reads it with masked:

let raster = [
{ op: "mask", as: "centre", from: "radial", at: [0.5, 0.5], radius: 220 },
{ op: "halftone", size: 5, masked: "centre" }
];

Now the halftone only applies inside that circle. as names the field and defaults to "mask", so masked: true reads the unnamed one:

[ { op: "mask", from: "vertical" },
{ op: "dither", levels: 4, masked: true } ]

A field producer on its own renders nothing — if you see no change, check that something downstream actually consumes it.

merge: two branches from one source

A chain feeds each node the previous node's output. A merge runs two sub-chains from the same starting state and blends the results, so neither branch sees the other:

{ op: "merge", mode: "screen", mix: 1.0,
a: [ { op: "halftone", size: 5 } ],
b: [ { op: "duotone", colors: ["#0b1b2b", "#e6e9ed"] } ] }

mix fades the whole merge back toward branch A — mix: 0 is A alone, mix: 1 the full blend.

mode is one of:

over  add  screen  multiply  difference  lighten  darken

Blending applies to the colour stages only. Scalars and coordinates are interpolated plainly, because "screen" means nothing for a displacement.

The shader work is emitted inline, so a merge costs no extra pass, no extra framebuffer and no second capture of the DOM. For exactly that reason it cannot give the branches different source content — both start from the same pixels. If you need two different sources, you need two elements.

Use a chain when each step should build on the last. Use a merge when two treatments of the same content should be combined.

switch: pick a chain per device

switch is resolved before the shader is compiled, so the branch not taken costs nothing at all:

{ op: "switch",
when: "coarse",
use: [],
else: [ { op: "stir", force: 0.6, by: "mouse" } ] }

That reads: on touch devices do nothing, everywhere else run the fluid. An empty branch is the idiomatic way to say "no effect here".

when accepts a function, a boolean, a raw media query, or one of these aliases:

aliasquery
coarse(pointer: coarse)
fine(pointer: fine)
hover(hover: hover)
no-hover(hover: none)
reduced-motion(prefers-reduced-motion: reduce)
dark(prefers-color-scheme: dark)
light(prefers-color-scheme: light)

A predicate that throws resolves to false. Switches nest, up to a depth of 8 — sub-chains are plain data and could be shared by reference, so the cap is a cycle guard.

reduced-motion deserves a mention on its own: the expensive, moving ops (stir, echo, anything driven by time) are exactly what that setting exists to suppress.

You can check which branch a device takes without inspecting pixels:

import { resolveSwitches } from "nodality";

resolveSwitches(raster, 0); // the flat chain this device will actually run

Reference

Generated from the library source on each publish.

Blend modes for merge: over add screen multiply difference lighten darken

switch aliases:

aliasmedia query
coarse(pointer: coarse)
fine(pointer: fine)
hover(hover: hover)
no-hover(hover: none)
reduced-motion(prefers-reduced-motion: reduce)
dark(prefers-color-scheme: dark)
light(prefers-color-scheme: light)

Drivers

import { DRIVER_NAMES } from "nodality"; // static mouse hover scroll time

by chooses what moves the effect, and radius sets how far its influence reaches. static applies the effect evenly and never animates — useful when you want the look without the interaction, and the cheapest option since nothing needs re-uploading per frame.