Skip to content
All lab notes

Lab note · from BEAROUT

THREE.Sprite ignores negative scale: how to flip a sprite in three.js

By Mark Santos · · 5 min read

Setting sprite.scale.x = -1 does not mirror a THREE.Sprite. The sprite vertex shader builds its quad from the length of the model matrix's first two columns, and a length is never negative, so the sign is lost. To mirror, either flip the texture coordinates (a negative map.repeat.x) or draw the character on a camera-facing PlaneGeometry mesh, where negative scale works as it does on any mesh. BEAROUT uses the plane.

Symptoms

BEAROUT has an optional mode that replaces the player's box-built model with an AI-generated character, animated from one texture atlas per weapon: a 4 × 6 grid of walk and attack frames seen from the front, the back and the side. All of the art faces screen-left, and the plan was to mirror it for rightward movement by making scale.x negative.

Instead, the character faced screen-left in every direction, so walking right looked like walking backwards. In JavaScript, sprite.scale.x read negative exactly as intended; only the rendered pixels showed the problem.

Why it happens

Here is the relevant part of the sprite vertex shader in three.js r170 (sprite.glsl.js), trimmed:

vec4 mvPosition = modelViewMatrix[ 3 ];

vec2 scale = vec2( length( modelMatrix[ 0 ].xyz ), length( modelMatrix[ 1 ].xyz ) );

vec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;

A sprite takes only the translation column of its model-view matrix. It then builds a quad parallel to the view plane and sizes it by the lengths of the first two model-matrix columns. A length has no sign, so scale.x = -1 gives the same quad as scale.x = 1. For the same reason, rotating a sprite or its parent doesn't turn it. The only in-plane rotation is SpriteMaterial.rotation, and that is what the sprite version of BEAROUT used for its death tip-over.

What about atlas frames that look frozen?

The commit that replaced the sprite gave a second reason: the hero seemed stuck on one frame while JavaScript cycled map.offset, and the commit message says the sprite's UV transform "was uploaded to the GPU once and never refreshed." Re-tested, that doesn't hold for r170.

  • In WebGLMaterials.js, refreshUniformsSprites() calls refreshTransformUniform(material.map, uniforms.mapTransform). That runs map.updateMatrix() when matrixAutoUpdate is true and copies the result into the uniform. Mesh materials get the same treatment.
  • WebGLRenderer.render() resets its current-material cache at the end of every call (_currentMaterialId = - 1), so material uniforms are refreshed again on the next frame.
  • We ran the sprite-era build of BEAROUT (commit cc386a6) in headless Chromium 153 and WebKit 26.6. After each draw we read mapTransform back from the GL program. It matched map.offset on every sampled draw: 59 in Chromium, 44 in WebKit, zero mismatches.

So changing map.offset does animate a THREE.Sprite in r170. If your frames look frozen, check three things before blaming the sprite:

  • texture.matrixAutoUpdate might be false. Then offset changes never reach the UV matrix.
  • The render loop might not be running. requestAnimationFrame() calls "are paused in most browsers when running in background tabs".
  • The animation logic might be choosing the idle frame. BEAROUT's walk cycle followed actual movement, so a hero pushing against a fence counted as standing still; the same fix commit switched it to joystick input.

The fix

The broken version, simplified from the sprite-era code:

const hero = new THREE.Sprite(
  new THREE.SpriteMaterial({ map: atlas, transparent: true, alphaTest: 0.35 })
);
hero.scale.set(facing * h * aspect, h, 1); // facing = -1 never flips

The version BEAROUT ships (src/player.js), trimmed:

const geo = new THREE.PlaneGeometry(1, 1);
geo.translate(0, 0.5, 0); // pivot at the feet
const hero = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
  transparent: true, alphaTest: 0.35, side: THREE.DoubleSide,
}));
hero.material.map = atlas;
playerRoot.add(hero);

const q = new THREE.Quaternion();

