深秋时节,若得半日闲暇,我总喜欢翻看桌边的汉魏碑刻与砖瓦拓本。那些历经千年风雨侵蚀的石刻文字,被古人以澄心堂纸、松烟黑墨与手作拓包覆于其上,细细扑打拓印出来。字迹如骨,凹槽留白似雪,凸起处墨色深沉如铁,且在边缘处伴随着飞白与水墨微微晕染的温润颗粒。在数字化插画与现代手账设计中,纯矢量或扁平黑白填充往往失之于呆板锐利,缺少金石拓片那种沉浑古雅的厚度与呼吸感。
今天我想和大家分享一个纯前端实现的“金石碑帖拓印微引擎”。我们不需要依赖任何臃肿的 WebGL 3D 渲染引擎,仅在标准的 2D Canvas 与离屏像素缓冲区内,利用微表面高度场接触检测(Heightmap Contact Sampling)与松烟墨色物理扩散算法,重现“覆纸、打拓、受墨、留白”的传统拓印体验。
传统金石拓印的物理机理拆解
要用算法模拟出逼真的拓片质感,必须先理解传统打拓的物理过程:
- 凹凸高度场(Heightmap & Engraved Depths):
石碑表面并非光滑平面,阴刻的文字或花纹是低于基准面的凹槽,而石面本身由于岁月风化具有细微的砂石起伏。当湿润的宣纸覆于石面并用鬃刷轻轻打入凹陷后,纸面就形成了与石刻完全契合的微三维起伏。 - 拓包触碰与受墨面积(Contact Compression):
拓工手持由丝绸与棉花包裹而成的拓包(墨扑),蘸取微湿的松烟墨。当拓包拍打或擦拭纸面时,拓包柔软的弧形表面只会接触到突起的石面平整处,而深陷在文字凹线内的纸张则不会受到垂直压力。拓包施加的下压力越大,触及的边缘斜坡面积也越宽。 - 松烟墨的干湿与纤维渗透(Soot Ink Dynamics):
墨汁在宣纸纤维中受表面张力与毛细效应影响,并非均质色块。墨色在初触纸时形成浓墨(焦墨),多次反复扑拓则墨层层层叠加;而如果墨汁水分较少,在砂砾凸起处会形成苍茫的“飞白”,凹陷边界则会有自然的颗粒状墨尘沉积。
核心架构:离屏高度场检测与交互拓包实现
在前端工程中,我们将用户输入的黑白线稿或灰度图像作为高度场输入(白色或明亮区域代表平整凸起石面,黑色阴线代表深刻凹槽),拓包则是携带压力与含墨量参数的动力学画刷。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>纯前端传统金石碑帖拓印微引擎</title> <style> :root { --bg-desk: #211e1b; --panel-bg: #2d2925; --paper-cream: #eee5d8; --accent-wood: #a67c52; } * { box-sizing: border-box; margin: 0; padding: 0; } body { background-color: var(--bg-desk); color: var(--paper-cream); font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Kaiti SC", serif; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 30px 20px; user-select: none; } h1 { font-size: 20px; font-weight: 400; letter-spacing: 0.15em; margin-bottom: 8px; } .sub-tip { font-size: 13px; color: #9c9186; margin-bottom: 24px; } .workspace { position: relative; background-color: var(--paper-cream); box-shadow: 0 15px 40px rgba(0, 0, 0, 0.5); border-radius: 4px; cursor: crosshair; } canvas { display: block; border-radius: 4px; } .tools { margin-top: 24px; display: flex; gap: 20px; align-items: center; background: var(--panel-bg); padding: 12px 24px; border-radius: 100px; border: 1px solid rgba(255, 255, 255, 0.08); font-size: 13px; } .control-group { display: flex; align-items: center; gap: 8px; } button { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.15); color: var(--paper-cream); padding: 6px 16px; border-radius: 20px; cursor: pointer; font-size: 12px; transition: all 0.2s ease; } button:hover { background: var(--accent-wood); color: #fff; } input[type="range"] { accent-color: var(--accent-wood); width: 90px; } </style> </head> <body> <h1>金石金石 • 传统碑帖拓印工坊</h1> <div class="sub-tip">用鼠标或手指按住纸面,如手持墨拓包般轻轻擦拓,凹槽字迹自会留白显现</div> <div class="workspace"> <canvas id="rubbingCanvas" width="600" height="420"></canvas> </div> <div class="tools"> <div class="control-group"> <span>拓包尺寸:</span> <input type="range" id="sizeRange" min="15" max="50" value="30"> </div> <div class="control-group"> <span>下压墨重:</span> <input type="range" id="pressureRange" min="0.1" max="0.8" step="0.05" value="0.35"> </div> <div class="control-group"> <span>纸面宣透:</span> <input type="range" id="dryRange" min="0.2" max="0.9" step="0.05" value="0.55"> </div> <button id="resetPaperBtn">重换净纸</button> </div> <script> const canvas = document.getElementById('rubbingCanvas'); const ctx = canvas.getContext('2d', { willReadFrequently: true }); // 离屏高度场 Canvas(存储石刻的高度三维信息) const heightCanvas = document.createElement('canvas'); heightCanvas.width = canvas.width; heightCanvas.height = canvas.height; const heightCtx = heightCanvas.getContext('2d'); let heightData = null; let isRubbing = false; // 1. 初始化模拟石碑底板刻痕(生成带砂砾噪点与汉魏风骨的深刻阴文) function initStoneHeightmap() { heightCtx.fillStyle = '#ffffff'; // 高平面(基准平整石面) heightCtx.fillRect(0, 0, heightCanvas.width, heightCanvas.height); // 叠加石面自然风化砂砾颗粒噪点 const imgData = heightCtx.getImageData(0, 0, heightCanvas.width, heightCanvas.height); const data = imgData.data; for (let i = 0; i < data.length; i += 4) { const noise = (Math.random() - 0.5) * 20; const val = Math.min(255, Math.max(0, 255 + noise)); data[i] = val; data[i + 1] = val; data[i + 2] = val; } heightCtx.putImageData(imgData, 0, 0); // 镌刻阴文书法字符(深色表示深凹槽,拓包拍不到) heightCtx.fillStyle = '#050505'; heightCtx.font = 'bold 64px "Kaiti SC", "Songti SC", serif'; heightCtx.textAlign = 'center'; heightCtx.textBaseline = 'middle'; const lines = ["静水深流", "秋水长天", "金石永固"]; lines.forEach((line, idx) => { heightCtx.fillText(line, heightCanvas.width / 2, 120 + idx * 90); }); // 刻画古雅的汉砖回纹边框 heightCtx.strokeStyle = '#050505'; heightCtx.lineWidth = 6; heightCtx.strokeRect(30, 30, heightCanvas.width - 60, heightCanvas.height - 60); // 缓存高度场原始像素数据以供微表面碰撞检测 heightData = heightCtx.getImageData(0, 0, heightCanvas.width, heightCanvas.height).data; } // 2. 铺上一张洁净的微黄生宣纸(带细微纤维纹理) function resetPaper() { ctx.fillStyle = '#f4ece1'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 绘制生宣纸的细微木浆纤维底色 ctx.fillStyle = 'rgba(180, 160, 140, 0.04)'; for (let i = 0; i < 15000; i++) { const x = Math.random() * canvas.width; const y = Math.random() * canvas.height; ctx.fillRect(x, y, 1.5, 1.5); } } // 3. 核心算法:微表面拓包擦拓接触动力学 function applyRubbingDab(centerX, centerY, radius, pressure, dryness) { const startX = Math.max(0, Math.floor(centerX - radius)); const endX = Math.min(canvas.width, Math.ceil(centerX + radius)); const startY = Math.max(0, Math.floor(centerY - radius)); const endY = Math.min(canvas.height, Math.ceil(centerY + radius)); const w = endX - startX; const h = endY - startY; if (w <= 0 || h <= 0) return; const paperImgData = ctx.getImageData(startX, startY, w, h); const paperPixels = paperImgData.data; // 遍历拓包覆盖圆域 for (let y = 0; y < h; y++) { const curY = startY + y; for (let x = 0; x < w; x++) { const curX = startX + x; const dx = curX - centerX; const dy = curY - centerY; const distSq = dx * dx + dy * dy; if (distSq > radius * radius) continue; // 拓包中心向边缘的球形接触衰减(穹顶受力曲线) const domePressure = Math.cos((Math.sqrt(distSq) / radius) * (Math.PI / 2)); // 从石板高度场中读取当前像素的微高程(0~255,越接近 255 说明越平整突出) const heightIndex = (curY * canvas.width + curX) * 4; const surfaceHeight = heightData[heightIndex]; // 0~255 // 关键阈值检测:只有表面高度高于凹陷门槛的凸起石面,才会被拓包接触到! // 凹痕深处(文字笔画,低于 140)拓包完全悬空留白; // 边缘斜坡依据下压力产生部分接触; const contactThreshold = 140; if (surfaceHeight > contactThreshold) { // 归一化接触率 const heightFactor = (surfaceHeight - contactThreshold) / (255 - contactThreshold); // 加入松烟墨的干涩飞白随机因子 const speckleNoise = (Math.random() * 0.4 + 0.6); // 综合受墨强度 const inkAlpha = pressure * domePressure * heightFactor * speckleNoise * (1 - dryness * 0.3); if (inkAlpha > 0.01) { const pixelIndex = (y * w + x) * 4; // 松烟墨深黑底色(带一点点青灰矿物色温) const targetR = 26; const targetG = 25; const targetB = 24; // Alpha 混合渗透 paperPixels[pixelIndex] = Math.round(paperPixels[pixelIndex] * (1 - inkAlpha) + targetR * inkAlpha); paperPixels[pixelIndex + 1] = Math.round(paperPixels[pixelIndex + 1] * (1 - inkAlpha) + targetG * inkAlpha); paperPixels[pixelIndex + 2] = Math.round(paperPixels[pixelIndex + 2] * (1 - inkAlpha) + targetB * inkAlpha); } } } } ctx.putImageData(paperImgData, startX, startY); } // 4. 鼠标与触控事件监听 function handleMove(e) { if (!isRubbing) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX || e.touches[0].clientX) - rect.left; const y = (e.clientY || e.touches[0].clientY) - rect.top; const radius = parseFloat(document.getElementById('sizeRange').value); const pressure = parseFloat(document.getElementById('pressureRange').value); const dryness = parseFloat(document.getElementById('dryRange').value); applyRubbingDab(x, y, radius, pressure, dryness); } canvas.addEventListener('mousedown', (e) => { isRubbing = true; handleMove(e); }); window.addEventListener('mousemove', handleMove); window.addEventListener('mouseup', () => { isRubbing = false; }); canvas.addEventListener('touchstart', (e) => { isRubbing = true; handleMove(e); }, { passive: false }); window.addEventListener('touchmove', handleMove, { passive: false }); window.addEventListener('touchend', () => { isRubbing = false; }); document.getElementById('resetPaperBtn').addEventListener('click', () => { resetPaper(); }); // 初始化 initStoneHeightmap(); resetPaper(); </script> </body> </html>算法精髓:如何用像素级碰撞实现“石黑字白”的神韵?
在实现这个拓印微引擎时,我反复推敲了如何避免一般画笔“一刷子抹黑全部”的问题。传统的绘图软件依靠笔刷蒙版,但那种蒙版是静态的、与纸下物体无关的。而在真实拓印中,纸下的石板骨骼决定了墨色去往何方:
- 双层像素场的正交分离:我们维护了一个不可见的高度场离屏画布
heightCanvas,它的作用就像埋藏在宣纸底下的花岗岩石碑。文字的黑色笔画本质上是“海拔极低的地沟”。拓包在宣纸表面摩擦时,只根据heightData的高低差来决定墨水能否渗透着色。 - 拓包穹顶曲面的力学模拟:棉布拓包在下压时,中心受力最大,边缘逐渐翘起。我们在算法中引入了
domePressure = Math.cos(...)的余弦半波函数,让拓包画出的轨迹自然呈现从中央到外缘的渐进过渡,彻底摆脱了机械硬边缘的锯齿生硬感。 - 松烟微粒随机沉降:为了表现中国传统松烟墨轻盈细腻、偶有飞白的微观特性,代码在接触率计算中注入了带有高斯倾向的微小散斑噪点(Speckle Noise)。多次在同一个地方擦拓,墨层会由淡淡的微灰层层加重为沉郁苍茫的焦墨,宛如手工打拓数遍后的温润包浆。
结语与秋日随想
做完这个金石拓印算法后,我自己在画布上拿着虚拟拓包反反复复扑打了大半个小时。当原本空无一物的宣纸在鼠标的轻拭下,一点点浮现出“静水深流”四个苍劲字迹的留白轮廓时,那种发自内心的宁静感让人恍若隔世。
在快节奏的工程交付中,我们习惯了追求极速、高效与无损的矢量图形,但在某些时刻,正是那些手工带来的不均质、细微的凹凸阻力与墨色的渐次沉淀,才构成了数字艺术中最动人的温度。希望这个小巧的拓印引擎,也能成为你秋日手账创作中一抹古朴沉静的墨香。