concinnity-device 0.19.1

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
// src/metal/hot_reload.rs
//
// Filesystem watcher driving Metal shader hot-reload. A background notify
// watcher tails `<CARGO_MANIFEST_DIR>/src/metal/shaders/` and, on any modify
// event for a `.metal` file, flips a shared `Arc<AtomicBool>`. The main thread
// polls that flag at the top of `draw_frame` and calls
// `MtlContext::reload_shaders` when it's set. Same flag is also set by the
// `reload-shaders` debug WebSocket command, so the two trigger paths converge.
//
// All entirely a dev-loop concern: only constructed when
// `MtlContext::new` is called with `hot_reload = true`. Production `cn run`
// never instantiates it.
#![deny(unsafe_op_in_unsafe_fn)]

use notify::{Event, EventKind, RecursiveMode, Watcher};
use objc2::rc::Retained;
use objc2_metal::{MTLVertexDescriptor, MTLVertexFormat, MTLVertexStepFunction};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};

use super::auto_exposure::build_auto_exposure_pipelines;
use super::context::MtlContext;
use super::cull::{build_cull_pipeline, build_shadow_cull_pipeline};
use super::decal::build_decal_pipeline;
use super::descriptors::{VertexAttr, VertexLayout, vertex_descriptor};
use super::fog::build_fog_pipeline;
use super::hiz::build_hiz_pipelines;
use super::init::pipelines::{
    MainPipelineBundle, build_instanced_pipeline, build_main_pipeline,
    build_shadow_bindless_pipeline, build_shadow_pipeline, make_vertex_descriptor,
};
use super::pipeline::{build_post_pipeline, build_text_pipeline};
use super::post::{
    build_bloom_pipelines, build_gbuffer_bindless_pipeline, build_gbuffer_prepass_pipeline,
    build_reflection_blur_pipeline, build_reflection_composite_pipeline,
    build_rt_reflection_pipeline, build_ssao_pipeline, build_ssgi_composite_pipeline,
    build_ssgi_gather_pipeline, build_ssr_pipeline, build_taa_pipeline,
};
use super::resources::skinning::{
    build_skinned_main_pipeline, build_skinned_shadow_pipeline, make_skinned_vertex_descriptor,
};
use crate::metal::slang_shaders::{self, SSAO_BLUR, SSAO_KERNEL};

// Rebuild a built-in pipeline only when it is currently live. Expands to
// `if $cond { Some($build?) } else { None }`: the rebuild-then-swap pattern
// `reload_shaders` repeats for every optional pipeline: a `None` field stays
// `None`, and any compile error (the `?`) aborts the whole reload before the
// swap, leaving the live pipelines untouched.
macro_rules! rebuild_if_live {
    ($cond:expr_2021, $build:expr_2021 $(,)?) => {
        if $cond { Some($build?) } else { None }
    };
}

// Live watcher handle. Held by `MtlContext` purely to keep the watcher
// thread alive; dropping it stops the watcher. The flag itself is shared
// via `MtlContext`'s `hot_reload.reload_pending`.
pub(crate) struct WatcherHandle {
    // We don't read `_watcher` after construction; notify keeps its own
    // listener thread for as long as the handle is alive.
    #[expect(
        dead_code,
        reason = "notify keeps its listener thread alive while the handle lives; never read after construction"
    )]
    watcher: notify::RecommendedWatcher,
}

