画像 切り替え エフェクト 9種 — キャンバスが計算するリビール
画像 切り替え エフェクト 9種のうち、1つはピクセルの対角座標から通過時刻を計算して斜めに掃き、1つは格子マスごとにノイズしきい値を先に計算して不規則だが再現される順で現れ、1つは固定シードで横列オフセットを計算し毎回同じ順でグリッチが揃います。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 対角座標で計算される画面切り替えワイプ
- 02 角度で計算される時計回りの円形リビール
- 03 値ノイズフィールドでマスごとの登場順を計算するディゾルブ
- 04 ブロックサイズが時間から計算されて縮むモザイク解像
- 05 三角片ごとの落下を計算する破片トランジション
- 06 横列オフセットを固定シードで計算するグリッチトランジション
- 07 距離フィールドで滲み半径を計算するインクにじみリビール
- 08 明暗値で半径を計算する網点リビール
- 09 サイン波の位相差で境界を計算する波リビール
1枚の画像を別の画像へ切り替える方法が、この9種にまとまっています。対角座標が斜めの境界を掃くもの、角度が境界を時計回りの円形リビールに変えるもの、値ノイズフィールドが不規則だが再現される順でマスを溶かすもの、溶解順が四角ブロックを一つずつ現すもの、中心からの距離順が三角の破片を落とすもの、固定シードが横列をずらしてから揃え直すもの、複数のインク起点からの距離フィールドが縁のにじんだ形で滲み広がるもの、マスごとの明暗値が網点の半径を同時に育てるもの、そしてサイン波の位相が水面のような境界を押し上げるものです。どれもcss 画像 切り替え アニメーションの重いライブラリには頼らず、1枚の小さなキャンバスパネルとクリック・キー操作の土台の上に、名前の付いた計算関数がひとつ乗っているだけです。
デモ

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
上のパターン01はtFieldという配列と、それをgetImageData/putImageDataで読み書きしながらピクセルの対角位置をリビールの通過時刻へ変換するRENDER関数だけで動いています。残り8つも同じ形で、それぞれ専用のコードブロックを添えて下に並びます。
9つの画像切り替えエフェクトの解剖 — どこで計算の一層が回るか
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 対角座標ワイプ | 48.76% | 126.4 | 23 / 23 |
| 02 | 角度掃引の円形リビール | 48.249% | 132.6 | 23 / 23 |
| 03 | ノイズしきい値ディゾルブ | 48.761% | 169.8 | 20 / 23 |
| 04 | 溶解順モザイクリビール | 48.051% | 147.4 | 22 / 23 |
| 05 | 距離順の破片落下 | 48.444% | 83.5 | 23 / 23 |
| 06 | 固定シードの横列グリッチ | 47.115% | 158.8 | 23 / 23 |
| 07 | インク距離フィールドのにじみ | 48.568% | 115.0 | 23 / 23 |
| 08 | 明暗値束縛の網点成長 | 34.422% | 79.9 | 21 / 23 |
| 09 | サイン位相の波リビール | 47.283% | 163.5 | 22 / 23 |
9つともrequestAnimationFrameを直接呼んでいて、CSSのtransitionや@keyframesがピクセル計算を肩代わりしている箇所はありません。それぞれ1つのFloat32Array(座標フィールド・マスごとのしきい値・破片の順序のいずれか)を一度だけ準備し、あとは毎フレーム同じ配列をRENDER(p)が読み返してgetImageData/putImageDataで書き戻すか、drawImage/clip/fillRectで描き直します。
パターン03は累積面積が最も広く(48.761%)、平均強度も最も高い(169.8)反面、動いたフレーム数は9つ中最少(20/23)です。ノイズしきい値のマスがソフトな1ピクセル単位のフェードではなく16px単位の塊としてまとめて切り替わるため、切り替わったフレームは一度に大きく変化する一方、しきい値を跨がない何フレームかは何も動きません。逆にパターン08は面積が最小(34.422%)で強度も最も低く(79.9)、ドットの半径がそのマス自身の明暗値で頭打ちになるため、暗いマスは進行率が1に達しても大きなドットに育たず、画面の一部がそのまま埋まらずに残るからです。css 画像 切り替え フェードとして実写の写真を使う場面では、明暗の分布が今回の合成シーンより不均一になりやすく、この差はさらに開くと考えられます。
一度だけ準備し毎フレーム読み返す座標フィールド
パターン01・02・07は、キャンバス全体のピクセル数分のFloat32ArrayをPRECOMPUTEで一度だけ組み立て、RENDERのピクセルループで同じフィールドを読み返します。01のtFieldは各ピクセルの対角位置(x/iw + y/ih)を半分にした値を保持します。02のangFieldはMath.atan2でパネル中心からの角度を求め、12時方向がゼロになるよう回転させたものです。07のdistFieldは複数の固定インク起点からの距離を、縁がにじんで見えるよう値ノイズのオフセットとブレンドしています。
固定シードや明暗値でマスごと・列ごとに決まる値
パターン03・06・08はピクセル単位ではなくマスまたは列ごとに1つの値を先に計算しておき、RENDERはこの3つに関してはピクセルデータに一切触れず、進行率と保存済みの値を比べてdrawImage・fillRect・arcを呼ぶだけです。03のcellNoiseは固定の順列テーブル(buildPerm)から作った値ノイズ関数で、各マスに不規則だが再現されるしきい値を割り当てます。06のrowOffsetとrowOrderは同じ固定シード生成器(makeLcg)から生まれるため、同じ列は毎回同じ横幅だけずれます。08のdotRは各マスの平均明暗値を一度読み取り、そのマスのドット半径の上限にします。
進行率から毎フレーム計算し直す幾何情報
パターン04・05・09は準備段階を軽く済ませ、代わりにRENDER自身の中で小さな幾何計算をやり直します。04は行と列からmeltOrderという順位をブロックごとに先に計算しておき、まだ溶けきっていないブロックの色付き矩形が毎フレームどれだけ縮んで下の鮮明な画像を透かすかを計算し直します。05は各三角破片の中心からの距離順を先に計算しておき、落下中の破片が毎フレームどれだけ下へ移動し、どれだけ透明になるかを計算し直します。09は準備段階をほぼ省き、列の位置と現在の進行率からサイン関数で境界の高さを毎フレーム一から求め直します。
パターン別コード
計算関数は9つ、1パターンにつき1つです。どれもindex.htmlから連続した範囲をそのまま切り出したもので、簡略化した書き直しではありません。
01対角座標で計算される画面切り替えワイプ
パネル全体を覆うキャンバスが各ピクセルの対角座標をtFieldから読み戻し、毎フレームそのピクセルがまだ古い画像を見せているべきか新しい画像を見せているべきかを判定します。結果として現れる境界は、一方の角からもう一方の辺へ向かって進む一本の斜め線です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
02角度で計算される時計回りの円形リビール
01と同じピクセルブレンドのループがここでも回りますが、読み取るフィールドが対角位置ではなくパネル中心からの角度になっているため、境界は横に滑るのではなく時計の針のように回転します。現在の掃引角度に細いアクセント線が重ねて描かれるので、動きの途中でも進行方向がひと目でわかります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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();
}
03値ノイズフィールドでマスごとの登場順を計算するディゾルブ
パネルは小さなマスの格子に分けられ、各マス自身の値ノイズの読み取り値が、そのマスが古い画像から新しい画像へ切り替わる瞬間を決めます。ノイズフィールドは固定シードから生成されるため、見た目は不規則でも同じマスは常に同じ順番で溶けていきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
}
}
}
04ブロックサイズが時間から計算されて縮むモザイク解像
粗い格子の各ブロックは最初、平均化された単色の平面を見せています。行と列から計算されたmeltOrderという値が、そのブロックが下の鮮明な画像に切り替わる瞬間を決めます。切り替わる直前には、その単色矩形の高さが縮んでいき、鮮明な画像が下から溶け上がるように見えます。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
}
}
}
05三角片ごとの落下を計算する破片トランジション
画面は小さな三角の破片に分割されていて、各破片の中心からの距離が、その破片が落ち始める瞬間を決めます。落下が始まった破片は自分の三角形にクリップされ、しきい値を過ぎた進行率の分だけ真下へ平行移動しながら、同時に透明度も下がっていきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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();
}
}
}
}
06横列オフセットを固定シードで計算するグリッチトランジション
パネルは細い横列に分けられ、固定シードの生成器がレンダリング開始前に各列の左右オフセットを一度だけ割り当てます。進行率がその列自身の積み順を過ぎるにつれ、そのオフセットはゼロへ向かって縮み、列は最終的に整列した位置へ落ち着いて新しい画像を見せます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
}
07距離フィールドで滲み半径を計算するインクにじみリビール
いくつかの固定点をインクの起点に見立て、各ピクセルから最も近い起点までの距離を、値ノイズでわずかにブレンドしてから読み取ります。その値を進行率が超えた瞬間からピクセルが切り替わるため、結果は均一な掃引ではなく、少数の起点からインクがにじみ広がっていくように見えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
08明暗値で半径を計算する網点リビール
画像は格子状のドットへサンプリングされ、各ドット自身の平均明暗値がそのドットの育つ上限を決めます。進行率が進むにつれて全てのドットが自分の上限へ向かって一斉に半径を広げていくため、印刷の網点のように画面全体が少しずつ埋まっていきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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();
}
}
09サイン波の位相差で境界を計算する波リビール
1本の境界線が画面を下から上へ掃き、その列ごとの高さがサイン波によってずらされることで、線がまっすぐではなく波打つように見えます。ある列の境界より上はまだ古い画像にクリップされ、下は新しい画像を見せています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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();
}
}
値が画面を変える場所 — 対角座標・ノイズしきい値・固定シード
01 — EDGE_SOFT
この定数は、対角の境界線に沿って「まだ古い画像」から「完全に新しい画像」までの間に置かれる帯の幅を決めるものです。境界の角度そのものではなく、その一本の縁がどれだけ柔らかく、あるいは硬く見えるかだけを変えます。
03 — NOISE_CELL_PX
これは1つのディゾルブマスのピクセルサイズです。ノイズの値自体ではなく、しきい値を越えたときに一緒に切り替わる塊がどれだけ粗いか、細かいかだけを変えます。
09 — WAVE_AMPLITUDE_PX
これはサイン波が境界の基準線からどれだけ高く押し上げられるかの最大値です。波の速さではなく、境界が上へ進むときにどれだけ大きく波打つ幅だけを変えます。
| バリエーション | 変わる値 | 見た目の変化 |
|---|---|---|
| EDGE_SOFT 0.05 → 0.015 | 対角境界の柔らかさ幅が0.05から0.015へ下がる | 古い画像と新しい画像の間の帯が急に狭まり、対角の境界がソフトなグラデーションではなくほぼ硬い切れ目として見える |
| NOISE_CELL_PX 16px → 8px | ディゾルブマスのサイズが16pxから8pxへ縮む | 一度に現れる塊の面積が4分の1になり、ディゾルブが少数の目立つブロックではなく細かく散った粒として広がる |
| WAVE_AMPLITUDE_PX 10px → 26px | 波の振幅が10pxから26pxへ上がる | 境界の波打ちが2倍以上高くなり、水面のような曲線が上へ進むにつれてより荒く、はっきり見える |
どこで壊れるか — アクセシビリティ・reduced-motion・低スペック
9つとも同じ土台を共有しています。クリック可能なパネルにはrole="button"・tabindex="0"・再生し直すと何が起きるかを説明するaria-labelが付き、キャンバス自体はaria-hidden="true"でラベルと重複しないようにしてあり、どのパネルもclickとEnter・Spaceのkeydownの両方に応答します。window.matchMedia('(prefers-reduced-motion: reduce)')はCSSのメディアクエリだけに任せず、読み込み時に一度JavaScript側でも読み取られます。
reduced-motionが有効なとき、9つとも自動でループするdriveTickの呼び出しをまるごとスキップし、is-stillクラスを付け、RENDER(0.5)へ直接ジャンプして半分ずつ見える状態をすぐ表示し、クリック・Enterのハンドラーを、アニメーションさせずに進行率を0と1の間で一気に切り替えるものへ差し替えます。この設定の下でいつも通りのイージングを裏で動かし続けるパターンは1つもありません。
9つの計算関数を1つずつ読むのではなくまとめて見比べるなら、パスワードzd738fjgで開くzipそのものを読むのが一番早い方法です。
getImageData/putImageDataで全ピクセルに触れる3パターン(01・02・07)は、端末のピクセル比でスケールされたキャンバス全体サイズの配列を毎回RENDERで再計算するため、9つの中では低スペックな端末で最も重くなります。残り6つはマス・破片・ドット・列のいずれか一部だけをdrawImage/fillRect/arcで触るだけなので、実際のピクセルコピーはブラウザ自身のコンポジターが担い、フレームあたりのコストははるかに軽くなります。
この全ピクセル走査のコストは、実際のページに実際の端末ピクセル比で組み込んで初めて表面化します。下のzipに入っている9つのファイルは、まさにその条件で確かめるためのものです。
3つのバリエーションとアクセシビリティ
上の表がすでに、確認済みの3つのバリエーション — EDGE_SOFT・NOISE_CELL_PX・WAVE_AMPLITUDE_PX — をすべて含んでいます。どのバリエーションもその1つの定数だけを書き換えたもので、リスナー・aria-*属性・reduced-motionの分岐は元のパターンとまったく同じままです。バリエーションのindex.htmlは、その1つの数値だけを変えた元ファイルなので、上で説明したアクセシビリティの挙動をバリエーションごとに確認し直す必要はありません。
画像 切り替え エフェクト の実装例はどこで見られる?
画像 トランジションの実装例として多く見つかるのは、単純なCSSのopacityクロスフェードか、1方向だけをclip-pathアニメーションに固定したワイプです。上の9つのパターンはそれとは違う場所に立っていて、座標フィールド・マスごとのしきい値・破片ごとの順序のいずれかを素のJavaScriptで一度だけ計算し、毎フレーム読み戻してキャンバスに描かせています — チャートやアニメーションのライブラリもCDNのスクリプトも使わず、上のzipとブラウザだけで完結します。ギャラリーの画像切り替え、ヒーローバナーの写真差し替え、読み込み画面のビフォーアフター表示のいずれも、同じクリック・キー操作の土台をそのまま流用しつつ、9つの計算関数のうち雰囲気に合う1つを差し込むだけで作れます。
FAQ
パターン03のノイズディゾルブは、なぜ9つの中で平均強度が最も高いのですか?
各マスは1ピクセルずつフェードするのではなく、drawImageで16px単位の塊としてまとめて切り替わります。複数のマスが同時にしきい値を越えるフレームでは、一度に広い面積が変化するため、01・02・07の緩やかな1ピクセル単位のブレンドより1フレームあたりの差分が大きくなります。
パターン07のインクにじみの縁は、1つの距離点から計算されていますか、それとも複数ですか?
複数です。PRECOMPUTEが固定数のインク起点を配置し、各ピクセルについて最も近い起点までの距離だけを残したうえで、値ノイズのオフセットをブレンドします。そのため縁は完全な円ではなく、にじんだ不規則な形として現れます。
このcss 画像 切り替え アニメーション自動デモの構成は、実際のギャラリーにそのまま使えますか?
click・keydownのリスナー、aria-labelとrole="button"の組み合わせ、そして各計算関数はそのまま移せます。デモの2枚の固定シーンを、実際のギャラリーで必要な2枚の画像に差し替えるだけで、タイミングの計算式に手を入れる必要はありません。どのパターンも意図的に省いているのは2枚目の画像の先読みだけなので、公開する前にそこだけは自分で足してください。
Sources: MDN — getImageData · MDN — putImageData · MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — CanvasRenderingContext2D.clip
関連記事: html5 canvas アニメーション9種 — 水滴とブラックホール · CSS 画面切り替え アニメーション9選 · パーリンノイズ地形9選 — ライブラリなし、計算だけで — 1つ目は同じピクセル計算の骨組みをリビールではなく1つの場面全体に使っており、2つ目はキャンバスのピクセル処理を使わないCSSだけの画面切り替えを扱い、3つ目はパターン03がマスごとのしきい値に使っているのと同じ値ノイズの計算をさらに深く掘り下げています。









