Projects bibleweb Docs atmosphere-post-processing.md

Post-Processing Effects for Web-Based Pixel Art Scenes

Last modified March 28, 2026

Post-Processing Effects for Web-Based Pixel Art Scenes

Research date: 2026-03-29 Scope: Porting MonoGame C# post-processing to web (SvelteKit + Canvas/WebGL)


Context

The source desktop app (MonoGame C#) uses a set of post-processing effects that give scenes their "atmosphere": a darkened vignette overlay, soft glow circles for light sources, scene crossfades, and GPU-interpolated sky gradients. This document surveys how each effect translates to the web, with implementation approaches, performance costs, and honest verdicts on complexity vs. value.

The target stack is SvelteKit (Svelte 5 runes) rendering to an HTML <canvas> element. The rendering layer will be either Canvas 2D API or WebGL depending on effect requirements.


Architecture: The Post-Processing Pipeline

Before diving into individual effects, the architecture matters. There are three broad approaches:

Option A: CSS Overlays

Pure CSS/HTML elements layered over the canvas via absolute positioning. Zero rendering cost, but limited to blending modes available in CSS and no access to pixel data.

Option B: Canvas 2D Compositing

Effects drawn directly onto the game canvas using globalCompositeOperation and createRadialGradient. Simple, widely supported, but cannot access individual pixel data for shader-style effects.

Option C: WebGL Render-to-Texture (Full Post-Processing Pipeline)

Render the scene to a framebuffer texture, then apply one or more post-processing fragment shaders before displaying the final result on screen. This is the "correct" approach for multi-effect pipelines and matches how the MonoGame version works. It has a higher setup cost but runs entirely on the GPU.

Recommended architecture for this project: Hybrid. Use CSS overlays for static or near-static effects (vignette), Canvas 2D compositing for simple glow circles, and reserve WebGL framebuffer pipeline for effects that genuinely need per-pixel access (bloom, dithering, palette LUTs). This avoids paying the full WebGL setup cost for effects that don't need it.

WebGL Framebuffer Setup (for effects that need it)

function createRenderTarget(gl, width, height) {
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0,
                gl.RGBA, gl.UNSIGNED_BYTE, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  const fb = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
                          gl.TEXTURE_2D, texture, 0);
  return { texture, fb };
}

// Helper: always set viewport to match render target
function bindTarget(gl, target, width, height) {
  gl.bindFramebuffer(gl.FRAMEBUFFER, target?.fb ?? null);
  gl.viewport(0, 0, width, height);
}

The ping-pong pattern (alternating between two render targets) is used for multi-pass effects like bloom.


1. Vignette

What the desktop app does

256×256 pre-rendered radial gradient texture, strength 0.3, dark purple default, drawn over the scene at full canvas size each frame.

Web approaches

CSS radial-gradient overlay (recommended)

The simplest and cheapest option. A <div> absolutely positioned over the canvas:

.vignette-overlay {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    ellipse at center,
    transparent 40%,
    rgba(20, 10, 40, 0.75) 140%
  );
}

Strengths:

  • Zero per-frame rendering cost — the browser composites it on the GPU compositor thread, not the rendering thread
  • Trivial to parameterize via CSS custom properties: --vignette-color, --vignette-strength
  • Per-scene customization via Svelte reactive bindings

Weaknesses:

  • Cannot interact with scene pixel data (no screen or lighten blend modes without mix-blend-mode)
  • Slightly different visual output than a texture-based vignette; may need tweaking

Verdict: Use this. Replaces the 256×256 texture with zero memory cost and no per-frame draw call.

Canvas 2D radial gradient

Draw a radial gradient directly on the canvas after the scene is rendered:

function drawVignette(ctx, width, height, color = 'rgba(20,10,40,0.65)') {
  const cx = width / 2, cy = height / 2;
  const radius = Math.sqrt(cx * cx + cy * cy);
  const grad = ctx.createRadialGradient(cx, cy, radius * 0.4,
                                         cx, cy, radius * 1.2);
  grad.addColorStop(0, 'transparent');
  grad.addColorStop(1, color);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, width, height);
}

