이미지 물결 효과 9가지 — 캔버스로 계산하는 픽셀 파동
이미지 물결 효과는 사진 위에 진짜 물이 흐르는 것처럼 픽셀을 흔드는 캔버스 연산이다. 9번 히어로 장면은 이미 완성된 물결이다. 그 전 5번은 반사 웅덩이가 흔들리는 단계였고, 가장 처음 1번은 포인터 한 번이 동심원 하나를 그리는 것에서 시작했다.
01 ripple-canvas-pointer-splash
02 ripple-canvas-auto-loop-scene
03 ripple-canvas-hover-reveal
04 ripple-canvas-refraction-lens
05 ripple-canvas-reflection-pool
06 ripple-canvas-card-flip-wave
07 ripple-canvas-audio-reactive
08 ripple-canvas-text-liquid
09 ripple-canvas-hero-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 포인터로 튀기는 물결 파동
- 02 저절로 일렁이는 배경 물결
- 03 물결이 걷히며 다른 이미지가 드러나는 호버
- 04 포인터를 따라다니는 굴절 렌즈
- 05 물에 비친 듯 흔들리는 반사 이미지
- 06 물결이 쓸고 지나가며 뒤집히는 카드
- 07 소리 크기만큼 커지는 물결
- 08 글자 안에서 출렁이는 이미지
- 09 위 기법을 합친 히어로 물결 장면
9번 히어로 장면에서 거꾸로 감아보면 보이는 계산 층
아홉 개는 화면에 물결이 나타나는 방식이 저마다 다르다 — 01·04·06·09는 포인터나 키보드 입력이 직접 충격을 찍고, 02·05·08은 조작 없이 사인파만으로 계속 흔들리며, 03은 호버 반경이 시간에 비례해 자라고, 07은 가상 오디오 레벨이 진폭을 정한다. 모든 계산은 160×100(16,000픽셀) 오프스크린 캔버스 위에서 getImageData로 읽은 이미지를 매 프레임 다시 계산해 putImageData로 그리고, 화면에 보이는 크기는 drawImage가 확대할 뿐이다. 사진은 외부에서 가져오지 않고 seedRand로 만든 절차적 무늬이며, 아래 코드는 run/419/demo 원본 index.html에서 연속된 줄 그대로 옮겼다.
01포인터가 지나간 자리마다 높이 필드에 충격을 찍는 스플래시 물결
포인터가 지나간 자리에서 작은 파문이 퍼져나가 이미지 표면을 밀어낸 것처럼 보인다. splashAt이 그 좌표의 높이 값을 낮춰 놓으면 stepWave가 이웃 칸 평균과 이전 프레임 값을 섞는 파동식으로 매 프레임 그 충격을 사방으로 퍼뜨리고, RIPPLE_SPLASH_DECAY_MS(기본 400)로 정한 damping 값이 그 파문이 얼마 만에 잦아드는지를 가른다.

