从零构建一个"有笔锋"的 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() 是整个流程的终点:
- 计算 uniform scale:
photo.width / photoAreaWidth,确保像素级精度 - 创建临时正交相机 + 临时 Canvas(图层 30),尺寸匹配输出分辨率
- 实例化 SouvenirPhoto prefab,填充照片、寄语、签名
camera.Render()渲染到临时 RenderTextureReadPixels→EncodeToPNG→ 写入磁盘- 清理所有临时对象
最终输出 PNG 保存在 config/HistoryImag/ 或自定义文件夹中,文件名格式 souvenir_20260512_143025_123.png。
踩过的坑
-
_Size的反比关系:Shader 中uv *= size,所以_Size越大笔刷越小。速度映射、起笔缩放、收笔衰减三个公式最初都写反了,导致快速画线反而变粗。 -
回溯式 Catmull-Rom 抖动:用 4 帧前的历史点重绘旧线段,导致笔迹在转角处"跳变"。改为纯前向插值 + 输入端 EMA 平滑解决。
-
重复盖章 200 次/帧:
ProcessStrokeInput无条件Paint(screenPoint)+ 线性填充循环的终点重叠。消除重复后 GPU 负载直接减半。 -
AsyncGPUReadback不兼容GetTemporary:尝试用异步 GPU 读回优化撤销/确认操作,但GetTemporary创建的 RT 在该平台上不可靠——GetData<T>()始终抛异常。回退为同步ReadPixels,因为只在按钮点击时触发(不在连续绘制期间),性能影响可忽略。 -
_lastSetSize缓存一致性问题:收笔协程绕过了_lastSetSize缓存直接设置_Size,在特定边缘情况下可能导致下一笔不刷新。在收笔协程末尾重置缓存解决。
写在最后
这个签名系统的核心亮点在于动态笔刷模型——通过速度感应、起笔渐入、收笔尖尾三个维度,在一个简单的 Shader + Blit 架构上模拟出了接近真实的书写笔锋效果。
最大的工程教训是:不要过早做"聪明的"优化。回溯式 Catmull-Rom 试图一劳永逸解决平滑问题,但破坏了前向绘制的简单性;AsyncGPUReadback 试图消除 GPU 停顿,但在目标平台上不可靠。最终最有效的 GPU 优化反而是最简单的——把 RenderTexture 分辨率从全屏降到画布实际尺寸。
如果你也在做类似的 Unity 手写签名系统,希望这篇文章能帮你少走一些弯路。
浙公网安备 33010602011771号