Babylon.js – Tips

바로 시작할 수 있는 프로젝트 구성

git clone https://github.com/GISDEVCODE/babylonjs-with-javascript-starter.git 생성할폴더

Mesh의 Bounding Box 크기값

const getParentSize = parent => {
  const sizes = parent.getHierarchyBoundingVectors()
  const size = {
    x: sizes.max.x - sizes.min.x,
    y: sizes.max.y - sizes.min.y,
    z: sizes.max.z - sizes.min.z
  }
  return size
};

HDR, ENV를 통한 광원 및 배경

데이터 형식에 따라 코드도 달라짐. 먼저 HDR에 대한 코드는 다음과 같다.

const hdrTexture = new BABYLON.HDRCubeTexture("christmas_photo_studio_01_2k.hdr", this.#scene, 512);
this.#scene.environmentTexture = hdrTexture;
/* const skybox = */ this.#scene.createDefaultSkybox(this.#scene.environmentTexture);
// skybox.visibility = 0.1;

ENV에 대한 코드는 다음과 같다.

this.#scene.createDefaultEnvironment({
  environmentTexture: "./forest.env", // as lighting
  skyboxTexture: "./forest.env", // as background
});

매시에 대한 로컬좌표계축의 원점을 유지하고 이동

sphere.setPivotMatrix(BABYLON.Matrix.Translation(2, 2, 0), false);

여러개의 매시로 구성된 배열(meshes)를 하나의 Mesh로 만드는 코드

const singleMesh = Mesh.MergeMeshes(meshes as Mesh[], true, true, undefined, false, true);

Three.js의 Group에 대응하는 클래스는 BABYLON.TransformNode이다.

async #createModel() {
  const { meshes } = await BABYLON.SceneLoader.ImportMeshAsync("", "/", "Barrel_01_2k.gltf");

  this.#group = new BABYLON.TransformNode("group", this.#scene);
  meshes[1].parent = this.#group;
  this.#group.position.y = -0.5;
}

WebGPU 렌더러 설정

export default class App {
  #engine;
  #scene;
  #mesh;
  
  constructor() {
    this.#setupBabylon();    
  }

  async #setupBabylon() {
    const canvas = document.querySelector("canvas");

    this.#engine = new BABYLON.WebGPUEngine(canvas, { adaptToDeviceRatio: true });
    await this.#engine.initAsync();
    
    this.#scene = new BABYLON.Scene(this.#engine);

    this.#createCamera();
    this.#createLight();
    this.#createModel();
    this.#setupEvents();
  }

  ...

기본적으로 사용하는 좌표계는 왼손 좌표계이다. 하지만 오른손 좌표계로의 전환도 가능한데 아래의 코드를 실행해 주면 바로 오른손 좌표계로 전환되어 이를 기준으로 개발이 가능하다.

scene.useRightHandedSystem = true;

glTF 형식 등을 가져오기 위해 설치해야할 패키지

npm i babylonjs-loaders

import "babylonjs-loaders"

..

  #createModel() {
    BABYLON.SceneLoader.ImportMeshAsync(
      "", 
      "https://assets.babylonjs.com/meshes/", "both_houses_scene.babylon").then((result) => {
        const house1 = this.#scene.getMeshByName("detached_house");
        house1.position.y = 2;
        const house2 = result.meshes[2];
        house2.position.y = 1;
      }
    );

    BABYLON.SceneLoader.ImportMesh("", 
      Assets.meshes.Yeti.rootUrl, Assets.meshes.Yeti.filename, 
      this.#scene, 
      (meshes) => {
        meshes[0].scaling = new BABYLON.Vector3(0.1, 0.1, 0.1);
      }
    );
  }

위 코드에서 Assets은 다음 코드가 필요함

<script src="https://assets.babylonjs.com/generated/Assets.js"></script>

도 단위를 라디언 단위로 변경해주는 API

BABYLON.Tools.ToRadians(45);

물리엔진 하복(Havok)을 사용하기 위해서는 먼저 @babylonjs/havok를 설치하고 node_modules/@babylonjs/havok/lib/esm/HavokPhysics.wasm 파일을 node_modules/.vite/deps 경로에 복사해 두어야 한다. 아래는 코드예시이다.

import * as BABYLON from "babylonjs"
import HavokPhysics from "@babylonjs/havok";

export default class App {
  #engine;
  #scene;

  constructor() {
    const canvas = document.querySelector("canvas");
    this.#engine = new BABYLON.Engine(canvas, true, { adaptToDeviceRatio: true });
    this.#scene = new BABYLON.Scene(this.#engine);

    this.#createCamera();
    this.#createLight();
    this.#createModel();
    this.#setupEvents();
  }

