Skip to content

Soft lights, sortOffset, onFrame, tintOverLife, track regions and fixes - #1764

Merged
obiot merged 2 commits into
masterfrom
feat/api-batch-20.11
Oct 24, 2026
Merged

obiot merged 2 commits into
masterfrom
feat/api-batch-20.11

Conversation

@obiot

@obiot obiot commented Oct 24, 2026

Copy link
Copy Markdown
Member

Engine additions and fixes from the isometric dungeon example (coming in its own PR). Each new API was reviewed on its own before it was built. This branch starts from master and does not depend on #1763; both add to the 20.11 changelog, so whichever merges second needs a small rebase there.

Added

  • Light2d#softness (0 to 1) fades the edge of a light's hole in the stage's ambient darkness, from a hard rim (0, the default, unchanged) to a darkness that thins straight from the centre (1), along the same falloff as the glow. When any light in view is soft, Stage#drawLighting draws the darkness in one pass through a new internal Renderer#drawAmbientLight. On WebGL and WebGPU that is one quad through an internal AmbientLightEffect, which cuts up to 32 lights in view; overlapping lights multiply. On Canvas the darkness is erased from a reused offscreen canvas. Hard and soft lights mix in one scene, and a stage whose lights are all hard keeps the old fillRect path.
  • Renderable#sortOffset lines a renderable up as if it stood that many pixels lower or higher when its container sorts on "y" or "x", without moving it. It takes finite numbers only (TypeError otherwise), re-sorts the parent when it changes, and is cleared when a pooled renderable is reset. Equal positions keep insertion order, which the tilemaps skill now spells out (the child added last is drawn behind).
  • setCurrentAnimation(name, { onFrame }) calls back with each frame index as playback reaches it, from frame 0, on a Sprite or a Sprite3d.
  • ParticleEmitterSettings.tintOverLife blends a particle's tint from the first color at birth to the last at the end of its life. It sets the color only (the fade keeps the opacity). The blend is taken in 32 steps, sampled once in reset(), because the Canvas renderer keeps a tinted copy of a texture per color for as long as the texture lives: a continuous blend would grow that cache without bound.
  • audio.playTrack(name, { volume, sprite }) plays one region of a clip as the looping track (TrackOptions is exported). A region of the loop's exact length keeps an MP3 loop gapless in Safari, which plays the padding an MP3 encoder adds to the end of the file.

Changed

  • autoSort keeps an "x" or "y" sorted container in order as its children move. At the end of each Container#update it checks the order in one comparison pass and re-sorts (and marks the frame dirty) only when something is out of place. A walking character passes in front of and behind the scenery with no sort() call. A frame where nothing moved costs one pass and causes no redraw (about 0.065 ms for 956 children). Setting autoSort to false keeps the order you set, and a "z" sort is unchanged.

