From 3d0341cf802f8e776454cb6eb0ea03ea93ba6c37 Mon Sep 17 00:00:00 2001 From: Alaric Baraou Date: Tue, 7 Jul 2026 11:52:24 +0900 Subject: [PATCH 1/2] fix(troika-three-text): survive WebGL context loss during SDF glyph generation The atlas.contextLost flag guards entry into glyph generation, but the flag only flips when the async webglcontextlost event dispatches, while context loss itself is synchronous (cap eviction, GPU process reset). Glyphs already in flight reach the atlas with a dead context in two unguarded places: - SDFGenerator's JS-worker fallback writes the worker result into the atlas via webglUtils.renderImageData inside a .then with no rejection handler downstream: one unhandled rejection per in-flight glyph, and the typeset callback never fires, so the Text keeps stale render info even after the context is restored. - TextBuilder's atlas-grow copy (resizeWebGLCanvasWithoutClearing) throws the same way when growth coincides with a lost context. Both writes are now dropped when the context reports lost (and rethrow otherwise); the existing webglcontextrestored handler already regenerates every known glyph, so dropped writes self-heal on restore. In the resize case the canvas dimensions are applied before the copy throws, and the sdfTexture.dispose() still runs, so the texture reallocates at the new size for the restore-time regeneration. --- packages/troika-three-text/src/SDFGenerator.js | 12 +++++++++++- packages/troika-three-text/src/TextBuilder.js | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/troika-three-text/src/SDFGenerator.js b/packages/troika-three-text/src/SDFGenerator.js index ddcf77cc..79670511 100644 --- a/packages/troika-three-text/src/SDFGenerator.js +++ b/packages/troika-three-text/src/SDFGenerator.js @@ -116,7 +116,17 @@ function generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, for (let i = 0; i < textureData.length; i++) { imageData[i * 4 + channel] = textureData[i] } - mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel)) + // The atlas context may have been lost while the worker was generating (before the async + // webglcontextlost event has flipped atlas.contextLost), in which case this GL write would + // throw from deep inside webgl-sdf-generator as an unhandled rejection, one per in-flight + // glyph, and strand the typeset callback. Dropping the write is safe: the TextBuilder's + // webglcontextrestored handler regenerates every known glyph into the restored atlas. + try { + mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel)) + } catch (err) { + const gl = typeof canvas.getContext === 'function' ? canvas.getContext('webgl') : null + if (!gl || !gl.isContextLost()) throw err + } timing += now() - start // clean up workers after a while diff --git a/packages/troika-three-text/src/TextBuilder.js b/packages/troika-three-text/src/TextBuilder.js index c4876d2d..a684861f 100644 --- a/packages/troika-three-text/src/TextBuilder.js +++ b/packages/troika-three-text/src/TextBuilder.js @@ -317,7 +317,16 @@ function getTextRenderInfo(args, callback) { if (neededHeight > currentHeight) { // Since resizing the canvas clears its render buffer, it needs special handling to copy the old contents over console.info(`Increasing SDF texture size ${currentHeight}->${neededHeight}`) - resizeWebGLCanvasWithoutClearing(sdfCanvas, textureWidth, neededHeight) + // If the context was lost, the canvas dimensions are still applied before the GL copy-over + // throws; drop the copy in that case - the webglcontextrestored handler regenerates every + // glyph into the resized atlas. The dispose below must still run either way so the texture + // reallocates at the new canvas size. + try { + resizeWebGLCanvasWithoutClearing(sdfCanvas, textureWidth, neededHeight) + } catch (err) { + const gl = sdfCanvas.getContext('webgl') + if (!gl || !gl.isContextLost()) throw err + } // As of Three r136 textures cannot be resized once they're allocated on the GPU, we must dispose to reallocate it sdfTexture.dispose() } From 7ae7a22f6bd19d33afc312e1aa985d6ea6fdc6b1 Mon Sep 17 00:00:00 2001 From: Alaric Baraou Date: Tue, 7 Jul 2026 13:11:48 +0900 Subject: [PATCH 2/2] fix(troika-three-text): also drop atlas writes when context creation was refused A null getContext probe in the catch means context creation was refused under the same GPU pressure (a canvas whose webgl creation failed stays in mode 'none', so re-attempts can also fail) - the write is equally impossible and no restore event will ever fire, so rethrowing only keeps the per-glyph rejection storm alive for that variant. The probe now also passes the generator's context attributes so a probe that happens to succeed can't stick a wrong-attribute (non-preserved drawing buffer) context onto the atlas canvas. --- packages/troika-three-text/src/SDFGenerator.js | 11 ++++++++--- packages/troika-three-text/src/TextBuilder.js | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/troika-three-text/src/SDFGenerator.js b/packages/troika-three-text/src/SDFGenerator.js index 79670511..07ab2ef9 100644 --- a/packages/troika-three-text/src/SDFGenerator.js +++ b/packages/troika-three-text/src/SDFGenerator.js @@ -120,12 +120,17 @@ function generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, // webglcontextlost event has flipped atlas.contextLost), in which case this GL write would // throw from deep inside webgl-sdf-generator as an unhandled rejection, one per in-flight // glyph, and strand the typeset callback. Dropping the write is safe: the TextBuilder's - // webglcontextrestored handler regenerates every known glyph into the restored atlas. + // webglcontextrestored handler regenerates every known glyph into the restored atlas. A null + // probe means context creation was refused under the same GPU pressure - equally benign to + // drop, and no restore event will ever fire for a context that never existed. The probe + // passes the generator's context attributes so a probe that succeeds can't stick a + // wrong-attribute context onto the atlas. Rethrow only when a healthy context proves the + // error was unrelated. try { mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel)) } catch (err) { - const gl = typeof canvas.getContext === 'function' ? canvas.getContext('webgl') : null - if (!gl || !gl.isContextLost()) throw err + const gl = typeof canvas.getContext === 'function' ? canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true, antialias: false, depth: false }) : null + if (gl && !gl.isContextLost()) throw err } timing += now() - start diff --git a/packages/troika-three-text/src/TextBuilder.js b/packages/troika-three-text/src/TextBuilder.js index a684861f..f8d0c8e2 100644 --- a/packages/troika-three-text/src/TextBuilder.js +++ b/packages/troika-three-text/src/TextBuilder.js @@ -324,8 +324,8 @@ function getTextRenderInfo(args, callback) { try { resizeWebGLCanvasWithoutClearing(sdfCanvas, textureWidth, neededHeight) } catch (err) { - const gl = sdfCanvas.getContext('webgl') - if (!gl || !gl.isContextLost()) throw err + const gl = sdfCanvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true, antialias: false, depth: false }) + if (gl && !gl.isContextLost()) throw err } // As of Three r136 textures cannot be resized once they're allocated on the GPU, we must dispose to reallocate it sdfTexture.dispose()