Perf: minor drawing speedup (#3464)

Tiny changes as I walk through freehand code. These would only really
make a difference on pages with many freehand shapes.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` — Improving existing features

### Release Notes

- Improve performance of draw shapes.
This commit is contained in:
Steve Ruiz 2024-04-21 12:46:35 +01:00 committed by GitHub
parent b5fab15c6d
commit a6d2ab05d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2369 additions and 53 deletions

View file

@ -97,7 +97,7 @@ export class Drawing extends StateNode {
this.mergeNextPoint = false this.mergeNextPoint = false
} }
this.updateShapes() this.updateDrawingShape()
} }
} }
@ -115,7 +115,7 @@ export class Drawing extends StateNode {
} }
} }
} }
this.updateShapes() this.updateDrawingShape()
} }
override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => { override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => {
@ -137,7 +137,7 @@ export class Drawing extends StateNode {
} }
} }
this.updateShapes() this.updateDrawingShape()
} }
override onExit? = () => { override onExit? = () => {
@ -281,7 +281,7 @@ export class Drawing extends StateNode {
this.initialShape = this.editor.getShape<DrawableShape>(id) this.initialShape = this.editor.getShape<DrawableShape>(id)
} }
private updateShapes() { private updateDrawingShape() {
const { initialShape } = this const { initialShape } = this
const { inputs } = this.editor const { inputs } = this.editor

View file

@ -1,12 +1,4 @@
import { import { Vec, VecLike, assert, average, precise, toDomPrecision } from '@tldraw/editor'
Vec,
VecLike,
assert,
average,
precise,
shortAngleDist,
toDomPrecision,
} from '@tldraw/editor'
import { getStrokeOutlineTracks } from './getStrokeOutlinePoints' import { getStrokeOutlineTracks } from './getStrokeOutlinePoints'
import { getStrokePoints } from './getStrokePoints' import { getStrokePoints } from './getStrokePoints'
import { setStrokePointRadii } from './setStrokePointRadii' import { setStrokePointRadii } from './setStrokePointRadii'
@ -36,17 +28,20 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] {
const result: StrokePoint[][] = [] const result: StrokePoint[][] = []
let currentPartition: StrokePoint[] = [points[0]] let currentPartition: StrokePoint[] = [points[0]]
for (let i = 1; i < points.length - 1; i++) { let prevV = Vec.Sub(points[1].point, points[0].point).uni()
const prevPoint = points[i - 1] let nextV: Vec
const thisPoint = points[i] let dpr: number
const nextPoint = points[i + 1] let prevPoint: StrokePoint, thisPoint: StrokePoint, nextPoint: StrokePoint
const prevAngle = Vec.Angle(prevPoint.point, thisPoint.point) for (let i = 1, n = points.length; i < n - 1; i++) {
const nextAngle = Vec.Angle(thisPoint.point, nextPoint.point) prevPoint = points[i - 1]
// acuteness is a normalized representation of how acute the angle is. thisPoint = points[i]
// 1 is an infinitely thin wedge nextPoint = points[i + 1]
// 0 is a straight line
const acuteness = Math.abs(shortAngleDist(prevAngle, nextAngle)) / Math.PI nextV = Vec.Sub(nextPoint.point, thisPoint.point).uni()
if (acuteness > 0.8) { dpr = Vec.Dpr(prevV, nextV)
prevV = nextV
if (dpr < -0.8) {
// always treat such acute angles as elbows // always treat such acute angles as elbows
// and use the extended .input point as the elbow point for swooshiness in fast zaggy lines // and use the extended .input point as the elbow point for swooshiness in fast zaggy lines
const elbowPoint = { const elbowPoint = {
@ -59,19 +54,20 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] {
continue continue
} }
currentPartition.push(thisPoint) currentPartition.push(thisPoint)
if (acuteness < 0.25) {
// this is not an elbow, bail out if (dpr > 0.7) {
// Not an elbow
continue continue
} }
// so now we have a reasonably acute angle but it might not be an elbow if it's far // so now we have a reasonably acute angle but it might not be an elbow if it's far
// away from it's neighbors // away from it's neighbors, angular dist is a normalized representation of how far away the point is from it's neighbors
const avgRadius = (prevPoint.radius + thisPoint.radius + nextPoint.radius) / 3
const incomingNormalizedDist = Vec.Dist(prevPoint.point, thisPoint.point) / avgRadius
const outgoingNormalizedDist = Vec.Dist(thisPoint.point, nextPoint.point) / avgRadius
// angular dist is a normalized representation of how far away the point is from it's neighbors
// (normalized by the radius) // (normalized by the radius)
const angularDist = incomingNormalizedDist + outgoingNormalizedDist if (
if (angularDist < 1.5) { (Vec.Dist2(prevPoint.point, thisPoint.point) + Vec.Dist2(thisPoint.point, nextPoint.point)) /
((prevPoint.radius + thisPoint.radius + nextPoint.radius) / 3) ** 2 <
1.5
) {
// if this point is kinda close to its neighbors and it has a reasonably // if this point is kinda close to its neighbors and it has a reasonably
// acute angle, it's probably a hard elbow // acute angle, it's probably a hard elbow
currentPartition.push(thisPoint) currentPartition.push(thisPoint)
@ -89,11 +85,13 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] {
function cleanUpPartition(partition: StrokePoint[]) { function cleanUpPartition(partition: StrokePoint[]) {
// clean up start of partition (remove points that are too close to the start) // clean up start of partition (remove points that are too close to the start)
const startPoint = partition[0] const startPoint = partition[0]
let nextPoint: StrokePoint
while (partition.length > 2) { while (partition.length > 2) {
const nextPoint = partition[1] nextPoint = partition[1]
const dist = Vec.Dist(startPoint.point, nextPoint.point) if (
const avgRadius = (startPoint.radius + nextPoint.radius) / 2 Vec.Dist2(startPoint.point, nextPoint.point) <
if (dist < avgRadius * 0.5) { (((startPoint.radius + nextPoint.radius) / 2) * 0.5) ** 2
) {
partition.splice(1, 1) partition.splice(1, 1)
} else { } else {
break break
@ -101,11 +99,13 @@ function cleanUpPartition(partition: StrokePoint[]) {
} }
// clean up end of partition in the same fashion // clean up end of partition in the same fashion
const endPoint = partition[partition.length - 1] const endPoint = partition[partition.length - 1]
let prevPoint: StrokePoint
while (partition.length > 2) { while (partition.length > 2) {
const prevPoint = partition[partition.length - 2] prevPoint = partition[partition.length - 2]
const dist = Vec.Dist(endPoint.point, prevPoint.point) if (
const avgRadius = (endPoint.radius + prevPoint.radius) / 2 Vec.Dist2(endPoint.point, prevPoint.point) <
if (dist < avgRadius * 0.5) { (((endPoint.radius + prevPoint.radius) / 2) * 0.5) ** 2
) {
partition.splice(partition.length - 2, 1) partition.splice(partition.length - 2, 1)
} else { } else {
break break
@ -115,13 +115,14 @@ function cleanUpPartition(partition: StrokePoint[]) {
if (partition.length > 1) { if (partition.length > 1) {
partition[0] = { partition[0] = {
...partition[0], ...partition[0],
vector: Vec.FromAngle(Vec.Angle(partition[1].point, partition[0].point)), vector: Vec.Sub(partition[0].point, partition[1].point).uni(),
} }
partition[partition.length - 1] = { partition[partition.length - 1] = {
...partition[partition.length - 1], ...partition[partition.length - 1],
vector: Vec.FromAngle( vector: Vec.Sub(
Vec.Angle(partition[partition.length - 1].point, partition[partition.length - 2].point) partition[partition.length - 2].point,
), partition[partition.length - 1].point
).uni(),
} }
} }
return partition return partition

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { TLDrawShape, TLHighlightShape, last } from '@tldraw/editor' import { TLDrawShape, TLHighlightShape, last } from '@tldraw/editor'
import { TestEditor } from './TestEditor' import { TestEditor } from './TestEditor'
import { TEST_DRAW_SHAPE_SCREEN_POINTS } from './drawing.data'
jest.useFakeTimers() jest.useFakeTimers()
@ -260,3 +261,22 @@ for (const toolType of ['draw', 'highlight'] as const) {
}) })
}) })
} }
it('Draws a bunch', () => {
editor.setCurrentTool('draw').setCamera({ x: 0, y: 0, z: 1 })
const [first, ...rest] = TEST_DRAW_SHAPE_SCREEN_POINTS
editor.pointerMove(first.x, first.y).pointerDown()
for (const point of rest) {
editor.pointerMove(point.x, point.y)
}
editor.pointerUp()
editor.selectAll()
const shape = { ...editor.getLastCreatedShape() }
// @ts-expect-error
delete shape.id
expect(shape).toMatchSnapshot('draw shape')
})

View file

@ -34,15 +34,17 @@ export function measureAverageDuration(
const start = performance.now() const start = performance.now()
const result = originalMethod.apply(this, args) const result = originalMethod.apply(this, args)
const end = performance.now() const end = performance.now()
const value = averages.get(descriptor.value)!
const length = end - start const length = end - start
const total = value.total + length if (length !== 0) {
const count = value.count + 1 const value = averages.get(descriptor.value)!
averages.set(descriptor.value, { total, count }) const total = value.total + length
// eslint-disable-next-line no-console const count = value.count + 1
console.log( averages.set(descriptor.value, { total, count })
`${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms` // eslint-disable-next-line no-console
) console.log(
`${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`
)
}
return result return result
} }
averages.set(descriptor.value, { total: 0, count: 0 }) averages.set(descriptor.value, { total: 0, count: 0 })