GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

캔버스 보이드(boids javascript) 군집 9종 — 새떼 애니메이션

보이드(boids javascript)는 이웃 반경 안 개체끼리 분리·정렬·결합 세 벡터를 더해 리더 없이 무리를 짓는 군집 시뮬레이션이며, 라이브러리·WebGL 없이 세 개의 벡터 규칙만으로 수백 마리 새떼가 짓는다는 것을 이 아홉 판이 캔버스와 순수 자바스크립트만으로 보여줍니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

세 규칙 하나가 아홉 군집으로 갈라지는 이유

순서는 군집에 얹히는 힘의 개수로 갈립니다. 01 세 규칙의 떼는 분리·정렬·결합 세 벡터만으로 도는 최소 단위이고, 02 새떼 실루엣과 03 물고기 떼는 그 뼈대에 그리기(날갯짓 위상·가상 깊이)를 입힌 장면 판입니다. 04 천적은 네 번째 힘 "도망"이 처음 더해지는 판이고, 05 공간 해시는 힘은 그대로 두고 이웃을 찾는 셈을 격자로 바꿉니다. 06 머뮤레이션은 바람장을, 07 궤도는 인력점을, 08 플랑크톤은 발광 합성을 얹고, 09 글자 떼는 목표점 스프링까지 더해 문장을 군집으로 재조립합니다. 잔상 트레일을 쓰는 06·07·09는 이 순서에 처음 등장하는 누적 기법이기도 합니다.

군집은 왜 세 규칙으로 짓나? — 분리·정렬·결합과 이웃 반경의 셈

떼를 짓는 가장 단순한 길은 리더를 두는 것이 아니라 아무 개체도 우위가 없게 두는 것입니다. 각 개체는 자기 눈앞의 이웃 반경만 보고 셋을 계산합니다 — 가까우면 밀려나고(분리), 옆의 방향에 끌리고(정렬), 무리의 한가운데로 흘러갑니다(결합). 이 세 벡터를 무게로 합해 매 프레임 속도를 조금씩 틀면 개체 어느 것도 전체를 알지 못하는데 전체가 하나의 결을 짓습니다. 크레이그 레이놀즈가 1987년에 제안한 이 세 규칙의 조합이 지금도 flocking 알고리즘 의 표준형입니다. 이웃 반경이 얼마인가가 성격을 가릅니다 — 01 의 42px 처럼 넓으면 촘촘한 한 덩어리, 06 의 26px 처럼 좁으면 결이 실처럼 가늘어집니다. 이 편의 정체성이 이 지점입니다. 314 불꽃 파티클처럼 개체 하나하나가 제 갈 길을 계산하는 독립 파티클도 아니고, 306 처럼 점과 점을 스프링 제약으로 묶어 당기는 물리 그물도 아닙니다 — 이웃을 읽는 규칙 자체가 개체 사이를 이어 무리를 짓습니다.

항목별 코드

01세 규칙의 떼 — 분리·정렬·결합만으로 도는 최소 군집

인트로 배경·시뮬레이션 학습의 첫 화면에 쓰는, 개체 260마리가 세 벡터의 합만으로 무리를 짓는 최소 보이드입니다 — 실측 누적면적 41.242% · 강도 평균 124.8 · 23/23 프레임이 움직였습니다.

세 규칙의 떼 — 분리·정렬·결합만으로 도는 최소 군집
새 창에서 열기

01 · 세 규칙의 떼 — 분리·정렬·결합만으로 도는 최소 군집 — 측정 누적면적 41.242% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectrequestAnimationFrameMath.hypot

이웃 반경 안에서 세 벡터를 모은다

var R_NEIGH = 42;    // 이웃 반경 (px)
var MAX_SPD = 128;   // 최대 속도 (px/s)
var MAX_FOR = 340;   // 최대 힘 (px/s^2) — 스티어 예리함
// 개체 i 의 한 스텝 — 이웃 j 를 훑어 세 스티어링 원료를 쌓는다
for (var j = 0; j < n; j++) {
  if (j === i) continue;
  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;   // 가까울수록 큰 가중
  }
}

