04빛 번짐 — 필드 값을 명도로 읽는 글로우 블룸
필드 값을 임계위·아래 어디서나 명도에 그대로 대응시키고 lighter 합성으로 겹치면 볼이 가까워질수록 내부가 밝아지다 합체 순간 flare 로 터지는 발광 액체가 됩니다.
쓰임다크 모드 히어로·음악 서비스 배경
createRadialGradientglobalCompositeOperationrequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>임계값 위·아래 어디서나 필드 값을 명도로 읽어 lighter 로 겹칩니다 — 볼이 가까워질수록 내부가 밝아지다 합체 순간 플레어로 터지는 발광 액체입니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="빛 번짐 글로우 블룸 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">볼 <b class="cd__val" id="cdN">6</b>개 · 광원 배율 <b class="cd__val">2.6</b></span>
<span class="cd__meta cd__meta--dim">canvas · createRadialGradient · lighter</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 빛 번짐 글로우 블룸(04). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(필드→명도 radialGradient · lighter 합성 · 플레어)가 만든다.
// 무대는 잉크(04 = ink). 파스텔 고정 팔레트를 --glow-* 로 편다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(317 전 항목 동일 — JS 가 --metaball-* 로 읽는다) ──
$meta-ink: #1b1722; // 어스름 무대 — 잉크보다 한 단계 보라 기운
$meta-dusk: #4a4258; // 깊은 안개 — 배경 숨결
$meta-core: #f3eaff; // 플레어 코어 — 화이트-라일락
$meta-lilac: #b9a0ff; // 발광 기본 — 라일락
$meta-mint: #b9ddd1; // 플레어 확산 — 민트
$meta-pink: #ffb9c8; // 플레어 고리 — 핑크
$meta-mist: #d8dce6; // 밤 먼지·보조 글자
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --glow-* 를 받아 쓴다
--glow-ink: #{$meta-ink};
--glow-dusk: #{$meta-dusk};
--glow-core: #{$meta-core};
--glow-lilac: #{$meta-lilac};
--glow-mint: #{$meta-mint};
--glow-pink: #{$meta-pink};
--glow-mist: #{$meta-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $meta-mist; // 잉크 무대 위 — 먼 먼지 톤 글자
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $meta-ink;
box-shadow: inset 0 0 0 1px rgba($meta-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $meta-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($meta-mist, .72); }
.cd__val { color: $meta-lilac; font-weight: 700; } // 잉크 위 라일락 라벨
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 합체 직전 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}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);
}
.cd {
--glow-ink: #1b1722;
--glow-dusk: #4a4258;
--glow-core: #f3eaff;
--glow-lilac: #b9a0ff;
--glow-mint: #b9ddd1;
--glow-pink: #ffb9c8;
--glow-mist: #d8dce6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1722;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #b9a0ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 빛 번짐 글로우 블룸(04). 메타볼 필드 r²/(r²+d²) 을 임계값으로 자르지 않고 명도에 그대로 대응시킨다.
// createRadialGradient 정지점마다 falloff 값에서 색·알파를 계산해 넣고 globalCompositeOperation="lighter"
// 로 겹치면 필드가 더해지는 셈이라 볼이 다가올수록 내부가 밝아진다. 두 볼이 합체 직전이 되면 사이 중점에
// 플레어(코어·민트 확산 + 핑크 고리)가 터진다. 어두운 무대 안개·먼지가 배경도 느리게 숨쉬게 한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N_BALLS = 7; // 볼 수
var GLOW = 3.0; // 광원 배율 — 시각 광원 반경이 볼 반경의 몇 배인지
var FLARE = 1.0; // 합체 플레어 강도
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 흐름으로 시작한다
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x317A04);
// 색은 CSS 커스텀 프로퍼티(--glow-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--glow-ink');
var C_DUSK = tokRGB('--glow-dusk');
var C_CORE = tokRGB('--glow-core');
var C_LILA = tokRGB('--glow-lilac');
var C_MINT = tokRGB('--glow-mint');
var C_PINK = tokRGB('--glow-pink');
var C_MIST = tokRGB('--glow-mist');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var balls = [], fogs = [], specks = [];
function build() {
balls = [];
var cols = 3, rows = 2;
for (var i = 0; i < N_BALLS; i++) {
var gx = ((i % cols) + 0.5) / cols, gy = (Math.floor(i / cols) + 0.5) / rows;
var ang = rand() * TAU, spd = 15 + rand() * 19;
balls.push({
x: gx * W, y: gy * H,
r: 17 + rand() * 14,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
ph: rand() * TAU,
k: 0
});
}
fogs = [];
for (var f = 0; f < 4; f++) {
fogs.push({ x: rand() * W, y: rand() * H, R: 130 + rand() * 90, vx: (rand() - 0.5) * 14, ph: rand() * TAU });
}
specks = [];
for (var s = 0; s < 24; s++) {
specks.push({ rx: rand(), ry: rand() * 0.85, ph: rand() * TAU, spd: 0.8 + rand() * 1.6 });
}
}
// 메타볼 필드 — u = d/r 로 볼 때 r²/(r²+d²) = 1/(1+u²). 임계값 없이 전 구간을 쓴다
function falloff(u) {
var q = u * GLOW;
return 1 / (1 + q * q);
}
// 주변 볼과의 접근도(0..1) — 가까워질수록 내부가 밝아진다
function heat(b) {
var s = 0;
for (var j = 0; j < balls.length; j++) {
var o = balls[j];
if (o === b) continue;
var d = Math.hypot(o.x - b.x, o.y - b.y);
var lim = (o.r + b.r) * 1.5;
if (d < lim) s += 1 - d / lim;
}
return Math.min(1, s);
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function mix(c1, c2, k) {
return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}
function update(t, dt) {
var m = 24;
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
// 배회 — 속도 방향을 천천히 휘게 한다(관성은 유지)
var ang = Math.atan2(b.vy, b.vx) + Math.sin(t * 0.6 + b.ph) * dt * 1.2;
var spd = Math.min(34, Math.hypot(b.vx, b.vy) + dt * 4);
b.vx = Math.cos(ang) * spd; b.vy = Math.sin(ang) * spd;
b.x += b.vx * dt; b.y += b.vy * dt;
if (b.x < m) { b.x = m; b.vx = Math.abs(b.vx); }
if (b.x > W - m) { b.x = W - m; b.vx = -Math.abs(b.vx); }
if (b.y < m) { b.y = m; b.vy = Math.abs(b.vy); }
if (b.y > H - m) { b.y = H - m; b.vy = -Math.abs(b.vy); }
b.k += (heat(b) - b.k) * Math.min(1, dt * 5);
}
for (var f = 0; f < fogs.length; f++) {
var fg = fogs[f];
fg.x += fg.vx * dt;
if (fg.x < -fg.R) fg.x = W + fg.R;
if (fg.x > W + fg.R) fg.x = -fg.R;
}
}
function draw(t) {
// 무대 — 잉크
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba(C_INK, 1);
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
// 안개 — 낮은 알파의 큰 방사가 배경도 느리게 바꾼다
for (var f = 0; f < fogs.length; f++) {
var fg = fogs[f];
var fy = fg.y + Math.sin(t * 0.22 + fg.ph) * 16;
var g = ctx.createRadialGradient(fg.x, fy, 0, fg.x, fy, fg.R);
g.addColorStop(0, rgba(C_DUSK, 0.2));
g.addColorStop(1, rgba(C_DUSK, 0));
ctx.fillStyle = g;
ctx.fillRect(fg.x - fg.R, fy - fg.R, fg.R * 2, fg.R * 2);
}
// 발광 볼 — 필드 falloff 를 명도(알파·색)로 그대로 얹는다
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
var R = b.r * GLOW;
var g2 = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, R);
for (var s = 0; s <= 4; s++) {
var u = s / 4;
var fv = falloff(u);
var a = Math.min(1, Math.pow(fv, 1.35) * (1 - u * u) * (0.8 + 0.28 * b.k));
var c = fv > 0.5 ? mix(C_LILA, C_CORE, (fv - 0.5) / 0.5) : mix(C_DUSK, C_LILA, fv / 0.5);
g2.addColorStop(u, rgba(c, a));
}
ctx.fillStyle = g2;
ctx.fillRect(b.x - R, b.y - R, R * 2, R * 2);
}
// 플레어 — 합체 직전 두 볼 사이에서 터지는 빛
for (var p = 0; p < balls.length; p++) {
for (var q = p + 1; q < balls.length; q++) {
var a1 = balls[p], b1 = balls[q];
var d = Math.hypot(a1.x - b1.x, a1.y - b1.y);
var lim = (a1.r + b1.r) * 1.6;
if (d >= lim) continue;
var e = 1 - d / lim;
e = e * e;
var mx = (a1.x + b1.x) / 2, my = (a1.y + b1.y) / 2;
var pulse = 0.72 + 0.28 * Math.sin(t * 8 + p * 2.1);
var fr = 20 + d * 0.55 + 42 * e;
var g3 = ctx.createRadialGradient(mx, my, 0, mx, my, fr);
g3.addColorStop(0, rgba(C_CORE, 0.5 * e * pulse * FLARE + 0.04 * e));
g3.addColorStop(0.45, rgba(C_MINT, 0.26 * e * pulse));
g3.addColorStop(1, rgba(C_PINK, 0));
ctx.fillStyle = g3;
ctx.fillRect(mx - fr, my - fr, fr * 2, fr * 2);
ctx.beginPath();
ctx.arc(mx, my, 10 + 26 * e, 0, TAU);
ctx.strokeStyle = rgba(C_PINK, 0.34 * e * pulse);
ctx.lineWidth = 1.2;
ctx.stroke();
}
}
// 밤 먼지 — 깜빡이는 스펙
for (var s2 = 0; s2 < specks.length; s2++) {
var sp = specks[s2];
var al = 0.05 + 0.26 * (0.5 + 0.5 * Math.sin(t * sp.spd + sp.ph));
ctx.fillStyle = rgba(C_MIST, al);
ctx.fillRect(sp.rx * W, sp.ry * H, 1.5, 1.5);
}
// 비네트 — 시선을 중앙으로
ctx.globalCompositeOperation = 'source-over';
var vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.72);
vg.addColorStop(0, rgba(C_INK, 0));
vg.addColorStop(1, rgba(C_INK, 0.42));
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
}
function stillFrame() {
build();
balls[0].x = W * 0.44; balls[0].y = H * 0.5;
balls[1].x = W * 0.56; balls[1].y = H * 0.52;
for (var i = 0; i < balls.length; i++) balls[i].k = heat(balls[i]);
draw(0.4);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
update(t, dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit(); build();
if (reduce) stillFrame();
});
fit();
build();
nOut.textContent = String(N_BALLS);
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GlowBloom — vanilla 판(run/317/demo/04-metaball-gradient-glow-bloom/index.html) 의 React 포트.
// 빛 번짐 — 메타볼 필드 r²/(r²+d²) 를 임계값으로 자르지 않고 명도에 그대로 대응시킨다.
// createRadialGradient 정지점마다 falloff 값에서 색·알파를 계산해 넣고 globalCompositeOperation="lighter"
// 로 겹치면 필드가 더해지는 셈이라 볼이 다가올수록 내부가 밝아진다. 두 볼이 합체 직전이 되면 사이 중점에
// 플레어(코어·민트 확산 + 핑크 고리)가 터진다. 어두운 무대 안개·먼지가 배경도 느리게 숨쉬게 한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GlowBloom.module.scss";
export type GlowBloomProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 볼 수
glow?: number; // 광원 배율 — 시각 광원 반경이 볼 반경의 몇 배인지
flare?: number; // 합체 플레어 강도
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Ball = { x: number; y: number; r: number; vx: number; vy: number; ph: number; k: number };
type Fog = { x: number; y: number; R: number; vx: number; ph: number };
type Speck = { rx: number; ry: number; ph: number; spd: number };
const FIELD_LABEL = "빛 번짐 글로우 블룸 실험판";
const TAU = Math.PI * 2;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function GlowBloom({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
count = 7,
glow = 3.0,
flare = 1.0,
}: GlowBloomProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Record<string, RGB> | null>(null);
const ballsRef = useRef<Ball[]>([]) as Box<Ball[]>;
const fogsRef = useRef<Fog[]>([]) as Box<Fog[]>;
const specksRef = useRef<Speck[]>([]) as Box<Speck[]>;
const countRef = useRef(count) as Box<number>;
const glowRef = useRef(glow) as Box<number>;
const flareRef = useRef(flare) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [label, setLabel] = useState(String(count));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 메타볼 필드 — u = d/r 로 볼 때 r²/(r²+d²) = 1/(1+u²). 임계값 없이 전 구간을 쓴다
function falloff(u: number): number {
const q = u * glowRef.current;
return 1 / (1 + q * q);
}
// 주변 볼과의 접근도(0..1) — 가까워질수록 내부가 밝아진다
function heat(b: Ball): number {
const balls = ballsRef.current;
let s = 0;
for (let j = 0; j < balls.length; j++) {
const o = balls[j];
if (o === b) continue;
const d = Math.hypot(o.x - b.x, o.y - b.y);
const lim = (o.r + b.r) * 1.5;
if (d < lim) s += 1 - d / lim;
}
return Math.min(1, s);
}
function build(): void {
const { w: W, h: H } = sizeRef.current;
if (W <= 0) return;
const rand = makePRNG(0x317a04);
const balls: Ball[] = [];
const cols = 3, rows = 2;
for (let i = 0; i < countRef.current; i++) {
const gx = ((i % cols) + 0.5) / cols;
const gy = (Math.floor(i / cols) + 0.5) / rows;
const ang = rand() * TAU;
const spd = 15 + rand() * 19;
balls.push({
x: gx * W, y: gy * H,
r: 17 + rand() * 14,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
ph: rand() * TAU,
k: 0,
});
}
const fogs: Fog[] = [];
for (let f = 0; f < 4; f++) {
fogs.push({ x: rand() * W, y: rand() * H, R: 130 + rand() * 90, vx: (rand() - 0.5) * 14, ph: rand() * TAU });
}
const specks: Speck[] = [];
for (let s = 0; s < 24; s++) {
specks.push({ rx: rand(), ry: rand() * 0.85, ph: rand() * TAU, spd: 0.8 + rand() * 1.6 });
}
ballsRef.current = balls;
fogsRef.current = fogs;
specksRef.current = specks;
}
function update(t: number, dt: number): void {
const { w: W, h: H } = sizeRef.current;
const balls = ballsRef.current;
const m = 24;
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
// 배회 — 속도 방향을 천천히 휘게 한다(관성은 유지)
const ang = Math.atan2(b.vy, b.vx) + Math.sin(t * 0.6 + b.ph) * dt * 1.2;
const spd = Math.min(34, Math.hypot(b.vx, b.vy) + dt * 4);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < m) { b.x = m; b.vx = Math.abs(b.vx); }
if (b.x > W - m) { b.x = W - m; b.vx = -Math.abs(b.vx); }
if (b.y < m) { b.y = m; b.vy = Math.abs(b.vy); }
if (b.y > H - m) { b.y = H - m; b.vy = -Math.abs(b.vy); }
b.k += (heat(b) - b.k) * Math.min(1, dt * 5);
}
const fogs = fogsRef.current;
for (let f = 0; f < fogs.length; f++) {
const fg = fogs[f];
fg.x += fg.vx * dt;
if (fg.x < -fg.R) fg.x = W + fg.R;
if (fg.x > W + fg.R) fg.x = -fg.R;
}
}
function rgba(c: RGB, a: number): string {
return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}
function mix(c1: RGB, c2: RGB, k: number): RGB {
return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !col) return;
const C_INK = col.ink, C_DUSK = col.dusk, C_CORE = col.core;
const C_LILA = col.lilac, C_MINT = col.mint, C_PINK = col.pink, C_MIST = col.mist;
// 무대 — 잉크
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = rgba(C_INK, 1);
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
// 안개 — 낮은 알파의 큰 방사가 배경도 느리게 바꾼다
const fogs = fogsRef.current;
for (let f = 0; f < fogs.length; f++) {
const fg = fogs[f];
const fy = fg.y + Math.sin(t * 0.22 + fg.ph) * 16;
const g = ctx.createRadialGradient(fg.x, fy, 0, fg.x, fy, fg.R);
g.addColorStop(0, rgba(C_DUSK, 0.2));
g.addColorStop(1, rgba(C_DUSK, 0));
ctx.fillStyle = g;
ctx.fillRect(fg.x - fg.R, fy - fg.R, fg.R * 2, fg.R * 2);
}
// 발광 볼 — 필드 falloff 를 명도(알파·색)로 그대로 얹는다
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
const R = b.r * glowRef.current;
const g2 = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, R);
for (let s = 0; s <= 4; s++) {
const u = s / 4;
const fv = falloff(u);
const a = Math.min(1, Math.pow(fv, 1.35) * (1 - u * u) * (0.8 + 0.28 * b.k));
const c = fv > 0.5 ? mix(C_LILA, C_CORE, (fv - 0.5) / 0.5) : mix(C_DUSK, C_LILA, fv / 0.5);
g2.addColorStop(u, rgba(c, a));
}
ctx.fillStyle = g2;
ctx.fillRect(b.x - R, b.y - R, R * 2, R * 2);
}
// 플레어 — 합체 직전 두 볼 사이에서 터지는 빛
for (let p = 0; p < balls.length; p++) {
for (let q = p + 1; q < balls.length; q++) {
const a1 = balls[p], b1 = balls[q];
const d = Math.hypot(a1.x - b1.x, a1.y - b1.y);
const lim = (a1.r + b1.r) * 1.6;
if (d >= lim) continue;
let e = 1 - d / lim;
e = e * e;
const mx = (a1.x + b1.x) / 2, my = (a1.y + b1.y) / 2;
const pulse = 0.72 + 0.28 * Math.sin(t * 8 + p * 2.1);
const fr = 20 + d * 0.55 + 42 * e;
const g3 = ctx.createRadialGradient(mx, my, 0, mx, my, fr);
g3.addColorStop(0, rgba(C_CORE, 0.5 * e * pulse * flareRef.current + 0.04 * e));
g3.addColorStop(0.45, rgba(C_MINT, 0.26 * e * pulse));
g3.addColorStop(1, rgba(C_PINK, 0));
ctx.fillStyle = g3;
ctx.fillRect(mx - fr, my - fr, fr * 2, fr * 2);
ctx.beginPath();
ctx.arc(mx, my, 10 + 26 * e, 0, TAU);
ctx.strokeStyle = rgba(C_PINK, 0.34 * e * pulse);
ctx.lineWidth = 1.2;
ctx.stroke();
}
}
// 밤 먼지 — 깜빡이는 스펙
const specks = specksRef.current;
for (let s2 = 0; s2 < specks.length; s2++) {
const sp = specks[s2];
const al = 0.05 + 0.26 * (0.5 + 0.5 * Math.sin(t * sp.spd + sp.ph));
ctx.fillStyle = rgba(C_MIST, al);
ctx.fillRect(sp.rx * W, sp.ry * H, 1.5, 1.5);
}
// 비네트 — 시선을 중앙으로
ctx.globalCompositeOperation = "source-over";
const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.72);
vg.addColorStop(0, rgba(C_INK, 0));
vg.addColorStop(1, rgba(C_INK, 0.42));
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current = { w: Math.max(2, Math.round(r.width)), h: Math.max(2, Math.round(r.height)) };
cv.width = sizeRef.current.w;
cv.height = sizeRef.current.h;
ctxRef.current = ctx;
}
function stillFrame(): void {
build();
const balls = ballsRef.current;
const { w: W, h: H } = sizeRef.current;
if (balls.length > 1) {
balls[0].x = W * 0.44; balls[0].y = H * 0.5;
balls[1].x = W * 0.56; balls[1].y = H * 0.52;
}
for (let i = 0; i < balls.length; i++) balls[i].k = heat(balls[i]);
draw(0.4);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
update(t, dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
countRef.current = count;
glowRef.current = glow;
flareRef.current = flare;
const cs = getComputedStyle(root);
const tokRGB = (name: string): RGB => {
const s = cs.getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
};
colRef.current = {
ink: tokRGB("--glow-ink"),
dusk: tokRGB("--glow-dusk"),
core: tokRGB("--glow-core"),
lilac: tokRGB("--glow-lilac"),
mint: tokRGB("--glow-mint"),
pink: tokRGB("--glow-pink"),
mist: tokRGB("--glow-mist"),
};
fit();
build();
setLabel(String(count));
function onResize(): void {
fit();
build();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, glow, flare]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>임계값 위·아래 어디서나 필드 값을 명도로 읽어 lighter 로 겹칩니다 — 볼이 가까워질수록 내부가 밝아지다 합체 순간 플레어로 터지는 발광 액체입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>볼 <b className={styles.val}>{label}</b>개 · 광원 배율 <b className={styles.val}>{glow.toFixed(1)}</b></span>
<span className={styles.metaDim}>canvas · createRadialGradient · lighter</span>
</div>
</div>
</main>
);
}04 빛 번짐 — 필드 값을 명도로 읽는 글로우 블룸 — 측정 누적면적 36.467% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리
이 데모가 속한 글
캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리메타볼 효과는 여러 덩어리가 가까워지면 하나로 녹아 붙고 멀어지면 갈라지는, 액체처럼 굴러가는 캔버스 장면입니다. 필드 값이 임계값을 넘는 경계를 격자 칸마다 잇는 마칭 스퀘어 하나로 블롭이 스며들듯 합쳐졌다 갈라집 이 글의 데모 전부 보기


