concinnity-device 0.19.23

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
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
// src/metal/init/mod.rs
//
// MtlContext construction. The constructor is intentionally a flat top-to-
// bottom sequence so the order of dependencies stays obvious; helpers for
// self-contained sub-phases live in sibling modules:
//
//   window.rs    NSWindow + MTKView setup + initial HDR target sizing
//   pipelines.rs Vertex descriptor, main pipeline (+cull/bindless), instanced
//                pipeline, depth-stencil state
//   effects.rs   Bloom, TAA, velocity, SSAO, SSR, decal, volumetric fog,
//                auto-exposure (everything gated on per-world settings)
//
// What still lives inline here:
//   * Device + command queue creation
//   * Geometry, texture, sampler, IBL and LUT uploads (they share local state
//     with shadow + text + post-pipeline setup)
//   * Shadow pipeline + shadow map (depends on the shared vertex descriptor)
//   * Text + post-process pipelines + their samplers
//   * BVH partition + previous-model snapshot + hot-reload watcher
//   * The final `Self { ... }` literal
#![deny(unsafe_op_in_unsafe_fn)]

pub(super) mod effects;
pub(crate) mod pipelines;
mod window;
// Runtime vsync toggle reaches the backing CAMetalLayer through this helper.
pub(crate) use window::set_display_sync;

use concinnity_core::gfx::transform::IDENTITY;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_metal::{
    MTLCommandQueue, MTLCompareFunction, MTLCreateSystemDefaultDevice, MTLDevice as _,
    MTLResourceOptions, MTLSamplerAddressMode, MTLSamplerDescriptor, MTLSamplerMinMagFilter,
};

use crate::gfx::mesh_payload::Vertex;
use crate::gfx::render_types::NUM_SHADOW_CASCADES;

use super::allocator::DeviceAllocator;
use super::context::*;
use super::pipeline::{build_post_pipeline, build_text_pipeline};
use super::texture::{
    EnvironmentMapTextures, create_fallback_color_lut, create_fallback_cubemap,
    create_fallback_texture, create_hdr_targets, create_lut_texture, create_shadow_map_array,
    create_shadow_map_fallback, upload_color_lut, upload_environment_map, upload_texture,
    upload_texture_image,
};

// The reusable hardware handles a live world reload (`cn editor` SAVE) hands
// back so `MtlContext::build` rebuilds a world's GPU content on the *existing*
// device + command queue + window instead of creating new ones. Cloned (the
// Metal / AppKit handles are reference counted), so the underlying objects stay
// alive while the old context is dropped. See `MtlContext::apply_world_reload`.
pub(super) struct ReuseHandles {
    pub device: Retained<ProtocolObject<dyn objc2_metal::MTLDevice>>,
    pub command_queue: Retained<ProtocolObject<dyn MTLCommandQueue>>,
    pub existing: window::ExistingWindow,
}

impl MtlContext {
    // Create a window and Metal render pipeline from the assembled backend
    // inputs (see `crate::gfx::backend_init::BackendInit` for per-field docs).
    // The shadow pass is engine-internal and enabled whenever
    // `shadows.map_size > 0`.
    pub(crate) fn new(init: crate::gfx::backend_init::BackendInit<'_>) -> Result<Self, String> {
        Self::build(init, None)
    }

