concinnity-device 0.19.119

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
//! CBV/SRV/UAV heap slot layout for the DirectX backend. The shader-visible
//! descriptor heap is a flat array of fixed slots assigned in a positional
//! cascade: each block's base is the previous block's base plus its
//! reservation. Keeping the cascade in one function (instead of two dozen
//! inline `let x_slot = prev_slot + prev_extra` bindings) lets a unit test
//! assert it stays gap-free and that the total matches the created heap size,
//! so a stray offset edit fails a test instead of silently misbinding a
//! descriptor at shader time (a visual glitch or an out-of-bounds heap write
//! with no compile-time signal).
//!
//! Heap order:
//!   [0]                            shadow map array SRV (Texture2DArray)
//!   [1]                            IBL irradiance cube SRV
//!   [2]                            IBL prefilter cube SRV
//!   [atlas_base_slot..]            text atlas SRVs
//!   [hdr_srv_slot]                 HDR scene target SRV (composite pass)
//!   [lut_srv_slot]                 3D color-grading LUT SRV
//!   [post_srv_base_slot..]         POST_TARGET_SLOTS shared post-pass target SRVs
//!   [ssao_white_srv_slot]          1x1 white occlusion fallback (always)
//!   [decal_depth_srv_slot]         main-depth SRV (decal + glass + line passes)
//!   [decal_srv_base_slot..]        MAX_DECALS per-decal albedo SRVs
//!   [particle_srv_base_slot..]     MAX_EMITTERS emitter albedo SRVs
//!   [fog_froxel_uav_slot]          froxel-volume UAV
//!   [fog_froxel_srv_slot]          froxel-volume SRV
//!   [upscale_uav_slot]             temporal-upscale output UAV
//!   [upscale_srv_slot]             temporal-upscale output SRV
//!   [raymarch_srv_base_slot..+4]   raymarch t0..t3 (shadow, irr, prefilter, scene)
//!   [hiz_srv_slot]                 Hi-Z pyramid SRV (covers every mip)
//!   [hiz_uav_base_slot..]          HIZ_MAX_MIPS per-mip UAVs
//!   [probe_capture_srv_slot]       reflection-probe capture pyramid SRV
//!   [probe_capture_uav_base_slot..] PROBE_MAX_MIPS capture per-mip UAVs
//!   [probe_cube_uav_base_slot..]   PROBE_MAX_MIPS probe-cube per-mip UAVs
//!   [probe_mip0_pair_slot..+2]     the mirror copy's (capture mip 0, probe mip 0)
//!   [transparent_scene_copy_srv_slot] pre-transparent scene snapshot SRV
//!   [glass_reflection_srv_base_slot..] GLASS_REFLECTION_SRV_SLOTS glass reflection layer windows
//!   [gbuffer_srv_base_slot..]      (G-buffer) normal+depth, roughness, velocity
//!   [rt_output_srv_slot]           (RT) reflection output
//!   [planar_resolve_srv_base_slot..] planar reflector resolves
//!   [flat_pool_base_slot..]        bindless albedo + normal pool, per frame
//!   [probe_cubes_srv_slot]         the reflection-probe cube array
//!   [spot_shadow_srv_slot]         spot shadow depth array SRV (Texture2DArray)
//!   [ltc_srv_base_slot..+2]        area-light LTC tables (matrix, magnitude)
//!   [reactive_mask_srv_slot]       reactive mask SRV (TAA, upscalers, composite)
//!   srv_slots                      total descriptor count (heap size)
//!
//! The RTV heap follows the same cascade in `RtvHeapLayout`, and the DSV heap's
//! fixed slots are the `DSV_*` constants.

use concinnity_core::gfx::render_types::{MAX_SHADOWED_SPOTS, NUM_SHADOW_CASCADES};

use super::HIZ_MAX_MIPS;
use crate::directx::context::FRAMES;
use crate::directx::decal::MAX_DECALS;
use crate::directx::particle::MAX_EMITTERS;
use crate::directx::post::descriptors::POST_TARGET_SLOTS;
use crate::directx::probe_prefilter::PROBE_MAX_MIPS;

