05The arithmetic of a thousand — finding neighbors with a spatial hash
Dividing the screen into cells, registering each bird in one, and checking only the neighboring cells drops neighbor search from all-pairs to a grid lookup as the count grows.
Best forLarge-scale particle demos and performance studies
MapfillRectrequestAnimationFrame

<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="M40 40h64v64H40z" opacity=".2"/><path d="M96 32H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56H48V48h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56h-40V48h40Zm-72 40H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56H48v-40h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56h-40v-40h40Z"/></g></svg>
<span>The screen is divided into 48px cells, and each agent checks only its own cell and the 8 adjacent ones — even with 1000 agents, the flock forms without an exhaustive 1,000×1,000 comparison</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Spatial-hash-grid boids 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">1000</b>agents · neighbor checks per frame <b class="cd__val" id="cdChecks">0</b>checks</span>
<span class="cd__meta cd__meta--dim">Map hash · 3x3 cell scan · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 천 마리의 셈(05). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(공간 해시 3x3 셀 조사 보이드)가 만든다.
// 셀 경계는 화면에 그리지 않는다(측정 노이즈).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 — 04~06 동일) ──
$flock-ink: #1b1e26;
$flock-dusk: #3d4358;
$flock-glow: #f4cfb2;
$flock-bird: #23262f;
$flock-lilac: #b9a0ff;
$flock-mist: #d8dce6;
.cd {
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-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: $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);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; 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-glow; 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; }
}
@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;
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);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
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: #f4cfb2;
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;
}
}// 천 마리의 셈(05). 전수 비교(O(n^2))는 1000개체에서 감당이 안 된다 — 화면을 48px 셀로 나눠
// Map<"cx,cy", 인덱스[]> 로 등록하고, 매 개체는 자기 셀 + 인접 8칸(3x3)만 조사한다.
// 조사 범위가 줄어도 무리 행동(분리·정렬·결합)은 그대로 만들어진다는 것이 이 데모의 요점.
(function () {
// ── 변형 상수 블록 ──
var N_BIRD = 1000; // 개체 수 — 변형 "개체 수" 축(1000 → 2000)
var CELL = 48; // 해시 셀 한 변(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');
var chOut = document.getElementById('cdChecks');
nOut.textContent = String(N_BIRD);
var SEED = 0x0b0105;
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_INK = tokRGB('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_DUSK = tokRGB('--flock-dusk');
var C_LILAC = tokRGB('--flock-lilac');
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;
var PERCEP = 30, SEP_R = 12;
var MAX_SPD = 78, MAX_FRC = 220;
var W_SEP = 1.6, W_ALI = 1.0, W_COH = 0.85;
var birds = [];
for (var i = 0; i < N_BIRD; i++) {
birds.push({
x: rand() * 400, y: rand() * 180,
vx: (rand() - 0.5) * MAX_SPD, vy: (rand() - 0.5) * MAX_SPD
});
}
// ── 공간 해시 — 매 프레임 다시 짓는다(개체가 매 프레임 옮겨가므로) ──
var grid = new Map();
function cellKey(cx, cy) { return cx + ',' + cy; }
function buildGrid() {
grid.clear();
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var cx = Math.floor(b.x / CELL), cy = Math.floor(b.y / CELL);
var k = cellKey(cx, cy);
var arr = grid.get(k);
if (!arr) { arr = []; grid.set(k, arr); }
arr.push(i);
}
}
var checksThisFrame = 0;
function step(dt) {
buildGrid();
checksThisFrame = 0;
var predPx = (px01 - 0.5) * 2, predPy = (py01 - 0.5) * 2; // 포인터 -1..1 — 약한 조류(바람) 신호로만 사용
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
var sepN = 0, aliN = 0, cohN = 0;
var gcx = Math.floor(b.x / CELL), gcy = Math.floor(b.y / CELL);
// 자기 셀 + 인접 8칸(3x3) 만 조사 — 이것이 이 데모의 핵심
for (var oy = -1; oy <= 1; oy++) {
for (var ox = -1; ox <= 1; ox++) {
var arr = grid.get(cellKey(gcx + ox, gcy + oy));
if (!arr) continue;
for (var a = 0; a < arr.length; a++) {
var j = arr[a];
if (j === i) continue;
checksThisFrame++;
var o = birds[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
}
}
var fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.05 * W_COH; fy += (cy0 / cohN) * 0.05 * W_COH; }
// 약한 조류(포인터/가상) — 무리 전체가 한쪽으로 서서히 쏠리는 신호만
fx += predPx * 8; fy += predPy * 8;
var fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = fx / fl * MAX_FRC; fy = fy / fl * MAX_FRC; }
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.3) { b.vx = b.vx / (sp || 1) * MAX_SPD * 0.3; b.vy = b.vy / (sp || 1) * MAX_SPD * 0.3; }
b.x += b.vx * dt; b.y += b.vy * dt;
}
// 위치 갱신 뒤 랩(다음 buildGrid 가 이 값으로 다시 짓는다)
for (var w = 0; w < birds.length; w++) {
var bb = birds[w];
if (bb.x < 0) bb.x += W; else if (bb.x > W) bb.x -= W;
if (bb.y < 0) bb.y += H; else if (bb.y > H) bb.y -= H;
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
function draw(tSec) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
var hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0.2)');
hg.addColorStop(1, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0)');
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sp = Math.hypot(b.vx, b.vy);
var flash = (i % 7 === (((tSec * 6) | 0) % 7)) ? 0.22 : 0; // 지역 밀도 대신 순환 반짝임 — 정적 카드가 아니게
var a = 0.34 + Math.min(0.4, sp / MAX_SPD * 0.4) + flash;
var col = (i % 23 === 0) ? C_LILAC : C_MIST;
ctx.fillStyle = 'rgba(' + col[0] + ',' + col[1] + ',' + col[2] + ',' + a.toFixed(3) + ')';
ctx.fillRect(b.x - 1, b.y - 1, 2, 2);
}
chOut.textContent = String(checksThisFrame);
}
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;
if (!manual) {
var ph = t * TAU / 6.2;
px01 = 0.5 + 0.4 * Math.sin(ph);
py01 = 0.5 + 0.32 * Math.sin(ph * 1.4 + 0.6);
}
step(dt);
draw(t);
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) { step(0.016); draw(1.2); }
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 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 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) { step(0.016); draw(1.2); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(1.2); });
fit();
if (reduce) {
for (var s = 0; s < 30; s++) step(0.033);
draw(1.2);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// SpatialHashGrid — vanilla 판(run/315/demo/05-flock-spatial-hash-grid/index.html) 의 React 포트.
// 화면을 48px 셀로 나눠 Map<"cx,cy", 인덱스[]> 로 등록하고 자기+인접 8칸만 조사한다 —
// 1000개체를 전수 비교 없이 무리로 만든다. 미리보기는 시드 고정 Lissajous 조류 신호.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpatialHashGrid.module.scss";
export type SpatialHashGridProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 개체 수 — 변형 "개체 수" 축(1000 → 2000)
cell?: number; // 해시 셀 한 변(px)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number };
type Colors = { ink: RGB; mist: RGB; dusk: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "공간 해시 그리드 보이드 실험판";
const STILL_T = 1.2;
const PERCEP = 30;
const SEP_R = 12;
const MAX_SPD = 78;
const MAX_FRC = 220;
const W_SEP = 1.6;
const W_ALI = 1.0;
const W_COH = 0.85;
// 시드 고정 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 SpatialHashGrid({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4cfb2",
birds = 1000,
cell = 48,
}: SpatialHashGridProps) {
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 cellRef = useRef(cell) as Box<number>;
const birdsArrRef = useRef<Bird[]>([]) as Box<Bird[]>;
const gridRef = useRef(new Map<string, number[]>()) as Box<Map<string, number[]>>;
const checksRef = 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 rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
const [checks, setChecks] = useState("0");
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)];
}
// 개체 풀 리빌드 — birds prop 이 바뀌거나 최초 마운트 때
function buildBirds(): void {
const rand = makePRNG(0x0b0105);
const arr: Bird[] = [];
for (let i = 0; i < birds; i++) {
arr.push({
x: rand() * 400,
y: rand() * 180,
vx: (rand() - 0.5) * MAX_SPD,
vy: (rand() - 0.5) * MAX_SPD,
});
}
birdsArrRef.current = arr;
setN(String(birds));
}
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 cellKey(cx: number, cy: number): string {
return cx + "," + cy;
}
// ── 공간 해시 — 매 프레임 다시 짓는다(개체가 매 프레임 옮겨가므로) ──
function buildGrid(): void {
const grid = gridRef.current;
const birdsArr = birdsArrRef.current;
const CELL = cellRef.current;
grid.clear();
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
const cx = Math.floor(b.x / CELL);
const cy = Math.floor(b.y / CELL);
const k = cellKey(cx, cy);
const arr = grid.get(k);
if (arr) arr.push(i);
else grid.set(k, [i]);
}
}
function step(dt: number): void {
buildGrid();
const grid = gridRef.current;
const birdsArr = birdsArrRef.current;
const { w: W, h: H } = sizeRef.current;
const CELL = cellRef.current;
let checks = 0;
const driftX = (pxRef.current - 0.5) * 2;
const driftY = (pyRef.current - 0.5) * 2;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
let sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
let sepN = 0, aliN = 0, cohN = 0;
const gcx = Math.floor(b.x / CELL);
const gcy = Math.floor(b.y / CELL);
// 자기 셀 + 인접 8칸(3x3) 만 조사 — 이것이 이 데모의 핵심
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) {
const arr = grid.get(cellKey(gcx + ox, gcy + oy));
if (!arr) continue;
for (let a = 0; a < arr.length; a++) {
const j = arr[a];
if (j === i) continue;
checks++;
const o = birdsArr[j];
const dx = o.x - b.x;
const dy = o.y - b.y;
const d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= (dx / d / d) * SEP_R; sy -= (dy / d / d) * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
}
}
let fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.05 * W_COH; fy += (cy0 / cohN) * 0.05 * W_COH; }
fx += driftX * 8;
fy += driftY * 8;
const fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = (fx / fl) * MAX_FRC; fy = (fy / fl) * MAX_FRC; }
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.3) { b.vx = (b.vx / (sp || 1)) * MAX_SPD * 0.3; b.vy = (b.vy / (sp || 1)) * MAX_SPD * 0.3; }
b.x += b.vx * dt;
b.y += b.vy * dt;
}
for (let w = 0; w < birdsArr.length; w++) {
const bb = birdsArr[w];
if (bb.x < 0) bb.x += W; else if (bb.x > W) bb.x -= W;
if (bb.y < 0) bb.y += H; else if (bb.y > H) bb.y -= H;
}
checksRef.current = checks;
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birdsArr = birdsArrRef.current;
if (!ctx || !col || birdsArr.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0.2)`);
hg.addColorStop(1, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0)`);
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
ctx.globalCompositeOperation = "lighter";
const slot = ((tSec * 6) | 0) % 7;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
const sp = Math.hypot(b.vx, b.vy);
const flash = i % 7 === slot ? 0.22 : 0;
const a = 0.34 + Math.min(0.4, (sp / MAX_SPD) * 0.4) + flash;
const col2 = i % 23 === 0 ? col.lilac : col.mist;
ctx.fillStyle = `rgba(${col2[0]},${col2[1]},${col2[2]},${a.toFixed(3)})`;
ctx.fillRect(b.x - 1, b.y - 1, 2, 2);
}
setChecks(String(checksRef.current));
}
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;
if (!manualRef.current) {
const ph = (t * TAU) / 6.2;
pxRef.current = 0.5 + 0.4 * Math.sin(ph);
pyRef.current = 0.5 + 0.32 * Math.sin(ph * 1.4 + 0.6);
}
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(e: PointerEvt): 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 (reduced()) { step(0.016); draw(STILL_T); }
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
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 (reduced()) { step(0.016); draw(STILL_T); }
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
cellRef.current = cell;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
dusk: tokRGB(root, "--flock-dusk"),
lilac: tokRGB(root, "--flock-lilac"),
mist: tokRGB(root, "--flock-mist"),
};
buildBirds();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let s = 0; s < 30; s++) step(0.033);
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
}, [birds, cell]);
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="M40 40h64v64H40z" opacity=".2"/><path d="M96 32H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56H48V48h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56h-40V48h40Zm-72 40H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56H48v-40h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56h-40v-40h40Z"/></g></svg>
<span>화면을 48px 셀로 나눠 자기 셀과 인접 8칸만 조사합니다 — 개체 1000마리라도 전수 비교(1,000×1,000) 없이 무리가 만들어집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개체 · 프레임당 이웃 조사 <b className={styles.val}>{checks}</b>회</span>
<span className={styles.metaDim}>Map 해시 · 3x3 셀 조사 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}05 The arithmetic of a thousand — finding neighbors with a spatial hash — Measured area 48.21% · 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 UI06 Firework burst — the arithmetic of gravity and embers9 Canvas Fire Animation Scenes, No WebGL
Creative UI04 Wall and neighbor bounceCanvas Particle Animation: 9 Engine Patterns
Creative UI08 Strike flash — the whole scene cools off exponentially right after impact9 Lightning Effect Background Scenes, No WebGL