diff --git a/index.html b/index.html
new file mode 100644
index 0000000..a8786d1
--- /dev/null
+++ b/index.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ Three.js Hourglass Sand
+
+
+
+
+
+ Hourglass with particle sand (Three.js)
+ - Drag to orbit, scroll to zoom
+ - Press F to flip the hourglass
+
+
+
+
\ No newline at end of file
diff --git a/main.js b/main.js
new file mode 100644
index 0000000..664e75c
--- /dev/null
+++ b/main.js
@@ -0,0 +1,390 @@
+import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js';
+import { OrbitControls } from 'https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js';
+import { GPUComputationRenderer } from 'https://unpkg.com/three@0.160.0/examples/jsm/misc/GPUComputationRenderer.js';
+
+// Scene setup
+const root = document.getElementById('app');
+const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
+renderer.setSize(window.innerWidth, window.innerHeight);
+renderer.outputColorSpace = THREE.SRGBColorSpace;
+root.appendChild(renderer.domElement);
+
+const scene = new THREE.Scene();
+scene.fog = new THREE.Fog(0x0b0d16, 12, 30);
+
+const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
+camera.position.set(0, 0.8, 5);
+
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enableDamping = true;
+controls.dampingFactor = 0.06;
+controls.maxDistance = 12;
+controls.minDistance = 2.2;
+
+// Lights
+{
+ const hemi = new THREE.HemisphereLight(0xbfd4ff, 0x2a1b0a, 0.9);
+ hemi.position.set(0, 4, 0);
+ scene.add(hemi);
+
+ const dir = new THREE.DirectionalLight(0xffffff, 0.9);
+ dir.position.set(3, 5, 4);
+ dir.castShadow = false;
+ scene.add(dir);
+
+ const rim = new THREE.DirectionalLight(0x88b7ff, 0.3);
+ rim.position.set(-4, 2, -5);
+ scene.add(rim);
+}
+
+// Floor
+{
+ const geo = new THREE.CircleGeometry(8, 64);
+ const mat = new THREE.MeshStandardMaterial({ color: 0x0f1322, roughness: 0.9, metalness: 0.0 });
+ const floor = new THREE.Mesh(geo, mat);
+ floor.rotation.x = -Math.PI / 2;
+ floor.position.y = -2.0;
+ floor.receiveShadow = false;
+ scene.add(floor);
+}
+
+// Hourglass parameters and shaping
+const HALF_HEIGHT = 1.6; // Half height of inner cavity
+const BULB_RADIUS = 0.95; // Radius at the top/bottom bulges (inner cavity)
+const NECK_RADIUS = 0.08; // Radius at narrow neck (inner cavity)
+const GLASS_THICKNESS = 0.035; // Visual glass thickness for the lathe shell
+const SHAPE_POWER = 1.6; // Controls curvature between neck and bulbs
+const NECK_HALF_HEIGHT = 0.14; // Region where extra funnel forces apply
+
+function innerRadiusAtY(y) {
+ const t = Math.min(1.0, Math.max(0.0, Math.abs(y) / HALF_HEIGHT));
+ const curve = Math.pow(t, SHAPE_POWER); // 0 at neck, 1 at caps
+ return NECK_RADIUS + (BULB_RADIUS - NECK_RADIUS) * curve;
+}
+
+// Create glass shell using a lathe of the OUTER profile
+(function createGlass() {
+ const segments = 200;
+ const profile = [];
+ for (let i = 0; i <= segments; i++) {
+ const v = i / segments; // 0..1 from bottom(-H) to top(+H)
+ const y = -HALF_HEIGHT + 2 * HALF_HEIGHT * v;
+ const r = innerRadiusAtY(y) + GLASS_THICKNESS;
+ profile.push(new THREE.Vector2(r, y));
+ }
+ const latheGeo = new THREE.LatheGeometry(profile, 256);
+ latheGeo.computeVertexNormals();
+ const glassMat = new THREE.MeshPhysicalMaterial({
+ color: 0xffffff,
+ metalness: 0.0,
+ roughness: 0.05,
+ transmission: 0.95,
+ thickness: 0.25,
+ ior: 1.45,
+ transparent: true,
+ opacity: 0.6,
+ envMapIntensity: 1.0,
+ });
+ const glass = new THREE.Mesh(latheGeo, glassMat);
+ scene.add(glass);
+
+ // Stand rings
+ const ringMat = new THREE.MeshStandardMaterial({ color: 0x5a4636, roughness: 0.7, metalness: 0.1 });
+ const ringGeo = new THREE.TorusGeometry(BULB_RADIUS + GLASS_THICKNESS * 1.2, 0.03, 12, 64);
+ const ringTop = new THREE.Mesh(ringGeo, ringMat);
+ ringTop.position.y = HALF_HEIGHT + 0.02;
+ scene.add(ringTop);
+ const ringBottom = ringTop.clone();
+ ringBottom.position.y = -HALF_HEIGHT - 0.02;
+ scene.add(ringBottom);
+})();
+
+// ------------------------------
+// GPGPU Particle Simulation
+// ------------------------------
+
+// Change these to scale particle count (width * height)
+const TEXTURE_WIDTH = 256;
+const TEXTURE_HEIGHT = 256;
+const NUM_PARTICLES = TEXTURE_WIDTH * TEXTURE_HEIGHT;
+
+// Physics constants
+const GRAVITY = 6.0;
+const LINEAR_DAMPING = 0.995;
+const VISCOSITY = 0.0025;
+const WALL_RESTITUTION = 0.4;
+const FLOOR_BOUNCE = 0.25;
+const FUNNEL_EXTRA_G = 22.0;
+const FUNNEL_CENTERING = 8.0;
+
+// GPUComputation setup
+const gpu = new GPUComputationRenderer(TEXTURE_WIDTH, TEXTURE_HEIGHT, renderer);
+
+// Helper: create initial textures
+const dtPosition = gpu.createTexture();
+const dtVelocity = gpu.createTexture();
+
+function randNormal() {
+ // Box-Muller for slight spread
+ const u = Math.random();
+ const v = Math.random();
+ return Math.sqrt(-2.0 * Math.log(u + 1e-6)) * Math.cos(2.0 * Math.PI * v);
+}
+
+function samplePointInTopBulb() {
+ for (let attempts = 0; attempts < 64; attempts++) {
+ const y = THREE.MathUtils.lerp(0.12, HALF_HEIGHT - 0.05, Math.random());
+ const rMax = innerRadiusAtY(y) * 0.98;
+ const rr = Math.sqrt(Math.random()) * rMax;
+ const ang = Math.random() * Math.PI * 2;
+ const x = rr * Math.cos(ang);
+ const z = rr * Math.sin(ang);
+ return new THREE.Vector3(x, y, z);
+ }
+ return new THREE.Vector3(0, HALF_HEIGHT * 0.6, 0);
+}
+
+// Fill initial textures
+{
+ const posArray = dtPosition.image.data; // Float32Array RGBA
+ const velArray = dtVelocity.image.data;
+ let ptr = 0;
+ for (let j = 0; j < TEXTURE_HEIGHT; j++) {
+ for (let i = 0; i < TEXTURE_WIDTH; i++) {
+ const p = samplePointInTopBulb();
+ posArray[ptr + 0] = p.x;
+ posArray[ptr + 1] = p.y;
+ posArray[ptr + 2] = p.z;
+ posArray[ptr + 3] = 1.0;
+
+ // tiny jitter
+ velArray[ptr + 0] = randNormal() * 0.02;
+ velArray[ptr + 1] = randNormal() * 0.02;
+ velArray[ptr + 2] = randNormal() * 0.02;
+ velArray[ptr + 3] = 0.0;
+
+ ptr += 4;
+ }
+ }
+}
+
+// Shaders
+const commonDefs = /* glsl */`
+ float innerRadiusAtY(float y) {
+ float t = clamp(abs(y) / ${HALF_HEIGHT.toFixed(6)}, 0.0, 1.0);
+ float curve = pow(t, ${SHAPE_POWER.toFixed(6)});
+ return ${NECK_RADIUS.toFixed(6)} + (${BULB_RADIUS.toFixed(6)} - ${NECK_RADIUS.toFixed(6)}) * curve;
+ }
+`;
+
+const velocityFragmentShader = /* glsl */`
+ uniform sampler2D texturePosition;
+ uniform sampler2D textureVelocity;
+ uniform float uDelta;
+ uniform float uGravitySign;
+
+ ${commonDefs}
+
+ void main() {
+ vec2 uv = gl_FragCoord.xy / resolution.xy;
+ vec3 pos = texture2D(texturePosition, uv).xyz;
+ vec3 vel = texture2D(textureVelocity, uv).xyz;
+
+ // Gravity
+ vel.y += -${GRAVITY.toFixed(6)} * uGravitySign * uDelta;
+
+ // Linear damping and mild viscosity
+ vel *= ${LINEAR_DAMPING.toFixed(6)};
+ vel -= vel * ${VISCOSITY.toFixed(6)};
+
+ // Funnel acceleration and centering in neck region
+ if (pos.y > -${NECK_HALF_HEIGHT.toFixed(6)} && pos.y < ${NECK_HALF_HEIGHT.toFixed(6)}) {
+ float rAtY = innerRadiusAtY(pos.y);
+ float rLen = length(pos.xz);
+ if (rLen < rAtY * 0.9) {
+ vel.y += -${FUNNEL_EXTRA_G.toFixed(6)} * uGravitySign * uDelta;
+ if (rLen > 1e-6) {
+ vec2 n = -pos.xz / rAtY; // towards center
+ vel.xz += n * ${FUNNEL_CENTERING.toFixed(6)} * uDelta;
+ }
+ }
+ }
+
+ // Collide with inner boundary (approx using current pos)
+ float rY = innerRadiusAtY(pos.y) - 0.006;
+ float radialLen = length(pos.xz);
+ if (radialLen > rY && radialLen > 1e-6) {
+ vec2 nxz = pos.xz / radialLen;
+ float vDotN = dot(vel.xz, nxz);
+ if (vDotN > 0.0) {
+ vel.xz -= (1.0 + ${WALL_RESTITUTION.toFixed(6)}) * vDotN * nxz;
+ vel.xz *= 0.96;
+ }
+ }
+
+ // Cap collisions
+ if (pos.y > ${HALF_HEIGHT.toFixed(6)} && vel.y > 0.0) {
+ vel.y = -vel.y * ${FLOOR_BOUNCE.toFixed(6)};
+ vel.xz *= 0.96;
+ }
+ if (pos.y < -${HALF_HEIGHT.toFixed(6)} && vel.y < 0.0) {
+ vel.y = -vel.y * ${FLOOR_BOUNCE.toFixed(6)};
+ vel.xz *= 0.95;
+ }
+
+ gl_FragColor = vec4(vel, 1.0);
+ }
+`;
+
+const positionFragmentShader = /* glsl */`
+ uniform sampler2D texturePosition;
+ uniform sampler2D textureVelocity;
+ uniform float uDelta;
+
+ ${commonDefs}
+
+ void main() {
+ vec2 uv = gl_FragCoord.xy / resolution.xy;
+ vec3 pos = texture2D(texturePosition, uv).xyz;
+ vec3 vel = texture2D(textureVelocity, uv).xyz;
+
+ // Integrate
+ pos += vel * uDelta;
+
+ // Project back inside boundary in case of drift
+ float rY = innerRadiusAtY(pos.y) - 0.006;
+ float radialLen = length(pos.xz);
+ if (radialLen > rY && radialLen > 1e-6) {
+ vec2 nxz = pos.xz / radialLen;
+ float over = radialLen - rY;
+ pos.xz -= nxz * over;
+ }
+
+ // Clamp top/bottom
+ pos.y = clamp(pos.y, -${HALF_HEIGHT.toFixed(6)}, ${HALF_HEIGHT.toFixed(6)});
+
+ gl_FragColor = vec4(pos, 1.0);
+ }
+`;
+
+// Create variables
+const velVar = gpu.addVariable('textureVelocity', velocityFragmentShader, dtVelocity);
+const posVar = gpu.addVariable('texturePosition', positionFragmentShader, dtPosition);
+
+gpu.setVariableDependencies(velVar, [posVar, velVar]);
+gpu.setVariableDependencies(posVar, [posVar, velVar]);
+
+velVar.material.uniforms.uDelta = { value: 0.0 };
+velVar.material.uniforms.uGravitySign = { value: 1.0 };
+
+posVar.material.uniforms.uDelta = { value: 0.0 };
+
+const initError = gpu.init();
+if (initError) {
+ console.error(initError);
+}
+
+// Renderable particles using shader material
+const particleGeometry = new THREE.BufferGeometry();
+const aRef = new Float32Array(NUM_PARTICLES * 2);
+let ptr = 0;
+for (let y = 0; y < TEXTURE_HEIGHT; y++) {
+ for (let x = 0; x < TEXTURE_WIDTH; x++) {
+ aRef[ptr++] = (x + 0.5) / TEXTURE_WIDTH;
+ aRef[ptr++] = (y + 0.5) / TEXTURE_HEIGHT;
+ }
+}
+particleGeometry.setAttribute('aRef', new THREE.BufferAttribute(aRef, 2));
+
+// A dummy position so Three.js knows it's points; values unused in shader
+const dummyPositions = new Float32Array(NUM_PARTICLES * 3);
+particleGeometry.setAttribute('position', new THREE.BufferAttribute(dummyPositions, 3));
+
+const particleVertexShader = /* glsl */`
+ uniform sampler2D tPosition;
+ uniform float uPointSize;
+ uniform float uHalfHeight;
+ attribute vec2 aRef;
+ varying float vFade;
+ void main() {
+ vec3 pos = texture2D(tPosition, aRef).xyz;
+ vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
+ gl_Position = projectionMatrix * mvPosition;
+ gl_PointSize = uPointSize * (300.0 / -mvPosition.z);
+ vFade = clamp(1.0 - abs(pos.y) / uHalfHeight, 0.2, 1.0);
+ }
+`;
+
+const particleFragmentShader = /* glsl */`
+ precision mediump float;
+ uniform vec3 uColor;
+ varying float vFade;
+ void main() {
+ vec2 r = gl_PointCoord - 0.5;
+ float d = length(r);
+ float alpha = smoothstep(0.5, 0.45, d) * vFade;
+ vec3 col = uColor;
+ gl_FragColor = vec4(col, alpha);
+ }
+`;
+
+const particleMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ tPosition: { value: null },
+ uPointSize: { value: 2.2 },
+ uHalfHeight: { value: HALF_HEIGHT },
+ uColor: { value: new THREE.Color(0xf3d7a5) },
+ },
+ vertexShader: particleVertexShader,
+ fragmentShader: particleFragmentShader,
+ transparent: true,
+ depthWrite: false,
+ blending: THREE.NormalBlending,
+});
+
+const particlePoints = new THREE.Points(particleGeometry, particleMaterial);
+scene.add(particlePoints);
+
+// Flip function: invert gravity sign and add slight jitter to unstick
+let gravitySign = 1.0;
+function flipHourglass() {
+ gravitySign *= -1.0;
+ velVar.material.uniforms.uGravitySign.value = gravitySign;
+}
+
+window.addEventListener('keydown', (e) => {
+ if (e.key.toLowerCase() === 'f') {
+ flipHourglass();
+ }
+});
+
+// Animate
+let lastTime = performance.now() / 1000;
+function animate() {
+ const now = performance.now() / 1000;
+ let dt = now - lastTime;
+ lastTime = now;
+ dt = Math.min(dt, 0.033);
+
+ velVar.material.uniforms.uDelta.value = dt;
+ posVar.material.uniforms.uDelta.value = dt;
+
+ gpu.compute();
+
+ particleMaterial.uniforms.tPosition.value = gpu.getCurrentRenderTarget(posVar).texture;
+
+ controls.update();
+ renderer.render(scene, camera);
+ requestAnimationFrame(animate);
+}
+
+animate();
+
+// Handle resizing
+function onResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+}
+window.addEventListener('resize', onResize);
\ No newline at end of file