mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-28 12:14:38 +02:00
354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
/*
|
|
* 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 * as os from '@/os.js';
|
|
import { i18n } from '@/i18n.js';
|
|
|
|
export type EngineControllerBaseOptions = {
|
|
workerMode?: boolean;
|
|
graphicsQuality: number;
|
|
fps: number | null;
|
|
resolution: number;
|
|
antialias: boolean;
|
|
};
|
|
|
|
// 抽象化レイヤー
|
|
export abstract class EngineControllerBase<T extends RoomEngineBase> {
|
|
private worker: Worker | null = null;
|
|
private engine: T | null = null;
|
|
private canvas: HTMLCanvasElement | null = null;
|
|
protected options: EngineControllerBaseOptions;
|
|
private returnHooks = new Map<number, (value: any) => void>();
|
|
public isReady = ref(false);
|
|
public initializeProgress = ref(0);
|
|
private pointerDownPosition: { x: number; y: number } | null = null;
|
|
private abortController = new AbortController();
|
|
|
|
constructor(options: EngineControllerBaseOptions) {
|
|
this.options = options;
|
|
}
|
|
|
|
protected async _init_(canvas: HTMLCanvasElement, params: {
|
|
createWorker: (offscreen: OffscreenCanvas) => Promise<Worker>;
|
|
createEngine: (babylonEngine: BABYLON.WebGPUEngine) => Promise<T>;
|
|
}) {
|
|
this.canvas = canvas;
|
|
|
|
// 最低でも4pxないとなんかエラーになる
|
|
this.canvas.width = canvas.clientWidth > 4 ? canvas.clientWidth : 4;
|
|
this.canvas.height = canvas.clientHeight > 4 ? canvas.clientHeight : 4;
|
|
|
|
const engineEvents = new EventEmitter<RoomEngineBaseEvents>();
|
|
|
|
if (this.options.workerMode) {
|
|
const offscreen = canvas.transferControlToOffscreen();
|
|
this.worker = await params.createWorker(offscreen);
|
|
this.worker.onmessage = (event) => {
|
|
switch (event.data?.type) {
|
|
case 'inited': {
|
|
this.initializeProgress.value = 1;
|
|
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: 'high-performance', antialias: this.options.antialias });
|
|
babylonEngine.compatibilityMode = false;
|
|
babylonEngine.enableOfflineSupport = false;
|
|
babylonEngine.onContextLostObservable.add(() => {
|
|
os.alert({
|
|
type: 'error',
|
|
title: i18n.ts.somethingHappened,
|
|
text: i18n.ts._miWorld.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 = await params.createEngine(babylonEngine);
|
|
|
|
this.engine.on('ev', ({ type, ctx }) => {
|
|
engineEvents.emit(type, ctx);
|
|
});
|
|
|
|
await this.engine.init();
|
|
|
|
this.initializeProgress.value = 1;
|
|
this.isReady.value = true;
|
|
|
|
if (_DEV_) {
|
|
(window as any).showBabylonInspector = () => {
|
|
import('@babylonjs/inspector').then(({ ShowInspector }) => {
|
|
ShowInspector(this.engine.scene);
|
|
});
|
|
};
|
|
}
|
|
}
|
|
|
|
engineEvents.on('loadingProgress', ({ progress }) => {
|
|
this.initializeProgress.value = progress;
|
|
});
|
|
|
|
this.canvas.addEventListener('keydown', (ev) => {
|
|
if (this.worker != null) {
|
|
this.worker.postMessage({ type: 'input:keydown', ev: { code: ev.code, shiftKey: ev.shiftKey } });
|
|
} else if (this.engine != null) {
|
|
this.engine.inputs.emit('keydown', { code: ev.code, shiftKey: ev.shiftKey });
|
|
}
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
return false;
|
|
}, { signal: this.abortController.signal });
|
|
|
|
this.canvas.addEventListener('keyup', (ev) => {
|
|
if (this.worker != null) {
|
|
this.worker.postMessage({ type: 'input:keyup', ev: { code: ev.code, shiftKey: ev.shiftKey } });
|
|
} else if (this.engine != null) {
|
|
this.engine.inputs.emit('keyup', { code: ev.code, shiftKey: ev.shiftKey });
|
|
}
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
return false;
|
|
}, { signal: this.abortController.signal });
|
|
|
|
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:zoom', ev: { delta: delta } });
|
|
} else if (this.engine != null) {
|
|
this.engine.inputs.emit('zoom', { delta: delta });
|
|
}
|
|
}
|
|
prevTwoTouchPointsDistance = distance;
|
|
|
|
pointerVec = { x: 0, y: 0 };
|
|
if (this.worker != null) {
|
|
this.worker.postMessage({ type: 'input:pointer', ev: { x: 0, y: 0 } });
|
|
} else if (this.engine != null) {
|
|
this.engine.inputs.emit('pointer', { 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) {
|
|
if (this.worker != null) {
|
|
this.worker.postMessage({ type: 'input:pointer', ev: {
|
|
x: after.x - before.x,
|
|
y: after.y - before.y,
|
|
} });
|
|
} else if (this.engine != null) {
|
|
this.engine.inputs.emit('pointer', {
|
|
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;
|
|
if (this.worker != null) {
|
|
this.worker.postMessage({ type: 'input:pointer', ev: pointerVec });
|
|
} else if (this.engine != null) {
|
|
this.engine.inputs.emit('pointer', 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 });
|
|
|
|
return {
|
|
engineEvents,
|
|
};
|
|
}
|
|
|
|
protected async _reset_() {
|
|
this.destroy();
|
|
this.abortController = new AbortController();
|
|
this.isReady.value = false;
|
|
this.initializeProgress.value = 0;
|
|
}
|
|
|
|
private callCounter = 0;
|
|
|
|
protected call<FN extends keyof T>(fn: FN, args: Parameters<T[FN]> = [] as any): void {
|
|
if (!this.isReady.value) {
|
|
throw new Error('Engine is not initialized');
|
|
}
|
|
if (this.worker != null) {
|
|
this.worker.postMessage({ type: 'call', fn, args });
|
|
} else if (this.engine != null) {
|
|
this.engine[fn](...args);
|
|
} else {
|
|
throw new Error('Engine is not initialized');
|
|
}
|
|
}
|
|
|
|
protected callAndWaitReturn<FN extends keyof T>(fn: FN, args: Parameters<T[FN]> = [] as any): ReturnType<T[FN]> extends Promise<any> ? ReturnType<T[FN]> : Promise<ReturnType<T[FN]>> {
|
|
if (!this.isReady.value) {
|
|
throw new Error('Engine is not initialized');
|
|
}
|
|
if (this.worker != null) {
|
|
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 if (this.engine != null) {
|
|
return new Promise((resolve) => {
|
|
resolve(this.engine);
|
|
});
|
|
} else {
|
|
throw new Error('Engine is not initialized');
|
|
}
|
|
}
|
|
|
|
protected set<K extends keyof T>(key: K, value: T[K]) {
|
|
if (!this.isReady.value) {
|
|
throw new Error('Engine is not initialized');
|
|
}
|
|
if (this.worker != null) {
|
|
this.worker.postMessage({ type: 'set', key, value });
|
|
} else if (this.engine != null) {
|
|
this.engine[key] = value;
|
|
} else {
|
|
throw new Error('Engine is not initialized');
|
|
}
|
|
}
|
|
|
|
public pauseRender() {
|
|
this.call('pauseRender');
|
|
}
|
|
|
|
public resumeRender() {
|
|
this.call('resumeRender');
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// TODO: isReadyになる前に呼ばれたらメモリリークしそうな気もするから調査の上いい感じにする
|
|
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;
|
|
}
|
|
}
|
|
}
|