Skip to content

Background blur for layer surfaces and translucent windows - #3

Merged
FakeShell merged 6 commits into
FuriLabs:forkyfrom
wayne-tbl:glass-theme
Sep 23, 2026
Merged

FakeShell merged 6 commits into
FuriLabs:forkyfrom
wayne-tbl:glass-theme

Conversation

@wayne-tbl

@wayne-tbl wayne-tbl commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Background blur for layer surfaces that ask for it, and for translucent
application windows.

  • layer-shell-effects: add a background blur request — bumps
    zphoc_layer_shell_effects_v1 to version 4 and adds
    get_blur_layer_surface. The radius is double buffered and takes effect on
    wl_surface.commit, following the alpha surface it is modelled on.
  • layer-surface: track a background blur radius
  • layer-shell-effects: implement the blur layer surface — mirrors
    PhocAlphaLayerSurface: same lifecycle, same listeners.
  • renderer: blur the backdrop of translucent surfaces
  • tests: exercise the blur layer surface

The renderer commit is the large one and does not split further — the filter,
the capture and the two call sites are one mechanism. Its shape is forced by the
hardware rather than chosen: on the android renderer a wlr_allocator buffer
cannot back a render target (no dmabuf import, no native window), and an earlier
revision that rendered into one produced incomplete framebuffers and aborted the
compositor at startup. phoc says as much itself, in the "Do not use
wlr_allocator on android"
path of phoc_renderer_render_view_to_buffer(). So
the dual-Kawase pyramid runs on plain GL textures and FBOs, with the source
copied out of the output's own framebuffer with glCopyTexSubImage2D from
inside the render pass. The pyramid halves resolution per level rather than
widening the tap offset: past roughly a texel of separation the samples stop
overlapping and read as discrete copies — a visible star pattern on diagonal
taps.

Application windows are blurred without asking, decided from their opaque
region, since a toplevel has no way to request it over the layer shell effects
protocol.

Nothing is blurred unless a surface asks for it, so that request is the opt-in.
On top of that the blur gsetting switches the whole effect off, for a device
whose GL path misbehaves, without downgrading the compositor. It defaults to
on, is read at startup and followed while phoc runs -- the same shape as the
touch-points key in the screen-capture PR, damaging the outputs on change
because the backdrop is captured from the render pass. An earlier revision of
this branch used a PHOC_BLUR environment variable for that; it has been
replaced by the setting at review request, and is gone.

On testing: the GL path only runs on a device. The suite forces
WLR_RENDERER=pixman and no gles2 renderer can be created under Xvfb, so what
the new test covers is the protocol plumbing and the fallback when blur is
unavailable. The renderer itself has been exercised on an FLX1 (Halium/hybris,
Mali-G68) across many boots, including rotation.

How it reaches the GPU

Nothing is read back to the CPU anywhere in this path -- there is no
glReadPixels and no buffer mapping.

phoc_renderer_capture_blur() runs inside the output's existing
wlr_render_pass, called from render_layer() just before a surface that
asked for blur is drawn. It does not begin a render pass of its own, and that
is deliberate: the android renderer has no dmabuf import and no native window
for an offscreen wlr_allocator buffer, so a second render target is not
available here -- phoc_renderer_render_view_to_buffer() already says as much.

  • Capture -- glCopyTexSubImage2D copies the output framebuffer, as it
    stands at that moment, into a GL texture. Driver-side blit.
  • Blur -- a Kawase pyramid over plain GL textures and FBOs, down-sample and
    up-sample shaders drawing a 4-vertex triangle strip. The pyramid starts at
    quarter resolution, cutting the fill rate of every iteration by 16.
    Depth follows the radius: levels = round(log2(max(radius, 8) / 8)), clamped
    to 0..4, so radius 8 is one level and each doubling buys one more.
  • Composite -- render_blur_backdrop() draws the result as a raw GL quad
    under the surface.
  • State -- one PhocBlurState per output (capture texture, five pyramid
    textures and their FBOs) in a hashtable on PhocRenderer, dropped by
    phoc_renderer_forget_output(). Allocated once per output, not per frame.
    The FBO binding and surrounding GL state are saved and restored, since this
    borrows wlroots' render pass rather than owning it.

Cost when idle is nil, because phoc_output_draw() renders no frame at all
when nothing is damaged. The cost that is real: when blur is active and the
damage ring is non-empty the whole output is damaged, since a blurred pixel
depends on hundreds of pixels around it and partial redraw artifacts. So
anything that damages a small region every frame repaints the whole output.
That promotion is now asked for twice before it happens — a surface must want
blur and the renderer must be able to draw it — so with blur false nothing
is promoted. gsettings set sm.puri.phoc blur false gives a live A/B on the
same device.

