1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-28 11:14:36 +02:00
This commit is contained in:
syuilo
2026-05-13 10:30:42 +09:00
parent 8195f5257f
commit 8072dbef8b
10 changed files with 573 additions and 83 deletions

View File

@@ -62,24 +62,29 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick, shallowRef, computed, triggerRef } from 'vue';
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick, shallowRef, computed, triggerRef, markRaw } from 'vue';
import XObjectCustomizeForm from './room.object-customize-form.vue';
import XItem from './room.add-object-dialog.item.vue';
import type { RoomObjectInstance, RoomStateObject } from '@/world/room/object.js';
import type { PreviewEngineControllerOptions } from '@/world/room/previewEngineController.js';
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/previewEngine.js';
import { camelToKebab } from '@/world/utility.js';
import MkButton from '@/components/MkButton.vue';
import { prefer } from '@/preferences.js';
import { deepClone } from '@/utility/clone.js';
import { store } from '@/store.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { PreviewEngineController } from '@/world/room/previewEngineController.js';
// TODO: instanceのidと紛らわしいのでid -> typeにする
const props = defineProps<{
graphicsQuality: number;
}>();
const emit = defineEmits<{
(ev: 'ok', ctx: {
id: string;
@@ -97,7 +102,15 @@ const selectedInstanceId = ref<string | null>(null);
const selectedObjectOptionsState = ref<RoomStateObject | null>(null);
const selectedObjectDef = computed(() => OBJECT_DEFS.find(def => def.id === selectedId.value) ?? null);
const showObjectOptions = ref(false);
const engine = shallowRef<RoomObjectPreviewEngine | null>(null);
const previewEngineControllerOptions = computed<PreviewEngineControllerOptions>(() => ({
graphicsQuality: props.graphicsQuality,
fps: null,
resolution: 1,
workerMode: true,
}));
const controller = markRaw(new PreviewEngineController(previewEngineControllerOptions.value));
const recentlyUsedDefs = computed(() => {
const recentlyUsed = store.s.recentlyUsedRoomObjects;
@@ -105,13 +118,23 @@ const recentlyUsedDefs = computed(() => {
});
onMounted(async () => {
engine.value = await createRoomObjectPreviewEngine(canvas.value!);
try {
await controller.init(canvas.value!);
} catch (err) {
console.error(err);
os.alert({
type: 'error',
title: i18n.ts._room.failedToInitialize,
text: (err instanceof Error ? err.message : String(err)),
});
return;
}
await engine.value.init();
canvas.value!.focus();
});
onUnmounted(() => {
engine.value.destroy();
controller.destroy();
});
watch(selectedId, (newId) => {
@@ -119,21 +142,21 @@ watch(selectedId, (newId) => {
showPreview.value = false;
if (newId == null) {
engine.value!.clear();
engine.value!.pauseRender();
controller.clearObject();
controller.pauseRender();
selectedInstanceId.value = null;
selectedObjectOptionsState.value = null;
} else {
const closeWaiting = os.waiting();
nextTick(() => {
engine.value!.load(newId).then(res => {
controller.loadObject(newId).then(res => {
selectedInstanceId.value = res.id;
selectedObjectOptionsState.value = deepClone(res.options);
engine.value!.resumeRender();
controller.resumeRender();
closeWaiting();
showPreview.value = true;
nextTick(() => {
engine.value!.resize();
controller.resize();
});
}).catch(err => {
console.error(err);
@@ -144,8 +167,8 @@ watch(selectedId, (newId) => {
});
function updateObjectOption(k: string, v: any) {
const updatedOptions = engine.value!.updateObjectOption(k, v);
selectedObjectOptionsState.value = deepClone(updatedOptions);
controller.updateObjectOption(k, v);
selectedObjectOptionsState.value![k] = v;
}
function ok() {

View File

@@ -10,6 +10,7 @@ import type { ShallowRef } from 'vue';
import * as sound from '@/utility/sound.js';
// 抽象化レイヤー
// TODO: 他のcontrollerと共通部分を抽出してabstract classを作ってそこから派生する形にする
export class WorldController {
private worker: Worker | null = null;
private engine: WorldEngine | null = null;
@@ -50,9 +51,9 @@ export class WorldController {
this.canvas.addEventListener('keydown', (ev) => {
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:keydown', ev: { code: ev.code, shiftKey: ev.shiftKey } });
this.worker.postMessage({ type: 'input:keydown', ev: { code: ev.code, shiftKey: ev.shiftKey } });
} else if (this.engine != null) {
this.engine.domEvents.emit('keydown', { code: ev.code, shiftKey: ev.shiftKey });
this.engine.inputs.emit('keydown', { code: ev.code, shiftKey: ev.shiftKey });
}
ev.preventDefault();
ev.stopPropagation();
@@ -61,9 +62,9 @@ export class WorldController {
this.canvas.addEventListener('keyup', (ev) => {
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:keyup', ev: { code: ev.code, shiftKey: ev.shiftKey } });
this.worker.postMessage({ type: 'input:keyup', ev: { code: ev.code, shiftKey: ev.shiftKey } });
} else if (this.engine != null) {
this.engine.domEvents.emit('keyup', { code: ev.code, shiftKey: ev.shiftKey });
this.engine.inputs.emit('keyup', { code: ev.code, shiftKey: ev.shiftKey });
}
ev.preventDefault();
ev.stopPropagation();
@@ -76,9 +77,9 @@ export class WorldController {
this.canvas.addEventListener('wheel', (ev) => {
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:wheel', ev: { deltaY: ev.deltaY } });
this.worker.postMessage({ type: 'input:wheel', ev: { deltaY: ev.deltaY } });
} else if (this.engine != null) {
this.engine.domEvents.emit('wheel', { deltaY: ev.deltaY });
this.engine.inputs.emit('wheel', { deltaY: ev.deltaY });
}
ev.preventDefault();
ev.stopPropagation();
@@ -107,9 +108,9 @@ export class WorldController {
this.canvas.addEventListener('click', (ev) => {
if (isDragging) return;
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:click', ev: { offsetX: ev.offsetX, offsetY: ev.offsetY } });
this.worker.postMessage({ type: 'input:click', ev: { offsetX: ev.offsetX, offsetY: ev.offsetY } });
} else if (this.engine != null) {
this.engine.domEvents.emit('click', { offsetX: ev.offsetX, offsetY: ev.offsetY });
this.engine.inputs.emit('click', { offsetX: ev.offsetX, offsetY: ev.offsetY });
}
ev.preventDefault();
ev.stopPropagation();

View File

@@ -49,7 +49,7 @@ export class WorldEngine extends EventEmitter<WorldEngineEvents> {
private fps: number | null = null;
private disposed = false;
public domEvents: EventEmitter<{
public inputs: EventEmitter<{
'click': (event: { offsetX: number; offsetY: number; }) => void;
'keydown': (event: { code: string; shiftKey: boolean; }) => void;
'keyup': (event: { code: string; shiftKey: boolean; }) => void;
@@ -230,15 +230,15 @@ export class WorldEngine extends EventEmitter<WorldEngineEvents> {
window.requestAnimationFrame(renderLoop);
}
this.domEvents.on('keydown', (ev) => {
this.inputs.on('keydown', (ev) => {
});
this.domEvents.on('wheel', (ev) => {
this.inputs.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) => {
this.inputs.on('click', (ev) => {
});
}

View File

@@ -27,11 +27,13 @@ export type RoomControllerOptions = {
};
// 抽象化レイヤー
// TODO: 他のcontrollerと共通部分を抽出してabstract classを作ってそこから派生する形にする
export class RoomController {
private worker: Worker | null = null;
private engine: RoomEngine | null = null;
private canvas: HTMLCanvasElement | null = null;
private options: RoomControllerOptions;
private returnHooks = new Map<number, (value: any) => void>();
public isReady = ref(false);
public isSitting = ref(false);
public isEditMode = ref(false);
@@ -75,6 +77,15 @@ export class RoomController {
engineEvents.emit(type, ctx);
break;
}
case 'return': {
const { id, value } = event.data;
const hook = this.returnHooks.get(id);
if (hook != null) {
hook(value);
this.returnHooks.delete(id);
}
break;
}
default: {
console.warn('Unrecognized message from worker:', event.data?.type);
}
@@ -164,9 +175,9 @@ export class RoomController {
this.canvas.addEventListener('keydown', (ev) => {
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:keydown', ev: { code: ev.code, shiftKey: ev.shiftKey } });
this.worker.postMessage({ type: 'input:keydown', ev: { code: ev.code, shiftKey: ev.shiftKey } });
} else if (this.engine != null) {
this.engine.domEvents.emit('keydown', { code: ev.code, shiftKey: ev.shiftKey });
this.engine.inputs.emit('keydown', { code: ev.code, shiftKey: ev.shiftKey });
}
ev.preventDefault();
ev.stopPropagation();
@@ -175,9 +186,9 @@ export class RoomController {
this.canvas.addEventListener('keyup', (ev) => {
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:keyup', ev: { code: ev.code, shiftKey: ev.shiftKey } });
this.worker.postMessage({ type: 'input:keyup', ev: { code: ev.code, shiftKey: ev.shiftKey } });
} else if (this.engine != null) {
this.engine.domEvents.emit('keyup', { code: ev.code, shiftKey: ev.shiftKey });
this.engine.inputs.emit('keyup', { code: ev.code, shiftKey: ev.shiftKey });
}
ev.preventDefault();
ev.stopPropagation();
@@ -186,9 +197,9 @@ export class RoomController {
this.canvas.addEventListener('wheel', (ev) => {
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:wheel', ev: { deltaY: ev.deltaY } });
this.worker.postMessage({ type: 'input:wheel', ev: { deltaY: ev.deltaY } });
} else if (this.engine != null) {
this.engine.domEvents.emit('wheel', { deltaY: ev.deltaY });
this.engine.inputs.emit('wheel', { deltaY: ev.deltaY });
}
ev.preventDefault();
ev.stopPropagation();
@@ -231,9 +242,9 @@ export class RoomController {
if (prevTwoTouchPointsDistance > 0) {
const delta = distance - prevTwoTouchPointsDistance;
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:wheel', ev: { deltaY: -delta * 3 } });
this.worker.postMessage({ type: 'input:wheel', ev: { deltaY: -delta * 3 } });
} else if (this.engine != null) {
this.engine.domEvents.emit('wheel', { deltaY: -delta * 3 });
this.engine.inputs.emit('wheel', { deltaY: -delta * 3 });
}
}
prevTwoTouchPointsDistance = distance;
@@ -284,9 +295,9 @@ export class RoomController {
if (dx < 10 && dy < 10) {
const median = { x: (ev.offsetX + this.pointerDownPosition.x) / 2, y: (ev.offsetY + this.pointerDownPosition.y) / 2 };
if (this.worker != null) {
this.worker.postMessage({ type: 'dom:click', ev: { x: median.x, y: median.y } });
this.worker.postMessage({ type: 'input:click', ev: { x: median.x, y: median.y } });
} else if (this.engine != null) {
this.engine.domEvents.emit('click', { x: median.x, y: median.y });
this.engine.inputs.emit('click', { x: median.x, y: median.y });
}
}
}
@@ -309,12 +320,24 @@ export class RoomController {
await this.init(canvas ?? this.canvas!);
}
private callCounter = 0;
// TODO: いい感じに型付け
private call(fn, args = []) {
private call(fn, args = [], needReturnValue = false) {
if (this.worker != null) {
this.worker.postMessage({ type: 'call', fn, args });
if (needReturnValue) {
return new Promise((resolve) => {
const id = this.callCounter++;
this.returnHooks.set(id, (value) => {
resolve(value);
});
this.worker!.postMessage({ type: 'call', fn, args, needReturnValue: true, id });
});
} else {
this.worker.postMessage({ type: 'call', fn, args });
}
} else if (this.engine != null) {
this.engine[fn](...args);
return this.engine[fn](...args);
}
}

View File

@@ -202,7 +202,7 @@ export class RoomEngine extends EventEmitter {
private fps: number | null = null;
private disposed = false;
public domEvents: EventEmitter<{
public inputs: EventEmitter<{
'click': (event: { x: number; y: number; }) => void;
'keydown': (event: { code: string; shiftKey: boolean; }) => void;
'keyup': (event: { code: string; shiftKey: boolean; }) => void;
@@ -394,8 +394,8 @@ export class RoomEngine extends EventEmitter {
const box = BABYLON.MeshBuilder.CreateBox('', { size: cm(10) }, this.scene);
window.setInterval(() => {
box.position = new BABYLON.Vector3(0, Math.random() * cm(10), 0);
}, 10);
box.position = new BABYLON.Vector3(0, Math.random() * cm(10), 0);
}, 10);
this.startRenderLoop();
@@ -404,7 +404,7 @@ export class RoomEngine extends EventEmitter {
// 必ずシーンが少なくとも1フレームレンダリングがされてから呼ばれるように注意すること。そうしないとタイミングによってはエンジンがクラッシュする
this.sr.enableSnapshotRendering();
this.domEvents.on('keydown', (ev) => {
this.inputs.on('keydown', (ev) => {
if (ev.code === 'KeyE') {
if (this.isEditMode) {
if (this.grabbingCtx != null) {
@@ -432,7 +432,7 @@ export class RoomEngine extends EventEmitter {
}
});
this.domEvents.on('wheel', (ev) => {
this.inputs.on('wheel', (ev) => {
if (this.grabbingCtx != null) {
this.changeGrabbingDistance(ev.deltaY * 0.025);
} else {
@@ -441,7 +441,7 @@ export class RoomEngine extends EventEmitter {
}
});
this.domEvents.on('click', (ev) => {
this.inputs.on('click', (ev) => {
if (this.grabbingCtx != null) return;
this.selectObject(null);
@@ -454,7 +454,7 @@ export class RoomEngine extends EventEmitter {
const oid = pickingInfo.pickedMesh.metadata.objectId;
if (oid != null && this.objectEntities.has(oid)) {
const o = this.objectEntities.get(oid)!;
const boundingInfo = getMeshesBoundingBox(o.rootMesh.getChildMeshes().filter(m => m.isEnabled() && m.isVisible && !m.isDisposed()));
const boundingInfo = getMeshesBoundingBox(o.rootMesh.getChildMeshes().filter(m => m.isEnabled() && m.isVisible && !m.isDisposed()), true);
this.selectObject(oid);
{ // camera animation

View File

@@ -6,24 +6,15 @@
import * as BABYLON from '@babylonjs/core';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
import { GridMaterial } from '@babylonjs/materials';
import { camelToKebab, WORLD_SCALE, cm, getMeshesBoundingBox, Timer, sleep } from '../utility.js';
import EventEmitter from 'eventemitter3';
import { camelToKebab, WORLD_SCALE, cm, getMeshesBoundingBox, Timer, sleep, ArcRotateCameraManualInput } from '../utility.js';
import { getObjectDef } from './object-defs.js';
import { SYSTEM_MESH_NAMES, ModelManager, GRAPHICS_QUALITY } from './utility.js';
import type { RoomObjectInstance } from './object.js';
import { genId } from '@/utility/id.js';
import { deepClone } from '@/utility/clone.js';
import { store } from '@/store.js';
export async function createRoomObjectPreviewEngine(canvas: HTMLCanvasElement) {
const babylonEngine = new BABYLON.WebGPUEngine(canvas, { doNotHandleContextLost: true, powerPreference: 'low-power' });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
await babylonEngine.initAsync();
return new RoomObjectPreviewEngine({ canvas, engine: babylonEngine });
}
export class RoomObjectPreviewEngine {
private canvas: HTMLCanvasElement;
export class RoomObjectPreviewEngine extends EventEmitter {
private engine: BABYLON.WebGPUEngine;
private scene: BABYLON.Scene;
private sr: BABYLON.SnapshotRenderingHelper;
@@ -38,17 +29,29 @@ export class RoomObjectPreviewEngine {
private zGridPreviewPlane: BABYLON.Mesh;
private timerForEachObject: Timer | null = null;
private pipeline: BABYLON.DefaultRenderingPipeline;
private graphicsQuality: number;
private fps: number | null = null;
private disposed = false;
public inputs: EventEmitter<{
'click': (event: { x: number; y: 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;
graphicsQuality: number;
fps: number | null;
}) {
this.canvas = options.canvas;
super();
registerBuiltInLoaders();
this.graphicsQuality = options.graphicsQuality;
this.fps = options.fps;
this.engine = options.engine;
this.scene = new BABYLON.Scene(this.engine);
this.scene.autoClear = false;
@@ -78,7 +81,7 @@ export class RoomObjectPreviewEngine {
this.shadowGenerator.getShadowMap().refreshRate = 60;
const gridMaterial = new GridMaterial('grid', this.scene);
gridMaterial.lineColor = store.s.darkMode ? new BABYLON.Color3(1, 1, 1) : new BABYLON.Color3(0, 0, 0);
gridMaterial.lineColor = new BABYLON.Color3(1, 1, 1);
gridMaterial.mainColor = new BABYLON.Color3(0, 0, 0);
gridMaterial.minorUnitVisibility = 1;
gridMaterial.opacity = 0.05;
@@ -97,16 +100,17 @@ export class RoomObjectPreviewEngine {
this.pipeline = new BABYLON.DefaultRenderingPipeline('default', true, this.scene);
this.pipeline.samples = 4;
// 重い
//this.pipeline.bloomEnabled = true;
//this.pipeline.bloomThreshold = 0.95;
//this.pipeline.bloomWeight = 0.1;
//this.pipeline.bloomKernel = 256;
//this.pipeline.bloomScale = 2;
if (this.graphicsQuality >= GRAPHICS_QUALITY.HIGH) {
this.pipeline.bloomEnabled = true;
this.pipeline.bloomThreshold = 0.95;
this.pipeline.bloomWeight = 0.1;
this.pipeline.bloomKernel = 256;
this.pipeline.bloomScale = 2;
}
this.pipeline.sharpenEnabled = true;
this.pipeline.sharpen.edgeAmount = 0.5;
if (_DEV_) {
if (_DEV_ && typeof window !== 'undefined') {
window.takeScreenshot = () => {
const def = getObjectDef(this.objectType);
@@ -190,11 +194,16 @@ export class RoomObjectPreviewEngine {
public async init() {
await this.scene.whenReadyAsync();
this.sr.enableSnapshotRendering();
this.inputs.on('wheel', (ev) => {
this.camera.fov += ev.deltaY * 0.001;
this.camera.fov = Math.max(0.25, Math.min(0.5, this.camera.fov));
});
}
public async load(type: string) {
public async loadObject(type: string) {
this.sr.disableSnapshotRendering();
this.clear();
this.clearObject();
const id = genId();
const def = getObjectDef(type);
@@ -205,7 +214,7 @@ export class RoomObjectPreviewEngine {
}
}
await this.loadObject({
await this.loadObject_({
type,
options: this.objectOptions,
id,
@@ -217,7 +226,6 @@ export class RoomObjectPreviewEngine {
this.camera.dispose();
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;
@@ -229,6 +237,11 @@ export class RoomObjectPreviewEngine {
this.camera.wheelDeltaPercentage = 0.01;
//this.camera.mode = BABYLON.Camera.ORTHOGRAPHIC_CAMERA;
this.camera.setTarget(new BABYLON.Vector3(0, boundingInfo.centerWorld.y, 0));
this.camera.inputs.clear();
this.camera.inputs.add(new ArcRotateCameraManualInput(this.scene, {
rotationSensitivity: 0.0005,
}));
if (def.placement === 'wall' || def.placement === 'side') {
this.camera.lowerBetaLimit = 0;
@@ -260,12 +273,11 @@ export class RoomObjectPreviewEngine {
return {
id,
objectInstance: this.objectInstance,
options: this.objectOptions,
};
}
private async loadObject(args: {
private async loadObject_(args: {
type: string;
options: any;
id: string;
@@ -365,7 +377,7 @@ export class RoomObjectPreviewEngine {
return this.objectOptions;
}
public clear() {
public clearObject() {
this.sr.disableSnapshotRendering();
if (this.timerForEachObject != null) {
this.timerForEachObject.dispose();
@@ -382,6 +394,10 @@ export class RoomObjectPreviewEngine {
this.sr.enableSnapshotRendering();
}
public cameraRotate(vector: { x: number; y: number; }) {
(this.camera.inputs.attached.manual as ArcRotateCameraManualInput).setRotationVector(vector);
}
public resize() {
// 一旦snapshot renderingを無効にしておかないとエラーが出る(babylonのバグ)
// ~~...が、一旦無効にしたらしたで複数のマテリアルがそれぞれ入れ替わる(?)という謎の現象が発生するためコメントアウトしとく(エラー出てもレンダリングが止まったりするわけでもないし)~~

View File

@@ -0,0 +1,293 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { reactive, ref, shallowRef, triggerRef, watch } from 'vue';
import * as BABYLON from '@babylonjs/core';
import { EventEmitter } from 'eventemitter3';
import PreviewEngineWorker from './previewEngineWorker?worker';
import { RoomObjectPreviewEngine } from './previewEngine.js';
import type { ShallowRef } from 'vue';
import type { RoomEngineEvents, RoomState } from './engine.js';
import * as sound from '@/utility/sound.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { deepEqual } from '@/utility/deep-equal.js';
export type PreviewEngineControllerOptions = {
workerMode?: boolean;
graphicsQuality: number;
fps: number | null;
resolution: number;
};
// 抽象化レイヤー
// TODO: 他のcontrollerと共通部分を抽出してabstract classを作ってそこから派生する形にする
export class PreviewEngineController {
private worker: Worker | null = null;
private engine: RoomObjectPreviewEngine | null = null;
private canvas: HTMLCanvasElement | null = null;
private options: PreviewEngineControllerOptions;
private returnHooks = new Map<number, (value: any) => void>();
public isReady = ref(false);
private pointerDownPosition: { x: number; y: number } | null = null;
private abortController = new AbortController();
constructor(options: PreviewEngineControllerOptions) {
this.options = options;
}
public async init(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.canvas.width = canvas.clientWidth;
this.canvas.height = canvas.clientHeight;
const engineEvents = new EventEmitter<RoomEngineEvents>();
if (this.options.workerMode) {
const offscreen = canvas.transferControlToOffscreen();
this.worker = new PreviewEngineWorker();
this.worker.postMessage({ type: 'init', canvas: offscreen, options: this.options }, [offscreen]);
this.worker.onmessage = (event) => {
switch (event.data?.type) {
case 'inited': {
this.isReady.value = true;
break;
}
case 'ev': {
const { type, ctx } = event.data.ev;
engineEvents.emit(type, ctx);
break;
}
case 'return': {
const { id, value } = event.data;
const hook = this.returnHooks.get(id);
if (hook != null) {
hook(value);
this.returnHooks.delete(id);
}
break;
}
default: {
console.warn('Unrecognized message from worker:', event.data?.type);
}
}
};
} else {
const babylonEngine = new BABYLON.WebGPUEngine(canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
babylonEngine.onContextLostObservable.add(() => {
os.alert({
type: 'error',
title: i18n.ts.somethingHappened,
text: i18n.ts._room.crushed_description,
});
});
await babylonEngine.initAsync();
if (this.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
if (this.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
this.engine = new RoomObjectPreviewEngine({
engine: babylonEngine,
...this.options,
});
this.engine.on('ev', ({ type, ctx }) => {
engineEvents.emit(type, ctx);
});
await this.engine.init();
this.isReady.value = true;
}
this.canvas.addEventListener('wheel', (ev) => {
if (this.worker != null) {
this.worker.postMessage({ type: 'input:wheel', ev: { deltaY: ev.deltaY } });
} else if (this.engine != null) {
this.engine.inputs.emit('wheel', { deltaY: ev.deltaY });
}
ev.preventDefault();
ev.stopPropagation();
return false;
}, { signal: this.abortController.signal });
const pointerEventCache = new Map<number, PointerEvent>();
let pointerVec = { x: 0, y: 0 };
this.canvas.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.canvas!.focus();
pointerEventCache.set(ev.pointerId, ev);
this.canvas!.setPointerCapture(ev.pointerId);
pointerVec = { x: ev.clientX, y: ev.clientY };
this.pointerDownPosition = { x: ev.offsetX, y: ev.offsetY };
return false;
}, { signal: this.abortController.signal });
let prevTwoTouchPointsDistance = 0;
this.canvas!.addEventListener('pointermove', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (pointerEventCache.size === 0) {
return;
}
pointerEventCache.set(ev.pointerId, ev);
if (pointerEventCache.size > 1) {
const a = Array.from(pointerEventCache.values())[0];
const b = Array.from(pointerEventCache.values())[1];
const distance = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
if (prevTwoTouchPointsDistance > 0) {
const delta = distance - prevTwoTouchPointsDistance;
if (this.worker != null) {
this.worker.postMessage({ type: 'input:wheel', ev: { deltaY: -delta * 3 } });
} else if (this.engine != null) {
this.engine.inputs.emit('wheel', { deltaY: -delta * 3 });
}
}
prevTwoTouchPointsDistance = distance;
pointerVec = { x: 0, y: 0 };
this.setCameraRotateVector({
x: 0,
y: 0,
});
return;
}
prevTwoTouchPointsDistance = 0;
const before = pointerVec;
const after = { x: ev.clientX, y: ev.clientY };
if (pointerVec.x !== 0 || pointerVec.y !== 0) {
this.setCameraRotateVector({
x: after.x - before.x,
y: after.y - before.y,
});
}
pointerVec.x = after.x;
pointerVec.y = after.y;
return false;
}, { signal: this.abortController.signal });
this.canvas.addEventListener('pointerup', (ev) => {
pointerEventCache.delete(ev.pointerId);
this.canvas!.releasePointerCapture(ev.pointerId);
prevTwoTouchPointsDistance = 0;
if (pointerEventCache.size > 0) {
return;
}
pointerVec.x = 0;
pointerVec.y = 0;
this.setCameraRotateVector(pointerVec);
if (this.pointerDownPosition != null) {
const dx = Math.abs(ev.offsetX - this.pointerDownPosition.x);
const dy = Math.abs(ev.offsetY - this.pointerDownPosition.y);
if (dx < 10 && dy < 10) {
const median = { x: (ev.offsetX + this.pointerDownPosition.x) / 2, y: (ev.offsetY + this.pointerDownPosition.y) / 2 };
if (this.worker != null) {
this.worker.postMessage({ type: 'input:click', ev: { x: median.x, y: median.y } });
} else if (this.engine != null) {
this.engine.inputs.emit('click', { x: median.x, y: median.y });
}
}
}
this.pointerDownPosition = null;
}, { signal: this.abortController.signal });
}
private callCounter = 0;
// TODO: いい感じに型付け
private call(fn, args = [], needReturnValue = false) {
if (this.worker != null) {
if (needReturnValue) {
return new Promise((resolve) => {
const id = this.callCounter++;
this.returnHooks.set(id, (value) => {
resolve(value);
});
this.worker!.postMessage({ type: 'call', fn, args, needReturnValue: true, id });
});
} else {
this.worker.postMessage({ type: 'call', fn, args });
}
} else if (this.engine != null) {
return this.engine[fn](...args);
}
}
// TODO: いい感じに型付け
private set(key, value) {
if (this.worker != null) {
this.worker.postMessage({ type: 'set', key, value });
} else if (this.engine != null) {
this.engine[key] = value;
}
}
public pauseRender() {
this.call('pauseRender');
}
public resumeRender() {
this.call('resumeRender');
}
public setCameraRotateVector(vec: { x: number; y: number }) {
this.call('cameraRotate', [vec]);
}
public updateObjectOption(key: string, value: any) {
this.call('updateObjectOption', [key, value]);
}
public loadObject(type: string) {
return this.call('loadObject', [type], true);
}
public clearObject() {
this.call('clearObject');
}
public resize() {
if (this.canvas == null) return;
const width = this.canvas.clientWidth;
const height = this.canvas.clientHeight;
if (this.worker != null) {
this.worker.postMessage({ type: 'resize', width, height });
} else if (this.engine != null) {
this.engine.resize();
}
}
public destroy() {
this.abortController.abort();
if (this.worker != null) {
this.worker.terminate();
this.worker = null;
}
if (this.engine != null) {
this.engine.destroy();
this.engine = null;
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import { RoomObjectPreviewEngine } from './previewEngine.js';
let engine: RoomObjectPreviewEngine | null = null;
let canvas: OffscreenCanvas | null = null;
onmessage = async (event) => {
//console.log('Worker received message:', event.data);
switch (event.data?.type) {
case 'init': {
canvas = event.data.canvas as OffscreenCanvas;
const babylonEngine = new BABYLON.WebGPUEngine(canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
await babylonEngine.initAsync();
if (event.data.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
if (event.data.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
engine = new RoomObjectPreviewEngine({
engine: babylonEngine,
...event.data.options,
});
engine.on('ev', ({ type, ctx }) => {
self.postMessage({ type: 'ev', ev: { type, ctx } });
});
await engine.init();
self.postMessage({ type: 'inited' });
break;
}
case 'resize': {
canvas.width = event.data.width;
canvas.height = event.data.height;
if (engine != null) engine.resize();
break;
}
case 'input:keydown': {
if (engine == null) break;
engine.inputs.emit('keydown', event.data.ev);
break;
}
case 'input:keyup': {
if (engine == null) break;
engine.inputs.emit('keyup', event.data.ev);
break;
}
case 'input:click': {
if (engine == null) break;
engine.inputs.emit('click', event.data.ev);
break;
}
case 'input:wheel': {
if (engine == null) break;
engine.inputs.emit('wheel', event.data.ev);
break;
}
case 'call': {
if (engine != null) {
const res = engine[event.data.fn](...(event.data.args ?? []));
if (event.data.needReturnValue) {
if (res instanceof Promise) {
res.then((r) => {
self.postMessage({ type: 'return', id: event.data.id, value: r });
});
} else {
self.postMessage({ type: 'return', id: event.data.id, value: res });
}
}
}
break;
}
case 'set': {
if (engine != null) engine[event.data.key] = event.data.value;
break;
}
default: {
console.warn('Unrecognized message type:', event.data?.type);
}
}
};

View File

@@ -44,28 +44,39 @@ onmessage = async (event) => {
if (engine != null) engine.resize();
break;
}
case 'dom:keydown': {
case 'input:keydown': {
if (engine == null) break;
engine.domEvents.emit('keydown', event.data.ev);
engine.inputs.emit('keydown', event.data.ev);
break;
}
case 'dom:keyup': {
case 'input:keyup': {
if (engine == null) break;
engine.domEvents.emit('keyup', event.data.ev);
engine.inputs.emit('keyup', event.data.ev);
break;
}
case 'dom:click': {
case 'input:click': {
if (engine == null) break;
engine.domEvents.emit('click', event.data.ev);
engine.inputs.emit('click', event.data.ev);
break;
}
case 'dom:wheel': {
case 'input:wheel': {
if (engine == null) break;
engine.domEvents.emit('wheel', event.data.ev);
engine.inputs.emit('wheel', event.data.ev);
break;
}
case 'call': {
if (engine != null) engine[event.data.fn](...(event.data.args ?? []));
if (engine != null) {
const res = engine[event.data.fn](...(event.data.args ?? []));
if (event.data.needReturnValue) {
if (res instanceof Promise) {
res.then((r) => {
self.postMessage({ type: 'return', id: event.data.id, value: r });
});
} else {
self.postMessage({ type: 'return', id: event.data.id, value: res });
}
}
}
break;
}
case 'set': {

View File

@@ -701,3 +701,38 @@ export class FreeCameraManualInput implements BABYLON.ICameraInput<BABYLON.FreeC
//this.camera.cameraDirection.addInPlace(this.camera._transformedDirection);
}
}
export class ArcRotateCameraManualInput implements BABYLON.ICameraInput<BABYLON.ArcRotateCamera> {
public camera: BABYLON.ArcRotateCamera;
private scene: BABYLON.Scene;
private rotationSensitivity: number;
constructor(scene: BABYLON.Scene, options: {
rotationSensitivity?: number;
}) {
this.scene = scene;
this.rotationSensitivity = options.rotationSensitivity ?? 0.01;
}
getClassName = () => this.constructor.name;
getSimpleName = () => 'manual';
attachControl(noPreventDefault) {
}
detachControl() {
}
public setRotationVector(vec: { x: number; y: number }) {
let directionAdjust = 1;
if (this.scene.useRightHandedSystem) directionAdjust *= -1;
if (this.camera.parent && this.camera.parent._getWorldMatrixDeterminant() < 0) directionAdjust *= -1;
this.camera.inertialBetaOffset += -vec.y * this.rotationSensitivity * directionAdjust;
this.camera.inertialAlphaOffset += -vec.x * this.rotationSensitivity * directionAdjust;
}
checkInputs() {
}
}