Skip to content
GitHub repository
Smooth graphics

Smooth graphics in PixiJS v8

Find the cause and fix for jagged edges, faceted circles, flickering thin lines, blurry retina canvases, blurry filters and banded gradients in PixiJS v8.

Updated pixi-silk 0.1.0

Graphics in PixiJS v8 look rough for several reasons. Find your symptom below. Each section gives the cause, then the fix. Most fixes work with plain Graphics, and each ends with the pixi-silk equivalent.

Jagged, stair-stepped edges#

Anti-aliasing is off. antialias is false unless you set it, so every edge pixel is fully in or fully out.

Turn on MSAA with await app.init({ antialias: true }). Or draw with SilkGraphics, which computes exact edge coverage without MSAA. See Anti-aliasing in PixiJS v8.

Circles and curves look like polygons when zoomed#

Graphics turns curves into a fixed number of segments when you draw them. Scaling the object up later reveals the facets.

Raise bezierSmoothness (0 to 1, default 0.5) in app.init(), or redraw at the final scale. Or use SilkGraphics. It evaluates circles, arcs and rounded corners exactly per pixel, at any zoom.

Thin lines flicker, look dotted or disappear#

A line thinner than one device pixel covers only a fraction of each pixel. Point samples (and MSAA’s four samples) hit it on some pixels and miss it on others. The pattern changes as the line moves.

Keep strokes at least one device pixel wide and fade them instead. SilkGraphics does this for you with energy-conserving hairlines:

Source src/demos/hairlines.demo.ts
import { SilkGraphics } from 'pixi-silk';
import { defineDemo } from './runtime';

// Lines thinner than a device pixel keep one pixel of width and fade instead of breaking up.
export default defineDemo({
    size: [480, 220],
    controls: {
        scale: { type: 'range', label: 'width scale', min: 0.05, max: 2, step: 0.01, value: 0.5 },
    },
    setup({ stage, params, tick }) {
        const g = new SilkGraphics();

        stage.addChild(g);
        tick((t) => {
            g.clear();
            for (let i = 0; i < 24; i++) {
                const a = -Math.PI / 2 + (i / 23) * Math.PI + Math.sin(t * 0.3) * 0.05;
                const w = (0.1 + i * 0.12) * params.scale;

                g.line(240, 200, 240 + Math.cos(a) * 220, 200 + Math.sin(a) * 190).stroke({
                    width: w,
                    color: 0xffffff,
                });
            }
        });
    },
});

Everything is slightly blurry on retina or HiDPI screens#

The canvas has fewer pixels than the screen. Either resolution is 1, or the browser scales a canvas sized in CSS pixels. Or the size was rounded at a fractional DPR (1.25, 1.5), so the browser resamples it.

Call app.init({ resolution: window.devicePixelRatio, autoDensity: true }) and size the backing store from devicePixelContentBoxSize. createSilkApp does both and follows DPR changes:

Source src/demos/resolution.demo.ts
import { SilkGraphics } from 'pixi-silk';
import { defineDemo } from './runtime';

// A Siemens star is the hardest test for anti-aliasing: every wedge narrows below a pixel.
export default defineDemo({
    size: [480, 240],
    controls: {
        resolution: { type: 'select', label: 'resolution', options: ['device', '1x', '2x', '3x'], value: 'device' },
    },
    setup({ silk, stage, params, tick }) {
        const g = new SilkGraphics();
        let applied = '';

        stage.addChild(g);
        for (let i = 0; i < 36; i++) {
            const a = (i / 36) * Math.PI * 2;

            g.sector(240, 120, 110, a, a + Math.PI / 36).fill(0xffffff);
        }
        g.circle(240, 120, 110).stroke({ width: 1, color: 0x636366 });
        tick(() => {
            if (params.resolution === applied) return;
            applied = params.resolution;
            // createSilkApp sizes the backing store in device pixels; force 1x/2x/3x to compare
            silk.setResolution(applied === 'device' ? null : Number(applied[0]));
        });
    },
});

Content behind a filter is blurry#

Filters render into their own texture at resolution: 1 by default.

Set Filter.defaultOptions.resolution = 'inherit' before you create filters, or pass resolution to each filter. createSilkApp sets this default for you.

Gradients show visible bands#

The cause is 8-bit colour. A dark gradient across 1000 pixels may have only 20 distinct values.

The fix is dithering. Pixi’s FillGradient has no dither option. pixi-silk bakes gradients in half floats, interpolates in OKLab and dithers every sample by plus or minus half an 8-bit step. See Gradients.

Slow motion snaps from pixel to pixel#

roundPixels: true (on the renderer or the object) rounds positions to whole pixels.

Keep roundPixels: false for smoothly animated vector content. With analytic anti-aliasing, sub-pixel positions render correctly.

Checklist#

import { createSilkApp, SilkGraphics } from 'pixi-silk';

const { app } = await createSilkApp({ parent: el });   // DPR-exact canvas, filters at screen resolution
const g = new SilkGraphics();                          // exact anti-aliasing, hairlines, dithered gradients
app.stage.addChild(g);

API reference: createSilkApp, SilkGraphics