<main class="stage">
<div class="rv is-demo" id="rvRoot">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">포인터가 지나간 자리마다 동심원 물결이 퍼지며 이미지 픽셀을 밀어낸다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.rv {
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;
cursor: crosshair;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
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) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rv { cursor: pointer; }
}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);
}
.rv {
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);
cursor: crosshair;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
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) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function () {
// 기법: 높이 필드 2버퍼 전파(감쇠 파동식) 위에 포인터가 지나가는 자리마다 임펄스를 찍는다.
// 높이 기울기를 픽셀 변위(굴절 오프셋)로 써서 절차적 "사진"을 getImageData/putImageData 로 다시 그린다.
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
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, '#bfe3ff');
sky.addColorStop(1, '#fff3da');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.7, sunY = h * 0.24, sunR = h * 0.15;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.6);
glow.addColorStop(0, 'rgba(255,247,225,.9)');
glow.addColorStop(1, 'rgba(255,247,225,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 = ['#8fd0c7', '#4a9d8f', '#215a56'];
for (var i = 0; i < hillColors.length; i += 1) {
var baseY = h * (0.5 + i * 0.14);
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.3 + seed * 0.0003) * h * 0.045 + Math.sin(x * 0.16 + 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.5, r = rand() * 1.3 + 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 RIPPLE_SPLASH_DECAY_MS = 400;
var SPLASH_AMP = -520;
var REFRACT_K = 0.9;
var gridA = new Float32Array(CALC_W * CALC_H);
var gridB = new Float32Array(CALC_W * CALC_H);
var damping = Math.pow(0.02, 16 / RIPPLE_SPLASH_DECAY_MS);
function cellIndex(x, y) { return y * CALC_W + x; }
function splashAt(gx, gy) {
gx = Math.round(gx); gy = Math.round(gy);
for (var dy = -1; dy <= 1; dy += 1) {
for (var dx = -1; dx <= 1; dx += 1) {
var x = gx + dx, y = gy + dy;
if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
gridA[cellIndex(x, y)] += SPLASH_AMP * (dx === 0 && dy === 0 ? 1 : 0.45);
}
}
}
}
function stepWave() {
for (var y = 1; y < CALC_H - 1; y += 1) {
var row = y * CALC_W;
for (var x = 1; x < CALC_W - 1; x += 1) {
var i = row + x;
var next = (gridA[i - 1] + gridA[i + 1] + gridA[i - CALC_W] + gridA[i + CALC_W]) / 2 - gridB[i];
gridB[i] = next * damping;
}
}
var t = gridA; gridA = gridB; gridB = t;
}
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 419011);
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 sampleBase(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function renderField() {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
var row = y * CALC_W;
for (var x = 0; x < CALC_W; x += 1) {
var i = row + x;
var hl = x > 0 ? gridA[i - 1] : gridA[i];
var hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
var hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
var hd = y < CALC_H - 1 ? gridA[i + CALC_W] : gridA[i];
var dx = (hr - hl) * REFRACT_K;
var dy = (hd - hu) * REFRACT_K;
var rgb = sampleBase(x + dx, y + dy);
var shine = Math.max(-30, Math.min(60, (hu - hd) * 0.6));
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] + shine));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] + shine));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] + shine));
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();
}
function draw() {
renderField();
outCtx.putImageData(outImg, 0, 0);
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;
if (reduce) { root.classList.remove('is-demo'); }
var pointerActive = false;
var lastSplashMs = -9999;
function toGrid(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * CALC_W;
var relY = (t0.clientY - r.top) / r.height * CALC_H;
return { x: relX, y: relY };
}
wrap.addEventListener('pointerdown', function (e) {
pointerActive = true;
var g = toGrid(e);
splashAt(g.x, g.y);
lastSplashMs = performance.now();
if (reduce) draw();
});
wrap.addEventListener('pointermove', function (e) {
if (!pointerActive && e.pressure === 0 && e.pointerType === 'mouse') { /* hover still allowed below */ }
var g = toGrid(e);
var now = performance.now();
if (now - lastSplashMs > 90) {
splashAt(g.x, g.y);
lastSplashMs = now;
if (reduce) draw();
}
});
wrap.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
var keySplashRand = seedRand(419012);
wrap.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
splashAt(CALC_W / 2 + (keySplashRand() - 0.5) * 20, CALC_H / 2);
if (reduce) draw();
}
});
wrap.tabIndex = 0;
var VIRTUAL_INTERVAL_MS = 460;
var lastVirtualMs = -9999;
function virtualPointerOrbit(t) {
var cx = CALC_W * (0.28 + 0.44 * ((Math.sin(t * 0.0013) + 1) / 2));
var cy = CALC_H * (0.3 + 0.4 * ((Math.cos(t * 0.0019) + 1) / 2));
return { x: cx, y: cy };
}
var lastTs = null;
var elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (!pointerActive && elapsed - lastVirtualMs > VIRTUAL_INTERVAL_MS) {
var p = virtualPointerOrbit(elapsed);
splashAt(p.x, p.y);
lastVirtualMs = elapsed;
}
stepWave();
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
splashAt(CALC_W * 0.5, CALC_H * 0.45);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasPointerSplash.module.scss";
export default function RippleCanvasPointerSplash() {
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") as CanvasRenderingContext2D;
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, "#bfe3ff");
sky.addColorStop(1, "#fff3da");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.7, sunY = h * 0.24, sunR = h * 0.15;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.6);
glow.addColorStop(0, "rgba(255,247,225,.9)");
glow.addColorStop(1, "rgba(255,247,225,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 = ["#8fd0c7", "#4a9d8f", "#215a56"];
for (let i = 0; i < hillColors.length; i += 1) {
const baseY = h * (0.5 + i * 0.14);
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.3 + seed * 0.0003) * h * 0.045 + Math.sin(x * 0.16 + 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.5, r = rand() * 1.3 + 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 RIPPLE_SPLASH_DECAY_MS = 400;
const SPLASH_AMP = -520;
const REFRACT_K = 0.9;
let gridA = new Float32Array(CALC_W * CALC_H);
let gridB = new Float32Array(CALC_W * CALC_H);
const damping = Math.pow(0.02, 16 / RIPPLE_SPLASH_DECAY_MS);
function cellIndex(x: number, y: number) { return y * CALC_W + x; }
function splashAt(gx: number, gy: number) {
gx = Math.round(gx); gy = Math.round(gy);
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
const x = gx + dx, y = gy + dy;
if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
gridA[cellIndex(x, y)] += SPLASH_AMP * (dx === 0 && dy === 0 ? 1 : 0.45);
}
}
}
}
function stepWave() {
for (let y = 1; y < CALC_H - 1; y += 1) {
const row = y * CALC_W;
for (let x = 1; x < CALC_W - 1; x += 1) {
const i = row + x;
const next = (gridA[i - 1] + gridA[i + 1] + gridA[i - CALC_W] + gridA[i + CALC_W]) / 2 - gridB[i];
gridB[i] = next * damping;
}
}
const t = gridA; gridA = gridB; gridB = t;
}
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, 419011);
const baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).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 sampleBase(sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function renderField() {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
const row = y * CALC_W;
for (let x = 0; x < CALC_W; x += 1) {
const i = row + x;
const hl = x > 0 ? gridA[i - 1] : gridA[i];
const hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
const hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
const hd = y < CALC_H - 1 ? gridA[i + CALC_W] : gridA[i];
const dx = (hr - hl) * REFRACT_K;
const dy = (hd - hu) * REFRACT_K;
const rgb = sampleBase(x + dx, y + dy);
const shine = Math.max(-30, Math.min(60, (hu - hd) * 0.6));
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] + shine));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] + shine));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] + shine));
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();
}
function draw() {
renderField();
outCtx.putImageData(outImg, 0, 0);
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;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
let pointerActive = false;
let lastSplashMs = -9999;
function toGrid(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * CALC_W;
const relY = ((e.clientY - r.top) / r.height) * CALC_H;
return { x: relX, y: relY };
}
function onPointerDown(e: PointerEvent) {
pointerActive = true;
const g = toGrid(e);
splashAt(g.x, g.y);
lastSplashMs = performance.now();
if (reduce) draw();
}
function onPointerMove(e: PointerEvent) {
const g = toGrid(e);
const now = performance.now();
if (now - lastSplashMs > 90) {
splashAt(g.x, g.y);
lastSplashMs = now;
if (reduce) draw();
}
}
function onPointerUp() { pointerActive = false; }
function onPointerLeave() { pointerActive = false; }
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
splashAt(CALC_W / 2, CALC_H / 2);
if (reduce) draw();
}
}
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.addEventListener("keydown", onKeyDown);
wrap.tabIndex = 0;
const VIRTUAL_INTERVAL_MS = 460;
let lastVirtualMs = -9999;
function virtualPointerOrbit(t: number) {
return {
x: CALC_W * (0.28 + 0.44 * ((Math.sin(t * 0.0013) + 1) / 2)),
y: CALC_H * (0.3 + 0.4 * ((Math.cos(t * 0.0019) + 1) / 2))
};
}
let lastTs: number | null = null;
let elapsed = 0;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (!pointerActive && elapsed - lastVirtualMs > VIRTUAL_INTERVAL_MS) {
const p = virtualPointerOrbit(elapsed);
splashAt(p.x, p.y);
lastVirtualMs = elapsed;
}
stepWave();
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
splashAt(CALC_W * 0.5, CALC_H * 0.45);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rv} is-demo`} ref={rootRef}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>포인터가 지나간 자리마다 동심원 물결이 퍼지며 이미지 픽셀을 밀어낸다</p>
</div>
</main>
);
}01 · 포인터로 튀기는 물결 파동 — 측정 누적면적 15.95% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var RIPPLE_SPLASH_DECAY_MS = 400;
var SPLASH_AMP = -520;
var REFRACT_K = 0.9;
var gridA = new Float32Array(CALC_W * CALC_H);
var gridB = new Float32Array(CALC_W * CALC_H);
var damping = Math.pow(0.02, 16 / RIPPLE_SPLASH_DECAY_MS);
function cellIndex(x, y) { return y * CALC_W + x; }
function splashAt(gx, gy) {
gx = Math.round(gx); gy = Math.round(gy);
for (var dy = -1; dy <= 1; dy += 1) {
for (var dx = -1; dx <= 1; dx += 1) {
var x = gx + dx, y = gy + dy;
if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
gridA[cellIndex(x, y)] += SPLASH_AMP * (dx === 0 && dy === 0 ? 1 : 0.45);
}
}
}
}
function stepWave() {
for (var y = 1; y < CALC_H - 1; y += 1) {
var row = y * CALC_W;
01 항목은 누적면적 15.9%·강도 평균 56.8·23프레임 중 23프레임에서 움직였다.
02조작 없이도 사인파 여러 겹이 표면을 흔드는 배경 물결
사용자가 아무것도 하지 않아도 이미지 표면 전체가 잔잔하게 계속 출렁인다. ambientHeight가 RIPPLE_AMBIENT_WAVE_COUNT(기본 2)개의 사인파를 저마다 다른 각도·주기·진폭으로 더해 높이 값을 만들고, 그 값의 이웃 칸 기울기를 굴절 오프셋 삼아 매 프레임 이미지를 다시 샘플링한다.

<main class="stage">
<div class="rv is-demo" id="rvRoot">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">사용자 조작 없이도 사인파가 겹쳐 이미지 표면이 잔잔하게 계속 일렁인다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.rv {
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;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
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) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rv { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
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);
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
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) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: default;
}
}(function () {
// 기법: 포인터 없이도 사인파 RIPPLE_AMBIENT_WAVE_COUNT 겹을 분석식으로 합쳐 높이 필드를 매 프레임 계산한다.
// 높이 필드의 x·y 기울기를 굴절 변위로 써서 절차적 "사진" 픽셀을 매 프레임 다시 그린다(속성 트윈이 아니다).
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
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, '#ffe6a8');
sky.addColorStop(1, '#fff8ea');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.24, sunY = h * 0.28, sunR = h * 0.17;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.4);
glow.addColorStop(0, 'rgba(255,255,255,.85)');
glow.addColorStop(1, 'rgba(255,255,255,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#fff3c9';
pctx.fill();
var hillColors = ['#f3b25a', '#c9713a', '#6a3420'];
for (var i = 0; i < hillColors.length; i += 1) {
var baseY = h * (0.5 + i * 0.14);
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.05 + i * 1.7 + seed * 0.0002) * h * 0.04 + Math.sin(x * 0.14 + 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 < 55; k += 1) {
var px = rand() * w, py = rand() * h * 0.5, r = rand() * 1.2 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.1 + rand() * 0.2) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var RIPPLE_AMBIENT_WAVE_COUNT = 2;
var REFRACT_K = 1.15;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 419021);
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 ambientHeight(x, y, t) {
var h = 0;
for (var i = 0; i < RIPPLE_AMBIENT_WAVE_COUNT; i += 1) {
var ang = (i / RIPPLE_AMBIENT_WAVE_COUNT) * Math.PI + 0.5;
var freq = 0.05 + i * 0.021;
var speed = 0.0017 + i * 0.0009;
var amp = 6.5 / (i + 1);
var dir = (i % 2 === 0) ? 1 : -1;
h += amp * Math.sin(x * Math.cos(ang) * freq + y * Math.sin(ang) * freq + t * speed * dir);
}
return h;
}
function sampleBase(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function renderFrame(t) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var h0 = ambientHeight(x, y, t);
var hx = ambientHeight(x + 1, y, t) - ambientHeight(x - 1, y, t);
var hy = ambientHeight(x, y + 1, t) - ambientHeight(x, y - 1, t);
var dx = hx * REFRACT_K, dy = hy * REFRACT_K;
var rgb = sampleBase(x + dx, y + dy);
var shine = Math.max(-26, Math.min(46, h0 * 4));
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] + shine));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] + shine));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] + shine));
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(t) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
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 ? 900 : 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; }
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasAutoLoopScene.module.scss";
export default function RippleCanvasAutoLoopScene() {
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") as CanvasRenderingContext2D;
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, "#ffe6a8");
sky.addColorStop(1, "#fff8ea");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.24, sunY = h * 0.28, sunR = h * 0.17;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.4);
glow.addColorStop(0, "rgba(255,255,255,.85)");
glow.addColorStop(1, "rgba(255,255,255,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#fff3c9";
pctx.fill();
const hillColors = ["#f3b25a", "#c9713a", "#6a3420"];
for (let i = 0; i < hillColors.length; i += 1) {
const baseY = h * (0.5 + i * 0.14);
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.05 + i * 1.7 + seed * 0.0002) * h * 0.04 + Math.sin(x * 0.14 + 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 < 55; k += 1) {
const px = rand() * w, py = rand() * h * 0.5, r = rand() * 1.2 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.1 + rand() * 0.2) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const RIPPLE_AMBIENT_WAVE_COUNT = 2;
const REFRACT_K = 1.15;
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, 419021);
const baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).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 ambientHeight(x: number, y: number, t: number) {
let h = 0;
for (let i = 0; i < RIPPLE_AMBIENT_WAVE_COUNT; i += 1) {
const ang = (i / RIPPLE_AMBIENT_WAVE_COUNT) * Math.PI + 0.5;
const freq = 0.05 + i * 0.021;
const speed = 0.0017 + i * 0.0009;
const amp = 6.5 / (i + 1);
const dir = (i % 2 === 0) ? 1 : -1;
h += amp * Math.sin(x * Math.cos(ang) * freq + y * Math.sin(ang) * freq + t * speed * dir);
}
return h;
}
function sampleBase(sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function renderFrame(t: number) {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const h0 = ambientHeight(x, y, t);
const hx = ambientHeight(x + 1, y, t) - ambientHeight(x - 1, y, t);
const hy = ambientHeight(x, y + 1, t) - ambientHeight(x, y - 1, t);
const dx = hx * REFRACT_K, dy = hy * REFRACT_K;
const rgb = sampleBase(x + dx, y + dy);
const shine = Math.max(-26, Math.min(46, h0 * 4));
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] + shine));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] + shine));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] + shine));
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
let lastElapsed = 0;
function draw(t: number) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
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);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
let elapsed = reduce ? 900 : 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; }
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.rv} is-demo`} ref={rootRef}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>사용자 조작 없이도 사인파가 겹쳐 이미지 표면이 잔잔하게 계속 일렁인다</p>
</div>
</main>
);
}02 · 저절로 일렁이는 배경 물결 — 측정 누적면적 66.05% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function ambientHeight(x, y, t) {
var h = 0;
for (var i = 0; i < RIPPLE_AMBIENT_WAVE_COUNT; i += 1) {
var ang = (i / RIPPLE_AMBIENT_WAVE_COUNT) * Math.PI + 0.5;
var freq = 0.05 + i * 0.021;
var speed = 0.0017 + i * 0.0009;
var amp = 6.5 / (i + 1);
var dir = (i % 2 === 0) ? 1 : -1;
h += amp * Math.sin(x * Math.cos(ang) * freq + y * Math.sin(ang) * freq + t * speed * dir);
}
return h;
}
function sampleBase(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function renderFrame(t) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var h0 = ambientHeight(x, y, t);
02 항목은 누적면적 66.0%·강도 평균 32.2·23프레임 중 23프레임에서 움직였다.
03호버 반경이 시간에 비례해 자라며 두 번째 사진을 드러내는 물결
호버하면 시작점에서 원 하나가 점점 커지고, 그 경계를 넘은 자리부터 밑에 깔린 두 번째 이미지가 드러난다. renderFrame이 경계에서 WAVE_BAND(기본 10) 폭만큼 sin 값으로 두 이미지를 섞고, 그 폭 안에서만 픽셀을 살짝 밀어 원이 아니라 물결 전선이 지나가는 것처럼 보이게 한다.

<main class="stage">
<div class="rv is-demo" id="rvRoot" tabindex="0">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">호버하면 물결이 퍼지면서 밑에 깔린 두 번째 이미지가 파동을 따라 서서히 드러난다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.rv {
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;
cursor: pointer;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
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) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rv { cursor: pointer; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
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);
cursor: pointer;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
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) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function () {
// 기법: 호버 시작점에서 시간에 비례해 자라는 파동 반경으로 두 절차적 "사진" A·B 를 마스크 블렌드한다.
// 반경 경계띠 안에서는 sin 오프셋으로 픽셀을 살짝 밀어 물결 전선이 지나가는 것처럼 보이게 한다.
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
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, palette) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, palette[0]);
sky.addColorStop(1, palette[1]);
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.68, sunY = h * 0.26, sunR = h * 0.15;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.5);
glow.addColorStop(0, palette[2]);
glow.addColorStop(1, 'rgba(255,255,255,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = palette[3];
pctx.fill();
for (var i = 0; i < palette[4].length; i += 1) {
var baseY = h * (0.5 + i * 0.14);
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 + seed * 0.0003) * h * 0.04 + Math.sin(x * 0.15 + i) * h * 0.018;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = palette[4][i];
pctx.fill();
}
for (var k = 0; k < 50; k += 1) {
var px = rand() * w, py = rand() * h * 0.5, r = rand() * 1.2 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.1 + rand() * 0.2) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var WAVE_FRONT_SPEED = 0.11;
var WAVE_BAND = 10;
function makeBase(seed, palette) {
var c = document.createElement('canvas');
c.width = CALC_W; c.height = CALC_H;
var cx = c.getContext('2d');
drawPhotoBase(cx, CALC_W, CALC_H, seed, palette);
return cx.getImageData(0, 0, CALC_W, CALC_H).data;
}
var bufA = makeBase(419031, ['#bfe3ff', '#fff3da', 'rgba(255,247,225,.9)', '#ffe27a', ['#8fd0c7', '#4a9d8f', '#215a56']]);
var bufB = makeBase(419032, ['#3a2154', '#8a4fae', 'rgba(255,214,247,.7)', '#ffd6f7', ['#5c3a7a', '#2f1f4a', '#160f28']]);
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 sampleAt(buf, sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [buf[i], buf[i + 1], buf[i + 2]];
}
var origin = { x: CALC_W * 0.5, y: CALC_H * 0.5 };
var radius = 0;
var target = 0;
function renderFrame(t) {
var maxR = Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var dx0 = x - origin.x, dy0 = y - origin.y;
var dist = Math.sqrt(dx0 * dx0 + dy0 * dy0);
var band = Math.sin(dist * 0.35 - t * 0.01);
var mix = 1 - Math.max(0, Math.min(1, (dist - (radius - WAVE_BAND)) / WAVE_BAND));
var rippled = dist + band * (mix > 0.02 && mix < 0.98 ? 2.4 : 0);
var a = sampleAt(bufA, x + band * 0.6, y);
var b = sampleAt(bufB, x - band * 0.6, y);
var m = mix;
outBuf[idx] = a[0] + (b[0] - a[0]) * m;
outBuf[idx + 1] = a[1] + (b[1] - a[1]) * m;
outBuf[idx + 2] = a[2] + (b[2] - a[2]) * m;
outBuf[idx + 3] = 255;
idx += 4;
void rippled; void maxR;
}
}
}
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(t) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
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;
if (reduce) { root.classList.remove('is-demo'); }
var hovering = false;
function toGrid(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
return {
x: (t0.clientX - r.left) / r.width * CALC_W,
y: (t0.clientY - r.top) / r.height * CALC_H
};
}
function startReveal(e) {
hovering = true;
if (e) origin = toGrid(e);
target = Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H);
if (reduce) draw(lastElapsed);
}
function endReveal() {
hovering = false;
target = 0;
if (reduce) draw(lastElapsed);
}
wrap.addEventListener('pointerenter', startReveal);
wrap.addEventListener('pointermove', function (e) { if (hovering) origin = toGrid(e); });
wrap.addEventListener('pointerleave', endReveal);
wrap.addEventListener('focus', function () { startReveal(null); origin = { x: CALC_W * 0.5, y: CALC_H * 0.5 }; });
wrap.addEventListener('blur', endReveal);
var VIRTUAL_CYCLE_MS = 1900;
function virtualHover(t) {
var phase = (t % VIRTUAL_CYCLE_MS) / VIRTUAL_CYCLE_MS;
return phase < 0.55;
}
var lastTs = null;
var elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (!hovering) {
var on = virtualHover(elapsed);
target = on ? Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H) : 0;
if (on) {
origin.x = CALC_W * (0.3 + 0.4 * ((Math.sin(elapsed * 0.0007) + 1) / 2));
origin.y = CALC_H * (0.3 + 0.4 * ((Math.cos(elapsed * 0.0011) + 1) / 2));
}
}
var maxR = Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H);
var step = WAVE_FRONT_SPEED * dt * (maxR / 20);
if (radius < target) radius = Math.min(target, radius + step);
else if (radius > target) radius = Math.max(target, radius - step);
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasHoverReveal.module.scss";
export default function RippleCanvasHoverReveal() {
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") as CanvasRenderingContext2D;
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, palette: [string, string, string, string, string[]]) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, palette[0]);
sky.addColorStop(1, palette[1]);
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.68, sunY = h * 0.26, sunR = h * 0.15;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.5);
glow.addColorStop(0, palette[2]);
glow.addColorStop(1, "rgba(255,255,255,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = palette[3];
pctx.fill();
const bands = palette[4];
for (let i = 0; i < bands.length; i += 1) {
const baseY = h * (0.5 + i * 0.14);
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 + seed * 0.0003) * h * 0.04 + Math.sin(x * 0.15 + i) * h * 0.018;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = bands[i];
pctx.fill();
}
for (let k = 0; k < 50; k += 1) {
const px = rand() * w, py = rand() * h * 0.5, r = rand() * 1.2 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.1 + rand() * 0.2) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const WAVE_FRONT_SPEED = 0.11;
const WAVE_BAND = 10;
function makeBase(seed: number, palette: [string, string, string, string, string[]]) {
const c = document.createElement("canvas");
c.width = CALC_W; c.height = CALC_H;
const cx = c.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(cx, CALC_W, CALC_H, seed, palette);
return cx.getImageData(0, 0, CALC_W, CALC_H).data;
}
const bufA = makeBase(419031, ["#bfe3ff", "#fff3da", "rgba(255,247,225,.9)", "#ffe27a", ["#8fd0c7", "#4a9d8f", "#215a56"]]);
const bufB = makeBase(419032, ["#3a2154", "#8a4fae", "rgba(255,214,247,.7)", "#ffd6f7", ["#5c3a7a", "#2f1f4a", "#160f28"]]);
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 sampleAt(buf: Uint8ClampedArray, sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [buf[i], buf[i + 1], buf[i + 2]];
}
let origin = { x: CALC_W * 0.5, y: CALC_H * 0.5 };
let radius = 0;
let target = 0;
function renderFrame(t: number) {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const dx0 = x - origin.x, dy0 = y - origin.y;
const dist = Math.sqrt(dx0 * dx0 + dy0 * dy0);
const band = Math.sin(dist * 0.35 - t * 0.01);
const mix = 1 - Math.max(0, Math.min(1, (dist - (radius - WAVE_BAND)) / WAVE_BAND));
const a = sampleAt(bufA, x + band * 0.6, y);
const b = sampleAt(bufB, x - band * 0.6, y);
const m = mix;
outBuf[idx] = a[0] + (b[0] - a[0]) * m;
outBuf[idx + 1] = a[1] + (b[1] - a[1]) * m;
outBuf[idx + 2] = a[2] + (b[2] - a[2]) * m;
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
let lastElapsed = 0;
function draw(t: number) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
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);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
let hovering = false;
function toGrid(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
return {
x: (e.clientX - r.left) / r.width * CALC_W,
y: (e.clientY - r.top) / r.height * CALC_H
};
}
function startReveal(e: PointerEvent | null) {
hovering = true;
if (e) origin = toGrid(e);
target = Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H);
if (reduce) draw(lastElapsed);
}
function endReveal() {
hovering = false;
target = 0;
if (reduce) draw(lastElapsed);
}
function onPointerMove(e: PointerEvent) { if (hovering) origin = toGrid(e); }
function onFocus() { startReveal(null); origin = { x: CALC_W * 0.5, y: CALC_H * 0.5 }; }
wrap.addEventListener("pointerenter", startReveal as EventListener);
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", endReveal);
wrap.addEventListener("focus", onFocus);
wrap.addEventListener("blur", endReveal);
const VIRTUAL_CYCLE_MS = 1900;
function virtualHover(t: number) {
const phase = (t % VIRTUAL_CYCLE_MS) / VIRTUAL_CYCLE_MS;
return phase < 0.55;
}
let lastTs: number | null = null;
let elapsed = 0;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (!hovering) {
const on = virtualHover(elapsed);
target = on ? Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H) : 0;
if (on) {
origin.x = CALC_W * (0.3 + 0.4 * ((Math.sin(elapsed * 0.0007) + 1) / 2));
origin.y = CALC_H * (0.3 + 0.4 * ((Math.cos(elapsed * 0.0011) + 1) / 2));
}
}
const maxR = Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H);
const step = WAVE_FRONT_SPEED * dt * (maxR / 20);
if (radius < target) radius = Math.min(target, radius + step);
else if (radius > target) radius = Math.max(target, radius - step);
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointerenter", startReveal as EventListener);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", endReveal);
wrap.removeEventListener("focus", onFocus);
wrap.removeEventListener("blur", endReveal);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rv} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>호버하면 물결이 퍼지면서 밑에 깔린 두 번째 이미지가 파동을 따라 서서히 드러난다</p>
</div>
</main>
);
}03 · 물결이 걷히며 다른 이미지가 드러나는 호버 — 측정 누적면적 66.526% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function renderFrame(t) {
var maxR = Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var dx0 = x - origin.x, dy0 = y - origin.y;
var dist = Math.sqrt(dx0 * dx0 + dy0 * dy0);
var band = Math.sin(dist * 0.35 - t * 0.01);
var mix = 1 - Math.max(0, Math.min(1, (dist - (radius - WAVE_BAND)) / WAVE_BAND));
var rippled = dist + band * (mix > 0.02 && mix < 0.98 ? 2.4 : 0);
var a = sampleAt(bufA, x + band * 0.6, y);
var b = sampleAt(bufB, x - band * 0.6, y);
var m = mix;
outBuf[idx] = a[0] + (b[0] - a[0]) * m;
outBuf[idx + 1] = a[1] + (b[1] - a[1]) * m;
outBuf[idx + 2] = a[2] + (b[2] - a[2]) * m;
outBuf[idx + 3] = 255;
idx += 4;
void rippled; void maxR;
}
}
}
03 항목은 누적면적 66.5%·강도 평균 44.4·23프레임 중 23프레임에서 움직였다.
04포인터를 따라다니는 원형 영역만 픽셀을 휘게 하는 굴절 렌즈
포인터 주변 동그란 영역만 빛이 굴절된 렌즈를 통과한 것처럼 이미지가 휘어 보인다. 중심 거리 dist가 LENS_RADIUS(기본 26) 안에 들면 LENS_REFRACT_K(기본 1.7)를 곱한 반경 변위로 그 지점의 픽셀을 안쪽에서 바깥으로 밀어 샘플링하고, sin 파동 한 겹을 더해 렌즈 표면이 물처럼 미세하게 흔들리게 한다.

<main class="stage">
<div class="rv is-demo" id="rvRoot" tabindex="0">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">포인터를 따라다니는 원형 렌즈 안에서 이미지가 물속처럼 굴절되어 보인다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.rv {
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;
cursor: crosshair;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .68);
color: $stage-paper;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rv { cursor: pointer; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
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);
cursor: crosshair;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.68);
color: #ffffff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function () {
// 기법: 렌즈 중심에서의 거리 기반 반경 변위 필드(스넬 굴절 근사)로 원 안쪽만 픽셀을 재배치한다.
// 렌즈 표면에는 sin 파동을 얹어 정적 돋보기가 아니라 물 렌즈처럼 표면이 흔들리게 한다.
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
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, '#d8e9ff');
sky.addColorStop(1, '#f6f2ff');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.3, sunY = h * 0.22, sunR = h * 0.14;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.6);
glow.addColorStop(0, 'rgba(255,255,255,.9)');
glow.addColorStop(1, 'rgba(255,255,255,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
var gridColors = ['#a9c9ff', '#6f97e0', '#3d5fa8'];
for (var i = 0; i < gridColors.length; i += 1) {
var baseY = h * (0.48 + i * 0.16);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 22);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.055 + i * 1.9 + seed * 0.00025) * h * 0.035;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = gridColors[i];
pctx.fill();
}
for (var k = 0; k < 46; k += 1) {
var px = rand() * w, py = rand() * h, r = rand() * 1.1 + 0.4;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.2) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LENS_RADIUS = 26;
var LENS_REFRACT_K = 1.7;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 419041);
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 sampleBase(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
var lens = { x: CALC_W * 0.5, y: CALC_H * 0.5 };
function renderFrame(t) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var dxp = x - lens.x, dyp = y - lens.y;
var dist = Math.sqrt(dxp * dxp + dyp * dyp);
var rgb;
if (dist < LENS_RADIUS) {
var norm = dist / LENS_RADIUS;
var bulge = (1 - norm) * (1 - norm);
var ripple = Math.sin(dist * 0.9 - t * 0.012) * 0.6 * (1 - norm);
var factor = LENS_REFRACT_K * bulge + ripple;
var ux = dist > 0.0001 ? dxp / dist : 0;
var uy = dist > 0.0001 ? dyp / dist : 0;
var sx = x - ux * factor * 4;
var sy = y - uy * factor * 4;
rgb = sampleBase(sx, sy);
var shine = Math.max(-20, Math.min(50, (1 - norm) * 30 - ripple * 14));
rgb = [
Math.max(0, Math.min(255, rgb[0] + shine)),
Math.max(0, Math.min(255, rgb[1] + shine)),
Math.max(0, Math.min(255, rgb[2] + shine))
];
} else {
rgb = sampleBase(x, y);
}
outBuf[idx] = rgb[0];
outBuf[idx + 1] = rgb[1];
outBuf[idx + 2] = rgb[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(t) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
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;
if (reduce) { root.classList.remove('is-demo'); }
var pointerActive = false;
function toGrid(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
return {
x: (t0.clientX - r.left) / r.width * CALC_W,
y: (t0.clientY - r.top) / r.height * CALC_H
};
}
wrap.addEventListener('pointerenter', function () { pointerActive = true; });
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
lens = toGrid(e);
if (reduce) draw(lastElapsed);
});
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowLeft') lens.x = Math.max(0, lens.x - 6);
else if (e.key === 'ArrowRight') lens.x = Math.min(CALC_W, lens.x + 6);
else if (e.key === 'ArrowUp') lens.y = Math.max(0, lens.y - 6);
else if (e.key === 'ArrowDown') lens.y = Math.min(CALC_H, lens.y + 6);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) draw(lastElapsed);
}
});
wrap.tabIndex = 0;
function virtualLens(t) {
return {
x: CALC_W * (0.5 + 0.32 * Math.sin(t * 0.0011)),
y: CALC_H * (0.5 + 0.3 * Math.cos(t * 0.0017))
};
}
var lastTs = null;
var elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (!pointerActive) lens = virtualLens(elapsed);
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasRefractionLens.module.scss";
export default function RippleCanvasRefractionLens() {
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") as CanvasRenderingContext2D;
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, "#d8e9ff");
sky.addColorStop(1, "#f6f2ff");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.3, sunY = h * 0.22, sunR = h * 0.14;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.6);
glow.addColorStop(0, "rgba(255,255,255,.9)");
glow.addColorStop(1, "rgba(255,255,255,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
const gridColors = ["#a9c9ff", "#6f97e0", "#3d5fa8"];
for (let i = 0; i < gridColors.length; i += 1) {
const baseY = h * (0.48 + i * 0.16);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 22);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.055 + i * 1.9 + seed * 0.00025) * h * 0.035;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = gridColors[i];
pctx.fill();
}
for (let k = 0; k < 46; k += 1) {
const px = rand() * w, py = rand() * h, r = rand() * 1.1 + 0.4;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.2) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LENS_RADIUS = 26;
const LENS_REFRACT_K = 1.7;
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, 419041);
const baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).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 sampleBase(sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
let lens = { x: CALC_W * 0.5, y: CALC_H * 0.5 };
function renderFrame(t: number) {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const dxp = x - lens.x, dyp = y - lens.y;
const dist = Math.sqrt(dxp * dxp + dyp * dyp);
let rgb: [number, number, number];
if (dist < LENS_RADIUS) {
const norm = dist / LENS_RADIUS;
const bulge = (1 - norm) * (1 - norm);
const ripple = Math.sin(dist * 0.9 - t * 0.012) * 0.6 * (1 - norm);
const factor = LENS_REFRACT_K * bulge + ripple;
const ux = dist > 0.0001 ? dxp / dist : 0;
const uy = dist > 0.0001 ? dyp / dist : 0;
const sx = x - ux * factor * 4;
const sy = y - uy * factor * 4;
const base = sampleBase(sx, sy);
const shine = Math.max(-20, Math.min(50, (1 - norm) * 30 - ripple * 14));
rgb = [
Math.max(0, Math.min(255, base[0] + shine)),
Math.max(0, Math.min(255, base[1] + shine)),
Math.max(0, Math.min(255, base[2] + shine))
];
} else {
rgb = sampleBase(x, y);
}
outBuf[idx] = rgb[0];
outBuf[idx + 1] = rgb[1];
outBuf[idx + 2] = rgb[2];
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
let lastElapsed = 0;
function draw(t: number) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
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);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
let pointerActive = false;
function toGrid(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
return {
x: (e.clientX - r.left) / r.width * CALC_W,
y: (e.clientY - r.top) / r.height * CALC_H
};
}
function onPointerEnter() { pointerActive = true; }
function onPointerMove(e: PointerEvent) {
pointerActive = true;
lens = toGrid(e);
if (reduce) draw(lastElapsed);
}
function onPointerLeave() { pointerActive = false; }
function onKeyDown(e: KeyboardEvent) {
let moved = true;
if (e.key === "ArrowLeft") lens.x = Math.max(0, lens.x - 6);
else if (e.key === "ArrowRight") lens.x = Math.min(CALC_W, lens.x + 6);
else if (e.key === "ArrowUp") lens.y = Math.max(0, lens.y - 6);
else if (e.key === "ArrowDown") lens.y = Math.min(CALC_H, lens.y + 6);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) draw(lastElapsed);
}
}
wrap.addEventListener("pointerenter", onPointerEnter);
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.addEventListener("keydown", onKeyDown);
wrap.tabIndex = 0;
function virtualLens(t: number) {
return {
x: CALC_W * (0.5 + 0.32 * Math.sin(t * 0.0011)),
y: CALC_H * (0.5 + 0.3 * Math.cos(t * 0.0017))
};
}
let lastTs: number | null = null;
let elapsed = 0;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (!pointerActive) lens = virtualLens(elapsed);
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointerenter", onPointerEnter);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rv} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>포인터를 따라다니는 원형 렌즈 안에서 이미지가 물속처럼 굴절되어 보인다</p>
</div>
</main>
);
}04 · 포인터를 따라다니는 굴절 렌즈 — 측정 누적면적 21.081% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function renderFrame(t) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var dxp = x - lens.x, dyp = y - lens.y;
var dist = Math.sqrt(dxp * dxp + dyp * dyp);
var rgb;
if (dist < LENS_RADIUS) {
var norm = dist / LENS_RADIUS;
var bulge = (1 - norm) * (1 - norm);
var ripple = Math.sin(dist * 0.9 - t * 0.012) * 0.6 * (1 - norm);
var factor = LENS_REFRACT_K * bulge + ripple;
var ux = dist > 0.0001 ? dxp / dist : 0;
var uy = dist > 0.0001 ? dyp / dist : 0;
var sx = x - ux * factor * 4;
var sy = y - uy * factor * 4;
rgb = sampleBase(sx, sy);
var shine = Math.max(-20, Math.min(50, (1 - norm) * 30 - ripple * 14));
rgb = [
Math.max(0, Math.min(255, rgb[0] + shine)),
Math.max(0, Math.min(255, rgb[1] + shine)),
Math.max(0, Math.min(255, rgb[2] + shine))
];
} else {
rgb = sampleBase(x, y);
04 항목은 누적면적 21.1%·강도 평균 22.5·23프레임 중 23프레임에서 움직였다.
05좌우 반전한 아래쪽 절반이 파형을 따라 흔들리는 반사 웅덩이
이미지 아래로 상하 반전된 반사본이 붙어 있고, 그 반사가 물에 뜬 것처럼 좌우로 흔들린다. reflectWave가 깊이 값에 따라 위상이 달라지는 sin 값을 계산해 REFLECT_SWAY_AMP(기본 6.5)만큼 x축 오프셋을 주고, 깊이가 깊어질수록 알파를 낮춰 반사가 아래로 갈수록 옅어지게 한다.

<main class="stage">
<div class="rv is-demo" id="rvRoot">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">이미지 아래 뒤집힌 반사가 물결치듯 좌우로 흔들리며 서서히 흐려진다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.rv {
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;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
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) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rv { box-shadow: $shadow-raised; }
}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);
}
.rv {
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);
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
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) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv {
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}(function () {
// 기법: 원본을 위쪽에 그리고 아래쪽엔 상하 반전한 복사본을 행마다 다른 x 오프셋(sin)으로 밀어 수면 반사처럼 만든다.
// 오프셋은 정적이지 않고 시간에 따라 위상이 흐르며, 아래로 갈수록 알파를 낮춰 감쇠하는 반사를 표현한다.
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
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, '#274b7a');
sky.addColorStop(1, '#6f9fd6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.62, sunY = h * 0.22, sunR = h * 0.13;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.8);
glow.addColorStop(0, 'rgba(255,224,160,.85)');
glow.addColorStop(1, 'rgba(255,224,160,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe9b8';
pctx.fill();
var towerColors = ['#1c2f4a', '#24405f', '#2d5578'];
for (var i = 0; i < towerColors.length; i += 1) {
var tw = w * (0.05 + i * 0.03);
var tx = w * (0.18 + i * 0.28);
var th = h * (0.32 + i * 0.08);
pctx.fillStyle = towerColors[i];
pctx.fillRect(tx, h * 0.5 - th, tw, th);
}
for (var k = 0; k < 40; k += 1) {
var px = rand() * w, py = rand() * h * 0.5, r = rand() * 1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.1 + rand() * 0.2) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var HORIZON = Math.round(CALC_H * 0.5);
var REFLECT_SWAY_AMP = 6.5;
var REFLECT_SWAY_SPEED = 0.0066;
var REFLECT_GLOW_AMP = 46;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 419051);
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 sampleBase(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function reflectWave(depth, t) {
return Math.sin(depth * 0.5 - t * REFLECT_SWAY_SPEED);
}
function renderFrame(t) {
var idx = 0;
var span = CALC_H - HORIZON;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var rgb;
var alpha = 1;
var glow = 0;
if (y < HORIZON) {
rgb = sampleBase(x, y);
} else {
var depth = y - HORIZON;
var srcY = HORIZON - depth;
var frac = depth / span;
var wave = reflectWave(depth, t);
var offset = wave * REFLECT_SWAY_AMP * frac;
rgb = sampleBase(x + offset, srcY);
// 수면 하이라이트: x 오프셋과 같은 파형(wave)을 밝기 감쇠·스웨이 알파에도 그대로 전파한다.
alpha = Math.max(0.15, 1 - frac * 0.75) * (0.9 + 0.1 * wave);
glow = Math.max(0, wave) * REFLECT_GLOW_AMP * (1 - frac);
}
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] * alpha + glow));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] * alpha + glow));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] * alpha + glow));
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(t) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
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 ? 700 : 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; }
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasReflectionPool.module.scss";
export default function RippleCanvasReflectionPool() {
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") as CanvasRenderingContext2D;
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, "#274b7a");
sky.addColorStop(1, "#6f9fd6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.62, sunY = h * 0.22, sunR = h * 0.13;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.8);
glow.addColorStop(0, "rgba(255,224,160,.85)");
glow.addColorStop(1, "rgba(255,224,160,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe9b8";
pctx.fill();
const towerColors = ["#1c2f4a", "#24405f", "#2d5578"];
for (let i = 0; i < towerColors.length; i += 1) {
const tw = w * (0.05 + i * 0.03);
const tx = w * (0.18 + i * 0.28);
const th = h * (0.32 + i * 0.08);
pctx.fillStyle = towerColors[i];
pctx.fillRect(tx, h * 0.5 - th, tw, th);
}
for (let k = 0; k < 40; k += 1) {
const px = rand() * w, py = rand() * h * 0.5, r = rand() * 1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.1 + rand() * 0.2) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const HORIZON = Math.round(CALC_H * 0.5);
const REFLECT_SWAY_AMP = 6.5;
const REFLECT_SWAY_SPEED = 0.0066;
const REFLECT_GLOW_AMP = 46;
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, 419051);
const baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).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 sampleBase(sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function reflectWave(depth: number, t: number) {
return Math.sin(depth * 0.5 - t * REFLECT_SWAY_SPEED);
}
function renderFrame(t: number) {
let idx = 0;
const span = CALC_H - HORIZON;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
let rgb: [number, number, number];
let alpha = 1;
let glow = 0;
if (y < HORIZON) {
rgb = sampleBase(x, y);
} else {
const depth = y - HORIZON;
const srcY = HORIZON - depth;
const frac = depth / span;
const wave = reflectWave(depth, t);
const offset = wave * REFLECT_SWAY_AMP * frac;
rgb = sampleBase(x + offset, srcY);
// 수면 하이라이트: x 오프셋과 같은 파형(wave)을 밝기 감쇠·스웨이 알파에도 그대로 전파한다.
alpha = Math.max(0.15, 1 - frac * 0.75) * (0.9 + 0.1 * wave);
glow = Math.max(0, wave) * REFLECT_GLOW_AMP * (1 - frac);
}
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] * alpha + glow));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] * alpha + glow));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] * alpha + glow));
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
let lastElapsed = 0;
function draw(t: number) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
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);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
let elapsed = reduce ? 700 : 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; }
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.rv} is-demo`} ref={rootRef}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>이미지 아래 뒤집힌 반사가 물결치듯 좌우로 흔들리며 서서히 흐려진다</p>
</div>
</main>
);
}05 · 물에 비친 듯 흔들리는 반사 이미지 — 측정 누적면적 25.191% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function reflectWave(depth, t) {
return Math.sin(depth * 0.5 - t * REFLECT_SWAY_SPEED);
}
function renderFrame(t) {
var idx = 0;
var span = CALC_H - HORIZON;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var rgb;
var alpha = 1;
var glow = 0;
if (y < HORIZON) {
rgb = sampleBase(x, y);
} else {
var depth = y - HORIZON;
var srcY = HORIZON - depth;
var frac = depth / span;
var wave = reflectWave(depth, t);
var offset = wave * REFLECT_SWAY_AMP * frac;
rgb = sampleBase(x + offset, srcY);
// 수면 하이라이트: x 오프셋과 같은 파형(wave)을 밝기 감쇠·스웨이 알파에도 그대로 전파한다.
alpha = Math.max(0.15, 1 - frac * 0.75) * (0.9 + 0.1 * wave);
glow = Math.max(0, wave) * REFLECT_GLOW_AMP * (1 - frac);
05 항목은 누적면적 25.2%·강도 평균 23.8·23프레임 중 23프레임에서 움직였다.
06좌우로 진행하는 파동 전선이 앞뒤 이미지를 바꿔치는 카드
카드를 뒤집으면 물결 하나가 화면을 가로질러 지나가며 그 자리부터 뒷면 이미지로 바뀐다. renderFrame이 진행률로 계산한 frontX 위치를 기준으로 FRONT_BAND(기본 9) 폭 안에서만 두 이미지를 섞고, 그 경계에 sin 오프셋을 더해 직선이 아니라 물결이 훑고 지나가는 모양을 만든다.

<main class="stage">
<div class="rv is-demo" id="rvRoot" tabindex="0">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">클릭하면 물결 전선이 화면을 가로지르며 지나간 자리부터 뒷면 이미지로 뒤집힌다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.rv {
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;
cursor: pointer;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .7);
color: $stage-paper;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rv { cursor: pointer; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
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);
cursor: pointer;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.7);
color: #ffffff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
}(function () {
// 기법: 좌→우로 진행하는 파동 전선의 x 위치를 시간으로 계산해, 전선을 지난 열은 뒷면 이미지, 못 지난 열은 앞면 이미지를 샘플링한다.
// 전선 경계에는 sin 오프셋을 더해 직선 와이프가 아니라 물결이 훑고 지나가는 모양으로 만든다.
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
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, palette) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, palette[0]);
sky.addColorStop(1, palette[1]);
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.35, sunY = h * 0.25, sunR = h * 0.15;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.5);
glow.addColorStop(0, palette[2]);
glow.addColorStop(1, 'rgba(255,255,255,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = palette[3];
pctx.fill();
for (var i = 0; i < palette[4].length; i += 1) {
var baseY = h * (0.5 + i * 0.14);
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 + seed * 0.0003) * h * 0.04;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = palette[4][i];
pctx.fill();
}
for (var k = 0; k < 48; k += 1) {
var px = rand() * w, py = rand() * h * 0.5, r = rand() * 1.1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.1 + rand() * 0.2) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var CARD_FLIP_MS = 900;
var FRONT_BAND = 9;
function makeBase(seed, palette) {
var c = document.createElement('canvas');
c.width = CALC_W; c.height = CALC_H;
var cx = c.getContext('2d');
drawPhotoBase(cx, CALC_W, CALC_H, seed, palette);
return cx.getImageData(0, 0, CALC_W, CALC_H).data;
}
var bufFront = makeBase(419061, ['#ffe3b0', '#fff8ea', 'rgba(255,255,255,.85)', '#ffcf6b', ['#f0a35a', '#c76b34', '#6a331a']]);
var bufBack = makeBase(419062, ['#c9e8ff', '#eaf6ff', 'rgba(255,255,255,.8)', '#9fd4ff', ['#5fa8d9', '#2f6fa8', '#173a5c']]);
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 sampleAt(buf, sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [buf[i], buf[i + 1], buf[i + 2]];
}
var progress = 0;
var target = 0;
function renderFrame(t) {
var frontX = progress * (CALC_W + FRONT_BAND * 2) - FRONT_BAND;
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
var wobble = Math.sin(y * 0.45 + t * 0.01) * 3.2;
var localFront = frontX + wobble;
for (var x = 0; x < CALC_W; x += 1) {
var rgb;
var d = x - localFront;
if (d < -FRONT_BAND) {
rgb = sampleAt(bufBack, x, y);
} else if (d > FRONT_BAND) {
rgb = sampleAt(bufFront, x, y);
} else {
var m = (d + FRONT_BAND) / (FRONT_BAND * 2);
var a = sampleAt(bufFront, x, y);
var b = sampleAt(bufBack, x, y);
rgb = [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m];
var shine = (1 - Math.abs(m - 0.5) * 2) * 34;
rgb = [rgb[0] + shine, rgb[1] + shine, rgb[2] + shine];
}
outBuf[idx] = Math.max(0, Math.min(255, rgb[0]));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1]));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[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(t) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
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;
if (reduce) { root.classList.remove('is-demo'); }
function toggleFlip() {
target = target === 0 ? 1 : 0;
if (reduce) { progress = target; draw(lastElapsed); }
}
wrap.addEventListener('click', toggleFlip);
wrap.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleFlip(); }
});
wrap.tabIndex = 0;
var AUTO_CYCLE_MS = 2600;
var lastAutoMs = -9999;
function maybeAutoFlip(t) {
if (t - lastAutoMs > AUTO_CYCLE_MS) {
target = target === 0 ? 1 : 0;
lastAutoMs = t;
}
}
var lastTs = null;
var elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
maybeAutoFlip(elapsed);
var step = dt / CARD_FLIP_MS;
if (progress < target) progress = Math.min(target, progress + step);
else if (progress > target) progress = Math.max(target, progress - step);
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasCardFlipWave.module.scss";
export default function RippleCanvasCardFlipWave() {
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") as CanvasRenderingContext2D;
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, palette: [string, string, string, string, string[]]) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, palette[0]);
sky.addColorStop(1, palette[1]);
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.35, sunY = h * 0.25, sunR = h * 0.15;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.5);
glow.addColorStop(0, palette[2]);
glow.addColorStop(1, "rgba(255,255,255,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = palette[3];
pctx.fill();
const bands = palette[4];
for (let i = 0; i < bands.length; i += 1) {
const baseY = h * (0.5 + i * 0.14);
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 + seed * 0.0003) * h * 0.04;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = bands[i];
pctx.fill();
}
for (let k = 0; k < 48; k += 1) {
const px = rand() * w, py = rand() * h * 0.5, r = rand() * 1.1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.1 + rand() * 0.2) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const CARD_FLIP_MS = 900;
const FRONT_BAND = 9;
function makeBase(seed: number, palette: [string, string, string, string, string[]]) {
const c = document.createElement("canvas");
c.width = CALC_W; c.height = CALC_H;
const cx = c.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(cx, CALC_W, CALC_H, seed, palette);
return cx.getImageData(0, 0, CALC_W, CALC_H).data;
}
const bufFront = makeBase(419061, ["#ffe3b0", "#fff8ea", "rgba(255,255,255,.85)", "#ffcf6b", ["#f0a35a", "#c76b34", "#6a331a"]]);
const bufBack = makeBase(419062, ["#c9e8ff", "#eaf6ff", "rgba(255,255,255,.8)", "#9fd4ff", ["#5fa8d9", "#2f6fa8", "#173a5c"]]);
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 sampleAt(buf: Uint8ClampedArray, sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [buf[i], buf[i + 1], buf[i + 2]];
}
let progress = 0;
let target = 0;
function renderFrame(t: number) {
const frontX = progress * (CALC_W + FRONT_BAND * 2) - FRONT_BAND;
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
const wobble = Math.sin(y * 0.45 + t * 0.01) * 3.2;
const localFront = frontX + wobble;
for (let x = 0; x < CALC_W; x += 1) {
let rgb: number[];
const d = x - localFront;
if (d < -FRONT_BAND) {
rgb = sampleAt(bufBack, x, y);
} else if (d > FRONT_BAND) {
rgb = sampleAt(bufFront, x, y);
} else {
const m = (d + FRONT_BAND) / (FRONT_BAND * 2);
const a = sampleAt(bufFront, x, y);
const b = sampleAt(bufBack, x, y);
rgb = [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m];
const shine = (1 - Math.abs(m - 0.5) * 2) * 34;
rgb = [rgb[0] + shine, rgb[1] + shine, rgb[2] + shine];
}
outBuf[idx] = Math.max(0, Math.min(255, rgb[0]));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1]));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2]));
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
let lastElapsed = 0;
function draw(t: number) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
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);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function toggleFlip() {
target = target === 0 ? 1 : 0;
if (reduce) { progress = target; draw(lastElapsed); }
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleFlip(); }
}
wrap.addEventListener("click", toggleFlip);
wrap.addEventListener("keydown", onKeyDown);
wrap.tabIndex = 0;
const AUTO_CYCLE_MS = 2600;
let lastAutoMs = -9999;
function maybeAutoFlip(t: number) {
if (t - lastAutoMs > AUTO_CYCLE_MS) {
target = target === 0 ? 1 : 0;
lastAutoMs = t;
}
}
let lastTs: number | null = null;
let elapsed = 0;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
maybeAutoFlip(elapsed);
const step = dt / CARD_FLIP_MS;
if (progress < target) progress = Math.min(target, progress + step);
else if (progress > target) progress = Math.max(target, progress - step);
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("click", toggleFlip);
wrap.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rv} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>클릭하면 물결 전선이 화면을 가로지르며 지나간 자리부터 뒷면 이미지로 뒤집힌다</p>
</div>
</main>
);
}06 · 물결이 쓸고 지나가며 뒤집히는 카드 — 측정 누적면적 63.749% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function renderFrame(t) {
var frontX = progress * (CALC_W + FRONT_BAND * 2) - FRONT_BAND;
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
var wobble = Math.sin(y * 0.45 + t * 0.01) * 3.2;
var localFront = frontX + wobble;
for (var x = 0; x < CALC_W; x += 1) {
var rgb;
var d = x - localFront;
if (d < -FRONT_BAND) {
rgb = sampleAt(bufBack, x, y);
} else if (d > FRONT_BAND) {
rgb = sampleAt(bufFront, x, y);
} else {
var m = (d + FRONT_BAND) / (FRONT_BAND * 2);
var a = sampleAt(bufFront, x, y);
var b = sampleAt(bufBack, x, y);
rgb = [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m];
var shine = (1 - Math.abs(m - 0.5) * 2) * 34;
rgb = [rgb[0] + shine, rgb[1] + shine, rgb[2] + shine];
06 항목은 누적면적 63.7%·강도 평균 59.5·23프레임 중 23프레임에서 움직였다.
07가상 오디오 레벨이 커질수록 진폭이 함께 자라는 물결
소리가 커지는 순간마다 화면 가운데서 물결이 크게 퍼져나간다. virtualAudioLevel이 비트·펄스·플리커 세 성분을 합쳐 만든 가상 레벨 값에 RIPPLE_AUDIO_GAIN(기본 0.4)을 곱해 splashAt의 진폭으로 넘기고, 실제 마이크 입력이 아니라 이 절차적 값이 매 프레임 충격 크기를 정한다.

<main class="stage">
<div class="rv is-demo" id="rvRoot">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">합성된 오디오 레벨 곡선이 커질 때마다 이미지 표면에 물결이 세게 친다</p>
<div class="rv__control">
<label class="rv__label" for="rvGain">반응 세기</label>
<input class="rv__range" id="rvGain" type="range" min="10" max="160" value="40" step="1" aria-label="반응 세기">
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.rv {
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;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
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;
}
.rv__control {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: $sp-2;
box-sizing: border-box;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .55);
}
.rv__label {
flex: 0 0 auto;
font-size: 11px;
font-weight: 600;
color: $stage-paper;
white-space: nowrap;
}
.rv__range {
flex: 1 1 auto;
min-width: 0;
width: 0;
accent-color: $subject-blue;
}
@media (max-width: 340px) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.rv__label { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.rv { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
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);
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
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;
}
.rv__control {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.55);
}
.rv__label {
flex: 0 0 auto;
font-size: 11px;
font-weight: 600;
color: #ffffff;
white-space: nowrap;
}
.rv__range {
flex: 1 1 auto;
min-width: 0;
width: 0;
accent-color: #2f6df6;
}
@media (max-width: 340px) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
.rv__label {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: default;
}
}(function () {
// 기법: 실제 마이크·파일 없이 시간 함수로 합성한 가상 오디오 레벨 곡선(virtualAudioLevel)을 만든다.
// 레벨이 문턱을 넘을 때마다 높이 필드 2버퍼 파동식에 임펄스를 찍어 RIPPLE_AUDIO_GAIN 배율로 세기를 조절한다.
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var gainInput = document.getElementById('rvGain');
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, '#2a1640');
sky.addColorStop(1, '#5c2a6b');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.62, sunY = h * 0.24, sunR = h * 0.14;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.8);
glow.addColorStop(0, 'rgba(255,140,200,.75)');
glow.addColorStop(1, 'rgba(255,140,200,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffd6f2';
pctx.fill();
var hillColors = ['#7a3b8a', '#4a2364', '#251140'];
for (var i = 0; i < hillColors.length; i += 1) {
var baseY = h * (0.5 + i * 0.14);
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 + seed * 0.0003) * h * 0.04;
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.6, r = rand() * 1.2 + 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 RIPPLE_AUDIO_GAIN = 0.4;
var gridA = new Float32Array(CALC_W * CALC_H);
var gridB = new Float32Array(CALC_W * CALC_H);
var damping = 0.965;
function cellIndex(x, y) { return y * CALC_W + x; }
function splashAt(gx, gy, amp) {
gx = Math.round(gx); gy = Math.round(gy);
for (var dy = -1; dy <= 1; dy += 1) {
for (var dx = -1; dx <= 1; dx += 1) {
var x = gx + dx, y = gy + dy;
if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
gridA[cellIndex(x, y)] += amp * (dx === 0 && dy === 0 ? 1 : 0.4);
}
}
}
}
function stepWave() {
for (var y = 1; y < CALC_H - 1; y += 1) {
var row = y * CALC_W;
for (var x = 1; x < CALC_W - 1; x += 1) {
var i = row + x;
var next = (gridA[i - 1] + gridA[i + 1] + gridA[i - CALC_W] + gridA[i + CALC_W]) / 2 - gridB[i];
gridB[i] = next * damping;
}
}
var t = gridA; gridA = gridB; gridB = t;
}
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 419071);
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 sampleBase(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function renderField() {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
var row = y * CALC_W;
for (var x = 0; x < CALC_W; x += 1) {
var i = row + x;
var hl = x > 0 ? gridA[i - 1] : gridA[i];
var hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
var hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
var hd = y < CALC_H - 1 ? gridA[i + CALC_W] : gridA[i];
var dx = (hr - hl) * 0.8;
var dy = (hd - hu) * 0.8;
var rgb = sampleBase(x + dx, y + dy);
var shine = Math.max(-30, Math.min(60, (hu - hd) * 0.5));
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] + shine));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] + shine));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] + shine));
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function virtualAudioLevel(t) {
var beat = Math.pow(Math.abs(Math.sin(t * 0.0026)), 3);
var pulse = Math.pow(Math.abs(Math.sin(t * 0.00071 + 1.3)), 5);
var flicker = 0.5 + 0.5 * Math.sin(t * 0.021);
var level = beat * 0.6 + pulse * 0.5 + flicker * 0.15;
return Math.max(0, Math.min(1, level));
}
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();
}
function draw() {
renderField();
outCtx.putImageData(outImg, 0, 0);
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;
if (reduce) { root.classList.remove('is-demo'); }
var gainMul = parseFloat(gainInput.value);
gainInput.addEventListener('input', function () {
gainMul = parseFloat(gainInput.value);
if (reduce) { splashAt(CALC_W * 0.5, CALC_H * 0.5, RIPPLE_AUDIO_GAIN * gainMul * 6); draw(); }
});
var lastBeatIdx = -1;
var BEAT_INTERVAL_MS = 220;
var lastTs = null;
var elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
var beatIdx = Math.floor(elapsed / BEAT_INTERVAL_MS);
if (beatIdx !== lastBeatIdx) {
lastBeatIdx = beatIdx;
var level = virtualAudioLevel(elapsed);
if (level > 0.25) {
var gx = CALC_W * (0.3 + 0.4 * ((Math.sin(elapsed * 0.0017) + 1) / 2));
var gy = CALC_H * (0.3 + 0.4 * ((Math.cos(elapsed * 0.0013) + 1) / 2));
splashAt(gx, gy, -level * RIPPLE_AUDIO_GAIN * gainMul * 10);
}
}
stepWave();
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
splashAt(CALC_W * 0.5, CALC_H * 0.5, -RIPPLE_AUDIO_GAIN * gainMul * 6);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasAudioReactive.module.scss";
export default function RippleCanvasAudioReactive() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const gainRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const gainInput = gainRef.current;
if (!root || !canvas || !gainInput) return;
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
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, "#2a1640");
sky.addColorStop(1, "#5c2a6b");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.62, sunY = h * 0.24, sunR = h * 0.14;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.8);
glow.addColorStop(0, "rgba(255,140,200,.75)");
glow.addColorStop(1, "rgba(255,140,200,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffd6f2";
pctx.fill();
const hillColors = ["#7a3b8a", "#4a2364", "#251140"];
for (let i = 0; i < hillColors.length; i += 1) {
const baseY = h * (0.5 + i * 0.14);
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 + seed * 0.0003) * h * 0.04;
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.6, r = rand() * 1.2 + 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 RIPPLE_AUDIO_GAIN = 0.4;
let gridA = new Float32Array(CALC_W * CALC_H);
let gridB = new Float32Array(CALC_W * CALC_H);
const damping = 0.965;
function cellIndex(x: number, y: number) { return y * CALC_W + x; }
function splashAt(gx: number, gy: number, amp: number) {
gx = Math.round(gx); gy = Math.round(gy);
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
const x = gx + dx, y = gy + dy;
if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
gridA[cellIndex(x, y)] += amp * (dx === 0 && dy === 0 ? 1 : 0.4);
}
}
}
}
function stepWave() {
for (let y = 1; y < CALC_H - 1; y += 1) {
const row = y * CALC_W;
for (let x = 1; x < CALC_W - 1; x += 1) {
const i = row + x;
const next = (gridA[i - 1] + gridA[i + 1] + gridA[i - CALC_W] + gridA[i + CALC_W]) / 2 - gridB[i];
gridB[i] = next * damping;
}
}
const t = gridA; gridA = gridB; gridB = t;
}
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, 419071);
const baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).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 sampleBase(sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function renderField() {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
const row = y * CALC_W;
for (let x = 0; x < CALC_W; x += 1) {
const i = row + x;
const hl = x > 0 ? gridA[i - 1] : gridA[i];
const hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
const hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
const hd = y < CALC_H - 1 ? gridA[i + CALC_W] : gridA[i];
const dx = (hr - hl) * 0.8;
const dy = (hd - hu) * 0.8;
const rgb = sampleBase(x + dx, y + dy);
const shine = Math.max(-30, Math.min(60, (hu - hd) * 0.5));
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] + shine));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] + shine));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] + shine));
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function virtualAudioLevel(t: number) {
const beat = Math.pow(Math.abs(Math.sin(t * 0.0026)), 3);
const pulse = Math.pow(Math.abs(Math.sin(t * 0.00071 + 1.3)), 5);
const flicker = 0.5 + 0.5 * Math.sin(t * 0.021);
const level = beat * 0.6 + pulse * 0.5 + flicker * 0.15;
return Math.max(0, Math.min(1, level));
}
let cw = 480, ch = 300;
function draw() {
renderField();
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
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();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
let gainMul = parseFloat(gainInput.value);
function onGainInput() {
gainMul = parseFloat(gainInput!.value);
if (reduce) { splashAt(CALC_W * 0.5, CALC_H * 0.5, RIPPLE_AUDIO_GAIN * gainMul * 6); draw(); }
}
gainInput.addEventListener("input", onGainInput);
let lastBeatIdx = -1;
const BEAT_INTERVAL_MS = 220;
let lastTs: number | null = null;
let elapsed = 0;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
const beatIdx = Math.floor(elapsed / BEAT_INTERVAL_MS);
if (beatIdx !== lastBeatIdx) {
lastBeatIdx = beatIdx;
const level = virtualAudioLevel(elapsed);
if (level > 0.25) {
const gx = CALC_W * (0.3 + 0.4 * ((Math.sin(elapsed * 0.0017) + 1) / 2));
const gy = CALC_H * (0.3 + 0.4 * ((Math.cos(elapsed * 0.0013) + 1) / 2));
splashAt(gx, gy, -level * RIPPLE_AUDIO_GAIN * gainMul * 10);
}
}
stepWave();
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
splashAt(CALC_W * 0.5, CALC_H * 0.5, -RIPPLE_AUDIO_GAIN * gainMul * 6);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
gainInput.removeEventListener("input", onGainInput);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rv} is-demo`} ref={rootRef}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>합성된 오디오 레벨 곡선이 커질 때마다 이미지 표면에 물결이 세게 친다</p>
<div className={styles.rv__control}>
<label className={styles.rv__label} htmlFor="rvGain">반응 세기</label>
<input
className={styles.rv__range}
id="rvGain"
ref={gainRef}
type="range"
min={10}
max={160}
defaultValue={40}
step={1}
aria-label="반응 세기"
/>
</div>
</div>
</main>
);
}07 · 소리 크기만큼 커지는 물결 — 측정 누적면적 19.934% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function splashAt(gx, gy, amp) {
gx = Math.round(gx); gy = Math.round(gy);
for (var dy = -1; dy <= 1; dy += 1) {
for (var dx = -1; dx <= 1; dx += 1) {
var x = gx + dx, y = gy + dy;
if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
gridA[cellIndex(x, y)] += amp * (dx === 0 && dy === 0 ? 1 : 0.4);
}
}
}
}
function stepWave() {
for (var y = 1; y < CALC_H - 1; y += 1) {
var row = y * CALC_W;
for (var x = 1; x < CALC_W - 1; x += 1) {
var i = row + x;
var next = (gridA[i - 1] + gridA[i + 1] + gridA[i - CALC_W] + gridA[i + CALC_W]) / 2 - gridB[i];
gridB[i] = next * damping;
}
}
var t = gridA; gridA = gridB; gridB = t;
}
07 항목은 누적면적 19.9%·강도 평균 33.3·23프레임 중 23프레임에서 움직였다.
08글자 모양으로 자른 영역 안에서만 출렁이는 이미지
글자 윤곽 안쪽에서만 이미지가 액체처럼 출렁이고, 바깥은 정적인 배경 그대로 남는다. fillText로 그린 글자의 알파 채널을 마스크로 저장해 두고, 그 마스크 값이 임계치를 넘는 픽셀에만 liquidHeight의 사인파 굴절을 적용해 매 프레임 그 영역만 다시 계산한다.

