포트폴리오 배경을 자율 에이전트 시뮬레이션으로 만들기
이 사이트의 배경에서는 작은 에이전트들이 쉬지 않고 움직입니다. 스스로 목표 노드를 골라 이동하고, 도착하면 궤도를 돌며 “작업”을 처리하고, 끝나면 다음 목표를 찾아 떠납니다. 마우스를 가져가면 하던 일을 멈추고 커서 주위로 모여들고, 클릭하면 충격파에 흩어졌다가 다시 제 할 일로 돌아갑니다.
게임의 오토 모드를 구경하는 느낌을 의도했습니다. 이 글에서는 이 시뮬레이션을 어떻게 설계하고 구현했는지 정리합니다. 파라미터를 직접 조절해볼 수 있는 실험실 버전도 있습니다.
왜 three.js를 버렸나
이전 배경은 three.js 기반이었습니다. 셰이더로 모핑하는 블롭, 반짝이는 별, 별자리 라인 — 예뻤지만 두 가지가 아쉬웠습니다.
- 번들 크기. 배경 하나를 위해 three.js 전체를 실어 나르는 건 과했습니다.
- 서사 부재. 움직임이 무작위라서 “보는 맛”이 없었습니다. 별이 반짝이는 건 3초면 충분히 감상합니다.
새 배경은 Canvas 2D API만 씁니다. 의존성 0, 코드 전체가 한 파일. 대신 움직임에 목적을 넣었습니다. 에이전트마다 지금 뭘 하려는지가 있고, 그게 화면에 보입니다 — 목표까지의 점선 경로, 차오르는 진행률 링, 완료 펄스.
상태머신: seek → work → 다음 태스크
각 에이전트는 3개 상태를 가진 단순한 유한 상태 기계(FSM)입니다.
┌──────────────────────────────┐
▼ │
┌────────┐ 도착 ┌────────┐ 완료│
│ seek │ ────────▶ │ work │ ────┘
└────────┘ └────────┘
▲ │
│ 호기심 종료 │ 커서 접근
│ ┌─────────┐
└────────────── │ curious │
└─────────┘
- seek: 목표 노드를 향해 이동. 도착 판정 반경에 들어오면
work로 전이. - work: 노드 주위를 궤도 비행하며 1.4~3.2초간 “처리”. 이 시간이 노드의 진행률 링으로 그려집니다. 완료되면 노드가 펄스를 내뿜고, 연결된 노드로 패킷을 하나 쏘고, 새 목표를 골라
seek로. - curious: 커서가 가까이 오면 확률적으로 진입. 커서를 중심으로 궤도를 돌다가 1.6~3.2초 후 쿨다운과 함께 원래 임무로 복귀. 쿨다운이 없으면 에이전트들이 커서에 영원히 붙어버립니다.
상태가 3개뿐인데도 “일하는 무리”처럼 보이는 이유는, 각 에이전트의 타이머와 목표가 전부 달라서 전체 화면에서는 항상 어딘가에서 이동·처리·완료가 동시에 일어나기 때문입니다.
조향 행동 (Steering Behaviors)
이동은 Craig Reynolds의 고전적인 steering behaviors 패턴을 단순화한 것입니다. 핵심은 “위치를 직접 옮기지 않고, 원하는 속도와 현재 속도의 차이만큼 힘을 가한다”는 것.
function steerTo(a: Agent, tx: number, ty: number, dt: number, arrive = 0) {
const dx = tx - a.x, dy = ty - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
let speed = a.maxSpeed;
if (arrive > 0 && d < arrive) speed *= d / arrive; // 도착 감속
const steer = 3.2; // 반응성
a.vx += ((dx / d) * speed - a.vx) * Math.min(1, steer * dt);
a.vy += ((dy / d) * speed - a.vy) * Math.min(1, steer * dt);
}
여기에 세 가지를 얹습니다.
- Arrival: 목표 근처에서 거리에 비례해 감속. 이게 없으면 노드를 휙 지나쳐서 왕복하는 우스운 모습이 됩니다.
- Wander: 사인파 기반의 약한 흔들림. 경로가 레이저처럼 직선이면 기계적으로 보입니다.
Math.sin(wanderPhase * 1.7) * 14 * dt정도의 작은 노이즈만으로 살아있는 느낌이 납니다. - Separation: 가까운 에이전트끼리 밀어내는 힘. 여러 에이전트가 같은 노드에서 일할 때 겹치지 않게 해줍니다. 에이전트가 ~30개라 O(n²) 무식한 이중 루프로도 충분합니다.
속도 클램프는 maxSpeed의 3배까지 허용합니다. 평소엔 도달할 수 없는 값이지만, 충격파로 튕겨나갈 때의 과속을 자연스럽게 감쇠시키는 여유 구간이 됩니다.
마우스: 필드로서의 커서
커서는 클릭 대상이 아니라 필드입니다. 두 가지 규칙만 있습니다.
- 유혹(lure): 커서가 살아있고(최근 2.2초 내 이동) 에이전트가 반경 안 + 쿨다운 종료 상태면
curious로 전이. 커서 좌표는 지수 평활(sx += (x - sx) * 8dt)로 따라가서, 마우스를 휙 움직여도 에이전트들이 부드럽게 따라옵니다. - 충격파: 클릭 지점에서 거리 감쇠하는 방사형 임펄스.
work/curious중이던 에이전트도 강제로seek로 리셋되고 잠시 쿨다운이 걸립니다. 흩어졌다가 다시 각자 임무로 복귀하는 흐름이 이 시뮬레이션에서 제일 게임 같은 순간입니다.
캔버스 자체는 pointer-events: none이라 페이지 조작을 전혀 방해하지 않습니다. 이벤트는 window에서 받습니다.
Canvas 2D 성능
WebGL 없이도 60fps를 유지하기 위해 한 일들:
- 그리드 프리렌더: 배경 도트 그리드는 매 프레임 수백 번의
fillRect대신, 리사이즈 시점에 오프스크린 캔버스에 한 번 그려두고drawImage한 번으로 찍습니다. - DPR 캡:
devicePixelRatio를 1.75로 제한. 고밀도 디스플레이에서 픽셀 수가 2.25배로 뛰는 것을 막습니다. 배경 그래픽에서 그 차이는 보이지 않습니다. - 개체 수 스케일링: 화면 면적 기준으로 에이전트·노드 수를 계산하고, 터치 기기에서는 더 줄입니다.
- 탭 숨김 시 정지:
visibilitychange에서cancelAnimationFrame. 백그라운드 탭에서 배터리를 태울 이유가 없습니다. prefers-reduced-motion존중: 모션 최소화 설정 사용자에게는 애니메이션 루프 없이 정적인 그래프 한 장만 그립니다.- 트레일은 링 버퍼: 잔상 효과는 반투명 덧칠(광원 누적) 대신 에이전트별 최근 22개 좌표를 폴리라인으로 그립니다. 밝은 배경에서는 덧칠 방식이 지저분해지기 때문이기도 합니다.
마치며
전체 코드는 GitHub 저장소의 src/components/AgentField.astro 한 파일입니다. 프레임워크 없는 시뮬레이션 코드는 오랜만이었는데, 상태머신 + 조향 행동 조합은 20년 전 게임 AI 교과서 그대로면서도 여전히 잘 통합니다.
실험실 버전에서 에이전트 수를 60까지 올리고 충격파를 눌러보세요. 그게 이 글보다 설명이 빠릅니다.