Client-owned camera for demo spectator

cgame runs its own PmoveSingle for the free camera using real time,
independent of server time. Solves pause freezing — camera moves
smoothly while svs.time is frozen.

Changes:
- usercmd_t: add optional origin[3] with 1-bit flag (zero cost for
  normal gameplay, 13 bytes during demo viewing)
- msg.c: serialize optional origin in usercmd delta encoding
- CG_SETCLIENTORIGIN trap: cgame sends camera origin to engine
- cl_input.c: CL_FinishMove writes cgame origin into usercmd
- cg_predict.c: CG_PredictPlayerState runs local PmoveSingle for
  free camera in svDemo mode, sends origin via trap for PVS
- cg_snapshot.c: detect follow→free camera transition, init camera
  from last known position
- cg_main.c: detect svDemo mode from CS_SVDEMO configstring,
  lazy-init camera from first snapshot's spectator spawn
- g_main.c: G_InitGame sets CS_SVDEMO configstring, G_RunFrame
  copies cmd.origin to ps.origin for PVS instead of ClientThink_real
- bg_public.h: CS_SVDEMO configstring index (26)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
serge_shubin 2026-03-24 03:51:51 +08:00
parent 7141d941a3
commit 6e13c747ba
13 changed files with 149 additions and 14 deletions

View file

