Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions SOURCES/CONSOLE/CONSOLE_CMD.CPP
Original file line number Diff line number Diff line change
Expand Up @@ -1546,4 +1546,6 @@ void Console_RegisterAll(void) {
Console_RegisterCvar("cam_ground_clear", &FollowCamGroundClearance, CONSOLE_CVAR_INT, "Ground clearance: keep the eye this far above the ground under it");
Console_RegisterCvar("cam_manual_hold", &FollowCamManualHoldFrames, CONSOLE_CVAR_INT, "Frames the pitch/distance auto-assist yields after a manual camera nudge");
Console_RegisterCvar("cam_hold_angle", &FollowCamHoldAngle, CONSOLE_CVAR_BOOL, "Hold the manual camera rotation (free-camera); off = classic auto-recenter drift");
Console_RegisterCvar("cam_blend", &FollowCamBlend, CONSOLE_CVAR_BOOL, "Smoothly blend camera handoffs (zones / recenter) instead of jump-cutting");
Console_RegisterCvar("cam_blend_dur", &FollowCamBlendDur, CONSOLE_CVAR_INT, "Camera blend length in frames (~30 = 0.5s)");
}
2 changes: 2 additions & 0 deletions SOURCES/C_EXTERN.H
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ extern S32 FollowCamGroundClearance; /* keep the eye this far above the ground
extern S32 FollowCamManualHold; /* frames the pitch/distance auto-assist yields after a manual nudge */
extern S32 FollowCamManualHoldFrames; /* manual-override hold length (cvar cam_manual_hold) */
extern S32 FollowCamHoldAngle; /* free-camera mode: hold manual rotation, no auto-recenter drift (cvar cam_hold_angle) */
extern S32 FollowCamBlend; /* camera blend: smooth handoffs between controllers (cvar cam_blend) */
extern S32 FollowCamBlendDur; /* camera blend length in frames (cvar cam_blend_dur) */
extern S32 ReverseStereo;

extern S32 ScaleFactorSprite;
Expand Down
8 changes: 8 additions & 0 deletions SOURCES/FOLLOWCAM_CFG.H
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@
* classic lazy drift-back. cvar cam_hold_angle. */
#define FOLLOW_CAM_HOLD_ANGLE_DEFAULT 1

/* Camera blend (prototype) --------------------------------------------------*/
/* Smooth the hard cuts between camera controllers (dynamic follow <-> scripted
* zone camera, manual recenter) by interpolating the presented camera params
* over a few frames instead of jump-cutting. See docs/CAMERA_BLEND_DESIGN.md.
* cvars cam_blend / cam_blend_dur. */
#define FOLLOW_CAM_BLEND_DEFAULT 1 /* master enable */
#define FOLLOW_CAM_BLEND_DUR_DEFAULT 30 /* blend length in frames (~0.5s at 60fps) */

/* Elevation (AlphaCam) — applies when Auto camera (FollowCamera) is on -----*/
#define FOLLOW_CAM_ALPHA_MIN 150 /* minimum elevation angle */
#define FOLLOW_CAM_ALPHA_MAX 600 /* maximum elevation angle */
Expand Down
2 changes: 2 additions & 0 deletions SOURCES/GLOBAL.CPP
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ S32 FollowCamGround = FOLLOW_CAM_GROUND_DEFAULT;
S32 FollowCamGroundClearance = FOLLOW_CAM_GROUND_CLEARANCE_DEFAULT;
S32 FollowCamManualHoldFrames = FOLLOW_CAM_MANUAL_HOLD_DEFAULT;
S32 FollowCamHoldAngle = FOLLOW_CAM_HOLD_ANGLE_DEFAULT;
S32 FollowCamBlend = FOLLOW_CAM_BLEND_DEFAULT;
S32 FollowCamBlendDur = FOLLOW_CAM_BLEND_DUR_DEFAULT;
S32 ReverseStereo = FALSE;
S32 Island = 0;
S32 Planet = 0;
Expand Down
120 changes: 120 additions & 0 deletions SOURCES/PERSO.CPP
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include "ASSET_PREFLIGHT.H"
#include <SYSTEM/EVENTS.H>
#include <SVGA/VIDEO.H>
#include <3D/REGLE3.H> /* RegleTrois: camera blend interpolation */
#include <FILEIO/SAVEPNG.H>

#include <SVGA/SCREEN.H>
Expand Down Expand Up @@ -769,6 +770,114 @@ U8 WaitNoKey35 = FALSE;

/*──────────────────────────────────────────────────────────────────────────*/

