Skip to content

课程 25 - 绘制模式与笔刷

课程 14 - 画布模式 中我们介绍了手型和选择模式,在本节课中我们将介绍绘制模式:包括矩形、椭圆和箭头,以及更加自由的笔刷模式。

矩形绘制模式

首先增加以下模式,绘制椭圆的实现几乎一致,就不重复介绍了:

ts
export enum Pen {
    HAND = 'hand',
    SELECT = 'select',
    DRAW_RECT = 'draw-rect', 
    DRAW_Ellipse = 'draw-ellipse', 
}

课程 18 - 使用 ECS 重构 中我们介绍了 ECS 架构,这里创建一个 DrawRect 的 System,一旦进入该模式,就将鼠标样式设置为 crosshair

ts
import { System } from '@lastolivegames/becsy';

export class DrawRect extends System {
    execute() {
        if (pen !== Pen.DRAW_RECT) {
            return;
        }

        const input = canvas.write(Input);
        const cursor = canvas.write(Cursor);

        cursor.value = 'crosshair';
        //...
    }
}

然后随着鼠标拖拽,在目标区域不断重绘矩形,类似选择模式中的框选效果。当鼠标抬起完成矩形的创建,从绘制矩形模式切换到选择模式:

ts
export class DrawRect extends System {
    execute() {
        //...
        // 拖拽,绘制辅助 UI
        this.handleBrushing(api, x, y);

        if (input.pointerUpTrigger) {
            // 鼠标抬起,创建矩形
            const node: RectSerializedNode = {
                id: uuidv4(),
                type: 'rect', // 椭圆绘制模式下改成 'ellipse' 即可
                x,
                y,
                width,
                height,
            };
            api.setAppState({ penbarSelected: Pen.SELECT }); // 模式切换
            api.updateNode(node);
            api.record(); // 保存历史记录
        }
    }
}

接下来我们来看在拖拽过程中发生了什么。

重绘辅助矩形

和框选类似,为了避免拖拽一小段距离就开始绘制,我们需要设置一段阈值,在 Viewport 坐标系下计算:

ts
handleBrushing(api: API, viewportX: number, viewportY: number) {
    const camera = api.getCamera();
    const {
        pointerDownViewportX,
        pointerDownViewportY,
    } = camera.read(ComputedCameraControl);

    // Use a threshold to avoid showing the selection brush when the pointer is moved a little.
    const shouldShowSelectionBrush =
        distanceBetweenPoints(
            viewportX,
            viewportY,
            pointerDownViewportX,
            pointerDownViewportY,
        ) > 10;
}

辅助矩形的位置坐标 x/y 就是 pointerdown 触发时的位置,接下来也需要将 pointermove 事件对象的坐标转换到 Canvas 坐标系下计算此时的宽高:

ts
const { x: cx, y: cy } = api.viewport2Canvas({
    x: viewportX,
    y: viewportY,
});

let x = pointerDownCanvasX;
let y = pointerDownCanvasY;
let width = cx - x;
let height = cy - y;

api.updateNode(
    selection.brush,
    {
        visibility: 'visible',
        x,
        y,
        width,
        height,
    },
    false,
);

值得一提的是需要考虑反向拖拽的场景,此时计算出的 width/height 可能为负数,相应的 x/y 就不再是 pointerdown 时的位置,需要重新计算。Figma 也是这么做的:

ts
if (width < 0) {
    x += width;
    width = -width;
}
if (height < 0) {
    y += height;
    height = -height;
}

绘制尺寸标签

我们希望在绘制过程中实时展示矩形的尺寸,就像 Figma 这样:

Size label in Figma

同样,这类视口空间的标注图形放在 SVG 容器中很合适,正如下文会介绍的激光笔和橡皮擦。矩形标签使用 CSS 样式设置背景颜色、padding、居中等等,文本内容使用包围盒的宽高:

ts
label.style.visibility = 'visible';
label.style.top = `${y + height}px`;
label.style.left = `${x + width / 2}px`;
label.innerText = `${Math.round(width)} × ${Math.round(height)}`;

如果绘制的是直线,标签可以沿着直线方向旋转,但需要让文本始终正向展示,你可以在下一节的箭头例子中体验:

ts
label.style.top = `${y + height / 2}px`;
const rad = Math.atan2(height, width);
let deg = rad * (180 / Math.PI);
if (deg >= 90 && deg <= 180) {
    deg = deg - 180;
} else if (deg <= -90 && deg >= -180) {
    deg = deg + 180;
}
label.style.transform = `translate(-50%, -50%) rotate(${deg}deg)`;

按住 Shift 绘制正方形

最后,按住 Shift 可以以相同的宽高绘制正方形(正圆),此时使用宽高绝对值的最小值作为正方形的边长。

ts
if (isSquare) {
    if (Math.abs(width) > Math.abs(height)) {
        width = Math.sign(width) * Math.abs(height);
    } else {
        height = Math.sign(height) * Math.abs(width);
    }
}

绘制箭头

除了矩形、椭圆、折线等基础图形,箭头这样的复合图形也很常用。我们暂时不涉及箭头的绑定关系(被箭头首尾关联的图形发生移动,箭头的朝向也跟着改变),后面放到单独的一章中介绍,这里仅关注箭头的绘制方式。

在 SVG 中首先使用 <marker> 声明箭头,通常是一个 <path>,然后通过目标图形的 marker-startmarker-end 属性关联箭头:

html
<defs>
    <!-- arrowhead marker definition -->
    <marker
        id="arrow"
        viewBox="0 0 10 10"
        refX="5"
        refY="5"
        markerWidth="6"
        markerHeight="6"
        orient="auto-start-reverse"
    >
        <path d="M 0 0 L 10 5 L 0 10 z" />
    </marker>
</defs>
<!-- Coordinate axes with a arrowhead in both direction -->
<polyline
    points="10,10 10,90 90,90"
    fill="none"
    stroke="black"
    marker-start="url(#arrow)"
    marker-end="url(#arrow)"
/>

这种将箭头端点与主体分离的方式十分灵活。但在图形编辑器场景下,只需要提供一些预设的常见样式即可。例如 Figma 中的箭头就是通过附加在 Path 的两个端点(start/end point)上实现的,包括 line/triangle/diamond 等若干种预设样式,详见:How to Curve an Arrow in Figma

Arrow in Figma

因此在声明式用法中,我们完全可以牺牲自定义箭头样式这一特性,提供一系列内置的箭头样式字面量,在构建 Polyline / Path 时将箭头端点和主体一并生成。这种思路在使用 SVG 渲染的 plot - arrow 中也可以看到,它并没有使用 <marker>,而是一个完整的 <path> 定义。

ts
export interface MarkerAttributes {
    markerStart: Marker['start'];
    markerEnd: Marker['end'];
}

接下来我们来看具体的构建 geometry 过程。

起始点和终点

首先需要找到箭头的起始点和终点。但朝向需要手动计算,计算方式并不复杂,沿切线即可。

导出 SVG

SVG 中可以通过 orient 属性调整 <marker> 的朝向,但需要注意该属性的字面量只有 'auto''auto-start-reverse' 两个值

ts
if (isEnd) {
    $marker.setAttribute('orient', 'auto');
} else {
    $marker.setAttribute('orient', 'auto-start-reverse');
}

然后根据 marker 的类型创建 <path>,让它继承目标图形的 stroke 等属性。你可以在 export arrow 测试用例中查看导出结果:

ts
if (marker === 'line') {
    const points = lineArrow(0, 0, arrowRadius, Math.PI);
    const $path = createSVGElement('path');
    $path.setAttribute('fill', 'none');
    $path.setAttribute('stroke', stroke);
    $path.setAttribute('stroke-width', `${strokeWidth}`);
    $marker.appendChild($path);
}

与之相对的,导出的 SVG 也要支持再导入画布。

[WIP] 绘制多边形

Shape tools - polygons

铅笔工具

首先我们先来看最简单的一种实现,使用折线展示,在 Figma 中称作 Pencil。

为了尽可能减少拖拽过程中产生的顶点,尤其是大量重复的、或者距离较近的顶点,我们使用 课程 12 - 简化折线的顶点 中介绍的方法对折线进行简化,选择simplify-js 实现。值得注意的是 tolerance 这个参数的定义,它会影响简化程度:

Affects the amount of simplification (in the same metric as the point coordinates).

