• 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

58.48
/src/KeenEyes.Network/Systems/NetworkServerSendSystem.cs
1
using KeenEyes.Capabilities;
2
using KeenEyes.Network.Components;
3
using KeenEyes.Network.Protocol;
4
using KeenEyes.Network.Serialization;
5
using KeenEyes.Network.Transport;
6

7
namespace KeenEyes.Network.Systems;
8

9
/// <summary>
10
/// Server system that sends state updates to clients.
11
/// </summary>
12
/// <remarks>
13
/// Runs in LateUpdate phase after game logic has executed.
14
/// </remarks>
15
public sealed class NetworkServerSendSystem(NetworkServerPlugin plugin) : SystemBase
1✔
16
{
17
    private readonly byte[] sendBuffer = new byte[4096];
1✔
18

19
    // Track last sent component values per entity for delta detection
20
    private readonly Dictionary<Entity, Dictionary<Type, object>> lastSentState = [];
1✔
21

22
    // Track bytes sent this tick for bandwidth limiting
23
    private int bytesSentThisTick;
24

25
    // Pre-allocated list to avoid per-tick allocations
26
    private readonly List<(Entity entity, float priority, bool needsFullSync)> entitiesToUpdate = [];
1✔
27

28
    /// <inheritdoc/>
29
    public override void Update(float deltaTime)
30
    {
31
        // Advance tick
32
        if (!plugin.Tick(deltaTime))
1✔
33
        {
34
            return; // Not time for a network tick yet
1✔
35
        }
36

37
        // Check for clients that need full snapshots
38
        foreach (var client in plugin.GetConnectedClients())
1✔
39
        {
40
            if (client.NeedsFullSnapshot)
1✔
41
            {
42
                plugin.SendFullSnapshot(client.ClientId);
1✔
43
                client.NeedsFullSnapshot = false;
1✔
44
            }
45
        }
46

47
        var serializer = plugin.Config.Serializer;
1✔
48
        var config = plugin.Config;
1✔
49

50
        // Calculate bytes per tick budget
51
        var bytesPerTick = config.EnableBandwidthLimiting
1✔
52
            ? config.MaxBandwidthBytesPerSecond / config.TickRate
1✔
53
            : int.MaxValue;
1✔
54
        bytesSentThisTick = 0;
1✔
55

56
        // Collect entities that need updates and sort by priority
57
        entitiesToUpdate.Clear();
1✔
58

59
        foreach (var entity in World.Query<NetworkId, NetworkState>())
1✔
60
        {
61
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
62

63
            // Accumulate priority over time
64
            networkState.AccumulatedPriority += deltaTime;
1✔
65

66
            if (ShouldSendEntity(entity, ref networkState, serializer))
1✔
67
            {
68
                entitiesToUpdate.Add((entity, networkState.AccumulatedPriority, networkState.NeedsFullSync));
1✔
69
            }
70
        }
71

72
        // Sort by priority (higher first), with full sync entities always at front
73
        entitiesToUpdate.Sort((a, b) =>
1✔
74
        {
1✔
75
            // Full sync entities have highest priority
1✔
76
            if (a.needsFullSync != b.needsFullSync)
1✔
77
            {
1✔
78
                return a.needsFullSync ? -1 : 1;
×
79
            }
1✔
80
            return b.priority.CompareTo(a.priority);
1✔
81
        });
1✔
82

83
        // Send entity updates within bandwidth budget
84
        foreach (var (entity, _, _) in entitiesToUpdate)
1✔
85
        {
86
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
87
            ref readonly var networkId = ref World.Get<NetworkId>(entity);
1✔
88

89
            // Check bandwidth budget
90
            if (config.EnableBandwidthLimiting && bytesSentThisTick >= bytesPerTick)
1✔
91
            {
92
                // Don't reset priority for entities we couldn't send
93
                break;
×
94
            }
95

96
            SendEntityUpdate(entity, networkId, ref networkState);
1✔
97
            networkState.LastSentTick = plugin.CurrentTick;
1✔
98
            networkState.AccumulatedPriority = 0; // Reset priority after sending
1✔
99

100
            // Stop if we've exceeded budget (but we already sent this message)
101
            if (config.EnableBandwidthLimiting && bytesSentThisTick > bytesPerTick)
1✔
102
            {
103
                break;
×
104
            }
105
        }
106

107
        // Pump the transport to flush outgoing data
108
        plugin.Transport.Update();
1✔
109
    }
1✔
110

111
    private bool ShouldSendEntity(Entity entity, ref NetworkState state, INetworkSerializer? serializer)
112
    {
113
        // Always send if needs full sync
114
        if (state.NeedsFullSync)
1✔
115
        {
116
            return true;
1✔
117
        }
118

119
        // If no serializer, we can only send spawn/despawn
120
        if (serializer is null)
×
121
        {
122
            return false;
×
123
        }
124

125
        // Check if any replicated component has changed
126
        if (!lastSentState.TryGetValue(entity, out var entityState))
×
127
        {
128
            // Never sent this entity - needs update
129
            return true;
×
130
        }
131

132
        // Compare current state to last sent state using delta masks for efficiency
133
        if (World is ISnapshotCapability snapshot)
×
134
        {
135
            foreach (var (type, value) in snapshot.GetComponents(entity))
×
136
            {
137
                if (!serializer.IsNetworkSerializable(type))
×
138
                {
139
                    continue;
140
                }
141

142
                if (!entityState.TryGetValue(type, out var lastValue))
×
143
                {
144
                    // New component - needs update
145
                    return true;
×
146
                }
147

148
                // Use dirty mask for delta-supported types, fallback to Equals for others
149
                if (serializer.SupportsDelta(type))
×
150
                {
151
                    if (serializer.GetDirtyMask(type, value, lastValue) != 0)
×
152
                    {
153
                        return true;
×
154
                    }
155
                }
156
                else if (!Equals(lastValue, value))
×
157
                {
158
                    return true;
×
159
                }
160
            }
161
        }
162

163
        return false;
×
164
    }
×
165

166
    private void SendEntityUpdate(Entity entity, NetworkId networkId, ref NetworkState state)
167
    {
168
        var writer = new NetworkMessageWriter(sendBuffer);
1✔
169
        var serializer = plugin.Config.Serializer;
1✔
170

171
        if (state.NeedsFullSync)
1✔
172
        {
173
            // Send full entity state
174
            writer.WriteHeader(MessageType.EntitySpawn, plugin.CurrentTick);
1✔
175

176
            var owner = World.Has<NetworkOwner>(entity)
1✔
177
                ? World.Get<NetworkOwner>(entity)
1✔
178
                : NetworkOwner.Server;
1✔
179

180
            writer.WriteEntitySpawn(networkId.Value, owner.ClientId);
1✔
181

182
            // Write all replicated components (full serialization)
183
            WriteReplicatedComponentsFull(entity, ref writer, serializer);
1✔
184

185
            state.NeedsFullSync = false;
1✔
186
        }
187
        else
188
        {
189
            // Send delta update (only changed fields within components)
190
            writer.WriteHeader(MessageType.ComponentDelta, plugin.CurrentTick);
×
191
            writer.WriteUInt32(networkId.Value);
×
192

193
            // Write components with delta encoding
194
            WriteReplicatedComponentsDelta(entity, ref writer, serializer);
×
195
        }
196

197
        var span = writer.GetWrittenSpan();
1✔
198
        bytesSentThisTick += span.Length;
1✔
199
        plugin.SendToAll(span, DeliveryMode.UnreliableSequenced);
1✔
200

201
        // Update last sent state for delta tracking
202
        SaveSentState(entity, serializer);
1✔
203
    }
1✔
204

205
    private void WriteReplicatedComponentsFull(Entity entity, ref NetworkMessageWriter writer, INetworkSerializer? serializer)
206
    {
207
        if (serializer is null)
1✔
208
        {
209
            writer.WriteComponentCount(0);
1✔
210
            return;
1✔
211
        }
212

213
        // Collect all replicated components
214
        var toSend = new List<(Type, object)>();
1✔
215
        if (World is ISnapshotCapability snapshot)
1✔
216
        {
217
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
218
            {
219
                if (serializer.IsNetworkSerializable(type))
1✔
220
                {
221
                    toSend.Add((type, value));
×
222
                }
223
            }
224
        }
225

226
        writer.WriteComponentCount((byte)toSend.Count);
1✔
227
        foreach (var (type, value) in toSend)
1✔
228
        {
229
            writer.WriteComponent(serializer, type, value);
×
230
        }
231
    }
1✔
232

233
    private void WriteReplicatedComponentsDelta(Entity entity, ref NetworkMessageWriter writer, INetworkSerializer? serializer)
234
    {
235
        if (serializer is null)
×
236
        {
237
            writer.WriteComponentCount(0);
×
238
            return;
×
239
        }
240

241
        // Get last sent state for delta comparison
242
        lastSentState.TryGetValue(entity, out var entityLastState);
×
243

244
        // Collect components that have changed
245
        var toSend = new List<(Type type, object current, object? baseline)>();
×
246
        if (World is ISnapshotCapability snapshot)
×
247
        {
248
            foreach (var (type, value) in snapshot.GetComponents(entity))
×
249
            {
250
                if (!serializer.IsNetworkSerializable(type))
×
251
                {
252
                    continue;
253
                }
254

255
                object? lastValue = null;
×
256
                if (entityLastState is not null)
×
257
                {
258
                    entityLastState.TryGetValue(type, out lastValue);
×
259
                }
260

261
                // Check if changed using delta mask or equality
262
                bool hasChanged;
263
                if (lastValue is null)
×
264
                {
265
                    hasChanged = true; // New component
×
266
                }
267
                else if (serializer.SupportsDelta(type))
×
268
                {
269
                    hasChanged = serializer.GetDirtyMask(type, value, lastValue) != 0;
×
270
                }
271
                else
272
                {
273
                    hasChanged = !Equals(lastValue, value);
×
274
                }
275

276
                if (hasChanged)
×
277
                {
278
                    toSend.Add((type, value, lastValue));
×
279
                }
280
            }
281
        }
282

283
        writer.WriteComponentCount((byte)toSend.Count);
×
284

285
        // Write each component with delta encoding where supported
286
        foreach (var (type, current, baseline) in toSend)
×
287
        {
288
            // Use delta serialization if we have a baseline and the type supports it
289
            if (baseline is not null && serializer.SupportsDelta(type))
×
290
            {
291
                writer.WriteComponentDelta(serializer, type, current, baseline);
×
292
            }
293
            else
294
            {
295
                // Fall back to full serialization
296
                writer.WriteComponent(serializer, type, current);
×
297
            }
298
        }
299
    }
×
300

301
    private void SaveSentState(Entity entity, INetworkSerializer? serializer)
302
    {
303
        if (serializer is null)
1✔
304
        {
305
            return;
1✔
306
        }
307

308
        if (!lastSentState.TryGetValue(entity, out var entityState))
1✔
309
        {
310
            entityState = [];
1✔
311
            lastSentState[entity] = entityState;
1✔
312
        }
313

314
        // Save current state of all replicated components
315
        if (World is ISnapshotCapability snapshot)
1✔
316
        {
317
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
318
            {
319
                if (serializer.IsNetworkSerializable(type))
1✔
320
                {
321
                    // Store a copy of the value (boxing creates a copy for value types)
322
                    entityState[type] = value;
×
323
                }
324
            }
325
        }
326
    }
1✔
327

328
    /// <summary>
329
    /// Clears tracking state for an entity (call when entity is despawned).
330
    /// </summary>
331
    /// <param name="entity">The entity to clear.</param>
332
    public void ClearEntityState(Entity entity)
333
    {
334
        lastSentState.Remove(entity);
×
335
    }
×
336
}
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