본문 바로가기
웹/프론트엔드

[Babylon.js] Gimzo, GimzoManager를 pub/sub 패턴으로 관리하기

by 이민훈 2023. 6. 25.

Babylon.js에서 gimzo를 다뤄야 할 때가 있습니다.

 

서버에서 저장된 메쉬 정보를 불러와 메쉬들을 그려준 뒤, React component에서 메쉬들의 정보를 띄워준다고 가정해 봅시다.

 

그리고 그 메쉬정보를 클릭하면 해당하는 메쉬에 gizmo가 부착되어야 하는 상황입니다.

 

이때 State와 GizmoManager를 동기화하기가 꽤 까다롭습니다.

 

 

이때 pub/sub 패턴을 적극 활용하면, 손쉽게 gizmo를 관리할 수 있습니다.

 

먼저 메쉬들과, 선택된 메쉬의 정보가 있는 zustand store를 하나 생성합니다.

 

import { AbstractMesh, Nullable } from "@babylonjs/core";
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import { useShallow } from "../hooks/useShallow";

interface MeshStoreStates {
  meshes: AbstractMesh[];
  selectedMesh: Nullable<AbstractMesh>;

  setMeshes: (meshes: AbstractMesh[]) => void;
  setSelectedMesh: (mesh: Nullable<AbstractMesh>) => void;
}

export const meshStore = create(
  subscribeWithSelector<MeshStoreStates>((set) => ({
    meshes: [],
    selectedMesh: null,

    setMeshes: (meshes) => set({ meshes }),
    setSelectedMesh: (selectedMesh) => set({ selectedMesh }),
  }))
);

export const useMeshStore = <T extends keyof MeshStoreStates>(keys: T[]) => {
  return useShallow(meshStore, keys);
};

 

이제 싱글톤 클래스로 gizmo를 관리하기 위한 클래스를 하나 생성합니다.

 

그런 다음, gizmoManager를 원하는 대로 세팅하고,

 

meshStore를 subscribe하여 selectedMesh의 값을 관찰합니다.

 

selectedMesh의 값이 바뀌면 gizmoManager에 mesh를 추가합니다.

 

import {
  GizmoManager,
  Nullable,
  Scene
} from "@babylonjs/core";
import { shallow } from "zustand/shallow";
import { meshStore } from "../store/mesh";

export class GizmoController {
  private static instance: Nullable<GizmoController>;
  private _gizmoManager!: GizmoManager;
  private _unsubscribers: Array<() => void> = [];

  constructor(scene: Scene) {
    if (!GizmoController.instance) {
      GizmoController.instance = this;
      this._gizmoManager = new GizmoManager(scene);
      this._gizmoManager.positionGizmoEnabled = true;
      this._gizmoManager.rotationGizmoEnabled = true;

      this._unsubscribers.push(
        meshStore.subscribe(
          (state) => state.selectedMesh,
          (mesh) => {
            if (mesh) {
              this._gizmoManager.attachToMesh(mesh);
            } else {
              this._gizmoManager.attachToMesh(null);
            }
          },
          { equalityFn: shallow }
        )
      );
    }

    return GizmoController.instance;
  }

  public dispose() {
    this._gizmoManager.dispose();
    this._unsubscribers.forEach((unsubscriber) => unsubscriber());
    GizmoController.instance = null;
  }
}

 

이제 어떤 컴포넌트에서든 selectedMesh를 바꿔주면 해당 메쉬에 gimzo가 부착됩니다.

 

 

아래 구문을 추가해서 pointer를 이용해서 mesh를 선택한 경우에도 selectedMesh state를 바꿔줍니다.

 

this._gizmoManager.usePointerToAttachGizmos = true;
      
const setSelectedMesh = meshStore.getState().setSelectedMesh;
this._gizmoManager.onAttachedToMeshObservable.add((mesh) => {
  if (mesh) {
    setSelectedMesh(mesh);
  }
});

 

그럼, selectedMesh state와 실제 기즈모가 부착된 mesh가 동기화됩니다.

 

 

마지막으로 기즈모를 움직여 메쉬의 위치를 바꾸거나, 회전시킬 때 React component에 알려줘야 하는 경우도 있는데요.

 

예를 들어 컴포넌트에서 mesh의 position이나 rotation정보를 띄워준다면, 리렌더링을 위해 메시지를 받아야 하는 경우도 있습니다.

 

이 부분은 rxjs의 subject를 이용해서 해결할 수 있습니다.

 

position 기즈모와, rotatation 기즈모가 drag 될 때, subject로 메시지를 publish합니다.

 

그런 다음 컴포넌트에서 subscribe할 수 있도록 subscribe와 unsubscribe 함수를 구현해 줍니다.

 

드래그 이벤트는 매우 짧은 시간에 많은 횟수가 발생하기 때문에, throttling을 구현해 준다면 더 좋습니다.

 

 

 

import {
  EventState,
  GizmoManager,
  Nullable,
  Observer,
  PointerInfo,
  Scene,
  Vector3,
} from "@babylonjs/core";
import { Subject, Subscription, timer } from "rxjs";
import { shallow } from "zustand/shallow";
import { meshStore } from "../store/mesh";

type EventData = {
  delta: Vector3;
  dragPlanePoint: Vector3;
  dragPlaneNormal: Vector3;
  dragDistance: number;
  pointerId: number;
  pointerInfo: Nullable<PointerInfo>;
};

export class GizmoController {
  private static instance: Nullable<GizmoController>;
  private _gizmoManager!: GizmoManager;
  private _subject: Nullable<
    Subject<{ eventData: EventData; eventState: EventState }>
  > = null;
  private _observers: Nullable<Observer<EventData>>[] = [];
  private _unsubscribers: Array<() => void> = [];

