Skip to content

[macOS] BioShock Infinite [NPUB31128]: dark colours turn green (colour-grading LUT, fract() of an interpolated value lands just below an integer) #19814

Open

Description

@JustWoody

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).

master (left) / patched (right)
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

  1. kd-11 commented on Oct 12, 2026

    @kd-11
    Contributor

    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

  2. self-assigned this
    on Oct 12, 2026
  3. schm1dtmac commented on Oct 12, 2026

    @schm1dtmac
    Contributor
    1. 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

    Just confirmed I can repro the issue with both MVK fast math enabled & disabled on my M4 Pro system.

  4. JustWoody commented on Oct 12, 2026

    @JustWoody
    Author

    Thanks both. You're right: with the || the original patch also changed FRC on NVIDIA, and I have nothing showing NVIDIA needs that. Here's a narrower version that leaves the NVIDIA path exactly as it is. floor is untouched, and only FRC gets the nudge, only on MoltenVK:

    diff --git a/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.cpp b/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.cpp
    index f98afcd0d..2baa6254c 100644
    --- a/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.cpp
    +++ b/rpcs3/Emu/RSX/Program/FragmentProgramDecompiler.cpp
    @@ -983,6 +983,19 @@ std::string FragmentProgramDecompiler::BuildCode()
     		"#define _builtin_floor floor\n\n";
     	}
     
    +	if (device_props.has_low_precision_interpolation)
    +	{
    +		// 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 (BioShock Infinite colour-grading LUT slice selection)
    +		OS <<
    +		"#define _builtin_fract(x) fract(x + 0.000001)\n\n";
    +	}
    +	else
    +	{
    +		OS <<
    +		"#define _builtin_fract fract\n\n";
    +	}
    +
     	if (properties.has_pkg)
     	{
     		OS <<
    @@ -1116,7 +1129,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);

    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 the fract() maths itself.

    Result on master 8f2fe1a, same boat savestate as above:

    Build Frames Frames with green Green px per frame (mean)
    master 34 34 ~422,000
    master + this version 34 0 0

    30 fps in both.

  5. kd-11 commented on Oct 13, 2026

    @kd-11
    Contributor

    We already have a workaround for low precision interpolation (required for NV) and can be enabled here:

    backend_config.supports_normalized_barycentrics = (

    Instead of chasing the symptoms, this would allow you to get precise interpolation so that the 3 vertex ratios come out to exactly 1.

  6. kd-11 commented on Oct 13, 2026

    @kd-11
    Contributor

    @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.

  7. added 2 commits that reference this issue on Oct 15, 2026
    0ed6d54
    65e99e7
  8. JustWoody commented on Oct 17, 2026

    @JustWoody
    Author

    I tried the one-liner (!vk::is_NVIDIA(...) → vk::get_driver_vendor() != vk::driver_vendor::MVK at VKGSRender.cpp:666) on master with the same boat savestate. MoltenVK advertises VK_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:

    [mvk-error] SPIR-V to MSL conversion error: PerVertexKHR decoration is not supported in MSL.
    [mvk-error] VK_ERROR_INITIALIZATION_FAILED: Fragment shader function could not be compiled into pipeline.
    F {RSX.W1} SIG: Thread terminated due to fatal error: Assertion Failed! Vulkan API call failed with unrecoverable error: Initialization failed (VK_ERROR_INITIALIZATION_FAILED)
    (in file rpcs3/Emu/RSX/VK/VKProgramPipeline.cpp:274, in function 'program &vk::glsl::program::link(VkPipelineCache, bool)')
    

    So on MoltenVK the barycentric path can't be used as it stands: SPIRV-Cross has no MSL translation for pervertexEXT inputs. The MVK-only FRC nudge from my previous comment is the only variant that has worked on Mac so far (34/34 frames green → 0/34).

  9. kd-11 commented on Oct 17, 2026

    @kd-11
    Contributor

    Give the KK branch a try.

    EDIT: KK branch: #17902
    Reasoning - MVK is deprecated, we'll not bother maintaining MVK-only paths going forward.

  10. JustWoody commented on Oct 18, 2026

    @JustWoody
    Author

    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 FRC nudge 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):

    KosmicKrisp, nudge off (top) and on (bottom)
  11. JustWoody commented on Oct 18, 2026

    @JustWoody
    Author
  12. kd-11 commented on Oct 18, 2026

    @kd-11
  13. kd-11 commented on Oct 18, 2026

    @kd-11
    Contributor

    @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.

  14. JustWoody commented on Oct 18, 2026

    @JustWoody
    Author

    It isn't fract that's wrong. I wrote the raw bits of the shader's values into the 16x256 LUT target (MoltenVK, M3 Pro, nudge off): fract gives the correct IEEE result everywhere. The error is one step earlier, in the interpolated tc0.y.

    The slice-1 draw is one triangle, scissored to rows 16–31 of a 16x256 viewport:

    vertex position tc0.y
    v0 (-1, 0.875) 0.0625 (0x3d800000)
    v1 (3, 0.875) 0.0625 (0x3d800000)
    v2 (-1, 0.625) 0.1875 (0x3e400000)

    At each pixel centre tc0.y should 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: 0x3d83ffff instead of 0x3d840000 (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 of 0x3f800000
    • fract(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.

  15. kd-11 commented on Oct 18, 2026

    @kd-11
    Contributor

    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 relationship a + 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.

  16. JustWoody commented on Oct 18, 2026

    @JustWoody
    Author

    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/z are exact at all 256 pixels (row 16, column 0: 0.96875 / 0.015625 / 0.015625) and sum to exactly 1.
    • The interpolation from the barycentric path, 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-function tc0.y is 1 ulp low. For this test I used the per-slice vertex values as constants in place of the pervertexEXT inputs.

    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:

    • MoltenVK does report VK_KHR_fragment_shader_barycentric, and RPCS3 enables it, but SPIRV-Cross can't emit pervertexEXT inputs, so the first program that uses the path aborts ("PerVertexKHR decoration is not supported in MSL", my comment above).
    • KosmicKrisp doesn't expose the extension yet.
  17. kd-11 commented on Oct 18, 2026

    @kd-11
    Contributor

    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

  18. JustWoody commented on Oct 18, 2026

    @JustWoody
    Author

    Tried mvk_nv_barycentric on my local build (MoltenVK 1.4.3, same BSI savestate, FRC nudge off). It aborts the same way when the first program that uses the path is built:

    [mvk-error] SPIR-V to MSL conversion error: PerVertexKHR decoration is not supported in MSL.
    

    glslang emits the same decoration for pervertexNV and pervertexEXT (PerVertexNV and PerVertexKHR are 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:

  19. squidbus commented on Oct 20, 2026

    @squidbus

    KosmicKrisp generates its own MSL with the Metal 4 compiler, so vertex_value looks like the natural way for it to expose the extension. I'll ask there.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions