concinnity-render 0.18.67

GPU-free render preparation for the Concinnity engine
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
//! RenderBackend trait: the union of methods every graphics backend
//! implements, dispatched dynamically by GraphicsSystem so the per-frame
//! step + setup logic lives in one cfg-free copy instead of three.
//!
//! Each concrete backend (MtlContext / DxContext / VkContext) supplies a
//! thin forwarder impl that delegates to the existing inherent methods
//! (see metal/backend.rs, directx/backend.rs, vulkan/backend.rs).
//!
//! Two cross-backend signature variances are handled here:
//!   - `upload_skinned`: Metal uses three shader payloads (vert + frag +
//!     shadow); DX/VK use one (frag). The trait method takes all three;
//!     DX/VK ignore the unused bytes.
//!   - `setup_chunk_streaming`: Metal binds chunk textures per draw and
//!     ignores the (texture_slot, normal_map_slot) args; DX/VK bake them
//!     into a shared descriptor at setup time.
//!
//! `render_stats` has a default no-op impl so a backend with no draw-call /
//! object counters need not override it; all three shipping backends do.

use crate::auto_exposure::AutoExposureSettings;
use crate::backend_init::{BackendInit, ShaderBytes, SwapchainConfig};
use crate::error::{RenderError, RenderResult};
use crate::input::RenderInput;
use crate::keymap::KeyMap;
use crate::mesh_payload::{SkinnedVertex, Vertex};
use crate::profile::RenderStats;
use crate::render_types::{
    LineVertex, MaterialUniforms, PostProcessTunables, SkinnedDrawObject, TextDrawCall,
};
use crate::rt_reflections::RtReflectionSettings;
use crate::scene_flow::SceneControl;
use crate::ssao::SsaoSettings;
use crate::ssgi::SsgiSettings;
use crate::ssr::SsrSettings;
use crate::volumetric_fog::FogSettings;
use alloc::string::String;
use alloc::string::ToString;
use alloc::vec::Vec;

/// Per-frame inputs for [`RenderBackend::draw_frame`]. `world_hidden` is set when
/// an opaque menu backdrop covers the scene: the backend skips every world pass
/// and presents only the overlay (`text_calls`) over a cleared target.
#[derive(Clone, Copy)]
pub struct FrameParams<'a> {
    /// Seconds since the world started, for time-driven effects.
    pub elapsed: f32,
    /// Vertical field of view in radians.
    pub fov_y_radians: f32,
    /// Near clip distance in world units.
    pub near: f32,
    /// Far clip distance in world units.
    pub far: f32,
    /// World-space camera position.
    pub cam_pos: [f32; 3],
    /// Overlay draw calls for this frame.
    pub text_calls: &'a [TextDrawCall],
    /// Expanded line ribbons (`lines::build_vertices`) for this frame's camera,
    /// drawn depth-tested into the scene after the world passes. Empty on any
    /// frame that submits no lines, which also drops the pass from the graph.
    pub lines: &'a [LineVertex],
    /// `true` when an opaque menu backdrop covers the scene.
    pub world_hidden: bool,
    /// Viewport view mode + show flags for the frame (`ViewOverrides` when the
    /// editor publishes one, defaults otherwise). Backends run their seeded
    /// graph inputs through `render_graph::apply_view` and steer the composite
    /// by the mode.
    pub view_mode: concinnity_core::gfx::view_modes::ViewMode,
    /// Feature passes to run this frame.
    pub show: concinnity_core::gfx::view_modes::ShowFlags,
}

/// One streamed chunk's geometry plus placement, supplied to
/// [`RenderBackend::add_chunk_mesh`]. `frame` reclaims retired deferred frees
/// before the chunk is placed in the streaming headroom.
#[derive(Clone, Copy)]
pub struct ChunkMesh<'a> {
    /// Chunk vertices.
    pub verts: &'a [Vertex],
    /// Chunk indices, mesh-relative.
    pub idxs: &'a [u16],
    /// Column-major placement matrix.
    pub model: [[f32; 4]; 4],
    /// Index into the shared texture pool for the albedo map.
    pub texture_slot: usize,
    /// Index into the shared texture pool for the normal map.
    pub normal_map_slot: usize,
    /// Per-chunk material scalars.
    pub material: MaterialUniforms,
    /// Current frame number, used to reclaim retired deferred frees.
    pub frame: u64,
}

/// One draw slot's fresh geometry, supplied to
/// [`RenderBackend::rebuild_static_geometry`] when an asset hot-reload
/// changed its vertex / index count and the slot can no longer hold the new
/// data in place. The backend rebuilds the entire shared vertex / index
/// buffer; draws not named here keep their current geometry, copied byte-for-
/// byte from the live buffers. `indices` are mesh-relative (0-based); the
/// backend rebases them onto whatever new vertex region the draw lands in.
pub struct DrawGeometryUpdate {
    /// The draw slot whose geometry is replaced.
    pub draw_idx: usize,
    /// Replacement vertices.
    pub vertices: Vec<Vertex>,
    /// Replacement indices, mesh-relative.
    pub indices: Vec<u16>,
    /// One slice per additional LOD, ordered mip 0 → mip N-1. Each is
    /// `(switch_distance, mesh-relative indices)`. Empty for meshes
    /// declared `lod_levels <= 1`.
    pub lod_alternates: Vec<(f32, Vec<u16>)>,
}

/// One skinned draw slot's fresh geometry, supplied to
/// [`RenderBackend::rebuild_skinned_geometry`] when an asset hot-reload
/// changed its vertex / index count and the slot can no longer hold the new
/// data in its existing region of the shared skinned vertex / index buffers.
/// The backend rebuilds both shared buffers; slots not named here keep their
/// current geometry, copied byte-for-byte from the live buffers and re-based
/// onto whatever new vertex region they land in. `indices` are mesh-relative
/// (0-based); the backend rebases them onto the new vertex region.
pub struct SkinnedDrawGeometryUpdate {
    /// The skinned slot whose geometry is replaced.
    pub skinned_index: usize,
    /// Replacement vertices.
    pub vertices: Vec<SkinnedVertex>,
    /// Replacement indices, mesh-relative.
    pub indices: Vec<u16>,
}

/// The post-rebuild layout for one skinned slot, returned by
/// [`RenderBackend::rebuild_skinned_geometry`] so the asset hot-reload
/// helper can refresh its `SkinnedMeshSourceEntry`s'
/// `vertex_base` / `vertex_count` / `index_count` to point at the new
/// regions. Returned for every slot (both the ones whose geometry was
/// replaced and the ones whose geometry was carried over) because the
/// rebuild may have shifted every slot's `vertex_base`.
/// Constructed only by the `cn debug` binary's skinned-rebuild reload pass;
/// reads as dead under `cargo check --lib`.
pub struct SkinnedSlotLayout {
    /// The skinned slot this layout describes.
    pub skinned_index: usize,
    /// First vertex of the slot's region in the shared skinned buffer.
    pub vertex_base: u32,
    /// Vertices in the slot's region.
    pub vertex_count: usize,
    /// Indices in the slot's region.
    pub index_count: usize,
}

/// The resolved per-feature quality settings for [`RenderBackend::apply_quality_settings`].
/// `GraphicsSystem` derives these from its stored `PostProcessConfig` (with the
/// user's persisted toggle overrides applied) whenever a Quality-group toggle
/// changes, so the backend receives ready-to-use settings rather than re-deriving
/// from the asset. Each `Option` mirrors the init-time gate: `None` means the
/// feature is off and its passes / resources should be torn down; `Some` means it
/// is on and its resources should exist. A backend without a live-rebuild path
/// ignores this (the choice still persists and applies at the next launch).
pub struct QualitySettings {
    /// Temporal anti-aliasing on/off (the `Taa` anti-aliasing mode). The backend
    /// additionally suppresses TAA while temporal upscaling is active (the scaler
    /// does its own accumulation). The other anti-aliasing modes are the composite
    /// FXAA edge filter, which rides `PostProcessTunables.fxaa` (pushed via
    /// `update_post_process`), not this pass-rebuild payload.
    pub taa: bool,
    /// Screen-space ambient occlusion, or `None` when off.
    pub ssao: Option<SsaoSettings>,
    /// Screen-space reflections, or `None` when off.
    pub ssr: Option<SsrSettings>,
    /// Hardware ray-traced reflections. The backend further gates this on GPU
    /// ray-tracing support, falling back to leaving it off when unsupported.
    pub rt_reflections: Option<RtReflectionSettings>,
    /// Screen-space global illumination, or `None` when off.
    pub ssgi: Option<SsgiSettings>,
    /// Per-axis divisor for the roughness-aware reflection blur target (the
    /// reduced-resolution first pass of the SSR / RT reflection composite),
    /// resolved from `PostProcessConfig.reflection_blur_resolution`. Every backend
    /// sizes its blur target at render / this on a live reflection rebuild.
    pub reflection_blur_scale: u32,
    /// Auto-exposure, or `None` when off.
    pub auto_exposure: Option<AutoExposureSettings>,
    /// The authored exposure bias (stops) auto-exposure applies on top of its
    /// adapted value; carried so a live auto-exposure enable matches init.
    pub auto_exposure_bias_ev: f32,
}

