본문으로 건너뛰기

씬 세부 설정

마지막으로 씬의 세부 설정을 구성하겠습니다. 이 단계에서는 다음 작업을 수행합니다:

  • 로딩 스크린 표시: 모델과 애니메이션이 로드되는 동안 로딩 스크린을 표시합니다.
  • SDEF (Spherical Deformation) 지원 추가: SDEF를 사용하는 모델을 위해 엔진에 셰이더 지원을 추가합니다.
  • BMP 텍스처 로더 등록: MMD 모델의 BMP 텍스처를 올바르게 로드하기 위해 BMP 텍스처 로더를 등록합니다.
  • 플레이어 컨트롤 표시: 애니메이션 재생을 제어할 수 있는 플레이어 컨트롤 UI를 표시합니다.

로딩 스크린 표시

씬이 로드되는 동안 로딩 스크린을 표시하고 로딩 상태를 업데이트하는 방법을 살펴보겠습니다.

먼저 로딩 스크린 기능을 활성화하기 위해 **"@babylonjs/core/Loading/loadingScreen"**을 임포트합니다.

src/sceneBuilder.ts
import "@babylonjs/core/Loading/loadingScreen";
//...

로딩 스크린을 표시하려면 **engine.displayLoadingUI()**를 호출하고, 로딩이 완료되면 **engine.hideLoadingUI()**를 호출합니다.

로딩 스크린을 숨기는 타이밍은 **scene.onAfterRenderObservable**을 사용하여 씬의 첫 렌더링이 완료된 후로 설정하는 것이 가장 좋습니다.

src/sceneBuilder.ts
//...
export class SceneBuilder implements ISceneBuilder {
public async build(_canvas: HTMLCanvasElement, engine: AbstractEngine): Promise<Scene> {
engine.displayLoadingUI();

const vmdLoader = new VmdLoader(scene);
vmdLoader.loggingEnabled = true;

const [[mmdRuntime, physicsRuntime], mmdAnimation, modelMesh] = await Promise.all([
//...
]);

scene.onAfterRenderObservable.addOnce(() => engine.hideLoadingUI());
//...
}
}

로딩 상태 업데이트

vmdLoader의 loadAsync 메서드와 LoadAssetContainerAsync 함수는 로딩 진행 정보를 제공하는 onProgress 콜백을 지원합니다.

이를 사용하여 로딩 상태를 업데이트할 수 있습니다.

하지만 WebAssembly로 구현된 MMD 물리 엔진 초기화는 진행 상황을 추적할 방법이 없으므로, 로딩 시작과 완료 시점에만 상태를 업데이트합니다.

로딩 상태를 표시하기 위해 **engine.loadingUIText**를 사용하겠습니다.

src/sceneBuilder.ts
//...
export class SceneBuilder implements ISceneBuilder {
public async build(_canvas: HTMLCanvasElement, engine: AbstractEngine): Promise<Scene> {
//...
const loadingTexts: string[] = [];
const updateLoadingText = (updateIndex: number, text: string): void => {
loadingTexts[updateIndex] = text;
engine.loadingUIText = "<br/><br/><br/><br/>" + loadingTexts.join("<br/><br/>");
};

const vmdLoader = new VmdLoader(scene);
vmdLoader.loggingEnabled = true;

const [[mmdRuntime, physicsRuntime], mmdAnimation, modelMesh] = await Promise.all([
(async(): Promise<[MmdRuntime, MultiPhysicsRuntime]> => {
updateLoadingText(0, "Loading mmd runtime...");
const wasmInstance = await GetMmdWasmInstance(new MmdWasmInstanceTypeMPR());
updateLoadingText(0, "Loading mmd runtime... Done");

const physicsRuntime = new MultiPhysicsRuntime(wasmInstance);
physicsRuntime.setGravity(new Vector3(0, -98, 0));
physicsRuntime.register(scene);

const mmdRuntime = new MmdRuntime(scene, new MmdBulletPhysics(physicsRuntime));
mmdRuntime.loggingEnabled = true;
mmdRuntime.register(scene);
mmdRuntime.setAudioPlayer(audioPlayer);
mmdRuntime.playAnimation();
return [mmdRuntime, physicsRuntime];
})(),
vmdLoader.loadAsync("motion",
[
"res/private_test/motion/メランコリ・ナイト/メランコリ・ナイト_カメラ.vmd",
"res/private_test/motion/メランコリ・ナイト/メランコリ・ナイト_表情モーション.vmd",
"res/private_test/motion/メランコリ・ナイト/メランコリ・ナイト_リップモーション.vmd",
"res/private_test/motion/メランコリ・ナイト/メランコリ・ナイト.vmd"
],
(event) => updateLoadingText(0, `Loading motion... ${event.loaded}/${event.total} (${Math.floor(event.loaded * 100 / event.total)}%)`)),
LoadAssetContainerAsync(
"res/private_test/model/YYB Hatsune Miku_10th/YYB Hatsune Miku_10th_v1.02.pmx",
scene,
{
onProgress: (event) => updateLoadingText(1, `Loading model... ${event.loaded}/${event.total} (${Math.floor(event.loaded * 100 / event.total)}%)`),
pluginOptions: {
mmdmodel: {
loggingEnabled: true,
materialBuilder: materialBuilder
}
}
}
).then(result => {
result.addAllToScene();
return result.rootNodes[0] as MmdMesh;
})
]);
//...
}
}

