@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,731 @@
1
+ //! Cascaded shadow-map pass: cascade fitting from the primary
2
+ //! directional light, the ticket-004 cache-hit skip, and the per-cascade
3
+ //! depth renders. Split from end_frame_with_scene (2000-line file policy
4
+ //! + render-graph migration prep).
5
+
6
+ use super::*;
7
+
8
+ impl Renderer {
9
+ pub(super) fn record_shadow_pass(
10
+ &mut self,
11
+ encoder: &mut wgpu::CommandEncoder,
12
+ profiler: &mut crate::profiler::Profiler,
13
+ scene: &mut crate::scene::SceneGraph,
14
+ ) {
15
+ // Shadow pass: render scene nodes from light's perspective into
16
+ // cascaded shadow maps (3 cascades).
17
+ //
18
+ // Cache hit path (ticket 004): if no caster moved, the light
19
+ // didn't move, and the freshly-computed cascade VPs match the
20
+ // ones the cached depth textures were rendered with, we skip
21
+ // the whole pass. The depth textures retain their content and
22
+ // the main pass samples from them as if we had redrawn.
23
+ profiler.begin("shadow_pass");
24
+ if self.shadow_map.enabled {
25
+ // EN-043 — take last frame's caster transforms out of `self` up front: the
26
+ // caster lists below hold immutable borrows of self.model_gpu_cache for the
27
+ // rest of the function, so this map cannot be touched through `self` again
28
+ // until they are dead.
29
+ let prev_caster_ids = std::mem::take(&mut self.shadow_caster_tf);
30
+ let mut caster_ids_now: std::collections::HashSet<u64> =
31
+ std::collections::HashSet::with_capacity(prev_caster_ids.len() + 64);
32
+ // Compute cascade VPs from the primary directional light and camera.
33
+ let light_dir = [
34
+ self.lighting_uniforms.light_dir[0],
35
+ self.lighting_uniforms.light_dir[1],
36
+ self.lighting_uniforms.light_dir[2],
37
+ ];
38
+ // Auto-fit: compute world-space AABB across every visible,
39
+ // cast-shadow node so the ortho volume always covers the
40
+ // scene regardless of what's loaded. No per-scene magic
41
+ // numbers.
42
+ let scene_bounds = scene.compute_shadow_bounds();
43
+ self.shadow_map.compute_cascade_vps(
44
+ light_dir,
45
+ self.current_camera_pos,
46
+ self.current_view_matrix,
47
+ // Use the pre-jitter projection so the cascade VPs
48
+ // stay byte-stable when the camera is actually
49
+ // stationary (the shadow cache compares them exactly).
50
+ self.current_proj_matrix_unjittered,
51
+ 0.5, // near — start cascades slightly past the camera
52
+ 80.0, // far — shadow coverage range
53
+ scene_bounds,
54
+ );
55
+
56
+ // Re-upload lighting uniforms with cascade VPs and splits.
57
+ // Always write these — even on a cache hit the cascade
58
+ // split distances and view matrix track camera movement
59
+ // (they drive per-pixel cascade selection in the main
60
+ // shader), which is independent of shadow texture content.
61
+ self.lighting_uniforms.shadow_cascade_vps = self.shadow_map.light_vps;
62
+ self.lighting_uniforms.shadow_cascade_splits = [
63
+ self.shadow_map.cascade_splits[0],
64
+ self.shadow_map.cascade_splits[1],
65
+ self.shadow_map.cascade_splits[2],
66
+ // .w = mip-LOD bias for material textures. Bias finer
67
+ // by log2(render_scale) — recovers -1.0 at 0.5 (one
68
+ // mip finer to offset hardware's coarser selection
69
+ // at half-res), ~-0.42 at 0.75, 0 at native.
70
+ if self.render_scale < 0.999 { self.render_scale.log2() } else { 0.0 },
71
+ ];
72
+ self.lighting_uniforms.shadow_view_matrix = self.current_view_matrix;
73
+ self.queue.write_buffer(
74
+ &self.lighting_buffer,
75
+ 0,
76
+ bytemuck::bytes_of(&self.lighting_uniforms),
77
+ );
78
+ // Shadow-flicker fix: the material system's PerView buffer was
79
+ // uploaded before this fit ran and still carries LAST frame's
80
+ // cascade VPs. Patch its shadow fields so material-path
81
+ // receivers sample the depth maps with the same matrices the
82
+ // maps are rendered with this frame. Keep .w at 0.0 — that slot
83
+ // is the TSR mip-LOD bias, which the material path historically
84
+ // never received (begin_mode_3d resets it before the material
85
+ // PerView upload); delivering -1.0 here makes hardware mip
86
+ // selection flip per-panel under TAA jitter (visible texture
87
+ // detail popping).
88
+ let mut splits = self.lighting_uniforms.shadow_cascade_splits;
89
+ splits[3] = 0.0;
90
+ self.material_system.refresh_shadow_uniforms(
91
+ &self.queue,
92
+ splits,
93
+ self.lighting_uniforms.shadow_view_matrix,
94
+ self.shadow_map.light_vps,
95
+ );
96
+
97
+ // Cache gate, stage 1 — whole-pass invalidators. Per-cascade
98
+ // staleness (VP compare + caster-content signature) is decided
99
+ // after the caster lists are built. Texel-snap + radius
100
+ // quantization + re-fit slack in `compute_cascade_vps` make the
101
+ // per-cascade VP compare exact, so a kept VP + unchanged content
102
+ // means the cascade's cached depth texture is still valid.
103
+ let scene_ver = scene.shadow_version;
104
+ let light_changed = self.shadow_map.rendered_light_dir
105
+ .map(|cached| cached != light_dir)
106
+ .unwrap_or(true);
107
+ let force_all = self.shadow_map.always_fresh
108
+ || self.shadow_map.dirty
109
+ || light_changed
110
+ || self.shadow_map.rendered_light_vps.is_none()
111
+ || self.shadow_map.rendered_scene_version != scene_ver;
112
+
113
+ // Build a shared caster list + buffer-ref vectors, then
114
+ // filter per cascade against that cascade's ortho frustum.
115
+ // A caster outside cascade N's frustum can't write pixels
116
+ // into cascade N; near/far pancaking already covers
117
+ // behind-camera casters via the cascade's own far plane.
118
+ struct ShadowDrawEntry {
119
+ vb_idx: usize,
120
+ ib_idx: usize,
121
+ index_start: u32,
122
+ index_count: u32,
123
+ transform: [[f32; 4]; 4],
124
+ wmin: [f32; 3],
125
+ wmax: [f32; 3],
126
+ // Index into `cutout_bgs` for an alpha-tested caster (cutout
127
+ // foliage), or -1 for an opaque caster (plain depth pipeline).
128
+ cutout_idx: i32,
129
+ // Immediate-mode segment containing skinned characters —
130
+ // rendered with the skinning-aware shadow pipeline so animated
131
+ // player/enemies cast a posed shadow instead of a rest pose at
132
+ // the origin. (Mixed segments: non-skinned verts still
133
+ // transform by the model matrix via the shader's weight branch.)
134
+ skinned: bool,
135
+ // Content identity for the per-cascade cache: stable across
136
+ // frames for static casters, salted with `frame_nonce` for
137
+ // animated ones so their cascades re-render every frame.
138
+ sig: u64,
139
+ // Immediate-batch content (animated characters, per-frame
140
+ // primitives). Dynamic casters render into the live cascade
141
+ // texture every frame on top of the cached static depth;
142
+ // they never invalidate the static cache.
143
+ dynamic: bool,
144
+ // Base slot of a skinned cached draw's pose in the shared
145
+ // joint buffer (the cached VB keeps RAW joint indices, so
146
+ // vs_shadow_skinned adds this via ShadowUniforms.misc.x).
147
+ // 0.0 for everything else — including immediate-batch
148
+ // skinned segments, whose vertex joints are pre-offset.
149
+ joint_offset: f32,
150
+ // Foliage wind amount for this caster (0 = rigid). Non-zero makes the
151
+ // caster MOVE, which is why it also forces `dynamic` — a swaying tree
152
+ // cannot reuse its cached static shadow depth.
153
+ foliage: f32,
154
+ // EN-043 — stable identity (NOT including the transform), so a caster
155
+ // that moved can be told apart from a caster that appeared.
156
+ key: u64,
157
+ }
158
+ fn entry_sig(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 {
159
+ let mut h = FNV_OFFSET;
160
+ h = fnv1a_bytes(h, &[kind]);
161
+ h = fnv1a_bytes(h, &id.to_le_bytes());
162
+ h = fnv1a_bytes(h, &idx.to_le_bytes());
163
+ fnv1a_bytes(h, bytemuck::bytes_of(transform))
164
+ }
165
+ // EN-043 — "was this exact caster, at this exact transform, here last frame?"
166
+ //
167
+ // One combined hash of identity AND transform, looked up in a SET. If it is
168
+ // in last frame's set, the caster has not moved and stays static. If it is
169
+ // not, it either moved or is new — either way it goes in the dynamic set,
170
+ // where it draws on top of the cached static depth instead of invalidating it.
171
+ //
172
+ // ORDER-INDEPENDENT, and that is the whole point of the rewrite. The first
173
+ // version keyed on "the Nth draw of this model handle", which was fine until
174
+ // the game started drawing its forest FRONT-TO-BACK: the sort order changes
175
+ // as the camera moves, so occurrence N became a different tree every frame,
176
+ // dozens of perfectly stationary trees were misread as movers, and the
177
+ // dynamic set blew past 32 casters in combat. A set membership test does not
178
+ // care what order the draws arrive in.
179
+ fn caster_id(kind: u8, id: u64, idx: u64, transform: &[[f32; 4]; 4]) -> u64 {
180
+ let mut h = FNV_OFFSET;
181
+ h = fnv1a_bytes(h, &[kind]);
182
+ h = fnv1a_bytes(h, &id.to_le_bytes());
183
+ h = fnv1a_bytes(h, &idx.to_le_bytes());
184
+ fnv1a_bytes(h, bytemuck::bytes_of(transform))
185
+ }
186
+ // Per-frame nonce for animated casters' signatures. Bumped
187
+ // whenever shadows render — skinned CACHED model draws need it
188
+ // even when the immediate batch is empty, which is the norm now
189
+ // that skinned models draw through the cache.
190
+ self.shadow_map.frame_nonce = self.shadow_map.frame_nonce.wrapping_add(1);
191
+ let nonce = self.shadow_map.frame_nonce;
192
+
193
+ let mut shadow_nodes: Vec<ShadowDrawEntry> = Vec::new();
194
+ let mut shadow_vbs: Vec<&wgpu::Buffer> = Vec::new();
195
+ let mut shadow_ibs: Vec<&wgpu::Buffer> = Vec::new();
196
+ let mut cutout_bgs: Vec<&wgpu::BindGroup> = Vec::new();
197
+ for (i, (_handle, node)) in scene.nodes.iter().enumerate() {
198
+ // gi_only proxies duplicate geometry that already casts through
199
+ // the material-command path below — including them would
200
+ // double-render every caster.
201
+ if !node.visible || node.gi_only || !node.cast_shadow || node.indices.is_empty() {
202
+ continue;
203
+ }
204
+ let Some(vb) = &node.gpu_vb else { continue };
205
+ let Some(ib) = &node.gpu_ib else { continue };
206
+ let vb_idx = shadow_vbs.len();
207
+ shadow_vbs.push(vb);
208
+ shadow_ibs.push(ib);
209
+ // MASK-material nodes carry an alpha-test shadow bind group so
210
+ // foliage casts dappled shadows (same as the cached-model path).
211
+ let cutout_idx = match &node.gpu_shadow_cutout_bg {
212
+ Some(bg) => { let i = cutout_bgs.len(); cutout_bgs.push(bg); i as i32 }
213
+ None => -1,
214
+ };
215
+ shadow_nodes.push(ShadowDrawEntry {
216
+ vb_idx,
217
+ ib_idx: vb_idx,
218
+ index_start: 0,
219
+ index_count: node.gpu_index_count,
220
+ transform: node.transform,
221
+ wmin: node.world_bounds_min,
222
+ wmax: node.world_bounds_max,
223
+ cutout_idx,
224
+ skinned: false,
225
+ sig: entry_sig(0, i as u64, node.gpu_index_count as u64, &node.transform),
226
+ dynamic: false,
227
+ joint_offset: 0.0,
228
+ foliage: 0.0,
229
+ key: caster_id(0, i as u64, node.gpu_index_count as u64, &node.transform),
230
+ });
231
+ }
232
+
233
+ // Immediate-mode 3D batch (drawCube/drawSphere/non-cached models),
234
+ // one entry per segment. These verts are already in WORLD space, so
235
+ // the model matrix is identity. Model draws maintain per-segment
236
+ // bounds inline (skinned via joint-transformed rest AABBs);
237
+ // primitive-only segments are scanned here. Segments with skinned
238
+ // content take a per-frame nonce as their signature (animation
239
+ // means their rendered output changes every frame); static
240
+ // segments hash their vertex positions, so e.g. pickups
241
+ // re-submitted identically each frame don't dirty their cascades.
242
+ if !self.indices_3d.is_empty() {
243
+ self.scan_unbounded_segments_3d();
244
+ let vb_idx = shadow_vbs.len();
245
+ shadow_vbs.push(&self.persistent_vb_3d);
246
+ shadow_ibs.push(&self.persistent_ib_3d);
247
+ if self.draw_calls_3d.is_empty() {
248
+ // Fallback: vertices without segment tracking — one
249
+ // unbounded, always-dirty entry (pre-segmentation shape).
250
+ shadow_nodes.push(ShadowDrawEntry {
251
+ vb_idx,
252
+ ib_idx: vb_idx,
253
+ index_start: 0,
254
+ index_count: self.indices_3d.len() as u32,
255
+ transform: IDENTITY_MAT4,
256
+ wmin: [1.0, 1.0, 1.0],
257
+ wmax: [-1.0, -1.0, -1.0],
258
+ cutout_idx: -1,
259
+ skinned: true,
260
+ sig: nonce,
261
+ dynamic: true,
262
+ joint_offset: 0.0,
263
+ foliage: 0.0,
264
+ key: 0,
265
+ });
266
+ } else {
267
+ let num_calls = self.draw_calls_3d.len();
268
+ for ci in 0..num_calls {
269
+ let call = &self.draw_calls_3d[ci];
270
+ let next_start = if ci + 1 < num_calls {
271
+ self.draw_calls_3d[ci + 1].index_start
272
+ } else {
273
+ self.indices_3d.len() as u32
274
+ };
275
+ let count = next_start - call.index_start;
276
+ if count == 0 { continue; }
277
+ shadow_nodes.push(ShadowDrawEntry {
278
+ vb_idx,
279
+ ib_idx: vb_idx,
280
+ index_start: call.index_start,
281
+ index_count: count,
282
+ transform: IDENTITY_MAT4,
283
+ wmin: call.wmin,
284
+ wmax: call.wmax,
285
+ cutout_idx: -1,
286
+ skinned: call.has_skinned,
287
+ sig: if call.has_skinned { nonce } else { call.content_hash },
288
+ dynamic: true,
289
+ joint_offset: 0.0,
290
+ foliage: 0.0,
291
+ key: 0,
292
+ });
293
+ }
294
+ }
295
+ }
296
+
297
+ // Foliage promoted to the dynamic set this frame. Capped well below
298
+ // SHADOW_MAX_DYNAMIC so the characters — whose shadows are the ones a
299
+ // player actually looks at — always keep their slots.
300
+ const MAX_FOLIAGE_DYNAMIC: u32 = 24;
301
+ let mut foliage_dynamic: u32 = 0;
302
+
303
+ // Cached models (drawModel: trees, characters, etc.) — each is a
304
+ // GpuMesh plus its object→world matrix. World AABB from the
305
+ // cache-time local AABB so per-cascade culling rejects casters
306
+ // outside a cascade's ortho frustum (the forest was previously
307
+ // re-drawn into every cascade every frame). Skinned cached draws
308
+ // render through the skinning pipeline as dynamic casters (pose
309
+ // changes every frame → nonce signature) with the joint-union
310
+ // AABB computed at submit time.
311
+ for cmd in self.model_draw_commands.iter() {
312
+ if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.cache_handle) {
313
+ if cmd.mesh_idx < meshes.len() {
314
+ let mesh = &meshes[cmd.mesh_idx];
315
+ let vb_idx = shadow_vbs.len();
316
+ shadow_vbs.push(&mesh.vb);
317
+ shadow_ibs.push(&mesh.ib);
318
+ // Cutout foliage → alpha-tested shadow pipeline.
319
+ let cutout_idx = match &mesh.shadow_cutout_bg {
320
+ Some(bg) => { let i = cutout_bgs.len(); cutout_bgs.push(bg); i as i32 }
321
+ None => -1,
322
+ };
323
+ if cmd.skinned {
324
+ // Sentinel bounds (min > max) when the submit-time
325
+ // AABB was empty → uncullable, never lost.
326
+ let (wmin, wmax) = cmd.bounds_override
327
+ .unwrap_or(([1.0, 1.0, 1.0], [-1.0, -1.0, -1.0]));
328
+ shadow_nodes.push(ShadowDrawEntry {
329
+ vb_idx,
330
+ ib_idx: vb_idx,
331
+ index_start: 0,
332
+ index_count: mesh.index_count,
333
+ transform: cmd.model,
334
+ wmin,
335
+ wmax,
336
+ cutout_idx,
337
+ skinned: true,
338
+ sig: nonce,
339
+ dynamic: true,
340
+ joint_offset: cmd.joint_offset,
341
+ foliage: 0.0,
342
+ key: 0,
343
+ });
344
+ } else {
345
+ // Only sway the shadow if the game asked for it AND there is
346
+ // room in the dynamic-caster budget.
347
+ //
348
+ // That second condition is not paranoia. A swaying caster
349
+ // cannot reuse the cached static depth, so it must move to
350
+ // the DYNAMIC set — and that set holds SHADOW_MAX_DYNAMIC
351
+ // (64) entries. The shooter's forest alone is 88 trees x 4
352
+ // primitives = 352. Marking them all dynamic overflows the
353
+ // budget, and the overflow is dropped — which does not merely
354
+ // cost frames, it silently DELETES shadows. Measured: turning
355
+ // this on removed every tree shadow AND the player's own
356
+ // shadow from under their feet, while reporting a higher fps.
357
+ //
358
+ // So: sway as many as fit, leave the rest rigid. A slightly
359
+ // stale canopy shadow is invisible; a missing one is not.
360
+ let fol = if self.foliage_shadow_motion
361
+ && foliage_dynamic < MAX_FOLIAGE_DYNAMIC
362
+ {
363
+ let f = self.foliage_wind.get(&cmd.cache_handle).copied().unwrap_or(0.0);
364
+ if f > 0.0 { foliage_dynamic += 1; }
365
+ f
366
+ } else { 0.0 };
367
+ let (wmin, wmax) =
368
+ transform_aabb(&cmd.model, mesh.local_min, mesh.local_max);
369
+ shadow_nodes.push(ShadowDrawEntry {
370
+ vb_idx,
371
+ ib_idx: vb_idx,
372
+ index_start: 0,
373
+ index_count: mesh.index_count,
374
+ transform: cmd.model,
375
+ wmin,
376
+ wmax,
377
+ cutout_idx,
378
+ skinned: false,
379
+ // A swaying caster changes shape every frame, so it
380
+ // cannot share the cached static depth: signature goes
381
+ // to the per-frame nonce and it renders as dynamic.
382
+ // That is exactly the cost `foliage_shadow_motion`
383
+ // gates, which is why it defaults off.
384
+ sig: if fol > 0.0 { nonce }
385
+ else { entry_sig(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model) },
386
+ dynamic: fol > 0.0,
387
+ joint_offset: 0.0,
388
+ foliage: fol,
389
+ key: caster_id(1, cmd.cache_handle, cmd.mesh_idx as u64, &cmd.model),
390
+ });
391
+ }
392
+ }
393
+ }
394
+ }
395
+
396
+ // Material-system draws (terrain / building / trees rendered through
397
+ // compiled materials). Same GpuMesh cache as drawModel — the command
398
+ // carries a CPU-side copy of its model matrix precisely for this
399
+ // pass. `commands` holds only the opaque + cutout buckets, so water /
400
+ // glass / additive effects never cast. Instanced draws (the 20k-blade
401
+ // grass field) are skipped deliberately: vs_shadow has no instance
402
+ // stream, and per-blade grass shadows are sub-texel noise at these
403
+ // cascade resolutions anyway.
404
+ for cmd in self.material_system.commands.iter() {
405
+ if cmd.instance.is_some() { continue; }
406
+ if let Some(Some(meshes)) = self.model_gpu_cache.get(&cmd.mesh_handle) {
407
+ if cmd.mesh_idx < meshes.len() {
408
+ let mesh = &meshes[cmd.mesh_idx];
409
+ let vb_idx = shadow_vbs.len();
410
+ shadow_vbs.push(&mesh.vb);
411
+ shadow_ibs.push(&mesh.ib);
412
+ // MASK-material meshes (leaf cards) keep their dappled
413
+ // alpha-tested shadows, same as the other two paths.
414
+ let cutout_idx = match &mesh.shadow_cutout_bg {
415
+ Some(bg) => { let i = cutout_bgs.len(); cutout_bgs.push(bg); i as i32 }
416
+ None => -1,
417
+ };
418
+ let (wmin, wmax) =
419
+ transform_aabb(&cmd.model, mesh.local_min, mesh.local_max);
420
+ shadow_nodes.push(ShadowDrawEntry {
421
+ vb_idx,
422
+ ib_idx: vb_idx,
423
+ index_start: 0,
424
+ index_count: mesh.index_count,
425
+ transform: cmd.model,
426
+ wmin,
427
+ wmax,
428
+ cutout_idx,
429
+ skinned: false,
430
+ sig: entry_sig(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model),
431
+ dynamic: false,
432
+ joint_offset: 0.0,
433
+ foliage: 0.0,
434
+ key: caster_id(2, cmd.mesh_handle, cmd.mesh_idx as u64, &cmd.model),
435
+ });
436
+ }
437
+ }
438
+ }
439
+
440
+ // EN-043 — promote MOVERS to the dynamic set.
441
+ //
442
+ // A non-skinned cached caster whose transform changed since last frame used
443
+ // to stay in the STATIC set with a different content signature. That
444
+ // invalidated the cascade's cached depth, so every tree, wall and terrain
445
+ // tile in the world re-rendered into all three cascades — every frame —
446
+ // because one pickup was bobbing. Measured on the shooter's title screen:
447
+ // shadow_pass GPU 6.0-7.0 ms against the 0.1-1.7 ms the cache was built to
448
+ // deliver.
449
+ //
450
+ // A caster that moves is DYNAMIC, by definition. Dynamic casters draw on
451
+ // top of the cached static depth every frame and never invalidate it, which
452
+ // is exactly what a moving object needs and costs one draw instead of a
453
+ // thousand.
454
+ for e in shadow_nodes.iter_mut() {
455
+ if e.dynamic { continue; }
456
+ caster_ids_now.insert(e.key);
457
+ // Not here last frame at this exact transform => it moved (or is new).
458
+ // Either way: dynamic. A first-frame caster costs one extra dynamic draw
459
+ // and settles into the static set next frame.
460
+ if !prev_caster_ids.is_empty() && !prev_caster_ids.contains(&e.key) {
461
+ e.dynamic = true;
462
+ e.sig = nonce;
463
+ }
464
+ }
465
+
466
+ let cascade_planes: [[[f32; 4]; 6]; crate::shadows::NUM_CASCADES] =
467
+ std::array::from_fn(|c| {
468
+ crate::scene::extract_frustum_planes(&self.shadow_map.light_vps[c])
469
+ });
470
+ let mut cascade_indices: [Vec<usize>; crate::shadows::NUM_CASCADES] =
471
+ std::array::from_fn(|_| Vec::with_capacity(shadow_nodes.len()));
472
+ for (i, entry) in shadow_nodes.iter().enumerate() {
473
+ let has_bounds = entry.wmin[0] <= entry.wmax[0];
474
+ for c in 0..crate::shadows::NUM_CASCADES {
475
+ if has_bounds
476
+ && crate::scene::aabb_outside_frustum(&cascade_planes[c], entry.wmin, entry.wmax)
477
+ {
478
+ continue;
479
+ }
480
+ cascade_indices[c].push(i);
481
+ }
482
+ }
483
+ // Per-cascade STATIC content signature: fold every surviving
484
+ // non-dynamic caster's identity, in draw order. The static depth
485
+ // cache re-renders only when its cascade's VP changed, this
486
+ // signature changed, or a whole-pass invalidator fired. Dynamic
487
+ // casters are excluded — they draw on top of the cached static
488
+ // depth every frame and never invalidate it.
489
+ let mut cascade_sigs = [0u64; crate::shadows::NUM_CASCADES];
490
+ for c in 0..crate::shadows::NUM_CASCADES {
491
+ let mut h = FNV_OFFSET;
492
+ for &ei in cascade_indices[c].iter() {
493
+ if shadow_nodes[ei].dynamic { continue; }
494
+ h = fnv1a_bytes(h, &shadow_nodes[ei].sig.to_le_bytes());
495
+ }
496
+ cascade_sigs[c] = h;
497
+ }
498
+
499
+ // Render each cascade. Static casters live in a cached depth
500
+ // texture ("cached whole-scene shadows") re-rendered only when
501
+ // the cascade's VP or static content changes; every frame the
502
+ // live texture is refreshed by copy and the few dynamic casters
503
+ // draw on top with Load. A cascade with no change is skipped
504
+ // entirely. Uniform slots: static casters use the head of the
505
+ // cascade's region, dynamic casters the reserved tail — the
506
+ // ranges are disjoint because every write_buffer lands at
507
+ // submit, before any encoded pass executes.
508
+ for cascade in 0..crate::shadows::NUM_CASCADES {
509
+ let stride = crate::shadows::SHADOW_UNIFORM_STRIDE as usize;
510
+ let max = crate::shadows::SHADOW_MAX_NODES as usize;
511
+ let max_dynamic = crate::shadows::SHADOW_MAX_DYNAMIC as usize;
512
+ let max_static = max - max_dynamic;
513
+ let cascade_base = cascade * stride * max;
514
+ let cascade_vp = self.shadow_map.light_vps[cascade];
515
+ let entries = &cascade_indices[cascade];
516
+
517
+ let vp_changed = self.shadow_map.rendered_light_vps
518
+ .map(|vps| vps[cascade] != self.shadow_map.light_vps[cascade])
519
+ .unwrap_or(true);
520
+ let static_stale = force_all || vp_changed
521
+ || self.shadow_map.rendered_cascade_sig[cascade] != cascade_sigs[cascade];
522
+ let dyn_now = entries.iter().any(|&ei| shadow_nodes[ei].dynamic);
523
+ if !static_stale && !dyn_now && !self.shadow_map.had_dynamic[cascade] {
524
+ // Live texture already holds exactly this content.
525
+ continue;
526
+ }
527
+
528
+ if static_stale {
529
+ let static_entries: Vec<usize> = entries.iter().copied()
530
+ .filter(|&ei| !shadow_nodes[ei].dynamic)
531
+ .take(max_static)
532
+ .collect();
533
+ let mut uniform_data: Vec<u8> =
534
+ vec![0u8; stride * static_entries.len().max(1)];
535
+ for (slot, &ei) in static_entries.iter().enumerate() {
536
+ let uniforms = crate::shadows::ShadowUniforms {
537
+ light_vp: cascade_vp,
538
+ model: shadow_nodes[ei].transform,
539
+ misc: [shadow_nodes[ei].joint_offset, 0.0, shadow_nodes[ei].foliage, 0.0],
540
+ wind: self.lighting_uniforms.wind,
541
+ };
542
+ let off = slot * stride;
543
+ uniform_data[off..off + std::mem::size_of::<crate::shadows::ShadowUniforms>()]
544
+ .copy_from_slice(bytemuck::bytes_of(&uniforms));
545
+ }
546
+ // Each cascade owns its own slice of the uniform buffer —
547
+ // all write_buffer calls execute at submit, BEFORE any of
548
+ // the encoded passes run, so sharing one region would
549
+ // leave every cascade rendering with the last cascade's
550
+ // matrices (the no-near-shadows bug).
551
+ if !static_entries.is_empty() {
552
+ self.queue.write_buffer(
553
+ &self.shadow_map.uniform_buffer,
554
+ cascade_base as u64,
555
+ &uniform_data[..static_entries.len() * stride],
556
+ );
557
+ }
558
+ {
559
+ let shadow_ts = profiler.pass_timestamp_writes("shadow_pass");
560
+ let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
561
+ label: Some("shadow_pass_static"),
562
+ color_attachments: &[],
563
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
564
+ view: &self.shadow_map.static_depth_views[cascade],
565
+ depth_ops: Some(wgpu::Operations {
566
+ load: wgpu::LoadOp::Clear(1.0),
567
+ store: wgpu::StoreOp::Store,
568
+ }),
569
+ stencil_ops: None,
570
+ }),
571
+ timestamp_writes: shadow_ts,
572
+ occlusion_query_set: None,
573
+ multiview_mask: None,
574
+ });
575
+
576
+ // Pipeline kind per caster: 0 opaque, 1 cutout, 2 skinned.
577
+ // Only switch when the kind changes.
578
+ let mut cur_kind: u8 = 0;
579
+ shadow_pass.set_pipeline(&self.shadow_map.pipeline);
580
+ for (slot, &ei) in static_entries.iter().enumerate() {
581
+ let entry = &shadow_nodes[ei];
582
+ let offset = (cascade_base + slot * stride) as u32;
583
+ let kind: u8 = if entry.skinned { 2 }
584
+ else if entry.cutout_idx >= 0 { 1 }
585
+ else { 0 };
586
+ if kind != cur_kind {
587
+ shadow_pass.set_pipeline(match kind {
588
+ 1 => &self.shadow_map.pipeline_cutout,
589
+ 2 => &self.shadow_map.pipeline_skinned,
590
+ _ => &self.shadow_map.pipeline,
591
+ });
592
+ cur_kind = kind;
593
+ }
594
+ shadow_pass.set_bind_group(0, &self.shadow_map.uniform_bind_group, &[offset]);
595
+ if kind == 1 {
596
+ shadow_pass.set_bind_group(1, cutout_bgs[entry.cutout_idx as usize], &[]);
597
+ } else if kind == 2 {
598
+ shadow_pass.set_bind_group(1, &self.joint_bind_group, &[]);
599
+ }
600
+ shadow_pass.set_vertex_buffer(0, shadow_vbs[entry.vb_idx].slice(..));
601
+ shadow_pass.set_index_buffer(shadow_ibs[entry.ib_idx].slice(..), wgpu::IndexFormat::Uint32);
602
+ shadow_pass.draw_indexed(
603
+ entry.index_start..entry.index_start + entry.index_count,
604
+ 0,
605
+ 0..1,
606
+ );
607
+ }
608
+ }
609
+ self.shadow_map.rendered_cascade_sig[cascade] = cascade_sigs[cascade];
610
+ }
611
+
612
+ // Refresh the live texture from the static cache, then draw
613
+ // dynamic casters on top.
614
+ encoder.copy_texture_to_texture(
615
+ self.shadow_map.static_depth_textures[cascade].as_image_copy(),
616
+ self.shadow_map.depth_textures[cascade].as_image_copy(),
617
+ wgpu::Extent3d {
618
+ width: crate::shadows::CASCADE_MAP_SIZE,
619
+ height: crate::shadows::CASCADE_MAP_SIZE,
620
+ depth_or_array_layers: 1,
621
+ },
622
+ );
623
+ if dyn_now {
624
+ let dyn_base = cascade_base + stride * max_static;
625
+ // EN-042 — the dynamic budget can overflow, and the overflow IS
626
+ // dropped. Which caster gets dropped must not be an accident of
627
+ // queue order. It was, and it cost this project twice: both times
628
+ // the thing that silently vanished was the player's own shadow, and
629
+ // both times the frame rate went UP and looked like a win.
630
+ //
631
+ // Rank them, so if we must lose a shadow we lose one nobody misses:
632
+ // characters first (the shadow a player actually looks at), then
633
+ // other movers, then foliage — a swaying canopy shadow is soft and
634
+ // dappled and the most forgiving thing in the frame.
635
+ let mut dyn_entries: Vec<usize> = entries.iter().copied()
636
+ .filter(|&ei| shadow_nodes[ei].dynamic)
637
+ .collect();
638
+ if dyn_entries.len() > max_dynamic {
639
+ dyn_entries.sort_by_key(|&ei| {
640
+ let e = &shadow_nodes[ei];
641
+ if e.skinned { 0u8 }
642
+ else if e.foliage > 0.0 { 2u8 }
643
+ else { 1u8 }
644
+ });
645
+ dyn_entries.truncate(max_dynamic);
646
+ }
647
+ let mut uniform_data: Vec<u8> =
648
+ vec![0u8; stride * dyn_entries.len().max(1)];
649
+ for (slot, &ei) in dyn_entries.iter().enumerate() {
650
+ let uniforms = crate::shadows::ShadowUniforms {
651
+ light_vp: cascade_vp,
652
+ model: shadow_nodes[ei].transform,
653
+ misc: [shadow_nodes[ei].joint_offset, 0.0, shadow_nodes[ei].foliage, 0.0],
654
+ wind: self.lighting_uniforms.wind,
655
+ };
656
+ let off = slot * stride;
657
+ uniform_data[off..off + std::mem::size_of::<crate::shadows::ShadowUniforms>()]
658
+ .copy_from_slice(bytemuck::bytes_of(&uniforms));
659
+ }
660
+ self.queue.write_buffer(
661
+ &self.shadow_map.uniform_buffer,
662
+ dyn_base as u64,
663
+ &uniform_data[..dyn_entries.len() * stride],
664
+ );
665
+ {
666
+ let shadow_ts = profiler.pass_timestamp_writes("shadow_pass");
667
+ let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
668
+ label: Some("shadow_pass_dynamic"),
669
+ color_attachments: &[],
670
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
671
+ view: &self.shadow_map.depth_views[cascade],
672
+ depth_ops: Some(wgpu::Operations {
673
+ // Refreshed static depth is the base.
674
+ load: wgpu::LoadOp::Load,
675
+ store: wgpu::StoreOp::Store,
676
+ }),
677
+ stencil_ops: None,
678
+ }),
679
+ timestamp_writes: shadow_ts,
680
+ occlusion_query_set: None,
681
+ multiview_mask: None,
682
+ });
683
+ let mut cur_kind: u8 = 0;
684
+ shadow_pass.set_pipeline(&self.shadow_map.pipeline);
685
+ for (slot, &ei) in dyn_entries.iter().enumerate() {
686
+ let entry = &shadow_nodes[ei];
687
+ let offset = (dyn_base + slot * stride) as u32;
688
+ let kind: u8 = if entry.skinned { 2 }
689
+ else if entry.cutout_idx >= 0 { 1 }
690
+ else { 0 };
691
+ if kind != cur_kind {
692
+ shadow_pass.set_pipeline(match kind {
693
+ 1 => &self.shadow_map.pipeline_cutout,
694
+ 2 => &self.shadow_map.pipeline_skinned,
695
+ _ => &self.shadow_map.pipeline,
696
+ });
697
+ cur_kind = kind;
698
+ }
699
+ shadow_pass.set_bind_group(0, &self.shadow_map.uniform_bind_group, &[offset]);
700
+ if kind == 1 {
701
+ shadow_pass.set_bind_group(1, cutout_bgs[entry.cutout_idx as usize], &[]);
702
+ } else if kind == 2 {
703
+ // Joint matrices for skinning the animated
704
+ // characters in the immediate-mode batch.
705
+ shadow_pass.set_bind_group(1, &self.joint_bind_group, &[]);
706
+ }
707
+ shadow_pass.set_vertex_buffer(0, shadow_vbs[entry.vb_idx].slice(..));
708
+ shadow_pass.set_index_buffer(shadow_ibs[entry.ib_idx].slice(..), wgpu::IndexFormat::Uint32);
709
+ shadow_pass.draw_indexed(
710
+ entry.index_start..entry.index_start + entry.index_count,
711
+ 0,
712
+ 0..1,
713
+ );
714
+ }
715
+ }
716
+ }
717
+ self.shadow_map.had_dynamic[cascade] = dyn_now;
718
+ }
719
+
720
+ // Cache bookkeeping — next frame skips every cascade whose VP
721
+ // and caster content stay put.
722
+ self.shadow_caster_tf = caster_ids_now;
723
+ self.shadow_map.rendered_light_vps = Some(self.shadow_map.light_vps);
724
+ self.shadow_map.rendered_light_dir = Some(light_dir);
725
+ self.shadow_map.rendered_scene_version = scene_ver;
726
+ self.shadow_map.dirty = false;
727
+ }
728
+
729
+ profiler.end("shadow_pass");
730
+ }
731
+ }