Skip to content

SpringController: rework smooth mesh camera height tracking (mode 2)#3012

Open
PtaQQ wants to merge 3 commits into
masterfrom
spring-camera-smoothmesh-tracking
Open

SpringController: rework smooth mesh camera height tracking (mode 2)#3012
PtaQQ wants to merge 3 commits into
masterfrom
spring-camera-smoothmesh-tracking

Conversation

@PtaQQ

@PtaQQ PtaQQ commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Reworks spring camera height tracking mode 2 (CamSpringTrackMapHeightMode=2) to base the camera focus height on the air smooth mesh directly, so the camera no longer bobs over every terrain bump while scrolling at fixed zoom. Related: #854

Why rework rather than fix in place
The old mode 2 nudged the zoom distance by per-frame smooth mesh deltas. It only applied while scrolling (zoom, minimap clicks and Lua camera moves bypassed it), under-compensated by the view tilt factor, and accumulated drift because the offset lived in the zoom distance. The new implementation is stateless: the focus height itself comes from the mesh, which covers all camera paths uniformly and cannot drift.

How it works

  • Focus height = mix(ground, smoothmesh, smoothstep(blendMin, blendMax, zoomDist)) — fades to the raw terrain at close zoom so minimum zoom depth and panning speed near the ground are identical to mode 1 (near the ground the camera clamp forces terrain following anyway)
  • The mesh is sampled with a Catmull-Rom bicubic patch (new SmoothHeightMesh::GetHeightSmooth, camera-only — aircraft still use bilinear GetHeight), since bilinear gradient creases at cell borders show up as small camera jolts
  • ZoomIn/ZoomOut derive the camera distance against the same blended surface (coarse march to the first crossing + bisection along the view ray); deriving against the raw ground would lift the camera by the mesh-to-ground gap on every wheel tick and drift the view forward
  • New configs CamSpringSmoothMeshBlendMinDist (150) and CamSpringSmoothMeshBlendMaxDist (600) control the blend window
  • If the game disables the smooth mesh (enableSmoothMesh=false), mode 2 falls back to terrain tracking at point of use; the previous ConfigUpdate guard ran before modInfo was loaded so it never fired, and the mesh is zero-filled when disabled, which would pin the focus at sea level. The user's config value is no longer rewritten
  • Mode 0 (Disabled) keeps its height-freeze behavior (FreezeCamHeight, formerly SmoothCamHeight minus the now-dead delta branch); mode 1 (Terrain, default) is unchanged

Behavior change note: anyone using mode 2 today gets the new behavior. Saved camera states round-trip fine (height is re-derived every frame), but GetCameraState().py reports the smoothed focus height in mode 2.

Manual test procedure (BAR, Windows, maps with rough terrain and tall cliffs)

  • Mode 2 vs mode 1 A/B via /set CamSpringTrackMapHeightMode, runtime switch works
  • Scroll at fixed zoom over bumpy terrain: bob gone in mode 2, /airmesh overlay matches the camera's vertical path at high zoom
  • Cursor zoom-in repeatedly over rough ground: no forward/upward drift; zoom round trip returns to the same view
  • Zoom fully in at a cliff base: reaches the same minimum height as mode 1, panning speed normal
  • Minimap clicks, group jumps, Spring.SetCameraState: consistent altitude (these bypassed the old mode 2)
  • Modes 0/1 spot-checked unchanged; smooth-mesh-disabled fallback verified via config

AI disclosure:

Investigated and implemented with Claude Code under my direction; all design decisions and in-game testing are my own.

Comment thread rts/System/FileSystem/ArchiveScanner.cpp
Comment thread rts/Game/Camera/SpringController.cpp Outdated
LOG_L(L_ERROR, "Smooth mesh disabled");
trackMapHeight = HeightTracking::Terrain;
}
// fallback is at point of use, modInfo is not loaded yet when the ctor runs this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell now it's checked continuously which is bad, do it once as soon as modinfo loads. This sounds like something that could be a standalone fix commit too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a separate commit. Guard moved out of ConfigUpdate (ran in ctor before modInfo loaded, and rewrote the user's config every change) to point-of-use, warns once, leaves config untouched

Comment thread rts/Game/Camera/SpringController.cpp Outdated
Comment on lines +332 to +336
if (meshBlend <= 0.0f)
return CGround::GetHeightReal(x, z, false);

if (meshBlend >= 1.0f)
return smoothGround.GetHeightSmooth(x, z);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mix should take care of these

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, GetFocusSurfaceHeight collapsed to one no-mesh guard + a single mix()

