Skip to content

gl.pixelStorei calls bypass three.js state cache, causing unrelated textures to upload flipped #405

Description

@sawa-zen

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:

  1. three.js uploads a texture → state.pixelStorei(FLIP_Y, true) → real GL call, cache records true
  2. Spark issues a raw gl.pixelStorei(FLIP_Y, false) → real GL state is false, three's cache still says true
  3. 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 = falseflipped

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions