Shading

shade() paints printed shading, light and grain from marks, and blur() blurs what is under an element where marks paint.

Marks such as a band, a glow or a path paint a field of how much ink each point gets. shade() draws that field as stipple, splatter, halftone or dither, or as light or grain, on its own canvas, and blur() uses it as the mask of a backdrop blur. Both return a handle with set(), redraw() and dispose().

shade(canvas, options)

shade(canvas, { scene, kind, …, marks }) paints shading from marks and draws it as stipple, splatter, halftone or dither, or as light or grain. Examples are crayon down one edge of a block, splatter thick at a hill's edge, a sphere shaded from an offset light, a stripe across the sky, a light leak from a corner, and grain heavier at the frame's edges. The marks paint a field of how much ink each point gets, and in the four printed kinds each speck, drop, dot or cell is printed in the color of the field under it, over a transparent paper. It returns a handle with set(params) and dispose().

const { scene, shade } = wildflower.kinetic;

scene('hills', {
    init() {
        shade(this.element.querySelector('canvas'), { scene: this, kind: 'splatter', fps: 2, marks: (m, t) => {
            m.edge({ width: 40, ink: '#2c5a46', falloff: 'power2.in', clip: { circle: [300, 240, 120] } })
             .band({ from: [0, 520], to: [1280, 500], width: 30, ink: '#d99a2b' })
             .glow({ at: [250, 200], radius: 150, invert: true, ink: '#1b3a2c', amount: 0.6, clip: { circle: [300, 240, 120] } });
        } });
    },
});
A glow with invert: true, cut to the circle, shades the sphere from the light at at, and the layer draws it in the kind chosen, cut at the outline by its mask. Density is for stipple, splatter and halftone, and fps for the kinds with noise. A click on the stage pauses.

The marks option is a function, marks(m, t), and every layer needs one; a layer without it, or with the marks as a third argument, throws KN-100. Kinetic calls it with the time to paint whenever the field needs painting: before every draw with freeze: 'seed', and once a scatter with freeze: 'all' or move. The time is the scene's local time for a layer made with scene: this, and the composition time otherwise; a layer's scene has the rule. Each call on m adds a mark and returns m.

Marks

MarkWhat it paints
band({ from, to, width, side })Along the line from from to to, width px to each side (side: 'both', the default) or to one ('left' or 'right', seen walking from from to to), falling off away from the line. Use it for a stripe, for one edge of a block (cut to the block), or for spray at the frame's edge.
glow({ at, radius, invert })From the point out to radius px, falling off outward. With invert: true it rises outward and is full past the radius, which shades a sphere from a light at an offset point, cut to the circle.
edge({ width, clip })Inward from the whole outline of its clip, which it needs, width px deep.
path({ d, width, side })Along the SVG path data d, full on it and falling off over width px to each side (side: 'both', the default) or to one ('left' or 'right', seen walking along the path). Use it for a shade down one side of a flat shape, with the shape as its clip, or for a stripe along a curve.
fill({ amount })Its amount everywhere in its clip, for a tint.
paint(fn, { clip })fn(ctx, t) paints the field on a 2D context, the ink as color and the density as alpha.
marks([…])Marks as data, such as { band: { from, to, width }, ink }, { fill: 0.3, clip } or { paint: fn }, for marks made from rows.
  • Every mark takes clip, the shape it is cut to: { circle: [x, y, r] }, { rect: [x, y, w, h] }, { polygon: [[x, y], …] }, { path: 'M…' } or a Path2D, in px from the canvas's top left.
  • Every mark except paint takes amount, the density at full from 0 to 1 (1), ink, its color (black; an alpha thins the mark), and falloff, an ease from the far end (0) to full (1): 'linear' by default, 'smooth', any of Kinetic's eases, or a function. paint takes clip only, and its function sets the color and density itself.
  • Marks laid over each other combine as alpha layers: a mark of density b over a field of density a gives a + b(1 − a), so two marks of 0.5 give 0.75.

Boil, freeze and move

  • fps: n gives the noise of a stipple or a splatter a new seed n times a second, and each seed's span is a scatter: the specks change place on each scatter and hold still between. 0, the default, keeps one seed. A halftone, a dither or light has no noise and uses the scatters for freeze and move alone. delay: s holds the first scatter for s seconds of the layer's time before the boil starts, so a layer can hold still while its shape rises and boil once it is up.
  • freeze and move set what moving marks do between scatters, and take effect only with fps above 0; with fps at 0 the marks are painted for every frame.
    • freeze: 'seed', the default, paints the marks for every frame and keeps the seed, so specks come and go at a moving edge.
    • freeze: 'all' paints the marks for the scatter's start only, so the whole texture holds still and jumps on each scatter.
    • move(t), returning { x, y, scale, rotate, origin }, paints the marks for the scatter's start, then sets the canvas's CSS transform to move(t) with move(start) undone, so the specks move with marks that all move together, such as rings growing from one center. The layer owns the canvas's transform, and set({ move: null }) stops it and clears the transform.
A glow slides from side to side on a stipple layer. With freeze: 'seed' it moves smoothly over specks that stay put, with freeze: 'all' it jumps on each scatter, and with move the specks travel with it. Boil sets the scatters a second, and delay holds the first. A click on the stage pauses.

Light, grain and blending

  • kind: 'light' draws the field itself, smooth and in its colors, for light leaks, glows and stripes of light, and kind: 'grain' draws noise as strong as the field, a new pattern fps times a second (the frame rate for film grain), its size scale px (1). Grain is gray whatever the marks' ink; the ink's alpha and amount set how strong it is.
  • blend sets the canvas's CSS mix-blend-mode, over any the stylesheet gives it: 'screen' for light, 'overlay' for grain and 'normal' for the other kinds by default, or any other CSS mix-blend-mode value.

Grain over the whole frame is the finish's grain, still unless its fps is set; see Which to use for when grain from marks fits instead.

Crachis and ink

These two kinds print inks the way a poster printer does, so where two inks meet they mix into a third color, as yellow and blue print green. Each mark's ink is one ink, and marks in the same ink add up to that ink's density. A layer prints at most four inks, and it multiplies onto what is under it by default.

  • kind: 'crachis' sprays each ink as drops, the spatter of 1920s and 1930s French posters. A density of 1 covers 92% of the area, so a little paper always shows through, and the coverage falls as the density does. Where drops of two inks land on each other, the inks multiply. dot (CSS px; 1.6) sets the size of the drops, spread (0.6) how far some grow past the smallest, and seed where they fall. The drops hold still unless fps is set; each new seed lays a new set of drops, which takes about a tenth of a second at 1920 by 1080.
  • kind: 'ink' is smooth: each ink absorbs light as its density rises, computed in linear light, so a blend between two inks passes through the color their overprint makes rather than through gray. On a dark ground it barely shows, as transparent ink does.
  • paint(fn, { ink }) names the ink it paints, and fn paints that ink's density as alpha. A fifth ink on one layer, or paint() with no ink, throws KN-100.
// A rainbow roll down the frame: gold into teal, the two densities adding to 1
// all the way down, and overprinting where they meet.
shade(canvas, { scene: this, kind: 'crachis', marks: (m) => {
    m.band({ from: [0, 0], to: [1920, 0], width: 1080, ink: '#E8B04A', side: 'right' })
     .band({ from: [0, 1080], to: [1920, 1080], width: 1080, ink: '#4FB3A9', side: 'left' });
} });

Here is a roll in each kind, solid ink at each end and the blend over the middle third, its inks coming in over two seconds. The crachis lays more drops as the density rises, and where gold and teal drops land on each other they print green; the ink passes through the same green smoothly.

