css 이미지 필터 효과 9가지 — 값 하나가 색을 바꾼다
css 이미지 필터 효과 9가지를 캔버스 픽셀 계산으로 직접 만들어 봤다. 필터는 사진을 망치는 줄 알았다 — 값 하나를 만지기 전까지는. 01번은 두 색 사이 매핑 값으로 듀오톤을 세우고, 05번은 노이즈 값으로 필름 그레인을 흩뿌리며, 09번은 커서가 오를 때 그 채도 값을
01 filter-duotone-colormap
02 filter-chromatic-split-glitch
03 filter-halftone-dot-print
04 filter-frosted-glass-noise
05 filter-vintage-film-grain
06 filter-thermal-heatmap-tone
07 filter-chromatic-edge-aberration
08 filter-dominant-color-swatch
09 filter-grayscale-hover-reveal
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 두 색 매핑 값으로 세우는 듀오톤
- 02 채널 어긋남 거리 값으로 갈라지는 글리치
- 03 점 크기 값이 인쇄 질감을 만드는 망점 필터
- 04 흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터
- 05 노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인
- 06 밝기 순서 값을 색으로 바꾸는 열화상 톤
- 07 가장자리 값이 색수차를 만드는 렌즈 필터
- 08 픽셀 값을 읽어 뽑는 대표 색 스와치
- 09 채도 값이 돌아오는 흑백 호버 리빌
픽셀 값 하나가 아홉 필터 계산을 가르는 자리
아홉 장면 모두 <canvas> 에서 픽셀 버퍼를 읽고 다시 그린다(04·09는 예외로 각각 브라우저 filter 와 css 필터 토글을 쓴다). 화면에 보이는 사진은 실제 촬영본이 아니라 drawPhotoBase 가 seedRand 로 매번 새로 그리는 절차적 이미지이고, 필터마다 이 버퍼 위에서 값 하나(혼합 비율·오프셋 거리·노이즈 강도)가 색을 어떻게 다시 계산하는지가 다르다.
01두 색 매핑 값으로 세우는 듀오톤
밝기(lum) 값 하나를 mapDuotone 이 어두운 색과 밝은 색 사이에서 보간해, 사진 전체를 두 가지 색조로만 다시 칠한다. DUOTONE_MIX_PERCENT 값이 커질수록 원색이 사라지고 두 지정색 쪽으로 완전히 쏠린다. 01 항목은 누적면적 73.1%·강도 평균 21.4·23프레임 중 16프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<input class="vd__range" id="vdRange" type="range" min="0" max="100" value="30" aria-label="duotone mix">
<p class="vd__caption">두 색 매핑 값으로 세우는 듀오톤</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__range {
position: absolute;
left: $sp-3;
right: $sp-3;
top: $sp-3;
margin: 0;
accent-color: $subject-blue;
cursor: pointer;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__range {
position: absolute;
left: 12px;
right: 12px;
top: 12px;
margin: 0;
accent-color: #2f6df6;
cursor: pointer;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var range = document.getElementById('vdRange');
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var DUOTONE_MIX_PERCENT = 30;
var DUOTONE_DARK = [10, 79, 232];
var DUOTONE_LIGHT = [255, 210, 63];
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414011);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function mapDuotone(lum, mix) {
var t = mix / 100;
var dr = DUOTONE_DARK[0] + (DUOTONE_LIGHT[0] - DUOTONE_DARK[0]) * lum;
var dg = DUOTONE_DARK[1] + (DUOTONE_LIGHT[1] - DUOTONE_DARK[1]) * lum;
var db = DUOTONE_DARK[2] + (DUOTONE_LIGHT[2] - DUOTONE_DARK[2]) * lum;
return [dr, dg, db, t];
}
var userMix = DUOTONE_MIX_PERCENT;
if (range) {
range.value = String(userMix);
range.addEventListener('input', function () { userMix = Number(range.value); });
}
function renderFrame(t) {
var wave = Math.sin(t * Math.PI * 4 / LOOP_MS);
var mix = Math.max(0, Math.min(100, userMix + wave * 25));
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var r = baseBuf[idx], g = baseBuf[idx + 1], b = baseBuf[idx + 2];
var lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
var dc = mapDuotone(lum, mix);
outBuf[idx] = r + (dc[0] - r) * dc[3];
outBuf[idx + 1] = g + (dc[1] - g) * dc[3];
outBuf[idx + 2] = b + (dc[2] - b) * dc[3];
outBuf[idx + 3] = 255;
idx += 4;
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterDuotoneColormap.module.scss";
export default function FilterDuotoneColormap() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rangeRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const range = rangeRef.current;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const DUOTONE_MIX_PERCENT = 30;
const DUOTONE_DARK = [10, 79, 232];
const DUOTONE_LIGHT = [255, 210, 63];
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414011);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function mapDuotone(lum: number, mix: number): [number, number, number, number] {
const t = mix / 100;
const dr = DUOTONE_DARK[0] + (DUOTONE_LIGHT[0] - DUOTONE_DARK[0]) * lum;
const dg = DUOTONE_DARK[1] + (DUOTONE_LIGHT[1] - DUOTONE_DARK[1]) * lum;
const db = DUOTONE_DARK[2] + (DUOTONE_LIGHT[2] - DUOTONE_DARK[2]) * lum;
return [dr, dg, db, t];
}
let userMix = DUOTONE_MIX_PERCENT;
if (range) {
range.value = String(userMix);
range.addEventListener("input", function () { userMix = Number(range.value); });
}
function renderFrame(t: number) {
const wave = Math.sin(t * Math.PI * 4 / LOOP_MS);
const mix = Math.max(0, Math.min(100, userMix + wave * 25));
let idx = 0;
for (let i = 0; i < CALC_W * CALC_H; i += 1) {
const r = baseBuf[idx], g = baseBuf[idx + 1], b = baseBuf[idx + 2];
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const dc = mapDuotone(lum, mix);
outBuf[idx] = r + (dc[0] - r) * dc[3];
outBuf[idx + 1] = g + (dc[1] - g) * dc[3];
outBuf[idx + 2] = b + (dc[2] - b) * dc[3];
outBuf[idx + 3] = 255;
idx += 4;
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<input className={styles.vd__range} ref={rangeRef} type="range" min={0} max={100} defaultValue={30} aria-label="duotone mix" />
<p className={styles.vd__caption}>두 색 매핑 값으로 세우는 듀오톤</p>
</div>
</main>
);
}01 · 두 색 매핑 값으로 세우는 듀오톤 — 측정 누적면적 73.067% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function mapDuotone(lum, mix) {
var t = mix / 100;
var dr = DUOTONE_DARK[0] + (DUOTONE_LIGHT[0] - DUOTONE_DARK[0]) * lum;
var dg = DUOTONE_DARK[1] + (DUOTONE_LIGHT[1] - DUOTONE_DARK[1]) * lum;
var db = DUOTONE_DARK[2] + (DUOTONE_LIGHT[2] - DUOTONE_DARK[2]) * lum;
return [dr, dg, db, t];
}
var userMix = DUOTONE_MIX_PERCENT;
if (range) {
range.value = String(userMix);
range.addEventListener('input', function () { userMix = Number(range.value); });
}
02채널 어긋남 거리 값으로 갈라지는 글리치
shiftChannels 가 빨강·초록·파랑 채널을 CHANNEL_OFFSET_PX 값만큼 서로 다른 방향으로 밀어 픽셀을 다시 쓴다. 오프셋이 사인파를 타고 커졌다 작아지길 반복해, 채널이 벌어지는 정도가 주기적으로 출렁인다. 02 항목은 누적면적 14.1%·강도 평균 37.5·23프레임 중 22프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">채널 어긋남 거리 값으로 갈라지는 글리치</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var CHANNEL_OFFSET_PX = 6;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414021);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function clampIdx(v, max) { return v < 0 ? 0 : (v >= max ? max - 1 : v); }
function sampleBase(x, y, ch) {
var xi = clampIdx(x, CALC_W), yi = clampIdx(y, CALC_H);
return baseBuf[(yi * CALC_W + xi) * 4 + ch];
}
function shiftChannels(offset) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
outBuf[idx] = sampleBase(x - offset, y, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offset, y, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function renderFrame(t) {
var wave = Math.abs(Math.sin(t * Math.PI * 4 / LOOP_MS));
var offset = Math.round(wave * CHANNEL_OFFSET_PX);
shiftChannels(offset);
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterChromaticSplitGlitch.module.scss";
export default function FilterChromaticSplitGlitch() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const CHANNEL_OFFSET_PX = 6;
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414021);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function clampIdx(v: number, max: number) { return v < 0 ? 0 : (v >= max ? max - 1 : v); }
function sampleBase(x: number, y: number, ch: number) {
const xi = clampIdx(x, CALC_W), yi = clampIdx(y, CALC_H);
return baseBuf[(yi * CALC_W + xi) * 4 + ch];
}
function shiftChannels(offset: number) {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
outBuf[idx] = sampleBase(x - offset, y, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offset, y, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function renderFrame(t: number) {
const wave = Math.abs(Math.sin(t * Math.PI * 4 / LOOP_MS));
const offset = Math.round(wave * CHANNEL_OFFSET_PX);
shiftChannels(offset);
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>채널 어긋남 거리 값으로 갈라지는 글리치</p>
</div>
</main>
);
}02 · 채널 어긋남 거리 값으로 갈라지는 글리치 — 측정 누적면적 14.143% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function shiftChannels(offset) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
outBuf[idx] = sampleBase(x - offset, y, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offset, y, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function renderFrame(t) {
var wave = Math.abs(Math.sin(t * Math.PI * 4 / LOOP_MS));
var offset = Math.round(wave * CHANNEL_OFFSET_PX);
shiftChannels(offset);
}
03점 크기 값이 인쇄 질감을 만드는 망점 필터
drawHalftoneDot 이 픽셀 밝기를 읽어 어두울수록 큰 원, 밝을수록 작은 원으로 바꿔 그린다. HALFTONE_DOT_SIZE_PX 값이 원 반지름 계산식에 그대로 곱해져 격자 자체의 굵기를 정한다. 03 항목은 누적면적 63.0%·강도 평균 82.9·23프레임 중 18프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<input class="vd__range" id="vdRange" type="range" min="3" max="12" value="4" aria-label="dot size">
<p class="vd__caption">점 크기 값이 인쇄 질감을 만드는 망점 필터</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__range {
position: absolute;
left: $sp-3;
right: $sp-3;
top: $sp-3;
margin: 0;
accent-color: $subject-blue;
cursor: pointer;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__range {
position: absolute;
left: 12px;
right: 12px;
top: 12px;
margin: 0;
accent-color: #2f6df6;
cursor: pointer;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var range = document.getElementById('vdRange');
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var HALFTONE_DOT_SIZE_PX = 4;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414031);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
function sampleBrightness(xCalc, yCalc) {
var xi = Math.max(0, Math.min(CALC_W - 1, Math.round(xCalc)));
var yi = Math.max(0, Math.min(CALC_H - 1, Math.round(yCalc)));
var idx = (yi * CALC_W + xi) * 4;
return (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
}
function drawHalftoneDot(x, y, size, brightness) {
var radius = (1 - brightness) * size * 0.62;
if (radius < 0.4) return;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
var userDot = HALFTONE_DOT_SIZE_PX;
if (range) {
range.value = String(userDot);
range.addEventListener('input', function () { userDot = Number(range.value); });
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var dot = Math.max(3, userDot + wave * 3);
var scaleX = CALC_W / cw, scaleY = CALC_H / ch;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#fff7e6';
ctx.fillRect(0, 0, cw, ch);
ctx.fillStyle = '#17141a';
for (var y = dot / 2; y < ch; y += dot) {
for (var x = dot / 2; x < cw; x += dot) {
drawHalftoneDot(x, y, dot, sampleBrightness(x * scaleX, y * scaleY));
}
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterHalftoneDotPrint.module.scss";
export default function FilterHalftoneDotPrint() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rangeRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const range = rangeRef.current;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const HALFTONE_DOT_SIZE_PX = 4;
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414031);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
function sampleBrightness(xCalc: number, yCalc: number) {
const xi = Math.max(0, Math.min(CALC_W - 1, Math.round(xCalc)));
const yi = Math.max(0, Math.min(CALC_H - 1, Math.round(yCalc)));
const idx = (yi * CALC_W + xi) * 4;
return (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
}
function drawHalftoneDot(x: number, y: number, size: number, brightness: number) {
const radius = (1 - brightness) * size * 0.62;
if (radius < 0.4) return;
ctx!.beginPath();
ctx!.arc(x, y, radius, 0, Math.PI * 2);
ctx!.fill();
}
let userDot = HALFTONE_DOT_SIZE_PX;
if (range) {
range.value = String(userDot);
range.addEventListener("input", function () { userDot = Number(range.value); });
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
const wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
const dot = Math.max(3, userDot + wave * 3);
const scaleX = CALC_W / cw, scaleY = CALC_H / ch;
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "#fff7e6";
ctx!.fillRect(0, 0, cw, ch);
ctx!.fillStyle = "#17141a";
for (let y = dot / 2; y < ch; y += dot) {
for (let x = dot / 2; x < cw; x += dot) {
drawHalftoneDot(x, y, dot, sampleBrightness(x * scaleX, y * scaleY));
}
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<input className={styles.vd__range} ref={rangeRef} type="range" min={3} max={12} defaultValue={4} aria-label="dot size" />
<p className={styles.vd__caption}>점 크기 값이 인쇄 질감을 만드는 망점 필터</p>
</div>
</main>
);
}03 · 점 크기 값이 인쇄 질감을 만드는 망점 필터 — 측정 누적면적 62.988% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function drawHalftoneDot(x, y, size, brightness) {
var radius = (1 - brightness) * size * 0.62;
if (radius < 0.4) return;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
var userDot = HALFTONE_DOT_SIZE_PX;
if (range) {
range.value = String(userDot);
range.addEventListener('input', function () { userDot = Number(range.value); });
}
04흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터
draw 가 매 프레임 ctx.filter = 'blur()' 로 사진을 흐리게 그린 뒤, drawNoiseOverlay 로 미세한 흰 점을 겹쳐 유리 표면처럼 보이게 한다. FROST_BLUR_PX 값에 사인파를 더해 흐림 정도가 숨쉬듯 오르내린다. 04 항목은 누적면적 43.3%·강도 평균 19.0·23프레임 중 22프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var LOOP_MS = 2000;
var FROST_BLUR_PX = 8;
var NOISE_ALPHA = 0.05;
var photo = document.createElement('canvas');
var pctx = photo.getContext('2d');
function buildPhoto() {
photo.width = cw; photo.height = ch;
drawPhotoBase(pctx, cw, ch, 414041);
}
function drawNoiseOverlay(bucket) {
var rand = seedRand(414041 + bucket);
for (var i = 0; i < 90; i += 1) {
var x = rand() * cw, y = rand() * ch, s = rand() * 2 + 0.6;
ctx.fillStyle = 'rgba(255,255,255,' + (NOISE_ALPHA + rand() * 0.05) + ')';
ctx.fillRect(x, y, s, s);
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
buildPhoto();
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var blur = Math.max(2, FROST_BLUR_PX + wave * 6);
ctx.clearRect(0, 0, cw, ch);
ctx.filter = 'blur(' + blur.toFixed(1) + 'px)';
ctx.drawImage(photo, 0, 0, cw, ch);
ctx.filter = 'none';
drawNoiseOverlay(Math.floor(elapsed / 150));
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterFrostedGlassNoise.module.scss";
export default function FilterFrostedGlassNoise() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const LOOP_MS = 2000;
const FROST_BLUR_PX = 8;
const NOISE_ALPHA = 0.05;
const photo = document.createElement("canvas");
const pctx = photo.getContext("2d") as CanvasRenderingContext2D;
let cw = 480, ch = 300;
function buildPhoto() {
photo.width = cw; photo.height = ch;
drawPhotoBase(pctx, cw, ch, 414041);
}
function drawNoiseOverlay(bucket: number) {
const rand = seedRand(414041 + bucket);
for (let i = 0; i < 90; i += 1) {
const x = rand() * cw, y = rand() * ch, s = rand() * 2 + 0.6;
ctx!.fillStyle = "rgba(255,255,255," + (NOISE_ALPHA + rand() * 0.05) + ")";
ctx!.fillRect(x, y, s, s);
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
buildPhoto();
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
const wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
const blur = Math.max(2, FROST_BLUR_PX + wave * 6);
ctx!.clearRect(0, 0, cw, ch);
ctx!.filter = "blur(" + blur.toFixed(1) + "px)";
ctx!.drawImage(photo, 0, 0, cw, ch);
ctx!.filter = "none";
drawNoiseOverlay(Math.floor(elapsed / 150));
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터</p>
</div>
</main>
);
}04 · 흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터 — 측정 누적면적 43.31% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function draw(elapsed) {
lastElapsed = elapsed;
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var blur = Math.max(2, FROST_BLUR_PX + wave * 6);
ctx.clearRect(0, 0, cw, ch);
ctx.filter = 'blur(' + blur.toFixed(1) + 'px)';
ctx.drawImage(photo, 0, 0, cw, ch);
ctx.filter = 'none';
drawNoiseOverlay(Math.floor(elapsed / 150));
}
05노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인
applyFilmGrain 이 픽셀마다 -1~1 사이 난수에 GRAIN_STRENGTH 값을 곱한 만큼 밝기를 흔들어 흑백 노이즈를 얹는다. 노이즈 강도 자체도 사인파를 타 프레임마다 짙어졌다 옅어지길 반복한다. 05 항목은 누적면적 68.1%·강도 평균 17.7·23프레임 중 20프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var GRAIN_STRENGTH = 18;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414051);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function applyFilmGrain(strength, bucket) {
var grainRand = seedRand(414051 + bucket);
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var n = (grainRand() - 0.5) * 2 * strength;
outBuf[idx] = baseBuf[idx] + n;
outBuf[idx + 1] = baseBuf[idx + 1] + n;
outBuf[idx + 2] = baseBuf[idx + 2] + n;
outBuf[idx + 3] = 255;
idx += 4;
}
}
function renderFrame(elapsed) {
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var strength = Math.max(4, GRAIN_STRENGTH + wave * 14);
var bucket = Math.floor(elapsed / 90);
applyFilmGrain(strength, bucket);
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterVintageFilmGrain.module.scss";
export default function FilterVintageFilmGrain() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const GRAIN_STRENGTH = 18;
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414051);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function applyFilmGrain(strength: number, bucket: number) {
const grainRand = seedRand(414051 + bucket);
let idx = 0;
for (let i = 0; i < CALC_W * CALC_H; i += 1) {
const n = (grainRand() - 0.5) * 2 * strength;
outBuf[idx] = baseBuf[idx] + n;
outBuf[idx + 1] = baseBuf[idx + 1] + n;
outBuf[idx + 2] = baseBuf[idx + 2] + n;
outBuf[idx + 3] = 255;
idx += 4;
}
}
function renderFrame(elapsed: number) {
const wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
const strength = Math.max(4, GRAIN_STRENGTH + wave * 14);
const bucket = Math.floor(elapsed / 90);
applyFilmGrain(strength, bucket);
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인</p>
</div>
</main>
);
}05 · 노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인 — 측정 누적면적 68.114% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function applyFilmGrain(strength, bucket) {
var grainRand = seedRand(414051 + bucket);
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var n = (grainRand() - 0.5) * 2 * strength;
outBuf[idx] = baseBuf[idx] + n;
outBuf[idx + 1] = baseBuf[idx + 1] + n;
outBuf[idx + 2] = baseBuf[idx + 2] + n;
outBuf[idx + 3] = 255;
idx += 4;
}
}
function renderFrame(elapsed) {
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var strength = Math.max(4, GRAIN_STRENGTH + wave * 14);
var bucket = Math.floor(elapsed / 90);
applyFilmGrain(strength, bucket);
}
06밝기 순서 값을 색으로 바꾸는 열화상 톤
thermalColor 가 밝기 값 하나를 0에서 1 사이 위치로 바꿔 THERMAL_STOPS 색 목록에서 두 색 사이를 보간해 돌려준다. THERMAL_SHIFT 값이 사인파를 타면서 같은 밝기라도 매 프레임 다른 색 구간에 매핑돼 열이 흐르는 것처럼 보인다. 06 항목은 누적면적 75.3%·강도 평균 72.9·23프레임 중 20프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">밝기 순서 값을 색으로 바꾸는 열화상 톤</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var THERMAL_SHIFT = 0;
var THERMAL_STOPS = [[10, 20, 90], [10, 90, 200], [40, 200, 120], [240, 210, 40], [230, 50, 20]];
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414061);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function thermalColor(v) {
var t = Math.max(0, Math.min(1, v)) * (THERMAL_STOPS.length - 1);
var i0 = Math.floor(t), i1 = Math.min(THERMAL_STOPS.length - 1, i0 + 1);
var f = t - i0;
var a = THERMAL_STOPS[i0], b = THERMAL_STOPS[i1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
function renderFrame(elapsed) {
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var shift = THERMAL_SHIFT + wave * 0.28;
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var lum = (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
var c = thermalColor(lum + shift);
outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
idx += 4;
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterThermalHeatmapTone.module.scss";
export default function FilterThermalHeatmapTone() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const THERMAL_SHIFT = 0;
const THERMAL_STOPS: number[][] = [[10, 20, 90], [10, 90, 200], [40, 200, 120], [240, 210, 40], [230, 50, 20]];
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414061);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function thermalColor(v: number): [number, number, number] {
const t = Math.max(0, Math.min(1, v)) * (THERMAL_STOPS.length - 1);
const i0 = Math.floor(t), i1 = Math.min(THERMAL_STOPS.length - 1, i0 + 1);
const f = t - i0;
const a = THERMAL_STOPS[i0], b = THERMAL_STOPS[i1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
function renderFrame(elapsed: number) {
const wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
const shift = THERMAL_SHIFT + wave * 0.28;
let idx = 0;
for (let i = 0; i < CALC_W * CALC_H; i += 1) {
const lum = (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
const c = thermalColor(lum + shift);
outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
idx += 4;
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>밝기 순서 값을 색으로 바꾸는 열화상 톤</p>
</div>
</main>
);
}06 · 밝기 순서 값을 색으로 바꾸는 열화상 톤 — 측정 누적면적 75.314% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function thermalColor(v) {
var t = Math.max(0, Math.min(1, v)) * (THERMAL_STOPS.length - 1);
var i0 = Math.floor(t), i1 = Math.min(THERMAL_STOPS.length - 1, i0 + 1);
var f = t - i0;
var a = THERMAL_STOPS[i0], b = THERMAL_STOPS[i1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
function renderFrame(elapsed) {
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var shift = THERMAL_SHIFT + wave * 0.28;
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var lum = (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
var c = thermalColor(lum + shift);
outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
idx += 4;
}
}
07가장자리 값이 색수차를 만드는 렌즈 필터
distFromCenter 가 중심에서의 거리를 0~1 값으로 구하고, 그 값에 EDGE_STRENGTH_PX 를 곱한 만큼 채널마다 다른 방향으로 픽셀을 밀어 가장자리에만 색 테두리를 남긴다. 중심에 가까운 픽셀은 오프셋이 0에 가까워 거의 갈라지지 않는다. 07 항목은 누적면적 13.6%·강도 평균 37.3·23프레임 중 23프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">가장자리 값이 색수차를 만드는 렌즈 필터</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var EDGE_STRENGTH_PX = 5;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414071);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function clampIdx(v, max) { return v < 0 ? 0 : (v >= max ? max - 1 : v); }
function sampleBase(x, y, ch) {
var xi = clampIdx(x, CALC_W), yi = clampIdx(y, CALC_H);
return baseBuf[(yi * CALC_W + xi) * 4 + ch];
}
function distFromCenter(x, y) {
var cx = CALC_W / 2, cy = CALC_H / 2;
var dx = (x - cx) / cx, dy = (y - cy) / cy;
return Math.min(1, Math.sqrt(dx * dx + dy * dy));
}
function renderFrame(elapsed) {
var wave = Math.abs(Math.sin(elapsed * Math.PI * 4 / LOOP_MS));
var strength = EDGE_STRENGTH_PX * wave;
var cx = CALC_W / 2, cy = CALC_H / 2;
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var ux = (x - cx) / cx, uy = (y - cy) / cy;
var len = Math.sqrt(ux * ux + uy * uy) || 1;
var d = distFromCenter(x, y);
var off2 = strength * d;
var offX = Math.round((ux / len) * off2), offY = Math.round((uy / len) * off2);
outBuf[idx] = sampleBase(x - offX, y - offY, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offX, y + offY, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterChromaticEdgeAberration.module.scss";
export default function FilterChromaticEdgeAberration() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const EDGE_STRENGTH_PX = 5;
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414071);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function clampIdx(v: number, max: number) { return v < 0 ? 0 : (v >= max ? max - 1 : v); }
function sampleBase(x: number, y: number, ch: number) {
const xi = clampIdx(x, CALC_W), yi = clampIdx(y, CALC_H);
return baseBuf[(yi * CALC_W + xi) * 4 + ch];
}
function distFromCenter(x: number, y: number) {
const cx = CALC_W / 2, cy = CALC_H / 2;
const dx = (x - cx) / cx, dy = (y - cy) / cy;
return Math.min(1, Math.sqrt(dx * dx + dy * dy));
}
function renderFrame(elapsed: number) {
const wave = Math.abs(Math.sin(elapsed * Math.PI * 4 / LOOP_MS));
const strength = EDGE_STRENGTH_PX * wave;
const cx = CALC_W / 2, cy = CALC_H / 2;
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const ux = (x - cx) / cx, uy = (y - cy) / cy;
const len = Math.sqrt(ux * ux + uy * uy) || 1;
const d = distFromCenter(x, y);
const off2 = strength * d;
const offX = Math.round((ux / len) * off2), offY = Math.round((uy / len) * off2);
outBuf[idx] = sampleBase(x - offX, y - offY, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offX, y + offY, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>가장자리 값이 색수차를 만드는 렌즈 필터</p>
</div>
</main>
);
}07 · 가장자리 값이 색수차를 만드는 렌즈 필터 — 측정 누적면적 13.646% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function distFromCenter(x, y) {
var cx = CALC_W / 2, cy = CALC_H / 2;
var dx = (x - cx) / cx, dy = (y - cy) / cy;
return Math.min(1, Math.sqrt(dx * dx + dy * dy));
}
function renderFrame(elapsed) {
var wave = Math.abs(Math.sin(elapsed * Math.PI * 4 / LOOP_MS));
var strength = EDGE_STRENGTH_PX * wave;
var cx = CALC_W / 2, cy = CALC_H / 2;
08픽셀 값을 읽어 뽑는 대표 색 스와치
computeDominantColor 가 픽셀을 4비트 단위로 묶어 가장 많이 나온 색을 세어 뽑고, 그 값을 카드 배경색으로 그대로 쓴다. PHOTO_SEEDS 값이 바뀌면 사진이 다시 그려지고 대표 색도 새로 계산된다. 08 항목은 누적면적 6.7%·강도 평균 19.6·23프레임 중 23프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">픽셀 값을 읽어 뽑는 대표 색 스와치</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var PHOTO_SEEDS = [414081, 414082, 414083];
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var photoIndex = 0;
var baseBuf = null;
var dominant = '#2f6df6';
function computeDominantColor() {
var bins = {};
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var r = baseBuf[idx] >> 4, g = baseBuf[idx + 1] >> 4, b = baseBuf[idx + 2] >> 4;
var key = r + '-' + g + '-' + b;
bins[key] = (bins[key] || 0) + 1;
idx += 4;
}
var bestKey = null, bestCount = -1;
for (var k in bins) { if (bins[k] > bestCount) { bestCount = bins[k]; bestKey = k; } }
var parts = bestKey.split('-');
dominant = 'rgb(' + (parseInt(parts[0], 10) * 16 + 8) + ',' + (parseInt(parts[1], 10) * 16 + 8) + ',' + (parseInt(parts[2], 10) * 16 + 8) + ')';
}
function renderPhoto(elapsed) {
var seedVariant = PHOTO_SEEDS[photoIndex] + Math.floor(elapsed / 45);
octx.clearRect(0, 0, CALC_W, CALC_H);
drawPhotoBase(octx, CALC_W, CALC_H, seedVariant);
baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).data;
computeDominantColor();
}
function cyclePhoto() { photoIndex = (photoIndex + 1) % PHOTO_SEEDS.length; }
canvas.addEventListener('click', function () { cyclePhoto(); draw(lastElapsed); });
var cw = 480, ch = 300;
var lastElapsed = 0;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
function draw(elapsed) {
lastElapsed = elapsed;
renderPhoto(elapsed);
var photoW = Math.round(cw * 0.72);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, photoW, ch);
ctx.fillStyle = dominant;
ctx.fillRect(photoW, 0, cw - photoW, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null, lastBucket = -1;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
var bucket = Math.floor(elapsed / (LOOP_MS / 2));
if (bucket !== lastBucket) { lastBucket = bucket; cyclePhoto(); }
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterDominantColorSwatch.module.scss";
export default function FilterDominantColorSwatch() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const PHOTO_SEEDS = [414081, 414082, 414083];
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
let photoIndex = 0;
let baseBuf: Uint8ClampedArray | null = null;
let dominant = "#2f6df6";
function computeDominantColor() {
if (!baseBuf) return;
const bins: Record<string, number> = {};
let idx = 0;
for (let i = 0; i < CALC_W * CALC_H; i += 1) {
const r = baseBuf[idx] >> 4, g = baseBuf[idx + 1] >> 4, b = baseBuf[idx + 2] >> 4;
const key = r + "-" + g + "-" + b;
bins[key] = (bins[key] || 0) + 1;
idx += 4;
}
let bestKey: string | null = null, bestCount = -1;
for (const k in bins) { if (bins[k] > bestCount) { bestCount = bins[k]; bestKey = k; } }
if (!bestKey) return;
const parts = bestKey.split("-");
dominant = "rgb(" + (parseInt(parts[0], 10) * 16 + 8) + "," + (parseInt(parts[1], 10) * 16 + 8) + "," + (parseInt(parts[2], 10) * 16 + 8) + ")";
}
function renderPhoto(elapsed: number) {
const seedVariant = PHOTO_SEEDS[photoIndex] + Math.floor(elapsed / 45);
octx.clearRect(0, 0, CALC_W, CALC_H);
drawPhotoBase(octx, CALC_W, CALC_H, seedVariant);
baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).data;
computeDominantColor();
}
function cyclePhoto() { photoIndex = (photoIndex + 1) % PHOTO_SEEDS.length; }
canvas.addEventListener("click", function () { cyclePhoto(); draw(lastElapsed); });
let cw = 480, ch = 300;
let lastElapsed = 0;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
function draw(elapsed: number) {
lastElapsed = elapsed;
renderPhoto(elapsed);
const photoW = Math.round(cw * 0.72);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, photoW, ch);
ctx!.fillStyle = dominant;
ctx!.fillRect(photoW, 0, cw - photoW, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null, lastBucket = -1;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
const bucket = Math.floor(elapsed / (LOOP_MS / 2));
if (bucket !== lastBucket) { lastBucket = bucket; cyclePhoto(); }
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>픽셀 값을 읽어 뽑는 대표 색 스와치</p>
</div>
</main>
);
}08 · 픽셀 값을 읽어 뽑는 대표 색 스와치 — 측정 누적면적 6.676% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function computeDominantColor() {
var bins = {};
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var r = baseBuf[idx] >> 4, g = baseBuf[idx + 1] >> 4, b = baseBuf[idx + 2] >> 4;
var key = r + '-' + g + '-' + b;
bins[key] = (bins[key] || 0) + 1;
idx += 4;
}
var bestKey = null, bestCount = -1;
for (var k in bins) { if (bins[k] > bestCount) { bestCount = bins[k]; bestKey = k; } }
var parts = bestKey.split('-');
dominant = 'rgb(' + (parseInt(parts[0], 10) * 16 + 8) + ',' + (parseInt(parts[1], 10) * 16 + 8) + ',' + (parseInt(parts[2], 10) * 16 + 8) + ')';
}
09채도 값이 돌아오는 흑백 호버 리빌
setHoverState 가 캔버스 style.filter 를 grayscale(1) 과 grayscale(0) 사이에서 그대로 바꿔, 픽셀 계산 없이 채도 값만 전환한다. GRAYSCALE_HOVER_MS 는 이 전환이 몇 밀리초 안에 끝나는지를 정한다. 09 항목은 누적면적 58.5%·강도 평균 33.5·23프레임 중 10프레임에서 움직였다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">채도 값이 돌아오는 흑백 호버 리빌</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var LOOP_MS = 2000;
var GRAYSCALE_HOVER_MS = 200;
var photo = document.createElement('canvas');
var pctx = photo.getContext('2d');
var cw = 480, ch = 300;
function buildPhoto() {
photo.width = cw; photo.height = ch;
drawPhotoBase(pctx, cw, ch, 414091);
draw();
}
function draw() {
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(photo, 0, 0);
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
buildPhoto();
}
canvas.style.transition = 'filter ' + GRAYSCALE_HOVER_MS + 'ms ease';
canvas.style.filter = 'grayscale(1) saturate(0)';
canvas.tabIndex = 0;
function setHoverState(on) {
canvas.style.filter = on ? 'grayscale(0) saturate(1)' : 'grayscale(1) saturate(0)';
}
canvas.addEventListener('pointerenter', function () { setHoverState(true); });
canvas.addEventListener('pointerleave', function () { setHoverState(false); });
canvas.addEventListener('focus', function () { setHoverState(true); });
canvas.addEventListener('blur', function () { setHoverState(false); });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); setHoverState(true); }
var elapsed = 0, lastTs = null, lastBucket = -1;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
var bucket = Math.floor(elapsed / (LOOP_MS / 4));
if (bucket !== lastBucket) { lastBucket = bucket; setHoverState(bucket % 2 === 1); }
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterGrayscaleHoverReveal.module.scss";
export default function FilterGrayscaleHoverReveal() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const LOOP_MS = 2000;
const GRAYSCALE_HOVER_MS = 200;
const photo = document.createElement("canvas");
const pctx = photo.getContext("2d") as CanvasRenderingContext2D;
let cw = 480, ch = 300;
function draw() {
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(photo, 0, 0);
}
function buildPhoto() {
photo.width = cw; photo.height = ch;
drawPhotoBase(pctx, cw, ch, 414091);
draw();
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
buildPhoto();
}
canvas.style.transition = "filter " + GRAYSCALE_HOVER_MS + "ms ease";
canvas.style.filter = "grayscale(1) saturate(0)";
canvas.tabIndex = 0;
function setHoverState(on: boolean) {
canvas!.style.filter = on ? "grayscale(0) saturate(1)" : "grayscale(1) saturate(0)";
}
canvas.addEventListener("pointerenter", function () { setHoverState(true); });
canvas.addEventListener("pointerleave", function () { setHoverState(false); });
canvas.addEventListener("focus", function () { setHoverState(true); });
canvas.addEventListener("blur", function () { setHoverState(false); });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); setHoverState(true); }
let elapsed = 0, lastTs: number | null = null, lastBucket = -1;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
const bucket = Math.floor(elapsed / (LOOP_MS / 4));
if (bucket !== lastBucket) { lastBucket = bucket; setHoverState(bucket % 2 === 1); }
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>채도 값이 돌아오는 흑백 호버 리빌</p>
</div>
</main>
);
}09 · 채도 값이 돌아오는 흑백 호버 리빌 — 측정 누적면적 58.465% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function setHoverState(on) {
canvas.style.filter = on ? 'grayscale(0) saturate(1)' : 'grayscale(1) saturate(0)';
}
canvas.addEventListener('pointerenter', function () { setHoverState(true); });
canvas.addEventListener('pointerleave', function () { setHoverState(false); });
canvas.addEventListener('focus', function () { setHoverState(true); });
canvas.addEventListener('blur', function () { setHoverState(false); });
매핑 비율·오프셋 거리·노이즈 강도 — 값이 갈라지는 세 갈래
값 하나가 파고드는 자리는 크게 세 갈래다. 매핑 비율은 밝기 하나를 두 값 사이에서 고르는 쪽(01의 mix·06의 THERMAL_SHIFT)이고, 오프셋 거리는 채널이나 좌표를 몇 px 밀지 정하는 쪽(02의 CHANNEL_OFFSET_PX·07의 EDGE_STRENGTH_PX)이며, 노이즈·흐림 강도는 원본 위에 레이어를 얼마나 짙게 얹을지 정하는 쪽(04의 FROST_BLUR_PX·05의 GRAIN_STRENGTH)이다. 03의 HALFTONE_DOT_SIZE_PX 은 그 값을 반지름 계산식에 직접 곱해 격자 자체를 바꾸고, 08은 픽셀 값을 집계해 거꾸로 색 하나를 뽑아내며, 09만 픽셀을 건드리지 않고 css 필터 효과 값을 그대로 전환한다.
캡처 24장으로 잰 아홉 필터의 프레임별 변화량
값이 프레임마다 바뀌면 화면의 몇 퍼센트가 다시 칠해지는지를 캡처 24장으로 재실측했다. 누적면적은 겹친 프레임을 하나로 합친 값이고, 강도는 그 변화의 평균 밝기 차다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 두 색 매핑 값으로 세우는 듀오톤 | 73.1 | 21.4 | 16/23 |
| 02 채널 어긋남 거리 값으로 갈라지는 글리치 | 14.1 | 37.5 | 22/23 |
| 03 점 크기 값이 인쇄 질감을 만드는 망점 필터 | 63.0 | 82.9 | 18/23 |
| 04 흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터 | 43.3 | 19.0 | 22/23 |
| 05 노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인 | 68.1 | 17.7 | 20/23 |
| 06 밝기 순서 값을 색으로 바꾸는 열화상 톤 | 75.3 | 72.9 | 20/23 |
| 07 가장자리 값이 색수차를 만드는 렌즈 필터 | 13.6 | 37.3 | 23/23 |
| 08 픽셀 값을 읽어 뽑는 대표 색 스와치 | 6.7 | 19.6 | 23/23 |
| 09 채도 값이 돌아오는 흑백 호버 리빌 | 58.5 | 33.5 | 10/23 |
듀오톤부터 흑백 리빌까지, 아홉 패턴이 실제로 앉는 자리
듀오톤(01)은 랜딩 페이지 히어로 이미지에 브랜드 두 색만 남겨 통일감을 주고, 글리치(02)는 프로모션 배너에 커서가 오를 때만 잠깐 끼얹는 강조로 쓴다. 인쇄 질감(03)은 포스터·전단 카드에, 젖빛 유리(04)는 모달이 뜰 때 뒤 배경을 가리는 용도로 맞는다. 그레인(05)은 감성 사진 갤러리, 열화상(06)은 데이터를 다루는 카드, 색수차(07)는 렌즈가 있는 프로필 이미지처럼 자리가 구체적이다.
대표 색 스와치(08)는 상품 카드 배경을 사진 색과 맞추는 css 이미지 색상 필터 조합으로 상품 목록 화면에 바로 쓸 수 있고, 흑백 리빌(09)은 제품 그리드에서 마우스가 지나갈 때만 색이 살아나는 흔한 호버 패턴이다. CSS 이미지 호버 효과 9종 — 한 줄 복붙으로 의 커서 반응 아홉 개와 나란히 두면, 09는 필터 값이 바뀌고 그 편의 나머지는 transform·shadow 값이 바뀐다는 차이가 드러난다.
css 이미지 필터는 css 백그라운드 필터와 뭐가 다른가?
filter 는 요소 자체(이미지·캔버스)를 다시 계산해 그리고, backdrop-filter 는 그 요소 뒤에 깔린 배경을 계산해 흐리거나 색을 바꾼다. 04의 젖빛 유리는 이미지를 직접 blur 하는 filter 예제라, 뒤가 비치는 진짜 유리 느낌을 내려면 CSS 글래스모피즘 리퀴드 글래스 효과 9종 쪽의 backdrop-filter 조합과 나눠 맡기는 편이 맞다 — 카드 앞면은 이 편의 필터로, 카드 뒤 배경은 그 편의 backdrop-filter 로 처리한다.
저사양 기기에서 계산이 멈추지 않는 이유 — 어디서 깨지는가
동작 줄임 설정(prefers-reduced-motion)이 켜지면 9개 전부 elapsed 값을 고정해 화면이 멈춘 것처럼 보이지만, tick 의 requestAnimationFrame 루프 자체는 멈추지 않는다. 01·02·03·05·06·07·08 일곱 개는 고정된 elapsed 로 매 프레임 160×100 픽셀 버퍼를 처음부터 다시 계산해 그림은 그대로인데 CPU 만 계속 돈다 — 04는 브라우저 filter(blur)를 전체 해상도 사진에 매 프레임 다시 걸고, 09만 값 전환이 없어 사실상 빈 루프다. 이 오프스크린을 160×100 대신 실제 사진 크기(예: 1920×1080)로 올리면 픽셀 수가 16,000에서 2,073,600으로 130배 가까이 뛰어, 저사양 기기에서 큰 이미지를 그대로 필터에 물리면 프레임이 뚝뚝 끊긴다 — index.html·style.scss 원본에서 이 오프스크린 크기를 직접 맞춰보고 싶다면 wd482b7s 으로 zip 을 열면 된다.
상수 셋만 바꿔보는 변형과 키보드 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 듀오톤 혼합 비율 값 조정 | 01 DUOTONE_MIX_PERCENT 30 → 70 | 30 쪽에 두면 원색이 남아 듀오톤이 옅어지고, 70까지 올리면 화면이 두 지정색으로만 눌려 대비가 세진다. |
| 망점 크기 값 조정 | 03 HALFTONE_DOT_SIZE_PX 4 → 12 | 4에 두면 점이 빽빽해 사진처럼 읽히고, 12로 키우면 점 하나하나가 도드라져 낡은 인쇄판처럼 거칠어진다. |
| 호버 채도 복원 시간 값 조정 | 09 GRAYSCALE_HOVER_MS 200 → 600 | 200이면 색이 순간적으로 튀어 돌아오고, 600까지 늘리면 색이 스며들듯 천천히 차오른다. |
접근성은 reduce 상태에서 무엇이 남는지로 갈린다. 01·03은 <input type="range"> 에 aria-label(duotone mix·dot size)을 달아 키보드로도 값을 직접 조절할 수 있고, 09는 pointerenter·pointerleave 뿐 아니라 focus·blur 도 같은 setHoverState 를 부르므로 탭 이동만으로 색이 돌아온다. 캡션 배지는 크림색 배경(rgba(255,247,230,.85)) 위에 $subject-ink 글자색을 쓰는 토큰 그대로라 대비가 사이트 전체와 같다.
css 이미지 필터를 처음 쓸 때 자주 묻는 것
css 필터 색상을 두 개보다 더 섞어도 되나요?
된다 — mapDuotone 처럼 밝기 값 하나를 색 배열 하나에 보간하는 구조라, 06의 THERMAL_STOPS 처럼 색을 다섯 개로 늘려도 같은 방식으로 돈다. 색이 늘수록 구간(i0·i1) 계산만 한 단계 늘어난다.
이 데모의 사진을 내 사진으로 바꿀 수 있나요?
지금은 drawPhotoBase 가 seedRand 로 매번 새로 그리는 절차적 이미지라 실제 촬영본이 아니다. <img> 를 로드해 ctx.drawImage(img, 0, 0, CALC_W, CALC_H) 로 오프스크린에 한 번 그려 넣으면 이후 픽셀 루프는 그대로 쓸 수 있다 — 이미지 갤러리 CSS 9종 — 메이슨리부터 라이트박스까지 의 갤러리 카드에 얹어도 된다.
구형 브라우저에서도 그대로 보이나요?
filter·getImageData·backdrop-filter 는 오래전에 표준화돼 대부분의 최신 환경에서 지원한다. 04의 ctx.filter = 'blur()' 처럼 캔버스 2D 컨텍스트의 filter 속성만 예외적으로 늦게 들어왔으니, 그 항목만 구형 환경에서 확인해 두는 편이 안전하다.









