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

orion-ecs / keen-eye / 30114885092

24 Jul 2026 05:55PM UTC coverage: 64.71% (+0.5%) from 64.224%
30114885092

push

github

tyevco
fix(editor): Fix plugin init subscription leak, unreachable play-mode reload, and hot-reload CTS race (#1177,#1178,#1182)

#1177: EditorPluginManager.InstallPlugin and EnableDynamicPlugin now dispose
the context's event subscriptions when a plugin's Initialize throws, so a
dead plugin's handlers can never fire. RaiseSceneOpened guards each handler
so one plugin's fault cannot suppress others or escape into the caller.

Closes #1177

#1178: Entering play mode disables the file watcher, so its change callback
could never queue a deferred reload and edits made during play mode were
silently lost. HotReloadService now records the play-mode entry time and, on
exit, scans project sources for edits since that time, making the
pending-reload path reachable.

Closes #1178

#1182: HotReloadManager swapped the debounce CancellationTokenSource without
synchronization, so a concurrently-dispatched watcher event could dispose the
source between creation and token read, throwing ObjectDisposedException out
of an async void handler. The swap/dispose is now guarded by a Lock and the
token is captured inside it; the delay also catches ObjectDisposedException
defensively.

Closes #1182

8718 of 12744 branches covered (68.41%)

Branch coverage included in aggregate %.

38 of 45 new or added lines in 3 files covered. (84.44%)

130 existing lines in 17 files now uncovered.

51720 of 80654 relevant lines covered (64.13%)

1.0 hits per line

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

64.16
/src/KeenEyes.Graphics/Systems/InstanceBatchingSystem.cs
1
using System.Numerics;
2

3
using KeenEyes.Common;
4
using KeenEyes.Graphics.Abstractions;
5

6
namespace KeenEyes.Graphics;
7

8
/// <summary>
9
/// Key for grouping instanced entities into batches.
10
/// </summary>
11
/// <param name="MeshId">The mesh resource handle.</param>
12
/// <param name="MaterialId">The material resource handle.</param>
13
/// <param name="BatchId">The user-defined batch identifier.</param>
14
internal readonly record struct BatchKey(int MeshId, int MaterialId, int BatchId);
15

16
/// <summary>
17
/// Represents a prepared batch of instances ready for rendering.
18
/// </summary>
19
public sealed class PreparedBatch
20
{
21
    /// <summary>
22
    /// The mesh handle for this batch.
23
    /// </summary>
24
    public MeshHandle Mesh { get; init; }
25

26
    /// <summary>
27
    /// The material for this batch (if any entities have materials).
28
    /// </summary>
29
    public Material Material { get; set; }
30

31
    /// <summary>
32
    /// Whether this batch has a material component.
33
    /// </summary>
34
    public bool HasMaterial { get; set; }
35

36
    /// <summary>
37
    /// The instance buffer handle containing per-instance data.
38
    /// </summary>
39
    public InstanceBufferHandle InstanceBuffer { get; set; }
40

41
    /// <summary>
42
    /// The allocated capacity (in instances) of <see cref="InstanceBuffer"/> on the GPU.
43
    /// Tracked per batch so growth decisions are independent of any shared staging array.
44
    /// </summary>
45
    internal int Capacity { get; set; }
46

47
    /// <summary>
48
    /// The number of instances in this batch.
49
    /// </summary>
50
    public int InstanceCount { get; set; }
51

52
    /// <summary>
53
    /// The render layer for sorting.
54
    /// </summary>
55
    public int Layer { get; set; }
56
}
57

58
/// <summary>
59
/// System that prepares instance batches for GPU instanced rendering.
60
/// </summary>
61
/// <remarks>
62
/// <para>
63
/// The InstanceBatchingSystem queries for entities with <see cref="Transform3D"/>,
64
/// <see cref="Renderable"/>, and <see cref="InstanceBatch"/> components. It groups
65
/// them by mesh, material, and batch ID, then uploads the instance data to GPU buffers.
66
/// </para>
67
/// <para>
68
/// This system should run in the PreRender phase, before the <see cref="RenderSystem"/>.
69
/// The RenderSystem should skip entities with InstanceBatch components and instead
70
/// render the prepared batches from this system.
71
/// </para>
72
/// <para>
73
/// For optimal performance, instance buffers are reused when possible and only
74
/// resized when the batch grows beyond capacity.
75
/// </para>
76
/// </remarks>
77
public sealed class InstanceBatchingSystem : ISystem
78
{
79
    private const int InitialBufferCapacity = 128;
80
    private const float BufferGrowthFactor = 1.5f;
81

82
    private IWorld? world;
83
    private IGraphicsContext? graphics;
84

85
    // Batch building state
86
    private readonly Dictionary<BatchKey, List<InstanceData>> batchData = [];
1✔
87
    private readonly Dictionary<BatchKey, PreparedBatch> preparedBatches = [];
1✔
88
    private readonly Dictionary<BatchKey, int> batchLayers = [];
1✔
89
    private readonly Dictionary<BatchKey, Material> batchMaterials = [];
1✔
90
    private readonly Dictionary<BatchKey, bool> batchHasMaterials = [];
1✔
91

92
    // Reusable array for uploading
93
    private InstanceData[] uploadBuffer = new InstanceData[InitialBufferCapacity];
1✔
94

95
    /// <summary>
96
    /// Gets all prepared batches for rendering.
97
    /// </summary>
98
    /// <remarks>
99
    /// This collection is updated each frame by the system. The RenderSystem
100
    /// should iterate over these batches and render them using instanced draw calls.
101
    /// </remarks>
102
    public IEnumerable<PreparedBatch> Batches => preparedBatches.Values;
1✔
103

104
    /// <summary>
105
    /// Gets the number of active batches.
106
    /// </summary>
UNCOV
107
    public int BatchCount => preparedBatches.Count;
×
108

109
    /// <inheritdoc />
110
    public bool Enabled { get; set; } = true;
1✔
111

112
    /// <inheritdoc />
113
    public void Initialize(IWorld world)
114
    {
115
        this.world = world;
1✔
116

117
        if (!world.TryGetExtension<IGraphicsContext>(out graphics))
1✔
118
        {
UNCOV
119
            throw new InvalidOperationException("InstanceBatchingSystem requires IGraphicsContext extension");
×
120
        }
121
    }
1✔
122

123
    /// <inheritdoc />
124
    public void Update(float deltaTime)
125
    {
126
        if (world is null || graphics is null || !graphics.IsInitialized)
1✔
127
        {
128
            return;
×
129
        }
130

131
        // Clear batch data from previous frame
132
        foreach (var list in batchData.Values)
1✔
133
        {
134
            list.Clear();
1✔
135
        }
136
        batchLayers.Clear();
1✔
137
        batchMaterials.Clear();
1✔
138
        batchHasMaterials.Clear();
1✔
139

140
        // Collect all instanced entities and group by batch key
141
        foreach (var entity in world.Query<Transform3D, Renderable, InstanceBatch>())
1✔
142
        {
143
            ref readonly var transform = ref world.Get<Transform3D>(entity);
1✔
144
            ref readonly var renderable = ref world.Get<Renderable>(entity);
1✔
145
            ref readonly var batch = ref world.Get<InstanceBatch>(entity);
1✔
146

147
            // Skip if no mesh
148
            if (renderable.MeshId <= 0)
1✔
149
            {
150
                continue;
151
            }
152

153
            var key = new BatchKey(renderable.MeshId, renderable.MaterialId, batch.BatchId);
1✔
154

155
            // Get or create batch data list
156
            if (!batchData.TryGetValue(key, out var instances))
1✔
157
            {
158
                instances = [];
1✔
159
                batchData[key] = instances;
1✔
160
            }
161

162
            // Create instance data from transform and tint
163
            var instanceData = InstanceData.FromTransform(transform.Matrix(), batch.ColorTint);
1✔
164
            instances.Add(instanceData);
1✔
165

166
            // Track layer (use minimum layer in batch for sorting)
167
            if (!batchLayers.TryGetValue(key, out var existingLayer) || renderable.Layer < existingLayer)
1✔
168
            {
169
                batchLayers[key] = renderable.Layer;
1✔
170
            }
171

172
            // Track material (use first entity's material)
173
            if (!batchHasMaterials.ContainsKey(key))
1✔
174
            {
175
                if (world.Has<Material>(entity))
1✔
176
                {
UNCOV
177
                    batchMaterials[key] = world.Get<Material>(entity);
×
UNCOV
178
                    batchHasMaterials[key] = true;
×
179
                }
180
                else
181
                {
182
                    batchHasMaterials[key] = false;
1✔
183
                }
184
            }
185
        }
186

187
        // Update instance buffers for each batch
188
        foreach (var (key, instances) in batchData)
1✔
189
        {
190
            if (instances.Count == 0)
1✔
191
            {
192
                continue;
193
            }
194

195
            // Get or create prepared batch
196
            if (!preparedBatches.TryGetValue(key, out var prepared))
1✔
197
            {
198
                // Create new batch with a buffer sized to fit the current instances
199
                int capacity = Math.Max(InitialBufferCapacity, instances.Count);
1✔
200
                var buffer = graphics.CreateInstanceBuffer(capacity);
1✔
201

202
                prepared = new PreparedBatch
1✔
203
                {
1✔
204
                    Mesh = new MeshHandle(key.MeshId),
1✔
205
                    InstanceBuffer = buffer,
1✔
206
                    Capacity = capacity,
1✔
207
                };
1✔
208
                preparedBatches[key] = prepared;
1✔
209
            }
210

211
            // Ensure the reusable CPU staging buffer is large enough to hold this batch.
212
            // This array is shared across all batches, so it only ever grows.
213
            if (instances.Count > uploadBuffer.Length)
1✔
214
            {
215
                uploadBuffer = new InstanceData[(int)(instances.Count * BufferGrowthFactor)];
1✔
216
            }
217

218
            // Grow this batch's GPU buffer when it can no longer hold the instances.
219
            // Capacity is tracked per batch (not via the shared staging array) so that a
220
            // batch which grows after another batch already enlarged the staging buffer
221
            // still resizes its own GPU buffer (see #1184).
222
            var currentBuffer = prepared.InstanceBuffer;
1✔
223
            if (instances.Count > prepared.Capacity)
1✔
224
            {
225
                int newCapacity = (int)(instances.Count * BufferGrowthFactor);
1✔
226

227
                graphics.DeleteInstanceBuffer(currentBuffer);
1✔
228
                currentBuffer = graphics.CreateInstanceBuffer(newCapacity);
1✔
229
                prepared.InstanceBuffer = currentBuffer;
1✔
230
                prepared.Capacity = newCapacity;
1✔
231
            }
232

233
            // Copy to upload buffer
234
            for (int i = 0; i < instances.Count; i++)
1✔
235
            {
236
                uploadBuffer[i] = instances[i];
1✔
237
            }
238

239
            // Upload to GPU
240
            graphics.UpdateInstanceBuffer(currentBuffer, uploadBuffer.AsSpan(0, instances.Count));
1✔
241

242
            // Update batch metadata
243
            prepared.InstanceCount = instances.Count;
1✔
244
            prepared.Layer = batchLayers.GetValueOrDefault(key, 0);
1✔
245
            prepared.HasMaterial = batchHasMaterials.GetValueOrDefault(key, false);
1✔
246
            if (prepared.HasMaterial && batchMaterials.TryGetValue(key, out var material))
1✔
247
            {
UNCOV
248
                prepared.Material = material;
×
249
            }
250
        }
251

252
        // Clean up empty batches (batches that had no entities this frame)
253
        var keysToRemove = new List<BatchKey>();
1✔
254
        foreach (var (key, prepared) in preparedBatches)
1✔
255
        {
256
            if (!batchData.TryGetValue(key, out var instances) || instances.Count == 0)
1✔
257
            {
258
                // Delete GPU buffer
UNCOV
259
                graphics.DeleteInstanceBuffer(prepared.InstanceBuffer);
×
UNCOV
260
                keysToRemove.Add(key);
×
261
            }
262
        }
263

264
        foreach (var key in keysToRemove)
1✔
265
        {
266
            preparedBatches.Remove(key);
×
267
        }
268

269
        // Render all batches
270
        RenderBatches();
1✔
271
    }
1✔
272

273
    /// <summary>
274
    /// Renders all prepared batches using instanced draw calls.
275
    /// </summary>
276
    private void RenderBatches()
277
    {
278
        if (preparedBatches.Count == 0 || graphics is null || world is null)
1✔
279
        {
280
            return;
×
281
        }
282

283
        // Find active camera
284
        Camera camera = default;
1✔
285
        Transform3D cameraTransform = default;
1✔
286
        bool foundCamera = false;
1✔
287

288
        // Prefer main camera tag
289
        foreach (var entity in world.Query<Camera, Transform3D, MainCameraTag>())
1✔
290
        {
291
            camera = world.Get<Camera>(entity);
×
UNCOV
292
            cameraTransform = world.Get<Transform3D>(entity);
×
UNCOV
293
            foundCamera = true;
×
UNCOV
294
            break;
×
295
        }
296

297
        // Fall back to any camera
298
        if (!foundCamera)
1✔
299
        {
300
            foreach (var entity in world.Query<Camera, Transform3D>())
1✔
301
            {
302
                camera = world.Get<Camera>(entity);
×
303
                cameraTransform = world.Get<Transform3D>(entity);
×
UNCOV
304
                foundCamera = true;
×
UNCOV
305
                break;
×
306
            }
307
        }
308

309
        if (!foundCamera)
1✔
310
        {
311
            // No camera, nothing to render
312
            return;
1✔
313
        }
314

315
        // Calculate camera matrices
UNCOV
316
        Matrix4x4 viewMatrix = camera.ViewMatrix(cameraTransform);
×
UNCOV
317
        Matrix4x4 projectionMatrix = camera.ProjectionMatrix();
×
318

319
        // Sort batches by layer
UNCOV
320
        var sortedBatches = preparedBatches.Values.OrderBy(b => b.Layer).ToList();
×
321

UNCOV
322
        ShaderHandle currentShader = default;
×
323

324
        foreach (var batch in sortedBatches)
×
325
        {
UNCOV
326
            if (batch.InstanceCount == 0)
×
327
            {
328
                continue;
329
            }
330

331
            // Determine shader (use instanced versions)
332
            ShaderHandle shader;
333
            Material material = batch.HasMaterial ? batch.Material : Material.Default;
×
334

335
            if (batch.HasMaterial)
×
336
            {
337
                // Use instanced lit shader for materials
UNCOV
338
                shader = graphics.InstancedLitShader;
×
339
            }
340
            else
341
            {
342
                // No material - use instanced solid shader
343
                shader = graphics.InstancedSolidShader;
×
344
            }
345

346
            // Handle culling based on material double-sided flag
UNCOV
347
            if (material.DoubleSided)
×
348
            {
349
                graphics.SetCulling(false);
×
350
            }
351
            else
352
            {
353
                graphics.SetCulling(true, CullFaceMode.Back);
×
354
            }
355

356
            // Handle alpha blending
UNCOV
357
            if (material.AlphaMode == AlphaMode.Blend)
×
358
            {
359
                graphics.SetBlending(true);
×
360
            }
361
            else
362
            {
UNCOV
363
                graphics.SetBlending(false);
×
364
            }
365

366
            // Bind shader if changed
UNCOV
367
            if (currentShader.Id != shader.Id)
×
368
            {
UNCOV
369
                graphics.BindShader(shader);
×
370
                currentShader = shader;
×
371

372
                // Set per-frame uniforms (instanced shaders don't have uModel)
373
                graphics.SetUniform("uView", viewMatrix);
×
374
                graphics.SetUniform("uProjection", projectionMatrix);
×
375
                graphics.SetUniform("uCameraPosition", cameraTransform.Position);
×
376

377
                // Set default light uniforms
UNCOV
378
                graphics.SetUniform("uLightDirection", -Vector3.UnitY);
×
379
                graphics.SetUniform("uLightColor", Vector3.One);
×
380
                graphics.SetUniform("uLightIntensity", 1f);
×
381
            }
382

383
            // Bind texture and set material uniforms
UNCOV
384
            var texture = material.BaseColorTextureId > 0
×
UNCOV
385
                ? new TextureHandle(material.BaseColorTextureId)
×
UNCOV
386
                : graphics.WhiteTexture;
×
UNCOV
387
            graphics.BindTexture(texture, 0);
×
388
            graphics.SetUniform("uTexture", 0);
×
UNCOV
389
            graphics.SetUniform("uColor", material.BaseColorFactor);
×
390
            graphics.SetUniform("uEmissive", material.EmissiveFactor);
×
391

392
            // Draw instanced
UNCOV
393
            graphics.BindMesh(batch.Mesh);
×
UNCOV
394
            graphics.DrawMeshInstanced(batch.Mesh, batch.InstanceBuffer, batch.InstanceCount);
×
395
        }
396
    }
×
397

398
    /// <inheritdoc />
399
    public void Dispose()
400
    {
401
        // Clean up all GPU buffers
402
        if (graphics is not null)
1✔
403
        {
404
            foreach (var prepared in preparedBatches.Values)
1✔
405
            {
406
                graphics.DeleteInstanceBuffer(prepared.InstanceBuffer);
1✔
407
            }
408
        }
409

410
        preparedBatches.Clear();
1✔
411
        batchData.Clear();
1✔
412
        batchLayers.Clear();
1✔
413
        batchMaterials.Clear();
1✔
414
        batchHasMaterials.Clear();
1✔
415
    }
1✔
416
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc