Computer Graphics Homework 2 Report

Overview

Here's the website link: https://cal-cs184-student.github.io/hw-webpages-sp24-YuntingZh/hw2/index.html I gained a lot from this homework 2. It began with an exploration of bezier curves and surfaces, progressing to many operations related to the halfedge, I think this homework is primarily focusing on achieving different smoothing effects. The bezier surface can achieve great smoothness, though the teapot lid not connecting properly. Comparing this to the loop subdivision effect on the teapot at the end was particularly intriguing to me. A lot of the concepts covered in this assignment are ones I've previously encountered while using Blender, but without understanding the underlying principles, which I found especially interesting. Also, regarding bezier surfaces, I think they could be converted into geometry in Blender for further simple or catmull-clark subdivision. However, I don't know what's the principles behind this conversion process. It's something I look forward to exploring in the future.

Section I: Bezier Curves and Surfaces

Overview

TODO:

Task 1:Bezier Curves with 1D de Castlejau Subdivison.

This part aims to generate a Bezier curve and make our own style with 6 points. Behind the scenes of this curve, the crucial part is implementing Castlejau’s algorithm to evaluate the curve. It's a numerical method that recursively subdivides the curve at each iteration step, moving from the initial control points to the evaluation point on the curve. The Bezier curve is made from a series of control points, and de Castlejau’s algorithm enables us to calculate the control points by using t as a parameter, with values ranging from 0 – 1. When t = 0, the point on the curve is the first control point, and when t = 1, the point on the curve is the last control point.

In order to implement Castlejau’s algorithm on this first task 1, we need to pay attention to the header file that stores std::vector controlPoints: A std::vector and float t.

Task 3 before

In function evaluateStep, we created a new vector2D array called newPoints to store the interpolated points. Going back to the definition again, the Bezier curve is a curve that can be manipulated by using a series of control points. Its degree of n is defined by n+1 control points and based on parameter t with a value starting from 0 – 1 (start to end point of curve). This specific loop iterates through each pair of consecutive control points to generate a new set of control points.

Task 3 before

The new interpolated point is calculated by (1 - t) * currentPoint + t * nextPoint. This generates the points that can be used for the next level of subdivision. We continue this process recursively by calling evaluateStep until we're left with one final point. This point is the position on the Bezier curve corresponding to the parameter t.

The Bezier Curves

Task 2:Bezier Surfaces with Separable 1D de Castlejau.

The de Casteljau algorithm is also utilized for evaluating Bezier curves. For Bezier surfaces, this algorithm is extended to two parameters, commonly represented as u and v. While a Bezier curve is defined by a single set of control points, a Bezier surface is defined by a grid of control points. The extension to surfaces is applied as a separable 1D de Castlejau algorithm twice separately. First, applying the algorithm in one parameter direction (u) across all rows or columns of control points produces a set of new control points that define a Bezier curve in the other parameter direction (v). Then, the algorithm will recursively reduce the number of control points by linearly interpolating between those two parameters until a single point remains. Finally, this single point is the resulting point on the Bezier surface for the given parameters u and v.

In the case of integrating the algorithm, we must work with these three functions step by step. First is the BezierPath::evaluateStep(…) function, which is responsible for performing one step of the de Castlejau algorithm to reduce the grid of control points. This function will be modified just like the step we did for the Bezier curve and only needs to change from Vector2D to Vector3D.

Task 3 before

Then, secondly, continue to the BezierPatch::evaluate1D(…) function, which will be responsible for iterating until a single point is obtained for each direction.

Task 3 before

Finally, the last step is to apply BezierPatch::evaluate(…) to combine all these steps to find the final point on the surface.

Task 3 before

The Teapot

Task 3 before

Section II: Triangle Meshes and Half-Edge Data Structure

Overview

TODO:

Why Bezier surfaces are much more difficult to render directly?

The advantage of Bezier surfaces is that they can create very smooth surfaces. They do not require much storage space, as only the information of those control points is needed. However, displaying Bezier surfaces directly on the screen is challenging because of their smoothness, which requires more complex calculations for accurate depiction. Therefore, typically, Bezier surfaces are converted into triangle meshes before being displayed on the screen. This allows us to see a very smooth surface of a 3D model on the screen. The storage method for triangle meshes needs two lists: one for vertices and one for triangles. Although this method is straightforward, it is limited when it comes to traversing a large number of triangles for complex geometric processing. For example, if I want to find all the triangles around a specific triangle, I need to check every triangle in the framework, which can be slow and complicated if there are many triangles.😫

