Skip to content

fix(rendering): SpriteEcsComponent.pivot is Y-down while the rest of the engine is Y-up #585

Description

@stormmuller

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:

  1. createProjectionMatrix scales Y by -2 / height — the projection flips Y.
  2. 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. ✅
  3. 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.

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