    // The shared constructor. `reuse == None` creates a fresh device + command
    // queue + window (the normal `new` path); `reuse == Some` rebuilds the
    // world's content on the retained hardware for a live `cn editor` reload,
    // reusing the same window so a save does not recreate it. Everything between
    // the two is identical: the same pipelines, buffers, textures, and targets
    // are built from `init` either way.
    fn build(
        init: crate::gfx::backend_init::BackendInit<'_>,
        reuse: Option<ReuseHandles>,
    ) -> Result<Self, String> {
        use crate::gfx::backend_init::{
            BackendInit, MediaPayloads, PostSettings, SceneData, ShadowParams, WorldFx,
        };
        let BackendInit {
            window,
            // The Metal validation layer is enabled by the CLI re-execing with
            // MTL_DEBUG_LAYER, not through this flag.
            validation: _,
            frames_in_flight,
            vsync,
            clear_color,
            hot_reload,
            capture,
            scene:
                SceneData {
                    vertices,
                    indices,
                    draw_objects,
                    instanced_clusters,
                    // Unused on Metal: the object / draw-args transient rings and
                    // the cull ICB auto-grow to `cull_count()` each frame (the
                    // skinned count is set later in `upload_skinned`, and resident
                    // chunks fold into the per-frame rebuild), so no init-time
                    // sizing is needed. DX/VK pre-size fixed buffers from these.
                    n_skinned: _,
                    n_chunk_max: _,
                },
            shaders: world_shaders,
            media:
                MediaPayloads {
                    textures,
                    text_atlases,
                    env_map_bytes,
                    color_lut_bytes,
                },
            light_uniforms,
            local_lights,
            spot_shadows,
            area_lights,
            shadows:
                ShadowParams {
                    map_size: shadow_map_size,
                    update: shadow_update,
                    distance: shadow_distance,
                    cascades: shadow_cascades,
                },
            anisotropy,
            planar_planes,
            post:
                PostSettings {
                    post_process: post_tunables,
                    taa_enabled,
                    ssao: ssao_settings,
                    ssr: ssr_settings,
                    ssgi: ssgi_settings,
                    rt_reflections: rt_reflection_settings,
                    rt_dynamic: rt_dynamic_mode,
                    rt_skinned_geometry,
                    reflection_blur_scale,
                    auto_exposure: auto_exposure_settings,
                    auto_exposure_bias_ev,
                    hdr_display: hdr_display_requested,
                    hdr_pq: hdr_pq_requested,
                    temporal_upscaling: temporal_upscaling_requested,
                    upscale_scale: upscale_scale_requested,
                    // Metal always uses MetalFX for temporal upscaling.
                    upscale_backend: _,
                    occlusion_two_pass: occlusion_two_pass_requested,
                },
            fx:
                WorldFx {
                    decals,
                    particles,
                    fog: fog_settings,
                    water_surfaces,
                    glass_panels,
                    sdf_volumes,
                },
            requirements,
        } = init;
        let (title, width, height, title_bar) = (
            window.title.as_str(),
            window.width,
            window.height,
            window.title_bar,
        );
        // all Metal and AppKit calls must happen on the main thread
        let mtm = objc2::MainThreadMarker::new()
            .ok_or("MtlContext::new must be called from the main thread")?;

        // A live reload reuses the existing device + command queue + window; a
        // fresh build creates them. `existing_window` (when reusing) is forwarded
        // to `setup_window_and_view` so it skips NSWindow / MTKView creation.
        let (device, command_queue, existing_window) = match reuse {
            Some(r) => (r.device, r.command_queue, Some(r.existing)),
            None => {
                let device = MTLCreateSystemDefaultDevice().ok_or("no default Metal device")?;
                let command_queue = device
                    .newCommandQueue()
                    .ok_or("failed to create Metal command queue")?;
                (device, command_queue, None)
            }
        };

        // The block pool the world's persistent buffers and textures are placed
        // in. Built before anything uploads through it, and per context: a live
        // reload keeps the device but rebuilds the world's resources, so the
        // outgoing context's heaps go with it.
        let allocator = DeviceAllocator::new(&device, frames_in_flight);

        // Main + cull + bindless argument encoder. A world with no
        // 3D scene content skips the main PBR pipeline and the whole GPU-cull
        // path: the Main pass then survives as a bare clear the composite pass
        // samples (the same shape a world_hidden frame takes).
        let vert_desc = pipelines::make_vertex_descriptor();
        let bindless = requirements.scene;
        let (pipeline_state, cull, bindless_tex_arg_encoder, bindless_sampler_arg_encoder) =
            if bindless {
                let pipelines::MainPipelineBundle {
                    pipeline_state,
                    cull,
                    bindless_tex_arg_encoder,
                    bindless_sampler_arg_encoder,
                } = pipelines::build_main_pipeline(
                    &device,
                    &vert_desc,
                    world_shaders[0].programs,
                    hot_reload,
                )?;
                (
                    Some(pipeline_state),
                    Some(cull),
                    bindless_tex_arg_encoder,
                    bindless_sampler_arg_encoder,
                )
            } else {
                (None, None, None, None)
            };
        // The probe cube argument encoder is world-independent: every pass that
        // samples the set declares the same block, and the layout is fixed by
        // MAX_PROBES rather than by world content.
        let probe_cube_arg_encoder =
            super::probe_cubes::probe_cube_arg_encoder(&device, hot_reload)?;
        let (cull_pipeline, cull_pipeline_phase2, cull_encode_pipeline, cull_icb_arg_encoder) =
            match cull {
                Some(c) => (
                    Some(c.decide),
                    Some(c.decide_phase2),
                    Some(c.encode),
                    Some(c.icb_arg_encoder),
                ),
                None => (None, None, None, None),
            };

        // Two-pass occlusion is only usable on the bindless cull path (the
        // phase-2 pipeline exists exactly then). Gate the request here so the
        // runtime flag is true only when the feature can actually run.
        let two_pass_occlusion = occlusion_two_pass_requested && cull_pipeline_phase2.is_some();

        // Material-referenced shaders (ShaderHandle 1..) each get a bindless
        // pipeline; the cull kernel routes their draws into per-bucket ICBs.
        let world_pipelines = if requirements.scene && world_shaders.len() > 1 {
            let max = crate::gfx::render_types::MAX_SHADER_BUCKETS;
            if world_shaders.len() > max {
                return Err(format!(
                    "world declares {} Shaders but at most {max} are supported",
                    world_shaders.len()
                ));
            }
            if !bindless {
                return Err(
                    "material-referenced Shaders need the GPU-driven main pass, which a \
                            world with no 3D scene content does not build"
                        .to_string(),
                );
            }
            pipelines::build_world_pipeline_table(
                &device,
                &vert_desc,
                &world_shaders[1..],
                hot_reload,
            )?
        } else {
            Vec::new()
        };
        let shader_bucket_count = 1 + world_pipelines.len();

        let depth_state = pipelines::make_depth_state(&device)?;
        let depth_state_read_only = pipelines::make_depth_state_read_only(&device)?;

        // upload vertex and index data into GPU-accessible buffers. A
        // geometry-less world (text-only) has empty slices; Metal rejects a
        // zero-length buffer, so a minimal placeholder is allocated instead --
        // the draw list is empty so the placeholder is never read.
        let vertex_buffer = if vertices.is_empty() {
            allocator.alloc_buffer(
                std::mem::size_of::<Vertex>(),
                MTLResourceOptions::StorageModeShared,
            )
        } else {
            allocator.alloc_buffer_with_bytes(
                bytes_of_slice(vertices),
                MTLResourceOptions::StorageModeShared,
            )
        }
        .map_err(|e| format!("vertex buffer: {e}"))?;

        let index_buffer = if indices.is_empty() {
            allocator.alloc_buffer(
                std::mem::size_of::<u32>(),
                MTLResourceOptions::StorageModeShared,
            )
        } else {
            allocator.alloc_buffer_with_bytes(
                bytes_of_slice(indices),
                MTLResourceOptions::StorageModeShared,
            )
        }
        .map_err(|e| format!("index buffer: {e}"))?;

        // Per-scene local-light storage buffer bound to the forward pass at
        // fragment buffer(8). Metal rejects a zero-length buffer, so a scene with
        // no local lights gets a one-element placeholder; num_local_lights == 0
        // keeps the shader from reading it.
        let local_light_buffer = {
            use crate::gfx::render_types::GpuLight;
            if local_lights.is_empty() {
                allocator.alloc_buffer(
                    std::mem::size_of::<GpuLight>(),
                    MTLResourceOptions::StorageModeShared,
                )
            } else {
                allocator.alloc_buffer_with_bytes(
                    bytes_of_slice(local_lights.as_slice()),
                    MTLResourceOptions::StorageModeShared,
                )
            }
            .map_err(|e| format!("local-light buffer: {e}"))?
        };

        // Spot shadow resources: one array slice per shadow-casting spot plus the
        // per-slice projections. Local lights are static, so both are built once
        // here and never rebuilt. A scene with no casting spot gets the 1x1
        // fallback array (depth 1.0 = lit) and a placeholder buffer, since Metal
        // rejects zero-length buffers and the fragment binding must stay valid.
        let spot_shadow_count = spot_shadows.len() as u32;
        let spot_shadow_map = if spot_shadows.is_empty() {
            create_shadow_map_fallback(&device)?
        } else {
            create_shadow_map_array(
                &device,
                crate::gfx::render_types::spot_shadow_slice_size(shadow_map_size),
                spot_shadow_count,
            )?
        };
        let spot_shadow_buffer = {
            use crate::gfx::render_types::SpotShadowData;
            if spot_shadows.is_empty() {
                allocator.alloc_buffer(
                    std::mem::size_of::<SpotShadowData>(),
                    MTLResourceOptions::StorageModeShared,
                )
            } else {
                allocator.alloc_buffer_with_bytes(
                    bytes_of_slice(spot_shadows.as_slice()),
                    MTLResourceOptions::StorageModeShared,
                )
            }
            .map_err(|e| format!("spot-shadow buffer: {e}"))?
        };

        // Per-scene rect area-light table, indexed by `GpuLight.data_index`.
        // Static like the lights themselves, so it uploads once. Metal rejects a
        // zero-length buffer, so a world with no area light gets a one-element
        // placeholder the shader never reads (every data_index stays -1).
        let area_light_buffer = {
            use crate::gfx::render_types::AreaLightData;
            if area_lights.is_empty() {
                allocator.alloc_buffer(
                    std::mem::size_of::<AreaLightData>(),
                    MTLResourceOptions::StorageModeShared,
                )
            } else {
                allocator.alloc_buffer_with_bytes(
                    bytes_of_slice(area_lights.as_slice()),
                    MTLResourceOptions::StorageModeShared,
                )
            }
            .map_err(|e| format!("area-light buffer: {e}"))?
        };

        // Area-light LTC tables. Scene-independent (they depend only on the
        // build-time fit), so they are created unconditionally and the shader
        // simply never samples them when no area light is declared.
        let ltc_matrix_texture = create_lut_texture(
            &allocator,
            crate::gfx::ltc::matrix_texels(),
            crate::gfx::ltc::LTC_LUT_SIZE as u32,
            4,
        )?;
        let ltc_magnitude_texture = create_lut_texture(
            &allocator,
            crate::gfx::ltc::magnitude_texels(),
            crate::gfx::ltc::LTC_LUT_SIZE as u32,
            2,
        )?;

        // Clustered-lighting resources: the per-cluster light-index buffer
        // (always allocated so the forward pass has a valid fragment buffer(12)
        // binding) and the binning compute pipeline (built only when the world
        // has local lights to bin).
        let cluster_light_buffer = super::light_cull::build_cluster_light_buffer(&device)?;
        let light_cull_pipeline = if local_lights.is_empty() {
            None
        } else {
            Some(super::light_cull::build_light_cull_pipeline(
                &device, hot_reload,
            )?)
        };

        // upload textures; fall back to a 1x1 opaque white texture when none provided
        let gpu_textures = if textures.is_empty() {
            vec![create_fallback_texture(&allocator)?]
        } else {
            textures
                .iter()
                .enumerate()
                .map(|(i, image)| {
                    upload_texture_image(&allocator, image)
                        .map_err(|e| format!("texture[{}]: {}", i, e))
                })
                .collect::<Result<Vec<_>, _>>()?
        };

        // Reserved fallbacks, in the order `FALLBACK_TEXTURE_COUNT` documents:
        // the 1x1 tangent-space (0,0,1) texture a draw with no normal map
        // samples, then the 1x1 white texture a draw with no albedo samples.
        // Real normal maps and albedos are textures in `gpu_textures` (the
        // shared pool) at their own handle; only these two live in
        // `gpu_fallbacks`, past the last real texture.
        let flat_normal = upload_texture(&allocator, 1, 1, &[128u8, 128, 255, 255])
            .map_err(|e| format!("flat normal fallback: {}", e))?;
        let white = upload_texture(&allocator, 1, 1, &[255u8, 255, 255, 255])
            .map_err(|e| format!("white fallback: {}", e))?;
        let gpu_fallbacks = vec![flat_normal, white];

        // The bindless static pass binds every texture plus the flat-normal
        // fallback into one capped pool. A world that exceeds the cap still
        // renders, but objects whose pool index would overflow get clamped to
        // the last slot.
        if bindless && gpu_textures.len() + gpu_fallbacks.len() > BINDLESS_TEXTURE_COUNT {
            tracing::warn!(
                "Metal: texture pool ({} textures + 2 fallbacks) exceeds bindless \
                 capacity {}; some objects will sample a clamped texture",
                gpu_textures.len(),
                BINDLESS_TEXTURE_COUNT,
            );
        }

        // linear filter, repeat wrap -- matches the room shader expectations.
        // Mipmap linear + anisotropy let minified scene textures trilinear-select
        // down the mip chain now that uploads carry one, instead of aliasing from
        // mip 0. The degree comes from GraphicsConfig.anisotropy (default 8),
        // clamped to Metal's guaranteed 1..16 range.
        let sampler = {
            let desc = MTLSamplerDescriptor::new();
            desc.setMinFilter(MTLSamplerMinMagFilter::Linear);
            desc.setMagFilter(MTLSamplerMinMagFilter::Linear);
            desc.setMipFilter(objc2_metal::MTLSamplerMipFilter::Linear);
            desc.setSAddressMode(MTLSamplerAddressMode::Repeat);
            desc.setTAddressMode(MTLSamplerAddressMode::Repeat);
            desc.setMaxAnisotropy(anisotropy.clamp(1, 16) as usize);
            // Written into the engine sampler block (an argument buffer) for
            // the single-source main program, which requires this flag.
            desc.setSupportArgumentBuffers(true);
            device
                .newSamplerStateWithDescriptor(&desc)
                .ok_or("failed to create sampler state")?
        };

        // compare sampler for PCF: always created so texture(2) / sampler(1) are
        // always bound; LessEqual returns 1.0 (lit) when reference <= stored depth.
        let shadow_sampler = {
            let desc = MTLSamplerDescriptor::new();
            desc.setMinFilter(MTLSamplerMinMagFilter::Linear);
            desc.setMagFilter(MTLSamplerMinMagFilter::Linear);
            desc.setSAddressMode(MTLSamplerAddressMode::ClampToEdge);
            desc.setTAddressMode(MTLSamplerAddressMode::ClampToEdge);
            desc.setCompareFunction(MTLCompareFunction::LessEqual);
            // Rides the engine sampler block alongside the pool sampler.
            desc.setSupportArgumentBuffers(true);
            device
                .newSamplerStateWithDescriptor(&desc)
                .ok_or("failed to create shadow sampler state")?
        };

        // Cube sampler: linear filter + clamp-to-edge + mipmap linear for prefilter
        // roughness lookups. Bound at sampler(2) and shared by both IBL cubes.
        let cube_sampler = {
            let desc = MTLSamplerDescriptor::new();
            desc.setMinFilter(MTLSamplerMinMagFilter::Linear);
            desc.setMagFilter(MTLSamplerMinMagFilter::Linear);
            desc.setMipFilter(objc2_metal::MTLSamplerMipFilter::Linear);
            desc.setSAddressMode(MTLSamplerAddressMode::ClampToEdge);
            desc.setTAddressMode(MTLSamplerAddressMode::ClampToEdge);
            desc.setRAddressMode(MTLSamplerAddressMode::ClampToEdge);
            // Rides the engine sampler block alongside the pool sampler.
            desc.setSupportArgumentBuffers(true);
            device
                .newSamplerStateWithDescriptor(&desc)
                .ok_or("failed to create cube sampler state")?
        };

        // The engine sampler block for the single-source main program, written
        // once now that the three sampler states exist.
        let bindless_sampler_args = match &bindless_sampler_arg_encoder {
            Some(enc) => Some(pipelines::build_bindless_sampler_args(
                &device,
                enc,
                &sampler,
                &shadow_sampler,
                &cube_sampler,
            )?),
            None => None,
        };

        // IBL: either upload the supplied EnvironmentMap payload or build a
        // 1x1 grey fallback cube pair so texture(3) / texture(4) are always
        // bound. The fragment shader uses `prefilter_mip_count == 0` to
        // detect the fallback and skip IBL math.
        let env_map = if let Some(bytes) = env_map_bytes {
            let view = crate::bake::environment_map::deserialise(bytes)
                .map_err(|e| format!("EnvironmentMap payload malformed: {}", e))?;
            upload_environment_map(
                &allocator,
                view.irradiance_face,
                view.irradiance_bytes,
                view.prefilter_face,
                &view.prefilter_mip_bytes,
            )?
        } else {
            EnvironmentMapTextures {
                irradiance: create_fallback_cubemap(&allocator, [0.05, 0.05, 0.05, 1.0])?,
                prefilter: create_fallback_cubemap(&allocator, [0.05, 0.05, 0.05, 1.0])?,
                prefilter_mip_count: 0,
            }
        };

        // Colour-grading LUT: upload the declared ColorLut payload, or build a
        // 2x2x2 identity LUT so the composite pass always binds a valid 3D
        // texture. With the identity LUT the grade is a no-op at any strength.
        let color_lut = if let Some(bytes) = color_lut_bytes {
            let (size, data) = crate::bake::color_lut::deserialise(bytes)
                .map_err(|e| format!("ColorLut payload malformed: {}", e))?;
            upload_color_lut(&allocator, size, data)?
        } else {
            create_fallback_color_lut(&allocator)?
        };

        // shadow pipeline + array map: created only when shadow_map_size > 0.
        // The fallback 1x1 shadow map (all depth = 1.0 = max = lit) is always
        // bound so fragment shaders can safely sample texture(2) as a depth array.
        let (shadow_pipeline_state, shadow_map, shadow_uniforms_init, effective_shadow_size) =
            if shadow_map_size > 0 {
                let shadow_ps = pipelines::build_shadow_pipeline(&device, &vert_desc, hot_reload)?;
                // Depth32Float 2D array, NUM_SHADOW_CASCADES layers, GPU-private.
                let shadow_tex =
                    create_shadow_map_array(&device, shadow_map_size, NUM_SHADOW_CASCADES as u32)?;
                (
                    Some(shadow_ps),
                    shadow_tex,
                    crate::gfx::csm::empty_shadow_uniforms(),
                    shadow_map_size,
                )
            } else {
                // 1x1 fallback depth array (value 1.0 = fully lit).
                let shadow_tex = create_shadow_map_fallback(&device)?;
                (
                    None,
                    shadow_tex,
                    crate::gfx::csm::empty_shadow_uniforms(),
                    1,
                )
            };

        // GPU-driven cascaded-shadow resources: the frustum-only
        // shadow decision kernel and the depth-only bindless shadow render
        // pipeline. Built only on the bindless path with
        // shadows enabled; non-bindless / no-shadow worlds keep the legacy
        // per-cascade CPU shadow loop and leave these `None`. The shadow ICB +
        // its argument buffer are allocated lazily by `ensure_shadow_icb_capacity`
        // (sized to NUM_SHADOW_CASCADES * cull_count once geometry is known).
        let (shadow_cull_pipeline, shadow_bindless_pipeline) = if shadow_pipeline_state.is_some()
            && bindless
        {
            let sc = super::cull::build_shadow_cull_pipeline(&device, hot_reload)?;
            let sb = pipelines::build_shadow_bindless_pipeline(&device, &vert_desc, hot_reload)?;
            (Some(sc), Some(sb))
        } else {
            (None, None)
        };

        // Cache the first directional light's direction; per-frame CSM updates
        // use it. `update_directional_lights` re-caches it when the sun changes.
        let shadow_light_dir = crate::gfx::lights::sun_direction(&light_uniforms);

        // Window + MTKView + initial drawable sizing. A geometry-less world
        // is clamped to 1x1 HDR/bloom/effect targets so the composite pass
        // alone runs at the full drawable size (it samples the 1x1 uniformly).
        // Window setup also resolves the swapchain colour-output mode
        // (`HdrOutputMode::Sdr` vs `Hdr`); the post + text pipelines that
        // target the drawable need to know that mode to pick BGRA8Unorm vs
        // RGBA16Float, so this hop happens before pipeline construction.
        // Scene-less (UI / text only) worlds clamp the HDR / bloom / effect
        // targets to 1x1; keyed off the derived requirements rather than raw
        // vertex presence so a vertex-less world that still renders 3D content
        // (SDF volumes, water, glass) keeps full-size targets.
        let geometry_less = !requirements.scene;
        let window::WindowSetup {
            window,
            mtk_view,
            pump_events,
            initial_w,
            initial_h,
            fullscreen,
            window_delegate,
            hdr_mode,
        } = window::setup_window_and_view(
            mtm,
            &device,
            window::WindowConfig {
                title,
                width,
                height,
                title_bar,
                geometry_less,
                capture_enabled: capture,
            },
            window::HdrRequest {
                display_requested: hdr_display_requested,
                pq_requested: hdr_pq_requested,
            },
            existing_window,
        )?;
        // Honor the requested vsync on the backing CAMetalLayer (default
        // CAMetalLayer presentation is display-synced).
        window::set_display_sync(&mtk_view, vsync);
        let swap_pixel_format = window::swap_pixel_format(hdr_mode);
        // Resolved EDR encoding, kept for the headless `screenshot` decode (it
        // must know scRGB-linear vs PQ to turn the captured `RGBA16Float`
        // drawable into a display-correct PNG). `None` on the SDR path.
        let hdr_encoding = match hdr_mode {
            crate::gfx::hdr_output::HdrOutputMode::Hdr { encoding, .. } => Some(encoding),
            crate::gfx::hdr_output::HdrOutputMode::Sdr => None,
        };
        // Pair the authored tunables with the resolved mode's output flags. On
        // the SDR path both flags stay 0.0 and the shader runs the full ACES +
        // gamma + FXAA + LUT chain unchanged. On the HDR path `hdr_output`
        // lights up; `pq_output` further picks PQ-encode vs scRGB-linear
        // passthrough inside that branch.
        let post_process = hdr_mode.post_process_params(post_tunables);

        // text rendering resources
        let (text_pipeline_state, gpu_text_atlases) = if text_atlases.is_empty() {
            (None, Vec::new())
        } else {
            let text_ps = build_text_pipeline(&device, swap_pixel_format, hot_reload)?;
            let mut gpu_atlases = Vec::with_capacity(text_atlases.len());
            for (i, (aw, ah, pixels)) in text_atlases.iter().enumerate() {
                let tex = upload_texture(&allocator, *aw, *ah, pixels)
                    .map_err(|e| format!("text_atlas[{}]: {}", i, e))?;
                gpu_atlases.push(tex);
            }
            (Some(text_ps), gpu_atlases)
        };

        let text_sampler = {
            let desc = MTLSamplerDescriptor::new();
            desc.setMinFilter(MTLSamplerMinMagFilter::Linear);
            desc.setMagFilter(MTLSamplerMinMagFilter::Linear);
            desc.setSAddressMode(MTLSamplerAddressMode::ClampToEdge);
            desc.setTAddressMode(MTLSamplerAddressMode::ClampToEdge);
            device
                .newSamplerStateWithDescriptor(&desc)
                .ok_or("failed to create text sampler state")?
        };

        // Post-process pipeline + sampler. The composite pass samples the
        // resolved HDR target with a linear-clamp filter and writes either
        // ACES-tonemapped + gamma + FXAA-filtered output (SDR drawable) or
        // linear extended-range values (HDR drawable) into the swapchain.
        let post_pipeline_state = build_post_pipeline(&device, swap_pixel_format, hot_reload)?;
        let post_sampler = {
            let desc = MTLSamplerDescriptor::new();
            desc.setMinFilter(MTLSamplerMinMagFilter::Linear);
            desc.setMagFilter(MTLSamplerMinMagFilter::Linear);
            desc.setSAddressMode(MTLSamplerAddressMode::ClampToEdge);
            desc.setTAddressMode(MTLSamplerAddressMode::ClampToEdge);
            // Clamp the R axis too -- the same sampler trilinearly filters the
            // 3D colour LUT in the composite pass.
            desc.setRAddressMode(MTLSamplerAddressMode::ClampToEdge);
            device
                .newSamplerStateWithDescriptor(&desc)
                .ok_or("failed to create post sampler state")?
        };

        // MetalFX temporal upscaler. Built ahead of the HDR + post targets
        // because the resolved input size (clamped to the device's supported
        // scale range) determines the render resolution every other 3D-scene
        // target uses; bloom + composite stay at the drawable (output)
        // resolution. Failure or unsupported hardware falls back silently to
        // native-resolution rendering: the entire `temporal_upscaling`
        // feature is asset-driven so a world that doesn't author it pays no
        // construction cost either way.
        let upscaler = if temporal_upscaling_requested {
            if super::post::temporal_scaler_supported(&device) {
                match super::post::MetalFXUpscaler::new(
                    &device,
                    initial_w,
                    initial_h,
                    upscale_scale_requested,
                ) {
                    Ok(u) => {
                        tracing::info!(
                            "MetalFX: temporal upscaling on: render {}x{} → present {}x{} ({}x scale)",
                            u.input_width,
                            u.input_height,
                            u.output_width,
                            u.output_height,
                            (u.input_width as f32) / (u.output_width.max(1) as f32),
                        );
                        Some(u)
                    }
                    Err(e) => {
                        tracing::warn!(
                            "MetalFX: temporal scaler creation failed ({}); falling back to native resolution",
                            e
                        );
                        None
                    }
                }
            } else {
                tracing::warn!(
                    "MetalFX: temporal scaler not supported on this GPU; falling back to native resolution"
                );
                None
            }
        } else {
            None
        };
        // Render resolution comes from the scaler, which owns the clamp to the
        // device's supported range. The stored scale is the *requested* one, not
        // `input / output`: that ratio is rounded to whole pixels, so feeding it
        // back into the next rebuild shrinks the input a little further every
        // resize.
        let (render_w, render_h, upscale_scale) = match &upscaler {
            Some(u) => (u.input_width, u.input_height, upscale_scale_requested),
            None => (initial_w, initial_h, 1.0),
        };
        // With the MetalFX scaler doing temporal accumulation, the TAA pass
        // is bypassed but the velocity pre-pass and projection jitter stay
        // on (the scaler consumes both). `effective_taa_enabled` is what
        // the engine carries downstream; the asset `taa` flag is ignored
        // when upscaling is on.
        let upscaling_active = upscaler.is_some();
        let effective_taa_enabled = taa_enabled && !upscaling_active;
        let velocity_needed = effective_taa_enabled || upscaling_active;

        let hdr_targets = create_hdr_targets(&device, render_w, render_h, HDR_SAMPLE_COUNT)?;

        // Hi-Z depth pyramid for GPU-driven occlusion culling. Built exactly
        // when the bindless cull pipeline is active and sized to the render
        // (depth) resolution; `resize_targets_if_needed` rebuilds it on a
        // window resize. The cull kernel projects each AABB through the
        // previous frame's depth pyramid and culls fully-occluded objects.
        let hiz = if cull_pipeline.is_some() {
            Some(super::hiz::HiZResources::new(
                &device, render_w, render_h, hot_reload,
            )?)
        } else {
            None
        };

        // The reflection-probe convolution kernels, under the same gate: a probe
        // capture renders through the bindless ICB, so a world without the cull
        // pipeline never bakes one and never needs them.
        let probe_prefilter = if cull_pipeline.is_some() {
            Some(super::probe_prefilter::ProbePrefilterPipelines::new(
                &device, hot_reload,
            )?)
        } else {
            None
        };

        // Post-process effect chain: bloom is built for any world with a 3D
        // scene; TAA / velocity / SSAO / SSR / decal / fog / auto-exposure are
        // gated on their own settings so a world that disables them pays zero
        // construction cost.
        let effects::EffectsBundle {
            bloom_targets,
            bloom_pipelines,
            taa_pipeline_state,
            taa_targets,
            ssao,
            transient_pool,
            ssr,
            gbuffer,
            ssgi,
            rt_pipeline,
            rt_pipeline_textured,
            rt_skin_pipeline,
            decal_pipeline,
            decal_cube_vertex_buffer,
            decal_cube_index_buffer,
            decal_sampler,
            fog_pipeline,
            fog_froxel_pipeline,
            fog_froxel_volume,
            particle_pipelines,
            particle_emitter_state,
            auto_exposure_pipelines,
            auto_exposure_histogram,
            auto_exposure_output,
            auto_exposure_state,
            auto_exposure_bias_ev: auto_exposure_bias,
        } = effects::build_effects(
            &allocator,
            requirements.scene,
            effects::EffectDimensions {
                render_w,
                render_h,
                output_w: initial_w,
                output_h: initial_h,
            },
            effects::EffectSettings {
                ssao: &ssao_settings,
                ssr: &ssr_settings,
                ssgi: &ssgi_settings,
                rt_reflection: &rt_reflection_settings,
                auto_exposure: &auto_exposure_settings,
                reflection_blur_scale,
                auto_exposure_bias_ev,
            },
            effects::EffectFlags {
                taa_enabled: effective_taa_enabled,
                needs_velocity: velocity_needed,
                hot_reload,
            },
            effects::WorldContentEffects {
                fog_settings: &fog_settings,
                decals: &decals,
                particles: &particles,
                frames_in_flight,
            },
        )?;

        // The slot table the decal pass draws from: authored decals seed it in
        // order, and a runtime add reuses whatever `remove_decal` freed. Metal
        // reserves no per-decal descriptors, so the table is uncapped.
        let mut decal_set = crate::gfx::decal::DecalSet::new(usize::MAX, frames_in_flight);
        for record in decals {
            decal_set
                .insert(record)
                .map_err(|_| "decals: decal slot table is full".to_string())?;
        }

        // Transparent water surfaces. Built only when the world declared
        // ≥1 `WaterSurface`; the transparent-pass executor stays a no-op
        // otherwise. Per-surface tessellated grids upload once at init.
        let (water_pipeline, water_pipeline_rt, water_pipeline_rt_textured, mut water_records) =
            if water_surfaces.is_empty() {
                (None, None, None, Vec::new())
            } else {
                let ps = super::water::build_water_pipeline(&device, hot_reload)?;
                // The ray-traced variants are built whenever the device can ray
                // trace (regardless of whether RT is on at launch), so a live RT
                // toggle can select them without a pipeline rebuild. The shader
                // uses `metal_raytracing`, so it must not be compiled on a non-RT
                // device. The textured variant additionally needs a bindless world
                // at draw time; it is selected over the flat variant then.
                let (ps_rt, ps_rt_tex) = if super::raytrace::raytracing_supported(&device) {
                    (
                        Some(super::water::build_water_pipeline_rt(&device, hot_reload)?),
                        Some(super::water::build_water_pipeline_rt_textured(
                            &device, hot_reload,
                        )?),
                    )
                } else {
                    (None, None)
                };
                let mut records = Vec::with_capacity(water_surfaces.len());
                for s in &water_surfaces {
                    records.push(super::water::build_water_surface_record(&device, s)?);
                }
                (Some(ps), ps_rt, ps_rt_tex, records)
            };

        // Translucent glass panels. Built only when the world declared ≥1
        // `GlassPanel`; rides the same transparent pass as water. Per-panel
        // world-space quads upload once at init.
        let (glass_pipeline, glass_pipeline_rt, glass_pipeline_rt_textured, mut glass_records) =
            if glass_panels.is_empty() {
                (None, None, None, Vec::new())
            } else {
                let ps = super::glass::build_glass_pipeline(&device, hot_reload)?;
                // The ray-traced variants are built whenever the device can ray
                // trace (regardless of whether RT is on at launch), so a live RT
                // toggle can select them without a pipeline rebuild. The shader
                // uses `metal_raytracing`, so it must not be compiled on a non-RT
                // device. The textured variant additionally needs a bindless world
                // at draw time; it is selected over the flat variant then.
                let (ps_rt, ps_rt_tex) = if super::raytrace::raytracing_supported(&device) {
                    (
                        Some(super::glass::build_glass_pipeline_rt(&device, hot_reload)?),
                        Some(super::glass::build_glass_pipeline_rt_textured(
                            &device, hot_reload,
                        )?),
                    )
                } else {
                    (None, None)
                };
                let mut records = Vec::with_capacity(glass_panels.len());
                for g in &glass_panels {
                    records.push(super::glass::build_glass_panel_record(&device, g)?);
                }
                (Some(ps), ps_rt, ps_rt_tex, records)
            };

        // Transparent glass MESH pipelines (Layer 2): built whenever the device can
        // ray trace, INDEPENDENT of any `GlassPanel` -- the transparent material
        // lives on imported meshes, not panels, and a live RT toggle then has them
        // ready. `glass_mesh_pipeline_rt.is_some()` gates the whole transparent-mesh
        // reroute; `seethrough_mesh_indices` marks which `draw_objects` carry it.
        let (glass_mesh_pipeline_rt, glass_mesh_pipeline_rt_textured) =
            if super::raytrace::raytracing_supported(&device) {
                (
                    Some(super::glass::build_glass_mesh_pipeline_rt(
                        &device, hot_reload,
                    )?),
                    Some(super::glass::build_glass_mesh_pipeline_rt_textured(
                        &device, hot_reload,
                    )?),
                )
            } else {
                (None, None)
            };
        // Layer 2 see-through glass is opt-in per `Material` (the `see_through`
        // arg, which implies `transparent`): see-through only looks right when the
        // space behind the glass is modelled. A material that is `transparent` but
        // NOT `see_through` renders as Layer 1 (opaque, low roughness, scene
        // reflections) = tinted reflective glass that hides the interior. This list
        // drives the producer + the opaque-pass skip (`mesh_glass_active`) + the
        // RT-BLAS exclude together.
        let seethrough_mesh_indices: Vec<usize> = draw_objects
            .iter()
            .enumerate()
            .filter(|(_, o)| o.material.transparent != 0 && o.material.see_through != 0)
            .map(|(i, _)| i)
            .collect();
        // The Layer 2 path is enabled when at least one material opts into
        // see-through AND the mesh pipeline built (RT-capable device). Mirrors
        // `MtlContext::seethrough_meshes_enabled`; used here for the init-time BVH
        // build, which must exclude the see-through meshes it will reroute.
        let seethrough_enabled =
            !seethrough_mesh_indices.is_empty() && glass_mesh_pipeline_rt.is_some();

        // Planar reflection set: group every flat reflector (water surfaces +
        // glass panes) into a bounded number of distinct planes, one mirror render
        // each. Water planes are listed first so they take slots before glass when
        // the budget is tight. Each reflector records the slot it samples; planes
        // past the budget get no slot and keep the box-projected probe cube
        // (warned here, not silently dropped). The set is built only when the
        // world has >=1 reflector; the per-frame pass is additionally gated on RT
        // being off.
        let planar_reflection = {
            let mut planes: Vec<[f32; 4]> = Vec::new();
            for s in &water_surfaces {
                // Horizontal plane at the surface base height, normal +y.
                planes.push([0.0, 1.0, 0.0, -s.centre[1]]);
            }
            for g in &glass_panels {
                // The pane plane: normal (unit from `from_args`) through centre,
                // so `n . p + d = 0` on the pane.
                let n = g.normal;
                let d = -(n[0] * g.centre[0] + n[1] * g.centre[1] + n[2] * g.centre[2]);
                planes.push([n[0], n[1], n[2], d]);
            }
            // The budget is capped at the capacity ceiling the mirror targets + ICB
            // slots are sized to, so a stale/over-large preset value can never
            // over-allocate.
            let planar_budget = planar_planes.min(super::planar::MAX_PLANAR_PLANES);
            let assignment =
                crate::gfx::planar_reflection::assign_planar_slots(&planes, planar_budget);
            // Record each reflector's slot (water first, then glass, matching the
            // push order above).
            for (rec, slot) in water_records.iter_mut().zip(assignment.slots.iter()) {
                rec.planar_slot = *slot;
            }
            let glass_offset = water_records.len();
            for (rec, slot) in glass_records
                .iter_mut()
                .zip(assignment.slots[glass_offset..].iter())
            {
                rec.planar_slot = *slot;
            }
            let overflow = assignment.slots.iter().filter(|s| s.is_none()).count();
            if overflow > 0 {
                tracing::warn!(
                    "planar reflection: {} reflector plane(s) exceed the budget of {} \
                     and fall back to the box-projected probe cube",
                    overflow,
                    planar_budget
                );
            }
            if assignment.representatives.is_empty() {
                None
            } else {
                Some(super::planar::create_planar_set(
                    &device,
                    render_w,
                    render_h,
                    HDR_SAMPLE_COUNT,
                    &assignment.representatives,
                )?)
            }
        };

        // Raymarched SDF volumes. Each volume builds its own pipelines from the
        // field the build compiled into its payload; the proxy-cube buffers are
        // allocated once and shared across all volumes. Empty input list means
        // both stay None / empty and the raymarch executor short-circuits.
        let (raymarch_records, raymarch_cube_vertex_buffer, raymarch_cube_index_buffer) =
            if sdf_volumes.is_empty() {
                (Vec::new(), None, None)
            } else {
                let mut records = Vec::with_capacity(sdf_volumes.len());
                for (volume, payload, label) in &sdf_volumes {
                    records.push(super::raymarch::build_raymarch_volume_record(
                        &device, volume, payload, hot_reload, label,
                    )?);
                }
                let (vb, ib) = super::raymarch::build_raymarch_cube_buffers(&device)?;
                (records, Some(vb), Some(ib))
            };

        // Shader hot-reload wiring. The atomic flag is shared between the
        // notify watcher thread and `draw_frame`, plus (eventually) the
        // debug WS `reload-shaders` command path via `GraphicsSystem`.
        // Watcher creation is best-effort: a missing source dir or a notify
        // error logs a warning and disables only the watcher half -- the
        // debug command still works on the same flag.
        let (shader_reload_pending, shader_watcher) = if hot_reload {
            let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
            let watcher = super::hot_reload::spawn(std::sync::Arc::clone(&flag));
            (Some(flag), watcher)
        } else {
            (None, None)
        };

        // Built before `device` moves into Self. Returns `None` when the
        // device does not expose the timestamp counter set; the per-pass
        // GPU timer then stays at zero for every pass.
        let pass_timing = super::pass_timing::PassTimingResources::new(&device);
        tracing::info!(
            "pass-timing: per-pass GPU sample buffers {}",
            if pass_timing.is_some() {
                "ready"
            } else {
                "unavailable (no MTLCommonCounterSetTimestamp)"
            }
        );

        // Capture the resolved EDR multiplier (or None on SDR) so the HUD +
        // debug WS can report it. The shader flag in `post_process.hdr_output`
        // tracks "is HDR on" as a bool; this captures the multiplier itself.
        let max_edr = match hdr_mode {
            crate::gfx::hdr_output::HdrOutputMode::Hdr { max_edr, .. } => Some(max_edr),
            crate::gfx::hdr_output::HdrOutputMode::Sdr => None,
        };

        // Build the scene acceleration structure for hardware ray-traced
        // reflections. Only when the world enabled RT (the RT pipeline is built
        // above) and the GPU supports ray tracing; `build_rt_accel` returns None
        // when the scene has no resident geometry, in which case the RT pass
        // stays a no-op (draw/mod gates `rt_reflections_enabled` on this being
        // Some). Built here because it needs the shared geometry buffers + draw
        // list, which exist by now; resolution-independent, so untouched on
        // resize. A wholesale rebuild on geometry change is the current update path.
        let rt_accel = if rt_reflection_settings.is_some()
            && super::raytrace::raytracing_supported(&device)
        {
            match super::raytrace::build_rt_accel(
                super::raytrace::RtGpu {
                    device: &device,
                    command_queue: &command_queue,
                    frames_in_flight,
                },
                super::raytrace::RtStaticGeometry {
                    vertex_buffer: &vertex_buffer,
                    index_buffer: &index_buffer,
                },
                super::raytrace::RtSceneGeometry {
                    draw_objects: &draw_objects,
                    clusters: &instanced_clusters,
                },
                super::raytrace::RtTextureCounts {
                    albedo_count: gpu_textures.len(),
                },
                // Skinned meshes upload after `new`, so the initial BVH is
                // static + instanced; the first frame's update seeds the
                // skinned geometry once `upload_skinned` has run.
                None,
                seethrough_enabled,
            )? {
                Some(a) => {
                    tracing::info!(
                        "ray-traced reflections: built BVH over {} static objects",
                        a.blas.len()
                    );
                    Some(a)
                }
                None => {
                    tracing::warn!(
                        "ray-traced reflections requested but the scene has no static geometry to build a BVH from; reflections disabled"
                    );
                    None
                }
            }
        } else {
            if rt_reflection_settings.is_some() {
                tracing::warn!(
                    "ray-traced reflections requested but this GPU does not support hardware ray tracing; falling back (no RT reflections)"
                );
            }
            None
        };

        if rt_accel.is_some() {
            tracing::info!("ray-traced reflections: dynamic transform mode = {rt_dynamic_mode:?}");
        }

        // Fold every instanced-cluster instance into the GPU-driven bindless
        // main pass: each becomes a `GpuObjectData` record appended after the
        // static objects, drawn through the shared cull + indirect path
        // (`build_object_buffer` / `build_draw_args_buffer` re-append these every
        // frame; see `cull_count`). Built once here against the final bindless
        // pool counts (`gpu_textures` / `gpu_fallbacks`, the same counts the
        // static fill uses) via the Metal-local `metal_instance_records`, which
        // addresses the flat pool with Metal's CPU-bias convention (NOT the
        // shared core `instance_object_records`, which is the DX/VK raw-index
        // convention): instances are placed at world load and never move, so the
        // records are static. The draw args carry the cluster base index range;
        // `build_draw_args_buffer` patches per-instance LOD over it each frame
        // for the clusters that declare alternates.
        let n_instances: usize = instanced_clusters.iter().map(|c| c.instances.len()).sum();
        let (instance_records, instance_draw_args) = {
            use crate::gfx::render_types::{GpuDrawArgs, draw_args_flags};
            let records =
                super::cull::metal_instance_records(&instanced_clusters, gpu_textures.len());
            let mut args: Vec<GpuDrawArgs> = Vec::with_capacity(records.len());
            for cluster in &instanced_clusters {
                for _ in &cluster.instances {
                    args.push(GpuDrawArgs {
                        index_count: cluster.index_count as u32,
                        index_offset: cluster.index_offset as u32,
                        base_vertex: 0,
                        flags: draw_args_flags(true, true, true),
                    });
                }
            }
            (records, args)
        };

        let ctx = Self {
            device,
            command_queue,
            swap_pixel_format,
            hdr: super::context::HdrState {
                max_edr,
                encoding: hdr_encoding,
                display_requested: hdr_display_requested,
                pq_requested: hdr_pq_requested,
            },
            last_present_texture: None,
            pipeline_state,
            world_pipelines,
            bindless,
            cull: super::cull::CullState {
                pipeline: cull_pipeline,
                encode_pipeline: cull_encode_pipeline,
                bucket_count: shader_bucket_count,
                icbs: Vec::new(),
                icb_arg_encoder: cull_icb_arg_encoder,
                icb_arg_buffer: None,
                icb_capacity: 0,
                pipeline_phase2: cull_pipeline_phase2,
                icbs_2: Vec::new(),
                icb_2_arg_buffer: None,
                status_buffer: None,
                two_pass_occlusion,
                hiz,
                prev_view_proj: IDENTITY,
                cur_view_proj: IDENTITY,
                hiz_valid: false,
                shadow_pipeline: shadow_cull_pipeline,
                shadow_bindless_pipeline,
                shadow_icb: None,
                shadow_icb_arg_buffer: None,
                shadow_status: None,
                shadow_icb_capacity: 0,
                mirror_slots: Vec::new(),
                mirror_status: None,
                mirror_icb_capacity: 0,
            },
            bindless_tex_arg_encoder,
            probe_cube_arg_encoder,
            bindless_sampler_args,
            depth_state,
            depth_state_read_only,
            vertex_buffer,
            index_buffer,
            draw: super::context::DrawState {
                objects: draw_objects,
                graph_cache: None,
                n_instances,
                // Set by `upload_skinned` (when bindless + static geometry
                // present); 0 keeps the skinned fold inactive until a
                // SkinnedMesh uploads.
                n_skinned: 0,
            },
            instanced: super::context::InstancedState {
                any_lod: crate::gfx::lod::any_cluster_has_lod(&instanced_clusters),
                clusters: instanced_clusters,
                records: instance_records,
                draw_args: instance_draw_args,
            },
            view: super::context::ViewState {
                clear_color,
                scene_fade: 0.0,
                mode: Default::default(),
                far: 1.0,
                matrix: IDENTITY,
                sky_rot: concinnity_core::sky::SkyOrientation::IDENTITY_ROWS,
            },
            geometry_less,
            allocator,
            textures: gpu_textures,
            fallback_textures: gpu_fallbacks,
            light_uniforms,
            local_light_buffer,
            sampler,
            shadow: super::context::ShadowState {
                pipeline_state: shadow_pipeline_state,
                map: shadow_map,
                map_size: effective_shadow_size,
                update: shadow_update,
                distance: shadow_distance,
                cascades: shadow_cascades,
                scheduler: Default::default(),
                render_mask: 0,
                sampler: shadow_sampler,
                uniforms: shadow_uniforms_init,
                light_dir: shadow_light_dir,
            },
            spot_shadow: super::context::SpotShadowState {
                map: spot_shadow_map,
                buffer: spot_shadow_buffer,
                count: spot_shadow_count,
                scheduler: Default::default(),
                render_mask: 0,
            },
            area_light_buffer,
            ltc_matrix_texture,
            ltc_magnitude_texture,
            env_map,
            probe: super::context::ProbeState {
                placements: Vec::new(),
                maps: Vec::new(),
                // Empty until `set_reflection_probes` supplies placements.
                bake_queue: crate::gfx::reflection_probe::ProbeBakeQueue::new(0),
                set: concinnity_core::render::uniforms::ProbeSet::EMPTY,
                rendering: None,
                prefiltering: None,
                prefilter: probe_prefilter,
                retire_pool: super::transient::RetirePool::new(),
                cube_args: None,
            },
            cube_sampler,
            text: super::context::TextState {
                pipeline_state: text_pipeline_state,
                atlas_textures: gpu_text_atlases,
                sampler: text_sampler,
                upload: super::text_upload::TextUploadRing::new(frames_in_flight),
            },
            hdr_targets,
            post_pipeline_state,
            post_sampler,
            bloom_targets,
            bloom_pipelines,
            transient_pool,
            post_process,
            color_lut,
            taa: super::post::TaaState {
                enabled: effective_taa_enabled,
                pipeline_state: taa_pipeline_state,
                targets: taa_targets,
                dst: 0,
                history_valid: false,
                frame: 0,
            },
            prev_view_proj: IDENTITY,
            upscale: super::post::UpscaleState {
                scaler: upscaler,
                scale: upscale_scale,
                jitter: Default::default(),
                reset_pending: std::sync::atomic::AtomicBool::new(true),
            },
            ssao,
            ssr,
            gbuffer,
            ssgi,
            rt: super::raytrace::RtState {
                settings: rt_reflection_settings,
                accel: rt_accel,
                dynamic_mode: rt_dynamic_mode,
                skinned_geometry: rt_skinned_geometry,
                update_failed: false,
                topology_dirty: false,
                pipeline: rt_pipeline,
                pipeline_textured: rt_pipeline_textured,
                skin_pipeline: rt_skin_pipeline,
            },
            lines: super::line::LineState {
                pipeline: None,
                build_failed: false,
            },
            decal: super::decal::DecalState {
                set: decal_set,
                pipeline: decal_pipeline,
                cube_vertex_buffer: decal_cube_vertex_buffer,
                cube_index_buffer: decal_cube_index_buffer,
                sampler: decal_sampler,
            },
            fog: super::fog::FogState {
                settings: fog_settings,
                pipeline: fog_pipeline,
                froxel_pipeline: fog_froxel_pipeline,
                froxel_volume: fog_froxel_volume,
            },
            light_cull: super::light_cull::LightCullState {
                pipeline: light_cull_pipeline,
                cluster_buffer: cluster_light_buffer,
            },
            cluster_params: crate::gfx::render_types::ClusterParams::ZERO,
            particle: super::particle::ParticleState {
                records: particles.into_iter().map(Some).collect(),
                emitter_state: particle_emitter_state.into_iter().map(Some).collect(),
                free_slots: Vec::new(),
                pipelines: particle_pipelines,
                last_elapsed: 0.0,
                frame_index: 0,
                counter_slot: 0,
            },
            auto_exposure: super::auto_exposure::AutoExposureGpu {
                settings: auto_exposure_settings,
                state: auto_exposure_state,
                bias_ev: auto_exposure_bias,
                pipelines: auto_exposure_pipelines,
                histogram: auto_exposure_histogram,
                output: auto_exposure_output,
                last_elapsed: 0.0,
            },
            hot_reload: super::context::HotReloadState {
                enabled: hot_reload,
                reload_pending: shader_reload_pending,
                watcher: shader_watcher,
            },
            world_shader: world_shaders[0].programs.cloned(),
            capture,
            model_history: concinnity_core::render::model_history::ModelHistory::new(),
            skinned: super::resources::skinning::SkinnedState {
                shadow_pipeline_state: None,
                vertex_buffer: None,
                index_buffer: None,
                draw_objects: Vec::new(),
                joint_matrices: Vec::new(),
                skin_pipeline: None,
                deformed: Vec::new(),
                deformed_primed: std::sync::atomic::AtomicBool::new(false),
                morphs: Vec::new(),
                morph_weights: Vec::new(),
            },
            geometry_alloc: super::context::GeometryAllocators {
                mesh_vtx: crate::suballoc::range_alloc::RangeAllocator::new(),
                mesh_idx: crate::suballoc::range_alloc::RangeAllocator::new(),
                chunk_vtx: crate::suballoc::range_alloc::RangeAllocator::new(),
                chunk_idx: crate::suballoc::range_alloc::RangeAllocator::new(),
            },
            window: super::context::WindowState {
                appkit: crate::appkit::AppKitWindow::new(crate::appkit::AppKitWindowParts {
                    window,
                    // The shared layer drives the view through NSView alone; the
                    // MTKView below stays for drawable acquisition.
                    view: objc2::rc::Retained::into_super(mtk_view.clone()),
                    title_bar,
                    pump_events,
                    fullscreen,
                    window_delegate,
                }),
                view: mtk_view,
                // A freshly built context owns its window; a live reload flips
                // the outgoing context's flag off before handing this one the
                // window.
                owns: true,
                was_visible: false,
            },
            diagnostics: super::context::Diagnostics {
                frame_stats: crate::gfx::profile::RenderStats::default(),
                gpu_time_us: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
                render_fault_logged: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
                device_error: std::sync::Arc::new(std::sync::Mutex::new(None)),
                pass_fault_count: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
                pass_timing,
                pass_times_us: std::sync::Arc::new(std::array::from_fn(|_| {
                    std::sync::atomic::AtomicU32::new(0)
                })),
                draw_calls_accum: std::sync::atomic::AtomicU32::new(0),
            },
            frame_pacing: super::frame_pacing::FrameInFlight::new(frames_in_flight),
            frames_in_flight: frames_in_flight.max(1),
            frame_ring_index: 0,
            // The bindless buffers an async reflection-probe bake reads (object,
            // draw-args, bindless-texture-args, and the skinned joint palettes) get
            // one EXTRA ring slot. The frame only ever uses slots
            // `frame_ring_index % frames_in_flight` -- i.e. `[0, frames_in_flight)`
            // -- so slot `frames_in_flight` is reserved for the bake: a slot the
            // frame never overwrites, keeping the bake's CPU-written buffers valid
            // across its asynchronous (no `waitUntilCompleted`) GPU capture. See
            // metal/probe.rs `bake_ring_slot`.
            rings: super::context::FrameRings {
                object: super::transient::TransientRing::new(frames_in_flight.max(1) + 1),
                draw_args: super::transient::TransientRing::new(frames_in_flight.max(1) + 1),
                model_history: super::transient::TransientRing::new(frames_in_flight),
                bindless_tex: super::transient::TransientRing::new(frames_in_flight.max(1) + 1),
                probe_cube: super::transient::TransientRing::new(frames_in_flight.max(1) + 1),
                joint: super::transient::JointRing::new(frames_in_flight.max(1) + 1),
                object_scratch: Vec::new(),
                draw_args_scratch: Vec::new(),
            },
            water: super::context::WaterState {
                pipeline: water_pipeline,
                pipeline_rt: water_pipeline_rt,
                pipeline_rt_textured: water_pipeline_rt_textured,
                surfaces: water_records,
            },
            planar_reflection,
            glass: super::context::GlassState {
                pipeline: glass_pipeline,
                pipeline_rt: glass_pipeline_rt,
                pipeline_rt_textured: glass_pipeline_rt_textured,
                mesh_pipeline_rt: glass_mesh_pipeline_rt,
                mesh_pipeline_rt_textured: glass_mesh_pipeline_rt_textured,
                seethrough_mesh_indices,
                panels: glass_records,
            },
            raymarch: super::context::RaymarchState {
                volumes: raymarch_records,
                cube_vertex_buffer: raymarch_cube_vertex_buffer,
                cube_index_buffer: raymarch_cube_index_buffer,
            },
        };
        let pooled = ctx.allocator.stats();
        tracing::info!(
            "device allocator: {} heap(s), {} KiB reserved for {} KiB of resources",
            pooled.block_count,
            pooled.reserved_bytes / 1024,
            pooled.in_use_bytes / 1024,
        );
        // Tally the raymarch metallib cache (the only shader-cache client on
        // Metal; everything else precompiles at build time).
        crate::shader_cache::report_init();
        crate::runtime_cache::checkpoint();
        Ok(ctx)
    }