/* --- Camera blend (prototype) --------------------------------------------
* Runs once per frame at the single camera commit point, just before AffScene.
* It watches the committed camera params; when they jump beyond a threshold (a
* handoff between controllers: dynamic follow <-> scripted zone, or a manual
* recenter) it presents an interpolation from the previous frame's camera
* toward the new target over FollowCamBlendDur frames, killing the jump cut.
* Scene transitions (NumCube change) and cutscenes (CinemaMode) are cut, not
* blended. Divergence-triggered so it needs no changes to the controllers.
* See docs/CAMERA_BLEND_DESIGN.md. */
#define CAM_BLEND_POS_THRESH 2500 /* VueOffset |dx|+|dz| jump that counts as a handoff */
#define CAM_BLEND_POS_MAX 30000 /* above this the look-at teleported: cut, do not blend */
#define CAM_BLEND_BETA_THRESH 300 /* azimuth jump (units; 4096 = 360 deg) */
#define CAM_BLEND_DIST_THRESH 3000 /* boom-length jump */
#define CAM_BLEND_SETTLE_FRAMES 8 /* skip handoff detection this many frames after a cube change (let the follow settle) */

static S32 CamBlendActive = 0;
static S32 CamBlendSettle = 0;
static S32 CamBlendElapsed = 0;
static S32 CamBlendValid = 0;
static S32 CamBlendCube = -1;
static S32 CamBlendPresX = 0, CamBlendPresY = 0, CamBlendPresZ = 0;
static S32 CamBlendPresA = 0, CamBlendPresB = 0, CamBlendPresG = 0, CamBlendPresD = 0;
static S32 CamBlendFromX = 0, CamBlendFromY = 0, CamBlendFromZ = 0;
static S32 CamBlendFromA = 0, CamBlendFromB = 0, CamBlendFromG = 0, CamBlendFromD = 0;

static S32 CamBlendAbs(S32 v) { return v < 0 ? -v : v; }

/* shortest-arc lerp of a mod-4096 angle from -> to at step/dur */
static S32 CamBlendArc(S32 from, S32 to, S32 step, S32 dur) {
S32 d = (to - from) & 4095;
if (d > 2048)
d -= 4096;
return (from + (S32)((S64)d * step / dur)) & 4095;
}

static void CameraBlendPresent(void) {
S32 tX = VueOffsetX, tY = VueOffsetY, tZ = VueOffsetZ;
S32 tA = AlphaCam, tB = BetaCam, tG = GammaCam, tD = VueDistance;

if (NumCube != CamBlendCube)
CamBlendSettle = CAM_BLEND_SETTLE_FRAMES;

S32 canBlend = FollowCamBlend AND FollowCamera AND !CinemaMode AND CubeMode == CUBE_EXTERIEUR AND NumCube == CamBlendCube AND CamBlendValid AND CamBlendSettle == 0;

if (canBlend AND !CamBlendActive) {
S32 dpos = CamBlendAbs(tX - CamBlendPresX) + CamBlendAbs(tZ - CamBlendPresZ);
S32 dbeta = (tB - CamBlendPresB) & 4095;
if (dbeta > 2048)
dbeta -= 4096;
dbeta = CamBlendAbs(dbeta);
S32 ddist = CamBlendAbs(tD - CamBlendPresD);
if ((dpos > CAM_BLEND_POS_THRESH OR dbeta > CAM_BLEND_BETA_THRESH OR ddist > CAM_BLEND_DIST_THRESH)
AND dpos < CAM_BLEND_POS_MAX) {
CamBlendFromX = CamBlendPresX;
CamBlendFromY = CamBlendPresY;
CamBlendFromZ = CamBlendPresZ;
CamBlendFromA = CamBlendPresA;
CamBlendFromB = CamBlendPresB;
CamBlendFromG = CamBlendPresG;
CamBlendFromD = CamBlendPresD;
CamBlendActive = 1;
CamBlendElapsed = 0;
}
}

if (canBlend AND CamBlendActive) {
S32 dur = FollowCamBlendDur;
if (dur < 1)
dur = 1;
CamBlendElapsed++;
S32 e = CamBlendElapsed;
if (e > dur)
e = dur;
CamBlendPresX = RegleTrois(CamBlendFromX, tX, dur, e);
CamBlendPresY = RegleTrois(CamBlendFromY, tY, dur, e);
CamBlendPresZ = RegleTrois(CamBlendFromZ, tZ, dur, e);
CamBlendPresA = CamBlendArc(CamBlendFromA, tA, e, dur);
CamBlendPresB = CamBlendArc(CamBlendFromB, tB, e, dur);
CamBlendPresG = CamBlendArc(CamBlendFromG, tG, e, dur);
CamBlendPresD = RegleTrois(CamBlendFromD, tD, dur, e);
SetFollowCamera(CamBlendPresX, CamBlendPresY, CamBlendPresZ,
CamBlendPresA, CamBlendPresB, CamBlendPresG, CamBlendPresD);
FirstTime = AFF_ALL_FLIP;
CamBlendCube = NumCube;
CamBlendValid = 1;
if (CamBlendElapsed >= dur)
CamBlendActive = 0;
return;
}

/* Not blending: leave the controller's committed camera (with its occlusion
* lift) untouched, and record it as the presented state for next frame. */
CamBlendActive = 0;
if (CamBlendSettle > 0)
CamBlendSettle--;
CamBlendPresX = tX;
CamBlendPresY = tY;
CamBlendPresZ = tZ;
CamBlendPresA = tA;
CamBlendPresB = tB;
CamBlendPresG = tG;
CamBlendPresD = tD;
CamBlendCube = NumCube;
CamBlendValid = (CubeMode == CUBE_EXTERIEUR);
}

