// particles.js – exact replication of antigravity.google background animation import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js'; /* ─────────────────────────── CONFIG ─────────────────────────── */ const CONFIG = { light: { bg: '#ffffff', color1: '#2c64ed', color2: '#f84242', color3: '#ffcf03' }, dark: { bg: '#121217', color1: '#318bf7', color2: '#bada4c', color3: '#e35058' }, gridSize: 256, density: 150, particlesScale: 1, ringDisplacement: 0.15, ringWidth: 0.05, ringWidth2: 0.015, cameraZoom: 3.5, theme: 'light' // white background like antigravity.google default }; /* ─────────────────────── SIMPLEX NOISE GLSL ─────────────────── */ const noiseGLSL = ` vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); } vec4 permute(vec4 x){ return mod(((x*34.0)+1.0)*x, 289.0); } float permute(float x){ return floor(mod(((x*34.0)+1.0)*x, 289.0)); } vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; } float taylorInvSqrt(float r){ return 1.79284291400159 - 0.85373472095314 * r; } float snoise(vec2 v){ const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439); vec2 i = floor(v + dot(v, C.yy) ); vec2 x0 = v - i + dot(i, C.xx); vec2 i1; i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod(i, 289.0); vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 )) + i.x + vec3(0.0, i1.x, 1.0 )); vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); m = m*m ; m = m*m ; vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox; m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h ); vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw; return 130.0 * dot(m, g); } float snoise(vec3 v){ const vec2 C = vec2(1.0/6.0, 1.0/3.0) ; const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); vec3 i = floor(v + dot(v, C.yyy) ); vec3 x0 = v - i + dot(i, C.xxx) ; vec3 g = step(x0.yzx, x0.xyz); vec3 l = 1.0 - g; vec3 i1 = min( g.xyz, l.zxy ); vec3 i2 = max( g.xyz, l.zxy ); vec3 x1 = x0 - i1 + 1.0 * C.xxx; vec3 x2 = x0 - i2 + 2.0 * C.xxx; vec3 x3 = x0 - 1. + 3.0 * C.xxx; i = mod(i, 289.0 ); vec4 p = permute( permute( permute( i.z + vec4(0.0, i1.z, i2.z, 1.0 )) + i.y + vec4(0.0, i1.y, i2.y, 1.0 )) + i.x + vec4(0.0, i1.x, i2.x, 1.0 )); float n_ = 1.0/7.0; vec3 ns = n_ * D.wyz - D.xzx; vec4 j = p - 49.0 * floor(p * ns.z *ns.z); vec4 x_ = floor(j * ns.z); vec4 y_ = floor(j - 7.0 * x_ ); vec4 x = x_ *ns.x + ns.yyyy; vec4 y = y_ *ns.x + ns.yyyy; vec4 h = 1.0 - abs(x) - abs(y); vec4 b0 = vec4( x.xy, y.xy ); vec4 b1 = vec4( x.zw, y.zw ); vec4 s0 = floor(b0)*2.0 + 1.0; vec4 s1 = floor(b1)*2.0 + 1.0; vec4 sh = -step(h, vec4(0.0)); vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ; vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ; vec3 p0 = vec3(a0.xy,h.x); vec3 p1 = vec3(a0.zw,h.y); vec3 p2 = vec3(a1.xy,h.z); vec3 p3 = vec3(a1.zw,h.w); vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3))); p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); m = m * m; return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3) ) ); } `; /* ───────────────── SIMULATION FRAGMENT SHADER ───────────────── */ const simFragmentShader = ` precision highp float; uniform sampler2D uPosition; uniform sampler2D uPosRefs; uniform vec2 uMousePos; uniform float uTime; uniform float uDeltaTime; uniform float uIsHovering; vec2 hash( vec2 p ){ p = vec2( dot(p,vec2(2127.1,81.17)), dot(p,vec2(1269.5,283.37)) ); return fract(sin(p)*43758.5453); } void main() { vec2 simTexCoords = gl_FragCoord.xy / vec2(${CONFIG.gridSize}.0, ${CONFIG.gridSize}.0); vec4 pFrame = texture2D(uPosition, simTexCoords); float scale = pFrame.z; float velocity = pFrame.w; vec2 refPos = texture2D(uPosRefs, simTexCoords).xy; float seed = hash(simTexCoords).x; float seed2 = hash(simTexCoords).y; float time = uTime * .5; float lifeEnd = 3. + sin(seed2 * 100.) * 1.; float lifeTime = mod((seed * 100.) + time, lifeEnd); vec2 disp = vec2(0., 0.); vec2 pos = pFrame.xy; float distRadius = 0.15; vec2 targetPos = refPos; vec2 direction = normalize(targetPos - pos); direction *= .01; float dist = length(targetPos - pos); float distStrength = smoothstep(distRadius, 0., dist); if(dist > 0.005){ pos += direction * distStrength; } if(lifeTime < .01){ pos = refPos; pFrame.xy = refPos; scale = 0.; } // Scale lifecycle float targetScale = smoothstep(.01, 0.5, lifeTime) - smoothstep(0.5, 1., lifeTime/lifeEnd); float scaleDiff = targetScale - scale; scaleDiff *= .1; scale += scaleDiff; // Final position vec2 finalPos = pos + (disp * smoothstep(0.001, distRadius, dist)); vec2 diff = finalPos - pFrame.xy; diff *= .2; velocity = 0.0; vec4 frame = vec4(pFrame.xy + diff, scale, velocity); gl_FragColor = frame; } `; /* ─────────────── RENDER VERTEX SHADER ─────────────── */ const renderVertexShader = ` precision highp float; attribute vec4 seeds; uniform sampler2D uPosition; uniform float uTime; uniform float uParticleScale; uniform float uPixelRatio; uniform int uColorScheme; uniform float uIsHovering; uniform float uPulseProgress; varying vec4 vSeeds; varying float vVelocity; varying vec2 vLocalPos; varying vec2 vScreenPos; varying float vScale; ${noiseGLSL} void main() { vec4 pos = texture2D(uPosition, uv); vSeeds = seeds; float noiseX = snoise(vec3( vec2(pos.xy * 10.), uTime * .2 + 100.)); float noiseY = snoise(vec3( vec2(pos.xy * 10.), uTime * .2)); float noiseX2 = snoise(vec3( vec2(pos.xy * .5), uTime * .15 + 45.)); float noiseY2 = snoise(vec3( vec2(pos.xy * .5), uTime * .15 + 87.)); // Pulse wave float cDist = length(pos.xy) * 1.; float progress = uPulseProgress; float t = smoothstep(progress - .25, progress, cDist) - smoothstep(progress, progress + .25, cDist); t *= smoothstep(1., .0, cDist); pos.xy *= 1. + (t * .02); float dist = smoothstep(0., 0.9, pos.w); dist = mix(0., dist, uIsHovering); pos.y += noiseY * 0.005 * dist; pos.x += noiseX * 0.005 * dist; pos.y += noiseY2 * 0.02; pos.x += noiseX2 * 0.02; vVelocity = pos.w; vScale = pos.z; vLocalPos = pos.xy; vec4 viewSpace = modelViewMatrix * vec4(vec3(pos.xy, 0.), 1.0); gl_Position = projectionMatrix * viewSpace; vScreenPos = gl_Position.xy; float minScale = .25; minScale += float(uColorScheme) * .75; gl_PointSize = ((vScale * 7.) * (uPixelRatio * 0.5) * uParticleScale) + (minScale * uPixelRatio); } `; /* ─────────────── RENDER FRAGMENT SHADER ─────────────── */ const renderFragmentShader = ` precision highp float; varying vec4 vSeeds; varying vec2 vScreenPos; varying vec2 vLocalPos; varying float vScale; varying float vVelocity; uniform vec3 uColor1; uniform vec3 uColor2; uniform vec3 uColor3; uniform vec2 uMousePos; uniform vec2 uRez; uniform float uAlpha; uniform float uTime; uniform int uColorScheme; ${noiseGLSL} #define PI 3.1415926535897932384626433832795 float sdRoundBox( in vec2 p, in vec2 b, in vec4 r ) { r.xy = (p.x>0.0)?r.xy : r.zw; r.x = (p.y>0.0)?r.x : r.y; vec2 q = abs(p)-b+r.x; return min(max(q.x,q.y),0.0) + length(max(q,0.0)) - r.x; } vec2 rotate(vec2 v, float a) { float s = sin(a); float c = cos(a); mat2 m = mat2(c, s, -s, c); return m * v; } void main() { float uBorderSize = 0.2; vec2 center = vec2(.48, .4); float ratio = uRez.x / uRez.y; float angle = atan(vLocalPos.y - uMousePos.y, vLocalPos.x - uMousePos.x); vec2 uv = gl_PointCoord.xy; uv -= vec2(0.5); uv.y *= -1.; vec2 tuv = vScreenPos; tuv = rotate(tuv, uTime * 1.); tuv.y *= 1./ratio; tuv += .5; float h = 0.8; float progress = vVelocity; vec3 col = mix(mix(uColor1, uColor2, progress/h), mix(uColor2, uColor3, (progress - h)/(1.0 - h)), step(h, progress)); vec3 color = col; float dist = sqrt(dot(uv, uv)); float dr = .5; float t = smoothstep(dr+(uBorderSize + .0001), dr-uBorderSize, dist); t = clamp(t, 0., 1.); float rounded = sdRoundBox(uv, vec2(0.5, 0.2), vec4(.25)); rounded = smoothstep(.1, 0., rounded); float disc = smoothstep(.5, .45, length(uv)); float a = uAlpha * disc * smoothstep(0.1, 0.2, vScale); if(a < 0.01){ discard; } color = clamp(color, 0., 1.); color = mix(color, color * clamp(vVelocity, 0., 1.), float(uColorScheme)); gl_FragColor = vec4(color, clamp(a, 0., 1.)); } `; /* ─────────────── POISSON DISK SAMPLING ─────────────── */ function linearMap(x, a, b, c, d) { return ((x - a) * (d - c)) / (b - a) + c; } function poissonDiskSample(width, height, minDist, maxDist, tries = 20) { const cellSize = minDist / Math.SQRT2; const gridW = Math.ceil(width / cellSize); const gridH = Math.ceil(height / cellSize); const grid = new Int32Array(gridW * gridH).fill(-1); const points = []; const active = []; function gridIdx(x, y) { return Math.floor(y / cellSize) * gridW + Math.floor(x / cellSize); } function addPoint(x, y) { const i = points.length; points.push([x, y]); active.push(i); grid[gridIdx(x, y)] = i; return i; } function inNeighbourhood(px, py) { const gx = Math.floor(px / cellSize); const gy = Math.floor(py / cellSize); for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { const nx = gx + dx, ny = gy + dy; if (nx < 0 || ny < 0 || nx >= gridW || ny >= gridH) continue; const idx = grid[ny * gridW + nx]; if (idx === -1) continue; const [ex, ey] = points[idx]; const d = (px - ex) ** 2 + (py - ey) ** 2; if (d < minDist * minDist) return true; } } return false; } addPoint(width / 2, height / 2); while (active.length > 0) { const ri = Math.floor(Math.random() * active.length); const [cx, cy] = points[active[ri]]; let found = false; for (let t = 0; t < tries; t++) { const angle = Math.random() * Math.PI * 2; const dist = minDist + Math.random() * (maxDist - minDist); const nx = cx + Math.cos(angle) * dist; const ny = cy + Math.sin(angle) * dist; if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; if (!inNeighbourhood(nx, ny)) { addPoint(nx, ny); found = true; break; } } if (!found) active.splice(ri, 1); } return points; } /* ─────────────── MAIN INIT ─────────────── */ function initBackground() { const canvas = document.getElementById('webgl-bg'); if (!canvas) return; const theme = CONFIG.theme; const colors = CONFIG[theme]; const colorScheme = theme === 'dark' ? 0 : 1; const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: 'high-performance', stencil: false, precision: 'highp' }); const pixelRatio = Math.min(window.devicePixelRatio, 2); renderer.setPixelRatio(pixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); const scene = new THREE.Scene(); scene.background = new THREE.Color(colors.bg); const camera = new THREE.PerspectiveCamera( 40, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.z = CONFIG.cameraZoom; /* ── Create particle base positions with Poisson disk sampling ── */ const SIZE = CONFIG.gridSize; // 256 const count = SIZE * SIZE; // 65536 const minDist = linearMap(CONFIG.density, 0, 300, 10, 2); const maxDist = linearMap(CONFIG.density, 0, 300, 11, 3); const poissonPoints = poissonDiskSample(500, 500, minDist, maxDist, 20); // Create position data texture from Poisson points const posData = new Float32Array(count * 4); for (let i = 0; i < count; i++) { const pi = i % poissonPoints.length; const pt = poissonPoints[pi]; posData[i * 4 + 0] = (pt[0] - 250) * (1 / 250); posData[i * 4 + 1] = (pt[1] - 250) * (1 / 250); posData[i * 4 + 2] = 0; // scale posData[i * 4 + 3] = 0; // velocity } const posTex = new THREE.DataTexture(posData, SIZE, SIZE, THREE.RGBAFormat, THREE.FloatType); posTex.needsUpdate = true; const refTex = new THREE.DataTexture(posData.slice(), SIZE, SIZE, THREE.RGBAFormat, THREE.FloatType); refTex.needsUpdate = true; /* ── Render targets (GPGPU ping-pong) ── */ const rtParams = { wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, format: THREE.RGBAFormat, type: THREE.FloatType, depthBuffer: false, stencilBuffer: false }; let rt1 = new THREE.WebGLRenderTarget(SIZE, SIZE, rtParams); let rt2 = new THREE.WebGLRenderTarget(SIZE, SIZE, rtParams); // Initialize render targets with position data const initScene = new THREE.Scene(); const initCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const initMat = new THREE.ShaderMaterial({ uniforms: { uTex: { value: posTex } }, vertexShader: `void main(){ gl_Position = vec4(position, 1.0); }`, fragmentShader: ` precision highp float; uniform sampler2D uTex; void main(){ vec2 uv = gl_FragCoord.xy / vec2(${SIZE}.0); gl_FragColor = texture2D(uTex, uv); } ` }); initScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), initMat)); renderer.setRenderTarget(rt1); renderer.render(initScene, initCamera); renderer.setRenderTarget(rt2); renderer.render(initScene, initCamera); renderer.setRenderTarget(null); /* ── Simulation material (GPGPU) ── */ const simMaterial = new THREE.ShaderMaterial({ uniforms: { uPosition: { value: rt1.texture }, uPosRefs: { value: refTex }, uMousePos: { value: new THREE.Vector2(0, 0) }, uRingRadius: { value: 0.2 }, uDeltaTime: { value: 0 }, uRingWidth: { value: CONFIG.ringWidth }, uRingWidth2: { value: CONFIG.ringWidth2 }, uIsHovering: { value: 0 }, uRingDisplacement: { value: CONFIG.ringDisplacement }, uTime: { value: 0 } }, vertexShader: `void main(){ gl_Position = vec4(position, 1.0); }`, fragmentShader: simFragmentShader }); const simScene = new THREE.Scene(); simScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), simMaterial)); const simCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); /* ── Particle render material ── */ const particleScale = (window.innerWidth / pixelRatio) / 2000 * CONFIG.particlesScale; const particleGeo = new THREE.BufferGeometry(); const positions = new Float32Array(count * 3); const uvs = new Float32Array(count * 2); const seeds = new Float32Array(count * 4); for (let i = 0; i < count; i++) { const col = i % SIZE; const row = Math.floor(i / SIZE); uvs[i * 2] = col / SIZE; uvs[i * 2 + 1] = row / SIZE; seeds[i * 4] = Math.random(); seeds[i * 4 + 1] = Math.random(); seeds[i * 4 + 2] = Math.random(); seeds[i * 4 + 3] = Math.random(); } particleGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); particleGeo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); particleGeo.setAttribute('seeds', new THREE.BufferAttribute(seeds, 4)); const renderMaterial = new THREE.ShaderMaterial({ uniforms: { uPosition: { value: rt1.texture }, uTime: { value: 0 }, uColor1: { value: new THREE.Color(colors.color1) }, uColor2: { value: new THREE.Color(colors.color2) }, uColor3: { value: new THREE.Color(colors.color3) }, uAlpha: { value: 1.0 }, uIsHovering: { value: 0 }, uPulseProgress: { value: 0 }, uMousePos: { value: new THREE.Vector2(0, 0) }, uRez: { value: new THREE.Vector2(window.innerWidth * pixelRatio, window.innerHeight * pixelRatio) }, uParticleScale: { value: particleScale }, uPixelRatio: { value: pixelRatio }, uColorScheme: { value: colorScheme } }, vertexShader: renderVertexShader, fragmentShader: renderFragmentShader, transparent: true, depthTest: false, depthWrite: false }); const mesh = new THREE.Points(particleGeo, renderMaterial); mesh.scale.set(5, -5, 5); scene.add(mesh); /* ── Mouse interaction ── */ const mousePos = new THREE.Vector2(0, 0); let pulseProgress = 0; let pulseActive = false; let pulseStart = 0; window.addEventListener('pointermove', (e) => { const rect = canvas.getBoundingClientRect(); const mx = (e.clientX - rect.left) / rect.width * 2 - 1; const my = -((e.clientY - rect.top) / rect.height) * 2 + 1; mousePos.set(mx * 0.175, my * 0.175); }); /* ── Animation loop ── */ const clock = new THREE.Clock(); let lastTime = 0; function animate() { requestAnimationFrame(animate); const elapsed = clock.getElapsedTime(); const dt = elapsed - lastTime; lastTime = elapsed; // Pulse wave if (pulseActive) { pulseProgress = (elapsed - pulseStart) / 2; if (pulseProgress > 1) { pulseActive = false; pulseProgress = 0; } } // Update simulation uniforms simMaterial.uniforms.uPosition.value = rt1.texture; simMaterial.uniforms.uTime.value = elapsed; simMaterial.uniforms.uDeltaTime.value = dt; simMaterial.uniforms.uRingRadius.value = 0.175 + Math.sin(elapsed) * 0.03 + Math.cos(elapsed * 3) * 0.02; simMaterial.uniforms.uMousePos.value = mousePos; // GPGPU simulation pass renderer.setRenderTarget(rt2); renderer.render(simScene, simCamera); renderer.setRenderTarget(null); // Update render uniforms renderMaterial.uniforms.uPosition.value = rt2.texture; renderMaterial.uniforms.uTime.value = elapsed; renderMaterial.uniforms.uMousePos.value = mousePos; renderMaterial.uniforms.uPulseProgress.value = pulseProgress; // Render particles renderer.autoClear = false; renderer.clear(); renderer.render(scene, camera); // Ping-pong swap const tmp = rt1; rt1 = rt2; rt2 = tmp; } animate(); // Trigger initial pulse pulseActive = true; pulseStart = clock.getElapsedTime(); /* ── Resize handler ── */ window.addEventListener('resize', () => { const w = window.innerWidth; const h = window.innerHeight; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); renderMaterial.uniforms.uRez.value.set(w * pixelRatio, h * pixelRatio); renderMaterial.uniforms.uPixelRatio.value = pixelRatio; renderMaterial.uniforms.uParticleScale.value = (w / pixelRatio) / 2000 * CONFIG.particlesScale; }); } document.addEventListener('DOMContentLoaded', initBackground);