feat: move display transform into frame out

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 16:14:21 +00:00
co-authored by heaust
parent 972fadc635
commit fd85b3d051
15 changed files with 978 additions and 216 deletions
+62 -27
View File
@@ -38,8 +38,15 @@ struct PipelineParameters {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct ToneMapParameters { #[serde(rename_all = "camelCase")]
exposure: f32, struct FrameOutParameters {
hdr_enabled: bool,
tone_mapper: ToneMapper,
exposure_stops: f32,
output_transfer: OutputTransfer,
scale_mode: ScaleMode,
filter: FrameFilter,
background_color: [f32; 4],
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")] #[serde(deny_unknown_fields, rename_all = "camelCase")]
@@ -424,13 +431,6 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
NormalizedParameters::FrustumCull { camera: p.camera } NormalizedParameters::FrustumCull { camera: p.camera }
} }
"fullscreen_copy" => empty!(NormalizedParameters::FullscreenCopy), "fullscreen_copy" => empty!(NormalizedParameters::FullscreenCopy),
"tone_map" => {
let p: ToneMapParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
NormalizedParameters::ToneMap {
exposure: range(p.exposure, 0.0, 32.0, format!("{base}.exposure"))?,
}
}
"color_balance" => { "color_balance" => {
let p: ColorBalanceParameters = let p: ColorBalanceParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?; serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
@@ -528,7 +528,36 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
strength: range(p.strength, 0.0, 16.0, format!("{base}.strength"))?, strength: range(p.strength, 0.0, 16.0, format!("{base}.strength"))?,
} }
} }
"frame_out" => empty!(NormalizedParameters::FrameOut), "frame_out" => {
let p: FrameOutParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
let exposure_stops = range(
p.exposure_stops,
-10.0,
10.0,
format!("{base}.exposureStops"),
)?;
let background_color = components(
p.background_color,
0.0,
1.0,
&format!("{base}.backgroundColor"),
)?;
NormalizedParameters::FrameOut {
dynamic_range: if p.hdr_enabled {
FrameDynamicRange::Hdr {
tone_mapper: p.tone_mapper,
exposure_stops,
}
} else {
FrameDynamicRange::Sdr
},
output_transfer: p.output_transfer,
scale_mode: p.scale_mode,
filter: p.filter,
background_color,
}
}
"texture" => { "texture" => {
let p: TextureParameters = let p: TextureParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?; serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
@@ -1378,12 +1407,6 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
&& descriptor.extent == source_descriptor.extent && descriptor.extent == source_descriptor.extent
}) })
} }
Some(FullscreenPolicy::ToneMap) => target_descriptor.is_some_and(|descriptor| {
descriptor.format != TextureFormat::Depth32Float
&& descriptor.format != TextureFormat::R32Float
&& is_single_view_d2(descriptor)
&& descriptor.extent == source_descriptor.extent
}),
Some(FullscreenPolicy::BloomExtract) => authored_target_ok, Some(FullscreenPolicy::BloomExtract) => authored_target_ok,
Some(FullscreenPolicy::HdrSameExtent) => authored_target_ok && target_matches_source, Some(FullscreenPolicy::HdrSameExtent) => authored_target_ok && target_matches_source,
Some(FullscreenPolicy::BloomComposite) => { Some(FullscreenPolicy::BloomComposite) => {
@@ -1418,10 +1441,19 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}; };
let TextureFamilySource::AuthoredTexture { descriptor, .. } = let TextureFamilySource::AuthoredTexture { descriptor, .. } =
&families[family as usize].source; &families[family as usize].source;
if !is_filterable_frame_color(descriptor) { let NormalizedParameters::FrameOut { dynamic_range, .. } = &params[i] else {
unreachable!()
};
if !frame_out_source_compatible(descriptor, dynamic_range) {
let message = match dynamic_range {
FrameDynamicRange::Hdr { .. } => "HDR frame output requires rgba16_float",
FrameDynamicRange::Sdr => {
"SDR frame output requires a linear filterable color texture"
}
};
return Err(error( return Err(error(
"GRAPH_ILLEGAL_ACCESS", "GRAPH_ILLEGAL_ACCESS",
"frame output requires a filterable single-view d2 color texture", message,
format!("nodes[{i}].inputs.color"), format!("nodes[{i}].inputs.color"),
)); ));
} }
@@ -1903,17 +1935,20 @@ pub(super) fn is_single_view_d2(descriptor: &NormalizedTextureDescriptor) -> boo
&& descriptor.sample_count == 1 && descriptor.sample_count == 1
&& descriptor.mip_level_count == 1 && descriptor.mip_level_count == 1
&& extent_layers(&descriptor.extent) == 1 && extent_layers(&descriptor.extent) == 1
&& descriptor.view_formats.is_empty()
} }
pub(super) fn is_filterable_frame_color(descriptor: &NormalizedTextureDescriptor) -> bool { pub(super) fn frame_out_source_compatible(
descriptor: &NormalizedTextureDescriptor,
dynamic_range: &FrameDynamicRange,
) -> bool {
is_single_view_d2(descriptor) is_single_view_d2(descriptor)
&& matches!( && match dynamic_range {
descriptor.format, FrameDynamicRange::Hdr { .. } => descriptor.format == TextureFormat::Rgba16Float,
TextureFormat::Rgba8Unorm FrameDynamicRange::Sdr => matches!(
| TextureFormat::Rgba8UnormSrgb descriptor.format,
| TextureFormat::Bgra8Unorm TextureFormat::Rgba8Unorm | TextureFormat::Bgra8Unorm | TextureFormat::Rgba16Float
| TextureFormat::Bgra8UnormSrgb ),
| TextureFormat::Rgba16Float }
)
} }
pub(super) fn texture_usage( pub(super) fn texture_usage(
f: &TextureFamily, f: &TextureFamily,
+1 -11
View File
@@ -25,7 +25,6 @@ pub enum ExecutionClass {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FullscreenPolicy { pub enum FullscreenPolicy {
Copy, Copy,
ToneMap,
HdrSameExtent, HdrSameExtent,
BloomExtract, BloomExtract,
BloomComposite, BloomComposite,
@@ -335,15 +334,6 @@ pub static CONTRACTS: &[Contract] = &[
inherently_observable: false, inherently_observable: false,
fullscreen_policy: Some(FullscreenPolicy::Copy), fullscreen_policy: Some(FullscreenPolicy::Copy),
}, },
Contract {
key: "tone_map",
version: 1,
execution: ExecutionClass::Render,
inputs: FULLSCREEN_COPY_IN,
outputs: FULLSCREEN_COPY_OUT,
inherently_observable: false,
fullscreen_policy: Some(FullscreenPolicy::ToneMap),
},
Contract { Contract {
key: "color_balance", key: "color_balance",
version: 1, version: 1,
@@ -418,7 +408,7 @@ pub static CONTRACTS: &[Contract] = &[
}, },
Contract { Contract {
key: "frame_out", key: "frame_out",
version: 1, version: 2,
execution: ExecutionClass::Frame, execution: ExecutionClass::Frame,
inputs: FRAME_OUT_IN, inputs: FRAME_OUT_IN,
outputs: NONE_OUT, outputs: NONE_OUT,
+44 -4
View File
@@ -209,9 +209,6 @@ pub enum NormalizedParameters {
clear_color: [f64; 4], clear_color: [f64; 4],
}, },
FullscreenCopy, FullscreenCopy,
ToneMap {
exposure: f32,
},
ColorBalance { ColorBalance {
mode: ColorBalanceMode, mode: ColorBalanceMode,
factor: f32, factor: f32,
@@ -258,7 +255,50 @@ pub enum NormalizedParameters {
LuminanceEdge { LuminanceEdge {
strength: f32, strength: f32,
}, },
FrameOut, FrameOut {
dynamic_range: FrameDynamicRange,
output_transfer: OutputTransfer,
scale_mode: ScaleMode,
filter: FrameFilter,
background_color: [f32; 4],
},
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FrameDynamicRange {
Sdr,
Hdr {
tone_mapper: ToneMapper,
exposure_stops: f32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToneMapper {
Aces,
Reinhard,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputTransfer {
Srgb,
Linear,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScaleMode {
Stretch,
Contain,
Cover,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameFilter {
Linear,
Nearest,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
+31 -13
View File
@@ -330,9 +330,6 @@ fn validate_fullscreen_execution(
let vector = |v: &[f32], min, max| v.iter().all(|x| scalar(x, min, max)); let vector = |v: &[f32], min, max| v.iter().all(|x| scalar(x, min, max));
let valid_parameters = match (key, &execution.parameters) { let valid_parameters = match (key, &execution.parameters) {
("fullscreen_copy", NormalizedParameters::FullscreenCopy) => true, ("fullscreen_copy", NormalizedParameters::FullscreenCopy) => true,
("tone_map", NormalizedParameters::ToneMap { exposure }) => {
exposure.is_finite() && (0.0..=32.0).contains(exposure)
}
( (
"color_balance", "color_balance",
NormalizedParameters::ColorBalance { NormalizedParameters::ColorBalance {
@@ -566,14 +563,6 @@ fn validate_fullscreen_execution(
&& target_d.format != TextureFormat::Depth32Float && target_d.format != TextureFormat::Depth32Float
&& target_d.extent == source.extent && target_d.extent == source.extent
} }
FullscreenPolicy::ToneMap => {
single_view_d2(target_d)
&& !matches!(
target_d.format,
TextureFormat::Depth32Float | TextureFormat::R32Float
)
&& target_d.extent == source.extent
}
FullscreenPolicy::BloomExtract => hdr(target_d), FullscreenPolicy::BloomExtract => hdr(target_d),
FullscreenPolicy::HdrSameExtent => hdr(target_d) && target_d.extent == source.extent, FullscreenPolicy::HdrSameExtent => hdr(target_d) && target_d.extent == source.extent,
FullscreenPolicy::BloomComposite => { FullscreenPolicy::BloomComposite => {
@@ -1477,11 +1466,40 @@ pub fn prepare_runtime_plan(
} }
let frame_out = &graph.executions[frame_out_index]; let frame_out = &graph.executions[frame_out_index];
if !matches!(frame_out.parameters, NormalizedParameters::FrameOut) { let NormalizedParameters::FrameOut {
dynamic_range,
output_transfer,
scale_mode: _,
filter: _,
background_color,
} = &frame_out.parameters
else {
return Err(invalid( return Err(invalid(
"frame_out parameters mismatch", "frame_out parameters mismatch",
format!("executions[{frame_out_index}].parameters"), format!("executions[{frame_out_index}].parameters"),
)); ));
};
let parameters_valid = background_color
.iter()
.all(|v| v.is_finite() && (0.0..=1.0).contains(v))
&& match dynamic_range {
FrameDynamicRange::Sdr => true,
FrameDynamicRange::Hdr { exposure_stops, .. } => {
exposure_stops.is_finite() && (-10.0..=10.0).contains(exposure_stops)
}
};
if !parameters_valid {
return Err(invalid(
"frame_out parameters are out of range",
format!("executions[{frame_out_index}].parameters"),
));
}
if *output_transfer == OutputTransfer::Linear && surface.format.is_srgb() {
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"linear output transfer cannot target an sRGB surface",
format!("executions[{frame_out_index}].parameters.outputTransfer"),
));
} }
let ExecutionKind::FrameOut { color } = frame_out.kind else { let ExecutionKind::FrameOut { color } = frame_out.kind else {
return Err(invalid( return Err(invalid(
@@ -1546,7 +1564,7 @@ pub fn prepare_runtime_plan(
) )
})?; })?;
let TextureFamilySource::AuthoredTexture { descriptor, .. } = &family.source; let TextureFamilySource::AuthoredTexture { descriptor, .. } = &family.source;
if !super::compiler::is_filterable_frame_color(descriptor) { if !super::compiler::frame_out_source_compatible(descriptor, dynamic_range) {
return Err(invalid( return Err(invalid(
"frame_out texture descriptor is incompatible", "frame_out texture descriptor is incompatible",
format!("textureFamilies[{}].source.descriptor", family.id), format!("textureFamilies[{}].source.descriptor", family.id),
+281 -56
View File
@@ -6,8 +6,11 @@ use serde_json::{json, Value};
fn input(node: &str, socket: &str) -> Value { fn input(node: &str, socket: &str) -> Value {
json!({"node":node,"socket":socket}) json!({"node":node,"socket":socket})
} }
fn node(id: &str, key: &str, parameters: Value, inputs: Value) -> Value { fn node(id: &str, key: &str, mut parameters: Value, inputs: Value) -> Value {
json!({"id":id,"state":"enabled","executor":{"key":key,"version":1},"parameters":parameters,"inputs":inputs}) if key == "frame_out" && parameters.as_object().is_some_and(|p| p.is_empty()) {
parameters = json!({"hdrEnabled":false,"toneMapper":"aces","exposureStops":0,"outputTransfer":"srgb","scaleMode":"stretch","filter":"linear","backgroundColor":[0,0,0,1]});
}
json!({"id":id,"state":"enabled","executor":{"key":key,"version":if key == "frame_out" { 2 } else { 1 }},"parameters":parameters,"inputs":inputs})
} }
fn texture(format: &str, residency: &str) -> Value { fn texture(format: &str, residency: &str) -> Value {
json!({"texture":{"dimension":"d2","format":format,"extent":{"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1,"viewFormats":[]},"residency":residency}) json!({"texture":{"dimension":"d2","format":format,"extent":{"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1,"viewFormats":[]},"residency":residency})
@@ -247,7 +250,7 @@ fn fullscreen_copy_parameters_are_exactly_empty() {
let copy = node_index(&g, "copy"); let copy = node_index(&g, "copy");
g["nodes"][copy]["parameters"] = json!({"obsolete":true}); g["nodes"][copy]["parameters"] = json!({"obsolete":true});
assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID");
assert_eq!(CONTRACTS.len(), 17); assert_eq!(CONTRACTS.len(), 16);
} }
#[test] #[test]
@@ -839,7 +842,6 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() {
("pipeline_registry", 1), ("pipeline_registry", 1),
("pipeline", 1), ("pipeline", 1),
("fullscreen_copy", 1), ("fullscreen_copy", 1),
("tone_map", 1),
("color_balance", 1), ("color_balance", 1),
("exposure_contrast", 1), ("exposure_contrast", 1),
("saturation", 1), ("saturation", 1),
@@ -848,7 +850,7 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() {
("bloom_blur", 1), ("bloom_blur", 1),
("bloom_composite", 1), ("bloom_composite", 1),
("luminance_edge", 1), ("luminance_edge", 1),
("frame_out", 1), ("frame_out", 2),
] ]
); );
assert_eq!( assert_eq!(
@@ -864,7 +866,6 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() {
("pipeline_registry", None), ("pipeline_registry", None),
("pipeline", None), ("pipeline", None),
("fullscreen_copy", Some(FullscreenPolicy::Copy)), ("fullscreen_copy", Some(FullscreenPolicy::Copy)),
("tone_map", Some(FullscreenPolicy::ToneMap)),
("color_balance", Some(FullscreenPolicy::HdrSameExtent)), ("color_balance", Some(FullscreenPolicy::HdrSameExtent)),
("exposure_contrast", Some(FullscreenPolicy::HdrSameExtent)), ("exposure_contrast", Some(FullscreenPolicy::HdrSameExtent)),
("saturation", Some(FullscreenPolicy::HdrSameExtent)), ("saturation", Some(FullscreenPolicy::HdrSameExtent)),
@@ -2083,6 +2084,41 @@ fn assert_runtime_path(graph: &CompiledGraph, path: impl AsRef<str>) {
assert_eq!(error.details["path"], path.as_ref()); assert_eq!(error.details["path"], path.as_ref());
} }
fn mutate_texture_descriptor(
graph: &mut CompiledGraph,
resource: u32,
mutate: impl FnOnce(&mut NormalizedTextureDescriptor),
) {
let (family, allocation) = match graph.resources[resource as usize].plan {
ResourcePlan::Texture {
family, allocation, ..
} => (family as usize, allocation.unwrap()),
_ => unreachable!(),
};
let TextureFamilySource::AuthoredTexture {
resource: source,
descriptor,
..
} = &mut graph.texture_families[family].source;
mutate(descriptor);
let descriptor = descriptor.clone();
let ResourcePlan::TextureSource {
descriptor: source_descriptor,
..
} = &mut graph.resources[*source as usize].plan
else {
unreachable!()
};
*source_descriptor = descriptor.clone();
let key = &mut graph.allocation_classes[allocation.class as usize].key;
key.dimension = descriptor.dimension;
key.format = descriptor.format;
key.extent = descriptor.extent;
key.mip_level_count = descriptor.mip_level_count;
key.sample_count = descriptor.sample_count;
key.view_formats = descriptor.view_formats;
}
#[test] #[test]
fn runtime_rejects_coordinated_contract_and_texture_target_mutations() { fn runtime_rejects_coordinated_contract_and_texture_target_mutations() {
let baseline = compile_graph(full_cull_graph()); let baseline = compile_graph(full_cull_graph());
@@ -2920,15 +2956,6 @@ fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() {
.position(|execution| execution.executor.key == "fullscreen_copy") .position(|execution| execution.executor.key == "fullscreen_copy")
.unwrap(); .unwrap();
let mut graph = post.clone(); let mut graph = post.clone();
graph.executions[copy].executor.key = "tone_map".into();
assert_runtime_path(&graph, format!("executions[{copy}].parameters"));
for exposure in [f32::NAN, -0.1, 32.1] {
let mut graph = post.clone();
graph.executions[copy].executor.key = "tone_map".into();
graph.executions[copy].parameters = NormalizedParameters::ToneMap { exposure };
assert_runtime_path(&graph, format!("executions[{copy}].parameters"));
}
let mut graph = post.clone();
let ExecutionKind::Render { let ExecutionKind::Render {
color_attachments, .. color_attachments, ..
} = &mut graph.executions[copy].kind } = &mut graph.executions[copy].kind
@@ -2943,6 +2970,242 @@ fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() {
assert_runtime_path(&graph, format!("executions[{copy}].kind")); assert_runtime_path(&graph, format!("executions[{copy}].kind"));
} }
fn frame_parameters(hdr: bool) -> Value {
json!({"hdrEnabled":hdr,"toneMapper":"reinhard","exposureStops":2,
"outputTransfer":"srgb","scaleMode":"contain","filter":"nearest",
"backgroundColor":[0.1,0.2,0.3,0.4]})
}
#[test]
fn frame_out_v2_has_exact_seven_fields_normalizes_sdr_and_rejects_v1() {
let fields = [
"hdrEnabled",
"toneMapper",
"exposureStops",
"outputTransfer",
"scaleMode",
"filter",
"backgroundColor",
];
for field in fields {
let mut g = full_cull_graph();
let i = node_index(&g, "frame_out");
g["nodes"][i]["parameters"] = frame_parameters(false);
g["nodes"][i]["parameters"]
.as_object_mut()
.unwrap()
.remove(field);
assert_eq!(
compile_error(g).code,
"GRAPH_PARAMETERS_INVALID",
"missing {field}"
);
}
let mut g = full_cull_graph();
let i = node_index(&g, "frame_out");
g["nodes"][i]["parameters"] = frame_parameters(false);
g["nodes"][i]["parameters"]["extra"] = json!(0);
assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID");
for (field, bad) in [
("toneMapper", json!("bad")),
("exposureStops", json!(11)),
("backgroundColor", json!([0, 0, -0.1, 1])),
] {
let mut g = full_cull_graph();
let i = node_index(&g, "frame_out");
g["nodes"][i]["parameters"] = frame_parameters(false);
g["nodes"][i]["parameters"][field] = bad;
assert_eq!(
compile_error(g).code,
"GRAPH_PARAMETERS_INVALID",
"hidden {field}"
);
}
let mut g = full_cull_graph();
let i = node_index(&g, "frame_out");
g["nodes"][i]["parameters"] = frame_parameters(false);
assert!(matches!(
execution(&compile_graph(g), "frame_out").parameters,
NormalizedParameters::FrameOut {
dynamic_range: FrameDynamicRange::Sdr,
..
}
));
let mut g = full_cull_graph();
let i = node_index(&g, "frame_out");
g["nodes"][i]["executor"]["version"] = json!(1);
assert_eq!(compile_error(g).code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED");
}
#[test]
fn frame_out_source_format_matrix_is_exact() {
let surface = RuntimeSurfaceContract {
format: wgpu::TextureFormat::Bgra8Unorm,
width: 1280,
height: 720,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: vec![],
};
for (format, sdr, hdr) in [
("rgba8_unorm", true, false),
("bgra8_unorm", true, false),
("rgba16_float", true, true),
("rgba8_unorm_srgb", false, false),
("bgra8_unorm_srgb", false, false),
("r32_float", false, false),
("depth32_float", false, false),
] {
for (hdr_enabled, accepted) in [(false, sdr), (true, hdr)] {
let mut g = full_cull_graph();
let i = node_index(&g, "frame_out");
if format == "depth32_float" {
g["nodes"][i]["inputs"]["color"] = input("pipeline_main", "depth");
} else {
g["nodes"][0]["parameters"]["texture"]["format"] = json!(format);
}
g["nodes"][i]["parameters"] = frame_parameters(hdr_enabled);
match compile(serde_json::from_value(g).unwrap()) {
Ok(compiled) => {
assert!(
accepted,
"unexpected accept: hdr={hdr_enabled} format={format}"
);
prepare_runtime_plan(&compiled, surface.clone(), None).unwrap();
}
Err(error) => {
assert!(
!accepted,
"unexpected reject: hdr={hdr_enabled} format={format}"
);
let expected_message = if hdr_enabled {
"HDR frame output requires rgba16_float"
} else {
"SDR frame output requires a linear filterable color texture"
};
assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS");
assert_eq!(error.details["path"], format!("nodes[{i}].inputs.color"));
assert_eq!(error.message, expected_message);
assert_eq!(error.details["message"], expected_message);
}
}
}
}
}
#[test]
fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() {
let baseline = compile_graph(full_cull_graph());
let i = baseline
.executions
.iter()
.position(|e| e.executor.key == "frame_out")
.unwrap();
for version in [1, 3] {
let mut g = baseline.clone();
g.executions[i].executor.version = version;
assert_runtime_path(&g, format!("executions[{i}].executor.version"));
}
for lane in 0..4 {
for value in [f32::NAN, -0.1, 1.1] {
let mut g = baseline.clone();
let NormalizedParameters::FrameOut {
background_color, ..
} = &mut g.executions[i].parameters
else {
unreachable!()
};
background_color[lane] = value;
assert_runtime_path(&g, format!("executions[{i}].parameters"));
}
}
for exposure in [f32::NAN, -10.1, 10.1] {
let mut g = baseline.clone();
let NormalizedParameters::FrameOut { dynamic_range, .. } = &mut g.executions[i].parameters
else {
unreachable!()
};
*dynamic_range = FrameDynamicRange::Hdr {
tone_mapper: ToneMapper::Aces,
exposure_stops: exposure,
};
assert_runtime_path(&g, format!("executions[{i}].parameters"));
}
let ExecutionKind::FrameOut { color } = baseline.executions[i].kind else {
unreachable!()
};
let family = match baseline.resources[color as usize].plan {
ResourcePlan::Texture { family, .. } => family,
_ => unreachable!(),
} as usize;
for format in [
TextureFormat::Rgba8UnormSrgb,
TextureFormat::Bgra8UnormSrgb,
TextureFormat::R32Float,
] {
let mut g = baseline.clone();
mutate_texture_descriptor(&mut g, color, |descriptor| descriptor.format = format);
assert_runtime_path(&g, format!("textureFamilies[{family}].source.descriptor"));
}
let mut sdr_to_hdr = baseline.clone();
let NormalizedParameters::FrameOut { dynamic_range, .. } =
&mut sdr_to_hdr.executions[i].parameters
else {
unreachable!()
};
*dynamic_range = FrameDynamicRange::Hdr {
tone_mapper: ToneMapper::Aces,
exposure_stops: 0.,
};
assert_runtime_path(
&sdr_to_hdr,
format!("textureFamilies[{family}].source.descriptor"),
);
let mut hdr_value = full_cull_graph();
hdr_value["nodes"][0]["parameters"]["texture"]["format"] = json!("rgba16_float");
let frame_node = node_index(&hdr_value, "frame_out");
hdr_value["nodes"][frame_node]["parameters"] = frame_parameters(true);
let mut hdr_to_sdr = compile_graph(hdr_value);
let hdr_frame = hdr_to_sdr
.executions
.iter()
.position(|e| e.executor.key == "frame_out")
.unwrap();
let NormalizedParameters::FrameOut {
dynamic_range,
output_transfer,
..
} = &mut hdr_to_sdr.executions[hdr_frame].parameters
else {
unreachable!()
};
*dynamic_range = FrameDynamicRange::Sdr;
*output_transfer = OutputTransfer::Linear;
let linear_surface = RuntimeSurfaceContract {
format: wgpu::TextureFormat::Bgra8Unorm,
width: 1280,
height: 720,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: vec![],
};
prepare_runtime_plan(&hdr_to_sdr, linear_surface.clone(), None).unwrap();
let error = prepare_runtime_plan(
&hdr_to_sdr,
RuntimeSurfaceContract {
format: wgpu::TextureFormat::Bgra8UnormSrgb,
..linear_surface
},
None,
)
.unwrap_err();
assert_eq!(error.code, "GRAPH_SURFACE_INCOMPATIBLE");
assert_eq!(
error.details["path"],
format!("executions[{hdr_frame}].parameters.outputTransfer")
);
}
#[test] #[test]
fn runtime_rejects_mesh_query_predicate_shape_mutations() { fn runtime_rejects_mesh_query_predicate_shape_mutations() {
let baseline = compile_graph(full_cull_graph()); let baseline = compile_graph(full_cull_graph());
@@ -2997,12 +3260,6 @@ fn runtime_rejects_fullscreen_parameter_and_sample_order_mutations() {
.position(|execution| execution.executor.key == "fullscreen_copy") .position(|execution| execution.executor.key == "fullscreen_copy")
.unwrap(); .unwrap();
let invalid_parameters = [ let invalid_parameters = [
(
"tone_map",
NormalizedParameters::ToneMap {
exposure: f32::INFINITY,
},
),
( (
"bloom_extract", "bloom_extract",
NormalizedParameters::BloomExtract { NormalizedParameters::BloomExtract {
@@ -3130,55 +3387,23 @@ fn runtime_rechecks_pipeline_attachment_descriptors() {
.unwrap() .unwrap()
.resource .resource
}; };
let mutate_descriptor =
|graph: &mut CompiledGraph, resource: u32, mutate: fn(&mut NormalizedTextureDescriptor)| {
let (family, allocation) = match graph.resources[resource as usize].plan {
ResourcePlan::Texture {
family, allocation, ..
} => (family as usize, allocation.unwrap()),
_ => unreachable!(),
};
let TextureFamilySource::AuthoredTexture {
resource: source,
descriptor,
..
} = &mut graph.texture_families[family].source;
mutate(descriptor);
let descriptor = descriptor.clone();
let ResourcePlan::TextureSource {
descriptor: source_descriptor,
..
} = &mut graph.resources[*source as usize].plan
else {
unreachable!()
};
*source_descriptor = descriptor.clone();
let key = &mut graph.allocation_classes[allocation.class as usize].key;
key.dimension = descriptor.dimension;
key.format = descriptor.format;
key.extent = descriptor.extent;
key.mip_level_count = descriptor.mip_level_count;
key.sample_count = descriptor.sample_count;
key.view_formats = descriptor.view_formats;
};
let mut graph = baseline.clone(); let mut graph = baseline.clone();
let color = attachment(&graph, "color"); let color = attachment(&graph, "color");
mutate_descriptor(&mut graph, color, |descriptor| { mutate_texture_descriptor(&mut graph, color, |descriptor| {
descriptor.format = TextureFormat::Depth32Float; descriptor.format = TextureFormat::Depth32Float;
}); });
assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); assert_runtime_path(&graph, format!("executions[{pipeline}].inputs"));
let mut graph = baseline.clone(); let mut graph = baseline.clone();
let depth = attachment(&graph, "depth"); let depth = attachment(&graph, "depth");
mutate_descriptor(&mut graph, depth, |descriptor| { mutate_texture_descriptor(&mut graph, depth, |descriptor| {
descriptor.format = TextureFormat::Rgba16Float; descriptor.format = TextureFormat::Rgba16Float;
}); });
assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); assert_runtime_path(&graph, format!("executions[{pipeline}].inputs"));
let mut graph = baseline; let mut graph = baseline;
let color = attachment(&graph, "color"); let color = attachment(&graph, "color");
mutate_descriptor(&mut graph, color, |descriptor| { mutate_texture_descriptor(&mut graph, color, |descriptor| {
descriptor.extent = NormalizedTextureExtent::Absolute { descriptor.extent = NormalizedTextureExtent::Absolute {
width: 4, width: 4,
height: 4, height: 4,
+1 -1
View File
@@ -102,7 +102,7 @@ pub(crate) fn encode_compiled<T: Scene>(
( (
surface, surface,
wgpu::Operations { wgpu::Operations {
load: wgpu::LoadOp::Load, load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
) )
+22 -3
View File
@@ -17,10 +17,29 @@ fn aces(x: vec3<f32>) -> vec3<f32> {
return clamp((x * (2.51 * x + vec3(0.03))) / (x * (2.43 * x + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0)); return clamp((x * (2.51 * x + vec3(0.03))) / (x * (2.43 * x + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0));
} }
fn linear_to_srgb(x: vec3<f32>) -> vec3<f32> { fn linear_to_srgb(x: vec3<f32>) -> vec3<f32> {
let low = x * 12.92; let high = 1.055 * pow(x, vec3(1.0 / 2.4)) - vec3(0.055); let safe = clamp(x, vec3(0.0), vec3(1.0));
return select(high, low, x <= vec3(0.0031308)); let low = safe * 12.92; let high = 1.055 * pow(safe, vec3(1.0 / 2.4)) - vec3(0.055);
return select(high, low, safe <= vec3(0.0031308));
}
struct FrameCoordinates { uv: vec2<f32>, contained: bool }
fn frame_coordinates(position: vec2<f32>, surface: vec2<f32>, source: vec2<f32>, mode: f32) -> FrameCoordinates {
if mode < 0.5 { return FrameCoordinates(position / surface, true); }
let surface_aspect = surface.x / surface.y; let source_aspect = source.x / source.y; var size = surface;
if (mode < 1.5 && source_aspect > surface_aspect) || (mode > 1.5 && source_aspect < surface_aspect) { size.y = surface.x / source_aspect; } else { size.x = surface.y * source_aspect; }
let origin = (surface - size) * 0.5;
return FrameCoordinates((position - origin) / size, mode > 1.5 || (all(position >= origin) && all(position < origin + size)));
}
@fragment fn fs_frame_out(in: VertexOut) -> @location(0) vec4<f32> {
let surface=parameters.values[1].yz; let source=vec2<f32>(textureDimensions(source_texture));
let coordinates=frame_coordinates(in.position.xy,surface,source,parameters.values[1].x);
if !coordinates.contained {
var bg=parameters.values[2]; if parameters.values[0].w > 0.5 { bg=vec4(linear_to_srgb(bg.rgb),clamp(bg.a,0.0,1.0)); } return bg;
}
let sampled=sample_source(coordinates.uv); var rgb: vec3<f32>;
if parameters.values[0].x > 0.5 { rgb=max(sampled.rgb*exp2(parameters.values[0].z),vec3(0.0)); if parameters.values[0].y > 1.5 { rgb=aces(rgb); } else if parameters.values[0].y > 0.5 { rgb=rgb/(vec3(1.0)+rgb); } else { rgb=clamp(rgb,vec3(0.0),vec3(1.0)); } } else { rgb=clamp(sampled.rgb,vec3(0.0),vec3(1.0)); }
if parameters.values[0].w > 0.5 { rgb=linear_to_srgb(rgb); }
return vec4(clamp(rgb,vec3(0.0),vec3(1.0)),clamp(sampled.a,0.0,1.0));
} }
@fragment fn fs_tone_map(in: VertexOut) -> @location(0) vec4<f32> { let c=sample_source(in.uv); return vec4(linear_to_srgb(aces(c.rgb * parameters.values[0].x)), c.a); }
fn grading_result(source: vec4<f32>, graded: vec3<f32>, factor: f32) -> vec4<f32> { return vec4(mix(source.rgb, graded, vec3(factor)), source.a); } fn grading_result(source: vec4<f32>, graded: vec3<f32>, factor: f32) -> vec4<f32> { return vec4(mix(source.rgb, graded, vec3(factor)), source.a); }
@fragment fn fs_color_balance(in: VertexOut) -> @location(0) vec4<f32> { @fragment fn fs_color_balance(in: VertexOut) -> @location(0) vec4<f32> {
let c=sample_source(in.uv); var graded: vec3<f32>; let c=sample_source(in.uv); var graded: vec3<f32>;
+404 -14
View File
@@ -33,6 +33,24 @@ struct FullscreenUniforms {
values: [[f32; 4]; 8], values: [[f32; 4]; 8],
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FullscreenSamplerChoice {
Linear,
Nearest,
}
fn frame_out_sampler_choice(
parameters: &crate::render_graph::NormalizedParameters,
) -> Option<FullscreenSamplerChoice> {
let crate::render_graph::NormalizedParameters::FrameOut { filter, .. } = parameters else {
return None;
};
Some(match filter {
crate::render_graph::FrameFilter::Linear => FullscreenSamplerChoice::Linear,
crate::render_graph::FrameFilter::Nearest => FullscreenSamplerChoice::Nearest,
})
}
fn pack_fullscreen_uniforms( fn pack_fullscreen_uniforms(
key: &str, key: &str,
parameters: &crate::render_graph::NormalizedParameters, parameters: &crate::render_graph::NormalizedParameters,
@@ -40,11 +58,7 @@ fn pack_fullscreen_uniforms(
use crate::render_graph::NormalizedParameters; use crate::render_graph::NormalizedParameters;
let mut values = [[0.; 4]; 8]; let mut values = [[0.; 4]; 8];
let first = match (key, parameters) { let first = match (key, parameters) {
( ("fullscreen_copy", NormalizedParameters::FullscreenCopy) => [0.; 4],
"fullscreen_copy" | "frame_out",
NormalizedParameters::FullscreenCopy | NormalizedParameters::FrameOut,
) => [0.; 4],
("tone_map", NormalizedParameters::ToneMap { exposure }) => [*exposure, 0., 0., 0.],
( (
"color_balance", "color_balance",
NormalizedParameters::ColorBalance { NormalizedParameters::ColorBalance {
@@ -134,10 +148,68 @@ fn pack_fullscreen_uniforms(
Some(FullscreenUniforms { values }) Some(FullscreenUniforms { values })
} }
fn pack_frame_out_uniforms(
parameters: &crate::render_graph::NormalizedParameters,
surface: &crate::render_graph::RuntimeSurfaceContract,
) -> Option<FullscreenUniforms> {
use crate::render_graph::*;
let NormalizedParameters::FrameOut {
dynamic_range,
output_transfer,
scale_mode,
background_color,
..
} = parameters
else {
return None;
};
if *output_transfer == OutputTransfer::Linear && surface.format.is_srgb() {
return None;
}
let (hdr, mapper, exposure) = match dynamic_range {
FrameDynamicRange::Sdr => (0., 0., 0.),
FrameDynamicRange::Hdr {
tone_mapper,
exposure_stops,
} => (
1.,
match tone_mapper {
ToneMapper::None => 0.,
ToneMapper::Reinhard => 1.,
ToneMapper::Aces => 2.,
},
*exposure_stops,
),
};
let mut values = [[0.; 4]; 8];
values[0] = [
hdr,
mapper,
exposure,
if *output_transfer == OutputTransfer::Srgb && !surface.format.is_srgb() {
1.
} else {
0.
},
];
values[1] = [
match scale_mode {
ScaleMode::Stretch => 0.,
ScaleMode::Contain => 1.,
ScaleMode::Cover => 2.,
},
surface.width as f32,
surface.height as f32,
0.,
];
values[2] = *background_color;
Some(FullscreenUniforms { values })
}
fn resolve_fullscreen_entry(key: &str) -> Option<&'static str> { fn resolve_fullscreen_entry(key: &str) -> Option<&'static str> {
match key { match key {
"fullscreen_copy" | "frame_out" => Some("fs_copy"), "fullscreen_copy" => Some("fs_copy"),
"tone_map" => Some("fs_tone_map"), "frame_out" => Some("fs_frame_out"),
"color_balance" => Some("fs_color_balance"), "color_balance" => Some("fs_color_balance"),
"exposure_contrast" => Some("fs_exposure_contrast"), "exposure_contrast" => Some("fs_exposure_contrast"),
"saturation" => Some("fs_saturation"), "saturation" => Some("fs_saturation"),
@@ -153,7 +225,7 @@ fn resolve_fullscreen_entry(key: &str) -> Option<&'static str> {
#[cfg(test)] #[cfg(test)]
mod fullscreen_tests { mod fullscreen_tests {
use super::*; use super::*;
use crate::render_graph::{ColorBalanceMode, NormalizedParameters}; use crate::render_graph::*;
fn assert_packed(key: &str, parameters: NormalizedParameters, expected: &[[f32; 4]]) { fn assert_packed(key: &str, parameters: NormalizedParameters, expected: &[[f32; 4]]) {
let packed = pack_fullscreen_uniforms(key, &parameters).unwrap(); let packed = pack_fullscreen_uniforms(key, &parameters).unwrap();
@@ -179,15 +251,15 @@ mod fullscreen_tests {
assert!(packed.values[1..].iter().all(|value| *value == [0.0; 4])); assert!(packed.values[1..].iter().all(|value| *value == [0.0; 4]));
assert_eq!(bytemuck::bytes_of(&packed).len(), 128); assert_eq!(bytemuck::bytes_of(&packed).len(), 128);
assert!( assert!(
pack_fullscreen_uniforms("tone_map", &NormalizedParameters::FullscreenCopy).is_none() pack_fullscreen_uniforms("frame_out", &NormalizedParameters::FullscreenCopy).is_none()
); );
} }
#[test] #[test]
fn fullscreen_entries_are_explicit() { fn fullscreen_entries_are_explicit() {
assert_eq!(resolve_fullscreen_entry("fullscreen_copy"), Some("fs_copy")); assert_eq!(resolve_fullscreen_entry("fullscreen_copy"), Some("fs_copy"));
assert_eq!(resolve_fullscreen_entry("frame_out"), Some("fs_copy")); assert_eq!(resolve_fullscreen_entry("frame_out"), Some("fs_frame_out"));
assert_eq!(resolve_fullscreen_entry("tone_map"), Some("fs_tone_map")); assert_eq!(resolve_fullscreen_entry("tone_map"), None);
assert_eq!( assert_eq!(
resolve_fullscreen_entry("color_balance"), resolve_fullscreen_entry("color_balance"),
Some("fs_color_balance") Some("fs_color_balance")
@@ -223,6 +295,296 @@ mod fullscreen_tests {
assert_eq!(resolve_fullscreen_entry("unknown"), None); assert_eq!(resolve_fullscreen_entry("unknown"), None);
} }
fn surface(format: wgpu::TextureFormat) -> RuntimeSurfaceContract {
RuntimeSurfaceContract {
format,
width: 1920,
height: 1080,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: vec![],
}
}
fn frame(
dynamic_range: FrameDynamicRange,
transfer: OutputTransfer,
scale_mode: ScaleMode,
filter: FrameFilter,
) -> NormalizedParameters {
NormalizedParameters::FrameOut {
dynamic_range,
output_transfer: transfer,
scale_mode,
filter,
background_color: [0.1, 0.2, 0.3, 0.4],
}
}
#[test]
fn frame_out_packer_tags_all_lanes_and_sampler_is_gpu_independent() {
let expected = |first, scale| {
[
first,
[scale, 1920., 1080., 0.],
[0.1, 0.2, 0.3, 0.4],
[0.; 4],
[0.; 4],
[0.; 4],
[0.; 4],
[0.; 4],
]
};
for (mapper, mapper_tag, scale, scale_tag, filter) in [
(
ToneMapper::None,
0.,
ScaleMode::Stretch,
0.,
FrameFilter::Linear,
),
(
ToneMapper::Reinhard,
1.,
ScaleMode::Contain,
1.,
FrameFilter::Nearest,
),
(
ToneMapper::Aces,
2.,
ScaleMode::Cover,
2.,
FrameFilter::Linear,
),
] {
let p = frame(
FrameDynamicRange::Hdr {
tone_mapper: mapper,
exposure_stops: -2.,
},
OutputTransfer::Srgb,
scale,
filter,
);
assert_eq!(
pack_frame_out_uniforms(&p, &surface(wgpu::TextureFormat::Rgba8Unorm))
.unwrap()
.values,
expected([1., mapper_tag, -2., 1.], scale_tag)
);
assert_eq!(
frame_out_sampler_choice(&p),
Some(if filter == FrameFilter::Nearest {
FullscreenSamplerChoice::Nearest
} else {
FullscreenSamplerChoice::Linear
})
);
}
let sdr = frame(
FrameDynamicRange::Sdr,
OutputTransfer::Srgb,
ScaleMode::Stretch,
FrameFilter::Linear,
);
for format in [
wgpu::TextureFormat::Bgra8UnormSrgb,
wgpu::TextureFormat::Rgba8UnormSrgb,
] {
assert_eq!(
pack_frame_out_uniforms(&sdr, &surface(format))
.unwrap()
.values,
expected([0.; 4], 0.)
);
}
assert_eq!(
pack_frame_out_uniforms(&sdr, &surface(wgpu::TextureFormat::Bgra8Unorm))
.unwrap()
.values,
expected([0., 0., 0., 1.], 0.)
);
let linear = frame(
FrameDynamicRange::Sdr,
OutputTransfer::Linear,
ScaleMode::Stretch,
FrameFilter::Linear,
);
assert_eq!(
pack_frame_out_uniforms(&linear, &surface(wgpu::TextureFormat::Bgra8Unorm))
.unwrap()
.values,
expected([0.; 4], 0.)
);
assert!(
pack_frame_out_uniforms(&linear, &surface(wgpu::TextureFormat::Bgra8UnormSrgb))
.is_none()
);
assert_eq!(
frame_out_sampler_choice(&NormalizedParameters::FullscreenCopy),
None
);
}
fn coordinates(
p: [f32; 2],
surface: [f32; 2],
source: [f32; 2],
mode: ScaleMode,
) -> ([f32; 2], bool) {
if mode == ScaleMode::Stretch {
return ([p[0] / surface[0], p[1] / surface[1]], true);
}
let (sa, ia) = (surface[0] / surface[1], source[0] / source[1]);
let mut size = surface;
if (mode == ScaleMode::Contain && ia > sa) || (mode == ScaleMode::Cover && ia < sa) {
size[1] = surface[0] / ia;
} else {
size[0] = surface[1] * ia;
}
let origin = [(surface[0] - size[0]) * 0.5, (surface[1] - size[1]) * 0.5];
(
[(p[0] - origin[0]) / size[0], (p[1] - origin[1]) / size[1]],
mode == ScaleMode::Cover
|| (p[0] >= origin[0]
&& p[1] >= origin[1]
&& p[0] < origin[0] + size[0]
&& p[1] < origin[1] + size[1]),
)
}
#[test]
fn frame_coordinates_reference_half_open_centered_crop_and_stretch() {
let contain = |p| coordinates(p, [4., 4.], [4., 2.], ScaleMode::Contain);
assert_eq!(contain([0., 1.]), ([0., 0.], true));
assert_eq!(contain([4., 1.]), ([1., 0.], false));
assert_eq!(contain([0., 3.]), ([0., 1.], false));
assert!(contain([4. - 0.0001, 3. - 0.0001]).1);
for point in [[-1., 2.], [5., 2.], [2., 0.], [2., 4.]] {
assert!(!contain(point).1, "outside point {point:?}");
}
let cover = |p| coordinates(p, [4., 4.], [4., 2.], ScaleMode::Cover);
assert_eq!(cover([2., 2.]), ([0.5, 0.5], true));
assert_eq!(cover([0., 2.]), ([0.25, 0.5], true));
assert_eq!(cover([4., 2.]), ([0.75, 0.5], true));
assert_eq!(
coordinates([2., 2.], [4., 4.], [1., 9.], ScaleMode::Stretch),
([0.5, 0.5], true)
);
}
fn srgb(x: f32) -> f32 {
let x = x.clamp(0., 1.);
if x <= 0.0031308 {
12.92 * x
} else {
1.055 * x.powf(1. / 2.4) - 0.055
}
}
fn shade(c: [f32; 4], mapper: Option<ToneMapper>, exposure: f32, transfer: bool) -> [f32; 4] {
let mut rgb = [0.; 3];
for i in 0..3 {
let x = if mapper.is_some() {
(c[i] * 2f32.powf(exposure)).max(0.)
} else {
c[i]
};
rgb[i] = match mapper {
Some(ToneMapper::Aces) => {
((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0., 1.)
}
Some(ToneMapper::Reinhard) => x / (1. + x),
_ => x.clamp(0., 1.),
};
if transfer {
rgb[i] = srgb(rgb[i]);
}
}
[rgb[0], rgb[1], rgb[2], c[3].clamp(0., 1.)]
}
fn shade_background(
c: [f32; 4],
_hdr_mapper: ToneMapper,
_hdr_exposure_stops: f32,
output_transfer: OutputTransfer,
) -> [f32; 4] {
let mut rgb = [c[0].clamp(0., 1.), c[1].clamp(0., 1.), c[2].clamp(0., 1.)];
if output_transfer == OutputTransfer::Srgb {
rgb.iter_mut().for_each(|value| *value = srgb(*value));
}
[rgb[0], rgb[1], rgb[2], c[3].clamp(0., 1.)]
}
#[test]
fn frame_cpu_goldens_process_rgb_with_straight_clamped_alpha_and_background_once() {
let close = |actual: f32, expected: f32| {
assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}")
};
for (input, mapper, expected) in [
(0., ToneMapper::Aces, 0.),
(1., ToneMapper::Aces, 0.8037975),
(1., ToneMapper::Reinhard, 0.5),
(4., ToneMapper::Reinhard, 0.8),
(-1., ToneMapper::None, 0.),
(2., ToneMapper::None, 1.),
] {
close(
shade([input, 0., 0., 0.25], Some(mapper), 0., false)[0],
expected,
);
}
for (input, expected) in [(0., 0.), (0.0031308, 0.0404499), (0.18, 0.461356), (1., 1.)] {
close(srgb(input), expected);
}
let low_alpha = shade([0.18, 0.5, 1., 0.25], Some(ToneMapper::Reinhard), 1., true);
let opaque = shade([0.18, 0.5, 1., 1.], Some(ToneMapper::Reinhard), 1., true);
assert_eq!(&low_alpha[..3], &opaque[..3]);
assert_eq!((low_alpha[3], opaque[3]), (0.25, 1.));
assert_eq!(shade([0.18, 0.5, 1., 2.], None, 0., false)[3], 1.);
let background = [0.18, 0.5, 1., 0.25];
let once = shade_background(background, ToneMapper::Aces, 10., OutputTransfer::Srgb);
for (actual, expected) in once.into_iter().zip([0.461356, 0.7353569, 1., 0.25]) {
close(actual, expected);
}
assert_eq!(
once,
shade_background(background, ToneMapper::Reinhard, -10., OutputTransfer::Srgb)
);
assert_eq!(
shade_background(background, ToneMapper::Aces, 10., OutputTransfer::Linear),
background
);
assert_eq!(
shade_background(
[0.18, 0.5, 1., 1.2],
ToneMapper::Aces,
10.,
OutputTransfer::Srgb
)[3],
1.
);
assert_eq!(
shade_background(
[0.18, 0.5, 1., -0.2],
ToneMapper::Aces,
10.,
OutputTransfer::Srgb
)[3],
0.
);
let white = shade_background(
[1., 1., 1., 0.25],
ToneMapper::Aces,
10.,
OutputTransfer::Srgb,
);
white[..3].iter().for_each(|channel| close(*channel, 1.));
assert_eq!(white[3], 0.25);
let tone_mapped_white = shade([1., 1., 1., 1.], Some(ToneMapper::Aces), 0., false);
assert!(tone_mapped_white[0] < 0.81 && white[0] > tone_mapped_white[0]);
}
fn balance(mode: ColorBalanceMode) -> NormalizedParameters { fn balance(mode: ColorBalanceMode) -> NormalizedParameters {
NormalizedParameters::ColorBalance { NormalizedParameters::ColorBalance {
mode, mode,
@@ -1218,6 +1580,15 @@ impl<T: Scene + 'static> Renderer<T> {
min_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear,
..Default::default() ..Default::default()
}); });
let nearest_sampler = self
.context
.device
.create_sampler(&wgpu::SamplerDescriptor {
label: Some(" frame nearest clamp"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let mut executions = Vec::new(); let mut executions = Vec::new();
for (index, execution) in graph.executions.iter().enumerate() { for (index, execution) in graph.executions.iter().enumerate() {
let contract = crate::render_graph::contract(&execution.executor.key) let contract = crate::render_graph::contract(&execution.executor.key)
@@ -1256,9 +1627,20 @@ impl<T: Scene + 'static> Renderer<T> {
_ => return Err(fail("fullscreen inputs mismatch")), _ => return Err(fail("fullscreen inputs mismatch")),
} }
}; };
let values = let values = if frame_out {
pack_frame_out_uniforms(&execution.parameters, &runtime.surface)
} else {
pack_fullscreen_uniforms(&execution.executor.key, &execution.parameters) pack_fullscreen_uniforms(&execution.executor.key, &execution.parameters)
.ok_or_else(|| fail("executor parameters mismatch"))?; }
.ok_or_else(|| fail("executor parameters mismatch"))?;
let sampler_choice = if frame_out {
Some(
frame_out_sampler_choice(&execution.parameters)
.ok_or_else(|| fail("executor parameters mismatch"))?,
)
} else {
None
};
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
let uniform = let uniform =
self.context self.context
@@ -1347,7 +1729,15 @@ impl<T: Scene + 'static> Renderer<T> {
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 2, binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler), resource: wgpu::BindingResource::Sampler(
if sampler_choice
== Some(FullscreenSamplerChoice::Nearest)
{
&nearest_sampler
} else {
&sampler
},
),
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 3, binding: 3,
+4 -1
View File
@@ -176,7 +176,10 @@
<option value="ember">Ember</option> <option value="ember">Ember</option>
<option value="hdr">HDR Fullscreen</option> <option value="hdr">HDR Fullscreen</option>
<option value="culling">GPU frustum culling</option> <option value="culling">GPU frustum culling</option>
<option value="tone">Tone map</option> <option value="tone">ACES display</option>
<option value="contain">SDR contain</option>
<option value="reinhard">Reinhard cover</option>
<option value="linear">Linear clip</option>
<option value="grading">Grading</option> <option value="grading">Grading</option>
<option value="edges">Edges</option> <option value="edges">Edges</option>
<option value="bloom">Bloom</option> <option value="bloom">Bloom</option>
+23 -15
View File
@@ -1,5 +1,5 @@
export const GRAPH_ID = "authored_gpu_culling"; export const GRAPH_ID = "authored_gpu_culling";
export const CATALOG_VERSION = 5; export const CATALOG_VERSION = 6;
const exact = (type) => ({ kind: "exact", types: [type] }); const exact = (type) => ({ kind: "exact", types: [type] });
const i = (type, required = true, authoringType) => ({ const i = (type, required = true, authoringType) => ({
accepted: typeof type === "string" ? exact(type) : type, accepted: typeof type === "string" ? exact(type) : type,
@@ -120,16 +120,6 @@ export const semanticCatalog = Object.freeze({
outputs: { color: o("texture") }, outputs: { color: o("texture") },
parameters: {}, parameters: {},
}, },
tone_map: {
version: 1,
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i("texture"),
},
outputs: { color: o("texture") },
parameters: { exposure: 1 },
},
color_balance: { color_balance: {
version: 1, version: 1,
execution: "render", execution: "render",
@@ -198,11 +188,11 @@ export const semanticCatalog = Object.freeze({
parameters: { strength: 2 }, parameters: { strength: 2 },
}, },
frame_out: { frame_out: {
version: 1, version: 2,
execution: "frame", execution: "frame",
inputs: { color: i("texture") }, inputs: { color: i("texture") },
outputs: {}, outputs: {},
parameters: {}, parameters: { hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] },
}, },
}); });
const socketColors = [ const socketColors = [
@@ -383,7 +373,6 @@ const parameterSchemas = {
clearColor: color([0.015, 0.02, 0.03, 1]), clearColor: color([0.015, 0.02, 0.03, 1]),
}, },
fullscreen_copy: {}, fullscreen_copy: {},
tone_map: { exposure: number(1, 0, 32) },
color_balance: { color_balance: {
mode: enumeration("lift_gamma_gain", ["lift_gamma_gain", "offset_power_slope"]), factor: number(1, 0, 1), mode: enumeration("lift_gamma_gain", ["lift_gamma_gain", "offset_power_slope"]), factor: number(1, 0, 1),
lift: number(0, -1, 1), liftColor: color([1, 1, 1, 1], 0, 4), gamma: number(1, 0.01, 4), gammaColor: color([1, 1, 1, 1], 0, 4), gain: number(1, 0, 4), gainColor: color([1, 1, 1, 1], 0, 4), lift: number(0, -1, 1), liftColor: color([1, 1, 1, 1], 0, 4), gamma: number(1, 0.01, 4), gammaColor: color([1, 1, 1, 1], 0, 4), gain: number(1, 0, 4), gainColor: color([1, 1, 1, 1], 0, 4),
@@ -399,7 +388,15 @@ const parameterSchemas = {
}, },
bloom_composite: { intensity: number(1, 0, 16) }, bloom_composite: { intensity: number(1, 0, 16) },
luminance_edge: { strength: number(2, 0, 16) }, luminance_edge: { strength: number(2, 0, 16) },
frame_out: {}, frame_out: {
hdrEnabled: boolean(true),
toneMapper: enumeration("aces", ["aces", "reinhard", "none"]),
exposureStops: number(0, -10, 10),
outputTransfer: enumeration("srgb", ["srgb", "linear"]),
scaleMode: enumeration("stretch", ["stretch", "contain", "cover"]),
filter: enumeration("linear", ["linear", "nearest"]),
backgroundColor: color([0, 0, 0, 1]),
},
}; };
export const nodeDefinitions = Object.fromEntries( export const nodeDefinitions = Object.fromEntries(
Object.entries(semanticCatalog).map(([key, c]) => { Object.entries(semanticCatalog).map(([key, c]) => {
@@ -471,6 +468,17 @@ nodeDefinitions.color_balance.ui = [
{ kind: "parameter", parameter: "factor" }, { kind: "parameter", parameter: "factor" },
{ kind: "socket", socket: "source" }, { kind: "socket", socket: "colorTarget" }, { kind: "socket", socket: "color" }, { kind: "socket", socket: "source" }, { kind: "socket", socket: "colorTarget" }, { kind: "socket", socket: "color" },
]; ];
nodeDefinitions.frame_out.ui = [
{ kind: "text", variant: "section", title: "Display Transform" },
{ kind: "parameter", parameter: "hdrEnabled", title: "HDR" },
{ kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } },
{ kind: "parameter", parameter: "exposureStops", title: "Exposure", visibleWhen: { parameter: "hdrEnabled", equals: true } },
{ kind: "parameter", parameter: "outputTransfer", title: "Transfer" },
{ kind: "parameter", parameter: "scaleMode", title: "Scale" },
{ kind: "parameter", parameter: "filter" },
{ kind: "parameter", parameter: "backgroundColor", title: "Background", visibleWhen: { parameter: "scaleMode", equals: "contain" } },
{ kind: "socket", socket: "color" },
];
export const fxNodeComposition = Object.freeze({ export const fxNodeComposition = Object.freeze({
schemaVersion: 2, schemaVersion: 2,
id: "yawn.render-graph", id: "yawn.render-graph",
+23 -16
View File
@@ -4,17 +4,18 @@ const input = (node, socket) => ({ node, socket });
const node = (id, key, parameters = {}, inputs = {}) => ({ const node = (id, key, parameters = {}, inputs = {}) => ({
id, state: "enabled", executor: { key, version: descriptors[key].version }, parameters, inputs, id, state: "enabled", executor: { key, version: descriptors[key].version }, parameters, inputs,
}); });
const texture = (format, scale = 1) => ({ const texture = (format, scale = 1, heightScale = scale) => ({
texture: { texture: {
dimension: "d2", format, dimension: "d2", format,
extent: { kind: "surface_relative", width: { numerator: 1, denominator: scale }, height: { numerator: 1, denominator: scale }, depthOrArrayLayers: 1 }, extent: { kind: "surface_relative", width: { numerator: 1, denominator: scale }, height: { numerator: 1, denominator: heightScale }, depthOrArrayLayers: 1 },
mipLevelCount: 1, sampleCount: 1, viewFormats: [], mipLevelCount: 1, sampleCount: 1, viewFormats: [],
}, },
residency: "transient", residency: "transient",
}); });
const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1]) => [ const frameOut = (hdr, options = {}) => ({ hdrEnabled: hdr, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1], ...options });
node("hdr", "texture", texture("rgba16_float")), const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1) => [
node("depth", "texture", texture("depth32_float")), node("hdr", "texture", texture("rgba16_float", 1, heightScale)),
node("depth", "texture", texture("depth32_float", 1, heightScale)),
node("mesh", "mesh"), 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("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("registry", "pipeline_registry", {}, { pipelineIndices: input("mesh", "pipelineIndices") }),
@@ -26,13 +27,13 @@ const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 2, graphId, rev
const direct = (graphId, clearColor) => graph(graphId, [ const direct = (graphId, clearColor) => graph(graphId, [
node("ldr", "texture", texture("rgba8_unorm")), node("ldr", "texture", texture("rgba8_unorm")),
...scene("ldr", clearColor).filter((item) => item.id !== "hdr"), ...scene("ldr", clearColor).filter((item) => item.id !== "hdr"),
node("frame_out", "frame_out", {}, { color: input("pbr_double", "color") }), node("frame_out", "frame_out", frameOut(false), { color: input("pbr_double", "color") }),
]); ]);
export const midnight = direct("preset_midnight", [0.015, 0.06, 0.18, 1]); 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 ember = direct("preset_ember", [0.18, 0.035, 0.012, 1]);
export const hdr = graph("preset_hdr_fullscreen", [ export const hdr = graph("preset_hdr_fullscreen", [
...scene("hdr"), ...scene("hdr"),
node("frame_out", "frame_out", {}, { color: input("pbr_double", "color") }), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }),
]); ]);
export const culling = graph("preset_gpu_culling", (() => { export const culling = graph("preset_gpu_culling", (() => {
const nodes = structuredClone(hdr.nodes); const nodes = structuredClone(hdr.nodes);
@@ -43,15 +44,15 @@ export const culling = graph("preset_gpu_culling", (() => {
return nodes; return nodes;
})()); })());
const postPreset = (graphId, kind) => { const postPreset = (graphId, kind) => {
const nodes = [node("ldr", "texture", texture("rgba8_unorm")), ...scene("hdr")]; const nodes = [...scene("hdr")];
let source = "pbr_double"; let source = "pbr_double";
if (kind === "edges") { if (kind === "edges") {
nodes.splice(1, 0, node("edge_hdr", "texture", texture("rgba16_float"))); nodes.splice(0, 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") })); nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") }));
source = "edges"; source = "edges";
} }
if (kind === "bloom" || kind === "combined") { if (kind === "bloom" || kind === "combined") {
nodes.splice(1, 0, nodes.splice(0, 0,
node("half_a", "texture", texture("rgba16_float", 2)), node("half_b", "texture", texture("rgba16_float", 2)), 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"))); node("half_c", "texture", texture("rgba16_float", 2)), node("composite_hdr", "texture", texture("rgba16_float")));
nodes.push( nodes.push(
@@ -62,26 +63,32 @@ const postPreset = (graphId, kind) => {
); );
source = "composite"; source = "composite";
if (kind === "combined") { if (kind === "combined") {
nodes.splice(1, 0, node("edge_hdr", "texture", texture("rgba16_float"))); nodes.splice(0, 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") })); nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") }));
source = "edges"; source = "edges";
} }
} }
nodes.push(node("tone", "tone_map", { exposure: 1 }, { source: input(source, "color"), colorTarget: input("ldr", "texture") })); nodes.push(node("frame_out", "frame_out", frameOut(true), { color: input(source, "color") }));
nodes.push(node("frame_out", "frame_out", {}, { color: input("tone", "color") }));
return graph(graphId, nodes); return graph(graphId, nodes);
}; };
export const tone = postPreset("preset_tone", "tone"); export const tone = postPreset("preset_tone", "tone");
const displayPreset = (id, parameters, heightScale = 1) => graph(id, [
...scene("hdr", undefined, heightScale),
node("frame_out", "frame_out", parameters, { color: input("pbr_double", "color") }),
]);
export const contain = displayPreset("preset_contain", frameOut(false, { scaleMode: "contain", filter: "nearest", backgroundColor: [0.18, 0.18, 0.18, 0.25] }), 2);
export const reinhard = displayPreset("preset_reinhard", frameOut(true, { toneMapper: "reinhard", exposureStops: 2, scaleMode: "cover", filter: "nearest" }), 2);
export const linear = displayPreset("preset_linear", frameOut(true, { toneMapper: "none", exposureStops: 2, outputTransfer: "linear" }));
export const edges = postPreset("preset_edges", "edges"); export const edges = postPreset("preset_edges", "edges");
export const bloom = postPreset("preset_bloom", "bloom"); export const bloom = postPreset("preset_bloom", "bloom");
export const combined = postPreset("preset_combined", "combined"); export const combined = postPreset("preset_combined", "combined");
export const grading = graph("preset_grading", [ export const grading = graph("preset_grading", [
node("balance_hdr", "texture", texture("rgba16_float")), node("exposure_hdr", "texture", texture("rgba16_float")), node("saturation_hdr", "texture", texture("rgba16_float")), node("mixer_hdr", "texture", texture("rgba16_float")), node("ldr", "texture", texture("rgba8_unorm")), node("balance_hdr", "texture", texture("rgba16_float")), node("exposure_hdr", "texture", texture("rgba16_float")), node("saturation_hdr", "texture", texture("rgba16_float")), node("mixer_hdr", "texture", texture("rgba16_float")),
...scene("hdr"), ...scene("hdr"),
node("balance", "color_balance", { mode: "lift_gamma_gain", factor: 1, lift: 0, liftColor: [1,1,1,1], gamma: 1, gammaColor: [1,1,1,1], gain: 1, gainColor: [1,1,1,1], offset: 0, offsetColor: [1,1,1,1], power: 1, powerColor: [1,1,1,1], slope: 1, slopeColor: [1,1,1,1] }, { source: input("pbr_double", "color"), colorTarget: input("balance_hdr", "texture") }), node("balance", "color_balance", { mode: "lift_gamma_gain", factor: 1, lift: 0, liftColor: [1,1,1,1], gamma: 1, gammaColor: [1,1,1,1], gain: 1, gainColor: [1,1,1,1], offset: 0, offsetColor: [1,1,1,1], power: 1, powerColor: [1,1,1,1], slope: 1, slopeColor: [1,1,1,1] }, { source: input("pbr_double", "color"), colorTarget: input("balance_hdr", "texture") }),
node("exposure", "exposure_contrast", { exposureStops: 0, contrast: 1, pivot: 0.18, factor: 1 }, { source: input("balance", "color"), colorTarget: input("exposure_hdr", "texture") }), node("exposure", "exposure_contrast", { exposureStops: 0, contrast: 1, pivot: 0.18, factor: 1 }, { source: input("balance", "color"), colorTarget: input("exposure_hdr", "texture") }),
node("saturation", "saturation", { saturation: 1, factor: 1 }, { source: input("exposure", "color"), colorTarget: input("saturation_hdr", "texture") }), node("saturation", "saturation", { saturation: 1, factor: 1 }, { source: input("exposure", "color"), colorTarget: input("saturation_hdr", "texture") }),
node("mixer", "channel_mixer", { redOutput: [1,0,0], greenOutput: [0,1,0], blueOutput: [0,0,1], factor: 1 }, { source: input("saturation", "color"), colorTarget: input("mixer_hdr", "texture") }), node("mixer", "channel_mixer", { redOutput: [1,0,0], greenOutput: [0,1,0], blueOutput: [0,0,1], factor: 1 }, { source: input("saturation", "color"), colorTarget: input("mixer_hdr", "texture") }),
node("tone", "tone_map", { exposure: 1 }, { source: input("mixer", "color"), colorTarget: input("ldr", "texture") }), node("frame_out", "frame_out", {}, { color: input("tone", "color") }), node("frame_out", "frame_out", frameOut(true), { color: input("mixer", "color") }),
]); ]);
export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, grading, edges, bloom, combined }); export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, contain, reinhard, linear, grading, edges, bloom, combined });
+12 -16
View File
@@ -10,17 +10,13 @@ import {
spawnRequestedNode, spawnRequestedNode,
} from "../static/render-graph/node-spawn.js"; } from "../static/render-graph/node-spawn.js";
test("add-node model contains all 17 catalog types in application groups", () => { test("add-node model contains all 16 catalog types in application groups", () => {
assert.equal(addNodeItems.length, 17); assert.equal(addNodeItems.length, 16);
assert.deepEqual( assert.deepEqual(
[...new Set(addNodeItems.map((item) => item.group))], [...new Set(addNodeItems.map((item) => item.group))],
["Source", "Compute", "CPU preparation", "Render / post", "Frame"], ["Source", "Compute", "CPU preparation", "Render / post", "Frame"],
); );
assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 17); assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 16);
assert.deepEqual(
searchAddNodeItems("tone render").map((item) => item.typeId),
["tone_map"],
);
assert.deepEqual(searchAddNodeItems("no such node"), []); assert.deepEqual(searchAddNodeItems("no such node"), []);
}); });
@@ -40,7 +36,7 @@ test("allocator avoids existing and session-reserved IDs and is bounded", () =>
); );
}); });
test("all 17 types spawn with exact position, current version and generated ID", async () => { test("all 16 types spawn with exact position, current version and generated ID", async () => {
let revision = 5, let revision = 5,
expectedType; expectedType;
const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } }; const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } };
@@ -91,13 +87,13 @@ test("spawn rechecks composition after getState and propagates add errors", asyn
}, },
}; };
assert.equal( assert.equal(
await spawnRequestedNode(root, view, request, "tone_map", allocate), await spawnRequestedNode(root, view, request, "fullscreen_copy", allocate),
false, false,
); );
revision = 2; revision = 2;
root.getState = async () => ({ version: 3, nodes: [] }); root.getState = async () => ({ version: 3, nodes: [] });
await assert.rejects( await assert.rejects(
spawnRequestedNode(root, view, request, "tone_map", allocate), spawnRequestedNode(root, view, request, "fullscreen_copy", allocate),
/must not add/, /must not add/,
); );
}); });
@@ -124,7 +120,7 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () =
root, root,
view, view,
request, request,
"tone_map", "fullscreen_copy",
() => "node_a", () => "node_a",
() => alive, () => alive,
); );
@@ -136,7 +132,7 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () =
root, root,
view, view,
request, request,
"tone_map", "fullscreen_copy",
() => "node_b", () => "node_b",
() => alive, () => alive,
); );
@@ -161,7 +157,7 @@ test("spawn has a final liveness guard after ID allocation", async () => {
root, root,
view, view,
request, request,
"tone_map", "fullscreen_copy",
() => { () => {
alive = false; alive = false;
return "node_reserved"; return "node_reserved";
@@ -190,7 +186,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e
root, root,
view, view,
request, request,
"tone_map", "fullscreen_copy",
() => "node_a", () => "node_a",
() => alive, () => alive,
), ),
@@ -208,7 +204,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e
root, root,
view, view,
request, request,
"tone_map", "fullscreen_copy",
() => "node_b", () => "node_b",
() => alive, () => alive,
), ),
@@ -224,7 +220,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e
root, root,
view, view,
request, request,
"tone_map", "fullscreen_copy",
() => "node_c", () => "node_c",
() => alive, () => alive,
), ),
+2 -2
View File
@@ -36,8 +36,8 @@ test("production render graph composition passes fxnode's public validator", asy
result.ok ? undefined : JSON.stringify(result.issues, null, 2), result.ok ? undefined : JSON.stringify(result.issues, null, 2),
); );
assert.equal(fxNodeComposition.schemaVersion, 2); assert.equal(fxNodeComposition.schemaVersion, 2);
assert.equal(fxNodeComposition.version, 5); assert.equal(fxNodeComposition.version, 6);
assert.equal(Object.keys(fxNodeComposition.nodes).length, 17); assert.equal(Object.keys(fxNodeComposition.nodes).length, 16);
assert.ok( assert.ok(
Object.values(fxNodeComposition.nodes).every( Object.values(fxNodeComposition.nodes).every(
(definition) => definition.migrations.length === 0, (definition) => definition.migrations.length === 0,
+49 -4
View File
@@ -24,7 +24,7 @@ function fixture() {
return { return {
id: n.id, id: n.id,
typeId: n.executor.key, typeId: n.executor.key,
typeVersion: 1, typeVersion: d.version,
known: true, known: true,
muted: n.state !== "enabled", muted: n.state !== "enabled",
position: { x: 10, y: 20 }, position: { x: 10, y: 20 },
@@ -96,7 +96,7 @@ function fixture() {
test("catalog exhaustively mirrors all current contracts", () => { test("catalog exhaustively mirrors all current contracts", () => {
for (const [key, semantic] of Object.entries(semanticCatalog)) { for (const [key, semantic] of Object.entries(semanticCatalog)) {
assert.ok(Object.hasOwn(semantic, "version")); assert.ok(Object.hasOwn(semantic, "version"));
assert.equal(semantic.version, 1); assert.equal(semantic.version, key === "frame_out" ? 2 : 1);
assert.equal(nodeDefinitions[key].version, semantic.version); assert.equal(nodeDefinitions[key].version, semantic.version);
assert.equal(descriptors[key].version, semantic.version); assert.equal(descriptors[key].version, semantic.version);
} }
@@ -110,7 +110,6 @@ test("catalog exhaustively mirrors all current contracts", () => {
"pipeline_registry", "pipeline_registry",
"pipeline", "pipeline",
"fullscreen_copy", "fullscreen_copy",
"tone_map",
"color_balance", "color_balance",
"exposure_contrast", "exposure_contrast",
"saturation", "saturation",
@@ -134,7 +133,7 @@ test("catalog exhaustively mirrors all current contracts", () => {
Object.keys(contract.parameters).sort(), Object.keys(contract.parameters).sort(),
key, key,
); );
assert.equal(CATALOG_VERSION, 5); assert.equal(CATALOG_VERSION, 6);
assert.deepEqual(nodeDefinitions.pipeline.parameters, { assert.deepEqual(nodeDefinitions.pipeline.parameters, {
pipeline: { pipeline: {
type: "string", type: "string",
@@ -238,6 +237,7 @@ test("adapter validates and exactly lowers canonical pipeline controls and blur
visible: socket.visible, visible: socket.visible,
}), }),
); );
blur.typeVersion = descriptors.bloom_blur.version;
x.nodes.push(blur); x.nodes.push(blur);
assert.deepEqual( assert.deepEqual(
adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "blur").parameters adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "blur").parameters
@@ -346,6 +346,51 @@ test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type
link.fromSocketId = "mesh:mesh"; link.fromSocketId = "mesh:mesh";
}, "AUTHORING_LINK_TYPE"); }, "AUTHORING_LINK_TYPE");
}); });
test("Frame Out has the exact v2 schema, defaults, UI, and strict authoring validation", () => {
const fields = ["hdrEnabled", "toneMapper", "exposureStops", "outputTransfer", "scaleMode", "filter", "backgroundColor"];
assert.equal(CATALOG_VERSION, 6);
assert.deepEqual(semanticCatalog.frame_out, {
version: 2, execution: "frame", inputs: { color: semanticCatalog.frame_out.inputs.color }, outputs: {},
parameters: { hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] },
});
assert.deepEqual(nodeDefinitions.frame_out.parameters, {
hdrEnabled: { type: "boolean", default: { kind: "boolean", value: true } },
toneMapper: { type: "string", default: { kind: "string", value: "aces" }, enum: ["aces", "reinhard", "none"] },
exposureStops: { type: "number", default: { kind: "number", value: 0 }, minimum: -10, maximum: 10 },
outputTransfer: { type: "string", default: { kind: "string", value: "srgb" }, enum: ["srgb", "linear"] },
scaleMode: { type: "string", default: { kind: "string", value: "stretch" }, enum: ["stretch", "contain", "cover"] },
filter: { type: "string", default: { kind: "string", value: "linear" }, enum: ["linear", "nearest"] },
backgroundColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] }, minimum: 0, maximum: 1 },
});
assert.deepEqual(nodeDefinitions.frame_out.ui, [
{ kind: "text", variant: "section", title: "Display Transform" },
{ kind: "parameter", parameter: "hdrEnabled", title: "HDR" },
{ kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } },
{ kind: "parameter", parameter: "exposureStops", title: "Exposure", visibleWhen: { parameter: "hdrEnabled", equals: true } },
{ kind: "parameter", parameter: "outputTransfer", title: "Transfer" },
{ kind: "parameter", parameter: "scaleMode", title: "Scale" },
{ kind: "parameter", parameter: "filter" },
{ kind: "parameter", parameter: "backgroundColor", title: "Background", visibleWhen: { parameter: "scaleMode", equals: "contain" } },
{ kind: "socket", socket: "color" },
]);
const reject = (mutate, code, parameter) => {
const x = fixture(), n = x.nodes.find((node) => node.typeId === "frame_out");
mutate(x, n);
assert.throws(() => adaptFxNodeSnapshot(x), (e) => e instanceof AuthoringGraphError && e.code === code && (!parameter || e.details.nodeId === n.id && e.details.parameter === parameter));
};
reject((x) => x.catalogVersion = 5, "AUTHORING_CATALOG");
reject((x, n) => n.typeVersion = 1, "AUTHORING_NODE_INVALID");
for (const field of fields) reject((x, n) => delete n.parameters[field], "AUTHORING_PARAMETER_SET");
reject((x, n) => n.parameters.extra = { kind: "number", value: 0 }, "AUTHORING_PARAMETER_SET");
for (const [field, value] of [
["hdrEnabled", 1], ["toneMapper", "bad"], ["outputTransfer", "bad"], ["scaleMode", "bad"], ["filter", "bad"],
["exposureStops", NaN], ["exposureStops", -10.01], ["exposureStops", 10.01],
["backgroundColor", [0, 0, 0]], ["backgroundColor", [0, 0, Infinity, 1]], ["backgroundColor", [-0.01, 0, 0, 1]], ["backgroundColor", [0, 0, 0, 1.01]],
]) reject((x, n) => n.parameters[field].value = value, "AUTHORING_PARAMETER", field);
for (const [hidden, value] of [["toneMapper", "bad"], ["exposureStops", Infinity]])
reject((x, n) => { n.parameters.hdrEnabled.value = false; n.parameters[hidden].value = value; }, "AUTHORING_PARAMETER", hidden);
reject((x, n) => { n.parameters.scaleMode.value = "stretch"; n.parameters.backgroundColor.value = [2, 0, 0, 1]; }, "AUTHORING_PARAMETER", "backgroundColor");
});
test("adapter counts only active incoming links and reports socket overflow", () => { test("adapter counts only active incoming links and reports socket overflow", () => {
const x = fixture(); const x = fixture();
const active = x.links.find((link) => link.toSocketId === "frame_out:color"); const active = x.links.find((link) => link.toSocketId === "frame_out:color");
+19 -33
View File
@@ -9,6 +9,9 @@ const order = [
"hdr", "hdr",
"culling", "culling",
"tone", "tone",
"contain",
"reinhard",
"linear",
"grading", "grading",
"edges", "edges",
"bloom", "bloom",
@@ -61,7 +64,6 @@ const sequences = {
["frame_out", "frame_out"], ["frame_out", "frame_out"],
], ],
tone: [ tone: [
["ldr", "texture"],
["hdr", "texture"], ["hdr", "texture"],
["depth", "texture"], ["depth", "texture"],
["mesh", "mesh"], ["mesh", "mesh"],
@@ -70,17 +72,15 @@ const sequences = {
["ground", "pipeline"], ["ground", "pipeline"],
["pbr", "pipeline"], ["pbr", "pipeline"],
["pbr_double", "pipeline"], ["pbr_double", "pipeline"],
["tone", "tone_map"],
["frame_out", "frame_out"], ["frame_out", "frame_out"],
], ],
grading: [ grading: [
["balance_hdr", "texture"], ["exposure_hdr", "texture"], ["saturation_hdr", "texture"], ["mixer_hdr", "texture"], ["ldr", "texture"], ["balance_hdr", "texture"], ["exposure_hdr", "texture"], ["saturation_hdr", "texture"], ["mixer_hdr", "texture"],
["hdr", "texture"], ["depth", "texture"], ["mesh", "mesh"], ["query", "mesh_query"], ["registry", "pipeline_registry"], ["hdr", "texture"], ["depth", "texture"], ["mesh", "mesh"], ["query", "mesh_query"], ["registry", "pipeline_registry"],
["ground", "pipeline"], ["pbr", "pipeline"], ["pbr_double", "pipeline"], ["balance", "color_balance"], ["exposure", "exposure_contrast"], ["ground", "pipeline"], ["pbr", "pipeline"], ["pbr_double", "pipeline"], ["balance", "color_balance"], ["exposure", "exposure_contrast"],
["saturation", "saturation"], ["mixer", "channel_mixer"], ["tone", "tone_map"], ["frame_out", "frame_out"], ["saturation", "saturation"], ["mixer", "channel_mixer"], ["frame_out", "frame_out"],
], ],
edges: [ edges: [
["ldr", "texture"],
["edge_hdr", "texture"], ["edge_hdr", "texture"],
["hdr", "texture"], ["hdr", "texture"],
["depth", "texture"], ["depth", "texture"],
@@ -91,11 +91,9 @@ const sequences = {
["pbr", "pipeline"], ["pbr", "pipeline"],
["pbr_double", "pipeline"], ["pbr_double", "pipeline"],
["edges", "luminance_edge"], ["edges", "luminance_edge"],
["tone", "tone_map"],
["frame_out", "frame_out"], ["frame_out", "frame_out"],
], ],
bloom: [ bloom: [
["ldr", "texture"],
["half_a", "texture"], ["half_a", "texture"],
["half_b", "texture"], ["half_b", "texture"],
["half_c", "texture"], ["half_c", "texture"],
@@ -112,11 +110,9 @@ const sequences = {
["blur_h", "bloom_blur"], ["blur_h", "bloom_blur"],
["blur_v", "bloom_blur"], ["blur_v", "bloom_blur"],
["composite", "bloom_composite"], ["composite", "bloom_composite"],
["tone", "tone_map"],
["frame_out", "frame_out"], ["frame_out", "frame_out"],
], ],
combined: [ combined: [
["ldr", "texture"],
["edge_hdr", "texture"], ["edge_hdr", "texture"],
["half_a", "texture"], ["half_a", "texture"],
["half_b", "texture"], ["half_b", "texture"],
@@ -135,10 +131,11 @@ const sequences = {
["blur_v", "bloom_blur"], ["blur_v", "bloom_blur"],
["composite", "bloom_composite"], ["composite", "bloom_composite"],
["edges", "luminance_edge"], ["edges", "luminance_edge"],
["tone", "tone_map"],
["frame_out", "frame_out"], ["frame_out", "frame_out"],
], ],
}; };
for (const name of ["contain", "reinhard", "linear"])
sequences[name] = sequences.tone;
test("presets have the exact canonical pipeline identities, schemas, and node sequences", () => { test("presets have the exact canonical pipeline identities, schemas, and node sequences", () => {
assert.deepEqual(Object.keys(presets.renderGraphPresets), order); assert.deepEqual(Object.keys(presets.renderGraphPresets), order);
@@ -150,6 +147,9 @@ test("presets have the exact canonical pipeline identities, schemas, and node se
"preset_hdr_fullscreen", "preset_hdr_fullscreen",
"preset_gpu_culling", "preset_gpu_culling",
"preset_tone", "preset_tone",
"preset_contain",
"preset_reinhard",
"preset_linear",
"preset_grading", "preset_grading",
"preset_edges", "preset_edges",
"preset_bloom", "preset_bloom",
@@ -282,16 +282,19 @@ test("presets preserve common mesh, texture, query, pipeline, culling and post w
node: "cull", node: "cull",
socket: "isFrustumCulled", socket: "isFrustumCulled",
}); });
for (const name of ["tone", "grading", "edges", "bloom", "combined"]) for (const name of ["tone", "contain", "reinhard", "linear", "grading", "edges", "bloom", "combined"])
assert.ok(!presets[name].nodes.some((node) => node.id === "copy")); assert.ok(!presets[name].nodes.some((node) => node.id === "copy"));
const finalSource = { const finalSource = {
hdr: "pbr_double", hdr: "pbr_double",
culling: "pbr_double", culling: "pbr_double",
tone: "tone", tone: "pbr_double",
grading: "tone", contain: "pbr_double",
edges: "tone", reinhard: "pbr_double",
bloom: "tone", linear: "pbr_double",
combined: "tone", grading: "mixer",
edges: "edges",
bloom: "composite",
combined: "edges",
midnight: "pbr_double", midnight: "pbr_double",
ember: "pbr_double", ember: "pbr_double",
}; };
@@ -300,21 +303,4 @@ test("presets preserve common mesh, texture, query, pipeline, culling and post w
node: finalSource[name], node: finalSource[name],
socket: "color", 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",
);
}); });