<div id="stage" data-composition-id="inks" data-start="0" data-duration="6" data-width="1280" data-height="720" data-fps="30">
    <p class="label a">Crachis<code>kind: 'crachis'</code></p>
    <p class="label b">Ink<code>kind: 'ink'</code></p>

    <div class="panel a" data-component="roll-crachis" data-start="0" data-duration="6"><canvas></canvas></div>
    <div class="panel b" data-component="roll-ink" data-start="0" data-duration="6"><canvas></canvas></div>
</div>
const { scene, shade } = wildflower.kinetic;

// Solid gold at the top and solid teal at the bottom, blending over the middle
// third, where the two densities add to 1; both rise from nothing over the
// first two seconds.
const roll = (m, t) => {
    const amount = Math.min(1, t / 2);
    m.fill({ ink: '#E8B04A', amount, clip: { rect: [0, 0, 580, 180] } })
     .band({ from: [0, 180], to: [580, 180], width: 180, side: 'right', ink: '#E8B04A', amount })
     .fill({ ink: '#4FB3A9', amount, clip: { rect: [0, 360, 580, 180] } })
     .band({ from: [0, 360], to: [580, 360], width: 180, side: 'left', ink: '#4FB3A9', amount });
};

scene('roll-crachis', {
    init() { shade(this.element.querySelector('canvas'), { scene: this, kind: 'crachis', marks: roll }); }
});

