UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

343 lines (342 loc) 16.3 kB
// A cache record with sub-nodes. import { computeFirstIndexToRender, sortLeavesByZIndex } from '../../image/EditorImage.mjs'; import { Rect2, Color4 } from '@js-draw/math'; // 3x3 divisions for each node. const cacheDivisionSize = 3; export default class RenderingCacheNode { constructor(region, cacheState) { this.region = region; this.cacheState = cacheState; // invariant: instantiatedChildren.length === 9 this.instantiatedChildren = []; this.parent = null; this.cachedRenderer = null; // invariant: sortedInAscendingOrder(renderedIds) this.renderedIds = []; this.renderedMaxZIndex = null; } // Creates a previous layer of the cache tree and adds this as a child near the // center of the previous layer's children. // Returns this' parent if it already exists. generateParent() { if (this.parent) { return this.parent; } const parentRegion = Rect2.fromCorners(this.region.topLeft.minus(this.region.size), this.region.bottomRight.plus(this.region.size)); const parent = new RenderingCacheNode(parentRegion, this.cacheState); parent.generateChildren(); // Ensure the new node is matches the middle child's region. const checkTolerance = this.region.maxDimension / 100; const middleChildIdx = (parent.instantiatedChildren.length - 1) / 2; if (!parent.instantiatedChildren[middleChildIdx].region.eq(this.region, checkTolerance)) { console.error(parent.instantiatedChildren[middleChildIdx].region, '≠', this.region); throw new Error("Logic error: [this] is not contained within its parent's center child"); } // Replace the middle child parent.instantiatedChildren[middleChildIdx] = this; this.parent = parent; return parent; } // Generates children, if missing. generateChildren() { if (this.instantiatedChildren.length === 0) { if (this.region.size.x / cacheDivisionSize === 0 || this.region.size.y / cacheDivisionSize === 0) { console.warn('Cache element has zero size! Not generating children.'); return; } const childRects = this.region.divideIntoGrid(cacheDivisionSize, cacheDivisionSize); console.assert(childRects.length === cacheDivisionSize * cacheDivisionSize, 'Warning: divideIntoGrid created the wrong number of subrectangles!'); for (const rect of childRects) { const child = new RenderingCacheNode(rect, this.cacheState); child.parent = this; this.instantiatedChildren.push(child); } } this.checkRep(); } // Returns CacheNodes directly contained within this. getChildren() { this.checkRep(); this.generateChildren(); return this.instantiatedChildren; } smallestChildContaining(rect) { const largerThanChildren = rect.maxDimension > this.region.maxDimension / cacheDivisionSize; if (!this.region.containsRect(rect) || largerThanChildren) { return null; } for (const child of this.getChildren()) { if (child.region.containsRect(rect)) { return child.smallestChildContaining(rect) ?? child; } } return null; } // => [true] iff [this] can be rendered without too much scaling renderingWouldBeHighEnoughResolution(viewport) { // Determine how 1px in this corresponds to 1px on the canvas. // this.region.w is in canvas units. Thus, const sizeOfThisPixelOnCanvas = this.region.w / this.cacheState.props.blockResolution.x; const sizeOfThisPixelOnScreen = viewport.getScaleFactor() * sizeOfThisPixelOnCanvas; if (sizeOfThisPixelOnScreen > this.cacheState.props.maxScale) { return false; } return true; } // => [true] if all children of this can be rendered from their caches. allChildrenCanRender(viewport, leavesSortedById) { if (this.instantiatedChildren.length === 0) { return false; } for (const child of this.instantiatedChildren) { if (!child.region.intersects(viewport.visibleRect)) { continue; } if (!child.renderingIsUpToDate(this.idsOfIntersecting(leavesSortedById))) { return false; } } return true; } computeSortedByLeafIds(leaves) { const ids = leaves.slice(); ids.sort((a, b) => a.getId() - b.getId()); return ids; } // Returns a list of the ids of the nodes intersecting this idsOfIntersecting(nodes) { const result = []; for (const node of nodes) { if (node.getBBox().intersects(this.region)) { result.push(node.getId()); } } return result; } // Returns true iff all elems of this.renderedIds are in sortedIds. // sortedIds should be sorted by z-index (or some other order, so long as they are // sorted by the same thing as this.renderedIds.) allRenderedIdsIn(sortedIds) { if (this.renderedIds.length > sortedIds.length) { return false; } for (let i = 0; i < this.renderedIds.length; i++) { if (sortedIds[i] !== this.renderedIds[i]) { return false; } } return true; } renderingIsUpToDate(sortedIds) { if (this.cachedRenderer === null || sortedIds.length !== this.renderedIds.length) { return false; } return this.allRenderedIdsIn(sortedIds); } // Render all [items] within [viewport] renderItems(screenRenderer, items, viewport) { if (!viewport.visibleRect.intersects(this.region) || items.length === 0) { return; } // Divide [items] until nodes are smaller than this, or are leaves. const divideUntilSmallerThanThis = (itemsToDivide) => { const newItems = []; for (const item of itemsToDivide) { const bbox = item.getBBox(); if (!bbox.intersects(this.region)) { continue; } if (bbox.maxDimension >= this.region.maxDimension) { newItems.push(...item.getChildrenOrSelfIntersectingRegion(this.region)); } else { newItems.push(item); } } return newItems; }; items = divideUntilSmallerThanThis(items); // Can we cache at all? if (!this.cacheState.props.isOfCorrectType(screenRenderer)) { for (const item of items) { item.render(screenRenderer, viewport.visibleRect); } return; } if (this.cacheState.debugMode) { screenRenderer.drawRect(this.region, viewport.getSizeOfPixelOnCanvas(), { fill: Color4.yellow, }); } // Could we render direclty from [this] or do we need to recurse? const couldRender = this.renderingWouldBeHighEnoughResolution(viewport); if (!couldRender) { for (const child of this.getChildren()) { child.renderItems(screenRenderer, items.filter((item) => { return item.getBBox().intersects(child.region); }), viewport); } } else { // Determine whether we already have rendered the items const tooSmallToRender = (rect) => rect.w / this.region.w < 1 / this.cacheState.props.blockResolution.x; const leaves = []; for (const item of items) { leaves.push(...item.getLeavesIntersectingRegion(this.region, tooSmallToRender)); } sortLeavesByZIndex(leaves); const leavesByIds = this.computeSortedByLeafIds(leaves); // No intersecting leaves? No need to render if (leavesByIds.length === 0) { return; } const leafIds = leavesByIds.map((leaf) => leaf.getId()); let thisRenderer; if (!this.renderingIsUpToDate(leafIds)) { if (this.allChildrenCanRender(viewport, leavesByIds)) { for (const child of this.getChildren()) { child.renderItems(screenRenderer, items, viewport); } return; } let leafApproxRenderTime = 0; for (const leaf of leavesByIds) { if (!tooSmallToRender(leaf.getBBox())) { leafApproxRenderTime += leaf.getContent().getProportionalRenderingTime(); } } // Is it worth it to render the items? if (leafApproxRenderTime > this.cacheState.props.minProportionalRenderTimePerCache) { let fullRerenderNeeded = true; if (!this.cachedRenderer) { this.cachedRenderer = this.cacheState.recordManager.allocCanvas(this.region, () => this.onRegionDealloc()); } else if (leavesByIds.length > this.renderedIds.length && this.allRenderedIdsIn(leafIds) && this.renderedMaxZIndex !== null) { // We often don't need to do a full re-render even if something's changed. // Check whether we can just draw on top of the existing cache. const newLeaves = []; let minNewZIndex = null; for (let i = 0; i < leavesByIds.length; i++) { const leaf = leavesByIds[i]; const content = leaf.getContent(); const zIndex = content.getZIndex(); if (i >= this.renderedIds.length || leaf.getId() !== this.renderedIds[i]) { newLeaves.push(leaf); if (minNewZIndex === null || zIndex < minNewZIndex) { minNewZIndex = zIndex; } } } if (minNewZIndex !== null && minNewZIndex > this.renderedMaxZIndex) { fullRerenderNeeded = false; thisRenderer = this.cachedRenderer.startRender(); // Looping is faster than re-sorting. for (let i = 0; i < leaves.length; i++) { const leaf = leaves[i]; const zIndex = leaf.getContent().getZIndex(); if (zIndex > this.renderedMaxZIndex) { leaf.render(thisRenderer, this.region); this.renderedMaxZIndex = zIndex; } } if (this.cacheState.debugMode) { // Clay for adding new elements screenRenderer.drawRect(this.region, 2 * viewport.getSizeOfPixelOnCanvas(), { fill: Color4.clay, }); } } } else if (this.cacheState.debugMode) { console.log('Decided on a full re-render. Reason: At least one of the following is false:', '\n leafIds.length > this.renderedIds.length: ', leafIds.length > this.renderedIds.length, '\n this.allRenderedIdsIn(leafIds): ', this.allRenderedIdsIn(leafIds), '\n this.renderedMaxZIndex !== null: ', this.renderedMaxZIndex !== null, '\n\nthis.rerenderedIds: ', this.renderedIds, ', leafIds: ', leafIds); } if (fullRerenderNeeded) { thisRenderer = this.cachedRenderer.startRender(); thisRenderer.clear(); this.renderedMaxZIndex = null; const startIndex = computeFirstIndexToRender(leaves, this.region); for (let i = startIndex; i < leaves.length; i++) { const leaf = leaves[i]; const content = leaf.getContent(); this.renderedMaxZIndex ??= content.getZIndex(); this.renderedMaxZIndex = Math.max(this.renderedMaxZIndex, content.getZIndex()); leaf.render(thisRenderer, this.region); } if (this.cacheState.debugMode) { // Red for full rerender screenRenderer.drawRect(this.region, 3 * viewport.getSizeOfPixelOnCanvas(), { fill: Color4.red, }); } } this.renderedIds = leafIds; } else { this.cachedRenderer?.dealloc(); // Slightly increase the clip region to prevent seams. // Divide by two because grownBy expands the rectangle on all sides. const pixelSize = viewport.getSizeOfPixelOnCanvas(); const expandedRegion = new Rect2(this.region.x, this.region.y, this.region.w + pixelSize, this.region.h + pixelSize); const clip = true; screenRenderer.startObject(expandedRegion, clip); for (const leaf of leaves) { leaf.render(screenRenderer, this.region.intersection(viewport.visibleRect)); } screenRenderer.endObject(); if (this.cacheState.debugMode) { // Green for no cache needed render screenRenderer.drawRect(this.region, 2 * viewport.getSizeOfPixelOnCanvas(), { fill: Color4.green, }); } } } else { thisRenderer = this.cachedRenderer.startRender(); } if (thisRenderer) { const transformMat = this.cachedRenderer.getTransform(this.region).inverse(); screenRenderer.renderFromOtherOfSameType(transformMat, thisRenderer); } // Can we clean up this' children? (Are they unused?) if (this.instantiatedChildren.every((child) => child.isEmpty())) { this.instantiatedChildren = []; } } this.checkRep(); } // Returns true iff this/its children have no cached state. isEmpty() { if (this.cachedRenderer !== null) { return false; } return this.instantiatedChildren.every((child) => child.isEmpty()); } onRegionDealloc() { this.cachedRenderer = null; if (this.isEmpty()) { this.instantiatedChildren = []; } } checkRep() { if (this.instantiatedChildren.length !== cacheDivisionSize * cacheDivisionSize && this.instantiatedChildren.length !== 0) { throw new Error(`Repcheck: Wrong number of children. Got ${this.instantiatedChildren.length}`); } if (this.renderedIds[1] !== undefined && this.renderedIds[0] >= this.renderedIds[1]) { console.error(this.renderedIds); throw new Error('Repcheck: First two ids are not in ascending order!'); } for (const child of this.instantiatedChildren) { if (child.parent !== this) { throw new Error('Children should be linked to their parents!'); } } if (this.cachedRenderer && !this.cachedRenderer.isAllocd()) { throw new Error("this' cachedRenderer != null, but is dealloc'd"); } } }