About this technique →
supershape-3d/sketch.js
// 3D Supershape — Gielis spherical product of two superformulas
//
// r1(θ) = superformula(θ, m1, n1a, n2a, n3a)   — latitude ring
// r2(φ) = superformula(φ, m2, n1b, n2b, n3b)   — longitude sweep
//
// x = r1(θ) * cos(θ) * r2(φ) * cos(φ)
// y = r1(θ) * sin(θ) * r2(φ) * cos(φ)
// z =                   r2(φ) * sin(φ)
//
// θ ∈ [-π, π],  φ ∈ [-π/2, π/2]
//
// Rebuilt in-place each frame when shape params change; full rebuild when
// `segments` changes (rebuildOnChange).

import * as THREE from 'three';

export const WARMUP = { framesBeforeReady: 60 };

export const PARAMS = {
  m1:       { value: 8.0, min: 0,    max: 16,  step: 0.1,  label: "θ Symmetry (m1)", folder: "Shape (θ)" },
  n1a:      { value: 0.5, min: 0.05, max: 5,   step: 0.01, label: "θ n1",            folder: "Shape (θ)" },
  n2a:      { value: 0.5, min: 0.05, max: 5,   step: 0.01, label: "θ n2",            folder: "Shape (θ)" },
  n3a:      { value: 8.0, min: 0.05, max: 20,  step: 0.05, label: "θ n3",            folder: "Shape (θ)" },
  m2:       { value: 6.0, min: 0,    max: 16,  step: 0.1,  label: "φ Symmetry (m2)", folder: "Shape (φ)" },
  n1b:      { value: 1.0, min: 0.05, max: 5,   step: 0.01, label: "φ n1",            folder: "Shape (φ)" },
  n2b:      { value: 0.5, min: 0.05, max: 5,   step: 0.01, label: "φ n2",            folder: "Shape (φ)" },
  n3b:      { value: 1.0, min: 0.05, max: 20,  step: 0.05, label: "φ n3",            folder: "Shape (φ)" },
  segments: { value: 80,  min: 20,   max: 200, step: 5,    label: "Segments",        folder: "Performance", rebuildOnChange: true },
  hue:      { value: 30,  min: 0,    max: 360, step: 1,    label: "Hue",             folder: "Appearance" },
};

export const SHARE = {
  bookmarked: ["m1", "n1a", "n2a", "n3a", "m2", "n1b", "n2b", "n3b", "hue"],
};

// --- Superformula core ---

function superformula(angle, m, n1, n2, n3) {
  const t1 = Math.abs(Math.cos(m * angle / 4));
  const t2 = Math.abs(Math.sin(m * angle / 4));
  const base = Math.pow(t1, n2) + Math.pow(t2, n3);
  if (base === 0) return 1;
  return Math.pow(base, -1 / n1);
}

// Fill a Float32Array with supershape vertex positions for the given params.
// Array must have length (segments+1)*(segments+1)*3.
function fillPositions(arr, segments, m1, n1a, n2a, n3a, m2, n1b, n2b, n3b) {
  let idx = 0;
  for (let i = 0; i <= segments; i++) {
    const phi = (i / segments) * Math.PI - Math.PI / 2; // -π/2 → π/2
    const r2 = superformula(phi, m2, n1b, n2b, n3b);
    const r2cosPhi = r2 * Math.cos(phi);
    const r2sinPhi = r2 * Math.sin(phi);

    for (let j = 0; j <= segments; j++) {
      const theta = (j / segments) * Math.PI * 2 - Math.PI; // -π → π
      const r1 = superformula(theta, m1, n1a, n2a, n3a);

      arr[idx++] = r1 * Math.cos(theta) * r2cosPhi;
      arr[idx++] = r1 * Math.sin(theta) * r2cosPhi;
      arr[idx++] = r2sinPhi;
    }
  }
}

// Build a triangulated index array for a (segments+1)*(segments+1) grid.
function buildIndices(segments) {
  const indices = [];
  for (let i = 0; i < segments; i++) {
    for (let j = 0; j < segments; j++) {
      const a = i * (segments + 1) + j;
      const b = a + 1;
      const c = a + (segments + 1);
      const d = c + 1;
      indices.push(a, c, b);
      indices.push(b, c, d);
    }
  }
  return new Uint32Array(indices);
}

export function sceneSetup(THREE, scene, camera, renderer, params, seed) {
  scene.background = new THREE.Color(0x07080a);
  camera.position.set(0, 0, 3.5);
  camera.lookAt(0, 0, 0);

  // Lighting
  const ambient = new THREE.AmbientLight(0xffffff, 0.3);
  scene.add(ambient);
  const dirLight = new THREE.DirectionalLight(0xffffff, 1.4);
  dirLight.position.set(5, 5, 5);
  scene.add(dirLight);

  const seg = params.segments | 0;
  const vertexCount = (seg + 1) * (seg + 1);
  const positions = new Float32Array(vertexCount * 3);

  fillPositions(
    positions, seg,
    params.m1, params.n1a, params.n2a, params.n3a,
    params.m2, params.n1b, params.n2b, params.n3b,
  );

  const geo = new THREE.BufferGeometry();
  // NOTE: Float32BufferAttribute COPIES its input array. Hold the attribute's
  // own .array reference in state so per-frame writes target the buffer the
  // GPU actually reads.
  const posAttr = new THREE.Float32BufferAttribute(positions, 3);
  geo.setAttribute('position', posAttr);
  geo.setIndex(new THREE.BufferAttribute(buildIndices(seg), 1));
  geo.computeVertexNormals();

  const mat = new THREE.MeshStandardMaterial({
    color: new THREE.Color().setHSL(params.hue / 360, 0.7, 0.55),
    roughness: 0.45,
    metalness: 0.15,
    side: THREE.DoubleSide,
  });

  const mesh = new THREE.Mesh(geo, mat);
  scene.add(mesh);

  // Use the attribute's actual buffer (Float32BufferAttribute may have copied).
  return { mesh, geo, mat, positions: posAttr.array, seg };
}

export function sceneAnimate(THREE, scene, camera, state, params, time, delta) {
  const { mat } = state;

  // Live hue update
  mat.color.setHSL(params.hue / 360, 0.7, 0.55);

  // Always rebuild positions from current live params. With segments=80 default
  // this is ~6500 vertex calculations per frame — cheap and guarantees the mesh
  // reflects the current slider values. (segments itself is rebuildOnChange, so
  // state.seg matches the live value.)
  fillPositions(
    state.positions, state.seg,
    params.m1, params.n1a, params.n2a, params.n3a,
    params.m2, params.n1b, params.n2b, params.n3b,
  );

  state.geo.attributes.position.needsUpdate = true;
  state.geo.computeVertexNormals();
}