// Spawn a `notify` watcher over the Metal shader source directory and wire
// it to flip `flag` on any `.metal` file modify event. The path is derived
// from `CARGO_MANIFEST_DIR` at compile time so the watcher works no matter
// where the binary is launched from, but only as long as the source tree
// still exists at that path. A shipped binary should never be hot-reload-
// enabled, so the missing-path case logs and returns `None` instead of
// failing the whole context init.
pub(crate) fn spawn(flag: Arc<AtomicBool>) -> Option<WatcherHandle> {
    let dir: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("src")
        .join("metal")
        .join("shaders");
    if !dir.is_dir() {
        tracing::warn!(
            "hot-reload: shader source dir {} not found; watcher disabled (debug \
             command still works)",
            dir.display()
        );
        return None;
    }

    // Suppress event bursts: editors (vim, VSCode) frequently emit several
    // close-write / rename events per save. Coalesce by a small debounce so
    // one save triggers exactly one reload.
    let debounce = Duration::from_millis(150);
    let last_fire = std::sync::Mutex::new(Instant::now() - debounce);
    let flag_for_cb = Arc::clone(&flag);
    let mut watcher = match notify::recommended_watcher(move |res: notify::Result<Event>| {
        let event = match res {
            Ok(e) => e,
            Err(e) => {
                tracing::debug!("hot-reload watcher error: {e}");
                return;
            }
        };
        if !is_relevant(&event) {
            return;
        }
        let mut last = match last_fire.lock() {
            Ok(g) => g,
            Err(p) => p.into_inner(),
        };
        let now = Instant::now();
        if now.duration_since(*last) < debounce {
            return;
        }
        *last = now;
        tracing::info!(
            "hot-reload: detected change to {:?}, scheduling shader rebuild",
            event.paths
        );
        flag_for_cb.store(true, Ordering::SeqCst);
    }) {
        Ok(w) => w,
        Err(e) => {
            tracing::warn!("hot-reload: failed to create notify watcher: {e}");
            return None;
        }
    };

    if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) {
        tracing::warn!(
            "hot-reload: failed to watch {} ({}); watcher disabled",
            dir.display(),
            e
        );
        return None;
    }

    // The single-source shader directory rides the same watcher: a `.slang`
    // save rebuilds through the same flag. Best-effort, like the main dir.
    let slang_dir: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("src")
        .join("shaders");
    if slang_dir.is_dir()
        && let Err(e) = watcher.watch(&slang_dir, RecursiveMode::NonRecursive)
    {
        tracing::warn!(
            "hot-reload: failed to watch {} ({e}); .slang edits will not trigger reloads",
            slang_dir.display()
        );
    }

    tracing::info!(
        "hot-reload: watching {} for .metal and .slang changes",
        dir.display()
    );
    Some(WatcherHandle { watcher })
}

// True when this notify event is a modify of a shader source file we care
// about (`.metal`, or a single-source `.slang`). Filters out unrelated paths
// (e.g. swap files, sub-directory churn) and the non-mutating events notify
// emits (e.g. access/metadata).
fn is_relevant(event: &Event) -> bool {
    if !matches!(
        event.kind,
        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
    ) {
        return false;
    }
    event
        .paths
        .iter()
        .any(|p| p.extension().is_some_and(|e| e == "metal" || e == "slang"))
}

// Static-vertex-layout descriptor used by the velocity / SSAO / SSR pre-pass
// rebuilds during hot-reload. Matches the layout `MtlContext::new` builds at
// init; kept in sync by construction since both touch the 56-byte static
// `Vertex` struct.
fn static_vertex_descriptor() -> Retained<MTLVertexDescriptor> {
    vertex_descriptor(
        &[
            VertexAttr {
                index: 0,
                format: MTLVertexFormat::Float3,
                offset: 0,
                buffer_index: 1,
            },
            VertexAttr {
                index: 1,
                format: MTLVertexFormat::Float3,
                offset: 12,
                buffer_index: 1,
            },
            VertexAttr {
                index: 2,
                format: MTLVertexFormat::Float3,
                offset: 24,
                buffer_index: 1,
            },
            VertexAttr {
                index: 3,
                format: MTLVertexFormat::Float3,
                offset: 36,
                buffer_index: 1,
            },
            VertexAttr {
                index: 4,
                format: MTLVertexFormat::Float2,
                offset: 48,
                buffer_index: 1,
            },
        ],
        &[VertexLayout {
            buffer_index: 1,
            stride: std::mem::size_of::<crate::gfx::mesh_payload::Vertex>(),
            step: MTLVertexStepFunction::PerVertex,
        }],
    )
}