  constructor(scene: Scene) {
    if (!GizmoController.instance) {
      GizmoController.instance = this;
      this._gizmoManager = new GizmoManager(scene);
      this._gizmoManager.usePointerToAttachGizmos = true;
      this._gizmoManager.positionGizmoEnabled = true;
      this._gizmoManager.rotationGizmoEnabled = true;

      // position gimzo가 드래그 될 때 이벤트 정보를 방출합니다.
      if (this._gizmoManager.gizmos.positionGizmo) {
        const { xGizmo, yGizmo, zGizmo } =
          this._gizmoManager.gizmos.positionGizmo;
        const gizmos = [xGizmo, yGizmo, zGizmo];
        gizmos.forEach((gizmo) => {
          this._observers.push(
            gizmo.dragBehavior.onDragObservable.add((eventData, eventState) => {
              if (this._subject) {
                this._subject.next({ eventData, eventState });
              }
            })
          );
        });
      }

      // rotation gimzo가 드래그 될 때 이벤트 정보를 방출합니다.
      if (this._gizmoManager.gizmos.rotationGizmo) {
        const { xGizmo, yGizmo, zGizmo } =
          this._gizmoManager.gizmos.rotationGizmo;
        const gizmos = [xGizmo, yGizmo, zGizmo];
        gizmos.forEach((gizmo) => {
          this._observers.push(
            gizmo.dragBehavior.onDragObservable.add((eventData, eventState) => {
              if (this._subject) {
                this._subject.next({ eventData, eventState });
              }
            })
          );
        });
      }

      const setSelectedMesh = meshStore.getState().setSelectedMesh;
      this._gizmoManager.onAttachedToMeshObservable.add((mesh) => {
        if (mesh) {
          setSelectedMesh(mesh);
        }
      });

      this._unsubscribers.push(
        meshStore.subscribe(
          (state) => state.selectedMesh,
          (mesh) => {
            if (mesh) {
              this._gizmoManager.attachToMesh(mesh);
            } else {
              this._gizmoManager.attachToMesh(null);
            }
          },
          { equalityFn: shallow }
        )
      );
    }

    return GizmoController.instance;
  }

  public subscribe(
    callback: ({
      eventData,
      eventState,
    }: {
      eventData: EventData;
      eventState: EventState;
    }) => void,
    throttle?: number
  ) {
    let _ready = true;
    let _timer: Nullable<Subscription> = null;
    this._subject = new Subject();
    const throttledCallback = throttle
      ? (stream: { eventData: EventData; eventState: EventState }) => {
          if (_ready) {
            _timer?.unsubscribe();
            callback(stream);
            _ready = false;
            _timer = timer(throttle).subscribe(() => {
              _ready = true;
            });
          }
        }
      : callback;
    return this._subject.subscribe(throttledCallback);
  }

  public unsubscribe() {
    this._subject?.unsubscribe();
  }

  public dispose() {
    this._gizmoManager.dispose();
    this._observers.forEach((observer) => observer?.remove());
    this._unsubscribers.forEach((unsubscriber) => unsubscriber());
    GizmoController.instance = null;
  }
}

 

이제, 해당 클래스 하나로 scene이 아닌 리액트 컴포넌트 어디에서든 메쉬에 gimzo를 부착할 수 있고,

 

gizmo가 드래그될 때 정보를 받아올 수 있습니다.

 

import { useEffect, useRef } from "react";
import "./App.css";
import { GizmoController } from "./core/gizmo";
import { createMeshes, createScene } from "./core/helpers";
import { useMeshStore } from "./store/mesh";

function App() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { meshes, setMeshes, selectedMesh, setSelectedMesh } = useMeshStore([
    "meshes",
    "setMeshes",
    "selectedMesh",
    "setSelectedMesh",
  ]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const scene = createScene(canvas);
    const meshes = createMeshes(scene, 10);
    setMeshes(meshes);

    const gizmoController = new GizmoController(scene);
    gizmoController.subscribe(({ eventData, eventState }) => {
      console.log(eventData);
      console.log(eventState);
    }, 100);

    return () => {
      meshes.forEach((mesh) => mesh.dispose());
      gizmoController.dispose();
      scene.dispose();
    };
  }, [setMeshes]);

  return (
    <>
      <div
        style={{
          zIndex: 1,
          position: "fixed",
          display: "flex",
          flexDirection: "column",
          width: "200px",
          height: "100vh",
          backgroundColor: "white",
        }}
      >
        {meshes.map((mesh) => (
          <div
            key={mesh.name}
            onClick={() => setSelectedMesh(mesh)}
            style={{
              display: "flex",
              flexDirection: "column",
              padding: "10px",
              backgroundColor:
                mesh === selectedMesh
                  ? "rgba(255, 0, 0, 0.5)"
                  : "rgba(0, 0, 255, 0.5)",
              cursor: "pointer",
            }}
          >
            <span>{mesh.name}</span>
          </div>
        ))}
      </div>
      <canvas
        ref={canvasRef}
        style={{
          position: "fixed",
          width: "100vw",
          height: "100vh",
        }}
      />
    </>
  );
}

export default App;

 

 

완성된 모습입니다.

 

 

소스 코드가 궁금하신 분들은 링크를 올려두겠습니다.

 

https://github.com/Lee-Minhoon/babylonjs-examples/tree/main/gizmo-example

 

GitHub - Lee-Minhoon/babylonjs-examples

Contribute to Lee-Minhoon/babylonjs-examples development by creating an account on GitHub.

github.com

 

댓글