#![deny(unsafe_op_in_unsafe_fn)]
use std::ptr::NonNull;
use std::sync::Arc;
use std::sync::OnceLock;
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 _, MTLOrigin, MTLPixelFormat, MTLRegion, MTLResourceOptions, MTLSize,
MTLStorageMode, MTLTexture, MTLTextureType, MTLTextureUsage,
};
use super::context::{HDR_SAMPLE_COUNT, MtlContext};
use super::descriptors::TextureDesc;
use crate::gfx::image_decode::f16_to_f32;
use crate::gfx::reflection_probe::{self, BakeAction, BakePhase};
const PROBE_FACE_SIZE: u32 = 512;
const PROBE_IRRADIANCE_FACE: u32 = 16;
const PROBE_PREFILTER_SAMPLES: u32 = 128;
const PROBE_PREFILTER_CLAMP: f32 = 12.0;
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 ConvertingBake {
index: usize,
placement: reflection_probe::ProbePlacement,
payload: Arc<OnceLock<Vec<u8>>>,
}
pub(in crate::metal) struct BakeGpu {
msaa_color: Retained<ProtocolObject<dyn MTLTexture>>,
msaa_depth: Retained<ProtocolObject<dyn MTLTexture>>,
resolves: Vec<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());
if let Some(bake) = self.probe.rendering.take() {
self.probe.retire_pool.push(self.frame_ring_index, bake.gpu);
}
self.probe.converting = None;
}
fn bake_ring_slot(&self) -> usize {
self.frames_in_flight
}
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 {
if let Some(bake) = self.probe.rendering.take() {
self.probe.retire_pool.push(self.frame_ring_index, bake.gpu);
}
self.probe.converting = None;
self.probe.bake_queue.abort();
return Ok(());
}
let converting_occupied = self.probe.converting.is_some();
let payload_ready = self
.probe
.converting
.as_ref()
.is_some_and(|c| c.payload.get().is_some());
let install = reflection_probe::next_bake_action(
if converting_occupied {
BakePhase::Converting
} else {
BakePhase::Idle
},
false,
payload_ready,
false,
false,
false,
) == BakeAction::Install;
if install && let Err(e) = self.probe_install() {
self.fail_bake(e);
return Ok(());
}
let converting_free = !converting_occupied || install;
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
},
done && converting_free,
false,
self.probe.bake_queue.pending(),
eligible,
more_faces,
) {
BakeAction::RenderFace => {
if let Err(e) = self.probe_render_next_face() {
self.fail_bake(e);
}
}
BakeAction::Readback => {
if let Err(e) = self.probe_readback_and_convolve() {
self.fail_bake(e);
}
}
BakeAction::StartNext => {
if let Err(e) = self.probe_start_next(near, far, elapsed) {
self.fail_bake(e);
}
}
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.probe.rendering = None;
self.probe.converting = None;
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, PROBE_FACE_SIZE)?;
let msaa_depth = make_msaa_depth(&self.device, PROBE_FACE_SIZE)?;
let resolves: Vec<Retained<ProtocolObject<dyn MTLTexture>>> = (0..PROBE_FACE_COUNT)
.map(|_| make_resolve_shared(&self.device, PROBE_FACE_SIZE))
.collect::<Result<_, _>>()?;
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,
resolves,
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.resolves[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_readback_and_convolve(&mut self) -> Result<(), String> {
let RenderingBake {
index,
placement,
gpu,
..
} = self
.probe
.rendering
.take()
.ok_or("probe: readback with no bake in flight")?;
let mut faces: [Vec<f32>; 6] = std::array::from_fn(|_| Vec::new());
for (face, resolve) in gpu.resolves.iter().enumerate() {
faces[face] = read_face_rgba_f32(resolve, PROBE_FACE_SIZE)?;
}
drop(gpu);
let payload = Arc::new(OnceLock::new());
let slot = Arc::clone(&payload);
std::thread::spawn(move || {
let bytes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
reflection_probe::build_probe_payload(
&crate::pool_rows::PoolRows,
&faces,
PROBE_FACE_SIZE,
PROBE_IRRADIANCE_FACE,
PROBE_PREFILTER_SAMPLES,
PROBE_PREFILTER_CLAMP,
)
}))
.unwrap_or_else(|_| {
tracing::error!("reflection probe convolution panicked; abandoning bake");
Vec::new()
});
let _ = slot.set(bytes);
});
self.probe.converting = Some(ConvertingBake {
index,
placement,
payload,
});
Ok(())
}
fn probe_install(&mut self) -> Result<(), String> {
let ConvertingBake {
index,
placement: p,
payload,
} = self
.probe
.converting
.take()
.ok_or("probe: install with no bake in flight")?;
let bytes = payload.get().ok_or("probe: install before payload ready")?;
let textures = self.build_probe_textures(bytes)?;
debug_assert_eq!(index, self.probe.maps.len());
self.probe.maps.push(textures);
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())
}
fn make_resolve_shared(
device: &ProtocolObject<dyn objc2_metal::MTLDevice>,
size: u32,
) -> Result<Retained<ProtocolObject<dyn MTLTexture>>, String> {
let desc = TextureDesc {
format: MTLPixelFormat::RGBA16Float,
width: size as usize,
height: size as usize,
usage: MTLTextureUsage(MTLTextureUsage::ShaderRead.0 | MTLTextureUsage::RenderTarget.0),
storage: MTLStorageMode::Shared,
..Default::default()
}
.build();
device
.newTextureWithDescriptor(&desc)
.ok_or_else(|| "probe: failed to create resolve face".into())
}
fn read_face_rgba_f32(
tex: &ProtocolObject<dyn MTLTexture>,
face_size: u32,
) -> Result<Vec<f32>, String> {
let w = face_size as usize;
let h = w;
let bytes_per_row = w * 8; let mut raw = vec![0u8; bytes_per_row * h];
let region = MTLRegion {
origin: MTLOrigin { x: 0, y: 0, z: 0 },
size: MTLSize {
width: w,
height: h,
depth: 1,
},
};
unsafe {
tex.getBytes_bytesPerRow_fromRegion_mipmapLevel(
std::ptr::NonNull::new(raw.as_mut_ptr() as *mut std::ffi::c_void)
.ok_or("probe: null readback pointer")?,
bytes_per_row,
region,
0,
);
}
let mut out = vec![0.0f32; w * h * 4];
for (i, px) in raw.chunks_exact(8).enumerate() {
out[i * 4] = f16_to_f32(u16::from_le_bytes([px[0], px[1]]));
out[i * 4 + 1] = f16_to_f32(u16::from_le_bytes([px[2], px[3]]));
out[i * 4 + 2] = f16_to_f32(u16::from_le_bytes([px[4], px[5]]));
out[i * 4 + 3] = f16_to_f32(u16::from_le_bytes([px[6], px[7]]));
}
Ok(out)
}