@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,1996 @@
1
+ // Material system — the runtime state behind Phase 1c.
2
+ //
3
+ // Owns the compiled `MaterialPipeline`s, the per-frame / per-view /
4
+ // per-material / per-draw uniform buffers, the bind groups wiring
5
+ // them to the ABI layouts, and the per-frame draw command list.
6
+ //
7
+ // The Renderer owns one `MaterialSystem` instance. Games interact via
8
+ // three methods: `compile_material`, `submit_draw`, and (internally)
9
+ // `dispatch` which runs inside the main HDR pass.
10
+
11
+ use wgpu::util::DeviceExt;
12
+
13
+ use super::material_pipeline::{
14
+ MaterialAbiLayouts, MaterialPipeline, MaterialCompileDesc, FragmentProfile,
15
+ Bucket, compile_material, MaterialCompileError,
16
+ };
17
+ use super::types::Vertex3D;
18
+
19
+ // =====================================================================
20
+ // Uniform structs — repr(C), bytemuck-Pod, mirror the WGSL in
21
+ // material_abi.wgsl. Kept local to this module so changes to ABI
22
+ // struct layouts happen in exactly one place per language.
23
+ // =====================================================================
24
+
25
+ #[repr(C)]
26
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
27
+ pub struct PerFrameUniforms {
28
+ pub time: f32,
29
+ pub delta_time: f32,
30
+ pub frame_index: u32,
31
+ pub _pad0: u32,
32
+ pub screen_resolution: [f32; 2],
33
+ pub render_resolution: [f32; 2],
34
+ pub taa_jitter: [f32; 2],
35
+ pub _pad1: [f32; 2],
36
+ /// Global wind: x=dir_x, y=dir_z, z=amplitude, w=frequency.
37
+ pub wind: [f32; 4],
38
+ /// Cloud deck: x = shadow strength, y = deck height (m), z = feature scale,
39
+ /// w = drift speed (m/s). Materials feed this to `cloud_shadow_at` from
40
+ /// common/clouds.wgsl — the same deck the sky pass draws.
41
+ pub cloud: [f32; 4],
42
+ }
43
+
44
+ #[repr(C)]
45
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
46
+ pub struct PerViewDirLight {
47
+ pub direction: [f32; 4], // xyz + intensity
48
+ pub color: [f32; 4],
49
+ }
50
+
51
+ #[repr(C)]
52
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
53
+ pub struct PerViewPointLight {
54
+ pub position: [f32; 4],
55
+ pub color: [f32; 4],
56
+ }
57
+
58
+ #[repr(C)]
59
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
60
+ pub struct PerViewUniforms {
61
+ pub view: [[f32; 4]; 4],
62
+ pub proj: [[f32; 4]; 4],
63
+ pub view_proj: [[f32; 4]; 4],
64
+ pub prev_view_proj: [[f32; 4]; 4],
65
+ pub inv_proj: [[f32; 4]; 4],
66
+ pub camera_pos: [f32; 4],
67
+ pub camera_dir: [f32; 4],
68
+ pub ambient: [f32; 4],
69
+ pub fog: [f32; 4],
70
+ pub sun_dir: [f32; 4],
71
+ pub sun_color: [f32; 4],
72
+ pub dir_light_count: [f32; 4],
73
+ pub dir_lights: [PerViewDirLight; 8],
74
+ pub point_light_count: [f32; 4],
75
+ pub point_lights: [PerViewPointLight; 256],
76
+ pub shadow_splits: [f32; 4],
77
+ pub shadow_view: [[f32; 4]; 4],
78
+ pub shadow_cascades: [[[f32; 4]; 4]; 3],
79
+ }
80
+
81
+ #[repr(C)]
82
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
83
+ pub struct MaterialFactorsUniforms {
84
+ pub metal_rough: [f32; 4],
85
+ pub emissive: [f32; 4],
86
+ pub base_color: [f32; 4],
87
+ /// EN-012 — shading-model selector + foliage transmission tint.
88
+ /// x = shading_model enum (0 = default lit, 1 = foliage,
89
+ /// 2 = subsurface — V2 stub),
90
+ /// yzw = transmission_color (rgb tint for back-lit foliage; ignored
91
+ /// when shading_model == 0).
92
+ pub shading_model: [f32; 4],
93
+ /// EN-012 — foliage shading parameters. Only consumed when
94
+ /// `shading_model.x == 1.0`.
95
+ /// x = transmission_amount (0..1),
96
+ /// y = wrap_factor (0..1),
97
+ /// zw = reserved.
98
+ pub foliage_params: [f32; 4],
99
+ }
100
+
101
+ impl Default for MaterialFactorsUniforms {
102
+ fn default() -> Self {
103
+ Self {
104
+ metal_rough: [0.0, 1.0, 0.0, 0.0], // non-metal, rough, no MR tex, no cutoff
105
+ emissive: [0.0, 0.0, 0.0, 0.0],
106
+ base_color: [1.0, 1.0, 1.0, 1.0],
107
+ // EN-012 — default lit, white transmission tint. Materials
108
+ // that never call `setMaterialShadingModel` get standard PBR
109
+ // (shading_model.x == 0.0).
110
+ shading_model: [0.0, 1.0, 1.0, 1.0],
111
+ // EN-012 — moderate defaults so a freshly-flagged foliage
112
+ // material (shading_model = 1) looks reasonable before any
113
+ // tuning. Wrap=0.5 + transmission=0.5 gives soft back-face
114
+ // shading + a noticeable halo against the sun.
115
+ foliage_params: [0.5, 0.5, 0.0, 0.0],
116
+ }
117
+ }
118
+ }
119
+
120
+ #[repr(C)]
121
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
122
+ pub struct PerDrawUniforms {
123
+ pub mvp: [[f32; 4]; 4],
124
+ pub model: [[f32; 4]; 4],
125
+ pub prev_mvp: [[f32; 4]; 4],
126
+ pub model_tint: [f32; 4],
127
+ pub skin_info: [u32; 4],
128
+ }
129
+
130
+ // =====================================================================
131
+ // Handles + draw commands
132
+ // =====================================================================
133
+
134
+ pub type MaterialHandle = u32;
135
+
136
+ pub struct MaterialDrawCommand {
137
+ pub material: MaterialHandle,
138
+ pub mesh_handle: u64, // matches model_gpu_cache keys
139
+ pub mesh_idx: usize, // sub-mesh index within that cached model
140
+ pub draw_slot: usize, // which slot in per_draw_buffers to bind
141
+ /// Clip-space w of the object pivot at submit time (= view-space
142
+ /// depth for a standard projection). Drives the back-to-front sort
143
+ /// of the translucent bucket; opaque draws ignore it.
144
+ pub view_depth: f32,
145
+ /// EN-001 — when set, the engine binds vertex slot 1 to this
146
+ /// instance buffer and emits draw_indexed with `0..count` instances.
147
+ /// `None` means a single-instance draw (the legacy path).
148
+ pub instance: Option<InstanceDrawInfo>,
149
+ /// CPU-side copy of the model matrix (the GPU one lives in the
150
+ /// per-draw UBO slot). The shadow pass needs it to re-render these
151
+ /// draws into the sun cascades without reading buffers back.
152
+ pub model: [[f32; 4]; 4],
153
+ }
154
+
155
+ /// Reference to an instance buffer for an instanced draw command.
156
+ /// `buffer_handle` is 1-based into `MaterialSystem::instance_buffers`;
157
+ /// `count` is the number of GPU instances to emit. Created via
158
+ /// `MaterialSystem::create_instance_buffer`, consumed by
159
+ /// `submit_draw_instanced`.
160
+ #[derive(Copy, Clone, Debug)]
161
+ pub struct InstanceDrawInfo {
162
+ pub buffer_handle: u32,
163
+ pub count: u32,
164
+ }
165
+
166
+ /// EN-001 — owned wgpu::Buffer + element count for an instance buffer.
167
+ /// Lives in the `MaterialSystem::instance_buffers` registry, indexed
168
+ /// by 1-based handle (0 = invalid).
169
+ pub struct InstanceBuffer {
170
+ pub buffer: wgpu::Buffer,
171
+ pub count: u32,
172
+ /// Spatial tiles over the instances (built at creation by
173
+ /// reordering instances into an XZ grid). Empty = untiled (small
174
+ /// buffers). Opaque/cutout instanced draws use these to
175
+ /// frustum-cull whole tiles per view — a 20k-instance grass field
176
+ /// stops rasterizing the half behind the camera. Translucent draws
177
+ /// ignore tiles (their submission order is author-controlled).
178
+ pub tiles: Vec<InstanceTile>,
179
+ }
180
+
181
+ /// One spatial tile of an instance buffer: a contiguous instance range
182
+ /// plus the AABB of the member instances' POSITIONS and their max
183
+ /// scale. At cull time the position AABB is inflated by
184
+ /// `max_scale × (drawn mesh's local half-diagonal)` — conservative for
185
+ /// any rotation, exact enough at tile granularity.
186
+ #[derive(Copy, Clone)]
187
+ pub struct InstanceTile {
188
+ pub first: u32,
189
+ pub count: u32,
190
+ pub pmin: [f32; 3],
191
+ pub pmax: [f32; 3],
192
+ pub max_scale: f32,
193
+ }
194
+
195
+ /// EN-014 — owned wgpu::Texture + view + layer-count for a texture
196
+ /// array. Lives in the `MaterialSystem::texture_arrays` registry,
197
+ /// indexed by 1-based handle (0 = invalid). The view is a D2Array
198
+ /// view over all `layer_count` layers (so `textureSample(arr, samp,
199
+ /// uv, layer_idx)` resolves layers 0..layer_count-1).
200
+ pub struct TextureArray {
201
+ pub texture: wgpu::Texture,
202
+ pub view: wgpu::TextureView,
203
+ pub layer_count: u32,
204
+ }
205
+
206
+ /// EN-014 — V1 cap on layers per texture array. The ticket calls this
207
+ /// "enough for any landscape shader" (UE5's typical landscape uses
208
+ /// 4–8 layers; 16 leaves headroom for foliage/debris splats).
209
+ pub const MAX_TEXTURE_ARRAY_LAYERS: u32 = 16;
210
+
211
+ /// EN-014 V2 — texture-array format codes, as exposed to the TS API
212
+ /// via `TEX_ARRAY_FORMAT_*`. Anything unrecognised falls back to sRGB
213
+ /// since albedo is the most common splat layer.
214
+ pub const TEX_ARRAY_FORMAT_SRGB: u32 = 0;
215
+ pub const TEX_ARRAY_FORMAT_LINEAR: u32 = 1;
216
+
217
+ /// Map a TS-side format code to a wgpu format. sRGB suits albedo; linear
218
+ /// is mandatory for normal/MR data textures (sRGB decoding would corrupt
219
+ /// the encoded normals or rough/metal channels).
220
+ pub fn map_texture_array_format(code: u32) -> wgpu::TextureFormat {
221
+ match code {
222
+ TEX_ARRAY_FORMAT_SRGB => wgpu::TextureFormat::Rgba8UnormSrgb,
223
+ TEX_ARRAY_FORMAT_LINEAR => wgpu::TextureFormat::Rgba8Unorm,
224
+ _ => wgpu::TextureFormat::Rgba8UnormSrgb,
225
+ }
226
+ }
227
+
228
+ // =====================================================================
229
+ // The system
230
+ // =====================================================================
231
+
232
+ pub struct MaterialSystem {
233
+ pub layouts: MaterialAbiLayouts,
234
+
235
+ // Compiled pipelines, indexed by MaterialHandle (1-based; 0 = invalid).
236
+ pub pipelines: Vec<Option<MaterialPipeline>>,
237
+
238
+ // Per-material "render into planar reflection probes" flag (1-based
239
+ // like `pipelines`, default true). Authoring control for content
240
+ // that is sub-pixel or irrelevant at probe resolution — e.g. a 20k-
241
+ // instance grass field in a 512² water probe — mirroring the
242
+ // "visible in reflections" toggle every major engine exposes.
243
+ pub probe_visible: Vec<bool>,
244
+
245
+ // Per-frame UBO + bind group (rewritten at the start of every frame).
246
+ pub per_frame_buffer: wgpu::Buffer,
247
+ pub per_frame_bg: wgpu::BindGroup,
248
+
249
+ // Per-view UBO — one for now (single camera). Phase 2 may add more
250
+ // for split-screen / shadow cascades.
251
+ pub per_view_buffer: wgpu::Buffer,
252
+ pub per_view_bg: wgpu::BindGroup,
253
+
254
+ // Default per-material bind group: all white 1×1 textures, default
255
+ // factors, zero-initialised user-params. Materials that don't
256
+ // provide their own share this.
257
+ pub default_per_material_bg: wgpu::BindGroup,
258
+ /// Kept alive so the BG it backs doesn't dangle.
259
+ _default_material_factors_buffer: wgpu::Buffer,
260
+ _default_user_params_buffer: wgpu::Buffer,
261
+ _default_white_tex: wgpu::Texture,
262
+ _default_white_view: wgpu::TextureView,
263
+ _default_sampler: wgpu::Sampler,
264
+ /// EN-011 — 1×1 black texture bound at @group(2) @binding(12) for
265
+ /// materials that don't have a planar reflection probe linked.
266
+ /// Lets shaders unconditionally `textureSample(planar_reflection_tex,
267
+ /// …)` without branching on probe presence.
268
+ _default_black_tex: wgpu::Texture,
269
+ pub default_black_view: wgpu::TextureView,
270
+ /// EN-014 — 1×1×1 transparent-black texture-array stub bound to
271
+ /// bindings 14/15/16 (@group(2)) for materials that don't declare
272
+ /// their own array. Has to be a real D2Array view (not a 2D
273
+ /// texture cast) so the layout's `view_dimension: D2Array`
274
+ /// matches at bind time.
275
+ _default_array_tex: wgpu::Texture,
276
+ pub default_array_view: wgpu::TextureView,
277
+
278
+ /// Phase 5 — per-material `user_params` UBOs. Indexed by
279
+ /// `MaterialHandle - 1`; `None` means the material uses the default
280
+ /// (zero-initialised) bind group. Created lazily on first
281
+ /// `set_user_params` call.
282
+ pub material_params_buffers: Vec<Option<wgpu::Buffer>>,
283
+ pub material_per_material_bgs: Vec<Option<wgpu::BindGroup>>,
284
+
285
+ /// EN-011 — link from material → planar reflection probe handle
286
+ /// (1-based; `None` → default 1×1 black texture at binding 12).
287
+ /// Indexed by `MaterialHandle - 1`. Set via
288
+ /// `Renderer::set_material_reflection_probe`; the per-material
289
+ /// bind group is rebuilt at that call so subsequent draws see
290
+ /// the probe's texture even though the engine repaints the
291
+ /// texture each frame (the wgpu Texture identity stays stable).
292
+ pub material_reflection_probe: Vec<Option<u32>>,
293
+
294
+ // Per-draw UBO pool. Buffers grow 1-by-1 as draws pile up.
295
+ // Each entry is `(PerDraw UBO, bind group binding it + the global
296
+ // joint buffer at binding 1)`.
297
+ pub per_draw_buffers: Vec<wgpu::Buffer>,
298
+ pub per_draw_bgs: Vec<wgpu::BindGroup>,
299
+
300
+ // Phase 4b — group 4 (SceneInputs) bind group. Rebuilt per-frame
301
+ // when any Refractive material is submitted and a scene-colour
302
+ // snapshot is available. `None` means "no material needs this
303
+ // group this frame" — translucent dispatch skips group 4
304
+ // binding entirely.
305
+ pub scene_inputs_bg: Option<wgpu::BindGroup>,
306
+ /// Linear sampler for scene-colour sampling (group 4 binding 1).
307
+ _scene_color_sampler: wgpu::Sampler,
308
+ /// Non-filtering sampler for scene-depth sampling (binding 3).
309
+ _scene_depth_sampler: wgpu::Sampler,
310
+ /// 1×1 default texture for impulse / motion-vectors slots when
311
+ /// no Phase 7 impulse system is wired up yet.
312
+ _scene_stub_tex: wgpu::Texture,
313
+ _scene_stub_view: wgpu::TextureView,
314
+ /// 1×1 stub depth texture — bound to scene_depth_tex in Phase 4b
315
+ /// because the live depth buffer can't be simultaneously sampled
316
+ /// and used as a depth-stencil attachment. Phase 4c will add a
317
+ /// copy-to-sample depth snapshot for shoreline-fade materials.
318
+ _scene_stub_depth: wgpu::Texture,
319
+ _scene_stub_depth_view: wgpu::TextureView,
320
+
321
+ // Frame state — commands split by bucket so the graph can
322
+ // schedule them into the right pass. Phase 4a keeps them in
323
+ // parallel lists; Phase 4b dispatches the translucent lists in
324
+ // their own sub-pass.
325
+ pub commands: Vec<MaterialDrawCommand>, // Bucket::Opaque + Bucket::Cutout
326
+ pub translucent_commands: Vec<MaterialDrawCommand>, // Transparent + Refractive + Additive
327
+ next_draw_slot: usize,
328
+
329
+ /// EN-022 — per-slot model matrices from the PREVIOUS frame, keyed
330
+ /// by draw slot (= submission order, which is stable frame-to-frame
331
+ /// for immediate-mode games; the cached-model path relies on the
332
+ /// same convention). `submit_draw` composes prev_vp × prev_model
333
+ /// into PerDraw.prev_mvp so materials can output real motion
334
+ /// vectors — the ABI field existed since day one but was filled
335
+ /// with the CURRENT mvp, making world velocity identically zero
336
+ /// (round-2 audit F8).
337
+ prev_models: Vec<[[f32; 4]; 4]>,
338
+ cur_models: Vec<[[f32; 4]; 4]>,
339
+ /// Previous frame's view-projection (set in reset_draw_slot).
340
+ prev_vp: [[f32; 4]; 4],
341
+
342
+ /// EN-001 — instance buffers, indexed by InstanceBufferHandle
343
+ /// (1-based; 0 = invalid). Each entry owns a wgpu Buffer + element
344
+ /// count. Created via `create_instance_buffer`, consumed by
345
+ /// `submit_draw_instanced` commands. Slots remain `None` after
346
+ /// `destroy_instance_buffer` so existing handles never collide
347
+ /// with re-issued ones.
348
+ pub instance_buffers: Vec<Option<InstanceBuffer>>,
349
+
350
+ /// EN-014 — texture arrays, indexed by 1-based handle (0 = invalid).
351
+ /// Each entry owns a wgpu D2Array texture + view + layer-count.
352
+ /// Created via `create_texture_array`, linked to a material's
353
+ /// per-material BG via `set_material_texture_array`.
354
+ pub texture_arrays: Vec<Option<TextureArray>>,
355
+
356
+ /// EN-014 — per-material → array-handle link, one slot for each
357
+ /// of the 3 array bindings (0 = albedo, 1 = normal, 2 = MR).
358
+ /// `None` means "use the default 1×1×1 stub array". Indexed by
359
+ /// `MaterialHandle - 1`, parallel to `material_per_material_bgs`
360
+ /// / `material_reflection_probe`.
361
+ pub material_texture_arrays: Vec<[Option<u32>; 3]>,
362
+
363
+ /// EN-012 — per-material `MaterialFactorsUniforms` UBO. `None` means
364
+ /// the material uses the shared `_default_material_factors_buffer`
365
+ /// (uniform default) — lazily allocated on the first
366
+ /// `set_material_shading_model` / `set_material_foliage` call.
367
+ /// Indexed by `MaterialHandle - 1`. The CPU-side mirror in
368
+ /// `material_factors_data` lets us partial-update one field at a
369
+ /// time without losing the others.
370
+ pub material_factors_buffers: Vec<Option<wgpu::Buffer>>,
371
+ pub material_factors_data: Vec<MaterialFactorsUniforms>,
372
+ }
373
+
374
+ impl MaterialSystem {
375
+ pub fn new(
376
+ device: &wgpu::Device,
377
+ queue: &wgpu::Queue,
378
+ // Joint-buffer plumbing is per-draw (used by ensure_draw_slot,
379
+ // not at construction time). Kept on the constructor's
380
+ // signature for symmetry with the per-draw path.
381
+ _joint_buffer: &wgpu::Buffer,
382
+ ) -> Self {
383
+ let layouts = MaterialAbiLayouts::create(device);
384
+
385
+ // Per-frame UBO.
386
+ let per_frame_buffer = device.create_buffer(&wgpu::BufferDescriptor {
387
+ label: Some("material_per_frame"),
388
+ size: std::mem::size_of::<PerFrameUniforms>() as u64,
389
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
390
+ mapped_at_creation: false,
391
+ });
392
+ // EN-063 — on wasm32 the per_frame group also carries the seven
393
+ // folded SceneInputs bindings, whose stub resources are only
394
+ // created further down; the wasm32 bind group is built there.
395
+ #[cfg(not(target_arch = "wasm32"))]
396
+ let per_frame_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
397
+ label: Some("material_per_frame_bg"),
398
+ layout: &layouts.per_frame,
399
+ entries: &[wgpu::BindGroupEntry {
400
+ binding: 0, resource: per_frame_buffer.as_entire_binding(),
401
+ }],
402
+ });
403
+
404
+ // Default white 1×1 texture + sampler shared across optional PBR bindings.
405
+ let default_white_tex = device.create_texture_with_data(
406
+ queue,
407
+ &wgpu::TextureDescriptor {
408
+ label: Some("material_default_white"),
409
+ size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
410
+ mip_level_count: 1,
411
+ sample_count: 1,
412
+ dimension: wgpu::TextureDimension::D2,
413
+ format: wgpu::TextureFormat::Rgba8UnormSrgb,
414
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
415
+ view_formats: &[],
416
+ },
417
+ Default::default(),
418
+ &[255, 255, 255, 255],
419
+ );
420
+ let default_white_view = default_white_tex.create_view(&Default::default());
421
+
422
+ // EN-011 — Default 1×1 black HDR texture for the planar
423
+ // reflection slot (binding 12). Format is Rgba16Float to match
424
+ // the actual probe RT so the bind-group layout is identical
425
+ // whether a probe is bound or the default.
426
+ let black_pixel: [u16; 4] = [0, 0, 0, 0];
427
+ let default_black_tex = device.create_texture_with_data(
428
+ queue,
429
+ &wgpu::TextureDescriptor {
430
+ label: Some("material_default_black_reflection"),
431
+ size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
432
+ mip_level_count: 1,
433
+ sample_count: 1,
434
+ dimension: wgpu::TextureDimension::D2,
435
+ format: super::formats::HDR_FORMAT,
436
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
437
+ view_formats: &[],
438
+ },
439
+ Default::default(),
440
+ bytemuck::cast_slice(&black_pixel),
441
+ );
442
+ let default_black_view = default_black_tex.create_view(&Default::default());
443
+
444
+ // EN-014 — Default 1×1×1 transparent-black texture-array stub
445
+ // for bindings 14/15/16. Has to be a real D2Array (depth_or_
446
+ // _array_layers = 1, viewed as D2Array) so the bind-group
447
+ // layout's view_dimension matches at bind time. Format is
448
+ // Rgba8Unorm — same as a typical albedo source so a game
449
+ // dropping in albedo data without thinking gets the expected
450
+ // sRGB-vs-linear behaviour. Materials that need linear
451
+ // (normal/MR) typically encode that into the layer they upload.
452
+ let default_array_tex = device.create_texture_with_data(
453
+ queue,
454
+ &wgpu::TextureDescriptor {
455
+ label: Some("material_default_array_stub"),
456
+ size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
457
+ mip_level_count: 1,
458
+ sample_count: 1,
459
+ dimension: wgpu::TextureDimension::D2,
460
+ format: wgpu::TextureFormat::Rgba8Unorm,
461
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
462
+ view_formats: &[],
463
+ },
464
+ Default::default(),
465
+ &[0, 0, 0, 0],
466
+ );
467
+ let default_array_view = default_array_tex.create_view(&wgpu::TextureViewDescriptor {
468
+ label: Some("material_default_array_stub_view"),
469
+ dimension: Some(wgpu::TextureViewDimension::D2Array),
470
+ ..Default::default()
471
+ });
472
+
473
+ let default_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
474
+ label: Some("material_default_samp"),
475
+ address_mode_u: wgpu::AddressMode::Repeat,
476
+ address_mode_v: wgpu::AddressMode::Repeat,
477
+ mag_filter: wgpu::FilterMode::Linear,
478
+ min_filter: wgpu::FilterMode::Linear,
479
+ mipmap_filter: wgpu::MipmapFilterMode::Linear,
480
+ ..Default::default()
481
+ });
482
+
483
+ // Per-view UBO — zero init; write the real data each frame in `begin_frame`.
484
+ let per_view_buffer = device.create_buffer(&wgpu::BufferDescriptor {
485
+ label: Some("material_per_view"),
486
+ size: std::mem::size_of::<PerViewUniforms>() as u64,
487
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
488
+ mapped_at_creation: false,
489
+ });
490
+ // We don't have the env / BRDF LUT / shadow textures plumbed through
491
+ // the material-system layout yet — Phase 2 wires them from the
492
+ // existing renderer state. For now the PerView bind group uses the
493
+ // default white texture + sampler for the env / BRDF / shadow slots
494
+ // as placeholders so draws validate.
495
+ let white_samp = &default_sampler;
496
+ let white_view = &default_white_view;
497
+ let cmp_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
498
+ label: Some("material_default_cmp_samp"),
499
+ compare: Some(wgpu::CompareFunction::LessEqual),
500
+ ..Default::default()
501
+ });
502
+ // PerView layout wants a depth texture for shadow bindings 6-8.
503
+ // Use a 1×1 depth as a stub.
504
+ let stub_depth = device.create_texture(&wgpu::TextureDescriptor {
505
+ label: Some("material_stub_depth"),
506
+ size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
507
+ mip_level_count: 1,
508
+ sample_count: 1,
509
+ dimension: wgpu::TextureDimension::D2,
510
+ format: wgpu::TextureFormat::Depth32Float,
511
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
512
+ view_formats: &[],
513
+ });
514
+ let stub_depth_view = stub_depth.create_view(&Default::default());
515
+ let per_view_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
516
+ label: Some("material_per_view_bg"),
517
+ layout: &layouts.per_view,
518
+ entries: &[
519
+ wgpu::BindGroupEntry { binding: 0, resource: per_view_buffer.as_entire_binding() },
520
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(white_view) },
521
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(white_samp) },
522
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(white_view) },
523
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(white_view) },
524
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(white_samp) },
525
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&stub_depth_view) },
526
+ wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&stub_depth_view) },
527
+ wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&stub_depth_view) },
528
+ wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&cmp_sampler) },
529
+ ],
530
+ });
531
+ // The stub_depth texture and cmp_sampler outlive the bind group via
532
+ // wgpu internal Arc; we don't need to hold them in the struct.
533
+ std::mem::forget(stub_depth);
534
+ std::mem::forget(stub_depth_view);
535
+ std::mem::forget(cmp_sampler);
536
+
537
+ // Default MaterialFactors UBO.
538
+ let default_mf = MaterialFactorsUniforms::default();
539
+ let default_material_factors_buffer = device.create_buffer_init(
540
+ &wgpu::util::BufferInitDescriptor {
541
+ label: Some("material_default_factors"),
542
+ contents: bytemuck::bytes_of(&default_mf),
543
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
544
+ },
545
+ );
546
+ // Default user-params UBO — 256 bytes of zeros (ABI §1.4).
547
+ let default_user_params_buffer = device.create_buffer(&wgpu::BufferDescriptor {
548
+ label: Some("material_default_user_params"),
549
+ size: 256,
550
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
551
+ mapped_at_creation: false,
552
+ });
553
+ // Need a SECOND sampler here because wgpu requires distinct sampler
554
+ // handles when binding the same logical sampler to multiple slots
555
+ // within one BG — actually it doesn't, the same handle works fine.
556
+ // We reuse default_sampler for every material-tex sampler slot.
557
+ let default_per_material_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
558
+ label: Some("material_default_per_material_bg"),
559
+ layout: &layouts.per_material,
560
+ entries: &[
561
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&default_white_view) },
562
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&default_sampler) },
563
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&default_white_view) },
564
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&default_sampler) },
565
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&default_white_view) },
566
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&default_sampler) },
567
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&default_white_view) },
568
+ wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&default_sampler) },
569
+ wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&default_white_view) },
570
+ wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&default_sampler) },
571
+ wgpu::BindGroupEntry { binding: 10, resource: default_material_factors_buffer.as_entire_binding() },
572
+ wgpu::BindGroupEntry { binding: 11, resource: default_user_params_buffer.as_entire_binding() },
573
+ // EN-011 — default black 1×1 reflection texture +
574
+ // shared linear sampler. Replaced per-material when a
575
+ // game calls `set_material_reflection_probe`.
576
+ wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(&default_black_view) },
577
+ wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&default_sampler) },
578
+ // EN-014 — default 1×1×1 stub array bound to all 3
579
+ // texture-array slots, with the shared default
580
+ // sampler at binding 17. Replaced per-slot when a
581
+ // game calls `set_material_texture_array`.
582
+ wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(&default_array_view) },
583
+ wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(&default_array_view) },
584
+ wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(&default_array_view) },
585
+ wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&default_sampler) },
586
+ ],
587
+ });
588
+
589
+ // Phase 4b — scene-inputs scratch resources. Samplers are
590
+ // stable across frames; the bind group itself is rebuilt
591
+ // when a scene-colour snapshot becomes available.
592
+ let scene_color_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
593
+ label: Some("scene_color_samp"),
594
+ address_mode_u: wgpu::AddressMode::ClampToEdge,
595
+ address_mode_v: wgpu::AddressMode::ClampToEdge,
596
+ mag_filter: wgpu::FilterMode::Linear,
597
+ min_filter: wgpu::FilterMode::Linear,
598
+ mipmap_filter: wgpu::MipmapFilterMode::Nearest,
599
+ ..Default::default()
600
+ });
601
+ let scene_depth_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
602
+ label: Some("scene_depth_samp"),
603
+ address_mode_u: wgpu::AddressMode::ClampToEdge,
604
+ address_mode_v: wgpu::AddressMode::ClampToEdge,
605
+ mag_filter: wgpu::FilterMode::Nearest,
606
+ min_filter: wgpu::FilterMode::Nearest,
607
+ mipmap_filter: wgpu::MipmapFilterMode::Nearest,
608
+ ..Default::default()
609
+ });
610
+ // 1×1 black texture as the stub impulse / motion-vector slot
611
+ // until Phase 7 wires real sources.
612
+ let scene_stub_tex = device.create_texture_with_data(
613
+ queue,
614
+ &wgpu::TextureDescriptor {
615
+ label: Some("scene_inputs_stub"),
616
+ size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
617
+ mip_level_count: 1,
618
+ sample_count: 1,
619
+ dimension: wgpu::TextureDimension::D2,
620
+ format: wgpu::TextureFormat::Rgba8Unorm,
621
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
622
+ view_formats: &[],
623
+ },
624
+ Default::default(),
625
+ &[0, 0, 0, 0],
626
+ );
627
+ let scene_stub_view = scene_stub_tex.create_view(&Default::default());
628
+
629
+ // Stub depth texture — Depth32Float 1×1, cleared to 1.0 (far).
630
+ let scene_stub_depth = device.create_texture(&wgpu::TextureDescriptor {
631
+ label: Some("scene_depth_stub"),
632
+ size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
633
+ mip_level_count: 1,
634
+ sample_count: 1,
635
+ dimension: wgpu::TextureDimension::D2,
636
+ format: wgpu::TextureFormat::Depth32Float,
637
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
638
+ view_formats: &[],
639
+ });
640
+ let scene_stub_depth_view = scene_stub_depth.create_view(&Default::default());
641
+
642
+ // EN-063 — wasm32 per_frame bind group: the UBO plus the seven
643
+ // folded SceneInputs slots, initially bound to the same stub
644
+ // resources `update_scene_inputs` falls back to. Rebuilt with
645
+ // the real snapshot views each frame by `update_scene_inputs`.
646
+ #[cfg(target_arch = "wasm32")]
647
+ let per_frame_bg = super::material_system_wasm::build_per_frame_bg_wasm(
648
+ device,
649
+ &layouts.per_frame,
650
+ &per_frame_buffer,
651
+ &scene_stub_view, // scene_color_tex stub
652
+ &scene_color_sampler,
653
+ &scene_stub_depth_view, // scene_depth_tex stub
654
+ &scene_depth_sampler,
655
+ &scene_stub_view, // impulse_tex stub
656
+ &scene_depth_sampler, // impulse_samp — NonFiltering, matches layout
657
+ &scene_stub_view, // motion_vectors stub
658
+ );
659
+
660
+ Self {
661
+ layouts,
662
+ pipelines: Vec::new(),
663
+ probe_visible: Vec::new(),
664
+ per_frame_buffer,
665
+ per_frame_bg,
666
+ per_view_buffer,
667
+ per_view_bg,
668
+ default_per_material_bg,
669
+ _default_material_factors_buffer: default_material_factors_buffer,
670
+ _default_user_params_buffer: default_user_params_buffer,
671
+ _default_white_tex: default_white_tex,
672
+ _default_white_view: default_white_view,
673
+ _default_sampler: default_sampler,
674
+ _default_black_tex: default_black_tex,
675
+ default_black_view,
676
+ _default_array_tex: default_array_tex,
677
+ default_array_view,
678
+ material_params_buffers: Vec::new(),
679
+ material_per_material_bgs: Vec::new(),
680
+ material_reflection_probe: Vec::new(),
681
+ per_draw_buffers: Vec::new(),
682
+ per_draw_bgs: Vec::new(),
683
+ scene_inputs_bg: None,
684
+ _scene_color_sampler: scene_color_sampler,
685
+ _scene_depth_sampler: scene_depth_sampler,
686
+ _scene_stub_tex: scene_stub_tex,
687
+ _scene_stub_view: scene_stub_view,
688
+ _scene_stub_depth: scene_stub_depth,
689
+ _scene_stub_depth_view: scene_stub_depth_view,
690
+ commands: Vec::new(),
691
+ translucent_commands: Vec::new(),
692
+ next_draw_slot: 0,
693
+ prev_models: Vec::new(),
694
+ cur_models: Vec::new(),
695
+ prev_vp: super::IDENTITY_MAT4,
696
+ instance_buffers: Vec::new(),
697
+ texture_arrays: Vec::new(),
698
+ material_texture_arrays: Vec::new(),
699
+ // EN-012 — per-material MaterialFactors UBOs are lazy.
700
+ // Until a material calls `set_shading_model` /
701
+ // `set_foliage`, it shares the default factors buffer.
702
+ material_factors_buffers: Vec::new(),
703
+ material_factors_data: Vec::new(),
704
+ }
705
+ }
706
+
707
+ // --- Material registry --------------------------------------------
708
+
709
+ /// Compile a material and return its handle. Handles are 1-based;
710
+ /// 0 is reserved for "invalid material".
711
+ pub fn compile(
712
+ &mut self,
713
+ device: &wgpu::Device,
714
+ wgsl_source: &str,
715
+ profile: FragmentProfile,
716
+ bucket: Bucket,
717
+ reads_scene: bool,
718
+ wants_instancing: bool,
719
+ hdr_format: wgpu::TextureFormat,
720
+ material_format: wgpu::TextureFormat,
721
+ velocity_format: wgpu::TextureFormat,
722
+ albedo_format: wgpu::TextureFormat,
723
+ depth_format: wgpu::TextureFormat,
724
+ ) -> Result<MaterialHandle, MaterialCompileError> {
725
+ // Inject the user's WGSL under a synthetic path so the
726
+ // preprocessor can resolve `#include "material_abi.wgsl"` etc.
727
+ let entry_path = "__user_material.wgsl";
728
+ let desc = MaterialCompileDesc {
729
+ label: "user_material",
730
+ entry_path,
731
+ extra_sources: &[(entry_path, wgsl_source)],
732
+ profile,
733
+ bucket,
734
+ reads_scene,
735
+ hdr_format,
736
+ material_format,
737
+ velocity_format,
738
+ albedo_format,
739
+ depth_format,
740
+ vertex_buffers: &[Vertex3D::desc()],
741
+ wants_instancing,
742
+ };
743
+ let pipeline = compile_material(device, &self.layouts, &desc)?;
744
+ self.pipelines.push(Some(pipeline));
745
+ self.probe_visible.push(true);
746
+ Ok(self.pipelines.len() as MaterialHandle)
747
+ }
748
+
749
+ /// Set whether a material's draws render into planar-reflection
750
+ /// probes (default true). See `probe_visible` field docs.
751
+ pub fn set_probe_visible(&mut self, material: MaterialHandle, visible: bool) {
752
+ if material >= 1 {
753
+ if let Some(slot) = self.probe_visible.get_mut(material as usize - 1) {
754
+ *slot = visible;
755
+ }
756
+ }
757
+ }
758
+
759
+ pub fn material_probe_visible(&self, material: MaterialHandle) -> bool {
760
+ if material < 1 { return true; }
761
+ self.probe_visible.get(material as usize - 1).copied().unwrap_or(true)
762
+ }
763
+
764
+ // --- Frame lifecycle ----------------------------------------------
765
+
766
+ /// Write the per-frame + per-view UBOs. Safe to call any time
767
+ /// before `dispatch`; callers should call exactly once per frame
768
+ /// to keep `PerFrame.time` accurate. Does NOT clear the commands
769
+ /// list — that's `reset_frame`'s job (called at begin_frame, not
770
+ /// end_frame, so draws submitted during the frame survive).
771
+ pub fn update_frame_uniforms(
772
+ &mut self,
773
+ queue: &wgpu::Queue,
774
+ per_frame: &PerFrameUniforms,
775
+ per_view: &PerViewUniforms,
776
+ ) {
777
+ queue.write_buffer(&self.per_frame_buffer, 0, bytemuck::bytes_of(per_frame));
778
+ queue.write_buffer(&self.per_view_buffer, 0, bytemuck::bytes_of(per_view));
779
+ }
780
+
781
+ /// Shadow-flicker fix — re-upload just PerView's trailing shadow
782
+ /// fields. `update_frame_uniforms` runs BEFORE the shadow pass fits
783
+ /// this frame's cascade VPs, so PerView otherwise carries the
784
+ /// PREVIOUS frame's matrices while the depth maps hold this frame's
785
+ /// content — a one-frame mismatch that flashes acne bands across
786
+ /// material-path receivers whenever the fit moves (the deferred path
787
+ /// reads the freshly-written lighting buffer and never had this).
788
+ /// Called from `record_shadow_pass` after the fit; queued buffer
789
+ /// writes all apply before any of this frame's draws execute, so
790
+ /// this patch wins over the earlier stale upload.
791
+ pub fn refresh_shadow_uniforms(
792
+ &self,
793
+ queue: &wgpu::Queue,
794
+ shadow_splits: [f32; 4],
795
+ shadow_view: [[f32; 4]; 4],
796
+ shadow_cascades: [[[f32; 4]; 4]; 3],
797
+ ) {
798
+ #[repr(C)]
799
+ #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
800
+ struct ShadowTail {
801
+ shadow_splits: [f32; 4],
802
+ shadow_view: [[f32; 4]; 4],
803
+ shadow_cascades: [[[f32; 4]; 4]; 3],
804
+ }
805
+ let tail = ShadowTail { shadow_splits, shadow_view, shadow_cascades };
806
+ // The shadow fields are the last three in PerViewUniforms; Pod
807
+ // guarantees no padding, so the tail offset is exact.
808
+ let offset = (std::mem::size_of::<PerViewUniforms>()
809
+ - std::mem::size_of::<ShadowTail>()) as u64;
810
+ queue.write_buffer(&self.per_view_buffer, offset, bytemuck::bytes_of(&tail));
811
+ }
812
+
813
+ /// Reset the per-draw slot cursor. Commands lists are cleared by
814
+ /// the Renderer from its own `begin_frame` so the order of reset
815
+ /// vs. submit is deterministic.
816
+ ///
817
+ /// EN-022 — also rotates the per-slot model history: this frame's
818
+ /// models become the previous frame's, and `prev_vp` is set to the
819
+ /// VP that was current when those models were submitted.
820
+ pub fn reset_draw_slot(&mut self, prev_vp: [[f32; 4]; 4]) {
821
+ self.next_draw_slot = 0;
822
+ std::mem::swap(&mut self.prev_models, &mut self.cur_models);
823
+ self.cur_models.clear();
824
+ self.prev_vp = prev_vp;
825
+ }
826
+
827
+ /// EN-022 fix — `begin_mode_3d` overrides the velocity reference
828
+ /// with the previous frame's UNJITTERED VP re-jittered with the
829
+ /// CURRENT frame's offsets, so prev_mvp cancels the TAA jitter
830
+ /// exactly. (reset_draw_slot runs at begin_frame, before the
831
+ /// current frame's jitter is known.)
832
+ pub fn set_velocity_reference_vp(&mut self, vp: [[f32; 4]; 4]) {
833
+ self.prev_vp = vp;
834
+ }
835
+
836
+ /// Phase 5 — set/replace `user_params` for a specific material. The
837
+ /// next dispatch of this handle binds a per-material BindGroup with
838
+ /// the given bytes uploaded to `@group(2) @binding(11)`. Materials
839
+ /// that never receive a `set_user_params` call keep using the
840
+ /// default zero-initialised UBO.
841
+ ///
842
+ /// `params.len()` must be ≤ 256 bytes (ABI §1.4 cap). The buffer
843
+ /// is allocated lazily on first call per handle and reused on
844
+ /// subsequent updates. Pass an empty slice to revert to the default.
845
+ pub fn set_user_params(
846
+ &mut self,
847
+ device: &wgpu::Device,
848
+ queue: &wgpu::Queue,
849
+ handle: MaterialHandle,
850
+ params: &[u8],
851
+ ) -> Result<(), &'static str> {
852
+ if handle == 0 { return Err("invalid material handle"); }
853
+ let idx = (handle - 1) as usize;
854
+ if idx >= self.pipelines.len() || self.pipelines[idx].is_none() {
855
+ return Err("material handle not registered");
856
+ }
857
+ if params.len() > 256 {
858
+ return Err("user_params exceeds 256-byte cap");
859
+ }
860
+
861
+ // Grow parallel vectors so handle index is valid.
862
+ while self.material_params_buffers.len() <= idx {
863
+ self.material_params_buffers.push(None);
864
+ self.material_per_material_bgs.push(None);
865
+ }
866
+
867
+ // Reverting to default — drop the per-material entries.
868
+ if params.is_empty() {
869
+ self.material_params_buffers[idx] = None;
870
+ self.material_per_material_bgs[idx] = None;
871
+ return Ok(());
872
+ }
873
+
874
+ // Allocate the per-material UBO + BG on first set. Padded to 256 B
875
+ // so the ABI cap is reflected in the buffer size and write_buffer
876
+ // never partially fills. EN-011 bindings 12/13 default to the
877
+ // shared 1×1 black reflection texture; if a probe was previously
878
+ // linked via `set_reflection_probe`, the reflection BG will be
879
+ // rebuilt by that path the next time the link changes.
880
+ if self.material_params_buffers[idx].is_none() {
881
+ let buf = device.create_buffer(&wgpu::BufferDescriptor {
882
+ label: Some("material_user_params"),
883
+ size: 256,
884
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
885
+ mapped_at_creation: false,
886
+ });
887
+ // EN-012 — resolve the per-material factors UBO so a
888
+ // material that previously called `set_shading_model` /
889
+ // `set_foliage` keeps its custom factors at binding 10
890
+ // when user_params is later set on it.
891
+ let factors_buf: &wgpu::Buffer = self.material_factors_buffers
892
+ .get(idx)
893
+ .and_then(|b| b.as_ref())
894
+ .unwrap_or(&self._default_material_factors_buffer);
895
+ // EN-014 BUG FIX: this used to hardcode the 1×1 stub array on
896
+ // bindings 14/15/16, so setting user params AFTER linking a texture
897
+ // array silently unbound the array — the material kept sampling the
898
+ // stub and every fetch came back empty. Nothing errored; the pixels
899
+ // simply never appeared. Resolve the links like every other
900
+ // BG-build path does.
901
+ let [albedo_view, normal_view, mr_view] = self.resolve_array_views(idx);
902
+ let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
903
+ label: Some("material_per_material_bg_user"),
904
+ layout: &self.layouts.per_material,
905
+ entries: &[
906
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
907
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
908
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
909
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
910
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
911
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
912
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
913
+ wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
914
+ wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
915
+ wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
916
+ wgpu::BindGroupEntry { binding: 10, resource: factors_buf.as_entire_binding() },
917
+ wgpu::BindGroupEntry { binding: 11, resource: buf.as_entire_binding() },
918
+ wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(&self.default_black_view) },
919
+ wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
920
+ wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) },
921
+ wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) },
922
+ wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) },
923
+ wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
924
+ ],
925
+ });
926
+ self.material_params_buffers[idx] = Some(buf);
927
+ self.material_per_material_bgs[idx] = Some(bg);
928
+ }
929
+
930
+ // Pad short writes to 256 B so write_buffer doesn't read past `params`.
931
+ let mut padded = [0u8; 256];
932
+ padded[..params.len()].copy_from_slice(params);
933
+ let buf = self.material_params_buffers[idx].as_ref().unwrap();
934
+ queue.write_buffer(buf, 0, &padded);
935
+ Ok(())
936
+ }
937
+
938
+ /// Per-material BG when set, otherwise the shared default.
939
+ fn per_material_bg_for(&self, handle: MaterialHandle) -> &wgpu::BindGroup {
940
+ let idx = (handle as usize).wrapping_sub(1);
941
+ self.material_per_material_bgs.get(idx)
942
+ .and_then(|b| b.as_ref())
943
+ .unwrap_or(&self.default_per_material_bg)
944
+ }
945
+
946
+ /// EN-011 — link a material to a planar reflection probe's RT
947
+ /// view. The per-material bind group is rebuilt so binding 12
948
+ /// resolves to the probe's `color_view` instead of the default
949
+ /// 1×1 black texture. Subsequent draws of `handle` see the
950
+ /// probe's contents.
951
+ ///
952
+ /// Pass `probe_view = None` (or revert to the default by passing
953
+ /// `&self.default_black_view` from the caller side) to unlink.
954
+ /// The `probe_handle` is opaque to this method — it's stored
955
+ /// purely so `material_reflection_probe[idx]` reflects the link
956
+ /// for diagnostics + the renderer's per-frame dispatch.
957
+ ///
958
+ /// The wgpu Texture identity is stable for the probe's lifetime,
959
+ /// so we DON'T need to rebuild this bind group every frame; the
960
+ /// engine repaints the probe's texture but the same view keeps
961
+ /// pointing at it.
962
+ ///
963
+ /// Why we route through here vs direct field access: the BG
964
+ /// also needs to bind a user-params UBO (binding 11) — either
965
+ /// the per-material UBO when one was allocated by
966
+ /// `set_user_params`, or the shared default zero-UBO. This
967
+ /// helper picks the right one.
968
+ pub fn set_reflection_probe(
969
+ &mut self,
970
+ device: &wgpu::Device,
971
+ handle: MaterialHandle,
972
+ probe_handle: u32,
973
+ probe_view: &wgpu::TextureView,
974
+ ) -> Result<(), &'static str> {
975
+ if handle == 0 { return Err("invalid material handle"); }
976
+ let idx = (handle - 1) as usize;
977
+ if idx >= self.pipelines.len() || self.pipelines[idx].is_none() {
978
+ return Err("material handle not registered");
979
+ }
980
+
981
+ // Grow parallel vectors so the index is in bounds. We grow
982
+ // BOTH params + reflection registries together — they share
983
+ // an index domain (MaterialHandle - 1).
984
+ while self.material_params_buffers.len() <= idx {
985
+ self.material_params_buffers.push(None);
986
+ self.material_per_material_bgs.push(None);
987
+ }
988
+ while self.material_reflection_probe.len() <= idx {
989
+ self.material_reflection_probe.push(None);
990
+ }
991
+ self.material_reflection_probe[idx] = Some(probe_handle);
992
+
993
+ // Resolve binding 11 — per-material UBO if one's been
994
+ // allocated, else the shared zero-init default.
995
+ let user_params_buf: &wgpu::Buffer = self.material_params_buffers
996
+ .get(idx)
997
+ .and_then(|b| b.as_ref())
998
+ .unwrap_or(&self._default_user_params_buffer);
999
+
1000
+ // EN-012 — resolve binding 10 to the per-material factors UBO
1001
+ // when one's been allocated by `set_shading_model` /
1002
+ // `set_foliage`. Otherwise the reflection-probe rebind would
1003
+ // silently revert a foliage-flagged material back to the
1004
+ // default factors at binding 10.
1005
+ let factors_buf: &wgpu::Buffer = self.material_factors_buffers
1006
+ .get(idx)
1007
+ .and_then(|b| b.as_ref())
1008
+ .unwrap_or(&self._default_material_factors_buffer);
1009
+
1010
+ // EN-014 — resolve binding 14/15/16 from any per-material
1011
+ // texture-array links so a probe-bound material doesn't lose
1012
+ // its splat layers when the BG gets rebuilt.
1013
+ let [albedo_view, normal_view, mr_view] = self.resolve_array_views(idx);
1014
+
1015
+ let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1016
+ label: Some("material_per_material_bg_reflection"),
1017
+ layout: &self.layouts.per_material,
1018
+ entries: &[
1019
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1020
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1021
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1022
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1023
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1024
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1025
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1026
+ wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1027
+ wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1028
+ wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1029
+ wgpu::BindGroupEntry { binding: 10, resource: factors_buf.as_entire_binding() },
1030
+ wgpu::BindGroupEntry { binding: 11, resource: user_params_buf.as_entire_binding() },
1031
+ // EN-011 — probe's color view + a filtering sampler.
1032
+ wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(probe_view) },
1033
+ wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1034
+ // EN-014 — resolved texture arrays + shared sampler.
1035
+ wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) },
1036
+ wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) },
1037
+ wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) },
1038
+ wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1039
+ ],
1040
+ });
1041
+ self.material_per_material_bgs[idx] = Some(bg);
1042
+ Ok(())
1043
+ }
1044
+
1045
+ /// EN-014 — resolve the 3 D2Array texture views for a material
1046
+ /// (albedo / normal / MR), falling back to the shared 1×1×1 stub
1047
+ /// when the slot has no link. Returns borrowed views that share
1048
+ /// the lifetime of `self`. Callers use these to populate bindings
1049
+ /// 14/15/16 when (re)building a per-material BG.
1050
+ fn resolve_array_views(&self, idx: usize) -> [&wgpu::TextureView; 3] {
1051
+ let mut out: [&wgpu::TextureView; 3] = [
1052
+ &self.default_array_view,
1053
+ &self.default_array_view,
1054
+ &self.default_array_view,
1055
+ ];
1056
+ if let Some(slots) = self.material_texture_arrays.get(idx) {
1057
+ for (slot, link) in slots.iter().enumerate() {
1058
+ if let Some(handle) = link {
1059
+ let h = *handle as usize;
1060
+ if h > 0 {
1061
+ if let Some(Some(arr)) = self.texture_arrays.get(h - 1) {
1062
+ out[slot] = &arr.view;
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+ }
1068
+ out
1069
+ }
1070
+
1071
+ /// EN-012 — resolve the per-material `MaterialFactors` UBO for
1072
+ /// `idx`, falling back to the shared default buffer when no
1073
+ /// per-material factors UBO has been allocated yet. Used by every
1074
+ /// per-material BG-build path so EN-011 / EN-014 rebinds don't
1075
+ /// silently revert a material's foliage / shading-model state to
1076
+ /// the default factors.
1077
+ fn resolve_factors_buffer(&self, idx: usize) -> &wgpu::Buffer {
1078
+ self.material_factors_buffers
1079
+ .get(idx)
1080
+ .and_then(|b| b.as_ref())
1081
+ .unwrap_or(&self._default_material_factors_buffer)
1082
+ }
1083
+
1084
+ /// EN-012 — ensure a per-material `MaterialFactorsUniforms` UBO
1085
+ /// exists for `idx`, allocating + initialising from the default
1086
+ /// values on first use. Returns mutable access to the CPU mirror
1087
+ /// so the caller can mutate one field then write back via
1088
+ /// `flush_material_factors`. Grows `material_factors_buffers` /
1089
+ /// `material_factors_data` so `idx` is in bounds.
1090
+ fn ensure_material_factors(
1091
+ &mut self, device: &wgpu::Device, idx: usize,
1092
+ ) -> &mut MaterialFactorsUniforms {
1093
+ while self.material_factors_buffers.len() <= idx {
1094
+ self.material_factors_buffers.push(None);
1095
+ self.material_factors_data.push(MaterialFactorsUniforms::default());
1096
+ }
1097
+ if self.material_factors_buffers[idx].is_none() {
1098
+ // Allocate a fresh per-material factors UBO seeded with the
1099
+ // current CPU-side data (defaults on first call).
1100
+ let init = self.material_factors_data[idx];
1101
+ let buf = device.create_buffer_init(
1102
+ &wgpu::util::BufferInitDescriptor {
1103
+ label: Some("material_factors_per_material"),
1104
+ contents: bytemuck::bytes_of(&init),
1105
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1106
+ },
1107
+ );
1108
+ self.material_factors_buffers[idx] = Some(buf);
1109
+ }
1110
+ &mut self.material_factors_data[idx]
1111
+ }
1112
+
1113
+ /// EN-012 — write the current CPU-side `MaterialFactorsUniforms`
1114
+ /// for `idx` to the per-material UBO. Caller must have previously
1115
+ /// called `ensure_material_factors` so the buffer exists.
1116
+ fn flush_material_factors(&self, queue: &wgpu::Queue, idx: usize) {
1117
+ if let Some(Some(buf)) = self.material_factors_buffers.get(idx) {
1118
+ let data = &self.material_factors_data[idx];
1119
+ queue.write_buffer(buf, 0, bytemuck::bytes_of(data));
1120
+ }
1121
+ }
1122
+
1123
+ /// EN-012 — rebuild the per-material BG for `idx` after a
1124
+ /// MaterialFactors mutation. Resolves the per-material UBO,
1125
+ /// user_params UBO, reflection-probe view, and texture-array
1126
+ /// views from the current state so we don't clobber any of the
1127
+ /// EN-005/EN-011/EN-014 wiring. Caller is responsible for
1128
+ /// passing `probe_view` (the renderer wrapper looks the linked
1129
+ /// probe up via `material_reflection_probe_handle` and supplies
1130
+ /// either the probe's RT view or the default 1×1 black view).
1131
+ fn rebuild_per_material_bg(
1132
+ &mut self,
1133
+ device: &wgpu::Device,
1134
+ idx: usize,
1135
+ probe_view: &wgpu::TextureView,
1136
+ ) {
1137
+ // Grow parallel BG vector so `idx` is in bounds.
1138
+ while self.material_per_material_bgs.len() <= idx {
1139
+ self.material_params_buffers.push(None);
1140
+ self.material_per_material_bgs.push(None);
1141
+ }
1142
+
1143
+ let factors_buf: &wgpu::Buffer = self.resolve_factors_buffer(idx);
1144
+ let user_params_buf: &wgpu::Buffer = self.material_params_buffers
1145
+ .get(idx)
1146
+ .and_then(|b| b.as_ref())
1147
+ .unwrap_or(&self._default_user_params_buffer);
1148
+ let [albedo_view, normal_view, mr_view] = self.resolve_array_views(idx);
1149
+
1150
+ let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1151
+ label: Some("material_per_material_bg_factors"),
1152
+ layout: &self.layouts.per_material,
1153
+ entries: &[
1154
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1155
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1156
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1157
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1158
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1159
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1160
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1161
+ wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1162
+ wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1163
+ wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1164
+ wgpu::BindGroupEntry { binding: 10, resource: factors_buf.as_entire_binding() },
1165
+ wgpu::BindGroupEntry { binding: 11, resource: user_params_buf.as_entire_binding() },
1166
+ wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(probe_view) },
1167
+ wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1168
+ wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) },
1169
+ wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) },
1170
+ wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) },
1171
+ wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1172
+ ],
1173
+ });
1174
+ self.material_per_material_bgs[idx] = Some(bg);
1175
+ }
1176
+
1177
+ /// EN-012 — set the shading model for a material (`0` = default
1178
+ /// lit, `1` = foliage, `2` = subsurface — V2 stub). Lazily
1179
+ /// allocates a per-material `MaterialFactors` UBO on first call,
1180
+ /// then rebuilds the per-material BG so subsequent draws sample
1181
+ /// the new shading model. Caller passes `probe_view` so EN-011
1182
+ /// reflection links survive — see `Renderer::set_material_shading_model`.
1183
+ pub fn set_material_shading_model(
1184
+ &mut self,
1185
+ device: &wgpu::Device,
1186
+ queue: &wgpu::Queue,
1187
+ material: MaterialHandle,
1188
+ model: u32,
1189
+ probe_view: &wgpu::TextureView,
1190
+ ) -> Result<(), &'static str> {
1191
+ if material == 0 { return Err("invalid material handle"); }
1192
+ let idx = (material - 1) as usize;
1193
+ if idx >= self.pipelines.len() || self.pipelines[idx].is_none() {
1194
+ return Err("material handle not registered");
1195
+ }
1196
+ {
1197
+ let factors = self.ensure_material_factors(device, idx);
1198
+ factors.shading_model[0] = model as f32;
1199
+ }
1200
+ self.flush_material_factors(queue, idx);
1201
+ self.rebuild_per_material_bg(device, idx, probe_view);
1202
+ Ok(())
1203
+ }
1204
+
1205
+ /// EN-012 — set the foliage shading parameters for a material.
1206
+ /// Only takes effect when `shading_model == 1` (foliage). Updates
1207
+ /// `MaterialFactors.shading_model.yzw` (transmission_color) and
1208
+ /// `foliage_params.xy` (transmission_amount, wrap_factor). Lazily
1209
+ /// allocates a per-material UBO on first call.
1210
+ pub fn set_material_foliage(
1211
+ &mut self,
1212
+ device: &wgpu::Device,
1213
+ queue: &wgpu::Queue,
1214
+ material: MaterialHandle,
1215
+ trans_color: [f32; 3],
1216
+ trans_amount: f32,
1217
+ wrap_factor: f32,
1218
+ probe_view: &wgpu::TextureView,
1219
+ ) -> Result<(), &'static str> {
1220
+ if material == 0 { return Err("invalid material handle"); }
1221
+ let idx = (material - 1) as usize;
1222
+ if idx >= self.pipelines.len() || self.pipelines[idx].is_none() {
1223
+ return Err("material handle not registered");
1224
+ }
1225
+ {
1226
+ let factors = self.ensure_material_factors(device, idx);
1227
+ factors.shading_model[1] = trans_color[0];
1228
+ factors.shading_model[2] = trans_color[1];
1229
+ factors.shading_model[3] = trans_color[2];
1230
+ factors.foliage_params[0] = trans_amount;
1231
+ factors.foliage_params[1] = wrap_factor;
1232
+ }
1233
+ self.flush_material_factors(queue, idx);
1234
+ self.rebuild_per_material_bg(device, idx, probe_view);
1235
+ Ok(())
1236
+ }
1237
+
1238
+ /// EN-014 — create a 2D texture array from a slice of layer data.
1239
+ /// Each `(bytes, w, h)` describes one layer's RGBA8 source. All
1240
+ /// layers must share `w × h` (wgpu requires a uniform extent for
1241
+ /// D2Array). V1 panics on mismatch — V2 may resize. Layer count
1242
+ /// is capped at `MAX_TEXTURE_ARRAY_LAYERS`; extra layers are
1243
+ /// dropped. Returns a 1-based handle (0 on failure: empty layers
1244
+ /// or zero extent).
1245
+ ///
1246
+ /// Defaults: `format = 0` (Rgba8UnormSrgb, suitable for albedo),
1247
+ /// `mip_levels = 1` (no mips). For data textures (normal / MR)
1248
+ /// or auto-mip generation, see `create_texture_array_ex`.
1249
+ pub fn create_texture_array(
1250
+ &mut self,
1251
+ device: &wgpu::Device,
1252
+ queue: &wgpu::Queue,
1253
+ layers: &[(&[u8], u32, u32)],
1254
+ ) -> u32 {
1255
+ // V1 default: sRGB albedo, no mips. V2 callers use the _ex
1256
+ // variant directly.
1257
+ self.create_texture_array_ex(device, queue, layers, 0, 1)
1258
+ }
1259
+
1260
+ /// EN-014 V2 — create a 2D texture array with explicit pixel format
1261
+ /// and mip-level control. Layer extent / count rules match V1.
1262
+ ///
1263
+ /// `format`:
1264
+ /// 0 → `Rgba8UnormSrgb` (albedo / colour textures; default)
1265
+ /// 1 → `Rgba8Unorm` (normal / MR / data textures — linear)
1266
+ /// _ → falls back to `Rgba8UnormSrgb`
1267
+ ///
1268
+ /// `mip_levels`:
1269
+ /// 1 → no mips (matches V1 behaviour)
1270
+ /// 0 → auto-generate `floor(log2(max(w,h))) + 1` mips, filled
1271
+ /// by point-downsample (`copy_texture_to_texture` halving
1272
+ /// the previous mip). Cheap, correct sized, but aliased
1273
+ /// — a render-pass box filter is the V2.5 follow-up.
1274
+ /// N > 1 → not yet supported (game-supplied per-mip bytes); V2
1275
+ /// treats this as auto-generate.
1276
+ pub fn create_texture_array_ex(
1277
+ &mut self,
1278
+ device: &wgpu::Device,
1279
+ queue: &wgpu::Queue,
1280
+ layers: &[(&[u8], u32, u32)],
1281
+ format: u32,
1282
+ mip_levels: u32,
1283
+ ) -> u32 {
1284
+ let layer_count = (layers.len() as u32).min(MAX_TEXTURE_ARRAY_LAYERS);
1285
+ if layer_count == 0 { return 0; }
1286
+ let (_first_bytes, w, h) = layers[0];
1287
+ if w == 0 || h == 0 { return 0; }
1288
+ // Uniform extent check — V1 hard-fail surfaces obvious bugs at
1289
+ // creation rather than during silent GPU truncation later.
1290
+ for (i, (_, lw, lh)) in layers.iter().enumerate().take(layer_count as usize) {
1291
+ if *lw != w || *lh != h {
1292
+ eprintln!(
1293
+ "[texture_array] layer {} extent {}×{} does not match layer 0 ({}×{}); aborting create",
1294
+ i, lw, lh, w, h,
1295
+ );
1296
+ return 0;
1297
+ }
1298
+ }
1299
+ let wgpu_format = map_texture_array_format(format);
1300
+ // Resolve mip count. mip_levels = 1 → single mip. mip_levels = 0
1301
+ // → engine-generated max. Anything else (game-supplied per-mip)
1302
+ // is V2.5; for now treat N > 1 as auto so games opting into mips
1303
+ // don't silently regress to no-mips.
1304
+ let max_mips = (w.max(h) as f32).log2().floor() as u32 + 1;
1305
+ let auto_generate = mip_levels == 0 || mip_levels > 1;
1306
+ let mip_level_count = if mip_levels == 1 { 1 } else { max_mips.max(1) };
1307
+ // Auto-gen needs COPY_SRC on the texture so we can ping-pong each
1308
+ // mip into the next via copy_texture_to_texture.
1309
+ let mut usage = wgpu::TextureUsages::TEXTURE_BINDING
1310
+ | wgpu::TextureUsages::COPY_DST;
1311
+ if auto_generate && mip_level_count > 1 {
1312
+ usage |= wgpu::TextureUsages::COPY_SRC;
1313
+ }
1314
+ let bytes_per_layer = (w as usize) * (h as usize) * 4;
1315
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
1316
+ label: Some("material_texture_array"),
1317
+ size: wgpu::Extent3d {
1318
+ width: w,
1319
+ height: h,
1320
+ depth_or_array_layers: layer_count,
1321
+ },
1322
+ mip_level_count,
1323
+ sample_count: 1,
1324
+ dimension: wgpu::TextureDimension::D2,
1325
+ format: wgpu_format,
1326
+ usage,
1327
+ view_formats: &[],
1328
+ });
1329
+ for (i, (bytes, _, _)) in layers.iter().enumerate().take(layer_count as usize) {
1330
+ // Defensive — a short layer slice would panic in
1331
+ // write_texture; skip and emit a diagnostic instead.
1332
+ if bytes.len() < bytes_per_layer {
1333
+ eprintln!(
1334
+ "[texture_array] layer {} short: {} B < {} B (skipping)",
1335
+ i, bytes.len(), bytes_per_layer,
1336
+ );
1337
+ continue;
1338
+ }
1339
+ queue.write_texture(
1340
+ wgpu::TexelCopyTextureInfo {
1341
+ texture: &texture,
1342
+ mip_level: 0,
1343
+ origin: wgpu::Origin3d { x: 0, y: 0, z: i as u32 },
1344
+ aspect: wgpu::TextureAspect::All,
1345
+ },
1346
+ &bytes[..bytes_per_layer],
1347
+ wgpu::TexelCopyBufferLayout {
1348
+ offset: 0,
1349
+ bytes_per_row: Some(w * 4),
1350
+ rows_per_image: Some(h),
1351
+ },
1352
+ wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
1353
+ );
1354
+ }
1355
+ // EN-014 V2 — auto-generate the mip chain via point-sample copies.
1356
+ // For each mip > 0, copy a half-size region of mip-1 into mip,
1357
+ // for every layer. wgpu's copy_texture_to_texture covers a single
1358
+ // mip level + array layer per call. This is point-filtered (not
1359
+ // box-filtered): correct sizes, sampleable at distance, but
1360
+ // aliased. V2.5 follow-up upgrades this to a render-pass box
1361
+ // filter (one fullscreen draw per (mip, layer)).
1362
+ if auto_generate && mip_level_count > 1 {
1363
+ let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1364
+ label: Some("material_texture_array_mipgen"),
1365
+ });
1366
+ for mip in 1..mip_level_count {
1367
+ let src_w = (w >> (mip - 1)).max(1);
1368
+ let src_h = (h >> (mip - 1)).max(1);
1369
+ let dst_w = (w >> mip).max(1);
1370
+ let dst_h = (h >> mip).max(1);
1371
+ // Copy region is dst-sized so we read the top-left
1372
+ // 2x2 reduction implicitly. Truly we'd want a filter,
1373
+ // but copy is the cheapest "mips exist" path.
1374
+ let copy_w = dst_w.min(src_w);
1375
+ let copy_h = dst_h.min(src_h);
1376
+ for layer in 0..layer_count {
1377
+ encoder.copy_texture_to_texture(
1378
+ wgpu::TexelCopyTextureInfo {
1379
+ texture: &texture,
1380
+ mip_level: mip - 1,
1381
+ origin: wgpu::Origin3d { x: 0, y: 0, z: layer },
1382
+ aspect: wgpu::TextureAspect::All,
1383
+ },
1384
+ wgpu::TexelCopyTextureInfo {
1385
+ texture: &texture,
1386
+ mip_level: mip,
1387
+ origin: wgpu::Origin3d { x: 0, y: 0, z: layer },
1388
+ aspect: wgpu::TextureAspect::All,
1389
+ },
1390
+ wgpu::Extent3d {
1391
+ width: copy_w,
1392
+ height: copy_h,
1393
+ depth_or_array_layers: 1,
1394
+ },
1395
+ );
1396
+ }
1397
+ }
1398
+ queue.submit(std::iter::once(encoder.finish()));
1399
+ }
1400
+ let view = texture.create_view(&wgpu::TextureViewDescriptor {
1401
+ label: Some("material_texture_array_view"),
1402
+ dimension: Some(wgpu::TextureViewDimension::D2Array),
1403
+ ..Default::default()
1404
+ });
1405
+ self.texture_arrays.push(Some(TextureArray {
1406
+ texture, view, layer_count,
1407
+ }));
1408
+ self.texture_arrays.len() as u32
1409
+ }
1410
+
1411
+ /// EN-014 — link a texture array to a material's per-material
1412
+ /// bind-group at one of three slots (0 = albedo, 1 = normal,
1413
+ /// 2 = MR). Pass `array = 0` to revert the slot to the default
1414
+ /// 1×1×1 stub. The per-material BG is rebuilt so subsequent draws
1415
+ /// see the new array view at @binding 14/15/16.
1416
+ ///
1417
+ /// `probe_view` is the binding-12 source — the caller (typically
1418
+ /// `Renderer::set_material_texture_array`) resolves the
1419
+ /// material's currently-linked reflection probe and passes its
1420
+ /// `color_view` here, or `&self.default_black_view` to default.
1421
+ /// This indirection keeps EN-011 reflection links live across
1422
+ /// EN-014 rebinds without the MaterialSystem having to reach
1423
+ /// into the Renderer's probe registry. Out-of-range slots /
1424
+ /// unknown handles are no-ops with a diagnostic.
1425
+ pub fn set_material_texture_array(
1426
+ &mut self,
1427
+ device: &wgpu::Device,
1428
+ material: MaterialHandle,
1429
+ slot: u32,
1430
+ array: u32,
1431
+ probe_view: &wgpu::TextureView,
1432
+ ) {
1433
+ if material == 0 { return; }
1434
+ let idx = (material - 1) as usize;
1435
+ if idx >= self.pipelines.len() || self.pipelines[idx].is_none() {
1436
+ eprintln!("[texture_array] unknown material handle {material}");
1437
+ return;
1438
+ }
1439
+ if slot >= 3 {
1440
+ eprintln!("[texture_array] slot {slot} out of range (0..=2)");
1441
+ return;
1442
+ }
1443
+ if array != 0 {
1444
+ let h = array as usize;
1445
+ if h == 0 || h > self.texture_arrays.len()
1446
+ || self.texture_arrays[h - 1].is_none()
1447
+ {
1448
+ eprintln!("[texture_array] unknown array handle {array}");
1449
+ return;
1450
+ }
1451
+ }
1452
+
1453
+ // Grow the link table so `idx` is in bounds. Parallel to
1454
+ // the params + reflection registries but not strictly tied
1455
+ // to either — texture-array links are independent.
1456
+ while self.material_texture_arrays.len() <= idx {
1457
+ self.material_texture_arrays.push([None, None, None]);
1458
+ }
1459
+ let link = if array == 0 { None } else { Some(array) };
1460
+ self.material_texture_arrays[idx][slot as usize] = link;
1461
+
1462
+ // Rebuild the per-material BG. Resolve user_params from
1463
+ // existing state so we don't clobber EN-005 links.
1464
+ while self.material_params_buffers.len() <= idx {
1465
+ self.material_params_buffers.push(None);
1466
+ self.material_per_material_bgs.push(None);
1467
+ }
1468
+ let user_params_buf: &wgpu::Buffer = self.material_params_buffers
1469
+ .get(idx)
1470
+ .and_then(|b| b.as_ref())
1471
+ .unwrap_or(&self._default_user_params_buffer);
1472
+ // EN-012 — preserve any per-material MaterialFactors UBO
1473
+ // across an EN-014 array rebind.
1474
+ let factors_buf: &wgpu::Buffer = self.material_factors_buffers
1475
+ .get(idx)
1476
+ .and_then(|b| b.as_ref())
1477
+ .unwrap_or(&self._default_material_factors_buffer);
1478
+
1479
+ let [albedo_view, normal_view, mr_view] = self.resolve_array_views(idx);
1480
+
1481
+ let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1482
+ label: Some("material_per_material_bg_array"),
1483
+ layout: &self.layouts.per_material,
1484
+ entries: &[
1485
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1486
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1487
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1488
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1489
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1490
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1491
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1492
+ wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1493
+ wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self._default_white_view) },
1494
+ wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1495
+ wgpu::BindGroupEntry { binding: 10, resource: factors_buf.as_entire_binding() },
1496
+ wgpu::BindGroupEntry { binding: 11, resource: user_params_buf.as_entire_binding() },
1497
+ wgpu::BindGroupEntry { binding: 12, resource: wgpu::BindingResource::TextureView(probe_view) },
1498
+ wgpu::BindGroupEntry { binding: 13, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1499
+ wgpu::BindGroupEntry { binding: 14, resource: wgpu::BindingResource::TextureView(albedo_view) },
1500
+ wgpu::BindGroupEntry { binding: 15, resource: wgpu::BindingResource::TextureView(normal_view) },
1501
+ wgpu::BindGroupEntry { binding: 16, resource: wgpu::BindingResource::TextureView(mr_view) },
1502
+ wgpu::BindGroupEntry { binding: 17, resource: wgpu::BindingResource::Sampler(&self._default_sampler) },
1503
+ ],
1504
+ });
1505
+ self.material_per_material_bgs[idx] = Some(bg);
1506
+ }
1507
+
1508
+ /// EN-014 — accessor for `material_reflection_probe[idx]`. The
1509
+ /// Renderer wrapper uses this to resolve the currently-linked
1510
+ /// probe handle when rebuilding the BG via
1511
+ /// `set_material_texture_array`. Returns `None` for unset / out-
1512
+ /// of-range / unlinked materials.
1513
+ pub fn material_reflection_probe_handle(&self, material: MaterialHandle) -> Option<u32> {
1514
+ if material == 0 { return None; }
1515
+ let idx = (material as usize).checked_sub(1)?;
1516
+ self.material_reflection_probe.get(idx).copied().flatten()
1517
+ }
1518
+
1519
+ /// Convenience — true if either bucket has queued work this frame.
1520
+ pub fn any_commands(&self) -> bool {
1521
+ !self.commands.is_empty() || !self.translucent_commands.is_empty()
1522
+ }
1523
+
1524
+ /// Submit a draw against a compiled material. Allocates (or reuses)
1525
+ /// a per-draw UBO slot, writes the MVP / model / tint / skin info,
1526
+ /// and queues the command for dispatch.
1527
+ pub fn submit_draw(
1528
+ &mut self,
1529
+ device: &wgpu::Device,
1530
+ queue: &wgpu::Queue,
1531
+ joint_buffer: &wgpu::Buffer,
1532
+ material: MaterialHandle,
1533
+ mesh_handle: u64,
1534
+ mesh_idx: usize,
1535
+ mvp: [[f32; 4]; 4],
1536
+ model: [[f32; 4]; 4],
1537
+ // EN-022: ignored — callers historically passed the CURRENT mvp
1538
+ // here, zeroing every motion vector. The real previous-frame
1539
+ // transform is reconstructed from the per-slot model history.
1540
+ _legacy_prev_mvp: [[f32; 4]; 4],
1541
+ tint: [f32; 4],
1542
+ skin_info: [u32; 4],
1543
+ ) {
1544
+ let idx = material as usize;
1545
+ if material == 0 || idx > self.pipelines.len() { return; }
1546
+ let bucket = match self.pipelines[idx - 1].as_ref() {
1547
+ Some(p) => p.bucket,
1548
+ None => return,
1549
+ };
1550
+
1551
+ let slot = self.next_draw_slot;
1552
+ self.next_draw_slot += 1;
1553
+ self.ensure_draw_slot(device, joint_buffer, slot);
1554
+
1555
+ // EN-022 — draw identity is the submission slot (stable order
1556
+ // frame-to-frame, same convention the cached-model path uses).
1557
+ // A fresh slot (first frame, or the frame after a draw-count
1558
+ // change) falls back to the current model = zero object motion.
1559
+ let prev_model = self.prev_models.get(slot).copied().unwrap_or(model);
1560
+ let prev_mvp = super::mat4_multiply(self.prev_vp, prev_model);
1561
+ if self.cur_models.len() <= slot {
1562
+ self.cur_models.resize(slot + 1, model);
1563
+ }
1564
+ self.cur_models[slot] = model;
1565
+
1566
+ let per_draw = PerDrawUniforms { mvp, model, prev_mvp, model_tint: tint, skin_info };
1567
+ queue.write_buffer(&self.per_draw_buffers[slot], 0, bytemuck::bytes_of(&per_draw));
1568
+
1569
+ let cmd = MaterialDrawCommand {
1570
+ material, mesh_handle, mesh_idx, draw_slot: slot,
1571
+ view_depth: mvp[3][3],
1572
+ instance: None,
1573
+ model,
1574
+ };
1575
+ if bucket.is_translucent() {
1576
+ self.translucent_commands.push(cmd);
1577
+ } else {
1578
+ self.commands.push(cmd);
1579
+ }
1580
+ }
1581
+
1582
+ /// EN-001 — submit an instanced draw. Identical to `submit_draw`
1583
+ /// except the engine binds vertex slot 1 to the registered
1584
+ /// instance buffer and emits `draw_indexed(.., 0..count)`. The
1585
+ /// pipeline must have been compiled with `wants_instancing=true`
1586
+ /// (use `compile_material_instanced` on the renderer).
1587
+ ///
1588
+ /// `model` / `mvp` here are the instance-local→world fallback
1589
+ /// transform — the per-instance buffer's `instance_pos`/`rot_y`/
1590
+ /// `scale` typically dominate, so callers usually pass identity
1591
+ /// for `model` and the camera VP for `mvp`. `tint` is multiplied
1592
+ /// per-draw (in addition to the per-instance tint).
1593
+ pub fn submit_draw_instanced(
1594
+ &mut self,
1595
+ device: &wgpu::Device,
1596
+ queue: &wgpu::Queue,
1597
+ joint_buffer: &wgpu::Buffer,
1598
+ material: MaterialHandle,
1599
+ mesh_handle: u64,
1600
+ mesh_idx: usize,
1601
+ instance_buffer: u32,
1602
+ instance_count: u32,
1603
+ mvp: [[f32; 4]; 4],
1604
+ model: [[f32; 4]; 4],
1605
+ // EN-022: ignored — see submit_draw.
1606
+ _legacy_prev_mvp: [[f32; 4]; 4],
1607
+ tint: [f32; 4],
1608
+ skin_info: [u32; 4],
1609
+ ) {
1610
+ let idx = material as usize;
1611
+ if material == 0 || idx > self.pipelines.len() { return; }
1612
+ let bucket = match self.pipelines[idx - 1].as_ref() {
1613
+ Some(p) => p.bucket,
1614
+ None => return,
1615
+ };
1616
+
1617
+ let slot = self.next_draw_slot;
1618
+ self.next_draw_slot += 1;
1619
+ self.ensure_draw_slot(device, joint_buffer, slot);
1620
+
1621
+ // EN-022 — same slot-history reconstruction as submit_draw.
1622
+ // Instance transforms live in the (static) instance buffer, so
1623
+ // prev vs current only differ by the fallback model + camera;
1624
+ // per-instance wind sway derives its own previous position in
1625
+ // the vertex shader from frame.time - frame.delta_time.
1626
+ let prev_model = self.prev_models.get(slot).copied().unwrap_or(model);
1627
+ let prev_mvp = super::mat4_multiply(self.prev_vp, prev_model);
1628
+ if self.cur_models.len() <= slot {
1629
+ self.cur_models.resize(slot + 1, model);
1630
+ }
1631
+ self.cur_models[slot] = model;
1632
+
1633
+ let per_draw = PerDrawUniforms { mvp, model, prev_mvp, model_tint: tint, skin_info };
1634
+ queue.write_buffer(&self.per_draw_buffers[slot], 0, bytemuck::bytes_of(&per_draw));
1635
+
1636
+ let cmd = MaterialDrawCommand {
1637
+ material, mesh_handle, mesh_idx, draw_slot: slot,
1638
+ // Instanced draws sort as a group by their fallback-transform
1639
+ // pivot — per-instance ordering inside one buffer is the
1640
+ // standard engine limitation.
1641
+ view_depth: mvp[3][3],
1642
+ instance: Some(InstanceDrawInfo {
1643
+ buffer_handle: instance_buffer,
1644
+ count: instance_count,
1645
+ }),
1646
+ model,
1647
+ };
1648
+ if bucket.is_translucent() {
1649
+ self.translucent_commands.push(cmd);
1650
+ } else {
1651
+ self.commands.push(cmd);
1652
+ }
1653
+ }
1654
+
1655
+
1656
+ fn ensure_draw_slot(
1657
+ &mut self,
1658
+ device: &wgpu::Device,
1659
+ joint_buffer: &wgpu::Buffer,
1660
+ slot: usize,
1661
+ ) {
1662
+ while self.per_draw_buffers.len() <= slot {
1663
+ let buf = device.create_buffer(&wgpu::BufferDescriptor {
1664
+ label: Some("material_per_draw"),
1665
+ size: std::mem::size_of::<PerDrawUniforms>() as u64,
1666
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1667
+ mapped_at_creation: false,
1668
+ });
1669
+ let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1670
+ label: Some("material_per_draw_bg"),
1671
+ layout: &self.layouts.per_draw,
1672
+ entries: &[
1673
+ wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() },
1674
+ wgpu::BindGroupEntry { binding: 1, resource: joint_buffer.as_entire_binding() },
1675
+ ],
1676
+ });
1677
+ self.per_draw_buffers.push(buf);
1678
+ self.per_draw_bgs.push(bg);
1679
+ }
1680
+ }
1681
+
1682
+ /// Dispatch all queued material draws. Caller owns the render pass;
1683
+ /// this method binds the pipelines + groups + meshes and issues
1684
+ /// indexed draws. `mesh_fetch` is a closure that returns
1685
+ /// `(vertex_buffer, index_buffer, index_count)` for a given
1686
+ /// (mesh_handle, mesh_idx) — lets the renderer hand over its
1687
+ /// `model_gpu_cache` without this module taking a dependency on it.
1688
+ pub fn dispatch<'pass, F>(
1689
+ &'pass self,
1690
+ pass: &mut wgpu::RenderPass<'pass>,
1691
+ planes: Option<&[[f32; 4]; 6]>,
1692
+ mesh_fetch: F,
1693
+ )
1694
+ where F: FnMut(u64, usize) -> Option<(&'pass wgpu::Buffer, &'pass wgpu::Buffer, u32, [f32; 3], [f32; 3])>
1695
+ {
1696
+ self.dispatch_with_view(pass, &self.per_view_bg, |_| true, false, planes, mesh_fetch);
1697
+ }
1698
+
1699
+ /// EN-011 — like `dispatch`, but uses a caller-supplied PerView
1700
+ /// bind group (so the planar-reflection pass can render the same
1701
+ /// draws against a mirrored camera UBO) and skips any command
1702
+ /// whose material handle the `accept` predicate rejects (so the
1703
+ /// reflection pass can drop hardcoded-excluded materials —
1704
+ /// foliage, particles, grass — without touching the original
1705
+ /// command list).
1706
+ ///
1707
+ /// `accept` is consulted once per command; the engine passes a
1708
+ /// closure that closes over a small `HashSet<MaterialHandle>` of
1709
+ /// excluded material handles.
1710
+ ///
1711
+ /// EN-011 V2 — `use_reflection_pipeline` swaps in the material's
1712
+ /// sibling pipeline (cull_mode flipped) when one was compiled.
1713
+ /// Falls back to the main pipeline if no reflection variant
1714
+ /// exists (translucent / cutout materials, where the original
1715
+ /// pipeline already cull-mode = None and no flip is needed).
1716
+ pub fn dispatch_with_view<'pass, F, A>(
1717
+ &'pass self,
1718
+ pass: &mut wgpu::RenderPass<'pass>,
1719
+ per_view_bg: &'pass wgpu::BindGroup,
1720
+ mut accept: A,
1721
+ use_reflection_pipeline: bool,
1722
+ // View frustum for instance-tile culling (`mesh_fetch` supplies
1723
+ // the mesh's local AABB). None = no per-tile culling.
1724
+ planes: Option<&[[f32; 4]; 6]>,
1725
+ mut mesh_fetch: F,
1726
+ )
1727
+ where
1728
+ F: FnMut(u64, usize) -> Option<(&'pass wgpu::Buffer, &'pass wgpu::Buffer, u32, [f32; 3], [f32; 3])>,
1729
+ A: FnMut(MaterialHandle) -> bool,
1730
+ {
1731
+ if self.commands.is_empty() { return; }
1732
+
1733
+ let mut last_material: MaterialHandle = 0;
1734
+ for cmd in &self.commands {
1735
+ if !accept(cmd.material) { continue; }
1736
+ if cmd.material != last_material {
1737
+ let mat = match self.pipelines.get(cmd.material as usize - 1) {
1738
+ Some(Some(m)) => m,
1739
+ _ => continue,
1740
+ };
1741
+ // Probe passes (use_reflection_pipeline) present the
1742
+ // 4-attachment opaque G-buffer layout. Translucent-
1743
+ // profile pipelines are single-target — binding one
1744
+ // there is a wgpu validation panic — and reads_scene
1745
+ // materials expect a group-4 bind the probe pass never
1746
+ // provides. Skip both; `last_material` stays latched on
1747
+ // the previously bound material, which is correct
1748
+ // because no pipeline/bind state changes here.
1749
+ if use_reflection_pipeline
1750
+ && (matches!(mat.profile, FragmentProfile::Translucent) || mat.reads_scene)
1751
+ {
1752
+ continue;
1753
+ }
1754
+ let pipeline = if use_reflection_pipeline {
1755
+ mat.reflection_pipeline.as_ref().unwrap_or(&mat.pipeline)
1756
+ } else {
1757
+ &mat.pipeline
1758
+ };
1759
+ pass.set_pipeline(pipeline);
1760
+ pass.set_bind_group(0, &self.per_frame_bg, &[]);
1761
+ pass.set_bind_group(1, per_view_bg, &[]);
1762
+ pass.set_bind_group(2, self.per_material_bg_for(cmd.material), &[]);
1763
+ last_material = cmd.material;
1764
+ }
1765
+ if let Some((vb, ib, icount, lmin, lmax)) = mesh_fetch(cmd.mesh_handle, cmd.mesh_idx) {
1766
+ pass.set_bind_group(3, &self.per_draw_bgs[cmd.draw_slot], &[]);
1767
+ pass.set_vertex_buffer(0, vb.slice(..));
1768
+ pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32);
1769
+ let instance_range = self.bind_instance_buffer(pass, &cmd.instance);
1770
+ if instance_range.end <= instance_range.start { continue; }
1771
+ // Instance-tile culling: `commands` holds only the
1772
+ // opaque + cutout buckets (translucent draws dispatch
1773
+ // elsewhere), so emitting the visible tile ranges is
1774
+ // output-identical to the full-range draw. Tiles' pos
1775
+ // AABBs are inflated by max_scale × the mesh's local
1776
+ // half-diagonal (rotation-safe). Adjacent visible tiles
1777
+ // merge into one draw.
1778
+ let tiles = cmd.instance
1779
+ .as_ref()
1780
+ .filter(|_| planes.is_some() && lmin[0] <= lmax[0])
1781
+ .and_then(|inst| {
1782
+ self.instance_buffers
1783
+ .get(inst.buffer_handle as usize - 1)
1784
+ .and_then(|s| s.as_ref())
1785
+ .map(|s| &s.tiles)
1786
+ })
1787
+ .filter(|t| !t.is_empty());
1788
+ match (tiles, planes) {
1789
+ (Some(tiles), Some(planes)) => {
1790
+ let half_diag = 0.5 * ((lmax[0] - lmin[0]).powi(2)
1791
+ + (lmax[1] - lmin[1]).powi(2)
1792
+ + (lmax[2] - lmin[2]).powi(2)).sqrt();
1793
+ let mut run: Option<(u32, u32)> = None;
1794
+ for tile in tiles.iter() {
1795
+ let r = tile.max_scale * half_diag;
1796
+ let bmin = [tile.pmin[0] - r, tile.pmin[1] - r, tile.pmin[2] - r];
1797
+ let bmax = [tile.pmax[0] + r, tile.pmax[1] + r, tile.pmax[2] + r];
1798
+ if crate::scene::aabb_outside_frustum(planes, bmin, bmax) {
1799
+ if let Some((s, e)) = run.take() {
1800
+ pass.draw_indexed(0..icount, 0, s..e);
1801
+ }
1802
+ continue;
1803
+ }
1804
+ run = match run {
1805
+ Some((s, e)) if e == tile.first => Some((s, tile.first + tile.count)),
1806
+ Some((s, e)) => {
1807
+ pass.draw_indexed(0..icount, 0, s..e);
1808
+ Some((tile.first, tile.first + tile.count))
1809
+ }
1810
+ None => Some((tile.first, tile.first + tile.count)),
1811
+ };
1812
+ }
1813
+ if let Some((s, e)) = run {
1814
+ pass.draw_indexed(0..icount, 0, s..e);
1815
+ }
1816
+ }
1817
+ _ => {
1818
+ pass.draw_indexed(0..icount, 0, instance_range);
1819
+ }
1820
+ }
1821
+ }
1822
+ }
1823
+ }
1824
+
1825
+ /// EN-001 — resolve an instanced draw command's vertex slot 1 binding
1826
+ /// and return the instance range. For non-instanced draws (`info` is
1827
+ /// None) this is a no-op and returns `0..1`. For instanced draws
1828
+ /// with a missing/destroyed buffer slot we return an empty range
1829
+ /// so the caller skips the draw rather than crashing on a stale
1830
+ /// handle.
1831
+ fn bind_instance_buffer<'pass>(
1832
+ &'pass self,
1833
+ pass: &mut wgpu::RenderPass<'pass>,
1834
+ info: &Option<InstanceDrawInfo>,
1835
+ ) -> std::ops::Range<u32> {
1836
+ match info {
1837
+ None => 0..1,
1838
+ Some(inst) => {
1839
+ if inst.buffer_handle == 0 { return 0..1; }
1840
+ let slot_idx = inst.buffer_handle as usize - 1;
1841
+ match self.instance_buffers.get(slot_idx).and_then(|s| s.as_ref()) {
1842
+ Some(ib_slot) => {
1843
+ pass.set_vertex_buffer(1, ib_slot.buffer.slice(..));
1844
+ 0..inst.count
1845
+ }
1846
+ None => 0..0,
1847
+ }
1848
+ }
1849
+ }
1850
+ }
1851
+
1852
+ /// Phase 4b — rebuild the SceneInputs (group 4) bind group with
1853
+ /// the current frame's snapshot textures. Called by the Renderer
1854
+ /// once per frame when translucent draws exist and a SceneColor
1855
+ /// transient has been allocated. `scene_color_view` is the
1856
+ /// copy-to-sample snapshot from `hdr_rt`; `scene_depth_view` is
1857
+ /// the live depth buffer the opaque pass wrote. Other slots
1858
+ /// (impulse, motion vectors) bind to internal stub textures
1859
+ /// until Phase 7 wires them.
1860
+ pub fn update_scene_inputs(
1861
+ &mut self,
1862
+ device: &wgpu::Device,
1863
+ scene_color_view: &wgpu::TextureView,
1864
+ scene_depth_view: Option<&wgpu::TextureView>,
1865
+ impulse_view: Option<(&wgpu::TextureView, &wgpu::Sampler)>,
1866
+ ) {
1867
+ let depth_view = scene_depth_view.unwrap_or(&self._scene_stub_depth_view);
1868
+ // Layout entry 5 is NonFiltering — fallback uses the depth
1869
+ // sampler (which is also NonFiltering) rather than the
1870
+ // filtering color sampler so the layout matches either way.
1871
+ let (imp_view, imp_samp): (&wgpu::TextureView, &wgpu::Sampler) = match impulse_view {
1872
+ Some((v, s)) => (v, s),
1873
+ None => (&self._scene_stub_view, &self._scene_depth_sampler),
1874
+ };
1875
+ // Phase 4c — group 4 binding 2 receives a COPY_DST snapshot of
1876
+ // the opaque depth buffer, rather than the live depth-stencil
1877
+ // attachment (wgpu rejects read+write aliasing in the same
1878
+ // pass). Callers that don't need depth pass the stub view
1879
+ // already held here; `Renderer::end_frame_with_scene` acquires
1880
+ // a transient depth texture when any translucent material
1881
+ // declares a depth read.
1882
+ let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1883
+ label: Some("scene_inputs_bg"),
1884
+ layout: &self.layouts.scene_inputs,
1885
+ entries: &[
1886
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(scene_color_view) },
1887
+ wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self._scene_color_sampler) },
1888
+ wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(depth_view) },
1889
+ wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self._scene_depth_sampler) },
1890
+ wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(imp_view) },
1891
+ wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(imp_samp) },
1892
+ wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self._scene_stub_view) },
1893
+ ],
1894
+ });
1895
+ self.scene_inputs_bg = Some(bg);
1896
+
1897
+ // EN-063 — on wasm32 the scene inputs are folded into the
1898
+ // per_frame group (group 0), so the real snapshot views have
1899
+ // to land there: rebuild per_frame_bg with the same seven
1900
+ // resources bound above, at WASM_SCENE_INPUTS_BASE..+6. The
1901
+ // opaque pass re-binds this group too, which is harmless —
1902
+ // opaque materials never statically use the scene-input slots.
1903
+ #[cfg(target_arch = "wasm32")]
1904
+ {
1905
+ self.per_frame_bg = super::material_system_wasm::build_per_frame_bg_wasm(
1906
+ device,
1907
+ &self.layouts.per_frame,
1908
+ &self.per_frame_buffer,
1909
+ scene_color_view,
1910
+ &self._scene_color_sampler,
1911
+ depth_view,
1912
+ &self._scene_depth_sampler,
1913
+ imp_view,
1914
+ imp_samp,
1915
+ &self._scene_stub_view, // motion_vectors stub
1916
+ );
1917
+ }
1918
+ }
1919
+
1920
+ /// Sort the translucent bucket back-to-front by view depth. Call
1921
+ /// once per frame after all submits, before dispatch_translucent —
1922
+ /// alpha blending only composites correctly when farther surfaces
1923
+ /// land first. The sort is stable: equal-depth draws keep submission
1924
+ /// order, so existing games that hand-ordered their transparency
1925
+ /// keep their result. (Sorting costs pipeline-switch batching; with
1926
+ /// translucent counts in the hundreds the correctness is worth it.)
1927
+ pub fn sort_translucent(&mut self) {
1928
+ self.translucent_commands
1929
+ .sort_by(|a, b| b.view_depth.total_cmp(&a.view_depth));
1930
+ }
1931
+
1932
+ /// Dispatch translucent-bucket draws (Transparent, Refractive,
1933
+ /// Additive). Caller owns a render pass set up with a single HDR
1934
+ /// attachment (LoadOp::Load) + depth read-only. Refractive
1935
+ /// materials additionally receive the SceneInputs bind group at
1936
+ /// group 4 — `update_scene_inputs` must have been called this
1937
+ /// frame for that to be non-None.
1938
+ pub fn dispatch_translucent<'pass, F>(
1939
+ &'pass self,
1940
+ pass: &mut wgpu::RenderPass<'pass>,
1941
+ mut mesh_fetch: F,
1942
+ )
1943
+ where F: FnMut(u64, usize) -> Option<(&'pass wgpu::Buffer, &'pass wgpu::Buffer, u32)>
1944
+ {
1945
+ if self.translucent_commands.is_empty() { return; }
1946
+
1947
+ let mut last_material: MaterialHandle = 0;
1948
+ let mut last_reads_scene: bool = false;
1949
+ for cmd in &self.translucent_commands {
1950
+ if cmd.material != last_material {
1951
+ let mat = match self.pipelines.get(cmd.material as usize - 1) {
1952
+ Some(Some(m)) => m,
1953
+ _ => continue,
1954
+ };
1955
+ pass.set_pipeline(&mat.pipeline);
1956
+ pass.set_bind_group(0, &self.per_frame_bg, &[]);
1957
+ pass.set_bind_group(1, &self.per_view_bg, &[]);
1958
+ pass.set_bind_group(2, self.per_material_bg_for(cmd.material), &[]);
1959
+ // EN-063 — on wasm32 there is no group 4: the scene
1960
+ // inputs are folded into per_frame (group 0), already
1961
+ // bound above with the frame's snapshot views.
1962
+ if mat.reads_scene && cfg!(not(target_arch = "wasm32")) {
1963
+ if let Some(bg) = self.scene_inputs_bg.as_ref() {
1964
+ pass.set_bind_group(4, bg, &[]);
1965
+ }
1966
+ }
1967
+ last_material = cmd.material;
1968
+ last_reads_scene = mat.reads_scene;
1969
+ }
1970
+ // Re-bind group 4 if the material switches its reads_scene
1971
+ // between subsequent draws — rarely happens with a
1972
+ // stable bucket but keeps the state machine honest.
1973
+ let _ = last_reads_scene;
1974
+
1975
+ if let Some((vb, ib, icount)) = mesh_fetch(cmd.mesh_handle, cmd.mesh_idx) {
1976
+ pass.set_bind_group(3, &self.per_draw_bgs[cmd.draw_slot], &[]);
1977
+ pass.set_vertex_buffer(0, vb.slice(..));
1978
+ pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32);
1979
+ let instance_range = self.bind_instance_buffer(pass, &cmd.instance);
1980
+ if instance_range.end > instance_range.start {
1981
+ pass.draw_indexed(0..icount, 0, instance_range);
1982
+ }
1983
+ }
1984
+ }
1985
+ }
1986
+ }
1987
+
1988
+ // `build_per_frame_bg_wasm` (the wasm32-only per_frame bind-group builder)
1989
+ // lives in `material_system_wasm.rs` now — see the 2000-line policy note there.
1990
+
1991
+ // ---- Tests ----------------------------------------------------------
1992
+ // GPU-backed tests live in material_system_tests.rs (2000-line file
1993
+ // policy); the #[path] keeps them a private child module of this one.
1994
+ #[cfg(test)]
1995
+ #[path = "material_system_tests.rs"]
1996
+ mod tests_file;