impl MtlContext {
    // True when the shared shader-reload flag is set. Cheap atomic load; called
    // at the top of `draw_frame`. Returns false when hot-reload is off so
    // the production path never enters the reload branch.
    pub(super) fn shader_reload_requested(&self) -> bool {
        self.hot_reload
            .reload_pending
            .as_ref()
            .map(|f| f.load(Ordering::SeqCst))
            .unwrap_or(false)
    }

    // Clear the pending-reload flag. Called after `reload_shaders` regardless
    // of outcome so a failed rebuild does not loop forever.
    pub(super) fn clear_shader_reload_flag(&self) {
        if let Some(flag) = &self.hot_reload.reload_pending {
            flag.store(false, Ordering::SeqCst);
        }
    }

    // Rebuild every built-in Metal renderer pipeline from disk-resident source.
    // Each pipeline is constructed into a temporary first; only when every
    // rebuild succeeds does the context atomically swap them in. Any compile
    // or link error logs the underlying message and leaves the live pipelines
    // untouched: a typo in a shader edit won't crash the running session.
    //
    // Covers the skinned velocity / SSAO / SSR pre-pass variants too: they
    // compile from the same on-disk `velocity.metal` / `ssao.metal` /
    // `ssr.metal` sources as their static + instanced siblings, just with a
    // different vertex entry point. The skinned main + skinned shadow
    // pipelines are not built here: their entry points live in the world's
    // vertex / fragment / shadow `Shader` library bytes, so they
    // reload through [`Self::update_world_shader_pipelines`] alongside the
    // static main pipeline.
    pub(super) fn reload_shaders(&mut self) -> Result<(), String> {
        if !self.hot_reload.enabled {
            return Ok(());
        }
        let device = &self.device;
        let hr = true;

        // Build every replacement into a temporary first. A `?` early-return
        // here means we never overwrite a live pipeline with a failed build:
        // any compile error leaves the running session rendering with the
        // previous shader source.
        let post = build_post_pipeline(device, self.swap_pixel_format, hr)?;
        let bloom = rebuild_if_live!(
            self.bloom_pipelines.is_some(),
            build_bloom_pipelines(device, hr)
        );

        let text = rebuild_if_live!(
            self.text.pipeline_state.is_some(),
            build_text_pipeline(device, self.swap_pixel_format, hr)
        );
        let taa = rebuild_if_live!(
            self.taa.pipeline_state.is_some(),
            build_taa_pipeline(device, hr)
        );
        let cull = rebuild_if_live!(
            self.cull.pipeline.is_some(),
            build_cull_pipeline(device, hr)
        );
        // Hi-Z build kernels are engine built-ins (independent of the world
        // shader); rebuild them whenever a Hi-Z resource exists so a saved
        // edit to `hiz_build.slang` is picked up. The texture + mip views are
        // kept: only the pipelines swap.
        let hiz = rebuild_if_live!(self.cull.hiz.is_some(), build_hiz_pipelines(device, hr));
        let auto_ev = rebuild_if_live!(
            self.auto_exposure.pipelines.is_some(),
            build_auto_exposure_pipelines(device, hr)
        );
        let decal = rebuild_if_live!(
            self.decal.pipeline.is_some(),
            build_decal_pipeline(device, hr)
        );
        let fog = rebuild_if_live!(self.fog.pipeline.is_some(), build_fog_pipeline(device, hr));

        // The G-buffer pre-pass + SSAO/SSR resolve variants need the static layout.
        let static_vdesc = static_vertex_descriptor();
        let ssao_kernel = rebuild_if_live!(
            self.ssao.kernel_pipeline.is_some(),
            build_ssao_pipeline(device, &SSAO_KERNEL, hr)
        );
        let ssao_blur = rebuild_if_live!(
            self.ssao.blur_pipeline.is_some(),
            build_ssao_pipeline(device, &SSAO_BLUR, hr)
        );
        let gbuffer_prepass = rebuild_if_live!(
            self.gbuffer.prepass_pipeline.is_some(),
            build_gbuffer_prepass_pipeline(
                device,
                &static_vdesc,
                &slang_shaders::GBUFFER_PREPASS_VERT,
                hr
            )
        );
        let gbuffer_instanced = rebuild_if_live!(
            self.gbuffer.instanced_pipeline.is_some(),
            build_gbuffer_prepass_pipeline(
                device,
                &static_vdesc,
                &slang_shaders::GBUFFER_PREPASS_VERT_INSTANCED,
                hr,
            )
        );
        // GPU-driven bindless G-buffer pipeline: builds its own
        // two-stream vertex descriptor internally.
        let gbuffer_bindless = rebuild_if_live!(
            self.gbuffer.bindless_pipeline.is_some(),
            build_gbuffer_bindless_pipeline(device, hr)
        );
        let ssr_resolve = rebuild_if_live!(
            self.ssr.resolve_pipeline.is_some(),
            build_ssr_pipeline(device, hr)
        );
        let reflection_composite = rebuild_if_live!(
            self.ssr.composite_pipeline.is_some(),
            build_reflection_composite_pipeline(device, hr)
        );
        let reflection_blur = rebuild_if_live!(
            self.ssr.blur_pipeline.is_some(),
            build_reflection_blur_pipeline(device, hr)
        );
        let ssgi_gather = rebuild_if_live!(
            self.ssgi.gather_pipeline.is_some(),
            build_ssgi_gather_pipeline(device, hr)
        );
        let ssgi_composite = rebuild_if_live!(
            self.ssgi.composite_pipeline.is_some(),
            build_ssgi_composite_pipeline(device, hr)
        );
        let rt_reflections = rebuild_if_live!(
            self.rt.pipeline.is_some(),
            build_rt_reflection_pipeline(
                device,
                &crate::metal::slang_shaders::RT_REFLECTIONS_FRAG,
                hr
            )
        );
        let rt_reflections_textured = rebuild_if_live!(
            self.rt.pipeline_textured.is_some(),
            build_rt_reflection_pipeline(
                device,
                &crate::metal::slang_shaders::RT_REFLECTIONS_FRAG_TEXTURED,
                hr
            )
        );

        // Skinned pre-pass variants compile from the same on-disk shader
        // sources as the static variants: only the vertex entry point and
        // 80-byte vertex layout differ. `upload_skinned` only builds these
        // when the matching static pre-pass exists, so the per-field
        // `is_some()` check here is the same gate.
        let skinned_vdesc = if self.gbuffer.skinned_pipeline.is_some()
            || self.skinned.shadow_pipeline_state.is_some()
        {
            Some(make_skinned_vertex_descriptor())
        } else {
            None
        };
        let gbuffer_skinned = rebuild_if_live!(
            self.gbuffer.skinned_pipeline.is_some(),
            build_gbuffer_prepass_pipeline(
                device,
                skinned_vdesc.as_ref().expect("skinned vdesc just built"),
                &slang_shaders::GBUFFER_PREPASS_VERT_SKINNED,
                hr,
            )
        );

        // Shadow pass shaders are engine-internal (compiled from
        // `shadow.metal`), so they rebuild here alongside the other
        // built-ins rather than in `update_world_shader_pipelines`. The static
        // shadow pipeline shares the 56-byte static layout; the skinned one
        // rides the 80-byte skinned layout.
        let shadow = rebuild_if_live!(
            self.shadow.pipeline_state.is_some(),
            build_shadow_pipeline(device, &static_vdesc, hr)
        );
        let skinned_shadow = rebuild_if_live!(
            self.skinned.shadow_pipeline_state.is_some(),
            build_skinned_shadow_pipeline(
                device,
                skinned_vdesc.as_ref().expect("skinned vdesc just built"),
                hr,
            )
        );

        // GPU-driven cascaded-shadow pipelines: the frustum-only
        // shadow cull kernel (from cull.metal) + the depth-only bindless shadow
        // render pipeline (from shadow.metal). Both engine-internal, so they
        // rebuild here. Gated on the live shadow-bindless path.
        let shadow_cull = rebuild_if_live!(
            self.cull.shadow_pipeline.is_some(),
            build_shadow_cull_pipeline(device, hr)
        );
        let shadow_bindless = rebuild_if_live!(
            self.cull.shadow_bindless_pipeline.is_some(),
            build_shadow_bindless_pipeline(device, &static_vdesc, hr)
        );

        // All builds succeeded: swap into the live context. After this
        // point the next frame's draw calls bind the freshly compiled
        // pipelines.
        self.post_pipeline_state = post;
        if let Some(b) = bloom {
            self.bloom_pipelines = Some(b);
        }
        if let Some(p) = text {
            self.text.pipeline_state = Some(p);
        }
        if let Some(p) = taa {
            self.taa.pipeline_state = Some(p);
        }
        if let Some(p) = cull {
            self.cull.pipeline = Some(p.state);
            self.cull.icb_arg_encoder = Some(p.icb_arg_encoder);
            // The phase-2 (two-pass occlusion) pipeline + ICB arg encoder come
            // from the same rebuilt library; swap them in lockstep.
            self.cull.pipeline_phase2 = Some(p.state_phase2);
            self.cull.icb_2_arg_encoder = Some(p.icb2_arg_encoder);
            // Force ICB rebuild on next frame so its argument-buffer encoding
            // re-binds to the new arg encoders the new cull kernels produced.
            // The status buffer + phase-2 ICB are rebuilt by the same
            // `ensure_icb_capacity` pass that rebuilds the phase-1 ICB.
            self.cull.icbs = Vec::new();
            self.cull.icb_arg_buffer = None;
            self.cull.icb_capacity = 0;
            self.cull.icbs_2 = Vec::new();
            self.cull.icb_2_arg_buffer = None;
            self.cull.status_buffer = None;
        }
        if let Some((init_pipeline, downsample_pipeline)) = hiz
            && let Some(h) = self.cull.hiz.as_mut()
        {
            h.swap_pipelines(init_pipeline, downsample_pipeline);
        }
        if let Some(p) = auto_ev {
            self.auto_exposure.pipelines = Some(p);
        }
        if let Some(p) = decal {
            self.decal.pipeline = Some(p);
        }
        if let Some(p) = fog {
            self.fog.pipeline = Some(p);
        }
        if let Some(p) = ssao_kernel {
            self.ssao.kernel_pipeline = Some(p);
        }
        if let Some(p) = ssao_blur {
            self.ssao.blur_pipeline = Some(p);
        }
        if let Some(p) = gbuffer_prepass {
            self.gbuffer.prepass_pipeline = Some(p);
        }
        if let Some(p) = gbuffer_instanced {
            self.gbuffer.instanced_pipeline = Some(p);
        }
        if let Some(p) = gbuffer_bindless {
            self.gbuffer.bindless_pipeline = Some(p);
        }
        if let Some(p) = ssr_resolve {
            self.ssr.resolve_pipeline = Some(p);
        }
        if let Some(p) = reflection_composite {
            self.ssr.composite_pipeline = Some(p);
        }
        if let Some(p) = reflection_blur {
            self.ssr.blur_pipeline = Some(p);
        }
        if let Some(p) = ssgi_gather {
            self.ssgi.gather_pipeline = Some(p);
        }
        if let Some(p) = ssgi_composite {
            self.ssgi.composite_pipeline = Some(p);
        }
        if let Some(p) = rt_reflections {
            self.rt.pipeline = Some(p);
        }
        if let Some(p) = rt_reflections_textured {
            self.rt.pipeline_textured = Some(p);
        }
        if let Some(p) = gbuffer_skinned {
            self.gbuffer.skinned_pipeline = Some(p);
        }
        if let Some(p) = shadow {
            self.shadow.pipeline_state = Some(p);
        }
        if let Some(p) = skinned_shadow {
            self.skinned.shadow_pipeline_state = Some(p);
        }
        if let Some((p, enc)) = shadow_cull {
            self.cull.shadow_pipeline = Some(p);
            self.cull.shadow_icb_arg_encoder = Some(enc);
            // Force the shadow ICB rebuild on the next frame so its argument
            // buffer re-binds to the freshly compiled kernel's arg encoder
            // (mirrors the main cull ICB reset above).
            self.cull.shadow_icb = None;
            self.cull.shadow_icb_arg_buffer = None;
            self.cull.shadow_icb_capacity = 0;
        }
        if let Some(p) = shadow_bindless {
            self.cull.shadow_bindless_pipeline = Some(p);
        }
        Ok(())
    }

