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
//! 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 the init stages' effect builders -- 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 concinnity_core::gfx::render_types;
use concinnity_core::render::backend;
use concinnity_core::render::backend::QualitySettings;
use concinnity_core::render::backend_init;
use concinnity_core::render::error::{RenderError, RenderResult};
use super::auto_exposure::AutoExposureGpu;
use super::context::MtlContext;
use super::init::effects::{
EffectSettings, build_auto_exposure, build_gbuffer, build_ssao, build_ssgi, build_ssr,
build_taa,
};
use super::init::ray_tracing::build_rt_pipelines;
use super::init::targets::build_transient_pool;
use super::post::post_device::MtlPostDevice;
use super::post::{GBufferState, SsaoState, SsgiState, SsrState, TaaState};
use super::raytrace::{
RtGpu, RtPipelines, RtSceneGeometry, RtStaticGeometry, RtTextureCounts, build_rt_accel,
raytracing_supported,
};
use super::transient_pool::TransientTexturePool;
// The toggle-controlled subset of the effects stack: the features the Quality
// settings group switches on and off at runtime (TAA, SSAO, SSR, SSGI, RT
// reflection pipelines, auto-exposure) plus the resources they share (the
// G-buffer pre-pass + the transient pool). Bloom, decals, fog, and particles are
// NOT here: bloom is always on (only its uniforms change, live), and
// decals/fog/particles are world-content effects a quality toggle never affects
// (and rebuilding particles would reset their live GPU pools).
struct QualityEffects {
taa: TaaState,
ssao: SsaoState,
transient_pool: TransientTexturePool,
ssr: SsrState,
gbuffer: GBufferState,
ssgi: SsgiState,
rt: RtPipelines,
auto_exposure: AutoExposureGpu,
}
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: 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;
}
pub(crate) fn set_shadow_cadence(&mut self, cadence: backend_init::ShadowCadence) {
self.shadow.cadence = cadence;
}
// 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's trace resolution and ray count ride
// `apply_quality_settings` (the resolution sizes its targets), so only its
// scalar intensity / distance are updated.
pub(crate) fn update_quality_params(&mut self, q: 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.adaptation.as_mut()) {
cur.settings = live;
}
}
// Rebuild the toggle-controlled effects in place to match `q`, applied
// between frames (the GraphicsSystem drain runs before the next
// `draw_frame`). An effect build failure returns before anything is
// swapped; a later failure keeps the rebuilt state and returns the first
// error once the rest has been applied.
pub(crate) fn apply_quality_settings(&mut self, q: QualitySettings) -> RenderResult<()> {
// RT reflections only when the GPU supports hardware ray tracing;
// otherwise the toggle persists + value-syncs but renders nothing,
// matching the init-time fallback. The refusal is reported the way the
// other two backends report theirs, so a request driven from the debug
// port or a persisted settings file does not read as a success.
let rt_capable = raytracing_supported(&self.hw.device);
if q.rt_reflections.is_some() && !rt_capable {
tracing::warn!(
"ray-traced reflections requested but the device does not support \
hardware ray tracing; keeping SSR"
);
}
let rt_settings = q.rt_reflections.filter(|_| rt_capable);
// 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;
let settings = 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,
};
let effects = self.build_quality_effects(&settings, taa_effective, needs_velocity)?;
let mut first_err: Option<RenderError> = None;
// 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).
// A rebuilt pass starts with its ring at slot 0 and its history
// invalid, so the first frame after a toggle passes through.
self.taa.enabled = effects.taa.enabled;
self.taa.pass = effects.taa.pass;
self.ssao = effects.ssao;
// The bloom chain and the composite fetch the rebuilt pool's
// `bloom_top` by label each frame, so nothing holds the old one.
self.targets.transient_pool = effects.transient_pool;
self.ssr = effects.ssr;
self.gbuffer = effects.gbuffer;
self.ssgi = effects.ssgi;
// Every bucket's pre-pass follows the G-buffer: built when this change
// added one, dropped when it removed it.
self.sync_prepass_pipelines();
// 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.pipelines = effects.rt;
if self.rt.settings.is_some() {
if self.rt.accel.is_none() {
match build_rt_accel(
RtGpu {
device: &self.hw.device,
command_queue: &self.hw.command_queue,
frames_in_flight: self.frames_in_flight,
},
RtStaticGeometry {
vertex_buffer: &self.scene.vertex_buffer,
index_buffer: &self.scene.index_buffer,
},
RtSceneGeometry {
draw_objects: &self.state.draw.objects,
clusters: &self.instanced.clusters,
},
RtTextureCounts {
albedo_count: self.scene.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) => {
let e = e.context("RT accel build");
if first_err.is_some() {
tracing::error!("apply_quality_settings: {e}");
} else {
first_err = Some(e);
}
}
}
}
} else {
// Between frames, so it is tagged with the next frame's id.
self.rt.retire_accel(self.frame_ring_index);
}
// Reset the failure streak so a later toggle-on starts clean.
self.rt.update_streak = concinnity_core::render::rt_accel::FailureStreak::default();
// 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; the frame clock carries over.
self.auto_exposure = AutoExposureGpu {
last_elapsed: self.auto_exposure.last_elapsed,
..effects.auto_exposure
};
first_err.map_or(Ok(()), Err)
}
// Build the toggle-controlled effects (see [`QualityEffects`]) in the order
// init builds them. Render dimensions come from the live HDR targets
// (render-resolution, already post-upscale). Output dimensions are the ones
// the live bloom chain was built at, so the rebuilt pool's `bloom_top` is
// the octave above it. The RT acceleration structure is the caller's
// responsibility.
fn build_quality_effects(
&self,
settings: &EffectSettings,
taa_enabled: bool,
needs_velocity: bool,
) -> RenderResult<QualityEffects> {
let device = &*self.hw.device;
let hot_reload = self.hot_reload.enabled;
let post_device = MtlPostDevice {
device,
sampler: &self.composite.sampler,
cube_sampler: &self.scene.cube_sampler,
probes: None,
timing: None,
hot_reload,
};
let render = (self.targets.hdr.width, self.targets.hdr.height);
let output = self.targets.output;
let gbuffer_enabled = settings.gbuffer_needed(needs_velocity);
Ok(QualityEffects {
taa: build_taa(&post_device, taa_enabled, render)?,
ssao: build_ssao(&post_device, &self.hw.allocator, settings, render)?,
transient_pool: build_transient_pool(
device,
settings.ssao.is_some(),
gbuffer_enabled,
render,
output,
)?,
ssr: build_ssr(&post_device, settings, render)?,
gbuffer: build_gbuffer(device, gbuffer_enabled, render, hot_reload)?,
ssgi: build_ssgi(&post_device, settings, render)?,
rt: build_rt_pipelines(device, settings.rt_reflection, hot_reload)?,
auto_exposure: build_auto_exposure(
device,
settings,
self.frames_in_flight,
hot_reload,
)?,
})
}
}