concinnity-device 0.18.64

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
Documentation
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
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
// src/directx/resources.rs
//
// Runtime GPU resource management for DxContext: texture-pool slot updates,
// mesh upload/eviction, chunk streaming, and skinned-mesh upload. Also owns
// the skinned-mesh pipelines (built lazily by `upload_skinned` the first time
// a SkinnedMesh is uploaded), mirroring metal/resources/skinning.rs.
use concinnity_core::gfx::transform::IDENTITY;
use windows::Win32::Graphics::Direct3D12::*;
use windows::Win32::Graphics::Dxgi::Common::*;

use super::allocator::PooledBuffer;
use crate::gfx::backend::ChunkMesh;
use crate::gfx::mesh_payload::{SkinnedVertex, Vertex};
use crate::gfx::render_types::*;

use super::builtins;
use super::com;
use super::context::*;
use super::init::pipelines::{create_main_instanced_root_signature, create_main_pso};
use super::pipeline::{serialize_and_create_root_sig, skinned_input_layout};
use super::slang_builtins;
use super::texture::*;

// Skinned pipeline builders
//
// These mirror the static + shadow PSO builders in init/pipelines.rs but use
// the skinned vertex layout (80-byte SkinnedVertex with joint indices +
// weights) and pair with the skinned vertex shaders.

// Compile the skinned-mesh shader stages. Returns (main_skinned_vs,
// shadow_skinned_vs, frag_ps). The main skinned VS pairs with the standard
// fragment shader; the shadow skinned VS is depth-only. `frag_bytes`, when
// non-empty, is treated as pre-compiled DXBC (the same resolution the static
// path applies); otherwise the built-in default fragment shader is compiled.
// Compiled skinned-mesh shaders: main vertex, shadow vertex, fragment bytecode.
type SkinnedShaders = (Vec<u8>, Vec<u8>, Vec<u8>);

fn compile_skinned_shaders(frag_bytes: &[u8], hot_reload: bool) -> Result<SkinnedShaders, String> {
    let main_vs = builtins::SKINNED_VERT.compile(hot_reload)?;
    let shadow_vs = slang_builtins::SKINNED_SHADOW_VERT.compile(hot_reload)?;
    let frag_ps = if !frag_bytes.is_empty() {
        frag_bytes.to_vec()
    } else {
        builtins::MAIN_FRAG.compile(hot_reload)?
    };
    Ok((main_vs, shadow_vs, frag_ps))
}

// Same as the shadow root signature but with one extra root SRV at slot [2]
// (t0) carrying the per-object joint matrices. Used by the skinned shadow PSO.
fn create_skinned_shadow_root_signature(
    device: &ID3D12Device,
) -> Result<ID3D12RootSignature, String> {
    let params = [
        // [0] Root constants: model mat4 (16) + cascade_idx + 3 pad = 20 DWORDs at b0
        D3D12_ROOT_PARAMETER {
            ParameterType: D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS,
            Anonymous: D3D12_ROOT_PARAMETER_0 {
                Constants: D3D12_ROOT_CONSTANTS {
                    ShaderRegister: 0,
                    RegisterSpace: 0,
                    Num32BitValues: 20,
                },
            },
            ShaderVisibility: D3D12_SHADER_VISIBILITY_VERTEX,
        },
        // [1] Root CBV: shadow UBO (light_vps[4] + cascade_splits) at b1
        D3D12_ROOT_PARAMETER {
            ParameterType: D3D12_ROOT_PARAMETER_TYPE_CBV,
            Anonymous: D3D12_ROOT_PARAMETER_0 {
                Descriptor: D3D12_ROOT_DESCRIPTOR {
                    ShaderRegister: 1,
                    RegisterSpace: 0,
                },
            },
            ShaderVisibility: D3D12_SHADER_VISIBILITY_VERTEX,
        },
        // [2] Root SRV: per-object joint matrices (t0, VS-only)
        D3D12_ROOT_PARAMETER {
            ParameterType: D3D12_ROOT_PARAMETER_TYPE_SRV,
            Anonymous: D3D12_ROOT_PARAMETER_0 {
                Descriptor: D3D12_ROOT_DESCRIPTOR {
                    ShaderRegister: 0,
                    RegisterSpace: 0,
                },
            },
            ShaderVisibility: D3D12_SHADER_VISIBILITY_VERTEX,
        },
    ];

    serialize_and_create_root_sig(device, &params, "skinned shadow root sig")
}

// Main-pass PSO for skinned geometry: the skinned vertex shader (80-byte
// layout) paired with the standard fragment shader. Uses the instanced root
// signature: its extra root SRV at slot [8] (t3) carries the joint matrices.
fn create_skinned_pso(
    device: &ID3D12Device,
    root_sig: &ID3D12RootSignature,
    vs: &[u8],
    ps: &[u8],
    rtv_format: DXGI_FORMAT,
    sample_count: u32,
) -> Result<ID3D12PipelineState, String> {
    create_skinned_pso_filled(
        device,
        root_sig,
        vs,
        ps,
        rtv_format,
        sample_count,
        D3D12_FILL_MODE_SOLID,
    )
}

// The Wireframe view mode's variant of `create_skinned_pso`; see
// [`super::wireframe`] for why the mode needs its own PSO per pipeline.
pub(in crate::directx) fn create_skinned_pso_wireframe(
    device: &ID3D12Device,
    root_sig: &ID3D12RootSignature,
    vs: &[u8],
    ps: &[u8],
    rtv_format: DXGI_FORMAT,
    sample_count: u32,
) -> Result<ID3D12PipelineState, String> {
    create_skinned_pso_filled(
        device,
        root_sig,
        vs,
        ps,
        rtv_format,
        sample_count,
        D3D12_FILL_MODE_WIREFRAME,
    )
}

fn create_skinned_pso_filled(
    device: &ID3D12Device,
    root_sig: &ID3D12RootSignature,
    vs: &[u8],
    ps: &[u8],
    rtv_format: DXGI_FORMAT,
    sample_count: u32,
    fill_mode: D3D12_FILL_MODE,
) -> Result<ID3D12PipelineState, String> {
    let layout = skinned_input_layout();
    let pso_desc = D3D12_GRAPHICS_PIPELINE_STATE_DESC {
        pRootSignature: com::borrowed(root_sig),
        VS: D3D12_SHADER_BYTECODE {
            pShaderBytecode: vs.as_ptr() as _,
            BytecodeLength: vs.len(),
        },
        PS: D3D12_SHADER_BYTECODE {
            pShaderBytecode: ps.as_ptr() as _,
            BytecodeLength: ps.len(),
        },
        InputLayout: D3D12_INPUT_LAYOUT_DESC {
            pInputElementDescs: layout.as_ptr(),
            NumElements: layout.len() as u32,
        },
        PrimitiveTopologyType: D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE,
        NumRenderTargets: 1,
        RTVFormats: {
            let mut a = [DXGI_FORMAT_UNKNOWN; 8];
            a[0] = rtv_format;
            a
        },
        DSVFormat: DXGI_FORMAT_D32_FLOAT,
        SampleDesc: DXGI_SAMPLE_DESC {
            Count: sample_count,
            Quality: 0,
        },
        SampleMask: u32::MAX,
        RasterizerState: D3D12_RASTERIZER_DESC {
            FillMode: fill_mode,
            CullMode: D3D12_CULL_MODE_NONE,
            FrontCounterClockwise: true.into(),
            DepthClipEnable: true.into(),
            ..Default::default()
        },
        DepthStencilState: D3D12_DEPTH_STENCIL_DESC {
            DepthEnable: true.into(),
            DepthWriteMask: D3D12_DEPTH_WRITE_MASK_ALL,
            DepthFunc: D3D12_COMPARISON_FUNC_LESS,
            StencilEnable: false.into(),
            ..Default::default()
        },
        BlendState: D3D12_BLEND_DESC {
            RenderTarget: {
                let mut arr = [D3D12_RENDER_TARGET_BLEND_DESC::default(); 8];
                arr[0] = D3D12_RENDER_TARGET_BLEND_DESC {
                    BlendEnable: false.into(),
                    RenderTargetWriteMask: D3D12_COLOR_WRITE_ENABLE_ALL.0 as u8,
                    ..Default::default()
                };
                arr
            },
            ..Default::default()
        },
        ..Default::default()
    };

    // SAFETY: `desc` outlives this synchronous call, and so do the root signature, shader bytecode
    // and input-element array whose raw pointers it borrows.
    unsafe { crate::directx::pso_library::create_graphics(device, &pso_desc) }
        .map_err(|e| format!("create skinned PSO: {e}"))
}

// Shadow-pass PSO for skinned geometry: the skinned shadow vertex shader
// (80-byte layout, depth-only). Uses the skinned shadow root signature.
fn create_skinned_shadow_pso(
    device: &ID3D12Device,
    root_sig: &ID3D12RootSignature,
    vs: &[u8],
) -> Result<ID3D12PipelineState, String> {
    let layout = skinned_input_layout();
    let pso_desc = D3D12_GRAPHICS_PIPELINE_STATE_DESC {
        pRootSignature: com::borrowed(root_sig),
        VS: D3D12_SHADER_BYTECODE {
            pShaderBytecode: vs.as_ptr() as _,
            BytecodeLength: vs.len(),
        },
        InputLayout: D3D12_INPUT_LAYOUT_DESC {
            pInputElementDescs: layout.as_ptr(),
            NumElements: layout.len() as u32,
        },
        PrimitiveTopologyType: D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE,
        NumRenderTargets: 0,
        DSVFormat: DXGI_FORMAT_D32_FLOAT,
        SampleDesc: DXGI_SAMPLE_DESC {
            Count: 1,
            Quality: 0,
        },
        SampleMask: u32::MAX,
        RasterizerState: D3D12_RASTERIZER_DESC {
            FillMode: D3D12_FILL_MODE_SOLID,
            CullMode: D3D12_CULL_MODE_NONE,
            FrontCounterClockwise: true.into(),
            DepthBias: 1,
            DepthBiasClamp: 0.01,
            SlopeScaledDepthBias: 1.0,
            DepthClipEnable: true.into(),
            ..Default::default()
        },
        DepthStencilState: D3D12_DEPTH_STENCIL_DESC {
            DepthEnable: true.into(),
            DepthWriteMask: D3D12_DEPTH_WRITE_MASK_ALL,
            DepthFunc: D3D12_COMPARISON_FUNC_LESS,
            StencilEnable: false.into(),
            ..Default::default()
        },
        BlendState: D3D12_BLEND_DESC {
            ..Default::default()
        },
        ..Default::default()
    };

    // SAFETY: `desc` outlives this synchronous call, and so do the root signature, shader bytecode
    // and input-element array whose raw pointers it borrows.
    unsafe { crate::directx::pso_library::create_graphics(device, &pso_desc) }
        .map_err(|e| format!("create skinned shadow PSO: {e}"))
}

impl DxContext {
    // CPU descriptor handle for CBV/SRV/UAV heap `slot`.
    fn srv_slot_cpu(&self, slot: usize) -> D3D12_CPU_DESCRIPTOR_HANDLE {
        // SAFETY: a property query on a live descriptor heap; it only reads.
        let base = unsafe {
            self.descriptors
                .srv_heap
                .GetCPUDescriptorHandleForHeapStart()
        };
        D3D12_CPU_DESCRIPTOR_HANDLE {
            ptr: base.ptr + slot * self.descriptors.srv_descriptor_size,
        }
    }

