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
23 changes: 15 additions & 8 deletions packages/examples/src/examples/billboard/ExampleBillboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ const createGame = () => {
// frames are rotated + trimmed (also exercising that mapping). `preload`'s
// third arg is `false` so it does NOT switch to the built-in loading state
// (this example builds its scene manually rather than through a Stage).
const GY = -112; // character center (feet near the floor, render space Y-down)
const GY = 0; // the characters' FEET baseline (anchored on the floor plane, render space Y-down)
const HERO_H = 225;
const TAG_GAP = 20; // gap between head-top and tag bottom
const modes: Array<
[number, boolean | "cylindrical" | "spherical", string, string]
> = [
Expand All @@ -202,26 +204,29 @@ const createGame = () => {
loader.getImage("cityscene"),
);
for (const [x, mode, label, accent] of modes) {
// the character — same art + animation, one per billboard mode
const guy = new Sprite3d(x, GY, {
...atlas.getAnimationSettings(WALK_FRAMES),
width: 130,
height: 225,
height: HERO_H,
z: 0,
billboard: mode,
anchorPoint: "bottom", // feet at pos — character only
});
guy.addAnimation("walk", WALK_FRAMES, 90);
guy.setCurrentAnimation("walk");
app.world.addChild(guy);

// a name tag above the head — spherical so it always reads, whatever
// the camera is doing
const tag = new Sprite3d(x, GY - 150, {
// tag stays centered (default origin) — floats above the head,
// positioned relative to the character's new head-top position
const tagHeight = 42;
const tagY = GY - HERO_H - TAG_GAP - tagHeight / 2;
const tag = new Sprite3d(x, tagY, {
image: bakeLabel(label, accent),
width: 150,
height: 42,
height: tagHeight,
z: 0,
billboard: "spherical",
// no anchorPoint — the default center is correct here
});
app.world.addChild(tag);
}
Expand All @@ -233,7 +238,9 @@ const createGame = () => {
const camera = app.viewport as InstanceType<typeof Camera3dClass>;
camera.fov = (55 * Math.PI) / 180;
camera.setClipPlanes(8, 6000);
const TARGET = { x: 0, y: GY * 0.6, z: 0 };
// frame mid-body — ≈ the pre-anchoring framing (was GY * 0.6 with
// GY = -112 → -67.2, before GY became the feet baseline at 0)
const TARGET = { x: 0, y: -HERO_H * 0.3, z: 0 };

let t = 0;
let paused = false;
Expand Down
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- **`shared` flag on `ShaderEffect` / `GLShader`** — set `effect.shared = true` on a post-processing shader that is reused across several renderables, so one renderable's cleanup (the `shader` setter, `removePostEffect`, `clearPostEffects`, or `destroy()`) doesn't free the GL program the others still use. Defaults to `false` (unchanged auto-destroy behavior); when set, you own the shader's lifecycle and call `destroy()` yourself.
- **`ShaderEffect.setTime(seconds)`** — a convenience for the `uTime` shader-animation convention, now on the base class: call it once per frame to write a shader's `uniform float uTime` (e.g. to scroll a static texture's UVs, or pulse / wave), driven by whatever clock you choose. A no-op when the shader doesn't declare `uTime`, so it's safe on any effect. Manual by design — the engine never calls it, so animation stays opt-in. The built-in `Hologram` / `Shine` effects now inherit it (their identical `setTime` overrides were removed).
- **`ShaderEffect.setTexture(name, image[, repeat])`** (closes [#1532](https://github.com/melonjs/melonJS/issues/1532)) — bind an **extra** `sampler2D` to a custom post-effect, beyond the sprite/target it processes (`uSampler`) — a noise map, mask, gradient, or flow table. Declare `uniform sampler2D <name>;` in your fragment and pass any engine texture (e.g. `noiseTexture.getTexture()`); the engine uploads it once, caches it, and (re)binds it to a reserved texture unit each draw while pointing the sampler uniform at it — no raw WebGL texture-unit juggling. Works on both post-effect paths (single-effect sprite shader and the multi-effect / camera FBO blit). Enables UV-distortion water, dissolve masks, flow maps, palette lookups, etc. Purely additive — effects that never call it are unchanged. See the new **Water Refraction** example (a perspective pool floor refracted through a GPU-scrolled seamless `NoiseTexture2d`).
- **Anchor-point presets + `Sprite3d` anchor support** (#1514) — `settings.anchorPoint` now accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"` in addition to an `{x, y}` object, uniformly across `Sprite`, `Entity`, `Collectable`, `ImageLayer`, `Text`, `BitmapText` (and their subclasses), including as a plain-string Tiled property. `Sprite3d` gains anchoring through the same `settings.anchorPoint` key (centered default, same convention as `Sprite`): the anchor is baked into the quad's local vertices so it composes with billboarding, flips and trimmed/rotated atlas frames, never leaks into the renderer transform on either camera path, is mutable at runtime via `sprite3d.anchorPoint.set(...)` (automatic re-bake + cull-bounds update), and grows the frustum-cull bounds exactly so an anchored sprite can't pop out while still on screen. On the 2D classes invalid values (unknown preset, malformed object) keep their historical silent `(0, 0)` outcome for full backward compatibility, but now log a console warning; `Sprite3d` (a new surface) throws. See the updated billboard example (`anchorPoint: "bottom"` — feet on the ground plane).
- **Shader preloading: `"shader"` asset type + `loader.getShader()` + `ShaderEffect.clone()`** — custom shaders can now be preloaded like any other asset and are **compiled at load time** (the GLSL compile cost lands in the loading screen, and a compile error fails the load — `loader.load()` rejects with an error carrying the asset's name). The source is a GLSL fragment body following the `ShaderEffect` convention (`uniform` declarations + `vec4 apply(vec4 color, vec2 uv)`), loaded from a `src` URL / data: URI or inline via the `data` field (the inline-TMX convention): `{ name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" }`. `loader.getShader(name)` returns the **shared, loader-owned instance** — the *same* object on every call, with `shared = true` so renderable cleanup never auto-destroys it; everything it is assigned to shares one set of uniform values, and it is freed only by `loader.unload()` / `loader.unloadAll()` (which destroy the GL program). For per-renderable uniform values, the new `ShaderEffect.clone()` compiles an independent, caller-owned copy: it copies the *recipe* (fragment source, precision, uniform values, `setTexture` bindings — with its own GL uploads) but never the *ownership* — the clone's `shared` flag is **always reset to `false`**, so it is auto-destroyed with the renderable it is assigned to unless explicitly re-shared. `GLShader.clone()` exists likewise for raw full-program (vertex + fragment) shaders, with the same recipe-not-ownership semantics. The **Water Refraction** example now ships its fragment as a preloaded shader asset.

### Fixed
Expand Down
90 changes: 90 additions & 0 deletions packages/melonjs/src/renderable/anchorPoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Shared `settings.anchorPoint` resolution for every renderable that accepts
* it (Sprite, Entity, Collectable, ImageLayer, Text, BitmapText, Sprite3d).
* Same normalized convention as {@link Renderable#anchorPoint}: x 0=left →
* 1=right, y 0=top → 1=bottom.
*/

/**
* Named anchor-point presets accepted anywhere `settings.anchorPoint` is —
* self-documenting shorthands for the common `{x, y}` pairs.
* @ignore
*/
export const ANCHOR_POINT_PRESETS = Object.freeze({
center: Object.freeze({ x: 0.5, y: 0.5 }),
top: Object.freeze({ x: 0.5, y: 0 }),
bottom: Object.freeze({ x: 0.5, y: 1 }),
left: Object.freeze({ x: 0, y: 0.5 }),
right: Object.freeze({ x: 1, y: 0.5 }),
"top-left": Object.freeze({ x: 0, y: 0 }),
"top-right": Object.freeze({ x: 1, y: 0 }),
"bottom-left": Object.freeze({ x: 0, y: 1 }),
"bottom-right": Object.freeze({ x: 1, y: 1 }),
});

/**
* An anchor value as accepted by `settings.anchorPoint`: a preset name or
* any object with numeric `x`/`y` (plain object, Vector2d, ObservablePoint).
* @ignore
*/
export type AnchorPointValue =
| keyof typeof ANCHOR_POINT_PRESETS
| { x: number; y: number };

const isAnchorPair = (value: unknown): value is { x: number; y: number } => {
if (typeof value !== "object" || value === null) {
return false;
}
const pair = value as { x?: unknown; y?: unknown };
return (
typeof pair.x === "number" &&
Number.isFinite(pair.x) &&
typeof pair.y === "number" &&
Number.isFinite(pair.y)
);
};

/**
* Resolve a `settings.anchorPoint` value — a preset name or an `{x, y}`
* object — into a fresh `{x, y}` pair (never a shared/frozen object).
* Values are passed through unclamped: anchors outside 0..1 are legal.
*
* Invalid values (unknown preset, malformed object, non-finite numbers) are
* handled per surface:
* - with a `fallback` (the legacy 2D classes): logs a console warning and
* returns the fallback — full backward compatibility with the historical
* silent `set(undefined, undefined)` → `(0, 0)` outcome, nothing that
* constructed before can start throwing;
* - without (new surfaces, e.g. Sprite3d): throws.
* @ignore
*/
export function resolveAnchorPoint(
value: unknown,
owner = "Renderable",
fallback?: { x: number; y: number },
): { x: number; y: number } {
if (typeof value === "string") {
const preset =
ANCHOR_POINT_PRESETS[value as keyof typeof ANCHOR_POINT_PRESETS];
if (typeof preset !== "undefined") {
return { x: preset.x, y: preset.y };
}
} else if (isAnchorPair(value)) {
return { x: value.x, y: value.y };
}
if (typeof fallback !== "undefined") {
console.warn(
`${owner}: invalid anchorPoint value (expected a preset name — ${Object.keys(
ANCHOR_POINT_PRESETS,
).join(", ")} — or an {x, y} object); falling back to (${fallback.x}, ${
fallback.y
})`,
);
return { x: fallback.x, y: fallback.y };
}
throw new Error(
`${owner}: invalid anchorPoint value (expected a preset name — ${Object.keys(
ANCHOR_POINT_PRESETS,
).join(", ")} — or an {x, y} object)`,
);
}
8 changes: 7 additions & 1 deletion packages/melonjs/src/renderable/collectable.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { polygonPool } from "../geometries/polygon.ts";
import { vector2dPool } from "../math/vector2d.ts";
import { collision } from "./../physics/collision.js";
import { resolveAnchorPoint } from "./anchorPoint.ts";
import Sprite from "./sprite.js";

/**
Expand All @@ -12,6 +13,7 @@ export default class Collectable extends Sprite {
* @param {number} x - the x coordinates of the collectable
* @param {number} y - the y coordinates of the collectable
* @param {object} settings - See {@link Sprite}
* @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0,y:0}] - anchor point (note the backward-compatible `(0, 0)` default, unlike Sprite's centered one). Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`.
*/
constructor(x, y, settings) {
// call the super constructor
Expand Down Expand Up @@ -50,7 +52,11 @@ export default class Collectable extends Sprite {

// Update anchorPoint
if (settings.anchorPoint) {
this.anchorPoint.set(settings.anchorPoint.x, settings.anchorPoint.y);
const anchor = resolveAnchorPoint(settings.anchorPoint, "Collectable", {
x: 0,
y: 0,
});
this.anchorPoint.set(anchor.x, anchor.y);
} else {
// for backward compatibility
this.anchorPoint.set(0, 0);
Expand Down
9 changes: 7 additions & 2 deletions packages/melonjs/src/renderable/entity/entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { polygonPool } from "../../geometries/polygon.ts";
import { warning } from "../../lang/console.js";
import { vector2dPool } from "../../math/vector2d.ts";
import Body from "../../physics/builtin/body.js";
import { resolveAnchorPoint } from "../anchorPoint.ts";
import Renderable from "../renderable.js";
import Sprite from "../sprite.js";

Expand Down Expand Up @@ -170,7 +171,7 @@ export default class Entity extends Renderable {
* @param {string} [settings.name] - object entity name
* @param {string} [settings.id] - object unique IDs
* @param {Image|string} [settings.image] - resource name of a spritesheet to use for the entity renderable component
* @param {Vector2d} [settings.anchorPoint=0.0] - Entity anchor point
* @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint=0.0] - Entity anchor point. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`.
* @param {number} [settings.framewidth=settings.width] - width of a single frame in the given spritesheet
* @param {number} [settings.frameheight=settings.width] - height of a single frame in the given spritesheet
* @param {string} [settings.type] - object type
Expand Down Expand Up @@ -203,7 +204,11 @@ export default class Entity extends Renderable {

// Update anchorPoint
if (settings.anchorPoint) {
this.anchorPoint.setMuted(settings.anchorPoint.x, settings.anchorPoint.y);
const anchor = resolveAnchorPoint(settings.anchorPoint, "Entity", {
x: 0,
y: 0,
});
this.anchorPoint.setMuted(anchor.x, anchor.y);
} else {
// for backward compatibility
this.anchorPoint.setMuted(0, 0);
Expand Down
25 changes: 19 additions & 6 deletions packages/melonjs/src/renderable/imagelayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
VIEWPORT_ONRESIZE,
} from "../system/event.ts";
import * as stringUtil from "./../utils/string.ts";
import { resolveAnchorPoint } from "./anchorPoint.ts";
import Sprite from "./sprite.js";

/**
Expand All @@ -29,7 +30,7 @@ export default class ImageLayer extends Sprite {
* @param {number} [settings.z=0] - z-index position
* @param {number|Vector2d} [settings.ratio=1.0] - Scrolling ratio to be applied. See {@link ImageLayer#ratio}
* @param {"repeat"|"repeat-x"|"repeat-y"|"no-repeat"} [settings.repeat="repeat"] - define if and how an Image Layer should be repeated. See {@link ImageLayer#repeat}
* @param {number|Vector2d} [settings.anchorPoint=<0.0,0.0>] - Define how the image is anchored to the viewport bound. By default, its upper-left corner is anchored to the viewport bounds upper left corner.
* @param {number|string|Vector2d|{x:number,y:number}} [settings.anchorPoint=<0.0,0.0>] - Define how the image is anchored to the viewport bound. By default, its upper-left corner is anchored to the viewport bounds upper left corner. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"` (anchored relative to the viewport, e.g. `"bottom"` = bottom-center of the viewport); a bare number anchors both axes.
* @example
* // create a repetitive background pattern on the X axis using the citycloud image asset
* app.world.addChild(new me.ImageLayer(0, 0, {
Expand All @@ -38,6 +39,16 @@ export default class ImageLayer extends Sprite {
* }), 1);
*/
constructor(x, y, settings) {
// bare-number shorthand (anchors both axes) — normalize BEFORE super():
// the Sprite base constructor consumes the same settings.anchorPoint,
// and the shared resolver doesn't accept bare numbers (pre-super
// settings mutation has precedent, e.g. Entity / TMXTileMap)
if (typeof settings.anchorPoint === "number") {
settings.anchorPoint = {
x: settings.anchorPoint,
y: settings.anchorPoint,
};
}
// call the constructor
super(x, y, settings);

Expand Down Expand Up @@ -74,11 +85,13 @@ export default class ImageLayer extends Sprite {
if (typeof settings.anchorPoint === "undefined") {
this.anchorPoint.set(0, 0);
} else {
if (typeof settings.anchorPoint === "number") {
this.anchorPoint.set(settings.anchorPoint, settings.anchorPoint);
} /* vector */ else {
this.anchorPoint.set(settings.anchorPoint.x, settings.anchorPoint.y);
}
// preset name or {x, y} (bare numbers were normalized pre-super);
// invalid values warn + keep the historical silent (0, 0) outcome
const anchor = resolveAnchorPoint(settings.anchorPoint, "ImageLayer", {
x: 0,
y: 0,
});
this.anchorPoint.set(anchor.x, anchor.y);
}

this.repeat = settings.repeat || "repeat";
Expand Down
26 changes: 22 additions & 4 deletions packages/melonjs/src/renderable/mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ function resolveGroupMaterial(group, materials) {
* mesh under a transformed parent to pivot elsewhere). Consequently, on the
* `Camera3d` world-space path the mesh opts out of the anchor offset entirely
* (see {@link Renderable#applyAnchorTransform}); the legacy 2D path still
* honors `anchorPoint` for backward compatibility.
* honors `anchorPoint` for backward compatibility. Subclasses that bake the
* anchor into their vertex data instead (e.g. {@link Sprite3d}, via the
* internal `_anchorBaked` flag) opt out on **both** paths — for those, the
* vertex bake is the single anchoring mechanism.
* @category Game Objects
*/
export default class Mesh extends Renderable {
Expand Down Expand Up @@ -574,6 +577,15 @@ export default class Mesh extends Renderable {

this.anchorPoint.set(0.5, 0.5);

// Subclasses that bake the anchor offset directly into their vertex
// data (e.g. Sprite3d) set this true, so preDraw suppresses the base
// anchor translate on BOTH camera paths — the vertex bake is then the
// single anchoring mechanism (the Camera2d fallback loses its exact
// pixel-shift anchor for such subclasses; that path is documented as
// degraded for them anyway). Plain meshes keep the legacy 2D anchor.
/** @ignore */
this._anchorBaked = false;

// skip applying the 3D transform to the 2D renderer context in preDraw
// the full 3D transform is applied to vertices in draw() instead
this.autoTransform = false;
Expand Down Expand Up @@ -803,13 +815,19 @@ export default class Mesh extends Renderable {
* path the mesh emits final world coordinates, so it opts out of the base
* anchor-point offset ({@link Renderable#applyAnchorTransform} = `false`) —
* otherwise the offset would leak into the shared mesh view matrix. The 2D
* path keeps the anchor. See the class description for the pivot rationale.
* path keeps the anchor — except for subclasses with a vertex-baked anchor
* (`_anchorBaked`, e.g. {@link Sprite3d}), which suppress it on both paths.
* See the class description for the pivot rationale.
* @param {CanvasRenderer|WebGLRenderer} renderer - a renderer instance
*/
preDraw(renderer) {
// world-space meshes pivot about their own origin, not a bounds-box
// anchor (see Mesh class doc + Renderable#applyAnchorTransform)
this.applyAnchorTransform = this._useWorldSpace !== true;
// anchor (see Mesh class doc + Renderable#applyAnchorTransform); a
// subclass with a vertex-baked anchor (`_anchorBaked`) opts out on
// both paths — recomputed here every frame, so a one-shot constructor
// assignment could never stick
this.applyAnchorTransform =
this._useWorldSpace !== true && this._anchorBaked !== true;
super.preDraw(renderer);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/melonjs/src/renderable/nineslicesprite.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default class NineSliceSprite extends Sprite {
* @param {string|Color} [settings.tint] - a tint to be applied to this sprite
* @param {number} [settings.flipX] - flip the sprite on the horizontal axis
* @param {number} [settings.flipY] - flip the sprite on the vertical axis
* @param {Vector2d} [settings.anchorPoint={x:0.5, y:0.5}] - Anchor point to draw the frame at (defaults to the center of the frame).
* @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.5, y:0.5}] - Anchor point to draw the frame at (defaults to the center of the frame). Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`.
* @example
* this.panelSprite = new me.NineSliceSprite(0, 0, {
* image : game.texture,
Expand Down
Loading
Loading