Skip to content

Repository files navigation

Radiant

An open runtime feature stack for GPUs on Linux — the part of AMD Software: Adrenalin Edition that Linux does not have.

Radiant is a Vulkan layer that applies spatial upscaling to any game, native or Proton, with no wrapper process, no nested compositor, and no launch options, driven by per-application profiles from a daemon.

Working name. Placeholder until something better turns up.


Why

GPU tuning on Linux is solved — LACT and CoreCtrl cover clocks, voltage, fan curves and power limits. What does not exist is the half of Adrenalin that sits in the game's present path: Radeon Super Resolution, Chill, Anti-Lag, per-app profiles.

The nearest equivalents each fall short in a different way. gamescope is a nested compositor you wrap a game in. Proton's fullscreen hack is Proton-only and is being retired. vkBasalt sharpens but does not upscale. OptiScaler drops files into game directories. LSFG-VK has the right architecture but depends on a proprietary Windows DLL.

Radiant's thesis: the interception layer is the product; the control panel is a client of it.

Design rationale, decisions and risks are in DESIGN.md. Test procedures and the findings that shaped the implementation are in TESTING.md.


Status

Working, and verified on AMD RDNA 4 (RX 9070 XT, RADV, Mesa 26.1) under both X11 and Wayland.

About 4,100 lines of Rust and 105 of GLSL wrapping AMD's FidelityFX headers.

Zero dependencies outside ash, and no systemd requirement. The layer loads into every Vulkan process on the machine, and the daemon will eventually run as root on systems that may have no systemd, no dbus and no polkit — so both are kept dependency-free deliberately. They link nothing but libc, not even libvulkan, since a layer receives its function pointers through the loader chain:

libradiant_layer.so  ->  libc, libm, libgcc_s
radiantd             ->  libc, libgcc_s

The control panel takes a GTK4 dependency, and it is the only component that does.

Both properties are enforced rather than intended: scripts/check-deps.sh fails the build if either binary links beyond the C runtime, and a CI job builds and tests the daemon in a container with no systemd present.

Measured cost

FSR 1 at 2560×1412 output on an RX 9070 XT: 0.245 ms GPU, which extrapolates to roughly 0.55 ms at 4K. The linear blit is 0.008 ms at 500×500 against FSR's 0.028 ms at the same size.

Untested, and worth knowing

Everything measured so far comes from vkcube — three flat faces and one texture, which flatters every upscaler and hides most settings. No real game, no Proton title, and no anti-cheat title has been run. DESIGN.md §6 tracks that as an outstanding risk rather than an oversight.

Known limitations

  • Steam games are not covered yet. The layer works on anything launched directly, including native Linux titles started from a terminal — verified end to end, with settings applied and the process tracked. Steam runs games inside pressure-vessel, which imports host Vulkan layers into its container on its own terms. Tracked as M7; DESIGN.md §13 records what has been ruled out.
  • Windowed resize on Wayland is not tracked. Wayland does not state a surface size, so once the layer offers a reduced extent the application's requests no longer reveal the window size. Fullscreen is correct; X11 is correct. Fixing the windowed case needs compositor cooperation.
  • BGRA and sRGB swapchains take a slower path. RCAS cannot write them directly, so it writes an intermediate that is then copied — one extra full-resolution copy.
  • No fp16 shader path. Measured and deliberately skipped: it would save ~0.25 ms at 4K in exchange for shader variants, device feature injection and a hardware support matrix. Revisit when something else needs that machinery.
  • Settings resolve once per process. Changing a profile takes effect on the next launch.

Complete

