terrain-sim · C++ simulation systems

The world no longer belongs to the renderer.

The C++ core owns the world. I moved terrain and physics out of Unity and into an independent C++20 core—so the same world could be tested, evaluated, and replayed without giving the presentation layer ownership of its rules.

Enter the system ↓
RoleSolo developer
StackC++20 · pybind11 · Unity
FocusSimulation core · correctness · performance
VerificationGoogleTest + CI · fixed-seed evaluation

System boundary

The simulation owns truth. Consumers ask questions of it.

Objective

Move terrain and physics into an independent C++20 core that can run without a renderer.

Core responsibility

Own terrain generation, erosion, physics state, deterministic stepping, and the values tested for correctness.

Consumer responsibility

Python evaluates the core and records metrics, outcomes, and trajectories. Trajectory JSON is the replay boundary Unity consumes without recomputing the episode.

  1. CoreC++20Simulation state and rules
  2. EvaluationPythonControllers and evidence
  3. RecordsEvaluation outputsMetrics, outcomes, trajectories
  4. Replay clientUnitySelected trajectory only

01 · The world

One seed. Rules leave a trace.

fBm creates the initial heightfield. Thermal and hydraulic erosion transform it inside the C++ core. The image is evidence of the same data moving through three simulation stages—not a painted terrain.

Same seed terrain shown before erosion, after thermal erosion, and after hydraulic erosion with a shared height scale
Seed 42 · 64×64 · shared color range · generated by the C++ core
C++ corepybind11Python evaluationTrajectory contractUnity replay

Design decisions

Each interface protects a different boundary of ownership.

Rejected boundary

Renderer-owned simulation

Keeping terrain and physics inside Unity would make headless tests and alternative consumers depend on a presentation runtime.

Chosen · standalone C++20 core
Evaluation interface

Direct state access without duplicate rules

pybind11 exposes the core to Gymnasium/PPO while leaving the simulation implementation in one place.

Chosen · bind the core, do not port it
Replay interface

A record instead of a live dependency

Trajectory JSON lets Unity visualize completed evaluation without owning the episode loop or importing controller logic.

Chosen · serialize behavior at the boundary
Reproducibility

Seeds are part of the evidence

Fixed seeds connect terrain generation, deterministic evaluation, outcome classification, and the replay shown on this page.

Chosen · make the world repeatable

02 · The proof

A test found what the image could not.

A 64×64 heightmap accepts x coordinates from 0 to 63, so x=1000.5 should return the edge height, 0.133545. Instead, interpolation used the original out-of-range coordinate and returned −61.1175. Clamping the float coordinate restored the edge result without changing interpolation inside the map.

Input · sample coordinatex1000.5 z32.5The x coordinate is outside the valid heightmap range, 0–63.
Before fix · returned terrain heightheight−61.1175Invalid extrapolation · absolute error 61.2511.
After fix · returned edge heightheight0.133545Matches the x=63 edge sample · absolute error 0.

Fixed in commit 6eead6d ↗

Fixed

Boundary gradient

Out-of-grid coordinates now clamp both height and the relevant gradient axis instead of injecting a false slope.

Protected

Interior interpolation

A fractional-coordinate regression test rejects the earlier boundary fix that accidentally flattened valid interpolation.

Bounded, not fixed

Terrain tunneling

Paired CCD tests reproduce the structural gap and its control case. Currently generated terrain does not reach the measured trigger condition, so I did not expand the solver without evidence that the current workload needs it.

Candidate commit verification · native C++ suite 16/16 passed. The count supports the evidence above; it is not a coverage claim.

Proof note · Cross-platform CI

CI caught what the laptop couldn’t.

.github/workflows/ci.yml builds the full CMake tree and runs the GoogleTest suite on ubuntu-latest for every push and PR. The first real run failed on code that had already passed locally.

Local · macOS, AppleClang/libc++Passingvec3.cpp calls sqrt() without #include <cmath> — compiled via a stray transitive include.
CI run #1 · ubuntu-latest, GCC/libstdc++FailureExit code 2. libstdc++ doesn’t provide the same transitive include — a hard compile error.
CI run #2 · same dayFixedAdded the missing #include <cmath>. Re-ran, green.

