mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(landing): full-viewport GPU node-engine hero + waitlist + dashboard auth removal
The /launch landing page (bleeding-edge waitlist hero): - Full-viewport WebGL node engine (src/lib/hero/nodeEngine.ts): 40k GPU particles on a two-FBO GPUComputationRenderer running an 18s looping cinematic — particles stream in from the screen edges, slam together and EXPLODE at center, reform into a brain / graph constellation / neural lattice, dissolve back out, loop. Glowing additive particles + UnrealBloom, fills the whole screen edge to edge. - Key GPGPU fix: custom DataTexture shape targets read black in GPUComputationRenderer (three.js #15882), so shape targets are computed PROCEDURALLY in-shader from the per-particle seed. Position integrates raw per-frame velocity (Codrops pattern), texel-center reference attribute. - AmbientField (god-ray glow + parallax starfield), phantomBrain (deterministic seed-from-identity memory graph), LandingHero/neuralFlow (WebGPU Cinema-storm reuse + WebGL fallback, kept as alternates). Manifesto copy, seed input, email capture. No em-dashes. SSR off + prerender for the WebGL route. Waitlist backend (Supabase, owned data): - supabase/migrations/0001_waitlist.sql (RLS-locked table, dedup), Edge Function waitlist-join (CORS, validation, honeypot, dedup, Resend confirmation), setup doc. Dashboard auth removed (reverts the launch-polish auth wall that 401'd the dashboard against itself): mod.rs/state.rs/websocket.rs back to no-token, api.ts/websocket.ts/ +layout.svelte stripped of the token client. Pending-review UX + Memory PR gating kept. Research/spec docs under docs/launch/. Frontend typechecks 0/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
335a42f341
commit
cfe8d03d36
26 changed files with 3002 additions and 266 deletions
|
|
@ -1,6 +1,8 @@
|
|||
# Optional public waitlist capture endpoint used by /dashboard/waitlist.
|
||||
# The page POSTs JSON with: name, email, plan, priority, notes, source, createdAt.
|
||||
# Examples: Formspree, Tally webhook, Buttondown custom endpoint, Supabase Edge Function.
|
||||
# Vestige ships a Supabase Edge Function for this — see docs/launch/waitlist-setup.md.
|
||||
# Format: https://<project-ref>.functions.supabase.co/waitlist-join
|
||||
# (Formspree / Tally / Buttondown endpoints also work — anything that accepts the POST.)
|
||||
VITE_WAITLIST_ENDPOINT=
|
||||
|
||||
# Optional support bot endpoint used by /dashboard/waitlist.
|
||||
|
|
|
|||
54
apps/dashboard/src/lib/hero/HeroNodeEngine.svelte
Normal file
54
apps/dashboard/src/lib/hero/HeroNodeEngine.svelte
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script lang="ts">
|
||||
// Mounts the full-viewport NodeEngine (edge -> converge -> EXPLODE -> reform
|
||||
// -> loop) as the fullscreen landing hero. Reseeds per visitor.
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { NodeEngine } from '$lib/hero/nodeEngine';
|
||||
|
||||
interface Props {
|
||||
seed?: number;
|
||||
reducedMotion?: boolean;
|
||||
onTextBeat?: (isText: boolean) => void;
|
||||
}
|
||||
let { seed = 1234, reducedMotion = false, onTextBeat }: Props = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||
let engine: NodeEngine | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (host) {
|
||||
try {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const fp = qs.get('phase');
|
||||
const fs = qs.get('shape');
|
||||
const forcePhase = fp !== null ? parseFloat(fp) : undefined;
|
||||
const forceShape = fs !== null ? parseInt(fs, 10) : undefined;
|
||||
engine = new NodeEngine(host, { seed, reducedMotion, forcePhase, forceShape, onTextBeat });
|
||||
} catch (e) {
|
||||
console.warn('[hero] NodeEngine failed to boot:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
engine?.dispose();
|
||||
engine = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="node-stage" bind:this={host} aria-hidden="true"></div>
|
||||
|
||||
<style>
|
||||
.node-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
}
|
||||
.node-stage :global(canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
717
apps/dashboard/src/lib/hero/nodeEngine.ts
Normal file
717
apps/dashboard/src/lib/hero/nodeEngine.ts
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
// Full-viewport Node Engine — the legendary landing cinematic.
|
||||
//
|
||||
// ~40k GPU particles (200x200 two-FBO GPGPU sim) running one looping 4-beat
|
||||
// cinematic that OWNS the entire viewport:
|
||||
// STREAM particles fly in from beyond all four screen edges toward center
|
||||
// EXPLODE they slam together and a one-shot radial blast fills the frame
|
||||
// REFORM they spring onto a shape (brain -> graph constellation -> lattice)
|
||||
// DISSOLVE they drift back out to the edge shell, seamlessly restarting
|
||||
//
|
||||
// Separate from Memory Cinema (src/lib/graph/cinema/*) which must NOT be touched.
|
||||
// WebGL2 only, ships everywhere. Built from a researched spec
|
||||
// (docs/launch/node-engine-spec.json).
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js';
|
||||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||
|
||||
const LOOP = 18.0; // seconds per full cinematic loop
|
||||
|
||||
// ---- shared GLSL noise (simplex + curl) ------------------------------------
|
||||
const NOISE = /* glsl */ `
|
||||
vec3 hash3(vec3 p){
|
||||
p = vec3(dot(p,vec3(127.1,311.7,74.7)),dot(p,vec3(269.5,183.3,246.1)),dot(p,vec3(113.5,271.9,124.6)));
|
||||
return -1.0+2.0*fract(sin(p)*43758.5453123);
|
||||
}
|
||||
float snoise(vec3 p){
|
||||
vec3 i=floor(p),f=fract(p),u=f*f*(3.0-2.0*f);
|
||||
return mix(mix(mix(dot(hash3(i+vec3(0,0,0)),f-vec3(0,0,0)),dot(hash3(i+vec3(1,0,0)),f-vec3(1,0,0)),u.x),
|
||||
mix(dot(hash3(i+vec3(0,1,0)),f-vec3(0,1,0)),dot(hash3(i+vec3(1,1,0)),f-vec3(1,1,0)),u.x),u.y),
|
||||
mix(mix(dot(hash3(i+vec3(0,0,1)),f-vec3(0,0,1)),dot(hash3(i+vec3(1,0,1)),f-vec3(1,0,1)),u.x),
|
||||
mix(dot(hash3(i+vec3(0,1,1)),f-vec3(0,1,1)),dot(hash3(i+vec3(1,1,1)),f-vec3(1,1,1)),u.x),u.y),u.z);
|
||||
}
|
||||
vec3 snoiseVec3(vec3 p){ return vec3(snoise(p),snoise(p+vec3(17.1,9.2,3.3)),snoise(p+vec3(101.7,5.4,71.2))); }
|
||||
float h11(float p){ return fract(sin(p*127.1)*43758.5453); }
|
||||
float h1(float p){ return fract(sin(p*78.233)*12543.531); }
|
||||
|
||||
#define TAU 6.28318530718
|
||||
|
||||
// --- UNEXPECTED math shapes (researched, real parametric/ODE point clouds) ---
|
||||
|
||||
// Aizawa strange attractor: chaotic "thinking" sculpture, toroidal shell + axial spike.
|
||||
vec3 shapeAizawa(float a, float b, float c, float S){
|
||||
vec3 p = (vec3(fract(a*43.0+0.13), fract(b*91.7+0.41), fract(c*57.3+0.77)) - 0.5) * 0.12;
|
||||
const float A=0.95, B=0.7, C=0.6, D=3.5, E=0.25, F=0.1, dt=0.01;
|
||||
int iters = 260 + int(a*140.0);
|
||||
for(int i=0;i<400;i++){
|
||||
if(i>=iters) break;
|
||||
float dx = (p.z - B)*p.x - D*p.y;
|
||||
float dy = D*p.x + (p.z - B)*p.y;
|
||||
float dz = C + A*p.z - p.z*p.z*p.z/3.0 - (p.x*p.x + p.y*p.y)*(1.0 + E*p.z) + F*p.z*p.x*p.x*p.x;
|
||||
p += vec3(dx,dy,dz)*dt;
|
||||
}
|
||||
// the Aizawa body is centered around ~(0,0,0.7); shift z to center it in frame
|
||||
p.z -= 0.7;
|
||||
return p.xzy * (0.46 * S); // swap y/z so the axial spike stands upright
|
||||
}
|
||||
|
||||
// Hopf fibration: ~12 interlocking rings, every ring links every other once.
|
||||
vec3 shapeHopf(float a, float b, float c, float S){
|
||||
float ringId = floor(a * 12.0);
|
||||
float cosT = 2.0*fract(ringId*0.61803398875) - 1.0;
|
||||
float baseTheta = acos(clamp(cosT,-1.0,1.0));
|
||||
float basePhi = TAU * fract(ringId*0.7548776662);
|
||||
vec3 bp = vec3(sin(baseTheta)*cos(basePhi), sin(baseTheta)*sin(basePhi), cos(baseTheta));
|
||||
float t = TAU * b;
|
||||
float k = 1.0 / sqrt(2.0*(1.0 + bp.z) + 1e-4);
|
||||
vec4 P = vec4((1.0+bp.z)*cos(t), bp.x*sin(t)-bp.y*cos(t), bp.x*cos(t)+bp.y*sin(t), (1.0+bp.z)*sin(t)) * k;
|
||||
vec3 q = P.xyz / (1.0 - P.w + 1e-3);
|
||||
q += (vec3(fract(c*71.3),fract(c*131.7),fract(c*197.1))-0.5)*0.04;
|
||||
return q * (0.28 * S);
|
||||
}
|
||||
|
||||
// (p,q) torus knot inflated into a volumetric tube — a glowing self-tied rope.
|
||||
vec3 shapeTorusKnot(float a, float b, float c, float S){
|
||||
const float P = 3.0, Q = 7.0, R = 1.0, rMinor = 0.45, rTube = 0.16;
|
||||
float t = a * TAU;
|
||||
float cq = cos(Q*t), sq = sin(Q*t);
|
||||
float ring = R + rMinor*cq;
|
||||
vec3 Cc = vec3(ring*cos(P*t), ring*sin(P*t), rMinor*sq);
|
||||
vec3 T = normalize(vec3(-rMinor*Q*sq*cos(P*t) - ring*P*sin(P*t),
|
||||
-rMinor*Q*sq*sin(P*t) + ring*P*cos(P*t), rMinor*Q*cq));
|
||||
vec3 N = normalize(cross(T, vec3(0.0,0.0,1.0)));
|
||||
vec3 Bn = cross(T, N);
|
||||
float ang = b * TAU; float rad = sqrt(c) * rTube;
|
||||
return (Cc + rad*(cos(ang)*N + sin(ang)*Bn)) * (0.55 * S);
|
||||
}
|
||||
|
||||
// DNA double helix: two strands + base-pair rungs.
|
||||
vec3 shapeDNA(float a, float b, float c, float S){
|
||||
const float TWISTS = 6.0, HEIGHT = 2.0, RADIUS = 0.55;
|
||||
float yy = (a - 0.5) * HEIGHT;
|
||||
float ang = a * TWISTS * TAU;
|
||||
vec3 sA = vec3(cos(ang)*RADIUS, yy, sin(ang)*RADIUS);
|
||||
vec3 sB = vec3(cos(ang+3.14159)*RADIUS, yy, sin(ang+3.14159)*RADIUS);
|
||||
vec3 pos;
|
||||
if(b < 0.42) pos = sA; else if(b < 0.84) pos = sB; else pos = mix(sA, sB, fract(b*53.0));
|
||||
float rad = sqrt(c)*0.06; float ja = fract(c*131.7)*TAU, jb = fract(c*71.3)*3.14159;
|
||||
pos += rad * vec3(sin(jb)*cos(ja), cos(jb), sin(jb)*sin(ja));
|
||||
return pos * (0.62 * S);
|
||||
}
|
||||
|
||||
// Spiral galaxy: 4 log-spiral arms + bright spherical core.
|
||||
vec3 shapeGalaxy(float a, float b, float c, float S){
|
||||
const float N_ARMS = 4.0, TWIST = 2.6;
|
||||
float arm = floor(a * N_ARMS);
|
||||
float t = sqrt(b); float r = t;
|
||||
float jit = (fract(a*97.13) - 0.5) * 0.35 * (1.0 - t);
|
||||
float ang = arm * TAU / N_ARMS + t * TWIST * TAU + jit;
|
||||
float yy = (fract(c*53.7) - 0.5) * 0.14 * exp(-2.5*r);
|
||||
vec3 pos = vec3(cos(ang)*r, yy, sin(ang)*r);
|
||||
if(b < 0.06){
|
||||
float rr = pow(fract(c*191.3), 0.5) * 0.12;
|
||||
float th = fract(a*311.7)*TAU, ph = acos(2.0*fract(c*131.1)-1.0);
|
||||
pos = vec3(rr*sin(ph)*cos(th), rr*cos(ph), rr*sin(ph)*sin(th));
|
||||
}
|
||||
// tilt the disc ~58deg around X so we see the spiral face, not edge-on
|
||||
float ca = 0.53, sa = 0.85;
|
||||
pos = vec3(pos.x, pos.y*ca - pos.z*sa, pos.y*sa + pos.z*ca);
|
||||
return pos * (1.15 * S);
|
||||
}
|
||||
|
||||
// Gielis superformula supershape: a spiky alien crystal sea-urchin.
|
||||
float superR(float ang, float m, float n1, float n2, float n3){
|
||||
float t = m * ang * 0.25;
|
||||
float c1 = pow(abs(cos(t)), n2);
|
||||
float c2 = pow(abs(sin(t)), n3);
|
||||
return pow(c1 + c2 + 1e-6, -1.0/n1);
|
||||
}
|
||||
vec3 shapeSupershape(float a, float b, float c, float S){
|
||||
const float PI = 3.14159265359;
|
||||
float theta = (a*2.0 - 1.0) * PI;
|
||||
float phi = (b - 0.5) * PI;
|
||||
float m=7.0, n1=0.2, n2=1.7, n3=1.7;
|
||||
float r1 = superR(theta, m, n1, n2, n3);
|
||||
float r2 = superR(phi, m, n1, n2, n3);
|
||||
vec3 pos = vec3(r1*cos(theta)*r2*cos(phi), r1*sin(theta)*r2*cos(phi), r2*sin(phi));
|
||||
pos = normalize(pos) * pow(length(pos), 0.6);
|
||||
pos *= (1.0 + (c - 0.5)*0.04);
|
||||
return pos * (0.85 * S);
|
||||
}
|
||||
|
||||
// TEXT beat: particles land where the launch-message mask alpha is high,
|
||||
// spelling VESTIGE / JULY 14TH / SIGN UP NOW. Strays relax up the alpha
|
||||
// gradient so the message reads crisp.
|
||||
vec3 shapeText(float a, float b, float c, float S){
|
||||
vec2 uv = vec2(a, b);
|
||||
for(int i=0;i<5;i++){
|
||||
float al = texture2D(uMask, uv).r;
|
||||
if(al > 0.5) break;
|
||||
float e = 1.0/256.0;
|
||||
float gx = texture2D(uMask, uv+vec2(e,0.0)).r - texture2D(uMask, uv-vec2(e,0.0)).r;
|
||||
float gy = texture2D(uMask, uv+vec2(0.0,e)).r - texture2D(uMask, uv-vec2(0.0,e)).r;
|
||||
uv += normalize(vec2(gx,gy) + 1e-5) * 0.025;
|
||||
}
|
||||
float depth = (c - 0.5) * 0.08; // thin slab so it isn't perfectly flat
|
||||
// map UV (0..1) to centered world; flip v (canvas is top-down); aspect-correct.
|
||||
// Scale so the wide message fits the frame width (not height).
|
||||
vec3 pos = vec3((uv.x - 0.5) * uMaskAspect, -(uv.y - 0.5), depth);
|
||||
return pos * (0.62 * S);
|
||||
}
|
||||
|
||||
// Procedural shape targets computed from the per-particle seed. Computed in
|
||||
// shader (NOT sampled from a custom DataTexture, which renders black in GPGPU).
|
||||
// 0=BRAIN 1=GRAPH 2=LATTICE 3=AIZAWA 4=HOPF 5=KNOT 6=DNA 7=GALAXY 8=SUPERSHAPE
|
||||
vec3 shapeTarget(float seed, float shape, float S){
|
||||
float a = h11(seed*3.1), b = h1(seed*7.7), c = h11(seed*13.3+2.0);
|
||||
float ms = shape;
|
||||
if (ms > 2.5){
|
||||
if (ms < 3.5) return shapeAizawa(a,b,c,S);
|
||||
if (ms < 4.5) return shapeHopf(a,b,c,S);
|
||||
if (ms < 5.5) return shapeTorusKnot(a,b,c,S);
|
||||
if (ms < 6.5) return shapeDNA(a,b,c,S);
|
||||
if (ms < 7.5) return shapeGalaxy(a,b,c,S);
|
||||
return shapeSupershape(a,b,c,S);
|
||||
}
|
||||
if (ms < 0.5){
|
||||
// BRAIN: two lobes, SURFACE-weighted (r near 1) so it reads as a shell
|
||||
// with structure, not a fuzzy filled ball. Sulci ridges from noise.
|
||||
float r = mix(0.78, 1.0, pow(a, 0.5)); // most mass near the surface
|
||||
float theta = b*6.2831853, phi = acos(2.0*c-1.0);
|
||||
float lobe = (h11(seed*2.7) < 0.5) ? -1.0 : 1.0; // assign to a hemisphere
|
||||
float x = abs(sin(phi)*cos(theta)) * lobe;
|
||||
float y = cos(phi);
|
||||
float z = sin(phi)*sin(theta);
|
||||
// squash front-back, separate the two lobes, dimple the medial fissure
|
||||
vec3 p = vec3(x*0.78 + lobe*0.42, y*0.95, z*1.18);
|
||||
// sulci: fold the surface inward along noise ridges
|
||||
float ridges = snoise(p*3.4 + seed) * 0.12;
|
||||
p *= (1.0 + ridges);
|
||||
// flatten the bottom a touch (brain stem region)
|
||||
p.y *= (p.y < 0.0) ? 0.82 : 1.0;
|
||||
return p * r * S;
|
||||
} else if (ms < 1.5){
|
||||
// GRAPH CONSTELLATION: fibonacci-sphere node OR edge filament
|
||||
float M = 600.0;
|
||||
float k = floor(a*M);
|
||||
float yy = 1.0 - (k/(M-1.0))*2.0; float rr = sqrt(max(0.0,1.0-yy*yy));
|
||||
float t = 2.39996323*k;
|
||||
vec3 nodeA = vec3(cos(t)*rr, yy, sin(t)*rr)*S*1.2;
|
||||
if (b < 0.4) return nodeA + (snoiseVec3(vec3(seed))*0.06*S); // bright node
|
||||
float k2 = floor(c*M);
|
||||
float y2 = 1.0-(k2/(M-1.0))*2.0; float r2 = sqrt(max(0.0,1.0-y2*y2)); float t2=2.39996323*k2;
|
||||
vec3 nodeB = vec3(cos(t2)*r2, y2, sin(t2)*r2)*S*1.2;
|
||||
return mix(nodeA, nodeB, h1(seed*5.0)); // edge filament
|
||||
} else {
|
||||
// NEURAL LATTICE: hash-jittered 3D grid + struts
|
||||
float G = 6.0;
|
||||
vec3 cell = floor(vec3(a,b,c)*G);
|
||||
vec3 center = (cell+0.5)/G*2.0*S - S;
|
||||
float strut = h11(seed*17.0);
|
||||
if (strut < 0.4){
|
||||
float ax = floor(h1(seed*19.0)*3.0);
|
||||
float m = (h11(seed*23.0)-0.5)*(2.0*S/G);
|
||||
if (ax<0.5) center.x += m; else if (ax<1.5) center.y += m; else center.z += m;
|
||||
} else {
|
||||
center += (snoiseVec3(vec3(seed*2.0))*0.3*(2.0*S/G));
|
||||
}
|
||||
return center;
|
||||
}
|
||||
}
|
||||
vec3 curlNoise(vec3 p){
|
||||
const float e=0.1; vec3 dx=vec3(e,0,0),dy=vec3(0,e,0),dz=vec3(0,0,e);
|
||||
vec3 px0=snoiseVec3(p-dx),px1=snoiseVec3(p+dx);
|
||||
vec3 py0=snoiseVec3(p-dy),py1=snoiseVec3(p+dy);
|
||||
vec3 pz0=snoiseVec3(p-dz),pz1=snoiseVec3(p+dz);
|
||||
float x=py1.z-py0.z-pz1.y+pz0.y, y=pz1.x-pz0.x-px1.z+px0.z, z=px1.y-px0.y-py1.x+py0.x;
|
||||
return normalize(vec3(x,y,z)/(2.0*e)+1e-6);
|
||||
}
|
||||
float hash11(float p){ return fract(sin(p*127.1)*43758.5453); }
|
||||
`;
|
||||
|
||||
const POSITION_SHADER = /* glsl */ `
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy / resolution.xy;
|
||||
vec4 pT = texture2D(texturePosition, uv);
|
||||
vec3 vel = texture2D(textureVelocity, uv).xyz;
|
||||
// velocity is a per-frame delta (proven Codrops GPGPU pattern): add raw,
|
||||
// NOT vel*dt — otherwise particles crawl at 1/60th speed and never form.
|
||||
gl_FragColor = vec4(pT.xyz + vel, pT.w); // carry seed in .w
|
||||
}
|
||||
`;
|
||||
|
||||
const VELOCITY_SHADER = /* glsl */ `
|
||||
uniform float uTime, uPhaseT, uDt, uBlastTime, uDebug, uShape, uScale, uMaskAspect;
|
||||
uniform sampler2D texOrigin;
|
||||
uniform sampler2D uMask;
|
||||
${NOISE}
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy / resolution.xy;
|
||||
vec4 pT = texture2D(texturePosition, uv);
|
||||
vec3 pos = pT.xyz; float seed = pT.w;
|
||||
vec3 vel = texture2D(textureVelocity, uv).xyz;
|
||||
vec4 oT = texture2D(texOrigin, uv);
|
||||
vec3 originPos = oT.xyz; float perimT = oT.w;
|
||||
vec3 shapePos = shapeTarget(seed, uShape, uScale);
|
||||
|
||||
float wStream = 1.0 - smoothstep(0.18, 0.21, uPhaseT);
|
||||
float pulse = smoothstep(0.200,0.215,uPhaseT) * (1.0 - smoothstep(0.215,0.245,uPhaseT));
|
||||
float wReform = smoothstep(0.235,0.55,uPhaseT) * (1.0 - smoothstep(0.80,0.86,uPhaseT));
|
||||
float wDissolve = smoothstep(0.82,1.00,uPhaseT);
|
||||
|
||||
// Velocity is a PER-FRAME delta (Codrops GPGPU pattern): forces are small,
|
||||
// position adds raw velocity, damping keeps it stable.
|
||||
|
||||
// STREAM: staggered release toward center
|
||||
float delay = 0.10*hash11(seed*91.7) + 0.08*perimT;
|
||||
float act = smoothstep(0.0, 0.06, uPhaseT - delay);
|
||||
vec3 toCenter = -pos; float dC = length(toCenter);
|
||||
vec3 dirC = toCenter/max(dC,1e-4);
|
||||
float tConv = clamp(uPhaseT/0.20, 0.0, 1.0);
|
||||
float pull = pow(tConv, 3.0);
|
||||
vel += dirC * (0.004 + 0.02*pull) * act * wStream * smoothstep(0.0,6.0,dC);
|
||||
|
||||
// EXPLODE: one-shot gaussian radial blast
|
||||
float age = uTime - uBlastTime;
|
||||
float gate = exp(-age*age*60.0);
|
||||
float r = max(length(pos),1e-4);
|
||||
vec3 outDir = pos/r;
|
||||
float falloff = 1.0/(1.0 + r*r*4.0);
|
||||
vel += outDir * 1.4 * falloff * gate;
|
||||
vel += snoiseVec3(pos*31.0) * 0.02 * gate;
|
||||
|
||||
// REFORM/HOLD: spring onto shape (per-frame delta -> small k, critically damped).
|
||||
float wHold = smoothstep(0.235, 0.42, uPhaseT) * (1.0 - smoothstep(0.80, 0.86, uPhaseT));
|
||||
float localProg = smoothstep(delay, delay+0.30, (uPhaseT-0.235)/0.30);
|
||||
float kAttract = mix(0.0, 0.10, wHold) * localProg;
|
||||
vec3 toShape = shapePos - pos;
|
||||
vel += toShape * kAttract;
|
||||
|
||||
// DISSOLVE: drift back to edge shell (== next-loop spawn -> seamless)
|
||||
vec3 toEdge = originPos - pos;
|
||||
vel += toEdge * (0.04 * wDissolve);
|
||||
vel += normalize(pos+1e-4) * 0.01 * wDissolve;
|
||||
|
||||
// curl turbulence: strong on stream/blast/dissolve, NEAR ZERO once formed.
|
||||
float turb = (0.012*wStream + 0.006*gate + 0.014*wDissolve) * (1.0 - wHold*0.96) + 0.0002;
|
||||
vel += curlNoise(pos*0.30 + uTime*0.10) * turb;
|
||||
|
||||
// damping: heavy on hold so the shape SETTLES crisp; lighter during motion.
|
||||
float damping = mix(0.90, 0.55, pulse) * mix(1.0, 0.82, wHold);
|
||||
vel *= damping;
|
||||
float v = length(vel); if (v > 0.6) vel *= 0.6/v;
|
||||
|
||||
// DIAGNOSTIC: uDebug=1 ignores all phase logic and just springs to target.
|
||||
// If the brain forms with this, the pipeline works and phase logic is the bug.
|
||||
if (uDebug > 0.5) {
|
||||
// spring to procedural BRAIN with HARDCODED scale 5.0 (rules out uScale uniform).
|
||||
vec3 tgt = shapeTarget(seed, 0.0, 5.0);
|
||||
vec3 d = tgt - pos;
|
||||
vel = d * 0.12;
|
||||
vel *= 0.86;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(vel, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const RENDER_VERT = /* glsl */ `
|
||||
uniform sampler2D texturePosition;
|
||||
uniform sampler2D textureVelocity;
|
||||
uniform float uSize, uDpr, uPhaseT, uTime;
|
||||
attribute vec2 reference;
|
||||
varying float vSpeed; varying float vSeed;
|
||||
void main(){
|
||||
vec4 pT = texture2D(texturePosition, reference);
|
||||
vec3 pos = pT.xyz; vSeed = pT.w;
|
||||
vec3 vel = texture2D(textureVelocity, reference).xyz;
|
||||
vSpeed = length(vel);
|
||||
vec4 mv = modelViewMatrix * vec4(pos,1.0);
|
||||
float breathe = 1.0 + 0.05*sin(uTime*1.4)*step(0.55,uPhaseT)*step(uPhaseT,0.82);
|
||||
float stretch = 1.0 + clamp(vSpeed*0.6, 0.0, 2.5);
|
||||
gl_PointSize = uSize * uDpr * breathe * stretch / max(-mv.z, 0.1);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`;
|
||||
|
||||
const RENDER_FRAG = /* glsl */ `
|
||||
precision highp float;
|
||||
uniform vec3 uViolet, uCyan, uEmerald;
|
||||
uniform vec2 uTextCenter, uResolution;
|
||||
varying float vSpeed; varying float vSeed;
|
||||
void main(){
|
||||
vec2 q = gl_PointCoord - 0.5;
|
||||
float d2 = dot(q,q);
|
||||
if (d2 > 0.25) discard;
|
||||
// bright core + soft glowing halo so each particle reads as a luminous orb
|
||||
float a = exp(-d2*16.0) + 0.5*exp(-d2*4.0);
|
||||
|
||||
// RICH 5-stop spectrum across the seed: magenta -> violet -> blue -> cyan -> emerald.
|
||||
// Each particle holds its hue (spatially separated) instead of averaging to one color.
|
||||
vec3 magenta = vec3(0.95, 0.25, 0.85);
|
||||
vec3 violet = vec3(0.55, 0.35, 1.00);
|
||||
vec3 blue = vec3(0.25, 0.45, 1.00);
|
||||
vec3 cyan = vec3(0.15, 0.85, 0.95);
|
||||
vec3 emerald = vec3(0.25, 0.95, 0.55);
|
||||
float s = vSeed;
|
||||
vec3 col;
|
||||
if (s < 0.25) col = mix(magenta, violet, s/0.25);
|
||||
else if (s < 0.50) col = mix(violet, blue, (s-0.25)/0.25);
|
||||
else if (s < 0.75) col = mix(blue, cyan, (s-0.50)/0.25);
|
||||
else col = mix(cyan, emerald,(s-0.75)/0.25);
|
||||
|
||||
// fast particles flare WARM gold (energy), not blue-white — keeps color alive.
|
||||
vec3 gold = vec3(1.0, 0.75, 0.35);
|
||||
col = mix(col, gold, clamp(vSpeed*0.5, 0.0, 0.55));
|
||||
|
||||
col *= 1.7; // luminous but not blown-out
|
||||
vec2 sUv = gl_FragCoord.xy / uResolution;
|
||||
float textMask = smoothstep(0.0, 0.20, length(sUv - uTextCenter));
|
||||
float alpha = a * (0.7 + 0.3*textMask);
|
||||
gl_FragColor = vec4(col * alpha, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export interface NodeEngineOptions {
|
||||
seed?: number;
|
||||
reducedMotion?: boolean;
|
||||
/** Debug: freeze the loop at a fixed phase in [0,1) to inspect a single beat. */
|
||||
forcePhase?: number;
|
||||
/** Debug: force a specific shape index 0..8 to inspect one shape. */
|
||||
forceShape?: number;
|
||||
/** Called when the active beat is/ isn't the particle TEXT message. */
|
||||
onTextBeat?: (isText: boolean) => void;
|
||||
}
|
||||
|
||||
export class NodeEngine {
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
private scene = new THREE.Scene();
|
||||
private camera: THREE.PerspectiveCamera;
|
||||
private composer: EffectComposer;
|
||||
private gpu: GPUComputationRenderer;
|
||||
private posVar!: ReturnType<GPUComputationRenderer['addVariable']>;
|
||||
private velVar!: ReturnType<GPUComputationRenderer['addVariable']>;
|
||||
private points!: THREE.Points;
|
||||
private material!: THREE.ShaderMaterial;
|
||||
private targets: THREE.DataTexture[] = [];
|
||||
private clock = new THREE.Clock();
|
||||
private raf = 0;
|
||||
private host: HTMLElement;
|
||||
private TEX: number;
|
||||
private N: number;
|
||||
private reduced: boolean;
|
||||
private disposed = false;
|
||||
private uBlastTime = -999;
|
||||
private prevPhase = 0;
|
||||
private shapeIndex = 0;
|
||||
private frustum = new THREE.Vector2(20, 12);
|
||||
private forcePhase: number | undefined;
|
||||
private forceShape: number | undefined;
|
||||
private rotAmt = 1;
|
||||
private onTextBeat: ((isText: boolean) => void) | undefined;
|
||||
private lastTextState = false;
|
||||
|
||||
constructor(host: HTMLElement, opts: NodeEngineOptions = {}) {
|
||||
this.host = host;
|
||||
this.reduced = !!opts.reducedMotion;
|
||||
this.forcePhase = opts.forcePhase;
|
||||
this.forceShape = opts.forceShape;
|
||||
this.onTextBeat = opts.onTextBeat;
|
||||
const lowEnd = window.devicePixelRatio > 2.2 || (navigator.hardwareConcurrency || 8) <= 4;
|
||||
this.TEX = lowEnd ? 140 : 200;
|
||||
this.N = this.TEX * this.TEX;
|
||||
const seed = (opts.seed ?? 1234) % 100000;
|
||||
|
||||
const w = host.clientWidth || 1, h = host.clientHeight || 1;
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true, powerPreference: 'high-performance' });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.setClearColor(0x05050f, 0);
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this.renderer.setSize(w, h);
|
||||
host.appendChild(this.renderer.domElement);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(55, w / h, 0.1, 100);
|
||||
this.camera.position.set(0, 0, 14);
|
||||
this.computeFrustum();
|
||||
|
||||
const originTex = this.buildOriginTexture(seed);
|
||||
this.gpu = new GPUComputationRenderer(this.TEX, this.TEX, this.renderer);
|
||||
const pos0 = this.gpu.createTexture();
|
||||
const vel0 = this.gpu.createTexture();
|
||||
// start everyone on the edge shell, with seed in .w
|
||||
(pos0.image.data as Float32Array).set(originTex.image.data as Float32Array);
|
||||
for (let i = 0; i < this.N; i++) (pos0.image.data as Float32Array)[i * 4 + 3] = (i / this.N);
|
||||
this.posVar = this.gpu.addVariable('texturePosition', POSITION_SHADER, pos0);
|
||||
this.velVar = this.gpu.addVariable('textureVelocity', VELOCITY_SHADER, vel0);
|
||||
this.gpu.setVariableDependencies(this.posVar, [this.posVar, this.velVar]);
|
||||
this.gpu.setVariableDependencies(this.velVar, [this.posVar, this.velVar]);
|
||||
|
||||
const pu = this.posVar.material.uniforms; pu.uDt = { value: 0 };
|
||||
const vu = this.velVar.material.uniforms;
|
||||
vu.uTime = { value: 0 }; vu.uPhaseT = { value: 0 }; vu.uDt = { value: 0 }; vu.uBlastTime = { value: -999 };
|
||||
vu.texOrigin = { value: originTex };
|
||||
vu.uShape = { value: this.forceShape ?? 0 };
|
||||
vu.uScale = { value: this.frustum.y * 0.42 };
|
||||
vu.uDebug = { value: this.forcePhase !== undefined && this.forcePhase < 0 ? 1 : 0 };
|
||||
const mask = this.buildTextMask(['VESTIGE', 'JULY 14TH', 'SIGN UP NOW']);
|
||||
vu.uMask = { value: mask.texture };
|
||||
vu.uMaskAspect = { value: mask.aspect };
|
||||
|
||||
const err = this.gpu.init();
|
||||
if (err) console.warn('[nodeEngine] gpgpu init:', err);
|
||||
|
||||
this.buildPoints(seed);
|
||||
|
||||
this.composer = new EffectComposer(this.renderer);
|
||||
this.composer.addPass(new RenderPass(this.scene, this.camera));
|
||||
const bloom = new UnrealBloomPass(new THREE.Vector2(w, h), 0.6, 0.5, 0.6);
|
||||
this.composer.addPass(bloom);
|
||||
|
||||
this.onResize = this.onResize.bind(this);
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.animate();
|
||||
}
|
||||
|
||||
private computeFrustum() {
|
||||
const vFOV = (this.camera.fov * Math.PI) / 180;
|
||||
const h = 2 * Math.tan(vFOV / 2) * Math.abs(this.camera.position.z);
|
||||
const w = h * this.camera.aspect;
|
||||
this.frustum.set(w, h);
|
||||
}
|
||||
|
||||
// Build an alpha mask of the launch message (3 rows) on a canvas. The shader
|
||||
// samples it: particles land where text alpha is high, spelling the message.
|
||||
private buildTextMask(lines: string[]): { texture: THREE.Texture; aspect: number } {
|
||||
const W = 1024, H = 384;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d')!;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const rowH = H / lines.length;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// size each line to fill the width nicely
|
||||
const line = lines[i];
|
||||
let size = Math.floor(rowH * 0.78);
|
||||
ctx.font = `900 ${size}px Inter, Arial, sans-serif`;
|
||||
// shrink to fit if too wide
|
||||
while (ctx.measureText(line).width > W * 0.92 && size > 10) {
|
||||
size -= 2; ctx.font = `900 ${size}px Inter, Arial, sans-serif`;
|
||||
}
|
||||
ctx.fillText(line, W / 2, rowH * (i + 0.5));
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter;
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping; tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.generateMipmaps = false;
|
||||
tex.needsUpdate = true;
|
||||
this.renderer.initTexture(tex);
|
||||
return { texture: tex, aspect: W / H };
|
||||
}
|
||||
|
||||
// edge-spawn shell: each particle off-screen on one of 4 edges, perimeterT in .w
|
||||
private buildOriginTexture(seed: number): THREE.DataTexture {
|
||||
const data = new Float32Array(this.N * 4);
|
||||
let s = seed + 1;
|
||||
const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||
const hw = this.frustum.x * 0.5 * 1.18, hh = this.frustum.y * 0.5 * 1.18;
|
||||
const camZ = Math.abs(this.camera.position.z);
|
||||
for (let i = 0; i < this.N; i++) {
|
||||
const edge = Math.floor(rnd() * 4);
|
||||
const t = rnd();
|
||||
let x = 0, y = 0;
|
||||
if (edge === 0) { x = -hw; y = (t * 2 - 1) * hh; }
|
||||
else if (edge === 1) { x = hw; y = (t * 2 - 1) * hh; }
|
||||
else if (edge === 2) { y = hh; x = (t * 2 - 1) * hw; }
|
||||
else { y = -hh; x = (t * 2 - 1) * hw; }
|
||||
const z = (rnd() - 0.4) * camZ * 0.6;
|
||||
const perimT = (edge + t) / 4;
|
||||
data[i * 4] = x; data[i * 4 + 1] = y; data[i * 4 + 2] = z; data[i * 4 + 3] = perimT;
|
||||
}
|
||||
const tex = new THREE.DataTexture(data, this.TEX, this.TEX, THREE.RGBAFormat, THREE.FloatType);
|
||||
tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter;
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping; tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.generateMipmaps = false;
|
||||
tex.needsUpdate = true;
|
||||
this.renderer.initTexture(tex); // force GPU upload (three.js #15882)
|
||||
return tex;
|
||||
}
|
||||
|
||||
private dataTex(fill: (i: number, out: Float32Array, o: number) => void): THREE.DataTexture {
|
||||
const data = new Float32Array(this.N * 4);
|
||||
for (let i = 0; i < this.N; i++) fill(i, data, i * 4);
|
||||
const tex = new THREE.DataTexture(data, this.TEX, this.TEX, THREE.RGBAFormat, THREE.FloatType);
|
||||
tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter;
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping; tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.generateMipmaps = false;
|
||||
tex.needsUpdate = true;
|
||||
// FORCE GPU upload now. A procedurally-built DataTexture passed as a custom
|
||||
// sampler2D into a GPUComputationRenderer pass reads BLACK until uploaded
|
||||
// (three.js #15882). initTexture uploads it before the first compute().
|
||||
this.renderer.initTexture(tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// three target shapes the cloud reforms into
|
||||
private buildTargets(seed: number) {
|
||||
let s = seed * 1.7 + 5;
|
||||
const rnd = () => { s = Math.sin(s) * 43758.5453; return s - Math.floor(s); };
|
||||
const scale = this.frustum.y * 0.42;
|
||||
|
||||
// A: BRAIN — two squashed hemispheres + sulci wrinkle, volumetric fill
|
||||
const brain = this.dataTex((i, out, o) => {
|
||||
const r = Math.cbrt(rnd()); // volumetric (denser core)
|
||||
const theta = rnd() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * rnd() - 1);
|
||||
let x = Math.sin(phi) * Math.cos(theta);
|
||||
let y = Math.cos(phi) * 0.92;
|
||||
let z = Math.sin(phi) * Math.sin(theta) * 1.18;
|
||||
x += Math.sign(x) * 0.34; // split into two lobes
|
||||
const wrinkle = 1 + 0.07 * Math.sin(8 * x) * Math.sin(7 * y);
|
||||
out[o] = x * r * scale * wrinkle;
|
||||
out[o + 1] = y * r * scale * wrinkle;
|
||||
out[o + 2] = z * r * scale * wrinkle;
|
||||
out[o + 3] = rnd() < 0.45 ? 1 : 0;
|
||||
});
|
||||
|
||||
// B: GRAPH CONSTELLATION — Fibonacci-sphere nodes + edge filaments
|
||||
const M = 600, GA = Math.PI * (3 - Math.sqrt(5));
|
||||
const nodes: [number, number, number][] = [];
|
||||
for (let k = 0; k < M; k++) {
|
||||
const y = 1 - (k / (M - 1)) * 2, rr = Math.sqrt(1 - y * y), t = GA * k;
|
||||
nodes.push([Math.cos(t) * rr * scale * 1.2, y * scale * 1.2, Math.sin(t) * rr * scale * 1.2]);
|
||||
}
|
||||
const constellation = this.dataTex((i, out, o) => {
|
||||
if (rnd() < 0.35) {
|
||||
const n = nodes[Math.floor(rnd() * M)];
|
||||
out[o] = n[0] + (rnd() - 0.5) * 0.4; out[o + 1] = n[1] + (rnd() - 0.5) * 0.4; out[o + 2] = n[2] + (rnd() - 0.5) * 0.4;
|
||||
out[o + 3] = 1;
|
||||
} else {
|
||||
const a = nodes[Math.floor(rnd() * M)], b = nodes[Math.floor(rnd() * M)];
|
||||
const m = rnd();
|
||||
out[o] = a[0] + (b[0] - a[0]) * m; out[o + 1] = a[1] + (b[1] - a[1]) * m; out[o + 2] = a[2] + (b[2] - a[2]) * m;
|
||||
out[o + 3] = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// C: NEURAL LATTICE — hash-jittered 3D grid
|
||||
const G = 7, cell = (scale * 2) / G;
|
||||
const lattice = this.dataTex((i, out, o) => {
|
||||
const cx = Math.floor(rnd() * G), cy = Math.floor(rnd() * G), cz = Math.floor(rnd() * G);
|
||||
const onStrut = rnd() < 0.4;
|
||||
let x = (cx + 0.5) * cell - scale, y = (cy + 0.5) * cell - scale, z = (cz + 0.5) * cell - scale;
|
||||
if (onStrut) {
|
||||
const axis = Math.floor(rnd() * 3), m = rnd();
|
||||
if (axis === 0) x += (m - 0.5) * cell;
|
||||
else if (axis === 1) y += (m - 0.5) * cell;
|
||||
else z += (m - 0.5) * cell;
|
||||
out[o + 3] = 0;
|
||||
} else {
|
||||
x += (rnd() - 0.5) * 0.3 * cell; y += (rnd() - 0.5) * 0.3 * cell; z += (rnd() - 0.5) * 0.3 * cell;
|
||||
out[o + 3] = 1;
|
||||
}
|
||||
out[o] = x; out[o + 1] = y; out[o + 2] = z;
|
||||
});
|
||||
|
||||
this.targets = [brain, constellation, lattice];
|
||||
}
|
||||
|
||||
private buildPoints(seed: number) {
|
||||
const refs = new Float32Array(this.N * 2);
|
||||
// texel CENTERS (+0.5) so render samples exactly the texel the sim writes.
|
||||
// Using texel corners (i/TEX) reads a neighbor -> particles never move.
|
||||
for (let i = 0; i < this.N; i++) {
|
||||
refs[i * 2] = ((i % this.TEX) + 0.5) / this.TEX;
|
||||
refs[i * 2 + 1] = (Math.floor(i / this.TEX) + 0.5) / this.TEX;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(this.N * 3), 3));
|
||||
geo.setAttribute('reference', new THREE.BufferAttribute(refs, 2));
|
||||
const w = this.host.clientWidth || 1, h = this.host.clientHeight || 1;
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
texturePosition: { value: null }, textureVelocity: { value: null },
|
||||
uSize: { value: 38 }, uDpr: { value: Math.min(window.devicePixelRatio, 2) },
|
||||
uPhaseT: { value: 0 }, uTime: { value: 0 },
|
||||
uViolet: { value: new THREE.Color(0x6366f1) }, uCyan: { value: new THREE.Color(0x22d3ee) },
|
||||
uEmerald: { value: new THREE.Color(0x34d399) },
|
||||
uTextCenter: { value: new THREE.Vector2(0.5, 0.46) }, uResolution: { value: new THREE.Vector2(w, h) }
|
||||
},
|
||||
transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending,
|
||||
vertexShader: RENDER_VERT, fragmentShader: RENDER_FRAG
|
||||
});
|
||||
this.points = new THREE.Points(geo, this.material);
|
||||
this.points.frustumCulled = false;
|
||||
// Push the whole cloud DOWN so shapes cut off near the top, leaving a clean
|
||||
// band at the very top of the viewport for the fixed launch sign.
|
||||
this.points.position.y = -this.frustum.y * 0.16;
|
||||
this.scene.add(this.points);
|
||||
}
|
||||
|
||||
private onResize() {
|
||||
const w = this.host.clientWidth || 1, h = this.host.clientHeight || 1;
|
||||
this.renderer.setSize(w, h); this.composer.setSize(w, h);
|
||||
this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
|
||||
this.computeFrustum();
|
||||
(this.material.uniforms.uResolution.value as THREE.Vector2).set(w, h);
|
||||
}
|
||||
|
||||
private animate = () => {
|
||||
if (this.disposed) return;
|
||||
this.raf = requestAnimationFrame(this.animate);
|
||||
const dt = Math.min(this.clock.getDelta(), 1 / 30);
|
||||
const t = this.clock.elapsedTime;
|
||||
|
||||
let phase = (t % LOOP) / LOOP;
|
||||
if (this.reduced) phase = 0.66; // freeze in HOLD on the brain
|
||||
if (this.forcePhase !== undefined) phase = this.forcePhase; // debug freeze
|
||||
|
||||
// loop wrap -> advance shape + fresh blast time (unless a shape is forced)
|
||||
if (this.forceShape === undefined && !this.reduced && this.prevPhase > 0.9 && phase < 0.1) {
|
||||
this.shapeIndex = (this.shapeIndex + 1) % 9;
|
||||
(this.velVar.material.uniforms as any).uShape.value = this.shapeIndex;
|
||||
}
|
||||
// fire the blast once when crossing into the SLAM window
|
||||
if (!this.reduced && this.prevPhase < 0.2 && phase >= 0.2) {
|
||||
this.uBlastTime = t;
|
||||
}
|
||||
this.prevPhase = phase;
|
||||
|
||||
const vu = this.velVar.material.uniforms as any;
|
||||
vu.uTime.value = t; vu.uPhaseT.value = phase; vu.uDt.value = dt; vu.uBlastTime.value = this.uBlastTime;
|
||||
(this.posVar.material.uniforms as any).uDt.value = dt;
|
||||
this.gpu.compute();
|
||||
|
||||
this.material.uniforms.texturePosition.value = this.gpu.getCurrentRenderTarget(this.posVar).texture;
|
||||
this.material.uniforms.textureVelocity.value = this.gpu.getCurrentRenderTarget(this.velVar).texture;
|
||||
this.material.uniforms.uPhaseT.value = phase;
|
||||
this.material.uniforms.uTime.value = t;
|
||||
|
||||
// 3D rotation for MATH shapes (mesmerizing); but TEXT beats (even index)
|
||||
// must stay flat-on and upright so the message is readable. Ease the
|
||||
// rotation to 0 during text beats.
|
||||
// 3D rotation so every shape reads volumetrically (a static galaxy/knot
|
||||
// looks flat; a slowly turning one is mesmerizing).
|
||||
this.points.rotation.y = t * 0.18 * (this.reduced ? 0 : 1);
|
||||
this.points.rotation.x = Math.sin(t * 0.13) * 0.22 * (this.reduced ? 0 : 1);
|
||||
|
||||
this.composer.render();
|
||||
};
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
cancelAnimationFrame(this.raf);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
this.material?.dispose();
|
||||
this.points?.geometry.dispose();
|
||||
this.targets.forEach((t) => t.dispose());
|
||||
this.composer?.dispose();
|
||||
this.renderer?.dispose();
|
||||
if (this.renderer?.domElement?.parentNode === this.host) this.host.removeChild(this.renderer.domElement);
|
||||
}
|
||||
}
|
||||
148
apps/dashboard/src/lib/landing/AmbientField.svelte
Normal file
148
apps/dashboard/src/lib/landing/AmbientField.svelte
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<script lang="ts">
|
||||
// Ambient outer field: god-ray glow radiating from the center (so the outer
|
||||
// frame looks CAUSED BY the storm, not decorated) + a parallax starfield that
|
||||
// fills the corners with quiet depth. Pure 2D canvas + CSS, engine-agnostic,
|
||||
// sits BEHIND the WebGPU/WebGL storm. ~one cheap canvas, holds 60fps.
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
seed?: number;
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
let { seed = 1234, reducedMotion = false }: Props = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||
let raf = 0;
|
||||
let disposed = false;
|
||||
|
||||
type Star = { x: number; y: number; z: number; r: number; tw: number; hue: number };
|
||||
let stars: Star[] = [];
|
||||
let w = 0, h = 0, dpr = 1;
|
||||
let pointer = { x: 0.5, y: 0.5 };
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return () => {
|
||||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (!canvas) return;
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
w = canvas.clientWidth; h = canvas.clientHeight;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
const rnd = mulberry32(seed || 1);
|
||||
// density rises toward the EDGES (rejection sample away from center)
|
||||
const count = Math.floor((w * h) / 9000);
|
||||
stars = [];
|
||||
let guard = 0;
|
||||
while (stars.length < count && guard < count * 6) {
|
||||
guard++;
|
||||
const x = rnd(), y = rnd();
|
||||
const dx = x - 0.5, dy = y - 0.5;
|
||||
const edge = Math.min(1, Math.hypot(dx, dy) * 2); // 0 center -> 1 corner
|
||||
if (rnd() > edge * 0.85 + 0.06) continue; // keep more near edges
|
||||
const z = 0.2 + rnd() * 0.8; // depth -> parallax + size
|
||||
const hue = [262, 190, 152][Math.floor(rnd() * 3)]; // violet/cyan/emerald
|
||||
stars.push({ x, y, z, r: (0.4 + rnd() * 1.4) * z, tw: rnd() * Math.PI * 2, hue });
|
||||
}
|
||||
}
|
||||
|
||||
function draw(t: number) {
|
||||
if (disposed || !canvas) return;
|
||||
raf = requestAnimationFrame(draw);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cx = w / 2, cy = h * 0.46;
|
||||
const time = reducedMotion ? 0 : t * 0.001;
|
||||
|
||||
// --- god-ray glow: violet/cyan/emerald light radiating from the storm ---
|
||||
const breathe = 0.85 + 0.15 * Math.sin(time * 0.6);
|
||||
const maxR = Math.hypot(w, h) * 0.62 * breathe;
|
||||
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
|
||||
g.addColorStop(0.0, 'rgba(120, 90, 240, 0.22)');
|
||||
g.addColorStop(0.22, 'rgba(60, 120, 230, 0.14)');
|
||||
g.addColorStop(0.5, 'rgba(40, 180, 160, 0.07)');
|
||||
g.addColorStop(1.0, 'rgba(5, 6, 12, 0)');
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// faint rotating light shafts for the "rays" read
|
||||
if (!reducedMotion) {
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(time * 0.04);
|
||||
const shafts = 7;
|
||||
for (let i = 0; i < shafts; i++) {
|
||||
ctx.rotate((Math.PI * 2) / shafts);
|
||||
const sg = ctx.createLinearGradient(0, 0, maxR, 0);
|
||||
sg.addColorStop(0, 'rgba(90,140,235,0.05)');
|
||||
sg.addColorStop(1, 'rgba(5,6,12,0)');
|
||||
ctx.fillStyle = sg;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(maxR, -maxR * 0.06);
|
||||
ctx.lineTo(maxR, maxR * 0.06);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// --- parallax starfield filling the corners ---
|
||||
const px = (pointer.x - 0.5), py = (pointer.y - 0.5);
|
||||
for (const s of stars) {
|
||||
const driftX = reducedMotion ? 0 : Math.sin(time * 0.05 * s.z + s.tw) * 6 * s.z;
|
||||
const sx = s.x * w + driftX - px * 40 * s.z;
|
||||
const sy = s.y * h - py * 40 * s.z;
|
||||
const tw = reducedMotion ? 0.7 : 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(time * 1.5 + s.tw));
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `hsla(${s.hue}, 80%, 75%, ${0.55 * tw})`;
|
||||
ctx.arc(sx, sy, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
function onPointer(e: PointerEvent) {
|
||||
pointer.x = e.clientX / window.innerWidth;
|
||||
pointer.y = e.clientY / window.innerHeight;
|
||||
}
|
||||
function onResize() { build(); }
|
||||
|
||||
onMount(() => {
|
||||
build();
|
||||
raf = requestAnimationFrame(draw);
|
||||
window.addEventListener('resize', onResize);
|
||||
if (!reducedMotion) window.addEventListener('pointermove', onPointer, { passive: true });
|
||||
});
|
||||
onDestroy(() => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('pointermove', onPointer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas class="ambient" bind:this={canvas} aria-hidden="true"></canvas>
|
||||
|
||||
<style>
|
||||
.ambient {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
138
apps/dashboard/src/lib/landing/LandingHero.svelte
Normal file
138
apps/dashboard/src/lib/landing/LandingHero.svelte
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script lang="ts">
|
||||
// Fullscreen living hero. Uses the REAL Memory Cinema WebGPU storm engine
|
||||
// (CinemaSandbox) where WebGPU is available, and falls back to the WebGL
|
||||
// NeuralFlow field otherwise. Never modifies Memory Cinema itself.
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import * as THREE from 'three';
|
||||
import type { CinemaSandbox } from '$lib/graph/cinema/sandbox';
|
||||
import type { SemanticRole } from '$lib/graph/cinema/storm';
|
||||
import { NeuralFlow } from '$lib/landing/neuralFlow';
|
||||
|
||||
interface Props {
|
||||
/** Deterministic seed so each visitor's hero is one of one. */
|
||||
seed?: number;
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
let { seed = 1234, reducedMotion = false }: Props = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||
let sandbox: CinemaSandbox | null = null;
|
||||
let fallback: NeuralFlow | null = null;
|
||||
let raf = 0;
|
||||
let usingWebGPU = $state(false);
|
||||
let last = 0;
|
||||
|
||||
// Cinema's three storm "worlds". We cycle them slowly so the hero keeps
|
||||
// transforming (the explode -> reform look) without the full Cinema director.
|
||||
const ROLES: SemanticRole[] = ['anchor', 'connection', 'contradiction'];
|
||||
let roleIndex = 0;
|
||||
const ORIGIN = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
// pointer for the fallback's liquid cursor
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!fallback || !host) return;
|
||||
const r = host.getBoundingClientRect();
|
||||
const nx = ((e.clientX - r.left) / r.width) * 2 - 1;
|
||||
const ny = -(((e.clientY - r.top) / r.height) * 2 - 1);
|
||||
fallback.setCursor(nx, ny);
|
||||
}
|
||||
|
||||
async function bootSandbox(): Promise<boolean> {
|
||||
if (!host) return false;
|
||||
try {
|
||||
const { CinemaSandbox, isWebGPUSupported } = await import('$lib/graph/cinema/sandbox');
|
||||
if (!isWebGPUSupported()) return false;
|
||||
sandbox = new CinemaSandbox(host);
|
||||
await sandbox.boot();
|
||||
// Kick the first world: the storm forms from its initial cloud.
|
||||
sandbox.transitionTo('anchor', ORIGIN, 'I', 0);
|
||||
if (!reducedMotion) sandbox.setFlythrough(0.45);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[hero] WebGPU sandbox unavailable, using WebGL fallback:', e);
|
||||
sandbox?.dispose();
|
||||
sandbox = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function bootFallback() {
|
||||
if (!host) return;
|
||||
fallback = new NeuralFlow(host, { seed, reducedMotion });
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||
}
|
||||
|
||||
// camera orbit for the sandbox: slow auto-rotate so the storm breathes; the
|
||||
// sandbox re-clamps distance + looks at origin every frame.
|
||||
let camAngle = 0;
|
||||
function driveSandboxCamera(dt: number) {
|
||||
if (!sandbox) return;
|
||||
const cam = sandbox.cameraRef;
|
||||
camAngle += dt * 0.06 * (reducedMotion ? 0 : 1);
|
||||
const radius = 58;
|
||||
cam.position.set(Math.sin(camAngle) * radius, 14 + Math.sin(camAngle * 0.5) * 6, Math.cos(camAngle) * radius);
|
||||
}
|
||||
|
||||
let roleTimer = 0;
|
||||
async function loop(now: number) {
|
||||
raf = requestAnimationFrame(loop);
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
|
||||
if (sandbox) {
|
||||
driveSandboxCamera(dt);
|
||||
// cycle storm worlds every ~7s for the perpetual transform
|
||||
roleTimer += dt;
|
||||
if (roleTimer > 7 && !reducedMotion) {
|
||||
roleTimer = 0;
|
||||
roleIndex = (roleIndex + 1) % ROLES.length;
|
||||
sandbox.transitionTo(ROLES[roleIndex], ORIGIN, 'I', roleIndex);
|
||||
}
|
||||
try {
|
||||
await sandbox.render(dt);
|
||||
} catch {
|
||||
/* one bad frame must not kill the loop */
|
||||
}
|
||||
}
|
||||
// the WebGL fallback runs its own internal rAF, so nothing to do here
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
(async () => {
|
||||
usingWebGPU = await bootSandbox();
|
||||
if (usingWebGPU) {
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(loop);
|
||||
} else {
|
||||
bootFallback();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
sandbox?.dispose();
|
||||
fallback?.dispose();
|
||||
sandbox = null;
|
||||
fallback = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="hero-stage" bind:this={host} aria-hidden="true"></div>
|
||||
|
||||
<style>
|
||||
.hero-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
}
|
||||
.hero-stage :global(canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
174
apps/dashboard/src/lib/landing/NeuralSign.svelte
Normal file
174
apps/dashboard/src/lib/landing/NeuralSign.svelte
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<script lang="ts">
|
||||
// Living neural launch sign: VESTIGE / JULY 14TH / SIGN UP NOW built from REAL
|
||||
// grown dendrites (space colonization on the letterforms) with synapse bulbs,
|
||||
// multi-tier bloom, energy burst, and draw-on growth. Not glow-on-text.
|
||||
import { onMount } from 'svelte';
|
||||
import { growSign, type DendriteSign } from '$lib/landing/dendriteGen';
|
||||
|
||||
let sign = $state<DendriteSign | null>(null);
|
||||
let reduced = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
// grow the dendrites once after fonts are ready (so glyph metrics are right)
|
||||
const grow = () => {
|
||||
try {
|
||||
sign = growSign();
|
||||
} catch (e) {
|
||||
console.warn('[neural-sign] grow failed:', e);
|
||||
}
|
||||
};
|
||||
if (document.fonts?.ready) {
|
||||
document.fonts.load('900 150px Inter').then(() => document.fonts.ready).then(grow).catch(grow);
|
||||
} else {
|
||||
grow();
|
||||
}
|
||||
});
|
||||
|
||||
// energy-burst streaks (radial, behind the letters)
|
||||
const streaks = Array.from({ length: 30 }, (_, i) => i);
|
||||
const cx = 500;
|
||||
const cy = 90;
|
||||
</script>
|
||||
|
||||
<div class="neural-sign" class:reduced aria-label="Vestige launches July 14th, sign up now">
|
||||
{#if sign}
|
||||
<svg
|
||||
viewBox={`0 0 ${sign.width} ${sign.height}`}
|
||||
class="sign-svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<filter id="ns-bloom" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="1.6" result="b1" />
|
||||
<feColorMatrix in="b1" values="0 0 0 0 .22 0 0 0 0 1 0 0 0 0 .62 0 0 0 1 0" result="g1" />
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="5" result="b2" />
|
||||
<feColorMatrix in="b2" values="0 0 0 0 .13 0 0 0 0 .82 0 0 0 0 1 0 0 0 .85 0" result="g2" />
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="12" result="b3" />
|
||||
<feColorMatrix in="b3" values="0 0 0 0 .70 0 0 0 0 .42 0 0 0 0 1 0 0 0 .6 0" result="g3" />
|
||||
<feMerge>
|
||||
<feMergeNode in="g3" /><feMergeNode in="g2" /><feMergeNode in="g1" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="ns-grad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#b388ff" />
|
||||
<stop offset="45%" stop-color="#22d3ee" />
|
||||
<stop offset="75%" stop-color="#39ff9d" />
|
||||
<stop offset="100%" stop-color="#b388ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- energy burst behind -->
|
||||
<g class="streaks" filter="url(#ns-bloom)">
|
||||
{#each streaks as i}
|
||||
{@const ang = (i / streaks.length) * Math.PI * 2}
|
||||
<line
|
||||
x1={cx + Math.cos(ang) * 36}
|
||||
y1={cy + Math.sin(ang) * 22}
|
||||
x2={cx + Math.cos(ang) * (240 + (i % 5) * 28)}
|
||||
y2={cy + Math.sin(ang) * (140 + (i % 5) * 16)}
|
||||
stroke="url(#ns-grad)"
|
||||
stroke-width={1 + (i % 3)}
|
||||
class="streak"
|
||||
style={`--i:${i}`}
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<!-- the grown dendrites -->
|
||||
<g filter="url(#ns-bloom)">
|
||||
<g class="dendrites">
|
||||
{#each sign.paths as p (p.d)}
|
||||
<path d={p.d} stroke={p.col} stroke-width={p.w} stroke-linecap="round" />
|
||||
{/each}
|
||||
</g>
|
||||
<g class="synapses">
|
||||
{#each sign.synapses as s, i (i)}
|
||||
<circle cx={s.x} cy={s.y} r={s.r} fill="#cffff0" class="syn" style={`--i:${i}`} />
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.neural-sign {
|
||||
position: fixed;
|
||||
top: clamp(0.1rem, 1vh, 0.8rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
width: min(680px, 94vw);
|
||||
pointer-events: none;
|
||||
}
|
||||
.sign-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* PERF: per-path stroke-dashoffset animation on 5700 paths tanks FPS to ~12
|
||||
(SVG dashoffset is not GPU-compositable; full paint per path per frame).
|
||||
Instead fade in the WHOLE dendrite group with ONE GPU-compositable opacity
|
||||
transition. Static dendrites + one group fade = 120fps. */
|
||||
.dendrites {
|
||||
animation: ns-grow 1.6s ease-out both;
|
||||
}
|
||||
@keyframes ns-grow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.92);
|
||||
transform-origin: 50% 30%;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* synapse nodes fire */
|
||||
.syn {
|
||||
transform-origin: center;
|
||||
animation: ns-fire 2.6s ease-in-out infinite;
|
||||
animation-delay: calc(var(--i) * -0.073s);
|
||||
}
|
||||
@keyframes ns-fire {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* energy streaks shimmer */
|
||||
.streak {
|
||||
opacity: 0.3;
|
||||
animation: ns-streak 3.4s ease-in-out infinite;
|
||||
animation-delay: calc(var(--i) * -0.07s);
|
||||
}
|
||||
@keyframes ns-streak {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.reduced .dendrites {
|
||||
animation: none;
|
||||
}
|
||||
.reduced .syn,
|
||||
.reduced .streak {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
196
apps/dashboard/src/lib/landing/dendriteGen.ts
Normal file
196
apps/dashboard/src/lib/landing/dendriteGen.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// Grows real neural dendrites from text glyphs via space colonization, in the
|
||||
// browser (canvas + fonts available), and returns SVG-ready path/synapse data.
|
||||
// Runs once on mount (~0.7s for 3 lines), deterministic via a seeded RNG so the
|
||||
// same text always grows the same neural sign.
|
||||
|
||||
export interface DendritePath {
|
||||
d: string;
|
||||
col: string;
|
||||
w: number;
|
||||
len: number;
|
||||
depth: number;
|
||||
}
|
||||
export interface Synapse {
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
}
|
||||
export interface DendriteSign {
|
||||
paths: DendritePath[];
|
||||
synapses: Synapse[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// deterministic PRNG (mulberry32) so the sign is stable across loads
|
||||
function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
interface Node {
|
||||
x: number;
|
||||
y: number;
|
||||
parent: number;
|
||||
w: number;
|
||||
}
|
||||
|
||||
function growLine(text: string, fontPx: number, iters: number, rand: () => number) {
|
||||
const SEG = 2.2,
|
||||
ATTRACT = 14,
|
||||
KILL = 4,
|
||||
PAD = 40;
|
||||
const meas = document.createElement('canvas').getContext('2d')!;
|
||||
meas.font = `900 ${fontPx}px Inter, Arial, sans-serif`;
|
||||
const tw = Math.ceil(meas.measureText(text).width);
|
||||
const W = tw + PAD * 2;
|
||||
const H = Math.ceil(fontPx * 1.5);
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W;
|
||||
cv.height = H;
|
||||
const ctx = cv.getContext('2d')!;
|
||||
ctx.font = `900 ${fontPx}px Inter, Arial, sans-serif`;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, PAD, H / 2);
|
||||
const data = ctx.getImageData(0, 0, W, H).data;
|
||||
const aAt = (x: number, y: number) =>
|
||||
x < 0 || y < 0 || x >= W || y >= H ? 0 : data[(y * W + x) * 4 + 3];
|
||||
|
||||
const attractors: [number, number][] = [];
|
||||
const inside: [number, number][] = [];
|
||||
for (let y = 0; y < H; y += 2) {
|
||||
for (let x = 0; x < W; x += 2) {
|
||||
if (aAt(x, y) > 128) {
|
||||
inside.push([x, y]);
|
||||
attractors.push([x, y]);
|
||||
const edge =
|
||||
aAt(x + 2, y) <= 128 || aAt(x - 2, y) <= 128 || aAt(x, y + 2) <= 128 || aAt(x, y - 2) <= 128;
|
||||
if (edge && rand() < 0.22) {
|
||||
attractors.push([x + (rand() - 0.5) * 22, y + (rand() - 0.5) * 22]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!inside.length) return { nodes: [] as Node[], W, H };
|
||||
|
||||
const bands: Record<number, [number, number]> = {};
|
||||
for (const [x, y] of inside) {
|
||||
const b = (x / 16) | 0;
|
||||
if (!bands[b] || y > bands[b][1]) bands[b] = [x, y];
|
||||
}
|
||||
const nodes: Node[] = [];
|
||||
for (const k in bands) nodes.push({ x: bands[k][0], y: bands[k][1], parent: -1, w: 1 });
|
||||
|
||||
let live = attractors.slice();
|
||||
for (let it = 0; it < iters && live.length; it++) {
|
||||
const infCount = new Array(nodes.length).fill(0);
|
||||
const acc: [number, number][] = nodes.map(() => [0, 0]);
|
||||
for (const a of live) {
|
||||
let best = -1,
|
||||
bd = ATTRACT * ATTRACT;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const dx = nodes[i].x - a[0],
|
||||
dy = nodes[i].y - a[1];
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if (best >= 0) {
|
||||
acc[best][0] += a[0] - nodes[best].x;
|
||||
acc[best][1] += a[1] - nodes[best].y;
|
||||
infCount[best]++;
|
||||
}
|
||||
}
|
||||
const fresh: Node[] = [];
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (!infCount[i]) continue;
|
||||
const L = Math.hypot(acc[i][0], acc[i][1]) || 1;
|
||||
fresh.push({
|
||||
x: nodes[i].x + (SEG * acc[i][0]) / L + (rand() - 0.5) * 1.2,
|
||||
y: nodes[i].y + (SEG * acc[i][1]) / L + (rand() - 0.5) * 1.2,
|
||||
parent: i,
|
||||
w: 1
|
||||
});
|
||||
}
|
||||
if (!fresh.length) break;
|
||||
const before = nodes.length;
|
||||
for (const f of fresh) nodes.push(f);
|
||||
live = live.filter((a) => {
|
||||
for (let i = before; i < nodes.length; i++) {
|
||||
const dx = nodes[i].x - a[0],
|
||||
dy = nodes[i].y - a[1];
|
||||
if (dx * dx + dy * dy < KILL * KILL) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
for (let i = nodes.length - 1; i > 0; i--) {
|
||||
if (nodes[i].parent >= 0) nodes[nodes[i].parent].w += nodes[i].w * 0.045;
|
||||
}
|
||||
return { nodes, W, H };
|
||||
}
|
||||
|
||||
/** Grow the full 3-line launch sign. Returns SVG-ready data centered to width 1000. */
|
||||
export function growSign(): DendriteSign {
|
||||
const rand = rng(0x5e57); // fixed seed -> stable sign
|
||||
const palette = ['#39ff9d', '#22d3ee', '#b388ff'];
|
||||
const specs = [
|
||||
{ text: 'VESTIGE', px: 150, targetW: 940, iters: 600 },
|
||||
{ text: 'JULY 14TH', px: 90, targetW: 620, iters: 400 },
|
||||
{ text: 'SIGN UP NOW', px: 78, targetW: 700, iters: 400 }
|
||||
];
|
||||
const VBW = 1000;
|
||||
const lines = specs.map((s) => {
|
||||
const res = growLine(s.text, s.px, s.iters, rand);
|
||||
const scale = s.targetW / Math.max(1, res.W);
|
||||
return { res, scale, scaledH: res.H * scale };
|
||||
});
|
||||
const VBH = lines.reduce((a, l) => a + l.scaledH, 0);
|
||||
|
||||
const paths: DendritePath[] = [];
|
||||
const synapses: Synapse[] = [];
|
||||
let yCursor = 0;
|
||||
let colBase = 0;
|
||||
for (const line of lines) {
|
||||
const { res, scale, scaledH } = line;
|
||||
const offX = (VBW - res.W * scale) / 2;
|
||||
const offY = yCursor;
|
||||
const childCount = new Array(res.nodes.length).fill(0);
|
||||
for (const n of res.nodes) if (n.parent >= 0) childCount[n.parent]++;
|
||||
for (let i = 0; i < res.nodes.length; i++) {
|
||||
const n = res.nodes[i];
|
||||
if (n.parent < 0) continue;
|
||||
const p = res.nodes[n.parent];
|
||||
const x1 = offX + n.x * scale,
|
||||
y1 = offY + n.y * scale;
|
||||
const x2 = offX + p.x * scale,
|
||||
y2 = offY + p.y * scale;
|
||||
const w = Math.max(0.5, Math.min(4.5, n.w * scale * 0.5));
|
||||
const len = Math.hypot(x2 - x1, y2 - y1);
|
||||
paths.push({
|
||||
d: `M${x1.toFixed(1)} ${y1.toFixed(1)}L${x2.toFixed(1)} ${y2.toFixed(1)}`,
|
||||
col: palette[(i + colBase) % 3],
|
||||
w: Math.round(w * 10) / 10,
|
||||
len: Math.round(len + 1),
|
||||
depth: Math.round((i / res.nodes.length) * 30)
|
||||
});
|
||||
// synapse at junctions/tips — sparse, so the pulse animation stays cheap
|
||||
// (animating thousands of SVG nodes kills FPS; ~40 per line is plenty).
|
||||
if ((childCount[i] === 0 || childCount[i] >= 2) && rand() < 0.08) {
|
||||
synapses.push({ x: Math.round(x1), y: Math.round(y1), r: Math.round((w * 0.9 + 1.4) * 10) / 10 });
|
||||
}
|
||||
}
|
||||
yCursor += scaledH;
|
||||
colBase += 1;
|
||||
}
|
||||
return { paths, synapses, width: VBW, height: Math.round(VBH) };
|
||||
}
|
||||
420
apps/dashboard/src/lib/landing/neuralFlow.ts
Normal file
420
apps/dashboard/src/lib/landing/neuralFlow.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
// Living Brain Neural Flow — a GPGPU particle organism.
|
||||
//
|
||||
// 65,536 particles simulated on the GPU via a TWO-variable GPUComputationRenderer
|
||||
// (position + velocity, ping-ponged). Flow runs TANGENT to a two-lobe brain SDF
|
||||
// so particles glide along the cortex instead of dispersing into a starfield.
|
||||
// The cursor parts the field like liquid (speed-gated repulsion + signed swirl)
|
||||
// and it heals back to the brain via a Hooke spring. Color is physical thin-film
|
||||
// iridescence (spectral_zucconi6), not HSV. An entrance timeline inhales chaos
|
||||
// into a dense, breathing, iridescent brain.
|
||||
//
|
||||
// Built from a researched spec (docs/launch/living-brain-hero-spec.json).
|
||||
// WebGL2, ships everywhere. Degrades to 16,384 particles on low-end.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js';
|
||||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||
|
||||
export interface NeuralFlowOptions {
|
||||
seed?: number;
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
// ---- shared GLSL: brain SDF + curl-of-SDF-modulated-noise (divergence free) --
|
||||
const GLSL_COMMON = /* glsl */ `
|
||||
vec3 hash3(vec3 p){
|
||||
p = vec3(dot(p,vec3(127.1,311.7,74.7)),
|
||||
dot(p,vec3(269.5,183.3,246.1)),
|
||||
dot(p,vec3(113.5,271.9,124.6)));
|
||||
return -1.0 + 2.0*fract(sin(p)*43758.5453123);
|
||||
}
|
||||
float snoise(vec3 p){
|
||||
vec3 i = floor(p); vec3 f = fract(p);
|
||||
vec3 u = f*f*(3.0-2.0*f);
|
||||
return mix(mix(mix(dot(hash3(i+vec3(0,0,0)),f-vec3(0,0,0)),
|
||||
dot(hash3(i+vec3(1,0,0)),f-vec3(1,0,0)),u.x),
|
||||
mix(dot(hash3(i+vec3(0,1,0)),f-vec3(0,1,0)),
|
||||
dot(hash3(i+vec3(1,1,0)),f-vec3(1,1,0)),u.x),u.y),
|
||||
mix(mix(dot(hash3(i+vec3(0,0,1)),f-vec3(0,0,1)),
|
||||
dot(hash3(i+vec3(1,0,1)),f-vec3(1,0,1)),u.x),
|
||||
mix(dot(hash3(i+vec3(0,1,1)),f-vec3(0,1,1)),
|
||||
dot(hash3(i+vec3(1,1,1)),f-vec3(1,1,1)),u.x),u.y),u.z);
|
||||
}
|
||||
float sdEllipsoid(vec3 p, vec3 r){ float k0=length(p/r); float k1=length(p/(r*r)); return k0*(k0-1.0)/k1; }
|
||||
float brainSDF(vec3 p){
|
||||
float L = sdEllipsoid(p-vec3(-0.32,0.0,0.0), vec3(0.85,0.95,1.15));
|
||||
float R = sdEllipsoid(p-vec3( 0.32,0.0,0.0), vec3(0.85,0.95,1.15));
|
||||
float d = min(L,R);
|
||||
d += 0.06*sin(8.0*p.x)*sin(7.0*p.y)*sin(6.0*p.z); // sulci/gyri wrinkles
|
||||
return d;
|
||||
}
|
||||
vec3 sdfNormal(vec3 p){
|
||||
const vec2 e = vec2(1.0,-1.0)*0.004;
|
||||
return normalize(e.xyy*brainSDF(p+e.xyy)+e.yyx*brainSDF(p+e.yyx)+
|
||||
e.yxy*brainSDF(p+e.yxy)+e.xxx*brainSDF(p+e.xxx));
|
||||
}
|
||||
vec3 potential(vec3 p, float t){ return sdfNormal(p)*snoise(p*1.6 + vec3(0.0,0.0,t*0.15)); }
|
||||
vec3 curlNoise(vec3 p, float t){
|
||||
const float e=0.012; vec3 dx=vec3(e,0,0),dy=vec3(0,e,0),dz=vec3(0,0,e);
|
||||
float x=potential(p+dy,t).z-potential(p-dy,t).z-(potential(p+dz,t).y-potential(p-dz,t).y);
|
||||
float y=potential(p+dz,t).x-potential(p-dz,t).x-(potential(p+dx,t).z-potential(p-dx,t).z);
|
||||
float z=potential(p+dx,t).y-potential(p-dx,t).y-(potential(p+dy,t).x-potential(p-dy,t).x);
|
||||
return vec3(x,y,z)/(2.0*e);
|
||||
}
|
||||
// center-weighted reseed point on the brain shell from a uv hash
|
||||
vec3 shellPoint(vec2 uv, float seed){
|
||||
vec3 h = hash3(vec3(uv*97.0, seed));
|
||||
float rad = pow(fract(h.x*0.5+0.5), 1.5);
|
||||
float theta = (h.y*0.5+0.5)*6.2831853;
|
||||
float phi = acos(2.0*(h.z*0.5+0.5)-1.0);
|
||||
vec3 p = vec3(sin(phi)*cos(theta), sin(phi)*sin(theta)*0.85, cos(phi));
|
||||
p *= mix(0.25, 1.25, rad);
|
||||
p.x += sign(h.x)*0.32; // bias toward a lobe
|
||||
p -= sdfNormal(p)*brainSDF(p)*0.85; // snap onto the shell
|
||||
return p;
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- VELOCITY shader: the 4-force liquid + brain physics --------------------
|
||||
const VELOCITY_SHADER = /* glsl */ `
|
||||
uniform float uTime, uDt, uForm;
|
||||
uniform vec2 uMouse, uMouseVel;
|
||||
uniform float uMouseSpeed, uAspect, uCalm;
|
||||
|
||||
${GLSL_COMMON}
|
||||
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy / resolution.xy;
|
||||
vec4 P = texture2D( texturePosition, uv );
|
||||
vec4 V = texture2D( textureVelocity, uv );
|
||||
vec3 pos = P.xyz; vec3 vel = V.xyz;
|
||||
|
||||
vec3 accel = vec3(0.0);
|
||||
float pulse = 0.5 + 0.5*sin(uTime*2.0);
|
||||
|
||||
// (4) ambient SDF-tangent brain flow + gentle shell restoring (loose, so the
|
||||
// cloud keeps visible structure instead of collapsing to a dense membrane)
|
||||
vec3 flow = curlNoise(pos, uTime) * (1.4 * (1.0 - uCalm*0.85));
|
||||
float restore = (1.8 + 1.0*pulse) * uForm;
|
||||
flow += -sdfNormal(pos) * brainSDF(pos) * restore;
|
||||
accel += flow;
|
||||
|
||||
// (3) HEAL: spring back toward this particle's brain-home
|
||||
vec3 home = shellPoint(uv, 3.0);
|
||||
accel += (home - pos) * (0.012 * uForm);
|
||||
|
||||
// (1)+(2) liquid cursor: speed-gated gaussian repulsion + signed swirl
|
||||
vec2 q = (pos.xy - uMouse); q.x *= uAspect;
|
||||
float fall = exp(-dot(q,q) / (0.10));
|
||||
vec2 rdir = normalize(pos.xy - uMouse + 1e-5);
|
||||
accel.xy += rdir * fall * 0.9 * uMouseSpeed;
|
||||
vec2 tangent = vec2(-rdir.y, rdir.x);
|
||||
float swirlSign = sign(uMouseVel.x*rdir.y - uMouseVel.y*rdir.x);
|
||||
accel.xy += tangent * swirlSign * fall * 0.9 * uMouseSpeed;
|
||||
|
||||
vel = (vel + accel*uDt) * 0.82;
|
||||
float sp = length(vel);
|
||||
float maxSpeed = 3.0;
|
||||
if (sp > maxSpeed) vel *= maxSpeed/sp;
|
||||
|
||||
gl_FragColor = vec4(vel, length(flow)); // .w carries curl magnitude for color
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- POSITION shader: integrate + life/reseed -------------------------------
|
||||
const POSITION_SHADER = /* glsl */ `
|
||||
uniform float uTime, uDt, uForm;
|
||||
${GLSL_COMMON}
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy / resolution.xy;
|
||||
vec4 P = texture2D( texturePosition, uv );
|
||||
vec4 V = texture2D( textureVelocity, uv );
|
||||
vec3 pos = P.xyz; float life = P.w;
|
||||
|
||||
pos += V.xyz * uDt;
|
||||
life -= uDt * 0.025;
|
||||
// reseed if it died OR wandered too far from the brain (keeps the cloud bounded)
|
||||
if (life < 0.0 || brainSDF(pos) > 1.8) {
|
||||
pos = shellPoint(uv, floor(uTime*0.7));
|
||||
life = 2.0 + fract(sin(dot(uv,vec2(12.9,78.2)))*43758.5)*3.0;
|
||||
}
|
||||
gl_FragColor = vec4(pos, life);
|
||||
}
|
||||
`;
|
||||
|
||||
const RENDER_VERT = /* glsl */ `
|
||||
uniform sampler2D texturePosition;
|
||||
uniform sampler2D textureVelocity;
|
||||
uniform float uSize, uForm;
|
||||
attribute vec2 aRef;
|
||||
varying float vCurl;
|
||||
varying float vSpeed;
|
||||
varying vec3 vViewN;
|
||||
varying vec3 vViewDir;
|
||||
${GLSL_COMMON}
|
||||
void main(){
|
||||
vec4 P = texture2D(texturePosition, aRef);
|
||||
vec4 V = texture2D(textureVelocity, aRef);
|
||||
vec3 pos = P.xyz;
|
||||
vCurl = V.w;
|
||||
vSpeed = length(V.xyz);
|
||||
vec3 n = sdfNormal(pos);
|
||||
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
|
||||
vViewN = normalize(normalMatrix * n);
|
||||
vViewDir = normalize(-mv.xyz);
|
||||
float dist = -mv.z;
|
||||
gl_PointSize = uSize * (0.6 + 2.0*clamp(vSpeed*0.6,0.04,1.0)) * (300.0/dist) * uForm;
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`;
|
||||
|
||||
const RENDER_FRAG = /* glsl */ `
|
||||
precision highp float;
|
||||
varying float vCurl;
|
||||
varying float vSpeed;
|
||||
varying vec3 vViewN;
|
||||
varying vec3 vViewDir;
|
||||
uniform float uTime;
|
||||
|
||||
// verified spectral fit (Zucconi 6) wavelength[400..700] -> RGB
|
||||
vec3 bump3y(vec3 x, vec3 yo){ vec3 y = 1.0 - x*x; return max(y - yo, 0.0); }
|
||||
vec3 spectral_zucconi6(float w){
|
||||
float x = clamp((w-400.0)/300.0, 0.0, 1.0);
|
||||
const vec3 c1=vec3(3.54585104,2.93225262,2.41593945);
|
||||
const vec3 x1=vec3(0.69549072,0.49228336,0.27699880);
|
||||
const vec3 y1=vec3(0.02312639,0.15225084,0.52607955);
|
||||
const vec3 c2=vec3(3.90307140,3.21182957,3.96587128);
|
||||
const vec3 x2=vec3(0.11748627,0.86755042,0.66077860);
|
||||
const vec3 y2=vec3(0.84897130,0.88445281,0.73949448);
|
||||
return bump3y(c1*(x-x1), y1) + bump3y(c2*(x-x2), y2);
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec2 uv = gl_PointCoord - 0.5;
|
||||
float d2 = dot(uv,uv);
|
||||
if (d2 > 0.25) discard;
|
||||
// sharper core + soft halo so particles read as distinct glowing points
|
||||
float soft = exp(-d2 * 14.0) + 0.35*exp(-d2 * 4.0);
|
||||
|
||||
float pulse = 0.5 + 0.5*sin(uTime*2.0);
|
||||
float cosTheta = abs(dot(vViewDir, vViewN));
|
||||
float thickness = 280.0 + vCurl*120.0 + 60.0*pulse;
|
||||
float w = clamp(2.0*1.35*thickness*cosTheta, 400.0, 700.0);
|
||||
vec3 irid = spectral_zucconi6(w);
|
||||
|
||||
float fres = pow(1.0 - clamp(dot(vViewN, vViewDir), 0.0, 1.0), 2.0);
|
||||
vec3 indigo = vec3(0.20, 0.10, 0.55);
|
||||
// blend toward full iridescence more readily so the violet/cyan/emerald show
|
||||
vec3 col = mix(indigo, irid, 0.35 + 0.65*fres);
|
||||
float brightness = 0.45 + 1.9*fres + vSpeed*0.6;
|
||||
// MUCH lower per-particle energy: additive of 65k must stay see-through,
|
||||
// a glittering constellation, not a solid white ball.
|
||||
float energy = soft * (0.05 + 0.12*clamp(vSpeed,0.0,1.0));
|
||||
gl_FragColor = vec4(col * brightness, energy);
|
||||
}
|
||||
`;
|
||||
|
||||
export class NeuralFlow {
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
private scene = new THREE.Scene();
|
||||
private camera: THREE.PerspectiveCamera;
|
||||
private composer: EffectComposer;
|
||||
private gpu: GPUComputationRenderer;
|
||||
private posVar: ReturnType<GPUComputationRenderer['addVariable']>;
|
||||
private velVar: ReturnType<GPUComputationRenderer['addVariable']>;
|
||||
private points!: THREE.Points;
|
||||
private material!: THREE.ShaderMaterial;
|
||||
private clock = new THREE.Clock();
|
||||
private raf = 0;
|
||||
private host: HTMLElement;
|
||||
private size: number;
|
||||
private calm: number;
|
||||
private disposed = false;
|
||||
private mouse = new THREE.Vector2(0.5, 0.5);
|
||||
private pmouse = new THREE.Vector2(0.5, 0.5);
|
||||
private mouseVel = new THREE.Vector2(0, 0);
|
||||
private mouseSpeed = 0;
|
||||
private form = 0; // entrance progress 0..1
|
||||
|
||||
constructor(host: HTMLElement, opts: NeuralFlowOptions = {}) {
|
||||
this.host = host;
|
||||
this.calm = opts.reducedMotion ? 1 : 0;
|
||||
const seed = (opts.seed ?? 1234) % 1000;
|
||||
|
||||
// perf: drop to 128 on low-end
|
||||
const lowEnd = window.devicePixelRatio > 2.2 || navigator.hardwareConcurrency <= 4;
|
||||
this.size = lowEnd ? 128 : 256;
|
||||
|
||||
const w = host.clientWidth || 1;
|
||||
const h = host.clientHeight || 1;
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true, powerPreference: 'high-performance' });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
|
||||
this.renderer.setSize(w, h);
|
||||
this.renderer.setClearColor(0x04050a, 0);
|
||||
host.appendChild(this.renderer.domElement);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 100);
|
||||
this.camera.position.set(0, 0, 6.4);
|
||||
|
||||
this.gpu = new GPUComputationRenderer(this.size, this.size, this.renderer);
|
||||
const pos0 = this.gpu.createTexture();
|
||||
const vel0 = this.gpu.createTexture();
|
||||
this.fillInitial(pos0, vel0, seed);
|
||||
this.posVar = this.gpu.addVariable('texturePosition', POSITION_SHADER, pos0);
|
||||
this.velVar = this.gpu.addVariable('textureVelocity', VELOCITY_SHADER, vel0);
|
||||
this.gpu.setVariableDependencies(this.posVar, [this.posVar, this.velVar]);
|
||||
this.gpu.setVariableDependencies(this.velVar, [this.posVar, this.velVar]);
|
||||
|
||||
const pu = this.posVar.material.uniforms;
|
||||
pu.uTime = { value: 0 }; pu.uDt = { value: 0 }; pu.uForm = { value: 0 };
|
||||
const vu = this.velVar.material.uniforms;
|
||||
vu.uTime = { value: 0 }; vu.uDt = { value: 0 }; vu.uForm = { value: 0 };
|
||||
vu.uMouse = { value: new THREE.Vector2(0.5, 0.5) };
|
||||
vu.uMouseVel = { value: new THREE.Vector2(0, 0) };
|
||||
vu.uMouseSpeed = { value: 0 };
|
||||
vu.uAspect = { value: w / h };
|
||||
vu.uCalm = { value: this.calm };
|
||||
|
||||
const err = this.gpu.init();
|
||||
if (err) console.warn('[neuralflow] gpgpu init:', err);
|
||||
|
||||
this.buildPoints();
|
||||
|
||||
this.composer = new EffectComposer(this.renderer);
|
||||
this.composer.addPass(new RenderPass(this.scene, this.camera));
|
||||
const bloom = new UnrealBloomPass(new THREE.Vector2(w, h), 0.5, 0.5, 0.82);
|
||||
this.composer.addPass(bloom);
|
||||
|
||||
this.onResize = this.onResize.bind(this);
|
||||
window.addEventListener('resize', this.onResize);
|
||||
|
||||
// entrance: ease form 0->1 over ~1.6s
|
||||
this.clock.start();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
private fillInitial(pos: THREE.DataTexture, vel: THREE.DataTexture, seed: number) {
|
||||
const p = pos.image.data as Float32Array;
|
||||
const v = vel.image.data as Float32Array;
|
||||
let s = seed * 9301 + 49297;
|
||||
const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||
for (let i = 0; i < p.length; i += 4) {
|
||||
// start as a wide scattered cloud; the entrance pulls it into the brain
|
||||
const r = 3.5 + rnd() * 4.0;
|
||||
const th = rnd() * Math.PI * 2;
|
||||
const ph = Math.acos(2 * rnd() - 1);
|
||||
p[i] = r * Math.sin(ph) * Math.cos(th);
|
||||
p[i + 1] = r * Math.sin(ph) * Math.sin(th);
|
||||
p[i + 2] = r * Math.cos(ph);
|
||||
p[i + 3] = rnd() * 2; // life
|
||||
v[i] = 0; v[i + 1] = 0; v[i + 2] = 0; v[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private buildPoints() {
|
||||
const count = this.size * this.size;
|
||||
const refs = new Float32Array(count * 2);
|
||||
for (let i = 0; i < count; i++) {
|
||||
refs[i * 2] = (i % this.size) / this.size;
|
||||
refs[i * 2 + 1] = Math.floor(i / this.size) / this.size;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(count * 3), 3));
|
||||
geo.setAttribute('aRef', new THREE.BufferAttribute(refs, 2));
|
||||
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
texturePosition: { value: null },
|
||||
textureVelocity: { value: null },
|
||||
uSize: { value: (this.host.clientHeight || 800) * 0.0014 },
|
||||
uForm: { value: 0 },
|
||||
uTime: { value: 0 }
|
||||
},
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
vertexShader: RENDER_VERT,
|
||||
fragmentShader: RENDER_FRAG
|
||||
});
|
||||
|
||||
this.points = new THREE.Points(geo, this.material);
|
||||
this.points.frustumCulled = false;
|
||||
this.scene.add(this.points);
|
||||
}
|
||||
|
||||
setCursor(nx: number, ny: number) {
|
||||
// nx, ny in -1..1; store as 0..1 mouse + velocity
|
||||
const mx = nx * 0.5 + 0.5;
|
||||
const my = ny * 0.5 + 0.5;
|
||||
this.mouse.set(mx, my);
|
||||
}
|
||||
|
||||
private onResize() {
|
||||
const w = this.host.clientWidth || 1;
|
||||
const h = this.host.clientHeight || 1;
|
||||
this.renderer.setSize(w, h);
|
||||
this.composer.setSize(w, h);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
(this.velVar.material.uniforms as any).uAspect.value = w / h;
|
||||
this.material.uniforms.uSize.value = h * 0.0016;
|
||||
}
|
||||
|
||||
private animate = () => {
|
||||
if (this.disposed) return;
|
||||
this.raf = requestAnimationFrame(this.animate);
|
||||
const dt = Math.min(this.clock.getDelta(), 0.033);
|
||||
const t = this.clock.elapsedTime;
|
||||
|
||||
// entrance: easeOutExpo to 1 over ~1.6s
|
||||
const target = 1;
|
||||
const ease = 1 - Math.pow(2, -10 * Math.min(t / 1.6, 1));
|
||||
this.form = this.form + (target * ease - this.form) * 0.2;
|
||||
|
||||
// mouse velocity (EMA) + idle decay -> heal
|
||||
const dx = (this.mouse.x - this.pmouse.x);
|
||||
const dy = (this.mouse.y - this.pmouse.y);
|
||||
const raw = Math.hypot(dx, dy);
|
||||
this.mouseSpeed = this.mouseSpeed * 0.85 + raw * 12 * 0.15;
|
||||
this.mouseSpeed *= 0.92;
|
||||
this.mouseVel.set(dx, dy);
|
||||
this.pmouse.copy(this.mouse);
|
||||
|
||||
const vu = this.velVar.material.uniforms as any;
|
||||
vu.uTime.value = t; vu.uDt.value = dt; vu.uForm.value = this.form;
|
||||
vu.uMouse.value.copy(this.mouse);
|
||||
vu.uMouseVel.value.copy(this.mouseVel);
|
||||
vu.uMouseSpeed.value = Math.min(this.mouseSpeed, 2.5);
|
||||
const pu = this.posVar.material.uniforms as any;
|
||||
pu.uTime.value = t; pu.uDt.value = dt; pu.uForm.value = this.form;
|
||||
|
||||
this.gpu.compute();
|
||||
|
||||
this.material.uniforms.texturePosition.value = this.gpu.getCurrentRenderTarget(this.posVar).texture;
|
||||
this.material.uniforms.textureVelocity.value = this.gpu.getCurrentRenderTarget(this.velVar).texture;
|
||||
this.material.uniforms.uForm.value = this.form;
|
||||
this.material.uniforms.uTime.value = t;
|
||||
|
||||
this.points.rotation.y = t * 0.05 * (1 - this.calm);
|
||||
this.points.rotation.x = Math.sin(t * 0.04) * 0.12;
|
||||
|
||||
this.composer.render();
|
||||
};
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
cancelAnimationFrame(this.raf);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
this.material?.dispose();
|
||||
this.points?.geometry.dispose();
|
||||
this.composer?.dispose();
|
||||
this.renderer?.dispose();
|
||||
if (this.renderer?.domElement?.parentNode === this.host) {
|
||||
this.host.removeChild(this.renderer.domElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
183
apps/dashboard/src/lib/landing/phantomBrain.ts
Normal file
183
apps/dashboard/src/lib/landing/phantomBrain.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// Phantom Brain — deterministic "seed-from-identity" generative memory graph.
|
||||
//
|
||||
// The viral artifact engine: any string (GitHub handle, email, a typed memory)
|
||||
// deterministically produces a unique, believable memory graph. Same input ->
|
||||
// same brain, every time, on every device (no RNG, no backend). This is what
|
||||
// makes the share artifact one-of-one per visitor while the page stays static.
|
||||
//
|
||||
// Output matches the dashboard's GraphNode/GraphEdge contract so it feeds the
|
||||
// real MemoryCinema + 3D graph components unchanged.
|
||||
|
||||
import type { GraphNode, GraphEdge } from '$types';
|
||||
|
||||
// ---- deterministic hashing (FNV-1a 32-bit) + seeded PRNG (mulberry32) -------
|
||||
|
||||
function fnv1a(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- believable memory vocabulary, themed by node type ----------------------
|
||||
|
||||
const NODE_TYPES = ['decision', 'fact', 'preference', 'pattern', 'insight', 'identity'] as const;
|
||||
|
||||
const FRAGMENTS: Record<string, string[]> = {
|
||||
decision: [
|
||||
'Chose Postgres over Mongo for the ledger',
|
||||
'Migrated auth to short-lived JWTs',
|
||||
'Adopted a monorepo with pnpm workspaces',
|
||||
'Picked Rust for the hot path',
|
||||
'Standardized on trunk-based deploys',
|
||||
'Moved embeddings on-device for privacy'
|
||||
],
|
||||
fact: [
|
||||
'The API rate-limits at 600 req/min',
|
||||
'Staging mirrors prod with seeded data',
|
||||
'CI runs the full suite in 4 minutes',
|
||||
'The retry budget is 3 with jitter',
|
||||
'Feature flags live in the edge config',
|
||||
'Cold starts dropped to 40ms after the rewrite'
|
||||
],
|
||||
preference: [
|
||||
'Prefers small, reviewable PRs',
|
||||
'Always writes the test first',
|
||||
'Hates implicit any',
|
||||
'Likes results over exceptions',
|
||||
'Wants logs structured, never printf',
|
||||
'Trusts source over memory'
|
||||
],
|
||||
pattern: [
|
||||
'Reaches for a state machine when flows branch',
|
||||
'Caches at the edge, invalidates on write',
|
||||
'Wraps external calls in a circuit breaker',
|
||||
'Names things for what they do, not how',
|
||||
'Pushes side effects to the boundary',
|
||||
'Treats deletes as tombstones, never hard'
|
||||
],
|
||||
insight: [
|
||||
'The flaky test was a clock, not the code',
|
||||
'Most latency was one N+1 query',
|
||||
'The bug only reproduced under real load',
|
||||
'The slow path was JSON, not the DB',
|
||||
'Two services were fighting the same lock',
|
||||
'The memory leak was an unclosed stream'
|
||||
],
|
||||
identity: [
|
||||
'Ships fast, refuses to ship broken',
|
||||
'Builds for the developer who comes after',
|
||||
'Optimizes for the next maintainer',
|
||||
'Treats taste as a feature',
|
||||
'Defaults to the ambitious version',
|
||||
'Owns the whole vertical slice'
|
||||
]
|
||||
};
|
||||
|
||||
const TAGS = [
|
||||
'architecture', 'auth', 'performance', 'ci', 'database', 'privacy',
|
||||
'refactor', 'incident', 'design', 'workflow', 'security', 'launch'
|
||||
];
|
||||
|
||||
export interface PhantomBrain {
|
||||
seed: string;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
stats: {
|
||||
memories: number;
|
||||
connections: number;
|
||||
topConcept: string;
|
||||
dominantType: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic phantom brain from any identity string.
|
||||
* @param identity GitHub handle, email, or a typed memory — anything.
|
||||
* @param size node count (default scales nicely for the hero, 28).
|
||||
*/
|
||||
export function seedPhantomBrain(identity: string, size = 28): PhantomBrain {
|
||||
const clean = identity.trim().toLowerCase() || 'anonymous-builder';
|
||||
const rng = mulberry32(fnv1a(`vestige:${clean}`));
|
||||
const pick = <T>(arr: readonly T[]): T => arr[Math.floor(rng() * arr.length)];
|
||||
|
||||
const now = Date.now();
|
||||
const typeCounts: Record<string, number> = {};
|
||||
const nodes: GraphNode[] = [];
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
const type = pick(NODE_TYPES);
|
||||
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
|
||||
const ageMs = Math.floor(rng() * 1000 * 60 * 60 * 24 * 120); // up to ~120 days
|
||||
const created = new Date(now - ageMs).toISOString();
|
||||
// Older memories decay; a few are freshly reinforced.
|
||||
const retention = Math.max(0.12, Math.min(1, 1 - (ageMs / (1000 * 60 * 60 * 24 * 140)) + (rng() - 0.5) * 0.3));
|
||||
const tagCount = 1 + Math.floor(rng() * 2);
|
||||
const tags = Array.from({ length: tagCount }, () => pick(TAGS));
|
||||
nodes.push({
|
||||
id: `n${i}`,
|
||||
label: pick(FRAGMENTS[type]),
|
||||
type,
|
||||
retention: Math.round(retention * 100) / 100,
|
||||
tags: [...new Set(tags)],
|
||||
createdAt: created,
|
||||
updatedAt: created,
|
||||
isCenter: i === 0
|
||||
});
|
||||
}
|
||||
|
||||
// Edges: a connected backbone (so the graph never fragments) plus seeded
|
||||
// cross-links weighted toward shared tags / strong memories.
|
||||
const edges: GraphEdge[] = [];
|
||||
const edgeKey = new Set<string>();
|
||||
const addEdge = (a: number, b: number, type: string) => {
|
||||
if (a === b) return;
|
||||
const k = a < b ? `${a}-${b}` : `${b}-${a}`;
|
||||
if (edgeKey.has(k)) return;
|
||||
edgeKey.add(k);
|
||||
edges.push({
|
||||
source: `n${a}`,
|
||||
target: `n${b}`,
|
||||
weight: Math.round((0.3 + rng() * 0.7) * 100) / 100,
|
||||
type
|
||||
});
|
||||
};
|
||||
// backbone
|
||||
for (let i = 1; i < size; i++) addEdge(i, Math.floor(rng() * i), 'relates_to');
|
||||
// cross-links — denser for a richer constellation
|
||||
const extra = Math.floor(size * 1.4);
|
||||
for (let i = 0; i < extra; i++) {
|
||||
addEdge(Math.floor(rng() * size), Math.floor(rng() * size), pick(['supports', 'contradicts', 'relates_to', 'supersedes']));
|
||||
}
|
||||
|
||||
const dominantType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'fact';
|
||||
// Top concept = most-seen tag.
|
||||
const tagFreq: Record<string, number> = {};
|
||||
for (const n of nodes) for (const t of n.tags) tagFreq[t] = (tagFreq[t] ?? 0) + 1;
|
||||
const topConcept = Object.entries(tagFreq).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'architecture';
|
||||
|
||||
return {
|
||||
seed: clean,
|
||||
nodes,
|
||||
edges,
|
||||
stats: {
|
||||
memories: nodes.length,
|
||||
connections: edges.length,
|
||||
topConcept,
|
||||
dominantType
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -21,71 +21,18 @@ import type {
|
|||
} from '$types';
|
||||
|
||||
const BASE = '/api';
|
||||
const DASHBOARD_TOKEN_STORAGE_KEY = 'vestige.dashboard.token';
|
||||
const DASHBOARD_TOKEN_HEADER = 'X-Vestige-Dashboard-Token';
|
||||
|
||||
async function fetcher<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const headers = withDashboardAuthHeaders(options?.headers);
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error(
|
||||
`API ${res.status}: dashboard auth failed. Open Vestige from the CLI or set VESTIGE_AUTH_TOKEN.`
|
||||
);
|
||||
}
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function withDashboardAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers);
|
||||
if (!next.has('Content-Type')) {
|
||||
next.set('Content-Type', 'application/json');
|
||||
}
|
||||
const token = getDashboardAuthToken();
|
||||
if (token) {
|
||||
next.set(DASHBOARD_TOKEN_HEADER, token);
|
||||
next.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getDashboardAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.localStorage.getItem(DASHBOARD_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function installDashboardTokenFromLocation(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash;
|
||||
if (!hash) return false;
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('vestige_token') ?? params.get('token');
|
||||
if (!token) return false;
|
||||
|
||||
window.localStorage.setItem(DASHBOARD_TOKEN_STORAGE_KEY, token);
|
||||
params.delete('vestige_token');
|
||||
params.delete('token');
|
||||
|
||||
const remainingHash = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${
|
||||
remainingHash ? `#${remainingHash}` : ''
|
||||
}`;
|
||||
window.history.replaceState(null, document.title, nextUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadJson(path: string, filename: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: withDashboardAuthHeaders()
|
||||
});
|
||||
const res = await fetch(`${BASE}${path}`);
|
||||
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { writable, derived } from 'svelte/store';
|
||||
import type { VestigeEvent } from '$types';
|
||||
import { getDashboardAuthToken } from './api';
|
||||
|
||||
const MAX_EVENTS = 200;
|
||||
|
||||
|
|
@ -24,11 +23,9 @@ function createWebSocketStore() {
|
|||
let reconnectAttempts = 0;
|
||||
|
||||
function connect(url?: string) {
|
||||
const baseWsUrl = url || (window.location.port === '5173'
|
||||
const wsUrl = url || (window.location.port === '5173'
|
||||
? `ws://${window.location.hostname}:3927/ws`
|
||||
: `ws://${window.location.host}/ws`);
|
||||
const token = getDashboardAuthToken();
|
||||
const wsUrl = token ? withToken(baseWsUrl, token) : baseWsUrl;
|
||||
|
||||
if (ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
|
|
@ -110,12 +107,6 @@ function createWebSocketStore() {
|
|||
};
|
||||
}
|
||||
|
||||
function withToken(url: string, token: string): string {
|
||||
const next = new URL(url, window.location.href);
|
||||
next.searchParams.set('token', token);
|
||||
return next.toString();
|
||||
}
|
||||
|
||||
export const websocket = createWebSocketStore();
|
||||
|
||||
// Derived stores for specific event types
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import Icon, { type IconName } from '$lib/components/Icon.svelte';
|
||||
import { initTheme } from '$stores/theme';
|
||||
import { installDashboardTokenFromLocation } from '$stores/api';
|
||||
|
||||
let { children } = $props();
|
||||
let showCommandPalette = $state(false);
|
||||
|
|
@ -29,14 +28,14 @@
|
|||
let dashboardPath = $derived(
|
||||
$page.url.pathname.startsWith(base) ? $page.url.pathname.slice(base.length) || '/' : $page.url.pathname
|
||||
);
|
||||
let isMarketingRoute = $derived(dashboardPath === '/waitlist' || dashboardPath.startsWith('/waitlist/'));
|
||||
let isMarketingRoute = $derived(
|
||||
dashboardPath === '/waitlist' ||
|
||||
dashboardPath.startsWith('/waitlist/') ||
|
||||
dashboardPath === '/launch' ||
|
||||
dashboardPath.startsWith('/launch/')
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
// Vestige opens the dashboard with the auth token in the URL fragment.
|
||||
// Install it into localStorage (and scrub it from the address bar) BEFORE
|
||||
// any API call or WebSocket connect, or the now-authenticated /api and /ws
|
||||
// endpoints reject every request.
|
||||
installDashboardTokenFromLocation();
|
||||
if (!isMarketingRoute) {
|
||||
websocket.connect();
|
||||
}
|
||||
|
|
|
|||
388
apps/dashboard/src/routes/launch/+page.svelte
Normal file
388
apps/dashboard/src/routes/launch/+page.svelte
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import MemoryCinema from '$lib/components/MemoryCinema.svelte';
|
||||
import HeroNodeEngine from '$lib/hero/HeroNodeEngine.svelte';
|
||||
import AmbientField from '$lib/landing/AmbientField.svelte';
|
||||
import NeuralSign from '$lib/landing/NeuralSign.svelte';
|
||||
import { seedPhantomBrain, type PhantomBrain } from '$lib/landing/phantomBrain';
|
||||
|
||||
// ---- the phantom brain: seeded from whatever the visitor gives us --------
|
||||
let identity = $state('');
|
||||
let brain = $state<PhantomBrain>(seedPhantomBrain('vestige'));
|
||||
let hasSeeded = $state(false);
|
||||
let heroSeed = $state(1234);
|
||||
|
||||
function seedToNumber(s: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0) % 100000;
|
||||
}
|
||||
|
||||
function reseed(value: string) {
|
||||
brain = seedPhantomBrain(value || 'vestige');
|
||||
hasSeeded = value.trim().length > 0;
|
||||
heroSeed = seedToNumber(value || 'vestige');
|
||||
}
|
||||
|
||||
// ---- waitlist capture ----------------------------------------------------
|
||||
type SubmitState = 'idle' | 'submitting' | 'success' | 'error';
|
||||
let email = $state('');
|
||||
let submitState = $state<SubmitState>('idle');
|
||||
let submitMessage = $state('');
|
||||
const waitlistEndpoint = import.meta.env.VITE_WAITLIST_ENDPOINT as string | undefined;
|
||||
|
||||
async function join(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!email.includes('@')) {
|
||||
submitState = 'error';
|
||||
submitMessage = 'Enter an email so we can send your invite.';
|
||||
return;
|
||||
}
|
||||
submitState = 'submitting';
|
||||
submitMessage = '';
|
||||
const payload = {
|
||||
email: email.trim(),
|
||||
name: identity.trim(),
|
||||
plan: 'solo',
|
||||
priority: 'sync',
|
||||
source: 'vestige-launch',
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
if (!waitlistEndpoint) {
|
||||
// No endpoint wired yet — still give the visitor the win locally.
|
||||
submitState = 'success';
|
||||
submitMessage = `You're on the list. Your brain is one of one.`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(waitlistEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error(`Endpoint returned ${res.status}`);
|
||||
submitState = 'success';
|
||||
submitMessage = `You're on the list. Your brain is one of one.`;
|
||||
} catch (err) {
|
||||
submitState = 'error';
|
||||
submitMessage = err instanceof Error ? err.message : 'Could not reach the waitlist.';
|
||||
}
|
||||
}
|
||||
|
||||
let mounted = $state(false);
|
||||
let prefersReducedMotion = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Vestige · give your agent a brain you can watch think</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Local-first memory for AI coding agents. Watch your agent remember, forget, and leave a receipt, rendered as a living brain."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="landing">
|
||||
<!-- HERO: the live, seeded brain IS the product -->
|
||||
<section class="hero">
|
||||
<!-- Ambient outer field: god-ray glow radiating from the storm + parallax
|
||||
starfield filling the corners, so the whole page feels alive edge to edge. -->
|
||||
{#if mounted}
|
||||
<AmbientField seed={heroSeed} reducedMotion={prefersReducedMotion} />
|
||||
{/if}
|
||||
|
||||
<!-- Full-viewport node engine: particles stream in from the edges, slam
|
||||
together and EXPLODE at center, reform into brain / graph / lattice,
|
||||
then dissolve back out and loop. Owns the whole screen. -->
|
||||
{#if mounted}
|
||||
{#key heroSeed}
|
||||
<HeroNodeEngine seed={heroSeed} reducedMotion={prefersReducedMotion} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<!-- Radial readability mask: darkens toward the edges so the outer field
|
||||
can be vivid while the headline core stays pristine. -->
|
||||
<div class="readability-mask" aria-hidden="true"></div>
|
||||
|
||||
<!-- The full Memory Cinema overlay launches on demand from the CTA below. -->
|
||||
{#if mounted}
|
||||
<MemoryCinema nodes={brain.nodes} edges={brain.edges} centerId="n0" />
|
||||
{/if}
|
||||
|
||||
<!-- Living neural launch sign at the very top -->
|
||||
{#if mounted}
|
||||
<NeuralSign />
|
||||
{/if}
|
||||
|
||||
<div class="hero-overlay">
|
||||
<div class="hud">
|
||||
<span class="hud-dot"></span>
|
||||
<span>{brain.stats.memories} memories</span>
|
||||
<span class="hud-sep">·</span>
|
||||
<span>{brain.stats.connections} connections</span>
|
||||
<span class="hud-sep">·</span>
|
||||
<span>150,000 GPU particles</span>
|
||||
</div>
|
||||
|
||||
<h1 class="manifesto">
|
||||
Your agent forgets everything.<br />
|
||||
Your memory should be <em>yours</em>. Local, and beautiful.
|
||||
</h1>
|
||||
|
||||
<p class="sub">
|
||||
Vestige is a local-first memory for AI coding agents. Watch it remember,
|
||||
forget, and leave a receipt, rendered as a brain you can fly through.
|
||||
</p>
|
||||
|
||||
<div class="seed-row">
|
||||
<input
|
||||
class="seed-input"
|
||||
placeholder="your github handle, or a memory…"
|
||||
bind:value={identity}
|
||||
oninput={(e) => reseed((e.target as HTMLInputElement).value)}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="seed-hint">
|
||||
{hasSeeded ? `this brain is seeded from "${brain.seed}". one of one.` : 'type anything to seed your own brain'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if submitState === 'success'}
|
||||
<div class="success">
|
||||
<strong>{submitMessage}</strong>
|
||||
<p>We'll email you before launch. Top concept in your brain: <em>{brain.stats.topConcept}</em>.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="capture" onsubmit={join}>
|
||||
<input
|
||||
class="email-input"
|
||||
type="email"
|
||||
placeholder="you@dev.com"
|
||||
bind:value={email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
<button class="cta" type="submit" disabled={submitState === 'submitting'}>
|
||||
{submitState === 'submitting' ? 'Joining…' : 'Claim your brain'}
|
||||
</button>
|
||||
</form>
|
||||
{#if submitState === 'error'}
|
||||
<p class="err">{submitMessage}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #05060a;
|
||||
}
|
||||
|
||||
.landing {
|
||||
color: #e8eaf2;
|
||||
font-family:
|
||||
'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(120% 120% at 50% 0%, #0b1020 0%, #05060a 60%);
|
||||
}
|
||||
|
||||
|
||||
.readability-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
/* transparent core protects the headline, darkens into the vivid edges */
|
||||
background: radial-gradient(
|
||||
ellipse 70% 60% at 50% 46%,
|
||||
transparent 0%,
|
||||
transparent 32%,
|
||||
rgba(5, 6, 12, 0.55) 78%,
|
||||
rgba(5, 6, 12, 0.82) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
padding: clamp(1.5rem, 5vw, 4rem);
|
||||
gap: 1.5rem;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(80% 60% at 50% 55%, rgba(5, 6, 10, 0.55) 0%, rgba(5, 6, 10, 0) 70%);
|
||||
}
|
||||
.hero-overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hud {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #8b93b0;
|
||||
background: rgba(12, 16, 28, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 1rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.hud-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 10px #34d399;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.hud-sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.manifesto {
|
||||
font-size: clamp(2rem, 6vw, 4.25rem);
|
||||
line-height: 1.05;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
max-width: 16ch;
|
||||
text-wrap: balance;
|
||||
text-shadow: 0 2px 40px rgba(5, 6, 10, 0.8);
|
||||
}
|
||||
.manifesto em {
|
||||
font-style: italic;
|
||||
background: linear-gradient(120deg, #a78bfa, #22d3ee, #34d399);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: clamp(1rem, 2vw, 1.2rem);
|
||||
color: #aab2cc;
|
||||
max-width: 52ch;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
text-shadow: 0 2px 30px rgba(5, 6, 10, 0.9);
|
||||
}
|
||||
|
||||
.seed-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: min(440px, 90vw);
|
||||
}
|
||||
.seed-input {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(167, 139, 250, 0.3);
|
||||
background: rgba(12, 16, 28, 0.6);
|
||||
color: #e8eaf2;
|
||||
backdrop-filter: blur(8px);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.seed-input:focus {
|
||||
border-color: rgba(167, 139, 250, 0.8);
|
||||
}
|
||||
.seed-hint {
|
||||
font-size: 0.78rem;
|
||||
color: #7c84a0;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.capture {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: min(440px, 90vw);
|
||||
}
|
||||
.email-input {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(12, 16, 28, 0.7);
|
||||
color: #e8eaf2;
|
||||
outline: none;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.email-input:focus {
|
||||
border-color: rgba(34, 211, 238, 0.7);
|
||||
}
|
||||
.cta {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 0.85rem 1.4rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #05060a;
|
||||
background: linear-gradient(120deg, #a78bfa, #22d3ee);
|
||||
white-space: nowrap;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
box-shadow: 0 6px 24px rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
.cta:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 32px rgba(34, 211, 238, 0.4);
|
||||
}
|
||||
.cta:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.success {
|
||||
max-width: 46ch;
|
||||
}
|
||||
.success strong {
|
||||
font-size: 1.25rem;
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.success p {
|
||||
color: #aab2cc;
|
||||
margin: 0;
|
||||
}
|
||||
.success em {
|
||||
color: #34d399;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
.err {
|
||||
color: #fca5a5;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
5
apps/dashboard/src/routes/launch/+page.ts
Normal file
5
apps/dashboard/src/routes/launch/+page.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// The launch landing page is a client-only WebGL experience (live 3D memory
|
||||
// graph + Memory Cinema). Disable SSR so Three.js never runs during prerender;
|
||||
// the page is still prerendered as a static shell that hydrates on the client.
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
Loading…
Add table
Add a link
Reference in a new issue