Dynamic MikkTSpace Tangent Generation for glTF Meshes

Generating MikkTSpace tangents dynamically for glTF meshes requires implementing Morten Mikkelsen’s standardized tangent space algorithm directly within a runtime pipeline. When a glTF asset supplies normal maps but omits the four-component TANGENT vertex attribute, the host application must derive these tangents on the fly to prevent visual seams and distorted surface shading. This dynamic process extracts position, normal, and texture coordinate buffers, computes triangle-level directional derivatives in texture space, performs angle-weighted averaging across shared vertices, orthogonalizes the resulting vectors against vertex normals, and computes a handedness scalar to produce the final four-component vector required by the glTF specification.

1. Mesh Buffer Extraction and Pre-Conditioning

The glTF format stores mesh data across disparate accessor buffers. Dynamic generation begins by obtaining three core vertex attributes:

  • Positions: 3-component float vectors (VEC3).
  • Normals: 3-component float vectors (VEC3).
  • UV Coordinates: 2-component float vectors (VEC2 from TEXCOORD_0).
  • Indices: Triangle list indices defining primitive topology.

MikkTSpace assumes indexed triangle meshes where vertices are distinct if their geometric attributes diverge. If positions, normals, or UV coordinates differ, or if an edge represents a sharp seam, those vertices must not share calculated tangents. The algorithm operates either by unwrapping the mesh into an unindexed triangle array or by maintaining an adjacency map based on combined (Position, Normal, UV) identity.

2. Triangle-Level Tangent and Bitangent Calculation

For every individual triangle defined by vertices \(P_0, P_1, P_2\) with corresponding UV coordinates \(UV_0, UV_1, UV_2\), edge vectors in object space and parametric texture space are established:

\[\Delta P_1 = P_1 - P_0, \quad \Delta P_2 = P_2 - P_0\] \[\Delta U_1 = U_1 - U_0, \quad \Delta V_1 = V_1 - V_0\] \[\Delta U_2 = U_2 - U_0, \quad \Delta V_2 = V_2 - V_0\]

The relationship between spatial coordinates and texture space coordinates forms a linear system:

\[\begin{bmatrix} \Delta P_1 \\ \Delta P_2 \end{bmatrix} = \begin{bmatrix} \Delta U_1 & \Delta V_1 \\ \Delta U_2 & \Delta V_2 \end{bmatrix} \begin{bmatrix} T \\ B \end{bmatrix}\]

Solving for the unnormalized tangent vector \(T\) and bitangent vector \(B\) yields:

\[r = \frac{1}{\Delta U_1 \Delta V_2 - \Delta U_2 \Delta V_1}\] \[T = (\Delta P_1 \Delta V_2 - \Delta P_2 \Delta V_1) \cdot r\] \[B = (\Delta P_2 \Delta U_1 - \Delta P_1 \Delta U_2) \cdot r\]

If the determinant \((\Delta U_1 \Delta V_2 - \Delta U_2 \Delta V_1)\) is zero or near-zero, the triangle exhibits degenerate mapping. In dynamic implementations, these triangles assign zero vectors to \(T\) and \(B\) to avoid division by zero.

3. Angle-Weighted Neighborhood Accumulation

Unlike basic tangent generation that uses uniform or area-weighted averaging, MikkTSpace dictates an angle-weighted accumulation scheme to avoid bias introduced by tessellation density:

  1. Vertex Angle Weighting: For each vertex of a triangle, the internal angle \(\alpha\) subtended by the incident edges is evaluated using the dot product: \[\alpha = \arccos\left(\frac{E_1 \cdot E_2}{|E_1| |E_2|}\right)\]
  2. Grouping Criteria: Triangles contribute their weighted tangents (\(\alpha \cdot T\)) and bitangents (\(\alpha \cdot B\)) to an accumulator mapped to the vertex.
  3. Discontinuity Detection: Neighboring faces only merge their tangent spaces if:
    • They share identical position, normal, and UV coordinates.
    • The angle between their unnormalized face normals does not exceed a smoothing threshold.
    • The sign of their determinants (texture orientation) matches. If a texture is mirrored across a seam, the vertices must be split to prevent canceling out tangent contributions.

4. Gram-Schmidt Orthogonalization

Because interpolated surface normals (\(N\)) are not guaranteed to be strictly perpendicular to accumulated surface tangents (\(T_{acc}\)), the tangent must be re-projected onto the tangent plane perpendicular to the normal:

\[T_{\text{ortho}} = T_{acc} - N \cdot (N \cdot T_{acc})\] \[T_{\text{final}} = \text{normalize}(T_{\text{ortho}})\]

If \(T_{\text{ortho}}\) has a length near zero (which occurs when the UV coordinates are degenerately mapped parallel to the normal), an arbitrary vector orthogonal to \(N\) is generated programmatically to maintain pipeline stability.

5. Handedness Evaluation for glTF Compliance

glTF requires tangents to be stored as a VEC4 attribute: \((T_x, T_y, T_z, w)\). The fourth component, \(w\), is a sign scalar (\(+1.0\) or \(-1.0\)) indicating the handedness of the coordinate basis. This value allows the vertex or fragment shader to reconstruct the bitangent vector (\(B\)) dynamically:

\[B = (N \times T_{\text{final}}) \cdot w\]

To determine \(w\), the algorithm compares the cross product of the normal and the final tangent with the accumulated bitangent \(B_{acc}\):

\[w = \operatorname{sgn}((N \times T_{\text{final}}) \cdot B_{acc})\]

If the dot product is greater than or equal to zero, \(w = 1.0\); otherwise, \(w = -1.0\).

6. Execution Strategies for Dynamic Generation

Executing this algorithm dynamically can become a bottleneck during runtime glTF loading. High-performance dynamic implementations apply several architectural optimizations:

  • Compute Shaders: The algorithm maps efficiently to GPU compute pipelines. Stage one computes triangle differentials; stage two groups and accumulates tangents atomically or via pre-sorted spatial bins; stage three orthogonalizes and packs the VEC4 output.
  • WebAssembly / SIMD Compilation: For CPU-bound loaders (such as WebGL/WebGPU browser runtimes), the official C reference implementation by Morten Mikkelsen is typically compiled to WebAssembly with SIMD vectorization enabled, operating on flat typed arrays directly allocated in shared memory.
  • In-Place Cache Optimization: Reusing vertex index arrays and operating on interleaved attribute buffers limits cache misses when traversing large index lists.