// Per-world feature counts that size the variable-length blocks of the SRV
// heap. The fixed-size blocks (decals, particles, raymarch, Hi-Z, fog,
// upscale) use module constants and are not parameters.
pub(in crate::directx) struct SrvHeapParams {
    pub n_atlases: usize,
    // 3 (normal+depth, roughness, velocity) when the unified G-buffer pre-pass
    // is active, else 0. The shared post passes take their targets' SRVs from a
    // fixed block instead (`post_srv_base_slot`), so no effect drawing through
    // that seam appears here.
    pub gbuffer_srv_extra: usize,
    // 1 when hardware ray-traced reflections are enabled (the RT output target's
    // SRV), else 0.
    pub rt_output_srv_extra: usize,
    // One resolve SRV per distinct planar reflector plane (0..MAX_PLANAR_PLANES),
    // reserved when the world has glass panes assigned to a planar slot, else 0.
    // The glass pass binds these per pane.
    pub planar_resolve_srv_extra: usize,
    // Flat deduplicated bindless pool sizes: one SRV per distinct albedo-pool
    // texture (incl. emissive / ORM maps) followed by one per distinct normal
    // map. The bindless main pass and the RT hit shader address this region by a
    // flat index (`albedo = texture_slot`, `normal = albedo_count + normal_slot`),
    // mirroring Vulkan/Metal. `albedo_count` is the albedo resource count
    // (>= 1: a 1x1 white fallback stands in when no albedo textures exist);
    // `normal_count` includes the slot-0 flat-normal fallback.
    pub albedo_count: usize,
    pub normal_count: usize,
}

// Resolved slot indices into the CBV/SRV/UAV heap. Field order matches the
// heap order documented above; `srv_slots` is the total descriptor count the
// heap is created with.
pub(in crate::directx) struct SrvHeapLayout {
    pub atlas_base_slot: usize,
    pub hdr_srv_slot: usize,
    pub lut_srv_slot: usize,
    // Base of the shared post passes' target SRVs. A fixed
    // `POST_TARGET_SLOTS`-wide block, always reserved, sub-allocated at runtime
    // by `post/descriptors.rs`. Fixed and unconditional on purpose: a pass that
    // draws through the shared seam should not have to add a row to this
    // cascade, and a block that appears only when one feature is on would put
    // every later block at a different offset per world.
    pub post_srv_base_slot: usize,
    pub ssao_white_srv_slot: usize,
    pub decal_depth_srv_slot: usize,
    pub decal_srv_base_slot: usize,
    pub particle_srv_base_slot: usize,
    pub fog_froxel_uav_slot: usize,
    pub fog_froxel_srv_slot: usize,
    pub upscale_uav_slot: usize,
    pub upscale_srv_slot: usize,
    pub raymarch_srv_base_slot: usize,
    pub hiz_srv_slot: usize,
    pub hiz_uav_base_slot: usize,
    pub probe_capture_srv_slot: usize,
    pub probe_capture_uav_base_slot: usize,
    pub probe_cube_uav_base_slot: usize,
    pub probe_mip0_pair_slot: usize,
    pub transparent_scene_copy_srv_slot: usize,
    // The glass reflection pre-pass's three two-descriptor windows over its
    // layers (see `GLASS_REFLECTION_SRV_SLOTS`).
    pub glass_reflection_srv_base_slot: usize,
    pub gbuffer_srv_base_slot: usize,
    pub rt_output_srv_slot: usize,
    // Planar reflection resolve SRVs (one per distinct reflector plane).
    pub planar_resolve_srv_base_slot: usize,
    pub flat_pool_base_slot: usize,
    // The reflection-probe cube array's SRV (the bindless main shader's
    // `TextureCubeArray probe_cubes` table), one slot however many cubes it holds.
    pub probe_cubes_srv_slot: usize,
    // Spot shadow depth array SRV. A single slot rather than one of the three
    // fixed globals: the main root signatures reach the CSM array and the IBL
    // cubes as one contiguous 3-slot table, so slots 0..3 cannot take a fourth
    // member without splitting that table.
    pub spot_shadow_srv_slot: usize,
    // The two area-light LTC lookup tables, contiguous so one 2-descriptor
    // table covers both: [0] the inverse-transform matrix (RGBA32F), [1] the
    // magnitude / Fresnel pair (RG32F).
    pub ltc_srv_base_slot: usize,
    pub reactive_mask_srv_slot: usize,
    pub srv_slots: usize,
}

// The three global SRVs (shadow array, IBL irradiance, IBL prefilter) occupy
// slots [0, 3); the first per-world block starts here.
const GLOBAL_SRV_COUNT: usize = 3;

