/ 개발
ACS #1 — PixiJS 사용기
무인 운반 로봇(AMR) 관제 시스템의 프론트엔드를 만들고 있다. 서비스에서 가장 고민이 되었던 부분은 맵 부분이었는데, 실제 공장의 구조를 단순화해 표현한 맵 위에 장애물, 경로, 구역을 그리고, 그 위에서 로봇들이 실시간으로 움직이는 걸 보여주는 것이 목표였다.
시작할 때 정리한 요구사항:
- 좌표 스트림 — 로봇 한 대가 정보를 초당 몇 개를 보낼지에 대한 기획 사항이 없어서, 우선 최소부터 최대치를 테스트하는 과정을 거쳐야 했다. 초기에 상한으로 잡은 가정치는 로봇 100대 × 초당 30건 = 초당 3,000건. 이후 부하테스트로 초당 약 2,000건·로봇 300대 동시 이동까지 실측했고, 이 범위에서 렌더링은 병목이 아니었다(FPS·Frame Budget 초과율이 로봇 1대일 때와 거의 동일).
- 2D 기반 실시간 애니메이션 — x, y 의 좌표 기반으로 로봇의 이동이 표현되어야 한다.
- 맵 표현 — 비정형의 장애물/구역 표현이 될 수 있으며, line 형태의 경로 표현이 되어야 한다.
- 컴포넌트 결합성 — 그래픽 뿐만 아니라, 클릭 시 팝오버 같은 이벤트와 UI가 붙을 수 있어야 한다.
- 60fps — 공장에서 사용되는 서비스인만큼 정확한 로봇의 위치와 상태를 반영해야 한다.
왜 PixiJS인가 — 검토한 대안들
후보를 나열하면 SVG, Canvas 2D, 그리고 GPU 렌더링 계열(PixiJS, Three.js, Mapbox GL)이었다. 브라우저에서 GPU를 쓰는 API는 WebGL과 WebGPU 두 가지인데, 기술 선정 시점 기준으로 WebGPU는 지원 브라우저가 제한적이었고 공장 환경 특성상 구형 브라우저를 배제할 수 없었다. 그래서 렌더러 백엔드는 WebGL을 기준으로 잡았다.
SVG vs Canvas 2D vs WebGL
SVG는 도형 하나가 DOM 노드 하나가 된다. SVG 도형이 선언되면 브라우저가 그 도형을 문서 트리의 노드로 계속 보유한다. 이 상태에서 로봇이 이동하는 로직을 구현하면 해당 노드의 attribute가 변경되며, 스타일 재계산 → 지오메트리 갱신 → 페인트 → 합성의 과정을 거친다. 선언적이고 디버깅이 쉬운 건 큰 장점이지만, 생성할 수 있는 도형의 상한 개수가 없는 현재로선 요구사항을 충족하지 못할 것이라고 예상했다.
Canvas 2D는 immediate mode, 그린 것을 기억하지 않는다. ctx.fillStyle = "red"; ctx.fillRect(10, 10, 100, 100) 이 코드를 실행하면 캔버스에 빨간 사각형 모양의 픽셀이 찍히나, 이 사각형에 대한 정보를 저장하지 않는다. DOM에 저장해 attribute로 관리하는 SVG와 다른 점이다. 그래서 로봇 객체가 움직이면 어떠한 로봇 객체를 옮겨야 하는가에 대한 개념 자체가 없고, 클리어 후 장면 전체를 다시 명령해야 한다. 구현 자체도 raw할 것 뿐만 아니라, 수많은 요소를 매번 계산하고 다시 그리는 비용도 클 것으로 예상, 적합하지 않다고 생각했다.
WebGL은 그리기를 GPU로 넘긴다. GPU에 렌더링 작업을 맡겨 많은 양의 데이터를 병렬로 빠르게 처리할 수 있다. CPU는 명령(draw call)을 GPU에 전달하는 역할만 하고, 무거운 작업은 GPU가 처리하기 때문에 메인 스레드의 비용이 적다.
WebGL 기반 라이브러리: PixiJS vs Three.js vs Mapbox GL
| 렌더링 | 성격 | 이 프로젝트 기준 | |
|---|---|---|---|
| PixiJS | 2D (WebGL·WebGPU) | 고성능 2D 렌더링 엔진 | 요구사항 일치 |
| Three.js | 3D (WebGL·WebGPU) | 3D 씬 엔진 | 가능하지만 3D 오버헤드·개념(카메라·조명)이 오버스펙 |
| Mapbox GL | WebGL + 지도 엔진 | GIS 지도 | 타일·레이어 엔진 오버헤드, 경로가 많으면 불리 |
- Three.js로 2D를 못 그리는 건 아니다. 하지만 본질이 3D 씬 엔진이라, 사용되지 않을 기능이 많다.
- Mapbox GL은 지도 "엔진"이다. 구현할 맵은 GIS가 아니라 공장 도면 수준의 격자다.
- PixiJS는 처음부터 "고성능 2D 렌더러"가 정체성이며, 요구사항을 커버하고, 공식 문서도 잘 정리되어 있음. React 기반의 사례도 있는 편. v8은 WebGPU 렌더러도 지원하지만, 위에서 말한 이유로 렌더러 백엔드는 WebGL을 쓰도록 했다.
PixiJS — 구조
씬 그래프
Pixi는 Canvas 2D처럼 매번 명령을 다시 내리는 게 아니라, Container 트리에 그릴 객체들을 보유한다. 이 트리를 씬 그래프(scene graph)라고 부른다. 루트는 항상 app.stage이고, addChild로 붙인 객체는 그 순간 렌더링 대상이자 이벤트 대상이 된다. (app.stage.addChild(...);)
app.stage
├─ containerA
│ ├─ spriteB
│ └─ spriteC
└─ spriteD
이 구조에서 로봇 이동은 "스프라이트의 position을 바꾼다"로 끝난다. 매 프레임 Pixi가 stage부터 트리를 순회하며 각 노드의 최종 위치·회전·스케일·투명도를 계산해두고(worldTransform, worldAlpha), 렌더러는 그 값만 보고 그리기 때문이다. DOM과 비슷한 멘탈 모델인데, 노드가 브라우저의 무거운 레이아웃 파이프라인이 아니라 GPU로 가는 가벼운 객체라는 점이 다르다.
부모-자식 관계의 규칙은 하나다: 부모의 변환은 자식에게 누적 적용된다. 부모를 이동·회전·스케일하면 자식이 같이 움직이고, 투명도는 곱셈으로 누적된다(부모 alpha 0.5 × 자식 alpha 0.5 = 실제 0.25). 맵 요소 전체를 하나의 Container 아래에 넣으면 줌·팬이 "그 Container의 scale/position 변경" 하나로 끝나는 이유가 이것이다. 아래 viewport에서 다시 나온다.
배칭(batching)
WebGL에서 비싼 건 그리기 자체보다 draw call이다. Pixi는 같은 텍스처·같은 상태의 객체들을 하나의 버퍼로 묶어 draw call 수를 줄인다. 로봇 100대가 같은 스프라이트라면 100번이 아니라 한 번에 가깝게 그려진다.
PixiJS — 적용
맵 뷰어의 구성은 세 가지로 정리된다.
맵 요소(그리드·경로·구역)는 Graphics. 좌표 데이터 기반의 선·다각형이라 벡터 드로잉 API로 직접 그린다. 경로는 일정 굵기의 선, 구역은 채워진 다각형이다.
로봇은 Sprite + SVG 텍스처. Assets.load로 SVG 아이콘을 불러와 스프라이트의 텍스처로 쓴다.
const ROBOT_SVG = await Assets.load('/src/assets/robot-activate.svg');class Robot { sprite: Sprite; constructor() { this.sprite = new Sprite(ROBOT_SVG); }}
드래그·핀치·휠 줌·줌 클램핑의 기능을 구현하기 위해 pixi-viewport 라이브러리를 사용했다. stage 바로 아래에 viewport를 두고, 모든 객체를 viewport의 자식으로 넣는다.
const app = new Application();app.stage.addChild(viewport);viewport.addChild(gridGraphics);viewport.addChild(trackGraphics); viewport.addChild(pointGraphics); const robot = new Robot({ id: i, x, y, stage: viewport });
Pixi는 트리를 부모 → 자식 순서로 그리고, 뒤에 추가된 자식이 위에 온다. 즉 addChild 순서가 곧 레이어 순서라, 그리드 → 경로 → 포인트 → 로봇 순으로 추가해 z-index를 설정했다.
viewport .drag({ clampWheel: false }) .pinch() .wheel({ smooth: 5, percent: 0.1, trackpadPinch: true }) .clampZoom({ minWidth: 100, minHeight: 100, maxWidth: MAP_WIDTH, maxHeight: MAP_HEIGHT });