ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity编辑器扩展:FlowMap实时绘制插件FlowPainter实现

Unity编辑器扩展:FlowMap实时绘制插件FlowPainter实现 1. 为什么要在Unity里自己画FlowMapFlowMap这个东西做过水面、岩浆、能量护盾或者角色头发飘动的人应该都不陌生。简单说它就是一张记录了“流动方向”的贴图用RG两个通道存X和Y方向的向量采样之后拿去偏移UV贴图就动起来了。听起来简单但真正上手做项目的时候最头疼的往往不是Shader怎么写而是这张图怎么来。市面上能生成FlowMap的工具不少Houdini、Substance Designer、甚至一些在线的生成器都能干这活。但问题在于美术同学改一版流向就得重新导出一遍来回折腾。而且很多时候我们只是想在场景里快速试一下“水往这边流会不会更好看”结果要开一堆软件流程太重了。FlowPainter这个编辑器插件解决的正是这个痛点让你直接在Unity的Scene视图里用鼠标在模型表面刷流向实时看到贴图流动的效果刷完直接存成贴图。它属于编辑器插件系列的第05篇前面几篇应该已经铺垫了编辑器扩展的基础这一篇聚焦在FlowMap的实时绘制上。这篇文章适合谁看如果你已经写过一些简单的Shader知道UV偏移是怎么回事但每次做流动效果都卡在“图从哪来”这一步那这篇就是写给你的。如果你连Shader都还没碰过建议先把顶点片元着色器的基本流程过一遍再回来不然中间有些地方会有点吃力。我会把整个插件的设计思路、核心代码、踩过的坑都摊开讲尽量让你看完能自己复现一个能用的版本。2. 整体设计思路与方案选型2.1 为什么不用Houdini或者SD先说选型。做FlowMap最专业的工具肯定是Houdini节点式生成精度高可编程性强。Substance Designer也有专门的Flow Map节点配合Painter可以画出很漂亮的流向。但这些工具都有一个共同的问题它们和Unity是割裂的。你在Houdini里调好一个流向导出成EXR导入Unity挂到材质上发现角度不对想微调一下。这时候你得回到Houdini改参数重新导出重新导入。如果美术同学不熟悉Houdini那基本就是程序在中间来回传话效率极低。FlowPainter的思路是把这个闭环缩短到最短在Unity里刷在Unity里看在Unity里存。虽然精度和可控性比不上Houdini但对于大多数游戏项目里“水面往一个方向流”“岩浆缓慢涌动”这种需求完全够用而且迭代速度是数量级的提升。2.2 核心架构EditorWindow Raycast RenderTexture整个插件的架构其实不复杂拆开来看就三块第一块是编辑器窗口继承EditorWindow提供笔刷大小、强度、流向模式这些参数。第二块是Scene视图里的交互逻辑通过SceneView.duringSceneGui这个回调在Scene视图里监听鼠标事件发射射线打到模型表面拿到命中点的信息。第三块是数据的存储和写回把刷出来的流向数据存到一张RenderTexture里最后ReadPixels回读到Texture2D存成PNG或者TGA。这里有个关键决策为什么用RenderTexture而不是直接操作Texture2D因为Texture2D的SetPixels在频繁调用的时候性能很差尤其是笔刷半径大的时候每帧要改几千上万个像素用CPU逐像素写会卡到没法用。RenderTexture走的是GPU用Graphics.Blit或者CommandBuffer把笔刷形状画上去性能好得多。但RenderTexture有个麻烦的地方它不能直接存成文件。所以流程是刷的时候用RenderTexture刷完了ReadPixels回读到Texture2D再EncodeToPNG存盘。这个回读操作只在保存的时候做一次不影响刷的手感。2.3 流向的表示世界空间还是切线空间这是设计里最容易踩坑的地方。FlowMap里存的向量到底是世界空间的还是切线空间的如果你存的是世界空间的流向那模型一旋转流向就全乱了。比如你刷了一个“从左往右流”的水面模型转90度流向就变成“从前往后”了这显然不对。所以正确的做法是存切线空间的流向。具体来说射线打到模型表面会拿到一个法线我们需要根据这个法线和模型的切线构建一个切线空间到世界空间的变换矩阵然后把世界空间的流向向量转到切线空间去存。采样的时候在Shader里再转回世界空间这样模型怎么转流向都跟着模型走。但这里又有一个问题Unity里拿切线信息不像拿法线那么直接。Mesh的tangent数组有时候是空的或者没归一化。所以实际实现的时候我一般会用叉乘的方式手动构建一个切线空间用世界法线和世界坐标的某个轴叉乘得到切线再叉乘得到副切线。这样虽然不如原始切线精确但对于FlowMap这种精度要求不高的场景完全够用。2.4 笔刷的绘制方式Blit还是DrawMesh笔刷画到RenderTexture上有两种方式。一种是用Graphics.Blit把笔刷形状做成一张贴图通过Blit的材质参数控制位置和大小。另一种是用Graphics.DrawMesh直接画一个面片。Blit的方式更简单但灵活性差一些笔刷形状只能是贴图里预定义的。DrawMesh的方式更灵活可以画任意形状但需要处理投影矩阵和视口变换代码量大一些。FlowPainter里我选的是Blit因为笔刷形状无非就是圆形、方形、软边硬边这几种做成几张预定义的贴图完全够用。而且Blit的性能很稳定不会因为笔刷形状复杂而掉帧。3. 核心细节解析与实操要点3.1 编辑器窗口的参数设计先看EditorWindow里需要暴露哪些参数。这些参数直接决定了刷起来的手感不能随便设。public class FlowPainterWindow : EditorWindow { private float brushSize 0.5f; private float brushStrength 1.0f; private Color brushColor Color.red; private int textureSize 1024; private RenderTexture flowRT; private Texture2D brushTexture; private bool isPainting false; private Vector3 lastHitPoint; private Vector3 lastHitNormal; }brushSize控制笔刷在屏幕上的大小但实际映射到模型表面的时候需要根据射线命中点的距离做换算。brushStrength控制写入的强度太强会一下子刷满太弱要刷很多遍才有效果。brushColor这里其实不是颜色而是流向的编码RG通道分别对应X和Y方向的向量分量。textureSize是生成的FlowMap分辨率。这里有个经验值512x512对于大多数场景够用了1024x1024适合大世界的水面2048以上基本没必要因为FlowMap本身是低频信息高分辨率带来的收益很小反而占内存。3.2 射线检测与表面信息获取Scene视图里的交互核心是HandleUtility.GUIPointToWorldRay。把鼠标的屏幕坐标转成世界空间的射线然后Physics.Raycast打出去拿到RaycastHit。Ray ray HandleUtility.GUIPointToWorldRay(Event.current.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, 1000f)) { Vector3 worldPos hit.point; Vector3 worldNormal hit.normal; // 构建切线空间 Vector3 tangent Vector3.Cross(worldNormal, Vector3.up).normalized; if (tangent.magnitude 0.01f) tangent Vector3.Cross(worldNormal, Vector3.right).normalized; Vector3 bitangent Vector3.Cross(worldNormal, tangent).normalized; // 把世界空间的流向转到切线空间 Vector3 flowDir GetFlowDirection(); // 根据鼠标移动方向计算 Vector3 tangentFlow new Vector3( Vector3.Dot(flowDir, tangent), Vector3.Dot(flowDir, bitangent), 0 ); }这里有个细节当法线和Vector3.up平行的时候叉乘结果会变成零向量所以需要加一个判断换一个轴来叉乘。这个坑我在第一次写的时候踩过刷到模型顶部的时候笔刷突然失效排查了半天才发现是叉乘退化。流向的计算方式有两种。一种是固定方向比如永远从左往右那flowDir就是Vector3.right。另一种是跟随鼠标移动方向用当前帧的鼠标位置减去上一帧的鼠标位置得到屏幕空间的移动向量再转到世界空间。后者刷起来更自然但需要处理鼠标移动过快时的跳变。3.3 RenderTexture的初始化与清空RenderTexture的格式选择很关键。FlowMap只需要RG两个通道但RenderTexture没有专门的RG格式所以用ARGB32或者RGBAHalf。ARGB32够用了8位精度对于流向来说足够因为流向本身不需要太高的精度。flowRT new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32); flowRT.enableRandomWrite false; flowRT.Create(); // 清空为中性值(0.5, 0.5, 0, 1) RenderTexture.active flowRT; GL.Clear(true, true, new Color(0.5f, 0.5f, 0f, 1f)); RenderTexture.active null;清空成(0.5, 0.5)是因为在Shader里采样之后会做一次乘2减1的操作把0到1映射到-1到1。0.5映射过去就是0表示没有流动。如果清成黑色(0,0)映射过去就是(-1,-1)表示往左下角流这显然不是我们想要的默认状态。3.4 笔刷的绘制Blit的具体实现刷的时候每一帧根据鼠标位置把笔刷贴图Blit到flowRT上。但直接Blit会覆盖整个RT所以需要用一个自定义的材质通过材质参数控制笔刷的位置和大小。Material brushMat new Material(Shader.Find(Hidden/FlowBrush)); brushMat.SetVector(_BrushPos, new Vector4(uv.x, uv.y, 0, 0)); brushMat.SetFloat(_BrushSize, brushSize); brushMat.SetFloat(_BrushStrength, brushStrength); brushMat.SetColor(_BrushColor, brushColor); brushMat.SetTexture(_BrushTex, brushTexture); RenderTexture.active flowRT; Graphics.Blit(null, flowRT, brushMat); RenderTexture.active null;Hidden/FlowBrush这个Shader的逻辑是根据当前像素的UV和_BrushPos的距离决定笔刷的权重然后用这个权重把_BrushColor混合到原来的颜色上。混合公式大概是lerp(original, brushColor, weight * strength)。这里有个性能优化的点不要每帧都new Material应该在OnEnable的时候创建一次之后复用。我见过有人在OnGUI里new Material结果刷的时候GC狂飙帧率掉到个位数。3.5 保存为PNG的完整流程刷完之后需要把RenderTexture回读成Texture2D再存成PNG。这个流程有几个坑。Texture2D resultTex new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false); RenderTexture.active flowRT; resultTex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0); resultTex.Apply(); RenderTexture.active null; byte[] pngData resultTex.EncodeToPNG(); string path EditorUtility.SaveFilePanel(Save FlowMap, Assets, FlowMap, png); if (!string.IsNullOrEmpty(path)) { File.WriteAllBytes(path, pngData); AssetDatabase.Refresh(); }第一个坑ReadPixels之前必须设置RenderTexture.active否则读出来是黑的。第二个坑Texture2D的格式要和RenderTexture匹配ARGB32对应RGBA32。第三个坑存完之后要AssetDatabase.Refresh()不然Unity不会立刻识别到新文件。还有一个隐藏的坑如果RenderTexture的sRGB选项没关读出来的颜色会偏亮。在创建RenderTexture的时候要把sRGB设为false因为FlowMap存的是向量数据不是颜色数据不应该走sRGB转换。4. 实操过程与核心环节实现4.1 环境准备与插件目录结构先把目录结构定好不然后面文件多了会乱。我习惯这样组织Assets/ FlowPainter/ Editor/ FlowPainterWindow.cs FlowBrushShader.shader Textures/ Brush_Circle.png Brush_Soft.png Shaders/ FlowMap.shader Examples/ WaterFlow.mat WaterFlow.flowmap.pngEditor文件夹是必须的因为EditorWindow和EditorUtility这些类只在编辑器下可用放到运行时文件夹里打包会报错。FlowBrushShader放在Editor下是因为它只服务于编辑器绘制运行时不需要。4.2 FlowBrush Shader的完整实现这个Shader是笔刷的核心逻辑不复杂但细节多。Shader Hidden/FlowBrush { Properties { _MainTex (Texture, 2D) white {} _BrushTex (Brush, 2D) white {} _BrushPos (Brush Pos, Vector) (0.5, 0.5, 0, 0) _BrushSize (Brush Size, Float) 0.1 _BrushStrength (Brush Strength, Float) 1.0 _BrushColor (Brush Color, Color) (1, 0, 0, 1) } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; sampler2D _BrushTex; float4 _BrushPos; float _BrushSize; float _BrushStrength; float4 _BrushColor; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 original tex2D(_MainTex, i.uv); float2 delta i.uv - _BrushPos.xy; float dist length(delta); float brushMask tex2D(_BrushTex, (delta / _BrushSize) 0.5).r; float weight saturate(brushMask * _BrushStrength); fixed4 result lerp(original, _BrushColor, weight); return result; } ENDCG } } }这里用_BrushTex的R通道作为笔刷的遮罩圆形笔刷就是中间白边缘黑软边笔刷就是高斯衰减。_BrushSize控制笔刷在UV空间的大小注意是UV空间不是世界空间所以模型UV展开的大小会影响笔刷的实际视觉大小。如果模型的UV是0到1铺满的那_BrushSize设0.1就是十分之一的模型宽度。4.3 鼠标事件的处理与绘制循环Scene视图里的鼠标事件处理需要在OnSceneGUI或者duringSceneGui回调里做。void OnSceneGUI(SceneView sceneView) { Event e Event.current; if (e.type EventType.MouseDown e.button 0 e.alt false) { isPainting true; e.Use(); } if (e.type EventType.MouseUp e.button 0) { isPainting false; e.Use(); } if (isPainting e.type EventType.MouseDrag) { Ray ray HandleUtility.GUIPointToWorldRay(e.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, 1000f)) { Vector2 uv hit.textureCoord; PaintAt(uv, hit.normal); } e.Use(); sceneView.Repaint(); } }注意e.Use()的调用如果不调用Unity默认的Scene视图操作会和你的绘制冲突比如你想刷的时候结果在旋转视角。另外MouseDrag事件里要调用sceneView.Repaint()不然Scene视图不会实时刷新看不到刷的效果。hit.textureCoord拿到的是命中点的UV坐标这个坐标是直接对应模型UV的所以刷的位置和模型UV是对齐的。但这里有个前提模型的Mesh必须开启了Read/Write Enabled否则textureCoord拿不到。这个在模型的Import Settings里勾选。4.4 流向方向的计算跟随鼠标移动固定方向的刷法很简单但不够灵活。跟随鼠标移动的刷法需要记录上一帧的鼠标位置。private Vector2 lastMousePos; private Vector3 lastWorldPos; void PaintAt(Vector2 uv, Vector3 normal) { Vector3 currentWorldPos HandleUtility.GUIPointToWorldRay(Event.current.mousePosition).origin; if (lastWorldPos ! Vector3.zero) { Vector3 moveDir (currentWorldPos - lastWorldPos).normalized; // 把moveDir投影到表面切平面 moveDir Vector3.ProjectOnPlane(moveDir, normal).normalized; // 转到切线空间 Vector3 tangent Vector3.Cross(normal, Vector3.up).normalized; if (tangent.magnitude 0.01f) tangent Vector3.Cross(normal, Vector3.right).normalized; Vector3 bitangent Vector3.Cross(normal, tangent).normalized; float tx Vector3.Dot(moveDir, tangent); float ty Vector3.Dot(moveDir, bitangent); // 编码到0-1 Color flowColor new Color(tx * 0.5f 0.5f, ty * 0.5f 0.5f, 0, 1); // 用这个颜色去刷 brushColor flowColor; } lastWorldPos currentWorldPos; // 执行Blit DoPaint(uv); }这里的关键是Vector3.ProjectOnPlane把鼠标移动方向投影到表面切平面上。如果不做这个投影在曲面上的流向会不准确。比如在一个球面上刷鼠标从左往右移动但球面是弯的直接拿世界空间的移动方向去编码出来的流向在球面上会偏。4.5 运行时Shader的采样与流动刷出来的FlowMap最终是要给运行时Shader用的。一个最简单的流动Shader大概是这样Shader Custom/FlowMap { Properties { _MainTex (Base Texture, 2D) white {} _FlowMap (Flow Map, 2D) white {} _FlowSpeed (Flow Speed, Float) 1.0 _FlowStrength (Flow Strength, Float) 0.1 } SubShader { Tags { RenderTypeOpaque } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; sampler2D _FlowMap; float _FlowSpeed; float _FlowStrength; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata_base v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.texcoord; return o; } fixed4 frag (v2f i) : SV_Target { float2 flow tex2D(_FlowMap, i.uv).rg; flow flow * 2 - 1; float2 offset flow * _FlowStrength * _Time.y * _FlowSpeed; fixed4 col tex2D(_MainTex, i.uv offset); return col; } ENDCG } } }flow * 2 - 1是把0到1的编码解码回-1到1的向量。_FlowStrength控制流动的幅度_FlowSpeed控制速度_Time.y是Unity内置的时间变量。这个Shader是最基础的版本实际项目里可能还需要加两层采样做无缝循环或者用噪声图扰动流向但核心逻辑就是这个。5. 常见问题与排查技巧实录5.1 刷的时候没反应或者刷不上这是最常见的问题原因通常有几个。第一模型没有开启Read/Write Enabled导致hit.textureCoord返回零向量。去模型的Import Settings里勾上。第二Physics.Raycast打不到模型检查模型有没有Collider。第三RenderTexture没有正确设置activeBlit的目标不对。第四笔刷的_BrushPos传的是屏幕坐标而不是UV坐标导致笔刷画到了RT外面。排查的时候可以先把笔刷颜色设成纯红(1,0,0)刷完保存出来看看有没有红色。如果有红色说明绘制流程是通的问题出在流向编码上。如果没有红色那就是绘制流程本身有问题。5.2 流向在模型旋转后错乱这个前面提过根本原因是存了世界空间的流向。检查你的编码逻辑确保在写入之前做了世界空间到切线空间的转换。另外如果模型有非均匀缩放切线空间的构建也会出问题因为叉乘出来的切线不是单位向量。这种情况下需要对切线做归一化。还有一个容易忽略的点Unity的UV坐标系和OpenGL/DirectX的差异。Unity的UV原点在左下角但有些建模软件导出的UV原点在左上角这会导致刷出来的流向上下颠倒。如果发现流向反了检查一下模型的UV导入设置。5.3 保存的PNG颜色偏亮或者偏暗这是sRGB的问题。RenderTexture在创建的时候如果sRGB为trueReadPixels出来的数据会经过一次sRGB到线性的转换导致颜色值偏移。FlowMap存的是向量数据不应该走这个转换所以创建RenderTexture的时候要设sRGB为false。另外Texture2D的格式也要注意。如果用TextureFormat.RGBA32ReadPixels出来的数据是原始字节EncodeToPNG会按sRGB编码。如果发现保存出来的图在Photoshop里看颜色不对但在Unity里看是对的那就是色彩空间的问题不用太纠结以Unity里的效果为准。5.4 笔刷边缘有硬边或者锯齿笔刷的软硬程度取决于_BrushTex的遮罩。如果用的是纯圆形的硬边遮罩刷出来边缘会有锯齿。解决办法是用高斯模糊的软边遮罩或者在Shader里对brushMask做一次smoothstep。float brushMask tex2D(_BrushTex, (delta / _BrushSize) 0.5).r; brushMask smoothstep(0.0, 1.0, brushMask);smoothstep可以让边缘过渡更平滑。如果还觉得不够可以把_BrushTex的分辨率提高或者用SDF的方式在Shader里直接算圆形距离不依赖贴图。5.5 刷大笔刷的时候卡顿笔刷半径大的时候每帧要处理的像素数量是半径的平方。512的笔刷在1024的RT上一帧要处理26万个像素如果Shader写得复杂确实会卡。优化方向有几个。第一降低RT的分辨率512x512对于大多数场景够用了。第二简化FlowBrush Shader去掉不必要的计算。第三不要每帧都刷可以隔帧刷或者鼠标移动距离超过阈值才刷。第四用CommandBuffer代替Graphics.Blit减少CPU到GPU的同步开销。我实测下来1024的RT配256的笔刷在普通笔记本上能跑到60帧以上基本不影响操作。如果卡到影响手感优先降RT分辨率。5.6 常见问题速查表问题现象可能原因排查方法解决方案刷不上模型未开Read/Write检查Import Settings勾选Read/Write Enabled刷不上无Collider检查模型组件添加MeshCollider或BoxCollider流向错乱存了世界空间向量检查编码逻辑转到切线空间再存颜色偏亮sRGB未关检查RT创建参数sRGB设为false边缘锯齿笔刷遮罩太硬查看_BrushTex用软边遮罩或smoothstep刷的时候卡笔刷太大或RT太高看Profiler降RT分辨率或简化Shader保存后Unity不识别未Refresh检查保存流程调用AssetDatabase.Refresh流向上下颠倒UV坐标系差异对比模型UV翻转V坐标或调整导入设置6. 一些实操心得和扩展思路6.1 关于笔刷的叠加模式默认的lerp叠加是“覆盖”模式新刷的流向会覆盖旧的。但有时候我们想要“叠加”模式比如在已有的流向上再加一点偏移。这时候可以把lerp改成加法然后clamp到0到1。fixed4 result saturate(original _BrushColor * weight);但加法模式有个问题多次叠加之后会饱和到1流向就固定了。所以更好的方式是做一个带衰减的叠加每次叠加的权重逐渐减小。6.2 关于多层FlowMap有些复杂的效果需要两层FlowMap一层控制大范围的流向一层控制局部的扰动。这种情况下可以刷两张图在Shader里分别采样再合并。FlowPainter本身只生成一张图但你可以刷两次存成两个文件在Shader里用两张贴图。6.3 关于和ShaderGraph的配合如果你用的是ShaderGraphFlowMap的采样节点是Sample Texture 2D把RG通道拆出来乘2减1然后加到UV上。ShaderGraph里没有直接的“乘2减1”节点可以用Remap节点把0到1映射到-1到1。或者用Multiply加Add节点手动算。6.4 关于性能的进一步优化如果项目里FlowMap用的地方很多可以考虑把多张FlowMap打包成一张图集用UV偏移来采样不同的区域。这样能减少DrawCall和纹理切换。但打包之后笔刷的UV映射会变复杂需要额外处理。另外FlowMap的分辨率不需要太高512x512在大多数情况下足够。如果发现流向的细节不够优先考虑增加笔刷的精度而不是提高RT的分辨率。6.5 一个实际项目中的案例之前做过一个熔岩地面的效果需要熔岩从中心向四周缓慢流动。用FlowPainter刷的时候先用大笔刷从中心往外刷一圈确定大致的流向然后用小笔刷在局部做扰动让流向不那么规则。刷完之后在Shader里加了一层噪声图做UV扰动最终效果很自然。整个流程从开始刷到最终效果调好大概花了二十分钟。如果用Houdini做光是建节点和导出导入就不止这个时间。这也是我觉得FlowPainter这类工具最大的价值它不一定能做出最顶级的效果但它能把迭代速度提上来让你有更多时间去试不同的方案。6.6 关于后续扩展这个插件目前只支持在模型表面刷不支持在Terrain上刷。如果要做地形上的水流需要额外处理Terrain的UV映射。Terrain的UV是平铺的和普通模型的UV逻辑不一样射线命中点的textureCoord拿到的不是0到1的范围需要做归一化。另一个扩展方向是支持笔刷的旋转和压感。旋转可以让笔刷的流向不局限于鼠标移动方向压感可以让笔刷的强度随压力变化。这两个功能对于数位板用户来说会很实用但实现起来需要处理额外的输入数据。最后再分享一个小技巧刷FlowMap的时候把Scene视图的渲染模式切成Wireframe或者Shaded Wireframe能更清楚地看到模型表面的结构刷的时候更容易对齐UV的接缝。这个在刷复杂模型的时候特别有用。
返回列表