    // Resolve the pool resource a legacy per-draw normal SRV should bake for a
    // `normal_map_slot`: a real normal map is a texture at its own slot (clamped
    // to the pool); `NO_NORMAL_MAP_SLOT` selects the flat-normal fallback (the
    // first entry of `fallback_textures`).
    fn normal_pool_resource(&self, normal_map_slot: usize) -> &ID3D12Resource {
        if normal_map_slot == NO_NORMAL_MAP_SLOT {
            &self.descriptors.fallback_textures[0]
        } else {
            let last = self.descriptors.textures.len().saturating_sub(1);
            &self.descriptors.textures[normal_map_slot.min(last)]
        }
    }

    // The same for a legacy per-draw albedo SRV: a real albedo is a texture at
    // its own slot; `NO_ALBEDO_SLOT` selects the white fallback (the second
    // entry of `fallback_textures`).
    fn albedo_pool_resource(&self, texture_slot: usize) -> &ID3D12Resource {
        if texture_slot == NO_ALBEDO_SLOT {
            &self.descriptors.fallback_textures[1]
        } else {
            let last = self.descriptors.textures.len().saturating_sub(1);
            &self.descriptors.textures[texture_slot.min(last)]
        }
    }

    // Whether a draw samples texture `slot` as its normal map, given the pool
    // clamp. A real normal is a texture at its own handle; `NO_NORMAL_MAP_SLOT`
    // samples the never-streamed flat-normal fallback and matches no streamed
    // slot.
    fn normal_is_slot(&self, nms: usize, slot: usize) -> bool {
        let last = self.descriptors.textures.len().saturating_sub(1);
        nms != NO_NORMAL_MAP_SLOT && nms.min(last) == slot
    }

    // The same for an albedo slot: `NO_ALBEDO_SLOT` samples the never-streamed
    // white fallback and so matches no streamed slot.
    fn albedo_is_slot(&self, ts: usize, slot: usize) -> bool {
        let last = self.descriptors.textures.len().saturating_sub(1);
        ts != NO_ALBEDO_SLOT && ts.min(last) == slot
    }

    // Re-point the build-time per-object / per-cluster / per-skinned SRV pairs
    // that sample texture-pool `slot`, at the (just-swapped)
    // `self.descriptors.textures[slot]` resource. Albedo and normal maps share
    // one pool, so a streamed texture may be an albedo for one draw (the even
    // SRV of its heap pair) and a normal map for another (the odd SRV); this
    // re-points both wherever they resolve to `slot`. These pairs are only
    // referenced by the legacy (non-bindless) draw loops, so while the
    // bindless pass drives every draw no pending list dereferences them and
    // rewriting needs no drain. Runtime clones live in their own pool
    // (`rewrite_bound_texture_srvs`); streamed `VoxelWorld` chunks share
    // `chunk_srv_base_slot` and are fixed at `setup_chunk_streaming` time
    // (skipped here).
    fn rewrite_legacy_object_pairs(&self, slot: usize) {
        let resource = &self.descriptors.textures[slot];
        for (obj_idx, obj) in self.draw.objects.iter().enumerate() {
            if self.clone.slot_by_draw_idx.contains_key(&obj_idx) || obj_idx >= self.draw.n_objects
            {
                continue;
            }
            let pair_base = 3 + obj_idx * 2;
            if self.albedo_is_slot(obj.texture_slot, slot) {
                write_texture_srv(&self.device, resource, self.srv_slot_cpu(pair_base));
            }
            if self.normal_is_slot(obj.normal_map_slot, slot) {
                write_texture_srv(&self.device, resource, self.srv_slot_cpu(pair_base + 1));
            }
        }
        let cluster_base = 3 + self.draw.n_objects * 2;
        for (cluster_idx, cluster) in self.instanced.clusters.iter().enumerate() {
            let pair_base = cluster_base + cluster_idx * 2;
            if self.albedo_is_slot(cluster.texture_slot, slot) {
                write_texture_srv(&self.device, resource, self.srv_slot_cpu(pair_base));
            }
            if self.normal_is_slot(cluster.normal_map_slot, slot) {
                write_texture_srv(&self.device, resource, self.srv_slot_cpu(pair_base + 1));
            }
        }
        for (i, obj) in self.skinned.draw_objects.iter().enumerate() {
            let pair_base = self.skinned.srv_base_slot + i * 2;
            if self.albedo_is_slot(obj.texture_slot, slot) {
                write_texture_srv(&self.device, resource, self.srv_slot_cpu(pair_base));
            }
            if self.normal_is_slot(obj.normal_map_slot, slot) {
                write_texture_srv(&self.device, resource, self.srv_slot_cpu(pair_base + 1));
            }
        }
    }

    // Re-point every SRV that samples texture-pool `slot` and may be
    // dereferenced by in-flight command lists: the runtime-clone pairs plus
    // every per-frame flat-pool copy. Only legal under a device drain (the
    // fallback streaming path and the `cn debug` hot-reload paths).
    fn rewrite_bound_texture_srvs(&self, slot: usize) {
        let resource = &self.descriptors.textures[slot];
        for (&obj_idx, &clone_offset) in self.clone.slot_by_draw_idx.iter() {
            let Some(obj) = self.draw.objects.get(obj_idx) else {
                continue;
            };
            let pair_base = self.clone.srv_base_slot + clone_offset * 2;
            if self.albedo_is_slot(obj.texture_slot, slot) {
                write_texture_srv(&self.device, resource, self.srv_slot_cpu(pair_base));
            }
            if self.normal_is_slot(obj.normal_map_slot, slot) {
                write_texture_srv(&self.device, resource, self.srv_slot_cpu(pair_base + 1));
            }
        }
        // Flat shared pool: the swapped resource has exactly one descriptor per
        // frame copy (index == its handle), shared by albedo + normal sampling
        // and by the RT hit shader, so one re-point per copy refreshes every
        // consumer at once.
        for f in 0..FRAMES {
            write_texture_srv(
                &self.device,
                resource,
                self.srv_slot_cpu(self.flat_pool_slot(f, slot)),
            );
        }
    }

    // Heap slot of pool index `slot` in frame `frame`'s flat-pool copy.
    fn flat_pool_slot(&self, frame: usize, slot: usize) -> usize {
        self.descriptors.flat_pool_base_slot + frame * self.descriptors.flat_pool_len + slot
    }

    // Re-point every SRV that samples texture-pool `slot`. Only legal under a
    // device drain.
    fn rewrite_texture_slot(&self, slot: usize) {
        self.rewrite_legacy_object_pairs(slot);
        self.rewrite_bound_texture_srvs(slot);
    }

    // Whether replacing pool `slot` must drain the device first: true when an
    // SRV that samples the slot may be dereferenced by pending command lists
    // AND cannot wait for the per-frame propagation. The flat-pool copies
    // propagate per frame; the build-time pairs are rewritten immediately
    // while the legacy loops are inert. What remains are the runtime-clone
    // pairs and whole worlds where the bindless pass is off (custom-shader;
    // every legacy pair is then live).
    fn streamed_slot_needs_drain(&self, slot: usize) -> bool {
        let bindless_active = self.cull.main_bindless_pso.is_some() && self.cull_count() > 0;
        if !bindless_active {
            return true;
        }
        self.clone.slot_by_draw_idx.keys().any(|&draw_idx| {
            self.draw.objects.get(draw_idx).is_some_and(|obj| {
                self.albedo_is_slot(obj.texture_slot, slot)
                    || self.normal_is_slot(obj.normal_map_slot, slot)
            })
        })
    }

    // Replace texture-pool `slot` with a freshly decoded texture.
    //
    // The asset-streaming subsystem calls this to bring a texture resident
    // after init. Like Vulkan -- and unlike Metal, whose bind paths re-read
    // the texture pool every frame -- the D3D12 per-object / per-cluster SRVs
    // are baked into the descriptor heap at init, so a streamed swap must
    // rewrite every heap slot that samples this pool index (as an albedo or a
    // normal map). The streaming fast path never stalls the device: the
    // upload is submitted without waiting (the in-order queue executes it
    // before any later frame's lists), the build-time pairs are re-pointed
    // immediately (undereferenced while the bindless pass drives every draw),
    // the per-frame flat-pool copies re-point one per frame as their fences
    // retire, and the old resource plus upload transients are parked on
    // `stream.retires` until every consumer provably moved off them. When a
    // pending-referenced SRV samples the slot (see `streamed_slot_needs_drain`)
    // the swap instead drains the device and rewrites everything in place,
    // matching the hot-reload paths below.
    pub(crate) fn update_texture_slot(
        &mut self,
        slot: usize,
        image: &crate::build::texture::TextureImage,
    ) -> Result<(), String> {
        if slot >= self.descriptors.textures.len() {
            return Err(format!(
                "update_texture_slot: slot {} out of range (pool size {})",
                slot,
                self.descriptors.textures.len()
            ));
        }
        if self.streamed_slot_needs_drain(slot) {
            self.wait_idle();
            let texture = upload_texture_image(&self.alloc, image)?;
            self.descriptors.textures[slot] = texture;
            self.rewrite_texture_slot(slot);
            // The full rewrite covered every flat-pool copy, so any propagation
            // queued for this slot is already satisfied.
            self.stream.pool_rewrites.remove(slot);
            return Ok(());
        }
        let (texture, in_flight) = upload_texture_image_deferred(&self.alloc, image)?;
        let old = std::mem::replace(&mut self.descriptors.textures[slot], texture);
        self.rewrite_legacy_object_pairs(slot);
        self.stream.pool_rewrites.queue(slot);
        // `+ 1`: the swap lands between frames, after the previous frame's
        // submit, so the first frame fence that covers the upload submission
        // is the one signalled by the NEXT draw -- waited FRAMES ticks after
        // that draw's own tick.
        self.stream
            .retires
            .push(super::texture::StreamedUploadRetire {
                texture: old,
                upload: in_flight.upload,
                allocator: in_flight.allocator,
                cmd: in_flight.cmd,
                retire_at: self.stream.frame + FRAMES as u64 + 1,
            });
        Ok(())
    }

    // Per-frame streamed-texture upkeep, called at the top of `draw_frame`
    // right after frame slot `frame`'s fence wait: re-point this frame's
    // flat-pool copy at any swapped slots (legal now -- the wait retired every
    // list that dereferences this copy), then release retires whose covering
    // fence has signalled (dropping the entry releases the COM references).
    pub(super) fn apply_streamed_texture_rewrites(&mut self, frame: usize) {
        self.stream.frame += 1;
        if !self.stream.pool_rewrites.is_empty() {
            let last = self.descriptors.textures.len().saturating_sub(1);
            for slot in self.stream.pool_rewrites.begin_frame() {
                let resource = &self.descriptors.textures[slot.min(last)];
                write_texture_srv(
                    &self.device,
                    resource,
                    self.srv_slot_cpu(self.flat_pool_slot(frame, slot)),
                );
            }
        }
        let now = self.stream.frame;
        self.stream.retires.retain(|r| r.retire_at > now);
    }

    // Reset texture-pool `slot` to a 1x1 mid-grey placeholder.
    //
    // Used by the asset-streaming subsystem to mark a slot whose texture is
    // not yet resident; a later `update_texture_slot` brings the real texture
    // back. The grey is distinct from the white no-texture fallback so a
    // not-yet-streamed slot reads differently under inspection.
    pub(crate) fn evict_texture_slot(&mut self, slot: usize) -> Result<(), String> {
        let grey = crate::build::texture::TextureImage::rgba8(1, 1, vec![128, 128, 128, 255]);
        self.update_texture_slot(slot, &grey)
    }

