更逼真的 HTML Canvas 绘图工具
用 JavaScript 创建一个基本的画布绘图工具很简单,但最终效果更像是用 MS Paint 画出来的,而不是莫奈的作品。不过,稍加修改,你就能做出一个效果更加逼真的工具。继续阅读,学习如何一针一线地构建画布画笔。
我们先从最基本的实现方式开始。首先,你需要在页面中设置一个简单的 canvas 元素。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, user-scalable=no" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Drawing tools</title>
<style>
body {
margin: 0;
}
canvas {
border: 2px solid black;
}
</style>
<script src="src/index.js" defer></script>
</head>
<body>
<canvas id="canvas" height="600" width="800"></canvas>
</body>
</html>
基本流程是监听 ` mousedownor`touchstart事件,并在该事件发生时开始绘制。然后,在touchmove`or` 事件发生时mousemove,从之前的画笔位置到当前位置绘制一条线。您可以添加多个监听器来处理绘制结束事件。
以下是鼠标事件的基本绘图处理程序:
// Brush colour and size
const colour = "#3d34a5";
const strokeWidth = 25;
// Drawing state
let latestPoint;
let drawing = false;
// Set up our drawing context
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");
// Drawing functions
const continueStroke = newPoint => {
context.beginPath();
context.moveTo(latestPoint[0], latestPoint[1]);
context.strokeStyle = colour;
context.lineWidth = strokeWidth;
context.lineCap = "round";
context.lineJoin = "round";
context.lineTo(newPoint[0], newPoint[1]);
context.stroke();
latestPoint = newPoint;
};
// Event helpers
const startStroke = point => {
drawing = true;
latestPoint = point;
};
const BUTTON = 0b01;
const mouseButtonIsDown = buttons => (BUTTON & buttons) === BUTTON;
// Event handlers
const mouseMove = evt => {
if (!drawing) {
return;
}
continueStroke([evt.offsetX, evt.offsetY]);
};
const mouseDown = evt => {
if (drawing) {
return;
}
evt.preventDefault();
canvas.addEventListener("mousemove", mouseMove, false);
startStroke([evt.offsetX, evt.offsetY]);
};
const mouseEnter = evt => {
if (!mouseButtonIsDown(evt.buttons) || drawing) {
return;
}
mouseDown(evt);
};
const endStroke = evt => {
if (!drawing) {
return;
}
drawing = false;
evt.currentTarget.removeEventListener("mousemove", mouseMove, false);
};
// Register event handlers
canvas.addEventListener("mousedown", mouseDown, false);
canvas.addEventListener("mouseup", endStroke, false);
canvas.addEventListener("mouseout", endStroke, false);
canvas.addEventListener("mouseenter", mouseEnter, false);
我们需要添加一些额外的处理程序来处理触摸事件。
const getTouchPoint = evt => {
if (!evt.currentTarget) {
return [0, 0];
}
const rect = evt.currentTarget.getBoundingClientRect();
const touch = evt.targetTouches[0];
return [touch.clientX - rect.left, touch.clientY - rect.top];
};
const touchStart = evt => {
if (drawing) {
return;
}
evt.preventDefault();
startStroke(getTouchPoint(evt));
};
const touchMove = evt => {
if (!drawing) {
return;
}
continueStroke(getTouchPoint(evt));
};
const touchEnd = evt => {
drawing = false;
};
canvas.addEventListener("touchstart", touchStart, false);
canvas.addEventListener("touchend", touchEnd, false);
canvas.addEventListener("touchcancel", touchEnd, false);
canvas.addEventListener("touchmove", touchMove, false);
这是一个可运行的示例。
你可以修改strokeWidth它colour,但它看起来不太像画笔。我们来改进一下。
第一个问题是它只用了一根线。真正的画笔是由许多刷毛组成的。我们来看看能否通过增加刷毛来改进我们的画笔。
首先,我们将笔触功能改为绘制单根笔毛,然后在绘制笔触时,一次绘制多根笔毛。
const strokeBristle = (origin, destination, width) => {
context.beginPath();
context.moveTo(origin[0], origin[1]);
context.strokeStyle = colour;
context.lineWidth = width;
context.lineCap = "round";
context.lineJoin = "round";
context.lineTo(destination[0], destination[1]);
context.stroke();
};
const continueStroke = newPoint => {
const bristleCount = Math.round(strokeWidth / 3);
const gap = strokeWidth / bristleCount;
for (let i = 0; i < bristleCount; i++) {
strokeBristle(
[latestPoint[0] + i * gap, latestPoint[1]],
[newPoint[0] + i * gap, newPoint[1]],
2
);
}
latestPoint = newPoint;
};
结果如下:
现在,这算是一个改进,但它看起来更像一把梳子而不是画笔。每根刷毛的宽度和位置都完全相同,这与真正的画笔相去甚远。我们可以通过增加一些随机性来改进这一点。与其让刷毛之间的间距完全一致,不如随机改变每根刷毛的宽度和位置。我们会在笔画的开头进行这种改变,使其在笔画的整个长度内保持不变,但在下一次笔画绘制时发生变化。
首先,我们将创建一个辅助函数来生成画笔,并将其存储为“笔毛”对象的数组。
const makeBrush = size => {
const brush = [];
strokeWidth = size;
let bristleCount = Math.round(size / 3);
const gap = strokeWidth / bristleCount;
for (let i = 0; i < bristleCount; i++) {
const distance =
i === 0 ? 0 : gap * i + Math.random() * gap / 2 - gap / 2;
brush.push({
distance,
thickness: Math.random() * 2 + 2
});
}
return brush;
};
let currentBrush = makeBrush();
它使用对象来指定每根刷毛的宽度和位置,然后我们可以用这些对象来绘制笔画。
const strokeBristle = (origin, destination, width) => {
context.beginPath();
context.moveTo(origin[0], origin[1]);
context.strokeStyle = colour;
context.lineWidth = width;
context.lineCap = "round";
context.lineJoin = "round";
context.lineTo(destination[0], destination[1]);
context.stroke();
};
const drawStroke = (bristles, origin, destination) => {
bristles.forEach(bristle => {
context.beginPath();
const bristleOrigin = origin[0] - strokeWidth / 2 + bristle.distance;
const bristleDestination =
destination[0] - strokeWidth / 2 + bristle.distance;
strokeBristle(
[bristleOrigin, origin[1]],
[bristleDestination, destination[1]],
bristle.thickness
);
});
};
const continueStroke = newPoint => {
drawStroke(currentBrush, latestPoint, newPoint);
latestPoint = newPoint;
};
const startStroke = point => {
currentBrush = makeBrush(strokeWidth);
drawing = true;
latestPoint = point;
};
结果如下:
看起来好多了。刷毛已经更自然了。然而,它仍然比真正的画笔显得更均匀。问题在于颜色太平淡了。真正的笔触会根据颜料的厚度和光线角度略微变化。我们可以通过像改变颜料厚度和位置一样,稍微改变颜色来模拟这种效果。为此,我们将使用一个名为TinyColor的库。它的包名为 ` tinycolor` tinycolor2,所以npm install请将其添加到您的文件中,或者如果您不进行转译,也可以从 CDN 引入它。
首先创建一个辅助函数,用于随机改变颜色的亮度。
import tinycolor from "tinycolor2";
const varyBrightness = 5;
const varyColour = sourceColour => {
const amount = Math.round(Math.random() * 2 * varyBrightness);
const c = tinycolor(sourceColour);
const varied =
amount > varyBrightness
? c.brighten(amount - varyBrightness)
: c.darken(amount);
return varied.toHexString();
};
现在我们可以扩展该makeBrush方法,添加一个colour属性。
const makeBrush = size => {
const brush = [];
let bristleCount = Math.round(size / 3);
const gap = strokeWidth / bristleCount;
for (let i = 0; i < bristleCount; i++) {
const distance =
i === 0 ? 0 : gap * i + Math.random() * gap / 2 - gap / 2;
brush.push({
distance,
thickness: Math.random() * 2 + 2,
colour: varyColour(colour)
});
}
return brush;
};
然后修改绘图函数,使其使用刷毛颜色:
const strokeBristle = (origin, destination, bristle) => {
context.beginPath();
context.moveTo(origin[0], origin[1]);
context.strokeStyle = bristle.colour;
context.lineWidth = bristle.thickness;
context.lineCap = "round";
context.lineJoin = "round";
context.lineTo(destination[0], destination[1]);
context.stroke();
};
const drawStroke = (bristles, origin, destination) => {
bristles.forEach(bristle => {
context.beginPath();
const bristleOrigin = origin[0] - strokeWidth / 2 + bristle.distance;
const bristleDestination =
destination[0] - strokeWidth / 2 + bristle.distance;
strokeBristle(
[bristleOrigin, origin[1]],
[bristleDestination, destination[1]],
bristle
);
});
};
结果如下:
我现在对这些笔触的效果很满意,但问题在于动态效果。这里的画笔角度是固定的,更像是马克笔。真正的画笔会随着移动而改变角度。为了实现这一点,我们可以让画笔的角度与移动方向保持一致。这需要一些数学计算。
在我们的移动处理程序中,我们知道先前的位置和新的位置。由此我们可以计算出方位角,从而得到刷子的新角度。然后,我们为每根刷毛绘制一条线,从其原来的位置和角度指向其新的位置和角度。
首先,我们将添加一些辅助函数来进行三角运算,以计算这些角度。
const rotatePoint = (distance, angle, origin) => [
origin[0] + distance * Math.cos(angle),
origin[1] + distance * Math.sin(angle)
];
const getBearing = (origin, destination) =>
(Math.atan2(destination[1] - origin[1], destination[0] - origin[0]) -
Math.PI / 2) %
(Math.PI * 2);
const getNewAngle = (origin, destination, oldAngle) => {
const bearing = getBearing(origin, destination);
return oldAngle - angleDiff(oldAngle, bearing);
};
const angleDiff = (angleA, angleB) => {
const twoPi = Math.PI * 2;
const diff =
(angleA - (angleB > 0 ? angleB : angleB + twoPi) + Math.PI) % twoPi -
Math.PI;
return diff < -Math.PI ? diff + twoPi : diff;
};
然后我们可以更新绘图函数以使用这些角度。
let currentAngle = 0;
const drawStroke = (bristles, origin, destination, oldAngle, newAngle) => {
bristles.forEach(bristle => {
context.beginPath();
const bristleOrigin = rotatePoint(
bristle.distance - strokeWidth / 2,
oldAngle,
origin
);
const bristleDestination = rotatePoint(
bristle.distance - strokeWidth / 2,
newAngle,
destination
);
strokeBristle(bristleOrigin, bristleDestination, bristle);
});
};
const continueStroke = newPoint => {
const newAngle = getNewAngle(latestPoint, newPoint, currentAngle);
drawStroke(currentBrush, latestPoint, newPoint, currentAngle, newAngle);
currentAngle = newAngle % (Math.PI * 2);
latestPoint = newPoint;
};
由此得出以下结果:
这比以前的动作更自然了,但转弯处有点奇怪。这是因为角度变化太剧烈了。我们可以使用贝塞尔曲线来改善这一点。
首先,更新drawStroke以计算曲线的控制点。我们使用原点的位置,并将其旋转到新的角度。
const drawStroke = (bristles, origin, destination, oldAngle, newAngle) => {
bristles.forEach(bristle => {
context.beginPath();
const start = bristle.distance - strokeWidth / 2;
const bristleOrigin = rotatePoint(start, oldAngle, origin);
const bristleDestination = rotatePoint(start, newAngle, destination);
const controlPoint = rotatePoint(start, newAngle, origin);
strokeBristle(bristleOrigin, bristleDestination, bristle, controlPoint);
});
};
然后我们进行更新strokeBristle,用曲线代替直线:
const strokeBristle = (origin, destination, bristle, controlPoint) => {
context.beginPath();
context.moveTo(origin[0], origin[1]);
context.strokeStyle = bristle.colour;
context.lineWidth = bristle.thickness;
context.lineCap = "round";
context.lineJoin = "round";
context.shadowColor = bristle.colour;
context.shadowBlur = bristle.thickness / 2;
context.quadraticCurveTo(
controlPoint[0],
controlPoint[1],
destination[0],
destination[1]
);
context.stroke();
};
这个方法效果很好,但当我们开始画一笔时,它会尝试根据之前画笔的角度弯曲,这会导致一些不自然的效果。我们最终的修改方案是,在开始画一笔时不再使用弯曲功能。
let currentAngle;
const getNewAngle = (origin, destination, oldAngle) => {
const bearing = getBearing(origin, destination);
if (typeof oldAngle === "undefined") {
return bearing;
}
return oldAngle - angleDiff(oldAngle, bearing);
};
// ...
const startStroke = point => {
currentAngle = undefined;
currentBrush = makeBrush(strokeWidth);
drawing = true;
latestPoint = point;
};
这是最终版本:
虽然我很喜欢紫色,但你可能想用其他颜色。这是一个简单的添加,使用了一个很少用到的<input type="color">:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, user-scalable=no" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Drawing tools</title>
<style>
body {
margin: 0;
}
canvas {
border: 2px solid black;
}
#colourInput {
position: absolute;
top: 10px;
left: 10px;
}
</style>
<script src="src/index.js" defer></script>
</head>
<body>
<canvas id="canvas" height="450" width="800"></canvas>
<input type="color" id="colourInput" value="#3d34a5" />
</body>
</html>
然后,在每次开始击笔时读取该值:
const startStroke = point => {
colour = document.getElementById("colourInput").value;
currentAngle = undefined;
currentBrush = makeBrush(strokeWidth);
drawing = true;
latestPoint = point;
};
你也可以用类似的方法调整笔刷大小。你还可以尝试使用笔刷预设,它可以改变笔刷的刷毛大小和数量。
这是最终版本,已包含颜色选择器:
试试全屏版本。如果您有任何建议,请在GitHub 仓库中提交 PR 。
文章来源:https://dev.to/ascorbic/a-more-realistic-html-canvas-paint-tool-313b