    // Rebuild the world-loaded shader pipelines (main, optional instanced,
    // optional shadow) from freshly compiled metallib bytes. Driven by
    // asset hot-reload (`cn debug` only) when a captured `Shader`
    // source file is saved or `reload-assets` is fired. Mirrors the
    // rebuild-then-swap safety pattern of [`Self::reload_shaders`]: every
    // replacement is constructed into a temporary first, and the atomic
    // swap only runs when every build succeeds: a typo in a shader edit
    // leaves the live pipelines untouched and the session keeps rendering.
    //
    // `vert_bytes` and `frag_bytes` are always required (the main pipeline
    // is required for the world to render). `vert_instanced_bytes` is honoured
    // only when the instanced pipeline is currently live. Hot-reload cannot
    // introduce a new shader-stage kind (or drop one): that would need
    // draw-list / asset graph changes that this path doesn't support.
    //
    // Skinned variants ride the same library bytes: when the world declared
    // a `SkinnedMesh` (so `upload_skinned` ran and `skinned_pipeline_state`
    // is live), this also rebuilds the main skinned pipeline. The shadow
    // pipelines (static + skinned) and the skinned velocity / SSAO / SSR
    // pre-pass pipelines compile from disk-resident engine-internal source
    // (not from world library bytes), so they are covered by
    // [`Self::reload_shaders`]: no work here. `_shadow_bytes` is retained for
    // the cross-backend signature but unused (the shadow shader is internal).
    pub(super) 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 = vert_bytes
            .ok_or_else(|| "vertex shader bytes are required for the main pipeline".to_string())?;
        let frag_bytes = frag_bytes.ok_or_else(|| {
            "fragment shader bytes are required for the main pipeline".to_string()
        })?;