    // Replace the live colour-grading LUT with a fresh `size³` RGBA8 payload.
    // Driven by asset hot-reload (`cn debug` only) when the file-backed
    // `ColorLut` source is saved. Reuses the SRV heap slot the composite pass
    // already binds, so the new texture is picked up on the next `draw_frame`
    // with no pipeline or descriptor-table change. `wait_idle` first
    // guarantees no in-flight command list still references the old texture
    // (or the now-stale SRV) before it is overwritten and dropped. Mirrors
    // `MtlContext::update_color_lut`.
    pub(crate) fn update_color_lut(&mut self, size: u32, data: &[u8]) -> Result<(), String> {
        self.wait_idle();
        let srv_cpu = self.color_lut.srv_cpu;
        let srv_gpu = self.color_lut.srv_gpu;
        let new_lut = upload_color_lut(&self.alloc, size, data, srv_cpu, srv_gpu)?;
        self.color_lut = new_lut;
        Ok(())
    }

    // Swap the live IBL cubemap pair for a freshly precomputed envmap payload.
    // Driven by asset hot-reload (`cn debug` only). Re-uploads into the same
    // SRV heap slots [1] (irradiance) + [2] (prefilter) the init path wrote,
    // so every pipeline that references those slots keeps working without a
    // descriptor-table rebind. The new payload may declare different mip /
    // face sizes than the original; `EnvironmentMapTextures` is replaced
    // wholesale and the next frame's `ViewUniforms` picks up the new
    // `prefilter_mip_count` from `self.env_map`. `wait_idle` first guarantees
    // no in-flight command list still references the old cubes (or the
    // now-stale SRVs) before they are overwritten and dropped. Mirrors
    // `MtlContext::update_environment_map`.
    pub(crate) fn update_environment_map(&mut self, payload: &[u8]) -> Result<(), String> {
        let view = crate::build::environment_map::deserialise(payload)
            .map_err(|e| format!("envmap hot-reload payload malformed: {e}"))?;
        self.wait_idle();
        let irr_srv_cpu = self.env_map.irradiance.srv_cpu;
        let irr_srv_gpu = self.env_map.irradiance.srv_gpu;
        let pre_srv_cpu = self.env_map.prefilter.srv_cpu;
        let pre_srv_gpu = self.env_map.prefilter.srv_gpu;
        let new_env = upload_environment_map(
            &self.alloc,
            EnvironmentMapPayload {
                irradiance_face: view.irradiance_face,
                irradiance_bytes: view.irradiance_bytes,
                prefilter_face: view.prefilter_face,
                mip_bytes: &view.prefilter_mip_bytes,
            },
            EnvironmentMapDescriptors {
                irr_srv_cpu,
                irr_srv_gpu,
                pre_srv_cpu,
                pre_srv_gpu,
            },
        )?;
        self.env_map = new_env;
        Ok(())
    }

    // GPU descriptor handle for a runtime clone's (albedo, normal) SRV pair.
    // The pair lives at `clone_srv_base_slot + clone_offset * 2`; the
    // 2-descriptor table the legacy main pass binds covers both slots.
    pub(super) fn clone_srv_gpu(&self, clone_offset: usize) -> D3D12_GPU_DESCRIPTOR_HANDLE {
        // SAFETY: a property query on a live descriptor heap; it only reads.
        let base = unsafe {
            self.descriptors
                .srv_heap
                .GetGPUDescriptorHandleForHeapStart()
        };
        let slot = self.clone.srv_base_slot + clone_offset * 2;
        D3D12_GPU_DESCRIPTOR_HANDLE {
            ptr: base.ptr + (slot * self.descriptors.srv_descriptor_size) as u64,
        }
    }

    // Append a new draw object that re-uses an existing slot's geometry
    // region (vertex / index offsets, base_vertex, LOD alternates) with a
    // fresh model matrix, texture / normal-map slots, material, and cull
    // distance. Driven by `world.jsonl` hot-reload (`cn debug` only) when a
    // newly authored Prop references a Mesh / Model already present in the
    // init world. The clone is non-cullable (sentinel AABB) and joins
    // `draw.always` since the init-time BVH cannot refit; the dynamically
    // added prop is drawn every frame, like a streamed `VoxelWorld` chunk.
    // Bakes the clone's (albedo, normal) SRV pair at the next free slot in
    // the clone descriptor pool reserved at init (`MAX_CLONE_DRAWS` pairs),
    // and records `draw_idx → clone_offset` in `clone_slot_by_draw_idx`
    // so the legacy main pass + `rewrite_albedo_slot` /
    // `rewrite_normal_slot` can find it. Mirrors
    // `MtlContext::clone_static_draw_object`.
    pub(crate) fn clone_static_draw_object(
        &mut self,
        src_draw_idx: usize,
        model: [[f32; 4]; 4],
        dst: crate::gfx::draw_slot::SlotAlloc,
    ) -> Result<(), String> {
        let src = self.draw.objects.get(src_draw_idx).ok_or_else(|| {
            format!(
                "clone_static_draw_object: src draw {} out of range",
                src_draw_idx
            )
        })?;
        // A runtime spawn duplicates the template, swapping only the transform:
        // copy the source's material, pool slots, and cull distance.
        let texture_slot = src.texture_slot;
        let normal_map_slot = src.normal_map_slot;
        let material = src.material;
        let cull_distance = src.cull_distance;
        let obj = DrawObject {
            vertex_offset: src.vertex_offset,
            vertex_count: src.vertex_count,
            index_offset: src.index_offset,
            index_count: src.index_count,
            base_vertex: src.base_vertex,
            geometry_generation: src.geometry_generation,
            model,
            texture_slot,
            normal_map_slot,
            material,
            visible: true,
            resident: true,
            // Sentinel AABB so the init-time BVH cull skips the new draw:
            // it joins `draw.always` and is drawn every frame regardless of
            // camera position. Matches the runtime-streamed chunk pattern.
            bb_min: [f32::NAN; 3],
            bb_max: [f32::NAN; 3],
            cull_distance,
            lod_alternates: src.lod_alternates.clone(),
            shader_bucket: src.shader_bucket,
        };

        // Reuse a vacated clone descriptor-pool offset, else grow the pool up to
        // its cap. `count` is the high-water mark of distinct offsets handed out.
        let mut reused_offset = false;
        let clone_offset = if let Some(offset) = self.clone.free_offsets.pop() {
            reused_offset = true;
            offset
        } else if self.clone.count < MAX_CLONE_DRAWS {
            let offset = self.clone.count;
            self.clone.count += 1;
            offset
        } else {
            return Err(format!(
                "clone_static_draw_object: MAX_CLONE_DRAWS ({MAX_CLONE_DRAWS}) exceeded"
            ));
        };

        // Always (re)point the offset's (albedo, normal) SRV pair at this clone's
        // textures. A reused offset's pair may still be referenced by an in-flight
        // command list (its prior occupant was drawn before being retired) AND its
        // texture pool slot may have been stream-swapped to a new resource while
        // the offset sat free -- `rewrite_albedo_slot` only refreshes offsets still
        // live in `slot_by_draw_idx`, so a freed offset's baked SRV can dangle at a
        // released resource -- so drain the GPU first on reuse, then overwrite with
        // the live resource. A fresh offset was never bound, so no drain is needed.
        if reused_offset {
            self.wait_idle();
        }
        let albedo_slot = self.clone.srv_base_slot + clone_offset * 2;
        let normal_slot = albedo_slot + 1;
        write_texture_srv(
            &self.device,
            self.albedo_pool_resource(texture_slot),
            self.srv_slot_cpu(albedo_slot),
        );
        write_texture_srv(
            &self.device,
            self.normal_pool_resource(normal_map_slot),
            self.srv_slot_cpu(normal_slot),
        );

        // Write at the engine-allocated destination slot.
        let new_idx = match dst {
            crate::gfx::draw_slot::SlotAlloc::Reuse(slot) => {
                self.draw.objects[slot] = obj;
                // Seed the velocity prepass's previous-model snapshot so a
                // recycled slot does not ghost from the prior occupant's
                // transform for one frame. A slot past the snapshot's end (one
                // appended beyond the build-time object count) falls back to its
                // own current model in the prepass, so the guard is enough.
                if let Some(gbuffer) = &self.gbuffer {
                    let mut prev = gbuffer.prev_models.borrow_mut();
                    if slot < prev.len() {
                        prev[slot] = model;
                    }
                }
                slot
            }
            crate::gfx::draw_slot::SlotAlloc::Append(slot) => {
                debug_assert_eq!(
                    slot,
                    self.draw.objects.len(),
                    "appended draw slot must match the draw-object count"
                );
                self.draw.objects.push(obj);
                self.draw.always_member.push(false);
                slot
            }
        };
        self.ensure_always_draw(new_idx);
        self.clone.slot_by_draw_idx.insert(new_idx, clone_offset);
        // The cloned prop joins the RT-relevant draw set; the next RT update folds
        // it into the BVH (it reuses the source mesh's geometry slice, so only
        // this clone's BLAS is built).
        self.rt_topology_dirty = true;
        Ok(())
    }

    // Copy `data` into a sub-region of a DEFAULT-heap geometry buffer.
    //
    // `dest` is a buffer currently in `usage_state` (the vertex or index
    // buffer). The copy goes through a temporary UPLOAD-heap staging buffer
    // and a one-shot command list that transitions the resource
    // `usage_state -> COPY_DEST -> usage_state` around a `CopyBufferRegion`.
    // The caller must `wait_idle` first: the COPY_DEST transition covers the
    // whole resource, so no in-flight command list may still reference it.
    fn write_geometry_region(
        &self,
        dest: &ID3D12Resource,
        usage_state: D3D12_RESOURCE_STATES,
        offset: u64,
        data: &[u8],
    ) -> Result<(), String> {
        if data.is_empty() {
            return Ok(());
        }
        let upload = create_buffer(
            &self.alloc,
            data.len() as u64,
            D3D12_HEAP_TYPE_UPLOAD,
            D3D12_RESOURCE_STATE_GENERIC_READ,
        )?;
        let mut ptr = std::ptr::null_mut::<std::ffi::c_void>();
        // SAFETY: the resource is a live CPU-visible buffer, and the out-parameter is a live local
        // that receives the mapping.
        unsafe { upload.Map(0, None, Some(&mut ptr)) }
            .map_err(|e| format!("mesh region map: {e}"))?;
        // SAFETY: the mapping covers an UPLOAD-heap buffer created to hold this payload, and the
        // source is a separate allocation, so the ranges cannot overlap.
        unsafe {
            std::ptr::copy_nonoverlapping(data.as_ptr(), ptr as *mut u8, data.len());
            upload.Unmap(0, None);
        }
        // SAFETY: the command list is in the recording state, and every resource, descriptor and
        // slice these commands name is live for the call.
        one_shot_submit(&self.device, &self.command_queue, |cmd| unsafe {
            let to_dst = transition_barrier(dest, usage_state, D3D12_RESOURCE_STATE_COPY_DEST);
            cmd.ResourceBarrier(&[to_dst]);
            cmd.CopyBufferRegion(dest, offset, &*upload, 0, data.len() as u64);
            let back = transition_barrier(dest, D3D12_RESOURCE_STATE_COPY_DEST, usage_state);
            cmd.ResourceBarrier(&[back]);
        })
    }

    // Upload a streamed mesh's geometry into the shared vertex and index
    // buffers, place it via the sub-allocators, and mark the draw resident.
    //
    // The mesh-streaming subsystem calls this to bring a mesh resident after
    // init. The geometry is placed wherever the allocators find free space
    // (not the build-time region), so `DrawObject::vertex_offset` /
    // `index_offset` are rewritten here. `vertices` / `indices` must match the
    // fixed `vertex_count` / `index_count` recorded by `build_draw_list`.
    //
    // `indices` are mesh-relative (0-based); they are rebased onto the chosen
    // vertex region before upload, so the D3D12 draw can keep a 0 base-vertex.
    // `frame` reclaims deferred frees that have retired by then. `wait_idle`
    // runs first so the whole-resource COPY_DEST transition races no in-flight
    // command list (see `write_geometry_region`).
    pub(crate) fn upload_mesh(
        &mut self,
        draw_idx: usize,
        vertices: &[Vertex],
        indices: &[u16],
        frame: u64,
    ) -> Result<(), String> {
        let obj = self
            .draw
            .objects
            .get(draw_idx)
            .ok_or_else(|| format!("upload_mesh: draw object {} out of range", draw_idx))?;
        let (vertex_count, index_count) = (obj.vertex_count, obj.index_count);
        if vertices.len() != vertex_count {
            return Err(format!(
                "upload_mesh: draw {} expects {} vertices, got {}",
                draw_idx,
                vertex_count,
                vertices.len()
            ));
        }
        if indices.len() != index_count {
            return Err(format!(
                "upload_mesh: draw {} expects {} indices, got {}",
                draw_idx,
                index_count,
                indices.len()
            ));
        }

        // Reclaim frees whose in-flight frames have retired, then place the
        // geometry. build_draw_list never emits a zero-length mesh, so an
        // empty allocation request is treated as a hard error.
        self.mesh_stream.vtx_alloc.reclaim(frame);
        self.mesh_stream.idx_alloc.reclaim(frame);
        let v_len = std::mem::size_of_val(vertices);
        // Static IB is u32 (the per-scene total can exceed u16); per-mesh
        // indices come in as u16 (each mesh fits in u16, enforced by the
        // build-time splitter) and get widened on write below. Size the
        // allocation against the u32 stride. Mirrors metal's upload_mesh.
        let i_len = indices.len() * std::mem::size_of::<u32>();
        let v_off = self
            .mesh_stream
            .vtx_alloc
            .alloc(v_len as u64)
            .ok_or_else(|| {
                format!(
                    "upload_mesh: draw {}: no free vertex space for {} bytes",
                    draw_idx, v_len
                )
            })? as usize;
        let i_off = match self.mesh_stream.idx_alloc.alloc(i_len as u64) {
            Some(o) => o as usize,
            None => {
                // hand the vertex region back so a half-failed upload leaks no
                // space (frame 0: it was never written or drawn)
                self.mesh_stream
                    .vtx_alloc
                    .free(v_off as u64, v_len as u64, 0);
                return Err(format!(
                    "upload_mesh: draw {}: no free index space for {} bytes",
                    draw_idx, i_len
                ));
            }
        };

        self.wait_idle();

        // Vertices copy verbatim. Indices are mesh-relative, so rebase them to
        // the vertex region the allocator chose: v_off is always a multiple of
        // size_of::<Vertex>() (every seed region and allocation is), so the
        // base is an exact vertex index.
        let vert_bytes = bytemuck::cast_slice(vertices);
        self.write_geometry_region(
            &self.geometry.vertex_buffer,
            D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER,
            v_off as u64,
            vert_bytes,
        )?;
        let base = (v_off / std::mem::size_of::<Vertex>()) as u32;
        // Widen u16 → u32 while rebasing onto the chosen vertex region.
        let rebased: Vec<u32> = indices.iter().map(|&i| u32::from(i) + base).collect();
        let idx_bytes = bytemuck::cast_slice(&rebased);
        self.write_geometry_region(
            &self.geometry.index_buffer,
            D3D12_RESOURCE_STATE_INDEX_BUFFER,
            i_off as u64,
            idx_bytes,
        )?;

        let obj = &mut self.draw.objects[draw_idx];
        obj.vertex_offset = v_off;
        obj.index_offset = i_off / std::mem::size_of::<u32>();
        obj.resident = true;
        // The mesh joins the RT-relevant draw set at a freshly allocated region;
        // the next RT update builds its BLAS over the new slice.
        self.rt_topology_dirty = true;
        Ok(())
    }

    // Overwrite a `Mesh` draw slot's vertex / index data in place. Driven by
    // asset hot-reload (`cn debug` only). New `vertices` / `indices` are
    // written at the draw object's existing offsets in the shared vertex /
    // index buffers, so the slot's count must match init-time; size-changing
    // reloads need `rebuild_static_geometry`, not this call. Each entry in
    // `lod_alternates` is written to the matching slot's pre-allocated LOD
    // region; LOD counts and per-LOD index counts must match init-time too.
    // Per-LOD `switch_distance`s are re-stored so JSON-side tweaks to
    // `lod_distances` propagate without a process restart. `wait_idle` is
    // folded into each `write_geometry_region` call (the whole-resource
    // COPY_DEST transition needs no in-flight command list referencing the
    // buffer). Mirrors `MtlContext::update_mesh_geometry`.
    pub(crate) fn update_mesh_geometry(
        &mut self,
        draw_idx: usize,
        vertices: &[Vertex],
        indices: &[u16],
        lod_alternates: &[(f32, Vec<u16>)],
    ) -> Result<(), String> {
        let obj = self.draw.objects.get(draw_idx).ok_or_else(|| {
            format!(
                "update_mesh_geometry: draw object {} out of range",
                draw_idx
            )
        })?;
        if vertices.len() != obj.vertex_count {
            return Err(format!(
                "update_mesh_geometry: draw {} expects {} vertices, got {} \
                 (in-place path is size-matched only; size changes route through \
                 rebuild_static_geometry)",
                draw_idx,
                obj.vertex_count,
                vertices.len()
            ));
        }
        if indices.len() != obj.index_count {
            return Err(format!(
                "update_mesh_geometry: draw {} expects {} indices, got {} \
                 (in-place path is size-matched only; size changes route through \
                 rebuild_static_geometry)",
                draw_idx,
                obj.index_count,
                indices.len()
            ));
        }
        if lod_alternates.len() != obj.lod_alternates.len() {
            return Err(format!(
                "update_mesh_geometry: draw {} expects {} LOD alternate(s), got {} \
                 (LOD-count changes need rebuild_static_geometry)",
                draw_idx,
                obj.lod_alternates.len(),
                lod_alternates.len()
            ));
        }
        for (lod_idx, ((_, alt_idx), slice)) in lod_alternates
            .iter()
            .zip(obj.lod_alternates.iter())
            .enumerate()
        {
            if alt_idx.len() != slice.index_count {
                return Err(format!(
                    "update_mesh_geometry: draw {} LOD{} expects {} indices, got {} \
                     (LOD size changes need rebuild_static_geometry)",
                    draw_idx,
                    lod_idx + 1,
                    slice.index_count,
                    alt_idx.len()
                ));
            }
        }
        let v_off = obj.vertex_offset as u64;
        let i_off_bytes = (obj.index_offset * std::mem::size_of::<u32>()) as u64;
        // Static draws keep indices absolute (base_vertex == 0), so rebase
        // mesh-relative u16 indices onto the slot's vertex_offset and widen to
        // u32 before writing, matching the shared u32 index buffer and the
        // streaming upload_mesh path. `v_off` is always a multiple of
        // size_of::<Vertex>() (every region build_draw_list emits starts on a
        // vertex boundary).
        let base = (obj.vertex_offset / std::mem::size_of::<Vertex>()) as u32;
        let lod_byte_offsets: Vec<u64> = obj
            .lod_alternates
            .iter()
            .map(|s| (s.index_offset * std::mem::size_of::<u32>()) as u64)
            .collect();

        self.wait_idle();

        let vert_bytes = bytemuck::cast_slice(vertices);
        self.write_geometry_region(
            &self.geometry.vertex_buffer,
            D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER,
            v_off,
            vert_bytes,
        )?;
        let rebased: Vec<u32> = indices.iter().map(|&i| u32::from(i) + base).collect();
        let idx_bytes = bytemuck::cast_slice(&rebased);
        self.write_geometry_region(
            &self.geometry.index_buffer,
            D3D12_RESOURCE_STATE_INDEX_BUFFER,
            i_off_bytes,
            idx_bytes,
        )?;
        // LOD alternate slots were laid out at init alongside LOD0 in the
        // same shared index buffer. Each alternate shares LOD0's vertex
        // region (LOD decimation never touches vertices), so rebase onto the
        // same `base`.
        for ((_, alt_idx), &alt_off_bytes) in lod_alternates.iter().zip(lod_byte_offsets.iter()) {
            let alt_rebased: Vec<u32> = alt_idx.iter().map(|&i| u32::from(i) + base).collect();
            let alt_bytes = bytemuck::cast_slice(&alt_rebased);
            self.write_geometry_region(
                &self.geometry.index_buffer,
                D3D12_RESOURCE_STATE_INDEX_BUFFER,
                alt_off_bytes,
                alt_bytes,
            )?;
        }
        // Refresh the per-LOD switch distances so JSON-side tweaks to
        // `lod_distances` propagate without a process restart.
        let slot = &mut self.draw.objects[draw_idx];
        for ((switch_distance, _), slice) in
            lod_alternates.iter().zip(slot.lod_alternates.iter_mut())
        {
            slice.switch_distance = *switch_distance;
        }
        // The slot now holds different triangles at the same offsets, so its RT
        // BLAS traces the pre-reload positions. Nothing else in the geometry
        // signature moved, so bump the generation (which the signature carries)
        // and flag the topology: the next RT update rebuilds this slot's BLAS
        // rather than reusing the stale one.
        slot.geometry_generation = slot.geometry_generation.wrapping_add(1);
        self.rt_topology_dirty = true;
        Ok(())
    }

    // Return a streamed mesh's geometry region to the sub-allocators and mark
    // the draw non-resident so it is skipped in every pass.
    //
    // `retire_frame` is the frame from which the freed region may be reused:
    // pass `current_frame + frames_in_flight` for a runtime eviction so a
    // still-in-flight command list never has its region overwritten by a
    // later `upload_mesh`, and `0` at init, where nothing has been drawn.
    // The region is not zeroed: the draw leaves the RT-relevant set here, so
    // the next RT update retires its BLAS rather than tracing the vacated
    // bytes, and every raster pass skips a non-resident draw.
    pub(crate) fn evict_mesh(&mut self, draw_idx: usize, retire_frame: u64) -> Result<(), String> {
        let obj = self
            .draw
            .objects
            .get(draw_idx)
            .ok_or_else(|| format!("evict_mesh: draw object {} out of range", draw_idx))?;
        let v_off = obj.vertex_offset as u64;
        let v_len = (obj.vertex_count * std::mem::size_of::<Vertex>()) as u64;
        let i_off = (obj.index_offset * std::mem::size_of::<u32>()) as u64;
        let i_len = (obj.index_count * std::mem::size_of::<u32>()) as u64;
        self.mesh_stream.vtx_alloc.free(v_off, v_len, retire_frame);
        self.mesh_stream.idx_alloc.free(i_off, i_len, retire_frame);
        self.draw.objects[draw_idx].resident = false;
        // The mesh leaves the RT-relevant draw set; the next RT update drops its
        // BLAS (deferred-freed once in-flight traces retire).
        self.rt_topology_dirty = true;
        Ok(())
    }