Strengths: Interacts with canvas pixel data; works inside the render loop. Weaknesses: Redraws every frame; moderate CPU cost at 60fps; blocks the main thread.

WebGL fragment shader vignette

// fragment shader
uniform vec2 u_resolution;
uniform float u_strength;
uniform vec3 u_color;

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec2 d = (uv - 0.5) * 2.0;
  float vignette = 1.0 - dot(d, d) * u_strength;
  vec4 scene = texture2D(u_scene, uv);
  gl_FragColor = vec4(mix(u_color, scene.rgb, vignette), scene.a);
}

Strengths: Exact port of the MonoGame approach; GPU-only; composable with other post-processing shaders. Weaknesses: Requires full WebGL pipeline. Overkill if vignette is the only effect.

Performance comparison:

Method Per-frame cost Setup Flexibility
CSS overlay ~0ms (compositor) 2 lines High via CSS vars
Canvas 2D gradient ~0.3ms @ 1080p 8 lines Full
WebGL shader ~0.05ms (GPU) ~50 lines + pipeline Full

2. Bloom / Glow

What the desktop app does

128×128 radial white-to-transparent gradient, quadratic falloff, used for soft light sources (candles, stars, etc.). Currently drawn as pre-rendered sprites.

Web approaches

CSS filter: blur() + screen blend mode

The "poor man's bloom": render light sources into an absolutely positioned overlay div, apply CSS blur, and use mix-blend-mode: screen to additively blend.

.glow-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  mix-blend-mode: screen;
  filter: blur(12px);
}
// Draw glow circles into an offscreen canvas, then set as bg-image
// OR use canvas element directly with the CSS applied

Important caveat: CSS filter: blur() is notoriously expensive in practice, particularly in Firefox (historical bug 925025) and on canvas elements. A single large blur on a canvas can force software rendering fallback. Use only on small, bounded elements, never on the full-scene canvas.

Verdict: Suitable for a small number (< 10) of discrete light sources if each glow is a small element. Not suitable for scene-wide bloom.

Canvas 2D with globalCompositeOperation: 'screen'

Draw pre-scaled radial gradients onto the canvas using the screen composite operation, which matches additive light blending:

