EIDOLON - Samin Sami

by Samin Sami

HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EIDOLON — A museum of lost things, rendered in dust</title>
<meta name="description" content="EIDOLON is a museum whose artifacts are ghosts: point-cloud sculptures of things that no longer exist, each assembled from sixty thousand grains of drifting light. An amphora, a fallen column, a moth, a hand — sculpture from pure mathematics." />
<meta property="og:title" content="EIDOLON — A museum of lost things" />
<meta property="og:description" content="Four artifacts that no longer exist, reconstructed from dust as you descend. Point-cloud sculpture rendered live in your browser." />
<meta name="theme-color" content="#0b0a0c" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230b0a0c'/%3E%3Cg fill='%23efe7d6'%3E%3Ccircle cx='16' cy='7' r='1.1'/%3E%3Ccircle cx='11' cy='12' r='1'/%3E%3Ccircle cx='21' cy='12' r='1'/%3E%3Ccircle cx='16' cy='14' r='1.2'/%3E%3Ccircle cx='13' cy='19' r='1'/%3E%3Ccircle cx='19' cy='19' r='1'/%3E%3Ccircle cx='16' cy='24' r='1.1'/%3E%3C/g%3E%3Ccircle cx='16' cy='16' r='11' fill='none' stroke='%23c98a3c' stroke-width='0.6' opacity='0.7'/%3E%3C/svg%3E" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;1,9..144,300;1,9..144,400&family=Space+Grotesk:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="grain" aria-hidden="true"></div>
<div class="vignette" aria-hidden="true"></div>

<!-- fixed WebGL stage — the exhibit hall -->
<canvas id="scene" aria-hidden="true"></canvas>
<div class="webgl-fallback"...

CSS

/* ============================================================
   EIDOLON — museum of lost things
   Deep hall dark · bone-white points · amber plaque type
   ============================================================ */

:root {
  --hall:        #0b0a0c;   /* near-black gallery dark */
  --hall-2:      #131117;   /* faintly warmer pool of light */
  --bone:        #efe7d6;   /* point / heading white */
  --bone-dim:    #b8b0a3;   /* body text */
  --bone-faint:  #6d675e;   /* meta / captions */
  --amber:       #d79a4a;   /* plaque accent */
  --amber-deep:  #b47a2f;
  --plaque-bg:   rgba(18,15,12,0.58);
  --rule:        rgba(215,154,74,0.35);
  --maxw:        1440px;
  --ease:        cubic-bezier(0.22, 1, 0.36, 1);
  --ease-in:     cubic-bezier(0.7, 0, 0.84, 0);
}

* { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }

html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }

body {
  background: var(--hall);
  color: var(--bone-dim);
  font-family: "Space Grotesk", system-ui, sans-serif;
  font-weight: 300;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
  cursor: default;
}

::selection { background: rgba(215,154,74,0.28); color: var(--bone); }

a { color: inherit; text-decoration: none; }

/* thin custom scrollbar */
::-webkit-scrollbar { width: 9px; }
::-webkit-scrollbar-track { background: #060506; }
::-webkit-scrollbar-thumb { background: #2a2620; border-radius: 9px; border: 2px solid #060506; }
::-webkit-scrollbar-thumb:hover { background: var(--amber-deep); }

/* ---------- fixed atmosphere overlays ---------- */
.grain {
  position: fixed; inset: 0; z-index: 40; pointer-events: none;
  opacity: 0.05; mix-blend-mode: screen;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence...

JavaScript

// ============================================================================
// EIDOLON — point-cloud museum of lost things
//
// Each exhibit is ~60k points whose HOME positions are sampled parametrically
// (lathe-revolved profile, fluted+fractured column, parametric moth, anatomical
// hand). Points live in one big BufferGeometry; a per-vertex "assembly" uniform
// (0 = scattered dust, 1 = fully assembled) lerps each grain between a drifting
// dust cloud and its home. Scroll drives assembly per exhibit; a curl-noise field
// (evaluated in the vertex shader) makes the dust breathe and lets the cursor
// stir it. Hold-to-focus tightens + brightens the whole cloud.
//
// No external models. Everything is math.
// ============================================================================

import * as THREE from 'three';

const canvas   = document.getElementById('scene');
const fallback = document.getElementById('fallback');
const focusRing= document.getElementById('focusRing');
const hallIdx  = document.getElementById('hallIdx');
const scrollCue= document.getElementById('scrollCue');
const live     = document.getElementById('live');
const reduce   = matchMedia('(prefers-reduced-motion: reduce)').matches;

const PER = 60000;              // points per exhibit (approx; each generator returns its own count)

// ---- WebGL capability check -> graceful fallback -------------------------
let renderer;
try {
  renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
  const gl = renderer.getContext();
  if (!gl) throw new Error('no gl');
} catch (e) {
  canvas.hidden = true;
  fallback.hidden = false;
  console.warn('EIDOLON: WebGL unavailable, showing fallback.');
}

if (renderer) boot();

function boot() {
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setSize(innerWidth, innerHeight);
  // Opaque clear (hall...