Milestone Delivered
M0 Layer skeleton Loader negotiation, vkGetPhysicalDeviceProcAddr, instance and device dispatch chaining, process denylist, three-symbol export gate, leave-no-trace CI check
M1 Swapchain interposition Proxy images with 1:1 index mapping, surface-capability spoofing, present rewriting, layout transitions, SetDeviceLoaderData, per-swapchain fences, device-owned command pools, linear blit
M2 Upscaler backends Upscaler vtable, passthrough and FSR 1 (EASU + RCAS compute), direct and blit output paths, mip LOD bias injection, diagnostic overlays, frame timing and GPU cost measurement
M3 Daemon and profiles radiantd, query socket, pull-based activation, per-game and global profiles with a shared option surface, resolution order, filename scheme, config validation, display extent from DRM connector state
M4 radiantctl Profile management and diagnostics from a terminal — list, show, set, export, import, status, reload, doctor. Edits preserve comments and layout
M6 GUI GTK4 / libadwaita control panel — game library with generated cover art, per-game and global settings pages, every schema key as a control, apply-timing on every row, reasons on every disabled one, saves through the same in-place editor radiantctl uses
M5 Lifecycle tracking SO_PEERCRED process identification, pidfd exit detection with a /proc fallback, refcounting across launcher and render processes, revert on daemon shutdown, running list in status and doctor, GpuTuning trait with a logging no-op implementation

In progress

Nothing. M6 is the current head, and M7 — Steam support — is the next thing worth doing: scanning Steam's library matters much less while Steam's games cannot be reached. Twelve tests cover the config surface and the in-place editor, because the GUI will be writing these files and any ambiguity becomes a support burden rather than a bug report.

Planned

Milestone Scope Blocks
M8 Game library Steam library scanning with no dependencies, real cover art from Steam's local cache, installed games shown whether or not they have a profile, learned executables, and a Create profile button
M7 Steam support Get the layer reliably into games launched through Steam. It works on anything launched directly, including native Linux titles; pressure-vessel is the obstacle. See DESIGN.md §13 for what has been established and what has not
M8b Lutris and Heroic Their libraries live in SQLite and JSON respectively; Steam's are plain text, which is why it went first M8
M9 More spatial backends NIS, integer, Lanczos behind the existing vtable
M10 FSR 3.1 / FSR 4 support Capability probe, integration detection, middleware management, version matrix, honest status, anti-cheat warnings M6
M11 FFX API interposer Hook the FidelityFX Vulkan calls native games already make, and redirect them M10
M12 Control socket and GPU tuning Privileged second socket, LACT fork for clocks, power limits and fan curves, SO_PEERCRED auth with polkit where available M5, M6

Why GPU tuning is last

It is the only part of this project that cannot be developed or reviewed without the hardware in front of it, and the only part where being wrong costs more than a corrupted frame. A fan curve that does not revert, or a voltage offset left applied after a game exits, is hardware misbehaving after the software has stopped — a different category of bug from anything the layer can produce.

So it goes last, and everything before it is built to receive it:

Milestone What it must already carry
M3 [tuning] parses and validates in both global and per-game profiles, is reported at startup as requested-but-unapplied, and never crosses the query socket the layer reads
M4 radiantctl set <profile> tuning.power_cap_w=260 already works; show already prints tuning keys and says they are not applied
M5 Start and exit events are correct and refcounted with nothing attached, and GpuTuning exists with a no-op implementation that logs what it would do
M6 A GPU tuning section in both the per-game and global pages, rendering the keys already in the schema, disabled and carrying the reason — the same mechanism that greys out FSR 4

By the time M11 starts, the schema, the CLI, the UI and the lifecycle hooks are all in place and tested. What remains is implementing one trait against sysfs, which is the part that needs a GPU on the desk.

M5 covers the polite exits — it cannot cover SIGKILL, a segfault or a power cut. Those run no handler, and whatever was written to sysfs stays written. The fix is state on disk written before acting and replayed at startup, not a better signal handler. DESIGN.md §12 records that and the other tuning safety requirements, while tuning is still a no-op and the constraints can be designed for rather than retrofitted.

Opportunistic

Not scheduled; done when something else justifies the machinery or a measurement changes.