/*──────────────────────────────────────────────────────────────────────────*/

S32 MainLoop() {
S32 xm, ym, zm;
T_OBJET *ptrobj;
Expand Down Expand Up @@ -2132,6 +2241,11 @@ restartloop:
/*-------------------------------------------------------------------------*/
/* affiche tout */

/* Smooth any camera-controller handoff that just happened (zone in/out,
* recenter) before the scene is committed. No-op unless a handoff is in
* progress; see docs/CAMERA_BLEND_DESIGN.md. */
CameraBlendPresent();

Perftrace_PhaseBegin(PERFTRACE_PHASE_SCENE);
AffScene(FirstTime);
Perftrace_PhaseEnd(PERFTRACE_PHASE_SCENE);
Expand Down Expand Up @@ -2329,6 +2443,10 @@ void ReadConfigFile(void) {
if (FollowCamManualHoldFrames < 0)
FollowCamManualHoldFrames = 0;
FollowCamHoldAngle = DefFileBufferReadValueDefault("FollowCamHoldAngle", FOLLOW_CAM_HOLD_ANGLE_DEFAULT) ? TRUE : FALSE;
FollowCamBlend = DefFileBufferReadValueDefault("FollowCamBlend", FOLLOW_CAM_BLEND_DEFAULT) ? TRUE : FALSE;
FollowCamBlendDur = DefFileBufferReadValueDefault("FollowCamBlendDur", FOLLOW_CAM_BLEND_DUR_DEFAULT);
if (FollowCamBlendDur < 1)
FollowCamBlendDur = 1;
}

/*──────────────────────────────────────────────────────────────────────────*/
Expand Down Expand Up @@ -2395,6 +2513,8 @@ void WriteConfigFile(void) {
DefFileBufferWriteValue("FollowCamGroundClearance", FollowCamGroundClearance);
DefFileBufferWriteValue("FollowCamManualHoldFrames", FollowCamManualHoldFrames);
DefFileBufferWriteValue("FollowCamHoldAngle", FollowCamHoldAngle);
DefFileBufferWriteValue("FollowCamBlend", FollowCamBlend);
DefFileBufferWriteValue("FollowCamBlendDur", FollowCamBlendDur);

if (batched) {
DefFileBufferEndBatch();
Expand Down
141 changes: 141 additions & 0 deletions docs/CAMERA_BLEND_DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Camera blending design

Status: exploratory. Phase A (in-scene handoff blending) has a prototype behind
the `cam_blend` cvar; Phase B (free dynamic camera) is not started.

## Problem

LBA2's exterior camera is driven by several independent controllers that each
grab the camera by hard-writing its globals. There is no interpolation between
them, so every handoff is a jump cut:

- Entering a scripted camera zone (a scenic view of an area) cuts instantly to
the scripted framing.
- Leaving the zone cuts instantly back to the dynamic follow camera.
- Manually re-centering (Center camera) snaps instantly.

The goal is to make these handoffs gradual, and separately to make the dynamic
camera hold its angle (a free third-person camera) instead of auto-recentering.

## The controllers

Everything that moves the exterior camera, and how it does it today:

| Controller | Applies via | Trigger |
| --- | --- | --- |
| Dynamic follow (`FollowCamera`) | per-frame `CameraCenter(3)` / `SetFollowCamera` in `UpdateFollowCameraExt` | default in exterior |
| Scripted zone camera | `SetZoneCamera` forces `AlphaCam`/`BetaCam`/`GammaCam`/`VueDistance` + `CameraZone=TRUE` + `AffScene(AFF_ALL_FLIP)` | hero enters a camera zone (`OBJECT.CPP`), or the `LM_SET_CAMERA` script op |
| Cutscene | scripted | `CinemaMode` |
| Manual recenter | `CameraCenter(1)` snaps behind the hero (zeroes `AddBetaCam`) | Enter / gamepad B / mouse right-click |
| Scene transition | `CameraCenter(1)` (full, `FlagChgCube` 0/2) or `CameraCenter(0)` (seamless, `FlagChgCube` 1) | `ChangeCube` |
| Off-screen recenter | `CameraCenter(0)` | already suppressed under `FollowCamera` |
| Load | `CameraCenter(1)`/`(0)` | `SAVEGAME` |

The camera's interpolatable state is seven scalars: `VueOffsetX/Y/Z` (look-at
target), `AlphaCam`/`BetaCam`/`GammaCam` (angles, mod 4096), and `VueDistance`
(boom). `SetFollowCamera(...)` builds the matrix from exactly these. The
editor-only `FlyCamera` already lerps all seven with `RegleTrois` over a
duration; it is the reference for the blend, just blocking and editor-gated.

## The blend layer

Insert one step between "a controller decided where the camera should be" and
"the matrix is committed for rendering":

```
controllers -> [target params in globals] -> BLEND -> SetFollowCamera(presented)
```

Each frame while a blend is active:

```
w = ease(elapsed / duration) // 0 -> 1
presented = lerp(blendStart, target, w) // per param
SetFollowCamera(presented...)
```

`blendStart` is a frozen snapshot of the presented camera at the moment of the
handoff; `target` is whatever the active controller wrote this frame. `lerp` is
`RegleTrois` for the positional and distance params and a shortest-arc variant
for the three angles (4000 to 100 must travel +192, not -3900).

### Blending to a moving target

Blending into a zone is a fixed endpoint. Blending back out to the dynamic
follow is not: the follow target moves every frame as it tracks the hero. The
robust form is a weight blend rather than an endpoint blend:

```
presented = lerp(frozenStart, liveTargetThisFrame, w) // w rises 0 -> 1
```

Only the outgoing state is frozen. Each frame the presented camera is pulled a
little further toward whatever the incoming controller currently wants, so if
the hero keeps walking during the blend the camera converges onto the live
follow instead of a stale point. The same code path handles static (zone) and
moving (follow) targets.

This composes with the existing recompose / occlusion / manual-fade work: those
all produce the dynamic target, so they feed the incoming side of the blend.

## Selective blending

Not every cut should become a blend:

- Cutscene (`CinemaMode`) cuts are usually intentional. Exclude.
- Scene transitions and load teleport the camera across the world. Blending
across that is a long fly through nothing. Cut.
- Interior to exterior is the one transition that should blend (Phase B), and
in-scene zone and recenter handoffs are the ones this design targets.

So the blend is opt-in per handoff. The prototype approximates this cheaply (see
below); a production version would instrument the handoff sites directly.

## Phase A prototype (divergence-triggered)

Rather than instrument every controller, the prototype watches the committed
camera params once per frame just before `AffScene` and reacts to jumps:

- Track the presented params each frame.
- If they diverge beyond a threshold (a handoff) and we are not already
blending, in an exterior cube, not in `CinemaMode`, and the cube did not just
change, start a blend from the previous presented state toward the new target.
- While blending, present the interpolated params via `SetFollowCamera` and
force a redraw; when `elapsed >= duration`, hand back to the controller.

Scene transitions are cut by resetting the tracker when `NumCube` changes.
Cutscenes are cut by the `CinemaMode` guard. This catches the in-scene zone-in,
zone-out, and manual-recenter handoffs (the reported jump cuts) with a single
integration point and no changes to the zone or transition code.

Tunables (cvars, persisted): `cam_blend` (enable), `cam_blend_dur` (frames).

Known rough edges of the prototype, to revisit:

- Divergence detection is a heuristic; a very fast legitimate move could trip
it, and a small zone offset could miss it. Instrumenting `SetZoneCamera` /
zone-exit / `CameraCenter` directly removes the guesswork.
- The blend presents the parametric camera without the per-frame occlusion lift,
so the lift pops in at blend end (usually small; zones have none).
- A handoff during a blend continues toward the new target rather than
re-snapshotting from the current presented state.
- Interpolation is linear; ease-in-out is a feel refinement.

## Phase B: free dynamic camera

Independent of blending: make the dynamic camera hold its angle and stop
grabbing back. `cam_hold_angle` already stops the pan drift. The remaining
involuntary recenters are the `CameraCenter(1)` calls; gate the non-interior-to-
exterior ones so only interior-to-exterior transitions and manual Center camera
re-center. With the blend layer, even those become gradual.

## Risks

- Cutscene integrity: a blend leaking into `CinemaMode` could desync scripted
timing. The exclusion must be airtight.
- Gameplay-critical framing: some zones frame a puzzle or jump that the player
needs to see now; a slow blend can hurt readability. Per-zone metadata to opt
out is not currently addable without scene-data editing.
- Save/load mid-blend: snapshot the target, or force-complete on save.
- Determinism harness: the blend is frame-counted and deterministic, but adds
per-frame camera state the projrec/dump baselines should account for.
Loading