diff --git a/sources/engine/Stride.Graphics.Tests.11_0/TestRadiancePrefilteringGgxFaceContinuity.cs b/sources/engine/Stride.Graphics.Tests.11_0/TestRadiancePrefilteringGgxFaceContinuity.cs new file mode 100644 index 0000000000..8a0e186b93 --- /dev/null +++ b/sources/engine/Stride.Graphics.Tests.11_0/TestRadiancePrefilteringGgxFaceContinuity.cs @@ -0,0 +1,142 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System; + +using Xunit; + +using Stride.Core.Mathematics; +using Stride.Rendering; +using Stride.Rendering.ComputeEffect.GGXPrefiltering; + +namespace Stride.Graphics.Tests; + +public class TestRadiancePrefilteringGgxFaceContinuity : GraphicTestGameBase +{ + // The 12 edges cube faces share, in Direct3D face order, with the orientation each pair meets at. + private static readonly (int FaceA, Edge EdgeA, int FaceB, Edge EdgeB, bool Reversed)[] SharedEdges = + { + (0, Edge.Bottom, 3, Edge.Right, false), (0, Edge.Left, 4, Edge.Right, false), + (0, Edge.Right, 5, Edge.Left, false), (0, Edge.Top, 2, Edge.Right, true), + (1, Edge.Bottom, 3, Edge.Left, true), (1, Edge.Left, 5, Edge.Right, false), + (1, Edge.Right, 4, Edge.Left, false), (1, Edge.Top, 2, Edge.Left, false), + (2, Edge.Bottom, 4, Edge.Top, false), (2, Edge.Top, 5, Edge.Top, true), + (3, Edge.Bottom, 5, Edge.Bottom, true), (3, Edge.Top, 4, Edge.Bottom, false), + }; + + private enum Edge { Top, Bottom, Left, Right } + + private const int OutputSize = 64; + + private double seamRatio; + private double withinFaceVariation; + + protected override void RegisterTests() + { + base.RegisterTests(); + + FrameGameSystem.Draw(MeasureFaceContinuity); + } + + /// + /// A cubemap discretizes a function over a sphere, so neighboring texels on either side of a shared + /// face edge must stay as close as neighboring texels within a face. The output format differs from + /// the source here, which is what a low dynamic range skybox produces in SkyboxGenerator. + /// + private void MeasureFaceContinuity() + { + var commandList = GraphicsContext.CommandList; + var input = Content.Load("CubeMap"); + + using var output = Texture.New2D(GraphicsDevice, OutputSize, OutputSize, MathUtil.Log2(OutputSize), + PixelFormat.R8G8B8A8_UNorm, TextureFlags.ShaderResource | TextureFlags.RenderTarget, 6); + + var filter = new RadiancePrefilteringGGXNoCompute(RenderContext.GetShared(Services)) + { + RadianceMap = input, + PrefilteredRadiance = output, + MipmapGenerationCount = MathUtil.Log2(OutputSize), + }; + filter.Draw(new RenderDrawContext(Services, RenderContext.GetShared(Services), GraphicsContext)); + + var faces = new double[6][,]; + for (var face = 0; face < faces.Length; face++) + faces[face] = ReadLuminance(output, commandList, face); + + var seam = 0.0; + foreach (var (faceA, edgeA, faceB, edgeB, reversed) in SharedEdges) + { + var a = ReadEdge(faces[faceA], edgeA); + var b = ReadEdge(faces[faceB], edgeB); + if (reversed) + Array.Reverse(b); + + var total = 0.0; + for (var i = 0; i < OutputSize; i++) + total += Math.Abs(a[i] - b[i]); + seam += total / OutputSize; + } + seam /= SharedEdges.Length; + + var within = 0.0; + foreach (var face in faces) + { + var total = 0.0; + for (var y = 1; y < OutputSize; y++) + for (var x = 0; x < OutputSize; x++) + total += Math.Abs(face[y, x] - face[y - 1, x]); + within += total / ((OutputSize - 1) * OutputSize); + } + within /= faces.Length; + + withinFaceVariation = within; + seamRatio = seam / Math.Max(double.Epsilon, within); + } + + private static double[,] ReadLuminance(Texture texture, CommandList commandList, int face) + { + var data = texture.GetData(commandList, face, 0); + var result = new double[OutputSize, OutputSize]; + for (var y = 0; y < OutputSize; y++) + { + for (var x = 0; x < OutputSize; x++) + { + var i = (y * OutputSize + x) * 4; + result[y, x] = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]; + } + } + return result; + } + + private static double[] ReadEdge(double[,] face, Edge edge) + { + var result = new double[OutputSize]; + for (var i = 0; i < OutputSize; i++) + { + result[i] = edge switch + { + Edge.Top => face[0, i], + Edge.Bottom => face[OutputSize - 1, i], + Edge.Left => face[i, 0], + _ => face[i, OutputSize - 1], + }; + } + return result; + } + + [Fact] + public void HighestLevelIsContinuousAcrossFaces() + { + var game = new TestRadiancePrefilteringGgxFaceContinuity(); + RunGameTest(game); + + // A flat level has no seam to measure, so it would satisfy the ratio below for the wrong reason. + Assert.True(game.withinFaceVariation > 1.0, + $"Mip 0 varies by {game.withinFaceVariation:F2} between neighboring texels, so it carries no image. " + + "The filter returns a single averaged color when it runs at roughness 0."); + + Assert.True(game.seamRatio < 0.5, + $"Mip 0 steps across cube face edges by {game.seamRatio:F2} times the variation inside a face. " + + "The levels the filter produces stay near 0.3, so this level does not match its neighbors."); + } +} diff --git a/sources/engine/Stride.Rendering/Rendering/ComputeEffect/GGXPrefiltering/CubemapFaceResampleShader.sdsl b/sources/engine/Stride.Rendering/Rendering/ComputeEffect/GGXPrefiltering/CubemapFaceResampleShader.sdsl new file mode 100644 index 0000000000..60a04c36c0 --- /dev/null +++ b/sources/engine/Stride.Rendering/Rendering/ComputeEffect/GGXPrefiltering/CubemapFaceResampleShader.sdsl @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +namespace Stride.Rendering.Images +{ + /// + /// Shader writing one face of a cubemap from a given mipmap level of another cubemap. + /// It reads by direction, so a texel on a face border takes the neighboring face into account instead + /// of clamping inside its own face. + /// + shader CubemapFaceResampleShader : Math, ImageEffectShader + { + TextureCube RadianceMap; + + // The mipmap level to read + stage float MipLevel; + + // The face being written + stage int Face; + + override stage float4 Shading() + { + float3 direction = normalize(CubemapUtils.ConvertTexcoordsNoFlip(streams.TexCoord, Face)); + + return RadianceMap.SampleLevel(Texturing.LinearSampler, direction, MipLevel); + } + }; +} diff --git a/sources/engine/Stride.Rendering/Rendering/ComputeEffect/GGXPrefiltering/RadiancePrefilteringGGXNoCompute.cs b/sources/engine/Stride.Rendering/Rendering/ComputeEffect/GGXPrefiltering/RadiancePrefilteringGGXNoCompute.cs index fcf857021d..2166acd684 100644 --- a/sources/engine/Stride.Rendering/Rendering/ComputeEffect/GGXPrefiltering/RadiancePrefilteringGGXNoCompute.cs +++ b/sources/engine/Stride.Rendering/Rendering/ComputeEffect/GGXPrefiltering/RadiancePrefilteringGGXNoCompute.cs @@ -38,7 +38,7 @@ public class RadiancePrefilteringGGXNoCompute : DrawEffect /// public int MipmapGenerationCount { get; set; } - private ImageScaler scaler; + private readonly ImageEffectShader resampleShader; /// /// Create a new instance of the class. @@ -48,11 +48,9 @@ public RadiancePrefilteringGGXNoCompute(RenderContext context) : base(context, "RadiancePrefilteringGGX") { shader = new ImageEffectShader("RadiancePrefilteringGGXNoComputeEffect"); + resampleShader = new ImageEffectShader("CubemapFaceResampleShader"); DoNotFilterHighestLevel = true; samplingsCount = 1024; - - scaler = new ImageScaler(SamplingPattern.Expanded); - scaler.Initialize(context); } /// @@ -107,13 +105,17 @@ protected override void DrawCore(RenderDrawContext context) var outputSubresource = 0 + faceIndex * output.MipLevelCount; context.CommandList.CopyRegion(input, inputSubresource, null, output, outputSubresource); } - else // otherwise rescale the closest mipmap + else // otherwise resample the closest mipmap { + // Reading by direction rather than from a single face view keeps the texels on a + // face border continuous with the neighboring face. The filter cannot serve this + // level, because at roughness 0 it collapses to the smallest mipmap. var inputMipmapLevel = Math.Min(inputLevel, input.MipLevelCount - 1); - using var inputView = input.ToTextureView(ViewType.Single, faceIndex, inputMipmapLevel); - scaler.SetInput(inputView); - scaler.SetOutput(outputView); - scaler.Draw(context); + resampleShader.Parameters.Set(CubemapFaceResampleShaderKeys.RadianceMap, input); + resampleShader.Parameters.Set(CubemapFaceResampleShaderKeys.MipLevel, inputMipmapLevel); + resampleShader.Parameters.Set(CubemapFaceResampleShaderKeys.Face, faceIndex); + resampleShader.SetOutput(outputView); + resampleShader.Draw(context); } } else