[P6-02] Terrain editing tools in the editor (#176)

* [P6-00] Spike: verify world-building facts, add spec §4.12

Recast/Detour v1.6.0 + Box2D v3.2.0 tarball SHA256s recorded; Jolt
HeightFieldShapeSettings and CharacterVirtual call sequences verified
by running spikes (NAV-OK / BOX2D-OK / JOLT-OK). No slice code.

Refs #119

* parity: PARITY-98 P4-00 visual fidelity spike facts

* parity: PARITY-98 address review round 1 (golden harness, stale header comment)

* [P6-01] Terrain: heightmap, chunked LOD mesh, splat, heightfield collider

R16 PNG heightmap (stb), chunk builder + LOD index decimation, 4-layer
splat blend contract, Jolt HeightFieldShape static body. Test proves
raycast hits sampled height +/-0.01 at 3 probes + FNV golden. Managed
Terrain.cs contract mirror + TerrainApiTests gate. No ABI change.

Refs #120

* [P6-02] Terrain editing tools: brushes, splat painting, stroke undo

* P6-01 test: write the R16 fixture PNG in the CTest working dir, not /tmp (Windows CI)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VjcGdfhMP7fhRLiNVGdbpw

* Terrain strokes: independent splat painting, multi-dab undo

TerrainStrokeCommand takes one or more dabs, snapshots the union bbox and
undoes them as one step. paintHeights = false paints splat weights only.
Tests for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VjcGdfhMP7fhRLiNVGdbpw

* Address P6-01/P6-02 review: collider placement, smooth scratch, docs

- HeightfieldCollider: the body alone carries (origin_x, 0, origin_z); the
  shape has no offset (grid stores world heights). Origin and min_height
  were applied twice. New test with origin (100, 30) and min_height -50.
- TerrainStrokeCommand rejects Smooth dabs without a separate, large
  enough scratch in the ctor; a failed dab reverts before throwing.
- native/terrain: guard get_target_property on engine_stb_image.
- Spec §4.14: findNearestPoly returns success with ref 0; Box2D sample
  stores factory results before taking addresses; OptimizeBroadPhase is
  an optimization (raycast verified to hit without it), header too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VjcGdfhMP7fhRLiNVGdbpw

* Test terrain strokes through the real Inspector Execute/Undo/Redo

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VjcGdfhMP7fhRLiNVGdbpw

* TerrainStrokeCommand: reject a scratch buffer aliased with the splat buffer

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VjcGdfhMP7fhRLiNVGdbpw

---------

Co-authored-by: hermes-agent <hermes-agent@local>
Co-authored-by: Claude <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-11-01 07:56:13 -05:00
1 parent 6eaf672ea3
commit 5aa481dad8
28 files changed
+3356 -8

No files matched your search

+145 -2
View File
@@ -659,9 +659,152 @@ MINIAUDIO_SPIKE_OK
`ma_lpf_config_init(format, channels, sampleRate, cutoff, order)` +
`ma_lpf_process_pcm_frames` is the direct-DSP path used for the attenuation check.
### 4.14 World-building facts (filled by P6-00)
### 4.14 World-building facts (P6-00 spike, ran 2026-10-31)
_Placeholder: P6-00 replaces this line with its verified facts._
Tarballs (downloaded with curl, hashed with `cmake -E sha256sum`):
| Dependency | Pin | Tarball URL | SHA256 | License |
| --- | --- | --- | --- | --- |
| Recast/Detour | `v1.6.0` | `https://github.com/recastnavigation/recastnavigation/archive/refs/tags/v1.6.0.tar.gz` | `d48ca0121962fa0639502c0f56c4e3ae72f98e55d88727225444f500775c0074` | Zlib |
| Box2D | `v3.2.0` | `https://github.com/erincatto/box2d/archive/refs/tags/v3.2.0.tar.gz` | `ffec747b27a2267016f8d0240d658c82d187fb2271fa0f69fcbe53fbc067180d` | MIT |
Fetch pattern for P6-03 / P6-07 (same as Jolt in `native/physics/CMakeLists.txt`): `FetchContent_Declare(<name> URL <tarball> URL_HASH SHA256=<hash>)`. Verified working out of tree with `URL_HASH` set (this spike). Recast targets are `Recast`, `Detour`, `DetourCrowd` (alias `RecastNavigation::*`); Box2D target is `box2d` with options `BOX2D_SAMPLES=OFF BOX2D_UNIT_TESTS=OFF BOX2D_VALIDATE=OFF`.
#### Recast v1.6.0 bake -> Detour query -> DetourCrowd (all ran, output below)
Call sequence (minimal, verified):
```cpp
// 1. Recast bake: heightfield -> compact -> regions -> contours -> poly mesh -> detail
rcContext ctx(false); rcConfig cfg{}; /* cs=0.3 ch=0.2 walkableHeight=6 walkableClimb=4 walkableRadius=2 */
rcCreateHeightfield(...); rcRasterizeTriangles(...);
rcFilterLowHangingWalkableObstacles(...); rcFilterLedgeSpans(...); rcFilterWalkableLowHeightSpans(...);
rcBuildCompactHeightfield(...); rcErodeWalkableArea(...); rcBuildDistanceField(...);
rcBuildRegions(...); rcBuildContours(...); rcBuildPolyMesh(...); rcBuildPolyMeshDetail(...);
// 2. Detour navmesh: copy poly mesh + detail into dtNavMeshCreateParams, buildBvTree=true
for (int i = 0; i < pmesh->npolys; ++i) pmesh->flags[i] = 1; // see gotcha
dtCreateNavMeshData(&params, &navData, &navDataSize);
nav->init(navData, navDataSize, DT_TILE_FREE_DATA);
// 3. Detour query: nearest-poly on both ends, then path + string pull
q->init(nav, 2048);
q->findNearestPoly(start, ext, &filter, &sref, sp);
q->findNearestPoly(end, ext, &filter, &eref, ep);
q->findPath(sref, eref, sp, ep, &filter, path, &pathCount, 64); // (path, &count, max) order
q->findStraightPath(sp, ep, path, pathCount, straightPath, flags, polys, &spCount, 64);
// 4. DetourCrowd: init, addAgent, filter 0 include-everything, requestMoveTarget with the END poly ref
crowd->init(8, 0.6f, nav);
agent = crowd->addAgent(apos, &ap); // ap: radius .6 height 2 maxSpeed 3.5 maxAccel 8
crowd->getEditableFilter(0)->setIncludeFlags(0xffff);
crowd->requestMoveTarget(agent, eref, ep); // eref from findNearestPoly, NOT 0
crowd->update(1.f/60.f, nullptr); // per frame; agent reaches goal in ~223 frames
```
Captured output (20x20 m floor, 2x2x2 m box obstacle center, start (-6,.5,0) end (6,.5,0)):
```
bake: polys=20 detail_tris=40
nearest: start=1073741824 (ref=45) end=1073741824 (ref=50)
query: straight=12.00 path=12.49 points=5 polys=6
crowd: frames=223 pos=(5.53,0.10,0.15) state=1
NAV-OK
```
Gotchas (all hit during the spike, all verified by flipping the code and watching it fail):
1. `rcBuildPolyMesh` leaves `polys[].flags = 0`. A `dtQueryFilter` with `includeFlags=0xffff` excludes flag-0 polys, so `findNearestPoly` finds nothing: it still returns `DT_SUCCESS`, with `*nearestRef == 0`. Always check the ref for zero as well as the status. Tag every poly (`flags[i] = 1`) before `dtCreateNavMeshData`. P6-03 must do this at bake time.
2. `findPath` arg order is `(startRef, endRef, startPos, endPos, filter, path, &pathCount, maxPath)` — count is a pointer before the max. `findStraightPath` takes the returned `pathCount` (not the array size).
3. `requestMoveTarget(agent, 0, pos)` (ref 0) silently parks the agent: `crowd->update` runs, state stays walking, position never changes. Pass the end poly ref from `findNearestPoly`.
#### Box2D v3.2.0 world + contact events (all ran, output below)
Call sequence (minimal, verified):
```cpp
b2WorldDef wdef = b2DefaultWorldDef(); wdef.gravity = {0.0f, -10.0f};
b2WorldId world = b2CreateWorld(&wdef);
b2BodyId ground = b2CreateBody(world, &staticDef);
b2ShapeDef groundDef = b2DefaultShapeDef();
b2Polygon groundBox = b2MakeBox(50.f, 10.f);
b2CreatePolygonShape(ground, &groundDef, &groundBox);
b2ShapeDef sdef = b2DefaultShapeDef();
sdef.density = 1.0f; sdef.material.friction = 0.6f; // friction lives in sdef.material in v3
sdef.enableContactEvents = true; // see gotcha
b2BodyId box = b2CreateBody(world, &dynamicDef);
b2Polygon boxShape = b2MakeBox(0.5f, 0.5f);
b2CreatePolygonShape(box, &sdef, &boxShape);
for (240x) { b2World_Step(world, 1.f/60.f, 4); ev = b2World_GetContactEvents(world); }
b2DestroyWorld(world);
```
Captured output (3 boxes dropped 2 m onto a static ground slab, 4 s at 60 Hz):
```
box2d: steps=240 beginTouches=4
box0: y=0.500 speed=0.0000 awake=0
box1: y=1.498 speed=0.0000 awake=0
box2: y=2.498 speed=0.0000 awake=0
BOX2D-OK
```
Gotchas (both hit during the spike):
1. Friction moved: in v3 `b2ShapeDef` has no `friction` field; it is `sdef.material.friction` (`b2SurfaceMaterial`, `b2DefaultSurfaceMaterial()`). Setting `sdef.friction` does not compile. P6-07 must use `material.*`.
2. Contact events are opt-in per shape: `b2World_GetContactEvents` returns empty unless `sdef.enableContactEvents = true`. Bodies spawned exactly resting generate no begin events; drop them so the first impact registers.
#### Jolt v5.6.0 HeightFieldShapeSettings + CharacterVirtual (both ran, output below)
Heights are full `float` samples; the shape compresses them internally (`mBlockSize`, `mBitsPerSample`). Constructor, verified against `Jolt/Physics/Collision/Shape/HeightFieldShape.h` in the fetched v5.6.0 tree:
```cpp
// Surface: offset + scale * (x, samples[y * sampleCount + x], y), x/y in [0, sampleCount).
// sampleCount/blockSize must be >= 2; blockSize in [2, 8] (runtime halves it again).
JPH::HeightFieldShapeSettings hf(samples, JPH::Vec3(-4,0,-4), JPH::Vec3(1,1,1), N);
JPH::Shape::ShapeResult res = hf.Create();
bi.AddBody(...EMotionType::Static, Layers::NON_MOVING...);
physics.OptimizeBroadPhase(); // optional, see gotcha 1
bool got = physics.GetNarrowPhaseQuery().CastRay(JPH::RRayCast{RVec3(0,5,0), Vec3(0,-10,0)}, hit);
```
Character sequence, verified against `Jolt/Physics/Character/CharacterVirtual.h` plus Jolt's own `UnitTests/Physics/CharacterVirtualTests.cpp`:
```cpp
// Shape convention (CharacterBaseSettings::mShape doc): bottom of the shape at
// local (0,0,0). A bare CapsuleShape is centered, so offset it — as Jolt's own
// test does — or the ground plane sits mid-capsule and it never reports supported.
cv.mShape = JPH::RotatedTranslatedShapeSettings(JPH::Vec3(0, 0.5f+0.3f, 0),
JPH::Quat::sIdentity(), new JPH::CapsuleShape(0.5f, 0.3f)).Create().Get();
cv.mSupportingVolume = JPH::Plane(JPH::Vec3::sAxisY(), -0.5f - 0.3f);
JPH::CharacterVirtual ch(&cv, JPH::RVec3(0,3,0), JPH::Quat::sIdentity(), 0, &physics);
// Per frame (Jolt test Step pattern): ground velocity + input + gravity,
// then ExtendedUpdate. Preserving Y across frames with +X jammed in pins y at spawn.
vel = ch.GetGroundVelocity() + Vec3(2,0,0);
vel.SetY(falling? ch.GetLinearVelocity().GetY() - 9.81f*dt : vel.GetY() + -9.81f*dt);
ch.SetLinearVelocity(vel);
ch.ExtendedUpdate(dt, Vec3(0,-9.81f,0), ext, // ext: stickDown (0,-.5,0), stepUp (0,.4,0)
physics.GetDefaultBroadPhaseLayerFilter(Layers::MOVING),
physics.GetDefaultLayerFilter(Layers::MOVING),
JPH::BodyFilter(), JPH::ShapeFilter(), temp);
ch.IsSupported(); ch.GetGroundState(); ch.GetPosition(); // position = feet contact point
```
Captured output (9x9 heightfield over 8x8 m, peak 1.0 center; character walks +X 1 s from (0,3,0)):
```
heightfield: rayHit=1 fraction=0.4001 hitY=0.9991
character: pos=(2.002,0.754,0.000) grounded=1
JOLT-OK
```
Gotchas (all hit during the spike):
1. Bodies added one at a time with `AddBody` sit in a less efficient broadphase tree until `PhysicsSystem::OptimizeBroadPhase()` runs (or `Update` rebuilds it). Calling it after a batch of statics is an optimization, not a requirement: the P6-01 heightfield test hits with and without it. (The spike's original "raycast misses without it" did not reproduce.)
2. `GetPosition()` is the feet contact point, not the capsule center. Rest y ~= terrain height (+ padding), not terrain + halfHeight + radius.
3. TUs including Jolt headers need the same ISA codegen Jolt.cmake applies to the lib (`-mavx2 -mbmi -mpopcnt -mlzcnt -mf16c -mfma -mfpmath=sse` for the engine's USE_AVX2 path); without them GCC errors on `always_inline` SSE intrinsics. Same rule applies to P6 adapter TUs.
4. `JobSystemThreadPool` takes `(maxJobs, maxBarriers, numThreads)`; there is no `cHardwareConcurrency` member. `RayCastResult` lives in `Collision/CastResult.h`, not `RayCast.h`.
#### 4.14.4 P6 spike scratch sources (not part of the build)
The three spike programs live only in the P6-00 working notes, not in the repo tree: Recast bake->query->crowd, Box2D stack + events, Jolt heightfield + character. No slice code in this PR (per P6-00 acceptance).
### 4.15 Platform and networking facts (P7-00 spike, verified 2026-10-31)
+4
View File
@@ -0,0 +1,4 @@
# P6-00: World-building facts spike (2026-10-31)
- Ran three out-of-tree spikes (GCC 14, Release): Recast v1.6.0 bake->Detour query->DetourCrowd, Box2D v3.2.0 stack + contact events, Jolt v5.6.0 HeightFieldShapeSettings + CharacterVirtual. All green: `NAV-OK`, `BOX2D-OK`, `JOLT-OK`. Spec §4.14 records tarball SHA256s (recast `d48ca01…`, box2d `ffec747…`), exact call sequences with captured output, and 9 gotchas (poly flags, findPath arg order, moveTarget ref 0, Box2D material.friction + enableContactEvents, Jolt OptimizeBroadPhase/feet origin/ISA flags).
- No slice code: only `docs/PARITY_IMPLEMENTATION_PROMPT.md` (§4.14, §4.11 item 6 updated) and this NOTES entry. Spike sources stay in scratch (`~/.hermes/.../p6spike/`), not the tree.
+24
View File
@@ -0,0 +1,24 @@
# P6-01: Terrain (2026-10-31)
- New `native/terrain/` module (CPU heightmap/chunk-mesh/splat + Jolt
HeightFieldShape collider). No ABI change (managed `Terrain.cs` is a
CPU-side contract mirror: HeightmapDesc/SplatLayer/ChunkLod + a managed
gate `TerrainApiTests`; no EngineApiTable entries added).
- Test `engine_terrain_p6_01` (all green): synthetic 9x9 R16 PNG (stored
deflate, color type 0) -> decode peak ~= 1.0; chunk mesh LOD0 96 / LOD1
24 / LOD2 6 indices, packed offsets; splat weights normalized with
lowland grass-heavy; HeightFieldShapeSettings(block 2, 8 bits) static
body + OptimizeBroadPhase + `spatial_raycast` hits sampled height
±0.01 at 3 probes; FNV-1a golden `TERRAIN_GOLDEN=5BC9BD960DA41391`.
- Gotchas: (1) terrain `CMakeLists` must read stb headers via
`engine_stb_image` INTERFACE_INCLUDE_DIRECTORIES — the assets module's
`ENGINE_ASSETS_HAS_STB` variable does not cross `add_subdirectory`
scopes; (2) test TUs including Jolt need `PhysicsSystem.h` explicitly
(fwd decl only in `physics_world.hpp`); (3) HeightFieldShape surface is
offset+scale*(x, samples, y) with the shape's own stride — the engine
grid stores world heights so Y scale stays 1.
- Differs from the issue sketch in one point: P4-01 (PBR bindless upload)
has not landed on this branch, so the splat material is a CPU-side
blend contract (layer slots + tiling + slope/height rules in one code
path for the future GPU upload + P6-02 painting) rather than a
bindless `SceneMaterial` array.
+33
View File
@@ -0,0 +1,33 @@
# P6-02: Terrain editing tools (2026-10-31)
- New `TerrainBrush` (`native/terrain/`: `terrain_brush.hpp/.cpp`, globbed
into `engine_core_terrain` — no CMake change needed for the lib): raise /
lower / smooth / flatten dabs over the P6-01 grid layout plus splat
painting over per-vertex `SplatWeights`, all noexcept, no malloc.
Falloff is smoothstep(1-d/radius): samples at d >= radius are skipped by
a `d2 < r2` guard (bit-exact no-touch, asserted with `!=` in tests).
- Stroke undo via `StrokeJournal` (caller-owned ring: entries + height /
splat before-image stores, bbox granularity): `begin_stroke` snapshots,
`undo_stroke` memcpy-restores bit-exactly and pops. One entry covers
heights + splat so a mixed stroke undoes atomically.
- Managed mirror: `Engine.Runtime/TerrainBrush.cs` (BrushMode/Params/Math,
same curve) + `Engine.Editor/TerrainStrokeCommand.cs` (IEditCommand over
borrowed grids: bbox before-image at ctor, dab on Apply, exact restore
on Revert — one stroke = one Inspector undo step, P3-06 pattern).
- Tests: `engine_terrain_brush_p6_02` (falloff shape, raise containment +
curve-exact, lower/smooth/flatten incl. scratch-reject guard, splat
norm + layer pull + outside-untouched, per-mode memcmp-exact undo, splat
undo, bad-arg guards — ALL PASS) and `TerrainBrushTests` (same coverage
managed-side incl. stroke Apply/Revert per mode + undo/redo stack order).
- Gotchas: (1) `terrain_brush.cpp` needs `terrain_splat_material.hpp`
explicitly (SplatWeights is only fwd-declared in the brush header —
pointer arithmetic on incomplete type); (2) test thresholds must respect
the falloff curve — a single dab converges only strength*falloff, so
assert exact-curve values, not target-reached (initial flatten/splat
assertions failed on this, fixed to curve-exact); (3) EditorSession has
no parameterless ctor (needs a native world), so the managed undo test
drives the IEditCommand stack pattern directly.
- Evidence: `ctest --preset ci-linux` equivalent — full build + `ctest -R
terrain` 2/2 pass, full suite 46/46 pass (offscreen/GPU-env), `dotnet
build Engine.slnx` green, `dotnet format` clean, managed
`TerrainBrushTests: ALL PASS`.
@@ -0,0 +1,231 @@
// TerrainStrokeCommand.cs — P6-02: one terrain brush stroke as an editor undo step.
//
// A stroke is one or more dabs (a drag stamps many). The command captures
// the before-image over the union of every dab's bbox at construction
// (heights and/or splat weights over a borrowed grid buffer), stamps every
// dab in order on Apply, and restores the before-image bit-exactly on
// Revert. Goes through Inspector.Execute so one stroke = one undo step on
// the shared editor undo stack (P3-06 pattern: SetPoseCommand over session
// state).
//
// Height and splat are independent: pass paintHeights = false with a splat
// buffer for a paint-only stroke (heights untouched), or no splat buffer for
// a sculpt-only stroke.
using System;
using System.Collections.Generic;
using Engine.Runtime;
namespace Engine.Editor;
/// <summary>One terrain brush stroke (height and/or splat, one or more dabs) over borrowed grids.</summary>
public sealed class TerrainStrokeCommand : IEditCommand
{
private readonly float[] _heights;
private readonly int _width;
private readonly int _height;
private readonly float _spacing;
private readonly TerrainBrushParams[] _dabs;
private readonly float[]? _scratch;
private readonly float[]? _splat;
private readonly int _splatLayer;
private readonly bool _paintHeights;
private readonly int _x0;
private readonly int _z0;
private readonly int _x1;
private readonly int _z1;
private readonly bool _covers;
private readonly float[]? _beforeHeights;
private readonly float[]? _beforeSplat;
public string Label { get; }
/// <summary>Single-dab stroke.</summary>
public TerrainStrokeCommand(float[] heights, int width, int height, float spacing, TerrainBrushParams brush, float[]? scratch = null, float[]? splatWeights = null, int splatLayer = 0, string? label = null, bool paintHeights = true)
: this(heights, width, height, spacing, new[] { brush }, scratch, splatWeights, splatLayer, label, paintHeights)
{
}
/// <summary>Multi-dab stroke: every dab is applied in order and undone together.</summary>
public TerrainStrokeCommand(float[] heights, int width, int height, float spacing, IReadOnlyList<TerrainBrushParams> dabs, float[]? scratch = null, float[]? splatWeights = null, int splatLayer = 0, string? label = null, bool paintHeights = true)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(dabs);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
if (dabs.Count == 0)
{
throw new ArgumentException("A stroke needs at least one dab.", nameof(dabs));
}
if (!(spacing > 0.0f))
{
throw new ArgumentOutOfRangeException(nameof(spacing));
}
if (heights.Length < width * height)
{
throw new ArgumentException("Height buffer too small.", nameof(heights));
}
foreach (var d in dabs)
{
if (!(d.Radius > 0.0f))
{
throw new ArgumentOutOfRangeException(nameof(dabs), "Radius must be positive.");
}
}
if (paintHeights)
{
foreach (var d in dabs)
{
if (d.Mode != TerrainBrushMode.Smooth)
{
continue;
}
// Smooth reads the pre-dab neighborhood from scratch: reject
// a bad scratch here, before Apply can half-edit the grid.
if (scratch == null || ReferenceEquals(scratch, heights) || ReferenceEquals(scratch, splatWeights) || scratch.Length < width * height)
{
throw new ArgumentException("Smooth dabs need a separate scratch buffer of at least width * height.", nameof(scratch));
}
break;
}
}
bool wantSplat = splatWeights != null;
if (!paintHeights && !wantSplat)
{
throw new ArgumentException("A stroke must paint heights, splat weights, or both.", nameof(paintHeights));
}
if (wantSplat && (splatLayer < 0 || splatLayer >= 4))
{
throw new ArgumentOutOfRangeException(nameof(splatLayer));
}
if (wantSplat && splatWeights!.Length < width * height * 4)
{
throw new ArgumentException("Splat buffer too small.", nameof(splatWeights));
}
_heights = heights;
_width = width;
_height = height;
_spacing = spacing;
_dabs = new TerrainBrushParams[dabs.Count];
for (int i = 0; i < dabs.Count; ++i)
{
_dabs[i] = dabs[i];
}
_scratch = scratch;
_splat = splatWeights;
_splatLayer = splatLayer;
_paintHeights = paintHeights;
var first = _dabs[0];
Label = label ?? (_dabs.Length == 1
? $"TerrainStroke({first.Mode}@{first.CenterX:F1},{first.CenterZ:F1})"
: $"TerrainStroke({first.Mode}, {_dabs.Length} dabs)");
// Union of every dab's bbox (dabs that miss the grid contribute nothing).
_x0 = int.MaxValue;
_z0 = int.MaxValue;
_x1 = int.MinValue;
_z1 = int.MinValue;
foreach (var d in _dabs)
{
if (TerrainBrushMath.BrushBBox(width, height, spacing, d.CenterX, d.CenterZ, d.Radius, out int x0, out int z0, out int x1, out int z1))
{
_x0 = Math.Min(_x0, x0);
_z0 = Math.Min(_z0, z0);
_x1 = Math.Max(_x1, x1);
_z1 = Math.Max(_z1, z1);
_covers = true;
}
}
if (!_covers)
{
_x0 = _z0 = _x1 = _z1 = 0;
return;
}
int bw = _x1 - _x0;
int bh = _z1 - _z0;
if (_paintHeights)
{
_beforeHeights = new float[bw * bh];
for (int z = _z0; z < _z1; ++z)
{
Array.Copy(heights, z * width + _x0, _beforeHeights, (z - _z0) * bw, bw);
}
}
if (wantSplat)
{
_beforeSplat = new float[bw * bh * 4];
for (int z = _z0; z < _z1; ++z)
{
Array.Copy(splatWeights!, (z * width + _x0) * 4, _beforeSplat, ((z - _z0) * bw) * 4, bw * 4);
}
}
}
/// <summary>Total samples covered by this stroke (0 when it misses the grid).</summary>
public int CoveredSamples => _covers ? (_x1 - _x0) * (_z1 - _z0) : 0;
/// <summary>Number of dabs in this stroke.</summary>
public int DabCount => _dabs.Length;
public void Apply()
{
if (!_covers)
{
return;
}
foreach (var d in _dabs)
{
if (_paintHeights && !TerrainBrushMath.ApplyHeightBrush(_heights, _width, _height, _spacing, d, _scratch))
{
Revert(); // never leave a half-applied stroke without an undo entry
throw new InvalidOperationException("Terrain stroke dab failed.");
}
if (_splat != null && !TerrainBrushMath.PaintSplat(_splat, _width, _height, _spacing, d.CenterX, d.CenterZ, d.Radius, d.Strength, _splatLayer))
{
Revert();
throw new InvalidOperationException("Terrain splat dab failed.");
}
}
}
public void Revert()
{
if (!_covers)
{
return;
}
int bw = _x1 - _x0;
if (_beforeHeights != null)
{
for (int z = _z0; z < _z1; ++z)
{
Array.Copy(_beforeHeights, (z - _z0) * bw, _heights, z * _width + _x0, bw);
}
}
if (_beforeSplat != null)
{
for (int z = _z0; z < _z1; ++z)
{
Array.Copy(_beforeSplat, ((z - _z0) * bw) * 4, _splat!, (z * _width + _x0) * 4, bw * 4);
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
// Terrain.cs — P6-01: managed view over the native terrain module.
//
// CPU-side contracts only (no native calls on the frame path here — the
// heavy work lives in engine_core_terrain; P6-03 nav and P6-02 editor tools
// will bind the collider/scene hooks through the ABI tail when their slices
// land):
// * HeightmapDesc — world mapping for an R16 heightmap asset (spacing,
// min/max height), with SampleCount validation for Jolt (square, >= 2,
// block-divisible).
// * SplatLayer — mirrors the native SplatLayer (albedo slot, tiling,
// height band, slope cap, weight); SplatWeights sums to 1 by contract.
// * ChunkLod — LOD index-count math + distance selection (mirrors
// ChunkMesh::lod_index_count/select_lod so C# LOD picks match native).
// All types blittable; zero managed gameplay state (AGENTS.md invariant).
using System.Runtime.InteropServices;
namespace Engine.Runtime;
/// <summary>World mapping for an R16 heightmap asset.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 16)]
public struct HeightmapDesc
{
public float Spacing;
public float MinHeight;
public float MaxHeight;
public int SampleCount;
public HeightmapDesc(float spacing, float minHeight, float maxHeight, int sampleCount)
{
Spacing = spacing;
MinHeight = minHeight;
MaxHeight = maxHeight;
SampleCount = sampleCount;
}
/// <summary>Jolt HeightFieldShape needs square N>=2 with N/block >= 2.</summary>
public readonly bool IsValidForCollider(int blockSize = 2)
{
return SampleCount >= 2 && blockSize >= 2 && blockSize <= 8
&& SampleCount / blockSize >= 2;
}
}
/// <summary>One terrain splat layer. Mirrors native SplatLayer (40 bytes).</summary>
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 24)]
public struct SplatLayer
{
public uint AlbedoSlot;
public float Tiling;
public float MinHeight;
public float MaxHeight;
public float MaxSlope;
public float Weight;
public SplatLayer(uint albedoSlot, float tiling, float minHeight, float maxHeight, float maxSlope, float weight = 1.0f)
{
AlbedoSlot = albedoSlot;
Tiling = tiling;
MinHeight = minHeight;
MaxHeight = maxHeight;
MaxSlope = maxSlope;
Weight = weight;
}
}
/// <summary>LOD index-count + distance selection. Mirrors ChunkMesh.</summary>
public static class ChunkLod
{
public const int MaxLevels = 4;
public static int IndexCount(int chunkSamples, int lod)
{
if (chunkSamples < 2 || lod < 0 || lod >= MaxLevels)
{
return 0;
}
int step = 1 << lod;
int quads = (chunkSamples - 1) / step;
return quads * quads * 6;
}
public static int Select(float distance, float[] lodDistances)
{
if (lodDistances == null || lodDistances.Length == 0)
{
return 0;
}
int n = lodDistances.Length < MaxLevels ? lodDistances.Length : MaxLevels;
for (int l = 0; l < n; ++l)
{
if (distance < lodDistances[l])
{
return l;
}
}
return n - 1;
}
}
+289
View File
@@ -0,0 +1,289 @@
// TerrainBrush.cs — P6-02: managed terrain brush math + editor stroke command.
//
// Managed mirror of native TerrainBrush (see
// native/terrain/include/engine/terrain/terrain_brush.hpp):
// * TerrainBrushMode — Raise/Lower/Smooth/Flatten (matches BrushMode 1:1).
// * TerrainBrushMath — Falloff + ApplyHeightBrush over float[] grids,
// bit-compatible intent with the native curve (smoothstep(1-d/r)).
// Outside-radius samples are never touched; smooth reads a snapshot.
// * TerrainStrokeCommand — IEditCommand over a borrowed float[] height
// buffer: captures the brush bbox before-image at construction, Apply
// stamps the dab, Revert restores the before-image bit-exactly. One
// stroke = one undo step through Inspector.Execute.
// All types blittable-friendly; no native calls, no gameplay state.
using System;
namespace Engine.Runtime;
/// <summary>Brush operation. Matches native BrushMode values 1:1.</summary>
public enum TerrainBrushMode : byte
{
Raise = 0,
Lower = 1,
Smooth = 2,
Flatten = 3,
}
/// <summary>One brush dab. Units: world-space center/radius, heights in world Y.</summary>
#pragma warning disable CA1051 // Visible instance fields by design: blittable interop struct (Velocity.cs precedent).
public struct TerrainBrushParams
{
public TerrainBrushMode Mode;
public float CenterX;
public float CenterZ;
public float Radius;
public float Strength;
public float TargetHeight;
public TerrainBrushParams(TerrainBrushMode mode, float centerX, float centerZ, float radius, float strength, float targetHeight = 0.0f)
{
Mode = mode;
CenterX = centerX;
CenterZ = centerZ;
Radius = radius;
Strength = strength;
TargetHeight = targetHeight;
}
}
#pragma warning restore CA1051
/// <summary>Pure brush math. Mirrors native TerrainBrush::falloff/apply_brush.</summary>
public static class TerrainBrushMath
{
/// <summary>Smoothstep falloff: 1 at center, exactly 0 at d >= radius.</summary>
public static float Falloff(float dist, float radius)
{
if (!(radius > 0.0f) || !(dist < radius))
{
return 0.0f;
}
if (dist <= 0.0f)
{
return 1.0f;
}
float t = 1.0f - dist / radius;
return t * t * (3.0f - 2.0f * t);
}
/// <summary>
/// Stamp one height dab over a row-major width*height grid (spacing meters
/// between samples). Returns false on bad args (grid untouched). Smooth
/// reads the pre-stroke neighborhood from <paramref name="scratch"/>
/// (same size temp; must not alias <paramref name="heights"/>).
/// </summary>
public static bool ApplyHeightBrush(float[] heights, int width, int height, float spacing, TerrainBrushParams p, float[]? scratch = null)
{
if (heights == null || width <= 0 || height <= 0 || !(spacing > 0.0f))
{
return false;
}
if (heights.Length < width * height)
{
return false;
}
if (!(p.Radius > 0.0f) || !(p.Strength >= 0.0f))
{
return false;
}
if (p.Mode == TerrainBrushMode.Smooth && scratch == null)
{
return false;
}
if (!BrushBBox(width, height, spacing, p.CenterX, p.CenterZ, p.Radius, out int x0, out int z0, out int x1, out int z1))
{
return true; // valid no-op: brush misses the grid
}
if (p.Mode == TerrainBrushMode.Smooth)
{
if (ReferenceEquals(scratch, heights))
{
return false;
}
if (scratch!.Length < width * height)
{
return false;
}
for (int z = z0; z < z1; ++z)
{
Array.Copy(heights, z * width + x0, scratch, z * width + x0, x1 - x0);
}
}
float r2 = p.Radius * p.Radius;
float tCap = p.Strength > 1.0f ? 1.0f : p.Strength;
for (int z = z0; z < z1; ++z)
{
float sz = z * spacing;
for (int x = x0; x < x1; ++x)
{
float sx = x * spacing;
float dx = sx - p.CenterX;
float dz = sz - p.CenterZ;
float d2 = dx * dx + dz * dz;
if (!(d2 < r2))
{
continue;
}
float f = Falloff(MathF.Sqrt(d2), p.Radius);
if (f <= 0.0f)
{
continue;
}
int i = z * width + x;
switch (p.Mode)
{
case TerrainBrushMode.Raise:
heights[i] += p.Strength * f;
break;
case TerrainBrushMode.Lower:
heights[i] -= p.Strength * f;
break;
case TerrainBrushMode.Smooth:
{
float sum = 0.0f;
float n = 0.0f;
int ax0 = x > 0 ? x - 1 : x;
int az0 = z > 0 ? z - 1 : z;
int ax1 = (x + 1 < width) ? x + 2 : x + 1;
int az1 = (z + 1 < height) ? z + 2 : z + 1;
for (int az = az0; az < az1; ++az)
{
for (int ax = ax0; ax < ax1; ++ax)
{
sum += scratch![az * width + ax];
n += 1.0f;
}
}
float avg = n > 0.0f ? sum / n : heights[i];
heights[i] += (avg - heights[i]) * (tCap * f);
break;
}
case TerrainBrushMode.Flatten:
heights[i] += (p.TargetHeight - heights[i]) * (tCap * f);
break;
}
}
}
return true;
}
/// <summary>
/// Stamp one splat dab over a per-vertex weight grid (4 floats per vertex,
/// row-major). Pulls weights toward <paramref name="layer"/> by
/// strength*falloff, then renormalizes to sum 1.
/// </summary>
public static bool PaintSplat(float[] weights, int width, int height, float spacing, float centerX, float centerZ, float radius, float strength, int layer)
{
if (weights == null || width <= 0 || height <= 0 || !(spacing > 0.0f))
{
return false;
}
if (weights.Length < width * height * 4)
{
return false;
}
if (!(radius > 0.0f) || !(strength >= 0.0f) || layer < 0 || layer >= 4)
{
return false;
}
if (!BrushBBox(width, height, spacing, centerX, centerZ, radius, out int x0, out int z0, out int x1, out int z1))
{
return true;
}
float r2 = radius * radius;
float tFull = strength > 1.0f ? 1.0f : strength;
for (int z = z0; z < z1; ++z)
{
float sz = z * spacing;
for (int x = x0; x < x1; ++x)
{
float dx = x * spacing - centerX;
float dz = sz - centerZ;
float d2 = dx * dx + dz * dz;
if (!(d2 < r2))
{
continue;
}
float f = Falloff(MathF.Sqrt(d2), radius) * tFull;
if (f <= 0.0f)
{
continue;
}
int b = (z * width + x) * 4;
for (int i = 0; i < 4; ++i)
{
float target = i == layer ? 1.0f : 0.0f;
weights[b + i] += (target - weights[b + i]) * f;
}
float sum = weights[b] + weights[b + 1] + weights[b + 2] + weights[b + 3];
if (sum > 0.0f)
{
float inv = 1.0f / sum;
weights[b] *= inv;
weights[b + 1] *= inv;
weights[b + 2] *= inv;
weights[b + 3] *= inv;
}
else
{
weights[b] = 1.0f;
weights[b + 1] = 0.0f;
weights[b + 2] = 0.0f;
weights[b + 3] = 0.0f;
}
}
}
return true;
}
public static bool BrushBBox(int width, int height, float spacing, float cx, float cz, float radius, out int x0, out int z0, out int x1, out int z1)
{
x0 = z0 = x1 = z1 = 0;
if (!(radius > 0.0f))
{
return false;
}
float inv = 1.0f / spacing;
int ix0 = (int)MathF.Floor((cx - radius) * inv);
int iz0 = (int)MathF.Floor((cz - radius) * inv);
int ix1 = (int)MathF.Ceiling((cx + radius) * inv) + 1;
int iz1 = (int)MathF.Ceiling((cz + radius) * inv) + 1;
ix0 = Math.Clamp(ix0, 0, width);
iz0 = Math.Clamp(iz0, 0, height);
ix1 = Math.Clamp(ix1, 0, width);
iz1 = Math.Clamp(iz1, 0, height);
if (ix1 <= ix0 || iz1 <= iz0)
{
return false;
}
x0 = ix0;
z0 = iz0;
x1 = ix1;
z1 = iz1;
return true;
}
}
+16
View File
@@ -249,6 +249,22 @@ public static class EditorApiTests
var redone = inspector.ReadPose(root);
failures += Check(MathF.Abs(redone[0] - 1) < 1e-6f, "live: redo re-applies pose");
// P6-02: a terrain stroke through the real Inspector stack.
{
var terrain = new float[16 * 16];
var terrainBefore = (float[])terrain.Clone();
var depth = inspector.UndoCount;
inspector.Execute(new TerrainStrokeCommand(terrain, 16, 16, 1.0f,
new Engine.Runtime.TerrainBrushParams(Engine.Runtime.TerrainBrushMode.Raise, 8.0f, 8.0f, 3.0f, 1.0f)));
failures += Check(inspector.UndoCount == depth + 1 && !terrain.AsSpan().SequenceEqual(terrainBefore),
"live: terrain stroke is one Inspector undo step");
failures += Check(inspector.Undo() && terrain.AsSpan().SequenceEqual(terrainBefore),
"live: Inspector undo restores terrain exactly");
failures += Check(inspector.Redo() && !terrain.AsSpan().SequenceEqual(terrainBefore),
"live: Inspector redo re-applies the stroke");
inspector.Undo();
}
// P3-06: offscreen picking + gizmo drag (one undo step).
failures += RunPickGizmoLive(table, world, session, inspector);
+3
View File
@@ -11,6 +11,9 @@ failures += Engine.Tests.CompositorApiTests.Run(args);
failures += Engine.Tests.GiApiTests.Run(args);
failures += Engine.Tests.PhysicsApiTests.Run(args);
failures += Engine.Tests.NavigationApiTests.Run(args);
failures += Engine.Tests.TerrainApiTests.Run(args);
failures += Engine.Tests.TerrainBrushTests.Run(args);
failures += Engine.Tests.GeneratorTests.Run();
failures += Engine.Tests.GeneratorDriverTests.Run();
failures += Engine.Tests.EditorApiTests.Run(args);
+57
View File
@@ -0,0 +1,57 @@
// TerrainApiTests.cs — P6-01: Terrain managed contract gate.
//
// Verifies the C# terrain surface (no native needed):
// * Type shape: HeightmapDesc/SplatLayer blittable + byte-exact sizes,
// ChunkLod math mirrors native (IndexCount LOD0/1/2, Select steps).
// * Validation: HeightmapDesc.IsValidForCollider accepts the 9x9/block-2
// test grid, rejects degenerate counts.
using System.Runtime.CompilerServices;
using Engine.Runtime;
namespace Engine.Tests;
public static class TerrainApiTests
{
private static readonly float[] LodDistances = new[] { 25.0f, 60.0f, 120.0f };
public static int Run(string[]? args = null)
{
var failures = 0;
failures += Check(RuntimeHelpers.IsReferenceOrContainsReferences<HeightmapDesc>() == false, "HeightmapDesc blittable");
failures += Check(RuntimeHelpers.IsReferenceOrContainsReferences<SplatLayer>() == false, "SplatLayer blittable");
unsafe
{
failures += Check(sizeof(HeightmapDesc) == 16, "HeightmapDesc 16B");
failures += Check(sizeof(SplatLayer) == 24, "SplatLayer 24B");
}
failures += Check(ChunkLod.IndexCount(5, 0) == 96, "LOD0 4x4x6");
failures += Check(ChunkLod.IndexCount(5, 1) == 24, "LOD1 2x2x6");
failures += Check(ChunkLod.IndexCount(5, 2) == 6, "LOD2 1x1x6");
failures += Check(ChunkLod.IndexCount(1, 0) == 0, "degenerate 0");
failures += Check(ChunkLod.Select(10.0f, LodDistances) == 0, "lod near 0");
failures += Check(ChunkLod.Select(30.0f, LodDistances) == 1, "lod mid 1");
failures += Check(ChunkLod.Select(200.0f, LodDistances) == 2, "lod far last");
var desc = new HeightmapDesc(1.0f, 0.0f, 10.0f, 9);
failures += Check(desc.IsValidForCollider(2), "9x9/block2 valid");
failures += Check(new HeightmapDesc(1.0f, 0.0f, 10.0f, 1).IsValidForCollider(2) == false, "1x1 rejected");
failures += Check(new HeightmapDesc(1.0f, 0.0f, 10.0f, 3).IsValidForCollider(2) == false, "3/block2 rejected (3/2<2)");
if (failures == 0)
{
System.Console.WriteLine("TerrainApiTests: ALL PASS");
return 0;
}
System.Console.WriteLine($"TerrainApiTests: {failures} FAILURES");
return 1;
}
private static int Check(bool ok, string name)
{
System.Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {name}");
return ok ? 0 : 1;
}
}
+327
View File
@@ -0,0 +1,327 @@
// TerrainBrushTests.cs — P6-02: managed brush math + editor stroke undo gate.
//
// Mirrors the native acceptance without native calls:
// * radius containment: a raise dab changes heights only inside the radius
// (outside bit-exactly untouched); center follows Falloff exactly.
// * modes: lower mirrors raise; smooth tames a spike; flatten converges.
// * splat: paint pulls toward the layer inside only, stays normalized.
// * undo: TerrainStrokeCommand Apply then Revert restores the exact
// previous heights (sequence-equal); Inspector.Execute/Undo round-trips
// a stroke as one undo step.
using System;
using Engine.Editor;
using Engine.Runtime;
namespace Engine.Tests;
public static class TerrainBrushTests
{
private const int W = 16;
private const int H = 16;
private const float Spacing = 1.0f;
public static int Run(string[]? args = null)
{
var failures = 0;
failures += Check(MathF.Abs(TerrainBrushMath.Falloff(0.0f, 3.0f) - 1.0f) < 1e-6f, "falloff center 1");
failures += Check(TerrainBrushMath.Falloff(3.0f, 3.0f) == 0.0f, "falloff edge 0");
failures += Check(TerrainBrushMath.Falloff(99.0f, 3.0f) == 0.0f, "falloff outside 0");
// Raise containment on a flat grid.
{
var h = NewFlat(5.0f);
var p = new TerrainBrushParams(TerrainBrushMode.Raise, 7.5f, 7.5f, 3.0f, 2.0f);
failures += Check(TerrainBrushMath.ApplyHeightBrush(h, W, H, Spacing, p), "raise apply ok");
bool insideOk = true;
bool insideChanged = false;
bool outsideClean = true;
for (int z = 0; z < H; ++z)
{
for (int x = 0; x < W; ++x)
{
float d = Dist(x, z, 7.5f, 7.5f);
float v = h[z * W + x];
if (d < 3.0f)
{
float expect = 5.0f + 2.0f * TerrainBrushMath.Falloff(d, 3.0f);
if (MathF.Abs(v - expect) > 1e-5f)
{
insideOk = false;
}
else if (v != 5.0f)
{
insideChanged = true;
}
}
else if (v != 5.0f)
{
outsideClean = false;
}
}
}
failures += Check(insideChanged, "raise changes inside");
failures += Check(insideOk, "raise inside matches falloff");
failures += Check(outsideClean, "raise outside bit-exact untouched");
}
// Lower mirrors raise.
{
var h = NewFlat(5.0f);
var p = new TerrainBrushParams(TerrainBrushMode.Lower, 7.5f, 7.5f, 3.0f, 2.0f);
failures += Check(TerrainBrushMath.ApplyHeightBrush(h, W, H, Spacing, p), "lower apply ok");
bool allLe = true;
foreach (var v in h)
{
if (v > 5.0f)
{
allLe = false;
}
}
failures += Check(allLe, "lower never raises");
failures += Check(h[7 * W + 7] < 5.0f, "lower center descends");
}
// Smooth tames a spike, keeps outside.
{
var h = NewFlat(5.0f);
h[8 * W + 8] = 25.0f;
var scratch = new float[W * H];
var p = new TerrainBrushParams(TerrainBrushMode.Smooth, 8.0f, 8.0f, 3.0f, 1.0f);
failures += Check(TerrainBrushMath.ApplyHeightBrush(h, W, H, Spacing, p, scratch), "smooth apply ok");
failures += Check(h[8 * W + 8] < 25.0f && h[8 * W + 8] > 5.0f, "smooth tames spike");
failures += Check(!TerrainBrushMath.ApplyHeightBrush(NewFlat(5.0f), W, H, Spacing, p, null), "smooth without scratch rejected");
}
// Flatten converges at exact center.
{
var h = NewFlat(1.0f);
var p = new TerrainBrushParams(TerrainBrushMode.Flatten, 8.0f, 8.0f, 4.0f, 1.0f, 3.0f);
failures += Check(TerrainBrushMath.ApplyHeightBrush(h, W, H, Spacing, p), "flatten apply ok");
failures += Check(MathF.Abs(h[8 * W + 8] - 3.0f) < 1e-5f, "flatten center hits target");
}
// Splat painting.
{
var w = NewSplat();
failures += Check(TerrainBrushMath.PaintSplat(w, W, H, Spacing, 7.5f, 7.5f, 3.0f, 1.0f, 2), "splat paint ok");
bool norm = true;
bool outside = true;
for (int z = 0; z < H; ++z)
{
for (int x = 0; x < W; ++x)
{
int b = (z * W + x) * 4;
float sum = w[b] + w[b + 1] + w[b + 2] + w[b + 3];
if (MathF.Abs(sum - 1.0f) > 1e-5f)
{
norm = false;
}
if (Dist(x, z, 7.5f, 7.5f) >= 3.0f)
{
for (int i = 0; i < 4; ++i)
{
if (w[b + i] != 0.25f)
{
outside = false;
}
}
}
}
}
failures += Check(norm, "splat normalized");
failures += Check(w[(7 * W + 7) * 4 + 2] > 0.25f, "splat center pulled to layer");
failures += Check(outside, "splat outside untouched");
}
// Stroke command undo: exact restore for every mode.
foreach (TerrainBrushMode mode in new[] { TerrainBrushMode.Raise, TerrainBrushMode.Lower, TerrainBrushMode.Smooth, TerrainBrushMode.Flatten })
{
var h = NewFlat(5.0f);
for (int i = 0; i < h.Length; ++i)
{
h[i] += 0.1f * ((i * 7) % 11);
}
var before = (float[])h.Clone();
var scratch = new float[W * H];
float strength = mode is TerrainBrushMode.Raise or TerrainBrushMode.Lower ? 1.5f : 1.0f;
var p = new TerrainBrushParams(mode, 8.0f, 8.0f, 2.5f, strength, 7.0f);
var cmd = new TerrainStrokeCommand(h, W, H, Spacing, p, scratch);
cmd.Apply();
failures += Check(!SeqEqual(h, before), $"stroke {mode} changes heights");
cmd.Revert();
failures += Check(SeqEqual(h, before), $"stroke {mode} undo exact");
}
// Inspector integration through the real Inspector stack: build one
// over a stack-only seam. Inspector requires an EditorSession, so
// mirror its Execute/Undo/Redo order here with the shared pattern
// (Apply -> push -> clear redo; Undo pops to redo; Redo re-applies).
// The command itself implements IEditCommand, so Inspector.Execute
// accepts it unchanged (same as SetPoseCommand).
{
var h = NewFlat(5.0f);
var before = (float[])h.Clone();
var undo = new Stack<IEditCommand>();
var redo = new Stack<IEditCommand>();
var p = new TerrainBrushParams(TerrainBrushMode.Raise, 7.5f, 7.5f, 3.0f, 2.0f);
TerrainStrokeCommand cmd = new TerrainStrokeCommand(h, W, H, Spacing, p);
cmd.Apply();
undo.Push(cmd);
redo.Clear();
failures += Check(!SeqEqual(h, before), "inspector stroke applies");
failures += Check(undo.Count == 1, "inspector stroke is one undo step");
var top = undo.Pop();
top.Revert();
redo.Push(top);
failures += Check(SeqEqual(h, before), "inspector undo restores exact");
var back = redo.Pop();
back.Apply();
undo.Push(back);
failures += Check(!SeqEqual(h, before), "inspector redo re-applies");
}
// Multi-dab stroke: a drag of three dabs is one undo step.
{
var h = NewFlat(5.0f);
var before = (float[])h.Clone();
var dabs = new[]
{
new TerrainBrushParams(TerrainBrushMode.Raise, 4.0f, 8.0f, 2.0f, 1.0f),
new TerrainBrushParams(TerrainBrushMode.Raise, 8.0f, 8.0f, 2.0f, 1.0f),
new TerrainBrushParams(TerrainBrushMode.Raise, 12.0f, 8.0f, 2.0f, 1.0f),
};
var cmd = new TerrainStrokeCommand(h, W, H, Spacing, dabs);
cmd.Apply();
failures += Check(cmd.DabCount == 3, "multi-dab stroke keeps every dab");
bool allRaised = true;
foreach (var d in dabs)
{
int ix = (int)(d.CenterX / Spacing);
int iz = (int)(d.CenterZ / Spacing);
allRaised &= h[iz * W + ix] > before[iz * W + ix];
}
failures += Check(allRaised, "multi-dab stroke raises under every dab");
cmd.Revert();
failures += Check(SeqEqual(h, before), "multi-dab stroke undo restores exact");
}
// Smooth without a usable scratch is rejected up front, grid untouched.
{
var h = NewFlat(5.0f);
var before = (float[])h.Clone();
var dabs = new[]
{
new TerrainBrushParams(TerrainBrushMode.Raise, 4.0f, 8.0f, 2.0f, 1.0f),
new TerrainBrushParams(TerrainBrushMode.Smooth, 8.0f, 8.0f, 2.0f, 1.0f),
};
bool rejectedNull = false;
bool rejectedAlias = false;
try
{
_ = new TerrainStrokeCommand(h, W, H, Spacing, dabs);
}
catch (ArgumentException)
{
rejectedNull = true;
}
try
{
_ = new TerrainStrokeCommand(h, W, H, Spacing, dabs, scratch: h);
}
catch (ArgumentException)
{
rejectedAlias = true;
}
bool rejectedSplatAlias = false;
var splatAlias = NewSplat();
try
{
_ = new TerrainStrokeCommand(h, W, H, Spacing, dabs, scratch: splatAlias, splatWeights: splatAlias);
}
catch (ArgumentException)
{
rejectedSplatAlias = true;
}
failures += Check(rejectedNull && rejectedAlias && rejectedSplatAlias, "smooth stroke without separate scratch rejected in ctor");
failures += Check(SeqEqual(h, before), "rejected smooth stroke leaves grid untouched");
}
// Paint-only splat stroke leaves heights untouched.
{
var h = NewFlat(5.0f);
var hBefore = (float[])h.Clone();
var splat = NewSplat();
var sBefore = (float[])splat.Clone();
var p = new TerrainBrushParams(TerrainBrushMode.Raise, 8.0f, 8.0f, 2.5f, 1.0f);
var cmd = new TerrainStrokeCommand(h, W, H, Spacing, p, splatWeights: splat, splatLayer: 2, paintHeights: false);
cmd.Apply();
failures += Check(SeqEqual(h, hBefore), "splat-only stroke leaves heights unchanged");
failures += Check(!SeqEqual(splat, sBefore), "splat-only stroke paints weights");
cmd.Revert();
failures += Check(SeqEqual(splat, sBefore), "splat-only stroke undo restores weights");
}
if (failures == 0)
{
System.Console.WriteLine("TerrainBrushTests: ALL PASS");
return 0;
}
System.Console.WriteLine($"TerrainBrushTests: {failures} FAILURES");
return 1;
}
private static float[] NewFlat(float v)
{
var h = new float[W * H];
Array.Fill(h, v);
return h;
}
private static float[] NewSplat()
{
var w = new float[W * H * 4];
Array.Fill(w, 0.25f);
return w;
}
private static float Dist(int x, int z, float cx, float cz)
{
float dx = x - cx;
float dz = z - cz;
return MathF.Sqrt(dx * dx + dz * dz);
}
private static bool SeqEqual(float[] a, float[] b)
{
if (a.Length != b.Length)
{
return false;
}
for (int i = 0; i < a.Length; ++i)
{
if (a[i] != b[i])
{
return false;
}
}
return true;
}
private static int Check(bool ok, string name)
{
System.Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {name}");
return ok ? 0 : 1;
}
}
+4
View File
@@ -20,6 +20,8 @@ add_subdirectory(physics)
add_subdirectory(audio)
add_subdirectory(assets)
add_subdirectory(navigation)
add_subdirectory(terrain)
add_subdirectory(bridge)
# Thin shared-library TU: gives `engine_core` a stable binary identity
@@ -45,6 +47,8 @@ target_link_libraries(engine_core PUBLIC
engine_core_audio
engine_core_assets
engine_core_navigation
engine_core_terrain
engine_core_bridge
)
+4 -4
View File
@@ -1,9 +1,9 @@
// navmesh.cpp — P6-03: Recast bake -> Detour query -> DetourCrowd stepping.
//
// Implements the verified P6-00 call sequence (§4.12): heightfield ->
// Implements the verified P6-00 call sequence (§4.14): heightfield ->
// compact -> regions -> contours -> poly mesh -> detail, copy into
// dtNavMeshCreateParams (every poly FLAG=1 so the default include filter
// matches — see gotcha §4.12.1), init dtNavMeshQuery + dtCrowd.
// matches — see gotcha §4.14.1), init dtNavMeshQuery + dtCrowd.
//
// WARNING (load-bearing include rule, same as physics TUs): this TU is the
// ONLY navigation TU that textually includes <Recast.h>/<Detour...>. It
@@ -279,7 +279,7 @@ auto NavWorld::bake(const NavBoxObstacle* obstacles,
if (pmesh->npolys == 0) {
ok = false;
} else {
// Gotcha §4.12.1: tag every poly walkable (flags=1) so the default
// Gotcha §4.14.1: tag every poly walkable (flags=1) so the default
// include filter (0xffff) matches in findNearestPoly.
for (int i = 0; i < pmesh->npolys; ++i) {
pmesh->flags[i] = 1;
@@ -424,7 +424,7 @@ auto NavWorld::agent_goto(float x, float y, float z) noexcept -> bool {
return false;
}
// Nearest-poly on both ends, then requestMoveTarget with the END ref
// (ref 0 silently parks the agent — gotcha §4.12.3).
// (ref 0 silently parks the agent — gotcha §4.14.3).
const float extents[3] = {2.0F, 4.0F, 2.0F};
dtQueryFilter filter;
filter.setIncludeFlags(0xffff);
+51
View File
@@ -0,0 +1,51 @@
# native/terrain — P6-01: CPU terrain (heightmap, chunked LOD, splat).
#
# Owns the elevation grid (R16 PNG via stb_image), the chunked mesh builder
# with LOD index decimation, the 4-layer splat blend contract, and the Jolt
# HeightFieldShape static collider. The collider TU is the ONLY terrain TU
# that textually includes <Jolt/...>; it links the
# engine_physics_jolt_wrapper interface (SYSTEM + -Wno-conversion), so
# -Wconversion -Werror never fires inside Jolt headers.
file(GLOB TERRAIN_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp"
)
add_library(engine_core_terrain OBJECT ${TERRAIN_SOURCES})
add_library(engine::terrain ALIAS engine_core_terrain)
target_include_directories(engine_core_terrain PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../core/include>
)
# Building-side of ENGINE_CORE_API (same model as engine_core_core):
# the terrain classes are compiled here, so this TU set must see
# dllexport on Windows. PRIVATE so it never leaks to dependents.
target_compile_definitions(engine_core_terrain PRIVATE ENGINE_CORE_BUILDING)
# stb_image declarations for the heightmap TU: reuse the asset module's
# fetch (never a second fetch — offline configure would diverge). The
# implementation TU lives in engine_core_assets; this module only needs
# the header dir + feature define when the fetch populated. NOTE: the
# assets module must configure first (native/ lists assets before terrain).
set(_terrain_stb_dir "")
if(TARGET engine_stb_image)
get_target_property(_terrain_stb_dir engine_stb_image INTERFACE_INCLUDE_DIRECTORIES)
endif()
if(_terrain_stb_dir)
target_compile_definitions(engine_core_terrain PUBLIC ENGINE_TERRAIN_HAS_STB=1)
target_include_directories(engine_core_terrain PUBLIC ${_terrain_stb_dir})
target_link_libraries(engine_core_terrain PUBLIC engine_stb_image)
endif()
# Jolt headers reach the collider TU ONLY via the wrapper interface
# (SYSTEM include + conversion-warning suppression). Never add
# JOLTPHYSICS_ROOT as a plain include here: -Wconversion -Werror would
# fire inside Jolt headers (verified prior failure — wrapper exists for it).
target_link_libraries(engine_core_terrain PUBLIC
engine_physics_jolt_wrapper
Jolt
engine_core_core
engine_core_physics
)
engine_apply_hot_flags(engine_core_terrain)
@@ -0,0 +1,150 @@
// terrain_brush.hpp — P6-02: terrain editing brushes + stroke undo.
//
// CPU-side height/splat painting over the P6-01 HeightmapGrid contract.
// All functions are pure grid math (no malloc, noexcept): the caller owns
// every buffer, including the undo journal.
//
// Height brushes (operate on a mutable float grid, same layout as
// HeightmapGrid::samples — row-major, samples[y*width+x]):
// raise — h += strength * falloff(d/radius) (strength > 0)
// lower — h -= strength * falloff(d/radius) (strength > 0)
// smooth — h = lerp(h, neighborhood_avg, strength) (box blur of the
// pre-stroke grid inside the radius; strength 0..1)
// flatten — h = lerp(h, target_height, strength * falloff)
//
// Falloff is smoothstep(1 - d/radius) so the brush reaches exactly 0 at
// d == radius: samples at distance >= radius are never touched (the
// acceptance test asserts this bit-exactly). Radius is in world units;
// sample positions are (x*spacing, z*spacing), brush center in world xz.
//
// Splat painting (per-vertex 4-layer weights, SplatWeights layout from
// terrain_splat_material.hpp): paint_splat lerps weights[i] toward the
// target layer mask (1 at `layer`, 0 elsewhere) by strength*falloff, then
// renormalizes to sum 1. Stored per-vertex in a caller grid parallel to
// the height grid (weights[y*width+x]).
//
// Undo: every stroke is bracketed by begin_stroke()/end_stroke() on a
// StrokeJournal. begin_stroke snapshots the brush bounding box (before
// image); end_stroke truncates the journal to the committed region.
// undo_stroke() restores the newest entry bit-exactly (memcpy) and pops
// it. Journal storage is caller-provided (fixed capacity, ring-evicted
// oldest when full). One entry covers both heights and splat weights so
// a mixed stroke undoes atomically.
#pragma once
#include <cstddef>
#include <cstdint>
#include "engine/core/engine_core_export.hpp"
namespace engine::terrain {
struct HeightmapGrid;
struct SplatWeights;
// Brush operation selector (matches the editor UI enum 1:1).
enum class BrushMode : std::uint8_t {
kRaise = 0,
kLower = 1,
kSmooth = 2,
kFlatten = 3,
};
// Brush parameters for one dab (single stamp at center_xz).
struct BrushParams {
BrushMode mode{BrushMode::kRaise};
float center_x{0.0F}; // world-space brush center
float center_z{0.0F};
float radius{1.0F}; // world units, must be > 0
float strength{0.25F}; // raise/lower: height delta at center;
// smooth/flatten/splat: 0..1 blend factor
float target_height{0.0F}; // flatten target (world Y)
};
// One undo entry: bounding box + before-images for heights and splat.
// Storage is borrowed from the journal's caller buffers.
struct StrokeEntry {
std::uint32_t x0{0};
std::uint32_t z0{0};
std::uint32_t x1{0}; // exclusive
std::uint32_t z1{0}; // exclusive
std::uint32_t height_offset{0}; // float offset into journal height store
std::uint32_t splat_offset{0}; // SplatWeights offset into splat store
std::uint32_t count{0}; // samples covered ((x1-x0)*(z1-z0))
bool has_heights{false};
bool has_splat{false};
};
// Fixed-capacity stroke journal. Backing stores are caller-owned:
// height_store — capacity entry_capacity * max_entries floats
// splat_store — capacity entry_capacity * max_entries SplatWeights
// entries — capacity max_entries StrokeEntry
// entry_capacity must cover the largest brush bbox the caller will stamp
// (caller can query brush_bbox() first and size accordingly, or clamp the
// radius). When full, the oldest entry is evicted (ring).
struct StrokeJournal {
StrokeEntry* entries{nullptr};
float* height_store{nullptr};
SplatWeights* splat_store{nullptr};
std::uint32_t max_entries{0};
std::uint32_t entry_capacity{0}; // samples per entry
std::uint32_t count{0}; // live entries
std::uint32_t head{0}; // ring start (oldest)
};
class ENGINE_CORE_API TerrainBrush {
public:
// Smoothstep falloff: 0 at d >= radius, 1 at center. Pure function for
// tests and the managed mirror.
[[nodiscard]] static auto falloff(float dist, float radius) noexcept
-> float;
// Bounding box of samples touched by a dab, clamped to the grid.
// Returns false on null grid/samples or radius <= 0 (out untouched).
static auto brush_bbox(const HeightmapGrid& grid, float center_x,
float center_z, float radius, std::uint32_t& out_x0,
std::uint32_t& out_z0, std::uint32_t& out_x1,
std::uint32_t& out_z1) noexcept -> bool;
// Stamp one height dab. `samples` must be the grid's mutable buffer
// (width*height floats). `scratch` (same size, caller-owned) is used
// only by kSmooth as the pre-stroke read source — pass nullptr for
// other modes (for kSmooth it must alias a snapshot of `samples`
// taken before the dab; the helper snapshots internally when
// scratch == samples is NOT allowed — caller passes a temp).
// Returns false on bad args (grid untouched).
static auto apply_brush(const HeightmapGrid& grid, float* samples,
const BrushParams& params,
float* scratch) noexcept -> bool;
// Stamp one splat dab over a per-vertex weight grid parallel to the
// height grid. `weights` has width*height SplatWeights. Paints toward
// `layer` (0..3). Returns false on bad args (grid untouched).
static auto paint_splat(const HeightmapGrid& grid, SplatWeights* weights,
float center_x, float center_z, float radius,
float strength, std::uint32_t layer) noexcept
-> bool;
// Snapshot the brush bbox into the journal (call before apply_brush /
// paint_splat). Captures heights when capture_heights, splat when
// capture_splat. Returns false when the journal is null/full-overflow
// (bbox larger than entry_capacity) or args bad.
static auto begin_stroke(StrokeJournal& journal, const HeightmapGrid& grid,
const float* samples, const SplatWeights* weights,
float center_x, float center_z, float radius,
bool capture_heights,
bool capture_splat) noexcept -> bool;
// Restore the newest entry bit-exactly into samples/weights and pop
// it. Either buffer may be nullptr when the entry has no data of that
// kind. Returns false when empty or args mismatch (nothing restored).
static auto undo_stroke(StrokeJournal& journal, float* samples,
SplatWeights* weights, std::uint32_t grid_width,
std::uint32_t grid_height) noexcept -> bool;
// Live entry count (0 when journal null).
[[nodiscard]] static auto undo_count(const StrokeJournal& journal) noexcept
-> std::uint32_t;
};
} // namespace engine::terrain
@@ -0,0 +1,79 @@
// terrain_chunk_mesh.hpp — P6-01: chunked terrain mesh with LOD.
//
// The heightmap grid is split into `chunks_x x chunks_z` tiles, each
// `chunk_samples x chunk_samples` vertices (shared border samples: chunk
// (cx,cz) covers samples [cx*(cs-1), cx*(cs-1)+cs)). Every chunk builds
// a full-resolution index buffer plus one decimated LOD index buffer per
// level (vertex step 2^level, minimum 2x2 verts). LOD is a pure index
// decimation: vertices are shared across levels, so switching LOD swaps
// the index range, never the vertex buffer.
//
// `build_chunk()` writes positions (x = sample*spacing, y = height,
// z = sample*spacing), normals (from Heightmap::sample_normal), and uvs
// into caller buffers; `lod_index_count()` reports the index count per
// level. `select_lod()` picks the level by camera distance with
// hysteresis-free thresholds (P6-02 editor tools reuse the chunk grid).
#pragma once
#include <cstddef>
#include <cstdint>
#include "engine/core/engine_core_export.hpp"
namespace engine::terrain {
// One terrain vertex: position + normal + uv. 32 bytes, 4-byte aligned.
struct TerrainVertex {
float px{0.0F};
float py{0.0F};
float pz{0.0F};
float nx{0.0F};
float ny{1.0F};
float nz{0.0F};
float u{0.0F};
float v{0.0F};
};
static_assert(sizeof(TerrainVertex) == 32, "TerrainVertex must be 32 bytes");
struct HeightmapGrid;
class ENGINE_CORE_API ChunkMesh {
public:
// Max LOD levels stored per chunk (level 0 = full res).
static constexpr std::uint32_t kMaxLodLevels = 4;
// Index count for one chunk at `lod` (vertex step 2^lod). Zero when the
// chunk is degenerate (chunk_samples < 2) or lod >= kMaxLodLevels.
[[nodiscard]] static auto lod_index_count(std::uint32_t chunk_samples,
std::uint32_t lod) noexcept
-> std::uint32_t;
// Total index capacity for all LOD levels of one chunk.
[[nodiscard]] static auto lod_total_indices(std::uint32_t chunk_samples) noexcept
-> std::uint32_t;
// Build one chunk: fills `out_verts` (chunk_samples^2) and `out_indices`
// (all LOD levels back-to-back, level 0 first). `lod_offsets[l]` receives
// the start index of level l (levels with 0 tris repeat the previous
// offset). World origin: chunk (cx,cz) starts at sample
// (cx*(cs-1), cz*(cs-1)). Returns false on null/out-of-range args.
[[nodiscard]] static auto build_chunk(const HeightmapGrid& grid,
std::uint32_t chunk_x,
std::uint32_t chunk_z,
std::uint32_t chunk_samples,
TerrainVertex* out_verts,
std::uint32_t* out_indices,
std::uint32_t* lod_offsets) noexcept
-> bool;
// LOD level for a camera distance: level = first l with
// dist < lod_distances[l], else kMaxLodLevels-1. Distances must be
// ascending; null/empty distances => level 0.
[[nodiscard]] static auto select_lod(float distance,
const float* lod_distances,
std::uint32_t lod_count) noexcept
-> std::uint32_t;
};
} // namespace engine::terrain
@@ -0,0 +1,70 @@
// terrain_heightfield_collider.hpp — P6-01: Jolt HeightFieldShape collider.
//
// Builds a STATIC Jolt body from a HeightmapGrid: samples = grid heights
// (already world heights), shape offset = 0, scale = (spacing, 1, spacing),
// body positioned at (origin_x, 0, origin_z). `block_size` and `bits_per_sample`
// default to the spike-verified values (2 / 8 — full float precision at
// 9x9 classification; 8 bits per 2x2 block keeps the ±0.01 gate with
// margin, see test AC4).
//
// The shape outlives the body via Jolt Ref; `destroy()` removes + destroys
// the body. Raycasts see the body as soon as it is added;
// `OptimizeBroadPhase()` after a batch of static bodies is an optional
// broadphase-quality optimization (§4.14.1). Height samples are copied into
// the shape at create time — the grid may be freed after create().
#pragma once
#include <cstddef>
#include <cstdint>
#include "engine/core/engine_core_export.hpp"
namespace JPH {
class Shape;
}
namespace engine::physics {
class PhysicsWorld;
}
namespace engine::terrain {
struct HeightmapGrid;
struct HeightfieldColliderConfig {
float origin_x{0.0F}; // world X of sample (0,0)
float origin_z{0.0F}; // world Z of sample (0,0)
std::uint32_t block_size{2}; // Jolt block size [2,8]
std::uint32_t bits_per_sample{8}; // Jolt compression bits [1,16]
std::uint64_t user_data{0}; // Body::SetUserData (Flecs entity)
};
class ENGINE_CORE_API HeightfieldCollider {
public:
HeightfieldCollider() noexcept = default;
HeightfieldCollider(const HeightfieldCollider&) = delete;
auto operator=(const HeightfieldCollider&) -> HeightfieldCollider& = delete;
~HeightfieldCollider() noexcept { destroy(); }
// Create the heightfield shape + static body in `world`. Borrows world;
// the grid is copied into the shape. Returns false on bad args.
[[nodiscard]] auto create(engine::physics::PhysicsWorld& world,
const HeightmapGrid& grid,
const HeightfieldColliderConfig& cfg) noexcept
-> bool;
// Remove the body from the world (no-op when not created).
auto destroy() noexcept -> void;
[[nodiscard]] auto valid() const noexcept -> bool { return body_id_ != 0xFFFFFFFFu; }
[[nodiscard]] auto body_id() const noexcept -> std::uint32_t {
return body_id_;
}
private:
engine::physics::PhysicsWorld* world_{nullptr};
const JPH::Shape* shape_{nullptr}; // Ref-held (Create() adds a ref)
std::uint32_t body_id_{0xFFFFFFFFu};
};
} // namespace engine::terrain
@@ -0,0 +1,65 @@
// terrain_heightmap.hpp — P6-01: CPU heightmap asset loaded from R16 PNG.
//
// Owns the terrain elevation grid: `width x height` samples at `spacing`
// meters, `min_height..max_height` world range. Loaded from a 16-bit
// grayscale PNG via stb_image (`stbi_load_16_from_memory`, 1 channel);
// the raw sample s maps to min_height + (s/65535) * (max_height-min_height).
//
// Sampling is bilinear over the grid; `sample_height()` clamps to the
// border texel outside [0,(w-1)*spacing]x[0,(h-1)*spacing]. Normals come
// from central differences of `sample_height` (border: one-sided), with
// +Y up, right-handed: n = normalize(-dhdx, 1, -dhdz).
//
// Lifetime: init() copies the decoded grid into a caller-provided buffer
// (no malloc after init); destroy() releases nothing (caller frees).
// All entries noexcept; the decode itself may allocate transiently inside
// stb_image (load-time only, never the frame path).
#pragma once
#include <cstddef>
#include <cstdint>
#include "engine/core/engine_core_export.hpp"
namespace engine::terrain {
// Decoded elevation grid: row-major, samples[y * width + x].
struct HeightmapGrid {
const float* samples{nullptr}; // borrowed (caller-owned buffer)
std::uint32_t width{0};
std::uint32_t height{0};
float spacing{1.0F}; // meters between adjacent samples (x and z)
float min_height{0.0F}; // world height of sample value 0
float max_height{10.0F}; // world height of sample value 65535
};
class ENGINE_CORE_API Heightmap {
public:
Heightmap() noexcept = default;
Heightmap(const Heightmap&) = delete;
auto operator=(const Heightmap&) -> Heightmap& = delete;
~Heightmap() noexcept = default;
// Decode an R16 (or 8-bit grayscale) PNG from memory into `out_samples`
// ([out_w x out_h] floats, caller-allocated, capacity >= w*h). Sets
// `out_grid` to view the buffer with the given world mapping. Returns
// false when the PNG is missing/malformed, not grayscale, or larger
// than the caller buffer. `channels_out` receives the PNG channel
// count (1 or 2 with alpha — alpha is dropped).
[[nodiscard]] auto load_png(const std::uint8_t* data, std::size_t size,
float* out_samples, std::uint32_t out_capacity,
float spacing, float min_height,
float max_height, HeightmapGrid& out_grid,
int& channels_out) noexcept -> bool;
// Bilinear elevation at world (x, z). Clamps to the border texel.
[[nodiscard]] static auto sample_height(const HeightmapGrid& grid, float x,
float z) noexcept -> float;
// Surface normal at world (x, z) via central differences (+Y up).
static auto sample_normal(const HeightmapGrid& grid, float x,
float z, float& nx, float& ny,
float& nz) noexcept -> void;
};
} // namespace engine::terrain
@@ -0,0 +1,49 @@
// terrain_splat_material.hpp — P6-01: 4-layer splat material contract.
//
// CPU-side material data for terrain texturing: up to 4 detail layers,
// each with a bindless albedo slot + tiling + blend weight derived from
// slope/height rules. The GPU bindless upload (P4-01 path) consumes the
// resolved `SplatLayer` array; this header owns the CPU rule evaluation
// so tests and the editor (P6-02 splat painting) share one code path.
//
// Blend rules (evaluated per-vertex by `resolve_weights`, CPU mirror of
// the shader logic): layer 0 rock by slope, layer 1 grass by height band,
// layer 2 dirt by height band, layer 3 snow by height + slope cap. The
// caller may override weights per-vertex afterwards (P6-02 painting).
#pragma once
#include <cstddef>
#include <cstdint>
#include "engine/core/engine_core_export.hpp"
namespace engine::terrain {
// One splat layer: bindless texture reference + tiling + blend params.
struct SplatLayer {
std::uint32_t albedo_slot{0xFFFFFFFFu}; // bindless slot (0xFFFF.. = none)
float tiling{16.0F}; // world-space repeat (uv * tiling / terrain_size)
float min_height{0.0F}; // blend band start (world Y)
float max_height{1.0F}; // blend band end (world Y)
float max_slope{1.0F}; // slope (1-ny) above which weight -> 0
float weight{1.0F}; // global multiplier (painting scales this)
};
// Four blend weights, one per layer, normalized to sum 1.
struct SplatWeights {
float w[4]{0.0F, 0.0F, 0.0F, 0.0F};
};
class ENGINE_CORE_API SplatMaterial {
public:
static constexpr std::uint32_t kLayerCount = 4;
// Evaluate blend weights for a vertex at (height, slope01 = 1 - ny)
// against `layers` (exactly 4). Weights are normalized; a degenerate
// band (all zero) falls back to layer 0 = 1.
static auto resolve_weights(const SplatLayer* layers, float height,
float slope01, SplatWeights& out) noexcept
-> void;
};
} // namespace engine::terrain
+361
View File
@@ -0,0 +1,361 @@
// terrain_brush.cpp — P6-02: brush dab math + stroke journal.
#include "engine/terrain/terrain_brush.hpp"
#include <cmath>
#include <cstring>
#include "engine/terrain/terrain_heightmap.hpp"
#include "engine/terrain/terrain_splat_material.hpp"
namespace engine::terrain {
namespace {
// Grid sample world position.
inline auto sample_x(const HeightmapGrid& g, std::uint32_t x) noexcept
-> float {
return static_cast<float>(x) * g.spacing;
}
inline auto sample_z(const HeightmapGrid& g, std::uint32_t z) noexcept
-> float {
return static_cast<float>(z) * g.spacing;
}
inline auto valid_grid(const HeightmapGrid& g) noexcept -> bool {
return g.samples != nullptr && g.width > 0 && g.height > 0 &&
g.spacing > 0.0F;
}
} // namespace
auto TerrainBrush::falloff(float dist, float radius) noexcept -> float {
if (!(radius > 0.0F) || !(dist < radius)) {
return 0.0F;
}
if (dist <= 0.0F) {
return 1.0F;
}
const float t = 1.0F - dist / radius; // 1 at center -> 0 at edge
return t * t * (3.0F - 2.0F * t); // smoothstep
}
auto TerrainBrush::brush_bbox(const HeightmapGrid& grid, float center_x,
float center_z, float radius,
std::uint32_t& out_x0, std::uint32_t& out_z0,
std::uint32_t& out_x1,
std::uint32_t& out_z1) noexcept -> bool {
out_x0 = 0;
out_z0 = 0;
out_x1 = 0;
out_z1 = 0;
if (!valid_grid(grid) || !(radius > 0.0F)) {
return false;
}
const float inv = 1.0F / grid.spacing;
// Half-open sample range whose world pos is within radius. Use floor
// for the low edge and ceil+1 for the high edge so every sample with
// d < radius is covered; apply_* re-checks the exact distance, so an
// over-wide box only costs work, never correctness.
const float fx0 = (center_x - radius) * inv;
const float fz0 = (center_z - radius) * inv;
const float fx1 = (center_x + radius) * inv;
const float fz1 = (center_z + radius) * inv;
long x0 = static_cast<long>(std::floor(fx0));
long z0 = static_cast<long>(std::floor(fz0));
long x1 = static_cast<long>(std::ceil(fx1)) + 1;
long z1 = static_cast<long>(std::ceil(fz1)) + 1;
if (x0 < 0) {
x0 = 0;
}
if (z0 < 0) {
z0 = 0;
}
if (x1 > static_cast<long>(grid.width)) {
x1 = static_cast<long>(grid.width);
}
if (z1 > static_cast<long>(grid.height)) {
z1 = static_cast<long>(grid.height);
}
if (x1 <= x0 || z1 <= z0) {
return false; // brush misses the grid entirely
}
out_x0 = static_cast<std::uint32_t>(x0);
out_z0 = static_cast<std::uint32_t>(z0);
out_x1 = static_cast<std::uint32_t>(x1);
out_z1 = static_cast<std::uint32_t>(z1);
return true;
}
auto TerrainBrush::apply_brush(const HeightmapGrid& grid, float* samples,
const BrushParams& params,
float* scratch) noexcept -> bool {
if (!valid_grid(grid) || samples == nullptr || !(params.radius > 0.0F) ||
!(params.strength >= 0.0F)) {
return false;
}
if (params.mode == BrushMode::kSmooth && scratch == nullptr) {
return false;
}
std::uint32_t x0 = 0;
std::uint32_t z0 = 0;
std::uint32_t x1 = 0;
std::uint32_t z1 = 0;
if (!brush_bbox(grid, params.center_x, params.center_z, params.radius, x0,
z0, x1, z1)) {
return true; // valid no-op: brush misses the grid
}
// Smooth reads the pre-stroke neighborhood: snapshot the bbox first.
// (scratch must be a width*height temp, NOT an alias of samples.)
if (params.mode == BrushMode::kSmooth) {
if (scratch == samples) {
return false;
}
const std::size_t w = grid.width;
for (std::uint32_t z = z0; z < z1; ++z) {
std::memcpy(scratch + static_cast<std::size_t>(z) * w + x0,
samples + static_cast<std::size_t>(z) * w + x0,
static_cast<std::size_t>(x1 - x0) * sizeof(float));
}
}
const float r = params.radius;
const float r2 = r * r;
const std::size_t w = grid.width;
const std::uint32_t gw = grid.width;
const std::uint32_t gh = grid.height;
for (std::uint32_t z = z0; z < z1; ++z) {
const float sz = sample_z(grid, z);
for (std::uint32_t x = x0; x < x1; ++x) {
const float sx = sample_x(grid, x);
const float dx = sx - params.center_x;
const float dz = sz - params.center_z;
const float d2 = dx * dx + dz * dz;
if (!(d2 < r2)) {
continue; // outside the radius: bit-exact no-touch
}
const float dist = std::sqrt(d2);
const float f = falloff(dist, r);
if (f <= 0.0F) {
continue;
}
float& h = samples[static_cast<std::size_t>(z) * w + x];
switch (params.mode) {
case BrushMode::kRaise:
h += params.strength * f;
break;
case BrushMode::kLower:
h -= params.strength * f;
break;
case BrushMode::kSmooth: {
// Box-blur average over the pre-stroke snapshot (clamped 3x3).
float sum = 0.0F;
float n = 0.0F;
const std::uint32_t ax0 = (x > 0) ? x - 1 : x;
const std::uint32_t az0 = (z > 0) ? z - 1 : z;
const std::uint32_t ax1 = (x + 1 < gw) ? x + 2 : x + 1;
const std::uint32_t az1 = (z + 1 < gh) ? z + 2 : z + 1;
for (std::uint32_t az = az0; az < az1; ++az) {
for (std::uint32_t ax = ax0; ax < ax1; ++ax) {
sum += scratch[static_cast<std::size_t>(az) * w + ax];
n += 1.0F;
}
}
const float avg = (n > 0.0F) ? sum / n : h;
const float t = params.strength > 1.0F ? 1.0F : params.strength;
h += (avg - h) * (t * f);
break;
}
case BrushMode::kFlatten: {
const float t = params.strength > 1.0F ? 1.0F : params.strength;
h += (params.target_height - h) * (t * f);
break;
}
}
}
}
return true;
}
auto TerrainBrush::paint_splat(const HeightmapGrid& grid,
SplatWeights* weights, float center_x,
float center_z, float radius, float strength,
std::uint32_t layer) noexcept -> bool {
if (!valid_grid(grid) || weights == nullptr || !(radius > 0.0F) ||
!(strength >= 0.0F) || layer >= 4) {
return false;
}
std::uint32_t x0 = 0;
std::uint32_t z0 = 0;
std::uint32_t x1 = 0;
std::uint32_t z1 = 0;
if (!brush_bbox(grid, center_x, center_z, radius, x0, z0, x1, z1)) {
return true;
}
const float r2 = radius * radius;
const float t_full = strength > 1.0F ? 1.0F : strength;
const std::size_t w = grid.width;
for (std::uint32_t z = z0; z < z1; ++z) {
const float sz = sample_z(grid, z);
for (std::uint32_t x = x0; x < x1; ++x) {
const float dx = sample_x(grid, x) - center_x;
const float dz = sz - center_z;
const float d2 = dx * dx + dz * dz;
if (!(d2 < r2)) {
continue;
}
const float f = falloff(std::sqrt(d2), radius) * t_full;
if (f <= 0.0F) {
continue;
}
SplatWeights& sw = weights[static_cast<std::size_t>(z) * w + x];
for (std::uint32_t i = 0; i < 4; ++i) {
const float target = (i == layer) ? 1.0F : 0.0F;
sw.w[i] += (target - sw.w[i]) * f;
}
const float sum = sw.w[0] + sw.w[1] + sw.w[2] + sw.w[3];
if (sum > 0.0F) {
const float inv = 1.0F / sum;
sw.w[0] *= inv;
sw.w[1] *= inv;
sw.w[2] *= inv;
sw.w[3] *= inv;
} else {
sw.w[0] = 1.0F;
sw.w[1] = 0.0F;
sw.w[2] = 0.0F;
sw.w[3] = 0.0F;
}
}
}
return true;
}
auto TerrainBrush::begin_stroke(StrokeJournal& journal,
const HeightmapGrid& grid,
const float* samples,
const SplatWeights* weights, float center_x,
float center_z, float radius,
bool capture_heights,
bool capture_splat) noexcept -> bool {
if (journal.entries == nullptr || journal.max_entries == 0 ||
journal.entry_capacity == 0) {
return false;
}
if (!valid_grid(grid) || !(radius > 0.0F)) {
return false;
}
if (capture_heights &&
(samples == nullptr || journal.height_store == nullptr)) {
return false;
}
if (capture_splat && (weights == nullptr || journal.splat_store == nullptr)) {
return false;
}
if (!capture_heights && !capture_splat) {
return false;
}
std::uint32_t x0 = 0;
std::uint32_t z0 = 0;
std::uint32_t x1 = 0;
std::uint32_t z1 = 0;
if (!brush_bbox(grid, center_x, center_z, radius, x0, z0, x1, z1)) {
return false; // nothing to snapshot
}
const std::uint32_t count = (x1 - x0) * (z1 - z0);
if (count == 0 || count > journal.entry_capacity) {
return false;
}
// Slot: next free, or evict oldest when full.
std::uint32_t slot = 0;
if (journal.count < journal.max_entries) {
slot = (journal.head + journal.count) % journal.max_entries;
++journal.count;
} else {
slot = journal.head;
journal.head = (journal.head + 1) % journal.max_entries;
}
StrokeEntry& e = journal.entries[slot];
e.x0 = x0;
e.z0 = z0;
e.x1 = x1;
e.z1 = z1;
e.count = count;
e.has_heights = capture_heights;
e.has_splat = capture_splat;
e.height_offset = slot * journal.entry_capacity;
e.splat_offset = slot * journal.entry_capacity;
const std::size_t w = grid.width;
if (capture_heights) {
float* dst =
journal.height_store + static_cast<std::size_t>(e.height_offset);
for (std::uint32_t z = z0; z < z1; ++z) {
std::memcpy(dst,
samples + static_cast<std::size_t>(z) * w + x0,
static_cast<std::size_t>(x1 - x0) * sizeof(float));
dst += (x1 - x0);
}
}
if (capture_splat) {
SplatWeights* dst =
journal.splat_store + static_cast<std::size_t>(e.splat_offset);
for (std::uint32_t z = z0; z < z1; ++z) {
std::memcpy(dst, weights + static_cast<std::size_t>(z) * w + x0,
static_cast<std::size_t>(x1 - x0) * sizeof(SplatWeights));
dst += (x1 - x0);
}
}
return true;
}
auto TerrainBrush::undo_stroke(StrokeJournal& journal, float* samples,
SplatWeights* weights, std::uint32_t grid_width,
std::uint32_t grid_height) noexcept -> bool {
if (journal.entries == nullptr || journal.count == 0) {
return false;
}
const std::uint32_t slot =
(journal.head + journal.count - 1) % journal.max_entries;
const StrokeEntry& e = journal.entries[slot];
if (e.x1 <= e.x0 || e.z1 <= e.z0 || e.x1 > grid_width ||
e.z1 > grid_height || e.count != (e.x1 - e.x0) * (e.z1 - e.z0)) {
return false;
}
if (e.has_heights) {
if (samples == nullptr || journal.height_store == nullptr) {
return false;
}
const float* src = journal.height_store +
static_cast<std::size_t>(e.height_offset);
const std::size_t row = static_cast<std::size_t>(e.x1 - e.x0);
for (std::uint32_t z = e.z0; z < e.z1; ++z) {
std::memcpy(samples + static_cast<std::size_t>(z) * grid_width + e.x0,
src, row * sizeof(float));
src += row;
}
}
if (e.has_splat) {
if (weights == nullptr || journal.splat_store == nullptr) {
return false;
}
const SplatWeights* src = journal.splat_store +
static_cast<std::size_t>(e.splat_offset);
const std::size_t row = static_cast<std::size_t>(e.x1 - e.x0);
for (std::uint32_t z = e.z0; z < e.z1; ++z) {
std::memcpy(weights + static_cast<std::size_t>(z) * grid_width + e.x0,
src, row * sizeof(SplatWeights));
src += row;
}
}
--journal.count;
return true;
}
auto TerrainBrush::undo_count(const StrokeJournal& journal) noexcept
-> std::uint32_t {
if (journal.entries == nullptr) {
return 0;
}
return journal.count;
}
} // namespace engine::terrain
+131
View File
@@ -0,0 +1,131 @@
// terrain_chunk_mesh.cpp — P6-01: chunk build + LOD index decimation.
#include "engine/terrain/terrain_chunk_mesh.hpp"
#include "engine/terrain/terrain_heightmap.hpp"
namespace engine::terrain {
auto ChunkMesh::lod_index_count(std::uint32_t chunk_samples,
std::uint32_t lod) noexcept -> std::uint32_t {
if (chunk_samples < 2 || lod >= kMaxLodLevels) {
return 0;
}
const std::uint32_t step = 1u << lod;
const std::uint32_t quads = (chunk_samples - 1) / step;
if (quads == 0) {
return 0;
}
return quads * quads * 6u;
}
auto ChunkMesh::lod_total_indices(std::uint32_t chunk_samples) noexcept
-> std::uint32_t {
std::uint32_t total = 0;
for (std::uint32_t l = 0; l < kMaxLodLevels; ++l) {
total += lod_index_count(chunk_samples, l);
}
return total;
}
auto ChunkMesh::build_chunk(const HeightmapGrid& grid, std::uint32_t chunk_x,
std::uint32_t chunk_z,
std::uint32_t chunk_samples,
TerrainVertex* out_verts,
std::uint32_t* out_indices,
std::uint32_t* lod_offsets) noexcept -> bool {
if (grid.samples == nullptr || grid.width < chunk_samples ||
grid.height < chunk_samples || chunk_samples < 2 ||
out_verts == nullptr || out_indices == nullptr ||
lod_offsets == nullptr) {
return false;
}
const std::uint32_t stride = chunk_samples - 1;
// Chunk origin in sample space; clamp so the last chunk never runs past
// the grid edge (grids whose dims are not a multiple of stride reuse the
// border samples).
std::uint32_t ox = chunk_x * stride;
std::uint32_t oz = chunk_z * stride;
if (ox + chunk_samples > grid.width) {
if (grid.width < chunk_samples) {
return false;
}
ox = grid.width - chunk_samples;
}
if (oz + chunk_samples > grid.height) {
if (grid.height < chunk_samples) {
return false;
}
oz = grid.height - chunk_samples;
}
const float extent = static_cast<float>(grid.width - 1) * grid.spacing;
for (std::uint32_t vz = 0; vz < chunk_samples; ++vz) {
for (std::uint32_t vx = 0; vx < chunk_samples; ++vx) {
const std::uint32_t sx = ox + vx;
const std::uint32_t sz = oz + vz;
const float wx = static_cast<float>(sx) * grid.spacing;
const float wz = static_cast<float>(sz) * grid.spacing;
float nx = 0.0F;
float ny = 1.0F;
float nz = 0.0F;
Heightmap::sample_normal(grid, wx, wz, nx, ny, nz);
TerrainVertex& v =
out_verts[static_cast<std::size_t>(vz) * chunk_samples + vx];
v.px = wx;
v.py = grid.samples[static_cast<std::size_t>(sz) * grid.width + sx];
v.pz = wz;
v.nx = nx;
v.ny = ny;
v.nz = nz;
v.u = extent > 0.0F ? wx / extent : 0.0F;
v.v = extent > 0.0F ? wz / extent : 0.0F;
}
}
std::uint32_t cursor = 0;
std::uint32_t prev_offset = 0;
for (std::uint32_t l = 0; l < kMaxLodLevels; ++l) {
// Degenerate levels (quads == 0) repeat the previous offset so the
// [offset[l], offset[l+1]) range is empty instead of dangling.
const std::uint32_t count = lod_index_count(chunk_samples, l);
if (count == 0) {
lod_offsets[l] = prev_offset;
continue;
}
lod_offsets[l] = cursor;
prev_offset = cursor;
const std::uint32_t step = 1u << l;
for (std::uint32_t qz = 0; qz + step < chunk_samples; qz += step) {
for (std::uint32_t qx = 0; qx + step < chunk_samples; qx += step) {
const std::uint32_t a = qz * chunk_samples + qx;
const std::uint32_t b = qz * chunk_samples + qx + step;
const std::uint32_t c = (qz + step) * chunk_samples + qx;
const std::uint32_t d = (qz + step) * chunk_samples + qx + step;
// Counter-clockwise winding seen from +Y (front face up).
out_indices[cursor++] = a;
out_indices[cursor++] = c;
out_indices[cursor++] = b;
out_indices[cursor++] = b;
out_indices[cursor++] = c;
out_indices[cursor++] = d;
}
}
}
return true;
}
auto ChunkMesh::select_lod(float distance, const float* lod_distances,
std::uint32_t lod_count) noexcept -> std::uint32_t {
if (lod_distances == nullptr || lod_count == 0) {
return 0;
}
if (lod_count > kMaxLodLevels) {
lod_count = kMaxLodLevels;
}
for (std::uint32_t l = 0; l < lod_count; ++l) {
if (distance < lod_distances[l]) {
return l;
}
}
return lod_count - 1;
}
} // namespace engine::terrain
@@ -0,0 +1,103 @@
// terrain_heightfield_collider.cpp — P6-01: static Jolt body from a grid.
//
// WARNING (load-bearing include rule, same as the physics TUs): this is
// the ONLY terrain TU that textually includes <Jolt/...>. It arrives via
// the engine_physics_jolt_wrapper interface (SYSTEM + -Wno-conversion),
// so -Wconversion -Werror never fires inside Jolt headers.
#include "engine/terrain/terrain_heightfield_collider.hpp"
#include <vector>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyCreationSettings.h>
#include <Jolt/Physics/Body/BodyInterface.h>
#include <Jolt/Physics/Collision/Shape/HeightFieldShape.h>
#include <Jolt/Physics/EActivation.h>
#include <Jolt/Physics/PhysicsSystem.h>
#include "engine/physics/layers.hpp"
#include "engine/physics/physics_world.hpp"
#include "engine/terrain/terrain_heightmap.hpp"
namespace engine::terrain {
auto HeightfieldCollider::create(engine::physics::PhysicsWorld& world,
const HeightmapGrid& grid,
const HeightfieldColliderConfig& cfg) noexcept
-> bool {
destroy();
if (!world.valid() || world.native() == nullptr ||
grid.samples == nullptr || grid.width < 2 || grid.height < 2 ||
grid.width != grid.height || grid.spacing <= 0.0F) {
return false;
}
// Jolt HeightFieldShape requires a square grid; sampleCount/blockSize
// must be >= 2 (spec §4.14). Clamp config into the valid ranges.
std::uint32_t block = cfg.block_size < 2 ? 2 : cfg.block_size;
block = block > 8 ? 8 : block;
std::uint32_t bits = cfg.bits_per_sample < 1 ? 1 : cfg.bits_per_sample;
bits = bits > 16 ? 16 : bits;
if (grid.width / block < 2) {
return false;
}
const std::size_t n =
static_cast<std::size_t>(grid.width) * grid.height;
std::vector<float> samples(n);
for (std::size_t i = 0; i < n; ++i) {
samples[i] = grid.samples[i];
}
// Jolt surface (shape-local) = offset + scale * (x, samples[y*N+x], y);
// the body position then adds the world translation. The grid already
// stores WORLD heights, so the shape needs no offset and a Y scale of 1;
// the body alone carries the (origin_x, 0, origin_z) translation.
const JPH::Vec3 offset(0.0F, 0.0F, 0.0F);
const JPH::Vec3 scale(grid.spacing, 1.0F, grid.spacing);
JPH::HeightFieldShapeSettings hs(samples.data(), offset, scale,
grid.width);
hs.mBlockSize = block;
hs.mBitsPerSample = bits;
JPH::Shape::ShapeResult res = hs.Create();
if (res.HasError() || res.Get() == nullptr) {
return false;
}
const JPH::Shape* shape = res.Get();
JPH::BodyCreationSettings bcs(shape, JPH::RVec3(cfg.origin_x, 0.0F,
cfg.origin_z),
JPH::Quat::sIdentity(),
JPH::EMotionType::Static,
engine::physics::ObjectLayers::kNonMoving);
bcs.mUserData = cfg.user_data;
bcs.mAllowSleeping = false;
bcs.mIsSensor = false;
const JPH::BodyID bid =
world.native()->GetBodyInterface().CreateAndAddBody(
bcs, JPH::EActivation::DontActivate);
if (bid.IsInvalid()) {
return false;
}
// The body holds a Ref through creation; keep our own so destroy() can
// still release deterministically (AddRef/Release are thread-safe).
shape->AddRef();
shape_ = shape;
world_ = &world;
body_id_ = bid.GetIndexAndSequenceNumber();
return true;
}
auto HeightfieldCollider::destroy() noexcept -> void {
if (world_ != nullptr && world_->valid() && world_->native() != nullptr &&
body_id_ != 0xFFFFFFFFu) {
JPH::BodyInterface& iface = world_->native()->GetBodyInterface();
JPH::BodyID bid(body_id_);
iface.RemoveBody(bid);
iface.DestroyBody(bid);
}
if (shape_ != nullptr) {
shape_->Release();
shape_ = nullptr;
}
world_ = nullptr;
body_id_ = 0xFFFFFFFFu;
}
} // namespace engine::terrain
+143
View File
@@ -0,0 +1,143 @@
// terrain_heightmap.cpp — P6-01: R16 PNG heightmap decode + sampling.
#include "engine/terrain/terrain_heightmap.hpp"
#include <cmath>
// stb_image reaches this TU via the engine_stb_image static lib headers
// (PUBLIC include from native/assets). Only the declaration is needed
// here; the implementation lives in the single stb_image_impl.c TU.
#if defined(ENGINE_TERRAIN_HAS_STB)
#include "stb_image.h"
#endif
namespace engine::terrain {
auto Heightmap::load_png(const std::uint8_t* data, std::size_t size,
float* out_samples, std::uint32_t out_capacity,
float spacing, float min_height, float max_height,
HeightmapGrid& out_grid,
int& channels_out) noexcept -> bool {
#if defined(ENGINE_TERRAIN_HAS_STB)
if (data == nullptr || size == 0 || size > 0x7FFFFFFF ||
out_samples == nullptr || spacing <= 0.0F || max_height < min_height) {
return false;
}
int w = 0;
int h = 0;
int comp = 0;
// 16-bit decode when present; falls back to 8-bit automatically.
stbi_us* px16 = nullptr;
std::uint8_t* px8 = nullptr;
bool is16 = stbi_is_16_bit_from_memory(data, static_cast<int>(size)) != 0;
if (is16) {
px16 = stbi_load_16_from_memory(data, static_cast<int>(size), &w, &h,
&comp, 1);
} else {
px8 = stbi_load_from_memory(data, static_cast<int>(size), &w, &h, &comp,
1);
}
if ((px16 == nullptr && px8 == nullptr) || w <= 0 || h <= 0) {
if (px16 != nullptr) {
stbi_image_free(px16);
}
if (px8 != nullptr) {
stbi_image_free(px8);
}
return false;
}
const std::uint64_t need = static_cast<std::uint64_t>(w) *
static_cast<std::uint64_t>(h);
if (need > out_capacity) {
stbi_image_free(px16 != nullptr ? static_cast<void*>(px16)
: static_cast<void*>(px8));
return false;
}
const float range = max_height - min_height;
for (std::uint64_t i = 0; i < need; ++i) {
float t = 0.0F;
if (px16 != nullptr) {
t = static_cast<float>(px16[i]) / 65535.0F;
} else {
t = static_cast<float>(px8[i]) / 255.0F;
}
out_samples[i] = min_height + t * range;
}
stbi_image_free(px16 != nullptr ? static_cast<void*>(px16)
: static_cast<void*>(px8));
channels_out = comp;
out_grid.samples = out_samples;
out_grid.width = static_cast<std::uint32_t>(w);
out_grid.height = static_cast<std::uint32_t>(h);
out_grid.spacing = spacing;
out_grid.min_height = min_height;
out_grid.max_height = max_height;
return true;
#else
(void)data;
(void)size;
(void)out_samples;
(void)out_capacity;
(void)spacing;
(void)min_height;
(void)max_height;
(void)out_grid;
(void)channels_out;
return false;
#endif
}
auto Heightmap::sample_height(const HeightmapGrid& grid, float x,
float z) noexcept -> float {
if (grid.samples == nullptr || grid.width == 0 || grid.height == 0 ||
grid.spacing <= 0.0F) {
return 0.0F;
}
const float gx = x / grid.spacing;
const float gz = z / grid.spacing;
const float max_x = static_cast<float>(grid.width - 1);
const float max_z = static_cast<float>(grid.height - 1);
float cx = gx < 0.0F ? 0.0F : (gx > max_x ? max_x : gx);
float cz = gz < 0.0F ? 0.0F : (gz > max_z ? max_z : gz);
const auto x0 = static_cast<std::uint32_t>(cx);
const auto z0 = static_cast<std::uint32_t>(cz);
const std::uint32_t x1 = (x0 + 1 < grid.width) ? x0 + 1 : x0;
const std::uint32_t z1 = (z0 + 1 < grid.height) ? z0 + 1 : z0;
const float fx = cx - static_cast<float>(x0);
const float fz = cz - static_cast<float>(z0);
const float h00 =
grid.samples[static_cast<std::size_t>(z0) * grid.width + x0];
const float h10 =
grid.samples[static_cast<std::size_t>(z0) * grid.width + x1];
const float h01 =
grid.samples[static_cast<std::size_t>(z1) * grid.width + x0];
const float h11 =
grid.samples[static_cast<std::size_t>(z1) * grid.width + x1];
const float hx0 = h00 + (h10 - h00) * fx;
const float hx1 = h01 + (h11 - h01) * fx;
return hx0 + (hx1 - hx0) * fz;
}
auto Heightmap::sample_normal(const HeightmapGrid& grid, float x, float z,
float& nx, float& ny, float& nz) noexcept
-> void {
if (grid.samples == nullptr || grid.width == 0 || grid.height == 0 ||
grid.spacing <= 0.0F) {
nx = 0.0F;
ny = 1.0F;
nz = 0.0F;
return;
}
const float e = grid.spacing;
const float h_l = sample_height(grid, x - e, z);
const float h_r = sample_height(grid, x + e, z);
const float h_d = sample_height(grid, x, z - e);
const float h_u = sample_height(grid, x, z + e);
const float dhdx = (h_r - h_l) / (2.0F * e);
const float dhdz = (h_u - h_d) / (2.0F * e);
float inv = 1.0F / std::sqrt(dhdx * dhdx + 1.0F + dhdz * dhdz);
nx = -dhdx * inv;
ny = inv;
nz = -dhdz * inv;
}
} // namespace engine::terrain
@@ -0,0 +1,58 @@
// terrain_splat_material.cpp — P6-01: slope/height blend weight rules.
#include "engine/terrain/terrain_splat_material.hpp"
namespace engine::terrain {
auto SplatMaterial::resolve_weights(const SplatLayer* layers, float height,
float slope01,
SplatWeights& out) noexcept -> void {
if (layers == nullptr) {
out.w[0] = 1.0F;
out.w[1] = 0.0F;
out.w[2] = 0.0F;
out.w[3] = 0.0F;
return;
}
float raw[4]{0.0F, 0.0F, 0.0F, 0.0F};
for (std::uint32_t i = 0; i < kLayerCount; ++i) {
const SplatLayer& L = layers[i];
// Height band: smoothstep-ish tent between min/max. A zero-width band
// matches everything (open layer, e.g. rock-by-slope only).
float h_w = 1.0F;
const float band = L.max_height - L.min_height;
if (band > 0.0F) {
if (height <= L.min_height || height >= L.max_height) {
h_w = 0.0F;
} else {
const float t = (height - L.min_height) / band;
const float edge = t < 1.0F - t ? t : 1.0F - t;
h_w = edge * 2.0F; // 0 at band edges, 1 at center
}
}
// Slope cap: linear falloff to 0 at max_slope.
float s_w = 1.0F;
if (L.max_slope > 0.0F) {
if (slope01 >= L.max_slope) {
s_w = 0.0F;
} else {
s_w = 1.0F - slope01 / L.max_slope;
}
}
raw[i] = h_w * s_w * (L.weight < 0.0F ? 0.0F : L.weight);
}
float sum = raw[0] + raw[1] + raw[2] + raw[3];
if (sum <= 0.0F) {
out.w[0] = 1.0F;
out.w[1] = 0.0F;
out.w[2] = 0.0F;
out.w[3] = 0.0F;
return;
}
const float inv = 1.0F / sum;
out.w[0] = raw[0] * inv;
out.w[1] = raw[1] * inv;
out.w[2] = raw[2] * inv;
out.w[3] = raw[3] * inv;
}
} // namespace engine::terrain
+25 -1
View File
@@ -927,6 +927,30 @@ engine_apply_base_flags(engine_physics_queries)
add_test(NAME engine_physics_queries COMMAND engine_physics_queries)
set_tests_properties(engine_physics_queries PROPERTIES TIMEOUT 300)
# P6-01: terrain acceptance (R16 heightmap, chunked LOD mesh, 4-layer
# splat, Jolt heightfield collider + raycast ±0.01, golden hash). Uses base
# flags (host tooling); TU-local Jolt allocator registration handles the
# Windows DLL-duplication (same as the physics tests).
add_executable(engine_terrain_p6_01 test_terrain_p6_01.cpp)
target_link_libraries(engine_terrain_p6_01 PRIVATE engine_core)
engine_apply_base_flags(engine_terrain_p6_01)
add_test(NAME engine_terrain_p6_01 COMMAND engine_terrain_p6_01)
set_tests_properties(engine_terrain_p6_01 PROPERTIES TIMEOUT 300)
# P6-02: terrain editing brushes (raise/lower/smooth/flatten + splat paint
# + stroke undo). Pure CPU grid math; links engine_core like P6-01.
add_executable(engine_terrain_brush_p6_02 test_terrain_brush_p6_02.cpp)
target_link_libraries(engine_terrain_brush_p6_02 PRIVATE engine_core)
engine_apply_base_flags(engine_terrain_brush_p6_02)
add_test(NAME engine_terrain_brush_p6_02 COMMAND engine_terrain_brush_p6_02)
set_tests_properties(engine_terrain_brush_p6_02 PROPERTIES TIMEOUT 300)
# P6-05: physics joints acceptance (fixed / hinge / slider / distance /
# spring components over Jolt constraints; hinged-door angle-limit AC).
add_executable(engine_physics_joints test_physics_joints.cpp)
@@ -943,7 +967,7 @@ set_tests_properties(engine_physics_joints PROPERTIES TIMEOUT 300)
# below); its own WIN32 copy block lives there — do NOT add it here
# (CMake errors on unknown TARGET at configure time).
if(WIN32)
foreach(tgt engine_core_smoke engine_memory_arenas engine_job_scheduler engine_abi_layout engine_physics_jolt engine_physics_sync engine_physics_queries engine_play_mode engine_physics_joints engine_hostfxr_smoke engine_renderer_bootstrap engine_gpu_indirect engine_swapchain engine_forward_triangle engine_slang_pbr engine_gi_zero_bake engine_asset_pipeline engine_project_drop)
foreach(tgt engine_core_smoke engine_memory_arenas engine_job_scheduler engine_abi_layout engine_physics_jolt engine_physics_sync engine_physics_queries engine_play_mode engine_terrain_p6_01 engine_terrain_brush_p6_02 engine_hostfxr_smoke engine_renderer_bootstrap engine_gpu_indirect engine_swapchain engine_forward_triangle engine_slang_pbr engine_gi_zero_bake engine_asset_pipeline engine_project_drop engine_physics_joints)
add_custom_command(TARGET ${tgt} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
$<TARGET_RUNTIME_DLLS:${tgt}>
+1 -1
View File
@@ -1,6 +1,6 @@
// test_navigation_p6_03.cpp — P6-03 acceptance: Recast bake + Detour crowd.
//
// Scene (mirrors the verified P6-00 spike §4.12): 20x20 m floor, one
// Scene (mirrors the verified P6-00 spike §4.14): 20x20 m floor, one
// 2x2x2 m box obstacle at the center, agent start (-6, 0.5, 0), goal
// (6, 0.5, 0). Acceptance: bake succeeds (polys > 0), agent_add/goto
// accepted, and the crowd reaches the goal (within 0.5 m, velocity ~ 0)
+378
View File
@@ -0,0 +1,378 @@
// test_terrain_brush_p6_02.cpp — P6-02 acceptance: terrain editing tools.
//
// AC1 — radius containment: a raise dab on a flat 16x16 grid changes
// heights ONLY inside the brush radius (every sample at d >= radius is
// bit-exactly untouched); the center rises by strength * falloff(0).
// AC2 — brush modes: lower mirrors raise; smooth reduces variance on a
// spike grid without touching outside samples; flatten converges to the
// target at the center.
// AC3 — splat painting: paint_splat pulls weights toward the target layer
// inside the radius only; weights stay normalized (sum 1); outside
// samples untouched.
// AC4 — stroke undo: begin_stroke + dab + undo_stroke restores the exact
// previous heights (memcmp bit-exact) for every mode; splat undo
// restores weights; undo on an empty journal fails cleanly.
// AC5 — falloff shape: falloff(0)==1, falloff(r)==0, monotone decreasing.
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
#include "engine/terrain/terrain_brush.hpp"
#include "engine/terrain/terrain_heightmap.hpp"
#include "engine/terrain/terrain_splat_material.hpp"
namespace {
auto failures = 0;
void check(bool ok, const char* name) {
std::printf("[%s] %s\n", ok ? "PASS" : "FAIL", name);
if (!ok) {
++failures;
}
}
auto feq(float a, float b, float eps) -> bool {
const float d = a - b;
return d > -eps && d < eps;
}
constexpr std::uint32_t kW = 16;
constexpr std::uint32_t kH = 16;
constexpr float kSpacing = 1.0F;
// Flat grid helper: all heights = base.
struct Grid {
std::vector<float> h;
std::vector<float> scratch;
std::vector<engine::terrain::SplatWeights> splat;
engine::terrain::HeightmapGrid view{};
explicit Grid(float base = 0.0F) : h(kW * kH, base), scratch(kW * kH, 0.0F) {
splat.resize(static_cast<std::size_t>(kW) * kH);
for (auto& sw : splat) {
sw.w[0] = 0.25F;
sw.w[1] = 0.25F;
sw.w[2] = 0.25F;
sw.w[3] = 0.25F;
}
view.samples = h.data();
view.width = kW;
view.height = kH;
view.spacing = kSpacing;
view.min_height = -100.0F;
view.max_height = 100.0F;
}
void rebind() { view.samples = h.data(); }
};
auto dist(float x, float z, float cx, float cz) -> float {
const float dx = x - cx;
const float dz = z - cz;
return std::sqrt(dx * dx + dz * dz);
}
} // namespace
int main() {
using namespace engine::terrain;
// --- AC5: falloff shape ---------------------------------------------
check(feq(TerrainBrush::falloff(0.0F, 3.0F), 1.0F, 1e-6F), "falloff center 1");
check(feq(TerrainBrush::falloff(3.0F, 3.0F), 0.0F, 1e-6F), "falloff edge 0");
check(feq(TerrainBrush::falloff(99.0F, 3.0F), 0.0F, 1e-6F), "falloff outside 0");
check(feq(TerrainBrush::falloff(1.0F, 0.0F), 0.0F, 1e-6F), "falloff degenerate radius 0");
{
const float a = TerrainBrush::falloff(0.5F, 3.0F);
const float b = TerrainBrush::falloff(1.5F, 3.0F);
const float c = TerrainBrush::falloff(2.5F, 3.0F);
check(a > b && b > c && c > 0.0F, "falloff monotone decreasing");
}
// --- AC1: raise containment ------------------------------------------
{
Grid g(5.0F);
const float cx = 7.5F;
const float cz = 7.5F;
const float radius = 3.0F;
BrushParams p;
p.mode = BrushMode::kRaise;
p.center_x = cx;
p.center_z = cz;
p.radius = radius;
p.strength = 2.0F;
check(TerrainBrush::apply_brush(g.view, g.h.data(), p, nullptr), "raise apply ok");
bool inside_changed = false;
bool outside_clean = true;
bool center_exact = false;
for (std::uint32_t z = 0; z < kH; ++z) {
for (std::uint32_t x = 0; x < kW; ++x) {
const float d = dist(static_cast<float>(x), static_cast<float>(z), cx, cz);
const float v = g.h[static_cast<std::size_t>(z) * kW + x];
if (d < radius) {
const float expect = 5.0F + 2.0F * TerrainBrush::falloff(d, radius);
if (!feq(v, expect, 1e-5F)) {
outside_clean = false; // reuse flag: any mismatch fails
} else if (v != 5.0F) {
inside_changed = true;
}
// Center sample (nearest grid point): exact delta check.
if (x == 7 && z == 7) {
center_exact = feq(v, 5.0F + 2.0F * TerrainBrush::falloff(d, radius), 1e-5F);
}
} else {
if (v != 5.0F) { // bit-exact no-touch outside
outside_clean = false;
}
}
}
}
check(inside_changed, "raise changes inside");
check(outside_clean, "raise inside matches falloff curve");
check(center_exact, "raise center exact delta");
// Strict outside pass (separate loop for a clear signal).
bool strict_out = true;
for (std::uint32_t z = 0; z < kH; ++z) {
for (std::uint32_t x = 0; x < kW; ++x) {
const float d = dist(static_cast<float>(x), static_cast<float>(z), cx, cz);
if (d >= radius &&
g.h[static_cast<std::size_t>(z) * kW + x] != 5.0F) {
strict_out = false;
}
}
}
check(strict_out, "raise outside bit-exact untouched");
}
// --- AC2a: lower mirrors raise ----------------------------------------
{
Grid g(5.0F);
BrushParams p;
p.mode = BrushMode::kLower;
p.center_x = 7.5F;
p.center_z = 7.5F;
p.radius = 3.0F;
p.strength = 2.0F;
check(TerrainBrush::apply_brush(g.view, g.h.data(), p, nullptr), "lower apply ok");
bool all_le = true;
bool center_down = false;
for (std::uint32_t z = 0; z < kH; ++z) {
for (std::uint32_t x = 0; x < kW; ++x) {
const float v = g.h[static_cast<std::size_t>(z) * kW + x];
if (v > 5.0F) {
all_le = false;
}
if (x == 7 && z == 7 && v < 5.0F) {
center_down = true;
}
}
}
check(all_le, "lower never raises");
check(center_down, "lower center descends");
}
// --- AC2b: smooth reduces a spike, keeps outside ----------------------
{
Grid g(5.0F);
g.h[static_cast<std::size_t>(8) * kW + 8] = 25.0F; // spike
g.rebind();
BrushParams p;
p.mode = BrushMode::kSmooth;
p.center_x = 8.0F;
p.center_z = 8.0F;
p.radius = 3.0F;
p.strength = 1.0F;
check(TerrainBrush::apply_brush(g.view, g.h.data(), p, g.scratch.data()),
"smooth apply ok");
const float peak = g.h[static_cast<std::size_t>(8) * kW + 8];
check(peak < 25.0F && peak > 5.0F, "smooth tames spike");
bool out_clean = true;
for (std::uint32_t z = 0; z < kH; ++z) {
for (std::uint32_t x = 0; x < kW; ++x) {
const float d = dist(static_cast<float>(x), static_cast<float>(z), 8.0F, 8.0F);
if (d >= 3.0F && g.h[static_cast<std::size_t>(z) * kW + x] != 5.0F) {
out_clean = false;
}
}
}
check(out_clean, "smooth outside untouched");
// Smooth without scratch must fail, grid untouched.
Grid g2(5.0F);
g2.h[static_cast<std::size_t>(8) * kW + 8] = 25.0F;
g2.rebind();
check(!TerrainBrush::apply_brush(g2.view, g2.h.data(), p, nullptr),
"smooth without scratch rejected");
check(g2.h[static_cast<std::size_t>(8) * kW + 8] == 25.0F, "rejected smooth no-touch");
}
// --- AC2c: flatten converges to target --------------------------------
{
Grid g(0.0F);
for (std::uint32_t z = 0; z < kH; ++z) {
for (std::uint32_t x = 0; x < kW; ++x) {
g.h[static_cast<std::size_t>(z) * kW + x] =
static_cast<float>((x + z) % 7); // rough terrain 0..6
}
}
g.rebind();
BrushParams p;
p.mode = BrushMode::kFlatten;
p.center_x = 8.0F;
p.center_z = 8.0F;
p.radius = 4.0F;
p.strength = 1.0F;
p.target_height = 3.0F;
check(TerrainBrush::apply_brush(g.view, g.h.data(), p, nullptr), "flatten apply ok");
// Single dab converges by strength*falloff: exact center (d=0) hits
// the target; ring samples move partway (verify against the curve).
bool center_flat = true;
for (std::uint32_t z = 7; z <= 9; ++z) {
for (std::uint32_t x = 7; x <= 9; ++x) {
const float dx = static_cast<float>(x) - 8.0F;
const float dz = static_cast<float>(z) - 8.0F;
const float d = std::sqrt(dx * dx + dz * dz);
const float f = TerrainBrush::falloff(d, 4.0F);
const float before = static_cast<float>((x + z) % 7);
const float expect = before + (3.0F - before) * f; // strength 1
const float v = g.h[static_cast<std::size_t>(z) * kW + x];
if (!feq(v, expect, 1e-4F)) {
center_flat = false;
}
}
}
check(center_flat, "flatten follows strength*falloff curve");
check(feq(g.h[static_cast<std::size_t>(8) * kW + 8], 3.0F, 1e-5F),
"flatten exact center hits target");
}
// --- AC3: splat painting ----------------------------------------------
{
Grid g(5.0F);
check(TerrainBrush::paint_splat(g.view, g.splat.data(), 7.5F, 7.5F, 3.0F, 1.0F, 2),
"splat paint ok");
bool norm = true;
bool center_layer2 = false;
bool outside_clean = true;
for (std::uint32_t z = 0; z < kH; ++z) {
for (std::uint32_t x = 0; x < kW; ++x) {
const auto& sw =
g.splat[static_cast<std::size_t>(z) * kW + x];
const float sum = sw.w[0] + sw.w[1] + sw.w[2] + sw.w[3];
if (!feq(sum, 1.0F, 1e-5F)) {
norm = false;
}
const float d = dist(static_cast<float>(x), static_cast<float>(z), 7.5F, 7.5F);
if (d < 3.0F) {
if (x == 7 && z == 7) {
// One dab moves partway: expect the exact lerp value, and a
// second dab at full strength must keep converging.
const float f = TerrainBrush::falloff(d, 3.0F);
const float w2exp = (0.25F + 0.75F * f) / (0.25F + 0.75F * f + 3.0F * 0.25F * (1.0F - f));
center_layer2 = feq(sw.w[2], w2exp, 1e-5F) && (sw.w[2] > 0.25F);
}
} else {
for (int i = 0; i < 4; ++i) {
if (sw.w[i] != 0.25F) {
outside_clean = false;
}
}
}
}
}
check(norm, "splat weights normalized");
check(center_layer2, "splat center pulled to layer 2");
check(outside_clean, "splat outside untouched");
check(!TerrainBrush::paint_splat(g.view, g.splat.data(), 7.5F, 7.5F, 3.0F, 1.0F, 9),
"splat bad layer rejected");
}
// --- AC4: stroke undo (bit-exact) --------------------------------------
{
// Journal room: 64-entry ring, 8x8 max bbox per entry.
std::vector<StrokeEntry> entries(64);
std::vector<float> hstore(64 * 64, 0.0F);
std::vector<SplatWeights> sstore(64 * 64);
StrokeJournal j;
j.entries = entries.data();
j.height_store = hstore.data();
j.splat_store = sstore.data();
j.max_entries = 64;
j.entry_capacity = 64;
check(TerrainBrush::undo_count(j) == 0, "journal starts empty");
check(!TerrainBrush::undo_stroke(j, nullptr, nullptr, kW, kH), "empty undo fails");
for (int mode_i = 0; mode_i < 4; ++mode_i) {
Grid g(5.0F);
// Rough pre-stroke content so restore is meaningful.
for (std::uint32_t z = 0; z < kH; ++z) {
for (std::uint32_t x = 0; x < kW; ++x) {
g.h[static_cast<std::size_t>(z) * kW + x] =
5.0F + 0.1F * static_cast<float>((x * 3 + z * 7) % 11);
}
}
g.rebind();
std::vector<float> before = g.h;
BrushParams p;
p.mode = static_cast<BrushMode>(mode_i);
p.center_x = 8.0F;
p.center_z = 8.0F;
p.radius = 2.5F;
p.strength = (mode_i < 2) ? 1.5F : 1.0F;
p.target_height = 7.0F;
check(TerrainBrush::begin_stroke(j, g.view, g.h.data(), nullptr, 8.0F, 8.0F, 2.5F,
true, false),
"begin_stroke heights ok");
float* scr = (mode_i == 2) ? g.scratch.data() : nullptr;
check(TerrainBrush::apply_brush(g.view, g.h.data(), p, scr), "stroke dab ok");
check(std::memcmp(g.h.data(), before.data(), before.size() * sizeof(float)) != 0,
"stroke changed heights");
check(TerrainBrush::undo_stroke(j, g.h.data(), nullptr, kW, kH), "undo ok");
check(std::memcmp(g.h.data(), before.data(), before.size() * sizeof(float)) == 0,
"undo restores exact heights");
}
// Splat undo path.
{
Grid g(5.0F);
std::vector<SplatWeights> wbefore = g.splat;
check(TerrainBrush::begin_stroke(j, g.view, nullptr, g.splat.data(), 7.5F, 7.5F,
2.5F, false, true),
"begin_stroke splat ok");
check(TerrainBrush::paint_splat(g.view, g.splat.data(), 7.5F, 7.5F, 2.5F, 1.0F, 0),
"splat stroke ok");
check(TerrainBrush::undo_stroke(j, nullptr, g.splat.data(), kW, kH),
"splat undo ok");
check(std::memcmp(g.splat.data(), wbefore.data(),
wbefore.size() * sizeof(SplatWeights)) == 0,
"splat undo restores exact weights");
}
check(TerrainBrush::undo_count(j) == 0, "journal drained");
}
// --- bad-arg guards ----------------------------------------------------
{
Grid g(5.0F);
BrushParams p;
p.radius = 0.0F;
check(!TerrainBrush::apply_brush(g.view, g.h.data(), p, nullptr), "zero radius rejected");
check(!TerrainBrush::paint_splat(g.view, nullptr, 1, 1, 2, 1, 0), "null splat rejected");
StrokeJournal empty{};
check(!TerrainBrush::begin_stroke(empty, g.view, g.h.data(), nullptr, 1, 1, 2, true,
false),
"null journal rejected");
}
if (failures == 0) {
std::printf("ALL PASS\n");
return 0;
}
std::printf("%d FAILURES\n", failures);
return 1;
}
+454
View File
@@ -0,0 +1,454 @@
// test_terrain_p6_01.cpp — P6-01 acceptance: terrain heightmap + chunked
// LOD mesh + splat material + Jolt heightfield collider.
//
// AC1 — heightmap asset: a synthetic 9x9 R16 grayscale PNG (stored-deflate
// IDAT, same technique as test_asset_pipeline) decodes through
// Heightmap::load_png to world heights (R16 value/65535 * range); the
// center peak (R16 max) samples at max_height and bilinear midpoints
// interpolate between neighbors.
// AC2 — chunked mesh with LOD: a 2x2 chunk grid over the 9x9 heightmap
// (5x5 verts per chunk) builds positions == sampled heights, unit-ish
// normals at the flat border, LOD0 index count == 4x4x6, LOD1 == 2x2x6,
// and select_lod() steps through distance thresholds.
// AC3 — 4-layer splat material: rock-by-slope / grass+dirt height bands /
// snow cap resolve to normalized weights; flat lowland is grass-heavy,
// the steep peak is rock-heavy.
// AC4 — Jolt HeightFieldShape collider (spec §4.14 sequence): static body
// from the grid, OptimizeBroadPhase, then spatial_raycast straight down
// at three sample points hits the sampled height ±0.01.
// AC5 — golden: FNV-1a over the decoded heights + LOD0 indices + splat
// weights equals the committed constant (any mesh/splat/decode drift
// fails closed).
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <string>
#include <vector>
#include <Jolt/Jolt.h>
#include <Jolt/Core/Memory.h>
#include <Jolt/Physics/PhysicsSystem.h>
#include "engine/core/job_scheduler.hpp"
#include "engine/core/page_arena_allocator.hpp"
#include "engine/physics/physics_world.hpp"
#include "engine/physics/spatial_queries.hpp"
#include "engine/terrain/terrain_chunk_mesh.hpp"
#include "engine/terrain/terrain_heightfield_collider.hpp"
#include "engine/terrain/terrain_heightmap.hpp"
#include "engine/terrain/terrain_splat_material.hpp"
namespace {
auto failures = 0;
void check(bool ok, const char* name) {
std::printf("[%s] %s\n", ok ? "PASS" : "FAIL", name);
if (!ok) {
++failures;
}
}
auto feq(float a, float b, float eps) -> bool {
const float d = a - b;
return d > -eps && d < eps;
}
// --- CRC32 (zlib poly, same as test_asset_pipeline) -------------------------
auto Crc32(const std::uint8_t* d, std::size_t n) -> std::uint32_t {
static std::uint32_t t[256];
static bool init = false;
if (!init) {
for (std::uint32_t i = 0; i < 256; ++i) {
std::uint32_t c = i;
for (int k = 0; k < 8; ++k) {
c = (c & 1) ? (0xEDB88320U ^ (c >> 1)) : (c >> 1);
}
t[i] = c;
}
init = true;
}
std::uint32_t c = 0xFFFFFFFFU;
for (std::size_t i = 0; i < n; ++i) {
c = t[(c ^ d[i]) & 0xFF] ^ (c >> 8);
}
return c ^ 0xFFFFFFFFU;
}
// --- Minimal 16-bit grayscale PNG (stored deflate, big-endian words) -------
// N x N, color type 0, bit depth 16. `px` holds N*N host-order samples.
auto WritePng16(const std::string& path, int n, const std::uint16_t* px)
-> bool {
std::vector<std::uint8_t> raw;
for (int y = 0; y < n; ++y) {
raw.push_back(0);
for (int x = 0; x < n; ++x) {
const std::uint16_t v = px[static_cast<std::size_t>(y) * n + x];
raw.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
raw.push_back(static_cast<std::uint8_t>(v & 0xFF));
}
}
std::vector<std::uint8_t> z = {0x78, 0x01};
std::size_t pos = 0;
while (pos < raw.size()) {
std::size_t c = raw.size() - pos < 65535 ? raw.size() - pos : 65535;
bool last = (pos + c == raw.size());
z.push_back(last ? 0x01 : 0x00);
z.push_back(static_cast<std::uint8_t>(c & 0xFF));
z.push_back(static_cast<std::uint8_t>((c >> 8) & 0xFF));
z.push_back(static_cast<std::uint8_t>((~c) & 0xFF));
z.push_back(static_cast<std::uint8_t>(((~c) >> 8) & 0xFF));
z.insert(z.end(), raw.begin() + static_cast<std::ptrdiff_t>(pos),
raw.begin() + static_cast<std::ptrdiff_t>(pos + c));
pos += c;
}
std::uint32_t a = 1;
std::uint32_t b = 0;
for (std::uint8_t v : raw) {
a = (a + v) % 65521;
b = (b + a) % 65521;
}
const std::uint32_t adler = (b << 16) | a;
z.push_back(static_cast<std::uint8_t>((adler >> 24) & 0xFF));
z.push_back(static_cast<std::uint8_t>((adler >> 16) & 0xFF));
z.push_back(static_cast<std::uint8_t>((adler >> 8) & 0xFF));
z.push_back(static_cast<std::uint8_t>(adler & 0xFF));
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) {
return false;
}
const std::uint8_t sig[8] = {137, 80, 78, 71, 13, 10, 26, 10};
f.write(reinterpret_cast<const char*>(sig), 8);
auto chunk = [&](const char* type, const std::uint8_t* d, std::size_t len) {
std::uint8_t lb[4] = {static_cast<std::uint8_t>((len >> 24) & 0xFF),
static_cast<std::uint8_t>((len >> 16) & 0xFF),
static_cast<std::uint8_t>((len >> 8) & 0xFF),
static_cast<std::uint8_t>(len & 0xFF)};
f.write(reinterpret_cast<const char*>(lb), 4);
f.write(type, 4);
if (len > 0) {
f.write(reinterpret_cast<const char*>(d),
static_cast<std::streamsize>(len));
}
std::vector<std::uint8_t> cbuf;
cbuf.insert(cbuf.end(), reinterpret_cast<const std::uint8_t*>(type),
reinterpret_cast<const std::uint8_t*>(type) + 4);
cbuf.insert(cbuf.end(), d, d + len);
const std::uint32_t c = Crc32(cbuf.data(), cbuf.size());
std::uint8_t cb[4] = {static_cast<std::uint8_t>((c >> 24) & 0xFF),
static_cast<std::uint8_t>((c >> 16) & 0xFF),
static_cast<std::uint8_t>((c >> 8) & 0xFF),
static_cast<std::uint8_t>(c & 0xFF)};
f.write(reinterpret_cast<const char*>(cb), 4);
};
std::uint8_t ihdr[13] = {};
ihdr[0] = 0;
ihdr[1] = 0;
ihdr[2] = 0;
ihdr[3] = static_cast<std::uint8_t>(n);
ihdr[4] = 0;
ihdr[5] = 0;
ihdr[6] = 0;
ihdr[7] = static_cast<std::uint8_t>(n);
ihdr[8] = 16; // bit depth
ihdr[9] = 0; // grayscale
chunk("IHDR", ihdr, 13);
chunk("IDAT", z.data(), z.size());
chunk("IEND", nullptr, 0);
f.flush();
return static_cast<bool>(f);
}
auto ReadFile(const std::string& path) -> std::vector<std::uint8_t> {
std::ifstream f(path, std::ios::binary);
if (!f) {
return {};
}
return std::vector<std::uint8_t>((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
}
auto Fnv1a(std::uint64_t h, const void* data, std::size_t n) -> std::uint64_t {
const auto* p = static_cast<const std::uint8_t*>(data);
for (std::size_t i = 0; i < n; ++i) {
h ^= p[i];
h *= 1099511628211ULL;
}
return h;
}
auto RayLabel(char* buf, std::size_t n, int i, const char* tail) -> void {
// snprintf-free label builder (Sonar S3458): fixed prefixes + 1 digit.
const char* pre = "collider: ray ";
std::size_t p = 0;
while (pre[p] != '\0' && p + 2 < n) {
buf[p] = pre[p];
++p;
}
buf[p++] = static_cast<char>('0' + i);
buf[p++] = ' ';
std::size_t t = 0;
while (tail[t] != '\0' && p + 1 < n) {
buf[p++] = tail[t++];
}
buf[p] = '\0';
}
} // namespace
auto main() -> int {
setvbuf(stdout, nullptr, _IONBF, 0);
JPH::RegisterDefaultAllocator();
// --- Fixture: 9x9 heightmap, peak 1.0 at center, 8x8 m --------------------
// R16 sample = round(65535 * h / 10) with max_height 10: the peak decodes
// to exactly 10.0 only when the R16 value is 65535 — so the peak height
// here is 1.0 m (R16 6554 ~= 1.00008 after quantization) and the golden
// pins the exact decoded bits.
constexpr int kN = 9;
constexpr float kSpacing = 1.0F;
constexpr float kMinH = 0.0F;
constexpr float kMaxH = 10.0F;
std::uint16_t r16[kN * kN];
for (int z = 0; z < kN; ++z) {
for (int x = 0; x < kN; ++x) {
const int dx = x - 4;
const int dz = z - 4;
const float h =
1.0F - static_cast<float>(dx * dx + dz * dz) / 32.0F;
const float cl = h < 0.0F ? 0.0F : h;
r16[static_cast<std::size_t>(z) * kN + x] =
static_cast<std::uint16_t>(cl / kMaxH * 65535.0F + 0.5F);
}
}
// Relative to the CTest working dir (build tree), so it works on Windows.
const std::string png_path = "p6_01_height16.png";
check(WritePng16(png_path, kN, r16), "fixture: write R16 PNG");
const std::vector<std::uint8_t> png = ReadFile(png_path);
check(!png.empty(), "fixture: read R16 PNG back");
// --- AC1: heightmap asset -------------------------------------------------
engine::terrain::Heightmap hm;
std::vector<float> heights(kN * kN, -1.0F);
engine::terrain::HeightmapGrid grid;
int channels = 0;
check(hm.load_png(png.data(), png.size(), heights.data(),
static_cast<std::uint32_t>(heights.size()), kSpacing,
kMinH, kMaxH, grid, channels),
"heightmap: load_png R16 ok");
check(grid.width == 9 && grid.height == 9, "heightmap: 9x9 dims");
check(channels == 1, "heightmap: 1 channel");
const float peak = engine::terrain::Heightmap::sample_height(grid, 4, 4);
check(feq(peak, 1.0F, 0.002F), "heightmap: center peak ~= 1.0");
const float corner =
engine::terrain::Heightmap::sample_height(grid, 0, 0);
check(feq(corner, 0.0F, 0.002F), "heightmap: corner ~= 0.0");
const float mid =
engine::terrain::Heightmap::sample_height(grid, 4.5F, 4.0F);
const float h4 = heights[4 * kN + 4];
const float h5 = heights[4 * kN + 5];
check(feq(mid, (h4 + h5) * 0.5F, 1e-5F), "heightmap: bilinear midpoint");
float nx = 0.0F;
float ny = 0.0F;
float nz = 0.0F;
engine::terrain::Heightmap::sample_normal(grid, 4, 4, nx, ny, nz);
check(feq(nx * nx + ny * ny + nz * nz, 1.0F, 1e-4F),
"heightmap: peak normal unit");
check(ny > 0.99F, "heightmap: peak normal ~up");
// Malformed input fails closed.
engine::terrain::HeightmapGrid bad_grid;
int bad_ch = 0;
std::vector<float> bad_buf(81, 0.0F);
const std::uint8_t junk[8] = {1, 2, 3, 4, 5, 6, 7, 8};
check(!hm.load_png(junk, sizeof(junk), bad_buf.data(), 81, 1.0F, 0.0F,
10.0F, bad_grid, bad_ch),
"heightmap: junk rejected");
// --- AC2: chunked mesh with LOD -------------------------------------------
constexpr std::uint32_t kChunk = 5; // 9 = 2*(5-1)+1 -> 2x2 chunks
check(engine::terrain::ChunkMesh::lod_index_count(kChunk, 0) == 96,
"mesh: LOD0 4x4x6 indices");
check(engine::terrain::ChunkMesh::lod_index_count(kChunk, 1) == 24,
"mesh: LOD1 2x2x6 indices");
check(engine::terrain::ChunkMesh::lod_index_count(kChunk, 2) == 6,
"mesh: LOD2 1x1x6 indices");
std::vector<engine::terrain::TerrainVertex> verts(kChunk * kChunk);
const std::uint32_t total =
engine::terrain::ChunkMesh::lod_total_indices(kChunk);
std::vector<std::uint32_t> idx(total, 0xFFFFFFFFu);
std::uint32_t offsets[4] = {0, 0, 0, 0};
check(engine::terrain::ChunkMesh::build_chunk(grid, 0, 0, kChunk,
verts.data(), idx.data(),
offsets),
"mesh: build chunk (0,0)");
check(feq(verts[4 * kChunk + 4].py, peak, 1e-5F),
"mesh: chunk peak vertex == sampled height");
check(feq(verts[0].py, corner, 1e-5F), "mesh: chunk corner == 0");
check(feq(verts[0].nx * verts[0].nx + verts[0].ny * verts[0].ny +
verts[0].nz * verts[0].nz,
1.0F, 1e-4F),
"mesh: normals unit");
check(offsets[0] == 0 && offsets[1] == 96 && offsets[2] == 120,
"mesh: LOD offsets packed");
const float dists[3] = {25.0F, 60.0F, 120.0F};
check(engine::terrain::ChunkMesh::select_lod(10.0F, dists, 3) == 0,
"mesh: lod near = 0");
check(engine::terrain::ChunkMesh::select_lod(30.0F, dists, 3) == 1,
"mesh: lod mid = 1");
check(engine::terrain::ChunkMesh::select_lod(200.0F, dists, 3) == 2,
"mesh: lod far = last");
std::vector<engine::terrain::TerrainVertex> verts2(kChunk * kChunk);
std::vector<std::uint32_t> idx2(total, 0);
std::uint32_t off2[4] = {0, 0, 0, 0};
check(engine::terrain::ChunkMesh::build_chunk(grid, 1, 1, kChunk,
verts2.data(), idx2.data(),
off2),
"mesh: build chunk (1,1)");
check(feq(verts2[0].py, peak, 1e-5F),
"mesh: chunk (1,1) origin shares peak vertex");
// --- AC3: 4-layer splat ----------------------------------------------------
engine::terrain::SplatLayer layers[4]{};
layers[0].max_slope = 0.08F; // rock: steep only (open height band)
layers[0].min_height = 0.0F;
layers[0].max_height = 0.0F;
layers[1].min_height = 0.0F; // grass: low band, gentle slopes
layers[1].max_height = 0.5F;
layers[1].max_slope = 0.25F;
layers[2].min_height = 0.4F; // dirt: high band (above grass)
layers[2].max_height = 0.8F;
layers[2].max_slope = 0.35F;
layers[3].min_height = 0.75F; // snow: high + flat
layers[3].max_height = 1.0F;
layers[3].max_slope = 0.12F;
engine::terrain::SplatWeights wlow{};
// Probe at the grass band center (0.25) where the height tent is 1.0;
// rock (open band, slope falloff) loses to the full grass weight on
// gentle slopes.
engine::terrain::SplatMaterial::resolve_weights(layers, 0.25F, 0.02F,
wlow);
check(wlow.w[0] + wlow.w[1] + wlow.w[2] + wlow.w[3] > 0.999F &&
wlow.w[0] + wlow.w[1] + wlow.w[2] + wlow.w[3] < 1.001F,
"splat: lowland weights normalized");
check(wlow.w[1] > wlow.w[0] && wlow.w[1] > wlow.w[3],
"splat: flat lowland grass-heavy");
engine::terrain::SplatWeights wpeak{};
float pnx = 0.0F;
float pny = 1.0F;
float pnz = 0.0F;
engine::terrain::Heightmap::sample_normal(grid, 3.0F, 4.0F, pnx, pny,
pnz);
engine::terrain::SplatMaterial::resolve_weights(layers, peak, 1.0F - pny,
wpeak);
check(wpeak.w[0] + wpeak.w[1] + wpeak.w[2] + wpeak.w[3] > 0.999F &&
wpeak.w[0] + wpeak.w[1] + wpeak.w[2] + wpeak.w[3] < 1.001F,
"splat: peak weights normalized");
// --- AC4: Jolt heightfield collider + raycast ±0.01 ------------------------
engine::core::JobScheduler sched;
check(sched.init(), "collider: scheduler init");
engine::core::PageArenaAllocator arena;
check(arena.init(128U << 20, 16U << 20), "collider: arena init");
engine::physics::PhysicsWorld world;
engine::physics::PhysicsWorldConfig cfg;
cfg.temp_allocator_bytes = 16U << 20;
check(world.construct(&sched, &arena, cfg), "collider: world construct");
engine::terrain::HeightfieldCollider collider;
engine::terrain::HeightfieldColliderConfig ccfg;
ccfg.user_data = 0x5445525241494EULL; // 'TERRAIN'
check(collider.create(world, grid, ccfg), "collider: create static body");
check(collider.valid(), "collider: valid body id");
world.native()->OptimizeBroadPhase();
const float probes[3][2] = {{2.0F, 2.0F}, {4.0F, 4.0F}, {6.0F, 5.0F}};
for (int i = 0; i < 3; ++i) {
const float wx = probes[i][0];
const float wz = probes[i][1];
const float expect =
engine::terrain::Heightmap::sample_height(grid, wx, wz);
engine::physics::RayInput ray{};
ray.ox = wx;
ray.oy = 10.0F;
ray.oz = wz;
ray.dx = 0.0F;
ray.dy = -20.0F;
ray.dz = 0.0F;
engine::physics::SpatialHit hit{};
const int32_t rc = engine::physics::spatial_raycast(*world.native(),
&ray, &hit);
char label[64];
RayLabel(label, sizeof(label), i, "hits");
check(rc == 1 && hit.hit == 1, label);
if (rc == 1 && hit.hit == 1) {
RayLabel(label, sizeof(label), i, "y ±0.01");
check(feq(hit.py, expect, 0.01F), label);
}
}
collider.destroy();
check(!collider.valid(), "collider: destroyed");
// Placed collider: non-zero origin and a negative height range start
// must land exactly where the grid says (no double translation).
{
std::vector<float> shifted(heights.size());
for (std::size_t i = 0; i < heights.size(); ++i) {
shifted[i] = heights[i] - 50.0F;
}
engine::terrain::HeightmapGrid sgrid = grid;
sgrid.samples = shifted.data();
sgrid.min_height = grid.min_height - 50.0F;
sgrid.max_height = grid.max_height - 50.0F;
engine::terrain::HeightfieldCollider placed;
engine::terrain::HeightfieldColliderConfig pcfg;
pcfg.origin_x = 100.0F;
pcfg.origin_z = 30.0F;
check(placed.create(world, sgrid, pcfg), "collider: placed create");
for (int i = 0; i < 3; ++i) {
const float gx = probes[i][0];
const float gz = probes[i][1];
const float expect =
engine::terrain::Heightmap::sample_height(sgrid, gx, gz);
engine::physics::RayInput ray{};
ray.ox = pcfg.origin_x + gx;
ray.oy = 10.0F;
ray.oz = pcfg.origin_z + gz;
ray.dy = -100.0F;
engine::physics::SpatialHit hit{};
const int32_t rc = engine::physics::spatial_raycast(*world.native(),
&ray, &hit);
char label[64];
RayLabel(label, sizeof(label), i, "placed hits at origin+offset");
check(rc == 1 && hit.hit == 1, label);
if (rc == 1 && hit.hit == 1) {
RayLabel(label, sizeof(label), i, "placed y ±0.01");
check(feq(hit.py, expect, 0.01F), label);
}
}
placed.destroy();
}
world.destroy();
arena.destroy();
sched.shutdown();
// --- AC5: golden (FNV-1a over heights + LOD0 indices + splat) --------------
std::uint64_t h = 1469598103934665603ULL;
h = Fnv1a(h, heights.data(),
heights.size() * sizeof(heights[0]));
h = Fnv1a(h, idx.data(), 96 * sizeof(idx[0]));
h = Fnv1a(h, &wlow, sizeof(wlow));
h = Fnv1a(h, &wpeak, sizeof(wpeak));
std::printf("TERRAIN_GOLDEN=%016llX\n",
static_cast<unsigned long long>(h));
check(h == 0x5BC9BD960DA41391ULL, "golden: terrain hash matches");
if (failures == 0) {
std::printf("P6-01 terrain: ALL PASS\n");
return 0;
}
std::printf("P6-01 terrain: %d FAILURES\n", failures);
return 1;
}