diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index b64ee89f0..e0404c7af 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,36 +3,32 @@ ## [20.8.0] (melonJS 2) - _unreleased_ ### Added -- UI: `onOver` can return `false` to consume the pointer, the way `onClick` and `onRelease` already could. Returning anything else propagates, so nothing existing moves. Without it a UI element had no way to be opaque to hover: it could swallow a click on whatever it covered but not stop that thing lighting up underneath it. `onOver` only runs on the frame the pointer crosses in, so an element that has to stay opaque for as long as the pointer rests on it wants a `pointermove` callback registered through `input.registerPointerEvent` returning `false` as well. `onOut` stays `void` on purpose, since suppressing a leave would strand the element lit -- `ProgressBar` is a renderable: a track, a fill sized by a value, an optional border and an optional label, for a health bar, a shield gauge, a cooldown or a loading bar. Set `value` and it redraws. `min` and `max` default to `0` and `1`, so a fraction works without stating either, and `ratio` reads back the normalized form. Four fill directions, horizontal or vertical, each growing from its own edge. Drawn with primitives and no artwork, so `trackColor`, `fillColor` and `borderColor` each take a colour, a css string or a `Gradient`; a `null` track leaves the bar hollow and a `radius` rounds it. The colour is re-read every frame, which is what makes a value-driven one a matter of mutating the `Color` you passed rather than anything the bar has to know about. `bindEvent` names an event to take the value from, and the subscription then lives exactly as long as the bar does, which a listener held anywhere else does not. The engine's own loading screen is built on it -- `Color#setLinear(r, g, b, alpha)` sets a colour from LINEAR values, encoding them to sRGB. The sibling of `setFloat`, which takes the same `0..1` range and treats it as already sRGB: the two are not interchangeable, since a linear `0.42` is sRGB `0.68`. Reach for it whenever the numbers come from a renderer's own colour space rather than from a css string or an image, glTF's `baseColorFactor` and `emissiveFactor` being the common case. Handing those to `setFloat`, or scaling them by 255 into `setColor`, renders every untextured material markedly too dark, by 60 to 70 counts per channel in the midtones, and it is easy to leave in because the result still looks coherent -- `Mesh#depthTest` decides whether geometry in front of a mesh hides it, `true` by default so nothing existing moves. Depth TEST, not depth write: the transparent pass already turns writing off for everything blended, and that is policy, while being occluded at all is an authoring choice. `false` is for a mesh standing in for a screen-space effect, an additive glow carrying a world position only so it can sort and move with what it belongs to. Left depth tested, a flat billboard is sliced along a hard straight line the moment any geometry is nearer at some pixel, worst around something round where the near surface bulges further toward the camera than any offset would clear. Honoured in the transparent pass on both GPU backends; the Canvas renderer has no depth buffer and ignores it -- Physics: `Sphere` is a collision shape. A body can carry one, and the builtin 3D narrowphase resolves it against another `Sphere`, a `Box3d`, or any planar shape through its own XY silhouette. It has no orientation to get wrong, which is what a `Box3d` cannot say and what anything tumbling or laid out over a curved surface needs. It joins `Box3d` in the portable `BodyShape` union -- Physics: `raycast3d` reports the surface of a sphere body, measured as that sphere rather than as a bounding sphere derived from the renderable's 2D bounds -- `renderer.stroke()` and `renderer.fill()` accept a `Sphere`, drawing the circle that is its silhouette -- `RadialGradientEffect` is exported. It was already a complete effect with both language bodies and a documented page of examples, reached by the renderers through a direct import for `drawLight`, so nothing failed and nobody noticed that `import { RadialGradientEffect } from "melonjs"` did not work. A soft spot, a pickup highlight or a damage flash no longer needs a baked canvas -- Renderable: `setPosition(x, y, z)` moves any renderable, with `z` optional. `pos` holds an `ObservableVector3d` but the geometry it inherits from declares it as 2D, so from TypeScript `pos.set(x, y)` takes two arguments and silently writes a zero depth, dropping anything with one onto the near plane. Omitting `z` leaves the depth alone, which is the difference that makes this safe to call without knowing whether what you are moving has a depth. Inherited by every renderable including both cameras, and it does not clamp to a camera's bounds the way `moveTo` does -- Camera: `Camera3d#setBasis(right, up, forward)` writes the orientation that `getBasis` reads, and `lookAt(target, up)` takes the direction that should appear up on screen. Together they let a camera be posed from a basis the game already holds, which is what a view over a curved surface needs: up there is the surface normal and differs at every point, so no pitch and yaw pair expresses it. The basis is decoded into the existing `pitch`, `yaw` and `roll`, so the view transform, the frustum planes and billboards all follow it with no new state, and a drifted, sheared or oddly scaled triple is absorbed rather than leaving the camera holding something that is not a rotation. `up` has no default, so `lookAt(target)` is unchanged and still leaves `roll` alone -- `hdrOutput` application setting and `renderer.setHDROutput(enable)`: present the frame in the display's full dynamic range instead of clamping it to SDR at the final blit. Off by default and requires `hdr`. **WebGPU only**. The WebGL2 equivalent is an unapproved specification change no browser implements, so that backend reports `false` and warns once. Unlike `hdr` this does change how a game looks on an HDR display, so branch on `renderer.supportsHDROutput`; note `"aces"` clamps internally and cannot feed it -- `toneMapping` application setting and `renderer.setToneMapping(mode, { exposure, white })`: a tone curve (`"aces"`, `"reinhard"`, `"exponential"`) kept last on the camera, driven from a settings screen without wiring an effect by hand. `"none"` by default, and independent of `hdr` in both directions, because a curve maps 1 to less than 1 and so restyles any game whose art is authored against a clamp. `ToneMappingEffect` gains `white`, the value that comes out as display white, which is what a brightness slider drives. An unknown curve name, or a non-finite `exposure` or `white`, is refused rather than substituted or clamped -- `hdr` application setting: the camera's render targets become half-float, so additive content stops saturating on every write and `BloomEffect` can threshold on genuinely over-bright pixels. Off by default, and additive: nothing is added to your effect chain and the frame is still presented in SDR, so anything above 1 clamps on the final blit as it always did. The cost is the camera's two render targets at 8 bytes per pixel instead of 4; sprite chains stay 8-bit, since their blit composites with `ONE_MINUS_SRC_ALPHA` and additive blending would push alpha past 1. `renderer.supportsHDR` reports what was granted and `renderer.setHDR()` moves it at runtime. WebGPU grants it unconditionally; a WebGL2 driver with neither `EXT_color_buffer_float` nor `EXT_color_buffer_half_float` warns once and stays at 8 bits -- `ToneMappingEffect`: values are lifted by `exposure` and brought back into display range by a curve instead of clipping flat at white, which is what stops a bright effect reading as a white sticker. Three curves, chosen at construction: `aces` (filmic, the default), `reinhard` and `exponential`. Put it last, after `BloomEffect`, so what bloom spreads is mapped too. Without `hdr`, it is a grade rather than a colour-managed pipeline: the default targets are `RGBA8`, so the frame arrives already clamped and `exposure` is what gives the curve something to work on. Either way it deliberately does not convert linear to sRGB, because nothing linearized it -- `BloomEffect`: the bright parts of a frame bleed light into the pixels around them, which is what makes emitters, neon and specular highlights read as light rather than as bright paint. `threshold`, `intensity` and `radius` are settable live, and it sizes itself from the renderer. One gather pass with a soft knee, so a light fading through the threshold ramps in rather than popping. `GlowEffect` is an outline drawn outside a sprite's silhouette and returns early on an opaque fragment, so it never was the screen bloom people reached for it as - -### Fixed -- A single post effect on a renderable that draws with primitives applies, instead of silently doing nothing. One effect is normally applied by drawing the renderable with the effect's own program rather than capturing it offscreen, which costs no render target but only works when everything the renderable draws is a textured quad: `fillRect` and the shape dispatch go to a batcher that never reads that shader. Measured with a `DesaturateEffect` over pure red, one effect read back `[255, 0, 0]` untouched while two read `[76, 76, 76]`, so only the single-effect case was ever wrong. `Trail` was affected and is fixed; a renderable of your own that draws with primitives sets `postEffectNeedsCapture` to opt in, and everything else keeps the cheap path exactly as before. Both GPU backends -- Input: a region covered by something that consumed the pointer is told it lost it, instead of being left in its hover state. A widget only ever got its leave by the pointer going outside its own bounds, so a button half covered by a panel stayed lit when the pointer slid off its exposed part and onto the panel, which never takes it out of the button's bounds. A consumed move now carries on down the candidate list, not to offer the event to anything underneath but to take it away from whatever still holds it. A consumed press, release or wheel does not, since none of those says where the pointer is -- Input: the pointer hit test asks the renderable that is drawn on top first. `pos.z` is container-local, because `autoDepth` numbers each container's own children from 1, and `Container#draw` never compares across containers: it recurses, so a child's z is only ever weighed against its siblings. The hit test sorted one flat list of broadphase candidates on raw z instead, so a button at local z 8 inside a low panel outranked an entire panel stacked on top of it and **a covered widget answered clicks and lit up on hover right through whatever was drawn over it**. Each pair is now resolved where `draw` resolves it, between the two siblings whose order decides which subtree paints last, with a child ahead of the container holding it and equal sibling z falling back to child order. Ordering between siblings of one container is unchanged, which is every case a game with a single container has -- The loading screen's progress bar is the public `ProgressBar`, and the loader subscription moved out of it. It used to call `on(LOADER_PROGRESS, ...)` from its own constructor, which is what kept it private: a renderable that subscribes to the loader can only ever show loading. It also stored its fill as a pixel count rather than a ratio, so a viewport resize part way through a load left the fill at the old scale until the next asset happened to land. Output is unchanged but for the fill's leading edge, which no longer truncates to a whole pixel -- Typing: much of the public API reached TypeScript as `any`, because the generated declarations named types they never imported. `renderable.body`, `tint`, `shader`, `getBounds()`, the post-effect methods, `loader.getShader()` and the `a` and `b` of every collision response are typed now, so a cast written to work around one of them can go. Code that leaned on those `any`s may surface errors it was never shown before. Method chaining works on a subclass again, since `transform`, `rotate`, `scale`, `scaleV` and `translate` return the type they were called on rather than a bare `Renderable`, so `sprite.scale(2).setCurrentAnimation("walk")` type checks -- `Sprite3d#scale()` sizes a billboarding card, and so does `meshScale`. A billboard takes its orientation from the camera, so it cannot apply `currentTransform` wholesale, and the scale sitting in that matrix was being discarded along with the rotation: `scale()` was silently a no-op on the one renderable whose size a game most often animates, leaving a growing shockwave or a shrinking pickup to be rebuilt at a new size instead. The scale is now read out of the transform and applied in the card's own plane on both GPU backends and the CPU path alike, and the frustum-cull bounds follow it, so a card scaled up is no longer culled while a third of it is still on screen. A rotation is still ignored, because a card turned to face the camera has no free rotation left to give; use `billboard: false` and orient the quad yourself for a streak or an exhaust. An identity transform multiplies by exactly 1, so a card that was never scaled is unchanged to the bit -- `Sprite3d#isFlippedX` and `isFlippedY` are getters, as they are on every other renderable. They were methods, which shadowed the ones `Renderable` defines with a member of a different kind: reading `sprite.isFlippedX` handed back the function, which is always truthy, so `if (thing.isFlippedX)` written against the common renderable API was silently true for a `Sprite3d` and only for a `Sprite3d`. It also stopped the class satisfying `Renderable` structurally, so TypeScript rejected `renderable === sprite3d` as a comparison with no overlap. The setters `flipX()` and `flipY()` were already right and are unchanged -- Camera: `Camera3d#reset()` clears the whole camera. It inherited the 2D version, which resets `roll` because in 2D the roll is the transform matrix it identity-resets, and knows nothing about `pitch`, `yaw` or a depth. A reset 3D camera therefore kept pointing wherever it had been left and stood at whatever depth it had reached. It now zeroes all three angles and takes an optional `z` alongside `x` and `y` -- Skills: the shader skill said a `ShaderEffect` cannot shade a mesh on WebGPU, which has been untrue since 20.5, so anyone following it abandoned a working feature or wrote a full `GLShader` for nothing. It now carries the real limits, which are extra samplers, the screen builtins, WGSL `vColor` and `InstancedMesh`, each warning once and degrading. The 3D skill's companion claim that a mesh carrying a `ShaderEffect` is not fogged described a supplied `GLShader`, not a hosted effect, which is spliced into the engine's own program and fogs like any other mesh -- Skills: the depth rule for a full-screen pass under the HUD was given only for `sortOn = "z"`, where a higher z draws on top. Under `sortOn = "depth"`, which every `Camera3d` scene sets, a floating child sorts on `pos.z * pos.z` and the smaller magnitude draws on top, so the snippet put the pass over the labels rather than under them. Also documented: `math.damp` for frame-rate-independent smoothing, `Renderer.getWhitePixel()` and `createCanvas()`, that `removeChild` defers and a recycled child needs `removeChildNow(child, true)`, that `scale()` applies after whatever the matrix holds so scaling must come before rotating, the `CollisionResponse` type name, that a shape's offset is measured from the top-left of the frame its renderable draws in so a small shape at `(0, 0)` lands on the corner rather than the middle, and how to tween a value that is set through a method rather than held as a property -- Pooling: a shape built by hand is dropped on `body.destroy()` rather than pooled without a reset. A pool can only reset instances it created itself, so releasing any other one handed the next caller a shape carrying the destroyed body's geometry and ignoring the arguments it asked for. This affected every shape pool -- Physics: `adapter.getBodyShapes()` keeps a 3D shape's depth offset on a renderable that is not corner-anchored. The anchor-shifted copy was written with a two-argument `Vector3d.set`, which zeroes z, so the debug overlay drew the shape at its renderable's own depth -- `Sphere#getBounds()` reports where the sphere is now. The AABB was rebuilt only by `setShape`, so a sphere moved through `pos` kept reporting its first position for the rest of its life +- **`ProgressBar`**, a gauge renderable: track, value-sized fill, optional border and label, four directions, colour or gradient. The loading screen is built on it +- **HDR rendering** (`hdr`, `hdrOutput`, `renderer.setHDR()` / `setHDROutput()`), off by default: half-float camera targets so additive content stops saturating, and the frame presented in the display's full range. `hdrOutput` is WebGPU only; check `renderer.supportsHDR` / `supportsHDROutput` +- **`toneMapping` setting and `renderer.setToneMapping(mode, options)`**: `"aces"`, `"reinhard"` or `"exponential"`, kept last on the camera and driveable from a settings screen +- **`ToneMappingEffect`**: exposure plus a filmic curve instead of clipping flat at white. `white` sets what reads as display white +- **`BloomEffect`**: bright areas bleed light into their surroundings +- **`Mesh#depthTest`**: whether geometry in front of a mesh hides it, `true` by default. Set `false` for an additive glow with no surface to be occluded on +- **Physics: `Sphere` collision shape**, resolved against another `Sphere`, a `Box3d`, or any planar shape through its XY silhouette +- **`raycast3d` on a sphere body** reports that sphere's surface, not a bounding sphere derived from the renderable's 2D bounds +- **`Renderable#setPosition(x, y, z)`**, `z` optional: `pos` is 3D but typed 2D, so `pos.set(x, y)` silently zeroes the depth +- **`Camera3d#setBasis(right, up, forward)` and `lookAt(target, up)`**: pose a camera from a basis the game already holds, which is what a view over a curved surface needs + +### Fixed +- Pointer events reach whatever is drawn on top. `pos.z` is container-local, so a button inside one panel outranked another panel stacked over it. A covered region is also told when it loses the pointer +- `onOver` can consume the pointer by returning `false`, as `onClick` and `onRelease` already could, so a panel can stop a widget it covers lighting up on hover +- A single post effect did nothing on a renderable that draws with primitives, while two or more worked. `Trail` was affected; opt in with `postEffectNeedsCapture` +- The loading screen's progress bar held a pixel count rather than a ratio, so a viewport resize mid-load left the fill at the old scale +- `renderer.stroke()` and `renderer.fill()` did not handle a `Sphere`, which has existed since 19.7 +- Much of the public API reached TypeScript as `any`, because the generated declarations named types they never imported. Chaining returns the subclass again +- `Sprite3d#scale()` and `meshScale` size a billboarding card. The scale in `currentTransform` was discarded along with the rotation, so `scale()` was a no-op; the cull bounds follow it now +- `Sprite3d#isFlippedX` and `isFlippedY` are getters, as on every other renderable. They were methods, so reading one handed back a function, which is always truthy +- `Camera3d#reset()` zeroes `pitch`, `yaw` and `roll` and takes an optional `z`. It inherited the 2D version and left the camera pointing wherever it had been +- Skills: the shader skill claimed a `ShaderEffect` cannot shade a mesh on WebGPU, untrue since 20.5. It carries the real limits now +- Skills: the depth rule for a full-screen pass under the HUD was given only for `sortOn = "z"`, so under `"depth"` it put the pass over the labels rather than under them +- A hand-built shape is dropped on `body.destroy()` rather than pooled without a reset, which handed the next caller the destroyed body's geometry +- `adapter.getBodyShapes()` keeps a 3D shape's depth offset on a renderable that is not corner-anchored +- `Sphere#getBounds()` reports where the sphere is now; the AABB was rebuilt only by `setShape` ## [20.7.0] (melonJS 2) - _2026-09-23_