03A school of fish — schooling with a sense of depth
Giving each fish a virtual depth and drawing far ones smaller and fainter layers a swimming school without any perspective projection.
Best forAquarium and marine-concept intro screens
globalAlphascalerequestAnimationFrame

<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"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>A fish school on a deep-sea stage — each fish carries a virtual depth z (0 to 1), so farther fish are drawn smaller and fainter to form layers, and streamlined bodies and tails lean along their heading</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Fish-school depth 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">140</b>fish · depth-z layers</span>
<span class="cd__meta cd__meta--dim">globalAlpha · ellipse · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 물고기 떼(03). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(군집 스티어링 + 깊이 z 스케일·globalAlpha 층 + 꼬리 sin)가 만든다.
// 무대는 심해(어스름 잉크 + 상부 옅은 하늘빛 광원). 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(01~09 동일).
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
.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-sky: #{$subject-sky}; // 심해 상부 광원·물고기 몸 — 전역 토큰 재사용
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $subject-sky; 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: $subject-sky; 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-sky: #6ec8ff;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #6ec8ff;
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: #6ec8ff;
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;
}
}// 물고기 떼(03). 군집 코어는 01 과 같은 뼈대(분리·정렬·결합 + 토러스 랩).
// 장면이 다르다: 심해 무대(잉크 + 상부 옅은 하늘빛 radial 광원) 위에 개체마다 가상 깊이 z(0=가까움, 1=멀음) 을
// 들고 다닌다 — 멀수록 스케일이 작아지고 globalAlpha 가 옅아져 한 화면에 층이 생긴다.
// 몸은 유선형 타원 + 꼬리 삼각형, 진행 방향으로 ctx.rotate. 결합 계수가 뭉침 강도(변형 축).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_B = 140; // 물고기 수
var W_SEP = 1.6; // 분리 무게
var W_ALI = 1.1; // 정렬 무게
var W_COH = 0.6; // 결합 무게 — 변형 "결합 계수" 축(0.6 → 1.2, 뭉침 강도)
// ────────────────────────────────────────────────────
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 = 0x30c103;
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 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('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_SKY = tokRGB('--flock-sky');
var C_LILA = tokRGB('--flock-lilac');
var C_SILH = C_SKY; // 물고기 몸색 — 심해 위 하늘빛 실루엣
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 STILL_T = 1.6; // reduce 에서 그리는 시각
// ── 시뮬 상수 ──
var R_NEIGH = 48; // 이웃 반경 (px)
var MAX_SPD = 100; // 최대 속도 (px/s)
var MAX_FOR = 260; // 최대 힘 (px/s^2)
// ── 물고기 개체 ──
var boids = [];
function spawnB(b, i) {
b.x = rand() * W;
b.y = rand() * H;
var ang = rand() * TAU;
var spd = MAX_SPD * (0.5 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.z = rand(); // 가상 깊이 0(가까움)~1(멀음)
b.ph = rand() * TAU; // 안내 위상
b.sz = 0.75 + rand() * 0.6; // 몸 스케일
b.grp = (i || 0) % 4; // 하위 무리 — 화면 커버리지
}
function build() {
boids = [];
for (var i = 0; i < N_B; i++) { var b = {}; spawnB(b, i); boids.push(b); }
nOut.textContent = String(N_B);
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx, vy, tvx, tvy, out) {
var sx = tvx - vx, sy = tvy - vy;
var m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
var sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt) {
var n = boids.length;
for (var i = 0; i < n; i++) {
var b = boids[i];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (var j = 0; j < n; j++) {
if (j === i) continue;
var o = boids[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) { // 분리 — 반경 절반 안에서만, 가까울수록 세게
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w;
}
}
var fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB);
fx += W_ALI * sA[0] + W_COH * sB[0];
fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { var k2 = MAX_SPD * 0.5 / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다
function herdGuide(dt, tSec) {
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 150 * dt;
b.vy += Math.sin(ga) * 150 * dt;
}
}
function draw(tSec) {
// 1) 심해 무대 — 잉크 바탕 + 상부 옅은 하늘빛 광원(radial). 빛이 느리게 숨쉬어 넓은 면적이 매 프레임 변한다
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
var breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
var lx = W * 0.5 + Math.sin(tSec * 0.3) * W * 0.1;
var ly = -H * 0.1;
var lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
var lg = ctx.createRadialGradient(lx, ly, 6, lx, ly, lr);
var la = 0.16 + 0.05 * breathe;
lg.addColorStop(0, 'rgba(' + C_SKY[0] + ',' + C_SKY[1] + ',' + C_SKY[2] + ',' + la.toFixed(3) + ')');
lg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = lg;
ctx.fillRect(0, 0, W, H);
// 2) 물고기 — 깊이 z 순으로 그린다(멀 것부터 → 가까운 것이 위에 덮임)
var order = boids.slice();
order.sort(function (p, q) { return q.z - p.z; });
for (var k = 0; k < order.length; k++) {
var b = order[k];
var depth = 1 - b.z; // 1 = 가까움, 0 = 멀음
var sc = (0.45 + 0.75 * depth) * b.sz; // 멀수록 작게
var alpha = 0.35 + 0.6 * depth; // 멀수록 옅게 — 층의 핵심
var ang = Math.atan2(b.vy, b.vx);
// 꼬리 흔들림 — 진행축 수직으로 sin
var tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
// 몸 — 유선형 타원(앞이 둥글고 뒤로 갈수록 좁아지는 두 타원 겹침)
ctx.fillStyle = 'rgb(' + C_SILH[0] + ',' + C_SILH[1] + ',' + C_SILH[2] + ')';
ctx.beginPath();
ctx.ellipse(0, 0, 7.5 * sc, 2.8 * sc, 0, 0, TAU);
ctx.fill();
// 꼬리 — 몸 뒤 삼각형, 수직 sin 흔들림
ctx.beginPath();
ctx.moveTo(-6 * sc, tail * 0.4);
ctx.lineTo(-11 * sc, tail - 3.4 * sc);
ctx.lineTo(-11 * sc, tail + 3.4 * sc);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
// 3) 가까운 층 강조 — 라일락 포인트 개체 몇 마리(z 가 가장 작은 5마리)에 눈광점
var near = boids.slice().sort(function (p, q) { return p.z - q.z; });
for (var m = 0; m < Math.min(5, near.length); m++) {
var f2 = near[m];
var sc2 = (0.45 + 0.75 * (1 - f2.z)) * f2.sz;
var ang2 = Math.atan2(f2.vy, f2.vx);
ctx.save();
ctx.translate(f2.x, f2.y);
ctx.rotate(ang2);
ctx.fillStyle = 'rgb(' + C_LILA[0] + ',' + C_LILA[1] + ',' + C_LILA[2] + ')';
ctx.beginPath();
ctx.arc(4.2 * sc2, -0.8 * sc2, 0.9 * sc2, 0, TAU);
ctx.fill();
ctx.restore();
}
}
function fit() {
var r = field.getBoundingClientRect();
var oldW = W, oldH = H;
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
if (oldW !== W || oldH !== H) {
prngState = SEED >>> 0;
build();
}
}
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;
herdGuide(dt, t);
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T); });
fit();
build();
if (reduce) {
for (var w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FishSchoolDepth — vanilla 판(run/315/demo/03-flock-fish-school-depth/index.html) 의 React 포트.
// 군집 코어는 01 과 같은 뼈대. 장면은 심해 — 개체마다 가상 깊이 z(0~1) 를 가져 멀수록 작고 옅게(globalAlpha)
// 그려져 층이 생긴다. 몸은 유선형 타원 + 꼬리 삼각형이 진행 방향으로 눕고 꼬리는 sin 으로 흔들린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FishSchoolDepth.module.scss";
export type FishSchoolDepthProps = {
duration?: string;
easing?: string;
color?: string;
fish?: number; // 물고기 수
cohesion?: number; // 결합 계수 — 변형 "뭉침 강도" 축(0.6 → 1.2)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Fish = { x: number; y: number; vx: number; vy: number; z: number; ph: number; sz: number; grp: number };
type Colors = { ink: RGB; mist: RGB; sky: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "물고기 떼 깊이 실험판";
const STILL_T = 1.6; // reduce 에서 그리는 시각
const R_NEIGH = 48; // 이웃 반경 (px)
const MAX_SPD = 100; // 최대 속도 (px/s)
const MAX_FOR = 260; // 최대 힘 (px/s^2)
// 시드 고정 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;
};
}
export default function FishSchoolDepth({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#6ec8ff",
fish = 140,
cohesion = 0.6,
}: FishSchoolDepthProps) {
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<Colors | null>(null);
const nfRef = useRef(fish) as Box<number>;
const cohRef = useRef(cohesion) as Box<number>;
const fishRef = useRef<Fish[]>([]) as Box<Fish[]>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(fish));
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;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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 build(): void {
const rand = makePRNG(0x30c103);
const { w: W, h: H } = sizeRef.current;
const arr: Fish[] = [];
for (let i = 0; i < nfRef.current; i++) {
const ang = rand() * TAU;
const spd = MAX_SPD * (0.5 + rand() * 0.5);
arr.push({
x: rand() * W,
y: rand() * H,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
z: rand(),
ph: rand() * TAU,
sz: 0.75 + rand() * 0.6,
grp: i % 4,
});
}
fishRef.current = arr;
setN(String(nfRef.current));
}
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 steer(vx: number, vy: number, tvx: number, tvy: number, out: number[]): void {
let sx = tvx - vx, sy = tvy - vy;
const m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = (sx / m) * MAX_FOR; sy = (sy / m) * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
function herdGuide(dt: number, tSec: number): void {
const fish = fishRef.current;
for (let i = 0; i < fish.length; i++) {
const b = fish[i];
const ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 150 * dt;
b.vy += Math.sin(ga) * 150 * dt;
}
}
const sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt: number): void {
const fish = fishRef.current;
const n = fish.length;
for (let i = 0; i < n; i++) {
const b = fish[i];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const o = fish[j];
const dx = o.x - b.x, dy = o.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
const d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) {
const w = ((R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5)) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= (dx / d) * w; sepY -= (dy / d) * w;
}
}
let fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, ((cx / cnt) - b.x) * 2.2, ((cy / cnt) - b.y) * 2.2, sB);
fx += 1.1 * sA[0] + cohRef.current * sB[0];
fy += 1.1 * sA[1] + cohRef.current * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += 1.6 * sC[0]; fy += 1.6 * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { const k2 = (MAX_SPD * 0.5) / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
const { w: W, h: H } = sizeRef.current;
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const fish = fishRef.current;
if (!ctx || !col || fish.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
const lx = W * 0.5 + Math.sin(tSec * 0.3) * W * 0.1;
const ly = -H * 0.1;
const lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
const lg = ctx.createRadialGradient(lx, ly, 6, lx, ly, lr);
const la = 0.16 + 0.05 * breathe;
lg.addColorStop(0, `rgba(${col.sky[0]},${col.sky[1]},${col.sky[2]},${la.toFixed(3)})`);
lg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = lg;
ctx.fillRect(0, 0, W, H);
const order = fish.slice().sort((p, q) => q.z - p.z);
for (let k = 0; k < order.length; k++) {
const b = order[k];
const depth = 1 - b.z;
const sc = (0.45 + 0.75 * depth) * b.sz;
const alpha = 0.35 + 0.6 * depth;
const ang = Math.atan2(b.vy, b.vx);
const tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
ctx.fillStyle = `rgb(${col.sky[0]},${col.sky[1]},${col.sky[2]})`;
ctx.beginPath();
ctx.ellipse(0, 0, 7.5 * sc, 2.8 * sc, 0, 0, TAU);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-6 * sc, tail * 0.4);
ctx.lineTo(-11 * sc, tail - 3.4 * sc);
ctx.lineTo(-11 * sc, tail + 3.4 * sc);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
const near = fish.slice().sort((p, q) => p.z - q.z);
for (let m = 0; m < Math.min(5, near.length); m++) {
const f2 = near[m];
const sc2 = (0.45 + 0.75 * (1 - f2.z)) * f2.sz;
const ang2 = Math.atan2(f2.vy, f2.vx);
ctx.save();
ctx.translate(f2.x, f2.y);
ctx.rotate(ang2);
ctx.fillStyle = `rgb(${col.lilac[0]},${col.lilac[1]},${col.lilac[2]})`;
ctx.beginPath();
ctx.arc(4.2 * sc2, -0.8 * sc2, 0.9 * sc2, 0, TAU);
ctx.fill();
ctx.restore();
}
}
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;
herdGuide(dt, t);
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nfRef.current = fish;
cohRef.current = cohesion;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
mist: tokRGB(root, "--flock-mist"),
sky: tokRGB(root, "--flock-sky"),
lilac: tokRGB(root, "--flock-lilac"),
};
fit();
build();
function onResize(): void {
fit();
build();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fish, cohesion]);
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"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>심해 무대 위 물고기 떼입니다 — 개체마다 가상 깊이 z(0~1) 를 가져 멀수록 작고 옅게 그려져 층이 생기고, 유선형 몸과 꼬리가 진행 방향을 따라 눕습니다</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}>{n}</b>마리 물고기 · 깊이 z 층</span>
<span className={styles.metaDim}>globalAlpha · ellipse · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 A school of fish — schooling with a sense of depth — Measured area 44.171% · 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 UI04 Beat-reactive particlesAudio Visualizer JavaScript: 9 Parts, No Library
Creative UI02 Recursive branching — side forks thinning and dimming with depth9 Lightning Effect Background Scenes, No WebGL
Creative UI07 Phase morph — same coefficients, rotated phases, a pattern that shape-shiftsFourier Epicycles, 9 Ways (No Library)