SDEF 지원 추가

**SDEF (Spherical Deformation)**는 MMD 모델에서 사용되는 스키닝 메서드 중 하나입니다. SDEF를 사용하는 모델을 올바르게 렌더링하려면 SDEF에 대한 셰이더 지원이 필요합니다.

babylon-mmd셰이더 컴파일 함수를 오버라이드하여 SDEF 지원을 추가하는 SdefInjector 유틸리티를 제공합니다. 이는 매우 까다로운 메서드이지만 MMD 동작을 올바르게 재현하기 위해 필요합니다.

src/sceneBuilder.ts
import { SdefInjector } from "babylon-mmd/esm/Loader/sdefInjector";
//...
export class SceneBuilder implements ISceneBuilder {
public async build(_canvas: HTMLCanvasElement, engine: AbstractEngine): Promise<Scene> {
SdefInjector.OverrideEngineCreateEffect(engine);
//...
}
}

BMP 텍스처 로더 등록

MMD와 브라우저 간의 BMP 텍스처 로더 구현 차이로 인해 Babylon.js에서 MMD 모델의 BMP 텍스처를 올바르게 로드하려면 별도의 BMP 텍스처 로더를 등록해야 합니다.

이 예제에서 현재 사용 중인 "YYB Hatsune Miku_10th" 모델은 BMP 텍스처를 사용하지 않으므로 이 단계를 건너뛰어도 모델이 올바르게 표시됩니다. 하지만 BMP 텍스처를 사용하는 모델을 로드할 때는 이 단계를 수행하지 않으면 텍스처가 올바르게 표시되지 않을 수 있습니다.

src/sceneBuilder.ts
import { RegisterDxBmpTextureLoader } from "babylon-mmd/esm/Loader/registerDxBmpTextureLoader";
//...
export class SceneBuilder implements ISceneBuilder {
public async build(_canvas: HTMLCanvasElement, engine: AbstractEngine): Promise<Scene> {
//...
RegisterDxBmpTextureLoader();
//...
}
}

플레이어 컨트롤 표시

babylon-mmdMMD 애니메이션 재생을 제어하기 위한 MmdPlayerControl 유틸리티를 제공합니다. 이 유틸리티를 사용하여 비디오 플레이어와 유사한 컨트롤 UI를 표시할 수 있습니다.

src/sceneBuilder.ts
import { MmdPlayerControl } from "babylon-mmd/esm/Runtime/Util/mmdPlayerControl";
//...
export class SceneBuilder implements ISceneBuilder {
public async build(_canvas: HTMLCanvasElement, engine: AbstractEngine): Promise<Scene> {
//...
const mmdPlayerControl = new MmdPlayerControl(scene, mmdRuntime, audioPlayer);
mmdPlayerControl.showPlayerControl();
//...
}
}

**MmdPlayerControl**은 프로덕션 수준의 UI 컴포넌트가 아니며 단순히 MMD 애니메이션 재생을 테스트하기 위해 제공됩니다. 따라서 프로덕션 환경에서는 자체 UI를 구현하는 것이 권장됩니다.

결과

이제 씬이 로드되는 동안 로딩 스크린이 표시되고 플레이어 컨트롤 UI가 나타납니다.

