Artoke
03

WebGL Dither Shader

디더 스모크

마우스로 연기를 걷어보세요

↑ 데모 안에서 마우스를 움직이면 연기가 걷힙니다 (WebGL2 필요)

흑백 노이즈가 향처럼 피어오르고, 커서가 지나간 자리에는 연기가 걷혀 구멍이 뚫립니다. reactbits의 'Dither' 셰이더를 헤더 배경으로 이식한 것으로, |Perlin| 노이즈의 fbm을 시간축으로 도메인 워핑한 뒤 계조를 낮추고 Bayer 8×8 오더드 디더로 특유의 거친 질감을 만듭니다.

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 />;
}