Fixed

  • Effects keep to the sprite's frame. On a sprite cut from an atlas or a tileset, a glow, outline, blur, drop shadow, chromatic aberration, wave, sway, pixelate or bloom read the neighbouring frames along its edges and drew a glow or a line from them (a gold line beside the dungeon's chests on WebGPU). The quad batchers now hand an effect the frame they draw (ME_frame, ME_frame_half, through an internal ShaderEffect#_setFrameRect), and these effects sample through a frameTexel() helper: transparent outside the frame, and kept half a texel inside it so filtering does not reach over the edge. A side of the frame on the texture's own edge stays unbounded, as do whole-texture passes (camera effects, offscreen passes, meshes), so those sample as before. Custom shaders are untouched unless they declare the uniforms.
  • A zoomed camera draws its floating renderables, and a ColorLayer fills its whole view. Container#draw skipped floating children on any camera that was not at zoom 1, so a zoomed main camera lost its HUD; that check now also lets through the stage's "default" camera when it is drawn at the screen origin, so a minimap still skips them. ColorLayer now scales its clip by the zoom.
  • Lighting, all released bugs:
    • On Canvas, a light's hole is cut where the light is under a scrolled camera. The mask kept the darkness inside the light and cleared it around it.
    • On Canvas, the darkness of a zoomed camera, or of one drawn at an offset such as a minimap, has its holes where the lights are.
    • The darkness covers the first column and row of a camera at a fractional position, such as one following with damping. translate() truncates without subPixel, so the darkness now reaches a whole-pixel margin past the view (ceil(zoom) / zoom world units).
    • The darkness covers the whole view of a zoomed-out camera. It covered camera.width world units, not camera.width / zoom.
  • Audio: a sprite region declared looping ([start, duration, true]) loops when played with audio.play(name, { sprite }). Its loop flag was overridden to false unless loop: true was passed too.

Also

  • Skills: lighting (softness, soft and hard lights together, and symptom rows), effects and shaders (effects keep to the frame on an atlas), audio (playTrack regions), math (Noise#getNoise1d for a flicker or a wobble), particles (tintOverLife), renderables and tilemaps (sortOffset, autoSort, tie order), sprites and animation (onFrame).
  • A test name in tmxlayer-data.spec.js no longer points at the isometricRpg example, which the dungeon example replaces.

Verification

  • New specs: light-softness (38), particle-tint-over-life (27), effect-frame-bounds (29), sort-offset (19), camera-zoom-floating (18), container-autosort (10), plus onFrame cases in frameAnimation and animation (9) and audio cases in audio-backend (6).
  • Mutation-checked:
    • audio 7/7, onFrame 7/7, sortOffset 12/12, autoSort 7/7, tintOverLife 10/10;
    • floating renderables: the old check fails 12 tests, a fix that ignored the minimap case fails 2;
    • the lighting edge margin: no margin fails 8, a one-world-unit margin fails 4;
    • frame bounds: no feed fails 8, a bounded blit fails 1, no half-texel inset fails 1, clamping without the outside test fails 1, and bounding the texture's own edges fails 4.
  • The WGSL of all 21 effect presets compiles on a real WebGPU device (headed Chrome).
  • Before/after comparison on WebGL and WebGPU: each frame drawn as shipped and with the frame bounds switched off, at three moments in shader-effects, platformer, platformer-matter, hdr, after-burner, whac-a-mole, earth-defender, waterOverworld and the dungeon. No pixel differs in any of them, and the same frame drawn twice also gives 0. In the comparison run next to the dungeon chest, the only pixels that change form a 4 × 87 px strip: the old line.
  • Full suite on this branch: 8,468 passed, 10 skipped. Root pnpm lint and pnpm doc pass with no new doc warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx

Engine additions and fixes from the isometric dungeon example:

- Light2d#softness fades the edge of a light's hole in the stage's
  ambient darkness: one quad through an internal AmbientLightEffect on
  WebGL and WebGPU, an offscreen erase on Canvas
- Renderable#sortOffset, and autoSort keeping an "x" or "y" sorted
  container in order as its children move
- setCurrentAnimation(name, { onFrame }) on Sprite and Sprite3d
- ParticleEmitterSettings.tintOverLife
- audio.playTrack(name, { volume, sprite }) (TrackOptions), and a sprite
  region declared looping loops through audio.play
- effects that sample around a pixel keep to the sprite's frame on an
  atlas or a tileset (ME_frame, fed per quad by the quad batchers)
- a zoomed camera draws its floating renderables, and a ColorLayer
  fills its view
- stage lighting: the Canvas mask under a scrolled, zoomed or offset
  camera, the first column and row at a fractional camera position, and
  the whole view of a zoomed-out camera
- the skills cover all of the above

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx
Copilot AI balanced review requested due to automatic review settings October 24, 2026 02:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Copilot AI balanced review requested due to automatic review settings October 24, 2026 02:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@obiot
obiot merged commit d7db994 into master Oct 24, 2026
6 checks passed
@obiot
obiot deleted the feat/api-batch-20.11 branch October 24, 2026 02:39
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.

2 participants