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] } });
} });
},
});
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
| Mark | What 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 aPath2D, in px from the canvas's top left. - Every mark except
painttakesamount, the density at full from 0 to 1 (1),ink, its color (black; an alpha thins the mark), andfalloff, an ease from the far end (0) to full (1):'linear'by default,'smooth', any of Kinetic's eases, or a function.painttakescliponly, 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: ngives 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 forfreezeandmovealone.delay: sholds 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.freezeandmoveset what moving marks do between scatters, and take effect only withfpsabove 0; withfpsat 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 tomove(t)withmove(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, andset({ move: null })stops it and clears the transform.
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, andkind: 'grain'draws noise as strong as the field, a new patternfpstimes a second (the frame rate for film grain), its sizescalepx (1). Grain is gray whatever the marks' ink; the ink's alpha andamountset how strong it is.blendsets the canvas's CSSmix-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 CSSmix-blend-modevalue.
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, andseedwhere they fall. The drops hold still unlessfpsis 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, andfnpaints that ink's density as alpha. A fifth ink on one layer, orpaint()with noink, 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; }
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; }
The layer's options
scene(a layer's scene),kind,marks,blend,fps,delay(0),freeze,move,opacity(0 to 1; 1),density,maskandseed(1) are the layer's, andset()changes all of them butsceneandkind. Values that are all as they were change nothing, soseek()can handset()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'scliponly 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, aseffect()'sdensitydoes. The kind's other parameters, such asscale,grade,maxorcell, pass through toeffect(). Each mark gives its ink, soink,paper,colorandsourceon 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'ormove, 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
falloffease).
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 element gets
backdrop-filter: blur()and amask-imageof 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
marksoption, which a blur needs, adds the marks ofshade(), in px from the element's top left. Their ink's color is not used, though an ink with an alpha thins the mask asamountdoes. - The mask blends between sharp and fully blurred, and does not vary the radius from point to point; a
smoothfalloff 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()andrender()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;fpswithfreeze: 'all'holds it between steps. set()changesradius,fps,delay,freezeandmarks,redraw()paints the marks again, anddispose()takes the blur and the mask off the element. A blur made withscene: thisis disposed with the scene.- A bad element, option or mark throws KN-100.
See Which to use for the other ways to blur.