Symbols effect
Drop in an image or a video and it gets cut into four brightness bands. Each band is stamped with a tiny symbol, tinted with its own colour, so the picture is rebuilt out of little marks. It all runs on the GPU, so video plays through it live.
The symbols, the colours and where the bands split are yours to change. When it looks right you can save a still as a PNG or record the whole thing as a video.
It runs entirely in the browser, nothing gets uploaded, and you can grab the code below and drop the renderer into your own project.
Implementation
Bands (dark → light)
Band 10–25
Band 225–50
Band 350–75
Band 475–100
Thresholds
Code
// Symbols effect shader — authored from first principles. A photo or video frame is
// pixelated into a grid of cells; each cell's luminance is bucketed into one of four
// brightness bands; and that band's symbol glyph (tiled once per cell) is stamped,
// tinted with the band's colour over a white ground. The result is a halftone built
// out of little marks.
export const SANDBOX_VERT = /* glsl */ `
precision highp float;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
export const SANDBOX_FRAG = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform sampler2D src;
uniform vec2 resolution;
uniform vec2 srcScale; // cover-crop of the source (<=1 on the cropped axis)
uniform float zoom; // user zoom: >1 scales the source up, <1 shrinks it
uniform vec3 bgColor; // colour shown around a shrunk source (the background)
uniform float cell; // pixel cell size (px)
uniform vec3 bandColor[4]; // current colour per brightness band (the "to" preset)
uniform vec3 bandColorB[4]; // previous colour per band (the "from" preset)
uniform float bandLo[4]; // band lower bound (inclusive)
uniform float bandHi[4]; // band upper bound (inclusive)
uniform sampler2D glyph[4]; // current symbol per band
uniform sampler2D glyphB[4]; // previous symbol per band
uniform float morphT; // 0 = fully previous, 1 = fully current (crossfade)
float lum(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }
// map a full-frame uv into the COVER-cropped + user-zoomed source (centred). zoom
// >1 magnifies; zoom <1 shrinks the source so background shows around it.
vec2 cover(vec2 uv) {
return (uv - 0.5) * srcScale / zoom + 0.5;
}
// snap a uv to the centre of its cell, so a whole cell reads one source colour
vec2 cellUV(vec2 step) {
return floor(vUv / step) * step + step * 0.5;
}
vec4 sampleGlyph(int i, vec2 uv) {
if (i == 0) return texture2D(glyph[0], uv);
if (i == 1) return texture2D(glyph[1], uv);
if (i == 2) return texture2D(glyph[2], uv);
return texture2D(glyph[3], uv);
}
vec4 sampleGlyphB(int i, vec2 uv) {
if (i == 0) return texture2D(glyphB[0], uv);
if (i == 1) return texture2D(glyphB[1], uv);
if (i == 2) return texture2D(glyphB[2], uv);
return texture2D(glyphB[3], uv);
}
void main() {
vec3 paper = vec3(1.0);
vec2 step = vec2(cell) / resolution;
// sample the source at this cell's centre. If the sample falls outside the source
// — including a 1px inset to avoid the clamped edge column that smears into a thin
// line, and the area around a shrunk/zoomed source — fill with the background
// colour instead of black, so a scaled-down video sits on a clean ground.
vec2 suv = cover(cellUV(step));
vec2 inset = 1.0 / resolution;
if (suv.x < inset.x || suv.x > 1.0 - inset.x ||
suv.y < inset.y || suv.y > 1.0 - inset.y) {
gl_FragColor = vec4(bgColor, 1.0);
return;
}
float l = lum(texture2D(src, suv).rgb);
gl_FragColor = vec4(paper, 1.0);
for (int i = 0; i < 4; i++) {
if (l >= bandLo[i] && l <= bandHi[i]) {
vec2 gUv = mod(vUv / step, vec2(1.0)); // glyph repeats once per cell
// crossfade the glyph alpha AND the band colour from the previous preset
// (B) to the current one, by morphT — so a remix dissolves smoothly.
vec4 gA = sampleGlyph(i, gUv);
vec4 gB = sampleGlyphB(i, gUv);
float a = mix(gB.a, gA.a, morphT);
vec3 gcol = mix(gB.rgb, gA.rgb, morphT);
vec3 col = mix(bandColorB[i], bandColor[i], morphT);
vec3 sym = mix(paper, gcol, a); // glyph over paper
float k = smoothstep(0.0, 1.0, lum(sym));
gl_FragColor = vec4(mix(col, paper, k), 1.0);
}
}
}
`;
Credits
CompanyPersonal
DateJun 2026
TagsWebGL, Halftone, Shaders