
SVG 笔刷应用:实现思路与分步教程
这个项目巧妙地结合了 HTML、CSS、JavaScript 以及 SVG 的强大滤镜功能,构建了一个功能丰富、效果逼真的在线画板。下面我们将深入剖析其核心实现思路和具体的构建步骤。
一、 核心实现思路总结
项目的核心可以概括为 "SVG 滤镜负责外观,JavaScript 逻辑负责交互" 。
-
渲染核心 (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)"
。而"钢笔"模式,则是不应用任何滤镜。
-
-
交互核心 (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
,并重置历史记录和重做栈。
- 撤销 (Undo) : 从
-
二、 分步实现教程
让我们按照从无到有的顺序,梳理一遍这个应用的构建过程。
步骤 1: 搭建基础 HTML 骨架
这是应用的"骨骼"。
-
创建主文件 : 新建
brush_effect_demo.html
。 -
头部设置 : 在
<head>
中引入外部字体 (Google Fonts) 和 Tailwind CSS 以便快速构建美观的 UI。 -
主体布局 : 在
<body>
中放置两个主要部分:- 一个
<div>
作为顶部控制面板 (class="controls"
),之后会放入所有按钮和滑块。 - 一个
<svg>
元素作为画布 (id="drawing-canvas"
),它将占据整个屏幕。
- 一个
-
SVG 内部结构 : 在
<svg>
内部,创建两个关键部分:<defs>
: 用于定义所有不会直接显示但会被引用的元素,我们的滤镜就放在这里。<g>
: 一个分组元素 (id="drawing-group"
),之后所有绘制的<path>
都会被添加到这个组里。将滤镜应用到这个组上,就可以让所有笔画都拥有同样的效果。
步骤 2: 定义笔刷效果 (SVG 滤镜)
这是应用的"灵魂",决定了画出来的东西好不好看。
-
在
<defs>
标签内,为每一种笔刷模式创建一个<filter>
元素,并给它一个唯一的id
。 -
毛笔滤镜 (
#calligraphy-filter
) :- 用
<feTurbulence>
生成类似水墨纹理的噪点。 - 用
<feDisplacementMap>
把噪点应用到笔画上,使其边缘产生自然的凹凸感。 - 用
<feGaussianBlur>
轻微模糊,模拟晕染。 - 用
<feColorMatrix>
锐化边缘,让晕染的墨迹有一个清晰的边界。
- 用
-
铅笔滤镜 (
#pencil-filter
) :- 同样使用
<feTurbulence>
,但参数 (baseFrequency
) 更高,噪点更细碎,模拟铅笔在粗糙纸张上的颗粒感。 - 使用
<feDisplacementMap>
进行轻微的扭曲。这个效果相对简单,力求还原铅笔的质感。
- 同样使用
-
泼墨滤镜 (
#splatter-filter
) :- 这是一个效果比较夸张的滤镜。
feTurbulence
的频率较低,产生大块的随机斑点。 feDisplacementMap
的scale
值非常大,导致线条被严重扭曲,形成随机的墨点飞溅效果。
- 这是一个效果比较夸张的滤镜。
步骤 3: 实现核心绘画逻辑 (JavaScript)
这是应用的"大脑",负责响应用户操作。
-
获取元素 : 在
<script>
标签内,首先通过document.getElementById
等方法获取所有需要操作的 DOM 元素(画布、按钮、滑块等)。 -
绑定事件 : 为 SVG 画布添加
mousedown
,mousemove
,mouseup
,mouseleave
事件监听器。同时,为了兼容移动设备,也添加对应的touchstart
,touchmove
,touchend
事件。 -
startDrawing
函数:- 设置
isDrawing = true
。 - 使用
document.createElementNS
(注意命名空间) 创建一个<path>
元素。 - 设置其
stroke
,stroke-width
等基本样式属性。 - 将路径的起点 (
d
属性) 设置为当前鼠标位置M x,y
。 - 将其添加到
<g>
容器中。
- 设置
-
draw
函数:- 首先检查
isDrawing
是否为true
。 - 获取当前鼠标位置,并更新
<path>
的d
属性,在末尾追加L x,y
。
- 首先检查
-
stopDrawing
函数:- 设置
isDrawing = false
。 - 将刚刚完成的
path
元素推入historyStack
数组。 - 清空
redoStack
数组,因为新的绘画操作会覆盖掉之前的"重做"历史。 - 调用
updateUndoRedoState()
来更新按钮状态。
- 设置
步骤 4: 添加撤销、重做与模式切换功能
这是让应用变得完整的"高级功能"。
-
撤销/重做逻辑:
undoButton
的点击事件:检查historyStack
是否为空。如果不为空,就pop()
出最后一个元素,push()
进redoStack
,并将其隐藏。redoButton
的点击事件:逻辑相反,从redoStack
中pop()
,push()
回historyStack
,并恢复显示。
-
模式切换逻辑:
- 为所有模式按钮添加点击事件。
- 在点击事件中,首先移除所有按钮的
active
样式,然后给当前点击的按钮加上。 - 使用
switch
语句或if/else
,根据按钮的data-mode
属性,通过setAttribute
或removeAttribute
来更改<g>
元素的filter
属性。
步骤 5: 美化界面 (CSS)
这是应用的"皮肤"。
-
使用 Tailwind CSS 的原子类快速设置了控制面板的布局、颜色、阴影和圆角等。
-
编写了少量自定义 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>