Summary
SpriteEcsComponent.pivot uses a Y-down convention — (0, 0) is the sprite's top-left — while world space, entity positions, and rotation are all Y-up. Pivot is the only part of the public surface that inverts, and it does so because an internal projection detail leaks through.
Raised by @stormmuller while reviewing #580.
Confirming the convention
The JSDoc on SpriteEcsComponent.pivot states (0, 0) is the top-left corner, and tracing the pipeline confirms the code matches that:
createProjectionMatrix scales Y by -2 / height — the projection flips Y.
bindSpriteInstanceData pre-negates the entity's position: buffer[POSITION_Y_OFFSET] = -position.world.y. Those two negations cancel, so a larger position.world.y correctly draws higher on screen. World space is Y-up. ✅
sprite.vert.glsl computes the quad's local offset in the shader, so it never gets that compensating negation:
vec2 normalizedPivot = (a_instancePivot - 0.5) * 2.0;
vec2 pivoted = a_position - normalizedPivot;
vec2 scaled = pivoted * a_instanceSize * a_instanceScale * 0.5;
vec2 world = rotated + a_instancePos;
Local +y therefore passes through the projection's flip unopposed and moves down the screen.
With pivot = (0, 0): normalizedPivot = (-1, -1), so pivoted = a_position + 1 spans [0, 2], and scaled spans [0, size]. All local offsets are ≥ 0, and since local +y is down, the sprite hangs below the entity position — i.e. the entity sits at the sprite's top edge. Top-left, as documented.
Why it's a bug
Every other Y-facing convention in the engine is Y-up, and each is deliberately corrected for the projection flip:
|
Convention |
Compensated? |
position.world.y |
Y-up |
✅ negated in bindSpriteInstanceData |
RotationEcsComponent |
Y-up |
✅ negated, with a comment explaining it matches position |
screenToWorldSpace |
Y-up |
✅ flips Y explicitly |
SpriteEcsComponent.pivot |
Y-down |
❌ not compensated |
So pivot is the one place a rendering-internal detail surfaces in the public API. A caller reasoning "my world is Y-up, so pivot (0,0) is bottom-left" gets a sprite offset vertically by its full height — and the error is invisible at the default centered pivot, which is why it has survived.
Suggested fix
Negate the pivot's Y contribution so (0, 0) means bottom-left, then update the JSDoc. Either flip on upload:
instanceDataBufferArray[offset + PIVOT_Y_OFFSET] = 1 - sprite.pivot.y;
or handle it in sprite.vert.glsl alongside the existing normalizedPivot conversion. The shader is arguably the better home, since that's where the flip originates.
This is a breaking behaviour change for any content using a non-centered pivot — the default (0.5, 0.5) is unaffected, which limits the blast radius. Pre-1.0, correcting it seems right, but it wants a changelog entry calling out the visual change.
Worth checking in the same pass
- Nine-slice region offsets.
render-system.ts adds region.offset directly to entityPosition.world (Y-up space) while the pivot inside the shader is Y-down. Those two spaces disagree, so computeNineSliceRegions should be re-derived against whichever convention wins. Nine-slice always renders its regions with a centered pivot, which may be masking this.
- Test coverage. Given the pivot bug fixed in 0.24.1 (pivot applying only half its offset), non-centered pivots look under-tested. Worth adding cases that pin all four corners.
Relationship to #580
design/ui-system.md §5.2 documents the mismatch as a gotcha and specifies a sprite.pivot.y = 1 - rectTransform.pivot.y bridge in the UI layout system. If this is fixed, that bridge becomes unnecessary and should be deleted rather than kept as a compensating error.
Summary
SpriteEcsComponent.pivotuses a Y-down convention —(0, 0)is the sprite's top-left — while world space, entity positions, and rotation are all Y-up. Pivot is the only part of the public surface that inverts, and it does so because an internal projection detail leaks through.Raised by @stormmuller while reviewing #580.
Confirming the convention
The JSDoc on
SpriteEcsComponent.pivotstates(0, 0)is the top-left corner, and tracing the pipeline confirms the code matches that:createProjectionMatrixscales Y by-2 / height— the projection flips Y.bindSpriteInstanceDatapre-negates the entity's position:buffer[POSITION_Y_OFFSET] = -position.world.y. Those two negations cancel, so a largerposition.world.ycorrectly draws higher on screen. World space is Y-up. ✅sprite.vert.glslcomputes the quad's local offset in the shader, so it never gets that compensating negation:+ytherefore passes through the projection's flip unopposed and moves down the screen.With
pivot = (0, 0):normalizedPivot = (-1, -1), sopivoted = a_position + 1spans[0, 2], andscaledspans[0, size]. All local offsets are ≥ 0, and since local+yis down, the sprite hangs below the entity position — i.e. the entity sits at the sprite's top edge. Top-left, as documented.Why it's a bug
Every other Y-facing convention in the engine is Y-up, and each is deliberately corrected for the projection flip:
position.world.ybindSpriteInstanceDataRotationEcsComponentscreenToWorldSpaceSpriteEcsComponent.pivotSo pivot is the one place a rendering-internal detail surfaces in the public API. A caller reasoning "my world is Y-up, so pivot
(0,0)is bottom-left" gets a sprite offset vertically by its full height — and the error is invisible at the default centered pivot, which is why it has survived.Suggested fix
Negate the pivot's Y contribution so
(0, 0)means bottom-left, then update the JSDoc. Either flip on upload:or handle it in
sprite.vert.glslalongside the existingnormalizedPivotconversion. The shader is arguably the better home, since that's where the flip originates.This is a breaking behaviour change for any content using a non-centered pivot — the default
(0.5, 0.5)is unaffected, which limits the blast radius. Pre-1.0, correcting it seems right, but it wants a changelog entry calling out the visual change.Worth checking in the same pass
render-system.tsaddsregion.offsetdirectly toentityPosition.world(Y-up space) while the pivot inside the shader is Y-down. Those two spaces disagree, socomputeNineSliceRegionsshould be re-derived against whichever convention wins. Nine-slice always renders its regions with a centered pivot, which may be masking this.Relationship to #580
design/ui-system.md§5.2 documents the mismatch as a gotcha and specifies asprite.pivot.y = 1 - rectTransform.pivot.ybridge in the UI layout system. If this is fixed, that bridge becomes unnecessary and should be deleted rather than kept as a compensating error.