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.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-render"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-world = { path = "../fparkan-world" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user