Projects bibleweb Docs atmosphere-pixel-art-web.md

Pixel Art Rendering Techniques on the Web

Last modified March 28, 2026

Pixel Art Rendering Techniques on the Web

Context: Porting a MonoGame C# desktop app's pixel art aesthetic to a SvelteKit web app. The desktop app renders procedurally at the pixel level — mountains via Perlin noise, pixel-by-pixel water, 5x7 pixel fonts, low-res terrain silhouettes. This document covers how to faithfully replicate that look on the modern web.


1. CSS image-rendering Property

How It Works

The image-rendering CSS property tells the browser which algorithm to use when scaling an image up or down. For pixel art, the default bicubic smoothing destroys the look entirely — you need nearest-neighbor.

/* Full compatibility stack */
img, canvas {
  image-rendering: pixelated;           /* Chrome, Safari, Opera — nearest-neighbor up */
  image-rendering: -moz-crisp-edges;   /* Firefox */
  image-rendering: crisp-edges;        /* CSS standard */
}

Key values:

Value Algorithm Best for
pixelated Nearest-neighbor on upscale, area-average on downscale Pixel art scaled up
crisp-edges Nearest-neighbor in both directions Pixel art at any scale
auto / smooth Bicubic/bilinear Photos, non-pixel art

Browser Compatibility

All modern browsers support both pixelated and crisp-edges as of 2024:

  • Chrome 41+, Edge 79+, Safari 10+, Firefox 3.6+, Opera 11.6+

The only rough edge: at non-integer zoom levels (e.g., browser zoomed to 110%), CSS pixels may not align with physical device pixels, causing some pixels to render slightly larger than others — producing uneven "chunky" edges rather than true nearest-neighbor. This is a known limitation with no perfect CSS-only workaround.

When to Use vs. Avoid

Use when: Displaying pre-made pixel art sprites or backgrounds that just need to be scaled up cleanly. Simple, zero-JS approach for static or near-static images.

Avoid when: You need guaranteed pixel-perfect output on all zoom levels and Retina displays — use the canvas approach instead.


2. Canvas Pixel-Perfect Rendering

imageSmoothingEnabled

Any time you draw an image onto a canvas context — via drawImage(), drawImage() with scaling arguments, or ctx.scale() — the browser may apply smoothing. Disable it explicitly:

const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
// Vendor-prefixed versions for older browsers:
ctx.webkitImageSmoothingEnabled = false;
ctx.mozImageSmoothingEnabled = false;
ctx.msImageSmoothingEnabled = false;

This must be re-applied after ctx.setTransform() or ctx.restore() calls, as those can reset context state.

Integer Scaling

Non-integer scale factors cause pixel bleed. If a 128x128 source image is drawn into a 100x100 area, each source pixel maps to 0.78 canvas pixels — the browser has no choice but to blend. The fix is to always scale by integer multiples:

// BAD: 128 / 100 = 1.28 — not an integer, pixels blend
ctx.drawImage(img, 0, 0, 128, 128, 0, 0, 100, 100);

// GOOD: 128 * 4 = 512 — clean 4x scale
ctx.drawImage(img, 0, 0, 128, 128, 0, 0, 512, 512);

// If you must use fractional transform scales, ensure destination
// dimensions are integer multiples of the source / scale
ctx.scale(0.8, 0.8);
ctx.drawImage(img, 0, 0, 128, 128, 0, 0, 160, 160); // 128 / 0.8 = 160 ✓

Device Pixel Ratio (DPR) Handling

On Retina and HiDPI displays, window.devicePixelRatio is 2 or higher. A canvas set to 480×270 CSS pixels is rendered at 480×270 physical pixels — the display then upscales it using bilinear filtering, making everything blurry regardless of your nearest-neighbor settings.

The correct pattern:

function resizeCanvas(canvas, ctx, logicalWidth, logicalHeight) {
  const dpr = window.devicePixelRatio || 1;

  // Physical pixel dimensions
  canvas.width  = logicalWidth  * dpr;
  canvas.height = logicalHeight * dpr;

  // CSS display size unchanged
  canvas.style.width  = logicalWidth  + 'px';
  canvas.style.height = logicalHeight + 'px';

  // Scale the context so drawing commands use logical coordinates
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.imageSmoothingEnabled = false;
}

