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
23 changes: 20 additions & 3 deletions src/rendering/webgl2/WebGl2MeshRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,11 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer<Mesh> 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,
);
Expand Down Expand Up @@ -1185,11 +1189,24 @@ export class WebGl2MeshRenderer extends AbstractWebGl2Renderer<Mesh> 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);
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 => {
Expand Down
76 changes: 43 additions & 33 deletions test/rendering/browser/webgl2-text-pixel-ratio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
Expand All @@ -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();
Expand All @@ -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) });
Expand All @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions test/rendering/browser/webgl2-untextured-mesh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 26 additions & 11 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
}),
Expand All @@ -479,13 +479,28 @@ 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: {
name: 'browser-webgl-firefox',
globals: true,
setupFiles: renderingBrowserSetupFiles,
include: ['test/rendering/browser/webgl2-*.test.ts'],
// 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,
headless: !firefoxCiHeaded,
Expand All @@ -495,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,
},
},
}),
Expand Down
Loading