본문으로 건너뛰기

그리기 ​

<IsoScene> ​

씬 하나를 SVG 로 그리는 뷰어. 편집 기능은 없고 렌더·이벤트·상태 바인딩·재생만 한다.

prop기본뜻
scene(필수)그릴 씬
packs / registry에셋 팩 목록, 또는 createRegistry(packs) 로 만든 레지스트리 (둘 중 하나)
rotation0시점 회전 0·90·180·270
states오브젝트 id → 상태 이름. 실시간 데이터를 여기로 넘긴다
showGridtrue바닥 격자선
animateMotiontrue씬 데이터에서 사람 자리·자세가 바뀌면 걷기·앉기로 잇는다
playTimelinesfalse타임라인 재생. true 면 전부, 배열이면 그 id 만
timelineSpeed1타임라인 배속 (clock 을 주면 보지 않음)
playBehaviorsfalse루틴 재생 (behavior 를 주면 보지 않음)
behaviorSpeed1행동 배속 (clock·behavior 를 주면 보지 않음)
behavior앱이 만든 useBehaviorPlayback(). 주면 그것을 그린다 — 명령·읽기는 사람 움직이기
clock행동·타임라인이 함께 쓰는 시계. 재생·멈춤·배속을 앱이 한다
showActivitiesfalse사람 머리 위 활동 아이콘 말풍선
onActivityClick(id) => void. 말풍선을 누르면 그 사람 id. 말풍선만 손가락 커서가 되고, 이 클릭에는 onSceneClick·onObjectClick 이 불리지 않는다. ActivityBubbles 를 직접 쓰면 onBubbleClick
renderOverlay(overlay) => ReactNode. 매 프레임 사람·가구 위에 SVG 를 덮어 그린다
onObjectClick(object) => void
onSceneClick(point, object | null) => void. 포인터 아래 바닥의 씬 좌표와 클릭한 오브젝트
padding24씬 둘레 여백 (SVG 좌표)
highlightColor#3b5bdb포인터가 올라간 오브젝트 테두리 색
className·style<svg> 에 붙는다. 부모 크기를 채우므로 부모에 높이를 준다

오버레이 ​

renderOverlay 는 매 프레임 이번 프레임의 실루엣으로 불린다. 걷는 사람 위의 표시가 사람을 그대로 따라간다.

tsx
<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) 을 계산한다.

클릭한 곳 ​

tsx
<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 캔버스)가 이 방식이다.

tsx
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)정지한 것을 그릴 파츠로 만들고 앞뒤 간선을 재 둔다. 씬·상태·회전이 바뀔 때만 다시 부른다
LiveSceneLayersources 에서 매 프레임 움직이는 사람을 받아 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).

사람과 화물은 같은 길로 그려진다.

tsx
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 만들기 ​

ts
// 지게차를 경로를 따라 매 프레임 움직인다 (예: 실시간 위치를 보간).
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·서버 이미지로 그릴 때:

ts
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 (CLI isora 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 로 노드를 옮겨 애니메이션이 처음부터 다시 돌지 않는다 (지원하지 않는 브라우저는 다시 돈다).

MIT 라이선스로 배포합니다.