Vite を使って高速で babylon.js 用ライブラリ開発をする
Vite を使って急いで babylon.js 用のライブラリ babylon-fps-shooter
を作ってみます。
FPS のベースとなるような Input の実装をしてみます。
$ yarn create @vitejs/app babylon-fps-shooter --template vanilla-ts
yarn create v1.22.5
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Installed "@vitejs/create-app@2.4.3" with binaries:
- create-app
- cva
[#######] 7/7
Scaffolding project in /home/[user]/repos/babylon-fps-shooter...
Done. Now run:
cd babylon-fps-shooter
yarn
yarn dev
Done in 2.47s.
$ cd babylon-fps-shooter
$ yarn
$ yarn dev
これでもう TypeScript アプリのビルド環境が整います。極速。
$ yarn add @babylonjs/core
...
info Direct dependencies
└─ @babylonjs/core@4.2.0
info All dependencies
├─ @babylonjs/core@4.2.0
└─ tslib@2.3.0
Done in 2.81s.
これで現在の最新版である 4.2.0 が組み込まれました。とりあえず初期化をここをベースにして書きます。
$ yarn add pepjs
...
success Saved 1 new dependency.
info Direct dependencies
└─ pepjs@0.5.3
info All dependencies
└─ pepjs@0.5.3
Done in 2.47s.
依存パッケージである jQuery.pep.js を追加します。
import { MainScene } from './MainScene'
import 'pepjs'
import './style.css'
const canvas = document.querySelector<HTMLCanvasElement>('#app')!
const mainScene = new MainScene(canvas)
await mainScene.start();
html, body {
overflow: hidden;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
width: 100%;
height: 100%;
touch-action: none;
}
import { BoxBuilder } from '@babylonjs/core/Meshes/Builders/boxBuilder'
import { Camera } from '@babylonjs/core/Cameras/camera'
import { CubeTexture } from '@babylonjs/core/Materials/Textures/cubeTexture'
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight'
import { Engine } from '@babylonjs/core/Engines/engine'
import { EngineOptions } from '@babylonjs/core/Engines/thinEngine'
import { GroundBuilder } from '@babylonjs/core/Meshes/Builders/groundBuilder'
import { Light } from '@babylonjs/core/Lights/light'
import { Mesh } from '@babylonjs/core/Meshes/mesh'
import { Scene, SceneOptions } from '@babylonjs/core/scene'
import { ShadowGenerator } from '@babylonjs/core/Lights/Shadows/shadowGenerator'
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'
import { Texture } from '@babylonjs/core/Materials/Textures/texture'
import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera'
import { Vector3 } from '@babylonjs/core/Maths/math.vector'
import '@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent'
/**
* メインのインゲームシーン
*/
export class MainScene
{
private readonly engine: Engine
private readonly scene: Scene
private readonly camera: Camera
private readonly mainLight: Light
private readonly shadowGenerator: ShadowGenerator
constructor(
canvas: HTMLCanvasElement,
antialias: boolean = true,
options: EngineOptions = {},
adoptToDeviceRatio: boolean = true,
sceneOptions: SceneOptions = {},
) {
this.engine = new Engine(canvas, antialias, options, adoptToDeviceRatio)
this.scene = new Scene(this.engine, sceneOptions)
this.camera = setUpCamera(canvas, this.scene)
this.mainLight = setUpMainLight(this.scene)
this.shadowGenerator = new ShadowGenerator(1024, <any>this.mainLight)
this.shadowGenerator.useContactHardeningShadow = true
setUpSkybox(this.scene)
setUpGround(this.scene)
}
public start(): void {
this.engine.onResizeObservable.add(this.onResise)
this.engine.runRenderLoop(() => {
this.scene.render()
})
}
private readonly onResise = (): void => {
this.engine.resize()
}
}
/**
* カメラを作成
*
* @param canvas Target Canvas
* @param scene Target Scene
* @returns MainCamera
*/
function setUpCamera(canvas: HTMLCanvasElement, scene: Scene): Camera {
const camera = new UniversalCamera(`MainCamera`, new Vector3(0, 2, 10), scene)
camera.setTarget(Vector3.Zero())
camera.attachControl(canvas, true)
camera.applyGravity = true
camera.ellipsoid = Vector3.One()
camera.checkCollisions = true
return camera
}
/**
* メインライトを作成
*
* @param scene Target Scene
* @returns MainLight
*/
function setUpMainLight(scene: Scene): Light {
const light = new DirectionalLight(`MainLight`, new Vector3(0, -7, 4), scene)
light.intensity = 0.7
return light
}
/**
* スカイボックス背景を生成
*
* @param scene Target Scene
* @returns Mesh
*/
function setUpSkybox(scene: Scene): Mesh {
const mesh = BoxBuilder.CreateBox(`MainSkyBox`, { size: 10000 }, scene)
const material = new StandardMaterial(`MainSkyBoxMaterial`, scene)
material.backFaceCulling = false
material.reflectionTexture = new CubeTexture(`https://playground.babylonjs.com/textures/skybox`, scene)
material.reflectionTexture.coordinatesMode = Texture.SKYBOX_MODE
material.disableLighting = true
mesh.material = material
return mesh
}
/**
* 地面を生成
*
* @param scene Target Scene
* @returns Ground Mesh
*/
function setUpGround(scene: Scene): Mesh {
const material = new StandardMaterial('ground', scene)
const groundDiffuse = new Texture('https://playground.babylonjs.com/textures/grass.png', scene)
groundDiffuse.uScale = 3000
groundDiffuse.vScale = 3000
material.diffuseTexture = groundDiffuse
const groundBump = new Texture('https://playground.babylonjs.com/textures/grassn.png', scene)
groundBump.uScale = 3000
groundBump.vScale = 3000
material.bumpTexture = groundBump
const ground = GroundBuilder.CreateGround("MainGround", { width: 10000, height: 10000}, scene)
ground.material = material
ground.checkCollisions = true
ground.receiveShadows = true
return ground
}
上記コードでスカイボックスと地面、デフォルトのカメラ(矢印キーで移動、マウスドラッグで視点回転, スマホでも動かせる)を追加しました。
https://playground.babylonjs.com/#YNEAUL#11 を参考にして家を生成します。
ついでに端から落ちてしまわないように壁で囲います。
import { BoxBuilder } from '@babylonjs/core/Meshes/Builders/boxBuilder'
import { Camera } from '@babylonjs/core/Cameras/camera'
import { CubeTexture } from '@babylonjs/core/Materials/Textures/cubeTexture'
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight'
import { Engine } from '@babylonjs/core/Engines/engine'
import { EngineOptions } from '@babylonjs/core/Engines/thinEngine'
import { GroundBuilder } from '@babylonjs/core/Meshes/Builders/groundBuilder'
import { Light } from '@babylonjs/core/Lights/light'
import { Mesh } from '@babylonjs/core/Meshes/mesh'
import { Scene, SceneOptions } from '@babylonjs/core/scene'
import { SceneLoader } from '@babylonjs/core/Loading/sceneLoader'
import { ShadowGenerator } from '@babylonjs/core/Lights/Shadows/shadowGenerator'
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'
import { Texture } from '@babylonjs/core/Materials/Textures/texture'
import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera'
import { Vector3 } from '@babylonjs/core/Maths/math.vector'
// side-effects
import '@babylonjs/core/Collisions/collisionCoordinator'
import '@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent'
import '@babylonjs/core/Loading/Plugins/babylonFileLoader'
/**
* メインのインゲームシーン
*/
export class MainScene
{
private readonly engine: Engine
private readonly scene: Scene
private readonly camera: Camera
private readonly mainLight: Light
private readonly shadowGenerator: ShadowGenerator
/**
* コンストラクタ
*
* @param canvas レンダリングするキャンバス要素
* @param antialias アンチエイリアスを有効にするかどうか
* @param options エンジンオプション
* @param adoptToDeviceRatio デバイスレシオに適応するかどうか
* @param sceneOptions シーンオプション
*/
constructor(
canvas: HTMLCanvasElement,
antialias: boolean = true,
options: EngineOptions = {},
adoptToDeviceRatio: boolean = true,
sceneOptions: SceneOptions = {},
) {
this.engine = new Engine(canvas, antialias, options, adoptToDeviceRatio)
this.scene = new Scene(this.engine, sceneOptions)
this.camera = setUpCamera(canvas, this.scene)
this.mainLight = setUpMainLight(this.scene)
this.shadowGenerator = new ShadowGenerator(2048, <any>this.mainLight)
this.shadowGenerator.useContactHardeningShadow = true
setUpSkybox(this.scene)
setUpGround(this.scene)
setUpWalls(this.scene)
}
/**
* シーンの動作をスタートする
*/
public async start(): Promise<void> {
await loadHouses(this.shadowGenerator)
document.addEventListener('resize', this.onResise)
this.engine.runRenderLoop(() => {
this.scene.render()
})
}
/**
* 画面リサイズ時の挙動
*/
private readonly onResise = (): void => {
this.engine.resize()
}
}
/**
* カメラを作成
*
* @param canvas Target Canvas
* @param scene Target Scene
* @returns MainCamera
*/
function setUpCamera(canvas: HTMLCanvasElement, scene: Scene): Camera {
const camera = new UniversalCamera(`MainCamera`, new Vector3(0, 2, 10), scene)
camera.setTarget(Vector3.Zero())
camera.attachControl(canvas, true)
camera.applyGravity = true
camera.ellipsoid = new Vector3(1.2, 1.2, 1.2)
camera.checkCollisions = true
return camera
}
/**
* メインライトを作成
*
* @param scene Target Scene
* @returns MainLight
*/
function setUpMainLight(scene: Scene): Light {
const light = new DirectionalLight(`MainLight`, new Vector3(0, -7, 4), scene)
light.intensity = 0.7
return light
}
/**
* スカイボックス背景を生成
*
* @param scene Target Scene
* @returns Mesh
*/
function setUpSkybox(scene: Scene): Mesh {
const mesh = BoxBuilder.CreateBox(`MainSkyBox`, { size: 10000 }, scene)
const material = new StandardMaterial(`MainSkyBoxMaterial`, scene)
material.backFaceCulling = false
material.reflectionTexture = new CubeTexture(`https://playground.babylonjs.com/textures/skybox`, scene)
material.reflectionTexture.coordinatesMode = Texture.SKYBOX_MODE
material.disableLighting = true
mesh.material = material
return mesh
}
/**
* 地面を生成
*
* @param scene Target Scene
* @returns Ground Mesh
*/
function setUpGround(scene: Scene): Mesh {
const material = new StandardMaterial('ground', scene)
const groundDiffuse = new Texture('https://playground.babylonjs.com/textures/grass.png', scene)
groundDiffuse.uScale = 60
groundDiffuse.vScale = 60
material.diffuseTexture = groundDiffuse
const groundBump = new Texture('https://playground.babylonjs.com/textures/grassn.png', scene)
groundBump.uScale = 60
groundBump.vScale = 60
material.bumpTexture = groundBump
const ground = GroundBuilder.CreateGround("MainGround", { width: 200, height: 200}, scene)
ground.material = material
ground.checkCollisions = true
ground.receiveShadows = true
return ground
}
/**
* サンプル用の家メッシュを読み込む
*
* @param scene Target Scene
*/
async function loadHouses(shadowGenerator: ShadowGenerator): Promise<void> {
const result = await SceneLoader.ImportMeshAsync(
`semi_house`,
`https://assets.babylonjs.com/meshes/`,
`both_houses_scene.babylon`,
)
const houseBase = result.meshes[0]
houseBase.checkCollisions = true;
for (let index = 0; index < 100; index++) {
const house = houseBase.clone(`house${index}`, null)
shadowGenerator.addShadowCaster(house!)
house!.position = new Vector3(Math.random() * 200 - 100, 0, Math.random() * 200 - 100)
house!.scaling = new Vector3(Math.random() + 1, Math.random() + 1, Math.random() + 1)
house!.rotate(Vector3.Up(), Math.random() * Math.PI * 4)
}
}
/**
* フィールド端の壁を設置する
*
* @param scene Target Scene
*/
function setUpWalls(scene: Scene): void {
const wallZMaterial = new StandardMaterial('wall', scene);
const wallZDiffuse = new Texture('https://playground.babylonjs.com/textures/floor.png', scene);
wallZDiffuse.uScale = 60;
wallZDiffuse.vScale = 5;
wallZMaterial.diffuseTexture = wallZDiffuse;
const wallZBump = new Texture('https://playground.babylonjs.com/textures/normalmap.jpg', scene);
wallZBump.uScale = 60;
wallZBump.vScale = 5;
wallZMaterial.bumpTexture = wallZBump;
const wallXMaterial = new StandardMaterial('wall', scene);
const wallXDiffuse = new Texture('https://playground.babylonjs.com/textures/floor.png', scene);
wallXDiffuse.uScale = 5;
wallXDiffuse.vScale = 60;
wallXMaterial.diffuseTexture = wallXDiffuse;
const wallXBump = new Texture('https://playground.babylonjs.com/textures/normalmap.jpg', scene);
wallXBump.uScale = 5;
wallXBump.vScale = 60;
wallXMaterial.bumpTexture = wallXBump;
let box = BoxBuilder.CreateBox('+z', {
size: 1,
}, scene);
box.material = wallZMaterial.clone('mat+z');
box.scaling = new Vector3(200, 10, 1);
box.position.z = 100;
box.checkCollisions = true;
box.receiveShadows = true;
box = BoxBuilder.CreateBox('-z', {
size: 1,
}, scene);
box.material = wallZMaterial.clone('mat-z');
box.scaling = new Vector3(200, 10, 1);
box.position.z = -100;
box.checkCollisions = true;
box.receiveShadows = true;
box = BoxBuilder.CreateBox('+x', {
size: 1,
}, scene);
box.material = wallXMaterial.clone('mat+x');
box.scaling = new Vector3(1, 10, 200);
box.position.x = 100;
box.checkCollisions = true;
box.receiveShadows = true;
box = BoxBuilder.CreateBox('-x', {
size: 1,
}, scene);
box.material = wallXMaterial.clone('mat-x');
box.scaling = new Vector3(1, 10, 200);
box.position.x = -100;
box.checkCollisions = true;
box.receiveShadows = true;
}
とりあえず物がある状態になりました。一応当たり判定もついているので、簡単には壁抜け出来ません。
/**
* 動く箱を生成する
*
* @param scene Target Scene
*/
function setUpMovingBox(scene: Scene): void {
const movingBox = BoxBuilder.CreateBox(`MovingBox`, {
size: 5,
}, scene)
movingBox.position.y = -2.3
movingBox.checkCollisions = true
movingBox.receiveShadows = true
scene.onBeforeRenderObservable.add(() => {
const delta = scene.getEngine().getDeltaTime()
movingBox.position.x += delta / 60
if (movingBox.position.x > 100) {
movingBox.position.x = -100
}
})
}
動く箱を生成してみました。しかし、うまく乗れてもカメラは移動せず...。ファイナルソード状態。
TODO
- 矢印キーではなくWASDで移動できる
- Pointer Lock API を使って視点移動できる
- LeftShift 押下でダッシュができる
- スペース押下でジャンプができる
WASD で移動
/**
* カメラを作成
*
* @param canvas Target Canvas
* @param scene Target Scene
* @returns MainCamera
*/
function setUpCamera(canvas: HTMLCanvasElement, scene: Scene): Camera {
const camera = new FreeCamera(`MainCamera`, new Vector3(0, 2, 10), scene)
camera.setTarget(Vector3.Zero())
camera.attachControl(canvas, true);
// 矢印キーではなく WASD で移動
camera.keysUp = ["W".charCodeAt(0)]
camera.keysDown = ["S".charCodeAt(0)]
camera.keysLeft = ["A".charCodeAt(0)]
camera.keysRight = ["D".charCodeAt(0)]
camera.applyGravity = true
camera.ellipsoid = new Vector3(1.2, 1.2, 1.2)
camera.checkCollisions = true
camera.speed = 0.5
return camera
}
トラッキングするキーコードを上書きしています。ついでに動きが早すぎるのでスピードを調整(default: 2.0)。
Pointer Lock API を使用
/**
* シーンの動作をスタートする
*/
public async start(): Promise<void> {
await loadHouses(this.shadowGenerator)
window.addEventListener('resize', this.onResise)
this.scene.activeCamera = this.camera
document.addEventListener('click', this.onMouseClick)
this.engine.runRenderLoop(() => {
this.scene.render()
})
}
private readonly onMouseClick = (): void => {
if (!this.engine.isPointerLock) {
this.engine.enterPointerlock()
}
}
this.engine.enterPointerLock
メソッドがあるので、それを使ってマウス操作時にポインターロックをかけます。 Escape でロックを解除できます。
ダッシュ
/**
* ダッシュを行えるようにするインプット
*/
class ShooterCameraDashInput implements ICameraInput<FreeCamera> {
public camera: FreeCamera
/**
* ダッシュ速度
*/
public dashSpeed = 0.8
/**
* 歩き速度
*/
public walkSpeed = 0.5
private _onKeyboardObservable: any
public constructor(camera: FreeCamera) {
this.camera = camera
}
public attachControl(noPreventDefault?: boolean): void {
noPreventDefault = Tools.BackCompatCameraNoPreventDefault(arguments)
this._onKeyboardObservable = this.camera.getScene().onKeyboardObservable.add((info) => {
if (info.type === 1 && info.event.code === 'ShiftLeft') {
this.camera.speed = this.dashSpeed
} else {
this.camera.speed = this.walkSpeed
}
if (!noPreventDefault) {
info.event.preventDefault()
}
})
}
public detachControl(): void;
public detachControl(ignored?: any): void {
if (this._onKeyboardObservable) {
this.camera.getScene().onActiveCameraChanged.remove(this._onKeyboardObservable)
this._onKeyboardObservable = null
}
}
public getClassName(): string {
return 'ShooterCameraDashInput'
}
public getSimpleName(): string {
return 'dash'
}
}
const camera = new FreeCamera(`MainCamera`, new Vector3(0, 2, 10), scene)
camera.setTarget(Vector3.Zero())
camera.inputs.add(new ShooterCameraDashInput(camera))
camera.attachControl(canvas, true);
これで LeftShift を押すと少し移動速度が上がるようになりました。
現在のコードはこんな感じです。
/**
* 敵キャラクターを生成する
*
* @param scene Target Scene
* @param shadowGenerator Shadow Generator
*/
async function loadMobs(scene: Scene, shadowGenerator: ShadowGenerator): Promise<void> {
const capsuleBase = CapsuleBuilder.CreateCapsule(`mobBase`, {
height: 2,
capSubdivisions: 6,
radius: 1,
subdivisions: 2,
tessellation: 16,
}, scene)
capsuleBase.position.y = 1
const material = new StandardMaterial(`MobMaterialBase`, scene)
capsuleBase.material = material
for (let index = 0; index < 100; index++) {
const capsule = capsuleBase.clone(`Mob${index}`, null)
capsule.material = material.clone(`MobMaterial${index}`);
(capsule.material as StandardMaterial).diffuseColor = new Color3(Math.random(), Math.random(), Math.random())
shadowGenerator.addShadowCaster(capsule)
capsule.position = new Vector3(Math.random() * 200 - 100, 1, Math.random() * 200 - 100)
capsule.rotate(Vector3.Up(), Math.random() * Math.PI * 4)
scene.onBeforeRenderObservable.add(() => {
capsule.rotate(Vector3.Up(), 0.01)
capsule.moveWithCollisions(capsule.forward.multiplyByFloats(0.05, 0.05, 0.05))
})
}
}
これで色カラフルな敵キャラクターが自動で移動していきます。 mesh.moveWithCollisions
を利用しているので壁にめり込みません。
上のスクリーンショットで気づいた方がいるかもしれませんが、最新コードでは Screen Space Ambient Occlusion
通称 SSAO
を追加しています。
import { SSAORenderingPipeline } from '@babylonjs/core/PostProcesses/RenderPipeline/Pipelines/ssaoRenderingPipeline'
// side-effects
import '@babylonjs/core/Rendering/depthRendererSceneComponent'
// ...
new SSAORenderingPipeline(`ssaoPipeline`, this.scene, 0.75, [this.camera])
一行このレンダリングパイプラインを new するだけ、それだけで SSAO が動きます。あまり精度は高くないですが、リアリティがかなり増しますね。
ちなみに影は ShadowGenerator
というコンポーネントを利用することで簡単に影生成が出来るのですが、今回は CascadedShadowGenerator
という拡張コンポーネントを利用しています。
これは、影の生成に必要なテクスチャを、カメラからの距離に応じて複数枚に分割し、近くの影は精細に、遠くの影はそれなりにという感じでよりリアルに影を感じれるものです。
import { CascadedShadowGenerator } from '@babylonjs/core/Lights/Shadows/cascadedShadowGenerator'
import '@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent'
// ...
this.shadowGenerator = new CascadedShadowGenerator(2048, <any>this.mainLight)
この shadowGenerator
に addShadowCaster(mesh)
していくことで、影を生成するメッシュを選択できます。 mesh.receiveShadows = true
とすることで、生成された影を投影するメッシュとすることが出来ます。今回は地面のみ receiveShadows をつけています。
TODO
- Mob に HP を持たせる
- Click で Ray を飛ばして Mob に当たったらダメージを与える
- 弾速を再現する
- 自分に HP を持たせる
- UI ゲージ
- Mob に自分が当たったらダメージを受ける
Ray の勉強中。 origin を camera.position にするとなぜか表示されなくなる。
const origin = this.camera.position
これだと参照渡しになるのでカメラの移動にくっついてしまうため表示域に出てこなかった。
const origin = this.camera.globalPosition.clone()
これでちゃんとマウスクリックした時点の原点から Ray が飛ばせるようになった。
const origin = this.camera.globalPosition.clone()
const forward = this.camera.getDirection(Vector3.Forward())
const ray = new Ray(origin, forward, 100)
const rayHelper = new RayHelper(ray)
rayHelper.show(this.scene)
const hit = this.scene.pickWithRay(ray, (mesh) => {
return mesh.name.match(/^Mob.+/) !== null
})
if (hit && hit.pickedMesh) {
hit.pickedMesh.dispose()
}
これで Mob を正面にとらえてクリックしたら、Mobが削除されるようになった。
(最初 predicate 関数を入れなかったので、クリックしたら床が抜けました。)
Audio も簡単に実装できる。
// こうすることで static なアセットのURLを入手することが出来る
// @see https://vitejs.dev/guide/assets.html#importing-asset-as-url
import gunfireSoundURL from './gunfire.mp3?url'
// ...
// コンストラクタで非同期読み込み
this.gunfireSound = new Sound(`Gunfire`, gunfireSoundURL, this.scene, () => this.isReadyGunfiresound = true)
// 単純に play するだけ
if (this.isReadyGunfiresound) {
this.gunfireSound.play()
}