12
\$\begingroup\$

I'm working with GLSL and trying to implement flat shading on a 3D model (rather than smooth shading). To illustrate what I mean, here are two screenshots of cubes in Blender. Here's one with flat shading.

enter image description here

And here's the same cube with smooth shading.

enter image description here

I understand the theory behind this kind of shading. Each face on the cube (six total) has a normal facing away from the surface. Each vertex (eight total) has a normal computed by summing together face normals, then normalizing to unit length. This results in each vertex normal pointing directly away from the center of the cube.

Smooth shading can be implemented in basically two ways. In the first, color is computed per-vertex (using light direction and normal), then fragment color is interpolated among all vertices. In the second, normals themselves are interpolated, then color is computed per-fragment (using the same lighting calculations).

Here are my current GLSL shaders to implement the first option (there's no specular lighting yet, but it gets the idea across with ambient and diffuse). Vertex shader first.

in vec3 vPosition;
in vec3 vNormal;

out vec4 fColor;

uniform mat4 mvp;
uniform vec3 aColor;
uniform vec3 lDirection;
uniform vec3 lColor;

void main()
{
    gl_Position = mvp * vec4(vPosition, 1);

    vec4 ambient = vec4(aColor, 1);
    vec4 diffuse = vec4(max(dot(lDirection, -vNormal), 0) * lColor, 1);

    fColor = ambient + diffuse;
}

Then fragment.

in vec4 fColor;

void main()
{
    gl_FragColor = fColor;
}

So that works fine for smoothed shading, with fragment values interpolated among vertices. I'll also point out that I'm using buffers and index arrays for rendering.

For flat shading, each fragment on a face should instead use the same normal (such that every pixel on the surface has the same final color after lighting calculations). The problem is that I can't pass data to shaders per-face, but only per-vertex. Given this, I can think of three solutions.

  1. Pass four vertices per face, with each vertex storing the face normal. This would still technically be smooth shading, but done in such a way that every interpolated pixel will use the same color (making if effectively flat). This approach seems wrong because it would basically ruin my vertex buffer, since I'd have to pass 24 vertices (four per face) despite the cube only containing eight unique vertices.

  2. Use GLSL's flat mode (there's a flat keyword in GLSL). Using this approach, each fragment would only pull from a single "provoking vertex" rather than interpolating from all vertices on the face. This feels wrong because that I wouldn't actually be using the correct face normal. I also haven't been able to figure out the proper syntax for this style anyway. For the record, I'm aware of glShadeModel, but it's apparently deprecated.

  3. Average vertex normals per fragment rather than interpolating them. To me, this feels like exactly the correct solution, since every pixel on a face would use the same normal, with that normal computed by summing and normalizing vertex normals (similar to how vertex normals are computed from face normals to begin with).

From those options, #3 clearly feels like the correct solution, but I haven't had any luck in figuring out how. So that's my question.

How can I tell the fragment shader to use a normal averaged among all vertices, rather than interpolated?

\$\endgroup\$
3
  • 1
    \$\begingroup\$ #3 is incorrect, mathematically.; the correct face normal is very unlikely to equal the average of the vertex normals. That "average the vertex normals" approach definitely won't work in the case of a cube that's rendered as triangles, as presented here. \$\endgroup\$ Commented Mar 1, 2018 at 2:18
  • \$\begingroup\$ Oh, right. Good call. I've spent too long looking at this symmetric cube. \$\endgroup\$
    – Grimelios
    Commented Mar 1, 2018 at 2:40
  • \$\begingroup\$ It even won't work on your cube; you'll have two vertices on one side of the face, and one on the other side of the face. They totally won't cancel each other out properly unless you're actually rendering quads (which is deprecated in modern OpenGL). \$\endgroup\$ Commented Mar 1, 2018 at 2:48

3 Answers 3

8
\$\begingroup\$

