@bornengine/engine 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/native/android/Cargo.lock +1848 -0
  4. package/native/android/Cargo.toml +24 -0
  5. package/native/android/src/lib.rs +702 -0
  6. package/native/ios/Cargo.lock +1690 -0
  7. package/native/ios/Cargo.toml +32 -0
  8. package/native/ios/src/lib.rs +1267 -0
  9. package/native/linux/Cargo.lock +3279 -0
  10. package/native/linux/Cargo.toml +29 -0
  11. package/native/linux/src/lib.rs +1331 -0
  12. package/native/macos/Cargo.lock +3310 -0
  13. package/native/macos/Cargo.toml +46 -0
  14. package/native/macos/src/lib.rs +1302 -0
  15. package/native/shared/Cargo.lock +1899 -0
  16. package/native/shared/Cargo.toml +62 -0
  17. package/native/shared/assets/default_font.ttf +0 -0
  18. package/native/shared/build.rs +270 -0
  19. package/native/shared/shaders/common/clouds.wgsl +122 -0
  20. package/native/shared/shaders/common/fog.wgsl +16 -0
  21. package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
  22. package/native/shared/shaders/common/imposter.wgsl +112 -0
  23. package/native/shared/shaders/common/pbr.wgsl +186 -0
  24. package/native/shared/shaders/common/shadows.wgsl +186 -0
  25. package/native/shared/shaders/common/sky.wgsl +8 -0
  26. package/native/shared/shaders/common/tonemap.wgsl +25 -0
  27. package/native/shared/shaders/impulse_field.wgsl +57 -0
  28. package/native/shared/shaders/material_abi.wgsl +383 -0
  29. package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
  30. package/native/shared/src/anim_mixer.rs +61 -0
  31. package/native/shared/src/attach.rs +263 -0
  32. package/native/shared/src/audio/decode.rs +123 -0
  33. package/native/shared/src/audio/mod.rs +863 -0
  34. package/native/shared/src/audio/render.rs +892 -0
  35. package/native/shared/src/audio/spsc.rs +156 -0
  36. package/native/shared/src/audio/stream.rs +226 -0
  37. package/native/shared/src/custom_shaders.rs +104 -0
  38. package/native/shared/src/decals.rs +245 -0
  39. package/native/shared/src/drs.rs +211 -0
  40. package/native/shared/src/engine.rs +261 -0
  41. package/native/shared/src/ffi.rs +116 -0
  42. package/native/shared/src/ffi_core/assets.rs +388 -0
  43. package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
  44. package/native/shared/src/ffi_core/draw.rs +334 -0
  45. package/native/shared/src/ffi_core/game_loop.rs +577 -0
  46. package/native/shared/src/ffi_core/input.rs +234 -0
  47. package/native/shared/src/ffi_core/mod.rs +127 -0
  48. package/native/shared/src/ffi_core/models.rs +1154 -0
  49. package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
  50. package/native/shared/src/ffi_core/scene.rs +626 -0
  51. package/native/shared/src/ffi_core/vfx.rs +212 -0
  52. package/native/shared/src/ffi_core/visual.rs +691 -0
  53. package/native/shared/src/frame_callbacks.rs +122 -0
  54. package/native/shared/src/geometry.rs +236 -0
  55. package/native/shared/src/handles.rs +182 -0
  56. package/native/shared/src/input.rs +448 -0
  57. package/native/shared/src/jolt_sys.rs +822 -0
  58. package/native/shared/src/lib.rs +55 -0
  59. package/native/shared/src/models.rs +1093 -0
  60. package/native/shared/src/models_gltf.rs +1280 -0
  61. package/native/shared/src/particles.rs +391 -0
  62. package/native/shared/src/physics_jolt.rs +1908 -0
  63. package/native/shared/src/picking.rs +298 -0
  64. package/native/shared/src/postfx.rs +345 -0
  65. package/native/shared/src/profiler.rs +492 -0
  66. package/native/shared/src/ragdoll.rs +474 -0
  67. package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
  68. package/native/shared/src/renderer/brdf_lut.rs +154 -0
  69. package/native/shared/src/renderer/draw2d.rs +143 -0
  70. package/native/shared/src/renderer/formats.rs +822 -0
  71. package/native/shared/src/renderer/froxel.rs +421 -0
  72. package/native/shared/src/renderer/gi_bake.rs +653 -0
  73. package/native/shared/src/renderer/graph.rs +462 -0
  74. package/native/shared/src/renderer/hiz.rs +269 -0
  75. package/native/shared/src/renderer/hot_reload.rs +390 -0
  76. package/native/shared/src/renderer/impulse_field.rs +456 -0
  77. package/native/shared/src/renderer/lighting.rs +154 -0
  78. package/native/shared/src/renderer/material_instancing.rs +171 -0
  79. package/native/shared/src/renderer/material_pipeline.rs +700 -0
  80. package/native/shared/src/renderer/material_system.rs +1996 -0
  81. package/native/shared/src/renderer/material_system_tests.rs +601 -0
  82. package/native/shared/src/renderer/material_system_wasm.rs +41 -0
  83. package/native/shared/src/renderer/mod.rs +12556 -0
  84. package/native/shared/src/renderer/model_draw.rs +641 -0
  85. package/native/shared/src/renderer/occlusion.rs +429 -0
  86. package/native/shared/src/renderer/planar_pass.rs +593 -0
  87. package/native/shared/src/renderer/planar_reflection.rs +499 -0
  88. package/native/shared/src/renderer/post_pass.rs +249 -0
  89. package/native/shared/src/renderer/postfx_chain.rs +728 -0
  90. package/native/shared/src/renderer/pt_pass.rs +577 -0
  91. package/native/shared/src/renderer/scene_pass.rs +607 -0
  92. package/native/shared/src/renderer/shader_include.rs +205 -0
  93. package/native/shared/src/renderer/shader_library.rs +135 -0
  94. package/native/shared/src/renderer/shaders/ao.rs +570 -0
  95. package/native/shared/src/renderer/shaders/core.rs +1243 -0
  96. package/native/shared/src/renderer/shaders/env.rs +907 -0
  97. package/native/shared/src/renderer/shaders/gi.rs +810 -0
  98. package/native/shared/src/renderer/shaders/mod.rs +19 -0
  99. package/native/shared/src/renderer/shaders/post.rs +1558 -0
  100. package/native/shared/src/renderer/shaders/pt.rs +1859 -0
  101. package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
  102. package/native/shared/src/renderer/shadow_pass.rs +731 -0
  103. package/native/shared/src/renderer/ssgi_pass.rs +392 -0
  104. package/native/shared/src/renderer/ssr_pass.rs +188 -0
  105. package/native/shared/src/renderer/texture_store.rs +473 -0
  106. package/native/shared/src/renderer/transient.rs +591 -0
  107. package/native/shared/src/renderer/types.rs +941 -0
  108. package/native/shared/src/renderer/util.rs +152 -0
  109. package/native/shared/src/scene.rs +1362 -0
  110. package/native/shared/src/sdf_cache.rs +274 -0
  111. package/native/shared/src/shadows.rs +1036 -0
  112. package/native/shared/src/staging.rs +102 -0
  113. package/native/shared/src/string_header.rs +266 -0
  114. package/native/shared/src/text_renderer.rs +502 -0
  115. package/native/shared/src/textures.rs +197 -0
  116. package/native/tvos/Cargo.lock +1693 -0
  117. package/native/tvos/Cargo.toml +36 -0
  118. package/native/tvos/metal-patched/Cargo.toml +178 -0
  119. package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
  120. package/native/tvos/metal-patched/LICENSE-MIT +25 -0
  121. package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
  122. package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
  123. package/native/tvos/metal-patched/src/argument.rs +366 -0
  124. package/native/tvos/metal-patched/src/blitpass.rs +102 -0
  125. package/native/tvos/metal-patched/src/buffer.rs +71 -0
  126. package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
  127. package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
  128. package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
  129. package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
  130. package/native/tvos/metal-patched/src/computepass.rs +107 -0
  131. package/native/tvos/metal-patched/src/constants.rs +152 -0
  132. package/native/tvos/metal-patched/src/counters.rs +119 -0
  133. package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
  134. package/native/tvos/metal-patched/src/device.rs +2134 -0
  135. package/native/tvos/metal-patched/src/drawable.rs +39 -0
  136. package/native/tvos/metal-patched/src/encoder.rs +2041 -0
  137. package/native/tvos/metal-patched/src/heap.rs +281 -0
  138. package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
  139. package/native/tvos/metal-patched/src/lib.rs +657 -0
  140. package/native/tvos/metal-patched/src/library.rs +902 -0
  141. package/native/tvos/metal-patched/src/mps.rs +575 -0
  142. package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
  143. package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
  144. package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
  145. package/native/tvos/metal-patched/src/renderpass.rs +443 -0
  146. package/native/tvos/metal-patched/src/resource.rs +182 -0
  147. package/native/tvos/metal-patched/src/sampler.rs +165 -0
  148. package/native/tvos/metal-patched/src/sync.rs +178 -0
  149. package/native/tvos/metal-patched/src/texture.rs +352 -0
  150. package/native/tvos/metal-patched/src/types.rs +90 -0
  151. package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
  152. package/native/tvos/src/audio_backend.rs +197 -0
  153. package/native/tvos/src/lib.rs +1891 -0
  154. package/native/visionos/Cargo.lock +1693 -0
  155. package/native/visionos/Cargo.toml +40 -0
  156. package/native/visionos/src/audio_backend.rs +197 -0
  157. package/native/visionos/src/lib.rs +1887 -0
  158. package/native/watchos/Cargo.lock +16 -0
  159. package/native/watchos/Cargo.toml +19 -0
  160. package/native/watchos/shaders/bloom_postfx.metal +99 -0
  161. package/native/watchos/src/BloomWatchApp.swift +1267 -0
  162. package/native/watchos/src/BloomWatchAudio.swift +179 -0
  163. package/native/watchos/src/audio.rs +55 -0
  164. package/native/watchos/src/draw_list.rs +229 -0
  165. package/native/watchos/src/ffi_stubs.rs +915 -0
  166. package/native/watchos/src/ffi_stubs_manual.rs +35 -0
  167. package/native/watchos/src/lib.rs +1124 -0
  168. package/native/watchos/src/models.rs +746 -0
  169. package/native/watchos/src/postfx.rs +95 -0
  170. package/native/watchos/src/scene.rs +534 -0
  171. package/native/watchos/src/textures.rs +184 -0
  172. package/native/web/Cargo.lock +1657 -0
  173. package/native/web/Cargo.toml +43 -0
  174. package/native/web/bloom_glue.js +695 -0
  175. package/native/web/build.sh +131 -0
  176. package/native/web/index.html +35 -0
  177. package/native/web/jolt_bridge.js +1519 -0
  178. package/native/web/src/input_ffi.rs +286 -0
  179. package/native/web/src/lib.rs +1796 -0
  180. package/native/web/src/material_ffi.rs +710 -0
  181. package/native/web/src/parity_ffi.rs +343 -0
  182. package/native/web/src/physics_ffi.rs +643 -0
  183. package/native/web/src/ragdoll_ffi.rs +250 -0
  184. package/native/web/src/render_settings.rs +98 -0
  185. package/native/windows/Cargo.lock +1815 -0
  186. package/native/windows/Cargo.toml +68 -0
  187. package/native/windows/src/lib.rs +1486 -0
  188. package/package.json +4279 -0
  189. package/src/audio/index.ts +315 -0
  190. package/src/core/colors.ts +63 -0
  191. package/src/core/index.ts +1206 -0
  192. package/src/core/keys.ts +63 -0
  193. package/src/core/types.ts +104 -0
  194. package/src/index.ts +171 -0
  195. package/src/math/index.ts +516 -0
  196. package/src/mobile/index.ts +294 -0
  197. package/src/models/index.ts +1258 -0
  198. package/src/physics/index.ts +1134 -0
  199. package/src/scene/index.ts +698 -0
  200. package/src/shapes/index.ts +120 -0
  201. package/src/text/index.ts +48 -0
  202. package/src/textures/index.ts +187 -0
  203. package/src/vfx/index.ts +191 -0
  204. package/src/world/index.ts +24 -0
  205. package/src/world/loader.ts +423 -0
  206. package/src/world/prefab.ts +217 -0
  207. package/src/world/render.ts +172 -0
  208. package/src/world/saver.ts +108 -0
  209. package/src/world/serialize.ts +301 -0
  210. package/src/world/terrain.ts +355 -0
  211. package/src/world/types.ts +160 -0
  212. package/src/world/validate.ts +319 -0
  213. package/src/world/version.ts +114 -0
@@ -0,0 +1,245 @@
1
+ //! EN-027 — surface decals (bullet holes, scorch, blood splats).
2
+ //!
3
+ //! Textbook AAA decals are a deferred pass: project a box, read depth,
4
+ //! rewrite the G-buffer before lighting. That is the right long-term shape and
5
+ //! it is what a future revision should do. It is also a new pass wired into a
6
+ //! hand-ordered 559 KB frame, for a feature whose entire visible job here is
7
+ //! "the wall remembers the bullet".
8
+ //!
9
+ //! So this version is a *sticker*: an oriented quad, pushed a couple of
10
+ //! millimetres along the surface normal, drawn through the existing instanced
11
+ //! cutout material path. It depth-tests against the world, receives no
12
+ //! lighting of its own (the material multiplies the surface's own shading), and
13
+ //! costs one draw call for the whole ring. It cannot wrap around a corner —
14
+ //! that is the honest limitation, and for bullet holes on flat stone and blood
15
+ //! on flat ground nobody will ever see it.
16
+ //!
17
+ //! Packing note: the instance stride only carries a single Y rotation, but a
18
+ //! decal needs an arbitrary orientation. A unit normal is two angles, so it
19
+ //! fits in the two spare `extra` slots, leaving `rot_y` free to mean roll
20
+ //! about the normal and `extra.x` to carry the atlas frame.
21
+
22
+ /// A decal in flight. Fades out over the last `fade` seconds of its life so it
23
+ /// does not pop.
24
+ struct Decal {
25
+ pos: [f32; 3],
26
+ /// Surface normal, stored as azimuth/elevation to fit the instance stride.
27
+ az: f32,
28
+ el: f32,
29
+ roll: f32,
30
+ size: f32,
31
+ color: [f32; 4],
32
+ frame: f32,
33
+ age: f32,
34
+ life: f32,
35
+ fade: f32,
36
+ }
37
+
38
+ /// Look + lifetime for the *next* spawns. Set once per decal type (bullet
39
+ /// hole, scorch, blood) rather than passed on every hit — a spawn is then 8
40
+ /// f64 args, which is exactly the ARM64 register ceiling the FFI has to
41
+ /// respect.
42
+ #[derive(Clone, Copy)]
43
+ pub struct DecalStyle {
44
+ pub frame: f32,
45
+ pub color: [f32; 4],
46
+ pub life: f32,
47
+ pub fade: f32,
48
+ }
49
+
50
+ impl Default for DecalStyle {
51
+ fn default() -> Self {
52
+ Self { frame: 0.0, color: [1.0; 4], life: 0.0, fade: 0.0 }
53
+ }
54
+ }
55
+
56
+ pub struct DecalManager {
57
+ decals: Vec<Decal>,
58
+ capacity: usize,
59
+ /// Ring cursor: once full, the oldest decal is the one overwritten.
60
+ next: usize,
61
+ pub instance_buffer: u32,
62
+ pub style: DecalStyle,
63
+ packed: Vec<f32>,
64
+ pub live: u32,
65
+ }
66
+
67
+ impl DecalManager {
68
+ pub fn new() -> Self {
69
+ Self {
70
+ decals: Vec::new(),
71
+ capacity: 0,
72
+ next: 0,
73
+ instance_buffer: 0,
74
+ style: DecalStyle::default(),
75
+ packed: Vec::new(),
76
+ live: 0,
77
+ }
78
+ }
79
+
80
+ pub fn init(&mut self, capacity: usize, instance_buffer: u32) {
81
+ self.capacity = capacity;
82
+ self.instance_buffer = instance_buffer;
83
+ self.decals = Vec::with_capacity(capacity);
84
+ self.packed = vec![0.0; capacity * 12];
85
+ self.next = 0;
86
+ self.live = 0;
87
+ }
88
+
89
+ /// Place a decal using the current `style`. `n` is the surface normal (need
90
+ /// not be normalized).
91
+ pub fn spawn_styled(&mut self, pos: [f32; 3], n: [f32; 3], size: f32, roll: f32) {
92
+ let st = self.style;
93
+ self.spawn(pos, n, size, roll, st.frame, st.color, st.life, st.fade);
94
+ }
95
+
96
+ /// Place a decal. `n` is the surface normal (need not be normalized);
97
+ /// `life <= 0` means permanent (well — until the ring wraps).
98
+ #[allow(clippy::too_many_arguments)]
99
+ pub fn spawn(
100
+ &mut self,
101
+ pos: [f32; 3],
102
+ n: [f32; 3],
103
+ size: f32,
104
+ roll: f32,
105
+ frame: f32,
106
+ color: [f32; 4],
107
+ life: f32,
108
+ fade: f32,
109
+ ) {
110
+ if self.capacity == 0 { return; }
111
+ let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
112
+ let nn = if len > 1e-5 { [n[0] / len, n[1] / len, n[2] / len] } else { [0.0, 1.0, 0.0] };
113
+ // Spherical encode. el is the angle off +Y; az is the heading in XZ.
114
+ let el = nn[1].clamp(-1.0, 1.0).acos();
115
+ let az = nn[2].atan2(nn[0]);
116
+
117
+ let d = Decal {
118
+ // Lift off the surface: z-fighting on a coplanar quad is guaranteed
119
+ // otherwise, and 2 mm is under the depth precision of anything the
120
+ // player can stand close enough to notice.
121
+ pos: [
122
+ pos[0] + nn[0] * 0.002,
123
+ pos[1] + nn[1] * 0.002,
124
+ pos[2] + nn[2] * 0.002,
125
+ ],
126
+ az, el, roll,
127
+ size,
128
+ color,
129
+ frame,
130
+ age: 0.0,
131
+ life: if life <= 0.0 { f32::MAX } else { life },
132
+ fade: fade.max(0.0),
133
+ };
134
+
135
+ if self.decals.len() < self.capacity {
136
+ self.decals.push(d);
137
+ } else {
138
+ self.decals[self.next] = d;
139
+ self.next = (self.next + 1) % self.capacity;
140
+ }
141
+ }
142
+
143
+ /// Age everything, drop the expired, repack. Returns the live count.
144
+ pub fn update(&mut self, dt: f32) -> u32 {
145
+ let mut i = 0usize;
146
+ while i < self.decals.len() {
147
+ self.decals[i].age += dt;
148
+ if self.decals[i].age >= self.decals[i].life {
149
+ self.decals.swap_remove(i);
150
+ // The ring cursor indexes into a Vec that just shrank; clamp it
151
+ // or the next spawn writes out of bounds.
152
+ if self.next >= self.decals.len().max(1) { self.next = 0; }
153
+ continue;
154
+ }
155
+ i += 1;
156
+ }
157
+
158
+ for (i, d) in self.decals.iter().enumerate() {
159
+ // Fade only over the tail of the lifetime.
160
+ let alpha = if d.fade > 0.0 && d.life != f32::MAX {
161
+ let remaining = d.life - d.age;
162
+ (remaining / d.fade).clamp(0.0, 1.0)
163
+ } else { 1.0 };
164
+ let o = i * 12;
165
+ self.packed[o] = d.pos[0];
166
+ self.packed[o + 1] = d.pos[1];
167
+ self.packed[o + 2] = d.pos[2];
168
+ self.packed[o + 3] = d.roll;
169
+ self.packed[o + 4] = d.size;
170
+ self.packed[o + 5] = d.color[0];
171
+ self.packed[o + 6] = d.color[1];
172
+ self.packed[o + 7] = d.color[2];
173
+ self.packed[o + 8] = d.color[3] * alpha;
174
+ self.packed[o + 9] = d.frame; // extra.x
175
+ self.packed[o + 10] = d.az; // extra.y
176
+ self.packed[o + 11] = d.el; // extra.z
177
+ }
178
+ self.live = self.decals.len() as u32;
179
+ self.live
180
+ }
181
+
182
+ pub fn packed(&self) -> &[f32] { &self.packed }
183
+
184
+ pub fn clear(&mut self) {
185
+ self.decals.clear();
186
+ self.next = 0;
187
+ self.live = 0;
188
+ }
189
+ }
190
+
191
+ impl Default for DecalManager {
192
+ fn default() -> Self { Self::new() }
193
+ }
194
+
195
+ #[cfg(test)]
196
+ mod tests {
197
+ use super::*;
198
+
199
+ #[test]
200
+ fn ring_wraps_without_growing() {
201
+ let mut m = DecalManager::new();
202
+ m.init(4, 1);
203
+ for i in 0..10 {
204
+ m.spawn([i as f32, 0.0, 0.0], [0.0, 1.0, 0.0], 0.2, 0.0, 0.0, [1.0; 4], 0.0, 0.0);
205
+ }
206
+ assert_eq!(m.update(0.016), 4);
207
+ }
208
+
209
+ #[test]
210
+ fn expired_decals_are_reclaimed() {
211
+ let mut m = DecalManager::new();
212
+ m.init(8, 1);
213
+ m.spawn([0.0; 3], [0.0, 1.0, 0.0], 0.2, 0.0, 0.0, [1.0; 4], 1.0, 0.2);
214
+ assert_eq!(m.update(0.5), 1);
215
+ assert_eq!(m.update(0.6), 0);
216
+ }
217
+
218
+ #[test]
219
+ fn permanent_decals_survive() {
220
+ let mut m = DecalManager::new();
221
+ m.init(8, 1);
222
+ m.spawn([0.0; 3], [0.0, 1.0, 0.0], 0.2, 0.0, 0.0, [1.0; 4], 0.0, 0.0);
223
+ for _ in 0..100 { m.update(1.0); }
224
+ assert_eq!(m.live, 1);
225
+ }
226
+
227
+ /// The normal must survive the spherical round-trip, or every decal on a
228
+ /// wall silently lies flat on the floor.
229
+ #[test]
230
+ fn normal_encoding_round_trips() {
231
+ let mut m = DecalManager::new();
232
+ m.init(4, 1);
233
+ let n = [0.0f32, 0.0, 1.0];
234
+ m.spawn([0.0; 3], n, 1.0, 0.0, 0.0, [1.0; 4], 0.0, 0.0);
235
+ m.update(0.0);
236
+ let az = m.packed[10];
237
+ let el = m.packed[11];
238
+ let rx = el.sin() * az.cos();
239
+ let ry = el.cos();
240
+ let rz = el.sin() * az.sin();
241
+ assert!((rx - n[0]).abs() < 1e-4, "x {rx}");
242
+ assert!((ry - n[1]).abs() < 1e-4, "y {ry}");
243
+ assert!((rz - n[2]).abs() < 1e-4, "z {rz}");
244
+ }
245
+ }
@@ -0,0 +1,211 @@
1
+ //! Dynamic resolution scaling (DRS) — self-tunes the renderer's
2
+ //! `render_scale` to track a target framerate. Lives outside the
3
+ //! renderer so it can read frame-time from the engine without
4
+ //! threading a timing source down.
5
+ //!
6
+ //! Algorithm sketch:
7
+ //! - Smooth frame time with an EMA (~10-frame window).
8
+ //! - After a cooldown, step `render_scale` down one rung when EMA
9
+ //! exceeds target × 1.10, up one rung when EMA falls below
10
+ //! target × 0.80. Asymmetric thresholds — humans tolerate blur
11
+ //! better than stutter, so we drop quickly and recover slowly.
12
+ //! - 6-rung ladder spaced ~√2 apart: 0.50 → 0.58 → 0.67 → 0.75 →
13
+ //! 0.85 → 1.00. Keeps step size proportional to fragment cost.
14
+ //! - Cooldown of 30 frames between any two steps prevents thrashing.
15
+
16
+ use crate::renderer::Renderer;
17
+
18
+ /// Discrete render-scale rungs DRS can choose from. Spaced so each
19
+ /// step is roughly a √2 change in fragment-shader cost.
20
+ const STEPS: &[f32] = &[0.50, 0.58, 0.67, 0.75, 0.85, 1.00];
21
+
22
+ /// EMA smoothing factor — α=0.1 ≈ 10-frame effective window.
23
+ /// Responsive enough to catch a sustained spike, slow enough to
24
+ /// ignore single-frame outliers.
25
+ const EMA_ALPHA: f32 = 0.1;
26
+
27
+ /// Frames between any two scale steps. At 60 fps this is 0.5 s,
28
+ /// long enough for the EMA to reflect the post-step frame time.
29
+ const COOLDOWN_FRAMES: u32 = 30;
30
+
31
+ /// Step *down* (cheaper) when EMA exceeds target × this.
32
+ const HYSTERESIS_DOWN: f32 = 1.10;
33
+ /// Step *up* (sharper) only when EMA falls below target × this.
34
+ /// Tighter than HYSTERESIS_DOWN — blur is preferable to stutter.
35
+ const HYSTERESIS_UP: f32 = 0.80;
36
+
37
+ pub struct DrsController {
38
+ pub enabled: bool,
39
+ /// Per-frame target in milliseconds (1000 / target_hz).
40
+ pub target_frame_ms: f32,
41
+ ema_ms: f32,
42
+ frames_since_step: u32,
43
+ /// Index into `STEPS`. Starts at the top so DRS only ever cuts
44
+ /// from the user's chosen baseline downward — once enabled it
45
+ /// will climb back as headroom appears.
46
+ idx: usize,
47
+ }
48
+
49
+ impl DrsController {
50
+ pub fn new() -> Self {
51
+ Self {
52
+ enabled: false,
53
+ target_frame_ms: 1000.0 / 60.0,
54
+ ema_ms: 0.0,
55
+ frames_since_step: 0,
56
+ idx: STEPS.len() - 1, // 1.0 — start at native, step down as needed
57
+ }
58
+ }
59
+
60
+ /// Enable DRS targeting the given refresh rate in Hz. Snaps the
61
+ /// internal index to whichever rung is closest to the renderer's
62
+ /// current `render_scale` so the first step doesn't overshoot.
63
+ pub fn enable(&mut self, target_hz: f32, current_scale: f32) {
64
+ self.enabled = true;
65
+ self.target_frame_ms = 1000.0 / target_hz.max(1.0);
66
+ self.ema_ms = 0.0;
67
+ self.frames_since_step = 0;
68
+ self.idx = closest_step_idx(current_scale);
69
+ }
70
+
71
+ pub fn disable(&mut self) {
72
+ self.enabled = false;
73
+ }
74
+
75
+ /// Called once per frame from `EngineState::begin_frame` after
76
+ /// `delta_time` is updated. `dt_seconds` is wall-clock frame time.
77
+ pub fn tick(&mut self, dt_seconds: f64, renderer: &mut Renderer) {
78
+ if !self.enabled {
79
+ return;
80
+ }
81
+
82
+ let frame_ms = (dt_seconds * 1000.0) as f32;
83
+ // EMA bootstrap — first frame after enable, seed the buffer
84
+ // with the observed value so we don't spend 10 frames climbing
85
+ // from 0 toward the steady state.
86
+ if self.ema_ms == 0.0 {
87
+ self.ema_ms = frame_ms;
88
+ } else {
89
+ self.ema_ms += EMA_ALPHA * (frame_ms - self.ema_ms);
90
+ }
91
+
92
+ self.frames_since_step = self.frames_since_step.saturating_add(1);
93
+ if self.frames_since_step < COOLDOWN_FRAMES {
94
+ return;
95
+ }
96
+
97
+ let target = self.target_frame_ms;
98
+ if self.ema_ms > target * HYSTERESIS_DOWN && self.idx > 0 {
99
+ self.idx -= 1;
100
+ renderer.set_render_scale(STEPS[self.idx]);
101
+ self.frames_since_step = 0;
102
+ } else if self.ema_ms < target * HYSTERESIS_UP && self.idx + 1 < STEPS.len() {
103
+ self.idx += 1;
104
+ renderer.set_render_scale(STEPS[self.idx]);
105
+ self.frames_since_step = 0;
106
+ }
107
+ }
108
+
109
+ pub fn current_scale(&self) -> f32 { STEPS[self.idx] }
110
+ }
111
+
112
+ impl Default for DrsController {
113
+ fn default() -> Self { Self::new() }
114
+ }
115
+
116
+ fn closest_step_idx(scale: f32) -> usize {
117
+ let mut best = 0usize;
118
+ let mut best_d = (STEPS[0] - scale).abs();
119
+ for (i, s) in STEPS.iter().enumerate().skip(1) {
120
+ let d = (s - scale).abs();
121
+ if d < best_d {
122
+ best_d = d;
123
+ best = i;
124
+ }
125
+ }
126
+ best
127
+ }
128
+
129
+ #[cfg(test)]
130
+ mod tests {
131
+ use super::*;
132
+
133
+ #[test]
134
+ fn closest_step_idx_picks_nearest_rung() {
135
+ // STEPS = [0.50, 0.58, 0.67, 0.75, 0.85, 1.00]
136
+ assert_eq!(closest_step_idx(0.50), 0);
137
+ assert_eq!(closest_step_idx(0.53), 0); // closer to 0.50 (Δ0.03) than 0.58 (Δ0.05)
138
+ assert_eq!(closest_step_idx(0.55), 1); // closer to 0.58 (Δ0.03) than 0.50 (Δ0.05)
139
+ assert_eq!(closest_step_idx(0.70), 2); // closer to 0.67 (Δ0.03) than 0.75 (Δ0.05)
140
+ assert_eq!(closest_step_idx(1.00), 5);
141
+ assert_eq!(closest_step_idx(2.00), 5); // clamped at top
142
+ assert_eq!(closest_step_idx(0.0), 0); // clamped at bottom
143
+ }
144
+
145
+ #[test]
146
+ fn disabled_controller_never_steps() {
147
+ let mut drs = DrsController::new();
148
+ // Don't enable. Even with a huge frame time the controller
149
+ // should be a no-op (no renderer reference passed: skip via
150
+ // early return — exercised by reading enabled state).
151
+ for _ in 0..100 {
152
+ // Mimic: tick() early-returns when !enabled. We test by
153
+ // confirming idx hasn't moved off its initial top rung.
154
+ assert!(!drs.enabled);
155
+ }
156
+ assert_eq!(drs.idx, STEPS.len() - 1);
157
+ }
158
+
159
+ #[test]
160
+ fn enable_seeds_index_to_nearest_rung() {
161
+ let mut drs = DrsController::new();
162
+ drs.enable(60.0, 0.75);
163
+ assert_eq!(drs.idx, 3); // 0.75 is rung 3
164
+ assert_eq!(drs.current_scale(), 0.75);
165
+ assert!(drs.enabled);
166
+ assert!((drs.target_frame_ms - 1000.0 / 60.0).abs() < 0.001);
167
+ }
168
+
169
+ /// Synthetic frame-time trace: induce a 33 ms spike on a 60 fps
170
+ /// (16.67 ms) target and confirm the controller steps down,
171
+ /// then steps back up when frame time recovers.
172
+ #[test]
173
+ fn step_down_on_spike_recover_on_idle() {
174
+ let mut drs = DrsController::new();
175
+ drs.enable(60.0, 1.0);
176
+ let start_idx = drs.idx;
177
+
178
+ // Simulate the EMA + cooldown logic *without* a renderer by
179
+ // inlining the same updates against a local idx tracker.
180
+ let target = drs.target_frame_ms;
181
+ let mut idx = drs.idx;
182
+ let mut ema = 0.0f32;
183
+ let mut cooldown = 0u32;
184
+
185
+ let advance = |ms: f32, ema: &mut f32, idx: &mut usize, cooldown: &mut u32| {
186
+ if *ema == 0.0 { *ema = ms; } else { *ema += EMA_ALPHA * (ms - *ema); }
187
+ *cooldown = cooldown.saturating_add(1);
188
+ if *cooldown < COOLDOWN_FRAMES { return; }
189
+ if *ema > target * HYSTERESIS_DOWN && *idx > 0 {
190
+ *idx -= 1; *cooldown = 0;
191
+ } else if *ema < target * HYSTERESIS_UP && *idx + 1 < STEPS.len() {
192
+ *idx += 1; *cooldown = 0;
193
+ }
194
+ };
195
+
196
+ // 200 frames at 33ms (double target) should drop the rung
197
+ // at least once.
198
+ for _ in 0..200 {
199
+ advance(33.0, &mut ema, &mut idx, &mut cooldown);
200
+ }
201
+ assert!(idx < start_idx, "expected step-down, idx={idx}");
202
+
203
+ // 400 frames at 8ms (well under HYSTERESIS_UP * target = 13.3ms)
204
+ // should recover at least one rung.
205
+ let after_spike = idx;
206
+ for _ in 0..400 {
207
+ advance(8.0, &mut ema, &mut idx, &mut cooldown);
208
+ }
209
+ assert!(idx > after_spike, "expected step-up, idx={idx}");
210
+ }
211
+ }