Skip to content

feat(Sprite3d): anchor support via settings.anchorPoint + named presets engine-wide#1538

Merged
obiot merged 6 commits into
melonjs:masterfrom
asadullahbro:sprite3d-origin-point
Jul 8, 2026
Merged

feat(Sprite3d): anchor support via settings.anchorPoint + named presets engine-wide#1538
obiot merged 6 commits into
melonjs:masterfrom
asadullahbro:sprite3d-origin-point

Conversation

@asadullahbro

@asadullahbro asadullahbro commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Adds a settings.originPoint option (preset string or {x,y}) so the quad's pivot can be placed anywhere relative to pos, e.g. bottom-center for ground-anchored 2.5D characters. Baked as a constant local offset applied after flips, so it composes with billboarding and trimmed/rotated atlas frames. Default stays centered — no behavior change for existing code.

Updated the billboard example to anchor the character at its feet instead of using a manual GY offset fudge factor.

Closes #1514

Description

Type of change

  • Bug fix
  • New feature
  • Documentation update
  • Performance improvement
  • Refactoring (no functional changes)

Checklist

  • I have read the Contributing Guide
  • My code follows the existing code style (pnpm lint passes)
  • I have tested my changes locally (pnpm test passes)
  • I have added tests that cover my changes (if applicable)
  • The build succeeds (pnpm build)

Related issues

Adds a settings.originPoint option (preset string or {x,y}) so the
quad's pivot can be placed anywhere relative to pos, e.g. bottom-center
for ground-anchored 2.5D characters. Baked as a constant local offset
applied after flips, so it composes with billboarding and
trimmed/rotated atlas frames. Default stays centered — no behavior
change for existing code.

Updated the billboard example to anchor the character at its feet
instead of using a manual GY offset fudge factor.

Closes melonjs#1514
@obiot

obiot commented Jul 6, 2026

Copy link
Copy Markdown
Member

Hi,

Thanks for the PR, before merging I'm gonna make some further changes however that I think makes it a better fit with the legacy/existing API :

  1. drop originPoint and override the existing anchorPoint instead, so Sprite3d anchors exactly like the 2D Sprite:

    • accept settings.anchorPoint (same key/convention/default as Sprite; your preset strings can stay as sugar)
    • hook the inherited ObservablePoint via setCallback() so runtime changes re-bake the vertices, like the live 2D anchor (but then force applyAnchorTransform = false on both camera paths, so that vertex bake is the single anchor mechanism and nothing leaks into the view matrix)
  2. keep the preset strings you added, but extend them to regular sprite (2d) object.

@asadullahbro

Copy link
Copy Markdown
Contributor Author

@obiot thanks for the detailed feedback! Happy to implement this myself, just let me know if you'd rather I push these changes, or if you're planning to handle it on your end. Either way works for me.

@obiot

obiot commented Jul 7, 2026

Copy link
Copy Markdown
Member

@obiot thanks for the detailed feedback! Happy to implement this myself, just let me know if you'd rather I push these changes, or if you're planning to handle it on your end. Either way works for me.

no worries, I will do it, already on it :)

obiot and others added 2 commits July 7, 2026 08:36
…ine-wide

Rework of the originPoint proposal into the hybrid agreed in review:
Sprite3d anchors through the SAME settings.anchorPoint key, convention
and centered default as the 2D Sprite — no second anchor API — while
keeping the (verified-correct) vertex-bake mechanism from the original
commit.

- new shared resolver (renderable/anchorPoint.ts): the named presets
  ("center", "top", "bottom", ..., "bottom-right") now work everywhere
  settings.anchorPoint does — Sprite, Entity, Collectable, ImageLayer,
  Text, BitmapText (and subclasses), plus Sprite3d. On the 2D classes
  invalid values keep their historical silent (0, 0) outcome for full
  backward compatibility, but log a console warning; Sprite3d (a new
  surface) throws. Values are never clamped; Vector2d instances and
  bare {x, y} objects pass through unchanged.
- Sprite resolves the anchor once, up front, and forwards the RESOLVED
  pair into the texture-cache descriptor (spritesheet atlases store it
  as the cached per-frame pivot), so presets and {x, y} behave
  identically on both paths and a raw string or aliased Vector2d never
  lands in the shared cache.
- ImageLayer normalizes its bare-number shorthand pre-super (its Sprite
  base consumes the same setting and the strict resolver rejects bare
  numbers).
