恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
OpenGL毛笔字实时渲染:笔锋几何与墨迹扩散算法
首页
资讯中心
/
OpenGL毛笔字实时渲染:笔锋几何与墨迹扩散算法
OpenGL毛笔字实时渲染:笔锋几何与墨迹扩散算法
发布时间:2026/10/14 1:27:47
简介这份资源围绕OpenGL实现毛笔字渲染算法展开面向具备一定C与图形学基础、希望研究笔触模拟与水墨效果的开发者。它解决的是如何在实时渲染中还原毛笔书写时笔画粗细变化、墨迹扩散与边缘毛糙感的问题可用于书法教学软件、国风游戏UI或数字绘画工具的原型验证。压缩包共37个文件约840KB以bmp位图、h头文件、cpp源文件为主另含jpg贴图、ico图标及vc工程文件位图与贴图用于笔刷纹理和背景素材头文件与源文件构成核心渲染逻辑工程文件可直接在Visual Studio中打开编译。资源已有367人学习下载说明其在图形学与毛笔模拟方向具有一定参考价值。读者可获得一套可运行的VC源码从中理解毛笔笔画的几何建模、纹理采样与混合渲染思路并借助示例图片与工程结构快速调试、二次开发适合作为图形学课程设计或算法研究的起点。1. 用 OpenGL 写毛笔字算法从笔锋到墨韵的实时渲染路径很多做图形学的朋友第一次听到“用 OpenGL 写毛笔字算法”脑子里蹦出来的画面是拿glLineWidth画一条粗线再贴个毛笔纹理。真跑起来就会发现线条拐弯处像折铁丝起笔收笔像被刀切墨色均匀得像马克笔——完全没有毛笔那种“提按顿挫、枯湿浓淡”的味道。毛笔字渲染的难点不在画线而在把笔毫形变、墨量扩散、纸张吸墨这三件事在 GPU 上实时算出来。这套方案适合两类人一类是做书法教学 App、电子签批、国风游戏 UI 的图形开发者另一类是想拿 OpenGL 练手做非真实感渲染NPR的工程师。下面我按自己踩过的路径把笔锋几何、墨迹扩散、参数调优和避坑点拆开讲代码基于 OpenGL 3.3 Core Profile用 C 和 GLSL 实现思路换成 Metal 或 Vulkan 也成立。2. 毛笔笔锋的几何建模为什么不能用一条粗线糊弄2.1 笔毫形变的核心把笔画拆成骨架点加宽度剖面毛笔和硬笔的本质区别是笔毫会散开、会分叉、会随压力改变接触面。如果只用一条中心线加固定宽度写“捺”的时候收笔处会变成一个圆头写“钩”的时候转折处会鼓包。我一般把一笔拆成三样东西骨架点序列记录笔尖轨迹、每个骨架点上的压力值0 到 1、以及笔毫的横向散开系数。压力决定笔画粗细散开系数决定边缘是齐整还是毛糙。具体做法是在 CPU 端把用户输入的轨迹点做一次重采样保证点间距均匀然后对每个点计算切线方向和法线方向。沿法线方向向两侧各偏移width * pressure的距离得到左右两条边界线。但这样得到的边界是折线直接三角化会有明显棱角所以还要在边界上做一次 Catmull-Rom 插值把边界点加密到原来的 4 到 8 倍。// 骨架点结构位置、压力、散开系数 struct BrushPoint { glm::vec2 pos; // 屏幕坐标或纹理坐标 float pressure; // 0.0 ~ 1.0由压感笔或鼠标速度模拟 float spread; // 0.0 ~ 1.0笔毫散开程度 }; // 对骨架点做 Catmull-Rom 加密生成左右边界顶点 std::vectorglm::vec2 buildOutline(const std::vectorBrushPoint skeleton, float baseWidth) { std::vectorglm::vec2 left, right; for (size_t i 0; i skeleton.size(); i) { // 计算切线用前后点差分首尾做单边差分 glm::vec2 tangent; if (i 0) tangent skeleton[1].pos - skeleton[0].pos; else if (i skeleton.size() - 1) tangent skeleton[i].pos - skeleton[i-1].pos; else tangent skeleton[i1].pos - skeleton[i-1].pos; tangent glm::normalize(tangent); glm::vec2 normal(-tangent.y, tangent.x); // 半宽 基础宽度 * 压力 * (1 散开系数) float halfW baseWidth * skeleton[i].pressure * (1.0f skeleton[i].spread * 0.8f); left.push_back(skeleton[i].pos normal * halfW); right.push_back(skeleton[i].pos - normal * halfW); } // 把 left 和 right 按 Catmull-Rom 加密后合并成一条闭合轮廓 // 实际工程里会分别加密再拼接这里省略插值函数细节 std::vectorglm::vec2 outline; outline.insert(outline.end(), left.begin(), left.end()); outline.insert(outline.end(), right.rbegin(), right.rend()); return outline; }这段代码里baseWidth是笔刷的基础宽度单位是屏幕像素或纹理坐标单位取决于你的坐标系。pressure我通常用鼠标移动速度反推移动越快压力越小模拟真实书写时快速运笔笔迹变细。spread在起笔和收笔时给一个较大的值让边缘出现毛刺感。注意halfW的计算里我加了一个0.8的系数这是为了让散开效果不至于把笔画撑得太肥这个值可以根据笔刷类型在 0.5 到 1.2 之间调。2.2 三角化与抗锯齿把轮廓变成 GPU 能吃的三角形得到闭合轮廓后下一步是三角化。毛笔笔画通常是凸多边形或者接近凸多边形用耳切法ear clipping就够不需要上复杂的 Delaunay。但这里有个坑如果轮廓点太密耳切法会产生大量细长三角形光栅化时容易出现裂缝。我的做法是先用 Douglas-Peucker 算法对轮廓做一次简化容差取baseWidth * 0.05然后再三角化。三角化之后每个顶点除了位置还要带三个属性沿笔画的纵向参数t0 到 1用于采样墨迹纹理、横向参数s-1 到 1-1 是左边缘1 是右边缘、以及该点的压力值。这三个属性会传给片元着色器用来算墨色和边缘虚化。// 顶点着色器传递纵向、横向参数和压力 #version 330 core layout(location 0) in vec2 aPos; layout(location 1) in float aT; // 纵向参数 0~1 layout(location 2) in float aS; // 横向参数 -1~1 layout(location 3) in float aPressure; out float vT; out float vS; out float vPressure; void main() { vT aT; vS aS; vPressure aPressure; gl_Position vec4(aPos, 0.0, 1.0); }顶点着色器本身很简单关键在片元着色器里怎么用这三个参数。vS的绝对值越接近 1说明越靠近笔画边缘墨色应该越淡模拟笔毫边缘墨量少的效果。vT用来采样一张一维的墨量变化纹理模拟一笔之中墨量的自然衰减。2.3 笔锋分叉的模拟用噪声扰动边缘顶点真实毛笔在枯笔状态下笔毫会分叉笔画边缘会出现不规则的缺口。如果只在片元里做 alpha 噪声边缘会显得“虚”但不“裂”。我的做法是在顶点着色器里对边缘顶点做位置扰动只扰动aS绝对值大于 0.7 的顶点扰动幅度和spread成正比噪声用一张 2D 噪声纹理采样采样坐标是(vT * 10.0, vS * 3.0)。// 顶点着色器里加入边缘扰动 uniform sampler2D uNoiseTex; uniform float uSpreadScale; void main() { vec2 pos aPos; if (abs(aS) 0.7) { float noise texture(uNoiseTex, vec2(aT * 10.0, aS * 3.0)).r; // 沿法线方向扰动法线方向近似用 aS 的符号决定 vec2 dir vec2(0.0, sign(aS)); pos dir * (noise - 0.5) * uSpreadScale * aPressure; } gl_Position vec4(pos, 0.0, 1.0); }uSpreadScale控制分叉的剧烈程度我一般设在 2 到 6 像素之间。噪声纹理用一张 256x256 的灰度图内容用 Perlin 噪声生成注意要设置GL_REPEAT和GL_LINEAR否则采样边界会出现硬边。这个扰动只影响视觉边缘不改变三角化拓扑所以不会引入裂缝。3. 墨迹扩散与纸张吸收片元着色器里的水墨算法3.1 墨量模型从笔尖到纸面的三层衰减毛笔落在宣纸上墨会沿着纸纤维扩散形成中间浓、边缘淡、外围有毛边的效果。我在片元着色器里把墨量分成三层核心墨量、边缘墨量、扩散墨量。核心墨量由vPressure和vT决定边缘墨量由1.0 - abs(vS)决定扩散墨量用一张预计算的扩散纹理采样采样坐标是vT和vS的组合。// 片元着色器计算最终墨色 #version 330 core in float vT; in float vS; in float vPressure; out vec4 FragColor; uniform sampler2D uDiffuseTex; // 扩散纹理模拟宣纸吸墨 uniform vec3 uInkColor; // 墨色通常是深灰到黑 uniform float uEdgeSoftness; // 边缘柔化系数 void main() { // 核心墨量压力越大越浓纵向衰减模拟墨量减少 float core vPressure * (1.0 - vT * 0.3); // 边缘墨量越靠边越淡 float edge 1.0 - smoothstep(0.6, 1.0, abs(vS)); // 扩散墨量从扩散纹理采样坐标用 vT 和 vS 映射 float diffuse texture(uDiffuseTex, vec2(vT, vS * 0.5 0.5)).r; // 三层叠加扩散层权重随压力降低而增大枯笔时扩散更明显 float ink core * edge diffuse * (1.0 - vPressure) * 0.6; // 边缘柔化用 smoothstep 让边缘过渡更自然 ink smoothstep(0.0, uEdgeSoftness, ink); FragColor vec4(uInkColor, ink); }uEdgeSoftness我一般设 0.15 到 0.3值越小边缘越硬适合写楷书值越大边缘越虚适合写行草。uDiffuseTex是一张 512x512 的灰度图用高斯模糊处理过的噪声生成模拟宣纸的纤维纹理。注意vS映射到 0 到 1 的时候用了vS * 0.5 0.5因为vS范围是 -1 到 1。3.2 纸张纹理与墨色混合让字“长”在纸上如果直接把墨色画在纯白背景上字会显得“浮”。真实书写中墨会渗进纸纤维纸的纹理会影响墨的最终颜色。我的做法是在片元着色器最后一步把墨色和纸张底色做一次混合混合系数由纸张纹理的亮度决定。uniform sampler2D uPaperTex; // 纸张纹理 uniform vec2 uPaperScale; // 纸张纹理缩放 void main() { // ... 前面计算 ink 的代码 ... vec2 paperUV gl_FragCoord.xy * uPaperScale; float paper texture(uPaperTex, paperUV).r; // 纸张越暗纤维越粗墨色越容易被吸收颜色偏灰 vec3 finalColor mix(uInkColor, uInkColor * 0.7, paper); // 最终 alpha 由墨量和纸张共同决定 float finalAlpha ink * (0.8 paper * 0.2); FragColor vec4(finalColor, finalAlpha); }uPaperScale控制纸张纹理的重复频率我一般设成1.0 / 256.0让纹理在屏幕上大约每 256 像素重复一次。纸张纹理用一张 512x512 的灰度图内容用分形噪声生成亮度范围控制在 0.6 到 1.0 之间避免太暗影响墨色判断。3.3 实时性能优化把能预计算的都预计算毛笔字渲染如果每帧都重新计算轮廓和三角化在移动端会卡成幻灯片。我的优化策略是只在笔画进行中动态更新骨架点和轮廓笔画结束后把整个笔画的三角形烘焙到一个 VBO 里不再每帧重建。墨迹扩散纹理和纸张纹理在初始化时生成一次运行时只做采样。另外片元着色器里的smoothstep和texture调用是性能大头。如果目标平台是移动端可以把uEdgeSoftness做成顶点属性插值减少片元里的计算。还有一个技巧把扩散纹理和纸张纹理打包到同一张纹理的 R 和 G 通道一次采样拿两个值减少纹理带宽。// 笔画结束后烘焙到 VBO 的伪代码 void bakeStroke(const std::vectorBrushPoint skeleton) { auto outline buildOutline(skeleton, baseWidth); auto triangles triangulate(outline); // 耳切法三角化 // 把 triangles 上传到 VBO只上传一次 glBindBuffer(GL_ARRAY_BUFFER, strokeVBO); glBufferData(GL_ARRAY_BUFFER, triangles.size() * sizeof(Vertex), triangles.data(), GL_STATIC_DRAW); // 后续渲染直接 draw不再重建 }这个烘焙策略的代价是笔画结束后不能再修改形状但书法场景里落笔无悔正好符合需求。如果要做“撤销重写”就保留骨架点重新烘焙一次即可代价可控。4. 参数调优与笔刷预设楷书、行书、草书怎么调4.1 三组核心参数宽度、压力曲线、扩散强度不同书体对参数的要求差别很大。我整理了一张表列出楷书、行书、草书三组预设的核心参数范围。这些值是在 1080p 屏幕上、笔刷基础宽度 20 像素的条件下测出来的换分辨率要按比例缩放。参数楷书行书草书baseWidth18~2220~2624~32压力曲线指数1.21.00.8spread 最大值0.20.50.9uEdgeSoftness0.120.200.30扩散层权重0.30.50.8边缘扰动幅度1~2 px3~4 px5~8 px压力曲线指数是指pressure pow(rawPressure, exponent)指数大于 1 时轻压变化不明显、重压变化剧烈适合楷书的顿笔指数小于 1 时轻压就有明显变化适合草书的连绵。4.2 用鼠标模拟压感速度反推与平滑滤波没有压感笔的场合只能用鼠标移动速度反推压力。原始速度算出来的压力抖动很大直接用在笔画上会看到粗细忽变。我一般做两级滤波先对速度做指数平滑再把平滑后的速度映射到压力。// 鼠标速度转压力带指数平滑 float smoothSpeed 0.0f; float lastTime 0.0f; glm::vec2 lastPos; float computePressure(glm::vec2 pos, float time) { float dt time - lastTime; if (dt 1e-4) return currentPressure; float speed glm::length(pos - lastPos) / dt; // 指数平滑alpha 越小越平滑但延迟越大 smoothSpeed smoothSpeed * 0.7f speed * 0.3f; // 速度越快压力越小映射到 0.2~1.0 float pressure 1.0f - smoothSpeed / (smoothSpeed 800.0f); pressure glm::clamp(pressure, 0.2f, 1.0f); lastPos pos; lastTime time; return pressure; }800.0f这个常数是速度的半衰点单位是像素每秒。屏幕越大这个值要越大1080p 下 800 左右比较跟手。平滑系数0.7和0.3可以根据手感调追求跟手就加大0.3追求平滑就减小。4.3 笔刷预设的存储与切换实际项目里不会把参数硬编码在着色器里而是做成 uniform 变量运行时切换。我一般用一个结构体存预设切换时批量更新 uniform。struct BrushPreset { float baseWidth; float pressureExponent; float spreadMax; float edgeSoftness; float diffuseWeight; float noiseScale; }; void applyPreset(const BrushPreset p) { glUniform1f(uBaseWidthLoc, p.baseWidth); glUniform1f(uPressureExpLoc, p.pressureExponent); glUniform1f(uSpreadMaxLoc, p.spreadMax); glUniform1f(uEdgeSoftnessLoc, p.edgeSoftness); glUniform1f(uDiffuseWeightLoc, p.diffuseWeight); glUniform1f(uNoiseScaleLoc, p.noiseScale); }切换预设时要注意正在书写中的笔画不要立即切换否则一笔之内参数突变会很难看。我的做法是等当前笔画结束后再应用新预设。5. 避坑与排查毛笔字渲染里最容易翻车的五个点5.1 笔画拐弯处出现“折角”或“鼓包”现象写“横折”的时候拐角外侧出现尖角内侧出现重叠的暗斑。原因是骨架点在拐角处法线方向突变左右边界线在拐角处交叉。解决办法是在拐角处插入一个圆角过渡以拐角点为圆心用圆弧连接前后两段边界。圆弧半径取baseWidth * pressure * 0.5。如果不想改几何也可以在片元着色器里对拐角区域做一次额外的 alpha 混合但效果不如几何修正干净。5.2 墨色在笔画重叠处变得过浓现象写“十”字的时候横竖交叉处明显比别处黑。原因是两个笔画的 alpha 叠加了。解决办法有两种一是用glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA)做预乘 alpha 混合让重叠处不会无限叠加二是在一个笔画内部用glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)笔画之间用离屏渲染合并。我一般用第一种简单且效果可接受。5.3 边缘扰动导致三角形出现裂缝现象枯笔效果开启后笔画边缘出现细小的白色裂缝。原因是顶点扰动只改了位置但相邻三角形的顶点扰动不一致导致三角形之间出现缝隙。解决办法是扰动噪声的采样坐标必须基于顶点的原始位置而不是基于vT和vS这样相邻顶点如果原始位置接近扰动也接近。另外扰动幅度不要超过baseWidth * 0.1否则裂缝会很明显。5.4 移动端帧率骤降现象在手机上写连续笔画时帧率从 60 掉到 20。原因通常是每帧都在重建 VBO 和重新三角化。解决办法是把笔画分成“进行中”和“已完成”两段进行中的笔画用动态 VBO每帧只更新最后几个骨架点对应的三角形已完成的笔画烘焙到静态 VBO。另外片元着色器里的texture调用次数要控制扩散纹理和纸张纹理尽量打包到一张纹理里。5.5 高 DPI 屏幕上笔画发虚现象在 4K 屏上笔画边缘模糊没有锐利感。原因是顶点坐标用的是逻辑像素没有乘以设备像素比。解决办法是在顶点着色器里把位置乘以uDevicePixelRatio或者在 CPU 端就把骨架点坐标转换到物理像素。另外抗锯齿不要依赖 MSAA毛笔字边缘本身就需要柔化MSAA 反而会让边缘出现锯齿状的硬边。我一般关掉 MSAA用片元里的smoothstep做边缘柔化。6. 进阶技巧用一张高度图让笔锋“立”起来前面讲的都是 2D 平面上的墨迹渲染如果想让毛笔字有立体感比如在国风游戏里做浮雕效果可以加一张高度图。高度图的生成方式很简单把墨量ink当作高度在片元着色器里计算梯度然后用梯度做简单的光照。// 用墨量梯度做伪光照让笔锋有立体感 uniform vec3 uLightDir; // 光照方向归一化 void main() { // ... 前面计算 ink 的代码 ... // 用相邻像素的 ink 差值近似梯度 float inkRight texture(uInkTex, vUV vec2(1.0/512.0, 0.0)).r; float inkUp texture(uInkTex, vUV vec2(0.0, 1.0/512.0)).r; vec3 normal normalize(vec3(ink - inkRight, ink - inkUp, 0.1)); float diffuse max(dot(normal, uLightDir), 0.0); vec3 litColor uInkColor * (0.6 0.4 * diffuse); FragColor vec4(litColor, ink); }这里uInkTex是把当前帧的墨量渲染到一张离屏纹理上然后在下一帧采样。注意0.1这个 z 分量控制立体感的强弱值越小立体感越强但越容易过曝。光照方向我一般设成normalize(vec3(-0.5, 0.5, 1.0))模拟左上方来光。另一个进阶方向是笔顺动画。把一笔的骨架点按时间参数化用uProgress控制当前写到哪个点配合墨量从笔尖向笔尾扩散的效果可以做出“一笔一划写出来”的动画。关键是在片元着色器里用smoothstep(uProgress - 0.1, uProgress, vT)来裁剪墨量让笔画前端有一个渐显的过渡。我自己的习惯是每调完一组笔刷参数先写十个“永”字因为“永”字八法涵盖了横竖撇捺钩提折能快速暴露拐角、收笔、枯笔的问题。如果“永”字写得顺眼基本这套参数就稳了。希望帮到你。本文还有配套的精品资源点击获取