diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-22 12:12:27 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-22 12:13:32 +0300 |
| commit | d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch) | |
| tree | a0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-render/src | |
| parent | 7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff) | |
| download | fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip | |
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/fparkan-render/src')
| -rw-r--r-- | crates/fparkan-render/src/lib.rs | 554 |
1 files changed, 554 insertions, 0 deletions
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs new file mode 100644 index 0000000..a2f18d6 --- /dev/null +++ b/crates/fparkan-render/src/lib.rs @@ -0,0 +1,554 @@ +#![forbid(unsafe_code)] +//! Backend-neutral render commands and deterministic captures. + +use fparkan_world::OriginalObjectId; + +/// Immutable camera data visible to command generation. +#[derive(Clone, Debug, PartialEq)] +pub struct CameraSnapshot { + /// View matrix, row-major. + pub view: [f32; 16], + /// Projection matrix, row-major. + pub projection: [f32; 16], +} + +impl Default for CameraSnapshot { + fn default() -> Self { + Self { + view: identity_transform(), + projection: identity_transform(), + } + } +} + +/// Draw id. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct DrawId(pub u64); + +/// GPU mesh id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GpuMeshId(pub u64); + +/// GPU material id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GpuMaterialId(pub u64); + +/// Render phase. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum RenderPhase { + /// Terrain. + Terrain, + /// Opaque. + Opaque, + /// Alpha test. + AlphaTest, + /// Transparent. + Transparent, + /// Effects. + Effects, + /// Debug. + Debug, + /// UI. + Ui, +} + +/// Index range. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IndexRange { + /// Start. + pub start: u32, + /// Count. + pub count: u32, +} + +/// A draw candidate in an immutable render snapshot. +#[derive(Clone, Debug, PartialEq)] +pub struct RenderSnapshotDraw { + /// Draw id. + pub id: DrawId, + /// Phase. + pub phase: RenderPhase, + /// Object id. + pub object_id: Option<OriginalObjectId>, + /// Mesh. + pub mesh: GpuMeshId, + /// Material table after WEAR/MAT0 fallback resolution. + pub material_slots: Vec<GpuMaterialId>, + /// Batch material index into [`Self::material_slots`]. + pub material_index: u16, + /// Node transform matrix, row-major. + pub transform: [f32; 16], + /// Index range. + pub range: IndexRange, + /// Stable sort order. + pub stable_order: u64, +} + +/// Immutable backend-neutral render snapshot. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RenderSnapshot { + /// Camera data for the frame. + pub camera: CameraSnapshot, + /// Draw candidates gathered from world/assets. + pub draws: Vec<RenderSnapshotDraw>, +} + +/// Command generation profile. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RenderProfile { + /// Include UI phase commands when present. + pub include_ui: bool, +} + +/// Draw command. +#[derive(Clone, Debug, PartialEq)] +pub struct DrawCommand { + /// Draw id. + pub id: DrawId, + /// Phase. + pub phase: RenderPhase, + /// Object id. + pub object_id: Option<OriginalObjectId>, + /// Mesh. + pub mesh: GpuMeshId, + /// Material. + pub material: GpuMaterialId, + /// Transform matrix, row-major. + pub transform: [f32; 16], + /// Index range. + pub range: IndexRange, + /// Stable sort order. + pub stable_order: u64, +} + +/// Render command. +#[derive(Clone, Debug, PartialEq)] +pub enum RenderCommand { + /// Begin frame. + BeginFrame, + /// Draw. + Draw(DrawCommand), + /// End frame. + EndFrame, +} + +/// Render command list. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RenderCommandList { + /// Commands. + pub commands: Vec<RenderCommand>, +} + +/// Frame output. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct FrameOutput; + +/// Render error. +#[derive(Debug)] +pub enum RenderError { + /// Invalid range. + InvalidRange, + /// Invalid draw range with command-generation context. + InvalidDrawRange { + /// Draw id. + draw_id: DrawId, + /// Stable sort order. + stable_order: u64, + /// Range start. + start: u32, + /// Range count. + count: u32, + }, + /// A batch material index did not resolve through the material table. + MaterialIndexOutOfBounds { + /// Draw id. + draw_id: DrawId, + /// Requested material index. + material_index: u16, + /// Available material slots. + material_count: usize, + }, +} + +impl std::fmt::Display for RenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for RenderError {} + +/// Builds a deterministic command list from an immutable render snapshot. +/// +/// # Errors +/// +/// Returns [`RenderError`] when a draw has an invalid index range or a material +/// index that cannot be resolved through its material slot table. +pub fn build_commands( + snapshot: &RenderSnapshot, + profile: RenderProfile, +) -> Result<RenderCommandList, RenderError> { + let mut draws = snapshot + .draws + .iter() + .filter(|draw| profile.include_ui || draw.phase != RenderPhase::Ui) + .collect::<Vec<_>>(); + draws.sort_by_key(|draw| (draw.phase, draw.stable_order, draw.id)); + + let mut commands = Vec::with_capacity(draws.len() + 2); + commands.push(RenderCommand::BeginFrame); + for draw in draws { + if draw.range.count == 0 { + return Err(RenderError::InvalidDrawRange { + draw_id: draw.id, + stable_order: draw.stable_order, + start: draw.range.start, + count: draw.range.count, + }); + } + let material = draw + .material_slots + .get(usize::from(draw.material_index)) + .copied() + .ok_or(RenderError::MaterialIndexOutOfBounds { + draw_id: draw.id, + material_index: draw.material_index, + material_count: draw.material_slots.len(), + })?; + commands.push(RenderCommand::Draw(DrawCommand { + id: draw.id, + phase: draw.phase, + object_id: draw.object_id, + mesh: draw.mesh, + material, + transform: draw.transform, + range: draw.range, + stable_order: draw.stable_order, + })); + } + commands.push(RenderCommand::EndFrame); + Ok(RenderCommandList { commands }) +} + +/// Backend port. +pub trait RenderBackend { + /// Executes commands. + /// + /// # Errors + /// + /// Returns [`RenderError`] when the command stream is malformed for the + /// backend. + fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>; +} + +/// Backend that validates commands and intentionally produces no pixels. +#[derive(Clone, Debug, Default)] +pub struct NullBackend; + +impl RenderBackend for NullBackend { + fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { + validate_commands(commands)?; + Ok(FrameOutput) + } +} + +/// Backend that stores deterministic command captures for verification. +#[derive(Clone, Debug, Default)] +pub struct RecordingBackend { + captures: Vec<Vec<u8>>, +} + +impl RecordingBackend { + /// Returns all captures in submission order. + #[must_use] + pub fn captures(&self) -> &[Vec<u8>] { + &self.captures + } + + /// Returns the most recent capture. + #[must_use] + pub fn last_capture(&self) -> Option<&[u8]> { + self.captures.last().map(Vec::as_slice) + } + + /// Clears stored captures without changing backend behavior. + pub fn clear(&mut self) { + self.captures.clear(); + } +} + +impl RenderBackend for RecordingBackend { + fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { + let capture = canonical_capture(commands)?; + self.captures.push(capture); + Ok(FrameOutput) + } +} + +/// Builds a canonical capture. +/// +/// # Errors +/// +/// Returns [`RenderError`] when a draw command contains an invalid index range. +pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> { + validate_commands(commands)?; + let mut out = Vec::new(); + for command in &commands.commands { + match command { + RenderCommand::BeginFrame => out.extend_from_slice(b"B\n"), + RenderCommand::EndFrame => out.extend_from_slice(b"E\n"), + RenderCommand::Draw(draw) => { + out.extend_from_slice( + format!( + "D,{:?},{},{},{},{}\n", + draw.phase, draw.id.0, draw.mesh.0, draw.material.0, draw.stable_order + ) + .as_bytes(), + ); + } + } + } + Ok(out) +} + +fn validate_commands(commands: &RenderCommandList) -> Result<(), RenderError> { + for command in &commands.commands { + if let RenderCommand::Draw(draw) = command { + if draw.range.count == 0 { + return Err(RenderError::InvalidRange); + } + } + } + Ok(()) +} + +fn identity_transform() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot_draw( + id: u64, + phase: RenderPhase, + material_index: u16, + stable_order: u64, + ) -> RenderSnapshotDraw { + RenderSnapshotDraw { + id: DrawId(id), + phase, + object_id: Some(OriginalObjectId(u32::try_from(id).expect("id fits"))), + mesh: GpuMeshId(10 + id), + material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)], + material_index, + transform: identity_transform(), + range: IndexRange { start: 0, count: 3 }, + stable_order, + } + } + + #[test] + fn capture_is_stable() { + let list = RenderCommandList { + commands: vec![ + RenderCommand::BeginFrame, + RenderCommand::Draw(DrawCommand { + id: DrawId(1), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(2), + material: GpuMaterialId(3), + transform: [0.0; 16], + range: IndexRange { start: 0, count: 3 }, + stable_order: 4, + }), + RenderCommand::EndFrame, + ], + }; + assert_eq!( + canonical_capture(&list).expect("capture"), + b"B\nD,Opaque,1,2,3,4\nE\n" + ); + } + + #[test] + fn null_backend_validates_without_capture() { + let mut backend = NullBackend; + let invalid = RenderCommandList { + commands: vec![RenderCommand::Draw(DrawCommand { + id: DrawId(1), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(2), + material: GpuMaterialId(3), + transform: [0.0; 16], + range: IndexRange { start: 0, count: 0 }, + stable_order: 4, + })], + }; + + assert!(matches!( + backend.execute(&invalid), + Err(RenderError::InvalidRange) + )); + } + + #[test] + fn recording_backend_stores_captures() { + let mut backend = RecordingBackend::default(); + let list = RenderCommandList { + commands: vec![RenderCommand::BeginFrame, RenderCommand::EndFrame], + }; + + backend.execute(&list).expect("execute"); + backend.execute(&list).expect("execute"); + + assert_eq!(backend.captures().len(), 2); + assert_eq!(backend.last_capture(), Some(&b"B\nE\n"[..])); + backend.clear(); + assert!(backend.captures().is_empty()); + } + + #[test] + fn one_snapshot_draw_produces_one_draw_command() -> Result<(), RenderError> { + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)], + }; + + let commands = build_commands(&snapshot, RenderProfile::default())?; + + assert!(matches!(commands.commands[0], RenderCommand::BeginFrame)); + assert!(matches!(commands.commands[2], RenderCommand::EndFrame)); + let RenderCommand::Draw(draw) = &commands.commands[1] else { + panic!("expected draw"); + }; + assert_eq!(draw.id, DrawId(1)); + assert_eq!(draw.mesh, GpuMeshId(11)); + assert_eq!(draw.range, IndexRange { start: 0, count: 3 }); + Ok(()) + } + + #[test] + fn material_index_maps_through_resolved_material_slots() -> Result<(), RenderError> { + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![snapshot_draw(2, RenderPhase::Opaque, 1, 10)], + }; + + let commands = build_commands(&snapshot, RenderProfile::default())?; + + let RenderCommand::Draw(draw) = &commands.commands[1] else { + panic!("expected draw"); + }; + assert_eq!(draw.material, GpuMaterialId(37)); + Ok(()) + } + + #[test] + fn node_transform_is_retained() -> Result<(), RenderError> { + let mut draw = snapshot_draw(3, RenderPhase::Opaque, 0, 10); + draw.transform[3] = 12.5; + draw.transform[7] = -4.0; + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![draw], + }; + + let commands = build_commands(&snapshot, RenderProfile::default())?; + + let RenderCommand::Draw(draw) = &commands.commands[1] else { + panic!("expected draw"); + }; + assert_eq!(draw.transform[3], 12.5); + assert_eq!(draw.transform[7], -4.0); + Ok(()) + } + + #[test] + fn command_order_uses_phase_then_stable_key() -> Result<(), RenderError> { + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![ + snapshot_draw(3, RenderPhase::Transparent, 0, 0), + snapshot_draw(2, RenderPhase::Opaque, 0, 20), + snapshot_draw(1, RenderPhase::Opaque, 0, 10), + ], + }; + + let commands = build_commands(&snapshot, RenderProfile::default())?; + let capture = canonical_capture(&commands)?; + + assert_eq!( + capture, + b"B\nD,Opaque,1,11,31,10\nD,Opaque,2,12,31,20\nD,Transparent,3,13,31,0\nE\n" + ); + Ok(()) + } + + #[test] + fn command_capture_independent_of_snapshot_construction_order() -> Result<(), RenderError> { + let forward = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![ + snapshot_draw(1, RenderPhase::Opaque, 0, 10), + snapshot_draw(2, RenderPhase::Opaque, 1, 20), + ], + }; + let reverse = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![ + snapshot_draw(2, RenderPhase::Opaque, 1, 20), + snapshot_draw(1, RenderPhase::Opaque, 0, 10), + ], + }; + + assert_eq!( + canonical_capture(&build_commands(&forward, RenderProfile::default())?)?, + canonical_capture(&build_commands(&reverse, RenderProfile::default())?)? + ); + Ok(()) + } + + #[test] + fn invalid_range_returns_contextual_error() { + let mut draw = snapshot_draw(9, RenderPhase::Opaque, 0, 10); + draw.range = IndexRange { start: 4, count: 0 }; + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![draw], + }; + + assert!(matches!( + build_commands(&snapshot, RenderProfile::default()), + Err(RenderError::InvalidDrawRange { + draw_id: DrawId(9), + stable_order: 10, + start: 4, + count: 0 + }) + )); + } + + #[test] + fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> { + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![ + snapshot_draw(1, RenderPhase::Opaque, 0, 10), + snapshot_draw(2, RenderPhase::Ui, 0, 20), + ], + }; + + let default_commands = build_commands(&snapshot, RenderProfile::default())?; + let ui_commands = build_commands(&snapshot, RenderProfile { include_ui: true })?; + + assert_eq!(default_commands.commands.len(), 3); + assert_eq!(ui_commands.commands.len(), 4); + Ok(()) + } +} |