You may have heard 3D modelers talk about "hard edges" or "sharp edges", which are roughly equivalent to what you're looking for here. When modelers create a hard edge, their software will internally utilize your first method: Pass four vertices per face, with each vertex storing the face normal.

This allows a modeler to determine which edges of the object should be "smooth" (with light and other values interpolated between faces) and which should be "sharp" (with light calculated separately for each face), without needing to reconfigure the render pipeline.

That's especially useful because the "sharpness" of an edge is usually a property of that model. If this is something you wanted to manage scene-wide, changes to shaders and such might be more appropriate.

\$\endgroup\$
3
  • \$\begingroup\$ That makes sense, I suppose, especially given the comment above that my "average normals" approach is wrong for a shape that isn't perfectly symmetric. \$\endgroup\$
    – Grimelios
    Commented Mar 1, 2018 at 2:41
  • \$\begingroup\$ @Grimelios I think it should be noted that in Blender the Split Edge modifier allows you to give better results when working with smooth+flat faces next to each other, but it does mean there will be additional data in your model on export. There is also an Edit Normals modifier that could help shape your normals to your liking. \$\endgroup\$
    – Sidar
    Commented Mar 1, 2018 at 6:07
  • \$\begingroup\$ I did eventually discover the Edge Split command in Blender and how to use it. You're right that it increases data, but given the responses, it clearly seems like the right choice. \$\endgroup\$
    – Grimelios
    Commented Mar 1, 2018 at 7:38
12
\$\begingroup\$

As @rutter says, your #1 option is the correct thing to do in the overwhelming majority of all cases. However, if you're really adamant that you want everything to be hard-edged, then you can do this trick:

In the vertex shader:

#version 330
out vec3 viewPosition;


void main()    
{
    (...)
    viewPosition = (worldToView * worldPosition).xyz;
}

In the fragment shader:

#version 330
in vec3 viewPosition;

void main()
{
    vec3 xTangent = dFdx( viewPosition );
    vec3 yTangent = dFdy( viewPosition );
    vec3 faceNormal = normalize( cross( xTangent, yTangent ) );
}

Here, we using dFdx and dFdy.

viewPosition tells us where we are in view-space, in the current pixel. dFdx(viewPosition) checks the value of viewPosition in pixels to the left and right of us, and tells us how that value is changing as we move left and right on the screen. dFdy does the same thing vertically. In effect, it's telling us view-space tangent vectors for the face, in two different directions on the screen.

Since we have two different tangent vectors, we can take their cross-product to get the face's normal (again, still in view-space). That's exactly what we need for our lighting calculations, so we can simply use that rather than passing the normal in via vertex attributes at all.

That is, we're rebuilding the face normal inside the fragment shader based solely upon where the geometry actually is. Under this approach, vertex normals can be excluded from the vertex buffer entirely (if you don't need them for anything else). The big downside of this technique is that it becomes impossible to ever have a smoothly curving surface, because we're using the actual faces, and the faces are always actually flat.

Note that the above example assumes GLSL 330; you may need to switch "out/in" values to "varying", if you're in a much earlier version, but the maths itself should work anywhere.

And just to repeat, @rutter's answer is actually the right thing to do, you almost definitely want to do that. I've only added this answer since it provides the correct way to reconstruct actual face normals, when that's needed.

\$\endgroup\$
1
  • \$\begingroup\$ Note for readers like me, who like to ignore crucial information: doing the calculations in view space is important actually. You can do them in world space, too, but this will come at a loss of precision, leading to ugly artifacts, especially when you get close to objects. If you have your normal view_mat, you can easily get world space normals from view space normals via transpose(view_mat) * view_space_normal. \$\endgroup\$ Commented May 25 at 13:20
3
\$\begingroup\$

The third option is to use flat interpolation specifier when passing attributes from the vertex shader to the fragment shader.

This will make the gpu take the attribute of only 1 vertex and feed it to the fragment shader for the triangle, as opposed to interpolating the output of all 3 vertices per triangle.

\$\endgroup\$

You must log in to answer this question.

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