Build Rust render graph core

Keep hot render state in a generic shared SOA arena and use worker messages only for allocation, graph compilation, loadout changes, and render pacing. Compile external S-expression graphs into upfront WebGPU resources, transient aliases, and reusable render bundles.

Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-20 05:35:24 +00:00
co-authored by heaust
parent d095ee7849
commit 239b1d25b0
17 changed files with 2697 additions and 667 deletions
+500
View File
@@ -0,0 +1,500 @@
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenderGraph {
pub id: String,
#[serde(default)]
pub resources: ResourceDeclarations,
#[serde(default)]
pub pipelines: PipelineDeclarations,
pub passes: Vec<Pass>,
}
#[derive(Clone, Default, Deserialize)]
pub struct ResourceDeclarations {
#[serde(default)]
pub buffers: Vec<Buffer>,
#[serde(default)]
pub textures: Vec<Texture>,
#[serde(default)]
pub samplers: Vec<Sampler>,
}
#[derive(Clone, Deserialize)]
pub struct Buffer {
pub id: String,
pub array: String,
#[serde(default)]
pub usage: Vec<String>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Texture {
pub id: String,
#[serde(default)]
pub size: Vec<Extent>,
pub format: String,
#[serde(default)]
pub usage: Vec<String>,
#[serde(default = "one")]
pub mip_level_count: u32,
#[serde(default = "one")]
pub sample_count: u32,
#[serde(default = "dimension")]
pub dimension: String,
#[serde(default = "yes")]
pub transient: bool,
#[serde(skip)]
pub slot: usize,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Extent {
Pixels(u32),
Canvas(String),
}
#[derive(Clone, Deserialize)]
pub struct Sampler {
pub id: String,
#[serde(default)]
pub descriptor: Value,
}
#[derive(Clone, Default, Deserialize)]
pub struct PipelineDeclarations {
#[serde(default)]
pub render: Vec<RenderPipeline>,
#[serde(default)]
pub compute: Vec<ComputePipeline>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenderPipeline {
pub id: String,
pub code: String,
#[serde(default)]
pub vertex: VertexStage,
#[serde(default)]
pub fragment: FragmentStage,
#[serde(default)]
pub primitive: Value,
#[serde(default)]
pub depth_stencil: Value,
#[serde(default)]
pub multisample: Value,
}
#[derive(Clone, Deserialize)]
pub struct ComputePipeline {
pub id: String,
pub code: String,
#[serde(default = "compute_entry")]
pub entry: String,
}
#[derive(Clone, Deserialize)]
pub struct VertexStage {
#[serde(default = "vertex_entry")]
pub entry: String,
#[serde(default)]
pub buffers: Vec<VertexBuffer>,
}
impl Default for VertexStage {
fn default() -> Self {
Self {
entry: vertex_entry(),
buffers: Vec::new(),
}
}
}
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VertexBuffer {
pub array_stride: u64,
#[serde(default = "vertex_step")]
pub step_mode: String,
#[serde(default)]
pub attributes: Vec<VertexAttribute>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VertexAttribute {
pub format: String,
pub offset: u64,
pub shader_location: u32,
}
#[derive(Clone, Deserialize)]
pub struct FragmentStage {
#[serde(default = "fragment_entry")]
pub entry: String,
#[serde(default)]
pub targets: Vec<FragmentTarget>,
}
impl Default for FragmentStage {
fn default() -> Self {
Self {
entry: fragment_entry(),
targets: Vec::new(),
}
}
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FragmentTarget {
pub format: String,
#[serde(default)]
pub blend: Value,
pub write_mask: Option<u32>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Pass {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub pipeline: String,
#[serde(default)]
pub after: Vec<String>,
#[serde(skip)]
pub dependencies: Vec<usize>,
#[serde(default)]
pub bindings: Vec<Binding>,
#[serde(default)]
pub color: Vec<ColorAttachment>,
pub depth: Option<DepthAttachment>,
#[serde(default)]
pub vertex_buffers: Vec<VertexBinding>,
pub index_buffer: Option<IndexBinding>,
#[serde(default)]
pub draw: Draw,
#[serde(default = "dispatch")]
pub dispatch: [u32; 3],
}
#[derive(Clone, Deserialize)]
pub struct Binding {
#[serde(default)]
pub group: u32,
pub binding: u32,
pub resource: String,
#[serde(default)]
pub offset: u64,
pub size: Option<u64>,
}
#[derive(Clone, Deserialize)]
pub struct ColorAttachment {
pub resource: String,
#[serde(default)]
pub clear: Vec<f32>,
#[serde(default = "clear_op")]
pub load: String,
#[serde(default = "store_op")]
pub store: String,
}
#[derive(Clone, Deserialize)]
pub struct DepthAttachment {
pub resource: String,
#[serde(default = "one_f32")]
pub clear: f32,
#[serde(default = "clear_op")]
pub load: String,
#[serde(default = "store_op")]
pub store: String,
}
#[derive(Clone, Deserialize)]
pub struct VertexBinding {
#[serde(default)]
pub slot: u32,
pub resource: String,
#[serde(default)]
pub offset: u64,
}
#[derive(Clone, Deserialize)]
pub struct IndexBinding {
pub resource: String,
#[serde(default = "index_format")]
pub format: String,
#[serde(default)]
pub offset: u64,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Draw {
#[serde(default = "three")]
pub vertices: u32,
#[serde(default)]
pub indices: u32,
#[serde(default = "one")]
pub instances: u32,
#[serde(default)]
pub first_vertex: u32,
#[serde(default)]
pub first_index: u32,
#[serde(default)]
pub base_vertex: i32,
#[serde(default)]
pub first_instance: u32,
}
impl Default for Draw {
fn default() -> Self {
Self {
vertices: 3,
indices: 0,
instances: 1,
first_vertex: 0,
first_index: 0,
base_vertex: 0,
first_instance: 0,
}
}
}
impl RenderGraph {
pub fn prepare(&mut self) -> Result<(), &'static str> {
if self.id.is_empty() || self.passes.is_empty() {
return Err("GRAPH_SHAPE");
}
let mut ids = HashMap::new();
for (index, pass) in self.passes.iter().enumerate() {
if pass.id.is_empty() || ids.insert(pass.id.clone(), index).is_some() {
return Err("GRAPH_PASS");
}
}
let dependencies = self
.passes
.iter()
.map(|pass| {
pass.after
.iter()
.map(|id| ids.get(id).copied().ok_or("GRAPH_DEPENDENCY"))
.collect::<Result<HashSet<_>, _>>()
})
.collect::<Result<Vec<_>, _>>()?;
let mut emitted = vec![false; self.passes.len()];
let mut order = Vec::with_capacity(self.passes.len());
while order.len() < self.passes.len() {
let ready = (0..self.passes.len()).find(|&index| {
!emitted[index]
&& dependencies[index]
.iter()
.all(|dependency| emitted[*dependency])
});
let index = ready.ok_or("GRAPH_CYCLE")?;
emitted[index] = true;
order.push(index);
}
let original = self.passes.clone();
self.passes = order
.into_iter()
.map(|index| original[index].clone())
.collect();
let sorted_ids: HashMap<_, _> = self
.passes
.iter()
.enumerate()
.map(|(index, pass)| (pass.id.clone(), index))
.collect();
for pass in &mut self.passes {
pass.dependencies = pass.after.iter().map(|id| sorted_ids[id]).collect();
}
self.plan_resources()
}
fn plan_resources(&mut self) -> Result<(), &'static str> {
let mut used = HashSet::new();
let mut lifetimes = HashMap::new();
let mut render_pipelines = HashSet::new();
let mut compute_pipelines = HashSet::new();
for (frame, pass) in self.passes.iter().enumerate() {
match pass.kind.as_str() {
"render" => _ = render_pipelines.insert(pass.pipeline.clone()),
"compute" => _ = compute_pipelines.insert(pass.pipeline.clone()),
_ => return Err("GRAPH_PASS"),
}
for id in pass.resources() {
used.insert(id.to_owned());
lifetimes
.entry(id.to_owned())
.and_modify(|range: &mut (usize, usize)| range.1 = frame)
.or_insert((frame, frame));
}
}
self.resources
.buffers
.retain(|value| used.contains(&value.id));
self.resources
.samplers
.retain(|value| used.contains(&value.id));
self.pipelines
.render
.retain(|value| render_pipelines.contains(&value.id));
self.pipelines
.compute
.retain(|value| compute_pipelines.contains(&value.id));
let mut textures = self
.resources
.textures
.drain(..)
.filter_map(|texture| {
lifetimes
.get(&texture.id)
.copied()
.map(|range| (texture, range))
})
.collect::<Vec<_>>();
textures.sort_by_key(|value| value.1 .0);
let mut slots: Vec<(String, usize, bool)> = Vec::new();
for (texture, (first, last)) in &mut textures {
let key = texture.key()?;
let reusable = texture
.transient
.then(|| {
slots
.iter()
.position(|slot| slot.2 && slot.0 == key && slot.1 < *first)
})
.flatten();
texture.slot = match reusable {
Some(slot) => {
slots[slot].1 = *last;
slot
}
None => {
slots.push((key, *last, texture.transient));
slots.len() - 1
}
};
}
self.resources.textures = textures.into_iter().map(|value| value.0).collect();
self.validate_ids()
}
fn validate_ids(&self) -> Result<(), &'static str> {
let mut resources = HashSet::new();
for id in self
.resources
.buffers
.iter()
.map(|value| &value.id)
.chain(self.resources.textures.iter().map(|value| &value.id))
.chain(self.resources.samplers.iter().map(|value| &value.id))
{
if id.is_empty() || !resources.insert(id) {
return Err("GRAPH_RESOURCE");
}
}
let mut pipelines = HashSet::new();
for id in self
.pipelines
.render
.iter()
.map(|value| &value.id)
.chain(self.pipelines.compute.iter().map(|value| &value.id))
{
if id.is_empty() || !pipelines.insert(id) {
return Err("GRAPH_PIPELINE");
}
}
Ok(())
}
}
impl Pass {
fn resources(&self) -> Vec<&str> {
self.bindings
.iter()
.map(|value| value.resource.as_str())
.chain(self.color.iter().map(|value| value.resource.as_str()))
.chain(self.depth.iter().map(|value| value.resource.as_str()))
.chain(
self.vertex_buffers
.iter()
.map(|value| value.resource.as_str()),
)
.chain(
self.index_buffer
.iter()
.map(|value| value.resource.as_str()),
)
.collect()
}
}
impl Texture {
fn key(&self) -> Result<String, &'static str> {
let mut usage = self.usage.clone();
usage.sort_unstable();
usage.dedup();
serde_json::to_string(&(
&self.size,
&self.format,
usage,
self.mip_level_count,
self.sample_count,
&self.dimension,
))
.map_err(|_| "GRAPH_RESOURCE")
}
}
fn one() -> u32 {
1
}
fn three() -> u32 {
3
}
fn one_f32() -> f32 {
1.0
}
fn yes() -> bool {
true
}
fn dimension() -> String {
"2d".into()
}
fn vertex_entry() -> String {
"vertex".into()
}
fn fragment_entry() -> String {
"fragment".into()
}
fn compute_entry() -> String {
"main".into()
}
fn vertex_step() -> String {
"vertex".into()
}
fn clear_op() -> String {
"clear".into()
}
fn store_op() -> String {
"store".into()
}
fn index_format() -> String {
"uint32".into()
}
fn dispatch() -> [u32; 3] {
[1, 1, 1]
}