mirror of
https://github.com/Heretek-AI/game-engine.git
synced 2026-11-07 22:42:36 -10:00
[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:
28 files changed
+3356
-8
No files matched your search
@@ -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(¶ms, &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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user