impl SrvHeapLayout {
    pub(in crate::directx) fn compute(p: &SrvHeapParams) -> Self {
        let atlas_base_slot = GLOBAL_SRV_COUNT;
        // Text atlases. `n_atlases.max(1)` reserves one slot even with no atlas
        // so the HDR SRV that follows always lands at a stable offset.
        let hdr_srv_slot = atlas_base_slot + p.n_atlases.max(1);
        let lut_srv_slot = hdr_srv_slot + 1;
        let post_srv_base_slot = lut_srv_slot + 1;
        // The white fallback is always present, so the main pass can bind a
        // pass-through occlusion when SSAO is disabled.
        let ssao_white_srv_slot = post_srv_base_slot + POST_TARGET_SLOTS;
        let decal_depth_srv_slot = ssao_white_srv_slot + 1;
        let decal_srv_base_slot = decal_depth_srv_slot + 1;
        let particle_srv_base_slot = decal_srv_base_slot + MAX_DECALS;
        let fog_froxel_uav_slot = particle_srv_base_slot + MAX_EMITTERS;
        let fog_froxel_srv_slot = fog_froxel_uav_slot + 1;
        let upscale_uav_slot = fog_froxel_srv_slot + 1;
        let upscale_srv_slot = upscale_uav_slot + 1;
        let raymarch_srv_base_slot = upscale_srv_slot + 1;
        let hiz_srv_slot = raymarch_srv_base_slot + 4;
        let hiz_uav_base_slot = hiz_srv_slot + 1;
        // One bake's convolution descriptors. Rewritten per bake rather than per
        // probe slot: only one probe convolves at a time, and the install's fence
        // gate proves the prior bake's dispatches retired before the next rewrite.
        let probe_capture_srv_slot = hiz_uav_base_slot + HIZ_MAX_MIPS;
        let probe_capture_uav_base_slot = probe_capture_srv_slot + 1;
        let probe_cube_uav_base_slot = probe_capture_uav_base_slot + PROBE_MAX_MIPS;
        let probe_mip0_pair_slot = probe_cube_uav_base_slot + PROBE_MAX_MIPS;
        let transparent_scene_copy_srv_slot = probe_mip0_pair_slot + 2;
        let glass_reflection_srv_base_slot = transparent_scene_copy_srv_slot + 1;
        // Unified G-buffer SRVs (normal+depth, roughness, velocity). 3 slots
        // when any screen-space consumer drives the pre-pass, else 0.
        let gbuffer_srv_base_slot = glass_reflection_srv_base_slot + GLASS_REFLECTION_SRV_SLOTS;
        // RT-reflection output SRV: one slot at the heap tail when RT is on.
        let rt_output_srv_slot = gbuffer_srv_base_slot + p.gbuffer_srv_extra;
        // Planar reflection resolve SRVs: one per distinct reflector plane, bound
        // per pane by the glass pass.
        let planar_resolve_srv_base_slot = rt_output_srv_slot + p.rt_output_srv_extra;
        // Flat deduplicated bindless pool: [albedo SRVs..] ++ [normal SRVs..],
        // one full copy per frame in flight. The bindless main pass and the RT
        // hit shader bind the current frame's copy and index it by a flat slot.
        // Per-frame copies let a streamed texture swap rewrite the copy whose
        // frame just fence-waited (provably unreferenced) instead of draining
        // the device to rewrite one shared region while lists reference it.
        let flat_pool_base_slot = planar_resolve_srv_base_slot + p.planar_resolve_srv_extra;
        // Reflection-probe cube array at the heap tail: one SRV over every cube.
        let probe_cubes_srv_slot = flat_pool_base_slot + FRAMES * (p.albedo_count + p.normal_count);
        // Spot shadow array SRV. Always reserved: a world with no shadowed spot
        // binds a 1x1 fallback array there so the descriptor is never unwritten.
        let spot_shadow_srv_slot = probe_cubes_srv_slot + 1;
        // Area-light LTC tables. Scene-independent (fitted at build time), so
        // they are always reserved and always uploaded.
        let ltc_srv_base_slot = spot_shadow_srv_slot + 1;
        // The reactive mask is one of the scene targets, so it is always
        // reserved.
        let reactive_mask_srv_slot = ltc_srv_base_slot + 2;
        let srv_slots = reactive_mask_srv_slot + 1;
        Self {
            atlas_base_slot,
            hdr_srv_slot,
            lut_srv_slot,
            post_srv_base_slot,
            ssao_white_srv_slot,
            decal_depth_srv_slot,
            decal_srv_base_slot,
            particle_srv_base_slot,
            fog_froxel_uav_slot,
            fog_froxel_srv_slot,
            upscale_uav_slot,
            upscale_srv_slot,
            raymarch_srv_base_slot,
            hiz_srv_slot,
            hiz_uav_base_slot,
            probe_capture_srv_slot,
            probe_capture_uav_base_slot,
            probe_cube_uav_base_slot,
            probe_mip0_pair_slot,
            transparent_scene_copy_srv_slot,
            glass_reflection_srv_base_slot,
            gbuffer_srv_base_slot,
            rt_output_srv_slot,
            planar_resolve_srv_base_slot,
            flat_pool_base_slot,
            probe_cubes_srv_slot,
            spot_shadow_srv_slot,
            ltc_srv_base_slot,
            reactive_mask_srv_slot,
            srv_slots,
        }
    }
}

