0
$\begingroup$

Screenshot

Questions:

  1. Why does the wavy white line on the tennis ball texture seem to be at the wrong mip level, with blocky artifacts ? For example, at 'A' in the picture. The basketball and football textures are nice and smooth, even when they are smaller than the tennis ball.

  2. Why does the equatorial gradient between light and shade due to the overhead directional light source seem aliased in some cases, e.g. on the basketball at B1 and on the tennis ball at B2, but not in others, e.g. on the basketball at C1 and on the tennis ball at C2 ? Is MSAA able to detect such cases ? I thought it might be that MSAA only works on boundaries of triangles, and that this might explain the difference - however - these effects are invariant with respect to arbitrary rotations of the spheres about their centres.

Errors (which I will fix but which I suppose don't account for the above issues):

  1. I know about the swirly pattern visible e.g. at 'D' on the pole of the basketball - that was my mistake with assigning texture coordinates.

  2. I know about the sawtooth glitch e.g. at 'E1' on the basketball, and at 'E2' on the football (a spurious edge running North West to South East). This is my failure to assign correct normal vectors along the meridian of longitude that forms a seam at the left and right of the equirectangular texture for the approximate sphere.

I am using OpenGL 4.4, plus the anistropic filtering extension GL_EXT_texture_filter_anisotropic. My GPU is an integrated one by Intel. In the Intel Graphics HD Control Panel in Windows, for my program I have turned off CMAA, and I have set both MSAA and Anisotropic Filtering to "Use Application Default Settings". My screen is 1920 x 1080 pixels.

My scene has a white directional light overhead, and a red point light, both of which are calculated in the shader program, using Blinn-Phong shading.

EDIT Here is my vertex shader:

#version 440 core

uniform mat4 M_matrix;
uniform mat3 N_matrix;
uniform mat4 PVM_matrix;

uniform vec3 camera_position_wc;

// directional_light_direction_wc must be normalised before being sent to the shader program:
uniform vec3 directional_light_direction_wc;
uniform vec3 point_light_position_wc;


layout (location = 0) in vec3 v_position_mc;
layout (location = 1) in vec2 v_tex_coord;
//layout (location = 1) in vec3 v_normal_mc;
layout (location = 2) in vec3 v_normal_mc;


out vec2 v_tex_coord_pass_through;
out vec3 normalised_normal_wc;
out float directional_light_diffuse_cosine;
out vec3 camera_vector_wc;
out vec3 point_light_vector_wc;



void main()
{
    
    //// Pass through the texture coordinates:
    v_tex_coord_pass_through = v_tex_coord;
    // Scale the texture coordinates by the norm of the first column of the model matrix, which I believe should be equal
    // to model_parameters.scale[0], then translate the origin to the centre of the texture, then pass the result through:
    //v_tex_coord_pass_through = length(M_matrix[0])*v_tex_coord + 0.5f;
    //v_tex_coord_pass_through = length(M_matrix[0])*v_tex_coord;
    
    // Here we assume that v_normal_mc is nonzero, and N_matrix nondegenerate, so that their product is nonzero.
    // In this case, we avoid the risk that normalize tries to divide by zero:
    normalised_normal_wc = normalize(N_matrix*v_normal_mc);

    // Note that "directional_light_diffuse_cosine" may be negative. In fact it is positive iff the directional light
    // direction is on the outward-facing side of the triangle.
    // We don't clamp it at zero here, because we want the fragment shader to be able to use the directional light,
    // even when the triangle is facing away from the camera.
    // This is a precaution, in case the programmer chooses *not* to cull back faces.
    // Because we are doing flat shading, and because the directional light direction is constant over the whole scene,
    // this cosine can be computed once per triangle vertex, instead of once per fragment.
    // Unfortunately, the fragment shader will still do unnecessary work, to interpolate this constant over the triangle,
    // but this seems to be an unavoidable result of the design of the OpenGL rendering pipeline.
    directional_light_diffuse_cosine = dot(directional_light_direction_wc, normalised_normal_wc);

    vec3 vertex_position_wc = vec3(M_matrix*vec4(v_position_mc, 1.0f));
    camera_vector_wc = camera_position_wc - vertex_position_wc;
    point_light_vector_wc = point_light_position_wc - vertex_position_wc;
    gl_Position = PVM_matrix*vec4(v_position_mc, 1.0f);

}

and my fragment shader:

#version 440 core

uniform sampler2D tex;

uniform vec3 model_colour;
uniform vec3 model_glow_colour;
uniform float model_specular_exponent;
uniform float model_specular_strength;

uniform vec3 ambient_light_colour;
// directional_light_direction_wc must be normalised before being sent to the shader program:
uniform vec3 directional_light_direction_wc;
uniform vec3 directional_light_colour;
uniform vec3 point_light_colour;
uniform float point_light_fake_radius_squared;

uniform float exposure;


in vec2 v_tex_coord_pass_through;
in vec3 normalised_normal_wc;
in float directional_light_diffuse_cosine;
in vec3 camera_vector_wc;
in vec3 point_light_vector_wc;


out vec4 frag_colour;


void main()
{
    
    float directional_light_diffuse_coefficient;
    float directional_light_specular_coefficient;
    float point_light_diffuse_coefficient;
    float point_light_specular_coefficient;
    const float reciprocal_gamma = 0.4545f;
    
    // Interpolate the camera vector (automatically), and then normalise it:
    // I believe that the various coordinate transforms must necessarily be set up in such a way that the camera is strictly outside the viewing frustum.
    // If so, then no fragments inside the frustum can coincide with the camera position, and therefore the camera_vector_wc must be non-zero.
    // If so, then it is safe to normalise this vector using the built-in "normalize" function:
    // Since the camera vector might be zero, we will use out own home-made version of "normalize".
    // If all the light sources that can produce specular reflections are on the opposite side of the triangle, then the following computation of the camera direction
    // is a wasted effort. However, let's keep the flow simple by just computing it in any case:
    vec3 camera_direction_wc = normalize(camera_vector_wc);
    

    // The point of this "rectification" is to illuminate the back faces (just in case the programmer
    // has chosen not to enable back-face culling) in the same way as the front faces.
    // A similar result can probably be achieved by using the built-in GLSL function "faceforward"
    // somewhere in the fragment or vertex shader, but for now let's just use this home-made solution.
    // By converting the Boolean gl_FrontFacing to a float equal to 1.0f or -1.0f, we avoid an "if" statement.
    // Branching statements are said to be expensive, although I have read that in modern GPUs, they are less of a problem than they used to be:
    float front_facing;
    vec3 normalised_normal_wc_rectified;
    float directional_light_diffuse_cosine_rectified;
    
    front_facing = 2.0f*float(gl_FrontFacing) - 1.0f;
    normalised_normal_wc_rectified = front_facing*normalised_normal_wc;
    directional_light_diffuse_cosine_rectified = front_facing*directional_light_diffuse_cosine;

    // "directional_light_diffuse_cosine_rectified" is positive iff the directional light direction is on the same side of
    // the triangle as the camera is, regardless of whether that is the front or back face of the triangle.
    // If it is negative, then as seen by the camera, the directional light does not illuminate the triangle
    // at all, and we should clamp it at zero:
    directional_light_diffuse_coefficient = max(directional_light_diffuse_cosine_rectified, 0.0f);
    
    // Note that "normalised_normal_wc_rectified" is now on the same side of the triangle as is the camera.
    // The "half_vector_wc" could be also on the same side of the triangle as is the camera, *even* when the
    // directional light direction is *not*. In that situation, the directional light does not illuminate the
    // triangle, as seen by the camera, so its specular contribution should be set to zero:
    if (directional_light_diffuse_coefficient == 0.0f)
    {
        directional_light_specular_coefficient = 0.0f;
    }
    else
    {
        // Having reached this point, we know that the directional light direction and the camera are both on the same
        // side of the triangle (which may be the front or the back side). Therefore the camera will see some specular
        // contribution from the directional light to the triangle surface.
        vec3 directional_light_half_vector_wc = normalize(directional_light_direction_wc + camera_direction_wc);
        // Mathematically, the half vector is now guaranteed to be on the same side of the triangle as is the rectified
        // normalised normal vector to the triangle, so the dot product of these two vectors must be non-negative.
        // However, I suppose that numerical errors might cause it to be a very small but negative number.
        // Were this to happen, we would find ourselves raising that negative number to a power with the "pow" function, whose
        // behaviour in that case is undefined (because to do that properly requires complex numbers, as in the square roots
        // of negative 1, etc.) This may produce an error.
        // Therefore, for safety's sake, let's clamp that dot product at zero, using the "max" function:
        directional_light_specular_coefficient = pow(max(dot(directional_light_half_vector_wc, normalised_normal_wc_rectified), 0.0f), model_specular_exponent);
    }

    // Interpolate the vector from the fragment to the point light position, and normalise it.
    // However, in case the interpolated vector is zero, use "length" for this, rather than "normalize":
    float point_light_distance = length(point_light_vector_wc);
    if (point_light_distance == 0.0f)
    {
        // The position of the point light source coincides with the position of the current fragment.
        // Set the contributions of the point light source to the colour of the fragment to zero:
        point_light_diffuse_coefficient = 0.0f;
        point_light_specular_coefficient = 0.0f;
    }
    else
    {
        vec3 point_light_direction_wc = point_light_vector_wc/point_light_distance;
        float point_light_diffuse_cosine_rectified = dot(point_light_direction_wc, normalised_normal_wc_rectified);
        point_light_diffuse_coefficient = max(point_light_diffuse_cosine_rectified, 0.0f);
                
        if (point_light_diffuse_coefficient == 0.0f)
        {
            point_light_specular_coefficient = 0.0f;
        }
        else
        {
            // The intensity (I'm probably not using the right word from physics) of the radiation from the point light decreases inversely as the square
            // of the distance to the point light, because the energy is spread over the surface of a sphere centred on the point light, and the surface area
            // of the sphere increases as the square of its radius. However, such a model has the intensity asymptotic to infinity, as the distance tends to zero !
            // This unphysical behaviour arises, if I understand correctly, from the unphysical notion of an infinitessimally small source of energy.
            // In this video, Prof. Cem Yuksel explains the derivation of a good alternative attenuation function that solves this problem:
            //
            // https://youtu.be/wzIcjzKQ2BE
            //
            // That is the formula that we use here. The parameter "point_light_fake_radius_squared" represents the square of the radius of a spherical light,
            // or at other points in the approximation, a disc light orthogonal to the direction from the fragment to the point light, that approximates the
            // point light. Note that this is *not* intended to approximate actual spherical lights, nor disk lights.
            // The squared radius is a uniform variable, so that the user can control the radius in real time, using the keyboard.
            // We can, if we like, add a sphere or approximate sphere to our scene, of that radius, to represent the point light source:
            float point_light_attenuation = 2.0f*(1.0f - point_light_distance/sqrt(point_light_distance*point_light_distance + point_light_fake_radius_squared))/(point_light_fake_radius_squared);
            point_light_diffuse_coefficient = point_light_diffuse_coefficient*point_light_attenuation;
            
            // Having reached this point, we know that the point light direction and the camera are both on the same
            // side of the triangle (which may be the front or the back side). Therefore the camera will see some specular
            // contribution from the point light to the triangle surface.
            vec3 point_light_half_vector_wc = normalize(point_light_direction_wc + camera_direction_wc);
            // Mathematically, the half vector is now guaranteed to be on the same side of the triangle as is the rectified
            // normalised normal vector to the triangle, so the dot product of these two vectors must be non-negative.
            // However, I suppose that numerical errors might cause it to be a very small but negative number.
            // Were this to happen, we would find ourselves raising that negative number to a power with the "pow" function, whose
            // behaviour in that case is undefined (because to do that properly requires complex numbers, as in the square roots
            // of negative 1, etc.) This may produce an error.
            // Therefore, for safety's sake, let's clamp that dot product at zero, using the "max" function:
            point_light_specular_coefficient = pow(max(dot(point_light_half_vector_wc, normalised_normal_wc_rectified), 0.0f), model_specular_exponent);
            point_light_specular_coefficient = point_light_specular_coefficient*point_light_attenuation;
        }
        
    }
    

    //vec3 ambient_and_diffuse_colour = (ambient_light_colour + directional_light_diffuse_coefficient*directional_light_colour + point_light_diffuse_coefficient*point_light_colour)*model_colour;
    vec3 ambient_and_diffuse_colour = (ambient_light_colour + directional_light_diffuse_coefficient*directional_light_colour + point_light_diffuse_coefficient*point_light_colour)*(texture(tex, v_tex_coord_pass_through)).rgb;

    // Unlike the ambient and diffuse light, the specular light is *not* modulated by the colour of the triangle:
    vec3 specular_colour = model_specular_strength*(directional_light_specular_coefficient*directional_light_colour + point_light_specular_coefficient*point_light_colour);

    // Instead of clamping the fragment colour above at 1, as we used to, we will use a High Dynamic Range (HDR), in which we map the interval [0, +infinity)
    // into the interval [0, 1] by an *injective* function, thereby preserving all the information about contrasting colours. This also allows us to set the
    // colour channels for the colours of the lights to values greater than 1.0f, if we like. In this way, we can more accurately represent the relative
    // brightness of, say, the sun and an LED light.
    // However, colours of materials should still have their channels between 0.0f and 1.0f, because they represent *proportions* of light to be reflected.
    // The injective map that we'll use has a parameter called "exposure", a bit like the exposure values used by photographers.
    // For now we will hard code it here to have some value that seems to work well, but in future, let's make it a uniform variable, so that it can be
    // controlled from within the c program. It could even be adjusted interactively by the user, during the animation loop.
    // Although it seems to work as intended, when we take the min of a vec3 and a float, i.e. the float seems to be implicitly converted into a vec3 whose
    // components are all equal to that float, yet trying the same thing in the pow function instead seems to cause an error. Therefore, to be on the safe
    // side, and avoid as many bugs as possible, let's always be explicit about such things:
    //frag_colour.rgb = min(ambient_and_diffuse_colour + specular_colour, vec3(1.0f));
    frag_colour.rgb = vec3(1.0f) - exp(-exposure*(ambient_and_diffuse_colour + specular_colour + model_glow_colour));
    //frag_colour.rgb = texture(tex, v_tex_coord_pass_through).rgb;
    frag_colour = vec4(pow(frag_colour.rgb, vec3(reciprocal_gamma)), 1.0f);

}

The 3 ball textures (which are 2048 x 1024) are by Paul Bourke. http://paulbourke.net/geometry/spherical/ The wallpaper texture in the background is from wallpaper flare. https://www.wallpaperflare.com/

EDIT Here is the tennis ball texture: tennis ball texture

My process for generating textures:

  1. Open the image in MS Paint.
  2. In the case of the wallpaper texture, crop out a 4096 x 4096 square.
  3. Save the file as a .bmp.
  4. My C code opens and reads the bitmap file, and makes it into a texture with internal format GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM.
  5. This is then written to the hard drive so that in future, the compressed version can be loadly directly instead of having to load the .bmp version and compress it.
  6. Set the MAG filter to LINEAR, and the MIN filter to LINEAR_MIPMAP_LINEAR.
  7. Ask for anistropic filtering using

glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, anisotropic_sample_limit);,

