08Glowing plankton — a swarm shining in the night sea
Drawing each agent as a glow dot composited with lighter — so overlaps brighten — and letting them drift on a deep-blue sea gives the bioluminescent bloom that spreads from every oar-stroke.
Best forNight events and immersive story pages
globalCompositeOperationcreateRadialGradientrequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><circle cx="80" cy="96" r="10"/><circle cx="140" cy="70" r="7"/><circle cx="176" cy="130" r="12"/><circle cx="96" cy="160" r="8"/><circle cx="160" cy="190" r="6"/></g><path d="M32 200q32-24 64 0t64 0t64 0" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" opacity=".5"/></svg>
<span>180 glowing plankton drift across a night sea — bring the pointer close and the glow scatters and flees</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Glowing-plankton night-sea test panel" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">180</b>plankton · lighter glow blend</span>
<span class="cd__meta cd__meta--dim">drawImage sprite · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 발광 플랑크톤(08). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(밤바다 그라디언트 + 발광 스프라이트 lighter 합성)가 만든다.
// 무대는 밤바다 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(07~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 07~09 — BD9~11 원칙) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색(라일락 발광 개체)
$flock-mist: #d8dce6; // 먼 개체 흐릿
// ── 심해 전용 확장(08 만 — 심해 그라디언트·발광 톤. 전부 HLS 파스텔 안전값) ──
$flock-deep: #2c3750; // 심해 바닥 — 슬레이트보다 한 단 깊은 청
$flock-sea: #46608c; // 중간 수심
$flock-shallow: #5d7cae; // 얕은 수심 — 수면 쪽
$flock-plankton: #b4def0; // 발광 플랑크톤 기본 톤(파스텔 하늘)
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
--flock-deep: #{$flock-deep};
--flock-sea: #{$flock-sea};
--flock-shallow: #{$flock-shallow};
--flock-plankton: #{$flock-plankton};
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: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-deep;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .16);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-plankton; 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($flock-mist, .72); }
.cd__val { color: $flock-plankton; 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 {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--flock-deep: #2c3750;
--flock-sea: #46608c;
--flock-shallow: #5d7cae;
--flock-plankton: #b4def0;
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__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #2c3750;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.16);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #b4def0;
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: #b4def0;
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;
}
}// 발광 플랑크톤(08). 밤바다 배경도 캔버스가 매 프레임 그린다(깊이별 그라디언트가 느리게 흔들린다).
// 개체 180마리는 미리 만든 발광 스프라이트(오프스크린 캔버스 1장, createRadialGradient)를
// drawImage 로 찍고 globalCompositeOperation 'lighter' 로 겹치면 밝아지게 한다.
// 포인터(또는 시드 고정 가상 자극) 근처에서는 도망 벡터가 더해지고, 표준 군집 3벡터를
// 약하게 얹어 무리가 흩어졌다 다시 느슨히 모이는 결을 만든다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 180; // 개체 수
var GLOW_R = 7; // 발광 반경(px) — 변형 "발광 반경" 축(5 → 12)
var FLEE_R = 46; // 도망 반경(px)
// ────────────────────────────────────────────────────
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
var SEED = 0x08fee0d;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
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_DEEP = tokRGB('--flock-deep');
var C_MID = tokRGB('--flock-sea');
var C_SHALL = tokRGB('--flock-shallow');
var C_GLOWA = tokRGB('--flock-plankton');
var C_GLOWB = tokRGB('--flock-lilac');
var C_MIST = tokRGB('--flock-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 px01 = 0.5, py01 = 0.5;
var manual = false;
// ── 발광 스프라이트(오프스크린 1장) — drawImage 로 찍어 비용을 낮춘다 ──
var spriteSize = Math.max(8, Math.round(GLOW_R * 6));
var sprite = document.createElement('canvas');
sprite.width = spriteSize; sprite.height = spriteSize;
var sctx = sprite.getContext('2d');
(function buildSprite() {
var r = spriteSize / 2;
var g = sctx.createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.35, 'rgba(255,255,255,0.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
sctx.fillStyle = g;
sctx.fillRect(0, 0, spriteSize, spriteSize);
})();
// ── 이웃 반경 안 3벡터 파라미터(군집 코어 공통 뼈대) — 느슨한 학교 짓기 ──
var NEI_R = 30, NEI_R2 = NEI_R * NEI_R, SEP_R = 12;
var W_SEP = 14, W_ALI = 5, W_COH = 2.4;
var plks = [];
function spawn(p) {
p.x = rand() * 1; p.y = rand() * 1; // 0..1 정규화 좌표(필드에 곱해 사용)
p.vx = (rand() - 0.5) * 10;
p.vy = (rand() - 0.5) * 10;
p.ph = rand() * TAU;
p.hue = rand() < 0.16 ? 1 : 0; // 16% 는 라일락 톤
p.sz = 0.75 + rand() * 0.7;
}
for (var i = 0; i < N; i++) { var p = {}; spawn(p); plks.push(p); }
function flockForce(idx) {
var p = plks[idx];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
var px = p.x * W, py = p.y * H;
for (var j = 0; j < N; j++) {
if (j === idx) continue;
var q = plks[j];
var qx = q.x * W, qy = q.y * H;
var dx = qx - px, dy = qy - py;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
var d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
var fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
var STILL_T = 1.0;
function draw(tSec, dt) {
// ── 밤바다 배경 — 깊이별 그라디언트가 느리게 흔들린다(캔버스가 매 프레임 그린다) ──
ctx.globalCompositeOperation = 'source-over';
var shiftA = 0.5 + 0.5 * Math.sin(tSec * 0.35);
var seaGrad = ctx.createLinearGradient(0, 0, 0, H);
seaGrad.addColorStop(0, 'rgb(' + lerpC(C_SHALL, C_MID, shiftA * 0.3) + ')');
seaGrad.addColorStop(0.5, 'rgb(' + lerpC(C_MID, C_DEEP, 0.4 + shiftA * 0.2) + ')');
seaGrad.addColorStop(1, 'rgb(' + C_DEEP[0] + ',' + C_DEEP[1] + ',' + C_DEEP[2] + ')');
ctx.fillStyle = seaGrad;
ctx.fillRect(0, 0, W, H);
// 표류하는 해류 결 — 대각 띠가 위상만큼 흘러 화면 전체가 실제로 바뀐다
for (var band = 0; band < 3; band++) {
var by = H * (0.2 + band * 0.32) + Math.sin(tSec * 0.5 + band * 1.7) * H * 0.08;
var bandA = 0.05 + 0.03 * Math.sin(tSec * 0.8 + band);
ctx.fillStyle = 'rgba(' + C_MID[0] + ',' + C_MID[1] + ',' + C_MID[2] + ',' + bandA.toFixed(3) + ')';
ctx.fillRect(0, by - 14, W, 28);
}
// 가상 자극 — 포인터 없을 때 화면을 천천히 순회(시드 고정)
var stimX, stimY;
if (manual) { stimX = px01 * W; stimY = py01 * H; }
else {
stimX = W * (0.5 + 0.4 * Math.sin(tSec * 0.6));
stimY = H * (0.5 + 0.36 * Math.sin(tSec * 0.41 + 1.3));
}
ctx.globalCompositeOperation = 'lighter';
for (var i2 = 0; i2 < N; i2++) {
var p = plks[i2];
var fx3 = p.x * W, fy3 = p.y * H;
var dxs = fx3 - stimX, dys = fy3 - stimY;
var ds = Math.hypot(dxs, dys) || 0.0001;
var fleeX = 0, fleeY = 0;
if (ds < FLEE_R) {
var fleeMag = (1 - ds / FLEE_R) * 120;
fleeX = (dxs / ds) * fleeMag;
fleeY = (dys / ds) * fleeMag;
}
var ff = flockForce(i2);
var driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
var driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3; // 살짝 위로 뜨는 성향
var ax = fleeX + ff[0] + driftX;
var ay = fleeY + ff[1] + driftY;
p.vx += ax * dt; p.vy += ay * dt;
var damp = Math.pow(0.88, dt * 60);
p.vx *= damp; p.vy *= damp;
var sp = Math.hypot(p.vx, p.vy);
var maxV = 70;
if (sp > maxV) { p.vx = p.vx / sp * maxV; p.vy = p.vy / sp * maxV; }
var nx2 = (fx3 + p.vx * dt + W) % W;
var ny2 = (fy3 + p.vy * dt + H) % H;
p.x = nx2 / W; p.y = ny2 / H;
var glow = p.hue ? C_GLOWB : C_GLOWA;
var pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
var dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
ctx.save();
ctx.globalAlpha = 0.55 + 0.35 * pulse;
ctx.filter = 'none';
ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
ctx.restore();
}
}
// 색조 스프라이트 캐시 — 두 색(플랑크톤/라일락)만 쓰므로 두 장만 만든다
var tintCache = {};
function tintedSprite(rgb) {
var key = rgb.join(',');
if (tintCache[key]) return tintCache[key];
var c = document.createElement('canvas');
c.width = spriteSize; c.height = spriteSize;
var cc = c.getContext('2d');
cc.drawImage(sprite, 0, 0);
cc.globalCompositeOperation = 'source-in';
cc.fillStyle = 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
cc.fillRect(0, 0, spriteSize, spriteSize);
tintCache[key] = c;
return c;
}
function lerpC(a, b, m) {
return (a[0] + (b[0] - a[0]) * m | 0) + ',' + (a[1] + (b[1] - a[1]) * m | 0) + ',' + (a[2] + (b[2] - a[2]) * m | 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
nOut.textContent = String(N);
}
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;
draw(t, dt);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () { manual = false; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); px01 = 0.5; py01 = 0.5; }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GlowPlankton — vanilla 판(run/315/demo/08-flock-glow-plankton/index.html) 의 React 포트.
// 밤바다 배경(깊이별 그라디언트가 느리게 흔들린다)도 캔버스가 매 프레임 그린다. 개체는
// 미리 만든 발광 스프라이트(오프스크린 캔버스, createRadialGradient)를 drawImage 로 찍고
// globalCompositeOperation 'lighter' 로 겹쳐 밝아지게 한다. 포인터(또는 가상 자극) 근처에서는
// 도망 벡터가 더해지고, 표준 군집 3벡터를 약하게 얹어 무리가 흩어졌다 다시 느슨히 모인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GlowPlankton.module.scss";
export type GlowPlanktonProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 개체 수
glowR?: number; // 발광 반경(px) — 변형 "발광 반경" 축(5 → 12)
fleeR?: number; // 도망 반경(px)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Plankton = { x: number; y: number; vx: number; vy: number; ph: number; hue: number; sz: number };
type Colors = { deep: RGB; sea: RGB; shallow: RGB; glowA: RGB; glowB: RGB; mist: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "발광 플랑크톤 밤바다 실험판";
const STILL_T = 1.0;
const NEI_R = 30, NEI_R2 = NEI_R * NEI_R, SEP_R = 12;
const W_SEP = 14, W_ALI = 5, W_COH = 2.4;
const ARROW_STEP: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
// 시드 고정 PRNG — mulberry32
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;
};
}
function lerpC(a: RGB, b: RGB, m: number): string {
return `${(a[0] + (b[0] - a[0]) * m) | 0},${(a[1] + (b[1] - a[1]) * m) | 0},${(a[2] + (b[2] - a[2]) * m) | 0}`;
}
export default function GlowPlankton({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b4def0",
count = 180,
glowR = 7,
fleeR = 46,
}: GlowPlanktonProps) {
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 spriteRef = useRef<HTMLCanvasElement | null>(null);
const spriteSizeRef = useRef(0) as Box<number>;
const tintCacheRef = useRef<Record<string, HTMLCanvasElement>>({}) as Box<Record<string, HTMLCanvasElement>>;
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const plksRef = useRef<Plankton[]>([]) as Box<Plankton[]>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const reduceRef = useRef(false) as Box<boolean>;
const [n, setN] = useState(String(count));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).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)];
}
function buildSprite(): void {
const spriteSize = Math.max(8, Math.round(glowR * 6));
spriteSizeRef.current = spriteSize;
const sprite = document.createElement("canvas");
sprite.width = spriteSize; sprite.height = spriteSize;
const sctx = sprite.getContext("2d");
if (sctx) {
const r = spriteSize / 2;
const g = sctx.createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, "rgba(255,255,255,1)");
g.addColorStop(0.35, "rgba(255,255,255,0.55)");
g.addColorStop(1, "rgba(255,255,255,0)");
sctx.fillStyle = g;
sctx.fillRect(0, 0, spriteSize, spriteSize);
}
spriteRef.current = sprite;
tintCacheRef.current = {};
}
function tintedSprite(rgb: RGB): HTMLCanvasElement {
const key = rgb.join(",");
const cache = tintCacheRef.current;
if (cache[key]) return cache[key];
const spriteSize = spriteSizeRef.current;
const c = document.createElement("canvas");
c.width = spriteSize; c.height = spriteSize;
const cc = c.getContext("2d")!;
if (spriteRef.current) cc.drawImage(spriteRef.current, 0, 0);
cc.globalCompositeOperation = "source-in";
cc.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
cc.fillRect(0, 0, spriteSize, spriteSize);
cache[key] = c;
return c;
}
function spawn(rand: () => number): Plankton {
return {
x: rand(), y: rand(),
vx: (rand() - 0.5) * 10,
vy: (rand() - 0.5) * 10,
ph: rand() * TAU,
hue: rand() < 0.16 ? 1 : 0,
sz: 0.75 + rand() * 0.7,
};
}
function buildPlks(): void {
const rand = makePRNG(0x08fee0d);
const arr: Plankton[] = [];
for (let i = 0; i < count; i++) arr.push(spawn(rand));
plksRef.current = arr;
setN(String(count));
}
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 = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function flockForce(idx: number): [number, number] {
const plks = plksRef.current;
const { w: W, h: H } = sizeRef.current;
const p = plks[idx];
const px = p.x * W, py = p.y * H;
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (let j = 0; j < plks.length; j++) {
if (j === idx) continue;
const q = plks[j];
const qx = q.x * W, qy = q.y * H;
let dx = qx - px, dy = qy - py;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
const d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
let fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const plks = plksRef.current;
if (!ctx || !col || plks.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
const shiftA = 0.5 + 0.5 * Math.sin(tSec * 0.35);
const seaGrad = ctx.createLinearGradient(0, 0, 0, H);
seaGrad.addColorStop(0, `rgb(${lerpC(col.shallow, col.sea, shiftA * 0.3)})`);
seaGrad.addColorStop(0.5, `rgb(${lerpC(col.sea, col.deep, 0.4 + shiftA * 0.2)})`);
seaGrad.addColorStop(1, `rgb(${col.deep[0]},${col.deep[1]},${col.deep[2]})`);
ctx.fillStyle = seaGrad;
ctx.fillRect(0, 0, W, H);
for (let band = 0; band < 3; band++) {
const by = H * (0.2 + band * 0.32) + Math.sin(tSec * 0.5 + band * 1.7) * H * 0.08;
const bandA = 0.05 + 0.03 * Math.sin(tSec * 0.8 + band);
ctx.fillStyle = `rgba(${col.sea[0]},${col.sea[1]},${col.sea[2]},${bandA.toFixed(3)})`;
ctx.fillRect(0, by - 14, W, 28);
}
let stimX: number, stimY: number;
if (manualRef.current) { stimX = pxRef.current * W; stimY = pyRef.current * H; }
else {
stimX = W * (0.5 + 0.4 * Math.sin(tSec * 0.6));
stimY = H * (0.5 + 0.36 * Math.sin(tSec * 0.41 + 1.3));
}
ctx.globalCompositeOperation = "lighter";
const spriteSize = spriteSizeRef.current;
for (let i2 = 0; i2 < plks.length; i2++) {
const p = plks[i2];
const fx3 = p.x * W, fy3 = p.y * H;
const dxs = fx3 - stimX, dys = fy3 - stimY;
const ds = Math.hypot(dxs, dys) || 0.0001;
let fleeX = 0, fleeY = 0;
if (ds < fleeR) {
const fleeMag = (1 - ds / fleeR) * 120;
fleeX = (dxs / ds) * fleeMag;
fleeY = (dys / ds) * fleeMag;
}
const ff = flockForce(i2);
const driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
const driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3;
const ax = fleeX + ff[0] + driftX;
const ay = fleeY + ff[1] + driftY;
p.vx += ax * dt; p.vy += ay * dt;
const damp = Math.pow(0.88, dt * 60);
p.vx *= damp; p.vy *= damp;
const sp = Math.hypot(p.vx, p.vy);
const maxV = 70;
if (sp > maxV) { p.vx = (p.vx / sp) * maxV; p.vy = (p.vy / sp) * maxV; }
const nx2 = (fx3 + p.vx * dt + W) % W;
const ny2 = (fy3 + p.vy * dt + H) % H;
p.x = nx2 / W; p.y = ny2 / H;
const glow = p.hue ? col.glowB : col.glowA;
const pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
const dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
ctx.save();
ctx.globalAlpha = 0.55 + 0.35 * pulse;
ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
ctx.restore();
}
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduceRef.current) draw(STILL_T, 0.016);
}
function onPointerLeave(): void { manualRef.current = false; }
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void {
const d = ARROW_STEP[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) { manualRef.current = true; pxRef.current = 0.5; pyRef.current = 0.5; }
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduceRef.current) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
colRef.current = {
deep: tokRGB(root, "--flock-deep"),
sea: tokRGB(root, "--flock-sea"),
shallow: tokRGB(root, "--flock-shallow"),
glowA: tokRGB(root, "--flock-plankton"),
glowB: tokRGB(root, "--flock-lilac"),
mist: tokRGB(root, "--flock-mist"),
};
buildSprite();
buildPlks();
fit();
function onResize(): void {
fit();
if (reduceRef.current) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduceRef.current) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, glowR, fleeR]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><circle cx="80" cy="96" r="10" /><circle cx="140" cy="70" r="7" /><circle cx="176" cy="130" r="12" /><circle cx="96" cy="160" r="8" /><circle cx="160" cy="190" r="6" /></g><path d="M32 200q32-24 64 0t64 0t64 0" fill="none" stroke="currentColor" strokeWidth="10" strokeLinecap="round" opacity=".5" /></svg>
<span>{n}마리 발광 플랑크톤이 밤바다를 떠돕니다 — 포인터를 가까이 대면 빛무리가 흩어지며 도망칩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>마리 · lighter 발광 합성</span>
<span className={styles.metaDim}>drawImage 스프라이트 · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 Glowing plankton — a swarm shining in the night sea — Measured area 32.674% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Canvas Boids Algorithm Flocking Birds Simulation
The post this demo lives in
9 Canvas Boids Algorithm Flocking Birds SimulationA boids algorithm demo steers every agent with three local vectors, separation, alignment, cohesion, and that is enough See all demos in this post
If you like this effect - similar demos
Creative UI09 Glow inside the cloud — a stemless cloud-to-cloud flicker in the haze9 Lightning Effect Background Scenes, No WebGL
Creative UI04 Glow bloom — reading field strength as lightness9 Metaball Effect Scenes, Marching Squares Canvas
Creative UI01 One candle — the smallest fire standing on a wick9 Canvas Fire Animation Scenes, No WebGL