Skip to content
GitHub repository
Paint

Gradients

Linear, radial, conic and along-the-path gradients in OKLab, with smooth easing. Half-float ramps and dithering keep dark gradients free of banding.

Updated pixi-silk 0.1.0

Source src/demos/gradients.demo.ts
import { Text } from 'pixi.js';
import { conic, linear, radial, SilkGraphics } from 'pixi-silk';
import { defineDemo } from './runtime';

export default defineDemo({
    size: [480, 240],
    controls: {
        space: { type: 'select', label: 'space', options: ['oklab', 'linear', 'srgb'], value: 'oklab' },
        easing: { type: 'select', label: 'easing', options: ['linear', 'smooth'], value: 'linear' },
    },
    setup({ stage, params, tick }) {
        const g = new SilkGraphics();
        const label = new Text({ text: '', style: { fill: 0x8e8e93, fontSize: 12, fontFamily: 'system-ui' } });

        label.position.set(20, 214);
        stage.addChild(g, label);
        tick(() => {
            const options = { space: params.space as 'oklab', easing: params.easing as 'linear' };

            g.clear();
            // gradient objects are cheap: ramps with the same stops share one atlas row
            g.roundRect(20, 20, 440, 50, 14).fill(linear([0x0a84ff, 0xffd60a], options));
            g.roundRect(20, 84, 440, 50, 14).fill(linear([0xff375f, 0x30d158, 0x5e5ce6], options));
            g.circle(80, 180, 26).fill(radial([0xffffff, 0xff9f0a, [1, 0xff375f, 0]], options));
            g.circle(160, 180, 26).fill(conic([0xff375f, 0xffd60a, 0x30d158, 0x64d2ff, 0xbf5af2, 0xff375f], options));
            g.arcSweep(240, 180, 22, 0, Math.PI * 1.6).stroke({
                width: 10,
                cap: 'round',
                gradient: conic([0x30d158, 0xff453a], options),
            });
            label.text = `interpolated in ${params.space}, ${params.easing} easing`;
        });
    },
});

Kinds#

import { linear, vertical, horizontal, radial, conic, along } from 'pixi-silk';

linear(stops, { from: [0, 0], to: [1, 1] });       // diagonal across the shape's box
vertical(stops);                                    // top to bottom of the shape
horizontal(stops);                                  // left to right
radial(stops, { center: [0.3, 0.3], radius: 0.8, innerRadius: 0.2 });
conic(stops, { startAngle, sweep });                // around a centre (on an arc, it spans the arc)
along(stops);                                       // along a stroke, first point to last

Use a gradient anywhere a colour goes: fill(gradient), stroke({ gradient }), or fill({ gradient, color }), where color tints the ramp.

Stops#

linear([0xff375f, 0x0a84ff]);                           // spread evenly
linear([[0, 0xff375f], [0.3, 0xffd60a], [1, 0x0a84ff]]);
linear([[0, 0xffffff, 1], [1, 0xffffff, 0]]);            // [offset, color, alpha]
linear([{ offset: 0.5, color: '#30d158', alpha: 0.5 }]);
linear([[0, RED], [0.5, RED], [0.5, BLUE], [1, BLUE]]);  // hard stop

Silk clamps offsets to 0..1 and makes them non-decreasing, like CSS.

Colour space#

By default, stops interpolate in OKLab, a perceptual space. A red-to-green ramp passes through a bright yellow, not a muddy brown. Lightness also changes evenly. Pass space: 'linear' for physically linear light (glows, light falloff) or space: 'srgb' for CSS-like results. Interpolation is premultiplied, so fading a colour to transparent never darkens it.

Easing#

easing: 'smooth' runs a monotone cubic spline through the stops, with flat ends. It removes the Mach bands at every stop of a linear ramp. It never overshoots. A function (t) => t remaps the ramp parameter for custom falloffs.

Units and extend#

  • With units: 'shape' (default), coordinates are fractions of the shape’s bounding box. One gradient object then fits every shape it paints.
  • With units: 'local', coordinates are in the graphics’ local space. One continuous ramp then spans many shapes (a chart’s bars, a row of pills).
  • extend: 'pad' | 'repeat' | 'reflect' controls what happens outside 0..1.

No banding#

Dark, wide gradients stay smooth on 8-bit displays. Silk bakes each ramp once into a shared 256-wide half-float atlas. The shader samples it per pixel with ±½ LSB dither (interleaved gradient noise). Try both toggles below. The reveal toggle multiplies the contrast by 8 to show the steps.

Source src/demos/dither.demo.ts
import { ColorMatrixFilter, Container } from 'pixi.js';
import { SilkGraphics, vertical } from 'pixi-silk';
import { defineDemo } from './runtime';

// A dark, wide gradient has only a few 8-bit steps. Half-float ramps + ±½ LSB dither hide them.
const NIGHT = vertical([0x0b1026, 0x1d1a3a, 0x10141f]);

export default defineDemo({
    size: [480, 220],
    background: 0x000000,
    controls: {
        dither: { type: 'toggle', label: 'dither', value: true },
        reveal: { type: 'toggle', label: 'reveal x8', value: true },
    },
    setup({ stage, params, tick }) {
        const view = new Container();
        const g = new SilkGraphics();
        // multiply the contrast by 8 around black so every banding step shows
        const reveal = new ColorMatrixFilter();

        reveal.brightness(8, false);
        view.addChild(g);
        stage.addChild(view);
        g.rect(0, 0, 480, 220).fill(NIGHT);
        g.circle(360, 70, 60).fill({ color: 0xffd9a0, alpha: 0.12, blur: 40 });
        tick(() => {
            g.dither = params.dither ? 1 : 0;
            view.filters = params.reveal ? [reveal] : [];
        });
    },
});

API reference: linear, vertical, horizontal, radial, conic, along, Gradient