This commit is contained in:
2026-04-30 14:46:01 +08:00
parent 604dcdf3fb
commit 4207fcf7c2
15 changed files with 1038 additions and 87 deletions

View File

@ -13,5 +13,17 @@ export const AppConfig = {
intensity: 1.5,
rotationY: 0,
background: true,
}
},
gizmo: {
position: true,
rotation: false,
scale: false,
},
outline: {
enable: true,
color: '#2196F3',
thickness: 3.0,
occlusionStrength: 0.9,
occlusionThreshold: 0.0002,
},
};

View File

@ -1,6 +1,8 @@
import { ImportMeshAsync, ISceneLoaderProgressEvent } from '@babylonjs/core/Loading/sceneLoader';
import '@babylonjs/loaders/glTF';
import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
import { Mesh } from '@babylonjs/core/Meshes/mesh';
import { Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector';
import { Scene } from '@babylonjs/core/scene';
import { Monobehiver } from '../base/Monobehiver';
import { Dictionary } from '../utils/Dictionary';
@ -19,17 +21,27 @@ type ModelConfig = {
url: string;
};
type ModelControlType = 'rotation' | 'color';
type ModelMetadata = {
modelId: string;
modelUrl: string;
modelControlType?: ModelControlType;
};
/**
* 模型管理类 - 负责加载、缓存和管理3D模型
*/
export class AppModel extends Monobehiver {
private modelDic: Dictionary<AbstractMesh[]>;
private modelMetadataDic: Dictionary<ModelMetadata>;
private loadedMeshes: AbstractMesh[];
private isLoading: boolean;
constructor(mainApp: any) {
super(mainApp);
this.modelDic = new Dictionary<AbstractMesh[]>();
this.modelMetadataDic = new Dictionary<ModelMetadata>();
this.loadedMeshes = [];
this.isLoading = false;
}
@ -143,6 +155,40 @@ export class AppModel extends Monobehiver {
});
}
/** 为网格设置阴影(投射和接收) */
private createModelRoot(modelId: string, meshes: AbstractMesh[]): AbstractMesh[] {
const scene = this.mainApp.appScene.object;
const root = new Mesh(`${modelId}__root`, scene);
const meshSet = new Set<AbstractMesh>(meshes);
root.position.copyFrom(this.getMeshesBoundingCenter(meshes));
meshes.forEach(mesh => {
if (!mesh.parent || !meshSet.has(mesh.parent as AbstractMesh)) {
mesh.setParent(root, true, true);
}
});
this.loadedMeshes.push(root);
return [root, ...meshes];
}
private getMeshesBoundingCenter(meshes: AbstractMesh[]): Vector3 {
const renderableMeshes = meshes.filter(mesh => !mesh.isDisposed() && mesh.getTotalVertices() > 0);
if (!renderableMeshes.length) return Vector3.Zero();
const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY);
renderableMeshes.forEach(mesh => {
mesh.computeWorldMatrix(true);
const boundingBox = mesh.getBoundingInfo().boundingBox;
min.minimizeInPlace(boundingBox.minimumWorld);
max.maximizeInPlace(boundingBox.maximumWorld);
});
return min.add(max).scaleInPlace(0.5);
}
setupShadows(meshes: AbstractMesh[]): void {
const appLight = this.mainApp.appLight;
if (!appLight) return;
@ -170,30 +216,28 @@ export class AppModel extends Monobehiver {
/**
* 添加模型到场景(支持单个或批量)
* @param modelName 模型名称 或 模型配置数组
* @param modelUrl 模型URL单个模型时使用
* @param modelConfig 模型配置对象 或 模型配置数组
*/
async add(
modelName: string | ModelConfig[],
modelUrl?: string
modelConfig: ModelMetadata | ModelMetadata[]
): Promise<LoadResult | { success: boolean; results: LoadResult[] }> {
// 批量加载
if (Array.isArray(modelName)) {
return await this.addMultiple(modelName);
if (Array.isArray(modelConfig)) {
return await this.addMultiple(modelConfig);
}
// 单个加载
if (!modelUrl) {
return { success: false, error: '缺少模型URL参数' };
}
return await this.addSingle(modelName, modelUrl);
return await this.addSingle(
modelConfig.modelId,
modelConfig.modelUrl,
modelConfig.modelControlType
);
}
/**
* 添加单个模型
*/
private async addSingle(modelName: string, modelUrl: string): Promise<LoadResult> {
private async addSingle(modelName: string, modelUrl: string, modelControlType?: ModelControlType): Promise<LoadResult> {
// 检查是否已存在
const existingMeshes = this.modelDic.Get(modelName);
if (existingMeshes?.length && !existingMeshes[0].isDisposed()) {
@ -209,8 +253,16 @@ export class AppModel extends Monobehiver {
if (result.success && result.meshes) {
// this.cloneMaterials(result.meshes, modelName);
result.meshes = this.createModelRoot(modelName, result.meshes);
this.modelDic.Set(modelName, result.meshes);
// 存储元数据
this.modelMetadataDic.Set(modelName, {
modelId: modelName,
modelUrl: modelUrl,
modelControlType: modelControlType
});
// 更新 GameManager 的字典
this.mainApp.gameManager?.updateDictionaries();
@ -225,32 +277,40 @@ export class AppModel extends Monobehiver {
/**
* 批量添加模型
*/
private async addMultiple(models: ModelConfig[]): Promise<{ success: boolean; results: LoadResult[] }> {
private async addMultiple(models: ModelMetadata[]): Promise<{ success: boolean; results: LoadResult[] }> {
const total = models.length;
const results: LoadResult[] = [];
EventBridge.modelLoadProgress({ loaded: 0, total, progress: 0, percentage: 0 });
for (let i = 0; i < models.length; i++) {
const { name, url } = models[i];
const { modelId, modelUrl, modelControlType } = models[i];
const result = await this.loadSingleModel(url, (event) => {
this.emitProgress(i, total, url, event);
const result = await this.loadSingleModel(modelUrl, (event) => {
this.emitProgress(i, total, modelUrl, event);
});
if (result.success && result.meshes) {
this.cloneMaterials(result.meshes, name);
this.modelDic.Set(name, result.meshes);
result.meshes = this.createModelRoot(modelId, result.meshes);
this.cloneMaterials(result.meshes, modelId);
this.modelDic.Set(modelId, result.meshes);
// 存储元数据
this.modelMetadataDic.Set(modelId, {
modelId: modelId,
modelUrl: modelUrl,
modelControlType: modelControlType
});
}
results.push(result);
this.emitProgress(i + 1, total, url, null, result.success);
this.emitProgress(i + 1, total, modelUrl, null, result.success);
}
// 批量加载完成后统一更新字典
this.mainApp.gameManager?.updateDictionaries();
EventBridge.modelLoaded({ urls: models.map(m => m.url) });
EventBridge.modelLoaded({ urls: models.map(m => m.modelUrl) });
return {
success: results.every(r => r.success),
@ -352,14 +412,17 @@ export class AppModel extends Monobehiver {
/**
* 替换模型
* @param modelName 模型名称
* @param newModelUrl 新模型URL
* @param modelConfig 模型配置对象
*/
async replaceModel(modelName: string, newModelUrl: string): Promise<LoadResult> {
console.log( modelName,this.modelDic);
this.removeByName(modelName);
return await this.addSingle(modelName, newModelUrl);
async replaceModel(modelConfig: ModelMetadata): Promise<LoadResult> {
console.log(modelConfig.modelId, this.modelDic);
this.removeByName(modelConfig.modelId);
return await this.addSingle(
modelConfig.modelId,
modelConfig.modelUrl,
modelConfig.modelControlType
);
}
/**
@ -375,6 +438,151 @@ export class AppModel extends Monobehiver {
meshes.forEach(mesh => mesh.dispose());
this.modelDic.Remove(modelName);
this.modelMetadataDic.Remove(modelName);
console.log(`Model removed: ${modelName}`);
}
/**
* 获取模型元数据
* @param modelName 模型名称
*/
getModelMetadata(modelName: string): ModelMetadata | undefined {
return this.modelMetadataDic.Get(modelName);
}
/**
* 根据网格查找模型元数据
* @param mesh 网格对象
*/
getMetadataByMesh(mesh: AbstractMesh): ModelMetadata | undefined {
const modelName = this.findModelNameByMesh(mesh);
if (modelName) {
return this.modelMetadataDic.Get(modelName);
}
return undefined;
}
private getModelTransformTargets(meshes: AbstractMesh[]): AbstractMesh[] {
const meshSet = new Set<AbstractMesh>(meshes);
const rootMeshes = meshes.filter(mesh => !mesh.parent || !meshSet.has(mesh.parent as AbstractMesh));
return rootMeshes.length ? rootMeshes : meshes.slice(0, 1);
}
getModelTransformTargetByMesh(mesh: AbstractMesh): AbstractMesh | undefined {
const modelName = this.findModelNameByMesh(mesh);
if (!modelName) return mesh;
const meshes = this.modelDic.Get(modelName);
if (!meshes?.length) return mesh;
return this.getModelTransformTargets(meshes)[0] ?? mesh;
}
getModelMeshesByMesh(mesh: AbstractMesh): AbstractMesh[] {
const modelName = this.findModelNameByMesh(mesh);
if (!modelName) return [mesh];
const meshes = this.modelDic.Get(modelName);
return meshes?.length ? meshes : [mesh];
}
/**
* 设置模型旋转
* @param modelId 模型ID
* @param rotation 旋转向量 {x, y, z}(默认使用角度)
* @param useDegrees 是否使用角度默认true
*/
setRotation(modelId: string, rotation: { x: number; y: number; z: number }, useDegrees: boolean = true): void {
const meshes = this.modelDic.Get(modelId);
if (!meshes?.length) {
console.warn(`Model not found: ${modelId}`);
return;
}
// 如果使用角度,转换为弧度
const toRadians = (degrees: number) => degrees * Math.PI / 180;
const rotationValues = useDegrees ? {
x: toRadians(rotation.x),
y: toRadians(rotation.y),
z: toRadians(rotation.z)
} : rotation;
this.getModelTransformTargets(meshes).forEach(mesh => {
if (mesh.rotationQuaternion) {
mesh.rotationQuaternion = Quaternion.FromEulerAngles(
rotationValues.x,
rotationValues.y,
rotationValues.z
);
return;
}
mesh.rotation.set(rotationValues.x, rotationValues.y, rotationValues.z);
});
}
/**
* 累加模型旋转
* @param modelId 模型ID
* @param rotation 旋转向量 {x, y, z}(默认使用角度)
* @param useDegrees 是否使用角度默认true
*/
addRotation(modelId: string, rotation: { x: number; y: number; z: number }, useDegrees: boolean = true): void {
const meshes = this.modelDic.Get(modelId);
if (!meshes?.length) {
console.warn(`Model not found: ${modelId}`);
return;
}
// 如果使用角度,转换为弧度
const toRadians = (degrees: number) => degrees * Math.PI / 180;
const rotationValues = useDegrees ? {
x: toRadians(rotation.x),
y: toRadians(rotation.y),
z: toRadians(rotation.z)
} : rotation;
this.getModelTransformTargets(meshes).forEach(mesh => {
mesh.addRotation(rotationValues.x, rotationValues.y, rotationValues.z);
});
}
/**
* 设置模型位置
* @param modelId 模型ID
* @param position 位置向量 {x, y, z}
*/
setPosition(modelId: string, position: { x: number; y: number; z: number }): void {
const meshes = this.modelDic.Get(modelId);
if (!meshes?.length) {
console.warn(`Model not found: ${modelId}`);
return;
}
this.getModelTransformTargets(meshes).forEach(mesh => {
mesh.position.x = position.x;
mesh.position.y = position.y;
mesh.position.z = position.z;
});
}
/**
* 设置模型缩放
* @param modelId 模型ID
* @param scale 缩放向量 {x, y, z}
*/
setScale(modelId: string, scale: { x: number; y: number; z: number }): void {
const meshes = this.modelDic.Get(modelId);
if (!meshes?.length) {
console.warn(`Model not found: ${modelId}`);
return;
}
this.getModelTransformTargets(meshes).forEach(mesh => {
mesh.scaling.x = scale.x;
mesh.scaling.y = scale.y;
mesh.scaling.z = scale.z;
});
}
}

View File

@ -0,0 +1,80 @@
import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
import { PositionGizmo } from '@babylonjs/core/Gizmos/positionGizmo';
import { UtilityLayerRenderer } from '@babylonjs/core/Rendering/utilityLayerRenderer';
import { MainApp } from './MainApp';
import { Monobehiver } from '../base/Monobehiver';
export class AppPositionGizmo extends Monobehiver {
private utilityLayer: UtilityLayerRenderer | null = null;
private gizmo: PositionGizmo | null = null;
private enabled = true;
private rotationEnabled = false;
private scaleEnabled = false;
constructor(mainApp: MainApp) {
super(mainApp);
}
Awake(): void {
const scene = this.mainApp.appScene.object;
if (!scene) return;
this.utilityLayer = new UtilityLayerRenderer(scene);
this.gizmo = new PositionGizmo(this.utilityLayer);
this.gizmo.updateGizmoRotationToMatchAttachedMesh = false;
this.gizmo.updateGizmoPositionToMatchAttachedMesh = true;
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
if (!enabled) {
this.detach();
}
}
configure(options?: { position?: boolean; rotation?: boolean; scale?: boolean }): void {
if (!options) return;
if (typeof options.position === 'boolean') {
this.setEnabled(options.position);
}
if (typeof options.rotation === 'boolean') {
this.rotationEnabled = options.rotation;
}
if (typeof options.scale === 'boolean') {
this.scaleEnabled = options.scale;
}
}
toggle(): void {
this.setEnabled(!this.enabled);
}
attach(mesh: AbstractMesh | null): void {
if (!this.enabled || !this.gizmo) return;
this.gizmo.attachedMesh = mesh;
}
detach(): void {
if (this.gizmo) {
this.gizmo.attachedMesh = null;
}
}
isEnabled(): boolean {
return this.enabled;
}
getAttachedMesh(): AbstractMesh | null {
return this.gizmo?.attachedMesh ?? null;
}
dispose(): void {
this.gizmo?.dispose();
this.utilityLayer?.dispose();
this.gizmo = null;
this.utilityLayer = null;
}
}

View File

@ -87,17 +87,35 @@ class AppRay extends Monobehiver {
return;
}
this.mainApp.appDomTo3D.hideAll()
const materialName = pickInfo.pickedMesh.material?.name || '';
const holdingShift = Boolean((evt as any).shiftKey);
const modelMeshes = this.mainApp.appModel.getModelMeshesByMesh(pickInfo.pickedMesh);
if (holdingShift) {
this.mainApp.appSelectionOutline.toggle(modelMeshes);
} else {
this.mainApp.appSelectionOutline.select(modelMeshes);
}
const transformTarget = this.mainApp.appModel.getModelTransformTargetByMesh(pickInfo.pickedMesh);
this.mainApp.appPositionGizmo.attach(transformTarget ?? pickInfo.pickedMesh);
// 获取模型元数据
const modelMetadata = this.mainApp.appModel.getMetadataByMesh(pickInfo.pickedMesh);
EventBridge.modelClick({
meshName: pickInfo.pickedMesh.name,
pickedMesh: pickInfo.pickedMesh,
pickedPoint: pickInfo.pickedPoint,
materialName: materialName,
modelControlType: modelMetadata?.modelControlType,
});
}
else{
console.log(1111);
this.mainApp.appSelectionOutline.clear();
this.mainApp.appPositionGizmo.detach();
this.mainApp.appDomTo3D.hideAll()
}
}

View File

@ -0,0 +1,159 @@
import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
import { Color3 } from '@babylonjs/core/Maths/math.color';
import { SelectionOutlineLayer } from '@babylonjs/core/Layers/selectionOutlineLayer';
import '@babylonjs/core/Layers/effectLayerSceneComponent';
import { MainApp } from './MainApp';
import { Monobehiver } from '../base/Monobehiver';
type OutlineConfig = {
enable?: boolean;
color?: Color3 | string;
thickness?: number;
width?: number;
occlusionStrength?: number;
occlusionThreshold?: number;
};
export class AppSelectionOutline extends Monobehiver {
private selectedMeshes: AbstractMesh[] = [];
private outlineLayer: SelectionOutlineLayer | null = null;
private enabled = true;
private color = new Color3(0.1, 0.65, 1);
private width = 0.08;
private occlusionStrength = 0.9;
private occlusionThreshold = 0.0002;
constructor(mainApp: MainApp) {
super(mainApp);
}
init(): void {
const scene = this.mainApp.appScene.object;
if (!scene || this.outlineLayer) return;
this.outlineLayer = new SelectionOutlineLayer('selection-outline', scene, {
mainTextureRatio: 1,
});
this.applyLayerConfig();
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
if (!enabled) {
this.clear();
}
}
setStyle(options: { color?: Color3 | string; width?: number }): void {
this.configure(options);
}
configure(options?: OutlineConfig): void {
if (!options) return;
if (typeof options.enable === 'boolean') {
this.setEnabled(options.enable);
}
if (options.color instanceof Color3) {
this.color = options.color;
} else if (typeof options.color === 'string') {
this.color = Color3.FromHexString(options.color);
}
if (typeof options.thickness === 'number') {
this.width = options.thickness;
} else if (typeof options.width === 'number') {
this.width = options.width;
}
if (typeof options.occlusionStrength === 'number') {
this.occlusionStrength = options.occlusionStrength;
}
if (typeof options.occlusionThreshold === 'number') {
this.occlusionThreshold = options.occlusionThreshold;
}
this.applyLayerConfig();
this.rebuildLayerSelection(this.selectedMeshes);
}
select(meshes: AbstractMesh | AbstractMesh[], additive = false): void {
if (!this.enabled) return;
this.init();
if (!additive) {
this.clear();
}
this.addGroup(this.normalizeMeshes(meshes));
}
toggle(meshes: AbstractMesh | AbstractMesh[]): void {
if (!this.enabled) return;
this.init();
const targets = this.normalizeMeshes(meshes);
if (targets.length && targets.every(mesh => this.isSelected(mesh))) {
this.remove(targets);
return;
}
this.addGroup(targets);
}
remove(meshes: AbstractMesh | AbstractMesh[]): void {
const targetIds = new Set(this.normalizeMeshes(meshes).map(mesh => mesh.uniqueId));
this.selectedMeshes = this.selectedMeshes.filter(item => !targetIds.has(item.uniqueId));
this.rebuildLayerSelection(this.selectedMeshes);
}
clear(): void {
this.outlineLayer?.clearSelection();
this.selectedMeshes = [];
}
getSelection(): AbstractMesh[] {
return [...this.selectedMeshes];
}
private addGroup(meshes: AbstractMesh[]): void {
const newMeshes = meshes.filter(mesh => !this.isSelected(mesh));
if (!newMeshes.length) return;
this.selectedMeshes.push(...newMeshes);
this.rebuildLayerSelection(this.selectedMeshes);
}
private isSelected(mesh: AbstractMesh): boolean {
return this.selectedMeshes.some(item => item.uniqueId === mesh.uniqueId);
}
private normalizeMeshes(meshes: AbstractMesh | AbstractMesh[]): AbstractMesh[] {
const input = Array.isArray(meshes) ? meshes : [meshes];
const uniqueMeshes = new Map<number, AbstractMesh>();
input.forEach(mesh => {
if (!mesh || mesh.isDisposed() || mesh.metadata?.type === 'hotspot') return;
if (!mesh.isEnabled() || mesh.getTotalVertices() <= 0 || !mesh.material) return;
uniqueMeshes.set(mesh.uniqueId, mesh);
});
return [...uniqueMeshes.values()];
}
private rebuildLayerSelection(meshes: AbstractMesh[]): void {
this.outlineLayer?.clearSelection();
meshes.forEach(mesh => this.outlineLayer?.addSelection(mesh));
}
private applyLayerConfig(): void {
if (!this.outlineLayer) return;
this.outlineLayer.outlineColor = this.color;
this.outlineLayer.outlineThickness = this.width;
this.outlineLayer.occlusionStrength = this.occlusionStrength;
this.outlineLayer.occlusionThreshold = this.occlusionThreshold;
}
}

View File

@ -15,6 +15,8 @@ import { GameManager } from './GameManager';
import { EventBridge } from '../event/bridge';
import { AppHotspot } from './AppHotspot';
import { AppDomTo3D } from './AppDomTo3D';
import { AppSelectionOutline } from './AppSelectionOutline';
import { AppPositionGizmo } from './AppPositionGizmo';
/**
* 主应用类 - 3D场景的核心控制器
@ -30,6 +32,8 @@ export class MainApp {
appRay: AppRay;
appHotspot: AppHotspot;
appDomTo3D: AppDomTo3D;
appSelectionOutline: AppSelectionOutline;
appPositionGizmo: AppPositionGizmo;
gameManager: GameManager;
@ -43,6 +47,8 @@ export class MainApp {
this.appRay = new AppRay(this);
this.appHotspot = new AppHotspot(this);
this.appDomTo3D = new AppDomTo3D(this);
this.appSelectionOutline = new AppSelectionOutline(this);
this.appPositionGizmo = new AppPositionGizmo(this);
this.gameManager = new GameManager(this);
window.addEventListener("resize", () => this.appEngin.handleResize());
@ -55,7 +61,11 @@ export class MainApp {
loadAConfig(config: any): void {
AppConfig.container = config.container;
AppConfig.modelUrlList = config.modelUrlList || [];
AppConfig.env = config.env;
AppConfig.env = { ...AppConfig.env, ...(config.env || {}) };
AppConfig.gizmo = { ...AppConfig.gizmo, ...(config.gizmo || {}) };
AppConfig.outline = { ...AppConfig.outline, ...(config.outline || {}) };
this.appPositionGizmo.configure(AppConfig.gizmo);
this.appSelectionOutline.configure(AppConfig.outline);
}
async loadModel(): Promise<void> {
@ -73,6 +83,8 @@ export class MainApp {
this.appLight.Awake();
this.appEnv.Awake();
this.appRay.Awake();
this.appSelectionOutline.init();
this.appPositionGizmo.Awake();
this.appDomTo3D.init();
this.appModel.initManagers();
this.update();
@ -93,6 +105,7 @@ export class MainApp {
async dispose(): Promise<void> {
this.appModel?.clean();
this.appEnv?.clean();
this.appPositionGizmo?.dispose();
// this.appHotspot?.clear();
}
}