Item Why it is waiting
fp16 shader path Measured at ~0.25 ms saved at 4K. Needs shader variants, device feature injection and a hardware matrix — build it when something else needs that machinery
BGRA direct write Needs VK_KHR_swapchain_mutable_format and a swizzled store, to save one full-resolution copy
sRGB correctness FSR 1 wants perceptual-space input; an _SRGB swapchain defeats it on sample. Needs a mutable-format proxy view
Chill, Anti-Lag Mesa now ships VK_LAYER_MESA_anti_lag; the job may be to expose it rather than build it
Windowed Wayland resize Needs compositor cooperation. Fullscreen and X11 are already correct

Not planned

  • Reimplementing GPU tuning from scratch — LACT already carries the per-generation quirk knowledge.
  • A compositor. gamescope remains the right answer for HDR, VRR and compositor-side scaling.
  • Native FSR 4 — RDNA 4 only, DirectX 12 only, and AMD has not committed to a Vulkan backend.
  • Frame generation.

Why FSR 1 and not FSR 2, 3 or 4

The version numbers imply a ladder. They are not one.

FSR 1 is spatial. Its input is a single colour image, which is why it can run at present time and work on any game ever shipped. That is also why Radeon Super Resolution is FSR 1 and stays FSR 1 — not because AMD got lazy, but because it is the only thing the interception point can support.

FSR 2, 3 and 4 are temporal. Per frame they need depth, motion vectors, the sub-pixel jitter the camera was rendered with, exposure, a reactive mask, and colour before UI compositing. None of that exists at vkQueuePresentKHR — there is one flattened backbuffer with the HUD already burned in. No amount of layer engineering conjures motion vectors out of it.

So a layer cannot implement them, and neither does AMD. Adrenalin's FSR 4 toggle is a backend swap: it intercepts games that already integrate FSR 3.1 through the FidelityFX API and routes those calls to a newer model. The game still does all the temporal work. OptiScaler is the third-party version of the same idea.

So will there be an FSR 2/3/4 setting?

Yes — but it does something different from the FSR 1 setting, and the difference is worth being blunt about.

FSR 1: Radiant does the upscaling. It works on every game, always, with no cooperation from the game at all. That is the whole point of the layer.

FSR 2, 3.1 and 4: Radiant does not upscale anything. It sets the game up to use a better upscaler the game already supports. If the game has no such support, the option cannot work and will say so.

In the control panel that looks like one dropdown where most entries are usually unavailable, each with a reason:

Upscaler   ( FSR 1 — Radiant                                    ▾ )
             FSR 1 — Radiant            always available
             FSR 3.1                    unavailable: game has no FSR 3.1 support
             FSR 4                      unavailable: needs a Radeon RX 9000 series
             DLSS → FSR 3.1             unavailable: game has no DLSS support

For a game that does ship FSR 3.1 — a growing number do — picking FSR 4 gets you genuine temporal upscaling, far better than FSR 1. Radiant's job there is detecting the integration, checking your GPU and driver versions, placing and configuring the middleware, and telling you plainly when it cannot work. Today users do all of that by hand from forum posts.

The Upscaler trait already declares required_inputs and hw_requirements for exactly this reason. A temporal backend declares that it needs depth, motion vectors and jitter, and the profile engine refuses to offer it without an input provider — so the UI greys the option out with a stated reason rather than silently substituting something worse. Quietly falling back to FSR 1 when a user picked FSR 4 would be the worst possible behaviour.

Native FSR 4 implemented by Radiant itself is not planned: it is RDNA 4 only, DirectX 12 only, and AMD has not committed to a Vulkan backend.


Prerequisites

Runtime

  • A Vulkan 1.1+ driver. Developed against RADV; the layer itself is vendor-neutral and contains no AMD-specific code.
  • vulkan-icd-loader 1.3.261 or newer for VK_ADD_IMPLICIT_LAYER_PATH. Older loaders work via XDG_DATA_DIRS, which scripts/dev-env.sh also sets.

Build

  • Rust 1.75+ and cargo.
  • glslangValidator or glslang — shaders are compiled to SPIR-V at build time and embedded, so no shader compiler is linked into the layer itself.
