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
//! The stages `MtlContext::draw_frame` runs in order around its render-graph
//! dispatch.
use concinnity_core::gfx::frustum::Frustum;
use concinnity_core::gfx::jitter;
use concinnity_core::gfx::render_types::{self, LineVertex};
use concinnity_core::gfx::view_modes::{ShowFlags, ViewMode};
use concinnity_core::profile;
use concinnity_core::render::depth::camera_projection;
use concinnity_core::render::error;
use concinnity_core::render::model_history::HistoryMode;
use concinnity_core::render::reactive_mask::ReactiveReader;
use concinnity_core::render::render_graph::{self, FrameGraphInputs};
use concinnity_core::render::shadow_schedule::{CascadeCamera, CascadeLight};
use concinnity_core::render::view_history::ViewFrame;
use concinnity_core::render::volumetric_fog::FogSettings;
use concinnity_core::transform::mat4_inverse;
use concinnity_core::transform::mat4_mul;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_metal::{MTLBuffer, MTLCommandBuffer, MTLDevice as _};
use objc2_quartz_core::CAMetalDrawable;
use crate::metal::context::MtlContext;
use crate::metal::frame_pacing::{FrameJoin, FrameSlot, SubmissionToken};
// A frame past the frames-in-flight fence with a drawable to present into.
pub(super) struct AcquiredFrame {
pub(super) frame_slot: FrameSlot,
pub(super) drawable: Retained<ProtocolObject<dyn CAMetalDrawable>>,
pub(super) frame_id: u64,
pub(super) ring_slot: usize,
}
// The camera projection, its jittered view-projection, and what derives from it.
pub(super) struct FrameProjection {
pub(super) proj: [[f32; 4]; 4],
pub(super) vp: [[f32; 4]; 4],
pub(super) inv_vp: [[f32; 4]; 4],
pub(super) frustum: Frustum,
}
// The per-frame locals `frame_graph_inputs` gates passes on.
pub(super) struct GraphInputArgs<'a> {
pub(super) bindless_cull_enabled: bool,
pub(super) velocity_active: bool,
pub(super) fog_settings: Option<&'a FogSettings>,
pub(super) transparent_active: bool,
pub(super) lines: &'a [LineVertex],
pub(super) world_hidden: bool,
pub(super) clustered: bool,
pub(super) view_mode: ViewMode,
pub(super) show: ShowFlags,
}
// The recorded frame's presenting command buffer and its completion join.
pub(super) struct PresentFrame {
pub(super) cmd_buf: Retained<ProtocolObject<dyn MTLCommandBuffer>>,
pub(super) drawable: Retained<ProtocolObject<dyn CAMetalDrawable>>,
pub(super) join: std::sync::Arc<FrameJoin>,
pub(super) composite_span_us: std::sync::Arc<std::sync::atomic::AtomicU32>,
pub(super) pending_terminal: Option<u64>,
pub(super) submission_token: SubmissionToken,
}
// The per-frame inputs the scene buffer builds read.
pub(super) struct SceneBufferArgs<'a> {
pub(super) ring_slot: usize,
pub(super) frame_id: u64,
pub(super) cam_pos: [f32; 3],
pub(super) elapsed: f32,
pub(super) world_hidden: bool,
pub(super) skinned_joint_bufs: &'a [Retained<ProtocolObject<dyn MTLBuffer>>],
// This frame's `bindless_texture_signature`.
pub(super) texture_signature: u64,
}
// This frame's bindless-path buffers. All three are `None` while the world is
// hidden, and individually `None` when the path that fills them is inactive.
pub(super) struct SceneBuffers {
pub(super) object_buffer: Option<Retained<ProtocolObject<dyn MTLBuffer>>>,
pub(super) material_params: Option<Retained<ProtocolObject<dyn MTLBuffer>>>,
pub(super) cull_draw_args: Option<Retained<ProtocolObject<dyn MTLBuffer>>>,
pub(super) bindless_tex_args: Option<Retained<ProtocolObject<dyn MTLBuffer>>>,
}
// The motion-history buffers the GPU-driven G-buffer pre-pass binds.
pub(super) struct HistoryBuffers {
pub(super) deformed_this_frame: Option<Retained<ProtocolObject<dyn MTLBuffer>>>,
pub(super) deformed_prev_frame: Option<Retained<ProtocolObject<dyn MTLBuffer>>>,
pub(super) prev_model_buffer: Option<Retained<ProtocolObject<dyn MTLBuffer>>>,
pub(super) history_targets: Vec<Retained<ProtocolObject<dyn MTLBuffer>>>,
}
impl MtlContext {
pub(super) fn begin_frame_stats(&mut self) -> usize {
// Reset this frame's render stats; the draw counters below accumulate
// into `diagnostics.frame_stats`, and `render_stats()` reports them (plus the GPU
// frame time) to the profiler overlay.
let counts = crate::object_counts::object_counts(
self.state.draw.objects.len(),
self.instanced.clusters.iter().map(|c| c.instances.len()),
self.state.skinned.draw_objects.iter().map(|o| o.visible),
);
self.diagnostics.frame_stats = profile::RenderStats {
objects: counts.objects,
skinned_visible: counts.skinned_visible,
// On Apple Silicon's unified memory this is the Metal device's
// allocation within system RAM.
vram_bytes: self.hw.device.currentAllocatedSize() as u64,
transient_pool_bytes: self.targets.transient_pool.heap_bytes(),
..profile::RenderStats::default()
};
// Rotate the per-frame sample-buffer slot if per-pass GPU timing is
// available. Every `diagnostics.pass_timing.attach_*` call this frame writes
// into the same slot's buffer; the completion handler resolves it
// after the frame retires.
self.diagnostics
.pass_timing
.as_mut()
.map(|p| p.begin_frame())
.unwrap_or(0)
}
// Returns false once the window has closed.
pub(super) fn pump_window_events(&mut self, mtm: objc2::MainThreadMarker) -> bool {
// Drain all pending NSEvents so the window stays responsive. The
// preview tab leaves `pump_events` false so the host owns event
// delivery (pumping there would dequeue mouse clicks meant for the
// tab bar before they reach their targets); the windowed CLI path
// and the blocking-in-view play path opt in.
if self.window().appkit.pump_events() {
self.window_mut().appkit.pump_ns_events(mtm);
if self.window_closed() {
return false;
}
}
// Converge the display on the fullscreen state: hold the chosen mode
// while the window is fullscreen, restore the desktop mode otherwise.
// Runs off the delegate-tracked flag so OS-driven fullscreen exits
// (green traffic-light button, Mission Control) restore too. Cheap
// when nothing changed.
self.window_mut().appkit.reconcile_display_mode();
true
}
// None when no drawable is ready, which skips the frame.
pub(super) fn acquire_frame(&mut self, world_hidden: bool) -> Option<AcquiredFrame> {
// Frames-in-flight gate: block until the GPU has retired an older frame
// so the CPU never queues more than `frames_in_flight` frames ahead,
// bounding how many sets of per-frame transient buffers pile up. Taken
// here, before drawable prep and all per-frame buffer building. The slot
// is handed to the frame command buffer's completion handler below
// (released on GPU retirement); if this frame is abandoned before commit
// (the drawable isn't ready, or a `?` fails mid-encode) `frame_slot`
// drops and releases the slot synchronously, keeping the count balanced.
// Both blocking calls this frame makes are measured into `gpu_wait`
// and published on the frame's stats: this one and the drawable
// acquire below. They are wall time inside `draw_frame`, which the
// engine times its graphics system around, so a GPU-bound frame is
// only distinguishable from a CPU-bound one with this reading.
let mut gpu_wait = crate::gpu_wait::GpuWait::none();
let frame_slot = gpu_wait.measure(|| self.frame_pacing.acquire());
// Asynchronous reflection-probe bake, prefiltering half: convolve one mip of
// a finished capture or install its cube, ahead of the argument buffers
// below so an installed cube is sampled this frame. The capture half runs
// once those buffers exist. Runs AFTER `acquire()` so the bake's retire-pool
// collection sees a fence-consistent frame id. Skipped while the world is
// hidden: a probe bake feeds reflections no pass will sample this frame.
if !world_hidden {
self.advance_probe_prefilter();
}
// tell MTKView to prepare its drawable for this frame, then take it.
// `currentDrawable` blocks on the drawable pool when every drawable is
// still with the compositor, which is the display-paced half of the
// frame's GPU wait.
let drawable = gpu_wait.measure(|| {
self.window().view.draw();
self.window().view.currentDrawable()
});
self.diagnostics.frame_stats.gpu_wait_us = gpu_wait.micros();
// drawable not yet available -- skip this frame silently
let drawable = drawable?;
// This frame's transient-buffer ring slot. The fence guarantees the
// frame that last used `frame_ring_index - frames_in_flight` has retired
// on the GPU, so overwriting this slot's buffers can't race an in-flight
// read. Advanced once per built frame; skipped frames (no drawable) bail
// above without consuming a slot.
let frame_id = self.frame_ring_index;
let ring_slot = (frame_id % self.frames_in_flight as u64) as usize;
self.frame_ring_index = frame_id.wrapping_add(1);
// Hand back the pooled ranges whose retire frame has passed and release
// any heap left holding nothing. Ticked here, past the fence, so a range
// is only reused once every frame that could reference it has retired.
self.hw.allocator.begin_frame();
Some(AcquiredFrame {
frame_slot,
drawable,
frame_id,
ring_slot,
})
}
// Rebuilds requested since last frame: hot-reloaded shaders.
pub(super) fn apply_pending_rebuilds(&mut self) -> error::RenderResult<()> {
// Shader hot-reload: if the debug `reload-shaders` command set the
// flag, rebuild every built-in pipeline from disk-resident source
// before the frame's passes start using them. The flag is cleared
// regardless of outcome so a failed rebuild (typo in a shader edit)
// doesn't loop, and the previous pipelines stay live so the session
// keeps rendering; only a device failure propagates. In-flight command
// buffers retain the pipelines they encoded, so no GPU drain is needed.
if self.shader_reload_requested() {
self.clear_shader_reload_flag();
match self.reload_shaders() {
Ok(()) => tracing::info!("hot-reload: shader pipelines rebuilt"),
Err(e) if e.is_device_failure() => return Err(e),
Err(e) => tracing::error!("hot-reload: shader rebuild failed: {}", e),
}
}
Ok(())
}
pub(super) fn service_background_work(&mut self, elapsed: f32, ring_slot: usize) {
// Update auto-exposure from the previous frame's GPU-measured average
// log-luminance, *before* any pass reads `self.post_process.exposure`
// (the bloom prefilter and composite both consume it). A no-op when
// auto-exposure is disabled -- the static authored EV then drives the
// exposure multiplier unchanged.
self.update_auto_exposure(elapsed, ring_slot);
}
// Picks this frame's shadow cascades and spot slices; returns the cascade aspect.
pub(super) fn update_shadow_schedule(
&mut self,
cam_pos: [f32; 3],
fov_y_radians: f32,
near: f32,
view_distance: Option<f32>,
) -> f32 {
// Compute per-frame cascade VPs + splits from current camera + light.
// The aspect/near are taken from the same params used by the main
// perspective below so cascades match the visible camera frustum.
let cascade_aspect = {
let s = self.window().view.drawableSize();
if s.height == 0.0 {
1.0
} else {
(s.width / s.height) as f32
}
};
if self.shadow.enabled {
let camera = CascadeCamera {
view: self.state.view.matrix,
position: cam_pos,
fov_y_rad: fov_y_radians,
aspect: cascade_aspect,
near,
view_distance,
};
let shadow = &mut self.shadow;
let light = CascadeLight {
dir_to_source: shadow.light_dir,
map_size: shadow.map_size,
};
// Cascades skipped this frame keep the VP their slice was rendered
// with, so the Main pass samples each slice consistently.
shadow.render_mask =
shadow
.scheduler
.refresh(&mut shadow.uniforms, &shadow.cadence, light, camera);
}
// Spot shadow slices refresh on their own prime-then-round-robin clock;
// their projections are static, so only the depth contents redraw.
self.spot_shadow.render_mask = self.next_spot_shadow_mask();
cascade_aspect
}
pub(super) fn frame_projection(
&mut self,
fov_y_radians: f32,
aspect: f32,
near: f32,
view_distance: Option<f32>,
render_w: u32,
render_h: u32,
) -> FrameProjection {
// View-projection + GPU-driven cull.
// The projection / jitter / VP are resolved here, ahead of the main
// render encoder, because the cull compute pass needs the frustum
// before the render pass begins.
let proj = camera_projection(fov_y_radians, aspect, near);
// This frame's un-jittered VP, captured before the graph runs so the
// two-pass phase-2 cull (`encode_cull_phase2`, dispatched inside
// `execute_graph`) can project AABBs through it against the pyramid the
// mid-frame `HizBuild` rebuilds from this frame's depth. The same value
// becomes `cull_prev_view_proj` at end-of-frame for next frame's phase 1.
self.cull.cur_view_proj = mat4_mul(proj, self.state.view.matrix);
// When TAA or the MetalFX upscaler is on, offset the projection by
// a sub-pixel Halton jitter so the temporal accumulator has fresh
// sample positions each frame. The jitter is a pure NDC x/y shift,
// so depth is unaffected. `proj[2][0/1]` are the z-coefficients of
// clip x/y; subtracting the jitter there shifts post-divide NDC by
// exactly the jitter amount (clip.w == -view_z). Pixel-space
// jitter (`±0.5` per axis) is stashed for MetalFX, which expects
// its input in pixel coords; TAA reads NDC directly.
let needs_jitter = self.taa.enabled || self.upscale.scaler.is_some();
let proj_render = if needs_jitter {
let [jx_pix, jy_pix] = jitter::offset_in_cycle(self.taa.frame, 8);
let jx = jx_pix * 2.0 / render_w as f32;
let jy = jy_pix * 2.0 / render_h as f32;
if self.upscale.scaler.is_some() {
self.upscale
.jitter
.store(jx_pix, jy_pix, std::sync::atomic::Ordering::Release);
}
let mut p = proj;
p[2][0] -= jx;
p[2][1] -= jy;
p
} else {
proj
};
let vp = mat4_mul(proj_render, self.state.view.matrix);
// Inverse of the (jittered) view-projection, computed once here and
// threaded through `GraphFrameParams` to every pass that reconstructs a
// world-space position from depth (fog, decals, raymarch, transparent),
// instead of each pass re-inverting `vp` independently.
let inv_vp = mat4_inverse(vp);
let frustum = Frustum::from_camera(vp, view_distance);
FrameProjection {
proj,
vp,
inv_vp,
frustum,
}
}
// This frame's graph inputs: the passes the live resources and settings run,
// masked by the viewport's view mode and show flags.
pub(super) fn frame_graph_inputs(&self, args: GraphInputArgs<'_>) -> FrameGraphInputs {
let GraphInputArgs {
bindless_cull_enabled,
velocity_active,
fog_settings,
transparent_active,
lines,
world_hidden,
clustered,
view_mode,
show,
} = args;
let reflection_path = self.reflection_path();
let graph_inputs = FrameGraphInputs {
shadow_enabled: self.shadow.enabled,
shadow_map_size: self.shadow.map_size,
hdr_width: self.targets.hdr.width,
hdr_height: self.targets.hdr.height,
hdr_sample_count: self.targets.hdr.sample_count,
bindless_cull_enabled,
auto_exposure_enabled: self.auto_exposure.pipelines.is_some(),
// Gated on the chain existing: a scene-less world builds none.
bloom_enabled: self.post_process.bloom_intensity > 0.0 && self.bloom.is_some(),
// Velocity runs whenever something reprojects through it: TAA,
// the upscaler, or the SSGI accumulation. TaaResolve / Upscale /
// Ssgi declare a read edge on it for ordering.
velocity_enabled: velocity_active,
taa_enabled: self.taa.enabled,
ssr_enabled: reflection_path.ssr_resolve,
particles_enabled: self.particle.pipelines.is_some()
&& !self.particle.records.is_empty()
&& !self.particle.emitter_state.is_empty(),
fog_enabled: self.fog.pipeline.is_some() && fog_settings.is_some(),
decals_enabled: self.decal.pipeline.is_some() && !self.decal.set.is_empty(),
// The SSR depth + normal + roughness pre-pass also feeds SSGI and
// the RT-reflection kernel, so it runs when SSR, SSGI, *or* RT
// reflections are on (RT keys off the live acceleration structure).
ssr_prepass_enabled: self.ssr.settings.is_some()
|| self.ssgi.settings.is_some()
|| self.rt.accel.is_some(),
ssao_enabled: self.ssao.settings.is_some(),
upscale_enabled: self.upscale.scaler.is_some(),
// Transparent pass runs when at least one translucent producer
// (`WaterSurface` or `GlassPanel`) exists; the executor
// short-circuits an empty draw list, but gating here keeps the
// graph builder from inserting the slot at all.
transparent_enabled: transparent_active,
planar_reflection_enabled: self.planar_mirrors_needed(),
// Lines run only on the frames a system published them (the
// `cn editor` axes), and only once their pipeline is live: the
// build above is lazy, so a shipped runtime never compiles it.
lines_enabled: !lines.is_empty() && self.lines.pipeline.is_some(),
// Raymarch runs when at least one `SdfVolume` is live; the
// per-volume pipeline cache is populated in lockstep with
// the volume vec at init. Tightened to the real `is_some()
// && !empty()` predicate once the context fields land
// alongside `encode_raymarch` (see metal/raymarch.rs).
raymarch_enabled: !self.raymarch.volumes.is_empty(),
// Two-pass Hi-Z occlusion. Resolved from
// `PostProcessConfig.occlusion_two_pass` (and gated at init on the
// bindless cull path existing). The graph builder further ANDs this
// with `bindless_cull_enabled` for this frame, so a frame with no
// static geometry simply runs single-pass. When on, the builder
// inserts HizBuild → Cull2 → Main2 between Main and the post chain.
two_pass_occlusion_enabled: self.cull.two_pass_occlusion,
// The terminal Hi-Z build. Present whenever the GPU-cull path built a
// pyramid: the frame ends by reducing its final depth into it for the
// next frame's phase-1 occlusion test.
hiz_build_enabled: self.cull.hiz.is_some(),
// The mask is one of the HDR targets, so it always exists.
reactive_mask_enabled: true,
upscale_reads_reactive: ReactiveReader::MetalFx.reads()
&& self.upscale.scaler.as_ref().is_some_and(|s| s.reactive),
// SSGI runs when `indirect_lighting: "ssgi"` resolved settings that
// contribute: the composite scales by intensity, so zero would pay a
// hemisphere trace to add nothing. The builder inserts the Ssgi
// RMW pass after Raymarch on the hdr_resolve chain; the trace reads
// the SSR pre-pass G-buffer (forced on above via
// `ssr_prepass_enabled`).
ssgi_enabled: self.ssgi.settings.is_some_and(|s| s.contributes()),
// RT reflections run when the scene acceleration structure is live
// (RT requested + GPU supports it + scene has geometry). The builder
// inserts the RtReflections pass in the SsrResolve slot, which a live
// trace takes from SSR.
rt_reflections_enabled: reflection_path.rt_trace,
// Metal collapses the SSR / SSAO / velocity pre-passes into one
// GBufferPrepass node; the other backends keep them separate.
gbuffer_prepass_enabled: true,
// An opaque menu backdrop hides the scene: the builder masks every
// world pass off, collapsing to Main (a bare clear, fed the empty
// scene above) -> Composite (presents the overlay).
world_hidden,
// Clustered binning runs while a local light or a baked probe is
// live. The builder inserts LightCull before Main, and Main, the SSR
// resolve and the transparent pass read its per-cluster lists.
clustering_enabled: clustered,
// Set by the view-mode mask below (occlusion view only).
composite_reads_ao: false,
composite_reads_motion: false,
composite_reads_reactive: false,
shadowed_spot_count: self.spot_shadow.count,
spot_shadow_slice_size: render_types::spot_shadow_slice_size(self.shadow.map_size),
};
// The viewport's view mode + show flags mask the seeded inputs (the
// per-frame counterpart of the init-time trims); Lit with every flag
// set is the identity, so a shipped runtime is unaffected.
render_graph::apply_view(&graph_inputs, view_mode, show)
}
pub(super) fn submit_and_present(&mut self, frame: PresentFrame) {
let PresentFrame {
cmd_buf,
drawable,
join,
composite_span_us,
pending_terminal,
submission_token,
} = frame;
cmd_buf.presentDrawable(ProtocolObject::from_ref(&*drawable));
// Retain this drawable's color texture so the headless `screenshot`
// command can blit the last presented frame back to the host. Only
// under `hot_reload` (the `cn debug` path that runs the debug endpoint able
// to request a capture, and the only path where the MTKView has
// `framebufferOnly` switched off so this texture is blit-readable);
// production keeps this `None`. Reading it next frame is safe: the
// composite pass that wrote it committed earlier on the same queue, so
// same-queue FIFO order guarantees it is fully rendered, and a
// read-only blit may run alongside the compositor's scan-out.
if self.capture {
self.last_present_texture = Some(drawable.texture());
}
// The presenting command buffer's own completion handler. It reports
// the buffer's fault status and publishes its GPU span as the
// whole-frame fallback, then arrives at the frame's completion join;
// the per-pass resolve and the frame-slot release belong to the join's
// last arrival, since the async queue may still be running.
{
let device_error = std::sync::Arc::clone(&self.diagnostics.device_error);
let part = std::sync::Arc::clone(&join);
join.add_part();
let handler = block2::RcBlock::new(
move |cb: std::ptr::NonNull<ProtocolObject<dyn objc2_metal::MTLCommandBuffer>>| {
// SAFETY: Metal hands the completion handler a live command buffer, and the
// borrow does not escape the block.
let cb = unsafe { cb.as_ref() };
crate::metal::fault_log::report_fault(cb, "frame render");
use objc2_metal::MTLCommandBufferStatus;
if cb.status() == MTLCommandBufferStatus::Error {
// Classify and park the first failure for the next
// draw_frame to report across the backend boundary.
let classified = match cb.error() {
Some(e) => crate::metal::error::classify_ns_error(&e),
None => error::RenderError::Other(
"frame command buffer faulted without an error object".to_string(),
),
};
if let Ok(mut slot) = device_error.lock()
&& slot.is_none()
{
*slot = Some(classified);
}
}
// This buffer is one slice of a multi-buffer, two-queue
// frame, so its own span under-reports the frame; it is only
// the fallback for a device with no per-pass timing.
// GPUStartTime / GPUEndTime are valid only inside the handler.
let span = cb.GPUEndTime() - cb.GPUStartTime();
composite_span_us.store(
(span * 1.0e6).clamp(0.0, f64::from(u32::MAX)) as u32,
std::sync::atomic::Ordering::Relaxed,
);
// Fires on success and on GPU fault alike, so the join can
// never leak the frame's slot.
part.arrive();
},
);
// SAFETY: addCompletedHandler copies the block (Block_copy), so
// the RcBlock is free to drop when this scope ends.
unsafe {
cmd_buf.addCompletedHandler(block2::RcBlock::as_ptr(&handler));
}
}
cmd_buf.commit();
// The graphics queue's frame terminal rides that buffer, so the next
// frame may only wait on it now that it has been committed.
if let Some(value) = pending_terminal {
self.record_graph_terminal(value);
}
// Recording is done: release the join's submission part so the frame
// can complete once every command buffer has retired.
drop(submission_token);
}
// Drop every accumulated temporal history for a frame that does not
// continue the last: the TAA and SSGI rings, the camera half of the motion
// history, the Hi-Z pyramid the occlusion test would reproject the old view
// through, and MetalFX's history on its next encode.
pub(super) fn reset_temporal_history(&mut self) {
self.cull.hiz_valid = false;
if let Some(taa) = self.taa.pass.as_mut() {
taa.reset_history();
}
if let Some(ssgi) = self.ssgi.pass.as_mut() {
ssgi.reset_history();
}
self.view_history.reset();
self.upscale.reset.request();
}
pub(super) fn advance_temporal_state(&mut self, velocity_active: bool, cur: ViewFrame) {
// The Hi-Z reduction that feeds next frame's cull is the graph's terminal
// `HizFinal` pass, so it has already been encoded. Advance the temporal
// state it depends on: the pyramid is now valid for next frame's cull, and
// the un-jittered VP captured at the top of the frame becomes the
// projection that cull tests through (distinct from the velocity
// pre-pass's `view_history`, which only advances when velocity runs).
if self.cull.hiz.is_some() {
self.cull.hiz_valid = true;
self.cull.prev_view_proj = self.cull.cur_view_proj;
}
// Advance temporal state for the next frame whenever the velocity
// pre-pass runs: TAA, the MetalFX upscaler or SSGI. The un-jittered VP,
// clock and camera position become the previous frame the pre-pass
// reprojects to; the per-object transforms were snapshotted on the GPU
// by the pre-pass's own history dispatch. A frame without it drops the
// camera history, so motion restarts from its own view. TAA-specific
// bookkeeping (history-target ping-pong) only runs when TAA itself is on.
if velocity_active {
self.view_history.advance(cur);
self.taa.frame = self.taa.frame.wrapping_add(1);
if let Some(taa) = self.taa.pass.as_mut() {
taa.advance();
}
} else {
self.view_history.reset();
}
// What the SSGI accumulation wrote this frame is next frame's history.
if let Some(ssgi) = self.ssgi.pass.as_mut() {
ssgi.advance();
}
}
// Resizes the off-screen targets to this frame's drawable and returns the
// render resolution the scene and post passes draw at.
pub(super) fn resize_frame_targets(&mut self) -> error::RenderResult<(u32, u32)> {
// Main pass prep: resize off-screen targets.
// Resize the HDR targets if the drawable size changed (window resize
// or initial layout). The drawable was just refreshed by window.view.draw().
let draw_size = self.window().view.drawableSize();
// Geometry-less worlds keep their off-screen targets pinned at 1x1
// (see MtlContext::new); the composite pass still uses the full drawable.
let (want_w, want_h) = if self.targets.geometry_less {
(1, 1)
} else {
(
draw_size.width.max(1.0) as u32,
draw_size.height.max(1.0) as u32,
)
};
self.resize_targets_if_needed(want_w, want_h)?;
// Render resolution: where the 3D scene + most post passes draw.
// Equals `want_w/h` (the drawable size) when no upscaler is active;
// otherwise it's smaller, so the upscaler reconstructs back up to
// drawable size.
let render_w = self.targets.hdr.width;
let render_h = self.targets.hdr.height;
Ok((render_w, render_h))
}
// This frame's probe records, plus the residency the bindless block's
// contents need. Returns the block's texture signature, which the block's
// own write reuses.
pub(super) fn refresh_probe_records_and_residency(
&mut self,
ring_slot: usize,
) -> error::RenderResult<u64> {
// The probe records, for every pass that samples the set. Built ahead
// of `build_scene_buffers` and outside its world-hidden gate: the
// transparent and post passes read the set without a static draw list
// of their own, and a slot left holding last frame's ring buffer would
// outlive the frame that wrote it.
self.probe.records_buf = Some(self.build_probe_records(ring_slot)?);
// The residency the bindless block's contents need. Refreshed before
// any pass encodes, and a no-op on a frame whose textures are
// unchanged, which is every frame between a stream-in or a bake.
let sig = self.bindless_texture_signature();
self.refresh_bindless_residency(sig);
Ok(sig)
}
// The per-frame GPU buffers the world's passes consume, plus the probe
// capture and acceleration-structure refresh that ride the same gate.
pub(super) fn build_scene_buffers(
&mut self,
args: SceneBufferArgs<'_>,
) -> error::RenderResult<SceneBuffers> {
let SceneBufferArgs {
ring_slot,
frame_id,
cam_pos,
elapsed,
world_hidden,
skinned_joint_bufs,
texture_signature,
} = args;
// While the world is hidden behind an opaque menu, the surviving Main
// pass is fed an empty scene -- no bindless object / cull / texture
// buffers, no instanced clusters, and no acceleration-structure refresh
// -- so it runs as a bare clear that the opaque overlay then covers. The
// masked graph drops every other world pass, so none of this work would
// be consumed anyway.
// The GPU-driven G-buffer pre-pass both fills and reads the model-history
// ring. With no consumer of motion, or with the pre-pass not running,
// the ring goes stale, so the draw-args build marks every record
// `NO_HISTORY` and the tracker re-primes when the pre-pass returns.
let history_live = !world_hidden
&& self.reads_motion()
&& self.gbuffer.targets.is_some()
&& self.cull.main_pipeline.is_some();
let (object_buffer, material_params, cull_draw_args, bindless_tex_args) = if world_hidden {
(None, None, None, None)
} else {
// Per-frame GPU buffer prep for the bindless path.
// The object data + indirect-args + bindless texture argbuf are
// all per-frame Metal buffers the bindless Main pass + Cull
// compute pass consume. They must outlive the command buffer,
// hence the bindings handed back to the caller, which holds them
// through to `cmd_buf.commit()`.
let object_buffer = if self.cull.bindless {
self.build_object_buffer(ring_slot)?
} else {
None
};
let material_params = match object_buffer {
Some(_) => Some(
self.rings
.material_params
.buffer(&self.hw.device, ring_slot)?,
),
None => None,
};
let cull_draw_args = if object_buffer.is_some() {
let draw_args = self.build_draw_args_buffer(
cam_pos,
ring_slot,
if history_live {
HistoryMode::Track
} else {
HistoryMode::Stale
},
)?;
if draw_args.is_some() {
self.ensure_icb_capacity(self.cull_count())?;
// GPU-driven shadow views: size the cascade ICB to
// NUM_SHADOW_CASCADES * cull_count and the spot ICB to one
// region per slice. A no-op when the shadow-bindless path is
// inactive (no shadow cull pipeline).
self.ensure_shadow_icb_capacity(self.cull_count())?;
// Per-planar-slot mirror cull ICBs: one per distinct reflection
// plane, each sized to cull_count. A no-op (clears the slots) when
// the world has no planar set (RT on, or no flat reflectors).
let mirror_slots = self
.planar_reflection
.as_ref()
.map(|s| s.layout.planes().len())
.unwrap_or(0);
self.ensure_mirror_icb_capacity(mirror_slots, self.cull_count())?;
}
draw_args
} else {
None
};
let bindless_tex_args = if object_buffer.is_some() {
self.build_bindless_texture_args(ring_slot, texture_signature)?
} else {
None
};
// Asynchronous reflection-probe bake, capture half: submit one cube
// face, or start or hand off a capture. Every face samples through
// this frame's texture arguments, so it never reads a texture that
// streaming has since replaced.
self.advance_probe_capture(&crate::metal::probe::ProbeFrame {
elapsed,
tex_args: bindless_tex_args.as_ref(),
});
// Keep the RT acceleration structure current with this frame's
// transforms before any pass reads `rt_accel`. The default `Auto` mode
// rebuilds the TLAS only when a participating prop actually moved; a
// fully static scene pays just a matrix compare here. Non-fatal: a
// transient rebuild failure keeps last frame's BVH rather than stopping
// the renderer.
self.rt_dynamic_update(
crate::metal::raytrace::RtFrame {
id: frame_id,
ring_slot,
},
skinned_joint_bufs,
);
(
object_buffer,
material_params,
cull_draw_args,
bindless_tex_args,
)
};
Ok(SceneBuffers {
object_buffer,
material_params,
cull_draw_args,
bindless_tex_args,
})
}
// The skinned deformed-vertex and model-history ring slots the GPU-driven
// G-buffer pre-pass reads and writes.
pub(super) fn build_history_buffers(
&mut self,
ring_slot: usize,
object_buffer_live: bool,
) -> error::RenderResult<HistoryBuffers> {
// This frame's skinned deformed-vertex buffer (skinned fold), cloned into
// a local so `params` owns a handle rather than borrowing `self.skinned`
// across the `&mut self` execute_graph call (every other GraphFrameParams
// buffer is likewise a local). `Some` only when the fold is active
// (draw.n_skinned > 0, set in upload_skinned under bindless + static geometry);
// the Cull pass writes it via encode_main_skin and the Main / Main2
// skinned ICB tail binds it.
let deformed_this_frame = if self.state.draw.n_skinned > 0 {
self.skinned.deformed.get(ring_slot).cloned()
} else {
None
};
// The previous frame's deformed slot (one behind in the ring), read by
// the GPU-driven G-buffer skinned tail for per-vertex skin motion. The
// priming gate (`deformed_primed`) covers the unposed first frame.
let deformed_prev_frame = if self.state.draw.n_skinned > 0 {
let prev_slot = (ring_slot + self.frames_in_flight - 1) % self.frames_in_flight;
self.skinned.deformed.get(prev_slot).cloned()
} else {
None
};
// Model-history ring slots for the GPU-driven G-buffer pass: the one the
// previous frame's snapshot filled, which this frame reprojects through,
// and the one(s) this frame's snapshot fills. Both are bound whenever the
// pre-pass runs, motion consumer or not -- the pass still writes the
// normals and depth every screen-space consumer reads. Priming writes
// every slot, so the first pre-pass after a rebuild reads this frame's
// models rather than an unwritten buffer.
let (prev_model_buffer, history_targets) = if object_buffer_live
&& self.gbuffer.targets.is_some()
&& self.cull.main_pipeline.is_some()
{
let bytes = self.cull_count() * std::mem::size_of::<[[f32; 4]; 4]>();
let prime = self.state.model_history.get_mut().take_prime();
let read_slot = (ring_slot + self.frames_in_flight - 1) % self.frames_in_flight;
let mut targets = Vec::new();
if prime {
for slot in 0..self.frames_in_flight {
targets.push(
self.rings
.model_history
.slot(&self.hw.device, slot, bytes)?,
);
}
} else {
targets.push(
self.rings
.model_history
.slot(&self.hw.device, ring_slot, bytes)?,
);
}
let read = self
.rings
.model_history
.slot(&self.hw.device, read_slot, bytes)?;
(Some(read), targets)
} else {
(None, Vec::new())
};
Ok(HistoryBuffers {
deformed_this_frame,
deformed_prev_frame,
prev_model_buffer,
history_targets,
})
}
}