From 5fbbc1388591e915fea654fb6bf38a95b113c6ce Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 15:18:00 -0400 Subject: [PATCH] Render preview: draw within the pixel budget on 4K/5K screens (#1464) (#1477) 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 --- assets/locales/de.json | 2 + assets/locales/en.json | 2 + assets/locales/fr.json | 2 + assets/locales/ja.json | 2 + assets/locales/ko.json | 2 + assets/locales/ru.json | 2 + assets/locales/zh.json | 2 + crates/doc/src/render_settings.rs | 29 +++++++++--- crates/render/src/backend.rs | 48 ++++++++++++++++++++ crates/render/src/backend/tests.rs | 21 +++++++++ crates/ui-egui/src/gpu.rs | 10 ++-- crates/ui-egui/src/render_workspace.rs | 29 ++++++++++-- crates/ui-egui/src/render_workspace_tests.rs | 37 +++++++++++++++ crates/ui-egui/src/viewport.rs | 2 + docs/render-workspace.md | 10 ++++ 15 files changed, 188 insertions(+), 12 deletions(-) diff --git a/assets/locales/de.json b/assets/locales/de.json index 8e3653bd..03b43cc3 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -1049,6 +1049,8 @@ "render.image_failed": "Rendering fehlgeschlagen", "render.image_unknown": "Renderstatus nicht verfügbar", "render.image_reduce": "Kleineres Bild oder einfachere Szene versuchen.", + "render.image_too_large": "Über dem Bildlimit: 4096 Pixel pro Seite und 4.194.304 Pixel insgesamt.", + "render.image_use_largest": "Größte Größe mit diesem Seitenverhältnis verwenden:", "render.image_cancel": "Rendering abbrechen", "render.image_stale": "Dieses Bild verwendet die Szene vor den nachfolgenden Änderungen.", "render.image_start": "Rendering starten", diff --git a/assets/locales/en.json b/assets/locales/en.json index 3b905524..bc33cfe1 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1307,6 +1307,8 @@ "render.image_failed": "Render failed", "render.image_unknown": "Render status unavailable", "render.image_reduce": "Try a smaller image or simpler scene.", + "render.image_too_large": "Over the image limit: 4096 pixels per side and 4,194,304 pixels in all.", + "render.image_use_largest": "Use the largest size at this aspect:", "render.image_cancel": "Cancel Render", "render.image_stale": "This image uses the scene captured before subsequent changes.", "render.image_start": "Start Render", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index bc82cf21..2a09363d 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -1170,6 +1170,8 @@ "render.image_failed": "Échec du rendu", "render.image_unknown": "État du rendu indisponible", "render.image_reduce": "Essayez une image plus petite ou une scène plus simple.", + "render.image_too_large": "Au-delà de la limite d'image : 4096 pixels par côté et 4 194 304 pixels au total.", + "render.image_use_largest": "Utiliser la plus grande taille à ce format :", "render.image_cancel": "Annuler le rendu", "render.image_stale": "Cette image utilise la scène capturée avant les dernières modifications.", "render.image_start": "Lancer le rendu", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 0a93e692..bfd2ef88 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1307,6 +1307,8 @@ "render.image_failed": "レンダリングに失敗しました", "render.image_unknown": "レンダリング状態を取得できません", "render.image_reduce": "画像を小さくするかシーンを簡略化してください。", + "render.image_too_large": "画像の上限を超えています:一辺 4096 ピクセル、合計 4,194,304 ピクセル。", + "render.image_use_largest": "この縦横比での最大サイズを使用:", "render.image_cancel": "レンダリングをキャンセル", "render.image_stale": "この画像は後続の変更前に取得したシーンを使用しています。", "render.image_start": "レンダリングを開始", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index b600071b..2740f32a 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -1049,6 +1049,8 @@ "render.image_failed": "렌더링 실패", "render.image_unknown": "렌더 상태를 확인할 수 없습니다", "render.image_reduce": "더 작은 이미지나 더 단순한 장면을 사용해 보세요.", + "render.image_too_large": "이미지 한도를 초과했습니다: 한 변 4096픽셀, 전체 4,194,304픽셀.", + "render.image_use_largest": "이 비율에서 가장 큰 크기 사용:", "render.image_cancel": "렌더링 취소", "render.image_stale": "이 이미지는 후속 변경 전에 캡처한 장면을 사용합니다.", "render.image_start": "렌더링 시작", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 9d2e46b8..8894d50b 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -1091,6 +1091,8 @@ "render.image_failed": "Ошибка рендеринга", "render.image_unknown": "Состояние рендеринга недоступно", "render.image_reduce": "Попробуйте уменьшить изображение или упростить сцену.", + "render.image_too_large": "Превышен предел изображения: 4096 пикселей по стороне и 4 194 304 пикселя всего.", + "render.image_use_largest": "Использовать наибольший размер с этим соотношением сторон:", "render.image_cancel": "Отменить рендеринг", "render.image_stale": "Используется сцена, сохранённая до последующих изменений.", "render.image_start": "Начать рендеринг", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 29ef2729..4bd604ce 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1307,6 +1307,8 @@ "render.image_failed": "渲染失败", "render.image_unknown": "渲染状态不可用", "render.image_reduce": "请尝试更小的图像或更简单的场景。", + "render.image_too_large": "超出图像限制:每边 4096 像素,总计 4,194,304 像素。", + "render.image_use_largest": "使用此宽高比下的最大尺寸:", "render.image_cancel": "取消渲染", "render.image_stale": "此图像使用后续更改之前捕获的场景。", "render.image_start": "开始渲染", diff --git a/crates/doc/src/render_settings.rs b/crates/doc/src/render_settings.rs index bff01ff8..f7aa4ce0 100644 --- a/crates/doc/src/render_settings.rs +++ b/crates/doc/src/render_settings.rs @@ -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 [ diff --git a/crates/render/src/backend.rs b/crates/render/src/backend.rs index d9f4ba97..1f25d719 100644 --- a/crates/render/src/backend.rs +++ b/crates/render/src/backend.rs @@ -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) -> 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 { diff --git a/crates/render/src/backend/tests.rs b/crates/render/src/backend/tests.rs index a08f7c21..09de97e6 100644 --- a/crates/render/src/backend/tests.rs +++ b/crates/render/src/backend/tests.rs @@ -188,3 +188,24 @@ fn identifiers_and_json_reject_unsafe_or_unsupported_input() { assert!(serde_json::from_str::(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}"); +} diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index f0cf8c5b..12ec2b88 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -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() } diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index 42d2cdbc..68c9e8e9 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -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, 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(()) diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs index c873df61..027dc4c8 100644 --- a/crates/ui-egui/src/render_workspace_tests.rs +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -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"); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a2b72dbf..12313a5a 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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}"); diff --git a/docs/render-workspace.md b/docs/render-workspace.md index 261b5b53..a1a0c2f5 100644 --- a/docs/render-workspace.md +++ b/docs/render-workspace.md @@ -65,6 +65,16 @@ per dimension and 4,194,304 pixels, within the 256 MiB pixel scratch admission p The estimate includes the selected MSAA colour/depth cost; it excludes scene geometry, driver overhead and cached source meshes. It is not a measurement of total GPU memory. +The preview never refuses a frame because of the screen size. When the viewport's physical +size is past those limits (a maximised 4K or 5K window is 8–15 million pixels), the preview +draws at the largest size of the same aspect the executor admits (`RenderLimits::fit`, both +axes scaled by one factor, scratch included) and filters it up into the viewport. A viewport +within the limits draws at full size. + +PNG images keep the saved-setup limits above. A setup past them is refused with the limits and +the largest size at its aspect (for example `4096 × 4096` → `2048 × 2048`), and the image panel +offers to use that size. + Pure capability metadata lives in the session/host, outside persistence and history, surviving document replacement, scratch sessions and document-tab switches. No wgpu types enter the engine or document crates. A host without an installed GPU/depth buffer