// The live-toggleable features outside the shared post block (the unified
// G-buffer pre-pass, the RT reflection output and the glass reflection layers)
// reserve their RTV / DSV / SRV slots UNCONDITIONALLY, independent of the
// world's init-time gates. The slots are fixed positions the passes bind by
// absolute index, so a live toggle (`apply_quality_settings`) can build a
// feature that launched off and write into its pre-reserved slot without
// shifting any other feature's slots. A reserved-but-unbuilt feature leaves its
// slots unwritten; that is safe because no always-running pass binds them.
//
// Unified G-buffer pre-pass: normal+depth, roughness, velocity, plus one DSV
// (`DSV_GBUFFER_DEPTH_SLOT`) for its private depth.
pub(super) const GBUFFER_TARGETS: usize = 3;
// RT-reflection output: the trace writes the RTV, the post stack samples the SRV.
pub(super) const RT_OUTPUT_TARGETS: usize = 1;
// The glass reflection pre-pass's two layers. Its SRVs are three contiguous
// two-descriptor windows the RT transparent signature's t11..t12 table points
// at: the first layer's pass (both null), the second layer's (the first layer,
// null) and the scene pass's (both layers). A null SRV reads as zero, which is
// the empty layer the first one peels behind.
pub(in crate::directx) const GLASS_REFLECTION_TARGETS: usize = 2;
pub(in crate::directx) const GLASS_REFLECTION_SRV_SLOTS: usize = 6;
// The reactive mask's RTV and the null RTV that stands in for it.
pub(super) const REACTIVE_MASK_RTVS: usize = 2;

// DSV heap slots: the main depth, one per shadow cascade (a slice each into the
// shadow map array), one per shadowed spot slice, the unified G-buffer
// pre-pass's private depth buffer, then the glass reflection pre-pass's.
pub(super) const DSV_MAIN_DEPTH_SLOT: usize = 0;
pub(super) const DSV_SHADOW_BASE_SLOT: usize = DSV_MAIN_DEPTH_SLOT + 1;
pub(super) const DSV_SPOT_SHADOW_BASE_SLOT: usize = DSV_SHADOW_BASE_SLOT + NUM_SHADOW_CASCADES;
pub(super) const DSV_GBUFFER_DEPTH_SLOT: usize = DSV_SPOT_SHADOW_BASE_SLOT + MAX_SHADOWED_SPOTS;
pub(super) const DSV_GLASS_REFLECTION_DEPTH_SLOT: usize = DSV_GBUFFER_DEPTH_SLOT + 1;
pub(super) const DSV_SLOTS: usize = DSV_GLASS_REFLECTION_DEPTH_SLOT + 1;

// Resolved slot indices into the RTV heap, after the back-buffer views at
// `[0, FRAMES)`. `rtv_slots` is the total descriptor count the heap is created
// with.
pub(super) struct RtvHeapLayout {
    // HDR scene target.
    pub hdr_slot: usize,
    // The shared fullscreen post passes' target RTVs: a fixed
    // `POST_TARGET_SLOTS` block sub-allocated at runtime by
    // `post/descriptors.rs`. A post target is color only, so it reserves no DSV.
    pub post_base_slot: usize,
    // `hdr_resolve`, which the projected-decal pass renders into. Reserved only
    // under MSAA; the MSAA-off path writes through the HDR scene RTV.
    pub decal_resolve_slot: usize,
    pub gbuffer_base_slot: usize,
    pub rt_output_slot: usize,
    pub glass_reflection_base_slot: usize,
    // The reactive mask's view, then a null view its writers bind in its place
    // on a frame nothing reads it.
    pub reactive_mask_base_slot: usize,
    pub rtv_slots: usize,
}