Therefore, at this point, our lecture introduced the half-edge data structure, a more efficient way to handle triangle meshes. It provides quick access and more effective mesh processing. The characteristic of the half-edge data structure is that each triangle is not only composed of points (vertices) and edges, but also an additional element called "half-edge." This "half-edge" acts like a small bridge connecting the points and edges in a triangle, storing all the necessary connection information. For instance, if I now want to find all the triangles adjacent to a triangle, it's like having a map that not only marks all the scenic spots (points) but also shows how to get from one scenic spot to another (edges and faces).

Task 3 - Area-Weighted Vertex Normals

In class, when we covered this topic, I personally understood it through the "Shade Smooth" function in Blender.

In Blender, there are two shading methods provided: shade flat and shade smooth. I think the so-called area-weighted vertex normals are similar to Blender's "Shade Smooth" function in that both are based on a similar concept. They are used to achieve a smoother, more natural surface effect when rendering models.

Task 3 blender exaple

About My Implementation:

The essence of area-weighted vertex normals lies in converting the normals of a face into the normals of a vertex. A vertex may be connected to many faces, so my goal is to find a comprehensive average direction that represents all these faces.

  1. Calculate Each Face's Normal: Start by calculating the normal of each face. For a given vertex, find each adjacent triangle. The normal is equal to the cross product of two vectors of the triangle. Specifically, I used a loop do {...} while (h != halfedge()); to traverse all the adjacent half-edges of a vertex. This ensures all the faces adjacent to that vertex are covered, and I checked if the face is a boundary face with if (!h->face()->isBoundary()) {...} since boundary faces may not be applicable for normal face normal calculations. Then I used Vector3D faceNormal = cross(edge1, edge2); to calculate the face's normal.
  2. Weight the Normals by Area: This ensures that larger faces have a greater impact on the vertex normal. I calculated the area with double area = faceNormal.norm() / 2.0;, allowing for a weighted calculation.
  3. Summation: Sum up all the normals around the vertex to get an overall normal vector. The implementation is N += faceNormal.unit() * area;. Here I used faceNormal.unit() because the length of the unnormalized faceNormal is twice the area. Multiplying it by the area would square the area, resulting in a physically meaningless result.
  4. Normalize the Overall Normal Vector: Scale the vector to unit length (i.e., length of 1). return N.unit(); The normalized normal vector maintains its original direction but with a uniform length. This is important for lighting and rendering, as it ensures that all normal vectors contribute evenly to the calculation.

Understanding the Impact of Not Normalizing:

If the normal vectors are not uniform in length, i.e., not normalized, several issues can arise during lighting calculations and rendering:

  • Inconsistent Lighting Calculations: In 3D rendering, normal vectors are typically used for calculating lighting, especially in determining how surfaces reflect light. If the normal vectors are not uniform in length, lighting intensity calculations can be incorrect. In lighting models like the Phong model, the length of normals affects the intensity of reflected light. Longer normals can make some areas appear brighter or darker than they actually are.
  • Visual Inconsistency: Non-uniform normal vectors can lead to visually incoherent images. Some areas might appear abnormal, like unexpected bright spots or dark areas, which do not correspond to the object's actual material and lighting conditions.
  • Performance Impact: Normalizing normal vectors is usually a standard step in the rendering pipeline. If the normals are not normalized, additional calculations may need to be performed during the lighting processing stage, which can affect rendering performance.
  • Cumulative Calculation Errors: In complex rendering scenes, unnormalized normals can lead to the accumulation of calculation errors, especially during multiple rendering iterations or when dealing with complex lighting effects.

Task 4: Edge Flip

When performing operations like edge flipping, it's essentially about rearranging the connections of these elements. To maintain the integrity and functionality of the mesh, I need to ensure:

This process, in my understanding, is like moving some pieces in a puzzle, where I need to ensure that all the puzzle pieces are still correctly fitted together. In this process, each puzzle piece (mesh element) needs to be correctly connected to others to maintain the integrity of the entire pattern (mesh). If these pointers point to the wrong elements, the mesh might experience issues, such as breaking or distortion (which I encountered during implementation).