    // Seed the streamed-mesh sub-allocators with one reserved headroom block
    // (byte ranges in the shared vertex / index buffers), for the
    // shrinkable-seed path.
    //
    // The streamed geometry is not baked into the buffers at build time;
    // instead the buffers carry one zeroed headroom region (sized to the
    // cap-many resident meshes) at these offsets, which `compact_for_streaming`
    // appended before init. `retire_frame 0`: nothing has been drawn yet, so
    // the space is allocatable immediately -- mirrors `setup_chunk_streaming`'s
    // seeding. From then on `upload_mesh` / `evict_mesh` place and free streamed
    // meshes within it. Mirrors `MtlContext::seed_mesh_streaming`.
    pub(crate) fn seed_mesh_streaming(
        &mut self,
        vtx_offset: u64,
        vtx_bytes: u64,
        idx_offset: u64,
        idx_bytes: u64,
    ) {
        self.mesh_stream.vtx_alloc.free(vtx_offset, vtx_bytes, 0);
        self.mesh_stream.vtx_alloc.reclaim(0);
        self.mesh_stream.idx_alloc.free(idx_offset, idx_bytes, 0);
        self.mesh_stream.idx_alloc.reclaim(0);
    }

    // GPU descriptor handle for the shared chunk (albedo, normal) SRV pair.
    // Valid only after `setup_chunk_streaming` has populated the two slots.
    pub(super) fn chunk_srv_gpu(&self) -> D3D12_GPU_DESCRIPTOR_HANDLE {
        // SAFETY: a property query on a live descriptor heap; it only reads.
        let base = unsafe {
            self.descriptors
                .srv_heap
                .GetGPUDescriptorHandleForHeapStart()
        };
        D3D12_GPU_DESCRIPTOR_HANDLE {
            ptr: base.ptr
                + (self.chunk_stream.srv_base_slot * self.descriptors.srv_descriptor_size) as u64,
        }
    }

    // Grow the shared vertex/index buffers by a headroom region for streamed
    // `VoxelWorld` chunks, seed the chunk sub-allocators with it, and bake the
    // shared chunk (albedo, normal) SRV pair from the world's chunk material.
    //
    // Called once at init by `GraphicsSystem` when a `VoxelWorld` is present.
    // The build-time geometry is copied verbatim into the start of the new
    // (larger) DEFAULT-heap buffers; chunks are placed in the appended
    // headroom by `add_chunk_mesh`. This runs before the first frame, so no
    // in-flight command list references the replaced buffers.
    pub(crate) fn setup_chunk_streaming(
        &mut self,
        chunk_vtx_bytes: usize,
        chunk_idx_bytes: usize,
        texture_slot: usize,
        normal_map_slot: usize,
    ) -> Result<(), String> {
        self.wait_idle();
        let old_v_len = self.geometry.vertex_buffer_view.SizeInBytes as u64;
        let old_i_len = self.geometry.index_buffer_view.SizeInBytes as u64;
        let new_v_len = old_v_len + chunk_vtx_bytes as u64;
        let new_i_len = old_i_len + chunk_idx_bytes as u64;

        // Buffers are created in COMMON; the CopyBufferRegion below implicitly
        // promotes the destination COMMON -> COPY_DEST.
        let new_vbuf = create_buffer(
            &self.alloc,
            new_v_len,
            D3D12_HEAP_TYPE_DEFAULT,
            D3D12_RESOURCE_STATE_COMMON,
        )?;
        let new_ibuf = create_buffer(
            &self.alloc,
            new_i_len,
            D3D12_HEAP_TYPE_DEFAULT,
            D3D12_RESOURCE_STATE_COMMON,
        )?;

        // Copy the build-time geometry into the start of the grown buffers so
        // every existing draw's offsets stay valid.
        // SAFETY: the command list is in the recording state, and every resource, descriptor and
        // slice these commands name is live for the call.
        one_shot_submit(&self.device, &self.command_queue, |cmd| unsafe {
            let v_src = transition_barrier(
                &self.geometry.vertex_buffer,
                D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER,
                D3D12_RESOURCE_STATE_COPY_SOURCE,
            );
            let i_src = transition_barrier(
                &self.geometry.index_buffer,
                D3D12_RESOURCE_STATE_INDEX_BUFFER,
                D3D12_RESOURCE_STATE_COPY_SOURCE,
            );
            cmd.ResourceBarrier(&[v_src, i_src]);
            cmd.CopyBufferRegion(&*new_vbuf, 0, &*self.geometry.vertex_buffer, 0, old_v_len);
            cmd.CopyBufferRegion(&*new_ibuf, 0, &*self.geometry.index_buffer, 0, old_i_len);
            let v_dst = transition_barrier(
                &new_vbuf,
                D3D12_RESOURCE_STATE_COPY_DEST,
                D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER,
            );
            let i_dst = transition_barrier(
                &new_ibuf,
                D3D12_RESOURCE_STATE_COPY_DEST,
                D3D12_RESOURCE_STATE_INDEX_BUFFER,
            );
            cmd.ResourceBarrier(&[v_dst, i_dst]);
        })?;

        self.geometry.vertex_buffer_view = D3D12_VERTEX_BUFFER_VIEW {
            BufferLocation: com::gpu_va(&new_vbuf),
            SizeInBytes: new_v_len as u32,
            StrideInBytes: std::mem::size_of::<Vertex>() as u32,
        };
        self.geometry.index_buffer_view = D3D12_INDEX_BUFFER_VIEW {
            BufferLocation: com::gpu_va(&new_ibuf),
            SizeInBytes: new_i_len as u32,
            // Static IB is u32 (matches the `Format` chosen in init/mod.rs).
            Format: windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_R32_UINT,
        };
        self.geometry.vertex_buffer = new_vbuf;
        self.geometry.index_buffer = new_ibuf;

        // Seed the chunk allocators with the appended headroom. retire_frame 0:
        // nothing has been drawn, so the space is reusable immediately.
        self.chunk_stream
            .vtx_alloc
            .free(old_v_len, chunk_vtx_bytes as u64, 0);
        self.chunk_stream
            .idx_alloc
            .free(old_i_len, chunk_idx_bytes as u64, 0);

        // Bake the shared chunk (albedo, normal) SRV pair from the chunk
        // material's texture-pool slots, clamped to the pool length.
        let last_tex = self.descriptors.textures.len().saturating_sub(1);
        write_texture_srv(
            &self.device,
            &self.descriptors.textures[texture_slot.min(last_tex)],
            self.srv_slot_cpu(self.chunk_stream.srv_base_slot),
        );
        write_texture_srv(
            &self.device,
            self.normal_pool_resource(normal_map_slot),
            self.srv_slot_cpu(self.chunk_stream.srv_base_slot + 1),
        );
        Ok(())
    }

    // Place one streamed chunk's geometry in the chunk headroom region and
    // write its `DrawObject` at the engine-allocated destination slot.
    //
    // The chunk is non-cullable and joins the `draw.always` set: the streaming
    // window already bounds the resident chunk count. Indices stay
    // mesh-relative (0-based) and the draw passes the vertex region's base as
    // `base_vertex`, so a chunk placed past the 65 535-vertex `u16` index
    // range still renders. `frame` reclaims retired deferred frees first.
    // `wait_idle` runs before the geometry copy so the whole-resource
    // COPY_DEST transition races no in-flight command list.
    pub(crate) fn add_chunk_mesh(
        &mut self,
        mesh: ChunkMesh<'_>,
        dst: crate::gfx::draw_slot::SlotAlloc,
    ) -> crate::gfx::error::RenderResult<()> {
        let ChunkMesh {
            verts: vertices,
            idxs: indices,
            model,
            texture_slot,
            normal_map_slot,
            material,
            frame,
        } = mesh;
        if vertices.is_empty() || indices.is_empty() {
            return Err("add_chunk_mesh: empty chunk geometry".into());
        }
        self.chunk_stream.vtx_alloc.reclaim(frame);
        self.chunk_stream.idx_alloc.reclaim(frame);

        let v_len = std::mem::size_of_val(vertices);
        // Static IB is u32; chunk indices come in as u16 and get widened on
        // write. Size the allocation against the u32 stride.
        let i_len = indices.len() * std::mem::size_of::<u32>();
        let v_off = self
            .chunk_stream
            .vtx_alloc
            .alloc(v_len as u64)
            .ok_or_else(|| {
                crate::gfx::error::RenderError::OutOfDeviceMemory(format!(
                    "add_chunk_mesh: no free chunk vertex space for {} bytes",
                    v_len
                ))
            })? as usize;
        let i_off = match self.chunk_stream.idx_alloc.alloc(i_len as u64) {
            Some(o) => o as usize,
            None => {
                self.chunk_stream
                    .vtx_alloc
                    .free(v_off as u64, v_len as u64, 0);
                return Err(crate::gfx::error::RenderError::OutOfDeviceMemory(format!(
                    "add_chunk_mesh: no free chunk index space for {} bytes",
                    i_len
                )));
            }
        };

        self.wait_idle();

        // Vertices and indices both copy verbatim: the indices stay 0-based and
        // the draw fixes them up with `base_vertex`.
        let vert_bytes = bytemuck::cast_slice(vertices);
        self.write_geometry_region(
            &self.geometry.vertex_buffer,
            D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER,
            v_off as u64,
            vert_bytes,
        )?;
        // Chunk indices stay mesh-relative; the draw fixes them up with
        // `base_vertex`. Widen u16 → u32 to match the static IB's stride.
        let widened: Vec<u32> = indices.iter().map(|&i| u32::from(i)).collect();
        let idx_bytes = bytemuck::cast_slice(&widened);
        self.write_geometry_region(
            &self.geometry.index_buffer,
            D3D12_RESOURCE_STATE_INDEX_BUFFER,
            i_off as u64,
            idx_bytes,
        )?;

        // v_off is a multiple of size_of::<Vertex>() (the headroom start and
        // every alloc are), so the base is an exact vertex index.
        let base_vertex = (v_off / std::mem::size_of::<Vertex>()) as i32;
        let obj = DrawObject {
            vertex_offset: v_off,
            vertex_count: vertices.len(),
            index_offset: i_off / std::mem::size_of::<u32>(),
            index_count: indices.len(),
            base_vertex,
            geometry_generation: 0,
            model,
            texture_slot,
            normal_map_slot,
            material,
            visible: true,
            resident: true,
            // Non-cullable: degenerate AABB disables frustum/distance culling.
            bb_min: [f32::NAN; 3],
            bb_max: [f32::NAN; 3],
            cull_distance: 0.0,
            // Streamed chunks always render at the build-time mesh; no LOD.
            lod_alternates: Vec::new(),
            // Streamed chunks render through the world default program.
            shader_bucket: 0,
        };

        // Write at the engine-allocated destination slot. A slot recycled from
        // a culled static prop is not yet in `draw.always`;
        // `ensure_always_draw` adds it, while one recycled from another chunk /
        // clone already is.
        let draw_idx = match dst {
            crate::gfx::draw_slot::SlotAlloc::Reuse(slot) => {
                self.draw.objects[slot] = obj;
                slot
            }
            crate::gfx::draw_slot::SlotAlloc::Append(slot) => {
                debug_assert_eq!(
                    slot,
                    self.draw.objects.len(),
                    "appended draw slot must match the draw-object count"
                );
                self.draw.objects.push(obj);
                self.draw.always_member.push(false);
                slot
            }
        };
        self.ensure_always_draw(draw_idx);
        // Seed the G-buffer pre-pass's previous-model snapshot for a recycled
        // slot so a fresh chunk does not inherit the removed chunk's transform
        // and ghost for one frame. A fresh append is past the snapshot's end
        // and the pre-pass falls back to the current model itself.
        if let Some(gbuffer) = &self.gbuffer {
            let mut prev = gbuffer.prev_models.borrow_mut();
            if draw_idx < prev.len() {
                prev[draw_idx] = model;
            }
        }
        // A new resident chunk changes the RT-relevant draw set; the next RT
        // update folds it into the BVH (building just this chunk's BLAS).
        self.rt_topology_dirty = true;
        Ok(())
    }

