Skip to content

Feature request: expose the surface present mode (FIFO is hardcoded in two places) and request the panel refresh rate #444

Description

@smm-h

Hi — first, thank you for this library. I have a React Native app rendering a three.js WebGPU scene through it on Dawn/Vulkan, and it works; this is a request from having profiled that app on a device, not a complaint.

Two related things about frame delivery, with the evidence I gathered.

1. Present mode is hardcoded to FIFO, in two places

Verified against the installed react-native-webgpu@0.8.2 and re-checked against main (same line numbers in both):

  • packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp:30

    surfaceConfiguration.presentMode = wgpu::PresentMode::Fifo;

    set unconditionally at the end of GPUCanvasContext::configure(), after the format/usage conversion.

  • packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h:242

    _config.presentMode = wgpu::PresentMode::Fifo;

    inside SurfaceInfo::configure(), which re-stamps FIFO over whatever wgpu::SurfaceConfiguration the caller passed in, right after the width/height clamp.

The second one is what makes this a hard wall rather than a default: even if a JS caller could express a present mode, SurfaceRegistry::configure() would overwrite it. And there is no way to express one anyway — GPUCanvasConfiguration (packages/webgpu/cpp/rnwgpu/api/descriptors/GPUCanvasConfiguration.h:16) carries only device, format, usage, viewFormats and alphaMode. wgpu::PresentMode itself has FifoRelaxed, Immediate and Mailbox available (cpp/webgpu/webgpu_cpp.h:505-511), so the Dawn side already supports the alternatives.

I understand why FIFO is the safe default (it is the only mode guaranteed present on every Vulkan surface, and it is what the web canvas contract implies). The ask is not to change the default — it is to make it reachable.

Why this shows up as a frame-rate cliff

FIFO is a strict queue, and Dawn's Vulkan backend configures the surface with a minimal image count for it. With a shallow chain there is essentially no pipelining slack: the app cannot get ahead of the compositor, so getCurrentTexture() blocks the JS thread waiting for an image, and a frame that misses one vsync waits for the whole next one.

That quantizes the achievable frame rate to refresh / N — you get refresh/1, refresh/2, refresh/3, refresh/4 and nothing in between. The measured consequence in my app, on a 120 Hz-capable panel:

  • JS-side frame work measured at 26-30 ms
  • delivered rate: exactly 30 fps, i.e. 120/4 — pinned there, not fluctuating

26-30 ms of work is between 3 and 4 vsync intervals at 8.33 ms, so it lands on the 4-vsync rung and stays there. The 3.5-vsync average that the work actually costs is unreachable. Under a mailbox or immediate mode, or simply a deeper chain, the same work would deliver somewhere in the 34-38 fps range instead. The gap is not a small one, and it is entirely a present-path artifact — the frame budget itself is unchanged.

This also makes profiling misleading: shaving 3 ms off a frame produces exactly zero change in delivered fps until the work crosses a rung boundary, at which point it jumps by 10 fps at once. Every optimization reads as "no effect" until suddenly it reads as a large one.

Ask

Either of these would resolve it, and the first is the smaller change:

  1. Expose the present mode — a presentMode field on the canvas configuration (or a view/component prop), honored in GPUCanvasContext::configure() and not overwritten in SurfaceRegistry::configure(), with FIFO kept as the default. Mailbox and Immediate where the platform reports support for them (Dawn exposes SurfaceCapabilities::presentModes, so support can be checked rather than assumed).
  2. Or widen the swapchain — request a deeper image count for FIFO so there is at least one frame of pipelining slack, which recovers most of the same headroom without exposing new API surface.

If neither is wanted as public API, even an escape hatch (a build flag, or a native-side setter) would let apps that are demonstrably present-bound get out of the corner.

2. Nothing requests a high refresh rate

Separately, and compounding the above: nothing in the package asks the platform for anything above 60 Hz. I searched the whole shipped package (android/, apple/, cpp/, src/, excluding build/ and node_modules/) for setFrameRate, FRAME_RATE, preferredFramesPerSecond and CADisplayLinkzero hits. A repo-wide code search on main for setFrameRate also returns nothing.

WebGPUSurfaceView.java is 43 lines and just forwards the surface:

  @Override
  public void surfaceCreated(@NonNull SurfaceHolder holder) {
    mApi.surfaceCreated(holder.getSurface());
  }

  @Override
  public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) {
    mApi.surfaceChanged(holder.getSurface());
  }

(lines 30-37). Android schedules an app window at 60 Hz unless something asks otherwise, so on a 120 Hz panel the frame loop is capped at 60 fps regardless of how cheap a frame is — and any reading above 60 is a reading of the cap, not of the app.

Surface.setFrameRate(float, int) (API 30+) is exactly the call for this, and surfaceCreated/surfaceChanged above are where it would go. My app currently works around it at the Activity level (window.attributes.preferredRefreshRate = 120f in MainActivity.onCreate), which works, but it is a per-app workaround for something the surface owner is better placed to do — and it does not help anyone who does not know to look.

Requesting the rate on the surface would also compose better with (1): under FIFO, the refresh rate is the rung spacing, so a 120 Hz window has rungs at 8.33 ms rather than 16.67 ms and the quantization hurts half as much.

Ask

Request a frame rate on the Surface (API-checked, CHANGE_FRAME_RATE_ALWAYS or the default compatibility), ideally with a prop to opt out or to name a target — and the iOS equivalent (CADisplayLink.preferredFrameRateRange / preferredFramesPerSecond) if the same applies there.

Related

#211 ("Why is there no automatic present?") is the other end of the same pipeline — it asks for a web-parity automatic present rather than manual context.present(). Whatever shape the present path ends up in there, the present mode and the requested refresh rate are the two inputs that decide what rate the result can actually hit, so it seemed worth writing them down as their own request rather than folding them into that thread.

Environment

  • react-native-webgpu@0.8.2 (current latest tag), React Native 0.81.4
  • Android, Dawn/Vulkan, arm64, 120 Hz panel
  • three.js WebGPU renderer

Happy to run experiments on the device if a patched build would help — I have per-frame CPU/GPU timings and a debug sink already wired up, so measuring a candidate change against the same scene is cheap on my side.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions