前端,用SVG 模仿毛笔写字绘画,defs,filter

SVG 笔刷应用:实现思路与分步教程

这个项目巧妙地结合了 HTML、CSS、JavaScript 以及 SVG 的强大滤镜功能,构建了一个功能丰富、效果逼真的在线画板。下面我们将深入剖析其核心实现思路和具体的构建步骤。

一、 核心实现思路总结

项目的核心可以概括为 "SVG 滤镜负责外观,JavaScript 逻辑负责交互"

  1. 渲染核心 (SVG Filters) :

    • 笔刷质感 : 应用的灵魂在于 SVG 的 <filter> 元素。我们预先在 <defs> (definitions) 区域定义好多种滤镜效果。

    • 效果组合: 每种笔刷模式(如毛笔、铅笔)对应一个特定的滤镜。这些滤镜通过组合不同的"滤镜基元"(Filter Primitives)来实现:

      • feTurbulence: 创建随机的柏林噪声,这是模拟纸张纹理、笔触干湿变化的基础。
      • feDisplacementMap: 使用噪声纹理去"扭曲"我们绘制的线条,让线条边缘变得不规则,告别计算机生成的平滑感。
      • feGaussianBlur: 对线条进行模糊处理,模拟墨水晕染或铅笔的柔和效果。
      • feColorMatrix: 一个强大的颜色矩阵工具,在这里主要用来锐化 Alpha (透明度) 通道,让模糊的边缘重新变得清晰,产生类似墨水在宣纸上浸润后边缘清晰的效果。
    • 模式切换 : 所谓的"切换笔刷",在代码层面仅仅是改变承载所有笔画的 SVG <g> 元素的 filter 属性,将其指向不同的滤镜 ID。例如,从 filter="url(#calligraphy-filter)" 切换到 filter="url(#pencil-filter)"。而"钢笔"模式,则是不应用任何滤镜。

  2. 交互核心 (JavaScript) :

    • 绘画逻辑:

      • 监听鼠标/触摸屏的三个核心事件:mousedown (开始)、mousemove (移动)、mouseup (结束)。
      • 开始时,创建一个新的 SVG <path> 元素,并记录起始点。
      • 移动时,不断地向 <path> 元素的 d 属性中追加 L (Line To) 指令,从而延长路径。
      • 结束时,将这个完整的 <path> 元素存入一个"历史记录"数组中,为撤销功能做准备。
    • 状态管理:

      • 使用 isDrawing 布尔值来判断当前是否处于绘画状态。
      • historyStack (历史栈) 和 redoStack (重做栈) 是两个数组,分别用于存放用户的每一步笔画。
    • 功能实现:

      • 撤销 (Undo) : 从 historyStack 的末尾取出一个笔画元素,将其推入 redoStack,并将其在界面上隐藏 (display: 'none')。
      • 重做 (Redo) : 从 redoStack 的末尾取出一个笔画元素,将其推回 historyStack,并恢复其显示。
      • 清空 : 直接清空 <g> 容器的 innerHTML,并重置历史记录和重做栈。

二、 分步实现教程

让我们按照从无到有的顺序,梳理一遍这个应用的构建过程。

步骤 1: 搭建基础 HTML 骨架

这是应用的"骨骼"。

  1. 创建主文件 : 新建 brush_effect_demo.html

  2. 头部设置 : 在 <head> 中引入外部字体 (Google Fonts) 和 Tailwind CSS 以便快速构建美观的 UI。

  3. 主体布局 : 在 <body> 中放置两个主要部分:

    • 一个 <div> 作为顶部控制面板 (class="controls"),之后会放入所有按钮和滑块。
    • 一个 <svg> 元素作为画布 (id="drawing-canvas"),它将占据整个屏幕。
  4. SVG 内部结构 : 在 <svg> 内部,创建两个关键部分:

    • <defs>: 用于定义所有不会直接显示但会被引用的元素,我们的滤镜就放在这里。
    • <g>: 一个分组元素 (id="drawing-group"),之后所有绘制的 <path> 都会被添加到这个组里。将滤镜应用到这个组上,就可以让所有笔画都拥有同样的效果。

