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
JavaScript
// 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");
}
}
}