Comment thread rts/Sim/Misc/SmoothHeightMesh.cpp Outdated
Comment on lines +62 to +72
static constexpr float CatmullRom(float p0, float p1, float p2, float p3, float t)
{
return p1 + 0.5f * t * ((p2 - p0) + t * ((2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) + t * (3.0f * (p1 - p2) + p3 - p0)));
}

// C1-continuous, unlike Interpolate whose gradient jumps at cell borders
static float InterpolateBicubic(float x, float y, const int maxx, const int maxy, const float res, const float* heightmap)
{
RECOIL_DETAILED_TRACY_ZONE;
x = std::clamp(x / res, 0.0f, (float)maxx);
y = std::clamp(y / res, 0.0f, (float)maxy);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these sound like they belong as generic math functions (that you would then pass heights as args into)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CatmullRom + a generic InterpolateBicubic(p[4][4], dx, dy) moved to SpringMath.h; SmoothHeightMesh just gathers the 4×4 patch and calls it

Comment thread rts/Game/Camera/SpringController.cpp
Comment thread rts/Game/Camera/SpringController.cpp Outdated
return groundDist; // ground above the surface

// march to bracket the first crossing (grazing rays can cross several times), then bisect
constexpr int NUM_STEPS = 16;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why 16 (and not, say, 4)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The march brackets the first crossing of the view ray with the blended surface.

Grazing rays near cliffs can dip below the surface and back out several times, so too coarse a march would step over a near crossing and bracket a farther one, locking the camera onto the wrong hill.

It runs once per zoom action, not per frame, so 16 is cheap. Added a comment to that effect; happy to drop to 8 if you'd rather.

Comment thread rts/Game/Camera/SpringController.cpp
Comment thread rts/Game/Camera/SpringController.cpp Outdated
Comment thread rts/Sim/Misc/SmoothHeightMesh.cpp Outdated
Comment thread rts/Sim/Misc/SmoothHeightMesh.cpp Outdated
Comment on lines +84 to +85
const float* row = &heightmap[std::clamp(sy + i - 1, 0, maxy - 1) * maxx];
rows[i] = CatmullRom(row[x0], row[sx], row[x2], row[x3], dx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this edit the mesh?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't, InterpolateBicubic only reads from mesh. The confusion was the local rows[] scratch array; renamed to patch and commented it as a read-only gather. The camera samples the mesh on the main thread while the sim updates it, but this path never writes.

PtaQQ and others added 3 commits June 13, 2026 14:03
Pure rename in preparation for the smooth mesh height tracking rework.

The camera focus height and the zoom-distance-to-focus computation are
about to stop being plain raw-ground lookups, so route them through
GetFocusSurfaceHeight() and DistanceToFocusSurface() wrappers.
Behavior is unchanged: both currently forward to the raw ground.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guard lived in ConfigUpdate(), which runs from the constructor before
modInfo is loaded, so modInfo.enableSmoothMesh was always still at its
default and the check never fired in practice.
It also rewrote the user's CamSpringTrackMapHeightMode config value and
logged an error on every config change.

Resolve the fallback at point of use instead, where modInfo is loaded:
fall back to terrain tracking when the game disabled the smooth mesh,
warn once, and leave the user's config untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CamSpringTrackMapHeightMode=2 now bases the camera focus height on the air
smooth mesh directly instead of nudging the zoom distance by per-frame
smooth mesh deltas. The old implementation only applied while scrolling
(zoom, minimap and Lua camera moves bypassed it), under-compensated by the
view tilt factor, and accumulated drift since the offset lived in the zoom
distance. The new implementation is stateless.

- focus height = mix(ground, smooth mesh, smoothstep(min, max, dist)), fading
  to the raw terrain at close zoom so minimum zoom depth and panning speed
  stay identical to mode 1 near the ground
- the mesh is sampled with a Catmull-Rom bicubic patch (new
  SmoothHeightMesh::GetHeightSmooth), as the gradient creases of bilinear
  sampling show up as small camera jolts; CatmullRom and the generic bicubic
  patch helper live in SpringMath
- ZoomIn/ZoomOut derive the camera distance against the same blended surface
  (march + bisection along the view ray), otherwise each zoom step lifts the
  camera by the mesh-to-ground gap and the view drifts forward
- blend window configurable via CamSpringSmoothMeshBlend{Min,Max}Dist
- mode 0 (Disabled) keeps the old raycast re-base as FreezeCamHeight, minus
  its now-dead smooth delta branch; mode 1 (Terrain) is unchanged

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@PtaQQ PtaQQ force-pushed the spring-camera-smoothmesh-tracking branch from 03d0500 to 3acf092 Compare June 13, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants