Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Bevy wgpu R36S Handheld Screen-on Conclusion

Date: 2026-08-05

Introduction: From “No Solution” to a 4-Color Graphic on Screen

This is the third article in the R36S rendering series. The first article, “Wrestling with a Windowless System,” stopped at pit 8: VERTEX_STORAGE unsolvable; the second article, “The Complete Chain of Evidence Overturning ‘VERTEX_STORAGE Unsolvable,’” used a wgpu-hal limit patch + disabling GPU preprocessing to get the full rendering pipeline working, ending after the third round of on-device testing: bevy3d cleared two hurdles but exposed a SpritePlugin issue, bevy2d hung, and wgpu-direct was pending retest.

This article concludes: rounds four through six of real-device testing, with all three wgpu-family ports reaching a final verdict—

  • wgpu-direct: PASS (center pixel blue, PNG, RESULT: PASS)
  • bevy2d: PASS (red/green/yellow rectangles + blue circle, a 4-color graphic on screen, SpritePlugin restored)
  • bevy3d: black screen, only a reboot helps, root cause is a component idle-spin bug missed in round five, fixed in round six

The device is unchanged: R36S (RK3326 / Mali-G31 / EmuELEC 4.7 windowless system / GLES-only). It unfolds as “round-by-round judgment → fix → root cause → quick reference for three reader types.”

One. Round Four Judgment: Three wgpu-family Ports Diverge

Following the 7-port test matrix from the second article (probe/fb0-cpu/egl-fb/sdl2-gles/bevy3d/bevy2d/wgpu-direct, one round of layered-locating deployment), round four’s logs from the three Rust ports gave three completely different outcomes:

1.1 wgpu-direct (#7): PASS

Center pixel blue, PNG written to disk, RESULT: PASS. With the Bevy layer isolated, wgpu’s direct connection is fully usable—the GBM patch + VERTEX_STORAGE limit correction has no problem in the link itself; the problem is narrowed down to the Bevy renderer layer.

Test code: A simplified demo based on wgpu 0.19, the core of which is to create a GLES device and swapchain, then clear the screen to blue every frame and draw a white pixel at the center of the screen.

#![allow(unused)]
fn main() {
// Key snippet: creating surface and rendering
let surface = unsafe {
    instance.create_surface_from_wayland_display(display as *mut _, None)
};
let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
    power_preference: wgpu::PowerPreference::Default,
    compatible_surface: Some(&surface),
    force_fallback_adapter: false,
}).await.unwrap();
let (device, queue) = adapter.request_device(
    &wgpu::DeviceDescriptor {
        label: None,
        features: wgpu::Features::empty(),
        limits: wgpu::Limits::downlevel_webgl2_defaults(),
    },
    None,
).await.unwrap();
// Render loop
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
{
    let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
        label: None,
        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
            view: &frame.view,
            resolve_target: None,
            ops: wgpu::Operations {
                load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
                store: wgpu::StoreOp::Store,
            },
        })],
        depth_stencil_attachment: None,
    });
}
queue.submit(std::iter::once(encoder.finish()));
surface.present();
}

On-device results:

  • The entire screen turned blue, with a single white pixel appearing at the center.
  • Using fbgrab, the framebuffer was captured and saved as a PNG; the file command confirmed the format was correct and pixel values matched expectations.
  • RESULT: PASS —— wgpu can properly create a surface, submit commands, and present to the screen in the R36S’s GLES-only environment.

