Unity画板实现:从UI绘制原理到高性能涂鸦功能开发

📅 2026/7/11 4:53:13
Unity画板实现:从UI绘制原理到高性能涂鸦功能开发
1. 项目概述为什么要在Unity里做画板如果你用过Unity大概率知道它是个做游戏的引擎但用它来做一个画板听起来是不是有点“杀鸡用牛刀”我一开始也是这么想的直到我需要在项目里集成一个让玩家自由涂鸦、做标记的功能才发现这其实是个非常高频且实用的需求。无论是教育类应用里的白板、解谜游戏里的线索记录还是创意工具里的草图绘制一个运行流畅、交互自然的画板功能都能极大地提升用户体验。Unity实现画板核心思路其实很直接捕获用户的输入鼠标或触摸将这些输入点连接成线并实时渲染到屏幕上。听起来简单但里面门道不少。比如你是用UI系统在2D平面上画还是用3D物体在空间里画画出来的线条是粗糙的像素点还是平滑的贝塞尔曲线要不要支持撤销、重做、换颜色、调粗细这些细节决定了画板的“专业度”。我这次要分享的就是一个从零开始、功能完备的简易画板实现方案。它不依赖复杂的第三方插件完全基于Unity内置的UI系统和图形API代码结构清晰性能可控非常适合作为功能模块集成到你的项目中。无论你是Unity新手想了解图形交互还是老手需要快速实现一个涂鸦功能这篇文章都能给你一套可以直接“抄作业”的完整方案。2. 核心思路与架构设计2.1 技术路线选型UI画布 vs 3D物体实现画板首先得决定在哪里“作画”。Unity里主要有两条路基于UI系统的2D画布Canvas这是最常见和推荐的方式。将画板作为一个全屏或指定区域的UI面板在其上动态生成线条。优点是开发简单、适配屏幕分辨率方便、与UI事件系统EventSystem天然集成非常适合制作HUD上的涂鸦、签名板等功能。基于3D物体的空间绘制在3D世界中创建一个平面Plane或Quad作为“画纸”通过射线检测Raycast获取绘制坐标。这种方式适合VR/AR应用、3D场景中的立体涂鸦或者需要将绘制内容与3D场景光照、阴影进行交互的情况。对于“简易画板”我们的目标是快速、稳定、易集成因此首选基于UI Canvas的方案。它避开了3D坐标转换、射线检测等相对复杂的逻辑让我们能更专注于绘制本身。2.2 绘制原理如何把点变成线确定了“画纸”接下来是“画笔”的工作原理。核心流程可以概括为以下几步输入捕获在每一帧Update中通过Input类或EventTrigger组件监听鼠标/触摸事件按下、拖拽、抬起。坐标转换将屏幕坐标Input.mousePosition转换为画布Canvas下的局部坐标。这一步至关重要因为屏幕原点在左下角而UI的RectTransform锚点坐标系可能千变万化。点序列记录将转换后的坐标点按时间顺序存入一个列表ListVector2。网格生成与渲染Unity中任何可见的图形都是由网格Mesh构成的。我们需要根据记录的点序列实时生成一个由三角形构成的、代表线条的网格。然后为这个网格附上一个材质Material使用着色器Shader进行渲染上色。这里的关键在于第4步。一种更简单但不推荐的方法是使用LineRenderer组件但它更适用于3D空间中的线在UI系统中控制不够灵活且性能开销较大。我们采用的方法是动态创建并修改一个MeshFilter和MeshRenderer组件来绘制2D网格线条。这种方法虽然代码量稍多但性能最优控制粒度最细。2.3 功能模块设计一个可用的画板至少需要以下模块绘制核心Drawing Core负责上述输入、计算、网格生成的完整流程。画笔属性Brush Settings管理线条的颜色、宽度、平滑度等。画布管理Canvas Management负责清空画布、可能需要的图层管理。交互控制Interaction Control处理绘制模式的开启/关闭可能还有撤销/重做栈。我们的架构将围绕一个主要的DrawingManager单例类展开它挂载在画布对象上统筹所有功能。注意关于性能的提前考量。动态生成网格是性能敏感操作。如果玩家疯狂涂鸦点数会急剧增加。我们需要引入“点采样”机制即当鼠标移动很快时不记录每一个像素点而是按最小距离阈值采样避免生成过于密集、浪费性能的点。同时对于已经画完的线条可以考虑将其“烘焙”为静态的纹理Texture2D以减轻实时网格计算的负担。这是我们设计之初就要想好的优化点。3. 核心实现从零搭建绘制系统3.1 创建UI画布与绘制区域首先在Unity场景中创建UI画布菜单栏选择GameObject - UI - Canvas。这会自动创建一个带有Canvas、Canvas Scaler和Graphic Raycaster组件的对象。将Render Mode设置为Screen Space - Overlay这是最常用的UI渲染模式。在Canvas下创建一个Panel(GameObject - UI - Panel)。这个Panel将作为我们的绘制区域。将其锚点Anchors拉伸至全屏并确保其Image组件的颜色Color设置为纯白或其他你想要的画纸底色。在这个Panel下创建一个空的GameObject命名为“DrawingContainer”。我们将把所有动态生成的线条网格都放在这个容器下便于管理。为其添加一个RectTransform组件同样设置为拉伸全屏。现在你的层级视图Hierarchy应该类似这样Canvas ├── Panel (绘制背景板) │ └── DrawingContainer (空对象用于挂载线条) └── EventSystem (自动创建)3.2 编写核心绘制管理器DrawingManager创建一个C#脚本命名为DrawingManager.cs并将其挂载到Canvas或DrawingContainer上。using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; public class DrawingManager : MonoBehaviour { public static DrawingManager Instance; // 单例方便全局访问 [Header(画笔设置)] public Color brushColor Color.black; public float brushWidth 5.0f; public float minDistanceBetweenPoints 0.1f; // 点采样最小距离 [Header(引用)] public RectTransform drawingArea; // 指向DrawingContainer的RectTransform public Camera uiCamera; // 如果是Screen Space - Camera模式需要 private bool isDrawing false; private ListVector2 currentPoints new ListVector2(); private GameObject currentLineObject; // 当前正在绘制的线条对象 private MeshFilter currentMeshFilter; private MeshRenderer currentMeshRenderer; private ListGameObject lineHistory new ListGameObject(); // 历史线条用于撤销 void Awake() { if (Instance null) Instance this; else Destroy(gameObject); } void Update() { HandleInput(); } void HandleInput() { // 鼠标左键或触摸按下 if (Input.GetMouseButtonDown(0)) { StartDrawing(); } // 鼠标左键或触摸持续 if (isDrawing Input.GetMouseButton(0)) { Vector2 mousePos Input.mousePosition; Vector2 localPoint; // 将屏幕坐标转换为绘制区域内的局部坐标 if (RectTransformUtility.ScreenPointToLocalPointInRectangle(drawingArea, mousePos, uiCamera, out localPoint)) { AddPoint(localPoint); } } // 鼠标左键或触摸抬起 if (Input.GetMouseButtonUp(0)) { EndDrawing(); } } void StartDrawing() { isDrawing true; currentPoints.Clear(); // 创建新的线条游戏对象 currentLineObject new GameObject(Line); currentLineObject.transform.SetParent(drawingArea, false); currentLineObject.transform.localPosition Vector3.zero; // 添加必要的组件 currentMeshFilter currentLineObject.AddComponentMeshFilter(); currentMeshRenderer currentLineObject.AddComponentMeshRenderer(); // 创建一个简单的无光照着色器材质用于绘制颜色 Material lineMat new Material(Shader.Find(Unlit/Color)); lineMat.color brushColor; currentMeshRenderer.material lineMat; } void AddPoint(Vector2 newPoint) { // 点采样如果距离上一个点太近则忽略避免点过密 if (currentPoints.Count 0) { float distance Vector2.Distance(currentPoints[currentPoints.Count - 1], newPoint); if (distance minDistanceBetweenPoints) return; } currentPoints.Add(newPoint); UpdateLineMesh(); // 每次添加新点更新一次网格 } void UpdateLineMesh() { if (currentPoints.Count 2) return; // 至少需要两个点才能形成线段 Mesh mesh new Mesh(); ListVector3 vertices new ListVector3(); Listint triangles new Listint(); // 核心算法将点序列扩展为具有宽度的线条网格 for (int i 0; i currentPoints.Count; i) { Vector2 point currentPoints[i]; // 计算当前点的法线方向垂直于线段方向 Vector2 direction Vector2.zero; if (i 0) // 起点使用下一个点的方向 direction (currentPoints[i 1] - point).normalized; else if (i currentPoints.Count - 1) // 终点使用前一个点的方向 direction (point - currentPoints[i - 1]).normalized; else // 中间点取前后方向的平均值使转角平滑 direction ((currentPoints[i 1] - point).normalized (point - currentPoints[i - 1]).normalized).normalized; // 求垂直于方向向量的法线 Vector2 normal new Vector2(-direction.y, direction.x).normalized; // 根据画笔宽度向法线正反两个方向扩展生成两个顶点 Vector2 offset normal * brushWidth * 0.5f; vertices.Add(point offset); // 右侧顶点 vertices.Add(point - offset); // 左侧顶点 } // 构建三角形 for (int i 0; i vertices.Count - 2; i 2) { // 两个相邻的线段四边形由两个三角形组成 // 四边形顶点顺序当前右(i), 当前左(i1), 下一个右(i2), 下一个左(i3) triangles.Add(i); // 当前右 triangles.Add(i 1); // 当前左 triangles.Add(i 2); // 下一个右 triangles.Add(i 2); // 下一个右 triangles.Add(i 1); // 当前左 triangles.Add(i 3); // 下一个左 } mesh.SetVertices(vertices); mesh.SetTriangles(triangles, 0); mesh.RecalculateNormals(); // 虽然不是必须但是个好习惯 currentMeshFilter.mesh mesh; } void EndDrawing() { if (!isDrawing) return; isDrawing false; if (currentPoints.Count 2) { lineHistory.Add(currentLineObject); // 将完成的线条加入历史 } else { // 如果点数太少比如只是点了一下销毁这个无用的对象 Destroy(currentLineObject); } currentLineObject null; currentPoints.Clear(); } // 清空画布 public void ClearCanvas() { foreach (var line in lineHistory) { Destroy(line); } lineHistory.Clear(); if (currentLineObject ! null) { Destroy(currentLineObject); currentLineObject null; } } // 撤销上一步 public void Undo() { if (lineHistory.Count 0) { int lastIndex lineHistory.Count - 1; Destroy(lineHistory[lastIndex]); lineHistory.RemoveAt(lastIndex); } } }代码关键点解析RectTransformUtility.ScreenPointToLocalPointInRectangle这是UI坐标转换的灵魂函数。它准确地将屏幕点击位置转换到目标RectTransform的局部坐标系下无论画布采用何种锚点或缩放模式。点采样minDistanceBetweenPoints这是一个非常重要的优化。如果不加限制在Update中每一帧都记录点当鼠标快速移动时会生成大量冗余的、距离极近的点导致网格顶点数爆炸。这个阈值过滤掉了过于密集的点。网格生成算法这是最核心的部分。我们不是简单地用GL.LINES绘制这在UI中不可行而是构建了一个2D网格。思路是将每个输入点沿着垂直于线段方向的法线向两侧扩展半个画笔宽度生成两个顶点。然后将这些顶点两两连接形成连续的四边形带再分割为三角形。这样我们就得到了一条具有视觉宽度的、连续的线条。单例模式使用单例方便其他UI控件如颜色选择按钮、清空按钮调用画板的管理功能。3.3 创建画笔控制UI现在我们需要一些UI控件来改变画笔属性和控制画板。在Canvas下创建几个Button分别命名为“Btn_Black”“Btn_Red”“Btn_Blue” 并修改其Image组件的颜色。创建一个Slider用于调整画笔宽度将其值范围Min Value, Max Value设置为例如 1 到 20。创建“Btn_Clear”和“Btn_Undo”按钮。为这些按钮和滑块编写事件监听脚本或者使用Unity的OnClick()和OnValueChanged()事件直接绑定到DrawingManager.Instance的公共方法上。创建一个UIController.cs脚本挂载在Canvas上或者直接使用按钮的OnClick事件绑定// 这是一个简单的控制器示例也可以直接在Inspector面板中拖拽绑定 using UnityEngine; using UnityEngine.UI; public class BrushUIController : MonoBehaviour { public DrawingManager drawingManager; // 拖拽赋值 public Slider brushWidthSlider; void Start() { if (brushWidthSlider ! null) { brushWidthSlider.onValueChanged.AddListener(OnBrushWidthChanged); brushWidthSlider.value drawingManager.brushWidth; } } public void SetBrushColor(Color color) { drawingManager.brushColor color; } void OnBrushWidthChanged(float value) { drawingManager.brushWidth value; } public void ClearCanvas() { drawingManager.ClearCanvas(); } public void Undo() { drawingManager.Undo(); } }在Inspector面板中将DrawingManager实例和brushWidthSlider拖拽赋值并将各个颜色按钮的OnClick事件绑定到SetBrushColor方法传入对应的颜色如Color.black。4. 性能优化与高级功能拓展基础画板已经能用了但要让它在真实项目中稳定运行还需要考虑更多。4.1 性能瓶颈分析与优化动态网格生成在移动端或绘制复杂图形时可能成为瓶颈。以下是几个优化方向合并网格Mesh Combining目前每画一笔就生成一个GameObject和一个Mesh。当线条数量成百上千时Draw Call会非常高。一个优化策略是定期或在特定时机如暂停绘制时将多个线条的网格合并成一个。可以使用Mesh.CombineMeshes方法。但要注意合并后这些线条将成为一个整体无法单独操作如单独擦除某一条。// 伪代码合并所有历史线条的网格 ListCombineInstance combineInstances new ListCombineInstance(); foreach (var lineObj in lineHistory) { MeshFilter mf lineObj.GetComponentMeshFilter(); if (mf ! null mf.sharedMesh ! null) { CombineInstance ci new CombineInstance(); ci.mesh mf.sharedMesh; ci.transform mf.transform.localToWorldMatrix; combineInstances.Add(ci); } } Mesh combinedMesh new Mesh(); combinedMesh.CombineMeshes(combineInstances.ToArray()); // 将合并后的网格赋给一个新对象...使用对象池Object Pooling频繁创建和销毁GameObject线条对象会产生GC垃圾回收压力。可以预先实例化一定数量的线条对象放入池中绘制时从池中取用画完或撤销时回收到池中而不是直接Destroy。简化网格Mesh Simplification对于已经绘制完成的、不再变化的线条可以运行一个简化算法在保持外观大致不变的前提下减少顶点数量。这对于长而平滑的线条效果显著。限制绘制区域与分辨率如果画板不需要全屏就严格限制绘制区域。也可以考虑将最终绘制结果渲染到一张固定分辨率的RenderTexture上然后将这张纹理显示在UI中。这能有效控制最大顶点数。4.2 实现笔触平滑贝塞尔曲线目前的实现是直线连接点画出来的线条会有棱角。要实现类似Photoshop或Procreate那样光滑的笔触需要引入贝塞尔曲线插值。基本思路是我们仍然记录原始输入点但在生成网格时不直接用这些点而是用它们作为控制点生成一条平滑的贝塞尔曲线再从这条曲线上采样出更多的、分布均匀的点来构建网格。这里以二次贝塞尔曲线为例进行简单修改 在UpdateLineMesh函数中在遍历currentPoints生成顶点之前先通过一个函数将原始点序列转换为平滑的曲线点序列。ListVector2 GetSmoothedPoints(ListVector2 originalPoints, float segmentScale 0.5f) { ListVector2 smoothed new ListVector2(); if (originalPoints.Count 2) return originalPoints; for (int i 0; i originalPoints.Count - 1; i) { Vector2 p0 originalPoints[Mathf.Max(i - 1, 0)]; // 前一个点 Vector2 p1 originalPoints[i]; // 当前点 Vector2 p2 originalPoints[i 1]; // 下一个点 Vector2 p3 originalPoints[Mathf.Min(i 2, originalPoints.Count - 1)]; // 下下个点 // 使用Catmull-Rom样条或贝塞尔公式计算平滑路径上的点 // 这里简化处理在p1和p2之间插入几个插值点 int segments Mathf.CeilToInt(Vector2.Distance(p1, p2) / segmentScale); for (int s 0; s segments; s) { float t s / (float)segments; // 这是一个简单的二次贝塞尔插值实际常用三次或Catmull-Rom // Vector2 interpolated CalculateBezierPoint(t, p0, p1, p2, p3); // 简化直接使用线性插值演示实际应使用更复杂的公式 Vector2 interpolated Vector2.Lerp(p1, p2, t); smoothed.Add(interpolated); } } return smoothed; } // 然后在UpdateLineMesh中使用smoothed列表代替currentPoints来生成顶点。实操心得笔触平滑是一把双刃剑。它极大地提升了视觉效果但计算量也显著增加尤其是实时绘制时。务必在平滑度和性能之间取得平衡。一个折中的方案是在绘制过程中先用直线连接保证实时性当一笔画完EndDrawing时再对这条线的所有点运行一次平滑算法并替换掉原来的网格。这样用户绘制时感觉流畅松手后线条自动变光滑。4.3 实现擦除Eraser功能擦除功能并非真的“删除”网格因为修改或删除已生成网格的特定部分非常复杂。更实用的方法是利用渲染的深度ZTest或模板测试Stencil或者采用纹理绘制RenderTexture的思路。纹理绘制方案是更高级但更强大的方法创建一张RenderTexture作为画布纹理。使用一个RawImageUI组件来显示这张纹理。绘制时不再动态生成网格而是通过Graphics.Blit或CommandBuffer将笔刷形状一个圆形纹理以“混合”模式绘制到RenderTexture上。擦除时使用相同的笔刷形状但以“擦除”混合模式如目标Alpha为0绘制到纹理上。这种方案的优点是功能强大支持各种笔刷、滤镜性能稳定与线条复杂度无关且天然支持“半透明”、“混合”等效果。缺点是实现复杂度高需要熟悉渲染纹理和着色器。对于“简易画板”如果擦除不是核心需求可以暂时不做或者用“用背景色再画一遍”来模拟。5. 常见问题与调试技巧在实际集成和测试中你肯定会遇到一些坑。这里记录了几个最常见的问题和解决方法。5.1 绘制坐标不准或偏移问题描述鼠标点击的位置和画线起点对不上线条好像偏移了一大截。原因与解决drawingArea引用错误确保DrawingManager脚本中drawingArea变量引用的就是DrawingContainer的RectTransform而不是它的父级Panel或Canvas。Canvas Render Mode 与 Camera 设置如果你的Canvas是Screen Space - Camera模式必须正确赋值uiCamera变量。如果是Screen Space - Overlay模式uiCamera参数应传入null。这是ScreenPointToLocalPointInRectangle函数的要求。锚点与轴心点Pivot检查DrawingContainer的轴心点Pivot。默认是(0.5, 0.5)即中心。如果你的绘制逻辑假设原点在左下角而轴心在中心就会产生偏移。一个简单的办法是将DrawingContainer的轴心设置为(0, 0)锚点也设置为左下角拉伸这样局部坐标原点就在左下角更符合直觉。5.2 线条边缘锯齿严重问题描述画出的线条边缘有明显的锯齿Aliasing不够平滑。原因与解决Unity UI 默认抗锯齿确保在项目设置Project Settings - Quality中开启了抗锯齿Anti Aliasing。使用MSDF或SDF字体/纹理技术对于非常细的线条UI系统的传统渲染可能力不从心。可以考虑使用基于有向距离场SDF的着色器来渲染线条它能提供极其平滑的边缘即使在放大时也是如此。但这属于进阶内容。增加线条网格的细分在我们生成网格的算法中顶点数决定了线条的“精细度”。在转角处可以通过插入更多顶点来让拐角更圆滑。这会在UpdateLineMesh的顶点生成环节增加计算量。5.3 在UI滚动视图Scroll Rect内绘制失效问题描述画板放在一个可以滚动的区域里绘制时线条位置错乱或者根本无法绘制。原因与解决事件被拦截Scroll Rect组件会处理拖拽事件用于滚动这可能会拦截掉我们的绘制输入。需要修改DrawingManager的输入检测逻辑。解决方案不要只在Update里检测全局Input。改为使用EventTrigger组件挂在绘制区域Panel上监听BeginDrag、Drag和EndDrag事件。注意Scroll Rect的拖拽阈值Drag Threshold可能比我们画板的操作阈值大导致轻微移动不被识别为绘制。可以尝试适当调小Scroll Rect的阈值或者在EventTrigger中调用eventData.Use()来阻止事件继续冒泡给Scroll Rect但这又会阻止滚动。一个更鲁棒的方法是判断拖拽的起始偏移量如果小于某个值则认为是意图绘制并阻止滚动否则允许滚动。这需要更精细的事件管理。5.4 移动端触摸性能与体验问题描述在手机或平板上运行触摸绘制不跟手、延迟高或有断线。原因与解决输入帧率Update帧率不稳定。移动设备帧率波动大可能导致点采样不均匀。可以考虑使用FixedUpdate来捕获输入或者记录输入的时间戳在生成网格时进行基于时间的插值。触摸点数量Input.touches返回多个触摸点。我们的简单实现只处理了第一个类似鼠标。如果需要支持多指同时绘制比如两人同时画需要遍历所有Touch。性能过热动态网格生成是CPU密集型操作。在低端移动设备上如果一笔画得非常长顶点数过多可能导致卡顿。必须严格执行点采样优化并考虑引入网格合并或纹理绘制的后备方案。可以使用System.Diagnostics.Stopwatch来 profilingUpdateLineMesh函数的耗时确保它在一帧内完成。最后分享一个我调试时的小技巧在UpdateLineMesh函数末尾添加Debug.Log(“顶点数: ” vertices.Count)。这样你就能直观地看到每一笔产生了多少顶点对于优化点采样阈值minDistanceBetweenPoints非常有帮助。通常将单条线的顶点数控制在几百个以内在移动端也能有不错的性能表现。