Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions examples/tests/viewport-events.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,48 @@
import type { RendererMain } from '@lightningjs/renderer';
import type { ExampleSettings } from '../common/ExampleSettings.js';

/**
* Wait until the renderer has drained all pending scene updates and fires
* `'idle'`. Each `page(i)` mutation in this test kicks off a cascade —
* position change → bounds events → status text mutations → text re-layout
* → next render — that takes multiple frames to settle. Snapshotting before
* the cascade finishes captures intermediate state and produces flaky diffs.
*
* A short timeout fallback prevents the test from hanging if a mutation
* happens to be a no-op (e.g. setting clipping to its current value).
*/
const waitForIdle = (renderer: RendererMain, timeoutMs = 500): Promise<void> =>
new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled === true) {
return;
}
settled = true;
clearTimeout(timer);
renderer.off('idle', onIdle);
resolve();
};
const onIdle = () => finish();
const timer = setTimeout(finish, timeoutMs);
renderer.on('idle', onIdle);
});

export async function automation(settings: ExampleSettings) {
const TESTPAGES = 17;
const testPageArray: number[] = [];
for (let i = 1; i < TESTPAGES; i++) {
testPageArray.push(i);
}

const page = await test(settings);
// i = 0

// i = 0 - let the initial scene settle before the first capture so font
// load, atlas upload, and the first bounds-event cascade are all complete.
await waitForIdle(settings.renderer);
await settings.snapshot();

let testIdx = 1;
const testPage = async () => {
console.log('Testing ', testIdx);
page(testIdx);
await waitForIdle(settings.renderer);
await settings.snapshot();

if (testIdx >= TESTPAGES) {
Expand Down
7 changes: 5 additions & 2 deletions src/core/text-rendering/CanvasTextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
const font = `${fontStyle} ${fontSize}px Unknown, ${fontFamily}`;
// Get font metrics and calculate line height
measureContext.font = font;
measureContext.textBaseline = 'hanging';
// The layout engine emits line[4] as the alphabetic baseline Y, matching
// CSS line box layout. Both contexts must use 'alphabetic' so fillText draws
// the baseline exactly at line[4].
measureContext.textBaseline = 'alphabetic';

const metrics = CanvasFontHandler.getFontMetrics(fontFamily, fontSize);

Expand Down Expand Up @@ -150,7 +153,7 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
const a = color & 0xff;
context.fillStyle = `rgba(${r},${g},${b},${a / 255})`;
context.font = font;
context.textBaseline = 'hanging';
context.textBaseline = 'alphabetic';

// Performance optimization for large fonts
if (fontSize >= 128) {
Expand Down
28 changes: 18 additions & 10 deletions src/core/text-rendering/SdfTextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const font: FontHandler = SdfFontHandler;
const layoutCache = new Map<string, TextLayout>();

const getLayoutCacheKey = (props: CoreTextNodeProps): string =>
`${props.fontFamily}-${props.fontSize}-${props.letterSpacing}-${props.lineHeight}-${props.maxHeight}-${props.maxWidth}-${props.maxLines}-${props.textAlign}-${props.wordBreak}-${props.overflowSuffix}-${props.text}`;
`${props.fontFamily}-${props.fontStyle}-${props.fontSize}-${props.letterSpacing}-${props.lineHeight}-${props.maxHeight}-${props.maxWidth}-${props.maxLines}-${props.textAlign}-${props.wordBreak}-${props.overflowSuffix}-${props.text}`;

/**
* SDF text renderer using MSDF/SDF fonts with WebGL
Expand Down Expand Up @@ -219,9 +219,14 @@ const generateTextLayout = (
const fontData = fontCache.data;
const commonFontData = fontData.common;
const designFontSize = fontData.info.size;
const designLineHeight = commonFontData.lineHeight;
const lineHeight =
props.lineHeight || (designLineHeight * fontSize) / designFontSize;
// common.base = distance from BMFont line-box top to the alphabetic baseline,
// in atlas design units. Used to convert per-glyph yoffset (BMFont top -> glyph top)
// into baseline-relative placement.
const atlasBase = commonFontData.base;
// When the user does not specify lineHeight, fall back to the engine's
// 'normal' line height (ascender + lineGap - descender) computed inside
// mapTextLayout via the supplied metrics. Passing 0 below triggers that path.
const lineHeight = props.lineHeight;

const atlasWidth = commonFontData.scaleW;
const atlasHeight = commonFontData.scaleH;
Expand Down Expand Up @@ -265,15 +270,16 @@ const generateTextLayout = (

const glyphs: GlyphLayout[] = [];
let currentX = 0;
let currentY = 0;
let baselineY = 0;
for (let i = 0; i < lineAmount; i++) {
const line = lines[i] as TextLineStruct;
const textLine = line[0];
const textLineLength = textLine.length;
let prevGlyphId = 0;
currentX = line[3];
//convert Y coord to vertex value
currentY = line[4] / fontScale;
// line[4] is the alphabetic baseline Y in screen px. Convert to atlas
// design units (where glyph.yoffset and atlasBase live).
baselineY = line[4] / fontScale;

for (let j = 0; j < textLineLength; j++) {
const codepoint = textLine.codePointAt(j) as number;
Expand Down Expand Up @@ -316,10 +322,13 @@ const generateTextLayout = (
// Apply pair kerning before placing this glyph.
currentX += kerning;

// Calculate glyph position and atlas coordinates (in design units)
// Glyph position in atlas design units. yoffset is measured from the
// BMFont line-box top; subtracting atlasBase re-anchors it relative to
// the alphabetic baseline so fonts with different BMFont 'base' values
// share the same on-screen baseline.
const glyphLayout: GlyphLayout = {
x: currentX + glyph.xoffset,
y: currentY + glyph.yoffset,
y: baselineY + glyph.yoffset - atlasBase,
width: glyph.width,
height: glyph.height,
atlasX: glyph.x * invAtlasWidth,
Expand All @@ -334,7 +343,6 @@ const generateTextLayout = (
currentX += glyph.xadvance + letterSpacing;
prevGlyphId = glyph.id;
}
currentY += lineHeightPx;
}

// Convert final dimensions to pixel space for the layout
Expand Down
38 changes: 29 additions & 9 deletions src/core/text-rendering/TextLayoutEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,16 @@ export const mapTextLayout = (
): TextLayoutStruct => {
const ascPx = metrics.ascender;
const descPx = metrics.descender;
const lineGapPx = metrics.lineGap;

const bareLineHeight = ascPx - descPx;
// Default line height matches CSS 'normal': ascender + lineGap - descender.
// descPx is negative for descents below the baseline.
const bareLineHeight = ascPx - descPx + lineGapPx;
const lineHeightPx =
lineHeight <= 3 ? lineHeight * bareLineHeight : lineHeight;
const lineHeightDelta = lineHeightPx - bareLineHeight;
const halfDelta = lineHeightDelta * 0.5;
// Half-leading: extra space split evenly above the ascent and below the descent.
// Negative when the user requests a line height smaller than the font's own extent.
const halfLeading = (lineHeightPx - bareLineHeight) * 0.5;

let effectiveMaxLines = maxLines;

Expand Down Expand Up @@ -107,12 +111,27 @@ export const mapTextLayout = (
effectiveMaxLines,
);

let effectiveLineAmount = lines.length;
const effectiveLineAmount = lines.length;
let effectiveMaxWidth = 0;

// CSS letter-spacing applies between characters, not after the trailing one.
// measureText accumulates one advance + letterSpacing per glyph, so each
// line carries one extra trailing letterSpacing. Trim it once per line for
// alignment / reported width purposes. Wrap decisions inside the loops above
// intentionally still use the un-trimmed width (CSS engines also consider
// trailing letter-spacing during break decisions; only the rendered line
// width is trimmed).
if (letterSpacing !== 0) {
for (let i = 0; i < effectiveLineAmount; i++) {
const line = lines[i]!;
if (line[0].length > 0) {
line[1] -= letterSpacing;
}
}
}

if (effectiveLineAmount > 0) {
effectiveMaxWidth = lines[0]![1];
//check for longest line
if (effectiveLineAmount > 1) {
for (let i = 1; i < effectiveLineAmount; i++) {
effectiveMaxWidth = Math.max(effectiveMaxWidth, lines[i]![1]);
Expand All @@ -134,12 +153,13 @@ export const mapTextLayout = (

const effectiveMaxHeight = effectiveLineAmount * lineHeightPx;

let firstBaseLine = halfDelta;

const startY = firstBaseLine;
// line[4] stores the alphabetic baseline Y of each line in screen px.
// The first baseline sits half-leading + ascender below the line box top,
// matching CSS line box layout.
const firstBaselineY = halfLeading + ascPx;
for (let i = 0; i < effectiveLineAmount; i++) {
const line = lines[i] as TextLineStruct;
line[4] = startY + lineHeightPx * i;
line[4] = firstBaselineY + lineHeightPx * i;
}

return [
Expand Down
Binary file modified visual-regression/certified-snapshots/chromium-ci/alignment-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/autosize-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/autosize-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/clipping-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/clipping-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/clipping-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/destroy-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/resize-mode-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/resize-mode-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/resize-mode-4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/resize-mode-5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified visual-regression/certified-snapshots/chromium-ci/scaling-1.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/scaling-2.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/scaling-3.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-align-4.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-align-5.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-align-6.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-alpha-1.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-jump-1.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-mixed-1.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-zwsp-1.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-zwsp-2.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/text-zwsp-3.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/texture-svg-1.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/textures-1.png
Binary file modified visual-regression/certified-snapshots/chromium-ci/zIndex-1.png
24 changes: 22 additions & 2 deletions visual-regression/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import { failedResultsDir } from './index.js';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';

/**
* Fraction of total pixels in a snapshot that are allowed to differ before a
* comparison is reported as a failure. Tuned to absorb sub-pixel SDF glyph
* edge jitter (typically tens to a few hundred pixels per frame across
* Docker rebuilds) while still catching any meaningful UI change.
*/
const MAX_DIFF_RATIO = 0.0005; // 0.05% of frame pixels

/**
* Keep in sync with `examples/common/ExampleSettings.ts`.
* `width`/`height` (not `w`/`h`) so the object can be handed directly to
Expand Down Expand Up @@ -200,11 +208,23 @@ export function compareBuffers(
{ threshold: 0.1 },
);

const doesMatch = count === 0;
// Even with AA detection enabled, pixelmatch will misclassify a small
// number of glyph-edge pixels as "real" diffs because SDF text positions
// are subject to float-precision drift across builds (the Chromium
// anti-aliasing flags target Canvas text and do not affect our WebGL
// SDF path). Allow a tiny absolute count of differing pixels so this
// noise doesn't fail tests while still catching anything larger - for
// a 1280x720 frame this caps "acceptable" drift at ~460 pixels, well
// below a missing word or shifted rectangle but above per-glyph AA
// jitter.
const maxAllowedDiff = Math.floor(width * height * MAX_DIFF_RATIO);
const doesMatch = count <= maxAllowedDiff;

return {
doesMatch,
diffImageBuffer: doesMatch ? undefined : diff,
reason: doesMatch ? undefined : `${count} pixels differ`,
reason: doesMatch
? undefined
: `${count} pixels differ (tolerance ${maxAllowedDiff})`,
};
}
Loading