    // Free a streamed chunk's geometry region and retire its `DrawObject`
    // slot for reuse.
    //
    // `retire_frame` is `current_frame + frames_in_flight` so an in-flight
    // command list never has the freed region overwritten by a later
    // `add_chunk_mesh`. The slot stays in `draw.objects` / `draw.always` but
    // is marked non-resident and invisible, so every pass skips it. The region
    // is not zeroed -- a non-resident draw is skipped everywhere and an
    // `alloc` hands back exactly `size` bytes that `add_chunk_mesh` fully
    // overwrites.
    pub(crate) fn remove_chunk_mesh(
        &mut self,
        draw_idx: usize,
        retire_frame: u64,
    ) -> Result<(), String> {
        let obj =
            self.draw.objects.get(draw_idx).ok_or_else(|| {
                format!("remove_chunk_mesh: draw object {} out of range", draw_idx)
            })?;
        let v_off = obj.vertex_offset as u64;
        let v_len = (obj.vertex_count * std::mem::size_of::<Vertex>()) as u64;
        let i_off = (obj.index_offset * std::mem::size_of::<u32>()) as u64;
        let i_len = (obj.index_count * std::mem::size_of::<u32>()) as u64;
        self.chunk_stream.vtx_alloc.free(v_off, v_len, retire_frame);
        self.chunk_stream.idx_alloc.free(i_off, i_len, retire_frame);
        let obj = &mut self.draw.objects[draw_idx];
        obj.visible = false;
        obj.resident = false;
        // The removed chunk leaves the RT-relevant draw set; the next RT update
        // drops its BLAS (deferred-freed once in-flight traces retire).
        self.rt_topology_dirty = true;
        Ok(())
    }

    // Rewrite a resident chunk's model matrix.
    //
    // Used by camera-relative rendering: when the camera crosses into a new
    // chunk the render origin follows it, so every resident chunk is rebased
    // onto the new origin. Only the model matrix changes -- the geometry stays
    // where it was uploaded.
    pub(crate) fn set_chunk_model(
        &mut self,
        draw_idx: usize,
        model: [[f32; 4]; 4],
    ) -> Result<(), String> {
        let obj = self
            .draw
            .objects
            .get_mut(draw_idx)
            .ok_or_else(|| format!("set_chunk_model: draw object {} out of range", draw_idx))?;
        obj.model = model;
        Ok(())
    }
}

impl DxContext {
    // Upload skinned-mesh geometry and build the skinned render pipelines.
    //
    // Called once at init by `GraphicsSystem` when the world declares at least
    // one `SkinnedMesh`. The skinned vertex + shadow shaders are compiled from
    // the inline HLSL; the fragment shader is shared with the static path. The
    // joint matrices live in per-(frame, object) upload buffers the skinned
    // passes bind as a root SRV. With no skinned meshes this is never called
    // and every skinned pass is skipped.
    pub(crate) fn upload_skinned(
        &mut self,
        vertices: &[SkinnedVertex],
        indices: &[u32],
        draw_objects: Vec<SkinnedDrawObject>,
        frag_bytes: &[u8],
    ) -> Result<(), String> {
        if draw_objects.is_empty() || vertices.is_empty() || indices.is_empty() {
            return Ok(());
        }
        if draw_objects.len() > MAX_SKINNED_OBJECTS {
            return Err(format!(
                "skinned: {} skinned meshes exceeds MAX_SKINNED_OBJECTS ({})",
                draw_objects.len(),
                MAX_SKINNED_OBJECTS
            ));
        }
        self.wait_idle();

        let (skinned_vs, skinned_shadow_vs, frag_ps) =
            compile_skinned_shaders(frag_bytes, self.hot_reload.enabled)?;

        // Main skinned pipeline: reuses the instanced root signature (its root
        // SRV at t3 carries the joint matrices) and the off-screen HDR target.
        let skinned_root_sig = dump_on_err(
            self.diagnostics.info_queue.as_ref(),
            create_main_instanced_root_signature(&self.device),
        )?;
        let skinned_pso = dump_on_err(
            self.diagnostics.info_queue.as_ref(),
            create_skinned_pso(
                &self.device,
                &skinned_root_sig,
                &skinned_vs,
                &frag_ps,
                HDR_FORMAT,
                self.hdr.msaa_samples,
            ),
        )?;

        // Skinned shadow pipeline: built only when the static shadow pass is
        // active, so a skinned mesh casts a correctly deformed shadow.
        let (skinned_shadow_root_sig, skinned_shadow_pso) = if self.shadow_pso.is_some() {
            let sr = dump_on_err(
                self.diagnostics.info_queue.as_ref(),
                create_skinned_shadow_root_signature(&self.device),
            )?;
            let sp = dump_on_err(
                self.diagnostics.info_queue.as_ref(),
                create_skinned_shadow_pso(&self.device, &sr, &skinned_shadow_vs),
            )?;
            (Some(sr), Some(sp))
        } else {
            (None, None)
        };

        // Shared skinned vertex/index buffers (DEFAULT heap, GPU-copied once).
        let vtx_bytes = bytemuck::cast_slice(vertices);
        let idx_bytes = bytemuck::cast_slice(indices);
        // GENERIC_READ (rather than the narrower VERTEX_AND_CONSTANT_BUFFER /
        // INDEX_BUFFER) so these stay both vertex/index-bindable for the skinned
        // main + shadow passes AND shader-readable as raw root SRVs for the RT
        // skin compute dispatch (bind-pose VB) and the RT reflection trace (u32
        // IB). GENERIC_READ is a superset of both, so no per-frame transition on
        // these shared resources is needed.
        let skinned_vertex_buffer =
            upload_buffer(&self.alloc, vtx_bytes, D3D12_RESOURCE_STATE_GENERIC_READ)?;
        // Never zero-length: the ray-traced hit path binds this buffer as a raw
        // word array and no backend accepts a zero-length binding.
        let skinned_index_buffer = upload_buffer_padded(
            &self.alloc,
            idx_bytes,
            crate::gfx::rt_geom::skinned_index_buffer_bytes(indices.len()) as u64,
            D3D12_RESOURCE_STATE_GENERIC_READ,
        )?;
        self.skinned.vertex_buffer_view = D3D12_VERTEX_BUFFER_VIEW {
            BufferLocation: com::gpu_va(&skinned_vertex_buffer),
            SizeInBytes: vtx_bytes.len() as u32,
            StrideInBytes: std::mem::size_of::<SkinnedVertex>() as u32,
        };
        self.skinned.index_buffer_view = D3D12_INDEX_BUFFER_VIEW {
            BufferLocation: com::gpu_va(&skinned_index_buffer),
            SizeInBytes: idx_bytes.len() as u32,
            Format: DXGI_FORMAT_R32_UINT,
        };

        // Per-(frame, object) joint-matrix upload buffers, each MAX_JOINTS
        // float4x4 matrices, persistently mapped.
        //
        // The buffer is seeded with `MAX_JOINTS` identity matrices once at
        // creation. `upload_joint_matrices` later overwrites only the first
        // `mats.len()` slots each frame; anything past the live pose count
        // keeps the identity seed, so a vertex whose `joints.{xyzw}` indexes
        // past the live range degenerates into an LBS of identity matrices
        // (i.e. its bind-pose position) instead of reading uninitialised
        // UPLOAD-heap memory and producing an arbitrary spike. The seed is
        // also what the renderer wants on frame 0 before the first pose
        // arrives: every joint is identity, so the mesh shows in bind pose.
        let joint_buf_bytes = (MAX_JOINTS * std::mem::size_of::<[[f32; 4]; 4]>()) as u64;
        let identity_seed: Vec<[[f32; 4]; 4]> = vec![IDENTITY; MAX_JOINTS];
        let mut joint_buffers: Vec<Vec<PooledBuffer>> = Vec::with_capacity(FRAMES);
        let mut joint_ptrs: Vec<Vec<*mut u8>> = Vec::with_capacity(FRAMES);
        for _ in 0..FRAMES {
            let mut frame_bufs: Vec<PooledBuffer> = Vec::with_capacity(draw_objects.len());
            let mut frame_ptrs: Vec<*mut u8> = Vec::with_capacity(draw_objects.len());
            for _ in 0..draw_objects.len() {
                let buf = create_buffer(
                    &self.alloc,
                    joint_buf_bytes,
                    D3D12_HEAP_TYPE_UPLOAD,
                    D3D12_RESOURCE_STATE_GENERIC_READ,
                )
                .map_err(|e| format!("skinned joint buf: {e}"))?;
                let mut ptr = std::ptr::null_mut::<std::ffi::c_void>();
                // SAFETY: the mapping covers an UPLOAD-heap buffer created to hold this payload,
                // and the source is a separate allocation, so the ranges cannot overlap.
                unsafe {
                    buf.Map(0, None, Some(&mut ptr))
                        .map_err(|e| format!("map skinned joint buf: {e}"))?;
                    std::ptr::copy_nonoverlapping(
                        identity_seed.as_ptr() as *const u8,
                        ptr as *mut u8,
                        joint_buf_bytes as usize,
                    );
                }
                frame_bufs.push(buf);
                frame_ptrs.push(ptr as *mut u8);
            }
            joint_buffers.push(frame_bufs);
            joint_ptrs.push(frame_ptrs);
        }

        // Bake each skinned object's (albedo, normal) SRV pair from its
        // material's texture-pool slots, clamped to the pool length.
        let last_tex = self.descriptors.textures.len().saturating_sub(1);
        for (i, obj) in draw_objects.iter().enumerate() {
            write_texture_srv(
                &self.device,
                &self.descriptors.textures[obj.texture_slot.min(last_tex)],
                self.srv_slot_cpu(self.skinned.srv_base_slot + i * 2),
            );
            write_texture_srv(
                &self.device,
                self.normal_pool_resource(obj.normal_map_slot),
                self.srv_slot_cpu(self.skinned.srv_base_slot + i * 2 + 1),
            );
        }

        // Seed each object's joint matrices to identity (bind pose) so the mesh
        // renders undeformed until the first `update_skinned_pose`.
        self.skinned.joint_matrices = draw_objects
            .iter()
            .map(|o| vec![IDENTITY; o.joint_count.max(1)])
            .collect();

        self.skinned.pso = Some(skinned_pso);
        self.skinned.root_sig = Some(skinned_root_sig);
        self.skinned.shadow_pso = skinned_shadow_pso;
        self.skinned.shadow_root_sig = skinned_shadow_root_sig;
        self.skinned.vertex_buffer = Some(skinned_vertex_buffer);
        self.skinned.index_buffer = Some(skinned_index_buffer);
        self.skinned.joint_buffers = joint_buffers;
        self.skinned.joint_ptrs = joint_ptrs;
        self.skinned.draw_objects = draw_objects;

        // Morph targets are attached by a later `upload_skinned_morphs`; until
        // then every object is morphless (a re-upload / hot-reload resets here).
        let n_objects = self.skinned.draw_objects.len();
        self.skinned.morph_delta_buffers = (0..n_objects).map(|_| None).collect();
        self.skinned.morph_target_counts = vec![0; n_objects];
        self.skinned.morph_weights = vec![Vec::new(); n_objects];
        self.skinned.morph_weight_buffers = Vec::new();
        self.skinned.morph_weight_ptrs = Vec::new();

        // GPU-driven main-pass skinning fold. When the bindless cull path is
        // active, build the `rt_skin` compute pipeline (reused independently of
        // RT) + one UAV-writable deformed-vertex buffer per frame-in-flight, sized
        // to all skinned verts. Each frame `encode_skin` poses the bind-pose verts
        // into this frame's buffer and the bindless main pass's 2nd ExecuteIndirect
        // draws the skinned records the cull buffers reserved. Setting
        // `self.draw.n_skinned` here (not at init) engages the fold; a build failure
        // leaves it 0 and the legacy skinned main pass runs.
        // The cull / object / draw-args / indirect buffers already reserved the
        // skinned tail at init via the threaded `n_skinned` capacity.
        //
        // The gate counts the objects being uploaded here: `cull_count()` reads
        // `draw.n_skinned`, which only the block below sets, so consulting it
        // alone would leave a world whose only geometry is skinned on the legacy
        // pass forever -- and that pass does not morph (rt_skin is the only
        // DirectX shader that reads morph targets).
        if self.cull.main_bindless_pso.is_some()
            && self.cull_count() + self.skinned.draw_objects.len() > 0
        {
            let stride = std::mem::size_of::<Vertex>();
            let deformed_bytes = (vertices.len() * stride).max(stride) as u64;
            let mut deformed_buffers: Vec<ID3D12Resource> = Vec::with_capacity(FRAMES);
            let mut deformed_vbvs: Vec<D3D12_VERTEX_BUFFER_VIEW> = Vec::with_capacity(FRAMES);
            for _ in 0..FRAMES {
                let buf =
                    create_uav_buffer(&self.device, deformed_bytes, D3D12_RESOURCE_STATE_COMMON)?;
                let vbv = D3D12_VERTEX_BUFFER_VIEW {
                    BufferLocation: com::gpu_va(&buf),
                    SizeInBytes: deformed_bytes as u32,
                    StrideInBytes: stride as u32,
                };
                deformed_buffers.push(buf);
                deformed_vbvs.push(vbv);
            }
            // Move COMMON -> VERTEX_AND_CONSTANT_BUFFER so the per-frame skin
            // pass's VERTEX -> UAV -> VERTEX transition cycle is valid from frame 0.
            // SAFETY: the command list is in the recording state, and every resource, descriptor
            // and slice these commands name is live for the call.
            one_shot_submit(&self.device, &self.command_queue, |cmd| unsafe {
                let barriers: Vec<D3D12_RESOURCE_BARRIER> = deformed_buffers
                    .iter()
                    .map(|b| {
                        transition_barrier(
                            b,
                            D3D12_RESOURCE_STATE_COMMON,
                            D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER,
                        )
                    })
                    .collect();
                cmd.ResourceBarrier(&barriers);
            })?;
            match super::raytrace::build_rt_skin_pipeline(&self.device, self.hot_reload.enabled) {
                Ok(skin) => {
                    self.skinned.skin_pipeline = Some(skin);
                    self.skinned.deformed_buffers = deformed_buffers;
                    self.skinned.deformed_vbvs = deformed_vbvs;
                    // Fresh ring: no slot has been posed yet, so the G-buffer
                    // velocity must treat the previous deformed buffer as the
                    // current one until a full frame has primed it.
                    self.skinned
                        .deformed_primed
                        .store(false, std::sync::atomic::Ordering::Relaxed);
                    self.draw.n_skinned = self.skinned.draw_objects.len();
                }
                Err(e) => {
                    tracing::warn!(
                        "skinned: rt_skin pipeline build failed ({e}); skinned meshes use \
                         the legacy main pass"
                    );
                }
            }
        }

        // Build the unified G-buffer pre-pass's skinned PSO now that the
        // skinned vertex layout exists. Without it, skinned meshes are missing
        // from the normal+depth / roughness / velocity targets, so they ghost
        // under TAA, fail to occlude in SSAO, and do not appear in the SSR
        // reflection ray-march. A no-op when no screen-space consumer drives the
        // pre-pass (the G-buffer is absent).
        if let Some(gbuffer) = self.gbuffer.as_mut() {
            gbuffer.ensure_skinned_pso(
                &self.device,
                self.hot_reload.enabled,
                self.diagnostics.info_queue.as_ref(),
            )?;
        }

        Ok(())
    }

