Niels P. Wouters

Graphics Programmer


Seamless spherical mipmapping

5 January 2019


During my graduation project about atmospheric scattering I wanted to render planet earth seen from space. A simple solution is to render spherical geometry consisting of triangles and map a texture containing the colors of the planet. Enabling a more advanced texture filtering technique that requires mipmaps - either Trilinear or Anisotropic filtering - results in a visually unsatisfying seam over an entire longitude.

For the geometry I subdivided an icosahedron several times to generate a sphere with evenly spread triangles. The texture was retrieved from Nasa and the UV-coordinates are calculated per pixel using the commonly used cylindrical projection formula.

The following code snippet shows a simple planet rendering hlsl pixel shader.

Texture2D gTexture : register(t0);
SamplerState gSamplerState : register(s0);

static const float PI = 3.14159265f;

struct VS_OUT_PS_IN
{
    float4 mPosition : SV_POSITION;
    float3 mLocalPosition : LOCAL_POSITION;
};

float4 main(VS_OUT_PS_IN aIn) : SV_TARGET
{
    float3 p = normalize(aIn.mLocalPosition);

    float u = 0.5f + atan2(p.z, p.x) / (2.f * PI);
    float v = 0.5f - asin(p.y) / PI;

    return gTexture.Sample(gSamplerState, float2(u, v));
}

The images below show the spherical geometry as a subdivided icosphere, the UV-coordinates and point texture filtering respectively.

Nothing fancy here, right? Well, let's enable mipmaps and trilinear filtering and see what happens. I have enlarged a small area of the image for better visibility of the problem.

We want the texture on the sphere to wrap around nicely and a seam is obviously not what was intended. Because the seam is at the exact position as the texture wrap, I initially thought that the problem was caused by the sampler's address mode. However, the address mode was left unchanged at D3D11_TEXTURE_ADDRESS_CLAMP and the UV formulae's range of [0.0, 1.0] this could not possibly cause the issue. Inspecting the individual mips looking for baked borders also came back negative.

Next, I wanted to investigate the mip selection of the GPU. By sampling a texture with uniquely colored mips I was able to see exactly which mip level the GPU selected for each pixel. This left image below shows the individual mip of the texture and the right shows the result. Note that the smallest level is yellow and is being sampled exclusively on the seam.

It is important to know how triangle rasterization and mip selection on the GPU works in order to fully understand the problem we are having with the seam. Small blocks of the triangle are rasterized in parallel by the cores of the GPU. These blocks consist of 2x2 adjacent pixels and are performed in lockstep, meaning that the same instruction is executed on the individual datasets at the same time. The intermediate values based on the vertex attributes are interpolated linearly over the triangle's surface and will differ for each pixel. The GPU uses the slight difference of the UV-coordinates between the adjecent pixels, called gradients, in each block to calculate the footprint of the pixels in texture space. The most suitable mip is then automatically selected by the gpu. Note that for anisotropic filtering the gradients on the U and V axis are used independently.

Imagine that the texture wrap goes right through a block of 2x2 pixels where the U-coordinates of the left pixels are close to 1 and the U-coordinate of the right pixels are close to 0. The gradient of this block, and therefore the footprint on the texture, will be huge and thus the GPU will select the samllest mip to sample an approximation of the average texel values. When you look closely at the right image above you can actually notice that the seam is discontinous and consists of small blocks of 2x2 pixels.

The HLSL shader language exposes the ddx(x) and ddy(x) functions that can be used to retrieve the gradient of an intermediate value of the current pixel in both the x and y axis. Additionally, since version 4.0, the sample function Texture.SampleGrad(Sampler, UV, ddx, ddy) is exposed which explicitly requires the derivatives to be passed as parameters. Since the UV-coordinates are the result of function that takes the position, with continuous gradients, as input, I thought it must be possible to calculate the derivatives myself without the discontinuity around the seam. With a bit of Calculus I was able to find the derivative which is displayed below.

where

The following code snippet shows planet rendering hlsl pixel shader with fixed mip level selection.

Texture2D gTexture : register(t0);
SamplerState gSamplerState : register(s0);

static const float PI = 3.14159265f;

struct VS_OUT_PS_IN
{
    float4 mPosition : SV_POSITION;
    float3 mLocalPosition : LOCAL_POSITION;
};

float derivative_atan2_x(float aX, float aY)
{
    return -aY / (aX * aX + aY * aY);
}

float derivative_atan2_y(float aX, float aY)
{
    return aX / (aX * aX + aY * aY);
}

float calculate_u_dd(float3 aP, float3 aP_dd)
{
    return 0.5f / PI * (derivative_atan2_x(aP.x, aP.z) * aP_dd.x + derivative_atan2_y(aP.x, aP.z) * aP_dd.z);
}

float4 main(VS_OUT_PS_IN aIn) : SV_TARGET
{
    float3 p = normalize(aIn.mLocalPosition);

    float u = 0.5f + atan2(p.z, p.x) / (2.f * PI);
    float v = 0.5f - asin(p.y) / PI;

    float u_ddx = calculate_u_dd(p, ddx(p));
    float u_ddy = calculate_u_dd(p, ddy(p));

    float2 uv_ddx = float2(u_ddx, ddx(v));
    float2 uv_ddy = float2(u_ddy, ddy(v));

    return gTexture.SampleGrad(gSamplerState, float2(u, v), uv_ddx, uv_ddy);
}

The images below show the fixed, using the shader above, mip selection and trilinear filtering respectively.

This theory can be applied to resolve wrapping seams caused by other map projections by explicitly calculating the derivative of the mapping function in the pixelshader. The small demo below shows basic lighting and seamless anisotropic filtering on multiple textures.


← Older