Article
Geometry · ⏱ ≈ 14 хв читання

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:

P(u, v) = (x(u,v), y(u,v), z(u,v)) The two tangent vectors at P: T_u = ∂P/∂u = (∂x/∂u, ∂y/∂u, ∂z/∂u) T_v = ∂P/∂v = (∂x/∂v, ∂y/∂v, ∂z/∂v) Surface normal (outward): N = T_u × T_v (cross product, then normalise)

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):

x(u,v) = (R + r·cos v) · cos u y(u,v) = (R + r·cos v) · sin u z(u,v) = r · sin v u ∈ [0, 2π) (longitude — around the large ring) v ∈ [0, 2π) (latitude — around the tube) Analytic normal: T_u = (−(R+r·cos v)·sin u, (R+r·cos v)·cos u, 0) T_v = (−r·sin v·cos u, −r·sin v·sin u, r·cos v) N = T_u × T_v (then normalise) Special cases: R = r → horn torus (inner circle collapses to a point) R < r → spindle torus (self-intersecting) R >> r → thin ring

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.

x(u,v) = a · cosh v · cos u y(u,v) = b · cosh v · sin u z(u,v) = c · sinh v u ∈ [0, 2π), v ∈ [−vmax, vmax] Implicit form: x²/a² + y²/b² − z²/c² = 1 With a=b=1, c=1: revolution hyperboloid (cooling tower profile) Waist radius: a (at v=0, cosh(0)=1, sinh(0)=0)

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:

For u ∈ [0, π), v ∈ [0, 2π): x = (a + b·cos(u/2)·sin v − b·sin(u/2)·sin(2v)) · cos u y = (a + b·cos(u/2)·sin v − b·sin(u/2)·sin(2v)) · sin u z = b·sin(u/2)·sin v + b·cos(u/2)·sin(2v) Here a controls overall size, b controls tube radius. The self-intersection (in ℝ³) is not geometric — it is an artefact of embedding a 4D object in 3D.

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 δ:

T_u ≈ [P(u+δ,v) − P(u−δ,v)] / (2δ) T_v ≈ [P(u,v+δ) − P(u,v−δ)] / (2δ) N = normalise(T_u × T_v)

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:

TBN matrix at surface point: T = tangent (along u-direction, in world space) B = bitangent (T × N) N = geometric normal Perturbed normal: n_tangent = normalMap.sample(uv) * 2 − 1 (unpack from [0,1]) n_world = normalise(TBN · n_tangent) In Three.js: MeshStandardMaterial or MeshPhongMaterial mat.normalMap = new THREE.TextureLoader().load(url) mat.normalScale = new THREE.Vector2(1, 1)

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.