--- 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 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 #include #include #include +#include +#include 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;}; +templatestruct Array:std::vector{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 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="<