#![deny(unsafe_op_in_unsafe_fn)]
use std::ptr::NonNull;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_metal::{
MTLBuffer as _, MTLCommandBuffer as _, MTLCommandBufferStatus, MTLCommandQueue as _,
MTLDevice as _, MTLPixelFormat, MTLResourceOptions, MTLTexture, MTLTextureType,
MTLTextureUsage,
};
use super::context::{HDR_SAMPLE_COUNT, MtlContext};
use super::descriptors::TextureDesc;
use super::probe_prefilter::{PrefilterGpu, create_capture_cube};
use crate::gfx::reflection_probe::{self, BakeAction, BakePhase, BakeSignals, PrefilterPlan};
const PLAN: PrefilterPlan = PrefilterPlan::RUNTIME;
const PROBE_FACE_COUNT: usize = 6;
pub(in crate::metal) struct RenderingBake {
index: usize,
placement: reflection_probe::ProbePlacement,
done: Arc<AtomicBool>,
cursor: usize,
eye: [f32; 3],
near: f32,
far: f32,
elapsed: f32,
gpu: BakeGpu,
counts: crate::metal::context::DrawRecordCounts,
}
pub(in crate::metal) struct PrefilteringBake {
index: usize,
placement: reflection_probe::ProbePlacement,
gpu: PrefilterGpu,
cursor: u32,
}
#[expect(
dead_code,
reason = "payloads are held to defer their free, never read"
)]
pub(in crate::metal) enum RetiredBake {
Capture(BakeGpu),
Prefilter(PrefilterGpu),
}
pub(in crate::metal) struct BakeGpu {
msaa_color: Retained<ProtocolObject<dyn MTLTexture>>,
msaa_depth: Retained<ProtocolObject<dyn MTLTexture>>,
capture: Retained<ProtocolObject<dyn MTLTexture>>,
object_buffer: Retained<ProtocolObject<dyn objc2_metal::MTLBuffer>>,
draw_args: Retained<ProtocolObject<dyn objc2_metal::MTLBuffer>>,
tex_args: Retained<ProtocolObject<dyn objc2_metal::MTLBuffer>>,
joint_bufs: Vec<Retained<ProtocolObject<dyn objc2_metal::MTLBuffer>>>,
morph_weight_bufs: Vec<Retained<ProtocolObject<dyn objc2_metal::MTLBuffer>>>,
deformed: Option<Retained<ProtocolObject<dyn objc2_metal::MTLBuffer>>>,
}
impl MtlContext {
pub(in crate::metal) fn set_reflection_probes(
&mut self,
declared: &[reflection_probe::ProbePlacement],
) {
use concinnity_core::render::uniforms::MAX_PROBES;
let mut placements: Vec<reflection_probe::ProbePlacement> = if declared.is_empty() {
match self.scene_world_bounds() {
Some((mn, mx)) => {
let occupancy: Vec<([f32; 3], [f32; 3])> = self
.draw
.objects
.iter()
.map(|o| (o.bb_min, o.bb_max))
.filter(|(mn, mx)| mn.iter().chain(mx).all(|c| c.is_finite()))
.collect();
reflection_probe::auto_seed_probes(mn, mx, &occupancy)
}
None => Vec::new(),
}
} else {
declared.to_vec()
};
if placements.len() > MAX_PROBES {
tracing::warn!(
"reflection probes: {} placements, capping at MAX_PROBES={}",
placements.len(),
MAX_PROBES
);
placements.truncate(MAX_PROBES);
}
self.probe.placements = placements;
self.probe.maps.clear();
self.probe.set = concinnity_core::render::uniforms::ProbeSet::EMPTY;
self.probe.bake_queue = reflection_probe::ProbeBakeQueue::new(self.probe.placements.len());
self.retire_in_flight_bakes();
}
fn bake_ring_slot(&self) -> usize {
self.frames_in_flight
}
fn retire_in_flight_bakes(&mut self) {
let frame = self.frame_ring_index;
if let Some(bake) = self.probe.rendering.take() {
self.probe
.retire_pool
.push(frame, RetiredBake::Capture(bake.gpu));
}
if let Some(bake) = self.probe.prefiltering.take() {
self.probe
.retire_pool
.push(frame, RetiredBake::Prefilter(bake.gpu));
}
}
pub(in crate::metal) fn bake_pending_probes(
&mut self,
elapsed: f32,
near: f32,
far: f32,
) -> Result<(), String> {
self.probe
.retire_pool
.collect(self.frame_ring_index, self.frames_in_flight as u64);
if !self.bindless
|| self.geometry_less
|| self.env_map.prefilter_mip_count <= 1
|| self.probe.prefilter.is_none()
{
self.retire_in_flight_bakes();
self.probe.bake_queue.abort();
return Ok(());
}
let prefiltering_occupied = self.probe.prefiltering.is_some();
let more_mips = self
.probe
.prefiltering
.as_ref()
.is_some_and(|p| p.cursor < PLAN.mips());
match reflection_probe::next_bake_action(
if prefiltering_occupied {
BakePhase::Prefiltering
} else {
BakePhase::Idle
},
BakeSignals {
more_mips,
mips_done: true,
..Default::default()
},
) {
BakeAction::PrefilterMip => {
if let Err(e) = self.probe_prefilter_next_mip() {
self.fail_bake(e);
return Ok(());
}
}
BakeAction::Install => {
if let Err(e) = self.probe_install() {
self.fail_bake(e);
return Ok(());
}
}
_ => {}
}
let prefiltering_free = self.probe.prefiltering.is_none();
let rendering_occupied = self.probe.rendering.is_some();
let more_faces = self
.probe
.rendering
.as_ref()
.is_some_and(|r| r.cursor < PROBE_FACE_COUNT);
let done = self
.probe
.rendering
.as_ref()
.is_some_and(|r| r.done.load(Ordering::Acquire));
let eligible = self.cull_count() > 0;
match reflection_probe::next_bake_action(
if rendering_occupied {
BakePhase::Rendering
} else {
BakePhase::Idle
},
BakeSignals {
faces_done: done && prefiltering_free,
queue_pending: self.probe.bake_queue.pending(),
eligible,
more_faces,
..Default::default()
},
) {
BakeAction::RenderFace => {
if let Err(e) = self.probe_render_next_face() {
self.fail_bake(e);
}
}
BakeAction::StartPrefilter => {
if let Err(e) = self.probe_begin_prefilter() {
self.fail_bake(e);
}
}
BakeAction::StartNext => {
if let Err(e) = self.probe_start_next(near, far, elapsed) {
self.fail_bake(e);
}
}
BakeAction::PrefilterMip | BakeAction::Install | BakeAction::Idle => {}
}
Ok(())
}
fn fail_bake(&mut self, e: String) {
tracing::warn!(
"reflection probe bake failed, keeping {} baked: {e}",
self.probe.maps.len()
);
self.retire_in_flight_bakes();
self.probe.bake_queue.abort();
}
fn probe_start_next(&mut self, near: f32, far: f32, elapsed: f32) -> Result<(), String> {
let Some(index) = self.probe.bake_queue.take_next() else {
return Ok(());
};
let placement = self.probe.placements[index];
let eye = placement.position;
let slot = self.bake_ring_slot();
let object_buffer = self
.build_object_buffer(slot)?
.ok_or("probe: no static geometry to bake")?;
let draw_args = self
.build_draw_args_buffer(eye, slot)?
.ok_or("probe: no draw args to bake")?;
let counts = self.draw_record_counts();
let tex_args = self
.build_bindless_texture_args(slot)?
.ok_or("probe: no bindless texture args")?;
let joint_bufs = self.build_joint_buffers(slot)?;
let morph_weight_bufs = self.build_morph_weight_buffers(slot)?;
let deformed: Option<Retained<ProtocolObject<dyn objc2_metal::MTLBuffer>>> =
if self.draw.n_skinned > 0 {
match self.skinned.deformed.first().map(|b| b.length()) {
Some(len) if len > 0 => Some(
self.device
.newBufferWithLength_options(len, MTLResourceOptions::StorageModeShared)
.ok_or("probe: failed to allocate deformed buffer")?,
),
_ => None,
}
} else {
None
};
let msaa_color = make_msaa_color(&self.device, PLAN.face_size())?;
let msaa_depth = make_msaa_depth(&self.device, PLAN.face_size())?;
let capture = create_capture_cube(&self.device, &PLAN)?;
self.probe.rendering = Some(RenderingBake {
index,
placement,
done: Arc::new(AtomicBool::new(false)),
cursor: 0,
eye,
near,
far,
elapsed,
gpu: BakeGpu {
msaa_color,
msaa_depth,
capture,
object_buffer,
draw_args,
tex_args,
joint_bufs,
morph_weight_bufs,
deformed,
},
counts,
});
Ok(())
}
fn probe_render_next_face(&mut self) -> Result<(), String> {
let Some(bake) = self.probe.rendering.as_ref() else {
return Err("probe: render face with no capture in flight".into());
};
let (face, eye, near, far, elapsed, counts) = (
bake.cursor,
bake.eye,
bake.near,
bake.far,
bake.elapsed,
bake.counts,
);
let attach_done = face + 1 == PROBE_FACE_COUNT;
self.ensure_icb_capacity(counts.total)?;
let vp = reflection_probe::face_view_projection(eye, face, near, far);
let view = reflection_probe::face_view_matrix(eye, face);
let frustum = crate::gfx::frustum::Frustum::from_view_projection(vp);
let RenderingBake { done, gpu, .. } = self
.probe
.rendering
.as_ref()
.expect("probe capture was just checked");
let cull_cb = self
.command_queue
.commandBuffer()
.ok_or("probe: failed to get cull command buffer")?;
if face == 0
&& let Some(def) = gpu.deformed.as_ref()
{
self.encode_main_skin(
&cull_cb,
def,
crate::metal::raytrace::MainSkinBuffers {
joints: &gpu.joint_bufs,
morph_weights: &gpu.morph_weight_bufs,
},
)?;
}
self.encode_cull(
&cull_cb,
&gpu.object_buffer,
&gpu.draw_args,
&frustum,
eye,
counts,
)?;
cull_cb.commit();
let render_cb = self
.command_queue
.commandBuffer()
.ok_or("probe: failed to get render command buffer")?;
let prepared = super::instanced::PreparedInstances {
clusters: Vec::new(),
};
self.encode_main_into_face(
&render_cb,
crate::metal::draw::main::FaceTargets {
color_msaa: &gpu.msaa_color,
depth_msaa: &gpu.msaa_depth,
resolve: &gpu.capture,
resolve_slice: face,
},
crate::metal::draw::main::MainPassCamera {
elapsed,
vp,
view,
cam_pos: eye,
},
crate::metal::draw::main::DrawInputs {
visible: &[],
prepared_instances: &prepared,
skinned_joint_bufs: &gpu.joint_bufs,
},
crate::metal::draw::main::GpuFrameBuffers {
object_buffer: Some(&gpu.object_buffer),
bindless_tex_args: Some(&gpu.tex_args),
deformed_skinned: gpu.deformed.as_ref(),
counts,
},
None,
)?;
if attach_done {
let flag = Arc::clone(done);
let handler = block2::RcBlock::new(
move |cb: NonNull<ProtocolObject<dyn objc2_metal::MTLCommandBuffer>>| {
let cb = unsafe { cb.as_ref() };
if cb.status() == MTLCommandBufferStatus::Error {
tracing::error!(
"reflection probe face bake faulted (async): {:?}",
cb.error()
);
}
flag.store(true, Ordering::Release);
},
);
unsafe {
render_cb.addCompletedHandler(block2::RcBlock::as_ptr(&handler));
}
}
render_cb.commit();
if let Some(RenderingBake { cursor, .. }) = &mut self.probe.rendering {
*cursor += 1;
}
Ok(())
}
fn probe_begin_prefilter(&mut self) -> Result<(), String> {
let RenderingBake {
index,
placement,
gpu,
..
} = self
.probe
.rendering
.take()
.ok_or("probe: prefilter with no bake in flight")?;
let BakeGpu { capture, .. } = gpu;
let prefilter_gpu = PrefilterGpu::new(&self.allocator, capture, &PLAN)?;
let cmd_buf = self
.command_queue
.commandBuffer()
.ok_or("probe: failed to get prefilter command buffer")?;
self.encode_probe_pyramid(&cmd_buf, &prefilter_gpu, &PLAN)?;
cmd_buf.commit();
self.probe.prefiltering = Some(PrefilteringBake {
index,
placement,
gpu: prefilter_gpu,
cursor: 1,
});
Ok(())
}
fn probe_prefilter_next_mip(&mut self) -> Result<(), String> {
let (cursor, gpu) = {
let bake = self
.probe
.prefiltering
.as_ref()
.ok_or("probe: convolve with no bake in flight")?;
(bake.cursor, &bake.gpu)
};
let cmd_buf = self
.command_queue
.commandBuffer()
.ok_or("probe: failed to get convolution command buffer")?;
self.encode_probe_ggx_mip(&cmd_buf, gpu, &PLAN, cursor)?;
cmd_buf.commit();
if let Some(bake) = self.probe.prefiltering.as_mut() {
bake.cursor += 1;
}
Ok(())
}
fn probe_install(&mut self) -> Result<(), String> {
let PrefilteringBake {
index,
placement: p,
gpu,
..
} = self
.probe
.prefiltering
.take()
.ok_or("probe: install with no bake in flight")?;
debug_assert_eq!(index, self.probe.maps.len());
self.probe.maps.push(super::context::ProbeCube {
prefilter: gpu.into_probe_cube(),
});
self.probe.set.probes[index] = concinnity_core::render::uniforms::ProbeUniforms {
box_min: [p.box_min[0], p.box_min[1], p.box_min[2], 1.0],
box_max: [p.box_max[0], p.box_max[1], p.box_max[2], 0.0],
probe_pos: [p.position[0], p.position[1], p.position[2], 0.0],
};
self.probe.set.count = self.probe.maps.len() as u32;
tracing::info!(
"reflection probes: baked {}/{}",
index + 1,
self.probe.placements.len()
);
Ok(())
}
pub(in crate::metal) fn scene_world_bounds(&self) -> Option<([f32; 3], [f32; 3])> {
reflection_probe::fold_world_bounds(self.draw.objects.iter().map(|o| (o.bb_min, o.bb_max)))
}
}
fn make_msaa_color(
device: &ProtocolObject<dyn objc2_metal::MTLDevice>,
size: u32,
) -> Result<Retained<ProtocolObject<dyn MTLTexture>>, String> {
let desc = TextureDesc {
kind: MTLTextureType::Type2DMultisample,
format: MTLPixelFormat::RGBA16Float,
width: size as usize,
height: size as usize,
sample_count: HDR_SAMPLE_COUNT as usize,
usage: MTLTextureUsage::RenderTarget,
..Default::default()
}
.build();
device
.newTextureWithDescriptor(&desc)
.ok_or_else(|| "probe: failed to create MSAA colour face".into())
}
fn make_msaa_depth(
device: &ProtocolObject<dyn objc2_metal::MTLDevice>,
size: u32,
) -> Result<Retained<ProtocolObject<dyn MTLTexture>>, String> {
let desc = TextureDesc {
kind: MTLTextureType::Type2DMultisample,
format: MTLPixelFormat::Depth32Float,
width: size as usize,
height: size as usize,
sample_count: HDR_SAMPLE_COUNT as usize,
usage: MTLTextureUsage::RenderTarget,
..Default::default()
}
.build();
device
.newTextureWithDescriptor(&desc)
.ok_or_else(|| "probe: failed to create MSAA depth face".into())
}