diff --git a/docs/tickets.md b/docs/tickets.md index 44ee957..87abfb6 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -817,3 +817,43 @@ building base and β‰₯8-10% luma in shaded receivers. `setSsgiEnabled(false)` on SW adapters banks the ~1.6 ms until this lands; needs a backend query FFI or the game reading the new boot log. +--- + +## EN-024 β€” iOS/iPadOS/tvOS report pixels as the logical size πŸ”΄ needs decision + +**Why.** macOS passes points as logical and pixels as physical +(`native/macos/src/lib.rs:361`, via `backingScaleFactor`). iOS/iPadOS +and tvOS pass **pixels for both**: `bloom_attach_native` sets +`logical_w = physical_w = width` (`native/ios/src/lib.rs:770-773`) and +the orientation-change poll resizes with `resize(pw, ph, pw, ph)` +(`native/ios/src/lib.rs:828`); tvOS mirrors it. Touch input is then +scaled pointsβ†’pixels to match (`native/tvos/src/lib.rs:267`), so the +convention is at least self-consistent β€” a deliberate-looking choice, +not an obvious slip. + +Two consequences, both real: + +1. **The physical-resolution glyph work is inert on iOS.** The shared + text renderer derives `dpi = surface_config.width / logical_width` + (`native/shared/src/text_renderer.rs:284-285`). With logical == + physical that is always 1.0, so the "rasterize glyphs at physical + resolution" change (60ba704) does nothing on Apple mobile β€” glyphs + rasterize at 1Γ— of a pixel-sized font instead of at the backing + scale. Text is not blurry (sizes are already in pixels) but the + crispness win never lands, and 3Γ— devices get 3Γ—-smaller text than + the same source produces on macOS. +2. **Game-facing coordinates differ across Apple targets.** `screenWidth` + and 2D HUD coordinates are points on macOS and pixels on iOS, so the + same layout code does not carry across. + +**Decision needed (breaking either way).** Either (a) switch iOS/tvOS to +the macOS convention β€” pass points as logical, pixels as physical, and +stop pre-scaling touches β€” which fixes both consequences but changes +game-facing coordinates and will break existing iOS games' layout; or +(b) keep pixels and document the divergence explicitly, accepting that +`text_renderer`'s dpi path is dead on Apple mobile. Deliberately not +folded into the boot-path render-target fix (PR for EN-024's sibling +bug), which was a straight bug β€” this one is a product call. + +**Found by** the macOS/iOS parity audit, 2026-07-11. + diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ada0a5c..e490c58 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -6082,7 +6082,7 @@ impl Renderer { ..Default::default() }); - Self { + let mut renderer = Self { headless_target: if surface.is_none() { Some(device.create_texture(&wgpu::TextureDescriptor { label: Some("headless_swapchain"), @@ -6530,7 +6530,21 @@ impl Renderer { composite_ldr_rt_b: None, composite_ldr_rt_b_view: None, post_pass_depth_sampler, - } + }; + + // The targets above are sized to the full surface, but the pass + // chain runs at `render_extent()` (surface * render_scale, 0.5 by + // default) and `resize` is the only thing that reconciles the two. + // Run it once here rather than trusting the host to: every + // `attach_engine` platform (macOS/iOS/tvOS/Linux/Android) resizes + // only when the OS size *changes*, so it never fires on frame 1 β€” + // those hosts rendered against targets that ignored render_scale, + // dropping post-FX and failing the depth-snapshot copy outright + // with a scene-reading translucent material in view. + let (pw, ph) = (renderer.surface_config.width, renderer.surface_config.height); + let (lw, lh) = (renderer.logical_width, renderer.logical_height); + renderer.resize(pw, ph, lw, lh); + renderer } /// Q1: Set up a render target override. The next end_frame will render to @@ -10732,6 +10746,15 @@ impl Renderer { self.surface_config.height } + /// Actual size of the render-resolution targets (depth / HDR / + /// G-buffer). Must always equal `render_extent()`: the pass chain + /// computes viewports and dispatches from the latter and writes into + /// the former. Pinned by `tests/render_targets.rs`. + pub fn render_target_extent(&self) -> (u32, u32) { + let s = self.depth_texture.size(); + (s.width, s.height) + } + pub fn surface_format(&self) -> wgpu::TextureFormat { self.surface_config.format } diff --git a/native/shared/tests/render_targets.rs b/native/shared/tests/render_targets.rs new file mode 100644 index 0000000..7348f37 --- /dev/null +++ b/native/shared/tests/render_targets.rs @@ -0,0 +1,76 @@ +//! Render-target sizing invariant. +//! +//! The pass chain computes viewports and compute dispatches from +//! `render_extent()` (surface * render_scale) and writes into the +//! depth/HDR/G-buffer targets. Those two must agree from the very first +//! frame. +//! +//! The golden suite cannot cover this: its harness calls +//! `set_taa_enabled(false)` immediately after construction, which +//! internally resizes and so always repairs the invariant before +//! anything renders. A real host does not β€” every `attach_engine` +//! platform (macOS/iOS/tvOS/Linux/Android) boots and then only resizes +//! when the OS-reported window size *changes*, which is false on frame +//! 1. That left those platforms rendering against targets sized to the +//! full surface while the passes ran at half extent: post-FX silently +//! dropped out, and the depth-snapshot copy failed validation outright +//! whenever a scene-reading translucent material was in view. + +use bloom_shared::renderer::Renderer; + +fn try_renderer() -> Option { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .ok()?; + if adapter.get_info().device_type == wgpu::DeviceType::Cpu { + return None; + } + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .ok()?; + Some(Renderer::new_headless(device, queue, 256, 256)) +} + +/// Straight out of the constructor β€” no resize, no TAA toggle, which is +/// exactly what a freshly attached host looks like on frame 1. +#[test] +fn render_targets_match_render_extent_at_construction() { + let Some(r) = try_renderer() else { + eprintln!("no GPU adapter β€” skipping"); + return; + }; + assert_eq!( + r.render_target_extent(), + r.render_extent(), + "render targets disagree with render_extent straight out of the \ + constructor β€” the boot path is not honouring render_scale" + ); +} + +/// The invariant must survive the scale/TAA setters and an explicit +/// resize, not just hold at construction. +#[test] +fn render_targets_track_render_extent_through_changes() { + let Some(mut r) = try_renderer() else { + eprintln!("no GPU adapter β€” skipping"); + return; + }; + + r.set_render_scale(1.0); + assert_eq!(r.render_target_extent(), r.render_extent(), "after set_render_scale(1.0)"); + + r.set_render_scale(0.5); + assert_eq!(r.render_target_extent(), r.render_extent(), "after set_render_scale(0.5)"); + + r.resize(640, 480, 640, 480); + assert_eq!(r.render_target_extent(), r.render_extent(), "after resize"); + + r.set_taa_enabled(false); + assert_eq!(r.render_target_extent(), r.render_extent(), "after set_taa_enabled(false)"); +} diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index 9c647ad..cfd6e52 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -675,15 +675,11 @@ unsafe fn init_engine_for_hwnd( }; surface.configure(&device, &surface_config); - let mut renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); - // Route initial target creation through the same resize path a - // WM_SIZE takes. Without this, windowed mode (which never gets a - // resize, unlike the borderless-fullscreen transition) keeps - // construction-time render targets that ignore render_scale β€” - // the depth-snapshot copy then spans a partial depth texture, - // which wgpu rejects (fatal validation error on the first frame - // with a scene-reading translucent material in view). - renderer.resize(phys_w, phys_h, logical_w, logical_h); + // `Renderer::new` routes initial target creation through `resize` + // itself, so the construction-time targets already honour + // render_scale β€” no explicit resize needed here (it used to live + // here, which left every non-Windows host with the bug). + let renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); let _ = ENGINE.set(EngineState::new(renderer)); }