Move terrain and physics into an independent C++20 core that can run without a renderer.
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 ↓System boundary
The simulation owns truth. Consumers ask questions of it.
Own terrain generation, erosion, physics state, deterministic stepping, and the values tested for correctness.
Python evaluates the core and records metrics, outcomes, and trajectories. Trajectory JSON is the replay boundary Unity consumes without recomputing the episode.
- CoreC++20Simulation state and rules
- EvaluationPythonControllers and evidence
- RecordsEvaluation outputsMetrics, outcomes, trajectories
- 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.

Design decisions
Each interface protects a different boundary of ownership.
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 coreDirect 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 itA 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 boundarySeeds 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 repeatable02 · 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.
Fixed in commit 6eead6d ↗
Boundary gradient
Out-of-grid coordinates now clamp both height and the relevant gradient axis instead of injecting a false slope.
Interior interpolation
A fractional-coordinate regression test rejects the earlier boundary fix that accidentally flattened valid interpolation.
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.
vec3.cpp calls sqrt() without #include <cmath> — compiled via a stray transitive include.#include <cmath>. Re-ran, green.Fixed in commit 2a02307 ↗

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.
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.
findLowestNeighbor heap allocations per reset — one 64×64 grid, 10 thermal-erosion iterations.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.


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.

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.

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.