R_NEIGH 42px 안의 이웃만 셈에 들어가고 분리는 그 절반(21px) 안의 가까운 이웃에서만 거리에 반비례해 밀어내며, 정렬은 이웃 속도의 평균·결합은 이웃 질량중심 방향으로 원료를 나눠 쌓습니다.

세 벡터를 무게로 합해 스티어한다

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.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -4) b.x += W + 8; else if (b.x > W + 4) b.x -= W + 8;
if (b.y < -4) b.y += H + 8; else if (b.y > H + 4) b.y -= H + 8;

벽 반사 대신 반대편으로 이어 붙여 떼가 화면 가장자리에서 끊기지 않고 흐르며, 최소 속도 규칙(MAX_SPD * 0.5 밑으로 안 떨어짐)이 개체가 제자리에서 얼어붙는 것도 막습니다.

02새떼 실루엣 — 날갯짓 위상이 어긋나는 깃털

저녁 무드 페이지의 하늘 배경으로 쓰는, 삼각형 날개 두 개가 개체마다 어긋난 위상으로 파닥이며 진행 방향으로 몸이 기우는 검은 새떼입니다 — 실측 누적면적 39.212% · 강도 평균 69.5 · 23/23 프레임이 움직였습니다.

새떼 실루엣 — 날갯짓 위상이 어긋나는 깃털
새 창에서 열기

02 · 새떼 실루엣 — 날갯짓 위상이 어긋나는 깃털 — 측정 누적면적 39.212% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

saverotaterequestAnimationFrame

날갯짓은 개체마다 다른 sin 위상

b.ph = rand() * TAU;              // 날갯짓 위상 — 개체마다 어긋나게
b.flap = 5.2 + rand() * 3.4;      // 날갯짓 속도 (rad/s)
b.sz = 0.8 + rand() * 0.55;       // 날개 스케일
// 그리기 — 위상이 양수일 때 위로, 음수일 때 아래로 접힌다
var flap = Math.sin(tSec * b.flap + b.ph);        // -1..1 — 날개 펄럭임
var ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);                  // 진행 방향으로 몸을 회전

모든 새가 같은 박자로 파닥이면 기계 문양처럼 죽어 보이기 때문에, flap 속도와 ph 위상을 개체마다 흩어 둔 것이 떼가 한 몸처럼 보이면서도 낱마리가 살아 있는 느낌을 주는 차이입니다.

날개 두 개는 서로 반대 위상으로 접힌다

var wy = flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(1.5 * s, 0);
ctx.lineTo(-2.5 * s, (-4.5 + wy) * s * 0 + wy - 4.5 * s);
ctx.lineTo(-1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
// 날개 뒤 — 앞 날개와 반대 위상(어긋난 두 박자)
var wy2 = -flap * 4.2 * s;

저녁하늘은 숨쉬는 그라디언트 정지점

var breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
var horizon = H * (0.72 + 0.05 * breath);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, 'rgb(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ')');
sky.addColorStop(Math.max(0.2, horizon / H - 0.22), 'rgb(...)');
sky.addColorStop(1, 'rgb(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ')');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);

지평선 파스텔의 정지점을 tSec 로 미세하게 밀어 하늘 전체가 은은히 숨쉬게 하고, 새는 잔상 없이 매 프레임 완전히 다시 그려 01 과 대비되는 또렷한 실루엣을 만듭니다.

03물고기 떼 — 깊이감을 가진 스쿨링

아쿠아리움·해양 콘셉트 소개 화면에 쓰는, 개체마다 가상 깊이 z(0~1) 를 들고 다녀 멀수록 작고 옅게 그려지는 물고기 떼입니다 — 실측 누적면적 44.171% · 강도 평균 96.1 · 23/23 프레임이 움직였습니다.

물고기 떼 — 깊이감을 가진 스쿨링
새 창에서 열기

03 · 물고기 떼 — 깊이감을 가진 스쿨링 — 측정 누적면적 44.171% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

globalAlphascalerequestAnimationFrame

가상 깊이 z 로 크기와 알파를 정한다