How to Implement Edge Flip

  1. Confirm Flip Conditions: if (e0->isBoundary()) { return e0; } Ensure that the selected edge is not a boundary edge. If the edge or its adjacent faces are boundary elements, do not perform the flip.
  2. Identify Key Elements: Determine the elements directly related to the edge to be flipped: four vertices, six half-edges, two faces. VertexIter v0, v1, v2, v3; HalfedgeIter h0, h1, h2, h3; FaceIter f0, f1;
  3. Identify Elements
  4. Update Pointers and Half-Edge Pointers of Vertices, Edges, and Faces: Reallocate the pointers of the half-edges to reflect the new connections. h0->setNeighbors(...), h1->setNeighbors(...), ... Ensure that each half-edge's next, twin, vertex, edge, and face pointers are correctly directed towards the appropriate elements. And in this step I encountered the following errors:
  5. Update Pointers Failed attempts
  6. My Fix: This is a correction made by ChatGPT. I attempted to comprehend it, but it didn't seem to make sense. Regardless, I also revised my own code. After attending the office hours on Tuesday, I realized the issue was that the faces of h0_next and h1_next were not set correctly, as evident from the red circle in the image above. As long as I switch the surface, it functions well.😁
  7. Fixed

Task 5: Face Splitting

Splitting a boundary edge indeed makes sense, but flipping a boundary edge does not. (Can you explain why this is the case?)

Edge Operations on Meshes: Splitting vs. Flipping Boundary Edges

Understanding the difference between splitting and flipping boundary edges in mesh processing is crucial for maintaining the integrity and structure of the mesh:

  • Validity of Splitting a Boundary Edge: Splitting a boundary edge involves adding a new vertex along the edge, effectively dividing it into two. This operation is valid for boundary edges as it extends the mesh by adding more detail, without altering the fundamental shape or the integrity of the mesh.
  • Impracticality of Flipping a Boundary Edge: Flipping an edge is a process of reassigning connections between vertices, typically applicable to internal edges. For boundary edges, flipping is not feasible as they are part of only one triangle and lack the quadrilateral structure needed for flipping. Attempting to flip a boundary edge could distort or break the mesh's intended structure, as it would either create non-manifold edges or disrupt the mesh's boundary.
  • Preservation of Mesh Structure: In mesh processing, operations should preserve the mesh's overall structure and integrity. Splitting boundary edges aligns with this principle by adding detail without changing the mesh's shape, while flipping boundary edges does not and can lead to structural inconsistencies.

Task 5 requires me to perform a Edge Split. In the process of splitting, new vertices, edges, and faces are created. My focus is on reassigning pointers to ensure that the new relationships are still correct after the split. This step sounds simple, but I found that during implementation, due to not checking carefully, I sometimes reassigned the next edge or vertex incorrectly, which often led to errors. For example, I forgot to update the edge's pointer to the halfedge...

Errors

Implementation Steps

  1. Boundary Edge Check: First, check if the edge is a boundary edge. If so: return VertexIter(); And for the Extra Credit part I basically did the same as the non boundary part except when reassign relationships, I assign the half edge as following. Extra credit
  2. Create New Vertex: Create a new vertex at the midpoint of the edge to be split. This involves getting the two vertices at the ends of the e0 and computing the midpoint: Vector3D midpoint = (pos0 + pos1) / 2.0;
  3. Update Mesh Connectivity: With the new vertex added, it's necessary to create 3 new edges, 6 new half edges, and 2 new faces accordingly.
  4. Task5_graphics
  5. Update Pointers: Ensure all halfedges, vertices, and edges are updated to point to the correct elements after the split.
  6. Testing Flip: Before and After Face Splitting.Before and After Face SplittingHere are screenshots showing the mesh before and after combining edge splitting and flipping.Combining Edge Split and Flip

Task 6: 3D Mesh Upsampling and Loop Subdivision

In this task, we explore the challenges of upsampling 3D meshes, which is significantly more complex than upsampling 2D images, such as the bilinear filtering discussed in Lecture 5. A key challenge is that mesh vertices often occupy irregular locations, unlike the regular grid of a 2D image. There are several other factors that complicate 3D mesh upsampling:

I initially struggled to understand why we use this 3/8 * (A + B) + 1/8 * (C + D) in Loop subdivision instead of simply averaging the midpoints of triangle edges. I realized that this formula is central to Loop subdivision, aiming to increase mesh smoothness while preserving detail. This approach avoids sharp edges or unnatural shapes that might result from simpler vertex addition.

