Meshes: vertices, faces and normals
Store a solid as a list of corners and a list of triangles, learn why the order of a triangle’s corners matters, and see how face and vertex normals are worked out.
Vertices and indices
// The 8 corners of a 4 x 3 x 2.5 m box, each stored oncevar v = new V3[]{ new(0, 0, 0), new(4, 0, 0), new(4, 3, 0), new(0, 3, 0), new(0, 0, 2.5), new(4, 0, 2.5), new(4, 3, 2.5), new(0, 3, 2.5)}; // 12 triangles, each listed counter-clockwise seen from outsidevar f = new (int A, int B, int C)[]{ (0, 2, 1), (0, 3, 2), // floor (seen from below) (4, 5, 6), (4, 6, 7), // roof (0, 1, 5), (0, 5, 4), // front, y = 0 (1, 2, 6), (1, 6, 5), // right, x = 4 (2, 3, 7), (2, 7, 6), // back, y = 3 (3, 0, 4), (3, 4, 7) // left, x = 0}; var edges = new HashSet<(int, int)>();double area = 0;foreach (var (a, b, c) in f){ area += V3.Cross(v[b] - v[a], v[c] - v[a]).Length / 2; foreach (var (p, q) in new[] { (a, b), (b, c), (c, a) }) edges.Add((Math.Min(p, q), Math.Max(p, q)));} Console.WriteLine($"V = {v.Length}, E = {edges.Count}, F = {f.Length}, V - E + F = {v.Length - edges.Count + f.Length}");Console.WriteLine($"surface area = {area} m2");Console.WriteLine($"memory: {v.Length * 24 + f.Length * 12} bytes indexed, {f.Length * 3 * 24} bytes as separate triangles");Console.WriteLine($"normals: floor {Normal(0)}, roof {Normal(2)}, front {Normal(4)}, right {Normal(6)}"); V3 Normal(int i) { var (a, b, c) = f[i]; return V3.Cross(v[b] - v[a], v[c] - v[a]).Normalized(); } readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}V = 8, E = 18, F = 12, V - E + F = 2 surface area = 59 m2 memory: 336 bytes indexed, 864 bytes as separate triangles normals: floor (0, 0, -1), roof (0, 0, 1), front (0, -1, 0), right (1, 0, 0)
Winding: which way a triangle faces
Drawing: renderers skip triangles that face away from the camera (back-face culling), so the flipped triangle vanishes and leaves a hole. Lighting: the light is computed from the normal, so even without culling the face is shaded as if lit from inside. Measuring: volumes, inside/outside tests and sections all rely on consistent winding, as the next lessons show.
var box = Mesh.Box(4, 3, 2.5);Console.WriteLine($"box: {Check(box)}"); var (a, b, c) = box.F[3];box.F[3] = (a, c, b); // one roof triangle listed clockwiseConsole.WriteLine($"one triangle flipped: {Check(box)}"); box.F.RemoveAt(3); // and now that triangle missingConsole.WriteLine($"one triangle missing: {Check(box)}"); static string Check(Mesh m){ var count = new Dictionary<(int, int), int>(); foreach (var (a, b, c) in m.F) foreach (var e in new[] { (a, b), (b, c), (c, a) }) count[e] = count.GetValueOrDefault(e) + 1; int same = 0, open = 0; foreach (var ((p, q), n) in count) { if (n > 1) same++; // used twice in one direction else if (!count.ContainsKey((q, p))) open++; // nothing on the other side } return same == 0 && open == 0 ? "closed and consistently wound" : $"{same} edges used twice in the same direction, {open} edges with no partner";} sealed class Mesh{ public readonly List<V3> V = new(); public readonly List<(int A, int B, int C)> F = new(); // counter-clockwise seen from outside public static Mesh Box(double lx, double ly, double lz) => Prism(new[] { (0.0, 0.0), (lx, 0.0), (lx, ly), (0.0, ly) }, lz); // A counter-clockwise footprint extruded up by h (floor and roof fanned from corner 0) public static Mesh Prism((double X, double Y)[] poly, double h) { var m = new Mesh(); int n = poly.Length; foreach (var (x, y) in poly) m.V.Add(new V3(x, y, 0)); foreach (var (x, y) in poly) m.V.Add(new V3(x, y, h)); for (int k = 1; k < n - 1; k++) { m.F.Add((0, k + 1, k)); // floor, facing down m.F.Add((n, n + k, n + k + 1)); // roof, facing up } for (int i = 0; i < n; i++) { int j = (i + 1) % n; m.F.Add((i, j, j + n)); // each wall as two triangles m.F.Add((i, j + n, i + n)); } return m; } public static Mesh Pyramid(double b, double h) { var m = new Mesh(); m.V.AddRange(new V3[] { new(0, 0, 0), new(b, 0, 0), new(b, b, 0), new(0, b, 0), new(b / 2, b / 2, h) }); m.F.AddRange(new[] { (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4), (0, 2, 1), (0, 3, 2) }); return m; } // (b - a) x (c - a): points out of the solid, and its length is twice the triangle's area public V3 Normal((int A, int B, int C) t) => V3.Cross(V[t.B] - V[t.A], V[t.C] - V[t.A]);} readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}box: closed and consistently wound one triangle flipped: 3 edges used twice in the same direction, 0 edges with no partner one triangle missing: 0 edges used twice in the same direction, 3 edges with no partner
Face normals and vertex normals
var box = Mesh.Box(4, 3, 2.5);int corner = 6; // the top corner at (4, 3, 2.5) var sum = new V3(0, 0, 0);var faces = new HashSet<V3>();int count = 0;foreach (var t in box.F){ if (t.A != corner && t.B != corner && t.C != corner) continue; sum += box.Normal(t); // length 2 x area: big triangles weigh more faces.Add(box.Normal(t).Normalized()); count++;} Console.WriteLine($"{count} triangles meet at {box.V[corner]}");Console.WriteLine($"smooth vertex normal: {sum.Normalized()}");Console.WriteLine($"flat shading needs {faces.Count} copies of the corner: {string.Join(", ", faces)}"); sealed class Mesh{ public readonly List<V3> V = new(); public readonly List<(int A, int B, int C)> F = new(); // counter-clockwise seen from outside public static Mesh Box(double lx, double ly, double lz) => Prism(new[] { (0.0, 0.0), (lx, 0.0), (lx, ly), (0.0, ly) }, lz); // A counter-clockwise footprint extruded up by h (floor and roof fanned from corner 0) public static Mesh Prism((double X, double Y)[] poly, double h) { var m = new Mesh(); int n = poly.Length; foreach (var (x, y) in poly) m.V.Add(new V3(x, y, 0)); foreach (var (x, y) in poly) m.V.Add(new V3(x, y, h)); for (int k = 1; k < n - 1; k++) { m.F.Add((0, k + 1, k)); // floor, facing down m.F.Add((n, n + k, n + k + 1)); // roof, facing up } for (int i = 0; i < n; i++) { int j = (i + 1) % n; m.F.Add((i, j, j + n)); // each wall as two triangles m.F.Add((i, j + n, i + n)); } return m; } public static Mesh Pyramid(double b, double h) { var m = new Mesh(); m.V.AddRange(new V3[] { new(0, 0, 0), new(b, 0, 0), new(b, b, 0), new(0, b, 0), new(b / 2, b / 2, h) }); m.F.AddRange(new[] { (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4), (0, 2, 1), (0, 3, 2) }); return m; } // (b - a) x (c - a): points out of the solid, and its length is twice the triangle's area public V3 Normal((int A, int B, int C) t) => V3.Cross(V[t.B] - V[t.A], V[t.C] - V[t.A]);} readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}5 triangles meet at (4, 3, 2.5) smooth vertex normal: (0.5, 0.333, 0.8) flat shading needs 3 copies of the corner: (0, 0, 1), (1, 0, 0), (0, 1, 0)
| Format | How it stores a mesh |
|---|---|
Show a hint
Show the solution
var v = new V3[] { new(0, 0, 0), new(4, 0, 0), new(4, 4, 0), new(0, 4, 0), new(2, 2, 3) };var f = new (int A, int B, int C)[] { (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4), (0, 2, 1), (0, 3, 2) }; var edges = new HashSet<(int, int)>();double area = 0;foreach (var (a, b, c) in f){ var n = V3.Cross(v[b] - v[a], v[c] - v[a]); area += n.Length / 2; Console.WriteLine($"({a}, {b}, {c}): normal {n.Normalized()}"); foreach (var (p, q) in new[] { (a, b), (b, c), (c, a) }) edges.Add((Math.Min(p, q), Math.Max(p, q)));}Console.WriteLine($"V - E + F = {v.Length} - {edges.Count} + {f.Length} = {v.Length - edges.Count + f.Length}");Console.WriteLine($"surface area = {area:0.###} m2"); readonly record struct V3(double X, double Y, double Z){ public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k); public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; public static V3 Cross(V3 a, V3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); public double Length => Math.Sqrt(Dot(this, this)); public V3 Normalized() => this * (1 / Length); static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###"); public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}(0, 1, 4): normal (0, -0.832, 0.555) (1, 2, 4): normal (0.832, 0, 0.555) (2, 3, 4): normal (0, 0.832, 0.555) (3, 0, 4): normal (-0.832, 0, 0.555) (0, 2, 1): normal (0, 0, -1) (0, 3, 2): normal (0, 0, -1) V - E + F = 5 - 9 + 6 = 2 surface area = 44.844 m2
Remember
A mesh is a vertex list plus an index list of triangles; shared vertices save memory and make neighbours meet exactly. V − E + F = 2 for any closed surface without holes: a cheap check on imported geometry. Corners are listed counter-clockwise seen from outside, so (b − a) × (c − a) points out of the solid. In a closed, well-wound mesh every edge is used exactly twice, once in each direction. Vertex normals average the face normals for smooth shading; hard edges need one vertex copy per face plane.