/// GPU/device capability flags, queried from the backend once it is built.
/// Surfaced so the settings menu can gray out (and make inert) toggles the
/// device cannot honor -- e.g. ray-traced reflections on a GPU without hardware
/// ray tracing. Mirrors an RHI-style capability set: a handful of bools held in
/// memory and re-queried each launch, never persisted, so it is always correct
/// for the current device + driver.
#[derive(Clone, Copy, Debug)]
pub struct DeviceCapabilities {
    /// Hardware ray tracing for the RT-reflections pass: DXR 1.1 on DirectX, the
    /// ray-query device extensions on Vulkan (and not under XeSS), and
    /// `MTLDevice::supportsRaytracing` on Metal.
    pub ray_tracing: bool,
    /// Whether the upscaler implementation is a choice (FSR3 / DLSS / XeSS)
    /// rather than fixed. DirectX and Vulkan offer the selection; Metal always
    /// upscales through MetalFX, so the row has nothing to pick.
    pub selectable_upscaler: bool,
    /// Whether a retired build-time draw slot may be recycled by a runtime
    /// clone. Metal's per-frame RT topology refresh re-admits recycled
    /// build-time slots; DirectX / Vulkan key their cull BVH + RT tables to
    /// fixed build-time indices and cannot refit, so only the runtime-append
    /// region recycles there. Read by the engine's draw-slot allocator.
    pub reuses_build_slots: bool,
    /// Whether a built draw slot's material and cull distance may be rewritten
    /// in place ([`RenderBackend::set_draw_material`] /
    /// [`RenderBackend::set_draw_cull_distance`]). Metal rebuilds its per-object
    /// buffer from the draw list every frame, so a rewritten slot draws with the
    /// new material next frame; DirectX / Vulkan bake per-object material state
    /// at build time and would keep drawing the old one. Read by the editor's
    /// live draw seam, which sends the edit to a world rebuild instead.
    pub rewrites_draws: bool,
}

impl DeviceCapabilities {
    /// Every capability present. The trait default, so a backend that does not
    /// report capabilities never wrongly disables a toggle (it keeps the prior
    /// behavior: the feature no-ops with a warning on an incapable device).
    pub const ALL: Self = Self {
        ray_tracing: true,
        selectable_upscaler: true,
        reuses_build_slots: true,
        rewrites_draws: true,
    };
}

impl Default for DeviceCapabilities {
    fn default() -> Self {
        Self::ALL
    }
}

/// Coarse GPU vendor class, derived per backend from the adapter's reported
/// vendor id (DirectX / Vulkan) or unified-memory / Apple-family signals (Metal).
/// Used only to pick default quality and to gate vendor-specific options (e.g.
/// which upscalers to offer); never persisted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GpuVendor {
    /// Apple silicon.
    Apple,
    /// NVIDIA.
    Nvidia,
    /// AMD.
    Amd,
    /// Intel.
    Intel,
    /// A vendor the probe does not recognise.
    Other,
}

/// Coarse performance class for default-quality selection, ordered low -> high so
/// callers can compare with `>=`. Each backend maps its native signals (memory
/// budget, discrete / integrated, Apple GPU family) onto this via `classify_tier`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum GpuTier {
    /// Unknown hardware: the conservative default, never the top preset. Sorts
    /// lowest so a comparison-based resolver treats it as the floor.
    Unknown,
    /// Integrated / low-power GPU: the lowest quality tier.
    Integrated,
    /// Older or small discrete GPU, or an Apple base M-series: entry quality.
    EntryDiscrete,
    /// Mainstream discrete GPU, or an Apple Pro: mid quality.
    MidDiscrete,
    /// Enthusiast discrete GPU, or an Apple Max / Ultra: high quality.
    HighDiscrete,
}

/// A coarse, Copy snapshot of the active GPU's class, queried from the backend
/// once it is built (mirrors `DeviceCapabilities`). Read at init to choose
/// sensible default graphics quality; never persisted, re-queried each launch so
/// it is always correct for the current device + driver. The GPU *name* is
/// deliberately omitted (it is not `Copy`); a backend exposes the name separately
/// when a UI needs it.
#[derive(Clone, Copy, Debug)]
pub struct GpuProfile {
    /// The GPU's vendor.
    pub vendor: GpuVendor,
    /// The performance tier the probe placed the GPU in.
    pub tier: GpuTier,
    /// Dedicated VRAM on a discrete GPU, or the recommended working-set on a
    /// unified-memory GPU. 0 when the backend / driver cannot report it.
    pub memory_budget_bytes: u64,
    /// Whether the GPU shares memory with the host.
    pub unified_memory: bool,
    /// Whether the GPU is a discrete card.
    pub discrete: bool,
}

impl GpuProfile {
    /// Conservative fallback for a backend that does not report a profile:
    /// unknown hardware picks the cautious baseline, never a high preset. The
    /// opposite default from `DeviceCapabilities::ALL` -- a feature gate fails
    /// open (assume capable, no-op with a warning if not), but quality
    /// auto-config fails safe (assume modest, never overdrive a weak GPU).
    pub const UNKNOWN: Self = Self {
        vendor: GpuVendor::Other,
        tier: GpuTier::Unknown,
        memory_budget_bytes: 0,
        unified_memory: false,
        discrete: false,
    };
}

impl Default for GpuProfile {
    fn default() -> Self {
        Self::UNKNOWN
    }
}

/// The cheap signals every backend can gather about its GPU, mapped to a coarse
/// `GpuTier` by one shared rule so the three backends classify consistently and
/// the mapping is unit-testable without a GPU. The backends differ in what they
/// can report (Apple exposes a GPU family; DirectX / Vulkan expose a VRAM figure
/// and a discrete / integrated flag), so this carries the union and the rule
/// uses whichever signals are present.
pub struct GpuClassInput {
    /// The GPU's vendor.
    pub vendor: GpuVendor,
    /// Device memory the driver reports as budgeted for this process.
    pub memory_budget_bytes: u64,
    /// Whether the GPU is a discrete card.
    pub discrete: bool,
    /// Apple GPU family generation rank (7 = M1 .. 10 = M4), or 0 for a non-Apple
    /// GPU. Apple silicon classifies by generation; everything else by VRAM.
    pub apple_family: u8,
}

/// The Apple GPU family generation rank a device name implies, or 0 when the name
/// is not an Apple silicon GPU. Metal reads the rank straight off the device
/// (`MTLDevice::supportsFamily`); Vulkan has no equivalent query, so a MoltenVK
/// build recovers it from the reported device name ("Apple M2 Max"). Without it
/// Apple silicon falls through `classify_tier`'s integrated branch and the two
/// backends disagree on the same GPU. `M<n>` maps to `n + 6`, matching Metal's
/// `MTLGPUFamily::Apple7` = M1.
pub fn apple_family_from_device_name(name: &str) -> u8 {
    let Some(rest) = name.strip_prefix("Apple M") else {
        return 0;
    };
    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
    match digits.parse::<u8>() {
        Ok(n) if n >= 1 => n.saturating_add(6),
        _ => 0,
    }
}

/// Map the gathered GPU signals to a coarse performance tier. Apple silicon is
/// classified by GPU family generation (family alone cannot separate base from
/// Pro / Max / Ultra within a generation -- a working-set refinement can split
/// them later); a non-Apple integrated / low-power GPU is the lowest tier; a
/// discrete GPU is bucketed by dedicated VRAM. An unreporting device (no memory,
/// not discrete) stays `Unknown` so the resolver uses the conservative baseline.
pub fn classify_tier(input: &GpuClassInput) -> GpuTier {
    const GB: u64 = 1 << 30;
    // Apple silicon: classify by GPU family generation.
    if input.vendor == GpuVendor::Apple && input.apple_family >= 7 {
        return match input.apple_family {
            7 => GpuTier::EntryDiscrete, // M1 class
            8 => GpuTier::MidDiscrete,   // M2 class
            _ => GpuTier::HighDiscrete,  // M3 / M4 and newer
        };
    }
    // Any non-Apple integrated / low-power GPU is the lowest tier (Apple silicon
    // is unified too, but it returned above via its family branch).
    if !input.discrete {
        return GpuTier::Integrated;
    }
    // Discrete GPU: bucket by dedicated VRAM.
    match input.memory_budget_bytes {
        0 => GpuTier::Unknown,
        b if b >= 12 * GB => GpuTier::HighDiscrete,
        b if b >= 6 * GB => GpuTier::MidDiscrete,
        _ => GpuTier::EntryDiscrete,
    }
}

