Box3d: 3D collision resolution (#1476), plus two glTF colour fixes - #1583
Open
obiot wants to merge 6 commits into
Open
Box3d: 3D collision resolution (#1476), plus two glTF colour fixes#1583obiot wants to merge 6 commits into
obiot wants to merge 6 commits into
Conversation
Adds a `Box3d` body shape so a body can be resolved along Z as well as X and Y. Every other built-in shape is planar and goes through the 2D SAT narrowphase, whose minimum translation vector is a `Vector2d` — a depth gate can filter candidate pairs, but nothing could produce a Z pushback. - `Box3d` (geometries/box3d.ts): centre + half-extents, with a cached XY footprint so every 2D consumer (body bounds, broadphase pre-gate, debug draw) needs no 3D awareness. The footprint is floored at a minimum edge because `Polygon.recalc` normalizes edges with no zero guard and would otherwise emit NaN normals for a zero-size box. - `testBox3dBox3d` (physics/builtin/sat3d.js): AABB-vs-AABB MTV, filling the new `ResponseObject.overlapZ` / `overlapNZ`. - `Body`: `velZ`, `forceZ`, `frictionZ`, `maxVelZ`, and a `hasDepth` flag. - `raycast3d`: exact ray-vs-AABB slab test for bodies carrying a `Box3d`, replacing a bounding-sphere approximation whose radius came from a 2D bounds with no depth — useless for probing floor height. The 2D path is unchanged. Z arrives as scalars beside `overlapV` / `overlapN` / `body.vel` rather than by widening them: `Vector3d` is not a subclass of `Vector2d`, so retyping would break every existing consumer. The MTV is a single axis, so a planar pair leaves every Z field at 0 and the added arithmetic is inert. Mixed pairs treat the planar shape as unbounded along Z, so introducing one `Box3d` body into a 2D game leaves its world shapes colliding exactly as before. 54 tests, including differential sweeps against a brute-force AABB test (seeded LCG, reported as a rate) and a compat suite pinning that 2D-only responses are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
`pbrMetallicRoughness.baseColorFactor` is defined by the glTF spec in linear space; a melonJS tint is 8-bit sRGB, the same space as a CSS colour or a PNG texel. The loader scaled the linear value by 255 and used it as the tint, so every untextured glTF material rendered lighter and less saturated than authored — a mid-green (linear 0.29) arrived as sRGB 0.58 and read as pale mint. Adds `linearToSrgb8` and applies it at both tint sites. The helper clamps its domain as well: an exporter emitting a slightly out-of-range factor would previously have reached `Math.pow` with a negative base and NaN-ed the whole tint. Tested at both levels — the transfer function itself, and the loader wiring (a `GLTFModel` built from a known factor must arrive at the encoded tint). The wiring tests are the load-bearing ones: the helper tests alone still pass if the loader stops calling it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
An indexed (palette) PNG texture in a glTF asset rendered greyscale on Safari: geometry and animation intact, all colour gone (r == g == b). The glTF parser decoded images to an `HTMLImageElement`, unlike the ordinary image loader, which has always produced an `ImageBitmap`. That difference is invisible for an RGBA source but not for an indexed one (PNG colorType 3): WebKit's `copyExternalImageToTexture` uploads such an image as its raw palette indices rather than expanding them through the palette. Other browsers normalise the image at decode, and the WebGL backend's `texImage2D` is unaffected, so it presented as a WebGPU regression in one browser only. Nothing in the WebGPU API can correct this at upload time — `flipY`, `premultipliedAlpha` and `colorSpace` are the only knobs, and expanding a palette belongs to the decoder. glTF images therefore now decode to an `ImageBitmap`, which is RGBA by definition, bringing the glTF path in line with the rest of the loader. All three sources (embedded bufferView, data: URI, external file) fall back to the previous element path on failure: `createImageBitmap` is stricter about malformed sources, and a stricter loader would itself be a regression — a first cut without the fallback broke 17 existing glTF tests. Regression test builds a real 2x2 indexed PNG (palette: red, blue) inside a GLB and asserts both that the decode is an `ImageBitmap` and that the palette actually expands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
Two unrelated corrections to the same example. Ground shadows: this scene has no ground GEOMETRY to receive a blob — the "ground" is a screen-space stroked line grid drawn in pixel coords at a computed horizon, so a world-space shadow has nothing correct to land on and drifts as the camera pitches. It was only casting them because the application default ships `true`. MSAA: the jet, enemies and terrain props are low-poly models with long straight edges, which aliased badly as they receded. `antiAlias` was never set here, so it defaulted off. The example composites through post effects, so this depends on capture targets being multisampled too (#1556). Measured on the horizon band: intermediate-coverage pixels along the mountain silhouette rise from 8.5% to 27.0%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
Entries had grown into multi-paragraph essays — some single bullets ran to 4000 characters. Rewritten to one or two sentences each: what changed, why it matters, and any migration-critical detail. Measurement tables kept where the numbers are the point. Unreleased section 51KB -> 21KB (58% shorter), average entry ~1000 -> ~450 characters. No entry removed and no issue link dropped. Released sections are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
obiot
force-pushed
the
platformer25d-1476
branch
from
August 11, 2026 23:37
b7e5620 to
81ae4c6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for #1476 — the engine side only. The example itself is not in this PR, so this does not close the issue.
Box3d— collision along ZA body can now be resolved along Z as well as X and Y. Every other built-in shape is planar and goes through the 2D SAT narrowphase, whose minimum translation vector is a
Vector2d— a depth gate could filter candidate pairs, but nothing could produce a Z pushback, so a character in a 2.5D scene could be pushed around the screen plane and never into or out of it.Box3d— centre + half-extents, with a cached XY footprint so every 2D consumer (body bounds, broadphase pre-gate, debug draw) needs no 3D awarenesstestBox3dBox3d— AABB-vs-AABB MTV, filling the newResponseObject.overlapZ/overlapNZBody—velZ,forceZ,frictionZ,maxVelZraycast3d— exact ray-vs-AABB slab test for bodies carrying aBox3d, replacing a bounding-sphere approximation whose radius came from a 2D bounds with no depth at all (a wide floor slab read as an enormous sphere and reported a hit well above its surface — useless for probing floor height)The 2D path does not change
Z arrives as scalars beside
overlapV/overlapN/body.velrather than by widening them:Vector3dis not a subclass ofVector2d, so retyping would break every existing consumer. The MTV is a single axis, so a planar pair leaves every Z field at0and the added arithmetic is inert. Mixed pairs treat the planar shape as unbounded along Z, so introducing oneBox3dbody into an existing 2D game leaves its world shapes colliding exactly as before.Two pre-existing glTF colour bugs, found on the way
baseColorFactorwas never sRGB-encoded. The spec defines it in linear; a tint is 8-bit sRGB. The loader scaled by 255 and used it directly, so every untextured glTF material rendered washed out — an authored mid-green (linear0.29) arrived as sRGB0.58.HTMLImageElement, unlike the rest of the loader, which produces anImageBitmap. Invisible for RGBA sources; for PNGcolorType 3, WebKit'scopyExternalImageToTextureuploads the raw palette indices, so the texture arrives withr == g == b. Other browsers normalise at decode, and WebGL'stexImage2Dis unaffected, so it presented as a WebGPU regression in one browser only. Nothing in the WebGPU API can fix this at upload time — palette expansion belongs to the decoder.Also fixed along the way:
Body.destroy()threw for any shape class not registered with the legacy pool (aborting teardown half-done and surfacing later as aTypeErrorin the broadphase), and an unknown shape-type pair crashed the physics step instead of warning.Tests
240 files / 5925 passing. 66 new tests.
The differential sweeps compare
testBox3dBox3dagainst a brute-force AABB test over 4000 seeded-random pairs, asserting both directions and reporting a rate rather than failing on the first mismatch. The MTV sweep separately checks the reported pushback actually separates the pair.Each load-bearing test was verified to fail against the broken code — removing the Z separation check, flipping the Z MTV sign, disabling the box raycast path, and reverting each glTF fix all produce failures. The glTF sRGB suite tests the loader wiring, not just the helper: the helper-only tests still pass if the loader stops calling it.
AfterBurner example — two unrelated corrections
true.antiAlias: true. The jet, enemies and props are low-poly models with long straight edges that aliased badly as they receded. This example composites through post effects, so it depends on capture targets being multisampled too (Tracking: WebGL 2 capabilities unblocked by the WebGL2-only baseline (#1509 scope C) #1556). Measured on the horizon band: intermediate-coverage pixels along the mountain silhouette rise from 8.5% to 27.0%.Also
The unreleased CHANGELOG section is condensed in its own commit — 51 KB → 21 KB, no entry removed and no issue link dropped. Entries had grown into multi-paragraph essays; they are now one or two sentences each. Released sections are untouched.
Verified
Rendering checked on WebGL2, Chrome/WebGPU and WebKit/WebGPU — the Safari bug above is invisible to a SwiftShader harness, which silently falls back to WebGL2.
🤖 Generated with Claude Code
https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi