
说个有意思的现象不少人第一次在Unity里做“毛笔字”功能都会先用LineRenderer加一张笔刷贴图把线宽调粗、颜色调黑然后满怀期待地落笔——写出来的东西怎么看都更像马克笔或者油漆刷跟“毛笔字”三个字没什么关系。其实问题不在于“线条”本身而在于毛笔笔画的形态规律宽度会随着行笔速度和力度大幅变化笔锋是有方向的尖角边缘是毛糙的而不是平滑的墨色还要跟纸面发生“渗透”才算数。缺了这些任何笔迹都只是一根会拐弯的管子罢了。这篇文章整理的是我在项目里把“Unity模拟毛笔字”从“能写字”做到“像毛笔字”的完整思路。核心方案是程序化网格每落一笔就动态生成带宽度、带UV、带进度数据的笔迹Mesh再用自定义Shader把墨色、纸纹、飞白这些细节渲染出来。后面也会聊到LineRenderer、粒子模拟和流体方案的取舍以及移动端和WebGL小游戏环境下的性能控制。适合想在手写交互、签名板、书法教学这类场景里做出真实笔迹的Unity开发者看美术同学也可以参考纹理设计和墨色层次那一段。1. 毛笔字的核心难点为什么画出来总像“马克笔”想要把毛笔字做像先得弄清楚差的到底在哪。我以前试过很多种“快速方案”最后发现绕不开三个核心点宽度不固定、边缘不光滑、墨色有层次。这三条少了一条写出来的东西就假。1.1 宽度节奏毛笔不是签字笔毛笔最典型的特征是“提按”。行笔快、落笔轻的时候笔锋跟纸面接触面积小笔画细而且虚行笔慢、用力重的时候笔画明显加粗转折处还会出现顿笔。也就是说一个笔画内部不同位置的宽度是实时变化的不能用一条固定的widthCurve去套。实践中我用的驱动参数是“速度”有压感时再把压感当作辅助系数叠加进来。核心公式类似这样float speed (currentPos - lastPos).magnitude / deltaTime; float speedFactor Mathf.Clamp01(minSpeed / Mathf.Max(speed, minSpeed)); float width Mathf.Lerp(minWidth, maxWidth, speedFactor);这里的思路很直白速度越快笔锋受到的摩擦力越小触纸面积越小笔画应该越细速度越慢笔尖在纸上停留时间长墨水渗开越多笔画越粗。minSpeed是为了防止速度接近0时除法爆炸同时让慢笔有一个宽度上限。这个公式虽然简单但已经能解决“全程等宽”的塑料感。1.2 边缘形态干净的边缘本身就是破绽用常规线条渲染出来的笔迹边缘是一条平滑的、半透明的渐变带这在马克笔效果里没问题但毛笔字不行。真实墨迹在宣纸上会沿着纸纤维方向微微渗开边缘是参差不齐的有细小毛刺甚至局部会出现断开的墨痕。我见过很多项目试图用“给线条加抖动”来模拟这种毛糙效果非常差因为抖动是时间域上的会造成笔迹整体晃动。正确做法是在纹理和Shader层面做文章让笔刷贴图本身在宽度方向上带噪声扰动同时在边缘区域把alpha压得低一些形成一种“半渗开”的状态。1.3 墨色层次不要用纯黑一涂到底另一个很容易被忽略的点是“墨色”。直接把颜色设成(0,0,0)当然能写字但会显得死板。真实墨迹在纸面上有浓淡变化起笔和顿笔处墨浓快速掠过的地方墨淡笔画重叠处颜色更黑。这种层次感用纯色加Alpha是做不出来的需要结合纹理灰度、叠加混合和噪声来模拟。我自己判断一个毛笔效果能不能用的标准很简单把写出来的字截图放大看三点——宽度有没有节奏感、边缘有没有自然的不规则、转折处有没有笔锋感。如果一条直线下来从头到尾粗细一致、边缘光滑如刀切那不管换什么贴图都救不回来。2. 方案选型LineRenderer、程序化网格还是流体模拟动手写代码之前先花时间把方案选型想明白能少走很多弯路。我在调研和实测中主要考察了四条路线各有各的适用场景。2.1 LineRenderer只适合“线”不适合“面”LineRenderer的好处是API简单给一组点就能出线也支持widthCurve和渐变色。但它的设计目标是“线的渲染”不是“面的渲染”。你要在任意采样点位置改变截面形状、要精确控制UV方向、要给不同顶点写入不同的进度值用LineRenderer会非常别扭。更重要的是LineRenderer的UV沿线段分布逻辑比较固定想让它老老实实地把一张笔刷纹理“顺着笔画方向”铺在每一段上需要花费大量精力去处理纹理坐标映射。即使做到了它对每个顶点的操控自由度仍然不如直接用Mesh。所以我的结论是LineRenderer可以做喷墨、发光线、轨迹预览但不适合做真正的毛笔字。2.2 程序化网格可控性最强程序化网格的玩法简单说就是把每一笔轨迹当成一条“路径”根据路径生成左右两条边界再拆成三角形得到一张可以独立控制每个顶点属性的Mesh。这样每个顶点都能拥有自己的位置、宽度、法线、UV、颜色和进度值想怎么调就怎么调。这个方案最大的优势是“确定性”。用户怎么画笔迹就怎么长不会像粒子系统那样充满随机性。在需要做签名、书法教学、给用户回放书写过程的产品里这种确定性非常关键。代价是要自己管理采样、插值、宽高计算和Mesh更新上手门槛比LineRenderer高一些。本文选的就是这条路线后面所有实现细节都围绕它展开。2.3 粒子系统和流体模拟效果惊艳但不适合“手写”粒子系统和流体模拟做水墨扩散确实很厉害。用Unity的粒子系统发射墨粒让它们在纸面上随机流动模拟晕染、飞溅、水墨融合做艺术生成或者动态水墨Logo都很有视觉冲击力。Unity DOTS环境下用Job System做大量粒子更新也能模拟出很不错的墨水流动感。但书法要求的是“落笔成字”是用户控制下的精确笔迹。粒子方案天然带随机性很难保证用户画出来的一撇一捺跟手指轨迹完全一致签名场景里更是不可能用这种方案。流体模拟则更重移动端和WebGL小游戏上跑起来代价太高。除非你的目标是做纯艺术效果否则不建议把手写毛笔字寄托在粒子和流体上。2.4 预渲染笔触与SDF拼接适合固定字不适合自由书写还有一种做法是准备一堆毛笔笔触贴图比如横、竖、撇、捺、折甚至把常见汉字直接做成SDF图用拼接方式组成文字。很多字体类应用里会看到这种方案。它的优点是静态效果好因为贴图是美术精心画出来的笔锋和墨色都无可挑剔缺点是只能处理固定笔画和固定顺序用户一旦自由书写、倒过来写或者写英文素材拼接就会露馅。简单整理一下各方案的关键差异方案实时性笔迹可控性笔锋表现性能适合场景LineRenderer高低弱高装饰线条、轨迹预览程序化网格高高强中高手写书法、签名、教学粒子系统中低中中低水墨艺术、扩散动画流体模拟低低强低艺术短片、视觉特效笔触拼接高低强高固定汉字、字体特效从我的角度如果目标是“让用户自己写毛笔字”程序化网格是唯一性价比合理的选择。3. 程序化笔迹网格从原始采样点到可渲染多边形确定了程序化网格方案之后真正的难题才刚开始怎么把一连串手指或鼠标的采样点变成一个能渲染的、带宽度变化的、边缘自然的笔迹Mesh。3.1 采样预处理先解决“点数该有多少”的问题Input系统拿到的触点频率通常在30Hz到60Hz直接连起来会看到明显折线。反过来如果每帧都加一个点又会造成顶点过于密集Mesh更新压力大且纹理周期会乱。我的做法是先设定一个最小间距阈值。比如在世界空间0.01单位以内新增的采样点直接丢弃超过阈值才生成新点然后再用Catmull-Rom插值把路径补顺让转弯处不生硬。Catmull-Rom的好处是曲线会经过所有原始点不会像贝塞尔那样因为控制点位置不当而产生偏移很适合还原书写轨迹。public static Vector3 CatmullRom(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { float t2 t * t; float t3 t2 * t; return 0.5f * ((2f * p1) (-p0 p2) * t (2f * p0 - 5f * p1 4f * p2 - p3) * t2 (-p0 3f * p1 - 3f * p2 p3) * t3); }实践中我在每两个原始点之间插入2到4个插值点具体数量根据两个点之间的距离和夹角决定。夹角大的地方多插几个点平滑效果更明显直线段少插避免浪费顶点。3.2 宽度计算速度为主压感为辅每个采样点都要有自己的宽度。这个值由速度和压感共同决定。因为很多移动设备或者鼠标根本没有压感值所以速度一定是主力压感只能作为修正系数。float speed (points[i].pos - points[i - 1].pos).magnitude / deltaTime; float speedFactor Mathf.Clamp01(baseSpeed / Mathf.Max(speed, 0.001f)); float pressureFactor Mathf.Lerp(0.6f, 1.2f, points[i].pressure); points[i].width Mathf.Lerp(minWidth, maxWidth, speedFactor) * pressureFactor;宽度计算后一定要做一次平滑滤波不能让相邻两帧的宽度剧烈跳变。我用的是EMA简单有效float smoothWidth Mathf.Lerp(prevWidth, currentWidth, 0.35f);这个0.35的系数可以让宽度变化有“笔锋逐渐落下”的迟滞感而不是瞬间变粗变细观感自然很多。3.3 左右边界生成法线方向的正确算法拿到一系列带宽度的点之后接下来要生成笔迹的左右两侧顶点。对每条路径段上的采样点P_i先算它的切线方向再算法线方向法线乘以宽度的一半就得到左右点。Vector3 tangent (points[i 1].pos - points[i - 1].pos).normalized; Vector3 normal new Vector3(-tangent.y, tangent.x, 0f); Vector3 left points[i].pos normal * points[i].width * 0.5f; Vector3 right points[i].pos - normal * points[i].width * 0.5f;这段代码看起来简单但有一个很重要的前提切线要用前后两点插值而不是只用后一个点。否则路径在拐弯处会出现“锯齿状”的方向跳动笔迹形态会很差。此外当路径的折角特别尖锐时相邻两段法线可能会互相交叠生成翻转三角形。基础的解决办法是把法线做一次平滑比如用前后两段法线的平均值再归一化。3.4 三角形索引与Mesh属性生成左右顶点之后按顺序连接成四边形条带int baseIndex i * 2; triangles.Add(baseIndex); triangles.Add(baseIndex 1); triangles.Add(baseIndex 2); triangles.Add(baseIndex 2); triangles.Add(baseIndex 1); triangles.Add(baseIndex 3);注意绕序要保持一致否则三角形面会朝向背面导致从默认视角看不到。渲染毛笔字通常用双面渲染或者有特殊需求但绕序还是规范点好省得后面引入光照时出各种怪问题。每个顶点除了position之外还需要写入normal、uv和进度值。即使你用的是不受光照的UnlitShader也建议把法线设置好将来万一想换到Lit渲染管线或加纸张法线贴图不用返工。进度值可以放在顶点的color通道或者uv2里后面做动态回放要用。4. 笔锋和枯笔宽度曲线、导向UV与墨迹纹理设计Mesh只是骨架真正让笔迹像毛笔的是纹理和着色逻辑。这一节聊几个关键细节也是我跟美术同学反复撕扯之后总结出来的经验。4.1 起笔、行笔、收笔的宽度轮廓并不是所有笔画都是“两头尖”的。毛笔字里有藏锋、露锋、顿笔、收笔形态丰富。如果要支持这种变化最灵活的方式是给每个笔画预设一段“轮廓曲线”当前落笔时根据笔画进度去采样曲线得到宽度。实际项目中起笔阶段通常是前几个点宽度从很小快速增加表现“落笔”的力度行笔阶段宽度跟随速度和压感实时变化收笔阶段最麻烦——在书写过程中你不知道这笔会在哪里结束所以需要维护一个“收尾缓冲区”把最近几个采样点先存在临时数组里等用户抬起手指后再根据收笔方式重新计算这些点的宽度和位置。比如我想做“提笔收尾”就让最后一段宽度从当前值平滑降到0想做“顿笔回锋”就让最后一个点宽度突然增大并往回拉一小段。这两种收尾方式在书法里都很常见用缓冲区处理就能兼顾。4.2 导向UV让笔刷纹理顺着笔画方向走有了笔迹Mesh怎么让笔刷纹理“贴”上去我的做法是uv.x沿笔画方向从0增长到1或者按固定周期重复uv.y沿宽度方向从-1到1中心线为0。这样纹理的上下边缘会自然形成从两边向中间收拢的效果。折角处会遇到一个经典问题uv.x整体归一化后路径短的段和长的段共享同一段纹理会导致纹理被不均匀拉伸。更合理的做法是不做整笔归一化而是让uv.x按世界空间距离递增比如每0.02个单位长度对应一个纹理周期。这样在转弯处纹理不会因为路径曲率变化而大面积变形每个小段的纹理都顺着笔画的局部方向走。4.3 枯笔飞白用速度阈值配合噪声调制alpha写快了之后笔画会出现一段段断断续续的“飞白”这是毛笔字很出效果的特征。最实用的方法是用一张低频噪声纹理沿uv.x方向采样再用速度因子做阈值让噪声在某些区域把alpha削掉fixed noise tex2D(_NoiseTex, float2(i.uv.x * _NoiseScale, i.uv.y * _NoiseScale)).r; float dry saturate((i.speedFactor - _DryThreshold) * _DryStrength); float alpha tex2D(_BrushTex, i.uv).a * smoothstep(dry, 1.0, noise);关键点是噪声纹理要沿uv.x分布而不是屏幕空间。如果用屏幕空间噪声笔迹一旦被移动、旋转、缩放飞白的位置就会跟笔迹错位看起来像是墨迹自己在跳舞非常出戏。4.4 笔刷纹理怎么做灰度就够了如果你有条件做纹理可以自己做一张横向长条中间部分不透明越靠近上下边缘alpha越低并且边缘要带一些不规则毛刺。不要用纯色黑用深灰到黑的渐变这样在叠加混合时才能产生浓淡变化。毛刺部分直接用PS笔刷画或者程序化生成Perlin噪声都行重点是“边缘不光滑”。纹理不需要追求高分辨率512x512足够了因为墨迹边缘本来就模糊分辨率太高反而显得太锐利假。5. 从“边写边出”到“逐笔回放”动态笔画的两种实现路线动态书写和回放是毛笔字项目里最常见两个需求。很多人以为必须靠粒子或动画才能实现其实用Mesh的进度值就能做得非常干净。5.1 实时书写笔尖到哪墨迹跟到哪用户写字时其实不需要“额外的动画”——笔迹跟着笔尖实时出现就行。实现上采用动态追加每次新增采样点就往Mesh里追加两个顶点、两个三角形。if (points.Count 1 distance minStep) { AddStrokeSegment(points, width, tangent); mesh.SetVertices(vertexBuffer, 0, vertexCount); mesh.SetTriangles(indexBuffer, 0, indexCount, 0); mesh.RecalculateBounds(); }这里有个性能细节不要每帧都调用SetVertices和SetTriangles最好把顶点数组和索引数组预分配好然后只更新长度范围内的数据。每帧都新建List再赋值会产生大量GC。在移动端和小游戏平台GC带来的卡顿很致命。5.2 逐笔回放用“进度值”裁剪Mesh比实时书写更常见的是教学回放录制一段书写过程然后让用户看笔迹一步步长出来。如果为每一帧重建Mesh性能和内存都受不了。我给每个顶点在生成时算一个“累计弧长”存到uv2或者其他通道里Shader里用一个全局进度变量来控制显示范围float arcLen i.arcLength; clip(_Progress - arcLen);当_Progress小于某个采样点对应的弧长时这个顶点所在的三角形就会被裁剪掉。因为_Progress是光滑递增的所以整条笔画会从开头到结尾“长”出来。这个方案完全不需要修改Mesh秒级回放、慢速回放、倒放都能轻松实现而且多个笔画可以共享同一个_Progress实现整字同步书写。5.3 两种路线的选择建议如果你做的是在线白板或实时签名用实时书写方案如果你做的是书法教学、笔迹动画、签名回放用进度值裁剪方案。还有一种混合做法书写过程中实时追加顶点同时把每个顶点的弧长写进去录像回放时直接用进度值裁剪写好的静态Mesh。我做书法教学项目时就是这么干的一套数据两用维护成本很低。6. 墨色与宣纸混合模式、噪声羽化和纸面质感毛笔墨迹最终要落在“纸”上材质层面的配合能拉开普通效果和专业效果之间的差距。6.1 为什么普通Alpha混合会让墨色发灰默认的Transparent混合是Blend SrcAlpha OneMinusSrcAlpha也就是说当两笔墨迹重合时颜色会被二次混合稀释重叠区域反而显得淡、发灰。真实宣纸上后来画的墨是叠加在之前墨色上的只会越来越浓、越来越黑而不是变灰。要解决这个问题推荐用叠加混合模式。Unity Shader里这样写BlendOp Add Blend DstColor Zero这个混合模式的含义是“最终颜色 当前片元颜色 × 背景颜色”。墨迹区域乘以背景纸纹会让重叠处颜色变深模拟墨水层层叠加的效果。注意在这种混合下笔刷纹理中摩擦和黑色区域最好输出灰度值而不是纯白否则会把纸的颜色完全盖住。6.2 墨色要“活”必须跟纸纹互动纯黑墨迹在宣纸上观感很僵。墨色其实带有很微妙的色偏浓墨偏暖灰淡墨偏冷青灰。想要同一次书写里呈现浓淡干湿的变化可以把速度和压感映射成一个“墨量系数”在Shader里跟噪声叠加让墨色有微小的明度和色相波动。6.3 褶纸和洇墨用纸纹采样微调alpha纸面光照效果可以用法线贴图来模拟但不要用过强的法线强度否则宣纸看起来像一块粗糙岩石。更值得做的是洇墨效果让笔迹边缘沿着纸纹方向自然晕开。最简单的办法是把纸张的纹理作为alpha扰动源float paper tex2D(_PaperTex, i.paperUV).r; float wing tex2D(_NoiseTex, i.uv * 2.0).r; float alpha tex2D(_BrushTex, i.uv).a * lerp(0.85, 1.15, paper * wing);这样墨迹在纸纤维密集的地方略微深一点、边缘稍微散开一点整体看起来就有“墨吃进纸里”的感觉。7. WebGL小游戏与移动端的性能取舍如果你做的是微信小游戏或其他WebGL平台Unity里很多常用操作都需要重新审视。毛笔笔迹这种高频更新的Mesh正好是容易踩坑的地方。7.1 控制顶点数量比优化代码更有效顶点数量直接决定每帧Mesh更新的开销。一个书写流畅的笔画每秒大概需要20到40个采样点一分钟持续书写也就一两千个点看起来不多但如果每次更新都做全量SetVertices在WebGL上依然会感受到明显的延迟。我的经验是把一条长笔画拆成多个“段”每段固定数量的顶点比如256个顶点一段。写满一段就把这段Mesh设为static后续只更新当前正在写的那一小段。这样每帧上传的顶点数被限制在一个很小的范围内性能稳定很多。另外距离阈值不要设得太小。世界空间0.005和0.01的区别肉眼几乎看不到但顶点数量和GC压力翻一倍。7.2 多笔画的DrawCall与合批一个汉字五到十几个笔画每个笔画如果都是独立MeshRendererDrawCall也就是十几个绝大多数平台都能扛住。但如果用户连续写了很多字笔画总数上百就要考虑合并。建议在落笔抬起时把已完成笔画合并到一个静态Mesh里用CombineMeshes做一次合并同时保留弧长数据方便后续回放。如果用了同样的材质和同一个图集多个MeshRenderer还能走Unity的合批路径进一步降低DrawCall。7.3 触控输入的压感陷阱很多安卓设备和浏览器环境下触控压感并不可用返回的pressure可能恒为1或者0。如果代码把压感当主力参数会出现一种奇怪现象同一个手势在PC上写得很有笔锋在手机上写出来却像硬笔字。我的建议是始终用速度作为宽度的主驱动压感只在可用时作为小幅修正这样跨端表现才稳定。7.4 避免书写过程中的GC书写过程中高频产生的临时对象包括字符串、List、数组片段、甚至Path对象都会拖慢帧率。调试时用Debug.Log打坐标打印也要小心移动端上这种日志带来的开销比想象中大得多。书写逻辑的数据结构建议全部用预分配的数组和结构体避免每帧new。8. 调试排错几个必然遇到的奇怪现象做这个功能的过程中我踩过不少坑。下面这几个问题基本每个项目都会遇到提前排掉能省很多时间。8.1 网格有数据但屏幕上看不见最常见的原因是法线方向问题。如果用了带光照的默认着色器而生成Mesh时没有提供法线所有三角形会被当成黑色甚至因为背面剔除而消失。解决方法是生成网格后立即调用mesh.RecalculateNormals()或者干脆一开始就用Unlit/UnlitShade的Shader省去光照相关的坑。8.2 折角处Mesh“扭麻花”当笔迹宽度较大且相邻两个采样点方向变化剧烈时两个四边形会互相穿插产生像麻花一样的翻转。这个问题的根源是法线方向突变。我用过的有效解决办法有三个一是在预处理阶段加大插值密度让方向变化分解到多个小步骤二是对法线做平均平滑三是在锐角处额外生成一圈顶点作为“转角环”类似管状模型的转角处理。这三招可以组合使用优先用前两招第三招只有在需要极粗笔迹和极端锐角时才值得上。8.3 笔迹写完后在纸上“漂”着出现这种现象多半是Shader里用了屏幕空间坐标或者世界坐标来采样纸张纹理导致墨迹和纸面UV没对齐。纸面纹理一定要用模型本身的UV进行采样并把纸张平面放在一个固定的局部坐标系中这样才能做到墨迹和纸纹严丝合缝。8.4 噪声参数一调就出现花屏、拖影飞白噪声如果采样频率过高或者UV超出了0到1范围很容易出现闪烁着花屏的情况。检查噪声纹理的Wrap Mode是否设为Repeatuv.x的缩放系数是否跟笔画长度匹配。如果uv.x用了累计长度注意别让Unity在传输大float值时发生精度损失。顶点坐标和uv精度不一致时也会表现为远端笔迹出现细小的抖动甚至撕裂。最后分享一个我自己的经验在动手写完整方案之前建议做一套“数据可视化调试”把采样点、宽度、法线方向、弧长值全部用Gizmos画出来。很多效果问题其实不是渲染问题而是数据问题。宽度曲线不合理、法线跳动、采样密度不均匀这些在Gizmos视图里一眼就能看出来比反复调材质参数效率高得多。这道工序看起来多花了半小时但往往能让人少熬三个晚上的夜。