For a pixel-art game, you often don't want DPR scaling — you want each game pixel to occupy multiple physical pixels intentionally. In that case, consciously omit the DPR multiplication but apply image-rendering: pixelated to the canvas CSS so the browser's CSS scaling uses nearest-neighbor.


3. Low-Res Rendering with Upscaling (The Retro Game Pattern)

This is the canonical technique used by browser-based retro games: render to a small internal canvas (the "game resolution"), then scale that canvas up to fill the screen using nearest-neighbor. This is exactly how a SNES or Game Boy game works — the hardware renders at 256×224, and the output is scaled to whatever screen you have.

Implementation

const GAME_W = 320;  // Internal game resolution
const GAME_H = 180;

// Off-screen buffer — your entire game renders here
const offscreen = document.createElement('canvas');
offscreen.width  = GAME_W;
offscreen.height = GAME_H;
const gameCtx = offscreen.getContext('2d');

// Display canvas — fills the window
const display = document.getElementById('display');
const displayCtx = display.getContext('2d');

function resize() {
  const dpr = window.devicePixelRatio || 1;
  const xScale = Math.floor((window.innerWidth  * dpr) / GAME_W);
  const yScale = Math.floor((window.innerHeight * dpr) / GAME_H);
  const scale  = Math.min(xScale, yScale); // integer scale, letterbox

  display.width  = window.innerWidth  * dpr;
  display.height = window.innerHeight * dpr;
  display.style.width  = window.innerWidth  + 'px';
  display.style.height = window.innerHeight + 'px';

  // Center the game in the display
  const offsetX = Math.floor((display.width  - GAME_W * scale) / 2);
  const offsetY = Math.floor((display.height - GAME_H * scale) / 2);

  displayCtx.setTransform(scale, 0, 0, scale, offsetX, offsetY);
  displayCtx.imageSmoothingEnabled = false;
}

function gameLoop() {
  // 1. Draw everything into the small game canvas
  gameCtx.clearRect(0, 0, GAME_W, GAME_H);
  renderScene(gameCtx);

  // 2. Blit the small canvas up to the display canvas (nearest-neighbor)
  displayCtx.clearRect(0, 0, display.width, display.height);
  displayCtx.drawImage(offscreen, 0, 0);

  requestAnimationFrame(gameLoop);
}

window.addEventListener('resize', resize);
resize();
requestAnimationFrame(gameLoop);

Why integer-scale only: Fractional scaling (e.g., 2.5x) will cause half-pixel misalignments. Using Math.floor() guarantees you only ever scale by whole integers, producing perfectly uniform pixel blocks.

Performance note: The blit from offscreen to display is a single drawImage() call — extremely cheap. All the expensive work happens in the small game canvas.


4. Procedural Pixel Art — Terrain, Water, Fire

For a desktop app that generates mountains via Perlin noise and renders water pixel-by-pixel, the web equivalent is ImageData + putImageData.

ImageData for Per-Pixel Rendering

// Create a pixel buffer for the game canvas
const imageData = gameCtx.createImageData(GAME_W, GAME_H);
const pixels = imageData.data; // Uint8ClampedArray, RGBA per pixel

function setPixel(x, y, r, g, b, a = 255) {
  const i = (y * GAME_W + x) * 4;
  pixels[i]     = r;
  pixels[i + 1] = g;
  pixels[i + 2] = b;
  pixels[i + 3] = a;
}

// After filling the buffer:
gameCtx.putImageData(imageData, 0, 0);

Perlin Noise Terrain

JavaScript Perlin noise libraries (e.g., simplex-noise, noisejs) provide the same API as the C# equivalent. The pattern for a mountain silhouette:

import { createNoise2D } from 'simplex-noise';
const noise2D = createNoise2D();

function renderTerrain(pixels, width, height, seed) {
  for (let x = 0; x < width; x++) {
    // Stack multiple octaves (Fractal Brownian Motion)
    let elevation = 0;
    elevation += noise2D(x * 0.01  + seed, 0) * 1.0;
    elevation += noise2D(x * 0.02  + seed, 0) * 0.5;
    elevation += noise2D(x * 0.04  + seed, 0) * 0.25;
    elevation = (elevation / 1.75 + 1) / 2; // normalize 0..1

    const mountainTop = Math.floor((1 - elevation) * height);
    for (let y = mountainTop; y < height; y++) {
      setPixel(x, y, 40, 50, 70); // mountain color
    }
  }
}

Animated Water (Fire Propagation Model)

Classic pixel water and fire use an accumulation buffer — each frame propagates heat/wave values from neighbors:

const waterBuf = new Float32Array(GAME_W * GAME_H);

function stepWater() {
  for (let y = 1; y < GAME_H - 1; y++) {
    for (let x = 1; x < GAME_W - 1; x++) {
      const i = y * GAME_W + x;
      // Average of 4 neighbors minus center (wave equation)
      waterBuf[i] = (
        waterBuf[i - 1] +
        waterBuf[i + 1] +
        waterBuf[i - GAME_W] +
        waterBuf[i + GAME_W]
      ) / 2 - waterBuf[i];
      waterBuf[i] *= 0.97; // damping
    }
  }
}

function renderWater(pixels) {
  for (let i = 0; i < GAME_W * GAME_H; i++) {
    const val = Math.max(0, Math.min(255, 128 + waterBuf[i] * 20));
    pixels[i * 4]     = 0;
    pixels[i * 4 + 1] = Math.floor(val * 0.6);
    pixels[i * 4 + 2] = Math.floor(val);
    pixels[i * 4 + 3] = 255;
  }
}

Performance warning: putImageData is CPU-bound. For large resolutions, move this to an OffscreenCanvas in a Web Worker (see section 8). At a game resolution of 320×180, it runs comfortably at 60fps on the main thread.


5. Pixel Art Asset Pipelines

Format Choice

For web pixel art assets, use PNG exclusively. Never JPEG — it applies lossy compression that introduces color fringing at hard pixel edges, visually destroying pixel art.

WebP lossless is 15–60% smaller than PNG and supported in all modern browsers. However, for tiny pixel art sprites, the size savings are minimal and PNG has better tooling. WebP is worth considering for large tilesets or background images.

APNG vs. GIF for animated sprites: APNG supports full 24-bit color and alpha transparency; GIF is limited to 256 colors with binary transparency. Use APNG for sprite animations unless you need maximum compatibility with ancient browsers.

Spritesheet vs. Individual Files

Always pack sprites into spritesheets for web delivery. Individual files incur HTTP request overhead and cause layout shifts. A spritesheet packs all animation frames into a single image file, with JavaScript reading sub-rectangles:

const TILE_SIZE = 16;
function drawSprite(ctx, sheet, frameIndex, x, y) {
  const cols = sheet.width / TILE_SIZE;
  const sx = (frameIndex % cols) * TILE_SIZE;
  const sy = Math.floor(frameIndex / cols) * TILE_SIZE;
  ctx.drawImage(sheet, sx, sy, TILE_SIZE, TILE_SIZE, x, y, TILE_SIZE, TILE_SIZE);
}

Color Palette Limiting

Constraining your art to a fixed palette (16, 32, or 64 colors) achieves two goals: authentic retro aesthetics and significantly better PNG compression (indexed PNG files with 16 colors are a fraction the size of 24-bit PNG). Tools like Aseprite export indexed PNGs directly. In code, you can quantize colors post-load using a fragment shader or a canvas lookup table.


6. Pre-Rendered vs. On-the-Fly Generation

Pre-Rendered Backgrounds (Images)

Generate the background once — either at build time or on first load — and cache it as a PNG blob or in an off-screen canvas. Redraw it each frame with a single drawImage() call.

// Generate once
const bgCanvas = new OffscreenCanvas(GAME_W, GAME_H);
const bgCtx = bgCanvas.getContext('2d');
renderMountains(bgCtx); // expensive, runs once

// Reuse every frame (nearly free)
gameCtx.drawImage(bgCanvas, 0, 0);

Pros: Essentially zero per-frame CPU cost. Deterministic output. Suitable for static or slowly-changing backgrounds.

Cons: Memory cost (one canvas per background layer). Cannot respond to real-time parameters (e.g., time of day shifting the mountain lighting) without re-generating.

On-the-Fly Generation

Run the Perlin noise and putImageData loop every frame. Enables live animation (scrolling parallax terrain, time-varying water color), but costs CPU every frame.

Recommended hybrid: Generate the static background once off-thread in a Web Worker, post the result back as a bitmap, and only run the per-frame loop for animated elements (water surface, fire, weather effects).


7. Retro Aesthetic Techniques

CRT Scanlines (CSS Overlay)

