← All articles

[ Blog ]

July 9, 2026

9 min read

Halftone Dot Grid Image Looks Faint and Washed Out? Fix It

Halftone dot grid image looks faint and washed out? The fix: pre-mask the background into alpha and lift midtones before mapping tone to dot size.

WebGLCanvasShadersParticlesHalftoneCreative Coding
Halftone Dot Grid Image Looks Faint and Washed Out? Fix It

I built a halftone/LED-wall particle effect last month — a grid of dots whose size and opacity map to the brightness of a source photo, the kind of thing you've seen resolve out of noise on a hundred Awwwards hero sections. The code was correct. The math was correct. The result was a ghost: a barely-there smear of grey dots that never got darker than "meh," no matter how much I cranked the contrast in the source image. If your halftone dot grid image looks faint and washed out, you've hit the same wall I did — and the fix isn't in your dot-rendering shader, it's in how you sample the image in the first place.

This is the write-up I wish existed when I was three hours into "why won't this just look dark." Two separate averaging problems stack on top of each other, and until you understand both, no amount of shader tweaking saves you.

Why the image-to-grid brightness mapping is too dim

A halftone/particle grid needs one tone value per cell — one brightness number for a 20×20px patch of your source image, not 400 individual pixels. The cheap, correct way to get that is a box average: draw the source at the size it actually occupies on screen, then do a single drawImage downscale into a tiny cols × rows canvas with imageSmoothingEnabled turned on. The GPU/canvas compositor does the averaging for you — each output pixel becomes the hardware average of exactly one pitch×pitch cell. No manual pixel-walking loop, no getImageData math per cell.

// 1. Draw the source into a viewport-sized canvas, at its actual on-screen box.
const full = document.createElement('canvas');
full.width = viewportWidth;
full.height = viewportHeight;
const fullCtx = full.getContext('2d');
fullCtx.drawImage(sourceImage, boxX, boxY, boxWidth, boxHeight);

// 2. One downscale draw into the cols x rows target — this IS the box average.
const cols = 64;
const rows = 40;
const small = document.createElement('canvas');
small.width = cols;
small.height = rows;
const smallCtx = small.getContext('2d');
smallCtx.imageSmoothingEnabled = true;
smallCtx.imageSmoothingQuality = 'high';
smallCtx.drawImage(full, 0, 0, cols, rows);

// 3. Read the tone per cell.
const { data } = smallCtx.getImageData(0, 0, cols, rows);

That part is correct, and most tutorials stop right there — which is exactly why the effect looks washed out. The problem is that averaged tone is not display tone. Two things are working against you:

1. Box-averaging pulls every tone toward the mean. Edge cells straddling your subject's silhouette are only part-covered by anything dark, so they average toward whatever's behind them. Interior cells rarely hit a clean 1.0 either — real photos don't have flat blocks of pure black or white the size of a grid cell, so almost every sampled value lands somewhere in a narrow midrange band. Feed that band straight into a linear tone → alpha or tone → dotSize map and the whole image renders as a uniform grey smear, never reaching the extremes that would read as "bold."

2. A "removed" background is still opaque pixels. If your source is a subject cut out onto a white or near-white fill (a common background-removal export), those white pixels are not transparent — they're just light-colored, fully opaque RGB. When the downscale averages a cell that straddles the subject edge, the white background bleeds straight into the subject's tone, which is what produces that faint "glow" around a cutout silhouette instead of a clean edge.

Both problems compound: dim in the middle, glowing at the edges. Fixing only one gets you halfway.

The fix: push tone into alpha before you average

The trick is to stop treating the background as "just another pixel" before the box-average ever runs. Pre-pass the source image once, off the main averaging path: for every pixel, either drop it to transparent (it's background) or rewrite it as pure white with the tone encoded in the alpha channel, not the RGB.

function preMaskToAlpha(sourceCanvas, { lumCeiling = 0.92, alphaFloor = 0.05 } = {}) {
  const ctx = sourceCanvas.getContext('2d');
  const { width, height } = sourceCanvas;
  const img = ctx.getImageData(0, 0, width, height);
  const d = img.data;

  for (let i = 0; i < d.length; i += 4) {
    const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3] / 255;
    const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;

    // Background: near-white, or already low alpha from a bg-removal export.
    if (lum > lumCeiling || a < alphaFloor) {
      d[i] = d[i + 1] = d[i + 2] = 255;
      d[i + 3] = 0; // fully transparent — cannot bleed into a downscale
      continue;
    }

    // Subject: encode tone as coverage, not colour. Stretch so the
    // remaining tonal range fills 0..1 instead of clustering near lumCeiling.
    const stretched = Math.min(1, (1 - lum) / (1 - lumCeiling * 0.4));
    d[i] = d[i + 1] = d[i + 2] = 255;
    d[i + 3] = Math.round(stretched * 255);
  }

  ctx.putImageData(img, 0, 0);
  return sourceCanvas;
}

Run this once, before step 1 of the box-average pipeline above. Now every subject pixel is rgba(255,255,255, tone) and every background pixel is fully transparent. When the downscale averages a cell, imageSmoothingEnabled's hardware blend does coverage × alpha math — a cell that's half background, half subject correctly comes out at roughly half the subject's alpha, instead of averaging toward a bright RGB value. There's no white to bleed in because the "white" carries zero opacity. The tone lives entirely in alpha, so alpha compositing does exactly the box-average math you want, for free.

Lift the midtones in the shader, not the source

Pre-masking fixes the background bleed, but you'll still find the shape reads as flat and grey rather than punchy — that's problem #1 from above, the regression-to-the-mean from box averaging. The fix is a midtone lift applied after sampling, right where you map tone to dot size or opacity:

// Fragment shader (or vertex shader driving per-instance dot scale)
uniform sampler2D uToneMap; // cols x rows texture, tone in .a

void main() {
  float tone = texture2D(uToneMap, vGridUv).a;

  // Midtone lift: pow < 1 pushes mid-range values up toward 1.0
  // without clipping the true blacks (tone == 0 stays 0) or the
  // true whites (tone == 1 stays 1). 0.5-0.7 is the usable range;
  // 0.6 is a good starting point for most photos.
  float lit = pow(tone, 0.6);

  float dotSize = mix(minDotSize, maxDotSize, lit);
  float dotAlpha = mix(minAlpha, lit, step(0.001, lit));

  // ... drive gl_PointSize / instance scale and fragment alpha from lit
}

A pow(tone, 0.6) curve leaves 0 at 0 and 1 at 1, but bows every value in between upward — a raw tone of 0.3 becomes roughly 0.5 after the lift. That's the difference between a shape that visually separates from the ambient dot field and one that dissolves into it. Without this step, even a perfectly pre-masked image still looks anemic, because a box-averaged photograph almost never produces tones near the extremes on its own.

Tune it by screenshot, at real DPR

The two fixes above get the math right, but the last mile is visual: take a real screenshot of the effect at the device pixel ratio you actually ship at, not at dpr: 1 in a desktop-scaled dev window. Sub-2px dots rendered at low alpha (under ~0.2) are functionally invisible at dpr: 1 — they round down to nothing or anti-alias into a haze the eye reads as "still faint," even though your tone math is now correct. If your target hardware runs at dpr: 2 or 3, verify there too; a dot that reads fine at 3x can vanish on a 1x display. Push your minAlpha and minDotSize floors up until the darkest tone in your image is still clearly a dot, not a rumor of one, at the lowest DPR you support.

FAQ

Why does my canvas drawImage downscale halftone effect look dim?

Because a single drawImage downscale is a correct box average, but box-averaged tone regresses toward the mean — few cells reach a true 0 or 1 — and a linear tone-to-alpha or tone-to-size map renders that narrow midrange band as a flat grey. Apply a midtone lift (pow(tone, 0.6)) after sampling, before driving dot size/opacity.

Why is my image-to-particle-grid brightness mapping too dim even with high-contrast source photos?

High source contrast doesn't survive the downscale unless your grid resolution is fine relative to detail size — box-averaging a busy region still lands near the mean. Confirm your pre-pass stretches tone into alpha (not RGB) before averaging, then add the midtone lift; the combination recovers the contrast the raw average destroyed.

How do I stop WebGL particle image sampling from bleeding the background into the subject?

Pre-mask the source once: any near-white or low-alpha pixel becomes fully transparent, and every subject pixel is rewritten as white-with-tone-in-alpha. Because the background carries zero alpha, it contributes nothing when the box-average blends a cell straddling the silhouette edge — there's no bright RGB left to bleed in.

What's the correct box-average image sampling algorithm for canvas?

Draw the source at its actual on-screen box into a viewport-sized canvas, then do one drawImage downscale into a cols × rows target canvas with imageSmoothingEnabled (and imageSmoothingQuality = 'high') enabled. That single hardware-accelerated draw call is the average — never loop pixels manually per cell, it's slower and gives you no better result.

How does a midtone lift shader tone mapping curve work?

pow(tone, 0.6) (or any exponent under 1) leaves black and white fixed at 0 and 1 but bows the curve so mid-range input values map to higher output values — a photograph's typical narrow band of averaged tones gets stretched back out toward visible contrast. Apply it after sampling and before driving any visual property (size, alpha, colour) from tone.


Same lesson, two fixes: a drawImage downscale is the right box-average, but you have to pre-mask backgrounds into alpha so they can't bleed, and lift midtones with pow(tone, 0.6) after sampling so the shape actually separates from the ambient field. Skip either one and you're back to a faint ghost.

If you're building the shader side of an effect like this, my GLSL shaders tutorial for web developers covers the uniform/varying pipeline this technique sits on top of, and the WebGL image displacement hover effect write-up walks through a related image-to-shader sampling pattern. Once the particle count climbs, Three.js performance optimization is the next thing worth reading. Reference docs: MDN — CanvasRenderingContext2D.drawImage() and MDN — imageSmoothingEnabled.

If you want this kind of effect built into a real hero section, the projects page has shipped examples, and this is exactly the sort of thing I take on in creative-developer engagements.