[SpirvTranslator] Saturate V_CVT_PKRTZ_F16_F32 at 65504 instead of overflowing to infinity

Rounding toward zero never produces infinity from a finite value: past the
f16 range the result is +-65504. We truncated the mantissa and let
packHalf2x16 round, so large values became infinite. Silent Hill's deferred
lighting packs title-menu values past 65504; its height fog then multiplies
scene color by a tiny transmittance, which keeps 65504 finite but leaves an
infinity infinite, a later pass turns it into NaN and the whole 3D menu
background renders black. Subnormal results now also keep whole 2^-24 steps.
A MoltenVK device test covers overflow, truncation, subnormals, NaN and inf.
This commit is contained in:
kuba 2026-10-03 20:26:22 +02:00
parent 829cf4132e
commit 52ba493155
2 changed files with 86 additions and 4 deletions

View File

@ -5202,12 +5202,33 @@ public static partial class Gen5SpirvTranslator
return Bitcast(_uintType, value);
}
// Rounds an f32 toward zero onto an f16 value, so packHalf2x16 then encodes it exactly
// (V_CVT_PKRTZ_F16_F32). Toward zero, a finite value beyond the f16 range becomes
// +-65504, never infinity: Silent Hill's deferred lighting packs values past 65504 and
// its fog pass then scales them down, while an infinity stays infinite, turns into NaN
// and blacks out the whole title-menu scene. Subnormal results keep whole 2^-24 steps.
private uint TruncateFloat32ForPack(uint value)
{
var raw = BitwiseAnd(
Bitcast(_uintType, value),
UInt(0xFFFF_E000));
return Bitcast(_floatType, raw);
var normal = Bitcast(
_floatType,
BitwiseAnd(Bitcast(_uintType, Ext(43, _floatType, value, Float(-65504f), Float(65504f))), UInt(0xFFFF_E000)));
var subnormal = _module.AddInstruction(
SpirvOp.FMul,
_floatType,
Ext(3, _floatType, _module.AddInstruction(SpirvOp.FMul, _floatType, value, Float(16777216f))),
Float(1f / 16777216f));
var isSubnormal = _module.AddInstruction(
SpirvOp.FOrdLessThan,
_boolType,
Ext(4, _floatType, value),
Float(6.10351562e-05f));
var finite = _module.AddInstruction(SpirvOp.Select, _floatType, isSubnormal, subnormal, normal);
var special = _module.AddInstruction(
SpirvOp.LogicalOr,
_boolType,
_module.AddInstruction(SpirvOp.IsNan, _boolType, value),
_module.AddInstruction(SpirvOp.IsInf, _boolType, value));
return _module.AddInstruction(SpirvOp.Select, _floatType, special, value, finite);
}
private uint Ext(uint operation, uint resultType, params uint[] operands)

View File

@ -162,5 +162,66 @@ public sealed class FloatSemanticsDeviceTests(HeadlessVulkanFixture fixture) : I
}
}
// V_CVT_PKRTZ_F16_F32 rounds toward zero: finite values past the f16 range saturate at
// +-65504 instead of becoming infinity, and subnormal results keep whole 2^-24 steps.
[Fact]
public void PackToHalfRoundsTowardZeroWithoutOverflowingToInfinity()
{
var vulkan = fixture.Vulkan;
if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return;
var program = Program(
Vop1(0, "VCvtF32U32", 1, Gen5Operand.Vector(0)),
MoveScalar(4, 8, Bits(5003.7f)),
MoveScalar(12, 9, Bits(-80000.3f)),
MoveScalar(20, 10, Bits(3.3e-6f)),
MoveScalar(28, 11, Bits(-5e-5f)),
MoveScalar(36, 12, Bits(float.NaN)),
MoveScalar(44, 13, Bits(float.PositiveInfinity)),
Vop3(52, "VMadF32", 2, Gen5Operand.Vector(1), Gen5Operand.Scalar(8), Gen5Operand.Scalar(9)),
Vop3(60, "VMadF32", 3, Gen5Operand.Vector(1), Gen5Operand.Scalar(10), Gen5Operand.Scalar(11)),
Vop2(68, "VCvtPkrtzF16F32", 12, Gen5Operand.Vector(2), Gen5Operand.Vector(3)),
Vop3(72, "VCvtPkrtzF16F32", 13, Gen5Operand.Scalar(12), Gen5Operand.Scalar(13)),
Vop2(80, "VLshlrevB32", 7, Operand(5), Gen5Operand.Vector(0)),
BufferAccess(84, "BufferStoreDword", 4, vectorData: 12, offsetEnabled: true, vectorAddress: 7),
BufferAccess(92, "BufferStoreDword", 4, offset: 4, vectorData: 13, offsetEnabled: true, vectorAddress: 7),
EndProgram(100));
var (plan, resources, layout) = Prepare(program);
var request = new ShaderCompileRequest(plan, resources, layout)
{
LocalSizeX = ThreadCount, ThreadCountX = ThreadCount, WaveSize = 32,
};
Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error);
using var harness = new ImageTestHarness(vulkan);
using var runner = new LayoutComputeRunner(harness, request, shader.Spirv);
var result = runner.CreateBuffer(ThreadCount * RowBytes);
var registers = new uint[256];
registers[6] = ThreadCount * RowBytes;
harness.Run(() => runner.Dispatch(registers,
new Dictionary<DescriptorBindingKind, GpuBuffer[]> { [DescriptorBindingKind.Buffers] = [result] }, 1));
var actual = runner.ReadBack(result, 0, ThreadCount * RowBytes);
for (var lane = 0u; lane < ThreadCount; lane++)
{
var row = actual.AsSpan((int)(lane * RowBytes));
var large = MathF.FusedMultiplyAdd(lane, 5003.7f, -80000.3f);
var small = MathF.FusedMultiplyAdd(lane, 3.3e-6f, -5e-5f);
var packed = BinaryPrimitives.ReadUInt32LittleEndian(row);
Assert.Equal(HalfTowardZero(large), packed & 0xFFFF);
Assert.Equal(HalfTowardZero(small), packed >> 16);
var special = BinaryPrimitives.ReadUInt32LittleEndian(row[4..]);
Assert.True((special & 0x7C00) == 0x7C00 && (special & 0x3FF) != 0, $"lane {lane}: 0x{special:X8}");
Assert.Equal(0x7C00u, special >> 16);
}
}
private static uint HalfTowardZero(float value)
{
var sign = value < 0 || (value == 0 && float.IsNegative(value)) ? 0x8000u : 0u;
var magnitude = MathF.Abs(value);
if (magnitude >= 65504f) return sign | 0x7BFF;
if (magnitude < 6.10351562e-05f) return sign | (uint)Math.Floor(magnitude * 16777216.0);
var bits = BitConverter.SingleToUInt32Bits(magnitude);
return sign | ((((bits >> 23) - 127 + 15) << 10) | ((bits >> 13) & 0x3FF));
}
private static uint Bits(float value) => BitConverter.SingleToUInt32Bits(value);
}