Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/rendering/components/sprite-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
13 changes: 12 additions & 1 deletion src/rendering/shaders/sprite/sprite.vert.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 32 additions & 15 deletions src/rendering/utilities/compute-nine-slice-regions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 12 additions & 10 deletions src/rendering/utilities/compute-nine-slice-regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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.
Expand Down
Loading