function drawGlowCircle(ctx, x, y, radius, color = 'white', alpha = 0.5) {
  ctx.save();
  ctx.globalCompositeOperation = 'screen';
  ctx.globalAlpha = alpha;

  const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
  grad.addColorStop(0, color);
  grad.addColorStop(1, 'transparent');

  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

This is the direct equivalent of the MonoGame glow circle sprites. screen composite operation inverts-multiplies-inverts, producing a lighter result — exactly what additive light blending looks like.

Performance: Each radial gradient fill call costs ~0.1–0.3ms depending on radius. Fine for < 20 lights per frame at 60fps.

WebGL multi-pass bloom (full implementation)

True bloom requires a three-stage pipeline:

  1. Brightness extraction pass — extract pixels above a luminance threshold
  2. Separable Gaussian blur — horizontal + vertical pass, ping-pong between two FBOs
  3. Additive composite — add blurred result to original scene
// Pass 1: brightness extraction
uniform float u_threshold;
void main() {
  vec4 color = texture2D(u_scene, v_uv);
  float luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
  gl_FragColor = luminance > u_threshold ? color : vec4(0.0);
}

// Pass 2: separable Gaussian blur (horizontal, then vertical)
uniform bool u_horizontal;
float weights[5] = float[](0.2270, 0.1945, 0.1216, 0.0540, 0.0162);
void main() {
  vec2 offset = u_horizontal
    ? vec2(1.0 / u_resolution.x, 0.0)
    : vec2(0.0, 1.0 / u_resolution.y);
  vec4 result = texture2D(u_tex, v_uv) * weights[0];
  for (int i = 1; i < 5; i++) {
    result += texture2D(u_tex, v_uv + offset * float(i)) * weights[i];
    result += texture2D(u_tex, v_uv - offset * float(i)) * weights[i];
  }
  gl_FragColor = result;
}

// Pass 3: composite
void main() {
  vec4 scene = texture2D(u_scene, v_uv);
  vec4 bloom = texture2D(u_bloom, v_uv);
  gl_FragColor = scene + bloom * u_bloom_intensity;
}

Performance: 2–4 blur passes (4–8 shader invocations per frame) at typical scene resolution. Negligible on desktop (< 1ms), noticeable on mobile if resolution is high.

Verdict for this project: The Canvas 2D screen compositing approach is the direct port of the existing glow sprites and should be the first implementation. Full WebGL bloom is a planned feature and can be added when the WebGL pipeline is in place.


3. Dithering and Palette Limiting

What the desktop app has planned (not yet implemented)

Ordered dithering and palette limiting for a stylized retro look.

Ordered Dithering (Bayer matrix)

The Bayer threshold matrix is the standard approach for pixel art: a precomputed 4×4 (or 8×8) grid of thresholds that, when applied per-pixel, distributes quantization error in a structured, visually pleasing pattern.

Canvas 2D implementation (CPU, slow but works):

const BAYER_4x4 = [
   0,  8,  2, 10,
  12,  4, 14,  6,
   3, 11,  1,  9,
  15,  7, 13,  5,
];
const BAYER_SCALE = 16;

function applyDither(imageData, paletteSize = 4) {
  const { data, width } = imageData;
  for (let i = 0; i < data.length; i += 4) {
    const px = (i / 4) % width;
    const py = Math.floor((i / 4) / width);
    const threshold = BAYER_4x4[(py % 4) * 4 + (px % 4)] / BAYER_SCALE;

    for (let c = 0; c < 3; c++) {
      const norm = data[i + c] / 255;
      const quantized = Math.floor(norm * (paletteSize - 1) + threshold) / (paletteSize - 1);
      data[i + c] = Math.min(255, Math.max(0, Math.round(quantized * 255)));
    }
  }
}

WebGL shader implementation (GPU, runs at 60fps at any resolution):

// Bayer 4x4 as uniform array
uniform float u_bayer[16];
uniform float u_palette_size;

void main() {
  vec2 pixelPos = floor(gl_FragCoord.xy);
  int bx = int(mod(pixelPos.x, 4.0));
  int by = int(mod(pixelPos.y, 4.0));
  float threshold = u_bayer[by * 4 + bx] / 16.0;

  vec4 color = texture2D(u_scene, v_uv);
  float n = u_palette_size - 1.0;
  vec3 quantized = floor(color.rgb * n + threshold) / n;
  gl_FragColor = vec4(quantized, color.a);
}

Performance: GPU-based Bayer dithering renders a full 4K frame in under 0.2ms. The Canvas 2D CPU version touching every pixel at 1080p takes ~15–30ms, making it unusable for real-time use. Use the WebGL shader version.

Palette sizes and visual results:

paletteSize Colors per channel Total addressable colors
2 1-bit 8 (monochrome-ish)
4 2-bit 64
8 3-bit 512
16 4-bit 4096

Floyd-Steinberg (error diffusion)

Floyd-Steinberg propagates quantization error to neighboring pixels for softer transitions. It produces better results than ordered dithering for photographs but cannot be parallelized — each pixel depends on its left neighbor. This makes it GPU-hostile. It's appropriate for offline image processing (thumbnails, placeholders) but not real-time rendering. Skip for the render loop.

Blue noise dithering

More visually pleasing than Bayer for organic scenes: uses a pre-computed blue noise texture (downloadable as a PNG) sampled at screen coordinates:

uniform sampler2D u_blue_noise;
uniform vec2 u_resolution;

void main() {
  vec2 noiseUV = gl_FragCoord.xy / 64.0; // tile 64px noise texture
  float threshold = texture2D(u_blue_noise, noiseUV).r;

  vec4 color = texture2D(u_scene, v_uv);
  float n = u_palette_size - 1.0;
  vec3 quantized = floor(color.rgb * n + threshold) / n;
  gl_FragColor = vec4(quantized, color.a);
}

4. Color Grading and Palette Limiting via LUT

A 3D LUT (Look-Up Table) maps any input RGB triplet to a new output color. It is the standard tool for cinematic color grading and palette limiting in games.

How LUTs work in WebGL

A 3D LUT is stored as a 2D texture of cube slices. A 16×16×16 LUT is stored as a 256×16 texture (16 slices of 16×16). To sample it:

vec4 applyLUT(sampler2D lut, vec3 color, float lutSize) {
  float sliceSize   = 1.0 / lutSize;
  float sliceInner  = (lutSize - 1.0) / lutSize;
  float zSlice0     = floor(color.b * (lutSize - 1.0));
  float zSlice1     = min(zSlice0 + 1.0, lutSize - 1.0);
  float xOffset     = sliceSize * 0.5 + color.r * sliceInner * sliceSize;
  float yRange      = (color.g * (lutSize - 1.0) + 0.5) / lutSize;
  float zOffset     = (color.b * (lutSize - 1.0) - zSlice0);
  vec4 s0 = texture2D(lut, vec2(zSlice0 * sliceSize + xOffset, yRange));
  vec4 s1 = texture2D(lut, vec2(zSlice1 * sliceSize + xOffset, yRange));
  return mix(s0, s1, zOffset);
}

Workflow for retro palette limiting

  1. Start with an identity LUT (a neutral 3D LUT that passes all colors through unchanged)
  2. In an image editor (GIMP, Photoshop, DaVinci Resolve), apply palette and color operations
  3. Export as PNG → import as WebGL texture → apply in fragment shader in one additional pass

This is the cleanest way to implement the "palette limiting" feature planned in the desktop app. The LUT itself encodes the palette restriction, and changing the mood of a scene is as simple as swapping the LUT texture.

Posterization (programmatic palette limiting)

If you don't want a pre-authored LUT, posterization quantizes each channel directly:

uniform float u_levels; // e.g. 4.0 = 4 shades per channel

void main() {
  vec4 color = texture2D(u_scene, v_uv);
  vec3 posterized = floor(color.rgb * u_levels) / u_levels;
  gl_FragColor = vec4(posterized, color.a);
}

Performance: Both LUT and posterization run in a single pass at negligible GPU cost. A 16³ LUT texture is 16KB.


5. CSS Filter Effects

CSS filter composites effects on any HTML element, including <canvas>. Available functions:

Filter Use case Performance
blur(Xpx) Soft glow approximation Expensive (GPU, but can fallback to CPU)
brightness(X) Fade to black/white Cheap
contrast(X) Stylistic punch Cheap
saturate(X) Desaturate for night/rain mood Cheap
hue-rotate(Xdeg) Color shift for mood Cheap
sepia(X) Aged/warm look Cheap

Filters except blur are cheap — they apply as a single-pass GPU color matrix. blur is expensive because Gaussian blur is O(radius²) and large radii on large canvases can force software rendering, particularly in Firefox.

Stacking filters:

canvas.night-mode {
  filter: brightness(0.6) saturate(0.4) hue-rotate(200deg);
}

Multiple filters on one element are composed as a single GPU pass — no additional cost for stacking cheap filters.

For scene mood transitions (entering night, storm, etc.), animating CSS filter values with a CSS transition is extremely efficient and requires zero JS per-frame work:

canvas {
  transition: filter 1.5s ease;
}
canvas.night {
  filter: brightness(0.5) saturate(0.3) hue-rotate(180deg);
}

Performance verdict: Use CSS filters liberally for everything except blur on large elements. Apply blur only to small, bounded elements.


6. Canvas globalCompositeOperation Reference

For Canvas 2D post-processing, the composite operation controls how new draws interact with existing pixels:

Mode Effect Use case
source-over Normal alpha blend (default) Everything
screen Additive light Glow, bloom, lens flares
lighten Keep lighter pixel Fog, soft light
multiply Darken overlap Shadows, tinting
overlay Contrast enhancement Color grading
color-burn Deep shadow darkening Night atmosphere
luminosity Transfer brightness only Preserve color, change brightness

Setting globalCompositeOperation before drawing an element applies that blend to every pixel of that draw call. Restore with ctx.save() / ctx.restore().

// Additive glow: same as 'screen' blend mode
ctx.globalCompositeOperation = 'screen';
// Draw glow sprite here
ctx.globalCompositeOperation = 'source-over'; // reset

Performance: All composite operations run on the GPU via the Canvas compositing pipeline. The mode itself has no meaningful performance difference; the cost is the fill area of the draw calls.


7. Scene Transitions

What the desktop app does

1.5s crossfade: interpolates opacity, particle positions, and light intensities between two scenes.

Web approach: dual-canvas crossfade

Maintain two canvas elements, animate opacity between them:

<!-- Svelte 5 runes -->
<script>
  let alpha = $state(1);

  async function transitionTo(newScene) {
    // Render new scene to back canvas while front is still visible
    renderScene(backCanvas, newScene);

    // Animate opacity
    const start = performance.now();
    const duration = 1500;

    function frame(now) {
      const t = Math.min((now - start) / duration, 1);
      const eased = t < 0.5 ? 2*t*t : -1+(4-2*t)*t; // ease in-out quad
      alpha = 1 - eased;
      if (t < 1) requestAnimationFrame(frame);
      else swapCanvases();
    }
    requestAnimationFrame(frame);
  }
</script>

<div class="scene-container">
  <canvas bind:this={frontCanvas} style:opacity={alpha} />
  <canvas bind:this={backCanvas} style:opacity={1 - alpha} />
</div>

For interpolated lights and particles during the transition, lerp their positions and intensities with the same eased t value.

Alternative: CSS View Transitions API

SvelteKit 1.24+ supports the View Transitions API for page-level crossfades:

document.startViewTransition(() => {
  renderNewScene();
});

This works well for route changes (moving between study modes), but not for in-scene atmospheric transitions where particles and lights must interpolate.

Verdict: Use dual-canvas opacity lerp for in-scene transitions. Use View Transitions API for route-level page changes.


8. Anti-Banding for Sky Gradients

What the desktop app does

GPU-interpolated vertex-colored sky gradients with zero banding (because the MonoGame renderer uses HDR color interpolation internally).

The banding problem on web

CSS gradients and Canvas 2D linear gradients operate in 8-bit sRGB. On a monitor with subtle banding, a dark sky gradient (navy → midnight → black) over 1080px will show ~5–8 visible bands.

Solutions

SVG feTurbulence noise displacement (recommended for CSS gradients)

<svg width="0" height="0" aria-hidden="true">
  <filter id="grain" color-interpolation-filters="sRGB"
          x="0" y="0" width="1" height="1">
    <feTurbulence type="fractalNoise" baseFrequency="0.75"
                  numOctaves="4" stitchTiles="stitch"/>
    <feDisplacementMap in="SourceGraphic" scale="8"
                       xChannelSelector="R" yChannelSelector="G"/>
  </filter>
</svg>

<style>
  .sky-gradient {
    background: linear-gradient(180deg, #0a0a1a 0%, #1a1030 60%, #2a1a50 100%);
    filter: url(#grain);
    clip-path: inset(0); /* prevent overflow */
  }
</style>

The displacement map uses two noise channels to slightly offset pixel positions, breaking up the banding into imperceptible grain without altering the overall colors.

Canvas 2D: add triangular noise to gradient stops

function drawAntiAliasedGradient(ctx, width, height, stops) {
  const grad = ctx.createLinearGradient(0, 0, 0, height);
  stops.forEach(([pos, color]) => grad.addColorStop(pos, color));
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, width, height);

  // Add subtle noise to break banding
  const imageData = ctx.getImageData(0, 0, width, height);
  const data = imageData.data;
  for (let i = 0; i < data.length; i += 4) {
    const noise = (Math.random() - 0.5) * 6; // ±3 units of noise
    data[i]     = Math.min(255, Math.max(0, data[i]     + noise));
    data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + noise));
    data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + noise));
  }
  ctx.putImageData(imageData, 0, 0);
}

