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