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
125 changes: 60 additions & 65 deletions packages/examples/src/examples/platformer-matter/entities/coin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,19 @@ import {
} from "melonjs";
import { gameState } from "../gameState";

// shared shine shader for all coins — one ShineEffect instance,
// ref-counted so it survives across level reloads / pool recycling
let coinShader: ShineEffect | undefined;
let coinShaderRefCount = 0;
let coinUpdateHandler: (() => void) | undefined;
// Per-instance shine shader. Pool-recycled (recycle:true) so
// onDestroyEvent doesn't fire on pickup — bind GAME_UPDATE on
// activate, unbind on deactivate.

export class CoinEntity extends Collectable {
/**
* Did *this* instance increment the shared shader's refcount? The
* shader is installed inside an `event.once(LEVEL_LOADED, …)` handler;
* coins constructed after that event has already fired register a
* listener that never runs and therefore never bump the refcount. The
* unconditional decrement in `onDestroyEvent` would otherwise drive
* the refcount negative and tear the shader down with live coins
* still using it.
*/
private didIncrementRefCount = false;
private shineShader: ShineEffect | undefined;
private shineUpdateHandler: (() => void) | undefined;
private shineSubscribed = false;

/**
* constructor
*/
constructor(x, y, _settings) {
// call the super constructor. Collectable defaults to a static
// sensor body — picked up on overlap, no physical push under
// either adapter.
super(
x,
y,
Expand All @@ -48,41 +36,60 @@ export class CoinEntity extends Collectable {
shapes: [new Ellipse(35 / 2, 35 / 2, 35, 35)], // coins are 35x35
}),
);
// shader + subscription deferred to onActivateEvent
}

