diff --git a/CHANGELOG.md b/CHANGELOG.md index 646298bf..d5b67ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Fixed - **common:** Fix `createTransformEcsSystem` composing a child's world position by plain addition of the parent's world position, ignoring the parent's world rotation and scale entirely - a child parented to a rotated and/or scaled entity now orbits/scales with it instead of spinning or resizing in place at an un-rotated, un-scaled offset. **Behavior change** for any existing content that parents a positioned entity to a rotated or scaled one and was authored against the previous (incorrect) composition. +- **rendering:** Fix `SpriteEcsComponent.pivot` using a Y-down convention (`(0, 0)` was the sprite's top-left) while every other Y-facing value in the engine (world position, rotation) is Y-up - `pivot` is now Y-up too, so `(0, 0)` is the bottom-left corner and `(1, 1)` is the top-right corner. Nine-slice region placement (`computeNineSliceRegions`) is updated to match. The centered default (`(0.5, 0.5)`) is unaffected. **Breaking change** for any content using a non-centered pivot: those sprites now render vertically mirrored around their old pivot point until the pivot's `y` is updated (`1 - oldPivotY`). - **utilities:** Fix `Game`'s render loop occasionally producing a negative `deltaTime` for one frame (e.g. right after constructing hundreds of entities in a single tick), by reading `performance.now()` at the point the frame callback runs instead of trusting `requestAnimationFrame`'s supplied timestamp, which is not guaranteed to be monotonic relative to the previous frame ## [0.24.2] - 2026-08-03 diff --git a/src/rendering/components/sprite-component.ts b/src/rendering/components/sprite-component.ts index 1195a9f3..b25edea7 100644 --- a/src/rendering/components/sprite-component.ts +++ b/src/rendering/components/sprite-component.ts @@ -37,9 +37,11 @@ export interface SpriteRequiredOptions { export interface SpriteDefaultedOptions { /** * The sprite's origin, normalized to the sprite's own size: `(0, 0)` is - * the top-left corner, `(0.5, 0.5)` (the default) is the center, and - * `(1, 1)` is the bottom-right corner. Determines which point of the - * sprite is placed at, and rotated/scaled around, the entity's position. + * the bottom-left corner, `(0.5, 0.5)` (the default) is the center, and + * `(1, 1)` is the top-right corner - Y-up, matching every other + * Y-facing value in the engine (world position, rotation). Determines + * which point of the sprite is placed at, and rotated/scaled around, the + * entity's position. */ pivot: Vector2; diff --git a/src/rendering/shaders/sprite/sprite.vert.glsl b/src/rendering/shaders/sprite/sprite.vert.glsl index d83dda03..5d883915 100644 --- a/src/rendering/shaders/sprite/sprite.vert.glsl +++ b/src/rendering/shaders/sprite/sprite.vert.glsl @@ -31,7 +31,18 @@ void main() { // pivot (0,0) lands 25% in from the sprite's edge instead of at the // edge, and every non-center pivot value needs a compensating // adjustment to land where it visually should. - vec2 normalizedPivot = (a_instancePivot - 0.5) * 2.0; + // + // Y is negated on top of that: `a_instancePivot` is public API + // (`SpriteEcsComponent.pivot`) and is Y-up like every other public + // Y-facing value in the engine (world position, rotation), so pivot + // (0, 0) means bottom-left and (1, 1) means top-right. Everything below + // this line - a_position, a_instancePos, the projection - lives in the + // Y-down space the projection matrix's flip expects, so the pivot's Y + // needs the same flip before it's combined with a_position. + vec2 normalizedPivot = vec2( + (a_instancePivot.x - 0.5) * 2.0, + -(a_instancePivot.y - 0.5) * 2.0 + ); // 1. Apply pivot (move origin) vec2 pivoted = a_position - normalizedPivot; diff --git a/src/rendering/utilities/compute-nine-slice-regions.test.ts b/src/rendering/utilities/compute-nine-slice-regions.test.ts index 334fdf28..54bcafeb 100644 --- a/src/rendering/utilities/compute-nine-slice-regions.test.ts +++ b/src/rendering/utilities/compute-nine-slice-regions.test.ts @@ -68,21 +68,38 @@ describe('computeNineSliceRegions', () => { expect(bottomRight!.offset.y).toBeCloseTo(-120 / 2 + 6 / 2, 5); }); - it('places the top-left corner exactly at the anchor when the pivot is (0, 0)', () => { - const regions = computeNineSliceRegions( - 100, - 100, - { x: 0, y: 0 }, - fullUv.offset, - fullUv.scale, - { left: 10, right: 10, top: 10, bottom: 10 }, - ); - - const topLeft = regions.find((r) => r.size.x === 10 && r.size.y === 10); - - expect(topLeft).toBeDefined(); - expect(topLeft!.offset).toEqual({ x: 5, y: -5 }); - }); + // Pivot is Y-up (matching SpriteEcsComponent.pivot): (0, 0) is bottom-left + // and (1, 1) is top-right. Each case anchors at one corner of the sprite, + // so the near corner region's own center lands a half-region-size offset + // away from the anchor, signed toward the sprite's interior. + it.each([ + { pivot: { x: 0, y: 0 }, name: 'bottom-left', offset: { x: 5, y: 5 } }, + { pivot: { x: 1, y: 0 }, name: 'bottom-right', offset: { x: -5, y: 5 } }, + { pivot: { x: 0, y: 1 }, name: 'top-left', offset: { x: 5, y: -5 } }, + { pivot: { x: 1, y: 1 }, name: 'top-right', offset: { x: -5, y: -5 } }, + ])( + 'places the $name corner exactly at the anchor when the pivot is ($pivot.x, $pivot.y)', + ({ pivot, offset }) => { + const regions = computeNineSliceRegions( + 100, + 100, + pivot, + fullUv.offset, + fullUv.scale, + { left: 10, right: 10, top: 10, bottom: 10 }, + ); + + const corner = regions.find( + (r) => + r.size.x === 10 && + r.size.y === 10 && + r.offset.x === offset.x && + r.offset.y === offset.y, + ); + + expect(corner).toBeDefined(); + }, + ); it('proportionally clamps insets that would otherwise overlap', () => { const regions = computeNineSliceRegions( diff --git a/src/rendering/utilities/compute-nine-slice-regions.ts b/src/rendering/utilities/compute-nine-slice-regions.ts index 49b10a68..e046fc0b 100644 --- a/src/rendering/utilities/compute-nine-slice-regions.ts +++ b/src/rendering/utilities/compute-nine-slice-regions.ts @@ -182,15 +182,15 @@ function createCellRegions( x: xSegment.start + xSegment.size / 2 - pivot.x * width, // Negated relative to x (written as a subtraction, not unary `-`, y: - // so a zero result stays +0 rather than -0): sprite instance data - // negates world.y again before it reaches the shader (see - // bindSpriteInstanceData), to convert this sprite-space, Y-down - // offset (near/top bands start at 0, far/bottom bands end at - // `height`) into the engine's Y-up world space. Without this, a - // region's own offset and its parent entity's position would be - // negated a different number of times, landing near/top bands at - // the far/bottom edge and vice versa. - pivot.y * height - (ySegment.start + ySegment.size / 2), + // so a zero result stays +0 rather than -0): band coordinates + // (`ySegment.start`/`.size`) run sprite-space Y-down (near/top + // bands start at 0, far/bottom bands end at `height`), but this + // offset is added directly to `entityPosition.world`, which is + // Y-up. `pivot.y` is also Y-up (public API - `(0, 0)` bottom, + // `(1, 1)` top), so `1 - pivot.y` first converts it to the same + // Y-down distance-from-top the band coordinates use, before the + // subtraction flips the whole result back to Y-up. + (1 - pivot.y) * height - (ySegment.start + ySegment.size / 2), }, size: { x: xSegment.size, y: ySegment.size }, uvOffset: { x: xBand.uvStart, y: yBand.uvStart }, @@ -214,7 +214,9 @@ function createCellRegions( * region a non-sliced sprite would render. * @param width - The sprite's current width, in unscaled sprite-space units. * @param height - The sprite's current height, in unscaled sprite-space units. - * @param pivot - The sprite's pivot, normalized to its own size. + * @param pivot - The sprite's pivot, normalized to its own size and Y-up + * (`(0, 0)` is bottom-left, `(1, 1)` is top-right), matching + * `SpriteEcsComponent.pivot`. * @param uvOffset - The sprite's texture rect offset, 0 to 1. * @param uvScale - The sprite's texture rect size, 0 to 1. * @param slices - The nine-slice configuration.