• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 20873263566

10 Jan 2026 05:06AM UTC coverage: 86.518% (-0.02%) from 86.533%
20873263566

push

github

tyevco
feat(graphics): Enhance RenderSystem for multi-light PBR rendering

Update RenderSystem to fully support the PBR shader:

- Collect up to 8 lights (directional, point, spot) into uniform arrays
- Bind all 5 PBR texture slots (base color, normal, metallic-roughness, occlusion, emissive)
- Set all material factor uniforms (baseColor, metallic, roughness, emissive, normalScale, occlusionStrength, alphaCutoff)
- Set texture presence flags for conditional shader logic
- Handle AlphaMode (blend vs opaque vs mask)
- Handle DoubleSided materials (disable culling)
- Maintain backward compatibility with legacy LitShader

Update PBR vertex shader to calculate normal matrix internally for simpler uniform interface.

Closes #886

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

9309 of 12756 branches covered (72.98%)

Branch coverage included in aggregate %.

161154 of 184270 relevant lines covered (87.46%)

1.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

20.56
/src/KeenEyes.Network/Systems/ClientPredictionSystem.cs
1
using KeenEyes.Capabilities;
2
using KeenEyes.Network.Components;
3
using KeenEyes.Network.Prediction;
4
using KeenEyes.Network.Serialization;
5

6
namespace KeenEyes.Network.Systems;
7

8
/// <summary>
9
/// System that handles client-side prediction, misprediction detection, and reconciliation.
10
/// </summary>
11
/// <remarks>
12
/// <para>
13
/// This system runs on the client and manages the prediction lifecycle:
14
/// 1. Saves predicted states after local simulation
15
/// 2. Compares server-confirmed states against predictions
16
/// 3. Triggers reconciliation (rollback + replay) on misprediction
17
/// </para>
18
/// </remarks>
19
/// <param name="plugin">The network client plugin.</param>
20
/// <param name="interpolator">Optional interpolator for smooth corrections.</param>
21
/// <param name="applyInput">Function to apply an input during replay.</param>
22
public sealed class ClientPredictionSystem(
1✔
23
    NetworkClientPlugin plugin,
1✔
24
    INetworkInterpolator? interpolator = null,
1✔
25
    Action<Entity, object>? applyInput = null) : SystemBase
