This commit is contained in:
syuilo
2026-07-27 16:00:11 +09:00
parent 83f3daccde
commit b9ca53785c
23 changed files with 1350 additions and 607 deletions
@@ -5,18 +5,19 @@
import * as BABYLON from '@babylonjs/core/pure.js';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
import tinycolor from 'tinycolor2';
import Hls from 'hls.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { RecyvlingTextGrid, Timer, createPlaneUvMapper, randomRange } from './utility.js';
import { FreeCameraManualInput, GRAPHICS_QUALITY, Timer } from './utility.js';
import { TIME_MAP } from './utility.js';
import { EngineBase } from './EngineBase.js';
import { LobbyEnvManager } from './envs/lobby.js';
import type { PlayerContainer, PlayerProfile, PlayerState } from './PlayerContainer.js';
import type { WorldEnvManager } from './env.js';
const SNAPSHOT_RENDERING = false; // 実験的
const USE_GLOW = true; // ドローコールが増えて重い
const IN_WEB_WORKER = typeof window === 'undefined';
export class WorldEngine extends EngineBase<{
'changeMyPlayerState': (ctx: PlayerState) => void;
'playerPointed': (ctx: { playerId: string; }) => void;
'playSfxUrl': (ctx: {
url: string;
options: {
@@ -25,29 +26,48 @@ export class WorldEngine extends EngineBase<{
};
}) => void;
'loadingProgress': (ctx: { progress: number }) => void;
'contextlost': (ctx: { reason: string; message: string; }) => void;
}> {
private shadowGeneratorForSunLight: BABYLON.ShadowGenerator;
public camera: BABYLON.UniversalCamera;
private time: 0 | 1 | 2 = 0; // 0: 昼, 1: 夕, 2: 夜
private envMap: BABYLON.CubeTexture;
public lightContainer: BABYLON.ClusteredLightContainer;
public sr: BABYLON.SnapshotRenderingHelper;
private gl: BABYLON.GlowLayer | null = null;
private textMaterial: BABYLON.StandardMaterial;
private translucentTextMaterial: BABYLON.StandardMaterial;
private reflectionProbe: BABYLON.ReflectionProbe;
public timer: Timer = new Timer();
public isSitting = false;
private cameraHeight = cm(130);
private fov: number;
private useGlow: boolean;
public graphicsQuality: number;
private playerProfiles: Record<string, PlayerProfile> = {};
private playerContainers: PlayerContainer[] = [];
private showUsernameOnAvatar: boolean;
private show2dAvatarOnAvatar: boolean;
private envManager: WorldEnvManager | null = null;
private inited = false;
constructor(options: {
canvas: HTMLCanvasElement;
engine: BABYLON.WebGPUEngine;
graphicsQuality: number;
fps: number | null;
antialias: boolean;
fov: number;
useVirtualJoystick?: boolean;
showUsernameOnAvatar: boolean;
show2dAvatarOnAvatar: boolean;
}) {
super({
engine: options.engine,
fps: null,
fps: options.fps,
});
this.graphicsQuality = options.graphicsQuality;
this.useGlow = this.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM;
this.fov = options.fov;
this.showUsernameOnAvatar = options.showUsernameOnAvatar;
this.show2dAvatarOnAvatar = options.show2dAvatarOnAvatar;
registerBuiltInLoaders();
this.scene.autoClear = false;
@@ -55,6 +75,7 @@ export class WorldEngine extends EngineBase<{
this.scene.skipPointerMovePicking = true;
this.scene.skipFrustumClipping = true; // snapshot renderingでは全てのメッシュがアクティブになっている必要があるため
this.scene.gravity = new BABYLON.Vector3(0, -0.1, 0).scale(WORLD_SCALE);
this.scene.collisionsEnabled = true;
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
@@ -81,73 +102,37 @@ export class WorldEngine extends EngineBase<{
//this.envMap.level = 1;
this.envMap.level = 0;
this.scene.collisionsEnabled = true;
this.camera = new BABYLON.UniversalCamera('camera', new BABYLON.Vector3(cm(0), cm(250), cm(3000)), this.scene);
this.camera.attachControl(this.canvas);
this.camera = new BABYLON.FreeCamera('camera', new BABYLON.Vector3(0, this.cameraHeight, cm(0)), this.scene);
this.camera.minZ = cm(1);
this.camera.maxZ = cm(100000);
this.camera.fov = 1;
this.camera.maxZ = cm(1000);
this.camera.fov = this.fov;
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.camera.keysUp.push(87); // W
this.camera.keysDown.push(83); // S
this.camera.keysLeft.push(65); // A
this.camera.keysRight.push(68); // D
const normalSpeed = 0.03 * WORLD_SCALE;
this.camera.speed = normalSpeed;
this.scene.onKeyboardObservable.add((kbInfo) => {
switch (kbInfo.type) {
case BABYLON.KeyboardEventTypes.KEYDOWN:
if (kbInfo.event.key === 'Shift') {
this.camera.speed = normalSpeed * 4;
}
break;
case BABYLON.KeyboardEventTypes.KEYUP:
if (kbInfo.event.key === 'Shift') {
this.camera.speed = normalSpeed;
}
break;
}
});
this.camera.inputs.clear();
if (options.useVirtualJoystick) {
this.camera.inputs.add(new FreeCameraManualInput(this.scene, {
moveSensitivity: 0.015 * WORLD_SCALE,
rotationSensitivity: 0.0007,
}));
this.camera.inertia = 0.75;
} else {
this.camera.inputs.add(new FreeCameraManualInput(this.scene, {
moveSensitivity: 0.002 * WORLD_SCALE,
rotationSensitivity: 0.0003,
}));
}
//this.scene.activeCamera = this.camera;
//this.reflectionProbe = new BABYLON.ReflectionProbe('rp', 512, this.scene, true, true, false);
//this.reflectionProbe.refreshRate = 60;
//const mainLight = new BABYLON.PointLight('mainLight', new BABYLON.Vector3(0, cm(300), 0), this.scene);
//mainLight.intensity = 10000000;
//mainLight.radius = cm(1000);
//mainLight.diffuse = new BABYLON.Color3(1, 1, 1);
const ambientLight1 = new BABYLON.HemisphericLight('ambientLight1', new BABYLON.Vector3(0, 1, 0), this.scene);
ambientLight1.diffuse = new BABYLON.Color3(1.0, 0.9, 0.8);
ambientLight1.intensity = 1;
const ambientLight2 = new BABYLON.HemisphericLight('ambientLight2', new BABYLON.Vector3(0, -1, 0), this.scene);
ambientLight2.diffuse = new BABYLON.Color3(0.8, 0.9, 1.0);
ambientLight2.intensity = 1;
//ambientLight.intensity = 0;
const sunLight = new BABYLON.DirectionalLight('sunLight', new BABYLON.Vector3(0, -1, 0), this.scene);
sunLight.position = new BABYLON.Vector3(cm(0), cm(10000), cm(0));
sunLight.diffuse = this.time === 0 ? new BABYLON.Color3(1.0, 1.0, 1.0) : 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 ? 2 : this.time === 1 ? 0.5 : 0.25;
sunLight.shadowMinZ = cm(1000);
sunLight.shadowMaxZ = cm(2000);
this.shadowGeneratorForSunLight = new BABYLON.ShadowGenerator(4096, sunLight);
this.shadowGeneratorForSunLight.forceBackFacesOnly = true;
this.shadowGeneratorForSunLight.bias = 0.0001;
this.shadowGeneratorForSunLight.usePercentageCloserFiltering = true;
this.shadowGeneratorForSunLight.usePoissonSampling = true;
//this.shadowGeneratorForSunLight.getShadowMap().refreshRate = 60;
this.scene.activeCamera = this.camera;
this.lightContainer = new BABYLON.ClusteredLightContainer('clustered', [], this.scene);
this.lightContainer.maxRange = cm(1000);
this.lightContainer.verticalTiles = 32;
this.lightContainer.horizontalTiles = 32;
this.lightContainer.depthSlices = 32;
if (USE_GLOW) {
if (this.useGlow) {
this.gl = new BABYLON.GlowLayer('glow', this.scene, {
//mainTextureFixedSize: 512,
blurKernelSize: 64,
@@ -155,405 +140,123 @@ export class WorldEngine extends EngineBase<{
this.gl.intensity = 0.5;
this.gl.addExcludedMesh(skybox);
this.scene.setRenderingAutoClearDepthStencil(this.gl.renderingGroupId, false);
if (SNAPSHOT_RENDERING) {
this.sr.updateMeshesForEffectLayer(this.gl);
}
this.sr.updateMeshesForEffectLayer(this.gl);
}
}
public async init() {
await this.loadEnvModel();
await this.loadEnv();
if (SNAPSHOT_RENDERING) {
this.sr.enableSnapshotRendering();
}
this.startRenderLoop();
this.inputs.on('keydown', (ev) => {
});
await this.scene.whenReadyAsync();
// 必ずシーンが少なくとも1フレームレンダリングがされてから呼ばれるように注意すること。そうしないとタイミングによってはエンジンがクラッシュする
this.sr.enableSnapshotRendering();
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));
if (this.scene.activeCamera === this.camera) {
this.camera.fov += ev.deltaY * 0.001;
this.camera.fov = Math.max(0.25, Math.min(this.fov, this.camera.fov));
}
});
this.inputs.on('zoom', (ev) => {
if (this.scene.activeCamera === this.camera) {
this.camera.fov += -ev.delta * 0.003;
this.camera.fov = Math.max(0.25, Math.min(this.fov, this.camera.fov));
}
});
this.inputs.on('click', (ev) => {
// TODO: GPUPickerを使いたいが、なぜか一部のメッシュが反応しない
const pickingInfo = this.scene.pick(ev.x, ev.y,
(m) => m.name.includes('__PICK__') || m.metadata?.isPlayer || (m.isVisible && m.isEnabled() && m.metadata?.furnitureId != null && this.furnitureContainers.has(m.metadata.furnitureId)));
if (pickingInfo.pickedMesh != null) {
const playerId = pickingInfo.pickedMesh.metadata.playerId;
if (playerId != null && this.playerContainers.some(c => c.id === playerId)) {
const c = this.playerContainers.find(c => c.id === playerId)!;
this.look(c.root.position);
this.ev('playerPointed', { playerId });
return;
}
}
});
}
private async loadEnvModel() {
const envObj = await BABYLON.ImportMeshAsync('/client-assets/world/lobby/default.glb', this.scene);
envObj.meshes[0].scaling = envObj.meshes[0].scaling.scale(WORLD_SCALE);
envObj.meshes[0].bakeCurrentTransformIntoVertices();
for (const mesh of envObj.meshes) {
if (mesh.name === '__root__') continue;
if (mesh.name.includes('__COLLISION__')) {
mesh.checkCollisions = true;
mesh.isVisible = false;
this.inputs.on('pointer', (ev) => {
if (this.scene.activeCamera === this.camera) {
(this.camera.inputs.attached.manual as FreeCameraManualInput).setRotationVector({ x: ev.x, y: ev.y });
}
if (this.reflectionProbe != null) {
if (mesh.material) (mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.reflectionProbe.cubeTexture;
if (mesh.material) (mesh.material as BABYLON.PBRMaterial).realTimeFiltering = true;
}
}
this.textMaterial = new BABYLON.StandardMaterial('textMaterial', this.scene);
this.textMaterial.diffuseTexture = new BABYLON.Texture('/client-assets/world/chars.png', this.scene, false, false);
this.textMaterial.diffuseTexture.hasAlpha = true;
this.textMaterial.disableLighting = true;
this.textMaterial.transparencyMode = BABYLON.Material.MATERIAL_ALPHABLEND;
this.textMaterial.useAlphaFromDiffuseTexture = true;
this.textMaterial.freeze();
this.translucentTextMaterial = this.textMaterial.clone('translucentTextMaterial');
this.translucentTextMaterial.alpha = 0.25;
{
const objet = envObj.meshes.find(m => m.name.includes('__OBJET__'));
objet.rotation = objet.rotationQuaternion.toEulerAngles();
objet.rotationQuaternion = null;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 5000, value: -(Math.PI * 2) },
]);
objet.animations = [anim];
this.scene.beginAnimation(objet, 0, 5000, true);
}
{
const ring = envObj.meshes.find(m => m.name.includes('__LED_RING__'));
ring.rotation = ring.rotationQuaternion.toEulerAngles();
ring.rotationQuaternion = null;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 5000, value: -(Math.PI * 2) },
]);
ring.animations = [anim];
this.scene.beginAnimation(ring, 0, 5000, true);
}
{
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_OUTER_1__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 256, {
meshFlipped: true,
material: this.textMaterial,
});
text.write('Wellcome to Misskey World!');
//messageRingRoot.rotation.x = Math.PI / 4;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: -(Math.PI * 2) },
]);
messageRing.animations = [anim];
this.scene.beginAnimation(messageRing, 0, 10000, true);
const texts = [
'Wellcome to Misskey World!',
'Enjoy your stay!',
'Feel free to look around!',
'This is a virtual space for Misskey users!',
//'You can chat, play games, and more!',
//'Check out the bulletin board for announcements',
'Have a nice day with Misskey!',
'MAINTENANCE will begin at 9:00 A.M.',
];
let currentTextIndex = 1;
this.timer.setInterval(() => {
const textToShow = texts[currentTextIndex];
currentTextIndex = (currentTextIndex + 1) % texts.length;
text.writeWithAnimation(textToShow);
}, 10000);
}
{
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_OUTER_2__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 256, {
meshFlipped: true,
material: this.translucentTextMaterial,
repeatSeparator: ' ',
});
messageRingRoot.rotation.x = Math.PI / 2;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: -(Math.PI * 2) },
]);
messageRing.animations = [anim];
this.scene.beginAnimation(messageRing, 0, 10000, true);
this.timer.setInterval(() => {
text.write(Date.now().toString());
}, 10);
}
{
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_1__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 64, {
material: this.textMaterial,
repeatSeparator: ' ',
});
//messageRingRoot.rotation.x = Math.PI / 4;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: (Math.PI * 2) },
]);
messageRing.animations = [anim];
this.scene.beginAnimation(messageRing, 0, 10000, true);
this.timer.setInterval(() => {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
text.write(`${hours}:${minutes}:${seconds}`);
}, 1000);
}
{
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_2__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 64, {
material: this.textMaterial,
repeatSeparator: ' ',
});
//messageRingRoot.rotation.x = Math.PI / 4;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: -(Math.PI * 2) },
]);
messageRing.animations = [anim];
this.scene.beginAnimation(messageRing, 0, 10000, true);
this.timer.setInterval(() => {
const now = new Date();
const years = now.getFullYear().toString();
const months = (now.getMonth() + 1).toString().padStart(2, '0');
const days = now.getDate().toString().padStart(2, '0');
text.write(`${years}/${months}/${days}`);
}, 1000);
}
for (let i = 0; i < 16; i++) {
const sphereRoot = new BABYLON.TransformNode('', this.scene);
sphereRoot.position = new BABYLON.Vector3(cm(0), cm(1000 + (100 * i)), cm(0));
const rotation = Math.random() * Math.PI * 2;
const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(randomRange(50, 300)), segments: 16 }, this.scene);
sphere.parent = sphereRoot;
sphere.position = new BABYLON.Vector3(cm(0), cm(0), cm(randomRange(2000, 7000)));
const mat = new BABYLON.PBRMaterial('', this.scene);
const color = tinycolor({ h: Math.random() * 360, s: 1, l: 0.5 }).toRgb();
mat.emissiveColor = new BABYLON.Color3(color.r / 255, color.g / 255, color.b / 255);
mat.disableLighting = true;
this.gl?.addExcludedMesh(sphere);
sphere.material = mat;
const speed = randomRange(5000, 30000);
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: rotation },
{ frame: speed, value: Math.random() < 0.5 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
]);
sphereRoot.animations = [anim];
this.scene.beginAnimation(sphereRoot, 0, speed, true);
if (this.reflectionProbe != null) this.reflectionProbe.renderList.push(sphere);
}
for (let i = 0; i < 64; i++) {
const sphereRoot = new BABYLON.TransformNode('', this.scene);
sphereRoot.position = new BABYLON.Vector3(cm(0), cm(randomRange(-5000, 5000)), cm(0));
const rotation = Math.random() * Math.PI * 2;
const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(randomRange(500, 3000)), segments: 16 }, this.scene);
sphere.parent = sphereRoot;
sphere.position = new BABYLON.Vector3(cm(0), cm(0), cm(randomRange(10000, 15000)));
const mat = new BABYLON.PBRMaterial('', this.scene);
const color = tinycolor({ h: Math.random() * 360, s: randomRange(0, 1), l: randomRange(0.75, 1) }).toRgb();
mat.emissiveColor = new BABYLON.Color3(color.r / 255, color.g / 255, color.b / 255);
mat.disableLighting = true;
this.gl?.addExcludedMesh(sphere);
sphere.material = mat;
const speed = randomRange(10000, 100000);
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: rotation },
{ frame: speed, value: Math.random() < 0.5 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
]);
sphereRoot.animations = [anim];
this.scene.beginAnimation(sphereRoot, 0, speed, true);
if (this.reflectionProbe != null) this.reflectionProbe.renderList.push(sphere);
}
//const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(10) }, this.scene);
const adsCountCol = 4;
const adsCountRow = 2;
for (let j = 0; j < adsCountRow; j++) {
for (let i = 0; i < adsCountCol; i++) {
const adRoot = new BABYLON.TransformNode(`ad_${j}_${i}_root`, this.scene);
adRoot.position = new BABYLON.Vector3(cm(0), cm(500 + (1000 * j)), cm(0));
const rotation = (i / adsCountCol) * Math.PI * 2;
const adMesh = BABYLON.MeshBuilder.CreatePlane(`ad_${j}_${i}`, { width: cm(1000), height: cm(700) }, this.scene);
adMesh.parent = adRoot;
adMesh.position = new BABYLON.Vector3(cm(0), cm(0), cm(7500));
const tex = new BABYLON.Texture('/client-assets/world/lobby/dummy-ads/angry_ai.png', this.scene);
const adMat = new BABYLON.StandardMaterial(`ad_${j}_${i}_mat`, this.scene);
adMat.emissiveTexture = tex;
adMat.disableLighting = true;
adMesh.material = adMat;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: rotation },
{ frame: 15000, value: j % 2 === 0 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
]);
adRoot.animations = [anim];
this.scene.beginAnimation(adRoot, 0, 15000, true);
}
}
const worldRingH = envObj.meshes.find(m => m.name.includes('__WORLD_RING_H__'));
const worldRingM = envObj.meshes.find(m => m.name.includes('__WORLD_RING_M__'));
worldRingH.material.reflectionTexture = null;
worldRingM.material.reflectionTexture = null;
if (this.reflectionProbe != null) this.reflectionProbe.renderList.push(worldRingH);
if (this.reflectionProbe != null) this.reflectionProbe.renderList.push(worldRingM);
worldRingH.rotation = worldRingH.rotationQuaternion.toEulerAngles();
worldRingM.rotation = worldRingM.rotationQuaternion.toEulerAngles();
worldRingH.rotationQuaternion = null;
worldRingM.rotationQuaternion = null;
const _1h = 1000 * 60 * 60;
const _12h = _1h * 12;
const _7days = _1h * 24 * 7;
const _30days = _1h * 24 * 30;
});
this.timer.setInterval(() => {
const time = Date.now();
worldRingH.rotation.x = ((time % _12h) / _12h) * Math.PI * 2;
worldRingM.rotation.y = -(((time % _1h) / _1h) * Math.PI);
const camera = this.scene.activeCamera!;
const myPos = camera.globalPosition;
const myRotation = camera.absoluteRotation.toEulerAngles();
this.ev('changeMyPlayerState', {
position: [myPos.x, myPos.y, myPos.z],
rotation: [myRotation.x, myRotation.y, myRotation.z],
});
}, 100);
const screenMeshes = envObj.meshes.filter(m => m.name.includes('__SCREEN__'));
const screenMaterial = screenMeshes[0].material as BABYLON.PBRMaterial;
this.inited = true;
}
const videoEl = document.createElement('video');
videoEl.crossOrigin = 'anonymous';
private async loadEnv() {
const envManager = new LobbyEnvManager(this);
const hls = new Hls();
hls.loadSource('https://tvs.misskey.io/official/hq-beta/ts:abr.m3u8');
hls.attachMedia(videoEl);
await envManager.load();
envManager.setTime(this.time);
this.timer.setTimeout(() => {
const tex = new BABYLON.VideoTexture('', videoEl, this.scene, true, true);
tex.level = 0.5;
tex.video.loop = true;
tex.video.volume = 0.25;
tex.video.muted = true;
screenMaterial.albedoColor = new BABYLON.Color3(0, 0, 0);
screenMaterial.emissiveTexture = tex;
screenMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
tex.onLoadObservable.addOnce(() => {
tex.video.play();
for (const mesh of screenMeshes) {
if (mesh instanceof BABYLON.InstancedMesh) continue;
//normalizeUvToSquare(mesh);
const updateUv = createPlaneUvMapper(mesh);
if (tex == null) return;
const srcAspect = tex.getSize().width / tex.getSize().height;
const targetAspect = 16 / 9;
updateUv(srcAspect, targetAspect, 'cover');
for (const mat of this.scene.materials) {
mat.unfreeze();
if (mat instanceof BABYLON.MultiMaterial) {
for (const subMat of mat.subMaterials) {
if (subMat.metadata?.useEnvMap) subMat.reflectionTexture = envManager.envMapIndoor;
}
});
}, 3000);
} else {
if (mat.metadata?.useEnvMap) mat.reflectionTexture = envManager.envMapIndoor;
}
}
const emitter = new BABYLON.TransformNode('emitter', this.scene);
emitter.position = new BABYLON.Vector3(0, cm(-1000), 0);
const ps = new BABYLON.ParticleSystem('', 128, this.scene);
ps.particleTexture = new BABYLON.Texture('/client-assets/world/objects/lava-lamp/bubble.png');
ps.emitter = emitter;
ps.isLocal = true;
ps.minEmitBox = new BABYLON.Vector3(cm(-1000), 0, cm(-1000));
ps.maxEmitBox = new BABYLON.Vector3(cm(1000), 0, cm(1000));
ps.minEmitPower = cm(100);
ps.maxEmitPower = cm(500);
ps.minLifeTime = 30;
ps.maxLifeTime = 30;
ps.minSize = cm(30);
ps.maxSize = cm(300);
ps.direction1 = new BABYLON.Vector3(0, 1, 0);
ps.direction2 = new BABYLON.Vector3(0, 1, 0);
ps.emitRate = 1.5;
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
ps.preWarmCycles = Math.random() * 1000;
ps.start();
this.envManager = envManager;
this.camera.maxZ = this.envManager.maxCameraZ;
}
public sitChair(furnitureId: string) {
this.isSitting = true;
this.fixedCamera.parent = this.objectMeshs.get(furnitureId);
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.selectFurniture(null);
public cameraMove(vector: { x: number; y: number; }, dash: boolean) {
(this.camera.inputs.attached.manual as FreeCameraManualInput).setMoveVector(dash ? { x: vector.x * 3, y: vector.y * 3 } : vector);
}
public standUp() {
this.isSitting = false;
this.scene.activeCamera = this.camera;
this.fixedCamera.parent = null;
public cameraJoystickMove(vector: { x: number; y: number; }) {
(this.camera.inputs.attached.manual as FreeCameraManualInput).setMoveVector(vector);
}
private playSfxUrl(url: string, options: { volume: number; playbackRate: number }) {
this.emit('playSfxUrl', { url, options });
}
public clearPlayers() {
this.sr.disableSnapshotRendering();
for (const playerContainer of this.playerContainers) {
playerContainer.destroy();
}
this.sr.enableSnapshotRendering();
this.playerContainers = [];
}
public updateAvatarDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean }) {
this.showUsernameOnAvatar = options.showUsername;
this.show2dAvatarOnAvatar = options.show2dAvatar;
this.sr.disableSnapshotRendering();
for (const playerContainer of this.playerContainers) {
playerContainer.updateUserInfoDisplayOptions(options);
}
this.sr.enableSnapshotRendering();
}
public resize() {
this.engine.resize(true);
}
@@ -561,37 +264,6 @@ export class WorldEngine extends EngineBase<{
public destroy() {
super.destroy();
this.timer.dispose();
}
}
class MessageRing {
constructor(mesh: BABYLON.Mesh, scene: BABYLON.Scene, options: { material: BABYLON.StandardMaterial; repeatSeparator: string; }) {
const messageRingRoot = new BABYLON.TransformNode('', this.scene);
const messageRing = envObj.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_1__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 64, {
material: this.textMaterial,
repeatSeparator: ' ',
});
//messageRingRoot.rotation.x = Math.PI / 4;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: (Math.PI * 2) },
]);
messageRing.animations = [anim];
this.scene.beginAnimation(messageRing, 0, 10000, true);
this.timer.setInterval(() => {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
text.write(`${hours}:${minutes}:${seconds}`);
}, 1000);
this.envManager.dispose();
}
}
@@ -0,0 +1,109 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from './utility.js';
import type { WorldEngine } from './engine.js';
export abstract class WorldEnvManager {
protected engine: WorldEngine;
public abstract envMapIndoor: BABYLON.CubeTexture | null;
public abstract maxCameraZ: number;
private shadowGenerators: BABYLON.ShadowGenerator[] = [];
constructor(engine: WorldEngine) {
this.engine = engine;
}
abstract load(): Promise<void>;
abstract setTime(time: number): void;
protected registerShadowGenerator(shadowGenerator: BABYLON.ShadowGenerator) {
this.shadowGenerators.push(shadowGenerator);
const shadowMap = shadowGenerator.getShadowMap()!;
shadowMap.refreshRate = BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
// https://forum.babylonjs.com/t/is-it-intentional-that-the-shadow-map-refresh-rate-is-ignored-under-fast-snapshot-rendering/63523
const objectRenderer = shadowMap._objectRenderer;
const originalShouldRender = objectRenderer.shouldRender.bind(objectRenderer);
objectRenderer.shouldRender = function () {
if (this._engine.snapshotRendering) {
return this.refreshRate !== BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
}
return originalShouldRender();
};
}
public addShadowCaster(mesh: BABYLON.AbstractMesh) {
for (const shadowGen of this.shadowGenerators) {
shadowGen.addShadowCaster(mesh);
}
}
public removeShadowCaster(mesh: BABYLON.AbstractMesh) {
for (const shadowGen of this.shadowGenerators) {
shadowGen.removeShadowCaster(mesh);
}
}
public async renderShadow() {
this.engine.sr.disableSnapshotRendering();
for (const shadowGen of this.shadowGenerators) {
const shadowMap = shadowGen.getShadowMap()!;
shadowMap.refreshRate = 1;
}
await new Promise(resolve => setTimeout(resolve, 1));
for (const shadowGen of this.shadowGenerators) {
const shadowMap = shadowGen.getShadowMap()!;
shadowMap.refreshRate = BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
}
this.engine.sr.enableSnapshotRendering();
}
protected registerMeshes(meshes: BABYLON.AbstractMesh[]) {
for (const mesh of meshes) {
if (!this.engine.scene.meshes.includes(mesh)) this.engine.scene.addMesh(mesh);
if (['__COLLISION__'].some(name => mesh.name.includes(name))) {
mesh.isPickable = false;
mesh.receiveShadows = false;
mesh.isVisible = false;
mesh.checkCollisions = false;
if (mesh.name.includes('__COLLISION__')) {
mesh.checkCollisions = true;
}
continue;
}
mesh.isPickable = false;
mesh.checkCollisions = false;
if (mesh.material != null) {
(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 (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
subMat.reflectionTexture = this.envMapIndoor;
}
} else if (mesh.material instanceof BABYLON.PBRMaterial) {
mesh.material.reflectionTexture = this.envMapIndoor;
}
}
}
}
public dispose() {
for (const shadowGen of this.shadowGenerators) {
shadowGen.dispose();
}
}
}
@@ -0,0 +1,471 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import tinycolor from 'tinycolor2';
import Hls from 'hls.js';
import { findMaterial, GRAPHICS_QUALITY, Timer } from '../utility.js';
import { WorldEnvManager } from '../env.js';
import { RecyvlingTextGrid, createPlaneUvMapper, randomRange } from '../utility.js';
import type { WorldEngine } from '../engine.js';
export class LobbyEnvManager extends WorldEnvManager {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private skybox: BABYLON.Mesh | null = null;
private skyboxMat: BABYLON.StandardMaterial | null = null;
private sunLight: BABYLON.DirectionalLight | null = null;
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(100000);
private textMaterial: BABYLON.StandardMaterial | null = null;
private translucentTextMaterial: BABYLON.StandardMaterial | null = null;
private timer: Timer = new Timer();
constructor(engine: WorldEngine) {
super(engine);
}
public async load() {
this.engine.camera.position = new BABYLON.Vector3(cm(0), cm(250), cm(3000));
this.skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(30000) }, this.engine.scene);
this.skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.engine.scene);
this.skyboxMat.backFaceCulling = false;
this.skyboxMat.disableLighting = true;
this.skybox.material = this.skyboxMat;
this.skybox.infiniteDistance = true;
const ambientLight1 = new BABYLON.HemisphericLight('ambientLight1', new BABYLON.Vector3(0, 1, 0), this.engine.scene);
ambientLight1.diffuse = new BABYLON.Color3(1.0, 0.9, 0.8);
ambientLight1.intensity = 1;
const ambientLight2 = new BABYLON.HemisphericLight('ambientLight2', new BABYLON.Vector3(0, -1, 0), this.engine.scene);
ambientLight2.diffuse = new BABYLON.Color3(0.8, 0.9, 1.0);
ambientLight2.intensity = 1;
//ambientLight.intensity = 0;
this.sunLight = new BABYLON.DirectionalLight('sunLight', new BABYLON.Vector3(0, -1, 0), this.engine.scene);
this.sunLight.position = new BABYLON.Vector3(cm(0), cm(10000), cm(0));
this.sunLight.shadowMinZ = cm(1000);
this.sunLight.shadowMaxZ = cm(2000);
this.loaderResult = await BABYLON.ImportMeshAsync('/client-assets/world/envs/lobby/lobby.glb', this.engine.scene);
//this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
//this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
this.meshes = this.loaderResult.meshes;
this.meshes[0].scaling = this.meshes[0].scaling.scale(WORLD_SCALE);
this.meshes[0].rotationQuaternion = null;
this.meshes[0].rotation = new BABYLON.Vector3(0, 0, 0);
for (const mesh of this.meshes) {
if (['__COLLISION__'].some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
this.addShadowCaster(mesh);
}
this.textMaterial = new BABYLON.StandardMaterial('textMaterial', this.engine.scene);
this.textMaterial.diffuseTexture = new BABYLON.Texture('/client-assets/world/chars.png', this.engine.scene, false, false);
this.textMaterial.diffuseTexture.hasAlpha = true;
this.textMaterial.disableLighting = true;
this.textMaterial.transparencyMode = BABYLON.Material.MATERIAL_ALPHABLEND;
this.textMaterial.useAlphaFromDiffuseTexture = true;
this.textMaterial.freeze();
this.translucentTextMaterial = this.textMaterial.clone('translucentTextMaterial');
this.translucentTextMaterial.alpha = 0.25;
{
const objet = this.meshes.find(m => m.name.includes('__OBJET__'));
objet.rotation = objet.rotationQuaternion.toEulerAngles();
objet.rotationQuaternion = null;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 5000, value: -(Math.PI * 2) },
]);
objet.animations = [anim];
this.engine.scene.beginAnimation(objet, 0, 5000, true);
}
{
const ring = this.meshes.find(m => m.name.includes('__LED_RING__'));
ring.rotation = ring.rotationQuaternion.toEulerAngles();
ring.rotationQuaternion = null;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 5000, value: -(Math.PI * 2) },
]);
ring.animations = [anim];
this.engine.scene.beginAnimation(ring, 0, 5000, true);
}
{
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
const messageRing = this.meshes.find(m => m.name.includes('__MESSAGE_RING_OUTER_1__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 256, {
meshFlipped: true,
material: this.textMaterial,
});
text.write('Wellcome to Misskey World!');
//messageRingRoot.rotation.x = Math.PI / 4;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: -(Math.PI * 2) },
]);
messageRing.animations = [anim];
this.engine.scene.beginAnimation(messageRing, 0, 10000, true);
const texts = [
'Wellcome to Misskey World!',
'Enjoy your stay!',
'Feel free to look around!',
'This is a virtual space for Misskey users!',
//'You can chat, play games, and more!',
//'Check out the bulletin board for announcements',
'Have a nice day with Misskey!',
'MAINTENANCE will begin at 9:00 A.M.',
];
let currentTextIndex = 1;
this.timer.setInterval(() => {
const textToShow = texts[currentTextIndex];
currentTextIndex = (currentTextIndex + 1) % texts.length;
text.writeWithAnimation(textToShow);
}, 10000);
}
{
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
const messageRing = this.meshes.find(m => m.name.includes('__MESSAGE_RING_OUTER_2__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 256, {
meshFlipped: true,
material: this.translucentTextMaterial,
repeatSeparator: ' ',
});
messageRingRoot.rotation.x = Math.PI / 2;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: -(Math.PI * 2) },
]);
messageRing.animations = [anim];
this.engine.scene.beginAnimation(messageRing, 0, 10000, true);
this.timer.setInterval(() => {
text.write(Date.now().toString());
}, 10);
}
{
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
const messageRing = this.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_1__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 64, {
material: this.textMaterial,
repeatSeparator: ' ',
});
//messageRingRoot.rotation.x = Math.PI / 4;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: (Math.PI * 2) },
]);
messageRing.animations = [anim];
this.engine.scene.beginAnimation(messageRing, 0, 10000, true);
this.timer.setInterval(() => {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
text.write(`${hours}:${minutes}:${seconds}`);
}, 1000);
}
{
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
const messageRing = this.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_2__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 64, {
material: this.textMaterial,
repeatSeparator: ' ',
});
//messageRingRoot.rotation.x = Math.PI / 4;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: -(Math.PI * 2) },
]);
messageRing.animations = [anim];
this.engine.scene.beginAnimation(messageRing, 0, 10000, true);
this.timer.setInterval(() => {
const now = new Date();
const years = now.getFullYear().toString();
const months = (now.getMonth() + 1).toString().padStart(2, '0');
const days = now.getDate().toString().padStart(2, '0');
text.write(`${years}/${months}/${days}`);
}, 1000);
}
for (let i = 0; i < 16; i++) {
const sphereRoot = new BABYLON.TransformNode('', this.engine.scene);
sphereRoot.position = new BABYLON.Vector3(cm(0), cm(1000 + (100 * i)), cm(0));
const rotation = Math.random() * Math.PI * 2;
const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(randomRange(50, 300)), segments: 16 }, this.engine.scene);
sphere.parent = sphereRoot;
sphere.position = new BABYLON.Vector3(cm(0), cm(0), cm(randomRange(2000, 7000)));
const mat = new BABYLON.PBRMaterial('', this.engine.scene);
const color = tinycolor({ h: Math.random() * 360, s: 1, l: 0.5 }).toRgb();
mat.emissiveColor = new BABYLON.Color3(color.r / 255, color.g / 255, color.b / 255);
mat.disableLighting = true;
this.engine.gl?.addExcludedMesh(sphere);
sphere.material = mat;
const speed = randomRange(5000, 30000);
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: rotation },
{ frame: speed, value: Math.random() < 0.5 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
]);
sphereRoot.animations = [anim];
this.engine.scene.beginAnimation(sphereRoot, 0, speed, true);
}
for (let i = 0; i < 64; i++) {
const sphereRoot = new BABYLON.TransformNode('', this.engine.scene);
sphereRoot.position = new BABYLON.Vector3(cm(0), cm(randomRange(-5000, 5000)), cm(0));
const rotation = Math.random() * Math.PI * 2;
const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(randomRange(500, 3000)), segments: 16 }, this.engine.scene);
sphere.parent = sphereRoot;
sphere.position = new BABYLON.Vector3(cm(0), cm(0), cm(randomRange(10000, 15000)));
const mat = new BABYLON.PBRMaterial('', this.engine.scene);
const color = tinycolor({ h: Math.random() * 360, s: randomRange(0, 1), l: randomRange(0.75, 1) }).toRgb();
mat.emissiveColor = new BABYLON.Color3(color.r / 255, color.g / 255, color.b / 255);
mat.disableLighting = true;
this.engine.gl?.addExcludedMesh(sphere);
sphere.material = mat;
const speed = randomRange(10000, 100000);
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: rotation },
{ frame: speed, value: Math.random() < 0.5 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
]);
sphereRoot.animations = [anim];
this.engine.scene.beginAnimation(sphereRoot, 0, speed, true);
}
//const sphere = BABYLON.MeshBuilder.CreateSphere('', { diameter: cm(10) }, this.engine.scene);
const adsCountCol = 4;
const adsCountRow = 2;
for (let j = 0; j < adsCountRow; j++) {
for (let i = 0; i < adsCountCol; i++) {
const adRoot = new BABYLON.TransformNode(`ad_${j}_${i}_root`, this.engine.scene);
adRoot.position = new BABYLON.Vector3(cm(0), cm(500 + (1000 * j)), cm(0));
const rotation = (i / adsCountCol) * Math.PI * 2;
const adMesh = BABYLON.MeshBuilder.CreatePlane(`ad_${j}_${i}`, { width: cm(1000), height: cm(700) }, this.engine.scene);
adMesh.parent = adRoot;
adMesh.position = new BABYLON.Vector3(cm(0), cm(0), cm(7500));
const tex = new BABYLON.Texture('/client-assets/world/lobby/dummy-ads/angry_ai.png', this.engine.scene);
const adMat = new BABYLON.StandardMaterial(`ad_${j}_${i}_mat`, this.engine.scene);
adMat.emissiveTexture = tex;
adMat.disableLighting = true;
adMesh.material = adMat;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: rotation },
{ frame: 15000, value: j % 2 === 0 ? rotation + (Math.PI * 2) : rotation - (Math.PI * 2) },
]);
adRoot.animations = [anim];
this.engine.scene.beginAnimation(adRoot, 0, 15000, true);
}
}
const worldRingH = this.meshes.find(m => m.name.includes('__WORLD_RING_H__'));
const worldRingM = this.meshes.find(m => m.name.includes('__WORLD_RING_M__'));
worldRingH.material.reflectionTexture = null;
worldRingM.material.reflectionTexture = null;
worldRingH.rotation = worldRingH.rotationQuaternion.toEulerAngles();
worldRingM.rotation = worldRingM.rotationQuaternion.toEulerAngles();
worldRingH.rotationQuaternion = null;
worldRingM.rotationQuaternion = null;
const _1h = 1000 * 60 * 60;
const _12h = _1h * 12;
const _7days = _1h * 24 * 7;
const _30days = _1h * 24 * 30;
this.timer.setInterval(() => {
const time = Date.now();
worldRingH.rotation.x = ((time % _12h) / _12h) * Math.PI * 2;
worldRingM.rotation.y = -(((time % _1h) / _1h) * Math.PI);
}, 100);
const screenMeshes = this.meshes.filter(m => m.name.includes('__SCREEN__'));
const screenMaterial = screenMeshes[0].material as BABYLON.PBRMaterial;
const videoEl = document.createElement('video');
videoEl.crossOrigin = 'anonymous';
const hls = new Hls();
hls.loadSource('https://tvs.misskey.io/official/hq-beta/ts:abr.m3u8');
hls.attachMedia(videoEl);
this.timer.setTimeout(() => {
const tex = new BABYLON.VideoTexture('', videoEl, this.engine.scene, true, true);
tex.level = 0.5;
tex.video.loop = true;
tex.video.volume = 0.25;
tex.video.muted = true;
screenMaterial.albedoColor = new BABYLON.Color3(0, 0, 0);
screenMaterial.emissiveTexture = tex;
screenMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
tex.onLoadObservable.addOnce(() => {
tex.video.play();
for (const mesh of screenMeshes) {
if (mesh instanceof BABYLON.InstancedMesh) continue;
//normalizeUvToSquare(mesh);
const updateUv = createPlaneUvMapper(mesh);
if (tex == null) return;
const srcAspect = tex.getSize().width / tex.getSize().height;
const targetAspect = 16 / 9;
updateUv(srcAspect, targetAspect, 'cover');
}
});
}, 3000);
const emitter = new BABYLON.TransformNode('emitter', this.engine.scene);
emitter.position = new BABYLON.Vector3(0, cm(-1000), 0);
const ps = new BABYLON.ParticleSystem('', 128, this.engine.scene);
ps.particleTexture = new BABYLON.Texture('/client-assets/world/objects/lava-lamp/bubble.png');
ps.emitter = emitter;
ps.isLocal = true;
ps.minEmitBox = new BABYLON.Vector3(cm(-1000), 0, cm(-1000));
ps.maxEmitBox = new BABYLON.Vector3(cm(1000), 0, cm(1000));
ps.minEmitPower = cm(100);
ps.maxEmitPower = cm(500);
ps.minLifeTime = 30;
ps.maxLifeTime = 30;
ps.minSize = cm(30);
ps.maxSize = cm(300);
ps.direction1 = new BABYLON.Vector3(0, 1, 0);
ps.direction2 = new BABYLON.Vector3(0, 1, 0);
ps.emitRate = 1.5;
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
ps.preWarmCycles = Math.random() * 1000;
ps.start();
this.registerMeshes(this.meshes);
}
public setTime(time: number) {
if (this.skyboxMat == null) return;
if (time === 0) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.9, 1.0);
} else if (time === 1) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.8, 0.5, 0.3);
} else {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.05, 0.05, 0.2);
}
if (this.sunLight != null) {
this.sunLight.diffuse = time === 0 ? new BABYLON.Color3(1.0, 0.9, 0.8) : time === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
this.sunLight.intensity = time === 0 ? 3 : time === 1 ? 1 : 0.25;
}
}
public dispose() {
this.timer.dispose();
for (const m of this.meshes) {
m.dispose(false, true);
}
this.skybox?.dispose();
this.skyboxMat?.dispose();
this.envMapIndoor?.dispose();
this.sunLight?.dispose();
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
super.dispose();
}
}
class MessageRing {
constructor(mesh: BABYLON.Mesh, scene: BABYLON.Scene, options: { material: BABYLON.StandardMaterial; repeatSeparator: string; }) {
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
const messageRing = this.meshes.find(m => m.name.includes('__MESSAGE_RING_INNER_1__'));
messageRing.parent = messageRingRoot;
messageRing.rotation = messageRing.rotationQuaternion.toEulerAngles();
messageRing.rotationQuaternion = null;
const text = new RecyvlingTextGrid(messageRing, 64, {
material: this.textMaterial,
repeatSeparator: ' ',
});
//messageRingRoot.rotation.x = Math.PI / 4;
const anim = new BABYLON.Animation('', 'rotation.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: 0 },
{ frame: 10000, value: (Math.PI * 2) },
]);
messageRing.animations = [anim];
this.engine.scene.beginAnimation(messageRing, 0, 10000, true);
this.timer.setInterval(() => {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
text.write(`${hours}:${minutes}:${seconds}`);
}, 1000);
}
}
@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { registerBabylonRuntime } from './babylonRuntime.js';
import { WorldEngine } from './engine.js';
registerBabylonRuntime();
export async function createWorldEngine(params: {
canvas: HTMLCanvasElement;
options: {
antialias: boolean;
resolution: number;
fov: number;
graphicsQuality: number;
fps: number | null;
useVirtualJoystick?: boolean;
showUsernameOnAvatar: boolean;
show2dAvatarOnAvatar: boolean;
};
}) {
const babylonEngine = new BABYLON.WebGPUEngine(params.canvas, { doNotHandleContextLost: true, powerPreference: 'high-performance', antialias: params.options.antialias });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
await babylonEngine.initAsync();
if (params.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
if (params.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
const engine = new WorldEngine({
engine: babylonEngine,
...params.options,
});
return engine;
}
@@ -34,7 +34,7 @@ import { CustomMadoriEnvManager } from './envs/customMadori.js';
import { FurnitureContainer } from './FurnitureContainer.js';
import type { FurnitureDef } from './furniture.js';
import type { GridMaterial } from '@babylonjs/materials';
import type { EnvManager } from './env.js';
import type { RoomEnvManager } from './env.js';
import type { RoomState_InstalledFurniture } from 'misskey-world/src/room/furniture.js';
import type { RoomAttachments, RoomState } from 'misskey-world/src/room/type.js';
import type { RawOptions } from 'misskey-world/src/mono.js';
@@ -97,7 +97,7 @@ export class RoomEngine extends EngineBase<{
private fov: number;
private fixedCamera: BABYLON.FreeCamera;
public furnitureContainers: Map<string, FurnitureContainer> = new Map();
private envManager: EnvManager | null = null;
private envManager: RoomEnvManager | null = null;
// TODO: たぶんオブジェクト内の値のmutateはsetで検知できないので、そのような操作を実際に行うようになった & それを検知する必要性が出てきたら専用の設定関数などを新設してそれを使わせる
private _grabbingCtx: {
@@ -561,7 +561,7 @@ export class RoomEngine extends EngineBase<{
this.pauseRender();
}
let envManager: EnvManager;
let envManager: RoomEnvManager;
if (this.roomState.env.type === 'simple') {
envManager = new SimpleEnvManager(this);
@@ -5,12 +5,10 @@
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from '../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from './utility.js';
import type { RoomEngine } from './engine.js';
export abstract class EnvManager<T = any> {
export abstract class RoomEnvManager<T = any> {
protected engine: RoomEngine;
public abstract envMapIndoor: BABYLON.CubeTexture | null;
public abstract maxCameraZ: number;
@@ -7,11 +7,11 @@ import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { EnvManager } from '../env.js';
import { RoomEnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { CustomMadoriEnvOptions } from 'misskey-world/src/room/env.js';
export class CustomMadoriEnvManager extends EnvManager<CustomMadoriEnvOptions> {
export class CustomMadoriEnvManager extends RoomEnvManager<CustomMadoriEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private rootNode: BABYLON.TransformNode;
@@ -7,11 +7,11 @@ import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { EnvManager } from '../env.js';
import { RoomEnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { JapaneseEnvOptions } from 'misskey-world/src/room/env.js';
export class JapaneseEnvManager extends EnvManager<JapaneseEnvOptions> {
export class JapaneseEnvManager extends RoomEnvManager<JapaneseEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private skybox: BABYLON.Mesh | null = null;
@@ -7,11 +7,11 @@ import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { EnvManager } from '../env.js';
import { RoomEnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { MuseumEnvOptions } from 'misskey-world/src/room/env.js';
export class MuseumEnvManager extends EnvManager<MuseumEnvOptions> {
export class MuseumEnvManager extends RoomEnvManager<MuseumEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private roomLight: BABYLON.DirectionalLight | null = null;
@@ -7,13 +7,13 @@ import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY, treeClone } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { EnvManager } from '../env.js';
import { RoomEnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { SimpleEnvOptions } from 'misskey-world/src/room/env.js';
// TODO: マテリアルは必要になるまで作成しないようにする
export class SimpleEnvManager extends EnvManager<SimpleEnvOptions> {
export class SimpleEnvManager extends RoomEnvManager<SimpleEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private rootNode: BABYLON.TransformNode;
private wallRoots: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.TransformNode>;
@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { registerBabylonRuntime } from './babylonRuntime.js';
import { WorldEngine } from './engine.js';
registerBabylonRuntime();
let engine: WorldEngine | null = null;
let canvas: OffscreenCanvas | null = null;
// TODO: 他のWorkerと実装を共通化
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: 'high-performance', antialias: event.data.options.antialias });
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 WorldEngine({
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 'input:zoom': {
if (engine == null) break;
engine.inputs.emit('zoom', event.data.ev);
break;
}
case 'input:pointer': {
if (engine == null) break;
engine.inputs.emit('pointer', event.data.ev);
break;
}
case 'call': {
if (engine == null) {
console.error('Failed to call: Engine is not initialized yet!!!');
break;
}
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) {
console.error('Failed to set: Engine is not initialized yet!!!');
break;
}
engine[event.data.key] = event.data.value;
break;
}
default: {
console.warn('Unrecognized message type:', event.data?.type);
}
}
};

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

@@ -189,7 +189,7 @@ import { prefer } from '@/preferences.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { miLocalStorage } from '@/local-storage.js';
import { FURNITURE_UI_DEFS } from '@/world/room/furniture-ui-defs.js';
import { Multiplayer } from '@/world/room/multiplayer.js';
import { RoomMultiplayer } from '@/world/room/multiplayer.js';
import { $i } from '@/i.js';
import { userPage } from '@/filters/user.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
@@ -340,7 +340,7 @@ const roomControllerOptions = computed<RoomControllerOptions>(() => ({
}));
const controller = markRaw(new RoomController(deepClone(initialRoomState), roomControllerOptions.value));
const multiplayer = markRaw(new Multiplayer(props.room.id, controller));
const multiplayer = markRaw(new RoomMultiplayer(props.room.id, controller));
watch(controller.roomState, () => {
controller.roomState.value.worldScale = WORLD_SCALE;
+322 -108
View File
@@ -4,36 +4,73 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="[$style.screen, { [$style.zen]: isZenMode }]">
<canvas ref="canvas" :class="$style.canvas" tabindex="-1" :style="{ visibility: controller.isReady.value ? 'visible' : 'hidden' }"></canvas>
<div :class="[$style.screen, { [$style.zen]: false }]">
<canvas ref="canvas" :key="canvasKey" :class="$style.canvas" tabindex="-1"></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">
<MkProgressBar :class="$style.progressBar" :progress="controller.initializeProgress.value" :waiting="controller.initializeProgress.value === 1"/>
</div>
</Transition>
<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">
<MkProgressBar :class="$style.progressBar" :progress="controller.initializeProgress.value" :waiting="controller.initializeProgress.value === 1"/>
</div>
</Transition>
<template v-if="!isZenMode">
<div v-if="controller.isReady.value" class="_buttonsCenter" :class="$style.overlayControls">
<div :class="$style.overlayTop">
<div :class="$style.topMain">
<div :class="$style.topMenu">
<div :class="$style.topMenuRow">
<template v-if="isNarrow">
<button v-if="isMenuShowing" v-tooltip.noDelay="i18n.ts.menu" :class="$style.floatingButton" class="_button" style="color: var(--MI_THEME-accent)" @click="isMenuShowing = false"><i class="ti ti-menu"></i></button>
<button v-if="!isMenuShowing" v-tooltip.noDelay="i18n.ts.menu" :class="$style.floatingButton" class="_button" @click="isMenuShowing = true"><i class="ti ti-menu"></i></button>
</template>
<template v-if="isMenuShowing">
<template v-if="controller.isReady.value">
<button v-if="multiplayer.isOnline.value" v-tooltip.noDelay="i18n.ts._miWorld.onlineMenu" :class="$style.floatingButton" class="_button" style="color: var(--MI_THEME-accent)" @click="showOnlineMenu"><i class="ti ti-world"></i></button>
<button v-if="!multiplayer.isOnline.value" v-tooltip.noDelay="i18n.ts._miWorld.onlineMenu" :class="$style.floatingButton" class="_button" @click="showOnlineMenu"><i class="ti ti-world"></i></button>
<button v-tooltip.noDelay="i18n.ts._miWorld.character" :class="$style.floatingButton" class="_button" @click="showCharacterMenu"><i class="ti ti-man"></i></button>
<button v-tooltip.noDelay="i18n.ts._miWorld.takeScreenShot" :class="$style.floatingButton" class="_button" @click="takeScreenshot"><i class="ti ti-camera"></i></button>
</template>
<button v-tooltip.noDelay="i18n.ts.other" :class="$style.floatingButton" class="_button" @click="showOtherMenu"><i class="ti ti-dots"></i></button>
</template>
</div>
</div>
</template>
</div>
</div>
<template v-if="!isZenMode">
<div v-if="controller.isReady.value" class="_buttons" :class="$style.controls">
<div :class="$style.overlayBottom">
<MkVirtualJoystick v-if="useVirtualJoystick && controller.isReady.value" :class="$style.joystick" @update="v => controller.setCameraJoystickMoveVector(v)"/>
</div>
<XOverlayPanel v-if="isPlayerInfoOpen && pointedPlayerInfo != null" :isNarrow="isNarrow" :title="pointedPlayerInfo.user != null ? (pointedPlayerInfo.user?.name ?? pointedPlayerInfo.user?.username) : '(anonymous)'" @close="isPlayerInfoOpen = false">
<template #icon>
<i class="ti ti-user"></i>
</template>
<div v-if="pointedPlayerInfo.user != null">
<MkA :to="`/@${pointedPlayerInfo.user.username}`" :behavior="'window'">
<img v-if="pointedPlayerInfo.user.avatarUrl" :class="$style.pointedPlayerInfoAvatar" :src="pointedPlayerInfo.user.avatarUrl" decoding="async"/>
<span>@{{ pointedPlayerInfo.user.username }}</span>
</MkA>
</div>
</template>
<div v-else>anonymous</div>
</XOverlayPanel>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { computed, defineAsyncComponent, markRaw, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { GRAPHICS_QUALITY } from 'misskey-world-engine/src/utility.js';
import { useInterval } from '@@/js/use-interval.js';
import XOverlayPanel from './rooms/OverlayPanel.vue';
import type { WorldEngineControllerOptions } from '@/world/controller.js';
import type { PlayerProfile } from 'misskey-world-engine/src/PlayerContainer.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i';
@@ -44,38 +81,261 @@ import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import { WorldEngineController } from '@/world/controller.js';
import { prefer } from '@/preferences.js';
import { isTouchUsing } from '@/utility/touch.js';
import { deviceKind } from '@/utility/device-kind.js';
import MkProgressBar from '@/components/MkProgressBar.vue';
import { RoomMultiplayer } from '@/world/room/multiplayer.js';
import { $i } from '@/i.js';
import { userPage } from '@/filters/user.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkVirtualJoystick from '@/components/MkVirtualJoystick.vue';
const canvasKey = ref(0); // 一度ワーカーに渡したcanvasは再利用できないため作り直すためのkey
const canvas = useTemplateRef('canvas');
const interacions = shallowRef<{
id: string;
label: string;
isPrimary: boolean;
fn: () => void;
}[]>([]);
const isNarrow = deviceKind === 'smartphone';
const graphicsQualityRaw = prefer.model('world.graphicsQuality');
const graphicsQualityAutoValue = computed<number>(() => deviceKind !== 'desktop' ? GRAPHICS_QUALITY.LOW : GRAPHICS_QUALITY.MEDIUM);
const graphicsQuality = computed<number>(() => graphicsQualityRaw.value ?? graphicsQualityAutoValue.value);
const fpsRaw = prefer.model('world.fps');
const fpsAutoValue = computed<number | null>(() => deviceKind !== 'desktop' ? 30 : 60);
const fps = computed<number | null>(() =>
fpsRaw.value == null ? fpsAutoValue.value :
fpsRaw.value === 'max' ? null :
fpsRaw.value === '120' ? 120 :
fpsRaw.value === '60' ? 60 :
30);
const resolutionRaw = prefer.model('world.resolution');
const resolutionAutoValue = computed<number>(() => deviceKind !== 'desktop' ? 0.5 : 1);
const resolution = computed<number>(() => resolutionRaw.value ?? resolutionAutoValue.value);
const antialias = prefer.model('world.antialias');
const showUsernameOnAvatar = prefer.model('world.showUsernameOnAvatar');
const show2dAvatarOnAvatar = prefer.model('world.show2dAvatarOnAvatar');
const useVirtualJoystick = isTouchUsing && (deviceKind === 'smartphone' || deviceKind === 'tablet');
const worldControllerOptions = computed<WorldEngineControllerOptions>(() => ({
graphicsQuality: graphicsQuality.value,
fps: fps.value,
resolution: resolution.value,
antialias: antialias.value,
useVirtualJoystick,
fov: prefer.s['world.fov'],
workerMode: prefer.s['world.separateRenderingThread'],
showUsernameOnAvatar: showUsernameOnAvatar.value,
show2dAvatarOnAvatar: show2dAvatarOnAvatar.value,
}));
const controller = markRaw(new WorldEngineController(worldControllerOptions.value));
const multiplayer = markRaw(new RoomMultiplayer(0, controller));
const pointedPlayerInfo = ref<PlayerProfile | null>(null);
const isMenuShowing = ref(!isNarrow);
const isPlayerInfoOpen = ref(false);
watch([graphicsQuality, fps, resolution, antialias], () => {
refresh();
});
watch([showUsernameOnAvatar, show2dAvatarOnAvatar], () => {
controller.updateAvatarDisplayOptions({
showUsername: showUsernameOnAvatar.value,
show2dAvatar: show2dAvatarOnAvatar.value,
});
});
controller.addListener('playerPointed', ({ playerId }) => {
pointedPlayerInfo.value = multiplayer.playerProfiles[playerId] ?? null;
isPlayerInfoOpen.value = true;
});
async function refresh() {
canvasKey.value++;
await nextTick();
await controller.reset(canvas.value!, attachments, null, roomControllerOptions.value);
}
async function takeScreenshot() {
await controller.downloadScreenshot();
}
function resize() {
controller.resize();
}
const isZenMode = ref(false);
const controller = new WorldEngineController();
onMounted(async () => {
controller.init(canvas.value!);
// TODO: babylonに依存しないで判定する
//if (!await BABYLON.WebGPUEngine.IsSupportedAsync) {
// os.alert({
// type: 'warning',
// title: i18n.ts._miRoom.yourDeviceNotSupported_title,
// text: i18n.ts._miRoom.yourDeviceNotSupported_description,
// });
// return;
//}
try {
await controller.init(canvas.value!);
} catch (err) {
console.error(err);
os.alert({
type: 'error',
title: i18n.ts._miWorld.failedToInitialize,
text: (err instanceof Error ? err.message : String(err)),
});
return;
}
canvas.value!.focus();
window.addEventListener('resize', resize);
// canvasからフォーカスが外れていることに気づかずsとか押してしまうと検索画面が開かれてroomの状態が失われたりするので無効化
(window as any).disableGlobalHotkeys();
});
useInterval(() => {
//multiplayer.updateState(controller.myPlayerState.value);
}, 100, { immediate: false, afterMounted: true });
onDeactivated(() => {
controller.destroy();
//multiplayer.dispose();
window.removeEventListener('resize', resize);
});
onActivated(() => {
// controller.resetする?
});
onUnmounted(() => {
controller.destroy();
//multiplayer.dispose();
window.removeEventListener('resize', resize);
});
function showCharacterMenu(ev: PointerEvent) {
os.popupMenu([{
text: i18n.ts._miWorld.sit,
action: () => {
controller.sit();
canvas.value!.focus();
},
}, {
text: i18n.ts._miWorld.lyingDown,
action: () => {
controller.lyingDown();
canvas.value!.focus();
},
}], ev.currentTarget ?? ev.target);
}
function showOnlineMenu(ev: PointerEvent) {
os.popupMenu([{
text: multiplayer.isOnline.value ? i18n.ts._miWorld.disconnectToOnline : i18n.ts._miWorld.connectToOnline,
icon: multiplayer.isOnline.value ? 'ti ti-world-off' : 'ti ti-world',
danger: multiplayer.isOnline.value,
action: () => {
if (multiplayer.isOnline.value) {
leaveOnline();
} else {
enterOnline();
}
},
}, {
type: 'divider',
}, {
type: 'parent',
text: i18n.ts.settings,
icon: 'ti ti-settings',
children: [{
type: 'switch',
text: i18n.ts._miWorld.showUsernameOnAvatar,
ref: showUsernameOnAvatar,
}, {
type: 'switch',
text: i18n.ts._miWorld.show2dAvatarOnAvatar,
ref: show2dAvatarOnAvatar,
}],
}], ev.currentTarget ?? ev.target);
}
function showOtherMenu(ev: PointerEvent) {
os.popupMenu([{
type: 'parent',
text: i18n.ts._miWorld.graphicsSettings,
children: [{
type: 'radio',
text: i18n.ts._miWorld.graphicsQuality,
caption: graphicsQualityRaw.value == null ? i18n.ts.auto : graphicsQualityRaw.value === GRAPHICS_QUALITY.HIGH ? 'High' : graphicsQualityRaw.value === GRAPHICS_QUALITY.MEDIUM ? 'Medium' : 'Low',
options: [{
label: `${i18n.ts.auto} (${graphicsQualityAutoValue.value === GRAPHICS_QUALITY.HIGH ? 'High' : graphicsQualityAutoValue.value === GRAPHICS_QUALITY.MEDIUM ? 'Medium' : 'Low'})`,
value: null,
}, { type: 'divider' }, {
label: 'High',
value: GRAPHICS_QUALITY.HIGH,
}, {
label: 'Medium',
value: GRAPHICS_QUALITY.MEDIUM,
}, {
label: 'Low',
value: GRAPHICS_QUALITY.LOW,
}],
ref: graphicsQualityRaw,
}, {
type: 'radio',
text: i18n.ts._miWorld.frameRateLimitation,
caption: fpsRaw.value == null ? i18n.ts.auto : fpsRaw.value === 'max' ? 'Max' : `~${fpsRaw.value}fps`,
options: [{
label: `${i18n.ts.auto} (${fpsAutoValue.value}fps)`,
value: null,
}, { type: 'divider' }, {
label: 'Max',
value: 'max',
}, {
label: '~120fps',
value: '120',
}, {
label: '~60fps',
value: '60',
}, {
label: '~30fps',
value: '30',
}],
ref: fpsRaw,
}, {
type: 'radio',
text: i18n.ts._miWorld.resolution,
caption: resolutionRaw.value == null ? i18n.ts.auto : resolutionRaw.value + 'x',
options: [{
label: `${i18n.ts.auto} (${resolutionAutoValue.value}x)`,
value: null,
}, { type: 'divider' }, {
label: '2x',
value: 2,
}, {
label: '1x',
value: 1,
}, {
label: '0.5x',
value: 0.5,
}],
ref: resolutionRaw,
}, {
type: 'switch',
text: i18n.ts._miWorld.antialiasing,
ref: antialias,
}],
}], ev.currentTarget ?? ev.target);
}
definePage(() => ({
title: 'Room',
icon: 'ti ti-door',
@@ -84,12 +344,6 @@ definePage(() => ({
</script>
<style lang="scss" module>
.root {
height: 100%;
overflow: clip;
background: var(--MI_THEME-bg);
}
.screen {
position: relative;
width: 100%;
@@ -109,48 +363,20 @@ definePage(() => ({
}
}
.joyStick {
position: relative;
width: 50%;
height: 100px;
box-sizing: border-box;
.floatingButton {
background: var(--MI_THEME-panel);
padding: 8px;
touch-action: none;
width: 50px;
box-sizing: border-box;
aspect-ratio: 1;
border-radius: 999px;
display: grid;
place-items: center;
pointer-events: auto;
font-size: 15px;
}
.joyStick::before {
content: '';
display: block;
width: 100%;
height: 100%;
border: solid 2px #fff;
border-radius: 16px;
pointer-events: none;
}
.joyStickRangeCircle {
position: absolute;
top: var(--startYPx);
left: var(--startXPx);
width: calc(var(--rPx) * 2);
height: calc(var(--rPx) * 2);
border: solid 2px rgba(255, 255, 255, 0.5);
border-radius: 100%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.joyStickPuck {
position: absolute;
top: calc(var(--startYPx) + (var(--y) * var(--rPx)));
left: calc(var(--startXPx) + (var(--x) * var(--rPx)));
width: 30px;
height: 30px;
background: #fff;
border-radius: 100%;
transform: translate(-50%, -50%);
pointer-events: none;
.joystick {
}
.overlayTop {
@@ -159,6 +385,7 @@ definePage(() => ({
left: 0;
z-index: 1;
width: 100%;
pointer-events: none;
}
.overlayBottom {
@@ -174,36 +401,21 @@ definePage(() => ({
display: flex;
align-items: center;
gap: 16px;
pointer-events: none;
}
.topMenu {
display: flex;
flex-direction: column;
gap: 8px;
margin: 16px;
pointer-events: none;
}
.topMenuRow {
display: flex;
box-sizing: border-box;
width: max-content;
}
.topMenuButton {
padding: 8px;
}
.topMenuButton:first-child {
padding-left: 16px;
}
.topMenuButton:last-child {
padding-right: 16px;
}
.modified {
display: flex;
align-items: center;
font-size: 90%;
gap: 1em;
padding: 8px 16px;
}
.modifiedText {
color: var(--MI_THEME-warn);
animation: modified-blink 2s infinite;
flex-wrap: wrap;
gap: 8px;
pointer-events: none;
}
@keyframes modified-blink {
@@ -213,20 +425,22 @@ definePage(() => ({
}
.overlayControls {
margin: 16px auto;
display: flex;
gap: 8px;
flex-wrap: wrap;
box-sizing: border-box;
width: max-content;
pointer-events: auto;
}
.overlayControls:empty {
display: none;
}
.overlayFurnitureInfoPanel {
position: absolute;
top: 16px;
right: 16px;
z-index: 1;
padding: 16px;
box-sizing: border-box;
width: 300px;
.pointedPlayerInfoAvatar {
width: 32px;
height: 32px;
border-radius: 100%;
}
.loading {
+59 -16
View File
@@ -3,8 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EngineControllerBase } from './EngineControllerBase.js';
import type { WorldEngine } from './engine.js';
import { shallowRef } from 'vue';
import { EngineControllerBase, WASD } from './EngineControllerBase.js';
import type { WorldEngine } from 'misskey-world-engine/src/engine.js';
import type { PlayerProfile, PlayerState } from 'misskey-world-engine/src/PlayerContainer.js';
export type WorldEngineControllerOptions = {
workerMode?: boolean;
@@ -16,31 +18,72 @@ export type WorldEngineControllerOptions = {
// 抽象化レイヤー
export class WorldEngineController extends EngineControllerBase<WorldEngine> {
public myPlayerState = shallowRef<PlayerState>({
position: [0, 0, 0],
rotation: [0, 0, 0],
});
constructor(options: WorldEngineControllerOptions) {
super({
...options,
});
super(options, new WASD({
setCameraMoveVector: (vec, dash) => {
this.call('cameraMove', [vec, dash]);
},
}));
}
public async init(canvas: HTMLCanvasElement) {
/*
await this._init_(canvas, {
const { engineEvents } = await this._init_(canvas, {
createWorker: (offscreen) => new Promise((resolve) => {
import('./worker?worker').then(({ default: WorldEngineWorker }) => {
const worker = new WorldEngineWorker();
import('frontend-misskey-world-engine/src/worker?worker').then(({ default: WorldWorker }) => {
const worker = new WorldWorker();
worker.postMessage({ type: 'init', canvas: offscreen, options: this.options }, [offscreen]);
resolve(worker);
});
}),
createEngine: (babylonEngine) => new Promise((resolve) => {
import('./engine.js').then(({ WorldEngine }) => {
resolve(new WorldEngine({
engine: babylonEngine,
...this.options,
}));
createEngine: () => new Promise((resolve) => {
import('frontend-misskey-world-engine/src/nonWorker.js').then(({ createWorldEngine }) => {
const engine = createWorldEngine({ canvas, options: this.options });
resolve(engine);
});
}),
});
*/
engineEvents.on('changeMyPlayerState', (playerState) => {
this.myPlayerState.value = playerState;
});
engineEvents.on('playerPointed', ({ playerId }) => {
this.emit('playerPointed', { playerId });
});
}
public async reset(canvas: HTMLCanvasElement, options?: WorldEngineControllerOptions | null) {
this._reset_();
if (options != null) this.options = options;
this.myPlayerState.value = {
position: [0, 0, 0],
rotation: [0, 0, 0],
};
await this.init(canvas);
}
public setCameraJoystickMoveVector(vec: { x: number; y: number }) {
this.call('cameraJoystickMove', [vec]);
}
public updatePlayerProfiles(profiles: Record<string, PlayerProfile>) {
this.call('updatePlayerProfiles', [profiles]);
}
public updatePlayerStates(states: Record<string, PlayerState>) {
this.call('updatePlayerStates', [states]);
}
public clearPlayers() {
this.call('clearPlayers');
}
public updateAvatarDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean }) {
this.call('updateAvatarDisplayOptions', [options]);
}
}
@@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { reactive, ref, shallowRef, triggerRef, watch } from 'vue';
import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import type { PlayerProfile, PlayerState } from 'misskey-world-engine/src/PlayerContainer.js';
import type { WorldEngineController } from './controller.js';
import { useStream } from '@/stream.js';
import * as os from '@/os.js';
import { withTimeout } from '@/utility/promise-timeout.js';
import { deepEqual } from '@/utility/deep-equal.js';
export class WorldMultiplayer {
public isOnline = ref(false);
private controller: WorldEngineController;
private connection: Misskey.IChannelConnection<Misskey.Channels['worldRoom']> | null = null;
private dimensionId: string;
public playerProfiles: Record<string, PlayerProfile> = {};
constructor(dimensionId: string, controller: WorldEngineController) {
this.dimensionId = dimensionId;
this.controller = controller;
this.onSync = this.onSync.bind(this);
this.onPlayerEntered = this.onPlayerEntered.bind(this);
this.onPlayerLeft = this.onPlayerLeft.bind(this);
}
public enter() {
const p = new Promise<void>((resolve, reject) => {
this.connection = useStream().useChannel('worldRoom', {
roomId: this.dimensionId,
});
this.connection.once('entered', ({ playerProfiles }) => {
console.log('entered', playerProfiles);
this.playerProfiles = playerProfiles;
this.controller.updatePlayerProfiles(this.playerProfiles);
this.connection!.on('sync', this.onSync);
this.connection!.on('playerEntered', this.onPlayerEntered);
this.connection!.on('playerLeft', this.onPlayerLeft);
this.isOnline.value = true;
resolve();
});
});
return withTimeout(p, 5000).catch((err) => {
this.connection?.dispose();
this.connection = null;
throw err;
});
}
public left() {
if (this.connection == null) return;
this.connection.dispose();
this.connection = null;
this.isOnline.value = false;
}
private prevState: PlayerState | null = null;
public updateState(state: PlayerState) {
if (this.connection == null || !this.isOnline.value) return;
if (this.prevState != null && deepEqual(this.prevState, state)) return;
this.connection.send('update', state);
this.prevState = state;
}
private onSync(states: Record<string, PlayerState>) {
this.controller.updatePlayerStates(states);
}
private onPlayerEntered(data: { id: string; profile: PlayerProfile; }) {
this.playerProfiles[data.id] = data.profile;
this.controller.updatePlayerProfiles(this.playerProfiles);
}
private onPlayerLeft(data: { id: string; }) {
delete this.playerProfiles[data.id];
this.controller.updatePlayerProfiles(this.playerProfiles);
}
public dispose() {
this.left();
}
}
@@ -13,7 +13,7 @@ import * as os from '@/os.js';
import { withTimeout } from '@/utility/promise-timeout.js';
import { deepEqual } from '@/utility/deep-equal.js';
export class Multiplayer {
export class RoomMultiplayer {
public isOnline = ref(false);
private controller: RoomController;
private connection: Misskey.IChannelConnection<Misskey.Channels['worldRoom']> | null = null;