- Sprite3d wires the inherited ObservablePoint as the LIVE anchor:
  setMuted + setCallback post-super; runtime anchorPoint.set() re-bakes
  the quad (region path via _applyFrame, plain-quad path via
  bakeQuadVertices) and re-derives the cull bounds (direct point
  mutation — the Rect width setter's recalc() walks Mesh's repurposed
  normals array and would throw).
- Mesh gains the _anchorBaked opt-out consulted by preDraw's per-frame
  applyAnchorTransform recompute, so the anchor never leaks into the
  renderer transform on either camera path (plain Mesh behavior
  unchanged, webgl_mesh_anchor.spec.js untouched and green).
- cull bounds fixed: side = max(max(w,h), sqrt2*hypot(hw+|ox|, hh+|oy|))
  — the sphere exactly encloses the anchored quad in any billboard
  orientation (a bottom-anchored 130x225 character was ~47% under-sized
  and popped out on screen); centered default byte-identical.
- billboard example: anchorPoint: "bottom", camera-target framing
  restored (GY * 0.6 became dead code aiming at the floor), stale
  comment fixed.
- tests: new tests/anchorpoint-presets.spec.js (cross-renderable
  contract, 72 tests, failing-first) + 9 geometry/runtime/cull tests in
  sprite3d.spec.js (failing-first). Full suite 4698 passed / 0 failed.

Master merged in (CHANGELOG + the melonjs#1539-melonjs#1541 fixes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
@obiot obiot changed the title feat(Sprite3d): add originPoint anchor support feat(Sprite3d): anchor support via settings.anchorPoint + named presets engine-wide Jul 7, 2026
@obiot

obiot commented Jul 7, 2026

Copy link
Copy Markdown
Member

Thanks again @asadullahbro — the vertex-bake mechanism you built was verified correct end-to-end and is kept as-is. I've pushed the rework we discussed on top of your commit (d06eb59be):

API change: settings.originPointsettings.anchorPoint — Sprite3d now anchors through the same key, convention and centered default as the 2D Sprite, and the inherited anchorPoint ObservablePoint is live (sprite3d.anchorPoint.set(...) re-bakes the quad and re-derives the cull bounds at runtime). A new _anchorBaked opt-out in Mesh.preDraw keeps the anchor out of the renderer transform on both camera paths.

Your presets went engine-wide: the names now work everywhere settings.anchorPoint is accepted — Sprite, Entity, Collectable, ImageLayer, Text, BitmapText and subclasses — via a shared resolver. 2D classes stay fully backward compatible (invalid values keep the historical silent (0,0) outcome, now with a console warning); Sprite3d throws on invalid input.

Also fixed along the way:

  • cull bounds: a bottom-anchored 130×225 character had a ~47% under-sized cull sphere (visible pop-in); the bounds now grow to exactly enclose the anchored quad in any billboard orientation, byte-identical for the centered default
  • example: the camera target (GY * 0.6) had become dead code aiming at the floor after GY = 0 — framing restored to ≈ the original
  • Sprite now forwards the resolved anchor into the texture-cache descriptor, so a preset string (or an aliased Vector2d) never lands in the shared per-image atlas

Tests: new tests/anchorpoint-presets.spec.js (cross-renderable contract, 72 tests) + 9 Sprite3d geometry/runtime/cull tests — all written failing-first. Full suite 4698/0. Master is merged in, so the branch is current.

🤖 Generated with Claude Code

obiot and others added 2 commits July 7, 2026 12:52
…mpat pins

Sprite3d (9 new adversarial cases in sprite3d.spec.js):
- anchor survives animation frame changes
- combined torture: anchor + flipX + frame change + runtime re-anchor,
  then unflip — exact vertex assertions at every step
- anchor on a BOTH rotated and trimmed atlas region
- anchor round-trip (bottom → arbitrary → out-of-range → center)
  restores geometry AND cull bounds byte-exact vs a fresh sprite
- single-axis anchorPoint.y = ... property writes re-bake
- out-of-range anchors: cull sphere encloses every projected corner
  across billboard yaw/pitch (property invariant, 3 anchors × 3 poses)
- spherical billboard: corner distances from pos are rotation-invariant
- frame-derived sizing (no explicit width) computes offsets from the
  frame grid

Sprite 2D backward compatibility (7 pins in anchorpoint-presets.spec.js):
- spritesheet preset ≡ {x, y} equivalence
- PIN of the pre-existing shared-atlas pivot inheritance (first
  sprite's anchor becomes the cached pivot for later same-image
  sprites) — documents the wart, guards against accidental change
- caller Vector2d mutation no longer retro-poisons the sprite or the
  cache (the one deliberate safety divergence from master)
- garbage on a shared sheet keeps legacy (0, 0) for both sprites
- anchor survives 2D animation frames and flips (untrimmed pivot rule)
- Entity preset reaches entity AND child sprite; NineSliceSprite
  inherits presets

Master-parity proof: the whole spec was run against master's engine
src — all 31 pure back-compat pins pass there; the 48 failures are
exactly the new-feature surface (presets, warn-on-garbage, Sprite3d's
new API) plus the documented aliasing fix. Full suite 4713 / 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
An honest audit of the anchor coverage found five holes; all closed:

- FULL preset-table validation: only "bottom"/"top-left" were
  value-verified — a typo'd pair in any of the other 7 presets would
  have passed every test (same failure class as the CSS color table).
  All 9 names now assert their documented values.
- Tiled property chain: applyTMXProperties keeps a string anchorPoint
  ("bottom") intact through coercion and it resolves correctly in a
  renderable constructor; the legacy numeric expansion ("0.5" →
  {x, y}) is pinned too — the CHANGELOG's Tiled claim is now tested.
- pool recycling: a pooled Text resolves presets on pull AND
  re-resolves new settings on recycle (onResetEvent path).
- Sprite3d flipY + anchor (only flipX was covered): bottom anchor
  stays pinned, unflip restores byte-exact.
- REGRESSION GUARD for the cull fix: pins that the legacy max(w, h)
  bounds would NOT enclose a bottom-anchored 130×225 quad (farthest
  corner 234.2 vs old radius 159.1) while the shipped bounds do —
  the pop-in bug can't silently return.

anchorpoint-presets.spec.js: 93 tests; sprite3d.spec.js: 49. Full
suite green. All sprite/anchor-heavy examples verified running clean
in-browser (platformer, sprite, aseprite, text, isometric-rpg, ui,
tiled-map-loader, texture-packer — zero console/page errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
@obiot

obiot commented Jul 7, 2026

Copy link
Copy Markdown
Member

Follow-up hardening pushed (51203d263, 6d7e0486e) — the anchor surface is now adversarially covered:

  • 142 anchor tests total across anchorpoint-presets.spec.js (93) and sprite3d.spec.js (49): all 9 presets value-validated, combined flip + frame-change + runtime re-anchor torture, rotated+trimmed regions, out-of-range anchors vs the cull sphere (property invariant), spherical rotation-invariance, Tiled string-property chain, Text pool recycling, and a regression guard pinning that the legacy bounds under-enclosed a bottom-anchored quad.
  • Backward-compatibility proof: the whole spec was run against master's engine source — all 31 pure back-compat pins pass there (including a pin of the pre-existing shared-atlas pivot inheritance); the only deliberate divergence is that a caller's Vector2d is no longer aliased into the shared texture cache.
  • Example sweep: platformer, sprite, aseprite, text, isometric-rpg, ui, tiled-map-loader and texture-packer all verified running in-browser with zero console/page errors.

Full suite: 4727 passed / 0 failed.

🤖 Generated with Claude Code

- Mesh class doc + preDraw JSDoc: note the _anchorBaked subclass
  opt-out (Sprite3d) — "the 2D path keeps the anchor" was no longer
  the whole truth
- Renderable#anchorPoint property doc: document the named presets
  accepted by settings.anchorPoint across all consumers, and that a
  plain string preset now works as a Tiled property (the old note
  implied the json: expression was the only way)

Audited: zero originPoint references remain anywhere; all 8
settings.anchorPoint @param lines carry the widened type + presets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
@obiot obiot merged commit 2d11694 into melonjs:master Jul 8, 2026
3 checks passed
@obiot

obiot commented Jul 8, 2026

Copy link
Copy Markdown
Member

all done, thanks again for the PR !

@asadullahbro asadullahbro deleted the sprite3d-origin-point branch July 8, 2026 02:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sprite3d: bottom/origin anchor for ground placement (2.5D)

2 participants