For new vertices (those added on the edge midpoints), the standard method is to average the positions of the edge endpoints. However, Loop subdivision also considers the positions of adjacent vertices to further enhance mesh smoothness. That's why we use the formula for new vertex positions.

Loop Subdivision

Before implementing Task 6, I reviewed this (1 - n * u) * original_position + u * original_neighbor_position_sum. It's crucial for adjusting the positions of old vertices based on their neighboring vertices, which helps in maintaining the geometric features while smoothing the mesh. The weight u depends on the vertex degree and balances between the original position and the average position of neighboring vertices.

Implementation Steps

  1. Reset isNew: Reset the state of vertices and edges before each subdivision round, preparing the mesh for subsequent iterations.
  2. Old Vertex Position Calculation: Calculate new positions for all vertices using the Loop subdivision rule, marking each as an original mesh vertex.
  3. New Vertex Position Calculation: Update new vertices' positions associated with edges and store them to the edge. Vertex Position Calculation
  4. Edge Splitting: Split every edge, noting which are new and which are from the original mesh. Avoid splitting edges that were just split.
  5. Edge Flipping: Flip any new edge that connects an old and new vertex.
  6. Position Update: Update the mesh with the new vertex positions.

At the beginning, I actually wasn't able to execute this correctly, so you will see those weird mesh shapes. In simple models, my errors were particularly obvious, such as with a box. However, in more complex shapes like a cow and others, the errors were somewhat obscured by the complexity of the model. So initially, when I tested on the peter.dae file, I thought my code was running correctly.

However, once I discovered the problem through the cube.dae, I began the debugging process. I first re-checked the calculations of the new and old coordinates to ensure they were correct. Then I started investigating if there was an issue with the creation of new vertices, but that wasn’t the case either. I later found out the error was in the flipping process.

flip issue

As you can see from the screenshot, the flip didn't execute at all, indicating that the newly created edges did not pass the e->isNew condition. Upon checking my code, I realized that I had never marked my edges as new. So, I returned to my Task 5’s splitEdge function, and this time, after splitting, I marked all newly created edges as new. (Actually, at this step, I accidentally discovered that in Task 5 I hadn't updated a vertex's halfedge relationship correctly, and I fixed it as well. It was then that I deeply understood why the prompt says, “While correct behaviors do not imply correct code, incorrect behaviors do imply incorrect code.”) Eventually, as you can see in the image below, I successfully fixed my function.

flip issue fixed

And finally, at the beginning of the function, I updated the mesh state again to ensure that the next subdivision could execute correctly. Now, when testing with the quadBall.dae file, everything runs smoothly. I'm really happy 😁

testQuadBall

Reducing the Smoothing Effect

Loop subdivision tends to smooth out sharp corners and edges in a mesh. You can see that very clearly in a cube model as below: cube This is generally beneficial for creating organic shapes but can be undesirable when sharp features are needed.

Here are some methods that we casn use to reduce the Smoothing Effect

  • Pre-splitting Edges: One approach to mitigate the smoothing effect on specific parts of the mesh is to pre-split some edges before applying the Loop subdivision. By doing this, you add more geometry to areas where you want to preserve detail. The added vertices and edges provide more control over how the subdivision algorithm affects the shape of the mesh.
  • Selective Subdivision: Another method is to apply subdivision selectively, subdividing only certain parts of the mesh while leaving other areas (like sharp edges or corners) untouched. This technique requires careful planning and mesh preparation but can yield results that better preserve the original design intent.
  • Using Creases: Some subdivision algorithms, including Loop subdivision, allow for the use of "creases." Creases are a way to specify edges or vertices that should not be smoothed out during subdivision. By assigning higher crease values to certain edges, you can maintain their sharpness post-subdivision.
  • Hybrid Approaches: Often, a combination of these techniques is used. For instance, you might pre-split certain edges and apply creases to others, depending on the specific requirements of your model and the desired level of detail or sharpness.

For using Pre-splitting Edges: I need to modify my current code to selectively pre-split certain edges before the subdivision process begins. Therefore, I primarily added two functions:calculateSharpness and preSplitEdges.

