Perf: Incremental culled shapes calculation. (#3411)

Reworks our culling logic:
- No longer show the gray rectangles for culled shapes. 
- Don't use `renderingBoundExpanded`, instead we now use
`viewportPageBounds`. I've removed `renderingBoundsExpanded`, but we
might want to deprecate it?
- There's now a incremental computation of non visible shapes, which are
shapes outside of `viewportPageBounds` and shapes that outside of their
parents' clipping bounds.
- There's also a new `getCulledShapes` function in `Editor`, which uses
the non visible shapes computation as a part of the culled shape
computation.
- Also moved some of the `getRenderingShapes` tests to newly created
`getCullingShapes` tests.

Feels much better on my old, 2017 ipad (first tab is this PR, second is
current prod, third is staging).


https://github.com/tldraw/tldraw/assets/2523721/327a7313-9273-4350-89a0-617a30fc01a2

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Regular culling shapes tests. Pan / zoom around. Use minimap. Change
pages.

- [x] Unit Tests
- [ ] End to end tests

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Mitja Bezenšek 2024-04-10 12:29:11 +02:00 committed by GitHub
parent 2bbab1a790
commit 987b1ac0b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 357 additions and 401 deletions

View file

@ -5,25 +5,31 @@ export function useChangedShapesReactor(
cb: (info: { culled: TLShape[]; restored: TLShape[] }) => void
) {
const editor = useEditor()
const rPrevShapes = useRef(editor.getRenderingShapes())
const rPrevShapes = useRef({
renderingShapes: editor.getRenderingShapes(),
culledShapes: editor.getCulledShapes(),
})
useEffect(() => {
return react('when rendering shapes change', () => {
const after = editor.getRenderingShapes()
const after = {
culledShapes: editor.getCulledShapes(),
renderingShapes: editor.getRenderingShapes(),
}
const before = rPrevShapes.current
const culled: TLShape[] = []
const restored: TLShape[] = []
const beforeToVisit = new Set(before)
const beforeToVisit = new Set(before.renderingShapes)
for (const afterInfo of after) {
const beforeInfo = before.find((s) => s.id === afterInfo.id)
for (const afterInfo of after.renderingShapes) {
const beforeInfo = before.renderingShapes.find((s) => s.id === afterInfo.id)
if (!beforeInfo) {
continue
} else {
const isAfterCulled = editor.isShapeCulled(afterInfo.id)
const isBeforeCulled = editor.isShapeCulled(beforeInfo.id)
const isAfterCulled = after.culledShapes.has(afterInfo.id)
const isBeforeCulled = before.culledShapes.has(beforeInfo.id)
if (isAfterCulled && !isBeforeCulled) {
culled.push(afterInfo.shape)
} else if (!isAfterCulled && isBeforeCulled) {

View file

@ -675,6 +675,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// @internal
getCrashingError(): unknown;
getCroppingShapeId(): null | TLShapeId;
getCulledShapes(): Set<TLShapeId>;
getCurrentPage(): TLPage;
getCurrentPageBounds(): Box | undefined;
getCurrentPageId(): TLPageId;
@ -712,7 +713,6 @@ export class Editor extends EventEmitter<TLEventMap> {
getPointInParentSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
getPointInShapeSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
getRenderingBounds(): Box;
getRenderingBoundsExpanded(): Box;
getRenderingShapes(): {
id: TLShapeId;
shape: TLShape;
@ -817,7 +817,6 @@ export class Editor extends EventEmitter<TLEventMap> {
margin?: number | undefined;
hitInside?: boolean | undefined;
}): boolean;
isShapeCulled(shape: TLShape | TLShapeId): boolean;
isShapeInPage(shape: TLShape | TLShapeId, pageId?: TLPageId): boolean;
isShapeOfType<T extends TLUnknownShape>(shape: TLUnknownShape, type: T['type']): shape is T;
// (undocumented)

View file

@ -10284,6 +10284,51 @@
"isAbstract": false,
"name": "getCroppingShapeId"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getCulledShapes:member(1)",
"docComment": "/**\n * Get culled shapes.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getCulledShapes(): "
},
{
"kind": "Reference",
"text": "Set",
"canonicalReference": "!Set:interface"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": ">"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [],
"isOptional": false,
"isAbstract": false,
"name": "getCulledShapes"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getCurrentPage:member(1)",
@ -11876,38 +11921,6 @@
"isAbstract": false,
"name": "getRenderingBounds"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getRenderingBoundsExpanded:member(1)",
"docComment": "/**\n * The current rendering bounds in the current page space, expanded slightly. Used for determining which shapes to render and which to \"cull\".\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getRenderingBoundsExpanded(): "
},
{
"kind": "Reference",
"text": "Box",
"canonicalReference": "@tldraw/editor!Box:class"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [],
"isOptional": false,
"isAbstract": false,
"name": "getRenderingBoundsExpanded"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getRenderingShapes:member(1)",
@ -14814,64 +14827,6 @@
"isAbstract": false,
"name": "isPointInShape"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#isShapeCulled:member(1)",
"docComment": "/**\n * Get whether the shape is culled or not.\n *\n * @param shape - The shape (or shape id) to get the culled info for.\n *\n * @example\n * ```ts\n * editor.isShapeCulled(myShape)\n * editor.isShapeCulled(myShapeId)\n * ```\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "isShapeCulled(shape: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": " | "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shape",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "isShapeCulled"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#isShapeInPage:member(1)",

View file

@ -1,186 +0,0 @@
import { computed, react } from '@tldraw/state'
import { useEffect, useRef } from 'react'
import { useEditor } from '../hooks/useEditor'
import { useIsDarkMode } from '../hooks/useIsDarkMode'
// Parts of the below code are taken from MIT licensed project:
// https://github.com/sessamekesh/webgl-tutorials-2023
function setupWebGl(canvas: HTMLCanvasElement | null, isDarkMode: boolean) {
if (!canvas) return
const context = canvas.getContext('webgl2')
if (!context) return
const vertexShaderSourceCode = `#version 300 es
precision mediump float;
in vec2 shapeVertexPosition;
uniform vec2 viewportStart;
uniform vec2 viewportEnd;
void main() {
// We need to transform from page coordinates to something WebGl understands
float viewportWidth = viewportEnd.x - viewportStart.x;
float viewportHeight = viewportEnd.y - viewportStart.y;
vec2 finalPosition = vec2(
2.0 * (shapeVertexPosition.x - viewportStart.x) / viewportWidth - 1.0,
1.0 - 2.0 * (shapeVertexPosition.y - viewportStart.y) / viewportHeight
);
gl_Position = vec4(finalPosition, 0.0, 1.0);
}`
const vertexShader = context.createShader(context.VERTEX_SHADER)
if (!vertexShader) return
context.shaderSource(vertexShader, vertexShaderSourceCode)
context.compileShader(vertexShader)
if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) {
return
}
// Dark = hsl(210, 11%, 19%)
// Light = hsl(204, 14%, 93%)
const color = isDarkMode ? 'vec4(0.169, 0.188, 0.212, 1.0)' : 'vec4(0.922, 0.933, 0.941, 1.0)'
const fragmentShaderSourceCode = `#version 300 es
precision mediump float;
out vec4 outputColor;
void main() {
outputColor = ${color};
}`
const fragmentShader = context.createShader(context.FRAGMENT_SHADER)
if (!fragmentShader) return
context.shaderSource(fragmentShader, fragmentShaderSourceCode)
context.compileShader(fragmentShader)
if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) {
return
}
const program = context.createProgram()
if (!program) return
context.attachShader(program, vertexShader)
context.attachShader(program, fragmentShader)
context.linkProgram(program)
if (!context.getProgramParameter(program, context.LINK_STATUS)) {
return
}
context.useProgram(program)
const shapeVertexPositionAttributeLocation = context.getAttribLocation(
program,
'shapeVertexPosition'
)
if (shapeVertexPositionAttributeLocation < 0) {
return
}
context.enableVertexAttribArray(shapeVertexPositionAttributeLocation)
const viewportStartUniformLocation = context.getUniformLocation(program, 'viewportStart')
const viewportEndUniformLocation = context.getUniformLocation(program, 'viewportEnd')
if (!viewportStartUniformLocation || !viewportEndUniformLocation) {
return
}
return {
context,
program,
shapeVertexPositionAttributeLocation,
viewportStartUniformLocation,
viewportEndUniformLocation,
}
}
function _CulledShapes() {
const editor = useEditor()
const isDarkMode = useIsDarkMode()
const canvasRef = useRef<HTMLCanvasElement>(null)
const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin)
useEffect(() => {
const webGl = setupWebGl(canvasRef.current, isDarkMode)
if (!webGl) return
if (!isCullingOffScreenShapes) return
const {
context,
shapeVertexPositionAttributeLocation,
viewportStartUniformLocation,
viewportEndUniformLocation,
} = webGl
const shapeVertices = computed('shape vertices', function calculateCulledShapeVertices() {
const results: number[] = []
for (const { id } of editor.getUnorderedRenderingShapes(true)) {
const maskedPageBounds = editor.getShapeMaskedPageBounds(id)
if (editor.isShapeCulled(id) && maskedPageBounds) {
results.push(
// triangle 1
maskedPageBounds.minX,
maskedPageBounds.minY,
maskedPageBounds.minX,
maskedPageBounds.maxY,
maskedPageBounds.maxX,
maskedPageBounds.maxY,
// triangle 2
maskedPageBounds.minX,
maskedPageBounds.minY,
maskedPageBounds.maxX,
maskedPageBounds.minY,
maskedPageBounds.maxX,
maskedPageBounds.maxY
)
}
}
return results
})
return react('render culled shapes ', function renderCulledShapes() {
const canvas = canvasRef.current
if (!canvas) return
const width = canvas.clientWidth
const height = canvas.clientHeight
if (width !== canvas.width || height !== canvas.height) {
canvas.width = width
canvas.height = height
context.viewport(0, 0, width, height)
}
const verticesArray = shapeVertices.get()
context.clear(context.COLOR_BUFFER_BIT | context.DEPTH_BUFFER_BIT)
if (verticesArray.length > 0) {
const viewport = editor.getViewportPageBounds() // when the viewport changes...
context.uniform2f(viewportStartUniformLocation, viewport.minX, viewport.minY)
context.uniform2f(viewportEndUniformLocation, viewport.maxX, viewport.maxY)
const triangleGeoCpuBuffer = new Float32Array(verticesArray)
const triangleGeoBuffer = context.createBuffer()
context.bindBuffer(context.ARRAY_BUFFER, triangleGeoBuffer)
context.bufferData(context.ARRAY_BUFFER, triangleGeoCpuBuffer, context.STATIC_DRAW)
context.vertexAttribPointer(
shapeVertexPositionAttributeLocation,
2,
context.FLOAT,
false,
2 * Float32Array.BYTES_PER_ELEMENT,
0
)
context.drawArrays(context.TRIANGLES, 0, verticesArray.length / 2)
}
})
}, [isCullingOffScreenShapes, isDarkMode, editor])
return isCullingOffScreenShapes ? (
<canvas ref={canvasRef} className="tl-culled-shapes__canvas" />
) : null
}
export function CulledShapes() {
if (process.env.NODE_ENV === 'test') {
return null
}
return _CulledShapes()
}

View file

@ -50,6 +50,7 @@ export const Shape = memo(function Shape({
height: 0,
x: 0,
y: 0,
isCulled: false,
})
useQuickReactor(
@ -124,9 +125,13 @@ export const Shape = memo(function Shape({
const shape = editor.getShape(id)
if (!shape) return // probably the shape was just deleted
const isCulled = editor.isShapeCulled(shape)
setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block')
setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block')
const culledShapes = editor.getCulledShapes()
const isCulled = culledShapes.has(id)
if (isCulled !== memoizedStuffRef.current.isCulled) {
setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block')
setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block')
memoizedStuffRef.current.isCulled = isCulled
}
},
[editor]
)

View file

@ -21,7 +21,6 @@ import { toDomPrecision } from '../../primitives/utils'
import { debugFlags } from '../../utils/debug-flags'
import { setStyleProperty } from '../../utils/dom'
import { nearestMultiple } from '../../utils/nearestMultiple'
import { CulledShapes } from '../CulledShapes'
import { GeometryDebuggingView } from '../GeometryDebuggingView'
import { LiveCollaborators } from '../LiveCollaborators'
import { Shape } from '../Shape'
@ -125,9 +124,6 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
<Background />
</div>
)}
<div className="tl-culled-shapes">
<CulledShapes />
</div>
<div
ref={rCanvas}
draggable={false}
@ -388,6 +384,30 @@ function ShapesWithSVGs() {
</>
)
}
function ReflowIfNeeded() {
const editor = useEditor()
const culledShapesRef = useRef<Set<TLShapeId>>(new Set())
useQuickReactor(
'reflow for culled shapes',
() => {
const culledShapes = editor.getCulledShapes()
if (
culledShapesRef.current.size === culledShapes.size &&
[...culledShapes].every((id) => culledShapesRef.current.has(id))
)
return
culledShapesRef.current = culledShapes
const canvas = document.getElementsByClassName('tl-canvas')
if (canvas.length === 0) return
// This causes a reflow
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a
const _height = (canvas[0] as HTMLDivElement).offsetHeight
},
[editor]
)
return null
}
function ShapesToDisplay() {
const editor = useEditor()
@ -408,6 +428,7 @@ function ShapesToDisplay() {
{renderingShapes.map((result) => (
<Shape key={result.id + '_shape'} {...result} dprMultiple={dprMultiple} />
))}
{editor.environment.isSafari && <ReflowIfNeeded />}
</>
)
}

View file

@ -102,6 +102,7 @@ import { getReorderingShapesChanges } from '../utils/reorderShapes'
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
import { uniqueId } from '../utils/uniqueId'
import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
import { notVisibleShapes } from './derivations/notVisibleShapes'
import { parentsToChildren } from './derivations/parentsToChildren'
import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
import { getSvgJsx } from './getSvgJsx'
@ -3224,19 +3225,6 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
private readonly _renderingBounds = atom('rendering viewport', new Box())
/**
* The current rendering bounds in the current page space, expanded slightly. Used for determining which shapes
* to render and which to "cull".
*
* @public
*/
getRenderingBoundsExpanded() {
return this._renderingBoundsExpanded.get()
}
/** @internal */
private readonly _renderingBoundsExpanded = atom('rendering viewport expanded', new Box())
/**
* Update the rendering bounds. This should be called when the viewport has stopped changing, such
* as at the end of a pan, zoom, or animation.
@ -3254,13 +3242,6 @@ export class Editor extends EventEmitter<TLEventMap> {
if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this
this._renderingBounds.set(viewportPageBounds.clone())
if (Number.isFinite(this.renderingBoundsMargin)) {
this._renderingBoundsExpanded.set(
viewportPageBounds.clone().expandBy(this.renderingBoundsMargin / this.getZoomLevel())
)
} else {
this._renderingBoundsExpanded.set(viewportPageBounds)
}
return this
}
@ -4243,48 +4224,30 @@ export class Editor extends EventEmitter<TLEventMap> {
}
@computed
private _getShapeCullingInfoCache(): ComputedCache<boolean, TLShape> {
return this.store.createComputedCache(
'shapeCullingInfo',
({ id }) => {
// We don't cull shapes that are being edited
if (this.getEditingShapeId() === id) return false
const maskedPageBounds = this.getShapeMaskedPageBounds(id)
// if the shape is fully outside of its parent's clipping bounds...
if (maskedPageBounds === undefined) return true
// We don't cull selected shapes
if (this.getSelectedShapeIds().includes(id)) return false
const renderingBoundsExpanded = this.getRenderingBoundsExpanded()
// the shape is outside of the expanded viewport bounds...
return !renderingBoundsExpanded.includes(maskedPageBounds)
},
(a, b) => this.getShapeMaskedPageBounds(a) === this.getShapeMaskedPageBounds(b)
)
private _notVisibleShapes() {
return notVisibleShapes(this)
}
/**
* Get whether the shape is culled or not.
*
* @example
* ```ts
* editor.isShapeCulled(myShape)
* editor.isShapeCulled(myShapeId)
* ```
*
* @param shape - The shape (or shape id) to get the culled info for.
* Get culled shapes.
*
* @public
*/
isShapeCulled(shape: TLShape | TLShapeId): boolean {
// If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes
const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin)
if (!isCullingOffScreenShapes) return false
const id = typeof shape === 'string' ? shape : shape.id
return this._getShapeCullingInfoCache().get(id)! as boolean
@computed
getCulledShapes() {
const notVisibleShapes = this._notVisibleShapes().get()
const selectedShapeIds = this.getSelectedShapeIds()
const editingId = this.getEditingShapeId()
const culledShapes = new Set<TLShapeId>(notVisibleShapes)
// we don't cull the shape we are editing
if (editingId) {
culledShapes.delete(editingId)
}
// we also don't cull selected shapes
selectedShapeIds.forEach((id) => {
culledShapes.delete(id)
})
return culledShapes
}
/**
@ -4369,7 +4332,6 @@ export class Editor extends EventEmitter<TLEventMap> {
if (filter) return filter(shape)
return true
})
for (let i = shapesToCheck.length - 1; i >= 0; i--) {
const shape = shapesToCheck[i]
const geometry = this.getShapeGeometry(shape)
@ -4657,9 +4619,9 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
@computed
getCurrentPageRenderingShapesSorted(): TLShape[] {
return this.getCurrentPageShapesSorted().filter((shape) => !this.isShapeCulled(shape))
@computed getCurrentPageRenderingShapesSorted(): TLShape[] {
const culledShapes = this.getCulledShapes()
return this.getCurrentPageShapesSorted().filter(({ id }) => !culledShapes.has(id))
}
/**

View file

@ -0,0 +1,105 @@
import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema'
import { Box } from '../../primitives/Box'
import { Editor } from '../Editor'
function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Box): boolean {
const maskedPageBounds = editor.getShapeMaskedPageBounds(id)
// if the shape is fully outside of its parent's clipping bounds...
if (maskedPageBounds === undefined) return true
// if the shape is fully outside of the viewport page bounds...
return !viewportPageBounds.includes(maskedPageBounds)
}
/**
* Incremental derivation of not visible shapes.
* Non visible shapes are shapes outside of the viewport page bounds and shapes outside of parent's clipping bounds.
*
* @param editor - Instance of the tldraw Editor.
* @returns Incremental derivation of non visible shapes.
*/
export const notVisibleShapes = (editor: Editor) => {
const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin)
const shapeHistory = editor.store.query.filterHistory('shape')
let lastPageId: TLPageId | null = null
let prevViewportPageBounds: Box
function fromScratch(editor: Editor): Set<TLShapeId> {
const shapes = editor.getCurrentPageShapeIds()
lastPageId = editor.getCurrentPageId()
const viewportPageBounds = editor.getViewportPageBounds()
prevViewportPageBounds = viewportPageBounds.clone()
const notVisibleShapes = new Set<TLShapeId>()
shapes.forEach((id) => {
if (isShapeNotVisible(editor, id, viewportPageBounds)) {
notVisibleShapes.add(id)
}
})
return notVisibleShapes
}
return computed<Set<TLShapeId>>('getCulledShapes', (prevValue, lastComputedEpoch) => {
if (!isCullingOffScreenShapes) return new Set<TLShapeId>()
if (isUninitialized(prevValue)) {
return fromScratch(editor)
}
const diff = shapeHistory.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) {
return fromScratch(editor)
}
const currentPageId = editor.getCurrentPageId()
if (lastPageId !== currentPageId) {
return fromScratch(editor)
}
const viewportPageBounds = editor.getViewportPageBounds()
if (!prevViewportPageBounds || !viewportPageBounds.equals(prevViewportPageBounds)) {
return fromScratch(editor)
}
let nextValue = null as null | Set<TLShapeId>
const addId = (id: TLShapeId) => {
// Already added
if (prevValue.has(id)) return
if (!nextValue) nextValue = new Set(prevValue)
nextValue.add(id)
}
const deleteId = (id: TLShapeId) => {
// No need to delete since it's not there
if (!prevValue.has(id)) return
if (!nextValue) nextValue = new Set(prevValue)
nextValue.delete(id)
}
for (const changes of diff) {
for (const record of Object.values(changes.added)) {
if (isShape(record)) {
const isCulled = isShapeNotVisible(editor, record.id, viewportPageBounds)
if (isCulled) {
addId(record.id)
}
}
}
for (const [_from, to] of Object.values(changes.updated)) {
if (isShape(to)) {
const isCulled = isShapeNotVisible(editor, to.id, viewportPageBounds)
if (isCulled) {
addId(to.id)
} else {
deleteId(to.id)
}
}
}
for (const id of Object.keys(changes.removed)) {
if (isShapeId(id)) {
deleteId(id)
}
}
}
return nextValue ?? prevValue
})
}

View file

@ -0,0 +1,138 @@
import { Box, TLShapeId, createShapeId } from '@tldraw/editor'
import { TestEditor } from './TestEditor'
import { TL } from './test-jsx'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
editor.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
editor.renderingBoundsMargin = 100
})
function createShapes() {
return editor.createShapesFromJsx([
<TL.geo ref="A" x={100} y={100} w={100} h={100} />,
<TL.frame ref="B" x={200} y={200} w={300} h={300}>
<TL.geo ref="C" x={200} y={200} w={50} h={50} />
{/* this is outside of the frames clipping bounds, so it should never be rendered */}
<TL.geo ref="D" x={1000} y={1000} w={50} h={50} />
</TL.frame>,
])
}
it('lists shapes in viewport', () => {
const ids = createShapes()
editor.selectNone()
// D is clipped and so should always be culled / outside of viewport
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.D]))
// Move the camera 201 pixels to the right and 201 pixels down
editor.pan({ x: -201, y: -201 })
jest.advanceTimersByTime(500)
// A is now outside of the viewport
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.D]))
editor.pan({ x: -900, y: -900 })
jest.advanceTimersByTime(500)
// Now all shapes are outside of the viewport
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.B, ids.C, ids.D]))
editor.select(ids.B)
// We don't cull selected shapes
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.C, ids.D]))
editor.setEditingShape(ids.C)
// or shapes being edited
expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.D]))
})
const shapeSize = 100
const numberOfShapes = 100
function getChangeOutsideBounds(viewportSize: number) {
const changeDirection = Math.random() > 0.5 ? 1 : -1
const maxChange = 1000
const changeAmount = 1 + Math.random() * maxChange
if (changeDirection === 1) {
// We need to get past the viewport size and then add a bit more
return viewportSize + changeAmount
} else {
// We also need to take the shape size into account
return -changeAmount - shapeSize
}
}
function getChangeInsideBounds(viewportSize: number) {
// We can go from -shapeSize to viewportSize
return -shapeSize + Math.random() * (viewportSize + shapeSize)
}
function createFuzzShape(viewport: Box) {
const id = createShapeId()
if (Math.random() > 0.5) {
const positionChange = Math.random()
// Should x, or y, or both go outside the bounds?
const dimensionChange = positionChange < 0.33 ? 'x' : positionChange < 0.66 ? 'y' : 'both'
const xOutsideBounds = dimensionChange === 'x' || dimensionChange === 'both'
const yOutsideBounds = dimensionChange === 'y' || dimensionChange === 'both'
// Create a shape outside the viewport
editor.createShape({
id,
type: 'geo',
x:
viewport.x +
(xOutsideBounds ? getChangeOutsideBounds(viewport.w) : getChangeInsideBounds(viewport.w)),
y:
viewport.y +
(yOutsideBounds ? getChangeOutsideBounds(viewport.h) : getChangeInsideBounds(viewport.h)),
props: { w: shapeSize, h: shapeSize },
})
return { isCulled: true, id }
} else {
// Create a shape inside the viewport
editor.createShape({
id,
type: 'geo',
x: viewport.x + getChangeInsideBounds(viewport.w),
y: viewport.y + getChangeInsideBounds(viewport.h),
props: { w: shapeSize, h: shapeSize },
})
return { isCulled: false, id }
}
}
it('correctly calculates the culled shapes when adding and deleting shapes', () => {
const viewport = editor.getViewportPageBounds()
const shapes: Array<TLShapeId | undefined> = []
for (let i = 0; i < numberOfShapes; i++) {
const { isCulled, id } = createFuzzShape(viewport)
shapes.push(id)
if (isCulled) {
expect(editor.getCulledShapes()).toContain(id)
} else {
expect(editor.getCulledShapes()).not.toContain(id)
}
}
const numberOfShapesToDelete = Math.floor((Math.random() * numberOfShapes) / 2)
for (let i = 0; i < numberOfShapesToDelete; i++) {
const index = Math.floor(Math.random() * (shapes.length - 1))
const id = shapes[index]
if (id) {
editor.deleteShape(id)
shapes[index] = undefined
expect(editor.getCulledShapes()).not.toContain(id)
}
}
const culledShapesIncremental = editor.getCulledShapes()
// force full refresh
editor.pan({ x: -1, y: 0 })
editor.pan({ x: 1, y: 0 })
const culledShapeFromScratch = editor.getCulledShapes()
expect(culledShapesIncremental).toEqual(culledShapeFromScratch)
})