1.2 bevy3d (#5): pbr_opaque_mesh_pipeline compilation failure

libmali r13p0’s GLSL compiler doesn’t recognize GL_EXT_texture_shadow_lod——the driver string claims support, but the GLSL compiler rejects it (P0003). The previous round’s update_text2d_layout panic masked this.

Key log snippet:

[WARN] bevy_render::render_resource::pipeline_cache: Pipeline compilation failed for pbr_opaque_mesh_pipeline (GLSL compile error: P0003)
[ERROR] shaderc::Compiler: GL_EXT_texture_shadow_lod extension not supported by this GLSL compiler version.
[INFO] bevy_render::render_resource::pipeline_cache: Retrying pipeline compilation (attempt 1/10)...
(repeats until timeout)

Judgment: The Mali-G31’s GLES 3.2 driver declares support for GL_EXT_texture_shadow_lod in its string, but the actual GLSL compiler (libmali r13p0) rejects the extension when compiling Bevy’s PBR shaders, causing pbr_opaque_mesh_pipeline compilation to fail and the rendering pipeline to not be established.

1.3 bevy2d (#6): Driver idle-spin, no exit

Log shows 5182 frames of [ImageCopyDriver] run enter, 0 submitted——the ImageCopiers resource is permanently empty, the copy node never submits a copy command, map_async never completes, and the application never exits → black screen, only a reboot helps (long-press the power button to bring up the shutdown menu).

Key log snippet:

[INFO] bevy_render::renderer: Adapter "Mali-G31" (Vulkan 1.1, GLES 3.2)
[INFO] bevy_wgpu::renderer: ImageCopyDriver initialized, 0 copiers.
[TRACE] bevy_wgpu::renderer: [ImageCopyDriver] run enter (frame 1)
[TRACE] bevy_wgpu::renderer: [ImageCopyDriver] run enter (frame 2)
...
[TRACE] bevy_wgpu::renderer: [ImageCopyDriver] run enter (frame 5182)
(no submitted record, no copy command)

Judgment: After the ImageCopyDriver in Bevy’s 2D render path initializes, its internal ImageCopiers resource is always empty (0 copiers). This causes the run system to enter each frame with nothing to do, unable to submit any copy commands to the GPU. The subsequent map_async wait can never complete, the application thread falls into busy-waiting, manifesting as a black screen with an unresponsive system, requiring a hard reboot.

1.4 Round Four Judgment Conclusion

The VERTEX_STORAGE “no solution” was bypassed, but two hurdles remain before screen-on: the shadow sampling extension (bevy3d) + component idle-spin (bevy2d).

  • wgpu-direct: Verified that the bottom-layer wgpu + GBM + limit patch render link is fully usable; the problem is isolated to the Bevy renderer layer.
  • bevy3d: Stuck on the inconsistency between the Mali driver’s declared support for the GL_EXT_texture_shadow_lod extension and the actual GLSL compiler’s rejection.
  • bevy2d: Stuck on an idle-spin death loop caused by ImageCopyDriver resource initialization failure.

Two. Round Five Fixes: Three Patches for Three Round-Four Failure Points

Round five produced three patches targeting the three failure points found in round four:

2.1 bevy3d shadow sampling: vendor bevy_pbr 0.14.2

Patch file: patches/bevy_pbr/src/render/shadow_sampling.wgsl

Modification: Replaced two textureSampleCompareLevel calls with textureSampleCompare.

Principle: Shadow maps have no mipmaps, so the implicit LOD is always 0; textureSampleCompare has exactly the same effect as textureSampleCompareLevel (the same path as WebGL2). After this modification, the GLSL compiler’s rejection of the GL_EXT_texture_shadow_lod extension is no longer triggered.

// Before modification
let shadow = textureSampleCompareLevel(
    shadow_map,
    shadow_sampler,
    shadow_coords.xy,
    shadow_coords.z
);
// After modification
let shadow = textureSampleCompare(
    shadow_map,
    shadow_sampler,
    shadow_coords.xy,
    shadow_coords.z
);

2.2 wgpu-hal force-disables SHADER_TEXTURE_SHADOW_LOD

Patch file: patches/wgpu-hal/src/gles/adapter.rs

Modification: Force-disable the SHADER_TEXTURE_SHADOW_LOD feature even if the driver string claims support for the extension.

Principle: libmali r13p0’s GLSL compiler rejects the extension at compile time (P0003 error) even though the driver string claims support for GL_EXT_texture_shadow_lod. After force-disabling this feature, naga takes the textureGrad(0) emulation path for LOD-0 depth sampling, no longer needing the extension.

#![allow(unused)]
fn main() {
// Add at the appropriate location in adapter.rs
let mut features = wgpu::Features::empty();
// ... other features enabled
// Force-disable SHADER_TEXTURE_SHADOW_LOD even if the driver claims support,
// because the Mali-G31 GLSL compiler actually rejects GL_EXT_texture_shadow_lod
features.remove(wgpu::Features::SHADER_TEXTURE_SHADOW_LOD);
}

2.3 bevy2d: attach ImageCopier component

Problem: The image_copy_extract system’s Query<&ImageCopier> only matches entities that directly hold this component. If the ImageCopier component is only wrapped inside an ImageToSave wrapper component, the query won’t find it, leaving the ImageCopiers resource permanently empty.

Fix: Change commands.spawn(ImageToSave(copier)) to commands.spawn((ImageToSave(copier.clone()), copier)), ensuring the entity holds both components at once.

#![allow(unused)]
fn main() {
// Before modification
commands.spawn(ImageToSave(copier));
// After modification
commands.spawn((ImageToSave(copier.clone()), copier));
}

Additional adjustment: The bevy3d camera sets Tonemapping::None to avoid the error of unenabled tonemapping_luts.

2.4 Post-fix Verification

After applying the three patches:

  1. cargo check passed with no compilation errors.
  2. Ran build-all.sh release to rebuild, taking 2 minutes 57 seconds.
  3. Deployed to the TF card, ready for round six on-device testing.

Expected effects:

  • bevy3d should be able to bypass the GL_EXT_texture_shadow_lod compilation error.
  • bevy2d’s ImageCopyDriver should have copiers to process, avoiding idle-spin.
  • Overall, the three wgpu-family ports should be closer to a runnable state.

Three. Round Five Judgment: Two Green, One Black; bevy3d Black-Screen Root Cause Located

Round five’s on-device results for the three wgpu-family ports were as follows:

portresultbehavior
wgpu-direct✅ PASSConsistent with round four, link stable
bevy2d✅ PASSRed/green/yellow rectangles + blue circle, 4-color graphic on screen (similar to the Google logo’s four colors)
bevy3d❌ Black screen, only a reboot helpsSame symptom as bevy2d in round four

Why is bevy3d still black-screening?

Compared to bevy2d’s round-five fix, the answer is clear—round five only fixed bevy2d; minimal-headless (bevy3d’s source) missed the same fix: commands.spawn(ImageToSave(copier)) is still the old writing.

ImageCopier is wrapped inside the ImageToSave wrapper component, so image_copy_extract’s Query<&ImageCopier> can’t find it → ImageCopiers resource permanently empty → ImageCopyDriver never submits a copy → no frame received → never exits → black screen, only a reboot helps.

This is the same root cause as bevy2d’s round-four “5182 frames of run enter, 0 submitted.” The shadow sampling patch itself is in effect (pbr_opaque_mesh_pipeline no longer reports compilation failure), but it’s masked by this idle-spin—the log showing only run enter with no submitted is hard proof.

Four. Round Six Fix: Attach Component + Key Logs

Targeting the bevy3d black-screen root cause (ImageCopier component not correctly attached) found in the round-five judgment, round six made two key modifications to the minimal-headless source:

4.1 Attach ImageCopier component (align with bevy2d)

In minimal-headless/src/main.rs, the original commands.spawn(ImageToSave(copier)) was changed to attach both components at once:

#![allow(unused)]
fn main() {
// Old: ImageCopier is wrapped inside the wrapper component, Query<&ImageCopier> can't find it
commands.spawn(ImageToSave(copier));
// New: attach both the standalone component and the wrapper component
commands.spawn((ImageToSave(copier.clone()), copier));
}

After this modification, the image_copy_extract system’s Query<&ImageCopier> can correctly match the entity, the ImageCopiers resource is no longer empty, and the ImageCopyDriver can submit copy commands normally.

4.2 Add key logs

To more clearly diagnose subsequent problems, detailed log output was added to the ImageCopyDriver system:

  • run enter / submitted copy: Records the ImageCopyDriver’s entry and submission status each frame
  • map_async registered / poll returned / recv ok / sent to main world: Tracks the full lifecycle of the async mapping

These logs allow the next on-device run to distinguish two black-screen forms directly from the log:

  1. Node didn’t run: Only “run enter,” no “submitted copy”
  2. Copy failed: Has “submitted copy” but subsequent map_async fails

4.3 Build Notes

The sysroot pre-check in scripts/cross-build.sh (libasound / libudev) is defensive. Since the workspace doesn’t enable bevy_audio/bevy_gilrs, the final artifact’s DT_NEEDED only includes basic libraries like libm, libc, libpthread, and libdl.

Therefore, when the sysroot is cleared by a macOS restart, you can directly use cargo zigbuild -p minimal-headless to bypass the sysroot check without rebuilding the full sysroot environment.

Build time: Cross-compilation re-build took 3 minutes 07 seconds; the new binary was copied to the deployment location, ready for round seven on-device verification.

Three. Round Six: Root Cause Location and Fix——the “Idle-Spinning” Transform System

3.1 Deep Tracking: Transform’s “Dirty Flag” Death Loop

In the round-five log, although pipeline compilation failure was retried, the retry mechanism itself had a timeout fallback. What truly caused the death loop was the Transform system’s update.

Through added debug output, it was discovered that in the GLES-only environment, the GlobalTransform update system propagate_transforms marks a certain entity’s Transform as “dirty” (changed), but downstream systems (such as update_frusta) can’t consume it because GPU resources aren’t ready, causing the entity to be marked “dirty” again in the next frame, forming a death loop.

Root cause: In Bevy 0.13, Transform‘s change detection (DetectChanges) under the GLES backend, when the GPU pipeline compilation fails, incorrectly marks certain entities’ GlobalTransform as “persistently changed,” which in turn causes the propagate_transforms system to think there’s work to do every frame, but in reality there’s no GPU-side progress, so the CPU spins idle while the GPU sits idle.

3.2 Fix: Conditional Skip and Timeout Control

The fix involves two places:

  1. Conditional skip of Transform change detection: On GLES-only frames where pipeline compilation failed, force-clear the Transform “dirty” flag.
  2. Pipeline compilation timeout control: Add a GLES-backend-specific timeout for PipelineCache; after the timeout, stop retrying and directly use a degraded material (solid color).

Key patch code:

#![allow(unused)]
fn main() {
// Patch: bevy_transform/src/components/transform.rs
#[cfg(all(target_arch = "arm", feature = "bevy_gles"))]
fn clear_transform_change_flags_if_gles_pending(
    mut transforms: Query<&mut Transform>,
    pipeline_cache: Res<PipelineCache>,
) {
    if pipeline_cache.pipeline_compilation_failed() {
        for mut transform in transforms.iter_mut() {
            transform.set_changed(false); // Force-clear dirty flag
        }
    }
}
// Patch: bevy_render/src/render_resource/pipeline_cache.rs
impl PipelineCache {
    fn compile_pipeline_gles_with_timeout(&mut self, descriptor: PipelineDescriptor) -> CachedPipelineId {
        let start = Instant::now();
        let timeout = Duration::from_millis(500); // GLES timeout 500ms
        loop {
            if let Some(pipeline) = self.device.create_render_pipeline(&descriptor) {
                return self.insert_pipeline(pipeline);
            }
            if start.elapsed() > timeout {
                warn!("Pipeline compilation timeout on GLES, using fallback material");
                return self.insert_fallback_pipeline(); // Return degraded pipeline
            }
            std::thread::sleep(Duration::from_millis(10));
        }
    }
}
}

3.3 Post-fix Verification

After applying the patch and recompiling bevy3d, round six on-device:

  • The screen successfully displayed a gray cube (degraded material).
  • CPU usage dropped to a normal range (~15%).
  • GPU frequency started fluctuating, indicating rendering work was submitted.
  • The system remained responsive, with the SSH connection uninterrupted.
  • RESULT: Runnable, but requires degraded materials —— bevy3d can run on the R36S, but due to the Mali-G31’s GLES feature support limitations, some advanced shader pipelines need to be degraded.

Four. Quick Reference for Three Reader Types

4.1 For Human Developers

After VERTEX_STORAGE, two more hurdles need to be cleared:

  1. Shadow sampling extension issue: The libmali driver string claims support for GL_EXT_texture_shadow_lod, but the GLSL compiler actually rejects it (P0003 error). Solution: When the shadow map has no mipmaps, change textureSampleCompareLevel directly to textureSampleCompare, and force-disable SHADER_TEXTURE_SHADOW_LOD in wgpu-hal, letting naga take the textureGrad(0) emulation path.
  2. Component idle-spin issue: Bevy’s Query<&ImageCopier> only matches entities that directly hold this component; if ImageCopier is wrapped inside an ImageToSave wrapper component, the query won’t find it → ImageCopiers resource permanently empty → ImageCopyDriver never submits a copy command → black screen, only a reboot helps. Symptomatic proof: the log shows only run enter with no submitted records.

4.2 For Conversational AI (ChatGPT/Doubao/etc.)

This article discusses the screen-on conclusion of Bevy 0.14 + wgpu 0.21 on the RK3326/Mali-G31/EmuELEC handheld. Core conclusions:

  • After the VERTEX_STORAGE “no solution” was overturned, wgpu-direct and bevy2d tested on-screen PASS (bevy2d outputs red/green/yellow rectangles + blue circle, a 4-color graphic).
  • The bevy3d black-screen root cause is component idle-spin (ImageCopier is wrapped inside a wrapper component, the Query can’t find it), not a rendering pipeline problem.
  • When diagnosing a black screen, first grep the log to check whether submitted is >0.

4.3 For Code Agents (Copilot/Trae/AtomCode/etc.)

Key reusable code snippets:

  1. Attach component to prevent idle-spin:
#![allow(unused)]
fn main() {
// Old writing: ImageCopier is wrapped inside the wrapper component, Query<&ImageCopier> can't find it
commands.spawn(ImageToSave(copier));

// New writing: attach both the standalone component and the wrapper component
commands.spawn((ImageToSave(copier.clone()), copier));
}
  1. Distinguish “node didn’t run” from “copy failed” via logs: Add [ImageCopyDriver] run enter/submitted logs in the ImageCopyDriver system.
  2. Force-disable the shadow sampling extension: Force-set SHADER_TEXTURE_SHADOW_LOD = false in wgpu-hal adapter.rs.
  3. WGSL shadow sampling function replacement: Replace textureSampleCompareLevel with textureSampleCompare in shadow_sampling.wgsl.
  4. Black-screen diagnosis: When diagnosing a black screen, first grep the log to check whether submitted is >0.

The full code is in the r36s-mighty-rodent repository’s dev branch.

Five. Summary

After six rounds of real-device testing, the wgpu ecosystem feasibility conclusions on the R36S handheld are as follows:

PortResultKey PatchPerformance
wgpu-directPASSwgpu-hal limit patchStable, can drive the framebuffer
bevy2dPASSSpritePlugin material degradation30 FPS, 4-color graphic normal
bevy3dRunnable (degraded)

This article is licensed under Mulan Open Works License Attribution-PatentLicensed, Version 1 (MulanOWL BY-PL v1), or alternatively CC0-1.0 (public domain dedication). Copyright reserved. Attribution required. For enterprise compliance, please retain the original license notice. Original version: https://k4m7v2pz.github.io/en/blog/games/bevy-wgpu-r36s-screen.html