전체 코드
src/sceneBuilder.ts
import "@babylonjs/core/Loading/loadingScreen";
import "@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent";
import "babylon-mmd/esm/Loader/pmxLoader";
import "babylon-mmd/esm/Loader/mmdOutlineRenderer";
import "babylon-mmd/esm/Runtime/Animation/mmdRuntimeCameraAnimation";
import "babylon-mmd/esm/Runtime/Animation/mmdRuntimeModelAnimation";

import type { AbstractEngine } from "@babylonjs/core/Engines/abstractEngine";
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";
import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator";
import { LoadAssetContainerAsync } from "@babylonjs/core/Loading/sceneLoader";
import { Color3, Color4 } from "@babylonjs/core/Maths/math.color";
import { Vector3 } from "@babylonjs/core/Maths/math.vector";
import { CreateGround } from "@babylonjs/core/Meshes/Builders/groundBuilder";
import { Scene } from "@babylonjs/core/scene";
import { MmdStandardMaterialBuilder } from "babylon-mmd/esm/Loader/mmdStandardMaterialBuilder";
import { RegisterDxBmpTextureLoader } from "babylon-mmd/esm/Loader/registerDxBmpTextureLoader";
import { SdefInjector } from "babylon-mmd/esm/Loader/sdefInjector";
import { VmdLoader } from "babylon-mmd/esm/Loader/vmdLoader";
import { StreamAudioPlayer } from "babylon-mmd/esm/Runtime/Audio/streamAudioPlayer";
import { MmdCamera } from "babylon-mmd/esm/Runtime/mmdCamera";
import type { MmdMesh } from "babylon-mmd/esm/Runtime/mmdMesh";
import { MmdRuntime } from "babylon-mmd/esm/Runtime/mmdRuntime";
import { MmdWasmInstanceTypeMPR } from "babylon-mmd/esm/Runtime/Optimized/InstanceType/multiPhysicsRelease";
import { GetMmdWasmInstance } from "babylon-mmd/esm/Runtime/Optimized/mmdWasmInstance";
import { MultiPhysicsRuntime } from "babylon-mmd/esm/Runtime/Optimized/Physics/Bind/Impl/multiPhysicsRuntime";
import { MotionType } from "babylon-mmd/esm/Runtime/Optimized/Physics/Bind/motionType";
import { PhysicsStaticPlaneShape } from "babylon-mmd/esm/Runtime/Optimized/Physics/Bind/physicsShape";
import { RigidBody } from "babylon-mmd/esm/Runtime/Optimized/Physics/Bind/rigidBody";
import { RigidBodyConstructionInfo } from "babylon-mmd/esm/Runtime/Optimized/Physics/Bind/rigidBodyConstructionInfo";
import { MmdBulletPhysics } from "babylon-mmd/esm/Runtime/Optimized/Physics/mmdBulletPhysics";
import { MmdPlayerControl } from "babylon-mmd/esm/Runtime/Util/mmdPlayerControl";

import type { ISceneBuilder } from "./baseRuntime";

