diff --git a/examples/tests/text-baseline.ts b/examples/tests/text-baseline.ts index ba752705..c2b7d4dd 100644 --- a/examples/tests/text-baseline.ts +++ b/examples/tests/text-baseline.ts @@ -76,6 +76,7 @@ function generateBaselineTest( const baselineNode = renderer.createTextNode({ ...nodeProps, + parent: renderer.root, }); const dimensions = await waitForLoadedDimensions(baselineNode); diff --git a/examples/tests/text-canvas.ts b/examples/tests/text-canvas.ts new file mode 100644 index 00000000..774b4233 --- /dev/null +++ b/examples/tests/text-canvas.ts @@ -0,0 +1,139 @@ +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +const Colors = { + Black: 0x000000ff, + Red: 0xff0000ff, + Green: 0x00ff00ff, + Blue: 0x0000ffff, + Magenta: 0xff00ffff, + Gray: 0x7f7f7fff, + White: 0xffffffff, +}; + +const randomIntBetween = (from: number, to: number) => + Math.floor(Math.random() * (to - from + 1) + from); + +/** + * Tests that Single-Channel Signed Distance Field (SSDF) fonts are rendered + * correctly. + * + * Text that is thinner than the certified snapshot may indicate that the + * SSDF font atlas texture was premultiplied before being uploaded to the GPU. + * + * @param settings + * @returns + */ +export default async function test(settings: ExampleSettings) { + const { renderer, testRoot } = settings; + + // Set a smaller snapshot area + // testRoot.width = 200; + // testRoot.height = 200; + // testRoot.color = 0xffffffff; + + const nodes: any[] = []; + + const renderNode = (t: string) => { + const node = renderer.createTextNode({ + x: Math.random() * 1900, + y: Math.random() * 1080, + text: 'CANVAS ' + t, + fontFamily: 'sans-serif', + parent: testRoot, + fontSize: 80, + }); + + nodes.push(node); + + // pick random color from Colors + node.color = + Object.values(Colors)[ + randomIntBetween(0, Object.keys(Colors).length - 1) + ] || 0xff0000ff; + }; + + const spawn = (amount = 100) => { + for (let i = 0; i < amount; i++) { + renderNode(i.toString()); + } + }; + + const despawn = (amount = 100) => { + for (let i = 0; i < amount; i++) { + const node = nodes.pop(); + node.destroy(); + } + }; + + const move = () => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + node.x = randomIntBetween(0, 1600); + node.y = randomIntBetween(0, 880); + } + }; + + const newColor = () => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + node.color = + Object.values(Colors)[ + randomIntBetween(0, Object.keys(Colors).length - 1) + ] || 0x000000ff; + } + }; + + let animating = false; + const animate = () => { + animating = !animating; + + const animateNode = (node: any) => { + nodes.forEach((node) => { + node + .animate( + { + x: randomIntBetween(20, 1740), + y: randomIntBetween(20, 900), + rotation: Math.random() * Math.PI, + }, + { + duration: 3000, + easing: 'ease-out', + // loop: true, + stopMethod: 'reverse', + }, + ) + .start(); + }); + }; + + const animateRun = () => { + if (animating) { + for (let i = 0; i < nodes.length; i++) { + animateNode(nodes[i]); + } + setTimeout(animateRun, 3050); + } + }; + + animateRun(); + }; + + window.addEventListener('keydown', (event) => { + if (event.key === 'ArrowUp') { + spawn(); + } else if (event.key === 'ArrowDown') { + despawn(); + } else if (event.key === 'ArrowLeft') { + move(); + } else if (event.key === 'ArrowRight') { + move(); + } else if (event.key === '1') { + newColor(); + } else if (event.key === ' ') { + animate(); + } + }); + + spawn(); +} diff --git a/examples/tests/viewport-events-canvas.ts b/examples/tests/viewport-events-canvas.ts new file mode 100644 index 00000000..3502c566 --- /dev/null +++ b/examples/tests/viewport-events-canvas.ts @@ -0,0 +1,191 @@ +import type { IAnimationController } from '../../dist/exports/main-api.js'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; +import test from './alpha-blending.js'; + +export default async function ({ renderer, testRoot }: ExampleSettings) { + const instructionText = renderer.createTextNode({ + text: 'Press space to start animation, arrow keys to move, enter to reset', + fontSize: 30, + x: 10, + y: 960, + fontFamily: 'Ubuntu-ssdf', + parent: testRoot, + }); + + const redStatus = renderer.createTextNode({ + text: 'Red Status: ', + fontSize: 30, + x: 10, + y: 50, + fontFamily: 'Ubuntu-ssdf', + parent: testRoot, + }); + + const blueStatus = renderer.createTextNode({ + text: 'Blue Status: ', + fontSize: 30, + x: 10, + y: 10, + fontFamily: 'Ubuntu-ssdf', + parent: testRoot, + }); + + const boundaryRect = renderer.createNode({ + x: 1920 / 2 - (1920 * 0.75) / 2, + y: 1080 / 2 - (1080 * 0.75) / 2, + width: 1440, + height: 810, + color: 0x000000ff, + clipping: true, + parent: testRoot, + }); + + const redText = renderer.createTextNode({ + x: 500, + y: 305, + alpha: 1, + width: 200, + height: 200, + color: 0xff0000ff, + pivot: 0, + text: 'red', + fontSize: 80, + fontFamily: 'sans-serif', + parent: boundaryRect, + }); + + redText.on('outOfBounds', () => { + console.log('red text out of bounds'); + redStatus.text = 'Red Status: text out of bounds'; + redStatus.color = 0xff0000ff; + }); + + redText.on('inViewport', () => { + console.log('red text in view port'); + redStatus.text = 'Red Status: text in view port'; + redStatus.color = 0x00ff00ff; + }); + + redText.on('inBounds', () => { + console.log('red text inside render bounds'); + redStatus.text = 'Red Status: text in bounds'; + redStatus.color = 0xffff00ff; + }); + + const blueText = renderer.createTextNode({ + x: 1920 / 2 - 200, + y: 100, + alpha: 1, + width: 200, + height: 200, + color: 0x0000ffff, + pivot: 0, + text: 'blue', + fontSize: 80, + fontFamily: 'sans-serif', + parent: testRoot, + }); + + blueText.on('outOfBounds', () => { + console.log('blue text ouf ot bounds'); + blueStatus.text = 'Blue Status: blue text out of bounds'; + blueStatus.color = 0xff0000ff; + }); + + blueText.on('inViewport', () => { + console.log('blue text in view port'); + blueStatus.text = 'Blue Status: blue text in view port'; + blueStatus.color = 0x00ff00ff; + }); + + blueText.on('inBounds', () => { + console.log('blue text inside render bounds'); + blueStatus.text = 'Blue Status: blue text in bounds'; + blueStatus.color = 0xffff00ff; + }); + + let runAnimation = false; + const animate = async () => { + redText + .animate( + { + x: -500, + }, + { + duration: 4000, + }, + ) + .start(); + + await blueText + .animate( + { + x: -1200, + }, + { + duration: 4000, + }, + ) + .start() + .waitUntilStopped(); + + redText.x = 1920 + 400; + blueText.x = 1920 + 400; + + redText + .animate( + { + x: 520, + }, + { + duration: 4000, + }, + ) + .start(); + + await blueText + .animate( + { + x: 1920 / 2 - 200, + }, + { + duration: 4000, + }, + ) + .start() + .waitUntilStopped(); + + if (runAnimation) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + setTimeout(animate, 2000); + } + }; + + const moveModifier = 10; + window.onkeydown = (e) => { + if (e.key === ' ') { + runAnimation = !runAnimation; + + if (runAnimation) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + animate(); + } + } + + if (e.key === 'ArrowRight') { + redText.x += moveModifier; + blueText.x += moveModifier; + } + + if (e.key === 'ArrowLeft') { + redText.x -= moveModifier; + blueText.x -= moveModifier; + } + + if (e.key === 'Enter') { + runAnimation = false; + redText.x = 520; + blueText.x = 1920 / 2 - 200; + } + }; +} diff --git a/src/core/text-rendering/renderers/CanvasTextRenderer.ts b/src/core/text-rendering/renderers/CanvasTextRenderer.ts index dbe99311..a6208476 100644 --- a/src/core/text-rendering/renderers/CanvasTextRenderer.ts +++ b/src/core/text-rendering/renderers/CanvasTextRenderer.ts @@ -25,6 +25,7 @@ import { intersectRect, type Bound, intersectBound, + boundsOverlap, getNormalizedRgbaComponents, type Rect, getNormalizedAlphaComponent, @@ -88,10 +89,12 @@ export interface CanvasTextRendererState extends TextRendererState { } | undefined; canvasPages: [CanvasPageInfo, CanvasPageInfo, CanvasPageInfo] | undefined; + canvasPage: CanvasPageInfo | undefined; lightning2TextRenderer: LightningTextTextureRenderer; renderInfo: RenderInfo | undefined; renderWindow: Bound | undefined; visibleWindow: BoundWithValid; + isScrollable: boolean; } /** @@ -197,11 +200,17 @@ export class CanvasTextRenderer extends TextRenderer { }, x: (state, value) => { state.props.x = value; - this.invalidateVisibleWindowCache(state); + + if (this.isValidOnScreen(state) === true) { + this.invalidateVisibleWindowCache(state); + } }, y: (state, value) => { state.props.y = value; - this.invalidateVisibleWindowCache(state); + + if (this.isValidOnScreen(state) === true) { + this.invalidateVisibleWindowCache(state); + } }, contain: (state, value) => { state.props.contain = value; @@ -303,6 +312,7 @@ export class CanvasTextRenderer extends TextRenderer { updateScheduled: false, emitter: new EventEmitter(), canvasPages: undefined, + canvasPage: undefined, lightning2TextRenderer: new LightningTextTextureRenderer( this.canvas, this.context, @@ -331,37 +341,41 @@ 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 ( + 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 we're loaded, we don't need to do anything + return; } // If fontInfo is invalid, we need to establish it if (!state.fontInfo) { - const cssString = getFontCssString(state.props); - const trFontFace = TrFontManager.resolveFontFace( - this.fontFamilyArray, - state.props, - ) as WebTrFontFace | undefined; - assertTruthy(trFontFace, `Could not resolve font face for ${cssString}`); - state.fontInfo = { - fontFace: trFontFace, - cssString: cssString, - // TODO: For efficiency we would use this here but it's not reliable on WPE -> document.fonts.check(cssString), - loaded: false, - }; - // If font is not loaded, set up a handler to update the font info when the font loads - if (!state.fontInfo.loaded) { - globalFontSet - .load(cssString) - .then(this.onFontLoaded.bind(this, state, cssString)) - .catch(this.onFontLoadError.bind(this, state, cssString)); - return; - } + return this.loadFont(state); } // If we're waiting for a font face to load, don't render anything @@ -370,48 +384,90 @@ export class CanvasTextRenderer extends TextRenderer { } if (!state.renderInfo) { - state.lightning2TextRenderer.settings = { - text: state.props.text, - textAlign: state.props.textAlign, - fontFamily: state.props.fontFamily, - trFontFace: state.fontInfo.fontFace, - fontSize: state.props.fontSize, - fontStyle: [ - state.props.fontStretch, - state.props.fontStyle, - state.props.fontWeight, - ].join(' '), - textColor: getNormalizedRgbaComponents(state.props.color), - offsetY: state.props.offsetY, - wordWrap: state.props.contain !== 'none', - wordWrapWidth: - state.props.contain === 'none' ? undefined : state.props.width, - letterSpacing: state.props.letterSpacing, - lineHeight: state.props.lineHeight ?? null, - maxLines: state.props.maxLines, - maxHeight: - state.props.contain === 'both' - ? state.props.height - state.props.offsetY - : null, - textBaseline: state.props.textBaseline, - verticalAlign: state.props.verticalAlign, - 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.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; + } - // Invalidate renderWindow because the renderInfo changed + 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; } + state.canvasPage.texture.once('loaded', () => { + state.canvasPage?.texture?.setRenderableOwner(state, state.isRenderable); + this.setStatus(state, 'loaded'); + }); + } + + renderScrollableCanvasPages(state: CanvasTextRendererState): void { const { x, y, width, height, scrollY, contain } = state.props; const { visibleWindow } = state; let { renderWindow, canvasPages } = state; @@ -434,6 +490,15 @@ export class CanvasTextRenderer extends TextRenderer { 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, ); @@ -576,20 +641,151 @@ export class CanvasTextRenderer extends TextRenderer { this.setStatus(state, 'loaded'); } + loadFont = (state: CanvasTextRendererState): void => { + const cssString = getFontCssString(state.props); + const trFontFace = TrFontManager.resolveFontFace( + this.fontFamilyArray, + state.props, + ) as WebTrFontFace | undefined; + assertTruthy(trFontFace, `Could not resolve font face for ${cssString}`); + state.fontInfo = { + fontFace: trFontFace, + cssString: cssString, + // TODO: For efficiency we would use this here but it's not reliable on WPE -> document.fonts.check(cssString), + loaded: false, + }; + // If font is not loaded, set up a handler to update the font info when the font loads + if (!state.fontInfo.loaded) { + globalFontSet + .load(cssString) + .then(this.onFontLoaded.bind(this, state, cssString)) + .catch(this.onFontLoadError.bind(this, state, cssString)); + return; + } + }; + + calculateRenderInfo(state: CanvasTextRendererState): RenderInfo { + state.lightning2TextRenderer.settings = { + text: state.props.text, + textAlign: state.props.textAlign, + fontFamily: state.props.fontFamily, + trFontFace: state.fontInfo?.fontFace, + fontSize: state.props.fontSize, + fontStyle: [ + state.props.fontStretch, + state.props.fontStyle, + state.props.fontWeight, + ].join(' '), + textColor: getNormalizedRgbaComponents(state.props.color), + offsetY: state.props.offsetY, + wordWrap: state.props.contain !== 'none', + wordWrapWidth: + state.props.contain === 'none' ? undefined : state.props.width, + letterSpacing: state.props.letterSpacing, + lineHeight: state.props.lineHeight ?? null, + maxLines: state.props.maxLines, + maxHeight: + state.props.contain === 'both' + ? state.props.height - state.props.offsetY + : null, + textBaseline: state.props.textBaseline, + verticalAlign: state.props.verticalAlign, + 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, @@ -731,23 +927,62 @@ export class CanvasTextRenderer extends TextRenderer { // } } + 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); - // Set state object owner from any canvas page textures - state.canvasPages?.forEach((pageInfo) => { - pageInfo.texture?.setRenderableOwner(state, renderable); - }); + this.updateState(state); } override destroyState(state: CanvasTextRendererState): void { + if (state.status === 'destroyed') { + return; + } super.destroyState(state); + // Remove state object owner from any canvas page textures state.canvasPages?.forEach((pageInfo) => { - pageInfo.texture?.setRenderableOwner(state, false); + const { texture } = pageInfo; + if (texture) { + texture.setRenderableOwner(state, false); + texture.free(); + } }); + + const { texture } = state.canvasPage || {}; + if (texture) { + texture.setRenderableOwner(state, false); + texture.free(); + } + + delete state.renderInfo; + delete state.canvasPage; + delete state.canvasPages; + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); } //#endregion Overrides @@ -765,7 +1000,7 @@ export class CanvasTextRenderer extends TextRenderer { /** * Invalidate the layout cache stored in the state. This will cause the text - * to be re-layed out on the next update. + * to be re-rendered on the next update. * * @remarks * This also invalidates the visible window cache. @@ -773,8 +1008,19 @@ 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/textures/ImageTexture.ts b/src/core/textures/ImageTexture.ts index 0ecc9b5c..17826956 100644 --- a/src/core/textures/ImageTexture.ts +++ b/src/core/textures/ImageTexture.ts @@ -160,6 +160,18 @@ 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 d7fce11f..09761136 100644 --- a/src/core/textures/Texture.ts +++ b/src/core/textures/Texture.ts @@ -178,6 +178,13 @@ export abstract class Texture extends EventEmitter { return this.renderableOwners.size > 0; } + /** + * Free the texture + */ + free(): void { + this.setState('freed'); + } + /** * Set the state of the texture *