Unity动态纹理绘制实现高效鼠标签名功能:从原理到优化
2026/8/3 10:24:00 网站建设 项目流程

1. 项目概述:从“画线”到“签名”的完整闭环

最近在做一个需要用户确认的交互项目,客户提了个需求:能不能让用户在屏幕上用鼠标“签个名”?听起来简单,不就是画条线嘛。但真做起来,你会发现这远不止是画线那么简单。它涉及到从鼠标轨迹的实时捕捉、平滑处理,到笔触效果的模拟,再到最终签名图片的生成与保存,是一个完整的交互闭环。市面上虽然有一些现成的插件,但要么功能臃肿,要么定制性不够,或者存在一些性能上的小毛病。于是,我决定自己动手,在Unity里从头实现一个轻量、高效且效果不错的鼠标画线签名功能。

这个功能的核心价值在于,它为用户提供了一种直观、自然且具有法律或仪式感的确认方式。无论是电子合同签署、意见反馈确认,还是游戏内的个性化签名,都能用上。实现它,你需要掌握Unity基本的输入处理、图形绘制(LineRenderer或GL)、纹理动态生成以及数据序列化等知识。下面,我就把这次实现过程中的核心思路、技术细节、踩过的坑以及优化心得,毫无保留地分享出来。

2. 核心思路与方案选型:为什么不用简单的LineRenderer?

接到需求,很多人的第一反应可能就是使用Unity自带的LineRenderer组件。这确实是最快能让线“画”出来的方法。但经过评估,我放弃了它,主要原因有三点。

2.1 LineRenderer的局限性分析

首先,LineRenderer虽然方便,但它本质是一个3D物体,每帧更新其positionCountpositions数组来添加新点,在绘制大量短线段(即签名这种高频添加点的操作)时,会产生可观的GC(垃圾回收)压力。虽然可以通过对象池优化,但治标不治本。

其次,LineRenderer的笔触样式调整相对局限。要实现类似毛笔的起笔收笔效果、压力感应(虽然鼠标无压感,但可以通过速度模拟),或者更复杂的纹理平铺,都需要额外的计算和Shader编写,不够直接。

最后,也是最重要的,我们的目标是生成一张签名图片。用LineRenderer画出来的东西,是场景中的3D/2D物体,要把它“拍”下来转换成Texture2D,需要用到Camera.RenderRenderTexture,再转换,过程繁琐且性能开销较大,尤其是在需要实时生成多张签名时。

2.2 最终方案:动态纹理绘制(Drawing on Texture)

我选择的方案是:动态地在Texture2D上“画”像素。这个方案听起来底层,但非常高效和灵活。

  1. 画布准备:创建一张指定大小(如512x128)的透明Texture2D,作为我们的签名画布。
  2. 输入采样:在Update中监听鼠标输入(Input.GetMouseButton),获取鼠标在屏幕上的位置。
  3. 坐标转换:将屏幕坐标转换到画布纹理的UV坐标空间。这里的关键是要处理屏幕分辨率与纹理分辨率不一致的问题,以及UI遮挡。
  4. 绘制算法:根据当前鼠标位置和上一帧位置,在纹理的对应像素区域进行着色。不是只画一个点,而是画一条连接两点的、具有宽度的“线段”,以避免笔迹断断续续。
  5. 纹理应用:将绘制好的Texture2D赋值给一个RawImage UI组件,实时显示签名效果。
  6. 输出保存:签名完成后,直接对这张Texture2D进行编码(如PNG),保存为图片文件,或者转换为Base64字符串上传服务器。

这个方案的优点非常突出:零GC压力(纹理像素操作在非托管端)、输出极其简单(纹理本身就是图片)、笔触效果无限可能(通过像素着色算法控制)。缺点是需要自己处理绘制逻辑,但核心算法并不复杂。

2.3 辅助方案:GL即时模式绘图(备选)

在早期原型阶段,我也尝试过使用GL库进行即时模式绘图。GL.Begin(GL.LINES)/GL.End()可以在OnPostRender中直接绘制线段到屏幕。它的优点是绘制调用非常快,适合需要复杂几何线条但不需要保存为独立纹理的场景。但是,GL绘制的内容在常规的截图或RenderTexture捕获中比较“脆弱”,且与现代的URP/HDRP渲染管线兼容性需要额外处理。因此,它更适合作为动态预览,而将动态纹理绘制作为最终数据产出的方案。在本项目中,我以动态纹理绘制为主线进行讲解。

3. 核心模块实现细节拆解

确定了动态纹理绘制的方案,我们来深入每个环节的实现细节和注意事项。

3.1 画布初始化与配置

创建一个SignaturePad的MonoBehaviour脚本。在StartAwake中初始化画布纹理。

public class SignaturePad : MonoBehaviour { public RawImage signatureDisplay; // UI上用于显示的RawImage public int textureWidth = 512; public int textureHeight = 128; public Color drawColor = Color.black; private Texture2D signatureTexture; private Color[] clearPixels; // 用于清空的像素数组 void Start() { // 1. 创建可读写的纹理 signatureTexture = new Texture2D(textureWidth, textureHeight, TextureFormat.RGBA32, false); signatureTexture.filterMode = FilterMode.Bilinear; // 过滤模式,使线条平滑 signatureTexture.wrapMode = TextureWrapMode.Clamp; // 2. 初始化纹理为全透明 clearPixels = new Color[textureWidth * textureHeight]; for (int i = 0; i < clearPixels.Length; i++) { clearPixels[i] = Color.clear; } signatureTexture.SetPixels(clearPixels); signatureTexture.Apply(); // 应用更改到GPU // 3. 赋值给UI显示 if(signatureDisplay != null) signatureDisplay.texture = signatureTexture; } }

注意TextureFormat.RGBA32是保证透明度通道可用的常用格式。FilterMode.Bilinear能在纹理放大缩小时让线条边缘更平滑,避免锯齿。初始化时用SetPixels批量填充比逐像素SetPixel高效得多。

3.2 鼠标轨迹采样与坐标转换

这是最容易出bug的环节。鼠标屏幕坐标(Input.mousePosition)的原点在屏幕左下角,而UI系统(RectTransform)的坐标原点可能在不同位置(如中心点)。我们需要将鼠标位置转换到signatureDisplay纹理的UV空间(0,1)。

private Vector2 previousMousePos; // 上一帧鼠标位置(纹理UV坐标) void Update() { if (Input.GetMouseButton(0)) // 按住左键 { // 判断鼠标是否在签名区域 if (IsMouseOverSignatureArea()) { Vector2 currentMouseUV = GetMousePositionInUV(); if (Input.GetMouseButtonDown(0)) { // 按下瞬间,只画一个点 DrawPoint(currentMouseUV); } else { // 拖动过程,画一条从上一帧到当前帧的线段 DrawLine(previousMousePos, currentMouseUV); } previousMousePos = currentMouseUV; signatureTexture.Apply(); // 每帧绘制后更新纹理 } } else if (Input.GetMouseButtonUp(0)) { previousMousePos = Vector2.zero; } } // 判断鼠标是否在显示签名的UI区域内 private bool IsMouseOverSignatureArea() { if (signatureDisplay == null) return false; RectTransform rectTransform = signatureDisplay.rectTransform; Vector2 localPoint; // 将屏幕坐标转换到RawImage的本地坐标空间 return RectTransformUtility.ScreenPointToLocalPointInRectangle( rectTransform, Input.mousePosition, null, out localPoint); } // 获取鼠标在纹理UV空间中的位置 private Vector2 GetMousePositionInUV() { if (signatureDisplay == null) return Vector2.zero; RectTransform rectTransform = signatureDisplay.rectTransform; Vector2 localPoint; RectTransformUtility.ScreenPointToLocalPointInRectangle( rectTransform, Input.mousePosition, null, out localPoint); // RectTransform的pivot(中心点)会影响localPoint的范围 // 假设pivot为(0.5,0.5),即中心对齐 Rect rect = rectTransform.rect; // 将localPoint从Rect本地空间归一化到[0,1] float u = (localPoint.x - rect.x) / rect.width; float v = (localPoint.y - rect.y) / rect.height; // 钳制在[0,1]范围内,防止越界绘制 u = Mathf.Clamp01(u); v = Mathf.Clamp01(v); return new Vector2(u, v); }

实操心得RectTransformUtility.ScreenPointToLocalPointInRectangle是处理UI交互坐标转换的神器,务必掌握。要特别注意UI元素的AnchorPivot设置,它们会直接影响rectx,y,width,height值。在开发初期,建议将转换后的UV坐标打印出来,并绘制一个Debug点来验证转换是否正确。

3.3 核心绘制算法:从点到线

这是项目的灵魂。我们不能只绘制当前UV坐标对应的单个像素,那样笔迹会是离散的点。必须绘制一条连接previousMousePoscurrentMouseUV的、有宽度的线段。

3.3.1 Bresenham画线算法及其优化

在像素层面画线,经典的Bresenham算法非常高效。但这里我们需要画一条“粗线”。我的实现思路是:先用Bresenham算法计算出线段经过的所有中心像素点,然后以这些点为中心,绘制一个半径为brushRadius的圆形笔刷。

public int brushRadius = 3; // 笔刷半径(像素) private void DrawLine(Vector2 startUV, Vector2 endUV) { // 将UV坐标转换为纹理像素坐标 int x0 = Mathf.RoundToInt(startUV.x * (textureWidth - 1)); int y0 = Mathf.RoundToInt(startUV.y * (textureHeight - 1)); int x1 = Mathf.RoundToInt(endUV.x * (textureWidth - 1)); int y1 = Mathf.RoundToInt(endUV.y * (textureHeight - 1)); int dx = Mathf.Abs(x1 - x0); int dy = Mathf.Abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; int sy = (y0 < y1) ? 1 : -1; int err = dx - dy; while (true) { // 在每一个线条路径上的点,绘制一个笔刷圆 DrawBrushCircle(x0, y0); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } } private void DrawBrushCircle(int centerX, int centerY) { // 避免越界 int startX = Mathf.Max(centerX - brushRadius, 0); int endX = Mathf.Min(centerX + brushRadius, textureWidth - 1); int startY = Mathf.Max(centerY - brushRadius, 0); int endY = Mathf.Min(centerY + brushRadius, textureHeight - 1); int radiusSqr = brushRadius * brushRadius; for (int x = startX; x <= endX; x++) { for (int y = startY; y <= endY; y++) { // 计算当前像素到圆心的距离平方 int dx = x - centerX; int dy = y - centerY; if (dx * dx + dy * dy <= radiusSqr) { // 设置像素颜色 signatureTexture.SetPixel(x, y, drawColor); } } } } private void DrawPoint(Vector2 uv) { int x = Mathf.RoundToInt(uv.x * (textureWidth - 1)); int y = Mathf.RoundToInt(uv.y * (textureHeight - 1)); DrawBrushCircle(x, y); }

性能警告:上面的DrawBrushCircle使用了双重循环,并且在DrawLine的每一步都可能调用。当brushRadius较大或绘制速度很快时,这会成为性能瓶颈。在Update中每帧进行如此密集的SetPixel调用是不明智的。

3.3.2 性能优化:使用SetPixels进行批量绘制

优化策略是:将一帧内所有需要绘制的像素点先收集起来,最后一次性应用。我们可以维护一个HashSet<Vector2Int>或者一个bool[,]数组来标记本帧需要修改的像素位置,在LateUpdate中统一进行SetPixels

private bool[,] pixelMask; // 标记需要绘制的像素 private List<Color> pixelsToWrite; // 待写入的颜色列表 private List<int> pixelIndices; // 待写入的像素索引列表 void Start() { // ... 其他初始化 pixelMask = new bool[textureWidth, textureHeight]; pixelsToWrite = new List<Color>(); pixelIndices = new List<int>(); } private void DrawBrushCircleOptimized(int centerX, int centerY) { int startX = Mathf.Max(centerX - brushRadius, 0); int endX = Mathf.Min(centerX + brushRadius, textureWidth - 1); int startY = Mathf.Max(centerY - brushRadius, 0); int endY = Mathf.Min(centerY + brushRadius, textureHeight - 1); int radiusSqr = brushRadius * brushRadius; for (int x = startX; x <= endX; x++) { for (int y = startY; y <= endY; y++) { int dx = x - centerX; int dy = y - centerY; if (dx * dx + dy * dy <= radiusSqr && !pixelMask[x, y]) { pixelMask[x, y] = true; int index = y * textureWidth + x; pixelIndices.Add(index); pixelsToWrite.Add(drawColor); } } } } void LateUpdate() { if (pixelIndices.Count > 0) { // 获取当前纹理的所有像素 Color[] currentPixels = signatureTexture.GetPixels(); // 批量替换需要修改的像素 for (int i = 0; i < pixelIndices.Count; i++) { currentPixels[pixelIndices[i]] = pixelsToWrite[i]; } // 一次性设置并应用 signatureTexture.SetPixels(currentPixels); signatureTexture.Apply(); // 重置标记和列表 System.Array.Clear(pixelMask, 0, pixelMask.Length); pixelIndices.Clear(); pixelsToWrite.Clear(); } }

这个优化将每帧数百上千次的SetPixel调用,减少为一次GetPixels和一次SetPixels调用,性能提升是数量级的。pixelMask用于避免同一像素在同一帧内被重复标记,进一步减少冗余操作。

4. 高级效果与功能扩展

基础画线功能完成后,我们可以追求更佳的体验和更丰富的功能。

4.1 笔触效果模拟:速度感应与透明度

真实的笔迹会有粗细和深浅的变化。我们可以通过计算鼠标移动的速度来模拟这一点:速度快时,线条细且透明度高(飞白效果);速度慢时,线条粗且颜色实。

public float minBrushRadius = 1f; public float maxBrushRadius = 5f; public float speedSensitivity = 10f; // 速度敏感度 private Vector2 previousFrameScreenPos; // 上一帧的屏幕像素坐标 private float currentSpeed; void Update() { Vector2 currentScreenPos = Input.mousePosition; // 计算屏幕空间的速度(像素/秒) currentSpeed = (currentScreenPos - previousFrameScreenPos).magnitude / Time.deltaTime; previousFrameScreenPos = currentScreenPos; // 根据速度动态调整笔刷半径和颜色透明度 float speedFactor = Mathf.Clamp01(currentSpeed / speedSensitivity); float dynamicRadius = Mathf.Lerp(maxBrushRadius, minBrushRadius, speedFactor); float dynamicAlpha = Mathf.Lerp(1.0f, 0.3f, speedFactor); // 速度快,透明度降低 Color dynamicColor = new Color(drawColor.r, drawColor.g, drawColor.b, dynamicAlpha); // 在DrawLine等方法中使用dynamicRadius和dynamicColor // ... }

4.2 签名数据保存与导出

签名完成后,我们需要将其保存下来。最常见的是保存为PNG图片。

public byte[] SaveSignatureAsPNG() { if (signatureTexture == null) return null; // 应用所有未提交的绘制(如果有优化方案,确保先执行LateUpdate的逻辑) // signatureTexture.Apply(); return signatureTexture.EncodeToPNG(); } public void SaveToFile(string filePath) { byte[] pngData = SaveSignatureAsPNG(); if (pngData != null) { System.IO.File.WriteAllBytes(filePath, pngData); Debug.Log($"签名已保存至: {filePath}"); } } // 用于UI显示或网络传输的Base64字符串 public string GetSignatureAsBase64() { byte[] pngData = SaveSignatureAsPNG(); if (pngData != null) { return System.Convert.ToBase64String(pngData); } return string.Empty; }

4.3 清空与撤销功能

清空功能很简单,重新用透明色填充纹理即可。撤销(Undo)功能则相对复杂,需要记录绘制历史。一个简单的实现是使用栈来保存每一帧绘制前的纹理状态(或像素差异),但保存完整纹理太耗内存。更实用的方法是记录绘制命令(如线段起止点、笔刷参数),重做时从头开始执行到上一步。对于轻量级应用,提供“清空”功能通常已足够。

public void ClearSignature() { signatureTexture.SetPixels(clearPixels); signatureTexture.Apply(); // 同时清空优化绘制用的缓存列表 pixelIndices.Clear(); pixelsToWrite.Clear(); System.Array.Clear(pixelMask, 0, pixelMask.Length); }

5. 实战问题排查与性能调优

在实际开发和测试中,你肯定会遇到一些典型问题。这里记录了我遇到的和解决方案。

5.1 笔迹延迟、断点或不平滑

  • 现象:鼠标移动快了,画出来的线是断断续续的点,或者感觉延迟。
  • 原因1:采样率不足Update帧率是变化的,鼠标快速移动时,两帧之间的物理距离可能远超一个笔刷直径。
  • 解决方案:使用FixedUpdate进行输入采样?不,UI响应最好在Update。更好的办法是,即使在Update中,如果检测到鼠标移动距离过大,就在两点之间进行插值,补足中间点。
    private void DrawLineWithInterpolation(Vector2 startUV, Vector2 endUV) { float distance = Vector2.Distance(startUV, endUV); int segments = Mathf.CeilToInt(distance * Mathf.Max(textureWidth, textureHeight) / brushRadius); segments = Mathf.Max(segments, 1); // 至少一段 for (int i = 0; i <= segments; i++) { float t = (float)i / segments; Vector2 interpolatedUV = Vector2.Lerp(startUV, endUV, t); int x = Mathf.RoundToInt(interpolatedUV.x * (textureWidth - 1)); int y = Mathf.RoundToInt(interpolatedUV.y * (textureHeight - 1)); DrawBrushCircleOptimized(x, y); } }
  • 原因2:坐标转换抖动。鼠标坐标转换为UV时出现精度问题或RectTransform计算误差。
  • 解决方案:确保坐标转换函数稳定,并考虑对最终的像素坐标进行简单的四舍五入Mathf.RoundToInt,而不是向下取整Mathf.FloorToInt

5.2 在UI滚动视图(Scroll Rect)或其他可交互UI元素上签名失效

  • 现象:签名区域放在ScrollView里,拖动签名时却触发了滚动。
  • 原因:Unity的UI事件系统被Scroll Rect等组件拦截了。
  • 解决方案:使用EventTrigger组件监听BeginDragDragEndDrag事件,并在事件回调中调用EventSystem.current.SetSelectedGameObject将当前对象设为选中,或者直接调用EventSystem.current.currentInputModule.Process()?更标准的方法是,为签名区域添加一个独立的Graphic组件(如一个透明的Image),并实现IBeginDragHandler,IDragHandler,IEndDragHandler接口。在这些接口的实现中处理绘制逻辑,并调用eventData.Use()来阻止事件继续冒泡被Scroll Rect处理。
    using UnityEngine.EventSystems; public class SignaturePad : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { public void OnBeginDrag(PointerEventData eventData) { // 开始绘制 isDrawing = true; Vector2 localPos; RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out localPos); // ... 转换坐标并开始画点 } public void OnDrag(PointerEventData eventData) { if(isDrawing) { // 持续绘制 Vector2 localPos; RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out localPos); // ... 转换坐标并画线 } // 阻止事件冒泡,防止触发父级ScrollRect的滚动 // eventData.Use(); // 在某些UI框架中可能需要 } public void OnEndDrag(PointerEventData eventData) { isDrawing = false; } }

5.3 移动端适配与触摸输入

在移动设备上,需要将鼠标输入Input.GetMouseButton替换为触摸输入Input.touches。逻辑类似,但要注意多点触摸的处理,通常签名只响应第一个触摸点。

void Update() { #if UNITY_IOS || UNITY_ANDROID if (Input.touchCount > 0) { Touch touch = Input.GetTouch(0); if (touch.phase == TouchPhase.Began || touch.phase == TouchPhase.Moved || touch.phase == TouchPhase.Stationary) { // 将 touch.position 代替 Input.mousePosition 进行坐标转换 // ... } } #else // 原有的PC端鼠标逻辑 #endif }

5.4 内存与GC优化总结

  1. 避免每帧new数组GetPixels()会返回一个新数组。在我们的优化方案中,可以在Start时缓存这个数组Color[] currentPixels,之后一直复用,仅用SetPixels更新它。
  2. 使用ListCapacity:如果大致知道每帧绘制的像素数量,可以提前设置pixelIndicespixelsToWriteCapacity,减少扩容时的GC分配。
  3. 纹理尺寸合理化:纹理越大,像素操作越多。根据实际显示大小选择纹理分辨率,例如512x128通常足够清晰且性能友好。
  4. 按需更新:不一定每帧都必须Apply()。可以设置一个脏标记,仅在鼠标拖动时或拖动结束后Apply(),减少GPU上传次数。

6. 完整代码结构与使用示例

将以上所有模块整合,一个相对完整的SignaturePad类结构如下:

using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; using System.Collections.Generic; public class SignaturePad : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { [Header("UI Reference")] public RawImage signatureDisplay; [Header("Texture Settings")] public int textureWidth = 512; public int textureHeight = 128; public Color drawColor = Color.black; [Header("Brush Settings")] public int baseBrushRadius = 3; public float minBrushRadius = 1f; public float maxBrushRadius = 5f; public float speedSensitivity = 200f; // 根据屏幕像素速度调整 private Texture2D signatureTexture; private Color[] clearPixels; private bool[,] pixelMask; private List<int> pixelIndicesToUpdate; private List<Color> colorsToUpdate; private Color[] workingPixelArray; // 缓存的像素数组,用于复用 private RectTransform rectTransform; private bool isDrawing = false; private Vector2 previousUV; private Vector2 previousScreenPos; void Start() { InitializeTexture(); rectTransform = signatureDisplay.rectTransform; } void InitializeTexture() { signatureTexture = new Texture2D(textureWidth, textureHeight, TextureFormat.RGBA32, false); signatureTexture.filterMode = FilterMode.Bilinear; signatureTexture.wrapMode = TextureWrapMode.Clamp; clearPixels = new Color[textureWidth * textureHeight]; for (int i = 0; i < clearPixels.Length; i++) clearPixels[i] = Color.clear; pixelMask = new bool[textureWidth, textureHeight]; pixelIndicesToUpdate = new List<int>(textureWidth * textureHeight / 10); // 预估容量 colorsToUpdate = new List<Color>(textureWidth * textureHeight / 10); workingPixelArray = signatureTexture.GetPixels(); // 初始获取并缓存 ClearSignature(); if (signatureDisplay != null) signatureDisplay.texture = signatureTexture; } public void OnBeginDrag(PointerEventData eventData) { isDrawing = true; Vector2 localPos; if (RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out localPos)) { previousUV = ConvertToUV(localPos); previousScreenPos = eventData.position; DrawPoint(previousUV, maxBrushRadius, drawColor); // 起始点用最大半径 } eventData.Use(); // 阻止事件冒泡 } public void OnDrag(PointerEventData eventData) { if (!isDrawing) return; Vector2 localPos; if (RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out localPos)) { Vector2 currentUV = ConvertToUV(localPos); // 计算速度并动态调整笔刷 float currentSpeed = (eventData.position - previousScreenPos).magnitude / Time.deltaTime; float speedFactor = Mathf.Clamp01(currentSpeed / speedSensitivity); float dynamicRadius = Mathf.Lerp(maxBrushRadius, minBrushRadius, speedFactor); Color dynamicColor = new Color(drawColor.r, drawColor.g, drawColor.b, Mathf.Lerp(1f, 0.4f, speedFactor)); DrawLineWithInterpolation(previousUV, currentUV, dynamicRadius, dynamicColor); previousUV = currentUV; previousScreenPos = eventData.position; } eventData.Use(); } public void OnEndDrag(PointerEventData eventData) { isDrawing = false; CommitDrawingToTexture(); // 拖动结束,提交所有绘制 eventData.Use(); } // 其他辅助方法:ConvertToUV, DrawPoint, DrawLineWithInterpolation, DrawBrushCircleOptimized, CommitDrawingToTexture, ClearSignature, SaveSignatureAsPNG 等 // ... }

使用这个组件非常简单:

  1. 在UI Canvas下创建一个RawImage
  2. RawImage的RectTransform调整到你想要的签名区域大小和位置。
  3. SignaturePad脚本挂载到该RawImage或它的父物体上。
  4. 在Inspector中将signatureDisplay字段拖拽赋值。
  5. 调整笔刷颜色、大小、纹理分辨率等参数。
  6. 运行时,在RawImage区域内拖拽即可签名。调用SaveToFileGetSignatureAsBase64即可获取结果。

7. 延伸思考:从功能到体验

实现基本功能只是第一步,要让这个签名软件真正“好用”,还需要考虑更多细节。

7.1 抗锯齿与笔触美化

我们目前绘制的笔刷是硬边缘的圆形,在低分辨率下锯齿感明显。可以通过修改DrawBrushCircleOptimized中的着色逻辑,根据像素到圆心的距离进行Alpha混合,实现软边缘笔刷。

// 在DrawBrushCircleOptimized内,替换简单的布尔判断 float distance = Mathf.Sqrt(dx * dx + dy * dy); if (distance <= brushRadius) { float alphaFactor = 1.0f - (distance / brushRadius); // 距离越远,透明度越低 Color finalColor = dynamicColor; finalColor.a *= alphaFactor; // 这里需要与纹理上已有的颜色进行混合,而不是直接覆盖 Color existingColor = workingPixelArray[index]; Color blendedColor = Color.Lerp(existingColor, finalColor, finalColor.a); // 记录混合后的颜色... }

这需要将workingPixelArray的读取和混合计算纳入绘制循环,会增加一些计算量,但能显著提升视觉质量。

7.2 压力感应支持(数位板)

对于专业绘图场景,可以集成Unity的Tablet类(如果平台支持)或第三方插件来读取数位板的压力信息,从而直接控制笔刷大小和透明度,实现更真实的笔迹。

7.3 笔迹序列化与重演

如果需要“回放”签名过程,或者将签名数据压缩后传输,可以记录每一笔的坐标、时间戳、笔刷参数序列,而不是保存最终的位图。这能极大减少数据量,但需要额外的播放器来重现。

7.4 与后端结合:数字签名与验证

在严肃的电子签署场景,仅前端生成图片是不够的。需要将签名图片的哈希值、签署时间、用户身份等信息一起,通过非对称加密算法生成数字签名,并与签名图片一起打包发送到后端进行验证,确保签名的不可篡改性和不可抵赖性。这超出了Unity前端的范畴,需要与服务器端协同设计。

实现这个功能的过程,更像是在打磨一个产品细节。从最初能“画出来”,到“画得流畅”,再到“画得好看”,最后到“用得顺手”,每一步都需要对底层原理的深入理解和对用户体验的细致考量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询