Repository navigation
[macOS] BioShock Infinite [NPUB31128]: dark colours turn green (colour-grading LUT, fract() of an interpolated value lands just below an integer) #19814
Description
Quick summary
On macOS, at the start of the game (rowing boat, boathouse), near-black areas such as the sea, the boat and shadows are tinted bright green. Screenshot in Details: master left, patched right.
Details
Cause: the UE3 colour-grading pass builds its 3D LUT as a 16x256 render target (16 draws, cb0c0000) and picks the LUT slice per pixel with fract(16 * (tc0.y - 1/512)). On Apple GPUs the interpolated tc0.y at the rows where that product is a whole number comes out a few ulps low, so fract() returns ~1 instead of 0 and the row reads the last texel of the previous slice. That texel is (0, 255, 0), and the 3D lookup for dark colours blends it in. noperspective doesn't help. NVIDIA has the same class of problem, and has_low_precision_rounding already nudges floor() there (FragmentProgramDecompiler.cpp:972), but FRC isn't nudged and the nudge isn't enabled on MoltenVK.
Fix: add the same nudge to FRC (_builtin_fract), and enable both nudges on MoltenVK through a separate has_low_precision_interpolation flag, so the comparison tolerance used on NVIDIA isn't changed. 3 files, +10 / -4.
Result on master 8f2fe1a (savestate in the boat, shader cache cleared, window captured externally for 12 s; green px = g > r+30 and g > b+30):
| Build | Frames | Frames with green | Green px per frame (mean) |
|---|---|---|---|
| master | 36 | 36 | ~411,000 |
| master + fix | 34 | 0 | 0 |
30 fps in both. My local build carries the same change, and Batman: Arkham City, Far Cry 3, GTA IV and The Last of Us run normally with it (30/30/60/30 fps).
Diff (against master 8f2fe1a)
diff --git a/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.cpp b/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.cpp
index f98afcd0d..285989452 100644
--- a/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.cpp
+++ b/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.cpp
@@ -969,18 +969,22 @@ std::string FragmentProgramDecompiler::BuildCode()
"#define _builtin_div(x, y) (x / y)\n"
"#define _builtin_lerp mix\n";
- if (device_props.has_low_precision_rounding)
+ if (device_props.has_low_precision_rounding || device_props.has_low_precision_interpolation)
{
// NVIDIA has terrible rounding errors interpolating constant values across vertices with different w
// PS3 games blindly rely on interpolating a constant to not change the values
// Calling floor/equality will fail randomly causing a moire pattern
+ // Apple GPUs (MoltenVK) can interpolate a value that lands exactly on an integer boundary a few ulps low, so fract
+ // returns ~1 instead of 0 there as well.
OS <<
- "#define _builtin_floor(x) floor(x + 0.000001)\n\n";
+ "#define _builtin_floor(x) floor(x + 0.000001)\n"
+ "#define _builtin_fract(x) fract(x + 0.000001)\n\n";
}
else
{
OS <<
- "#define _builtin_floor floor\n\n";
+ "#define _builtin_floor floor\n"
+ "#define _builtin_fract fract\n\n";
}
if (properties.has_pkg)
@@ -1116,7 +1120,7 @@ bool FragmentProgramDecompiler::handle_sct_scb(u32 opcode)
case RSX_FP_OPCODE_REFL: SetDst(getFunction(FUNCTION::REFL), OPFLAGS::op_extern); return true;
case RSX_FP_OPCODE_EX2: SetDst("exp2($0.xxxx)"); return true;
case RSX_FP_OPCODE_FLR: SetDst("_builtin_floor($0)"); return true;
- case RSX_FP_OPCODE_FRC: SetDst(getFunction(FUNCTION::FRACT)); return true;
+ case RSX_FP_OPCODE_FRC: SetDst("_builtin_fract($0)"); return true;
case RSX_FP_OPCODE_LIT:
SetDst("_builtin_lit($0)");
properties.has_lit_op = true;
diff --git a/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.h b/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.h
index 8044044da..b96c3c7c5 100644
--- a/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.h
+++ b/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.h
@@ -195,6 +195,7 @@ public:
bool has_native_half_support = false;
bool emulate_depth_compare = false;
bool has_low_precision_rounding = false;
+ bool has_low_precision_interpolation = false;
bool emulated_depth_storage = false;
}
device_props;
diff --git a/rpcs3/Emu/RSX/VK/VKFragmentProgram.cpp b/rpcs3/Emu/RSX/VK/VKFragmentProgram.cpp
index 439cd3774..782f953f0 100644
--- a/rpcs3/Emu/RSX/VK/VKFragmentProgram.cpp
+++ b/rpcs3/Emu/RSX/VK/VKFragmentProgram.cpp
@@ -636,6 +636,7 @@ void VKFragmentProgram::Decompile(const RSXFragmentProgram& prog)
decompiler.device_props.emulate_depth_compare = !pdev->get_formats_support().d24_unorm_s8;
decompiler.device_props.emulated_depth_storage = vk::emulate_extended_depth_range();
decompiler.device_props.has_low_precision_rounding = vk::is_NVIDIA(vk::get_driver_vendor());
+ decompiler.device_props.has_low_precision_interpolation = vk::get_driver_vendor() == vk::driver_vendor::MVK;
decompiler.Task();
constant_offsets = std::move(decompiler.properties.constant_offsets);Patch file (same diff, for git apply): 36-mvk-floor-fract-nudge.patch
Attach a log file
36-RPCS3-master-base.log.gz
36-RPCS3-master-fix.log.gz
Attach capture files for visual issues
Screenshot in Details (no RSX capture).
System configuration
- Apple M3 Pro, macOS 27.0.1
- RPCS3 master 8f2fe1a, built locally; Vulkan through MoltenVK 1.4.2-rc1 (as bundled by the CI)
- Default settings otherwise (BioShock Infinite 1.06)
Other details
No response
Activity
kd-11 commentedon Oct 12, 2026
That's a nice finding. However the patch also makes the claim that nvidia needs the workaround when it may not actually need it and the workaround could break things for desktop users.
Afaik MVK has 2 math modes, one fast and one more compliant with the vulkan spec. Does this finding apply to both modes? Or only the fastmath mode?
CC @schm1dtmac
schm1dtmac commentedon Oct 12, 2026
Just confirmed I can repro the issue with both MVK fast math enabled & disabled on my M4 Pro system.
JustWoody commentedon Oct 12, 2026
Thanks both. You're right: with the
||the original patch also changedFRCon NVIDIA, and I have nothing showing NVIDIA needs that. Here's a narrower version that leaves the NVIDIA path exactly as it is.flooris untouched, and onlyFRCgets the nudge, only on MoltenVK:On fast math: as schm1dtmac found, it reproduces with fast math on and off. That fits the cause being the interpolated input to
fract(), not thefract()maths itself.Result on master 8f2fe1a, same boat savestate as above:
30 fps in both.
kd-11 commentedon Oct 13, 2026
We already have a workaround for low precision interpolation (required for NV) and can be enabled here:
rpcs3/rpcs3/Emu/RSX/VK/VKGSRender.cpp
Line 665 in 2ba2f75
Instead of chasing the symptoms, this would allow you to get precise interpolation so that the 3 vertex ratios come out to exactly 1.
kd-11 commentedon Oct 13, 2026
@schm1dtmac You can probably check that quickly, just change
!vk::is_NVIDIA(vk::get_driver_vendor())to(vk::get_driver_vendor() != vk::driver_vendor::MVK). Easy one-liner.rsx: Nudge FRC on MoltenVK to avoid low-precision interpolation errors
rsx: Nudge FRC on MoltenVK to avoid low-precision interpolation errors
JustWoody commentedon Oct 17, 2026
I tried the one-liner (
!vk::is_NVIDIA(...)→vk::get_driver_vendor() != vk::driver_vendor::MVKatVKGSRender.cpp:666) on master with the same boat savestate. MoltenVK advertisesVK_KHR_fragment_shader_barycentric, so the workaround does switch on, but the first program that uses it fails to build and RPCS3 aborts 22 s in:So on MoltenVK the barycentric path can't be used as it stands: SPIRV-Cross has no MSL translation for
pervertexEXTinputs. The MVK-onlyFRCnudge from my previous comment is the only variant that has worked on Mac so far (34/34 frames green → 0/34).kd-11 commentedon Oct 17, 2026
Give the KK branch a try.
EDIT: KK branch: #17902
Reasoning - MVK is deprecated, we'll not bother maintaining MVK-only paths going forward.
JustWoody commentedon Oct 18, 2026
I tried KosmicKrisp (Mesa main, with #17902's vendor detection): the dark areas turn green there too. Same boat savestate, 30 fps, 14k–111k green pixels per screenshot, the same pattern as on MoltenVK. So it looks like the Apple GPU's interpolation precision rather than MoltenVK.
With the
FRCnudge from my earlier comment enabled for KosmicKrisp as well (gated on MoltenVK or KosmicKrisp instead of MoltenVK only), it's gone: 0 green pixels in 4 of 4 screenshots, still 30 fps.These are from my local build (master-based, with my other changes), not clean master.
KosmicKrisp, nudge off (top) and on (bottom):
JustWoody commentedon Oct 18, 2026
kd-11 commentedon Oct 18, 2026
kd-11 commentedon Oct 18, 2026
@JustWoody Do you have the precise bit pattern where fract fails on MVK?
I'd like to add it to the hardware IEEE compliance suite testcase.
JustWoody commentedon Oct 18, 2026
It isn't
fractthat's wrong. I wrote the raw bits of the shader's values into the 16x256 LUT target (MoltenVK, M3 Pro, nudge off):fractgives the correct IEEE result everywhere. The error is one step earlier, in the interpolatedtc0.y.The slice-1 draw is one triangle, scissored to rows 16–31 of a 16x256 viewport:
0x3d800000)0x3d800000)0x3e400000)At each pixel centre
tc0.yshould be exactly (row + 0.5) / 256. In rows 16–31, 178 of the 256 pixels come out 1 ulp low, e.g. row 16, column 0:0x3d83ffffinstead of0x3d840000(33/512). The other 15 slices (same triangle shape, shifted in y) are exact.From there the maths is exact:
16 * (tc0.y - 1/512)=0x3f7ffffe(0.99999988) instead of0x3f800000fract(0x3f7ffffe)=0x3f7ffffe, which is correct, so the shader picks slice 15 (the green texel) instead of slice 0.So the hardware test case is varying interpolation at pixel centres rather than
fract: a value that should land exactly on 33/512 comes back 1 ulp low. KosmicKrisp shows the same green, so it's the Apple GPU, not MoltenVK.kd-11 commentedon Oct 18, 2026
So what went wrong with KomicKrisp then? Did the manual barycentric interpolation we use not kick in due to a missing code change somewhere? Or does apple hardware actually calculate incorrect barycentrics? It would be hard for them to pass some conformance suites if they were too far off.
SEE EDIT 1
Check in renderdoc what the barycoord vector is for your exact scenario v0, v1, v2. For most hardware, you get 2 or 3 interpolated bary-coordinates. If there are 3, there can be drift and we can only rely on 2 and recompute the other one using the relationshipa + b + c = 1. So if we can find the 'bad' one, we can ignore it and use the other 2. If the interpolator is genuinely broken then more aggressive measures would be required to fix it.I ask, because obviously modifying fract() behavior to work around the incorrect barycentrics cannot be merged. The two issues are only coincidentally correlated in this specific game, making it a hackfix.
EDIT: I read the comment again, the barycoord interpolation was not added into the KK test. See #19814 (comment)
We do need to check if it works on KK because MVK doesn't have the extension needed, its the only way out of this one.
EDIT2: And I'm wrong again. Just checked the registry and MVK does report the extension. That's good news for us.
JustWoody commentedon Oct 18, 2026
Measured on MoltenVK with the same draw (v0 (-1, 0.875), v1 (3, 0.875), v2 (-1, 0.625); tc0.y 0.0625 / 0.0625 / 0.1875). MoltenVK translates
gl_BaryCoordEXT, so I wrote its bits out like before:gl_BaryCoordEXT.x/y/zare exact at all 256 pixels (row 16, column 0: 0.96875 / 0.015625 / 0.015625) and sum to exactly 1.v0 + (v1 - v0) * b.y + (v2 - v0) * b.z, gives exactly (row + 0.5) / 256 at all 4096 pixels of the target, including the 178 where the fixed-functiontc0.yis 1 ulp low. For this test I used the per-slice vertex values as constants in place of thepervertexEXTinputs.So the barycentrics are fine and only the fixed-function interpolation is off; the barycentric path would fix this. What blocks it on Mac is the per-vertex inputs:
VK_KHR_fragment_shader_barycentric, and RPCS3 enables it, but SPIRV-Cross can't emitpervertexEXTinputs, so the first program that uses the path aborts ("PerVertexKHR decoration is not supported in MSL", my comment above).kd-11 commentedon Oct 18, 2026
Looks like SPIRV-cross only supports NV_fragment_shader_barycentric and not KHR_fragment_shader_barycentric. Easy enough to work around.
Try this hacky branch to confirm: https://github.com/kd-11/rpcs3/tree/mvk_nv_barycentric
Also CC @schm1dtmac
JustWoody commentedon Oct 18, 2026
Tried
mvk_nv_barycentricon my local build (MoltenVK 1.4.3, same BSI savestate,FRCnudge off). It aborts the same way when the first program that uses the path is built:glslang emits the same decoration for
pervertexNVandpervertexEXT(PerVertexNVandPerVertexKHRare both 5285), and SPIRV-Cross's MSL backend rejects any fragment input that has it (spirv_msl.cpp:3926, still the case on main). So the flavour doesn't change anything on MoltenVK.What exists:
vertex_value<T>, MSL spec 2.19). Using them in SPIRV-Cross was requested in MSL: Support for PerVertexKHR with MSL 4.0 KhronosGroup/SPIRV-Cross#2569 and closed as not planned.vertex_value<T>looks like the natural way for it to expose the extension. I'll ask there.squidbus commentedon Oct 20, 2026
Note you can only use this on M5+ hardware.
MoltenVK exposes the extension without any per-vertex value support, so that the barycentric coord inputs are usable. KosmicKrisp does not since that won't pass conformance. Possibly could be emulated on older hardware in the future, it's on the roadmap for features to support that were advertised in MoltenVK at any rate.