Parametric Surfaces in Three.js
A parametric surface is a map from a 2D domain — the parameter space
(u, v) ∈ [0,1]² — to a 3D point P(u,v). This simple idea generates
spheres, tori, hyperboloids, Klein bottles and every other smooth
surface used in 3D graphics. We derive the equations for several
classic surfaces, build Three.js BufferGeometry from
scratch, compute analytic normals, and add normal-map shading for
surface detail.
1. Parametric Surfaces
A parametric surface is a smooth function P : ℝ² → ℝ³. For each parameter pair (u, v) in some domain Ω we get a point:
The surface is regular at (u,v) if N ≠ 0. At singular points (where T_u and T_v are parallel) the normal is undefined — sphere poles are the classic example.
2. Torus
A torus is generated by sweeping a circle of radius r (tube radius) along another circle of radius R (major radius):
3. Hyperboloid
A one-sheet hyperboloid is a doubly-ruled surface — through every point pass two straight lines that lie entirely on the surface. This is why cooling towers are built in this shape: straight beams, curved form.
The two-sheet hyperboloid is x²/a² + y²/b² − z²/c² = −1 — parametrised with sinh for x,y and cosh for z.
4. Klein Bottle
The Klein bottle is a non-orientable surface with no boundary — it cannot be embedded in ℝ³ without self-intersection. The "figure-8 immersion" parametrisation:
5. Vertex Normals from Partial Derivatives
For any parametric surface, normals can be computed analytically by finite difference or exact symbolic differentiation. Finite difference approximation at parameter (u, v) with step δ:
For smooth shading Three.js also provides
computeVertexNormals() which averages face normals at
shared vertices. Analytic normals are always better at poles and
singularities where finite differences lose precision.
6. Building BufferGeometry in Three.js
// Generic parametric surface → Three.js BufferGeometry
function buildParametricGeometry(fn, uSegs = 64, vSegs = 64) {
const positions = [];
const normals = [];
const uvs = [];
const indices = [];
const δ = 1e-4;
for (let vi = 0; vi <= vSegs; vi++) {
for (let ui = 0; ui <= uSegs; ui++) {
const u = ui / uSegs;
const v = vi / vSegs;
const p = fn(u, v);
const pu = fn(u + δ, v);
const pv = fn(u, v + δ);
positions.push(p.x, p.y, p.z);
uvs.push(u, v);
// Tangent vectors via finite differences
const tu = {x:(pu.x-p.x)/δ, y:(pu.y-p.y)/δ, z:(pu.z-p.z)/δ};
const tv = {x:(pv.x-p.x)/δ, y:(pv.y-p.y)/δ, z:(pv.z-p.z)/δ};
// Cross product
const nx = tu.y*tv.z - tu.z*tv.y;
const ny = tu.z*tv.x - tu.x*tv.z;
const nz = tu.x*tv.y - tu.y*tv.x;
const nl = Math.sqrt(nx*nx + ny*ny + nz*nz) || 1;
normals.push(nx/nl, ny/nl, nz/nl);
}
}
// Build triangle index buffer
const stride = uSegs + 1;
for (let vi = 0; vi < vSegs; vi++) {
for (let ui = 0; ui < uSegs; ui++) {
const a = vi * stride + ui;
const b = a + stride;
indices.push(a, b, a+1, b, b+1, a+1);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices);
return geo;
}
// Torus: R=1, r=0.4
const R = 1, r = 0.4;
const torusGeo = buildParametricGeometry((u, v) => {
const [U, V] = [u * Math.PI * 2, v * Math.PI * 2];
return {
x: (R + r * Math.cos(V)) * Math.cos(U),
y: (R + r * Math.cos(V)) * Math.sin(U),
z: r * Math.sin(V)
};
});
// Hyperboloid: a=b=c=1, v ∈ [−1.5, 1.5]
const hyperGeo = buildParametricGeometry((u, v) => {
const U = u * Math.PI * 2;
const V = (v - 0.5) * 3; // remap to [−1.5, 1.5]
return { x: Math.cosh(V) * Math.cos(U), y: Math.cosh(V) * Math.sin(U), z: Math.sinh(V) };
});
7. Normal Mapping
Normal mapping adds the illusion of fine surface detail without extra geometry. A normal map is a texture encoding per-texel surface normals in tangent space (RGB → xyz). The fragment shader transforms these normals to world space using the TBN matrix and uses them in the lighting calculation:
The TBN matrix is computed per-fragment using partial derivatives:
Three.js computes tangent vectors automatically from UVs if you call
geometry.computeTangents() after setting UV and normal
attributes.
8. More Surfaces
Möbius strip
x=(1+v/2·cos(u/2))·cos u, y=(1+v/2·cos(u/2))·sin u, z=v/2·sin(u/2). Non-orientable 1D boundary surface.
Boy's surface
Non-orientable, no self-intersection in ℝ³ unlike Klein bottle. Constructed via a map of ℝP² into ℝ³ discovered by Werner Boy (1901).
Enneper surface
x=u−u³/3+uv², y=v−v³/3+vu², z=u²−v². A minimal surface (mean curvature H=0 everywhere).
Seashell / helix
x=r(v)·cos(Nu)·cos(v), y=r(v)·cos(Nu)·sin(v), z=−r(v)·sin(Nu). Generates gastropod shell shapes by varying r(v) exponentially.