1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-18 15:25:36 +02:00
This commit is contained in:
syuilo
2026-04-17 15:31:56 +09:00
parent ebdf627b19
commit 6cca5706f7
98 changed files with 1722 additions and 1042 deletions

View File

@@ -40,7 +40,7 @@ import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os.js';
import { OBJECT_DEFS } from '@/world/room/object-defs.js';
import { createRoomObjectPreviewEngine, RoomObjectPreviewEngine } from '@/world/room/engine.js';
import { createRoomObjectPreviewEngine, RoomObjectPreviewEngine } from '@/world/room/previewEngine.js';
const emit = defineEmits<{
(ev: 'ok', id: string): void;

View File

@@ -0,0 +1,421 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_pageScrollable">
<div :class="[$style.screen, { [$style.zen]: isZenMode }]">
<canvas ref="canvas" :class="$style.canvas" tabindex="-1" :style="{ visibility: controller.isReady.value ? 'visible' : 'hidden' }"></canvas>
<Transition
:enterActiveClass="$style.transition_fade_enterActive"
:leaveActiveClass="$style.transition_fade_leaveActive"
:enterFromClass="$style.transition_fade_enterFrom"
:leaveToClass="$style.transition_fade_leaveTo"
>
<div v-if="!controller.isReady.value" :class="$style.loading">
<div :class="$style.progressBar">
<div :class="$style.progressBarValue" :style="{ width: `${controller.initializeProgress.value * 100}%` }"></div>
</div>
</div>
</Transition>
<template v-if="!isZenMode">
<div v-if="controller.isReady.value" class="_buttonsCenter" :class="$style.overlayControls">
<template v-if="controller.isEditMode.value">
<MkButton v-if="controller.grabbing.value && !controller.grabbing.value.forInstall" @click="endGrabbing"><i class="ti ti-check"></i> (E)</MkButton>
<MkButton v-else-if="controller.grabbing.value && controller.grabbing.value.forInstall" @click="endGrabbing"><i class="ti ti-check"></i> (E)</MkButton>
<MkButton v-else-if="controller.selected.value != null" @click="beginSelectedInstalledObjectGrabbing"><i class="ti ti-hand-grab"></i> (E)</MkButton>
<MkButton v-if="controller.grabbing.value" @click="rotate"><i class="ti ti-view-360-arrow"></i> (R)</MkButton>
<MkButton :primary="controller.gridSnapping.value.enabled" @click="showSnappingMenu">Grid Snap: {{ controller.gridSnapping.value.enabled ? 'on' : 'off' }}</MkButton>
<MkButton v-if="!controller.grabbing.value && controller.selected.value != null" @click="removeSelectedObject"><i class="ti ti-trash"></i> (X)</MkButton>
</template>
<MkButton v-if="controller.isSitting.value" @click="controller.standUp()">降りる (Q)</MkButton>
<template v-for="interaction in interacions" :key="interaction.id">
<MkButton inline @click="interaction.fn()">{{ interaction.label }}{{ interaction.isPrimary ? ' (E)' : '' }}</MkButton>
</template>
</div>
<div v-if="controller.isReady.value && controller.isEditMode.value && controller.selected.value != null" :key="controller.selected.value.objectId" class="_panel" :class="$style.overlayObjectInfoPanel">
{{ controller.selected.value.objectDef.name }}
<div class="_gaps">
<div v-for="[k, s] in Object.entries(controller.selected.value.objectDef.options.schema)" :key="k">
<div>{{ s.label }}</div>
<div v-if="s.type === 'color'">
<MkInput :modelValue="getHex(controller.selected.value.objectState.options[k])" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) controller.updateObjectOption(controller.selected.value.objectId, k, c); }"></MkInput>
</div>
<div v-else-if="s.type === 'boolean'">
<MkSwitch :modelValue="controller.selected.value.objectState.options[k]" @update:modelValue="v => controller.updateObjectOption(controller.selected.value.objectId, k, v)"></MkSwitch>
</div>
<div v-else-if="s.type === 'enum'">
<MkSelect :items="s.enum.map(e => ({ label: e, value: e }))" :modelValue="controller.selected.value.objectState.options[k]" @update:modelValue="v => controller.updateObjectOption(controller.selected.value.objectId, k, v)"></MkSelect>
</div>
<div v-else-if="s.type === 'range'">
<MkRange :continuousUpdate="true" :min="s.min" :max="s.max" :step="s.step" :modelValue="controller.selected.value.objectState.options[k]" @update:modelValue="v => controller.updateObjectOption(controller.selected.value.objectId, k, v)"></MkRange>
</div>
<div v-else-if="s.type === 'image'">
<MkInput type="text" :modelValue="controller.selected.value.objectState.options[k]" @update:modelValue="v => controller.updateObjectOption(controller.selected.value.objectId, k, v)"></MkInput>
</div>
</div>
</div>
</div>
</template>
</div>
<template v-if="!isZenMode">
<div v-if="controller.isReady.value" class="_buttons" :class="$style.controls">
<!--<MkButton v-for="action in actions" :key="action.key" @click="action.fn">{{ action.label }}{{ hotkeyToLabel(action.hotkey) }}</MkButton>-->
<MkButton @click="toggleLight">Toggle Light</MkButton>
<MkButton v-if="controller.isEditMode.value" primary @click="save">Save</MkButton>
<MkButton v-if="controller.isEditMode.value" @click="exitEditMode">Exit edit mode</MkButton>
<MkButton v-if="!controller.isEditMode.value" @click="enterEditMode">Edit mode</MkButton>
<MkButton v-if="controller.isEditMode.value" @click="addObject">addObject</MkButton>
<MkButton @click="expor">Export</MkButton>
<MkButton @click="impor">Import</MkButton>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i';
import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import * as os from '@/os.js';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import { RoomController } from '@/world/room/controller.js';
const canvas = useTemplateRef('canvas');
const interacions = shallowRef<{
id: string;
label: string;
isPrimary: boolean;
fn: () => void;
}[]>([]);
function resize() {
controller.resize();
}
const isZenMode = ref(false);
const data = localStorage.getItem('roomData') != null ? { ...JSON.parse(localStorage.getItem('roomData')!), ...{
heya: {
type: 'simple',
options: {
dimension: [300, 300],
window: 'demado',
wallN: {
material: null,
color: [0.9, 0.9, 0.9],
},
wallE: {
material: null,
color: [0.33, 0.34, 0.35],
},
wallS: {
material: null,
color: [0.9, 0.9, 0.9],
},
wallW: {
material: null,
color: [0.9, 0.9, 0.9],
},
flooring: {
material: 'wood',
color: [0.9, 0.9, 0.9],
},
ceiling: {
material: null,
color: [0.9, 0.9, 0.9],
},
},
},
} } : {
heya: {
type: 'simple',
options: {
dimension: [300, 300],
window: 'demado',
wallN: {
material: null,
color: [0.9, 0.9, 0.9],
},
wallE: {
material: null,
color: [0.9, 0.9, 0.9],
},
wallS: {
material: null,
color: [0.9, 0.9, 0.9],
},
wallW: {
material: null,
color: [0.9, 0.9, 0.9],
},
flooring: {
material: 'wood',
color: [0.9, 0.9, 0.9],
},
ceiling: {
material: null,
color: [0.9, 0.9, 0.9],
},
},
},
installedObjects: [],
};
const controller = new RoomController(data);
onMounted(async () => {
controller.init(canvas.value!);
canvas.value!.focus();
window.addEventListener('resize', resize);
//watch(controller.selected, (v) => {
// if (v == null) {
// interacions.value = [];
// } else {
// interacions.value = Object.entries(v.objectEntity.instance.interactions).map(([interactionId, interactionInfo]) => ({
// id: interactionId,
// label: interactionInfo.label,
// isPrimary: v.objectEntity.instance.primaryInteraction === interactionId,
// fn: interactionInfo.fn,
// }));
// }
//});
});
onUnmounted(() => {
controller.destroy();
window.removeEventListener('resize', resize);
});
function beginSelectedInstalledObjectGrabbing() {
controller.beginSelectedInstalledObjectGrabbing();
canvas.value!.focus();
}
function endGrabbing() {
controller.endGrabbing();
canvas.value!.focus();
}
function toggleLight() {
controller.toggleRoomLight();
canvas.value!.focus();
}
function showSnappingMenu(ev: PointerEvent) {
os.popupMenu([{
type: 'switch',
text: i18n.ts._room.snapToGrid,
ref: computed({
get: () => controller.gridSnapping.value.enabled,
set: v => controller.setGridSnapping({ ...controller.gridSnapping.value, enabled: v }),
}),
}, {
type: 'radioOption',
text: '1cm',
active: computed(() => controller.gridSnapping.value.scale === 1),
action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 1 }),
}, {
type: 'radioOption',
text: '2cm',
active: computed(() => controller.gridSnapping.value.scale === 2),
action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 2 }),
}, {
type: 'radioOption',
text: '4cm',
active: computed(() => controller.gridSnapping.value.scale === 4),
action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 4 }),
}, {
type: 'radioOption',
text: '8cm',
active: computed(() => controller.gridSnapping.value.scale === 8),
action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 8 }),
}], ev.currentTarget ?? ev.target);
}
function rotate() {
controller.changeGrabbingRotationY(Math.PI / 8);
canvas.value!.focus();
}
async function addObject(ev: PointerEvent) {
const { dispose } = await os.popupAsyncWithDialog(import('./room.add-object-dialog.vue').then(x => x.default), {
}, {
ok: async (res) => {
controller.addObject(res);
canvas.value!.focus();
},
closed: () => dispose(),
});
}
function removeSelectedObject() {
controller.removeSelectedObject();
canvas.value!.focus();
}
function enterEditMode() {
controller.enterEditMode();
}
function exitEditMode() {
controller.exitEditMode();
}
function getHex(c: [number, number, number]) {
return `#${c.map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('')}`;
}
function getRgb(hex: string | number): [number, number, number] | null {
if (
typeof hex === 'number' ||
typeof hex !== 'string' ||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
) {
return null;
}
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
if (m == null) return [0, 0, 0];
return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
}
function save() {
localStorage.setItem('roomData', JSON.stringify(controller.roomState.value));
}
function expor() {
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(controller.roomState.value));
const dlAnchorElem = window.document.createElement('a');
dlAnchorElem.setAttribute('href', dataStr);
dlAnchorElem.setAttribute('download', 'room.json');
dlAnchorElem.click();
}
function impor() {
const inputElem = window.document.createElement('input');
inputElem.setAttribute('type', 'file');
inputElem.setAttribute('accept', 'application/json');
inputElem.addEventListener('change', () => {
const file = inputElem.files?.[0];
if (file == null) return;
const reader = new FileReader();
reader.onload = () => {
try {
localStorage.setItem('roomData', reader.result as string);
window.location.reload();
} catch (e) {
alert('Failed to load room data: ' + e);
}
};
reader.readAsText(file);
});
inputElem.click();
}
definePage(() => ({
title: 'Room',
icon: 'ti ti-door',
needWideArea: true,
}));
</script>
<style lang="scss" module>
.root {
height: 100%;
}
.screen {
position: relative;
width: 100%;
height: 90cqh;
}
.screen.zen {
height: 100%;
}
.canvas {
width: 100%;
height: 100%;
display: block;
background: #000;
&:focus {
outline: none;
}
}
.controls {
}
.overlayControls {
position: absolute;
bottom: 0;
left: 0;
z-index: 1;
width: 100%;
}
.overlayObjectInfoPanel {
position: absolute;
top: 16px;
right: 16px;
z-index: 1;
padding: 16px;
box-sizing: border-box;
width: 300px;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
place-items: center;
background: var(--MI_THEME-bg);
}
.progressBar {
width: 75%;
height: 4px;
border-radius: 999px;
overflow: clip;
background-color: var(--MI_THEME-accentedBg);
}
.progressBarValue {
height: 100%;
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
transition: all 0.5s cubic-bezier(0,.5,.5,1);
}
.transition_fade_enterActive,
.transition_fade_leaveActive {
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.transition_fade_enterFrom,
.transition_fade_leaveTo {
opacity: 0;
}
</style>

View File

@@ -594,6 +594,9 @@ export const ROUTE_DEF = [{
path: '/qr',
component: page(() => import('@/pages/qr.vue')),
loginRequired: true,
}, {
path: '/world',
component: page(() => import('@/pages/world.vue')),
}, {
path: '/room',
component: page(() => import('@/pages/room.vue')),

View File

@@ -0,0 +1,267 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// TODO: 家具設置時のコリジョン判定(めりこんで設置されないようにする)
// TODO: 近くのオブジェクトの端にスナップオプション
// TODO: 近くのオブジェクトの原点に軸を揃えるオプション
// TODO: glbを事前に最適化(なるべくメッシュをマージするなど)するツールもしくはMisskeyビルド時処理。ついでにカタログ用スクショも自動生成したい
import * as BABYLON from '@babylonjs/core';
import { AxesViewer } from '@babylonjs/core/Debug/axesViewer';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic';
import { EventEmitter } from 'eventemitter3';
import { HorizontalCameraKeyboardMoveInput, camelToKebab, cm } from './utility.js';
import { TIME_MAP } from './utility.js';
import { genId } from '@/utility/id.js';
import { deepClone } from '@/utility/clone.js';
const SNAPSHOT_RENDERING = true; // 実験的
const USE_GLOW = true; // ドローコールが増えて重い
const IN_WEB_WORKER = typeof window === 'undefined';
export type WorldEngineEvents = {
'playSfxUrl': (ctx: {
url: string;
options: {
volume: number;
playbackRate: number;
};
}) => void;
'loadingProgress': (ctx: { progress: number }) => void;
};
export class WorldEngine extends EventEmitter<WorldEngineEvents> {
private canvas: HTMLCanvasElement;
private engine: BABYLON.WebGPUEngine;
public scene: BABYLON.Scene;
private shadowGeneratorForSunLight: BABYLON.ShadowGenerator;
public camera: BABYLON.UniversalCamera;
public intervalIds: number[] = [];
public timeoutIds: number[] = [];
private time: 0 | 1 | 2 = 0; // 0: 昼, 1: 夕, 2: 夜
private envMapOutdoor: BABYLON.CubeTexture;
public lightContainer: BABYLON.ClusteredLightContainer;
public sr: BABYLON.SnapshotRenderingHelper;
public isSitting = false;
private fps: number | null = null;
private disposed = false;
public domEvents: EventEmitter<{
'click': (event: { offsetX: number; offsetY: number; }) => void;
'keydown': (event: { code: string; shiftKey: boolean; }) => void;
'keyup': (event: { code: string; shiftKey: boolean; }) => void;
'wheel': (event: { deltaY: number; }) => void;
}> = new EventEmitter();
constructor(options: {
canvas: HTMLCanvasElement;
engine: BABYLON.WebGPUEngine;
}) {
super();
this.canvas = options.canvas;
registerBuiltInLoaders();
this.engine = options.engine;
this.scene = new BABYLON.Scene(this.engine);
// なんかレンダリングがおかしくなるときがあるのでコメントアウト
// オブジェクトを選択し、後ろを向いて別のオブジェクトを選択した後、最初のオブジェクトに振り返ると消えているなど
//this.scene.performancePriority = BABYLON.ScenePerformancePriority.Intermediate;
this.scene.autoClear = false;
//this.scene.autoClearDepthAndStencil = false;
this.scene.skipPointerMovePicking = true;
this.scene.skipFrustumClipping = true; // snapshot renderingでは全てのメッシュがアクティブになっている必要があるため
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
const skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(1000) }, this.scene);
const skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.scene);
skyboxMat.backFaceCulling = false;
skyboxMat.disableLighting = true;
skybox.material = skyboxMat;
skybox.infiniteDistance = true;
this.time = TIME_MAP[new Date().getHours() as keyof typeof TIME_MAP];
if (this.time === 0) {
skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.9, 1.0);
} else if (this.time === 1) {
skyboxMat.emissiveColor = new BABYLON.Color3(0.8, 0.5, 0.3);
} else {
skyboxMat.emissiveColor = new BABYLON.Color3(0.05, 0.05, 0.2);
}
this.scene.ambientColor = new BABYLON.Color3(1.0, 0.9, 0.8);
this.envMapOutdoor = BABYLON.CubeTexture.CreateFromPrefilteredData(this.time === 2 ? '/client-assets/room/outdoor-night.env' : '/client-assets/room/outdoor-day.env', this.scene);
this.envMapOutdoor.level = this.time === 0 ? 0.5 : this.time === 1 ? 0.3 : 0.1;
this.scene.collisionsEnabled = true;
this.camera = new BABYLON.UniversalCamera('camera', new BABYLON.Vector3(0, cm(130), cm(0)), this.scene);
this.camera.inputs.removeByType('FreeCameraKeyboardMoveInput');
this.camera.inputs.add(new HorizontalCameraKeyboardMoveInput(this.camera));
this.camera.attachControl(this.canvas);
this.camera.minZ = cm(1);
this.camera.maxZ = cm(2000);
this.camera.fov = 1;
this.camera.ellipsoid = new BABYLON.Vector3(cm(15), cm(65), cm(15));
this.camera.checkCollisions = true;
this.camera.applyGravity = true;
this.camera.needMoveForGravity = true;
//this.scene.activeCamera = this.camera;
const ambientLight = new BABYLON.HemisphericLight('ambientLight', new BABYLON.Vector3(0, 1, -0.5), this.scene);
ambientLight.diffuse = new BABYLON.Color3(1.0, 1.0, 1.0);
ambientLight.intensity = 0.5;
//ambientLight.intensity = 0;
const sunLight = new BABYLON.DirectionalLight('sunLight', new BABYLON.Vector3(0.2, -1, -1), this.scene);
sunLight.position = new BABYLON.Vector3(cm(-20), cm(1000), cm(1000));
sunLight.diffuse = this.time === 0 ? new BABYLON.Color3(1.0, 0.9, 0.8) : this.time === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
sunLight.intensity = this.time === 0 ? 3 : this.time === 1 ? 1 : 0.25;
sunLight.shadowMinZ = cm(1000);
sunLight.shadowMaxZ = cm(2000);
this.shadowGeneratorForSunLight = new BABYLON.ShadowGenerator(2048, sunLight);
this.shadowGeneratorForSunLight.forceBackFacesOnly = true;
this.shadowGeneratorForSunLight.bias = 0.0001;
this.shadowGeneratorForSunLight.usePercentageCloserFiltering = true;
this.shadowGeneratorForSunLight.usePoissonSampling = true;
if (!SNAPSHOT_RENDERING) this.shadowGeneratorForSunLight.getShadowMap().refreshRate = 60; // snapshot renderingではrefreshRateが設定されているとなぜかクラッシュする
this.lightContainer = new BABYLON.ClusteredLightContainer('clustered', [], this.scene);
if (USE_GLOW) {
const gl = new BABYLON.GlowLayer('glow', this.scene, {
//mainTextureFixedSize: 512,
blurKernelSize: 64,
});
gl.intensity = 0.5;
this.scene.setRenderingAutoClearDepthStencil(gl.renderingGroupId, false);
if (SNAPSHOT_RENDERING) {
this.sr.updateMeshesForEffectLayer(gl);
}
}
if (_DEV_) {
// snapshot renderingかつglow layerが有効だとなんかクラッシュする
if (!(SNAPSHOT_RENDERING && USE_GLOW)) {
const axes = new AxesViewer(this.scene, 30);
axes.xAxis.position = new BABYLON.Vector3(0, 30, 0);
axes.yAxis.position = new BABYLON.Vector3(0, 30, 0);
axes.zAxis.position = new BABYLON.Vector3(0, 30, 0);
}
if (!IN_WEB_WORKER) {
(window as any).showBabylonInspector = () => {
import('@babylonjs/inspector').then(({ ShowInspector }) => {
ShowInspector(this.scene);
});
};
}
}
}
public async init() {
this.scene.blockMaterialDirtyMechanism = true;
if (SNAPSHOT_RENDERING) {
this.sr.enableSnapshotRendering();
}
if (this.fps == null) {
this.engine.runRenderLoop(() => {
this.scene.render();
});
} else {
let then = 0;
const interval = 1000 / this.fps;
const renderLoop = (timeStamp: number) => {
if (this.disposed) return;
window.requestAnimationFrame(renderLoop);
const delta = timeStamp - then;
if (delta <= interval) return;
then = timeStamp - (delta % interval);
this.engine.beginFrame();
this.scene.render();
this.engine.endFrame();
};
window.requestAnimationFrame(renderLoop);
}
this.domEvents.on('keydown', (ev) => {
});
this.domEvents.on('wheel', (ev) => {
this.camera.fov += ev.deltaY * 0.001;
this.camera.fov = Math.max(0.25, Math.min(1, this.camera.fov));
});
this.domEvents.on('click', (ev) => {
});
}
private async loadEnvModel() {
const envObj = await BABYLON.ImportMeshAsync('/client-assets/room/env.glb', this.scene);
envObj.meshes[0].scaling = envObj.meshes[0].scaling.scale(WORLD_SCALE);
envObj.meshes[0].bakeCurrentTransformIntoVertices();
envObj.meshes[0].position = new BABYLON.Vector3(0, cm(-900), 0); // 4階くらいの想定
envObj.meshes[0].rotation = new BABYLON.Vector3(0, -Math.PI, 0);
for (const mesh of envObj.meshes) {
mesh.isPickable = false;
mesh.checkCollisions = false;
//if (mesh.name === '__root__') continue;
mesh.receiveShadows = false;
if (mesh.material) (mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.envMapOutdoor;
}
}
public sitChair(objectId: string) {
this.isSitting = true;
this.fixedCamera.parent = this.objectMeshs.get(objectId);
this.fixedCamera.position = new BABYLON.Vector3(0, cm(120), 0);
this.fixedCamera.rotation = new BABYLON.Vector3(0, 0, 0);
this.scene.activeCamera = this.fixedCamera;
this.selectObject(null);
}
public standUp() {
this.isSitting = false;
this.scene.activeCamera = this.camera;
this.fixedCamera.parent = null;
}
private playSfxUrl(url: string, options: { volume: number; playbackRate: number }) {
this.emit('playSfxUrl', { url, options });
}
public resize() {
this.engine.resize();
}
public destroy() {
for (const id of this.intervalIds) {
window.clearInterval(id);
}
for (const id of this.timeoutIds) {
window.clearTimeout(id);
}
this.intervalIds = [];
this.timeoutIds = [];
this.engine.dispose();
this.disposed = true;
}
}

