feat(physics): add TerrainShape for 2D heightmap terrain#521
Open
stormmuller wants to merge 6 commits into
Open
feat(physics): add TerrainShape for 2D heightmap terrain#521stormmuller wants to merge 6 commits into
stormmuller wants to merge 6 commits into
Conversation
* feat(physics): add LinearSpring and LinearDamper forces Adds LinearSpring (Hooke's Law, F=-k*x) and LinearDamper (F=-c*v) position/velocity-based forces connecting two RigidBody instances via anchor points, plus matching ECS components and systems. Applied via RigidBody.applyImpulse scaled by deltaTimeInSeconds, following the engine's existing continuous-force-via-scaled-impulse convention rather than adding a new RigidBody method. Typical use case is vehicle suspension: the spring supports weight and pushes a wheel back down after a bump, the damper dissipates that energy to prevent endless bouncing. Closes #501. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E7eYLs4EA32zdax3EdSEk9 * docs(physics): add Linear Spring and Damper suspension demo Adds an interactive documentation-site demo comparing an undamped LinearSpring against a LinearSpring+LinearDamper pair: both hang a chassis from a fixed anchor and release it with a downward velocity to simulate hitting a bump, then periodically reset it to replay the same drop. The undamped column keeps oscillating between resets; the damped column settles to rest well before the next one. Resetting to a fixed state (rather than reapplying an impulse) keeps the undamped column's energy bounded indefinitely, since a periodic impulse not synchronized to its oscillation phase can otherwise pump energy into a lossless spring without bound over a long-running page. Also links the new LinearSpring/LinearDamper guide section from forces.md to the demo via the navbar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E7eYLs4EA32zdax3EdSEk9 * fix(docs): correct suspension demo bump direction The chassis was launched with a downward velocity while the blurb described it as "hitting a bump" - a bump kicks the wheel/chassis up, not down (the spring then pulls it back down, matching the original issue's description). Flips the velocity to upward and renames dropVelocity to bumpVelocity to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E7eYLs4EA32zdax3EdSEk9 * fix(docs): make suspension demo actually hang the wheel below its mount The blurb described the wheel as hanging from a fixed anchor, but the anchor was below the moving body and the body sat on top of it, reading as upside-down rather than hanging. Flips the layout (mount fixed above, wheel dangling below via the spring/damper) and renames anchor/chassis to mount/wheel throughout, matching both the "hanging" description and the wheel-hits-a-bump narrative (the bump still kicks the wheel up towards the mount, compressing the gap). Also swaps the wheel's shape from a rectangle to a circle sprite to read more clearly as a wheel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E7eYLs4EA32zdax3EdSEk9 * refactor(physics): flatten LinearSpring/LinearDamper into their ECS components Per PR review feedback: a standalone LinearSpring/LinearDamper class wrapped by its ECS component, with the actual force math abstracted into a separate applyLinearSpringForce/applyLinearDamperForce function, added indirection the systems didn't need - that's the whole point of a system. Removes src/physics/forces/ entirely and inlines bodyA/bodyB/anchorA/anchorB/stiffness (or dampingCoefficient) directly into LinearSpringEcsComponent/LinearDamperEcsComponent, with restLength/anchor defaulting moved into the add*Component factories and the force computation moved directly into createLinearSpringEcsSystem/createLinearDamperEcsSystem, matching how AngularVelocityMotorEcsComponent/System already work in this codebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E7eYLs4EA32zdax3EdSEk9 --------- Co-authored-by: Claude <noreply@anthropic.com>
Adds a static, non-convex ground collider defined by a heightmap (ordered surface points closed off by a flat bottom edge), with circle/polygon narrow-phase collision resolved per-segment by reusing the existing SAT code paths (factored into shared circle-polygon and polygon-faces helpers). Also adds getSharedWhiteTexture so terrain (and other untextured, tinted shapes) can be rendered as solid-color sprites. Updates the physics demo to use a rolling-hill TerrainShape floor instead of a flat wall, and documents the new shape in the physics guide. Closes #519
A longer, seeded-random rolling-hills TerrainShape course with a player-controlled ball: roll left/right (A/D or arrow keys) drives an AngularVelocityMotorEcsComponent, and friction against the terrain turns that spin into rolling motion with no special-cased "rolling" logic of its own. Space jumps while grounded (tracked via PhysicsWorld.collisionStarts/collisionEnds against the terrain body), and a small camera-follow system keeps the ball in view across the course. Registered in the docs navbar and linked from the Terrain guide alongside the physics demo's shorter example.
Rotated-rectangle-per-segment terrain rendering left visible gaps wherever the slope changed between adjacent segments, since neighboring strips only share their exact top corners and diverge below it. Replaces that with two approaches: - Rolling Ball demo: a genuine triangulated mesh, generated from a Catmull-Rom spline through sparse control points (rather than a jagged polyline), drawn with a custom shader that blends a tileable "border" texture near the surface into a tileable "fill" texture below it, both independently tileable/tintable. TerrainShape's collision points are sampled from the same curve as the render mesh, so what's drawn always matches what the ball touches. Since the mesh has an arbitrary vertex count, it's drawn with its own non-instanced gl.drawArrays call (terrain-render.system.ts) rather than through the sprite pipeline, coexisting with it via renderContext.clearStrategy = 'none'. - Physics demo: a simpler, gap-free fix - one axis-aligned bar per surface point instead of one rotated strip per segment - since its hill is a secondary element of a demo about general rigid-body physics. Adds Kenney's CC0 pattern-pack textures for the new demo, and updates the Terrain guide's Rendering section to cover both approaches.
stormmuller
commented
Jul 23, 2026
Member
Author
There was a problem hiding this comment.
Why is this logic/system in the demo and not in the engine?
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.
Summary
Closes #519.
TerrainShape: a static, non-convex 2D ground collider defined by a heightmap (surface points ordered left-to-right, closed off into a solid slab by a flat bottom edgedepthunits below the lowest point). Lives alongsideCircleShape/PolygonShapeinsrc/physics/shapes, following the sameShapeBasecontract.detectCollisionregistry (circle-terrain,terrain-circle,polygon-terrain,terrain-polygon). Internally, a terrain body is decomposed into one convex quad per consecutive pair of surface points, and each candidate segment (filtered by x-range) is tested using the same SAT code already used forCircleShape/PolygonShape, factored out into two shared modules (circle-polygon-contact.ts,polygon-faces-collision.ts) so there's no separate, less-tested "terrain physics" to reason about.getSharedWhiteTexture, alongside the existinggetSharedBlackTexture, so a sprite can be rendered as a flat, tintable color without needing an image asset — used to render terrain segments as solid-color rectangles.polygon-math.ts(signed area, centroid, normals, moment of inertia) out ofPolygonShapesoTerrainShapecan reuse the same formulas for its own silhouette;PolygonShape's public behavior/tests are unchanged.TerrainShape, rendered as one rotated rectangle sprite per segment (see_create-terrain.ts), and adds a "Terrain" guide to the physics docs.Test plan
npm run check-types- 0 errorsnpm test- 1008/1008 passing, including new unit tests forTerrainShape, the two new collision detectors,detectCollisiondispatch, and aPhysicsWorldintegration test (a circle falls under gravity and settles resting on a terrain body)npm run lint- 0 errors (only pre-existing, unrelated TODO warnings)npm run cspell- 0 errorsnpm run check-exports- all subpaths resolve correctly/dist, randocumentation-site'stypecheck/build, and loaded the physics demo in a real browser: the rolling-hill terrain renders correctly, shapes fall and settle naturally along the slope, and the explosion interaction still worksGenerated by Claude Code