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:
parent
2bbab1a790
commit
987b1ac0b9
10 changed files with 357 additions and 401 deletions
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
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]
|
||||
)
|
||||
|
|
|
@ -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 />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
105
packages/editor/src/lib/editor/derivations/notVisibleShapes.ts
Normal file
105
packages/editor/src/lib/editor/derivations/notVisibleShapes.ts
Normal 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
|
||||
})
|
||||
}
|
138
packages/tldraw/src/test/getCulledShapes.test.tsx
Normal file
138
packages/tldraw/src/test/getCulledShapes.test.tsx
Normal 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)
|
||||
})
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue