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:
@@ -40,6 +40,7 @@ struct PipelineParameters {
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FrameOutParameters {
|
||||
surface_format: SurfaceFormatRequest,
|
||||
hdr_enabled: bool,
|
||||
tone_mapper: ToneMapper,
|
||||
exposure_stops: f32,
|
||||
@@ -544,6 +545,7 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
|
||||
&format!("{base}.backgroundColor"),
|
||||
)?;
|
||||
NormalizedParameters::FrameOut {
|
||||
surface_format: p.surface_format,
|
||||
dynamic_range: if p.hdr_enabled {
|
||||
FrameDynamicRange::Hdr {
|
||||
tone_mapper: p.tone_mapper,
|
||||
|
||||
@@ -408,7 +408,7 @@ pub static CONTRACTS: &[Contract] = &[
|
||||
},
|
||||
Contract {
|
||||
key: "frame_out",
|
||||
version: 2,
|
||||
version: 3,
|
||||
execution: ExecutionClass::Frame,
|
||||
inputs: FRAME_OUT_IN,
|
||||
outputs: NONE_OUT,
|
||||
|
||||
@@ -256,6 +256,7 @@ pub enum NormalizedParameters {
|
||||
strength: f32,
|
||||
},
|
||||
FrameOut {
|
||||
surface_format: SurfaceFormatRequest,
|
||||
dynamic_range: FrameDynamicRange,
|
||||
output_transfer: OutputTransfer,
|
||||
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)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum FrameDynamicRange {
|
||||
|
||||
@@ -26,7 +26,131 @@ pub struct RuntimeSurfaceContract {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub usage: wgpu::TextureUsages,
|
||||
pub present_mode: wgpu::PresentMode,
|
||||
pub alpha_mode: wgpu::CompositeAlphaMode,
|
||||
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)]
|
||||
@@ -1467,6 +1591,7 @@ pub fn prepare_runtime_plan(
|
||||
|
||||
let frame_out = &graph.executions[frame_out_index];
|
||||
let NormalizedParameters::FrameOut {
|
||||
surface_format,
|
||||
dynamic_range,
|
||||
output_transfer,
|
||||
scale_mode: _,
|
||||
@@ -1479,6 +1604,22 @@ pub fn prepare_runtime_plan(
|
||||
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
|
||||
.iter()
|
||||
.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> {
|
||||
let surface = RuntimeSurfaceContract {
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
width: 1,
|
||||
height: 1,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: Vec::new(),
|
||||
// Runtime validation must diagnose noncanonical compiled plans before the
|
||||
// live-capability resolver. The validator below remains authoritative for
|
||||
// missing or duplicated Frame Out work, so a missing request gets a
|
||||
// harmless synthetic default solely for constructing test capabilities.
|
||||
let (request, frame_out_index) = graph
|
||||
.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(|_| ())
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ fn input(node: &str, socket: &str) -> 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()) {
|
||||
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 {
|
||||
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_composite", 1),
|
||||
("luminance_edge", 1),
|
||||
("frame_out", 2),
|
||||
("frame_out", 3),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2971,14 +2971,15 @@ fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() {
|
||||
}
|
||||
|
||||
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",
|
||||
"backgroundColor":[0.1,0.2,0.3,0.4]})
|
||||
}
|
||||
|
||||
#[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 = [
|
||||
"surfaceFormat",
|
||||
"hdrEnabled",
|
||||
"toneMapper",
|
||||
"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 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");
|
||||
}
|
||||
|
||||
@@ -3044,7 +3045,10 @@ fn frame_out_source_format_matrix_is_exact() {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
for (format, sdr, hdr) in [
|
||||
("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]
|
||||
fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() {
|
||||
let baseline = compile_graph(full_cull_graph());
|
||||
@@ -3100,7 +3272,7 @@ fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations()
|
||||
.iter()
|
||||
.position(|e| e.executor.key == "frame_out")
|
||||
.unwrap();
|
||||
for version in [1, 3] {
|
||||
for version in [1, 2, 4] {
|
||||
let mut g = baseline.clone();
|
||||
g.executions[i].executor.version = 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,
|
||||
height: 720,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
prepare_runtime_plan(&hdr_to_sdr, linear_surface.clone(), None).unwrap();
|
||||
let error = prepare_runtime_plan(
|
||||
|
||||
+413
-83
@@ -154,6 +154,7 @@ fn pack_frame_out_uniforms(
|
||||
) -> Option<FullscreenUniforms> {
|
||||
use crate::render_graph::*;
|
||||
let NormalizedParameters::FrameOut {
|
||||
surface_format: _,
|
||||
dynamic_range,
|
||||
output_transfer,
|
||||
scale_mode,
|
||||
@@ -301,7 +302,10 @@ mod fullscreen_tests {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +316,7 @@ mod fullscreen_tests {
|
||||
filter: FrameFilter,
|
||||
) -> NormalizedParameters {
|
||||
NormalizedParameters::FrameOut {
|
||||
surface_format: SurfaceFormatRequest::Preferred,
|
||||
dynamic_range,
|
||||
output_transfer: transfer,
|
||||
scale_mode,
|
||||
@@ -775,6 +780,57 @@ enum UploadGraph {
|
||||
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 {
|
||||
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)]
|
||||
mod switch_request_tests {
|
||||
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));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() {
|
||||
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");
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn resize_restart_snapshot_remains_bound_to_its_immutable_registry_revision() {
|
||||
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 adapter: wgpu::Adapter,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
pub surface_config: wgpu::SurfaceConfiguration,
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
initial_surface_config: wgpu::SurfaceConfiguration,
|
||||
pub depth_texture: wgpu::Texture,
|
||||
pub depth_view: wgpu::TextureView,
|
||||
}
|
||||
@@ -1125,6 +1273,7 @@ pub struct Renderer<T: scene::Scene> {
|
||||
graph_registry: crate::render_graph::Registry,
|
||||
active_compiled: Option<ActiveCompiledGraph>,
|
||||
pending_switch: Option<PendingSwitch>,
|
||||
pending_resize: Option<ActiveCompiledGraph>,
|
||||
in_flight: Option<InFlightPreparation>,
|
||||
next_preparation_token: u64,
|
||||
preparation_completions: Rc<RefCell<Vec<PreparationCompletion>>>,
|
||||
@@ -1133,6 +1282,28 @@ pub struct Renderer<T: scene::Scene> {
|
||||
}
|
||||
|
||||
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>) {
|
||||
let (ok, code, value, details) = match result {
|
||||
Ok(value) => (true, "OK", value, JsValue::UNDEFINED),
|
||||
@@ -1187,23 +1358,20 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
slot: words[2],
|
||||
generation: words[3],
|
||||
};
|
||||
let outcome =
|
||||
if self.active_compiled.as_ref().is_some_and(|a| a.id() == id) {
|
||||
Err(crate::render_graph::GraphError::new(
|
||||
"GRAPH_ACTIVE",
|
||||
"compiled graph is active",
|
||||
))
|
||||
} else if self.pending_switch.as_ref().is_some_and(
|
||||
|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 {
|
||||
self.graph_registry.drop_graph(id)
|
||||
};
|
||||
let outcome = drop_graph_request(
|
||||
&mut self.graph_registry,
|
||||
id,
|
||||
self.active_compiled.as_ref().map(ActiveCompiledGraph::id),
|
||||
self.pending_switch.as_ref().and_then(|pending| {
|
||||
if let SwitchTarget::Compiled(active) = &pending.target {
|
||||
Some(active.id())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
self.pending_resize.as_ref().map(ActiveCompiledGraph::id),
|
||||
self.in_flight.as_ref().map(|preparation| preparation.id),
|
||||
);
|
||||
match outcome {
|
||||
Ok(()) => self.reply(request, Ok(JsValue::UNDEFINED)),
|
||||
Err(error) => self.reply(request, Err(error.into())),
|
||||
@@ -1411,14 +1579,14 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
"gltf_standard",
|
||||
&layout,
|
||||
include_str!("../gltf.wgsl"),
|
||||
context.surface_config.format,
|
||||
context.initial_surface_config.format,
|
||||
);
|
||||
let double_sided = resources.get_or_create_pipeline(
|
||||
&context.device,
|
||||
"gltf_standard_double_sided",
|
||||
&layout,
|
||||
include_str!("../gltf.wgsl"),
|
||||
context.surface_config.format,
|
||||
context.initial_surface_config.format,
|
||||
);
|
||||
[culled, double_sided]
|
||||
}
|
||||
@@ -1426,16 +1594,19 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
fn plan_compiled(
|
||||
&self,
|
||||
graph: &crate::render_graph::CompiledGraph,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> 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(
|
||||
graph,
|
||||
crate::render_graph::RuntimeSurfaceContract {
|
||||
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(),
|
||||
},
|
||||
surface,
|
||||
Some(&self.context.device.limits()),
|
||||
)
|
||||
}
|
||||
@@ -1860,7 +2031,21 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
graph: crate::render_graph::CompiledGraph,
|
||||
purpose: PreparationPurpose,
|
||||
) -> 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
|
||||
// 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.
|
||||
@@ -1932,7 +2117,7 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
self.reply(request, Err(error.into()));
|
||||
}
|
||||
(PreparationPurpose::Resize, Ok(candidate)) => {
|
||||
self.active_compiled = Some(candidate);
|
||||
self.pending_resize = Some(candidate);
|
||||
}
|
||||
(PreparationPurpose::Resize, Err(error)) => {
|
||||
log::error!(
|
||||
@@ -2012,16 +2197,15 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
}));
|
||||
|
||||
let surface_caps = surface.get_capabilities(&adapter);
|
||||
let surface_config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format: surface_caps.formats[0],
|
||||
width: canvas.clone().width().max(1),
|
||||
height: canvas.clone().height().max(1),
|
||||
present_mode: surface_caps.present_modes[0],
|
||||
alpha_mode: surface_caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
let initial_contract = crate::render_graph::resolve_surface_contract(
|
||||
crate::render_graph::SurfaceFormatRequest::Preferred,
|
||||
&surface_caps,
|
||||
canvas.clone().width().max(1),
|
||||
canvas.clone().height().max(1),
|
||||
"initialSurfaceFormat",
|
||||
)
|
||||
.expect("surface must support the fixed presentation contract");
|
||||
let surface_config = Self::surface_config(&initial_contract);
|
||||
info!(
|
||||
"suface size: {} x {}",
|
||||
surface_config.width, surface_config.height
|
||||
@@ -2032,9 +2216,11 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
|
||||
let mut resources = PipelineLibrary::new();
|
||||
let context = RendererContext {
|
||||
adapter,
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
initial_surface_config: surface_config.clone(),
|
||||
surface_config,
|
||||
depth_texture,
|
||||
depth_view,
|
||||
@@ -2066,6 +2252,7 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
graph_registry: Default::default(),
|
||||
active_compiled: None,
|
||||
pending_switch: None,
|
||||
pending_resize: None,
|
||||
in_flight: None,
|
||||
next_preparation_token: 0,
|
||||
preparation_completions: Default::default(),
|
||||
@@ -2128,20 +2315,38 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
SwitchTarget::Immediate => UploadGraph::Immediate,
|
||||
SwitchTarget::Compiled(graph) => classify_upload_graph(graph),
|
||||
});
|
||||
let query = upload_query_for_render(
|
||||
pending,
|
||||
self.active_compiled.as_ref().map(classify_upload_graph),
|
||||
let target_source = select_frame_target_source(
|
||||
self.pending_switch.is_some(),
|
||||
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
|
||||
// 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)
|
||||
{
|
||||
Ok(planes) => planes,
|
||||
Err(error) => {
|
||||
if let Some(pending) = self.pending_switch.take() {
|
||||
self.reply(pending.request, Err(error.into()));
|
||||
} else {
|
||||
self.post_fatal("GRAPH_EXECUTION_FAILED", &error.message);
|
||||
match target_source {
|
||||
FrameTargetSource::PendingSwitch => {
|
||||
let pending = self.pending_switch.take().unwrap();
|
||||
self.reply(pending.request, Err(error.into()));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2167,10 +2372,68 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
.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() {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
self.post_fatal("SURFACE_FRAME_FAILED", &error.to_string());
|
||||
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());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -2182,10 +2445,15 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
label: Some("Render command encoder"),
|
||||
});
|
||||
|
||||
let rendering_compiled = match self.pending_switch.as_ref().map(|p| &p.target) {
|
||||
Some(SwitchTarget::Compiled(active)) => Some(active),
|
||||
Some(SwitchTarget::Immediate) => None,
|
||||
None => self.active_compiled.as_ref(),
|
||||
let rendering_compiled = match target_source {
|
||||
FrameTargetSource::PendingSwitch => match &self.pending_switch.as_ref().unwrap().target
|
||||
{
|
||||
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 {
|
||||
None => "immediate".to_owned(),
|
||||
@@ -2222,7 +2490,15 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
if let Some(frame) = profile_frame.take() {
|
||||
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(config) = restore_config {
|
||||
self.configure_surface(config);
|
||||
}
|
||||
self.reply(
|
||||
pending.request,
|
||||
Err(
|
||||
@@ -2230,6 +2506,10 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
.into(),
|
||||
),
|
||||
);
|
||||
} else if self.pending_resize.take().is_some() {
|
||||
if let Some(config) = restore_config {
|
||||
self.configure_surface(config);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2240,6 +2520,10 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
}
|
||||
surface_texture.present();
|
||||
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 {
|
||||
SwitchTarget::Immediate => {
|
||||
self.active_compiled = None;
|
||||
@@ -2258,6 +2542,8 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
}
|
||||
};
|
||||
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>();
|
||||
for reply in self.pending_replies.drain(..) {
|
||||
@@ -2288,6 +2574,16 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
("indices", index_count.into()),
|
||||
("width", self.context.surface_config.width.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()),
|
||||
(
|
||||
"renderMode",
|
||||
@@ -2540,14 +2836,6 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
if new_width != self.context.surface_config.width
|
||||
|| 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() {
|
||||
self.reply(
|
||||
pending.request,
|
||||
@@ -2575,38 +2863,80 @@ impl<T: Scene + 'static> Renderer<T> {
|
||||
}
|
||||
PreparationPurpose::Resize => Some((preparation.id, preparation.graph)),
|
||||
});
|
||||
let mut restarted = false;
|
||||
if let Some(old) = self.active_compiled.take() {
|
||||
let id = old.id();
|
||||
// Keep immediate resources live and fall back for this frame if recreation fails.
|
||||
restarted = true;
|
||||
self.begin_compiled_preparation(id, old.graph, PreparationPurpose::Resize)
|
||||
.unwrap_or_else(|error| {
|
||||
log::error!(
|
||||
"compiled graph resize preparation failed: {}",
|
||||
error.message
|
||||
)
|
||||
});
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let restore = self
|
||||
.active_compiled
|
||||
.take()
|
||||
.map(|active| (active.id(), active.graph))
|
||||
.or_else(|| {
|
||||
self.pending_resize
|
||||
.take()
|
||||
.map(|active| (active.id(), active.graph))
|
||||
})
|
||||
.or(interrupted_resize);
|
||||
|
||||
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(
|
||||
new_width as f64,
|
||||
new_height as f64,
|
||||
msg.scale_factor,
|
||||
&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!(
|
||||
"Resized: ({}, {}), scale: {}",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 i = (type, required = true, authoringType) => ({
|
||||
accepted: typeof type === "string" ? exact(type) : type,
|
||||
@@ -188,11 +188,11 @@ export const semanticCatalog = Object.freeze({
|
||||
parameters: { strength: 2 },
|
||||
},
|
||||
frame_out: {
|
||||
version: 2,
|
||||
version: 3,
|
||||
execution: "frame",
|
||||
inputs: { color: i("texture") },
|
||||
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 = [
|
||||
@@ -389,6 +389,7 @@ const parameterSchemas = {
|
||||
bloom_composite: { intensity: number(1, 0, 16) },
|
||||
luminance_edge: { strength: number(2, 0, 16) },
|
||||
frame_out: {
|
||||
surfaceFormat: enumeration("preferred", ["preferred", "rgba8_unorm", "bgra8_unorm", "rgba16_float"]),
|
||||
hdrEnabled: boolean(true),
|
||||
toneMapper: enumeration("aces", ["aces", "reinhard", "none"]),
|
||||
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" },
|
||||
];
|
||||
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: "parameter", parameter: "hdrEnabled", title: "HDR" },
|
||||
{ kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } },
|
||||
|
||||
@@ -12,7 +12,7 @@ const texture = (format, scale = 1, heightScale = scale) => ({
|
||||
},
|
||||
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) => [
|
||||
node("hdr", "texture", texture("rgba16_float", 1, heightScale)),
|
||||
node("depth", "texture", texture("depth32_float", 1, heightScale)),
|
||||
|
||||
@@ -36,7 +36,7 @@ test("production render graph composition passes fxnode's public validator", asy
|
||||
result.ok ? undefined : JSON.stringify(result.issues, null, 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.ok(
|
||||
Object.values(fxNodeComposition.nodes).every(
|
||||
|
||||
@@ -96,7 +96,7 @@ function fixture() {
|
||||
test("catalog exhaustively mirrors all current contracts", () => {
|
||||
for (const [key, semantic] of Object.entries(semanticCatalog)) {
|
||||
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(descriptors[key].version, semantic.version);
|
||||
}
|
||||
@@ -133,7 +133,7 @@ test("catalog exhaustively mirrors all current contracts", () => {
|
||||
Object.keys(contract.parameters).sort(),
|
||||
key,
|
||||
);
|
||||
assert.equal(CATALOG_VERSION, 6);
|
||||
assert.equal(CATALOG_VERSION, 7);
|
||||
assert.deepEqual(nodeDefinitions.pipeline.parameters, {
|
||||
pipeline: {
|
||||
type: "string",
|
||||
@@ -346,14 +346,15 @@ test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type
|
||||
link.fromSocketId = "mesh:mesh";
|
||||
}, "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);
|
||||
test("Frame Out has the exact v3 schema, defaults, UI, and strict authoring validation", () => {
|
||||
const fields = ["surfaceFormat", "hdrEnabled", "toneMapper", "exposureStops", "outputTransfer", "scaleMode", "filter", "backgroundColor"];
|
||||
assert.equal(CATALOG_VERSION, 7);
|
||||
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] },
|
||||
version: 3, execution: "frame", inputs: { color: semanticCatalog.frame_out.inputs.color }, outputs: {},
|
||||
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, {
|
||||
surfaceFormat: { type: "string", default: { kind: "string", value: "preferred" }, enum: ["preferred", "rgba8_unorm", "bgra8_unorm", "rgba16_float"] },
|
||||
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 },
|
||||
@@ -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 },
|
||||
});
|
||||
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: "parameter", parameter: "hdrEnabled", title: "HDR" },
|
||||
{ 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));
|
||||
};
|
||||
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");
|
||||
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"],
|
||||
["surfaceFormat", "bad"], ["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);
|
||||
|
||||
Reference in New Issue
Block a user