<main class="stage">
<div class="rv is-demo" id="rvRoot">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">글자 모양 안에만 이미지가 채워지고 그 안에서 물결이 계속 일렁인다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.rv {
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;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .68);
color: $stage-paper;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rv { box-shadow: $shadow-raised; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
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);
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.68);
color: #ffffff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv {
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}(function () {
// 기법: fillText 로 그린 글자의 알파 채널을 마스크로 삼아, 마스크 안쪽 픽셀에만 사인파 높이 필드 굴절을 적용한다.
// 마스크 밖은 정적 배경이고 안쪽만 매 프레임 getImageData/putImageData 로 다시 계산해 액체가 담긴 글자처럼 보인다.
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
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, '#bfe9ff');
sky.addColorStop(1, '#e8fbff');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.5, sunY = h * 0.3, sunR = h * 0.2;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.4);
glow.addColorStop(0, 'rgba(255,255,255,.9)');
glow.addColorStop(1, 'rgba(255,255,255,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
var bandColors = ['#6fc7e0', '#3f9fc9', '#1f6f96'];
for (var i = 0; i < bandColors.length; i += 1) {
var baseY = h * (0.45 + i * 0.16);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 20);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2 + seed * 0.0003) * h * 0.03;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = bandColors[i];
pctx.fill();
}
for (var k = 0; k < 40; k += 1) {
var px = rand() * w, py = rand() * h, r = rand() * 1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.1 + rand() * 0.2) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var RIPPLE_TEXT_WAVE_COUNT = 2;
var RIPPLE_TEXT_FIT_RATIO = 0.9;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 419081);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var maskCanvas = document.createElement('canvas');
maskCanvas.width = CALC_W; maskCanvas.height = CALC_H;
var mctx = maskCanvas.getContext('2d');
mctx.textAlign = 'center';
mctx.textBaseline = 'middle';
// 글자 클립 영역을 스테이지 폭 기준으로 크게: probe 크기로 잰 폭을 CALC_W 의 RIPPLE_TEXT_FIT_RATIO 만큼 채우도록 실제 폰트 크기를 역산한다.
var TEXT_LABEL = 'H2O';
var PROBE_PX = 100;
mctx.font = '700 ' + PROBE_PX + 'px sans-serif';
var probeWidth = mctx.measureText(TEXT_LABEL).width;
var fitFontPx = Math.round(PROBE_PX * (CALC_W * RIPPLE_TEXT_FIT_RATIO) / probeWidth);
fitFontPx = Math.min(fitFontPx, Math.round(CALC_H * 0.88));
mctx.font = '700 ' + fitFontPx + 'px sans-serif';
mctx.fillStyle = '#000';
mctx.fillText(TEXT_LABEL, CALC_W / 2, CALC_H / 2 + 2);
var maskBuf = mctx.getImageData(0, 0, CALC_W, CALC_H).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 sampleBase(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function liquidHeight(x, y, t) {
var h = 0;
for (var i = 0; i < RIPPLE_TEXT_WAVE_COUNT; i += 1) {
var freq = 0.09 + i * 0.05;
var speed = 0.0064 + i * 0.0032;
var amp = 8 / (i + 1);
h += amp * Math.sin(x * freq + t * speed) * Math.cos(y * (freq * 0.7) - t * speed * 0.7);
}
return h;
}
var BG_R = 23, BG_G = 20, BG_B = 26;
function renderFrame(t) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var mAlpha = maskBuf[idx + 3];
if (mAlpha < 24) {
outBuf[idx] = BG_R; outBuf[idx + 1] = BG_G; outBuf[idx + 2] = BG_B; outBuf[idx + 3] = 255;
} else {
var h0 = liquidHeight(x, y, t);
var hx = liquidHeight(x + 1, y, t) - liquidHeight(x - 1, y, t);
var hy = liquidHeight(x, y + 1, t) - liquidHeight(x, y - 1, t);
var rgb = sampleBase(x + hx * 1.4, y + hy * 1.4);
// 파동 높이(h0)를 굴절 샘플뿐 아니라 글자 안 명도(shine)·그림자(hy 기반 음영)에도 같은 함수로 전파한다.
var shine = Math.max(-34, Math.min(60, h0 * 7 - hy * 3));
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] + shine));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] + shine));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] + shine));
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(t) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
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 ? 600 : 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; }
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasTextLiquid.module.scss";
export default function RippleCanvasTextLiquid() {
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") as CanvasRenderingContext2D;
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, "#bfe9ff");
sky.addColorStop(1, "#e8fbff");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.5, sunY = h * 0.3, sunR = h * 0.2;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.4);
glow.addColorStop(0, "rgba(255,255,255,.9)");
glow.addColorStop(1, "rgba(255,255,255,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
const bandColors = ["#6fc7e0", "#3f9fc9", "#1f6f96"];
for (let i = 0; i < bandColors.length; i += 1) {
const baseY = h * (0.45 + i * 0.16);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 20);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2 + seed * 0.0003) * h * 0.03;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = bandColors[i];
pctx.fill();
}
for (let k = 0; k < 40; k += 1) {
const px = rand() * w, py = rand() * h, r = rand() * 1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.1 + rand() * 0.2) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const RIPPLE_TEXT_WAVE_COUNT = 2;
const RIPPLE_TEXT_FIT_RATIO = 0.9;
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, 419081);
const baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).data;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = CALC_W; maskCanvas.height = CALC_H;
const mctx = maskCanvas.getContext("2d") as CanvasRenderingContext2D;
mctx.textAlign = "center";
mctx.textBaseline = "middle";
// 글자 클립 영역을 스테이지 폭 기준으로 크게: probe 크기로 잰 폭을 CALC_W 의 RIPPLE_TEXT_FIT_RATIO 만큼 채우도록 실제 폰트 크기를 역산한다.
const TEXT_LABEL = "H2O";
const PROBE_PX = 100;
mctx.font = "700 " + PROBE_PX + "px sans-serif";
const probeWidth = mctx.measureText(TEXT_LABEL).width;
let fitFontPx = Math.round(PROBE_PX * (CALC_W * RIPPLE_TEXT_FIT_RATIO) / probeWidth);
fitFontPx = Math.min(fitFontPx, Math.round(CALC_H * 0.88));
mctx.font = "700 " + fitFontPx + "px sans-serif";
mctx.fillStyle = "#000";
mctx.fillText(TEXT_LABEL, CALC_W / 2, CALC_H / 2 + 2);
const maskBuf = mctx.getImageData(0, 0, CALC_W, CALC_H).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 sampleBase(sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function liquidHeight(x: number, y: number, t: number) {
let h = 0;
for (let i = 0; i < RIPPLE_TEXT_WAVE_COUNT; i += 1) {
const freq = 0.09 + i * 0.05;
const speed = 0.0064 + i * 0.0032;
const amp = 8 / (i + 1);
h += amp * Math.sin(x * freq + t * speed) * Math.cos(y * (freq * 0.7) - t * speed * 0.7);
}
return h;
}
const BG_R = 23, BG_G = 20, BG_B = 26;
function renderFrame(t: number) {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const mAlpha = maskBuf[idx + 3];
if (mAlpha < 24) {
outBuf[idx] = BG_R; outBuf[idx + 1] = BG_G; outBuf[idx + 2] = BG_B; outBuf[idx + 3] = 255;
} else {
const h0 = liquidHeight(x, y, t);
const hx = liquidHeight(x + 1, y, t) - liquidHeight(x - 1, y, t);
const hy = liquidHeight(x, y + 1, t) - liquidHeight(x, y - 1, t);
const rgb = sampleBase(x + hx * 1.4, y + hy * 1.4);
// 파동 높이(h0)를 굴절 샘플뿐 아니라 글자 안 명도(shine)·그림자(hy 기반 음영)에도 같은 함수로 전파한다.
const shine = Math.max(-34, Math.min(60, h0 * 7 - hy * 3));
outBuf[idx] = Math.max(0, Math.min(255, rgb[0] + shine));
outBuf[idx + 1] = Math.max(0, Math.min(255, rgb[1] + shine));
outBuf[idx + 2] = Math.max(0, Math.min(255, rgb[2] + shine));
outBuf[idx + 3] = 255;
}
idx += 4;
}
}
}
let cw = 480, ch = 300;
let lastElapsed = 0;
function draw(t: number) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
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);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
let elapsed = reduce ? 600 : 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; }
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.rv} is-demo`} ref={rootRef}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>글자 모양 안에만 이미지가 채워지고 그 안에서 물결이 계속 일렁인다</p>
</div>
</main>
);
}08 · 글자 안에서 출렁이는 이미지 — 측정 누적면적 17.21% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function liquidHeight(x, y, t) {
var h = 0;
for (var i = 0; i < RIPPLE_TEXT_WAVE_COUNT; i += 1) {
var freq = 0.09 + i * 0.05;
var speed = 0.0064 + i * 0.0032;
var amp = 8 / (i + 1);
h += amp * Math.sin(x * freq + t * speed) * Math.cos(y * (freq * 0.7) - t * speed * 0.7);
}
return h;
}
var BG_R = 23, BG_G = 20, BG_B = 26;
function renderFrame(t) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var mAlpha = maskBuf[idx + 3];
if (mAlpha < 24) {
outBuf[idx] = BG_R; outBuf[idx + 1] = BG_G; outBuf[idx + 2] = BG_B; outBuf[idx + 3] = 255;
} else {
var h0 = liquidHeight(x, y, t);
var hx = liquidHeight(x + 1, y, t) - liquidHeight(x - 1, y, t);
var hy = liquidHeight(x, y + 1, t) - liquidHeight(x, y - 1, t);
08 항목은 누적면적 17.2%·강도 평균 29.6·23프레임 중 23프레임에서 움직였다.
0901·02·05를 한 캔버스에 합친 히어로 물결 장면
포인터가 튀기는 파문과 배경 전체를 흔드는 잔물결, 아래쪽 반사 웅덩이가 한 화면 안에서 동시에 움직인다. upperRGB가 01의 높이 필드(gridA)와 02의 ambientHeight를 더한 기울기로 위쪽 픽셀을 굴절시키고, 05와 같은 방식으로 그 결과를 상하 반전해 아래쪽에 반사로 다시 그린다.

<main class="stage">
<div class="rv is-demo" id="rvRoot" tabindex="0">
<canvas class="rv__canvas" id="rvCanvas" width="480" height="300"></canvas>
<p class="rv__caption">포인터 물결과 잔잔한 파동, 아래쪽 반사까지 한 장면 안에서 합쳐 보여준다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.rv {
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;
cursor: crosshair;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
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) {
.rv__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rv { cursor: pointer; }
}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);
}
.rv {
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);
cursor: crosshair;
outline: none;
}
.rv__canvas {
display: block;
width: 100%;
height: 100%;
}
.rv__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
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) {
.rv__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function () {
// 히어로 장면: 01(포인터 스플래시 2버퍼 파동식) + 02(사인파 잔물결 높이 필드) + 05(하단 반전 반사·sway) 세 기법을 한 캔버스에 합성한다.
// 01: 포인터/가상 궤도가 splashAt 으로 grid 에 임펄스를 찍고 stepWave 로 전파한다.
// 02: ambientHeight 다중 사인파가 배경 전체에 잔잔한 굴절을 계속 더한다.
// 05: 수평선 아래는 위쪽을 상하 반전해 sway 오프셋과 알파 감쇠로 반사처럼 그린다.
var root = document.getElementById('rvRoot');
var canvas = document.getElementById('rvCanvas');
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, '#ffb877');
sky.addColorStop(1, '#ffe9c2');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.5, sunY = h * 0.28, sunR = h * 0.16;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.8);
glow.addColorStop(0, 'rgba(255,240,210,.9)');
glow.addColorStop(1, 'rgba(255,240,210,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#fff2d0';
pctx.fill();
var hillColors = ['#e08a4f', '#a5542c', '#5c2c16'];
for (var i = 0; i < hillColors.length; i += 1) {
var baseY = h * (0.42 + i * 0.05);
pctx.beginPath();
pctx.moveTo(0, h * 0.5);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2 + seed * 0.0003) * h * 0.03;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h * 0.5);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 40; k += 1) {
var px = rand() * w, py = rand() * h * 0.45, r = rand() * 1.1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.1 + rand() * 0.2) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var HORIZON = Math.round(CALC_H * 0.5);
var RIPPLE_AMBIENT_WAVE_COUNT = 2;
var HERO_SPLASH_DECAY_MS = 420;
var SPLASH_AMP = -420;
var gridA = new Float32Array(CALC_W * CALC_H);
var gridB = new Float32Array(CALC_W * CALC_H);
var damping = Math.pow(0.02, 16 / HERO_SPLASH_DECAY_MS);
function cellIndex(x, y) { return y * CALC_W + x; }
function splashAt(gx, gy) {
gx = Math.round(gx); gy = Math.round(gy);
for (var dy = -1; dy <= 1; dy += 1) {
for (var dx = -1; dx <= 1; dx += 1) {
var x = gx + dx, y = gy + dy;
if (x > 0 && x < CALC_W - 1 && y > 0 && y < HORIZON - 1) {
gridA[cellIndex(x, y)] += SPLASH_AMP * (dx === 0 && dy === 0 ? 1 : 0.45);
}
}
}
}
function stepWave() {
for (var y = 1; y < HORIZON - 1; y += 1) {
var row = y * CALC_W;
for (var x = 1; x < CALC_W - 1; x += 1) {
var i = row + x;
var next = (gridA[i - 1] + gridA[i + 1] + gridA[i - CALC_W] + gridA[i + CALC_W]) / 2 - gridB[i];
gridB[i] = next * damping;
}
}
var t = gridA; gridA = gridB; gridB = t;
}
function ambientHeight(x, y, t) {
var h = 0;
for (var i = 0; i < RIPPLE_AMBIENT_WAVE_COUNT; i += 1) {
var ang = (i / RIPPLE_AMBIENT_WAVE_COUNT) * Math.PI + 0.5;
var freq = 0.05 + i * 0.021;
var speed = 0.0017 + i * 0.0009;
var amp = 4.5 / (i + 1);
var dir = (i % 2 === 0) ? 1 : -1;
h += amp * Math.sin(x * Math.cos(ang) * freq + y * Math.sin(ang) * freq + t * speed * dir);
}
return h;
}
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 419091);
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 sampleBase(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > HORIZON - 1 ? HORIZON - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function upperRGB(x, y, t) {
var i = y * CALC_W + x;
var hl = x > 0 ? gridA[i - 1] : gridA[i];
var hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
var hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
var hd = y < HORIZON - 1 ? gridA[i + CALC_W] : gridA[i];
var amb = ambientHeight(x, y, t);
var ambX = ambientHeight(x + 1, y, t) - ambientHeight(x - 1, y, t);
var ambY = ambientHeight(x, y + 1, t) - ambientHeight(x, y - 1, t);
var dx = (hr - hl) * 0.85 + ambX * 1.1;
var dy = (hd - hu) * 0.85 + ambY * 1.1;
var rgb = sampleBase(x + dx, y + dy);
var shine = Math.max(-30, Math.min(55, (hu - hd) * 0.5 + amb * 4));
return [
Math.max(0, Math.min(255, rgb[0] + shine)),
Math.max(0, Math.min(255, rgb[1] + shine)),
Math.max(0, Math.min(255, rgb[2] + shine))
];
}
function renderFrame(t) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var rgb;
if (y < HORIZON) {
rgb = upperRGB(x, y, t);
} else {
var depth = y - HORIZON;
var srcY = Math.max(0, HORIZON - 1 - depth);
var sway = Math.sin(depth * 0.5 - t * 0.006) * 2.2 * (depth / (CALC_H - HORIZON));
var refl = upperRGB(Math.max(0, Math.min(CALC_W - 1, Math.round(x + sway))), srcY, t);
var alpha = Math.max(0.15, 1 - depth / (CALC_H - HORIZON) * 0.75);
rgb = [refl[0] * alpha, refl[1] * alpha, refl[2] * alpha];
}
outBuf[idx] = rgb[0];
outBuf[idx + 1] = rgb[1];
outBuf[idx + 2] = rgb[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(t) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
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;
if (reduce) { root.classList.remove('is-demo'); }
var pointerActive = false;
var lastSplashMs = -9999;
function toGrid(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
return {
x: (t0.clientX - r.left) / r.width * CALC_W,
y: (t0.clientY - r.top) / r.height * HORIZON
};
}
wrap.addEventListener('pointerdown', function (e) {
pointerActive = true;
var g = toGrid(e);
splashAt(g.x, g.y);
lastSplashMs = performance.now();
if (reduce) draw(lastElapsed);
});
wrap.addEventListener('pointermove', function (e) {
var g = toGrid(e);
var now = performance.now();
if (now - lastSplashMs > 100) {
splashAt(g.x, g.y);
lastSplashMs = now;
if (reduce) draw(lastElapsed);
}
});
wrap.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
splashAt(CALC_W / 2, HORIZON * 0.5);
if (reduce) draw(lastElapsed);
}
});
wrap.tabIndex = 0;
var VIRTUAL_INTERVAL_MS = 520;
var lastVirtualMs = -9999;
function virtualPointerOrbit(t) {
return {
x: CALC_W * (0.28 + 0.44 * ((Math.sin(t * 0.0012) + 1) / 2)),
y: HORIZON * (0.25 + 0.5 * ((Math.cos(t * 0.0017) + 1) / 2))
};
}
var lastTs = null;
var elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (!pointerActive && elapsed - lastVirtualMs > VIRTUAL_INTERVAL_MS) {
var p = virtualPointerOrbit(elapsed);
splashAt(p.x, p.y);
lastVirtualMs = elapsed;
}
stepWave();
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
splashAt(CALC_W * 0.5, HORIZON * 0.4);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RippleCanvasHeroScene.module.scss";
// 히어로 장면: 01(포인터 스플래시 2버퍼 파동식) + 02(사인파 잔물결 높이 필드) + 05(하단 반전 반사·sway) 세 기법을 한 캔버스에 합성한다.
// 01: 포인터/가상 궤도가 splashAt 으로 grid 에 임펄스를 찍고 stepWave 로 전파한다.
// 02: ambientHeight 다중 사인파가 배경 전체에 잔잔한 굴절을 계속 더한다.
// 05: 수평선 아래는 위쪽을 상하 반전해 sway 오프셋과 알파 감쇠로 반사처럼 그린다.
export default function RippleCanvasHeroScene() {
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") as CanvasRenderingContext2D;
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, "#ffb877");
sky.addColorStop(1, "#ffe9c2");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.5, sunY = h * 0.28, sunR = h * 0.16;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.8);
glow.addColorStop(0, "rgba(255,240,210,.9)");
glow.addColorStop(1, "rgba(255,240,210,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#fff2d0";
pctx.fill();
const hillColors = ["#e08a4f", "#a5542c", "#5c2c16"];
for (let i = 0; i < hillColors.length; i += 1) {
const baseY = h * (0.42 + i * 0.05);
pctx.beginPath();
pctx.moveTo(0, h * 0.5);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2 + seed * 0.0003) * h * 0.03;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h * 0.5);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 40; k += 1) {
const px = rand() * w, py = rand() * h * 0.45, r = rand() * 1.1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.1 + rand() * 0.2) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const HORIZON = Math.round(CALC_H * 0.5);
const RIPPLE_AMBIENT_WAVE_COUNT = 2;
const HERO_SPLASH_DECAY_MS = 420;
const SPLASH_AMP = -420;
let gridA = new Float32Array(CALC_W * CALC_H);
let gridB = new Float32Array(CALC_W * CALC_H);
const damping = Math.pow(0.02, 16 / HERO_SPLASH_DECAY_MS);
function cellIndex(x: number, y: number) { return y * CALC_W + x; }
function splashAt(gx: number, gy: number) {
gx = Math.round(gx); gy = Math.round(gy);
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
const x = gx + dx, y = gy + dy;
if (x > 0 && x < CALC_W - 1 && y > 0 && y < HORIZON - 1) {
gridA[cellIndex(x, y)] += SPLASH_AMP * (dx === 0 && dy === 0 ? 1 : 0.45);
}
}
}
}
function stepWave() {
for (let y = 1; y < HORIZON - 1; y += 1) {
const row = y * CALC_W;
for (let x = 1; x < CALC_W - 1; x += 1) {
const i = row + x;
const next = (gridA[i - 1] + gridA[i + 1] + gridA[i - CALC_W] + gridA[i + CALC_W]) / 2 - gridB[i];
gridB[i] = next * damping;
}
}
const t = gridA; gridA = gridB; gridB = t;
}
function ambientHeight(x: number, y: number, t: number) {
let h = 0;
for (let i = 0; i < RIPPLE_AMBIENT_WAVE_COUNT; i += 1) {
const ang = (i / RIPPLE_AMBIENT_WAVE_COUNT) * Math.PI + 0.5;
const freq = 0.05 + i * 0.021;
const speed = 0.0017 + i * 0.0009;
const amp = 4.5 / (i + 1);
const dir = (i % 2 === 0) ? 1 : -1;
h += amp * Math.sin(x * Math.cos(ang) * freq + y * Math.sin(ang) * freq + t * speed * dir);
}
return h;
}
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, 419091);
const baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).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 sampleBase(sx: number, sy: number): [number, number, number] {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > HORIZON - 1 ? HORIZON - 1 : sy);
const i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
}
function upperRGB(x: number, y: number, t: number): [number, number, number] {
const i = y * CALC_W + x;
const hl = x > 0 ? gridA[i - 1] : gridA[i];
const hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
const hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
const hd = y < HORIZON - 1 ? gridA[i + CALC_W] : gridA[i];
const amb = ambientHeight(x, y, t);
const ambX = ambientHeight(x + 1, y, t) - ambientHeight(x - 1, y, t);
const ambY = ambientHeight(x, y + 1, t) - ambientHeight(x, y - 1, t);
const dx = (hr - hl) * 0.85 + ambX * 1.1;
const dy = (hd - hu) * 0.85 + ambY * 1.1;
const rgb = sampleBase(x + dx, y + dy);
const shine = Math.max(-30, Math.min(55, (hu - hd) * 0.5 + amb * 4));
return [
Math.max(0, Math.min(255, rgb[0] + shine)),
Math.max(0, Math.min(255, rgb[1] + shine)),
Math.max(0, Math.min(255, rgb[2] + shine))
];
}
function renderFrame(t: number) {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
let rgb: [number, number, number];
if (y < HORIZON) {
rgb = upperRGB(x, y, t);
} else {
const depth = y - HORIZON;
const srcY = Math.max(0, HORIZON - 1 - depth);
const sway = Math.sin(depth * 0.5 - t * 0.006) * 2.2 * (depth / (CALC_H - HORIZON));
const refl = upperRGB(Math.max(0, Math.min(CALC_W - 1, Math.round(x + sway))), srcY, t);
const alpha = Math.max(0.15, 1 - depth / (CALC_H - HORIZON) * 0.75);
rgb = [refl[0] * alpha, refl[1] * alpha, refl[2] * alpha];
}
outBuf[idx] = rgb[0];
outBuf[idx + 1] = rgb[1];
outBuf[idx + 2] = rgb[2];
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
let lastElapsed = 0;
function draw(t: number) {
lastElapsed = t;
renderFrame(t);
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
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);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
let pointerActive = false;
let lastSplashMs = -9999;
function toGrid(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
return {
x: (e.clientX - r.left) / r.width * CALC_W,
y: (e.clientY - r.top) / r.height * HORIZON
};
}
function onPointerDown(e: PointerEvent) {
pointerActive = true;
const g = toGrid(e);
splashAt(g.x, g.y);
lastSplashMs = performance.now();
if (reduce) draw(lastElapsed);
}
function onPointerMove(e: PointerEvent) {
const g = toGrid(e);
const now = performance.now();
if (now - lastSplashMs > 100) {
splashAt(g.x, g.y);
lastSplashMs = now;
if (reduce) draw(lastElapsed);
}
}
function onPointerUp() { pointerActive = false; }
function onPointerLeave() { pointerActive = false; }
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
splashAt(CALC_W / 2, HORIZON * 0.5);
if (reduce) draw(lastElapsed);
}
}
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.addEventListener("keydown", onKeyDown);
wrap.tabIndex = 0;
const VIRTUAL_INTERVAL_MS = 520;
let lastVirtualMs = -9999;
function virtualPointerOrbit(t: number) {
return {
x: CALC_W * (0.28 + 0.44 * ((Math.sin(t * 0.0012) + 1) / 2)),
y: HORIZON * (0.25 + 0.5 * ((Math.cos(t * 0.0017) + 1) / 2))
};
}
let lastTs: number | null = null;
let elapsed = 0;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (!pointerActive && elapsed - lastVirtualMs > VIRTUAL_INTERVAL_MS) {
const p = virtualPointerOrbit(elapsed);
splashAt(p.x, p.y);
lastVirtualMs = elapsed;
}
stepWave();
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
splashAt(CALC_W * 0.5, HORIZON * 0.4);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rv} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.rv__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rv__caption}>포인터 물결과 잔잔한 파동, 아래쪽 반사까지 한 장면 안에서 합쳐 보여준다</p>
</div>
</main>
);
}09 · 위 기법을 합친 히어로 물결 장면 — 측정 누적면적 61.751% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function upperRGB(x, y, t) {
var i = y * CALC_W + x;
var hl = x > 0 ? gridA[i - 1] : gridA[i];
var hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
var hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
var hd = y < HORIZON - 1 ? gridA[i + CALC_W] : gridA[i];
var amb = ambientHeight(x, y, t);
var ambX = ambientHeight(x + 1, y, t) - ambientHeight(x - 1, y, t);
var ambY = ambientHeight(x, y + 1, t) - ambientHeight(x, y - 1, t);
var dx = (hr - hl) * 0.85 + ambX * 1.1;
var dy = (hd - hu) * 0.85 + ambY * 1.1;
var rgb = sampleBase(x + dx, y + dy);
var shine = Math.max(-30, Math.min(55, (hu - hd) * 0.5 + amb * 4));
return [
Math.max(0, Math.min(255, rgb[0] + shine)),
Math.max(0, Math.min(255, rgb[1] + shine)),
Math.max(0, Math.min(255, rgb[2] + shine))
];
}
09 항목은 누적면적 61.8%·강도 평균 38.1·23프레임 중 23프레임에서 움직였다.
화면 캡처 24장이 남긴 아홉 물결의 실제 움직임 수치
24장의 캡처를 앞뒤로 견주어 픽셀이 바뀐 양을 뽑으면 아래 표가 나온다 — 03 호버 리빌이 누적면적 66.5%로 가장 넓게 바뀌고, 08 글자 속 리퀴드는 마스크 밖이 정적 배경으로 남아 17.2%로 가장 좁다.
| NN 이름 | 누적면적 % | 강도 평균 | x/23 |
|---|---|---|---|
| 01 포인터 스플래시 | 15.9 | 56.8 | 23/23 |
| 02 배경 잔물결 | 66.0 | 32.2 | 23/23 |
| 03 호버 리빌 | 66.5 | 44.4 | 23/23 |
| 04 굴절 렌즈 | 21.1 | 22.5 | 23/23 |
| 05 반사 웅덩이 | 25.2 | 23.8 | 23/23 |
| 06 카드 플립 웨이브 | 63.7 | 59.5 | 23/23 |
| 07 오디오 반응 물결 | 19.9 | 33.3 | 23/23 |
| 08 글자 속 리퀴드 | 17.2 | 29.6 | 23/23 |
| 09 히어로 물결 장면 | 61.8 | 38.1 | 23/23 |
이 물결 계산을 실제로 붙이는 화면
01 포인터 스플래시와 04 굴절 렌즈는 제품 이미지 호버나 포트폴리오 프리뷰처럼 마우스가 지나갈 때 반응해야 하는 자리에 맞고, 02 배경 잔물결과 09 히어로 장면은 히어로 배경·로딩 화면처럼 계산만으로 계속 움직여야 하는 자리에 쓴다. 03 호버 리빌은 비교 이미지 카드나 상품 색상 변경에, 05 반사 웅덩이는 브랜드 로고 인트로에, 06 카드 플립 웨이브는 카드 상세 전환이나 이벤트 배너 교체에 붙인다. 07 오디오 반응 물결은 음악 앨범 커버나 팟캐스트 플레이어처럼 소리와 엮이는 화면에, 08 글자 속 리퀴드는 브랜드 타이포 히어로에 맞는다.
감쇠 시간·파형 개수·오디오 게인 값이 물결을 가르는 자리
01의 RIPPLE_SPLASH_DECAY_MS(기본 400)와 09의 HERO_SPLASH_DECAY_MS(기본 420)는 이름은 다르지만 같은 damping = Math.pow(0.02, 16 / 감쇠상수) 식에 그대로 들어가 한 프레임(16ms)마다 파형이 얼마나 줄어드는지를 정하고, 값이 작을수록 파문이 두어 프레임 만에 사그라든다. 02·09의 RIPPLE_AMBIENT_WAVE_COUNT(기본 2)는 ambientHeight가 더하는 사인파 겹 수 자체라 값을 늘리면 서로 다른 각도·주기의 파가 겹쳐 표면이 훨씬 복잡하게 흔들린다. 07의 RIPPLE_AUDIO_GAIN(기본 0.4)은 가상 레벨 값에 곱해져 splashAt으로 넘어가는 진폭을 정해, 값 하나만 올려도 같은 레벨에서 물결이 화면 가장자리까지 번진다. 이 세 값을 상수 하나만 바꿔도 같은 css 물결 애니메이션이라도 인상이 완전히 달라진다는 걸 아래 변형 표에서 확인할 수 있다.
동작 줄임을 켜도 계산이 멈추지 않는 값 하나, 그리고 깨지는 지점
prefers-reduced-motion: reduce가 참이면 01·03·04·06·07·09의 playing이 꺼져 tick의 stepWave·renderFrame 호출이 멈추고 화면은 마지막으로 계산된 프레임에서 그대로 굳는다. 대신 01·04·06·09는 keydown에서 Enter·Space나 화살표로 값을 바꾸면 그 순간만 다시 계산해 그리고, 07은 range 슬라이더를 조작할 때마다 if (reduce) 분기가 새 게인으로 스플래시를 한 번 더 찍어 정지 화면에서도 값 변화를 확인할 수 있다. e7jhekfs으로 zip을 열면 아홉 개의 index.html·style.scss 원본이 그대로 들어 있어 02·05·08 세 배경형은 조작 지점이 없는 대신 reduce 진입 시 elapsed를 900·700·600 같은 고정 시각으로 박아 파형이 절반쯤 자란 대표 프레임 한 장만 남긴다는 것도 소스에서 바로 확인할 수 있다. 지금은 CALC_W·CALC_H가 160×100(16,000픽셀)이라 getImageData·putImageData 왕복이 가볍지만, 이 값을 실제 화면 해상도로 그대로 올리면 픽셀 수가 수십~수백 배로 뛰어 저사양 기기에서는 한 프레임 계산이 16ms를 넘어 끊길 수 있다.
값 세 가지로 보는 변형, 그리고 reduced-motion·키보드 접근성
계산식은 그대로 두고 상수 하나만 건드려도 물결의 인상이 달라진다.
| 이름 | 값 | 느낌 |
|---|---|---|
| 물결 감쇠 시간 값 조정 | 01 RIPPLE_SPLASH_DECAY_MS 400 → 1400 | 값을 낮추면 파동이 한 번 튀고 곧장 잦아들어 클릭 피드백처럼 가볍고, 값을 올리면 앞선 파동이 채 가시기 전에 다음 파동이 겹쳐 화면이 계속 출렁인다. |
| 배경 파형 개수 값 조정 | 02 RIPPLE_AMBIENT_WAVE_COUNT 2 → 6 | 값을 낮추면 사인파 두 겹만 남아 표면이 한 방향으로 밋밋하게 흐르고, 값을 올리면 여섯 겹이 교차해 잔물결 결이 훨씬 촘촘하고 불규칙해진다. |
| 오디오 반응 게인 값 조정 | 07 RIPPLE_AUDIO_GAIN 0.4 → 1.6 | 값을 낮추면 가상 레벨이 최고조에 달해도 물결 진폭이 거의 안 자라고, 값을 올리면 레벨이 살짝만 올라가도 물결이 화면 가장자리까지 크게 번진다. |
prefers-reduced-motion이 걸리면 위에서 본 대로 여섯 개는 재생이 꺼지고 나머지 셋은 대표 프레임 한 장으로 고정된다. 01·04·06·09는 wrap.tabIndex = 0으로 포커스를 받아 키보드만으로도 Enter·Space·화살표로 물결을 일으킬 수 있고, 03은 focus·blur 리스너가 호버 대신 반전을 열고 닫아 마우스 없이도 두 번째 이미지를 볼 수 있다. 07의 게인 슬라이더는 <input type="range">에 aria-label="반응 세기"가 달려 있어 방향키만으로 게인을 조절할 수 있지만, 02·05·08 세 배경형은 애초에 조작 지점이 없어 키보드 대안이 필요하지 않다.
css 물결 효과와 사진으로 찍은 물결은 뭐가 다른가?
CSS filter나 background-image만으로 만드는 사진 물결 효과는 정적인 왜곡 한 장을 고정하거나 기껏해야 배경 위치를 반복 이동시키는 수준이라, 픽셀이 실제로 밀리는 굴절까지는 표현하지 못한다. 여기 아홉 개는 시작할 때 getImageData로 사진 픽셀을 한 번 읽어 두고, 매 프레임 높이 필드·거리·마스크 계산으로 그 좌표를 다시 정해 ImageData 버퍼를 채운 뒤 putImageData로 다시 그려 넣어, 같은 css 물결 효과라 불러도 실제로는 값 하나가 파동 계산 전체를 다시 도는 방식이다. 정적인 장면 효과로 확장한 결과는 캔버스 특수효과 9종에서, 색·채도·질감을 픽셀 단위로 바꾸는 계산은 css 이미지 필터 효과 9종에서, 사용자가 직접 확대·이동으로 이미지를 다루는 쪽은 이미지 확대 뷰어 UI 9종에서 각각 확인할 수 있다.
물결 계산을 처음 만질 때 자주 묻는 것
이 데모에 쓰인 이미지는 실제 사진인가요?
아니다. 아홉 개 모두 seedRand로 만든 절차적 무늬를 drawPhotoBase가 그려 베이스로 쓰고, 외부 사진 파일은 하나도 부르지 않는다. 실제 사진에 적용하고 싶다면 drawPhotoBase 대신 ctx.drawImage로 자신의 이미지를 그린 뒤 그 픽셀을 getImageData로 읽으면 같은 계산이 그대로 작동한다.
css 물결 애니메이션을 이 캔버스 코드 없이 CSS만으로도 만들 수 있나요?
SVG feTurbulence나 radial-gradient 애니메이션으로 비슷한 흔들림 인상은 낼 수 있지만, 픽셀 좌표 자체를 밀어내는 굴절까지는 CSS 단독으로 재현되지 않는다. 여기 아홉 개는 높이 필드나 거리 계산으로 얻은 값을 실제 픽셀 오프셋으로 써서 getImageData·putImageData를 오가며 다시 그리기 때문에 CSS 필터보다 계산량은 크지만 표현할 수 있는 왜곡 폭이 훨씬 넓다.
07의 물결은 실제 마이크 소리에 반응하나요?
아니다. virtualAudioLevel이 비트·펄스·플리커 세 사인 성분을 합쳐 만든 가상 레벨 값으로 진폭을 정하고, 마이크나 AnalyserNode 같은 실제 오디오 입력은 쓰지 않는다. 진짜 소리에 반응하게 하려면 Web Audio API의 AnalyserNode.getByteFrequencyData 값을 virtualAudioLevel의 반환값 자리에 넣으면 되고, 게인 슬라이더(RIPPLE_AUDIO_GAIN)는 그 값을 그대로 재사용할 수 있다.









