iOS: clamp a shader's prescale so upscaling cannot blacken the frame

crt-aperture and sharp-bilinear each derive a whole-number prescale from
output height over source height and then divide by it. RetroArch only ever
feeds them a small console framebuffer being scaled up, so that ratio never
falls below one. PCSX2 renders internally at up to 8x: past roughly 1.5x on
a phone the source is taller than the screen, the ratio drops under one,
floor() returns zero and the divide yields NaN. The whole frame goes black.

Reported on an iPhone SE 2 with a 1334x750 window, where 1.5x rendered and
2x did not. Two hypotheses were wrong first -- push-constant placement, then
parameter placement -- and both were refuted by tester data before the
reporter supplied the actual trigger, which was the internal resolution and
not the preset. Reproduced in the simulator at 3x and fixed there.

The clamp is what the sibling sharp-bilinear-simple already carries as
max(floor(...), vec2(1.0)) and what crt-geom carries as clamp(floor(...),
1.0, 2.0). Nine of the eleven bundled presets never divide by a derived
scale and were unaffected.

These files are otherwise byte-verbatim copies of a pinned upstream commit,
so the divergence is a reversible patch beside the librashader one and a
note in ATTRIBUTION.md. The test fails if either guard is dropped, which is
what a re-sync from upstream would otherwise do silently.
This commit is contained in:
J1coding
2026-08-18 23:41:53 +02:00
committed by Jeen
parent 16b571cf72
commit d24e9d76ed
5 changed files with 137 additions and 4 deletions
@@ -8,8 +8,17 @@ licence, and where it came from.
**Pinned commit:** `80372284ea8c00ae5e25e5a6e4f9f49415f85896` (2026-08-15)
Every file below is copied byte for byte from that commit, licence headers intact. Nothing
was renamed, reformatted or edited. To verify any file, clone the upstream repository at the
pinned commit and compare it against the copy in this bundle at the upstream path given.
was renamed or reformatted. To verify any file, clone the upstream repository at the pinned
commit and compare it against the copy in this bundle at the upstream path given.
**Two files carry a one line change**, recorded in
`platforms/ios/patches/slang-shaders-prescale-zero-guard.patch` and reversible with
`git apply -R`. `crt/shaders/crt-aperture.slang` and
`pixel-art-scaling/shaders/sharp-bilinear.slang` each clamp a derived integer prescale to a
minimum of one. Upstream divides by that prescale without checking it, which is safe in
RetroArch but not here: PCSX2 renders internally at up to 8x, and once the source is taller
than the screen the prescale floors to zero and the frame turns black. Both files remain
under their original licences and authorship.
The upstream collection is mixed licence and has no repository-wide licence file, so each
file here was cleared on its own header rather than on a blanket grant. Files whose headers
@@ -142,7 +142,7 @@ layout(set = 0, binding = 2) uniform sampler2D Source;
void main()
{
float scale = floor(params.OutputSize.y * params.SourceSize.w);
float scale = max(floor(params.OutputSize.y * params.SourceSize.w), 1.0);
float offset = 1.0 / scale * 0.5;
if (bool(mod(scale, 2.0))) offset = 0.0;
@@ -47,7 +47,7 @@ void main()
vec2 texel = vTexCoord * params.SourceSize.xy;
vec2 texel_floored = floor(texel);
vec2 s = fract(texel);
float scale = (params.AUTO_PRESCALE > 0.5) ? floor(params.OutputSize.y / params.SourceSize.y + 0.01) : params.SHARP_BILINEAR_PRE_SCALE;
float scale = max((params.AUTO_PRESCALE > 0.5) ? floor(params.OutputSize.y / params.SourceSize.y + 0.01) : params.SHARP_BILINEAR_PRE_SCALE, 1.0);
float region_range = 0.5 - 0.5 / scale;
// Figure out where in the texel to sample to get correct pre-scaled bilinear.
@@ -0,0 +1,47 @@
Guard the integer prescale in crt-aperture and sharp-bilinear against zero.
Both shaders derive a whole-number prescale from output height over source height,
then divide by it. RetroArch only ever feeds them a small console framebuffer being
scaled up, so the ratio is never below one. PCSX2 renders internally at up to 8x, and
above roughly 1.5x on a phone the source is taller than the screen: the ratio drops
under one, floor() returns zero, and the divide yields NaN. The whole frame goes black.
Reported on an iPhone SE 2 (1334x750) where 1.5x worked and 2x did not, then reproduced
in the simulator at 3x. Fixed by clamping the prescale to one, which is what the sibling
sharp-bilinear-simple already does with max(floor(...), vec2(1.0)) and what crt-geom does
with clamp(floor(...), 1.0, 2.0).
This diverges from the pinned upstream commit, so it is recorded here and in
shaders/ATTRIBUTION.md. Reverse it to recover the upstream bytes:
git apply -R platforms/ios/patches/slang-shaders-prescale-zero-guard.patch
test_ios_shader_prescale_guard.py fails if either guard is dropped, which is what a
re-sync from upstream would otherwise do silently.
diff --git a/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang b/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang
index 8ca5192e40..508c8fa2eb 100644
--- a/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang
+++ b/platforms/ios/app/src/main/assets/shaders/presets/crt/shaders/crt-aperture.slang
@@ -142,7 +142,7 @@ layout(set = 0, binding = 2) uniform sampler2D Source;
void main()
{
- float scale = floor(params.OutputSize.y * params.SourceSize.w);
+ float scale = max(floor(params.OutputSize.y * params.SourceSize.w), 1.0);
float offset = 1.0 / scale * 0.5;
if (bool(mod(scale, 2.0))) offset = 0.0;
diff --git a/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang b/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang
index 90508aa907..90865cc715 100644
--- a/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang
+++ b/platforms/ios/app/src/main/assets/shaders/presets/pixel-art-scaling/shaders/sharp-bilinear.slang
@@ -47,7 +47,7 @@ void main()
vec2 texel = vTexCoord * params.SourceSize.xy;
vec2 texel_floored = floor(texel);
vec2 s = fract(texel);
- float scale = (params.AUTO_PRESCALE > 0.5) ? floor(params.OutputSize.y / params.SourceSize.y + 0.01) : params.SHARP_BILINEAR_PRE_SCALE;
+ float scale = max((params.AUTO_PRESCALE > 0.5) ? floor(params.OutputSize.y / params.SourceSize.y + 0.01) : params.SHARP_BILINEAR_PRE_SCALE, 1.0);
float region_range = 0.5 - 0.5 / scale;
// Figure out where in the texel to sample to get correct pre-scaled bilinear.
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""A bundled shader must not divide by an unguarded integer prescale.
Several slang shaders derive a whole-number prescale from output height over source
height and then divide by it. That is safe in RetroArch, where the source is always a
small console framebuffer being scaled up. PCSX2 renders internally at up to 8x, so
above roughly 1.5x the source is taller than the screen, the ratio falls under one,
floor() returns zero and the frame turns black. Reported on an iPhone SE 2 at 2x,
reproduced in the simulator at 3x.
The guard is a max(..., 1.0) around the prescale, which sharp-bilinear-simple and
crt-geom already carry upstream. This test exists because re-syncing a preset from
upstream would silently drop it again.
"""
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[4]
PRESETS = ROOT / "platforms/ios/app/src/main/assets/shaders/presets"
# A prescale is a floor() over the output/source height ratio, in either the divide or
# the reciprocal-multiply spelling. SourceSize.w and .zw are 1/height and 1/size.
PRESCALE = re.compile(
r"floor\s*\([^;]*?OutputSize\.[xy]{1,2}[^;]*?"
r"(?:/\s*[a-zA-Z_.]*SourceSize\.[xy]{1,2}|\*\s*[a-zA-Z_.]*SourceSize\.[zw]{1,2})",
re.IGNORECASE,
)
def shaders():
return sorted(PRESETS.rglob("*.slang"))
class PrescaleGuard(unittest.TestCase):
def test_presets_exist(self):
self.assertTrue(shaders(), f"no .slang files under {PRESETS}")
def test_every_prescale_is_clamped(self):
offenders = []
for path in shaders():
for number, line in enumerate(path.read_text().splitlines(), 1):
code = line.split("//")[0]
if not PRESCALE.search(code):
continue
# clamp() carries its own lower bound; max() is the direct guard.
if "max(" in code or "clamp(" in code:
continue
offenders.append(f" {path.relative_to(PRESETS)}:{number}: {code.strip()}")
self.assertEqual(
offenders,
[],
"a derived integer prescale is not clamped to at least 1. Above ~1.5x internal "
"resolution it floors to zero and the frame goes black. Wrap it in max(..., 1.0) "
"and record the change in patches/slang-shaders-prescale-zero-guard.patch:\n"
+ "\n".join(offenders),
)
def test_the_two_known_shaders_still_carry_their_guard(self):
"""Named directly, so a rename or a re-sync cannot quietly drop the fix."""
for relative in (
"crt/shaders/crt-aperture.slang",
"pixel-art-scaling/shaders/sharp-bilinear.slang",
):
path = PRESETS / relative
self.assertTrue(path.is_file(), f"missing {relative}")
self.assertIn(
"max(",
path.read_text(),
f"{relative} lost its prescale guard; see "
"patches/slang-shaders-prescale-zero-guard.patch",
)
if __name__ == "__main__":
unittest.main()