where the anisotropic_sample_limit was determined by passing GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT to glGetFloatv. In my case, it is 16.0.

  1. Ask OpenGL to generate the mipmaps using glGenerateMipMap.

For antialising, I followed Joey de Vries learnopengl tutorial https://learnopengl.com/Advanced-OpenGL/Anti-Aliasing on how to use MSAA. I am not using GLFW nor GLEW. Instead I am using glad https://glad.dav1d.de/ and I am using the Windows API directly in my C code (as far as I can tell). I tried but failed to persuade OpenGL / Windows to give me a multisampled default framebuffer. Instead I went with "Off-screen MSAA". My antialising process is:

  1. Make a framebuffer and attach to it a multisampled colour buffer made like this (where I have set number_of_samples to 4):

glRenderbufferStorageMultisample(GL_RENDERBUFFER, number_of_samples, GL_RGBA8, window_width_pixels, window_height_pixels);

and a multisampled DEPTH / STENCIL buffer made like this:

glRenderbufferStorageMultisample(GL_RENDERBUFFER, number_of_samples, GL_DEPTH24_STENCIL8, window_width_pixels, window_height_pixels);

  1. Call glEnable(GL_MULTISAMPLE);
  2. Before the animation loop, bind this framebuffer for reading.
  3. For each frame of the animation, bind that framebuffer for drawing, then draw on it (my shader program has no explicit antialising).
  4. Then bind the default framebuffer for drawing using glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
  5. Blit my framebuffer to the default framebuffer using

glBlitFramebuffer(0, 0, window_width_pixels, window_height_pixels, 0, 0, window_width_pixels, window_height_pixels, GL_COLOR_BUFFER_BIT, GL_NEAREST);

  1. Swap the buffers, and begin the animation loop again.

I have tried the code on a different laptop, with a 4K screen and an Nvidia GPU as well as an integrated Intel one. With the integrated GPU on that laptop, both issues (the blockiness of the wavy line on the tennis ball, and the inconsistent antialiasing of the equatorial gradient on the balls) persist. With the Nvidia GPU, the wavy line on the tennis ball is perfectly correct, but the other problem persists.

Idea:

Is the problem with the inconsistent antialiasing of the equatorial gradient on the balls, due to the overhead directional light, dependent on the shininess of the balls ? Perhaps those that have a higher Blinn-Phong specular exponent and strength (which simply multiplies the specular contribution, and which I have set to be proportional to the specular exponent) have smoother gradients, because the function $\max(\cos^n \theta, 0)$ is smooth at 90 degrees, whereas the function $\max(\cos \theta, 0)$ has a corner (fails to be differentiable) at 90 degrees ?

$\endgroup$
6
  • 2
    $\begingroup$ Those are some big tennis balls, what is the resolution of the texture? $\endgroup$
    – pmw1234
    Commented Jan 16, 2023 at 13:30
  • $\begingroup$ Haha yes ! My to-do list includes making all the balls the correct size ! All 3 equirectangular textures are 2048 x 1024. I don't know whether you'd call that 1K or 2K ?! $\endgroup$
    – Simon
    Commented Jan 16, 2023 at 13:32
  • 1
    $\begingroup$ @Simon: "I don't know whether you'd call that 1K or 2K ?!" The "K" moniker for televisions is not what actual graphics people use. When we ask for resolution, we mean the actual width/height, not meaningless buzzwords like "2K" or "4K". More importantly, the resolution of the texture in question isn't as important as looking at the actual texture. Is it blocky? $\endgroup$ Commented Jan 16, 2023 at 16:00
  • 1
    $\begingroup$ "Why does the equatorial gradient between light and shade due to the overhead directional light source seem aliased in some cases" You're probably doing your lighting wrong, but since we don't know how you're computing your lighting, we can't say more. $\endgroup$ Commented Jan 16, 2023 at 16:02
  • 1
    $\begingroup$ @Simon: "which OpenGL then compresses" ... that's not wise if you care about the quality of the resulting compressed data. This goes doubly so for complex schemes like BPTC. Use an off-line tool to pre-compress this stuff. $\endgroup$ Commented Jan 16, 2023 at 20:35

1 Answer 1

1
$\begingroup$

Why does the wavy white line on the tennis ball texture seem to be at the wrong mip level, with blocky artifacts ?

I could be wrong, but those blocky artifacts look like compression artifacts, not aliasing artifacts. In particular, notice that the white center of the line appears smoother and of a more consistent width than the gray border. Try drawing the texture undistorted (i.e. on a quad) at different scales and with explicitly selected mip levels, to see if your mipmaps are correct and your texture compression is executing correctly.

Why does the equatorial gradient between light and shade due to the overhead directional light source seem aliased in some cases, … Is MSAA able to detect such cases ? I thought it might be that MSAA only works on boundaries of triangles, …

You are correct that MSAA only affects boundaries. Your fragment shader is run only for each pixel of the output (within the bounds of the triangle drawn), not for each MSAA sample. (That's the entire difference between MSAA and SSAA.)

So, any time you have a “corner” in the output of your fragment shader, you will see it as an aliased edge. You can see the same effect on the tennis ball in the bottom right, and just a little bit in the tennis ball on the center right (whose equator is almost a horizontal line but not quite).

The other balls do not display the same effect because your lighting is not producing a hard corner in those cases — formally, the spatial frequency of the image content is low enough to not produce noticeable aliasing.

You can solve this problem by modifying your shader to “round over” that hard corner and produce a gradient instead — conventional antialiasing is essentially making a 1-pixel-wide gradient, so as long as the lighting forms a gradient at least that wide, it will look “antialiased”.

(I'm not well-versed in lighting model math to recommend a specific modification.)

$\endgroup$
5
  • 1
    $\begingroup$ "look like compression artifacts" Note that the OP says (in comments) that he's uploading uncompressed data and telling OpenGL to compress it with BPTC. That's... generally not wise if you care about the quality of the output of that process. $\endgroup$ Commented Jan 16, 2023 at 20:34
  • $\begingroup$ Thank you very much @Kevin Reid and Nicol Bolas. I am sure Kevin is right, that these are compression artifacts. Nicol's suggestion to use an offline tool is very helpful. Evidently this Nvidia GPU does a better job of compressing than does ether of these Intel ones, but offline is clearly a better idea than hoping that the GPU is nice. $\endgroup$
    – Simon
    Commented Jan 16, 2023 at 21:00
  • $\begingroup$ Regarding the equatorial gradient: Strangely the Nvidia GPU did eventually smooth it, when I increased the number of MSAA samples to 16, 32 or 64. Even at 16 (the maximum it could handle) the Intel GPU continued not to smooth those gradients, agreeing with Kevin. I checked the Blinn-Phong specular highlight maths and found that the specular contribution does indeed have a discontinuity (from strictly positive to zero) where the light "sinks below the horizon" of the triangle. Large specular exponents reduce the gap to practically 0, but low ones leave it noticeable. $\endgroup$
    – Simon
    Commented Jan 16, 2023 at 21:03
  • $\begingroup$ Strangely (to me) I noticed that commenting out the line glEnable(GL_MULTISAMPLE); seemed not to make any difference ! Perhaps that is a legacy thing, that doesn't affect off-screen multisampled framebuffers... $\endgroup$
    – Simon
    Commented Jan 16, 2023 at 21:11
  • $\begingroup$ Actually I have just remembered from Joey de Vries' learnopengl.com, that he says that GL_MULTISAMPLE is usually enabled by default these days. That would answer my previous comment. $\endgroup$
    – Simon
    Commented Jan 16, 2023 at 23:11

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.