CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
41 KiB
ShaderFX: runtime slang shader presets
ShaderFX plays real libretro .slangp shader presets over the finished frame.
It replaced src/render/GBCFX.lua, a hand-ported fixed four-level effect, with
a picker: any preset the player drops in a folder, or any preset pulled from
the RetroArch buildbot, can be selected and run. Engine:
src/render/ShaderFX.lua (discovery, download, translation call, pass-graph
runtime, render entry point), src/render/ShaderFixup.lua (GLSL rewrites),
src/render/ShaderSourcePatches.lua (pre-translation source patches),
src/core/Sensors.lua (accelerometer and gyroscope), src/ui/ShaderFXScreen.lua
(the picker), src/ui/ShaderFXParamsScreen.lua (per-preset parameter editor),
tools/shaderfx-bridge/ (the Rust translator). Call sites:
src/render/Renderer.lua (Gen 1) and src/core/Game2.lua (Gen 2), both at the
end of the frame. Drivers: tests/drivers/gold_shaderfx_zoom_sizing_test.lua,
tests/drivers/gold_shaderfx_menu_black_crop_test.lua.
This is first-party engine code, not a mod. It calls the native translator
directly through ffi.load, with no Sandbox.lua, no native permission
declaration, and no mod boundary. Mods do not get that, and the distinction is
deliberate: the upstream maintainer will not accept native in mods.
What a player sees
OPTIONS carries two rows, SHADER FX and SHADER FX 2. Each opens the
same pushed list screen (ShaderFXScreen) on a different slot. The list is
OFF, then every .slangp found on disk, then a permanent DOWNLOAD SHADERS
action row at the bottom.
A preset that has never been translated draws muted with a CONVERT hint on
the right. A on that row translates it in place and stays open: converting is
a preparation step, not a selection. A on a converted row activates it,
persists the choice, and closes. SELECT on a converted row opens
ShaderFXParamsScreen, which lists every #pragma parameter the preset
declares and lets the player step each one (A wraps, Left/Right clamp,
SELECT resets one row, START resets all behind a confirm).
Presets live in a plain OS folder, shaders/ under the portable base directory
when running portable (SaveData.portableBaseDir(), the SD-card convention the
Anbernic pack uses, see docs/anbernic-rg34xxsp.md) and otherwise under LOVE's
save directory. ShaderFX.list() scans it recursively, so a shader pack keeps
whatever nested layout it shipped with. These are real filesystem paths rather
than love.filesystem virtual paths on purpose: the native translator does
plain std::fs reads and knows nothing about LOVE's mounts, and neither do the
LUT loads.
Persisted state, all in save.options:
| Key | Meaning |
|---|---|
shaderfx |
main slot's preset name, or absent for OFF |
shaderfxSecondary |
secondary slot's preset name |
shaderfxParams[name][paramId] |
one preset's edited pragma values |
POKEPORT_SHADERFX=<name> activates a preset in the main slot for scratch
harnesses that never call ShaderFX.applyOptions. It is a stand-in that
predates the real OPTIONS row; applyOptions marks itself as having run so the
env var can never later override a real player choice, including a real choice
of OFF.
One quiet behavior worth knowing about: if a slot wants a real preset while
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
ShaderFX off, ShaderFX.applyOptions pins PERFORMANCE to HIGH. Without it
the saved choice was force-deactivated a few lines later on every boot, which
looks identical to "the setting does not save" from the player's side. On
Android and iOS that is the common case, because AUTO always resolves to
balanced there. It never touches an already-explicit performance choice and
never un-escalates.
The five stages
| Stage | Owner |
|---|---|
| Fetch | ShaderFX.list, ShaderFX.downloadPresets, ShaderFX.installDownloaded |
| Translate | tools/shaderfx-bridge/ via ShaderFX.translate |
| Fixup | src/render/ShaderFixup.lua |
| Cache | ShaderFX.convert writes, ShaderFX.load reads |
| Run | ShaderFX.runChain / runPass / ShaderFX.render |
Fetch
Two acquisition paths, and they land in the same place. A player can copy a
shader pack into shaders/ by hand, or press DOWNLOAD SHADERS, which fetches
https://buildbot.libretro.com/assets/frontend/shaders_slang.zip (the same
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
src/net/Fetch.lua, the curl-on-a-love.thread transport the self-updater and
mod index already use.
Downloaded presets are not pre-converted. The buildbot is RetroArch's asset
mirror and has no notion of this project's cache format, so a downloaded preset
goes through the same CONVERT row a hand-copied one does. Every platform
ships both convert and use; there is no asymmetry to work around.
Repeat downloads are conditional. The zip itself is deleted right after
extraction, so what is cached instead is the buildbot's own ETag
(shaderfx_buildbot.etag), replayed as If-None-Match. The server answers a
match with 304 and no body, and curl writes no file at all in that case, so
installDownloaded(notModified) short-circuits before touching the filesystem
and reports "already up to date" rather than "FAILED".
The interesting part is what gets extracted. handheld/ is not self-contained:
its 78 presets carry 101 references that escape the folder (color-mod LUTs
into ../shaders/color/, console-border helpers into ../../reshade/, shared
motion-blur and misc helpers, a shared stock.slang) across roughly 40 of
them. Extracting handheld/ alone silently breaks about a third of its own
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
content nobody asked for. So extractClosure walks the real file-level
dependency closure in Lua before a single file is copied, the same
#reference-closure idea librashader applies internally, done up front because
deciding what to copy has to happen before the translator ever sees these
files. Against this zip that is 207 files and about 9.5 MB with zero broken
references. The closure seeds from KEPT_PRESETS, a curated shortlist rather
than all 78, after most of color-mod/ and console-border/ turned out either
irrelevant (color-only, no LCD effect) or broken for this project. That list
is a temporary trim pending wider testing and is expected to change.
Two mechanical details in that walk that are easy to get wrong a second time.
extractRefs tries a quoted key = "path" match per line first, with an
unrestricted [^"]+ capture, because the closing quote is an unambiguous
delimiter and real packs ship paths with spaces and parentheses in them
("shaders/handheld/color-mod/Game Boy (Color).slang"); only a line with no
quoted match falls back to a conservative character class, since an unquoted
path has no delimiter to trust past. And love.filesystem.write does not
create intermediate directories, so each destination directory is created once
before anything is written into it; without that, every file under a subfolder
handheld/ never had before was silently dropped while flat writes succeeded.
Cleanup is equally literal: love.filesystem.unmount() takes the archive path
originally passed to mount(), not the mountpoint. Called with the mountpoint
it returns false, leaves the zip's handle open, and the following remove()
silently fails too, so every download used to leave 54 MB on disk forever.
Translate: the native bridge
tools/shaderfx-bridge/ is a small Rust crate (spike) that builds a cdylib
named librashader_bridge. It wraps librashader-presets,
librashader-preprocess and librashader-reflect behind a two-function C ABI:
char* librashader_translate_preset(const char* preset_path, int es);
void librashader_free_string(char* s);
It returns a JSON TranslateResult: pass_count, a passes array (each with
its emitted vertex/fragment GLSL, filter, wrap_mode, scale_x/
scale_y, its own #pragma parameter declarations, a classified samplers
list and a classified size_uniforms list), the preset's textures (LUTs,
with resolved absolute paths and filter/wrap settings), its
parameter_overrides, and an error field.
What the bridge is not. No librashader runtime backend is linked in, for any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live graphics context is ever touched. It is the translation step only. LOVE still owns every draw call, every canvas and every shader object. The bridge hands back text and metadata and nothing else.
When it is called. Only from ShaderFX.convert(). Translation is ahead of
time, not just in time. ShaderFX.load(), ShaderFX.activate(), boot-time
reactivation of a saved choice and every frame of ShaderFX.render() read the
cached artifact and never call the library. An entry with no cached artifact
fails activate() loudly instead of silently live-translating.
The classification is done with librashader's real semantics resolution rather
than name matching on the Lua side, and that matters for correctness, not just
tidiness. Sampler classification uses ShaderSemantics::create_pass_semantics
plus the TextureSemanticMap lookup (explicit alias and LUT-name entries
first, then the built-in Source/Original/OriginalHistoryN/PassOutputN/
PassFeedbackN conventions), so a pass reachable only by its real .slangp
alias resolves. The per-pass convenience API only registers the alias of the
pass being compiled, so the bridge mirrors upstream's insert_pass_semantics
loop and builds a preset-wide alias map first; without that,
ds-hybrid-scalefx.slangp's pass 2 sampling scalefx_pass0 by alias, with no
PassOutput1-shaped name anywhere, can never resolve. Size-uniform
classification runs the same resolution over the pass's uniform_semantics
map, which also covers shapes (PassFeedbackSizeN, UserSizeN) that no
present preset uses but that the convention allows.
The es flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). ShaderFX picks it from
love.system.getOS(), and the same function decides which dialect
validateShader is asked about at run time, so the two always agree. Convert
and render always happen on the same device; artifacts are not portable
across platforms.
One class of fixup has to happen in the bridge rather than in ShaderFixup.lua,
on the raw .slang text before SPIR-V compilation: textureSize,
texelFetchOffset and textureOffset are all ES 3.00+ only, and spirv-cross
refuses to emit them for an ES 1.00 target, failing the whole pass with
UnsupportedSpirv("textureSize is not supported in ESSL 100."). No GLSL text
is ever produced for a later pass to patch, so rewrite_essl100_gaps rewrites
the source first, and only for the ES target:
textureSize(Tex, lod)becomes a literalivec2(w, h)whenTexis one of the preset's declared static textures, with dimensions read straight out of each PNG's IHDR chunk. A texture that is not one of those, or a non-literallod, is left alone so it fails as loudly as before instead of guessing.texelFetchOffsetandtextureOffsetbecome ordinarytexture()calls at the equivalent texel-centre UV, using the texture's own<Tex>Size.zwreciprocal-size uniform. The containing block instance (params,global, whatever) is discovered by scanning the source's own uniform block bodies, never assumed. When a pass samples another pass purely through these calls it may never declare that<Tex>Sizeuniform at all, so one is injected first, before any byte offsets are computed.
That last rewrite has an honest limit: texture() honours the sampler's wrap
mode at out-of-range coordinates, which is not necessarily identical to
texelFetch's implementation-defined out-of-bounds behavior. Any edge-of-image
discrepancy for passes whose offsets can leave the image is unmeasured.
Building it. cargo build --release inside tools/shaderfx-bridge/.
ShaderFX looks for the library, most specific first: the
LIBRASHADER_BRIDGE_DLL environment variable, the source directory,
<source>/tools/shaderfx-bridge/target/release/, the save directory, and
finally the bare name handed to the system loader. Per-OS names are
librashader_bridge.dll (Windows), liblibrashader_bridge.dylib or
librashader_bridge.dylib (macOS), and liblibrashader_bridge.so or
librashader_bridge.so (Linux and Android). Android resolves the bare name
because the .so ships as an ordinary jniLibs entry, so dlopen finds it
without a path. The desktop path is still a developer build sitting in cargo's
output directory; nothing packages it next to a shipped game yet.
ShaderFX.canConvert() reports whether the library resolved on this machine,
and ShaderFX.bridgeError() says why not. Activating an already-converted
preset never needs any of this.
Fixup
ShaderFixup.lua mechanically rewrites the emitted GLSL into something LOVE
will accept. librashader emits a standalone void main() /gl_FragData[0] /
gl_Position shape (translated Vulkan GLSL); LOVE requires the effect() and
position() convention and refuses a raw main()-shaped source outright. This
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
real preset in the corpus failed without it. This list is the least guessable
part of the whole feature.
#version line. Stripped; LOVE prepends its own.
Array constructors. SPIR-V Cross emits ES 3.0 array-constructor syntax
(const float _17[5] = float[](0.0, 1.0, ...)) for compile-time array
literals, which validation rejects with "arrayed constructor: not supported for
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
three real shapes in the corpus and each is handled: a const global (declared
without an initializer at global scope, with per-element assignments relocated
to the top of main()), a non-const local declaration with initializer
(rewritten in place, since a function body can hold assignment statements where
the literal was), and a bare reassignment of an array declared elsewhere (also
in place). Splitting the element list needs splitTopLevelCommas, because a
naive comma split breaks on any element containing its own parentheses
(vec2(-1.0, 0.0)), and the outer capture needs %b() rather than a
[^%)]- class for the same reason: the class stops at the first inner ), the
whole match fails, and the literal passes through completely untouched.
Whole-array copies. float param_1[7] = coeffs; is how SPIR-V Cross clones
a function-parameter array before passing it on, since GLSL array arguments are
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
declaration plus an element-by-element copy. The size is known from the
declaration, so no comma splitting is involved. This one only became reachable
once the other array shapes stopped masking it in the same file.
Integer modulo. ES 1.00 has no % operator, and SPIR-V Cross emits it
anyway for an upstream integer-modulo op. % in GLSL is only defined for
integer operands, so routing through float mod() and back is exact for the
non-negative operands this shader family uses (rotation and orientation enum
indices). Four patterns are tried in order (paren/paren, paren/bare,
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
operand and leaves a simple one bare, and the balanced form must be tried
before a plain identifier can partially match. Seen live on
authentic_gbc's subpixel-rotation math.
Precision. SPIR-V Cross hardcodes an unguarded precision highp float; /
precision highp int; pair with no toggle. That is removed and replaced with
the same guard the old hand-written DotMatrix port used: claim highp only
where GL_FRAGMENT_PRECISION_HIGH says the driver actually offers fragment
highp, and fall through to the stage default otherwise.
Struct flattening. This is the big one. LOVE's Shader:send cannot address
a member of a custom struct-typed uniform: neither an INSTANCE.member dot path
nor sending the whole struct as a table works, both raise "Shader uniform '...'
does not exist." librashader emits every pass's #pragma parameters and size
uniforms as exactly that kind of struct, so as shipped the output is unusable
from LOVE, not merely inefficient. flattenStruct deletes the struct and its
instance uniform and re-declares the members at top level. Scalar members are
packed four at a time into synthetic uniform vec4 LIBRA_PACKED_N; slots,
because GLSL ES 1.00 guarantees only 16 fragment uniform vectors and every
scalar costs a whole one; gb-pass4's pass 0 alone has 14 scalars, already over
budget unpacked. Non-scalar members keep their own uniform, since packing an
existing vec4 saves nothing. Every instance.member reference is rewritten to
LIBRA_PACKED_N.x (or .y/.z/.w), and a member whose original declared
type was int or bool gets an explicit cast back on every read, since a
packed slot only stores floats. Vertex and fragment declare identically ordered
structs for the same parameters, so packing both with the same prefix assigns
the same slot and component to the same parameter in both stages, and one
shader:send reaches whichever stage uses it.
Two ordering constraints inside that function are load bearing and look
arbitrary from the outside. Packing must walk the members in the struct's own
declaration order, because that order is what keeps the scalars in one
contiguous run; an earlier version sorted them longest-name-first before
packing and produced six packed vec4s instead of four on gb-pass4, blowing the
budget. Substitution, separately, must go longest-name-first, so a replacement
can never land as a substring inside a still-pending member name that shares a
prefix. The two orders are separate copies of the list for exactly that reason.
Fixup.packValues turns a flat {name = value} table back into the
{uniform = value_or_vec4} shape the packed shader expects, per the manifest
flattenStruct returned. Fixup.countUniformSlots counts declared uniform
slots against that same 16-vector budget, samplers excluded. Its pattern uses
[%w_]+ rather than %w+ because Lua's %w does not include underscore
unlike regex \w, and every generated name here is full of underscores.
UBO blocks. LIBRA_UBO_FRAGMENT and LIBRA_UBO_VERTEX have the identical
problem and are flattened the same way, under a distinct LIBRA_UBO_PACKED_
prefix so their groups cannot collide with the push block's numbering. MVP is
the one special member: it is substituted directly to transform_projection
instead of becoming a uniform, because "multiply the incoming vertex by it" is
exactly what LOVE's transform_projection already is on a full-screen draw, and
nothing would ever supply a value for it. An earlier version assumed the UBO
block only ever carried MVP and deleted the whole declaration after
substituting it. That is true only for presets that declare their parameters in
a push-constant block; presets that use a UBO instead (many real handheld and
console-border presets do) had every other member reference left dangling, and
the driver then read INSTANCE.PAR as a swizzle, which is where the "undeclared
identifier" and "unknown swizzle selection" errors on real Android hardware came
from.
Fragment entry point. void main() becomes LOVE's effect() signature.
The parameter list is qualified by an EFFECT_PREC define rather than a literal
precision, because LOVE forward-declares effect()'s prototype under its own
header's precision default before this source runs, which can mismatch whatever
the precision guard above raises the default to. Fixup.PREC_HEADS holds the
two variants (mediump, then unqualified) and the caller tries them in order
against validateShader, taking the first that passes.
Fragment output. gl_FragData does not exist in LOVE's effect()
convention, so every gl_FragData[0] occurrence is rewritten to a local
gbFragColor, declared at the top of the function, with a single return
appended before the closing brace. Rewriting every occurrence regardless of
the operator that follows is necessary, not just symmetric with the vertex side:
an earlier assign-then-return pair assumed one write at the very end of
main(), which holds for most presets but not for ones like ds-hybrid-sabr
that write once with = and later accumulate with +=. The += statement
passed through unconverted and collided with LOVE's own gl_FragColor write
("Cannot use both gl_FragColor and gl_FragData"). A bare early return; is
rewritten to return gbFragColor;, which holds the value assigned just before
it on every real shape seen.
Vertex entry point. void main() becomes position(mat4 transform_projection, vec4 vertex_position), the source's own attribute
redeclarations of Position/TexCoord are dropped since LOVE supplies them,
gl_Position = X; becomes gbClipPos = X; (named to share no substring with
Position, or the next step would mangle it), and a return gbClipPos; is
appended. The Position and TexCoord substitutions are frontier-matched
whole identifiers (%f[%w]...%f[%W]), not blind substring replacements: real
presets declare their own unrelated locals such as vec2 vTexCoord;, and a
blind gsub turned that declaration into the invalid vec2 vVertexTexCoord.xy;
(dot.slangp pass 0, a real driver "unexpected DOT" error).
Cache
ShaderFX.convert(entry) is the only path that calls the bridge. It runs
ShaderSourcePatches.apply first, translates, then serializes the decoded
result to ShaderFX.artifactPath(entry): the source .slangp's own absolute
path with the extension swapped to .lua, so the artifact sits next to the
preset it came from. The file is a plain return { ... } chunk written by
serializeLua, which handles the string/number/boolean/nested-table shape
Json.decode produces. Array detection walks every key rather than trusting
#t, since #t counts a trailing nil as absent and a sparse table can pass a
naive length check by accident.
ShaderFX.load(entry) loadfiles that chunk and builds a chain state. On
failure it says "convert this preset first" rather than falling back to a live
translation.
AOT rather than JIT is the whole point of this stage. Translation is a rare,
explicit, user-initiated action whose result is stable for a given preset and
dialect, so paying for it once and writing the answer to disk keeps ffi.load
and the native call off every activation, every boot and every frame. The cost
is a staleness gap: ShaderFX.isConverted() is a plain "does the artifact file
exist" check with no version or content stamp, and there is no explicit
"reconvert" action in the UI. A preset converted by an older build never picks
up a later translator fix on its own. This was seen on a real device, where
sunlight_shimmer.slangp's Accelerometer uniform never reached the shader
because that device's cache predated the fix while pixel_transparency's
happened to be fresher. Two places compensate by reconverting unconditionally:
ShaderFXScreen's explicit selection of an already-converted row, and
ShaderFX.applyOptions on every boot and options save. Both are human-paced,
CPU-only work with no GPU compile, and neither is on the per-frame path.
ShaderSourcePatches.lua sits just before translation and patches the raw
.slang/.inc files on disk, because only librashader's own preset parser,
reading the real files, discovers #pragma parameter lines and struct members;
nothing downstream can add one. Patches are small, explicit, per-preset literal
find/replace pairs (plain find, not gsub, since GLSL source is full of Lua
pattern magic), re-applied idempotently on every convert so a buildbot
re-download that replaces the upstream file wholesale does not quietly undo
them. The patch table ships empty on purpose and nothing registers one. Its
original use case, wiring gyroscope yaw into sunlight_shimmer.slangp as new
PT_YAW_* pragma parameters, was reverted precisely because of the staleness
gap above: a new pragma can only reach an artifact that gets reconverted, and at
the time nothing forced one. The mechanism is kept for a future preset that
genuinely needs a new declaration, but an already-wired engine-side channel is
preferred whenever one exists.
Run: the pass graph
ShaderFX.activate(slot, entry, paramOverrides) loads the artifact, layers the
player's edited parameters over the artifact's own defaults, loads the preset's
LUTs once, and snapshots the accelerometer rest pose. ShaderFX.render then
runs the chain each frame.
newChainState builds one instance of chain-local state per loaded preset,
never module-global, so switching presets cannot leak a previous preset's
canvases or dimensions. ALL_DEFAULTS is built in layers: each pass's declared
initial, then the preset's own parameter_overrides, then (in activate) the
player's shaderfxParams edits.
Sizes resolve through resolveScale, which handles all four slang scale types
(absolute, viewport, source, original) against the viewport, the pass's
input dimensions and the original frame. Size uniforms are packed as
{w, h, 1/w, 1/h}, the slang convention.
runPass caches two things per (state, pass index). The shader is
compiled once for the state's lifetime, along with the fragment manifest it was
compiled against, since a pass's GLSL depends only on the preset and never on
per-frame input. The canvas is reallocated only when the pass's resolved
size actually changes, a window resize or a different preset. Every harness this
runtime was ported from ran the chain once and quit, so allocating a fresh
canvas and compiling a fresh shader on every call was invisible there. On a real
per-frame render path it is one GPU allocation per pass per frame, and a shader
recompile on top. The same discipline applies to the crop canvas in
cropToGbSource, which is called once per frame and whose size grows with the
world canvas as the player zooms out; leaving it uncached was a real cost that
scaled with zoom level even with a single preset active.
Sampler binding is by semantic, from the bridge's classification, never by a
hardcoded per-preset name check: Source is the previous pass's output (or the
input frame for pass 0), Original and OriginalHistory are the input frame,
PassOutput indexes an earlier pass's canvas, and User resolves a LUT by the
real name the translation reported. A sampler that resolves to nothing asserts
rather than drawing garbage.
LUTs are loaded once per activate. ShaderFX.loadImageFromPath reads the
bytes with plain io.open and goes through love.data.newByteData and
love.image.newImageData, because a preset's texture paths are arbitrary
absolute OS paths outside any LOVE mount and love.graphics.newImage refuses
those outright ("Could not open file ... Does not exist") even when the file is
real. Wrap modes are mapped from librashader's names to LOVE's
(clamp_to_border to clampzero, clamp_to_edge to clamp, repeat,
mirrored_repeat to mirroredrepeat). A LUT that fails to load is logged and
left nil; the fail-loud point is the sampler assertion in runPass that
actually needed it, not the loader.
History ring. OriginalHistoryN currently resolves to a steady state: every
history slot reads the current frame, both for the sampler binding and for the
size uniform. Real per-frame history rotation has been proven out in a desktop
harness but is not wired into this path.
Feedback. PassFeedback is not implemented. A PassFeedback or User size
uniform raises an explicit "not yet supported" error, and a PassFeedback
sampler resolves to nothing and trips the binding assertion. No preset in the
shipped shortlist uses it.
Blending. Every pass draws with replace, and the chain's final pass uses
replace, premultiplied. Intermediate canvases are nearest filtered.
ShaderFX.render(canvas, rect, source, dpiX, dpiY) is the entry point
Renderer:endFrame and Game2 call. canvas is the finished window-sized
composite (world, UI, and any post-process pipeline that already ran); rect is
this frame's real playfield rectangle in physical framebuffer pixels and
source is the real pixel size of the content it frames. The sequence is: crop
rect out of the composite, run whichever slots are active over that crop, draw
the untouched composite, then stretch the chain output back over rect. UI and
letterbox bars outside the playfield pass through untouched. If the chain throws,
the frame still shows the unprocessed composite; a broken preset degrades to
"shader off", never to a crash or a blank frame.
Three details in that path are non-obvious:
- DPI.
love.graphics.newCanvasanddrawwork in LOVE's DPI-aware logical units, not raw pixels, so the viewport handed to the pass graph and the final draw-back position are both converted fromrect's physical pixels first. On adpiscale = 1desktop the two are numerically identical and the bug is invisible; at dpiscale 3 on real Android hardware the chain output rendered about three times too large and at a pixel-valued offset in unit space. - Draw color.
cropToGbSourcesetssetColor(1, 1, 1, 1)explicitly. The caller can leave the draw color dirty (a menu's black text leaves it at(0,0,0,x)), the crop draw multiplies the canvas texels by the active color, andpush("all")saves state forpop()without resetting it. That was the root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro wheregetColor()read0,0,0,1here exactly when a menu was on the stack.flushBatch()on the line above is cheap insurance against a read-after-write ordering hazard between this draw and whatever last rendered into the canvas; it was never confirmed to fix anything on its own. - The final blit stretches. A slang chain's last pass is not required to
land on the viewport size, and most presets (21 of the 78 in the corpus)
declare their last pass
scale_type = "source"and stay at native Game Boy resolution, relying on the frontend's blit exactly as RetroArch does. Requiring an exact size match here used to skip the draw outright for every such preset on every frame, which is a silent total no-op rather than a sizing quirk. The stretch uses the last-run chain's own final-passfilterto pick nearest or linear.
What this engine feeds shaders that a libretro core does not
A stock libretro core hands its frontend a raw framebuffer and a frame count. This engine has more context available and passes some of it through.
| Context | How it reaches the shader |
|---|---|
| Playfield rect and true source size | rect/source per frame from Renderer:endFrame or Game2, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
| Blit scale | rect.scale, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
| SGB zone coloring and palette | Baked into the input frame. PaletteFX zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
| Performance tier | chainRenderScale() reads Performance.CAPS[tier].shaderfx, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
| Accelerometer | Sensors.read("accelerometer"), bound to the Accelerometer unique semantic |
| Gyroscope | Sensors.read("gyroscope"), bound to Gyroscope, plus the integrated yaw twist below |
Two motion semantics are deliberately pinned rather than guessed. Rotation is
bound to 0 because librashader's own documentation is explicit that it is
retroarch_get_rotation(), the content's requested rotation (a vertically
oriented arcade core, say), not device orientation. Nothing here ever rotates
Game Boy content, so 0 is the correct answer, not a placeholder.
AccelerometerRest is bound to {0, 0, 0}: it is librashader's "reading at
rest" calibration reference, no preset in the corpus reads it, and a fixed
placeholder beats an invented value.
src/core/Sensors.lua is what makes the two real motion semantics work.
love.sensor does not exist in LOVE 11.5, the version this project ships, on
any platform including Android; it is a LOVE 12 addition. The working path is
raw FFI into the SDL2 that LOVE already links, the same technique
src/core/Orientation.lua uses, opening the first SDL_SENSOR_ACCEL or
SDL_SENSOR_GYRO device via SDL_NumSensors/SDL_SensorGetDeviceType/
SDL_SensorOpen. The love.sensor path is kept above it and simply stops being
dead code after a future LOVE 12 upgrade. Loading order matters:
ffi.load("SDL2") first, needed on desktop where SDL2 is a separate DLL, then
bare ffi.C, needed on Android where love-android links SDL2 statically into
libmain.so and there is no libSDL2.so for ffi.load to find by name. A
device with no sensor is probed once and then permanently reports zeros, so a
desktop run does not pay for it every frame.
SDL keeps sensor readings in the device's fixed chassis frame regardless of
screen orientation, so rotateForScreen remaps x and y into
"as currently displayed" terms using SDL_GetDisplayOrientation. That
compensation is mobile-only: a desktop monitor is legitimately and permanently
"landscape" to that query, which says something about the monitor's shape and
nothing about how a player is holding anything.
The accelerometer path in sizeTable does three things to the raw reading
before it becomes a uniform, all of them driven by real on-device data:
- Rest-pose subtraction.
activate()snapshots whatever pose the player is actually holding the device in and every later reading is measured relative to that, rather than to an assumed idealized vertical. The shipped tilt maths (pt_base.inc'sgetOrientedTilt) was authored assuming gravity sits almost entirely on one axis at rest; a natural, comfortable hold already puts 56 to 66 percent of gravity's magnitude on the axis the shader reads as tilt, so the effect sat near-saturated all the time instead of starting near neutral. - Axis swap. The tilt maths assumes a device resting flat, with gravity dominant on Z, the one axis it never reads. This engine's rest pose is upright portrait, where Y is gravity-dominant, so y and z are swapped to put gravity back on the ignored axis.
- Denominator stabilization.
getOrientedTiltnormalizes by the full vector's magnitude. Before calibration that magnitude was a stable ~9.8 that quietly damped tilt and noise alike by the same factor; calibration correctly zeroes x and y at neutral but also shrinks the magnitude near rest, and real logs showed it swinging between 0.65 and 11.4 second to second on ordinary hand jitter, which reads as wildly bouncing. A fixed constant is re-injected on the ignored axis to keep the denominator stable, but only when there is a genuine live reading to calibrate against. An all-zero raw read isSensors.lua's explicit "no hardware at all" sentinel, never a real value on Earth, and injecting into that case would make the shader believe it had sensor data and silently replace its own static fallback with fake motion.
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
angle, so it only becomes a usable on-screen offset by integrating over time,
and only the per-slot state persists frame to frame to do that. updateYawTwist
is deliberately a decaying spring rather than a true integrated heading:
gyro-only integration drifts without a magnetometer to correct it, so this
settles back toward neutral and stays bounded by construction. It is folded onto
the accelerometer's x component before the shader's own normalize and clamp,
because that is the only already-compiled channel the stock upstream maths
reads, and reaching an already-converted artifact with no reconvert was worth
the tradeoff that the twist reads as an added simulated tilt rather than a
cleanly separate motion. It applies to sunlight_shimmer.slangp only, the one
preset in the shortlist with a twist-reactive channel. YAW_GAIN = 0.6 and a
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
looked worse, because overshooting a comfortable range reads worse than being
subtle. Retune in small steps with real device checks, not big jumps.
Two slots
ShaderFX.SLOTS is {"main", "secondary"} and ShaderFX.OPTION_KEY maps each
to its save key. The slots are activated and persisted independently, and the
same ShaderFXScreen serves both, opened with the slot as its argument. Pragma
parameter edits are keyed by preset name, not by slot, because a preset's
values are a property of the preset the same way its cached artifact is;
editing them re-activates every slot currently showing that preset and persists
for the next load in either.
When both slots are active, render runs main's chain first and hands its
finished output to secondary as secondary's own input frame, along with its
dimensions, so a secondary preset that scales off its input sees main's real
output size rather than the original crop. Either slot alone behaves exactly as
a single-preset path; neither active is a plain passthrough.
This is not how RetroArch composes multiple presets. RetroArch merges
presets into a single pass list through #reference and Append, producing
one pass graph with one shared semantics map, where a later pass can reference
an earlier one's output by alias and the whole thing resolves as one unit. Two
slots here are two independent librashader chains run back to back, which is
what stacking two separate preset chains would give you, not what merging them
gives you. Presets that assume merged semantics will not behave the same way.
Test seams
ShaderFX exposes a few fields purely so a headless harness can assert on real
per-frame values without taking a screenshot: _lastRect and _lastSource
(the rect and source dimensions a caller handed in), _lastCrop (the exact crop
canvas, which the later unconditional draw-back would otherwise mask),
_lastYawTwist and _lastAccelPacked (the integrated twist and the values that
actually reached the packed uniform, per slot). Sensors.setOverride,
Sensors.clearOverride and Sensors.setOrientationOverride inject synthetic
readings on a machine with no hardware.
Limitations
None of these are theoretical.
- Tested on very little real hardware. Essentially one Android phone, one desktop, and the automated harnesses. Anything about how a preset actually looks or performs elsewhere is unverified.
- No performance tier is actually tuned. The chain-resolution multiplier in
Performance.CAPSis a working mechanism, but every tier that permits ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution today. Picking a real value for weak hardware needs a device this project does not have. - The dual-slot design does not match RetroArch. See above. Two chains in sequence is not one merged pass list.
OriginalHistoryNis a steady state. Real per-frame history rotation falls back to "every slot is the current frame" in the live render path, and is unverified there.PassFeedbackis unimplemented. Its size uniform raises an explicit error and its sampler trips an assertion.ShaderFXScreenhas a known text-overlap bug on long preset names.ListMenu'sfitLabeltruncation covers the ordinary case, but a long enough player-supplied filename still collides with the row's right-hand hint.ShaderSourcePatchesships with an empty patch table and nothing uses it. Intentional, for the reason given above, but it means the mechanism has no live coverage.- Cached artifacts have no staleness detection. Existence is the only check. The two unconditional reconvert points paper over it; anything that does not go through them can be running a stale translation.
- Artifacts are per-device. The GLSL dialect is baked in at convert time. Copying a converted preset folder between a phone and a desktop copies a wrong artifact along with it.
- The bundled bridge is only as good as the build machine.
scripts/build.shbundles the cdylib for mac, win and linux viabundle_shader_bridge, building it with cargo when a prebuilt one is not supplied throughSHADERFX_BRIDGE. A build host without cargo produces a package that can run converted presets but cannot CONVERT new ones, and says so rather than failing. Android ships the.soviajniLibs. - The buildbot shortlist is a temporary trim.
KEPT_PRESETSreflects one manual pass overhandheld/and is expected to change, most likely to shrink. - Tilt direction is unverified. Which way forward and back rocking moves the effect was never confirmed on a device; if it feels backwards the fix is a sign flip on the swapped axis, not a deeper bug. Likewise, whether the landscape rotation compensation matches real RetroArch is genuinely unknown: RetroArch's Android input driver computes a screen rotation but does not visibly apply it to the accelerometer values that reach shader uniforms, so this project's compensation may be an improvement over upstream rather than a match to it.
texelFetchwrap behavior at image edges may differ. The ES 1.00 rewrite in the bridge turns those calls intotexture(), which honours the sampler's wrap mode out of range wheretexelFetch's out-of-bounds behavior is implementation defined. Unmeasured.