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
// src/metal/quality.rs
//
// Runtime application of the Quality-group settings (TAA / SSAO / SSR / RT
// reflections / SSGI / auto-exposure). Each gates a render pass whose GPU
// resources (pipelines, render targets, the ray-tracing acceleration structure)
// are built once at init from the world's PostProcessConfig, so applying a
// change at runtime means rebuilding those resources, not flipping a uniform.
//
// The rebuild reuses `init::effects::build_quality_effects` -- the exact path
// `MtlContext::new` runs -- so a live toggle produces resources byte-identical
// to a launch with the same config. Only the toggle-controlled subset is rebuilt;
// bloom, decals, fog, particles, and the uploaded geometry are untouched (so no
// particle-sim reset and no multi-second geometry re-upload).
use crate::gfx::backend::QualitySettings;
use super::context::MtlContext;
use super::init::effects::{
EffectDimensions, EffectFlags, EffectSettings, QualityEffectsBundle, build_quality_effects,
};
use super::raytrace::{
RtGpu, RtSceneGeometry, RtStaticGeometry, RtTextureCounts, build_rt_accel, raytracing_supported,
};
impl MtlContext {
// Turn display sync (vsync) on or off at runtime via the view's backing
// CAMetalLayer. Setting displaySyncEnabled is an idempotent property write
// (no swapchain rebuild on Metal), so a redundant call is cheap. Backend
// specific: Vulkan reaches the same end by rebuilding the swapchain with a
// different present mode, so this does not live on the shared window layer.
pub(crate) fn set_vsync(&mut self, on: bool) {
super::init::set_display_sync(&self.window.view, on);
}
// Replace the live post-process tunables. They are pushed to the bloom
// prefilter + composite shaders every frame (see draw/composite.rs), so a
// change takes effect on the next draw with no allocation or pipeline
// rebuild. The composite's display-output flags are not part of the payload,
// so the EDR path negotiated at init survives every push. Auto-exposure,
// when on, overwrites `exposure` each frame from the adapted EV, so a static
// exposure change is only visible with auto-exposure off.
pub(crate) fn update_post_process(
&mut self,
tunables: crate::gfx::render_types::PostProcessTunables,
) {
self.post_process.set_tunables(tunables);
}
// Set the live ambient (IBL) light scale. `ambient_intensity` lives in
// `LightUniforms`, which the main lighting pass uploads every frame, so the
// change takes effect on the next draw with no allocation. It is not
// re-derived per frame (unlike auto-exposure's `exposure`), so the value
// stands until changed again.
pub(crate) fn set_ambient_intensity(&mut self, value: f32) {
self.light_uniforms.ambient_intensity = value;
}
// Set the live shadow cascade re-render cadence. The scheduler reads
// `shadow.update` at the start of each shadow pass, so a change takes effect
// on the next draw. Every cascade is already primed, so switching policy never
// leaves a slice unsampled (priming is one-shot per cascade, not per policy).
pub(crate) fn set_shadow_update(&mut self, update: crate::components::ShadowUpdate) {
self.shadow.update = update;
}
// Set the live shadow distance (world units). The per-frame cascade-split
// computation reads `shadow.distance` each draw, so a change takes effect on
// the next frame with no allocation (it sizes no GPU resource).
pub(crate) fn set_shadow_distance(&mut self, distance: u32) {
self.shadow.distance = distance;
}
// Set the live shadow cascade count (1..=4). The per-frame split + schedule
// read `shadow.cascades` each draw; only the first `count` of the four slots
// are rendered + sampled, so a change takes effect on the next frame with no
// resize (the shadow-map array stays sized for the 4-cascade capacity).
pub(crate) fn set_shadow_cascades(&mut self, count: u32) {
self.shadow.cascades = count;
}
// Update the live scalar sub-tunables of the SSAO / SSR / SSGI / auto-exposure
// passes without rebuilding anything. The draw path rebuilds each pass's
// per-frame uniform from these stored `*Settings` structs every frame
// (`settings.params(...)`), so mutating the stored struct here is picked up on
// the next draw. Only a feature that is currently on has a settings struct to
// mutate; the rest are skipped (the value still persists for the next launch).
// SSAO / SSR / auto-exposure settings are fully scalar, so they are replaced
// wholesale; SSGI keeps its gather resolution / ray / step counts (those size
// the gather target or ride `apply_quality_settings`), so only its scalar
// intensity / distance are updated.
pub(crate) fn update_quality_params(&mut self, q: crate::gfx::backend::QualitySettings) {
if let (Some(live), Some(cur)) = (q.ssao, self.ssao.settings.as_mut()) {
*cur = live;
}
if let (Some(live), Some(cur)) = (q.ssr, self.ssr.settings.as_mut()) {
*cur = live;
}
if let (Some(live), Some(cur)) = (q.ssgi, self.ssgi.settings.as_mut()) {
cur.intensity = live.intensity;
cur.max_distance = live.max_distance;
}
if let (Some(live), Some(cur)) = (q.auto_exposure, self.auto_exposure.settings.as_mut()) {
*cur = live;
}
}
// Rebuild the toggle-controlled effects in place to match `q`, applied
// between frames (the GraphicsSystem drain runs before the next
// `draw_frame`). A build failure logs and leaves the prior state intact.
pub(crate) fn apply_quality_settings(&mut self, q: QualitySettings) {
// RT reflections only when the GPU supports hardware ray tracing;
// otherwise the toggle persists + value-syncs but renders nothing,
// matching the init-time fallback.
let rt_settings = q
.rt_reflections
.filter(|_| raytracing_supported(&self.device));
// TAA is bypassed while the MetalFX upscaler is active (the scaler does
// its own temporal accumulation); the velocity pre-pass + G-buffer are
// needed when TAA is effectively on OR the upscaler is active. Mirrors
// the `effective_taa_enabled` / `velocity_needed` derivation in
// `MtlContext::new`. Render dimensions come from the live HDR targets
// (render-resolution, already post-upscale).
let upscaling_active = self.upscale.scaler.is_some();
let taa_effective = q.taa && !upscaling_active;
let needs_velocity = taa_effective || upscaling_active;
// Output dimensions come from the live bloom chain, which was built at
// them; the rebuilt pool sizes `bloom_top` off the same pair, so the new
// top mip drops back into the chain unchanged below.
let dims = EffectDimensions {
render_w: self.hdr_targets.width,
render_h: self.hdr_targets.height,
output_w: self.bloom_targets.width,
output_h: self.bloom_targets.height,
};
let bundle = match build_quality_effects(
&self.allocator,
dims,
EffectSettings {
ssao: &q.ssao,
ssr: &q.ssr,
ssgi: &q.ssgi,
rt_reflection: &rt_settings,
auto_exposure: &q.auto_exposure,
reflection_blur_scale: q.reflection_blur_scale,
auto_exposure_bias_ev: q.auto_exposure_bias_ev,
},
EffectFlags {
taa_enabled: taa_effective,
needs_velocity,
hot_reload: self.hot_reload.enabled,
},
) {
Ok(b) => b,
Err(e) => {
tracing::error!("apply_quality_settings: effect rebuild failed: {e}");
return;
}
};
let QualityEffectsBundle {
taa_pipeline_state,
taa_targets,
ssao,
transient_pool,
ssr,
gbuffer,
ssgi,
rt_pipeline,
rt_pipeline_textured,
rt_skin_pipeline,
auto_exposure_pipelines,
auto_exposure_histogram,
auto_exposure_output,
auto_exposure_state,
auto_exposure_bias_ev,
} = bundle;
// Swap the screen-space feature state in. The old `Retained` targets drop
// here; any in-flight command buffer still referencing them holds its own
// Metal retain until the GPU retires the frame, so the swap is safe
// between frames. The render graph is rebuilt from these gates every
// frame (no cached graph to invalidate).
self.taa.enabled = taa_effective;
self.taa.pipeline_state = taa_pipeline_state;
self.taa.targets = taa_targets;
self.taa.dst = 0;
// History is stale after a rebuild; the first frame passes through.
self.taa.history_valid = false;
self.ssao = ssao;
self.transient_pool = transient_pool;
// The rebuilt pool holds a fresh `bloom_top`, so the bloom chain's top
// mip (a handle into the old pool) is stale. Re-point it rather than
// rebuilding the chain: the extent is unchanged, so the mips below it
// are still correct.
match self.transient_pool.bloom_top() {
Ok(top) => self.bloom_targets.mips[0] = top,
Err(e) => tracing::error!("apply_quality_settings: {e}"),
}
self.ssr = ssr;
self.gbuffer = gbuffer;
self.ssgi = ssgi;
// RT resolve pipelines come from the rebuild; the acceleration structure
// is built here (it needs the resident geometry buffers) when RT turns
// on, and dropped when it turns off. Skinned geometry is seeded into the
// BVH by the next frame's per-frame update, matching the init path.
self.rt.settings = rt_settings;
self.rt.pipeline = rt_pipeline;
self.rt.pipeline_textured = rt_pipeline_textured;
self.rt.skin_pipeline = rt_skin_pipeline;
if self.rt.settings.is_some() {
if self.rt.accel.is_none() {
match build_rt_accel(
RtGpu {
device: &self.device,
command_queue: &self.command_queue,
frames_in_flight: self.frames_in_flight,
},
RtStaticGeometry {
vertex_buffer: &self.vertex_buffer,
index_buffer: &self.index_buffer,
},
RtSceneGeometry {
draw_objects: &self.draw.objects,
clusters: &self.instanced.clusters,
},
RtTextureCounts {
albedo_count: self.textures.len(),
},
None,
self.seethrough_meshes_enabled(),
) {
Ok(Some(a)) => {
tracing::info!(
"ray-traced reflections: built BVH over {} static objects",
a.blas.len()
);
self.rt.accel = Some(a);
}
Ok(None) => tracing::warn!(
"ray-traced reflections toggled on but the scene has no static geometry; no BVH built"
),
Err(e) => tracing::error!("apply_quality_settings: RT accel build: {e}"),
}
}
} else {
self.rt.accel = None;
}
// Reset the failure streak so a later toggle-on starts clean.
self.rt.update_failed = false;
// Auto-exposure. When it turns off the static path uses
// `self.post_process.exposure` (the authored / slider EV), already set,
// so only the GPU state is swapped here.
self.auto_exposure.settings = q.auto_exposure;
self.auto_exposure.state = auto_exposure_state;
self.auto_exposure.bias_ev = auto_exposure_bias_ev;
self.auto_exposure.pipelines = auto_exposure_pipelines;
self.auto_exposure.histogram = auto_exposure_histogram;
self.auto_exposure.output = auto_exposure_output;
}
}