多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

日系复古网点纸贴图生成器:SVG Pattern 与半色调网点(Halftone)算法

日系复古网点纸贴图生成器:SVG Pattern 与半色调网点(Halftone)算法 在黑白漫画、复古海报与日系手账插画中“网点纸Screentone”一直是最具辨识度的视觉符号。从经典漫画人物衣服上的阴影到秋日手账边框里细腻的颗粒感那些排列整齐、随着明暗虚实变大变小的微小圆点不仅赋予了画面独特的复古印刷质感Vintage Print更在单色调的限制下营造出丰富的光影层次。传统手绘时期创作者需要用刻刀小心翼翼地裁剪实体背胶网点纸再刮贴到原稿纸上而在数字绘画时代很多初学者如果直接使用位图网点贴图往往会遇到两大顽疾缩放严重失真与马赛克位图网点一旦放大或打印边缘立刻模糊起狗牙灾难性的摩尔纹Moiré Pattern由于屏幕像素网格与固定频率网点发生干涉画面会出现一圈圈旋转晃眼的干扰纹路同时机械的网格无法根据不同部位的灰度明暗动态调整网点大小。为了给手账插画涂色功能注入地道纯正的日系复古质感听汐在纯前端探索了一套矢量半色调网点Halftone Screentone生成算法。它结合 HTML5 Canvas 的高频像素采样与 SVGpattern矢量图形定义能够实时根据手账插画的阴影明暗程序化生成无损缩放、旋转 45 度的纯矢量网点材质。一、半色调Halftone网点背后的光学与数学原理在传统胶印或活字印刷中油墨只有“印上”或“不印”两种状态并没有所谓的“50% 灰色油墨”。之所以人眼能看到平滑的灰色是因为大脑的视觉残留将密密麻麻的微小黑点与纸张白底在宏观上自动混合成了灰阶感。1. 亮度Luminance与网点半径的非线性映射在 RGB 色彩空间中人眼对绿色的敏感度远高于红和蓝。计算采样区域的加权灰度标准公式为$$\text{Luminance} 0.299 \times R 0.587 \times G 0.114 \times B$$随后将亮度归一化到 $[0, 1]$。在纯白区域$\text{Luminance} 1.0$网点半径为 $0$不绘制在最暗区域$\text{Luminance} 0.0$网点半径达到最大格子的一半甚至微小相切融合。2. 经典 45 度倾斜角与摩尔纹消解如果网点的排列方向与水平显示屏的像素扫描线0 度或 90 度完全平行极易引发视网膜级别的视觉共振产生丑陋的摩尔波纹。印刷工业百年来沉淀出的黄金法则是单色网点必须严格旋转 45 度45° Screen Angle。这个角度是人类眼球最不易觉察网格干涉的舒适夹角。二、架构设计从像素采样到 SVG 矢量网点生成流程被严密地划分为四个阶段[原始插画阴影图层输入] │ ▼ [离屏 Canvas 划分 45 度旋转网格并计算各单元平均亮度] │ ▼ [根据亮度映射出各网点圆心的 (cx, cy) 与半径 r] │ ▼ [组装为极轻量无损的纯 SVG 矢量结构或动态 Pattern]三、纯前端生成器核心工程实现代码下面是封装好的复古网点纸生成器 TypeScript 模块输出直接为纯 SVG 字符串支持无损放大到万倍打印// types.ts export interface HalftoneOptions { dotSpacing: number; // 网点间距网格步长建议 6 ~ 14px maxDotRadius?: number; // 最大圆点半径默认 dotSpacing / 2 * 0.9 angleDeg?: number; // 网格倾角默认 45 度 dotColor?: string; // 网点色彩默认深灰咖色 #3A322D contrastBoost?: number; // 对比度增益系数1.0 ~ 2.0 } // ScreentoneGenerator.ts export class AutumnScreentone { /** * 将任意图像源转换为高质感的矢量日系网点 SVG */ public static async generateFromImage( img: HTMLImageElement, options: HalftoneOptions ): Promisestring { const { dotSpacing 8, angleDeg 45, dotColor #3A322D, contrastBoost 1.2 } options; const width img.naturalWidth || img.width; const height img.naturalHeight || img.height; const maxRadius options.maxDotRadius ?? (dotSpacing / 2) * 0.95; // 1. 创建离屏画布读取像素数据 const offCanvas document.createElement(canvas); offCanvas.width width; offCanvas.height height; const ctx offCanvas.getContext(2d, { willReadFrequently: true })!; ctx.drawImage(img, 0, 0, width, height); const imgData ctx.getImageData(0, 0, width, height).data; // 2. 角度换算与旋转变换包围盒 const rad (angleDeg * Math.PI) / 180; const cos Math.cos(rad); const sin Math.sin(rad); // 计算旋转网格的边界范围 const diag Math.hypot(width, height); const startU -diag / 2; const endU diag / 2; const startV -diag / 2; const endV diag / 2; const cx width / 2; const cy height / 2; const circleSvgNodes: string[] []; // 3. 在 45 度旋转的极坐标网格上循环采样 for (let u startU; u endU; u dotSpacing) { for (let v startV; v endV; v dotSpacing) { // 反向旋转映射回原图像图像坐标系 const px Math.round(cx (u * cos - v * sin)); const py Math.round(cy (u * sin v * cos)); if (px 0 || px width || py 0 || py height) { continue; } // 获取当前采样点附近的平均亮度 const lum this.sampleLuminance(imgData, width, height, px, py, Math.floor(dotSpacing / 2)); // 如果接近纯白则不绘制任何点 if (lum 0.94) continue; // 根据暗度反比计算网点半径越暗点越大 let darkness 1.0 - lum; // 应用对比度增益曲线 darkness Math.min(1.0, Math.pow(darkness, 1.0 / contrastBoost)); const r (darkness * maxRadius).toFixed(2); if (r 0.4) { circleSvgNodes.push(circle cx${px} cy${py} r${r} fill${dotColor} /); } } } // 4. 组装为纯矢量 SVG return svg xmlnshttp://www.w3.org/2000/svg width${width} height${height} viewBox0 0 ${width} ${height} g classscreentone-layer ${circleSvgNodes.join(\n)} /g /svg ; } /** * 区域平均亮度采样 */ private static sampleLuminance( data: Uint8ClampedArray, imgW: number, imgH: number, cx: number, cy: number, sampleRadius: number ): number { let totalLum 0; let count 0; for (let dy -sampleRadius; dy sampleRadius; dy 2) { for (let dx -sampleRadius; dx sampleRadius; dx 2) { const x cx dx; const y cy dy; if (x 0 x imgW y 0 y imgH) { const idx (y * imgW x) * 4; const r data[idx]; const g data[idx 1]; const b data[idx 2]; const a data[idx 3] / 255; // 经典灰度加权并考虑透明通道透明视作白色纸张底色 const baseLum (0.299 * r 0.587 * g 0.114 * b) / 255; const finalLum baseLum * a (1 - a); // 透明部分按 1.0 (纯白) 混合 totalLum finalLum; count; } } } return count 0 ? 1.0 : totalLum / count; } }四、手账画板即时涂色交互与集成在手账上色工具中网点纸不仅可以整图生成还可以作为画笔的“填充图案Fill Pattern”。当用户在画板上圈选出一段插画线稿的封闭区域如枫树干或女孩的风衣下摆时系统动态生成一段极简的无损 SVGpattern挂载在 SVG 画布的defs中用户一笔划过被选中的线框内部立刻填满均匀复古的微型点阵pattern iddot-pattern-30 width8 height8 patternTransformrotate(45 0 0) patternUnitsuserSpaceOnUse circle cx2 cy2 r1.4 fill#54483C / /pattern配合手账背景的牛皮纸米黄色底板呈现出如同 80 年代经典日漫杂志印刷在粗糙新闻纸上的温暖怀旧感。五、结语在 3D 渲染与超高清写实风格盛行的今天以网点纸为代表的复古黑白美学之所以依然历久弥新是因为它通过极度克制的形式语言激发了观者大脑中最丰富的想象力空间。用纯前端代码将百年前的机械印刷网点转换为充满现代数学美感的矢量阵列让数字时代的每一次涂色都依然带着胶片与铅字的温度。
返回列表