b.z = rand();                    // 가상 깊이 0(가까움)~1(멀음)
// 그리기 — 깊이 z 순으로 그린다(멀 것부터 → 가까운 것이 위에 덮임)
var order = boids.slice();
order.sort(function (p, q) { return q.z - p.z; });
var depth = 1 - b.z;               // 1 = 가까움, 0 = 멀음
var sc = (0.45 + 0.75 * depth) * b.sz;   // 멀수록 작게
var alpha = 0.35 + 0.6 * depth;    // 멀수록 옅게 — 층의 핵심

z 값 하나가 스케일과 globalAlpha 를 함께 결정하고, 매 프레임 z 순으로 정렬해 멀리 있는 물고기부터 그리면 가까운 개체가 자연스럽게 위에 덮이는 깊이 질서가 생깁니다.

유선형 몸과 꼬리는 진행축으로 눕는다

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.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();

상부 광원이 느리게 숨쉬는 심해 무대

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)');

수면에서 스며드는 빛을 화면 밖 상단의 방사 그라디언트로 치고 반경과 알파가 0.8 rad/s 로 숨쉬며, z 가 가장 작은(가까운) 5마리에는 라일락 눈광점을 찍어 시선의 닻을 둡니다.

04천적 등장 — 도망 벡터가 쪼개는 떼

인터랙티브 히어로·마우스 반응 이벤트에 쓰는, 포인터를 천적으로 삼아 반경 안 개체에 강한 도망 벡터를 더해 떼가 갈라졌다 다시 봉합되는 판입니다 — 실측 누적면적 40.232% · 강도 평균 114.6 · 23/23 프레임이 움직였습니다.

천적 등장 — 도망 벡터가 쪼개는 떼
새 창에서 열기

04 · 천적 등장 — 도망 벡터가 쪼개는 떼 — 측정 누적면적 40.232% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermovefillRectrequestAnimationFrame

네 번째 힘 도망은 거리의 제곱으로 세진다

var FLEE_R = 90;     // 천적 도망 반경(px)
var pdx = b.x - predPx, pdy = b.y - predPy;
var pd = Math.hypot(pdx, pdy);
if (pd < FLEE_R && pd > 0.0001) {
  var t = (FLEE_R - pd) / FLEE_R;              // 0..1 — 가까울수록 1
  var mag = t * t * MAX_FRC * 3.2 * FLEE_GAIN; // 제곱으로 급격히 강해짐
  fx += (pdx / pd) * mag; fy += (pdy / pd) * mag;
}

t * t 제곱 곡선이 핵심이라 반경 끝에서는 거의 0이던 힘이 중심에 가까워질수록 급격히 커져, 천적 바로 앞의 개체는 튕기듯 피하고 반경 가장자리의 개체는 살짝 비켜섭니다.

갈라진 틈은 결합력이 다시 봉합한다

var PERCEP  = 40;     // 이웃 지각 반경
var SEP_R   = 18;     // 분리 반경
var W_SEP = 1.7, W_ALI = 1.0, W_COH = 0.9;
if (cohN) { fx += (cx / cohN) * 0.04 * W_COH; fy += (cy / cohN) * 0.04 * W_COH; }

토러스 최단거리로 화면 끝의 이웃도 잡는다

var dx = o.x - b.x, dy = o.y - b.y;
// 토러스 최단거리(랩된 화면에서 가까운 쪽으로)
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 d = Math.hypot(dx, dy);

좌표를 반영 대신 최단거리로 보정해 왼쪽 끝의 개체가 오른쪽 끝의 이웃을 실제 가까운 이웃으로 보게 하고, 도망 벡터도 같은 보정을 거쳐 화면 반대편 천적을 피해 갑니다.

05천 마리의 셈 — 공간 해시로 이웃 찾기

대규모 파티클 데모·성능 학습 자료로 쓰는, 화면을 48px 셀로 나눠 자기 셀과 인접 8칸만 조사해 개체 1000마리의 이웃 탐색을 전수 비교에서 격자 조회로 떨어뜨리는 판입니다 — 실측 누적면적 48.21% · 강도 평균 71.3 · 23/23 프레임이 움직였습니다.

천 마리의 셈 — 공간 해시로 이웃 찾기
새 창에서 열기

05 · 천 마리의 셈 — 공간 해시로 이웃 찾기 — 측정 누적면적 48.21% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MapfillRectrequestAnimationFrame

