이미지 전환 효과 9종 — 캔버스 픽셀 계산으로 완성되는 리빌
이미지 전환 효과 아홉 가지 가운데 하나는 픽셀의 대각선 좌표로 통과 시각을 계산해 화면을 비스듬히 쓸고, 하나는 격자 칸마다 노이즈 문턱값을 미리 계산해 불규칙하지만 재현되는 순서로 드러나며, 하나는 고정 시드로 가로줄 오프셋을 계산해 매번 같은 순서로 글리치가 정리됩니다.
아홉 개 모두 requestAnimationFrame 으로 매 프레임 진행률을 다시 계산하고, 클릭·Enter·창 크기 변경(resize) 세 가지 입력만 듣습니다. Math.random 은 한 번도 부르지 않고 선형합동생성기(LCG) 하나로 장면을 만들기 때문에, 새로고침해도 색 배치와 노이즈 결이 항상 같습니다. 라이브러리도 WebGL 도 CDN 도 없이 캔버스 2D 컨텍스트와 자바스크립트 계산만으로 화면 전체를 다음 장면으로 넘기는 사례들로, css 이미지 전환 애니메이션이 흔히 흉내 내는 결과를 픽셀 단위 계산으로 직접 완성했습니다.
데모
01 canvas-reveal-diagonal-slice
02 canvas-reveal-radial-clock
03 canvas-reveal-noise-dissolve
05 canvas-reveal-shard-fracture-fall
06 canvas-reveal-scanline-glitch-slice
07 canvas-reveal-ink-bleed-spread
08 canvas-reveal-halftone-dot-grow
09 canvas-reveal-liquid-wave-morph
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 대각선 좌표로 계산되는 화면 전환 와이프
- 02 각도로 계산되는 시계 방향 원형 리빌
- 03 값 노이즈 필드로 픽셀마다 등장 순서를 계산하는 디졸브
- 04 블록 크기가 시간으로 계산되어 줄어드는 모자이크 리졸브
- 05 삼각 조각마다 낙하를 계산하는 파편 전환
- 06 가로줄 오프셋을 고정 시드로 계산하는 글리치 전환
- 07 거리 필드로 번짐 반경을 계산하는 잉크 리빌
- 08 명암값으로 반지름을 계산하는 망점 리빌
- 09 사인 곡선 위상차로 경계를 계산하는 물결 리빌
아홉 가지 이미지 전환 효과 — 계산 한 겹이 어디서 도는가
아홉 개를 나열한 순서는 계산이 도는 자리를 기준으로 잡았습니다. 01·02·07 은 픽셀마다 좌표값을 미리 계산해 두고 그 값과 진행률을 비교하는 필드 방식이고, 03·09 는 노이즈나 사인 곡선처럼 함수 하나로 경계를 그리는 방식이며, 04·05·06·08 은 화면을 블록·조각·줄·점으로 나눠 각 조각의 순서를 미리 계산해 두는 방식입니다. 셋 다 결과는 "이미지 A가 이미지 B로 바뀐다"는 같은 문제를 풀지만, 어디에 계산 한 겹을 얹느냐가 다릅니다.
값 노이즈로 문턱값을 칸마다 미리 계산해 두는 03 항목은 전형적인 디졸브 이미지 연출이라서, 노이즈 주파수 상수 하나만 옮겨도 흩어지는 결 자체가 완전히 달라집니다. 아래 표는 각 항목을 3초 렌더 창에서 재생했을 때 실측한 값입니다. 누적면적은 캔버스 픽셀이 이전 색과 달라진 비율을 24프레임 캡처 동안 합산한 값이고, 움직인프레임은 그 23개 델타(프레임 간 차이) 가운데 실제로 픽셀이 바뀐 델타 수입니다.
| 항목 | 누적면적 | 강도평균 | 움직인프레임 |
|---|---|---|---|
| 01 대각선 리빌 | 48.760% | 126.40 | 23/23 |
| 02 시계 원형 리빌 | 48.249% | 132.60 | 23/23 |
| 03 노이즈 디졸브 | 48.761% | 169.80 | 20/23 |
| 04 모자이크 리졸브 | 48.051% | 147.40 | 22/23 |
| 05 파편 낙하 전환 | 48.444% | 83.50 | 23/23 |
| 06 스캔라인 글리치 | 47.115% | 158.80 | 23/23 |
| 07 잉크 번짐 리빌 | 48.568% | 115.00 | 23/23 |
| 08 망점 리빌 | 34.422% | 79.90 | 21/23 |
| 09 물결 리빌 | 47.283% | 163.50 | 22/23 |
08 망점 리빌만 누적면적이 낮은 것은 점 사이 여백이 원래 구조상 남기 때문이고, 나머지 여덟은 화면 대부분이 매 델타마다 바뀌는 장면형 전환입니다.
패턴별 코드
아래 아홉 개는 실제 데모의 PRECOMPUTE(장면이 바뀔 때 한 번 계산)와 RENDER(p)(진행률 p 마다 다시 그리는 함수)를 그대로 옮긴 조각입니다. 값을 바꾸는 상수는 코드 위 주석에 적어 두었습니다.
01대각선 좌표로 계산되는 화면 전환 와이프
화면 전체를 덮은 캔버스가 각 픽셀의 대각선 좌표(가로·세로 위치의 평균)를 미리 한 번 계산해 두고, 진행률이 그 값을 지나는 픽셀부터 아래 이미지 색으로 바꿔 씁니다. 경계 부드러움 폭(EDGE_SOFT) 만큼만 색을 섞어서 선이 톱니처럼 보이지 않게 합니다.
function PRECOMPUTE() {
var iw = cv.width, ih = cv.height;
dataA = bctxA.getImageData(0, 0, iw, ih).data;
dataB = bctxB.getImageData(0, 0, iw, ih).data;
outImg = ctx.createImageData(iw, ih);
tField = new Float32Array(iw * ih);
for (var y = 0; y < ih; y++) {
for (var x = 0; x < iw; x++) tField[y * iw + x] = (x / iw + y / ih) / 2;
}
}
function RENDER(p) {
var o = outImg.data;
for (var i = 0, n = tField.length; i < n; i++) {
var t = tField[i], k = i * 4, reveal;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx.putImageData(outImg, 0, 0);
}

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 대각선 경계가 화면을 가르며 다음 장면을 드러냅니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="대각선 좌표로 계산되는 화면 전환 와이프 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-cream, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-cream, .14);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #6ec8ff;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(255, 247, 230, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(255, 247, 230, 0.14);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #6ec8ff;
}
.rv__panel:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 101, SEED_B = 102;
var PLAY_MS = 850;
var EDGE_SOFT = 0.05; // 대각선 경계 부드러움 폭(0~1) — 변형: 0.05 -> 0.015(더 날카로운 절단면)
var dataA, dataB, outImg, tField;
function PRECOMPUTE() {
var iw = cv.width, ih = cv.height;
dataA = bctxA.getImageData(0, 0, iw, ih).data;
dataB = bctxB.getImageData(0, 0, iw, ih).data;
outImg = ctx.createImageData(iw, ih);
tField = new Float32Array(iw * ih);
for (var y = 0; y < ih; y++) {
for (var x = 0; x < iw; x++) tField[y * iw + x] = (x / iw + y / ih) / 2;
}
}
function RENDER(p) {
var o = outImg.data;
for (var i = 0, n = tField.length; i < n; i++) {
var t = tField[i], k = i * 4, reveal;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx.putImageData(outImg, 0, 0);
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DiagonalSlice.module.scss";
export default function DiagonalSlice() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 101;
const SEED_B = 102;
const PLAY_MS = 850;
const EDGE_SOFT = 0.05; // 대각선 경계 부드러움 폭(0~1) — 변형: 0.05 -> 0.015(더 날카로운 절단면)
let dataA: Uint8ClampedArray | null = null;
let dataB: Uint8ClampedArray | null = null;
let outImg: ImageData | null = null;
let tField: Float32Array | null = null;
function PRECOMPUTE() {
const iw = cv!.width;
const ih = cv!.height;
dataA = bctxA!.getImageData(0, 0, iw, ih).data;
dataB = bctxB!.getImageData(0, 0, iw, ih).data;
outImg = ctx!.createImageData(iw, ih);
tField = new Float32Array(iw * ih);
for (let y = 0; y < ih; y++) {
for (let x = 0; x < iw; x++) tField[y * iw + x] = (x / iw + y / ih) / 2;
}
}
function RENDER(p: number) {
const o = outImg!.data;
const a = dataA!;
const b = dataB!;
const tf = tField!;
for (let i = 0, n = tf.length; i < n; i++) {
const t = tf[i];
const k = i * 4;
let reveal: number;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = b[k] + (a[k] - b[k]) * reveal;
o[k + 1] = b[k + 1] + (a[k + 1] - b[k + 1]) * reveal;
o[k + 2] = b[k + 2] + (a[k + 2] - b[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx!.putImageData(outImg!, 0, 0);
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 대각선 경계가 화면을 가르며 다음 장면을 드러냅니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="대각선 좌표로 계산되는 화면 전환 와이프 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}01 · 대각선 좌표로 계산되는 화면 전환 와이프 — 측정 누적면적 48.76% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02각도로 계산되는 시계 방향 원형 리빌
캔버스 중심을 기준으로 각 픽셀의 각도를 atan2 로 미리 계산해 두고, 진행률을 각도(0~2π)로 바꿔 그 값을 지나는 픽셀부터 다음 장면을 드러냅니다. 경계 위에는 지금 각도를 가리키는 바늘 선을 하나 더 그려서 시계가 도는 느낌을 보탭니다.
function RENDER(p) {
var o = outImg.data;
for (var i = 0, n = angField.length; i < n; i++) {
var t = angField[i], k = i * 4, reveal;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx.putImageData(outImg, 0, 0);
ctx.save();
ctx.strokeStyle = ACCENT; ctx.lineWidth = 2; ctx.globalAlpha = .8;
var ang = p * Math.PI * 2 - Math.PI / 2;
var r = Math.min(W, H) * 0.42;
ctx.beginPath();
ctx.moveTo(cx0 / dpr, cy0 / dpr);
ctx.lineTo(cx0 / dpr + Math.cos(ang) * r, cy0 / dpr + Math.sin(ang) * r);
ctx.stroke();
ctx.restore();
}

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 시침처럼 각도가 돌며 다음 장면을 드러냅니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="각도로 계산되는 시계 방향 원형 리빌 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-ink, .12);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #0a4fe8;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.12);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #0a4fe8;
}
.rv__panel:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var ACCENT = tok('--accent', '#2e6bff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 201, SEED_B = 202;
var PLAY_MS = 900;
var EDGE_SOFT = 0.03; // 각도 경계 부드러움 폭 — 변형: 0.03 -> 0.008(칼같은 경계)
var dataA, dataB, outImg, angField, cx0, cy0;
function PRECOMPUTE() {
var iw = cv.width, ih = cv.height;
dataA = bctxA.getImageData(0, 0, iw, ih).data;
dataB = bctxB.getImageData(0, 0, iw, ih).data;
outImg = ctx.createImageData(iw, ih);
angField = new Float32Array(iw * ih);
cx0 = iw / 2; cy0 = ih / 2;
for (var y = 0; y < ih; y++) {
for (var x = 0; x < iw; x++) {
var a = Math.atan2(x - cx0, -(y - cy0)); // 12시 방향을 0 으로, 시계방향으로 증가
if (a < 0) a += Math.PI * 2;
angField[y * iw + x] = a / (Math.PI * 2);
}
}
}
function RENDER(p) {
var o = outImg.data;
for (var i = 0, n = angField.length; i < n; i++) {
var t = angField[i], k = i * 4, reveal;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx.putImageData(outImg, 0, 0);
ctx.save();
ctx.strokeStyle = ACCENT; ctx.lineWidth = 2; ctx.globalAlpha = .8;
var ang = p * Math.PI * 2 - Math.PI / 2;
var r = Math.min(W, H) * 0.42;
ctx.beginPath();
ctx.moveTo(cx0 / dpr, cy0 / dpr);
ctx.lineTo(cx0 / dpr + Math.cos(ang) * r, cy0 / dpr + Math.sin(ang) * r);
ctx.stroke();
ctx.restore();
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./RadialClock.module.scss";
export default function RadialClock() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
const ACCENT = tok("--accent", "#2e6bff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 201;
const SEED_B = 202;
const PLAY_MS = 900;
const EDGE_SOFT = 0.03; // 각도 경계 부드러움 폭 — 변형: 0.03 -> 0.008(칼같은 경계)
let dataA: Uint8ClampedArray | null = null;
let dataB: Uint8ClampedArray | null = null;
let outImg: ImageData | null = null;
let angField: Float32Array | null = null;
let cx0 = 0;
let cy0 = 0;
function PRECOMPUTE() {
const iw = cv!.width;
const ih = cv!.height;
dataA = bctxA!.getImageData(0, 0, iw, ih).data;
dataB = bctxB!.getImageData(0, 0, iw, ih).data;
outImg = ctx!.createImageData(iw, ih);
angField = new Float32Array(iw * ih);
cx0 = iw / 2;
cy0 = ih / 2;
for (let y = 0; y < ih; y++) {
for (let x = 0; x < iw; x++) {
let a = Math.atan2(x - cx0, -(y - cy0)); // 12시 방향을 0 으로, 시계방향으로 증가
if (a < 0) a += Math.PI * 2;
angField[y * iw + x] = a / (Math.PI * 2);
}
}
}
function RENDER(p: number) {
const o = outImg!.data;
const a = dataA!;
const b = dataB!;
const af = angField!;
for (let i = 0, n = af.length; i < n; i++) {
const t = af[i];
const k = i * 4;
let reveal: number;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = b[k] + (a[k] - b[k]) * reveal;
o[k + 1] = b[k + 1] + (a[k + 1] - b[k + 1]) * reveal;
o[k + 2] = b[k + 2] + (a[k + 2] - b[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx!.putImageData(outImg!, 0, 0);
ctx!.save();
ctx!.strokeStyle = ACCENT;
ctx!.lineWidth = 2;
ctx!.globalAlpha = 0.8;
const ang = p * Math.PI * 2 - Math.PI / 2;
const r = Math.min(W, H) * 0.42;
ctx!.beginPath();
ctx!.moveTo(cx0 / dpr, cy0 / dpr);
ctx!.lineTo(cx0 / dpr + Math.cos(ang) * r, cy0 / dpr + Math.sin(ang) * r);
ctx!.stroke();
ctx!.restore();
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 시침처럼 각도가 돌며 다음 장면을 드러냅니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="각도로 계산되는 시계 방향 원형 리빌 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}02 · 각도로 계산되는 시계 방향 원형 리빌 — 측정 누적면적 48.249% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03값 노이즈 필드로 픽셀마다 등장 순서를 계산하는 디졸브
화면을 격자 칸으로 나눈 뒤 칸마다 2D 값 노이즈(noise2)로 고유한 문턱값을 미리 계산해 둡니다. 진행률이 그 문턱을 넘는 칸부터 다음 장면 조각을 그려 넣기 때문에, 같은 시드라면 흩어지는 순서가 매번 똑같습니다.
function PRECOMPUTE() {
cols = Math.ceil(W / NOISE_CELL_PX);
rows = Math.ceil(H / NOISE_CELL_PX);
cellNoise = new Float32Array(cols * rows);
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) cellNoise[r * cols + c] = noise2(c * NOISE_FREQ, r * NOISE_FREQ);
}
}
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
var cw = Math.round(NOISE_CELL_PX * dpr), ch = Math.round(NOISE_CELL_PX * dpr);
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
if (cellNoise[r * cols + c] <= p) {
var sx = Math.round(c * NOISE_CELL_PX * dpr), sy = Math.round(r * NOISE_CELL_PX * dpr);
ctx.drawImage(bufA, sx, sy, cw, ch, c * NOISE_CELL_PX, r * NOISE_CELL_PX, NOISE_CELL_PX, NOISE_CELL_PX);
}
}
}
}

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 격자 칸이 노이즈 값 순서로 흩어지며 다음 장면을 드러냅니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="값 노이즈 필드로 픽셀마다 등장 순서를 계산하는 디졸브 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-ink, .12);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #17141a;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.12);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #17141a;
}
.rv__panel:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 301, SEED_B = 302, SEED_NOISE = 303;
var PLAY_MS = 950;
var NOISE_CELL_PX = 16; // 격자 칸 크기 — 변형: 16px -> 8px(더 잘게 흩어지는 느낌)
var NOISE_FREQ = 0.36;
var PERM = buildPerm(SEED_NOISE);
var cols, rows, cellNoise;
function buildPerm(seed) {
var rng = makeLcg(seed);
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i = 255; i > 0; i--) {
var j = Math.floor(rng() * (i + 1));
var t = p[i]; p[i] = p[j]; p[j] = t;
}
return p.concat(p);
}
function fadeN(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerpN(a, b, t) { return a + (b - a) * t; }
function gradN(hash, x, y) {
var h = hash & 3, u = h < 2 ? x : y, v = h < 2 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
function noise2(x, y) {
var xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
var xf = x - Math.floor(x), yf = y - Math.floor(y);
var u = fadeN(xf), v = fadeN(yf);
var aa = PERM[PERM[xi] + yi], ab = PERM[PERM[xi] + yi + 1];
var ba = PERM[PERM[xi + 1] + yi], bb = PERM[PERM[xi + 1] + yi + 1];
var x1 = lerpN(gradN(aa, xf, yf), gradN(ba, xf - 1, yf), u);
var x2 = lerpN(gradN(ab, xf, yf - 1), gradN(bb, xf - 1, yf - 1), u);
return Math.max(0, Math.min(1, (lerpN(x1, x2, v) + 1) / 2));
}
function PRECOMPUTE() {
cols = Math.ceil(W / NOISE_CELL_PX);
rows = Math.ceil(H / NOISE_CELL_PX);
cellNoise = new Float32Array(cols * rows);
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) cellNoise[r * cols + c] = noise2(c * NOISE_FREQ, r * NOISE_FREQ);
}
}
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
var cw = Math.round(NOISE_CELL_PX * dpr), ch = Math.round(NOISE_CELL_PX * dpr);
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
if (cellNoise[r * cols + c] <= p) {
var sx = Math.round(c * NOISE_CELL_PX * dpr), sy = Math.round(r * NOISE_CELL_PX * dpr);
ctx.drawImage(bufA, sx, sy, cw, ch, c * NOISE_CELL_PX, r * NOISE_CELL_PX, NOISE_CELL_PX, NOISE_CELL_PX);
}
}
}
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./NoiseDissolve.module.scss";
export default function NoiseDissolve() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면·노이즈를 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 301;
const SEED_B = 302;
const SEED_NOISE = 303;
const PLAY_MS = 950;
const NOISE_CELL_PX = 16; // 격자 칸 크기 — 변형: 16px -> 8px(더 잘게 흩어지는 느낌)
const NOISE_FREQ = 0.36;
function buildPerm(seed: number) {
const rng = makeLcg(seed);
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i = 255; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
const t = p[i];
p[i] = p[j];
p[j] = t;
}
return p.concat(p);
}
function fadeN(t: number) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function lerpN(a: number, b: number, t: number) {
return a + (b - a) * t;
}
function gradN(hash: number, x: number, y: number) {
const h = hash & 3;
const u = h < 2 ? x : y;
const v = h < 2 ? y : x;
return (h & 1 ? -u : u) + (h & 2 ? -v : v);
}
const PERM = buildPerm(SEED_NOISE);
function noise2(x: number, y: number) {
const xi = Math.floor(x) & 255;
const yi = Math.floor(y) & 255;
const xf = x - Math.floor(x);
const yf = y - Math.floor(y);
const u = fadeN(xf);
const v = fadeN(yf);
const aa = PERM[PERM[xi] + yi];
const ab = PERM[PERM[xi] + yi + 1];
const ba = PERM[PERM[xi + 1] + yi];
const bb = PERM[PERM[xi + 1] + yi + 1];
const x1 = lerpN(gradN(aa, xf, yf), gradN(ba, xf - 1, yf), u);
const x2 = lerpN(gradN(ab, xf, yf - 1), gradN(bb, xf - 1, yf - 1), u);
return Math.max(0, Math.min(1, (lerpN(x1, x2, v) + 1) / 2));
}
let cols = 0;
let rows = 0;
let cellNoise: Float32Array | null = null;
function PRECOMPUTE() {
cols = Math.ceil(W / NOISE_CELL_PX);
rows = Math.ceil(H / NOISE_CELL_PX);
cellNoise = new Float32Array(cols * rows);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) cellNoise[r * cols + c] = noise2(c * NOISE_FREQ, r * NOISE_FREQ);
}
}
function RENDER(p: number) {
ctx!.drawImage(bufB, 0, 0, W, H);
const cn = cellNoise!;
const cw = Math.round(NOISE_CELL_PX * dpr);
const ch = Math.round(NOISE_CELL_PX * dpr);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (cn[r * cols + c] <= p) {
const sx = Math.round(c * NOISE_CELL_PX * dpr);
const sy = Math.round(r * NOISE_CELL_PX * dpr);
ctx!.drawImage(bufA, sx, sy, cw, ch, c * NOISE_CELL_PX, r * NOISE_CELL_PX, NOISE_CELL_PX, NOISE_CELL_PX);
}
}
}
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 격자 칸이 노이즈 값 순서로 흩어지며 다음 장면을 드러냅니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="값 노이즈 필드로 픽셀마다 등장 순서를 계산하는 디졸브 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}03 · 값 노이즈 필드로 픽셀마다 등장 순서를 계산하는 디졸브 — 측정 누적면적 48.761% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04블록 크기가 시간으로 계산되어 줄어드는 모자이크 리졸브
각 칸은 자기 차례가 되기 전까지 앞 장면을 그대로 보여주다가, 행·열로 미리 계산해 둔 순서에 이르면 그 칸의 평균색을 채운 사각형이 겹쳐 나타나며 매 프레임 높이를 줄여 나갑니다. 사각형 높이가 낮아지는 만큼 그 아래 이미 그려 둔 뒤 장면이 드러나고, 높이가 0 이 되면 그 칸에는 뒤 장면만 남습니다. 칸마다 차례가 다르게 오기 때문에 아직 차례가 안 된 칸의 앞 장면과 차례를 마친 칸의 뒤 장면이 한 프레임 안에 섞여 있어 마치 모자이크가 녹아내리는 것처럼 보입니다.
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var order = meltOrder[r * cols + c];
var bx = c * BLOCK_PX, by = r * BLOCK_PX;
if (order > p) {
var sx = Math.round(c * BLOCK_PX * dpr), sy = Math.round(r * BLOCK_PX * dpr);
ctx.drawImage(bufA, sx, sy, Math.round(BLOCK_PX * dpr), Math.round(BLOCK_PX * dpr), bx, by, BLOCK_PX, BLOCK_PX);
} else if (order > p - MELT_WINDOW) {
var u = (p - order) / MELT_WINDOW;
var h = BLOCK_PX * (1 - u);
ctx.fillStyle = avgColor[r * cols + c];
ctx.fillRect(bx, by + BLOCK_PX * u * 0.6, BLOCK_PX, h);
}
}
}
}
<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 블록이 평균색으로 녹아내리며 아래 장면이 드러납니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="블록 크기가 시간으로 계산되어 줄어드는 모자이크 리졸브 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-ink, .12);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #0a4fe8;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.12);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #0a4fe8;
}
.rv__panel:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 401, SEED_B = 402;
var PLAY_MS = 900;
var BLOCK_PX = 20; // 블록 크기 — 변형: 20px -> 34px(더 굵은 모자이크)
var MELT_WINDOW = 0.16;
var cols, rows, avgColor, meltOrder;
function PRECOMPUTE() {
cols = Math.ceil(W / BLOCK_PX); rows = Math.ceil(H / BLOCK_PX);
var data = bctxA.getImageData(0, 0, cv.width, cv.height).data;
var bw = Math.round(BLOCK_PX * dpr), bh = Math.round(BLOCK_PX * dpr);
avgColor = []; meltOrder = new Float32Array(cols * rows);
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var sx = Math.round(c * BLOCK_PX * dpr), sy = Math.round(r * BLOCK_PX * dpr);
var ex = Math.min(cv.width, sx + bw), ey = Math.min(cv.height, sy + bh);
var sr = 0, sg = 0, sb = 0, cnt = 0;
for (var y = sy; y < ey; y += 2) {
for (var x = sx; x < ex; x += 2) {
var k = (y * cv.width + x) * 4;
sr += data[k]; sg += data[k + 1]; sb += data[k + 2]; cnt++;
}
}
cnt = cnt || 1;
avgColor[r * cols + c] = "rgb(" + Math.round(sr / cnt) + "," + Math.round(sg / cnt) + "," + Math.round(sb / cnt) + ")";
meltOrder[r * cols + c] = Math.min(1, r / rows * 0.7 + c / cols * 0.3 + ((r * 7 + c * 3) % 5) / 5 * 0.1);
}
}
}
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var order = meltOrder[r * cols + c];
var bx = c * BLOCK_PX, by = r * BLOCK_PX;
if (order > p) {
var sx = Math.round(c * BLOCK_PX * dpr), sy = Math.round(r * BLOCK_PX * dpr);
ctx.drawImage(bufA, sx, sy, Math.round(BLOCK_PX * dpr), Math.round(BLOCK_PX * dpr), bx, by, BLOCK_PX, BLOCK_PX);
} else if (order > p - MELT_WINDOW) {
var u = (p - order) / MELT_WINDOW;
var h = BLOCK_PX * (1 - u);
ctx.fillStyle = avgColor[r * cols + c];
ctx.fillRect(bx, by + BLOCK_PX * u * 0.6, BLOCK_PX, h);
}
}
}
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./PixelMosaicMelt.module.scss";
export default function PixelMosaicMelt() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 401;
const SEED_B = 402;
const PLAY_MS = 900;
const BLOCK_PX = 20; // 블록 크기 — 변형: 20px -> 34px(더 굵은 모자이크)
const MELT_WINDOW = 0.16;
let cols = 0;
let rows = 0;
let avgColor: string[] = [];
let meltOrder: Float32Array | null = null;
function PRECOMPUTE() {
cols = Math.ceil(W / BLOCK_PX);
rows = Math.ceil(H / BLOCK_PX);
const data = bctxA!.getImageData(0, 0, cv!.width, cv!.height).data;
const bw = Math.round(BLOCK_PX * dpr);
const bh = Math.round(BLOCK_PX * dpr);
avgColor = [];
meltOrder = new Float32Array(cols * rows);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const sx = Math.round(c * BLOCK_PX * dpr);
const sy = Math.round(r * BLOCK_PX * dpr);
const ex = Math.min(cv!.width, sx + bw);
const ey = Math.min(cv!.height, sy + bh);
let sr = 0;
let sg = 0;
let sb = 0;
let cnt = 0;
for (let y = sy; y < ey; y += 2) {
for (let x = sx; x < ex; x += 2) {
const k = (y * cv!.width + x) * 4;
sr += data[k];
sg += data[k + 1];
sb += data[k + 2];
cnt++;
}
}
cnt = cnt || 1;
avgColor[r * cols + c] = `rgb(${Math.round(sr / cnt)},${Math.round(sg / cnt)},${Math.round(sb / cnt)})`;
meltOrder[r * cols + c] = Math.min(1, (r / rows) * 0.7 + (c / cols) * 0.3 + (((r * 7 + c * 3) % 5) / 5) * 0.1);
}
}
}
function RENDER(p: number) {
ctx!.drawImage(bufB, 0, 0, W, H);
const mo = meltOrder!;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const order = mo[r * cols + c];
const bx = c * BLOCK_PX;
const by = r * BLOCK_PX;
if (order > p) {
const sx = Math.round(c * BLOCK_PX * dpr);
const sy = Math.round(r * BLOCK_PX * dpr);
ctx!.drawImage(bufA, sx, sy, Math.round(BLOCK_PX * dpr), Math.round(BLOCK_PX * dpr), bx, by, BLOCK_PX, BLOCK_PX);
} else if (order > p - MELT_WINDOW) {
const u = (p - order) / MELT_WINDOW;
const blockH = BLOCK_PX * (1 - u);
ctx!.fillStyle = avgColor[r * cols + c];
ctx!.fillRect(bx, by + BLOCK_PX * u * 0.6, BLOCK_PX, blockH);
}
}
}
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 블록이 평균색으로 녹아내리며 아래 장면이 드러납니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="블록 크기가 시간으로 계산되어 줄어드는 모자이크 리졸브 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}04 · 블록 크기가 시간으로 계산되어 줄어드는 모자이크 리졸브 — 측정 누적면적 48.051% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05삼각 조각마다 낙하를 계산하는 파편 전환
화면을 정사각 칸마다 두 개씩, 총 여러 개의 삼각 조각으로 나눈 뒤 각 조각 중심에서 원점까지의 거리를 미리 계산해 둡니다. 진행률이 그 거리를 지나는 조각부터 아래로 떨어지듯 이동하며 투명해지고, 그 자리에는 뒤 장면이 드러납니다.
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
for (var i = 0; i < shards.length; i++) {
var s = shards[i];
if (s.dist > p) {
ctx.save(); clipTri(s.tri);
ctx.drawImage(bufA, 0, 0, cv.width, cv.height, 0, 0, W, H);
ctx.restore();
} else {
var u = Math.min(1, (p - s.dist) / FALL_WINDOW);
if (u < 1) {
var off = u * u * FALL_DIST_PX;
ctx.save();
ctx.globalAlpha = 1 - u;
ctx.translate(0, off);
clipTri(s.tri);
ctx.drawImage(bufA, 0, 0, cv.width, cv.height, 0, -off, W, H);
ctx.restore();
}
}
}
}

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 삼각 조각이 거리 순으로 부서져 떨어지며 아래 장면이 드러납니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="삼각 조각마다 낙하를 계산하는 파편 전환 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-ink);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-cream, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-cream, .14);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #4cd4a6;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(255, 247, 230, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(255, 247, 230, 0.14);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #4cd4a6;
}
.rv__panel:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 501, SEED_B = 502;
var PLAY_MS = 1100;
var SHARD_PX = 30; // 조각 크기 — 변형: 30px -> 46px(더 굵은 파편)
var FALL_WINDOW = 0.22;
var FALL_DIST_PX = 60;
var shards;
function PRECOMPUTE() {
var cols = Math.ceil(W / SHARD_PX), rows = Math.ceil(H / SHARD_PX);
var maxDist = Math.hypot(W, H);
shards = [];
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var x0 = c * SHARD_PX, y0 = r * SHARD_PX, x1 = x0 + SHARD_PX, y1 = y0 + SHARD_PX;
var tris = [[[x0, y0], [x1, y0], [x0, y1]], [[x1, y0], [x1, y1], [x0, y1]]];
for (var i = 0; i < tris.length; i++) {
var tri = tris[i];
var cx = (tri[0][0] + tri[1][0] + tri[2][0]) / 3;
var cy = (tri[0][1] + tri[1][1] + tri[2][1]) / 3;
shards.push({ tri: tri, dist: Math.hypot(cx, cy) / maxDist });
}
}
}
}
function clipTri(tri) {
ctx.beginPath();
ctx.moveTo(tri[0][0], tri[0][1]);
ctx.lineTo(tri[1][0], tri[1][1]);
ctx.lineTo(tri[2][0], tri[2][1]);
ctx.closePath();
ctx.clip();
}
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
for (var i = 0; i < shards.length; i++) {
var s = shards[i];
if (s.dist > p) {
ctx.save(); clipTri(s.tri);
ctx.drawImage(bufA, 0, 0, cv.width, cv.height, 0, 0, W, H);
ctx.restore();
} else {
var u = Math.min(1, (p - s.dist) / FALL_WINDOW);
if (u < 1) {
var off = u * u * FALL_DIST_PX;
ctx.save();
ctx.globalAlpha = 1 - u;
ctx.translate(0, off);
clipTri(s.tri);
ctx.drawImage(bufA, 0, 0, cv.width, cv.height, 0, -off, W, H);
ctx.restore();
}
}
}
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./ShardFractureFall.module.scss";
type Shard = { tri: number[][]; dist: number };
export default function ShardFractureFall() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 501;
const SEED_B = 502;
const PLAY_MS = 1100;
const SHARD_PX = 30; // 조각 크기 — 변형: 30px -> 46px(더 굵은 파편)
const FALL_WINDOW = 0.22;
const FALL_DIST_PX = 60;
let shards: Shard[] = [];
function PRECOMPUTE() {
const cols = Math.ceil(W / SHARD_PX);
const rows = Math.ceil(H / SHARD_PX);
const maxDist = Math.hypot(W, H);
shards = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x0 = c * SHARD_PX;
const y0 = r * SHARD_PX;
const x1 = x0 + SHARD_PX;
const y1 = y0 + SHARD_PX;
const tris: number[][][] = [
[[x0, y0], [x1, y0], [x0, y1]],
[[x1, y0], [x1, y1], [x0, y1]],
];
for (let i = 0; i < tris.length; i++) {
const tri = tris[i];
const cx = (tri[0][0] + tri[1][0] + tri[2][0]) / 3;
const cy = (tri[0][1] + tri[1][1] + tri[2][1]) / 3;
shards.push({ tri, dist: Math.hypot(cx, cy) / maxDist });
}
}
}
}
function clipTri(tri: number[][]) {
ctx!.beginPath();
ctx!.moveTo(tri[0][0], tri[0][1]);
ctx!.lineTo(tri[1][0], tri[1][1]);
ctx!.lineTo(tri[2][0], tri[2][1]);
ctx!.closePath();
ctx!.clip();
}
function RENDER(p: number) {
ctx!.drawImage(bufB, 0, 0, W, H);
for (let i = 0; i < shards.length; i++) {
const s = shards[i];
if (s.dist > p) {
ctx!.save();
clipTri(s.tri);
ctx!.drawImage(bufA, 0, 0, cv!.width, cv!.height, 0, 0, W, H);
ctx!.restore();
} else {
const u = Math.min(1, (p - s.dist) / FALL_WINDOW);
if (u < 1) {
const off = u * u * FALL_DIST_PX;
ctx!.save();
ctx!.globalAlpha = 1 - u;
ctx!.translate(0, off);
clipTri(s.tri);
ctx!.drawImage(bufA, 0, 0, cv!.width, cv!.height, 0, -off, W, H);
ctx!.restore();
}
}
}
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 삼각 조각이 거리 순으로 부서져 떨어지며 아래 장면이 드러납니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="삼각 조각마다 낙하를 계산하는 파편 전환 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}05 · 삼각 조각마다 낙하를 계산하는 파편 전환 — 측정 누적면적 48.444% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06가로줄 오프셋을 고정 시드로 계산하는 글리치 전환
화면을 얇은 가로줄로 잘게 나눈 뒤, 고정된 시드값 하나로 각 줄이 좌우로 얼마나 밀릴지를 미리 다 계산해 둡니다. 진행률이 그 줄의 순번을 지나는 순간 앞 장면 대신 뒤 장면을 그리기 시작하고, 순번에 가까운 줄일수록 계산해 둔 오프셋만큼 잠깐 어긋났다가 제자리로 모입니다.
function PRECOMPUTE() {
rowsN = Math.ceil(H / ROW_PX);
var rng = makeLcg(SEED_GLITCH); // 고정 시드 — 프레임마다 다시 뽑지 않는다
rowOffset = new Float32Array(rowsN);
rowOrder = new Float32Array(rowsN);
for (var i = 0; i < rowsN; i++) {
rowOffset[i] = (rng() * 2 - 1) * GLITCH_MAX_PX;
rowOrder[i] = i / rowsN;
}
}
function RENDER(p) {
for (var i = 0; i < rowsN; i++) {
var order = rowOrder[i];
var src = order <= p ? bufA : bufB;
var near = Math.abs(order - p);
var dx = near < GLITCH_WINDOW ? rowOffset[i] * (1 - near / GLITCH_WINDOW) : 0;
var dy = i * ROW_PX;
var sh = Math.min(ROW_PX, H - dy);
if (sh <= 0) continue;
var sy = Math.round(dy * dpr), shPx = Math.round(sh * dpr);
ctx.drawImage(src, 0, sy, cv.width, shPx, dx, dy, W, sh);
}
}

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 가로줄이 순서대로 어긋나며 다음 장면으로 바뀝니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="가로줄 오프셋을 고정 시드로 계산하는 글리치 전환 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-ink, .12);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #17141a;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.12);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #17141a;
}
.rv__panel:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 601, SEED_B = 602, SEED_GLITCH = 603;
var PLAY_MS = 450;
var ROW_PX = 6; // 줄 두께 — 변형: 6px -> 3px(더 촘촘한 줄)
var GLITCH_WINDOW = 0.06;
var GLITCH_MAX_PX = 14;
var rowsN, rowOffset, rowOrder;
function PRECOMPUTE() {
rowsN = Math.ceil(H / ROW_PX);
var rng = makeLcg(SEED_GLITCH); // 고정 시드 — 프레임마다 다시 뽑지 않는다
rowOffset = new Float32Array(rowsN);
rowOrder = new Float32Array(rowsN);
for (var i = 0; i < rowsN; i++) {
rowOffset[i] = (rng() * 2 - 1) * GLITCH_MAX_PX;
rowOrder[i] = i / rowsN;
}
}
function RENDER(p) {
for (var i = 0; i < rowsN; i++) {
var order = rowOrder[i];
var src = order <= p ? bufA : bufB;
var near = Math.abs(order - p);
var dx = near < GLITCH_WINDOW ? rowOffset[i] * (1 - near / GLITCH_WINDOW) : 0;
var dy = i * ROW_PX;
var sh = Math.min(ROW_PX, H - dy);
if (sh <= 0) continue;
var sy = Math.round(dy * dpr), shPx = Math.round(sh * dpr);
ctx.drawImage(src, 0, sy, cv.width, shPx, dx, dy, W, sh);
}
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./ScanlineGlitchSlice.module.scss";
export default function ScanlineGlitchSlice() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면·글리치를 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 601;
const SEED_B = 602;
const SEED_GLITCH = 603;
const PLAY_MS = 450;
const ROW_PX = 6; // 줄 두께 — 변형: 6px -> 3px(더 촘촘한 줄)
const GLITCH_WINDOW = 0.06;
const GLITCH_MAX_PX = 14;
let rowsN = 0;
let rowOffset: Float32Array | null = null;
let rowOrder: Float32Array | null = null;
function PRECOMPUTE() {
rowsN = Math.ceil(H / ROW_PX);
const rng = makeLcg(SEED_GLITCH); // 고정 시드 — 프레임마다 다시 뽑지 않는다
rowOffset = new Float32Array(rowsN);
rowOrder = new Float32Array(rowsN);
for (let i = 0; i < rowsN; i++) {
rowOffset[i] = (rng() * 2 - 1) * GLITCH_MAX_PX;
rowOrder[i] = i / rowsN;
}
}
function RENDER(p: number) {
const ro = rowOffset!;
const rord = rowOrder!;
for (let i = 0; i < rowsN; i++) {
const order = rord[i];
const src = order <= p ? bufA : bufB;
const near = Math.abs(order - p);
const dx = near < GLITCH_WINDOW ? ro[i] * (1 - near / GLITCH_WINDOW) : 0;
const dy = i * ROW_PX;
const sh = Math.min(ROW_PX, H - dy);
if (sh <= 0) continue;
const sy = Math.round(dy * dpr);
const shPx = Math.round(sh * dpr);
ctx!.drawImage(src, 0, sy, cv!.width, shPx, dx, dy, W, sh);
}
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 가로줄이 순서대로 어긋나며 다음 장면으로 바뀝니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="가로줄 오프셋을 고정 시드로 계산하는 글리치 전환 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}06 · 가로줄 오프셋을 고정 시드로 계산하는 글리치 전환 — 측정 누적면적 47.115% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07거리 필드로 번짐 반경을 계산하는 잉크 리빌
임의의 시작점 몇 개에서 화면의 모든 픽셀까지 거리를 미리 계산해 거리장(distance field)을 만들어 둡니다. 진행률이 그 거리를 넘어서는 픽셀부터 색이 바뀌는데, 거리에 값 노이즈를 살짝 섞어 둬서 원이 아니라 잉크가 번지듯 불규칙한 가장자리로 넓어집니다.
function RENDER(p) {
var o = outImg.data;
for (var i = 0, n = distField.length; i < n; i++) {
var t = distField[i], k = i * 4, reveal;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx.putImageData(outImg, 0, 0);
}

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 잉크가 번지듯 얼룩 경계가 퍼지며 다음 장면을 드러냅니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="거리 필드로 번짐 반경을 계산하는 잉크 리빌 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-orange);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-ink, .12);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #0a4fe8;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.12);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #0a4fe8;
}
.rv__panel:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 701, SEED_B = 702, SEED_SEED = 703, SEED_NOISE = 704;
var PLAY_MS = 950;
var EDGE_SOFT = 0.05; // 얼룩 경계 부드러움 — 변형: 0.05 -> 0.02(더 또렷한 잉크 테두리)
var INK_DROPS = 5;
var NOISE_AMT = 0.12;
var PERM, dataA, dataB, outImg, distField;
function buildPerm(seed) {
var rng = makeLcg(seed);
var p = []; for (var i = 0; i < 256; i++) p.push(i);
for (var i = 255; i > 0; i--) { var j = Math.floor(rng() * (i + 1)); var t = p[i]; p[i] = p[j]; p[j] = t; }
return p.concat(p);
}
function fadeI(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerpI(a, b, t) { return a + (b - a) * t; }
function gradI(hash, x, y) { var h = hash & 3, u = h < 2 ? x : y, v = h < 2 ? y : x; return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); }
function noiseI(x, y) {
var xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
var xf = x - Math.floor(x), yf = y - Math.floor(y);
var u = fadeI(xf), v = fadeI(yf);
var aa = PERM[PERM[xi] + yi], ab = PERM[PERM[xi] + yi + 1];
var ba = PERM[PERM[xi + 1] + yi], bb = PERM[PERM[xi + 1] + yi + 1];
var x1 = lerpI(gradI(aa, xf, yf), gradI(ba, xf - 1, yf), u);
var x2 = lerpI(gradI(ab, xf, yf - 1), gradI(bb, xf - 1, yf - 1), u);
return (lerpI(x1, x2, v) + 1) / 2;
}
function PRECOMPUTE() {
PERM = buildPerm(SEED_NOISE);
var iw = cv.width, ih = cv.height;
dataA = bctxA.getImageData(0, 0, iw, ih).data;
dataB = bctxB.getImageData(0, 0, iw, ih).data;
outImg = ctx.createImageData(iw, ih);
var rng = makeLcg(SEED_SEED);
var drops = [];
for (var i = 0; i < INK_DROPS; i++) drops.push([rng() * iw, rng() * ih]);
var maxDist = Math.hypot(iw, ih);
distField = new Float32Array(iw * ih);
var lo = Infinity, hi = -Infinity;
for (var y = 0; y < ih; y++) {
for (var x = 0; x < iw; x++) {
var md = Infinity;
for (var d = 0; d < drops.length; d++) {
var dd = Math.hypot(x - drops[d][0], y - drops[d][1]);
if (dd < md) md = dd;
}
var n = noiseI(x * 0.02, y * 0.02) - 0.5;
var v = md / maxDist + n * NOISE_AMT;
distField[y * iw + x] = v;
if (v < lo) lo = v; if (v > hi) hi = v;
}
}
// 실제 값 범위로 재정규화 — 점 배치에 따라 거리장이 0~1 을 다 못 채우면
// progress 스윕의 절반 이상이 아무 변화 없이 지나간다(고정 시드라 매번 같은 쏠림).
var span = Math.max(1e-6, hi - lo);
for (var i2 = 0; i2 < distField.length; i2++) distField[i2] = Math.max(0, Math.min(1, (distField[i2] - lo) / span));
}
function RENDER(p) {
var o = outImg.data;
for (var i = 0, n = distField.length; i < n; i++) {
var t = distField[i], k = i * 4, reveal;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = dataB[k] + (dataA[k] - dataB[k]) * reveal;
o[k + 1] = dataB[k + 1] + (dataA[k + 1] - dataB[k + 1]) * reveal;
o[k + 2] = dataB[k + 2] + (dataA[k + 2] - dataB[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx.putImageData(outImg, 0, 0);
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./InkBleedSpread.module.scss";
export default function InkBleedSpread() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면·잉크점·노이즈를 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 701;
const SEED_B = 702;
const SEED_SEED = 703;
const SEED_NOISE = 704;
const PLAY_MS = 950;
const EDGE_SOFT = 0.05; // 얼룩 경계 부드러움 — 변형: 0.05 -> 0.02(더 또렷한 잉크 테두리)
const INK_DROPS = 5;
const NOISE_AMT = 0.12;
let PERM: number[] = [];
let dataA: Uint8ClampedArray | null = null;
let dataB: Uint8ClampedArray | null = null;
let outImg: ImageData | null = null;
let distField: Float32Array | null = null;
function buildPerm(seed: number) {
const rng = makeLcg(seed);
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i = 255; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
const t = p[i];
p[i] = p[j];
p[j] = t;
}
return p.concat(p);
}
function fadeI(t: number) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function lerpI(a: number, b: number, t: number) {
return a + (b - a) * t;
}
function gradI(hash: number, x: number, y: number) {
const h = hash & 3;
const u = h < 2 ? x : y;
const v = h < 2 ? y : x;
return (h & 1 ? -u : u) + (h & 2 ? -v : v);
}
function noiseI(x: number, y: number) {
const xi = Math.floor(x) & 255;
const yi = Math.floor(y) & 255;
const xf = x - Math.floor(x);
const yf = y - Math.floor(y);
const u = fadeI(xf);
const v = fadeI(yf);
const aa = PERM[PERM[xi] + yi];
const ab = PERM[PERM[xi] + yi + 1];
const ba = PERM[PERM[xi + 1] + yi];
const bb = PERM[PERM[xi + 1] + yi + 1];
const x1 = lerpI(gradI(aa, xf, yf), gradI(ba, xf - 1, yf), u);
const x2 = lerpI(gradI(ab, xf, yf - 1), gradI(bb, xf - 1, yf - 1), u);
return (lerpI(x1, x2, v) + 1) / 2;
}
function PRECOMPUTE() {
PERM = buildPerm(SEED_NOISE);
const iw = cv!.width;
const ih = cv!.height;
dataA = bctxA!.getImageData(0, 0, iw, ih).data;
dataB = bctxB!.getImageData(0, 0, iw, ih).data;
outImg = ctx!.createImageData(iw, ih);
const rng = makeLcg(SEED_SEED);
const drops: number[][] = [];
for (let i = 0; i < INK_DROPS; i++) drops.push([rng() * iw, rng() * ih]);
const maxDist = Math.hypot(iw, ih);
distField = new Float32Array(iw * ih);
let lo = Infinity;
let hi = -Infinity;
for (let y = 0; y < ih; y++) {
for (let x = 0; x < iw; x++) {
let md = Infinity;
for (let d = 0; d < drops.length; d++) {
const dd = Math.hypot(x - drops[d][0], y - drops[d][1]);
if (dd < md) md = dd;
}
const n = noiseI(x * 0.02, y * 0.02) - 0.5;
const v = md / maxDist + n * NOISE_AMT;
distField[y * iw + x] = v;
if (v < lo) lo = v;
if (v > hi) hi = v;
}
}
// 실제 값 범위로 재정규화 — 점 배치에 따라 거리장이 0~1 을 다 못 채우면
// progress 스윕의 절반 이상이 아무 변화 없이 지나간다(고정 시드라 매번 같은 쏠림).
const span = Math.max(1e-6, hi - lo);
for (let i2 = 0; i2 < distField.length; i2++) distField[i2] = Math.max(0, Math.min(1, (distField[i2] - lo) / span));
}
function RENDER(p: number) {
const o = outImg!.data;
const a = dataA!;
const b = dataB!;
const df = distField!;
for (let i = 0, n = df.length; i < n; i++) {
const t = df[i];
const k = i * 4;
let reveal: number;
if (t <= p) reveal = 1;
else if (t <= p + EDGE_SOFT) reveal = 1 - (t - p) / EDGE_SOFT;
else reveal = 0;
o[k] = b[k] + (a[k] - b[k]) * reveal;
o[k + 1] = b[k + 1] + (a[k + 1] - b[k + 1]) * reveal;
o[k + 2] = b[k + 2] + (a[k + 2] - b[k + 2]) * reveal;
o[k + 3] = 255;
}
ctx!.putImageData(outImg!, 0, 0);
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 잉크가 번지듯 얼룩 경계가 퍼지며 다음 장면을 드러냅니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="거리 필드로 번짐 반경을 계산하는 잉크 리빌 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}07 · 거리 필드로 번짐 반경을 계산하는 잉크 리빌 — 측정 누적면적 48.568% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08명암값으로 반지름을 계산하는 망점 리빌
이미지를 작은 격자로 나눈 뒤 칸마다 평균 밝기(0.299r+0.587g+0.114b)를 구해 점의 최대 반지름으로 미리 바꿔 둡니다. 진행률이 오를수록 모든 점이 함께 커지면서 인쇄 망점처럼 이미지를 채우는데, 밝은 칸일수록 점이 커서 먼저 눈에 띕니다.
function PRECOMPUTE() {
var data = bctxA.getImageData(0, 0, cv.width, cv.height).data;
var cols = Math.ceil(W / DOT_PX), rows = Math.ceil(H / DOT_PX);
dotsX = []; dotsY = []; dotR = []; dotColor = [];
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var cx = c * DOT_PX + DOT_PX / 2, cy = r * DOT_PX + DOT_PX / 2;
var sx = Math.min(cv.width - 1, Math.round(cx * dpr)), sy = Math.min(cv.height - 1, Math.round(cy * dpr));
var k = (sy * cv.width + sx) * 4;
var rr = data[k], gg = data[k + 1], bb = data[k + 2];
var bri = (0.299 * rr + 0.587 * gg + 0.114 * bb) / 255; // 밝기 -> 반지름
dotsX.push(cx); dotsY.push(cy);
dotR.push(bri * DOT_PX * DOT_MAX_SCALE);
dotColor.push("rgb(" + rr + "," + gg + "," + bb + ")");
}
}
}

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 점이 밝기만큼 커지며 다음 장면을 채웁니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="명암값으로 반지름을 계산하는 망점 리빌 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-paper);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-ink, .12);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #17141a;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.12);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #17141a;
}
.rv__panel:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 801, SEED_B = 802;
var PLAY_MS = 900;
var DOT_PX = 14; // 점 간격 — 변형: 14px -> 22px(더 굵은 하프톤)
var DOT_MAX_SCALE = 0.62;
var dotsX, dotsY, dotR, dotColor;
function PRECOMPUTE() {
var data = bctxA.getImageData(0, 0, cv.width, cv.height).data;
var cols = Math.ceil(W / DOT_PX), rows = Math.ceil(H / DOT_PX);
dotsX = []; dotsY = []; dotR = []; dotColor = [];
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var cx = c * DOT_PX + DOT_PX / 2, cy = r * DOT_PX + DOT_PX / 2;
var sx = Math.min(cv.width - 1, Math.round(cx * dpr)), sy = Math.min(cv.height - 1, Math.round(cy * dpr));
var k = (sy * cv.width + sx) * 4;
var rr = data[k], gg = data[k + 1], bb = data[k + 2];
var bri = (0.299 * rr + 0.587 * gg + 0.114 * bb) / 255; // 밝기 -> 반지름
dotsX.push(cx); dotsY.push(cy);
dotR.push(bri * DOT_PX * DOT_MAX_SCALE);
dotColor.push("rgb(" + rr + "," + gg + "," + bb + ")");
}
}
}
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
for (var i = 0; i < dotsX.length; i++) {
var r = dotR[i] * Math.min(1, p * 1.15);
if (r < 0.6) continue;
ctx.save();
ctx.beginPath(); ctx.arc(dotsX[i], dotsY[i], r, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = dotColor[i];
ctx.fillRect(dotsX[i] - DOT_PX, dotsY[i] - DOT_PX, DOT_PX * 2, DOT_PX * 2);
ctx.restore();
}
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./HalftoneDotGrow.module.scss";
export default function HalftoneDotGrow() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 801;
const SEED_B = 802;
const PLAY_MS = 900;
const DOT_PX = 14; // 점 간격 — 변형: 14px -> 22px(더 굵은 하프톤)
const DOT_MAX_SCALE = 0.62;
let dotsX: number[] = [];
let dotsY: number[] = [];
let dotR: number[] = [];
let dotColor: string[] = [];
function PRECOMPUTE() {
const data = bctxA!.getImageData(0, 0, cv!.width, cv!.height).data;
const cols = Math.ceil(W / DOT_PX);
const rows = Math.ceil(H / DOT_PX);
dotsX = [];
dotsY = [];
dotR = [];
dotColor = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cx = c * DOT_PX + DOT_PX / 2;
const cy = r * DOT_PX + DOT_PX / 2;
const sx = Math.min(cv!.width - 1, Math.round(cx * dpr));
const sy = Math.min(cv!.height - 1, Math.round(cy * dpr));
const k = (sy * cv!.width + sx) * 4;
const rr = data[k];
const gg = data[k + 1];
const bb = data[k + 2];
const bri = (0.299 * rr + 0.587 * gg + 0.114 * bb) / 255; // 밝기 -> 반지름
dotsX.push(cx);
dotsY.push(cy);
dotR.push(bri * DOT_PX * DOT_MAX_SCALE);
dotColor.push(`rgb(${rr},${gg},${bb})`);
}
}
}
function RENDER(p: number) {
ctx!.drawImage(bufB, 0, 0, W, H);
for (let i = 0; i < dotsX.length; i++) {
const r = dotR[i] * Math.min(1, p * 1.15);
if (r < 0.6) continue;
ctx!.save();
ctx!.beginPath();
ctx!.arc(dotsX[i], dotsY[i], r, 0, Math.PI * 2);
ctx!.clip();
ctx!.fillStyle = dotColor[i];
ctx!.fillRect(dotsX[i] - DOT_PX, dotsY[i] - DOT_PX, DOT_PX * 2, DOT_PX * 2);
ctx!.restore();
}
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 점이 밝기만큼 커지며 다음 장면을 채웁니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="명암값으로 반지름을 계산하는 망점 리빌 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}08 · 명암값으로 반지름을 계산하는 망점 리빌 — 측정 누적면적 34.422% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09사인 곡선 위상차로 경계를 계산하는 물결 리빌
화면을 가로지르는 경계선의 높이를 열(세로줄)마다 사인 곡선과 열 번호로 계산한 위상차로 정합니다. 열마다 조금씩 다른 위상으로 흔들리기 때문에 직선이 아니라 물결치는 수면 같은 곡선이 아래에서 위로 올라오며 다음 장면을 드러냅니다.
var SEED_A = 901, SEED_B = 902;
var PLAY_MS = 1000;
var WAVE_AMPLITUDE_PX = 10; // 물결 진폭 — 변형: 10px -> 26px(더 거친 물결)
var WAVE_FREQ = 0.045;
var WAVE_PHASE_SPEED = 5.5;
var COL_STEP = 3;
function PRECOMPUTE() {}
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
var baseY = H * (1 - p);
var amp = WAVE_AMPLITUDE_PX * Math.sin(p * Math.PI);
for (var x = 0; x < W; x += COL_STEP) {
var y = baseY + Math.sin(x * WAVE_FREQ + p * WAVE_PHASE_SPEED) * amp;
if (y >= H) continue;
y = Math.max(0, y);
ctx.save();
ctx.beginPath(); ctx.rect(x, y, COL_STEP, H - y + 1); ctx.clip();
ctx.drawImage(bufA, 0, 0, W, H);
ctx.restore();
}
}

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>클릭하거나 Enter 를 누르면 물결 경계가 차오르며 다음 장면을 드러냅니다</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="사인 곡선 위상차로 경계를 계산하는 물결 리빌 다시 재생">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(리빌 판정)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-pink;
@include stage($stage-ink);
.rv {
--img-a1: #{$stage-orange};
--img-a2: #{$subject-cream};
--img-b1: #{$subject-blue};
--img-b2: #{$subject-sky};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba($subject-cream, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-cream, .14);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}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 {
--img-a1: #ff4d1f;
--img-a2: #fff7e6;
--img-b1: #2f6df6;
--img-b2: #6ec8ff;
--accent: #ff9ebb;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: rgba(255, 247, 230, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(255, 247, 230, 0.14);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #ff9ebb;
}
.rv__panel:focus-visible {
outline: 2px solid #ff9ebb;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var COLOR_A1 = tok('--img-a1', '#ff5a2e');
var COLOR_A2 = tok('--img-a2', '#fff3e6');
var COLOR_B1 = tok('--img-b1', '#2e6bff');
var COLOR_B2 = tok('--img-b2', '#bfe4ff');
var W = 0, H = 0, dpr = 1;
var bufA = document.createElement('canvas');
var bufB = document.createElement('canvas');
var bctxA = bufA.getContext('2d');
var bctxB = bufB.getContext('2d');
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx, w, h, seed, warm) {
var rng = makeLcg(seed);
var g = bctx.createLinearGradient(0, 0, w, h);
if (warm) { g.addColorStop(0, COLOR_A1); g.addColorStop(1, COLOR_A2); }
else { g.addColorStop(0, COLOR_B1); g.addColorStop(1, COLOR_B2); }
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (var i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
var bandW = Math.max(14, w * 0.05);
for (var x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0); bctx.lineTo(x + bandW, 0); bctx.lineTo(x + bandW - h, h); bctx.lineTo(x - h, h);
bctx.closePath(); bctx.fill();
}
for (var j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath(); bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2); bctx.fill();
}
}
bctx.globalAlpha = 1;
}
var SEED_A = 901, SEED_B = 902;
var PLAY_MS = 1000;
var WAVE_AMPLITUDE_PX = 10; // 물결 진폭 — 변형: 10px -> 26px(더 거친 물결)
var WAVE_FREQ = 0.045;
var WAVE_PHASE_SPEED = 5.5;
var COL_STEP = 3;
function PRECOMPUTE() {}
function RENDER(p) {
ctx.drawImage(bufB, 0, 0, W, H);
var baseY = H * (1 - p);
var amp = WAVE_AMPLITUDE_PX * Math.sin(p * Math.PI);
for (var x = 0; x < W; x += COL_STEP) {
var y = baseY + Math.sin(x * WAVE_FREQ + p * WAVE_PHASE_SPEED) * amp;
if (y >= H) continue;
y = Math.max(0, y);
ctx.save();
ctx.beginPath(); ctx.rect(x, y, COL_STEP, H - y + 1); ctx.clip();
ctx.drawImage(bufA, 0, 0, W, H);
ctx.restore();
}
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv.width;
bufA.height = bufB.height = cv.height;
bctxA.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA, W, H, SEED_A, true);
paintScene(bctxB, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) { // .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 둘고 즉시 전환만 허용한다.
root.classList.add('is-still');
progress = 0.5; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./LiquidWaveMorph.module.scss";
export default function LiquidWaveMorph() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const COLOR_A1 = tok("--img-a1", "#ff5a2e");
const COLOR_A2 = tok("--img-a2", "#fff3e6");
const COLOR_B1 = tok("--img-b1", "#2e6bff");
const COLOR_B2 = tok("--img-b2", "#bfe4ff");
let W = 0;
let H = 0;
let dpr = 1;
const bufA = document.createElement("canvas");
const bufB = document.createElement("canvas");
const bctxA = bufA.getContext("2d");
const bctxB = bufB.getContext("2d");
if (!bctxA || !bctxB) return;
// 장면 A(따뜻한 톤)·B(차가운 톤) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bctx: CanvasRenderingContext2D, w: number, h: number, seed: number, warm: boolean) {
const rng = makeLcg(seed);
const g = bctx.createLinearGradient(0, 0, w, h);
if (warm) {
g.addColorStop(0, COLOR_A1);
g.addColorStop(1, COLOR_A2);
} else {
g.addColorStop(0, COLOR_B1);
g.addColorStop(1, COLOR_B2);
}
bctx.fillStyle = g;
bctx.fillRect(0, 0, w, h);
bctx.globalAlpha = 0.55;
if (warm) {
for (let i = 0; i < 8; i++) {
bctx.fillStyle = i % 2 ? COLOR_A2 : COLOR_A1;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 12 + rng() * 30, 0, Math.PI * 2);
bctx.fill();
}
} else {
const bandW = Math.max(14, w * 0.05);
for (let x = -h; x < w + h; x += bandW * 2) {
bctx.fillStyle = COLOR_B2;
bctx.beginPath();
bctx.moveTo(x, 0);
bctx.lineTo(x + bandW, 0);
bctx.lineTo(x + bandW - h, h);
bctx.lineTo(x - h, h);
bctx.closePath();
bctx.fill();
}
for (let j = 0; j < 6; j++) {
bctx.fillStyle = COLOR_A2;
bctx.beginPath();
bctx.arc(rng() * w, rng() * h, 5 + rng() * 9, 0, Math.PI * 2);
bctx.fill();
}
}
bctx.globalAlpha = 1;
}
const SEED_A = 901;
const SEED_B = 902;
const PLAY_MS = 1000;
const WAVE_AMPLITUDE_PX = 10; // 파동 진폭 — 변형: 10px -> 26px(더 거친 물결)
const WAVE_FREQ = 0.045;
const WAVE_PHASE_SPEED = 5.5;
const COL_STEP = 3;
function PRECOMPUTE() {}
function RENDER(p: number) {
ctx!.drawImage(bufB, 0, 0, W, H);
for (let x = 0; x < W; x += COL_STEP) {
const baseY = H * (1 - p);
const amp = WAVE_AMPLITUDE_PX * Math.sin(p * Math.PI);
let y = baseY + Math.sin(x * WAVE_FREQ + p * WAVE_PHASE_SPEED) * amp;
if (y >= H) continue;
if (y < 0) y = 0;
ctx!.save();
ctx!.beginPath();
ctx!.rect(x, y, COL_STEP, H - y + 1);
ctx!.clip();
ctx!.drawImage(bufA, 0, 0, W, H);
ctx!.restore();
}
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
bufA.width = bufB.width = cv!.width;
bufA.height = bufB.height = cv!.height;
bctxA!.setTransform(dpr, 0, 0, dpr, 0, 0);
bctxB!.setTransform(dpr, 0, 0, dpr, 0, 0);
paintScene(bctxA!, W, H, SEED_A, true);
paintScene(bctxB!, W, H, SEED_B, false);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
// 실사용 경로 — 클릭·Enter·Space 로 두 장면을 다시 전환한다.
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
// .is-demo && !reduce 경로에서만 실행된다(아래 reduce 분기)
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
// reduce — 자동 루프 없음, 계산은 그대로 두고 즉시 전환만 허용한다.
root.classList.add("is-still");
progress = 0.5;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 물결 경계가 차오르며 다음 장면을 드러냅니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="사인 곡선 위상차로 경계를 계산하는 물결 리빌 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}09 · 사인 곡선 위상차로 경계를 계산하는 물결 리빌 — 측정 누적면적 47.283% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 화면을 바꾸는 곳 — 대각 좌표·노이즈 문턱·고정 시드
계산이 도는 위치는 다르지만 상수 하나가 바뀌면 화면 전체의 인상이 바뀐다는 점은 아홉 개가 같습니다. 01·07 의 EDGE_SOFT 는 경계를 얼마나 흐릴지 정하고, 03 의 NOISE_CELL_PX 는 한 번에 나타나는 덩어리 크기를, 09 의 WAVE_AMPLITUDE_PX 는 물결이 얼마나 거칠어질지 정합니다. 세 상수 모두 값 하나만 옮기면 되니, zip 을 열어 그 줄만 고쳐 보면 계산이 화면에 어떻게 반영되는지 바로 확인할 수 있습니다.
이웃 가지
- html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과 편은 굴절·중력렌즈 같은 화면 왜곡 계산을 다루지만 장면을 다른 장면으로 넘기지는 않습니다. 이 편은 같은 픽셀 단위 계산을 이미지 A에서 이미지 B로 넘어가는 리빌 자체에 씁니다.
- CSS 화면 전환 효과 9종 — 화면이 바뀌는 순간 모음 편은 CSS 속성만으로 화면을 바꾸고 캔버스 픽셀 계산이 없습니다. 이 편은 같은 목적을 캔버스로 픽셀을 직접 읽고 써서 계산으로 완성한 화면 전환 이펙트 쪽입니다.
- 펄린 노이즈 지형 9종 — 라이브러리 없이 계산만으로 편의 노이즈 계산과 이 편 03 항목의 노이즈 문턱값 계산은 같은 값 노이즈 뿌리를 씁니다. 그쪽은 지형의 높낮이를 그리고 이 편은 리빌되는 순서를 매깁니다.
어디서 깨지는가 — 접근성·reduced-motion·저사양
아홉 개가 공통으로 겪는 함정은 캔버스 크기입니다. fit() 이 패널의 실제 픽셀 크기(devicePixelRatio 포함)로 캔버스를 다시 잡고 두 장면 버퍼를 새로 그리는데, 이 재계산을 건너뛰고 CSS 로만 캔버스 요소 크기를 늘리면 픽셀 필드와 실제 화면 크기가 어긋나 경계선이 비뚤어집니다. resize 리스너 하나로 이 문제를 막고 있으니, 컨테이너 크기를 자바스크립트로 강제로 바꾸는 프레임워크에 옮겨 쓸 때는 그 시점에 fit() 을 다시 불러야 합니다. 이 편에서 아홉 개 소스를 통째로 담은 파일을 열 때 압축 비밀번호는 zd738fjg 이고, 아래 카드에서 받을 수 있습니다.
동작 줄이기(prefers-reduced-motion) 설정에서는 아홉 개 모두 같은 방식으로 반응합니다. 자동으로 도는 시연 루프(driveTick)를 아예 시작하지 않고 is-still 클래스만 추가한 뒤, 진행률을 0.5 로 고정해 RENDER(0.5) 를 한 번만 불러 중간 장면을 그립니다. 이 상태에서도 클릭이나 Enter 를 누르면 진행률이 0과 1 사이를 즉시 오가며 다음 장면으로 바뀌므로, 움직임은 줄었어도 전환 자체를 확인하는 기능은 남습니다.
키보드·스크린리더
각 데모 패널에는 role="button" 과 tabindex="0", 그리고 지금 재생 중인 효과 이름이 들어간 aria-label 이 붙어 있어 탭 키만으로 초점을 옮기고 Enter 또는 Space 로 다시 재생할 수 있습니다. 캔버스 자체는 aria-hidden="true" 로 감춰서 스크린리더가 픽셀 그림을 읽으려 하지 않고, 안내 문구는 패널 위 rv__hint 요소가 대신 전달합니다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 대각선 리빌 경계 부드러움 폭 값 조정 | 01 EDGE_SOFT 0.05 → 0.015 | 경계선이 더 날카롭게 잘리는 쪽에 가까워집니다 |
| 노이즈 디졸브 격자 칸 크기 값 조정 | 03 NOISE_CELL_PX 16px → 8px | 한 번에 나타나는 덩어리가 작아져 더 잘게 흩어집니다 |
| 물결 리빌 사인 위상 진폭 값 조정 | 09 WAVE_AMPLITUDE_PX 10px → 26px | 경계선의 물결 굴곡이 더 뚜렷하고 거칠어집니다 |
세 변형 모두 반응형·접근성 코드는 건드리지 않습니다. fit()·resize 리스너·is-still 분기는 상수 값과 무관하게 그대로 동작하므로, 값만 바꾼 사본을 만들어도 저사양 기기나 동작 줄이기 설정에서 깨지지 않습니다.
화면 전환 이펙트 · 이미지 전환 효과 예제는 어디서 보나?
브라우저가 픽셀을 직접 읽고 쓰는 방식은 MDN 의 getImageData 와 putImageData 문서에 정리되어 있고, 프레임을 언제 다시 그릴지는 requestAnimationFrame 문서를 보면 됩니다. 위 아홉 개는 이 API 세 개와 삼각형 잘라내기(clip)만으로 만든 결과이니, 직접 검색해 표준 동작을 확인하면서 압축을 풀어 코드를 그대로 옮겨 써도 됩니다.
FAQ
이 아홉 개 전환 효과는 실제로 반복 실행해도 항상 같은 모양으로 나오나요?
그렇습니다. 아홉 개 모두 Math.random 을 한 번도 부르지 않고 선형합동생성기(LCG) 하나로 장면과 노이즈를 만들기 때문에, 같은 시드값이면 새로고침을 몇 번 해도 색 배치와 흩어지는 순서가 그대로 반복됩니다.
이 화면 전환 이펙트 아홉 개를 다른 프로젝트에도 그대로 옮겨 써도 되나요?
네, 라이브러리나 CDN 없이 순수 캔버스 2D 와 자바스크립트만으로 짜여 있어서 파일만 그대로 옮기면 됩니다. 아홉 개 소스 전체는 위 zip 카드에서 비밀번호로 압축을 풀어 받을 수 있습니다.
느린 기기나 동작 줄이기 설정에서는 어떻게 보이나요?
prefers-reduced-motion 이 켜져 있으면 자동 시연 루프가 아예 돌지 않고 진행률 0.5 인 중간 장면 하나만 정지해서 보여줍니다. 클릭이나 Enter 를 누르면 그 자리에서 즉시 다음 장면으로 바뀌므로, 계산 자체는 그대로 남고 자동으로 움직이는 부분만 꺼집니다.