# Arch
sudo pacman -S --needed rust glslang vulkan-icd-loader vulkan-tools gtk4 libadwaita

# Debian / Ubuntu
sudo apt install rustc cargo glslang-tools libvulkan1 vulkan-tools \
                 libgtk-4-dev libadwaita-1-dev

# Fedora
sudo dnf install rust cargo glslang vulkan-loader vulkan-tools \
                 gtk4-devel libadwaita-devel

vulkan-tools provides vkcube and vulkaninfo, which every procedure in TESTING.md uses. GTK and libadwaita are needed only for the control panel; the layer, daemon and CLI build without them.

Optional but recommended while developing: vulkan-validation-layers. Several real bugs in this project were caught only by validation and tolerated silently by the driver.


Build

git clone <repo> radiant && cd radiant
cargo build
./scripts/check-symbols.sh

check-symbols.sh is not politeness. A Rust cdylib will otherwise export std symbols into processes that have their own allocator and their own Vulkan symbols, and the failure mode is a crash in an unrelated application. The layer must export exactly three symbols and CI should enforce it.

If you extracted an archive over an existing tree

find crates -name '*.rs' -exec touch {} +
cargo build

Archive members carry the timestamps they were packed with, which can be older than the last build. Cargo compares mtimes, quite reasonably concludes nothing changed, and skips the crate — so you run the previous binary while looking at the new source. The symptom is cargo build recompiling some crates and not the one you just replaced.

git pull sets mtimes correctly, so this only applies to tarballs and zips. cargo clean -p <crate> is the targeted alternative.


Run (development)

During development nothing is installed and nothing needs root. That is a property of the developer workflow, not of the finished product — a packaged install will place a layer manifest in /usr/share/vulkan/implicit_layer.d, binaries in /usr/bin, and a service definition, and the daemon will need privileges once GPU tuning arrives. DESIGN.md §10.6 enumerates every path a real install will touch, so uninstall is a known quantity rather than archaeology.

The development workflow deliberately stays out of all of it, because an implicit Vulkan layer loads into every Vulkan process on the machine — including the compositor. A half-built layer that installs system-wide costs you a login session, not a test run.

