☰
Unity粒子Laya本地预览Demo:从选文件到加载的完整实现
2026/10/17 12:59:05 网站建设 项目流程

简介:面向 Laya 引擎开发者的粒子预览示例工程,解决 Unity 导出的 .lh 粒子在 Laya 环境中难以直接预览和调试的问题,适合需要将 Unity 粒子效果迁移到 Laya3D 项目的开发者参考。Demo 通过按钮触发本地文件选择框,选中 .lh 文件即可实时加载并显示粒子效果;支持播放与停止状态切换,也支持直接更换另一个 lh 文件而无需重新运行项目;同时提供键盘与鼠标视角切换,便于从远近、左右等角度观察粒子细节。整体采用 3D 场景与网页 DOM 混合布局,能直观展示 Laya 与 Web 页面交互的常用写法;目前该版本仅支持最多两层嵌套的粒子,且已在 LayaIDE 环境下测试通过,适合作为 Laya 粒子加载与交互设计的起点。资源压缩包共 302 个文件,压缩后约 23.43MB,主要文件类型包括 js 脚本、png 贴图、lmat 材质、json 配置、lh 粒子描述、html 入口及 ts 源码等,配合 readme 说明可快速理解工程结构。已有 742 人学习/下载,从目录组织和代码实现中可看到文件选择、粒子播放停止、热切换、视角控制等关键模块的落法,按此思路可直接搭建自己的 Laya 粒子预览工具。

1. 这个Demo解决什么问题:Unity粒子在Laya里的本地预览

美术同事扔过来一个Unity导出的粒子包:一个lpi文件加一包贴图,让我在Laya项目里先确认效果再拍板。老流程要先新建场景、挂节点、拖资源、编译,来回折腾至少十分钟。这个Demo就是专治这种预览场景的——页面常驻一个「选择粒子文件」按钮,点一下弹出系统文件框,选中本地lpi,粒子立刻在舞台上生成并循环播放。它本质是Laya运行时加DOM文件操作的小工具,核心链路完整:弹框选文件、FileReader读JSON、加载贴图、挂粒子模板、清理旧节点。适合Laya前端、Unity转Laya的技术美术、做资源验收的测试同学直接抄去改。

2. 前置知识:Laya粒子系统与Unity导出文件的格式约定

2.1 LayaAir的粒子组件怎么工作

Laya引擎的粒子其实是两套方案。UI层和2D小特效走Particle2D加ParticleTemplate2D,适合飘字、火花、UI上的一圈粒子装饰;3D场景里走Particle3D,配合ParticleTemplate3D,适合法术爆发、受击冲击、带景深的风雨雪天气。Unity粒子转到Laya默认走3D这条线,官方导出插件会生成一份lpi文件,里面记录着粒子的所有参数和贴图引用。lpi没有固定的后缀要求,只是约定俗成,插件默认存成.lpi,偶尔也会是.json。

为什么不能直接让Laya去读Unity的prefab?因为运行时结构完全不同。Unity的粒子系统是一大堆组件组合,Laya的粒子是「模板数据 + 渲染器」的结构,中间必须有一个转译层。导出插件做的就是这件事:把Unity发射器、形状、曲线、颜色渐变、贴图全部翻译成一份结构化JSON。所以在Laya里做粒子预览,本质上不是把Unity的东西原样搬过来,而是让Laya能解析那份转译后的数据。

Laya的加载管线对lpi是「认识」的。用Laya.loader.create加载一个lpi路径时,引擎会按后缀匹配到粒子加载器,解析JSON,构造粒子模板,返回一个可以挂到场景上的Sprite3D节点。所以这个demo最核心的事其实只有三件:让Laya拿到lpi文本内容、让纹理可访问、把生成的节点挂到场景正确的位置。剩下全是流程穿插。

比喻一下:lpi是剧本,引擎是演员,demo是导演。导演把本地选中的剧本递给演员读,演员读懂了就上台。我们要写的代码,就是那副递剧本的手。

这里要提醒一下启动时序。Laya的3D场景必须在Laya.init之后才能创建,粒子加载的回调也依赖引擎渲染循环已经跑起来。如果你的demo一进页面就自动加载,回调里拿到节点时场景还没初始化,会白屏。所以demo里统一放在点击按钮之后再初始化预览区,点击这个动作天然保证了时机正确。这个顺序问题后面在第5章逐条说。

