top-level await 에러 대응 (WebGPU를 import 하기 위한 코드에서 …)

아래의 코드가 실행되면 서두부터 await가 실행됩니다. 즉, top-level에서 await가 실행되는 거죠. 이게 Vite에서는 문제가 발생합니다.

import WebGPU from "three/addons/capabilities/WebGPU.js"

이를 해결하기 위해서는 먼저 개발환경에서 다음과 같은 패키지를 설치해야 합니다.

npm install --save-dev vite-plugin-top-level-await

그리고 vite.config.js 파일을 만들어 다음처럼 내용을 구성합니다.

import { defineConfig } from "vite";
import topLevelAwait from "vite-plugin-top-level-await";

export default defineConfig({
  plugins:[
    topLevelAwait({
      // promiseExportName: "__tla",
      // promiseImportName: i => `__tla_${i}`
    })
  ],
});

WebGPU를 이용한 계산(Computing)

3개의 실수값을 전달하고 이 값에 2를 곱한 결과를 GPU를 통해 병렬로 처리 실행해 그 결과를 얻는 코드입니다. 먼저 GPU에 대한 객체를 얻는 것은 비동기로 처리해야 하므로 다음과 같은 코드가 필요합니다.

async function main() {
  // 여기에 이후 모든 코드가 작성됩니다.
}

await main();

자, 이제 GPU에 대한 객체를 얻습니다.

  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();

실행할 쉐이더 코드를 작성합니다. 쉐이더 코드는 WGSL로 작성됩니다.

  const module = device.createShaderModule({
    label: 'doubling compute module',
    code: /* wgsl */ `
      @group(0) @binding(0) var<storage, read_write> data: array<f32>;
 
      @compute @workgroup_size(1) fn computeSomething(
        @builtin(global_invocation_id) id: vec3u
      ) {
        let i = id.x;
        data[i] = data[i] * 2.0;
      }
    `,
  });

위의 쉐이더 코드를 실행할 파이프라인 객체를 생성합니다.

  const pipeline = device.createComputePipeline({
    label: 'x2 compute pipeline',
    layout: 'auto',
    compute: {
      module,
      entryPoint: 'computeSomething', // 실행할 쉐이더 함수
    },
  });

실행할 쉐이더 함수를 보면 1개의 인자를 받는데, 그 인자를 정의합니다.

  const input = new Float32Array([1, 3, 5]);

위의 인자는 CPU 메모리에 있으므로 이를 GPU 메모리에 만들어서 복사해 줘야 합니다.

  const workBuffer = device.createBuffer({
    label: 'work buffer',
    size: input.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
  });

  device.queue.writeBuffer(workBuffer, 0, input);

GPU를 통해 계산된 최종 결과를 읽기 위한 사본 버퍼를 생성합니다.

  const resultBuffer = device.createBuffer({
    label: 'result buffer',
    size: input.byteLength,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
  });

계산을 위해 사용될 인자에 대한 버퍼가 무엇인지를 명확히 지정합니다.

  const bindGroup = device.createBindGroup({
    label: 'bindGroup for work buffer',
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: { buffer: workBuffer } },
    ],
  });

GPU에서 실행할 명령을 인코딩합니다.

  const encoder = device.createCommandEncoder({ label: 'x2 encoder' });
  const pass = encoder.beginComputePass({ label: 'x2 compute pass' });

  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(input.length);
  pass.end();

계산 결과를 읽어 내기 위해 맵핑용 버퍼에 복사하는 명령을 인코딩합니다.

  encoder.copyBufferToBuffer(workBuffer, 0, resultBuffer, 0, resultBuffer.size);

실해 명령의 실행은 다음 코드를 통해 이루어집니다.

   const commandBuffer = encoder.finish();
   device.queue.submit([commandBuffer]);

실행 결과를 콘솔에 출력하기 위한 코드입니다.

  await resultBuffer.mapAsync(GPUMapMode.READ);
  const result = new Float32Array(resultBuffer.getMappedRange());
 
  console.log('input', input);
  console.log('result', result);
 
  resultBuffer.unmap();

실행 결과를 보면 다음과 같습니다.

실행 결과를 보면 입력값에 2배만큼 곱해졌는데, 이는 GPU를 통해 병렬로 계산된 것입니다.

WebGPU를 이용한 삼각형 그리기

WebGPU는 GPU를 이용해 그래픽을 렌더링하거나 범용적인 연산을 실행할 수 있는 웹 API입니다. 이 글은 WebGPU를 이용해 간단한 삼각형을 렌더링하는 코드를 살펴봅니다.

WebGL과 마찬가지로 그래픽을 출력할 Canvas가 필요합니다. HTML 파일에 Canvas 요소를 추가해야 합니다. 추가했다고 치고…

WebGPU의 초기화가 필요한데, WebGPU의 초기화는 비동기적으로 실행되므로 별도의 비동기 함수로 처리합니다.

async function main() {
  // 앞으로 코드는 모두 여기에 추가됨
}

await main();

먼저 GPU 디바이스 객체를 얻어옵니다.

  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();

이제 이 device 객체를 통해 그래픽을 렌더링할텐데, 아래의 코드를 통해 렌더링 결과가 출력된 캔버스와 연결해야 합니다.

  const canvas = document.querySelector('canvas');
  const context = canvas.getContext('webgpu');
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
  context.configure({
    device,
    format: presentationFormat,
  });

WebGL과 마찬가지로 WebGPU 역시 Shader 코드가 필요합니다. 하지만 쉐이더 언어가 다르며 WebGL은 GLSL이고 WebGPU는 WGSL(위그실)입니다. WebGL에서는 Vertex Shader와 Fragment Shader가 필요했던 것처럼 WebGPU 역시도 마찬가지입니다. 해당 코드는 다음과 같습니다.

  const module = device.createShaderModule({
    label: 'our hardcoded red triangle shaders',
    code: /* wgsl */ `
      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> @builtin(position) vec4f {
        let pos = array(
          vec3f(   0,  .5, 0), 
          vec3f( -.5, -.5, 0), 
          vec3f(  .5, -.5, 0)  
        );
 
        return vec4f(pos[vertexIndex], 1.0);
      }
 
      @fragment fn fs() -> @location(0) vec4f {
        return vec4f(1.0, 0.0, 0.0, 1.0);
      }
    `,
  });

이제 쉐이더 코드를 실행할 렌더 파이프 라인 객체를 생성합니다.

  const pipeline = device.createRenderPipeline({
    label: 'our hardcoded red triangle pipeline',
    layout: 'auto',
    vertex: {
      module,
      entryPoint: 'vs',
    },
    fragment: {
      module,
      entryPoint: 'fs',
      targets: [{ format: presentationFormat }],
    },
  });

다음은 실제 렌더링을 위해 필요한 설정값(Canvas의 텍스쳐뷰, 배경색상 등)을 갖는 디스크립터 객체를 생성합니다.

  const renderPassDescriptor = {
    label: 'our basic canvas renderPass',
    colorAttachments: [
      {
        view: context.getCurrentTexture().createView(),
        clearValue: [0.3, 0.3, 0.3, 1],
        loadOp: 'clear',
        storeOp: 'store',
      },
    ],
  };  

이제 실제 렌더링을 위한 구체적인 명령을 인코딩하기 위한 객체를 생성하고 렌더링 명령을 지정합니다.

  const encoder = device.createCommandEncoder({ label: 'our encoder' });

  const pass = encoder.beginRenderPass(renderPassDescriptor);
  pass.setPipeline(pipeline);
  pass.draw(3);  // 정점 셰이더를 3번 호출
  pass.end();

위의 코드는 명령을 지정만 했을 뿐이고 실제 실행을 위한 코드는 다음과 같습니다.

  const commandBuffer = encoder.finish();
  device.queue.submit([commandBuffer]);

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;