Skip to content

Lesson 25 - Drawing mode and brush

Lesson 14 - Canvas mode and auxiliary UI 中我们介绍了手型和选择模式,在本节课中我们将介绍绘制模式:包括矩形和椭圆,以及更加自由的笔刷模式。

Draw rect mode

First add the following canvas mode. The implementation of drawing ellipses is almost identical, so I won't repeat the introduction:

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

In Lesson 18 - Refactor with ECS we introduced the ECS architecture, where a DrawRect System is created, and once in that mode, the cursor style is set to 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';
        //...
    }
}

Then as the mouse is dragged, the rectangle is continually redrawn in the target area, similar to the box selection effect in selection mode. When the mouse is lifted to complete the creation of the rectangle, it switches from draw rectangle mode to selection mode:

ts
export class DrawRect extends System {
    execute() {
        //...
        // Draw rect brush when dragging
        this.handleBrushing(api, x, y);

        if (input.pointerUpTrigger) {
            // Create rect when pointerup event triggered
            const node: RectSerializedNode = {
                id: uuidv4(),
                type: 'rect', // Change to 'ellipse' in draw-ellipse mode
                x,
                y,
                width,
                height,
            };
            api.setPen(Pen.SELECT); // Switch canvas mode
            api.updateNode(node);
            api.record(); // Save to history
        }
    }
}

Next we look at what happens during the drag and drop process.

Redraw rect

Similar to box selection, in order to avoid dragging a small distance and starting to draw, we need to set a threshold, calculated in the Viewport coordinate system:

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;
}

The x/y coordinates of the auxiliary rectangle are where the pointerdown was triggered, and the coordinates of the pointermove event object need to be converted to the Canvas coordinate system to compute the width and height:

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,
);

It is worth to consider the scenario of reverse dragging, where the calculated width/height may be negative, and the corresponding x/y will no longer be at the position of the pointerdown and will have to be recalculated. Figma does the same:

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

Size label

We want to show the dimensions of the rectangle in real time during the drawing process, like Figma does:

Size label in Figma

Pencil tool

Let's start by looking at the simplest implementation, using a folded line display, called a Pencil in Figma.

In order to minimize the number of vertices generated by dragging and dropping, and especially the number of duplicated vertices or vertices in close proximity to each other, we will simplify the polyline using the method described in Lesson 12 - Simplify polyline, by choosing the simplify-js implementation. It is worth noting the definition of the tolerance parameter, which affects the degree of simplification:

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

We want to set the tolerance differently depending on the current camera zoom level, otherwise the jitter caused by oversimplification at high zoom levels will be easily visible:

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

// choose tolerance based on the camera zoom level
const tolerance = 1 / zoom;
selection.points = simplify(selection.pointsBeforeSimplify, tolerance);

Brush mode

You can select this sub-tool when you enter Paint mode in Photoshop Web and draw strokes by dragging and dropping continuously:

Brush mode in Photoshop Web

In Figma it is called Draw with illustration tools.

If we look closely at this type of stroke, we can see that it consists of a set of consecutive dots, which, if they have different radii, can give the effect of variable thickness. This can be realized by mapping the pressure of the brush to the radius:

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

Here we refer to Brush Rendering Tutorial to realize this effect.

Basic implementation

The underlying data structure is as follows:

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

The N vertices of a polyline form N1 segments, each consisting of two triangles and four vertices. In Lesson 12 - Extrude segment, we introduced the use of 9 vertices. Here we use the exact same method, but we don't need to take into account the joints of the segments, so we only need to use 4 vertices and draw them using instanced:

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

As we saw in Lesson 12 - Extrude segment, you can pass a_VertexNum into the Vertex Shader, or you can use gl_VertexID directly as in Brush Rendering Tutorial if you don't want to take into account WebGL 1 compatibility:

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]

By the way the other attributes, a_PointA and a_PointB store variable radii in addition to vertex position coordinates. Again we use vertexBufferOffsets to multiplex the same Buffer, and a_PointB reads from an offset of 4 * 3. This way we have the vertex numbers to stretch in the 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);
}

In order to support variable widths, the stretched distance is not always equal to the radius of the current point, but needs to be calculated based on the slope of the line segment:

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

The effect is as follows:

Stamp

This doesn't quite work like a real brushstroke.

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

Export SVG

Figma is able to export Brush to SVG.

Eraser

Extended reading

Released under the MIT License.