De kunst van dithering en retro shading voor het web
Tijdens dit proces ben ik me echt bewust geworden van de cruciale rol die post-processing speelt om een scène er echt goed uit te laten zien. Dit gaf antwoorden op frustraties die ik had bij eerdere React Three Fiber- en shader-projecten, waarbij mijn visie niet tot stand kwam, ongeacht hoeveel werk en zorg ik erin stak.
Het nemen van de tijd om custom post-processing effecten te bouwen, te combineren en mee te experimenteren, gaf me een extra creatieve uitlaatklep. Onder de vele typen die ik heb ontwikkeld, had ik een bijzondere voorkeur voor de "retro" effecten. Met subtiele details zoals dithering, kleurkwantisering of pixelisatie/CRT RGB-cellen, brengen ze een aangenaam contrast aan tussen het moderne weblandschap en een vervlogen tijdperk van technologie waar wij, kinderen uit de jaren '90 en vroege jaren 2000, soms naar verlangen.
Omdat ik veel tijd heb geïnvesteerd in het analyseren van elk aspect van deze effecten, wil ik dieper ingaan op de concepten die erachter zitten en de shader-technieken die ik onderweg heb geleerd. In dit artikel hoop ik je te overtuigen van de kracht van post-processing effecten en dat niets slaat op een elegante retro-vibe toegepast op een website. We kijken ook naar voorbeelden van dithering en pixel-art van getalenteerde makers die dezelfde processen gebruiken als ik later zal introduceren, evenals enkele van mijn eigen creaties.
Bronnen
- Color Quantization and Dithering: een geweldige video van @Acerola_t.
- Dithering on the GPU door Alex Charlton: een diepe duik in alternatieve kleurkwantiseringstechnieken.
- GM Shaders Mini: CRT van @XorDev: een uitgebreide analyse van shader-technieken om specifieke aspecten van CRT's na te bootsen.
---
Dithering-technieken
Dithering ontstond als een vroege grafische techniek om de hersenen van de kijker te misleiden, zodat ze meer kleur of vloeiendere overgangen in gradiënten en schaduwen zagen dan de machines van vroeger konden produceren. Dit werd gedaan door bewust ruis over een afbeelding of render toe te voegen.
Omdat kleurpaletten destijds zeer beperkt waren, was het voor game-designers essentieel om op dergelijke technieken te vertrouwen om hun visie te realiseren. Dit gaf games en media uit die specifieke periode—waarin computers alomtegenwoordig werden, maar geavanceerde grafische mogelijkheden nog ontbraken—een unieke look en feel.
Tegenwoordig is dithering meer een artistieke keuze dan een noodoplossing. Veel artiesten en game-designers gebruiken deze techniek als creatieve uitlaatklep om hun werk een unieke retro-vibe te geven, ter referentie aan het vroege game-tijdperk, of om te werken binnen zelfopgelegde kleurbeperkingen. Goede voorbeelden hiervan zijn:
- De game Basement Chronicle van Basement Studio: een goed uitgevoerde point-and-click game.
- De kunst van @loackme.
- Het gebruik van de originele Game Boy camera voor experimentele fotografie door @aweusmeuh, wat een subliem dithering-effect oplevert.
In dit artikel benaderen we dithering op dezelfde manier: om React Three Fiber/Three.js-projecten een unieke stijl te geven. We verkennen hoe de techniek werkt, implementeren het als een shader en bouwen een eerste iteratie van een custom dithering post-processing effect.
Een eerste aanzet tot dithering in React Three Fiber
Voor dit project maken we een custom post-processing effect. Door te vertrouwen op post-processing kunnen we een shader toepassen op een reeds gerenderde scène en de stijl aanpassen, vergelijkbaar met het toevoegen van een "image filter" aan een foto.
Pass VS Effect
Post-processing passes en effects zijn tools om hetzelfde doel te bereiken: de uiteindelijke render van de scène wijzigen. Ze werken echter heel verschillend:
- Passes kunnen op elkaar worden gestapeld. Ze nemen de output van de vorige pass als input, passen deze aan en sturen het resultaat naar de volgende pass, waarbij custom shaders één voor één worden toegepast.
- Effects worden samengevoegd in één enkele Effect Pass, wat de prestaties kan verbeteren. (Zie Effect Merging voor meer informatie).
Om een custom effect te maken, volgen we deze stappen:
- Declareer een klasse die overerft van
Effect. - Definieer de fragment shader en roep deze aan vanuit de parent constructor met het
superkeyword. - Definieer de set uniforms die nodig zijn voor het effect.
- Roep de functie
wrapEffectaan vanuit@react-three/post-processingmet de effect-klasse als argument. Hierdoor kan het effect als een JSX-component binnenEffectComposerworden gebruikt.
Voorbeeld van een custom shader post-processing effect in een R3F-scène:
import { OrbitControls, OrthographicCamera, useFBO } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import { wrapEffect, EffectComposer } from '@react-three/postprocessing';
import { Effect } from 'postprocessing';
import { Suspense, useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import fragmentShader from './fragmentShader.glsl';
class RetroEffectImpl extends Effect {
constructor() {
super('RetroEffect', fragmentShader, {
uniforms: new Map([]),
});
}
}
const RetroEffect = wrapEffect(RetroEffectImpl);
const Retro = () => {
const mesh = useRef();
return (
<>
<mesh receiveShadow castShadow>
<torusKnotGeometry args={[1, 0.25, 128, 100]} />
<meshStandardMaterial color="cyan" />
</mesh>
<EffectComposer>
<RetroEffect />
</EffectComposer>
</>
);
};
Als eerste stap bouwen we een eenvoudige luminance-based white noise dithering. Het idee hierachter is:
- Kijk naar de luminantie (helderheid) van elke pixel.
- Vergelijk deze met een willekeurig getal (vandaar de naam "white noise").
- Geef een witte of zwarte pixel terug, afhankelijk van of de luminantie boven of onder dat willekeurige getal valt.
White noise dithering geïmplementeerd in een fragment shader:
float random(vec2 c) {
return fract(sin(dot(c.xy, vec2(12.9898, 78.233))) * 43758.5453);
}
vec3 whiteNoiseDither(vec2 uv, float lum) {
vec3 color = vec3(0.0);
if (lum < random(uv)) {
color = vec3(0.0);
} else {
color = vec3(1.0);
}
return color;
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec4 color = texture2D(inputBuffer, uv);
float lum = dot(vec3(0.2126, 0.7152, 0.0722), color.rgb);
color.rgb = whiteNoiseDither(uv, lum);
outputColor = color;
}
Let op: De syntax voor de fragment shader van een effect verschilt van wat je gewend bent bij passes of materials. Er is geenmainfunctie, maar eenmainImagefunctie die deinputColorenuvcoördinaten van de huidige pixel bevat. Je hoeftgl_FragColorniet in te stellen; dit wordt afgehandeld door deoutputColorvariabele.
Daarnaast bieden effects handige preset-variabelen:
inputBuffer: een texture van de onderliggende scène.time: de huidige tijd.resolution: de breedte/hoogte van het venster.
Dit resulteert in een grijswaarsversie van de scène waarbij elke pixel die niet puur zwart of wit is, wordt gedithered, waardoor onze hersenen meer grijstinten zien.
Ordered Dithering en de Bayer-matrix
Het effect dat we zojuist hebben gebouwd werkt, maar omdat het vertrouwt op white noise, is het resultaat chaotisch. We kunnen hier orde in scheppen met ordered dithering.
Deze techniek maakt gebruik van een threshold map gedefinieerd via een Bayer-matrix. Deze matrix bevat waarden die bepalen of de kleur van een bepaalde pixel moet worden aangepast naar zwart of wit.
De Bayer-matrix De generieke formule voor de Bayer-matrix is: M(2n) = 1/(2n)^2 [[(2n)^2 M(n), (2n)^2 M(n) + 2],[(2n)^2 M(n) + 3, (2n)^2 * M(n) + 1]]
In de praktijk definiëren ontwikkelaars deze matrices vaak vooraf berekend:
- 2 x 2:
1/4 * [[0.0, 2.0], [3.0, 1.0]] - 4 x 4:
1/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]]
Om dit in GLSL te implementeren, bepalen we de luminantie van elke pixel en vergelijken we deze met de bijbehorende drempelwaarde uit de Bayer-matrix:
- Als het verschil tussen deze waarden positief is, is de pixel wit.
- Anders is de pixel zwart.
Ordered dithering met een 4x4 Bayer-matrix:
const mat4x4 bayerMatrix4x4 = mat4x4(
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
) / 16.0;
vec3 orderedDither(vec2 uv, float lum) {
vec3 color = vec3(0.0);
float threshold = 0.0;
int x = int(uv.x * resolution.x) % 4;
int y = int(uv.y * resolution.y) % 4;
threshold = bayerMatrix4x4[y][x];
if (lum < threshold + bias) {
color = vec3(0.0);
} else {
color = vec3(1.0);
}
return color;
}
Let op: De grootte van de Bayer-matrix beïnvloedt de output. Een grotere matrix zorgt voor een verfijnder dithering-effect.
Blue Noise Dithering
Naast ordered dithering is er ook blue noise dithering. Deze techniek wordt vaak gebruikt om "banding" (zichtbare kleurstappen) te elimineren in raymarching-scenes. In tegenstelling tot de vorige technieken, vertrouwt deze methode op een texture die via een uniform aan de shader wordt doorgegeven.
Implementatie in GLSL:
vec4 noise = texture2D(uNoise, gl_FragCoord.xy / 128.0);
float threshold = noise.r;
Hierbij is 128.0 de breedte/hoogte van de texture. De drempelwaarde wordt bepaald door het rode kleurkanaal van de grayscale noise texture.
Vergeet niet de texture wrapping eigenschappen in te stellen zodat de texture zowel horizontaal als verticaal herhaalt:
const texture = useTexture('/path/to/my/texture');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
Blue noise voelt minder repetitief en gestructureerd aan dan ordered dithering, maar is minder willekeurig dan white noise; een mooi middenpad.
(Opmerking: Sommige technieken zoals error diffusion of Floyd–Steinberg dithering zijn niet fragment shader-vriendelijk vanwege hun sequentiële aard.)
Kleurkwantisering (Color Quantization)
Tot nu toe hebben alle voorbeelden de scène omgezet naar zwart-wit. Dit komt omdat we de drempelwaarde berekenden op basis van luminantie en handmatig een zwarte of witte pixel teruggaven. Deze techniek wordt luminance-based dithering genoemd en comprimeert het kleurpalet naar 2-bit.
Kleurkwantisering is het proces van het reduceren van het aantal kleuren. Dit kan verder gaan dan alleen zwart en wit.
Grijstinten en kleuren
Om meer dan alleen zwart of wit te gebruiken, kunnen we een formule gebruiken om de dichtstbijzijnde kleur te vinden op basis van het gewenste aantal kleuren (n): floor(color * (n - 1) + 0.5) / (n - 1)
Grijswaarden kleurkwantisering in een custom effect:
vec3 dither(vec2 uv, float lum) {
vec3 color = vec3(lum);
int x = int(uv.x * resolution.x) % 8;
int y = int(uv.y * resolution.y) % 8;
float threshold = bayerMatrix8x8[y * 8 + x];
color.rgb += threshold;
color.r = floor(color.r * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
color.g = floor(color.g * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
color.b = floor(color.b * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
return color;
}
Deze formule werkt ook direct op de originele pixelkleur voor een full-color resultaat:
- Bij een palet van 2 kleuren per kanaal krijgen we $2^3 = 8$ kleuren.
- Bij een palet van 4 kleuren per kanaal krijgen we $4^3 = 64$ kleuren.
Kleurkwantisering implementatie:
vec3 dither(vec2 uv, vec3 color) {
int x = int(uv.x * resolution.x) % 8;
int y = int(uv.y * resolution.y) % 8;
float threshold = bayerMatrix8x8[y * 8 + x];
color.rgb += threshold;
color.r = floor(color.r * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
color.g = floor(color.g * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
color.b = floor(color.b * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
return color;
}
Custom kleurpaletten
Wat als we het aantal kleuren willen beperken tot een specifieke set kleuren? We kunnen de waarde van een grijsschaal-palet gebruiken om een texture te samplen die een custom kleurpalet definieert.
Implementatie van custom palet-sampling:
vec3 dither(vec2 uv, float lum) {
vec3 color = vec3(lum);
int x = int(uv.x * resolution.x) % 8;
int y = int(uv.y * resolution.y) % 8;
float threshold = bayerMatrix8x8[y * 8 + x];
color.rgb += threshold * 0.2;
color.r = floor(color.r * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
color.g = floor(color.g * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
color.b = floor(color.b * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
vec3 paletteColor = texture2D(palette, vec2(color.r)).rgb;
return paletteColor;
}
Kleurkwantisering op basis van Hue-Lightness
Een alternatieve techniek, geïntroduceerd door Alex Charlton, maakt gebruik van de HSL-waarden (Hue, Saturation, Lightness) van een kleur: 1 Zet de kleur om naar HSL. 2 Zoek de twee dichtstbijzijnde kleuren in "hue" (tint) vanuit een arbitrair palet. 3 Bereken de afstand tussen de pixel en de dichtstbijzijnde kleur. 4 Vergelijk deze afstand met de drempelwaarde van de Bayer-matrix om de eerste of tweede kleur te kiezen. 5 Doe hetzelfde voor de "lightness" (helderheid) om de uiteindelijke kleurintensiteit te bepalen.
Pixelisatie
Om de retro-look te voltooien, kunnen we de output downsamplen. Dit gebeurt door het UV-coördinatensysteem te hermappen en "vast te klikken" (snap) aan een raster.
Pixelisatie in GLSL:
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 normalizedPixelSize = pixelSize / resolution;
vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize);
vec4 color = texture2D(inputBuffer, uvPixel);
color.rgb = dither(uvPixel, color.rgb);
outputColor = color;
}
Hoe hoger de pixelSize, hoe meer gepixeliseerd de scène wordt. Er moet echter een balans worden gezocht: als de pixels te groot zijn, wordt het dithering-patroon onzichtbaar omdat er niet genoeg pixels zijn om een patroon te vormen.
Cathode-Ray Tube (CRT) effect
Het laatste element is het nabootsen van een CRT-monitor. Dit doen we door verschillende effecten te stapelen.
RGB-cellen
CRT-monitoren gebruiken elektronenkanonnen voor rood, groen en blauw. Om te voorkomen dat stralen de verkeerde fosforpunten raken, wordt een shadow mask gebruikt. We bootsen hier de Schiltzmaske na: een aperture grill waarbij elke kolom een halve celhoogte verschoven is.
Implementatie van het RGB-celpatroon:
vec2 pixel = uv * resolution;
vec2 coord = pixel / pixelSize;
vec2 subcoord = coord * vec2(3,1);
vec2 cellOffset = vec2(0, mod(floor(coord.x), 3.0) * 0.5);
float ind = mod(floor(subcoord.x), 3.0);
vec3 maskColor = vec3(ind == 0.0, ind == 1.0, ind == 2.0) * 2.0;
vec2 cellUv = fract(subcoord + cellOffset) * 2.0 - 1.0;
vec2 border = 1.0 - cellUv * cellUv * MASK_BORDER;
maskColor.rgb *= border.x * border.y;
vec2 rgbCellUV = floor(coord+cellOffset) * pixelSize / resolution;
Om vreemde kleuren of luminatieverlies te voorkomen, kunnen we de mask-kleur blenden met de onderliggende scène: color.rgb = 1.0 + (maskColor - 1.0) maskIntensity;
Het scherm krommen (Curving)
CRT-schermen waren niet vlak. We kunnen dit nabootsen door de UV-coördinaten quadratisch te vervormen:
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 curveUV = uv * 2.0 - 1.0;
vec2 offset = curveUV.yx * curve;
curveUV += curveUV * offset * offset;
curveUV = curveUV * 0.5 + 0.5;
//...
}
Vervolgens kunnen we de randen van het scherm tekenen met smoothstep: vec2 edge = smoothstep(0., 0.02, curveUV)(1.-smoothstep(1.-0.02, 1., curveUV)); color.rgb = edge.x * edge.y;
Scanlijnen, distortie en laatste touches
Om het effect echt authentiek te maken, kunnen we de volgende details toevoegen:
- Chromatische Aberratie: Door de kleurkanalen tijdens het samplen van de input buffer licht te verschuiven.
``glsl color.r = texture2D(inputBuffer, rgbCellUV + SPREAD).r; color.g = texture2D(inputBuffer, rgbCellUV).g; color.b = texture2D(inputBuffer, rgbCellUV - SPREAD).b; ``
- Bloom: Gebruik de
Bloomcomponent van@react-three/postprocessingom een lichte gloed toe te voegen. - Scanlijnen: Voeg horizontale lijnen toe via een sinusfunctie.
``glsl float lines = sin(uv.y 2000.0 + time 100.0); color *= lines + 1.0; ``
- Distortie: Voeg een lichte "shake" toe aan de UV-coördinaten in de
mainUvfunctie met behulp van ruis.
Conclusie
Door de combinatie van kleurkwantisering, pixelisatie, RGB-cellen, ordered dithering, schermkromming, scanlijnen, bloom en chromatische aberratie, ontstaat een overtuigend retro-effect.
Custom shader-effecten zijn extreem krachtig en kunnen een WebGL/3D-project volledig transformeren. De technieken die we hebben besproken zijn niet alleen bruikbaar voor een retro-stijl, maar kunnen onbeperkt worden aangepast om digitale kunst te laten opvallen in een groeiende scène van 3D-websites.
Groetjes,