View file

@ -60,55 +60,6 @@ it('updates the rendering viewport when the camera stops moving', () => {
expect(editor.getShapePageBounds(ids.A)).toMatchObject({ x: 100, y: 100, w: 100, h: 100 })
})
it('lists shapes in viewport', () => {
const ids = createShapes()
editor.selectNone()
expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual(
[
[ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen.
[ids.B, false],
[ids.C, false],
[ids.D, true], // D is clipped and so should always be culled / outside of viewport
]
)
// Move the camera 201 pixels to the right and 201 pixels down
editor.pan({ x: -201, y: -201 })
jest.advanceTimersByTime(500)
expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual(
[
[ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport)
[ids.B, false],
[ids.C, false],
[ids.D, true], // D is clipped and so should always be culled / outside of viewport
]
)
editor.pan({ x: -100, y: -100 })
jest.advanceTimersByTime(500)
expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual(
[
[ids.A, true], // A should be culled now that it's outside of the expanded viewport too
[ids.B, false],
[ids.C, false],
[ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport
]
)
editor.pan({ x: -900, y: -900 })
jest.advanceTimersByTime(500)
expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual(
[
[ids.A, true],
[ids.B, true],
[ids.C, true],
[ids.D, true],
]
)
})
it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => {
const ids = createShapes()
// Expect the results to be sorted correctly by id