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
//! The per-frame params blocks `MtlContext::draw_frame` hoists ahead of its
//! render-graph dispatch, so one `GraphFrameParams` carries the union.
use concinnity_core::gfx::render_types;
use concinnity_core::render::error;
use concinnity_core::render::post::rt_reflections::RtParamsInputs;
use concinnity_core::render::render_graph;
use concinnity_core::render::uniforms::GBufferView;
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::MTLTexture;
use crate::metal::context::MtlContext;
// The camera and resolution inputs every params block derives from.
pub(super) struct PassUniformArgs {
pub(super) fov_y_radians: f32,
pub(super) aspect: f32,
pub(super) near: f32,
pub(super) view_distance: Option<f32>,
pub(super) cam_pos: [f32; 3],
pub(super) sky_rot: [[f32; 4]; 3],
pub(super) proj: [[f32; 4]; 4],
pub(super) vp: [[f32; 4]; 4],
pub(super) render_w: u32,
pub(super) render_h: u32,
pub(super) elapsed: f32,
}
// One params block per pass that takes one, plus the gates the graph inputs
// and `GraphFrameParams` read alongside them.
pub(super) struct PassUniforms {
pub(super) ssao_params: Option<render_types::SsaoParams>,
pub(super) ssr_params: Option<render_types::SsrParams>,
pub(super) ssgi_params: Option<render_types::SsgiParams>,
pub(super) rt_reflection_params: Option<render_types::RtParams>,
pub(super) fog_settings: Option<FogSettings>,
pub(super) fog_params: Option<render_types::FogParams>,
pub(super) fog_froxel_params: Option<render_types::FogFroxelParams>,
pub(super) clustered: bool,
pub(super) cluster_params: render_types::ClusterParams,
pub(super) velocity_active: bool,
pub(super) gbuffer_view: GBufferView,
pub(super) scene_input: Retained<ProtocolObject<dyn MTLTexture>>,
pub(super) scene_color: Retained<ProtocolObject<dyn MTLTexture>>,
pub(super) transparent_active: bool,
}
impl MtlContext {
pub(super) fn frame_pass_uniforms(
&mut self,
args: PassUniformArgs,
) -> error::RenderResult<PassUniforms> {
let PassUniformArgs {
fov_y_radians,
aspect,
near,
view_distance,
cam_pos,
sky_rot,
proj,
vp,
render_w,
render_h,
elapsed,
} = args;
// Per-frame pass uniforms hoisted upfront.
// Every pass that needs a struct of per-frame params builds its
// uniforms here so a single GraphFrameParams can carry
// the union into `execute_graph`.
let ssao_params = self
.ssao
.settings
.map(|settings| settings.params(fov_y_radians, aspect));
let ssr_params = self.ssr.settings.map(|settings| {
let v = self.state.view.matrix;
let inv_view_rot = [
[v[0][0], v[1][0], v[2][0], 0.0],
[v[0][1], v[1][1], v[2][1], 0.0],
[v[0][2], v[1][2], v[2][2], 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let prefilter_mip_count = self.scene.env_map.prefilter_mip_count as f32;
settings.params(
fov_y_radians,
aspect,
inv_view_rot,
cam_pos,
prefilter_mip_count,
sky_rot,
)
});
let ssgi_params = self
.ssgi
.settings
.zip(self.ssgi.pass.as_ref())
.map(|(settings, pass)| settings.params(fov_y_radians, aspect, pass.frame()));
// RT-reflection params: built only when the acceleration structure is
// live (so they stay in lockstep with `rt_reflections_enabled`). Carries
// the camera-to-world transform + sun the kernel shades hits with, like
// SSR's params plus the world-space camera + sun.
let rt_reflection_params =
self.rt
.settings
.filter(|_| self.rt.accel.is_some())
.map(|settings| {
let v = self.state.view.matrix;
let inv_view_rot = [
[v[0][0], v[1][0], v[2][0], 0.0],
[v[0][1], v[1][1], v[2][1], 0.0],
[v[0][2], v[1][2], v[2][2], 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let prefilter_mip_count = self.scene.env_map.prefilter_mip_count as f32;
let sun = &self.light_uniforms.directional[0];
let sun_color = [
sun.color[0] * sun.intensity,
sun.color[1] * sun.intensity,
sun.color[2] * sun.intensity,
];
settings.params(RtParamsInputs {
fov_y_radians,
aspect,
inv_view_rot,
cam_pos,
sun_dir: sun.direction,
sun_color,
prefilter_mip_count,
sky_rot,
})
});
// The live settings, dropped when the medium cannot affect the frame (a
// zero density integrates to a transparent black over the whole volume).
// One source for the two param blocks and the `frame_graph_inputs` gate, so
// `GraphFrameParams`'s "Some only when the Fog pass is in the graph"
// contract holds.
let fog_settings = self.fog.settings.filter(|s| s.contributes());
let fog_params = fog_settings.map(|fog| {
// Sun = the first directional light; falls back to the
// LightUniforms::DEFAULT direction if the world declared none.
let sun = &self.light_uniforms.directional[0];
let sun_color = [
sun.color[0] * sun.intensity,
sun.color[1] * sun.intensity,
sun.color[2] * sun.intensity,
];
// Fog renders into hdr_resolve, which is render-resolution
// when the upscaler is on. The fog shader uses the viewport
// to reconstruct world position from screen UV, so it must
// match the actual render target's pixel grid.
let viewport = [render_w as f32, render_h as f32];
// Reconstruct the froxel volume with the UN-jittered view-projection.
// Fog is volumetric, so its screen-space contribution does not follow
// the surface motion vectors TAA reprojects by. Feeding it the jittered
// inv_vp shifts the whole volume sub-pixel every frame; on a large
// smooth low-contrast surface, where the fog is the dominant
// high-frequency signal, TAA cannot reconcile that per-frame shift with
// the jitter-free history, so the fog flickers (a moving moire). The
// un-jittered inv_vp keeps the volume stable frame to frame; its offset
// versus the jittered depth buffer is far below the coarse froxel grid.
let fog_inv_vp = mat4_inverse(mat4_mul(proj, self.state.view.matrix));
fog.params(fog_inv_vp, cam_pos, sun.direction, sun_color, viewport)
});
// FogFroxel volume extras: view matrix + volume dimensions + near/far
// so the compute kernel can place each froxel in world-space and the
// fragment shader can map a scene depth into the volume's Z axis.
let fog_froxel_params = fog_settings.map(|fog| render_types::FogFroxelParams {
view: self.state.view.matrix,
froxel_dims: [
render_graph::FOG_FROXEL_X,
render_graph::FOG_FROXEL_Y,
render_graph::FOG_FROXEL_Z,
],
_pad_align: 0,
z_near: near.max(1e-3),
z_far: fog.max_distance,
_pad: [0.0; 2],
});
// Clustered light-binning params (main camera). The compute pass reads
// these to build each cluster's world-space AABB (un-jittered inverse VP
// + camera forward, matching the fog froxel convention) and the forward
// pass reads the grid dims / depth range / screen size to place a
// fragment. `use_clusters` is set only when a local light or a baked
// probe is live; otherwise every reader brute-forces an empty list and
// the LightCull graph node is omitted, so a list the skipped pass did not
// write is never read. Stored on self so the shared main-pass bind can
// push it; a local copy feeds the LightCull arm.
self.cluster_params = render_types::ClusterParams::for_camera(
&render_types::ClusterCamera {
view: self.state.view.matrix,
proj,
position: cam_pos,
near,
range: self.scene.cluster_reach.range(
cam_pos,
near,
self.probe.book.records(),
view_distance,
),
width: render_w,
height: render_h,
},
self.light_uniforms.num_local_lights,
self.probe.book.count() as u32,
);
let cluster_params = self.cluster_params;
let clustered = cluster_params.use_clusters != 0;
// Velocity (motion vectors in the G-buffer pre-pass) is needed whenever
// something reprojects: TAA, the MetalFX upscaler, or the SSGI
// accumulation.
let velocity_active = self.reads_motion();
// The pre-pass reprojects to the previous frame's camera and clock while
// velocity runs; otherwise to its own, so the motion channel is a
// harmless zero no consumer reads and the surfaces skip reprojecting.
let cur = ViewFrame {
vp: mat4_mul(proj, self.state.view.matrix),
elapsed,
cam_pos,
};
let gbuffer_view = GBufferView::new(
vp,
self.state.view.matrix,
cur,
self.view_history.prev_or(cur),
velocity_active,
);
// `scene_input` is the engine-owned texture the post-decoration stack
// treats as the pre-TAA scene: the reflection composite's output when a
// reflection path is live, else the raw `hdr_resolve`.
//
// That output is the *composited* scene, not the reflection. Both the
// SSR and the RT resolve write radiance into `ssr.reflection`, then call
// the shared `encode_reflection_composite`, which blends that over
// `hdr_resolve` into the output. Worth stating precisely: the DirectX
// equivalent split the two apart and left its upscaler reading the
// radiance buffer as if it were the scene.
//
// `scene_color` is what Bloom + Composite read:
// - the upscaler's output (drawable-res) when MetalFX is on,
// - the TAA resolve target when TAA is on,
// - otherwise just the pre-TAA scene (no temporal stage).
let scene_input = if self.reflection_path().resolves() {
self.ssr
.composite
.as_ref()
.ok_or_else(|| {
error::RenderError::Other(
"reflections enabled but the reflection composite is missing".into(),
)
})?
.output()
.texture()
.clone()
} else {
self.targets.hdr.hdr_resolve.clone()
};
let scene_color = if let Some(u) = &self.upscale.scaler {
u.output.clone()
} else if let Some(out) = self.taa.output() {
out.clone()
} else {
scene_input.clone()
};
// The transparent pass runs when any translucent producer is live.
// Drives both the graph-input gate (whether the slot is inserted) and the
// `scene_pre_taa` supply in `GraphFrameParams` (the pass reads + writes
// it). With
// SSR off `scene_input` aliases `hdr_resolve`, which is the correct
// RMW target: the transparent encoder blits a scene copy first, so the
// self-read for refraction is safe.
let transparent_active = (self.water.pipeline.is_some()
&& self.water.surfaces.iter().any(|s| s.visible))
|| (self.glass.pipeline.is_some() && self.glass.panels.iter().any(|p| p.visible))
|| self.mesh_glass_visible();
Ok(PassUniforms {
ssao_params,
ssr_params,
ssgi_params,
rt_reflection_params,
fog_settings,
fog_params,
fog_froxel_params,
clustered,
cluster_params,
velocity_active,
gbuffer_view,
scene_input,
scene_color,
transparent_active,
})
}
}