简介:本资源是一个基于Laya引擎实现的Unity粒子系统导出预览Demo,面向Web 3D开发初学者与Laya进阶使用者,解决Unity粒子效果在网页端快速加载、动态切换与交互控制的实际需求。项目支持通过弹窗选择本地.lh粒子文件(兼容最多两层嵌套),实时切换播放/暂停状态,无需重启即可热替换粒子资源;同时集成键盘+鼠标视角控制,实现3D场景与HTML元素混合渲染,适用于游戏特效调试、H5互动广告或WebGL轻量级可视化场景。压缩包共302个文件,含67个核心JS逻辑脚本、51张PNG纹理贴图、6个.lh粒子资源及18个.lmat材质文件,辅以JSON配置、日志与IDE工程元数据,整体23.43MB,结构完整、即开即用。目前已有743人学习下载,提供可直接运行的LayaIDE工程、清晰的README说明及典型粒子文件示例,助开发者快速掌握Laya中Unity粒子导入流程与DOM协同操作技巧。
1. Laya预览Unity导出粒子demo:不是简单拖文件,而是打通「Unity导出 → 浏览器加载 → DOM交互」的完整链路
你有没有试过在LayaAir里直接跑Unity导出的粒子效果?不是用Unity WebGL Build那种黑盒打包,而是真正把Unity导出的.json粒子资源(含材质、贴图路径、发射器参数)原样喂给Laya引擎,在浏览器里实时预览、调试、甚至动态替换贴图?这个demo干的就是这事——它不依赖Unity运行时,也不走Laya的旧版Particle2D模拟,而是基于LayaAir 3.x 的Laya.Particle3D模块,完整解析Unity Particle System导出的JSON Schema(实测兼容Unity 2021.3 LTS + URP Particle System导出插件),再通过DOM弹窗精准捕获本地.json文件流,完成零配置热加载。适合做粒子特效审核工具、美术-程序联调沙盒、或轻量级H5动效预览器。如果你正卡在「Unity美术导出的粒子在Laya里白屏/报错/参数失真」,或者想绕过Unity WebGL巨包、用纯前端方案快速验证粒子逻辑,这个资源就是你缺的那块拼图。
2. 为什么必须用Laya.Particle3D而非Laya.Particle2D:从Unity导出结构反推引擎选型逻辑
Unity粒子系统导出的JSON不是扁平数据,而是一个嵌套极深的树状结构:顶层是ParticleSystem对象,下挂emission(发射模块)、shape(形状模块)、velocityOverLifetime(速度随时间变化)、colorOverLifetime(颜色渐变)等子模块,每个模块又含curve(贝塞尔曲线)、gradient(渐变色)、texture(贴图引用)等字段。Laya.Particle2D本质是2D Sprite动画模拟,连rotationOverLifetime这种基础三维旋转都无对应字段;而Laya.Particle3D底层绑定WebGL 2.0,其ParticleMaterial支持albedoTexture、normalTexture、emissionTexture三张贴图通道,且ParticleRenderer能解析startRotation3D、rotationOverLifetime等三维旋转参数——这正是Unity URP粒子导出JSON里rotation字段的映射目标。选错模块,等于拿螺丝刀拧螺母:表面能转,但一用力就崩。
2.1 Unity导出JSON的关键字段与Laya.Particle3D的映射关系
我们拆解一个典型Unity导出JSON片段(已脱敏):
{ "name": "Fire_Spark", "emission": { "rateOverTime": { "minMaxConstant": 50 }, "rateOverDistance": { "minMaxConstant": 0 } }, "shape": { "type": "Sphere", "radius": 0.5, "radiusThickness": 1 }, "colorOverLifetime": { "color": { "gradient": { "keys": [ { "time": 0, "value": [1,0.3,0.1,1] }, { "time": 1, "value": [0.8,0.1,0.05,0] } ] } } }, "textureSheetAnimation": { "enabled": true, "numTilesX": 4, "numTilesY": 4 } }提示:Unity导出需启用
Export Particle System插件(非内置导出),并勾选Include Texture References。若JSON中缺失textureSheetAnimation节点,说明导出时未勾选该选项,Laya将无法播放序列帧动画。
Laya.Particle3D对上述字段的解析逻辑如下:
| Unity JSON字段 | Laya.Particle3D对应API | 是否必需 | 说明 |
|---|---|---|---|
emission.rateOverTime.minMaxConstant | particleSystem.emissionRate | 是 | 直接赋值,单位:粒子/秒 |
shape.type="Sphere" | particleSystem.shape = new SphereShape() | 是 | 必须实例化对应Shape类,不能仅设字符串 |
colorOverLifetime.color.gradient.keys | particleSystem.colorGradient = new Gradient() | 是 | 需手动构建Gradient对象,keys数组需转为GradientKey[] |
textureSheetAnimation.enabled | particleSystem.textureSheetAnimation = true | 否(但启用了才播帧) | 若为true,还需设置particleSystem.textureSheetColumns/Rows |
2.2 DOM弹窗选择文件的核心实现:FileReader + Blob URL的零拷贝加载
Unity导出的粒子JSON通常带相对路径引用贴图(如"textures/fire_spark.png"),但浏览器禁止JS直接读取本地文件系统路径。本demo采用<input type="file">触发原生文件选择,再用FileReader读取为ArrayBuffer,最后通过URL.createObjectURL()生成Blob URL供Laya加载——全程不经过服务器,不写死路径,不依赖file://协议(该协议在Chrome 80+已禁用跨域资源加载)。
<!-- HTML部分:隐藏input,用按钮触发 --> <button id="loadParticleBtn">选择Unity粒子JSON</button> <input type="file" id="particleFileInput" accept=".json" style="display:none;">// JavaScript部分:绑定事件与加载逻辑 document.getElementById('loadParticleBtn').onclick = () => { document.getElementById('particleFileInput').click(); }; document.getElementById('particleFileInput').onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { // 1. 解析JSON文本 const jsonContent = JSON.parse(event.target.result); // 2. 构建粒子系统实例(关键:传入Laya.Scene当前场景) const particleSystem = new Laya.Particle3D(Laya.Scene.getInstance()); // 3. 调用自定义解析函数(见2.3节) parseUnityParticleJSON(particleSystem, jsonContent); // 4. 将粒子系统添加到场景(需先有摄像机和灯光) Laya.Scene.getInstance().addChild(particleSystem); console.log('✅ Unity粒子加载成功,共', particleSystem.particleCount, '个粒子'); } catch (err) { console.error('❌ 粒子JSON解析失败:', err); alert('JSON格式错误或不兼容,请检查Unity导出设置'); } }; reader.readAsText(file); // 注意:此处用readAsText,非readAsArrayBuffer };注意:
readAsText用于读取JSON文本,若后续需加载二进制贴图(如PNG),则需对贴图文件单独用readAsArrayBuffer并转为Laya.Texture2D。本demo默认贴图与JSON同目录,由Laya自动按相对路径请求(需确保开发服务器支持静态资源访问)。
3. 解析Unity JSON到Laya.Particle3D的四步转换:从字段映射到内存对象构建
Unity JSON是声明式描述,Laya.Particle3D是命令式API,二者之间没有自动转换器。本demo提供parseUnityParticleJSON()函数,分四步完成转换:初始化参数 → 构建发射器 → 配置生命周期行为 → 绑定贴图与材质。每步都处理Unity特有字段(如minMaxCurve、gradient),并规避Laya API的隐式约束(如colorGradient必须在particleSystem创建后立即设置,否则无效)。
3.1 初始化粒子系统基础参数:名称、数量、渲染模式
Unity导出JSON中maxParticles字段定义最大粒子数,但Laya.Particle3D的maxParticleCount必须在构造后立即设置,且需是2的幂次(如1024、2048)。若Unity设为1500,Laya需向上取整到2048,否则运行时报maxParticleCount must be power of 2。
function parseUnityParticleJSON(particleSystem, json) { // 步骤1:基础参数 particleSystem.name = json.name || 'UnnamedParticle'; // maxParticles必须是2的幂次,Laya内部用位运算优化 const maxParticles = json.maxParticles || 1024; const validMax = Math.pow(2, Math.ceil(Math.log2(maxParticles))); particleSystem.maxParticleCount = validMax; // 渲染模式:Unity的RenderMode映射到Laya的RenderMode枚举 // Unity: 0=Billboard, 1=Stretched Billboard, 2=Horizontal Billboard, 3=Vertical Billboard // Laya: Laya.Particle3D.RENDERMODE_BILLBOARD等 const unityRenderMode = json.renderMode || 0; const layaRenderModeMap = [ Laya.Particle3D.RENDERMODE_BILLBOARD, Laya.Particle3D.RENDERMODE_STRETCHED_BILLBOARD, Laya.Particle3D.RENDERMODE_HORIZONTAL_BILLBOARD, Laya.Particle3D.RENDERMODE_VERTICAL_BILLBOARD ]; particleSystem.renderMode = layaRenderModeMap[unityRenderMode] || layaRenderModeMap[0]; // 关键:必须在此处设置材质,否则后续贴图绑定失败 particleSystem.material = new Laya.ParticleMaterial(); }逻辑说明:
maxParticleCount的幂次校验是Laya引擎硬性要求,非建议。若忽略此步,粒子系统创建后particleCount恒为0,控制台无报错,属于静默失败。我一般会加一行console.assert((particleSystem.maxParticleCount & (particleSystem.maxParticleCount - 1)) === 0, 'maxParticleCount must be power of 2');作为后悔药。
3.2 构建发射器(Emission)与形状(Shape):处理minMaxConstant与SphereShape
Unity的rateOverTime常为{ "minMaxConstant": 50 },表示固定速率50;而Laya的emissionRate直接接收数字。但若Unity设为{ "minMaxCurve": { ... } }(曲线控制),本demo暂不支持,需降级为常量——这是第一个妥协点。
// 步骤2:发射器与形状 if (json.emission && json.emission.rateOverTime) { const rateData = json.emission.rateOverTime; // 优先取minMaxConstant,fallback到minMaxCurve.min(若存在) const rate = rateData.minMaxConstant !== undefined ? rateData.minMaxConstant : (rateData.minMaxCurve?.constantMin || 10); particleSystem.emissionRate = rate; } if (json.shape) { switch (json.shape.type) { case 'Sphere': const sphereShape = new Laya.SphereShape(); sphereShape.radius = json.shape.radius || 1; sphereShape.radiusThickness = json.shape.radiusThickness || 1; particleSystem.shape = sphereShape; break; case 'Box': const boxShape = new Laya.BoxShape(); boxShape.halfExtents = new Laya.Vector3( json.shape.boxX || 1, json.shape.boxY || 1, json.shape.boxZ || 1 ); particleSystem.shape = boxShape; break; default: console.warn(`⚠️ 不支持的Unity形状类型: ${json.shape.type},使用默认Sphere`); particleSystem.shape = new Laya.SphereShape(); } }参数说明:
radiusThickness控制粒子在球体内分布密度(0=表面,1=实心球),Unity默认为1,Laya需显式设置。若漏设,粒子只出现在球面,导致视觉稀疏——这是美术反馈“粒子太少”的常见原因。
3.3 配置生命周期行为:ColorGradient与RotationOverLifetime的精确还原
Unity的colorOverLifetime含gradient对象,其keys是时间-颜色数组。Laya的Gradient需将每个key转为GradientKey,且time必须归一化到[0,1](Unity导出的time已是0~1,可直用)。
// 步骤3:生命周期行为 if (json.colorOverLifetime && json.colorOverLifetime.color?.gradient?.keys) { const gradientKeys = json.colorOverLifetime.color.gradient.keys.map(key => { return new Laya.GradientKey( new Laya.Vector4(...key.value), // RGBA向量 key.time // time已在0~1范围 ); }); const gradient = new Laya.Gradient(); gradient.setKeys(gradientKeys); particleSystem.colorGradient = gradient; } // RotationOverLifetime:Unity导出为{ x: {minMaxConstant: 0}, y: {...}, z: {...} } if (json.rotationOverLifetime) { const rotX = json.rotationOverLifetime.x?.minMaxConstant || 0; const rotY = json.rotationOverLifetime.y?.minMaxConstant || 0; const rotZ = json.rotationOverLifetime.z?.minMaxConstant || 0; // Laya用Vector3表示三维旋转速度(度/秒) particleSystem.startRotation3D = new Laya.Vector3(rotX, rotY, rotZ); particleSystem.rotationOverLifetime = true; // 必须显式开启 }逻辑说明:
startRotation3D是初始旋转速度,rotationOverLifetime是开关。若只设前者不开启后者,粒子将保持初始朝向不动。Unity导出JSON中rotationOverLifetime是布尔值,但Laya需分开设置——这是字段语义差异导致的典型坑。
3.4 绑定贴图与材质:解决相对路径与跨域贴图加载
Unity导出JSON中的textureSheetAnimation含贴图名,但Laya需Laya.Texture2D实例。本demo采用Laya.Loader.create()异步加载,并用Promise.all等待所有贴图就绪后再设置粒子系统。
// 步骤4:贴图加载(示例:处理textureSheetAnimation) async function loadTexturesAndApply(particleSystem, json) { const texturePromises = []; if (json.textureSheetAnimation?.enabled && json.textureSheetAnimation.texture) { // Unity导出的texture字段是相对路径,如 "textures/fire_spark.png" const texturePath = json.textureSheetAnimation.texture; texturePromises.push( Laya.Loader.create(texturePath, null, null, Laya.Loader.TEXTURE2D) ); } if (texturePromises.length > 0) { try { const textures = await Promise.all(texturePromises); if (textures[0]) { particleSystem.textureSheetAnimation = true; particleSystem.textureSheetColumns = json.textureSheetAnimation.numTilesX || 4; particleSystem.textureSheetRows = json.textureSheetAnimation.numTilesY || 4; particleSystem.material.albedoTexture = textures[0]; // 主贴图 } } catch (err) { console.error('❌ 贴图加载失败:', err); // 降级:继续运行,但无贴图 } } }注意:
Laya.Loader.create()需在Laya引擎初始化后调用(即Laya.init()之后),且路径必须相对于HTML所在目录。若开发时用Vite,需将贴图放在public/目录下,路径写/textures/fire_spark.png。
4. 避坑:五个真实翻车现场与血泪解决方案
做这个demo时,我在某跨平台系统中连续踩了五天坑,最终整理出以下高频问题。每一条都来自真实控制台报错、白屏现象或美术反馈“效果不对”,不是理论推测。
4.1 现象:粒子完全不显示,控制台无报错,particleSystem.particleCount始终为0
原因:particleSystem.maxParticleCount未设为2的幂次,或未在new Laya.Particle3D()后立即设置。Laya引擎内部校验失败时静默丢弃粒子,不抛异常。
解决:在parseUnityParticleJSON开头强制校验并修正:
particleSystem.maxParticleCount = Math.pow(2, Math.ceil(Math.log2(json.maxParticles || 1024)));并在设置后加断言:console.assert(particleSystem.maxParticleCount > 0, 'maxParticleCount not set!');
4.2 现象:粒子显示但颜色全白,或渐变失效
原因:Unity导出的colorOverLifetime.gradient.keys中value是RGBA数组(如[1,0.3,0.1,1]),但Laya的Vector4构造函数要求new Laya.Vector4(x,y,z,w),若顺序错(如当XYZW为RGBα)会导致Alpha=0,粒子透明。
解决:严格按[R,G,B,A]顺序传参,且确认Unity导出JSON中value确实是四元组。可在解析前加校验:
if (key.value.length !== 4) { console.warn(`⚠️ color key value length ${key.value.length}, expected 4, using [1,1,1,1]`); key.value = [1,1,1,1]; }4.3 现象:贴图加载报404,路径显示为http://localhost:3000/textures/fire_spark.png但文件实际在public/textures/
原因:Vite开发服务器默认将public/目录映射到根路径,但Laya.Loader.create()的路径解析依赖HTML的<base href>。若HTML中无<base>或href不为/,路径会相对当前HTML位置计算。
解决:在HTML<head>中添加<base href="/">,或统一用绝对路径调用Laya.Loader.create('/textures/fire_spark.png')。
4.4 现象:粒子旋转方向与Unity中相反(如Unity顺时针,Laya逆时针)
原因:Unity使用左手坐标系,Laya使用右手坐标系,rotationOverLifetime的旋转轴方向相反。Unity的z轴旋转在Laya中需取负。
解决:对startRotation3D的z分量取负:
particleSystem.startRotation3D = new Laya.Vector3(rotX, rotY, -rotZ);血泪经验:这个坑让美术反复调整Unity参数三天,最后发现是坐标系差异。从此我所有跨引擎项目都加注释
// Unity Z+ = Laya Z-。
4.5 现象:弹窗选择文件后,第二次选择同一文件不触发onchange事件
原因:<input type="file">元素的value属性在选中文件后被赋值,若再次选相同文件,浏览器认为值未变,不触发事件。
解决:在onchange处理末尾清空input.value:
document.getElementById('particleFileInput').onchange = (e) => { // ... 加载逻辑 e.target.value = ''; // 关键:重置input值,确保下次可触发 };5. 进阶技巧:用Laya Inspector实时调试粒子参数,把JSON变成可交互的控件
光靠改JSON再刷新页面调试粒子,效率极低。LayaAir自带Laya.Inspector(需在Laya.init()后调用Laya.Inspector.open()),但默认不暴露Particle3D的深层参数。本demo扩展了Inspector,将Unity JSON的关键字段(发射率、球体半径、颜色渐变)映射为实时滑块,实现「改参数→看效果」的闭环。
5.1 注入Inspector控件:为粒子系统添加可编辑属性面板
// 在粒子系统加载完成后,注入Inspector function injectInspectorForParticle(particleSystem, json) { // 创建Inspector分组 const group = Laya.Inspector.addGroup('Unity Particle Controls'); // 发射率滑块:映射到particleSystem.emissionRate Laya.Inspector.addFloat(group, 'Emission Rate', () => particleSystem.emissionRate, (val) => { particleSystem.emissionRate = val; } ).min(0).max(500).step(1); // 球体半径滑块:映射到particleSystem.shape.radius if (particleSystem.shape instanceof Laya.SphereShape) { Laya.Inspector.addFloat(group, 'Sphere Radius', () => particleSystem.shape.radius, (val) => { particleSystem.shape.radius = val; } ).min(0.1).max(5).step(0.1); } // 颜色渐变预览(只读,展示当前gradient) Laya.Inspector.addString(group, 'Color Gradient Keys', () => { const keys = particleSystem.colorGradient?.keys || []; return keys.map(k => `[${k.time.toFixed(2)}, ${k.color.x.toFixed(2)},${k.color.y.toFixed(2)},${k.color.z.toFixed(2)},${k.color.w.toFixed(2)}]`).join('; '); } ); }提示:
Laya.Inspector需在开发环境启用(生产环境自动禁用),且依赖layaair2-cmd或layaair2-js完整包。若用精简版,需手动引入Laya.Inspector模块。
5.2 保存调试后的参数回JSON:生成可复用的Unity兼容配置
调试满意后,常需把修改后的参数同步回Unity工程。本demo提供exportToUnityJSON()函数,将Laya运行时参数反向生成Unity兼容JSON片段(不含贴图路径,仅核心行为参数)。
function exportToUnityJSON(particleSystem) { return { name: particleSystem.name, maxParticles: particleSystem.maxParticleCount, emission: { rateOverTime: { minMaxConstant: particleSystem.emissionRate } }, shape: { type: 'Sphere', radius: particleSystem.shape?.radius || 1, radiusThickness: particleSystem.shape?.radiusThickness || 1 }, colorOverLifetime: { color: { gradient: { keys: (particleSystem.colorGradient?.keys || []).map(key => ({ time: key.time, value: [key.color.x, key.color.y, key.color.z, key.color.w] })) } } }, rotationOverLifetime: { x: { minMaxConstant: particleSystem.startRotation3D?.x || 0 }, y: { minMaxConstant: particleSystem.startRotation3D?.y || 0 }, z: { minMaxConstant: -particleSystem.startRotation3D?.z || 0 } // 注意Z轴取负 } }; } // 使用:复制到剪贴板,粘贴到Unity脚本中 document.getElementById('exportBtn').onclick = () => { const json = exportToUnityJSON(currentParticleSystem); navigator.clipboard.writeText(JSON.stringify(json, null, 2)); alert('✅ Unity JSON已复制到剪贴板!'); };5.3 文件操作DOM的终极健壮性封装:支持拖拽、多文件、错误提示一体化
原始<input type="file">体验差。我们封装成ParticleLoader类,支持拖拽区域、多文件选择、文件类型校验、加载状态UI。
class ParticleLoader { constructor(containerId) { this.container = document.getElementById(containerId); this.input = document.createElement('input'); this.input.type = 'file'; this.input.accept = '.json'; this.input.multiple = false; this.bindEvents(); } bindEvents() { // 点击容器触发选择 this.container.onclick = () => this.input.click(); // 拖拽进入高亮 this.container.ondragover = (e) => { e.preventDefault(); this.container.style.borderColor = '#4CAF50'; }; this.container.ondragleave = () => { this.container.style.borderColor = '#ccc'; }; // 拖拽释放加载 this.container.ondrop = (e) => { e.preventDefault(); this.container.style.borderColor = '#ccc'; const files = e.dataTransfer.files; if (files.length > 0) this.loadFile(files[0]); }; // 文件选择加载 this.input.onchange = (e) => { if (e.target.files.length > 0) this.loadFile(e.target.files[0]); }; } async loadFile(file) { if (!file.name.endsWith('.json')) { alert('❌ 请选择.json格式的Unity粒子文件'); return; } this.container.innerHTML = '⏳ 加载中...'; try { const text = await file.text(); const json = JSON.parse(text); parseUnityParticleJSON(currentParticleSystem, json); this.container.innerHTML = '✅ 加载成功!点击重新选择'; } catch (err) { this.container.innerHTML = '❌ 加载失败:' + err.message; console.error(err); } } } // 初始化 new ParticleLoader('dropZone');从那以后我每次做文件加载功能,都强制走一遍这个封装:拖拽+点击双入口、文件类型强校验、加载中状态反馈、错误信息直出到UI。再也不用听测试说“点了没反应”——因为没反应时UI已经明确告诉你“错了”。希望帮到你。
本文还有配套的精品资源,点击获取