        // Build everything into temporaries first. Any `?` early-return
        // leaves the live pipelines untouched, mirroring `reload_shaders`.
        // A scene-less world never built a main pipeline; there is nothing
        // for the fresh world-shader bytes to replace.
        let vert_desc = make_vertex_descriptor();
        let new_main = if self.pipeline_state.is_some() {
            Some(build_main_pipeline(
                &self.device,
                &vert_desc,
                vert_bytes,
                frag_bytes,
                self.hot_reload.enabled,
            )?)
        } else {
            None
        };

        // Instanced pipeline depends on both the instanced vertex bytes and
        // the (potentially fresh) fragment bytes. Rebuilt only when an
        // instanced pipeline is currently live AND the caller supplied new
        // instanced vertex bytes: a world without an instanced stage keeps
        // `instanced.pipeline_state == None` and skips this branch.
        let new_instanced = if self.instanced.pipeline_state.is_some() {
            let inst_bytes = vert_instanced_bytes.ok_or_else(|| {
                "instanced vertex shader bytes are required when an instanced pipeline is live"
                    .to_string()
            })?;
            // `has_clusters = true` forces the build path (the builder
            // short-circuits on `!has_clusters`).
            let ps = build_instanced_pipeline(
                &self.device,
                &vert_desc,
                inst_bytes,
                frag_bytes,
                true,
                true,
            )?
            .ok_or_else(|| {
                "build_instanced_pipeline returned None on a forced rebuild".to_string()
            })?;
            Some(ps)
        } else {
            None
        };

