Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/tickets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

27 changes: 25 additions & 2 deletions native/shared/src/renderer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
76 changes: 76 additions & 0 deletions native/shared/tests/render_targets.rs
Original file line number Diff line number Diff line change
@@ -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<Renderer> {
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)");
}
14 changes: 5 additions & 9 deletions native/windows/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
Loading