Performance note: This reads and writes every pixel in CPU, at 1920×400 sky strip that's ~300K pixel operations. At 60fps this is unusable. Pre-render the sky once and cache it.

WebGL: sub-pixel dither in the vertex shader

If sky is rendered as a quad in WebGL, add a small dither in the fragment shader:

// In sky fragment shader
vec3 color = mix(u_sky_bottom, u_sky_top, v_position.y);
// Triangular noise: two rand calls averaged (removes DC bias)
float r1 = fract(sin(dot(gl_FragCoord.xy, vec2(127.1, 311.7))) * 43758.5);
float r2 = fract(sin(dot(gl_FragCoord.xy, vec2(269.5, 183.3))) * 31415.9);
float noise = (r1 + r2 - 1.0) / 255.0; // ±0.5/255 range
gl_FragColor = vec4(color + noise, 1.0);

This is the exact equivalent of what MonoGame's GPU vertex color interpolation achieves: sub-pixel noise eliminates quantization bands at zero visual cost.


9. Chromatic Aberration (Bonus: Retro Lens Effect)

Not in the current desktop app but frequently paired with the above effects for a retro/filmic look.

uniform float u_aberration_strength;

void main() {
  vec2 uv = v_uv;
  vec2 offset = (uv - 0.5) * u_aberration_strength;

  float r = texture2D(u_scene, uv + offset).r;
  float g = texture2D(u_scene, uv).g;
  float b = texture2D(u_scene, uv - offset).b;

  gl_FragColor = vec4(r, g, b, 1.0);
}

Three texture samples per pixel. Negligible cost. Works well combined with vignette in a single composite shader pass.


Implementation Priorities and Recommendations

Effect Approach When to implement
Vignette CSS radial-gradient overlay Phase 1 (now)
Glow circles Canvas 2D screen compositing Phase 1 (now)
Scene transitions Dual-canvas opacity lerp Phase 1 (now)
Sky anti-banding WebGL vertex dither or SVG noise Phase 1 (now)
CSS mood filters CSS filter on <canvas> Phase 1 (now)
Bloom (full) WebGL 3-pass pipeline Phase 2 (when WebGL pipeline exists)
Dithering WebGL Bayer matrix shader Phase 2
Palette LUT WebGL 3D LUT shader Phase 2
Chromatic aberration WebGL shader (single pass) Phase 3 (optional)

Key architectural decision: The Phase 1 effects (CSS + Canvas 2D) can all be implemented without a WebGL pipeline. When Phase 2 effects are needed, set up the WebGL render-to-texture infrastructure once, and all shader-based effects can share it.

SvelteKit component pattern:

<script>
  import { onMount, onDestroy } from 'svelte';

  let canvas;
  let gl;
  let frameId;

  onMount(() => {
    gl = canvas.getContext('webgl2');
    // init shaders, framebuffers, etc.
    frameId = requestAnimationFrame(renderLoop);
  });

  onDestroy(() => {
    cancelAnimationFrame(frameId);
    // cleanup WebGL resources
    gl.getExtension('WEBGL_lose_context')?.loseContext();
  });
</script>

<div class="scene-wrapper">
  <canvas bind:this={canvas} />
  <div class="vignette-overlay" />  <!-- CSS vignette -->
</div>

Browser Compatibility Summary

Feature Chrome Firefox Safari Notes
CSS radial-gradient 100% 100% 100% Universal
Canvas 2D compositing 100% 100% 100% Universal
WebGL 2.0 100% 100% 100% (15+) Safari support since 2021
CSS filter 100% 100% 100% All non-blur filters cheap
mix-blend-mode: screen 100% 100% 100% Universal
OffscreenCanvas Chrome/Edge/FF FF 105+ Limited Safari support incomplete
View Transitions API Chrome 111+ FF 119+ 18+ Progressively enhanced
SVG feTurbulence 100% 100% 100% Universal
image-rendering: pixelated Yes Yes Yes ~95% global coverage

Sources