매 프레임 Map 해시에 셀을 다시 짠다

var N_BIRD = 1000;   // 개체 수
var CELL   = 48;     // 해시 셀 한 변(px)
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);
  }
}

개체가 매 프레임 옮겨가므로 해시도 매 프레임 다시 지으며, "cx,cy" 문자열 키 하나에 그 셀의 인덱스 배열을 담는 것이 전부라 자료구조 부담이 없습니다.

자기 셀과 인접 8칸만 조사한다

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++;
      // 여기서 거리 재판정 — PERCEP 30 안이면 이웃
    }
  }
}

천 마리가 남는 이유

전수 비교는 1000마리에서 프레임당 백만 번의 거리 계산을 하지만, 같은 장면을 3×3 격자 조회로 바꾸면 조사는 개체당 실제 몇십 회로 떨어지고 화면의 checksThisFrame 이 그 차이를 숫자로 보여 줍니다.

06머뮤레이션 — 흐름선을 남기는 검은 구름

자연 다큐멘터리 무드의 히어로 배경에 쓰는, 지나간 자리를 반투명으로 덮어 잔상을 남기고 컬 노이즈 바람을 떼 전체에 불어 갈대밭 위 오르내리는 검은 물결을 만드는 판입니다 — 실측 누적면적 51.635%(아홉 판 중 가장 넓습니다) · 강도 평균 58.2 · 23/23 프레임이 움직였습니다.

머뮤레이션 — 흐름선을 남기는 검은 구름
새 창에서 열기

06 · 머뮤레이션 — 흐름선을 남기는 검은 구름 — 측정 누적면적 51.635% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

globalCompositeOperationcreateLinearGradientrequestAnimationFrame

잔상은 반투명 덮개 알파 하나로 남긴다

var N_BIRD = 400;    // 개체 수
var TRAIL  = 0.14;   // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
// 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 0.14 로 궤적을 남긴다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);

컬 노이즈 바람은 사인 중첩의 회전장이다

// potential psi 의 회전(u, v) = (dpsi/dy, -dpsi/dx)
// psi 는 사인 3겹 중첩(방향·주파수 다른 3개) — 라이브러리 없이 계산으로 만드는 흐름장
var psi =
  Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
  Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
  Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45;
var e = 1.5;                      // 유한차분 간격(px)
var dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
var dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
// 회전장 — 발산 0(공기가 쌓이거나 빠지지 않는다)
var mag = 46 * WIND;
return [dpsiDy * mag, -dpsiDx * mag];

포텐셜을 사인 세 겹으로 쌓고 그 회전(컬)을 유한차분으로 샘플링하면 발산이 0인 흐름장이 되어, 공기가 한쪽에 쌓이지 않는 노이즈 라이브러리 없는 바람으로 떼가 시들지 않고 계속 흐릅니다.

지평선 띠는 잔상 위에 매 프레임 산다

var hy = H * 0.82;
var hb = ctx.createLinearGradient(0, hy - 10, 0, hy + 8);
hb.addColorStop(0, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0)');
hb.addColorStop(0.5, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.5)');
hb.addColorStop(1, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0)');
ctx.fillStyle = hb;
ctx.fillRect(0, hy - 10, W, 18);

07먹이 중심 — 인력 근처를 도는 궤도

명상·집중 페이지의 느린 배경으로 쓰는, 중앙 인력점 하나가 개체를 방사 감쇠 인력과 접선 힘으로 느린 소용돌이 궤도에 붙잡는 판입니다 — 실측 누적면적 31.175% · 강도 평균 84.0 · 23/23 프레임이 움직였습니다.

먹이 중심 — 인력 근처를 도는 궤도
새 창에서 열기

07 · 먹이 중심 — 인력 근처를 도는 궤도 — 측정 누적면적 31.175% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

createRadialGradientfillRectrequestAnimationFrame

가우시안 감쇠 인력과 접선 소용돌이

