1. 为什么要在Unity里自己动手画FlowMap如果你做过水面、岩浆、能量护盾或者角色身上的流光特效大概率绕不开FlowMap这个词。简单说FlowMap就是一张记录流动方向的贴图它用RG两个通道存方向向量让另一张贴图沿着这个方向动起来。听起来不复杂但真正上手你会发现一个很现实的问题方向数据从哪来大多数人的第一反应是去外部工具里画。Photoshop里拉个渐变、Substance Designer里连个节点、或者干脆拿一张噪声图硬凑。这些方法都能用但都有一个共同的痛点——迭代太慢。你在Unity里调材质发现某个区域流向不对切出去改图再导回来再刷新一来一回十分钟没了。如果场景里有十几处需要不同流向的地方这个流程能把人逼疯。所以当我第一次看到FlowPainter这个思路的时候第一反应是这才对嘛。它的核心逻辑非常直接既然FlowMap本质上就是一张方向图那为什么不在Unity编辑器里直接画鼠标往哪拖方向就往哪指画完直接存成贴图材质立刻就能用。整个过程不需要离开Unity不需要外部工具不需要反复导入导出。这篇文章要聊的就是这套流程的完整实现思路。我会从FlowMap的底层原理讲起说清楚方向数据到底是怎么编码的、Shader里怎么采样、编辑器脚本怎么把鼠标操作翻译成像素数据最后给出一套可以直接抄的编辑器工具实现方案。适合有一定Unity基础、写过简单Shader、想让自己的特效迭代速度上一个台阶的开发者。如果你之前没接触过编辑器扩展也不用慌我会把关键API和踩坑点都标出来。2. FlowMap的方向编码与Shader采样逻辑2.1 方向向量为什么能塞进一张贴图里先把这个事情说透。一个二维方向向量本质上就是两个分量X和Y。而一张常规的RGB贴图每个像素有三个通道每个通道的取值范围是0到1。那么问题来了方向向量是有正有负的比如(-1, 0)表示向左(0, 1)表示向上但贴图通道存不了负数。解决办法很经典把[-1, 1]的范围映射到[0, 1]。具体做法就是先加1再除以2。反过来在Shader里采样得到[0, 1]的值之后乘以2再减1就还原回了[-1, 1]的方向向量。这个操作在数学上叫重映射在Shader里就是一行代码的事。float2 flowDir tex2D(_FlowMap, uv).rg * 2 - 1;这里只用了RG两个通道B通道可以空着也可以拿来存别的信息比如流动速度的遮罩。有些实现会把B通道用作强度控制A通道用作相位偏移这些都属于进阶用法后面会提到。2.2 在Shader里让贴图动起来的两种主流方式拿到方向之后怎么让贴图沿着这个方向流动常见的有两种做法。第一种是直接偏移UV。每帧根据时间累加方向向量然后把这个偏移量加到采样UV上。代码大概长这样float2 offset flowDir * _Speed * _Time.y; float2 flowUV uv offset; fixed4 col tex2D(_MainTex, flowUV);这种做法简单直接但有个致命问题UV会无限偏移导致贴图接缝处出现明显的断裂。因为贴图是循环的当偏移量超过一个周期采样就会跳回原点视觉上就是一次突兀的跳变。第二种是双采样混合也是目前业界更常用的方案。它的思路是同时采样两次一次用当前偏移一次用偏移加半个周期然后用一个随时间变化的权重把两者混合起来。当第一次采样快要跳到原点的时候它的权重已经降到零第二次采样正好接上。这样接缝就被藏起来了。float2 flowDir tex2D(_FlowMap, uv).rg * 2 - 1; float phase0 frac(_Time.y * _Speed); float phase1 frac(_Time.y * _Speed 0.5); float2 uv0 uv - flowDir * phase0; float2 uv1 uv - flowDir * phase1; fixed4 col0 tex2D(_MainTex, uv0); fixed4 col1 tex2D(_MainTex, uv1); float blend abs(phase0 * 2 - 1); fixed4 finalCol lerp(col0, col1, blend);这段代码里的blend计算是关键。当phase0从0走到0.5的时候blend从1降到0再从0.5走到1的时候blend从0升到1。这样两次采样就形成了一个无缝的交替。注意双采样方案会让纹理采样次数翻倍移动端上要留意性能开销。如果目标平台是手机可以考虑降低FlowMap分辨率或者只在关键区域使用。2.3 FlowMap的通道分配与精度取舍一张FlowMap到底该用什么格式存这个问题在实际项目里经常被忽略但它直接影响最终效果。最常规的做法是用RGB24RG存方向B通道空着。但如果你仔细算一下方向向量的精度其实不需要8位那么高。因为方向本身是个连续量稍微有点误差肉眼根本看不出来。所以很多项目会用RGB565或者RG88来压缩体积。不过在编辑器里画的时候为了方便预览和调试我建议还是先用RGBA32等最终打包的时候再根据平台做压缩。还有一个细节FlowMap的分辨率不需要和主贴图一样高。因为方向变化通常是低频的一张256x256的FlowMap驱动一张2048x2048的主贴图效果完全够用。这样做的好处是编辑器里绘制的时候响应更快存储开销也更小。3. FlowPainter编辑器工具的核心架构拆解3.1 工具的整体数据流在动手写代码之前先把整个工具的数据流理清楚。FlowPainter的工作流程可以拆成四个环节用户输入鼠标在场景视图或者一个自定义窗口里拖拽产生一系列屏幕坐标。坐标转换把屏幕坐标转换成贴图上的UV坐标再转换成像素坐标。方向计算根据拖拽的起点和终点算出一个方向向量然后编码成RGBA颜色值。写入贴图把颜色值写到目标贴图的对应像素上同时刷新预览。这四个环节里最容易出问题的是第二步和第三步。坐标转换涉及到相机投影和射线检测方向计算涉及到归一化和范围映射。下面逐个拆解。3.2 用EditorWindow还是用SceneView回调这是第一个需要做的架构决策。Unity编辑器扩展有两种常见的交互入口一种是自定义EditorWindow另一种是挂在SceneView上的回调。EditorWindow的好处是布局可控你可以在窗口里放笔刷大小、强度、方向模式等参数操作起来像一个小型绘图软件。缺点是它和场景是分离的你没法直接在模型表面画只能在一个二维平面上操作。SceneView回调的好处是所见即所得。你直接在场景里对着模型拖拽射线打到哪就画到哪非常直观。缺点是UI控件不好放参数调节得另想办法。我的建议是两者结合用EditorWindow放参数面板用SceneView回调处理绘制交互。这样既有完整的参数控制又能直接在场景里操作。具体实现的时候在EditorWindow的OnEnable里注册SceneView.duringSceneGui回调在OnDisable里注销。void OnEnable() { SceneView.duringSceneGui OnSceneGUI; } void OnDisable() { SceneView.duringSceneGui - OnSceneGUI; } void OnSceneGUI(SceneView sceneView) { Event e Event.current; // 处理鼠标事件 }提示duringSceneGui是Unity 2019之后推荐的API老版本用的是SceneView.onSceneGUIDelegate。如果你还在用2018需要做一下版本兼容。3.3 从鼠标位置到贴图像素的完整转换链路这一步是整个工具的核心难点。鼠标在屏幕上是一个二维坐标而你要写入的是贴图上的一个像素。中间需要经过好几次转换。首先用HandleUtility.GUIPointToWorldRay把鼠标位置转成一条世界空间射线。然后用Physics.Raycast或者HandleUtility.IntersectRayMesh检测这条射线和目标的交点。拿到交点之后还需要把世界坐标转换成目标物体的局部坐标再根据UV展开信息找到对应的UV坐标。如果目标是一个简单的平面UV转换可以手动算。但如果是一个复杂模型就需要借助RaycastHit.textureCoord。这个属性在射线打到MeshCollider的时候会自动填充直接就是UV坐标。Ray ray HandleUtility.GUIPointToWorldRay(e.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit)) { Vector2 uv hit.textureCoord; int px (int)(uv.x * flowMap.width); int py (int)(uv.y * flowMap.height); // 写入像素 }这里有个坑textureCoord的Y轴方向和贴图的Y轴方向可能不一致。Unity的UV原点在左下角而Texture2D的像素原点也在左下角理论上是对齐的。但如果你用的是RenderTexture或者从外部导入的贴图可能会遇到翻转问题。实测下来最稳妥的做法是先写一个测试像素看看位置对不对再继续往下做。3.4 方向计算从拖拽轨迹到RGBA值拿到像素坐标之后下一步是算方向。最直观的方式是记录鼠标按下的起点和当前点两者相减得到一个二维向量归一化之后映射到[0, 1]范围。Vector2 dir (currentPixel - startPixel).normalized; Color encoded new Color(dir.x * 0.5f 0.5f, dir.y * 0.5f 0.5f, 0, 1);但这里有个问题如果用户只是点了一下没有拖拽方向就是零向量归一化会出问题。所以需要加一个最小距离阈值小于这个阈值就不写入。另一个问题是笔刷的衰减。如果直接硬边写入画出来的FlowMap会有明显的方块感。更好的做法是用一个径向衰减的笔刷中心强度为1边缘渐变为0然后把新方向和旧方向做插值。float falloff 1 - Mathf.Clamp01(distToCenter / brushRadius); Color oldColor flowMap.GetPixel(px, py); Color newColor Color.Lerp(oldColor, encoded, falloff * strength); flowMap.SetPixel(px, py, newColor);这样画出来的过渡会自然很多尤其是在处理大面积流动区域的时候。4. 笔刷系统与实时预览的工程实现4.1 笔刷形状与衰减曲线的选择笔刷系统看起来简单但要做得顺手有几个细节值得抠。首先是笔刷形状。最基础的是圆形用距离判断就行。但有时候你需要画直线或者矩形区域这时候可以扩展成不同的笔刷模式。我的做法是定义一个枚举然后在计算falloff的时候根据模式走不同的分支。其次是衰减曲线。线性衰减是最简单的但画出来的边缘还是有点硬。用平滑步进函数会好很多float t Mathf.Clamp01(distToCenter / brushRadius); float falloff 1 - t * t * (3 - 2 * t); // smoothstep这条曲线在中心附近变化平缓在边缘附近变化快画出来的笔刷过渡非常自然。还有一个容易被忽略的点笔刷的旋转。如果你画的是有方向性的纹理笔刷本身也应该能旋转。这个通过把像素坐标先做一次旋转变换就能实现多几行代码的事但体验提升很明显。4.2 实时预览让贴图在编辑状态下就动起来画完FlowMap之后你肯定想立刻看到效果。如果每次都要手动刷新材质或者重新进入播放模式效率太低了。所以实时预览是必须做的。实现方式有两种。一种是在EditorWindow里用一个RawImage显示贴图每次写入像素之后调用Apply()然后刷新RawImage的纹理。这种方式简单但只能看到静态的FlowMap本身看不到流动效果。另一种是在场景里挂一个预览材质把刚画的FlowMap直接赋给材质的_FlowMap属性然后用EditorApplication.QueuePlayerLoopUpdate强制场景刷新。这样你一边画场景里的物体就一边流动非常直观。previewMaterial.SetTexture(_FlowMap, flowMap); EditorApplication.QueuePlayerLoopUpdate();注意QueuePlayerLoopUpdate在编辑状态下会持续触发重绘如果场景复杂可能会有性能压力。建议只在鼠标按下的时候调用松开就停。4.3 撤销系统Undo.RegisterCompleteObjectUndo的正确用法编辑器工具如果没有撤销功能用起来会非常痛苦。画错一笔想撤回结果发现整个贴图都回不去了那种感觉谁用谁知道。Unity的Undo系统对Texture2D的支持比较特殊。你不能直接对Texture2D调用Undo.RecordObject因为贴图的像素数据不是通过序列化系统管理的。正确的做法是在每次笔刷操作开始之前把当前贴图的状态记录一份。void BeginStroke() { undoTexture new Texture2D(flowMap.width, flowMap.height, flowMap.format, false); undoTexture.SetPixels(flowMap.GetPixels()); undoTexture.Apply(); } void EndStroke() { Undo.RegisterCompleteObjectUndo(flowMap, FlowMap Paint); // 把undoTexture的数据恢复回去的逻辑 }更完善的做法是实现一个自定义的Undo操作类继承UndoableOperation把前后两份像素数据都存下来。这样撤销和重做都能正常工作。不过对于大多数项目来说简单的快照方案已经够用了。4.4 性能优化大尺寸贴图下的绘制卡顿怎么破当FlowMap尺寸到1024以上时每次SetPixel加Apply的开销会变得很明显。如果笔刷半径又比较大一帧要写入几千个像素卡顿就不可避免了。几个优化方向批量写入不要每帧调用Apply而是攒一批像素等鼠标松开或者每隔几帧再统一提交。局部更新Texture2D.Apply有一个重载可以指定更新区域只提交笔刷覆盖的那一块而不是整张贴图。降低预览分辨率编辑的时候用一张低分辨率的预览图最终保存的时候再烘焙到高分辨率。这个方案稍微复杂一点但效果最好。// 只更新笔刷覆盖的区域 flowMap.Apply(false, false); // 或者用SetPixels32配合区域参数实测下来对于512x512的FlowMap直接全图Apply在大多数机器上都能跑到60帧以上。但如果到了2048就必须要做局部更新了。5. 从编辑器到运行时的数据衔接与常见坑5.1 保存格式的选择PNG、TGA还是Asset画完之后贴图得存下来。这里有三个选择PNG通用性最好任何工具都能打开但导入Unity之后需要设置正确的压缩格式和sRGB选项。TGA无损支持Alpha通道Unity原生支持但文件体积大。Asset直接存成Unity的资产文件不需要导入导出但只能在Unity项目里用。我的建议是存PNG然后在导入设置里把Texture Type设为DefaultsRGB关掉Compression设为None或者High Quality。因为FlowMap存的是方向数据不是颜色数据sRGB校正会破坏方向精度。注意如果FlowMap的sRGB选项没关Shader里采样出来的值会经过一次伽马校正方向就会偏。这个坑非常隐蔽很多人调半天效果不对最后发现是导入设置的问题。5.2 Shader里的采样坐标与贴图导入设置的联动除了sRGB还有一个容易出问题的地方是Wrap Mode。FlowMap的Wrap Mode必须是Clamp不能是Repeat。因为方向数据在边缘处不应该循环如果用了Repeat边缘像素会和对面像素插值产生莫名其妙的方向。另外Filter Mode建议用Bilinear或者Trilinear不要用Point。因为方向是连续变化的点采样会让流动出现明显的块状感。5.3 多物体共用FlowMap时的UV对齐问题如果你的场景里有多个物体共用同一张FlowMap那它们的UV必须是对齐的。否则同一个方向在不同物体上会指向不同的世界方向看起来就很乱。解决办法有两种。一种是用世界坐标投影UV在Shader里根据世界坐标计算采样点这样所有物体自然对齐。另一种是在建模阶段就统一UV布局确保所有需要共用FlowMap的物体在UV空间里占据相同区域。第一种方案更灵活但需要改Shader。第二种方案更简单但对美术流程有要求。具体选哪个看项目实际情况。5.4 在移动端上使用FlowMap的注意事项移动端对纹理采样次数很敏感。前面提到的双采样方案会让采样次数翻倍如果主贴图本身还有多层混合很容易超出移动端的纹理单元限制。几个应对策略把FlowMap和主贴图打包到同一张图集里减少纹理切换。用半分辨率渲染FlowMap效果或者只在近处物体上启用。如果流动效果不是核心视觉元素可以考虑用顶点动画或者UV动画替代。还有一个细节移动端上frac和lerp的开销虽然不大但积少成多。如果项目对性能要求极高可以把双采样的混合逻辑简化成单采样加一个正弦扰动效果差一点但开销小很多。6. 我在实际项目中踩过的几个坑6.1 方向反转为什么画出来的流向和预期相反这个问题我遇到过不止一次。明明在编辑器里画的是从左到右结果运行时变成从右到左。排查了半天发现是UV的Y轴方向和像素坐标的Y轴方向不一致导致的。Unity的UV原点在左下角但屏幕坐标的原点在左上角。如果你在转换的时候没有做Y轴翻转画出来的方向就会上下颠倒。解决办法很简单在计算像素坐标的时候把Y轴翻一下int py flowMap.height - (int)(uv.y * flowMap.height) - 1;但具体要不要翻取决于你的贴图导入设置和Shader里的采样方式。最稳妥的做法是画一个已知方向比如纯红色表示向右然后在Shader里输出方向向量看看对不对。6.2 笔刷断触快速拖拽时方向数据丢失当鼠标移动很快的时候两帧之间的像素距离可能很大如果只在当前帧的位置写入中间就会留下空隙。解决办法是在上一帧和当前帧之间做插值把中间的像素都补上。int steps Mathf.CeilToInt(Vector2.Distance(lastPixel, currentPixel) / brushRadius * 2); for (int i 0; i steps; i) { Vector2 lerped Vector2.Lerp(lastPixel, currentPixel, i / (float)steps); PaintAt(lerped); }这个技巧在处理长线条的时候特别有用画出来的FlowMap会连续很多。6.3 贴图变灰sRGB选项导致的颜色空间错误前面提过sRGB的问题这里再展开说一下。当FlowMap的sRGB选项打开时Unity会在采样的时候做一次伽马到线性的转换。对于颜色贴图这是对的但对于方向数据这是灾难性的。因为方向数据是线性量不需要任何颜色空间转换。表现就是画的时候看起来正常运行时流动方向偏得离谱或者流动速度不均匀。排查的时候优先检查导入设置里的sRGB选项十有八九是这个问题。6.4 编辑器卡死Undo操作与Texture2D的兼容性陷阱最后一个坑是关于Undo的。如果你在OnSceneGUI里直接调用Undo.RecordObject(flowMap, paint)在某些Unity版本上会导致编辑器卡死或者崩溃。原因是Texture2D不是标准的序列化对象Undo系统处理不了。正确的做法是用Undo.RegisterCompleteObjectUndo或者干脆自己实现一套快照机制。我现在的做法是在笔刷开始时存一份像素数组结束时如果用户按了CtrlZ就把数组恢复回去。虽然土但稳定。private Color32[] undoBuffer; void OnMouseDown() { undoBuffer flowMap.GetPixels32(); } void OnUndo() { flowMap.SetPixels32(undoBuffer); flowMap.Apply(); }这套方案不依赖Unity的Undo系统跨版本兼容性最好。缺点是不能和Unity原生的撤销栈集成但对于一个编辑器工具来说够用了。7. 把FlowPainter接入现有工作流的几种姿势7.1 作为独立工具窗口使用最基础的用法就是把FlowPainter做成一个独立的EditorWindow通过菜单栏打开。适合需要频繁绘制FlowMap的项目比如做水面特效或者角色流光。窗口里放这些控件目标贴图选择、笔刷大小和强度、方向模式自由绘制/固定方向/径向、预览开关、保存按钮。布局不用太复杂关键是操作路径要短。7.2 嵌入到材质Inspector中如果你的项目里FlowMap是和特定材质绑定的那更自然的做法是把绘制功能嵌入到材质的Inspector里。在OnInspectorGUI里加一个Edit FlowMap按钮点击之后弹出一个浮动窗口或者切换到绘制模式。这样做的好处是上下文清晰用户知道自己正在编辑哪个材质的FlowMap不会搞混。7.3 与Timeline或动画系统联动进阶用法是把FlowMap的绘制和Timeline绑定。比如做一个水面涟漪效果涟漪的方向和强度随时间变化那就可以在Timeline里用关键帧控制FlowMap的某个参数而不是手动一帧一帧画。这个需要把FlowMap的绘制逻辑封装成可调用的API然后在Timeline的Clip里调用。实现起来稍微复杂一点但对于做剧情动画或者过场特效的团队来说效率提升很明显。7.4 批量生成从曲线或噪声自动生成FlowMap不是所有FlowMap都需要手绘。有些场景下用程序化生成更高效。比如做一个漩涡效果可以用极坐标算方向做一个波浪效果可以用正弦函数算方向。我的做法是在FlowPainter里加一个Generate标签页提供几种预设的生成模式线性、径向、漩涡、噪声。用户选一个模式调几个参数点生成一张FlowMap就出来了。然后再用手动笔刷做局部微调。这样比纯手绘快很多也比纯程序化灵活。// 漩涡模式的简化实现 for (int y 0; y height; y) { for (int x 0; x width; x) { Vector2 center new Vector2(width / 2f, height / 2f); Vector2 dir new Vector2(x - center.x, y - center.y).normalized; Vector2 perp new Vector2(-dir.y, dir.x); Color c new Color(perp.x * 0.5f 0.5f, perp.y * 0.5f 0.5f, 0, 1); flowMap.SetPixel(x, y, c); } } flowMap.Apply();这段代码生成的是一个围绕中心的旋转方向场配合双采样Shader就能做出漩涡流动的效果。8. 关于FlowMap工具化的一些个人体会做编辑器工具这件事最大的回报不是工具本身而是你对底层原理的理解会深很多。在写FlowPainter之前我对FlowMap的认知停留在一张存方向的图这个层面。但真正动手实现之后才发现里面有这么多细节sRGB、Wrap Mode、UV对齐、双采样混合、撤销系统、性能优化每一个点单独拎出来都能写一篇。我现在做任何特效相关的工具都会先问自己三个问题这个数据能不能在编辑器里直接生成生成之后能不能实时预览预览的结果和运行时是不是一致的这三个问题回答好了工具的可用性就不会差。FlowPainter这个思路其实可以扩展到很多其他贴图类型。比如画一张遮罩图控制溶解效果画一张高度图做视差画一张噪声图做扰动。核心逻辑都是一样的把鼠标操作翻译成像素数据实时预览保存成资产。把这套框架搭好之后后面加新功能就是改改笔刷逻辑和预览Shader的事。如果你也在做类似的事情我的建议是先从最小的可用版本开始。不要一上来就想着做全能工具先把画一条线、存下来、能看到效果这个闭环跑通。跑通之后再逐步加笔刷形状、撤销、批量生成这些功能。每加一个功能都确保它不会破坏已有的流程。这样迭代下来工具的稳定性和实用性都会好很多。