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
'웹 > 프론트엔드' 카테고리의 다른 글
[Next.js] Next/Image layout:fill과 sizes에 대해 (0) | 2024.03.18 |
---|---|
[Next.js] NextJS Boilerplate 제작기 (1) | 2024.03.07 |
Zustand 현명하게 사용하기 (불필요한 리렌더링 막기) (10) | 2023.06.12 |
React 좀 더 현명하게 사용하기 (0) | 2023.05.08 |
[Babylon.js] Blender로 Babylon.js에 필요한 모델 추출하기 (0) | 2023.02.14 |
댓글