ASCII / dithering
Obraz przechodzi przez siatkę progów 4×4 i wraca jako znaki. Przełącznik pokazuje wynik obok oryginału.

Ta przeglądarka nie daje WebGL2. Zostaje oryginalny kadr i kod pod spodem.
Przełącz tryb i przeciągnij suwak. Wynik stoi w miejscu, bo shader liczy jedno przejście po obrazie i czeka.
Co tu jest trudne
Macierz Bayera to szesnaście progów ułożonych w kwadrat 4×4. Piksel dostaje atrament wtedy, kiedy jasność pod nim nie przebija progu przypisanego jego miejscu w kracie. Liczby są tak poprzestawiane, żeby dwa kolejne progi nigdy nie wypadły obok siebie. Z jednego bitu wychodzi wtedy regularny splot, a nie plamy.
Jasność bierze się ze środka komórki. Gdyby próg czytał piksel pod sobą, pracowałby na pełnej rozdzielczości i krata w ogóle nie byłaby widoczna. Suwak zmienia rozmiar tej komórki, więc widać moment, w którym półton przechodzi we wzór.
Glifów nie ma w żadnym pliku. Płótno 2D rasteryzuje Geist Mono, czyli pismo tej strony, do jednego pasa kafli, a shader wybiera kafel jasnością komórki. Nasz krój wraca do obrazu jako materiał.
Ile to waży
Sąsiedni eksperyment, czyli cząstki, ciągnie 278 KB leniwego kodu. Odpowiada za to three.js z rendererem WebGPU, bo compute shader bez frameworka nie działa. Tutaj nie ma ani jednej biblioteki: jeden trójkąt bez bufora, jeden fragment shader, atlas zrobiony płótnem 2D. Chunk demo waży 3,3 KB po gzipie, czyli ponad osiemdziesiąt razy mniej. Ta strona umie oba końce skali i pokazuje cenę jednego i drugiego.
Jak to jest zrobione
#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);
}