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
// src/metal/fog.rs
//
// Per-frame encoder for the volumetric-fog pass. Runs after the main HDR
// pass (and after the decal pass, so fog sits on top of decals just like it
// does for any other resolved scene colour) and before SSR / TAA, so the
// reflections and history reproject through the integrated fog colour and
// transmittance.
//
// The pass is a single fullscreen triangle: the fragment shader samples the
// main pass's MSAA depth attachment, reconstructs each pixel's world-space
// surface point via the inverse VP, ray-marches a sun-lit homogeneous
// medium with exponential height falloff, and writes `(scattered_rgb, 1 -
// transmittance)` so the pipeline's `over` blend yields
// `scene * T + scattered` automatically.
#![deny(unsafe_op_in_unsafe_fn)]
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_metal::{
MTLCommandBuffer as _, MTLComputeCommandEncoder as _, MTLComputePipelineState, MTLDevice as _,
MTLLibrary as _, MTLLoadAction, MTLPixelFormat, MTLPrimitiveType, MTLRenderCommandEncoder as _,
MTLRenderPassDescriptor, MTLRenderPipelineState, MTLSize, MTLStoreAction, MTLTexture,
MTLTextureType, MTLTextureUsage,
};
use crate::gfx::render_graph::{FOG_FROXEL_X, FOG_FROXEL_Y, FOG_FROXEL_Z};
use crate::gfx::render_types::{FogFroxelParams, FogParams};
use crate::gfx::volumetric_fog::FogSettings;
use super::context::MtlContext;
use super::descriptors::TextureDesc;
use super::encode::{ComputeEncode, RenderEncode};
use super::pipeline::ns_str;
use super::post::fullscreen::{
FullscreenBlend, build_slang_fullscreen_pipeline, set_fragment_sampler_range,
};
use super::scoped_encoder::ScopedEncoder;
use super::slang_shaders::{FOG_FRAG, FOG_FROXEL};
// All volumetric-fog state grouped into one feature unit: the resolved
// tunables, the fullscreen ray-march pipeline, and the froxel-volume compute
// pipeline + its 3D output volume. All `Some` only when the world declares a
// `VolumetricFog` with `enabled = true` (or one is set at runtime via
// `update_fog_settings`); `None` skips the pass entirely.
pub(crate) struct FogState {
pub settings: Option<FogSettings>,
pub pipeline: Option<Retained<ProtocolObject<dyn MTLRenderPipelineState>>>,
pub froxel_pipeline: Option<Retained<ProtocolObject<dyn MTLComputePipelineState>>>,
pub froxel_volume: Option<Retained<ProtocolObject<dyn MTLTexture>>>,
}
impl MtlContext {
// Hot-reload entry point for the volumetric-fog tunables. Writes the new
// `Option<FogSettings>` into `self.fog.settings`; the next `draw_frame`
// then re-builds `FogParams` from it. `None` disables the pass even if
// the pipeline is live (the guard in [`MtlContext::draw_frame`] needs
// both fog_pipeline and fog_settings).
//
// If the world started with no `VolumetricFog` (so `fog_pipeline` is
// `None`), a `Some` update logs once and is dropped: re-enabling fog
// mid-run requires a relaunch.
pub(crate) fn update_fog_settings(&mut self, settings: Option<FogSettings>) {
if settings.is_some() && self.fog.pipeline.is_none() {
tracing::warn!(
"VolumetricFog hot-reload: world started without fog, so the fog \
pipeline was never built: re-enabling fog mid-run is not \
supported (relaunch required). Ignoring update."
);
return;
}
self.fog.settings = settings;
}
// Encode the volumetric-fog pass. Caller has already ended the main HDR
// pass (and the decal pass, if any), so `hdr_targets.depth` (MSAA) holds
// the scene depth and `hdr_targets.hdr_resolve` holds the resolved
// scene + decals colour. The pass alpha-blends a single lit ray-march
// over `hdr_resolve`.
// pub(in crate::metal) so the render-graph executor in
// metal/graph_exec.rs can dispatch this pass from a CompiledGraph.
pub(in crate::metal) fn encode_fog(
&self,
cmd_buf: &objc2::runtime::ProtocolObject<dyn objc2_metal::MTLCommandBuffer>,
params: &FogParams,
froxel_params: &FogFroxelParams,
) -> Result<u32, String> {
let pipeline = match &self.fog.pipeline {
Some(p) => p,
None => return Ok(0),
};
let volume = match &self.fog.froxel_volume {
Some(v) => v,
None => return Ok(0),
};
let pass_desc = MTLRenderPassDescriptor::new();
// SAFETY: plain descriptor property setters; the subscripted slots are ones this descriptor
// declares.
unsafe {
let ca = pass_desc.colorAttachments().objectAtIndexedSubscript(0);
ca.setTexture(Some(self.hdr_targets.hdr_resolve.as_ref()));
ca.setLoadAction(MTLLoadAction::Load);
ca.setStoreAction(MTLStoreAction::Store);
}
if let Some(t) = &self.diagnostics.pass_timing {
t.attach_render(&pass_desc, super::pass_timing::PassId::Fog);
}
let enc = ScopedEncoder::new(
cmd_buf
.renderCommandEncoderWithDescriptor(&pass_desc)
.ok_or("failed to get fog render encoder")?,
"volumetric fog",
);
enc.set_pipeline(pipeline);
enc.set_fragment_value(params, 0);
enc.set_fragment_value(froxel_params, 1);
// Read the single-sample `depth_resolve` (post-
// Main depth + any raymarched surface depth) so fog
// attenuates raymarched surfaces by their true distance. It is
// fetched by pixel coordinate and never sampled, so it takes no
// sampler slot; the volume is trilinearly filtered and takes
// sampler(0). `post_sampler` is the linear clamp-to-edge state the
// shader used to declare inline as a constexpr sampler.
enc.set_fragment_texture(self.hdr_targets.depth_resolve.as_ref(), 0);
enc.set_fragment_texture(volume.as_ref(), 1);
set_fragment_sampler_range(&enc, &self.post_sampler, 0, 1);
// Fullscreen triangle: 3 vertices, no vertex buffer.
// SAFETY: the vertex shader generates all three vertices, so the draw reads no bound
// vertex buffer.
unsafe {
enc.drawPrimitives_vertexStart_vertexCount(MTLPrimitiveType::Triangle, 0, 3);
}
Ok(1)
}
// Encode the volumetric-fog froxel-volume compute pass. One thread per
// (x, y) tile of the 3D volume; the kernel walks the Z slices from front
// to back, accumulating per-slab scatter + transmittance with a CSM
// shadow tap per slice. Writes `(scattered_rgb, 1 - transmittance)` into
// the slice of `fog_froxel_volume`. Caller must dispatch this before
// `encode_fog`, which samples the same volume.
pub(in crate::metal) fn encode_fog_froxel(
&self,
cmd_buf: &objc2::runtime::ProtocolObject<dyn objc2_metal::MTLCommandBuffer>,
params: &FogParams,
froxel_params: &FogFroxelParams,
) -> Result<u32, String> {
let pipeline = match &self.fog.froxel_pipeline {
Some(p) => p,
None => return Ok(0),
};
let volume = match &self.fog.froxel_volume {
Some(v) => v,
None => return Ok(0),
};
let cmd_buf_dyn: &ProtocolObject<dyn objc2_metal::MTLCommandBuffer> = cmd_buf;
let desc = objc2_metal::MTLComputePassDescriptor::computePassDescriptor();
if let Some(t) = &self.diagnostics.pass_timing {
t.attach_compute(&desc, super::pass_timing::PassId::FogFroxel);
}
let enc = ScopedEncoder::new(
cmd_buf_dyn
.computeCommandEncoderWithDescriptor(&desc)
.ok_or("failed to get fog froxel compute encoder")?,
"fog froxel volume",
);
enc.set_pipeline(pipeline);
enc.set_value(params, 0);
enc.set_value(froxel_params, 1);
// ShadowUniforms at buffer(2) so the kernel can pick a CSM cascade per
// froxel.
enc.set_value(&self.shadow.uniforms, 2);
enc.set_texture(self.shadow.map.as_ref(), 0);
enc.set_texture(volume.as_ref(), 1);
// The per-slab CSM tap's comparison sampler, which the shader used
// to declare inline as a constexpr sampler. `shadow.sampler` is the
// same linear / clamp-to-edge / less-equal state the main pass taps
// the cascades with.
enc.set_sampler(self.shadow.sampler.as_ref(), 0);
// One thread per (x, y) tile; the kernel walks Z internally.
// Threadgroup of 8x8x1 keeps occupancy high without thrashing
// registers (the inner Z loop has decent working set).
let tg = MTLSize {
width: 8,
height: 8,
depth: 1,
};
let grid = MTLSize {
width: FOG_FROXEL_X as usize,
height: FOG_FROXEL_Y as usize,
depth: 1,
};
enc.dispatchThreads_threadsPerThreadgroup(grid, tg);
Ok(0)
}
}
// Build the volumetric-fog pipeline: a fullscreen triangle that samples the
// scene depth, maps each pixel into the froxel volume the compute kernel
// filled, and composites the result over the resolved HDR target with a
// standard `over` alpha blend. Takes the shared `fullscreen_vertex`, which is
// what settles the triangle winding the hand-written Metal vertex wound the
// other way from every other backend.
pub(super) fn build_fog_pipeline(
device: &ProtocolObject<dyn objc2_metal::MTLDevice>,
hot_reload: bool,
) -> Result<Retained<ProtocolObject<dyn MTLRenderPipelineState>>, String> {
// `(scattered, 1 - T)` over `scene` -> `scene * T + scattered`.
build_slang_fullscreen_pipeline(
device,
&FOG_FRAG,
MTLPixelFormat::RGBA16Float,
FullscreenBlend::PremultipliedOver,
hot_reload,
)
}
// Build the volumetric-fog froxel-volume compute pipeline from the same
// single-source file the fragment above compiles: the two halves share
// `FogParams` and `FogFroxelParams`, so they move as one unit.
pub(super) fn build_fog_froxel_pipeline(
device: &ProtocolObject<dyn objc2_metal::MTLDevice>,
hot_reload: bool,
) -> Result<Retained<ProtocolObject<dyn MTLComputePipelineState>>, String> {
let library = FOG_FROXEL.library(device, hot_reload)?;
let func = library
.newFunctionWithName(&ns_str("fog_froxel_kernel"))
.ok_or("fog_froxel_kernel not found")?;
device
.newComputePipelineStateWithFunction_error(&func)
.map_err(|e| format!("failed to create fog froxel pipeline: {:?}", e))
}
// Allocate the 3D `RGBA16Float` volume the froxel kernel writes and the
// fog fragment shader samples. Dimensions live in
// [`crate::gfx::render_graph::FOG_FROXEL_X`] / `Y` / `Z`.
pub(super) fn build_fog_froxel_volume(
device: &ProtocolObject<dyn objc2_metal::MTLDevice>,
) -> Result<Retained<ProtocolObject<dyn MTLTexture>>, String> {
let desc = TextureDesc {
kind: MTLTextureType::Type3D,
format: MTLPixelFormat::RGBA16Float,
width: FOG_FROXEL_X as usize,
height: FOG_FROXEL_Y as usize,
depth: FOG_FROXEL_Z as usize,
usage: MTLTextureUsage::ShaderRead | MTLTextureUsage::ShaderWrite,
..Default::default()
}
.build();
device
.newTextureWithDescriptor(&desc)
.ok_or_else(|| "failed to allocate fog froxel volume texture".to_string())
}