var ATTRACT = 26;       // 인력 세기 — 낮게 잡아 궤도가 넓게 퍼진다
var SWIRL = 40;         // 소용돌이(접선) 힘
var orbR = 270 * p.orbBias;                     // 개체별 목표 궤도 반경
var decay = Math.exp(-(d * d) / (orbR * orbR));  // 가우시안 감쇠 — 중심 근처만 강히
var pull = -ATTRACT * pullMul * decay;
var tx = -ny, ty = nx;                          // 접선 방향(수직)
var swirl = SWIRL * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
var ax = nx * pull + tx * swirl + ff[0];
var ay = ny * pull + ty * swirl + ff[1];

인력은 중심에서 멀수록 지수로 약해져 바깥 개체는 자유 궤도를 돌고 접선 힘이 그 궤도를 원을 그리게 미는데, 개체마다 orbBias(0.55~2.2배)가 달라 목표 궤도 반경이 흩어져 화면 전역에 궤도가 퍼집니다.

군집 3벡터는 약하게 얹어 결을 유지한다

var NEI_R = 26, NEI_R2 = NEI_R * NEI_R, SEP_R = 11;
var W_SEP = 20, W_ALI = 7, W_COH = 4.2;
// 표준 군집 3벡터 — 토러스 최단거리 보정, 분리·정렬·결합
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);
}

잔상 0.08 덮개로 궤적을 길게 남긴다

var TRAIL = 0.08;       // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';

06 보다 낮은 0.08 덮개로 궤적을 더 길게 남겨 소용돌이의 흐름을 한눈에 읽게 합니다.

08발광 플랑크톤 — 밤바다에서 빛나는 떼

야간 이벤트·몰입형 스토리 페이지에 쓰는, 개체를 발광 점으로 그려 lighter 합성으로 겹치면 밝아지게 둔 밤바다의 야광 무리입니다 — 실측 누적면적 32.674% · 강도 평균 17.5 · 23/23 프레임이 움직였습니다.

발광 플랑크톤 — 밤바다에서 빛나는 떼
새 창에서 열기

08 · 발광 플랑크톤 — 밤바다에서 빛나는 떼 — 측정 누적면적 32.674% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

globalCompositeOperationcreateRadialGradientrequestAnimationFrame

발광 스프라이트 한 장을 미리 구워 찍는다

var N = 180;          // 개체 수
var GLOW_R = 7;        // 발광 반경(px)
var spriteSize = Math.max(8, Math.round(GLOW_R * 6));
var sprite = document.createElement('canvas');
sprite.width = spriteSize; sprite.height = spriteSize;
(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);
})();

개체마다 방사 그라디언트를 새로 만들면 180장의 그라디언트 계산이 매 프레임 쌓이기 때문에, 흰 발광 스프라이트를 오프스크린에 한 장 구워 drawImage 로 찍는 것이 발광 떼의 성능 답입니다.

lighter 합성은 겹침을 빛으로 바꾼다

ctx.globalCompositeOperation = 'lighter';
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.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
ctx.restore();

표류와 도망을 얹어 느슨한 학교를 짓는다

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 FLEE_R = 46;       // 도망 반경(px)
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;

결합(2.4)을 아주 약하게 잡아 물고기 떼보다 느슨한 플랑크톤다운 부유 결을 만들고, 사인 표류에 -3 상수를 얹어 위로 뜨는 성향과 속도 감쇠(Math.pow(0.88, dt * 60))가 물의 점성을 대신합니다.

09글자를 이루는 떼 — 포인터를 피해 모이는 문장

히어로 헤드라인·전시 소개 문구에 쓰는, 글자의 밝은 픽셀 위치를 목표점으로 나눠 주고 포인터 근처에서만 도망 벡터를 더해 헤치면 흩어지고 비키면 다시 문장으로 돌아오는 판입니다 — 실측 누적면적 31.423% · 강도 평균 50.2 · 23/23 프레임이 움직였습니다.

글자를 이루는 떼 — 포인터를 피해 모이는 문장
새 창에서 열기

09 · 글자를 이루는 떼 — 포인터를 피해 모이는 문장 — 측정 누적면적 31.423% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillTextpointermoverequestAnimationFrame

오프스크린 fillText 에서 목표점을 샘플한다

var FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
var sampleGap = 4;   // 픽셀 샘플 간격
octx.font = FONT_STACK;
octx.textAlign = 'center';
octx.textBaseline = 'middle';
octx.fillText(word, off.width / 2, off.height / 2);   // word = 'BOIDS'
var img = octx.getImageData(0, 0, off.width, off.height);
var d = img.data, ow = off.width, oh = off.height;
for (var yy = 0; yy < oh; yy += sampleGap) {
  for (var xx = 0; xx < ow; xx += sampleGap) {
    if (d[(yy * ow + xx) * 4] > 128) {
      targets.push({ x: xx / ow, y: yy / oh });   // 밝은 픽셀 = 목표점
    }
  }
}

화면에 안 보이는 오프스크린 캔버스에 굵은 산세리프로 문장을 칠하고 4px 간격으로 밝은 픽셀 좌표를 걷어 목표점 약 320개를 만들며, 글자가 폭을 넘기면 measureText 로 글꼴 크기를 비례 축소해 다시 칠합니다.

목표점 스프링과 도망 벡터의 줄다리기

var FLEE_R = 120;     // 도망 반경(px)
var SPRING = 6.0;     // 목표점 스프링 세기
var dx = p.tx * W - p.x, dy = p.ty * H - p.y;
var ax = dx * SPRING, ay = dy * SPRING;   // 목표로 당기는 스프링
var ff = flockForce(i);                   // 군집 3벡터(약하게)
ax += ff[0]; ay += ff[1];
var dxs = p.x - sX, dys = p.y - sY;
var ds = Math.hypot(dxs, dys) || 0.0001;
if (ds < FLEE_R) {
  var fm = (1 - ds / FLEE_R) * 460;
  ax += (dxs / ds) * fm; ay += (dys / ds) * fm;
}

흩어지는 궤적은 잔상 0.07로 남긴다

var TRAIL = 0.07;      // 잔상 덮개 알파 — 흩어지는 궤적을 남겨 누적면적을 넓힌다
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);

포인터가 문장을 가르고 지나가는 순간의 궤적이 잔상에 흐름선으로 쌓여 재조립 과정이 한 장면에 겹쳐 보이고, 포인터가 없는 렌더 환경에서는 6.2초 주기의 가상 포인터가 문장을 가로질러 흩었다 모읍니다.

군집을 실제로 쓰는 화면

로고나 브랜드 문구가 흩어졌다 다시 모이는 인트로에는 09 글자를 이루는 떼가 그대로 맞습니다 — 포인터가 다가오면 피하다가 다시 문장으로 합쳐지는 동작이 헤더 영역의 시선을 붙잡습니다. 카드 호버나 로딩 오버레이처럼 어두운 배경 위에서는 08 발광 플랑크톤처럼 빛만 남기는 장면이 무겁지 않게 어울립니다.

배경 스크롤 섹션에는 06 머뮤레이션처럼 흐름선을 남기며 크게 도는 검은 구름이나 02 새떼 실루엣처럼 위상이 어긋나는 조용한 반복이 맞고, 둘 다 텍스트 위에 겹치지 않는 여백에만 둡니다.

이웃 반경을 너무 넓히면 군집이 여기서 깨지는 함정

가장 먼저 깨지는 지점은 분리와 결합의 균형입니다. 결합을 아주 세우면 떼가 한 점으로 수축하다 분리 반사로 튕겨 나와 진동합니다. 03 물고기 떼를 만들 때 결합 계수를 더 세게 두었더니 뭉치가 똬리를 틀다 멈춰 버려 유영이 죽었고, 0.6으로 내리고서야 물결처럼 흐르는 뭉침이 살았습니다. 둘째, 06 컬 노이즈 바람의 유한차분 간격 e 를 1.5px 로 잡은 것은 흐름장이 매끄러운 사인 중첩이라 간격을 더 벌려도 방향이 급히 꺾이지 않기 때문입니다 — 값을 크게 벌리면 샘플이 표면을 못 쫓아 바람이 우드득 꺾입니다. 셋째, 05 격자의 셀 한 변(48px)을 지각 반경(30px)보다 작게 줄이면 3×3 밖의 이웃을 놓쳐 무리가 화면에서 반쯤 잘린 결로 갈라집니다. 마지막으로 09 의 목표점 배정은 인덱스 나눗셈(i % targets.length)으로 돌리는데, 샘플 간격을 촘촘히 줄여 목표점이 개체 수보다 많아지면 나머지 목표가 빈 자리로 남아 글자가 옅어집니다. 아홉 판의 바닐라 소스와 React 판을 열어 이 계수들을 직접 흔들어 보려면 암호 zip 을 열어야 하고, 여는 암호는 nhgr9myq 입니다.

