refactor: redesign render graph nodes

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-28 13:00:29 +00:00
co-authored by heaust
parent fc8c16daec
commit edc7c8ef29
23 changed files with 5229 additions and 1810 deletions
+272 -269
View File
@@ -15,15 +15,25 @@ struct TextureParameters {
texture: TextureDescriptor,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct DepthParameters {
depth_compare: CompareFunction,
depth_write_enabled: bool,
clear_depth: f32,
#[serde(deny_unknown_fields)]
struct CullParameters {
camera: ActiveCamera,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct ForwardParameters {
struct QueryParameters {
visible_predicate: TriStatePredicate,
visible_default: bool,
frustum_culled_predicate: TriStatePredicate,
frustum_culled_default: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct PipelineParameters {
pipeline: String,
depth_compare: CompareFunction,
depth_write_enabled: bool,
clear_depth: f32,
clear_color: [f64; 4],
}
#[derive(Deserialize)]
@@ -157,11 +167,12 @@ fn validate_name_grammar(s: &str, path: impl Into<String>) -> Result<(), GraphEr
}
}
pub fn mesh_predicate_matches(predicate: TriStatePredicate, flag: bool) -> bool {
pub fn mesh_predicate_matches(predicate: RuntimePredicate, flag: bool) -> bool {
match predicate {
TriStatePredicate::Any => true,
TriStatePredicate::RequiredTrue => flag,
TriStatePredicate::RequiredFalse => !flag,
RuntimePredicate::Any => true,
RuntimePredicate::RequiredTrue => flag,
RuntimePredicate::RequiredFalse => !flag,
RuntimePredicate::Never => false,
}
}
@@ -349,12 +360,12 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
}};
}
Ok(match node.executor.key.as_str() {
"surface_target" => empty!(NormalizedParameters::SurfaceTarget),
"scene_table" => empty!(NormalizedParameters::SceneTable),
"local_aabb_buffer" => empty!(NormalizedParameters::LocalAabbBuffer),
"camera_frustum" => empty!(NormalizedParameters::CameraFrustum),
"visibility_flags" => empty!(NormalizedParameters::VisibilityFlags),
"frustum_cull" => empty!(NormalizedParameters::FrustumCull),
"mesh" => empty!(NormalizedParameters::Mesh),
"frustum_cull" => {
let p: CullParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
NormalizedParameters::FrustumCull { camera: p.camera }
}
"fullscreen_copy" => empty!(NormalizedParameters::FullscreenCopy),
"tone_map" => {
let p: ToneMapParameters =
@@ -402,8 +413,8 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
strength: range(p.strength, 0.0, 16.0, format!("{base}.strength"))?,
}
}
"present" => empty!(NormalizedParameters::Present),
"texture_spec" => {
"frame_out" => empty!(NormalizedParameters::FrameOut),
"texture" => {
let p: TextureParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
if matches!(
@@ -416,100 +427,86 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
format!("{base}.residency"),
));
}
NormalizedParameters::TextureSpec {
let unsupported = |suffix: &str| {
error(
"GRAPH_UNSUPPORTED_FEATURE",
"texture feature is unsupported",
format!("{base}.texture.{suffix}"),
)
};
if p.texture.dimension != TextureDimension::D2 {
return Err(unsupported("dimension"));
}
if p.texture.mip_level_count != 1 {
return Err(unsupported("mipLevelCount"));
}
if p.texture.sample_count != 1 {
return Err(unsupported("sampleCount"));
}
let depth_or_array_layers = match &p.texture.extent {
TextureExtent::Absolute {
depth_or_array_layers,
..
}
| TextureExtent::SurfaceRelative {
depth_or_array_layers,
..
} => *depth_or_array_layers,
};
if depth_or_array_layers != 1 {
return Err(unsupported("extent.depthOrArrayLayers"));
}
NormalizedParameters::Texture {
residency: p.residency,
texture: normalize_texture(p.texture, &base)?,
descriptor: normalize_texture(p.texture, &base)?,
}
}
"mesh_query" => {
let object = node.parameters.as_object().ok_or_else(|| {
error(
"GRAPH_PARAMETERS_INVALID",
"parameters must be an object",
base.clone(),
)
})?;
if object.len() != 1 || !object.contains_key("filters") {
return Err(error(
"GRAPH_PARAMETERS_INVALID",
"mesh query parameters must contain only filters",
base.clone(),
));
}
let filters = object["filters"].as_array().ok_or_else(|| {
error(
"GRAPH_PARAMETERS_INVALID",
"filters must be an array",
format!("{base}.filters"),
)
})?;
let mut found = [None, None];
for (j, value) in filters.iter().enumerate() {
let filter = value.as_object().ok_or_else(|| {
error(
"GRAPH_PARAMETERS_INVALID",
"filter must be an object",
format!("{base}.filters[{j}]"),
)
})?;
if filter.len() != 2
|| !filter.contains_key("flag")
|| !filter.contains_key("predicate")
{
return Err(error(
"GRAPH_PARAMETERS_INVALID",
"filter must contain flag and predicate",
format!("{base}.filters[{j}]"),
));
}
let flag: MeshFlag =
serde_json::from_value(filter["flag"].clone()).map_err(|e| {
error(
"GRAPH_PARAMETERS_INVALID",
&e.to_string(),
format!("{base}.filters[{j}].flag"),
)
})?;
let predicate: TriStatePredicate =
serde_json::from_value(filter["predicate"].clone()).map_err(|e| {
error(
"GRAPH_PARAMETERS_INVALID",
&e.to_string(),
format!("{base}.filters[{j}].predicate"),
)
})?;
let index = if flag == MeshFlag::IsVisible { 0 } else { 1 };
if found[index].replace(predicate).is_some() {
return Err(error(
"GRAPH_PARAMETERS_INVALID",
"duplicate mesh flag",
format!("{base}.filters[{j}].flag"),
));
}
}
if found.iter().any(Option::is_none) {
return Err(error(
"GRAPH_PARAMETERS_INVALID",
"both mesh flags are required",
format!("{base}.filters"),
));
let p: QueryParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
let fold = |predicate, default, linked| match (predicate, linked, default) {
(TriStatePredicate::Any, _, _) => RuntimePredicate::Any,
(TriStatePredicate::RequiredTrue, true, _) => RuntimePredicate::RequiredTrue,
(TriStatePredicate::RequiredFalse, true, _) => RuntimePredicate::RequiredFalse,
(TriStatePredicate::RequiredTrue, false, true)
| (TriStatePredicate::RequiredFalse, false, false) => RuntimePredicate::Any,
_ => RuntimePredicate::Never,
};
let mut visible = fold(
p.visible_predicate,
p.visible_default,
node.inputs.contains_key("isVisible"),
);
let mut culled = fold(
p.frustum_culled_predicate,
p.frustum_culled_default,
node.inputs.contains_key("isFrustumCulled"),
);
if visible == RuntimePredicate::Never || culled == RuntimePredicate::Never {
visible = RuntimePredicate::Never;
culled = RuntimePredicate::Never;
}
NormalizedParameters::MeshQuery {
filters: [
NormalizedMeshFilter {
flag: MeshFlag::IsVisible,
predicate: found[0].unwrap(),
},
NormalizedMeshFilter {
flag: MeshFlag::IsFrustumCulled,
predicate: found[1].unwrap(),
},
],
visible_predicate: visible,
frustum_culled_predicate: culled,
}
}
"depth_stencil_config" => {
let p: DepthParameters =
"pipeline_registry" => empty!(NormalizedParameters::PipelineRegistry),
"pipeline" => {
let p: PipelineParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
let valid_name = !p.pipeline.is_empty()
&& p.pipeline.len() <= 64
&& p.pipeline.bytes().enumerate().all(|(i, c)| {
c == b'_' || c.is_ascii_alphanumeric() && (i > 0 || c.is_ascii_alphabetic())
});
if !valid_name {
return Err(error(
"GRAPH_PARAMETERS_INVALID",
"pipeline must be a 1-64 byte identifier",
format!("{base}.pipeline"),
));
}
if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) {
return Err(error(
"GRAPH_PARAMETERS_INVALID",
@@ -517,17 +514,6 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
format!("{base}.clearDepth"),
));
}
NormalizedParameters::DepthStencilConfig {
config: NormalizedDepthStencil {
depth_compare: p.depth_compare,
depth_write_enabled: p.depth_write_enabled,
clear_depth: p.clear_depth,
},
}
}
"legacy_forward" => {
let p: ForwardParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
if p.clear_color.iter().any(|x| !x.is_finite()) {
return Err(error(
"GRAPH_PARAMETERS_INVALID",
@@ -535,7 +521,11 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
format!("{base}.clearColor"),
));
}
NormalizedParameters::LegacyForward {
NormalizedParameters::Pipeline {
pipeline: p.pipeline,
depth_compare: p.depth_compare,
depth_write_enabled: p.depth_write_enabled,
clear_depth: p.clear_depth,
clear_color: p.clear_color,
}
}
@@ -569,19 +559,6 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
));
}
}
if graph
.nodes
.iter()
.filter(|n| n.executor.key == "present")
.count()
> 64
{
return Err(error(
"GRAPH_LIMIT_EXCEEDED",
"present count exceeds 64",
"nodes",
));
}
if graph.schema_version != 2 {
return Err(GraphError::new(
"GRAPH_SCHEMA_UNSUPPORTED",
@@ -673,8 +650,21 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.enumerate()
.map(|(i, n)| decode(n, i))
.collect::<Result<_, _>>()?;
if graph
.nodes
.iter()
.filter(|node| node.executor.key == "frame_out" && node.state == NodeState::Enabled)
.count()
!= 1
{
return Err(error(
"GRAPH_EXECUTION_UNSUPPORTED",
"exactly one frame_out is required",
"nodes",
));
}
for (i, n) in graph.nodes.iter().enumerate() {
if n.state != NodeState::Enabled {
if n.state != NodeState::Enabled && n.executor.key != "frame_out" {
return Err(error(
"GRAPH_NODE_STATE_INVALID",
"muted nodes are unsupported",
@@ -710,12 +700,12 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
for (i, n) in graph.nodes.iter().enumerate() {
for input in contracts[i].inputs {
let inactive = matches!(&params[i], NormalizedParameters::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any));
let inactive = matches!(&params[i], NormalizedParameters::MeshQuery { visible_predicate, frustum_culled_predicate } if (input.name == "isVisible" && matches!(visible_predicate, RuntimePredicate::Any | RuntimePredicate::Never)) || (input.name == "isFrustumCulled" && matches!(frustum_culled_predicate, RuntimePredicate::Any | RuntimePredicate::Never)));
if !n.inputs.contains_key(input.name) {
if input.cardinality == InputCardinality::RequiredOne
|| (!inactive
&& matches!(params[i], NormalizedParameters::MeshQuery { .. })
&& input.name != "scene")
&& input.name != "mesh")
{
return Err(error(
"GRAPH_SOCKET_CARDINALITY",
@@ -730,7 +720,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let mut bound: Vec<BTreeMap<&str, BoundInput>> = vec![BTreeMap::new(); graph.nodes.len()];
for (i, n) in graph.nodes.iter().enumerate() {
for input in contracts[i].inputs {
let inactive = matches!(&params[i], NormalizedParameters::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any));
let inactive = matches!(&params[i], NormalizedParameters::MeshQuery { visible_predicate, frustum_culled_predicate } if (input.name == "isVisible" && matches!(visible_predicate, RuntimePredicate::Any | RuntimePredicate::Never)) || (input.name == "isFrustumCulled" && matches!(frustum_culled_predicate, RuntimePredicate::Any | RuntimePredicate::Never)));
let Some(r) = n.inputs.get(input.name) else {
continue;
};
@@ -741,10 +731,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.enumerate()
.find(|(_, o)| o.name == r.socket)
.expect("producer sockets were globally validated");
let attachment_shape_checked_later = contracts[i].key == "legacy_forward"
&& input.name == "depthTarget"
&& out.semantic_type == SemanticType::SurfaceTarget;
if !accepts(input.accepted, out.semantic_type) && !attachment_shape_checked_later {
if !accepts(input.accepted, out.semantic_type) {
return Err(error(
"GRAPH_SOCKET_TYPE_MISMATCH",
"socket type mismatch",
@@ -782,15 +769,26 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
if !seen.insert(k.0) {
return None;
}
if contracts[k.0].outputs[k.1 as usize].semantic_type == SemanticType::SceneTable {
if contracts[k.0].key == "mesh" {
let ordinal = contracts[k.0]
.outputs
.iter()
.position(|output| output.semantic_type == SemanticType::MeshData)?;
return Some(OutputKey(k.0, ordinal as u16));
}
if contracts[k.0].outputs[k.1 as usize].semantic_type == SemanticType::MeshData {
return Some(k);
}
k = bound[k.0].get("scene")?.producer;
k = bound[k.0]
.get("mesh")
.or_else(|| bound[k.0].get("pipelineIndices"))
.or_else(|| bound[k.0].get("activation"))?
.producer;
}
};
for (i, c) in contracts.iter().enumerate() {
if c.key == "frustum_cull"
&& root(bound[i]["scene"].producer, &bound, &contracts)
&& root(bound[i]["mesh"].producer, &bound, &contracts)
!= root(bound[i]["localAabbs"].producer, &bound, &contracts)
{
return Err(error(
@@ -799,11 +797,23 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
format!("nodes[{i}].inputs.localAabbs"),
));
}
if matches!(c.key, "mesh_query" | "legacy_forward") {
let scene = root(bound[i]["scene"].producer, &bound, &contracts);
if matches!(c.key, "mesh_query" | "pipeline_registry" | "pipeline") {
let scene_socket = if c.key == "pipeline_registry" {
"pipelineIndices"
} else {
"mesh"
};
let scene = root(bound[i][scene_socket].producer, &bound, &contracts);
for (s, b) in &bound[i] {
if b.active
&& matches!(*s, "isVisible" | "isFrustumCulled" | "draws")
&& matches!(
*s,
"isVisible"
| "isFrustumCulled"
| "draws"
| "pipelineIndices"
| "activation"
)
&& root(b.producer, &bound, &contracts) != scene
{
return Err(error(
@@ -853,7 +863,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let mut stack: Vec<_> = contracts
.iter()
.enumerate()
.filter(|(_, c)| c.inherently_observable)
.filter(|(i, c)| c.inherently_observable && graph.nodes[*i].state == NodeState::Enabled)
.map(|(i, _)| i)
.collect();
while let Some(i) = stack.pop() {
@@ -862,13 +872,26 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
}
// IDs are independent of scheduling: original node order, then contract output order.
// Source nodes expose only outputs that survived active-edge/liveness analysis;
// executable nodes retain their complete output shape for runtime lowering.
let referenced_outputs: HashSet<_> = edges
.iter()
.filter(|edge| live.contains(&edge.to_node))
.map(|edge| OutputKey(edge.from_node, edge.producer_output_ordinal))
.collect();
let mut output_ids = BTreeMap::new();
let mut resource_meta = Vec::new();
for i in 0..graph.nodes.len() {
if live.contains(&i) {
for (o, out) in contracts[i].outputs.iter().enumerate() {
let key = OutputKey(i, o as u16);
if contracts[i].execution == ExecutionClass::Source
&& !referenced_outputs.contains(&key)
{
continue;
}
let id = resource_meta.len() as u32;
output_ids.insert(OutputKey(i, o as u16), id);
output_ids.insert(key, id);
resource_meta.push((i, o as u16, *out));
}
}
@@ -884,28 +907,10 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
let source = output_ids.get(&OutputKey(i, 0)).copied();
match &params[i] {
NormalizedParameters::SurfaceTarget => {
let id = families.len() as u32;
let r = source.unwrap();
source_family.insert(OutputKey(i, 0), id);
families.push(TextureFamily {
id,
key: TextureFamilyKey {
source_node: i as u32,
source_socket: 0,
},
source: TextureFamilySource::ImportedSurface { resource: r },
lifetime: Lifetime {
first_use: 0,
last_use: 0,
},
versions: vec![],
usage: vec![],
allocation: None,
aliasable: false,
});
}
NormalizedParameters::TextureSpec { residency, texture } => {
NormalizedParameters::Texture {
residency,
descriptor,
} => {
let id = families.len() as u32;
let r = source.unwrap();
source_family.insert(OutputKey(i, 0), id);
@@ -918,7 +923,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
source: TextureFamilySource::AuthoredTexture {
resource: r,
residency: *residency,
descriptor: texture.clone(),
descriptor: descriptor.clone(),
},
lifetime: Lifetime {
first_use: 0,
@@ -940,7 +945,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
continue;
}
let transition_sockets: &[(&str, u16)] = match contracts[i].key {
"legacy_forward" => &[("colorTarget", 0), ("depthTarget", 1)],
"pipeline" => &[("colorTarget", 0), ("depthTarget", 1)],
"fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite"
| "luminance_edge" => &[("colorTarget", 0)],
_ => continue,
@@ -1066,7 +1071,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
for input in contract
.inputs
.iter()
.filter(|input| matches!(input.role, InputRole::Present | InputRole::SampledTexture))
.filter(|input| matches!(input.role, InputRole::SampledTexture))
{
let key = bound[i][input.name].producer;
if !version_of.contains_key(&key) {
@@ -1103,7 +1108,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
if !live.contains(&i) {
continue;
}
if contracts[i].key == "legacy_forward" {
if contracts[i].key == "pipeline" {
if bound[i]["colorTarget"].producer == bound[i]["depthTarget"].producer
|| matches!((version_of.get(&OutputKey(i, 0)), version_of.get(&OutputKey(i, 1))), (Some((cf, _, _)), Some((df, _, _))) if cf == df)
{
@@ -1113,7 +1118,8 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
format!("nodes[{i}].inputs"),
));
}
} else if contracts[i]
} else if contracts[i].key != "frame_out"
&& contracts[i]
.inputs
.iter()
.any(|input| matches!(input.role, InputRole::SampledTexture))
@@ -1176,7 +1182,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
// Validate every independently resolved attachment before graph cycle reporting.
for i in 0..graph.nodes.len() {
if !live.contains(&i) || contracts[i].key != "legacy_forward" {
if !live.contains(&i) || contracts[i].key != "pipeline" {
continue;
}
let (Some(&(cf, _, _)), Some(&(df, _, _))) = (
@@ -1185,33 +1191,19 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
) else {
continue;
};
let cd = match &families[cf as usize].source {
TextureFamilySource::AuthoredTexture { descriptor, .. } => Some(descriptor),
_ => None,
};
let dd = match &families[df as usize].source {
TextureFamilySource::AuthoredTexture { descriptor, .. } => descriptor,
_ => {
return Err(error(
"GRAPH_ILLEGAL_ACCESS",
"depth target must be authored",
format!("nodes[{i}].inputs.depthTarget"),
))
}
};
let TextureFamilySource::AuthoredTexture { descriptor: cd, .. } =
&families[cf as usize].source;
let TextureFamilySource::AuthoredTexture { descriptor: dd, .. } =
&families[df as usize].source;
let ok_depth = dd.dimension == TextureDimension::D2
&& dd.format == TextureFormat::Depth32Float
&& dd.sample_count == 1
&& extent_layers(&dd.extent) == 1;
let ok_color = cd.is_none_or(|d| {
d.format != TextureFormat::Depth32Float
&& d.dimension == dd.dimension
&& d.extent == dd.extent
&& d.sample_count == 1
});
let surface_ok = cd.is_some()
|| matches!(&dd.extent,NormalizedTextureExtent::SurfaceRelative{width,height,..} if *width==Ratio{numerator:1,denominator:1}&&*height==Ratio{numerator:1,denominator:1});
if !ok_depth || !ok_color || !surface_ok {
let ok_color = cd.format != TextureFormat::Depth32Float
&& cd.dimension == dd.dimension
&& cd.extent == dd.extent
&& cd.sample_count == 1;
if !ok_depth || !ok_color {
return Err(error(
"GRAPH_ILLEGAL_ACCESS",
"attachments are incompatible",
@@ -1222,6 +1214,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
for i in 0..graph.nodes.len() {
if !live.contains(&i)
|| contracts[i].key == "frame_out"
|| !contracts[i]
.inputs
.iter()
@@ -1242,19 +1235,11 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
};
let source_descriptor = match &families[source_family_id as usize].source {
TextureFamilySource::AuthoredTexture { descriptor, .. } => descriptor,
TextureFamilySource::ImportedSurface { .. } => {
return Err(error(
"GRAPH_ILLEGAL_ACCESS",
"copy source must be an authored texture",
format!("nodes[{i}].inputs.source"),
))
}
};
let source_ok = source_descriptor.format == TextureFormat::Rgba16Float
&& is_single_view_d2(source_descriptor);
let target_descriptor = match &families[target_family_id as usize].source {
TextureFamilySource::AuthoredTexture { descriptor, .. } => Some(descriptor),
TextureFamilySource::ImportedSurface { .. } => None,
};
let authored_target_ok = target_descriptor.is_some_and(|descriptor| {
descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor)
@@ -1272,13 +1257,6 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
TextureFamilySource::AuthoredTexture { descriptor, .. } => {
descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor)
}
TextureFamilySource::ImportedSurface { .. } => {
return Err(error(
"GRAPH_ILLEGAL_ACCESS",
"bloom source must be an authored texture",
format!("nodes[{i}].inputs.bloom"),
))
}
}
} else {
true
@@ -1295,7 +1273,12 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
&& descriptor.extent == source_descriptor.extent
})
}
"tone_map" => target_descriptor.is_none() && source_is_full_surface,
"tone_map" => target_descriptor.is_some_and(|descriptor| {
descriptor.format != TextureFormat::Depth32Float
&& descriptor.format != TextureFormat::R32Float
&& is_single_view_d2(descriptor)
&& descriptor.extent == source_descriptor.extent
}),
"bloom_extract" => authored_target_ok,
"bloom_blur" | "luminance_edge" => authored_target_ok && target_matches_source,
"bloom_composite" => authored_target_ok && target_matches_source && bloom_input_ok,
@@ -1310,30 +1293,29 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
}
// Initialization and presentation legality are later than attachment compatibility.
// Frame output must consume an initialized, produced, filterable color texture.
for (i, contract) in contracts.iter().enumerate() {
if !live.contains(&i) || contract.key != "present" {
if !live.contains(&i) || contract.key != "frame_out" {
continue;
}
let key = bound[i]["surface"].producer;
let key = bound[i]["color"].producer;
let Some(&(family, _, _)) = version_of.get(&key) else {
if !matches!(resolved.get(&key), Some(ResolvedTransition::Cyclic)) {
return Err(error(
"GRAPH_UNINITIALIZED_RESOURCE",
"present source is not produced",
format!("nodes[{i}].inputs.surface"),
"frame output source is not produced",
format!("nodes[{i}].inputs.color"),
));
}
continue;
};
if !matches!(
families[family as usize].source,
TextureFamilySource::ImportedSurface { .. }
) {
let TextureFamilySource::AuthoredTexture { descriptor, .. } =
&families[family as usize].source;
if !is_filterable_frame_color(descriptor) {
return Err(error(
"GRAPH_ILLEGAL_ACCESS",
"offscreen textures cannot be presented",
format!("nodes[{i}].inputs.surface"),
"frame output requires a filterable single-view d2 color texture",
format!("nodes[{i}].inputs.color"),
));
}
}
@@ -1463,17 +1445,18 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
for (i, o, out) in resource_meta {
let key = OutputKey(i, o);
let id = output_ids[&key];
let scene = || output_ids[&root(bound[i]["scene"].producer, &bound, &contracts).unwrap()];
let mesh = || output_ids[&root(key, &bound, &contracts).unwrap()];
let plan = match out.semantic_type {
SemanticType::SurfaceTarget => ResourcePlan::SurfaceTarget {
family: source_family[&key],
},
SemanticType::TextureSpec => {
if let NormalizedParameters::TextureSpec { residency, texture } = &params[i] {
ResourcePlan::TextureSpec {
SemanticType::Texture if matches!(params[i], NormalizedParameters::Texture { .. }) => {
if let NormalizedParameters::Texture {
residency,
descriptor,
} = &params[i]
{
ResourcePlan::TextureSource {
family: source_family[&key],
residency: *residency,
descriptor: texture.clone(),
descriptor: descriptor.clone(),
}
} else {
unreachable!()
@@ -1490,27 +1473,20 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
allocation: None,
}
}
SemanticType::SceneTable => ResourcePlan::SceneTable,
SemanticType::LocalAabbBuffer => ResourcePlan::LocalAabbBuffer { scene: scene() },
SemanticType::CameraFrustum => ResourcePlan::CameraFrustum,
SemanticType::MeshData => ResourcePlan::MeshData,
SemanticType::LocalAabbBuffer => ResourcePlan::LocalAabbBuffer { mesh: mesh() },
SemanticType::BooleanFlagBuffer => {
if let OutputMetadata::BooleanFlag { flag } = out.metadata {
ResourcePlan::BooleanFlagBuffer {
scene: scene(),
flag,
}
} else {
unreachable!()
}
}
SemanticType::DrawStream => ResourcePlan::DrawStream { scene: scene() },
SemanticType::DepthStencilConfig => {
if let NormalizedParameters::DepthStencilConfig { config } = &params[i] {
ResourcePlan::DepthStencilConfig { config: *config }
ResourcePlan::BooleanFlagBuffer { mesh: mesh(), flag }
} else {
unreachable!()
}
}
SemanticType::PipelineIndexStream => ResourcePlan::PipelineIndexStream { mesh: mesh() },
SemanticType::PipelineActivation => ResourcePlan::PipelineActivation {
pipeline_indices: output_ids[&bound[i]["pipelineIndices"].producer],
},
SemanticType::DrawStream => ResourcePlan::DrawStream { mesh: mesh() },
};
resources.push(CompiledResource {
original_node_index: i as u32,
@@ -1557,9 +1533,8 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let kind = match contracts[i].key {
"frustum_cull" => {
for (s, m) in [
("scene", AccessMode::StorageRead),
("mesh", AccessMode::StorageRead),
("localAabbs", AccessMode::StorageRead),
("frustum", AccessMode::UniformRead),
] {
accesses.push(CompiledAccess {
socket: s.into(),
@@ -1569,7 +1544,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
let r = output_ids[&OutputKey(i, 0)];
accesses.push(CompiledAccess {
socket: "flags".into(),
socket: "isFrustumCulled".into(),
resource: r,
mode: AccessMode::StorageWrite {
full_overwrite: true,
@@ -1580,7 +1555,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
}
"mesh_query" => {
for s in ["scene", "isVisible", "isFrustumCulled"] {
for s in ["mesh", "isVisible", "isFrustumCulled"] {
if let Some(b) = bound[i].get(s).filter(|b| b.active) {
accesses.push(CompiledAccess {
socket: s.into(),
@@ -1600,23 +1575,38 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
work: ComputeWork::MeshQuery,
}
}
"legacy_forward" => {
"pipeline_registry" => {
accesses.push(CompiledAccess {
socket: "pipelineIndices".into(),
resource: input_resource("pipelineIndices"),
mode: AccessMode::SemanticRead,
});
ExecutionKind::CpuPreparation
}
"pipeline" => {
let color = output_ids[&OutputKey(i, 0)];
let depth = output_ids[&OutputKey(i, 1)];
let clear = match params[i] {
NormalizedParameters::LegacyForward { clear_color } => clear_color,
NormalizedParameters::Pipeline { clear_color, .. } => clear_color,
_ => unreachable!(),
};
let config_node = bound[i]["depthStencil"].producer.0;
let dc = match params[config_node] {
NormalizedParameters::DepthStencilConfig { config } => config,
let clear_depth = match &params[i] {
NormalizedParameters::Pipeline { clear_depth, .. } => *clear_depth,
_ => unreachable!(),
};
let cl = NormalizedColorLoad::Clear { value: clear };
let dl = NormalizedDepthLoad::Clear {
value: dc.clear_depth,
let first_color = version_of[&OutputKey(i, 0)].1 == 0;
let first_depth = version_of[&OutputKey(i, 1)].1 == 0;
let cl = if first_color {
NormalizedColorLoad::Clear { value: clear }
} else {
NormalizedColorLoad::Load
};
for s in ["scene", "draws"] {
let dl = if first_depth {
NormalizedDepthLoad::Clear { value: clear_depth }
} else {
NormalizedDepthLoad::Load
};
for s in ["mesh", "draws", "activation"] {
accesses.push(CompiledAccess {
socket: s.into(),
resource: input_resource(s),
@@ -1634,7 +1624,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
location: 0,
load: cl,
store: StoreOp::Store,
full_overwrite: true,
full_overwrite: first_color,
},
});
accesses.push(CompiledAccess {
@@ -1643,7 +1633,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
mode: AccessMode::DepthAttachment {
load: dl,
store: StoreOp::Store,
full_overwrite: true,
full_overwrite: first_depth,
},
});
ExecutionKind::Render {
@@ -1698,14 +1688,14 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
depth_stencil: None,
}
}
"present" => {
let r = input_resource("surface");
"frame_out" => {
let r = input_resource("color");
accesses.push(CompiledAccess {
socket: "surface".into(),
socket: "color".into(),
resource: r,
mode: AccessMode::Present,
mode: AccessMode::SampledTexture,
});
ExecutionKind::Present { surface: r }
ExecutionKind::FrameOut { color: r }
}
_ => unreachable!(),
};
@@ -1803,13 +1793,27 @@ fn extent_layers(e: &NormalizedTextureExtent) -> u32 {
} => *depth_or_array_layers,
}
}
fn is_single_view_d2(descriptor: &NormalizedTextureDescriptor) -> bool {
pub(super) fn is_single_view_d2(descriptor: &NormalizedTextureDescriptor) -> bool {
descriptor.dimension == TextureDimension::D2
&& descriptor.sample_count == 1
&& descriptor.mip_level_count == 1
&& extent_layers(&descriptor.extent) == 1
}
fn texture_usage(f: &TextureFamily, executions: &[CompiledExecution]) -> Vec<TextureUsage> {
pub(super) fn is_filterable_frame_color(descriptor: &NormalizedTextureDescriptor) -> bool {
is_single_view_d2(descriptor)
&& matches!(
descriptor.format,
TextureFormat::Rgba8Unorm
| TextureFormat::Rgba8UnormSrgb
| TextureFormat::Bgra8Unorm
| TextureFormat::Bgra8UnormSrgb
| TextureFormat::Rgba16Float
)
}
pub(super) fn texture_usage(
f: &TextureFamily,
executions: &[CompiledExecution],
) -> Vec<TextureUsage> {
let rs: HashSet<_> = f.versions.iter().map(|v| v.resource).collect();
let mut u = BTreeSet::new();
for e in executions {
@@ -1842,7 +1846,7 @@ fn allocate(
) -> (Vec<AllocationClass>, u32) {
let mut grouped: BTreeMap<TextureCompatibilityKey, Vec<usize>> = BTreeMap::new();
for (i, f) in families.iter().enumerate() {
if let TextureFamilySource::AuthoredTexture { descriptor, .. } = &f.source {
let TextureFamilySource::AuthoredTexture { descriptor, .. } = &f.source;
grouped
.entry(TextureCompatibilityKey {
dimension: descriptor.dimension,
@@ -1855,7 +1859,6 @@ fn allocate(
.or_default()
.push(i);
}
}
let mut classes = Vec::new();
let mut transient = 0;
for (key, ids) in grouped {
+60 -102
View File
@@ -3,15 +3,13 @@ use super::MeshFlag;
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SemanticType {
SurfaceTarget,
TextureSpec,
MeshData,
Texture,
SceneTable,
LocalAabbBuffer,
CameraFrustum,
BooleanFlagBuffer,
PipelineIndexStream,
PipelineActivation,
DrawStream,
DepthStencilConfig,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
@@ -21,7 +19,7 @@ pub enum ExecutionClass {
CpuPreparation,
Compute,
Render,
Present,
Frame,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
@@ -48,8 +46,6 @@ pub enum InputRole {
SampledTexture,
ColorTarget { location: u32 },
DepthTarget,
Present,
Configuration,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
@@ -119,48 +115,42 @@ const REQUIRED: InputCardinality = InputCardinality::RequiredOne;
const OPTIONAL: InputCardinality = InputCardinality::OptionalOne;
const NONE_IN: &[InputSocketContract] = &[];
const NONE_OUT: &[OutputSocketContract] = &[];
const SURFACE_OUT: &[OutputSocketContract] =
&[output("surface", SurfaceTarget, OutputMetadata::None)];
const SPEC_OUT: &[OutputSocketContract] = &[output("spec", TextureSpec, OutputMetadata::None)];
const SCENE_OUT: &[OutputSocketContract] = &[output("scene", SceneTable, OutputMetadata::None)];
const AABB_OUT: &[OutputSocketContract] =
&[output("localAabbs", LocalAabbBuffer, OutputMetadata::None)];
const FRUSTUM_OUT: &[OutputSocketContract] =
&[output("frustum", CameraFrustum, OutputMetadata::None)];
const VISIBLE_OUT: &[OutputSocketContract] = &[output(
"flags",
const TEXTURE_OUT: &[OutputSocketContract] = &[output("texture", Texture, OutputMetadata::None)];
const MESH_OUT: &[OutputSocketContract] = &[
output("mesh", MeshData, OutputMetadata::None),
output("localAabbs", LocalAabbBuffer, OutputMetadata::None),
output(
"isVisible",
BooleanFlagBuffer,
OutputMetadata::BooleanFlag {
flag: MeshFlag::IsVisible,
},
)];
),
output("pipelineIndices", PipelineIndexStream, OutputMetadata::None),
];
const CULLED_OUT: &[OutputSocketContract] = &[output(
"flags",
"isFrustumCulled",
BooleanFlagBuffer,
OutputMetadata::BooleanFlag {
flag: MeshFlag::IsFrustumCulled,
},
)];
const DRAW_OUT: &[OutputSocketContract] = &[output("draws", DrawStream, OutputMetadata::None)];
const CONFIG_OUT: &[OutputSocketContract] =
&[output("config", DepthStencilConfig, OutputMetadata::None)];
const FORWARD_OUT: &[OutputSocketContract] = &[
const ACTIVATION_OUT: &[OutputSocketContract] = &[output(
"activation",
PipelineActivation,
OutputMetadata::None,
)];
const PIPELINE_OUT: &[OutputSocketContract] = &[
output("color", Texture, OutputMetadata::None),
output("depth", Texture, OutputMetadata::None),
];
const FULLSCREEN_COPY_OUT: &[OutputSocketContract] =
&[output("color", Texture, OutputMetadata::None)];
const LOCAL_IN: &[InputSocketContract] = &[input(
"scene",
TypeConstraint::Exact(SceneTable),
REQUIRED,
InputRole::SemanticRead,
)];
const VISIBILITY_IN: &[InputSocketContract] = LOCAL_IN;
const CULL_IN: &[InputSocketContract] = &[
input(
"scene",
TypeConstraint::Exact(SceneTable),
"mesh",
TypeConstraint::Exact(MeshData),
REQUIRED,
InputRole::StorageRead,
),
@@ -170,17 +160,11 @@ const CULL_IN: &[InputSocketContract] = &[
REQUIRED,
InputRole::StorageRead,
),
input(
"frustum",
TypeConstraint::Exact(CameraFrustum),
REQUIRED,
InputRole::UniformRead,
),
];
const QUERY_IN: &[InputSocketContract] = &[
input(
"scene",
TypeConstraint::Exact(SceneTable),
"mesh",
TypeConstraint::Exact(MeshData),
REQUIRED,
InputRole::StorageRead,
),
@@ -197,10 +181,16 @@ const QUERY_IN: &[InputSocketContract] = &[
InputRole::StorageRead,
),
];
const FORWARD_IN: &[InputSocketContract] = &[
const REGISTRY_IN: &[InputSocketContract] = &[input(
"pipelineIndices",
TypeConstraint::Exact(PipelineIndexStream),
REQUIRED,
InputRole::SemanticRead,
)];
const PIPELINE_IN: &[InputSocketContract] = &[
input(
"scene",
TypeConstraint::Exact(SceneTable),
"mesh",
TypeConstraint::Exact(MeshData),
REQUIRED,
InputRole::SemanticRead,
),
@@ -210,24 +200,24 @@ const FORWARD_IN: &[InputSocketContract] = &[
REQUIRED,
InputRole::IndirectRead,
),
input(
"activation",
TypeConstraint::Exact(PipelineActivation),
REQUIRED,
InputRole::SemanticRead,
),
input(
"colorTarget",
TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]),
TypeConstraint::Exact(Texture),
REQUIRED,
InputRole::ColorTarget { location: 0 },
),
input(
"depthTarget",
TypeConstraint::OneOf(&[TextureSpec, Texture]),
TypeConstraint::Exact(Texture),
REQUIRED,
InputRole::DepthTarget,
),
input(
"depthStencil",
TypeConstraint::Exact(DepthStencilConfig),
REQUIRED,
InputRole::Configuration,
),
];
const FULLSCREEN_COPY_IN: &[InputSocketContract] = &[
input(
@@ -238,7 +228,7 @@ const FULLSCREEN_COPY_IN: &[InputSocketContract] = &[
),
input(
"colorTarget",
TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]),
TypeConstraint::Exact(Texture),
REQUIRED,
InputRole::ColorTarget { location: 0 },
),
@@ -258,65 +248,33 @@ const BLOOM_COMPOSITE_IN: &[InputSocketContract] = &[
),
input(
"colorTarget",
TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]),
TypeConstraint::Exact(Texture),
REQUIRED,
InputRole::ColorTarget { location: 0 },
),
];
const PRESENT_IN: &[InputSocketContract] = &[input(
"surface",
const FRAME_OUT_IN: &[InputSocketContract] = &[input(
"color",
TypeConstraint::Exact(Texture),
REQUIRED,
InputRole::Present,
InputRole::SampledTexture,
)];
pub static CONTRACTS: &[Contract] = &[
Contract {
key: "surface_target",
key: "mesh",
version: 1,
execution: ExecutionClass::Source,
inputs: NONE_IN,
outputs: SURFACE_OUT,
outputs: MESH_OUT,
inherently_observable: false,
},
Contract {
key: "texture_spec",
key: "texture",
version: 1,
execution: ExecutionClass::Source,
inputs: NONE_IN,
outputs: SPEC_OUT,
inherently_observable: false,
},
Contract {
key: "scene_table",
version: 1,
execution: ExecutionClass::Source,
inputs: NONE_IN,
outputs: SCENE_OUT,
inherently_observable: false,
},
Contract {
key: "local_aabb_buffer",
version: 1,
execution: ExecutionClass::Source,
inputs: LOCAL_IN,
outputs: AABB_OUT,
inherently_observable: false,
},
Contract {
key: "camera_frustum",
version: 1,
execution: ExecutionClass::Source,
inputs: NONE_IN,
outputs: FRUSTUM_OUT,
inherently_observable: false,
},
Contract {
key: "visibility_flags",
version: 1,
execution: ExecutionClass::Source,
inputs: VISIBILITY_IN,
outputs: VISIBLE_OUT,
outputs: TEXTURE_OUT,
inherently_observable: false,
},
Contract {
@@ -336,19 +294,19 @@ pub static CONTRACTS: &[Contract] = &[
inherently_observable: false,
},
Contract {
key: "depth_stencil_config",
key: "pipeline_registry",
version: 1,
execution: ExecutionClass::Source,
inputs: NONE_IN,
outputs: CONFIG_OUT,
execution: ExecutionClass::CpuPreparation,
inputs: REGISTRY_IN,
outputs: ACTIVATION_OUT,
inherently_observable: false,
},
Contract {
key: "legacy_forward",
key: "pipeline",
version: 1,
execution: ExecutionClass::Render,
inputs: FORWARD_IN,
outputs: FORWARD_OUT,
inputs: PIPELINE_IN,
outputs: PIPELINE_OUT,
inherently_observable: false,
},
Contract {
@@ -400,10 +358,10 @@ pub static CONTRACTS: &[Contract] = &[
inherently_observable: false,
},
Contract {
key: "present",
key: "frame_out",
version: 1,
execution: ExecutionClass::Present,
inputs: PRESENT_IN,
execution: ExecutionClass::Frame,
inputs: FRAME_OUT_IN,
outputs: NONE_OUT,
inherently_observable: true,
},
+1 -1
View File
@@ -49,4 +49,4 @@ impl GraphError {
}
#[cfg(test)]
mod tests;
pub(crate) mod tests;
+33 -46
View File
@@ -33,10 +33,7 @@ pub struct CompiledResource {
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResourcePlan {
SurfaceTarget {
family: u32,
},
TextureSpec {
TextureSource {
family: u32,
residency: TextureResidency,
descriptor: NormalizedTextureDescriptor,
@@ -49,20 +46,22 @@ pub enum ResourcePlan {
stored: bool,
allocation: Option<AllocationRef>,
},
SceneTable,
MeshData,
LocalAabbBuffer {
scene: u32,
mesh: u32,
},
CameraFrustum,
BooleanFlagBuffer {
scene: u32,
mesh: u32,
flag: MeshFlag,
},
DrawStream {
scene: u32,
PipelineIndexStream {
mesh: u32,
},
DepthStencilConfig {
config: NormalizedDepthStencil,
PipelineActivation {
pipeline_indices: u32,
},
DrawStream {
mesh: u32,
},
}
@@ -104,12 +103,12 @@ pub enum ExecutionKind {
color_attachments: Vec<ColorAttachmentPlan>,
depth_stencil: Option<DepthStencilAttachmentPlan>,
},
Present {
surface: u32,
FrameOut {
color: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComputeWork {
FrustumCull,
@@ -184,29 +183,29 @@ pub enum AccessMode {
store: StoreOp,
full_overwrite: bool,
},
Present,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NormalizedParameters {
SurfaceTarget,
TextureSpec {
Texture {
residency: TextureResidency,
texture: NormalizedTextureDescriptor,
descriptor: NormalizedTextureDescriptor,
},
Mesh,
FrustumCull {
camera: ActiveCamera,
},
SceneTable,
LocalAabbBuffer,
CameraFrustum,
VisibilityFlags,
FrustumCull,
MeshQuery {
filters: [NormalizedMeshFilter; 2],
visible_predicate: RuntimePredicate,
frustum_culled_predicate: RuntimePredicate,
},
DepthStencilConfig {
config: NormalizedDepthStencil,
},
LegacyForward {
PipelineRegistry,
Pipeline {
pipeline: String,
depth_compare: CompareFunction,
depth_write_enabled: bool,
clear_depth: f32,
clear_color: [f64; 4],
},
FullscreenCopy,
@@ -227,22 +226,13 @@ pub enum NormalizedParameters {
LuminanceEdge {
strength: f32,
},
Present,
FrameOut,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NormalizedMeshFilter {
pub flag: MeshFlag,
pub predicate: TriStatePredicate,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NormalizedDepthStencil {
pub depth_compare: CompareFunction,
pub depth_write_enabled: bool,
pub clear_depth: f32,
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActiveCamera {
Active,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
@@ -288,9 +278,6 @@ pub struct TextureFamilyKey {
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TextureFamilySource {
ImportedSurface {
resource: u32,
},
AuthoredTexture {
resource: u32,
residency: TextureResidency,
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -114,11 +114,13 @@ pub enum TriStatePredicate {
RequiredFalse,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(deny_unknown_fields)]
pub struct MeshFilter {
pub flag: MeshFlag,
pub predicate: TriStatePredicate,
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum RuntimePredicate {
Any,
RequiredTrue,
RequiredFalse,
Never,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -42,10 +42,14 @@ fn mesh_query(@builtin(global_invocation_id) id: vec3<u32>) {
if (i >= params.count) { return; }
let draw_meta = metadata[i];
var selected = true;
if (params.visible_predicate != 0u) {
if (params.visible_predicate == 3u) {
selected = false;
} else if (params.visible_predicate != 0u) {
selected = matches(authored_visible[i], params.visible_predicate);
}
if (params.frustum_predicate != 0u) {
if (selected && params.frustum_predicate == 3u) {
selected = false;
} else if (selected && params.frustum_predicate != 0u) {
selected = selected && matches(frustum_flags[i], params.frustum_predicate);
}
commands[i] = Command(draw_meta.index_count, select(0u, 1u, selected), draw_meta.first_index, draw_meta.base_vertex, 0u);
+2 -2
View File
@@ -1,3 +1,3 @@
mod legacy_forward;
mod pipeline;
pub(super) use legacy_forward::{encode_compiled, encode_immediate};
pub(super) use pipeline::{encode_compiled, encode_immediate};
@@ -56,25 +56,8 @@ pub(crate) fn encode_compiled<T: Scene>(
materials: &MaterialResources,
mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) -> Result<(), &'static str> {
use crate::render_graph::{
ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, ResourcePlan, StoreOp,
};
use crate::render_graph::{ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, StoreOp};
let view = |resource: u32| -> Result<&wgpu::TextureView, &'static str> {
let is_surface = active
.graph
.resources
.get(resource as usize)
.is_some_and(|resource| {
matches!(
resource.plan,
ResourcePlan::SurfaceTarget { family }
| ResourcePlan::Texture { family, .. }
if family == active.runtime.allocations.surface_family
)
});
if is_surface {
return Ok(surface);
}
let a = active
.runtime
.allocations
@@ -93,15 +76,16 @@ pub(crate) fn encode_compiled<T: Scene>(
for (execution_index, prepared) in active.executions.iter().enumerate() {
let profile_id = &active.graph.executions[execution_index].id;
match prepared {
PreparedExecution::PipelineRegistry => {}
PreparedExecution::FrustumCull => {
gpu.encode_frustum_cull(encoder, profile.as_deref_mut(), profile_id);
}
PreparedExecution::MeshQuery => {
gpu.encode_mesh_query(encoder, profile.as_deref_mut(), profile_id);
}
PreparedExecution::Present => {}
PreparedExecution::Fullscreen {
execution,
frame_out,
bind_group,
pipeline,
..
@@ -111,6 +95,18 @@ pub(crate) fn encode_compiled<T: Scene>(
.executions
.get(*execution)
.ok_or(" execution out of bounds")?;
let (target, operations) = if *frame_out {
let ExecutionKind::FrameOut { .. } = execution.kind else {
return Err("frame_out kind mismatch");
};
(
surface,
wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
)
} else {
let ExecutionKind::Render {
color_attachments, ..
} = &execution.kind
@@ -120,13 +116,9 @@ pub(crate) fn encode_compiled<T: Scene>(
let color = color_attachments
.first()
.ok_or("fullscreen target missing")?;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&execution.id),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: view(color.resource)?,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
(
view(color.resource)?,
wgpu::Operations {
load: match color.load {
NormalizedColorLoad::Load => wgpu::LoadOp::Load,
NormalizedColorLoad::Clear { value } => {
@@ -144,6 +136,15 @@ pub(crate) fn encode_compiled<T: Scene>(
wgpu::StoreOp::Discard
},
},
)
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&execution.id),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: operations,
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
@@ -155,9 +156,10 @@ pub(crate) fn encode_compiled<T: Scene>(
pass.set_bind_group(0, bind_group, &[]);
pass.draw(0..3, 0..1);
}
PreparedExecution::LegacyForward {
PreparedExecution::Pipeline {
execution,
variants,
base,
variant,
} => {
let execution = active
.graph
@@ -169,10 +171,10 @@ pub(crate) fn encode_compiled<T: Scene>(
depth_stencil,
} = &execution.kind
else {
return Err("legacy forward is not render");
return Err("pipeline is not render");
};
let color = color_attachments.first().ok_or("legacy color missing")?;
let depth = depth_stencil.as_ref().ok_or("legacy depth missing")?;
let color = color_attachments.first().ok_or("pipeline color missing")?;
let depth = depth_stencil.as_ref().ok_or("pipeline depth missing")?;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&execution.id),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
@@ -235,16 +237,14 @@ pub(crate) fn encode_compiled<T: Scene>(
pass.set_vertex_buffer(4, t.slice(..));
pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32);
for draw in &gpu.draws {
if draw.pipeline != *base {
continue;
}
let slot = draw.instances.start as u64;
let start = slot
* std::mem::size_of::<crate::renderer::gpu_scene::GpuInstance>() as u64;
pass.set_vertex_buffer(3, inst.slice(start..start + 112));
let key = variants
.iter()
.find(|(base, _)| *base == draw.pipeline)
.map(|x| &x.1)
.ok_or("pipeline variant missing")?;
pass.set_pipeline(key);
pass.set_pipeline(variant);
if pipelines.requires_material(draw.pipeline) {
pass.set_bind_group(2, materials.group(draw.material), &[]);
}
@@ -274,7 +274,7 @@ pub(crate) fn encode_immediate<T: Scene>(
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render pass"),
label: Some("Immediate pipeline pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None,
view: color,
@@ -298,7 +298,7 @@ pub(crate) fn encode_immediate<T: Scene>(
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: profile.and_then(|p| p.render_writes("immediate.forward")),
timestamp_writes: profile.and_then(|p| p.render_writes("immediate.pipeline")),
});
encode_scene(&mut pass, scene, gpu, pipelines, materials);
}
+30 -15
View File
@@ -85,8 +85,8 @@ impl GpuScenePlan {
self::GpuScenePlan::build_with_query(
data,
crate::render_graph::MeshQueryRuntimeKey {
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
frustum_culled: crate::render_graph::TriStatePredicate::Any,
visible: crate::render_graph::RuntimePredicate::RequiredTrue,
frustum_culled: crate::render_graph::RuntimePredicate::Any,
},
)
}
@@ -277,7 +277,6 @@ pub struct BufferSlot {
#[derive(Default)]
pub struct GpuSceneCache {
revision: Option<u64>,
query: Option<crate::render_graph::MeshQueryRuntimeKey>,
pub positions: BufferSlot,
pub normals: BufferSlot,
pub uvs: BufferSlot,
@@ -310,11 +309,12 @@ struct CullingParams {
_pad: u32,
}
fn predicate_code(value: crate::render_graph::TriStatePredicate) -> u32 {
fn predicate_code(value: crate::render_graph::RuntimePredicate) -> u32 {
match value {
crate::render_graph::TriStatePredicate::Any => 0,
crate::render_graph::TriStatePredicate::RequiredTrue => 1,
crate::render_graph::TriStatePredicate::RequiredFalse => 2,
crate::render_graph::RuntimePredicate::Any => 0,
crate::render_graph::RuntimePredicate::RequiredTrue => 1,
crate::render_graph::RuntimePredicate::RequiredFalse => 2,
crate::render_graph::RuntimePredicate::Never => 3,
}
}
@@ -330,8 +330,8 @@ impl GpuSceneCache {
queue,
data,
crate::render_graph::MeshQueryRuntimeKey {
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
frustum_culled: crate::render_graph::TriStatePredicate::Any,
visible: crate::render_graph::RuntimePredicate::RequiredTrue,
frustum_culled: crate::render_graph::RuntimePredicate::Any,
},
)
}
@@ -343,14 +343,13 @@ impl GpuSceneCache {
data: &SceneFramePlan,
query: crate::render_graph::MeshQueryRuntimeKey,
) -> Result<(), String> {
if self.revision == Some(data.revision) && self.query == Some(query) {
if self.revision == Some(data.revision) {
return Ok(());
}
let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?;
if plan.draws.is_empty() {
self.draws.clear();
self.revision = Some(data.revision);
self.query = Some(query);
return Ok(());
}
let maximum = device.limits().max_buffer_size;
@@ -485,7 +484,6 @@ impl GpuSceneCache {
self.draws = plan.draws;
self.rebuild_compute(device)?;
self.revision = Some(data.revision);
self.query = Some(query);
Ok(())
}
@@ -642,11 +640,28 @@ mod tests {
#[test]
fn mesh_query_source_guards_optional_flag_buffer_reads() {
let source = include_str!("culling.wgsl");
let visible_guard = source.find("if (params.visible_predicate != 0u)").unwrap();
let visible_never = source.find("if (params.visible_predicate == 3u)").unwrap();
let visible_guard = source
.find("else if (params.visible_predicate != 0u)")
.unwrap();
let visible_load = source.find("matches(authored_visible[i]").unwrap();
let frustum_guard = source.find("if (params.frustum_predicate != 0u)").unwrap();
let frustum_never = source
.find("if (selected && params.frustum_predicate == 3u)")
.unwrap();
let frustum_guard = source
.find("else if (selected && params.frustum_predicate != 0u)")
.unwrap();
let frustum_load = source.find("matches(frustum_flags[i]").unwrap();
assert!(visible_guard < visible_load && frustum_guard < frustum_load);
assert!(visible_never < visible_guard && visible_guard < visible_load);
assert!(frustum_never < frustum_guard && frustum_guard < frustum_load);
assert!(
visible_load < frustum_load,
"visible rejection must precede the frustum load"
);
assert!(
source.contains("predicate == 0u ||"),
"predicate zero is handled without a load by the guards"
);
for binding in 0..=6 {
assert!(source.contains(&format!("@binding({binding})")));
}
+134 -82
View File
@@ -35,17 +35,19 @@ struct GpuTextureSlot {
enum PreparedExecution {
FrustumCull,
MeshQuery,
LegacyForward {
PipelineRegistry,
Pipeline {
execution: usize,
variants: Vec<(crate::render_data::PipelineKey, wgpu::RenderPipeline)>,
base: crate::render_data::PipelineKey,
variant: wgpu::RenderPipeline,
},
Fullscreen {
execution: usize,
frame_out: bool,
bind_group: wgpu::BindGroup,
pipeline: wgpu::RenderPipeline,
_uniform: wgpu::Buffer,
},
Present,
}
struct ActiveCompiledGraph {
@@ -81,7 +83,10 @@ fn resolve_culling_frustum(
query: crate::render_graph::MeshQueryRuntimeKey,
read: impl FnOnce() -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>>,
) -> Result<Option<[[f32; 4]; 6]>, crate::render_graph::GraphError> {
if query.frustum_culled == crate::render_graph::TriStatePredicate::Any {
if matches!(
query.frustum_culled,
crate::render_graph::RuntimePredicate::Any | crate::render_graph::RuntimePredicate::Never
) {
return Ok(None);
}
match read() {
@@ -177,18 +182,25 @@ fn resolve_switch_request(
#[cfg(test)]
mod switch_request_tests {
fn valid_compile_graph(graph_id: &str, revision: u64) -> Vec<u8> {
let mut graph = crate::render_graph::tests::full_cull_graph();
graph["graphId"] = serde_json::json!(graph_id);
graph["revision"] = serde_json::json!(revision);
serde_json::to_vec(&graph).unwrap()
}
use super::*;
fn query(visible: crate::render_graph::TriStatePredicate) -> UploadGraph {
fn query(visible: crate::render_graph::RuntimePredicate) -> UploadGraph {
UploadGraph::Compiled(crate::render_graph::MeshQueryRuntimeKey {
visible,
frustum_culled: crate::render_graph::TriStatePredicate::Any,
frustum_culled: crate::render_graph::RuntimePredicate::Any,
})
}
#[test]
fn upload_selection_follows_the_graph_rendered_for_the_commit_frame() {
use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue};
use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue};
let selected =
|pending, active| upload_query_for_render(pending, active).map(|query| query.visible);
assert_eq!(
@@ -214,7 +226,7 @@ mod switch_request_tests {
#[test]
fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() {
use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue};
use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue};
let query = |frustum_culled| crate::render_graph::MeshQueryRuntimeKey {
visible: RequiredTrue,
frustum_culled,
@@ -245,8 +257,10 @@ mod switch_request_tests {
#[test]
fn resolves_at_command_boundary_before_gpu_work() {
let mut registry = crate::render_graph::Registry::default();
let bytes = br#"{"schemaVersion":2,"graphId":"switch","revision":1,"nodes":[]}"#;
let (id, _) = registry.compile(bytes).unwrap();
let mut graph = crate::render_graph::tests::full_cull_graph();
graph["graphId"] = serde_json::json!("switch");
let bytes = serde_json::to_vec(&graph).unwrap();
let (id, _) = registry.compile(&bytes).unwrap();
let active = "existing_graph";
let pending: Option<&str> = None;
assert_eq!(
@@ -279,9 +293,7 @@ mod switch_request_tests {
#[test]
fn resize_restart_snapshot_remains_bound_to_its_immutable_registry_revision() {
let mut registry = crate::render_graph::Registry::default();
let (id, _) = registry
.compile(br#"{"schemaVersion":2,"graphId":"resize","revision":1,"nodes":[]}"#)
.unwrap();
let (id, _) = registry.compile(&valid_compile_graph("resize", 1)).unwrap();
let revision_one = registry.get(id).unwrap().clone();
let in_flight = InFlightPreparation {
token: 1,
@@ -289,9 +301,7 @@ mod switch_request_tests {
purpose: PreparationPurpose::Resize,
graph: revision_one,
};
let (revision_two_id, _) = registry
.compile(br#"{"schemaVersion":2,"graphId":"resize","revision":2,"nodes":[]}"#)
.unwrap();
let (revision_two_id, _) = registry.compile(&valid_compile_graph("resize", 2)).unwrap();
let original = registry.get(id).unwrap();
let revision_two = registry.get(revision_two_id).unwrap();
assert_eq!(in_flight.graph.revision, 1);
@@ -728,6 +738,26 @@ impl<T: Scene + 'static> Renderer<T> {
) -> Result<ActiveCompiledGraph, crate::render_graph::GraphError> {
use crate::render_graph::*;
let fail = |message| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", message);
let resolved_pipelines = graph
.executions
.iter()
.enumerate()
.map(|(index, execution)| {
let NormalizedParameters::Pipeline { pipeline, .. } = &execution.parameters else {
return Ok(None);
};
self.resources
.find_pipeline(pipeline)
.map(Some)
.ok_or_else(|| {
GraphError::at(
"GRAPH_EXECUTION_UNSUPPORTED",
format!("pipeline '{pipeline}' is not registered"),
format!("executions[{index}].parameters.pipeline"),
)
})
})
.collect::<Result<Vec<_>, _>>()?;
let mut textures = Vec::with_capacity(runtime.allocations.classes.len());
for class in &runtime.allocations.classes {
let mut gpu_class = Vec::with_capacity(class.slots.len());
@@ -845,36 +875,52 @@ impl<T: Scene + 'static> Renderer<T> {
match execution.executor.key.as_str() {
"frustum_cull" => executions.push(PreparedExecution::FrustumCull),
"mesh_query" => executions.push(PreparedExecution::MeshQuery),
"present" => executions.push(PreparedExecution::Present),
"fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur"
"frame_out" | "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur"
| "bloom_composite" | "luminance_edge" => {
let sampled: Vec<_> = execution
.accesses
.iter()
.filter(|a| matches!(a.mode, AccessMode::SampledTexture))
.map(|a| a.resource)
.collect();
let source = *sampled
.first()
.ok_or_else(|| fail("fullscreen source missing"))?;
let second = *sampled.get(1).unwrap_or(&source);
let values: [f32; 8] = match execution.parameters {
NormalizedParameters::ToneMap { exposure } => {
[exposure, 0., 0., 0., 0., 0., 0., 0.]
let frame_out = execution.executor.key == "frame_out";
let (source, second) = if frame_out {
let ExecutionKind::FrameOut { color } = execution.kind else {
return Err(fail("frame_out kind mismatch"));
};
(color, color)
} else {
match execution.inputs.as_slice() {
[source, _color_target] => (source.resource, source.resource),
[source, bloom, _color_target]
if execution.executor.key == "bloom_composite" =>
{
(source.resource, bloom.resource)
}
NormalizedParameters::BloomExtract { threshold, knee } => {
[threshold, knee, 0., 0., 0., 0., 0., 0.]
_ => return Err(fail("fullscreen inputs mismatch")),
}
NormalizedParameters::BloomBlur { direction, radius } => {
[direction[0], direction[1], radius, 0., 0., 0., 0., 0.]
};
let values: [f32; 8] =
match (execution.executor.key.as_str(), &execution.parameters) {
(
"fullscreen_copy" | "frame_out",
NormalizedParameters::FullscreenCopy
| NormalizedParameters::FrameOut,
) => [0.; 8],
("tone_map", NormalizedParameters::ToneMap { exposure }) => {
[*exposure, 0., 0., 0., 0., 0., 0., 0.]
}
NormalizedParameters::BloomComposite { intensity } => {
[intensity, 0., 0., 0., 0., 0., 0., 0.]
}
NormalizedParameters::LuminanceEdge { strength } => {
[strength, 0., 0., 0., 0., 0., 0., 0.]
}
_ => [0.; 8],
(
"bloom_extract",
NormalizedParameters::BloomExtract { threshold, knee },
) => [*threshold, *knee, 0., 0., 0., 0., 0., 0.],
(
"bloom_blur",
NormalizedParameters::BloomBlur { direction, radius },
) => [direction[0], direction[1], *radius, 0., 0., 0., 0., 0.],
(
"bloom_composite",
NormalizedParameters::BloomComposite { intensity },
) => [*intensity, 0., 0., 0., 0., 0., 0., 0.],
(
"luminance_edge",
NormalizedParameters::LuminanceEdge { strength },
) => [*strength, 0., 0., 0., 0., 0., 0., 0.],
_ => return Err(fail("executor parameters mismatch")),
};
use wgpu::util::DeviceExt;
let uniform =
@@ -885,6 +931,9 @@ impl<T: Scene + 'static> Renderer<T> {
contents: bytemuck::cast_slice(&values),
usage: wgpu::BufferUsages::UNIFORM,
});
let target_format = if frame_out {
runtime.surface.format
} else {
let ExecutionKind::Render {
color_attachments, ..
} = &execution.kind
@@ -895,7 +944,22 @@ impl<T: Scene + 'static> Renderer<T> {
.first()
.ok_or_else(|| fail("fullscreen target missing"))?
.resource;
let target_format = if graph.resources.get(target as usize).is_some_and(|r| matches!(r.plan, ResourcePlan::Texture { family, .. } if family == runtime.allocations.surface_family)) { runtime.surface.format } else { let a=runtime.allocations.resource_allocations[target as usize].ok_or_else(|| fail("fullscreen target allocation missing"))?; runtime.allocations.classes[a.class as usize].slots[a.slot as usize].descriptor.format };
let a = runtime
.allocations
.resource_allocations
.get(target as usize)
.copied()
.flatten()
.ok_or_else(|| fail("fullscreen target allocation missing"))?;
runtime
.allocations
.classes
.get(a.class as usize)
.and_then(|class| class.slots.get(a.slot as usize))
.ok_or_else(|| fail("fullscreen target allocation is invalid"))?
.descriptor
.format
};
let entry = match execution.executor.key.as_str() {
"fullscreen_copy" => "fs_copy",
"tone_map" => "fs_tone_map",
@@ -903,7 +967,8 @@ impl<T: Scene + 'static> Renderer<T> {
"bloom_blur" => "fs_bloom_blur",
"bloom_composite" => "fs_bloom_composite",
"luminance_edge" => "fs_luminance_edge",
_ => unreachable!(),
"frame_out" => "fs_copy",
_ => return Err(fail("fullscreen executor mismatch")),
};
let pipeline = self.context.device.create_render_pipeline(
&wgpu::RenderPipelineDescriptor {
@@ -963,36 +1028,25 @@ impl<T: Scene + 'static> Renderer<T> {
});
executions.push(PreparedExecution::Fullscreen {
execution: index,
frame_out,
bind_group,
pipeline,
_uniform: uniform,
});
}
"legacy_forward" => {
"pipeline_registry" => executions.push(PreparedExecution::PipelineRegistry),
"pipeline" => {
let ExecutionKind::Render {
color_attachments,
depth_stencil,
} = &execution.kind
else {
return Err(fail("legacy forward is not render"));
return Err(fail("pipeline is not render"));
};
let color = color_attachments
.first()
.ok_or_else(|| fail("legacy color missing"))?;
let color_is_surface = graph
.resources
.get(color.resource as usize)
.is_some_and(|resource| {
matches!(
resource.plan,
ResourcePlan::SurfaceTarget { family }
| ResourcePlan::Texture { family, .. }
if family == runtime.allocations.surface_family
)
});
let color_format = if color_is_surface {
runtime.surface.format
} else {
.ok_or_else(|| fail("pipeline color missing"))?;
let color_format = {
let a = runtime
.allocations
.resource_allocations
@@ -1027,19 +1081,21 @@ impl<T: Scene + 'static> Renderer<T> {
.ok_or_else(|| fail("depth allocation invalid"))
})
.transpose()?;
let config = execution
.inputs
.iter()
.filter_map(|i| graph.resources.get(i.resource as usize))
.find_map(|r| {
if let ResourcePlan::DepthStencilConfig { config } = r.plan {
Some(config)
} else {
None
}
})
.ok_or_else(|| fail("depth config missing"))?;
let compare = match config.depth_compare {
let NormalizedParameters::Pipeline {
pipeline: _,
depth_compare,
depth_write_enabled,
..
} = &execution.parameters
else {
return Err(fail("pipeline parameters mismatch"));
};
let base = resolved_pipelines
.get(index)
.copied()
.flatten()
.ok_or_else(|| fail("resolved pipeline missing"))?;
let compare = match depth_compare {
CompareFunction::Never => wgpu::CompareFunction::Never,
CompareFunction::Less => wgpu::CompareFunction::Less,
CompareFunction::LessEqual => wgpu::CompareFunction::LessEqual,
@@ -1049,9 +1105,6 @@ impl<T: Scene + 'static> Renderer<T> {
CompareFunction::NotEqual => wgpu::CompareFunction::NotEqual,
CompareFunction::Always => wgpu::CompareFunction::Always,
};
let mut variants = Vec::new();
let bases: Vec<_> = self.resources.pipeline_keys().collect();
for base in bases {
let variant = self
.resources
.create_target_variant(
@@ -1060,14 +1113,13 @@ impl<T: Scene + 'static> Renderer<T> {
color_format,
depth_format,
compare,
config.depth_write_enabled,
*depth_write_enabled,
)
.map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?;
variants.push((base, variant));
}
executions.push(PreparedExecution::LegacyForward {
executions.push(PreparedExecution::Pipeline {
execution: index,
variants,
base,
variant,
});
}
_ => return Err(fail("unsupported prepared execution")),
-30
View File
@@ -372,36 +372,6 @@ impl PipelineLibrary {
&& self.specs[key.get() as usize].layout == self.material_layout
}
pub fn pipeline_keys(&self) -> impl Iterator<Item = PipelineKey> + '_ {
let mut keys = self
.pipeline_registry
.values()
.map(|entry| entry.0)
.collect::<Vec<_>>();
keys.sort_by_key(|key| key.get());
keys.dedup();
keys.into_iter()
}
pub fn get_or_create_target_variant(
&mut self,
device: &wgpu::Device,
base: PipelineKey,
color_format: wgpu::TextureFormat,
depth_format: Option<wgpu::TextureFormat>,
depth_compare: wgpu::CompareFunction,
depth_write: bool,
) -> Result<PipelineKey, String> {
let spec = self
.specs
.get(base.get() as usize)
.cloned()
.ok_or_else(|| "unknown base pipeline".to_owned())?;
let spec =
target_variant_spec(spec, color_format, depth_format, depth_compare, depth_write);
Ok(self.get_or_create_from_spec(device, &spec, Some("target variant")))
}
pub fn create_target_variant(
&self,
device: &wgpu::Device,
+266 -40
View File
@@ -56,14 +56,20 @@ const sourceMaps = new WeakMap();
export const getSourceMap = (ir) => sourceMaps.get(ir);
export const mapAuthoringDiagnostic = (ir, diagnostic) => {
const details = diagnostic?.details;
const path = [details?.path, diagnostic?.path, details?.field, diagnostic?.field]
.find((value) => typeof value === "string");
const path = [
details?.path,
diagnostic?.path,
details?.field,
diagnostic?.field,
].find((value) => typeof value === "string");
const map = getSourceMap(ir);
let match;
if (path && map)
for (const key of Object.keys(map))
if (
(path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) &&
(path === key ||
path.startsWith(`${key}.`) ||
path.startsWith(`${key}[`)) &&
(!match || key.length > match.length)
)
match = key;
@@ -80,33 +86,49 @@ export const mapAuthoringDiagnostic = (ir, diagnostic) => {
const mapValuePaths = (paths, path, source, value) => {
paths[path] = source;
if (Array.isArray(value))
value.forEach((child, index) => mapValuePaths(paths, `${path}[${index}]`, source, child));
value.forEach((child, index) =>
mapValuePaths(paths, `${path}[${index}]`, source, child),
);
else if (object(value))
for (const key of Object.keys(value))
mapValuePaths(paths, `${path}.${key}`, source, value[key]);
};
function parameterValue(raw, schema, nodeId, key) {
const expected = schema.type === "json" ? "json" : schema.type;
if (
!exactKeys(raw, ["kind", "value"]) ||
raw.kind !== expected ||
!finiteJson(raw.value)
)
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
if (
(expected === "number" && typeof raw.value !== "number") ||
(expected === "string" && typeof raw.value !== "string") ||
(expected === "boolean" && typeof raw.value !== "boolean") ||
(expected === "json" && !finiteJson(raw.value))
)
if (!exactKeys(raw, ["kind", "value"]) || raw.kind !== schema.type)
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
const value = raw.value;
const bounded = (number) =>
Number.isFinite(number) &&
(schema.minimum === undefined || number >= schema.minimum) &&
(schema.maximum === undefined || number <= schema.maximum);
const valid =
schema.type === "number"
? bounded(value) && (!schema.integer || Number.isSafeInteger(value))
: schema.type === "string"
? typeof value === "string" &&
(!schema.enum || schema.enum.includes(value))
: schema.type === "boolean"
? typeof value === "boolean"
: schema.type === "vector" || schema.type === "color"
? Array.isArray(value) &&
value.length === (schema.type === "vector" ? 3 : 4) &&
value.every(bounded)
: schema.type === "json" && finiteJson(value);
if (!valid) fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
return canonical(structuredClone(raw.value));
}
export function adaptFxNodeSnapshot(raw, revision = 1) {
try {
const rootKeys = ["graphId", "catalogVersion", "nodes", "links", "metadata", "version"];
const rootKeys = [
"graphId",
"catalogVersion",
"nodes",
"links",
"metadata",
"version",
];
if (
!exactKeys(raw, rootKeys) ||
!Array.isArray(raw.nodes) ||
@@ -117,9 +139,7 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
fail("AUTHORING_SHAPE");
if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION)
fail("AUTHORING_CATALOG");
if (
!Number.isSafeInteger(raw.version) || raw.version < 0
)
if (!Number.isSafeInteger(raw.version) || raw.version < 0)
fail("AUTHORING_SHAPE");
if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff)
fail("AUTHORING_REVISION");
@@ -134,7 +154,20 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
definition = nodeDefinitions[n.typeId];
if (!descriptor)
fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId });
const nodeKeys = ["id", "typeId", "typeVersion", "position", "size", "label", "parameters", "sockets", "muted", "collapsed", "extensions", "known"];
const nodeKeys = [
"id",
"typeId",
"typeVersion",
"position",
"size",
"label",
"parameters",
"sockets",
"muted",
"collapsed",
"extensions",
"known",
];
if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId");
if (
!exactKeys(n, nodeKeys) ||
@@ -143,10 +176,17 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
typeof n.muted !== "boolean" ||
typeof n.collapsed !== "boolean" ||
typeof n.label !== "string" ||
!exactKeys(n.position, ["x", "y"]) || !Number.isFinite(n.position.x) || !Number.isFinite(n.position.y) ||
!exactKeys(n.size, ["x", "y"]) || !Number.isFinite(n.size.x) || !Number.isFinite(n.size.y) || n.size.x <= 0 || n.size.y <= 0 ||
!exactKeys(n.position, ["x", "y"]) ||
!Number.isFinite(n.position.x) ||
!Number.isFinite(n.position.y) ||
!exactKeys(n.size, ["x", "y"]) ||
!Number.isFinite(n.size.x) ||
!Number.isFinite(n.size.y) ||
n.size.x <= 0 ||
n.size.y <= 0 ||
(Object.hasOwn(n, "parentId") && !identifier(n.parentId)) ||
!object(n.extensions) || !finiteJson(n.extensions) ||
!object(n.extensions) ||
!finiteJson(n.extensions) ||
!Array.isArray(n.sockets) ||
!object(n.parameters)
)
@@ -168,6 +208,48 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
),
]),
);
if (n.typeId === "bloom_blur")
parameters.direction =
parameters.direction === "horizontal" ? [1, 0] : [0, 1];
if (n.typeId === "frustum_cull") {
parameters.camera = parameters.cameraSelection;
delete parameters.cameraSelection;
}
if (n.typeId === "texture") {
const extent =
parameters.extentMode === "absolute"
? {
kind: "absolute",
width: parameters.absoluteWidth,
height: parameters.absoluteHeight,
depthOrArrayLayers: parameters.depthOrArrayLayers,
}
: {
kind: "surface_relative",
width: {
numerator: parameters.relativeWidthNumerator,
denominator: parameters.relativeWidthDenominator,
},
height: {
numerator: parameters.relativeHeightNumerator,
denominator: parameters.relativeHeightDenominator,
},
depthOrArrayLayers: parameters.depthOrArrayLayers,
};
const flat = structuredClone(parameters);
Object.keys(parameters).forEach((key) => delete parameters[key]);
Object.assign(parameters, {
residency: flat.residency,
texture: {
dimension: flat.dimension,
format: flat.format,
extent,
mipLevelCount: flat.mipLevelCount,
sampleCount: Number(flat.sampleCount),
viewFormats: flat.viewFormat === "none" ? [] : [flat.viewFormat],
},
});
}
const expected = [
...Object.keys(descriptor.inputs),
...Object.keys(descriptor.outputs),
@@ -181,17 +263,49 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
socketDefinition = definition.sockets[s.key],
direction = input ? "input" : "output",
dataType = socketDefinition.type,
socketKeys = ["id", "key", "label", "direction", "dataType", "accepts", "maxIncomingLinks", ...(socketDefinition.value ? ["defaultValue"] : []), "visible"];
socketKeys = [
"id",
"key",
"label",
"direction",
"dataType",
"accepts",
"maxIncomingLinks",
...(socketDefinition.value ? ["defaultValue"] : []),
"visible",
];
if (
!exactKeys(s, socketKeys) ||
s.id !== `${n.id}:${s.key}` ||
s.label !== socketDefinition.title ||
s.direction !== direction ||
s.dataType !== dataType ||
!Array.isArray(s.accepts) || s.accepts.length !== (direction === "input" ? socketTypes[dataType].acceptsFrom.length : 0) ||
!s.accepts.every((v, i) => v === (direction === "input" ? socketTypes[dataType].acceptsFrom[i] : undefined)) ||
!Array.isArray(s.accepts) ||
s.accepts.length !==
(direction === "input"
? socketTypes[dataType].acceptsFrom.length
: 0) ||
!s.accepts.every(
(v, i) =>
v ===
(direction === "input"
? socketTypes[dataType].acceptsFrom[i]
: undefined),
) ||
(socketDefinition.value
? !exactKeys(s.defaultValue, ["kind", "value"]) || !finiteJson(s.defaultValue.value)
? (() => {
try {
parameterValue(
s.defaultValue,
socketDefinition.value,
n.id,
s.key,
);
return false;
} catch {
return true;
}
})()
: s.defaultValue !== undefined) ||
s.visible !== socketDefinition.visible ||
s.maxIncomingLinks !== socketDefinition.maxIncomingLinks
@@ -206,10 +320,21 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
: descriptor.outputs[s.key].type,
authoringType: s.dataType,
maxIncomingLinks: s.maxIncomingLinks,
defaultValue: socketDefinition.value
? structuredClone(s.defaultValue)
: undefined,
});
}
if (new Set(n.sockets.map((s) => s.key)).size !== expected.length)
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
if (n.typeId === "mesh_query") {
parameters.visibleDefault = sockets.get(
`${n.id}:isVisible`,
).defaultValue.value;
parameters.frustumCulledDefault = sockets.get(
`${n.id}:isFrustumCulled`,
).defaultValue.value;
}
nodes.set(n.id, {
ordinal,
value: {
@@ -230,8 +355,18 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
!object(link) ||
!identifier(link.id) ||
linkIds.has(link.id) ||
!exactKeys(link, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) ||
typeof link.muted !== "boolean" || !object(link.extensions) || !finiteJson(link.extensions)
!exactKeys(link, [
"id",
"fromNodeId",
"fromSocketId",
"toNodeId",
"toSocketId",
"muted",
"extensions",
]) ||
typeof link.muted !== "boolean" ||
!object(link.extensions) ||
!finiteJson(link.extensions)
)
fail("AUTHORING_LINK", { linkId: link?.id });
linkIds.add(link.id);
@@ -244,21 +379,33 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
link.toNodeId !== to.node ||
from.direction !== "output" ||
to.direction !== "input" ||
(!link.muted && (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks)
(!link.muted &&
(incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks)
)
fail(
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
!link.muted &&
(incoming.get(link.toSocketId) ?? 0) >=
(to?.maxIncomingLinks ?? Infinity)
? "AUTHORING_LINK_INCOMING"
: "AUTHORING_LINK",
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
!link.muted &&
(incoming.get(link.toSocketId) ?? 0) >=
(to?.maxIncomingLinks ?? Infinity)
? { socketId: link.toSocketId }
: { linkId: link.id },
);
const accepted =
descriptors[nodes.get(to.node).value.executor.key].inputs[to.key]
.accepted.types;
const authoringAccepted = socketTypes[nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key].type].acceptsFrom;
if (!accepted.includes(from.semanticType) || !authoringAccepted.includes(from.authoringType))
const authoringAccepted =
socketTypes[
nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key]
.type
].acceptsFrom;
if (
!accepted.includes(from.semanticType) ||
!authoringAccepted.includes(from.authoringType)
)
fail("AUTHORING_LINK_TYPE", { linkId: link.id });
const linkSource = {
kind: "link",
@@ -299,13 +446,92 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
const base = `nodes[${wireOrdinal}]`;
const nodeSource = { kind: "node", nodeId: item.value.id };
paths[base] = nodeSource;
for (const field of ["id", "state", "executor", "executor.key", "executor.version"])
for (const field of [
"id",
"state",
"executor",
"executor.key",
"executor.version",
])
paths[`${base}.${field}`] = nodeSource;
paths[`${base}.parameters`] = nodeSource;
const parameterSource = (parameter) => ({
kind: "parameter",
nodeId: item.value.id,
parameter,
});
if (item.value.executor.key === "texture") {
const root = `${base}.parameters`;
const texture = item.value.parameters.texture;
paths[`${root}.residency`] = parameterSource("residency");
paths[`${root}.texture`] = nodeSource;
paths[`${root}.texture.dimension`] = parameterSource("dimension");
paths[`${root}.texture.format`] = parameterSource("format");
paths[`${root}.texture.extent`] = parameterSource("extentMode");
paths[`${root}.texture.extent.kind`] = parameterSource("extentMode");
paths[`${root}.texture.extent.depthOrArrayLayers`] =
parameterSource("depthOrArrayLayers");
if (texture.extent.kind === "absolute") {
paths[`${root}.texture.extent.width`] =
parameterSource("absoluteWidth");
paths[`${root}.texture.extent.height`] =
parameterSource("absoluteHeight");
} else {
paths[`${root}.texture.extent.width`] = parameterSource("extentMode");
paths[`${root}.texture.extent.width.numerator`] = parameterSource(
"relativeWidthNumerator",
);
paths[`${root}.texture.extent.width.denominator`] = parameterSource(
"relativeWidthDenominator",
);
paths[`${root}.texture.extent.height`] =
parameterSource("extentMode");
paths[`${root}.texture.extent.height.numerator`] = parameterSource(
"relativeHeightNumerator",
);
paths[`${root}.texture.extent.height.denominator`] = parameterSource(
"relativeHeightDenominator",
);
}
paths[`${root}.texture.mipLevelCount`] =
parameterSource("mipLevelCount");
paths[`${root}.texture.sampleCount`] = parameterSource("sampleCount");
mapValuePaths(
paths,
`${root}.texture.viewFormats`,
parameterSource("viewFormat"),
texture.viewFormats,
);
} else
for (const key of Object.keys(item.value.parameters))
mapValuePaths(paths, `${base}.parameters.${key}`, { kind: "parameter", nodeId: item.value.id, parameter: key }, item.value.parameters[key]);
for (const key of Object.keys(descriptors[item.value.executor.key].inputs)) {
const link = raw.links.find((x) => !x.muted && x.toNodeId === item.value.id && sockets.get(x.toSocketId)?.key === key);
mapValuePaths(
paths,
`${base}.parameters.${key}`,
item.value.executor.key === "mesh_query" && key.endsWith("Default")
? {
kind: "input",
nodeId: item.value.id,
input:
key === "visibleDefault" ? "isVisible" : "isFrustumCulled",
socketId: `${item.value.id}:${key === "visibleDefault" ? "isVisible" : "isFrustumCulled"}`,
unconnected: true,
}
: parameterSource(
item.value.executor.key === "frustum_cull" && key === "camera"
? "cameraSelection"
: key,
),
item.value.parameters[key],
);
for (const key of Object.keys(
descriptors[item.value.executor.key].inputs,
)) {
const link = raw.links.find(
(x) =>
!x.muted &&
x.toNodeId === item.value.id &&
sockets.get(x.toSocketId)?.key === key,
);
const source = linkSources.get(link?.id) ?? {
kind: "input",
nodeId: item.value.id,
+2 -1
View File
@@ -3,8 +3,9 @@ import { semanticCatalog } from "./catalog.js";
const GROUPS = Object.freeze([
["source", "Source"],
["compute", "Compute"],
["cpu_preparation", "CPU preparation"],
["render", "Render / post"],
["present", "Present"],
["frame", "Frame"],
]);
const title = (typeId) => typeId.replaceAll("_", " ");
+186 -97
View File
@@ -1,7 +1,6 @@
export const GRAPH_ID = "authored_gpu_culling";
export const CATALOG_VERSION = 2;
export const CATALOG_VERSION = 4;
const exact = (type) => ({ kind: "exact", types: [type] });
const oneOf = (...types) => ({ kind: "one_of", types });
const i = (type, required = true, authoringType) => ({
accepted: typeof type === "string" ? exact(type) : type,
required,
@@ -25,104 +24,91 @@ const texture = {
},
};
export const semanticCatalog = Object.freeze({
surface_target: {
mesh: {
execution: "source",
inputs: {},
outputs: { surface: o("surface_target") },
parameters: {},
},
texture_spec: {
execution: "source",
inputs: {},
outputs: { spec: o("texture_spec") },
parameters: structuredClone(texture),
},
scene_table: {
execution: "source",
inputs: {},
outputs: { scene: o("scene_table") },
parameters: {},
},
local_aabb_buffer: {
execution: "source",
inputs: { scene: i("scene_table") },
outputs: { localAabbs: o("local_aabb_buffer") },
parameters: {},
},
camera_frustum: {
execution: "source",
inputs: {},
outputs: { frustum: o("camera_frustum") },
parameters: {},
},
visibility_flags: {
execution: "source",
inputs: { scene: i("scene_table") },
outputs: {
flags: {
mesh: o("mesh_data"),
localAabbs: o("local_aabb_buffer"),
isVisible: {
...o("boolean_flag_buffer"),
authoringType: "visibility_flag_buffer",
},
pipelineIndices: o("pipeline_index_stream"),
},
parameters: {},
},
texture: {
execution: "source",
inputs: {},
outputs: { texture: o("texture") },
parameters: {
residency: "transient",
format: "rgba16_float",
dimension: "d2",
extentMode: "surface_relative",
absoluteWidth: 1,
absoluteHeight: 1,
relativeWidthNumerator: 1,
relativeWidthDenominator: 1,
relativeHeightNumerator: 1,
relativeHeightDenominator: 1,
depthOrArrayLayers: 1,
mipLevelCount: 1,
sampleCount: "1",
viewFormat: "none",
},
},
frustum_cull: {
execution: "compute",
inputs: {
scene: i("scene_table"),
mesh: i("mesh_data"),
localAabbs: i("local_aabb_buffer"),
frustum: i("camera_frustum"),
},
outputs: {
flags: {
isFrustumCulled: {
...o("boolean_flag_buffer"),
authoringType: "frustum_flag_buffer",
},
},
parameters: {},
parameters: { cameraSelection: "active" },
},
mesh_query: {
execution: "compute",
inputs: {
scene: i("scene_table"),
mesh: i("mesh_data"),
isVisible: i("boolean_flag_buffer", false, "visibility_flag_buffer"),
isFrustumCulled: i("boolean_flag_buffer", false, "frustum_flag_buffer"),
},
outputs: { draws: o("draw_stream") },
parameters: {
filters: [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "required_false" },
],
visiblePredicate: "required_true",
frustumCulledPredicate: "required_false",
},
},
depth_stencil_config: {
execution: "source",
inputs: {},
outputs: { config: o("depth_stencil_config") },
parameters: {
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
pipeline_registry: {
execution: "cpu_preparation",
inputs: { pipelineIndices: i("pipeline_index_stream") },
outputs: { activation: o("pipeline_activation") },
parameters: {},
},
},
legacy_forward: {
pipeline: {
execution: "render",
inputs: {
scene: i("scene_table"),
mesh: i("mesh_data"),
draws: i("draw_stream"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
depthTarget: i(oneOf("texture_spec", "texture")),
depthStencil: i("depth_stencil_config"),
activation: i("pipeline_activation"),
colorTarget: i("texture"),
depthTarget: i("texture"),
},
outputs: { color: o("texture"), depth: o("texture") },
parameters: { clearColor: [0.015, 0.02, 0.03, 1] },
parameters: { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] },
},
fullscreen_copy: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
colorTarget: i("texture"),
},
outputs: { color: o("texture") },
parameters: {},
@@ -131,7 +117,7 @@ export const semanticCatalog = Object.freeze({
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
colorTarget: i("texture"),
},
outputs: { color: o("texture") },
parameters: { exposure: 1 },
@@ -140,7 +126,7 @@ export const semanticCatalog = Object.freeze({
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
colorTarget: i("texture"),
},
outputs: { color: o("texture") },
parameters: { threshold: 1, knee: 0.5 },
@@ -149,7 +135,7 @@ export const semanticCatalog = Object.freeze({
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
colorTarget: i("texture"),
},
outputs: { color: o("texture") },
parameters: { direction: [1, 0], radius: 1 },
@@ -159,7 +145,7 @@ export const semanticCatalog = Object.freeze({
inputs: {
source: i("texture"),
bloom: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
colorTarget: i("texture"),
},
outputs: { color: o("texture") },
parameters: { intensity: 1 },
@@ -168,14 +154,14 @@ export const semanticCatalog = Object.freeze({
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
colorTarget: i("texture"),
},
outputs: { color: o("texture") },
parameters: { strength: 2 },
},
present: {
execution: "present",
inputs: { surface: i("texture") },
frame_out: {
execution: "frame",
inputs: { color: i("texture") },
outputs: {},
parameters: {},
},
@@ -193,15 +179,13 @@ const socketColors = [
];
export const socketTypes = Object.fromEntries(
[
"surface_target",
"texture_spec",
"texture",
"scene_table",
"mesh_data",
"local_aabb_buffer",
"camera_frustum",
"boolean_flag_buffer",
"pipeline_index_stream",
"draw_stream",
"depth_stencil_config",
"pipeline_activation",
"visibility_flag_buffer",
"frustum_flag_buffer",
].map((type, index) => [
@@ -213,12 +197,6 @@ export const socketTypes = Object.fromEntries(
},
]),
);
socketTypes.surface_target.acceptsFrom = [
"surface_target",
"texture_spec",
"texture",
];
socketTypes.texture_spec.acceptsFrom = ["texture_spec", "texture"];
socketTypes.boolean_flag_buffer.acceptsFrom = [
"boolean_flag_buffer",
"visibility_flag_buffer",
@@ -259,33 +237,138 @@ export const theme = {
export const styles = {
source: { header: "#3977a8" },
compute: { header: "#725a9b" },
cpu_preparation: { header: "#8a6d3b" },
render: { header: "#426b43" },
present: { header: "#a75d37" },
frame: { header: "#a75d37" },
};
const socket = (title, direction, type) => ({
const socket = (title, direction, type, value = null) => ({
title,
direction,
type,
maxIncomingLinks: direction === "input" ? 1 : 0,
visible: true,
value: null,
showValue: false,
value,
showValue: value !== null,
});
const parameterSchema = (value) =>
typeof value === "number"
? { type: "number", default: { kind: "number", value } }
: typeof value === "string"
? { type: "string", default: { kind: "string", value } }
: typeof value === "boolean"
? { type: "boolean", default: { kind: "boolean", value } }
: { type: "json", default: { kind: "json", value } };
const tagged = (kind, value) => ({ kind, value: structuredClone(value) });
const number = (value, minimum, maximum) => ({
type: "number",
default: tagged("number", value),
minimum,
maximum,
});
const enumeration = (value, values) => ({
type: "string",
default: tagged("string", value),
enum: values,
});
const string = (value) => ({ type: "string", default: tagged("string", value) });
const boolean = (value) => ({
type: "boolean",
default: tagged("boolean", value),
});
const color = (value) => ({
type: "color",
default: tagged("color", value),
minimum: 0,
maximum: 1,
});
const json = (value) => ({ type: "json", default: tagged("json", value) });
const parameterSchemas = {
texture: {
residency: enumeration("transient", ["transient", "persistent"]),
format: enumeration("rgba16_float", [
"rgba8_unorm",
"rgba8_unorm_srgb",
"bgra8_unorm",
"bgra8_unorm_srgb",
"rgba16_float",
"r32_float",
"depth32_float",
]),
dimension: enumeration("d2", ["d1", "d2", "d3"]),
extentMode: enumeration("surface_relative", [
"surface_relative",
"absolute",
]),
absoluteWidth: { ...number(1, 1, 0xffffffff), integer: true },
absoluteHeight: { ...number(1, 1, 0xffffffff), integer: true },
relativeWidthNumerator: { ...number(1, 1, 0xffffffff), integer: true },
relativeWidthDenominator: { ...number(1, 1, 0xffffffff), integer: true },
relativeHeightNumerator: { ...number(1, 1, 0xffffffff), integer: true },
relativeHeightDenominator: { ...number(1, 1, 0xffffffff), integer: true },
depthOrArrayLayers: { ...number(1, 1, 0xffffffff), integer: true },
mipLevelCount: { ...number(1, 1, 0xffffffff), integer: true },
sampleCount: enumeration("1", ["1", "4"]),
viewFormat: enumeration("none", [
"none",
"rgba8_unorm",
"rgba8_unorm_srgb",
"bgra8_unorm",
"bgra8_unorm_srgb",
"rgba16_float",
"r32_float",
"depth32_float",
]),
},
mesh: {},
frustum_cull: { cameraSelection: enumeration("active", ["active"]) },
mesh_query: {
visiblePredicate: enumeration("required_true", [
"any",
"required_true",
"required_false",
]),
frustumCulledPredicate: enumeration("required_false", [
"any",
"required_true",
"required_false",
]),
},
pipeline_registry: {},
pipeline: {
pipeline: string("gltf_standard"),
depthCompare: enumeration("less_equal", [
"never",
"less",
"equal",
"less_equal",
"greater",
"not_equal",
"greater_equal",
"always",
]),
depthWriteEnabled: boolean(true),
clearDepth: number(1, 0, 1),
clearColor: color([0.015, 0.02, 0.03, 1]),
},
fullscreen_copy: {},
tone_map: { exposure: number(1, 0, 32) },
bloom_extract: { threshold: number(1, 0, 64), knee: number(0.5, 0, 1) },
bloom_blur: {
direction: enumeration("horizontal", ["horizontal", "vertical"]),
radius: number(1, 1, 16),
},
bloom_composite: { intensity: number(1, 0, 16) },
luminance_edge: { strength: number(2, 0, 16) },
frame_out: {},
};
export const nodeDefinitions = Object.fromEntries(
Object.entries(semanticCatalog).map(([key, c]) => {
const sockets = {
...Object.fromEntries(
Object.entries(c.inputs).map(([n, v]) => [
n,
socket(n, "input", v.authoringType ?? v.accepted.types[0]),
socket(
n,
"input",
v.authoringType ?? v.accepted.types[0],
key === "mesh_query" && n === "isVisible"
? boolean(true)
: key === "mesh_query" && n === "isFrustumCulled"
? boolean(false)
: null,
),
]),
),
...Object.fromEntries(
@@ -295,12 +378,15 @@ export const nodeDefinitions = Object.fromEntries(
]),
),
},
parameters = Object.fromEntries(
Object.entries(c.parameters).map(([name, value]) => [
name,
parameterSchema(value),
]),
);
parameters = parameterSchemas[key];
if (
!parameters ||
Object.keys(parameters).length !== Object.keys(c.parameters).length ||
!Object.keys(c.parameters).every((name) =>
Object.hasOwn(parameters, name),
)
)
throw new Error(`parameter schema mismatch for ${key}`);
return [
key,
{
@@ -314,6 +400,9 @@ export const nodeDefinitions = Object.fromEntries(
...Object.keys(parameters).map((parameter) => ({
kind: "parameter",
parameter,
...(key === "frustum_cull" && parameter === "cameraSelection"
? { title: "Camera" }
: {}),
})),
...Object.keys(sockets).map((socket) => ({ kind: "socket", socket })),
],
+57 -48
View File
@@ -5,19 +5,16 @@ import { createAddNodeMenu } from "./add-node-menu.js";
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
const spec = [
["surface", "surface_target", { x: 40, y: 40 }],
["hdr", "texture_spec", { x: 40, y: 170 }],
["depth", "texture_spec", { x: 40, y: 300 }],
["scene", "scene_table", { x: 40, y: 470 }],
["aabbs", "local_aabb_buffer", { x: 290, y: 430 }],
["frustum", "camera_frustum", { x: 290, y: 590 }],
["visible", "visibility_flags", { x: 290, y: 300 }],
["hdr", "texture", { x: 40, y: 170 }],
["depth", "texture", { x: 40, y: 300 }],
["mesh", "mesh", { x: 40, y: 470 }],
["cull", "frustum_cull", { x: 540, y: 480 }],
["query", "mesh_query", { x: 790, y: 330 }],
["depth_config", "depth_stencil_config", { x: 790, y: 620 }],
["forward", "legacy_forward", { x: 1040, y: 290 }],
["copy", "fullscreen_copy", { x: 1300, y: 250 }],
["present", "present", { x: 1540, y: 250 }],
["registry", "pipeline_registry", { x: 790, y: 620 }],
["ground", "pipeline", { x: 1040, y: 290 }],
["pbr", "pipeline", { x: 1300, y: 290 }],
["pbr_double", "pipeline", { x: 1560, y: 290 }],
["frame_out", "frame_out", { x: 1820, y: 250 }],
];
async function seed(root) {
await root.setState({
@@ -30,22 +27,24 @@ async function seed(root) {
for (const [nodeId, nodeType, position] of spec)
await root.dispatch({ type: "node.add", nodeId, nodeType, position });
const links = [
["scene", "scene", "aabbs", "scene"],
["scene", "scene", "visible", "scene"],
["scene", "scene", "cull", "scene"],
["aabbs", "localAabbs", "cull", "localAabbs"],
["frustum", "frustum", "cull", "frustum"],
["scene", "scene", "query", "scene"],
["visible", "flags", "query", "isVisible"],
["cull", "flags", "query", "isFrustumCulled"],
["scene", "scene", "forward", "scene"],
["query", "draws", "forward", "draws"],
["hdr", "spec", "forward", "colorTarget"],
["depth", "spec", "forward", "depthTarget"],
["depth_config", "config", "forward", "depthStencil"],
["forward", "color", "copy", "source"],
["surface", "surface", "copy", "colorTarget"],
["copy", "color", "present", "surface"],
["mesh", "mesh", "cull", "mesh"],
["mesh", "localAabbs", "cull", "localAabbs"],
["mesh", "mesh", "query", "mesh"],
["mesh", "isVisible", "query", "isVisible"],
["cull", "isFrustumCulled", "query", "isFrustumCulled"],
["mesh", "pipelineIndices", "registry", "pipelineIndices"],
...["ground", "pbr", "pbr_double"].flatMap((pipeline) => [
["mesh", "mesh", pipeline, "mesh"],
["query", "draws", pipeline, "draws"],
["registry", "activation", pipeline, "activation"],
]),
["hdr", "texture", "ground", "colorTarget"],
["depth", "texture", "ground", "depthTarget"],
["ground", "color", "pbr", "colorTarget"],
["ground", "depth", "pbr", "depthTarget"],
["pbr", "color", "pbr_double", "colorTarget"],
["pbr", "depth", "pbr_double", "depthTarget"],
["pbr_double", "color", "frame_out", "color"],
];
for (const [a, as, b, bs] of links) {
const id = `${a}_${as}_${b}_${bs}`;
@@ -64,34 +63,44 @@ async function seed(root) {
}
const authored = await root.getState(),
depth = authored.nodes.find((node) => node.id === "depth");
depth.parameters.texture = {
kind: "json",
value: {
dimension: "d2",
format: "depth32_float",
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
},
mipLevelCount: 1,
sampleCount: 1,
viewFormats: [],
},
};
depth.parameters.format = { kind: "string", value: "depth32_float" };
for (const [id, name] of [["ground", "ground_plane"], ["pbr", "gltf_standard"], ["pbr_double", "gltf_standard_double_sided"]])
authored.nodes.find((node) => node.id === id).parameters.pipeline = { kind: "string", value: name };
await root.setState(authored);
}
export async function createRenderGraphEditor(canvas) {
const allocateId = createNodeIdAllocator();
let root, view, menu, destroying, dead = false;
const requestAddNode = Object.assign(async (request, point, isCurrent = () => true) => {
let root,
view,
menu,
destroying,
dead = false;
const requestAddNode = Object.assign(
async (request, point, isCurrent = () => true) => {
let typeId;
try { typeId = await menu?.open(point); } catch (error) { if (!dead && isCurrent()) console.error(error); return; }
try {
typeId = await menu?.open(point);
} catch (error) {
if (!dead && isCurrent()) console.error(error);
return;
}
if (dead || !isCurrent() || !root || !view) return;
const alive = () => !dead && isCurrent();
try { await spawnRequestedNode(root, view, request, typeId, allocateId, alive); } catch (error) { if (!dead) console.error(error); }
}, { close: () => menu?.close() });
try {
await spawnRequestedNode(
root,
view,
request,
typeId,
allocateId,
alive,
);
} catch (error) {
if (!dead) console.error(error);
}
},
{ close: () => menu?.close() },
);
const host = prepareBrowserHost(canvas, { requestAddNode });
const destroy = () =>
(destroying ??= (async () => {
+56 -258
View File
@@ -1,278 +1,76 @@
const input = (node, socket) => ({ node, socket });
const node = (id, key, parameters = {}, inputs = {}) => ({
id,
state: "enabled",
executor: { key, version: 1 },
parameters,
inputs,
id, state: "enabled", executor: { key, version: 1 }, parameters, inputs,
});
const texture = (format) => ({
const texture = (format, scale = 1) => ({
texture: {
dimension: "d2",
format,
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
},
mipLevelCount: 1,
sampleCount: 1,
viewFormats: [],
dimension: "d2", format,
extent: { kind: "surface_relative", width: { numerator: 1, denominator: scale }, height: { numerator: 1, denominator: scale }, depthOrArrayLayers: 1 },
mipLevelCount: 1, sampleCount: 1, viewFormats: [],
},
residency: "transient",
});
const direct = (graphId, clearColor) => Object.freeze({
schemaVersion: 2,
graphId,
revision: 1,
nodes: [
node("surface", "surface_target"),
node("depth", "texture_spec", texture("depth32_float")),
node("scene", "scene_table"),
node("visible", "visibility_flags", {}, { scene: input("scene", "scene") }),
node("query", "mesh_query", {
filters: [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "any" },
],
}, { scene: input("scene", "scene"), isVisible: input("visible", "flags") }),
node("depth_config", "depth_stencil_config", {
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
}),
node("forward", "legacy_forward", { clearColor }, {
scene: input("scene", "scene"),
draws: input("query", "draws"),
colorTarget: input("surface", "surface"),
depthTarget: input("depth", "spec"),
depthStencil: input("depth_config", "config"),
}),
node("present", "present", {}, { surface: input("forward", "color") }),
],
});
const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1]) => [
node("hdr", "texture", texture("rgba16_float")),
node("depth", "texture", texture("depth32_float")),
node("mesh", "mesh"),
node("query", "mesh_query", { visiblePredicate: "required_true", visibleDefault: true, frustumCulledPredicate: "any", frustumCulledDefault: false }, { mesh: input("mesh", "mesh"), isVisible: input("mesh", "isVisible") }),
node("registry", "pipeline_registry", {}, { pipelineIndices: input("mesh", "pipelineIndices") }),
node("ground", "pipeline", { pipeline: "ground_plane", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input(colorTarget, "texture"), depthTarget: input("depth", "texture") }),
node("pbr", "pipeline", { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input("ground", "color"), depthTarget: input("ground", "depth") }),
node("pbr_double", "pipeline", { pipeline: "gltf_standard_double_sided", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input("pbr", "color"), depthTarget: input("pbr", "depth") }),
];
const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes });
const direct = (graphId, clearColor) => graph(graphId, [
node("ldr", "texture", texture("rgba8_unorm")),
...scene("ldr", clearColor).filter((item) => item.id !== "hdr"),
node("frame_out", "frame_out", {}, { color: input("pbr_double", "color") }),
]);
export const midnight = direct("preset_midnight", [0.015, 0.06, 0.18, 1]);
export const ember = direct("preset_ember", [0.18, 0.035, 0.012, 1]);
export const hdr = Object.freeze({
schemaVersion: 2,
graphId: "preset_hdr_fullscreen",
revision: 1,
nodes: [
node("surface", "surface_target"),
node("hdr", "texture_spec", texture("rgba16_float")),
node("depth", "texture_spec", texture("depth32_float")),
node("scene", "scene_table"),
node("visible", "visibility_flags", {}, { scene: input("scene", "scene") }),
node(
"query",
"mesh_query",
{
filters: [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "any" },
],
},
{ scene: input("scene", "scene"), isVisible: input("visible", "flags") },
),
node("depth_config", "depth_stencil_config", {
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
}),
node(
"forward",
"legacy_forward",
{ clearColor: [0.015, 0.02, 0.03, 1] },
{
scene: input("scene", "scene"),
draws: input("query", "draws"),
colorTarget: input("hdr", "spec"),
depthTarget: input("depth", "spec"),
depthStencil: input("depth_config", "config"),
},
),
node(
"copy",
"fullscreen_copy",
{},
{
source: input("forward", "color"),
colorTarget: input("surface", "surface"),
},
),
node("present", "present", {}, { surface: input("copy", "color") }),
],
});
export const culling = Object.freeze((() => {
const graph = structuredClone(hdr);
graph.graphId = "preset_gpu_culling";
graph.nodes.splice(
5,
0,
node("aabbs", "local_aabb_buffer", {}, { scene: input("scene", "scene") }),
node("frustum", "camera_frustum"),
node("cull", "frustum_cull", {}, {
scene: input("scene", "scene"),
localAabbs: input("aabbs", "localAabbs"),
frustum: input("frustum", "frustum"),
}),
);
const query = graph.nodes.find((x) => x.id === "query");
query.parameters.filters[1].predicate = "required_false";
query.inputs.isFrustumCulled = input("cull", "flags");
return graph;
export const hdr = graph("preset_hdr_fullscreen", [
...scene("hdr"),
node("frame_out", "frame_out", {}, { color: input("pbr_double", "color") }),
]);
export const culling = graph("preset_gpu_culling", (() => {
const nodes = structuredClone(hdr.nodes);
nodes.splice(3, 0, node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabbs: input("mesh", "localAabbs") }));
const query = nodes.find((item) => item.id === "query");
query.parameters.frustumCulledPredicate = "required_false";
query.inputs.isFrustumCulled = input("cull", "isFrustumCulled");
return nodes;
})());
const postPreset = (graphId, kind) => {
const nodes = hdr.nodes.slice(0, 8).map((x) => structuredClone(x));
if (kind === "tone")
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input("forward", "color"),
colorTarget: input("surface", "surface"),
},
),
);
const nodes = [node("ldr", "texture", texture("rgba8_unorm")), ...scene("hdr")];
let source = "pbr_double";
if (kind === "edges") {
nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float")));
nodes.push(
node(
"edges",
"luminance_edge",
{ strength: 2 },
{
source: input("forward", "color"),
colorTarget: input("edge_hdr", "spec"),
},
),
);
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input("edges", "color"),
colorTarget: input("surface", "surface"),
},
),
);
nodes.splice(1, 0, node("edge_hdr", "texture", texture("rgba16_float")));
nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") }));
source = "edges";
}
if (kind === "bloom" || kind === "combined") {
const half = {
texture: {
...texture("rgba16_float").texture,
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 2 },
height: { numerator: 1, denominator: 2 },
depthOrArrayLayers: 1,
},
},
residency: "transient",
};
nodes.splice(
1,
0,
node("half_a", "texture_spec", structuredClone(half)),
node("half_b", "texture_spec", structuredClone(half)),
node("half_c", "texture_spec", structuredClone(half)),
node("composite_hdr", "texture_spec", texture("rgba16_float")),
);
nodes.splice(1, 0,
node("half_a", "texture", texture("rgba16_float", 2)), node("half_b", "texture", texture("rgba16_float", 2)),
node("half_c", "texture", texture("rgba16_float", 2)), node("composite_hdr", "texture", texture("rgba16_float")));
nodes.push(
node(
"extract",
"bloom_extract",
{ threshold: 1, knee: 0.5 },
{
source: input("forward", "color"),
colorTarget: input("half_a", "spec"),
},
),
node("extract", "bloom_extract", { threshold: 1, knee: 0.5 }, { source: input("pbr_double", "color"), colorTarget: input("half_a", "texture") }),
node("blur_h", "bloom_blur", { direction: [1, 0], radius: 1 }, { source: input("extract", "color"), colorTarget: input("half_b", "texture") }),
node("blur_v", "bloom_blur", { direction: [0, 1], radius: 1 }, { source: input("blur_h", "color"), colorTarget: input("half_c", "texture") }),
node("composite", "bloom_composite", { intensity: 0.8 }, { source: input("pbr_double", "color"), bloom: input("blur_v", "color"), colorTarget: input("composite_hdr", "texture") }),
);
nodes.push(
node(
"blur_h",
"bloom_blur",
{ direction: [1, 0], radius: 1 },
{
source: input("extract", "color"),
colorTarget: input("half_b", "spec"),
},
),
);
nodes.push(
node(
"blur_v",
"bloom_blur",
{ direction: [0, 1], radius: 1 },
{
source: input("blur_h", "color"),
colorTarget: input("half_c", "spec"),
},
),
);
nodes.push(
node(
"composite",
"bloom_composite",
{ intensity: 0.8 },
{
source: input("forward", "color"),
bloom: input("blur_v", "color"),
colorTarget: input("composite_hdr", "spec"),
},
),
);
let toneSource = "composite";
source = "composite";
if (kind === "combined") {
nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float")));
nodes.push(
node(
"edges",
"luminance_edge",
{ strength: 2 },
{
source: input("composite", "color"),
colorTarget: input("edge_hdr", "spec"),
},
),
);
toneSource = "edges";
nodes.splice(1, 0, node("edge_hdr", "texture", texture("rgba16_float")));
nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") }));
source = "edges";
}
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input(toneSource, "color"),
colorTarget: input("surface", "surface"),
},
),
);
}
const last = nodes.at(-1);
nodes.push(
node("present", "present", {}, { surface: input(last.id, "color") }),
);
return Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes });
nodes.push(node("tone", "tone_map", { exposure: 1 }, { source: input(source, "color"), colorTarget: input("ldr", "texture") }));
nodes.push(node("frame_out", "frame_out", {}, { color: input("tone", "color") }));
return graph(graphId, nodes);
};
export const tone = postPreset("preset_tone", "tone"),
edges = postPreset("preset_edges", "edges"),
bloom = postPreset("preset_bloom", "bloom"),
combined = postPreset("preset_combined", "combined");
export const renderGraphPresets = Object.freeze({
midnight,
ember,
hdr,
culling,
tone,
edges,
bloom,
combined,
});
export const tone = postPreset("preset_tone", "tone");
export const edges = postPreset("preset_edges", "edges");
export const bloom = postPreset("preset_bloom", "bloom");
export const combined = postPreset("preset_combined", "combined");
export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, edges, bloom, combined });
+154 -34
View File
@@ -1,13 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
import { addNodeItems, moveAddNodeSelection, searchAddNodeItems } from "../static/render-graph/add-node-menu.js";
import { createNodeIdAllocator, spawnRequestedNode } from "../static/render-graph/node-spawn.js";
import {
addNodeItems,
moveAddNodeSelection,
searchAddNodeItems,
} from "../static/render-graph/add-node-menu.js";
import {
createNodeIdAllocator,
spawnRequestedNode,
} from "../static/render-graph/node-spawn.js";
test("add-node model contains all 17 catalog types in application groups", () => {
assert.equal(addNodeItems.length, 17);
assert.deepEqual([...new Set(addNodeItems.map((item) => item.group))], ["Source", "Compute", "Render / post", "Present"]);
assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 17);
assert.deepEqual(searchAddNodeItems("tone render").map((item) => item.typeId), ["tone_map"]);
test("add-node model contains all 13 catalog types in application groups", () => {
assert.equal(addNodeItems.length, 13);
assert.deepEqual(
[...new Set(addNodeItems.map((item) => item.group))],
["Source", "Compute", "CPU preparation", "Render / post", "Frame"],
);
assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 13);
assert.deepEqual(
searchAddNodeItems("tone render").map((item) => item.typeId),
["tone_map"],
);
assert.deepEqual(searchAddNodeItems("no such node"), []);
});
@@ -21,13 +34,19 @@ test("allocator avoids existing and session-reserved IDs and is bounded", () =>
const values = ["a-a", "a-a", "b-b"];
const allocate = createNodeIdAllocator(() => values.shift());
assert.equal(allocate(["node_aa"]), "node_bb");
assert.throws(() => createNodeIdAllocator(() => "bad id")([]), /Unable to allocate/);
assert.throws(
() => createNodeIdAllocator(() => "bad id")([]),
/Unable to allocate/,
);
});
test("all 17 types spawn with exact position, current version and generated ID", async () => {
let revision = 5, expectedType;
test("all 13 types spawn with exact position, current version and generated ID", async () => {
let revision = 5,
expectedType;
const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } };
const root = { getState: async () => ({ version: 91, nodes: [{ id: "existing" }] }) };
const root = {
getState: async () => ({ version: 91, nodes: [{ id: "existing" }] }),
};
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async (params, options) => {
@@ -38,41 +57,89 @@ test("all 17 types spawn with exact position, current version and generated ID",
},
};
let id = 0;
const allocate = createNodeIdAllocator(() => `00000000-0000-0000-0000-${String(++id).padStart(12, "0")}`);
const allocate = createNodeIdAllocator(
() => `00000000-0000-0000-0000-${String(++id).padStart(12, "0")}`,
);
for (const item of addNodeItems) {
expectedType = item.typeId;
assert.equal(await spawnRequestedNode(root, view, request, item.typeId, allocate), true);
assert.equal(
await spawnRequestedNode(root, view, request, item.typeId, allocate),
true,
);
}
revision = 6;
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false);
assert.equal(
await spawnRequestedNode(root, view, request, "tone_map", allocate),
false,
);
});
test("spawn rechecks composition after getState and propagates add errors", async () => {
let revision = 2;
const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } };
const root = { getState: async () => { revision++; return { version: 3, nodes: [] }; } };
const root = {
getState: async () => {
revision++;
return { version: 3, nodes: [] };
},
};
const allocate = createNodeIdAllocator(() => "a");
const view = { getHostSnapshot: () => ({ compositionRevision: revision }), addNode: async () => { throw Error("must not add"); } };
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false);
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async () => {
throw Error("must not add");
},
};
assert.equal(
await spawnRequestedNode(root, view, request, "tone_map", allocate),
false,
);
revision = 2;
root.getState = async () => ({ version: 3, nodes: [] });
await assert.rejects(spawnRequestedNode(root, view, request, "tone_map", allocate), /must not add/);
await assert.rejects(
spawnRequestedNode(root, view, request, "tone_map", allocate),
/must not add/,
);
});
test("spawn cancels when a pending getState becomes mutated or dead", async () => {
const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } };
let resolveState, revision = 2, alive = true, adds = 0;
const root = { getState: () => new Promise((resolve) => { resolveState = resolve; }) };
let resolveState,
revision = 2,
alive = true,
adds = 0;
const root = {
getState: () =>
new Promise((resolve) => {
resolveState = resolve;
}),
};
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async () => { adds++; },
addNode: async () => {
adds++;
},
};
const pendingMutation = spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive);
const pendingMutation = spawnRequestedNode(
root,
view,
request,
"tone_map",
() => "node_a",
() => alive,
);
revision++;
resolveState({ version: 1, nodes: [] });
assert.equal(await pendingMutation, false);
revision = 2;
const pendingDestroy = spawnRequestedNode(root, view, request, "tone_map", () => "node_b", () => alive);
const pendingDestroy = spawnRequestedNode(
root,
view,
request,
"tone_map",
() => "node_b",
() => alive,
);
alive = false;
resolveState({ version: 1, nodes: [] });
assert.equal(await pendingDestroy, false);
@@ -80,14 +147,27 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () =
});
test("spawn has a final liveness guard after ID allocation", async () => {
let alive = true, adds = 0;
let alive = true,
adds = 0;
const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } };
const root = { getState: async () => ({ version: 1, nodes: [] }) };
const view = { getHostSnapshot: () => ({ compositionRevision: 1 }), addNode: async () => { adds++; } };
const result = await spawnRequestedNode(root, view, request, "tone_map", () => {
const view = {
getHostSnapshot: () => ({ compositionRevision: 1 }),
addNode: async () => {
adds++;
},
};
const result = await spawnRequestedNode(
root,
view,
request,
"tone_map",
() => {
alive = false;
return "node_reserved";
}, () => alive);
},
() => alive,
);
assert.equal(result, false);
assert.equal(adds, 0);
});
@@ -95,19 +175,59 @@ test("spawn has a final liveness guard after ID allocation", async () => {
test("spawn suppresses teardown RPC rejections but propagates genuine live add errors", async () => {
const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } };
let alive = true;
const view = { getHostSnapshot: () => ({ compositionRevision: 1 }), addNode: async () => {} };
const root = { getState: async () => { alive = false; throw Error("detached state"); } };
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive), false);
const view = {
getHostSnapshot: () => ({ compositionRevision: 1 }),
addNode: async () => {},
};
const root = {
getState: async () => {
alive = false;
throw Error("detached state");
},
};
assert.equal(
await spawnRequestedNode(
root,
view,
request,
"tone_map",
() => "node_a",
() => alive,
),
false,
);
alive = true;
root.getState = async () => ({ version: 1, nodes: [] });
view.addNode = async () => { alive = false; throw Error("detached add"); };
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", () => "node_b", () => alive), false);
view.addNode = async () => {
alive = false;
throw Error("detached add");
};
assert.equal(
await spawnRequestedNode(
root,
view,
request,
"tone_map",
() => "node_b",
() => alive,
),
false,
);
alive = true;
view.addNode = async () => { throw Error("live add failure"); };
view.addNode = async () => {
throw Error("live add failure");
};
await assert.rejects(
spawnRequestedNode(root, view, request, "tone_map", () => "node_c", () => alive),
spawnRequestedNode(
root,
view,
request,
"tone_map",
() => "node_c",
() => alive,
),
/live add failure/,
);
});
+1 -1
View File
@@ -7,6 +7,6 @@ test("procedural cube and sphere have complete indexed vertex streams",()=>{cons
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}});
test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}});
test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"Phase 6 deterministic PBR gallery");});
test("loadout dropdown preserves legacy scenes and exposes the material gallery",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../static/index.html",import.meta.url),"utf8");for(const id of ["cubes","spheres","materials","manor","sponza"])assert.match(html,new RegExp(`<option value="${id}">`));});
test("loadout dropdown preserves every demo scene and exposes the material gallery",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../static/index.html",import.meta.url),"utf8");for(const id of ["cubes","spheres","materials","manor","sponza"])assert.match(html,new RegExp(`<option value="${id}">`));});
test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));});
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
+28 -6
View File
@@ -8,19 +8,41 @@ import { build } from "vite";
import { fxNodeComposition } from "../static/render-graph/catalog.js";
test("production render graph composition passes fxnode's public validator", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "yawn-fxnode-validator-"));
const directory = await mkdtemp(
path.join(tmpdir(), "yawn-fxnode-validator-"),
);
try {
const entry = path.join(directory, "entry.js");
await writeFile(entry, `export { validateFxNodeComposition } from ${JSON.stringify(pathToFileURL(path.resolve("vendor/fxnode/src/index.ts")).href)};`);
await writeFile(
entry,
`export { validateFxNodeComposition } from ${JSON.stringify(pathToFileURL(path.resolve("vendor/fxnode/src/index.ts")).href)};`,
);
await build({
configFile: false,
logLevel: "silent",
build: { lib: { entry, formats: ["es"], fileName: "validator" }, outDir: directory, emptyOutDir: false },
build: {
lib: { entry, formats: ["es"], fileName: "validator" },
outDir: directory,
emptyOutDir: false,
},
});
const { validateFxNodeComposition } = await import(`${pathToFileURL(path.join(directory, "validator.js")).href}?${Date.now()}`);
const { validateFxNodeComposition } = await import(
`${pathToFileURL(path.join(directory, "validator.js")).href}?${Date.now()}`
);
const result = validateFxNodeComposition(fxNodeComposition);
assert.equal(result.ok, true, result.ok ? undefined : JSON.stringify(result.issues, null, 2));
assert.equal(Object.keys(fxNodeComposition.nodes).length, 17);
assert.equal(
result.ok,
true,
result.ok ? undefined : JSON.stringify(result.issues, null, 2),
);
assert.equal(fxNodeComposition.schemaVersion, 2);
assert.equal(fxNodeComposition.version, 4);
assert.equal(Object.keys(fxNodeComposition.nodes).length, 13);
assert.ok(
Object.values(fxNodeComposition.nodes).every(
(definition) => definition.migrations.length === 0,
),
);
} finally {
await rm(directory, { recursive: true, force: true });
}
+333 -42
View File
@@ -41,16 +41,23 @@ function fixture() {
]),
),
sockets: [
...Object.entries(d.inputs).map(([key, x]) => ({
...Object.entries(d.inputs).map(([key, x]) => {
const socket = definition.sockets[key];
return {
key,
id: `${n.id}:${key}`,
direction: "input",
dataType: x.authoringType ?? x.accepted.types[0],
label: key,
accepts: socketTypes[x.authoringType ?? x.accepted.types[0]].acceptsFrom,
visible: true,
maxIncomingLinks: 1,
})),
label: socket.title,
accepts:
socketTypes[x.authoringType ?? x.accepted.types[0]].acceptsFrom,
...(socket.value
? { defaultValue: structuredClone(socket.value.default) }
: {}),
visible: socket.visible,
maxIncomingLinks: socket.maxIncomingLinks,
};
}),
...Object.entries(d.outputs).map(([key]) => ({
key,
id: `${n.id}:${key}`,
@@ -87,26 +94,22 @@ function fixture() {
}
test("catalog exhaustively mirrors all current contracts", () => {
assert.deepEqual(
Object.keys(semanticCatalog).sort(),
Object.keys(semanticCatalog),
[
"surface_target",
"texture_spec",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"visibility_flags",
"mesh",
"texture",
"frustum_cull",
"mesh_query",
"depth_stencil_config",
"legacy_forward",
"pipeline_registry",
"pipeline",
"fullscreen_copy",
"tone_map",
"bloom_extract",
"bloom_blur",
"bloom_composite",
"luminance_edge",
"present",
].sort(),
"frame_out",
],
);
for (const c of Object.values(semanticCatalog)) {
assert.ok(c.execution);
@@ -114,6 +117,161 @@ test("catalog exhaustively mirrors all current contracts", () => {
assert.ok(c.outputs);
assert.ok(c.parameters);
}
for (const [key, contract] of Object.entries(semanticCatalog))
assert.deepEqual(
Object.keys(nodeDefinitions[key].parameters).sort(),
Object.keys(contract.parameters).sort(),
key,
);
assert.equal(CATALOG_VERSION, 4);
assert.deepEqual(nodeDefinitions.pipeline.parameters, {
pipeline: {
type: "string",
default: { kind: "string", value: "gltf_standard" },
},
depthCompare: {
type: "string",
default: { kind: "string", value: "less_equal" },
enum: ["never", "less", "equal", "less_equal", "greater", "not_equal", "greater_equal", "always"],
},
depthWriteEnabled: {
type: "boolean",
default: { kind: "boolean", value: true },
},
clearDepth: {
type: "number",
default: { kind: "number", value: 1 },
minimum: 0,
maximum: 1,
},
clearColor: {
type: "color",
default: { kind: "color", value: [0.015, 0.02, 0.03, 1] },
minimum: 0,
maximum: 1,
},
});
assert.deepEqual(nodeDefinitions.bloom_blur.parameters.direction.enum, [
"horizontal",
"vertical",
]);
assert.deepEqual(nodeDefinitions.texture.parameters.residency.enum, [
"transient",
"persistent",
]);
assert.deepEqual(
nodeDefinitions.frustum_cull.parameters.cameraSelection.enum,
["active"],
);
assert.deepEqual(nodeDefinitions.mesh_query.sockets.isVisible.value.default, {
kind: "boolean",
value: true,
});
assert.equal(nodeDefinitions.mesh_query.sockets.isVisible.showValue, true);
});
test("adapter validates and exactly lowers canonical pipeline controls and blur direction", () => {
const x = fixture();
const pipeline = x.nodes.find((node) => node.id === "ground");
assert.equal(pipeline.parameters.clearColor.kind, "color");
assert.deepEqual(
adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "ground").parameters,
{
pipeline: "ground_plane",
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
clearColor: [0.015, 0.02, 0.03, 1],
},
);
const schema = nodeDefinitions.bloom_blur.parameters;
const blur = structuredClone(x.nodes.find((node) => node.id === "frame_out"));
blur.id = "blur";
blur.typeId = "bloom_blur";
blur.parameters = {
direction: { kind: "string", value: "vertical" },
radius: structuredClone(schema.radius.default),
};
blur.sockets = Object.entries(nodeDefinitions.bloom_blur.sockets).map(
([key, socket]) => ({
key,
id: `blur:${key}`,
label: socket.title,
direction: socket.direction,
dataType: socket.type,
accepts:
socket.direction === "input"
? socketTypes[socket.type].acceptsFrom
: [],
maxIncomingLinks: socket.maxIncomingLinks,
visible: socket.visible,
}),
);
x.nodes.push(blur);
assert.deepEqual(
adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "blur").parameters
.direction,
[0, 1],
);
pipeline.parameters.clearColor.value[0] = 2;
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1];
pipeline.parameters.clearColor.value = [0, 0, 0];
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1];
pipeline.parameters.clearColor.value = [0, 0, Number.NaN, 1];
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1];
const texture = x.nodes.find((node) => node.id === "hdr");
texture.parameters.residency.value = "unknown";
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
texture.parameters.residency.value = "transient";
pipeline.parameters.clearDepth.value = -1;
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
});
test("adapter validates and lowers disconnected query socket defaults", () => {
const x = fixture();
const query = x.nodes.find((node) => node.id === "query");
const visible = query.sockets.find((socket) => socket.key === "isVisible");
visible.defaultValue.value = false;
const ir = adaptFxNodeSnapshot(x);
assert.equal(visible.defaultValue.value, false);
const parameters = ir.nodes.find((node) => node.id === "query").parameters;
assert.equal(parameters.isVisible, undefined);
assert.equal(parameters.visibleDefault, false);
assert.equal(parameters.frustumCulledDefault, false);
visible.defaultValue = { kind: "number", value: 0 };
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_SOCKET",
);
delete visible.defaultValue;
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_SOCKET",
);
});
test("adapter lowers the authoring-safe camera selector to the Rust wire field", () => {
const ir = adaptFxNodeSnapshot(fixture());
const parameters = ir.nodes.find((node) => node.id === "cull").parameters;
assert.deepEqual(parameters, { camera: "active" });
assert.equal(parameters.cameraSelection, undefined);
});
test("adapter deterministically emits the canonical schema, permits repeated types, omits muted links and maps sources", () => {
const x = fixture(),
@@ -123,16 +281,14 @@ test("adapter deterministically emits the canonical schema, permits repeated typ
assert.deepEqual(adaptFxNodeSnapshot(x, 7), a);
assert.equal(a.schemaVersion, 2);
assert.equal(a.graphId, GRAPH_ID);
assert.equal(
a.nodes.filter((n) => n.executor.key === "texture_spec").length,
2,
);
assert.equal(a.nodes.filter((n) => n.executor.key === "texture").length, 2);
assert.ok(
Object.values(getSourceMap(a)).some((source) => source.input === "source"),
Object.values(getSourceMap(a)).some((source) => source.input === "color"),
);
x.links.find((l) => l.id === "l_forward_color_copy_source").muted = true;
x.links.find((l) => l.id === "l_pbr_double_color_frame_out_color").muted = true;
assert.equal(
adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "copy").inputs.source,
adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "frame_out").inputs
.color,
undefined,
);
});
@@ -146,50 +302,165 @@ test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type
);
};
reject((x) => (x.graphId = "bad"), "AUTHORING_CATALOG");
reject((x) => (x.catalogVersion = 2), "AUTHORING_CATALOG");
reject((x) => (x.nodes[0].id = "bad id"), "AUTHORING_ID");
reject((x) => (x.nodes[1].id = x.nodes[0].id), "AUTHORING_ID_DUPLICATE");
reject((x) => (x.nodes[0].typeId = "wat"), "AUTHORING_NODE_TYPE");
reject((x) => (x.nodes[0].sockets = []), "AUTHORING_SOCKET_SET");
reject((x) => (x.links[0].toSocketId = "missing:x"), "AUTHORING_LINK");
reject((x) => {
const link = x.links.find((l) => l.toSocketId === "copy:source");
link.fromNodeId = "scene";
link.fromSocketId = "scene:scene";
const link = x.links.find((l) => l.toSocketId === "frame_out:color");
link.fromNodeId = "mesh";
link.fromSocketId = "mesh:mesh";
}, "AUTHORING_LINK_TYPE");
});
test("adapter counts only active incoming links and reports socket overflow", () => {
const x = fixture();
const active = x.links.find((link) => link.toSocketId === "copy:source");
x.links.push({ ...structuredClone(active), id: "muted_duplicate", muted: true });
const active = x.links.find((link) => link.toSocketId === "frame_out:color");
x.links.push({
...structuredClone(active),
id: "muted_duplicate",
muted: true,
});
assert.doesNotThrow(() => adaptFxNodeSnapshot(x));
x.links.push({ ...structuredClone(active), id: "active_overflow" });
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_LINK_INCOMING" && error.details.socketId === "copy:source",
(error) =>
error.code === "AUTHORING_LINK_INCOMING" &&
error.details.socketId === "frame_out:color",
);
});
test("source map covers Rust fields, nested values, every input and is deeply frozen", () => {
const snapshot = fixture();
snapshot.links.find((link) => link.toSocketId === "copy:source").muted = true;
const ir = adaptFxNodeSnapshot(snapshot, 9), map = getSourceMap(ir);
for (const path of ["schemaVersion", "graphId", "revision", "nodes", "nodes[0].id", "nodes[0].state", "nodes[0].executor.key", "nodes[0].executor.version", "nodes[0].parameters", "nodes[0].inputs"])
snapshot.links.find((link) => link.toSocketId === "frame_out:color").muted =
true;
const ir = adaptFxNodeSnapshot(snapshot, 9),
map = getSourceMap(ir);
for (const path of [
"schemaVersion",
"graphId",
"revision",
"nodes",
"nodes[0].id",
"nodes[0].state",
"nodes[0].executor.key",
"nodes[0].executor.version",
"nodes[0].parameters",
"nodes[0].inputs",
])
assert.ok(map[path], path);
for (const [index, node] of ir.nodes.entries())
for (const input of Object.keys(semanticCatalog[node.executor.key].inputs))
assert.ok(map[`nodes[${index}].inputs.${input}`]);
assert.ok(Object.keys(map).some((path) => /parameters\..+\[|parameters\..+\..+/.test(path)));
assert.ok(
Object.keys(map).some((path) =>
/parameters\..+\[|parameters\..+\..+/.test(path),
),
);
const socket = Object.values(map).find((source) => source.kind === "socket");
const unconnected = Object.values(map).find((source) => source.unconnected === true);
const unconnected = Object.values(map).find(
(source) => source.unconnected === true,
);
const link = Object.values(map).find((source) => source.kind === "link");
assert.ok(socket?.socketId && unconnected?.socketId);
assert.equal(ir.nodes.find((node) => node.id === "copy").inputs.source, undefined);
for (const field of ["linkId", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted"])
assert.equal(
ir.nodes.find((node) => node.id === "frame_out").inputs.source,
undefined,
);
for (const field of [
"linkId",
"fromNodeId",
"fromSocketId",
"toNodeId",
"toSocketId",
"muted",
])
assert.ok(Object.hasOwn(link, field), field);
assert.ok(Object.isFrozen(map) && Object.isFrozen(link));
});
test("texture source maps identify every flat authored control", () => {
const snapshot = fixture();
const hdr = snapshot.nodes.find((node) => node.id === "hdr");
hdr.parameters.viewFormat.value = "rgba16_float";
const ir = adaptFxNodeSnapshot(snapshot);
const index = ir.nodes.findIndex((node) => node.id === "hdr");
const root = `nodes[${index}].parameters`;
const map = getSourceMap(ir);
const source = (parameter) => ({
kind: "parameter",
nodeId: "hdr",
parameter,
});
assert.deepEqual(map[`${root}.residency`], source("residency"));
assert.deepEqual(map[`${root}.texture`], { kind: "node", nodeId: "hdr" });
for (const [path, parameter] of [
["dimension", "dimension"],
["format", "format"],
["extent", "extentMode"],
["extent.kind", "extentMode"],
["extent.depthOrArrayLayers", "depthOrArrayLayers"],
["extent.width", "extentMode"],
["extent.width.numerator", "relativeWidthNumerator"],
["extent.width.denominator", "relativeWidthDenominator"],
["extent.height", "extentMode"],
["extent.height.numerator", "relativeHeightNumerator"],
["extent.height.denominator", "relativeHeightDenominator"],
["mipLevelCount", "mipLevelCount"],
["sampleCount", "sampleCount"],
["viewFormats", "viewFormat"],
["viewFormats[0]", "viewFormat"],
])
assert.deepEqual(map[`${root}.texture.${path}`], source(parameter), path);
assert.ok(!Object.values(map).some((value) => value.parameter === "texture"));
const absolute = fixture();
absolute.nodes.find((node) => node.id === "hdr").parameters.extentMode.value =
"absolute";
const absoluteIr = adaptFxNodeSnapshot(absolute);
const absoluteIndex = absoluteIr.nodes.findIndex((node) => node.id === "hdr");
const absoluteMap = getSourceMap(absoluteIr);
assert.deepEqual(
absoluteMap[`nodes[${absoluteIndex}].parameters.texture.extent.width`],
source("absoluteWidth"),
);
assert.deepEqual(
absoluteMap[`nodes[${absoluteIndex}].parameters.texture.extent.height`],
source("absoluteHeight"),
);
});
test("unsupported texture diagnostics map to their exact authored controls", () => {
const ir = adaptFxNodeSnapshot(fixture());
const index = ir.nodes.findIndex((node) => node.id === "hdr");
for (const [suffix, parameter] of [
["dimension", "dimension"],
["mipLevelCount", "mipLevelCount"],
["sampleCount", "sampleCount"],
["extent.depthOrArrayLayers", "depthOrArrayLayers"],
]) {
const path = `nodes[${index}].parameters.texture.${suffix}`;
const mapped = mapAuthoringDiagnostic(
ir,
new RendererError("GRAPH_UNSUPPORTED_FEATURE", {
message: "unsupported",
path,
}),
);
assert.equal(mapped.path, path);
assert.deepEqual(mapped.source, {
kind: "parameter",
nodeId: "hdr",
parameter,
});
}
});
test("diagnostic mapper creates a frozen RendererError DTO with fallbacks and prefix matching", () => {
const ir = adaptFxNodeSnapshot(fixture());
const original = new RendererError("GRAPH_INPUT", { message: "bad", field: "nodes[0].executor.key.more", nested: { x: 1 } });
const original = new RendererError("GRAPH_INPUT", {
message: "bad",
field: "nodes[0].executor.key.more",
nested: { x: 1 },
});
const mapped = mapAuthoringDiagnostic(ir, original);
assert.notStrictEqual(mapped, original);
assert.equal(mapped.code, original.code);
@@ -262,24 +533,44 @@ test("controller shares in-flight apply", async () => {
await a;
});
test("controller retains mapped diagnostic while apply rejects the original and subscriptions agree", async () => {
const original = new RendererError("GRAPH_BAD", { path: "nodes[0].id", message: "bad" });
const original = new RendererError("GRAPH_BAD", {
path: "nodes[0].id",
message: "bad",
});
const states = [];
const c = new AuthoringController({
adapt: (snapshot, revision) => adaptFxNodeSnapshot(snapshot, revision),
renderer: { compileGraph: async () => { throw original; }, switchCompiledGraph: async () => {}, dropCompiledGraph: async () => {} },
renderer: {
compileGraph: async () => {
throw original;
},
switchCompiledGraph: async () => {},
dropCompiledGraph: async () => {},
},
});
c.subscribe((state) => states.push(state));
c.markDirty(fixture());
await assert.rejects(c.apply(), (error) => error === original);
assert.notStrictEqual(states.at(-1).error, original);
let subscribed;
c.subscribe((state) => { subscribed = state; })();
c.subscribe((state) => {
subscribed = state;
})();
assert.strictEqual(subscribed.error, states.at(-1).error);
await c.destroy();
});
test("apply after destroy does not compile and destroy returns one strict promise", async () => {
let compiles = 0;
const c = new AuthoringController({ adapt: () => ({}), renderer: { compileGraph: async () => { compiles++; }, dropCompiledGraph: async () => {}, switchCompiledGraph: async () => {} } });
const c = new AuthoringController({
adapt: () => ({}),
renderer: {
compileGraph: async () => {
compiles++;
},
dropCompiledGraph: async () => {},
switchCompiledGraph: async () => {},
},
});
c.markDirty({});
const first = c.destroy();
assert.strictEqual(c.destroy(), first);
+275 -71
View File
@@ -1,14 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
culling,
ember,
hdr,
midnight,
renderGraphPresets,
} from "../static/render-graph/presets.js";
test("presets use canonical node graphs", () => {
assert.deepEqual(Object.keys(renderGraphPresets), [
import * as presets from "../static/render-graph/presets.js";
const order = [
"midnight",
"ember",
"hdr",
@@ -17,75 +11,285 @@ test("presets use canonical node graphs", () => {
"edges",
"bloom",
"combined",
]);
assert.deepEqual(
[midnight.graphId, ember.graphId],
["preset_midnight", "preset_ember"],
);
assert.notDeepEqual(midnight.nodes[6].parameters.clearColor, ember.nodes[6].parameters.clearColor);
for (const graph of [midnight, ember]) {
assert.equal(graph.schemaVersion, 2);
assert.equal(graph.revision, 1);
assert.equal(graph.nodes[6].executor.key, "legacy_forward");
assert.deepEqual(graph.nodes[6].inputs.colorTarget, { node: "surface", socket: "surface" });
assert.equal(graph.nodes.at(-1).executor.key, "present");
}
assert.equal(hdr.schemaVersion, 2);
assert.equal(hdr.revision, 1);
assert.equal(hdr.graphId, "preset_hdr_fullscreen");
assert.equal(
new Set(Object.values(renderGraphPresets).map((graph) => graph.graphId))
.size,
8,
);
assert.deepEqual(
hdr.nodes.map((node) => [node.id, node.executor.key]),
[
["surface", "surface_target"],
["hdr", "texture_spec"],
["depth", "texture_spec"],
["scene", "scene_table"],
["visible", "visibility_flags"],
];
const sequences = {
midnight: [
["ldr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["depth_config", "depth_stencil_config"],
["forward", "legacy_forward"],
["copy", "fullscreen_copy"],
["present", "present"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["frame_out", "frame_out"],
],
ember: [
["ldr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["frame_out", "frame_out"],
],
hdr: [
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["frame_out", "frame_out"],
],
culling: [
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["cull", "frustum_cull"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["frame_out", "frame_out"],
],
tone: [
["ldr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
edges: [
["ldr", "texture"],
["edge_hdr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["edges", "luminance_edge"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
bloom: [
["ldr", "texture"],
["half_a", "texture"],
["half_b", "texture"],
["half_c", "texture"],
["composite_hdr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["extract", "bloom_extract"],
["blur_h", "bloom_blur"],
["blur_v", "bloom_blur"],
["composite", "bloom_composite"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
combined: [
["ldr", "texture"],
["edge_hdr", "texture"],
["half_a", "texture"],
["half_b", "texture"],
["half_c", "texture"],
["composite_hdr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["extract", "bloom_extract"],
["blur_h", "bloom_blur"],
["blur_v", "bloom_blur"],
["composite", "bloom_composite"],
["edges", "luminance_edge"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
};
test("presets have the exact canonical pipeline identities, schemas, and node sequences", () => {
assert.deepEqual(Object.keys(presets.renderGraphPresets), order);
assert.deepEqual(
order.map((name) => presets[name].graphId),
[
"preset_midnight",
"preset_ember",
"preset_hdr_fullscreen",
"preset_gpu_culling",
"preset_tone",
"preset_edges",
"preset_bloom",
"preset_combined",
],
);
const byId = Object.fromEntries(hdr.nodes.map((node) => [node.id, node]));
assert.equal(byId.hdr.parameters.texture.format, "rgba16_float");
assert.deepEqual(byId.query.inputs, {
scene: { node: "scene", socket: "scene" },
isVisible: { node: "visible", socket: "flags" },
for (const name of order) {
const graph = presets[name];
assert.deepEqual([graph.schemaVersion, graph.revision], [2, 1]);
assert.equal(
new Set(graph.nodes.map((node) => node.id)).size,
graph.nodes.length,
);
assert.deepEqual(
graph.nodes.map((node) => [node.id, node.executor.key]),
sequences[name],
);
assert.equal(
graph.nodes.filter((node) => node.executor.key === "frame_out").length,
1,
);
assert.ok(
graph.nodes.every(
(node) => !["surface_target", "present"].includes(node.executor.key),
),
);
assert.ok(!graph.nodes.some((node) => node.id === "copy"));
}
});
test("presets preserve common mesh, texture, query, pipeline, culling and post wiring", () => {
const removed = [
"texture_spec",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"visibility_flags",
];
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.query.parameters, {
visiblePredicate: "required_true",
visibleDefault: true,
frustumCulledPredicate: name === "culling" ? "required_false" : "any",
frustumCulledDefault: false,
});
assert.deepEqual(byId.query.parameters.filters, [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "any" },
]);
assert.deepEqual(byId.forward.inputs.colorTarget, {
node: "hdr",
socket: "spec",
assert.deepEqual(byId.query.inputs.mesh, { node: "mesh", socket: "mesh" });
assert.deepEqual(byId.query.inputs.isVisible, {
node: "mesh",
socket: "isVisible",
});
assert.deepEqual(byId.copy.executor, { key: "fullscreen_copy", version: 1 });
assert.deepEqual(byId.copy.inputs, {
source: { node: "forward", socket: "color" },
colorTarget: { node: "surface", socket: "surface" },
assert.deepEqual(byId.ground.inputs.mesh, {
node: "mesh",
socket: "mesh",
});
assert.deepEqual(byId.present.inputs.surface, {
node: "copy",
assert.deepEqual(byId.ground.inputs.draws, {
node: "query",
socket: "draws",
});
assert.deepEqual(byId.ground.inputs.depthTarget, {
node: "depth",
socket: "texture",
});
assert.equal(
graph.nodes.filter((node) => node.executor.key === "pipeline_registry")
.length,
1,
);
const pipelines = graph.nodes.filter(
(node) => node.executor.key === "pipeline",
);
assert.deepEqual(
pipelines.map((node) => node.parameters.pipeline),
["ground_plane", "gltf_standard", "gltf_standard_double_sided"],
);
for (const pipeline of pipelines)
assert.deepEqual(pipeline.inputs.activation, {
node: "registry",
socket: "activation",
});
assert.deepEqual(byId.pbr.inputs.colorTarget, {
node: "ground",
socket: "color",
});
assert.deepEqual(
culling.nodes
.filter((node) => ["frustum_cull", "mesh_query"].includes(node.executor.key))
.map((node) => node.executor.key),
["frustum_cull", "mesh_query"],
);
const cullingQuery = culling.nodes.find((node) => node.id === "query");
assert.equal(cullingQuery.parameters.filters[1].predicate, "required_false");
assert.deepEqual(cullingQuery.inputs.isFrustumCulled, {
node: "cull",
socket: "flags",
assert.deepEqual(byId.pbr.inputs.depthTarget, {
node: "ground",
socket: "depth",
});
assert.deepEqual(byId.pbr_double.inputs.colorTarget, {
node: "pbr",
socket: "color",
});
assert.deepEqual(byId.pbr_double.inputs.depthTarget, {
node: "pbr",
socket: "depth",
});
assert.ok(
graph.nodes
.filter((node) => node.executor.key === "texture")
.every((node) => node.parameters.texture.dimension === "d2"),
);
assert.ok(
graph.nodes.every((node) => !removed.includes(node.executor.key)),
);
}
const cull = Object.fromEntries(
presets.culling.nodes.map((node) => [node.id, node]),
);
assert.deepEqual(cull.cull.parameters, { camera: "active" });
assert.deepEqual(cull.cull.inputs, {
mesh: { node: "mesh", socket: "mesh" },
localAabbs: { node: "mesh", socket: "localAabbs" },
});
assert.deepEqual(cull.query.inputs.isFrustumCulled, {
node: "cull",
socket: "isFrustumCulled",
});
for (const name of ["tone", "edges", "bloom", "combined"])
assert.ok(!presets[name].nodes.some((node) => node.id === "copy"));
const finalSource = {
hdr: "pbr_double",
culling: "pbr_double",
tone: "tone",
edges: "tone",
bloom: "tone",
combined: "tone",
midnight: "pbr_double",
ember: "pbr_double",
};
for (const name of order)
assert.deepEqual(presets[name].nodes.at(-1).inputs.color, {
node: finalSource[name],
socket: "color",
});
assert.equal(
presets.tone.nodes.find((node) => node.id === "tone").inputs.source.node,
"pbr_double",
);
assert.equal(
presets.edges.nodes.find((node) => node.id === "tone").inputs.source.node,
"edges",
);
assert.equal(
presets.bloom.nodes.find((node) => node.id === "tone").inputs.source.node,
"composite",
);
assert.equal(
presets.combined.nodes.find((node) => node.id === "tone").inputs.source
.node,
"edges",
);
});