In computer graphics, normals are vectors perpendicular to surfaces that are critical for rendering and lighting calculations. Transforming normals correctly is essential to ensure that the visual appearance of objects (such as shading, reflections, and lighting) remains accurate after transformations like scaling, rotation, or translation.
Challenges in Normal Transformation
-
Non-Uniform Scaling:
- Normals need special care under non-uniform scaling (e.g., scaling an object more in one direction than another) because simply applying the transformation matrix to the normal may not preserve its perpendicularity to the surface.
-
Translation:
- Normals represent direction and not position, so translation has no effect on them and must not be applied.
-
Maintaining Unit Length:
- Normals are typically normalized to unit length (magnitude = 1) for accurate lighting calculations. Transformations may distort their magnitude, requiring renormalization.
Mathematics Behind Normal Transformation
Standard Transformation
For a vertex , the transformation is done using the model matrix :
However, directly applying to normals is incorrect in cases involving non-uniform scaling. This is because normals must remain perpendicular to the surface even after transformations.
Correct Normal Transformation
Normals are transformed using the inverse transpose of the model matrix :
Here:
- : Inverse of the transformation matrix.
- : Transpose of the inverse matrix.
Why Use the Inverse Transpose?
-
Inverse:
- Ensures that the transformed normal remains perpendicular to the surface.
- Compensates for non-uniform scaling distortions.
-
Transpose:
- Handles the directionality of the normal vector.
-
Avoid Translation:
- Translation components of do not affect the normal.
Steps to Transform a Normal
- Extract the transformation matrix used for the object's geometry.
- Compute the inverse transpose of :
- Apply to the normal to get the transformed normal :
- Normalize to ensure it is a unit vector:
Special Cases
-
Uniform Scaling:
- If scaling is uniform, the same factor is applied in all directions, and the normals are directly proportional to the transformation.
- In this case, you can simply apply the transformation matrix and renormalize the result.
-
Rotation:
- Rotations preserve angles and perpendicularity, so the normal can be directly transformed using the rotation matrix.
-
Translation:
- Normals are not affected by translation.
Example
Given:
- A normal vector:
- A transformation matrix :
(Non-uniform scaling)
Steps:
- Compute :
- Compute :
- Apply to :
- Normalize :
Applications
-
Shading and Lighting: Normals are critical for calculating how light interacts with a surface (e.g., diffuse and specular reflection).
-
Normal Mapping: Techniques like normal mapping use altered normals to simulate surface details without changing the geometry.
-
Rendering Pipelines: Correct normal transformations are necessary for accurate rendering of objects under transformations.
Conclusion
Transforming normals in computer graphics is a key operation for ensuring visual accuracy in rendering. The use of the inverse transpose matrix for transforming normals, particularly under non-uniform scaling, ensures that they remain perpendicular to the surface and consistent with the object's geometry.
Here's the source code for experimentation of normal transformation in freeCAD...
import FreeCAD
import FreeCADGui
from PySide2 import QtWidgets, QtGui, QtCore
import Part
class NormalTransformationApp(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Normal Transformation in FreeCAD")
self.setGeometry(200, 200, 400, 300)
self.layout = QtWidgets.QVBoxLayout()
# Create Buttons
self.create_cube_btn = QtWidgets.QPushButton("Create Cube")
self.apply_scaling_btn = QtWidgets.QPushButton("Apply Non-Uniform Scaling")
self.reset_scaling_btn = QtWidgets.QPushButton("Reset Scaling")
self.show_normals_btn = QtWidgets.QPushButton("Show Normals")
# Add Buttons to Layout
self.layout.addWidget(self.create_cube_btn)
self.layout.addWidget(self.apply_scaling_btn)
self.layout.addWidget(self.reset_scaling_btn)
self.layout.addWidget(self.show_normals_btn)
self.setLayout(self.layout)
# Connect Buttons to Functions
self.create_cube_btn.clicked.connect(self.create_cube)
self.apply_scaling_btn.clicked.connect(self.apply_scaling)
self.reset_scaling_btn.clicked.connect(self.reset_scaling)
self.show_normals_btn.clicked.connect(self.show_normals)
def create_cube(self):
"""Creates a cube in the FreeCAD document."""
doc = FreeCAD.ActiveDocument
if not doc:
doc = FreeCAD.newDocument("NormalTransformationDemo")
cube = doc.addObject("Part::Box", "Cube")
cube.Length = 10
cube.Width = 10
cube.Height = 10
cube.Placement = FreeCAD.Placement(FreeCAD.Vector(0, 0, 0), FreeCAD.Rotation(0, 0, 0, 1))
doc.recompute()
FreeCADGui.Selection.addSelection(cube)
def apply_scaling(self):
"""Applies non-uniform scaling to the selected object."""
selected_objects = FreeCADGui.Selection.getSelection()
if not selected_objects:
QtWidgets.QMessageBox.warning(self, "Warning", "No object selected.")
return
obj = selected_objects[0]
scale_matrix = FreeCAD.Matrix()
scale_matrix.A11 = 2 # Scale X by 2
scale_matrix.A22 = 0.5 # Scale Y by 0.5
scale_matrix.A33 = 1 # Scale Z by 1
obj.Placement = obj.Placement.multiply(FreeCAD.Placement(scale_matrix))
FreeCAD.ActiveDocument.recompute()
def reset_scaling(self):
"""Resets the scaling of the selected object."""
selected_objects = FreeCADGui.Selection.getSelection()
if not selected_objects:
QtWidgets.QMessageBox.warning(self, "Warning", "No object selected.")
return
obj = selected_objects[0]
obj.Placement = FreeCAD.Placement(FreeCAD.Vector(0, 0, 0), FreeCAD.Rotation(0, 0, 0, 1))
FreeCAD.ActiveDocument.recompute()
def show_normals(self):
"""Calculates and displays normals for the faces of the selected object."""
selected_objects = FreeCADGui.Selection.getSelection()
if not selected_objects:
QtWidgets.QMessageBox.warning(self, "Warning", "No object selected.")
return
obj = selected_objects[0]
if not hasattr(obj, "Shape"):
QtWidgets.QMessageBox.warning(self, "Warning", "Selected object has no shape.")
return
shape = obj.Shape
normals = []
for face in shape.Faces:
normal = face.normalAt(0.5, 0.5) # Normal at the center of the face
normals.append(normal)
# Create a visual line for the normal
center = face.CenterOfMass
normal_line = Part.LineSegment(center, center + normal * 5)
normal_obj = FreeCAD.ActiveDocument.addObject("Part::Feature", "Normal")
normal_obj.Shape = normal_line.toShape()
FreeCAD.ActiveDocument.recompute()
QtWidgets.QMessageBox.information(self, "Normals", f"Calculated {len(normals)} normals.")
def show_app():
"""Displays the application within FreeCAD."""
main_window = FreeCADGui.getMainWindow()
dock_widget = QtWidgets.QDockWidget("Normal Transformation", main_window)
dock_widget.setWidget(NormalTransformationApp())
main_window.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock_widget)
show_app()