scene('roll-ink', {
    init() { shade(this.element.querySelector('canvas'), { scene: this, kind: 'ink', marks: roll }); }
});
html, body { margin: 0; background: #0a0b10; }

#stage { position: relative; overflow: hidden; background: #0b0f1a; }

.label { position: absolute; top: 28px; width: 580px; margin: 0; font: 700 32px system-ui, sans-serif; color: #fff; }
.label code { display: block; margin-top: 8px; font: 500 24px ui-monospace, monospace; color: #b8a6ff; }

/* Paper under each layer: the inks multiply onto it. */
.panel { position: absolute; top: 140px; width: 580px; height: 540px; overflow: hidden; border-radius: 16px; background: #eef0f2; }
.a { left: 40px; }
.b { left: 660px; }

canvas { position: absolute; inset: 0; width: 100%; height: 100%; }

[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
The same roll in crachis and in ink

A tint spray on a colored ground

Sprayed in a step of the color under it, crachis gives a ground tooth and light without a second hue. Draw it with blend: 'normal', so each drop is the step itself: multiplied onto the ground, a drop would be the step times the ground, about two steps darker. Here the pink takes its own 15% shade sprayed down from the top, and its 25% tint glowing behind the figure, each mixed in OKLab (Tints, shades and gradients).

<div id="stage" data-composition-id="tint-spray" data-start="0" data-duration="6" data-width="1280" data-height="720" data-fps="30">
    <div class="panel a" data-component="shade-spray" data-start="0" data-duration="6">
        <canvas></canvas>
        <p class="figure">2025</p>
        <p class="note">Its 15% shade, from the top</p>
    </div>
    <div class="panel b" data-component="tint-spray" data-start="0" data-duration="6">
        <canvas></canvas>
        <p class="figure">2025</p>
        <p class="note">Its 25% tint, behind the figure</p>
    </div>
</div>
const { scene, shade } = wildflower.kinetic;

// The pink #ec6da8 mixed in OKLab: 15% toward the deep blue #2d4c9c, and 25% toward the paper #f5efe3.
const SHADE = '#d06ba7', TINT = '#f290b7';

scene('shade-spray', {
    init() {
        shade(this.element.querySelector('canvas'), { scene: this, kind: 'crachis', blend: 'normal', marks: (m) => m
            .band({ from: [0, 0], to: [580, 0], width: 420, side: 'right', ink: SHADE, amount: 0.7, falloff: 'smooth' }) });
    }
});

scene('tint-spray', {
    init() {
        shade(this.element.querySelector('canvas'), { scene: this, kind: 'crachis', blend: 'normal', marks: (m) => m
            .glow({ at: [290, 300], radius: 300, ink: TINT, amount: 0.7, falloff: 'smooth' }) });
    }
});
html, body { margin: 0; background: #0a0b10; }

#stage { position: relative; overflow: hidden; background: #0b0f1a; }

.panel { position: absolute; top: 40px; width: 580px; height: 640px; overflow: hidden; border-radius: 16px; background: #ec6da8; }
.a { left: 40px; }
.b { left: 660px; }

canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.figure { position: absolute; left: 0; right: 0; top: 230px; margin: 0; text-align: center; font: 800 150px system-ui, sans-serif; color: #1f2440; }
.note { position: absolute; left: 40px; bottom: 32px; margin: 0; font: 600 26px system-ui, sans-serif; color: #1f2440; }

[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
A shade and a tint sprayed on the pink

The layer's options

  • scene (a layer's scene), kind, marks, blend, fps, delay (0), freeze, move, opacity (0 to 1; 1), density, mask and seed (1) are the layer's, and set() changes all of them but scene and kind. Values that are all as they were change nothing, so seek() can hand set() its options on every frame.
  • mask, a shape as a clip takes it, cuts what the layer draws to the shape, so a speck, drop or dot crossing its outline is cut there; a mark's clip only limits where its density is painted. set({ mask: null }) takes it off.
  • density (-1 to 1; 0) gives a stipple, a splatter or a halftone more ink everywhere or less, as effect()'s density does. The kind's other parameters, such as scale, grade, max or cell, pass through to effect(). Each mark gives its ink, so ink, paper, color and source on the layer throw KN-100.
  • Each layer is one canvas and one WebGL context, painted again only when its marks can have changed, so marks of one kind drawn together belong on one layer. A frozen layer, with freeze: 'all' or move, paints its marks once a scatter; when they read something that changed, such as a knob, redraw() on the handle paints them again.
  • A bad option throws KN-100, and a bad mark throws KN-100 from the seek that drew it (KN-121 for an unknown falloff ease).

Blur where the marks paint

blur(element, { scene, radius, …, marks }) blurs what is under element by radius px (8) where the marks' field is full, and leaves it sharp where the field is empty. Two bands give tilt-shift focus, sharp across the middle, and a glow blurs a round patch, most at its center.

const { scene, blur } = wildflower.kinetic;

scene('model', {
    init() {
        blur(this.element.querySelector('.over'), { scene: this, radius: 10, marks: (m) => m
            .band({ from: [0, 0], to: [1280, 0], width: 280, side: 'right', falloff: 'smooth' })
            .band({ from: [1280, 720], to: [0, 720], width: 280, side: 'right', falloff: 'smooth' }) });
    },
});
The two bands blur the town toward the top and the foot of the stage and leave a strip across the middle sharp, and the cars drive in and out of focus. Width sets how far each band reaches. A click on the stage pauses.
  • The element gets backdrop-filter: blur() and a mask-image of the field, a quarter of its width and height, so put it above what it blurs and below anything that should stay sharp. Leave the element empty, since the mask cuts its own content too. An ancestor with opacity below 1, a filter or a mask limits the blur to what is inside that ancestor.
  • The marks option, which a blur needs, adds the marks of shade(), in px from the element's top left. Their ink's color is not used, though an ink with an alpha thins the mask as amount does.
  • The mask blends between sharp and fully blurred, and does not vary the radius from point to point; a smooth falloff over a few hundred px takes the content from fully blurred at the frame's edge to sharp, with no visible line.
  • The mask is painted in the seek's media phase, and the seek waits for it to decode, so check() and render() never take a frame with the last frame's mask.
  • With freeze: 'seed', the default, the mask follows the marks on every frame and is encoded again only when its pixels change; fps with freeze: 'all' holds it between steps.
  • set() changes radius, fps, delay, freeze and marks, redraw() paints the marks again, and dispose() takes the blur and the mask off the element. A blur made with scene: this is disposed with the scene.
  • A bad element, option or mark throws KN-100.

See Which to use for the other ways to blur.