View File

@@ -199,6 +199,7 @@ export class RoomController {
}
public addObject(type: string) {
console.log(type);
if (this.worker != null) {
this.worker.postMessage({ type: 'addObject', objectType: type });
} else if (this.engine != null) {

View File

@@ -14,59 +14,19 @@ import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic';
import { BoundingBoxRenderer } from '@babylonjs/core/Rendering/boundingBoxRenderer';
import { GridMaterial } from '@babylonjs/materials';
import { EventEmitter } from 'eventemitter3';
import { TIME_MAP, scaleMorph, HorizontalCameraKeyboardMoveInput, camelToKebab, cm, WORLD_SCALE, getMeshesBoundingBox } from '../utility.js';
import { getObjectDef } from './object-defs.js';
import { HorizontalCameraKeyboardMoveInput, applyMorphTargetsToMesh, camelToKebab, cm, findMaterial, scaleMorph } from './utility.js';
import { findMaterial, ModelManager, SYSTEM_MESH_NAMES } from './utility.js';
import type { ObjectDef, RoomObjectInstance, RoomStateObject } from './object.js';
import { genId } from '@/utility/id.js';
import { deepClone } from '@/utility/clone.js';
const BAKE_TRANSFORM = false; // 実験的
const SNAPSHOT_RENDERING = true; // 実験的
const IGNORE_OBJECTS: string[] = []; // for debug
const SYSTEM_MESH_NAMES = ['__TOP__', '__SIDE__', '__PICK__', '__COLLISION__'];
const USE_GLOW = true; // ドローコールが増えて重い
const IN_WEB_WORKER = typeof window === 'undefined';
const TIME_MAP = {
0: 2,
1: 2,
2: 2,
3: 2,
4: 2,
5: 1,
6: 1,
7: 0,
8: 0,
9: 0,
10: 0,
11: 0,
12: 0,
13: 0,
14: 0,
15: 0,
16: 1,
17: 1,
18: 2,
19: 2,
20: 2,
21: 2,
22: 2,
23: 2,
} as const;
// babylonのドメイン知識は持たない
export type RoomStateObject<Options = any> = {
id: string;
type: string;
position: [number, number, number];
rotation: [number, number, number];
options: Options;
/**
* 別のオブジェクトのID
*/
sticky?: string | null;
};
type SimpleHeyaWallBase = {
material: null | 'wood' | 'concrete';
color: [number, number, number];
@@ -99,221 +59,6 @@ export type RoomState = {
installedObjects: RoomStateObject<any>[];
};
type RoomObjectInstance<Options> = {
onInited?: () => void;
onOptionsUpdated?: <K extends keyof Options, V extends Options[K]>(kv: [K, V]) => void;
interactions: Record<string, {
label: string;
fn: () => void;
}>;
primaryInteraction?: string | null;
resetTemporaryState?: () => void;
dispose?: () => void;
};
export const WORLD_SCALE = 100;
type NumberOptionSchema = {
type: 'number';
label: string;
min?: number;
max?: number;
step?: number;
};
type BooleanOptionSchema = {
type: 'boolean';
label: string;
};
type ColorOptionSchema = {
type: 'color';
label: string;
};
type EnumOptionSchema = {
type: 'enum';
label: string;
enum: string[];
};
type RangeOptionSchema = {
type: 'range';
label: string;
min: number;
max: number;
step?: number;
};
type ImageOptionSchema = {
type: 'image';
label: string;
};
type OptionsSchema = Record<string, NumberOptionSchema | BooleanOptionSchema | ColorOptionSchema | EnumOptionSchema | RangeOptionSchema | ImageOptionSchema>;
type GetOptionsSchemaValues<T extends OptionsSchema> = {
[K in keyof T]:
T[K] extends NumberOptionSchema ? number :
T[K] extends BooleanOptionSchema ? boolean :
T[K] extends ColorOptionSchema ? [number, number, number] :
T[K] extends EnumOptionSchema ? T[K]['enum'][number] :
T[K] extends RangeOptionSchema ? number :
T[K] extends ImageOptionSchema ? string | null :
never;
};
class ModelManager {
public root: BABYLON.Mesh;
public bakedCallback: (() => void) | null = null;
public bakeExcludeMeshes: BABYLON.Mesh[] = [];
private originalMeshes: BABYLON.Mesh[] = [];
private bakedMeshes: BABYLON.Mesh[] = [];
private hasTexture: boolean;
constructor(root: BABYLON.Mesh, originalMeshes: BABYLON.Mesh[], hasTexture: boolean, bakedCallback: (() => void) | null = null) {
this.root = root;
this.originalMeshes = originalMeshes;
this.hasTexture = hasTexture;
this.bakedCallback = bakedCallback;
}
public findMesh(keyword: string) {
const mesh = this.root.getChildMeshes().find(m => m.name.includes(keyword));
if (mesh == null) {
throw new Error(`Mesh with keyword "${keyword}" not found for object ${this.root.metadata?.objectType}`);
}
return mesh as BABYLON.Mesh;
}
public findMeshes(keyword: string) {
const meshes = this.root.getChildMeshes().filter(m => m.name.includes(keyword));
return meshes as BABYLON.Mesh[];
}
public findMaterial(keyword: string) {
return findMaterial(this.root, keyword);
}
public findTransformNode(keyword: string) {
const node = this.root.getChildTransformNodes().find(n => n.name.includes(keyword));
if (node == null) {
throw new Error(`TransformNode with keyword "${keyword}" not found for object ${this.root.metadata?.objectType}`);
}
return node;
}
public updated() {
}
public bakeMesh() {
for (const m of this.bakedMeshes) {
m.dispose();
}
this.bakedMeshes = [];
const excludeMeshes = [...this.bakeExcludeMeshes, ...this.root.getChildMeshes().filter(m => SYSTEM_MESH_NAMES.some(s => m.name.includes(s)))];
const childMeshes = this.root.getChildMeshes().filter(m => !excludeMeshes.some(x => x === m) && m.isVisible && !m.isDisposed());
if (childMeshes.length <= 1) {
this.bakedCallback?.([...childMeshes, ...excludeMeshes]);
return;
}
const _toMerge = [] as BABYLON.Mesh[];
for (const mesh of childMeshes) {
let fixedMesh = mesh;
fixedMesh.setEnabled(false);
if (mesh instanceof BABYLON.InstancedMesh) {
const sourceMesh = mesh.sourceMesh;
const realizedMesh = sourceMesh.clone(mesh.name + '_realized', null, true);
realizedMesh.getScene().removeMesh(realizedMesh);
realizedMesh.position = mesh.position.clone();
if (mesh.rotationQuaternion) {
realizedMesh.rotationQuaternion = mesh.rotationQuaternion.clone();
} else {
realizedMesh.rotation = mesh.rotation.clone();
}
realizedMesh.scaling = mesh.scaling.clone();
realizedMesh.parent = mesh.parent;
realizedMesh.setEnabled(false);
fixedMesh = realizedMesh;
}
_toMerge.push(fixedMesh);
}
const toMerge = [] as BABYLON.Mesh[];
for (const mesh of _toMerge) {
const newMesh = mesh.name.endsWith('_realized') ? mesh : mesh.clone(mesh.name + '_bakeMerged', null, true);
newMesh.makeGeometryUnique();
applyMorphTargetsToMesh(newMesh);
if (newMesh.parent === this.root) {
newMesh.parent = null;
} else {
newMesh.setParent(this.root);
//newMesh.bakeCurrentTransformIntoVertices();
newMesh.parent = null;
}
//newMesh.bakeCurrentTransformIntoVertices();
if (this.hasTexture) {
if (newMesh.getVerticesData(BABYLON.VertexBuffer.UVKind) == null) {
const vertexCount = newMesh.getTotalVertices();
const uvs = new Array(vertexCount * 2).fill(0);
newMesh.setVerticesData(BABYLON.VertexBuffer.UVKind, uvs, false, 2);
}
if (newMesh.getVerticesData(BABYLON.VertexBuffer.UV2Kind) == null) {
const vertexCount = newMesh.getTotalVertices();
const uvs = new Array(vertexCount * 2).fill(0);
newMesh.setVerticesData(BABYLON.VertexBuffer.UV2Kind, uvs, false, 2);
}
}
toMerge.push(newMesh);
}
if (toMerge.length === 0) {
this.bakedCallback?.([...childMeshes, ...excludeMeshes]);
return;
}
const merged = BABYLON.Mesh.MergeMeshes(toMerge, true, false, undefined, false, true);
merged.parent = this.root;
merged.material.freeze();
if (merged.material instanceof BABYLON.MultiMaterial) {
for (const subMat of merged.material.subMaterials) {
(subMat as BABYLON.PBRMaterial).freeze();
}
}
merged.freezeWorldMatrix();
merged.metadata = { ...this.root.metadata };
if (!this.hasTexture) merged.convertToUnIndexedMesh();
this.bakedMeshes = [merged];
this.bakedCallback?.([...this.bakedMeshes, ...excludeMeshes]);
}
public unbakeMesh() {
for (const m of this.bakedMeshes) {
m.dispose();
}
this.bakedMeshes = [];
const childMeshes = this.root.getChildMeshes();
for (const mesh of childMeshes) {
mesh.setEnabled(true);
}
this.bakedCallback?.(this.root.getChildMeshes());
}
}
function mergeMeshes(meshes: BABYLON.Mesh[], root: BABYLON.Mesh, hasTexture: boolean) {
const excludeMeshes = root.getChildMeshes().filter(m => SYSTEM_MESH_NAMES.some(s => m.name.includes(s)));
@@ -352,58 +97,6 @@ function mergeMeshes(meshes: BABYLON.Mesh[], root: BABYLON.Mesh, hasTexture: boo
return merged;
}
export type ObjectDef<OpSc extends OptionsSchema = OptionsSchema> = {
id: string;
name: string;
path?: string;
options: {
schema: OpSc;
default: GetOptionsSchemaValues<OpSc>;
};
placement: 'top' | 'side' | 'bottom' | 'wall' | 'ceiling' | 'floor';
hasCollisions?: boolean;
hasTexture?: boolean;
canPreMeshesMerging?: boolean;
//groupingMeshes: string[]; // multi-materialなメッシュは複数のメッシュに分割されるが、それだと不便な場合に追加の親メッシュでグルーピングするための指定
isChair?: boolean;
treatLoaderResult?: (loaderResult: BABYLON.AssetContainer) => void;
createInstance: (args: {
room?: RoomEngine | null;
scene: BABYLON.Scene;
root: BABYLON.Mesh;
options: Readonly<GetOptionsSchemaValues<OpSc>>;
model: ModelManager;
id: string;
stickyMarkerMeshUpdated?: (mesh: BABYLON.Mesh) => void;
}) => RoomObjectInstance<GetOptionsSchemaValues<OpSc>> | Promise<RoomObjectInstance<GetOptionsSchemaValues<OpSc>>>; // TODO: createInstanceをasyncにするのではなく、別にreadyみたいなものを返させる
};
export function defineObject<const OpSc extends OptionsSchema>(def: ObjectDef<OpSc>): ObjectDef<OpSc> {
return def;
}
export function defineObjectClass<const OpSc extends OptionsSchema>(baseDef: Partial<ObjectDef<OpSc>>): {
extend: (childDef: Partial<ObjectDef<OpSc>>) => ObjectDef<OpSc>;
} {
return {
extend: (childDef) => ({ ...baseDef, ...childDef }) as ObjectDef<OpSc>,
};
}
// この実装方法だとマイナスの座標をうまく処理できず結果がおかしくなるので応急処置で全体を+10000cmオフセットしてから計算している
function getMeshesBoundingBox(meshes: BABYLON.Mesh[]): BABYLON.BoundingBox {
let min = new BABYLON.Vector3(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE);
let max = new BABYLON.Vector3(Number.MIN_VALUE, Number.MIN_VALUE, Number.MIN_VALUE);
for (const mesh of meshes) {
const boundingInfo = mesh.getBoundingInfo();
min = BABYLON.Vector3.Minimize(min, boundingInfo.boundingBox.minimumWorld.add(new BABYLON.Vector3(10000, 10000, 10000)));
max = BABYLON.Vector3.Maximize(max, boundingInfo.boundingBox.maximumWorld.add(new BABYLON.Vector3(10000, 10000, 10000)));
}
return new BABYLON.BoundingBox(min.subtract(new BABYLON.Vector3(10000, 10000, 10000)), max.subtract(new BABYLON.Vector3(10000, 10000, 10000)));
}
function enableObjectCollision(meshes: BABYLON.Mesh[]) {
for (const mesh of meshes) {
if (mesh.name.includes('__COLLISION__')) {
@@ -444,8 +137,6 @@ export class RoomEngine extends EventEmitter<RoomEngineEvents> {
private shadowGeneratorForRoomLight: BABYLON.ShadowGenerator;
private shadowGeneratorForSunLight: BABYLON.ShadowGenerator;
public camera: BABYLON.UniversalCamera;
private fixedCamera: BABYLON.UniversalCamera;
private birdeyeCamera: BABYLON.ArcRotateCamera;
public intervalIds: number[] = [];
public timeoutIds: number[] = [];
public objectEntities: Map<string, {
@@ -655,45 +346,6 @@ export class RoomEngine extends EventEmitter<RoomEngineEvents> {
}
}
{
//const postProcess = new BABYLON.ImageProcessingPostProcess('processing', 1.0, this.camera);
//postProcess.exposure = 1.1;
//postProcess.contrast = 0.9;
//const curve = new BABYLON.ColorCurves();
//curve.highlightsHue = 40;
//curve.highlightsDensity = 50;
//curve.highlightsSaturation = 40;
//curve.shadowsHue = 200;
//curve.shadowsDensity = 100;
//curve.shadowsSaturation = 40;
//postProcess.colorCurvesEnabled = true;
//postProcess.colorCurves = curve;
//const postProcess2 = new BABYLON.ImageProcessingPostProcess('processing2', 1.0, this.birdeyeCamera);
//postProcess2.exposure = 2;
//postProcess2.contrast = 0.9;
//const ssao = new BABYLON.SSAORenderingPipeline('ssao', this.scene, {
// ssaoRatio: 4,
// combineRatio: 1,
//});
//ssao.radius = 0.0001;
//ssao.totalStrength = 0.8;
//this.scene.postProcessRenderPipelineManager.attachCamerasToRenderPipeline('ssao', this.camera);
//const lensEffect = new BABYLON.LensRenderingPipeline('lens', {
// edge_blur: 1.0,
// distortion: 0.5,
// dof_focus_distance: cm(90),
// dof_aperture: 6.0,
// dof_pentagon: true,
// dof_gain: 2.0,
// dof_threshold: 1.0,
// dof_darken: 0,
//}, this.scene, 1, [this.camera]);
}
this.putParticleSystem = new BABYLON.ParticleSystem('', 64, this.scene);
this.putParticleSystem.particleTexture = new BABYLON.Texture('/client-assets/room/steam.png');
this.putParticleSystem.createCylinderEmitter(cm(5), cm(1), cm(5));
@@ -1952,14 +1604,6 @@ export class RoomEngine extends EventEmitter<RoomEngineEvents> {
}
}
public showBoundingBox() {
for (const mesh of this.objectMeshs.values()) {
for (const m of mesh.getChildMeshes()) {
m.showBoundingBox = true;
}
}
}
private playSfxUrl(url: string, options: { volume: number; playbackRate: number }) {
this.emit('playSfxUrl', { url, options });
}
@@ -1981,239 +1625,3 @@ export class RoomEngine extends EventEmitter<RoomEngineEvents> {
this.disposed = true;
}
}
export async function createRoomObjectPreviewEngine(canvas: HTMLCanvasElement) {
//const babylonEngine = new BABYLON.WebGPUEngine(canvas);
//babylonEngine.compatibilityMode = false;
//await babylonEngine.initAsync();
const babylonEngine = new BABYLON.Engine(canvas, false, { alpha: false, antialias: false });
return new RoomObjectPreviewEngine({ canvas, engine: babylonEngine });
}
export class RoomObjectPreviewEngine {
private canvas: HTMLCanvasElement;
private engine: BABYLON.WebGPUEngine;
private scene: BABYLON.Scene;
private shadowGenerator1: BABYLON.ShadowGenerator;
private camera: BABYLON.ArcRotateCamera;
private objectMesh: BABYLON.Mesh | null = null;
private objectInstance: RoomObjectInstance<any> | null = null;
private envMapIndoor: BABYLON.CubeTexture;
private roomLight: BABYLON.SpotLight;
private zGridPreviewPlane: BABYLON.Mesh;
private fps = 60;
constructor(options: {
canvas: HTMLCanvasElement;
engine: BABYLON.WebGPUEngine;
}) {
this.canvas = options.canvas;
registerBuiltInLoaders();
this.engine = options.engine;
this.scene = new BABYLON.Scene(this.engine);
this.scene.ambientColor = new BABYLON.Color3(1.0, 0.9, 0.8);
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
this.camera = new BABYLON.ArcRotateCamera('camera', -Math.PI / 2, Math.PI / 2.5, cm(300), new BABYLON.Vector3(0, cm(90), 0), this.scene);
this.camera.attachControl(this.canvas);
this.camera.minZ = cm(1);
this.camera.maxZ = cm(100000);
this.camera.fov = 0.5;
this.camera.lowerBetaLimit = 0;
this.camera.upperBetaLimit = (Math.PI / 2) + 0.1;
this.camera.lowerRadiusLimit = cm(50);
this.camera.upperRadiusLimit = cm(1000);
this.camera.useAutoRotationBehavior = true;
this.camera.autoRotationBehavior!.idleRotationSpeed = 0.3;
//this.camera.mode = BABYLON.Camera.ORTHOGRAPHIC_CAMERA;
this.scene.activeCamera = this.camera;
const ambientLight = new BABYLON.HemisphericLight('ambientLight', new BABYLON.Vector3(0, 1, -0.5), this.scene);
ambientLight.diffuse = new BABYLON.Color3(1.0, 1.0, 1.0);
ambientLight.intensity = 0.5;
//ambientLight.intensity = 0;
this.roomLight = new BABYLON.SpotLight('roomLight', new BABYLON.Vector3(0, cm(249), 0), new BABYLON.Vector3(0, -1, 0), 16, 8, this.scene);
this.roomLight.diffuse = new BABYLON.Color3(1.0, 0.9, 0.8);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(300);
this.shadowGenerator1 = new BABYLON.ShadowGenerator(4096, this.roomLight);
this.shadowGenerator1.forceBackFacesOnly = true;
this.shadowGenerator1.bias = 0.0001;
this.shadowGenerator1.usePercentageCloserFiltering = true;
this.shadowGenerator1.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//this.shadowGenerator1.useContactHardeningShadow = true;
const gridMaterial = new GridMaterial('grid', this.scene);
gridMaterial.lineColor = new BABYLON.Color3(0.5, 0.5, 0.5);
gridMaterial.mainColor = new BABYLON.Color3(0, 0, 0);
gridMaterial.minorUnitVisibility = 1;
gridMaterial.opacity = 0.5;
gridMaterial.gridRatio = cm(10);
//this.zGridPreviewPlane = BABYLON.MeshBuilder.CreatePlane('zGridPreviewPlane', { width: cm(1000), height: cm(1000) }, this.scene);
//this.zGridPreviewPlane.material = gridMaterial;
//this.zGridPreviewPlane.rotation = new BABYLON.Vector3(Math.PI / 2, 0, 0);
//this.scene.fogMode = BABYLON.Scene.FOGMODE_LINEAR;
//this.scene.fogStart = cm(100);
//this.scene.fogEnd = cm(110);
//this.scene.fogColor = new BABYLON.Color3(0.0, 0.0, 0.0);
}
public async init() {
const frameInterval = 1000 / this.fps;
let lastTime = performance.now();
this.engine.runRenderLoop(() => {
const currentTime = performance.now();
const delta = currentTime - lastTime;
if (delta >= frameInterval) {
this.scene.render();
lastTime = currentTime - (delta % frameInterval);
}
});
}
public async load(type: string) {
if (this.objectInstance != null) {
this.objectInstance.dispose?.();
this.objectInstance = null;
this.objectMesh!.dispose();
}
// reset camera rotation
this.camera.setPosition(new BABYLON.Vector3(0, cm(90), cm(300)));
const def = getObjectDef(type);
const options = deepClone(def.options.default);
const id = genId();
await this.loadObject({
type,
options,
id,
});
// なぜかちょっと待たないとbounding boxのサイズが正しくない
window.setTimeout(() => {
const boundingInfo = getMeshesBoundingBox(this.objectMesh!.getChildMeshes().filter(m => m.isEnabled() && m.isVisible));
this.camera.setTarget(new BABYLON.Vector3(0, boundingInfo.center.y, 0));
// zoom to fit
const size = boundingInfo.extendSize;
const distance = Math.max(size.x, size.y, size.z) * 2;
this.camera.radius = distance * 3;
//this.camera.orthoLeft = -distance;
//this.camera.orthoRight = distance;
//this.camera.orthoTop = distance;
//this.camera.orthoBottom = -distance;
}, 10);
}
// TODO: RoomEngineのものとほぼ同じだからいい感じに共通化
private async loadObject(args: {
type: string;
options: any;
id: string;
}) {
const def = getObjectDef(args.type);
const root = new BABYLON.Mesh(`object_${args.type}`, this.scene);
const filePath = def.path != null ? `/client-assets/room/objects/${def.path}.glb` : `/client-assets/room/objects/${camelToKebab(args.type)}/${camelToKebab(args.type)}.glb`;
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
// 不要なUVを掃除
if (!def.hasTexture) {
for (const m of loaderResult.meshes) {
if (m.geometry != null) {
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UVKind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV2Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV3Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV4Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV5Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV6Kind);
}
}
}
// babylonによって自動で追加される右手系変換用ード
const subRoot = loaderResult.meshes[0];
subRoot.scaling = subRoot.scaling.scale(WORLD_SCALE);// cmをmに
def.treatLoaderResult?.(loaderResult);
root.addChild(subRoot);
const model = new ModelManager(subRoot, loaderResult.meshes.filter(m => !m.isDisposed() && m !== subRoot), def.hasTexture, (meshes) => {
for (const m of meshes) {
const mesh = m;
// シェイプキー(morph)を考慮してbounding boxを更新するために必要
mesh.refreshBoundingInfo({ applyMorph: true });
if (SYSTEM_MESH_NAMES.some(n => mesh.name.includes(n))) {
mesh.receiveShadows = false;
mesh.isVisible = false;
} else {
if (def.receiveShadows !== false) mesh.receiveShadows = true;
if (def.castShadows !== false) {
this.shadowGenerator1.addShadowCaster(mesh);
}
if (mesh.material) {
if (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
(subMat as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
(subMat as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
}
} else {
(mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
}
}
}
if (!this.scene.meshes.includes(mesh)) this.scene.addMesh(mesh);
}
});
const objectInstance = await def.createInstance({
room: null,
scene: this.scene,
root,
options: args.options,
model,
id: args.id,
});
objectInstance.onInited?.();
model.bakeMesh();
this.objectInstance = objectInstance;
this.objectMesh = root;
}
public updateObjectOption(key: string, value: any) {
this.objectInstance?.onOptionsUpdated?.([key, value]);
}
public resize() {
this.engine.resize();
}
public destroy() {
this.engine.dispose();
}
}

View File

@@ -0,0 +1,122 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import type { RoomEngine } from './engine.js';
import type { ModelManager } from './utility.js';
// babylonのドメイン知識は持たない
export type RoomStateObject<Options = any> = {
id: string;
type: string;
position: [number, number, number];
rotation: [number, number, number];
options: Options;
/**
* 別のオブジェクトのID
*/
sticky?: string | null;
};
export type RoomObjectInstance<Options> = {
onInited?: () => void;
onOptionsUpdated?: <K extends keyof Options, V extends Options[K]>(kv: [K, V]) => void;
interactions: Record<string, {
label: string;
fn: () => void;
}>;
primaryInteraction?: string | null;
resetTemporaryState?: () => void;
dispose?: () => void;
};
type NumberOptionSchema = {
type: 'number';
label: string;
min?: number;
max?: number;
step?: number;
};
type BooleanOptionSchema = {
type: 'boolean';
label: string;
};
type ColorOptionSchema = {
type: 'color';
label: string;
};
type EnumOptionSchema = {
type: 'enum';
label: string;
enum: string[];
};
type RangeOptionSchema = {
type: 'range';
label: string;
min: number;
max: number;
step?: number;
};
type ImageOptionSchema = {
type: 'image';
label: string;
};
type OptionsSchema = Record<string, NumberOptionSchema | BooleanOptionSchema | ColorOptionSchema | EnumOptionSchema | RangeOptionSchema | ImageOptionSchema>;
type GetOptionsSchemaValues<T extends OptionsSchema> = {
[K in keyof T]:
T[K] extends NumberOptionSchema ? number :
T[K] extends BooleanOptionSchema ? boolean :
T[K] extends ColorOptionSchema ? [number, number, number] :
T[K] extends EnumOptionSchema ? T[K]['enum'][number] :
T[K] extends RangeOptionSchema ? number :
T[K] extends ImageOptionSchema ? string | null :
never;
};
export type ObjectDef<OpSc extends OptionsSchema = OptionsSchema> = {
id: string;
name: string;
path?: string;
options: {
schema: OpSc;
default: GetOptionsSchemaValues<OpSc>;
};
placement: 'top' | 'side' | 'bottom' | 'wall' | 'ceiling' | 'floor';
hasCollisions?: boolean;
hasTexture?: boolean;
canPreMeshesMerging?: boolean;
//groupingMeshes: string[]; // multi-materialなメッシュは複数のメッシュに分割されるが、それだと不便な場合に追加の親メッシュでグルーピングするための指定
isChair?: boolean;
treatLoaderResult?: (loaderResult: BABYLON.AssetContainer) => void;
createInstance: (args: {
room?: RoomEngine | null;
scene: BABYLON.Scene;
root: BABYLON.Mesh;
options: Readonly<GetOptionsSchemaValues<OpSc>>;
model: ModelManager;
id: string;
stickyMarkerMeshUpdated?: (mesh: BABYLON.Mesh) => void;
}) => RoomObjectInstance<GetOptionsSchemaValues<OpSc>> | Promise<RoomObjectInstance<GetOptionsSchemaValues<OpSc>>>; // TODO: createInstanceをasyncにするのではなく、別にreadyみたいなものを返させる
};
export function defineObject<const OpSc extends OptionsSchema>(def: ObjectDef<OpSc>): ObjectDef<OpSc> {
return def;
}
export function defineObjectClass<const OpSc extends OptionsSchema>(baseDef: Partial<ObjectDef<OpSc>>): {
extend: (childDef: Partial<ObjectDef<OpSc>>) => ObjectDef<OpSc>;
} {
return {
extend: (childDef) => ({ ...baseDef, ...childDef }) as ObjectDef<OpSc>,
};
}

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const a4Case = defineObject({
id: 'a4Case',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const aircon = defineObject({
id: 'aircon',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm, createPlaneUvMapper } from '../utility.js';
import { defineObject } from '../object.js';
import { cm, WORLD_SCALE, createPlaneUvMapper } from '../../utility.js';
export const allInOnePc = defineObject({
id: 'allInOnePc',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { cm } from '../utility.js';
import { defineObject } from '../object.js';
import { cm } from '../../utility.js';
export const aquarium = defineObject({
id: 'aquarium',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const aromaReedDiffuser = defineObject({
id: 'aromaReedDiffuser',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const banknote = defineObject({
id: 'banknote',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm } from '../utility.js';
import { defineObject } from '../object.js';
import { cm, WORLD_SCALE } from '../../utility.js';
export const beamLamp = defineObject({
id: 'beamLamp',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const bed = defineObject({
id: 'bed',

View File

@@ -4,8 +4,9 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { cm, createOverridedStates } from '../utility.js';
import { defineObject } from '../object.js';
import { cm } from '../../utility.js';
import { createOverridedStates } from '../utility.js';
export const blind = defineObject({
id: 'blind',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const book = defineObject({
id: 'book',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { cm } from '../utility.js';
import { defineObject } from '../object.js';
import { cm } from '../../utility.js';
export const books = defineObject({
id: 'books',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const cactusS = defineObject({
id: 'cactusS',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const cardboardBox = defineObject({
id: 'cardboardBox',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const ceilingFanLight = defineObject({
id: 'ceilingFanLight',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const chair = defineObject({
id: 'chair',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const coffeeCup = defineObject({
id: 'coffeeCup',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const colorBox = defineObject({
id: 'colorBox',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const cuboid = defineObject({
id: 'cuboid',

View File

@@ -4,8 +4,9 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { cm, yuge } from '../utility.js';
import { defineObject } from '../object.js';
import { cm } from '../../utility.js';
import { yuge } from '../utility.js';
export const cupNoodle = defineObject({
id: 'cupNoodle',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const custardPudding = defineObject({
id: 'custardPudding',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const debugHipoly = defineObject({
id: 'debugHipoly',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const desk = defineObject({
id: 'desk',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm } from '../utility.js';
import { defineObject } from '../object.js';
import { cm, WORLD_SCALE } from '../../utility.js';
export const desktopPc = defineObject({
id: 'desktopPc',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const djMixer = defineObject({
id: 'djMixer',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper, normalizeUvToSquare } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper, normalizeUvToSquare } from '../../utility.js';
export const djPlayer = defineObject({
id: 'djPlayer',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const ductTape = defineObject({
id: 'ductTape',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const emptyBento = defineObject({
id: 'emptyBento',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const energyDrink = defineObject({
id: 'energyDrink',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const envelope = defineObject({
id: 'envelope',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const facialTissue = defineObject({
id: 'facialTissue',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const hangingTShirt = defineObject({
id: 'hangingTShirt',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const icosahedron = defineObject({
id: 'icosahedron',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, defineObjectClass } from '../engine.js';
import { defineObject, defineObjectClass } from '../object.js';
const base = defineObjectClass({
options: {

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const ironFrameTable = defineObject({
id: 'ironFrameTable',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const keyboard = defineObject({
id: 'keyboard',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm, createPlaneUvMapper } from '../utility.js';
import { defineObject } from '../object.js';
import { cm, WORLD_SCALE, createPlaneUvMapper } from '../../utility.js';
export const laptopPc = defineObject({
id: 'laptopPc',

View File

@@ -3,8 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm } from '../utility.js';
import { defineObject } from '../object.js';
import { cm, WORLD_SCALE } from '../../utility.js';
export const lavaLamp = defineObject({
id: 'lavaLamp',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const letterCase = defineObject({
id: 'letterCase',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const miObjet = defineObject({
id: 'miObjet',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const miPlate = defineObject({
id: 'miPlate',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const miPlateDisplayed = defineObject({
id: 'miPlateDisplayed',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const milk = defineObject({
id: 'milk',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const mixer = defineObject({
id: 'mixer',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const monitor = defineObject({
id: 'monitor',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const monitorSpeaker = defineObject({
id: 'monitorSpeaker',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const monstera = defineObject({
id: 'monstera',

View File

@@ -4,8 +4,9 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { cm, yuge } from '../utility.js';
import { defineObject } from '../object.js';
import { cm } from '../../utility.js';
import { yuge } from '../utility.js';
export const mug = defineObject({
id: 'mug',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const newtonsCradle = defineObject({
id: 'newtonsCradle',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const openedCardboardBox = defineObject({
id: 'openedCardboardBox',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const pachira = defineObject({
id: 'pachira',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const pc = defineObject({
id: 'pc',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const petBottle = defineObject({
id: 'petBottle',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const piano = defineObject({
id: 'piano',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper } from '../../utility.js';
// NOTE: シェイプキーのnormalのエクスポートは無効にしないとmatを大きくしたときに面のレンダリングがグリッチする

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const pizza = defineObject({
id: 'pizza',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const plant = defineObject({
id: 'plant',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const plant2 = defineObject({
id: 'plant2',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../../utility.js';
const remap = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) => {
return toMin + ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin);

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const powerStrip = defineObject({
id: 'powerStrip',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const radiometer = defineObject({
id: 'radiometer',

View File

@@ -5,7 +5,8 @@
import * as BABYLON from '@babylonjs/core';
import seedrandom from 'seedrandom';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { defineObject } from '../object.js';
import { WORLD_SCALE } from '@/world/utility.js';
const remap = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) => {
return toMin + ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin);

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const rolledUpPoster = defineObject({
id: 'rolledUpPoster',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const roundRug = defineObject({
id: 'roundRug',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const router = defineObject({
id: 'router',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const siphon = defineObject({
id: 'siphon',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const snakeplant = defineObject({
id: 'snakeplant',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const speaker = defineObject({
id: 'speaker',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const sprayer = defineObject({
id: 'sprayer',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const steelRack = defineObject({
id: 'steelRack',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const tabletopCalendar = defineObject({
id: 'tabletopCalendar',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm, get7segMeshesOfCurrentTime } from '../utility.js';
import { defineObject } from '../object.js';
import { cm, get7segMeshesOfCurrentTime, WORLD_SCALE } from '@/world/utility.js';
export const tabletopDigitalClock = defineObject({
id: 'tabletopDigitalClock',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper } from '../../utility.js';
export const tabletopFlag = defineObject({
id: 'tabletopFlag',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../../utility.js';
const remap = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) => {
return toMin + ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin);

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const tabletopIronFrameStand = defineObject({
id: 'tabletopIronFrameStand',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper } from '../../utility.js';
// NOTE: シェイプキーのnormalのエクスポートは無効にしないとmatを大きくしたときに面のレンダリングがグリッチする

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../../utility.js';
const remap = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) => {
return toMin + ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin);

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const tetrapod = defineObject({
id: 'tetrapod',

View File

@@ -4,8 +4,9 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm, createPlaneUvMapper, initTv } from '../utility.js';
import { defineObject } from '../object.js';
import { initTv } from '../utility.js';
import { cm, WORLD_SCALE } from '@/world/utility.js';
export const tv = defineObject({
id: 'tv',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const twistedCubeObjet = defineObject({
id: 'twistedCubeObjet',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const usedTissue = defineObject({
id: 'usedTissue',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../../utility.js';
export const wallCanvas = defineObject({
id: 'wallCanvas',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const wallClock = defineObject({
id: 'wallClock',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../utility.js';
import { defineObject } from '../object.js';
import { createPlaneUvMapper, getPlaneUvIndexes } from '../../utility.js';
const remap = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) => {
return toMin + ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin);

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const wallMirror = defineObject({
id: 'wallMirror',

View File

@@ -4,7 +4,7 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const wallShelf = defineObject({
id: 'wallShelf',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm } from '../utility.js';
import { defineObject } from '../object.js';
import { cm, WORLD_SCALE } from '@/world/utility.js';
export const woodRingFloorLamp = defineObject({
id: 'woodRingFloorLamp',

View File

@@ -4,8 +4,8 @@
*/
import * as BABYLON from '@babylonjs/core';
import { defineObject, WORLD_SCALE } from '../engine.js';
import { cm } from '../utility.js';
import { defineObject } from '../object.js';
import { cm, WORLD_SCALE } from '@/world/utility.js';
const remap = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) => {
return toMin + ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin);

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineObject } from '../engine.js';
import { defineObject } from '../object.js';
export const woodSoundAbsorbingPanel = defineObject({
id: 'woodSoundAbsorbingPanel',

View File

@@ -0,0 +1,250 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
import { GridMaterial } from '@babylonjs/materials';
import { camelToKebab, WORLD_SCALE, cm, getMeshesBoundingBox } from '../utility.js';
import { getObjectDef } from './object-defs.js';
import { SYSTEM_MESH_NAMES, ModelManager } from './utility.js';
import type { RoomObjectInstance } from './object.js';
import { genId } from '@/utility/id.js';
import { deepClone } from '@/utility/clone.js';
export async function createRoomObjectPreviewEngine(canvas: HTMLCanvasElement) {
//const babylonEngine = new BABYLON.WebGPUEngine(canvas);
//babylonEngine.compatibilityMode = false;
//await babylonEngine.initAsync();
const babylonEngine = new BABYLON.Engine(canvas, false, { alpha: false, antialias: false });
return new RoomObjectPreviewEngine({ canvas, engine: babylonEngine });
}
export class RoomObjectPreviewEngine {
private canvas: HTMLCanvasElement;
private engine: BABYLON.WebGPUEngine;
private scene: BABYLON.Scene;
private shadowGenerator1: BABYLON.ShadowGenerator;
private camera: BABYLON.ArcRotateCamera;
private objectMesh: BABYLON.Mesh | null = null;
private objectInstance: RoomObjectInstance<any> | null = null;
private envMapIndoor: BABYLON.CubeTexture;
private roomLight: BABYLON.SpotLight;
private zGridPreviewPlane: BABYLON.Mesh;
private fps = 60;
constructor(options: {
canvas: HTMLCanvasElement;
engine: BABYLON.WebGPUEngine;
}) {
this.canvas = options.canvas;
registerBuiltInLoaders();
this.engine = options.engine;
this.scene = new BABYLON.Scene(this.engine);
this.scene.ambientColor = new BABYLON.Color3(1.0, 0.9, 0.8);
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
this.camera = new BABYLON.ArcRotateCamera('camera', -Math.PI / 2, Math.PI / 2.5, cm(300), new BABYLON.Vector3(0, cm(90), 0), this.scene);
this.camera.attachControl(this.canvas);
this.camera.minZ = cm(1);
this.camera.maxZ = cm(100000);
this.camera.fov = 0.5;
this.camera.lowerBetaLimit = 0;
this.camera.upperBetaLimit = (Math.PI / 2) + 0.1;
this.camera.lowerRadiusLimit = cm(50);
this.camera.upperRadiusLimit = cm(1000);
this.camera.useAutoRotationBehavior = true;
this.camera.autoRotationBehavior!.idleRotationSpeed = 0.3;
//this.camera.mode = BABYLON.Camera.ORTHOGRAPHIC_CAMERA;
this.scene.activeCamera = this.camera;
const ambientLight = new BABYLON.HemisphericLight('ambientLight', new BABYLON.Vector3(0, 1, -0.5), this.scene);
ambientLight.diffuse = new BABYLON.Color3(1.0, 1.0, 1.0);
ambientLight.intensity = 0.5;
//ambientLight.intensity = 0;
this.roomLight = new BABYLON.SpotLight('roomLight', new BABYLON.Vector3(0, cm(249), 0), new BABYLON.Vector3(0, -1, 0), 16, 8, this.scene);
this.roomLight.diffuse = new BABYLON.Color3(1.0, 0.9, 0.8);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(300);
this.shadowGenerator1 = new BABYLON.ShadowGenerator(4096, this.roomLight);
this.shadowGenerator1.forceBackFacesOnly = true;
this.shadowGenerator1.bias = 0.0001;
this.shadowGenerator1.usePercentageCloserFiltering = true;
this.shadowGenerator1.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//this.shadowGenerator1.useContactHardeningShadow = true;
const gridMaterial = new GridMaterial('grid', this.scene);
gridMaterial.lineColor = new BABYLON.Color3(0.5, 0.5, 0.5);
gridMaterial.mainColor = new BABYLON.Color3(0, 0, 0);
gridMaterial.minorUnitVisibility = 1;
gridMaterial.opacity = 0.5;
gridMaterial.gridRatio = cm(10);
//this.zGridPreviewPlane = BABYLON.MeshBuilder.CreatePlane('zGridPreviewPlane', { width: cm(1000), height: cm(1000) }, this.scene);
//this.zGridPreviewPlane.material = gridMaterial;
//this.zGridPreviewPlane.rotation = new BABYLON.Vector3(Math.PI / 2, 0, 0);
//this.scene.fogMode = BABYLON.Scene.FOGMODE_LINEAR;
//this.scene.fogStart = cm(100);
//this.scene.fogEnd = cm(110);
//this.scene.fogColor = new BABYLON.Color3(0.0, 0.0, 0.0);
}
public async init() {
const frameInterval = 1000 / this.fps;
let lastTime = performance.now();
this.engine.runRenderLoop(() => {
const currentTime = performance.now();
const delta = currentTime - lastTime;
if (delta >= frameInterval) {
this.scene.render();
lastTime = currentTime - (delta % frameInterval);
}
});
}
public async load(type: string) {
if (this.objectInstance != null) {
this.objectInstance.dispose?.();
this.objectInstance = null;
this.objectMesh!.dispose();
}
// reset camera rotation
this.camera.setPosition(new BABYLON.Vector3(0, cm(90), cm(300)));
const def = getObjectDef(type);
const options = deepClone(def.options.default);
const id = genId();
await this.loadObject({
type,
options,
id,
});
// なぜかちょっと待たないとbounding boxのサイズが正しくない
window.setTimeout(() => {
const boundingInfo = getMeshesBoundingBox(this.objectMesh!.getChildMeshes().filter(m => m.isEnabled() && m.isVisible));
this.camera.setTarget(new BABYLON.Vector3(0, boundingInfo.center.y, 0));
// zoom to fit
const size = boundingInfo.extendSize;
const distance = Math.max(size.x, size.y, size.z) * 2;
this.camera.radius = distance * 3;
//this.camera.orthoLeft = -distance;
//this.camera.orthoRight = distance;
//this.camera.orthoTop = distance;
//this.camera.orthoBottom = -distance;
}, 10);
}
// TODO: RoomEngineのものとほぼ同じだからいい感じに共通化
private async loadObject(args: {
type: string;
options: any;
id: string;
}) {
const def = getObjectDef(args.type);
const root = new BABYLON.Mesh(`object_${args.type}`, this.scene);
const filePath = def.path != null ? `/client-assets/room/objects/${def.path}.glb` : `/client-assets/room/objects/${camelToKebab(args.type)}/${camelToKebab(args.type)}.glb`;
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
// 不要なUVを掃除
if (!def.hasTexture) {
for (const m of loaderResult.meshes) {
if (m.geometry != null) {
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UVKind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV2Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV3Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV4Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV5Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV6Kind);
}
}
}
// babylonによって自動で追加される右手系変換用ード
const subRoot = loaderResult.meshes[0];
subRoot.scaling = subRoot.scaling.scale(WORLD_SCALE);// cmをmに
def.treatLoaderResult?.(loaderResult);
root.addChild(subRoot);
const model = new ModelManager(subRoot, loaderResult.meshes.filter(m => !m.isDisposed() && m !== subRoot), def.hasTexture, (meshes) => {
for (const m of meshes) {
const mesh = m;
// シェイプキー(morph)を考慮してbounding boxを更新するために必要
mesh.refreshBoundingInfo({ applyMorph: true });
if (SYSTEM_MESH_NAMES.some(n => mesh.name.includes(n))) {
mesh.receiveShadows = false;
mesh.isVisible = false;
} else {
if (def.receiveShadows !== false) mesh.receiveShadows = true;
if (def.castShadows !== false) {
this.shadowGenerator1.addShadowCaster(mesh);
}
if (mesh.material) {
if (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
(subMat as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
(subMat as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
}
} else {
(mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
}
}
}
if (!this.scene.meshes.includes(mesh)) this.scene.addMesh(mesh);
}
});
const objectInstance = await def.createInstance({
room: null,
scene: this.scene,
root,
options: args.options,
model,
id: args.id,
});
objectInstance.onInited?.();
model.bakeMesh();
this.objectInstance = objectInstance;
this.objectMesh = root;
}
public updateObjectOption(key: string, value: any) {
this.objectInstance?.onOptionsUpdated?.([key, value]);
}
public resize() {
this.engine.resize();
}
public destroy() {
this.engine.dispose();
}
}

View File

@@ -4,10 +4,10 @@
*/
import * as BABYLON from '@babylonjs/core';
import { applyMorphTargetsToMesh, cm, getPlaneUvIndexes } from '../utility.js';
import type { RoomEngine } from './engine.js';
//export const cm = (value: number) => value / 100;
export const cm = (value: number) => value;
export const SYSTEM_MESH_NAMES = ['__TOP__', '__SIDE__', '__PICK__', '__COLLISION__'];
export function yuge(scene: BABYLON.Scene, mesh: BABYLON.Mesh, offset: BABYLON.Vector3) {
const emitter = new BABYLON.TransformNode('emitter', scene);
@@ -41,182 +41,6 @@ export function yuge(scene: BABYLON.Scene, mesh: BABYLON.Mesh, offset: BABYLON.V
};
}
export class HorizontalCameraKeyboardMoveInput extends BABYLON.BaseCameraPointersInput {
public camera: BABYLON.FreeCamera;
private engine: BABYLON.AbstractEngine;
private scene: BABYLON.Scene;
preShift = false;
codes = [];
codesUp = ['KeyW'];
codesDown = ['KeyS'];
codesLeft = ['KeyA'];
codesRight = ['KeyD'];
onCanvasBlurObserver = null;
onKeyboardObserver = null;
public canMove = true;
constructor(camera: BABYLON.UniversalCamera) {
super();
this.camera = camera;
this.scene = this.camera.getScene();
this.engine = this.scene.getEngine();
}
attachControl() {
if (this.onCanvasBlurObserver) {
return;
}
this.onCanvasBlurObserver = this.engine.onCanvasBlurObservable.add(() => {
this.codes = [];
});
this.onKeyboardObserver = this.scene.onKeyboardObservable.add(({ event, type }) => {
const { code, shiftKey } = event;
this.preShift = shiftKey;
if (type === BABYLON.KeyboardEventTypes.KEYDOWN) {
if (this.codesUp.indexOf(code) >= 0 ||
this.codesDown.indexOf(code) >= 0 ||
this.codesLeft.indexOf(code) >= 0 ||
this.codesRight.indexOf(code) >= 0) {
const index = this.codes.findIndex(v => v.code === code);
if (index < 0) { // 存在しなかったら追加する
this.codes.push({ code });
}
}
} else {
if (this.codesUp.indexOf(code) >= 0 ||
this.codesDown.indexOf(code) >= 0 ||
this.codesLeft.indexOf(code) >= 0 ||
this.codesRight.indexOf(code) >= 0) {
const index = this.codes.findIndex(v => v.code === code);
if (index >= 0) { // 存在したら削除する
this.codes.splice(index, 1);
}
}
}
});
}
detachControl() {
this.codes = [];
if (this.onKeyboardObserver) this.scene.onKeyboardObservable.remove(this.onKeyboardObserver);
if (this.onCanvasBlurObserver) this.engine.onCanvasBlurObservable.remove(this.onCanvasBlurObserver);
this.onKeyboardObserver = null;
this.onCanvasBlurObserver = null;
}
checkInputs() {
if (!this.onKeyboardObserver) {
return;
}
for (let index = 0; index < this.codes.length; index++) {
const { code } = this.codes[index];
const local = new BABYLON.Vector3();
if (this.codesLeft.indexOf(code) >= 0) {
local.x += -1;
} else if (this.codesUp.indexOf(code) >= 0) {
local.z += this.scene.useRightHandedSystem ? -1 : 1;
} else if (this.codesRight.indexOf(code) >= 0) {
local.x += 1;
} else if (this.codesDown.indexOf(code) >= 0) {
local.z += this.scene.useRightHandedSystem ? 1 : -1;
}
if (local.length() === 0) {
continue;
}
const dir = this.camera.getDirection(local.normalize());
dir.y = 0;
dir.normalize();
const rate = this.preShift ? 3 : 1;
const moveSpeed = 0.1 * this.scene.getAnimationRatio();
const move = dir.scale(moveSpeed * rate);
if (this.canMove) {
this.camera.cameraDirection.addInPlace(move);
}
}
}
getClassName() {
return 'HorizontalCameraKeyboardMoveInput';
}
getSimpleName() {
return 'horizontalkeyboard';
}
}
const nanasegNumberMap = [
['a', 'b', 'c', 'd', 'e', 'f'], // 0
['b', 'c'], // 1
['a', 'b', 'd', 'e', 'g'], // 2
['a', 'b', 'c', 'd', 'g'], // 3
['b', 'c', 'f', 'g'], // 4
['a', 'c', 'd', 'f', 'g'], // 5
['a', 'c', 'd', 'e', 'f', 'g'], // 6
['a', 'b', 'c'], // 7
['a', 'b', 'c', 'd', 'e', 'f', 'g'], // 8
['a', 'b', 'c', 'd', 'f', 'g'], // 9
];
export function get7segMeshesOfCurrentTime(meshes: {
'1a'?: BABYLON.AbstractMesh;
'1b'?: BABYLON.AbstractMesh;
'1c'?: BABYLON.AbstractMesh;
'1d'?: BABYLON.AbstractMesh;
'1e'?: BABYLON.AbstractMesh;
'1f'?: BABYLON.AbstractMesh;
'1g'?: BABYLON.AbstractMesh;
'2a'?: BABYLON.AbstractMesh;
'2b'?: BABYLON.AbstractMesh;
'2c'?: BABYLON.AbstractMesh;
'2d'?: BABYLON.AbstractMesh;
'2e'?: BABYLON.AbstractMesh;
'2f'?: BABYLON.AbstractMesh;
'2g'?: BABYLON.AbstractMesh;
'3a'?: BABYLON.AbstractMesh;
'3b'?: BABYLON.AbstractMesh;
'3c'?: BABYLON.AbstractMesh;
'3d'?: BABYLON.AbstractMesh;
'3e'?: BABYLON.AbstractMesh;
'3f'?: BABYLON.AbstractMesh;
'3g'?: BABYLON.AbstractMesh;
'4a'?: BABYLON.AbstractMesh;
'4b'?: BABYLON.AbstractMesh;
'4c'?: BABYLON.AbstractMesh;
'4d'?: BABYLON.AbstractMesh;
'4e'?: BABYLON.AbstractMesh;
'4f'?: BABYLON.AbstractMesh;
'4g'?: BABYLON.AbstractMesh;
}) {
const now = new Date();
const h = now.getHours();
const m = now.getMinutes();
const chars = [Math.floor(h / 10), h % 10, Math.floor(m / 10), m % 10];
const result: BABYLON.AbstractMesh[] = [];
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
const segs = nanasegNumberMap[char];
for (const seg of segs) {
const mesh = meshes[`${i + 1}${seg}`];
if (mesh) {
result.push(mesh);
}
}
}
return result;
}
export function createOverridedStates<T extends Record<string, (() => any)>>(stateDefs: T): { [K in keyof T]: ReturnType<T[K]>; } & { $reset: () => void } {
const overridedStates = {} as { [K in keyof T]: ReturnType<T[K]>; };
const result = {} as { [K in keyof T]: ReturnType<T[K]>; } & { $reset: () => void };
@@ -337,86 +161,6 @@ export function initTv(room: RoomEngine, screenMesh: BABYLON.Mesh) {
};
}
/**
* 0 1
* 0 a(x,y) --- b(x,y)
* | |
* 1 c(x,y) --- d(x,y)
*/
export function getPlaneUvIndexes(mesh: BABYLON.Mesh) {
const uvs = mesh.getVerticesData(BABYLON.VertexBuffer.UVKind);
if (uvs == null) {
throw new Error('Mesh does not have UV data');
}
let aIndex = 0;
let bIndex = 0;
let cIndex = 0;
let dIndex = 0;
for (let i = 0; i < 8; i += 2) {
const x = uvs[i];
const y = uvs[i + 1];
// 多少ずれがあってもいいように(例えばblenderではUV展開時にデフォルトでわずかなマージンを追加する)、中心より大きいか/小さいかで判定する
if (x < 0.5 && y < 0.5) {
aIndex = i;
} else if (x > 0.5 && y < 0.5) {
bIndex = i;
} else if (x < 0.5 && y > 0.5) {
cIndex = i;
} else if (x > 0.5 && y > 0.5) {
dIndex = i;
}
}
return [aIndex, bIndex, cIndex, dIndex];
}
export function normalizeUvToSquare(mesh: BABYLON.Mesh) {
const uvs = mesh.getVerticesData(BABYLON.VertexBuffer.UVKind)!;
const uvIndexes = getPlaneUvIndexes(mesh);
uvs[uvIndexes[0]] = 0;
uvs[uvIndexes[0] + 1] = 0;
uvs[uvIndexes[1]] = 1;
uvs[uvIndexes[1] + 1] = 0;
uvs[uvIndexes[2]] = 0;
uvs[uvIndexes[2] + 1] = 1;
uvs[uvIndexes[3]] = 1;
uvs[uvIndexes[3] + 1] = 1;
mesh.updateVerticesData(BABYLON.VertexBuffer.UVKind, uvs);
}
export function createPlaneUvMapper(mesh: BABYLON.Mesh) {
mesh.markVerticesDataAsUpdatable(BABYLON.VertexBuffer.UVKind, true);
const uvs = mesh.getVerticesData(BABYLON.VertexBuffer.UVKind)!;
const originalUvs = uvs.slice();
return (srcAspect: number, targetAspect: number, method: 'cover' | 'contain' | 'stretch') => {
let uScale = 1;
let vScale = 1;
const ratio = targetAspect / srcAspect;
if (method === 'cover') {
uScale = ratio < 1 ? ratio : 1;
vScale = ratio < 1 ? 1 : 1 / ratio;
} else if (method === 'contain') {
uScale = ratio > 1 ? ratio : 1;
vScale = ratio > 1 ? 1 : 1 / ratio;
} else if (method === 'stretch') {
// nop
}
// (0,0)を隅ではなく中心として扱いたいので0.5引いて計算してから最後に0.5足す
for (let i = 0; i < uvs.length; i += 2) {
uvs[i] = ((originalUvs[i] - 0.5) * uScale) + 0.5;
uvs[i + 1] = ((originalUvs[i + 1] - 0.5) * vScale) + 0.5;
}
mesh.updateVerticesData(BABYLON.VertexBuffer.UVKind, uvs);
};
}
export function findMaterial(rootMesh: BABYLON.AbstractMesh, keyword: string, allowMultiMaterial = false): BABYLON.PBRMaterial {
for (const m of rootMesh.getChildMeshes()) {
if (m.material == null) continue;
@@ -442,87 +186,153 @@ export function findMaterial(rootMesh: BABYLON.AbstractMesh, keyword: string, al
throw new Error(`Material with keyword "${keyword}" not found`);
}
export function scaleMorph(mesh: BABYLON.Mesh, scale: [number, number, number], offset: [number, number, number] = [0, 0, 0]) {
if (!mesh.morphTargetManager) {
return;
export class ModelManager {
public root: BABYLON.Mesh;
public bakedCallback: (() => void) | null = null;
public bakeExcludeMeshes: BABYLON.Mesh[] = [];
private originalMeshes: BABYLON.Mesh[] = [];
private bakedMeshes: BABYLON.Mesh[] = [];
private hasTexture: boolean;
constructor(root: BABYLON.Mesh, originalMeshes: BABYLON.Mesh[], hasTexture: boolean, bakedCallback: (() => void) | null = null) {
this.root = root;
this.originalMeshes = originalMeshes;
this.hasTexture = hasTexture;
this.bakedCallback = bakedCallback;
}
const morphTargetManager = mesh.morphTargetManager;
for (let targetIndex = 0; targetIndex < morphTargetManager.numTargets; targetIndex++) {
const target = morphTargetManager.getTarget(targetIndex);
const newPos = target.getPositions();
for (let i = 0; i < newPos.length; i += 3) {
newPos[i] = (newPos[i] + offset[0]) * scale[0];
newPos[i + 1] = (newPos[i + 1] + offset[1]) * scale[1];
newPos[i + 2] = (newPos[i + 2] + offset[2]) * scale[2];
public findMesh(keyword: string) {
const mesh = this.root.getChildMeshes().find(m => m.name.includes(keyword));
if (mesh == null) {
throw new Error(`Mesh with keyword "${keyword}" not found for object ${this.root.metadata?.objectType}`);
}
target.setPositions(newPos);
return mesh as BABYLON.Mesh;
}
morphTargetManager.synchronize();
public findMeshes(keyword: string) {
const meshes = this.root.getChildMeshes().filter(m => m.name.includes(keyword));
return meshes as BABYLON.Mesh[];
}
mesh.refreshBoundingInfo();
mesh.computeWorldMatrix(true);
public findMaterial(keyword: string) {
return findMaterial(this.root, keyword);
}
public findTransformNode(keyword: string) {
const node = this.root.getChildTransformNodes().find(n => n.name.includes(keyword));
if (node == null) {
throw new Error(`TransformNode with keyword "${keyword}" not found for object ${this.root.metadata?.objectType}`);
}
return node;
}
public updated() {
}
public bakeMesh() {
for (const m of this.bakedMeshes) {
m.dispose();
}
this.bakedMeshes = [];
const excludeMeshes = [...this.bakeExcludeMeshes, ...this.root.getChildMeshes().filter(m => SYSTEM_MESH_NAMES.some(s => m.name.includes(s)))];
const childMeshes = this.root.getChildMeshes().filter(m => !excludeMeshes.some(x => x === m) && m.isVisible && !m.isDisposed());
if (childMeshes.length <= 1) {
this.bakedCallback?.([...childMeshes, ...excludeMeshes]);
return;
}
const _toMerge = [] as BABYLON.Mesh[];
for (const mesh of childMeshes) {
let fixedMesh = mesh;
fixedMesh.setEnabled(false);
if (mesh instanceof BABYLON.InstancedMesh) {
const sourceMesh = mesh.sourceMesh;
const realizedMesh = sourceMesh.clone(mesh.name + '_realized', null, true);
realizedMesh.getScene().removeMesh(realizedMesh);
realizedMesh.position = mesh.position.clone();
if (mesh.rotationQuaternion) {
realizedMesh.rotationQuaternion = mesh.rotationQuaternion.clone();
} else {
realizedMesh.rotation = mesh.rotation.clone();
}
realizedMesh.scaling = mesh.scaling.clone();
realizedMesh.parent = mesh.parent;
realizedMesh.setEnabled(false);
fixedMesh = realizedMesh;
}
_toMerge.push(fixedMesh);
}
const toMerge = [] as BABYLON.Mesh[];
for (const mesh of _toMerge) {
const newMesh = mesh.name.endsWith('_realized') ? mesh : mesh.clone(mesh.name + '_bakeMerged', null, true);
newMesh.makeGeometryUnique();
applyMorphTargetsToMesh(newMesh);
if (newMesh.parent === this.root) {
newMesh.parent = null;
} else {
newMesh.setParent(this.root);
//newMesh.bakeCurrentTransformIntoVertices();
newMesh.parent = null;
}
//newMesh.bakeCurrentTransformIntoVertices();
if (this.hasTexture) {
if (newMesh.getVerticesData(BABYLON.VertexBuffer.UVKind) == null) {
const vertexCount = newMesh.getTotalVertices();
const uvs = new Array(vertexCount * 2).fill(0);
newMesh.setVerticesData(BABYLON.VertexBuffer.UVKind, uvs, false, 2);
}
if (newMesh.getVerticesData(BABYLON.VertexBuffer.UV2Kind) == null) {
const vertexCount = newMesh.getTotalVertices();
const uvs = new Array(vertexCount * 2).fill(0);
newMesh.setVerticesData(BABYLON.VertexBuffer.UV2Kind, uvs, false, 2);
}
}
toMerge.push(newMesh);
}
if (toMerge.length === 0) {
this.bakedCallback?.([...childMeshes, ...excludeMeshes]);
return;
}
const merged = BABYLON.Mesh.MergeMeshes(toMerge, true, false, undefined, false, true);
merged.parent = this.root;
merged.material.freeze();
if (merged.material instanceof BABYLON.MultiMaterial) {
for (const subMat of merged.material.subMaterials) {
(subMat as BABYLON.PBRMaterial).freeze();
}
}
merged.freezeWorldMatrix();
merged.metadata = { ...this.root.metadata };
if (!this.hasTexture) merged.convertToUnIndexedMesh();
this.bakedMeshes = [merged];
this.bakedCallback?.([...this.bakedMeshes, ...excludeMeshes]);
}
public unbakeMesh() {
for (const m of this.bakedMeshes) {
m.dispose();
}
this.bakedMeshes = [];
const childMeshes = this.root.getChildMeshes();
for (const mesh of childMeshes) {
mesh.setEnabled(true);
}
this.bakedCallback?.(this.root.getChildMeshes());
}
}
export function applyMorphTargetsToMesh(mesh: BABYLON.Mesh) {
if (!mesh.morphTargetManager) {
return;
}
const morphTargetManager = mesh.morphTargetManager;
const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);
if (!positions) {
return;
}
// Create a copy of the original positions to work with
const finalPositions = positions.slice();
// Apply each morph target
for (let targetIndex = 0; targetIndex < morphTargetManager.numTargets; targetIndex++) {
const target = morphTargetManager.getTarget(targetIndex);
const influence = target.influence;
if (influence === 0) {
continue;
}
// Get the morph target positions
const targetPositions = target.getPositions();
if (!targetPositions || targetPositions.length !== positions.length) {
console.warn(`Morph target ${targetIndex} has invalid position data`);
continue;
}
// Apply the morph target influence to each vertex
for (let i = 0; i < positions.length; i++) {
finalPositions[i] += (targetPositions[i] - positions[i]) * influence;
}
}
// Update the mesh with the morphed positions
mesh.setVerticesData(BABYLON.VertexBuffer.PositionKind, finalPositions);
//// Update normals if available
//const normals = mesh.getVerticesData(BABYLON.VertexBuffer.NormalKind);
//if (normals) {
// // For simplicity, we'll just recompute the normals
// mesh.createNormals(true);
//}
// Refresh the mesh
mesh.refreshBoundingInfo();
mesh.computeWorldMatrix(true);
}
// ex) hangingTShirt -> hanging-t-shirt
export const camelToKebab = (s: string) => {
return s
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
};

View File

@@ -0,0 +1,393 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
export const WORLD_SCALE = 100;
//export const cm = (value: number) => value / 100;
export const cm = (value: number) => value;
export const TIME_MAP = {
0: 2,
1: 2,
2: 2,
3: 2,
4: 2,
5: 1,
6: 1,
7: 0,
8: 0,
9: 0,
10: 0,
11: 0,
12: 0,
13: 0,
14: 0,
15: 0,
16: 1,
17: 1,
18: 2,
19: 2,
20: 2,
21: 2,
22: 2,
23: 2,
} as const;
export class HorizontalCameraKeyboardMoveInput extends BABYLON.BaseCameraPointersInput {
public camera: BABYLON.FreeCamera;
private engine: BABYLON.AbstractEngine;
private scene: BABYLON.Scene;
preShift = false;
codes = [];
codesUp = ['KeyW'];
codesDown = ['KeyS'];
codesLeft = ['KeyA'];
codesRight = ['KeyD'];
onCanvasBlurObserver = null;
onKeyboardObserver = null;
public canMove = true;
constructor(camera: BABYLON.UniversalCamera) {
super();
this.camera = camera;
this.scene = this.camera.getScene();
this.engine = this.scene.getEngine();
}
attachControl() {
if (this.onCanvasBlurObserver) {
return;
}
this.onCanvasBlurObserver = this.engine.onCanvasBlurObservable.add(() => {
this.codes = [];
});
this.onKeyboardObserver = this.scene.onKeyboardObservable.add(({ event, type }) => {
const { code, shiftKey } = event;
this.preShift = shiftKey;
if (type === BABYLON.KeyboardEventTypes.KEYDOWN) {
if (this.codesUp.indexOf(code) >= 0 ||
this.codesDown.indexOf(code) >= 0 ||
this.codesLeft.indexOf(code) >= 0 ||
this.codesRight.indexOf(code) >= 0) {
const index = this.codes.findIndex(v => v.code === code);
if (index < 0) { // 存在しなかったら追加する
this.codes.push({ code });
}
}
} else {
if (this.codesUp.indexOf(code) >= 0 ||
this.codesDown.indexOf(code) >= 0 ||
this.codesLeft.indexOf(code) >= 0 ||
this.codesRight.indexOf(code) >= 0) {
const index = this.codes.findIndex(v => v.code === code);
if (index >= 0) { // 存在したら削除する
this.codes.splice(index, 1);
}
}
}
});
}
detachControl() {
this.codes = [];
if (this.onKeyboardObserver) this.scene.onKeyboardObservable.remove(this.onKeyboardObserver);
if (this.onCanvasBlurObserver) this.engine.onCanvasBlurObservable.remove(this.onCanvasBlurObserver);
this.onKeyboardObserver = null;
this.onCanvasBlurObserver = null;
}
checkInputs() {
if (!this.onKeyboardObserver) {
return;
}
for (let index = 0; index < this.codes.length; index++) {
const { code } = this.codes[index];
const local = new BABYLON.Vector3();
if (this.codesLeft.indexOf(code) >= 0) {
local.x += -1;
} else if (this.codesUp.indexOf(code) >= 0) {
local.z += this.scene.useRightHandedSystem ? -1 : 1;
} else if (this.codesRight.indexOf(code) >= 0) {
local.x += 1;
} else if (this.codesDown.indexOf(code) >= 0) {
local.z += this.scene.useRightHandedSystem ? 1 : -1;
}
if (local.length() === 0) {
continue;
}
const dir = this.camera.getDirection(local.normalize());
dir.y = 0;
dir.normalize();
const rate = this.preShift ? 3 : 1;
const moveSpeed = 0.1 * this.scene.getAnimationRatio();
const move = dir.scale(moveSpeed * rate);
if (this.canMove) {
this.camera.cameraDirection.addInPlace(move);
}
}
}
getClassName() {
return 'HorizontalCameraKeyboardMoveInput';
}
getSimpleName() {
return 'horizontalkeyboard';
}
}
const nanasegNumberMap = [
['a', 'b', 'c', 'd', 'e', 'f'], // 0
['b', 'c'], // 1
['a', 'b', 'd', 'e', 'g'], // 2
['a', 'b', 'c', 'd', 'g'], // 3
['b', 'c', 'f', 'g'], // 4
['a', 'c', 'd', 'f', 'g'], // 5
['a', 'c', 'd', 'e', 'f', 'g'], // 6
['a', 'b', 'c'], // 7
['a', 'b', 'c', 'd', 'e', 'f', 'g'], // 8
['a', 'b', 'c', 'd', 'f', 'g'], // 9
];
export function get7segMeshesOfCurrentTime(meshes: {
'1a'?: BABYLON.AbstractMesh;
'1b'?: BABYLON.AbstractMesh;
'1c'?: BABYLON.AbstractMesh;
'1d'?: BABYLON.AbstractMesh;
'1e'?: BABYLON.AbstractMesh;
'1f'?: BABYLON.AbstractMesh;
'1g'?: BABYLON.AbstractMesh;
'2a'?: BABYLON.AbstractMesh;
'2b'?: BABYLON.AbstractMesh;
'2c'?: BABYLON.AbstractMesh;
'2d'?: BABYLON.AbstractMesh;
'2e'?: BABYLON.AbstractMesh;
'2f'?: BABYLON.AbstractMesh;
'2g'?: BABYLON.AbstractMesh;
'3a'?: BABYLON.AbstractMesh;
'3b'?: BABYLON.AbstractMesh;
'3c'?: BABYLON.AbstractMesh;
'3d'?: BABYLON.AbstractMesh;
'3e'?: BABYLON.AbstractMesh;
'3f'?: BABYLON.AbstractMesh;
'3g'?: BABYLON.AbstractMesh;
'4a'?: BABYLON.AbstractMesh;
'4b'?: BABYLON.AbstractMesh;
'4c'?: BABYLON.AbstractMesh;
'4d'?: BABYLON.AbstractMesh;
'4e'?: BABYLON.AbstractMesh;
'4f'?: BABYLON.AbstractMesh;
'4g'?: BABYLON.AbstractMesh;
}) {
const now = new Date();
const h = now.getHours();
const m = now.getMinutes();
const chars = [Math.floor(h / 10), h % 10, Math.floor(m / 10), m % 10];
const result: BABYLON.AbstractMesh[] = [];
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
const segs = nanasegNumberMap[char];
for (const seg of segs) {
const mesh = meshes[`${i + 1}${seg}`];
if (mesh) {
result.push(mesh);
}
}
}
return result;
}
/**
* 0 1
* 0 a(x,y) --- b(x,y)
* | |
* 1 c(x,y) --- d(x,y)
*/
export function getPlaneUvIndexes(mesh: BABYLON.Mesh) {
const uvs = mesh.getVerticesData(BABYLON.VertexBuffer.UVKind);
if (uvs == null) {
throw new Error('Mesh does not have UV data');
}
let aIndex = 0;
let bIndex = 0;
let cIndex = 0;
let dIndex = 0;
for (let i = 0; i < 8; i += 2) {
const x = uvs[i];
const y = uvs[i + 1];
// 多少ずれがあってもいいように(例えばblenderではUV展開時にデフォルトでわずかなマージンを追加する)、中心より大きいか/小さいかで判定する
if (x < 0.5 && y < 0.5) {
aIndex = i;
} else if (x > 0.5 && y < 0.5) {
bIndex = i;
} else if (x < 0.5 && y > 0.5) {
cIndex = i;
} else if (x > 0.5 && y > 0.5) {
dIndex = i;
}
}
return [aIndex, bIndex, cIndex, dIndex];
}
export function normalizeUvToSquare(mesh: BABYLON.Mesh) {
const uvs = mesh.getVerticesData(BABYLON.VertexBuffer.UVKind)!;
const uvIndexes = getPlaneUvIndexes(mesh);
uvs[uvIndexes[0]] = 0;
uvs[uvIndexes[0] + 1] = 0;
uvs[uvIndexes[1]] = 1;
uvs[uvIndexes[1] + 1] = 0;
uvs[uvIndexes[2]] = 0;
uvs[uvIndexes[2] + 1] = 1;
uvs[uvIndexes[3]] = 1;
uvs[uvIndexes[3] + 1] = 1;
mesh.updateVerticesData(BABYLON.VertexBuffer.UVKind, uvs);
}
export function createPlaneUvMapper(mesh: BABYLON.Mesh) {
mesh.markVerticesDataAsUpdatable(BABYLON.VertexBuffer.UVKind, true);
const uvs = mesh.getVerticesData(BABYLON.VertexBuffer.UVKind)!;
const originalUvs = uvs.slice();
return (srcAspect: number, targetAspect: number, method: 'cover' | 'contain' | 'stretch') => {
let uScale = 1;
let vScale = 1;
const ratio = targetAspect / srcAspect;
if (method === 'cover') {
uScale = ratio < 1 ? ratio : 1;
vScale = ratio < 1 ? 1 : 1 / ratio;
} else if (method === 'contain') {
uScale = ratio > 1 ? ratio : 1;
vScale = ratio > 1 ? 1 : 1 / ratio;
} else if (method === 'stretch') {
// nop
}
// (0,0)を隅ではなく中心として扱いたいので0.5引いて計算してから最後に0.5足す
for (let i = 0; i < uvs.length; i += 2) {
uvs[i] = ((originalUvs[i] - 0.5) * uScale) + 0.5;
uvs[i + 1] = ((originalUvs[i + 1] - 0.5) * vScale) + 0.5;
}
mesh.updateVerticesData(BABYLON.VertexBuffer.UVKind, uvs);
};
}
export function scaleMorph(mesh: BABYLON.Mesh, scale: [number, number, number], offset: [number, number, number] = [0, 0, 0]) {
if (!mesh.morphTargetManager) {
return;
}
const morphTargetManager = mesh.morphTargetManager;
for (let targetIndex = 0; targetIndex < morphTargetManager.numTargets; targetIndex++) {
const target = morphTargetManager.getTarget(targetIndex);
const newPos = target.getPositions();
for (let i = 0; i < newPos.length; i += 3) {
newPos[i] = (newPos[i] + offset[0]) * scale[0];
newPos[i + 1] = (newPos[i + 1] + offset[1]) * scale[1];
newPos[i + 2] = (newPos[i + 2] + offset[2]) * scale[2];
}
target.setPositions(newPos);
}
morphTargetManager.synchronize();
mesh.refreshBoundingInfo();
mesh.computeWorldMatrix(true);
}
export function applyMorphTargetsToMesh(mesh: BABYLON.Mesh) {
if (!mesh.morphTargetManager) {
return;
}
const morphTargetManager = mesh.morphTargetManager;
const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);
if (!positions) {
return;
}
// Create a copy of the original positions to work with
const finalPositions = positions.slice();
// Apply each morph target
for (let targetIndex = 0; targetIndex < morphTargetManager.numTargets; targetIndex++) {
const target = morphTargetManager.getTarget(targetIndex);
const influence = target.influence;
if (influence === 0) {
continue;
}
// Get the morph target positions
const targetPositions = target.getPositions();
if (!targetPositions || targetPositions.length !== positions.length) {
console.warn(`Morph target ${targetIndex} has invalid position data`);
continue;
}
// Apply the morph target influence to each vertex
for (let i = 0; i < positions.length; i++) {
finalPositions[i] += (targetPositions[i] - positions[i]) * influence;
}
}
// Update the mesh with the morphed positions
mesh.setVerticesData(BABYLON.VertexBuffer.PositionKind, finalPositions);
//// Update normals if available
//const normals = mesh.getVerticesData(BABYLON.VertexBuffer.NormalKind);
//if (normals) {
// // For simplicity, we'll just recompute the normals
// mesh.createNormals(true);
//}
// Refresh the mesh
mesh.refreshBoundingInfo();
mesh.computeWorldMatrix(true);
}
// ex) hangingTShirt -> hanging-t-shirt
export const camelToKebab = (s: string) => {
return s
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
};
// この実装方法だとマイナスの座標をうまく処理できず結果がおかしくなるので応急処置で全体を+10000cmオフセットしてから計算している
export function getMeshesBoundingBox(meshes: BABYLON.Mesh[]): BABYLON.BoundingBox {
let min = new BABYLON.Vector3(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE);
let max = new BABYLON.Vector3(Number.MIN_VALUE, Number.MIN_VALUE, Number.MIN_VALUE);
for (const mesh of meshes) {
const boundingInfo = mesh.getBoundingInfo();
min = BABYLON.Vector3.Minimize(min, boundingInfo.boundingBox.minimumWorld.add(new BABYLON.Vector3(10000, 10000, 10000)));
max = BABYLON.Vector3.Maximize(max, boundingInfo.boundingBox.maximumWorld.add(new BABYLON.Vector3(10000, 10000, 10000)));
}
return new BABYLON.BoundingBox(min.subtract(new BABYLON.Vector3(10000, 10000, 10000)), max.subtract(new BABYLON.Vector3(10000, 10000, 10000)));
}