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
// src/metal/transparent.rs
//
// The engine's `PassId::Transparent` slot: one shared pass that draws every
// translucent surface in the world (water, glass, and future ice / holograms /
// force fields). It runs after `SsrResolve` (so translucents see the resolved
// opaque scene + SSR reflections) and before `TaaResolve` / `Upscale` (so they
// pick up temporal accumulation). Output blends over `scene_pre_taa` with
// SRC_ALPHA / ONE_MINUS_SRC_ALPHA.
//
// The pass owns no pipeline of its own. Each translucent subsystem contributes
// a list of [`TransparentDraw`]s (a bound pipeline + buffers + per-draw
// uniforms + texture bindings + a camera distance); `encode_transparent`
// aggregates them, sorts back-to-front, and issues them into a single render
// encoder. This is a fixed sorted draw list, not order-independent
// transparency.
//
// Refraction read-back: at the head of the pass a blit snapshots the current
// `scene_pre_taa` into `hdr_targets.transparent_scene_copy`, which the draws
// sample. This makes refraction work whether or not SSR produced a distinct
// `scene_pre_taa` (with SSR off it aliases `hdr_resolve`, so sampling the
// destination directly would be reading the attachment being written).
#![deny(unsafe_op_in_unsafe_fn)]
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_foundation::NSString;
use objc2_metal::{
MTLBlitCommandEncoder as _, MTLBuffer, MTLCommandBuffer as _, MTLCommandEncoder as _,
MTLIndexType, MTLLoadAction, MTLPrimitiveType, MTLRenderCommandEncoder as _,
MTLRenderPassDescriptor, MTLRenderPipelineState, MTLSamplerState, MTLStoreAction, MTLTexture,
};
use super::context::MtlContext;
use super::encode::RenderEncode;
use super::scoped_encoder::ScopedEncoder;
use concinnity_render::uniforms::TransparentView;
// One translucent draw recorded for the transparent pass. Self-contained
// except for the shared [`TransparentView`], which `encode_transparent` binds
// once at buffer(5). The vertex buffer binds at buffer(1) and the per-draw
// `params` blob at buffer(6) (both stages), matching the transparent shaders'
// argument layout.
pub(in crate::metal) struct TransparentDraw {
pub(in crate::metal) pipeline: Retained<ProtocolObject<dyn MTLRenderPipelineState>>,
pub(in crate::metal) vertex_buffer: Retained<ProtocolObject<dyn MTLBuffer>>,
pub(in crate::metal) index_buffer: Retained<ProtocolObject<dyn MTLBuffer>>,
pub(in crate::metal) index_count: u32,
// Index element width. `UInt16` for the per-asset glass/water buffers; `UInt32`
// for a transparent mesh drawing from the shared scene index buffer.
pub(in crate::metal) index_type: MTLIndexType,
// Byte offset of the first index into `index_buffer`. 0 for the per-asset
// glass/water buffers; `DrawObject.index_offset * index_stride` for a mesh
// sharing the scene index buffer.
pub(in crate::metal) index_offset_bytes: usize,
// Value added to every fetched index (`baseVertex`). 0 for world-space
// glass/water and static meshes; non-zero for mesh-relative chunk indices.
pub(in crate::metal) base_vertex: i32,
// Per-draw uniform blob, bound at vertex + fragment buffer(6). Built from
// a `#[repr(C)]` params struct via [`bytes_of`].
pub(in crate::metal) params: Vec<u8>,
// Fragment textures: `(slot, texture)`. Bound before the draw.
pub(in crate::metal) fragment_textures: Vec<(usize, Retained<ProtocolObject<dyn MTLTexture>>)>,
// Fragment samplers: `(slot, sampler)`.
pub(in crate::metal) fragment_samplers:
Vec<(usize, Retained<ProtocolObject<dyn MTLSamplerState>>)>,
// World-space distance from camera to the draw's centre, used for the
// back-to-front sort. Larger = farther = drawn first.
pub(in crate::metal) sort_distance: f32,
}
// Copy a `#[repr(C)]` uniform struct into an owned byte buffer for a
// [`TransparentDraw::params`] blob. The bytes are consumed immediately by
// `setVertexBytes` / `setFragmentBytes` (which copy into the command buffer),
// so the buffer's 1-byte alignment is irrelevant to Metal.
pub(in crate::metal) fn bytes_of<T: bytemuck::NoUninit>(value: &T) -> Vec<u8> {
bytemuck::bytes_of(value).to_vec()
}
// Fragment sampler indices past the transparent pass's cube-sampler run, which
// slangc assigns to `glass.slang`'s two remaining combined declarations: the
// planar resolve (declared after the probe array) and, on the textured RT
// variant, the bindless pool. Pinned here because the emitted MSL is what
// numbers them.
const GLASS_PLANAR_SAMPLER_INDEX: usize = 10;
const GLASS_POOL_SAMPLER_INDEX: usize = 11;
impl MtlContext {
// True when the transparent pass traces a per-pixel RT reflection this frame:
// RT is live AND every live producer's RT pipeline built. A producer is live
// when its base pipeline built (the world declared one), so a world with no
// panes cannot hold the pass back over a glass metallib it never needed.
//
// Single-sources the decision the way `DxContext::rt_transparent_active` does,
// and for the same reason: a producer whose RT metallib failed to build falls
// back to its probe / planar path, so the planar gate has to keep the mirror
// re-render alive for it. `slangc` rejecting `TraceRayInline` on the Metal
// target is exactly that failure, and it is not hypothetical.
pub(in crate::metal) fn rt_transparent_active(&self) -> bool {
let water_ready = self.water.pipeline.is_none() || self.water.pipeline_rt.is_some();
let glass_ready = self.glass.pipeline.is_none() || self.glass.pipeline_rt.is_some();
self.rt.accel.is_some() && water_ready && glass_ready
}
// Encode the transparent pass: snapshot the scene for refraction, then
// draw every contributed translucent surface back-to-front into
// `scene_pre_taa`. Returns the number of draws issued (0 short-circuits
// before allocating the encoder).
pub(in crate::metal) fn encode_transparent(
&self,
cmd_buf: &ProtocolObject<dyn objc2_metal::MTLCommandBuffer>,
view: &TransparentView,
scene_pre_taa: &Retained<ProtocolObject<dyn objc2_metal::MTLTexture>>,
draws: &[TransparentDraw],
rt_params: Option<&crate::gfx::render_types::RtParams>,
bindless_tex_args: Option<&Retained<ProtocolObject<dyn objc2_metal::MTLBuffer>>>,
) -> Result<u32, String> {
if draws.is_empty() {
return Ok(0);
}
// Snapshot the pre-transparent scene so refraction taps read a stable
// copy instead of the attachment being written.
let blit = cmd_buf
.blitCommandEncoder()
.ok_or("failed to get transparent scene-copy blit encoder")?;
blit.pushDebugGroup(&NSString::from_str("transparent_scene_copy"));
// SAFETY: both textures are HDR scene targets created with the same format and dimensions,
// which is what a whole-texture blit copy requires.
unsafe {
blit.copyFromTexture_toTexture(
scene_pre_taa.as_ref(),
self.hdr_targets.transparent_scene_copy.as_ref(),
);
}
blit.popDebugGroup();
blit.endEncoding();
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(scene_pre_taa.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::Transparent);
}
// The blit above is ended explicitly (it must close before this render
// encoder opens). This render pass spans to the end of the function and
// has a `?` mid-encode (the per-draw params blob below), so the guard
// ensures it can't leak an open encoder on an early return.
let enc = ScopedEncoder::new(
cmd_buf
.renderCommandEncoderWithDescriptor(&pass_desc)
.ok_or("failed to get transparent render encoder")?,
"transparent",
);
// Shared per-frame view at buffer(5) for both stages. The pass has no
// depth attachment (translucents are not hardware depth-tested;
// depth-aware effects sample `depth_resolve` instead), so no
// depth-stencil state is bound.
enc.set_vertex_value(view, 5);
enc.set_fragment_value(view, 5);
// Reflection sources shared by every transparent shader that samples
// them (glass + water): the sky prefilter cube at texture(2), the local
// reflection-probe cubes at texture(3..3+MAX_PROBES), the cube sampler
// at sampler(1), and the probe set (parallax boxes + count) at fragment
// buffer(7). Frame-constant, so bound once before the draw loop; a probe
// count of 0 keeps the sky-only fallback. `probe_cube_or_sky` returns the
// sky for unbaked slots, so binding all MAX_PROBES is always valid. The
// per-draw bindings below never touch these slots, so the state persists.
enc.set_fragment_texture(self.env_map.prefilter.as_ref(), 2);
for i in 0..concinnity_render::uniforms::MAX_PROBES {
enc.set_fragment_texture(self.probe_cube_or_sky(i), 3 + i);
}
// The cube sampler covers the prefilter cube and every probe cube.
// The single-source glass fragment declares those as combined
// texture-samplers, which slangc lowers to one sampler per texture
// (prefilter at 1, the probe array at 2..1+MAX_PROBES); the
// hand-written water and glass-mesh shaders read the same cube
// sampler at 1, so one contiguous run serves both.
super::post::fullscreen::set_fragment_sampler_range(
&enc,
self.cube_sampler.as_ref(),
1,
1 + concinnity_render::uniforms::MAX_PROBES,
);
// The planar resolve at texture(11) takes the post sampler at the
// slot after the probe run, and the bindless pool the RT variants
// read takes the repeat-address sampler after that.
super::post::fullscreen::set_fragment_sampler_range(
&enc,
&self.post_sampler,
GLASS_PLANAR_SAMPLER_INDEX,
1,
);
super::post::fullscreen::set_fragment_sampler_range(
&enc,
self.sampler.as_ref(),
GLASS_POOL_SAMPLER_INDEX,
1,
);
enc.set_fragment_value(&self.probe.set, 7);
// A planar reflection resolve at texture(11), the default for every
// transparent draw so the slot is always bound (validation-safe) even
// for slotless / probe-path draws. water.slang + glass.slang sample it
// when their `planar.x` flag is set; a planar draw overrides this with
// ITS plane's resolve per-draw (see the collect paths). The first
// slot's resolve is a valid stand-in for draws that do not sample it.
if let Some(planar) = self.planar_reflection.as_ref()
&& let Some(first) = planar.targets.first()
{
enc.set_fragment_texture(first.resolve.as_ref(), 11);
}
// Ray-traced glass + water inputs. When the acceleration structure is
// live and an RT transparent pipeline exists,
// `collect_glass_transparent_draws` / `collect_water_transparent_draws`
// select `glass_fragment_rt`(`_textured`) / `water_fragment_rt`(`_textured`),
// which trace a reflection ray. These share one argument layout, so bind
// the inputs once here (the non-RT glass / water pipelines ignore these
// otherwise-free fragment slots): RT params @0, the shared scene geometry
// @1..3, the TLAS @4, the skinned deformed-vertex / index buffers
// @8..9, and -- in a bindless world -- the bindless texture pool @10 for
// the textured variants (the main pass's pool index 7 is the ProbeSet
// here). The TLAS references each BLAS indirectly, so the BLASes are not
// auto-tracked -- declare them resident or the trace reads garbage.
if let (Some(accel), Some(rt_params)) = (
self.rt.accel.as_ref().filter(|_| {
self.glass.pipeline_rt.is_some()
|| self.water.pipeline_rt.is_some()
|| self.glass.mesh_pipeline_rt.is_some()
}),
rt_params,
) {
enc.set_fragment_value(rt_params, 0);
enc.set_fragment_buffer(self.vertex_buffer.as_ref(), 0, 1);
enc.set_fragment_buffer(self.index_buffer.as_ref(), 0, 2);
enc.set_fragment_buffer(accel.geom_table.as_ref(), 0, 3);
enc.set_fragment_acceleration_structure(accel.tlas.as_ref(), 4);
enc.set_fragment_buffer(accel.deformed_verts.as_ref(), 0, 8);
enc.set_fragment_buffer(accel.skinned_indices.as_ref(), 0, 9);
super::raytrace::use_blas_resident_fragment(&enc, &accel.blas);
// Textured variants (bindless world): the albedo / normal /
// emissive pool at buffer(10) + its textures declared resident.
if let Some(tex_args) = bindless_tex_args.filter(|_| {
self.glass.pipeline_rt_textured.is_some()
|| self.water.pipeline_rt_textured.is_some()
|| self.glass.mesh_pipeline_rt_textured.is_some()
}) {
enc.set_fragment_buffer(tex_args.as_ref(), 0, 10);
self.use_bindless_textures(&enc);
}
}
let distances: Vec<f32> = draws.iter().map(|d| d.sort_distance).collect();
let order = crate::gfx::transparent::back_to_front_order(&distances);
for &i in &order {
let d = &draws[i];
enc.set_pipeline(&d.pipeline);
// SAFETY: `params_ptr`/`d.params.len()` describe the record own parameter blob, and the
// index range is that record own slice of `d.index_buffer`.
unsafe {
enc.set_vertex_buffer(&d.vertex_buffer, 0, 1);
let params_ptr = std::ptr::NonNull::new(d.params.as_ptr() as *mut std::ffi::c_void)
.ok_or("transparent draw params blob is null")?;
enc.setVertexBytes_length_atIndex(params_ptr, d.params.len(), 6);
enc.setFragmentBytes_length_atIndex(params_ptr, d.params.len(), 6);
for (slot, tex) in &d.fragment_textures {
enc.set_fragment_texture(tex.as_ref(), *slot);
}
for (slot, samp) in &d.fragment_samplers {
enc.set_fragment_sampler(samp.as_ref(), *slot);
}
enc.drawIndexedPrimitives_indexCount_indexType_indexBuffer_indexBufferOffset_instanceCount_baseVertex_baseInstance(
MTLPrimitiveType::Triangle,
d.index_count as usize,
d.index_type,
&d.index_buffer,
d.index_offset_bytes,
1,
d.base_vertex as isize,
0,
);
}
}
Ok(order.len() as u32)
}
}