mkdir -p ~/.config/radiant/profiles
cp examples/config/global.toml     ~/.config/radiant/
cp examples/config/profiles/*.toml ~/.config/radiant/profiles/

./target/debug/radiantd &        # foreground, unprivileged
eval "$(scripts/dev-env.sh)"     # stages the layer under .devroot/
vkcube

dev-env.sh writes a manifest into .devroot/ (gitignored) and prints the environment to use it. /usr, /etc and the real ~/.local/share are never touched; close the shell to undo it. scripts/leave-no-trace.sh enforces that property rather than leaving it to good intentions.

Without a profile, or without the daemon running, the layer is inert and the application behaves exactly as if it were not installed.

Without the daemon

Every setting also has an environment variable, and the environment always wins:

RADIANT_SCALE=0.5 RADIANT_BACKEND=fsr1 vkcube

Installing it

The development workflow above installs nothing, which is right for development and wrong for Steam: games run inside the Steam Linux Runtime container, which imports implicit layers from system paths only.

sudo -E scripts/install.sh
scripts/uninstall.sh

DESIGN.md §10.6 enumerates every path this touches.

Turning it off

RADIANT_DISABLE=1 loader skips the layer; never dlopened
VK_LOADER_LAYERS_DISABLE=VK_LAYER_RADIANT_upscale loader-level, by name
VK_LOADER_LAYERS_DISABLE=~implicit~ recovery — disables every implicit layer
rm -rf .devroot gone (development staging only)

The third is the one to remember from a TTY if a future change ever wedges a session.


Configuration

One file per game in ~/.config/radiant/profiles/, plus a global.toml for everything else.

Profile files

Two ways a profile gets there, with different owners:

  1. Written by the control panel. Saved as <game-id>.toml, derived from the canonical game id with : replaced — steam-1091500.toml, lutris-cyberpunk-2077.toml, exe-a1b2c3d4e5f6a7b8.toml.
  2. Written by hand, or shared by someone else. Any other filename. Drop it in profiles/ and it is picked up on the next reload or daemon start.

Keeping generated names in one predictable shape is what stops the two colliding: the control panel only ever writes files it can name itself, so it will never overwrite something a user placed by hand.

Rule
Extension .toml only. Anything else is ignored, so renaming to .toml.off disables a profile without deleting it
Matching comes from the match key inside the file, never from the filename
Display name the optional name key, falling back to the filename
Load order lexical by filename
Collisions two profiles claiming the same executable is reported at startup, naming both files; the lexically last one wins

Note that digits sort before letters, so a 50- style prefix makes a file lose to an unprefixed one rather than win. There is deliberately no priority mechanism: if two profiles claim the same game, the answer is to fix one of them, and the startup warning tells you which two to look at.

A profile is self-contained, which is what makes it shareable — nothing it needs lives anywhere else.

name  = "Cyberpunk 2077"              # display only; optional
match = "Cyberpunk2077.exe"           # what this actually applies to

[upscale]
enabled   = true
backend   = "fsr1"
scale     = 0.667
sharpness = 0.3

Unrecognised keys are reported rather than ignored. A silently swallowed sharpnes = 0.3 is exactly the sort of thing that costs somebody an evening, and hand-written and shared files are where it will happen.

How settings are chosen

   running application
           |
   has a matching profile? ----yes---->  use that profile
           |
           no
           |
   is global enabled? --------yes---->  use global settings
           |
           no
           |
      layer stays inert

Three points that follow from it:

  • A profile is self-contained. It does not inherit from global. What the file says is what the game gets; unset keys take documented defaults, not global's values. Either the profile applies or global does, never a blend.
  • A profile with enabled = false stops at step one. It does not fall through to global — otherwise there would be no way to exclude a single game from a global setting.
  • Writing a profile is a statement of intent, so its presence enables it. Omit enabled and it is on; set enabled = false to turn a game off explicitly.

Global is disabled by default. Silently changing the resolution of every application on the machine is not a reasonable thing to do without being asked.

match = "vkcube, /usr/bin/vkcube"   # basenames or absolute paths; longest wins

[upscale]
enabled   = true
backend   = "fsr1"        # passthrough | fsr1
scale     = 0.5           # render resolution as a fraction of the display
sharpness = 0.2           # RCAS, in stops. 0.0 is maximum
mip_bias  = "auto"        # auto | off | <float>

[display]
extent = "auto"           # auto (daemon reads DRM) | WxH

[debug]
overlay = "off"           # off | split | rcas | rcasdiff
stats   = false           # frame timing and GPU cost, logged every 2s

Every key is intended to be a control in a UI. Nothing exists only as a developer environment variable, because that is how features end up undiscoverable and impossible to expose.

Per-game GPU tuning

Clocks, power limits and fan curves belong in the same profile as the upscaler settings — one file, one page in the UI, one thing to reason about:

[tuning]
power_profile  = "3d_fullscreen"
power_cap_w    = 260
gpu_clock_max  = 2800
voltage_offset = -50
fan_curve      = "quiet"

Applied when the application starts and reverted when it exits. The layer's own announcement is the "started" signal — it is exact, it carries the pid, and it fires only for processes that actually create a Vulkan instance, which is a better trigger than watching every exec on the machine.

Three deliberate decisions here:

  • Keys are passed through to the tuning backend rather than re-invented. They follow LACT's naming, because LACT already carries the per-generation quirk knowledge — kernel version floors for RDNA 4 overclocking, zero-RPM fan configuration, ppfeaturemask bootstrapping — and duplicating that is a mistake. Radiant parses [tuning] as an unstructured map and validates it against the backend's declared capabilities.
  • Tuning never crosses the query socket. That socket is read by the layer, which runs inside untrusted game processes. It has no business knowing the fan curve, let alone being able to read one. Tuning stays daemon-internal and will be written through a separate, privileged control socket. There is a unit test asserting nothing from [tuning] leaks into what the layer is told.
  • Requested-but-unimplemented is reported, not ignored. The daemon parses [tuning] today and says so at startup — "profile 'x' requests tuning (3 keys); not applied until the control socket exists" — rather than reading a config key and silently doing nothing with it.

Diagnostics

debug.overlay Shows
split upscaled left half, plain linear blit right half, same frame
rcas upscaled left half, EASU output right half — isolates sharpening
rcasdiff the amplified difference RCAS made; black means it did nothing

debug.stats = true logs frame time and a GPU timestamp bracketing the upscale every two seconds. The absolute millisecond figure is the number that matters; the percentage is meaningless in an uncapped or vsync-pinned application.

Poke the daemon directly when a profile does not seem to apply:

printf 'query /usr/bin/vkcube\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/radiant/query.sock
printf 'reload\n'                | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/radiant/query.sock

Layout

crates/radiant-layer/     Vulkan implicit layer (cdylib)
  src/loader.rs           vk_layer.h bindings — not in any binding generator
  src/dispatch.rs         instance/device tables, keyed on the dispatch pointer
  src/swapchain.rs        proxy images, present rewriting, fences
  src/backends/           the upscaler vtable
  src/config.rs           profile pull from the daemon
  shaders/                GLSL wrappers + AMD's FidelityFX headers (MIT)
CONTRIBUTING.md           invariants, principles and gotchas
crates/radiant-gui/       radiant — GTK4 control panel
crates/radiant-cli/       radiantctl — profile management and diagnostics
crates/radiant-daemon/    lib: config schema, paths and system probing
                          bin: radiantd — profile store, query socket
  src/config.rs           profile parsing, resolution order, validation
  src/display.rs          display extent from /sys/class/drm
scripts/                  dev environment, symbol gate, leave-no-trace check
examples/config/          sample profiles
.github/workflows/        build, test, fmt, clippy, symbol gate, dependency gate,
                          and a job proving the daemon works with no systemd

What FSR 3.1 / FSR 4 support actually involves

FSR 3.1 and FSR 4 already work on Linux today, for games that integrate them. They are just unusable: buried under Proton, VKD3D and Mesa version pinning, DLL placement, prefix settings and per-game workarounds gathered from forum posts. Turning that into a dropdown is a real project, and it decomposes into six pieces — none of which involve writing an upscaler.

  1. Capability probe. GPU generation (RDNA 4 for FSR 4, via device ID and VK_KHR_cooperative_matrix), Mesa version, installed Proton versions, VKD3D version. Runtime and data-driven, because this matrix moves monthly.
  2. Integration detection. Whether a given game already ships an upscaler interface at all — FidelityFX, nvngx, XeSS — by file detection in the game directory, backed by a known-titles list. Without an existing integration there is nothing to swap.
  3. Middleware management. Installing, versioning and wiring OptiScaler or equivalent: file placement, config generation, environment. The unglamorous part, and the part users currently do by hand from forum posts.
  4. Version matrix resolution. Turning "this game, this GPU, this Mesa, this Proton" into either a working configuration or a specific reason it will not work.
  5. Honest status reporting. Per game and per tier: available, or unavailable with a stated reason — "needs RDNA 4", "game has no FSR 3.1 integration", "requires Proton 11+". The Upscaler trait's required_inputs and hw_requirements exist for exactly this.
  6. Anti-cheat warnings. OptiScaler's own documentation warns against using it in online titles. That warning belongs in front of the user at the point of enabling, not in a README.

The one piece that is layer work is a FidelityFX API interposer for native Linux games that ship FSR 2 or 3.1 through the FFX Vulkan backend — hook the calls the game already makes and redirect them to a different implementation. The install base for that is currently tiny, which is why it is not first.

Licence

Project code: MIT.

crates/radiant-layer/shaders/vendor/ffx_a.h and ffx_fsr1.h are AMD FidelityFX, MIT, copyright Advanced Micro Devices, Inc. Unmodified.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages