Skip to content

All experiments

ASCII / dithering

WebGL2 · fragment shader

The image runs through a 4×4 threshold grid and comes back as characters. A toggle sets the result next to the original.

Source frame of the demo: a contact sheet of numbered page-layout thumbnails.

This browser has no WebGL2. The original frame and the code below stay.

8 px

demo chunk: 3.3 KB

Switch the mode and drag the slider. The result holds still, because the shader makes one pass over the image and waits.

What's hard here

A Bayer matrix is sixteen thresholds arranged in a 4×4 square. A pixel takes ink when the brightness under it fails to clear the threshold belonging to its place in the grid. The numbers are shuffled so that two consecutive thresholds never land side by side. One bit then yields a regular weave rather than blotches.

Brightness is read from the centre of the cell. If the threshold looked at the pixel beneath it, it would work at full resolution and the grid would never show. The slider resizes that cell, so you can catch the moment halftone turns into pattern.

None of the glyphs live in a file. A 2D canvas rasterises Geist Mono, the typeface of this page, into a single strip of tiles, and the shader picks a tile by cell brightness. Our own type comes back into the image as material.

What it weighs

The experiment next door, the particle field, pulls 278 KB of lazy code. That is three.js with its WebGPU renderer, because a compute shader does not happen without a framework. Here there is no library at all: one triangle with no buffer, one fragment shader, an atlas made with a 2D canvas. The demo chunk weighs 3.3 KB gzipped, over eighty times less. Both ends of the scale run on this site and each one pays its own bill.

How it's built

#version 300 es
precision highp float;

uniform sampler2D uImage;
uniform sampler2D uAtlas;
uniform vec2 uSize;
uniform float uCell;
uniform float uGlyphs;
uniform float uGlyphAspect;
uniform float uImageAspect;
uniform vec3 uInk;
uniform vec3 uPaper;
uniform bool uAscii;

out vec4 fragColor;

// Macierz Bayera 4x4: kolejność, w jakiej 16 sąsiednich pikseli ma się
// zapalać. Liczby są permutacją 0..15 ułożoną tak, żeby każde dwa kolejne
// progi wypadały jak najdalej od siebie w kwadracie. Stąd bierze się
// regularny splot zamiast plam.
const float BAYER[16] = float[16](
   0.0,  8.0,  2.0, 10.0,
  12.0,  4.0, 14.0,  6.0,
   3.0, 11.0,  1.0,  9.0,
  15.0,  7.0, 13.0,  5.0
);

float luma(vec3 color) {
  return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

// Kadrowanie jak object-fit: cover. Rama demo ma inne proporcje niż plik, a
// obraz ma zostać obrazem: przycinamy nadmiar zamiast go rozciągać. Ten sam
// rachunek trzyma <img> pod spodem, więc oryginał i wynik pokazują dokładnie
// ten sam wycinek.
vec2 coverUv(vec2 px) {
  float frame = uSize.x / uSize.y;
  vec2 scale = frame > uImageAspect
    ? vec2(1.0, uImageAspect / frame)
    : vec2(frame / uImageAspect, 1.0);
  return (px / uSize - 0.5) * scale + 0.5;
}

// Jasność liczona ze ŚRODKA komórki, nie z bieżącego piksela: cała komórka
// ma dostać jedną decyzję, inaczej próg działałby na pełnej rozdzielczości i
// krata przestałaby być widoczna.
float cellLuma(vec2 cell, vec2 cellSize) {
  vec2 center = (cell + 0.5) * cellSize;
  return luma(texture(uImage, coverUv(center)).rgb);
}

void main() {
  if (uAscii) {
    vec2 cellSize = vec2(uCell * uGlyphAspect, uCell);
    vec2 cell = floor(gl_FragCoord.xy / cellSize);
    float level = cellLuma(cell, cellSize);

    // jasność komórki wybiera glif, miejsce w komórce wybiera piksel glifu
    float glyph = floor(clamp(level, 0.0, 0.999) * uGlyphs);
    vec2 inside = fract(gl_FragCoord.xy / cellSize);
    vec2 atlasUv = vec2((glyph + inside.x) / uGlyphs, 1.0 - inside.y);
    fragColor = vec4(mix(uPaper, uInk, texture(uAtlas, atlasUv).a), 1.0);
    return;
  }

  vec2 cell = floor(gl_FragCoord.xy / uCell);
  float level = cellLuma(cell, vec2(uCell));

  // próg z macierzy: piksel dostaje papier, gdy jasność go przebija
  int index = int(mod(cell.x, 4.0)) + int(mod(cell.y, 4.0)) * 4;
  float threshold = (BAYER[index] + 0.5) / 16.0;
  fragColor = vec4(mix(uInk, uPaper, step(threshold, level)), 1.0);
}
Get a quote