03
WebGL Dither Shader
디더 스모크
마우스로 연기를 걷어보세요
↑ 데모 안에서 마우스를 움직이면 연기가 걷힙니다 (WebGL2 필요)
흑백 노이즈가 향처럼 피어오르고, 커서가 지나간 자리에는 연기가 걷혀 구멍이 뚫립니다. reactbits의 'Dither' 셰이더를 헤더 배경으로 이식한 것으로, |Perlin| 노이즈의 fbm을 시간축으로 도메인 워핑한 뒤 계조를 낮추고 Bayer 8×8 오더드 디더로 특유의 거친 질감을 만듭니다.
- fbm(|classic-perlin|)을 자기 자신으로 도메인 워핑(warp)해 연기가 유기적으로 흐릅니다.
- colorNum 단계로 포스터라이즈한 뒤 Bayer 8×8 매트릭스로 오더드 디더링 — 매끈한 그라디언트 대신 도트 질감을 냅니다.
- 커서 주변은 smoothstep으로 밀도를 빼(f -= 0.5) 연기가 걷힌 구멍을 만듭니다. WebGL2가 없으면 실행하지 않습니다(접근성).
hero.tsx — 사용법
// 1) 아래 smoke-layer.tsx 파일을 프로젝트에 넣습니다.
// 2) 배경 레이어로 깔면 됩니다 — 셰이더는 검은 바탕 + 흰 디더 연기를
// 불투명하게 렌더하므로, 래퍼의 opacity로 원하는 만큼 얹습니다.
import { SmokeLayer } from "@/components/smoke-layer";
export function Hero() {
return (
<section className="relative overflow-hidden">
{/* 배경 연기 — 33% 정도로 얹으면 콘텐츠가 묻히지 않습니다 */}
<div aria-hidden className="pointer-events-none absolute inset-0 opacity-[0.33]">
<SmokeLayer />
</div>
{/* 콘텐츠 */}
<h1 className="relative">Artoke</h1>
</section>
);
}smoke-layer.tsx — 전체 컴포넌트 (복사해서 그대로 사용)
"use client";
import { type RefObject, useEffect, useRef } from "react";
/**
* Mouse-interactive smoke — a faithful port of getdesign.md's hero background,
* which is the reactbits.dev "Dither" shader: fbm of |classic-Perlin| noise,
* domain-warped over time, posterised and ordered-dithered (Bayer 8×8) for its
* grainy texture. The cursor clears a soft hole in the smoke (their effect:
* f -= 0.5 near the pointer).
*
* Rendered exactly as getdesign's hero instance: opaque black base + white
* dither smoke, laid over the page by the wrapper's 33% opacity (their canvas
* wrapper is opacity .33 too — see graph-preview). Params match their call
* site verbatim: waveColor [1,1,1], waveSpeed .04, freq 3, amp .3, colorNum 4,
* pixelSize 2, mouseRadius .6.
*
* The graph canvas above owns pointer events, so the cursor is tracked at the
* window level. Honours prefers-reduced-motion with a single still frame and
* no interaction.
*/
const VERT = `#version 300 es
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
// The reactbits "Dither" fragment shader, ported verbatim except the final
// composite (alpha from density instead of opaque), so it layers over stars.
const FRAG = `#version 300 es
precision highp float;
out vec4 fragColor;
uniform vec2 uRes;
uniform float uTime;
uniform vec2 uMouse; // px, origin top-left (offscreen when pointer is away)
uniform int uMouseOn;
// Params are uniforms so the effects showcase can tune them live. Defaults
// (set from JS when no config is supplied) match getdesign's hero instance:
// waveColor [1,1,1] (white dither smoke), speed .04, freq 3, amp .3, colorNum 4,
// pixelSize 2, mouseRadius .6 — rendered opaque on black, laid over at 33%.
uniform float waveSpeed;
uniform float waveFrequency;
uniform float waveAmplitude;
uniform vec3 waveColor;
uniform float colorNum;
uniform float pixelSize;
uniform float mouseRadius;
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
vec2 fade(vec2 t) { return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); }
float cnoise(vec2 P) {
vec4 Pi = floor(P.xyxy) + vec4(0.0, 0.0, 1.0, 1.0);
vec4 Pf = fract(P.xyxy) - vec4(0.0, 0.0, 1.0, 1.0);
Pi = mod289(Pi);
vec4 ix = Pi.xzxz;
vec4 iy = Pi.yyww;
vec4 fx = Pf.xzxz;
vec4 fy = Pf.yyww;
vec4 i = permute(permute(ix) + iy);
vec4 gx = 2.0 * fract(i * (1.0 / 41.0)) - 1.0;
vec4 gy = abs(gx) - 0.5;
vec4 tx = floor(gx + 0.5);
gx = gx - tx;
vec2 g00 = vec2(gx.x, gy.x);
vec2 g10 = vec2(gx.y, gy.y);
vec2 g01 = vec2(gx.z, gy.z);
vec2 g11 = vec2(gx.w, gy.w);
vec4 norm = taylorInvSqrt(
vec4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
g00 *= norm.x; g01 *= norm.y; g10 *= norm.z; g11 *= norm.w;
float n00 = dot(g00, vec2(fx.x, fy.x));
float n10 = dot(g10, vec2(fx.y, fy.y));
float n01 = dot(g01, vec2(fx.z, fy.z));
float n11 = dot(g11, vec2(fx.w, fy.w));
vec2 fade_xy = fade(Pf.xy);
vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
float n_xy = mix(n_x.x, n_x.y, fade_xy.y);
return 2.3 * n_xy;
}
const int OCTAVES = 4;
float fbm(vec2 p) {
float value = 0.0;
float amp = 1.0;
float freq = waveFrequency;
for (int i = 0; i < OCTAVES; i++) {
value += amp * abs(cnoise(p));
p *= freq;
amp *= waveAmplitude;
}
return value;
}
float pattern(vec2 p) {
vec2 p2 = p - uTime * waveSpeed;
return fbm(p + fbm(p2));
}
const float bayerMatrix8x8[64] = float[64](
0.0/64.0, 48.0/64.0, 12.0/64.0, 60.0/64.0, 3.0/64.0, 51.0/64.0, 15.0/64.0, 63.0/64.0,
32.0/64.0, 16.0/64.0, 44.0/64.0, 28.0/64.0, 35.0/64.0, 19.0/64.0, 47.0/64.0, 31.0/64.0,
8.0/64.0, 56.0/64.0, 4.0/64.0, 52.0/64.0, 11.0/64.0, 59.0/64.0, 7.0/64.0, 55.0/64.0,
40.0/64.0, 24.0/64.0, 36.0/64.0, 20.0/64.0, 43.0/64.0, 27.0/64.0, 39.0/64.0, 23.0/64.0,
2.0/64.0, 50.0/64.0, 14.0/64.0, 62.0/64.0, 1.0/64.0, 49.0/64.0, 13.0/64.0, 61.0/64.0,
34.0/64.0, 18.0/64.0, 46.0/64.0, 30.0/64.0, 33.0/64.0, 17.0/64.0, 45.0/64.0, 29.0/64.0,
10.0/64.0, 58.0/64.0, 6.0/64.0, 54.0/64.0, 9.0/64.0, 57.0/64.0, 5.0/64.0, 53.0/64.0,
42.0/64.0, 26.0/64.0, 38.0/64.0, 22.0/64.0, 41.0/64.0, 25.0/64.0, 37.0/64.0, 21.0/64.0
);
vec3 dither(vec2 fragCoord, vec3 color) {
int x = int(mod(floor(fragCoord.x / pixelSize), 8.0));
int y = int(mod(floor(fragCoord.y / pixelSize), 8.0));
float threshold = bayerMatrix8x8[y * 8 + x] - 0.25;
float stepv = 1.0 / (colorNum - 1.0);
color += threshold * stepv;
color = clamp(color - 0.2, 0.0, 1.0);
return floor(color * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
}
void main() {
vec2 pixel = (floor(gl_FragCoord.xy / pixelSize) + 0.5) * pixelSize;
vec2 uv = pixel / uRes;
uv -= 0.5;
uv.x *= uRes.x / uRes.y;
float f = pattern(uv);
if (uMouseOn == 1) {
vec2 mouseNDC = (uMouse / uRes - 0.5) * vec2(1.0, -1.0);
mouseNDC.x *= uRes.x / uRes.y;
float dist = length(uv - mouseNDC);
float effect = 1.0 - smoothstep(0.0, mouseRadius, dist);
f -= 0.5 * effect;
}
f = clamp(f, 0.0, 1.0);
vec3 col = mix(vec3(0.0), waveColor, f);
col = dither(gl_FragCoord.xy, col);
// Opaque, exactly as getdesign renders it: black base + white dither smoke.
// The wrapper's 33% opacity (see graph-preview) does the layering — the same
// structure their hero uses, so the texture and mouse hole read identically.
fragColor = vec4(col, 1.0);
}`;
function compile(
gl: WebGL2RenderingContext,
type: number,
src: string,
): WebGLShader | null {
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
// eslint-disable-next-line no-console
console.error("[smoke] shader compile failed:", gl.getShaderInfoLog(sh));
gl.deleteShader(sh);
return null;
}
return sh;
}
/** Live-tunable params. Omit any key to keep the getdesign hero default. */
export interface SmokeConfig {
waveSpeed: number;
waveFrequency: number;
waveAmplitude: number;
waveColor: string; // hex
colorNum: number;
pixelSize: number;
mouseRadius: number;
}
export const SMOKE_DEFAULTS: SmokeConfig = {
waveSpeed: 0.04,
waveFrequency: 3.0,
waveAmplitude: 0.3,
waveColor: "#ffffff",
colorNum: 4,
pixelSize: 2,
mouseRadius: 0.6,
};
function hexToRgb(hex: string): [number, number, number] {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return [1, 1, 1];
const n = parseInt(m[1], 16);
return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
export function SmokeLayer({
configRef,
}: {
/** Optional live config; the canvas reads it every frame. */
configRef?: RefObject<Partial<SmokeConfig>>;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl2", {
alpha: true,
premultipliedAlpha: false,
antialias: false,
});
if (!gl) return;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
if (!vs || !fs) return;
const prog = gl.createProgram();
if (!prog) return;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
// eslint-disable-next-line no-console
console.error("[smoke] link failed:", gl.getProgramInfoLog(prog));
return;
}
gl.useProgram(prog);
gl.bindVertexArray(gl.createVertexArray());
const uRes = gl.getUniformLocation(prog, "uRes");
const uTime = gl.getUniformLocation(prog, "uTime");
const uMouse = gl.getUniformLocation(prog, "uMouse");
const uMouseOn = gl.getUniformLocation(prog, "uMouseOn");
const uWaveSpeed = gl.getUniformLocation(prog, "waveSpeed");
const uWaveFrequency = gl.getUniformLocation(prog, "waveFrequency");
const uWaveAmplitude = gl.getUniformLocation(prog, "waveAmplitude");
const uWaveColor = gl.getUniformLocation(prog, "waveColor");
const uColorNum = gl.getUniformLocation(prog, "colorNum");
const uPixelSize = gl.getUniformLocation(prog, "pixelSize");
const uMouseRadius = gl.getUniformLocation(prog, "mouseRadius");
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0, 0, 0, 0);
let raf = 0;
let time = 0;
// Cursor in backing px, origin top-left (matches getdesign's mousePos). Eased
// for a smooth trailing hole; parked far offscreen while the pointer is away.
const OFF = -1e4;
let mxTarget = OFF;
let myTarget = OFF;
let mx = OFF;
let my = OFF;
const dpr = () => Math.min(window.devicePixelRatio || 1, 1.5);
function resize() {
const rect = canvas!.getBoundingClientRect();
const ratio = dpr();
const w = Math.max(1, Math.round(rect.width * ratio));
const h = Math.max(1, Math.round(rect.height * ratio));
if (canvas!.width !== w || canvas!.height !== h) {
canvas!.width = w;
canvas!.height = h;
}
gl!.viewport(0, 0, w, h);
}
function onPointerMove(e: PointerEvent) {
const rect = canvas!.getBoundingClientRect();
const lx = e.clientX - rect.left;
const ly = e.clientY - rect.top;
if (
rect.width <= 0 ||
rect.height <= 0 ||
lx < 0 ||
ly < 0 ||
lx > rect.width ||
ly > rect.height
) {
mxTarget = OFF;
myTarget = OFF;
return;
}
const ratio = dpr();
mxTarget = lx * ratio;
myTarget = ly * ratio; // top-left origin, as getdesign's mousePos
}
function render() {
resize();
// Ease toward the pointer; snap instantly when it (re)enters from offscreen.
if (mxTarget === OFF) {
mx = OFF;
my = OFF;
} else if (mx === OFF) {
mx = mxTarget;
my = myTarget;
} else {
mx += (mxTarget - mx) * 0.18;
my += (myTarget - my) * 0.18;
}
gl!.clear(gl!.COLOR_BUFFER_BIT);
gl!.uniform2f(uRes, canvas!.width, canvas!.height);
gl!.uniform1f(uTime, time);
gl!.uniform2f(uMouse, mx, my);
gl!.uniform1i(uMouseOn, 1);
// Live params — read the config ref each frame, falling back to defaults.
const c = { ...SMOKE_DEFAULTS, ...(configRef?.current ?? {}) };
const [cr, cg, cb] = hexToRgb(c.waveColor);
gl!.uniform1f(uWaveSpeed, c.waveSpeed);
gl!.uniform1f(uWaveFrequency, c.waveFrequency);
gl!.uniform1f(uWaveAmplitude, c.waveAmplitude);
gl!.uniform3f(uWaveColor, cr, cg, cb);
gl!.uniform1f(uColorNum, c.colorNum);
gl!.uniform1f(uPixelSize, c.pixelSize);
gl!.uniform1f(uMouseRadius, c.mouseRadius);
gl!.drawArrays(gl!.TRIANGLES, 0, 3);
}
function frame() {
if (!reduced) time += 1 / 60;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => render());
ro.observe(canvas);
if (reduced) {
render();
} else {
window.addEventListener("pointermove", onPointerMove, { passive: true });
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
window.removeEventListener("pointermove", onPointerMove);
};
}, []);
return <canvas ref={canvasRef} className="size-full" aria-hidden />;
}