发布于 2026-01-06 6 阅读
0

更逼真的 HTML Canvas 绘图工具

更逼真的 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>
Enter fullscreen mode Exit fullscreen mode

基本流程是监听 ` 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);
Enter fullscreen mode Exit fullscreen mode

我们需要添加一些额外的处理程序来处理触摸事件。

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);
Enter fullscreen mode Exit fullscreen mode

这是一个可运行的示例。

你可以修改strokeWidthcolour,但它看起来不太像画笔。我们来改进一下。

第一个问题是它只用了一根线。真正的画笔是由许多刷毛组成的。我们来看看能否通过增加刷毛来改进我们的画笔。

首先,我们将笔触功能改为绘制单根笔毛,然后在绘制笔触时,一次绘制多根笔毛。

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;
};
Enter fullscreen mode Exit fullscreen mode

结果如下:

现在,这算是一个改进,但它看起来更像一把梳子而不是画笔。每根刷毛的宽度和位置都完全相同,这与真正的画笔相去甚远。我们可以通过增加一些随机性来改进这一点。与其让刷毛之间的间距完全一致,不如随机改变每根刷毛的宽度和位置。我们会在笔画的开头进行这种改变,使其在笔画的整个长度内保持不变,但在下一次笔画绘制时发生变化。

首先,我们将创建一个辅助函数来生成画笔,并将其存储为“笔毛”对象的数组。

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();
Enter fullscreen mode Exit fullscreen mode

它使用对象来指定每根刷毛的宽度和位置,然后我们可以用这些对象来绘制笔画。

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;
};
Enter fullscreen mode Exit fullscreen mode

结果如下:

看起来好多了。刷毛已经更自然了。然而,它仍然比真正的画笔显得更均匀。问题在于颜色太平淡了。真正的笔触会根据颜料的厚度和光线角度略微变化。我们可以通过像改变颜料厚度和位置一样,稍微改变颜色来模拟这种效果。为此,我们将使用一个名为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();
};
Enter fullscreen mode Exit fullscreen mode

现在我们可以扩展该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;
};
Enter fullscreen mode Exit fullscreen mode

然后修改绘图函数,使其使用刷毛颜色:

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
        );
    });
};
Enter fullscreen mode Exit fullscreen mode

结果如下:

我现在对这些笔触的效果很满意,但问题在于动态效果。这里的画笔角度是固定的,更像是马克笔。真正的画笔会随着移动而改变角度。为了实现这一点,我们可以让画笔的角度与移动方向保持一致。这需要一些数学计算。

在我们的移动处理程序中,我们知道先前的位置和新的位置。由此我们可以计算出方位角,从而得到刷子的新角度。然后,我们为每根刷毛绘制一条线,从其原来的位置和角度指向其新的位置和角度。

首先,我们将添加一些辅助函数来进行三角运算,以计算这些角度。

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;
};
Enter fullscreen mode Exit fullscreen mode

然后我们可以更新绘图函数以使用这些角度。

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;
};
Enter fullscreen mode Exit fullscreen mode

由此得出以下结果:

这比以前的动作更自然了,但转弯处有点奇怪。这是因为角度变化太剧烈了。我们可以使用贝塞尔曲线来改善这一点。

首先,更新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);
    });
};
Enter fullscreen mode Exit fullscreen mode

然后我们进行更新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();
};
Enter fullscreen mode Exit fullscreen mode

这个方法效果很好,但当我们开始画一笔时,它会尝试根据之前画笔的角度弯曲,这会导致一些不自然的效果。我们最终的修改方案是,在开始画一笔时不再使用弯曲功能。

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;
};
Enter fullscreen mode Exit fullscreen mode

这是最终版本:

虽然我很喜欢紫色,但你可能想用其他颜色。这是一个简单的添加,使用了一个很少用到的<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>
Enter fullscreen mode Exit fullscreen mode

然后,在每次开始击笔时读取该值:

const startStroke = point => {
    colour = document.getElementById("colourInput").value;
    currentAngle = undefined;
    currentBrush = makeBrush(strokeWidth);
    drawing = true;
    latestPoint = point;
};
Enter fullscreen mode Exit fullscreen mode

你也可以用类似的方法调整笔刷大小。你还可​​以尝试使用笔刷预设,它可以改变笔刷的刷毛大小和数量。

这是最终版本,已包含颜色选择器:

试试全屏版本。如果您有任何建议,请在GitHub 仓库中提交 PR 。

文章来源:https://dev.to/ascorbic/a-more-realistic-html-canvas-paint-tool-313b