export class SceneBuilder implements ISceneBuilder {
public async build(_canvas: HTMLCanvasElement, engine: AbstractEngine): Promise<Scene> {
SdefInjector.OverrideEngineCreateEffect(engine);
RegisterDxBmpTextureLoader();

const materialBuilder = new MmdStandardMaterialBuilder();
const scene = new Scene(engine);
scene.clearColor = new Color4(0.95, 0.95, 0.95, 1.0);
scene.ambientColor = new Color3(0.5, 0.5, 0.5);

const mmdCamera = new MmdCamera("MmdCamera", new Vector3(0, 10, 0), scene);

const directionalLight = new DirectionalLight("DirectionalLight", new Vector3(0.5, -1, 1), scene);
directionalLight.intensity = 1.0;
directionalLight.autoCalcShadowZBounds = true;

const shadowGenerator = new ShadowGenerator(1024, directionalLight, true);
shadowGenerator.transparencyShadow = true;
shadowGenerator.usePercentageCloserFiltering = true;
shadowGenerator.forceBackFacesOnly = true;
shadowGenerator.filteringQuality = ShadowGenerator.QUALITY_MEDIUM;
shadowGenerator.frustumEdgeFalloff = 0.1;

const ground = CreateGround("ground1", { width: 100, height: 100, subdivisions: 2, updatable: false }, scene);
ground.receiveShadows = true;

const audioPlayer = new StreamAudioPlayer(scene);
audioPlayer.source = "res/private_test/motion/メランコリ・ナイト/melancholy_night.mp3";

// show loading screen
engine.displayLoadingUI();

const loadingTexts: string[] = [];
const updateLoadingText = (updateIndex: number, text: string): void => {
loadingTexts[updateIndex] = text;
engine.loadingUIText = "<br/><br/><br/><br/>" + loadingTexts.join("<br/><br/>");
};

const vmdLoader = new VmdLoader(scene);
vmdLoader.loggingEnabled = true;

const [[mmdRuntime, physicsRuntime], mmdAnimation, modelMesh] = await Promise.all([
(async(): Promise<[MmdRuntime, MultiPhysicsRuntime]> => {
updateLoadingText(0, "Loading mmd runtime...");
const wasmInstance = await GetMmdWasmInstance(new MmdWasmInstanceTypeMPR());
updateLoadingText(0, "Loading mmd runtime... Done");

const physicsRuntime = new MultiPhysicsRuntime(wasmInstance);
physicsRuntime.setGravity(new Vector3(0, -98, 0));
physicsRuntime.register(scene);

const mmdRuntime = new MmdRuntime(scene, new MmdBulletPhysics(physicsRuntime));
mmdRuntime.loggingEnabled = true;
mmdRuntime.register(scene);
mmdRuntime.setAudioPlayer(audioPlayer);
mmdRuntime.playAnimation();
return [mmdRuntime, physicsRuntime];
})(),
vmdLoader.loadAsync("motion",
[
"res/private_test/motion/メランコリ・ナイト/メランコリ・ナイト_カメラ.vmd",
"res/private_test/motion/メランコリ・ナイト/メランコリ・ナイト_表情モーション.vmd",
"res/private_test/motion/メランコリ・ナイト/メランコリ・ナイト_リップモーション.vmd",
"res/private_test/motion/メランコリ・ナイト/メランコリ・ナイト.vmd"
],
(event) => updateLoadingText(0, `Loading motion... ${event.loaded}/${event.total} (${Math.floor(event.loaded * 100 / event.total)}%)`)),
LoadAssetContainerAsync(
"res/private_test/model/YYB Hatsune Miku_10th/YYB Hatsune Miku_10th_v1.02.pmx",
scene,
{
onProgress: (event) => updateLoadingText(1, `Loading model... ${event.loaded}/${event.total} (${Math.floor(event.loaded * 100 / event.total)}%)`),
pluginOptions: {
mmdmodel: {
loggingEnabled: true,
materialBuilder: materialBuilder
}
}
}
).then(result => {
result.addAllToScene();
return result.rootNodes[0] as MmdMesh;
})
]);

scene.onAfterRenderObservable.addOnce(() => engine.hideLoadingUI());

const mmdPlayerControl = new MmdPlayerControl(scene, mmdRuntime, audioPlayer);
mmdPlayerControl.showPlayerControl();

const cameraAnimationHandle = mmdCamera.createRuntimeAnimation(mmdAnimation);
mmdCamera.setRuntimeAnimation(cameraAnimationHandle);
mmdRuntime.addAnimatable(mmdCamera);

{
for (const mesh of modelMesh.metadata.meshes) mesh.receiveShadows = true;
shadowGenerator.addShadowCaster(modelMesh);

const mmdModel = mmdRuntime.createMmdModel(modelMesh);
const modelAnimationHandle = mmdModel.createRuntimeAnimation(mmdAnimation);
mmdModel.setRuntimeAnimation(modelAnimationHandle);
}

const info = new RigidBodyConstructionInfo(physicsRuntime.wasmInstance);
info.motionType = MotionType.Static;
info.shape = new PhysicsStaticPlaneShape(physicsRuntime, new Vector3(0, 1, 0), 0);
const groundBody = new RigidBody(physicsRuntime, info);
physicsRuntime.addRigidBodyToGlobal(groundBody);

return scene;
}
}

다음 단계는?

이제 babylon-mmd의 기본 사용법을 모두 배웠습니다! 다음으로 레퍼런스 섹션을 살펴보세요. 이 섹션에서는 다양한 옵션과 고급 기능에 대한 자세한 설명을 제공합니다.