Fix black screen: prewarm pipelines on every backend, not just Vulkan - #555
Fix black screen: prewarm pipelines on every backend, not just Vulkan#555samoylenkodmitry wants to merge 1 commit into
Conversation
…kend The black-screen bug this fixes: on an Android emulator with no Vulkan adapter, the GL fallback pays 40+ seconds per shader-pipeline compile, and nothing is presented until every pipeline a first frame needs has compiled. A prior fix (f494e37) added a background prewarm thread and a persisted wgpu::PipelineCache, but wgpu-hal only grants Features::PIPELINE_CACHE on Vulkan (confirmed by reading wgpu-hal's adapter.rs), and the prewarm thread only ran when that feature was granted -- it built throwaway pipelines that only paid off through the driver reusing the shared cache's compiled code. GL, Metal, DX12 and wasm's WebGL/WebGPU got zero mitigation: the exact backend the bug report describes was never fixed. PassPipeline::new now returns a SharedPassPipeline (Arc off wasm, Rc on wasm, where wgpu handles are neither Send nor Sync) instead of a bare value. The prewarm thread races the render thread's own first-use get_or_init on the SAME OnceLock-backed slot instead of building a pipeline it immediately drops: whichever side gets there first wins, and the other blocks on OnceLock's own wait instead of compiling a redundant pipeline. This works with or without a device pipeline cache, so it now runs unconditionally rather than being gated on `pipeline_cache.is_some()`. render::tests::pipeline_prewarm_fills_the_shared_slot_without_a_device_pipeline_cache requests a device with zero features (so PIPELINE_CACHE is absent regardless of what the adapter under the test runner actually supports) and asserts prewarm still fills the shared slot. Confirmed red against the old gate by temporarily reinstating it and rerunning on this machine's real (non-Vulkan) adapter before writing the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Field evidence: this predicts a real, measured symptom on real hardwareIndependent corroboration, from an unrelated app (cranpose-orbit, a WGSL runtime shader showcase) pinned to cranpose 0.1.104 — i.e. pre-this-fix — that measured pipeline compilation on real devices while investigating something else entirely. On a Pixel 9 Pro with a working Vulkan adapter, it observed two compiles for the one shared shader module, logged as an unexplained framework-internal curiosity. That is exactly the redundant compile this PR describes: the old prewarm thread built and dropped a throwaway pipeline while the render thread separately compiled its own on first use, because the two never shared the same Numbers, all real devices with real Vulkan adapters, no emulator:
This also narrows the original bug report's scope, worth recording here: on real Vulkan hardware there is no 90-second black screen — that figure was the software-GL emulator path (no Vulkan adapter at all, GL fallback, where wgpu-hal never grants |
What this changes
Cranpose apps could show a black screen for up to 90 seconds at launch — reported on an Android emulator with no Vulkan adapter, where the GL fallback pays 40+ seconds per
create_render_pipelinecall, and nothing is presented until every pipeline the first frame needs has compiled.A prior fix (f494e37, "Give every pipeline creation a persistent device pipeline cache") added a background prewarm thread plus a persisted
wgpu::PipelineCache, intended to move that compile cost off the render thread. Butwgpu-halonly grantsFeatures::PIPELINE_CACHEon the Vulkan backend (confirmed by readingwgpu-hal-29.0.3/src/vulkan/adapter.rs— no GL, Metal, or DX12 backend sets that flag), and the prewarm thread was gated onpipeline_cache.is_some(). It also worked by building a throwaway pipeline and dropping it, hoping the driver's pipeline-cache blob would make the render thread's own later compile fast — a mechanism that only exists on Vulkan. So GL, Metal, DX12 and wasm's WebGL/WebGPU — every backend except Vulkan — got zero mitigation. The exact backend the bug report describes (GL, no Vulkan adapter) was never actually fixed by that prior work.The fix
PassPipeline::new(crates/cranpose-render/wgpu/src/lazy_resource.rs) now returns aSharedPassPipeline(Arcoff wasm;Rcon wasm, where wgpu's handles are neitherSendnorSync) instead of a bare value. The prewarm thread (crates/cranpose-render/wgpu/src/render.rs) now races the render thread's own first-useget_or_initon the sameOnceLock-backed slot, instead of building a pipeline it immediately drops. Whichever side gets there first wins; the other blocks onOnceLock's own wait instead of compiling a redundant pipeline. This mechanism needs no device-level pipeline cache to pay off, so prewarming now runs unconditionally rather than being gated onpipeline_cache.is_some(). The device pipeline cache and its disk persistence are unchanged and still help Vulkan warm-start across launches.Verification
Per this repo's debugging discipline: confirmed the cause by reverting to the old gate and rerunning the new test against this machine's real (non-Vulkan) adapter — it timed out waiting for the shared slot, exactly as the bug predicts. Restored the fix and it passed in 0.04s.
New test:
render::tests::pipeline_prewarm_fills_the_shared_slot_without_a_device_pipeline_cache. It requests a device with zero features, soFeatures::PIPELINE_CACHEis absent regardless of what the adapter under the test runner actually supports (Vulkan CI included) — so the test deterministically exercises the previously-broken "no device cache" path on any machine, not just a GL host.What I could and couldn't verify here
I do not have Android emulator/device access in this environment (and was instructed not to use the shared build host that does), so I could not reproduce the original 90-second GL scenario end-to-end. The fix and its test are backend-agnostic by construction and were validated on this machine's real (Metal, non-Vulkan) adapter, which exercises the same "no
PIPELINE_CACHE" code path GL hits.Residual gap, not fixed here: even with this fix, a first frame still presents nothing until its content pipelines finish compiling. wgpu-hal's GLES backend serializes all GL calls behind one context lock, so moving a compile to a background thread does not shrink GL's own wall-clock compile time — it removes redundant compiles and lets non-GPU startup work overlap, but on a genuinely slow GL driver the app can still show nothing for a while. Fully guaranteeing "never presents nothing for a perceptible period" needs a second piece: presenting an immediate placeholder (a clear-color pass needing zero pipelines) the moment the surface is configured. I've flagged that as separate follow-up work (present-thread startup in
crates/cranpose-render/wgpu/src/present_runtime.rs) rather than rushing an untested cross-platform change into this PR.Checklist
just cipasses in full, run locally end to end:fmt-check,typos,versions,clippy(workspace,-D warnings, zero warnings),doc(-D warnings, clean), andtest(cargo test --profile ci --workspace) — 0 failures across the whole workspace. Also ran the wasm target (clippy-wasm's exact feature set) and the camera-desktop/robot feature combo (clippy-camera-desktop's exact command) clean. Did not runbudgets(dependency/size budgets) locally — nothing in this change adds a dependency or should move the binary-size ceiling; left it to CI.PassPipeline::newhas exactly one return type everywhere it's constructed.