    // Overwrite a `SkinnedMesh` draw slot's vertex + index data in the shared
    // skinned vertex / index buffers in place. Driven by asset hot-reload
    // (`cn debug` only). The slot's vertex region starts at
    // `vertex_base * size_of::<SkinnedVertex>()` and is `vertices.len()`
    // vertices wide; the index region lives at the slot's init-time
    // `index_offset` / `index_count`. Indices are rebased onto `vertex_base`
    // before writing (matching the init-time `upload_skinned` rebasing).
    // `indices.len()` must match init-time; size-changing reloads route
    // through `rebuild_skinned_geometry`. Joint-count
    // changes resize the per-slot joint-matrix buffers via
    // `update_skinned_skeleton`. Pipelines stay untouched.
    // Mirrors `MtlContext::update_skinned_mesh_geometry`.
    pub(crate) fn update_skinned_mesh_geometry(
        &mut self,
        skinned_index: usize,
        vertex_base: u32,
        vertices: &[SkinnedVertex],
        indices: &[u16],
    ) -> Result<(), String> {
        let obj = self
            .skinned
            .draw_objects
            .get(skinned_index)
            .ok_or_else(|| {
                format!(
                    "update_skinned_mesh_geometry: skinned object {} out of range",
                    skinned_index
                )
            })?;
        if indices.len() != obj.index_count {
            return Err(format!(
                "update_skinned_mesh_geometry: skinned {} expects {} indices, got {} \
                 (in-place path is size-matched only; size changes route through \
                 rebuild_skinned_geometry)",
                skinned_index,
                obj.index_count,
                indices.len()
            ));
        }
        let v_buf = self.skinned.vertex_buffer.clone().ok_or(
            "update_skinned_mesh_geometry: no skinned vertex buffer (was upload_skinned called?)",
        )?;
        let i_buf = self.skinned.index_buffer.clone().ok_or(
            "update_skinned_mesh_geometry: no skinned index buffer (was upload_skinned called?)",
        )?;
        // Check the vertex region fits inside the live buffer. The shared
        // buffer was sized once at `upload_skinned` to hold every skinned
        // mesh's vertices; vertex_base + vertices.len() must stay within that
        // region or a neighbouring slot would be overwritten.
        let v_byte_off = (vertex_base as usize) * std::mem::size_of::<SkinnedVertex>();
        let v_byte_len = std::mem::size_of_val(vertices);
        let v_buf_len = self.skinned.vertex_buffer_view.SizeInBytes as usize;
        if v_byte_off + v_byte_len > v_buf_len {
            return Err(format!(
                "update_skinned_mesh_geometry: vertex region [{}, {}) overruns skinned \
                 vertex buffer length {}",
                v_byte_off,
                v_byte_off + v_byte_len,
                v_buf_len
            ));
        }
        let i_byte_off = (obj.index_offset * std::mem::size_of::<u32>()) as u64;
        let rebased: Vec<u32> = indices
            .iter()
            .map(|&i| u32::from(i) + vertex_base)
            .collect();

        self.wait_idle();

        let vert_bytes = bytemuck::cast_slice(vertices);
        self.write_geometry_region(
            &v_buf,
            D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER,
            v_byte_off as u64,
            vert_bytes,
        )?;
        let idx_bytes = bytemuck::cast_slice(&rebased);
        self.write_geometry_region(
            &i_buf,
            D3D12_RESOURCE_STATE_INDEX_BUFFER,
            i_byte_off,
            idx_bytes,
        )?;
        Ok(())
    }

    // Update a skinned slot's joint count and resize its per-slot CPU
    // joint-matrix buffer to match. Driven by asset hot-reload (`cn debug`
    // only) when a re-imported `.glb`'s skeleton has a different joint
    // count than the slot was initialised with. New entries are seeded to
    // identity so the slot renders undeformed until the next
    // `update_skinned_pose` writes the new pose. The shared skinned
    // pipelines + per-frame GPU joint buffers stay untouched; the GPU
    // buffers are sized for `MAX_JOINTS` at init, so a joint-count change
    // only resizes the CPU-side `skinned_joint_matrices[skinned_index]`
    // Vec (and `SkinnedDrawObject.joint_count`); the next
    // `upload_joint_matrices` writes the new (capped at `MAX_JOINTS`)
    // count of matrices into the per-frame ring. The velocity pre-pass
    // reads the previous-frame pose from `(frame_idx + FRAMES - 1) %
    // FRAMES` of the same ring rather than a separate CPU mirror, so no
    // "prev" array needs resizing; joints past the previous pose's
    // length retain the init identity seed (or stale prior data) for one
    // post-reload frame and then catch up. Mirrors
    // `MtlContext::update_skinned_skeleton`.
    pub(crate) fn update_skinned_skeleton(
        &mut self,
        skinned_index: usize,
        new_joint_count: usize,
    ) -> Result<(), String> {
        let obj = self
            .skinned
            .draw_objects
            .get_mut(skinned_index)
            .ok_or_else(|| {
                format!(
                    "update_skinned_skeleton: skinned object {} out of range",
                    skinned_index
                )
            })?;
        let capped = new_joint_count.min(MAX_JOINTS);
        obj.joint_count = capped;
        let size = capped.max(1);
        if let Some(slot) = self.skinned.joint_matrices.get_mut(skinned_index) {
            slot.resize(size, IDENTITY);
        }
        Ok(())
    }

    // Replace the skinning matrices for one skinned object. Called each frame
    // from `GraphicsSystem` with the pose `AnimationSystem` computed. Out-of-
    // range indices are ignored.
    pub(crate) fn update_skinned_pose(&mut self, skinned_index: usize, matrices: &[[[f32; 4]; 4]]) {
        if let Some(slot) = self.skinned.joint_matrices.get_mut(skinned_index) {
            slot.clear();
            slot.extend_from_slice(matrices);
            if slot.is_empty() {
                slot.push(IDENTITY);
            }
        }
    }

    // Reveal the pre-reserved skinned instance at `instance_index` (the
    // engine's instance pool decided which): show it at `model` and reset its
    // joint palette to the bind pose so it does not flash its previous
    // occupant's last frame (the owning `SkeletonPose`'s first pose push
    // replaces it next frame). The copy's deformed region is already valid
    // because `encode_skin` folds every pre-reserved copy each frame. A no-op
    // if the index is out of range. Mirrors the Metal path.
    pub(crate) fn reveal_skinned_instance(&mut self, instance_index: usize, model: [[f32; 4]; 4]) {
        let Some(obj) = self.skinned.draw_objects.get_mut(instance_index) else {
            return;
        };
        obj.model = model;
        obj.visible = true;
        if let Some(palette) = self.skinned.joint_matrices.get_mut(instance_index) {
            palette.iter_mut().for_each(|m| *m = IDENTITY);
        }
    }

    // Hide a skinned object; the engine's instance pool recycles the slot. A
    // no-op if the index is out of range. Mirrors the Metal path.
    pub(crate) fn retire_skinned_draw_object(&mut self, skinned_index: usize) {
        if let Some(obj) = self.skinned.draw_objects.get_mut(skinned_index) {
            obj.visible = false;
        }
    }

