Questions:
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.
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):
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.
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:
My process for generating textures:
- Open the image in MS Paint.
- In the case of the wallpaper texture, crop out a 4096 x 4096 square.
- Save the file as a .bmp.
- My C code opens and reads the bitmap file, and makes it into a texture with internal format
GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM
. - 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.
- Set the MAG filter to
LINEAR
, and the MIN filter toLINEAR_MIPMAP_LINEAR
. - 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.
- 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:
- 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);
- Call
glEnable(GL_MULTISAMPLE);
- Before the animation loop, bind this framebuffer for reading.
- For each frame of the animation, bind that framebuffer for drawing, then draw on it (my shader program has no explicit antialising).
- Then bind the default framebuffer for drawing using
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
- 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);
- 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 ?