diff --git a/examples/tests/text-baseline.ts b/examples/tests/text-baseline.ts index 6bb0aaa9..366a1c9d 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..d3cee531 --- /dev/null +++ b/examples/tests/text-canvas.ts @@ -0,0 +1,156 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2024 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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', + }, + ) + .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..e9b6858b --- /dev/null +++ b/examples/tests/viewport-events-canvas.ts @@ -0,0 +1,208 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2024 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ExampleSettings } from '../common/ExampleSettings.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/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 d992aab0..b6c15979 100644 --- a/src/core/text-rendering/renderers/CanvasTextRenderer.ts +++ b/src/core/text-rendering/renderers/CanvasTextRenderer.ts @@ -18,18 +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, 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'; @@ -61,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( @@ -76,9 +64,8 @@ function getFontCssString(props: TrProps): string { } export interface CanvasTextRendererState extends TextRendererState { + node: CoreTextNode; props: TrProps; - - fontFaceLoadedHandler: (() => void) | undefined; fontInfo: | { fontFace: WebTrFontFace; @@ -86,28 +73,16 @@ export interface CanvasTextRendererState extends TextRendererState { loaded: boolean; } | undefined; - canvasPages: [CanvasPageInfo, CanvasPageInfo, CanvasPageInfo] | undefined; + textureNode: CoreNode | undefined; lightning2TextRenderer: LightningTextTextureRenderer; renderInfo: RenderInfo | undefined; - renderWindow: Bound | undefined; - visibleWindow: BoundWithValid; } -/** - * 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. @@ -123,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({ @@ -196,11 +167,9 @@ export class CanvasTextRenderer extends TextRenderer { }, x: (state, value) => { state.props.x = value; - this.invalidateVisibleWindowCache(state); }, y: (state, value) => { state.props.y = value; - this.invalidateVisibleWindowCache(state); }, contain: (state, value) => { state.props.contain = value; @@ -251,9 +220,6 @@ export class CanvasTextRenderer extends TextRenderer { state.props.overflowSuffix = value; this.invalidateLayoutCache(state); }, - // debug: (state, value) => { - // state.props.debug = value; - // }, }; } @@ -295,31 +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, + 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, @@ -337,30 +298,20 @@ export class CanvasTextRenderer extends TextRenderer { // 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 (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,398 +320,145 @@ 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); state.textH = state.renderInfo.lineHeight * state.renderInfo.lines.length; state.textW = state.renderInfo.width; - - // Invalidate renderWindow because the renderInfo changed - state.renderWindow = undefined; + this.renderSingleCanvasPage(state); } - 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; - } + // handle scrollable text !!! + // if (state.isScrollable === true) { + // return this.renderScrollableCanvasPages(state); + // } - const visibleWindowHeight = visibleWindow.y2 - visibleWindow.y1; + // handle single page text + } - const maxLinesPerCanvasPage = Math.ceil( - visibleWindowHeight / state.renderInfo.lineHeight, - ); + renderSingleCanvasPage(state: CanvasTextRendererState): void { + assertTruthy(state.renderInfo); + const node = state.node; - 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 + 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'); } - override renderQuads( - 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, + 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, }; - - 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, - }); + // 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; } - - // 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), - // ); - // } + }; + + 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, + }; + state.renderInfo = state.lightning2TextRenderer.calculateRenderInfo(); + return state.renderInfo; } - 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); - }); + 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 { + 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); - }); - } - //#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); + if (state.textureNode) { + state.textureNode.destroy(); + delete state.textureNode; + } + delete state.renderInfo; } + //#endregion Overrides /** * 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. @@ -769,7 +467,6 @@ export class CanvasTextRenderer extends TextRenderer { */ private invalidateLayoutCache(state: CanvasTextRendererState): void { 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 0ecc9b5c..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. 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 * diff --git a/visual-regression/certified-snapshots/chromium-ci/text-canvas-font-no-metrics-1.png b/visual-regression/certified-snapshots/chromium-ci/text-canvas-font-no-metrics-1.png index 76b457df..7e1e1121 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-canvas-font-no-metrics-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-canvas-font-no-metrics-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-canvas-font-no-metrics-2.png b/visual-regression/certified-snapshots/chromium-ci/text-canvas-font-no-metrics-2.png index 9e88df3b..03398314 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-canvas-font-no-metrics-2.png and b/visual-regression/certified-snapshots/chromium-ci/text-canvas-font-no-metrics-2.png differ