이웃 수 하나로 갈리는 세 변형과 접근성 처리

값 하나를 바꾼 세 변형

이름 바꾼 값 느낌
개체 수 01 세 규칙 떼 260 → 640 떼가 짙어지는 대가로 프레임이 무거워진다
결합 세기 03 물고기 떼 결합 계수 0.6 → 1.2 뭉치가 단단해지지만 유영이 둔해진다
색 $flock-ink → $flock-lilac 검은 새 실루엣이 라일락 톤 물결로 바뀐다

셋 다 소스 맨 위 변형 상수 블록의 한 줄만 고치는 축입니다. 개체 수는 N_B 260을, 결합은 W_COH 0.6을, 색은 02 의 TONE 0→1 스위치를 움직이면 됩니다.

움직임을 줄인 사용자에게 남는 정지 프레임

prefers-reduced-motion: reduce 를 켠 사용자에게는 requestAnimationFrame 루프를 시작하지 않고, 미리 몇 스텝 시뮬레이션을 돌린 뒤 떼가 자리잡은 한 장만 그립니다 — 01·02·03 은 STILL_T 1.6초 시각, 04 는 도망이 시작된 순간(천적 0.5, 0.42 지점), 06 은 바람이 궤적을 그은 뒤 3.0초 장면입니다. 시뮬레이션 계산은 애니메이션과 똑같이 하고 화면만 한 번 그리므로 무리의 결이 그대로 읽힙니다.

키보드·포인터로 만지는 네 판

아홉 판 전부가 role="application" 과 tabindex="0" 을 가지고 있어 탭으로 항상 초점이 옮겨지며, 그중 04·05·07·08·09 는 키다운 핸들러가 더 있어 포인터·화살표 키로 천적과 자극을 몰 수 있습니다 — 04 는 방향키로 천적을, 07 은 가로 위치로 회전을·세로 위치로 인력 세기를, 09 는 화살표 키로 문장을 헤칩니다. reduced-motion 사용자도 손이 닿는 즉시 한 프레임을 다시 계산해 그립니다.

군집 시뮬레이션을 붙이기 전에 자주 나오는 질문

보이드 알고리즘 코드를 그대로 붙여 쓸 수 있나요?

붙여 쓸 수 있습니다. 각 판은 의존성 없이 짜여 있어, 암호 zip 에서 바닐라 판을 꺼내 <script> 로 붙이면 그대로 움직입니다. 개체 수·세 규칙 무게·이웃 반경은 각 판 소스 맨 위의 상수 블록에서 바꾸고, React 판도 같은 zip 에 들어 있습니다.

CSS 새떼 애니메이션으로도 이 효과를 낼 수 있나요?

정해 둔 경로를 도는 장식 새 몇 마리라면 CSS 키프레임으로 충분하지만, 이웃을 읽고 무리가 갈라졌다 봉합되는 군집은 매 프레임 개체 사이를 다시 계산해야 해서 CSS 로 만들 수 없습니다. boids algorithm javascript 검색으로 찾아오는 분이 원하는 것도 정확히 이 반응하는 떼 쪽입니다 — 리더 없이 규칙만으로 짓는 결이 캔버스 계산의 몫입니다.

개체를 몇 마리까지 늘려도 부드럽게 돌아가나요?

전수 비교 구조라면 개체 수의 제곱으로 셈이 늘어 300~400마리가 실용 한계입니다. 05 공간 해시는 화면을 48px 셀로 나눠 3×3 칸만 조사하므로 1000마리도 프레임당 조사가 수만 회 수준으로 떨어집니다. 셀 크기를 지각 반경보다 크게 유지하는 조건만 지키면 2000마리까지 늘려도 무리가 살아 있습니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.