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 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 15:18:00 -04:00
1 parent b6806b16cf
commit 5fbbc13885
15 files changed
+188 -12

No files matched your search

+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "レンダリングを開始",
+2
View File
@@ -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": "렌더링 시작",
+2
View File
@@ -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": "Начать рендеринг",
+2
View File
@@ -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": "开始渲染",
+23 -6
View File
@@ -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 [
+48
View File
@@ -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 {
+21
View File
@@ -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}");
}
+7 -3
View File
@@ -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()
}
+26 -3
View File
@@ -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");
}
+2
View File
@@ -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}");
+10
View File
@@ -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