Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

207 lines
14 KiB
Diff

--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c
+++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c
@@ -11,48 +11,75 @@
}
static bool Fresh(float now, float received) { return received >= 0 && now >= received && now - received <= 350; }
}
+// Keep actual sampled poses on a short timeline instead of restarting a blend
+// on every packet arrival. Only presentation is delayed; possession is unchanged.
+class ERR_ObserverViewSample
+{
+ int Stamp;
+ vector Position, Forward, Up;
+ float Fov, Near;
+}
class ERR_ObserverViewBuffer
{
protected RplId m_Target = RplId.Invalid();
protected int m_Stamp = -1;
protected RplId m_ScopeWeapon = RplId.Invalid();
protected vector m_ScopeData;
- void Scope(out RplId weapon, out vector data) { weapon = m_ScopeWeapon; data = m_ScopeData; }
protected float m_Received = -1;
- protected vector m_Position, m_Forward, m_Up;
- protected vector m_StartPosition, m_StartForward, m_StartUp;
- protected float m_Fov, m_Near, m_StartFov, m_Blend = 1;
- void Clear() { m_Target = RplId.Invalid(); m_Stamp = -1; m_Received = -1; }
+ protected float m_Playback, m_ReadAt;
+ protected ref array<ref ERR_ObserverViewSample> m_Samples = {};
+ void Scope(out RplId weapon, out vector data) { weapon = m_ScopeWeapon; data = m_ScopeData; }
+ void Clear()
+ {
+ m_Target = RplId.Invalid(); m_Stamp = -1; m_Received = -1;
+ m_ScopeWeapon = RplId.Invalid(); m_ScopeData = vector.Zero;
+ m_Samples.Clear();
+ }
void Push(RplId target, int stamp, float now, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
{
if (!ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
if (!ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
if (target == m_Target && stamp <= m_Stamp) return;
if (target != m_Target || scopeWeapon != m_ScopeWeapon || scopeData[0] != m_ScopeData[0] || !ERR_ObserverViewRules.Fresh(now, m_Received))
- { m_StartPosition = position; m_StartForward = forward; m_StartUp = up; m_StartFov = fov; }
- else
{
- m_StartPosition = vector.Lerp(m_StartPosition, m_Position, m_Blend);
- m_StartForward = vector.Lerp(m_StartForward, m_Forward, m_Blend);
- m_StartUp = vector.Lerp(m_StartUp, m_Up, m_Blend);
- m_StartFov = Math.Lerp(m_StartFov, m_Fov, m_Blend);
+ m_Samples.Clear();
+ m_Playback = stamp - 100; m_ReadAt = now;
}
+ ERR_ObserverViewSample sample = new ERR_ObserverViewSample();
+ sample.Stamp = stamp; sample.Position = position; sample.Forward = forward; sample.Up = up;
+ sample.Fov = fov; sample.Near = nearPlane;
+ m_Samples.Insert(sample);
+ // Bounded even when first-person view is not being rendered.
+ while (m_Samples.Count() > 12) m_Samples.Remove(0);
m_ScopeWeapon = scopeWeapon; m_ScopeData = scopeData;
- m_Target = target; m_Stamp = stamp; m_Received = now; m_Blend = 0;
- m_Position = position; m_Forward = forward; m_Up = up; m_Fov = fov; m_Near = nearPlane;
+ m_Target = target; m_Stamp = stamp; m_Received = now;
}
bool Read(RplId target, float now, float seconds, out vector local[4], out float fov, out float nearPlane)
{
- if (target != m_Target || !ERR_ObserverViewRules.Fresh(now, m_Received)) return false;
- m_Blend = Math.Clamp(m_Blend + Math.Max(0, seconds) / 0.05, 0, 1);
- vector forward = vector.Lerp(m_StartForward, m_Forward, m_Blend);
- vector up = vector.Lerp(m_StartUp, m_Up, m_Blend);
- if (forward.LengthSq() < 0.1 || up.LengthSq() < 0.1) { forward = m_Forward; up = m_Up; }
+ if (target != m_Target || !ERR_ObserverViewRules.Fresh(now, m_Received) || m_Samples.IsEmpty()) return false;
+ // Use elapsed local time, not the number of reads or a packet's arrival time.
+ // No clock synchronization between clients is required: stamps are relative.
+ if (now - m_ReadAt > 250) m_Playback = m_Stamp - 100;
+ else m_Playback += Math.Max(0, now - m_ReadAt);
+ m_ReadAt = now;
+ // A long render stall resumes near live rather than replaying old movement.
+ if (m_Stamp - m_Playback > 250) m_Playback = m_Stamp - 100;
+ // Never extrapolate through walls or invent aim during a packet gap.
+ m_Playback = Math.Min(m_Playback, m_Stamp);
+ while (m_Samples.Count() > 2 && m_Samples[1].Stamp <= m_Playback) m_Samples.Remove(0);
+ ERR_ObserverViewSample start = m_Samples[0];
+ ERR_ObserverViewSample end = start;
+ if (m_Samples.Count() > 1) end = m_Samples[1];
+ float blend = 0;
+ if (end.Stamp > start.Stamp) blend = Math.Clamp((m_Playback - start.Stamp) / (end.Stamp - start.Stamp), 0, 1);
+ vector forward = vector.Lerp(start.Forward, end.Forward, blend);
+ vector up = vector.Lerp(start.Up, end.Up, blend);
+ if (forward.LengthSq() < 0.1 || up.LengthSq() < 0.1) { forward = end.Forward; up = end.Up; }
forward.Normalize(); up.Normalize();
- if (Math.AbsFloat(vector.Dot(forward, up)) > 0.95) { forward = m_Forward; up = m_Up; }
+ if (Math.AbsFloat(vector.Dot(forward, up)) > 0.95) { forward = end.Forward; up = end.Up; }
Math3D.DirectionAndUpMatrix(forward, up, local);
- local[3] = vector.Lerp(m_StartPosition, m_Position, m_Blend);
- fov = Math.Lerp(m_StartFov, m_Fov, m_Blend); nearPlane = m_Near;
+ local[3] = vector.Lerp(start.Position, end.Position, blend);
+ fov = Math.Lerp(start.Fov, end.Fov, blend); nearPlane = end.Near;
return true;
}
}
--- a/tests/test_observer_view_buffer.py
+++ b/tests/test_observer_view_buffer.py
@@ -1,29 +1,41 @@
"""Execute actual camera sample validation/interpolation; native math substituted."""
+import re
from policy_harness import ROOT,body,run
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
def adapt(s):
for name in ['ERR_ObserverScope','ERR_ObserverViewRules','Math','Math3D','vector','RplId']:s=s.replace(name+'.',name+'::')
- return s.replace('out vector','vector').replace('out float ','float& ')
+ s=s.replace('out vector','vector').replace('out float ','float& ')
+ s=re.sub(r'ERR_ObserverViewSample (\w+)', r'ERR_ObserverViewSample* \1',s)
+ s=re.sub(r'(sample|start|end)\.', r'\1->',s)
+ return re.sub(r'(m_Samples\[\d+\])\.',r'\1->',s)
rules='\n'.join(adapt(body(p,k)) for k in ['static bool Sample(', 'static bool Fresh('])
methods='\n'.join(adapt(body(p,k)) for k in ['void Clear(', 'void Push(', 'bool Read('])
+baseline=ROOT/'docs/evidence/observer-smoothing-2026-10-09/baseline/addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverPlayerView.c'
+old_methods='\n'.join(adapt(body(baseline,k)) for k in ['void Clear(', 'void Push(', 'bool Read('])
run(r'''
#include <cassert>
#include <cmath>
#include <algorithm>
#include <limits>
+#include <vector>
+#include <iostream>
struct vector {float operator[](int i){return i==0?x:(i==1?y:z);}bool operator==(vector b){return x==b.x&&y==b.y&&z==b.z;}static vector Zero;float x=0,y=0,z=0;vector operator+(vector v){return{x+v.x,y+v.y,z+v.z};}vector operator-(vector v){return{x-v.x,y-v.y,z-v.z};}vector operator*(float n){return{x*n,y*n,z*n};}
float LengthSq(){return x*x+y*y+z*z;}void Normalize(){float n=sqrt(LengthSq());x/=n;y/=n;z/=n;}
static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}static vector Lerp(vector a,vector b,float f){return a+(b-a)*f;}
};
vector vector::Zero;
-namespace Math {float Round(float f){return std::round(f);}float AbsFloat(float f){return fabs(f);}float Max(float a,float b){return std::max(a,b);}float Clamp(float n,float a,float b){return std::clamp(n,a,b);}float Lerp(float a,float b,float f){return a+(b-a)*f;}}
+namespace Math {float Round(float f){return std::round(f);}float AbsFloat(float f){return fabs(f);}float Max(float a,float b){return std::max(a,b);}float Min(float a,float b){return std::min(a,b);}float Clamp(float n,float a,float b){return std::clamp(n,a,b);}float Lerp(float a,float b,float f){return a+(b-a)*f;}}
vector cross(vector a,vector b){return{a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x};}
namespace Math3D {void DirectionAndUpMatrix(vector forward,vector up,vector* out){auto right=cross(up,forward);right.Normalize();out[0]=right;out[1]=cross(forward,right);out[2]=forward;}}
struct RplId {int value=-1;RplId(int v=-1):value(v){}static RplId Invalid(){return{};}bool operator==(RplId a){return value==a.value;}bool operator!=(RplId a){return value!=a.value;}};
struct ERR_ObserverViewRules {'''+rules+r'''};
struct ERR_ObserverScope {'''+adapt(body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ObserverScope.c','static bool Valid('))+r'''};
-struct Buffer {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1;vector m_Position,m_Forward,m_Up,m_StartPosition,m_StartForward,m_StartUp;float m_Fov=0,m_Near=0,m_StartFov=0,m_Blend=1;
+struct ERR_ObserverViewSample {int Stamp;vector Position,Forward,Up;float Fov,Near;};
+template<class T>struct Array:std::vector<T>{int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Clear(){this->clear();}void Insert(T v){this->push_back(v);}void Remove(int i){this->erase(this->begin()+i);}};
+struct Buffer {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1,m_Playback=0,m_ReadAt=0;Array<ERR_ObserverViewSample*> m_Samples;
'''+methods+r'''};
+struct Baseline {RplId m_ScopeWeapon;vector m_ScopeData;RplId m_Target;int m_Stamp=-1;float m_Received=-1;vector m_Position,m_Forward,m_Up,m_StartPosition,m_StartForward,m_StartUp;float m_Fov=0,m_Near=0,m_StartFov=0,m_Blend=1;
+'''+old_methods+r'''};
bool close(float a,float b){return fabs(a-b)<0.0001;}
int main(){
vector pos{.1,1.6,.2},forward{0,0,1},up{0,1,0};
@@ -42,11 +54,11 @@
b.Push(1,100,100,pos,forward,up,60,.0125, -1, {});assert(b.Read(1,100,.01,matrix,fov,nearPlane));assert(close(matrix[3].y,1.6)&&close(fov,60));
// Actual lean/roll and zoom, without forcing world-up or a hard-coded camera FOV.
vector leanedUp{.6,.8,0};b.Push(1,150,150,{.3,1.4,.2},forward,leanedUp,30,.02, -1, {});
- assert(b.Read(1,175,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.2)&&close(fov,45)&&matrix[1].x>0);
- assert(b.Read(1,200,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.3)&&close(fov,30)&&close(matrix[1].x,.6)&&close(nearPlane,.02));
- b.Push(1,149,220,{9,9,9},forward,up,80,.1, -1, {}); // out-of-order packet cannot rewind the view
- assert(b.Read(1,225,.025,matrix,fov,nearPlane)&&close(matrix[3].x,.3));
- for(int i=0;i<1000;i++)assert(b.Read(1,250,.016,matrix,fov,nearPlane)&&close(matrix[3].x,.3)); // no integration drift
+ assert(b.Read(1,225,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.2)&&close(fov,45)&&matrix[1].x>0);
+ assert(b.Read(1,250,.025,matrix,fov,nearPlane));assert(close(matrix[3].x,.3)&&close(fov,30)&&close(matrix[1].x,.6)&&close(nearPlane,.02));
+ b.Push(1,149,270,{9,9,9},forward,up,80,.1, -1, {}); // out-of-order packet cannot rewind the view
+ assert(b.Read(1,275,.025,matrix,fov,nearPlane)&&close(matrix[3].x,.3));
+ for(int i=0;i<1000;i++)assert(b.Read(1,300,.016,matrix,fov,nearPlane)&&close(matrix[3].x,.3)); // no integration drift
assert(!b.Read(1,501,.01,matrix,fov,nearPlane)); // gap beyond 350 ms, no stale head approximation
assert(!b.Read(2,250,.01,matrix,fov,nearPlane)); // target switch never inherits previous pose
b.Push(2,200,600,{1,2,3},forward,up,75,.1, -1, {});assert(b.Read(2,600,0,matrix,fov,nearPlane)&&close(matrix[3].x,1));
@@ -62,6 +74,41 @@
assert(!ERR_ObserverScope::Valid(22,{1,91,0}));
assert(!ERR_ObserverScope::Valid(22,{1,0,2}));
assert(!ERR_ObserverViewRules::Fresh(100,101));
+ // Real interpolation bodies, same constant-speed poses, uneven packet arrivals.
+ // Different client/server clock origins, dropped packets and varied render FPS.
+ for(float frame:{1000.f/30,1000.f/60,1000.f/144}){
+ Buffer smooth;Baseline old;int packet=0;float prevNew=0,prevOld=0,lastNewStep=0,lastOldStep=0;
+ float newVariation=0,oldVariation=0;int delay[]={0,25,5,35,0,15};
+ for(float t=0;t<2500;t+=frame){
+ while(packet<=50&&packet*50+delay[packet%6]<=t){
+ if(packet%13!=12){ // occasional single-packet loss
+ vector location{packet*.05f,1.6,0};
+ smooth.Push(1,70000+packet*50,10000+t,location,forward,up,60,.02,-1,{});
+ old.Push(1,70000+packet*50,10000+t,location,forward,up,60,.02,-1,{});
+ }
+ packet++;
+ }
+ assert(smooth.Read(1,10000+t,frame/1000,matrix,fov,nearPlane));float nextNew=matrix[3].x;
+ assert(old.Read(1,10000+t,frame/1000,matrix,fov,nearPlane));float nextOld=matrix[3].x;
+ float newStep=nextNew-prevNew,oldStep=nextOld-prevOld;
+ assert(newStep>=-0.0001); // no timeline rewind
+ if(t>500){newVariation+=fabs(newStep-lastNewStep);oldVariation+=fabs(oldStep-lastOldStep);}
+ prevNew=nextNew;prevOld=nextOld;lastNewStep=newStep;lastOldStep=oldStep;
+ assert(smooth.m_Samples.Count()<=12);
+ }
+ std::cout<<"frame_ms="<<frame<<" buffered_step_variation="<<newVariation<<" baseline="<<oldVariation<<"\n";
+ assert(newVariation<oldVariation*.25f);
+ }
+ // Holding an unrendered stream stays bounded; opening it resumes near live.
+ Buffer parked;
+ for(int i=0;i<1000;i++)parked.Push(1,i*50,i*50,pos,forward,up,60,.02,-1,{});
+ assert(parked.m_Samples.Count()==12);
+ assert(parked.Read(1,49950,0,matrix,fov,nearPlane)&&close(matrix[3].x,pos.x));
+ // Scope changes, reacquisition after expiry and target changes snap to their
+ // own pose instead of blending through another sight or another player.
+ parked.Push(1,50000,50400,{2,1,0},forward,up,30,.02,-1,{});
+ assert(parked.Read(1,50400,0,matrix,fov,nearPlane)&&close(matrix[3].x,2));
+ parked.Clear();assert(parked.m_Samples.IsEmpty()&&parked.m_ScopeWeapon==RplId(-1));
}
''')
-print('PASS: actual view buffer validates samples, follows position/roll/FOV, rejects stale/foreign/reordered data, interpolates without drift and expires gaps')
+print('PASS: actual view buffer validates samples, follows position/roll/FOV, rejects stale/foreign/reordered data, buffers jitter without drift, bounds memory and expires gaps')