[culling] minimal culled diff with webgl (#3377)
This PR extracts the #3344 changes to a smaller diff against main. It does not include the changes to how / where culled shapes are calculated, though I understand this could be much more efficiently done! ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features --------- Co-authored-by: Mitja Bezenšek <mitja.bezensek@gmail.com>
This commit is contained in:
parent
4d32a38cf8
commit
97b5e4093a
4 changed files with 207 additions and 16 deletions
|
@ -24,6 +24,7 @@
|
||||||
/* Z Index */
|
/* Z Index */
|
||||||
--layer-background: 100;
|
--layer-background: 100;
|
||||||
--layer-grid: 150;
|
--layer-grid: 150;
|
||||||
|
--layer-culled-shapes: 175;
|
||||||
--layer-canvas: 200;
|
--layer-canvas: 200;
|
||||||
--layer-shapes: 300;
|
--layer-shapes: 300;
|
||||||
--layer-overlays: 400;
|
--layer-overlays: 400;
|
||||||
|
@ -236,6 +237,20 @@ input,
|
||||||
contain: strict;
|
contain: strict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tl-culled-shapes {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: var(--layer-culled-shapes);
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
contain: size layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-culled-shapes__canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.tl-shapes {
|
.tl-shapes {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: var(--layer-shapes);
|
z-index: var(--layer-shapes);
|
||||||
|
@ -269,13 +284,16 @@ input,
|
||||||
|
|
||||||
/* ------------------- Background ------------------- */
|
/* ------------------- Background ------------------- */
|
||||||
|
|
||||||
|
.tl-background__wrapper {
|
||||||
|
z-index: var(--layer-background);
|
||||||
|
}
|
||||||
|
|
||||||
.tl-background {
|
.tl-background {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
inset: 0px;
|
inset: 0px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: var(--layer-background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------- Grid Layer --------------------- */
|
/* --------------------- Grid Layer --------------------- */
|
||||||
|
|
178
packages/editor/src/lib/components/CulledShapes.tsx
Normal file
178
packages/editor/src/lib/components/CulledShapes.tsx
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 { isCulled, maskedPageBounds } of editor.getRenderingShapes()) {
|
||||||
|
if (isCulled && 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
|
||||||
|
}
|
|
@ -43,7 +43,6 @@ export const Shape = memo(function Shape({
|
||||||
const { ShapeErrorFallback } = useEditorComponents()
|
const { ShapeErrorFallback } = useEditorComponents()
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const culledContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const bgContainerRef = useRef<HTMLDivElement>(null)
|
const bgContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const memoizedStuffRef = useRef({
|
const memoizedStuffRef = useRef({
|
||||||
|
@ -67,7 +66,6 @@ export const Shape = memo(function Shape({
|
||||||
const clipPath = editor.getShapeClipPath(id) ?? 'none'
|
const clipPath = editor.getShapeClipPath(id) ?? 'none'
|
||||||
if (clipPath !== prev.clipPath) {
|
if (clipPath !== prev.clipPath) {
|
||||||
setStyleProperty(containerRef.current, 'clip-path', clipPath)
|
setStyleProperty(containerRef.current, 'clip-path', clipPath)
|
||||||
setStyleProperty(culledContainerRef.current, 'clip-path', clipPath)
|
|
||||||
setStyleProperty(bgContainerRef.current, 'clip-path', clipPath)
|
setStyleProperty(bgContainerRef.current, 'clip-path', clipPath)
|
||||||
prev.clipPath = clipPath
|
prev.clipPath = clipPath
|
||||||
}
|
}
|
||||||
|
@ -81,11 +79,6 @@ export const Shape = memo(function Shape({
|
||||||
if (transform !== prev.transform) {
|
if (transform !== prev.transform) {
|
||||||
setStyleProperty(containerRef.current, 'transform', transform)
|
setStyleProperty(containerRef.current, 'transform', transform)
|
||||||
setStyleProperty(bgContainerRef.current, 'transform', transform)
|
setStyleProperty(bgContainerRef.current, 'transform', transform)
|
||||||
setStyleProperty(
|
|
||||||
culledContainerRef.current,
|
|
||||||
'transform',
|
|
||||||
`${Mat.toCssString(pageTransform)} translate(${bounds.x}px, ${bounds.y}px)`
|
|
||||||
)
|
|
||||||
prev.transform = transform
|
prev.transform = transform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,8 +93,6 @@ export const Shape = memo(function Shape({
|
||||||
if (width !== prev.width || height !== prev.height) {
|
if (width !== prev.width || height !== prev.height) {
|
||||||
setStyleProperty(containerRef.current, 'width', Math.max(width, dprMultiple) + 'px')
|
setStyleProperty(containerRef.current, 'width', Math.max(width, dprMultiple) + 'px')
|
||||||
setStyleProperty(containerRef.current, 'height', Math.max(height, dprMultiple) + 'px')
|
setStyleProperty(containerRef.current, 'height', Math.max(height, dprMultiple) + 'px')
|
||||||
setStyleProperty(culledContainerRef.current, 'width', Math.max(width, dprMultiple) + 'px')
|
|
||||||
setStyleProperty(culledContainerRef.current, 'height', Math.max(height, dprMultiple) + 'px')
|
|
||||||
setStyleProperty(bgContainerRef.current, 'width', Math.max(width, dprMultiple) + 'px')
|
setStyleProperty(bgContainerRef.current, 'width', Math.max(width, dprMultiple) + 'px')
|
||||||
setStyleProperty(bgContainerRef.current, 'height', Math.max(height, dprMultiple) + 'px')
|
setStyleProperty(bgContainerRef.current, 'height', Math.max(height, dprMultiple) + 'px')
|
||||||
prev.width = width
|
prev.width = width
|
||||||
|
@ -132,10 +123,8 @@ export const Shape = memo(function Shape({
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
const bgContainer = bgContainerRef.current
|
const bgContainer = bgContainerRef.current
|
||||||
const culledContainer = culledContainerRef.current
|
|
||||||
setStyleProperty(container, 'display', isCulled ? 'none' : 'block')
|
setStyleProperty(container, 'display', isCulled ? 'none' : 'block')
|
||||||
setStyleProperty(bgContainer, 'display', isCulled ? 'none' : 'block')
|
setStyleProperty(bgContainer, 'display', isCulled ? 'none' : 'block')
|
||||||
setStyleProperty(culledContainer, 'display', isCulled ? 'block' : 'none')
|
|
||||||
}, [isCulled])
|
}, [isCulled])
|
||||||
|
|
||||||
const annotateError = useCallback(
|
const annotateError = useCallback(
|
||||||
|
@ -147,7 +136,6 @@ export const Shape = memo(function Shape({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={culledContainerRef} className="tl-shape__culled" draggable={false} />
|
|
||||||
{util.backgroundComponent && (
|
{util.backgroundComponent && (
|
||||||
<div
|
<div
|
||||||
ref={bgContainerRef}
|
ref={bgContainerRef}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { toDomPrecision } from '../../primitives/utils'
|
||||||
import { debugFlags } from '../../utils/debug-flags'
|
import { debugFlags } from '../../utils/debug-flags'
|
||||||
import { setStyleProperty } from '../../utils/dom'
|
import { setStyleProperty } from '../../utils/dom'
|
||||||
import { nearestMultiple } from '../../utils/nearestMultiple'
|
import { nearestMultiple } from '../../utils/nearestMultiple'
|
||||||
|
import { CulledShapes } from '../CulledShapes'
|
||||||
import { GeometryDebuggingView } from '../GeometryDebuggingView'
|
import { GeometryDebuggingView } from '../GeometryDebuggingView'
|
||||||
import { LiveCollaborators } from '../LiveCollaborators'
|
import { LiveCollaborators } from '../LiveCollaborators'
|
||||||
import { Shape } from '../Shape'
|
import { Shape } from '../Shape'
|
||||||
|
@ -46,7 +47,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
|
||||||
|
|
||||||
useQuickReactor(
|
useQuickReactor(
|
||||||
'position layers',
|
'position layers',
|
||||||
() => {
|
function positionLayersWhenCameraMoves() {
|
||||||
const { x, y, z } = editor.getCamera()
|
const { x, y, z } = editor.getCamera()
|
||||||
|
|
||||||
// Because the html container has a width/height of 1px, we
|
// Because the html container has a width/height of 1px, we
|
||||||
|
@ -105,9 +106,15 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
|
||||||
{SvgDefs && <SvgDefs />}
|
{SvgDefs && <SvgDefs />}
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
{Background && <Background />}
|
{Background && (
|
||||||
|
<div className="tl-background__wrapper">
|
||||||
|
<Background />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<GridWrapper />
|
<GridWrapper />
|
||||||
|
<div className="tl-culled-shapes">
|
||||||
|
<CulledShapes />
|
||||||
|
</div>
|
||||||
<div ref={rHtmlLayer} className="tl-html-layer tl-shapes" draggable={false}>
|
<div ref={rHtmlLayer} className="tl-html-layer tl-shapes" draggable={false}>
|
||||||
<OnTheCanvasWrapper />
|
<OnTheCanvasWrapper />
|
||||||
<SelectionBackgroundWrapper />
|
<SelectionBackgroundWrapper />
|
||||||
|
|
Loading…
Reference in a new issue