Fixed in commit 2a02307 ↗

GitHub Actions run history showing CI run 1 failing on GCC/libstdc++, run 2 passing after the cmath include fix, and run 3 also passing
Run history · #1 fails on GCC/libstdc++ · #2 passes after the fix. The bug local CI-less testing couldn’t have caught.

03 · The behavior

The same rules. Three outcomes.

The evaluation pipeline produces metrics and outcomes, not just replay footage. Selected fixed-seed episodes are serialized separately as trajectory JSON for visualization; Unity consumes each record without owning or recomputing the simulation.

Seed 1003Success
Seed 1000Out of bounds
Seed 1019Timeout

These outcomes demonstrate reproducible evaluation and visible failure modes. They do not establish controller superiority or parallel scaling.

04 · The bottleneck

It wasn’t the threads.

Baseline measurement found env.reset() taking roughly 870 times as long as env.step()— about 65% of an evaluation episode’s wall clock. The obvious first hypothesis—more thread-pool parallelism—undershot the 4x target on its own. The dominant avoidable cost came from per-cell heap allocations inside thermalErode.

Hypothesis · thread pool aloneInconclusiveParallelizing the stepping loop undershot the 4x throughput target by itself.
Root cause · found by code review40,960findLowestNeighbor heap allocations per reset — one 64×64 grid, 10 thermal-erosion iterations.
After the fix · 8 threads5.33xReset throughput scaling, up from 2.95x. Target was 4x.

Fixed in commit ca2a3ca ↗

A stack array replaced the per-cell heap allocation and increased 8-thread reset throughput by 196%. The change was verified bit-exact against the pre-refactor reference across 20 seeds before adoption.

Line chart of env.reset() throughput scaling from 2.95x to 5.33x across thread counts, before and after the allocation fix
env.reset() throughput vs. thread count · 2.95x → 5.33x
Line chart of env.step() throughput plateauing near 3.4x regardless of the allocation fix or thread count
env.step() throughput vs. thread count · plateaus around 3.4x

Step throughput never moved past ~3.4x, before or after the fix, at any thread count. That’s not an unfixed bug — it’s the observed scaling ceiling on this machine (8-core Apple Silicon, asymmetric P/E cores), and the experiment log records it instead of hiding it.

The next investigation

The first hypothesis was wrong.

A pull-model rewrite was supposed to unlock SIMD. It ran slower. Compiler vectorization reports identified the real barrier: an out-of-line Heightmap::at() definition prevented optimization across translation units. Moving the definition to the header preserved the calculation and changed the generated code.

Combined workload · direct mean20.0%fBm + thermal erosion · 2962.5 → 2369.0 µs.
Thermal stage · derived mean61.4%966.5 → 372.9 µs after subtracting each run’s fBm mean.
Measurement design12 × 200AB/BA alternating runs · 200 samples and 10 warmups per run.
Twelve-run native C++ benchmark comparing thermal erosion runtime before and after inlining Heightmap accessors
MacBook Air · Apple M2 · Apple Clang 17 · -O3 · native C++ only

Numerical trade-off

Fast enough is not stable enough.

At the operating dt=1/60 s, an isolated harmonic oscillator shows why the core keeps semi-implicit Euler: 0.8403% maximum absolute energy drift with one force evaluation per step, versus 711.6% for explicit Euler at the same evaluation count. Verlet and RK4 reduce drift further, but require two and four force evaluations. This is numerical evidence, not a terrain-contact claim.

Maximum absolute energy drift for four integrators at one sixtieth of a second timestep
1D harmonic oscillator · 20 periods · compares numerical behavior, not wall-clock speed

Current boundary

Verified pipeline.
Measured improvements.

Verified nowC++ terrain and physics · TCP · pybind11 · deterministic evaluation · trajectory records · Unity replay · native regression tests · CI on every push/PR · measured reset scaling and accessor performance

Not yet claimedtraining/ (the Python/Gymnasium/SB3 RL loop) is intentionally outside CI scope because it is frozen and currently has no changing surface for CI to protect. Physics step throughput plateaued at the observed scaling ceiling on this machine, so it is not a current optimization target.

The physics model is currently a non-rotating point-mass sphere, not an articulated robot or a domain-specific contact model.