Unity绘画板开发实战:从零构建动态网格绘制系统
1. 项目概述从零开始的Unity绘画板最近在社区里看到不少朋友对在Unity里实现一个绘画板功能很感兴趣这确实是个挺有意思的练手项目。它不像看起来那么简单背后涉及到UI交互、图形渲染、输入处理等多个Unity核心模块的协同工作。很多新手可能会直接去搜“Unity 画图插件”但我的建议是与其用现成的“黑盒”不如自己动手从零搭建一个。这个过程能让你对Unity的EventSystem、Mesh生成、材质Shader有更深刻的理解这才是真正的成长。今天我们就从这个系列的第一步——创建项目开始。别看这只是第一步项目初始的设置就像盖房子的地基配置得当后续开发会顺畅很多配置不当可能中途就得推倒重来。我们将创建一个专为2D绘画板优化的Unity项目并预先规划好核心的目录结构和必要的资源为后续的画笔逻辑、画布渲染等功能打下坚实基础。2. 项目创建与环境配置详解2.1 Unity Hub与编辑器版本选择工欲善其事必先利其器。第一步是打开Unity Hub。如果你还没有安装去Unity官网下载安装即可这是管理不同Unity版本和项目的门户。版本选择是关键。我强烈建议不要盲目追求最新版本。对于这样一个以2D UI和基础图形功能为主的项目选择一个成熟的、长期支持LTS版本是最稳妥的。比如2021.3 LTS或2022.3 LTS都是非常好的选择。它们经过了大量项目验证稳定性高社区资源插件、解决方案丰富能避免你掉进新版本一些未预料的坑里。在Hub中点击“新建项目”在模板选择页面务必选择“2D (Core)”模板。这个模板已经为我们预设好了2D项目相关的设置比如默认的2D渲染管线、Sprite的导入设置等能省去不少手动配置的麻烦。注意虽然Unity的通用渲染管线URP甚至高清渲染管线HDRP现在很火但对于一个2D绘画板内置的默认渲染管线Built-in Render Pipeline完全够用且复杂度最低更容易控制。我们后续涉及的自定义Shader在Built-in管线中也更容易编写和调试。因此除非你有明确的、需要URP高级特性的需求比如复杂的2D后期效果否则坚持使用“2D (Core)”模板自带的设置即可。给项目起个名字比如“MyDrawingBoard”选择一个干净的路径路径中最好不要有中文或特殊字符然后点击创建。Unity会初始化一段时间第一次打开可能会稍慢。2.2 初始项目结构梳理与优化项目创建完成后你会看到默认的Unity界面和Project窗口里的一些基础文件夹Assets,Packages,ProjectSettings等。一个清晰的项目结构是良好开发习惯的开端我们现在就来优化它。在Assets文件夹下我习惯创建如下子文件夹这能让资源管理井井有条_Scripts: 存放所有的C#脚本。前面的下划线让它能排在前面方便查找。_Scenes: 存放所有的场景文件。Art/Sprites: 存放UI图标、按钮背景等精灵图。Art/Brushes: 预留存放画笔纹理如果需要自定义画笔笔触形状。Materials: 存放材质球。绘画板的核心——画布和笔触的渲染都会用到材质。Prefabs: 存放预制体。比如我们将要创建的画布、画笔工具面板等。Resources: 如果需要动态加载资源可以放在这里。不过对于这个项目可能用不上。Shaders:这个文件夹非常重要。我们将在这里编写控制画笔绘制效果的Shader。Settings: 存放一些可脚本化对象ScriptableObject例如画笔配置、项目设置等。你可以在Project窗口右键点击Assets-Create - Folder来逐一创建。现在看起来可能有点空但随着项目推进你会庆幸早期做了这样的规划。特别是Shaders和Materials文件夹是我们后续实现绘画功能的核心区域。2.3 关键项目设置预设在开始动手写代码前还有几项关键的项目设置需要检查或调整它们会影响绘画的精度和性能。首先打开Edit - Project Settings...。Quality质量在左侧选择Quality。为了确保绘画的实时响应我们需要关闭一些不必要的后期效果。在对应的品质等级如Default设置中将Anti Aliasing抗锯齿暂时设为Disabled。虽然抗锯齿能让边缘平滑但它会引入延迟并且可能与我们自己实现的笔触混合算法产生干涉。我们可以在Shader层面实现更可控的平滑。同时确保VSync垂直同步设置为Don‘t Sync这能减少输入延迟让笔触更跟手。Input输入绘画板需要处理鼠标和触控如果发布到移动端。Unity的Input System新旧两套是个话题。对于这个入门到中级的项目使用经典的Input Manager旧系统就足够了它简单直接。确保Edit - Project Settings - Input Manager中的Mouse X/Y和Horizontal/Vertical等轴存在即可。我们主要用Input.GetMouseButton和Input.mousePosition来获取输入。Player播放器在Player Settings中找到Resolution and Presentation。如果目标是PC可以取消勾选Fullscreen Mode的默认全屏改为Windowed方便调试。更重要的是在Other Settings部分确保Color Space颜色空间是Gamma。对于2D项目和非PBR工作流Gamma空间足够了而且计算更轻量。如果设为Linear你可能会发现颜色混合和预期不符需要额外的Gamma校正。这些初始设置就像为赛车调校底盘虽然基础但决定了后续驾驶开发的“手感”。花几分钟配置好能避免很多后期难以排查的诡异问题。3. 构建绘画板核心场景框架3.1 画布Canvas与渲染层设置场景是内容的容器。我们首先创建一个空场景File - New Scene然后保存到_Scenes文件夹命名为Main。绘画板的核心是一个UI画布。在Hierarchy窗口右键 -UI - Canvas创建一个Canvas。Unity会自动同时创建一个EventSystem对象这是处理UI输入所必需的我们保留它。选中新建的Canvas在Inspector面板中我们需要关注几个关键组件Canvas:Render Mode设置为Screen Space - Overlay。这是最常用的UI渲染模式UI将直接绘制在屏幕最上层适合作为绘画工具面板的容器。但是我们的“绘画画布”本身不建议直接使用这个Canvas原因后面会讲。Canvas Scaler: 这个组件控制UI的缩放。对于绘画板我推荐将UI Scale Mode设置为Scale With Screen Size并设定一个参考分辨率例如1920x1080。这样在不同分辨率的屏幕上UI布局和绘画区域的相对比例能保持基本一致。将Match值设为0.5兼顾宽高或根据你的需求调整。Graphic Raycaster: 它允许UI响应点击事件。保留它我们的工具按钮需要它。现在这个Canvas将作为我们绘画UI的容器比如画笔颜色选择器、粗细滑块、清除按钮等。我们将在其下创建这些UI元素。但是真正的绘画区域我建议用一个独立的GameObject来处理而不是放在这个UI Canvas下。为什么因为我们要在画布上绘制大量的、动态生成的几何体线段、三角形来构成笔触这属于“世界空间”的图形渲染。如果混在UI层级里会受Canvas Scaler的影响并且深度排序、混合模式可能更复杂。3.2 创建绘画区域Drawing Board在Hierarchy中在Canvas之外创建一个空GameObject命名为DrawingBoard。我们可以重置它的Transform。这个DrawingBoard将是我们所有绘画逻辑的承载者。我们需要给它添加几个核心组件一个SpriteRenderer暂时不需要赋值Sprite或者更好的是直接添加一个MeshFilter和一个MeshRenderer。我们将通过代码动态生成一个Mesh来作为画布。MeshRenderer的材质我们将使用自定义的Shader来实现笔触的平滑、混合和橡皮擦效果。一个我们即将编写的C#脚本比如DrawingBoardController挂载上去它将负责捕获输入、管理笔触数据、生成网格等所有核心逻辑。为了让这个绘画区域在视觉上有一个边界或背景我们可以创建一个子GameObject添加SpriteRenderer并赋予一个简单的纯色或带边框的Sprite作为背景图。这个背景层和实际的绘画层Mesh通过Sorting Layer和Order in Layer来区分前后。3.3 基础UI控件布局回到我们的UI Canvas。我们需要一些基本的控件来与绘画板交互。在Canvas下创建以下UI元素通过右键 - UIButton命名为BtnClear文本改为“清除画布”。用于一键清空所有笔触。Slider命名为SliderBrushSize最小值0.1最大值5初始值1。用于调整画笔粗细。Image命名为ImgColorPreview作为一个颜色预览框。可以再创建一个Button点击后弹出颜色选择器Unity没有原生颜色选择器我们可以用一组预设颜色按钮或集成第三方资源简单实现。可以再创建几个Toggle作为画笔模式切换比如“画笔”和“橡皮擦”。简单地排列一下这些控件比如放在屏幕顶部或一侧。我们不需要现在就把UI做得非常精美功能可用即可重点是先把逻辑打通。给BtnClear添加一个空的OnClick事件我们稍后在脚本中绑定方法。至此我们的场景框架就搭好了一个用于UI交互的Canvas和一个用于实际绘画渲染的独立DrawingBoard对象。这种分离的结构职责清晰是项目稳健的第一步。4. 编写首个脚本与输入检测4.1 创建核心控制脚本在Assets/_Scripts文件夹下右键 -Create - C# Script命名为DrawingBoardController。双击在IDE如Visual Studio中打开。首先我们需要定义这个脚本的核心职责管理绘画状态是否正在绘画、当前画笔颜色、粗细、模式。捕获鼠标/触控输入。收集笔触点序列。根据点序列动态生成并更新Mesh以呈现笔触。我们先来搭建脚本的基本结构。引入必要的命名空间并定义一些关键变量using UnityEngine; using System.Collections.Generic; public class DrawingBoardController : MonoBehaviour { // 公开可调的参数 public Color brushColor Color.black; public float brushSize 1.0f; public bool isErasing false; // 当前是否为橡皮擦模式 // 内部状态 private bool isDrawing false; private ListVector3 currentStrokePoints new ListVector3(); // 当前笔触的所有点 private ListListVector3 allStrokes new ListListVector3(); // 记录所有笔触用于重绘或清除 // 渲染相关组件引用 private MeshFilter meshFilter; private MeshRenderer meshRenderer; private Mesh drawingMesh; // 材质相关 public Material drawingMaterial; // 在Inspector中赋值 void Start() { // 获取或添加MeshFilter和MeshRenderer组件 meshFilter GetComponentMeshFilter(); if (meshFilter null) meshFilter gameObject.AddComponentMeshFilter(); meshRenderer GetComponentMeshRenderer(); if (meshRenderer null) meshRenderer gameObject.AddComponentMeshRenderer(); // 创建新的Mesh并赋值 drawingMesh new Mesh(); drawingMesh.name DrawingMesh; meshFilter.mesh drawingMesh; // 设置材质 if (drawingMaterial ! null) meshRenderer.material drawingMaterial; else Debug.LogWarning(Drawing Material is not assigned. Please assign a material in the Inspector.); } }在Start方法中我们初始化了Mesh渲染所需的组件。注意drawingMaterial需要我们在Unity编辑器中将之前创建好的材质球例如使用我们后续编写的自定义Shader创建的材质拖拽到脚本的这个公共字段上。4.2 实现基础的输入检测逻辑绘画的核心是捕获连续的输入点。我们在Update方法中实现这个逻辑。这里采用经典的“按下-拖拽-抬起”三段式检测void Update() { // 检测鼠标左键或移动端的第一点触控 if (Input.GetMouseButtonDown(0)) { StartDrawing(); } if (isDrawing) { if (Input.GetMouseButton(0)) // 按住期间 { ContinueDrawing(); } else if (Input.GetMouseButtonUp(0)) // 松开 { EndDrawing(); } } } void StartDrawing() { // 第一步将输入屏幕坐标转换为绘画板本地坐标 Vector3 inputPoint Input.mousePosition; // 这里需要一个关键的坐标转换因为我们的DrawingBoard不在UI Canvas下Input.mousePosition是屏幕像素坐标。 // 我们需要将其转换到DrawingBoard对象所在的“世界空间”中。 // 假设我们的DrawingBoard位于世界原点且我们希望画布平面与屏幕平行。 // 一种简单的方法是使用Camera.main.ScreenToWorldPoint但需要处理Z轴。 // 更健壮的做法是使用射线检测但作为起步我们先做一个简单的假设转换 // 假设Canvas是Screen Space - Overlay而DrawingBoard在一个固定的世界Z位置如0。 // 我们创建一个临时的转换方法后续需优化 Vector3 worldPoint Camera.main.ScreenToWorldPoint(new Vector3(inputPoint.x, inputPoint.y, 10f)); // Z值需根据相机调整 worldPoint.z 0; // 假设画布在Z0平面 // 第二步重置当前笔触并添加第一个点 currentStrokePoints.Clear(); currentStrokePoints.Add(worldPoint); // 第三步更新状态 isDrawing true; // 第四步可以在这里初始化笔触的Mesh数据如初始化顶点、三角形列表 // 暂时留空后续在ContinueDrawing中统一处理 } void ContinueDrawing() { // 获取当前输入点并转换坐标同StartDrawing Vector3 inputPoint Input.mousePosition; Vector3 worldPoint Camera.main.ScreenToWorldPoint(new Vector3(inputPoint.x, inputPoint.y, 10f)); worldPoint.z 0; // 检查新点与上一个点的距离避免过于密集的点影响性能或重复的点 if (currentStrokePoints.Count 0) { float distance Vector3.Distance(currentStrokePoints[currentStrokePoints.Count - 1], worldPoint); if (distance 0.01f) // 设置一个最小距离阈值 return; } // 将新点加入当前笔触序列 currentStrokePoints.Add(worldPoint); // **核心根据 currentStrokePoints 更新Mesh** UpdateDrawingMesh(); } void EndDrawing() { isDrawing false; // 当前笔触结束将其存入历史记录 if (currentStrokePoints.Count 1) // 至少两个点才能构成一条线 { allStrokes.Add(new ListVector3(currentStrokePoints)); // 保存副本 } // 清空当前笔触为下一次做准备 currentStrokePoints.Clear(); // 可以在这里进行一次最终的Mesh更新或者ContinueDrawing中已经包含 Debug.Log(Stroke ended. Total points: (currentStrokePoints.Count)); }现在我们已经搭建起了输入检测的骨架。但是关键的UpdateDrawingMesh()方法还是空的坐标转换部分也非常简陋且有问题它依赖于主相机且Z值是硬编码的。这是我们接下来要解决的核心难题。4.3 坐标转换的陷阱与解决方案上面代码中ScreenToWorldPoint的用法是一个典型的坑点。它只有在特定相机设置下才有效。我们的DrawingBoard可能不在摄像机正前方或者摄像机是正交/透视模式都会影响结果。更可靠的方案是将绘画板视为一个位于世界中的平面通过射线检测Raycast来确定笔触点在其表面的位置。这要求我们的DrawingBoard有一个Collider比如Box Collider 2D或Mesh Collider。我们来改进一下给DrawingBoard对象添加一个Box Collider 2D组件并调整大小覆盖你希望的绘画区域。修改输入获取逻辑使用射线检测void StartDrawing() { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit2D hit Physics2D.Raycast(ray.origin, ray.direction); // 如果是3D Collider则用 Physics.Raycast if (hit.collider ! null hit.collider.gameObject this.gameObject) { // 命中了自己的Collider Vector3 localDrawingPoint transform.InverseTransformPoint(hit.point); // 转换为画布本地坐标 localDrawingPoint.z 0; // 确保在画布平面内 currentStrokePoints.Clear(); currentStrokePoints.Add(localDrawingPoint); // 存储本地坐标便于Mesh生成 isDrawing true; } } void ContinueDrawing() { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit2D hit Physics2D.Raycast(ray.origin, ray.direction); if (hit.collider ! null hit.collider.gameObject this.gameObject) { Vector3 localDrawingPoint transform.InverseTransformPoint(hit.point); localDrawingPoint.z 0; if (currentStrokePoints.Count 0) { float distance Vector3.Distance(currentStrokePoints[currentStrokePoints.Count - 1], localDrawingPoint); if (distance 0.01f) return; } currentStrokePoints.Add(localDrawingPoint); UpdateDrawingMesh(); } else { // 如果射线没有命中画布比如拖到了画布外也应该结束绘画吗 // 一种常见处理是即使鼠标移出画布只要没松开就继续尝试连接或者停止添加新点但保持绘画状态。 // 这里我们选择如果没命中就结束当前笔触。这更符合直觉。 if (isDrawing) { EndDrawing(); } } }这样无论DrawingBoard在场景中位于何处是什么旋转角度我们都能准确地获取到在其表面上的点击位置。使用InverseTransformPoint将世界坐标转换为画布自身的本地坐标这对于后续基于本地坐标系生成Mesh至关重要。5. 动态网格生成将点变成线5.1 理解笔触的网格化原理现在我们有了按顺序采集到的点序列currentStrokePoints本地坐标。如何将这些点变成屏幕上可见的、有粗细的线条这就是动态网格生成要解决的问题。一个直观的想法是把笔触看成一条连续的“带子”。每个输入点对应这条带子中心线上的一个点。我们需要在每个点处根据brushSize画笔粗细向垂直于前进方向的两侧各扩展一半宽度生成两个顶点。连接相邻两对顶点就构成了一个四边形两个三角形许多这样的四边形首尾相连就形成了连续的笔触。算法步骤简述遍历currentStrokePoints。对于第i个点非端点计算从点i-1到点i的方向向量。计算该方向向量的法线垂直方向。沿法线正负方向各偏移brushSize / 2得到该点处“带子”的两个边顶点左顶点和右顶点。将所有左顶点和右顶点按顺序存入顶点数组。根据顶点数组构造三角形索引数组。每两个相邻的线段4个顶点构成两个三角形。还可以为每个顶点分配UV坐标用于纹理映射比如实现笔刷纹理和颜色实现渐变或固定画笔色。5.2 实现UpdateDrawingMesh方法这是一个包含一定计算量的核心函数。我们逐步实现它private ListVector3 meshVertices new ListVector3(); private Listint meshTriangles new Listint(); private ListColor meshColors new ListColor(); void UpdateDrawingMesh() { if (currentStrokePoints.Count 2) { // 至少需要两个点才能生成一个四边形 return; } // 清空临时数据准备重新构建整个当前笔触的Mesh简单实现非增量更新 meshVertices.Clear(); meshTriangles.Clear(); meshColors.Clear(); int numPoints currentStrokePoints.Count; // 1. 生成顶点和颜色 for (int i 0; i numPoints; i) { Vector3 point currentStrokePoints[i]; Vector3 direction Vector3.zero; // 计算当前点的方向前后点的平均方向使转折处平滑 if (i 0) { // 起始点方向取向下一个点 direction (currentStrokePoints[i 1] - point).normalized; } else if (i numPoints - 1) { // 结束点方向取自上一个点 direction (point - currentStrokePoints[i - 1]).normalized; } else { // 中间点取前后方向的平均值 Vector3 dirToPrev (point - currentStrokePoints[i - 1]).normalized; Vector3 dirToNext (currentStrokePoints[i 1] - point).normalized; direction (dirToPrev dirToNext).normalized; } // 计算法线垂直于方向 Vector3 normal new Vector3(-direction.y, direction.x, 0).normalized; // 计算左右顶点偏移 Vector3 offset normal * (brushSize * 0.5f); // brushSize是总宽度一半是偏移量 Vector3 leftVertex point - offset; Vector3 rightVertex point offset; meshVertices.Add(leftVertex); meshVertices.Add(rightVertex); // 为顶点分配颜色 Color vertexColor isErasing ? new Color(0,0,0,0) : brushColor; // 橡皮擦可以理解为透明色 meshColors.Add(vertexColor); meshColors.Add(vertexColor); } // 2. 生成三角形索引 // 每个线段段两个连续点产生两个三角形一个四边形 for (int segment 0; segment numPoints - 1; segment) { int baseIndex segment * 2; // 当前段起始顶点在顶点数组中的索引 // 四边形由四个顶点构成left1, right1, left2, right2 // 三角形1: left1 - right1 - left2 meshTriangles.Add(baseIndex); // left1 meshTriangles.Add(baseIndex 1); // right1 meshTriangles.Add(baseIndex 2); // left2 // 三角形2: right1 - right2 - left2 meshTriangles.Add(baseIndex 1); // right1 meshTriangles.Add(baseIndex 3); // right2 meshTriangles.Add(baseIndex 2); // left2 } // 3. 应用数据到Mesh drawingMesh.Clear(); // 清除旧数据 drawingMesh.SetVertices(meshVertices); drawingMesh.SetColors(meshColors); drawingMesh.SetTriangles(meshTriangles, 0); // 子网格索引为0 drawingMesh.RecalculateNormals(); // 对于2D非光照渲染法线可能不需要但设置也无妨 drawingMesh.RecalculateBounds(); // 更新包围盒影响裁剪 }这个实现是一个基础版本。它将整个当前笔触重新生成网格。当笔触很长时每帧都全量更新可能效率不高。优化方向可以是增量更新只添加新线段对应的顶点和三角形或者使用LineRenderer结合自定义Shader但灵活性不如Mesh。对于入门项目全量更新在笔触点数不多几百个点时是完全可行的。5.3 材质与Shader初步准备Mesh有了还需要材质来着色。在Assets/Materials文件夹右键创建Material命名为DrawingMat。在Inspector中点击Shader下拉框选择Unlit/Color。这是一个最简单的着色器直接显示顶点颜色。将我们之前脚本中brushColor设置为红色运行游戏在画布上拖动你应该能看到红色的线条了但是Unlit/Color没有透明混合。要实现橡皮擦即绘制透明区域或者让笔触边缘羽化我们需要一个支持透明混合的Shader。我们可以创建一个自定义Shader。在Assets/Shaders文件夹右键 -Create - Shader - Unlit Shader命名为DrawingShader。用代码编辑器打开修改其片段着色器frag部分使其支持透明度。一个非常简单的支持顶点颜色和透明的Unlit Shader框架如下Shader Unlit/DrawingShader { Properties { _MainTex (Texture, 2D) white {} } SubShader { Tags { RenderTypeTransparent QueueTransparent } Blend SrcAlpha OneMinusSrcAlpha // 标准的Alpha混合 ZWrite Off // 关闭深度写入允许透明物体相互混合 LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; // 顶点颜色 }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; fixed4 color : COLOR; }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.color v.color; // 传递顶点颜色 return o; } fixed4 frag (v2f i) : SV_Target { // 简单输出顶点颜色纹理采样可以后续添加 fixed4 col i.color; return col; } ENDCG } } }将这个Shader赋给DrawingMat材质然后将DrawingMat拖拽到DrawingBoardController脚本的drawingMaterial字段上。现在你的画笔颜色应该能正常显示了并且因为开启了混合Blend和关闭了深度写入ZWrite Off透明的部分比如橡皮擦颜色alpha为0应该能与其他笔触正确混合。6. 连接UI与核心逻辑6.1 为UI控件绑定事件我们的UI按钮和滑块现在还只是摆设。我们需要让它们能控制DrawingBoardController中的参数。首先为DrawingBoardController脚本添加一些公共方法供UI调用// 在DrawingBoardController类中添加 public void SetBrushColor(Color newColor) { brushColor newColor; } public void SetBrushSize(float size) { brushSize size; } public void SetEraserMode(bool isEraser) { isErasing isEraser; // 可以在这里切换材质或颜色例如切换到白色或透明色 // 更优雅的做法是在Shader中根据一个属性切换这里我们先简单处理 // brushColor isEraser ? new Color(1,1,1,0) : someDefaultColor; } public void ClearAllStrokes() { allStrokes.Clear(); currentStrokePoints.Clear(); drawingMesh.Clear(); // 清空Mesh isDrawing false; }然后在Unity编辑器中为UI控件配置事件监听选中BtnClear按钮在Inspector的Button组件下方点击OnClick()事件列表的号。将DrawingBoard对象从Hierarchy拖拽到事件对象的框里。在函数选择下拉框中找到DrawingBoardController - ClearAllStrokes()。选中SliderBrushSize滑块在Slider组件的OnValueChanged事件中同样拖拽DrawingBoard对象选择DrawingBoardController - SetBrushSize(float)。注意滑块的动态浮点值会自动作为参数传入。对于颜色选择我们可以先做几个预设颜色按钮。创建几个Button为每个按钮的OnClick事件绑定SetBrushColor并手动设置参数为Color.red,Color.green,Color.blue等。对于“画笔/橡皮擦”Toggle可以绑定SetEraserMode(bool)Toggle的isOn状态会自动作为布尔参数传入。6.2 实现简单的颜色选择器如果觉得预设按钮不够可以快速实现一个简单的颜色选择器。创建一个新的脚本SimpleColorPickerusing UnityEngine; using UnityEngine.UI; public class SimpleColorPicker : MonoBehaviour { public DrawingBoardController drawingBoard; // 引用绘画板控制器 public Image colorPreviewImage; // 颜色预览UI Image public Slider hueSlider; // 色相滑块 public Slider saturationSlider; // 饱和度滑块 public Slider valueSlider; // 明度滑块 private Color currentColor Color.red; void Start() { // 初始化滑块事件 hueSlider.onValueChanged.AddListener(OnHSVChanged); saturationSlider.onValueChanged.AddListener(OnHSVChanged); valueSlider.onValueChanged.AddListener(OnHSVChanged); UpdateColorPreview(); } void OnHSVChanged(float value) { // 将HSV转换为RGB currentColor Color.HSVToRGB(hueSlider.value, saturationSlider.value, valueSlider.value); UpdateColorPreview(); if (drawingBoard ! null) drawingBoard.SetBrushColor(currentColor); } void UpdateColorPreview() { if (colorPreviewImage ! null) colorPreviewImage.color currentColor; } }在场景中创建一个面板放置三个Slider和预览Image挂载此脚本并赋值对应引用。这样一个基础的HSV颜色选择器就完成了。当然你也可以使用Unity的ColorPicker第三方插件或更复杂的UI方案。7. 性能优化与常见问题排查7.1 性能瓶颈分析与优化策略当笔触变得非常复杂点数成千上万时我们当前的实现可能会遇到性能问题CPU端每帧全量重建MeshSetVertices、SetTriangles等操作在点数多时开销大。GPU端顶点数过多绘制调用Draw Call虽然只有一个如果只有一个材质但顶点处理负担重。优化建议增量更新不要每帧清空整个Mesh。可以只追加新线段对应的顶点和三角形。这需要更复杂的数据管理但能显著减少CPU开销。维护一个全局的顶点/三角形列表EndDrawing时才将当前笔触的数据合并进去。点采样优化在ContinueDrawing中我们设置了最小距离阈值0.01f来避免过于密集的点。可以适当增大这个阈值或者在移动速度快时动态调整用更少的点来近似平滑曲线。笔触简化算法在笔触结束时EndDrawing中对currentStrokePoints应用道格拉斯-普克算法等折线简化算法用更少的点保留主要形状再生成Mesh。这能大幅减少最终顶点数。分批次渲染如果笔触真的非常多可以考虑将不同笔触或笔触分段放在不同的Mesh或SubMesh中但需要权衡Draw Call的增加。使用Job System和Burst Compiler对于超大量的顶点计算如实时笔触平滑、物理效果可以考虑使用Unity的C# Job System来并行处理并用Burst编译提升性能。但这属于高级主题。对于大多数情况控制单条笔触的点数比如不超过500个并且全量更新Mesh性能是完全可接受的。7.2 常见问题与解决方案速查表在开发过程中你可能会遇到以下问题问题现象可能原因解决方案画不出线Mesh为空1.UpdateDrawingMesh中currentStrokePoints点数不足。2. 射线检测未命中DrawingBoard的Collider。3. 材质Shader编译错误或未赋值。1. 检查ContinueDrawing中距离阈值是否过大确保能添加点。2. 确认DrawingBoard有Collider且射线检测代码正确。在StartDrawing和ContinueDrawing中打印hit.point调试。3. 检查Console是否有Shader错误。在Inspector中确认材质球已赋给MeshRenderer。线条断裂、不连续1. 点采样间隔不均匀或ContinueDrawing中因未命中Collider而意外调用了EndDrawing。2. 网格生成算法中法线计算错误导致顶点位置异常。1. 调整采样逻辑确保在画布内拖动时能持续添加点。检查射线检测在画布边缘是否失效可适当扩大Collider。2. 调试显示每个点的左右顶点位置。确保方向向量计算正确特别是在笔触拐弯处。线条边缘锯齿严重1. 使用的Shader不支持抗锯齿。2. 画笔粗细brushSize太小顶点数不足。1. 在自定义Shader中开启MSAA多采样抗锯齿或在Unity的Quality设置中开启。对于自定义Mesh可以在片段着色器中进行屏幕空间抗锯齿SSAA后处理但这较复杂。2. 适当增加brushSize或在网格生成时在拐角处添加更多顶点使过渡平滑。橡皮擦功能无效画不上透明1. 顶点颜色Alpha值未设置为0。2. Shader的混合模式未正确设置或深度测试导致透明部分被遮挡。1. 确认SetEraserMode中设置的brushColor的a通道为0。2. 确认Shader中Blend SrcAlpha OneMinusSrcAlpha和ZWrite Off已设置。确保绘画板物体的渲染队列Queue在透明物体队列。笔触有重叠或深度冲突多个笔触或同一笔触的不同部分因深度值相同而出现渲染顺序问题。在生成顶点时为每个顶点赋予一个微小的、递增的Z值偏移如localDrawingPoint.z i * 0.001f但要注意这可能会影响射线检测。更通用的做法是确保Shader中ZWrite Off并依赖正确的渲染队列Queue排序。在UI按钮上点击也开始绘画事件冒泡导致点击UI时也触发了画布的射线检测。在StartDrawing和ContinueDrawing的射线检测前先检查鼠标是否在UI上。可以使用EventSystem.current.IsPointerOverGameObject()来判断。如果是则直接返回不开始绘画。7.3 调试技巧与心得善用Debug Draw在Update中可以使用Debug.DrawLine或Debug.DrawRay来可视化射线、点位置和法线方向。这对于验证坐标转换和算法逻辑非常直观。在Scene视图调试运行游戏时在Scene视图可以选中DrawingBoard查看其Mesh Filter组件中实时生成的网格检查顶点位置是否正确。分段测试不要试图一次性写完所有功能。先确保能正确采集点打印点坐标再确保能生成一个简单的线段Mesh固定两个点最后再实现连续笔触和优化。材质球调试创建一个临时材质使用Unlit/Texture并赋予一个棋盘格纹理可以很容易看出UV是否正确或者网格是否拉伸。创建项目这一步远不止是点击“新建”那么简单。从模板选择、结构规划到场景搭建、输入处理、核心算法实现再到UI联动和问题排查每一步都藏着细节和选择。这个绘画板项目虽然基础但它像一根线串起了Unity开发中的多个重要知识点。当你看到屏幕上随着鼠标移动出现第一根自己生成的线条时那种成就感就是驱动我们不断探索的动力。在接下来的部分我们会深入画笔纹理、压力感应如果支持数位板、撤销重做、画布保存等更高级的功能。