그리기
<IsoScene>
씬 하나를 SVG 로 그리는 뷰어. 편집 기능은 없고 렌더·이벤트·상태 바인딩·재생만 한다.
| prop | 기본 | 뜻 |
|---|---|---|
scene | (필수) | 그릴 씬 |
packs / registry | 에셋 팩 목록, 또는 createRegistry(packs) 로 만든 레지스트리 (둘 중 하나) | |
rotation | 0 | 시점 회전 0·90·180·270 |
states | 오브젝트 id → 상태 이름. 실시간 데이터를 여기로 넘긴다 | |
showGrid | true | 바닥 격자선 |
animateMotion | true | 씬 데이터에서 사람 자리·자세가 바뀌면 걷기·앉기로 잇는다 |
playTimelines | false | 타임라인 재생. true 면 전부, 배열이면 그 id 만 |
timelineSpeed | 1 | 타임라인 배속 (clock 을 주면 보지 않음) |
playBehaviors | false | 루틴 재생 (behavior 를 주면 보지 않음) |
behaviorSpeed | 1 | 행동 배속 (clock·behavior 를 주면 보지 않음) |
behavior | 앱이 만든 useBehaviorPlayback(). 주면 그것을 그린다 — 명령·읽기는 사람 움직이기 | |
clock | 행동·타임라인이 함께 쓰는 시계. 재생·멈춤·배속을 앱이 한다 | |
showActivities | false | 사람 머리 위 활동 아이콘 말풍선 |
onActivityClick | (id) => void. 말풍선을 누르면 그 사람 id. 말풍선만 손가락 커서가 되고, 이 클릭에는 onSceneClick·onObjectClick 이 불리지 않는다. ActivityBubbles 를 직접 쓰면 onBubbleClick | |
renderOverlay | (overlay) => ReactNode. 매 프레임 사람·가구 위에 SVG 를 덮어 그린다 | |
onObjectClick | (object) => void | |
onSceneClick | (point, object | null) => void. 포인터 아래 바닥의 씬 좌표와 클릭한 오브젝트 | |
padding | 24 | 씬 둘레 여백 (SVG 좌표) |
highlightColor | #3b5bdb | 포인터가 올라간 오브젝트 테두리 색 |
className·style | <svg> 에 붙는다. 부모 크기를 채우므로 부모에 높이를 준다 |
오버레이
renderOverlay 는 매 프레임 이번 프레임의 실루엣으로 불린다. 걷는 사람 위의 표시가 사람을 그대로 따라간다.
<IsoScene
scene={scene}
registry={registry}
behavior={behavior}
renderOverlay={({ hulls, project, top }) => (
<g pointerEvents="none">
{/* 고른 사람 테두리 */}
{selected && <HullOutline hull={hulls.get(selected)} color="#f59f00" width={2} />}
{/* 씬 좌표 한 점 (회의실 입구) 에 핀 */}
<circle cx={project([6, 3, 0])[0]} cy={project([6, 3, 0])[1]} r={3} fill="#e03131" />
{/* 머리 위 이름표 */}
{[...behavior.ids].map((id) => {
const at = top(id)
return at && <text key={id} x={at[0]} y={at[1] - 4} textAnchor="middle" fontSize={8}>{names[id]}</text>
})}
</g>
)}
/>| overlay | 뜻 |
|---|---|
hulls | 오브젝트 id → 화면 실루엣 (SVG 좌표). 퇴근·외출한 사람은 없다 |
project(point) | 씬 좌표 [x, y, z] → SVG 좌표 (시점 회전 반영) |
top(id) | 실루엣 맨 위 가운데 (사람이면 머리 위). 없으면 null |
bounds(id) | 실루엣을 감싼 사각형. 발밑 이름표는 ((minX + maxX) / 2, maxY + 여백) 에 단다 (머리 위 말풍선과 겹치지 않는다) |
SVG 가 아닌 HTML(툴팁·패널)을 겹치려면 SVG 좌표를 화면 픽셀로 바꾼다: svg.getScreenCTM() 으로 new DOMPoint(x, y).matrixTransform(ctm) 을 계산한다.
클릭한 곳
<IsoScene
scene={scene}
registry={registry}
behavior={behavior}
onSceneClick={([x, y], object) => {
if (object && registry.get(object.asset)?.seats?.length) behavior.command('kim', { type: 'do', spot: object.id })
else behavior.command('kim', { type: 'go', to: [x - 0.25, y - 0.25] }) // 발자국 모서리로
}}
/>point는 포인터 아래 바닥재·러그 윗면 의 씬 좌표다. 두꺼운 바닥재 위에서도 포인터와 맞는다.- 가구를 누르면
object가 그 가구다. 가구 위를 눌러도point는 그 뒤 바닥이므로, 가구 클릭은object로 다룬다. - React 밖에서는
floorPointAt(scene, registry, [sx, sy], rotation)이 같은 계산을 한다.
훅으로 직접 그리기
<IsoScene> 의 레이아웃·이벤트가 맞지 않으면 훅과 레이어로 직접 조립한다. 편집기(Studio 캔버스)가 이 방식이다.
import { prepareRender } from '@isora/core'
import {
ActivityBubbles,
GroundGrid,
IsoStyles,
LiveSceneLayer,
useBehaviorPlayback,
useObjectMotion,
type LiveSource,
} from '@isora/react'
import { useMemo } from 'react'
function Canvas({ scene, registry }) {
const behavior = useBehaviorPlayback(scene, registry)
const motion = useObjectMotion(scene, registry, { ignore: behavior.ids })
// 매 프레임 그릴 사람(걷고 앉는 사람)
const sources = useMemo(() => [motion.live, behavior.live].filter((s): s is LiveSource => s !== null), [motion.live, behavior.live])
// 나머지는 한 번 그리고 앞뒤 간선까지 재 둔다. 매 프레임 그릴 사람은 dynamic 으로 뺀다.
const prepared = useMemo(
() => prepareRender(scene, registry, { states: behavior.states, dynamic: new Set(sources.flatMap((s) => [...s.ids])) }),
[scene, registry, behavior.states, sources],
)
return (
<svg viewBox="-400 -40 800 480">
<IsoStyles />
<GroundGrid scene={scene} />
<LiveSceneLayer prepared={prepared} sources={sources} interactive onObjectClick={(id) => console.log(id)}>
{(render) => <ActivityBubbles people={behavior.people} hulls={render.hulls} />}
</LiveSceneLayer>
</svg>
)
}| 조각 | 하는 일 |
|---|---|
prepareRender(scene, registry, options) | 정지한 것을 그릴 파츠로 만들고 앞뒤 간선을 재 둔다. 씬·상태·회전이 바뀔 때만 다시 부른다 |
LiveSceneLayer | sources 에서 매 프레임 움직이는 사람을 받아 composeRender 로 끼워 넣고 그린다. children 은 이번 프레임 그림을 받는 render prop |
LiveSource | { ids, sample(wall), until? }. useBehaviorPlayback().live·useObjectMotion().live 가 이것이다. 직접 만들어 다른 움직임을 넣을 수도 있다 |
SceneLayer | 파츠 목록을 SVG 로 그린다 (그리는 순서 유지, 클릭·호버, 틱 전환). 매 프레임 그릴 것이 없으면 이것만 쓴다 |
GroundGrid·HullOutline·IsoStyles | 바닥판·격자, 실루엣 테두리, 파츠 애니메이션 CSS |
useTimelinePlayback | 타임라인(화물·키프레임)도 live source 로 준다. 사람과 같이 sources 에 넣는다 |
prepareRender 옵션: rotation · states · overrides(그릴 때만 덮어쓸 값, 끌기 미리보기) · extra(씬에 없는 오브젝트) · partChanges(파츠 변화) · hidden · motion(오브젝트별 자세 전환·걸음) · dynamic(여기서 뺄 id).
사람과 화물은 같은 길로 그려진다.
const behavior = useBehaviorPlayback(scene, registry, { clock })
const timeline = useTimelinePlayback(scene, registry, { clock })
const sources = [behavior.live, timeline.live].filter((s): s is LiveSource => s !== null)
const prepared = useMemo(
() => prepareRender(scene, registry, { dynamic: new Set(sources.flatMap((s) => [...s.ids])) }),
[scene, registry, sources],
)
<LiveSceneLayer prepared={prepared} sources={sources} />파츠 변화 (PartChanges)
오브젝트 id 또는 오브젝트 id/파츠 id → { offset?, grow?, opacity? }. 씬을 고치지 않고 그릴 때만 옮기고 늘이고 지운다. 타임라인 키프레임이 이것을 만들고, 직접 만들어 partChanges(정지 그림) 나 LiveActor.changes(매 프레임) 로 넘겨도 된다.
| 값 | 뜻 |
|---|---|
offset | 월드 축 변위 [x, y, z] (칸) |
grow | 상자 크기에 더할 값 [w, d, h]. 작은 모서리를 두고 커진다 — 아래로 늘이려면 z 를 같이 내린다. 오브젝트가 통째로 내려가는 크레인 케이블은 아래 끝이 이미 따라 내려가므로 h 만 키운다 (거기에 z 까지 주면 줄이 끊긴다) |
opacity | 불투명도 배율. 0 이면 실루엣·앞뒤 정렬에서도 빠진다 |
다른 움직임 source 만들기
// 지게차를 경로를 따라 매 프레임 움직인다 (예: 실시간 위치를 보간).
const forklift: LiveSource = {
ids: new Set(['forklift-1']),
sample(wall) {
const [x, y] = positionAt(wall) // 앱이 가진 위치
return [{ object: { ...forkliftObject, position: [x, y, 0.1] } }] // 같은 배열을 돌려주면 다시 그리지 않는다
},
}sample 이 모습이 그대로일 때 같은 배열을 돌려주면 그 프레임은 다시 그리지 않는다. until(벽시계 ms)을 주면 그 뒤로 프레임을 돌리지 않는다.
다른 렌더러에 붙이기
core 는 DOM 에 기대지 않는다. Canvas·WebGL·서버 이미지로 그릴 때:
import { behaviorActors, composeRender, createBehaviorWorld, prepareRender } from '@isora/core'
const world = createBehaviorWorld(scene, registry)
const objects = new Map(scene.objects.map((o) => [o.id, o]))
const prepared = prepareRender(scene, registry, { dynamic: new Set(world.people) })
function draw(ctx: CanvasRenderingContext2D, t: number) {
const render = composeRender(prepared, behaviorActors(objects, world.frameAt(t), world.people))
for (const part of render.parts) { // 이미 그리는 순서(뒤 → 앞)다
for (const face of part.faces) {
const points = face.points.split(' ').map((p) => p.split(',').map(Number))
ctx.beginPath()
points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)))
ctx.fillStyle = face.fill
ctx.fill()
}
}
}DrawPart에는 면(faces), 월드 상자(box, 시점 회전 뒤), 실루엣(hull), 파츠 애니메이션 이름(animation), 화면(screen)이 있다. SVG 밖에서는 CSS 애니메이션(타자·화면 스크롤)은 직접 구현하거나 뺀다. 걸음·앉기는 이미 상자에 들어 있다.- 3D 엔진이면 면 대신 상자를 쓴다:
actorPartBoxes(object, asset, motion)(사람) ·objectPartBoxes(object, asset)(나머지) 가 월드 좌표 상자를 준다. - 정적 SVG 파일은
@isora/pack의renderSceneSvg(CLIisora export).
서버 렌더
IsoScene 은 서버에서도 그려진다 (React 18·19 renderToString·RSC). 첫 프레임은 재생 시각 0초의 모습이고, 하이드레이션 뒤 재생이 이어진다. renderOverlay 도 서버에서 한 번 불린다.
성능
- 정지한 것은
prepareRender한 번, 매 프레임은 움직이는 사람만 계산한다. 스타트업 오피스 샘플(파츠 600개, 8명)에서 프레임당 JS 약 1.5ms, 프로덕션 빌드에서 60fps 를 넘는다. prepareRender를 다시 부르는 조건(scene·states·rotation·dynamic)이 렌더마다 새 객체가 되지 않게useMemo로 묶는다.useBehaviorPlayback().states는 내용이 같으면 같은 객체다.- 파츠 애니메이션(화면·타자)은 CSS 라 JS 비용이 없다. 사람이 지나가며 그리는 순서가 바뀌어도
moveBefore로 노드를 옮겨 애니메이션이 처음부터 다시 돌지 않는다 (지원하지 않는 브라우저는 다시 돈다).