☰
Unity 2D路径画笔与粒子画笔:从绘制到数据管线实战
2026/10/14 11:59:18 网站建设 项目流程

简介:这是一份面向Unity开发者与交互设计学习者的粒子画笔与路径画笔实战工程,基于Unity 2D场景,重点解决如何用ParticleSystem模拟笔触轨迹、让粒子沿预设路径绘制图案与文字的问题。资源包共2891个文件,约23.02MB,包含69个cs脚本、84个prefab预制体、149个png贴图、3个mat材质以及大量bin、info、meta等工程与缓存文件,另有dll、asset、config等配置资源,完整保留了可运行工程与源码结构。内容围绕粒子生成、旋转、生命周期、颜色渐变、力场模拟,以及Bezier曲线路径、粒子跟随、形状描边、动画控制与交互式画布等知识点展开,脚本中涉及OnDrawGizmos路径可视化与Input输入处理。已有1525人学习下载,适合希望掌握粒子特效与路径绘制、并需要可修改原工程进行二次开发的Unity学习者参考。

1. 从一次翻车说起:粒子画笔和路径画笔到底能干什么

去年帮一个做独立游戏的朋友调一个 2D 关卡编辑器,需求很朴素:让策划能在场景里随手画一条曲线,敌人沿着曲线巡逻;再画一片粒子区域,角色踩进去就触发拖尾和爆点。他一开始想用 LineRenderer 硬拼,结果曲线拐弯处锯齿严重,粒子跟着路径走的时候速度忽快忽慢,调了两天直接摆烂。后来换成「路径画笔 + 粒子画笔」这套组合,半天就跑通了。这就是我拆这个 Unity 2D 资源的原因——它解决的不是「怎么画一条线」,而是「怎么把画出来的线变成可复用的数据,再让粒子系统按这条数据动起来」。

这套东西的核心是两套画笔工具:路径画笔负责在 Scene 视图里用鼠标拖拽生成平滑曲线,输出的是点序列加切线信息;粒子画笔负责在指定区域内按笔刷形状发射粒子,支持沿路径分布。适合做 2D 平台跳跃的巡逻路线、弹幕轨迹、技能范围指示、场景装饰粒子带。新手能直接拿来做关卡原型,熟手可以把它当数据管线的一环,把画出来的路径导出成 JSON 喂给 AI 或动画系统。下面按「原理 → 动手 → 避坑 → 进阶」的顺序拆开讲,代码都是能直接抄的。

2. 路径画笔的底层逻辑:从鼠标轨迹到 Catmull-Rom 曲线

2.1 为什么不用 LineRenderer 直接连点

很多人第一反应是拿 LineRenderer 的 positionCount 一个个塞鼠标坐标,画出来能用但没法看。鼠标采样点间距不均匀,快速拖动时点稀疏,慢速拖动时点密集,直接连线会出现折角。更麻烦的是,LineRenderer 只负责渲染,不提供「路径参数化」能力——你想让物体沿路径匀速运动,得自己算弧长,而原始点序列的弧长分布是乱的。

路径画笔的做法是:先把鼠标轨迹做一次重采样,再用 Catmull-Rom 样条插值生成平滑曲线,最后按弧长均匀取点。这样输出的路径点间距一致,后续做匀速运动、粒子分布、切线计算都省事。Catmull-Rom 的好处是曲线一定穿过控制点,策划画到哪线就到哪,不像 Bezier 那样需要额外调手柄。

2.2 核心代码:轨迹采样与样条插值

