@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,1362 @@
1
+ //! Retained scene graph for Bloom Engine.
2
+ //!
3
+ //! Unlike immediate-mode drawing (drawCube, drawModel), the scene graph holds
4
+ //! persistent meshes that survive across frames. Systems update geometry and
5
+ //! transforms; the renderer draws all visible nodes each frame automatically.
6
+
7
+ use wgpu::util::DeviceExt;
8
+ use crate::handles::HandleRegistry;
9
+ use crate::renderer::Vertex3D;
10
+
11
+ // ============================================================
12
+ // PBR Material
13
+ // ============================================================
14
+
15
+ #[derive(Clone, Debug)]
16
+ pub struct PbrMaterial {
17
+ pub color: [f32; 3],
18
+ pub roughness: f32,
19
+ pub metalness: f32,
20
+ pub opacity: f32,
21
+ pub emissive: [f32; 3],
22
+ pub double_sided: bool,
23
+ /// glTF MASK alpha cutoff. 0.0 = OPAQUE. Non-zero routes the node
24
+ /// through the scene shader's alpha-cutout path (discard below the
25
+ /// cutoff, two-sided foliage shading, wind sway).
26
+ pub alpha_cutoff: f32,
27
+ pub texture_idx: u32,
28
+ /// Normal-map texture. 0 means "no normal map" — scene shader falls
29
+ /// back to the geometric normal. Stored as a texture index rather
30
+ /// than bind group so the renderer can build per-material bind
31
+ /// groups lazily without SceneGraph holding GPU references.
32
+ pub normal_texture_idx: u32,
33
+ pub metallic_roughness_texture_idx: u32,
34
+ pub emissive_texture_idx: u32,
35
+ pub occlusion_texture_idx: u32,
36
+ }
37
+
38
+ impl Default for PbrMaterial {
39
+ fn default() -> Self {
40
+ Self {
41
+ color: [1.0, 1.0, 1.0],
42
+ roughness: 0.8,
43
+ metalness: 0.0,
44
+ opacity: 1.0,
45
+ emissive: [0.0, 0.0, 0.0],
46
+ double_sided: false,
47
+ alpha_cutoff: 0.0,
48
+ texture_idx: 0,
49
+ normal_texture_idx: 0,
50
+ metallic_roughness_texture_idx: 0,
51
+ emissive_texture_idx: 0,
52
+ occlusion_texture_idx: 0,
53
+ }
54
+ }
55
+ }
56
+
57
+ // ============================================================
58
+ // Scene Node Uniforms (matches Uniforms3D in renderer)
59
+ // ============================================================
60
+
61
+ #[repr(C)]
62
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
63
+ struct NodeUniforms {
64
+ mvp: [[f32; 4]; 4],
65
+ model: [[f32; 4]; 4],
66
+ prev_mvp: [[f32; 4]; 4],
67
+ model_tint: [f32; 4],
68
+ /// Mirrors Uniforms3D.misc (joint offset + skinned flag for cached
69
+ /// skinned model draws). Scene nodes are never skinned — always zero
70
+ /// here — but the field must exist so the buffer binding satisfies
71
+ /// the scene shader's grown struct size.
72
+ misc: [f32; 4],
73
+ }
74
+
75
+ // ============================================================
76
+ // Scene Node
77
+ // ============================================================
78
+
79
+ pub struct SceneNode {
80
+ // Geometry (CPU-side, updated by systems)
81
+ pub vertices: Vec<Vertex3D>,
82
+ pub indices: Vec<u32>,
83
+ // Material
84
+ pub material: PbrMaterial,
85
+ // Transform
86
+ pub transform: [[f32; 4]; 4],
87
+ /// Previous frame's world transform — used to compute per-mesh
88
+ /// screen-space velocity for motion blur and TAA reprojection.
89
+ pub prev_transform: [[f32; 4]; 4],
90
+ // Flags
91
+ pub visible: bool,
92
+ pub cast_shadow: bool,
93
+ pub receive_shadow: bool,
94
+ /// GI-proxy flag: the node feeds every global-illumination input
95
+ /// (BLAS/TLAS, mesh cards, SDF clipmap, world triangles) but is
96
+ /// skipped by the raster consumers — main scene render, planar
97
+ /// reflections, and the sun-shadow pass. Games whose world renders
98
+ /// through the material system (no scene nodes) register invisible
99
+ /// duplicates of their big static geometry with this flag so SSGI
100
+ /// picks up off-screen bounce from walls/terrain the screen-space
101
+ /// trace can't see. Keep `visible = true` on such nodes — the GI
102
+ /// feeds all filter on `visible`.
103
+ pub gi_only: bool,
104
+ pub parent: f64,
105
+ // Editor user data — an arbitrary i64 attached to the node. The editor
106
+ // uses this to store the entity id directly on the scene node so picking
107
+ // can return the entity id without a handle → id map lookup (Q7).
108
+ pub user_data: i64,
109
+ // Cached world-space AABB, recomputed when geometry changes (Q5).
110
+ pub bounds_min: [f32; 3],
111
+ pub bounds_max: [f32; 3],
112
+ /// World-space AABB cached each frame by `prepare()` — the result of
113
+ /// transforming the local `bounds_min`/`bounds_max` box by the node
114
+ /// transform. Consumed by the shadow pass for per-cascade frustum
115
+ /// culling so it doesn't repeat the 8-corner transform. Sentinel
116
+ /// `world_bounds_min[0] > world_bounds_max[0]` means "not yet valid"
117
+ /// (node never passed through `prepare()`, or local bounds empty).
118
+ pub world_bounds_min: [f32; 3],
119
+ pub world_bounds_max: [f32; 3],
120
+ // GPU resources (lazily created)
121
+ pub gpu_vb: Option<wgpu::Buffer>,
122
+ pub gpu_ib: Option<wgpu::Buffer>,
123
+ pub gpu_index_count: u32,
124
+ /// Vertex count, cached at VB upload so the ray-tracing BLAS build
125
+ /// can reference it without re-reading the full `vertices` Vec.
126
+ pub gpu_vertex_count: u32,
127
+ /// Ticket 007b — bottom-level acceleration structure built at the
128
+ /// same geo_dirty flush that creates `gpu_vb`/`gpu_ib`. `None` on
129
+ /// non-RT adapters or until the first upload. Build is scheduled
130
+ /// by `prepare()`, committed to the GPU by the renderer's main
131
+ /// encoder in `build_acceleration_structures`.
132
+ pub blas: Option<wgpu::Blas>,
133
+ /// Ticket 013 — first of 6 consecutive card-atlas slots assigned
134
+ /// at BLAS creation. Slots are laid out per axis:
135
+ /// first_slot + 0 → +X, +1 → -X, +2 → +Y,
136
+ /// +3 → -Y, +4 → +Z, +5 → -Z.
137
+ /// `None` until the capture pass has allocated the run.
138
+ pub card_first_slot: Option<u32>,
139
+ /// Ticket 013 V3 — when true, the mesh is re-queued into
140
+ /// `pending_card_captures` every frame so the card atlas stays
141
+ /// in sync with animated geometry. Off by default — static meshes
142
+ /// pay the capture cost once and never again.
143
+ pub card_dynamic: bool,
144
+ /// Ticket 014 — per-mesh unsigned distance field baked by a
145
+ /// compute pass at geo-upload time. 3D R16Float texture, fixed
146
+ /// resolution (`MESH_SDF_RES`³). Used later by the SW probe
147
+ /// trace for sphere-marching when the adapter lacks HW RT.
148
+ /// `None` on non-RT-capable adapters or until the bake lands.
149
+ pub mesh_sdf: Option<wgpu::Texture>,
150
+ pub mesh_sdf_view: Option<wgpu::TextureView>,
151
+ /// Content hash of (positions, indices) computed at upload time.
152
+ /// Set whenever `mesh_sdf` exists; the renderer reads it back when
153
+ /// flushing cache writes after a fresh bake. `None` until the
154
+ /// first geo upload — and on non-RT-capable adapters that never
155
+ /// allocate a per-mesh SDF.
156
+ pub mesh_hash: Option<crate::sdf_cache::MeshHash>,
157
+ /// Flat mesh-average world-space normal, cached on BLAS build so
158
+ /// the per-instance GI data buffer can be populated without
159
+ /// re-reading the vertex array. Rough heuristic — for walls and
160
+ /// floors this tracks the surface closely; for radially-symmetric
161
+ /// meshes (columns) it averages to near-zero and the trace falls
162
+ /// back to a fixed up-vector. Phase-2 Mesh Cards (ticket 013)
163
+ /// upgrades this to textured per-hit normals.
164
+ pub flat_normal_ws: [f32; 3],
165
+ /// Mean world-space albedo cached alongside `flat_normal_ws`.
166
+ /// For the hit-lighting-lite path in 007b HW trace.
167
+ pub flat_albedo: [f32; 3],
168
+ /// Index into the scene-graph's shared node-uniform pool. `None`
169
+ /// until this node's first `prepare()` assigns a slot. Cleared when
170
+ /// the pool reallocates so the next prepare re-assigns + rebuilds
171
+ /// `gpu_uniform_bg`.
172
+ uniform_slot: Option<u32>,
173
+ gpu_uniform_bg: Option<wgpu::BindGroup>,
174
+ /// Transient: set by `prepare()` based on the camera frustum, read
175
+ /// by `render()` to skip off-screen nodes. Shadow pass ignores this
176
+ /// flag — off-screen geometry can still cast shadows into view.
177
+ in_view_frustum: bool,
178
+ /// Hidden behind other geometry per the Hi-Z occlusion grid (one
179
+ /// frame of latency, conservative). Only gates the main camera
180
+ /// pass — shadows/picking/TLAS never read it.
181
+ occluded: bool,
182
+ /// Material bind group for the scene pipeline — holds base color,
183
+ /// normal, metallic-roughness and emissive texture views in one
184
+ /// group. Rebuilt whenever one of the material texture indices
185
+ /// changes (tracked via `mat_dirty`).
186
+ pub gpu_material_bg: Option<wgpu::BindGroup>,
187
+ pub gpu_material_uniform_buf: Option<wgpu::Buffer>,
188
+ /// Alpha-tested shadow-caster bind group for MASK materials (base
189
+ /// colour + sampler + cutoff). None for opaque casters. Built with
190
+ /// the material bind group; consumed by the shadow pass's cutout
191
+ /// pipeline so scene-node foliage casts dappled, not solid, shadows.
192
+ pub gpu_shadow_cutout_bg: Option<wgpu::BindGroup>,
193
+ pub mat_dirty: bool,
194
+ geo_dirty: bool,
195
+ /// Reduced-detail geometry variants, ordered coarser and coarser
196
+ /// (descending max_coverage). Selected per frame in prepare() by
197
+ /// projected screen coverage; the base geometry above is "LOD 0".
198
+ /// Shadows, picking, BLAS, and SDF always use the base geometry —
199
+ /// LODs only affect the main camera rasterization.
200
+ pub lods: Vec<LodLevel>,
201
+ /// Active LOD this frame: -1 = base geometry, otherwise an index
202
+ /// into `lods`. Driven by prepare(); render() binds accordingly.
203
+ active_lod: i32,
204
+ }
205
+
206
+ /// One reduced-detail variant for a SceneNode.
207
+ pub struct LodLevel {
208
+ pub vertices: Vec<Vertex3D>,
209
+ pub indices: Vec<u32>,
210
+ /// Use this level when the node's projected screen coverage (longest
211
+ /// NDC extent of its world AABB, 0..1) drops below this value.
212
+ pub max_coverage: f32,
213
+ gpu_vb: Option<wgpu::Buffer>,
214
+ gpu_ib: Option<wgpu::Buffer>,
215
+ gpu_index_count: u32,
216
+ dirty: bool,
217
+ }
218
+
219
+ impl SceneNode {
220
+ fn new() -> Self {
221
+ Self {
222
+ vertices: Vec::new(),
223
+ indices: Vec::new(),
224
+ material: PbrMaterial::default(),
225
+ transform: crate::renderer::IDENTITY_MAT4,
226
+ prev_transform: crate::renderer::IDENTITY_MAT4,
227
+ visible: true,
228
+ cast_shadow: true,
229
+ receive_shadow: true,
230
+ gi_only: false,
231
+ parent: 0.0,
232
+ user_data: 0,
233
+ bounds_min: [0.0; 3],
234
+ bounds_max: [0.0; 3],
235
+ world_bounds_min: [f32::MAX; 3],
236
+ world_bounds_max: [f32::MIN; 3],
237
+ gpu_vb: None,
238
+ gpu_ib: None,
239
+ gpu_index_count: 0,
240
+ gpu_vertex_count: 0,
241
+ blas: None,
242
+ card_first_slot: None,
243
+ card_dynamic: false,
244
+ mesh_sdf: None,
245
+ mesh_sdf_view: None,
246
+ mesh_hash: None,
247
+ flat_normal_ws: [0.0, 1.0, 0.0],
248
+ flat_albedo: [1.0, 1.0, 1.0],
249
+ uniform_slot: None,
250
+ gpu_uniform_bg: None,
251
+ in_view_frustum: true,
252
+ occluded: false,
253
+ gpu_material_bg: None,
254
+ gpu_material_uniform_buf: None,
255
+ gpu_shadow_cutout_bg: None,
256
+ mat_dirty: true,
257
+ geo_dirty: true,
258
+ lods: Vec::new(),
259
+ active_lod: -1,
260
+ }
261
+ }
262
+ }
263
+
264
+ // ============================================================
265
+ // Scene Graph
266
+ // ============================================================
267
+
268
+ /// Stride between per-node uniform slots in the shared pool buffer.
269
+ /// Must be >= sizeof(NodeUniforms) (224B) and a multiple of the device's
270
+ /// `min_uniform_buffer_offset_alignment`. 256 is safe on every platform.
271
+ const NODE_UNIFORM_STRIDE: u64 = 256;
272
+
273
+ pub struct SceneGraph {
274
+ pub nodes: HandleRegistry<SceneNode>,
275
+ /// Shared uniform buffer holding one 256B slot per scene node. All
276
+ /// per-node uniforms get written to this buffer in a single
277
+ /// `queue.write_buffer` call per frame, replacing what used to be
278
+ /// one write per node. Grows on demand; bind groups referencing
279
+ /// the old buffer get invalidated when that happens.
280
+ uniform_pool: Option<wgpu::Buffer>,
281
+ uniform_pool_capacity: u32,
282
+ /// Next free slot index. Slots are never released — they only grow
283
+ /// with the high-water-mark node count. Sufficient for the current
284
+ /// workload (scenes with < 10k retained nodes).
285
+ next_slot: u32,
286
+ /// Scratch buffer reused across frames for building the packed
287
+ /// uniform payload. Sized to `uniform_pool_capacity * STRIDE`.
288
+ scratch: Vec<u8>,
289
+ /// Monotonic counter bumped whenever a change lands that affects
290
+ /// what gets drawn into the directional shadow map — transform,
291
+ /// cast_shadow toggle, visibility (of a caster), geometry update,
292
+ /// or destruction of a visible caster. The renderer's shadow-map
293
+ /// cache compares this against its last-rendered version to decide
294
+ /// if it can reuse the cached cascades. Writes that can't affect
295
+ /// shadows (materials, user_data, etc.) deliberately don't bump it.
296
+ pub shadow_version: u64,
297
+
298
+ /// Ticket 007b — true when the host renderer's device was created
299
+ /// with `Features::EXPERIMENTAL_RAY_QUERY`. Controls whether the
300
+ /// scene bakes `BLAS_INPUT` buffer usage + a per-node BLAS at
301
+ /// `prepare()` time. Off on non-RT adapters (web, most Android,
302
+ /// Intel integrated GPUs) so the cost is never paid there.
303
+ pub hw_rt_enabled: bool,
304
+ /// Monotonic counter bumped when any change that would require a
305
+ /// TLAS rebuild lands — geometry upload, transform, visibility
306
+ /// toggle, node add/destroy. The renderer compares against its
307
+ /// cached `tlas_built_version` and rebuilds TLAS + instance-data
308
+ /// buffer when they differ. Mirror of `shadow_version`'s pattern.
309
+ pub tlas_version: u64,
310
+ /// Node handles whose BLAS was (re)created this frame and has not
311
+ /// been built yet. `prepare()` pushes entries; the renderer drains
312
+ /// this list and submits the builds in its main frame encoder via
313
+ /// `CommandEncoder::build_acceleration_structures`.
314
+ pub pending_blas_builds: Vec<f64>,
315
+ /// Ticket 013 — node handles waiting for their mesh card to be
316
+ /// rasterised into the shared atlas. Renderer drains this list
317
+ /// at frame start (via a dedicated capture pass) before the
318
+ /// probe chain runs. Populated alongside BLAS creation so each
319
+ /// new mesh gets exactly one card.
320
+ pub pending_card_captures: Vec<f64>,
321
+ /// Bump allocator for card-atlas slots. Grows monotonically
322
+ /// across the lifetime of the scene — free'd slots aren't
323
+ /// reclaimed in V1 (Sponza fits comfortably in 1024 slots; loop
324
+ /// back when scenes start exceeding capacity).
325
+ pub next_card_slot: u32,
326
+ /// 6-slot card blocks returned by destroyed nodes, reused before
327
+ /// next_card_slot grows — without recycling, create/destroy cycles
328
+ /// exhaust the fixed-size card atlas.
329
+ free_card_blocks: Vec<u32>,
330
+ /// Ticket 014 — node handles whose per-mesh SDF still needs to
331
+ /// be baked. Populated alongside BLAS creation; renderer drains
332
+ /// in a per-frame budget via a compute pass. Static meshes
333
+ /// never re-bake once their SDF lands.
334
+ pub pending_sdf_bakes: Vec<f64>,
335
+ }
336
+
337
+ impl SceneGraph {
338
+ pub fn new() -> Self {
339
+ Self {
340
+ nodes: HandleRegistry::new(),
341
+ uniform_pool: None,
342
+ uniform_pool_capacity: 0,
343
+ next_slot: 0,
344
+ scratch: Vec::new(),
345
+ // Start at 1 so the shadow-cache's initial 0 always differs
346
+ // on the first frame and forces an initial render.
347
+ shadow_version: 1,
348
+ hw_rt_enabled: false,
349
+ tlas_version: 1,
350
+ pending_blas_builds: Vec::new(),
351
+ pending_card_captures: Vec::new(),
352
+ next_card_slot: 0,
353
+ free_card_blocks: Vec::new(),
354
+ pending_sdf_bakes: Vec::new(),
355
+ }
356
+ }
357
+
358
+ pub fn create_node(&mut self) -> f64 {
359
+ // New nodes default to `cast_shadow = true`, so the first
360
+ // `set_transform` + `update_geometry` will dirty shadows
361
+ // anyway. Bumping here too costs nothing and keeps the
362
+ // invalidation story simple.
363
+ self.shadow_version = self.shadow_version.wrapping_add(1);
364
+ self.tlas_version = self.tlas_version.wrapping_add(1);
365
+ self.nodes.alloc(SceneNode::new())
366
+ }
367
+
368
+ pub fn destroy_node(&mut self, handle: f64) {
369
+ if let Some(node) = self.nodes.get(handle) {
370
+ if node.visible && node.cast_shadow {
371
+ self.shadow_version = self.shadow_version.wrapping_add(1);
372
+ }
373
+ if node.visible {
374
+ self.tlas_version = self.tlas_version.wrapping_add(1);
375
+ }
376
+ // Recycle the node's 6-slot card block. The freed node's GPU
377
+ // buffers/BLAS/SDF drop with the SceneNode itself (wgpu
378
+ // releases them once in-flight work completes).
379
+ if let Some(first) = node.card_first_slot {
380
+ self.free_card_blocks.push(first);
381
+ }
382
+ }
383
+ self.nodes.free(handle);
384
+ }
385
+
386
+ /// Position + Y-rotation + uniform-scale convenience setter. Exists
387
+ /// because the full-matrix `set_transform` crosses the FFI as an
388
+ /// i64 pointer parameter, which Perry 0.5.x rejects for JS arrays;
389
+ /// six f64 scalars stay register-friendly on every ABI. The yaw
390
+ /// convention matches `draw_model_rotated`:
391
+ /// world.x = c·x + s·z, world.z = −s·x + c·z.
392
+ pub fn set_trs(&mut self, handle: f64, px: f32, py: f32, pz: f32, yaw: f32, scale: f32) {
393
+ let (s, c) = yaw.sin_cos();
394
+ let m = [
395
+ [c * scale, 0.0, -s * scale, 0.0],
396
+ [0.0, scale, 0.0, 0.0],
397
+ [s * scale, 0.0, c * scale, 0.0],
398
+ [px, py, pz, 1.0],
399
+ ];
400
+ self.set_transform(handle, m);
401
+ }
402
+
403
+ pub fn set_transform(&mut self, handle: f64, matrix: [[f32; 4]; 4]) {
404
+ if let Some(node) = self.nodes.get_mut(handle) {
405
+ // Only dirty shadows when the transform actually changed on
406
+ // a shadow-casting node. Skeletal animation leaves the node
407
+ // transform untouched (joints drive the mesh), so static
408
+ // scenes with animated characters still cache well.
409
+ let changed = node.transform != matrix;
410
+ if changed && node.cast_shadow && node.visible {
411
+ self.shadow_version = self.shadow_version.wrapping_add(1);
412
+ }
413
+ if changed && node.visible {
414
+ self.tlas_version = self.tlas_version.wrapping_add(1);
415
+ }
416
+ node.transform = matrix;
417
+ }
418
+ }
419
+
420
+ pub fn set_visible(&mut self, handle: f64, visible: bool) {
421
+ if let Some(node) = self.nodes.get_mut(handle) {
422
+ if node.visible != visible {
423
+ self.tlas_version = self.tlas_version.wrapping_add(1);
424
+ }
425
+ if node.cast_shadow && node.visible != visible {
426
+ self.shadow_version = self.shadow_version.wrapping_add(1);
427
+ }
428
+ node.visible = visible;
429
+ }
430
+ }
431
+
432
+ pub fn set_gi_only(&mut self, handle: f64, gi_only: bool) {
433
+ if let Some(node) = self.nodes.get_mut(handle) {
434
+ if node.gi_only != gi_only {
435
+ // Raster participation changes: the shadow map must
436
+ // re-render without (or with) this caster.
437
+ if node.cast_shadow && node.visible {
438
+ self.shadow_version = self.shadow_version.wrapping_add(1);
439
+ }
440
+ node.gi_only = gi_only;
441
+ }
442
+ }
443
+ }
444
+
445
+ pub fn set_cast_shadow(&mut self, handle: f64, cast: bool) {
446
+ if let Some(node) = self.nodes.get_mut(handle) {
447
+ if node.visible && node.cast_shadow != cast {
448
+ self.shadow_version = self.shadow_version.wrapping_add(1);
449
+ }
450
+ node.cast_shadow = cast;
451
+ }
452
+ }
453
+
454
+ pub fn set_receive_shadow(&mut self, handle: f64, receive: bool) {
455
+ if let Some(node) = self.nodes.get_mut(handle) {
456
+ node.receive_shadow = receive;
457
+ }
458
+ }
459
+
460
+ /// Ticket 013 V3 — mark a mesh as dynamic so its card atlas slots
461
+ /// get re-captured every frame. Off by default; call once per
462
+ /// skeletal / morph-target / procedurally-animated node so its
463
+ /// indirect-bounce contribution tracks the animation.
464
+ pub fn set_mesh_dynamic(&mut self, handle: f64, dynamic: bool) {
465
+ if let Some(node) = self.nodes.get_mut(handle) {
466
+ node.card_dynamic = dynamic;
467
+ }
468
+ }
469
+
470
+ /// Ticket 014 V2 — gather every visible mesh's triangles into a
471
+ /// single world-space buffer so the scene-wide SDF clipmap bake
472
+ /// can treat them as one big mesh. Vertex layout matches the
473
+ /// shader's hard-coded 12-f32 Vertex3D stride; only position is
474
+ /// meaningful for the UDF (normals/colour/UV are left zero).
475
+ /// Returns (vertex_buf, index_buf, total_triangle_count).
476
+ pub fn build_world_triangles(&self) -> (Vec<f32>, Vec<u32>, u32) {
477
+ // 12 floats per vertex to match `Vertex3D` stride the bake
478
+ // shader indexes with. position + zero-padding.
479
+ const STRIDE: usize = 12;
480
+ let mut vbuf: Vec<f32> = Vec::new();
481
+ let mut ibuf: Vec<u32> = Vec::new();
482
+ let mut tri_count: u32 = 0;
483
+ for (_, node) in self.nodes.iter() {
484
+ if !node.visible || node.vertices.is_empty() || node.indices.is_empty() {
485
+ continue;
486
+ }
487
+ let base = (vbuf.len() / STRIDE) as u32;
488
+ let t = &node.transform;
489
+ for v in &node.vertices {
490
+ let px = v.position[0];
491
+ let py = v.position[1];
492
+ let pz = v.position[2];
493
+ let wx = t[0][0]*px + t[1][0]*py + t[2][0]*pz + t[3][0];
494
+ let wy = t[0][1]*px + t[1][1]*py + t[2][1]*pz + t[3][1];
495
+ let wz = t[0][2]*px + t[1][2]*py + t[2][2]*pz + t[3][2];
496
+ // Zero-pad the remaining 9 floats — only position is
497
+ // read by `SDF_BAKE_WGSL`.
498
+ vbuf.extend_from_slice(&[wx, wy, wz, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
499
+ }
500
+ for &idx in &node.indices {
501
+ ibuf.push(base + idx);
502
+ }
503
+ tri_count += (node.indices.len() / 3) as u32;
504
+ }
505
+ (vbuf, ibuf, tri_count)
506
+ }
507
+
508
+ pub fn set_parent(&mut self, handle: f64, parent: f64) {
509
+ if let Some(node) = self.nodes.get_mut(handle) {
510
+ node.parent = parent;
511
+ }
512
+ }
513
+
514
+ /// Set (or replace) a reduced-detail variant. `lod_index` is 0-based
515
+ /// into the reduced set (the node's base geometry is implicitly the
516
+ /// finest level). `max_coverage` is the screen-coverage threshold
517
+ /// below which this level is used; give coarser levels smaller
518
+ /// thresholds. Pass empty vertices to remove the level.
519
+ pub fn set_lod_geometry(
520
+ &mut self,
521
+ handle: f64,
522
+ lod_index: usize,
523
+ vertices: Vec<Vertex3D>,
524
+ indices: Vec<u32>,
525
+ max_coverage: f32,
526
+ ) {
527
+ let Some(node) = self.nodes.get_mut(handle) else { return };
528
+ if vertices.is_empty() {
529
+ if lod_index < node.lods.len() {
530
+ node.lods.remove(lod_index);
531
+ }
532
+ return;
533
+ }
534
+ while node.lods.len() <= lod_index {
535
+ node.lods.push(LodLevel {
536
+ vertices: Vec::new(),
537
+ indices: Vec::new(),
538
+ max_coverage: 0.0,
539
+ gpu_vb: None,
540
+ gpu_ib: None,
541
+ gpu_index_count: 0,
542
+ dirty: true,
543
+ });
544
+ }
545
+ let lod = &mut node.lods[lod_index];
546
+ lod.vertices = vertices;
547
+ lod.indices = indices;
548
+ lod.max_coverage = max_coverage;
549
+ lod.dirty = true;
550
+ }
551
+
552
+ pub fn update_geometry(&mut self, handle: f64, vertices: Vec<Vertex3D>, indices: Vec<u32>) {
553
+ if let Some(node) = self.nodes.get_mut(handle) {
554
+ // Recompute bounds from vertex positions (Q5).
555
+ let mut bmin = [f32::MAX; 3];
556
+ let mut bmax = [f32::MIN; 3];
557
+ for v in &vertices {
558
+ for k in 0..3 {
559
+ if v.position[k] < bmin[k] { bmin[k] = v.position[k]; }
560
+ if v.position[k] > bmax[k] { bmax[k] = v.position[k]; }
561
+ }
562
+ }
563
+ if vertices.is_empty() {
564
+ bmin = [0.0; 3];
565
+ bmax = [0.0; 3];
566
+ }
567
+ node.bounds_min = bmin;
568
+ node.bounds_max = bmax;
569
+ node.vertices = vertices;
570
+ node.indices = indices;
571
+ node.geo_dirty = true;
572
+ if node.cast_shadow && node.visible {
573
+ self.shadow_version = self.shadow_version.wrapping_add(1);
574
+ }
575
+ if node.visible {
576
+ self.tlas_version = self.tlas_version.wrapping_add(1);
577
+ }
578
+ }
579
+ }
580
+
581
+ // ---- Q4: transform read-back -------------------------------------------
582
+
583
+ /// Read back the current 4x4 transform matrix of a scene node.
584
+ pub fn get_transform(&self, handle: f64) -> [[f32; 4]; 4] {
585
+ match self.nodes.get(handle) {
586
+ Some(node) => node.transform,
587
+ None => crate::renderer::IDENTITY_MAT4,
588
+ }
589
+ }
590
+
591
+ // ---- Q5: world-space bounds query --------------------------------------
592
+
593
+ /// Return the cached AABB of a scene node's geometry (local space).
594
+ pub fn get_bounds(&self, handle: f64) -> ([f32; 3], [f32; 3]) {
595
+ match self.nodes.get(handle) {
596
+ Some(node) => (node.bounds_min, node.bounds_max),
597
+ None => ([0.0; 3], [0.0; 3]),
598
+ }
599
+ }
600
+
601
+ /// World-space AABB of every visible, shadow-casting node.
602
+ /// Used to auto-fit the directional shadow ortho volume — no
603
+ /// scene-specific magic numbers, works for Sponza / Bistro /
604
+ /// anything a user loads.
605
+ ///
606
+ /// Returns `None` if the scene is empty (caller should fall back
607
+ /// to a safe default).
608
+ pub fn compute_shadow_bounds(&self) -> Option<([f32; 3], [f32; 3])> {
609
+ let mut bmin = [f32::MAX; 3];
610
+ let mut bmax = [f32::MIN; 3];
611
+ let mut any = false;
612
+ for (_h, node) in self.nodes.iter() {
613
+ if !node.visible {
614
+ continue;
615
+ }
616
+ // gi_only proxies never render into the cascades themselves
617
+ // (see the shadow pass), but they stand in for material-path
618
+ // geometry that DOES cast — so their bounds must extend the
619
+ // pancake Z-range. Without this, casters behind the camera's
620
+ // cascade slice clip out of the ortho volume and their
621
+ // shadows flicker with camera movement.
622
+ if !node.gi_only && !node.cast_shadow {
623
+ continue;
624
+ }
625
+ if node.bounds_min[0] > node.bounds_max[0] {
626
+ continue; // empty bounds
627
+ }
628
+ // Transform the 8 local-AABB corners by the node's world matrix
629
+ // and union into the running bounds.
630
+ let t = &node.transform;
631
+ for ix in 0..2 {
632
+ for iy in 0..2 {
633
+ for iz in 0..2 {
634
+ let lx = if ix == 0 { node.bounds_min[0] } else { node.bounds_max[0] };
635
+ let ly = if iy == 0 { node.bounds_min[1] } else { node.bounds_max[1] };
636
+ let lz = if iz == 0 { node.bounds_min[2] } else { node.bounds_max[2] };
637
+ // column-major mat4 * vec4(x,y,z,1)
638
+ let wx = t[0][0]*lx + t[1][0]*ly + t[2][0]*lz + t[3][0];
639
+ let wy = t[0][1]*lx + t[1][1]*ly + t[2][1]*lz + t[3][1];
640
+ let wz = t[0][2]*lx + t[1][2]*ly + t[2][2]*lz + t[3][2];
641
+ if wx < bmin[0] { bmin[0] = wx; }
642
+ if wy < bmin[1] { bmin[1] = wy; }
643
+ if wz < bmin[2] { bmin[2] = wz; }
644
+ if wx > bmax[0] { bmax[0] = wx; }
645
+ if wy > bmax[1] { bmax[1] = wy; }
646
+ if wz > bmax[2] { bmax[2] = wz; }
647
+ any = true;
648
+ }
649
+ }
650
+ }
651
+ }
652
+ if any { Some((bmin, bmax)) } else { None }
653
+ }
654
+
655
+ // ---- Q7: user data -----------------------------------------------------
656
+
657
+ pub fn set_user_data(&mut self, handle: f64, data: i64) {
658
+ if let Some(node) = self.nodes.get_mut(handle) {
659
+ node.user_data = data;
660
+ }
661
+ }
662
+
663
+ pub fn get_user_data(&self, handle: f64) -> i64 {
664
+ match self.nodes.get(handle) {
665
+ Some(node) => node.user_data,
666
+ None => 0,
667
+ }
668
+ }
669
+
670
+ pub fn set_material_color(&mut self, handle: f64, r: f32, g: f32, b: f32, a: f32) {
671
+ if let Some(node) = self.nodes.get_mut(handle) {
672
+ node.material.color = [r, g, b];
673
+ node.material.opacity = a;
674
+ }
675
+ }
676
+
677
+ pub fn set_material_pbr(&mut self, handle: f64, roughness: f32, metalness: f32) {
678
+ if let Some(node) = self.nodes.get_mut(handle) {
679
+ node.material.roughness = roughness;
680
+ node.material.metalness = metalness;
681
+ // Factors live in the material uniform, which is only rebuilt
682
+ // together with the bind group — without dirtying, factor
683
+ // changes after the first render never applied.
684
+ node.mat_dirty = true;
685
+ }
686
+ }
687
+
688
+ /// glTF MASK alpha cutoff for the node's material (0 = opaque).
689
+ /// Routes the node through the scene shader's alpha-cutout path.
690
+ pub fn set_material_alpha_cutoff(&mut self, handle: f64, cutoff: f32) {
691
+ if let Some(node) = self.nodes.get_mut(handle) {
692
+ node.material.alpha_cutoff = cutoff;
693
+ node.mat_dirty = true;
694
+ }
695
+ }
696
+
697
+ /// Q8: Set a water-like material on a scene node. The actual animated
698
+ /// wave shader requires a dedicated WGSL pipeline pass (deferred).
699
+ /// For now, this sets a translucent tinted material that approximates water.
700
+ pub fn set_material_water(&mut self, handle: f64, _wave_amp: f32, _wave_speed: f32, r: f32, g: f32, b: f32, a: f32) {
701
+ if let Some(node) = self.nodes.get_mut(handle) {
702
+ node.material.color = [r, g, b];
703
+ node.material.opacity = a;
704
+ node.material.roughness = 0.1;
705
+ node.material.metalness = 0.3;
706
+ }
707
+ }
708
+
709
+ pub fn set_material_texture(&mut self, handle: f64, texture_idx: u32) {
710
+ if let Some(node) = self.nodes.get_mut(handle) {
711
+ node.material.texture_idx = texture_idx;
712
+ node.mat_dirty = true;
713
+ }
714
+ }
715
+
716
+ pub fn set_material_normal_texture(&mut self, handle: f64, texture_idx: u32) {
717
+ if let Some(node) = self.nodes.get_mut(handle) {
718
+ node.material.normal_texture_idx = texture_idx;
719
+ node.mat_dirty = true;
720
+ }
721
+ }
722
+
723
+ pub fn set_material_metallic_roughness_texture(&mut self, handle: f64, texture_idx: u32) {
724
+ if let Some(node) = self.nodes.get_mut(handle) {
725
+ node.material.metallic_roughness_texture_idx = texture_idx;
726
+ node.mat_dirty = true;
727
+ }
728
+ }
729
+
730
+ pub fn set_material_emissive_texture(&mut self, handle: f64, texture_idx: u32) {
731
+ if let Some(node) = self.nodes.get_mut(handle) {
732
+ node.material.emissive_texture_idx = texture_idx;
733
+ node.mat_dirty = true;
734
+ }
735
+ }
736
+
737
+ pub fn set_material_emissive_factor(&mut self, handle: f64, r: f32, g: f32, b: f32) {
738
+ if let Some(node) = self.nodes.get_mut(handle) {
739
+ node.material.emissive = [r, g, b];
740
+ node.mat_dirty = true;
741
+ }
742
+ }
743
+
744
+ /// Prepare GPU resources for all visible nodes. Must be called before render().
745
+ /// Creates/updates vertex buffers, index buffers, and uniform bind groups.
746
+ /// `prev_vp_matrix` is the previous frame's view-projection — used together
747
+ /// with each node's `prev_transform` to compute `prev_mvp` for the velocity
748
+ /// buffer (motion blur + TAA per-object reprojection).
749
+ pub fn prepare(
750
+ &mut self,
751
+ device: &wgpu::Device,
752
+ queue: &wgpu::Queue,
753
+ vp_matrix: &[[f32; 4]; 4],
754
+ prev_vp_matrix: &[[f32; 4]; 4],
755
+ uniform_layout: &wgpu::BindGroupLayout,
756
+ occlusion: Option<&crate::renderer::OcclusionCuller>,
757
+ ) {
758
+ let frustum = extract_frustum_planes(vp_matrix);
759
+
760
+ // Phase 1: upload geometry for any freshly-added or dirty nodes,
761
+ // assign uniform slots, and count how many nodes we'll draw.
762
+ // Borrow splits so we can push to `pending_blas_builds` while
763
+ // iterating `nodes` mutably.
764
+ let hw_rt = self.hw_rt_enabled;
765
+ let pending_blas = &mut self.pending_blas_builds;
766
+ let pending_cards = &mut self.pending_card_captures;
767
+ let pending_sdf = &mut self.pending_sdf_bakes;
768
+ let next_card_slot = &mut self.next_card_slot;
769
+ let free_card_blocks = &mut self.free_card_blocks;
770
+ let mut visible_count: u32 = 0;
771
+ for (handle, node) in self.nodes.iter_mut() {
772
+ if !node.visible || node.indices.is_empty() {
773
+ continue;
774
+ }
775
+ // Frustum cull against world-space AABB. Transform the local
776
+ // bounds into world space by applying the node's transform
777
+ // to the 8 corners; use the min/max of the result.
778
+ if node.bounds_min[0] <= node.bounds_max[0] {
779
+ let t = &node.transform;
780
+ let mut wmin = [f32::MAX; 3];
781
+ let mut wmax = [f32::MIN; 3];
782
+ for ix in 0..2 {
783
+ for iy in 0..2 {
784
+ for iz in 0..2 {
785
+ let lx = if ix == 0 { node.bounds_min[0] } else { node.bounds_max[0] };
786
+ let ly = if iy == 0 { node.bounds_min[1] } else { node.bounds_max[1] };
787
+ let lz = if iz == 0 { node.bounds_min[2] } else { node.bounds_max[2] };
788
+ let wx = t[0][0]*lx + t[1][0]*ly + t[2][0]*lz + t[3][0];
789
+ let wy = t[0][1]*lx + t[1][1]*ly + t[2][1]*lz + t[3][1];
790
+ let wz = t[0][2]*lx + t[1][2]*ly + t[2][2]*lz + t[3][2];
791
+ if wx < wmin[0] { wmin[0] = wx; }
792
+ if wy < wmin[1] { wmin[1] = wy; }
793
+ if wz < wmin[2] { wmin[2] = wz; }
794
+ if wx > wmax[0] { wmax[0] = wx; }
795
+ if wy > wmax[1] { wmax[1] = wy; }
796
+ if wz > wmax[2] { wmax[2] = wz; }
797
+ }
798
+ }
799
+ }
800
+ node.in_view_frustum = !aabb_outside_frustum(&frustum, wmin, wmax);
801
+ node.world_bounds_min = wmin;
802
+ node.world_bounds_max = wmax;
803
+ // Hi-Z occlusion: only worth testing what survived the
804
+ // frustum; every uncertain case inside test_aabb
805
+ // resolves to visible.
806
+ node.occluded = node.in_view_frustum
807
+ && occlusion.is_some_and(|o| !o.test_aabb(wmin, wmax));
808
+
809
+ // LOD selection by projected screen coverage: longest
810
+ // NDC extent of the world AABB. Corners at/behind the
811
+ // near plane force the base level (huge on screen).
812
+ if !node.lods.is_empty() && node.in_view_frustum && !node.occluded {
813
+ let coverage = aabb_screen_coverage(vp_matrix, wmin, wmax);
814
+ let current = node.active_lod;
815
+ let mut chosen: i32 = -1;
816
+ for (i, lod) in node.lods.iter().enumerate() {
817
+ // 10% hysteresis: stepping coarser needs coverage
818
+ // clearly below the threshold, stepping finer
819
+ // clearly above — kills boundary flicker.
820
+ let t = if current >= i as i32 {
821
+ lod.max_coverage * 1.05
822
+ } else {
823
+ lod.max_coverage * 0.95
824
+ };
825
+ if coverage < t {
826
+ chosen = i as i32;
827
+ }
828
+ }
829
+ node.active_lod = chosen;
830
+ } else {
831
+ node.active_lod = -1;
832
+ }
833
+ } else {
834
+ // Empty or uninitialized bounds — can't cull, play safe.
835
+ node.in_view_frustum = true;
836
+ node.occluded = false;
837
+ node.world_bounds_min = [f32::MAX; 3];
838
+ node.world_bounds_max = [f32::MIN; 3];
839
+ }
840
+ // Upload any dirty LOD variants (plain vertex/index buffers —
841
+ // LODs never feed BLAS/SDF, those read the base geometry).
842
+ for lod in node.lods.iter_mut().filter(|l| l.dirty) {
843
+ lod.gpu_vb = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
844
+ label: Some("scene_node_lod_vb"),
845
+ contents: bytemuck::cast_slice(&lod.vertices),
846
+ usage: wgpu::BufferUsages::VERTEX,
847
+ }));
848
+ lod.gpu_ib = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
849
+ label: Some("scene_node_lod_ib"),
850
+ contents: bytemuck::cast_slice(&lod.indices),
851
+ usage: wgpu::BufferUsages::INDEX,
852
+ }));
853
+ lod.gpu_index_count = lod.indices.len() as u32;
854
+ lod.dirty = false;
855
+ }
856
+
857
+ if node.geo_dirty || node.gpu_vb.is_none() {
858
+ // Ticket 007b: widen buffer usage when HW RT is on so
859
+ // the same buffer can back both the raster draw and
860
+ // the BLAS build. Cheap — no measurable cost when RT
861
+ // is off.
862
+ // Ticket 014 V3 — STORAGE is unconditional now so the
863
+ // scene-wide SDF clipmap can be baked on SW-only
864
+ // adapters too (web, older Android, Intel iGPUs). The
865
+ // cost is a buffer-usage-flag bit — no runtime
866
+ // overhead on non-RT adapters that don't read it.
867
+ // BLAS_INPUT stays gated on `hw_rt` since it's wgpu-29
868
+ // ray-tracing-only.
869
+ let vb_usage = if hw_rt {
870
+ wgpu::BufferUsages::VERTEX
871
+ | wgpu::BufferUsages::BLAS_INPUT
872
+ | wgpu::BufferUsages::STORAGE
873
+ } else {
874
+ wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE
875
+ };
876
+ let ib_usage = if hw_rt {
877
+ wgpu::BufferUsages::INDEX
878
+ | wgpu::BufferUsages::BLAS_INPUT
879
+ | wgpu::BufferUsages::STORAGE
880
+ } else {
881
+ wgpu::BufferUsages::INDEX | wgpu::BufferUsages::STORAGE
882
+ };
883
+ node.gpu_vb = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
884
+ label: Some("scene_node_vb"),
885
+ contents: bytemuck::cast_slice(&node.vertices),
886
+ usage: vb_usage,
887
+ }));
888
+ node.gpu_ib = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
889
+ label: Some("scene_node_ib"),
890
+ contents: bytemuck::cast_slice(&node.indices),
891
+ usage: ib_usage,
892
+ }));
893
+ node.gpu_index_count = node.indices.len() as u32;
894
+ node.gpu_vertex_count = node.vertices.len() as u32;
895
+ node.geo_dirty = false;
896
+
897
+ // Ticket 014 V4 — flat normal/albedo + card-slot
898
+ // allocation now happen regardless of `hw_rt`. The SW
899
+ // SDF sphere-trace's broad-phase hit uses these same
900
+ // per-instance fields to sample the card radiance
901
+ // atlas, so they need to be populated even when no
902
+ // BLAS / TLAS is built. BLAS + per-mesh SDF bake
903
+ // remain hw-rt-gated because only the HW trace reads
904
+ // those (and the per-mesh SDFs are currently dormant).
905
+ if !node.indices.is_empty() {
906
+ let mut n_sum = [0.0_f32; 3];
907
+ for v in &node.vertices {
908
+ n_sum[0] += v.normal[0];
909
+ n_sum[1] += v.normal[1];
910
+ n_sum[2] += v.normal[2];
911
+ }
912
+ let t = &node.transform;
913
+ let nx = t[0][0]*n_sum[0] + t[1][0]*n_sum[1] + t[2][0]*n_sum[2];
914
+ let ny = t[0][1]*n_sum[0] + t[1][1]*n_sum[1] + t[2][1]*n_sum[2];
915
+ let nz = t[0][2]*n_sum[0] + t[1][2]*n_sum[1] + t[2][2]*n_sum[2];
916
+ let len = (nx*nx + ny*ny + nz*nz).sqrt();
917
+ if len > 1e-4 {
918
+ node.flat_normal_ws = [nx / len, ny / len, nz / len];
919
+ } else {
920
+ // Radially symmetric — fall back to world-up.
921
+ node.flat_normal_ws = [0.0, 1.0, 0.0];
922
+ }
923
+ node.flat_albedo = node.material.color;
924
+
925
+ // Ticket 013 V2 — allocate 6 consecutive slots per
926
+ // signed AABB axis and schedule the capture pass.
927
+ // Runs on both HW and SW paths so the SDF trace's
928
+ // broad-phase hit can sample textured radiance.
929
+ if node.card_first_slot.is_none() {
930
+ let first = match free_card_blocks.pop() {
931
+ Some(reused) => reused,
932
+ None => {
933
+ let fresh = *next_card_slot;
934
+ *next_card_slot += 6;
935
+ fresh
936
+ }
937
+ };
938
+ node.card_first_slot = Some(first);
939
+ pending_cards.push(handle);
940
+ }
941
+
942
+ if hw_rt {
943
+ // BLAS creation only on HW-RT adapters.
944
+ let size_desc = wgpu::BlasTriangleGeometrySizeDescriptor {
945
+ vertex_format: wgpu::VertexFormat::Float32x3,
946
+ vertex_count: node.gpu_vertex_count,
947
+ index_format: Some(wgpu::IndexFormat::Uint32),
948
+ index_count: Some(node.gpu_index_count),
949
+ flags: wgpu::AccelerationStructureGeometryFlags::OPAQUE,
950
+ };
951
+ node.blas = Some(device.create_blas(
952
+ &wgpu::CreateBlasDescriptor {
953
+ label: Some("scene_node_blas"),
954
+ flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE,
955
+ update_mode: wgpu::AccelerationStructureUpdateMode::Build,
956
+ },
957
+ wgpu::BlasGeometrySizeDescriptors::Triangles {
958
+ descriptors: vec![size_desc],
959
+ },
960
+ ));
961
+ pending_blas.push(handle);
962
+
963
+ // Per-mesh SDF texture — currently dormant (V4
964
+ // dynamic-scene merge will consume it), but
965
+ // cheap to allocate alongside the BLAS.
966
+ if node.mesh_sdf.is_none()
967
+ && node.bounds_min[0] < node.bounds_max[0]
968
+ && node.bounds_min[1] < node.bounds_max[1]
969
+ && node.bounds_min[2] < node.bounds_max[2]
970
+ {
971
+ let (sdf_tex, sdf_view) = crate::renderer::create_mesh_sdf_texture_public(
972
+ device,
973
+ "scene_node_sdf",
974
+ );
975
+
976
+ // Ticket 022 — content-hash the geometry and
977
+ // try the on-disk SDF cache before scheduling
978
+ // a GPU bake. Vertex layout is interleaved;
979
+ // pull the position prefix out as a
980
+ // [[f32; 3]] slice so the hash only sees
981
+ // geometry-relevant bytes.
982
+ let positions: Vec<[f32; 3]> =
983
+ node.vertices.iter().map(|v| v.position).collect();
984
+ let hash = crate::sdf_cache::compute_mesh_hash(
985
+ &positions, &node.indices,
986
+ );
987
+ node.mesh_hash = Some(hash);
988
+
989
+ if let Some(bytes) = crate::sdf_cache::load(hash) {
990
+ // Cache hit — pad the tightly-packed
991
+ // 128 B/row payload to 256 B/row so it
992
+ // clears wgpu's COPY_BYTES_PER_ROW
993
+ // alignment, then upload directly and
994
+ // skip the bake. Native cache size
995
+ // stays compact (128 KB/mesh on disk);
996
+ // the 128 KB padding allocation is
997
+ // free'd immediately after the call.
998
+ const RES: u32 = crate::sdf_cache::VOXEL_RES;
999
+ let row_tight = (RES * 4) as usize;
1000
+ let row_padded = ((row_tight + 255) & !255) as u32;
1001
+ let mut padded = vec![
1002
+ 0u8;
1003
+ (row_padded as usize) * (RES as usize) * (RES as usize)
1004
+ ];
1005
+ for z in 0..RES as usize {
1006
+ for y in 0..RES as usize {
1007
+ let src_off = (z * RES as usize + y) * row_tight;
1008
+ let dst_off = (z * RES as usize + y) * row_padded as usize;
1009
+ padded[dst_off..dst_off + row_tight]
1010
+ .copy_from_slice(&bytes[src_off..src_off + row_tight]);
1011
+ }
1012
+ }
1013
+ queue.write_texture(
1014
+ wgpu::TexelCopyTextureInfo {
1015
+ texture: &sdf_tex,
1016
+ mip_level: 0,
1017
+ origin: wgpu::Origin3d::ZERO,
1018
+ aspect: wgpu::TextureAspect::All,
1019
+ },
1020
+ &padded,
1021
+ wgpu::TexelCopyBufferLayout {
1022
+ offset: 0,
1023
+ bytes_per_row: Some(row_padded),
1024
+ rows_per_image: Some(RES),
1025
+ },
1026
+ wgpu::Extent3d {
1027
+ width: RES,
1028
+ height: RES,
1029
+ depth_or_array_layers: RES,
1030
+ },
1031
+ );
1032
+ } else {
1033
+ pending_sdf.push(handle);
1034
+ }
1035
+
1036
+ node.mesh_sdf = Some(sdf_tex);
1037
+ node.mesh_sdf_view = Some(sdf_view);
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ if node.uniform_slot.is_none() {
1043
+ node.uniform_slot = Some(self.next_slot);
1044
+ self.next_slot += 1;
1045
+ }
1046
+
1047
+ // Ticket 013 V3 — re-queue dynamic meshes every frame so
1048
+ // their card atlas slots get re-captured to track
1049
+ // animated geometry. Static meshes (default) stay out of
1050
+ // the queue after their one-shot first-frame capture.
1051
+ if node.card_dynamic && node.card_first_slot.is_some() {
1052
+ pending_cards.push(handle);
1053
+ }
1054
+
1055
+ visible_count += 1;
1056
+ }
1057
+
1058
+ // Phase 2: ensure pool is large enough. Grow with 2x + padding
1059
+ // so this branch is rare once the scene has stabilized.
1060
+ let needed_capacity = self.next_slot.max(32);
1061
+ if needed_capacity > self.uniform_pool_capacity {
1062
+ let new_cap = needed_capacity.next_power_of_two().max(64);
1063
+ let byte_size = (new_cap as u64) * NODE_UNIFORM_STRIDE;
1064
+ self.uniform_pool = Some(device.create_buffer(&wgpu::BufferDescriptor {
1065
+ label: Some("scene_node_uniform_pool"),
1066
+ size: byte_size,
1067
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1068
+ mapped_at_creation: false,
1069
+ }));
1070
+ self.uniform_pool_capacity = new_cap;
1071
+ self.scratch.resize(byte_size as usize, 0);
1072
+ // Invalidate every per-node bind group — they reference
1073
+ // the old buffer.
1074
+ for (_handle, node) in self.nodes.iter_mut() {
1075
+ node.gpu_uniform_bg = None;
1076
+ }
1077
+ }
1078
+
1079
+ let Some(pool_buf) = self.uniform_pool.as_ref() else { return };
1080
+ let uniform_size = std::mem::size_of::<NodeUniforms>();
1081
+ let stride = NODE_UNIFORM_STRIDE as usize;
1082
+
1083
+ // Phase 3: build the packed payload + create any missing bind
1084
+ // groups. Bind-group creation is rare (only on first sight of
1085
+ // a slot or after pool grow); the hot per-frame path is just
1086
+ // the memcpy into scratch.
1087
+ let mut max_byte_offset: usize = 0;
1088
+ for (_handle, node) in self.nodes.iter_mut() {
1089
+ if !node.visible || node.indices.is_empty() {
1090
+ continue;
1091
+ }
1092
+ let Some(slot) = node.uniform_slot else { continue };
1093
+
1094
+ let mvp = mat4_mul(vp_matrix, &node.transform);
1095
+ let prev_mvp = mat4_mul(prev_vp_matrix, &node.prev_transform);
1096
+ // Guard against NaN/Inf opacity (Perry TS passes NaN
1097
+ // when a default-arg alpha isn't provided) — a single
1098
+ // NaN in model_tint.w propagates through every shader
1099
+ // output.
1100
+ let opacity = if node.material.opacity.is_finite() {
1101
+ node.material.opacity
1102
+ } else {
1103
+ 1.0
1104
+ };
1105
+ let tint = [
1106
+ node.material.color[0],
1107
+ node.material.color[1],
1108
+ node.material.color[2],
1109
+ opacity,
1110
+ ];
1111
+ let uniforms = NodeUniforms { mvp, model: node.transform, prev_mvp, model_tint: tint, misc: [0.0; 4] };
1112
+ node.prev_transform = node.transform;
1113
+
1114
+ let off = (slot as usize) * stride;
1115
+ self.scratch[off..off + uniform_size].copy_from_slice(bytemuck::bytes_of(&uniforms));
1116
+ max_byte_offset = max_byte_offset.max(off + stride);
1117
+
1118
+ if node.gpu_uniform_bg.is_none() {
1119
+ node.gpu_uniform_bg = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
1120
+ label: Some("scene_node_uniform_bg"),
1121
+ layout: uniform_layout,
1122
+ entries: &[wgpu::BindGroupEntry {
1123
+ binding: 0,
1124
+ resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
1125
+ buffer: pool_buf,
1126
+ offset: (slot as u64) * NODE_UNIFORM_STRIDE,
1127
+ size: std::num::NonZeroU64::new(uniform_size as u64),
1128
+ }),
1129
+ }],
1130
+ }));
1131
+ }
1132
+ }
1133
+
1134
+ // Phase 4: single write — replaces what used to be N per-node
1135
+ // queue.write_buffer calls. On sponza (68 nodes) this cut
1136
+ // scene_prepare from ~1.7 ms to ~0.3 ms.
1137
+ if visible_count > 0 && max_byte_offset > 0 {
1138
+ queue.write_buffer(pool_buf, 0, &self.scratch[..max_byte_offset]);
1139
+ }
1140
+ }
1141
+
1142
+ /// Build / refresh per-node material bind groups for the scene
1143
+ /// pipeline. Must be called every frame after `prepare` and before
1144
+ /// `render`. Only rebuilds when a material changed (mat_dirty).
1145
+ pub fn prepare_materials(&mut self, renderer: &crate::renderer::Renderer) {
1146
+ for (_handle, node) in self.nodes.iter_mut() {
1147
+ if !node.visible || node.indices.is_empty() {
1148
+ continue;
1149
+ }
1150
+ if node.mat_dirty || node.gpu_material_bg.is_none() {
1151
+ // Allocate or reuse the per-material uniform buffer.
1152
+ // (Could be updated in place when factors change, but
1153
+ // the current path always rebuilds together with the
1154
+ // bind group — cheap and simpler.)
1155
+ let uniform = renderer.create_scene_material_uniform(
1156
+ node.material.metalness,
1157
+ node.material.roughness,
1158
+ node.material.emissive,
1159
+ node.material.metallic_roughness_texture_idx != 0,
1160
+ // MASK cutoff from the node material (0 = opaque).
1161
+ // attach_model carries it over from the glTF mesh so
1162
+ // foliage cards keep their cutout + two-sided shading
1163
+ // + wind sway on the scene-graph path.
1164
+ node.material.alpha_cutoff,
1165
+ );
1166
+ let bg = renderer.create_scene_material_bg(
1167
+ node.material.texture_idx,
1168
+ node.material.normal_texture_idx,
1169
+ node.material.metallic_roughness_texture_idx,
1170
+ node.material.emissive_texture_idx,
1171
+ node.material.occlusion_texture_idx,
1172
+ &uniform,
1173
+ );
1174
+ node.gpu_material_bg = Some(bg);
1175
+ node.gpu_material_uniform_buf = Some(uniform);
1176
+ // MASK materials also get an alpha-tested shadow-caster
1177
+ // bind group so foliage casts dappled shadows on the
1178
+ // scene-graph path (mirrors the cached-model path).
1179
+ node.gpu_shadow_cutout_bg = if node.material.alpha_cutoff > 0.0 {
1180
+ Some(renderer.create_shadow_cutout_bg(
1181
+ node.material.texture_idx,
1182
+ node.material.alpha_cutoff,
1183
+ ))
1184
+ } else {
1185
+ None
1186
+ };
1187
+ node.mat_dirty = false;
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ /// Render all visible scene nodes into the given render pass.
1193
+ /// Must be called after prepare() and after the pipeline/lighting/joints are set.
1194
+ pub fn render<'a>(
1195
+ &'a self,
1196
+ pass: &mut wgpu::RenderPass<'a>,
1197
+ ) {
1198
+ for (_handle, node) in self.nodes.iter() {
1199
+ if !node.visible || node.gi_only || node.indices.is_empty() || !node.in_view_frustum || node.occluded {
1200
+ continue;
1201
+ }
1202
+ // Active LOD overrides the base buffers for the camera pass
1203
+ // (shadows/picking/BLAS keep using the base geometry).
1204
+ let (vb, ib, index_count) = match node
1205
+ .lods
1206
+ .get(node.active_lod.max(0) as usize)
1207
+ .filter(|_| node.active_lod >= 0)
1208
+ .and_then(|l| Some((l.gpu_vb.as_ref()?, l.gpu_ib.as_ref()?, l.gpu_index_count)))
1209
+ {
1210
+ Some(lod) => lod,
1211
+ None => {
1212
+ let Some(vb) = &node.gpu_vb else { continue };
1213
+ let Some(ib) = &node.gpu_ib else { continue };
1214
+ (vb, ib, node.gpu_index_count)
1215
+ }
1216
+ };
1217
+ let Some(bg) = &node.gpu_uniform_bg else { continue };
1218
+ let Some(mat_bg) = &node.gpu_material_bg else { continue };
1219
+ pass.set_bind_group(0, bg, &[]);
1220
+ pass.set_bind_group(2, mat_bg, &[]);
1221
+ pass.set_vertex_buffer(0, vb.slice(..));
1222
+ pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32);
1223
+ pass.draw_indexed(0..index_count, 0, 0..1);
1224
+ }
1225
+ }
1226
+
1227
+ pub fn node_count(&self) -> usize {
1228
+ self.nodes.iter().count()
1229
+ }
1230
+
1231
+ /// Draw list for the planar-reflection probe: every visible node with
1232
+ /// uploaded geometry as (vb, ib, index_count, material_bg, transform).
1233
+ /// Frustum / occlusion flags are intentionally ignored — they were
1234
+ /// computed for the MAIN camera and the mirrored probe camera sees a
1235
+ /// different set. Base geometry only (no LOD swap): the probe is
1236
+ /// half-res and consumed through a perturbed water lookup, where a
1237
+ /// LOD pop would be more visible than the detail it saves.
1238
+ /// (Treats node.transform as world — flat hierarchies.)
1239
+ pub fn reflect_draw_list(&self)
1240
+ -> Vec<(&wgpu::Buffer, &wgpu::Buffer, u32, &wgpu::BindGroup, [[f32; 4]; 4], [f32; 3], [f32; 3])>
1241
+ {
1242
+ let mut out = Vec::new();
1243
+ for (_handle, node) in self.nodes.iter() {
1244
+ if !node.visible || node.gi_only || node.indices.is_empty() { continue; }
1245
+ let Some(vb) = &node.gpu_vb else { continue };
1246
+ let Some(ib) = &node.gpu_ib else { continue };
1247
+ let Some(mat_bg) = &node.gpu_material_bg else { continue };
1248
+ // World bounds ride along so the probe pass can frustum-cull
1249
+ // against the MIRRORED camera (main-camera cull flags don't
1250
+ // apply there). Sentinel (min > max) = not yet computed →
1251
+ // never culled.
1252
+ out.push((
1253
+ vb, ib, node.gpu_index_count, mat_bg, node.transform,
1254
+ node.world_bounds_min, node.world_bounds_max,
1255
+ ));
1256
+ }
1257
+ out
1258
+ }
1259
+ }
1260
+
1261
+ // ============================================================
1262
+ // Matrix math (4x4, column-major)
1263
+ // ============================================================
1264
+
1265
+ // ============================================================
1266
+ // Frustum culling
1267
+ // ============================================================
1268
+ // Gribb-Hartmann plane extraction: for a column-major clip matrix M,
1269
+ // each plane = ±row_i + row_3. We build 6 planes (left/right/bottom/
1270
+ // top/near/far) in world space directly from the VP matrix, so every
1271
+ // plane-test below is a world-space dot product.
1272
+ //
1273
+ // A node's world-space AABB is outside the frustum if ALL 8 of its
1274
+ // corners are on the negative side of ANY single plane. The standard
1275
+ // "positive-vertex-only" optimization is skipped here — testing 8
1276
+ // corners is still a few dozen multiplies per node, trivial compared
1277
+ // to the per-node GPU cost we skip on a cull hit.
1278
+ //
1279
+ // Plane format: [nx, ny, nz, d] where `nx*x + ny*y + nz*z + d >= 0`
1280
+ // means the point is inside that plane's half-space. No normalization
1281
+ // — we only care about the sign.
1282
+
1283
+ /// Longest NDC-extent of a world AABB under `vp` — the "screen coverage"
1284
+ /// that drives LOD selection (1.0 = spans the full viewport). Corners at
1285
+ /// or behind the near plane return 1.0 (force the finest level).
1286
+ fn aabb_screen_coverage(vp: &[[f32; 4]; 4], wmin: [f32; 3], wmax: [f32; 3]) -> f32 {
1287
+ let mut lo = [f32::MAX, f32::MAX];
1288
+ let mut hi = [f32::MIN, f32::MIN];
1289
+ for ix in 0..2 {
1290
+ for iy in 0..2 {
1291
+ for iz in 0..2 {
1292
+ let x = if ix == 0 { wmin[0] } else { wmax[0] };
1293
+ let y = if iy == 0 { wmin[1] } else { wmax[1] };
1294
+ let z = if iz == 0 { wmin[2] } else { wmax[2] };
1295
+ let cw = vp[0][3] * x + vp[1][3] * y + vp[2][3] * z + vp[3][3];
1296
+ if cw <= 1e-3 {
1297
+ return 1.0;
1298
+ }
1299
+ let cx = (vp[0][0] * x + vp[1][0] * y + vp[2][0] * z + vp[3][0]) / cw;
1300
+ let cy = (vp[0][1] * x + vp[1][1] * y + vp[2][1] * z + vp[3][1]) / cw;
1301
+ lo[0] = lo[0].min(cx);
1302
+ lo[1] = lo[1].min(cy);
1303
+ hi[0] = hi[0].max(cx);
1304
+ hi[1] = hi[1].max(cy);
1305
+ }
1306
+ }
1307
+ }
1308
+ // NDC spans -1..1, so extent/2 = fraction of the viewport.
1309
+ (((hi[0] - lo[0]).max(hi[1] - lo[1])) * 0.5).clamp(0.0, 1.0)
1310
+ }
1311
+
1312
+ pub(crate) fn extract_frustum_planes(vp: &[[f32; 4]; 4]) -> [[f32; 4]; 6] {
1313
+ // Row vectors of the column-major matrix: row_i[col] = vp[col][i].
1314
+ let row = |i: usize| [vp[0][i], vp[1][i], vp[2][i], vp[3][i]];
1315
+ let r0 = row(0); let r1 = row(1); let r2 = row(2); let r3 = row(3);
1316
+ let add = |a: [f32;4], b: [f32;4]| [a[0]+b[0], a[1]+b[1], a[2]+b[2], a[3]+b[3]];
1317
+ let sub = |a: [f32;4], b: [f32;4]| [a[0]-b[0], a[1]-b[1], a[2]-b[2], a[3]-b[3]];
1318
+ [
1319
+ add(r3, r0), // left
1320
+ sub(r3, r0), // right
1321
+ add(r3, r1), // bottom
1322
+ sub(r3, r1), // top
1323
+ r2, // near (wgpu uses 0..1 depth → near = row_2)
1324
+ sub(r3, r2), // far
1325
+ ]
1326
+ }
1327
+
1328
+ pub(crate) fn aabb_outside_frustum(planes: &[[f32; 4]; 6], bmin: [f32; 3], bmax: [f32; 3]) -> bool {
1329
+ for p in planes.iter() {
1330
+ let mut all_outside = true;
1331
+ for ix in 0..2 {
1332
+ let x = if ix == 0 { bmin[0] } else { bmax[0] };
1333
+ for iy in 0..2 {
1334
+ let y = if iy == 0 { bmin[1] } else { bmax[1] };
1335
+ for iz in 0..2 {
1336
+ let z = if iz == 0 { bmin[2] } else { bmax[2] };
1337
+ if p[0]*x + p[1]*y + p[2]*z + p[3] >= 0.0 {
1338
+ all_outside = false;
1339
+ break;
1340
+ }
1341
+ }
1342
+ if !all_outside { break; }
1343
+ }
1344
+ if !all_outside { break; }
1345
+ }
1346
+ if all_outside { return true; }
1347
+ }
1348
+ false
1349
+ }
1350
+
1351
+ fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] {
1352
+ let mut result = [[0.0f32; 4]; 4];
1353
+ for col in 0..4 {
1354
+ for row in 0..4 {
1355
+ result[col][row] = a[0][row] * b[col][0]
1356
+ + a[1][row] * b[col][1]
1357
+ + a[2][row] * b[col][2]
1358
+ + a[3][row] * b[col][3];
1359
+ }
1360
+ }
1361
+ result
1362
+ }