concinnity-device 0.19.9

GPU backends (Metal, Vulkan, DirectX) behind a device facade for Concinnity
Documentation
// 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_core::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_core::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_core::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)
    }
}