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:
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
:
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:
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:
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:
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:
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:

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:

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:

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:

Here we refer to Brush Rendering Tutorial to realize this effect.
Basic implementation
The underlying data structure is as follows:
interface BrushPoint {
x: number;
y: number;
radius: number;
}
The
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:
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:

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:

The effect is as follows:
Stamp
This doesn't quite work like a real brushstroke.

Export SVG
Figma is able to export Brush to SVG.