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/src/core/text-rendering/renderers/CanvasTextRenderer.ts b/src/core/text-rendering/renderers/CanvasTextRenderer.ts index d992aab0..cad52b9d 100644 --- a/src/core/text-rendering/renderers/CanvasTextRenderer.ts +++ b/src/core/text-rendering/renderers/CanvasTextRenderer.ts @@ -87,10 +87,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; } /** @@ -302,6 +304,7 @@ export class CanvasTextRenderer extends TextRenderer { updateScheduled: false, emitter: new EventEmitter(), canvasPages: undefined, + canvasPage: undefined, lightning2TextRenderer: new LightningTextTextureRenderer( this.canvas, this.context, @@ -330,6 +333,7 @@ export class CanvasTextRenderer extends TextRenderer { drawSum: 0, bufferSize: 0, }, + isScrollable: props.scrollable === true, }; } @@ -339,28 +343,14 @@ export class CanvasTextRenderer extends TextRenderer { this.setStatus(state, 'loading'); } + if (state.status === 'loaded') { + // 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 @@ -369,48 +359,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; @@ -433,6 +465,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, ); @@ -571,11 +612,146 @@ 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; + + const renderInfo = state.lightning2TextRenderer.calculateRenderInfo(); + return 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; @@ -741,8 +917,23 @@ export class CanvasTextRenderer extends TextRenderer { 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 @@ -768,8 +959,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 0cfe4bde..71cb549a 100644 --- a/src/core/textures/Texture.ts +++ b/src/core/textures/Texture.ts @@ -221,6 +221,14 @@ export abstract class Texture extends EventEmitter { return ctxTexture; } + + /** + * Free the texture + */ + free(): void { + this.setState('freed'); + } + /** * Set the state of the texture *