纯前端水墨枯笔与飞白笔触模拟:贝塞尔速度插值与动态噪点纹理合成
在东方传统水墨画与书法艺术中“飞白”与“枯笔”有着不可替代的美学意境。所谓“燥裂秋风润含春雨”当饱蘸浓墨的毛笔在粗糙宣纸上极速掠过毛尖受力分散墨水供不应求线条中便会自然呈现出丝丝缕缕露出的纸白。这种断而不断、意到笔不到的质感正是表现深秋枯荷、苍劲古松与写意落款的灵魂所在。然而在主流的数字手账与 Web 绘图工具中大部分画笔实现仅仅是将贝塞尔曲线渲染为均匀宽度的实体色块或者简单贴上一张重复的圆形笔刷贴图。这种做法在绘制水彩或马克笔时或许差强人意但一旦用来临摹水墨枯笔就会显得僵硬死板毫无宣纸纤维与毛笔锋芒相搏的物理生命力。为了在“秋日手账杂货铺”的插画上色面板中重现具有东方韵味的水墨飞白我们构建了一套纯前端水墨物理笔触模拟引擎。系统结合了 PointerEvent 高频压感采样、三次贝塞尔动态速度插值、墨量衰减流体模型以及基于离屏 Perlin 噪点的宣纸纤维蒙版合成无需任何服务端渲染在浏览器中即可实现 60FPS 丝滑写意的水墨飞白。物理机理飞白是如何在宣纸上诞生的要在数字画布上还原枯笔飞白首先必须理清现实世界中的物理相互作用规律下笔速度与墨水转移率的反比关系行笔速度 $v$ 越快毛笔在宣纸表面停留的时间越短纸纤维吸收的墨汁量呈指数级衰减压力与笔尖开叉的动态解构下笔重时笔肚压下接触面宽且墨水丰沛提笔疾走时笔尖毛丝分散开来形成若干独立的小毛缕宣纸凹凸纤维与表面张力生宣表面有着细微的植物纤维凹凸。当墨量充沛时墨水借毛细现象填平凹凸而当墨量不足且高速移动时墨水只能附着在纤维凸起处凹陷处便形成肉眼可见的白色细丝飞白。基于这三点物理规律我们抽象出以下纯前端渲染架构-------------------------------------------------------------- | PointerEvent 硬件采样 (坐标、压力 pressure、时间戳) | -------------------------------------------------------------- | v -------------------------------------------------------------- | 三次贝塞尔平滑插值器 (平滑硬件抖动输出高密时间步长) | -------------------------------------------------------------- | v -------------------------------------------------------------- | 动态墨水蓄水池模型 (Ink Reservoir Velocity Decay) | | (根据瞬时速度与笔画累积长度计算墨水匮乏系数) | -------------------------------------------------------------- | v -------------------------------------------------------------- | 离屏宣纸纤维噪点蒙版合成 (Offscreen Noise Mask Blending) | | (利用 destination-out 与 Simplex Noise 动态擦除飞白隙缝) | -------------------------------------------------------------- | v -------------------------------------------------------------- | Canvas 2D 主画布水墨浸润输出 | --------------------------------------------------------------核心实现动态墨水量与飞白噪点合成为了兼顾性能与画质我们不能在主渲染循环中逐像素实时计算数学噪点。方案是预先在离屏 Canvas 中烘焙一张高质感的宣纸纤维 Perlin 噪点纹理瓦片Noise Tile在行笔渲染时根据当前的“枯墨程度”动态调整合成透明度与纹理偏移。// brushes/ink-dry-brush.ts export interface StrokePoint { x: number; y: number; pressure: number; timestamp: number; } export class InkDryBrush { private ctx: CanvasRenderingContext2D; private noiseCanvas: HTMLCanvasElement; private noiseCtx: CanvasRenderingContext2D; // 墨水物理状态 private currentInkLevel 1.0; // 1.0 为饱墨0.0 为枯墨 private inkConsumptionRate 0.0018; // 笔画长度消耗系数 private speedFactor 0.0035; // 速度致枯系数 private lastPoint: StrokePoint | null null; private midPoint: StrokePoint | null null; constructor(targetCtx: CanvasRenderingContext2D) { this.ctx targetCtx; this.noiseCanvas document.createElement(canvas); this.noiseCanvas.width 256; this.noiseCanvas.height 256; const nCtx this.noiseCanvas.getContext(2d); if (!nCtx) throw new Error(无法初始化离屏噪点上下文); this.noiseCtx nCtx; this.bakePaperNoise(); } /** * 预烘焙宣纸纤维凹凸噪点纹理 */ private bakePaperNoise(): void { const w this.noiseCanvas.width; const h this.noiseCanvas.height; const imgData this.noiseCtx.createImageData(w, h); const data imgData.data; for (let y 0; y h; y) { for (let x 0; x w; x) { // 模拟宣纸纵向与横向交织的植物长纤维 const n1 Math.sin(x * 0.15) * Math.cos(y * 0.05); const n2 Math.sin(x * 0.03 y * 0.12); const randomGrain (Math.random() - 0.5) * 0.4; const value Math.floor(((n1 n2 randomGrain 2) / 4) * 255); const idx (y * w x) * 4; data[idx] value; // R data[idx 1] value; // G data[idx 2] value; // B data[idx 3] 255; // A } } this.noiseCtx.putImageData(imgData, 0, 0); } /** * 笔画起点重置墨水存量 */ public strokeStart(point: StrokePoint): void { this.lastPoint point; this.midPoint point; this.currentInkLevel 1.0; // 起笔饱墨 } /** * 笔画移动贝塞尔插值与枯笔渲染 */ public strokeMove(currPoint: StrokePoint): void { if (!this.lastPoint || !this.midPoint) { this.strokeStart(currPoint); return; } // 计算两点间距与时间差推导瞬时划行速度 (px/ms) const dx currPoint.x - this.lastPoint.x; const dy currPoint.y - this.lastPoint.y; const dist Math.hypot(dx, dy); const dt Math.max(1, currPoint.timestamp - this.lastPoint.timestamp); const velocity dist / dt; // 墨量实时衰减 this.currentInkLevel Math.max(0.05, this.currentInkLevel - dist * this.inkConsumptionRate); // 枯墨程度由剩余墨量与当前速度共同决定速度越高、余墨越少越枯 const dryness Math.min(1.0, (1.0 - this.currentInkLevel) velocity * this.speedFactor); // 贝塞尔中点平滑 const nextMid: StrokePoint { x: (this.lastPoint.x currPoint.x) / 2, y: (this.lastPoint.y currPoint.y) / 2, pressure: (this.lastPoint.pressure currPoint.pressure) / 2, timestamp: (this.lastPoint.timestamp currPoint.timestamp) / 2 }; // 基础笔画粗细受压力调节 const baseRadius 14 * (currPoint.pressure || 0.5); this.renderDrySegment(this.midPoint, nextMid, baseRadius, dryness); this.lastPoint currPoint; this.midPoint nextMid; } /** * 渲染带有飞白噪点的单段笔触 */ private renderDrySegment(start: StrokePoint, end: StrokePoint, radius: number, dryness: number): void { const ctx this.ctx; ctx.save(); // 绘制水墨主轮廓 ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.lineCap round; ctx.lineJoin round; ctx.lineWidth radius * 2; // 浓淡由枯度控制墨浓时偏黑 (#1c1b1a)枯墨时泛出淡淡的宿墨赭褐色 const alpha Math.max(0.2, 1.0 - dryness * 0.45); ctx.strokeStyle rgba(28, 27, 26, ${alpha.toFixed(2)}); ctx.stroke(); // 若达到枯笔阈值通过离屏噪点纹理进行飞白镂空 if (dryness 0.4) { ctx.globalCompositeOperation destination-out; const pattern ctx.createPattern(this.noiseCanvas, repeat); if (pattern) { ctx.fillStyle pattern; // 飞白镂空强度 ctx.globalAlpha Math.min(0.85, (dryness - 0.4) * 1.5); ctx.lineWidth radius * 2.2; ctx.stroke(); } } ctx.restore(); } /** * 笔画收尾 */ public strokeEnd(): void { this.lastPoint null; this.midPoint null; } }交互调优如何让手写更具宣纸韵味在实际书写与线稿上色场景中纯粹的数学算法还需要配合细致的交互感受调优才能达到以假乱真的宣纸手感1. 停顿浸润机制Ink Bleed on Dwell毛笔在宣纸上停顿不动时墨水会受毛细引力向四周轻微渗化。我们可以在pointerdown后启动一个 150ms 的轻量延迟定时器。如果用户落笔后短暂停留画笔会在接触点周围自动触发一段极慢速的径向渐变晕染呈现自然湿润的墨斑。2. 运笔防抖与压感平滑普通触控笔或鼠标的压感信号采样频率通常在 60Hz 到 120Hz 之间数据容易夹带离散突刺。在传入笔刷前我们使用单向低通滤波器Low-pass Filter对pressure曲线进行平滑过渡function smoothPressure(current: number, target: number, smoothingFactor 0.25): number { return current (target - current) * smoothingFactor; }这保证了即便在快速飞白提笔的瞬间线条粗细变化也是呈现优雅顺滑的渐变而绝不会产生锯齿状的台阶断面。3. 跨设备性能节流与瓦片自适应由于飞白渲染采用了destination-out蒙版混合在低性能移动设备上频繁重绘大面积高分辨率画布可能造成 GPU 显存带宽压力。我们通过离屏局部脏矩形Dirty Rect更新机制仅对当前笔画经过的最小包围盒进行蒙版计算确保在千元机甚至低功耗平板上都能达到 60FPS 丝滑不卡顿。听汐的画板手记留白里的生命律动在秋日手账的扉页上画下一株枯荷时我总会被那些断续的飞白所打动。完美饱满的黑色实线代表着确定与封闭而飞白所展现的是风的流向、是笔尖毛丝的挣扎、是宣纸纤维对墨汁的挽留与成全。计算机的像素原本只有“有颜色”和“无颜色”两种状态但当我们用算法赋予它们速度的迟疾、墨量的枯竭与纸质的呼吸时代码便触摸到了传统书画中最含蓄也最炽热的生命力。放下鼠标看着画板上那道刚刚飞驰而过的枯墨笔锋仿佛真有一阵秋风吹拂过桌面卷走了一纸轻尘。