2.2 Unity导出的粒子文件里有什么

一份从Unity导出的lpi,打开看就是JSON,基本长这样:

{ "type": "ParticleSystem", "duration": 1.5, "looping": true, "startDelay": 0.2, "startLifetime": { "constant": 1.2, "min": 0.8, "max": 1.5 }, "startSize": { "constant": 0.8, "min": 0.5, "max": 1.0 }, "startColor": { "r": 1, "g": 0.5, "b": 0.2, "a": 1 }, "gravityModifier": 0.5, "emission": { "rate": { "constant": 40 } }, "shape": { "type": "Cone", "angle": 15, "radius": 0.3 }, "texture": "Textures/fire.png", "material": { "renderMode": "Additive" } }

这不是固定版本的标准答案,不同Unity版本和不同插件导出会多出不少字段,但核心块就是这些。duration是粒子系统持续多少秒,looping决定循环,startLifetime是每个粒子存活时间,emission.rate是每秒发射数量,shape决定发射区域,texture是贴图路径,material.renderMode决定叠加还是正常混合。字段名在不同导出版本里可能略有出入,但语义差不多。

最需要注意的就是texture这一个字段。插件导出时,lpi和贴图目录保持Unity工程里的相对布局,lpi里写的是相对路径。比如Unity工程里纹理在Assets/Textures/fire.png,导出后lpi里就是Textures/fire.png,而这个贴图和lpi在同一个导出根目录下。所以预览前先打开lpi看一眼texture,然后确认这个贴图路径在Web服务器上能不能访问到,八成粒子不显示的翻车原因都在这里。

因为lpi是美术协作出货的中间产物,不可能预先知道放在哪台机器、哪个目录,所以demo用弹出框在运行时选文件,比写死路径要合理得多。这也是这个demo存在的根本意义:预览的素材不是工程内置资源,而是外部输入的临时资源。

3. 核心链路拆解:DOM弹出框、FileReader读取、粒子模板生成

3.1 弹出框选择本地文件:input[type=file]与DOM操作

Laya前端最终跑在浏览器里,底层还是要跟DOM打交道。弹系统文件框的标准做法是创建一个隐藏的input[type=file]并模拟点击,监听change事件拿文件。我把这个行为封装成了一个类方法:

// ParticlePreview.ts public openFileDialog(): void { let input: HTMLInputElement = document.createElement("input"); input.type = "file"; input.accept = ".lpi,.json,application/json"; input.style.display = "none"; document.body.appendChild(input); input.addEventListener("change", (e: Event) => { let target: HTMLInputElement = e.target as HTMLInputElement; let files: FileList = target.files; if (files && files.length > 0) { this.handleFile(files[0]); } // 用完立即移除,避免DOM节点堆积,也能保证下次change一定触发 document.body.removeChild(input); }); input.click(); }

这里有几个细节值得说。第一,每次点击都动态创建input,而不是在index.html里写死一个隐藏input,是因为input.value在重复选择同一个文件时不会触发change事件;动态创建每次拿到的都是全新的input和File对象,重复选同一个文件也能正常回调。第二,change回调里取完FileList就removeChild,防止demo反复点击后DOM里堆积一堆废弃input节点。第三,accept只是一个过滤提示,用户依然可以选择任意后缀文件,真正校验要靠后续解析。

参数上,accept可以写成.lpi,.json,也可以带MIME类型application/json,实测对Windows和macOS的文件框都有过滤作用;如果美术给的lpi后缀不统一,最保险是把accept留空,完全靠JSON.parse判断。

还有一点,input.click()必须放在用户手势的调用栈里。如果你在异步回调里触发,或者页面加载完成后自动调用,Chrome这类浏览器会直接拦截弹框,表现为点击按钮没反应。所以按钮的点击事件直接同步调用openFileDialog,不要包一层setTimeout,也别在Promise回调里触发。

3.2 读取与解析:FileReader与JSON解析

拿到File对象后,Laya加载器是没法直接读它的,因为加载器只认URL,本地文件没有URL。所以先要用FileReader把文本读出来:

private handleFile(file: File): void { let reader: FileReader = new FileReader(); reader.onload = (e: ProgressEvent) => { try { let rawText: string = reader.result as string; // Unity导出的lpi常带BOM头,JSON.parse会不认,先剥掉 let jsonText: string = rawText.replace(/^\uFEFF/, ""); let data: any = JSON.parse(jsonText); this.loadParticleFromData(data, file.name); } catch (err) { console.error("解析粒子文件失败", err); } }; reader.onerror = (e: ProgressEvent) => { console.error("文件读取失败", file.name); }; reader.readAsText(file, "utf-8"); }

逻辑说明:readAsText默认按UTF-8读,Unity在Windows上导出的lpi常带一个不可见的BOM(\uFEFF),直接交给JSON.parse会在第一个字符上报错,所以读到的文本先replace掉,这是一个体积小但翻车率极高的坑。try/catch把解析错误接住,避免整个页面崩掉。onerror单独监听,权限问题、文件被占用都会走到这里。

参数说明:如果以后要支持Unity导出的prefab(部分插件支持),那不能指望它是纯文本,需要改用readAsArrayBuffer,再把字节流交给专门的解析器,这里只提方向不展开。lpi文件一般几十KB,FileReader读文本对内存没压力;真正要注意的是别把整个粒子目录拖进来一次性读,后面二次开发里说怎么改成批量。

3.3 从数据到粒子:加载与生成

JSON已经取到了,但Laya的粒子加载器需要的是一个「资源路径」,并不是现成的JSON对象。最顺手的做法,是把JSON重新变成Blob,用objectURL冒充一个路径交给加载器:

private loadParticleFromData(data: any, fileName: string): void { let lpiJson: string = JSON.stringify(data); let blob: Blob = new Blob([lpiJson], { type: "application/json" }); let url: string = URL.createObjectURL(blob); // 按文件名的前缀做节点标记,方便下次预览前按名字销毁 let nodeName: string = fileName.replace(/\.(lpi|json)$/i, ""); this.cleanParticle(nodeName); Laya.loader.create(url, Laya.Handler.create(this, () => { // 常见写法:3D粒子用Sprite3D.create挂到场景 let sprite: Laya.Sprite3D = Laya.Sprite3D.create(url); sprite.name = nodeName; this.previewScene.addChild(sprite); // 加载完成立即释放objectURL,避免占用内存池 URL.revokeObjectURL(url); })); }

逻辑说明:这一步把「从文件框拿到的本地内容」转换成加载器可以识别的内容。objectURL是浏览器给本地内容分配的临时内存路径,Laya加载器能不能正确处理它取决于引擎版本,我的实测是部分老版本对blob支持不够稳,新版本通常没问题。如果遇到加载器解析出空对象,就退回到另一种方案:先让美术把lpi和贴图拷到bin/res/particles,再用Laya.Sprite3D.load("res/particles/xxx.lpi", ...)去加载,虽然多一步文件放置,但稳定得多。demo里默认写的是objectURL方案,因为省事。

参数说明:Laya.Sprite3D.create(url)返回的是预制体节点,可以直接addChild;如果你的项目是2D粒子系统,则要换用new Laya.Particle2D(new Laya.ParticleTemplate2D(templateData, texture))这条路,两者获取数据的方式完全不一样,但前面读文件和解析JSON的部分是通用的。

清理逻辑很重要。每预览一个新粒子,先把之前场景里的同名节点destroy掉,否则每点一次文件,场景里就多一套粒子,内存和DrawCall都会暴涨。cleanParticle负责按name遍历previewScene的子节点做销毁。这个demo虽然小,但「先清理、再创建」的顺序我建议养成习惯,后面第5章还会展开说。

4. 跑通步骤:把Demo放进Laya项目并运行

4.1 项目结构与入口代码

建议不要在空页面里手搓,直接基于Laya IDE的空项目模板改,省得到处配tsconfig。改完的最小工程差不多这样:

layaParticlePreview/ ├─ bin/ │ ├─ index.html │ └─ res/ │ └─ particles/ │ ├─ fire.lpi │ └─ Textures/ │ └─ fire.png ├─ src/ │ ├─ Main.ts │ ├─ ParticlePreview.ts │ └─ config/ │ └─ Settings.ts └─ laya.config.js

bin目录是浏览器访问的根目录,res下面放的是测试粒子资源。src里两个文件就是demo的全部代码:Main.ts负责初始化Laya引擎并挂预览模块,ParticlePreview.ts负责弹框、读文件、生成粒子。config目录是给后续改造用的,demo里可以先不挂。

我习惯把粒子资源和代码分开,因为美术同事交付的就是一堆lpi加贴图,很多时候他们整个文件夹丢过来,你只需要拉到bin/res下就能跑,不用污染代码目录。文件职责简单对照如下:

文件职责是否必改
src/Main.ts初始化Laya、设置舞台模式按实际分辨率调
src/ParticlePreview.ts弹框、读文件、创建与清理粒子核心逻辑,必看
bin/res/particleslpi与贴图放置目录转存资源时改路径
laya.config.jsIDE编译与运行配置一般不动

Main.ts的入口代码比较固定:

import { ParticlePreview } from "./ParticlePreview"; class Main { constructor() { Laya.init(1280, 720); Laya.stage.scaleMode = "showall"; Laya.stage.alignH = "center"; Laya.stage.alignV = "middle"; let preview: ParticlePreview = new ParticlePreview(); preview.setup(); } } new Main();

逻辑说明:Laya.init(1280, 720)决定舞台逻辑尺寸,浏览器会按scaleMode缩放适配。showall表示完整显示,两边留黑边;如果项目是移动端全屏,改成fullscreen。setup里我会创建预览场景、挂一个底部按钮,并注册点击事件。

这里有一个小参数要说:Laya.init之后的2D舞台和3D场景是两套体系。粒子如果走Particle3D,需要再创建Laya.Scene3D,并把粒子节点挂到Scene3D下面;如果走2D粒子,直接挂Laya.stage。这个demo为了通用,我在ParticlePreview.setup里两个都建了,按资源配置切换。

4.2 运行与验证

跑起来不复杂,但验证要验证到位。操作步骤:

  1. 用Laya IDE打开工程,等IDE把依赖编译好
  2. 把美术给的测试粒子包整个拖进bin/res/particles,保持lpi里texture的相对结构
  3. 在ParticlePreview.ts里确认texture路径解析是按相对路径走的
  4. 按F5编译,IDE会启动内置静态服务器并打开调试页
  5. 点页面底部「选择粒子文件」,选中bin/res/particles下的fire.lpi
  6. 观察粒子是否生成、循环播放、颜色和贴图是否和Unity里一致

验证时我习惯开三个面板。Console看有没有解析报错和404;Network看贴图请求是否返回200;性能面板看DrawCall有没有异常飙升。两条安全线要守住:粒子能循环播放且不闪烁,连续点五个不同文件后场景里节点数不持续增长。

如果一切正常,说明从「本地文件」到「Laya运行时」这条链路是通的。之后要做的就是把ParticlePreview.ts里的硬编码路径替换成美术实际交付的目录,把按钮事件接到你自己的工具栏上。

有几个编译期问题提醒一下。src目录里的入口类必须在tsconfig.json的include范围内,新建的ParticlePreview.ts如果不在,IDE会报找不到模块。另一个是bin/index.html里的脚本引用方式,如果demo用了ES module语法,需要确认引用方式一致,不然浏览器会报CORS错误。这些在老Laya项目里都是常见的翻车点,遇到了别先怀疑代码,先看配置。

5. 常见问题与排查:本地文件读不出来、粒子不显示、贴图404

5.1 现象一:选中文件后Console报Unexpected token

很多第一次跑这个demo的人,会在JSON.parse那行看到Unexpected token,或者JSON.parse: unexpected character at line 1。原因基本只有一个:文件带BOM头。Windows环境下Unity导出的lpi经常在开头加一个不可见字符,JSON标准不允许这个字符存在。

解决方式就是handleFile里对读到的字符串做一次replace(/^\uFEFF/, "")。但要注意文件编码,如果美术同事在Mac上用文本编辑器另存成了UTF-16,那光去BOM也没用,读出来全是乱码。这种情况改用reader.readAsText(file, "utf-8")明确指定编码,如果还是乱码,让同事把文件另存为UTF-8 without BOM,这是最省事的手段。

5.2 现象二:粒子节点生成了,但画面里什么都没有

节点挂到场景了,控制台也没报错,就是黑屏看不见,或者只看到几个半透明点。最常见原因是贴图路径在Web上访问不到。lpi里写的是Textures/fire.png,你直接把lpi放bin根目录,贴图却放在别的路径,加载器解析纹理时返回null,粒子就渲染成空壳。

解决:先打开lpi看一下texture字段,再检查bin目录里有没有对应的相对路径文件,确保lpi和贴图在导出后保持原有相对布局。我一般会先手动访问一下http://localhost:xxxx/res/particles/Textures/fire.png确认200,再回demo里刷新预览。

还有一种是渲染模式的问题。lpi里material.renderMode是Additive,如果舞台背景是纯黑,叠加模式的粒子在暗色背景上看不清;换个灰白背景测试就能发现粒子其实是正常的。

5.3 现象三:每次预览后内存和DrawCall越来越高

连续选五个不同粒子文件后,场景节点越来越多,FPS往下掉。原因就是上一轮粒子没销毁,新粒子又挂上去了。Laya的粒子节点在播放结束后不会自动从场景移除,如果你不主动destroy,它就一直留在渲染列表里,DrawCall和显存都会被拖住。

解决:在创建新粒子前,按节点名找到旧粒子,先removeChild再destroy。我习惯把清理逻辑放在loadParticleFromData最前面,保证「先清后建」是原子操作。如果粒子数据里还带了纹理引用,记得把纹理也从显存里释放掉,光destroy节点不释放纹理,内存还是涨。

5.4 现象四:双击bin/index.html用file://打开,弹框后粒子不显示

直接把index.html拖进浏览器,file协议下页面能打开,按钮也能弹框,但粒子加载永远失败。原因是浏览器安全策略:file://页面里对本地资源的读取限制很多,Laya的加载器走XMLHttpRequest,在file协议下会直接失败。

解决:不要在file://下调试,用Laya IDE自带的静态服务器,或者命令行起一个npx serve bin,然后访问http://localhost:3000。这也是我为什么强调demo必须在http环境下跑的原因。如果你在公司内网环境,把这个demo挂到内部前端静态页面上也一样能跑,反而更省事。

5.5 现象五:objectURL方案在部分Laya版本下白屏

这是一个跟引擎版本强相关的坑。部分Laya 2.x版本对blob:URL识别不完整,加载器拿不到正确的资源类型,回调里Sprite3D.create返回空节点,画面就白屏。

解决:先升级引擎版本到3.x,如果项目锁版本没法动,就退回静态资源方案:lpi放bin/res,用Laya.Sprite3D.load("res/particles/fire.lpi", ...)加载。反正demo本身不挑引擎版本,选稳定的路走。升级后重新跑一遍4.2的验证步骤,确认没有回归。

6. 二次开发思路:从单文件预览扩展成内部粒子预览工具

这个demo最大的价值是链路完整,但离「能交付」还差一步。我给它加了三个改造方向,照着改就行。

第一个方向是批量选择。把input.multiple = true,用户在文件框里可以一次选多个lpi,遍历files列表,每个文件走一遍handleFile,粒子会在场景里依次叠加。这样比一次选一个高效,尤其是验收美术一个包里有十几个粒子特效时。但注意每个粒子的贴图路径可能是相对路径,批量选时最好把lpi的texture字段统一重写成实际可访问的URL,否则第一个能出来,后面全是空粒子。

第二个方向是控制面板。demo现在生成完就让它自己播,我加了一个顶部工具栏:暂停/继续、单次/循环、1倍速/2倍速/0.5倍速。Laya的粒子对象通常有paused和loop这类控制属性,具体字段名按你项目里的SDK版本查一下,但控制逻辑是一样的:暂停就停掉粒子系统的tick,继续就恢复,改速就调timeScale。这个小面板能解决美术「要看慢动作」的实际需求。

第三个方向是我自己踩过坑后加的习惯:内部工具里不要依赖objectURL这种临时内存路径,而是做一层「资源代理」。所有选中的文件在FileReader读完后,把lpi和配套贴图一起转成blob存进一个Map,统一分配虚拟路径preview://particles/fire.lpi给Laya加载器,加载完再把blob释放。这样引擎版本升级也不怕,还方便以后加缓存和指纹。代码结构上无非是把3.3里的URL.createObjectURL换成自己管理缓存的模块,接口保持一致。

从那以后,我每次在Laya里做粒子预览,都强制走一遍「选文件→读JSON→校验texture路径→再加载」的流程,把lpi和贴图当成一对资源整体处理,再也不在加载器上玩花活。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询