步骤 2: 定义笔刷效果 (SVG 滤镜)

这是应用的"灵魂",决定了画出来的东西好不好看。

  1. <defs> 标签内,为每一种笔刷模式创建一个 <filter> 元素,并给它一个唯一的 id

  2. 毛笔滤镜 (#calligraphy-filter) :

    • <feTurbulence> 生成类似水墨纹理的噪点。
    • <feDisplacementMap> 把噪点应用到笔画上,使其边缘产生自然的凹凸感。
    • <feGaussianBlur> 轻微模糊,模拟晕染。
    • <feColorMatrix> 锐化边缘,让晕染的墨迹有一个清晰的边界。
  3. 铅笔滤镜 (#pencil-filter) :

    • 同样使用 <feTurbulence>,但参数 (baseFrequency) 更高,噪点更细碎,模拟铅笔在粗糙纸张上的颗粒感。
    • 使用 <feDisplacementMap> 进行轻微的扭曲。这个效果相对简单,力求还原铅笔的质感。
  4. 泼墨滤镜 (#splatter-filter) :

    • 这是一个效果比较夸张的滤镜。feTurbulence 的频率较低,产生大块的随机斑点。
    • feDisplacementMapscale 值非常大,导致线条被严重扭曲,形成随机的墨点飞溅效果。

步骤 3: 实现核心绘画逻辑 (JavaScript)

这是应用的"大脑",负责响应用户操作。

  1. 获取元素 : 在 <script> 标签内,首先通过 document.getElementById 等方法获取所有需要操作的 DOM 元素(画布、按钮、滑块等)。

  2. 绑定事件 : 为 SVG 画布添加 mousedown, mousemove, mouseup, mouseleave 事件监听器。同时,为了兼容移动设备,也添加对应的 touchstart, touchmove, touchend 事件。

  3. startDrawing 函数:

    • 设置 isDrawing = true
    • 使用 document.createElementNS (注意命名空间) 创建一个 <path> 元素。
    • 设置其 stroke, stroke-width 等基本样式属性。
    • 将路径的起点 (d 属性) 设置为当前鼠标位置 M x,y
    • 将其添加到 <g> 容器中。
  4. draw 函数:

    • 首先检查 isDrawing 是否为 true
    • 获取当前鼠标位置,并更新 <path>d 属性,在末尾追加 L x,y
  5. stopDrawing 函数:

    • 设置 isDrawing = false
    • 将刚刚完成的 path 元素推入 historyStack 数组。
    • 清空 redoStack 数组,因为新的绘画操作会覆盖掉之前的"重做"历史。
    • 调用 updateUndoRedoState() 来更新按钮状态。

步骤 4: 添加撤销、重做与模式切换功能

这是让应用变得完整的"高级功能"。

  1. 撤销/重做逻辑:

    • undoButton 的点击事件:检查 historyStack 是否为空。如果不为空,就 pop() 出最后一个元素,push()redoStack,并将其隐藏。
    • redoButton 的点击事件:逻辑相反,从 redoStackpop()push()historyStack,并恢复显示。
  2. 模式切换逻辑:

    • 为所有模式按钮添加点击事件。
    • 在点击事件中,首先移除所有按钮的 active 样式,然后给当前点击的按钮加上。
    • 使用 switch 语句或 if/else,根据按钮的 data-mode 属性,通过 setAttributeremoveAttribute 来更改 <g> 元素的 filter 属性。

步骤 5: 美化界面 (CSS)

这是应用的"皮肤"。

  1. 使用 Tailwind CSS 的原子类快速设置了控制面板的布局、颜色、阴影和圆角等。

  2. 编写了少量自定义 CSS 来处理一些特殊效果,例如:

    • input[type="range"]input[type="color"] 的自定义样式。
    • .active 类,用于高亮显示当前选中的笔刷模式按钮。
    • 自定义光标,提供更沉浸的绘画体验。

通过以上五个步骤,一个功能完备、体验良好的在线 SVG 画板就诞生了。

xml 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SVG 笔刷效果演示</title>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        body {
            font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
            -webkit-font-smoothing: antialiased;
            -moz-osx-font-smoothing: grayscale;
        }
        /* 自定义光标 */
        #drawing-canvas {
            cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewport='0 0 100 100' style='fill:black;'><circle cx='10' cy='10' r='5'/></svg>") 10 10, auto;
        }
        .controls {
            position: absolute;
            top: 1.5rem;
            left: 50%;
            transform: translateX(-50%);
            padding: 0.75rem 1.5rem;
            background-color: rgba(255, 255, 255, 0.95);
            border-radius: 9999px;
            box-shadow: 0 4px A6px rgba(0, 0, 0, 0.1);
            display: flex;
            align-items: center;
            gap: 1.5rem; /* 增加了组之间的间距 */
            z-index: 10;
            flex-wrap: wrap; /* 允许换行 */
            justify-content: center;
        }
        .control-group {
            display: flex;
            align-items: center;
            gap: 0.75rem; /* 增加了组内元素的间距 */
        }
        .control-group label {
            font-size: 0.875rem;
            color: #4a5568;
            white-space: nowrap;
        }
        .control-group input[type="range"] {
            -webkit-appearance: none; appearance: none;
            width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; outline: none;
        }
        .control-group input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none; appearance: none;
            width: 16px; height: 16px; background: #4299e1; cursor: pointer; border-radius: 50%;
        }
        .control-group input[type="range"]::-moz-range-thumb {
            width: 16px; height: 16px; background: #4299e1; cursor: pointer; border-radius: 50%;
        }
        .control-group input[type="color"] {
            -webkit-appearance: none; -moz-appearance: none; appearance: none;
            width: 32px; height: 32px; background-color: transparent; border: none; cursor: pointer;
        }
        .control-group input[type="color"]::-webkit-color-swatch {
            border-radius: 50%; border: 2px solid #e2e8f0;
        }
        .control-group input[type="color"]::-moz-color-swatch {
            border-radius: 50%; border: 2px solid #e2e8f0;
        }
        .control-button {
            padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 600; color: #4a5568;
            background-color: #edf2f7; border: 2px solid transparent; border-radius: 9999px;
            cursor: pointer; transition: all 0.2s ease-in-out;
        }
        .control-button:hover {
            background-color: #e2e8f0; border-color: #c3dafe;
        }
        .control-button:disabled {
            opacity: 0.5; cursor: not-allowed;
        }
        .brush-mode-button.active {
            background-color: #4299e1; color: white; border-color: #2b6cb0;
        }
        .separator {
            width: 1px;
            height: 24px;
            background-color: #cbd5e0;
        }
    </style>
</head>
<body class="bg-gray-100 flex items-center justify-center h-screen overflow-hidden">

    <!-- 顶部控制面板 -->
    <div class="controls">
        <div class="control-group">
            <button id="undoButton" class="control-button">上一步</button>
            <button id="redoButton" class="control-button">下一步</button>
        </div>
        <div class="separator"></div>
        <div class="control-group">
            <button class="brush-mode-button control-button active" data-mode="calligraphy">毛笔</button>
            <button class="brush-mode-button control-button" data-mode="pen">钢笔</button>
            <button class="brush-mode-button control-button" data-mode="pencil">铅笔</button>
            <button class="brush-mode-button control-button" data-mode="splatter">泼墨</button>
        </div>
        <div class="separator"></div>
        <div class="control-group">
            <label for="brushSize">大小:</label>
            <input type="range" id="brushSize" min="5" max="80" value="20">
        </div>
        <div class="control-group">
            <label for="brushColor">颜色:</label>
            <input type="color" id="brushColor" value="#1a202c">
        </div>
        <div class="separator"></div>
        <button id="clearButton" class="control-button">清空</button>
    </div>

    <!-- SVG 画布 -->
    <svg id="drawing-canvas" class="w-full h-full bg-white shadow-lg">
        <defs>
            <!-- 1. 毛笔/书法滤镜 (Calligraphy Filter) -->
            <filter id="calligraphy-filter">
                <feTurbulence type="fractalNoise" baseFrequency="0.05 0.09" numOctaves="3" result="turbulence" />
                <feDisplacementMap in="SourceGraphic" in2="turbulence" scale="15" xChannelSelector="R" yChannelSelector="G" result="displaced" />
                <feGaussianBlur in="displaced" stdDeviation="1.5" result="blurred" />
                <feColorMatrix in="blurred" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 35 -15" result="sharpened"/>
            </filter>

            <!-- 2. 铅笔滤镜 (Pencil Filter) -->
            <filter id="pencil-filter">
                 <feTurbulence type="fractalNoise" baseFrequency="0.5" numOctaves="1" result="turbulence"/>
                 <feDisplacementMap in="SourceGraphic" in2="turbulence" scale="2" />
            </filter>
            
            <!-- 3. 泼墨滤镜 (Ink Splatter Filter) -->
            <filter id="splatter-filter">
                <feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="2" result="turbulence"/>
                <feDisplacementMap in="SourceGraphic" in2="turbulence" scale="50" result="displaced"/>
                <feGaussianBlur in="displaced" stdDeviation="3" result="blurred"/>
                 <feColorMatrix in="blurred" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 50 -10"/>
            </filter>
        </defs>
        <!-- 存放所有绘制路径的组 -->
        <g id="drawing-group" filter="url(#calligraphy-filter)"></g>
    </svg>

    <script>
        document.addEventListener('DOMContentLoaded', () => {
            // --- DOM 元素获取 ---
            const svg = document.getElementById('drawing-canvas');
            const drawingGroup = document.getElementById('drawing-group');
            const brushSizeSlider = document.getElementById('brushSize');
            const brushColorPicker = document.getElementById('brushColor');
            const clearButton = document.getElementById('clearButton');
            const undoButton = document.getElementById('undoButton');
            const redoButton = document.getElementById('redoButton');
            const brushModeButtons = document.querySelectorAll('.brush-mode-button');

            // --- 状态变量 ---
            let isDrawing = false;
            let currentPath;
            let currentBrushSize = brushSizeSlider.value;
            let currentBrushColor = brushColorPicker.value;
            let historyStack = [];
            let redoStack = [];

            // --- 函数定义 ---

            // 更新撤销/重做按钮的状态
            const updateUndoRedoState = () => {
                undoButton.disabled = historyStack.length === 0;
                redoButton.disabled = redoStack.length === 0;
            };

            // 获取鼠标/触摸点在SVG中的坐标
            const getPointerPosition = (event) => {
                const CTM = svg.getScreenCTM();
                const clientX = event.clientX ?? event.touches[0].clientX;
                const clientY = event.clientY ?? event.touches[0].clientY;
                return {
                    x: (clientX - CTM.e) / CTM.a,
                    y: (clientY - CTM.f) / CTM.d
                };
            };

            // 开始绘制
            const startDrawing = (event) => {
                event.preventDefault();
                isDrawing = true;
                const { x, y } = getPointerPosition(event);

                currentPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
                currentPath.setAttribute('d', `M${x},${y}`);
                currentPath.setAttribute('stroke', currentBrushColor);
                currentPath.setAttribute('stroke-width', currentBrushSize);
                currentPath.setAttribute('stroke-linecap', 'round');
                currentPath.setAttribute('stroke-linejoin', 'round');
                currentPath.setAttribute('fill', 'none');
                
                drawingGroup.appendChild(currentPath);
            };

            // 绘制过程
            const draw = (event) => {
                if (!isDrawing) return;
                event.preventDefault();
                const { x, y } = getPointerPosition(event);
                const d = currentPath.getAttribute('d');
                currentPath.setAttribute('d', `${d} L${x},${y}`);
            };

            // 停止绘制
            const stopDrawing = () => {
                if (!isDrawing) return;
                isDrawing = false;
                // 只有当路径真实存在(长度大于一个点)时才计入历史记录
                if (currentPath && currentPath.getAttribute('d').includes('L')) {
                    historyStack.push(currentPath);
                    redoStack = []; // 每次新的绘制都会清空重做栈
                } else if (currentPath) {
                    currentPath.remove(); // 移除无效的"点"路径
                }
                updateUndoRedoState();
                currentPath = null;
            };
            
            // --- 事件监听器 ---

            // 笔刷属性控制
            brushSizeSlider.addEventListener('input', (e) => currentBrushSize = e.target.value);
            brushColorPicker.addEventListener('input', (e) => currentBrushColor = e.target.value);

            // 清空画布
            clearButton.addEventListener('click', () => {
                drawingGroup.innerHTML = '';
                historyStack = [];
                redoStack = [];
                updateUndoRedoState();
            });

            // 上一步
            undoButton.addEventListener('click', () => {
                if (historyStack.length > 0) {
                    const lastPath = historyStack.pop();
                    lastPath.style.display = 'none'; // 隐藏而不是移除
                    redoStack.push(lastPath);
                    updateUndoRedoState();
                }
            });

            // 下一步
            redoButton.addEventListener('click', () => {
                if (redoStack.length > 0) {
                    const nextPath = redoStack.pop();
                    nextPath.style.display = ''; // 恢复显示
                    historyStack.push(nextPath);
                    updateUndoRedoState();
                }
            });
            
            // 笔刷模式切换
            brushModeButtons.forEach(button => {
                button.addEventListener('click', () => {
                    brushModeButtons.forEach(btn => btn.classList.remove('active'));
                    button.classList.add('active');
                    const mode = button.dataset.mode;
                    switch(mode) {
                        case 'calligraphy':
                            drawingGroup.setAttribute('filter', 'url(#calligraphy-filter)');
                            break;
                        case 'pencil':
                            drawingGroup.setAttribute('filter', 'url(#pencil-filter)');
                            break;
                        case 'splatter':
                            drawingGroup.setAttribute('filter', 'url(#splatter-filter)');
                            break;
                        case 'pen':
                            drawingGroup.removeAttribute('filter');
                            break;
                    }
                });
            });

            // 绑定绘制事件 (鼠标和触摸)
            svg.addEventListener('mousedown', startDrawing);
            svg.addEventListener('mousemove', draw);
            svg.addEventListener('mouseup', stopDrawing);
            svg.addEventListener('mouseleave', stopDrawing);
            svg.addEventListener('touchstart', startDrawing, { passive: false });
            svg.addEventListener('touchmove', draw, { passive: false });
            svg.addEventListener('touchend', stopDrawing);
            svg.addEventListener('touchcancel', stopDrawing);

            // 初始化按钮状态
            updateUndoRedoState();
        });
    </script>
</body>
</html>
相关推荐
怪可爱的地球人3 小时前
ts的迭代器和生成器
前端
FlowGram3 小时前
Flowgram 物料库建设思考与实践
前端
用户22152044278003 小时前
Promise实例方法和async、await语法糖
前端·javascript
普通码农3 小时前
uniapp开发微信小程序使用takeSnapshot截图保存分享
前端
snows_l4 小时前
MacOS 通过Homebrew 安装nvm
前端·macos
前端开发爱好者4 小时前
下一代全栈框架:Deno + Vite 的结合体!
前端·javascript·后端
CUGGZ4 小时前
前端部署,又有新花样?
前端·javascript
Zz_waiting.4 小时前
Javaweb 14.4 Vue3 视图渲染技术
前端·javascript·vue.js
前端开发爱好者4 小时前
一键 i18n 国际化神库!适配 Vue、React!
前端·javascript·vue.js