From 162a56425bae034a20cbd456ef6f5a738f8b2b9c Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 24 Sep 2026 06:01:18 +0200 Subject: [PATCH 1/5] test(browser): run the Firefox WebGL lane one file at a time --- vitest.config.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 005f10d60..e89c9d200 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -441,19 +441,19 @@ export default defineConfig({ globals: true, setupFiles: renderingBrowserSetupFiles, include: ['test/rendering/browser/webgl2-*.test.ts'], + // `--use-angle=swiftshader` renders on the CPU, so several Chromium + // instances racing for the same cores contend rather than gain + // anything: 78 files at default (parallel) concurrency measured no + // faster than sequential on an otherwise-loaded machine (~72s vs + // ~80s), and under that same load one file's timing-sensitive test + // missed its 15s timeout at 4x its isolated run time - reproduced + // twice, and the file passed clean every time run alone or as part + // of the sequential suite. `fileParallelism: false` trades the + // (near-zero) parallel speedup for not flaking under load. + fileParallelism: false, browser: { enabled: true, headless: webgl2Headless, - // `--use-angle=swiftshader` renders on the CPU, so several Chromium - // instances racing for the same cores contend rather than gain - // anything: 78 files at default (parallel) concurrency measured no - // faster than sequential on an otherwise-loaded machine (~72s vs - // ~80s), and under that same load one file's timing-sensitive test - // missed its 15s timeout at 4x its isolated run time - reproduced - // twice, and the file passed clean every time run alone or as part - // of the sequential suite. `fileParallelism: false` trades the - // (near-zero) parallel speedup for not flaking under load. - fileParallelism: false, provider: playwright({ launchOptions: { channel: 'chromium', args: ['--enable-webgl', '--use-angle=swiftshader'] }, }), @@ -486,6 +486,12 @@ export default defineConfig({ globals: true, setupFiles: renderingBrowserSetupFiles, include: ['test/rendering/browser/webgl2-*.test.ts'], + // Software WebRender contends for the same cores the way SwiftShader + // does for Chromium. On a 4-core machine, three parallel files took + // as long end to end as the sequential suite (~620s vs ~610s) while + // every file ran ~3x slower, pushing 16 tests past their 15s + // timeout; sequentially only one did. + fileParallelism: false, browser: { enabled: true, headless: !firefoxCiHeaded, From 188f3629722c4e1155576250ac14ea10df43120d Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 24 Sep 2026 06:53:39 +0200 Subject: [PATCH 2/5] test(browser): run Firefox WebGL on native OpenGL instead of ANGLE --- vitest.config.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index e89c9d200..f133b51c5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -479,6 +479,14 @@ export default defineConfig({ // `gfx.webrender.software` selects the software backend - the counterpart // to Chromium's `--use-angle=swiftshader`. (`webgl.out-of-process: false` // was tried and rejected: it kills the browser connection mid-run.) + // + // `webgl.disable-angle` only matters on Windows, the one platform where + // Firefox translates WebGL to Direct3D through ANGLE; Linux and macOS + // run native OpenGL either way. Through ANGLE, every new context spends + // ~5s compiling the lighting shaders, which pushes the tests that build + // several contexts past their timeout, and its WARP rasterizer has no + // MSAA for the antialiasing cases to observe. Native OpenGL is also what + // the Linux runner uses, so a local run compares like with like. { ...browserBase, test: { @@ -486,11 +494,12 @@ export default defineConfig({ globals: true, setupFiles: renderingBrowserSetupFiles, include: ['test/rendering/browser/webgl2-*.test.ts'], - // Software WebRender contends for the same cores the way SwiftShader - // does for Chromium. On a 4-core machine, three parallel files took - // as long end to end as the sequential suite (~620s vs ~610s) while - // every file ran ~3x slower, pushing 16 tests past their 15s - // timeout; sequentially only one did. + // Firefox serves WebGL for every file from one GPU process, so + // parallel files queue behind each other's GPU work there: a worker's + // first synchronous WebGL query stalled for ~10s behind its + // neighbours. The whole lane took twice as long in parallel as it + // does sequentially (~330s vs ~160s) and ran 32 tests past their + // 15s timeout; sequentially none. fileParallelism: false, browser: { enabled: true, @@ -501,7 +510,7 @@ export default defineConfig({ 'webgl.force-enabled': true, 'webgl.disabled': false, 'gfx.webrender.software': true, - 'webgl.angle.force-warp': true, + 'webgl.disable-angle': true, }, }, }), From 28441d0738a15040447f400af94dc555a2c14a7f Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 24 Sep 2026 06:53:42 +0200 Subject: [PATCH 3/5] test(text): judge SDF magnification over the whole edge, not one scanline --- .../browser/webgl2-text-pixel-ratio.test.ts | 76 +++++++++++-------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/test/rendering/browser/webgl2-text-pixel-ratio.test.ts b/test/rendering/browser/webgl2-text-pixel-ratio.test.ts index 7c43877c8..b443f782b 100644 --- a/test/rendering/browser/webgl2-text-pixel-ratio.test.ts +++ b/test/rendering/browser/webgl2-text-pixel-ratio.test.ts @@ -117,16 +117,19 @@ describe('the SDF atlas is sampled as a continuous field', () => { const scanline = (frame: Uint8Array, y: number): number[] => Array.from({ length: size }, (_, x) => frame[(y * size + x) * 4]!); /** - * The rising side of one glyph edge, as a spatial profile. + * The rising side of the glyph's left edge on every scanline that crosses it, + * as spatial profiles. * - * Taken across a scanline rather than over the whole frame, because the + * Taken across scanlines rather than over the whole frame, because the * property under test is about how coverage behaves ALONG an edge, and a set * of frame-wide values cannot express that: sorting them yields a monotone - * list whatever the frame looked like. The segment runs from the last fully + * list whatever the frame looked like. Each segment runs from the last fully * transparent pixel before the edge to the first fully covered one after it, * so it holds exactly one transition and nothing of the glyph's other three. */ - const edgeIntensityProfile = (frame: Uint8Array): number[] => { + const edgeIntensityProfiles = (frame: Uint8Array): number[][] => { + const profiles: number[][] = []; + for (let y = 0; y < size; y++) { const row = scanline(frame, y); const covered = row.findIndex(value => value > 247); @@ -137,14 +140,15 @@ describe('the SDF atlas is sampled as a continuous field', () => { while (start > 0 && row[start - 1]! >= 8) start--; - return row.slice(Math.max(0, start - 1), covered + 1); + profiles.push(row.slice(Math.max(0, start - 1), covered + 1)); } - return []; + return profiles; }; - /** The distinct values in a profile, ascending. */ - const distinctIntensityLevels = (profile: number[]): number[] => [...new Set(profile)].sort((a, b) => a - b); + /** The distinct partial-coverage values across all profiles, ascending. */ + const intermediateLevels = (profiles: number[][]): number[] => + [...new Set(profiles.flat().filter(value => value >= 8 && value <= 247))].sort((a, b) => a - b); /** * Whether coverage only ever increases along the profile. @@ -156,7 +160,11 @@ describe('the SDF atlas is sampled as a continuous field', () => { */ const isMonotoneEdgeProfile = (profile: number[]): boolean => profile.every((value, index) => index === 0 || value >= profile[index - 1]!); - const describeProfile = (profile: number[]): string => `profile (${profile.length}): [${profile.join(', ')}]`; + const describeProfiles = (profiles: number[][], levels: number[]): string => + `${profiles.length} rows, levels [${levels.join(', ')}], first rows ${profiles + .slice(0, 4) + .map(profile => `[${profile.join(', ')}]`) + .join(' ')}`; test('pins the page sampler to linear filtering', () => { const pool = new GlyphAtlasPool(); @@ -173,12 +181,18 @@ describe('the SDF atlas is sampled as a continuous field', () => { // produces - a node scaled up at runtime, or a `pixelRatio` below the surface // it is drawn on. Under NEAREST this frame is a staircase. // - // The number of distinct coverage levels is not a rendering contract. - // Software and hardware adapters may quantize linear texture filtering at - // different precision. This test verifies the invariant we actually require: - // magnified SDF glyph edges form a full-range monotone coverage ramp with - // multiple intermediate levels. NEAREST sampling collapses that ramp and must - // fail this oracle. + // The antialiasing band of an SDF edge is about one screen pixel wide at any + // magnification, so a single scanline crossing the edge steeply holds only one + // or two partial values; how many depends on the font's outline where the + // scanline happens to cut it. What tells LINEAR from NEAREST is the edge as a + // whole: filtered, the crossing moves continuously from row to row, so the + // rows along the curve land on many different partial values. Under NEAREST + // the distance is + // constant per texel, so rows repeat in blocks of the magnification and + // nearly every pixel is fully in or fully out. + // + // The number of distinct levels is not a rendering contract: software and + // hardware adapters may quantize linear filtering at different precision. test('keeps a magnified glyph smooth rather than blocky', async () => { const backend = await createWebGl2TestBackend(size, 1); const node = new Text('O', { fontSize: 24, pixelRatio: 1, fillColor: new Color(255, 255, 255) }); @@ -187,35 +201,31 @@ describe('the SDF atlas is sampled as a continuous field', () => { node.setScale(4); renderWebGl2Once(backend, node, Color.black); - const profile = edgeIntensityProfile(readWebGl2Frame(backend, size)); - const levels = distinctIntensityLevels(profile); - const described = describeProfile(profile); + const profiles = edgeIntensityProfiles(readWebGl2Frame(backend, size)); + const levels = intermediateLevels(profiles); + const described = describeProfiles(profiles, levels); node.destroy(); backend.destroy(); // Asserted as one object so a failure names which part of the invariant - // broke and prints the profile that broke it; `expect`'s message argument - // is not available here. The profile sits on both sides of the comparison + // broke and prints the profiles that broke it; `expect`'s message argument + // is not available here. The evidence sits on both sides of the comparison // for that reason - it is evidence, not an assertion. expect({ - // The ramp spans the full coverage range. - reachesTransparent: Math.min(...profile) < 8, - reachesOpaque: Math.max(...profile) > 247, - // NEAREST, or any collapsed filtering, produces essentially the end - // values alone. How many steps sit between them is the adapter's - // business, not a contract. + tracesAnEdge: profiles.length > 0, + // NEAREST, or any collapsed filtering, leaves the edge almost entirely at + // the end values. hasIntermediateLevels: levels.length > 4, // Real partial coverage on both sides, not one lonely midpoint. - partialCoverageLow: levels.some(value => value > 8 && value < 96), - partialCoverageHigh: levels.some(value => value > 160 && value < 247), - // Monotone along the edge IN SPACE. Asserted over sorted unique values it - // would hold for any frame whatsoever. - monotone: isMonotoneEdgeProfile(profile), + partialCoverageLow: levels.some(value => value < 96), + partialCoverageHigh: levels.some(value => value > 160), + // Monotone along the edge IN SPACE, row by row. Asserted over sorted + // unique values it would hold for any frame whatsoever. + monotone: profiles.every(isMonotoneEdgeProfile), evidence: described, }).toEqual({ - reachesTransparent: true, - reachesOpaque: true, + tracesAnEdge: true, hasIntermediateLevels: true, partialCoverageLow: true, partialCoverageHigh: true, From 82571db287bf7059eb1b1c031cc9adcede173936 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sat, 26 Sep 2026 06:34:40 +0200 Subject: [PATCH 4/5] fix(webgl2): orphan the dynamic mesh vertex stream on every upload --- src/rendering/webgl2/WebGl2MeshRenderer.ts | 14 +++++- .../browser/webgl2-untextured-mesh.test.ts | 45 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/rendering/webgl2/WebGl2MeshRenderer.ts b/src/rendering/webgl2/WebGl2MeshRenderer.ts index 40d05c414..ca927093b 100644 --- a/src/rendering/webgl2/WebGl2MeshRenderer.ts +++ b/src/rendering/webgl2/WebGl2MeshRenderer.ts @@ -363,7 +363,11 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements this._createBufferRuntime(gl, buffers), backend.accountant, ); - const dynamicVertexBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._vertexData, BufferUsage.DynamicDraw).connect( + // Only the vertex stream is orphaned per upload (see _createBufferRuntime): + // that alone keeps every per-draw stream of a flush intact on Firefox's + // native-GL path, while orphaning the index and instance streams as well + // measurably slows that path down for no further gain. + const dynamicVertexBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._vertexData, BufferUsage.StreamDraw).connect( this._createBufferRuntime(gl, buffers), backend.accountant, ); @@ -1185,7 +1189,13 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements const state = buffers.get(buffer); gl.bindBuffer(buffer.type, handle); - if (state && state.dataByteLength >= buffer.uploadByteLength) { + // A stream buffer is fully rewritten before every draw. Re-specifying the + // store (orphaning) instead of overwriting it in place lets the draw that + // still reads the previous contents keep them: an in-place bufferSubData + // needs an implicit sync with pending draws, which Firefox's native-GL + // WebGL path does not honor, so earlier draws of the same flush render + // the later draw's geometry. + if (buffer.usage !== BufferUsage.StreamDraw && state && state.dataByteLength >= buffer.uploadByteLength) { uploadBufferRange(gl, buffer, offset); } else { uploadBufferStore(gl, buffer); diff --git a/test/rendering/browser/webgl2-untextured-mesh.test.ts b/test/rendering/browser/webgl2-untextured-mesh.test.ts index a639cbd98..23e034bff 100644 --- a/test/rendering/browser/webgl2-untextured-mesh.test.ts +++ b/test/rendering/browser/webgl2-untextured-mesh.test.ts @@ -213,6 +213,51 @@ describe('WebGL2 untextured mesh rendering', () => { } }); + test('consecutive meshes in one flush each keep their own vertices, indices and tint', async () => { + // Every dynamic mesh draw rewrites the same streaming buffers. Each mesh + // here differs from its neighbours in all three per-draw streams, so a draw + // that read a later mesh's vertices, indices or instance slot would land in + // the wrong cell, pick the offscreen decoy quad, or take the wrong tint. + const size = 64; + const backend = await createBackend(size); + const cells: Array<{ x: number; y: number; tint: RgbaTuple }> = [ + { x: 4, y: 4, tint: [255, 0, 0, 255] }, + { x: 36, y: 4, tint: [0, 255, 0, 255] }, + { x: 4, y: 36, tint: [0, 0, 255, 255] }, + { x: 36, y: 36, tint: [255, 255, 0, 255] }, + ]; + const meshes = cells.map(({ x, y, tint }, i) => { + const quad = [x, y, x + 24, y, x + 24, y + 24, x, y + 24]; + const decoy = [-40, -40, -20, -40, -20, -20, -40, -20]; + const decoyFirst = i % 2 === 1; + const mesh = new Mesh({ + vertices: new Float32Array(decoyFirst ? [...decoy, ...quad] : [...quad, ...decoy]), + indices: new Uint16Array(decoyFirst ? [4, 5, 6, 4, 6, 7] : [0, 1, 2, 0, 2, 3]), + }); + + mesh.tint = new Color(tint[0], tint[1], tint[2], 1); + + return mesh; + }); + + try { + backend.clear(Color.black); + for (const mesh of meshes) { + mesh.render(backend); + } + backend.flush(); + + for (const { x, y, tint } of cells) { + expectPixelNear(readPixel(backend, x + 12, y + 12), tint); + } + } finally { + for (const mesh of meshes) { + mesh.destroy(); + } + backend.destroy(); + } + }); + test('the DebugOverlay boundingBoxes layer draws boxes around scene-graph nodes', async () => { // Playground finding: with boundingBoxes visible, no boxes appeared even // though the layer walks scene.root and the node is attached to it. From 5e7456ac0702183ea2f8b23cd547c164ac715c87 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sat, 26 Sep 2026 06:47:12 +0200 Subject: [PATCH 5/5] perf(webgl2): reuse the buffer state entry on stream re-specification --- src/rendering/webgl2/WebGl2MeshRenderer.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/rendering/webgl2/WebGl2MeshRenderer.ts b/src/rendering/webgl2/WebGl2MeshRenderer.ts index ca927093b..e3dad75d7 100644 --- a/src/rendering/webgl2/WebGl2MeshRenderer.ts +++ b/src/rendering/webgl2/WebGl2MeshRenderer.ts @@ -1199,7 +1199,14 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer implements uploadBufferRange(gl, buffer, offset); } else { uploadBufferStore(gl, buffer); - buffers.set(buffer, { handle, dataByteLength: buffer.uploadByteLength }); + + // Stream buffers take this branch on every draw, so reuse the entry + // rather than allocating one per upload. + if (state) { + state.dataByteLength = buffer.uploadByteLength; + } else { + buffers.set(buffer, { handle, dataByteLength: buffer.uploadByteLength }); + } } }, destroy: (buffer: WebGl2RenderBuffer): void => {