Skip to content

feat(physics): wire Jolt HeightFieldShape into terrain collision (#428) - #468

Merged
drsnuggles8 merged 2 commits into
masterfrom
feature/physics-terrain-heightfield
Jun 29, 2026
Merged

drsnuggles8 merged 2 commits into
masterfrom
feature/physics-terrain-heightfield

Conversation

@drsnuggles8

@drsnuggles8 drsnuggles8 commented Jun 29, 2026 •

Copy link
Copy Markdown
Owner

What & why

Closes the #428 Tier-1 gap: terrain had no collision — players fell through it, NPCs/vehicles couldn't traverse it, and AI/weapon raycasts missed it. This builds a static JPH::HeightFieldShape body from the terrain's CPU height field so characters, vehicles, and raycasts interact with the surface, matching the rendered mesh exactly.

How

  • JoltShapes::CreateTerrainHeightFieldShape — maps the terrain's worldSize / heightScale / entity scale onto Jolt's offset + scale·(x, sample, z) surface formula. Jolt's sample layout (index = z·N + x, normalized heights) matches TerrainData::GetHeightData() directly. Odd resolutions are padded up to the block-size multiple (Jolt requires sampleCount % blockSize == 0); bit-depth is chosen adaptively for ~2 cm precision; min/max is auto-derived.
  • ShapeType::HeightField + stringifier + GetShapeType round-trip fix (it was silently defaulting to Box).
  • JoltScene static terrain-body lifecycle (CreateTerrainBody / DestroyTerrainBody / DestroyAllTerrainBodies) on the NON_MOVING layer; body UserData = entity UUID so raycasts/contacts resolve back to the terrain entity; torn down in Shutdown / OnRuntimeStop / OnPhysics3DStop.
  • TerrainComponent.m_CollisionEnabled (serialized, default on) + runtime body token. Round-trips through scene YAML and save-game (format v5 → v6).
  • Scene wiring — collision is built GPU-free from CPU heights at OnPhysics3DStart (reads TerrainData if already built, else TerrainGenerator::GenerateHeightField — the identical field the render path uploads), so it works headless and on the dedicated server. Re-synced on runtime Regenerate.

Tests (104/104 green, incl. all cross-binding coverage guards)

New TerrainCollisionTest.cpp (Functional, headless — real Scene::OnPhysics3DStart + Jolt step):

  • a dynamic body rests on flat terrain (translated up via TransformComponent — proves transform application),
  • a dynamic body rests on procedural terrain and the rest height tracks the local surface (proves the shape follows the height field; uses an odd resolution to exercise the padding path),
  • a raycast down hits the terrain surface and resolves to the terrain entity.

Coverage guards (ComponentTuple/Handler/Serializer + SaveGame), save-game round-trips, SceneSerializer, LuaDrivesTerrainRegeneration, and JoltShapes all still pass.

Scoped to a follow-up (filed separately)

Streamed per-tile collision (TerrainStreamer) and sculpt/erosion dirty-region partial updates (HeightFieldShape::SetHeights).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added terrain collision support so static terrain can now participate in physics and raycasts.
    • Terrain collision can be enabled or disabled per terrain, and the setting is saved with scenes and game data.
  • Bug Fixes

    • Improved terrain physics setup and shutdown so collision bodies are created and removed reliably.
    • Fixed terrain shape handling so height-based terrain is recognized correctly instead of falling back to a generic shape.

Terrain had no collision — players fell through it, NPCs/vehicles couldn't
traverse it, raycasts missed it. Build a static JPH::HeightFieldShape body
from the terrain's CPU height field so characters, vehicles, and raycasts
interact with the surface.

- JoltShapes::CreateTerrainHeightFieldShape maps worldSize/heightScale/entity
  scale onto Jolt's offset + scale*(x, sample, z) surface; pads odd
  resolutions up to the block-size multiple; adaptive bit-depth (~2cm).
- ShapeType::HeightField + GetShapeType round-trip (was defaulting to Box).
- JoltScene static terrain-body lifecycle on the NON_MOVING layer; UserData =
  entity UUID so queries resolve the terrain entity; torn down on stop/shutdown.
- TerrainComponent.m_CollisionEnabled (serialized, default on) round-trips
  through scene YAML and save-game (format v5 -> v6).
- Built GPU-free from CPU heights at OnPhysics3DStart (works headless / on the
  dedicated server), re-synced on runtime Regenerate.
- Functional tests: body rests on flat + procedural terrain; raycast hits the
  surface and resolves to the terrain entity.

Streaming per-tile collision and sculpt dirty-region updates are scoped to a
follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@drsnuggles8, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ee1866f0-3caa-4da9-9f75-98bf8d79a574

📥 Commits

Reviewing files that changed from the base of the PR and between 6fca0fa and 883b51d.

⛔ Files ignored due to path filters (1)
  • OloEngine/src/OloEngine/Scene/Generated/OnComponentRemoved.Generated.inl is excluded by !**/generated/**
📒 Files selected for processing (5)
  • OloEngine/src/OloEngine/Physics3D/JoltShapes.cpp
  • OloEngine/src/OloEngine/Scene/Scene.cpp
  • OloEngine/tests/ComponentHandlerCoverageTest.cpp
  • OloEngine/tests/Functional/AnimationPhysics/TerrainCollisionTest.cpp
  • tools/OloHeaderTool/main.cpp
📝 Walkthrough

Walkthrough

Adds static Jolt physics height-field collision bodies for TerrainComponent. A new CreateTerrainHeightFieldShape factory builds padded Jolt shapes from CPU height data. JoltScene gains CreateTerrainBody/DestroyTerrainBody APIs with a UUID→BodyID map. Scene wires these through physics start/stop and runtime regeneration. A new m_CollisionEnabled toggle is persisted in save (v6) and scene YAML formats. Functional tests validate sphere settling and raycasts.

Changes

Terrain Height-Field Collision Integration

Layer / File(s) Summary
ShapeType enum and HeightFieldShape factory
OloEngine/src/OloEngine/Physics3D/Physics3DTypes.h, OloEngine/src/OloEngine/Physics3D/JoltShapes.h, OloEngine/src/OloEngine/Physics3D/JoltShapes.cpp
Adds HeightField to ShapeType enum and ShapeTypeToString; declares and implements CreateTerrainHeightFieldShape with input validation, block-size padding, adaptive mBitsPerSample selection, and fixes GetShapeType to return ShapeType::HeightField instead of falling back to Box.
JoltScene terrain body lifecycle API
OloEngine/src/OloEngine/Physics3D/JoltScene.h, OloEngine/src/OloEngine/Physics3D/JoltScene.cpp
Adds m_TerrainBodies (UUID→BodyID) map, public CreateTerrainBody/DestroyTerrainBody/HasTerrainBody API, private DestroyAllTerrainBodies, and implementations maintaining reverse-map sync; hooks cleanup into Shutdown and OnRuntimeStop.
TerrainComponent authored and runtime fields
OloEngine/src/OloEngine/Scene/Components.h
Adds m_CollisionEnabled authored boolean and m_RuntimeCollisionBodyToken runtime field; updates copy constructor, assignment operator, equality operator, and force-rebuild reset path accordingly.
Scene collision body build and lifecycle wiring
OloEngine/src/OloEngine/Scene/Scene.cpp
Adds BuildTerrainCollisionBody helper that selects CPU heights from TerrainData, procedural generation, or flat fallback; integrates it into OnPhysics3DStart, OnPhysics3DStop, and ProcessScene3DSharedLogic for runtime height regeneration.
Serialization persistence for m_CollisionEnabled
OloEngine/src/OloEngine/SaveGame/SaveGameTypes.h, OloEngine/src/OloEngine/SaveGame/SaveGameComponentSerializer.cpp, OloEngine/src/OloEngine/Scene/SceneSerializer.cpp
Bumps save format version to v6; serializes m_CollisionEnabled in the save-game terrain block; round-trips CollisionEnabled in scene YAML serialization/deserialization.
Functional terrain collision tests
OloEngine/tests/CMakeLists.txt, OloEngine/tests/Functional/AnimationPhysics/TerrainCollisionTest.cpp
Adds flat and procedural terrain test fixtures verifying sphere settling on terrain surfaces and downward raycast hits with bilinear surface-height sampling.

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: wiring Jolt HeightFieldShape into terrain collision.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@drsnuggles8

Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@OloEngine/src/OloEngine/Physics3D/JoltScene.cpp`:
- Around line 331-409: Terrain bodies are not being torn down when a terrain
entity or TerrainComponent is removed during runtime, so the Jolt body and
m_BodyIDToEntity mapping linger until shutdown. Update the terrain
removal/destruction path in JoltScene to call
DestroyTerrainBody(entity.GetUUID()) when a terrain entity is deleted or its
TerrainComponent is detached, and ensure the cleanup stays idempotent by reusing
DestroyTerrainBody and the existing CreateTerrainBody bookkeeping.

In `@OloEngine/src/OloEngine/Physics3D/JoltShapes.cpp`:
- Around line 458-522: CreateTerrainHeightFieldShape currently forwards
unvalidated floating-point inputs into Jolt shape setup; add finite checks
before constructing JPH::HeightFieldShapeSettings. In
JoltShapes::CreateTerrainHeightFieldShape, validate all values in heights as
well as worldSizeX, worldSizeZ, heightScale, and each component of scale with
std::isfinite, and return nullptr with an error log if any NaN/Inf is found.
Make sure the checks happen before computing joltScale,
CalculateBitsPerSampleForError, or settings.Create() so the method fails closed.

In `@OloEngine/src/OloEngine/Scene/Scene.cpp`:
- Around line 2717-2766: The terrain collision setup in CreateTerrainBody leaves
an old static body active on every skip/failure path; make sure any existing
runtime terrain body is destroyed before returning when collision is disabled,
terrain is streaming, CPU heights are unavailable, or
JoltShapes::CreateTerrainHeightFieldShape fails. Update the cleanup in
Scene::CreateTerrainBody so these early exits clear the previous body/token
state consistently, not only the success path that replaces it with a new shape.
- Around line 2848-2856: Mirror terrain body cleanup when terrain is destroyed
or its component is removed. Update DestroyEntity() and the TerrainComponent
removal path to explicitly destroy the raw Jolt static body created by
BuildTerrainCollisionBody and remove its BodyID→entity mapping, since terrain is
not handled by the Rigidbody3DComponent/vehicle/character cleanup. Use the
existing terrain-body bookkeeping in Scene::BuildTerrainCollisionBody and the
entity cleanup flow in Scene::DestroyEntity as the main touchpoints.

In `@OloEngine/tests/Functional/AnimationPhysics/TerrainCollisionTest.cpp`:
- Around line 54-63: The expected procedural height-field params in
MakeProcParams() are missing TerrainComponent-authored inputs that
BuildTerrainCollisionBody copies at runtime. Update MakeProcParams() to populate
the same TerrainGenerator::HeightParams fields used by the runtime path,
including m_HeightShaping and m_ProceduralErosionIterations, so the test builds
the expected field from the same terrain values and stays aligned with
BuildTerrainCollisionBody.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: bbffe2b4-9dca-4b07-86f0-f18f974ce734

📥 Commits

Reviewing files that changed from the base of the PR and between 21a8d89 and 6fca0fa.

📒 Files selected for processing (12)
  • OloEngine/src/OloEngine/Physics3D/JoltScene.cpp
  • OloEngine/src/OloEngine/Physics3D/JoltScene.h
  • OloEngine/src/OloEngine/Physics3D/JoltShapes.cpp
  • OloEngine/src/OloEngine/Physics3D/JoltShapes.h
  • OloEngine/src/OloEngine/Physics3D/Physics3DTypes.h
  • OloEngine/src/OloEngine/SaveGame/SaveGameComponentSerializer.cpp
  • OloEngine/src/OloEngine/SaveGame/SaveGameTypes.h
  • OloEngine/src/OloEngine/Scene/Components.h
  • OloEngine/src/OloEngine/Scene/Scene.cpp
  • OloEngine/src/OloEngine/Scene/SceneSerializer.cpp
  • OloEngine/tests/CMakeLists.txt
  • OloEngine/tests/Functional/AnimationPhysics/TerrainCollisionTest.cpp

Comment thread OloEngine/src/OloEngine/Scene/Scene.cpp Resolved
Comment thread OloEngine/src/OloEngine/Scene/Scene.cpp Resolved
Comment on lines +54 to +63
TerrainGenerator::HeightParams MakeProcParams()
{
TerrainGenerator::HeightParams params;
params.Resolution = kProcResolution;
params.Seed = kProcSeed;
params.Octaves = kProcOctaves;
params.Frequency = kProcFrequency;
params.Lacunarity = kProcLacunarity;
params.Persistence = kProcPersistence;
return params;

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mirror every runtime terrain-generation input in the expected height-field.

BuildTerrainCollisionBody also copies terrain.m_HeightShaping and terrain.m_ProceduralErosionIterations into TerrainGenerator::HeightParams, but MakeProcParams() leaves those implicit. That means the “exact CPU field” claim here stops being true as soon as either default diverges, and this test starts failing for the wrong reason. Build the expected params from the same authored TerrainComponent values the runtime path uses.

Also applies to: 188-189

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OloEngine/tests/Functional/AnimationPhysics/TerrainCollisionTest.cpp` around
lines 54 - 63, The expected procedural height-field params in MakeProcParams()
are missing TerrainComponent-authored inputs that BuildTerrainCollisionBody
copies at runtime. Update MakeProcParams() to populate the same
TerrainGenerator::HeightParams fields used by the runtime path, including
m_HeightShaping and m_ProceduralErosionIterations, so the test builds the
expected field from the same terrain values and stays aligned with
BuildTerrainCollisionBody.

…uts (review)

Address code-review findings on the #428 terrain heightfield collision:

- JoltShapes::CreateTerrainHeightFieldShape fails closed on non-finite inputs
  (any NaN/Inf height sample, world size, height scale, or entity scale) instead
  of forwarding them into Jolt's quantization math.
- Scene::BuildTerrainCollisionBody destroys any prior terrain body up front, so
  every skip/failure path (collision disabled, streaming, no CPU heights, shape
  build failure) leaves no stale body on the old surface — not just the success
  path that replaced it.
- The static terrain body is now torn down when terrain is removed at runtime:
  Scene::DestroyEntity drops it on entity destruction (m_Registry.destroy doesn't
  fire OnComponentRemoved), and a new hand-written OnComponentRemoved<Terrain
  Component> covers component detach. TerrainComponent added to
  kComponentsCustomOnRemove (OloHeaderTool + ComponentHandlerCoverageTest mirror);
  the generated no-op list is regenerated.
- Test: MakeProcParams mirrors every field BuildTerrainCollisionBody copies
  (shaping + erosion) so the reconstructed field can't silently drift; new
  DestroyingTerrainEntityRemovesCollision test pins the destroy-cleanup path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant