1. 为什么要在Unity里自己画FlowMap
FlowMap这个东西,做过水面、岩浆、能量护盾或者角色头发飘动的人应该都不陌生。简单说它就是一张记录了“流动方向”的贴图,用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 { "RenderType"="Opaque" } 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没有正确设置active,Blit的目标不对。第四,笔刷的_BrushPos传的是屏幕坐标而不是UV坐标,导致笔刷画到了RT外面。
排查的时候可以先把笔刷颜色设成纯红(1,0,0),刷完保存出来看看有没有红色。如果有红色说明绘制流程是通的,问题出在流向编码上。如果没有红色,那就是绘制流程本身有问题。
5.2 流向在模型旋转后错乱
这个前面提过,根本原因是存了世界空间的流向。检查你的编码逻辑,确保在写入之前做了世界空间到切线空间的转换。另外,如果模型有非均匀缩放,切线空间的构建也会出问题,因为叉乘出来的切线不是单位向量。这种情况下需要对切线做归一化。
还有一个容易忽略的点:Unity的UV坐标系和OpenGL/DirectX的差异。Unity的UV原点在左下角,但有些建模软件导出的UV原点在左上角,这会导致刷出来的流向上下颠倒。如果发现流向反了,检查一下模型的UV导入设置。
5.3 保存的PNG颜色偏亮或者偏暗
这是sRGB的问题。RenderTexture在创建的时候如果sRGB为true,ReadPixels出来的数据会经过一次sRGB到线性的转换,导致颜色值偏移。FlowMap存的是向量数据,不应该走这个转换,所以创建RenderTexture的时候要设sRGB为false。
另外,Texture2D的格式也要注意。如果用TextureFormat.RGBA32,ReadPixels出来的数据是原始字节,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的配合
如果你用的是ShaderGraph,FlowMap的采样节点是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的接缝。这个在刷复杂模型的时候特别有用。