mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
1905261c5b
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
674 lines
41 KiB
Markdown
674 lines
41 KiB
Markdown
# 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 literal `ivec2(w, h)` when `Tex` is 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-literal
|
|
`lod`, is left alone so it fails as loudly as before instead of guessing.
|
|
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
|
|
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
|
|
reciprocal-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>Size` uniform 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 parameter`s 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)` `loadfile`s 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.newCanvas` and `draw` work 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 from `rect`'s physical pixels
|
|
first. On a `dpiscale = 1` desktop 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.** `cropToGbSource` sets `setColor(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,
|
|
and `push("all")` saves state for `pop()` without resetting it. That was the
|
|
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
|
|
`getColor()` read `0,0,0,1` here 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-pass `filter` to 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:
|
|
|
|
1. **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`'s `getOrientedTilt`) 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.
|
|
2. **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.
|
|
3. **Denominator stabilization.** `getOrientedTilt` normalizes 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 is
|
|
`Sensors.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.CAPS` is 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.
|
|
- **`OriginalHistoryN` is 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.
|
|
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
|
|
error and its sampler trips an assertion.
|
|
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
|
|
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
|
|
player-supplied filename still collides with the row's right-hand hint.
|
|
- **`ShaderSourcePatches` ships 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.sh` bundles the cdylib for mac, win and linux via
|
|
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
|
|
supplied through `SHADERFX_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 `.so` via `jniLibs`.
|
|
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
|
|
manual pass over `handheld/` 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.
|
|
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
|
|
in the bridge turns those calls into `texture()`, which honours the sampler's
|
|
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
|
|
implementation defined. Unmeasured.
|