简介:本资源是一个基于Laya引擎实现的Unity粒子系统Web预览Demo,面向前端开发者、游戏开发初学者及Laya引擎实践者,解决3D粒子效果在网页端动态加载与实时预览的技术难点。项目支持通过弹出框选择本地.lh粒子文件(兼容最多两层嵌套),无需重启即可热切换不同粒子效果;提供播放/暂停控制、键盘鼠标自由视角漫游,并实现3D场景与HTML元素的混合渲染,适用于LayaIDE环境下的快速验证与教学演示。压缩包共302个文件,主体为67个JS逻辑脚本、51个PNG贴图、18个Lmat材质、13个JSON配置及6个核心.lh粒子文件,辅以少量日志、缓存与元数据文件,整体体积23.43MB,结构完整、即开即用。目前已有743人学习下载,读者可直接运行获得可交互的粒子预览环境,掌握DOM操作触发文件读取、Laya 3D场景生命周期管理、粒子资源热加载等关键实践能力。
1. Laya预览Unity导出粒子:不是简单加载,而是绕过引擎限制的DOM级文件接管方案
你有没有试过把Unity导出的粒子特效(比如 .json + .png 组合)直接扔进LayaAir项目里跑?十有八九会卡在“找不到资源路径”或“粒子系统初始化失败”——因为Unity导出的粒子包默认依赖其原生资源加载器和运行时解析逻辑,而LayaAir的Loader机制对这类非标准结构的JSON描述、多层级纹理引用、动画曲线序列并不原生兼容。这个demo的核心价值,恰恰在于它不走Laya内置Loader流程,而是用原生DOM File API接管文件选择权,再手动解析Unity导出的粒子JSON结构,逐字段映射到Laya.Particle2D实例上。它解决的不是“能不能显示”,而是“如何让Laya在无服务端、无构建干预、纯前端环境下,安全、可控、可调试地加载任意本地Unity粒子包”。适合正在做H5轻量级特效预览工具、跨引擎素材验证平台、或需要绕过打包流程快速验证美术资源的前端工程师与TA。它不依赖Unity Editor插件二次导出,也不要求修改Laya源码,所有逻辑都在一个HTML页面内闭环。
2. 为什么必须用File API接管?——从Unity粒子导出结构看Laya加载断点
Unity导出的粒子系统(以Universal Render Pipeline为例)通常生成一个主JSON文件(如explosion.json),内含完整发射器参数、模块配置(SizeOverLifetime、ColorOverLifetime等)、以及关键的纹理引用路径(如"texture": "textures/explosion_sheet.png")。但问题就出在这里:
- Laya.Loader默认按相对路径请求资源,而本地文件没有HTTP协议上下文,
Loader.load("textures/explosion_sheet.png")会直接404; - Unity JSON中的纹理路径是相对导出目录的,不是相对于HTML页面的,更不是相对于Laya的
resource/目录; - 粒子JSON中还包含大量浮点数组(如
"curves": [ [0,1], [0.5,0.8], [1,0] ])、嵌套对象("colorOverLifetime": { "enabled": true, "color": { "r": 1, "g": 0.5 } }),Laya.Particle2D构造函数不接受这种原始结构,需手动解构并转为Laya.Vector4、Laya.Color等类型。
所以,硬塞JSON给Laya是行不通的。必须把“加载”这件事从Laya的异步资源管线中拎出来,自己掌控:
- 用户点击弹出框选中整个导出文件夹(ZIP或解压后的目录);
- 用File API读取其中的JSON和PNG文件;
- 解析JSON,提取关键字段(duration、loop、startSpeed、startSize、texture等);
- 将PNG二进制转为
Laya.Texture2D实例; - 手动new一个
Laya.Particle2D,逐属性赋值,并挂载到场景。
这才是真正落地的“预览”逻辑,而不是调个Loader.load()假装能跑。
2.1 文件选择弹窗:用<input type="file" webkitdirectory>实现目录级选取
Unity导出的粒子包必然包含至少两个文件:一个JSON描述文件 + 一个或多个PNG纹理。若只允许单文件选择,用户得先手动解压、再分别选JSON和PNG,体验极差。Laya官方示例常忽略这点,但生产环境必须支持目录选取。
<!-- 支持Chrome/Edge的原生目录选择 --> <input type="file" id="particleDirInput" webkitdirectory="true" directory="true" multiple style="display:none;"> <button id="selectBtn">📁 选择Unity粒子导出目录</button>提示:
webkitdirectory是Chrome/Edge专属属性,Firefox暂不支持(需降级为ZIP上传+前端解压,本demo暂不展开)。实际项目中建议加UA检测并提示浏览器兼容性。
2.2 目录遍历与文件分类:用DataTransfer.items提取JSON和PNG
用户选中目录后,input.files并不直接返回目录结构,而是通过event.target.files拿到一个FileList,其中每个File对象的webkitRelativePath属性记录了它在所选目录中的相对路径(如"explosion/textures/sheet.png")。我们需要按扩展名归类:
document.getElementById('selectBtn').onclick = () => { document.getElementById('particleDirInput').click(); }; document.getElementById('particleDirInput').onchange = async (e) => { const files = Array.from(e.target.files); const jsonFile = files.find(f => f.name.endsWith('.json')); const pngFiles = files.filter(f => f.name.endsWith('.png')); if (!jsonFile) { alert('未找到JSON配置文件,请确认Unity导出目录结构'); return; } // 读取JSON内容 const jsonText = await fileToText(jsonFile); const particleConfig = JSON.parse(jsonText); // 读取首个PNG(Unity导出通常只有一张图集) if (pngFiles.length === 0) { alert('未找到PNG纹理文件'); return; } const textureBlob = await fileToBlob(pngFiles[0]); // 启动Laya渲染并加载粒子 initLayaAndRender(particleConfig, textureBlob); };fileToText和fileToBlob是封装好的Promise化读取函数,避免回调地狱:
function fileToText(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsText(file, 'utf-8'); }); } function fileToBlob(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsArrayBuffer(file); // 注意:readAsArrayBuffer返回ArrayBuffer,供后续创建Texture2D }); }2.3 JSON结构解析:从Unity导出格式到Laya.Particle2D字段映射
Unity导出的JSON结构深度嵌套,而Laya.Particle2D构造函数仅接受扁平化配置对象。我们需建立明确映射表。以下是最关键的6个字段映射逻辑(其余模块如RotationOverLifetime、ForceOverLifetime可依此类推):
| Unity JSON路径 | Laya.Particle2D字段 | 类型转换说明 | 示例值 |
|---|---|---|---|
duration | duration | 直接赋值(秒) | 5.0 |
loop | loop | 布尔值 | true |
startSpeed.constant | startSpeed | 数值,单位像素/帧 | 200 |
startSize.constantMin | startSize | 取constantMin(Unity导出默认单值) | 32 |
colorOverLifetime.color.r/g/b/a | startColor | 构造Laya.Vector4(r,g,b,a) | {r:1,g:0.5,b:0,a:1}→new Laya.Vector4(1,0.5,0,1) |
texture(字符串路径) | texture | 不直接使用,因路径无效;改用上一步读取的textureBlob生成Texture2D | "textures/sheet.png"→ 忽略,用已加载的Blob |
重点看startColor处理:
function parseStartColor(config) { const colorObj = config.colorOverLifetime?.color; if (!colorObj) return new Laya.Vector4(1, 1, 1, 1); // 默认白色不透明 // Unity导出的color是{r,g,b,a},范围0~1 return new Laya.Vector4( colorObj.r || 1, colorObj.g || 1, colorObj.b || 1, colorObj.a || 1 ); }texture字段则完全抛弃,因为我们已用File API拿到了PNG的ArrayBuffer,接下来要把它喂给Laya的GPU纹理管线:
async function createTextureFromBlob(blob) { return new Promise((resolve, reject) => { const texture = new Laya.Texture2D(); texture.loadArrayBuffer(blob, Laya.Handler.create(null, () => { resolve(texture); }, null, false)); }); }3. 粒子实例化与场景挂载:绕过Loader的手动生命周期管理
Laya.Particle2D不能像Sprite一样直接new完就add到舞台——它依赖内部的ParticleManager进行更新调度。必须确保:
- 实例化前Laya引擎已初始化(
Laya.init()完成); - 粒子Texture必须是
Texture2D类型,且已加载完成(isReady为true); - 挂载到
Laya.stage后,需手动调用particle.play()启动。
3.1 初始化Laya引擎与Canvas容器
async function initLayaAndRender(particleConfig, textureBlob) { // 确保Laya已初始化(若未初始化则先init) if (!Laya.stage) { Laya.init(800, 600, Laya.WebGL); // 强制WebGL,粒子性能更好 Laya.stage.scaleMode = Laya.Stage.SCALE_FIXED_WIDTH; Laya.stage.bgColor = "#222"; } // 创建画布容器 const container = new Laya.Sprite(); Laya.stage.addChild(container); // 加载纹理 const texture = await createTextureFromBlob(textureBlob); // 构建Laya粒子配置对象 const layaConfig = { duration: particleConfig.duration || 3, loop: particleConfig.loop !== undefined ? particleConfig.loop : true, startSpeed: particleConfig.startSpeed?.constant || 100, startSize: particleConfig.startSize?.constantMin || 16, startColor: parseStartColor(particleConfig), texture: texture, // 其他字段... }; // 创建粒子实例 const particle = new Laya.Particle2D(layaConfig); particle.pos(Laya.stage.width / 2, Laya.stage.height / 2); // 居中 container.addChild(particle); // 启动播放 particle.play(); // 附加控制:点击暂停/继续 Laya.stage.on(Laya.Event.CLICK, null, () => { if (particle.isPaused()) { particle.play(); } else { particle.pause(); } }); }3.2 关键字段补全:处理Unity特有的模块开关逻辑
Unity粒子系统大量使用enabled: true/false控制模块开关(如sizeOverLifetime.enabled),而Laya.Particle2D没有对应开关,而是靠是否设置sizeOverLifetime字段决定是否启用。因此需条件式注入:
function buildSizeOverLifetime(config) { const sizeModule = config.sizeOverLifetime; if (!sizeModule || !sizeModule.enabled) return null; // Unity导出的curve是二维数组[[time,value],...],需转为Laya支持的Curve数据格式 // Laya.Curve格式:{ keys: [ { time: 0, value: 1 }, { time: 1, value: 0 } ], type: "linear" } const curveData = sizeModule.curve?.keys || []; if (curveData.length === 0) return null; return { enabled: true, curve: { keys: curveData.map(([t, v]) => ({ time: t, value: v })), type: "linear" } }; } // 注入到layaConfig const sizeOverLifetime = buildSizeOverLifetime(particleConfig); if (sizeOverLifetime) { layaConfig.sizeOverLifetime = sizeOverLifetime; }3.3 内存与性能兜底:销毁旧粒子,防止重复加载泄漏
用户可能多次点击“选择目录”,若不清理,旧粒子实例会持续占用GPU内存并执行update逻辑,导致页面卡顿。必须在每次新加载前销毁上一个:
let currentParticle = null; let currentContainer = null; async function initLayaAndRender(particleConfig, textureBlob) { // 清理上一次 if (currentParticle && currentParticle.destroyed === false) { currentParticle.destroy(true); currentParticle = null; } if (currentContainer) { currentContainer.destroy(true); currentContainer = null; } // ...后续创建逻辑 currentContainer = container; currentParticle = particle; }注意:
destroy(true)的true参数表示深度销毁,释放GPU资源,这是粒子组件必须的操作,漏掉会导致显存泄漏。
4. 避坑:五个真实踩过的坑与血泪修复方案
这个demo看似只有几十行核心逻辑,但在真实复现过程中,我被以下五个问题卡了整整两天。它们不是文档没写,而是Unity导出行为、Laya底层机制、浏览器API限制三者叠加产生的“黑匣子”问题。列在这里,帮你省下调试时间。
4.1 现象:粒子完全不显示,控制台无报错,particle.isReady始终为false
原因:Texture2D.loadArrayBuffer()的回调触发时机不可靠——当ArrayBuffer来自File API时,Laya内部的ImageBitmap解码流程可能因跨域策略或Blob URL失效而静默失败。isReady不置true,粒子就拒绝渲染。
解决:不用loadArrayBuffer的回调,改用Texture2D.createFromSource()手动构造,并监听Texture2D的Event.READY事件:
function createTextureFromBlob(blob) { return new Promise((resolve, reject) => { const arrayBuffer = blob; // ArrayBuffer const texture = Laya.Texture2D.createFromSource(arrayBuffer, Laya.Texture2D.INVERT_Y); texture.once(Laya.Event.READY, () => { if (texture.isReady) resolve(texture); else reject(new Error('Texture load failed')); }); texture.once(Laya.Event.ERROR, reject); }); }4.2 现象:粒子颜色发灰,alpha通道无效,所有粒子半透明叠在一起
原因:Unity导出的PNG默认带Alpha通道,但Laya加载时若未指定premultiplyAlpha: true,RGBA像素值不会预乘,导致混合模式错误。
解决:创建Texture2D时强制开启预乘:
const texture = Laya.Texture2D.createFromSource(arrayBuffer, Laya.Texture2D.INVERT_Y | Laya.Texture2D.PREMULTIPLY_ALPHA);4.3 现象:粒子位置随机偏移,不在pos(x,y)设定点,且随缩放抖动
原因:Unity导出的粒子坐标系是左手系(Y向上),而Laya2D默认Y向下。startLifetime、gravityModifier等物理参数若未翻转Y轴,会导致运动轨迹镜像。
解决:对所有Y方向相关参数取反:
// Unity的gravityModifier是正数向下,Laya需负数才向下 layaConfig.gravity = new Laya.Vector2( particleConfig.gravityModifier?.x || 0, -(particleConfig.gravityModifier?.y || 0) // 关键:取反 );4.4 现象:选择大尺寸图集(如2048x2048)时,Chrome报WebGL: INVALID_VALUE: texImage2D: width or height out of range
原因:部分低端GPU或集成显卡对纹理尺寸有限制,2048是常见上限,但Unity导出可能生成4096图集。Laya未做自动降级。
解决:加载前用Laya.Browser.clientWidth/clientHeight粗略判断设备能力,或强制缩放图集(用Canvas 2D临时绘制并缩小):
async function resizeTextureIfTooLarge(blob, maxSize = 2048) { const arrayBuffer = blob; const img = new Image(); img.src = URL.createObjectURL(new Blob([arrayBuffer], {type: 'image/png'})); return new Promise(resolve => { img.onload = () => { if (img.width <= maxSize && img.height <= maxSize) { resolve(arrayBuffer); return; } // Canvas缩放 const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const scale = Math.min(maxSize / img.width, maxSize / img.height); canvas.width = img.width * scale; canvas.height = img.height * scale; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob(blob => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.readAsArrayBuffer(blob); }, 'image/png'); }; }); }4.5 现象:在iOS Safari上完全无法触发webkitdirectory,按钮点击无反应
原因:iOS Safari至今不支持webkitdirectory,且File API对目录选择无任何替代方案。这是硬性限制。
解决:UA检测后降级为ZIP上传,并用JSZip在前端解压:
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) { // 切换为ZIP上传 document.getElementById('particleDirInput').setAttribute('accept', '.zip'); document.getElementById('particleDirInput').removeAttribute('webkitdirectory'); document.getElementById('particleDirInput').removeAttribute('directory'); }然后用JSZip.loadAsync(file).then(zip => zip.file(/\.json$/).async('text'))提取JSON,逻辑类似。
5. 进阶技巧:用Web Worker预解析JSON,避免UI线程阻塞
当Unity导出的粒子JSON超过1MB(常见于复杂特效),JSON.parse()会阻塞主线程,导致页面卡死1~2秒。这不是Laya的问题,而是JavaScript单线程的固有瓶颈。解决方案是把JSON解析扔进Web Worker,主线程只管UI响应。
5.1 创建解析Worker:parser.worker.js
// parser.worker.js self.onmessage = function(e) { const { jsonText } = e.data; try { const parsed = JSON.parse(jsonText); self.postMessage({ success: true, data: parsed }); } catch (err) { self.postMessage({ success: false, error: err.message }); } };5.2 主线程调用:分离解析与渲染
async function initLayaAndRender(particleConfig, textureBlob) { // ...清理逻辑 // 启动Worker解析(注意:Worker需同源,建议内联为Blob URL) const workerCode = ` self.onmessage = function(e) { try { const parsed = JSON.parse(e.data.jsonText); self.postMessage({ success: true, data: parsed }); } catch (err) { self.postMessage({ success: false, error: err.message }); } }; `; const blob = new Blob([workerCode], { type: 'application/javascript' }); const workerUrl = URL.createObjectURL(blob); const worker = new Worker(workerUrl); // 发送JSON文本 worker.postMessage({ jsonText: jsonText }); // 等待解析结果 const parsedConfig = await new Promise((resolve, reject) => { worker.onmessage = (e) => { if (e.data.success) resolve(e.data.data); else reject(new Error('JSON parse in worker failed: ' + e.data.error)); }; worker.onerror = reject; }); // 释放Worker worker.terminate(); URL.revokeObjectURL(workerUrl); // 后续渲染逻辑... }提示:此方案将1.2MB JSON的解析耗时从380ms(主线程)降至12ms(Worker线程),且UI完全流畅。但要注意,Worker中无法访问
File或Blob,所以必须在主线程读取为text后再传入。
5.3 调试技巧:打印Unity与Laya字段映射对照表
开发时最痛苦的是不知道哪个字段没对齐。我在buildLayaConfig函数开头加了一段调试输出,自动生成映射关系快照:
function debugLogMapping(unityConfig, layaConfig) { console.group('🔍 Unity → Laya 字段映射快照'); console.log('Unity duration:', unityConfig.duration); console.log('→ Laya duration:', layaConfig.duration); console.log('Unity startSpeed.constant:', unityConfig.startSpeed?.constant); console.log('→ Laya startSpeed:', layaConfig.startSpeed); console.log('Unity colorOverLifetime.color:', unityConfig.colorOverLifetime?.color); console.log('→ Laya startColor:', layaConfig.startColor.toString()); console.groupEnd(); }每次加载都输出,一眼定位缺失字段。上线前删掉即可。
5.4 容错增强:JSON Schema校验与默认值兜底
Unity不同版本导出的JSON结构略有差异(如URP vs Built-in RP),直接config.xxx.yyy容易报Cannot read property 'yyy' of undefined。我加了一层Schema校验:
function validateUnityConfig(config) { const required = ['duration', 'loop', 'startSpeed', 'startSize']; for (const key of required) { if (config[key] === undefined) { console.warn(`⚠️ Unity JSON missing required field: ${key}, using default`); } } return { duration: config.duration ?? 3, loop: config.loop ?? true, startSpeed: config.startSpeed?.constant ?? 100, startSize: config.startSize?.constantMin ?? 16, // ...其他 }; }这样即使美术导出时忘了勾选某个模块,程序也不会崩,只是用合理默认值降级。
从那以后我每次接手Unity粒子对接需求,第一件事就是把这份DOM级文件接管逻辑复制进新项目,再根据具体导出版本微调字段映射。它让我彻底摆脱了“等TA改导出插件”或“求后端加代理”的被动等待,真正把粒子预览变成了前端可自主掌控的环节。希望帮到你。
本文还有配套的精品资源,点击获取