About this technique →
splats-from-lorenz/sketch.js
// Algorithmic Splats from Lorenz
//
// The Lorenz attractor's trajectory rendered as Gaussian splats.
// Each point of the orbit becomes a soft luminous billboard sized and colored
// by local velocity. Additive blending makes overlapping splats accumulate
// into glowing filaments that reveal the butterfly structure.

import * as THREE from 'three';

export const WARMUP = { framesBeforeReady: 60 };

export const PARAMS = {
  count:       { value: 8000,  min: 1000,  max: 20000, step: 100,    label: "Splat Count",        folder: "Structure",   rebuildOnChange: true },
  dt:          { value: 0.005, min: 0.001, max: 0.02,  step: 0.0005, label: "Step Size",           folder: "Dynamics",    rebuildOnChange: true },
  splatSize:   { value: 0.4,   min: 0.05,  max: 1.5,   step: 0.01,   label: "Splat Size",          folder: "Appearance" },
  hue:         { value: 200,   min: 0,     max: 360,   step: 1,      label: "Base Hue",            folder: "Appearance" },
  hueSpread:   { value: 80,    min: 0,     max: 200,   step: 1,      label: "Hue Spread by Speed", folder: "Appearance" },
  rotateSpeed: { value: 0.15,  min: 0,     max: 1,     step: 0.01,   label: "Rotate Speed",        folder: "Behavior" },
};

export const SHARE = {
  bookmarked: ["count", "dt", "splatSize", "hue", "hueSpread", "rotateSpeed"],
};

// Lorenz ODE derivatives
function lorenz(x, y, z, sigma, rho, beta) {
  return [
    sigma * (y - x),
    x * (rho - z) - y,
    x * y - beta * z,
  ];
}

// Build InstancedBufferGeometry for splats
function buildSplatGeometry(THREE, count, dt, hue, hueSpread) {
  // Integrate the Lorenz system to produce `count` trajectory points.
  // Skip the first 500 steps so the trajectory is on the attractor.
  const sigma = 10, rho = 28, beta = 8 / 3;

  let x = 0.1, y = 0, z = 0;

  // Warmup
  for (let i = 0; i < 500; i++) {
    const [dx, dy, dz] = lorenz(x, y, z, sigma, rho, beta);
    x += dx * dt;
    y += dy * dt;
    z += dz * dt;
  }

  const centers = new Float32Array(count * 3);
  const colors  = new Float32Array(count * 3);
  const sizes   = new Float32Array(count);

  // Collect speeds for normalization
  const speeds = new Float32Array(count);
  const pts    = new Float32Array(count * 3);

  for (let i = 0; i < count; i++) {
    const [dx, dy, dz] = lorenz(x, y, z, sigma, rho, beta);
    speeds[i] = Math.sqrt(dx * dx + dy * dy + dz * dz);
    pts[i * 3]     = x;
    pts[i * 3 + 1] = y;
    pts[i * 3 + 2] = z;
    x += dx * dt;
    y += dy * dt;
    z += dz * dt;
  }

  // Normalize speed for color mapping
  let minSpeed = Infinity, maxSpeed = -Infinity;
  for (let i = 0; i < count; i++) {
    if (speeds[i] < minSpeed) minSpeed = speeds[i];
    if (speeds[i] > maxSpeed) maxSpeed = speeds[i];
  }
  const speedRange = maxSpeed - minSpeed || 1;

  const hueBase = hue / 360;
  const hueSpreadNorm = hueSpread / 360;

  for (let i = 0; i < count; i++) {
    // Subtract centroid (0, 0, 25) so attractor is centered at origin
    centers[i * 3]     = pts[i * 3];
    centers[i * 3 + 1] = pts[i * 3 + 1];
    centers[i * 3 + 2] = pts[i * 3 + 2] - 25;

    const t = (speeds[i] - minSpeed) / speedRange;
    const h = ((hueBase + t * hueSpreadNorm) % 1 + 1) % 1;
    const col = new THREE.Color().setHSL(h, 0.85, 0.55);
    colors[i * 3]     = col.r;
    colors[i * 3 + 1] = col.g;
    colors[i * 3 + 2] = col.b;

    // Splats at high-speed regions are slightly smaller (tighter turns)
    sizes[i] = 1.0 - t * 0.4;
  }

  // Base quad: two triangles forming a [-1,1]^2 billboard
  const baseQuad = new THREE.BufferGeometry();
  baseQuad.setAttribute('position', new THREE.Float32BufferAttribute([
    -1, -1, 0,  1, -1, 0,  1,  1, 0,
    -1, -1, 0,  1,  1, 0, -1,  1, 0,
  ], 3));

  const geo = new THREE.InstancedBufferGeometry();
  geo.setAttribute('position', baseQuad.attributes.position);
  geo.setAttribute('iCenter', new THREE.InstancedBufferAttribute(centers, 3));
  geo.setAttribute('iColor',  new THREE.InstancedBufferAttribute(colors,  3));
  geo.setAttribute('iSize',   new THREE.InstancedBufferAttribute(sizes,   1));
  geo.instanceCount = count;

  return geo;
}

const VERT = `
  attribute vec3 iCenter;
  attribute vec3 iColor;
  attribute float iSize;
  uniform float uSplatSize;
  varying vec3 vColor;
  varying vec2 vUv;
  void main() {
    vColor = iColor;
    vUv = position.xy;
    // Camera-facing billboard: offset in view space
    vec4 mv = modelViewMatrix * vec4(iCenter, 1.0);
    mv.xy += position.xy * iSize * uSplatSize;
    gl_Position = projectionMatrix * mv;
  }
`;

const FRAG = `
  varying vec3 vColor;
  varying vec2 vUv;
  void main() {
    float r = length(vUv);
    float a = exp(-r * r * 4.0);
    if (a < 0.01) discard;
    gl_FragColor = vec4(vColor, a);
  }
`;

export function sceneSetup(THREE, scene, camera, renderer, params, seed) {
  scene.background = new THREE.Color(0x07080a);
  camera.position.set(0, 0, 80);
  camera.lookAt(0, 0, 0); // attractor is centered at origin after centroid subtraction

  const geo = buildSplatGeometry(THREE, params.count | 0, params.dt, params.hue, params.hueSpread);

  const mat = new THREE.ShaderMaterial({
    uniforms: {
      uSplatSize: { value: params.splatSize },
    },
    vertexShader: VERT,
    fragmentShader: FRAG,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });

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

  return { mesh, mat, geo };
}

export function sceneAnimate(THREE, scene, camera, state, params, time, delta) {
  // Update splat size uniform live
  state.mat.uniforms.uSplatSize.value = params.splatSize;

  // Orbit camera around the attractor
  const angle = time * params.rotateSpeed;
  const dist = 80;
  camera.position.set(
    Math.sin(angle) * dist,
    Math.sin(angle * 0.4) * 15,
    Math.cos(angle) * dist,
  );
  camera.lookAt(0, 0, 0);
}