1
\$\begingroup\$

Good morning, I am currently running into a UV interpolation issue with D3D12 that looks like this:

enter image description here

Is there something that needs to be set when initialising the pipeline so that the GPU does the correct interpolation or do I have to do it myself? I have seen several examples of textured 3D objects that looked fine and the associated code did not seem to do anything specific to compute the correct UVs manually, so I guess I just forgot to set a flag or something?

EDIT: here is some additional information (shaders are trimmed to the core):

// Input data layout:

D3D12_INPUT_ELEMENT_DESC mesh_layout[NUM_ELEMENTS_IN_MESH_LAYOUT] = {
    {"Vertex_Position",  0, DXGI_FORMAT_R32G32B32_FLOAT,    0, 0,                            D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,   0},
    {"Normal",           0, DXGI_FORMAT_R32G32B32_FLOAT,    0, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,   0},
    {"U_Axis",           0, DXGI_FORMAT_R32G32B32_FLOAT,    0, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,   0},
    {"V_Axis",           0, DXGI_FORMAT_R32G32B32_FLOAT,    0, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,   0},
    {"Uv_Coords",        0, DXGI_FORMAT_R32G32_FLOAT,       0, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,   0},
    
    {"Position",         0, DXGI_FORMAT_R32G32B32_FLOAT,    1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Scale",            0, DXGI_FORMAT_R32G32B32_FLOAT,    1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Orientation",      0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Colour",           0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Shadow_Map_Position", 0, DXGI_FORMAT_R32G32B32_FLOAT, 1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Shadow_Map_X_Axis",   0, DXGI_FORMAT_R32G32B32_FLOAT, 1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Shadow_Map_Y_Axis",   0, DXGI_FORMAT_R32G32B32_FLOAT, 1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Shadow_Map_Z_Axis",   0, DXGI_FORMAT_R32G32B32_FLOAT, 1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Shadow_Map_Uvs",   0, DXGI_FORMAT_R32G32_FLOAT,       1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
    {"Material_Index",   0, DXGI_FORMAT_R32_UINT,           1, D3D12_APPEND_ALIGNED_ELEMENT, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 0},
};

/*
The vertex buffer looks like this (only positions and UVs are represented here as this is what interests us):

{ // First triangle.
    { 1, -1, 0}, {1, 0},
    {-1,  1, 0}, {0, 1},
    {-1, -1, 0}, {0, 0},
},
{ // Second triangle.
    { 1, -1, 0}, {1, 0},
    { 1,  1, 0}, {1, 1},
    {-1,  1, 0}, {0, 1},
}
*/
// Vertex shader:

ConstantBuffer<Renderer_Info> renderer_info : register(b0, space2);
StructuredBuffer<Material> materials : register(t0, space2);


struct To_Pixel_Shader
{
    v4  position              : SV_Position;
    v4  colour                : Colour;
    v3  origin                : Origin;
    v3  position_3d           : Position_3d;
    v3  normal                : Normal;
    v2  uvs                   : Uv_Coords;
    v3  camera_view_direction : Camera_View_Direction;
    u32 material_index        : Material_Index;
    f32 scale                 : Uniform_Scale;
};

To_Pixel_Shader main(
    v3  vertex_position : Vertex_Position,
    v3  normal          : Normal,
    v3  u_axis          : U_Axis,
    v3  v_axis          : V_Axis,
    v2  uvs             : Uv_Coords,
    
    v3  position            : Position,
    v3  scale               : Scale,
    v4  orientation         : Orientation,
    v4  colour              : Colour,
    v3  shadow_map_position : Shadow_Map_Position,
    v3  shadow_map_x_axis   : Shadow_Map_X_Axis,
    v3  shadow_map_y_axis   : Shadow_Map_Y_Axis,
    v3  shadow_map_z_axis   : Shadow_Map_Z_Axis,
    v2  shadow_map_uvs      : Shadow_Map_Uvs,
    u32 material_index      : Material_Index
)
{
    To_Pixel_Shader result;
    
    v2  rt_dimensions = {renderer_info.rt_width, renderer_info.rt_height};
    f32 rt_ratio = rt_dimensions.y / rt_dimensions.x;
    
    // Calculate the vertex world position. START
    v4 orientation_inverse = quaternion_inverse_no_norm(orientation); // @ This could be given in the mesh instance along with the orientation.
    
    vertex_position *= scale;
    vertex_position  = rotate_point(orientation, vertex_position, orientation_inverse);
    vertex_position += position;
    
    result.position_3d = vertex_position;
    // Calculate the vertex world position. END
    
    // Calculate the vertex screen position. START
    v3 eye_to_vertex = vertex_position - renderer_info.camera_eye_position;
    result.camera_view_direction = eye_to_vertex; // This is normalised in the pixel shader.
    
    f32 vertex_dot_camera_z = -dot(eye_to_vertex, renderer_info.camera_z_direction);
    f32 distance_to_vertex = distance(renderer_info.camera_eye_position, vertex_position);
    
    f32 triangle_ratio = renderer_info.camera_eye_offset / vertex_dot_camera_z;
    
    f32 MAX_DEPTH_DISTANCE = 1000.0f;
    f32 NEAR_CLIP_PLANE    = 1;
    
    v3 vertex_position_on_screen = {
        dot(eye_to_vertex, renderer_info.camera_x_direction),
        dot(eye_to_vertex, renderer_info.camera_y_direction),
        (distance_to_vertex - NEAR_CLIP_PLANE) / MAX_DEPTH_DISTANCE
    };
    
    vertex_position_on_screen.xy *= triangle_ratio;
    vertex_position_on_screen.x  *= rt_ratio;
    
    ///////////////////////////////////////////////////////////////////////////////////////////
    // NOTE: @ for now we do not take vertices lying behind the near clip plane into account!!!
    ///////////////////////////////////////////////////////////////////////////////////////////
    
    result.screen_depth = vertex_position_on_screen.z;
    // Calculate the vertex screen position. END
    
    result.origin         = position;
    result.position       = v4(vertex_position_on_screen.xyz, 1);
    result.normal         = rotate_point(orientation, normal, orientation_inverse);
    result.uvs            = uvs;
    result.material_index = material_index;
    result.colour         = colour;
    result.scale          = scale.x;
    
    return result;
}
// Pixel shader:

Texture2D<v4> colour_atlas : register(t0, space3);
StructuredBuffer<Atlas_Texture> colour_textures : register(t1, space3);
ConstantBuffer<Renderer_Info> renderer_info : register(b0, space3);
StructuredBuffer<Material> materials : register(t2, space3);
Texture2D<f32> shadow_map_atlas : register(t3, space3);
sampler bilinear_sampler : register(s0);
sampler point_sampler    : register(s1);


v4 main(
    v4  position              : SV_Position,
    v4  colour                : Colour,
    v3  origin                : Origin,
    v3  position_3d           : Position_3d,
    v3  normal                : Normal,
    v2  uvs                   : Uv_Coords,
    v3  camera_view_direction : Camera_View_Direction,
    u32 material_index        : Material_Index,
    f32 scale                 : Uniform_Scale
) : SV_Target
{
    u32 real_material_index = material_index & 0x00ffFFff; // The top 8 bits are reserved for flags.

    Material material = materials[real_material_index];
    colour *= material.colour;
    
    if(material.flags & TEXTURED)
    {
        Atlas_Texture t = colour_textures[material.texture_index];
        v2 sample_uvs = uvs * t.uv_ratio + t.uv_offset_in_atlas;
        colour *= colour_atlas.Sample(bilinear_sampler, sample_uvs);
    }
    
    return linear_to_srgb(colour);
}
\$\endgroup\$
0

1 Answer 1

2
\$\begingroup\$

You've lost perspective correctness due to the way you've written your vertex shader.

Ordinarily, we'll transform our vertex from view space to clip space by multiplying it by a projection matrix that looks something a bit like... (depending on your conventions)

$$\begin{bmatrix} a \cdot \cot \frac {fov} 2 & 0 & 0 & 0\\ 0 & \cot \frac {fov} 2 & 0 & 0\\ 0 & 0 & \frac f {f - n} & - \frac {f\cdot n} {f - n}\\ 0 & 0 & 1 & 0 \end{bmatrix} \begin{bmatrix} x\\y\\z\\1 \end{bmatrix} = \begin{bmatrix} x\cdot a\cdot \cot \frac {fov} 2\\ y\cdot \cot \frac {fov} 2\\ \frac {f \cdot z - f \cdot n} {f - n}\\ z \end{bmatrix} $$

You'll notice that this puts the depth of the vertex into the w component.

After this shader has run, the next stage of the graphics pipeline divides the xyz by w (depth), creating the flared shape of the perspective frustum, and ensuring the left/right/top/bottom clipping planes map to x = ±1 and y = ±1 respectively, and the near to far depth range maps to the interval \$z \in [0, 1]\$ (we call this normalized device coordinates)

Crucially, even after z has been mapped to NDC like this, w still contains the original depth information, which can be used to ensure we get perspective-correct interpolation of all vertex attributes, including texture coordinates.

But in your shader, you've manually performed the conversion to NDC yourself, and output just 1.0 as w. That makes the division by w in the next stage a no-op, so you get exactly the on-screen vertex coordinates that you set, no additional perspective adjustment.

Wait... no additional perspective adjustment!?

Yep. To the rest of the graphics pipeline, this polygon appears to be sitting flat on the z=1 plane, facing the camera directly. So it can't detect any tilting or foreshortening that would demand additional perspective correction in the interpolation of vertex attributes. You've destroyed that information before the interpolator stage of the graphics pipeline can use it.

The solution: Don't do your own perspective divide in the vertex shader. Output depth in the w component. That's enough to get correct interpolation of vertex attributes like UV coordinates automatically.

If you need to get a screen position in the fragment shader, be sure to do your perspective divide there, on a per-fragment basis. That way the nonlinearity of the perspective projection is retained. Dividing in the vertex shader will give you linear interpolation of screen position over the triangle in 3D space, which will not match the correct interpolation in the 2D space of the triangle's pixels. (Or you can use an input with the SV_Position semantic to get a correctly computed position on screen automatically)

\$\endgroup\$
1
  • \$\begingroup\$ All right, that makes sense : ) Thank you very much for your time and explanation! \$\endgroup\$ Sep 24 at 8:44

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .