| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import * as THREE from 'https://esm.sh/three@0.168.0'; |
|
|
| const COLS = 140; |
| const ROWS = 48; |
| const STAGE_XS = [-0.8, -0.4, 0.0, 0.4, 0.8]; |
| const PARTICLES_PER_STAGE_PER_SIDE = 4; |
|
|
| |
| |
| |
| |
| |
| |
| function buildParticlesGeometry() { |
| const total = STAGE_XS.length * PARTICLES_PER_STAGE_PER_SIDE * 2; |
| |
| |
| const position = new Float32Array(total * 3); |
| const aStageX = new Float32Array(total); |
| const aNormOffset = new Float32Array(total); |
| const aStartY = new Float32Array(total); |
| const aSpawnOffset = new Float32Array(total); |
| const aSpeed = new Float32Array(total); |
|
|
| let i = 0; |
| for (let s = 0; s < STAGE_XS.length; s++) { |
| const sx = STAGE_XS[s]; |
| for (let side = 0; side < 2; side++) { |
| const dir = side === 0 ? 1 : -1; |
| for (let k = 0; k < PARTICLES_PER_STAGE_PER_SIDE; k++) { |
| aStageX[i] = sx; |
| aNormOffset[i] = (Math.random() - 0.5) * 1.7; |
| aStartY[i] = dir * (0.82 + Math.random() * 0.16); |
| aSpawnOffset[i] = Math.random() * 3.0; |
| aSpeed[i] = 0.28 + Math.random() * 0.22; |
| i++; |
| } |
| } |
| } |
|
|
| const geo = new THREE.BufferGeometry(); |
| geo.setAttribute('position', new THREE.BufferAttribute(position, 3)); |
| geo.setAttribute('aStageX', new THREE.BufferAttribute(aStageX, 1)); |
| geo.setAttribute('aNormOffset', new THREE.BufferAttribute(aNormOffset, 1)); |
| geo.setAttribute('aStartY', new THREE.BufferAttribute(aStartY, 1)); |
| geo.setAttribute('aSpawnOffset', new THREE.BufferAttribute(aSpawnOffset, 1)); |
| geo.setAttribute('aSpeed', new THREE.BufferAttribute(aSpeed, 1)); |
| return geo; |
| } |
|
|
| const PARTICLE_VERT = ` |
| attribute float aStageX; |
| attribute float aNormOffset; |
| attribute float aStartY; |
| attribute float aSpawnOffset; |
| attribute float aSpeed; |
| uniform float uTime; |
| uniform float uPointSize; |
| uniform float uNarrow; // must match the mesh material |
| uniform float uWide; |
| varying float vAlpha; |
| varying float vTop; |
| |
| void main() { |
| // Normalised lifetime progress in [0, 1], looping. |
| float t = fract((uTime + aSpawnOffset) * aSpeed); |
| |
| // y descends from |aStartY| toward 0 along the same easing used |
| // by the mesh pinch. |
| float curY = mix(aStartY, 0.0, smoothstep(0.0, 1.0, t)); |
| |
| // Hourglass width at the particle's current height — identical |
| // profile to the mesh so particles stay inside the visible grid. |
| float yF = smoothstep(0.0, 1.0, abs(curY)); |
| float width = mix(uNarrow, uWide, yF); |
| |
| // x is offset from the stage centre by a fixed fraction of the |
| // column width, so the particle follows the column's taper. |
| float curX = aStageX + aNormOffset * width; |
| |
| // Fade in at spawn, fade out just before the waist. |
| float fadeIn = smoothstep(0.0, 0.12, t); |
| float fadeOut = smoothstep(1.0, 0.78, t); |
| vAlpha = fadeIn * fadeOut; |
| vTop = aStartY > 0.0 ? 1.0 : 0.0; |
| |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(curX, curY, 0.0, 1.0); |
| gl_PointSize = uPointSize; |
| } |
| `; |
|
|
| const PARTICLE_FRAG = ` |
| uniform vec3 uAccentCool; // blue (from top, Edge) |
| uniform vec3 uAccentWarm; // terracotta (from bottom, Multilinguality) |
| uniform float uOpacity; |
| varying float vAlpha; |
| varying float vTop; |
| |
| void main() { |
| vec2 uv = gl_PointCoord - 0.5; |
| float d = length(uv); |
| if (d > 0.5) discard; |
| // Soft disc with a slight core brightness. |
| float shape = smoothstep(0.5, 0.12, d); |
| vec3 col = mix(uAccentWarm, uAccentCool, vTop); |
| gl_FragColor = vec4(col, vAlpha * shape * uOpacity); |
| } |
| `; |
|
|
| function buildGridGeometry() { |
| const positions = []; |
| for (let y = 0; y < ROWS; y++) { |
| for (let x = 0; x < COLS; x++) { |
| const px = (x / (COLS - 1)) * 2 - 1; |
| const py = (y / (ROWS - 1)) * 2 - 1; |
| positions.push(px, py, 0); |
| } |
| } |
|
|
| const indices = []; |
| for (let y = 0; y < ROWS; y++) { |
| for (let x = 0; x < COLS - 1; x++) { |
| indices.push(y * COLS + x, y * COLS + x + 1); |
| } |
| } |
| for (let y = 0; y < ROWS - 1; y++) { |
| for (let x = 0; x < COLS; x++) { |
| indices.push(y * COLS + x, (y + 1) * COLS + x); |
| } |
| } |
|
|
| const geo = new THREE.BufferGeometry(); |
| geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); |
| geo.setIndex(indices); |
| return geo; |
| } |
|
|
| const VERT = ` |
| uniform float uTime; |
| uniform float uNarrow; // column half-width at the pipeline waist |
| uniform float uWide; // column half-width at the top/bottom edges |
| varying float vMass; // 0 = whitespace, 1 = inside a flow column |
| varying float vMid; // 1 = at pipeline centre, 0 = at edges |
| varying float vY; // original y, for flow-band animation |
| |
| void main() { |
| vec3 pos = position; |
| float t = uTime; |
| vY = pos.y; // keep the untransformed y for the fragment shader |
| |
| // Five equal-spaced stage columns, matching the .pipeline-segment |
| // positions on the strip. |
| float stageXs[5]; |
| stageXs[0] = -0.8; |
| stageXs[1] = -0.4; |
| stageXs[2] = 0.0; |
| stageXs[3] = 0.4; |
| stageXs[4] = 0.8; |
| |
| // ---- 1. Mass (where the column renders) ---- |
| // Smoothstep gives an S-curve hourglass: the waist glides into |
| // the wide ends instead of kinking. |
| float yFactor = smoothstep(0.0, 1.0, abs(pos.y)); |
| float width = mix(uNarrow, uWide, yFactor); |
| width *= 1.0 + 0.05 * sin(t * 0.35); |
| |
| float mass = 0.0; |
| float nearStage = stageXs[0]; |
| float minD = 10.0; |
| for (int i = 0; i < 5; i++) { |
| float dx = pos.x - stageXs[i]; |
| mass = max(mass, exp(-dx * dx / (width * width))); |
| float d = abs(dx); |
| if (d < minD) { minD = d; nearStage = stageXs[i]; } |
| } |
| |
| // ---- 2. Pinch (lines curve inward toward the stage centre |
| // as they approach the pipeline) ---- |
| float pipeN = 1.0 - abs(pos.y); |
| float pinchT = smoothstep(0.0, 1.0, pipeN); |
| pos.x = mix(pos.x, nearStage, pinchT * 0.7); |
| |
| // ---- 3. Subtle drift so the mesh is never fully static ---- |
| pos.y += sin(pos.x * 5.5 + t * 0.45) * 0.010 * mass; |
| pos.x += cos(pos.y * 4.0 + t * 0.3 ) * 0.004 * mass; |
| |
| vMass = mass; |
| vMid = 1.0 - abs(pos.y); |
| |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); |
| } |
| `; |
|
|
| const FRAG = ` |
| uniform vec3 uInk; |
| uniform vec3 uAccentCool; // Edge — blue, top half |
| uniform vec3 uAccentWarm; // Multilinguality — terracotta, bottom half |
| uniform float uOpacity; |
| uniform float uTime; |
| varying float vMass; |
| varying float vMid; |
| varying float vY; |
| |
| void main() { |
| // Top/bottom tint selection — top half skews blue, bottom half |
| // skews terracotta, blended smoothly across the waist. |
| float topWeight = smoothstep(-0.15, 0.15, vY); |
| // Subtle ambient tint throughout (0.22) that strengthens at the |
| // waist (up to ~0.85) for the collision emphasis. |
| float midEmph = smoothstep(0.45, 1.0, vMid); |
| float tintAmt = 0.22 + midEmph * 0.65; |
| |
| vec3 topCol = mix(uInk, uAccentCool, tintAmt); |
| vec3 botCol = mix(uInk, uAccentWarm, tintAmt); |
| vec3 col = mix(botCol, topCol, topWeight); |
| |
| // Flow bands: soft crests travel FROM the edges TOWARD the |
| // middle. Wider wavelength + slower speed than before, so the |
| // motion reads as breathing rather than strobing. |
| float flowPhase = abs(vY) * 4.5 + uTime * 1.2; |
| float flow = 0.55 + 0.45 * sin(flowPhase); |
| |
| // Base boost: the top and bottom edges of the mesh (where it |
| // meets the requirement bands) get extra opacity so the "force |
| // origin" reads clearly. |
| float baseBoost = smoothstep(0.55, 1.0, abs(vY)); |
| |
| float waistPulse = 0.78 + 0.22 * sin(uTime * 1.1); |
| float a = uOpacity |
| * vMass |
| * (0.35 + flow * 0.7) |
| * (0.7 + midEmph * waistPulse * 0.55); |
| a *= 1.0 + baseBoost * 0.9; |
| |
| gl_FragColor = vec4(col, a); |
| } |
| `; |
|
|
| function mountPipelineMesh(container) { |
| if (!container) return; |
|
|
| if (window.matchMedia && |
| window.matchMedia('(prefers-reduced-motion: reduce)').matches) { |
| return; |
| } |
|
|
| const canvas = document.createElement('canvas'); |
| canvas.className = 'pipeline-mesh-canvas'; |
| canvas.setAttribute('aria-hidden', 'true'); |
| container.insertBefore(canvas, container.firstChild); |
|
|
| let renderer; |
| try { |
| renderer = new THREE.WebGLRenderer({ |
| canvas, |
| antialias: true, |
| alpha: true, |
| }); |
| } catch (e) { |
| canvas.remove(); |
| return; |
| } |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); |
| renderer.setClearColor(0x000000, 0); |
|
|
| const scene = new THREE.Scene(); |
| const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); |
| camera.position.z = 1; |
|
|
| const geometry = buildGridGeometry(); |
| const material = new THREE.ShaderMaterial({ |
| uniforms: { |
| uTime: { value: 0 }, |
| uInk: { value: new THREE.Color(0x35302e) }, |
| uAccentCool: { value: new THREE.Color(0x254eff) }, |
| uAccentWarm: { value: new THREE.Color(0xc96a2e) }, |
| uOpacity: { value: 0.55 }, |
| uNarrow: { value: 0.10 }, |
| uWide: { value: 0.28 }, |
| }, |
| vertexShader: VERT, |
| fragmentShader: FRAG, |
| transparent: true, |
| }); |
|
|
| const mesh = new THREE.LineSegments(geometry, material); |
| scene.add(mesh); |
|
|
| |
| |
| const particleMaterial = new THREE.ShaderMaterial({ |
| uniforms: { |
| uTime: material.uniforms.uTime, |
| uAccentCool: material.uniforms.uAccentCool, |
| uAccentWarm: material.uniforms.uAccentWarm, |
| uNarrow: material.uniforms.uNarrow, |
| uWide: material.uniforms.uWide, |
| uOpacity: { value: 0.9 }, |
| uPointSize: { value: 4.0 }, |
| }, |
| vertexShader: PARTICLE_VERT, |
| fragmentShader: PARTICLE_FRAG, |
| transparent: true, |
| depthTest: false, |
| }); |
| const particles = new THREE.Points(buildParticlesGeometry(), particleMaterial); |
| scene.add(particles); |
|
|
| function resize() { |
| const { width, height } = container.getBoundingClientRect(); |
| if (width === 0 || height === 0) return; |
| renderer.setSize(width, height, false); |
| |
| |
| const dpr = renderer.getPixelRatio(); |
| particleMaterial.uniforms.uPointSize.value = Math.min(6.0, Math.max(3.0, width / 240)) * dpr; |
| } |
| resize(); |
|
|
| const ro = new ResizeObserver(resize); |
| ro.observe(container); |
|
|
| const clock = new THREE.Clock(); |
| let running = !document.hidden; |
|
|
| document.addEventListener('visibilitychange', () => { |
| running = !document.hidden; |
| if (running) clock.start(); |
| }); |
|
|
| function frame() { |
| requestAnimationFrame(frame); |
| if (!running) return; |
| material.uniforms.uTime.value = clock.getElapsedTime(); |
| renderer.render(scene, camera); |
| } |
| frame(); |
| } |
|
|
| document.addEventListener('DOMContentLoaded', () => { |
| const container = document.getElementById('pipeline-figure'); |
| mountPipelineMesh(container); |
| }); |
|
|