    // Push the model-to-world matrices of the given skinned objects, one
    // `(skinned index, matrix)` entry per moved instance. The per-frame cull
    // records and the legacy skinned draw both read `obj.model` directly, so
    // this only writes the fields. Out-of-range indices have no effect.
    pub(crate) fn update_skinned_models(&mut self, updates: &[(u32, [[f32; 4]; 4])]) {
        for &(skinned_index, model) in updates {
            if let Some(obj) = self.skinned.draw_objects.get_mut(skinned_index as usize) {
                obj.model = model;
            }
        }
    }

    // Copy this frame's skinning matrices into the per-frame joint buffers.
    // Called from `record_frame` before the skinned shadow + main passes.
    pub(super) fn upload_joint_matrices(&self, frame_idx: usize) {
        let Some(frame_ptrs) = self.skinned.joint_ptrs.get(frame_idx) else {
            return;
        };
        for (i, mats) in self.skinned.joint_matrices.iter().enumerate() {
            let Some(&dst) = frame_ptrs.get(i) else {
                continue;
            };
            let n = mats.len().min(MAX_JOINTS);
            // SAFETY: the mapping covers an UPLOAD-heap buffer created to hold this payload, and
            // the source is a separate allocation, so the ranges cannot overlap.
            unsafe {
                std::ptr::copy_nonoverlapping(
                    mats.as_ptr() as *const u8,
                    dst,
                    n * std::mem::size_of::<[[f32; 4]; 4]>(),
                );
            }
        }
    }

    // GPU virtual address of skinned object `i`'s joint buffer for `frame_idx`.
    pub(super) fn skinned_joint_gva(&self, frame_idx: usize, i: usize) -> u64 {
        com::gpu_va(&self.skinned.joint_buffers[frame_idx][i])
    }

    // Attach morph-target buffers (`PayloadMorphs::packed_words`) to the skinned
    // draw objects. `morphs[i]` pairs with draw object `i`; instance copies share
    // their template's `Arc`, so each unique entry set becomes one GPU buffer. Allocates the per-frame
    // weight upload buffers (one f32 per target per object) when any object
    // carries morphs. Called once after `upload_skinned`.
    pub(super) fn upload_skinned_morphs(
        &mut self,
        morphs: Vec<Option<std::sync::Arc<crate::gfx::mesh_payload::PayloadMorphs>>>,
    ) -> Result<(), String> {
        use std::collections::HashMap;

        let n = self.skinned.draw_objects.len();
        let mut delta_buffers: Vec<Option<PooledBuffer>> = Vec::with_capacity(n);
        let mut target_counts: Vec<u32> = Vec::with_capacity(n);
        let mut weights: Vec<Vec<f32>> = Vec::with_capacity(n);
        let mut by_source: HashMap<usize, (PooledBuffer, u32)> = HashMap::new();

        for m in morphs.iter().take(n) {
            match m {
                None => {
                    delta_buffers.push(None);
                    target_counts.push(0);
                    weights.push(Vec::new());
                }
                Some(data) => {
                    let key = std::sync::Arc::as_ptr(data) as usize;
                    let (buf, count) = match by_source.get(&key) {
                        Some(entry) => entry.clone(),
                        None => {
                            let words = data.packed_words();
                            let bytes: &[u8] = bytemuck::cast_slice(&words);
                            let buf = upload_buffer(
                                &self.alloc,
                                bytes,
                                D3D12_RESOURCE_STATE_GENERIC_READ,
                            )?;
                            let count = data.target_count() as u32;
                            by_source.insert(key, (buf.clone(), count));
                            (buf, count)
                        }
                    };
                    delta_buffers.push(Some(buf));
                    weights.push(vec![0.0; count as usize]);
                    target_counts.push(count);
                }
            }
        }
        // Pad the tail morphless if `morphs` was shorter than `draw.objects`.
        while delta_buffers.len() < n {
            delta_buffers.push(None);
            target_counts.push(0);
            weights.push(Vec::new());
        }

        // Per-(frame, object) weight upload buffers, one f32 per target (>= 1 so
        // every slot has a valid GVA), persistently mapped and zero-seeded. Only
        // allocated when some object carries morphs.
        let (mut weight_buffers, mut weight_ptrs) = (Vec::new(), Vec::new());
        if target_counts.iter().any(|&c| c > 0) {
            for _ in 0..FRAMES {
                let mut frame_bufs: Vec<PooledBuffer> = Vec::with_capacity(n);
                let mut frame_ptrs: Vec<*mut u8> = Vec::with_capacity(n);
                for count in &target_counts {
                    let bytes = ((*count).max(1) as u64) * std::mem::size_of::<f32>() as u64;
                    let buf = create_buffer(
                        &self.alloc,
                        bytes,
                        D3D12_HEAP_TYPE_UPLOAD,
                        D3D12_RESOURCE_STATE_GENERIC_READ,
                    )
                    .map_err(|e| format!("morph weight buf: {e}"))?;
                    let mut ptr = std::ptr::null_mut::<std::ffi::c_void>();
                    // SAFETY: the mapping covers an UPLOAD-heap buffer created to hold this
                    // payload, and the source is a separate allocation, so the ranges cannot
                    // overlap.
                    unsafe {
                        buf.Map(0, None, Some(&mut ptr))
                            .map_err(|e| format!("map morph weight buf: {e}"))?;
                        std::ptr::write_bytes(ptr as *mut u8, 0, bytes as usize);
                    }
                    frame_bufs.push(buf);
                    frame_ptrs.push(ptr as *mut u8);
                }
                weight_buffers.push(frame_bufs);
                weight_ptrs.push(frame_ptrs);
            }
        }

        self.skinned.morph_delta_buffers = delta_buffers;
        self.skinned.morph_target_counts = target_counts;
        self.skinned.morph_weights = weights;
        self.skinned.morph_weight_buffers = weight_buffers;
        self.skinned.morph_weight_ptrs = weight_ptrs;
        Ok(())
    }

    // Replace one skinned object's morph weights. Out-of-range indices and
    // objects without morph targets are ignored; extra weights are dropped.
    pub(super) fn update_morph_weights(&mut self, skinned_index: usize, weights: &[f32]) {
        if let Some(slot) = self.skinned.morph_weights.get_mut(skinned_index) {
            for (i, w) in slot.iter_mut().enumerate() {
                *w = weights.get(i).copied().unwrap_or(0.0);
            }
        }
    }

    // Copy this frame's morph weights into the per-frame weight buffers. Called
    // from `record_frame` alongside `upload_joint_matrices`. A no-op when no
    // object carries morphs (the buffers are empty).
    pub(super) fn upload_morph_weights(&self, frame_idx: usize) {
        let Some(frame_ptrs) = self.skinned.morph_weight_ptrs.get(frame_idx) else {
            return;
        };
        for (i, w) in self.skinned.morph_weights.iter().enumerate() {
            let (Some(&dst), false) = (frame_ptrs.get(i), w.is_empty()) else {
                continue;
            };
            // SAFETY: the mapping covers an UPLOAD-heap buffer created to hold this payload, and
            // the source is a separate allocation, so the ranges cannot overlap.
            unsafe {
                std::ptr::copy_nonoverlapping(
                    w.as_ptr() as *const u8,
                    dst,
                    w.len() * std::mem::size_of::<f32>(),
                );
            }
        }
    }

    // GPU virtual address of skinned object `i`'s morph weight buffer for
    // `frame_idx`, or `None` when no weight buffers are allocated.
    pub(super) fn morph_weight_gva(&self, frame_idx: usize, i: usize) -> Option<u64> {
        let buf = self.skinned.morph_weight_buffers.get(frame_idx)?.get(i)?;
        Some(com::gpu_va(buf))
    }
}

// World-Shader runtime hot-swap (RenderBackend::update_world_shader_pipelines)

// cn-debug-only runtime-mutation surface; dead from the FFI lib crate's roots,
// live in the concinnity binary. See the note on the analogous block in
// [directx/particle.rs].
impl DxContext {
    // Rebuild the world-driven graphics pipelines from freshly compiled
    // Shader stage bytes and hot-swap them, for the live-reload path
    // (`reload_shader_stages` -> here). A custom-shader world's vertex +
    // fragment stages drive the legacy static main pipeline; the instanced
    // pipeline pairs the world's instanced vertex stage with the same fragment;
    // the skinned main pipeline keeps its engine-internal 80-byte vertex shader
    // and only swaps the fragment (matching `upload_skinned`, which ignores the
    // static vertex bytes). The shadow, bindless, and cull pipelines are
    // engine-internal and reload through `reload_shaders`, not here.
    //
    // Everything is built into temporaries first; any compile / PSO-create
    // failure early-returns with the live pipelines untouched, mirroring
    // `reload_shaders`. Mirrors `MtlContext::update_world_shader_pipelines`.
    pub(crate) fn update_world_shader_pipelines(
        &mut self,
        vert_bytes: Option<&[u8]>,
        frag_bytes: Option<&[u8]>,
        _shadow_bytes: Option<&[u8]>,
        vert_instanced_bytes: Option<&[u8]>,
    ) -> Result<(), String> {
        let vert = vert_bytes.ok_or_else(|| {
            "update_world_shader_pipelines: vertex shader bytes are required".to_string()
        })?;
        let frag = frag_bytes.ok_or_else(|| {
            "update_world_shader_pipelines: fragment shader bytes are required".to_string()
        })?;
        let iq = self.diagnostics.info_queue.as_ref();
        let msaa = self.hdr.msaa_samples;

        // Legacy static main pipeline (the path a custom-shader world uses; the
        // bindless variant stays engine-internal). Reuses the live root sig.
        let new_main = dump_on_err(
            iq,
            create_main_pso(
                &self.device,
                &self.main_root_sig,
                vert,
                frag,
                HDR_FORMAT,
                msaa,
            ),
        )?;

        // Instanced pipeline: rebuilt only when one is live. Needs the world's
        // instanced vertex stage paired with the fresh fragment.
        let new_instanced = if let (Some(_), Some(root_sig)) = (
            self.instanced.pso.as_ref(),
            self.instanced.root_sig.as_ref(),
        ) {
            let inst = vert_instanced_bytes.ok_or_else(|| {
                "update_world_shader_pipelines: instanced vertex shader bytes are required \
                 when an instanced pipeline is live"
                    .to_string()
            })?;
            Some(dump_on_err(
                iq,
                create_main_pso(&self.device, root_sig, inst, frag, HDR_FORMAT, msaa),
            )?)
        } else {
            None
        };

        // Skinned main pipeline: rebuilt only when one is live. Keeps its
        // engine-internal skinned vertex shader; only the fragment changes
        // (`compile_skinned_shaders` treats the fresh `frag` as the precompiled
        // pixel shader, exactly as `upload_skinned` does at init).
        let new_skinned = if let (Some(_), Some(root_sig)) =
            (self.skinned.pso.as_ref(), self.skinned.root_sig.as_ref())
        {
            let (skinned_vs, _skinned_shadow_vs, frag_ps) =
                compile_skinned_shaders(frag, self.hot_reload.enabled)?;
            Some(dump_on_err(
                iq,
                create_skinned_pso(
                    &self.device,
                    root_sig,
                    &skinned_vs,
                    &frag_ps,
                    HDR_FORMAT,
                    msaa,
                ),
            )?)
        } else {
            None
        };

        // All builds succeeded: swap into the live context. The next frame's
        // draw calls bind the freshly compiled pipelines.
        self.main_pso = new_main;
        if let Some(p) = new_instanced {
            self.instanced.pso = Some(p);
        }
        if let Some(p) = new_skinned {
            self.skinned.pso = Some(p);
        }
        // A skinned / instanced pipeline may have come live for the first time
        // here, so let the next wireframe frame rebuild its twins.
        self.invalidate_wireframe_pipelines();
        Ok(())
    }
}