  #createLight() {
    this.#scene.createDefaultLight();
  }

  #createCamera() {
    this.#scene.createDefaultCamera(true, false, true);
    const camera = this.#scene.cameras[0];
    camera.position = new BABYLON.Vector3(4, 4, 10);
  }

  async #createModel() {
    const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { diameter: 2, segments: 32 }, this.#scene);
    sphere.position.y = 4;

    const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 10, height: 10 }, this.#scene);

    const havokInstance = await HavokPhysics();
    const hk = new BABYLON.HavokPlugin(true, havokInstance);
    this.#scene.enablePhysics(new BABYLON.Vector3(0, -9.8, 0), hk);

    const sphereAggregate = new BABYLON.PhysicsAggregate(
      sphere, BABYLON.PhysicsShapeType.SPHERE,
      { mass: 1, restitution: 0.75 }, this.#scene
    );

    const groundAggregate = new BABYLON.PhysicsAggregate(
      ground, BABYLON.PhysicsShapeType.BOX,
      { mass: 0 }, this.#scene
    );

    const viewer = new BABYLON.Debug.PhysicsViewer(this.#scene);
    for (const mesh of this.#scene.meshes) {
      if (mesh.physicsBody) {
        viewer.showBody(mesh.physicsBody);
      }
    }
  }

  #setupEvents() {
    window.addEventListener("resize", this.#resize.bind(this));
    this.#scene.registerBeforeRender(this.update.bind(this));
    this.#engine.runRenderLoop(this.render.bind(this))
  }

  update({ deltaTime }) {

  }

  render() {
    this.#scene.render();
  }

  #resize() {
    this.#engine.resize();
  }
}

Node Material Editor 예

위의 결과를 JSON으로 저장할 수 있으며 코드를 통해 사용하는 예는 아래와 같다.

BABYLON.NodeMaterial.ParseFromFileAsync("nodeMat", "./nodeMaterial.json", this.#scene).then((mat) => {
  this.#mesh.material = mat;
});

Node Geometry Editor 예

위의 결과를 JSON으로 저장할 수 있으며 코드를 통해 사용하는 예는 아래와 같다.

#createModel() {
  const assetsManager = new BABYLON.AssetsManager(this.#scene);
  const nodeGeometryFile = assetsManager.addTextFileTask("file", "./nodeGeometry.json");
  assetsManager.load();

  assetsManager.onFinish = async (tasks) => {
    const nodeGeometryJSON = JSON.parse(nodeGeometryFile.text);
    const nodeGeometry = await BABYLON.NodeGeometry.Parse(nodeGeometryJSON);
    nodeGeometry.build();
    /* const myGeometry = */ nodeGeometry.createMesh("myGeometry");
  }
}

ArcRotateCamera의 마우스 휠 줌 기능 비활성화

this.#scene.createDefaultCamera(true, false, true);
const camera = this.#scene.cameras[0];
camera.inputs.removeByType("ArcRotateCameraMouseWheelInput");

scene을 구성하는 mesh를 제거하기 위해서는 dispose 매서드를 호출

const meshes = this.#scene.getMeshesById("cannon");
meshes[0].dispose();

사용할 카메라 선택하기

this.#scene.activeCamera = myCamera;
this.#scene.activeCamera.attachControl(true);

// 위의 코드는 다음 한줄로 대체가능함
myCamera.attachControl(true);

앞면 뒷면 모두 렌더링하기

방법은 2가지 인데 매시에 대해서 sideOrientation: BABYLON.Mesh.DOUBLESIDE를 지정하는 것, 또는 재질의 backFaceCulling = false로 지정하는 것으로 가능함

