The product preview asked the viewport executor for the full physical viewport, so a maximised 4K window (~8 MP) was refused against the 4 MP limit on every frame. The preview now draws at the largest size of the same aspect the executor admits (RenderLimits::fit, scratch included) and filters it up into the viewport through the existing offscreen blit. Viewports within the limits still draw at full size. PNG setups past the image limits now say what the limits are and the largest size at that aspect, and the image panel offers to use it. Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b6806b16cf
commit
5fbbc13885
15 files changed
+188
-12
No files matched your search
@@ -67,13 +67,18 @@ impl Default for RenderSetup {
|
||||
impl RenderSetup {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let limits = RenderLimits::default();
|
||||
if self.width == 0
|
||||
|| self.height == 0
|
||||
|| u64::from(self.width) > limits.max_dimension
|
||||
|| u64::from(self.height) > limits.max_dimension
|
||||
|| u64::from(self.width) * u64::from(self.height) > limits.max_pixels
|
||||
let (width, height) = (u64::from(self.width), u64::from(self.height));
|
||||
if width == 0 || height == 0 {
|
||||
return Err(DocError::Invalid("render output must be at least 1 × 1 pixels".into()));
|
||||
}
|
||||
if let Some((w, h)) = limits.fit(width, height, None)
|
||||
&& (w, h) != (width, height)
|
||||
{
|
||||
return Err(DocError::Invalid("render output exceeds the dimension/pixel limits".into()));
|
||||
return Err(DocError::Invalid(format!(
|
||||
"render output {width} × {height} exceeds the image limits ({} pixels per side, {} pixels in all); \
|
||||
the largest image at this aspect is {w} × {h}",
|
||||
limits.max_dimension, limits.max_pixels
|
||||
)));
|
||||
}
|
||||
let camera = &self.camera;
|
||||
if !camera.is_valid()
|
||||
@@ -218,6 +223,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_output_names_the_limits_and_the_largest_size() {
|
||||
let setup = RenderSetup { width: 4096, height: 4096, ..Default::default() };
|
||||
let message = setup.validate().unwrap_err().to_string();
|
||||
assert!(message.contains("4096 × 4096 exceeds the image limits (4096 pixels per side, 4194304 pixels in all)"), "{message}");
|
||||
assert!(message.contains("the largest image at this aspect is 2048 × 2048"), "{message}");
|
||||
let setup = RenderSetup { width: 7680, height: 4320, ..Default::default() };
|
||||
let message = setup.validate().unwrap_err().to_string();
|
||||
assert!(message.contains("the largest image at this aspect is 27"), "{message}");
|
||||
assert!(RenderSetup { width: 2048, height: 2048, ..Default::default() }.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_saved_intent_is_rejected_at_deserialization() {
|
||||
for patch in [
|
||||
|
||||
@@ -116,6 +116,47 @@ impl Default for RenderLimits {
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderLimits {
|
||||
/// The largest size with the aspect of `width` × `height`, never larger than it, within
|
||||
/// these limits (and the scratch budget, when a layout is given). Both axes scale by the
|
||||
/// same factor. `None` for a zero size or limits that admit no pixel at all.
|
||||
pub fn fit(self, width: u64, height: u64, scratch: Option<ScratchLayout>) -> Option<(u64, u64)> {
|
||||
let fits = |w: u64, h: u64| {
|
||||
w <= self.max_dimension
|
||||
&& h <= self.max_dimension
|
||||
&& w.checked_mul(h).is_some_and(|p| p <= self.max_pixels)
|
||||
&& scratch.is_none_or(|s| s.estimate(w, h).is_ok_and(|b| b <= self.max_scratch_bytes))
|
||||
};
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
if fits(width, height) {
|
||||
return Some((width, height));
|
||||
}
|
||||
let (w, h) = (width as f64, height as f64);
|
||||
let mut budget = self.max_pixels as f64;
|
||||
if let Some(s) = scratch {
|
||||
let per_pixel = (s.sample_scale as f64).powi(2) * s.sample_bytes_per_pixel as f64
|
||||
+ s.output_bytes_per_pixel as f64
|
||||
+ s.encoding_bytes_per_pixel as f64;
|
||||
if per_pixel > 0.0 {
|
||||
budget = budget.min(self.max_scratch_bytes as f64 / per_pixel);
|
||||
}
|
||||
}
|
||||
let side = self.max_dimension as f64;
|
||||
let mut scale = (side / w).min(side / h).min((budget / (w * h)).sqrt()).min(1.0);
|
||||
// Float rounding can land one pixel over a bound: step down until it fits.
|
||||
for _ in 0..64 {
|
||||
let size = (((w * scale).floor() as u64).max(1), ((h * scale).floor() as u64).max(1));
|
||||
if fits(size.0, size.1) {
|
||||
return Some(size);
|
||||
}
|
||||
scale *= 0.999;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimated pixel-buffer cost, excluding scene/texture/geometry and codec-internal memory.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -162,6 +203,13 @@ pub struct ExecutorDescriptor {
|
||||
pub scratch: ScratchLayout,
|
||||
}
|
||||
|
||||
impl ExecutorDescriptor {
|
||||
/// The largest request size at this aspect this executor admits ([`RenderLimits::fit`]).
|
||||
pub fn fit(&self, width: u64, height: u64) -> Option<(u64, u64)> {
|
||||
self.limits.fit(width, height, Some(self.scratch))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendDescriptor {
|
||||
|
||||
@@ -188,3 +188,24 @@ fn identifiers_and_json_reject_unsafe_or_unsupported_input() {
|
||||
assert!(serde_json::from_str::<RenderRequest>(text).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_scales_both_axes_into_dimension_pixel_and_scratch_limits() {
|
||||
let limits = RenderLimits::default();
|
||||
assert_eq!(limits.fit(1920, 1080, None), Some((1920, 1080)));
|
||||
assert_eq!(limits.fit(4096, 4096, None), Some((2048, 2048)));
|
||||
assert_eq!(limits.fit(0, 1080, None), None);
|
||||
// One long side: the dimension limit binds first.
|
||||
assert_eq!(limits.fit(8192, 16, None), Some((4096, 8)));
|
||||
for (w, h) in [(3840, 2160), (5120, 2880), (7680, 4320), (2160, 3840)] {
|
||||
let (fw, fh) = limits.fit(w, h, None).unwrap();
|
||||
assert!(fw <= 4096 && fh <= 4096 && fw * fh <= limits.max_pixels, "{w}x{h} -> {fw}x{fh}");
|
||||
// Same aspect (within a pixel) and close to the budget.
|
||||
assert!((fw as f64 / fh as f64 - w as f64 / h as f64).abs() < 2.0 / fh.min(fw) as f64, "{fw}x{fh}");
|
||||
assert!(fw * fh > limits.max_pixels * 99 / 100, "{fw}x{fh}");
|
||||
}
|
||||
// A scratch layout tighter than the pixel limit binds instead.
|
||||
let scratch = ScratchLayout { sample_scale: 1, sample_bytes_per_pixel: 128, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 0 };
|
||||
let (fw, fh) = limits.fit(3840, 2160, Some(scratch)).unwrap();
|
||||
assert!(scratch.estimate(fw, fh).unwrap() <= limits.max_scratch_bytes && fw * fh < limits.max_pixels, "{fw}x{fh}");
|
||||
}
|
||||
@@ -244,7 +244,8 @@ pub struct ViewportCallback {
|
||||
/// and the depth bias.
|
||||
pub eye: [f32; 4],
|
||||
/// Supersampling (#1288): draw the frame this many times the viewport's pixels in each
|
||||
/// direction and filter it down (1: off).
|
||||
/// direction and filter it down (1: off). Under 1 the frame is drawn smaller and filtered up
|
||||
/// (the product preview past its pixel budget, #1464).
|
||||
pub ssaa: f32,
|
||||
}
|
||||
|
||||
@@ -522,9 +523,12 @@ const SS_BUDGET: u64 = 384 << 20;
|
||||
/// The size (px) to draw a `size` viewport supersampled `factor` times in each direction:
|
||||
/// within the device's largest texture side (#869) and the memory budget (at `samples` per pixel,
|
||||
/// four bytes of colour and four of depth each). `None` when that leaves under 1.25×: the frame
|
||||
/// is drawn as usual.
|
||||
/// is drawn as usual. A `factor` under 1 draws it that much smaller, to be filtered up.
|
||||
pub(crate) fn supersampled_size(size: [f32; 2], factor: f32, max_side: u32, samples: u32) -> Option<[u32; 2]> {
|
||||
let [w, h] = size;
|
||||
if w >= 1.0 && h >= 1.0 && factor > 0.0 && factor < 1.0 {
|
||||
return Some([((w * factor).floor() as u32).clamp(1, max_side.max(1)), ((h * factor).floor() as u32).clamp(1, max_side.max(1))]);
|
||||
}
|
||||
if !(w >= 1.0 && h >= 1.0 && factor.is_finite() && factor > 1.0) {
|
||||
return None;
|
||||
}
|
||||
@@ -1139,7 +1143,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
res.quads.push((img.id, b));
|
||||
}
|
||||
}
|
||||
res.ss_ready = self.ssaa > 1.0 && res.supersample(device, encoder, self);
|
||||
res.ss_ready = self.ssaa != 1.0 && res.supersample(device, encoder, self);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,18 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.render_workspace.view.dirty = true;
|
||||
}
|
||||
});
|
||||
let draft = &app.render_workspace.view.draft;
|
||||
let size = (u64::from(draft.width), u64::from(draft.height));
|
||||
if let Some((w, h)) = RenderLimits::default().fit(size.0, size.1, None)
|
||||
&& (w, h) != size
|
||||
{
|
||||
ui.small(app.tr("render.image_too_large"));
|
||||
if ui.button(format!("{} {w} × {h}", app.tr("render.image_use_largest"))).clicked() {
|
||||
app.render_workspace.view.draft.width = u32::try_from(w).unwrap_or(1);
|
||||
app.render_workspace.view.draft.height = u32::try_from(h).unwrap_or(1);
|
||||
app.render_workspace.view.dirty = true;
|
||||
}
|
||||
}
|
||||
let transparent_label = app.tr("render.image_transparent").to_string();
|
||||
ui.checkbox(&mut app.render_workspace.view.transparent, transparent_label);
|
||||
if ui.button(app.tr("render.image_check")).clicked() {
|
||||
@@ -435,6 +447,13 @@ fn hash_surface(surface: Option<solidcraft_engine::render::surface::OpaqueSurfac
|
||||
}
|
||||
}
|
||||
|
||||
/// The size the product preview draws at for a viewport of `full` physical pixels: the full
|
||||
/// size when the viewport executor admits it, else the largest size at the same aspect it does.
|
||||
/// Without an executor the request is left for resolution to report.
|
||||
pub fn preview_size(executor: Option<&ExecutorDescriptor>, full: (u64, u64)) -> (u64, u64) {
|
||||
executor.and_then(|e| e.fit(full.0, full.1)).unwrap_or(full)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let rect = ui.max_rect();
|
||||
app.viewport.rect = Some(rect);
|
||||
@@ -454,12 +473,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if setup.exposure_ev != 0.0 || setup.environment_rotation != 0.0 || setup.environment_intensity != 1.0 {
|
||||
return Err("fixed studio preview requires exposure 0, environment rotation 0 and intensity 1".into());
|
||||
}
|
||||
// A 4K or 5K viewport is past the preview's pixel budget: draw it smaller and filter it
|
||||
// up into the viewport rather than refusing the frame.
|
||||
let full = ((rect.width() * ppp).max(1.0) as u64, (rect.height() * ppp).max(1.0) as u64);
|
||||
let (width, height) = preview_size(app.session.render_viewport(), full);
|
||||
let mut request = RenderRequest {
|
||||
backend: Some(setup.backend.clone()),
|
||||
mode: setup.mode,
|
||||
operation: RenderOperation::Viewport,
|
||||
width: (rect.width() * ppp).max(1.0) as u64,
|
||||
height: (rect.height() * ppp).max(1.0) as u64,
|
||||
width,
|
||||
height,
|
||||
format: None,
|
||||
features: Vec::new(),
|
||||
policy: Default::default(),
|
||||
@@ -516,7 +539,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
access: None,
|
||||
outline: false,
|
||||
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }],
|
||||
ssaa: 1.0,
|
||||
ssaa: if width < full.0 { width as f32 / full.0 as f32 } else { 1.0 },
|
||||
};
|
||||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback));
|
||||
Ok(())
|
||||
|
||||
@@ -636,3 +636,40 @@ fn render_workspace_view_keys_double_middle_click_and_navigators_work() {
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// #1464: a maximised 4K or 5K window is past the preview's pixel budget. The preview draws
|
||||
/// smaller at the same aspect instead of refusing every frame.
|
||||
#[test]
|
||||
fn four_and_five_k_viewports_preview_downscaled_within_the_limits() {
|
||||
use solidcraft_engine::render::*;
|
||||
for samples in [1, 4, 16] {
|
||||
let host = host_executor(16384, true, samples).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.set_render_viewport(Some(host.clone())).unwrap();
|
||||
let request = |width, height| RenderRequest {
|
||||
backend: None,
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Viewport,
|
||||
width,
|
||||
height,
|
||||
format: None,
|
||||
features: vec![RenderFeature::Pbr],
|
||||
policy: Default::default(),
|
||||
};
|
||||
if samples <= 4 {
|
||||
assert_eq!(preview_size(s.render_viewport(), (1920, 1080)), (1920, 1080), "fits: drawn at full size");
|
||||
}
|
||||
// 16× MSAA's scratch cost brings even 1080p under the budget.
|
||||
let sizes: &[(u64, u64)] = if samples == 16 { &[(1920, 1080), (3840, 2160), (5120, 2880)] } else { &[(3840, 2160), (5120, 2880)] };
|
||||
for &full in sizes {
|
||||
assert!(resolve_request(&[s.render_backend()], &request(full.0, full.1)).is_err(), "{full:?} at full size is over the limit");
|
||||
let (w, h) = preview_size(s.render_viewport(), full);
|
||||
assert!(w < full.0 && h < full.1 && w * h <= host.limits.max_pixels, "{full:?} -> {w}x{h}");
|
||||
assert!((w as f64 / h as f64 - 16.0 / 9.0).abs() < 0.01, "{w}x{h} keeps the aspect");
|
||||
let resolved = resolve_request(&[s.render_backend()], &request(w, h)).unwrap();
|
||||
assert_eq!(resolved.executor.as_str(), "gpu-viewport");
|
||||
assert!(resolved.estimated_scratch_bytes <= host.limits.max_scratch_bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(preview_size(None, (5120, 2880)), (5120, 2880), "no executor: resolution reports it");
|
||||
}
|
||||
@@ -3459,6 +3459,8 @@ mod tests {
|
||||
assert_eq!(supersampled_size([3840.0, 2160.0], 2.0, 4096, 1), None);
|
||||
assert_eq!(supersampled_size([1920.0, 1080.0], 1.0, 16384, 1), None);
|
||||
assert_eq!(supersampled_size([0.0, 1080.0], 2.0, 16384, 1), None);
|
||||
// Under 1: drawn smaller and filtered up (the product preview past its budget, #1464).
|
||||
assert_eq!(supersampled_size([3840.0, 2160.0], 0.5, 4096, 4), Some([1920, 1080]));
|
||||
// 4× MSAA at 1440p: the memory budget caps it below 2×.
|
||||
let [w, h] = supersampled_size([2560.0, 1440.0], 2.0, 16384, 4).unwrap();
|
||||
assert!(w < 5120 && w as f32 >= 2560.0 * 1.25 && u64::from(w) * u64::from(h) * 32 <= 384 << 20, "{w} x {h}");
|
||||
|
||||
Reference in new issue
Block a user