// every frame
atlas.offset.set(col / COLS, (ROWS - 1 - row) / ROWS);
hero.scale.set(facing * h * aspect, h, 1); // facing = -1 now mirrors
q.copy(playerRoot.quaternion).invert();     // cancel the parent's rotation
hero.quaternion.copy(q).multiply(camera.quaternion); // then face the camera

This works because a mesh uses its full model matrix: negative scale moves the vertices to the other side. Three.js also notices a mesh whose world matrix has a negative determinant and flips the front face to match (WebGLRenderer.js, the frontFaceCW check), so the mirrored plane renders even with the default FrontSide. We checked that too: DoubleSide in BEAROUT is not what makes the mirror appear.

alphaTest: 0.35 makes the chroma-keyed atlas a hard cutout: the shader discards any fragment with diffuseColor.a < alphaTest, which removed a magenta fringe along the edges. Because the plane is an ordinary mesh, the death animation is just one more quaternion: a rotation about Z, multiplied on after the camera rotation.

If you want to keep the Sprite, flip the texture instead of the geometry. Sample the frame right to left with map.repeat.x = -1 / COLS and map.offset.x = (col + 1) / COLS. BEAROUT's history has that version (commit cc386a6), and it rendered mirrored in both engines we tested.

How to check you've fixed it

Check what was drawn, not what the variables say. Two ways that don't depend on a screenshot:

Read pixels from a test atlas. Make a small atlas where each frame has a different colour on its left and right halves. Create the renderer with preserveDrawingBuffer: true, render, and sample one pixel from each half of the character:

const gl = renderer.getContext();
const px = new Uint8Array(4);
gl.readPixels(x, canvas.height - 1 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);

Mirroring swaps the two colours; advancing a frame changes them to the next frame's pair. A sprite with scale.x = -1 leaves them where they were.

Read the uniform the GPU actually has. This uses renderer.properties, which is internal, so treat it as a debugging tool only:

hero.onAfterRender = (renderer, scene, camera, geometry, material) => {
  const gl = renderer.getContext();
  const prog = renderer.properties.get(material).currentProgram.program;
  const m = gl.getUniform(prog, gl.getUniformLocation(prog, 'mapTransform'));
  console.log(m[6], m[7], material.map.offset.x, material.map.offset.y);
};

m[6] and m[7] are the translation of the UV matrix. With the default texture center and no rotation, they equal the offset.

Keep the tab in the foreground, and test every direction: front, back and both sides.

Notes

  • Versions. BEAROUT is locked to three.js 0.170.0 and Vite 6.4.3; the source quoted above is from that three.js release. We ran the reproduction in Playwright's headless Chromium 153 (SwiftShader) and WebKit 26.6, not on a phone.
  • Parent rotation. The rotation cancel uses the parent's local quaternion. That is only correct because the player root is a direct child of the scene. If the plane sits deeper in the hierarchy, invert the parent's world quaternion instead.
  • Check each sheet's facing. BEAROUT's generated atlases disagreed: the axe and bow side rows walk left, the shotgun and minigun rows walk right. A per-sheet flag (SIDE_FACES_RIGHT) sets the mirror direction.
  • What the plane costs. You set its orientation yourself every frame, and the cutout has hard edges. Shadows were never the sprite's strength either: three.js's shadow pass only draws meshes, lines and points, so a Sprite casts nothing. BEAROUT draws a blob shadow under the character instead.

The iOS build wraps the same game in a WKWebView using Capacitor 8. There, HUD controls bound with pointerdown didn't respond after the start splash was dismissed. The splash isn't removed: it fades out with opacity: 0; pointer-events: none and sits above the HUD (z-index: 20 over 10). The note in the code reads: "the faded splash overlay still wins pointer hit-testing in WKWebView; click resolves to the pill". Binding them with click fixed it. We didn't pin down the WebKit mechanism; the takeaway is the same as above: log event.target on the device instead of assuming which element was hit.

More lab notes