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