One is used to calculate the sharpness of an edge by calculating the angle between the normal vectors of two adjacent faces. Another is to pre-split edges by selecting edges based on a sharpness threshold. When testing the model, I set this sharpness to M_PI / 4; and called preSplitEdges(mesh, sharpnessThreshold)before performing loop subdivision in upsample. void MeshResampler::upsample(HalfedgeMesh& mesh) {
double sharpnessThreshold = M_PI / 4;
preSplitEdges(mesh, sharpnessThreshold);
...
}
The improvement in preserving sharpness can be seen in the image below: preSplitting

Asymmetry

Several factors can cause asymmetry:

In the case of our cube, you can refer to the image in the last section. The asymmetry is due to an inconsistency in the edge flow. If I flip two of the edges before upsampling, you can see now it's a symmetrical mesh. Here are 2 GIFs that show the differences:

So my pre-processing basically just ensures that the edges are symmetrically arranged around the vertices. You can see from the GIF above how I ensure edge flows by flipping the edges. Therefore, when performing loop subdivision, the mesh is symmetrical.

cubeSymmetry cubeSymmetry

Extra Credit - Support meshes with boundary.

I started with making sure new boundary faces are marked as boundary faces //FaceIter f2 = newFace();
FaceIter f2 = newBoundary(); // Create a new boundary face
This actually didn't work for me in the end so I switched back to newFace(); to fix it. bug and this is how I calculate old vertex pos if it's a boundary vertex based on:

Vnew = ¾ V + Vprev + Vnext

if (isBoundaryVertex) {
// Apply boundary vertex rule
Vector3D vPrev = v->halfedge()->next()->next()->vertex()->position;
Vector3D vNext = v->halfedge()->twin()->next()->vertex()->position;
v->newPosition = 0.75 * v->position + 0.125 * (vPrev + vNext);
}
If i change this equation it gaves me interesting look🤣like this: crazy car And this is how I calculate new vertex pos: if (e->isBoundary()) {
e->newPosition = 0.5 * (A + B);
}
After I fixed anything that I can think of, I got this smooth edges: fix

Conclusion

Task 6 demonstrates the complexity of 3D mesh upsampling and the effectiveness of the Loop subdivision method. By carefully adjusting vertex positions and respecting the mesh's topological and geometric properties, we can achieve a smoother and more detailed mesh.

Section III: Potential Extra Credit - Art Competition: Model something Creative!

Subdivision Observations: A Comparison of Loop and Catmull-Clark Subdivisions

Since learning about subdivision techniques, I revisited Blender and noticed that the use of subdivision modifiers in Blender differs from the Loop subdivision process. Loop subdivision will split each edge of a triangle into two and thus creating three new vertices within a triangle. However, in Blender, whether using simple subdivision or Catmull-Clark subdivision, four new vertices are added. Apart from one vertex at each edge of a triangle, there is an additional vertex at the center of the triangle.

I prepared a moth model. Due to boolean operations, one of the wings is merely joined to the main body without an actual union. Therefore, I anticipated some oddities during subdivision. However, out of curiosity about the final transformations of the two types of Subdivisions, I also conducted tests using the subdivision modifier in Blender. The images below show a low-poly moth and its appearance after a level 1 subdivision. I have displayed the differences under both framework and render effects.

blenderTest

And here's a gif of blender and a screenshot of the Loop subdivisiona in meshedit program to show a more clear differences

loopSubdivision

Loop Subdivision

  • Primarily used for triangulated meshes.
  • Splits each edge into two halves, adding new vertices at the midpoints.
  • For each original triangle, the newly formed vertices create four smaller triangles.

Catmull-Clark Subdivision

  • A more versatile method applicable to polygonal meshes, including quadrilaterals.
  • Adds new vertices at both the midpoints of edges and the centers of faces.
  • Transforms each original polygon into multiple quadrilaterals with the incorporation of these new vertices.

Summary

  • Blender commonly employs Catmull-Clark subdivision due to its smoother results, particularly for quad-based meshes.
  • The “Simple” subdivision modifier increases the number of vertices while maintaining the original shape and topology; while Catmull-Clark subdivision smoothens and refines the mesh by recalculating vertex positions.
  • Loop subdivision is efficient for purely triangulated meshes and is often used in real-time rendering.
  • Catmull-Clark subdivision is prevalent in 3D modeling and animation, offering smoother surfaces for high-quality renders.

Shader - TBU