1✔
26
{
27
    private readonly Dictionary<Entity, PredictionBuffer> predictionBuffers = [];
1✔
28
    private readonly INetworkInterpolator? smoothingInterpolator = interpolator;
1✔
29

30
    /// <summary>
31
    /// Gets the prediction buffer for an entity.
32
    /// </summary>
33
    /// <param name="entity">The entity.</param>
34
    /// <returns>The prediction buffer, or null if not found.</returns>
35
    public PredictionBuffer? GetPredictionBuffer(Entity entity)
36
    {
37
        return predictionBuffers.TryGetValue(entity, out var buffer) ? buffer : null;
×
38
    }
39

40
    /// <inheritdoc/>
41
    public override void Update(float deltaTime)
42
    {
43
        if (!plugin.Config.EnablePrediction)
1✔
44
        {
45
            return;
×
46
        }
47

48
        // Save predicted states for locally owned entities
49
        foreach (var entity in World.Query<LocallyOwned, Predicted, PredictionState>())
1✔
50
        {
51
            SavePredictedState(entity);
1✔
52
        }
53
    }
1✔
54

55
    /// <summary>
56
    /// Called when server state is received to check for misprediction.
57
    /// </summary>
58
    /// <param name="entity">The entity that received server state.</param>
59
    /// <param name="serverTick">The tick the server state is for.</param>
60
    /// <param name="serverStates">The server component states.</param>
61
    public void OnServerStateReceived(
62
        Entity entity,
63
        uint serverTick,
64
        IReadOnlyDictionary<Type, object> serverStates)
65
    {
66
        if (!World.Has<PredictionState>(entity))
×
67
        {
68
            return;
×
69
        }
70

71
        ref var predState = ref World.Get<PredictionState>(entity);
×
72

73
        // Check if we have a prediction for this tick
74
        if (!predictionBuffers.TryGetValue(entity, out var buffer))
×
75
        {
76
            return;
×
77
        }
78

79
        var predictedStates = buffer.GetStatesForTick(serverTick);
×
80
        if (predictedStates is null)
×
81
        {
82
            // No prediction for this tick, just apply server state
83
            ApplyServerState(entity, serverStates);
×
84
            predState.LastConfirmedTick = serverTick;
×
85
            return;
×
86
        }
87

88
        // Compare predicted vs server state
89
        var mispredicted = DetectMisprediction(predictedStates, serverStates);
×
90

91
        if (mispredicted)
×
92
        {
93
            predState.MispredictionDetected = true;
×
94
            Reconcile(entity, serverTick, serverStates);
×
95
        }
96
        else
97
        {
98
            predState.MispredictionDetected = false;
×
99
        }
100

101
        // Update confirmed tick and clean up old predictions
102
        predState.LastConfirmedTick = serverTick;
×
103
        buffer.RemoveOlderThan(serverTick);
×
104
    }
×
105

106
    private void SavePredictedState(Entity entity)
107
    {
108
        if (!predictionBuffers.TryGetValue(entity, out var buffer))
1✔
109
        {
110
            buffer = new PredictionBuffer(plugin.Config.InputBufferSize);
1✔
111
            predictionBuffers[entity] = buffer;
1✔
112
        }
113

114
        ref var predState = ref World.Get<PredictionState>(entity);
1✔
115
        var tick = plugin.CurrentTick;
1✔
116

117
        // Save all replicated component states
118
        var serializer = plugin.Config.Serializer;
1✔
119
        if (serializer is null)
1✔
120
        {
121
            return;
1✔
122
        }
123

124
        if (World is ISnapshotCapability snapshot)
×
125
        {
126
            foreach (var (type, value) in snapshot.GetComponents(entity))
×
127
            {
128
                if (serializer.IsNetworkSerializable(type))
×
129
                {
130
                    // Clone the value to avoid storing a reference
131
                    buffer.SaveState(tick, type, CloneValue(type, value));
×
132
                }
133
            }
134
        }
135

136
        predState.LastPredictedTick = tick;
×
137
    }
×
138

139
    private bool DetectMisprediction(
140
        IReadOnlyDictionary<Type, object> predicted,
141
        IReadOnlyDictionary<Type, object> server)
142
    {
143
        var threshold = plugin.Config.MispredictionThreshold;
×
144

145
        foreach (var (type, serverValue) in server)
×
146
        {
147
            if (!predicted.TryGetValue(type, out var predictedValue))
×
148
            {
149
                // We don't have a prediction for this component, misprediction
150
                return true;
×
151
            }
152

153
            if (!ValuesApproximatelyEqual(predictedValue, serverValue, threshold))
×
154
            {
155
                return true;
×
156
            }
157
        }
158

159
        return false;
×
160
    }
×
161

162
    private bool ValuesApproximatelyEqual(object predicted, object server, float threshold)
163
    {
164
        // Fast path: exact equality
165
        if (predicted.Equals(server))
×
166
        {
167
            return true;
×
168
        }
169

170
        // Types must match
171
        var type = predicted.GetType();
×
172
        if (type != server.GetType())
×
173
        {
174
            return false;
×
175
        }
176

177
        // For threshold-based comparison, use the network serializer's delta detection
178
        if (threshold > 0)
×
179
        {
180
            var serializer = plugin.Config.Serializer;
×
181
            if (serializer is not null && serializer.SupportsDelta(type))
×
182
            {
183
                // If the dirty mask is 0, no fields have changed significantly
184
                // The dirty mask uses epsilon-based comparison for floats
185
                var dirtyMask = serializer.GetDirtyMask(type, predicted, server);
×
186
                return dirtyMask == 0;
×
187
            }
188
        }
189

190
        // Fallback: not approximately equal if not exactly equal
191
        return false;
×
192
    }
193

194
    private void Reconcile(
195
        Entity entity,
196
        uint serverTick,
197
        IReadOnlyDictionary<Type, object> serverStates)
198
    {
199
        // Step 1: Apply server state (rollback)
200
        ApplyServerState(entity, serverStates);
×
201

202
        // Step 2: Replay inputs from serverTick to current tick
203
        if (applyInput is not null)
×
204
        {
205
            var inputBuffer = plugin.GetInputBuffer(entity) as IInputBuffer;
×
206
            if (inputBuffer is not null)
×
207
            {
208
                foreach (var input in inputBuffer.GetInputsFromBoxed(serverTick + 1))
×
209
                {
210
                    applyInput(entity, input);
×
211
                }
212
            }
213
        }
214

215
        // Calculate correction magnitude for smoothing
216
        ref var predState = ref World.Get<PredictionState>(entity);
×
217

218
        // If we have an interpolator, we could use it for smooth corrections
219
        // For now, just set to a fixed value - a proper implementation would
220
        // calculate the actual distance between old and new positions
221
        predState.LastCorrectionMagnitude = 1.0f;
×
222

223
        // Store interpolator availability for potential future smoothing
224
        predState.SmoothingAvailable = smoothingInterpolator is not null;
×
225
    }
×
226

227
    private void ApplyServerState(Entity entity, IReadOnlyDictionary<Type, object> serverStates)
228
    {
229
        foreach (var (type, value) in serverStates)
×
230
        {
231
            World.SetComponent(entity, type, value);
×
232
        }
233
    }
×
234

235
    private static object CloneValue(Type type, object value)
236
    {
237
        // For value types (structs), boxing already creates a copy
238
        if (type.IsValueType)
×
239
        {
240
            return value;
×
241
        }
242

243
        // For reference types, we'd need deep cloning
244
        // Network components should be value types (structs)
245
        return value;
246
    }
247

248
    /// <summary>
249
    /// Registers an entity for prediction tracking.
250
    /// </summary>
251
    /// <param name="entity">The entity to track.</param>
252
    public void RegisterEntity(Entity entity)
253
    {
254
        if (!predictionBuffers.ContainsKey(entity))
×
255
        {
256
            predictionBuffers[entity] = new PredictionBuffer(plugin.Config.InputBufferSize);
×
257
        }
258
    }
×
259

260
    /// <summary>
261
    /// Unregisters an entity from prediction tracking.
262
    /// </summary>
263
    /// <param name="entity">The entity to unregister.</param>
264
    public void UnregisterEntity(Entity entity)
265
    {
266
        predictionBuffers.Remove(entity);
×
267
    }
×
268
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc