Skip to content

feat(physics): add TerrainShape for 2D heightmap terrain#521

Open
stormmuller wants to merge 6 commits into
mainfrom
claude/github-issue-519-ogff0o
Open

feat(physics): add TerrainShape for 2D heightmap terrain#521
stormmuller wants to merge 6 commits into
mainfrom
claude/github-issue-519-ogff0o

Conversation

@stormmuller

Copy link
Copy Markdown
Member

Summary

Closes #519.

  • Adds 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 edge depth units below the lowest point). Lives alongside CircleShape/PolygonShape in src/physics/shapes, following the same ShapeBase contract.
  • Adds circle-vs-terrain and polygon-vs-terrain narrow-phase collision, dispatched through the existing detectCollision registry (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 for CircleShape/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.
  • Adds getSharedWhiteTexture, alongside the existing getSharedBlackTexture, 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.
  • Extracted polygon-math.ts (signed area, centroid, normals, moment of inertia) out of PolygonShape so TerrainShape can reuse the same formulas for its own silhouette; PolygonShape's public behavior/tests are unchanged.
  • Updates the physics demo's floor from a flat wall to a rolling-hill 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 errors
  • npm test - 1008/1008 passing, including new unit tests for TerrainShape, the two new collision detectors, detectCollision dispatch, and a PhysicsWorld integration 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 errors
  • npm run check-exports - all subpaths resolve correctly
  • Rebuilt /dist, ran documentation-site's typecheck/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 works

Generated by Claude Code

stormmuller and others added 6 commits July 20, 2026 23:53
* 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Why is this logic/system in the demo and not in the engine?

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