简介:这是一份面向Unity开发者与交互设计学习者的粒子与路径画笔实现工程,基于Unity 2D场景,重点解决如何用ParticleSystem模拟画笔笔触、并让粒子沿预设路径绘制图案或文字的问题。资源包共2891个文件,以bin、info、meta等Unity工程与缓存文件为主,包含84个prefab预制体、69个cs脚本、149个png贴图、21个asset资源及若干mat材质与dll库,压缩包约23.02MB,属于可直接导入运行的完整项目结构。已有1525人学习下载。内容围绕粒子生成、旋转、力场、生命周期与颜色渐变等画笔特性,以及Bezier曲线路径、粒子跟随、形状描边和交互式画布等路径绘制要点展开,配套脚本与预制体便于读者对照修改参数、理解粒子发射与运动控制逻辑,适合想深入掌握Unity粒子系统视觉表现与自定义画笔交互的中级开发者参考实践。
1. 粒子画笔与路径画笔:Unity 里两种“画”的底层差异
很多人在 Unity 里做绘画类交互时,第一反应是找现成的画笔插件,结果发现粒子画笔和路径画笔根本是两套逻辑。粒子画笔的核心是“发射器跟着手走”,每一帧在触点位置生成一批粒子,靠粒子系统自身的生命周期完成消散;路径画笔的核心是“记录轨迹点,再决定怎么渲染”,它不依赖粒子,而是把触点序列变成 LineRenderer 或 Mesh 的顶点数据。这两者在 Unity 里对应的组件、更新时机、性能瓶颈完全不同。如果你要做的是涂鸦、光绘、魔法轨迹这类效果,粒子画笔更合适;如果你要做的是签名、路径规划、可编辑线条,路径画笔才是正解。这篇文章会从组件选型、参数配置、代码实现到避坑,把两条路线都跑通一遍,适合已经会写 MonoBehaviour 但没系统做过绘画交互的开发者。
2. 粒子画笔:从发射器跟随到粒子参数调优
2.1 为什么粒子画笔不能直接挂 ParticleSystem 在笔尖
常见做法是把 ParticleSystem 作为子物体挂在笔尖上,然后靠Play()和Stop()控制。但这样做的直接问题是:粒子发射器的位置是固定的,你移动笔尖时,粒子系统整体跟着移动,已经发射出去的粒子也会跟着走,看起来像一团云在拖尾,而不是“留在原地消散”。正确的做法是把 ParticleSystem 的simulationSpace设为World,这样粒子一旦发射就脱离发射器,留在世界坐标里。同时把emission模块的rateOverTime设为 0,改用Emit()手动触发,这样才能精确控制“每帧在触点位置发射多少粒子”。
另一个容易翻车的点是startLifetime和startSpeed的配合。如果startSpeed太大,粒子会四散飞开,看起来像爆炸而不是画笔;如果太小,粒子会堆在笔尖不动。我一般会把startSpeed设在 0.5 到 2 之间,startLifetime设在 0.3 到 1.5 秒,具体看你要的是“光点拖尾”还是“烟雾消散”。
2.2 用 Emit 参数在触点生成粒子的最小代码
下面这段代码挂在笔尖物体上,每帧根据鼠标或触摸位置发射粒子。注意EmitParams的用法,它允许你覆盖粒子系统默认的起始参数,这是实现“不同触点不同颜色/大小”的关键。
using UnityEngine; public class ParticleBrush : MonoBehaviour { public ParticleSystem ps; public float emitCountPerFrame = 3f; public float minSize = 0.05f; public float maxSize = 0.15f; public Color brushColor = Color.cyan; private ParticleSystem.EmitParams emitParams; void Start() { // 确保粒子系统在世界空间模拟,否则粒子会跟着笔尖跑 var main = ps.main; main.simulationSpace = ParticleSystemSimulationSpace.World; // 关闭自动发射,完全由代码控制 var emission = ps.emission; emission.rateOverTime = 0f; } void Update() { if (Input.GetMouseButton(0)) { Vector3 screenPos = Input.mousePosition; // 根据相机深度把屏幕坐标转成世界坐标,z 值决定画笔平面 Vector3 worldPos = Camera.main.ScreenToWorldPoint( new Vector3(screenPos.x, screenPos.y, 10f)); emitParams.position = worldPos; emitParams.startColor = brushColor; emitParams.startSize = Random.Range(minSize, maxSize); // 每帧发射固定数量,保证线条连续 ps.Emit(emitParams, Mathf.RoundToInt(emitCountPerFrame)); } } }逻辑说明:simulationSpace设为World是粒子画笔的根基,不设这个后面全白搭。rateOverTime = 0配合手动Emit才能做到“只在按下时发射”。emitParams.position每帧更新为当前触点世界坐标,startSize用随机值让粒子有粗细变化,避免看起来像一条死板的线。参数方面,emitCountPerFrame决定线条密度,太小会断断续续,太大会瞬间堆满粒子导致性能下降,一般 2 到 5 之间比较稳。z = 10f是相机到画笔平面的距离,如果你的场景是 2D 或者正交相机,这个值要按实际调整。
2.3 粒子画笔的三个必调参数:Lifetime、Speed、Size over Lifetime
粒子画笔看起来像不像“笔”,八成取决于这三个参数。startLifetime决定粒子从生到灭的时间,短了像火花,长了像烟雾。startSpeed决定粒子离开笔尖后的扩散速度,设为 0 时粒子会原地不动,适合做“堆积型”画笔;设为 1 到 2 时会有轻微扩散,适合光绘。Size over Lifetime曲线是让粒子在生命周期内从大变小或从小变大,这是做出“消散感”的关键。我一般会把它设成一条从 1 降到 0 的曲线,这样粒子末端会自然淡出,而不是突然消失。
还有一个容易被忽略的参数是Color over Lifetime。如果你只设了startColor,粒子在整个生命周期内颜色不变,看起来会很生硬。加上Color over Lifetime把 alpha 从 1 降到 0,粒子就会在消散时逐渐透明,视觉上干净很多。这两个曲线在 Inspector 里调比在代码里设更直观,建议先手动调好再考虑用代码覆盖。
3. 路径画笔:用 LineRenderer 和 Mesh 记录轨迹
3.1 LineRenderer 的 positionCount 与 SetPosition 调用时机
路径画笔最直接的实现是 LineRenderer。它的逻辑是:维护一个List<Vector3>记录所有轨迹点,每帧把新触点加进去,然后调用SetPosition更新。但这里有个性能陷阱:positionCount每次改变都会触发内部重建,如果每帧都改positionCount再逐个SetPosition,点多了之后开销很明显。常见做法是预分配一个固定大小的数组,比如 5000 个点,然后用一个currentIndex记录当前写到哪,只更新positionCount到实际使用的数量。
另一个关键是“什么时候加点”。如果每帧都加,鼠标移动慢的时候点会非常密集,浪费顶点;移动快的时候点又太稀疏,线条会变成折线。我一般会设一个最小距离阈值,比如 0.05 个单位,只有新触点距离上一个点超过这个值才加入。这样既能保证线条平滑,又不会产生大量冗余顶点。
3.2 带最小距离过滤的路径记录代码
下面这段代码实现了带距离过滤的路径画笔,同时用 LineRenderer 渲染。注意minDistance的用法和positionCount的更新方式。
using System.Collections.Generic; using UnityEngine; public class PathBrush : MonoBehaviour { public LineRenderer lineRenderer; public float minDistance = 0.05f; public int maxPoints = 5000; private List<Vector3> points = new List<Vector3>(); private bool isDrawing = false; void Start() { lineRenderer.positionCount = 0; lineRenderer.startWidth = 0.1f; lineRenderer.endWidth = 0.1f; // 使用世界坐标,避免父物体移动导致线条错位 lineRenderer.useWorldSpace = true; } void Update() { if (Input.GetMouseButtonDown(0)) { points.Clear(); isDrawing = true; AddPoint(GetWorldPos()); } else if (Input.GetMouseButton(0) && isDrawing) { AddPoint(GetWorldPos()); } else if (Input.GetMouseButtonUp(0)) { isDrawing = false; } } void AddPoint(Vector3 newPoint) { // 距离过滤:太近的点不加入,避免顶点冗余 if (points.Count > 0) { float dist = Vector3.Distance(points[points.Count - 1], newPoint); if (dist < minDistance) return; } // 超过上限时移除最早的点,形成“有限长度”画笔 if (points.Count >= maxPoints) { points.RemoveAt(0); } points.Add(newPoint); lineRenderer.positionCount = points.Count; lineRenderer.SetPositions(points.ToArray()); } Vector3 GetWorldPos() { Vector3 screenPos = Input.mousePosition; return Camera.main.ScreenToWorldPoint( new Vector3(screenPos.x, screenPos.y, 10f)); } }逻辑说明:useWorldSpace = true是路径画笔的常见设置,这样线条不会因为父物体移动而整体偏移。minDistance过滤掉过近的点,maxPoints限制总点数,超过时移除最早的点,这样画笔不会无限长。SetPositions一次性传入数组比逐个SetPosition效率高,但每次调用都会复制数组,点特别多时可以考虑用SetPosition配合positionCount增量更新。参数方面,minDistance太小会导致顶点数暴涨,太大会让线条看起来有棱角,0.03 到 0.1 之间比较合适。maxPoints根据你的场景和性能预算来定,5000 个点在移动端已经需要留意了。
3.3 从 LineRenderer 升级到 Mesh:什么时候该换
LineRenderer 的局限在于它始终是一条“线”,宽度均匀,不能做渐变宽度、不能做纹理沿路径流动、不能做复杂的端点形状。如果你要做的是“笔刷有粗细变化”或者“路径上有流动光效”,就需要自己生成 Mesh。常见做法是:把轨迹点作为中心线,在每个点处计算法线方向,然后向两侧扩展出顶点,形成三角形带。这个过程比 LineRenderer 复杂得多,但灵活性也高得多。
我一般会先问自己:这个路径画笔需要宽度变化吗?需要纹理动画吗?如果都不需要,LineRenderer 足够。如果需要,再上 Mesh。Mesh 方案的核心是MeshFilter和MeshRenderer,每帧更新vertices和triangles数组。注意顶点数不能超过 65535,超过就要用IndexFormat.UInt32。这个切换点大概在路径点超过 3000 个、或者需要每点独立宽度的时候。
4. 避坑与排查:粒子画笔和路径画笔的五个血泪经验
4.1 粒子跟着笔尖跑,不是留在原地
现象:画出来的线条像一团云在拖尾,松手后粒子也跟着笔尖移动。原因:ParticleSystem的simulationSpace默认是Local,粒子坐标是相对于发射器的。解决:在Start里把main.simulationSpace设为ParticleSystemSimulationSpace.World,并且确保发射器本身不要挂在会移动的父物体下,或者挂上去但保持世界空间模拟。
4.2 路径画笔线条闪烁或断裂
现象:快速移动时线条断成几截,或者整体闪烁。原因:positionCount和SetPosition的调用顺序不对,或者每帧都重新分配数组导致 GC 抖动。解决:先设positionCount再SetPositions,并且用List缓存点,避免每帧ToArray()。如果还有点闪烁,检查minDistance是不是太大,导致快速移动时跳过了太多点。
4.3 粒子数量暴涨导致帧率骤降
现象:画了几秒后帧率从 60 掉到 20。原因:emitCountPerFrame设得太大,或者startLifetime太长,粒子还没消失就堆了几千个。解决:把emitCountPerFrame降到 2 到 3,startLifetime控制在 1 秒以内,并且在粒子系统上开启Max Particles限制,比如 2000。超过上限时粒子系统会自动回收最早的粒子,避免无限增长。
4.4 屏幕坐标转世界坐标的 z 值设错
现象:画笔位置和鼠标位置对不上,或者画出来的东西在相机后面。原因:ScreenToWorldPoint的 z 值代表“距离相机多远”,设成 0 会得到相机本身的位置,设成负值会跑到相机后面。解决:根据你的场景深度设一个固定值,比如 10,或者用射线检测和某个平面求交来得到精确的世界坐标。正交相机下 z 值不影响 x/y,但透视相机下必须设对。
4.5 LineRenderer 的材质和排序问题
现象:线条被场景物体遮挡,或者线条之间互相穿插错乱。原因:LineRenderer 默认使用Sprites/Default或Particles/Standard Unlit,渲染队列和深度测试设置不对。解决:给 LineRenderer 单独建一个材质,把Render Queue设为Transparent,ZWrite关掉,ZTest根据需求设为Always或LEqual。如果线条之间要正确遮挡,需要开启ZWrite并确保它们在同一渲染队列里按深度排序。
5. 进阶技巧:把粒子画笔和路径画笔合成一个可切换的笔刷系统
如果你已经跑通了上面两条路线,下一步很自然会想:能不能做一个笔刷系统,按一个键在粒子画笔和路径画笔之间切换?我实际做下来,关键不是“切换渲染方式”,而是“切换数据记录方式”。粒子画笔不需要记录轨迹点,它只关心当前帧的发射位置;路径画笔需要维护一个点列表。所以我的做法是:用一个BrushController管理当前模式,粒子模式下每帧调用ParticleBrush.EmitAt(pos),路径模式下每帧调用PathBrush.AddPoint(pos)。两者共用同一个GetWorldPos()方法,保证坐标转换逻辑一致。
下面是一个简化的切换代码,展示两种模式如何共存:
using UnityEngine; public class BrushController : MonoBehaviour { public enum BrushMode { Particle, Path } public BrushMode currentMode = BrushMode.Particle; public ParticleBrush particleBrush; public PathBrush pathBrush; void Update() { // 按空格切换模式 if (Input.GetKeyDown(KeyCode.Space)) { currentMode = currentMode == BrushMode.Particle ? BrushMode.Path : BrushMode.Particle; // 切换时清空路径,避免残留 if (currentMode == BrushMode.Path) { pathBrush.ClearPath(); } } // 根据模式决定是否启用对应组件 particleBrush.enabled = currentMode == BrushMode.Particle; pathBrush.enabled = currentMode == BrushMode.Path; } }逻辑说明:enabled控制组件的Update是否执行,这是最轻量的切换方式,不需要销毁或重建物体。ClearPath()需要在PathBrush里补一个公开方法,把points清空并设positionCount = 0。参数方面,切换时要注意粒子系统里已经发射的粒子不会立刻消失,如果希望切换后立刻干净,可以调用particleSystem.Clear()。路径画笔则必须手动清空,否则下次画的时候会把之前的点连起来。
还有一个实用技巧是“路径转粒子”:把路径画笔记录的点列表,每隔几个点就在该位置发射一簇粒子,这样就能做出“沿着路径飘散”的效果。这个在特效里很常见,实现上就是遍历points,按固定间隔调用ps.Emit()。注意不要每帧都转,只在需要的时候转一次,否则粒子数量会失控。
我自己的习惯是:做原型阶段先用 LineRenderer 快速验证交互,确认手感没问题后再决定要不要上粒子或 Mesh。粒子画笔调参数的时间通常比写代码长,别指望一次就能调出想要的效果,多试几组Lifetime和Speed的组合,把值记下来,下次直接复用。希望帮到你。
本文还有配套的精品资源,点击获取