Skip to content

Prefiltered radiance mip 0 rescales each cube face on its own, so face edges lose their neighbors #3334

Description

@sasvdw

Release Type: GitHub (built from source)

Version: master (81b17f0e2)

Platform(s): All. This is backend independent.

Describe the bug

RadiancePrefilteringGGXNoCompute fills mip 0 of the prefiltered cubemap from the source when
DoNotFilterHighestLevel is set. A straight copy serves that only while the sizes, the mip count and the
format all agree (RadiancePrefilteringGGXNoCompute.cs:103). Everything else falls to ImageScaler over a
single face view (:110-117). That rescale clamps at the face border, so the border texels never take the
neighboring face into account and mip 0 stops being continuous around every face edge.

How it is reached

SkyboxGenerator builds the output at SpecularCubeMapSize, which defaults to 64
(SkyboxGenerator.cs:144), and fixes the filtering format at :149:

var filteringTextureFormat = skyboxTexture.Format.IsHDR ? skyboxTexture.Format : PixelFormat.R8G8B8A8_UNorm;

A low dynamic range skybox therefore compiles to a block format while the filtering format stays
R8G8B8A8_UNorm. The two never match, so the rescale runs. TextureAsset.IsCompressed defaults to true
(TextureAsset.cs:83), which makes this the ordinary case rather than an unusual one.

High dynamic range sources avoid it. The texture compiler never compresses them, because
TextureHelper.cs:295-299 keeps the input format and carries a TODO about BC6H. So an HDR skybox stays at
R16G16B16A16_Float, matches the filtering format and takes the copy.

Two other ways in, both ordinary. A skybox texture with GenerateMipmaps: false and a source larger than
SpecularCubeMapSize fails the mip test. A source smaller than SpecularCubeMapSize fails the width test.

Every skybox texture in this repository sets IsCompressed: false by hand, against the default. Three out
of three, counting the two templates and the one in Stride.Graphics.Tests.10_0. That reads like people
have already stepped around this.

Measured

I ran the filter over the GraceCathedral cubemap into a 64 pixel output, which is the
SpecularCubeMapSize default, and changed only the output format, since that is what selects the branch.
For each result I measured the mean absolute luminance step across the 12 shared face edges, against the
step between neighboring texels inside a face.

output format mip branch seam within-face ratio
R16G16B16A16_Float 0 CopyRegion 0.77 123.66 0.01
R8G8B8A8_UNorm 0 ImageScaler 3.46 4.03 0.86
R8G8B8A8_UNorm 1 GGX filter 1.44 4.53 0.32

Read the last two rows together. They share a texture, a format and a content path. The level the rescale
produces steps by 0.86, and the level the filter produces directly below it steps by 0.32.

The effect is real but moderate. At 0.86 the step across a face edge is about the size of an ordinary step
between neighboring texels, so this softens the boundary rather than drawing a hard seam.

Expected behavior

Mip 0 stays continuous around face edges, like every other level.

Note for anybody fixing this

The obvious fix does not work, and it fails quietly. The filter cannot serve mip 0. At roughness 0 the GGX
distribution returns a zero probability density, so the solid angle goes to infinity and the sample mip
clamps to MipmapCount (RadiancePrefilteringGGXNoComputeShader.sdsl:41-44). Every texel then reads the
smallest mipmap and the whole level becomes one flat color. I measured a within-face variation of 0.00
that way. A continuity check passes on that output, because a flat level has no seam to find.

What works is to read the source by direction rather than through a single face view, so a border texel
crosses the edge the way the hardware samples a cubemap.

A second, opposite inconsistency

The compute path checks the size but not the format before CopyRegion
(RadiancePrefilteringGGX.cs:92). The no-compute path checks the format
(RadiancePrefilteringGGXNoCompute.cs:103). One of the two is wrong. The compute path also falls into the
filter when its check fails, which walks into the roughness 0 problem above.

Relationship to #3328

This is not the cause of the residual seam in #3328. Two measurements say so. The residual appears in the
compute path as well, which has no per-face structure. TestRadiancePrefilteringGgx never reaches this
branch, and never displays mip 0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions