About this technique →
splat-nebula/sketch.js
// 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);
}