1. 项目概述从“画不上”到“画得准”的涂鸦核心做模型涂鸦功能听起来挺酷的对吧想象一下玩家可以在一个3D模型上自由喷涂留下自己的印记。很多朋友包括我自己刚开始的时候都觉得用Unity的ShaderGraph配合RenderTexture来实现这个功能应该是个顺理成章的选择。毕竟ShaderGraph可视化RenderTexture又能当画布逻辑上很通。但真动手做起来十有八九会卡在第一个大坑里为什么我鼠标点的位置和模型上画出来的位置对不上明明感觉逻辑都对但笔触要么飘在天上要么沉入地底或者干脆在模型背后乱画。这个问题的根源几乎百分之百出在坐标转换上。这不是ShaderGraph或者RenderTexture的“Bug”而是从屏幕空间的一个点你的鼠标位置到模型表面一个具体UV坐标的转换链条中任何一个环节理解偏差或处理不当都会导致最终结果谬以千里。尤其是在Unity 2020及之后的版本中URPUniversal Render Pipeline成为主流其内置的ShaderGraph节点和渲染管线的一些特性让这个转换过程有了一些新的“坑点”和最佳实践。所以这篇内容不是什么高深理论的探讨而是一个纯粹的“避坑”实战记录。我会把在实现模型涂鸦时关于RenderTexture坐标转换那些最容易让人栽跟头的地方结合具体的ShaderGraph设置和C#脚本代码掰开揉碎了讲清楚。目标是让你看完之后不仅能解决“画不准”的问题更能透彻理解背后的“为什么”从而能灵活应对更复杂的交互需求。2. 核心思路拆解坐标系的“接力赛”在开始填坑之前我们必须先建立起清晰的坐标系转换全景图。模型涂鸦的本质是将屏幕空间的一个点鼠标点击/触摸位置映射到模型表面的纹理空间UV然后在对应的RenderTexture上绘制。这个过程就像一场多棒接力赛第一棒屏幕空间 (Screen Space)起点Input.mousePosition或Touch.position。这是一个以像素为单位的坐标原点(0,0)在屏幕左下角。特点这个坐标只知道在屏幕的哪个位置完全不知道和3D世界中的哪个物体、哪个点有关。第二棒视口空间 (Viewport Space)转换通过将屏幕坐标除以屏幕的宽高Screen.width,Screen.height将其归一化到[0, 1]范围。意义(0,0)代表视口左下角(1,1)代表视口右上角。这步操作是为了消除屏幕分辨率的影响让后续计算与设备无关。第三棒摄像机射线 (Camera Ray)核心操作使用Camera.ScreenPointToRay方法。这个方法接收一个屏幕坐标或归一化后的视口坐标返回一条从摄像机镜头出发穿过屏幕上该点的射线Ray。关键理解这条射线就是连接2D屏幕点和3D世界的桥梁。它包含了射线的起点摄像机位置和方向指向屏幕点对应的无限远方向。第四棒模型表面交点 (World Space Intersection)碰撞检测使用Physics.Raycast或Collider.Raycast。让上一步得到的射线与目标模型的碰撞体进行求交运算。获取信息如果射线击中目标我们可以从RaycastHit结构中获取到至关重要的信息point: 击中点的世界坐标。textureCoord或textureCoord2: 击中点在该碰撞体对应模型上的UV坐标。这是最关键的输出之一。collider: 被击中的碰撞体用于判断是否是我们想要涂鸦的物体。第五棒RenderTexture上的落笔点 (UV to RenderTexture Pixel)最终映射上一步得到的UV坐标通常在[0,1]范围需要转换为RenderTexture上的像素坐标。计算pixelX uv.x * renderTexture.width;pixelY uv.y * renderTexture.height。这样才能在正确的位置进行像素级绘制如使用Graphics.DrawTexture或设置CommandBuffer。为什么容易出错忽略视口归一化直接用屏幕坐标去生成射线在不同分辨率下结果会漂移。混淆坐标空间错误地将世界坐标或局部坐标当作UV坐标使用。RenderTexture尺寸影响得到UV后忘记乘以RenderTexture的尺寸导致绘制位置缩放错误。碰撞体与模型不匹配如果碰撞体是简化的如MeshCollider使用了简化网格或者模型有复杂的层级和变换RaycastHit.textureCoord可能无法对应到你期望的那个模型的UV。这是最深的一个坑。3. 关键坑点与ShaderGraph节点解析理解了流程我们来看看在ShaderGraph和具体实现中哪些地方最容易“踩雷”。3.1 坑点一Screen Position节点的“陷阱”在ShaderGraph中我们经常需要获取当前片元像素在屏幕上的位置可能会用到Screen Position节点。这个节点在涂鸦Shader用于将RenderTexture绘制到模型上的Shader里很有用但绝对不能用它来反向推导点击位置。节点输出Screen Position节点默认输出的是当前渲染像素的视口空间坐标即[0,1]范围的归一化坐标Tiling端口为None时。在片元着色器中它代表的是正在被绘制的这个像素在屏幕上的位置。核心误区有人试图在C#脚本中计算一个UV然后传到ShaderGraph里与Screen Position比较来判断是否绘制。这是行不通的因为Shader是并行执行的每个片元都有自己的Screen Position它和一次点击事件没有直接关系。正确做法点击检测和UV计算完全在C#脚本中完成。脚本计算出准确的UV坐标后将这个UV坐标或者基于它计算出的绘制信息传递给材质球或者直接写入RenderTexture。ShaderGraph中的Shader只负责“显示”RenderTexture的内容以及可能做一些基于UV的混合效果如笔触边缘羽化。实操心得把ShaderGraph想象成一个“画家”而C#脚本是“指挥家”。指挥家脚本告诉画家Shader“在画布模型UV的这个位置计算好的UV用这种颜色和笔刷通过材质参数传递作画。”画家自己并不需要知道鼠标在哪它只关心指挥家给的指令和当前正在涂抹的画布位置。3.2 坑点二UV与STScale-Offset的纠葛在ShaderGraph中处理纹理采样时尤其是当你需要将RenderTexture精准映射到模型某一部分时会接触到模型的UV和材质的Tiling Offset在Shader代码中常表示为STScale-Translation。模型的UV是顶点属性在导入模型时就确定了定义了顶点在纹理空间中的位置。RaycastHit.textureCoord返回的就是这个值。材质的Tiling Offset是材质球的属性用于在模型UV基础上进行平移和缩放。例如Tiling为(2,2)意味着将纹理在U和V方向上各重复两次。坑的表现你在脚本里用hit.textureCoord计算出了一个点但在模型上绘制时发现位置有偏移或缩放。这可能是因为模型材质应用了Tiling或Offset而你采样RenderTexture时没有考虑这一点。避坑方法方案A推荐在制作涂鸦功能时最好为模型使用一个独立的材质并将其Tiling和Offset设置为(1,1)和(0,0)即不使用缩放偏移。这样hit.textureCoord可以直接使用。方案B兼容如果必须使用原有材质你需要在ShaderGraph中对传入的UV进行反向变换。假设材质Tiling为_MainTex_ST.xyOffset为_MainTex_ST.zw那么从hit.textureCoord到实际采样UV的转换应为actualUV (hit.textureCoord - _MainTex_ST.zw) / _MainTex_ST.xy;你需要将这个actualUV从脚本传递给Shader。这增加了复杂度容易出错。3.3 坑点三RenderTexture的过滤与坐标对齐即使UV计算完全正确绘制到RenderTexture上时仍然可能出现笔触模糊、边缘锯齿或位置有半个像素偏移的问题。Filter ModeRenderTexture创建时默认的filterMode是FilterMode.Bilinear双线性过滤。这对于连续渲染的画面是好的但对于我们这种“点对点”的精确绘制可能会造成颜色渗透到相邻像素导致笔触边缘模糊。解决方案如果追求像素级精确的硬边笔触可以将RenderTexture的filterMode设置为FilterMode.Point。但这样笔触在缩放时会有明显的锯齿。需要根据美术风格权衡。坐标对齐当我们将一个[0,1]的UV坐标转换为像素坐标时例如int pixelX Mathf.FloorToInt(uv.x * rt.width);。这里有一个经典的“像素中心”与“像素角落”的问题。在计算机图形学中通常认为一个像素的中心位于其整数坐标0.5的位置。问题如果你简单地用FloorToInt或RoundToInt可能会因为取整方式导致绘制位置有轻微偏移。解决方案一个更稳健的转换方式是float pixelX (uv.x * rt.width) - 0.5f;然后根据你的绘制API如Graphics.DrawTexture的矩形参数来决定如何使用这个带小数的坐标。对于很多情况直接使用Mathf.RoundToInt(uv.x * rt.width)也能获得可接受的结果但了解这个原理有助于调试更精细的问题。4. 完整实现流程与代码剖析下面我们从一个可运行的简单案例出发详解每一步。4.1 第一步场景与资产准备创建场景新建一个URP项目Unity 2020导入一个简单的模型例如一个Sphere或Cube。创建RenderTexture在Project窗口右键 - Create - Render Texture。命名为“PaintRT”。建议初始尺寸设为512x512平衡性能和精度。在Inspector面板中根据需求设置Filter Mode如PointFormat如ARGB32即可。创建涂鸦材质新建一个Material命名为“PaintMat”。将其Shader改为“Universal Render Pipeline/Lit”或你需要的Shader。将上一步创建的“PaintRT”拖拽到材质的Base Map槽位。将这个材质赋给你的模型。4.2 第二步C#脚本——坐标计算的核心创建一个名为ModelPainter.cs的脚本挂载到场景中的摄像机或一个独立的管理器物体上。using UnityEngine; public class ModelPainter : MonoBehaviour { public Camera paintingCamera; // 用于发射射线的摄像机 public RenderTexture paintRenderTexture; // 引用的RenderTexture public Texture2D brushTexture; // 笔刷纹理如一个圆形软边贴图 public Color paintColor Color.red; public float brushSize 0.1f; // 基于UV空间的笔刷大小 private Material _paintMaterial; // 用于临时绘制的材质 private RaycastHit _hit; void Start() { if (paintingCamera null) paintingCamera Camera.main; // 初始化一个简单的绘制材质使用一个内置的简单Shader _paintMaterial new Material(Shader.Find(Unlit/Texture)); _paintMaterial.mainTexture brushTexture; } void Update() { if (Input.GetMouseButton(0)) // 按住鼠标左键绘制 { TryPaint(); } } void TryPaint() { // 1. 获取鼠标的视口坐标完成第一棒到第二棒 Vector3 mousePos Input.mousePosition; Ray ray paintingCamera.ScreenPointToRay(mousePos); // 关键方法屏幕坐标转射线 // 2. 进行射线检测第四棒 if (Physics.Raycast(ray, out _hit)) { // 假设我们只给名字包含“Paintable”的物体涂鸦避免误操作 if (!_hit.collider.name.Contains(Paintable)) return; // 3. 获取击中点的UV坐标这是第四棒的核心产出 Vector2 pixelUV _hit.textureCoord; // 注意这里是textureCoord // 4. 将UV坐标转换为RenderTexture上的像素坐标第五棒 // 注意这里假设模型的材质没有Tiling/Offset且UV在[0,1]连续。 int rtWidth paintRenderTexture.width; int rtHeight paintRenderTexture.height; // 计算笔刷在RenderTexture上覆盖的像素范围 int pixelX Mathf.FloorToInt(pixelUV.x * rtWidth); int pixelY Mathf.FloorToInt(pixelUV.y * rtHeight); int brushPixelRadius Mathf.FloorToInt(brushSize * Mathf.Min(rtWidth, rtHeight) * 0.5f); // 5. 在RenderTexture上绘制 DrawOnRenderTexture(pixelX, pixelY, brushPixelRadius); } } void DrawOnRenderTexture(int centerX, int centerY, int radius) { // 为了在RenderTexture上绘制我们需要临时激活它作为渲染目标 RenderTexture.active paintRenderTexture; // 设置绘制矩阵和材质参数 GL.PushMatrix(); GL.LoadPixelMatrix(0, paintRenderTexture.width, 0, paintRenderTexture.height); // 设置2D绘制坐标系 _paintMaterial.SetPass(0); // 激活我们的笔刷材质 // 开始绘制Quads这里简化实际应绘制一个与笔刷纹理匹配的矩形 Rect drawRect new Rect(centerX - radius, centerY - radius, radius * 2, radius * 2); Graphics.DrawTexture(drawRect, brushTexture, _paintMaterial); GL.PopMatrix(); RenderTexture.active null; // 非常重要绘制完成后必须还原否则会影响后续屏幕渲染 } }代码关键点解析ScreenPointToRay这是整个坐标转换的枢纽它内部已经处理了从屏幕坐标到摄像机射线的复杂计算。_hit.textureCoord这是我们千辛万苦要得到的模型UV坐标。对于简单的MeshCollider它通常能正确返回。对于复杂的复合碰撞体可能需要使用_hit.textureCoord2或通过_hit.collider.Raycast获取更精确的信息。RenderTexture.active这是一个全局状态。在绘制前设置它为我们的paintRenderTexture意味着后续的GL.**或Graphics.DrawTexture调用都会绘制到这张纹理上。绘制完成后务必设置为null这是一个非常常见的遗漏点会导致游戏主画面渲染异常如变黑。GL.LoadPixelMatrix此调用建立了2D绘制空间使得后续的坐标(centerX, centerY)直接对应RenderTexture的像素坐标简化了计算。4.3 第三步ShaderGraph的配合——显示与混合C#脚本负责在RenderTexture上“作画”而ShaderGraph负责把这张“画布”RenderTexture漂亮地显示在模型上并可能实现与原纹理的混合。创建ShaderGraph在Project窗口右键 - Create - Shader - Universal Render Pipeline - Lit Shader Graph或其他类型。命名为“PaintDisplay”。基础设置在Graph Inspector中将Surface Type设为TransparentBlend Mode设为Alpha这样可以让涂鸦层与原材质混合。节点连接添加一个Texture2D节点将其变量命名为“_PaintTex”关联到我们创建的RenderTexture。添加一个Sample Texture 2D节点将_PaintTex连接上去。模型的UV通过UV节点选择UV0通道自动输入到Sample Texture 2D的UV端口。这里就是关键C#脚本计算出的UV正是对应这个UV0通道。所以我们在RenderTexture上基于这个UV绘制的图案会完美贴合模型。将Sample Texture 2D的RGBA输出连接到主PBR Master节点的Base Color。你也可以用Lerp节点将其与原纹理另一张Sample Texture 2D混合用_PaintTex的Alpha作为混合因子实现只在有涂鸦的地方覆盖原图的效果。为什么这样可行因为整个过程共享同一套UV坐标系。模型导入时定义了UV0这个信息同时用于射线检测Physics.Raycast返回的textureCoord是基于模型UV0的。RenderTexture绘制我们用这个UV0坐标换算成像素位置在RenderTexture上画了一笔。ShaderGraph采样Shader渲染模型每个片元时用该片元的UV0坐标去采样RenderTexture正好就把刚才画的那一笔提取出来显示在模型的对应位置。5. 进阶问题与深度优化解决了基本问题后我们会遇到一些更棘手的场景。5.1 动态物体与局部/世界坐标转换如果你的模型是会移动、旋转、缩放的比如一个角色那么上述方法在模型变换后涂鸦位置会错乱吗答案是不会只要碰撞体随之变换。因为Physics.Raycast是在世界空间中进行的。射线与碰撞体求交后返回的hit.point是世界坐标而hit.textureCoord是模型自身的UV与模型的世界变换无关。所以无论模型怎么动射线击中模型表面某一点对应的UV是不变的。RenderTexture上的绘制基于这个不变的UV因此涂鸦会“粘”在模型的特定部位跟随模型一起运动。注意事项这里的前提是碰撞体Collider正确跟随模型变换。如果碰撞体是动态添加或缩放异常的可能会导致Raycast失败或textureCoord不准。对于Skinned Mesh Renderer蒙皮网格需要使用Raycast到其附加的MeshCollider需从SkinnedMeshRenderer生成实时网格并更新给MeshCollider过程更复杂。5.2 性能考量CommandBuffer与异步绘制上面示例中使用GL立即模式在每帧绘制如果笔刷很复杂或绘制频繁可能成为性能瓶颈。优化方案使用CommandBufferCommandBuffer允许我们将绘制命令缓存起来然后一次性提交更高效也更适合与SRP如URP配合。private CommandBuffer _commandBuffer; private MaterialPropertyBlock _propBlock; void InitializeCommandBuffer() { _commandBuffer new CommandBuffer { name PaintBuffer }; _propBlock new MaterialPropertyBlock(); // 将CommandBuffer加入到摄像机的某个渲染事件中 paintingCamera.AddCommandBuffer(CameraEvent.AfterEverything, _commandBuffer); } void DrawWithCommandBuffer(Vector2 uv, float size) { _commandBuffer.Clear(); // 每帧清空或增量管理 _propBlock.SetTexture(_MainTex, brushTexture); _propBlock.SetColor(_Color, paintColor); // 计算绘制矩阵和矩形... // _commandBuffer.DrawMesh(...) 或 _commandBuffer.DrawProcedural(...) // 这种方式更底层控制更精细性能更好。 }RenderTexture尺寸与内存512x512的RGBA32纹理占用约1MB内存。如果支持多张画布或高精度需注意内存开销。可以考虑根据设备性能动态调整尺寸或使用RenderTextureFormat.ARGBHalf等压缩格式。5.3 多模型与UV通道问题一个场景有多个可涂鸦模型它们共享一张RenderTexture吗通常不建议因为UV会重叠。更好的做法是每个模型独享一张RenderTexture或者使用一张更大的RenderTexture的不同区域Atlas这需要修改UV计算在原始UV上附加一个偏移量对应Atlas中的子区域。此外模型可能有多个UV通道UV0, UV1...。RaycastHit.textureCoord默认对应的是UV0。如果你的模型光照贴图用了UV1而你想在UV1对应的纹理空间上绘制就需要确保射线检测能返回正确的UV通道信息。这通常需要自定义射线检测或使用更底层的API。6. 常见问题排查清单当你遇到涂鸦位置不对时可以按照这个清单逐项检查问题现象可能原因排查步骤与解决方案笔触完全错位不在模型上1. 射线检测未命中目标。2. UV坐标计算根本性错误。1. 在Raycast后Debug.DrawRay画出射线确认是否击中模型。2. 在TryPaint中将hit.point和hit.textureCoord用Debug.Log打印出来检查是否合理。笔触位置偏移但有规律1. 未考虑材质Tiling/Offset。2. 屏幕坐标到视口坐标转换有误。3. RenderTexture像素坐标转换未考虑中心对齐。1. 检查模型材质的Tiling和Offset尝试设为(1,1)和(0,0)。2. 确认使用ScreenPointToRay它已处理转换。3. 尝试在UV转像素时±0.5f或检查笔刷绘制矩形的中心计算。笔触只在模型特定角度出现1. 模型碰撞体不完整如只有正面。2. 射线起点被遮挡。1. 检查模型的Collider类型和范围确保全覆盖。2. 使用Raycast的重载版本设置最大距离或层掩码。绘制后屏幕主画面异常变黑/花屏RenderTexture.active在绘制后未重置为null。确保在DrawOnRenderTexture函数末尾GL.PopMatrix()后执行RenderTexture.active null;。笔触边缘模糊RenderTexture的Filter Mode为Bilinear。将RenderTexture的Filter Mode改为Point或使用更高分辨率纹理。在移动设备上触摸不准触摸坐标可能存在多点触控处理问题。使用Input.GetTouch(0).position获取触摸坐标并确保正确处理触摸阶段Began, Moved, Stationary。动态物体移动后涂鸦错位可能错误地缓存了世界坐标而非UV。确认绘制逻辑始终基于hit.textureCoordUV而非hit.point世界坐标。UV不随物体变换而改变。7. 个人实践中的体会与技巧最后分享几个从实际项目踩坑中总结出的技巧调试利器可视化UV。在开发初期创建一个临时的调试Shader将模型的UV坐标UV0直接作为颜色输出例如R通道UV.x, G通道UV.y。这样你就能在Game视图看到模型表面的UV展开图。当你在某个位置点击时对比脚本计算出的UV和屏幕上该点显示的颜色能非常直观地定位问题。从简单开始。不要一开始就上复杂的模型和Shader。用一个标准的Unity Cube或Sphere它们的UV是规整的来验证你的核心绘制逻辑。确认在Cube的六个面上点击笔触都能出现在正确位置后再替换成你的目标模型。考虑使用Texture2D作为中间画布。对于绘制逻辑特别复杂、需要频繁读取像素颜色进行混合如颜色混合、模糊的情况可以先将内容绘制到Texture2D上然后在每帧结束时使用Graphics.Blit或CommandBuffer将Texture2D更新到RenderTexture。因为Texture2D的像素读写GetPixel,SetPixel比直接操作RenderTexture更灵活但性能较差需要权衡。笔刷的“压力”与“间距”。实现像真实画笔那样的效果需要在Update中记录上一帧的点击UV位置。当前帧绘制时不是只画一个点而是在上一帧位置和当前位置之间进行插值连续绘制多个点。同时可以根据移动速度动态调整笔刷大小或透明度模拟压力效果。URP下的深度纹理。如果你的涂鸦需要处理模型之间的遮挡例如只画在最先击中的模型上即使鼠标后面移动到其他物体前可能需要用到摄像机的深度纹理_CameraDepthTexture。在ShaderGraph中你可以利用深度比较来实现复杂的遮挡效果但这属于更高级的主题需要扎实的渲染管线知识。模型涂鸦功能的实现是一个将2D屏幕输入、3D几何信息、纹理空间映射和实时渲染紧密结合的典型例子。坐标转换是串联这一切的骨架。希望这篇围绕“避坑”展开的详细解析能帮你打通从思路到实现的任督二脉。当你再遇到坐标对不上的问题时不妨回到那个“五棒接力”的流程图一步步检查看看接力棒在哪一棒掉了。