using System.Collections.Generic; using UnityEngine; public class PathBrush : MonoBehaviour { [Range(0.05f, 0.5f)] public float sampleStep = 0.15f; // 重采样间距 [Range(4, 32)] public int splineResolution = 12; // 每段插值点数 private List<Vector3> rawPoints = new List<Vector3>(); private List<Vector3> smoothPoints = new List<Vector3>(); // 鼠标拖动时调用,收集原始点 public void AddRawPoint(Vector3 worldPos) { if (rawPoints.Count > 0 && Vector3.Distance(rawPoints[rawPoints.Count - 1], worldPos) < sampleStep) return; // 太近就丢弃,避免点堆积 rawPoints.Add(worldPos); } // 松手后调用,生成平滑路径 public List<Vector3> BuildSmoothPath() { smoothPoints.Clear(); if (rawPoints.Count < 2) return smoothPoints; for (int i = 0; i < rawPoints.Count - 1; i++) { // Catmull-Rom 需要前后各一个控制点,边界做钳制 Vector3 p0 = rawPoints[Mathf.Max(i - 1, 0)]; Vector3 p1 = rawPoints[i]; Vector3 p2 = rawPoints[i + 1]; Vector3 p3 = rawPoints[Mathf.Min(i + 2, rawPoints.Count - 1)]; for (int j = 0; j < splineResolution; j++) { float t = j / (float)splineResolution; smoothPoints.Add(CatmullRom(p0, p1, p2, p3, t)); } } smoothPoints.Add(rawPoints[rawPoints.Count - 1]); return smoothPoints; } private Vector3 CatmullRom(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { float t2 = t * t; float t3 = t2 * t; return 0.5f * ((2f * p1) + (-p0 + p2) * t + (2f * p0 - 5f * p1 + 4f * p2 - p3) * t2 + (-p0 + 3f * p1 - 3f * p2 + p3) * t3); } }

逻辑说明:AddRawPoint里的距离判断是关键,不加的话鼠标每帧都塞点,一千个点插值完直接卡死编辑器。sampleStep控制原始点密度,2D 场景一般 0.1 到 0.2 之间比较合适,太小浪费性能,太大曲线会丢细节。splineResolution是每两个原始点之间插多少个平滑点,12 是经验值,画大弧线可以调到 20,画小折线 6 就够。

参数说明:rawPoints存的是鼠标世界坐标,注意 2D 项目里要把 z 固定成 0,否则相机透视会让路径飘。CatmullRom公式里的 0.5f 系数不能省,省了曲线会变形。

2.3 弧长重参数化:让物体沿路径匀速走

插值出来的点虽然平滑,但间距仍然不均匀——曲线拐弯急的地方点会挤在一起。如果直接按索引推进,物体在直道飞快、弯道变慢,看起来像卡顿。常见做法是算累积弧长,再按目标速度反查位置。

public class PathFollower : MonoBehaviour { public float speed = 3f; private List<Vector3> path; private float[] cumulativeLength; private float totalLength; private float traveled; public void SetPath(List<Vector3> points) { path = points; cumulativeLength = new float[path.Count]; cumulativeLength[0] = 0f; for (int i = 1; i < path.Count; i++) cumulativeLength[i] = cumulativeLength[i - 1] + Vector3.Distance(path[i - 1], path[i]); totalLength = cumulativeLength[path.Count - 1]; traveled = 0f; } void Update() { if (path == null || path.Count < 2) return; traveled += speed * Time.deltaTime; if (traveled >= totalLength) traveled -= totalLength; // 循环 transform.position = GetPositionAtDistance(traveled); } Vector3 GetPositionAtDistance(float dist) { for (int i = 1; i < cumulativeLength.Length; i++) { if (cumulativeLength[i] >= dist) { float segLen = cumulativeLength[i] - cumulativeLength[i - 1]; float t = (dist - cumulativeLength[i - 1]) / segLen; return Vector3.Lerp(path[i - 1], path[i], t); } } return path[path.Length - 1]; } }

这段代码里cumulativeLength是前缀和数组,GetPositionAtDistance做的是「按距离找段、段内线性插值」。二分查找可以优化,但路径点通常几百个,线性扫足够。traveled -= totalLength实现循环巡逻,不想循环就改成enabled = false。

3. 粒子画笔:把笔刷形状映射到 ParticleSystem

3.1 粒子画笔和普通粒子系统的区别

Unity 自带的 ParticleSystem 发射器只有点、圆、盒、锥几种形状,想做「沿一条曲线撒粒子」或者「在自定义笔刷区域内随机发射」就得自己写。粒子画笔的思路是:把路径点或笔刷采样点作为发射位置,通过ParticleSystem.Emit手动发射,或者用SetParticles批量写入位置和速度。

手动 Emit 的坑在于:它不受发射模块的速率控制,你得自己管节奏。好处是位置完全可控,想在哪发就在哪发。适合做技能轨迹、拖尾、路径装饰这类「位置驱动」的效果。

3.2 沿路径发射粒子的实现

using UnityEngine; [RequireComponent(typeof(ParticleSystem))] public class ParticleBrush : MonoBehaviour { public float emitInterval = 0.05f; // 发射间隔 public float speedJitter = 0.5f; // 速度抖动 public Vector2 baseVelocity = new Vector2(0f, 1f); private ParticleSystem ps; private List<Vector3> brushPath; private float timer; private int cursor; void Awake() { ps = GetComponent<ParticleSystem>(); } public void SetBrushPath(List<Vector3> points) { brushPath = points; cursor = 0; } void Update() { if (brushPath == null || brushPath.Count == 0) return; timer += Time.deltaTime; if (timer < emitInterval) return; timer = 0f; Vector3 pos = brushPath[cursor]; cursor = (cursor + 1) % brushPath.Count; // 沿路径循环取点 var emitParams = new ParticleSystem.EmitParams(); emitParams.position = pos; emitParams.velocity = baseVelocity + new Vector2(Random.Range(-speedJitter, speedJitter), Random.Range(-speedJitter, speedJitter)); emitParams.startLifetime = Random.Range(0.4f, 0.8f); emitParams.startSize = Random.Range(0.1f, 0.25f); ps.Emit(emitParams, 1); } }

逻辑说明:cursor沿着路径点循环推进,保证粒子均匀分布在整条路径上。EmitParams允许每次发射单独覆盖位置、速度、生命周期、大小,这是手动发射的核心。注意ps.Emit的第二个参数是发射数量,这里每次发 1 个,想要密集效果可以调大或者减小emitInterval。

参数说明:emitInterval和粒子生命周期共同决定屏幕上同时存在的粒子数,0.05 秒间隔配 0.6 秒生命周期大约是 12 个粒子,性能无压力。speedJitter给速度加随机扰动,避免粒子排成一条死板的直线。baseVelocity是基础方向,2D 项目里通常设成 (0,1) 向上飘或者 (1,0) 向右喷。

3.3 笔刷区域随机发射:把路径变成「面」

如果不想沿路径发射,而是想在路径围成的区域内随机撒粒子,做法是先对路径做三角剖分或者简单用包围盒加多边形内点判断。轻量方案是用PolygonCollider2D的OverlapPoint做拒绝采样。

public class AreaParticleBrush : MonoBehaviour { public PolygonCollider2D brushArea; public int particlesPerBurst = 20; private ParticleSystem ps; void Awake() { ps = GetComponent<ParticleSystem>(); } public void BurstInArea() { Bounds b = brushArea.bounds; int emitted = 0, guard = 0; while (emitted < particlesPerBurst && guard < 500) { guard++; Vector2 candidate = new Vector2( Random.Range(b.min.x, b.max.x), Random.Range(b.min.y, b.max.y)); if (!brushArea.OverlapPoint(candidate)) continue; // 不在多边形内就重试 var ep = new ParticleSystem.EmitParams(); ep.position = candidate; ep.velocity = Random.insideUnitCircle * 2f; ps.Emit(ep, 1); emitted++; } } }

guard是防止多边形太细长导致死循环的后悔药,超过 500 次就放弃。OverlapPoint要求PolygonCollider2D的isTrigger勾上,否则物理系统会参与碰撞计算,白白浪费性能。

4. 避坑与排查:那些编辑器里看不出来的问题

4.1 现象:画完路径后物体抖动,像在抽搐

原因:鼠标采样点里混入了重复点或极近点,Catmull-Rom 在极短段上切线方向剧烈变化,插值结果来回跳。解决:在AddRawPoint里加距离阈值过滤,阈值取sampleStep的一半。另外检查 2D 项目里鼠标坐标转世界坐标时有没有把 z 写成相机距离,z 不对会导致点投影到不同平面。

4.2 现象:粒子沿路径走,但拐弯处粒子堆积

原因:路径点按索引均匀取,但弧长不均匀,弯道处单位索引对应的实际距离短,粒子自然挤在一起。解决:不要用cursor按索引推进,改成按累积弧长推进,用第 2.3 节的GetPositionAtDistance取位置。如果懒得改,至少把splineResolution调大,让点密度足够高,堆积会缓解但不根治。

4.3 现象:编辑器里画得好好的,运行后路径偏移

原因:画笔在 Scene 视图用的是世界坐标,但物体本身有父级 Transform,运行时路径点没有跟着父级变换走。解决:把路径点存成相对于画笔父级的局部坐标,运行时用transform.TransformPoint转世界坐标。或者干脆把画笔挂在一个不动的根节点下,路径点直接当世界坐标用。

4.4 现象:粒子发射数量对不上,设了 100 只出来 30

原因:ParticleSystem的maxParticles默认是 1000,但如果你在 Inspector 里改过,或者Emit调用频率超过粒子生命周期允许的上限,多出来的会被丢弃。解决:检查main模块的maxParticles,手动 Emit 场景下把它设成「生命周期 × 发射频率 × 峰值数量」的 1.5 倍。另外Emit每帧调用次数不要超过 10 次,太频繁会有性能开销。

4.5 现象:路径画笔在 Game 视图里看不到线

原因:路径画笔通常用Gizmos或Debug.DrawLine在 Scene 视图画预览,Game 视图不渲染 Gizmos。解决:如果要在游戏里显示路径,得用LineRenderer或TrailRenderer把平滑后的点渲染出来。常见做法是编辑时用 Gizmos 预览,运行时按需开启 LineRenderer。

5. 进阶技巧:把画笔数据导出成 JSON 喂给其他系统

路径画笔真正的价值不在「画」,而在「画完能存下来复用」。我一般会在画笔脚本里加一个导出方法,把平滑后的点序列和切线写成 JSON,策划画完直接导出,程序读 JSON 生成巡逻路线或弹幕轨迹,两边不用互相等。

[System.Serializable] public class PathData { public float[] xs; public float[] ys; public float totalLength; } public PathData ExportPath(List<Vector3> points) { PathData data = new PathData(); data.xs = new float[points.Count]; data.ys = new float[points.Count]; for (int i = 0; i < points.Count; i++) { data.xs[i] = points[i].x; data.ys[i] = points[i].y; } data.totalLength = 0f; for (int i = 1; i < points.Count; i++) data.totalLength += Vector3.Distance(points[i - 1], points[i]); return data; } // 调用处 string json = JsonUtility.ToJson(ExportPath(smoothPoints), true); System.IO.File.WriteAllText( Application.dataPath + "/Paths/level01_path.json", json);

JsonUtility是 Unity 自带的,不支持字典和顶层数组,所以PathData用两个平行数组存 x 和 y。导出路径建议放在Application.dataPath下的Paths文件夹,运行时用Resources.Load或者Addressables读回来。totalLength存下来是为了运行时不用重新算弧长,直接拿来做匀速运动。

验证方法:导出后在 Console 里打印totalLength,再在 Scene 视图里用 Gizmos 把点画出来,肉眼比对曲线是否一致。如果长度对不上,多半是导出前没做弧长重参数化,点间距不均匀导致累加误差。

还有一个容易忽略的点:路径点的数量。一条横跨屏幕的曲线,splineResolution设 12 大概会产生 200 到 400 个点,JSON 文件几 KB,完全没问题。但如果策划画了几十条路径,总量上到几万点,加载时会有明显卡顿。我一般会在导出前做一次抽稀,把曲率小于阈值的中间点删掉,保留拐点,点数能压到三分之一。

从那以后我每次接关卡编辑器的活,都强制先跑一遍「画路径 → 导出 JSON → 运行时读回 → Gizmos 比对」这个闭环,确认数据管线通了再往上堆功能。希望帮到你。

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

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

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

立即咨询