const plane = BABYLON.MeshBuilder.CreatePlane("wall", { size: 10, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, this.#scene);
const planeMaterial = new BABYLON.StandardMaterial("planeMat", this.#scene);
planeMaterial.backFaceCulling = false;
plane.material = planeMaterial;

three.js에서 두께를 갖는 라인

OpenGL이나 이를 기반으로 하는 WebGL에서 3D 그래픽에서 두께를 갖는 라인을 표현하기 위해서는 원하는 만큼의 두께를 표현하기 위한 볼륨을 갖는 매시를 구성해야 합니다. 3D 그래픽에서는 기본적으로 라인을 오직 1 픽셀만큼의 두께로 표현할 수 있다는 제약이 있기 때문입니다. 사실 이런 제약은 OpenGL의 제약은 아니고 이를 구현하는 쪽에서의 표준을 충족하지 못했다고 보는게 맞습니다. 원래 OpenGL은 라인에 대해서도 두께를 지정할 수 있고 이에 맞게 라인을 표현해야한다라는 표준을 정했지만 이 표준을 구현하는 쪽에서 이를 구현하지 않았기 때문입니다.

three.js에서도 라인을 표현할때 아무리 두께에 대한 값을 설정해줘도 항상 1 pixel로 표현됩니다. 다행히도 three.js는 두께를 갖는 라인을 표현하기 위해 충분히 검증된 기능을 Line2라는 확장 Addon을 제공합니다. 이 Line2를 사용하면 원하는 두께를 갖는 라인을 표현할 수 있습니다.

이 Line2를 이용하기 위해 다음과 같은 import문이 필요합니다.

import { ..., Line2, LineMaterial, LineGeometry, GeometryUtils } from "three/addons/Addons.js"

그리고 원하는 형태의 라인의 좌표와 색상을 통해 라인을 생성합니다.

_setupModel() {
  const positions = [];
  const colors = [];
  const points = GeometryUtils.hilbert3D(
    new THREE.Vector3(0, 0, 0), 20.0, 1, 0, 1, 2, 3, 4, 5, 6, 7);
  const spline = new THREE.CatmullRomCurve3(points);
  const divisions = Math.round(3 * points.length);
  const point = new THREE.Vector3();
  const color = new THREE.Color();

  for (let i = 0, l = divisions; i < l; i++) {
    const t = i / l;
    spline.getPoint(t, point);
    positions.push(point.x, point.y, point.z);
    color.setHSL(t, 1, 0.5, THREE.SRGBColorSpace);
    colors.push(color.r, color.g, color.b);
  }

  const geometry = new LineGeometry();
  geometry.setPositions(positions);
  geometry.setColors(colors);

  const matLine = new LineMaterial({
    // wireframe: true,
    // color: 0xffffff,
    vertexColors: true,

    // worldUnits: false,
    linewidth: 10, // worldUnits이 false일 경우 pixel 단위

    // alphaToCoverage: true,

    // dashed: true,
    // dashSize: 3,
    // gapSize: 1,
    // dashScale: 1,
  });

  const line = new Line2(geometry, matLine);
  line.computeLineDistances();
  line.scale.set(1, 1, 1);
  this._scene.add(line);
}

결과는 다음과 같습니다.

위의 결과는 단순히 선으로 보이지만 사실 매시입니다. 코드 중 LineMaterial에 wireframe을 true로 설정하면 다음처럼 면으로 구성된 매시라는 점과 항상 카메라를 향하도록(빌보드) 설정되어 있다느 것을 알 수 있습니다.

three.js의 보다 사실적인 유리 재질을 위한 dispersion 속성

어떤 유리를 보면 유리의 내부에서 마치 프리즘처럼 빛이 산란되어 보이는 경우가 있습니다. 유리를 마냥 투명하게만 표현하면 간혹 있는지 없는지스러운 느낌없는 느낌으로 표현되고 마는데요. 이때를 위해 재질의 속성 중 dispersion을 활용할 수 있습니다. 이 속성은 오직 MeshPhysicalMaterial에서만 제공된다는 안타까운 점은 있지만 유리와 같은 재질은 한두개만 만들어두고 재활용하는 경우가 대부분이므로 충분히 활용할만합니다.

아래 이미지는 dispersion에 대한 값 변화에 따른 표현입니다. (이미지 출처: three.js examples)

번외로 흔히 손쉽지만 매우 효과적인 광원으로 HDRi를 사용하는데요. 이는 정적인 데이터 파일을 이용하는 경우고 데이터 없이 이와 유사한 광원을 만들어 내는 방법이 있습니다. 바로 RoomEnvironment 확장 기능인데요. 이 클래스는 Scene을 상속받는 것으로 Scene으로 구성된 내용으로 PMREM 데이터(환경맵 데이터의 한 종류)를 생성할 수 있습니다. 이에 대해 관련된 코드는 다음과 같습니다. 즉, 아래의 코드가 있다면 별도의 광원이 필요하지 않습니다. 단, 그림자 표현을 위해서는 별도의 광원이 필요합니다.

const environment = new RoomEnvironment(this._renderer);
const pmremGenerator = new THREE.PMREMGenerator(this._renderer);
const env = pmremGenerator.fromScene(environment).texture;
this._scene.background = env;
this._scene.environment = env;
this._scene.backgroundBlurriness = 0.5;
environment.dispose();