From f443716d636e4077f4deb7df54575d204edd3438 Mon Sep 17 00:00:00 2001 From: Frank Weindel <6070611+frank-weindel@users.noreply.github.com> Date: Thu, 27 Jun 2024 14:51:25 -0400 Subject: [PATCH] Canvas2D Text: Use a managed child node to render text - Canvas Text Textures are now managed properly by the texture manager - Also add willReadFrequently option to text drawing canvas element context --- examples/tests/text-canvas.ts | 2 - src/core/CoreNode.ts | 2 +- src/core/CoreTextNode.ts | 11 +- src/core/Stage.ts | 111 ++- .../renderers/CanvasTextRenderer.ts | 697 ++---------------- .../renderers/LightningTextTextureRenderer.ts | 3 - .../text-rendering/renderers/TextRenderer.ts | 5 +- src/core/textures/ImageTexture.ts | 14 +- src/core/textures/Texture.ts | 8 - src/main-api/Inspector.ts | 11 +- src/main-api/Renderer.ts | 113 +-- 11 files changed, 208 insertions(+), 769 deletions(-) diff --git a/examples/tests/text-canvas.ts b/examples/tests/text-canvas.ts index 774b4233..0f7e0045 100644 --- a/examples/tests/text-canvas.ts +++ b/examples/tests/text-canvas.ts @@ -99,8 +99,6 @@ export default async function test(settings: ExampleSettings) { { duration: 3000, easing: 'ease-out', - // loop: true, - stopMethod: 'reverse', }, ) .start(); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 4cc79070..069cc8dc 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -647,7 +647,7 @@ export type CoreNodeAnimatableProps = { export class CoreNode extends EventEmitter { readonly children: CoreNode[] = []; protected _id: number = getNewId(); - protected props: Required; + readonly props: Required; public updateType = UpdateType.All; diff --git a/src/core/CoreTextNode.ts b/src/core/CoreTextNode.ts index 5bfe061c..76f86ab7 100644 --- a/src/core/CoreTextNode.ts +++ b/src/core/CoreTextNode.ts @@ -390,6 +390,15 @@ export class CoreTextNode override renderQuads(renderer: CoreRenderer) { assertTruthy(this.globalTransform); + // If the text renderer does not support rendering quads, fallback to the + // default renderQuads method + if (!this.textRenderer.renderQuads) { + super.renderQuads(renderer); + return; + } + + // If the text renderer does support rendering quads, use it... + // Prevent quad rendering if parent has a render texture // and this node is not the render texture if (this.parentHasRenderTexture) { @@ -444,7 +453,7 @@ export class CoreTextNode this._textRendererOverride, ); - const textRendererState = resolvedTextRenderer.createState(props); + const textRendererState = resolvedTextRenderer.createState(props, this); textRendererState.emitter.on('loaded', this.onTextLoaded); textRendererState.emitter.on('failed', this.onTextFailed); diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 1fa065bb..5042763c 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -20,7 +20,7 @@ import { startLoop, getTimeStamp } from './platform.js'; import { WebGlCoreRenderer } from './renderers/webgl/WebGlCoreRenderer.js'; import { assertTruthy, setPremultiplyMode } from '../utils.js'; import { AnimationManager } from './animations/AnimationManager.js'; -import { CoreNode } from './CoreNode.js'; +import { CoreNode, type CoreNodeWritableProps } from './CoreNode.js'; import { CoreTextureManager } from './CoreTextureManager.js'; import { TrFontManager } from './text-rendering/TrFontManager.js'; import { CoreShaderManager } from './CoreShaderManager.js'; @@ -46,6 +46,11 @@ import type { CoreRendererOptions, } from './renderers/CoreRenderer.js'; import { CanvasCoreRenderer } from './renderers/canvas/CanvasCoreRenderer.js'; +import { santizeCustomDataMap } from '../main-api/utils.js'; +import { + CoreTextNode, + type CoreTextNodeWritableProps, +} from './CoreTextNode.js'; export interface StageOptions { appWidth: number; @@ -448,4 +453,108 @@ export class Stage { // the covariant state argument in the setter method map return resolvedTextRenderer as unknown as TextRenderer; } + + createNode(props: Partial) { + const resolvedProps = this.resolveNodeDefaults(props); + const node = new CoreNode(this, { + ...resolvedProps, + shaderProps: null, + }); + return node; + } + + createTextNode(props: Partial) { + const fontSize = props.fontSize ?? 16; + const resolvedProps = { + ...this.resolveNodeDefaults(props), + text: props.text ?? '', + textRendererOverride: props.textRendererOverride ?? null, + fontSize, + fontFamily: props.fontFamily ?? 'sans-serif', + fontStyle: props.fontStyle ?? 'normal', + fontWeight: props.fontWeight ?? 'normal', + fontStretch: props.fontStretch ?? 'normal', + textAlign: props.textAlign ?? 'left', + contain: props.contain ?? 'none', + scrollable: props.scrollable ?? false, + scrollY: props.scrollY ?? 0, + offsetY: props.offsetY ?? 0, + letterSpacing: props.letterSpacing ?? 0, + lineHeight: props.lineHeight, // `undefined` is a valid value + maxLines: props.maxLines ?? 0, + textBaseline: props.textBaseline ?? 'alphabetic', + verticalAlign: props.verticalAlign ?? 'middle', + overflowSuffix: props.overflowSuffix ?? '...', + debug: props.debug ?? {}, + shaderProps: null, + }; + + return new CoreTextNode(this, resolvedProps); + } + + /** + * Resolves the default property values for a Node + * + * @remarks + * This method is used internally by the RendererMain to resolve the default + * property values for a Node. It is exposed publicly so that it can be used + * by Core Driver implementations. + * + * @param props + * @returns + */ + protected resolveNodeDefaults( + props: Partial, + ): CoreNodeWritableProps { + const color = props.color ?? 0xffffffff; + const colorTl = props.colorTl ?? props.colorTop ?? props.colorLeft ?? color; + const colorTr = + props.colorTr ?? props.colorTop ?? props.colorRight ?? color; + const colorBl = + props.colorBl ?? props.colorBottom ?? props.colorLeft ?? color; + const colorBr = + props.colorBr ?? props.colorBottom ?? props.colorRight ?? color; + const data = santizeCustomDataMap(props.data ?? {}); + + return { + x: props.x ?? 0, + y: props.y ?? 0, + width: props.width ?? 0, + height: props.height ?? 0, + alpha: props.alpha ?? 1, + autosize: props.autosize ?? false, + clipping: props.clipping ?? false, + color, + colorTop: props.colorTop ?? color, + colorBottom: props.colorBottom ?? color, + colorLeft: props.colorLeft ?? color, + colorRight: props.colorRight ?? color, + colorBl, + colorBr, + colorTl, + colorTr, + zIndex: props.zIndex ?? 0, + zIndexLocked: props.zIndexLocked ?? 0, + parent: props.parent ?? null, + texture: props.texture ?? null, + textureOptions: props.textureOptions ?? {}, + shader: props.shader ?? null, + shaderProps: props.shaderProps ?? null, + // Since setting the `src` will trigger a texture load, we need to set it after + // we set the texture. Otherwise, problems happen. + src: props.src ?? '', + scale: props.scale ?? null, + scaleX: props.scaleX ?? props.scale ?? 1, + scaleY: props.scaleY ?? props.scale ?? 1, + mount: props.mount ?? 0, + mountX: props.mountX ?? props.mount ?? 0, + mountY: props.mountY ?? props.mount ?? 0, + pivot: props.pivot ?? 0.5, + pivotX: props.pivotX ?? props.pivot ?? 0.5, + pivotY: props.pivotY ?? props.pivot ?? 0.5, + rotation: props.rotation ?? 0, + rtt: props.rtt ?? false, + data: data, + }; + } } diff --git a/src/core/text-rendering/renderers/CanvasTextRenderer.ts b/src/core/text-rendering/renderers/CanvasTextRenderer.ts index 31b1e8f2..b6c15979 100644 --- a/src/core/text-rendering/renderers/CanvasTextRenderer.ts +++ b/src/core/text-rendering/renderers/CanvasTextRenderer.ts @@ -18,19 +18,13 @@ */ import { EventEmitter } from '../../../common/EventEmitter.js'; -import { assertTruthy, mergeColorAlphaPremultiplied } from '../../../utils.js'; +import { assertTruthy } from '../../../utils.js'; +import type { CoreNode } from '../../CoreNode.js'; +import type { CoreTextNode } from '../../CoreTextNode.js'; import type { Stage } from '../../Stage.js'; -import type { Matrix3d } from '../../lib/Matrix3d.js'; import { - intersectRect, - type Bound, - intersectBound, - boundsOverlap, getNormalizedRgbaComponents, getNormalizedAlphaComponent, - type BoundWithValid, - createBound, - type RectWithValid, } from '../../lib/utils.js'; import type { ImageTexture } from '../../textures/ImageTexture.js'; import { TrFontManager, type FontFamilyMap } from '../TrFontManager.js'; @@ -62,13 +56,6 @@ declare module './TextRenderer.js' { } } -interface CanvasPageInfo { - texture: ImageTexture | undefined; - lineNumStart: number; - lineNumEnd: number; - valid: boolean; -} - function getFontCssString(props: TrProps): string { const { fontFamily, fontStyle, fontWeight, fontStretch, fontSize } = props; return [fontStyle, fontWeight, fontStretch, `${fontSize}px`, fontFamily].join( @@ -77,9 +64,8 @@ function getFontCssString(props: TrProps): string { } export interface CanvasTextRendererState extends TextRendererState { + node: CoreTextNode; props: TrProps; - - fontFaceLoadedHandler: (() => void) | undefined; fontInfo: | { fontFace: WebTrFontFace; @@ -87,30 +73,16 @@ export interface CanvasTextRendererState extends TextRendererState { loaded: boolean; } | undefined; - canvasPages: [CanvasPageInfo, CanvasPageInfo, CanvasPageInfo] | undefined; - canvasPage: CanvasPageInfo | undefined; + textureNode: CoreNode | undefined; lightning2TextRenderer: LightningTextTextureRenderer; renderInfo: RenderInfo | undefined; - renderWindow: Bound | undefined; - visibleWindow: BoundWithValid; - isScrollable: boolean; } -/** - * Ephemeral bounds object used for intersection calculations - * - * @remarks - * Used to avoid creating a new object every time we need to intersect - * element bounds. - */ -const tmpElementBounds = createBound(0, 0, 0, 0); - export class CanvasTextRenderer extends TextRenderer { protected canvas: OffscreenCanvas | HTMLCanvasElement; protected context: | OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; - private rendererBounds: Bound; /** * Font family map used to store web font faces that were added to the * canvas text renderer. @@ -126,25 +98,21 @@ export class CanvasTextRenderer extends TextRenderer { this.canvas = document.createElement('canvas'); } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - let context = this.canvas.getContext('2d') as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D - | null; + let context = this.canvas.getContext('2d', { + willReadFrequently: true, + }) as OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D | null; if (!context) { // A browser may appear to support OffscreenCanvas but not actually support the Canvas '2d' context // Here we try getting the context again after falling back to an HTMLCanvasElement. // See: https://github.com/lightning-js/renderer/issues/26#issuecomment-1750438486 this.canvas = document.createElement('canvas'); - context = this.canvas.getContext('2d'); + context = this.canvas.getContext('2d', { + willReadFrequently: true, + }); } assertTruthy(context); this.context = context; - this.rendererBounds = { - x1: 0, - y1: 0, - x2: this.stage.options.appWidth, - y2: this.stage.options.appHeight, - }; + // Install the default 'san-serif' font face this.addFontFace( new WebTrFontFace({ @@ -199,17 +167,9 @@ export class CanvasTextRenderer extends TextRenderer { }, x: (state, value) => { state.props.x = value; - - if (this.isValidOnScreen(state) === true) { - this.invalidateVisibleWindowCache(state); - } }, y: (state, value) => { state.props.y = value; - - if (this.isValidOnScreen(state) === true) { - this.invalidateVisibleWindowCache(state); - } }, contain: (state, value) => { state.props.contain = value; @@ -260,9 +220,6 @@ export class CanvasTextRenderer extends TextRenderer { state.props.overflowSuffix = value; this.invalidateLayoutCache(state); }, - // debug: (state, value) => { - // state.props.debug = value; - // }, }; } @@ -304,32 +261,26 @@ export class CanvasTextRenderer extends TextRenderer { faceSet.add(fontFace); } - override createState(props: TrProps): CanvasTextRendererState { + override createState( + props: TrProps, + node: CoreTextNode, + ): CanvasTextRendererState { return { + node, props, status: 'initialState', updateScheduled: false, emitter: new EventEmitter(), - canvasPages: undefined, - canvasPage: undefined, + textureNode: undefined, lightning2TextRenderer: new LightningTextTextureRenderer( this.canvas, this.context, ), - renderWindow: undefined, - visibleWindow: { - x1: 0, - y1: 0, - x2: 0, - y2: 0, - valid: false, - }, renderInfo: undefined, forceFullLayoutCalc: false, textW: 0, textH: 0, fontInfo: undefined, - fontFaceLoadedHandler: undefined, isRenderable: false, debugData: { updateCount: 0, @@ -340,34 +291,20 @@ export class CanvasTextRenderer extends TextRenderer { drawSum: 0, bufferSize: 0, }, - isScrollable: props.scrollable === true, }; } override updateState(state: CanvasTextRendererState): void { // On the first update call we need to set the status to loading if (state.status === 'initialState') { + this.setStatus(state, 'loading'); // check if we're on screen - if (this.isValidOnScreen(state) === true) { - this.setStatus(state, 'loading'); - } - } - - // If the state is not renderable, we don't want to keep the texture - if (state.isRenderable === false && state.status === 'loaded') { - return this.destroyState(state); + // if (this.isValidOnScreen(state) === true) { + // this.setStatus(state, 'loading'); + // } } - if ( - state.isRenderable === false && - (state.status === 'initialState' || state.status === 'destroyed') - ) { - // If the state is not renderable and we're in the initial or destroyed state - // we don't need to do anything else. - return; - } - - if (state.status === 'loaded' && state.visibleWindow.valid === true) { + if (state.status === 'loaded') { // If we're loaded, we don't need to do anything return; } @@ -382,257 +319,64 @@ export class CanvasTextRenderer extends TextRenderer { return; } - if (!state.renderInfo) { - state.renderInfo = this.calculateRenderInfo(state); - } - - // handle scrollable text - if (state.isScrollable === true) { - return this.renderScrollableCanvasPages(state); - } - - // handle single page text - return this.renderSingleCanvasPage(state); - } - - renderSingleCanvasPage(state: CanvasTextRendererState): void { if (!state.renderInfo) { state.renderInfo = this.calculateRenderInfo(state); state.textH = state.renderInfo.lineHeight * state.renderInfo.lines.length; state.textW = state.renderInfo.width; + this.renderSingleCanvasPage(state); } - const visibleWindow = this.getAndCalculateVisibleWindow(state); - const visibleWindowHeight = visibleWindow.y2 - visibleWindow.y1; - if (visibleWindowHeight === 0) { - // Nothing to render. Clear any canvasPages and existing renderWindow - // Return early. - state.canvasPage = undefined; - state.renderWindow = undefined; - this.setStatus(state, 'loaded'); - return; - } - - // if our canvasPage texture is still valid, return early - if (state.canvasPage?.valid) { - this.setStatus(state, 'loaded'); - return; - } - - if (state.canvasPage === undefined) { - state.canvasPage = { - texture: undefined, - lineNumStart: 0, - lineNumEnd: 0, - valid: false, - }; - } - - // render the text in the canvas - state.lightning2TextRenderer.draw(state.renderInfo, { - lines: state.renderInfo.lines, - lineWidths: state.renderInfo.lineWidths, - }); - - // load the canvas texture - const src = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - if (this.canvas.width === 0 || this.canvas.height === 0) { - return; - } - - // add texture to texture manager - state.canvasPage?.texture?.setRenderableOwner(state, false); - state.canvasPage.texture = this.stage.txManager.loadTexture( - 'ImageTexture', - { src: src }, - { preload: true }, - ); - state.canvasPage.valid = true; - - if (state.canvasPage.texture.state === 'loaded') { - state.canvasPage.texture.setRenderableOwner(state, state.isRenderable); - this.setStatus(state, 'loaded'); - return; - } + // handle scrollable text !!! + // if (state.isScrollable === true) { + // return this.renderScrollableCanvasPages(state); + // } - state.canvasPage.texture.once('loaded', () => { - state.canvasPage?.texture?.setRenderableOwner(state, state.isRenderable); - this.setStatus(state, 'loaded'); - }); + // handle single page text } - renderScrollableCanvasPages(state: CanvasTextRendererState): void { - const { x, y, width, height, scrollY, contain } = state.props; - const { visibleWindow } = state; - let { renderWindow, canvasPages } = state; - - if (!visibleWindow.valid) { - // Figure out whats actually in the bounds of the renderer/canvas (visibleWindow) - const elementBounds = createBound( - x, - y, - contain !== 'none' ? x + width : Infinity, - contain === 'both' ? y + height : Infinity, - tmpElementBounds, - ); - /** - * Area that is visible on the screen. - */ - intersectBound(this.rendererBounds, elementBounds, visibleWindow); - visibleWindow.valid = true; - } - - const visibleWindowHeight = visibleWindow.y2 - visibleWindow.y1; - - if (!state.renderInfo) { - state.renderInfo = this.calculateRenderInfo(state); - state.textH = state.renderInfo.lineHeight * state.renderInfo.lines.length; - state.textW = state.renderInfo.width; - - // Invalidate renderWindow because the renderInfo changed - state.renderWindow = undefined; - } - - const maxLinesPerCanvasPage = Math.ceil( - visibleWindowHeight / state.renderInfo.lineHeight, - ); - - if (visibleWindowHeight === 0) { - // Nothing to render. Clear any canvasPages and existing renderWindow - // Return early. - canvasPages = undefined; - renderWindow = undefined; - this.setStatus(state, 'loaded'); - return; - } else if (renderWindow && canvasPages) { - // Return early if we're still viewing inside the established render window - // No need to re-render what we've already rendered - const renderWindowScreenX1 = x + renderWindow.x1; - const renderWindowScreenY1 = y - scrollY + renderWindow.y1; - const renderWindowScreenX2 = x + renderWindow.x2; - const renderWindowScreenY2 = y - scrollY + renderWindow.y2; - - if ( - renderWindowScreenX1 <= visibleWindow.x1 && - renderWindowScreenX2 >= visibleWindow.x2 && - renderWindowScreenY1 <= visibleWindow.y1 && - renderWindowScreenY2 >= visibleWindow.y2 + renderSingleCanvasPage(state: CanvasTextRendererState): void { + assertTruthy(state.renderInfo); + const node = state.node; + + const texture = this.stage.txManager.loadTexture('ImageTexture', { + src: function ( + this: CanvasTextRenderer, + lightning2TextRenderer: LightningTextTextureRenderer, + renderInfo: RenderInfo, ) { - this.setStatus(state, 'loaded'); - return; - } - if (renderWindowScreenY2 < visibleWindow.y2) { - // We've scrolled up, so we need to render the next page - renderWindow.y1 += maxLinesPerCanvasPage * state.renderInfo.lineHeight; - renderWindow.y2 += maxLinesPerCanvasPage * state.renderInfo.lineHeight; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - canvasPages.push(canvasPages.shift()!); - canvasPages[2].lineNumStart = - canvasPages[1].lineNumStart + maxLinesPerCanvasPage; - canvasPages[2].lineNumEnd = - canvasPages[2].lineNumStart + maxLinesPerCanvasPage; - canvasPages[2].valid = false; - } else if (renderWindowScreenY1 > visibleWindow.y1) { - // We've scrolled down, so we need to render the previous page - renderWindow.y1 -= maxLinesPerCanvasPage * state.renderInfo.lineHeight; - renderWindow.y2 -= maxLinesPerCanvasPage * state.renderInfo.lineHeight; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - canvasPages.unshift(canvasPages.pop()!); - canvasPages[0].lineNumStart = - canvasPages[1].lineNumStart - maxLinesPerCanvasPage; - canvasPages[0].lineNumEnd = - canvasPages[0].lineNumStart + maxLinesPerCanvasPage; - canvasPages[0].valid = false; - } - } else { - const pageHeight = state.renderInfo.lineHeight * maxLinesPerCanvasPage; - const page1Block = Math.ceil(scrollY / pageHeight); - const page1LineStart = page1Block * maxLinesPerCanvasPage; - const page0LineStart = page1LineStart - maxLinesPerCanvasPage; - const page2LineStart = page1LineStart + maxLinesPerCanvasPage; - - // We haven't rendered anything yet, so we need to render the first page - // If canvasPages already exist, let's re-use the textures - canvasPages = [ - { - texture: canvasPages?.[0].texture, - lineNumStart: page0LineStart, - lineNumEnd: page0LineStart + maxLinesPerCanvasPage, - valid: false, - }, - { - texture: canvasPages?.[1].texture, - lineNumStart: page1LineStart, - lineNumEnd: page1LineStart + maxLinesPerCanvasPage, - valid: false, - }, - { - texture: canvasPages?.[2].texture, - lineNumStart: page2LineStart, - lineNumEnd: page2LineStart + maxLinesPerCanvasPage, - valid: false, - }, - ]; - state.canvasPages = canvasPages; - - const scrollYNearestPage = page1Block * pageHeight; - - renderWindow = { - x1: 0, - y1: scrollYNearestPage - pageHeight, - x2: width, - y2: scrollYNearestPage + pageHeight * 2, - }; - } - - state.renderWindow = renderWindow; - - const pageDrawTime = performance.now(); - for (const pageInfo of canvasPages) { - if (pageInfo.valid) continue; - if (pageInfo.lineNumStart < 0) { - pageInfo.texture?.setRenderableOwner(state, false); - pageInfo.texture = this.stage.txManager.loadTexture('ImageTexture', { - src: '', + // load the canvas texture + assertTruthy(renderInfo); + lightning2TextRenderer.draw(renderInfo, { + lines: renderInfo.lines, + lineWidths: renderInfo.lineWidths, }); - pageInfo.texture.setRenderableOwner(state, state.isRenderable); - pageInfo.valid = true; - continue; - } - state.lightning2TextRenderer.draw(state.renderInfo, { - lines: state.renderInfo.lines.slice( - pageInfo.lineNumStart, - pageInfo.lineNumEnd, - ), - lineWidths: state.renderInfo.lineWidths.slice( - pageInfo.lineNumStart, - pageInfo.lineNumEnd, - ), + if (this.canvas.width === 0 || this.canvas.height === 0) { + return null; + } + return this.context.getImageData( + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + }.bind(this, state.lightning2TextRenderer, state.renderInfo), + }); + if (state.textureNode) { + // Use the existing texture node + state.textureNode.texture = texture; + } else { + // Create a new texture node + const textureNode = this.stage.createNode({ + parent: node, + texture, + autosize: true, + // The alpha channel of the color is ignored when rasterizing the text + // texture so we need to pass it directly to the texture node. + alpha: getNormalizedAlphaComponent(state.props.color), }); - if (!(this.canvas.width === 0 || this.canvas.height === 0)) { - pageInfo.texture?.setRenderableOwner(state, false); - pageInfo.texture = this.stage.txManager.loadTexture('ImageTexture', { - src: this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ), - }); - pageInfo.texture.ctxTexture.load(); - - pageInfo.texture.setRenderableOwner(state, state.isRenderable); - } - pageInfo.valid = true; + state.textureNode = textureNode; } - // console.log('pageDrawTime', performance.now() - pageDrawTime, 'ms'); - // Report final status this.setStatus(state, 'loaded'); } @@ -688,269 +432,14 @@ export class CanvasTextRenderer extends TextRenderer { overflowSuffix: state.props.overflowSuffix, w: state.props.contain !== 'none' ? state.props.width : undefined, }; - // const renderInfoCalculateTime = performance.now(); state.renderInfo = state.lightning2TextRenderer.calculateRenderInfo(); - // console.log( - // 'Render info calculated in', - // performance.now() - renderInfoCalculateTime, - // 'ms', - // ); - state.textH = state.renderInfo.lineHeight * state.renderInfo.lines.length; - state.textW = state.renderInfo.width; - - // Invalidate renderWindow because the renderInfo changed - state.renderWindow = undefined; return state.renderInfo; } - getAndCalculateVisibleWindow(state: CanvasTextRendererState): BoundWithValid { - const { x, y, width, height, contain } = state.props; - const { visibleWindow } = state; - - if (!visibleWindow.valid) { - // Figure out whats actually in the bounds of the renderer/canvas (visibleWindow) - const elementBounds = createBound( - x, - y, - contain !== 'none' ? x + width : Infinity, - contain === 'both' ? y + height : Infinity, - tmpElementBounds, - ); - /** - * Area that is visible on the screen. - */ - intersectBound(this.rendererBounds, elementBounds, visibleWindow); - visibleWindow.valid = true; - } - - return visibleWindow; - } - - override renderQuads( - state: CanvasTextRendererState, - transform: Matrix3d, - clippingRect: RectWithValid, - alpha: number, - ): void { - if (state.props.scrollable === true) { - return this.renderQuadsScrollable(state, transform, clippingRect, alpha); - } - - const { canvasPage } = state; - if (!canvasPage) return; - - const { zIndex, color } = state.props; - - // Color alpha of text is not properly rendered to the Canvas texture, so we - // need to apply it here. - const combinedAlpha = alpha * getNormalizedAlphaComponent(color); - const quadColor = mergeColorAlphaPremultiplied(0xffffffff, combinedAlpha); - - this.stage.renderer.addQuad({ - alpha: combinedAlpha, - clippingRect, - colorBl: quadColor, - colorBr: quadColor, - colorTl: quadColor, - colorTr: quadColor, - width: canvasPage.texture?.dimensions?.width || 0, - height: canvasPage.texture?.dimensions?.height || 0, - texture: canvasPage.texture!, - textureOptions: {}, - shader: null, - shaderProps: null, - zIndex, - tx: transform.tx, - ty: transform.ty, - ta: transform.ta, - tb: transform.tb, - tc: transform.tc, - td: transform.td, - }); - } - - renderQuadsScrollable( - state: CanvasTextRendererState, - transform: Matrix3d, - clippingRect: RectWithValid, - alpha: number, - ): void { - const { stage } = this; - - const { canvasPages, textW = 0, textH = 0, renderWindow } = state; - if (!canvasPages || !renderWindow) return; - - const { x, y, scrollY, contain, width, height /*, debug*/ } = state.props; - const elementRect = { - x: x, - y: y, - width: contain !== 'none' ? width : textW, - height: contain === 'both' ? height : textH, - }; - - const visibleRect = intersectRect( - { - x: 0, - y: 0, - width: stage.options.appWidth, - height: stage.options.appHeight, - }, - elementRect, - ); - - // if (!debug.disableScissor) { - // renderer.enableScissor( - // visibleRect.x, - // visibleRect.y, - // visibleRect.w, - // visibleRect.h, - // ); - // } - - assertTruthy(canvasPages, 'canvasPages is not defined'); - assertTruthy(renderWindow, 'renderWindow is not defined'); - - const renderWindowHeight = renderWindow.y2 - renderWindow.y1; - const pageSize = renderWindowHeight / 3.0; - - const { zIndex, color } = state.props; - - // Color alpha of text is not properly rendered to the Canvas texture, so we - // need to apply it here. - const combinedAlpha = alpha * getNormalizedAlphaComponent(color); - const quadColor = mergeColorAlphaPremultiplied(0xffffffff, combinedAlpha); - if (canvasPages[0].valid) { - this.stage.renderer.addQuad({ - alpha: combinedAlpha, - clippingRect, - colorBl: quadColor, - colorBr: quadColor, - colorTl: quadColor, - colorTr: quadColor, - width: canvasPages[0].texture?.dimensions?.width || 0, - height: canvasPages[0].texture?.dimensions?.height || 0, - texture: canvasPages[0].texture!, - textureOptions: {}, - shader: null, - shaderProps: null, - zIndex, - tx: transform.tx, - ty: transform.ty - scrollY + renderWindow.y1, - ta: transform.ta, - tb: transform.tb, - tc: transform.tc, - td: transform.td, - }); - } - if (canvasPages[1].valid) { - this.stage.renderer.addQuad({ - alpha: combinedAlpha, - clippingRect, - colorBl: quadColor, - colorBr: quadColor, - colorTl: quadColor, - colorTr: quadColor, - width: canvasPages[1].texture?.dimensions?.width || 0, - height: canvasPages[1].texture?.dimensions?.height || 0, - texture: canvasPages[1].texture!, - textureOptions: {}, - shader: null, - shaderProps: null, - zIndex, - tx: transform.tx, - ty: transform.ty - scrollY + renderWindow.y1 + pageSize, - ta: transform.ta, - tb: transform.tb, - tc: transform.tc, - td: transform.td, - }); - } - if (canvasPages[2].valid) { - this.stage.renderer.addQuad({ - alpha: combinedAlpha, - clippingRect, - colorBl: quadColor, - colorBr: quadColor, - colorTl: quadColor, - colorTr: quadColor, - width: canvasPages[2].texture?.dimensions?.width || 0, - height: canvasPages[2].texture?.dimensions?.height || 0, - texture: canvasPages[2].texture!, - textureOptions: {}, - shader: null, - shaderProps: null, - zIndex, - tx: transform.tx, - ty: transform.ty - scrollY + renderWindow.y1 + pageSize + pageSize, - ta: transform.ta, - tb: transform.tb, - tc: transform.tc, - td: transform.td, - }); - } - - // renderer.disableScissor(); - - // if (debug.showElementRect) { - // this.renderer.drawBorder( - // Colors.Blue, - // elementRect.x, - // elementRect.y, - // elementRect.w, - // elementRect.h, - // ); - // } - - // if (debug.showVisibleRect) { - // this.renderer.drawBorder( - // Colors.Green, - // visibleRect.x, - // visibleRect.y, - // visibleRect.w, - // visibleRect.h, - // ); - // } - - // if (debug.showRenderWindow && renderWindow) { - // this.renderer.drawBorder( - // Colors.Red, - // x + renderWindow.x1, - // y + renderWindow.y1 - scrollY, - // x + renderWindow.x2 - (x + renderWindow.x1), - // y + renderWindow.y2 - scrollY - (y + renderWindow.y1 - scrollY), - // ); - // } - } - - isValidOnScreen(state: CanvasTextRendererState): boolean { - // if we dont have a valid render window, we can't be on screen - if (!state.visibleWindow.valid === false) { - return false; - } - - const { x, y, width, height, contain } = state.props; - const elementBounds = createBound( - x, - y, - contain !== 'none' ? x + width : Infinity, - contain === 'both' ? y + height : Infinity, - tmpElementBounds, - ); - - const isPossiblyOnScreen = boundsOverlap( - elementBounds, - this.rendererBounds, - ); - - return isPossiblyOnScreen; - } - - override setIsRenderable( - state: CanvasTextRendererState, - renderable: boolean, - ): void { - super.setIsRenderable(state, renderable); - this.updateState(state); + override renderQuads(): void { + // Do nothing. The renderer will render the child node(s) that were created + // in the state update. + return; } override destroyState(state: CanvasTextRendererState): void { @@ -959,40 +448,14 @@ export class CanvasTextRenderer extends TextRenderer { } super.destroyState(state); - // Remove state object owner from any canvas page textures - state.canvasPages?.forEach((pageInfo) => { - const { texture } = pageInfo; - if (texture) { - texture.setRenderableOwner(state, false); - texture.free(); - } - }); - - const { texture } = state.canvasPage || {}; - if (texture) { - texture.setRenderableOwner(state, false); - texture.free(); + if (state.textureNode) { + state.textureNode.destroy(); + delete state.textureNode; } - delete state.renderInfo; - delete state.canvasPage; - delete state.canvasPages; - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); } //#endregion Overrides - /** - * Invalidate the visible window stored in the state. This will cause a new - * visible window to be calculated on the next update. - * - * @param state - */ - protected invalidateVisibleWindowCache(state: CanvasTextRendererState): void { - state.visibleWindow.valid = false; - this.setStatus(state, 'loading'); - this.scheduleUpdateState(state); - } - /** * Invalidate the layout cache stored in the state. This will cause the text * to be re-rendered on the next update. @@ -1003,19 +466,7 @@ export class CanvasTextRenderer extends TextRenderer { * @param state */ private invalidateLayoutCache(state: CanvasTextRendererState): void { - state.canvasPage?.texture?.free(); - - if (state.canvasPages) { - state.canvasPages.forEach((pageInfo) => { - pageInfo.texture?.free(); - }); - } - - state.canvasPage = undefined; - state.canvasPages = undefined; state.renderInfo = undefined; - state.visibleWindow.valid = false; - this.setStatus(state, 'loading'); this.scheduleUpdateState(state); } diff --git a/src/core/text-rendering/renderers/LightningTextTextureRenderer.ts b/src/core/text-rendering/renderers/LightningTextTextureRenderer.ts index 0e0c247c..27c01d82 100644 --- a/src/core/text-rendering/renderers/LightningTextTextureRenderer.ts +++ b/src/core/text-rendering/renderers/LightningTextTextureRenderer.ts @@ -166,7 +166,6 @@ export class LightningTextTextureRenderer { | OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; private _settings: Settings; - private renderInfo: RenderInfo | undefined; constructor( canvas: OffscreenCanvas | HTMLCanvasElement, @@ -619,8 +618,6 @@ export class LightningTextTextureRenderer { if (renderInfo.cutSx || renderInfo.cutSy) { this._context.translate(renderInfo.cutSx, renderInfo.cutSy); } - - this.renderInfo = renderInfo; } wrapWord(word: string, wordWrapWidth: number, suffix: string) { diff --git a/src/core/text-rendering/renderers/TextRenderer.ts b/src/core/text-rendering/renderers/TextRenderer.ts index 28cb4fe7..cc655a8f 100644 --- a/src/core/text-rendering/renderers/TextRenderer.ts +++ b/src/core/text-rendering/renderers/TextRenderer.ts @@ -19,6 +19,7 @@ import type { Dimensions } from '../../../common/CommonTypes.js'; import type { EventEmitter } from '../../../common/EventEmitter.js'; +import type { CoreTextNode } from '../../CoreTextNode.js'; import type { Stage } from '../../Stage.js'; import type { Matrix3d } from '../../lib/Matrix3d.js'; import type { Rect, RectWithValid } from '../../lib/utils.js'; @@ -501,7 +502,7 @@ export abstract class TextRenderer< */ abstract addFontFace(fontFace: TrFontFace): void; - abstract createState(props: TrProps): StateT; + abstract createState(props: TrProps, node: CoreTextNode): StateT; /** * Destroy/Clean up the state object @@ -544,7 +545,7 @@ export abstract class TextRenderer< abstract updateState(state: StateT): void; - abstract renderQuads( + renderQuads?( state: StateT, transform: Matrix3d, clippingRect: RectWithValid, diff --git a/src/core/textures/ImageTexture.ts b/src/core/textures/ImageTexture.ts index 17826956..b521a3f9 100644 --- a/src/core/textures/ImageTexture.ts +++ b/src/core/textures/ImageTexture.ts @@ -38,7 +38,7 @@ export interface ImageTextureProps { * * @default '' */ - src?: string | ImageData | (() => ImageData); + src?: string | ImageData | (() => ImageData | null); /** * Whether to premultiply the alpha channel into the color channels of the * image. @@ -160,18 +160,6 @@ export class ImageTexture extends Texture { return `ImageTexture,${key},${resolvedProps.premultiplyAlpha ?? 'true'}`; } - override free(): void { - if (this.props.src instanceof ImageData) { - // ImageData is a non-cacheable texture, so we need to free it manually - const texture = this.txManager.getCtxTexture(this); - texture?.free(); - - this.props.src = ''; - } - - this.setState('freed'); - } - static override resolveDefaults( props: ImageTextureProps, ): Required { diff --git a/src/core/textures/Texture.ts b/src/core/textures/Texture.ts index 71cb549a..0cfe4bde 100644 --- a/src/core/textures/Texture.ts +++ b/src/core/textures/Texture.ts @@ -221,14 +221,6 @@ export abstract class Texture extends EventEmitter { return ctxTexture; } - - /** - * Free the texture - */ - free(): void { - this.setState('freed'); - } - /** * Set the state of the texture * diff --git a/src/main-api/Inspector.ts b/src/main-api/Inspector.ts index a5ed336b..edb97a34 100644 --- a/src/main-api/Inspector.ts +++ b/src/main-api/Inspector.ts @@ -240,8 +240,8 @@ export class Inspector { return div; } - createNode(node: CoreNode, properties: CoreNodeWritableProps): CoreNode { - const div = this.createDiv(node.id, properties); + createNode(node: CoreNode): CoreNode { + const div = this.createDiv(node.id, node.props); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any (div as any).node = node; @@ -252,11 +252,8 @@ export class Inspector { return this.createProxy(node, div); } - createTextNode( - node: CoreNode, - properties: CoreTextNodeWritableProps, - ): CoreTextNode { - const div = this.createDiv(node.id, properties); + createTextNode(node: CoreNode): CoreTextNode { + const div = this.createDiv(node.id, node.props); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any (div as any).node = node; diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index 775226ec..4d5c24f4 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -26,14 +26,8 @@ import type { } from '../core/CoreTextureManager.js'; import { EventEmitter } from '../common/EventEmitter.js'; import { Inspector } from './Inspector.js'; -import { santizeCustomDataMap } from './utils.js'; import { assertTruthy, isProductionEnvironment } from '../utils.js'; -import { - Stage, - type StageFpsUpdateHandler, - type StageFrameTickHandler, -} from '../core/Stage.js'; -import { getNewId } from '../utils.js'; +import { Stage } from '../core/Stage.js'; import { CoreNode, type CoreNodeWritableProps } from '../core/CoreNode.js'; import { CoreTextNode, @@ -363,14 +357,10 @@ export class RendererMain extends EventEmitter { createNode(props: Partial): CoreNode { assertTruthy(this.stage, 'Stage is not initialized'); - const resolvedProps = this.resolveNodeDefaults(props); - const node = new CoreNode(this.stage, { - ...resolvedProps, - shaderProps: null, - }); + const node = this.stage.createNode(props); if (this.inspector) { - return this.inspector.createNode(node, resolvedProps); + return this.inspector.createNode(node); } // FIXME onDestroy event? node.once('beforeDestroy' @@ -394,108 +384,15 @@ export class RendererMain extends EventEmitter { * @returns */ createTextNode(props: Partial): CoreTextNode { - const fontSize = props.fontSize ?? 16; - const data = { - ...this.resolveNodeDefaults(props), - id: getNewId(), - text: props.text ?? '', - textRendererOverride: props.textRendererOverride ?? null, - fontSize, - fontFamily: props.fontFamily ?? 'sans-serif', - fontStyle: props.fontStyle ?? 'normal', - fontWeight: props.fontWeight ?? 'normal', - fontStretch: props.fontStretch ?? 'normal', - textAlign: props.textAlign ?? 'left', - contain: props.contain ?? 'none', - scrollable: props.scrollable ?? false, - scrollY: props.scrollY ?? 0, - offsetY: props.offsetY ?? 0, - letterSpacing: props.letterSpacing ?? 0, - lineHeight: props.lineHeight, // `undefined` is a valid value - maxLines: props.maxLines ?? 0, - textBaseline: props.textBaseline ?? 'alphabetic', - verticalAlign: props.verticalAlign ?? 'middle', - overflowSuffix: props.overflowSuffix ?? '...', - debug: props.debug ?? {}, - shaderProps: null, - }; - - assertTruthy(this.stage); - const textNode = new CoreTextNode(this.stage, data); + const textNode = this.stage.createTextNode(props); if (this.inspector) { - return this.inspector.createTextNode(textNode, data); + return this.inspector.createTextNode(textNode); } return textNode; } - /** - * Resolves the default property values for a Node - * - * @remarks - * This method is used internally by the RendererMain to resolve the default - * property values for a Node. It is exposed publicly so that it can be used - * by Core Driver implementations. - * - * @param props - * @returns - */ - resolveNodeDefaults( - props: Partial, - ): CoreNodeWritableProps { - const color = props.color ?? 0xffffffff; - const colorTl = props.colorTl ?? props.colorTop ?? props.colorLeft ?? color; - const colorTr = - props.colorTr ?? props.colorTop ?? props.colorRight ?? color; - const colorBl = - props.colorBl ?? props.colorBottom ?? props.colorLeft ?? color; - const colorBr = - props.colorBr ?? props.colorBottom ?? props.colorRight ?? color; - const data = santizeCustomDataMap(props.data ?? {}); - - return { - x: props.x ?? 0, - y: props.y ?? 0, - width: props.width ?? 0, - height: props.height ?? 0, - alpha: props.alpha ?? 1, - autosize: props.autosize ?? false, - clipping: props.clipping ?? false, - color, - colorTop: props.colorTop ?? color, - colorBottom: props.colorBottom ?? color, - colorLeft: props.colorLeft ?? color, - colorRight: props.colorRight ?? color, - colorBl, - colorBr, - colorTl, - colorTr, - zIndex: props.zIndex ?? 0, - zIndexLocked: props.zIndexLocked ?? 0, - parent: props.parent ?? null, - texture: props.texture ?? null, - textureOptions: props.textureOptions ?? {}, - shader: props.shader ?? null, - shaderProps: props.shaderProps ?? null, - // Since setting the `src` will trigger a texture load, we need to set it after - // we set the texture. Otherwise, problems happen. - src: props.src ?? '', - scale: props.scale ?? null, - scaleX: props.scaleX ?? props.scale ?? 1, - scaleY: props.scaleY ?? props.scale ?? 1, - mount: props.mount ?? 0, - mountX: props.mountX ?? props.mount ?? 0, - mountY: props.mountY ?? props.mount ?? 0, - pivot: props.pivot ?? 0.5, - pivotX: props.pivotX ?? props.pivot ?? 0.5, - pivotY: props.pivotY ?? props.pivot ?? 0.5, - rotation: props.rotation ?? 0, - rtt: props.rtt ?? false, - data: data, - }; - } - /** * Destroy a node *