Przejdź do treści

Wszystkie eksperymenty

ASCII / dithering

WebGL2 · fragment shader

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

Kadr źródłowy demo: arkusz stykowy z ponumerowanymi miniaturami układów stron.

Ta przeglądarka nie daje WebGL2. Zostaje oryginalny kadr i kod pod spodem.

8 px

chunk demo: 3,3 KB

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);
}
Wyceń projekt