@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,1036 @@
1
+ //! Cascaded shadow mapping (CSM) for Bloom Engine.
2
+ //!
3
+ //! Implements 3-cascade directional light shadow mapping with PCF
4
+ //! (Percentage-Closer Filtering). The camera frustum is split into
5
+ //! near/mid/far slices, each rendered from the light's perspective into
6
+ //! its own depth texture. The scene shader selects the tightest cascade
7
+ //! for each fragment, giving high shadow resolution near the camera and
8
+ //! coverage out to the far plane.
9
+
10
+ use crate::renderer::IDENTITY_MAT4;
11
+
12
+ /// Number of shadow cascades.
13
+ pub const NUM_CASCADES: usize = 3;
14
+ /// Per-cascade shadow map resolution. Back to 2048 for desktop targets:
15
+ /// the 1024 cut was made chasing 60 fps on the Sponza benchmark machine
16
+ /// (integrated GPU); discrete desktop GPUs have shadow-pass headroom, and
17
+ /// at fullscreen native resolutions 1024 maps read visibly soft on
18
+ /// near-field edges. The normal-offset receiver bias and PCF radius are
19
+ /// texel-proportional, so both adapt to the size automatically.
20
+ pub const CASCADE_MAP_SIZE: u32 = 2048;
21
+ pub const SHADOW_NEAR: f32 = 0.1;
22
+ pub const SHADOW_FAR: f32 = 100.0;
23
+ /// Dynamic-uniform buffer stride for per-node shadow uniforms. Must
24
+ /// be >= sizeof(ShadowUniforms) (144B) and a multiple of the device's
25
+ /// min_uniform_buffer_offset_alignment. 256 is safe on every platform.
26
+ pub const SHADOW_UNIFORM_STRIDE: u32 = 256;
27
+ pub const SHADOW_MAX_NODES: u32 = 1024;
28
+ /// Slots at the TAIL of each cascade's uniform region reserved for
29
+ /// dynamic casters, which re-render every frame while static casters keep their
30
+ /// cached depth. Disjoint slot ranges keep the every-frame dynamic writes from
31
+ /// clobbering the uniforms the static render was encoded against (all
32
+ /// `write_buffer`s land at submit, before any pass executes).
33
+ ///
34
+ /// EN-042 — raised from 64. Sixty-four was fine while "dynamic" meant a handful of
35
+ /// characters, and became a trap the moment a *forest* could go dynamic: 88 trees x
36
+ /// 4 primitives is 352 casters, the overflow was dropped in queue order, and what
37
+ /// disappeared was whatever happened to be last — twice this session, that was the
38
+ /// player's own shadow from under their feet. 256 covers a moving crowd; the drop is
39
+ /// also RANKED now (see shadow_pass.rs) so an overflow costs a canopy shadow rather
40
+ /// than a character's.
41
+ pub const SHADOW_MAX_DYNAMIC: u32 = 256;
42
+
43
+ /// Depth-only shader for shadow pass.
44
+ pub const SHADOW_SHADER: &str = concat!(
45
+ include_str!("../shaders/common/foliage_wind.wgsl"),
46
+ r#"
47
+ struct ShadowUniforms {
48
+ light_vp: mat4x4<f32>,
49
+ model: mat4x4<f32>,
50
+ misc: vec4<f32>, // x = joint offset (skinned variant), z = foliage wind amount
51
+ wind: vec4<f32>, // xy = dir, z = amplitude, w = time
52
+ };
53
+
54
+ @group(0) @binding(0) var<uniform> shadow_u: ShadowUniforms;
55
+
56
+ struct ShadowVertexInput {
57
+ @location(0) position: vec3<f32>,
58
+ @location(1) normal: vec3<f32>,
59
+ @location(2) color: vec4<f32>,
60
+ @location(3) uv: vec2<f32>,
61
+ @location(4) joints: vec4<f32>,
62
+ @location(5) weights: vec4<f32>,
63
+ };
64
+
65
+ @vertex
66
+ fn vs_shadow(in: ShadowVertexInput) -> @builtin(position) vec4<f32> {
67
+ // is_leaf = 0: this pipeline draws the opaque casters (trunks, branches).
68
+ let p = foliage_wind_local(in.position, shadow_u.model, shadow_u.wind, shadow_u.misc.z, 0.0);
69
+ let world_pos = shadow_u.model * vec4<f32>(p, 1.0);
70
+ return shadow_u.light_vp * world_pos;
71
+ }
72
+ "#);
73
+
74
+ /// Alpha-tested shadow shader for cutout foliage (trees, grass, leaves). Same
75
+ /// depth-only output as SHADOW_SHADER but samples the caster's base-colour
76
+ /// alpha and discards below the material cutoff, so cutout cards cast their
77
+ /// real shape (dappled light) instead of an opaque billboard blob. Used by a
78
+ /// dedicated pipeline; the opaque shadow path stays untouched.
79
+ pub const SHADOW_SHADER_CUTOUT: &str = concat!(
80
+ include_str!("../shaders/common/foliage_wind.wgsl"),
81
+ r#"
82
+ struct ShadowUniforms {
83
+ light_vp: mat4x4<f32>,
84
+ model: mat4x4<f32>,
85
+ misc: vec4<f32>, // x = joint offset (skinned variant), z = foliage wind amount
86
+ wind: vec4<f32>, // xy = dir, z = amplitude, w = time
87
+ };
88
+ @group(0) @binding(0) var<uniform> shadow_u: ShadowUniforms;
89
+
90
+ struct CutoutUniforms { cutoff: vec4<f32> }; // x = alpha cutoff
91
+ @group(1) @binding(0) var base_tex: texture_2d<f32>;
92
+ @group(1) @binding(1) var base_samp: sampler;
93
+ @group(1) @binding(2) var<uniform> cut: CutoutUniforms;
94
+
95
+ struct ShadowVertexInput {
96
+ @location(0) position: vec3<f32>,
97
+ @location(1) normal: vec3<f32>,
98
+ @location(2) color: vec4<f32>,
99
+ @location(3) uv: vec2<f32>,
100
+ @location(4) joints: vec4<f32>,
101
+ @location(5) weights: vec4<f32>,
102
+ };
103
+ struct VsOut {
104
+ @builtin(position) pos: vec4<f32>,
105
+ @location(0) uv: vec2<f32>,
106
+ };
107
+
108
+
109
+ @vertex
110
+ fn vs_shadow_cutout(in: ShadowVertexInput) -> VsOut {
111
+ var o: VsOut;
112
+ // is_leaf = 1: this pipeline draws the cutout cards, so they get the fast
113
+ // flutter layer -- and their shadows now flutter with them.
114
+ let p = foliage_wind_local(in.position, shadow_u.model, shadow_u.wind, shadow_u.misc.z, 1.0);
115
+ let world_pos = shadow_u.model * vec4<f32>(p, 1.0);
116
+ o.pos = shadow_u.light_vp * world_pos;
117
+ o.uv = in.uv;
118
+ return o;
119
+ }
120
+
121
+ @fragment
122
+ fn fs_shadow_cutout(in: VsOut) {
123
+ let a = textureSample(base_tex, base_samp, in.uv).a;
124
+ if (a < cut.cutoff.x) { discard; }
125
+ }
126
+ "#);
127
+
128
+ /// Skinned shadow shader for animated characters (player, enemies). Their
129
+ /// vertices are *rest-pose* (cached model VBs with raw joint indices, or the
130
+ /// immediate-mode batch with pre-offset ones), with world placement living
131
+ /// entirely in the per-frame joint matrices. The plain `vs_shadow` doesn't
132
+ /// skin, so it would render those characters as a rest pose at the world
133
+ /// origin — i.e. no shadow under their feet. This variant skins per-vertex
134
+ /// (same math as the main scene shader) so characters cast a real, posed
135
+ /// shadow. The branch on total weight means the non-skinned verts sharing a
136
+ /// batch (ground cube, rigid accessories) still transform by the model
137
+ /// matrix as before.
138
+ pub const SHADOW_SHADER_SKINNED: &str = "
139
+ struct ShadowUniforms {
140
+ light_vp: mat4x4<f32>,
141
+ model: mat4x4<f32>,
142
+ misc: vec4<f32>, // x = joint offset for cached skinned casters
143
+ };
144
+ @group(0) @binding(0) var<uniform> shadow_u: ShadowUniforms;
145
+
146
+ struct JointMatrices { matrices: array<mat4x4<f32>, 1024> };
147
+ @group(1) @binding(0) var<uniform> joints: JointMatrices;
148
+
149
+ struct ShadowVertexInput {
150
+ @location(0) position: vec3<f32>,
151
+ @location(1) normal: vec3<f32>,
152
+ @location(2) color: vec4<f32>,
153
+ @location(3) uv: vec2<f32>,
154
+ @location(4) joints: vec4<f32>,
155
+ @location(5) weights: vec4<f32>,
156
+ };
157
+
158
+ @vertex
159
+ fn vs_shadow_skinned(in: ShadowVertexInput) -> @builtin(position) vec4<f32> {
160
+ let total_weight = in.weights.x + in.weights.y + in.weights.z + in.weights.w;
161
+ var pos = vec4<f32>(in.position, 1.0);
162
+ if (total_weight > 0.01) {
163
+ // misc.x = joint-buffer base offset for cached skinned casters
164
+ // (raw VB indices); 0 for the immediate batch (pre-offset).
165
+ let j0 = u32(in.joints.x + shadow_u.misc.x); let j1 = u32(in.joints.y + shadow_u.misc.x);
166
+ let j2 = u32(in.joints.z + shadow_u.misc.x); let j3 = u32(in.joints.w + shadow_u.misc.x);
167
+ // Joint matrices already bake scale + world position + rotation, so the
168
+ // skinned result is world-space; the model matrix is identity for the
169
+ // immediate-mode batch and is intentionally not re-applied here.
170
+ pos = joints.matrices[j0] * pos * in.weights.x
171
+ + joints.matrices[j1] * pos * in.weights.y
172
+ + joints.matrices[j2] * pos * in.weights.z
173
+ + joints.matrices[j3] * pos * in.weights.w;
174
+ return shadow_u.light_vp * pos;
175
+ }
176
+ let world_pos = shadow_u.model * pos;
177
+ return shadow_u.light_vp * world_pos;
178
+ }
179
+ ";
180
+
181
+ /// Uniform data for the shadow pass.
182
+ #[repr(C)]
183
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
184
+ pub struct ShadowUniforms {
185
+ pub light_vp: [[f32; 4]; 4],
186
+ pub model: [[f32; 4]; 4],
187
+ /// x = joint-buffer base offset for skinned CACHED casters (their
188
+ /// VBs keep raw joint indices; `vs_shadow_skinned` adds this before
189
+ /// indexing). 0 for everything else, including the immediate batch
190
+ /// whose vertex joints are pre-offset CPU-side.
191
+ /// z = foliage wind amount for this caster (0 = rigid). A tree that bends in
192
+ /// the scene pass but not in the shadow pass detaches from its own shadow.
193
+ /// yw unused.
194
+ pub misc: [f32; 4],
195
+ /// Global wind: xy = direction in XZ, z = amplitude, w = elapsed seconds.
196
+ /// The shadow pass needs it for the same reason the scene pass does.
197
+ pub wind: [f32; 4],
198
+ }
199
+
200
+ /// Shadow map resources for cascaded shadow mapping.
201
+ pub struct ShadowMap {
202
+ pub depth_textures: [wgpu::Texture; NUM_CASCADES],
203
+ pub depth_views: [wgpu::TextureView; NUM_CASCADES],
204
+ /// Cached static-caster depth per cascade ("cached whole-scene
205
+ /// shadows"): scene nodes / cached models / material draws render in
206
+ /// here only when the cascade's VP or static content changes. Every
207
+ /// frame the live cascade texture starts as a copy of this and only
208
+ /// dynamic casters (the immediate batch: animated characters,
209
+ /// moving primitives) are drawn on top — so one animated model no
210
+ /// longer forces the whole forest to re-render three times.
211
+ pub static_depth_textures: [wgpu::Texture; NUM_CASCADES],
212
+ pub static_depth_views: [wgpu::TextureView; NUM_CASCADES],
213
+ pub sampler: wgpu::Sampler,
214
+ pub bind_group_layout: wgpu::BindGroupLayout,
215
+ pub bind_group: wgpu::BindGroup,
216
+ pub pipeline: wgpu::RenderPipeline,
217
+ /// Alpha-tested variant for cutout foliage casters. Opaque casters keep
218
+ /// using `pipeline` (byte-identical to before this was added).
219
+ pub pipeline_cutout: wgpu::RenderPipeline,
220
+ /// Skinning-aware variant for the immediate-mode batch (animated
221
+ /// characters). Binds the joint-matrix buffer at group 1 and skins
222
+ /// per-vertex so player/enemies cast a real posed shadow.
223
+ pub pipeline_skinned: wgpu::RenderPipeline,
224
+ /// Group-1 layout for `pipeline_cutout`: base-colour tex + sampler + a
225
+ /// cutoff uniform. Per-mesh bind groups are built against this in
226
+ /// `cache_model_if_static`.
227
+ pub cutout_tex_layout: wgpu::BindGroupLayout,
228
+ pub uniform_buffer: wgpu::Buffer,
229
+ pub uniform_bind_group: wgpu::BindGroup,
230
+ pub uniform_layout: wgpu::BindGroupLayout,
231
+ pub light_vps: [[[f32; 4]; 4]; NUM_CASCADES],
232
+ /// View-space Z split distances for each cascade. Cascade i covers
233
+ /// [cascade_splits[i-1], cascade_splits[i]]; cascade 0 starts at near.
234
+ pub cascade_splits: [f32; NUM_CASCADES],
235
+ pub enabled: bool,
236
+ /// Forces a shadow re-render next frame. Set by `invalidate()`
237
+ /// (on light direction change, `setShadowsEnabled(true)`, resize,
238
+ /// shadow-texture aliasing, etc.). Cleared after a render.
239
+ pub dirty: bool,
240
+ /// Escape hatch for games with continuously-changing light state
241
+ /// where the cache hit rate would be ~zero anyway. When true, every
242
+ /// frame renders shadows; the cache is bypassed.
243
+ pub always_fresh: bool,
244
+ /// Cascade VPs that correspond to the contents currently stored in
245
+ /// the depth textures. `None` before the first render. When the
246
+ /// freshly-computed VPs match this byte-for-byte AND nothing else
247
+ /// has invalidated, we can skip the render entirely and sample the
248
+ /// retained depth textures. Texel-snapping + radius quantization in
249
+ /// `compute_cascade_vps` means identical camera poses produce
250
+ /// identical VPs, so this check is robust.
251
+ pub rendered_light_vps: Option<[[[f32; 4]; 4]; NUM_CASCADES]>,
252
+ /// Light direction used for the current depth-texture contents.
253
+ /// Checked at the cache gate rather than on the setter because
254
+ /// `begin_frame` resets `lighting_uniforms` to defaults every
255
+ /// frame — comparing a setter's old-vs-new would always see the
256
+ /// default as the "old" value and invalidate every frame.
257
+ pub rendered_light_dir: Option<[f32; 3]>,
258
+ /// Scene-graph version counter sampled at the last shadow render.
259
+ /// `SceneGraph::shadow_version` increments whenever a shadow-casting
260
+ /// node's transform / cast_shadow / visibility / geometry changes;
261
+ /// a mismatch here forces a re-render.
262
+ pub rendered_scene_version: u64,
263
+ /// Shadow-flicker fix — per-cascade accepted pancake extents
264
+ /// (back, far). Animated caster bounds drift a few centimetres per
265
+ /// frame, and the raw 1/16 m ceil() quantization made the fitted VP
266
+ /// toggle between two matrices whenever that drift straddled a
267
+ /// step. The accepted extent grows immediately (casters must stay
268
+ /// inside the volume) but shrinks only when the raw need has
269
+ /// dropped well below it — so idle animations stop re-fitting the
270
+ /// cascades every few frames.
271
+ pancake_hysteresis: [[f32; 2]; NUM_CASCADES],
272
+ /// Per-cascade STATIC caster-content signature at the last render
273
+ /// of that cascade's static depth texture (hash of every
274
+ /// non-immediate caster that passed its frustum filter: identity +
275
+ /// transform). 0 = never rendered. Together with a per-cascade VP
276
+ /// compare this decides when the static cache must re-render — the
277
+ /// whole-pass cache above only ever hit for fully-retained scenes.
278
+ pub rendered_cascade_sig: [u64; NUM_CASCADES],
279
+ /// Whether the live cascade texture currently contains dynamic
280
+ /// casters. A cascade whose dynamics all left still needs one
281
+ /// refresh copy to clear their stale shadows.
282
+ pub had_dynamic: [bool; NUM_CASCADES],
283
+ /// Monotonic counter folded into animated casters' signatures so
284
+ /// any cascade containing one re-renders every frame.
285
+ pub frame_nonce: u64,
286
+ /// Cascade re-fit slack — the accepted ortho fit per cascade
287
+ /// (cascades ≥ 1). While the freshly-required bounding sphere and
288
+ /// pancake extents still fit inside the accepted volume, the
289
+ /// cascade keeps its previous VP byte-for-byte, so camera travel
290
+ /// of a few metres no longer invalidates the far cascades' cached
291
+ /// depth. Accepted fits are inflated by `REFIT_SLACK` (the
292
+ /// resolution cost of that inflation is bounded and uniform; the
293
+ /// near cascade is exempt and stays exact-fit).
294
+ accepted_fit: [Option<AcceptedFit>; NUM_CASCADES],
295
+ accepted_light_dir: Option<[f32; 3]>,
296
+ }
297
+
298
+ /// Accepted (slack-inflated) ortho fit for one cascade. `ls_x`/`ls_y`
299
+ /// are the snapped center in the light-plane basis; `radius` is the
300
+ /// final ortho half-extent; `back`/`far` the accepted pancake extents
301
+ /// along the light axis relative to `center`.
302
+ #[derive(Copy, Clone)]
303
+ struct AcceptedFit {
304
+ ls_x: f32,
305
+ ls_y: f32,
306
+ center: [f32; 3],
307
+ radius: f32,
308
+ back: f32,
309
+ far: f32,
310
+ }
311
+
312
+ /// Re-fit slack factor for cascades ≥ 1. 15% larger ortho extent buys
313
+ /// ~0.15 × radius of camera travel between re-fits (≈ 4-5 m on a far
314
+ /// cascade) at the cost of ~15% coarser texels on the mid/far cascades
315
+ /// only. PCF radius and the normal-offset receiver bias are
316
+ /// texel-proportional, so the softening stays coherent (no acne).
317
+ const REFIT_SLACK: f32 = 1.15;
318
+
319
+ impl ShadowMap {
320
+ pub fn new(
321
+ device: &wgpu::Device,
322
+ vertex_layout: wgpu::VertexBufferLayout<'static>,
323
+ joint_layout: &wgpu::BindGroupLayout,
324
+ ) -> Self {
325
+ // Create NUM_CASCADES depth textures (live + static cache).
326
+ let mut depth_textures_vec: Vec<wgpu::Texture> = Vec::new();
327
+ let mut depth_views_vec: Vec<wgpu::TextureView> = Vec::new();
328
+ let mut static_textures_vec: Vec<wgpu::Texture> = Vec::new();
329
+ let mut static_views_vec: Vec<wgpu::TextureView> = Vec::new();
330
+ for i in 0..NUM_CASCADES {
331
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
332
+ label: Some(&format!("shadow_depth_cascade_{}", i)),
333
+ size: wgpu::Extent3d {
334
+ width: CASCADE_MAP_SIZE,
335
+ height: CASCADE_MAP_SIZE,
336
+ depth_or_array_layers: 1,
337
+ },
338
+ mip_level_count: 1,
339
+ sample_count: 1,
340
+ dimension: wgpu::TextureDimension::D2,
341
+ format: wgpu::TextureFormat::Depth32Float,
342
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT
343
+ | wgpu::TextureUsages::TEXTURE_BINDING
344
+ | wgpu::TextureUsages::COPY_SRC
345
+ // Refreshed from the static cache before dynamic
346
+ // casters draw on top.
347
+ | wgpu::TextureUsages::COPY_DST,
348
+ view_formats: &[],
349
+ });
350
+ let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
351
+ depth_textures_vec.push(tex);
352
+ depth_views_vec.push(view);
353
+ let stex = device.create_texture(&wgpu::TextureDescriptor {
354
+ label: Some(&format!("shadow_static_depth_cascade_{}", i)),
355
+ size: wgpu::Extent3d {
356
+ width: CASCADE_MAP_SIZE,
357
+ height: CASCADE_MAP_SIZE,
358
+ depth_or_array_layers: 1,
359
+ },
360
+ mip_level_count: 1,
361
+ sample_count: 1,
362
+ dimension: wgpu::TextureDimension::D2,
363
+ format: wgpu::TextureFormat::Depth32Float,
364
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT
365
+ | wgpu::TextureUsages::COPY_SRC,
366
+ view_formats: &[],
367
+ });
368
+ let sview = stex.create_view(&wgpu::TextureViewDescriptor::default());
369
+ static_textures_vec.push(stex);
370
+ static_views_vec.push(sview);
371
+ }
372
+
373
+ // Convert Vecs to fixed-size arrays
374
+ let depth_textures: [wgpu::Texture; NUM_CASCADES] =
375
+ depth_textures_vec.try_into().unwrap_or_else(|_| panic!("cascade texture count mismatch"));
376
+ let depth_views: [wgpu::TextureView; NUM_CASCADES] =
377
+ depth_views_vec.try_into().unwrap_or_else(|_| panic!("cascade view count mismatch"));
378
+ let static_depth_textures: [wgpu::Texture; NUM_CASCADES] =
379
+ static_textures_vec.try_into().unwrap_or_else(|_| panic!("static cascade texture count mismatch"));
380
+ let static_depth_views: [wgpu::TextureView; NUM_CASCADES] =
381
+ static_views_vec.try_into().unwrap_or_else(|_| panic!("static cascade view count mismatch"));
382
+
383
+ // Comparison sampler for PCF
384
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
385
+ label: Some("shadow_sampler"),
386
+ compare: Some(wgpu::CompareFunction::LessEqual),
387
+ mag_filter: wgpu::FilterMode::Linear,
388
+ min_filter: wgpu::FilterMode::Linear,
389
+ ..Default::default()
390
+ });
391
+
392
+ // Bind group layout for sampling shadow maps in the main pass:
393
+ // 3 depth textures (bindings 0,1,2) + 1 comparison sampler (binding 3)
394
+ let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
395
+ label: Some("shadow_sample_layout"),
396
+ entries: &[
397
+ wgpu::BindGroupLayoutEntry {
398
+ binding: 0,
399
+ visibility: wgpu::ShaderStages::FRAGMENT,
400
+ ty: wgpu::BindingType::Texture {
401
+ sample_type: wgpu::TextureSampleType::Depth,
402
+ view_dimension: wgpu::TextureViewDimension::D2,
403
+ multisampled: false,
404
+ },
405
+ count: None,
406
+ },
407
+ wgpu::BindGroupLayoutEntry {
408
+ binding: 1,
409
+ visibility: wgpu::ShaderStages::FRAGMENT,
410
+ ty: wgpu::BindingType::Texture {
411
+ sample_type: wgpu::TextureSampleType::Depth,
412
+ view_dimension: wgpu::TextureViewDimension::D2,
413
+ multisampled: false,
414
+ },
415
+ count: None,
416
+ },
417
+ wgpu::BindGroupLayoutEntry {
418
+ binding: 2,
419
+ visibility: wgpu::ShaderStages::FRAGMENT,
420
+ ty: wgpu::BindingType::Texture {
421
+ sample_type: wgpu::TextureSampleType::Depth,
422
+ view_dimension: wgpu::TextureViewDimension::D2,
423
+ multisampled: false,
424
+ },
425
+ count: None,
426
+ },
427
+ wgpu::BindGroupLayoutEntry {
428
+ binding: 3,
429
+ visibility: wgpu::ShaderStages::FRAGMENT,
430
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
431
+ count: None,
432
+ },
433
+ ],
434
+ });
435
+
436
+ let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
437
+ label: Some("shadow_sample_bg"),
438
+ layout: &bind_group_layout,
439
+ entries: &[
440
+ wgpu::BindGroupEntry {
441
+ binding: 0,
442
+ resource: wgpu::BindingResource::TextureView(&depth_views[0]),
443
+ },
444
+ wgpu::BindGroupEntry {
445
+ binding: 1,
446
+ resource: wgpu::BindingResource::TextureView(&depth_views[1]),
447
+ },
448
+ wgpu::BindGroupEntry {
449
+ binding: 2,
450
+ resource: wgpu::BindingResource::TextureView(&depth_views[2]),
451
+ },
452
+ wgpu::BindGroupEntry {
453
+ binding: 3,
454
+ resource: wgpu::BindingResource::Sampler(&sampler),
455
+ },
456
+ ],
457
+ });
458
+
459
+ // Shadow pass uniform layout (dynamic offset for per-node)
460
+ let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
461
+ label: Some("shadow_uniform_layout"),
462
+ entries: &[wgpu::BindGroupLayoutEntry {
463
+ binding: 0,
464
+ visibility: wgpu::ShaderStages::VERTEX,
465
+ ty: wgpu::BindingType::Buffer {
466
+ ty: wgpu::BufferBindingType::Uniform,
467
+ has_dynamic_offset: true,
468
+ min_binding_size: std::num::NonZeroU64::new(
469
+ std::mem::size_of::<ShadowUniforms>() as u64,
470
+ ),
471
+ },
472
+ count: None,
473
+ }],
474
+ });
475
+
476
+ // One region PER CASCADE. The pass encodes all three cascades before
477
+ // the queue submits, and `queue.write_buffer` executes at submit —
478
+ // sharing one region meant every cascade rendered with the LAST
479
+ // cascade's light_vp + models, killing shadows for everything that
480
+ // sampled cascades 0/1 (i.e. all near-camera receivers: the player
481
+ // and enemies never had shadows; distant trees kept theirs because
482
+ // cascade 2's write happened to be the surviving one).
483
+ let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
484
+ label: Some("shadow_uniform_buf"),
485
+ size: (SHADOW_UNIFORM_STRIDE * SHADOW_MAX_NODES * NUM_CASCADES as u32) as u64,
486
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
487
+ mapped_at_creation: false,
488
+ });
489
+
490
+ let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
491
+ label: Some("shadow_uniform_bg"),
492
+ layout: &uniform_layout,
493
+ entries: &[wgpu::BindGroupEntry {
494
+ binding: 0,
495
+ resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
496
+ buffer: &uniform_buffer,
497
+ offset: 0,
498
+ size: std::num::NonZeroU64::new(
499
+ std::mem::size_of::<ShadowUniforms>() as u64,
500
+ ),
501
+ }),
502
+ }],
503
+ });
504
+
505
+ // Shadow depth-only pipeline
506
+ let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
507
+ label: Some("shadow_shader"),
508
+ source: wgpu::ShaderSource::Wgsl(SHADOW_SHADER.into()),
509
+ });
510
+
511
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
512
+ label: Some("shadow_pipeline_layout"),
513
+ bind_group_layouts: &[Some(&uniform_layout)],
514
+ immediate_size: 0,
515
+ });
516
+
517
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
518
+ label: Some("shadow_pipeline"),
519
+ layout: Some(&pipeline_layout),
520
+ vertex: wgpu::VertexState {
521
+ module: &shader,
522
+ entry_point: Some("vs_shadow"),
523
+ buffers: &[vertex_layout.clone()],
524
+ compilation_options: Default::default(),
525
+ },
526
+ fragment: None, // depth only
527
+ primitive: wgpu::PrimitiveState {
528
+ topology: wgpu::PrimitiveTopology::TriangleList,
529
+ front_face: wgpu::FrontFace::Ccw,
530
+ cull_mode: None,
531
+ ..Default::default()
532
+ },
533
+ depth_stencil: Some(wgpu::DepthStencilState {
534
+ format: wgpu::TextureFormat::Depth32Float,
535
+ depth_write_enabled: Some(true),
536
+ depth_compare: Some(wgpu::CompareFunction::Less),
537
+ stencil: Default::default(),
538
+ bias: wgpu::DepthBiasState {
539
+ constant: 1,
540
+ slope_scale: 1.0,
541
+ clamp: 0.0,
542
+ },
543
+ }),
544
+ multisample: Default::default(),
545
+ multiview_mask: None,
546
+ cache: None,
547
+ });
548
+
549
+ // Cutout (alpha-tested) shadow pipeline. Separate so the opaque path
550
+ // above is untouched. Adds a group-1 texture/sampler/cutoff layout and
551
+ // a fragment stage that discards below the alpha cutoff. Still depth-
552
+ // only (no colour targets).
553
+ let cutout_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
554
+ label: Some("shadow_shader_cutout"),
555
+ source: wgpu::ShaderSource::Wgsl(SHADOW_SHADER_CUTOUT.into()),
556
+ });
557
+ let cutout_tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
558
+ label: Some("shadow_cutout_tex_layout"),
559
+ entries: &[
560
+ wgpu::BindGroupLayoutEntry {
561
+ binding: 0, visibility: wgpu::ShaderStages::FRAGMENT,
562
+ ty: wgpu::BindingType::Texture {
563
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
564
+ view_dimension: wgpu::TextureViewDimension::D2, multisampled: false,
565
+ },
566
+ count: None,
567
+ },
568
+ wgpu::BindGroupLayoutEntry {
569
+ binding: 1, visibility: wgpu::ShaderStages::FRAGMENT,
570
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
571
+ count: None,
572
+ },
573
+ wgpu::BindGroupLayoutEntry {
574
+ binding: 2, visibility: wgpu::ShaderStages::FRAGMENT,
575
+ ty: wgpu::BindingType::Buffer {
576
+ ty: wgpu::BufferBindingType::Uniform,
577
+ has_dynamic_offset: false, min_binding_size: None,
578
+ },
579
+ count: None,
580
+ },
581
+ ],
582
+ });
583
+ let cutout_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
584
+ label: Some("shadow_cutout_pipeline_layout"),
585
+ bind_group_layouts: &[Some(&uniform_layout), Some(&cutout_tex_layout)],
586
+ immediate_size: 0,
587
+ });
588
+ let pipeline_cutout = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
589
+ label: Some("shadow_pipeline_cutout"),
590
+ layout: Some(&cutout_pl_layout),
591
+ vertex: wgpu::VertexState {
592
+ module: &cutout_shader,
593
+ entry_point: Some("vs_shadow_cutout"),
594
+ buffers: &[vertex_layout.clone()],
595
+ compilation_options: Default::default(),
596
+ },
597
+ fragment: Some(wgpu::FragmentState {
598
+ module: &cutout_shader,
599
+ entry_point: Some("fs_shadow_cutout"),
600
+ targets: &[], // depth only
601
+ compilation_options: Default::default(),
602
+ }),
603
+ primitive: wgpu::PrimitiveState {
604
+ topology: wgpu::PrimitiveTopology::TriangleList,
605
+ front_face: wgpu::FrontFace::Ccw,
606
+ cull_mode: None,
607
+ ..Default::default()
608
+ },
609
+ depth_stencil: Some(wgpu::DepthStencilState {
610
+ format: wgpu::TextureFormat::Depth32Float,
611
+ depth_write_enabled: Some(true),
612
+ depth_compare: Some(wgpu::CompareFunction::Less),
613
+ stencil: Default::default(),
614
+ bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, clamp: 0.0 },
615
+ }),
616
+ multisample: Default::default(),
617
+ multiview_mask: None,
618
+ cache: None,
619
+ });
620
+
621
+ // Skinned (animated-character) shadow pipeline. Group 0 = the shared
622
+ // shadow uniforms (light_vp + model, dynamic offset); group 1 = the
623
+ // joint-matrix buffer. Depth-only, same bias as the opaque path.
624
+ let skinned_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
625
+ label: Some("shadow_shader_skinned"),
626
+ source: wgpu::ShaderSource::Wgsl(SHADOW_SHADER_SKINNED.into()),
627
+ });
628
+ let skinned_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
629
+ label: Some("shadow_skinned_pipeline_layout"),
630
+ bind_group_layouts: &[Some(&uniform_layout), Some(joint_layout)],
631
+ immediate_size: 0,
632
+ });
633
+ let pipeline_skinned = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
634
+ label: Some("shadow_pipeline_skinned"),
635
+ layout: Some(&skinned_pl_layout),
636
+ vertex: wgpu::VertexState {
637
+ module: &skinned_shader,
638
+ entry_point: Some("vs_shadow_skinned"),
639
+ buffers: &[vertex_layout],
640
+ compilation_options: Default::default(),
641
+ },
642
+ fragment: None, // depth only
643
+ primitive: wgpu::PrimitiveState {
644
+ topology: wgpu::PrimitiveTopology::TriangleList,
645
+ front_face: wgpu::FrontFace::Ccw,
646
+ cull_mode: None,
647
+ ..Default::default()
648
+ },
649
+ depth_stencil: Some(wgpu::DepthStencilState {
650
+ format: wgpu::TextureFormat::Depth32Float,
651
+ depth_write_enabled: Some(true),
652
+ depth_compare: Some(wgpu::CompareFunction::Less),
653
+ stencil: Default::default(),
654
+ bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, clamp: 0.0 },
655
+ }),
656
+ multisample: Default::default(),
657
+ multiview_mask: None,
658
+ cache: None,
659
+ });
660
+
661
+ Self {
662
+ depth_textures,
663
+ depth_views,
664
+ static_depth_textures,
665
+ static_depth_views,
666
+ sampler,
667
+ bind_group_layout,
668
+ bind_group,
669
+ pipeline,
670
+ pipeline_cutout,
671
+ pipeline_skinned,
672
+ cutout_tex_layout,
673
+ uniform_buffer,
674
+ uniform_bind_group,
675
+ uniform_layout,
676
+ light_vps: [IDENTITY_MAT4; NUM_CASCADES],
677
+ cascade_splits: [8.0, 25.0, 80.0],
678
+ enabled: false,
679
+ dirty: true,
680
+ always_fresh: false,
681
+ rendered_light_vps: None,
682
+ rendered_light_dir: None,
683
+ rendered_scene_version: 0,
684
+ pancake_hysteresis: [[0.0; 2]; NUM_CASCADES],
685
+ rendered_cascade_sig: [0; NUM_CASCADES],
686
+ had_dynamic: [false; NUM_CASCADES],
687
+ frame_nonce: 0,
688
+ accepted_fit: [None; NUM_CASCADES],
689
+ accepted_light_dir: None,
690
+ }
691
+ }
692
+
693
+ /// Force the next shadow pass to re-render the depth textures.
694
+ /// Called on `setShadowsEnabled(true)`, swap-chain resize, or any
695
+ /// other event that invalidates the cached cascade contents.
696
+ pub fn invalidate(&mut self) {
697
+ self.dirty = true;
698
+ self.rendered_light_vps = None;
699
+ self.rendered_light_dir = None;
700
+ self.rendered_cascade_sig = [0; NUM_CASCADES];
701
+ self.had_dynamic = [false; NUM_CASCADES];
702
+ self.accepted_fit = [None; NUM_CASCADES];
703
+ }
704
+
705
+ /// Compute cascade view-projection matrices by splitting the camera
706
+ /// frustum into NUM_CASCADES slices and fitting a tight ortho projection
707
+ /// around each slice from the light's perspective.
708
+ ///
709
+ /// `light_dir` points from the surface toward the light (the same
710
+ /// convention as the rest of the engine).
711
+ pub fn compute_cascade_vps(
712
+ &mut self,
713
+ light_dir: [f32; 3],
714
+ _camera_pos: [f32; 3],
715
+ camera_view: [[f32; 4]; 4],
716
+ camera_proj: [[f32; 4]; 4],
717
+ near: f32,
718
+ far: f32,
719
+ scene_bounds: Option<([f32; 3], [f32; 3])>,
720
+ ) {
721
+ let len = (light_dir[0] * light_dir[0]
722
+ + light_dir[1] * light_dir[1]
723
+ + light_dir[2] * light_dir[2])
724
+ .sqrt();
725
+ let d = if len > 1e-6 {
726
+ [light_dir[0] / len, light_dir[1] / len, light_dir[2] / len]
727
+ } else {
728
+ [0.0, 1.0, 0.0]
729
+ };
730
+
731
+ // Compute frustum split distances using practical split scheme
732
+ // (Nvidia GPU Gems 3, Chapter 10): blend of logarithmic and
733
+ // uniform split for stability.
734
+ let lambda = 0.5f32; // blend factor (0 = uniform, 1 = logarithmic)
735
+ let ratio = far / near;
736
+ let mut splits = [0.0f32; NUM_CASCADES + 1];
737
+ splits[0] = near;
738
+ for i in 1..NUM_CASCADES {
739
+ let p = i as f32 / NUM_CASCADES as f32;
740
+ let log_split = near * ratio.powf(p);
741
+ let uniform_split = near + (far - near) * p;
742
+ splits[i] = lambda * log_split + (1.0 - lambda) * uniform_split;
743
+ }
744
+ splits[NUM_CASCADES] = far;
745
+
746
+ // Store view-space Z split distances for shader cascade selection.
747
+ // cascade_splits[i] = far edge of cascade i.
748
+ for i in 0..NUM_CASCADES {
749
+ self.cascade_splits[i] = splits[i + 1];
750
+ }
751
+
752
+ // A light-direction change invalidates every accepted fit (the
753
+ // light-plane basis itself moves).
754
+ if self.accepted_light_dir != Some(d) {
755
+ self.accepted_fit = [None; NUM_CASCADES];
756
+ self.accepted_light_dir = Some(d);
757
+ }
758
+
759
+ // Light-space basis vectors for texel snapping
760
+ let up_hint = if d[1].abs() > 0.99 {
761
+ [1.0f32, 0.0, 0.0]
762
+ } else {
763
+ [0.0f32, 1.0, 0.0]
764
+ };
765
+ let right = normalize3([
766
+ up_hint[1] * d[2] - up_hint[2] * d[1],
767
+ up_hint[2] * d[0] - up_hint[0] * d[2],
768
+ up_hint[0] * d[1] - up_hint[1] * d[0],
769
+ ]);
770
+ let ortho_up = [
771
+ d[1] * right[2] - d[2] * right[1],
772
+ d[2] * right[0] - d[0] * right[2],
773
+ d[0] * right[1] - d[1] * right[0],
774
+ ];
775
+
776
+ for c in 0..NUM_CASCADES {
777
+ let c_near = splits[c];
778
+ let c_far = splits[c + 1];
779
+
780
+ // Frustum-slice corners for [c_near, c_far], computed DIRECTLY in
781
+ // view space from the camera FOV, then transformed to world by the
782
+ // (affine, well-conditioned) view inverse.
783
+ //
784
+ // We deliberately do NOT invert the perspective projection here.
785
+ // `mat4_perspective` uses the OpenGL [-1,1] NDC-z convention, so
786
+ // unprojecting its NDC clip corners handed the near plane a NEGATIVE
787
+ // homogeneous w — every corner then divided down onto the z=-1 plane
788
+ // and the frustum bounding sphere collapsed to ~0.12 m. The cascade
789
+ // ortho covered almost nothing, so terrain a few metres from the
790
+ // camera projected outside the shadow frustum and self-shadowed:
791
+ // the reported "moving dark patch" that scaled disproportionately
792
+ // with the camera. Half-extents per unit view depth come straight
793
+ // from the projection (no inversion, no convention hazard):
794
+ // tan(fovy/2) = 1 / proj[1][1]
795
+ // tan(fovy/2) * aspect = 1 / proj[0][0]
796
+ let inv_view = crate::renderer::mat4_invert(camera_view);
797
+ let half_w_per_d = 1.0 / camera_proj[0][0];
798
+ let half_h_per_d = 1.0 / camera_proj[1][1];
799
+ let mut world_corners = [[0.0f32; 3]; 8];
800
+ let mut ci = 0usize;
801
+ for &d in [c_near, c_far].iter() {
802
+ let hw = d * half_w_per_d;
803
+ let hh = d * half_h_per_d;
804
+ for &(sx, sy) in [(-1.0f32, -1.0f32), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)].iter() {
805
+ // View space looks down -Z, so this slice sits at z = -d.
806
+ let vp = [sx * hw, sy * hh, -d, 1.0];
807
+ let wc = crate::renderer::mat4_mul_vec4(&inv_view, &vp);
808
+ world_corners[ci] = [wc[0], wc[1], wc[2]];
809
+ ci += 1;
810
+ }
811
+ }
812
+
813
+ // Bounding sphere of this cascade's frustum slice. Sphere
814
+ // (not AABB) gives rotation-invariant extent so the ortho
815
+ // volume doesn't resize as the camera rotates.
816
+ let mut center = [0.0f32; 3];
817
+ for i in 0..8 {
818
+ center[0] += world_corners[i][0];
819
+ center[1] += world_corners[i][1];
820
+ center[2] += world_corners[i][2];
821
+ }
822
+ center[0] /= 8.0;
823
+ center[1] /= 8.0;
824
+ center[2] /= 8.0;
825
+ let mut radius: f32 = 0.0;
826
+ for i in 0..8 {
827
+ let dx = world_corners[i][0] - center[0];
828
+ let dy = world_corners[i][1] - center[1];
829
+ let dz = world_corners[i][2] - center[2];
830
+ let r2 = dx*dx + dy*dy + dz*dz;
831
+ if r2 > radius { radius = r2; }
832
+ }
833
+ radius = radius.sqrt();
834
+
835
+ // Re-fit slack (cascades ≥ 1): if the required sphere and
836
+ // pancake extents still fit inside the previously accepted
837
+ // (slack-inflated) ortho volume, keep the previous VP
838
+ // byte-identical. The per-cascade shadow cache compares VPs
839
+ // exactly, so a kept VP means the cascade's cached depth
840
+ // stays valid while the camera travels within the slack.
841
+ // EN-045 — cascade 0 gets the slack too.
842
+ //
843
+ // It was excluded, and that quietly made the whole static-shadow cache a
844
+ // title-screen feature. Cascade 0 is the NEAR cascade: it holds the
845
+ // player and everything they are standing next to. Re-fitting it every
846
+ // frame means its VP changes every frame the camera moves — which is all
847
+ // of gameplay — so its cached depth was thrown away and every static
848
+ // caster in it re-rendered, every frame. Measured: shadow_pass 0.12 ms on
849
+ // the stationary title screen, 3.2 ms in a moving fight.
850
+ //
851
+ // The slack costs ~15% of near-field shadow resolution and buys a cache
852
+ // that survives ~15 frames of walking instead of zero.
853
+ {
854
+ if let Some(acc) = self.accepted_fit[c] {
855
+ let ls_x = dot3(center, right);
856
+ let ls_y = dot3(center, ortho_up);
857
+ let fits_xy = (ls_x - acc.ls_x).abs() + radius <= acc.radius
858
+ && (ls_y - acc.ls_y).abs() + radius <= acc.radius;
859
+ // Required extents along the light axis, relative to
860
+ // the ACCEPTED center: the slice sphere plus the
861
+ // scene AABB corners (same needs the pancake fit
862
+ // below covers, measured against the old volume).
863
+ let rel = [
864
+ center[0] - acc.center[0],
865
+ center[1] - acc.center[1],
866
+ center[2] - acc.center[2],
867
+ ];
868
+ let along = dot3(rel, d);
869
+ let mut req_back = along + radius;
870
+ let mut req_far = radius - along;
871
+ if let Some((bmin, bmax)) = scene_bounds {
872
+ for i in 0..8 {
873
+ let p = [
874
+ if i & 1 == 0 { bmin[0] } else { bmax[0] },
875
+ if i & 2 == 0 { bmin[1] } else { bmax[1] },
876
+ if i & 4 == 0 { bmin[2] } else { bmax[2] },
877
+ ];
878
+ let a = dot3(
879
+ [
880
+ p[0] - acc.center[0],
881
+ p[1] - acc.center[1],
882
+ p[2] - acc.center[2],
883
+ ],
884
+ d,
885
+ );
886
+ if a > req_back { req_back = a; }
887
+ if -a > req_far { req_far = -a; }
888
+ }
889
+ }
890
+ if fits_xy && req_back <= acc.back && req_far <= acc.far {
891
+ // Keep light_vps[c] from the accepted fit.
892
+ continue;
893
+ }
894
+ }
895
+ }
896
+ let radius = radius * REFIT_SLACK;
897
+ // Quantize radius so subpixel camera movement can't shift
898
+ // the texel grid.
899
+ let radius = (radius * 16.0).ceil() / 16.0;
900
+
901
+ // Texel snap: quantize the ortho center to texel boundaries
902
+ // in light space so camera translation doesn't crawl edges.
903
+ let texel_world = (2.0 * radius) / CASCADE_MAP_SIZE as f32;
904
+ let ls_x = dot3(center, right);
905
+ let ls_y = dot3(center, ortho_up);
906
+ let snapped_x = (ls_x / texel_world).floor() * texel_world;
907
+ let snapped_y = (ls_y / texel_world).floor() * texel_world;
908
+ let dx_snap = snapped_x - ls_x;
909
+ let dy_snap = snapped_y - ls_y;
910
+ let snapped_center = [
911
+ center[0] + dx_snap * right[0] + dy_snap * ortho_up[0],
912
+ center[1] + dx_snap * right[1] + dy_snap * ortho_up[1],
913
+ center[2] + dx_snap * right[2] + dy_snap * ortho_up[2],
914
+ ];
915
+
916
+ // Extend Z-range using the scene AABB so casters behind the
917
+ // visible slice (from the light's view) still project shadows
918
+ // into it. This is "pancaking" — cascade XY is tight to the
919
+ // frustum sphere, but Z reaches back to the full scene.
920
+ let mut pancake_back: f32 = radius; // +d distance (toward light)
921
+ let mut pancake_far: f32 = radius; // -d distance (away from light)
922
+ if let Some((bmin, bmax)) = scene_bounds {
923
+ let corners = [
924
+ [bmin[0], bmin[1], bmin[2]],
925
+ [bmax[0], bmin[1], bmin[2]],
926
+ [bmin[0], bmax[1], bmin[2]],
927
+ [bmax[0], bmax[1], bmin[2]],
928
+ [bmin[0], bmin[1], bmax[2]],
929
+ [bmax[0], bmin[1], bmax[2]],
930
+ [bmin[0], bmax[1], bmax[2]],
931
+ [bmax[0], bmax[1], bmax[2]],
932
+ ];
933
+ for p in corners.iter() {
934
+ let rel = [
935
+ p[0] - snapped_center[0],
936
+ p[1] - snapped_center[1],
937
+ p[2] - snapped_center[2],
938
+ ];
939
+ let along_d = dot3(rel, d);
940
+ if along_d > pancake_back { pancake_back = along_d; }
941
+ if -along_d > pancake_far { pancake_far = -along_d; }
942
+ }
943
+ }
944
+ // Quantize Z range so scene-bounds drift doesn't shift depths.
945
+ // Flicker fix: animated casters (idle anims, wind-swayed
946
+ // proxies) drift the raw pancake need by centimetres-to-
947
+ // decimetres every cycle, and every resulting VP change
948
+ // re-rolls the acne pattern on grazing receivers — visible
949
+ // as periodic banding bursts. Quantize UP to whole 2 m steps
950
+ // and only shrink after a full 2-step (4 m) drop, so the
951
+ // fitted VP is byte-stable against anything short of a
952
+ // structural scene change. The cost is a few metres of extra
953
+ // ortho depth range on a Depth32Float target — irrelevant —
954
+ // and coverage stays correct: the accepted extent is never
955
+ // below the raw need.
956
+ const PANCAKE_STEP: f32 = 2.0;
957
+ let quantize = |v: f32| (v / PANCAKE_STEP).ceil() * PANCAKE_STEP;
958
+ let prev = self.pancake_hysteresis[c];
959
+ let pancake_back = if pancake_back > prev[0]
960
+ || pancake_back < prev[0] - 2.0 * PANCAKE_STEP
961
+ {
962
+ quantize(pancake_back)
963
+ } else {
964
+ prev[0]
965
+ };
966
+ let pancake_far = if pancake_far > prev[1]
967
+ || pancake_far < prev[1] - 2.0 * PANCAKE_STEP
968
+ {
969
+ quantize(pancake_far)
970
+ } else {
971
+ prev[1]
972
+ };
973
+ self.pancake_hysteresis[c] = [pancake_back, pancake_far];
974
+
975
+ // Place light eye at the far-back edge of the Z range so
976
+ // ortho near=0 exactly touches the top of the pancake volume.
977
+ let eye_offset = pancake_back;
978
+ let light_pos = [
979
+ snapped_center[0] + d[0] * eye_offset,
980
+ snapped_center[1] + d[1] * eye_offset,
981
+ snapped_center[2] + d[2] * eye_offset,
982
+ ];
983
+
984
+ let snapped_view = crate::renderer::mat4_look_at(light_pos, snapped_center, up_hint);
985
+ let light_proj = crate::renderer::mat4_ortho(
986
+ -radius, radius,
987
+ -radius, radius,
988
+ 0.0,
989
+ eye_offset + pancake_far,
990
+ );
991
+
992
+ self.light_vps[c] = crate::renderer::mat4_multiply(light_proj, snapped_view);
993
+
994
+ // Record the accepted fit so subsequent frames can keep this VP while
995
+ // their requirements stay inside it. EN-045 — cascade 0 included now;
996
+ // excluding it was what made the static-shadow cache a title-screen
997
+ // feature, because cascade 0's VP changed on every frame the camera moved.
998
+ {
999
+ self.accepted_fit[c] = Some(AcceptedFit {
1000
+ ls_x: dot3(snapped_center, right),
1001
+ ls_y: dot3(snapped_center, ortho_up),
1002
+ center: snapped_center,
1003
+ radius,
1004
+ back: pancake_back,
1005
+ far: pancake_far,
1006
+ });
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ /// Enable shadow mapping.
1012
+ pub fn enable(&mut self) {
1013
+ if !self.enabled {
1014
+ self.invalidate();
1015
+ }
1016
+ self.enabled = true;
1017
+ }
1018
+
1019
+ /// Disable shadow mapping.
1020
+ pub fn disable(&mut self) {
1021
+ self.enabled = false;
1022
+ }
1023
+ }
1024
+
1025
+ fn normalize3(v: [f32; 3]) -> [f32; 3] {
1026
+ let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1027
+ if len > 1e-6 {
1028
+ [v[0] / len, v[1] / len, v[2] / len]
1029
+ } else {
1030
+ [0.0, 0.0, 1.0]
1031
+ }
1032
+ }
1033
+
1034
+ fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
1035
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1036
+ }