The simplest approach: a full-viewport div overlay with a repeating CSS gradient simulating dark scanlines:

.crt-overlay {
  position: fixed;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    transparent 0px,
    transparent 1px,
    rgba(0, 0, 0, 0.15) 1px,
    rgba(0, 0, 0, 0.15) 2px
  );
  z-index: 100;
}

For higher fidelity, a CSS ::after pseudo-element with a vignette:

canvas::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0.5) 100%);
  pointer-events: none;
}

WebGL CRT Shader (Full Effect)

For barrel distortion, chromatic aberration, and phosphor glow, a WebGL post-processing pass is required. The CRTFilter.js library implements this in ~200 lines of GLSL and attaches to a canvas via WebGL. Key shader uniforms: scanlineIntensity, barrelDistortion, chromaticAberration, bloomStrength.

Ordered Dithering (Bayer Matrix)

Dithering creates the illusion of additional colors by arranging pixel patterns. A 4x4 Bayer matrix dither in a GLSL fragment shader:

float bayer4[16] = float[16](
   0.0/16.0,  8.0/16.0,  2.0/16.0, 10.0/16.0,
  12.0/16.0,  4.0/16.0, 14.0/16.0,  6.0/16.0,
   3.0/16.0, 11.0/16.0,  1.0/16.0,  9.0/16.0,
  15.0/16.0,  7.0/16.0, 13.0/16.0,  5.0/16.0
);

float threshold = bayer4[int(mod(fragCoord.x, 4.0)) + int(mod(fragCoord.y, 4.0)) * 4];
float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114));
vec3 dithered = lum < threshold ? vec3(0.0) : vec3(1.0);

On the CSS/Canvas side without WebGL, dithering can be approximated by manually writing checkerboard pixel patterns in ImageData for areas transitioning between two palette colors.

Color Palette Locking

Snap all drawn colors to a fixed palette at render time. This is most naturally done in a post-processing canvas pass:

// After drawing the scene, quantize each pixel to the nearest palette color
const frame = ctx.getImageData(0, 0, W, H);
for (let i = 0; i < frame.data.length; i += 4) {
  const closest = findClosestPaletteColor(
    frame.data[i], frame.data[i+1], frame.data[i+2]
  );
  [frame.data[i], frame.data[i+1], frame.data[i+2]] = closest;
}
ctx.putImageData(frame, 0, 0);

8. Device Pixel Ratio — HiDPI / Retina Challenges

The core problem: on a 2x Retina display, a 400px CSS canvas has 800 physical pixels. If your canvas width attribute is only 400, the OS upscales the canvas using bilinear filtering — blurring your crisp pixels.

Pattern A: Scale Canvas to Physical Pixels, Then Apply CSS image-rendering

This is the right approach for pixel art specifically:

const dpr = window.devicePixelRatio;
canvas.width  = GAME_W * dpr;
canvas.height = GAME_H * dpr;
canvas.style.width  = GAME_W + 'px';
canvas.style.height = GAME_H + 'px';
canvas.style.imageRendering = 'pixelated';

ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = false;

Pattern B: Intentional Low-Res (The Pixel Art Look)

If you want each game pixel to be 2x2 or 4x4 physical pixels, do NOT scale the canvas by DPR. Instead, keep the canvas small and let CSS image-rendering: pixelated handle the upscale. At 2x DPR this is handled automatically — CSS pixels become 2 physical pixels, and nearest-neighbor preserves the look:

canvas {
  width: 640px;   /* 2x game resolution */
  height: 360px;
  image-rendering: pixelated;
}
canvas.width  = 320; // internal game res
canvas.height = 180;
ctx.imageSmoothingEnabled = false;

This approach means your game sees a 320×180 canvas, draws at game-pixel precision, and the browser scales it up to 640×360 CSS pixels (or higher, on Retina) using nearest-neighbor — giving you clean 2x or 4x blocks.

Handling DPR Changes

DPR can change when a window moves between monitors with different pixel densities. Listen for this:

let removeListener;
function watchDPR() {
  removeListener?.();
  const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
  removeListener = () => mq.removeEventListener('change', onDPRChange);
  mq.addEventListener('change', onDPRChange);
}
function onDPRChange() {
  resize();
  watchDPR();
}
watchDPR();

9. Pixel Art Animation

Frame-by-Frame (Sprite Sheets)