    // Re-upload a new world's GPU content onto this live context, reusing the
    // retained device + command queue + window instead of recreating them.
    // Drives the `cn editor` live SAVE: after a structural edit recompiles the
    // blobs, GraphicsSystem transplants the running backend into the rebuilt
    // world and calls this so the edit applies with no window recreation.
    //
    // The GPU is idled so no in-flight command buffer still references the old
    // content, then a fresh context is `build`t on cloned hardware handles (the
    // Metal / AppKit objects are reference counted, so cloning keeps the same
    // window / device alive) and moved into `*self`. Assigning `*self` drops the
    // old content resources; the window survives because the new context holds a
    // clone of it. Only ever called when the swapchain config is unchanged (the
    // caller's `hot_swap_config` gate), so the layer's pixel format /
    // frames-in-flight are guaranteed to still match.
    pub(super) fn apply_world_reload(
        &mut self,
        init: crate::gfx::backend_init::BackendInit<'_>,
    ) -> Result<(), String> {
        debug_assert_main_thread("apply_world_reload");
        self.wait_idle();
        let h = self.window.appkit.handles_for_reuse();
        let reuse = ReuseHandles {
            device: self.device.clone(),
            command_queue: self.command_queue.clone(),
            existing: window::ExistingWindow {
                window: h.window,
                mtk_view: self.window.view.clone(),
                pump_events: h.pump_events,
                fullscreen: h.fullscreen,
                window_delegate: h.window_delegate,
            },
        };
        let mut rebuilt = MtlContext::build(init, Some(reuse))?;
        // Carry over the live window state the fresh build resets but a reload
        // must keep (see `AppKitWindow::adopt_live_state`).
        rebuilt
            .window
            .appkit
            .adopt_live_state(&mut self.window.appkit);
        // Hand the window over: the outgoing context (dropped by the assignment
        // below) must not close the shared window -- `rebuilt` owns it now.
        self.window.owns = false;
        *self = rebuilt;
        Ok(())
    }
}