메쉬 그라데이션 애니메이션 9가지 — 색점 보간부터 노이즈 비틀기까지 JS 계산 장면
메쉬 그라데이션은 색 몇 개를 번지게 하면 끝날 것 같지만, 그대로 섞으면 가운데가 탁해지고 어두운 면은 띠로 갈라진다 — 05번과 06번이 그 두 군데를 하나씩 막는다. 아래 아홉 장면은 캔버스의 픽셀을 자바스크립트로 하나씩 계산해 그렸고, 파일을 내려받아 붙이면 바로 움직인다.
메쉬 그라데이션 아홉 장면을 화면에서 바로 본다
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 네 모서리 색을 보간하는 기본 메쉬
- 02 3×3 제어점이 떠다니는 부드러운 메쉬
- 03 거리 역수로 색을 섞는 번짐 메쉬
- 04 노이즈로 좌표를 비틀어 흐르는 메쉬
- 05 그냥 섞기와 밝기 보정 섞기를 나란히
- 06 줄무늬를 지우는 알갱이 섞기
- 07 밝기를 높이로 읽어 빛이 도는 메쉬
- 08 색상 각도를 돌려 분위기가 바뀌는 메쉬
- 09 제목 뒤에 깔리는 메쉬 히어로
메쉬 그라데이션 애니메이션 9가지 — 픽셀마다 계산한 색이 화면을 채운다
아홉 장면은 같은 틀 위에 서 있다. 캔버스 안쪽은 160×100 칸(CALC_W·CALC_H)이고, 프레임마다 createImageData 로 만든 ImageData 에 칸 16,000개의 색을 하나씩 적어 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가지 — 캔버스 플라즈마 배경 만들기를 곁들여 보면 계산으로 색을 정하는 방식의 폭이 보인다.
기법별 코드로 보는 색 섞기
아래 조각은 데모 소스의 연속된 줄을 순서 그대로 잘라 붙였고, 완결된 파일은 압축 파일에 있다. 색 목록을 원형으로 읽는 pick(pal, pos, out) 은 pos 의 정수부로 색을 고르고 소수부만큼 다음 색으로 선형 보간하는 도우미라서, 01·02 의 색 순환이 이 함수에서 나온다(08 은 이 함수를 부르지 않고 HUE_SPEED 로 색상 각도를 직접 돌린다).
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 이고, 파스텔 색만 쓰므로 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 이다. 차이는 크지 않아서 한눈에 드러나는 변화를 기대하기는 어렵다. 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 으로 열면 된다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 제어점 흔들림 폭 키우기 | 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.6 px 에서 5.6 px 로 늘어나, 알갱이가 없는 왼쪽 절반의 6.6 px 에 가까운 띠가 다시 나타난다. |
prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 돌리지 않고, 장면마다 정해 둔 시각(REDUCE_MS, 700~1200밀리초)의 한 장면을 즉시 그린다. 이때 버튼은 「다음 장면」 이 되어 누를 때마다 REDUCE_STEP_MS 900밀리초만큼 시각을 넘겨 즉시 다시 그리고, 상태 줄(aria-live="polite")이 「한 장면씩 넘기는 중」 으로 알린다. 움직임을 켠 상태에서는 버튼이나 캔버스를 누르거나 캔버스에서 Enter·Space 를 눌러 멈춤과 재생을 바꾸고, 상태 줄이 「시연 중」 과 「일시정지됨」 으로 알린다. 캔버스에 tabindex 0 을 주고 멈춤 버튼을 button 요소로 만들어 두었으니 마우스 없이 키보드만으로도 멈추고 다시 돌린다.
메쉬 그라데이션은 CSS 만으로 만들 수 없는가?
비슷하게 흉내 낼 수는 있다. 방사형 그라데이션 몇 장을 겹쳐 위치를 옮기는 식이 대표적인데, 이 글의 계산은 칸마다 가중치를 정하고 색을 읽는 좌표를 비틀고 밝기를 보정하는 일이라 그 도구로는 한 겹 부족하다. css 배경 그라데이션 애니메이션만으로 충분한 장면은 CSS 배경 애니메이션 9종 — 한 줄 복붙으로에 모았고, 겹친 층이 깊이를 만드는 방식은 css 배경 애니메이션 효과 9가지 — 깊이 층 3D 장면에서 볼 수 있다.
쓰인 그리기 함수는 Canvas API 문서와 putImageData 문서에서 읽을 수 있다.
메쉬 그라데이션을 쓰기 전에 자주 묻는 질문
메시 그라데이션 배경을 내 첫 화면에 붙이려면 무엇부터 바꾸나?
09 를 열어 HERO_PAL 의 색점 다섯 개와 VIGNETTE 를 바꾸면 된다. 제목 자리 밑에 어두운 색이 지나가면 대비가 4.5:1 아래로 내려갈 수 있으니, 바꾼 뒤에는 60초쯤 돌려 글자가 읽히는지 본다.
외부 라이브러리나 이미지가 필요한가?
필요 없다. 아홉 장면 모두 캔버스 2d 와 한 파일의 스크립트로만 돌아가고, 외부 주소를 부르는 줄도 없어서 파일 하나만 옮겨도 장면이 그대로 재생된다.
장면이 끊기거나 가운데가 탁해 보이면 어디를 고치나?
끊기면 CALC_W·CALC_H 를 줄여 칸 수를 낮추고, 03 처럼 무거운 장면은 점 수부터 줄인다. 탁해 보이면 05 처럼 밝기를 선형으로 바꿔 섞거나 서로 반대 방향으로 도는 색을 줄인다.