/// The set of operations GraphicsSystem performs on a graphics backend.
/// Implementations are thin forwarders to the inherent methods on
/// MtlContext / DxContext / VkContext.
///
/// The asset hot-reload mutators below (`update_color_lut`,
/// `rebuild_*_geometry`, `clone_static_draw_object`, etc.) are provided
/// methods that default to a no-op, so a backend implements only the reload
/// paths it actually supports.
pub trait RenderBackend: SceneControl + Send {
    /// Window / input lifecycle.
    fn window_closed(&mut self) -> bool;
    /// Confine the cursor to the window.
    fn capture_cursor(&mut self);
    /// Take the input sampled since the last call.
    fn take_input(&mut self) -> RenderInput;
    /// Block until the GPU has drained every submitted frame.
    fn wait_idle(&self);

    /// Per-frame drive. See [`FrameParams`] for the inputs.
    fn draw_frame(&mut self, params: FrameParams<'_>) -> RenderResult<()>;
    /// Push the camera's view matrix, column-major.
    fn update_view(&mut self, matrix: [[f32; 4]; 4]);

    /// Push this frame's changed model matrices, one `(draw slot, matrix)`
    /// entry per moved draw object, applied in order. Batched so the trait is
    /// crossed once per frame rather than once per entity; the caller sends
    /// only slots whose matrix actually changed. An out-of-range slot is
    /// ignored.
    fn update_models(&mut self, updates: &[(u32, [[f32; 4]; 4])]);

    /// Retire a draw object: hide it from every pass (main, shadow, velocity)
    /// and exclude it from the ray-tracing acceleration structure, so a
    /// despawned entity's slot leaves no ghost. The slot's geometry buffers are
    /// untouched; the engine's draw-slot allocator returns the index to its
    /// free list so a later `clone_static_draw_object` can recycle it. A no-op
    /// if the index is out of range.
    fn retire_draw_object(&mut self, draw_idx: usize);

    /// Skinning. `vert_bytes` and `shadow_bytes` are Metal-only payloads;
    /// DX/VK ignore them.
    fn upload_skinned(
        &mut self,
        vertices: &[SkinnedVertex],
        indices: &[u32],
        draw_objects: Vec<SkinnedDrawObject>,
        vert_bytes: &[u8],
        frag_bytes: &[u8],
        shadow_bytes: &[u8],
    ) -> RenderResult<()>;
    /// Push one skinned slot's joint matrices for this frame.
    fn update_skinned_pose(&mut self, skinned_index: usize, matrices: &[[[f32; 4]; 4]]);

    /// Attach morph-target data to the skinned draw objects, called once after
    /// `upload_skinned`: `morphs[i]` belongs to draw object `i` (instance
    /// copies share their template's data via the `Arc`). Default no-op for a
    /// backend without a morph deformation path.
    fn upload_skinned_morphs(
        &mut self,
        _morphs: Vec<Option<alloc::sync::Arc<crate::mesh_payload::PayloadMorphs>>>,
    ) {
    }

    /// Push a skinned object's current morph-target weights, sampled by the
    /// animation system each frame. A no-op when the index is out of range or
    /// the object carries no morph targets.
    fn update_morph_weights(&mut self, _skinned_index: usize, _weights: &[f32]) {}

    // Runtime skinned spawn (pre-reserved instance pool): a backend pre-reserves
    // hidden bind-pose copies at load (`SkinnedMesh.max_instances`) and reveals
    // one per skinned SpawnRequest. The default no-op implementations are a
    // fallback for a backend that has not wired runtime skinned spawn, where a
    // skinned SpawnRequest finds nothing to claim and is dropped.

    /// Reveal the pre-reserved skinned instance at `instance_index` (a hidden
    /// bind-pose copy expanded at load): show it at `model` and reset its
    /// palette to bind so it does not flash a previous occupant's pose. Which
    /// instance to use is decided by the engine's instance pool; the backend
    /// only applies it. A no-op if the index is out of range.
    fn reveal_skinned_instance(&mut self, _instance_index: usize, _model: [[f32; 4]; 4]) {}

    /// Hide a live skinned instance. The engine's instance pool returns the
    /// slot for reuse; the backend only hides it. A no-op if the index is out
    /// of range.
    fn retire_skinned_draw_object(&mut self, _skinned_index: usize) {}

    /// Push this frame's changed skinned model-to-world matrices, one
    /// `(skinned index, matrix)` entry per moved instance, applied in order
    /// (a skinned object animates in place unless something moves it). Cheap:
    /// the per-frame cull rebuild reads the object's model directly, so this
    /// just writes the fields. Out-of-range indices are ignored; default
    /// no-op for a backend without movable skinned instances.
    fn update_skinned_models(&mut self, _updates: &[(u32, [[f32; 4]; 4])]) {}

    /// Texture streaming. Albedo and normal maps share one handle-indexed pool,
    /// so every streamed texture (whatever its role) flows through these. The
    /// image carries its GPU format and mip chain: RGBA8 regenerates mips on
    /// upload, block-compressed formats upload their chain verbatim.
    fn evict_texture_slot(&mut self, slot: usize) -> Result<(), String>;
    /// Replace a texture slot's image after a streaming upload.
    fn update_texture_slot(
        &mut self,
        slot: usize,
        image: &crate::build::texture::TextureImage,
    ) -> RenderResult<()>;

    /// Mesh streaming.
    fn evict_mesh(&mut self, draw_idx: usize, retire_frame: u64) -> Result<(), String>;
    /// Upload a streamed mesh's geometry into a draw slot.
    fn upload_mesh(
        &mut self,
        draw_idx: usize,
        verts: &[Vertex],
        idxs: &[u16],
        frame: u64,
    ) -> RenderResult<()>;

    /// Seed the streamed-mesh sub-allocators with one reserved headroom block
    /// (byte ranges in the shared vertex / index buffers) instead of the
    /// per-mesh build-time regions. Used by the shrinkable-seed path: the
    /// streamed geometry is no longer baked into the buffers at build time, so
    /// the renderer hands the allocators one contiguous block sized to the
    /// cap-many resident meshes rather than the whole streamed set. Implemented
    /// on Metal + DirectX + Vulkan. Default no-op: a backend without the
    /// shrinkable seed keeps freeing each mesh's build-time region in
    /// `setup_mesh_streaming`.
    fn seed_mesh_streaming(
        &mut self,
        vtx_offset: u64,
        vtx_bytes: u64,
        idx_offset: u64,
        idx_bytes: u64,
    ) {
        let _ = (vtx_offset, vtx_bytes, idx_offset, idx_bytes);
    }

    /// Voxel-world chunk streaming. `texture_slot` and `normal_map_slot`
    /// are ignored by Metal (it binds chunk textures per draw).
    fn setup_chunk_streaming(
        &mut self,
        chunk_vtx_bytes: usize,
        chunk_idx_bytes: usize,
        texture_slot: usize,
        normal_map_slot: usize,
    ) -> RenderResult<()>;
    /// The destination draw slot comes from the engine's allocator, like
    /// `clone_static_draw_object`; the freed slot is likewise returned to it by
    /// the caller of `remove_chunk_mesh`.
    fn add_chunk_mesh(
        &mut self,
        mesh: ChunkMesh<'_>,
        dst: crate::draw_slot::SlotAlloc,
    ) -> RenderResult<()>;
    /// Free a streamed chunk's geometry, retiring it after `retire_frame`.
    fn remove_chunk_mesh(&mut self, draw_idx: usize, retire_frame: u64) -> Result<(), String>;
    /// Move a streamed chunk by replacing its placement matrix.
    fn set_chunk_model(&mut self, draw_idx: usize, model: [[f32; 4]; 4]) -> Result<(), String>;

    /// Device capability flags, queried from the GPU once the backend is built.
    /// Read by GraphicsSystem to gray out + disable settings rows the device
    /// cannot honor. Default: all capable, so a backend that does not report
    /// capabilities keeps every toggle live (the feature then no-ops with a
    /// warning on an incapable device, as before).
    fn capabilities(&self) -> DeviceCapabilities {
        DeviceCapabilities::ALL
    }

    /// Coarse GPU performance profile, queried once the backend is built. Read at
    /// init to pick default graphics quality on first launch. Default: `UNKNOWN`
    /// (the conservative tier), so a backend that does not report a profile never
    /// makes the resolver auto-select a high preset.
    fn gpu_profile(&self) -> GpuProfile {
        GpuProfile::UNKNOWN
    }

    /// The overlay coordinate space: the window's content size in logical,
    /// DPI-independent units (points on macOS, client pixels on Windows, window
    /// coordinates on Linux). Every backend reports the cursor in these same
    /// units, so UI hit-testing, text layout, and the overlay shader's divide to
    /// NDC all share one space regardless of the backing scale. A backend
    /// converts to attachment pixels only where a pixel rect is unavoidable,
    /// through `fullscreen::clip_rect_to_scissor`.
    ///
    /// Default `(0.0, 0.0)` for a headless backend with no window.
    fn logical_size(&self) -> (f32, f32) {
        (0.0, 0.0)
    }
    /// Per-frame draw-call / object counters. Default no-op so a backend that
    /// tracks none still satisfies the trait; all three shipping backends
    /// override it.
    fn render_stats(&self) -> RenderStats {
        RenderStats::default()
    }

    /// Show or hide the OS cursor for an in-engine UI cursor (e.g. a MainMenu),
    /// independent of camera capture. Edge-triggered by the backend, so calling
    /// it every frame with the same value is cheap. Default no-op: a backend
    /// without a free-mode cursor hide leaves the system cursor visible (DX /
    /// Vulkan today).
    fn set_ui_cursor_hidden(&mut self, hidden: bool) {
        let _ = hidden;
    }

    /// Whether the real cursor has left the window, so an in-engine UI cursor
    /// should stop drawing (windowed / borderless). The backend confines the
    /// cursor to the active screen while in fullscreen, so it reports `false`
    /// there. Default `false` (inside): backends without window-bounds tracking
    /// (DX / Vulkan today) always draw the in-engine cursor.
    fn cursor_outside_window(&self) -> bool {
        false
    }

    /// Tell the backend a togglable menu (a Screen toggled by an Escape KeyBinding)
    /// coexists with a captured camera. In this mode Escape routes to the ECS
    /// (so the menu shows/hides) instead of releasing the cursor inline, and a
    /// click never recaptures the cursor (it fires a UI action). Set once at
    /// setup. Default no-op: backends without dynamic capture (DX / Vulkan today)
    /// keep the static behavior.
    fn set_menu_mode(&mut self, on: bool) {
        let _ = on;
    }

    /// Drive cursor capture from the menu state each frame: capture for camera
    /// control, release while a menu is open. Edge-triggered by the backend.
    /// Default no-op (DX / Vulkan): they keep their startup capture decision.
    fn set_camera_capture(&mut self, capture: bool) {
        let _ = capture;
    }

    /// Supply the reflection-probe placements (from declared `ReflectionProbe`
    /// assets, or empty to auto-seed from the scene bounds). The backend bakes a
    /// cube per placement and samples the nearest for the specular reflection.
    /// Pushed once after construction. Default no-op: backends without probe
    /// support (DX / Vulkan today) keep the sky reflection.
    fn set_reflection_probes(&mut self, probes: &[crate::reflection_probe::ProbePlacement]) {
        let _ = probes;
    }

    /// Turn display sync (vsync) on or off at runtime, applied to presentation.
    /// Edge-triggered by the backend, so calling it with the unchanged value is
    /// cheap. Default no-op: a backend that only honors vsync at init ignores
    /// runtime changes.
    fn set_vsync(&mut self, on: bool) {
        let _ = on;
    }

    /// Switch the window between windowed / borderless / fullscreen at runtime.
    /// The change flows through the backend's normal resize path (no GPU rebuild
    /// beyond the resize it triggers). Default no-op for backends without a
    /// window (embedded / preview) or that don't yet implement it.
    fn set_window_mode(&mut self, mode: crate::components::WindowMode) {
        let _ = mode;
    }

    /// Resize the window's content area at runtime (meaningful in windowed mode).
    /// Drives the same resize path as a user-dragged resize. Default no-op for
    /// backends without a window or that don't yet implement it.
    fn set_window_size(&mut self, width: u32, height: u32) {
        let _ = (width, height);
    }

    /// The display modes (pixel resolution + refresh rate) the display this
    /// backend renders to supports, unshaped (the caller dedups + sorts).
    /// Default empty: a backend that cannot enumerate (or has no window) makes
    /// the Resolution row fall back to the static preset list.
    fn display_modes(&self) -> Vec<crate::display_mode::DisplayMode> {
        Vec::new()
    }

    /// The mode the display is currently running, if the backend can read it.
    /// Shown by the Resolution row when the user has never chosen a mode (the
    /// display keeps its desktop mode until one is chosen). Default `None`.
    fn current_display_mode(&self) -> Option<crate::display_mode::DisplayMode> {
        None
    }

    /// Select the display mode to hold while the window is in fullscreen. The
    /// backend applies it whenever the window is (or becomes) fullscreen and
    /// restores the display's original mode when the window leaves fullscreen
    /// or shuts down; outside fullscreen the choice is only remembered. Default
    /// no-op: a backend without mode switching leaves the display alone.
    fn set_display_mode(&mut self, mode: crate::display_mode::DisplayMode) {
        let _ = mode;
    }

    /// Replace the live post-process tunables (bloom / exposure / vignette /
    /// LUT blend / FXAA). These are pushed to the bloom + composite shaders each
    /// frame, so a change takes effect on the next draw with no allocation or
    /// pipeline rebuild. Only the authored half travels here: the composite's
    /// display-output flags belong to the display the backend negotiated with
    /// at init, so a push cannot disturb them. Default no-op: a backend that
    /// only reads the tunables at init ignores runtime changes.
    fn update_post_process(&mut self, tunables: PostProcessTunables) {
        let _ = tunables;
    }

    /// Set the live ambient (IBL) light scale. Unlike the post-process params
    /// above, `ambient_intensity` lives in the shared `LightUniforms` (uploaded
    /// each frame by the main lighting pass), so it takes its own setter rather
    /// than `update_post_process`. Default no-op: only Metal mutates it live
    /// today; DirectX / Vulkan keep the init-time value (they read it at init).
    fn set_ambient_intensity(&mut self, value: f32) {
        let _ = value;
    }

    /// Replace the live directional-light set (the sun). Unlike the local
    /// lights, which ride a per-scene storage buffer sized once at init, the
    /// directional slots are a fixed-size array in the shared `LightUniforms`,
    /// so a new set is written in place: the backend re-packs the array and
    /// re-caches whatever it derived from the first light at init (the cascade
    /// shadow direction, the fog sun). Default no-op: a backend that only reads
    /// the lights at init keeps the init-time sun.
    fn update_directional_lights(&mut self, lights: &[crate::components::DirectionalLight]) {
        let _ = lights;
    }

    /// Push the gameplay movement key map. The backend resolves each canonical
    /// `InputKey` to its native key code and decodes physical key events through the
    /// map (instead of hardcoded keys), so a settings-menu rebind takes effect on
    /// the next key event. Pushed once after the backend is built and again on
    /// each rebind. Default no-op: a backend without keymap decode keeps its
    /// built-in defaults.
    fn set_keymap(&mut self, keymap: &KeyMap) {
        let _ = keymap;
    }

    /// Apply a change to the quality-feature toggles (TAA / SSAO / SSR / RT
    /// reflections / SSGI / auto-exposure) live. Unlike the post-process params,
    /// these gate render passes whose GPU resources (pipelines, render targets,
    /// ray-tracing acceleration structures) are built once at init, so applying a
    /// change rebuilds the affected resources in place rather than flipping a
    /// uniform. Default no-op: a backend that only reads these at init ignores
    /// runtime changes (DirectX / Vulkan today), so the choice persists and takes
    /// effect at the next launch there.
    fn apply_quality_settings(&mut self, settings: QualitySettings) {
        let _ = settings;
    }

    /// Set the shadow cascade re-render cadence live. The cascade scheduler reads
    /// the policy at the start of each shadow pass, so a change takes effect on the
    /// next draw with no pipeline rebuild or allocation (unlike the shadow map
    /// resolution, which is sized once at init). Default no-op: a backend that only
    /// reads the cadence at init keeps the init-time value (DirectX / Vulkan
    /// today), so the choice persists and takes effect at the next launch there.
    fn set_shadow_update(&mut self, update: crate::components::ShadowUpdate) {
        let _ = update;
    }

    /// Set the shadow distance (world units the cascades cover, capped at the
    /// camera far plane) live. The per-frame cascade-split computation reads it
    /// each draw, so a change takes effect on the next frame with no allocation or
    /// rebuild (it sizes no GPU resource, unlike the shadow map resolution).
    /// Default no-op: a backend that only reads the distance at init keeps the
    /// init-time value (DirectX / Vulkan today), so the choice persists and takes
    /// effect at the next launch there.
    fn set_shadow_distance(&mut self, distance: u32) {
        let _ = distance;
    }

    /// Set the live shadow cascade count (1..=4). The cascade-split math + the
    /// re-render schedule read it each frame and only the first `count` cascades
    /// are projected, rendered, and sampled (the array capacity stays 4), so a
    /// change takes effect on the next frame with no resize or rebuild. Default
    /// no-op: a backend that only reads the count at init keeps the init-time
    /// value (DirectX / Vulkan today), so the choice persists and takes effect at
    /// the next launch there.
    fn set_shadow_cascades(&mut self, count: u32) {
        let _ = count;
    }

    /// Update the live scalar sub-tunables of the SSAO / SSR / SSGI / auto-exposure
    /// passes (radius, intensity, distance, EV bounds, adaptation speed). Unlike
    /// `apply_quality_settings`, this rebuilds nothing: each backend re-reads these
    /// values from its stored `*Settings` structs into a per-frame uniform every
    /// draw, so mutating them takes effect on the next frame with no pipeline /
    /// target rebuild and no TAA-history reset. Only the fields of a feature that is
    /// currently on are honoured (its settings are present); a value for an off
    /// feature is ignored here and applies when the feature next turns on. The
    /// structural sub-knobs (gather resolution, ray / step counts) are NOT live and
    /// still ride `apply_quality_settings`. Default no-op: a backend that reads
    /// these only at init keeps the init-time values (DirectX / Vulkan today), so
    /// the choice persists and takes effect at the next launch there.
    fn update_quality_params(&mut self, settings: QualitySettings) {
        let _ = settings;
    }

    /// Shared atomic flag the backend polls at frame start to trigger a
    /// shader rebuild. `Some` only under `cn debug` on backends that ship
    /// hot-reload (Metal today); `None` on production runs and on backends
    /// that have not implemented hot-reload yet. The debug server reads this
    /// to forward `reload-shaders` commands; the filesystem watcher writes
    /// it directly. Default: `None`.
    fn shader_reload_flag(&self) -> Option<alloc::sync::Arc<core::sync::atomic::AtomicBool>> {
        None
    }

    /// Replace the live colour-grading LUT with a fresh `size³` RGBA8 payload.
    /// Driven by asset hot-reload (`cn debug` only). Default no-op: backends
    /// that have not implemented the swap leave the LUT bound at whatever
    /// payload was uploaded at init.
    fn update_color_lut(&mut self, size: u32, data: &[u8]) -> Result<(), String> {
        let _ = (size, data);
        Ok(())
    }

    /// `(vertex_count, index_count)` for the static draw at `draw_idx`, or
    /// `None` when the index is out of range / the backend does not expose
    /// the field. Used by asset hot-reload to detect size-changing
    /// reloads before attempting [`Self::update_mesh_geometry`], which
    /// rejects size mismatches. Default returns `None`; backends that
    /// implement the rebuild path also override this.
    fn draw_geometry_size(&self, draw_idx: usize) -> Option<(usize, usize)> {
        let _ = draw_idx;
        None
    }

    /// Per-LOD-alternate index counts for the static draw at `draw_idx`,
    /// ordered from LOD1 upward (LOD0 is reported by
    /// [`Self::draw_geometry_size`]). Returns `None` when the index is out of
    /// range or the backend does not expose its LOD layout. Used by asset
    /// hot-reload alongside [`Self::draw_geometry_size`] to detect
    /// size-changing reloads: a `.glb` that re-exports with a different LOD
    /// breakdown queues the entry for [`Self::rebuild_static_geometry`]
    /// instead of [`Self::update_mesh_geometry`]'s in-place write.
    fn draw_lod_index_counts(&self, draw_idx: usize) -> Option<Vec<usize>> {
        let _ = draw_idx;
        None
    }

    /// Rebuild the shared static-mesh vertex + index buffers, replacing the
    /// geometry of each `DrawGeometryUpdate.draw_idx` with the new
    /// vertices / indices / LOD alternates. Draws not named in `changes`
    /// keep their current geometry, copied byte-for-byte from the live
    /// buffers. The slot's `vertex_count`, `index_count`, and
    /// `lod_alternates` index offsets are rewritten as the new buffers are
    /// laid out. Driven by asset hot-reload (`cn debug` only) when a
    /// size-changing `.glb` re-export means the existing
    /// [`Self::update_mesh_geometry`] in-place write no longer fits.
    /// `wait_idle` first; the rebuild swaps the GPU buffers wholesale.
    /// Default no-op: backends that have not implemented the rebuild
    /// return `Ok(())` and the size-changing reload is logged + skipped at
    /// the caller (the existing in-place path already errored on size
    /// mismatch).
    fn rebuild_static_geometry(&mut self, changes: Vec<DrawGeometryUpdate>) -> RenderResult<()> {
        let _ = changes;
        Ok(())
    }

    /// Replace a `SkinnedMesh` draw slot's vertex + index data in place.
    /// Driven by asset hot-reload (`cn debug` only). Reuses the slot's
    /// existing vertex region + index region in the shared skinned vertex /
    /// index buffers (created once by [`Self::upload_skinned`]), so the new
    /// geometry must match the slot's init-time vertex count + index count
    /// and the new skeleton must keep the same joint count; pipelines stay
    /// untouched, only the bytes change. `vertex_base` is the init-time
    /// vertex offset (in vertex units) into the shared buffer; indices are
    /// rebased onto it before writing. Default no-op.
    fn update_skinned_mesh_geometry(
        &mut self,
        skinned_index: usize,
        vertex_base: u32,
        verts: &[SkinnedVertex],
        idxs: &[u16],
    ) -> Result<(), String> {
        let _ = (skinned_index, vertex_base, verts, idxs);
        Ok(())
    }

    /// Rebuild the shared skinned-mesh vertex + index buffers, replacing the
    /// geometry of each `SkinnedDrawGeometryUpdate.skinned_index` with the
    /// new vertices / indices. Slots not named in `changes` keep their
    /// current geometry, copied byte-for-byte from the live buffers and
    /// re-based onto the new vertex region they land in. Returns the
    /// post-rebuild layout (one [`SkinnedSlotLayout`] per slot, in
    /// `skinned_index` order) so the caller can refresh its source-map
    /// `vertex_base` / `vertex_count` / `index_count` to point at the new
    /// regions. Driven by asset hot-reload (`cn debug` only) when a
    /// size-changing `.glb` re-export means the existing
    /// [`Self::update_skinned_mesh_geometry`] in-place write no longer fits.
    /// The backend `wait_idle`s first; the rebuild swaps the GPU buffers
    /// wholesale. The skinned pipelines, shadow + velocity + SSAO + SSR
    /// variants, and `skinned_draw_objects` slot metadata
    /// (`texture_slot` / `normal_map_slot` / `material` / `joint_count`)
    /// all stay untouched; only the `index_offset` / `index_count` on each
    /// `SkinnedDrawObject` (and the buffers themselves) move. Default no-op
    /// (returns an empty layout vec): backends that have not implemented
    /// the rebuild leave the size-changing reload as logged + skipped at
    /// the caller, the same behaviour as before, since the in-place path
    /// already errored on size mismatch.
    fn rebuild_skinned_geometry(
        &mut self,
        changes: Vec<SkinnedDrawGeometryUpdate>,
    ) -> Result<Vec<SkinnedSlotLayout>, String> {
        let _ = changes;
        Ok(Vec::new())
    }

    /// Update a skinned slot's joint count and resize the backend's per-slot
    /// joint-matrix buffers 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. Shrinking truncates the
    /// per-slot Vec; growing seeds the new entries to identity so the slot
    /// renders undeformed on the next `update_skinned_pose`. The skinned
    /// shaders consume the joints buffer through a pointer (not a fixed-
    /// size array) and use vertex-attribute-encoded joint indices, so no
    /// pipeline or shader rebuild is required for a joint-count change;
    /// only the CPU-side per-slot buffer and `SkinnedDrawObject.joint_count`
    /// change. Default no-op: backends that have not implemented the resize
    /// leave the skeleton-shape change logged + skipped at the caller.
    fn update_skinned_skeleton(
        &mut self,
        skinned_index: usize,
        new_joint_count: usize,
    ) -> Result<(), String> {
        let _ = (skinned_index, new_joint_count);
        Ok(())
    }

    /// Replace a `Mesh` draw slot's vertex + index data in place. Driven by
    /// asset hot-reload (`cn debug` only). Reuses the slot's existing offset
    /// in the shared vertex / index buffers, so the new geometry must match
    /// the slot's init-time vertex count + index count; a size-changing
    /// reload returns an error so the caller can queue
    /// [`Self::rebuild_static_geometry`] instead, which repacks the shared
    /// buffers. Each entry in
    /// `lod_alternates` (`(switch_distance, mesh-relative indices)`) is
    /// written to the matching slot's pre-allocated LOD index region; the
    /// number of LODs and each LOD's index count must match the slot's
    /// init-time layout, otherwise the call returns an error so the caller
    /// can queue [`Self::rebuild_static_geometry`]. `switch_distance` is
    /// re-stored per LOD so a JSON-side tweak to `lod_distances` propagates
    /// without a process restart. Default no-op.
    fn update_mesh_geometry(
        &mut self,
        draw_idx: usize,
        verts: &[Vertex],
        idxs: &[u16],
        lod_alternates: &[(f32, Vec<u16>)],
    ) -> Result<(), String> {
        let _ = (draw_idx, verts, idxs, lod_alternates);
        Ok(())
    }

    /// Replace the live IBL environment map with a freshly precomputed payload.
    /// `payload` is the serialised byte format emitted by
    /// `concinnity_core::build::environment_map::compile_environment_map_payload`
    /// (header + irradiance cube + prefilter mip chain), so init and hot-reload
    /// share a single byte format. Driven by asset hot-reload (`cn debug`
    /// only). Default no-op: backends that have not implemented the swap leave
    /// the IBL cubes bound at whatever payload was uploaded at init.
    fn update_environment_map(&mut self, payload: &[u8]) -> RenderResult<()> {
        let _ = payload;
        Ok(())
    }

    /// Replace the live volumetric-fog settings, or disable the fog pass when
    /// `None`. Driven by world.jsonl hot-reload (`cn debug` only). Default
    /// no-op: backends that have not implemented the swap leave the fog pass
    /// at whatever settings were resolved at init.
    ///
    /// A backend that built its fog pipeline lazily based on the world's
    /// init-time `VolumetricFog` cannot enable the pass via this call when
    /// the world started with no fog declared; re-enabling fog on a world
    /// that did not declare it at startup requires a relaunch.
    fn update_fog_settings(&mut self, settings: Option<FogSettings>) {
        let _ = settings;
    }

    /// Capture the last presented frame to a PNG at `path` and return the saved
    /// path. Driven by the `cn debug` WS `screenshot` command for headless
    /// on-GPU render verification. Default `Err`: a backend without a capture
    /// path reports it unsupported (all current backends override this).
    fn screenshot(&mut self, path: &str) -> Result<String, String> {
        let _ = path;
        Err("screenshot capture not supported on this backend".to_string())
    }

    /// Instantiate a runtime copy of an existing draw object at a new transform:
    /// re-use the source slot's geometry region (`vertex_offset` / `vertex_count`
    /// / `index_offset` / `index_count` / `base_vertex` / `lod_alternates`) and
    /// copy its texture slots, material, and cull distance, swapping only the
    /// model matrix. The new slot reuses one freed by `retire_draw_object` before
    /// growing the draw-object vec. The destination slot comes from the
    /// engine's draw-slot allocator: `Reuse` overwrites a vacated entry,
    /// `Append` grows the vec (the index always equals the current length,
    /// which implementations debug-assert). Driven by runtime entity spawn
    /// (`SpawnRequest`). The copy is non-cullable (sentinel AABB) and drawn
    /// every frame, since the init-time BVH cannot refit to admit a slot added
    /// at runtime; moving copies (the common case) opt out of the static BVH
    /// exactly like streamed chunks and held items. Default no-op (returns
    /// `Err`): backends without an implementation leave the spawn path
    /// logged + skipped at the caller.
    fn clone_static_draw_object(
        &mut self,
        src_draw_idx: usize,
        model: [[f32; 4]; 4],
        dst: crate::draw_slot::SlotAlloc,
    ) -> Result<(), String> {
        let _ = (src_draw_idx, model, dst);
        Err("clone_static_draw_object: not implemented on this backend".to_string())
    }

    /// Rewrite a draw slot's material parameters + texture/normal-map pool
    /// indices in place. Driven by the editor's live draw seam when a Prop edits
    /// its `material` arg. Default no-op; a backend that implements it reports
    /// [`DeviceCapabilities::rewrites_draws`], which is what the caller gates on
    /// rather than pushing an edit that would not land.
    fn set_draw_material(
        &mut self,
        draw_idx: usize,
        material: MaterialUniforms,
        texture_slot: usize,
        normal_map_slot: usize,
    ) {
        let _ = (draw_idx, material, texture_slot, normal_map_slot);
    }

    /// Rewrite a draw slot's `cull_distance` in place. Driven by the editor's
    /// live draw seam when a Prop edits its `cull_distance` arg. Default no-op,
    /// gated by the same [`DeviceCapabilities::rewrites_draws`] flag.
    fn set_draw_cull_distance(&mut self, draw_idx: usize, cull_distance: f32) {
        let _ = (draw_idx, cull_distance);
    }

    /// Append a projected-decal record at runtime, returning a stable slot
    /// index the caller hands to [`Self::remove_decal`] later. Lets a
    /// gameplay system stamp bullet holes, footprints, or other ad-hoc
    /// decals after the world has built. Backends that have not implemented
    /// the runtime path return `Err`; the caller logs and drops the request.
    fn add_decal(&mut self, record: crate::decal::DecalRecord) -> Result<usize, String> {
        let _ = record;
        Err("add_decal: not implemented on this backend".to_string())
    }

    /// Tombstone a runtime decal slot. The id returned by
    /// [`Self::add_decal`] becomes invalid; the next add may reuse it.
    /// Default no-op-with-Err: backends without a runtime path leave the
    /// remove logged + skipped at the caller.
    fn remove_decal(&mut self, decal_id: usize) -> Result<(), String> {
        let _ = decal_id;
        Err("remove_decal: not implemented on this backend".to_string())
    }

    /// Append a particle-emitter record at runtime, returning a stable slot
    /// index. The backend allocates the per-emitter GPU pool + atomic
    /// spawn counter (matching the init-time path) so the compute kernel
    /// can begin ticking on the next frame. Default no-op-with-Err.
    fn add_emitter(
        &mut self,
        record: crate::particles::ParticleEmitterRecord,
    ) -> Result<usize, String> {
        let _ = record;
        Err("add_emitter: not implemented on this backend".to_string())
    }

    /// Tombstone a runtime emitter slot and release its GPU pool +
    /// counter buffers (the GPU keeps them alive via its own refcount
    /// until any in-flight command buffer that referenced them completes).
    /// Default no-op-with-Err.
    fn remove_emitter(&mut self, emitter_id: usize) -> Result<(), String> {
        let _ = emitter_id;
        Err("remove_emitter: not implemented on this backend".to_string())
    }

    /// Rebuild the live main / instanced / shadow render pipelines from
    /// freshly compiled world-loaded shader stage bytes. Driven by asset
    /// hot-reload (`cn debug` only) when one of the captured `Shader`
    /// source files is saved or a debug-WS `reload-assets` command fires.
    /// Each `Some(bytes)` replaces the matching live pipeline (and any
    /// dependent state: bindless-texture argument encoder, cull pipeline,
    /// instanced variant, shadow variant); `None` leaves the pipeline
    /// untouched (e.g. a world without an instanced shader passes `None`
    /// for the instanced slot). The backend should build every replacement
    /// into a temporary first and only swap when every build succeeds;
    /// mirrors the safety pattern in the Metal backend's `hot_reload` so a
    /// compile error never overwrites a live pipeline with a half-built
    /// replacement. Default no-op (returns `Err`): backends without an
    /// implementation leave the world-loaded shader reload logged + skipped
    /// at the caller.
    ///
    /// Skinned-mesh variants are out of scope here: their pipelines depend
    /// on the world's `SkinnedMesh`-injected library bytes that
    /// [`Self::upload_skinned`] consumes and drops.
    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_bytes, frag_bytes, shadow_bytes, vert_instanced_bytes);
        Err("update_world_shader_pipelines: not implemented on this backend".to_string())
    }

    /// Build the render pipeline for one shader bucket from its compiled stage
    /// bytes, making draws that carry that bucket renderable. Called by the
    /// streaming pump when a scene that exclusively owns the bucket's `Shader`
    /// pins: init skipped the build, so this is where the cost lands (behind
    /// the loading screen, since the bucket counts as scene-resident content).
    /// Bucket 0 is the world default program and is never installed this way.
    ///
    /// Default no-op-with-Ok: a backend that renders every draw with the world
    /// default program has no per-bucket pipeline to build, and the bucket is
    /// resident as far as scene loading is concerned.
    fn install_world_shader(&mut self, bucket: u32, shader: ShaderBytes<'_>) -> RenderResult<()> {
        let _ = (bucket, shader);
        Ok(())
    }

    /// Release one shader bucket's render pipeline, undoing
    /// [`Self::install_world_shader`]. Called when the owning scene unpins;
    /// draws carrying the bucket stop rendering until it is installed again.
    /// Default no-op, for the same reason as above.
    fn evict_world_shader(&mut self, bucket: u32) {
        let _ = bucket;
    }

    /// The swapchain-level configuration this live backend can hot-swap a world
    /// onto, or `None` when the backend cannot reload a world in place (it must
    /// be fully rebuilt instead). Read by GraphicsSystem when a transplanted
    /// backend is handed a new world (the `cn editor` live SAVE): the swap reuses
    /// the backend via [`Self::reload_world`] only when this equals the new
    /// world's `BackendInit::swapchain_config`; a `None` or a mismatch routes to a
    /// full rebuild (recreating the window). Default `None`: DirectX / Vulkan
    /// (and any backend without a real `reload_world`) always rebuild.
    fn hot_swap_config(&self) -> Option<SwapchainConfig> {
        None
    }

    /// Re-upload a new world's GPU content onto this already-constructed backend,
    /// reusing the live device + window + swapchain instead of building a new one.
    /// Driven by 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 without recreating the OS window
    /// or re-initialising the GPU device. The backend waits for the GPU to idle,
    /// drops the old world's content resources, and rebuilds them from `init` on
    /// the retained hardware. Only ever called when [`Self::hot_swap_config`]
    /// reported a config matching `init.swapchain_config()`, so the swapchain
    /// (pixel format / frames-in-flight / EDR) is guaranteed unchanged. Default
    /// `Err`/unsupported: DirectX / Vulkan fall back to a full rebuild (no
    /// regression; a real implementation is Windows-pending like the rest).
    fn reload_world(&mut self, init: BackendInit<'_>) -> RenderResult<()> {
        let _ = init;
        Err(RenderError::Other(
            "reload_world: not supported on this backend".to_string(),
        ))
    }
}

// A do-nothing backend used to exercise the trait's provided (default) method
// bodies without a GPU: the smallest valid bodies for the required methods,
// no defaults overridden. Shared by this module's tests and the ops tests.
#[cfg(test)]
pub(crate) mod test_stub {
    use super::*;

    pub(crate) struct StubBackend;

    impl SceneControl for StubBackend {
        fn update_visibility(&mut self, _draw_idx: usize, _visible: bool) {}
        fn set_fade(&mut self, _fade: f32) {}
    }

    impl RenderBackend for StubBackend {
        fn window_closed(&mut self) -> bool {
            false
        }
        fn capture_cursor(&mut self) {}
        fn take_input(&mut self) -> RenderInput {
            RenderInput::default()
        }
        fn wait_idle(&self) {}
        fn draw_frame(&mut self, _params: FrameParams<'_>) -> RenderResult<()> {
            Ok(())
        }
        fn update_view(&mut self, _matrix: [[f32; 4]; 4]) {}
        fn update_models(&mut self, _updates: &[(u32, [[f32; 4]; 4])]) {}
        fn retire_draw_object(&mut self, _draw_idx: usize) {}
        fn upload_skinned(
            &mut self,
            _vertices: &[SkinnedVertex],
            _indices: &[u32],
            _draw_objects: Vec<SkinnedDrawObject>,
            _vert_bytes: &[u8],
            _frag_bytes: &[u8],
            _shadow_bytes: &[u8],
        ) -> RenderResult<()> {
            Ok(())
        }
        fn update_skinned_pose(&mut self, _skinned_index: usize, _matrices: &[[[f32; 4]; 4]]) {}
        fn evict_texture_slot(&mut self, _slot: usize) -> Result<(), String> {
            Ok(())
        }
        fn update_texture_slot(
            &mut self,
            _slot: usize,
            _image: &crate::build::texture::TextureImage,
        ) -> RenderResult<()> {
            Ok(())
        }
        fn evict_mesh(&mut self, _draw_idx: usize, _retire_frame: u64) -> Result<(), String> {
            Ok(())
        }
        fn upload_mesh(
            &mut self,
            _draw_idx: usize,
            _verts: &[Vertex],
            _idxs: &[u16],
            _frame: u64,
        ) -> RenderResult<()> {
            Ok(())
        }
        fn setup_chunk_streaming(
            &mut self,
            _chunk_vtx_bytes: usize,
            _chunk_idx_bytes: usize,
            _texture_slot: usize,
            _normal_map_slot: usize,
        ) -> RenderResult<()> {
            Ok(())
        }
        fn add_chunk_mesh(
            &mut self,
            _mesh: ChunkMesh<'_>,
            _dst: crate::draw_slot::SlotAlloc,
        ) -> RenderResult<()> {
            Ok(())
        }
        fn remove_chunk_mesh(
            &mut self,
            _draw_idx: usize,
            _retire_frame: u64,
        ) -> Result<(), String> {
            Ok(())
        }
        fn set_chunk_model(
            &mut self,
            _draw_idx: usize,
            _model: [[f32; 4]; 4],
        ) -> Result<(), String> {
            Ok(())
        }
    }
}

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

    use alloc::vec;
    const GB: u64 = 1 << 30;

    fn input(
        vendor: GpuVendor,
        memory_budget_bytes: u64,
        discrete: bool,
        apple_family: u8,
    ) -> GpuClassInput {
        GpuClassInput {
            vendor,
            memory_budget_bytes,
            discrete,
            apple_family,
        }
    }

    #[test]
    fn unknown_profile_is_the_conservative_default() {
        // The opposite default from capabilities: quality auto-config fails safe.
        let p = GpuProfile::default();
        assert_eq!(p.tier, GpuTier::Unknown);
        assert_eq!(p.vendor, GpuVendor::Other);
        assert_eq!(p.memory_budget_bytes, 0);
        // Unknown sorts below every real tier, so a `>=` resolver treats it as
        // the floor.
        assert!(GpuTier::Unknown < GpuTier::Integrated);
        assert!(GpuTier::Integrated < GpuTier::EntryDiscrete);
        assert!(GpuTier::EntryDiscrete < GpuTier::MidDiscrete);
        assert!(GpuTier::MidDiscrete < GpuTier::HighDiscrete);
    }

    #[test]
    fn apple_family_reads_the_generation_out_of_the_device_name() {
        // The names MoltenVK reports, mapped onto Metal's family ranks.
        assert_eq!(apple_family_from_device_name("Apple M1"), 7);
        assert_eq!(apple_family_from_device_name("Apple M2 Max"), 8);
        assert_eq!(apple_family_from_device_name("Apple M3 Pro"), 9);
        assert_eq!(apple_family_from_device_name("Apple M4 Ultra"), 10);
        // A generation past what Metal's SDK names yet still ranks above M3, so
        // a newer Mac is not demoted.
        assert!(apple_family_from_device_name("Apple M9") > 9);
    }

    #[test]
    fn non_apple_device_names_report_no_family() {
        for name in [
            "NVIDIA GeForce RTX 4090",
            "AMD Radeon RX 7900 XTX",
            "Intel(R) Arc(tm) A770",
            // Apple's own non-M naming, and a truncated / malformed report.
            "Apple A17 Pro",
            "Apple M",
            "Apple MX",
            "",
        ] {
            assert_eq!(apple_family_from_device_name(name), 0, "{name}");
        }
    }

    #[test]
    fn apple_family_from_a_name_reaches_the_same_tier_metal_does() {
        // The whole point of the name probe: a MoltenVK build must land on the
        // tier the Metal backend reports for the same silicon, not on the
        // integrated floor a zero family falls through to.
        let family = apple_family_from_device_name("Apple M2 Max");
        assert_eq!(
            classify_tier(&input(GpuVendor::Apple, 32 * GB, false, family)),
            GpuTier::MidDiscrete
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Apple, 32 * GB, false, 0)),
            GpuTier::Integrated
        );
    }

    #[test]
    fn apple_silicon_classifies_by_generation() {
        // Unified memory is large on Apple silicon, but the family generation
        // (not the working-set) decides the tier, so the huge shared budget does
        // not read as a high-VRAM discrete card.
        assert_eq!(
            classify_tier(&input(GpuVendor::Apple, 16 * GB, false, 7)),
            GpuTier::EntryDiscrete // M1
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Apple, 24 * GB, false, 8)),
            GpuTier::MidDiscrete // M2
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Apple, 48 * GB, false, 9)),
            GpuTier::HighDiscrete // M3
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Apple, 64 * GB, false, 10)),
            GpuTier::HighDiscrete // M4 and newer cap at high
        );
    }

    #[test]
    fn discrete_gpu_classifies_by_vram() {
        // An Intel-Mac AMD dGPU or a PC discrete card: vendor is not Apple and
        // there is no Apple family, so VRAM buckets the tier.
        assert_eq!(
            classify_tier(&input(GpuVendor::Nvidia, 24 * GB, true, 0)),
            GpuTier::HighDiscrete
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Amd, 8 * GB, true, 0)),
            GpuTier::MidDiscrete
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Nvidia, 4 * GB, true, 0)),
            GpuTier::EntryDiscrete
        );
        // A discrete card that reports no memory budget is left Unknown rather
        // than guessed high.
        assert_eq!(
            classify_tier(&input(GpuVendor::Amd, 0, true, 0)),
            GpuTier::Unknown
        );
    }

    #[test]
    fn integrated_gpu_is_the_lowest_tier() {
        // Non-Apple integrated part: no dedicated memory, not unified, no Apple
        // family.
        assert_eq!(
            classify_tier(&input(GpuVendor::Intel, 0, false, 0)),
            GpuTier::Integrated
        );
    }

    #[test]
    fn vram_bucket_boundaries() {
        // Boundaries are inclusive lower bounds (>= 12 GB high, >= 6 GB mid).
        assert_eq!(
            classify_tier(&input(GpuVendor::Nvidia, 12 * GB, true, 0)),
            GpuTier::HighDiscrete
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Nvidia, 12 * GB - 1, true, 0)),
            GpuTier::MidDiscrete
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Nvidia, 6 * GB, true, 0)),
            GpuTier::MidDiscrete
        );
        assert_eq!(
            classify_tier(&input(GpuVendor::Nvidia, 6 * GB - 1, true, 0)),
            GpuTier::EntryDiscrete
        );
    }

    pub(crate) use super::test_stub::StubBackend;

    const IDENTITY: [[f32; 4]; 4] = [
        [1.0, 0.0, 0.0, 0.0],
        [0.0, 1.0, 0.0, 0.0],
        [0.0, 0.0, 1.0, 0.0],
        [0.0, 0.0, 0.0, 1.0],
    ];

    // Minimal QualitySettings with every feature off, so no *Settings sub-type
    // needs constructing.
    fn stub_quality() -> QualitySettings {
        QualitySettings {
            taa: false,
            ssao: None,
            ssr: None,
            rt_reflections: None,
            ssgi: None,
            reflection_blur_scale: 1,
            auto_exposure: None,
            auto_exposure_bias_ev: 0.0,
        }
    }

    #[test]
    fn default_query_methods_report_conservative_values() {
        let backend = StubBackend;
        // Capabilities fail open: a backend that does not report keeps every
        // toggle live.
        assert!(backend.capabilities().ray_tracing);
        // Quality auto-config fails safe: the unknown/conservative profile.
        assert_eq!(backend.gpu_profile().tier, GpuTier::Unknown);
        assert_eq!(backend.gpu_profile().vendor, GpuVendor::Other);
        assert_eq!(backend.gpu_profile().memory_budget_bytes, 0);
        // Diagnostics a backend may leave to the default: zeroed here.
        assert_eq!(backend.logical_size(), (0.0, 0.0));
        assert_eq!(backend.render_stats(), RenderStats::default());
        // No window-bounds tracking: the in-engine cursor always draws.
        assert!(!backend.cursor_outside_window());
        // No display enumeration and no hot-reload flag wired.
        assert!(backend.display_modes().is_empty());
        assert!(backend.current_display_mode().is_none());
        assert!(backend.shader_reload_flag().is_none());
        // Not hot-swap-capable: a live world reload routes to a full rebuild.
        assert!(backend.hot_swap_config().is_none());
        // No geometry-size introspection for the reload size check.
        assert!(backend.draw_geometry_size(0).is_none());
        assert!(backend.draw_lod_index_counts(0).is_none());
    }

    #[test]
    fn default_mutators_are_noops_and_fallible_hooks_report_defaults() {
        let mut backend = StubBackend;

        // Runtime skinned-spawn fallbacks: nothing to reveal or hide.
        backend.reveal_skinned_instance(0, IDENTITY);
        backend.retire_skinned_draw_object(0);
        backend.update_skinned_models(&[(0, IDENTITY)]);

        // Streaming + cursor + capture no-ops.
        backend.seed_mesh_streaming(0, 0, 0, 0);
        backend.set_ui_cursor_hidden(true);
        backend.set_menu_mode(true);
        backend.set_camera_capture(true);
        backend.set_reflection_probes(&[]);

        // Presentation + window no-ops.
        backend.set_vsync(true);
        backend.set_window_mode(crate::components::WindowMode::Fullscreen);
        backend.set_window_size(1280, 720);
        backend.set_display_mode(crate::display_mode::DisplayMode {
            width: 1920,
            height: 1080,
            refresh_hz: 60,
        });

        // Live look + input tunable no-ops.
        backend.update_post_process(PostProcessTunables::DEFAULT);
        backend.set_ambient_intensity(1.0);
        backend.set_keymap(&KeyMap::default());
        backend.apply_quality_settings(stub_quality());
        backend.update_quality_params(stub_quality());
        backend.set_shadow_update(crate::components::ShadowUpdate::EveryFrame);
        backend.set_shadow_distance(200);
        backend.set_shadow_cascades(3);
        backend.update_fog_settings(None);
        backend.update_directional_lights(&[]);
        backend.set_draw_material(0, MaterialUniforms::DEFAULT, 0, 0);
        backend.set_draw_cull_distance(0, 50.0);

        // Fallible hot-reload hooks that succeed by default (no-op Ok).
        assert!(backend.update_color_lut(2, &[0u8; 32]).is_ok());
        assert!(backend.rebuild_static_geometry(vec![]).is_ok());
        assert!(backend.update_skinned_mesh_geometry(0, 0, &[], &[]).is_ok());
        assert!(backend.rebuild_skinned_geometry(vec![]).unwrap().is_empty());
        assert!(backend.update_skinned_skeleton(0, 0).is_ok());
        assert!(backend.update_mesh_geometry(0, &[], &[], &[]).is_ok());
        assert!(backend.update_environment_map(&[]).is_ok());

        // Fallible hooks a bare backend does not implement: they report Err.
        assert!(backend.screenshot("unused.png").is_err());
        assert!(
            backend
                .clone_static_draw_object(0, IDENTITY, crate::draw_slot::SlotAlloc::Append(0))
                .is_err()
        );
        assert!(backend.add_decal(stub_decal()).is_err());
        assert!(backend.remove_decal(0).is_err());
        assert!(backend.add_emitter(stub_emitter()).is_err());
        assert!(backend.remove_emitter(0).is_err());
        assert!(
            backend
                .update_world_shader_pipelines(None, None, None, None)
                .is_err()
        );
    }

    // A minimal empty-world BackendInit borrowing `window`, for exercising the
    // default `reload_world`. Empty slices are `'static`; the only real borrow
    // is the window args.
    fn empty_backend_init(window: &crate::components::Window) -> BackendInit<'_> {
        BackendInit::minimal(window, alloc::vec::Vec::new())
    }

    #[test]
    fn default_reload_world_is_unsupported() {
        // A backend without a real reload path reports the swap unsupported, so
        // the caller falls back to a full rebuild.
        let mut backend = StubBackend;
        let window = crate::components::Window::default();
        assert!(backend.reload_world(empty_backend_init(&window)).is_err());
    }

    fn stub_decal() -> crate::decal::DecalRecord {
        crate::decal::DecalRecord {
            model: IDENTITY,
            inv_model: IDENTITY,
            texture_slot: 0,
            tint: [1.0; 4],
        }
    }

    fn stub_emitter() -> crate::particles::ParticleEmitterRecord {
        crate::particles::ParticleEmitterRecord {
            texture_slot: 0,
            position: [0.0; 3],
            direction: [0.0, 1.0, 0.0],
            spread_cos: 1.0,
            speed_min: 0.0,
            speed_max: 1.0,
            lifetime_min: 0.0,
            lifetime_max: 1.0,
            gravity: [0.0, -9.8, 0.0],
            spawn_rate: 1.0,
            max_particles: 1,
            size_start: 1.0,
            size_end: 1.0,
            color_start: [1.0; 4],
            color_end: [1.0; 4],
        }
    }
}