メッシュ グラデーション 9パターン — 色点の補間からノイズのゆがみまで計算で描く
メッシュグラデーションは数色をぼかせば終わりに見えるが、そのまま混ぜると真ん中が濁り暗い面は帯に割れる — 05番と06番がその二か所を一つずつ塞ぐ。この記事の九つのメッシュ グラデーションは、キャンバスのピクセルをJavaScriptで一つずつ計算して描いたもので、ファイルを貼ればそのまま動きます。
メッシュ グラデーション九つの場面をこの画面で見る
下のグリッドは、ページを開いた時点から自動で動いています。色の点を置いて混ぜる方法が、場面ごとに一つずつ違います。
01 mg-bilinear-four-corner-orbit
02 mg-grid3-smoothstep-control-points
03 mg-shepard-inverse-distance-blobs
04 mg-domain-warp-noise-flow
05 mg-linear-light-vs-srgb-mix-split
06 mg-grain-dither-banding-removal
07 mg-luminance-height-normal-sheen
08 mg-palette-hue-cycle-drift
09 mg-hero-backdrop-vignette-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 四隅の色を補間する基本のメッシュ
- 02 3×3の制御点が漂うやわらかいメッシュ
- 03 距離の逆数で色を混ぜるにじみメッシュ
- 04 ノイズで座標をゆがめて流れるメッシュ
- 05 素の混合と明るさ補正の混合を並べる
- 06 縞を消す粒子の混ぜ込み
- 07 明るさを高さとして読み光が巡るメッシュ
- 08 色相の角度を回して雰囲気が移るメッシュ
- 09 見出しの後ろに敷くメッシュのヒーロー
メッシュ グラデーション 9パターン — ピクセルごとに計算した色が画面を埋める
九つの場面は同じ土台の上に立っています。キャンバスの内側は160×100マス(CALC_W・CALC_H)で、フレームごとに createImageData で作った ImageData へ1万6000マスの色を一つずつ書き込み、putImageData で載せてから、CSSが16:10の比率に引き伸ばして見せます。マス数を160×100に絞るのは計算量のためで、描くマスが増えればフレームごとの計算も同じ割合で増えます。
その上で、九つの場面は色の混ぜ方を一つずつ変えます。01は四隅の色の双線形補間が最小のメッシュだと示し、02は3×3の制御点でマスを増やし、03は格子を捨てて色の点だけを置きます。04は色を読む座標をノイズでゆがめ、05と06は混ぜ方の落とし穴を一つずつ直す比較画面です。07は明るさを高さとして読んで光沢をのせ、08は色相の角度を回し、09は03の色点の混ぜ方に周辺の暗さと粒を重ねて、見出しの背景にまとめます。
時間は requestAnimationFrame のタイムスタンプを16ミリ秒(FIXED_DT)単位でためて秒に直し、paint(t, d) に渡します。乱数は種を固定した seedRand だけなので、同じ時刻なら必ず同じ絵になります。メッシュグラデーション 作り方の話でいえば、ライブラリ・WebGL・外部アドレスは一つも使わず、貼れば動く一枚のスクリプトです。同じキャンバスの方法で水滴とブラックホールを描くhtml5 canvas アニメーション9種 — 水滴とブラックホールと、正弦波を重ねて色の帯を作るレインボーグラデーション 9パターン — キャンバスで作るプラズマ背景を並べると、計算で色を決める方法の幅が見えてきます。
技法別コードで見る色の混ぜ方
以下の断片はデモのソースから連続した行を順番どおりに切り出したもので、完結したファイルは暗号化zipに入っています。色の一覧を輪にして読む pick(pal, pos, out) は、pos の整数部で色を選び、小数部の分だけ次の色へ線形に補間する小さな道具で、01と02の色の巡りはこの関数から出ています(08は色相の角度を回すので使いません)。
01四隅の色を補間して混ぜる基本のメッシュ
CORNER_RATE(毎秒2)に合わせて四隅の色が、ピンク・ライラック・空色・ミントの一覧を時計回りに一つ隣の色へ移り、全ピクセルは lerp を横に二回、縦に一回呼んで四色の加重平均を受け取ります。中央は四色が同じ割合で混ざった値なので隅より濁りやすく、この双線形の枠が残りの場面の骨組みになります。この場面は累積面積60.9%・平均強度15.9・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="四隅の色が円を描くように巡り、すべてのピクセルが縦横二方向の加重平均で色を受け取って、いちばん単純なメッシュグラデーションができる。"></canvas>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">四隅の色が円を描くように巡り、すべてのピクセルが縦横二方向の加重平均で色を受け取って、いちばん単純なメッシュグラデーションができる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 01 네 모서리 쌍선형 보간 — 모서리 색이 시계 방향으로 한 칸씩 돈다
var REDUCE_MS = 700;
var CORNER_RATE = 2;
var CORNER_PAL = [PAL[0], PAL[1], PAL[2], PAL[3]];
var corners = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]];
function paint(t, d) {
// 왼쪽 위 → 오른쪽 위 → 오른쪽 아래 → 왼쪽 아래 순서로 색 목록의 이웃 칸을 하나씩 받는다
pick(CORNER_PAL, t * CORNER_RATE + 0, corners[0]);
pick(CORNER_PAL, t * CORNER_RATE + 1, corners[1]);
pick(CORNER_PAL, t * CORNER_RATE + 2, corners[3]);
pick(CORNER_PAL, t * CORNER_RATE + 3, corners[2]);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var r = lerp(lerp(corners[0][0], corners[1][0], u), lerp(corners[2][0], corners[3][0], u), v);
var g = lerp(lerp(corners[0][1], corners[1][1], u), lerp(corners[2][1], corners[3][1], u), v);
var b = lerp(lerp(corners[0][2], corners[1][2], u), lerp(corners[2][2], corners[3][2], u), v);
put(d, x, y, r, g, b);
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgBilinearFourCornerOrbit.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 01 네 모서리 쌍선형 보간 — 모서리 색이 시계 방향으로 한 칸씩 돈다
var REDUCE_MS = 700;
var CORNER_RATE = 2;
var CORNER_PAL: number[][] = [PAL[0], PAL[1], PAL[2], PAL[3]];
var corners: number[][] = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]];
function paint(t: number, d: Uint8ClampedArray): void {
// 왼쪽 위 → 오른쪽 위 → 오른쪽 아래 → 왼쪽 아래 순서로 색 목록의 이웃 칸을 하나씩 받는다
pick(CORNER_PAL, t * CORNER_RATE + 0, corners[0]);
pick(CORNER_PAL, t * CORNER_RATE + 1, corners[1]);
pick(CORNER_PAL, t * CORNER_RATE + 2, corners[3]);
pick(CORNER_PAL, t * CORNER_RATE + 3, corners[2]);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var r = lerp(lerp(corners[0][0], corners[1][0], u), lerp(corners[2][0], corners[3][0], u), v);
var g = lerp(lerp(corners[0][1], corners[1][1], u), lerp(corners[2][1], corners[3][1], u), v);
var b = lerp(lerp(corners[0][2], corners[1][2], u), lerp(corners[2][2], corners[3][2], u), v);
put(d, x, y, r, g, b);
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgBilinearFourCornerOrbit() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="캔버스 네 모서리의 색이 원을 따라 돌고, 모든 픽셀이 가로·세로 두 방향 가중 평균으로 색을 받아 가장 단순한 메쉬 그라데이션이 만들어진다." />
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>캔버스 네 모서리의 색이 원을 따라 돌고, 모든 픽셀이 가로·세로 두 방향 가중 평균으로 색을 받아 가장 단순한 메쉬 그라데이션이 만들어진다.</p>
</div>
</main>
);
}01 · 四隅の色を補間する基本のメッシュ — 計測 累積面積 60.93% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var CORNER_RATE = 2;
var CORNER_PAL = [PAL[0], PAL[1], PAL[2], PAL[3]];
var corners = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]];
function paint(t, d) {
// 左上 → 右上 → 右下 → 左下の順に、色の一覧の隣の升を一つずつ受け取る
pick(CORNER_PAL, t * CORNER_RATE + 0, corners[0]);
pick(CORNER_PAL, t * CORNER_RATE + 1, corners[1]);
pick(CORNER_PAL, t * CORNER_RATE + 2, corners[3]);
pick(CORNER_PAL, t * CORNER_RATE + 3, corners[2]);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var r = lerp(lerp(corners[0][0], corners[1][0], u), lerp(corners[2][0], corners[3][0], u), v);
var g = lerp(lerp(corners[0][1], corners[1][1], u), lerp(corners[2][1], corners[3][1], u), v);
var b = lerp(lerp(corners[0][2], corners[1][2], u), lerp(corners[2][2], corners[3][2], u), v);
put(d, x, y, r, g, b);
}
}
023×3の制御点がただようなめらかなメッシュ
制御点九つが NODE_RATE(毎秒1.2)で色の一覧を読んで色を変え、SWAY(0.12)だけ元の位置の近くで揺れます。マスの中の重みは smooth を通るのでマスの境目で傾きが0になって継ぎ目の折れが減り、制御点が動いた分だけ読む位置を逆に押して画面がついてたわみます。この場面は累積面積69.1%・平均強度16.5・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="九つの制御点が持ち場の近くで揺れ、マス目の境でなめらかに重みを曲げて、継ぎ目なく色が続くメッシュを一ピクセルずつ計算する。"></canvas>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">九つの制御点が持ち場の近くで揺れ、マス目の境でなめらかに重みを曲げて、継ぎ目なく色が続くメッシュを一ピクセルずつ計算する。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 02 3×3 제어점 smoothstep — 아홉 제어점이 제자리 근처에서 흔들리고 색도 이웃 색으로 넘어간다
var REDUCE_MS = 900;
var SWAY = 0.12;
var NODE_RATE = 1.2;
var nodeCol = [];
var nodeDx = [];
var nodeDy = [];
for (var ni = 0; ni < 9; ni += 1) {
nodeCol.push([0, 0, 0]);
nodeDx.push(0);
nodeDy.push(0);
}
var meshTmp = [0, 0, 0];
// 0~1 좌표를 칸 번호(0 또는 1)와 칸 안의 부드러운 가중치로 나눈다
function meshAt(u, v, out) {
var gx = clamp(u, 0, 1) * 2, gy = clamp(v, 0, 1) * 2;
var ix = Math.min(1, Math.floor(gx)), iy = Math.min(1, Math.floor(gy));
var fx = smooth(gx - ix), fy = smooth(gy - iy);
var a = nodeCol[iy * 3 + ix], b = nodeCol[iy * 3 + ix + 1];
var c = nodeCol[(iy + 1) * 3 + ix], e = nodeCol[(iy + 1) * 3 + ix + 1];
out[0] = lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy);
out[1] = lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy);
out[2] = lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy);
}
function paint(t, d) {
for (var n = 0; n < 9; n += 1) {
pick(PAL, n * 0.7 + t * NODE_RATE, nodeCol[n]);
nodeDx[n] = SWAY * Math.sin(6.2832 * (t * 1.2 + n * 0.37));
nodeDy[n] = SWAY * Math.cos(6.2832 * (t * 1.05 + n * 0.51));
}
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
var gy = v * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var gx = u * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
var k0 = iy * 3 + ix, k1 = k0 + 1, k2 = k0 + 3, k3 = k0 + 4;
// 제어점이 움직인 만큼 읽는 자리를 거꾸로 민다 — 같은 부드러운 가중치로 변위를 섞는다
var du = lerp(lerp(nodeDx[k0], nodeDx[k1], fx), lerp(nodeDx[k2], nodeDx[k3], fx), fy);
var dv = lerp(lerp(nodeDy[k0], nodeDy[k1], fx), lerp(nodeDy[k2], nodeDy[k3], fx), fy);
meshAt(u - du, v - dv, meshTmp);
put(d, x, y, meshTmp[0], meshTmp[1], meshTmp[2]);
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgGrid3SmoothstepControlPoints.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 02 3×3 제어점 smoothstep — 아홉 제어점이 제자리 근처에서 흔들리고 색도 이웃 색으로 넘어간다
var REDUCE_MS = 900;
var SWAY = 0.12;
var NODE_RATE = 1.2;
var nodeCol: number[][] = [];
var nodeDx: number[] = [];
var nodeDy: number[] = [];
for (var ni = 0; ni < 9; ni += 1) { nodeCol.push([0, 0, 0]); nodeDx.push(0); nodeDy.push(0); }
var meshTmp: number[] = [0, 0, 0];
// 0~1 좌표를 칸 번호(0 또는 1)와 칸 안의 부드러운 가중치로 나눈다
function meshAt(u: number, v: number, out: number[]): void {
var gx = clamp(u, 0, 1) * 2, gy = clamp(v, 0, 1) * 2;
var ix = Math.min(1, Math.floor(gx)), iy = Math.min(1, Math.floor(gy));
var fx = smooth(gx - ix), fy = smooth(gy - iy);
var a = nodeCol[iy * 3 + ix], b = nodeCol[iy * 3 + ix + 1];
var c = nodeCol[(iy + 1) * 3 + ix], e = nodeCol[(iy + 1) * 3 + ix + 1];
out[0] = lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy);
out[1] = lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy);
out[2] = lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy);
}
function paint(t: number, d: Uint8ClampedArray): void {
for (var n = 0; n < 9; n += 1) {
pick(PAL, n * 0.7 + t * NODE_RATE, nodeCol[n]);
nodeDx[n] = SWAY * Math.sin(6.2832 * (t * 1.2 + n * 0.37));
nodeDy[n] = SWAY * Math.cos(6.2832 * (t * 1.05 + n * 0.51));
}
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
var gy = v * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var gx = u * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
var k0 = iy * 3 + ix, k1 = k0 + 1, k2 = k0 + 3, k3 = k0 + 4;
// 제어점이 움직인 만큼 읽는 자리를 거꾸로 민다 — 같은 부드러운 가중치로 변위를 섞는다
var du = lerp(lerp(nodeDx[k0], nodeDx[k1], fx), lerp(nodeDx[k2], nodeDx[k3], fx), fy);
var dv = lerp(lerp(nodeDy[k0], nodeDy[k1], fx), lerp(nodeDy[k2], nodeDy[k3], fx), fy);
meshAt(u - du, v - dv, meshTmp);
put(d, x, y, meshTmp[0], meshTmp[1], meshTmp[2]);
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgGrid3SmoothstepControlPoints() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="아홉 개 제어점이 제자리 근처에서 흔들리고, 칸 경계마다 가중치를 부드럽게 휘어 이음새 없이 색이 이어지는 메쉬를 한 픽셀씩 계산한다." />
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>아홉 개 제어점이 제자리 근처에서 흔들리고, 칸 경계마다 가중치를 부드럽게 휘어 이음새 없이 색이 이어지는 메쉬를 한 픽셀씩 계산한다.</p>
</div>
</main>
);
}02 · 3×3の制御点が漂うやわらかいメッシュ — 計測 累積面積 69.099% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function meshAt(u, v, out) {
var gx = clamp(u, 0, 1) * 2, gy = clamp(v, 0, 1) * 2;
var ix = Math.min(1, Math.floor(gx)), iy = Math.min(1, Math.floor(gy));
var fx = smooth(gx - ix), fy = smooth(gy - iy);
var a = nodeCol[iy * 3 + ix], b = nodeCol[iy * 3 + ix + 1];
var c = nodeCol[(iy + 1) * 3 + ix], e = nodeCol[(iy + 1) * 3 + ix + 1];
out[0] = lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy);
out[1] = lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy);
out[2] = lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy);
}
function paint(t, d) {
for (var n = 0; n < 9; n += 1) {
pick(PAL, n * 0.7 + t * NODE_RATE, nodeCol[n]);
nodeDx[n] = SWAY * Math.sin(6.2832 * (t * 1.2 + n * 0.37));
nodeDy[n] = SWAY * Math.cos(6.2832 * (t * 1.05 + n * 0.51));
}
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
var gy = v * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var gx = u * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
var k0 = iy * 3 + ix, k1 = k0 + 1, k2 = k0 + 3, k3 = k0 + 4;
// 制御点が動いた分だけ読む位置を逆に押す — 同じなめらかな重みでずれを混ぜる
var du = lerp(lerp(nodeDx[k0], nodeDx[k1], fx), lerp(nodeDx[k2], nodeDx[k3], fx), fy);
var dv = lerp(lerp(nodeDy[k0], nodeDy[k1], fx), lerp(nodeDy[k2], nodeDy[k3], fx), fy);
meshAt(u - du, v - dv, meshTmp);
put(d, x, y, meshTmp[0], meshTmp[1], meshTmp[2]);
}
}
}
03距離の逆数で色を混ぜるにじみのメッシュ
六つの色の点がリサージュ軌道で泳ぎ、各ピクセルは点までの距離の POWER 乗に反比例する重み 1 / (d^POWER + EPS) で六色を加重平均します。格子なしで点だけ置けば済む代わりに、ピクセルごとに点六つの距離と Math.pow を呼ぶので、九つの中で計算量がいちばん多い場面です。この場面は累積面積70.2%・平均強度25.5・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="六つの色の点が画面を泳ぎ、各ピクセルは点までの距離の逆二乗を重みにして近い色を多く受け取り、境がにじむように混ざる。"></canvas>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">六つの色の点が画面を泳ぎ、各ピクセルは点までの距離の逆二乗を重みにして近い色を多く受け取り、境がにじむように混ざる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 03 역거리 가중(IDW) 번짐 — 여섯 색점이 헤엄치고 픽셀은 거리의 POWER 제곱에 반비례해 색을 받는다
var REDUCE_MS = 1100;
var POWER = 2;
var EPS = 0.002;
var ASPECT = CALC_W / CALC_H;
var SEED_FX = [0.31, 0.43, 0.37, 0.5, 0.27, 0.41];
var SEED_FY = [0.39, 0.29, 0.47, 0.33, 0.45, 0.35];
var SEED_PH = [0.0, 0.17, 0.33, 0.5, 0.67, 0.83];
var dotX = [0, 0, 0, 0, 0, 0];
var dotY = [0, 0, 0, 0, 0, 0];
function paint(t, d) {
for (var k = 0; k < 6; k += 1) {
dotX[k] = (0.5 + 0.4 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.7));
}
var halfP = POWER / 2;
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 6; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + EPS);
wr += w * PAL[j][0];
wg += w * PAL[j][1];
wb += w * PAL[j][2];
ws += w;
}
put(d, x, y, wr / ws, wg / ws, wb / ws);
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgShepardInverseDistanceBlobs.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 03 역거리 가중(IDW) 번짐 — 여섯 색점이 헤엄치고 픽셀은 거리의 POWER 제곱에 반비례해 색을 받는다
var REDUCE_MS = 1100;
var POWER = 2;
var EPS = 0.002;
var ASPECT = CALC_W / CALC_H;
var SEED_FX = [0.31, 0.43, 0.37, 0.5, 0.27, 0.41];
var SEED_FY = [0.39, 0.29, 0.47, 0.33, 0.45, 0.35];
var SEED_PH = [0.0, 0.17, 0.33, 0.5, 0.67, 0.83];
var dotX: number[] = [0, 0, 0, 0, 0, 0];
var dotY: number[] = [0, 0, 0, 0, 0, 0];
function paint(t: number, d: Uint8ClampedArray): void {
for (var k = 0; k < 6; k += 1) {
dotX[k] = (0.5 + 0.4 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.7));
}
var halfP = POWER / 2;
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 6; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + EPS);
wr += w * PAL[j][0];
wg += w * PAL[j][1];
wb += w * PAL[j][2];
ws += w;
}
put(d, x, y, wr / ws, wg / ws, wb / ws);
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgShepardInverseDistanceBlobs() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="여섯 개 색점이 화면을 헤엄치고, 각 픽셀은 점까지 거리의 역제곱을 무게로 삼아 가까운 색을 더 많이 받아 경계가 번지듯 섞인다." />
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>여섯 개 색점이 화면을 헤엄치고, 각 픽셀은 점까지 거리의 역제곱을 무게로 삼아 가까운 색을 더 많이 받아 경계가 번지듯 섞인다.</p>
</div>
</main>
);
}03 · 距離の逆数で色を混ぜるにじみメッシュ — 計測 累積面積 70.224% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var POWER = 2;
var EPS = 0.002;
var ASPECT = CALC_W / CALC_H;
var SEED_FX = [0.31, 0.43, 0.37, 0.5, 0.27, 0.41];
var SEED_FY = [0.39, 0.29, 0.47, 0.33, 0.45, 0.35];
var SEED_PH = [0.0, 0.17, 0.33, 0.5, 0.67, 0.83];
var dotX = [0, 0, 0, 0, 0, 0];
var dotY = [0, 0, 0, 0, 0, 0];
function paint(t, d) {
for (var k = 0; k < 6; k += 1) {
dotX[k] = (0.5 + 0.4 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.7));
}
var halfP = POWER / 2;
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 6; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + EPS);
wr += w * PAL[j][0];
wg += w * PAL[j][1];
wb += w * PAL[j][2];
ws += w;
}
put(d, x, y, wr / ws, wg / ws, wb / ws);
}
}
}
04ノイズで座標をゆがめて流れるメッシュ
色を読む前に座標 u・v を値ノイズの二層(大きなうねりと細かいさざ波)で WARP(0.3)だけ押し、ずらした位置で固定の3×3格子の色を読みます。色そのものは同じなのに縁が煙のようにたなびくので、止まったグラデーションを生きた背景に変えたいときに使います。この場面は累積面積65.6%・平均強度18.1・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="色を読む前に座標を二重のノイズで押し流し、その位置でメッシュを読むので、縁が煙のように曲がって流れる。"></canvas>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">色を読む前に座標を二重のノイズで押し流し、その位置でメッシュを読むので、縁が煙のように曲がって流れる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 04 노이즈 좌표 비틀기 — 색을 읽기 전에 시드 고정 값 노이즈 두 겹으로 좌표를 민다
var REDUCE_MS = 1000;
var WARP = 0.3;
var LATTICE = 32;
var lattice = [];
var rnd = seedRand(7);
for (var li = 0; li < LATTICE * LATTICE; li += 1) {
lattice.push(rnd());
}
function lat(ix, iy) {
return lattice[(((iy % LATTICE) + LATTICE) % LATTICE) * LATTICE + (((ix % LATTICE) + LATTICE) % LATTICE)];
}
function vnoise(x, y) {
var ix = Math.floor(x), iy = Math.floor(y);
var fx = smooth(x - ix), fy = smooth(y - iy);
return lerp(lerp(lat(ix, iy), lat(ix + 1, iy), fx), lerp(lat(ix, iy + 1), lat(ix + 1, iy + 1), fx), fy);
}
var GRID = [PAL[0], PAL[2], PAL[1], PAL[3], PAL[5], PAL[0], PAL[1], PAL[4], PAL[2]];
function meshRead(u, v, out) {
var gx = clamp(u, 0, 1) * 2, gy = clamp(v, 0, 1) * 2;
var ix = Math.min(1, Math.floor(gx)), iy = Math.min(1, Math.floor(gy));
var fx = smooth(gx - ix), fy = smooth(gy - iy);
var a = GRID[iy * 3 + ix], b = GRID[iy * 3 + ix + 1], c = GRID[(iy + 1) * 3 + ix], e = GRID[(iy + 1) * 3 + ix + 1];
out[0] = lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy);
out[1] = lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy);
out[2] = lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy);
}
var warpTmp = [0, 0, 0];
function paint(t, d) {
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
// 큰 물결 + 잔물결 두 겹
var wu = (vnoise(u * 2.2 + t * 0.7, v * 2.2 - t * 0.4) - 0.5) * 2 + (vnoise(u * 5 - t * 0.9 + 7.7, v * 5 + t * 0.6) - 0.5) * 0.7;
var wv = (vnoise(u * 2.2 - t * 0.5 + 3.1, v * 2.2 + t * 0.8 + 5.3) - 0.5) * 2 + (vnoise(u * 5 + t * 0.8 + 1.9, v * 5 - t * 0.7 + 9.1) - 0.5) * 0.7;
meshRead(u + WARP * wu, v + WARP * wv, warpTmp);
put(d, x, y, warpTmp[0], warpTmp[1], warpTmp[2]);
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgDomainWarpNoiseFlow.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 04 노이즈 좌표 비틀기 — 색을 읽기 전에 시드 고정 값 노이즈 두 겹으로 좌표를 민다
var REDUCE_MS = 1000;
var WARP = 0.3;
var LATTICE = 32;
var lattice: number[] = [];
var rnd = seedRand(7);
for (var li = 0; li < LATTICE * LATTICE; li += 1) { lattice.push(rnd()); }
function lat(ix: number, iy: number): number {
return lattice[(((iy % LATTICE) + LATTICE) % LATTICE) * LATTICE + (((ix % LATTICE) + LATTICE) % LATTICE)];
}
function vnoise(x: number, y: number): number {
var ix = Math.floor(x), iy = Math.floor(y);
var fx = smooth(x - ix), fy = smooth(y - iy);
return lerp(lerp(lat(ix, iy), lat(ix + 1, iy), fx), lerp(lat(ix, iy + 1), lat(ix + 1, iy + 1), fx), fy);
}
var GRID: number[][] = [PAL[0], PAL[2], PAL[1], PAL[3], PAL[5], PAL[0], PAL[1], PAL[4], PAL[2]];
function meshRead(u: number, v: number, out: number[]): void {
var gx = clamp(u, 0, 1) * 2, gy = clamp(v, 0, 1) * 2;
var ix = Math.min(1, Math.floor(gx)), iy = Math.min(1, Math.floor(gy));
var fx = smooth(gx - ix), fy = smooth(gy - iy);
var a = GRID[iy * 3 + ix], b = GRID[iy * 3 + ix + 1], c = GRID[(iy + 1) * 3 + ix], e = GRID[(iy + 1) * 3 + ix + 1];
out[0] = lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy);
out[1] = lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy);
out[2] = lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy);
}
var warpTmp: number[] = [0, 0, 0];
function paint(t: number, d: Uint8ClampedArray): void {
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
// 큰 물결 + 잔물결 두 겹
var wu = (vnoise(u * 2.2 + t * 0.7, v * 2.2 - t * 0.4) - 0.5) * 2 + (vnoise(u * 5 - t * 0.9 + 7.7, v * 5 + t * 0.6) - 0.5) * 0.7;
var wv = (vnoise(u * 2.2 - t * 0.5 + 3.1, v * 2.2 + t * 0.8 + 5.3) - 0.5) * 2 + (vnoise(u * 5 + t * 0.8 + 1.9, v * 5 - t * 0.7 + 9.1) - 0.5) * 0.7;
meshRead(u + WARP * wu, v + WARP * wv, warpTmp);
put(d, x, y, warpTmp[0], warpTmp[1], warpTmp[2]);
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgDomainWarpNoiseFlow() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="색을 읽기 전에 좌표를 두 겹 노이즈로 밀어 보내고 그 자리에서 메쉬를 읽어, 가장자리가 연기처럼 휘어지며 흐르는 모양이 나온다." />
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>색을 읽기 전에 좌표를 두 겹 노이즈로 밀어 보내고 그 자리에서 메쉬를 읽어, 가장자리가 연기처럼 휘어지며 흐르는 모양이 나온다.</p>
</div>
</main>
);
}04 · ノイズで座標をゆがめて流れるメッシュ — 計測 累積面積 65.601% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function lat(ix, iy) {
return lattice[(((iy % LATTICE) + LATTICE) % LATTICE) * LATTICE + (((ix % LATTICE) + LATTICE) % LATTICE)];
}
function vnoise(x, y) {
var ix = Math.floor(x), iy = Math.floor(y);
var fx = smooth(x - ix), fy = smooth(y - iy);
return lerp(lerp(lat(ix, iy), lat(ix + 1, iy), fx), lerp(lat(ix, iy + 1), lat(ix + 1, iy + 1), fx), fy);
}
var warpTmp = [0, 0, 0];
function paint(t, d) {
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
// 大きなうねり + 細かいさざ波の二層
var wu = (vnoise(u * 2.2 + t * 0.7, v * 2.2 - t * 0.4) - 0.5) * 2 + (vnoise(u * 5 - t * 0.9 + 7.7, v * 5 + t * 0.6) - 0.5) * 0.7;
var wv = (vnoise(u * 2.2 - t * 0.5 + 3.1, v * 2.2 + t * 0.8 + 5.3) - 0.5) * 2 + (vnoise(u * 5 + t * 0.8 + 1.9, v * 5 - t * 0.7 + 9.1) - 0.5) * 0.7;
meshRead(u + WARP * wu, v + WARP * wv, warpTmp);
put(d, x, y, warpTmp[0], warpTmp[1], warpTmp[2]);
}
}
}
05そのまま混ぜる方法と明るさを直して混ぜる方法を並べる
同じ紫とオレンジを同じ割合 s で混ぜますが、左半分は値のまま lerp し、右半分は GAMMA(2.2)乗で明るさを線形に直して混ぜてから戻します。0.9秒の時点で縦全体を平均すると、帯の中央の色は左が191,122,155(明るさ146.4)、右が201,127,179(明るさ155.1)で、右のほうが少し明るく青みが残ります。この場面は累積面積64.5%・平均強度19.2・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="同じ二色を左は値のまま、右は明るさを線形に戻して混ぜ、真ん中の帯が暗く濁る側と澄んだまま残る側を一画面で比べる。"></canvas>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">同じ二色を左は値のまま、右は明るさを線形に戻して混ぜ、真ん中の帯が暗く濁る側と澄んだまま残る側を一画面で比べる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 05 감마 보정 섞기 — 왼쪽 절반은 값 그대로, 오른쪽 절반은 선형 밝기로 되돌려 섞는다 (같은 보라·주황)
var REDUCE_MS = 800;
var COLOR_A = [130, 90, 245];
var COLOR_B = [255, 156, 60];
var GAMMA = 2.2;
var linA = [Math.pow(COLOR_A[0] / 255, GAMMA), Math.pow(COLOR_A[1] / 255, GAMMA), Math.pow(COLOR_A[2] / 255, GAMMA)];
var linB = [Math.pow(COLOR_B[0] / 255, GAMMA), Math.pow(COLOR_B[1] / 255, GAMMA), Math.pow(COLOR_B[2] / 255, GAMMA)];
var HALF = CALC_W / 2;
function paint(t, d) {
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var right = x >= HALF;
var lx = (right ? x - HALF : x) / (HALF - 1);
// 두 색 사이의 비율 s — 양쪽 절반이 같은 값 s 를 쓴다
var s = smooth(lx + 0.22 * Math.sin(6.2832 * (v * 1.1 + t * 0.5)) + 0.1 * Math.sin(6.2832 * (v * 2.3 - t * 0.35)));
var r, g, b;
if (!right) {
r = lerp(COLOR_A[0], COLOR_B[0], s);
g = lerp(COLOR_A[1], COLOR_B[1], s);
b = lerp(COLOR_A[2], COLOR_B[2], s);
}
else {
r = 255 * Math.pow(lerp(linA[0], linB[0], s), 1 / GAMMA);
g = 255 * Math.pow(lerp(linA[1], linB[1], s), 1 / GAMMA);
b = 255 * Math.pow(lerp(linA[2], linB[2], s), 1 / GAMMA);
}
if (x === Math.floor(HALF) || x === Math.floor(HALF) - 1) {
r = 255;
g = 255;
b = 255;
}
put(d, x, y, r, g, b);
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgLinearLightVsSrgbMixSplit.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 05 감마 보정 섞기 — 왼쪽 절반은 값 그대로, 오른쪽 절반은 선형 밝기로 되돌려 섞는다 (같은 보라·주황)
var REDUCE_MS = 800;
var COLOR_A: number[] = [130, 90, 245];
var COLOR_B: number[] = [255, 156, 60];
var GAMMA = 2.2;
var linA: number[] = [Math.pow(COLOR_A[0] / 255, GAMMA), Math.pow(COLOR_A[1] / 255, GAMMA), Math.pow(COLOR_A[2] / 255, GAMMA)];
var linB: number[] = [Math.pow(COLOR_B[0] / 255, GAMMA), Math.pow(COLOR_B[1] / 255, GAMMA), Math.pow(COLOR_B[2] / 255, GAMMA)];
var HALF = CALC_W / 2;
function paint(t: number, d: Uint8ClampedArray): void {
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var right = x >= HALF;
var lx = (right ? x - HALF : x) / (HALF - 1);
// 두 색 사이의 비율 s — 양쪽 절반이 같은 값 s 를 쓴다
var s = smooth(lx + 0.22 * Math.sin(6.2832 * (v * 1.1 + t * 0.5)) + 0.1 * Math.sin(6.2832 * (v * 2.3 - t * 0.35)));
var r: number, g: number, b: number;
if (!right) {
r = lerp(COLOR_A[0], COLOR_B[0], s);
g = lerp(COLOR_A[1], COLOR_B[1], s);
b = lerp(COLOR_A[2], COLOR_B[2], s);
} else {
r = 255 * Math.pow(lerp(linA[0], linB[0], s), 1 / GAMMA);
g = 255 * Math.pow(lerp(linA[1], linB[1], s), 1 / GAMMA);
b = 255 * Math.pow(lerp(linA[2], linB[2], s), 1 / GAMMA);
}
if (x === Math.floor(HALF) || x === Math.floor(HALF) - 1) { r = 255; g = 255; b = 255; }
put(d, x, y, r, g, b);
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgLinearLightVsSrgbMixSplit() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="같은 두 색을 왼쪽은 값 그대로, 오른쪽은 밝기를 선형으로 되돌려 섞고, 가운데 띠가 어둡게 탁해지는 쪽과 맑게 남는 쪽을 한 화면에서 비교한다." />
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>같은 두 색을 왼쪽은 값 그대로, 오른쪽은 밝기를 선형으로 되돌려 섞고, 가운데 띠가 어둡게 탁해지는 쪽과 맑게 남는 쪽을 한 화면에서 비교한다.</p>
</div>
</main>
);
}05 · 素の混合と明るさ補正の混合を並べる — 計測 累積面積 64.516% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var COLOR_A = [130, 90, 245];
var COLOR_B = [255, 156, 60];
var GAMMA = 2.2;
var linA = [Math.pow(COLOR_A[0] / 255, GAMMA), Math.pow(COLOR_A[1] / 255, GAMMA), Math.pow(COLOR_A[2] / 255, GAMMA)];
var linB = [Math.pow(COLOR_B[0] / 255, GAMMA), Math.pow(COLOR_B[1] / 255, GAMMA), Math.pow(COLOR_B[2] / 255, GAMMA)];
var HALF = CALC_W / 2;
function paint(t, d) {
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var right = x >= HALF;
var lx = (right ? x - HALF : x) / (HALF - 1);
// 二色の間の割合 s — 両側の半分が同じ値 s を使う
var s = smooth(lx + 0.22 * Math.sin(6.2832 * (v * 1.1 + t * 0.5)) + 0.1 * Math.sin(6.2832 * (v * 2.3 - t * 0.35)));
var r, g, b;
if (!right) {
r = lerp(COLOR_A[0], COLOR_B[0], s);
g = lerp(COLOR_A[1], COLOR_B[1], s);
b = lerp(COLOR_A[2], COLOR_B[2], s);
}
else {
r = 255 * Math.pow(lerp(linA[0], linB[0], s), 1 / GAMMA);
g = 255 * Math.pow(lerp(linA[1], linB[1], s), 1 / GAMMA);
b = 255 * Math.pow(lerp(linA[2], linB[2], s), 1 / GAMMA);
}
if (x === Math.floor(HALF) || x === Math.floor(HALF) - 1) {
r = 255;
g = 255;
b = 255;
}
put(d, x, y, r, g, b);
}
}
}
06しま模様を消す粒子まぜ
暗い放射状のグラデーションを STEP(10)刻みで丸めて、わざと帯を作ります。左半分は丸めの境目を0.5に固定し、右半分は種2026の乱数表で境目を GRAIN の分だけ揺らして帯の縁を散らします。この場面は累積面積62.0%・平均強度16.0・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="明暗差の小さいメッシュは段階が帯に見えるので、ピクセルごとに種固定のノイズを一段階以内で足し、丸めの境を散らして帯を消す。"></canvas>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">明暗差の小さいメッシュは段階が帯に見えるので、ピクセルごとに種固定のノイズを一段階以内で足し、丸めの境を散らして帯を消す。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 06 알갱이 디더 — 같은 어두운 그라데이션을 8비트보다 거친 STEP 단계로 반올림해, 왼쪽은 그대로 오른쪽은 알갱이를 더한다
var REDUCE_MS = 900;
var STEP = 10;
var GRAIN = 1;
var DARK = [30, 34, 66];
var LIGHT = [112, 100, 170];
var NOISE_N = CALC_W * CALC_H;
var noiseTab = [];
var rnd = seedRand(2026);
for (var ni = 0; ni < NOISE_N; ni += 1) {
noiseTab.push(rnd());
}
function paint(t, d) {
var cx = 0.5 + 0.28 * Math.sin(6.2832 * t * 0.4);
var cy = 0.5 + 0.2 * Math.cos(6.2832 * t * 0.31);
var frame = Math.floor(t * 30);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var dx = (u - cx) * 1.6, dy = v - cy;
var k = 1 - clamp(Math.sqrt(dx * dx + dy * dy) * 1.05, 0, 1);
var right = x >= CALC_W / 2;
var n = noiseTab[(y * CALC_W + x + frame * 997) % NOISE_N];
// 반올림 문턱을 픽셀마다 0.5 ± GRAIN/2 단계 안에서 흔든다 (왼쪽은 문턱 0.5 고정)
var th = right ? 0.5 + (n - 0.5) * GRAIN : 0.5;
var ch = [0, 0, 0];
for (var c = 0; c < 3; c += 1) {
var val = lerp(DARK[c], LIGHT[c], k);
ch[c] = Math.floor(val / STEP + th) * STEP;
}
if (x === Math.floor(CALC_W / 2) || x === Math.floor(CALC_W / 2) - 1) {
ch = [236, 232, 255];
}
put(d, x, y, ch[0], ch[1], ch[2]);
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgGrainDitherBandingRemoval.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 06 알갱이 디더 — 같은 어두운 그라데이션을 8비트보다 거친 STEP 단계로 반올림해, 왼쪽은 그대로 오른쪽은 알갱이를 더한다
var REDUCE_MS = 900;
var STEP = 10;
var GRAIN = 1;
var DARK: number[] = [30, 34, 66];
var LIGHT: number[] = [112, 100, 170];
var NOISE_N = CALC_W * CALC_H;
var noiseTab: number[] = [];
var rnd = seedRand(2026);
for (var ni = 0; ni < NOISE_N; ni += 1) { noiseTab.push(rnd()); }
function paint(t: number, d: Uint8ClampedArray): void {
var cx = 0.5 + 0.28 * Math.sin(6.2832 * t * 0.4);
var cy = 0.5 + 0.2 * Math.cos(6.2832 * t * 0.31);
var frame = Math.floor(t * 30);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var dx = (u - cx) * 1.6, dy = v - cy;
var k = 1 - clamp(Math.sqrt(dx * dx + dy * dy) * 1.05, 0, 1);
var right = x >= CALC_W / 2;
var n = noiseTab[(y * CALC_W + x + frame * 997) % NOISE_N];
// 반올림 문턱을 픽셀마다 0.5 ± GRAIN/2 단계 안에서 흔든다 (왼쪽은 문턱 0.5 고정)
var th = right ? 0.5 + (n - 0.5) * GRAIN : 0.5;
var ch: number[] = [0, 0, 0];
for (var c = 0; c < 3; c += 1) {
var val = lerp(DARK[c], LIGHT[c], k);
ch[c] = Math.floor(val / STEP + th) * STEP;
}
if (x === Math.floor(CALC_W / 2) || x === Math.floor(CALC_W / 2) - 1) { ch = [236, 232, 255]; }
put(d, x, y, ch[0], ch[1], ch[2]);
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgGrainDitherBandingRemoval() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="명암 차가 작은 메쉬는 단계가 띠로 보이는데, 픽셀마다 시드 고정 노이즈를 한 단계 안에서 더해 반올림 경계를 흩어 띠를 지운다." />
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>명암 차가 작은 메쉬는 단계가 띠로 보이는데, 픽셀마다 시드 고정 노이즈를 한 단계 안에서 더해 반올림 경계를 흩어 띠를 지운다.</p>
</div>
</main>
);
}06 · 縞を消す粒子の混ぜ込み — 計測 累積面積 61.95% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var STEP = 10;
var GRAIN = 1;
var DARK = [30, 34, 66];
var LIGHT = [112, 100, 170];
var NOISE_N = CALC_W * CALC_H;
var noiseTab = [];
var rnd = seedRand(2026);
for (var ni = 0; ni < NOISE_N; ni += 1) {
noiseTab.push(rnd());
}
function paint(t, d) {
var cx = 0.5 + 0.28 * Math.sin(6.2832 * t * 0.4);
var cy = 0.5 + 0.2 * Math.cos(6.2832 * t * 0.31);
var frame = Math.floor(t * 30);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = x / (CALC_W - 1);
var dx = (u - cx) * 1.6, dy = v - cy;
var k = 1 - clamp(Math.sqrt(dx * dx + dy * dy) * 1.05, 0, 1);
var right = x >= CALC_W / 2;
var n = noiseTab[(y * CALC_W + x + frame * 997) % NOISE_N];
// 丸めの境目をピクセルごとに0.5 ± GRAIN/2段の中で揺らす(左は境目を0.5に固定)
var th = right ? 0.5 + (n - 0.5) * GRAIN : 0.5;
var ch = [0, 0, 0];
for (var c = 0; c < 3; c += 1) {
var val = lerp(DARK[c], LIGHT[c], k);
ch[c] = Math.floor(val / STEP + th) * STEP;
}
if (x === Math.floor(CALC_W / 2) || x === Math.floor(CALC_W / 2) - 1) {
ch = [236, 232, 255];
}
put(d, x, y, ch[0], ch[1], ch[2]);
}
}
}
07明るさを高さとして読み、光が回るメッシュ
点四つの逆二乗加重メッシュの明るさを二乗して HEIGHT 分の高さとして読み、隣のピクセルとの高さの差から傾きと法線を求めて、2.86秒で一周する光源の拡散光と白い照り(SHINE 24)を加えます。ボタンやカードの表面に光沢をのせるとき、どの値がきらめきを作るのかを一画面で確かめられます。この場面は累積面積71.2%・平均強度28.9・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="メッシュの明るさを高さとみなして隣のピクセルとの差から傾きを求め、動く光源が当たる所に輝きが乗った濡れたゼリーのような面になる。"></canvas>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">メッシュの明るさを高さとみなして隣のピクセルとの差から傾きを求め、動く光源が当たる所に輝きが乗った濡れたゼリーのような面になる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 07 밝기를 높이로 읽는 광택 — 메쉬 밝기 → 높이 → 이웃 차이 기울기 → 움직이는 광원의 확산광·반짝임
var REDUCE_MS = 1000;
var HEIGHT = 0.9;
var SLOPE_GAIN = 0.045;
var SHINE = 24;
var SHEEN = 0.85;
var ASPECT = CALC_W / CALC_H;
var SEED_FX = [0.27, 0.38, 0.33, 0.45];
var SEED_FY = [0.35, 0.29, 0.41, 0.31];
var SEED_PH = [0.0, 0.27, 0.55, 0.8];
var dotX = [0, 0, 0, 0];
var dotY = [0, 0, 0, 0];
var colR = new Float32Array(CALC_W * CALC_H);
var colG = new Float32Array(CALC_W * CALC_H);
var colB = new Float32Array(CALC_W * CALC_H);
var hgt = new Float32Array(CALC_W * CALC_H);
var TONE = [PAL[1], PAL[0], PAL[2], PAL[4]];
function paint(t, d) {
for (var k = 0; k < 4; k += 1) {
dotX[k] = (0.5 + 0.38 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
dotY[k] = 0.5 + 0.38 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.3));
}
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 4; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (dx * dx + dy * dy + 0.004);
wr += w * TONE[j][0];
wg += w * TONE[j][1];
wb += w * TONE[j][2];
ws += w;
}
var i = y * CALC_W + x;
colR[i] = wr / ws;
colG[i] = wg / ws;
colB[i] = wb / ws;
// 밝기(0~1)에서 가까운 점의 가중 비율을 한 번 더 제곱해 높이로 쓴다
var lum = (0.299 * colR[i] + 0.587 * colG[i] + 0.114 * colB[i]) / 255;
hgt[i] = lum * lum * HEIGHT * CALC_H;
}
}
var ang = 6.2832 * t * 0.35;
var lx = Math.cos(ang) * 0.7, ly = Math.sin(ang) * 0.7, lz = 0.6;
var ln = Math.sqrt(lx * lx + ly * ly + lz * lz);
lx /= ln;
ly /= ln;
lz /= ln;
// 반사 벡터 대신 절반 벡터(광원 + 시선)로 반짝임을 잰다
var hx = lx, hy = ly, hz = lz + 1;
var hn = Math.sqrt(hx * hx + hy * hy + hz * hz);
hx /= hn;
hy /= hn;
hz /= hn;
for (var y2 = 0; y2 < CALC_H; y2 += 1) {
var ya = Math.max(0, y2 - 1), yb = Math.min(CALC_H - 1, y2 + 1);
for (var x2 = 0; x2 < CALC_W; x2 += 1) {
var xa = Math.max(0, x2 - 1), xb = Math.min(CALC_W - 1, x2 + 1);
var gx = (hgt[y2 * CALC_W + xa] - hgt[y2 * CALC_W + xb]) * SLOPE_GAIN * 10;
var gy = (hgt[ya * CALC_W + x2] - hgt[yb * CALC_W + x2]) * SLOPE_GAIN * 10;
var nn = Math.sqrt(gx * gx + gy * gy + 1);
var nx = gx / nn, ny = gy / nn, nz = 1 / nn;
var diff = clamp(nx * lx + ny * ly + nz * lz, 0, 1);
var spec = Math.pow(clamp(nx * hx + ny * hy + nz * hz, 0, 1), SHINE) * SHEEN;
var j2 = y2 * CALC_W + x2;
var shade = 0.72 + 0.4 * diff;
put(d, x2, y2, clamp(colR[j2] * shade + 255 * spec, 0, 255), clamp(colG[j2] * shade + 255 * spec, 0, 255), clamp(colB[j2] * shade + 255 * spec, 0, 255));
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgLuminanceHeightNormalSheen.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 07 밝기를 높이로 읽는 광택 — 메쉬 밝기 → 높이 → 이웃 차이 기울기 → 움직이는 광원의 확산광·반짝임
var REDUCE_MS = 1000;
var HEIGHT = 0.9;
var SLOPE_GAIN = 0.045;
var SHINE = 24;
var SHEEN = 0.85;
var ASPECT = CALC_W / CALC_H;
var SEED_FX = [0.27, 0.38, 0.33, 0.45];
var SEED_FY = [0.35, 0.29, 0.41, 0.31];
var SEED_PH = [0.0, 0.27, 0.55, 0.8];
var dotX: number[] = [0, 0, 0, 0];
var dotY: number[] = [0, 0, 0, 0];
var colR = new Float32Array(CALC_W * CALC_H);
var colG = new Float32Array(CALC_W * CALC_H);
var colB = new Float32Array(CALC_W * CALC_H);
var hgt = new Float32Array(CALC_W * CALC_H);
var TONE: number[][] = [PAL[1], PAL[0], PAL[2], PAL[4]];
function paint(t: number, d: Uint8ClampedArray): void {
for (var k = 0; k < 4; k += 1) {
dotX[k] = (0.5 + 0.38 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
dotY[k] = 0.5 + 0.38 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.3));
}
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 4; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (dx * dx + dy * dy + 0.004);
wr += w * TONE[j][0];
wg += w * TONE[j][1];
wb += w * TONE[j][2];
ws += w;
}
var i = y * CALC_W + x;
colR[i] = wr / ws;
colG[i] = wg / ws;
colB[i] = wb / ws;
// 밝기(0~1)에서 가까운 점의 가중 비율을 한 번 더 제곱해 높이로 쓴다
var lum = (0.299 * colR[i] + 0.587 * colG[i] + 0.114 * colB[i]) / 255;
hgt[i] = lum * lum * HEIGHT * CALC_H;
}
}
var ang = 6.2832 * t * 0.35;
var lx = Math.cos(ang) * 0.7, ly = Math.sin(ang) * 0.7, lz = 0.6;
var ln = Math.sqrt(lx * lx + ly * ly + lz * lz);
lx /= ln; ly /= ln; lz /= ln;
// 반사 벡터 대신 절반 벡터(광원 + 시선)로 반짝임을 잰다
var hx = lx, hy = ly, hz = lz + 1;
var hn = Math.sqrt(hx * hx + hy * hy + hz * hz);
hx /= hn; hy /= hn; hz /= hn;
for (var y2 = 0; y2 < CALC_H; y2 += 1) {
var ya = Math.max(0, y2 - 1), yb = Math.min(CALC_H - 1, y2 + 1);
for (var x2 = 0; x2 < CALC_W; x2 += 1) {
var xa = Math.max(0, x2 - 1), xb = Math.min(CALC_W - 1, x2 + 1);
var gx = (hgt[y2 * CALC_W + xa] - hgt[y2 * CALC_W + xb]) * SLOPE_GAIN * 10;
var gy = (hgt[ya * CALC_W + x2] - hgt[yb * CALC_W + x2]) * SLOPE_GAIN * 10;
var nn = Math.sqrt(gx * gx + gy * gy + 1);
var nx = gx / nn, ny = gy / nn, nz = 1 / nn;
var diff = clamp(nx * lx + ny * ly + nz * lz, 0, 1);
var spec = Math.pow(clamp(nx * hx + ny * hy + nz * hz, 0, 1), SHINE) * SHEEN;
var j2 = y2 * CALC_W + x2;
var shade = 0.72 + 0.4 * diff;
put(d, x2, y2,
clamp(colR[j2] * shade + 255 * spec, 0, 255),
clamp(colG[j2] * shade + 255 * spec, 0, 255),
clamp(colB[j2] * shade + 255 * spec, 0, 255));
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgLuminanceHeightNormalSheen() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="메쉬의 밝기를 높이로 보고 이웃 픽셀 차이로 기울기를 구해, 움직이는 광원이 닿는 자리에 반짝임이 얹힌 젖은 젤리 같은 면이 보인다." />
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>메쉬의 밝기를 높이로 보고 이웃 픽셀 차이로 기울기를 구해, 움직이는 광원이 닿는 자리에 반짝임이 얹힌 젖은 젤리 같은 면이 보인다.</p>
</div>
</main>
);
}07 · 明るさを高さとして読み光が巡るメッシュ — 計測 累積面積 71.171% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 4; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (dx * dx + dy * dy + 0.004);
wr += w * TONE[j][0];
wg += w * TONE[j][1];
wb += w * TONE[j][2];
ws += w;
}
var i = y * CALC_W + x;
colR[i] = wr / ws;
colG[i] = wg / ws;
colB[i] = wb / ws;
// 明るさ(0〜1)で近い点の加重の割合をもう一度二乗して高さに使う
var lum = (0.299 * colR[i] + 0.587 * colG[i] + 0.114 * colB[i]) / 255;
hgt[i] = lum * lum * HEIGHT * CALC_H;
}
}
var ang = 6.2832 * t * 0.35;
var lx = Math.cos(ang) * 0.7, ly = Math.sin(ang) * 0.7, lz = 0.6;
var ln = Math.sqrt(lx * lx + ly * ly + lz * lz);
lx /= ln;
ly /= ln;
lz /= ln;
// 反射ベクトルの代わりにハーフベクトル(光源 + 視線)で照りを測る
var hx = lx, hy = ly, hz = lz + 1;
var hn = Math.sqrt(hx * hx + hy * hy + hz * hz);
hx /= hn;
hy /= hn;
hz /= hn;
for (var y2 = 0; y2 < CALC_H; y2 += 1) {
var ya = Math.max(0, y2 - 1), yb = Math.min(CALC_H - 1, y2 + 1);
for (var x2 = 0; x2 < CALC_W; x2 += 1) {
var xa = Math.max(0, x2 - 1), xb = Math.min(CALC_W - 1, x2 + 1);
var gx = (hgt[y2 * CALC_W + xa] - hgt[y2 * CALC_W + xb]) * SLOPE_GAIN * 10;
var gy = (hgt[ya * CALC_W + x2] - hgt[yb * CALC_W + x2]) * SLOPE_GAIN * 10;
var nn = Math.sqrt(gx * gx + gy * gy + 1);
var nx = gx / nn, ny = gy / nn, nz = 1 / nn;
var diff = clamp(nx * lx + ny * ly + nz * lz, 0, 1);
var spec = Math.pow(clamp(nx * hx + ny * hy + nz * hz, 0, 1), SHINE) * SHEEN;
var j2 = y2 * CALC_W + x2;
var shade = 0.72 + 0.4 * diff;
put(d, x2, y2, clamp(colR[j2] * shade + 255 * spec, 0, 255), clamp(colG[j2] * shade + 255 * spec, 0, 255), clamp(colB[j2] * shade + 255 * spec, 0, 255));
}
}
08色相の角度を回して雰囲気が変わるメッシュ
3×3の制御点九つの色相の角度が HUE_SPEED に従って毎秒66〜174度ずつ、三つは逆向きに回り、明るさは DAY_PERIOD(8秒)周期で0.74〜0.84の間を上下します。ソースには夜明けや夕焼けのような時間帯の名前はなく、明るさの曲線が一本あるだけです。この場面は累積面積62.6%・平均強度15.6・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="九つの制御点の色相の角度を別々の速さで回し、明るさも8秒周期で上下させて、同じ形のまま色の雰囲気が移り続けるメッシュを作る。"></canvas>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">九つの制御点の色相の角度を別々の速さで回し、明るさも8秒周期で上下させて、同じ形のまま色の雰囲気が移り続けるメッシュを作る。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 08 색상 각도 순환 — 3×3 제어점마다 서로 다른 속도로 색상 각도를 돌리고, 밝기도 한 주기 동안 낮밤처럼 오르내린다
var REDUCE_MS = 1200;
var SATURATION = 0.68;
var DAY_PERIOD = 8;
var BASE_HUE = [350, 25, 60, 200, 270, 320, 140, 90, 230];
var HUE_SPEED = [96, -135, 78, 174, -84, 123, -162, 66, 150];
var hueCol = [];
for (var hi = 0; hi < 9; hi += 1) {
hueCol.push([0, 0, 0]);
}
// 색상 h(도), 채도 s, 밝기 l → 0~255 RGB
function hslToRgb(h, s, l, out) {
var hh = ((h % 360) + 360) % 360;
var c = (1 - Math.abs(2 * l - 1)) * s;
var xx = c * (1 - Math.abs(((hh / 60) % 2) - 1));
var m = l - c / 2;
var r = 0, g = 0, b = 0;
if (hh < 60) {
r = c;
g = xx;
}
else if (hh < 120) {
r = xx;
g = c;
}
else if (hh < 180) {
g = c;
b = xx;
}
else if (hh < 240) {
g = xx;
b = c;
}
else if (hh < 300) {
r = xx;
b = c;
}
else {
r = c;
b = xx;
}
out[0] = (r + m) * 255;
out[1] = (g + m) * 255;
out[2] = (b + m) * 255;
}
function paint(t, d) {
var phase = 0.5 - 0.5 * Math.cos(6.2832 * t / DAY_PERIOD);
var light = 0.84 - 0.1 * phase;
for (var n = 0; n < 9; n += 1) {
hslToRgb(BASE_HUE[n] + HUE_SPEED[n] * t, SATURATION, light, hueCol[n]);
}
for (var y = 0; y < CALC_H; y += 1) {
var gy = (y / (CALC_H - 1)) * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
for (var x = 0; x < CALC_W; x += 1) {
var gx = (x / (CALC_W - 1)) * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
var a = hueCol[iy * 3 + ix], b = hueCol[iy * 3 + ix + 1], c = hueCol[(iy + 1) * 3 + ix], e = hueCol[(iy + 1) * 3 + ix + 1];
put(d, x, y, lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy), lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy), lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy));
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgPaletteHueCycleDrift.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 08 색상 각도 순환 — 3×3 제어점마다 서로 다른 속도로 색상 각도를 돌리고, 밝기도 한 주기 동안 낮밤처럼 오르내린다
var REDUCE_MS = 1200;
var SATURATION = 0.68;
var DAY_PERIOD = 8;
var BASE_HUE: number[] = [350, 25, 60, 200, 270, 320, 140, 90, 230];
var HUE_SPEED: number[] = [96, -135, 78, 174, -84, 123, -162, 66, 150];
var hueCol: number[][] = [];
for (var hi = 0; hi < 9; hi += 1) { hueCol.push([0, 0, 0]); }
// 색상 h(도), 채도 s, 밝기 l → 0~255 RGB
function hslToRgb(h: number, s: number, l: number, out: number[]): void {
var hh = ((h % 360) + 360) % 360;
var c = (1 - Math.abs(2 * l - 1)) * s;
var xx = c * (1 - Math.abs(((hh / 60) % 2) - 1));
var m = l - c / 2;
var r = 0, g = 0, b = 0;
if (hh < 60) { r = c; g = xx; }
else if (hh < 120) { r = xx; g = c; }
else if (hh < 180) { g = c; b = xx; }
else if (hh < 240) { g = xx; b = c; }
else if (hh < 300) { r = xx; b = c; }
else { r = c; b = xx; }
out[0] = (r + m) * 255;
out[1] = (g + m) * 255;
out[2] = (b + m) * 255;
}
function paint(t: number, d: Uint8ClampedArray): void {
var phase = 0.5 - 0.5 * Math.cos(6.2832 * t / DAY_PERIOD);
var light = 0.84 - 0.1 * phase;
for (var n = 0; n < 9; n += 1) {
hslToRgb(BASE_HUE[n] + HUE_SPEED[n] * t, SATURATION, light, hueCol[n]);
}
for (var y = 0; y < CALC_H; y += 1) {
var gy = (y / (CALC_H - 1)) * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
for (var x = 0; x < CALC_W; x += 1) {
var gx = (x / (CALC_W - 1)) * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
var a = hueCol[iy * 3 + ix], b = hueCol[iy * 3 + ix + 1], c = hueCol[(iy + 1) * 3 + ix], e = hueCol[(iy + 1) * 3 + ix + 1];
put(d, x, y,
lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy),
lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy),
lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy));
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgPaletteHueCycleDrift() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="아홉 제어점의 색상 각도를 서로 다른 속도로 돌리고 밝기도 8초 주기로 오르내려, 같은 형태인데 색 분위기가 계속 바뀌는 메쉬를 만든다." />
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>아홉 제어점의 색상 각도를 서로 다른 속도로 돌리고 밝기도 8초 주기로 오르내려, 같은 형태인데 색 분위기가 계속 바뀌는 메쉬를 만든다.</p>
</div>
</main>
);
}08 · 色相の角度を回して雰囲気が移るメッシュ — 計測 累積面積 62.622% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function paint(t, d) {
var phase = 0.5 - 0.5 * Math.cos(6.2832 * t / DAY_PERIOD);
var light = 0.84 - 0.1 * phase;
for (var n = 0; n < 9; n += 1) {
hslToRgb(BASE_HUE[n] + HUE_SPEED[n] * t, SATURATION, light, hueCol[n]);
}
for (var y = 0; y < CALC_H; y += 1) {
var gy = (y / (CALC_H - 1)) * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
for (var x = 0; x < CALC_W; x += 1) {
var gx = (x / (CALC_W - 1)) * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
var a = hueCol[iy * 3 + ix], b = hueCol[iy * 3 + ix + 1], c = hueCol[(iy + 1) * 3 + ix], e = hueCol[(iy + 1) * 3 + ix + 1];
put(d, x, y, lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy), lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy), lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy));
}
}
}
09見出しの後ろに敷くメッシュのヒーロー
色の点五つの逆距離メッシュに、縁の暗がり(VIGNETTE 0.22)と種99の粒子(GRAIN_AMOUNT 5)を重ねて見出しの背面に敷き、見出しはキャンバスの上の文字として別に載せます。60秒を0.1秒間隔でたどった見出しの位置の背景と文字色(#17141a)の最悪のコントラスト比は7.55:1で、最悪でも7.55:1あり、4.5:1を下回りませんでした。この場面は累積面積67.2%・平均強度23.1・23/23フレームです。

<main class="stage">
<div class="mg is-demo" id="mgRoot">
<canvas class="mg__canvas" id="mgCanvas" width="160" height="100" tabindex="0" aria-label="動くメッシュと粒子、縁の暗がりを重ねて見出しの後ろに敷き、文字の下の色が変わっても読みやすい明るさの差が保たれるかを一場面で見る。"></canvas>
<div class="mg__hero">
<h1 class="mg__title">やわらかな色が流れる最初の画面</h1>
<p class="mg__sub">後ろでメッシュが動き続けても読めるかを見る</p>
</div>
<p class="mg__status" id="mgStatus" aria-live="polite">デモ実行中</p>
<button class="mg__btn" id="mgBtn" type="button">一時停止</button>
<p class="mg__caption">動くメッシュと粒子、縁の暗がりを重ねて見出しの後ろに敷き、文字の下の色が変わっても読みやすい明るさの差が保たれるかを一場面で見る。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.mg {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, .85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 70%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.mg__hero {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: $sp-2;
padding: $sp-4;
text-align: center;
pointer-events: none;
}
.mg__title {
margin: 0;
color: $subject-ink;
font-weight: 700;
font-size: clamp(16px, 4.6vw, 28px);
line-height: 1.3;
text-shadow: 0 0 12px rgba(255, 255, 255, .6);
}
.mg__sub {
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: clamp(11px, 2.4vw, 14px);
line-height: 1.5;
text-shadow: 0 0 10px rgba(255, 255, 255, .6);
}
@media (max-width: 340px) {
.mg__caption, .mg__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.mg__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mg { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.mg__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.mg__canvas:focus-visible {
outline: 2px solid rgba(23, 20, 26, 0.85);
outline-offset: -6px;
}
.mg__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 70%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.mg__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.mg__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.mg__hero {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 8px;
padding: 16px;
text-align: center;
pointer-events: none;
}
.mg__title {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(16px, 4.6vw, 28px);
line-height: 1.3;
text-shadow: 0 0 12px rgba(255, 255, 255, 0.6);
}
.mg__sub {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: clamp(11px, 2.4vw, 14px);
line-height: 1.5;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.6);
}
@media (max-width: 340px) {
.mg__caption, .mg__btn {
font-size: 10px;
padding: 4px 8px;
}
.mg__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg {
cursor: default;
}
}(function () {
var root = document.getElementById('mgRoot');
var canvas = document.getElementById('mgCanvas');
var statusEl = document.getElementById('mgStatus');
var btnEl = document.getElementById('mgBtn');
function main(root, canvas, statusEl, btnEl) {
var ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
var ctx = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce)
root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg) { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed) {
var s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal, pos, out) {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d, x, y, r, g, b) {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 09 제목 뒤 히어로 — 역거리 메쉬 + 시드 고정 알갱이 + 가장자리 어둠. 글자 쪽(가운데)은 밝게 둔다
var REDUCE_MS = 1000;
var POWER = 2;
var VIGNETTE = 0.22;
var GRAIN_AMOUNT = 5;
var ASPECT = CALC_W / CALC_H;
var SEED_FX = [0.23, 0.34, 0.29, 0.41, 0.31];
var SEED_FY = [0.3, 0.26, 0.37, 0.28, 0.35];
var SEED_PH = [0.0, 0.21, 0.43, 0.62, 0.81];
var HERO_PAL = [PAL[1], PAL[2], PAL[0], PAL[3], PAL[5]];
var dotX = [0, 0, 0, 0, 0];
var dotY = [0, 0, 0, 0, 0];
var NOISE_N = CALC_W * CALC_H;
var noiseTab = [];
var rnd = seedRand(99);
for (var ni = 0; ni < NOISE_N; ni += 1) {
noiseTab.push(rnd());
}
function paint(t, d) {
for (var k = 0; k < 5; k += 1) {
dotX[k] = (0.5 + 0.42 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.6));
}
var halfP = POWER / 2;
var frame = Math.floor(t * 30);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 5; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + 0.003);
wr += w * HERO_PAL[j][0];
wg += w * HERO_PAL[j][1];
wb += w * HERO_PAL[j][2];
ws += w;
}
// 가장자리 어둠 — 중심에서 멀수록 곱하는 값이 1 에서 1 - VIGNETTE 로 내려간다
var ex = (x / (CALC_W - 1) - 0.5) * 2, ey = (y / (CALC_H - 1) - 0.5) * 2;
var vig = 1 - VIGNETTE * smooth((Math.sqrt(ex * ex * 0.6 + ey * ey) - 0.55) / 0.9);
var grain = (noiseTab[(y * CALC_W + x + frame * 991) % NOISE_N] - 0.5) * GRAIN_AMOUNT;
put(d, x, y, clamp((wr / ws) * vig + grain, 0, 255), clamp((wg / ws) * vig + grain, 0, 255), clamp((wb / ws) * vig + grain, 0, 255));
}
}
}
function draw() {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs = null;
var accMs = 0;
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton() {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('一場面ずつ送っています');
return;
}
paused = !paused;
btnEl.textContent = paused ? '再生' : '一時停止';
setStatus(paused ? '一時停止中' : 'デモ実行中');
}
draw();
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function () { onButton(); });
listen(canvas, 'keydown', function (e) {
var k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
}
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '次の場面';
setStatus('一場面ずつ送っています');
}
else {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(root, canvas, statusEl, btnEl);
})();import { useEffect, useRef } from "react";
import styles from "./MgHeroBackdropVignetteScene.module.scss";
function main(root: HTMLDivElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
var ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
var ctx: CanvasRenderingContext2D = ctx0;
var CALC_W = 160, CALC_H = 100;
var FIXED_DT = 16;
var REDUCE_STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var paused = false;
var elapsed = 0, rafId = 0;
var cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { var c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function setStatus(msg: string): void { statusEl.textContent = msg; }
// 시드 고정 난수 (mulberry32) — 같은 시드는 언제나 같은 수열이다
function seedRand(seed: number): () => number {
var s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
var z = s;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// 파스텔 여섯 색 — 분홍·라일락·하늘·민트·살구·버터
var PAL: number[][] = [[255, 152, 178], [182, 152, 255], [122, 200, 255], [122, 222, 186], [255, 196, 140], [255, 228, 130]];
// 색 목록을 원형으로 읽는다. pos 의 정수부는 몇 번째 색, 소수부는 다음 색으로 넘어가는 정도
function pick(pal: number[][], pos: number, out: number[]): void {
var n = pal.length;
var i0 = Math.floor(pos);
var f = pos - i0;
var a = pal[((i0 % n) + n) % n];
var b = pal[(((i0 + 1) % n) + n) % n];
out[0] = lerp(a[0], b[0], f);
out[1] = lerp(a[1], b[1], f);
out[2] = lerp(a[2], b[2], f);
}
function put(d: Uint8ClampedArray, x: number, y: number, r: number, g: number, b: number): void {
var o = (y * CALC_W + x) * 4;
d[o] = r;
d[o + 1] = g;
d[o + 2] = b;
d[o + 3] = 255;
}
canvas.width = CALC_W;
canvas.height = CALC_H;
var img = ctx.createImageData(CALC_W, CALC_H);
var px = img.data;
// 09 제목 뒤 히어로 — 역거리 메쉬 + 시드 고정 알갱이 + 가장자리 어둠. 글자 쪽(가운데)은 밝게 둔다
var REDUCE_MS = 1000;
var POWER = 2;
var VIGNETTE = 0.22;
var GRAIN_AMOUNT = 5;
var ASPECT = CALC_W / CALC_H;
var SEED_FX = [0.23, 0.34, 0.29, 0.41, 0.31];
var SEED_FY = [0.3, 0.26, 0.37, 0.28, 0.35];
var SEED_PH = [0.0, 0.21, 0.43, 0.62, 0.81];
var HERO_PAL: number[][] = [PAL[1], PAL[2], PAL[0], PAL[3], PAL[5]];
var dotX: number[] = [0, 0, 0, 0, 0];
var dotY: number[] = [0, 0, 0, 0, 0];
var NOISE_N = CALC_W * CALC_H;
var noiseTab: number[] = [];
var rnd = seedRand(99);
for (var ni = 0; ni < NOISE_N; ni += 1) { noiseTab.push(rnd()); }
function paint(t: number, d: Uint8ClampedArray): void {
for (var k = 0; k < 5; k += 1) {
dotX[k] = (0.5 + 0.42 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.6));
}
var halfP = POWER / 2;
var frame = Math.floor(t * 30);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 5; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + 0.003);
wr += w * HERO_PAL[j][0];
wg += w * HERO_PAL[j][1];
wb += w * HERO_PAL[j][2];
ws += w;
}
// 가장자리 어둠 — 중심에서 멀수록 곱하는 값이 1 에서 1 - VIGNETTE 로 내려간다
var ex = (x / (CALC_W - 1) - 0.5) * 2, ey = (y / (CALC_H - 1) - 0.5) * 2;
var vig = 1 - VIGNETTE * smooth((Math.sqrt(ex * ex * 0.6 + ey * ey) - 0.55) / 0.9);
var grain = (noiseTab[(y * CALC_W + x + frame * 991) % NOISE_N] - 0.5) * GRAIN_AMOUNT;
put(d, x, y,
clamp((wr / ws) * vig + grain, 0, 255),
clamp((wg / ws) * vig + grain, 0, 255),
clamp((wb / ws) * vig + grain, 0, 255));
}
}
}
function draw(): void {
paint(elapsed / 1000, px);
ctx.putImageData(img, 0, 0);
}
var lastTs: number | null = null;
var accMs = 0;
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
}
draw();
}
rafId = requestAnimationFrame(tick);
}
function onButton(): void {
root.classList.remove('is-demo');
if (reduce) {
elapsed += REDUCE_STEP_MS;
draw();
setStatus('한 장면씩 넘기는 중');
return;
}
paused = !paused;
btnEl.textContent = paused ? '재생' : '멈춤';
setStatus(paused ? '일시정지됨' : '시연 중');
}
draw();
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (): void { onButton(); });
listen(canvas, 'keydown', function (e: Event): void {
var k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') { e.preventDefault(); onButton(); }
});
if (reduce) {
elapsed = REDUCE_MS;
draw();
btnEl.textContent = '다음 장면';
setStatus('한 장면씩 넘기는 중');
} else {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function MgHeroBackdropVignetteScene() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mg} is-demo`} ref={rootRef}>
<canvas className={styles.mg__canvas} ref={canvasRef} width={160} height={100} tabIndex={0} aria-label="움직이는 메쉬와 알갱이, 가장자리 어둠을 합쳐 제목 뒤에 깔고, 글자 밑의 색이 바뀌어도 읽기 쉬운 밝기 차가 유지되는지 한 장면으로 본다." />
<div className={styles.mg__hero}>
<h1 className={styles.mg__title}>부드러운 색이 흐르는 첫 화면</h1>
<p className={styles.mg__sub}>글자 뒤에서 메쉬가 계속 움직여도 읽히는지 본다</p>
</div>
<p className={styles.mg__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.mg__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.mg__caption}>움직이는 메쉬와 알갱이, 가장자리 어둠을 합쳐 제목 뒤에 깔고, 글자 밑의 색이 바뀌어도 읽기 쉬운 밝기 차가 유지되는지 한 장면으로 본다.</p>
</div>
</main>
);
}09 · 見出しの後ろに敷くメッシュのヒーロー — 計測 累積面積 67.238% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function paint(t, d) {
for (var k = 0; k < 5; k += 1) {
dotX[k] = (0.5 + 0.42 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.6));
}
var halfP = POWER / 2;
var frame = Math.floor(t * 30);
for (var y = 0; y < CALC_H; y += 1) {
var v = y / (CALC_H - 1);
for (var x = 0; x < CALC_W; x += 1) {
var u = (x / (CALC_W - 1)) * ASPECT;
var wr = 0, wg = 0, wb = 0, ws = 0;
for (var j = 0; j < 5; j += 1) {
var dx = u - dotX[j], dy = v - dotY[j];
var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + 0.003);
wr += w * HERO_PAL[j][0];
wg += w * HERO_PAL[j][1];
wb += w * HERO_PAL[j][2];
ws += w;
}
// 縁の暗がり — 中心から遠いほど掛ける値が1から1 - VIGNETTEへ下がる
var ex = (x / (CALC_W - 1) - 0.5) * 2, ey = (y / (CALC_H - 1) - 0.5) * 2;
var vig = 1 - VIGNETTE * smooth((Math.sqrt(ex * ex * 0.6 + ey * ey) - 0.55) / 0.9);
var grain = (noiseTab[(y * CALC_W + x + frame * 991) % NOISE_N] - 0.5) * GRAIN_AMOUNT;
put(d, x, y, clamp((wr / ws) * vig + grain, 0, 255), clamp((wg / ws) * vig + grain, 0, 255), clamp((wb / ws) * vig + grain, 0, 255));
}
}
}
値が場面を変えるところ — 制御点の数・にじみの指数・粒子の強さ
場面の印象を変える値は三つあります。制御点がどれだけ揺れるか(02の SWAY)、色の点の周りがどれだけにじむか(03の POWER)、帯を散らす粒子がどれだけ強いか(06の GRAIN)です。同じ瞬間の二つの画面をNodeで paint に定数だけ変えて比べると、SWAY を0.24に上げればピクセルがチャンネルあたり平均5.9〜6.6、0.30なら8.9〜9.8変わり、POWER が1なら画面の色のばらつきは16〜19に減って4なら34〜38に増えます。最後のばらつきは255段階の値の標準偏差です。
色が巡る速さの定数(CORNER_RATE の2・NODE_RATE の1.2・HUE_SPEED の66〜174度)は、わざと速く置いてあります。落ち着いた背景に使うなら、この値から下げてください。
場面ごとの測定値は次の表にまとめました。01・06・08の平均強度は15.9・16.0・15.6で基準の15にいちばん近く、九つの中でフレーム間の差が小さい側の場面です。九つとも23フレームすべてで動きました。
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 四隅の双線形 | 60.9% | 15.9 | 23/23 |
| 02 3×3の制御点 | 69.1% | 16.5 | 23/23 |
| 03 逆距離のにじみ | 70.2% | 25.5 | 23/23 |
| 04 ノイズのゆがみ | 65.6% | 18.1 | 23/23 |
| 05 ガンマ混ぜの比較 | 64.5% | 19.2 | 23/23 |
| 06 粒子まぜ | 62.0% | 16.0 | 23/23 |
| 07 高さと光沢 | 71.2% | 28.9 | 23/23 |
| 08 色相の角度の巡り | 62.6% | 15.6 | 23/23 |
| 09 見出し裏のヒーロー | 67.2% | 23.1 | 23/23 |
どこで壊れるか — 解像度の増加・濁った中央・暗い面の帯
中央が濁る場合
01の中央は四色の平均なので隅より濁ります。05がその程度を数字で見せていて、同じ二色の帯の中央の明るさは、値のまま混ぜると146.4、明るさを線形に直して混ぜると155.1です。差は約9と控えめなので、大きな変化を期待すると肩すかしになります。05の紫とオレンジは濁りが目に入るように選んだ彩度の高い色で、この記事のパステルのパレットとは別物です。08では、反対向きに回る制御点の間が補色の近くを通るときに彩度が抜けます。色相を回しても混ぜるのはRGBの値なので、反対側の色どうしの中間は灰色に寄るためです。
暗い面に帯が出る場合
06の STEP の10は、実際の画面の一段(255のうち1)ではなく、帯が目に見えるようにわざと粗くした値です。粒子の GRAIN を1から1.5に上げると、8×8の箱の平均誤差は0.32から0.46へかえって大きくなります。境目が一段の中だけで揺れる仕組みなので、1を超える粒子はその範囲をはみ出すからです。
読む位置が端の外に出る場合
02の SWAY や04の WARP を大きくすると、読む位置が0〜1の外に出て、clamp が端の色をそのまま引き伸ばします。03は色の点の真上でその点の色がほぼそのまま残り、POWER が大きいほどとがります。07は明るさだけを高さとして読むので、色が違っても明るさが同じなら平らに見えます。
測定が動きを見逃す場合
ヘッドレスの描画でフレーム間の実際の間隔は約130ミリ秒でした。最初に色の巡りを毎秒0.6にしたところ、累積面積は58%出たのに動いたフレームが0/23でした。巡りを smooth で包んだときも色の升の端で止まってフレームが空いたため、pick を線形補間に変えて速さを上げました。
解像度を上げると計算が増える
CALC_W・CALC_H を320×200に広げるとマス数は4倍です。03はピクセルごとに Math.pow を六回呼ぶので、負担がいちばん大きくふくらみます。定数を自分で書き換えて解像度を比べたいときは、パスワード w432zjum でzipを開いてください。
変形3つとアクセシビリティ
| 名前 | 値 | 感じ |
|---|---|---|
| 制御点の揺れ幅を広げる | 02 SWAY 0.12 → 0.30 | 揺れ幅を0.12から0.30に広げると、同じ瞬間の画面と比べてピクセル値がチャンネルあたり平均8.9〜9.8(255中)、最大で56〜75変わり、色の境が大きくうねってマス目の継ぎ目付近の曲がりが目に入る。 |
| にじみの指数を変える | 03 POWER 2 → 4 | 指数を2から4に上げると画面全体の色のばらつきが約28から約35に増えて色の点の周りがくっきりした円に近づき、1に下げると約17に減って画面が一つの色調にぼやける。 |
| 粒子の強さを調整する | 06 GRAIN 1 → 0 | 粒子の強さを1から0に下げると、右半分で同じ色が横に続く長さが平均1.6pxから5.6pxに伸び、粒子のない左半分の6.6pxに近い帯がまた現れる。 |
prefers-reduced-motionが有効なとき、九つの場面はどれも requestAnimationFrame のループを回さず、場面ごとに決めた時刻(REDUCE_MS、700〜1200ミリ秒)の一場面をすぐに描きます。このときボタンは「次の場面」になり、押すたびに REDUCE_STEP_MS の900ミリ秒だけ時刻を進めて描き直し、状態の行(aria-live="polite")が一場面ずつ進めている状態を知らせます。動きが有効な状態では、ボタンかキャンバスを押すか、キャンバスでEnterまたはSpaceを押すと一時停止と再生が切り替わり、状態の行が「実演中」と「一時停止中」を知らせます。canvasに tabindex の0を付け、停止ボタンを button 要素にしてあるので、マウスなしでキーボードだけでも止めて再開できます。
メッシュグラデーションは CSS だけで作れないのか?
近いものをまねることはできます。放射状のグラデーションを何枚か重ねて位置をずらす方法が代表的ですが、この記事の計算はマスごとに重みを決め、色を読む座標をゆがめ、明るさを補正する作業なので、その道具では一段足りません。メッシュグラデーション cssだけで足りる場面はCSS 背景 アニメーション 9選 — 一行でコピペOKにまとめてあり、重なった層が奥行きを作る方法はcss パララックス 背景 9選 — 奥行き3Dシーンで見られます。
使っている描画関数はCanvas APIのドキュメントとputImageDataのドキュメントで読めます。
メッシュグラデーションを使う前によくある質問
メッシュグラデーションの背景を自分のトップページに貼るには、まず何を変えますか?
09を開いて、HERO_PAL の色の点五つと VIGNETTE を変えてください。見出しの位置の下を暗い色が通るとコントラスト比が4.5:1を下回ることがあるので、変えたあとは60秒ほど回して文字が読めるか確かめます。
外部のライブラリや画像は必要ですか?
必要ありません。九つの場面はキャンバス2dと一つのファイルのスクリプトだけで動き、外部アドレスを呼ぶ行もないので、ファイル一つを移すだけで場面がそのまま再生されます。ジェネレーターのようにスライダーで色を決める道具とは違い、調整はファイルの先頭の定数を書き換えて行います。
場面がカクついたり中央が濁って見えたりしたら、どこを直しますか?
カクつくときは CALC_W・CALC_H を減らしてマス数を下げ、03のように重い場面は点の数から減らします。濁って見えるときは、05のように明るさを線形に直して混ぜるか、反対向きに回る色を減らします。









