Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
207 lines
14 KiB
Diff
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')
|