我们希望根据当前的相机缩放等级设置不同的 tolerance,否则在高缩放等级下过度简化造成的抖动会被很容易看出来:

Over simplified polyline in 4x zoom level
ts
import simplify from 'simplify-js';

export class DrawPencil extends System {
    private handleBrushing() {
        // choose tolerance based on the camera zoom level
        const tolerance = 1 / zoom;
        selection.points = simplify(selection.pointsBeforeSimplify, tolerance);
    }
}

Perfect freehand

由于线宽是固定的,显得不够“灵动”。为了营造一些手写感,可以让线条跟随压力变化。为此我们可以使用 perfect-freehand,值得一提的是在 Excalidraw 中还未接入,详见:Perfect Freehand Drawing Issue

由于线宽可变,最终绘制出来的图形不再是 Polyline 而是 Path:

ts
import { getStroke } from 'perfect-freehand';

const outlinePoints = getStroke(points);
const d = getSvgPathFromStroke(outlinePoints); // 'M 0 0 L...'

默认情况下 perfect-freehand 是通过相邻点的距离计算可变的线宽,来模拟笔触的快慢。在带有压力感应的输入设备(pen or stylus)场景下,可以将事件自带的 pressure 映射上去:

ts
export class Input {
    @field.float32 declare pressure: number; 
}

此时就可以关闭默认的模拟行为:

ts
const outlinePoints = getStroke(inputPoints, {
    simulatePressure: false,
});

笔刷模式

在 Photoshop Web 中进入 Paint 模式后可以选择这个子工具,通过连续拖拽绘制笔迹:

Brush mode in Photoshop Web

在 Figma 中称作 Draw with illustration tools

如果我们仔细观察这类笔迹,可以看出它是由一组连续的圆点组成,如果这些圆点具有不同的半径,就能呈现粗细可变的效果。在实现时可以将画笔的压力映射到半径上:

source: https://shenciao.github.io/brush-rendering-tutorial/

下面我们参考 Brush Rendering Tutorial 来实现这一效果,作者介绍了一种不依赖顶点密度、沿折线均匀放置圆点(贴图)的方法。

source: https://shenciao.github.io/brush-rendering-tutorial/Basics/Stamp/#locate-stamps

基础实现

基础数据结构如下,序列化后形成 points: 'x1,y1,r1 x2,y2,r2...' 这样的三元组列表:

ts
interface BrushPoint {
    x: number;
    y: number;
    radius: number;
}

折线的 N 个顶点组成了 N1 条线段,每条线段由两个三角形、4 个顶点组成。在 课程 12 - 线段主体拉伸 中,我们介绍过使用 9 个顶点。这里使用完全相同的方法,但无需考虑线段的接头,因此只需要使用 4 个顶点,使用 instanced 绘制:

ts
renderPass.drawIndexed(6, points.length - 1); // indices: [0, 1, 2, 0, 2, 3]

我们在 课程 12 - 线段主体拉伸 中介绍过,可以将 a_VertexNum 传入 Vertex Shader。如果不考虑 WebGL 1 的兼容性,也可以像 Brush Rendering Tutorial 这样,直接使用 gl_VertexID

glsl
layout(location = ${Location.POINTA}) in vec3 a_PointA;
layout(location = ${Location.POINTB}) in vec3 a_PointB;
layout(location = ${Location.VERTEX_NUM}) in float a_VertexNum; // [0, 1, 2, 3]

顺便介绍下其他 attributesa_PointAa_PointB 除了存储顶点位置坐标,还存储了可变半径。同样我们使用了 vertexBufferOffsets 复用同一块 Buffer,a_PointB4 * 3 的偏移量后开始读取。这样有了顶点序号就可以在 Vertex Shader 中进行拉伸了:

source: https://shenciao.github.io/brush-rendering-tutorial/Basics/Vanilla/
glsl
vec2 position;
vec2 offsetSign;
float r;
if (vertexNum < 0.5) {
    position = p0;
    r = r0;
    offsetSign = vec2(-1.0, -1.0);
} else if (vertexNum < 1.5) {
    position = p0;
    r = r0;
    offsetSign = vec2(-1.0, 1.0);
}

为了支持可变宽度,拉伸的距离并不总是等于当前点的半径,而是需要根据线段的斜率计算:

