Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Real-Time Mesh Deformation and Normal Recalculation in WebGL

Tech Sep 18 1

Objective

Combining a base geometry, a distortion function, and a shading material within a WebGL context enables the generation of highly diverse, interactive visual outputs. The goal is to apply spatial distortions to 3D shapes dynamically. A key constraint is compatibility with p5.js, which lacks an API for passing custom vertex attributes to shaders. Therefore, the implementation must rely solely on standard vertex data: position, normal, texture coordinates, and transformation matrices.

The Normal Vector Problem

Applying arbitrary distortions to a mesh invalidates its pre-calculated surface normals, resulting in incorrect lighting and reflections. In a standard rendering pipeline, the vertex shader passes normals to the fragment shader, where they are interpolated across triangles to determine light interaction. While rotating a mesh simply applies the same rotation to its normals, deforming a mesh (e.g., twisting a plane with a sine wave) alters the surface curvature, meaning the original normals no longer correspond to the modified geometry.

Ineffective Approaches

Fragment Shader Derivatives

Ignoring vertex normals and computing them entirely in the fragment shader using screen-space derivatives is a tempting workaround. GLSL provides dFdx and dFdy to determine the rate of change of a variable across adjacent pixels. The cross product of these two tangent vectors yields a surface normal.

varying vec3 vWorldPos;
uniform vec3 uTint;

void main() {
  vec3 computedNorm = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));
  vec3 lightDir = vec3(0.0, 1.0, 0.0);
  float diffuse = max(dot(computedNorm, lightDir), 0.0);
  gl_FragColor = vec4(diffuse * uTint, 1.0);
}

However, this approach produces faceted shading. Because the mesh consists of discrete triangles, the screen-space derivatives are constant across a single triangle, preventing the smooth interpolation typically provided by varying vertex normals.

Manual Vertex Shader Derivatives

Computing the analytical derivatives of the distortion function directly in the vertex shader allows for smooth normal interpolation. Unfortunately, manually writing the mathematical derivatives for complex distortion functions is highly error-prone and tedious.

Core Solution: Automated Shader Code Generation

Since calculating derivatives follows strict algorithmic rules (like the chain rule), a computer can perform it reliably. By constructing an abstract syntax tree of the mathematical operations, it is possible to automatically generate both the evaluation code and its derivative code.

Automatic Differentiation Architecture

Each mathematical operation is modeled as an object capable of recursively generating its own GLSL code and derivative code. For instance, a sine operation applies the chain rule by multiplying the cosine of its argument by the argument's derivative.

class SineNode {
  constructor(arg) {
    this.arg = arg;
  }

  generateExpr() {
    return `sin(${this.arg.generateExpr()})`;
  }

  generateDeriv() {
    return `cos(${this.arg.generateExpr()}) * ${this.arg.generateDeriv()}`;
  }
}

By utilizing a builder pattern to construct these operation trees, the generated GLSL can be seamlessly injected into the vertex shader. This technique allows for complex procedural displacements with automatically computed partial derivatives.

import { buildAutoDiff } from 'custom-glsl-derivative';

const vertexSrc = `
  void main() {
    vec4 pos = vec4(aVertexPos, 1.0);
    float px = pos.x;
    float py = pos.y;
    ${buildAutoDiff((ctx) => {
      const px = ctx.var('px');
      const py = ctx.var('py');
      const tick = ctx.var('tick');
      let wave = ctx.const(0);
      for (let i = 0; i < 3; i++) {
        wave = wave.add(ctx.sin(
          ctx.add(wave.mult(0.5), px.mult(1.5), py.mult(2.8), tick.mult(0.002))
        ));
      }
      wave = wave.mult(0.1);
      wave.assign('zOffset');
      wave.derivative('dzx', px);
      wave.derivative('dzy', py);
    })}
    pos.z = zOffset;
    vec3 tx = vec3(1.0, 0.0, dzx);
    vec3 ty = vec3(0.0, 1.0, dzy);
    vTransformedNormal = uNormMat * normalize(cross(tx, ty));

    gl_Position = uProjMat * uModelViewMat * pos;
  }
`;

Applying Derivatives to Normal Calculation

Deforming a Plane

For a flat plane displaced along the Z-axis, the surface tangents are easily derived from the partial derivatives of the offset with respect to X and Y. The new normal is the normalized cross product of these two tangent vectors.

vec3 deformedPos = aPosition;
deformedPos.z += heightOffset;

vec3 tanX = vec3(1.0, 0.0, dOffset_dx);
vec3 tanY = vec3(0.0, 1.0, dOffset_dy);
vec3 recalculatedNorm = normalize(cross(tanX, tanY));

Deforming Arbitrary Meshes

Applying the same Z-displacement to a non-planar shape requires preserving the original curvature. This is achieved by calculating the rotation induced by the displacement on a hypothetical plane, and then applying that exact rotation to the mesh's original normal vector. The rotation axis is the cross product of the original plane normal and the newly computed normal, and the rotation angle is the arc cosine of their dot product.

mat4 computeRotationMatrix(vec3 rotAxis, float rotAngle) { /* ... */ }

vec3 tanX = vec3(1.0, 0.0, dOffset_dx);
vec3 tanY = vec3(0.0, 1.0, dOffset_dy);
vec3 warpedFlatNorm = normalize(cross(tanX, tanY));
vec3 flatBaseNorm = vec3(0.0, 0.0, 1.0);

float rotAngle = acos(dot(flatBaseNorm, warpedFlatNorm));
vec3 rotAxis = normalize(cross(flatBaseNorm, warpedFlatNorm));
mat4 rotMat = computeRotationMatrix(rotAxis, rotAngle);

vec3 finalNorm = (rotMat * vec4(aOriginalNorm, 0.0)).xyz;

3D Vector Displacement

When the distortion outputs a full 3D vector instead of a scalar Z-offset, partial derivatives must account for all three axes. By defining a composite direction variable, such as u = y + z, the Jacobian of the displacement function can be used to find the directional derivatives. The tangent vectors are formed by adding the respective partial derivatives to the basis vectors, and the same rotation matrix logic applies to transform the original normals correctly.

vec3 tanX = vec3(1.0, 0.0, 0.0) + dVec_dx;
vec3 tanYZ = vec3(0.0, 1.0, 1.0) + dVec_dy + dVec_dz;
vec3 warpedFlatNorm = normalize(cross(tanX, tanYZ));
vec3 flatBaseNorm = normalize(cross(vec3(1.0, 0.0, 0.0), vec3(0.0, 1.0, 1.0)));

float rotAngle = acos(dot(flatBaseNorm, warpedFlatNorm));
vec3 rotAxis = normalize(cross(flatBaseNorm, warpedFlatNorm));
mat4 rotMat = computeRotationMatrix(rotAxis, rotAngle);

vec3 finalNorm = (rotMat * vec4(aOriginalNorm, 0.0)).xyz;
Tags: WebGLGLSL

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.