impl RtvHeapLayout {
    pub(super) fn compute(msaa_samples: u32) -> Self {
        let hdr_slot = FRAMES;
        let post_base_slot = hdr_slot + 1;
        let decal_resolve_slot = post_base_slot + POST_TARGET_SLOTS;
        let gbuffer_base_slot = decal_resolve_slot + usize::from(msaa_samples > 1);
        let rt_output_slot = gbuffer_base_slot + GBUFFER_TARGETS;
        let glass_reflection_base_slot = rt_output_slot + RT_OUTPUT_TARGETS;
        let reactive_mask_base_slot = glass_reflection_base_slot + GLASS_REFLECTION_TARGETS;
        let rtv_slots = reactive_mask_base_slot + REACTIVE_MASK_RTVS;
        Self {
            hdr_slot,
            post_base_slot,
            decal_resolve_slot,
            gbuffer_base_slot,
            rt_output_slot,
            glass_reflection_base_slot,
            reactive_mask_base_slot,
            rtv_slots,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Walk the cascade for a given feature set: pair each block's reported
    // base with the size that block is independently known to occupy, then
    // assert every base equals the running total of all earlier reservations
    // (gap-free, no overlap) and that `srv_slots` covers the whole chain.
    //
    // The reservations here are derived independently of `compute`'s
    // arithmetic, so an offset slip in `compute` (a `+ 2` where `+ 1` was
    // meant, or a block sized off the wrong constant) makes a base disagree
    // with the running total and fails the assert.
    fn assert_gap_free(p: &SrvHeapParams) {
        let l = SrvHeapLayout::compute(p);
        let blocks: [(usize, usize); 29] = [
            (l.atlas_base_slot, p.n_atlases.max(1)),
            (l.hdr_srv_slot, 1),
            (l.lut_srv_slot, 1),
            (l.post_srv_base_slot, POST_TARGET_SLOTS),
            (l.ssao_white_srv_slot, 1),
            (l.decal_depth_srv_slot, 1),
            (l.decal_srv_base_slot, MAX_DECALS),
            (l.particle_srv_base_slot, MAX_EMITTERS),
            (l.fog_froxel_uav_slot, 1),
            (l.fog_froxel_srv_slot, 1),
            (l.upscale_uav_slot, 1),
            (l.upscale_srv_slot, 1),
            (l.raymarch_srv_base_slot, 4),
            (l.hiz_srv_slot, 1),
            (l.hiz_uav_base_slot, HIZ_MAX_MIPS),
            (l.probe_capture_srv_slot, 1),
            (l.probe_capture_uav_base_slot, PROBE_MAX_MIPS),
            (l.probe_cube_uav_base_slot, PROBE_MAX_MIPS),
            (l.probe_mip0_pair_slot, 2),
            (l.transparent_scene_copy_srv_slot, 1),
            (l.glass_reflection_srv_base_slot, 6),
            (l.gbuffer_srv_base_slot, p.gbuffer_srv_extra),
            (l.rt_output_srv_slot, p.rt_output_srv_extra),
            (l.planar_resolve_srv_base_slot, p.planar_resolve_srv_extra),
            (
                l.flat_pool_base_slot,
                FRAMES * (p.albedo_count + p.normal_count),
            ),
            (l.probe_cubes_srv_slot, 1),
            (l.spot_shadow_srv_slot, 1),
            (l.ltc_srv_base_slot, 2),
            (l.reactive_mask_srv_slot, 1),
        ];
        let mut expected_base = GLOBAL_SRV_COUNT;
        for (i, (base, count)) in blocks.iter().enumerate() {
            assert_eq!(
                *base, expected_base,
                "block {i} base {base} should sit at running total {expected_base}",
            );
            expected_base += count;
        }
        assert_eq!(
            l.srv_slots, expected_base,
            "srv_slots must cover every block exactly",
        );
        // The heap always reserves at least the three global SRVs.
        assert!(l.srv_slots >= GLOBAL_SRV_COUNT);
    }

    #[test]
    fn layout_gap_free_all_features_on() {
        assert_gap_free(&SrvHeapParams {
            n_atlases: 2,
            gbuffer_srv_extra: 3,
            rt_output_srv_extra: 1,
            planar_resolve_srv_extra: 2,
            albedo_count: 9,
            normal_count: 4,
        });
    }

    #[test]
    fn layout_gap_free_all_features_off() {
        assert_gap_free(&SrvHeapParams {
            n_atlases: 0,
            gbuffer_srv_extra: 0,
            rt_output_srv_extra: 0,
            planar_resolve_srv_extra: 0,
            albedo_count: 1,
            normal_count: 1,
        });
    }

    #[test]
    fn layout_gap_free_mixed_features() {
        assert_gap_free(&SrvHeapParams {
            n_atlases: 1,
            gbuffer_srv_extra: 3,
            rt_output_srv_extra: 1,
            planar_resolve_srv_extra: 1,
            albedo_count: 50,
            normal_count: 12,
        });
    }

    // The per-world blocks must start past the three fixed global SRVs
    // regardless of feature set, so slot 0/1/2 are never reused.
    #[test]
    fn first_block_clears_the_global_srvs() {
        let l = SrvHeapLayout::compute(&SrvHeapParams {
            n_atlases: 0,
            gbuffer_srv_extra: 0,
            rt_output_srv_extra: 0,
            planar_resolve_srv_extra: 0,
            albedo_count: 1,
            normal_count: 1,
        });
        assert_eq!(l.atlas_base_slot, GLOBAL_SRV_COUNT);
        assert!(l.hdr_srv_slot >= GLOBAL_SRV_COUNT);
    }

    // The RTV blocks must follow the back buffers gap-free, with the decal
    // resolve slot present only under MSAA. Sizes are restated independently of
    // `compute` so an offset slip there fails the assert.
    fn assert_rtv_gap_free(msaa_samples: u32) {
        let l = RtvHeapLayout::compute(msaa_samples);
        let decal_resolve = if msaa_samples > 1 { 1 } else { 0 };
        let blocks: [(usize, usize); 7] = [
            (l.hdr_slot, 1),
            (l.post_base_slot, POST_TARGET_SLOTS),
            (l.decal_resolve_slot, decal_resolve),
            (l.gbuffer_base_slot, 3),
            (l.rt_output_slot, 1),
            (l.glass_reflection_base_slot, 2),
            (l.reactive_mask_base_slot, 2),
        ];
        let mut expected_base = FRAMES;
        for (i, (base, count)) in blocks.iter().enumerate() {
            assert_eq!(
                *base, expected_base,
                "RTV block {i} base {base} should sit at running total {expected_base}",
            );
            expected_base += count;
        }
        assert_eq!(l.rtv_slots, expected_base);
    }

    #[test]
    fn rtv_layout_gap_free_without_msaa() {
        assert_rtv_gap_free(1);
    }

    #[test]
    fn rtv_layout_gap_free_with_msaa() {
        assert_rtv_gap_free(4);
    }

    #[test]
    fn rtv_post_block_follows_the_hdr_target() {
        let l = RtvHeapLayout::compute(1);
        assert_eq!(l.post_base_slot, FRAMES + 1);
        assert_eq!(l.decal_resolve_slot, l.gbuffer_base_slot);
    }

    // Main depth first, then a view per cascade and per shadowed spot slice,
    // then the G-buffer and glass reflection depths, with the heap sized to cover
    // the last one.
    #[test]
    fn dsv_layout_covers_every_depth_view() {
        assert_eq!(DSV_MAIN_DEPTH_SLOT, 0);
        assert_eq!(
            DSV_SPOT_SHADOW_BASE_SLOT - DSV_SHADOW_BASE_SLOT,
            NUM_SHADOW_CASCADES
        );
        assert_eq!(
            DSV_GBUFFER_DEPTH_SLOT - DSV_SPOT_SHADOW_BASE_SLOT,
            MAX_SHADOWED_SPOTS
        );
        assert_eq!(DSV_GLASS_REFLECTION_DEPTH_SLOT, DSV_GBUFFER_DEPTH_SLOT + 1);
        assert_eq!(DSV_SLOTS, 3 + NUM_SHADOW_CASCADES + MAX_SHADOWED_SPOTS);
    }
}