#![deny(unsafe_op_in_unsafe_fn)]
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_metal::{
MTLBlendFactor, MTLBuffer, MTLCommandBuffer as _, MTLComputeCommandEncoder as _,
MTLComputePassDescriptor, MTLComputePipelineState, MTLDevice as _, MTLLibrary as _,
MTLLoadAction, MTLPixelFormat, MTLPrimitiveType, MTLRenderCommandEncoder as _,
MTLRenderPassDescriptor, MTLRenderPipelineDescriptor, MTLRenderPipelineState,
MTLResourceOptions, MTLSamplerAddressMode, MTLSamplerDescriptor, MTLSamplerMinMagFilter,
MTLSamplerState, MTLSize, MTLStoreAction,
};
use crate::gfx::particles::{ParticleEmitterRecord, ParticleSpawnState};
use super::context::MtlContext;
use super::encode::{ComputeEncode, RenderEncode};
use super::pipeline::ns_str;
use super::scoped_encoder::ScopedEncoder;
use concinnity_core::render::uniforms::GpuParticle;
use concinnity_core::render::uniforms::ParticleView;
const SPAWN_COUNTER_STRIDE: usize = 256;
fn spawn_counter_offset(slot: usize) -> usize {
slot * SPAWN_COUNTER_STRIDE
}
fn spawn_counter_bytes(frames_in_flight: usize) -> usize {
frames_in_flight.max(1) * SPAWN_COUNTER_STRIDE
}
fn next_counter_slot(slot: usize, frames_in_flight: usize) -> usize {
(slot + 1) % frames_in_flight.max(1)
}
pub(super) struct ParticleEmitterGpuState {
pub pool: Retained<ProtocolObject<dyn MTLBuffer>>,
pub spawn_counter: Retained<ProtocolObject<dyn MTLBuffer>>,
pub spawn_state: ParticleSpawnState,
}
pub(super) struct ParticlePipelines {
pub simulate: Retained<ProtocolObject<dyn MTLComputePipelineState>>,
pub render: Retained<ProtocolObject<dyn MTLRenderPipelineState>>,
pub sampler: Retained<ProtocolObject<dyn MTLSamplerState>>,
}
pub(crate) struct ParticleState {
pub records: Vec<Option<ParticleEmitterRecord>>,
pub emitter_state: Vec<Option<ParticleEmitterGpuState>>,
pub free_slots: Vec<usize>,
pub pipelines: Option<ParticlePipelines>,
pub last_elapsed: f32,
pub frame_index: u32,
pub counter_slot: usize,
}
pub(in crate::metal) struct ParticleFrame {
pub dt: f32,
pub frame_index: u32,
pub counter_slot: usize,
pub spawn_budgets: Vec<u32>,
}
impl MtlContext {
pub(in crate::metal) fn prepare_particle_pass(
&mut self,
elapsed: f32,
) -> Option<ParticleFrame> {
self.particle.pipelines.as_ref()?;
if self.particle.records.is_empty() || self.particle.emitter_state.is_empty() {
return None;
}
let dt = (elapsed - self.particle.last_elapsed).max(0.0);
self.particle.last_elapsed = elapsed;
self.particle.frame_index = self.particle.frame_index.wrapping_add(1);
let frame_index = self.particle.frame_index;
let counter_slot = next_counter_slot(self.particle.counter_slot, self.frames_in_flight);
self.particle.counter_slot = counter_slot;
let offset = spawn_counter_offset(counter_slot);
let mut budgets = Vec::with_capacity(self.particle.records.len());
for (rec_slot, gpu_slot) in self
.particle
.records
.iter()
.zip(self.particle.emitter_state.iter_mut())
{
let budget = match (rec_slot.as_ref(), gpu_slot.as_mut()) {
(Some(rec), Some(gpu)) => {
let spawn = gpu
.spawn_state
.take_budget(dt, rec.spawn_rate, rec.max_particles);
unsafe {
let dst = gpu.spawn_counter.contents().as_ptr().add(offset) as *mut u32;
dst.write(spawn);
}
spawn
}
_ => 0,
};
budgets.push(budget);
}
Some(ParticleFrame {
dt,
frame_index,
counter_slot,
spawn_budgets: budgets,
})
}
pub(in crate::metal) fn encode_particles(
&self,
cmd_buf: &ProtocolObject<dyn objc2_metal::MTLCommandBuffer>,
frame: &ParticleFrame,
vp: [[f32; 4]; 4],
frustum: &crate::gfx::frustum::Frustum,
) -> Result<u32, String> {
let Some(pipelines) = self.particle.pipelines.as_ref() else {
return Ok(0);
};
if self.particle.records.is_empty() || self.particle.emitter_state.is_empty() {
return Ok(0);
}
let ParticleFrame {
dt,
frame_index,
counter_slot,
spawn_budgets,
} = frame;
let (dt, frame_index) = (*dt, *frame_index);
let counter_offset = spawn_counter_offset(*counter_slot);
let last_tex = self.textures.len().saturating_sub(1);
let visible: Vec<bool> = self
.particle
.records
.iter()
.map(|slot| match slot {
Some(r) => {
let (mn, mx) = r.aabb();
frustum.intersects_aabb(mn, mx)
}
None => false,
})
.collect();
let v = self.view.matrix;
let cam_right = [v[0][0], v[1][0], v[2][0]];
let cam_up = [v[0][1], v[1][1], v[2][1]];
let view = ParticleView {
vp,
cam_right,
_pad0: 0.0,
cam_up,
_pad1: 0.0,
};
{
let sim_desc = MTLComputePassDescriptor::new();
if let Some(t) = &self.diagnostics.pass_timing {
t.attach_compute(&sim_desc, super::pass_timing::PassId::ParticlesSim);
}
let enc = ScopedEncoder::new(
cmd_buf
.computeCommandEncoderWithDescriptor(&sim_desc)
.ok_or("failed to get particle compute encoder")?,
"particles: simulate",
);
enc.set_pipeline(&pipelines.simulate);
for (i, (rec_slot, gpu_slot)) in self
.particle
.records
.iter()
.zip(self.particle.emitter_state.iter())
.enumerate()
{
let (rec, gpu) = match (rec_slot.as_ref(), gpu_slot.as_ref()) {
(Some(r), Some(g)) => (r, g),
_ => continue,
};
let spawn_budget = spawn_budgets.get(i).copied().unwrap_or(0);
let params = rec.params(dt, spawn_budget, frame_index);
enc.set_buffer(gpu.pool.as_ref(), 0, 0);
enc.set_buffer(gpu.spawn_counter.as_ref(), counter_offset, 1);
enc.set_value(¶ms, 2);
let grid = MTLSize {
width: rec.max_particles as usize,
height: 1,
depth: 1,
};
let tg = MTLSize {
width: 64,
height: 1,
depth: 1,
};
enc.dispatchThreads_threadsPerThreadgroup(grid, tg);
}
}
if !visible.iter().any(|v| *v) {
return Ok(0);
}
let pass_desc = MTLRenderPassDescriptor::new();
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::ParticlesDraw);
}
let enc = ScopedEncoder::new(
cmd_buf
.renderCommandEncoderWithDescriptor(&pass_desc)
.ok_or("failed to get particle render encoder")?,
"particles: draw",
);
enc.set_pipeline(&pipelines.render);
enc.set_vertex_value(&view, 1);
enc.set_fragment_sampler(&pipelines.sampler, 0);
let mut draw_calls: u32 = 0;
for (i, (rec_slot, gpu_slot)) in self
.particle
.records
.iter()
.zip(self.particle.emitter_state.iter())
.enumerate()
{
if !visible[i] {
continue;
}
let (rec, gpu) = match (rec_slot.as_ref(), gpu_slot.as_ref()) {
(Some(r), Some(g)) => (r, g),
_ => continue,
};
let params = rec.params(0.0, 0, frame_index);
let slot = rec.texture_slot.min(last_tex);
enc.set_vertex_buffer(gpu.pool.as_ref(), 0, 0);
enc.set_vertex_value(¶ms, 2);
enc.set_fragment_texture(self.textures[slot].as_ref(), 0);
unsafe {
enc.drawPrimitives_vertexStart_vertexCount_instanceCount(
MTLPrimitiveType::TriangleStrip,
0,
4,
rec.max_particles as usize,
);
}
draw_calls += 1;
}
Ok(draw_calls)
}
}
pub(super) fn build_particle_pipelines(
device: &ProtocolObject<dyn objc2_metal::MTLDevice>,
hot_reload: bool,
) -> Result<ParticlePipelines, String> {
let sim_lib = super::slang_shaders::PARTICLE_SIMULATE.library(device, hot_reload)?;
let sim_fn = sim_lib
.newFunctionWithName(&ns_str("particle_simulate"))
.ok_or("particle_simulate not found")?;
let simulate = device
.newComputePipelineStateWithFunction_error(&sim_fn)
.map_err(|e| format!("failed to create particle_simulate pipeline: {:?}", e))?;
let vert_fn = super::slang_shaders::entry_function(
device,
&super::slang_shaders::PARTICLE_VERT,
hot_reload,
)?;
let frag_fn = super::slang_shaders::entry_function(
device,
&super::slang_shaders::PARTICLE_FRAG,
hot_reload,
)?;
let desc = MTLRenderPipelineDescriptor::new();
desc.setVertexFunction(Some(&vert_fn));
desc.setFragmentFunction(Some(&frag_fn));
desc.setRasterSampleCount(1);
unsafe {
let ca = desc.colorAttachments().objectAtIndexedSubscript(0);
ca.setPixelFormat(MTLPixelFormat::RGBA16Float);
ca.setBlendingEnabled(true);
ca.setSourceRGBBlendFactor(MTLBlendFactor::SourceAlpha);
ca.setDestinationRGBBlendFactor(MTLBlendFactor::OneMinusSourceAlpha);
ca.setSourceAlphaBlendFactor(MTLBlendFactor::SourceAlpha);
ca.setDestinationAlphaBlendFactor(MTLBlendFactor::OneMinusSourceAlpha);
}
let render = device
.newRenderPipelineStateWithDescriptor_error(&desc)
.map_err(|e| format!("failed to create particle render pipeline: {:?}", e))?;
let sampler = {
let sdesc = MTLSamplerDescriptor::new();
sdesc.setMinFilter(MTLSamplerMinMagFilter::Linear);
sdesc.setMagFilter(MTLSamplerMinMagFilter::Linear);
sdesc.setSAddressMode(MTLSamplerAddressMode::ClampToEdge);
sdesc.setTAddressMode(MTLSamplerAddressMode::ClampToEdge);
device
.newSamplerStateWithDescriptor(&sdesc)
.ok_or("failed to create particle sampler state")?
};
Ok(ParticlePipelines {
simulate,
render,
sampler,
})
}
pub(super) fn build_emitter_gpu_state(
device: &ProtocolObject<dyn objc2_metal::MTLDevice>,
record: &ParticleEmitterRecord,
frames_in_flight: usize,
) -> Result<ParticleEmitterGpuState, String> {
let slots = record.max_particles as usize;
let pool_bytes = slots * std::mem::size_of::<GpuParticle>();
let pool = device
.newBufferWithLength_options(pool_bytes, MTLResourceOptions::StorageModeShared)
.ok_or("failed to allocate particle pool buffer")?;
unsafe {
let dst = pool.contents().as_ptr() as *mut u8;
std::ptr::write_bytes(dst, 0, pool_bytes);
}
let counter_bytes = spawn_counter_bytes(frames_in_flight);
let spawn_counter = device
.newBufferWithLength_options(counter_bytes, MTLResourceOptions::StorageModeShared)
.ok_or("failed to allocate particle spawn counter")?;
unsafe {
let dst = spawn_counter.contents().as_ptr() as *mut u8;
std::ptr::write_bytes(dst, 0, counter_bytes);
}
Ok(ParticleEmitterGpuState {
pool,
spawn_counter,
spawn_state: ParticleSpawnState::default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counter_slots_are_stride_aligned_and_distinct() {
let offsets: Vec<usize> = (0..3).map(spawn_counter_offset).collect();
assert_eq!(offsets, vec![0, 256, 512]);
for (slot, offset) in offsets.iter().enumerate() {
assert_eq!(offset % SPAWN_COUNTER_STRIDE, 0, "slot {slot} misaligned");
assert!(offset + std::mem::size_of::<u32>() <= spawn_counter_bytes(3));
}
}
#[test]
fn counter_buffer_holds_one_slot_per_frame_in_flight() {
assert_eq!(spawn_counter_bytes(3), 3 * SPAWN_COUNTER_STRIDE);
assert_eq!(spawn_counter_bytes(1), SPAWN_COUNTER_STRIDE);
assert_eq!(spawn_counter_bytes(0), SPAWN_COUNTER_STRIDE);
assert_eq!(next_counter_slot(0, 0), 0);
}
#[test]
fn counter_slot_cycles_over_the_frames_in_flight_depth() {
let depth = 3;
let mut slot = 0;
let mut seen = Vec::new();
for _ in 0..depth {
slot = next_counter_slot(slot, depth);
assert!(slot < depth, "slot {slot} outside the allocated depth");
seen.push(slot);
}
let first = seen[0];
assert_eq!(next_counter_slot(slot, depth), first);
seen.sort_unstable();
assert_eq!(seen, (0..depth).collect::<Vec<_>>());
}
#[test]
fn single_frame_in_flight_pins_slot_zero() {
assert_eq!(next_counter_slot(0, 1), 0);
assert_eq!(next_counter_slot(5, 1), 0);
}
}