Pairs with FuriLabs/phosh#4, which requests the blur. phosh binds this
protocol only if the compositor offers it and is deliberately outside its
"lacks needed globals" fatal check, so it still starts against a stock phoc and
simply gets no blur. The two can land in either order.

6 commits, off forky.

Updated at review request. Two comment additions that had been appended to
existing lines now sit on their own lines, so those lines are untouched again;
and tests/testlib.c is back to upstream's DISPLAY / WLR_BACKENDS handling
(tests/testlib.c is +5/-0, just the FuriOS global's bind and cleanup). The only
upstream hunk this branch still changes is in src/output.c, where the damage
region has to be computed before the render pass so the backdrop capture can
sample it — 3 lines, a move rather than a rewrite.

17/17 tests pass under xvfb-run.

Updated after the performance review

The whole-output damage is real and stays. Narrowing it was implemented and
tested on device, and withdrawn: the patch is computed from a capture whose
other parts were taken at a different time, so it does not match what surrounds
it — visibly, as a block of different blur behind each key of the lock screen
keypad — and the framebuffer outside the damage still holds the previous
frame's composite, blurred surface included, which the pyramid then feeds back
into itself. Growing a margin does not fix it: the pyramid's coarse levels are
a sixty-fourth of the output, so one of their texels is sixty-four pixels wide.
A pyramid's footprint is not local.

The promotion never consulted the renderer, and that was a defect rather than
a slow path.
Both drawing paths check phoc_renderer_blur_enabled; the damage
test did not. With sm.puri.phoc blur false every damaged frame was still
promoted to a full repaint and no blur was drawn. Fixed.

Also from that pass, all behaviour-preserving:

  • The blur radius is scanned once a frame and handed to the renderer in the
    render context, rather than rediscovered for every surface drawn.
    phoc_output_get_blur_radius() is gone, and with it the output.h change —
    this branch no longer touches that header at all.
  • Redundant GL state changes dropped from the pyramid's inner loop.
  • phoc_view_wants_blur() moved to src/view.c, beside the view code it
    reasons about.
  • The protocol's documented range was wrong. It said 0-100.
    levels = round(log2(max(radius, 8) / 8)) clamps to 4, so the filter
    saturates at 128. The XML now says 0 to 128, that values above are
    clamped, and that the compositor may quantise within the range so two
    neighbouring values can produce the same result. phosh#4's glass-blur-radius
    range was 0-200 and is corrected to match.

Re-measured after this pass, on an FLX1 this time — see the comment below.
The first table in this thread was taken on an FLX1s before the pass and is
superseded by it. Short version: nothing at idle, and roughly double during
animation, which is the whole-output damage above.

@wayne-tbl
wayne-tbl force-pushed the glass-theme branch 3 times, most recently from 4837467 to f9f485a Compare August 18, 2026 11:18
@wayne-tbl

Copy link
Copy Markdown
Contributor Author

Blur cost, measured. FLX1s (720x1600), six runs, two per configuration.

phoc, % of one core:

phase glass off glass on
idle 0.53 0.50
drawer 6.78 12.08
app grid 11.02 22.42
app open 13.80 27.00

Zero at idle, roughly double during animation, peak near half a core. phosh unaffected.

Repeat runs of the same configuration reproduce phoc to 0.3-6% on drawer and app grid, so the doubling sits well outside noise. Governor pinned to performance, background package activity killed, every run started from the same temperature.

Caveats: the FLX1 has 2.3x the pixels and is not yet measured, and this is CPU only. The kawase pass is GPU work and was not measured.

The blur request needs somewhere to live. Adding it to
zphoc_layer_shell_effects_v1 would mean editing a protocol phoc owns:
our version 4 and any version 4 phoc later defines would be two
different things under one name, and a client could not tell them
apart.

So define zphoc_furios_layer_shell_effects_v1 instead, in a file of our
own, with get_blur_layer_surface and the zphoc_furios_blur_layer_surface_v1
it hands back. phoc's protocol is left exactly as it is.

The radius is documented as 0 to 128, with neighbouring values allowed to
look the same: the compositor quantises it into pyramid levels, and above
128 every value is the same picture.

Signed-off-by: wayne <wayne@furilabs.com>
Signed-off-by: wayne <wayne@furilabs.com>
Mirrors PhocAlphaLayerSurface: same lifecycle, same listeners, pending
radius applied on commit. Changing the radius damages the whole output,
since blurred pixels do not follow the surface's own damage.

The request arrives on our own global rather than phoc's, so this binds
zphoc_furios_layer_shell_effects_v1 alongside the existing one and leaves
zphoc_layer_shell_effects_v1 at version 3, exactly as phoc defines it.

Signed-off-by: wayne <wayne@furilabs.com>
A dual-Kawase pyramid over plain GL textures and framebuffers. The source
is the output's own framebuffer, copied with glCopyTexSubImage2D from
inside the render pass.

That shape is forced by the hardware rather than chosen: on the android
renderer a wlr_allocator buffer cannot back a render target -- no dmabuf
import, no native window -- and an earlier revision that rendered into
one produced incomplete framebuffers and aborted the compositor at
startup. phoc says as much itself, in the "Do not use wlr_allocator on
android" path of phoc_renderer_render_view_to_buffer().

The pyramid halves resolution per level rather than widening the tap
offset, because past roughly a texel of separation the samples stop
overlapping and read as discrete copies -- on diagonal taps, a visible
star pattern.

Application windows are blurred without asking, decided from their opaque
region, since a toplevel has no way to request it over the layer shell
effects protocol. The test is "claims no opaque pixels at all" rather than
"does not cover itself": a window with rounded corners reports an opaque
region a few pixels short of its own extent while being opaque everywhere
it paints, and the weaker test would put a blur pass behind it that
nothing can see.

The output damages itself whole while a blurred surface is up, and copies
the whole framebuffer, and both are deliberate. The pyramid's coarse levels
are a sixty-fourth of the output, so one of their texels is sixty-four
pixels wide and a blurred pixel is a weighted sum of content from hundreds
of pixels away, recomputed over the whole capture every frame. Repainting
only around the damage was tried on device: the patch is computed from a
capture whose other parts were taken at a different time, so it does not
match what surrounds it -- visibly, as a block of different blur behind
each key of the lock screen keypad -- and the framebuffer outside the
damage still holds the previous frame's composite, blurred surface
included, which the pyramid then feeds back into itself. Neither is fixable
by growing a margin: a pyramid's footprint is not local.

What that costs is bounded by asking first, in phoc_output_draw(): nothing
is promoted unless a surface has asked for blur and the renderer can draw
it. An idle screen damages nothing and renders nothing at all.

The radius is scanned once a frame and handed to the renderer in the
render context, rather than rediscovered for every surface drawn.

Nothing is blurred unless a surface asks for it, so that request is the
opt in.

Signed-off-by: wayne <wayne@furilabs.com>
Blur is on by default and a surface only gets it by asking over the layer
shell effects protocol, but a device whose GL path misbehaves needs a way
to switch the effect off without downgrading the compositor. Read at
startup and followed while phoc runs; the backdrop is captured from the
render pass, so nothing frosts or clears until a frame is asked for.

Signed-off-by: wayne <wayne@furilabs.com>
Covers the request and that a blurred surface still renders correctly.
The GL path itself only runs on a device: the suite forces
WLR_RENDERER=pixman and no gles2 renderer can be created under Xvfb, so
what runs here is the protocol plumbing and the fallback when blur is
unavailable.

Signed-off-by: wayne <wayne@furilabs.com>
@wayne-tbl

Copy link
Copy Markdown
Contributor Author

Re-measured after the performance pass, on an FLX1 this time (krypton,
1080x2412, Mali-G68) rather than the FLX1s of the table above: phoc ~r5c +
phosh ~r5int, performance governor on both cpufreq policies, package activity
stopped first. Five runs, two glass off and three glass on. The gestures are
manual on a metronome, so every run is listed rather than averaged into a
single figure.

phoc, % of one core:

phase glass off glass on
idle 0.70, 0.70 0.70, 0.73, 0.70 no cost
drawer 14.57, 15.40 27.23, 29.03, 28.93 1.90x
app open 21.23, 18.50 45.73, 34.03, 31.10 ~1.9x
app grid 23.40, 24.97 27.60, 37.03, 44.17 not separable, see below

GPU load, which the earlier run had no reading for — MediaTek's
/sys/module/ged/parameters/gpu_loading is readable without root on this
device: idle 0 with the glass on and off, drawer 13.6 -> 24.4, app open
18.0 -> 27.4. So the Kawase pass is visible on the GPU as well as in phoc's
CPU time, and idle is zero on both.

phosh is unchanged within noise: 27.1 vs 26.9 on app open, and its drawer
figure moves less between configurations than between repeats of the same one.

Idle costs nothing at all, which is the design: an idle screen damages
nothing, phoc_output_draw() renders no frame, and there is nothing to blur.

Drawer is the number to trust — 1.90x, with the repeats inside 6% on both
sides.

App grid is not separable and I am not quoting a figure for it. phoc and
phosh rise and fall together across the three glass-on runs (27.6/25.8,
37.0/41.4, 44.2/47.7): that is how hard the icon grid was flicked, not what the
compositor did with it.

App open is directionally solid but noisy — every glass-on run sits above
every glass-off run, and the spread within the on runs is a missed gesture in
one of them, which understates rather than inflates it.

The whole-output damage that these numbers measure is unchanged from the table
above: narrowing it was implemented, tested on device and withdrawn as unsound,
for the reason in the description.

@FakeShell
FakeShell merged commit d698652 into FuriLabs:forky Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants