// 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);
}