source: https://shenciao.github.io/brush-rendering-tutorial/Basics/Vanilla/
glsl
int MAX_i = 128; float currIndex = startIndex;
float A = 0.0;
for(int i = 0; i < MAX_i; i++){
    // Blend opacity
    A = A * (1.0-opacity) + opacity;
}

效果如下:

贴图

这样的效果还不太像真实的笔触,我们新增 brushStamp 属性表示贴图链接,它仅提供透明度的来源,笔刷颜色仍通过 stroke 控制:

ts
api.updateNodes([
    {
        id: '1',
        type: 'brush',
        brushType: BrushType.STAMP,
        brushStamp: '/stamp.png',
        points: position.map(([x, y], i) => `${x},${y},${radius[i]}`).join(' '),
        stroke: 'grey',
    },
]);

目前贴图的方向都是固定的,我们可以为贴图添加随机旋转角度:

glsl
float angle = rotationFactor*radians(360.0*fract(sin(currIndex)*1.0));
pToCurrStamp *= rotate(angle);

还可以在混合透明度时使用噪音影响因子:

glsl
float opacityNoise = noiseFactor*fbm(textureCoordinate*50.0);

[WIP] 导出 SVG

Figma 是可以将 Brush 导出 SVG 的。

激光笔

在画布中进行演示功能时,激光笔是一个必备的功能。Excalidraw 支持这一功能,详见:laser pointer,我们可以直接使用它封装好的 @excalidraw/laser-pointer,根据传入的一组点坐标生成轨迹路径,底层使用的也是 perfect-freehand

这一特性与之前在画布上绘制的图形不同:

  1. 绘制激光笔的轨迹发生在视口坐标系下,和当前画布缩放等级无关,因此可以在一个独立的 HTML 容器中完成,详见:课程 29 - HTML 容器
  2. 轨迹在一定时间后自动消失
  3. 考虑多人协同场景,详见:课程 20 - Awareness 和 Presence

橡皮擦

Excalidraw 支持 freedraw eraser,选中橡皮擦工具后,按下鼠标后经过的图形会“虚化”,表示即将被删除,抬起鼠标完成擦除。

在实现中,我们使用上一节介绍过的 Perfect freehand 绘制拖动的轨迹,但仅保存最近的 4 个点。同时实时检测经过的图形,设置它们的透明度(但记得保存原有的透明度,以备取消时回复),在鼠标抬起时删除选中的图形:

ts
export class DrawEraser extends System {
    execute() {
        if (input.pointerUpTrigger) {
            api.runAtNextTick(() => {
                api.updateNode(brush, { visibility: 'hidden' }, false);
                api.setAppState({
                    penbarSelected: Pen.SELECT,
                });
                api.deleteNodesById(
                    Array.from(selected).map((e) => api.getNodeByEntity(e)?.id),
                );
                api.record();
            });
        }
    }
}

[WIP] 非原子化

以整个图形为单位擦除在大多数场景下都足够使用了,但在手绘类场景中非原子化的擦除更实用,例如将一条直线从中间断开。Excalidraw 暂时没有支持这一特性,详见:non-atomic erasing for linear & freedraw shapes,FigJam 也是这样。如果画布是基于 Canvas 或者 SVG 渲染的,确实无法实现这种像素级擦除效果。

由于我们的画布是基于 WebGL / WebGPU 实现的,最适合的技术就是 stencil buffer。首先将橡皮擦的路径绘制在 stencil buffer 中,然后以它作为 mask 重绘场景。以 OpenGL 为例(WebGL 完全类似):

c++
// 1. Disable color/depth writes, enable stencil, and draw the eraser shape:
//    This writes “1” into the stencil where the eraser stamp is drawn.
glColorMask(false,false,false,false);
glDepthMask(false);
glEnable(GL_STENCIL_TEST);
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilOp(GL_REPLACE, GL_REPLACE, GL_REPLACE);
// draw eraser brush geometry here (e.g. a textured quad or circle)

glColorMask(true,true,true,true);
glDepthMask(true);
// 2. Now render the shapes with stencil test=EQUAL, so only pixels where stencil==1 pass:
glStencilFunc(GL_EQUAL, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
// draw all scene objects (they will only appear where eraser just wrote 1)

扩展阅读

Released under the MIT License.