This commit is contained in:
syuilo 2026-05-28 17:24:22 +09:00
commit 5998b01ffc
15 changed files with 461 additions and 38 deletions

View file

@ -5,6 +5,8 @@
import * as BABYLON from '@babylonjs/core';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { AccessoryContainer } from './avatars/AccessoryContainer.js';
import { getAccessoryDef } from './avatars/accessory-defs.js';
import type { WorldAvatar } from 'misskey-world/src/types.js';
export type PlayerProfile = {
@ -51,6 +53,7 @@ export class PlayerContainer {
private scene: BABYLON.Scene;
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
private animationObserver: BABYLON.Observer<BABYLON.Scene> | null = null;
private accessoryContainers: AccessoryContainer[] = [];
constructor(params: { id: string; profile: PlayerProfile; state: PlayerState | null; sr: BABYLON.SnapshotRenderingHelper; scene: BABYLON.Scene; }) {
this.id = params.id;
@ -86,7 +89,7 @@ export class PlayerContainer {
modelRootMesh.dispose();
const avatarTex = new BABYLON.Texture(this.profile.avatarUrl, this.scene, false, false);
//const avatarTex = new BABYLON.Texture(this.profile.avatarUrl, this.scene, false, false);
let eyesTex: BABYLON.Texture | null = null;
if (this.profile.worldAvatar.eyes.type in DEFAULT_FACE_PARTS_EYES) {
@ -107,17 +110,17 @@ export class PlayerContainer {
}
for (const mesh of this.modelRoot.getChildMeshes()) {
if (mesh.name.includes('__AVATAR__')) {
const mat = new BABYLON.PBRMaterial('', this.scene);
mat.albedoColor = new BABYLON.Color3(0.5, 0.5, 0.5);
mat.albedoTexture = avatarTex;
mat.emissiveColor = new BABYLON.Color3(0.5, 0.5, 0.5);
mat.emissiveTexture = avatarTex;
mat.roughness = 0;
mat.metallic = 0;
mat.backFaceCulling = false;
mesh.material = mat;
}
//if (mesh.name.includes('__AVATAR__')) {
// const mat = new BABYLON.PBRMaterial('', this.scene);
// mat.albedoColor = new BABYLON.Color3(0.5, 0.5, 0.5);
// mat.albedoTexture = avatarTex;
// mat.emissiveColor = new BABYLON.Color3(0.5, 0.5, 0.5);
// mat.emissiveTexture = avatarTex;
// mat.roughness = 0;
// mat.metallic = 0;
// mat.backFaceCulling = false;
// mesh.material = mat;
//}
if (mesh.name.includes('__BODY__')) {
mesh.material.albedoColor = new BABYLON.Color3(this.profile.worldAvatar.body.color[0], this.profile.worldAvatar.body.color[1], this.profile.worldAvatar.body.color[2]);
}
@ -145,6 +148,21 @@ export class PlayerContainer {
this.registerMeshes(this.modelRoot.getChildMeshes());
// debug
this.profile.worldAvatar.accessories = [{
id: 'a',
type: 'mug',
options: {},
}];
this.accessoryContainers = await Promise.all(this.profile.worldAvatar.accessories.map(ac => this.loadAccessory({
type: ac.type,
id: ac.id,
position: new BABYLON.Vector3(0, cm(20), 0),
rotation: new BABYLON.Vector3(0, 0, 0),
options: ac.options,
})));
const anim = new BABYLON.Animation('', 'position.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: cm(0) },
@ -160,6 +178,36 @@ export class PlayerContainer {
this.scene.beginAnimation(this.modelRootContainerForAnim, 0, 120, true);
}
private async loadAccessory(args: {
type: string;
id: string;
position: BABYLON.Vector3;
rotation: BABYLON.Vector3;
options: Record<string, unknown>;
}) {
const def = getAccessoryDef(args.type);
const container = new AccessoryContainer({
id: args.id,
type: args.type,
position: args.position.clone(),
rotation: args.rotation.clone(),
options: args.options,
sr: this.sr,
getIsSrReady: () => true,
lightContainer: this.lightContainer,
graphicsQuality: this.graphicsQuality,
scene: this.scene,
});
container.registerMeshes = (meshes) => {
this.registerMeshes(meshes);
};
await container.load();
return container;
}
public applyState(state: PlayerState, forInit = false) {
this.root.position.set(...state.position);
if (this.modelRoot) this.modelRoot.rotation.set(...state.rotation);
@ -173,6 +221,10 @@ export class PlayerContainer {
if (this.animationObserver != null) {
this.scene.onAfterAnimationsObservable.remove(this.animationObserver);
}
for (const ac of this.accessoryContainers) {
ac.destroy();
}
this.accessoryContainers = [];
this.root.dispose();
}
}

View file

@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import { camelToKebab, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { ModelExplorer, scaleMorph, Timer } from '../utility.js';
import { getAccessoryDef } from './accessory-defs.js';
import type { AvatarAccessoryInstance } from './accessory.js';
export class AccessoryContainer {
public id: string;
public type: string;
private options: Record<string, unknown>;
public root: BABYLON.TransformNode;
private subRoot: BABYLON.TransformNode | null = null;
public instance: AvatarAccessoryInstance | null = null;
public model: ModelExplorer | null = null;
private scene: BABYLON.Scene;
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
private sr: BABYLON.SnapshotRenderingHelper;
private getIsSrReady: () => boolean;
private lightContainer: BABYLON.ClusteredLightContainer;
private graphicsQuality: number;
private timer: Timer = new Timer();
constructor(args: {
id: string;
type: string;
options: Record<string, unknown>;
position: BABYLON.Vector3;
rotation: BABYLON.Vector3;
sr: BABYLON.SnapshotRenderingHelper;
getIsSrReady: () => boolean;
lightContainer: BABYLON.ClusteredLightContainer;
scene: BABYLON.Scene;
graphicsQuality: number;
}) {
this.id = args.id;
this.type = args.type;
this.options = args.options;
this.sr = args.sr;
this.getIsSrReady = args.getIsSrReady;
this.lightContainer = args.lightContainer;
this.scene = args.scene;
this.graphicsQuality = args.graphicsQuality;
this.root = new BABYLON.TransformNode(`accessory_${args.id}_${args.type}`, this.scene);
this.root.position = args.position;
this.root.rotation = args.rotation;
}
public async load() {
const def = getAccessoryDef(this.type);
const filePath = def.path != null ? `/client-assets/world/avatar-accessories/${def.path(this.options)}.glb` : `/client-assets/world/avatar-accessories/${camelToKebab(this.type)}/${camelToKebab(this.type)}.glb`;
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
// babylonによって自動で追加される右手系変換用ード
const subRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
// meshじゃなくtransform nodeにしてパフォーマンス向上
this.subRoot = new BABYLON.TransformNode('__root__', this.scene);
this.subRoot.parent = this.root;
this.subRoot.scaling.x = -1;
this.subRoot.scaling = this.subRoot.scaling.scale(WORLD_SCALE);// cmをmに
for (const m of subRootMesh.getChildren()) {
if (m.parent === subRootMesh) {
m.parent = this.subRoot;
}
}
subRootMesh.dispose();
this.registerMeshes(this.subRoot.getChildMeshes());
this.model = new ModelExplorer(this.subRoot);
this.instance = await def.createInstance({
scene: this.scene,
sr: {
updateMesh: (mesh) => {
if (!this.getIsSrReady()) return;
this.sr.updateMesh(mesh);
},
reset: () => {
if (!this.getIsSrReady()) return;
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
},
fixParticleSystem: (ps) => this.sr.fixParticleSystem(ps),
},
lc: this.lightContainer,
root: this.root,
options: this.options,
model: this.model!,
timer: this.timer,
graphicsQuality: this.graphicsQuality,
reloadModel: () => {
this.reload();
},
});
}
public async reload() {
this.timer.dispose();
this.instance?.dispose?.();
this.instance = null;
this.model = null;
this.subRoot?.dispose();
this.root.removeChild(this.subRoot);
this.scene.removeTransformNode(this.subRoot);
this.timer = new Timer();
await this.load();
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
}
public optionsUpdated(options: Record<string, unknown>, key: string, value: any) {
if (this.instance == null) return;
this.options[key] = options[key]; // 参照を切れさせないようにプロパティ個別にmutate
this.sr.disableSnapshotRendering();
this.instance.onOptionsUpdated?.([key, this.options[key]]);
this.sr.enableSnapshotRendering();
}
public destroy() {
this.sr.disableSnapshotRendering();
this.timer.dispose();
this.instance?.dispose?.();
this.subRoot.dispose();
this.root.dispose();
this.scene.removeTransformNode(this.root);
this.sr.enableSnapshotRendering();
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import { cm } from 'misskey-world/src/utility.js';
import { mug_schema } from 'misskey-world/src/room/objects/mug.schema.js';
import { defineAccessory } from '../accessory.js';
import { yuge } from '../utility.js';
export const mug = defineAccessory(mug_schema, {
createInstance: ({ scene, root, sr }) => {
const yugeDispose = yuge(scene, root, new BABYLON.Vector3(0, cm(5), 0), sr);
return {
dispose: () => {
yugeDispose?.();
},
};
},
});

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { mug } from './accessories/mug.js';
import type { AvatarAccessoryDef } from './accessory.js';
export const AVATAR_ACCESSORY_DEFS = [
mug,
] as AvatarAccessoryDef[];
export function getAccessoryDef(type: string): AvatarAccessoryDef {
const def = AVATAR_ACCESSORY_DEFS.find(x => x.id === type) as AvatarAccessoryDef | undefined;
if (def == null) {
throw new Error(`Unrecognized accessory type: ${type}`);
}
return def;
}

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import { findMaterial, ModelExplorer, type Timer } from '../utility.js';
import type { GetAvatarAccessoryOptionsSchemaValues, AccessorySchemaDef, AvatarAccessoryOptionsSchema } from 'misskey-world/src/avatars/accessory.js';
export type AvatarAccessoryInstance<Options = any> = {
onOptionsUpdated?: <K extends keyof Options, V extends Options[K]>(kv: [K, V]) => void;
dispose: () => void;
};
export type SnapshotRenderingHelperWrapper = {
updateMesh: (meshes: BABYLON.Mesh[]) => void;
reset: () => void;
fixParticleSystem: (ps: BABYLON.ParticleSystem) => void;
};
export type AvatarAccessoryDef<Schema extends AccessorySchemaDef = AccessorySchemaDef> = Schema & {
path?: (options: string extends keyof Schema['options']['schema'] ? Record<string, unknown> : Readonly<GetAvatarAccessoryOptionsSchemaValues<Schema['options']['schema']>>) => string;
createInstance: (args: {
scene: BABYLON.Scene;
// TODO: snapshot renderingの関心を隠蔽した方が綺麗かもしれない
// 例えばmaterialUpdatedというメソッドを用意して内部的にresetを呼ぶなど
sr: SnapshotRenderingHelperWrapper;
lc: BABYLON.ClusteredLightContainer | null;
root: BABYLON.TransformNode;
options: string extends keyof Schema['options']['schema'] ? Record<string, unknown> : Readonly<GetAvatarAccessoryOptionsSchemaValues<Schema['options']['schema']>>;
model: ModelExplorer;
timer: Timer;
graphicsQuality: number;
reloadModel: () => void;
}) => AvatarAccessoryInstance<string extends keyof Schema['options']['schema'] ? Record<string, unknown> : GetAvatarAccessoryOptionsSchemaValues<Schema['options']['schema']>> | Promise<RoomAccessoryInstance<Schema['options']['schema'] extends undefined ? Record<string, unknown> : GetConvertedOptionsSchemaValues<Schema['options']['schema']>>>; // TODO: createInstanceをasyncにするのではなく、別にreadyみたいなものを返させる
};
export function defineAccessorySchema<const OpSc extends AvatarAccessoryOptionsSchema>(def: AccessorySchemaDef<OpSc>): AccessorySchemaDef<OpSc> {
return def;
}
export function defineAccessory<const Schema extends AccessorySchemaDef<any>>(schema: Schema, def: Pick<AccessoryDef<Schema>, 'path' | 'createInstance'>): AccessoryDef<Schema> {
return { ...schema, ...def };
}

View file

@ -5,7 +5,7 @@
import * as BABYLON from '@babylonjs/core';
import { cm } from 'misskey-world/src/utility.js';
import { applyMorphTargetsToMesh, getPlaneUvIndexes, GRAPHICS_QUALITY, Timer } from '../utility.js';
import { applyMorphTargetsToMesh, findMaterial, getPlaneUvIndexes, GRAPHICS_QUALITY, Timer } from '../utility.js';
export function getLightRangeFactorByGraphicsQuality(quality: number) {
if (quality >= GRAPHICS_QUALITY.HIGH) {
@ -171,31 +171,6 @@ export function initTv(scene: BABYLON.Scene, screenMesh: BABYLON.Mesh, timer: Ti
};
}
export function findMaterial(rootMesh: BABYLON.AbstractMesh | BABYLON.TransformNode, keyword: string, allowMultiMaterial = false): BABYLON.PBRMaterial {
for (const m of rootMesh.getChildMeshes()) {
if (m.material == null) continue;
if (m.material instanceof BABYLON.MultiMaterial) {
if (allowMultiMaterial && m.material.name.includes(keyword)) {
return m.material as BABYLON.MultiMaterial;
}
if ((m.material as BABYLON.MultiMaterial).subMaterials == null) continue;
for (const sm of (m.material as BABYLON.MultiMaterial).subMaterials) {
if (sm == null) continue;
if (sm.name.includes(keyword)) {
return sm as BABYLON.PBRMaterial;
}
}
} else {
if (m.material.name.includes(keyword)) {
return m.material as BABYLON.PBRMaterial;
}
}
}
throw new Error(`Material with keyword "${keyword}" not found`);
}
export class ModelManager {
public root: BABYLON.TransformNode;
public bakedCallback: ((meshes: (BABYLON.Mesh | BABYLON.AbstractMesh)[]) => void) | null = null;

View file

@ -711,3 +711,61 @@ export class ArcRotateCameraManualInput implements BABYLON.ICameraInput<BABYLON.
checkInputs() {
}
}
export function findMaterial(rootMesh: BABYLON.AbstractMesh | BABYLON.TransformNode, keyword: string, allowMultiMaterial = false): BABYLON.PBRMaterial {
for (const m of rootMesh.getChildMeshes()) {
if (m.material == null) continue;
if (m.material instanceof BABYLON.MultiMaterial) {
if (allowMultiMaterial && m.material.name.includes(keyword)) {
return m.material as BABYLON.MultiMaterial;
}
if ((m.material as BABYLON.MultiMaterial).subMaterials == null) continue;
for (const sm of (m.material as BABYLON.MultiMaterial).subMaterials) {
if (sm == null) continue;
if (sm.name.includes(keyword)) {
return sm as BABYLON.PBRMaterial;
}
}
} else {
if (m.material.name.includes(keyword)) {
return m.material as BABYLON.PBRMaterial;
}
}
}
throw new Error(`Material with keyword "${keyword}" not found`);
}
export class ModelExplorer {
public root: BABYLON.TransformNode;
constructor(root: BABYLON.TransformNode) {
this.root = root;
}
public findMesh(keyword: string) {
const mesh = this.root.getChildMeshes().find(m => m.name.includes(keyword));
if (mesh == null) {
throw new Error(`Mesh with keyword "${keyword}" not found for object ${this.root.metadata?.objectType}`);
}
return mesh as BABYLON.Mesh;
}
public findMeshes(keyword: string) {
const meshes = this.root.getChildMeshes().filter(m => m.name.includes(keyword));
return meshes as BABYLON.Mesh[];
}
public findMaterial(keyword: string) {
return findMaterial(this.root, keyword);
}
public findTransformNode(keyword: string) {
const node = this.root.getChildTransformNodes().find(n => n.name.includes(keyword));
if (node == null) {
throw new Error(`TransformNode with keyword "${keyword}" not found for object ${this.root.metadata?.objectType}`);
}
return node;
}
}

View file

@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAccessorySchema } from '../accessory.js';
export const mug_schema = defineAccessorySchema({
id: 'mug',
options: {
schema: {},
default: {},
},
});

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { mug_schema } from './accessories/mug.schema.js';
import type { AccessorySchemaDef } from './accessory.js';
export const OBJECT_SCHEMA_DEFS = {
mug: mug_schema,
} as Record<string, AccessorySchemaDef<any>>;
export function getAccessorySchemaDef(type: string): AccessorySchemaDef {
const def = OBJECT_SCHEMA_DEFS[type as keyof typeof OBJECT_SCHEMA_DEFS];
if (def == null) {
throw new Error(`Unrecognized accessory type: ${type}`);
}
return def;
}

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type AvatarAccessoryInstance<Options = any> = {
onOptionsUpdated?: <K extends keyof Options, V extends Options[K]>(kv: [K, V]) => void;
dispose: () => void;
};
export type NumberOptionSchema = {
type: 'number';
min?: number;
max?: number;
step?: number;
};
export type BooleanOptionSchema = {
type: 'boolean';
};
export type StringOptionSchema = {
type: 'string';
};
export type ColorOptionSchema = {
type: 'color';
};
export type MaterialOptionSchema = {
type: 'material';
};
export type LightOptionSchema = {
type: 'light';
};
export type EnumOptionSchema = {
type: 'enum';
enum: {
value: string | number;
}[];
};
export type RangeOptionSchema = {
type: 'range';
min: number;
max: number;
step?: number;
};
export type AvatarAccessoryOptionsSchema = Record<string, NumberOptionSchema | BooleanOptionSchema | StringOptionSchema | ColorOptionSchema | MaterialOptionSchema | LightOptionSchema | EnumOptionSchema | RangeOptionSchema | ImageOptionSchema | SeedOptionSchema>;
export type GetAvatarAccessoryOptionsSchemaValues<T extends AvatarAccessoryOptionsSchema> = {
[K in keyof T]:
T[K] extends NumberOptionSchema ? number :
T[K] extends BooleanOptionSchema ? boolean :
T[K] extends StringOptionSchema ? string :
T[K] extends ColorOptionSchema ? [number, number, number] :
T[K] extends MaterialOptionSchema ? { color: [number, number, number]; metallic: number; roughness: number; } :
T[K] extends LightOptionSchema ? { color: [number, number, number]; brightness: number; } :
T[K] extends EnumOptionSchema ? T[K]['enum'][number]['value'] :
T[K] extends RangeOptionSchema ? number :
never;
};
export type AccessorySchemaDef<OpSc extends AvatarAccessoryOptionsSchema = AvatarAccessoryOptionsSchema> = {
id: string;
options: {
schema: string extends keyof OpSc ? AvatarAccessoryOptionsSchema : OpSc;
default: string extends keyof OpSc ? Record<string, unknown> : GetAvatarAccessoryOptionsSchemaValues<OpSc>; // 関数にした方が使用側でdeepCloneの必要がなくて綺麗かもしれない
};
};
export function defineAccessorySchema<const OpSc extends AvatarAccessoryOptionsSchema>(def: AccessorySchemaDef<OpSc>): AccessorySchemaDef<OpSc> {
return def;
}

View file

@ -19,6 +19,10 @@ export type WorldAvatar = {
color: [number, number, number];
};
accessories: {
id: string;
type: string;
options: Record<string, unknown>;
position?: [number, number, number];
rotation?: [number, number, number];
}[];
};