Skip to content

fix: window.opacity transparency on Windows (wgpu + cpu backends)#1583

Open
shiena wants to merge 2 commits into
raphamorim:mainfrom
shiena:cpu-translucency
Open

fix: window.opacity transparency on Windows (wgpu + cpu backends)#1583
shiena wants to merge 2 commits into
raphamorim:mainfrom
shiena:cpu-translucency

Conversation

@shiena
Copy link
Copy Markdown
Contributor

@shiena shiena commented May 3, 2026

Problem

On Windows, [window] opacity < 1 rendered the window almost entirely see-through instead of partially translucent.
Reproducible with the default cargo build (wgpu backend) and with [renderer] use-cpu = true (cpu backend).

Root cause

with_transparent(opacity < 1.) triggers DwmEnableBlurBehindWindow, after which DWM uses the framebuffer alpha for per-pixel compositing.
Both backends were producing the wrong alpha:

wgpu backend had two independent issues:

  1. frontends/rioterm/src/screen/mod.rs gates the wgpu code path on rioterm's own wgpu feature, which is off in default cargo build.
    Default Windows builds therefore fell into the CPU rasterizer, even though sugarloaf and rio-backend already get features = ["wgpu"] via the [target.'cfg(windows)'] dep override, so the wgpu code is in the binary.
  2. Sugarloaf::render_wgpu wrote LoadOp::Clear((bg_r, bg_g, bg_b, opacity)) raw.
    wgpu picks PreMultiplied alpha mode on Windows (the only non-Opaque option DX12 / Vulkan WSI offer), so DWM read the dark bg as if it were already-multiplied — the implied "straight" RGB came out near-fully transparent.

cpu backend wrote 0x00RRGGBB pixels (α = 0) and every blend function masked the alpha byte out (pack_opaque omits it, SIMD results post-masked with & 0x00ff_ffff).
Harmless on platforms that ignore swap-chain alpha; with DWM reading the alpha byte, every pixel came out fully transparent.

Fix

wgpu:

  • Allow the wgpu dispatch on Windows even without rioterm's own feature flag: cfg(any(feature = "wgpu", target_os = "windows")).
  • Premultiply the LoadOp::Clear color in render_wgpu for any alpha mode that isn't PostMultiplied.
    Promote WgpuContext::alpha_mode to pub so the renderer can branch on it.

cpu — switch the softbuffer framebuffer to premultiplied RGBA end-to-end:

  • pack_opaque writes α = 0xff (no-op on OSes that ignore the byte).
  • bg fill in render_cpu uses pack_premul((R,G,B)·a, a).
  • All four blend variants in renderer/cpu.rs (scalar SWAR + x4/x8 SIMD const-src + var-src) gain an A-channel computation via the same (x · inv) / 255 SWAR trick as G, and stop stripping alpha from the source / result.
  • The two scalar blend paths in grid/cpu.rs and text.rs (grid cells and UI text overlays) get the same A-channel fix, with a new pack_premul helper next to each pack_opaque.
  • Caller sites that splatted (src_premul & 0x00ff_ffff) and post-masked SIMD results are updated to splat the full premultiplied src and drop the post-mask.

The premultiplied invariant (RGB ≤ A) keeps byte-wise channel adds safe from inter-channel carry.
Default-bg cells continue to emit (0, 0, 0, 0); the sa == 0 early-out returns dst unchanged, so the cleared bg's alpha bleeds through.

Tested

Windows 11 / NVIDIA RTX 5070, opacity = 0.6:

  • wgpu (Vulkan, Bgra8Unorm, PreMultiplied surface) — renders as ~60% bg + ~40% desktop.
  • cpu (use-cpu = true) — renders translucent; tabs, prompt text, glyph AA edges, command palette overlay all composite correctly.

opacity = 1.0 (default) unaffected on both paths.

@shiena shiena force-pushed the cpu-translucency branch 2 times, most recently from b1b1be0 to e8cc7a9 Compare May 3, 2026 16:22
shiena added 2 commits May 4, 2026 12:04
On Windows the wgpu surface picks `PreMultiplied` alpha mode (the only
non-Opaque mode the DX12 / Vulkan WSI paths offer), so DWM interprets
framebuffer RGB as already multiplied by alpha. The grid bg pass emits
`(0,0,0,0)` for default-bg cells and lets the cleared color show
through, but `LoadOp::Clear` was writing the raw `(bg, opacity)` value
instead of premultiplied `(bg*opacity, opacity)`. The compositor then
treated the dark background as if it were premultiplied, which made
`window.opacity < 1` render almost fully transparent.

Premultiply the clear color in `Sugarloaf::render_wgpu` for any alpha
mode that isn't `PostMultiplied` (only mode that wants straight RGBA).
Promote `WgpuContext::alpha_mode` to `pub` so the renderer can branch
on it.

Also fix the cfg gating in `screen/mod.rs` that selected the CPU
backend on default Windows builds. The `wgpu` crate feature in rioterm
isn't on by default — only `--features wgpu` enables it — but the
sugarloaf + rio-backend deps are already forced to `features = ["wgpu"]`
on Windows via the target dependency override. Without this OS-level
fallback the `Backend::Webgpu => SugarloafBackend::Cpu` arm fired and
the user fell into the CPU rasterizer (which doesn't write per-pixel
alpha), so `with_transparent(true)` + `DwmEnableBlurBehindWindow`
showed a fully-transparent window regardless of the configured opacity.
The CPU rasterizer wrote 0x00RRGGBB pixels (alpha = 0) and every blend
function masked the alpha byte back out (`& 0x00ff_ffff`, or `pack_opaque`
that omits the upper byte). That was fine on platforms where the OS
ignores swap-chain alpha, but with `with_transparent(true)` plus
`DwmEnableBlurBehindWindow` (engaged when `window.opacity < 1`), DWM
treats the framebuffer alpha as the per-pixel transparency. The whole
window therefore came out 100% see-through on the CPU backend whenever
opacity was set, regardless of the configured value.

Switch the CPU framebuffer to premultiplied RGBA throughout:

- `pack_opaque` now writes `alpha = 0xff` (no-op for OSes that ignore it).
- bg fill in `render_cpu` uses `pack_premul((R,G,B)·a, a)` so the cleared
  color carries `window.opacity` into the alpha byte.
- All four blend variants in `renderer/cpu.rs` (scalar SWAR + 3 SIMD
  flavours) now compute the A channel via the same `(x*inv) / 255` SWAR
  trick used for G, and stop stripping alpha from the source/result.
  The premultiplied invariant (RGB ≤ A) keeps the byte-wise add safe
  from carry across channels.
- `grid/cpu.rs::blend_over` and `text.rs::blend_premul_over` (separate
  scalar paths used by the grid bg/text and UI text overlays) get the
  same A-channel computation. New `pack_premul` helper added next to
  each `pack_opaque`.
- Caller sites that splatted `(src_premul & 0x00ff_ffff)` and
  post-masked the SIMD result with `& mask_rgb_*` are updated to splat
  the full premultiplied src and drop the post-mask.

Default-bg cells emit `(0,0,0,0)`; with `sa == 0` the early-out branch
returns `dst` unchanged, so the cleared bg's alpha bleeds through and
the window renders translucent at the configured opacity.
@shiena shiena force-pushed the cpu-translucency branch from e8cc7a9 to 5c9bc6b Compare May 4, 2026 03:04
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.

1 participant