简介:这是一款基于Laya引擎的Unity粒子导出预览工具,专为需要在Web端快速查看.lh粒子效果的开发者设计。资源实现了点击按钮弹出文件选择框、导入.lh文件即时应加载预览的功能,支持最多两层嵌套粒子,可一键切换播放与停止状态,切换其他lh文件时无需重新运行项目。同时整合了键盘鼠标视角控制与3D场景混搭网页元素的操作体验,适合从事Laya3D开发、希望验证Unity导出粒子兼容性的中高级开发者参考。资源包共302个文件,以js脚本、png贴图、lmat材质、json配置及lh文件为主,整体23.43MB,代码结构完整,可直接在LayaIDE中运行调试。目前已有742人学习下载,既能作为粒子加载与文件操作的完整范例,也可帮助理解DOM交互、视角控制及3D场景混合布局的实现思路。
1. Laya 预览 Unity 导出粒子:一条本地文件直达浏览器的加载链路
想做一个 Laya 预览 Unity 导出粒子的 demo,最先要解决的不是渲染,而是文件从哪来。Unity 导出的粒子场景由.ls、.lmat、.mesh等一组互相引用的资源组成,美术在 Unity 里调好的 Shuriken 粒子,放到浏览器里并不能被 Laya 直接加载。这套链路里最关键的入口,恰恰是浏览器 DOM 里的弹出文件框:通过它选中本地导出目录,再由自定义加载器接管资源请求,才能真正做到「不启动 Unity、不配服务器、打开页面就能预览粒子」。这个思路对做 3D 小游戏前端、需要频繁核对美术资源的开发者非常实用。
2. Unity 粒子导出:从场景到资源的三个关键配置
粒子必须在 Unity 里先导出成 Laya 可识别的资源,这一步的配置决定了后续链路跑不跑得通。很多人在 Laya 端反复排查问题,最后发现源头是 Unity 导出时材质和资源路径就没对——这类问题查找成本极高,所以导出前我会先做一次脚本体检。
2.1 导出前体检:用编辑器脚本排查材质兼容
Unity 自带粒子用的通常是 Standard 或 URP 材质,而 Laya 的粒子系统需要的是它自己的自定义材质。手动逐个检查几十个粒子节点效率太低,我一般会在 Unity 编辑器里挂一段静态检查脚本,选中粒子根节点后自动遍历所有 Renderer,把不兼容的材质列出来。
using UnityEditor; using UnityEngine; public class ParticleExporterCheck { [MenuItem("Tools/Check Laya Particle")] static void CheckParticleMaterials() { foreach (Transform child in Selection.activeTransform) { var renderer = child.GetComponent<ParticleSystemRenderer>(); if (renderer == null) continue; var mat = renderer.sharedMaterial; if (mat == null || !mat.shader.name.ToLower().Contains("laya")) { Debug.LogWarning($"节点 {child.name} 使用的材质 {mat?.name} 不是 Laya 粒子材质,导出后会变白板"); } } } }这段代码挂在编辑器菜单下,核心逻辑是遍历选中节点及其子节点,检查每个ParticleSystemRenderer的 sharedMaterial。判断条件用了Contains("laya"),因为官方和第三方粒子材质命名都带这个前缀,这样能覆盖大部分情况。注意这里用的是sharedMaterial而不是material,前者不会触发实例化,不会污染场景里的原始资源。
运行这个检查后,凡是报警告的节点都要在导出前处理,否则后面 Laya 端加载时会出现「有节点但粒子完全不可见」这类怪问题。
2.2 粒子模块勾选:哪些能导,哪些必须关
Laya 的 Unity3D 导出插件对 Shuriken 粒子的支持不是全量的。基础发射、颜色、大小、旋转、速度这几大模块都没问题,但下面几个模块是高风险区:
- Sub Emitters(子发射器):嵌套粒子系统在部分插件版本里不会递归导出,表现为子粒子直接消失。
- Trails(拖尾):拖尾需要额外材质支持,Unity 默认的拖尾材质导出后经常让人物或特效变成一个长条色块。
- Custom Data:这个通道 Laya 侧没有对应解析,导了也会被忽略。
- 曲线编辑器的 Curve 模式:如果曲线有多个关键点,部分插件只取首尾值,粒子动画会表现成「跳变」而不是「渐变」。
我一般会在导出前把 Trails 和 Sub Emitters 彻底关掉,曲线尽量用 Gradient 或简单双关键点替代。这不是说功能做不到,而是本地预览 demo 的价值在于快速看效果、快速迭代,为一个小功能去追插件源码,投入产出比太低。
2.3 导出路径与资源依赖:别让反斜杠和中文名坑掉整个 demo
Unity 导出粒子场景时,会在指定目录下生成.ls主文件和一堆.lmat、.mesh、.ltc依赖。这些文件内部的引用关系,写的是相对于.ls所在目录的路径。这里有一个很隐蔽的坑:Unity 在 Windows 上生成的路径分隔符是反斜杠,而浏览器端的 Laya 加载器按正斜杠解析。如果你的导出目录恰好是中文名,还会多一层编码问题。
我个人的习惯是建一个纯英文目录,比如export_particle/effect_01,所有资源都用小写字母和下划线命名。在 Unity 里点导出之前,先看一眼Project窗口里资源名,凡是带中文、空格、大写驼峰的名字都先重命名再导出。这条规矩看着笨,但能省掉后面排查路径问题的几个小时。
3. Laya 侧加载链路:先搞懂 .ls 引用了什么,再谈加载代码
把 Unity 导出目录放到 Laya 工程里以后,不能直接写一句Laya3D.load就完事。粒子资源和其他 3D 资源不同,.ls是一棵资源依赖树,加载器要先拿到.ls的 JSON 内容,再按里面的相对路径去拉材质和网格。不理解这层依赖关系,后面排查「为什么粒子不显示」会非常吃力。
3.1 认识.ls文件的结构与依赖表
.ls文件本质上是 JSON,记录场景节点树、组件参数、以及所有外部资源的相对引用。导出一个名为fire_large的粒子特效后,目录结构通常是这样:
| 文件 | 作用 | 是否必须 |
|---|---|---|
fire_large.ls | 场景主文件,记录粒子节点树与参数 | 是 |
fire_large.lmat | 粒子材质,含混合模式和渲染面片设置 | 是 |
fire_large.lmesh | 粒子网格体,Billboard 模式可省 | 视具体导出而定 |
texture_01.png | 粒子贴图,被.lmat引用 | 是 |
加载顺序是:先加载.ls主文件,解析出lmat路径,再加载材质,材质解析出贴图路径,最后才在场景里实例化粒子节点。任何一个环节的路径断了,粒子就不会渲染,而且控制台经常不报错——这是 3D 资源加载和 2D 资源最大的区别,2D 加载失败会直接弹 404,3D 粒子失败可能只是静默地不显示。
3.2 正常工程的加载写法与参数边界
在 Laya 工程里部署好资源后,标准加载代码是这样的:
const url = "res/export_particle/fire_large.ls"; Laya3D.load(url, Laya3D.PARTICLE, Handler.create(this, function (particle) { this.scene.addChild(particle); particle.transform.position = new Vector3(0, 1, 0); }), Handler.create(this, function (progress) { console.log("加载进度", progress); }));这段代码里Laya3D.load的第二个参数填的是Laya3D.PARTICLE,这会让加载器按粒子资源类型去解析返回对象,实例类型是Particle3D。第三个参数是加载完成回调,回调里拿到的particle可以直接addChild挂到场景,不需要再包一层Sprite3D。第四个参数是进度回调,注意这个回调拿到的 progress 范围是 0 到 100。
加载器内部会先请求.ls的文本内容,解析出依赖列表后自动排队加载依赖资源,最后才触发 complete 回调。所以当 complete 回调触发时,粒子数据理论上已经就绪。如果粒子没显示,优先怀疑的不是这段代码,而是依赖资源是否真的加载成功。
3.3 本地文件的特殊之处:为什么不能直接把路径塞给 Loader
本地预览的难点在于,Laya3D.load内部走的是 XHR 请求。如果传入file:///E:/xxx/fire_large.ls这类路径,浏览器的 XHR 会直接拒绝,报的不是 404 而是「跨域请求被阻止」或直接 failed。这是浏览器的安全策略在起作用,和 Laya 引擎无关,任何 WebGL 框架都绕不过去。
所以本地文件的加载必须换思路:先用浏览器 DOM 的 File API 把文件内容读进内存,转成 ArrayBuffer 或 Blob URL,再让 Laya 的加载器去消费这些「虚拟资源」。第 4 章写的弹出框和文件读取,就是为这一步提供数据源。
4. 文件选择弹出框:操作 DOM 把本地资源送进 Laya
标题里「弹出框选择本地文件」看起来是 UI 功能,实质是让浏览器放行本地资源。这里操作 DOM 的方式有讲究,用原生<input>而不是第三方选择器,是为了绕过框架封装,拿到最原始的 File 对象,方便后续做 Blob URL 映射。
4.1 弹框的最小实现:accept、multiple、webkitdirectory
<input id="particleDir" type="file" accept=".ls,.lmat,.mesh,.png,.tga" webkitdirectory />关键在三个属性。accept限定文件类型,但注意它只是「推荐过滤」,不代表用户不能选其他文件,所以代码里还是要对文件后缀做二次校验。webkitdirectory是让弹框进入「选目录」模式,选中的是整个目录而不是单个文件,这样能一次性拿到.ls和它所有依赖资源。multiple在某些浏览器里和webkitdirectory有冲突,选目录模式下默认支持多文件,不需要重复写。
拿到文件列表后,通过input.files读取。这个FileList是类数组对象,每个File对象上有name、size、webkitRelativePath三个属性,其中webkitRelativePath记录了文件相对于所选目录的路径,这是重建资源依赖树的关键。
4.2 读取文件:FileReader 与 ObjectURL 两条路线
弹框拿到 File 对象后,有两条读取路线。第一条是FileReader.readAsArrayBuffer,拿到二进制数据;第二条是URL.createObjectURL,把 File 对象直接变成一个blob:开头的 URL。两条路线各有适用场景。
function fileToArrayBuffer(file: File): Promise<ArrayBuffer> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as ArrayBuffer); reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(file); }); } function fileToBlobUrl(file: File): string { return Laya.Browser.window.URL.createObjectURL(file); }FileReader适合需要手动解析二进制内容的场景,比如后面想自己把.ls的 JSON 内容读出来、改写依赖路径时,用readAsText更直接。createObjectURL则是一种「轻量引用」,它不复制文件内容,只是给浏览器一个可访问的 URL 指针,XHR 可以直接请求这个 URL,而且不受file://跨域限制。
这里有个内存细节:createObjectURL生成的 URL 在页面关闭前一直占着内存,一个目录套一个目录选下来,容易累积几百兆。所以每次重新选择文件之前,要先把旧的 Blob URL 释放掉。释放时机在 4.4 里讲。
4.3 虚拟路径映射:把目录结构原样搬给加载器
有了 File 对象和 Blob URL,还差最后一步:让 Laya 的加载器把.ls里的相对路径对应到 Blob URL 上。常见做法是先读.ls的文本内容,把里面的引用路径前缀替换成自定义协议头,再由一层映射函数翻译成 Blob URL。
const vfsMap = new Map<string, string>(); function buildVfsMap(files: FileList): void { vfsMap.clear(); for (let i = 0; i < files.length; i++) { const file = files[i]; const virtualPath = "vfs/" + file.webkitRelativePath.replace(/\\/g, "/"); vfsMap.set(virtualPath, fileToBlobUrl(file)); } } function resolveVfs(path: string): string { const normalized: string = path.replace(/^vfs\//, ""); for (const [key, blobUrl] of vfsMap.entries()) { if (key.endsWith(normalized)) { return blobUrl; } } return path; }buildVfsMap把目录里的每个文件生成一个虚拟路径,vfs/后面跟着 webkitRelativePath,注意这里把反斜杠统一替换成正斜杠。resolveVfs是查询函数,用后缀匹配的方式找出对应的 Blob URL——用endsWith而不是全等匹配,是为了抵消.ls文件里路径可能存在的前缀差异。
实际接入 Laya 时,可以把resolveVfs挂在自定义加载器里,拦截所有vfs/开头的 URL 请求,返回 Blob URL。这样.ls里的父子依赖关系在浏览器里完整复刻了一遍,加载器按原路径逐级加载,不需要把资源全部扁平化。
4.4 重新选择的清理:别让旧资源占着内存
弹框有一个容易忽视的行为:用户取消选择时,change事件根本不会触发。如果你在change回调里做了资源加载,取消操作会静默通过,页面还留着上一次的粒子在场景里,视觉上像「没反应」。解决方法是监听按钮的click事件,用延时判断文件框是否真的被用户操作过。
另外,第二次选择目录时,如果不清掉上一轮的 Blob URL,vfsMap里会残留旧文件,而场景里可能还有旧资源引用,造成「新旧两套粒子叠加显示」的幻觉。我每次重新选择前都会先遍历vfsMap释放 URL,再把场景里旧的粒子节点移除。这两步写在change回调最前面,形成固定顺序。
5. 常见问题排查:粒子本地加载的四个真实踩坑记录
这章把我在本地粒子预览 demo 里踩过的最典型的四个坑写出来,每一条都是「现象 → 原因 → 解决」的结构,方便你对照排查。
5.1 粒子节点加载成功但屏幕上一片空白
现象:Laya3D.load的 complete 回调触发了,场景里也能看到节点,但粒子完全不渲染,控制台没有任何报错。
原因:最常见的是材质引用的贴图路径在本地文件映射时断了。.ls引用.lmat能正常加载,但.lmat里引用的贴图路径和实际文件对不上,材质加载失败后粒子系统拿不到贴图,渲染结果就是透明。
解决:在浏览器 Network 面板里把vfs/请求过滤出来,逐个看哪些资源请求没有落到 Blob URL 上。重点查.lmat文件里texture字段对应的路径,和自己构建的vfsMap逐条比对。我一般直接把.lmat内容打印出来,和文件树对一遍,路径大小写不一致是重灾区。
5.2 加载时报 404 或跨域,但资源文件名明明没错
现象:加载.ls时控制台报错,错误信息里能看到资源路径,但那个路径在导出目录里确实存在。
原因:Windows 下 Unity 导出的路径带了反斜杠,.ls内部引用是effects\\fire\\texture_01.png这种格式,而浏览器端整个链路都按正斜杠解析。另外如果目录名带中文,虚拟路径映射时 URL 编码不一致也会出现这种「文件名对但加载不到」的情况。
解决:在构建 vfsMap 时统一做一次归一化,把反斜杠全部换成正斜杠,同时对路径做encodeURI编码。从 Unity 侧看,导出的资源名在进 Laya 工程前就要改成纯英文小写,两个方向同时规避。
5.3 粒子显示成大块白色方块或纯色色块
现象:粒子能渲染,但不是预期纹理,而是一块一块的白色或纯色方块,动态效果混乱。
原因:粒子材质本身没有成功导出,加载器回退到了默认材质。出现这种情况十有八九是 Unity 里粒子节点还在用 Standard 材质,没有换成 Laya 粒子材质,导出插件在遇到不认识的 Shader 时会回退到默认。
解决:回头跑一遍 2.1 里的检查脚本,把所有警告节点列出来,在 Unity 里把材质替换成 Laya 的粒子材质,Shader 选择Laya/Particle/Additive或Laya/Particle/AlphaBlended。替换后重新导出,再走一遍本地加载链路确认。
5.4 选大目录后页面卡死或加载超时
现象:选择了一个包含几千个文件的特效目录,点确认后浏览器卡顿明显,甚至变成无响应状态。
原因:构建 vfsMap 时如果对每个文件同步调用createObjectURL,大量的 Blob URL 会占用主线程,加上FileReader逐个读文件,内存压力瞬间拉满。浏览器对 Blob URL 也没做自动回收,几千个 URL 全堆在 Map 里。
解决:构建映射时加一层懒操作:只有 Laya 请求到某个路径时才临时生成 Blob URL,用完即释放。代码上把resolveVfs改成「先查 Map,没有就现场生成并缓存」,同时限定单次预览只加载.ls直接引用的资源,不把目录全量读进来。
6. 进阶:用拖拽文件替代弹出框,一步直达预览
弹出框是「点击 → 选择 → 确认」三步,高频验证粒子时很烦人。我最后把输入源换成了拖拽:把 Unity 导出目录整个拖进浏览器,事件系统会自带每一个文件的完整相对路径,不需要额外的存储权限,体验接近文件管理器。
private onDrop(e: DragEvent): void { e.preventDefault(); const items: DataTransferItemList = e.dataTransfer!.items; for (let i = 0; i < items.length; i++) { const entry: any = items[i].webkitGetAsEntry(); if (entry && entry.isDirectory) { this.traverseEntry(entry, "vfs/"); } } } private traverseEntry(entry: any, parentPath: string): void { if (entry.isFile) { entry.file((file: File) => { const fullPath = parentPath + entry.name; vfsMap.set(fullPath, fileToBlobUrl(file)); }); } else if (entry.isDirectory) { const reader: any = entry.createReader(); reader.readEntries((entries: any[]) => { for (const child of entries) { this.traverseEntry(child, parentPath + entry.name + "/"); } }); } }webkitGetAsEntry会递归返回目录树,isDirectory和isFile两个标志位区分文件节点。createReader每次最多读 100 条子项,所以readEntries要在回调里检查是否还有剩余项,否则大目录会丢文件——这段代码虽然只调了一次,但实际使用中要循环调用直到返回空数组。
拖拽输入比弹出框多的一个能力是preventDefault拦截浏览器默认打开文件的行为。如果不拦截,浏览器会直接跳转尝试打开.ls文件,导致整个预览页面被替换掉。触发点挂在哪一层也有讲究,我挂在Laya.stage对应的 DOM 元素上,这样既能接收拖入,又不会影响页面里其他普通文本的拖选交互。
从那以后,我每次在 Laya 工程里验证粒子效果,都强制走一遍这个流程:Unity 导出 → 清掉文件名里的空格和中文 → 浏览器拖入目录 → 控制台看加载日志。这套流程把以前半小时的「启动 Unity 编辑器等光照烘焙」压缩成了十秒内出结果。如果你也在做粒子相关开发,希望这个思路能帮到你。
本文还有配套的精品资源,点击获取