// Apply the built-in shine shader. The renderer is needed to build
// the shader, which isn't available before the first level loads —
// so we attempt it now (for coins constructed after LEVEL_LOADED)
// and also register a one-shot LEVEL_LOADED handler (for coins
// constructed during preload).
const attach = () => {
if (this.didIncrementRefCount) return;
const renderer = this.parentApp?.renderer;
if (!renderer) return false;
if (!coinShader) {
coinShader = new ShineEffect(renderer, {
color: [1.0, 0.95, 0.7], // warm white-gold highlight
speed: 0.8,
width: 0.2, // wider, gentler glint
intensity: 0.22, // softer highlight
angle: 0.35, // slight diagonal sweep (~20°)
bands: 14.5, // ~14 parallel etched-rim glints
pulseDepth: 0.04, // very subtle brightness pulse
});
coinUpdateHandler = () => {
coinShader?.setTime(timer.getTime() / 1000.0);
};
event.on(event.GAME_UPDATE, coinUpdateHandler);
}
coinShaderRefCount++;
this.didIncrementRefCount = true;
this.addPostEffect(coinShader);
return true;
private attachShine(): boolean {
if (this.shineShader) return true;
const renderer = this.parentApp?.renderer;
if (!renderer) return false;
this.shineShader = new ShineEffect(renderer, {
color: [1.0, 0.95, 0.7], // warm white-gold highlight
speed: 0.8,
width: 0.2, // wider, gentler glint
intensity: 0.22, // softer highlight
angle: 0.35, // slight diagonal sweep (~20°)
bands: 14.5, // ~14 parallel etched-rim glints
pulseDepth: 0.04, // very subtle brightness pulse
});
this.shineUpdateHandler = () => {
this.shineShader?.setTime(timer.getTime() / 1000.0);
};
if (!attach()) {
event.once(event.LEVEL_LOADED, attach);
this.addPostEffect(this.shineShader);
return true;
}

private subscribeShine() {
if (this.shineSubscribed || !this.shineUpdateHandler) return;
event.on(event.GAME_UPDATE, this.shineUpdateHandler);
this.shineSubscribed = true;
}

private unsubscribeShine() {
if (!this.shineSubscribed || !this.shineUpdateHandler) return;
event.off(event.GAME_UPDATE, this.shineUpdateHandler);
this.shineSubscribed = false;
}

override onActivateEvent() {
super.onActivateEvent();
if (this.attachShine()) {
this.subscribeShine();
} else {
// renderer not ready yet — defer to LEVEL_LOADED
event.once(event.LEVEL_LOADED, () => {
if (this.attachShine()) {
this.subscribeShine();
}
});
}
}

override onDeactivateEvent() {
this.unsubscribeShine();
super.onDeactivateEvent();
}

// called by the pool on object recycling
onResetEvent(x, y, _settings) {
this.shift(x, y);
Expand All @@ -95,26 +102,14 @@ export class CoinEntity extends Collectable {
onCollisionStart() {
audio.play("cling", false);
gameState.data.score += 250;
// TMX objects are children of their object-layer container, not of
// `game.world` directly — use the actual ancestor so removeChild's
// `hasChild` guard doesn't throw "Child is not mine".
// remove from the actual ancestor (object layer), not game.world
(this.ancestor as Container | undefined)?.removeChild(this);
}

override onDestroyEvent() {
// Only release the shared shader ref if *this* instance acquired
// one — otherwise a coin destroyed before its LEVEL_LOADED handler
// fired would over-decrement and tear the shader down with live
// coins still using it.
if (!this.didIncrementRefCount) return;
this.didIncrementRefCount = false;
if (coinShader && --coinShaderRefCount <= 0) {
if (coinUpdateHandler) {
event.off(event.GAME_UPDATE, coinUpdateHandler);
coinUpdateHandler = undefined;
}
coinShader = undefined;
coinShaderRefCount = 0;
}
// fires only on real destroy (level reset / app shutdown)
this.unsubscribeShine();
this.shineShader = undefined;
this.shineUpdateHandler = undefined;
}
}
109 changes: 59 additions & 50 deletions packages/examples/src/examples/platformer/entities/coin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,14 @@ import {
} from "melonjs";
import { gameState } from "../gameState";

// shared shine shader for all coins — one ShineEffect instance,
// ref-counted so it survives across level reloads / pool recycling
let coinShader: ShineEffect | undefined;
let coinShaderRefCount = 0;
let coinUpdateHandler: (() => void) | undefined;
// Per-instance shine shader. Pool-recycled (recycle:true) so
// onDestroyEvent doesn't fire on pickup — bind GAME_UPDATE on
// activate, unbind on deactivate.

export class CoinEntity extends Collectable {
/**
* Did *this* instance increment the shared shader's refcount? Without
* tracking it per-instance, a coin destroyed before its LEVEL_LOADED
* handler runs would over-decrement in onDestroyEvent.
*/
private didIncrementRefCount = false;
private shineShader: ShineEffect | undefined;
private shineUpdateHandler: (() => void) | undefined;
private shineSubscribed = false;

/**
* constructor
Expand All @@ -43,39 +38,60 @@ export class CoinEntity extends Collectable {
shapes: [new Ellipse(35 / 2, 35 / 2, 35, 35)], // coins are 35x35
}),
);
// shader + subscription deferred to onActivateEvent
}

// Apply the built-in shine shader. Attempt immediately for coins
// constructed after the first level loaded, otherwise defer to the
// next LEVEL_LOADED event.
const attach = () => {
if (this.didIncrementRefCount) return true;
const renderer = this.parentApp?.renderer;
if (!renderer) return false;
if (!coinShader) {
coinShader = new ShineEffect(renderer, {
color: [1.0, 0.95, 0.7], // warm white-gold highlight
speed: 0.8,
width: 0.25, // wider, gentler glint
intensity: 0.22, // softer highlight
angle: 0.35, // slight diagonal sweep (~20°)
bands: 14.5, // ~14 parallel etched-rim glints
pulseDepth: 0.04, // very subtle brightness pulse
});
coinUpdateHandler = () => {
coinShader?.setTime(timer.getTime() / 1000.0);
};
event.on(event.GAME_UPDATE, coinUpdateHandler);
}
coinShaderRefCount++;
this.didIncrementRefCount = true;
this.addPostEffect(coinShader);
return true;
private attachShine(): boolean {
if (this.shineShader) return true;
const renderer = this.parentApp?.renderer;
if (!renderer) return false;
this.shineShader = new ShineEffect(renderer, {
color: [1.0, 0.95, 0.7], // warm white-gold highlight
speed: 0.8,
width: 0.25, // wider, gentler glint
intensity: 0.22, // softer highlight
angle: 0.35, // slight diagonal sweep (~20°)
bands: 14.5, // ~14 parallel etched-rim glints
pulseDepth: 0.04, // very subtle brightness pulse
});
this.shineUpdateHandler = () => {
this.shineShader?.setTime(timer.getTime() / 1000.0);
};
if (!attach()) {
event.once(event.LEVEL_LOADED, attach);
this.addPostEffect(this.shineShader);
return true;
}

private subscribeShine() {
if (this.shineSubscribed || !this.shineUpdateHandler) return;
event.on(event.GAME_UPDATE, this.shineUpdateHandler);
this.shineSubscribed = true;
}

private unsubscribeShine() {
if (!this.shineSubscribed || !this.shineUpdateHandler) return;
event.off(event.GAME_UPDATE, this.shineUpdateHandler);
this.shineSubscribed = false;
}

onActivateEvent() {
super.onActivateEvent();
if (this.attachShine()) {
this.subscribeShine();
} else {
// renderer not ready yet — defer to LEVEL_LOADED
event.once(event.LEVEL_LOADED, () => {
if (this.attachShine()) {
this.subscribeShine();
}
});
}
}

onDeactivateEvent() {
this.unsubscribeShine();
super.onDeactivateEvent();
}

// called by the pool on object recycling
onResetEvent(x, y, _settings) {
this.shift(x, y);
Expand All @@ -101,16 +117,9 @@ export class CoinEntity extends Collectable {
}

onDestroyEvent() {
// Only release the shared shader ref if this instance acquired one.
if (!this.didIncrementRefCount) return;
this.didIncrementRefCount = false;
if (coinShader && --coinShaderRefCount <= 0) {
if (coinUpdateHandler) {
event.off(event.GAME_UPDATE, coinUpdateHandler);
coinUpdateHandler = undefined;
}
coinShader = undefined;
coinShaderRefCount = 0;
}
// fires only on real destroy (level reset / app shutdown)
this.unsubscribeShine();
this.shineShader = undefined;
this.shineUpdateHandler = undefined;
}
}
19 changes: 19 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [19.6.0] (melonJS 2) - _unreleased_

**Highlights:** WebGL-pipeline hardening release. Fixes a Windows + Chrome crash on NVIDIA Optimus dual-GPU laptops where switching between the integrated and discrete GPU loses the WebGL context, the renderer auto-destroys every `GLShader` via the existing `ONCONTEXT_LOST` subscription, and `gl.deleteProgram` throws on the dead context (ANGLE / D3D11 is strict about cross-context object use; Apple's GL driver silently no-ops, which is why the bug was Windows-only). The throw left `GLShader.uniforms = null` while `ShaderEffect.enabled` stayed true, and the next frame's `setUniform("uTime", …)` then crashed with `TypeError: Cannot read properties of null (reading 'uTime')`. The fix is layered: (1) `GLShader.destroy()` is now atomic + idempotent + wraps `gl.deleteProgram` in a try / catch so a partial-destroy can't happen; (2) `ShaderEffect.destroy()` sets `enabled = false` BEFORE the inner destroy so the public guard is in the safe state even if the inner destroy throws; (3) the previously half-implemented `ONCONTEXT_RESTORED` shader-recovery path is completed — shaders now suspend on context lost (keep source + cached uniform values) and recompile + replay uniforms on context restored; and (4) the renderer itself now recovers the rest of the pipeline on `webglcontextrestored` — its own `vertexBuffer` is re-created, default GL state (BLEND / DEPTH_TEST / SCISSOR_TEST / depthMask / blendMode) is re-applied, every batcher's GL resources are re-initialised, and stale texture-unit assignments in `TextureCache` are dropped so the next `drawImage` re-uploads from still-alive sources. The whole game keeps running across a GPU switch instead of just the shaders surviving.

### Added
- **Transparent WebGL context recovery, pipeline-wide** — the renderer (not just `GLShader` / `ShaderEffect`) now survives a `webglcontextlost` → `webglcontextrestored` cycle without intervention from user code. On context restored the renderer re-creates `this.vertexBuffer`, re-applies default GL state (blend / depth / scissor / depthMask / blend mode), re-initialises every batcher (own vertex / index buffers, default shader, `boundTextures`), drops stale texture-unit assignments in `TextureCache`, releases the post-effect FBO pool, and emits `ONCONTEXT_RESTORED` so `GLShader` / `ShaderEffect` instances also recompile + replay their cached uniforms. NVIDIA Optimus GPU switches, browser tab eviction recovery, and `WEBGL_lose_context` teardowns no longer require user code to re-apply uniforms, re-upload textures, or recreate shaders / buffers — the next frame just keeps drawing.
- `GLShader.destroyed` / `GLShader.suspended` / `ShaderEffect.destroyed` — public read-only diagnostic flags. `destroyed` is the stable "explicitly released" signal (distinct from `ShaderEffect.enabled`, which also toggles transiently across context lost / restored). `suspended` is `true` only between an `ONCONTEXT_LOST` and the matching `ONCONTEXT_RESTORED`.

### Changed
- **`platformer` and `platformer-matter` example coins migrated to per-instance `ShineEffect`** — previously each example kept a module-level singleton `coinShader` with a manual refcount tracked via `LEVEL_LOADED` / `onDestroyEvent`. The other 19.5+ shader-using examples (`plinko-planck/entities/peg.ts`, `dropZone.ts`) already used per-instance shaders + `enabled = true / false` toggling for visibility — the platformer coins were the deviation in 19.5.0. Both platformer entities now follow the same pattern, with `GAME_UPDATE` subscription tied to `onActivateEvent` / `onDeactivateEvent` so pool-recycled coins (`pool.register("CoinEntity", ..., true)`) re-bind on every respawn instead of leaking one dead handler per pickup.

### Fixed
- **Stale shader references no longer crash** — calling `setUniform` / `bind` / `getAttribLocation` on a destroyed `GLShader` (or a destroyed `ShaderEffect`) is now a silent no-op instead of throwing. `GLShader.destroy()` is atomic + idempotent — it wraps `gl.deleteProgram` in a try / catch so a throw on a dead ANGLE / D3D11 context can't leave the shader half-destroyed, and an early-return on the new `destroyed` flag makes double-destroy a no-op. The pre-19.6 implementation set `uniforms = null` before calling `gl.deleteProgram` then nulled the remaining fields after — if the GL call threw (e.g. on NVIDIA Optimus laptops, where switching the active GPU loses the WebGL context and ANGLE / D3D11 then rejects `deleteProgram` against the dead context), the shader was left with `uniforms === null` and `program !== null`, the partial state that caused the 19.5.0 Windows + Chrome `TypeError: Cannot read properties of null (reading 'uTime')` crash via still-registered `GAME_UPDATE` → `setTime(...)` → `setUniform(...)` calls from `ShaderEffect.enabled === true`. Together with the no-op guards on the public methods, late-frame callers holding a stale reference now degrade silently instead of bringing the game down.
- **`ShaderEffect.destroy()` reorders `enabled = false` BEFORE `_shader.destroy()`** — defense in depth. Even if the inner destroy throws outward (or any future failure mode leaves the inner shader half-torn-down), the public `setUniform` / `bind` / `getAttribLocation` / `uniforms` / `attributes` paths on the effect immediately become no-ops via the existing `enabled` gate.
- **`GLShader._uniformCache` snapshots arrays / typed arrays on write** — the cache used for context-restored replay now `.slice()`s every `Array.isArray(value) || ArrayBuffer.isView(value)` write (in addition to the existing `value.toArray()` branch for vector/matrix types) before caching. A caller that mutated a reused `Float32Array` / `Array` after `setUniform` could otherwise silently rewrite what gets replayed on context restore, producing a visual divergence between the pre-restore and post-restore frames.

### Performance
- None.

## [19.5.0] (melonJS 2) - _2026-05-22_

**Highlights:** physics-focused release. The headline is the new `PhysicsAdapter` abstraction — the same game code can now run on either the engine's built-in SAT physics (default, unchanged behavior) or on a third-party rigid-body engine via the `physic` Application setting. Two official adapters ship alongside the engine: `@melonjs/matter-adapter` (wraps matter-js — constraints, sleeping bodies, continuous collision detection, raycasts, and matter's solver) and `@melonjs/planck-adapter` (wraps planck.js — a faithful Box2D 2.3.0 port with the same feature set plus Box2D-native joints, CCD bullet flag, and per-body gravity scale). New examples demonstrate the abstraction — `Matter Platformer` (the canonical platformer ported to matter) and `Pool (Matter)` (top-down 8-ball pool, drag-to-aim cue with physics-driven break shots and pocket sensors). Collision dispatch also gains a proper lifecycle (`onCollisionStart` / `onCollisionActive` / `onCollisionEnd`) on every `Renderable`, dispatched consistently by all three adapters with a **receiver-symmetric contract** (`response.a === this`, `response.b === other`, `response.normal` is the MTV of the receiver). The legacy `onCollision` handler is preserved unchanged for backward compatibility, but is **superseded by `onCollisionActive` on a per-renderable basis** so users who migrate get a single, clean every-frame contract. Push-out semantics on the built-in adapter are now matter-aligned: dynamic non-sensor bodies separate by default whether or not `onCollision` is defined.
Expand Down
2 changes: 1 addition & 1 deletion packages/melonjs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "melonjs",
"version": "19.5.0",
"version": "19.6.0",
"description": "melonJS Game Engine",
"homepage": "http://www.melonjs.org/",
"type": "module",
Expand Down
Loading
Loading