// Splat Nebula
//
// A volumetric cloud of Gaussian splats sampled from 3D fractional Brownian
// motion (fBm). At each grid point, an fBm value decides whether a splat is
// placed; a second offset fBm controls color. Additive blending layers the
// transparency into a soft nebula-like volume.
import * as THREE from 'three';
export const WARMUP = { framesBeforeReady: 60 };
export const PARAMS = {
density: { value: 24, min: 8, max: 60, step: 1, label: "Grid Resolution", folder: "Structure", rebuildOnChange: true },
threshold: { value: 0.55, min: 0.3, max: 0.9, step: 0.01, label: "Density Threshold", folder: "Structure", rebuildOnChange: true },
noiseScale: { value: 1.5, min: 0.3, max: 5, step: 0.05, label: "Noise Scale", folder: "Structure", rebuildOnChange: true },
splatSize: { value: 0.18, min: 0.02, max: 0.6, step: 0.005, label: "Splat Size", folder: "Appearance" },
hueA: { value: 220, min: 0, max: 360, step: 1, label: "Hue A", folder: "Appearance" },
hueB: { value: 320, min: 0, max: 360, step: 1, label: "Hue B", folder: "Appearance" },
rotateSpeed: { value: 0.1, min: 0, max: 1, step: 0.01, label: "Rotate Speed", folder: "Behavior" },
};
export const SHARE = {
bookmarked: ["density", "threshold", "noiseScale", "splatSize", "hueA", "hueB", "rotateSpeed"],
};
// Simple deterministic 3D pseudo-noise via sin hash
function noise3(x, y, z) {
const s = Math.sin(x * 12.9898 + y * 78.233 + z * 37.719) * 43758.5453;
return s - Math.floor(s);
}
// Fractional Brownian Motion: 4-octave sum
function fbm3(x, y, z, octaves = 4) {
let v = 0, a = 0.5, f = 1;
for (let i = 0; i < octaves; i++) {
v += a * noise3(x * f, y * f, z * f);
f *= 2.03;
a *= 0.5;
}
return v;
}
// Build the InstancedBufferGeometry by sampling the noise field
function buildNebulaGeometry(THREE, density, threshold, noiseScale, hueA, hueB) {
const centers = [];
const colorsArr = [];
const sizesArr = [];
const step = 2.0 / (density - 1); // grid step in [-1, 1]
const hA = hueA / 360;
const hB = hueB / 360;
for (let ix = 0; ix < density; ix++) {
for (let iy = 0; iy < density; iy++) {
for (let iz = 0; iz < density; iz++) {
const px = -1 + ix * step;
const py = -1 + iy * step;
const pz = -1 + iz * step;
const density_val = fbm3(px * noiseScale, py * noiseScale, pz * noiseScale);
if (density_val > threshold) {
centers.push(px, py, pz);
// Color from a second noise field offset by a constant
const colorNoise = fbm3(
px * noiseScale + 17.3,
py * noiseScale + 31.7,
pz * noiseScale + 5.1,
);
// Interpolate between hueA and hueB
const h = hA + (hB - hA) * colorNoise;
const hWrapped = ((h % 1) + 1) % 1;
// Brightness slightly driven by density value
const brightness = 0.45 + (density_val - threshold) * 0.5;
const col = new THREE.Color().setHSL(hWrapped, 0.85, Math.min(brightness, 0.75));
colorsArr.push(col.r, col.g, col.b);
// Size: slightly vary with noise for organic feel
sizesArr.push(0.5 + colorNoise * 0.5);
}
}
}
}
const N = sizesArr.length;
if (N === 0) {
// Fallback: single center splat to avoid empty geometry
centers.push(0, 0, 0);
colorsArr.push(1, 1, 1);
sizesArr.push(1);
}
const centersBuf = new Float32Array(centers);
const colorsBuf = new Float32Array(colorsArr);
const sizesBuf = new Float32Array(sizesArr);
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(centersBuf, 3));
geo.setAttribute('iColor', new THREE.InstancedBufferAttribute(colorsBuf, 3));
geo.setAttribute('iSize', new THREE.InstancedBufferAttribute(sizesBuf, 1));
geo.instanceCount = sizesArr.length;
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(0x03040a);
camera.position.set(0, 0, 3.5);
camera.lookAt(0, 0, 0);
const geo = buildNebulaGeometry(
THREE,
params.density | 0,
params.threshold,
params.noiseScale,
params.hueA,
params.hueB,
);
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 nebula
const angle = time * params.rotateSpeed;
const dist = 3.5;
camera.position.set(
Math.sin(angle) * dist,
Math.sin(angle * 0.37) * 1.2,
Math.cos(angle) * dist,
);
camera.lookAt(0, 0, 0);
}