@ -457,6 +457,10 @@ typedef struct {
int clientNum;
qboolean demoPlayback;
qboolean svDemoPlayback; // server-side demo playback mode
qboolean svDemoFreeCamera; // free camera (client-owned movement)
playerState_t svDemoCameraPs; // local playerState for free camera
int svDemoCameraTime; // real time of last camera update
qboolean levelShot; // taking a level menu screenshot
int deferredPlayerLoading;
qboolean loading; // don't defer players at initial startup
@ -1618,6 +1622,7 @@ qboolean trap_GetUserCmd( int cmdNumber, usercmd_t *ucmd );
// used for the weapon select and zoom
void trap_SetUserCmdValue( int stateValue, float sensitivityScale );
void trap_SetClientOrigin( qboolean hasOrigin, float x, float y, float z );
// aids for VM testing
void testPrintInt( char *string, int i );

View file

@ -1896,6 +1896,13 @@ void CG_Init( int serverMessageNum, int serverCommandSequence, int clientNum ) {
// get the gamestate from the client system
trap_GetGameState( &cgs.gameState );
// detect server-side demo playback from configstring
cg.svDemoPlayback = ( atoi( CG_ConfigString( CS_SVDEMO ) ) != 0 );
if ( cg.svDemoPlayback ) {
cg.svDemoFreeCamera = qtrue;
// camera ps will be initialized from first snapshot in CG_PredictPlayerState
}
// check version
s = CG_ConfigString( CS_GAME_VERSION );
if ( strcmp( s, GAME_VERSION ) ) {

View file

@ -432,6 +432,59 @@ void CG_PredictPlayerState( void ) {
// demo playback just copies the moves
if ( cg.demoPlayback || (cg.snap->ps.pm_flags & PMF_FOLLOW) ) {
CG_InterpolatePlayerState( qfalse );
// send followed player's origin for PVS during server demo follow mode
if ( cg.svDemoPlayback && (cg.snap->ps.pm_flags & PMF_FOLLOW) ) {
trap_SetClientOrigin( qtrue,
cg.snap->ps.origin[0],
cg.snap->ps.origin[1],
cg.snap->ps.origin[2] );
cg.svDemoFreeCamera = qfalse;
}
return;
}
// server-side demo free camera: run local PmoveSingle
if ( cg.svDemoPlayback && cg.svDemoFreeCamera ) {
int realTime = trap_Milliseconds();
int dt = realTime - cg.svDemoCameraTime;
pmove_t pm;
// lazy init: place camera at spectator spawn from first snapshot
if ( cg.svDemoCameraPs.speed == 0 ) {
cg.svDemoCameraPs = cg.snap->ps;
cg.svDemoCameraPs.pm_type = PM_SPECTATOR;
cg.svDemoCameraPs.speed = 480;
cg.svDemoCameraTime = realTime;
dt = 1;
}
if ( dt < 1 ) dt = 1;
if ( dt > 200 ) dt = 200;
cg.svDemoCameraTime = realTime;
// get latest usercmd for input
current = trap_GetCurrentCmdNumber();
trap_GetUserCmd( current, &latestCmd );
// set up pmove
memset( &pm, 0, sizeof(pm) );
pm.ps = &cg.svDemoCameraPs;
pm.cmd = latestCmd;
pm.cmd.serverTime = cg.svDemoCameraPs.commandTime + dt;
pm.tracemask = MASK_PLAYERSOLID & ~CONTENTS_BODY;
pm.trace = CG_Trace;
pm.pointcontents = CG_PointContents;
Pmove( &pm );
// use as the predicted state for rendering
cg.predictedPlayerState = cg.svDemoCameraPs;
// send origin for PVS
trap_SetClientOrigin( qtrue,
cg.svDemoCameraPs.origin[0],
cg.svDemoCameraPs.origin[1],
cg.svDemoCameraPs.origin[2] );
return;
}

View file

@ -164,6 +164,7 @@ typedef enum {
CG_R_INPVS,
// 1.32
CG_FS_SEEK,
CG_SETCLIENTORIGIN, // set client-owned origin for demo spectator PVS
/*
CG_LOADCAMERA,

View file

@ -184,6 +184,22 @@ static void CG_TransitionSnapshot( void ) {
|| cg_nopredict.integer || cg_synchronousClients.integer ) {
CG_TransitionPlayerState( ps, ops );
}
// server demo: detect follow→free camera transition
if ( cg.svDemoPlayback ) {
qboolean wasFollowing = ( ops->pm_flags & PMF_FOLLOW ) != 0;
qboolean isFollowing = ( ps->pm_flags & PMF_FOLLOW ) != 0;
if ( wasFollowing && !isFollowing ) {
// exiting follow mode — init camera from last known position
cg.svDemoFreeCamera = qtrue;
cg.svDemoCameraTime = trap_Milliseconds();
VectorCopy( ops->origin, cg.svDemoCameraPs.origin );
VectorCopy( ops->viewangles, cg.svDemoCameraPs.viewangles );
cg.svDemoCameraPs.pm_type = PM_SPECTATOR;
cg.svDemoCameraPs.speed = 480;
cg.svDemoCameraPs.clientNum = cg.clientNum;
}
}
}
}

View file

@ -333,6 +333,10 @@ void trap_SetUserCmdValue( int stateValue, float sensitivityScale ) {
syscall( CG_SETUSERCMDVALUE, stateValue, PASSFLOAT(sensitivityScale) );
}
void trap_SetClientOrigin( qboolean hasOrigin, float x, float y, float z ) {
syscall( CG_SETCLIENTORIGIN, hasOrigin, PASSFLOAT(x), PASSFLOAT(y), PASSFLOAT(z) );
}
void testPrintInt( char *string, int i ) {
syscall( CG_TESTPRINTINT, string, i );
}

View file

@ -457,6 +457,14 @@ int CL_CgameSystemCalls( int *args ) {
return 0;
case CG_FS_SEEK:
return FS_Seek( args[1], args[2], args[3] );
case CG_SETCLIENTORIGIN:
cl.cgameHasOrigin = args[1];
if ( args[1] ) {
cl.cgameOrigin[0] = VMF(2);
cl.cgameOrigin[1] = VMF(3);
cl.cgameOrigin[2] = VMF(4);
}
return 0;
case CG_SENDCONSOLECOMMAND:
Cbuf_AddText( VMA(1) );
return 0;

View file

@ -516,6 +516,12 @@ void CL_FinishMove( usercmd_t *cmd ) {
for (i=0 ; i<3 ; i++) {
cmd->angles[i] = ANGLE2SHORT(cl.viewangles[i]);
}
// client-owned origin for demo spectator PVS
cmd->hasOrigin = cl.cgameHasOrigin;
if ( cl.cgameHasOrigin ) {
VectorCopy( cl.cgameOrigin, cmd->origin );
}
}

View file

@ -106,6 +106,8 @@ typedef struct {
// cgame communicates a few values to the client system
int cgameUserCmdValue; // current weapon to add to usercmd_t
float cgameSensitivity;
qboolean cgameHasOrigin; // client-owned origin for demo spectator PVS
vec3_t cgameOrigin; // the origin to send
// cmds[cmdNumber] is the predicted command, [cmdNumber-1] is the last
// properly generated command

View file

@ -79,6 +79,7 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#define CS_FLAGSTATUS 23 // string indicating flag status in CTF
#define CS_SHADERSTATE 24
#define CS_BOTINFO 25
#define CS_SVDEMO 26 // "1" during server-side demo playback
#define CS_ITEMS 27 // string of 0's and 1's that tell which items are present

View file

@ -418,6 +418,11 @@ void G_InitGame( int levelTime, int randomSeed, int restart ) {
G_RegisterCvars();
// signal server-side demo mode to cgame via configstring
if ( trap_Cvar_VariableIntegerValue( "sv_demoplaying" ) ) {
trap_SetConfigstring( CS_SVDEMO, "1" );
}
G_ProcessIPBans();
G_InitMemory();
@ -1746,10 +1751,16 @@ int start, end;
gclient_t *cl = &level.clients[i];
gentity_t *e = &g_entities[i];
// find and process the spectator
// find the spectator — use client-owned origin for PVS
if ( e->client && cl->pers.connected == CON_CONNECTED
&& cl->sess.sessionTeam == TEAM_SPECTATOR ) {
ClientThink_real( e );
// copy client-owned origin from usercmd for PVS culling.
// cgame runs its own PmoveSingle for camera movement.
if ( cl->pers.cmd.hasOrigin ) {
VectorCopy( cl->pers.cmd.origin, cl->ps.origin );
VectorCopy( cl->pers.cmd.origin, e->s.pos.trBase );
VectorCopy( cl->pers.cmd.origin, e->r.currentOrigin );
}
specEnt = e;
continue;
}

View file

@ -1255,6 +1255,9 @@ typedef struct usercmd_s {
int buttons;
byte weapon; // weapon
signed char forwardmove, rightmove, upmove;
// client-owned origin for demo spectator PVS (optional)
qboolean hasOrigin;
float origin[3];
} usercmd_t;
//===================================================================

View file

@ -702,8 +702,7 @@ void MSG_WriteDeltaUsercmdKey( msg_t *msg, int key, usercmd_t *from, usercmd_t *
from->weapon == to->weapon) {
MSG_WriteBits( msg, 0, 1 ); // no change
oldsize += 7;
return;
}
} else {
key ^= to->serverTime;
MSG_WriteBits( msg, 1, 1 );
MSG_WriteDeltaKey( msg, key, from->angles[0], to->angles[0], 16 );
@ -715,6 +714,16 @@ void MSG_WriteDeltaUsercmdKey( msg_t *msg, int key, usercmd_t *from, usercmd_t *
MSG_WriteDeltaKey( msg, key, from->buttons, to->buttons, 16 );
MSG_WriteDeltaKey( msg, key, from->weapon, to->weapon, 8 );
}
// optional client-owned origin (always written, independent of field changes)
if ( to->hasOrigin ) {
MSG_WriteBits( msg, 1, 1 );
MSG_WriteFloat( msg, to->origin[0] );
MSG_WriteFloat( msg, to->origin[1] );
MSG_WriteFloat( msg, to->origin[2] );
} else {
MSG_WriteBits( msg, 0, 1 );
}
}
/*
@ -748,6 +757,15 @@ void MSG_ReadDeltaUsercmdKey( msg_t *msg, int key, usercmd_t *from, usercmd_t *t
to->buttons = from->buttons;
to->weapon = from->weapon;
}
// optional client-owned origin
if ( MSG_ReadBits( msg, 1 ) ) {
to->hasOrigin = qtrue;
to->origin[0] = MSG_ReadFloat( msg );
to->origin[1] = MSG_ReadFloat( msg );
to->origin[2] = MSG_ReadFloat( msg );
} else {
to->hasOrigin = qfalse;
}
}
/*