        // Skinned main pipeline rides the same vert + frag library bytes as
        // the static main pipeline (just a different vertex entry point and
        // the 80-byte skinned vertex layout). Rebuilt only when a skinned
        // pipeline is currently live: a world without a `SkinnedMesh`
        // never called `upload_skinned`, so this stays `None`.
        let skinned_vdesc = if self.skinned.pipeline_state.is_some() {
            Some(make_skinned_vertex_descriptor())
        } else {
            None
        };
        let new_skinned_main = if self.skinned.pipeline_state.is_some() {
            let vdesc = skinned_vdesc.as_ref().expect("skinned vdesc just built");
            Some(build_skinned_main_pipeline(
                &self.device,
                vdesc,
                vert_bytes,
                frag_bytes,
                true,
            )?)
        } else {
            None
        };

        // The engine sampler block rides the fresh fragment's encoder; built
        // here (still before the swap) so a failure leaves the live state
        // untouched.
        let new_sampler_args = match new_main
            .as_ref()
            .and_then(|m| m.bindless_sampler_arg_encoder.as_ref())
        {
            Some(enc) => Some(super::init::pipelines::build_bindless_sampler_args(
                &self.device,
                enc,
                &self.sampler,
                &self.shadow.sampler,
                &self.cube_sampler,
            )?),
            None => None,
        };

        // All builds succeeded: swap into the live context. After this
        // point the next frame's draw calls bind the freshly compiled
        // pipelines.
        if let Some(new_main) = new_main {
            let MainPipelineBundle {
                pipeline_state,
                bindless,
                cull_pipeline,
                cull_icb_arg_encoder,
                cull_pipeline_phase2,
                cull_icb2_arg_encoder,
                bindless_tex_arg_encoder,
                bindless_sampler_arg_encoder: _,
            } = new_main;
            self.pipeline_state = Some(pipeline_state);
            // Swap the bindless flag + dependent state. The flag is the
            // bindless-vs-legacy switch for the static draw loop; if it changed
            // (e.g. the user toggled `fragment_main_bindless` on or off in
            // their shader), the ICB also has to be rebuilt because the new
            // arg encoder produces a different encoding shape.
            self.bindless = bindless;
            self.cull.pipeline = cull_pipeline;
            self.cull.icb_arg_encoder = cull_icb_arg_encoder;
            // Second-pass (two-pass occlusion) pipeline + ICB arg encoder swap with
            // the rest of the bundle. `two_pass_occlusion` is left as the init-time
            // resolution: a shader edit that drops `fragment_main_bindless` leaves
            // `cull_pipeline_phase2` None, and `ensure_icb_capacity` then skips the
            // phase-2 ICB while the graph builder skips the phase-2 nodes.
            self.cull.pipeline_phase2 = cull_pipeline_phase2;
            self.cull.icb_2_arg_encoder = cull_icb2_arg_encoder;
            self.bindless_tex_arg_encoder = bindless_tex_arg_encoder;
            self.bindless_sampler_args = new_sampler_args;
            // Force a fresh ICB on the next frame so its argument-buffer encoding
            // re-binds to the new encoder. Matches the `cull` swap in
            // `reload_shaders`; the phase-2 ICB + status buffer rebuild alongside.
            self.cull.icbs = Vec::new();
            self.cull.icb_arg_buffer = None;
            self.cull.icb_capacity = 0;
            self.cull.icbs_2 = Vec::new();
            self.cull.icb_2_arg_buffer = None;
            self.cull.status_buffer = None;
        }

        if let Some(ps) = new_instanced {
            self.instanced.pipeline_state = Some(ps);
        }
        if let Some(ps) = new_skinned_main {
            self.skinned.pipeline_state = Some(ps);
        }
        Ok(())
    }
}