feat(All):Initial

This commit is contained in:
2025-10-04 23:36:07 +08:00
commit 2b4e5d2668
1321 changed files with 415958 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/4/6 12:02
* @description 广告牌对象
*/
import * as THREE from 'three'
import {POSITION} from "@/constant";
import BillboardTexture from "./texture/BillboardTexture";
import {deepAssign} from "@/utils";
export interface BillboardEventMap extends THREE.Object3DEventMap {
imgLoaded: { url:string };
redraw: { url:string }
}
export const getDefaultBillboardOptions = () => ({
name: "Billboard",
position: [0, 0, 0],
image: {
// 图像地址
url: '',
// 可见性
visible: false,
// 宽度
width: 32,
// 高度
height: 32,
// 旋转角度 deg
rotate: 0,
// 与文本的间距
margin: 2,
// 位置
position: POSITION.CENTER,
// 置顶
top: false,
},
text: {
// 内容
value: '',
// 可见性
visible: false,
// 字体大小
fontSize: 16,
// 字体颜色
fontColor: "#ffffff",
// 字体
fontFamily: `sans-serif,"Source Han Sans SC","Source Han Sans","WenQuanYi Micro Hei", "Times New Roman", "隶书", "幼圆"`,
// 加粗
fontWeight: 400,
// 字体风格(斜体)
fontStyle: "normal",
// 行间距
lineGap: 0,
// 内边距
padding: 0,
// 对齐方式, left, center, right
align: "center",
// 文本基线, top, middle, bottom,alphabetic,hanging,ideographic
baseline: "top",
// 描边宽度
strokeWidth: 0,
// 描边颜色
strokeColor: "#FFFFFF",
// 是否填充
fill: false,
// 填充颜色
fillColor: "#000000",
}
})
export default class Billboard extends THREE.Sprite<BillboardEventMap> {
type = 'Billboard';
isBillboard = true;
options = getDefaultBillboardOptions();
constructor(options: IBillboard.options, material?: THREE.SpriteMaterial) {
super()
deepAssign(this.options, options);
this.name = this.options.name;
const texture = new BillboardTexture(this.options, material ? (material.map?.image) : undefined);
texture.colorSpace = THREE.SRGBColorSpace;
if (material) {
if (material.map) {
texture.mapping = material.map.mapping;
texture.wrapS = material.map.wrapS;
texture.wrapT = material.map.wrapT;
texture.magFilter = material.map.magFilter;
texture.minFilter = material.map.minFilter;
texture.anisotropy = material.map.anisotropy;
texture.format = material.map.format;
texture.type = material.map.type;
texture.colorSpace = material.map.colorSpace;
texture.repeat.copy(material.map.repeat);
texture.offset.copy(material.map.offset);
texture.center.copy(material.map.center);
texture.matrix.copy(material.map.matrix);
}
this.material = material;
this.material.map = texture;
} else {
this.material = new THREE.SpriteMaterial({
map: texture,
sizeAttenuation: true,
depthWrite: true,
});
}
// @ts-ignore
texture.addEventListener("imgLoaded", (event) => {
this.material.needsUpdate = true;
// @ts-ignore
this.dispatchEvent({type: "imgLoaded", url: event.url})
})
// @ts-ignore
texture.addEventListener("redraw", (event) => {
const wh = {
width: texture.width,
height: texture.height,
}
if (wh.width > wh.height) {
wh.width = wh.width / wh.height;
wh.height = 1;
} else {
wh.height = wh.height / wh.width;
wh.width = 1;
}
this.geometry = new THREE.PlaneGeometry(wh.width, wh.height);
// @ts-ignore
this.dispatchEvent({type: "redraw", url: event.url})
})
this.position.set(this.options.position[0], this.options.position[1], this.options.position[2]);
// this.center = new THREE.Vector2(0.5, 0);
}
/**
* 获取json配置
*/
toJSON(meta?: THREE.JSONMeta) {
const options = JSON.parse(JSON.stringify(this.options));
options.name = this.name;
options.position = this.position.toArray();
const superJSON = super.toJSON(meta);
superJSON.object.options = options;
return superJSON;
}
/**
* 从json配置解析
*/
static fromJSON(json: { material: THREE.SpriteMaterial, options: IBillboard.options }) {
return new Billboard(json.options, json.material);
}
}
+573
View File
@@ -0,0 +1,573 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/4/10 0:29
* @description html面板
*/
import {JSONMeta} from "three";
import JSZip from "jszip";
import {CSS3DObject, CSS3DSprite} from 'three/examples/jsm/renderers/CSS3DRenderer.js';
interface IHtmlPanelOption {
// 类型是否是精灵
isSprite: boolean;
// 代码内容
codes: Array<{ name: string, content: string | ArrayBuffer, isIndex?: boolean }>;
// 对应的代码是单html文件还是多文件
isSingleHtml: boolean;
}
/**
* html面板转换器
* @description 如果是单html文件,沙箱环境使用with + proxy;如果是zip包,沙箱环境使用iframe
*/
class HtmlPanelConverter {
private static instance: HtmlPanelConverter | null = null;
private config = {
// 是否允许执行脚本
allowScripts: true,
// 文件大小限制
maxFileSize: 1024 * 1024 * 10, // 10M
// 标签黑名单
notAllowedTags: ['iframe'],
};
private readonly sandbox: any;
private constructor() {
// 创建安全沙箱环境
this.sandbox = this._createSandbox();
// 配套CSS样式(需添加到页面)
const style = document.createElement('style');
style.textContent = `
.css3d-imported-content {
pointer-events: none !important;
// overflow: hidden;
}
.css3d-imported-content [style] {
will-change: transform, opacity;
}
`;
document.head.appendChild(style);
}
// 获取单例实例
public static getInstance(): HtmlPanelConverter {
if (!HtmlPanelConverter.instance) {
HtmlPanelConverter.instance = new HtmlPanelConverter();
}
return HtmlPanelConverter.instance;
}
// 创建安全沙箱
private _createSandbox() {
// 重写全局的 fetch 方法
const originalFetch = window.fetch;
// 重写 XMLHttpRequest 的 open 方法
const originalXMLHttpRequest = window.XMLHttpRequest;
// 重写Websocket
const originalWebSocket = window.WebSocket;
// url检查
const checkUrl = (url: string) => {
if(url.indexOf(import.meta.env.VITE_GLOB_ORIGIN) > -1) return false;
if(url.indexOf('http://') === -1 && url.indexOf('https://') === -1 && url.indexOf('ws://') === -1 && url.indexOf('wss://') === -1) return false;
return true;
}
const sandbox = {
fetch: async (url: string, options: any) => {
if (checkUrl(url)) {
throw new Error('请求被禁止:无法向当前服务器发起请求');
}
// 如果不是当前服务器的请求,则调用原始的 fetch 方法
return originalFetch(url, options);
},
XMLHttpRequest: () => {
const xhr = new originalXMLHttpRequest();
// 拦截 open 方法
const originalOpen = xhr.open;
// @ts-ignore
xhr.open = function (method, url, async, user, password) {
if (checkUrl(url as string)) {
throw new Error('请求被禁止:无法向当前服务器发起请求');
}
// 如果不是当前服务器的请求,则调用原始的 open 方法
return originalOpen.call(xhr, method, url, async, user, password);
};
return xhr;
},
WebSocket: (url:string) => {
if (checkUrl(url)) {
throw new Error('请求被禁止:无法连接到当前服务器的 WebSocket');
}
return new originalWebSocket(url);
},
// 其他要修改的的全局对象...
};
return new Proxy(sandbox, {
// 拦截所有属性,防止到 Proxy 对象以外的作用域链查找。
has: () => true,
get: (target, prop) => {
// 加固,防止逃逸
if (prop === Symbol.unscopables) {
return undefined;
}
if (Reflect.has(target, prop)) {
return Reflect.get(target, prop);
}
// 暂时允许从window直接获取属性,后续考虑限制。因为不限制很危险,比如可以获取到window.location、window.history等敏感信息。
// else{
// return undefined;
// }
// 禁止访问的属性
if(['location', 'history', 'top', 'parent', 'frameElement'].includes(typeof prop === "string" ? prop : '')) return undefined;
//如果找不到,就直接从window对象上取值
const rawValue = Reflect.get(window, prop);
//如果兜底的是一个函数,需要绑定window对象,比如window.addEventListener
if (typeof rawValue === 'function') {
const valueStr = rawValue.toString();
if (!/^function\s+[A-Z]/.test(valueStr) && !/^class\s+/.test(valueStr)) {
return rawValue.bind(window); // 所有 window 上非构造函数调用时候的 this 绑定window对象
}
}
return rawValue;
}
});
}
// 主加载方法
loadAsync(option: { url: string, isSprite: boolean, fileName?: string }): Promise<HtmlPanel | HtmlSprite> {
return new Promise(async (resolve, reject) => {
if (!option.url) {
reject(new Error('请输入url参数'));
return;
}
const htmlPanelOption: IHtmlPanelOption = {
isSprite: option.isSprite,
isSingleHtml: true,
codes: []
}
const response = await fetch(option.url);
try{
this._validateResponse(response);
}catch (e){
reject(e);
return;
}
// 判断是zip包还是html文件
let suffix = option.fileName?.split('.').pop() || option.url.split('.').pop();
if (suffix && ['zip'].includes(suffix)) {
// 解压zip包
const zip = new JSZip();
const zipContent = await zip.loadAsync(await response.arrayBuffer());
// 强制检查根目录下的index.html
const mainHtmlFile = zipContent.file('index.html');
if (!mainHtmlFile) {
reject(new Error('The .zip file root directory must contain index.html'));
return;
}
// 获取所有文件路径列表(JSZip存储结构为 {路径: 文件对象})
const filePaths = Object.keys(zipContent.files);
for (let i = 0; i < filePaths.length; i++) {
const relativePath = filePaths[i];
const file = zipContent.file(relativePath); // 通过路径获取文件对象
if(!file) continue;
// 判断是否为文件(JSZip中目录路径以 '/' 结尾)
if (!file.dir && !relativePath.endsWith('/')) {
const _isEdit = this._isEditable(file.name);
// 同步读取文件内容
const content = _isEdit ? await file.async('text') : await file.async('arraybuffer') //await file.async("binarystring");
// 存储到目标数组
htmlPanelOption.codes.push({
name: relativePath,
content: content,
isIndex: relativePath === 'index.html'
});
}
}
htmlPanelOption.isSingleHtml = false;
} else {
if (!option.fileName) {
// 解析URL以获取文件名
// const _url = new URL(option.url, document.baseURI);
const _url = new URL(option.url, "https://editor.astraljs.com");
option.fileName = _url.pathname.split('/').pop() || 'index.html';
}
htmlPanelOption.codes.push({
name: option.fileName,
content: await response.text(),
isIndex: true
});
htmlPanelOption.isSingleHtml = true;
}
try{
const htmlObject3D = this.parseToCSS3D(htmlPanelOption);
resolve(htmlObject3D);
}catch (e){
reject(e);
}
})
}
// 安全验证方法
private _validateResponse(response: Response) {
if (!response.ok) throw new Error('加载失败');
if (Number(response.headers.get('content-length')) > this.config.maxFileSize) {
throw new Error('文件大小超出限制');
}
}
// 解析HTML生成CSS3D对象
parseToCSS3D(options: IHtmlPanelOption) {
if (options.codes.length === 0) throw new Error('解析内容不能为空');
// 解析文档
let container: HTMLDivElement | HTMLIFrameElement;
if (options.isSingleHtml) {
// 创建容器
container = document.createElement('div');
container.className = 'css3d-imported-content';
const htmlDoc = this._parseHtml(options.codes[0].content as string);
// 克隆并处理内容
const content = this._processContent(htmlDoc);
// 仅添加content(body)的子节点
while (content.firstChild) {
container.appendChild(content.firstChild);
}
} else {
// TODO 解析zip,但是现在只处理了单文件HTML,后续加强支持解析多文件HTML
// 创建容器
container = document.createElement('div');
container.className = 'css3d-imported-content';
// 创建虚拟文件系统映射表
const filesMap = new Map<string, string>();
options.codes.forEach(code => {
const mimeType = this._getMimeType(code.name);
const blob = new Blob([code.content], {type: mimeType});
const blobURL = URL.createObjectURL(blob);
filesMap.set(code.name, blobURL);
});
// 获取主HTML内容
const mainHtmlCode = options.codes.find(code => code.isIndex);
if (!mainHtmlCode) throw new Error('主文件index.html不存在');
// 深度克隆文档对象以便修改
const htmlDoc = this._parseHtml(mainHtmlCode.content as string);
const basePath = mainHtmlCode.name.split('/').slice(0, -1).join('/') || '';
this._replaceResourceUrls(htmlDoc, filesMap, basePath);
const iframe = document.createElement('iframe');
iframe.style.border = 'none';
iframe.width = 192 * 5 + '';
iframe.height = 108 * 5 + '';
// @ts-ignore
(<any>iframe).sandbox = 'allow-same-origin allow-scripts';
iframe.srcdoc = htmlDoc.documentElement.outerHTML;
container.appendChild(iframe);
document.body.appendChild(container);
// 创建iframe并注入所有资源
const iframeDoc = iframe.contentDocument!;
// 添加基础路径保证相对路径解析
const baseTag = document.createElement('base');
baseTag.href = URL.createObjectURL(new Blob([], {type: 'text/html'}));
iframeDoc.head.prepend(baseTag);
// 清理blob URLs
iframe.addEventListener('load', () => {
filesMap.forEach(url => URL.revokeObjectURL(url));
// document.body.removeChild(container);
// container.remove();
});
}
if (!container) throw new Error('解析失败');
if (options.isSprite) {
return new HtmlSprite(container, options);
} else {
return new HtmlPanel(container, options);
}
}
// 使用DOMParser解析HTML结构
private _parseHtml(content: string) {
const parser = new DOMParser();
return parser.parseFromString(content, "text/html");
}
// 处理文档内容
private _processContent(htmlDoc: Document) {
const container = htmlDoc.documentElement.cloneNode(true) as HTMLElement;
// 清理危险元素
this._sanitizeContent(container);
// 处理脚本
if (this.config.allowScripts) {
this._processScripts(container);
}
return container;
}
// 清理危险内容
private _sanitizeContent(content: HTMLElement) {
// 删除不允许的标签
content.querySelectorAll('*').forEach(node => {
if (this.config.notAllowedTags.includes(node.tagName.toLowerCase())) {
node.remove();
}
});
// 删除危险属性
const dangerousAttrs = ['onload', 'onerror', 'onclick'];
content.querySelectorAll('*').forEach(node => {
dangerousAttrs.forEach(attr => node.removeAttribute(attr));
});
}
// 处理脚本内容
private async _processScripts(content: HTMLElement) {
const scripts = content.querySelectorAll('script');
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
const newScript = document.createElement('script');
let execute: Function | undefined;
// 执行脚本
const executeScript = (scriptContent:string) => {
try {
const code = `with(sandbox){${scriptContent}}`;
newScript.textContent = code;
execute = new Function('sandbox', code);
} catch (e) {
console.warn('脚本执行失败:', e);
}
// script.replaceWith(newScript);
execute && execute(this.sandbox);
}
// 检查是否存在src属性
const src = script.getAttribute('src');
if (src) {
// 处理外部脚本
try {
const res = await fetch(src);
if (!res.ok) {
console.error(`加载外部脚本失败: ${src}`);
continue; // 跳过当前脚本
}
executeScript(await res.text());
} catch (e) {
console.error(`加载外部脚本异常: ${src}`, e);
}
}else{
executeScript(script.textContent || '');
}
}
}
// 获取文件类型
private _getMimeType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
switch (ext) {
case 'html':
return 'text/html';
case 'css':
return 'text/css';
case 'js':
return 'application/javascript';
case 'json':
return 'application/json';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'gif':
return 'image/gif';
case 'svg':
return 'image/svg+xml';
case 'zip':
return 'application/zip';
default:
return 'text/plain';
}
}
// 通过filePath判断文件内容是否可编辑(html、css、js、json、svg
_isEditable(filePath: string): boolean {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
return ['html', 'css', 'js', 'json','svg'].includes(ext);
}
// 替换资源URL
private _replaceResourceUrls(doc: Document, filesMap: Map<string, string>, basePath: string) {
const attrMap = {
'script': 'src',
'link': 'href',
'img': 'src',
'audio': 'src',
'video': 'src',
'source': 'src',
'embed': 'src',
'object': 'data'
};
Object.entries(attrMap).forEach(([tag, attr]) => {
doc.querySelectorAll(`${tag}[${attr}]`).forEach(el => {
const originPath = el.getAttribute(attr);
if (!originPath) return;
// 路径标准化处理
const resolvedPath = new URL(originPath, `https://editor.astraljs.com/${basePath}/`).pathname.slice(1);
if (filesMap.has(resolvedPath)) {
el.setAttribute(attr, filesMap.get(resolvedPath)!);
}
});
});
// 上面的代码中为了解析路径使用了固定域名,正常而已解析结果不会受此影响,如果结果不对,可以改用下面的代码
// 使用虚拟路径协议代替真实域名
// const virtualProtocol = 'virtual-resource:';
// Object.entries(attrMap).forEach(([tag, attr]) => {
// doc.querySelectorAll(`${tag}[${attr}]`).forEach(el => {
// const originPath = el.getAttribute(attr);
// if (!originPath) return;
//
// // 构造无域名的标准化路径
// const resolved = new URL(originPath, `${virtualProtocol}//${basePath}/`);
// const resolvedPath = resolved.href
// .replace(`${virtualProtocol}//`, '') // 移除协议头
// .replace(/(\/\/+)/g, '/') // 处理多余斜杠
// .replace(/^\/+/, ''); // 移除开头的斜杠
//
// if (filesMap.has(resolvedPath)) {
// el.setAttribute(attr, filesMap.get(resolvedPath)!);
// }
// });
// });
}
}
class HtmlPanel extends CSS3DObject {
type = 'HtmlPanel';
isHtmlPanel = true;
options: IHtmlPanelOption;
constructor(element: HTMLElement, options: IHtmlPanelOption) {
super(element);
this.options = options;
}
/**
* 获取json配置
*/
toJSON(meta?: JSONMeta) {
const superJSON = super.toJSON(meta).object;
return {
metadata: {
version: 4.6,
type: 'Object',
generator: 'HtmlPanel.toJSON'
},
object: {
...superJSON,
type: this.type,
options: this.options
},
} as any;
}
/**
* 从json配置解析
*/
static fromJSON(data: any) {
return HtmlPanelConverter.getInstance().parseToCSS3D(data.options);
}
}
class HtmlSprite extends CSS3DSprite {
type = 'HtmlSprite';
isHtmlSprite = true;
options: IHtmlPanelOption;
constructor(element: HTMLElement, options: IHtmlPanelOption) {
super(element);
this.options = options;
}
/**
* 获取json配置
*/
toJSON(meta?: JSONMeta) {
const superJSON = super.toJSON(meta).object;
return {
metadata: {
version: 4.6,
type: 'Object',
generator: 'HtmlSprite.toJSON'
},
object: {
...superJSON,
type: this.type,
options: this.options
},
} as any;
}
/**
* 从json配置解析
*/
static fromJSON(data: any) {
return HtmlPanelConverter.getInstance().parseToCSS3D(data.options);
}
}
export {HtmlPanelConverter, HtmlPanel, HtmlSprite};
+54
View File
@@ -0,0 +1,54 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2024/5/21 22:08
* @description 带线框子模型的Mesh对象
*/
import {Mesh, Material, BufferGeometry, MeshBasicMaterial, LineSegments, LineBasicMaterial, EdgesGeometry} from 'three';
import {useDispatchSignal} from "@/hooks";
export function materialProxy(lineMesh: LineMesh) {
return new Proxy(lineMesh, {
set(target: LineMesh, p: string, newValue: any): boolean {
if(p === 'material'){
(<LineBasicMaterial>(<LineSegments>target.children[0]).material).dispose();
target.children = [];
// 更新场景树
useDispatchSignal("sceneGraphChanged");
}
target[p] = newValue;
return true;
}
})
}
export class LineMesh extends Mesh {
constructor(geometry = new BufferGeometry(), material: Material = new MeshBasicMaterial(), color = 0x00ffff) {
super(geometry, material);
// @ts-ignore
this.type = 'LineMesh';
const edges = new EdgesGeometry(geometry);
const edgesMaterial = new LineBasicMaterial({
color: color,
})
const line = new LineSegments(edges, edgesMaterial);
// let geometryArray = [geometry,edges];
// let materialArray = [material,edgesMaterial];
// const mergedGeometries = BufferGeometryUtils.mergeGeometries(geometryArray, false);
// const lineMesh = SceneUtils.createMultiMaterialObject(mergedGeometries, materialArray);
//
// this.parent?.add(lineMesh);
// this.removeFromParent();
this.add(line)
}
proxyMesh = materialProxy(this);
}
@@ -0,0 +1,934 @@
/**
* 用于代理粒子发射器的空对象,以便于进行场景树显示及控制操作
* @author ErSan
* @email mlt131220@163.com
* @date 2025-02-14 16:00:00
*/
import * as THREE from 'three';
import * as Particle from '@/core/libs/three-nebula';
import {ParticleSystem} from '@/core/viewer/modules/ParticleSystem';
import {ObjectLoader} from '@/core/loader/ObjectLoader';
import {useAddSignal, useDispatchSignal, useRemoveSignal} from '@/hooks';
/**
* 获取默认粒子配置
* @description 以函数调用的方式返回,避免在模块外被引用
*/
export const getDefaultParticleConfig = (): IParticle.Config => ({
attr: {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: 1,
totalEmitTimes: Infinity,
damping: 0.006,
life: Infinity,
numPan: {
min: 1,
max: 1
},
timePan: {
a: 0.1,
b: 0.1
}
},
init: {
mass: {
min: 1,
max: 1,
center: true,
isEnabled: false
},
life: {
min: 1,
max: 1,
center: true,
isEnabled: false
},
radius: {
width: 1,
height: 1,
center: false,
isEnabled: false
},
rotation: {
x: 0,
y: 0,
z: 0,
useEmitterRotation: false,
isEnabled: false
},
position: {
isEnabled: false,
zone: null
},
velocity: {
isEnabled: false,
velocity: null
},
body: {
isEnabled: false,
body: {
type: 'Sprite',
uuid: '',
}
}
},
behaviour: {
color: {
isEnabled: false,
colorA: "#002a4f",
colorB: "#0029FF",
life: Infinity,
easing: 'easeLinear',
},
scale: {
isEnabled: false,
scaleA: 1,
scaleB: 1,
life: Infinity,
easing: 'easeLinear',
},
alpha: {
isEnabled: false,
alphaA: 1,
alphaB: 1,
life: Infinity,
easing: 'easeLinear',
},
force: {
isEnabled: false,
fx: 0,
fy: 0,
fz: 0,
life: Infinity,
easing: 'easeLinear',
},
rotate: {
isEnabled: false,
x: 0,
y: 0,
z: 0,
life: Infinity,
easing: 'easeLinear',
},
randomDrift: {
isEnabled: false,
driftX: 0,
driftY: 0,
driftZ: 0,
delay: 0.03,
life: Infinity,
easing: 'easeLinear',
},
spring: {
isEnabled: false,
x: 0,
y: 0,
z: 0,
spring: 0.1,
friction: 0.98,
life: Infinity,
easing: 'easeLinear',
},
attraction: {
x: 0,
y: 0,
z: 0,
force: 100,
radius: 1000,
life: Infinity,
easing: 'easeLinear',
isEnabled: false
},
collision: {
useMass: false,
life: Infinity,
easing: 'easeLinear',
isEnabled: false
}
},
})
let _handleAddToParticleSystemFn, _handleParticleCreatedFn;
class ParticleEmitter extends THREE.Object3D {
emitter: Particle.Emitter;
isEmitterProxy = true;
constructor(emitter: Particle.Emitter) {
super();
// @ts-ignore
this.type = 'Particle';
this.emitter = emitter;
this.syncProperties();
this.proxyProperties();
this.initEvent();
}
initEvent(){
/**
* 需要做粒子的选中,选中时定位到这个粒子发射器的代理对象上
* 如果后续不需要做粒子选中了,就把下方代码删除
* 对应的particleSystemAddEmitter signal也删除
*/
_handleAddToParticleSystemFn = this.handleAddToParticleSystem.bind(this);
useAddSignal('particleSystemAddEmitter', _handleAddToParticleSystemFn)
this.emitter.particles.forEach(particle => {
if (!particle.target) return;
particle.target.proxy = this;
})
_handleParticleCreatedFn = this.handleParticleCreated.bind(this);
this.emitter.parent?.eventDispatcher.addEventListener('PARTICLE_CREATED', _handleParticleCreatedFn, true)
}
/**
* 监听粒子创建
* @param particle
*/
handleParticleCreated(particle){
if (!this.emitter?.particles) return;
if (!this.emitter.particles.includes(particle)) return;
if (!particle.target) return;
particle.target.proxy = this;
}
/**
* 添加到粒子系统时
* @param _emitter
*/
handleAddToParticleSystem(_emitter: Particle.Emitter){
if (_emitter === this.emitter) {
this.emitter.parent?.eventDispatcher.addEventListener('PARTICLE_CREATED', _handleParticleCreatedFn,true)
}
}
/**
* 同步粒子发射器的属性到组中
*/
syncProperties() {
this.position.set(this.emitter.position.x, this.emitter.position.y, this.emitter.position.z);
// 粒子发射器的缩放是统一的,无法从三个轴分开设置
this.scale.set(this.emitter.scale, this.emitter.scale, this.emitter.scale);
this.rotation.set(this.emitter.rotation.x, this.emitter.rotation.y, this.emitter.rotation.z);
this.updateMatrixWorld(true);
}
/**
* 拦截重要属性的 setter 方法,同步到粒子发射器中
*/
proxyProperties() {
// 重写 position 的 setter 方法
let _position = new THREE.Vector3().copy(this.position);
Object.defineProperty(this.position, 'x', {
get: () => _position.x,
set: (value: number) => {
_position.setX(value);
this.emitter.position.x = value;
},
configurable: true,
enumerable: true
});
Object.defineProperty(this.position, 'y', {
get: () => _position.y,
set: (value: number) => {
_position.setY(value);
this.emitter.position.y = value;
},
configurable: true,
enumerable: true
});
Object.defineProperty(this.position, 'z', {
get: () => _position.z,
set: (value: number) => {
_position.setZ(value);
this.emitter.position.z = value;
},
configurable: true,
enumerable: true
});
// 重写 rotation 的 setter 方法
let _rotation = this.rotation.clone();
Object.defineProperty(this.rotation, '_x', {
get: () => _rotation.x,
set: (value: number) => {
_rotation.x = value;
// this.emitter.rotation.x = value * THREE.MathUtils.RAD2DEG;
this.emitter.rotation.x = value;
},
configurable: true,
enumerable: true
});
Object.defineProperty(this.rotation, '_y', {
get: () => _rotation.y,
set: (value: number) => {
_rotation.y = value;
this.emitter.rotation.y = value;
},
configurable: true,
enumerable: true
});
Object.defineProperty(this.rotation, '_z', {
get: () => _rotation.z,
set: (value: number) => {
_rotation.z = value;
this.emitter.rotation.z = value;
},
configurable: true,
enumerable: true
});
// 重写 scale 的 setter 方法
let _scale = this.scale.clone();
Object.defineProperty(this.scale, 'x', {
get: () => _scale.x,
set: (value: number) => {
_scale.setX(value);
// 获取scale三轴中的最小值应用到粒子发射器
this.emitter.scale = Math.min(_scale.x, _scale.y, _scale.z);
},
configurable: true,
enumerable: true
});
Object.defineProperty(this.scale, 'y', {
get: () => _scale.y,
set: (value: number) => {
_scale.setY(value);
// 获取scale三轴中的最小值应用到粒子发射器
this.emitter.scale = Math.min(_scale.x, _scale.y, _scale.z);
},
configurable: true,
enumerable: true
});
Object.defineProperty(this.scale, 'z', {
get: () => _scale.z,
set: (value: number) => {
_scale.setZ(value);
// 获取scale三轴中的最小值应用到粒子发射器
this.emitter.scale = Math.min(_scale.x, _scale.y, _scale.z);
},
configurable: true,
enumerable: true
});
// 重写 visible 的 setter 方法
let _visible = this.visible, _totalEmitTimes = this.emitter.totalEmitTimes;
Object.defineProperty(this, 'visible', {
get: () => _visible,
set: (value: boolean) => {
_visible = value;
// 发射器上不存在直接控制显隐的属性,遍历粒子对象设置显隐影响瞬时性能,故使用emitter.totalEmitTimes控制显隐
this.emitter.totalEmitTimes = value ? _totalEmitTimes : 0;
},
configurable: true,
enumerable: true
})
}
/**
* 获取emitter的json配置
*/
getEmitterJSON() {
const emitter: IParticle.Config = getDefaultParticleConfig();
emitter.attr = {
position: JSON.parse(JSON.stringify(this.emitter.position)),
rotation: JSON.parse(JSON.stringify(this.emitter.rotation)),
scale: this.emitter.scale,
totalEmitTimes: this.emitter.totalEmitTimes,
damping: this.emitter.damping,
life: this.emitter.life,
numPan: {
min: this.emitter.rate.numPan.a,
max: this.emitter.rate.numPan.b,
},
timePan: {
a: this.emitter.rate.timePan.a,
b: this.emitter.rate.timePan.b,
}
};
this.emitter.initializers.forEach(initializer => {
switch (initializer.type) {
case "Mass":
emitter.init.mass.isEnabled = initializer.isEnabled;
emitter.init.mass.min = initializer.massPan.a;
emitter.init.mass.max = initializer.massPan.b;
emitter.init.mass.center = initializer.massPan._center;
break;
case "Life":
emitter.init.life.isEnabled = initializer.isEnabled;
emitter.init.life.min = initializer.lifePan.a;
emitter.init.life.max = initializer.lifePan.b;
emitter.init.life.center = initializer.lifePan._center;
break;
case "Radius":
emitter.init.radius.isEnabled = initializer.isEnabled;
emitter.init.radius.width = initializer.radius.a;
emitter.init.radius.height = initializer.radius.b;
emitter.init.radius.center = initializer.radius._center;
break;
case "Rotation":
emitter.init.rotation.isEnabled = initializer.isEnabled;
emitter.init.rotation.x = initializer.rotation.x;
emitter.init.rotation.y = initializer.rotation.y;
emitter.init.rotation.z = initializer.rotation.z;
emitter.init.rotation.useEmitterRotation = initializer.useEmitterRotation;
break;
case "Position":
emitter.init.position.isEnabled = initializer.isEnabled;
emitter.init.position.zone = (function () {
const zone = initializer.zones[0];
switch (zone.type) {
case 'PointZone':
return {
type: 'PointZone',
x: zone.x,
y: zone.y,
z: zone.z
}
case 'LineZone':
return {
type: 'LineZone',
x1: zone.x1,
y1: zone.y1,
z1: zone.z1,
x2: zone.x2,
y2: zone.y2,
z2: zone.z2,
}
case 'BoxZone':
return {
type: 'BoxZone',
depth: zone.depth,
height: zone.height,
width: zone.width,
x: zone.x,
y: zone.y,
z: zone.z
}
case 'SphereZone':
return {
type: 'SphereZone',
radius: zone.radius,
x: zone.x,
y: zone.y,
z: zone.z
}
default:
return null;
}
})()
break;
case 'RadialVelocity':
emitter.init.velocity.isEnabled = initializer.isEnabled;
emitter.init.velocity.velocity = {
type: 'RadialVelocity',
radius: initializer.radiusPan.a,
x: initializer.dir.x,
y: initializer.dir.y,
z: initializer.dir.z,
theta: initializer.tha * 180 / Math.PI,
}
break;
case "PolarVelocity":
emitter.init.velocity.isEnabled = initializer.isEnabled;
emitter.init.velocity.velocity = {
type: 'PolarVelocity',
radius: initializer._polar.radius,
theta: initializer._polar.theta * 180 / Math.PI,
phi: initializer._polar.phi * 180 / Math.PI,
tha: initializer.tha * 180 / Math.PI,
}
break;
case 'VectorVelocity':
emitter.init.velocity.isEnabled = initializer.isEnabled;
emitter.init.velocity.velocity = {
type: 'VectorVelocity',
x: initializer.dir.x,
y: initializer.dir.y,
z: initializer.dir.z,
theta: initializer.tha * 180 / Math.PI,
}
break;
case "Body":
emitter.init.body.isEnabled = initializer.isEnabled;
emitter.init.body.body = {
type: initializer.body.items[0].type === 'Sprite' ? 'Sprite' : initializer.body.items[0].type === 'Points' ? 'Point' : 'Mesh',
uuid: this.uuid
}
break;
}
});
this.emitter.behaviours.forEach(behaviour => {
switch (behaviour.type) {
case "Color":
emitter.behaviour.color.isEnabled = behaviour.isEnabled;
emitter.behaviour.color.colorA = behaviour.colorA.colors[0];
emitter.behaviour.color.colorB = behaviour.colorB.colors[0];
emitter.behaviour.color.life = behaviour._life;
emitter.behaviour.color.easing = behaviour.easing.name;
break;
case "Scale":
emitter.behaviour.scale.isEnabled = behaviour.isEnabled;
emitter.behaviour.scale.scaleA = behaviour.scaleA.a;
emitter.behaviour.scale.scaleB = behaviour.scaleB.a;
emitter.behaviour.scale.life = behaviour._life;
emitter.behaviour.scale.easing = behaviour.easing.name;
break;
case "Alpha":
emitter.behaviour.alpha.isEnabled = behaviour.isEnabled;
emitter.behaviour.alpha.alphaA = behaviour.alphaA.a;
emitter.behaviour.alpha.alphaB = behaviour.alphaB.a;
emitter.behaviour.alpha.life = behaviour._life;
emitter.behaviour.alpha.easing = behaviour.easing.name;
break;
case "Force":
emitter.behaviour.force.isEnabled = behaviour.isEnabled;
emitter.behaviour.force.fx = behaviour.force.x / 100;
emitter.behaviour.force.fy = behaviour.force.y / 100;
emitter.behaviour.force.fz = behaviour.force.z / 100;
emitter.behaviour.force.life = behaviour._life;
emitter.behaviour.force.easing = behaviour.easing.name;
break;
case "Rotate":
emitter.behaviour.rotate.isEnabled = behaviour.isEnabled;
emitter.behaviour.rotate.x = behaviour.x.a * 180 / Math.PI;
emitter.behaviour.rotate.y = behaviour.y.a * 180 / Math.PI;
emitter.behaviour.rotate.z = behaviour.z.a * 180 / Math.PI;
emitter.behaviour.rotate.life = behaviour._life;
emitter.behaviour.rotate.easing = behaviour.easing.name;
break;
case "RandomDrift":
emitter.behaviour.randomDrift.isEnabled = behaviour.isEnabled;
emitter.behaviour.randomDrift.driftX = behaviour.randomForce.x / 100;
emitter.behaviour.randomDrift.driftY = behaviour.randomForce.y / 100;
emitter.behaviour.randomDrift.driftZ = behaviour.randomForce.z / 100;
emitter.behaviour.randomDrift.delay = behaviour.delayPan.a;
emitter.behaviour.randomDrift.life = behaviour._life;
emitter.behaviour.randomDrift.easing = behaviour.easing.name;
break;
case "Spring":
emitter.behaviour.spring.isEnabled = behaviour.isEnabled;
emitter.behaviour.spring.x = behaviour.pos.x;
emitter.behaviour.spring.y = behaviour.pos.y;
emitter.behaviour.spring.z = behaviour.pos.z;
emitter.behaviour.spring.spring = behaviour.spring;
emitter.behaviour.spring.friction = behaviour.friction;
emitter.behaviour.spring.life = behaviour._life;
emitter.behaviour.spring.easing = behaviour.easing.name;
break;
case "Attraction":
emitter.behaviour.attraction.isEnabled = behaviour.isEnabled;
emitter.behaviour.attraction.x = behaviour.targetPosition.x;
emitter.behaviour.attraction.y = behaviour.targetPosition.y;
emitter.behaviour.attraction.z = behaviour.targetPosition.z;
emitter.behaviour.attraction.force = behaviour.force / 100;
emitter.behaviour.attraction.radius = behaviour.radius;
emitter.behaviour.attraction.life = behaviour._life;
emitter.behaviour.attraction.easing = behaviour.easing.name;
break;
case "Collision":
emitter.behaviour.collision.isEnabled = behaviour.isEnabled;
emitter.behaviour.collision.useMass = behaviour.useMass;
emitter.behaviour.collision.life = behaviour._life;
emitter.behaviour.collision.easing = behaviour.easing.name;
break;
}
});
return emitter;
}
/**
* 从json配置解析
*/
static fromJSON(json: IParticle.Object3DJSON) {
const emitterConfig = json.emitter.config;
const emitter = new Particle.Emitter({
position: new Particle.Vector3D(
emitterConfig.attr.position.x,
emitterConfig.attr.position.y,
emitterConfig.attr.position.z
),
rotation: new Particle.Vector3D(
emitterConfig.attr.rotation.x,
emitterConfig.attr.rotation.y,
emitterConfig.attr.rotation.z
),
scale: emitterConfig.attr.scale,
life: emitterConfig.attr.life
});
emitter.totalEmitTimes = emitterConfig.attr.totalEmitTimes;
emitter.damping = emitterConfig.attr.damping;
emitter.rate = new Particle.Rate(
new Particle.Span(emitterConfig.attr.numPan.min, emitterConfig.attr.numPan.max),
new Particle.Span(emitterConfig.attr.timePan.a, emitterConfig.attr.timePan.b)
);
emitter.emit();
// 还原initializers
json.emitter.useInitializers.forEach(initializer => {
switch (initializer) {
case "Mass":
emitter.addInitializer(
new Particle.Mass(
emitterConfig.init.mass.min,
emitterConfig.init.mass.max,
emitterConfig.init.mass.center,
emitterConfig.init.mass.isEnabled
)
);
break;
case "Life":
emitter.addInitializer(
new Particle.Life(
emitterConfig.init.life.min,
emitterConfig.init.life.max,
emitterConfig.init.life.center,
emitterConfig.init.life.isEnabled
)
);
break;
case "Radius":
emitter.addInitializer(
new Particle.Radius(
emitterConfig.init.radius.width,
emitterConfig.init.radius.height,
emitterConfig.init.radius.center,
emitterConfig.init.radius.isEnabled
)
);
break;
case "Rotation":
emitter.addInitializer(
new Particle.Rotation(
emitterConfig.init.rotation.x,
emitterConfig.init.rotation.y,
emitterConfig.init.rotation.z,
emitterConfig.init.rotation.useEmitterRotation,
emitterConfig.init.rotation.isEnabled
)
);
break;
case "Position":
let position = new Particle.Position();
emitter.addInitializer(position);
let zone;
const zoneData = emitterConfig.init.position.zone;
switch (zoneData?.type) {
case 'PointZone':
zone = new Particle.PointZone(zoneData.x, zoneData.y, zoneData.z);
break;
case 'LineZone':
zone = new Particle.LineZone(
zoneData.x1,
zoneData.y1,
zoneData.z1,
zoneData.x2,
zoneData.y2,
zoneData.z2,
);
break;
case 'BoxZone':
zone = new Particle.BoxZone(
zoneData.x,
zoneData.y,
zoneData.z,
zoneData.width,
zoneData.height,
zoneData.depth,
);
break;
case 'SphereZone':
zone = new Particle.SphereZone(
zoneData.x,
zoneData.y,
zoneData.z,
zoneData.radius
);
break;
}
if (!zone) return;
position.addZone(zone);
break;
case "RadialVelocity": {
const velocity = emitterConfig.init.velocity.velocity as IParticle.RadialVelocity;
emitter.addInitializer(
new Particle.RadialVelocity(
velocity.radius,
new Particle.Vector3D(
velocity.x,
velocity.y,
velocity.z
),
velocity.theta,
emitterConfig.init.velocity.isEnabled
)
);
}
break;
case "PolarVelocity": {
const velocity = emitterConfig.init.velocity.velocity as IParticle.PolarVelocity;
emitter.addInitializer(
new Particle.PolarVelocity(
new Particle.Polar3D(velocity.radius, velocity.theta * Math.PI / 180, velocity.phi * Math.PI / 180),
velocity.tha,
emitterConfig.init.velocity.isEnabled
)
);
}
break;
case "VectorVelocity": {
const velocity = emitterConfig.init.velocity.velocity as IParticle.VectorVelocity;
emitter.addInitializer(
new Particle.VectorVelocity(
new Particle.Vector3D(velocity.x, velocity.y, velocity.z),
velocity.theta,
emitterConfig.init.velocity.isEnabled
)
);
}
break;
case "Body":
if (!emitterConfig.init.body.body) break;
switch (emitterConfig.init.body.body.type) {
case "Sprite":
case "Mesh":
new ObjectLoader().parse(json.emitter.bodyObjectJSON, (object3D => {
emitter.addInitializer(
new Particle.Body(
object3D,
null,
null,
emitterConfig.init.body.isEnabled
)
);
ParticleSystem.Body3DMap.set(json.uuid, object3D);
}))
break;
case "Point":
emitter.addInitializer(
new Particle.Body(
ParticleSystem.PointBody.clone(),
null,
null,
emitterConfig.init.body.isEnabled
)
);
break;
}
break;
}
})
// 还原behaviours
json.emitter.useBehaviours.forEach(behaviour => {
switch (behaviour) {
case "Color":
emitter.addBehaviour(
new Particle.Color(
emitterConfig.behaviour.color.colorA,
emitterConfig.behaviour.color.colorB,
emitterConfig.behaviour.color.life,
Particle.ease[emitterConfig.behaviour.color.easing],
emitterConfig.behaviour.color.isEnabled
)
)
break;
case "Scale":
emitter.addBehaviour(
new Particle.Scale(
emitterConfig.behaviour.scale.scaleA,
emitterConfig.behaviour.scale.scaleB,
emitterConfig.behaviour.scale.life,
Particle.ease[emitterConfig.behaviour.scale.easing],
emitterConfig.behaviour.scale.isEnabled
)
)
break;
case "Alpha":
emitter.addBehaviour(
new Particle.Alpha(
emitterConfig.behaviour.alpha.alphaA,
emitterConfig.behaviour.alpha.alphaB,
emitterConfig.behaviour.alpha.life,
Particle.ease[emitterConfig.behaviour.alpha.easing],
emitterConfig.behaviour.alpha.isEnabled
)
)
break;
case "Force":
emitter.addBehaviour(
new Particle.Force(
emitterConfig.behaviour.force.fx,
emitterConfig.behaviour.force.fy,
emitterConfig.behaviour.force.fz,
emitterConfig.behaviour.force.life,
Particle.ease[emitterConfig.behaviour.force.easing],
emitterConfig.behaviour.force.isEnabled
)
)
break;
case "Rotate":
emitter.addBehaviour(
new Particle.Rotate(
emitterConfig.behaviour.rotate.x,
emitterConfig.behaviour.rotate.y,
emitterConfig.behaviour.rotate.z,
emitterConfig.behaviour.rotate.life,
Particle.ease[emitterConfig.behaviour.rotate.easing],
emitterConfig.behaviour.rotate.isEnabled
)
)
break;
case "RandomDrift":
emitter.addBehaviour(
new Particle.RandomDrift(
emitterConfig.behaviour.randomDrift.driftX,
emitterConfig.behaviour.randomDrift.driftY,
emitterConfig.behaviour.randomDrift.driftZ,
emitterConfig.behaviour.randomDrift.delay,
emitterConfig.behaviour.randomDrift.life,
Particle.ease[emitterConfig.behaviour.randomDrift.easing],
emitterConfig.behaviour.randomDrift.isEnabled
)
)
break;
case "Spring":
emitter.addBehaviour(
new Particle.Spring(
emitterConfig.behaviour.spring.x,
emitterConfig.behaviour.spring.y,
emitterConfig.behaviour.spring.z,
emitterConfig.behaviour.spring.spring,
emitterConfig.behaviour.spring.friction,
emitterConfig.behaviour.spring.life,
Particle.ease[emitterConfig.behaviour.spring.easing],
emitterConfig.behaviour.spring.isEnabled
)
)
break;
case "Attraction":
emitter.addBehaviour(
new Particle.Attraction(
new Particle.Vector3D(
emitterConfig.behaviour.attraction.x,
emitterConfig.behaviour.attraction.y,
emitterConfig.behaviour.attraction.z
),
emitterConfig.behaviour.attraction.force,
emitterConfig.behaviour.attraction.radius,
emitterConfig.behaviour.attraction.life,
Particle.ease[emitterConfig.behaviour.attraction.easing],
emitterConfig.behaviour.attraction.isEnabled
)
)
break;
case "Collision":
emitter.addBehaviour(
new Particle.Collision(
emitter,
emitterConfig.behaviour.collision.useMass,
() => { },
emitterConfig.behaviour.collision.life,
Particle.ease[emitterConfig.behaviour.collision.easing],
emitterConfig.behaviour.collision.isEnabled
)
)
break;
}
})
useDispatchSignal("emitterAdd2ParticleSystem",emitter,json.emitter.system)
const particleEmitter = new ParticleEmitter(emitter);
particleEmitter.name = json.name;
particleEmitter.uuid = json.uuid;
return particleEmitter;
}
/**
* 获取json配置
*/
toJSON(meta?: THREE.JSONMeta) {
const superJSON = super.toJSON(meta).object;
// @ts-ignore
superJSON.matrix = undefined;
// @ts-ignore
delete superJSON.matrix;
// 父级toJSON调用子级toJSON时,只会保留object对象,主要信息都需要放在这
const object: IParticle.Object3DJSON = {
uuid: this.uuid,
type: this.type,
name: this.name,
emitter: {
config:this.getEmitterJSON(),
system: this.emitter.parent.name,
useInitializers: this.emitter.initializers.map(initializer => initializer.type),
bodyObjectJSON: ParticleSystem.Body3DMap.get(this.uuid)?.toJSON() || null,
useBehaviours: this.emitter.behaviours.map(behaviour => behaviour.type)
},
children: [],
};
if (this.children.length > 0) {
object.children = [];
for (let i = 0; i < this.children.length; i++) {
//@ts-ignore
object.children.push(this.children[i].toJSON(meta).object);
}
}
return {
metadata: {
version: 4.6,
type: 'Object',
generator: 'ParticleEmitter.toJSON'
},
object: Object.assign(superJSON, object),
} as any;
}
/**
* 销毁
*/
dispose(){
// 手动销毁所有粒子模型对象,发射器的destroy方法不会进行销毁
this.emitter.particles && this.emitter.particles.forEach(p => {
if (!p.target) return;
p.target.removeFromParent();
})
useRemoveSignal('particleSystemAddEmitter', _handleAddToParticleSystemFn);
_handleAddToParticleSystemFn = null;
this.emitter.parent?.eventDispatcher.removeEventListener('PARTICLE_CREATED', _handleParticleCreatedFn);
_handleParticleCreatedFn = null;
this.emitter.destroy();
}
}
export default ParticleEmitter;
+159
View File
@@ -0,0 +1,159 @@
import {TilesRenderer} from "3d-tiles-renderer";
import {GLTFExtensionsPlugin,DebugTilesPlugin} from "3d-tiles-renderer/plugins";
import Loader from "@/core/loader/Loader.ts";
import {PerspectiveCamera, WebGLRenderer, Group, JSONMeta} from "three";
import {deepAssign} from "@/utils";
export default class Tiles extends Group{
type = "TilesGroup";
isTilesGroup = true;
// 默认配置
options: ITiles.options = {
url:"",
reset2origin:true,
debug:false,
name:"Tiles",
errorTarget: 5,
LRUCache:{
maxSize: 4000,
minSize: 3000,
maxBytesSize: 0.4 * 2**30,
minBytesSize: 0.3 * 2**30,
}
};
renderer: TilesRenderer;
constructor(options:ITiles.options) {
super();
if(!options.url){
throw new Error('[Astral 3D]: No url provided.');
}
deepAssign(this.options,options);
this.name = this.options.name as string;
this.renderer = this.initRenderer();
this.add(this.renderer.group);
}
get group(){
return this.renderer.group;
}
/**
* 初始化Tiles渲染器
*/
initRenderer():TilesRenderer{
const tilesRenderer = new TilesRenderer(this.options.url);
tilesRenderer.fetchOptions.mode = 'cors';
tilesRenderer.errorTarget = this.options.errorTarget || 6;
// LRUCache
if(this.options.LRUCache){
tilesRenderer.lruCache.maxSize = this.options.LRUCache.maxSize || 800;
tilesRenderer.lruCache.minSize = this.options.LRUCache.minSize || 600;
tilesRenderer.lruCache.maxBytesSize = this.options.LRUCache.maxBytesSize || 0.4 * 2**30;
tilesRenderer.lruCache.minBytesSize = this.options.LRUCache.minBytesSize || 0.3 * 2**30;
}
// isTilesGroup是只读的,此处绕过 readonly,防止编译报错
(tilesRenderer.group as { isTilesGroup: boolean }).isTilesGroup = false;
(tilesRenderer.group as { type: string }).type = "Tiles";
tilesRenderer.group.isTiles = true;
tilesRenderer.group.proxy = this;
tilesRenderer.registerPlugin(new GLTFExtensionsPlugin({
dracoLoader: Loader.dracoLoader,
ktxLoader: Loader.ktx2Loader,
}));
// Loader.createGLTFLoader(tilesRenderer.manager).then(loader => {
// tilesRenderer.manager.addHandler( /\.(gltf|glb)$/g, loader );
// })
// 子级瓦片加载
tilesRenderer.addEventListener('load-model', (e) => {
e.scene.traverse(c => {
c.isTiles = true;
// 子级瓦片不允许选中,添加proxy属性让点击此瓦片时选中此组
c.proxy = this;
if(c.type === "Group"){
(c as { type: string }).type = "Tiles";
}else{
(c as { type: string }).type = "Tile";
}
})
});
tilesRenderer.addEventListener("load-error", (e) => {
console.error(`${tilesRenderer.group.name} load error:`, e);
});
if(this.options.debug){
// 注册调试插件
tilesRenderer.registerPlugin(new DebugTilesPlugin());
// 获取调试插件
const debugTilesPlugin = tilesRenderer.getPluginByName('DEBUG_TILES_PLUGIN') as DebugTilesPlugin;
// 显示包围盒的线框
debugTilesPlugin.displayBoxBounds = true;
}
return tilesRenderer;
}
/**
* 设置相机和渲染器
*/
setCameraAndRenderer(camera:PerspectiveCamera,renderer:WebGLRenderer){
this.renderer.setCamera(camera);
this.renderer.setResolutionFromRenderer(camera, renderer);
}
/**
* 重写clone方法,因为要接收参数
*/
clone(recursive: boolean = true) {
// 断言为可构造类型
const Ctor = this.constructor as new (opts: ITiles.options) => this;
return new Ctor(this.options).copy(this, recursive);
}
/**
* 重写toJSON
*/
toJSON(meta?: JSONMeta){
const json = super.toJSON(meta);
json.object.type= "TilesGroup";
json.object.options = this.options;
return json;
}
static fromJSON(data: { options: ITiles.options,[s:string]:any },copyAttr = true){
const tiles = new Tiles(data.options);
if(copyAttr){
data.children = undefined;
Loader.objectLoader.copyAttrByData(tiles,data);
}
return tiles;
}
/**
* 更新/渲染Tiles
*/
update(){
this.renderer.update();
}
/**
* 自我销毁
*/
dispose(){
this.renderer.dispose();
}
}
+4
View File
@@ -0,0 +1,4 @@
export {default as Billboard, getDefaultBillboardOptions} from "./Billboard";
export {HtmlPanelConverter, HtmlPanel, HtmlSprite} from "./HtmlPanel";
export {default as ParticleEmitter, getDefaultParticleConfig} from "./ParticleEmitter";
export {default as Tiles} from "./Tile.ts";
@@ -0,0 +1,454 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/4/6 13:07
* @description 广告牌map
*/
import * as THREE from 'three';
import log from "@/utils/log/Logger";
import {POSITION} from "@//constant";
export default class BillboardTexture extends THREE.CanvasTexture {
private options: IBillboard.options;
private _img: HTMLImageElement | null = null;
private isImgLoading: boolean = false;
constructor(options:IBillboard.options,image?:HTMLImageElement) {
super(
document.createElement('canvas'), // image
THREE.Texture.DEFAULT_MAPPING, // mapping
THREE.RepeatWrapping, // wrapS
THREE.RepeatWrapping, // wrapT
THREE.LinearFilter, // magFilter
THREE.LinearMipmapLinearFilter, // minFilter
THREE.RGBAFormat, // format
THREE.UnsignedByteType, // type
THREE.Texture.DEFAULT_ANISOTROPY // anisotropy
)
this.options = options;
if(this.options.image){
this.options.image = new Proxy(this.options.image,{
set: (target, key, value) => {
target[key] = value;
if(key === "url" && value){
this.loadImg();
}else{
this.redraw();
}
return true;
}
})
}
if(this.options.text){
this.options.text = new Proxy(this.options.text,{
set: (target, key, value) => {
target[key] = value;
this.redraw();
return true;
}
})
}
this.redraw();
this.loadImg(image);
}
get lines() {
if (!this.options.text.visible || !this.options.text.value) return [];
return String(this.options.text.value).split(/\r?\n/);
}
get font(){
return `${this.options.text?.fontStyle || 'normal'} normal ${this.options.text?.fontWeight || 'normal'} ${this.options.text?.fontSize || 16}px ${this.options.text?.fontFamily || 'sans-serif'}`;
}
get textWidth(){
if (this.options.text.visible && this.lines.length) {
let canvas = document.createElement('canvas');
let context = canvas.getContext('2d') as CanvasRenderingContext2D;
context.font = this.font;
return Math.max(...this.lines.map(text => context.measureText(text).width));
}
return 0;
}
get textHeight(){
if (this.options.text.visible && this.lines.length) {
return this.lines.length * (this.options.text.fontSize || 16) + (this.options.text.lineGap || 0) * (this.lines.length - 1);
}
return 0;
}
get imageSize(){
return {
width:this.options.image.width || 0,
height:this.options.image.height || 0,
}
}
get width(){
const padding = this.options.text.padding || 0;
const imageMargin = this.options.image.margin || 0;
const imageSize = this.imageSize;
if(!this.options.text.value || !this.options.text.visible) return imageSize.width;
if (!this.options.image.url || !this.options.image.visible) return this.textWidth + padding * 2;
let width = padding * 2;
switch (this.options.image.position?.toLowerCase()) {
case POSITION.LEFT:
case POSITION.TOP_LEFT:
case POSITION.TOP_RIGHT:
case POSITION.BOTTOM_LEFT:
case POSITION.BOTTOM_RIGHT:
case POSITION.RIGHT:
width += this.textWidth + this.imageSize.width + imageMargin;
break;
case POSITION.BOTTOM:
case POSITION.TOP:
width += Math.max(this.textWidth, this.imageSize.width);
break;
default:
width += Math.max(this.textWidth, this.imageSize.width) + imageMargin;
break;
}
return width;
}
get height(){
const padding = this.options.text.padding || 0;
const imageMargin = this.options.image.margin || 0;
const imageSize = this.imageSize;
if(!this.options.text.value || !this.options.text.visible) return imageSize.height;
if (!this.options.image.url || !this.options.image.visible) return padding * 2 + this.textHeight;
let height = padding * 2;
switch (this.options.image.position?.toLowerCase()) {
case POSITION.TOP:
case POSITION.BOTTOM:
height += this.textHeight + imageSize.height + imageMargin;
break;
default:
height += Math.max(this.textHeight, imageSize.height);
break;
}
return height;
}
async loadImg(image?:HTMLImageElement){
if(this.isImgLoading) return;
if(image){
this._img = image;
this.redraw();
// @ts-ignore
this.dispatchEvent({type:"imgLoaded",url:this.options.image?.url})
this.isImgLoading = false;
return;
}
if(this.options.image?.url){
const img = new Image();
// 设置跨域模式(解决 CORS 问题)
img.crossOrigin = "anonymous";
this.isImgLoading = true;
img.onload = () =>{
this._img = img;
this.redraw();
// @ts-ignore
this.dispatchEvent({type:"imgLoaded",url:this.options.image?.url})
this.isImgLoading = false;
// // 生成Canvas的DataURL
// const dataUrl = this.image.toDataURL('image/png'); // 可选参数:'image/jpeg',并可设置质量
//
// // 创建下载链接
// const link = document.createElement('a');
// link.href = dataUrl;
// link.download = 'canvas-image.png'; // 设置下载的文件名
//
// // 触发下载
// document.body.appendChild(link);
// link.click();
//
// // 可选:移除链接
// document.body.removeChild(link);
}
// @ts-ignore
img.onerror = (e:Error) =>{
console.log(`[BillboardTexture] 图片载入失败:`,e)
log.error(`[BillboardTexture] 图片载入失败:${e.toString()}`);
this.isImgLoading = false;
}
img.src = this.options.image.url;
}
}
redraw(){
if(!this.image) return;
// 默认均按512x512绘制,再按比例缩放,以保持清晰
const canvasWidth = 512;
const canvasHeight = 512;
if (this.width && this.height){
this.image.width = canvasWidth;
this.image.height = canvasHeight;
const imageSize = this.imageSize;
let imageWidth = imageSize.width;
let imageHeight = imageSize.height;
let context = this.image.getContext('2d');
context.clearRect(0, 0, this.image.width, this.image.height);
context.scale(canvasWidth / this.width, canvasHeight / this.height);
context.save();
const imageIsVisible = this.options.image.url && this.options.image.visible;
let imageMargin = imageIsVisible ? (this.options.image.margin || 0) : 0;
imageWidth = imageIsVisible ? imageWidth : 0;
imageHeight = imageIsVisible ? imageHeight : 0;
let textIsVisible = this.options.text.value && this.options.text.visible;
imageMargin = textIsVisible ? imageMargin : 0;
const padding = this.options.text.padding || 0;
const textAlign = this.options.text.align?.toLowerCase() || 'left';
const imagePosition = this.options.image.position?.toLowerCase();
// 图像位置
let imageLeft = 0, imageTop = 0, left = 0, top = 0;
// 绘制图片
const drawImage = () => {
if (imageIsVisible) {
if(textIsVisible) {
if (imagePosition === POSITION.LEFT) {
imageTop = this.height / 2 - imageHeight / 2;
imageLeft = 0;
}
if (imagePosition === POSITION.RIGHT) {
imageLeft = this.textWidth + padding * 2 + imageMargin;
imageTop = this.height / 2 - imageHeight / 2;
}
if (imagePosition === POSITION.TOP) {
imageLeft = this.width / 2 - imageWidth / 2;
imageTop = 0;
}
if (imagePosition === POSITION.BOTTOM) {
imageLeft = this.width / 2 - imageWidth / 2;
imageTop = this.textHeight + padding * 2 + imageMargin;
}
if (imagePosition == POSITION.CENTER) {
imageLeft = this.width / 2 - imageWidth / 2;
imageTop = this.height / 2 - imageHeight / 2;
}
}
if(!this._img) {
this.loadImg();
}else{
const rotate = this.options.image.rotate;
if (rotate) {
context.translate(imageLeft + imageWidth / 2, imageTop + imageHeight / 2);
context.rotate(rotate);
context.drawImage(this._img, -imageWidth / 2, -imageHeight / 2, imageWidth, imageHeight);
context.rotate(-1 * rotate);
context.translate(-imageLeft - imageWidth / 2, -imageTop - imageHeight / 2)
} else {
context.drawImage(this._img, imageLeft, imageTop, imageWidth, imageHeight);
}
}
}
}
const drawText = () => {
// 绘制文字
if (textIsVisible) {
const rect = {
left: left,
top: top,
width: this.textWidth + padding * 2,
height: this.textHeight + padding * 2,
}
if(imageIsVisible) {
if (imagePosition === POSITION.LEFT) {
rect.left = imageWidth + imageMargin;
rect.top = this.height / 2 - rect.height / 2;
}
if (imagePosition === POSITION.RIGHT) {
rect.left = 0;
rect.top = this.height / 2 - rect.height / 2;
}
if (imagePosition === POSITION.TOP) {
rect.left = 0;
rect.top = imageHeight + imageMargin;
}
if (imagePosition === POSITION.BOTTOM) {
rect.left = 0;
rect.top = 0 ;
}
if (imagePosition == POSITION.CENTER) {
rect.left = 0;
rect.top = 0;
rect.width = this.width;
rect.height = this.height;
}
}
switch (textAlign) {
// 文字左对齐
case 'left':
left = padding;
top = padding;
if(imageIsVisible) {
switch(imagePosition) {
case POSITION.TOP:
top += imageMargin + imageHeight;
break;
case POSITION.LEFT:
left += imageMargin + imageWidth;
top = this.height / 2 - this.textHeight / 2;
break;
case POSITION.CENTER:
left = this.width / 2 - this.textWidth / 2 - padding;
top = this.height / 2 - this.textHeight / 2;
break;
case POSITION.BOTTOM:
break;
case POSITION.RIGHT:
top = this.height / 2 - this.textHeight / 2;
break;
}
}
break;
// 文字右对齐
case 'right':
left = this.width - padding;
top = padding;
if(imageIsVisible) {
switch(imagePosition) {
case POSITION.TOP:
top += imageMargin + imageHeight;
break;
case POSITION.LEFT:
left = this.width - padding;
top = this.height / 2 - this.textHeight / 2;
break;
case POSITION.CENTER:
left = this.width - padding;
top = this.height / 2 - this.textHeight / 2;
break;
case POSITION.BOTTOM:
break;
case POSITION.RIGHT:
left = this.width - imageWidth - imageMargin - padding;
top = this.height / 2 - this.textHeight / 2;
break;
}
}
if(this.lines.length == 1) {
top += 2;
}
break;
// 文字居中
case 'center':
top = padding;
left = this.width / 2 + imageMargin;
if(imageIsVisible) {
switch(imagePosition) {
case POSITION.TOP:
top += imageMargin + imageHeight;
left = this.width / 2;
break;
case POSITION.LEFT:
left = this.width / 2 + imageWidth / 2 + imageMargin / 2;
top = this.height / 2 - this.textHeight / 2;
break;
case POSITION.CENTER:
left = this.width / 2;
top = this.height / 2 - this.textHeight / 2;
break;
case POSITION.BOTTOM:
left = this.width / 2;
break;
case POSITION.RIGHT:
left = this.width / 2 - imageWidth / 2 - imageMargin / 2;
top = this.height / 2 - this.textHeight / 2;
break;
}
}
break;
}
// 填充背景
if (this.options.text.fill && this.options.text.fillColor) {
context.save();
context.fillStyle = this.options.text.fillColor;
context.fillRect(rect.left, rect.top, rect.width, rect.height);
context.restore();
}
context.textAlign = textAlign;
context.font = this.font;
context.textBaseline = this.options.text.baseline || 'top';
context.fillStyle = this.options.text.fontColor || '#ffffff';
context.lineJoin = 'miter';
context.miterLimit = 1;
context.lineWidth = this.options.text.strokeWidth;
context.strokeStyle = this.options.text.strokeColor;
this.lines.forEach(t => {
if (this.options.text.strokeWidth) {
context.strokeText(t, left, top);
}
context.fillText(t, left, top);
top += (this.options.text.fontSize || 16) + (this.options.text.lineGap || 0);
});
}
}
if(this.options.image.top){
drawText();
drawImage();
}else{
drawImage();
drawText();
}
context.restore();
} else {
this.image.width = this.image.height = 1;
log.warn("[BillboardTexture] 宽高为0,无法绘制");
}
this.needsUpdate = true;
// @ts-ignore
this.dispatchEvent({type:"redraw"})
}
}
@@ -0,0 +1,164 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/01/07
* @description 贴相机的下雨效果
*/
import * as THREE from 'three';
import type CameraControls from 'camera-controls';
interface IRainOption {
speed: number,
color: string,
size: number,
radian: number,
alpha: number,
}
export default class Rain{
options: IRainOption;
mesh:THREE.Mesh;
controls: CameraControls;
constructor(option: IRainOption, controls: CameraControls) {
const defaultOption: IRainOption = {
speed: 0.4,
color: "#ffffff",
size: 0.5,
radian: 95 * THREE.MathUtils.DEG2RAD,
alpha: 0.4
};
this.options = Object.assign({}, defaultOption, option);
this.controls = controls;
this.mesh = this.createMesh();
this.updatePosition();
}
createMesh(){
const geometry = new THREE.PlaneGeometry(200, 200);
const uniforms = {
u_time: {
type: "f",
value: 0.0
},
tDiffuse: { value: null },
u_resolution: {
type: "v2",
value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(window.devicePixelRatio)
},
alpha: {
type: "f",
value: this.options.alpha,
},
size: { value: this.options.size },
radian: { value: this.options.radian * THREE.MathUtils.DEG2RAD },
speed: { value: this.options.speed },
color: { value: new THREE.Color(this.options.color) }
};
const material = new THREE.ShaderMaterial({
transparent: true,
uniforms: uniforms,
side: 2,
vertexShader: `
#define GLSLIFY 1
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4( position, 1.0 );
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform vec2 u_resolution;
uniform float u_time;
uniform float speed;
uniform vec3 color;
uniform float radian;
uniform float alpha;
uniform float size;
varying highp vec2 vUv;
float hash(vec2 p){
p = 50.0*fract( p*0.3183099 + vec2(0.71,0.113));
return -1.0+2.0*fract( p.x*p.y*(p.x+p.y) );
}
float noise( in vec2 p ){
vec2 i = floor( p );
vec2 f = fract( p );
vec2 u = f*f*(3.0-2.0*f);
return mix( mix( hash( i + vec2(0.0,0.0) ),
hash( i + vec2(1.0,0.0) ), u.x),
mix( hash( i + vec2(0.0,1.0) ),
hash( i + vec2(1.0,1.0) ), u.x), u.y);
}
void main(){
vec3 col=texture(tDiffuse,vUv).rgb;
vec2 q = gl_FragCoord.xy/u_resolution.xy;
vec2 p = -1.0+2.0*q;
vec2 st = (p * vec2(.5, .01)+vec2(u_time)*0.05*speed)-vec2(q.y*cos(radian),0.0);
st*= (1000.0 - size * 500.0);
float f = noise(st) * noise(st*.773)* 1.55;
f = clamp(pow(abs(f), 23.0) * 13.0, 0.0, q.y*.14) * 2.7;
col += clamp(f,0.0,1.0)*color;
gl_FragColor = vec4(col, alpha);
}
`
});
return new THREE.Mesh(geometry, material);
}
updatePosition() {
if (this.controls && this.mesh) {
const position = this.controls.getPosition(new THREE.Vector3());
const center = this.controls.getTarget(new THREE.Vector3());
this.mesh.position.copy(center);
if (position.y < 100) {
this.mesh.position.y = -100;
} else {
this.mesh.position.y = 0;
}
}
}
updateOptions(option) {
const material = <THREE.ShaderMaterial>this.mesh.material;
for (const key in option) {
this.options[key] = option[key];
if (material.uniforms[key]) {
let value = option[key];
switch(key){
case "radian":
value *= THREE.MathUtils.DEG2RAD;
break;
case "color":
value = new THREE.Color(value);
break;
}
material.uniforms[key].value = value;
}
}
}
update(deltaTime){
this.updatePosition();
if (this.mesh.material && this.mesh.material instanceof THREE.ShaderMaterial) {
this.mesh.material.uniforms.u_time.value += deltaTime;
}
}
dispose(){
this.mesh.geometry.dispose();
(<THREE.Material>this.mesh.material).dispose();
this.mesh.removeFromParent();
}
}
@@ -0,0 +1,186 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/01/08
* @description 贴相机的下雪效果
*/
import * as THREE from 'three';
import type CameraControls from 'camera-controls';
interface ISnowOption {
size: number;
density: number;
speed: number;
alpha: number;
}
export default class Snow {
options: ISnowOption;
mesh: THREE.Mesh;
controls: CameraControls;
constructor(option: ISnowOption, controls: CameraControls) {
const defaultOption: ISnowOption = {
size: 0.05,
density: 1.0,
speed: 1.0,
alpha: 0.4,
};
this.options = Object.assign({}, defaultOption, option);
this.controls = controls;
this.mesh = this.createMesh();
this.mesh.renderOrder = 100;
this.updatePosition();
}
createMesh() {
const geometry = new THREE.PlaneGeometry(200, 200);
const uniforms = {
iTime: {
type: "f",
value: 0.0
},
tDiffuse: { value: null },
iResolution: {
type: "v2",
value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(window.devicePixelRatio)
},
size: { value: this.options.size },
speed: { value: this.options.speed },
density: { value: this.options.density },
alpha: {
type: "f",
value: this.options.alpha,
},
};
const material = new THREE.ShaderMaterial({
transparent: true,
uniforms: uniforms,
side: 2,
depthTest: false, // 禁用深度测试
depthWrite: false, // 禁用深度写入
blending: THREE.AdditiveBlending, // 使用叠加混合模式
vertexShader: `
varying highp vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4( position, 1.0 );
}`,
fragmentShader: `
#define PI 3.14159265359
uniform sampler2D tDiffuse;
uniform vec2 iResolution;
uniform float iTime;
uniform float size; // 输入的 size,显示给用户的值是实际值的十倍
uniform float density;
uniform float speed;
uniform float alpha;
varying highp vec2 vUv;
float ball(vec2 p) {
float d = distance(vec2(.5), p);
return smoothstep(size / 10.0, size / 10.0 - .05, d); // 将 size 缩小十倍
}
float N11(float n) {
return fract(sin(n * 871.213) * 3134.422);
}
float N21(vec2 uv) {
return N11(N11(uv.x) + uv.y);
}
float snow(vec2 uv, float t) {
vec2 org_uv = vec2(uv.x, uv.y);
float z = 10.;
uv.y += t * .5;
vec2 gv = fract(uv*z);
vec2 id = floor(uv*z);
gv.x += (sin(N21(id) * 128. + t) * .4);
gv.y += (sin(N11(N21(id)) * 128. + t) * .4);
float dots = ball(gv);
return dots;
}
void main(){
vec3 col=texture(tDiffuse,vUv).rgb;
vec2 uv = gl_FragCoord.xy/iResolution.xy;
uv.x *= iResolution.x / iResolution.y;
float t = iTime * .3*speed;
vec2 gh_uv = uv;
// Time varying pixel color
vec3 colSnow = vec3(0.);
float m = 0.;
for(float i =0.; i <= 1.; i += 1. / (32.*density)) {
float z = mix(1., .5 , i);
vec2 offset = vec2(N11(i), N11(N11(i)));
m += snow((uv + offset) * z, t) * .3;
}
colSnow = vec3(m)*1.2;
colSnow += col *.8 * mix(.5, 1., uv.y);
// 如果不是雪的像素,设置为透明
// if (m <= 0.0) {
// gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0); // 完全透明
// } else {
// gl_FragColor = vec4(colSnow, alpha); // 雪的像素保持原有颜色和透明度
// }
gl_FragColor = vec4(colSnow,alpha);
}`
});
return new THREE.Mesh(geometry, material);
}
updatePosition() {
if (this.controls && this.mesh) {
const position = this.controls.getPosition(new THREE.Vector3());
const center = this.controls.getTarget(new THREE.Vector3());
if (this.mesh.position.x === center.x && this.mesh.position.z === center.z) return;
this.mesh.position.copy(center);
if (position.y < 100) {
this.mesh.position.y = -100;
} else {
this.mesh.position.y = 0;
}
}
}
updateOptions(option) {
const material = <THREE.ShaderMaterial>this.mesh.material;
for (const key in option) {
this.options[key] = option[key];
if (material.uniforms[key]) {
material.uniforms[key].value = option[key];
}
}
}
update(deltaTime) {
this.updatePosition();
if (this.mesh.material && this.mesh.material instanceof THREE.ShaderMaterial) {
this.mesh.material.uniforms.iTime.value += deltaTime;
}
}
dispose() {
this.mesh.geometry.dispose();
(<THREE.Material>this.mesh.material).dispose();
this.mesh.removeFromParent();
}
}