Unity移动端实时绘画系统:从纹理绘制到多点触控的完整实现

发布时间:2026/7/10 5:17:42

Unity移动端实时绘画系统:从纹理绘制到多点触控的完整实现 1. 项目概述与核心价值最近在整理自己的Unity资源库翻到了一个老项目“Mobile Paint”的源码。这是一个专门为移动设备手机、平板设计的绘画涂鸦功能包最初是Unity Asset Store上的一个付费资源后来作者开源了。虽然它的最后一次更新停留在2018年代码风格和架构带着明显的Unity 4.x/5.x时代的烙印但时至今日我依然觉得它是一个非常值得研究的“教学级”项目。对于想深入理解Unity中实时纹理绘制、多点触控交互以及移动端性能优化的开发者来说这套源码就像一本活教材。这个项目的核心目标很纯粹在Unity里实现一个高性能、支持多点触控的“画板”。听起来简单但当你真正上手去实现时会发现一堆坑如何将屏幕触摸坐标精准映射到纹理像素如何实现流畅的笔触连线如何处理不同分辨率和屏幕比例下的画布适配如何管理纹理内存避免卡顿Mobile Paint用一套相对简洁的代码结构给出了它的答案。它不是功能最全的比如没有图层、复杂的混合模式但它的实现足够直接和高效让你能清晰地看到从触摸输入到像素着色的完整链路。如果你正打算为你的游戏做一个涂鸦签名板、或者开发一个简单的儿童填色应用直接基于它进行二次开发会比从零开始省下至少一周的摸索时间。2. 源码架构与核心模块拆解拿到源码先别急着运行Demo。我们花点时间看看它的文件夹结构理解作者的设计思路。整个项目主要围绕几个核心脚本展开结构清晰职责分明。2.1 核心控制器MobilePaint.cs这是整个系统的大脑一个挂在画布GameObject上的单例脚本虽然没严格用单例模式但通常场景中只存在一个实例。它的职责非常重初始化创建或获取一个Texture2D对象作为画布纹理并根据屏幕分辨率或自定义设置其尺寸。输入处理在Update()循环中检测触摸或编辑器内的鼠标输入将屏幕坐标转换为画布纹理的UV坐标。绘制调度根据当前的绘制模式如画笔、橡皮擦、油漆桶、笔刷大小和颜色调用底层方法在纹理上“画”出像素。资源管理管理笔刷纹理、画布材质并提供清空、撤销等公共接口。它的工作流程就像一个流水线触摸点进来 - 坐标转换 - 通知渲染器更新纹理 - 材质球显示新纹理。理解了这个流程就抓住了项目的命脉。2.2 渲染与着色器CanvasTexture.shader绘制最终要体现在屏幕上这离不开着色器。项目自带了一个名为“UnityCoder/CanvasTexture”的着色器。这个着色器并不复杂主要作用是正确地将MobilePaint.cs修改后的Texture2D显示在UI Image或一个Quad网格上。这里有一个关键点为什么需要自定义Shader默认的Unity UI Image使用的标准着色器可能会包含一些我们不想要的光照计算或颜色混合。而这个自定义Shader通常被写为“Unlit/Texture”的变体确保纹理颜色被原样输出并且正确处理透明通道Alpha。在早期版本中用户反馈的“透明背景图片绘制异常”问题就是通过修改这个Shader添加了Blend SrcAlpha OneMinusSrcAlpha混合指令来解决的这使得带有Alpha通道的笔刷和画布能够正确混合。注意在处理移动端2D绘画时尽量使用简单的、无光照的着色器。这能减少GPU的负担尤其是在低端设备上能有效提升绘制帧率。2.3 工具类与工具函数PaintTools.cs随着版本迭代作者将一些通用的绘制算法从主控制器中剥离出来放到了PaintTools.cs中。这是一个很好的代码实践保持了主类的整洁。这个工具类可能包含以下功能DrawLine函数这是实现流畅笔迹的关键。用户手指滑动会产生一系列离散的点。如果只在每个点画圆笔迹会是断断续续的圆点。DrawLine函数通常使用Bresenham算法或其变种能在两个点之间填充像素形成连续的线条。FloodFill函数即“油漆桶”填充算法。经典的递归或基于栈/队列的种子填充算法用于将一块连续的同色区域替换为目标颜色。这里需要注意性能对于大画布递归可能导致栈溢出因此迭代法是更安全的选择。颜色比较函数用于油漆桶算法中判断像素颜色是否相似。简单的RGB欧氏距离计算可能会把相近的色调误判为不同因此后期版本引入了“阈值Threshold”概念允许一定范围内的颜色容差。2.4 输入适配与UI缩放GUIScaler.cs针对旧版UI在较早的版本使用Unity旧版GUI或4.6之前的uGUIGUIScaler.cs用于解决不同屏幕分辨率下的UI布局问题。它会根据当前屏幕分辨率与一个预设参考分辨率如1920x1080的比值动态缩放UI元素的位置和大小确保按钮、调色板在所有设备上都能处于相对合适的位置。在较新的版本中如果项目升级到了Unity的Canvas系统这部分功能大概率被废弃取而代之的是Canvas的Canvas Scaler组件它提供了更强大、更便捷的UI自适应方案。3. 核心实现原理与关键技术点理解了架构我们深入到代码层面看看几个最核心的技术是如何实现的。这是从“会用”到“会改”的关键一步。3.1 从触摸到像素坐标转换的奥秘这是整个绘画系统的基石也是最容易出错的地方。核心代码通常位于MobilePaint.Update()或一个专门的ConvertScreenToTextureCoord()函数中。// 伪代码逻辑 Vector2 touchPos Input.GetTouch(0).position; // 屏幕像素坐标 RectTransform canvasRect canvas.GetComponentRectTransform(); Vector2 localPoint; // 将屏幕坐标转换到Canvas RectTransform的本地坐标空间 RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect, touchPos, null, out localPoint); // 将本地坐标归一化为[0,1]的UV坐标 Vector2 uv new Vector2( (localPoint.x canvasRect.rect.width * 0.5f) / canvasRect.rect.width, (localPoint.y canvasRect.rect.height * 0.5f) / canvasRect.rect.height ); // 将UV坐标转换为纹理像素坐标 int texX Mathf.FloorToInt(uv.x * canvasTexture.width); int texY Mathf.FloorToInt(uv.y * canvasTexture.height); // 确保坐标在纹理范围内 texX Mathf.Clamp(texX, 0, canvasTexture.width - 1); texY Mathf.Clamp(texY, 0, canvasTexture.height - 1);关键点与避坑指南坐标系差异屏幕坐标系原点在左下角而纹理/UV坐标系原点通常在左下角或左上角取决于APIRectTransform的本地坐标系中心点在Pivot点。必须清楚每一步转换在做什么。画布缩放与偏移如果画布没有铺满全屏或者有偏移必须在转换中考虑。MobilePaint中的canvasSizeAdjust、offsetX、offsetY等变量就是用来微调这个映射关系的。性能每帧对每个触摸点都进行这个计算。确保它高效避免在转换函数中进行复杂的运算或内存分配。3.2 实时纹理绘制GetPixels与SetPixels的权衡在CPU上修改纹理通常有两种方式GetPixel/SetPixel和GetPixels/SetPixels。GetPixel/SetPixel单点读写简单直观但性能极差。因为每次调用都是一次从CPU到GPU的通信频繁调用会造成巨大的开销。GetPixels/SetPixels一次性读写整个或部分纹理到一个Color[]数组在内存中修改这个数组最后再一次性应用回纹理。这是正确的高性能做法。Mobile Paint采用的就是后者。它通常会维护一个与画布纹理同尺寸的Color[]数组canvasPixels。当需要绘制时只修改这个数组中的相应颜色值。在一帧的绘制操作结束后或在固定间隔调用canvasTexture.SetPixels(canvasPixels)和canvasTexture.Apply()来更新GPU中的纹理。// 伪代码示例绘制一个圆点 void DrawBrushPoint(int centerX, int centerY, Color color, int brushSize) { int startX Mathf.Max(0, centerX - brushSize); int endX Mathf.Min(textureWidth - 1, centerX brushSize); int startY Mathf.Max(0, centerY - brushSize); int endY Mathf.Min(textureHeight - 1, centerY brushSize); for (int y startY; y endY; y) { for (int x startX; x endX; x) { // 计算当前像素到圆心的距离 float distance Mathf.Sqrt((x - centerX) * (x - centerX) (y - centerY) * (y - centerY)); if (distance brushSize) { // 计算数组索引注意纹理数据通常是一维数组按行存储 int index y * textureWidth x; // 简单的颜色覆盖实际可能涉及Alpha混合 canvasPixels[index] color; } } } textureNeedsUpdate true; // 标记纹理需要更新 } void LateUpdate() { if (textureNeedsUpdate) { canvasTexture.SetPixels(canvasPixels); canvasTexture.Apply(); textureNeedsUpdate false; } }实操心得Apply()函数比较耗时尤其是对于大纹理。不要每画一个点就调用一次。最佳实践是设置一个脏标记textureNeedsUpdate在LateUpdate或协程中集中更新比如每帧只Apply一次。对于追求极致流畅度的应用甚至可以尝试使用Texture2D.SetPixelData配合AsyncGPUReadback较新Unity版本进行更高级的异步操作但这会大幅增加复杂度。3.3 笔刷与混合模式基础的笔刷就是上面提到的圆形实色填充。但项目也支持自定义笔刷纹理。实现原理是预加载一个笔刷纹理同样获取其Color[]数组。在绘制时遍历笔刷纹理的每个像素将其颜色根据Alpha混合到画布目标区域的像素上。颜色混合是一个大学问。最简单的覆盖模式是直接替换目标像素颜色destColor brushColor。但更常见的是Alpha混合模拟真实的绘画叠加效果// 简单的Alpha混合 float alpha brushColor.a * brushAlphaStrength; // brushAlphaStrength用于控制强度 canvasPixels[index] Color.Lerp(canvasPixels[index], brushColor, alpha);项目中的useAdditiveColors和BrushAlphaStrength等变量就是用来控制这种混合行为的。useAdditiveColors为true时颜色会逐渐叠加变深模拟反复涂抹为false时单次绘制就达到目标颜色。3.4 油漆桶Flood Fill算法实现这是一个经典的计算机图形学算法。Mobile Paint中实现的通常是扫描线种子填充算法的变种它比简单的递归填充更节省栈空间效率更高。基本思路将起始种子点压入栈。当栈不为空时弹出一个点。向左向右遍历该点所在的行找到需要填充的连续线段并填充它们。检查上一行和下一行的相邻像素如果符合填充条件颜色在阈值范围内则将每一行线段的起始点作为新的种子点压入栈。重复步骤2-4。关键参数——颜色容差Threshold这是用户反馈中强烈要求的功能。因为图片经过压缩或缩放线条边缘可能不是纯色而是有渐变的。如果没有容差油漆桶无法填充这些区域。容差计算通常是比较目标颜色和当前像素颜色的RGB差值是否小于某个阈值。bool IsColorSimilar(Color c1, Color c2, float threshold) { float diff Mathf.Abs(c1.r - c2.r) Mathf.Abs(c1.g - c2.g) Mathf.Abs(c1.b - c2.b); return diff threshold; // 阈值例如0.1 }4. 性能优化与移动端适配实战移动设备资源有限绘画应用又是实时操作性能优化至关重要。Mobile Paint源码中体现或我们可以借鉴的优化点如下4.1 纹理尺寸与内存管理这是最大的性能瓶颈。一个2048x2048的RGBA32纹理占用内存约为2048 * 2048 * 4 bytes ≈ 16 MB。对于中低端手机这压力不小。动态分辨率可以根据设备内存或屏幕实际像素密度动态设置画布纹理大小。例如对于填色书应用1024x1024可能已经足够清晰。纹理格式如果不需要Alpha通道使用RGB24而非RGBA32可以节省1/4内存。在Unity中创建纹理时可以通过TextureFormat参数指定。及时释放清空画布时不要创建新纹理而是复用已有的Color[]数组并填充为背景色。切换画布时注意销毁旧的纹理资源。4.2 绘制区域优化我们不需要在每一帧都更新整个纹理甚至不需要更新整个笔刷区域。脏矩形更新记录本次绘制操作影响到的纹理矩形区域最小包围盒只调用SetPixels的重载版本更新这个矩形区域。canvasTexture.SetPixels(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height, partialPixels);这能显著减少Apply()的数据传输量。Mobile Paint的早期版本可能没有这么做但这是我们二次开发时可以加入的强力优化。笔刷采样优化对于大尺寸的软边笔刷可以不用遍历整个圆形区域。可以根据笔刷纹理的Alpha图只处理Alpha值大于某个阈值的像素。4.3 输入与渲染帧率解耦触摸采样率每秒上百次可能远高于屏幕刷新率通常60Hz。如果每收到一个触摸点就立即处理并Apply会造成大量无效计算。缓冲与插值将触摸点存入一个列表在Update中收集在LateUpdate中统一处理。对于连续线条可以在处理时对稀疏的点进行插值保证线条平滑。协程更新可以将纹理的Apply()操作放在协程中每N帧执行一次例如每2帧在性能紧张的设备上能平滑帧率但会引入轻微的绘制延迟需要权衡。4.4 针对不同设备的调试多线程渲染问题在部分Android设备上从非主线程调用Texture2D.Apply()可能导致崩溃。确保所有纹理操作都在主线程进行。电量与发热频繁的CPU计算和GPU纹理更新是耗电大户。在应用进入后台或画板闲置时应暂停绘制循环。内存泄漏检查使用Unity Profiler监控纹理内存。确保动态创建的纹理在场景切换或对象销毁时被正确释放Destroy(texture)或Resources.UnloadAsset。5. 基于源码的二次开发与功能扩展研究老项目的价值在于继承和扩展。以下是几个基于Mobile Paint源码进行功能增强的实用方向。5.1 实现图层Layer系统这是专业绘画工具的核心功能。我们可以这样设计数据结构创建一个PaintLayer类包含自己的Texture2D、Color[]数组、混合模式、可见性等属性。管理层在MobilePaint中维护一个ListPaintLayer。当前绘制操作只影响“当前激活图层”。合并渲染在最终显示前需要将所有可见图层的纹理按混合模式合并到一个最终纹理上。这可以在CPU端逐像素计算也可以编写一个多Pass的Shader在GPU端完成后者性能更高但更复杂。优化合并操作不需要每帧进行。可以设置一个脏标记只有当某个图层发生变化时才在下一帧触发合并。5.2 添加更多笔刷与动态参数笔刷资源包将笔刷预制为ScriptableObject资产包含笔刷纹理、大小范围、硬度、间距、散布等参数。压力感应如果设备支持如部分三星Note系列、iPad Pro with Apple Pencil可以通过Input.GetTouch().pressure获取压力值并动态调整笔刷大小或Alpha强度。笔刷间距记录上一次绘制的位置当移动距离超过“间距”参数时才绘制下一个笔刷图案避免图案重叠过密。5.3 实现更强大的撤销/重做Undo/RedoMobile Paint自带的DoUndo()只支持一步撤销。一个完整的撤销系统需要命令模式。定义命令接口IPaintCommand包含Execute()和Undo()方法。具体命令创建DrawLineCommand、FillCommand等记录操作影响的像素区域以及操作前后的颜色数据。命令历史维护两个栈undoStack和redoStack。每次绘制操作产生一个命令对象执行后压入undoStack。撤销时从undoStack弹出命令执行Undo()并压入redoStack。内存优化全纹理快照内存占用太大。应记录“脏矩形”内的像素变化命令对象只保存这个矩形区域的数据。5.4 保存与分享功能保存为图片使用Texture2D.EncodeToPNG()或EncodeToJPG()将最终的canvasTexture转换为字节流然后使用System.IO.File.WriteAllBytes保存到设备持久化路径。分享调用Android的Intent或iOS的UIActivityViewController将保存的图片文件路径传递给系统分享组件。这需要编写平台原生插件或使用Unity的社交分享插件如Unity的SocialAPI或第三方插件。6. 常见问题排查与调试技巧在实际使用或修改Mobile Paint源码时你肯定会遇到问题。下面是我踩过的一些坑和解决方法。6.1 绘制位置不准或偏移症状手指触摸的位置和画笔显示的位置对不上尤其在屏幕边缘。排查步骤检查坐标转换代码在ConvertScreenToTextureCoord函数中打印每一步的坐标值Debug.Log对比触摸输入、转换后的UV、最终的纹理坐标。确保画布的RectTransform锚点Anchors和轴心Pivot设置正确通常需要铺满全屏且轴心在中心(0.5,0.5)。检查Canvas渲染模式如果使用Unity UI确认Canvas的Render Mode。Screen Space - Overlay模式坐标转换最直接。如果是World Space还需要考虑相机。检查MobilePaint脚本参数确认canvasSizeAdjust、offsetX/Y等参数是否被意外修改。可以尝试将这些参数重置为0或默认值。6.2 绘制卡顿帧率下降症状在画复杂线条时游戏明显变卡。排查步骤使用Profiler打开Unity ProfilerWindow Analysis Profiler重点观察CPU的Gfx.WaitForPresentGPU瓶颈和CanvasRenderer.BuildBatchUI重建瓶颈。如果Apply()耗时过长会是CPU主因。降低纹理分辨率临时将画布纹理尺寸减半如从2048改为1024看卡顿是否缓解。如果是说明纹理太大。优化绘制循环检查是否每帧调用GetTouch太多次是否在循环内进行了不必要的计算或内存分配如new Vector2确保Apply()只在纹理真正需要更新时调用。检查笔刷大小一个巨大的笔刷如半径100像素意味着每帧要修改上万个像素点。考虑对笔刷大小设置上限或优化大笔刷的绘制算法如使用模糊后的圆形贴图代替实时计算。6.3 颜色显示异常或透明通道问题症状笔刷颜色比选择的浅如红色变粉色或者在有透明背景的图片上绘制时出现黑边、白边。排查步骤检查混合模式回顾3.3节的颜色混合公式。如果BrushAlphaStrength设置过小或笔刷纹理本身Alpha值低会导致颜色叠加不饱和。尝试将BrushAlphaStrength调大或检查笔刷纹理的导入设置确保Alpha通道正确。检查着色器确认画布和笔刷使用的材质球其Shader是否正确支持Alpha混合。对于透明背景绘制Shader中必须有正确的Blend指令如Blend SrcAlpha OneMinusSrcAlpha。检查纹理导入设置在Unity Project面板选中画布或笔刷纹理在Inspector中检查“Alpha Is Transparency”是否勾选。对于非2D Sprite用途的纹理有时需要取消勾选“sRGB (Color Texture)”并设置“Wrap Mode”为Clamp。6.4 油漆桶填充溢出或填充不全症状填充区域跑到线条外面去了或者该填满的区域有残留白点。排查步骤调整颜色阈值Threshold这是最常见的原因。线条边缘有抗锯齿或压缩噪点颜色不纯。增大FloodFill函数中的颜色容差阈值。可以在UI上暴露一个滑块让用户调节。检查填充算法的边界条件确保算法在遍历时正确地判断了纹理的边界x0 xwidth。使用参考纹理填充如源码讨论中用户Ostwind提到的需求有时需要根据原始线稿而非当前绘制结果进行填充。这需要修改算法在判断像素是否可填充时对比的是原始“掩膜”纹理的颜色而不是当前画布纹理的颜色。这需要维护一份干净的线稿纹理副本。6.5 在编辑器下鼠标绘制正常但真机上触摸无反应症状PC上测试完美打包到手机后画不了。排查步骤检查输入开关在Mobile Paint脚本的Inspector面板上检查enableTouch是否被勾选。有些版本可能默认只开启了鼠标绘制用于编辑器测试。检查触摸计数在手机端使用Input.touchCount来获取触摸数量。确保代码正确处理了多点触控Input.touches[i]。检查平台编译指令有时为了性能代码中可能使用了#if UNITY_EDITOR来包裹鼠标输入代码但忘记在#else部分正确实现触摸输入。仔细检查输入处理相关的代码块。权限问题仅限特定功能如果涉及保存到相册需要检查Android和iOS的存储权限是否已正确申请。最后面对这样一个有一定年头的项目最好的学习方式就是动手。我建议的路径是先原封不动地导入工程跑通所有Demo场景理解每个参数的作用。然后尝试修改它比如增加一个简单的调色板或者改变笔刷的形状。在这个过程中你会遇到上面提到的大部分问题而解决问题的过程就是你真正掌握移动端实时绘画技术的过程。这套源码的价值不在于它有多完美而在于它提供了一个清晰、可运行的起点让你可以沿着它去构建更复杂、更强大的创意工具。

相关新闻