feat(All):Initial
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user