从零构建一个"有笔锋"的 Unity 电子签名系统

项目背景

这是一个展厅/活动中使用的双屏留言签名一体机。用户在操作屏上拍照、选寄语、手写签名,大屏实时同步展示,最后合成为一张纪念照保存。整个系统运行在 Windows 平台的 Unity 中,操作屏支持触摸,大屏是面向观众的 1920x1080 显示器。

这篇文章聚焦于其中最复杂的部分——签名绘制系统——的技术实现和优化历程。


整体架构

签名功能横跨 3 个页面、4 个核心组件:

拍照 → 选寄语 → [签名绘制] → 选颜色 → 合图保存
                      ↓
              大屏实时镜像(并行)

页面导航采用栈式 UIManager,每页继承 BaseUIPage,通过 GoTo(PageId) / GoBack() 推进/弹出。页面间通信依赖两样东西:

  • EventBus —— 静态泛型 Pub/Sub,解耦页面间的事件传递
  • SharedState —— 静态属性袋,保存跨页面的共享数据(照片、签名、寄语、颜色)

大屏镜像由 DisplayCoordinator 桥接两个 UIManager,监听主屏导航事件,通过 PagePairMapping 查找对应的大屏页面同步操作。


核心:Ping-Pong RenderTexture 绘制

为什么用这个方案

Unity 里做 2D 手写绘制有几种常见方案:

方案 优点 缺点
LineRenderer 拖尾 简单 无笔刷纹理,折线感明显
Mesh 动态生成 灵活 重叠区域混合复杂
Ping-Pong RT + Blit 笔刷纹理完美叠加 GPU 开销需优化
Compute Shader 高性能 开发复杂度高

选择 Ping-Pong RT 方案是因为它能完美呈现软边缘笔刷纹理,并且每一笔都是"盖印"(stamp),天然支持撤销和一键清屏。

工作原理

维护两张全屏 RenderTexture,交替作为"源"和"目标":

帧 N:   Graphics.Blit(RT_A → RT_B, brushMaterial)  →  ActiveRT = RT_B
帧 N+1: Graphics.Blit(RT_B → RT_A, brushMaterial)  →  ActiveRT = RT_A

每次 Graphics.Blit 读取源 RT 的全部像素,在指定 UV 位置盖上笔刷纹理,写入目标 RT。Shader 使用 Blend SrcAlpha OneMinusSrcAlpha 标准 Alpha 混合。

PaintBrush Shader 的数学

// 片段着色器核心逻辑
float2 uv = i.uv + (0.5f / size);   // 微调偏移,使笔刷居中
uv = uv - _UV.xy;                    // 平移到笔刷中心
uv *= size;                          // 缩放:size 越大 → 笔刷越小
fixed4 col = tex2D(_BrushTex, uv);   // 采样笔刷纹理
col.rgb = 1;                         // 笔刷纹理只保留 Alpha 通道
col *= _Color;                       // 乘以用户选择的颜色

关键点:_Size 越大,笔刷显示越小。这是因为 uv *= size 将采样坐标放大,使得笔刷纹理被"压缩"到更小的屏幕区域。这个反直觉的关系后来导致了一个方向性 bug——快速划线的速度映射公式写反了。

笔刷纹理生成

程序化生成一个 64x64 的柔边圆形:

// softness=0.35 表示 65% 半径内完全不透明,外缘 35% 渐变
if (dist <= 1f - softness)
    alpha = 1f;
else
    alpha = 1f - (dist - (1f - softness)) / softness;
alpha *= alpha;  // 二次方衰减,边缘更柔和

笔迹平滑:从线性插值到 Catmull-Rom 样条

第一代:纯线性插值

最初只用 Vector2.Lerp(from, to, t) 在两个采样点之间补点。快速转角时棱角明显。

第二代:EMA 输入平滑

在 Update() 中对鼠标/触摸位置做指数移动平均:

_smoothMousePos = Vector2.Lerp(_smoothMousePos, rawPos, _inputSmoothing);

_inputSmoothing = 0.35 意味着新数据权重 35%,旧数据 65%——约 2-3 帧的时间常数,可以有效滤掉 25Hz 以上的手部微颤,同时几乎感受不到延迟。

第三代:Catmull-Rom 样条曲线

积累 4 个历史点后,对中间段 p1→p2 沿 Catmull-Rom 曲线重采样:

Vector2 CatmullRom(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float t)
{
    return 0.5f * (
        (2f * p1) +
        (-p0 + p2) * t +
        (2f * p0 - 5f * p1 + 4f * p2 - p3) * t * t +
        (-p0 + 3f * p1 - 3f * p2 + p3) * t * t * t
    );
}

Catmull-Rom 保证曲线通过所有控制点,且 C1 连续(切线连续),转角处自动圆滑过渡。

踩坑记录:最初尝试用"回溯式"Catmull-Rom——在第 4 帧拿到 4 个历史点后,回头重绘 p1→p2 段。这导致已经显示的笔迹被新路径覆盖,产生视觉抖动。最终改为纯前向绘制:EMA 平滑做输入端过滤,Catmull-Rom 做输出端插值。


动态笔刷:"笔锋"效果

这是整个系统最有趣的部分。目标是模拟真实毛笔/钢笔的书写感:

  • 速度快 → 笔画细(行笔如风)
  • 速度慢 → 笔画粗(顿笔如山的)
  • 起笔渐入(笔尖初触纸面)
  • 收笔尖尾(提笔离纸的自然消散)

速度-粗细模型

float velocity = distance / deltaTime;
float t = Clamp01(velocity / _maxVelocityThreshold);     // 归一化速度 [0,1]
float velocityScale = Lerp(1f, 1f / _minVelocityScale, t); // 1.0 → 1.82
return _brushSize * velocityScale * fadeInScale;

关键修正:由于 _Size 是反比(越大笔越小),快速划线的映射必须是 Lerp(1, 1/scale, t) 而非 Lerp(1, scale, t)——否则越快的线反而越粗。

起笔渐入

落笔后 0.12 秒内,笔刷从 2.5 倍基础尺寸渐进缩小到目标尺寸:

fadeInScale = Lerp(2.5f, 1f, SmoothStep(0, 1, timeSinceStart / _fadeInDuration));

这模拟笔尖刚接触纸面时墨迹较宽、随后收窄到正常粗细的过程。

收笔尖尾

抬笔后沿最后运动方向逐个盖印 6 个渐隐点,粗细按二次方衰减:

for (int i = 0; i < _tailDotCount; i++)
{
    float t = (float)i / _tailDotCount;
    float currentSize = lastSize / Lerp(1f, 0.08f, t * t);   // 越来越小
    Vector2 drawPos = lastPoint + lastDirection * stepDistance * i;  // 沿方向前进
    Paint(drawPos);
}

每个点间隔 15ms,总共约 90ms 的消散动画。

参数一览

参数 默认值 含义
_brushSize 20 基础笔刷粗细(_Size 值)
_stampDensity 3 盖印密度(越大越密、线条越平滑)
_brushSoftness 0.35 笔刷边缘柔和度
_inputSmoothing 0.35 EMA 输入平滑因子
_minVelocityScale 0.55 最快画线时的粗细比例
_fadeInDuration 0.12s 起笔渐入时长
_tailDotCount 6 收笔尖尾点数
_tailFadeInterval 0.015s 收笔点间隔

撤销系统

每笔下笔前(MouseDown),先将当前 RenderTexture 完整像素快照存入栈(最多 10 层):

var snapshot = GetSignatureTexture().GetPixels32();
_undoStack.Add(snapshot);

撤销时,将最近快照重建为 Texture2D,Blit 到两张 RT,恢复画布状态。


签名着色

签名在绘制时永远是黑色(单通道 Alpha 纹理)。选色页面的 RecolorSignature 做逐像素颜色替换:

for (int i = 0; i < srcPixels.Length; i++)
    dstPixels[i] = new Color(targetColor.r, targetColor.g, targetColor.b, srcPixels[i].a);

保留原始 Alpha 通道(笔刷形状),RGB 替换为用户选择的颜色。这是 CPU 端的像素操作,适合签名这种小纹理。


大屏实时镜像

大屏页面每帧轮询 SharedState.DrawingCanvas:

void Update()
{
    var rt = SharedState.DrawingCanvas;
    if (rt != null && rt != _lastDrawingCanvas)
    {
        _instance.SetSignatureRT(rt);   // 直接赋值 RenderTexture 给 RawImage
        _lastDrawingCanvas = rt;
    }
}

把 RenderTexture 直接赋给 RawImage,零拷贝、零延迟。大屏用的是同一个 GPU 资源,没有额外的纹理读取或编码。


GPU 性能优化实战

瓶颈分析

每个 Graphics.Blit 对全屏 RenderTexture 做片元着色器计算。以 1920x1080 为例:

  • 单次 Blit:200 万像素 × 片元着色器
  • 快速绘制一帧:100-200 次 Blit
  • 合计:每帧 2-4 亿次片元着色器调用

而笔刷实际只覆盖约 100x100 = 1 万像素——浪费率超过 99%。

优化措施

1. RT 分辨率匹配画布实际尺寸(最关键)

// 旧:全屏分辨率
_screenWidth = Screen.width;   // 1920
_screenHeight = Screen.height; // 1080

// 新:匹配 Canvas RectTransform 实际大小
var rect = _paintCanvas.rectTransform.rect;
_screenWidth = Mathf.RoundToInt(rect.width * _resolutionScale);
_screenHeight = Mathf.RoundToInt(rect.height * _resolutionScale);

如果画布占屏幕 40% 面积,像素量从 2M 降到 ~300K,每次 Blit 减少 85% 片元调用。

2. 移除无用的 Depth Buffer

// 旧:分配 24-bit 深度缓冲
GetTemporary(w, h, 24)
// 新:纯 2D 绘制无需深度
GetTemporary(w, h, 0, RenderTextureFormat.ARGB32)

3. 消除冗余 Material 属性设置

// 同帧内 _currentDynamicSize 不变时跳过 SetFloat
if (!Mathf.Approximately(_currentDynamicSize, _lastSetSize))
{
    _paintBrushMat.SetFloat("_Size", _currentDynamicSize);
    _lastSetSize = _currentDynamicSize;
}

4. Canvas 纹理按需更新

只在有实际绘制时才设置 _paintCanvas.texture,闲置帧跳过 Canvas 重建。

5. 可调分辨率缩放

新增 _resolutionScale 参数(0.25~1.0),允许运行时在画质和性能之间微调。


合图保存

SouvenirPhoto.CaptureAsPNG() 是整个流程的终点:

  1. 计算 uniform scale:photo.width / photoAreaWidth,确保像素级精度
  2. 创建临时正交相机 + 临时 Canvas(图层 30),尺寸匹配输出分辨率
  3. 实例化 SouvenirPhoto prefab,填充照片、寄语、签名
  4. camera.Render() 渲染到临时 RenderTexture
  5. ReadPixels → EncodeToPNG → 写入磁盘
  6. 清理所有临时对象

最终输出 PNG 保存在 config/HistoryImag/ 或自定义文件夹中,文件名格式 souvenir_20260512_143025_123.png。


踩过的坑

  1. _Size 的反比关系:Shader 中 uv *= size,所以 _Size 越大笔刷越小。速度映射、起笔缩放、收笔衰减三个公式最初都写反了,导致快速画线反而变粗。

  2. 回溯式 Catmull-Rom 抖动:用 4 帧前的历史点重绘旧线段,导致笔迹在转角处"跳变"。改为纯前向插值 + 输入端 EMA 平滑解决。

  3. 重复盖章 200 次/帧:ProcessStrokeInput 无条件 Paint(screenPoint) + 线性填充循环的终点重叠。消除重复后 GPU 负载直接减半。

  4. AsyncGPUReadback 不兼容 GetTemporary:尝试用异步 GPU 读回优化撤销/确认操作,但 GetTemporary 创建的 RT 在该平台上不可靠——GetData<T>() 始终抛异常。回退为同步 ReadPixels,因为只在按钮点击时触发(不在连续绘制期间),性能影响可忽略。

  5. _lastSetSize 缓存一致性问题:收笔协程绕过了 _lastSetSize 缓存直接设置 _Size,在特定边缘情况下可能导致下一笔不刷新。在收笔协程末尾重置缓存解决。


写在最后

这个签名系统的核心亮点在于动态笔刷模型——通过速度感应、起笔渐入、收笔尖尾三个维度,在一个简单的 Shader + Blit 架构上模拟出了接近真实的书写笔锋效果。

最大的工程教训是:不要过早做"聪明的"优化。回溯式 Catmull-Rom 试图一劳永逸解决平滑问题,但破坏了前向绘制的简单性;AsyncGPUReadback 试图消除 GPU 停顿,但在目标平台上不可靠。最终最有效的 GPU 优化反而是最简单的——把 RenderTexture 分辨率从全屏降到画布实际尺寸。

如果你也在做类似的 Unity 手写签名系统,希望这篇文章能帮你少走一些弯路。

posted @ 2026-05-12 09:51  带一蓑烟雨  阅读(99)  评论(0)    收藏  举报