The classic approach: a sprite sheet image contains all animation frames in a grid. At each animation tick, advance the frame index and draw the appropriate sub-rectangle:

class Animator {
  constructor(sheet, frameW, frameH, frameCount, fps) {
    this.sheet = sheet;
    this.frameW = frameW;
    this.frameH = frameH;
    this.frameCount = frameCount;
    this.frameDuration = 1000 / fps;
    this.frame = 0;
    this.elapsed = 0;
  }

  update(dt) {
    this.elapsed += dt;
    if (this.elapsed >= this.frameDuration) {
      this.frame = (this.frame + 1) % this.frameCount;
      this.elapsed %= this.frameDuration;
    }
  }

  draw(ctx, x, y) {
    const cols = Math.floor(this.sheet.width / this.frameW);
    const sx = (this.frame % cols) * this.frameW;
    const sy = Math.floor(this.frame / cols) * this.frameH;
    ctx.drawImage(this.sheet, sx, sy, this.frameW, this.frameH, x, y, this.frameW, this.frameH);
  }
}

Procedural Animation

For effects like water ripples, fire, or weather, procedural animation is superior to pre-drawn frames — infinite variation, no sprite sheet required. The wave equation loop from section 4 is a good example. Procedural animations update every frame using mathematical rules rather than discrete keyframes.

Smooth Movement at Low Resolution

Pixel art characters should move in whole-pixel steps to avoid sub-pixel blurring. Track position as a float internally, but floor to integers before drawing:

// Internal position — floats for smooth physics
entity.x += entity.vx * dt;
entity.y += entity.vy * dt;

// Render at integer coordinates
ctx.drawImage(entity.sprite, Math.floor(entity.x), Math.floor(entity.y));

For camera scrolling, the same rule applies: keep the camera position as a float for smooth panning math, but snap to integer pixels at draw time. This preserves the pixelated aesthetic while allowing smooth acceleration curves.

Animation Frame Rate vs. Display Rate

Pixel art games typically animate at 12–24fps (the "animation frame rate") while running the display loop at 60fps. Decouple the two:

const ANIM_FPS = 12;
const ANIM_TICK = 1000 / ANIM_FPS;

let lastAnimTick = 0;
function gameLoop(timestamp) {
  const dt = timestamp - lastFrameTime;
  lastFrameTime = timestamp;

  // Physics and movement run at display rate (60fps)
  updatePhysics(dt);

  // Sprite frames advance at animation rate (12fps)
  if (timestamp - lastAnimTick >= ANIM_TICK) {
    advanceFrames();
    lastAnimTick += ANIM_TICK;
  }

  render();
  requestAnimationFrame(gameLoop);
}

10. OffscreenCanvas and Web Workers

For heavy procedural generation (Perlin noise terrain, per-pixel water), moving the work off the main thread prevents jank.

// main.js
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('./render.worker.js');
worker.postMessage({ canvas: offscreen, width: GAME_W, height: GAME_H }, [offscreen]);

// render.worker.js
self.onmessage = ({ data }) => {
  const { canvas, width, height } = data;
  const ctx = canvas.getContext('2d');
  ctx.imageSmoothingEnabled = false;

  function loop() {
    renderTerrain(ctx, width, height);
    renderWater(ctx, width, height);
    // No requestAnimationFrame in workers — use this instead:
    self.requestAnimationFrame(loop);
  }
  loop();
};

Browser support: Chrome 69+, Edge 79+, Firefox 105+, Safari 16.4+ — safe for all modern targets.

Caveat: Once you call transferControlToOffscreen(), the main thread loses all access to that canvas. Input handling and state must be passed via postMessage. For a SvelteKit app, keep the Svelte UI on the main thread and transfer only the game canvas.


Summary: Recommended Stack for This Port

Need Technique
Scale pixel art sprites CSS image-rendering: pixelated + canvas imageSmoothingEnabled = false
Game resolution 320×180 internal canvas, CSS-scaled to display
DPR handling Pattern B: small canvas + image-rendering: pixelated on the CSS side
Mountain generation simplex-noise + ImageData + putImageData, generated once into OffscreenCanvas
Water animation Wave equation buffer, putImageData every frame, move to Web Worker if perf needed
Retro feel CSS scanline overlay + optional Bayer dither pass
Sprite animation Spritesheet with integer-FPS decoupled from display loop
Assets Indexed PNG spritesheets; lossless WebP for large backgrounds

Sources