Summary
SparkRenderer writes gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false) directly on the renderer's
WebGL context, bypassing three.js's WebGLState cache. Because three.js caches that parameter and
skips redundant GL calls, its cached value is left stale, and subsequent texture uploads by
three.js in the same context are transferred with the wrong unpack state — unrelated textures
render vertically flipped.
In our WebXR app this showed up as text (troika-three-text SDF atlas) and user avatar icons
appearing upside down, intermittently, only in scenes that use Spark.
Reproduction
Live page (no build step, plain three.js from CDN):
Source: https://github.com/sawa-zen/three-texture-flip-repro
Four textures (2D canvas, WebGL canvas, HTMLImageElement, ImageBitmap) are re-uploaded every
frame and checked automatically for vertical flip via readPixels. ?pollute=1 emulates the
offending call with a single raw gl.pixelStorei per frame, which makes it deterministic;
?splat=1 uses the real library but is intermittent, for the reason described below.
Measured over ~1000 frames:
| condition |
2d-canvas |
webgl-canvas |
img-element |
real pixelStorei(FLIP_Y) calls |
| baseline |
ok |
ok |
ok |
5 |
pollute=1 |
flipped 1038 |
flipped 1038 |
flipped 1038 |
1 |
pollute=1&fix=1 |
ok 0 |
ok 0 |
ok 0 |
1998 |
Note the call counts: ~4000 texture transfers happen, but pixelStorei reaches the driver only
once, because the cache suppresses the rest.
Root cause
three.js WebGLState.pixelStorei is cached:
function pixelStorei( name, value ) {
if ( parameters[ name ] !== value ) { // skips the GL call when unchanged
gl.pixelStorei( name, value );
parameters[ name ] = value;
}
}
and texture uploads go through it:
state.pixelStorei( _gl.UNPACK_FLIP_Y_WEBGL, texture.flipY );
Spark writes the same parameter without going through renderer.state (3 occurrences, in the sort
ordering / LoD index uploads):
gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, null);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); // bypasses three's state cache, never restored
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 4096, rows, gl.RGBA_INTEGER, gl.UNSIGNED_INT, ...);
renderer.state.bindTexture(gl.TEXTURE_2D, null); // note: binding *does* go through renderer.state
Setting it to false is of course necessary — texSubImage2D with an integer format and
UNPACK_FLIP_Y_WEBGL = true raises INVALID_OPERATION. The only problem is that three.js is never
told about it.
Sequence that breaks:
- three.js uploads a texture →
state.pixelStorei(FLIP_Y, true) → real GL call, cache records true
- Spark issues a raw
gl.pixelStorei(FLIP_Y, false) → real GL state is false, three's cache still says true
- three.js re-uploads any texture →
state.pixelStorei(FLIP_Y, true) → the cache believes it is
already true, so no GL call happens → uploaded with FLIP_Y = false → flipped
This only manifests when Spark's upload lands between two three.js uploads of a given texture, which
is why it is intermittent, varies with frame rate and machine speed, and affects a different set of
textures on each run. Textures uploaded before Spark initialises stay correct, which is why content
often looks right for a moment after load and then breaks.
Suggested fix
Use three.js's state wrapper so the cache stays truthful:
renderer.state.pixelStorei( gl.UNPACK_FLIP_Y_WEBGL, false );
renderer.resetState() after the upload also works but is heavier.
Spark already routes texture binding through renderer.state.bindTexture(...) in the same blocks,
so this looks like an oversight rather than a deliberate choice. The same reasoning applies to the
gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, null) next to it, though that one has not caused us trouble.
I'm happy to open a PR if that helps.
Environment
@sparkjsdev/spark 2.1.0
- three.js 0.185.1 (the caching
WebGLState.pixelStorei is long-standing, so older versions are affected too)
- Chrome (desktop) and Meta Quest Browser
Summary
SparkRendererwritesgl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)directly on the renderer'sWebGL context, bypassing three.js's
WebGLStatecache. Because three.js caches that parameter andskips redundant GL calls, its cached value is left stale, and subsequent texture uploads by
three.js in the same context are transferred with the wrong unpack state — unrelated textures
render vertically flipped.
In our WebXR app this showed up as text (troika-three-text SDF atlas) and user avatar icons
appearing upside down, intermittently, only in scenes that use Spark.
Reproduction
Live page (no build step, plain three.js from CDN):
Source: https://github.com/sawa-zen/three-texture-flip-repro
Four textures (2D canvas, WebGL canvas,
HTMLImageElement,ImageBitmap) are re-uploaded everyframe and checked automatically for vertical flip via
readPixels.?pollute=1emulates theoffending call with a single raw
gl.pixelStoreiper frame, which makes it deterministic;?splat=1uses the real library but is intermittent, for the reason described below.Measured over ~1000 frames:
pixelStorei(FLIP_Y)callspollute=1pollute=1&fix=1Note the call counts: ~4000 texture transfers happen, but
pixelStoreireaches the driver onlyonce, because the cache suppresses the rest.
Root cause
three.js
WebGLState.pixelStoreiis cached:and texture uploads go through it:
Spark writes the same parameter without going through
renderer.state(3 occurrences, in the sortordering / LoD index uploads):
Setting it to
falseis of course necessary —texSubImage2Dwith an integer format andUNPACK_FLIP_Y_WEBGL = trueraisesINVALID_OPERATION. The only problem is that three.js is nevertold about it.
Sequence that breaks:
state.pixelStorei(FLIP_Y, true)→ real GL call, cache recordstruegl.pixelStorei(FLIP_Y, false)→ real GL state isfalse, three's cache still saystruestate.pixelStorei(FLIP_Y, true)→ the cache believes it isalready
true, so no GL call happens → uploaded withFLIP_Y = false→ flippedThis only manifests when Spark's upload lands between two three.js uploads of a given texture, which
is why it is intermittent, varies with frame rate and machine speed, and affects a different set of
textures on each run. Textures uploaded before Spark initialises stay correct, which is why content
often looks right for a moment after load and then breaks.
Suggested fix
Use three.js's state wrapper so the cache stays truthful:
renderer.resetState()after the upload also works but is heavier.Spark already routes texture binding through
renderer.state.bindTexture(...)in the same blocks,so this looks like an oversight rather than a deliberate choice. The same reasoning applies to the
gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, null)next to it, though that one has not caused us trouble.I'm happy to open a PR if that helps.
Environment
@sparkjsdev/spark2.1.0WebGLState.pixelStoreiis long-standing, so older versions are affected too)