feat(All):Initial
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 性能状态监视器,基于stats.js
|
||||
* 默认展示全部面板
|
||||
*/
|
||||
import ThreeStats from 'three/examples/jsm/libs/stats.module.js';
|
||||
import Viewer from "@/core/viewer/Viewer.ts";
|
||||
|
||||
export class Stats {
|
||||
private viewer: Viewer;
|
||||
private threeStats: ThreeStats;
|
||||
private panel = 0;
|
||||
private _visible = true;
|
||||
|
||||
private fns:{
|
||||
beforeRender:null | (() => void);
|
||||
afterRender:null | (() => void);
|
||||
} = {
|
||||
beforeRender: null,
|
||||
afterRender: null,
|
||||
}
|
||||
|
||||
constructor(viewer: Viewer) {
|
||||
this.viewer = viewer;
|
||||
this.threeStats = new ThreeStats();
|
||||
|
||||
this.initEvent();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
get domElement():HTMLElement {
|
||||
return this.threeStats.dom;
|
||||
}
|
||||
|
||||
get visible(){
|
||||
return this._visible;
|
||||
}
|
||||
|
||||
set visible(visible:boolean) {
|
||||
this._visible = visible;
|
||||
|
||||
this.domElement.style.display = visible ? "block" : 'none';
|
||||
}
|
||||
|
||||
initEvent(){
|
||||
this.fns.beforeRender = () => {
|
||||
if(!this.visible) return;
|
||||
|
||||
this.threeStats.begin();
|
||||
};
|
||||
this.viewer.addEventListener("beforeRender",this.fns.beforeRender);
|
||||
|
||||
this.fns.afterRender = () => {
|
||||
if(!this.visible) return;
|
||||
|
||||
this.threeStats.end();
|
||||
};
|
||||
this.viewer.addEventListener("afterRender",this.fns.afterRender);
|
||||
}
|
||||
|
||||
init(){
|
||||
const canvases = this.domElement.querySelectorAll("canvas");
|
||||
canvases.forEach(canvas => {
|
||||
canvas.style.width = "5rem";
|
||||
canvas.style.height = "3rem";
|
||||
canvas.style.display = "block";
|
||||
});
|
||||
}
|
||||
|
||||
showPanel(type: number | 'fps' | 'ms' | 'mb') {
|
||||
if (typeof type === 'number') {
|
||||
this.threeStats.showPanel(type);
|
||||
this.panel = type;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case 'fps':
|
||||
this.threeStats.showPanel(0);
|
||||
this.panel = 0;
|
||||
break;
|
||||
case 'ms':
|
||||
this.threeStats.showPanel(1);
|
||||
this.panel = 1;
|
||||
break;
|
||||
case 'mb':
|
||||
this.threeStats.showPanel(2);
|
||||
this.panel = 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
showAllPanels(show:boolean) {
|
||||
const canvases = this.domElement.querySelectorAll("canvas");
|
||||
canvases.forEach(canvas => {
|
||||
canvas.style.display = show ? "block" : "none";
|
||||
});
|
||||
|
||||
if(!show){
|
||||
this.showPanel(this.panel);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(){
|
||||
if(this.fns.beforeRender){
|
||||
this.viewer.removeEventListener("beforeRender",this.fns.beforeRender);
|
||||
this.fns.beforeRender = null;
|
||||
}
|
||||
|
||||
if(this.fns.afterRender){
|
||||
this.viewer.removeEventListener("afterRender",this.fns.afterRender);
|
||||
this.fns.afterRender = null;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
this.threeStats = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Object3D,Box3,Vector3} from "three";
|
||||
import CameraControls from "camera-controls";
|
||||
|
||||
export function focusObject(object:Object3D,controls:CameraControls,enableTransition: boolean = true){
|
||||
const box3 = new Box3();
|
||||
box3.setFromObject(object);
|
||||
|
||||
if(box3.isEmpty()){
|
||||
box3.set(new Vector3(object.position.x-1, object.position.y-1, object.position.z-1), new Vector3(object.position.x+1, object.position.y+1, object.position.z+1));
|
||||
}
|
||||
return controls.fitToBox(box3,enableTransition);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Object3D,InstancedMesh,Mesh,Matrix4,AnimationAction,AnimationClip } from "three";
|
||||
import App from "@/core/app/App";
|
||||
|
||||
export * from "./material";
|
||||
export * from "./Stats";
|
||||
export * from "./controls";
|
||||
|
||||
/**
|
||||
* 获取对象到父对象的路径(结果不包含parentObject)
|
||||
* @param parentObject
|
||||
* @param object
|
||||
* @param attr 对象属性名
|
||||
* @param splitter 路径分隔符
|
||||
*/
|
||||
export function getParentPath(parentObject:Object3D,object:Object3D,attr = 'name',splitter = '/'){
|
||||
if(!parentObject || !object) return '';
|
||||
|
||||
if(parentObject === object) return object[attr];
|
||||
|
||||
let path = [object[attr]];
|
||||
|
||||
const getPath = (obj) => {
|
||||
if(!obj.parent) return;
|
||||
|
||||
if(obj.parent === parentObject) return;
|
||||
|
||||
path.unshift(obj.parent[attr]);
|
||||
|
||||
getPath(obj.parent);
|
||||
}
|
||||
getPath(object);
|
||||
|
||||
return path.join(splitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取鼠标按下的位置
|
||||
* @param dom
|
||||
* @param x
|
||||
* @param y
|
||||
*/
|
||||
export function getMousePosition(dom: HTMLElement, x: number, y: number) {
|
||||
const rect = dom.getBoundingClientRect();
|
||||
return [(x - rect.left) / rect.width, (y - rect.top) / rect.height];
|
||||
}
|
||||
|
||||
/**
|
||||
* InstancedMesh 解出所有 mesh
|
||||
*/
|
||||
export function getMeshByInstancedMesh(instancedMesh:InstancedMesh){
|
||||
const meshes:Mesh[] = [];
|
||||
// if (instancedMesh.material === undefined) return meshes;
|
||||
|
||||
const matrixWorld = instancedMesh.matrixWorld;
|
||||
const count = instancedMesh.count;
|
||||
|
||||
for (let instanceId = 0; instanceId < count; instanceId++) {
|
||||
const _mesh = new Mesh();
|
||||
const _instanceLocalMatrix = new Matrix4();
|
||||
const _instanceWorldMatrix = new Matrix4();
|
||||
|
||||
_mesh.geometry = instancedMesh.geometry;
|
||||
_mesh.material = instancedMesh.material;
|
||||
|
||||
// 计算每个实例的世界矩阵
|
||||
instancedMesh.getMatrixAt(instanceId, _instanceLocalMatrix);
|
||||
|
||||
_instanceWorldMatrix.multiplyMatrices(matrixWorld, _instanceLocalMatrix);
|
||||
|
||||
// 网格表示这个单一实例
|
||||
_mesh.matrixWorld = _instanceWorldMatrix;
|
||||
|
||||
meshes.push(_mesh);
|
||||
}
|
||||
|
||||
return meshes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是group,因为导入有可能存在被定义为Object3D类型的group
|
||||
*/
|
||||
export function isGroup(object3D:Object3D){
|
||||
return (object3D.isGroup || object3D.children.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是代理粒子发射器的3D对象
|
||||
*/
|
||||
export function isParticleObject(object:Object3D | null){
|
||||
return object && object.type === "Particle" && object.emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是Billboard 3D对象
|
||||
*/
|
||||
export function isBillboardObject(object:Object3D | null){
|
||||
return object && object.type === "Billboard" && object.options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是HtmlPanel 3D对象
|
||||
*/
|
||||
export function isHtmlPanelObject(object:Object3D | null){
|
||||
return object && (object.isHtmlPanel || object.isHtmlSprite) && object.element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景/物体中的所有动画
|
||||
*/
|
||||
export function getAnimations(object = App.scene) {
|
||||
const animations: any = [];
|
||||
|
||||
object.traverse(function (object) {
|
||||
animations.push(...object.animations);
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景/物体中的所有动画剪辑
|
||||
*/
|
||||
export function getAnimationClips(object:Object3D = App.scene) {
|
||||
const animations: any = [];
|
||||
|
||||
object.traverse(function (object) {
|
||||
object.animations.forEach(animation => {
|
||||
if(animation instanceof AnimationAction){
|
||||
animations.push(animation.getClip());
|
||||
}
|
||||
|
||||
if(animation instanceof AnimationClip){
|
||||
animations.push(animation);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import JSZip from "jszip";
|
||||
import * as THREE from "three";
|
||||
import Loader from "@/core/loader/Loader.ts";
|
||||
import App from "@/core/app/App.ts";
|
||||
|
||||
/**
|
||||
* 解析材质zip包
|
||||
*/
|
||||
export function parseMaterialZip(zipFile: File): Promise<THREE.MeshStandardMaterial> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const zip = new JSZip();
|
||||
|
||||
const zipContent = await zip.loadAsync(zipFile);
|
||||
|
||||
// 强制检查根目录下是否存在material.json
|
||||
let materialJson: any = zipContent.file('material.json');
|
||||
if (!materialJson) {
|
||||
materialJson = {
|
||||
textures: {},
|
||||
properties: {}
|
||||
}
|
||||
|
||||
// 获取所有文件路径列表(JSZip存储结构为 {路径: 文件对象})
|
||||
const filePaths = Object.keys(zipContent.files);
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const relativePath: string = filePaths[i];
|
||||
const file = zipContent.file(relativePath);
|
||||
|
||||
if (!file) continue;
|
||||
|
||||
// 判断是否为文件(JSZip中目录路径以 '/' 结尾)
|
||||
if (file.dir || relativePath.endsWith('/')) continue;
|
||||
|
||||
if (relativePath.includes("baseColor")) {
|
||||
materialJson.textures.baseColor = relativePath;
|
||||
} else if (relativePath.includes("normal")) {
|
||||
materialJson.textures.normal = relativePath;
|
||||
} else if (relativePath.includes("bump")) {
|
||||
materialJson.textures.bump = relativePath;
|
||||
} else if (relativePath.includes("displacement")) {
|
||||
materialJson.textures.displacement = relativePath;
|
||||
} else if (relativePath.includes("emissive")) {
|
||||
materialJson.textures.emissive = relativePath;
|
||||
} else if (relativePath.includes("alpha")) {
|
||||
materialJson.textures.alpha = relativePath;
|
||||
} else if (relativePath.includes("env")) {
|
||||
materialJson.textures.env = relativePath;
|
||||
} else if (relativePath.includes("light")) {
|
||||
materialJson.textures.light = relativePath;
|
||||
} else {
|
||||
// arm可能在一张图上各占一个通道
|
||||
if (relativePath.includes("roughness")) {
|
||||
materialJson.textures.roughness = relativePath;
|
||||
}
|
||||
if (relativePath.includes("metalness")) {
|
||||
materialJson.textures.metalness = relativePath;
|
||||
}
|
||||
if (relativePath.includes("ao")) {
|
||||
materialJson.textures.ao = relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
materialJson = JSON.parse(await materialJson.async('text'));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 并行加载所有纹理
|
||||
const texturePromises = Object.entries(materialJson.textures).map(
|
||||
async ([type, path]:any) => {
|
||||
const textureFile = zipContent.file(path);
|
||||
if (!textureFile) {
|
||||
console.warn(`Texture file not found: ${path}`);
|
||||
return { type, texture: null };
|
||||
}
|
||||
|
||||
const extension = path.toString().split(".").pop()?.toLowerCase() || "jpg";
|
||||
let textureBlob: Blob;
|
||||
|
||||
try {
|
||||
// 特殊处理 EXR 格式
|
||||
if (extension === "exr") {
|
||||
const buffer = await textureFile.async("arraybuffer");
|
||||
textureBlob = new Blob([buffer], { type: "image/x-exr" });
|
||||
} else {
|
||||
textureBlob = await textureFile.async("blob");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load texture (${type}):`, err);
|
||||
return { type, texture: null };
|
||||
}
|
||||
|
||||
const textureUrl = URL.createObjectURL(textureBlob);
|
||||
|
||||
return new Promise<{ type: string; texture: THREE.Texture | null }>(
|
||||
(resolve) => {
|
||||
Loader.loadUrlTexture(
|
||||
extension,
|
||||
textureUrl,
|
||||
(texture: THREE.Texture) => {
|
||||
URL.revokeObjectURL(textureUrl); // 清理资源
|
||||
resolve({ type, texture });
|
||||
},
|
||||
(error: Error) => {
|
||||
URL.revokeObjectURL(textureUrl);
|
||||
console.error(`Texture load error (${type}):`, error);
|
||||
resolve({ type, texture: null });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// 等待所有纹理加载完成
|
||||
const textureResults = await Promise.all(texturePromises);
|
||||
const textures = textureResults.reduce((acc, { type, texture }) => {
|
||||
if (texture) acc[type] = texture;
|
||||
return acc;
|
||||
}, {} as Record<string, THREE.Texture>);
|
||||
|
||||
// 处理无有效纹理的情况
|
||||
if (Object.keys(textures).length === 0) {
|
||||
throw new Error("No valid textures found in the zip file");
|
||||
}
|
||||
|
||||
const material = await App.createPBRMaterial(textures, materialJson.properties || {});
|
||||
|
||||
resolve(material);
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user