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

orion-ecs / keen-eye / 20889572769

11 Jan 2026 04:37AM UTC coverage: 85.824% (-0.1%) from 85.962%
20889572769

push

github

tyevco
feat(graphics): Add LOD system for automatic mesh switching

Implement Level of Detail system that automatically switches mesh
complexity levels based on camera distance or projected screen size,
improving performance for complex scenes.

Components:
- LodGroup: Component with up to 4 inline LOD levels for cache efficiency
- LodLevel: Record struct with MeshId and Threshold
- LodSelectionMode: Distance-based or screen-size-based selection

System:
- LodSystem: Selects LOD levels based on camera distance/screen coverage
- Hysteresis support to prevent flickering at LOD boundaries
- Global and per-entity bias for quality tuning
- Supports both perspective and orthographic projections

Closes #900

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

9446 of 13199 branches covered (71.57%)

Branch coverage included in aggregate %.

604 of 609 new or added lines in 4 files covered. (99.18%)

2 existing lines in 1 file now uncovered.

163849 of 188721 relevant lines covered (86.82%)

0.99 hits per line

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

94.9
/src/KeenEyes.Graphics/Systems/LODSystem.cs
1
using System.Numerics;
2

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

6
namespace KeenEyes.Graphics;
7

8
/// <summary>
9
/// System that automatically selects mesh LOD levels based on camera distance or screen coverage.
10
/// </summary>
11
/// <remarks>
12
/// <para>
13
/// The LodSystem processes entities with <see cref="Transform3D"/>, <see cref="Renderable"/>,
14
/// and <see cref="LodGroup"/> components. For each entity, it calculates the distance from the
15
/// camera (or screen size) and selects the appropriate LOD level.
16
/// </para>
17
/// <para>
18
/// This system should run BEFORE <see cref="RenderSystem"/> so that mesh selection happens
19
/// prior to rendering. Register it in the PreUpdate or EarlyUpdate phase.
20
/// </para>
21
/// <para>
22
/// Hysteresis is applied to prevent flickering at LOD boundaries. When transitioning to a
23
/// lower detail level, the threshold is increased; when transitioning to higher detail,
24
/// it is decreased.
25
/// </para>
26
/// </remarks>
27
/// <example>
28
/// <code>
29
/// // Register LOD system before render system
30
/// world.AddSystem(new LodSystem { HysteresisFactor = 0.1f });
31
/// world.AddSystem(new RenderSystem());
32
/// </code>
33
/// </example>
34
public sealed class LodSystem : ISystem
35
{
36
    private IWorld? world;
37

38
    /// <inheritdoc />
39
    public bool Enabled { get; set; } = true;
1✔
40

41
    /// <summary>
42
    /// Gets or sets the hysteresis factor to prevent LOD flickering at boundaries.
43
    /// Default is 0.05 (5%).
44
    /// </summary>
45
    /// <remarks>
46
    /// When switching to lower detail: threshold * (1 + hysteresis).
47
    /// When switching to higher detail: threshold * (1 - hysteresis).
48
    /// </remarks>
49
    public float HysteresisFactor { get; set; } = 0.05f;
1✔
50

51
    /// <summary>
52
    /// Gets or sets the global bias applied to all LOD calculations.
53
    /// Positive values prefer higher detail, negative values prefer lower detail.
54
    /// </summary>
55
    /// <remarks>
56
    /// For distance mode: Subtracts from calculated distance.
57
    /// For screen-size mode: Multiplies the calculated screen size.
58
    /// </remarks>
59
    public float GlobalBias { get; set; } = 0f;
1✔
60

61
    /// <inheritdoc />
62
    public void Initialize(IWorld world)
63
    {
64
        this.world = world;
1✔
65
    }
1✔
66

67
    /// <inheritdoc />
68
    public void Update(float deltaTime)
69
    {
70
        if (world is null)
1✔
71
        {
NEW
72
            return;
×
73
        }
74

75
        // Find active camera
76
        Vector3 cameraPosition = Vector3.Zero;
1✔
77
        Camera camera = default;
1✔
78
        bool foundCamera = false;
1✔
79

80
        // Prefer main camera tag
81
        foreach (var entity in world.Query<Camera, Transform3D, MainCameraTag>())
1✔
82
        {
83
            camera = world.Get<Camera>(entity);
1✔
84
            cameraPosition = world.Get<Transform3D>(entity).Position;
1✔
85
            foundCamera = true;
1✔
86
            break;
1✔
87
        }
88

89
        // Fall back to any camera
90
        if (!foundCamera)
1✔
91
        {
92
            foreach (var entity in world.Query<Camera, Transform3D>())
1✔
93
            {
94
                camera = world.Get<Camera>(entity);
1✔
95
                cameraPosition = world.Get<Transform3D>(entity).Position;
1✔
96
                foundCamera = true;
1✔
97
                break;
1✔
98
            }
99
        }
100

101
        if (!foundCamera)
1✔
102
        {
103
            // No camera, can't calculate LOD
104
            return;
1✔
105
        }
106

107
        // Process all entities with LOD groups
108
        foreach (var entity in world.Query<Transform3D, Renderable, LodGroup>())
1✔
109
        {
110
            ref readonly var transform = ref world.Get<Transform3D>(entity);
1✔
111
            ref var renderable = ref world.Get<Renderable>(entity);
1✔
112
            ref var lodGroup = ref world.Get<LodGroup>(entity);
1✔
113

114
            int newLevel = CalculateLODLevel(
1✔
115
                ref lodGroup,
1✔
116
                transform.Position,
1✔
117
                cameraPosition,
1✔
118
                camera);
1✔
119

120
            // Update mesh if LOD level changed
121
            if (newLevel != lodGroup.CurrentLevel)
1✔
122
            {
123
                lodGroup.CurrentLevel = newLevel;
1✔
124
                renderable.MeshId = lodGroup.GetLevel(newLevel).MeshId;
1✔
125
            }
126
        }
127
    }
1✔
128

129
    /// <summary>
130
    /// Calculates the appropriate LOD level for an entity.
131
    /// </summary>
132
    private int CalculateLODLevel(
133
        ref LodGroup lodGroup,
134
        Vector3 entityPosition,
135
        Vector3 cameraPosition,
136
        Camera camera)
137
    {
138
        if (lodGroup.LevelCount <= 1)
1✔
139
        {
140
            return 0;
1✔
141
        }
142

143
        float metric;
144

145
        if (lodGroup.SelectionMode == LodSelectionMode.Distance)
1✔
146
        {
147
            // Calculate distance from camera to entity
148
            float distance = Vector3.Distance(cameraPosition, entityPosition);
1✔
149

150
            // Apply biases (global and per-entity)
151
            distance -= GlobalBias;
1✔
152
            distance -= lodGroup.Bias;
1✔
153
            distance = MathF.Max(0, distance);
1✔
154

155
            metric = distance;
1✔
156
        }
157
        else
158
        {
159
            // Screen-size mode: calculate projected size
160
            float distance = Vector3.Distance(cameraPosition, entityPosition);
1✔
161

162
            // Avoid division by zero for entities at camera position
163
            if (distance < 0.001f)
1✔
164
            {
NEW
165
                return 0;
×
166
            }
167

168
            float screenSize = CalculateScreenSize(
1✔
169
                lodGroup.BoundingSphereRadius,
1✔
170
                distance,
1✔
171
                camera);
1✔
172

173
            // Apply biases
174
            screenSize *= (1 + GlobalBias * 0.1f);
1✔
175
            screenSize *= (1 + lodGroup.Bias * 0.1f);
1✔
176

177
            metric = screenSize;
1✔
178
        }
179

180
        // Find appropriate LOD level
181
        int currentLevel = lodGroup.CurrentLevel;
1✔
182
        int newLevel = 0;
1✔
183

184
        for (int i = 0; i < lodGroup.LevelCount; i++)
1✔
185
        {
186
            var level = lodGroup.GetLevel(i);
1✔
187
            float threshold = level.Threshold;
1✔
188

189
            // Apply hysteresis based on transition direction
190
            if (lodGroup.SelectionMode == LodSelectionMode.Distance)
1✔
191
            {
192
                // Distance mode: lower index = higher detail = closer distance
193
                if (i > currentLevel)
1✔
194
                {
195
                    // Transitioning to lower detail: increase threshold
196
                    threshold *= (1 + HysteresisFactor);
1✔
197
                }
198
                else if (i < currentLevel)
1✔
199
                {
200
                    // Transitioning to higher detail: decrease threshold
NEW
201
                    threshold *= (1 - HysteresisFactor);
×
202
                }
203

204
                if (metric >= threshold)
1✔
205
                {
206
                    newLevel = i;
1✔
207
                }
208
            }
209
            else
210
            {
211
                // Screen-size mode: lower index = higher detail = larger screen size
212
                if (i > currentLevel)
1✔
213
                {
214
                    // Transitioning to lower detail: decrease threshold
215
                    threshold *= (1 - HysteresisFactor);
1✔
216
                }
217
                else if (i < currentLevel)
1✔
218
                {
219
                    // Transitioning to higher detail: increase threshold
NEW
220
                    threshold *= (1 + HysteresisFactor);
×
221
                }
222

223
                if (metric <= threshold)
1✔
224
                {
225
                    newLevel = i;
1✔
226
                }
227
            }
228
        }
229

230
        return newLevel;
1✔
231
    }
232

233
    /// <summary>
234
    /// Calculates the projected screen size of a bounding sphere.
235
    /// </summary>
236
    /// <param name="radius">The bounding sphere radius in world units.</param>
237
    /// <param name="distance">The distance from camera to the sphere center.</param>
238
    /// <param name="camera">The camera component.</param>
239
    /// <returns>The approximate screen coverage ratio (0-1).</returns>
240
    private static float CalculateScreenSize(float radius, float distance, Camera camera)
241
    {
242
        if (camera.Projection == ProjectionType.Perspective)
1✔
243
        {
244
            // Perspective projection: projected height = radius / (distance * tan(fov/2))
245
            float fovRadians = camera.FieldOfView * MathF.PI / 180f;
1✔
246
            float tanHalfFov = MathF.Tan(fovRadians * 0.5f);
1✔
247
            return radius / (distance * tanHalfFov);
1✔
248
        }
249
        else
250
        {
251
            // Orthographic projection: projected height = radius / orthographic size
NEW
252
            return radius / camera.OrthographicSize;
×
253
        }
254
    }
255

256
    /// <inheritdoc />
257
    public void Dispose()
258
    {
259
        // No resources to dispose
260
    }
1✔
261
}
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