feat: resolve frame out surface formats

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 17:55:20 +00:00
co-authored by heaust
parent fd85b3d051
commit c7ffb0543e
10 changed files with 802 additions and 111 deletions
+2
View File
@@ -40,6 +40,7 @@ struct PipelineParameters {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct FrameOutParameters { struct FrameOutParameters {
surface_format: SurfaceFormatRequest,
hdr_enabled: bool, hdr_enabled: bool,
tone_mapper: ToneMapper, tone_mapper: ToneMapper,
exposure_stops: f32, exposure_stops: f32,
@@ -544,6 +545,7 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
&format!("{base}.backgroundColor"), &format!("{base}.backgroundColor"),
)?; )?;
NormalizedParameters::FrameOut { NormalizedParameters::FrameOut {
surface_format: p.surface_format,
dynamic_range: if p.hdr_enabled { dynamic_range: if p.hdr_enabled {
FrameDynamicRange::Hdr { FrameDynamicRange::Hdr {
tone_mapper: p.tone_mapper, tone_mapper: p.tone_mapper,
+1 -1
View File
@@ -408,7 +408,7 @@ pub static CONTRACTS: &[Contract] = &[
}, },
Contract { Contract {
key: "frame_out", key: "frame_out",
version: 2, version: 3,
execution: ExecutionClass::Frame, execution: ExecutionClass::Frame,
inputs: FRAME_OUT_IN, inputs: FRAME_OUT_IN,
outputs: NONE_OUT, outputs: NONE_OUT,
+10
View File
@@ -256,6 +256,7 @@ pub enum NormalizedParameters {
strength: f32, strength: f32,
}, },
FrameOut { FrameOut {
surface_format: SurfaceFormatRequest,
dynamic_range: FrameDynamicRange, dynamic_range: FrameDynamicRange,
output_transfer: OutputTransfer, output_transfer: OutputTransfer,
scale_mode: ScaleMode, scale_mode: ScaleMode,
@@ -264,6 +265,15 @@ pub enum NormalizedParameters {
}, },
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SurfaceFormatRequest {
Preferred,
Rgba8Unorm,
Bgra8Unorm,
Rgba16Float,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, serde::Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum FrameDynamicRange { pub enum FrameDynamicRange {
+174 -6
View File
@@ -26,7 +26,131 @@ pub struct RuntimeSurfaceContract {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub usage: wgpu::TextureUsages, pub usage: wgpu::TextureUsages,
pub present_mode: wgpu::PresentMode,
pub alpha_mode: wgpu::CompositeAlphaMode,
pub view_formats: Vec<wgpu::TextureFormat>, pub view_formats: Vec<wgpu::TextureFormat>,
pub desired_maximum_frame_latency: u32,
}
fn frame_out_surface_request(
graph: &CompiledGraph,
) -> Result<(SurfaceFormatRequest, u32), GraphError> {
graph
.executions
.iter()
.find_map(|execution| match execution.parameters {
NormalizedParameters::FrameOut { surface_format, .. } => {
Some((surface_format, execution.original_node_index))
}
_ => None,
})
.ok_or_else(|| {
error(
"GRAPH_EXECUTION_UNSUPPORTED",
"exactly one frame_out is required",
"executions",
)
})
}
fn surface_format_path(original_node_index: u32) -> String {
format!("nodes[{original_node_index}].parameters.surfaceFormat")
}
pub fn resolve_graph_surface_contract(
graph: &CompiledGraph,
capabilities: &wgpu::SurfaceCapabilities,
width: u32,
height: u32,
) -> Result<RuntimeSurfaceContract, GraphError> {
let (request, frame_out_index) = frame_out_surface_request(graph)?;
resolve_surface_contract(
request,
capabilities,
width,
height,
&surface_format_path(frame_out_index),
)
}
/// Resolves the authored surface request against current adapter/surface capabilities.
/// The presentation policy is intentionally fixed and is not graph-authored.
pub fn resolve_surface_contract(
request: SurfaceFormatRequest,
capabilities: &wgpu::SurfaceCapabilities,
width: u32,
height: u32,
authored_path: &str,
) -> Result<RuntimeSurfaceContract, GraphError> {
if width == 0 || height == 0 {
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"surface extent is zero",
"surface",
));
}
if !capabilities
.usages
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
{
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"surface lacks render attachment usage",
"surface.usage",
));
}
if !capabilities
.present_modes
.contains(&wgpu::PresentMode::Fifo)
{
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"fixed surface present mode is unsupported",
"surface",
));
}
if !capabilities
.alpha_modes
.contains(&wgpu::CompositeAlphaMode::Opaque)
{
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"fixed surface alpha mode is unsupported",
"surface",
));
}
let requested = match request {
SurfaceFormatRequest::Preferred => capabilities.formats.iter().copied().find(|format| {
matches!(
format,
wgpu::TextureFormat::Rgba8Unorm
| wgpu::TextureFormat::Bgra8Unorm
| wgpu::TextureFormat::Rgba16Float
)
}),
SurfaceFormatRequest::Rgba8Unorm => Some(wgpu::TextureFormat::Rgba8Unorm),
SurfaceFormatRequest::Bgra8Unorm => Some(wgpu::TextureFormat::Bgra8Unorm),
SurfaceFormatRequest::Rgba16Float => Some(wgpu::TextureFormat::Rgba16Float),
};
let format = requested
.filter(|format| capabilities.formats.contains(format))
.ok_or_else(|| {
error(
"GRAPH_SURFACE_INCOMPATIBLE",
"requested surface format is unsupported",
authored_path,
)
})?;
Ok(RuntimeSurfaceContract {
format,
width,
height,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
present_mode: wgpu::PresentMode::Fifo,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
view_formats: vec![],
desired_maximum_frame_latency: 2,
})
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -1467,6 +1591,7 @@ pub fn prepare_runtime_plan(
let frame_out = &graph.executions[frame_out_index]; let frame_out = &graph.executions[frame_out_index];
let NormalizedParameters::FrameOut { let NormalizedParameters::FrameOut {
surface_format,
dynamic_range, dynamic_range,
output_transfer, output_transfer,
scale_mode: _, scale_mode: _,
@@ -1479,6 +1604,22 @@ pub fn prepare_runtime_plan(
format!("executions[{frame_out_index}].parameters"), format!("executions[{frame_out_index}].parameters"),
)); ));
}; };
let requested_format = match surface_format {
SurfaceFormatRequest::Preferred => None,
SurfaceFormatRequest::Rgba8Unorm => Some(wgpu::TextureFormat::Rgba8Unorm),
SurfaceFormatRequest::Bgra8Unorm => Some(wgpu::TextureFormat::Bgra8Unorm),
SurfaceFormatRequest::Rgba16Float => Some(wgpu::TextureFormat::Rgba16Float),
};
if requested_format.is_some_and(|format| format != surface.format) {
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"resolved surface format does not match frame output request",
format!(
"nodes[{}].parameters.surfaceFormat",
frame_out.original_node_index
),
));
}
let parameters_valid = background_color let parameters_valid = background_color
.iter() .iter()
.all(|v| v.is_finite() && (0.0..=1.0).contains(v)) .all(|v| v.is_finite() && (0.0..=1.0).contains(v))
@@ -1836,12 +1977,39 @@ pub fn prepare_runtime_plan(
} }
pub fn validate_activatable(graph: &CompiledGraph) -> Result<(), GraphError> { pub fn validate_activatable(graph: &CompiledGraph) -> Result<(), GraphError> {
let surface = RuntimeSurfaceContract { // Runtime validation must diagnose noncanonical compiled plans before the
format: wgpu::TextureFormat::Bgra8Unorm, // live-capability resolver. The validator below remains authoritative for
width: 1, // missing or duplicated Frame Out work, so a missing request gets a
height: 1, // harmless synthetic default solely for constructing test capabilities.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, let (request, frame_out_index) = graph
view_formats: Vec::new(), .executions
.iter()
.find_map(|execution| match execution.parameters {
NormalizedParameters::FrameOut { surface_format, .. } => {
Some((surface_format, execution.original_node_index))
}
_ => None,
})
.unwrap_or((SurfaceFormatRequest::Preferred, 0));
let format = match request {
SurfaceFormatRequest::Preferred | SurfaceFormatRequest::Bgra8Unorm => {
wgpu::TextureFormat::Bgra8Unorm
}
SurfaceFormatRequest::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm,
SurfaceFormatRequest::Rgba16Float => wgpu::TextureFormat::Rgba16Float,
}; };
let capabilities = wgpu::SurfaceCapabilities {
formats: vec![format],
present_modes: vec![wgpu::PresentMode::Fifo],
alpha_modes: vec![wgpu::CompositeAlphaMode::Opaque],
usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
};
let surface = resolve_surface_contract(
request,
&capabilities,
1,
1,
&surface_format_path(frame_out_index),
)?;
prepare_runtime_plan(graph, surface, None).map(|_| ()) prepare_runtime_plan(graph, surface, None).map(|_| ())
} }
+182 -7
View File
@@ -8,9 +8,9 @@ fn input(node: &str, socket: &str) -> Value {
} }
fn node(id: &str, key: &str, mut parameters: Value, inputs: Value) -> Value { fn node(id: &str, key: &str, mut parameters: Value, inputs: Value) -> Value {
if key == "frame_out" && parameters.as_object().is_some_and(|p| p.is_empty()) { 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]}); parameters = json!({"surfaceFormat":"preferred","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}) json!({"id":id,"state":"enabled","executor":{"key":key,"version":if key == "frame_out" { 3 } 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})
@@ -850,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", 2), ("frame_out", 3),
] ]
); );
assert_eq!( assert_eq!(
@@ -2971,14 +2971,15 @@ fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() {
} }
fn frame_parameters(hdr: bool) -> Value { fn frame_parameters(hdr: bool) -> Value {
json!({"hdrEnabled":hdr,"toneMapper":"reinhard","exposureStops":2, json!({"surfaceFormat":"preferred","hdrEnabled":hdr,"toneMapper":"reinhard","exposureStops":2,
"outputTransfer":"srgb","scaleMode":"contain","filter":"nearest", "outputTransfer":"srgb","scaleMode":"contain","filter":"nearest",
"backgroundColor":[0.1,0.2,0.3,0.4]}) "backgroundColor":[0.1,0.2,0.3,0.4]})
} }
#[test] #[test]
fn frame_out_v2_has_exact_seven_fields_normalizes_sdr_and_rejects_v1() { fn frame_out_v3_has_exact_eight_fields_normalizes_sdr_and_rejects_v2() {
let fields = [ let fields = [
"surfaceFormat",
"hdrEnabled", "hdrEnabled",
"toneMapper", "toneMapper",
"exposureStops", "exposureStops",
@@ -3033,7 +3034,7 @@ fn frame_out_v2_has_exact_seven_fields_normalizes_sdr_and_rejects_v1() {
)); ));
let mut g = full_cull_graph(); let mut g = full_cull_graph();
let i = node_index(&g, "frame_out"); let i = node_index(&g, "frame_out");
g["nodes"][i]["executor"]["version"] = json!(1); g["nodes"][i]["executor"]["version"] = json!(2);
assert_eq!(compile_error(g).code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED"); assert_eq!(compile_error(g).code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED");
} }
@@ -3044,7 +3045,10 @@ fn frame_out_source_format_matrix_is_exact() {
width: 1280, width: 1280,
height: 720, height: 720,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
present_mode: wgpu::PresentMode::Fifo,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
view_formats: vec![], view_formats: vec![],
desired_maximum_frame_latency: 2,
}; };
for (format, sdr, hdr) in [ for (format, sdr, hdr) in [
("rgba8_unorm", true, false), ("rgba8_unorm", true, false),
@@ -3092,6 +3096,174 @@ fn frame_out_source_format_matrix_is_exact() {
} }
} }
#[test]
fn surface_contract_resolution_is_capability_driven_and_policy_is_fixed() {
let capabilities = wgpu::SurfaceCapabilities {
formats: vec![
wgpu::TextureFormat::Bgra8UnormSrgb,
wgpu::TextureFormat::Rgba16Float,
wgpu::TextureFormat::Bgra8Unorm,
],
present_modes: vec![wgpu::PresentMode::Immediate, wgpu::PresentMode::Fifo],
alpha_modes: vec![
wgpu::CompositeAlphaMode::PreMultiplied,
wgpu::CompositeAlphaMode::Opaque,
],
usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
};
let preferred = resolve_surface_contract(
SurfaceFormatRequest::Preferred,
&capabilities,
640,
480,
"format",
)
.unwrap();
assert_eq!(preferred.format, wgpu::TextureFormat::Rgba16Float);
assert_eq!(preferred.present_mode, wgpu::PresentMode::Fifo);
assert_eq!(preferred.alpha_mode, wgpu::CompositeAlphaMode::Opaque);
assert_eq!(preferred.desired_maximum_frame_latency, 2);
assert_eq!((preferred.width, preferred.height), (640, 480));
assert!(resolve_surface_contract(
SurfaceFormatRequest::Bgra8Unorm,
&capabilities,
1,
1,
"format"
)
.is_ok());
for (width, height) in [(0, 1), (1, 0)] {
assert_eq!(
resolve_surface_contract(
SurfaceFormatRequest::Preferred,
&capabilities,
width,
height,
"format",
)
.unwrap_err()
.details["path"],
"surface"
);
}
let no_attachment = wgpu::SurfaceCapabilities {
formats: capabilities.formats.clone(),
present_modes: capabilities.present_modes.clone(),
alpha_modes: capabilities.alpha_modes.clone(),
usages: wgpu::TextureUsages::COPY_DST,
};
assert_eq!(
resolve_surface_contract(
SurfaceFormatRequest::Preferred,
&no_attachment,
1,
1,
"format",
)
.unwrap_err()
.details["path"],
"surface.usage"
);
for request in [
SurfaceFormatRequest::Rgba8Unorm,
SurfaceFormatRequest::Preferred,
] {
let caps = wgpu::SurfaceCapabilities {
formats: vec![wgpu::TextureFormat::Bgra8UnormSrgb],
present_modes: capabilities.present_modes.clone(),
alpha_modes: capabilities.alpha_modes.clone(),
usages: capabilities.usages,
};
assert_eq!(
resolve_surface_contract(request, &caps, 1, 1, "format")
.unwrap_err()
.code,
"GRAPH_SURFACE_INCOMPATIBLE"
);
}
for caps in [
wgpu::SurfaceCapabilities {
formats: capabilities.formats.clone(),
present_modes: vec![wgpu::PresentMode::Immediate],
alpha_modes: capabilities.alpha_modes.clone(),
usages: capabilities.usages,
},
wgpu::SurfaceCapabilities {
formats: capabilities.formats.clone(),
present_modes: capabilities.present_modes.clone(),
alpha_modes: vec![wgpu::CompositeAlphaMode::PreMultiplied],
usages: capabilities.usages,
},
] {
assert_eq!(
resolve_surface_contract(SurfaceFormatRequest::Preferred, &caps, 1, 1, "format")
.unwrap_err()
.code,
"GRAPH_SURFACE_INCOMPATIBLE"
);
}
for (present_modes, alpha_modes, message) in [
(
vec![wgpu::PresentMode::Immediate],
vec![wgpu::CompositeAlphaMode::Opaque],
"fixed surface present mode is unsupported",
),
(
vec![wgpu::PresentMode::Fifo],
vec![wgpu::CompositeAlphaMode::PreMultiplied],
"fixed surface alpha mode is unsupported",
),
] {
let caps = wgpu::SurfaceCapabilities {
formats: vec![wgpu::TextureFormat::Bgra8UnormSrgb],
present_modes,
alpha_modes,
usages: capabilities.usages,
};
let error = resolve_surface_contract(
SurfaceFormatRequest::Rgba8Unorm,
&caps,
1,
1,
"nodes[7].parameters.surfaceFormat",
)
.unwrap_err();
assert_eq!(error.code, "GRAPH_SURFACE_INCOMPATIBLE");
assert_eq!(error.details["path"], "surface");
assert_eq!(error.message, message);
}
}
#[test]
fn graph_surface_resolution_uses_canonical_authored_path_and_all_requests_activate() {
for (request, format) in [
("preferred", wgpu::TextureFormat::Bgra8Unorm),
("rgba8_unorm", wgpu::TextureFormat::Rgba8Unorm),
("bgra8_unorm", wgpu::TextureFormat::Bgra8Unorm),
("rgba16_float", wgpu::TextureFormat::Rgba16Float),
] {
let mut authored = full_cull_graph();
let index = node_index(&authored, "frame_out");
authored["nodes"][index]["parameters"]["surfaceFormat"] = json!(request);
let graph = compile_graph(authored);
validate_activatable(&graph).unwrap();
let caps = wgpu::SurfaceCapabilities {
formats: vec![format],
present_modes: vec![wgpu::PresentMode::Fifo],
alpha_modes: vec![wgpu::CompositeAlphaMode::Opaque],
usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
};
resolve_graph_surface_contract(&graph, &caps, 1, 1).unwrap();
let mut unsupported = caps;
unsupported.formats.clear();
let error = resolve_graph_surface_contract(&graph, &unsupported, 1, 1).unwrap_err();
assert_eq!(
error.details["path"],
format!("nodes[{index}].parameters.surfaceFormat")
);
}
}
#[test] #[test]
fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() { fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() {
let baseline = compile_graph(full_cull_graph()); let baseline = compile_graph(full_cull_graph());
@@ -3100,7 +3272,7 @@ fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations()
.iter() .iter()
.position(|e| e.executor.key == "frame_out") .position(|e| e.executor.key == "frame_out")
.unwrap(); .unwrap();
for version in [1, 3] { for version in [1, 2, 4] {
let mut g = baseline.clone(); let mut g = baseline.clone();
g.executions[i].executor.version = version; g.executions[i].executor.version = version;
assert_runtime_path(&g, format!("executions[{i}].executor.version")); assert_runtime_path(&g, format!("executions[{i}].executor.version"));
@@ -3187,7 +3359,10 @@ fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations()
width: 1280, width: 1280,
height: 720, height: 720,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
present_mode: wgpu::PresentMode::Fifo,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
view_formats: vec![], view_formats: vec![],
desired_maximum_frame_latency: 2,
}; };
prepare_runtime_plan(&hdr_to_sdr, linear_surface.clone(), None).unwrap(); prepare_runtime_plan(&hdr_to_sdr, linear_surface.clone(), None).unwrap();
let error = prepare_runtime_plan( let error = prepare_runtime_plan(
+409 -79
View File
@@ -154,6 +154,7 @@ fn pack_frame_out_uniforms(
) -> Option<FullscreenUniforms> { ) -> Option<FullscreenUniforms> {
use crate::render_graph::*; use crate::render_graph::*;
let NormalizedParameters::FrameOut { let NormalizedParameters::FrameOut {
surface_format: _,
dynamic_range, dynamic_range,
output_transfer, output_transfer,
scale_mode, scale_mode,
@@ -301,7 +302,10 @@ mod fullscreen_tests {
width: 1920, width: 1920,
height: 1080, height: 1080,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
present_mode: wgpu::PresentMode::Fifo,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
view_formats: vec![], view_formats: vec![],
desired_maximum_frame_latency: 2,
} }
} }
@@ -312,6 +316,7 @@ mod fullscreen_tests {
filter: FrameFilter, filter: FrameFilter,
) -> NormalizedParameters { ) -> NormalizedParameters {
NormalizedParameters::FrameOut { NormalizedParameters::FrameOut {
surface_format: SurfaceFormatRequest::Preferred,
dynamic_range, dynamic_range,
output_transfer: transfer, output_transfer: transfer,
scale_mode, scale_mode,
@@ -775,6 +780,57 @@ enum UploadGraph {
Compiled(crate::render_graph::MeshQueryRuntimeKey), Compiled(crate::render_graph::MeshQueryRuntimeKey),
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FrameTargetSource {
PendingSwitch,
PendingResize,
Active,
Immediate,
}
fn select_frame_target_source(
pending_switch: bool,
pending_resize: bool,
active: bool,
) -> FrameTargetSource {
if pending_switch {
FrameTargetSource::PendingSwitch
} else if pending_resize {
FrameTargetSource::PendingResize
} else if active {
FrameTargetSource::Active
} else {
FrameTargetSource::Immediate
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AcquisitionAction {
RejectSwitch,
DropResize,
ReconfigureAndSkip,
Skip,
Halt,
}
fn acquisition_action(source: FrameTargetSource, error: &wgpu::SurfaceError) -> AcquisitionAction {
if matches!(error, wgpu::SurfaceError::OutOfMemory) {
return AcquisitionAction::Halt;
}
match source {
FrameTargetSource::PendingSwitch => AcquisitionAction::RejectSwitch,
FrameTargetSource::PendingResize => AcquisitionAction::DropResize,
FrameTargetSource::Active | FrameTargetSource::Immediate => match error {
wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated => {
AcquisitionAction::ReconfigureAndSkip
}
wgpu::SurfaceError::Timeout => AcquisitionAction::Skip,
wgpu::SurfaceError::Other => AcquisitionAction::Halt,
wgpu::SurfaceError::OutOfMemory => unreachable!(),
},
}
}
fn classify_upload_graph(graph: &ActiveCompiledGraph) -> UploadGraph { fn classify_upload_graph(graph: &ActiveCompiledGraph) -> UploadGraph {
UploadGraph::Compiled(graph.runtime.allocations.query) UploadGraph::Compiled(graph.runtime.allocations.query)
} }
@@ -890,6 +946,29 @@ fn resolve_switch_request(
} }
} }
fn drop_graph_request(
registry: &mut crate::render_graph::Registry,
id: crate::render_graph::CompiledGraphId,
active: Option<crate::render_graph::CompiledGraphId>,
pending_switch: Option<crate::render_graph::CompiledGraphId>,
pending_resize: Option<crate::render_graph::CompiledGraphId>,
in_flight: Option<crate::render_graph::CompiledGraphId>,
) -> Result<(), crate::render_graph::GraphError> {
if active == Some(id) {
Err(crate::render_graph::GraphError::new(
"GRAPH_ACTIVE",
"compiled graph is active",
))
} else if [pending_switch, pending_resize, in_flight].contains(&Some(id)) {
Err(crate::render_graph::GraphError::new(
"GRAPH_SWITCH_PENDING",
"compiled graph switch is pending",
))
} else {
registry.drop_graph(id)
}
}
#[cfg(test)] #[cfg(test)]
mod switch_request_tests { mod switch_request_tests {
fn valid_compile_graph(graph_id: &str, revision: u64) -> Vec<u8> { fn valid_compile_graph(graph_id: &str, revision: u64) -> Vec<u8> {
@@ -934,6 +1013,55 @@ mod switch_request_tests {
assert_eq!(selected(Some(query(Any)), None), Some(Any)); assert_eq!(selected(Some(query(Any)), None), Some(Any));
} }
#[test]
fn frame_target_precedence_and_pending_resize_upload_are_exact() {
use crate::render_graph::RuntimePredicate::{RequiredFalse, RequiredTrue};
assert_eq!(
select_frame_target_source(true, true, true),
FrameTargetSource::PendingSwitch
);
assert_eq!(
select_frame_target_source(false, true, true),
FrameTargetSource::PendingResize
);
assert_eq!(
select_frame_target_source(false, false, true),
FrameTargetSource::Active
);
assert_eq!(
select_frame_target_source(false, false, false),
FrameTargetSource::Immediate
);
let pending_resize = query(RequiredFalse);
let active = query(RequiredTrue);
assert_eq!(
upload_query_for_render(Some(pending_resize), Some(active))
.unwrap()
.visible,
RequiredFalse
);
}
#[test]
fn acquisition_policy_covers_every_source_and_surface_error() {
use wgpu::SurfaceError::*;
use AcquisitionAction::*;
use FrameTargetSource::*;
for error in [Lost, Outdated, Timeout, Other] {
assert_eq!(acquisition_action(PendingSwitch, &error), RejectSwitch);
assert_eq!(acquisition_action(PendingResize, &error), DropResize);
}
for source in [Active, Immediate] {
assert_eq!(acquisition_action(source, &Lost), ReconfigureAndSkip);
assert_eq!(acquisition_action(source, &Outdated), ReconfigureAndSkip);
assert_eq!(acquisition_action(source, &Timeout), Skip);
assert_eq!(acquisition_action(source, &Other), Halt);
}
for source in [PendingSwitch, PendingResize, Active, Immediate] {
assert_eq!(acquisition_action(source, &OutOfMemory), Halt);
}
}
#[test] #[test]
fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() { fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() {
use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue}; use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue};
@@ -1000,6 +1128,24 @@ mod switch_request_tests {
assert_eq!(registry.get(id).unwrap_err().code, "STALE_GRAPH_ID"); assert_eq!(registry.get(id).unwrap_err().code, "STALE_GRAPH_ID");
} }
#[test]
fn pending_resize_keeps_registry_ownership_through_commit() {
let mut registry = crate::render_graph::Registry::default();
let (id, _) = registry.compile(&valid_compile_graph("resize", 1)).unwrap();
let error = drop_graph_request(&mut registry, id, None, None, Some(id), None)
.expect_err("a completed resize candidate still owns its registry entry");
assert_eq!(error.code, "GRAPH_SWITCH_PENDING");
assert!(registry.contains(id));
let active = Some(id);
assert_eq!(registry.get(active.unwrap()).unwrap().revision, 1);
let (other, _) = registry.compile(&valid_compile_graph("other", 1)).unwrap();
drop_graph_request(&mut registry, other, active, None, None, None).unwrap();
assert_eq!(registry.get(other).unwrap_err().code, "STALE_GRAPH_ID");
}
#[test] #[test]
fn resize_restart_snapshot_remains_bound_to_its_immutable_registry_revision() { fn resize_restart_snapshot_remains_bound_to_its_immutable_registry_revision() {
let mut registry = crate::render_graph::Registry::default(); let mut registry = crate::render_graph::Registry::default();
@@ -1098,10 +1244,12 @@ fn render_data_error_code(error: &crate::render_data::RenderDataError) -> &'stat
} }
pub struct RendererContext { pub struct RendererContext {
pub adapter: wgpu::Adapter,
pub device: wgpu::Device, pub device: wgpu::Device,
pub queue: wgpu::Queue, pub queue: wgpu::Queue,
pub surface_config: wgpu::SurfaceConfiguration, pub surface_config: wgpu::SurfaceConfiguration,
pub surface: wgpu::Surface<'static>, pub surface: wgpu::Surface<'static>,
initial_surface_config: wgpu::SurfaceConfiguration,
pub depth_texture: wgpu::Texture, pub depth_texture: wgpu::Texture,
pub depth_view: wgpu::TextureView, pub depth_view: wgpu::TextureView,
} }
@@ -1125,6 +1273,7 @@ pub struct Renderer<T: scene::Scene> {
graph_registry: crate::render_graph::Registry, graph_registry: crate::render_graph::Registry,
active_compiled: Option<ActiveCompiledGraph>, active_compiled: Option<ActiveCompiledGraph>,
pending_switch: Option<PendingSwitch>, pending_switch: Option<PendingSwitch>,
pending_resize: Option<ActiveCompiledGraph>,
in_flight: Option<InFlightPreparation>, in_flight: Option<InFlightPreparation>,
next_preparation_token: u64, next_preparation_token: u64,
preparation_completions: Rc<RefCell<Vec<PreparationCompletion>>>, preparation_completions: Rc<RefCell<Vec<PreparationCompletion>>>,
@@ -1133,6 +1282,28 @@ pub struct Renderer<T: scene::Scene> {
} }
impl<T: Scene + 'static> Renderer<T> { impl<T: Scene + 'static> Renderer<T> {
fn surface_config(
contract: &crate::render_graph::RuntimeSurfaceContract,
) -> wgpu::SurfaceConfiguration {
wgpu::SurfaceConfiguration {
usage: contract.usage,
format: contract.format,
width: contract.width,
height: contract.height,
present_mode: contract.present_mode,
alpha_mode: contract.alpha_mode,
view_formats: contract.view_formats.clone(),
desired_maximum_frame_latency: contract.desired_maximum_frame_latency,
}
}
fn configure_surface(&mut self, config: wgpu::SurfaceConfiguration) {
self.context
.surface
.configure(&self.context.device, &config);
self.context.surface_config = config;
}
fn reply(&mut self, request: u32, result: Result<JsValue, CommandError>) { fn reply(&mut self, request: u32, result: Result<JsValue, CommandError>) {
let (ok, code, value, details) = match result { let (ok, code, value, details) = match result {
Ok(value) => (true, "OK", value, JsValue::UNDEFINED), Ok(value) => (true, "OK", value, JsValue::UNDEFINED),
@@ -1187,23 +1358,20 @@ impl<T: Scene + 'static> Renderer<T> {
slot: words[2], slot: words[2],
generation: words[3], generation: words[3],
}; };
let outcome = let outcome = drop_graph_request(
if self.active_compiled.as_ref().is_some_and(|a| a.id() == id) { &mut self.graph_registry,
Err(crate::render_graph::GraphError::new( id,
"GRAPH_ACTIVE", self.active_compiled.as_ref().map(ActiveCompiledGraph::id),
"compiled graph is active", self.pending_switch.as_ref().and_then(|pending| {
)) if let SwitchTarget::Compiled(active) = &pending.target {
} else if self.pending_switch.as_ref().is_some_and( Some(active.id())
|p| matches!(&p.target, SwitchTarget::Compiled(a) if a.id() == id),
) || self.in_flight.as_ref().is_some_and(|p| p.id == id)
{
Err(crate::render_graph::GraphError::new(
"GRAPH_SWITCH_PENDING",
"compiled graph switch is pending",
))
} else { } else {
self.graph_registry.drop_graph(id) None
}; }
}),
self.pending_resize.as_ref().map(ActiveCompiledGraph::id),
self.in_flight.as_ref().map(|preparation| preparation.id),
);
match outcome { match outcome {
Ok(()) => self.reply(request, Ok(JsValue::UNDEFINED)), Ok(()) => self.reply(request, Ok(JsValue::UNDEFINED)),
Err(error) => self.reply(request, Err(error.into())), Err(error) => self.reply(request, Err(error.into())),
@@ -1411,14 +1579,14 @@ impl<T: Scene + 'static> Renderer<T> {
"gltf_standard", "gltf_standard",
&layout, &layout,
include_str!("../gltf.wgsl"), include_str!("../gltf.wgsl"),
context.surface_config.format, context.initial_surface_config.format,
); );
let double_sided = resources.get_or_create_pipeline( let double_sided = resources.get_or_create_pipeline(
&context.device, &context.device,
"gltf_standard_double_sided", "gltf_standard_double_sided",
&layout, &layout,
include_str!("../gltf.wgsl"), include_str!("../gltf.wgsl"),
context.surface_config.format, context.initial_surface_config.format,
); );
[culled, double_sided] [culled, double_sided]
} }
@@ -1426,16 +1594,19 @@ impl<T: Scene + 'static> Renderer<T> {
fn plan_compiled( fn plan_compiled(
&self, &self,
graph: &crate::render_graph::CompiledGraph, graph: &crate::render_graph::CompiledGraph,
width: u32,
height: u32,
) -> Result<crate::render_graph::RuntimePlan, crate::render_graph::GraphError> { ) -> Result<crate::render_graph::RuntimePlan, crate::render_graph::GraphError> {
let capabilities = self.context.surface.get_capabilities(&self.context.adapter);
let surface = crate::render_graph::resolve_graph_surface_contract(
graph,
&capabilities,
width,
height,
)?;
crate::render_graph::prepare_runtime_plan( crate::render_graph::prepare_runtime_plan(
graph, graph,
crate::render_graph::RuntimeSurfaceContract { surface,
format: self.context.surface_config.format,
width: self.context.surface_config.width,
height: self.context.surface_config.height,
usage: self.context.surface_config.usage,
view_formats: self.context.surface_config.view_formats.clone(),
},
Some(&self.context.device.limits()), Some(&self.context.device.limits()),
) )
} }
@@ -1860,7 +2031,21 @@ impl<T: Scene + 'static> Renderer<T> {
graph: crate::render_graph::CompiledGraph, graph: crate::render_graph::CompiledGraph,
purpose: PreparationPurpose, purpose: PreparationPurpose,
) -> Result<(), crate::render_graph::GraphError> { ) -> Result<(), crate::render_graph::GraphError> {
let runtime = self.plan_compiled(&graph)?; let runtime = self.plan_compiled(
&graph,
self.context.surface_config.width,
self.context.surface_config.height,
)?;
self.begin_compiled_preparation_with_runtime(id, graph, runtime, purpose)
}
fn begin_compiled_preparation_with_runtime(
&mut self,
id: crate::render_graph::CompiledGraphId,
graph: crate::render_graph::CompiledGraph,
runtime: crate::render_graph::RuntimePlan,
purpose: PreparationPurpose,
) -> Result<(), crate::render_graph::GraphError> {
// Candidate construction allocates GPU resources, so the live scene preflight // Candidate construction allocates GPU resources, so the live scene preflight
// belongs here: this is the earliest boundary with both the runtime query and // belongs here: this is the earliest boundary with both the runtime query and
// scene access, and precedes GPU work and all pending/in-flight mutation. // scene access, and precedes GPU work and all pending/in-flight mutation.
@@ -1932,7 +2117,7 @@ impl<T: Scene + 'static> Renderer<T> {
self.reply(request, Err(error.into())); self.reply(request, Err(error.into()));
} }
(PreparationPurpose::Resize, Ok(candidate)) => { (PreparationPurpose::Resize, Ok(candidate)) => {
self.active_compiled = Some(candidate); self.pending_resize = Some(candidate);
} }
(PreparationPurpose::Resize, Err(error)) => { (PreparationPurpose::Resize, Err(error)) => {
log::error!( log::error!(
@@ -2012,16 +2197,15 @@ impl<T: Scene + 'static> Renderer<T> {
})); }));
let surface_caps = surface.get_capabilities(&adapter); let surface_caps = surface.get_capabilities(&adapter);
let surface_config = wgpu::SurfaceConfiguration { let initial_contract = crate::render_graph::resolve_surface_contract(
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, crate::render_graph::SurfaceFormatRequest::Preferred,
format: surface_caps.formats[0], &surface_caps,
width: canvas.clone().width().max(1), canvas.clone().width().max(1),
height: canvas.clone().height().max(1), canvas.clone().height().max(1),
present_mode: surface_caps.present_modes[0], "initialSurfaceFormat",
alpha_mode: surface_caps.alpha_modes[0], )
view_formats: vec![], .expect("surface must support the fixed presentation contract");
desired_maximum_frame_latency: 2, let surface_config = Self::surface_config(&initial_contract);
};
info!( info!(
"suface size: {} x {}", "suface size: {} x {}",
surface_config.width, surface_config.height surface_config.width, surface_config.height
@@ -2032,9 +2216,11 @@ impl<T: Scene + 'static> Renderer<T> {
let mut resources = PipelineLibrary::new(); let mut resources = PipelineLibrary::new();
let context = RendererContext { let context = RendererContext {
adapter,
surface, surface,
device, device,
queue, queue,
initial_surface_config: surface_config.clone(),
surface_config, surface_config,
depth_texture, depth_texture,
depth_view, depth_view,
@@ -2066,6 +2252,7 @@ impl<T: Scene + 'static> Renderer<T> {
graph_registry: Default::default(), graph_registry: Default::default(),
active_compiled: None, active_compiled: None,
pending_switch: None, pending_switch: None,
pending_resize: None,
in_flight: None, in_flight: None,
next_preparation_token: 0, next_preparation_token: 0,
preparation_completions: Default::default(), preparation_completions: Default::default(),
@@ -2128,21 +2315,39 @@ impl<T: Scene + 'static> Renderer<T> {
SwitchTarget::Immediate => UploadGraph::Immediate, SwitchTarget::Immediate => UploadGraph::Immediate,
SwitchTarget::Compiled(graph) => classify_upload_graph(graph), SwitchTarget::Compiled(graph) => classify_upload_graph(graph),
}); });
let query = upload_query_for_render( let target_source = select_frame_target_source(
pending, self.pending_switch.is_some(),
self.active_compiled.as_ref().map(classify_upload_graph), self.pending_resize.is_some(),
self.active_compiled.is_some(),
); );
let selected = match target_source {
FrameTargetSource::PendingSwitch => pending,
FrameTargetSource::PendingResize => {
self.pending_resize.as_ref().map(classify_upload_graph)
}
FrameTargetSource::Active => self.active_compiled.as_ref().map(classify_upload_graph),
FrameTargetSource::Immediate => Some(UploadGraph::Immediate),
};
let query = upload_query_for_render(selected, None);
// Resolve again immediately before every active frame. Do this before scene // Resolve again immediately before every active frame. Do this before scene
// upload so an invalid camera cannot mutate GPU state or produce a frame. // upload so an invalid camera cannot mutate GPU state or produce a frame.
let planes = match update_validate_write_scene(&mut self.scene, &self.context.queue, query) let planes = match update_validate_write_scene(&mut self.scene, &self.context.queue, query)
{ {
Ok(planes) => planes, Ok(planes) => planes,
Err(error) => { Err(error) => {
if let Some(pending) = self.pending_switch.take() { match target_source {
FrameTargetSource::PendingSwitch => {
let pending = self.pending_switch.take().unwrap();
self.reply(pending.request, Err(error.into())); self.reply(pending.request, Err(error.into()));
} else { }
FrameTargetSource::PendingResize => {
self.pending_resize = None;
log::error!("compiled graph resize preflight failed: {}", error.message);
}
FrameTargetSource::Active | FrameTargetSource::Immediate => {
self.post_fatal("GRAPH_EXECUTION_FAILED", &error.message); self.post_fatal("GRAPH_EXECUTION_FAILED", &error.message);
} }
}
return; return;
} }
}; };
@@ -2167,10 +2372,68 @@ impl<T: Scene + 'static> Renderer<T> {
.write_culling_params(&self.context.queue, planes, query); .write_culling_params(&self.context.queue, planes, query);
} }
// Candidate publication is transactional: configure its complete contract at
// the last possible point before acquisition, but retain the known-good
// configuration and active graph identity until presentation succeeds.
let candidate_config = match target_source {
FrameTargetSource::PendingSwitch => match &self.pending_switch.as_ref().unwrap().target
{
SwitchTarget::Compiled(active) => Self::surface_config(&active.runtime.surface),
SwitchTarget::Immediate => {
let mut config = self.context.initial_surface_config.clone();
config.width = self.context.surface_config.width;
config.height = self.context.surface_config.height;
config
}
},
FrameTargetSource::PendingResize => {
Self::surface_config(&self.pending_resize.as_ref().unwrap().runtime.surface)
}
FrameTargetSource::Active | FrameTargetSource::Immediate => {
self.context.surface_config.clone()
}
};
let restore_config = (candidate_config != self.context.surface_config)
.then(|| self.context.surface_config.clone());
if restore_config.is_some() {
self.configure_surface(candidate_config);
}
let surface_texture = match self.context.surface.get_current_texture() { let surface_texture = match self.context.surface.get_current_texture() {
Ok(value) => value, Ok(value) => value,
Err(error) => { Err(error) => {
match acquisition_action(target_source, &error) {
AcquisitionAction::RejectSwitch => {
if let Some(config) = restore_config {
self.configure_surface(config);
}
let pending = self.pending_switch.take().unwrap();
self.reply(
pending.request,
Err(crate::render_graph::GraphError::new(
"GRAPH_SURFACE_RECONFIGURE_FAILED",
error.to_string(),
)
.into()),
);
}
AcquisitionAction::DropResize => {
if let Some(config) = restore_config {
self.configure_surface(config);
}
self.pending_resize = None;
log::error!("compiled graph resize acquisition failed: {error}");
}
AcquisitionAction::ReconfigureAndSkip => {
self.context
.surface
.configure(&self.context.device, &self.context.surface_config);
}
AcquisitionAction::Skip => log::warn!("surface acquisition timed out"),
AcquisitionAction::Halt => {
self.halted = true;
self.post_fatal("SURFACE_FRAME_FAILED", &error.to_string()); self.post_fatal("SURFACE_FRAME_FAILED", &error.to_string());
}
}
return; return;
} }
}; };
@@ -2182,10 +2445,15 @@ impl<T: Scene + 'static> Renderer<T> {
label: Some("Render command encoder"), label: Some("Render command encoder"),
}); });
let rendering_compiled = match self.pending_switch.as_ref().map(|p| &p.target) { let rendering_compiled = match target_source {
Some(SwitchTarget::Compiled(active)) => Some(active), FrameTargetSource::PendingSwitch => match &self.pending_switch.as_ref().unwrap().target
Some(SwitchTarget::Immediate) => None, {
None => self.active_compiled.as_ref(), SwitchTarget::Compiled(active) => Some(active),
SwitchTarget::Immediate => None,
},
FrameTargetSource::PendingResize => self.pending_resize.as_ref(),
FrameTargetSource::Active => self.active_compiled.as_ref(),
FrameTargetSource::Immediate => None,
}; };
let mut profile_frame = self.profiler.begin(|| match rendering_compiled { let mut profile_frame = self.profiler.begin(|| match rendering_compiled {
None => "immediate".to_owned(), None => "immediate".to_owned(),
@@ -2222,7 +2490,15 @@ impl<T: Scene + 'static> Renderer<T> {
if let Some(frame) = profile_frame.take() { if let Some(frame) = profile_frame.take() {
self.profiler.cancel(frame); self.profiler.cancel(frame);
} }
// A surface must have no acquired texture (or objects retaining its view)
// when configure is called to roll back a transactional candidate.
drop(encoder);
drop(texture_view);
drop(surface_texture);
if let Some(pending) = self.pending_switch.take() { if let Some(pending) = self.pending_switch.take() {
if let Some(config) = restore_config {
self.configure_surface(config);
}
self.reply( self.reply(
pending.request, pending.request,
Err( Err(
@@ -2230,6 +2506,10 @@ impl<T: Scene + 'static> Renderer<T> {
.into(), .into(),
), ),
); );
} else if self.pending_resize.take().is_some() {
if let Some(config) = restore_config {
self.configure_surface(config);
}
} }
return; return;
} }
@@ -2240,6 +2520,10 @@ impl<T: Scene + 'static> Renderer<T> {
} }
surface_texture.present(); surface_texture.present();
if let Some(pending) = self.pending_switch.take() { if let Some(pending) = self.pending_switch.take() {
// A successful user switch supersedes any resize recreation of the
// previously active graph. Retain that resize candidate only as a
// fallback while the switch is being prepared or attempted.
self.pending_resize = None;
let result = match pending.target { let result = match pending.target {
SwitchTarget::Immediate => { SwitchTarget::Immediate => {
self.active_compiled = None; self.active_compiled = None;
@@ -2258,6 +2542,8 @@ impl<T: Scene + 'static> Renderer<T> {
} }
}; };
self.reply(pending.request, Ok(result)); self.reply(pending.request, Ok(result));
} else if let Some(candidate) = self.pending_resize.take() {
self.active_compiled = Some(candidate);
} }
let global = js_sys::global().unchecked_into::<DedicatedWorkerGlobalScope>(); let global = js_sys::global().unchecked_into::<DedicatedWorkerGlobalScope>();
for reply in self.pending_replies.drain(..) { for reply in self.pending_replies.drain(..) {
@@ -2288,6 +2574,16 @@ impl<T: Scene + 'static> Renderer<T> {
("indices", index_count.into()), ("indices", index_count.into()),
("width", self.context.surface_config.width.into()), ("width", self.context.surface_config.width.into()),
("height", self.context.surface_config.height.into()), ("height", self.context.surface_config.height.into()),
(
"surfaceFormat",
match self.context.surface_config.format {
wgpu::TextureFormat::Rgba8Unorm => "rgba8_unorm",
wgpu::TextureFormat::Bgra8Unorm => "bgra8_unorm",
wgpu::TextureFormat::Rgba16Float => "rgba16_float",
_ => "unknown",
}
.into(),
),
("framingRadius", self.framing_radius.into()), ("framingRadius", self.framing_radius.into()),
( (
"renderMode", "renderMode",
@@ -2540,14 +2836,6 @@ impl<T: Scene + 'static> Renderer<T> {
if new_width != self.context.surface_config.width if new_width != self.context.surface_config.width
|| new_height != self.context.surface_config.height || new_height != self.context.surface_config.height
{ {
self.context.surface_config.width = new_width;
self.context.surface_config.height = new_height;
self.context
.surface
.configure(&self.context.device, &self.context.surface_config);
self.recreate_depth_texture();
// The executable subset uses surface-relative transients exclusively.
// Dropping old buckets prevents stale-size reuse and bounds resize growth.
if let Some(pending) = self.pending_switch.take() { if let Some(pending) = self.pending_switch.take() {
self.reply( self.reply(
pending.request, pending.request,
@@ -2575,38 +2863,80 @@ impl<T: Scene + 'static> Renderer<T> {
} }
PreparationPurpose::Resize => Some((preparation.id, preparation.graph)), PreparationPurpose::Resize => Some((preparation.id, preparation.graph)),
}); });
let mut restarted = false; let restore = self
if let Some(old) = self.active_compiled.take() { .active_compiled
let id = old.id(); .take()
// Keep immediate resources live and fall back for this frame if recreation fails. .map(|active| (active.id(), active.graph))
restarted = true; .or_else(|| {
self.begin_compiled_preparation(id, old.graph, PreparationPurpose::Resize) self.pending_resize
.unwrap_or_else(|error| { .take()
log::error!( .map(|active| (active.id(), active.graph))
"compiled graph resize preparation failed: {}", })
error.message .or(interrupted_resize);
)
});
}
if !restarted {
if let Some((id, graph)) = interrupted_resize {
if let Err(error) =
self.begin_compiled_preparation(id, graph, PreparationPurpose::Resize)
{
log::error!(
"compiled graph resize preparation failed: {}",
error.message
);
}
}
}
self.context.initial_surface_config.width = new_width;
self.context.initial_surface_config.height = new_height;
let initial_request = match self.context.initial_surface_config.format {
wgpu::TextureFormat::Rgba8Unorm => {
crate::render_graph::SurfaceFormatRequest::Rgba8Unorm
}
wgpu::TextureFormat::Bgra8Unorm => {
crate::render_graph::SurfaceFormatRequest::Bgra8Unorm
}
wgpu::TextureFormat::Rgba16Float => {
crate::render_graph::SurfaceFormatRequest::Rgba16Float
}
_ => {
self.halted = true;
self.post_fatal(
"SURFACE_FRAME_FAILED",
"immediate surface format is unsupported",
);
return;
}
};
let capabilities = self.context.surface.get_capabilities(&self.context.adapter);
if let Err(error) = crate::render_graph::resolve_surface_contract(
initial_request,
&capabilities,
new_width,
new_height,
"surface",
) {
self.halted = true;
self.post_fatal("SURFACE_FRAME_FAILED", &error.message);
return;
}
// Resize always establishes the exact immediate fallback first. Compiled
// configuration is transactional and is applied only by the commit frame.
self.configure_surface(self.context.initial_surface_config.clone());
self.recreate_depth_texture();
self.scene.resize( self.scene.resize(
new_width as f64, new_width as f64,
new_height as f64, new_height as f64,
msg.scale_factor, msg.scale_factor,
&self.context.queue, &self.context.queue,
); );
if let Some((id, graph)) = restore {
match self.plan_compiled(&graph, new_width, new_height) {
Ok(runtime) => {
if let Err(error) = self.begin_compiled_preparation_with_runtime(
id,
graph,
runtime,
PreparationPurpose::Resize,
) {
log::error!(
"compiled graph resize preparation failed: {}",
error.message
);
}
}
Err(error) => {
log::error!("compiled graph resize planning failed: {}", error.message)
}
}
}
info!( info!(
"Resized: ({}, {}), scale: {}", "Resized: ({}, {}), scale: {}",
+6 -3
View File
@@ -1,5 +1,5 @@
export const GRAPH_ID = "authored_gpu_culling"; export const GRAPH_ID = "authored_gpu_culling";
export const CATALOG_VERSION = 6; export const CATALOG_VERSION = 7;
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,
@@ -188,11 +188,11 @@ export const semanticCatalog = Object.freeze({
parameters: { strength: 2 }, parameters: { strength: 2 },
}, },
frame_out: { frame_out: {
version: 2, version: 3,
execution: "frame", execution: "frame",
inputs: { color: i("texture") }, inputs: { color: i("texture") },
outputs: {}, outputs: {},
parameters: { hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, parameters: { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] },
}, },
}); });
const socketColors = [ const socketColors = [
@@ -389,6 +389,7 @@ 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: {
surfaceFormat: enumeration("preferred", ["preferred", "rgba8_unorm", "bgra8_unorm", "rgba16_float"]),
hdrEnabled: boolean(true), hdrEnabled: boolean(true),
toneMapper: enumeration("aces", ["aces", "reinhard", "none"]), toneMapper: enumeration("aces", ["aces", "reinhard", "none"]),
exposureStops: number(0, -10, 10), exposureStops: number(0, -10, 10),
@@ -469,6 +470,8 @@ nodeDefinitions.color_balance.ui = [
{ 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 = [ nodeDefinitions.frame_out.ui = [
{ kind: "text", variant: "section", title: "Canvas Presentation" },
{ kind: "parameter", parameter: "surfaceFormat", title: "Surface Format" },
{ kind: "text", variant: "section", title: "Display Transform" }, { kind: "text", variant: "section", title: "Display Transform" },
{ kind: "parameter", parameter: "hdrEnabled", title: "HDR" }, { kind: "parameter", parameter: "hdrEnabled", title: "HDR" },
{ kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } }, { kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } },
+1 -1
View File
@@ -12,7 +12,7 @@ const texture = (format, scale = 1, heightScale = scale) => ({
}, },
residency: "transient", residency: "transient",
}); });
const frameOut = (hdr, options = {}) => ({ hdrEnabled: hdr, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1], ...options }); const frameOut = (hdr, options = {}) => ({ surfaceFormat: "preferred", hdrEnabled: hdr, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1], ...options });
const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1) => [ const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1) => [
node("hdr", "texture", texture("rgba16_float", 1, heightScale)), node("hdr", "texture", texture("rgba16_float", 1, heightScale)),
node("depth", "texture", texture("depth32_float", 1, heightScale)), node("depth", "texture", texture("depth32_float", 1, heightScale)),
+1 -1
View File
@@ -36,7 +36,7 @@ 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, 6); assert.equal(fxNodeComposition.version, 7);
assert.equal(Object.keys(fxNodeComposition.nodes).length, 16); assert.equal(Object.keys(fxNodeComposition.nodes).length, 16);
assert.ok( assert.ok(
Object.values(fxNodeComposition.nodes).every( Object.values(fxNodeComposition.nodes).every(
+12 -9
View File
@@ -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, key === "frame_out" ? 2 : 1); assert.equal(semantic.version, key === "frame_out" ? 3 : 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);
} }
@@ -133,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, 6); assert.equal(CATALOG_VERSION, 7);
assert.deepEqual(nodeDefinitions.pipeline.parameters, { assert.deepEqual(nodeDefinitions.pipeline.parameters, {
pipeline: { pipeline: {
type: "string", type: "string",
@@ -346,14 +346,15 @@ 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", () => { test("Frame Out has the exact v3 schema, defaults, UI, and strict authoring validation", () => {
const fields = ["hdrEnabled", "toneMapper", "exposureStops", "outputTransfer", "scaleMode", "filter", "backgroundColor"]; const fields = ["surfaceFormat", "hdrEnabled", "toneMapper", "exposureStops", "outputTransfer", "scaleMode", "filter", "backgroundColor"];
assert.equal(CATALOG_VERSION, 6); assert.equal(CATALOG_VERSION, 7);
assert.deepEqual(semanticCatalog.frame_out, { assert.deepEqual(semanticCatalog.frame_out, {
version: 2, execution: "frame", inputs: { color: semanticCatalog.frame_out.inputs.color }, outputs: {}, version: 3, 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] }, parameters: { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] },
}); });
assert.deepEqual(nodeDefinitions.frame_out.parameters, { assert.deepEqual(nodeDefinitions.frame_out.parameters, {
surfaceFormat: { type: "string", default: { kind: "string", value: "preferred" }, enum: ["preferred", "rgba8_unorm", "bgra8_unorm", "rgba16_float"] },
hdrEnabled: { type: "boolean", default: { kind: "boolean", value: true } }, hdrEnabled: { type: "boolean", default: { kind: "boolean", value: true } },
toneMapper: { type: "string", default: { kind: "string", value: "aces" }, enum: ["aces", "reinhard", "none"] }, toneMapper: { type: "string", default: { kind: "string", value: "aces" }, enum: ["aces", "reinhard", "none"] },
exposureStops: { type: "number", default: { kind: "number", value: 0 }, minimum: -10, maximum: 10 }, exposureStops: { type: "number", default: { kind: "number", value: 0 }, minimum: -10, maximum: 10 },
@@ -363,6 +364,8 @@ test("Frame Out has the exact v2 schema, defaults, UI, and strict authoring vali
backgroundColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] }, minimum: 0, maximum: 1 }, backgroundColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] }, minimum: 0, maximum: 1 },
}); });
assert.deepEqual(nodeDefinitions.frame_out.ui, [ assert.deepEqual(nodeDefinitions.frame_out.ui, [
{ kind: "text", variant: "section", title: "Canvas Presentation" },
{ kind: "parameter", parameter: "surfaceFormat", title: "Surface Format" },
{ kind: "text", variant: "section", title: "Display Transform" }, { kind: "text", variant: "section", title: "Display Transform" },
{ kind: "parameter", parameter: "hdrEnabled", title: "HDR" }, { kind: "parameter", parameter: "hdrEnabled", title: "HDR" },
{ kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } }, { kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } },
@@ -379,11 +382,11 @@ test("Frame Out has the exact v2 schema, defaults, UI, and strict authoring vali
assert.throws(() => adaptFxNodeSnapshot(x), (e) => e instanceof AuthoringGraphError && e.code === code && (!parameter || e.details.nodeId === n.id && e.details.parameter === parameter)); 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) => x.catalogVersion = 5, "AUTHORING_CATALOG");
reject((x, n) => n.typeVersion = 1, "AUTHORING_NODE_INVALID"); reject((x, n) => n.typeVersion = 2, "AUTHORING_NODE_INVALID");
for (const field of fields) reject((x, n) => delete n.parameters[field], "AUTHORING_PARAMETER_SET"); 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"); reject((x, n) => n.parameters.extra = { kind: "number", value: 0 }, "AUTHORING_PARAMETER_SET");
for (const [field, value] of [ for (const [field, value] of [
["hdrEnabled", 1], ["toneMapper", "bad"], ["outputTransfer", "bad"], ["scaleMode", "bad"], ["filter", "bad"], ["surfaceFormat", "bad"], ["hdrEnabled", 1], ["toneMapper", "bad"], ["outputTransfer", "bad"], ["scaleMode", "bad"], ["filter", "bad"],
["exposureStops", NaN], ["exposureStops", -10.01], ["exposureStops", 10.01], ["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]], ["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); ]) reject((x, n) => n.parameters[field].value = value, "AUTHORING_PARAMETER", field);