// ─── Data ──────────────────────────────────────────────────────────────────────
const S = {
seed:0, tex:'solid', col:0, mat:0, deform:0,
dist:0.30, freq:1.50, speed:0.60, size:1.00,
rough:0.55, emit:0.10, opac:1.00
};
const SEEDS = [
{ name:'Forma 1', build(geo){ const p=geo.attributes.position; for(let i=0;i
0?1+z*0.14:1-(-z)*0.06; const rT=1-Math.pow(Math.max(0,(y-0.3)/0.7),2)*0.3; p.setXYZ(i,x*1.55*rT,y*1.0,z*1.18*bly);} }},
];
// ─── Tipos (seed identity colours) ────────────────────────────────────────────
// Each tipo has a primary colour. Multiple active tipos blend into a mixed palette.
const TIPOS = [
{name:'Ecossistema', col:'#54FBC2'},
{name:'Território', col:'#FFAE4B'},
{name:'Sociedade', col:'#F05D87'},
{name:'Governança', col:'#77C7E8'},
];
// Institutional colours — single-select, replaces tipo system when active
const INST_COLS = [
{hex:'#FF753F', dark:false, name:'Laranja'},
{hex:'#F9F4EE', dark:false, name:'Creme'},
{hex:'#1E160B', dark:true, name:'Escuro'},
{hex:'#FFFFFF', dark:false, name:'Branco'},
];
let colMode = 'tipo'; // 'tipo' | 'inst'
let activeInst = null; // index of selected inst colour, or null
// Active tipos: Set of indices. At least one must be active.
let activeTipos = new Set(); // no tipo selected — starts as base colour
// Compute blended palette from active tipos
const BASE_COL = '#F9F4EE';
function blendTipos(){
// Institutional mode: single solid colour
if(colMode === 'inst' && activeInst !== null){
const c = INST_COLS[activeInst].hex;
const r = hexToRgb(c);
return {s:false, a:c, b:c, e:c,
hueA:0, hueB:0, satA:1,
solid:true,
solidR:r.r/255, solidG:r.g/255, solidB:r.b/255};
}
const active = [...activeTipos].map(i => TIPOS[i]);
// No tipos selected — pure base colour, neutral hue
if(active.length === 0){
return makePal(BASE_COL, BASE_COL, BASE_COL);
}
// 1 tipo — cor sólida do tipo, sem mistura com base no hue
if(active.length === 1){
const c = active[0].col;
// Both hueA and hueB = same tipo hue → uniform colour across the surface
return makePal(c, c, c);
}
// Multiple tipos — spread colours across the surface
// A = first tipo, B = average of all, E = last tipo
let r=0,g=0,b=0;
active.forEach(t => { const c=hexToRgb(t.col); r+=c.r; g+=c.g; b+=c.b; });
const n=active.length;
const avg = rgbToHex(r/n, g/n, b/n);
const a = active[0].col;
const e = active[active.length-1].col;
return makePal(a, avg, e);
}
// Linear blend between two hex colours at factor t (0=a, 1=b)
function blendHex(hexA, hexB, t){
const a=hexToRgb(hexA), b=hexToRgb(hexB);
return rgbToHex(a.r+(b.r-a.r)*t, a.g+(b.g-a.g)*t, a.b+(b.b-a.b)*t);
}
function hexToRgb(hex){
const r=parseInt(hex.slice(1,3),16);
const g=parseInt(hex.slice(3,5),16);
const b=parseInt(hex.slice(5,7),16);
return {r,g,b};
}
function rgbToHex(r,g,b){
return '#'+[r,g,b].map(v=>Math.round(v).toString(16).padStart(2,'0')).join('');
}
function colToHue(hex){
// Convert hex colour to HSL hue (0-1)
const {r,g,b} = hexToRgb(hex);
const rf=r/255,gf=g/255,bf=b/255;
const max=Math.max(rf,gf,bf),min=Math.min(rf,gf,bf),d=max-min;
if(d===0) return 0;
let h;
if(max===rf) h=(gf-bf)/d%6;
else if(max===gf) h=(bf-rf)/d+2;
else h=(rf-gf)/d+4;
return ((h/6)%1+1)%1;
}
function makePal(a,b,e){
// saturation: 0 when no tipos (base colour), scales with tipo count
const nT=activeTipos.size;
const satA=nT===0?0:nT===1?1.0:Math.min(0.92,0.7+nT*0.08);
return {s:false, a, b, e, hueA:colToHue(a), hueB:colToHue(e||b), satA, solid:false, solidR:1,solidG:1,solidB:1};
}
// Legacy PALS alias — build() reads currentPal
let currentPal = blendTipos();
const PALS = [currentPal]; // single entry, updated on tipo change
const MATS = [
{name:'Espectral', type:'spectral', rough:0.1, emit:0.10},
{name:'Iridesc.', type:'iridescent', rough:0.1, emit:0.10},
{name:'Neon Glow', type:'glow', rough:0.5, emit:0.85},
{name:'Chrome', type:'chrome', rough:0.05, emit:0.0},
{name:'Pincel', type:'nebula', rough:0.85, emit:0.0},
{name:'Hologram', type:'hologram', rough:0.0, emit:0.50},
{name:'Dark Neb.', type:'darkneb', rough:0.3, emit:0.20},
{name:'Vidro', type:'glass', rough:0.0, emit:0.0},
];
const BGS = ['#1E160B','#F9F4EE','#FF753F'];
const DEFORM_DEF = [
{dist:0.30,freq:1.50,speed:0.60}, // fBm Orgânico
{dist:0.28,freq:1.20,speed:0.80}, // Ondas Radiais
{dist:0.45,freq:1.00,speed:0.30}, // Celular / Lobos
{dist:0.40,freq:1.80,speed:1.20}, // Pulse / Batida
{dist:0.32,freq:1.60,speed:0.45}, // Sulcos / Veias
{dist:0.38,freq:1.40,speed:0.70}, // Pinçamento
{dist:0.45,freq:1.80,speed:0.35}, // Celular / Voronoi
{dist:0.35,freq:1.20,speed:0.45}, // Torsão
];
// ─── GLSL ──────────────────────────────────────────────────────────────────────
const NOISE_GLSL = `
vec3 _m3(vec3 x){return x-floor(x*(1./289.))*289.;}
vec4 _m4(vec4 x){return x-floor(x*(1./289.))*289.;}
vec4 _pm(vec4 x){return _m4(((x*34.)+1.)*x);}
vec4 _ti(vec4 r){return 1.79284291400159-0.85373472095314*r;}
float snoise(vec3 v){
const vec2 C=vec2(1./6.,1./3.);const vec4 D=vec4(0.,.5,1.,2.);
vec3 i=floor(v+dot(v,C.yyy));vec3 x0=v-i+dot(i,C.xxx);
vec3 g=step(x0.yzx,x0.xyz);vec3 l=1.-g;
vec3 i1=min(g.xyz,l.zxy);vec3 i2=max(g.xyz,l.zxy);
vec3 x1=x0-i1+C.xxx;vec3 x2=x0-i2+C.yyy;vec3 x3=x0-D.yyy;
i=_m3(i);
vec4 p=_pm(_pm(_pm(i.z+vec4(0.,i1.z,i2.z,1.))+i.y+vec4(0.,i1.y,i2.y,1.))+i.x+vec4(0.,i1.x,i2.x,1.));
float n_=.142857142857;vec3 ns=n_*D.wyz-D.xzx;
vec4 j=p-49.*floor(p*ns.z*ns.z);
vec4 x_=floor(j*ns.z);vec4 y_=floor(j-7.*x_);
vec4 xv=x_*ns.x+ns.yyyy;vec4 yv=y_*ns.x+ns.yyyy;
vec4 h=1.-abs(xv)-abs(yv);
vec4 b0=vec4(xv.xy,yv.xy);vec4 b1=vec4(xv.zw,yv.zw);
vec4 s0=floor(b0)*2.+1.;vec4 s1=floor(b1)*2.+1.;
vec4 sh=-step(h,vec4(0.));
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
vec3 p0=vec3(a0.xy,h.x);vec3 p1=vec3(a0.zw,h.y);
vec3 p2=vec3(a1.xy,h.z);vec3 p3=vec3(a1.zw,h.w);
vec4 nm=_ti(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
p0*=nm.x;p1*=nm.y;p2*=nm.z;p3*=nm.w;
vec4 m=max(.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
m=m*m;return 42.*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}
float fbm(vec3 p,float f,float t){
float v=0.,a=.5;
v+=a*snoise(p*f+vec3(t*.38,t*.31,t*.27));a*=.5;f*=2.08;
v+=a*snoise(p*f+vec3(t*.22,t*.41,t*.33));a*=.5;f*=2.12;
v+=a*snoise(p*f+vec3(t*.17,t*.28,t*.45));a*=.5;f*=2.05;
v+=a*snoise(p*f+vec3(t*.35,t*.19,t*.24));
return v;
}
float wfbm(vec3 p,float f,float t){
// Cellular lobes — 12 rounded bumps on the sphere surface
// Creates morula/blastocyst look: cells separated by grooves
float ts=t*0.12;
vec3 pn=normalize(p);
float n=0.;
// 12 lobe directions (roughly icosahedral + face centres)
// Each is a smooth cosine-power bump that slowly rotates
for(int i=0;i<12;i++){
float fi=float(i);
// Distribute directions via golden angle on sphere
float phi=acos(1.-2.*(fi+.5)/12.);
float theta=3.88322*(fi+.5); // golden angle * i
vec3 d=vec3(sin(phi)*cos(theta), cos(phi), sin(phi)*sin(theta));
// Slow independent orbit per lobe
float ph=fi*0.5236+ts*(0.25+fi*0.035);
float ca=cos(ph*.6),sa=sin(ph*.6);
vec3 rd=normalize(vec3(d.x*ca-d.z*sa, d.y+sin(ph*.4)*.12, d.x*sa+d.z*ca));
float dt=max(0.,dot(pn,rd));
// Power 5 gives compact rounded lobe, size varies slightly per lobe
float sz=0.88+sin(fi*1.37)*.06;
n+=pow(dt,5.)*sz;
}
n=n/3.2-0.5;
// Micro-texture: fine noise for skin-like pores in the grooves
n+=snoise(p*f*2.5+vec3(ts*.5,ts*.4,ts*.6))*.07;
return n;
}
float rfbm(vec3 p,float f,float t){
float v=0.,a=.5,prev=1.;
for(int i=0;i<4;i++){float n=1.-abs(snoise(p*f+vec3(t*.3,t*.25,t*.35)));v+=n*n*a*prev;prev=n;a*=.5;f*=2.1;}
return v;
}
float vor(vec3 p){
vec3 ip=floor(p),fp=fract(p);float md=8.;
vec3 nb,h,rp;float d;
#define VC(ox,oy,oz) nb=vec3(float(ox),float(oy),float(oz));h=_m3(ip+nb+vec3(127.1,311.7,74.7));rp=fract(sin(h*vec3(127.1,311.7,74.7))*43758.5453);d=length(nb+rp-fp);md=min(md,d);
VC(-1,-1,-1)VC(0,-1,-1)VC(1,-1,-1)VC(-1,0,-1)VC(0,0,-1)VC(1,0,-1)VC(-1,1,-1)VC(0,1,-1)VC(1,1,-1)
VC(-1,-1,0)VC(0,-1,0)VC(1,-1,0)VC(-1,0,0)VC(0,0,0)VC(1,0,0)VC(-1,1,0)VC(0,1,0)VC(1,1,0)
VC(-1,-1,1)VC(0,-1,1)VC(1,-1,1)VC(-1,0,1)VC(0,0,1)VC(1,0,1)VC(-1,1,1)VC(0,1,1)VC(1,1,1)
#undef VC
return md;
}
float deformN(vec3 p,float fr,float t,float mode){
float n=0.;
if(mode<.5) n=fbm(p,fr,t);
else if(mode<1.5){float r=length(p);n=sin(r*fr*5.5-t*3.+atan(p.z,p.x)*1.5)*.5+.5;n+=snoise(p*fr*.5+vec3(t*.2))*.3;n=n*2.-1.;}
else if(mode<2.5) n=wfbm(p,fr,t);
else if(mode<3.5){float s=sin(t*2.8)*.5+.5;n=sin(p.y*fr*4.+t*2.)*.4+sin(p.x*fr*3.2+t*1.7)*.3+sin(p.z*fr*5.+t*2.5)*.2+snoise(p*fr+vec3(t*.5))*.3;n*=.8+s*.4;}
else if(mode<4.5){n=rfbm(p,fr,t)*2.-1.;n+=snoise(p*fr*3.+vec3(t*.15))*.15;}
else if(mode<5.5){float lat=asin(clamp(p.y/max(length(p),.001),-1.,1.));n=fbm(p,fr,t)*.5+cos(lat*2.5)*sin(t*1.2)*.5+snoise(p*fr*2.+vec3(t*.4,0.,t*.3))*.25;}
else if(mode<6.5){float v=vor(p*fr+vec3(t*.25,t*.18,t*.22));float v2=vor(p*fr*2.1+vec3(t*.35,t*.28,t*.15));n=(1.-v)*.7+(1.-v2)*.3;n=n*2.-1.;}
else {
// Torsão: helical twist — rotates surface around Y axis proportional to height
// Creates elegant twisted/spiral organic forms
float twist=p.y*fr*1.8+t*0.4;
float ca=cos(twist),sa=sin(twist);
vec3 tp=vec3(p.x*ca-p.z*sa, p.y, p.x*sa+p.z*ca);
n=fbm(tp,fr*1.2,t*0.5)*0.9;
// Add secondary wave for more organic feel
n+=sin(p.y*fr*3.+t*1.2)*0.25;
n+=snoise(tp*fr*2.+vec3(t*.2,t*.15,t*.18))*0.15;
}
return n;
}
`;
// Main vertex shader (solid/glass materials)
const VERT = NOISE_GLSL + `
uniform float uTime,uFreq,uAmp,uSpeed,uMode;
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vec3 p=position; float t=uTime*uSpeed;
float n=deformN(p,uFreq,t,uMode);
vNoise=n; vY=p.y;
vec3 d=p+normal*n*uAmp;
vN=normalMatrix*normalize(d);
vW=normalize(mat3(modelMatrix)*normalize(d));
vP=(modelMatrix*vec4(d,1.)).xyz;
gl_Position=projectionMatrix*modelViewMatrix*vec4(d,1.);
}`;
// Static vertex (no noise deformation — used for cluster spheres & glass)
const VERT_STATIC = `
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vN=normalMatrix*normal;
vW=normalize(mat3(modelMatrix)*normal);
vP=(modelMatrix*vec4(position,1.)).xyz;
vNoise=0.; vY=position.y;
gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);
}`;
// Wireframe vertex (barycentric)
const VERT_WIRE = NOISE_GLSL + `
uniform float uTime,uFreq,uAmp,uSpeed,uMode;
attribute vec3 bary;
varying vec3 vBary,vW; varying float vNoise;
void main(){
vec3 p=position; float t=uTime*uSpeed;
float n=deformN(p,uFreq,t,uMode);
vNoise=n; vBary=bary;
vec3 d=p+normal*n*uAmp;
vW=normalize(mat3(modelMatrix)*normalize(d));
gl_Position=projectionMatrix*modelViewMatrix*vec4(d,1.);
}`;
// ─── Colour helpers (shared GLSL) ──────────────────────────────────────────────
// NOTE: uHueA, uHueB, uSatA declared here — every frag shader using COL_GLSL
// must NOT redeclare them. FRAG_WIRE/FRAG_FLAT declare them separately.
const COL_GLSL = `
vec3 hsl(float h,float s,float l){
vec3 rgb=clamp(abs(mod(h*6.+vec3(0.,4.,2.),6.)-3.)-1.,0.,1.);
return l+s*(rgb-.5)*(1.-abs(2.*l-1.));
}
// Smooth shortest-path hue blend (avoids jumping across the colour wheel)
float hueBlend(float a,float b,float t){
float d=b-a; d=d-floor(d+.5);
return fract(a+d*t);
}
// Tipo colour uniforms — declared once here, shared by all COL_GLSL shaders
uniform float uHueA,uHueB,uSatA;
// nSpec: spectral colour tinted towards active tipos
// uSatA=0 → near-white base, uSatA=1 → pure solid tipo colour
// Institutional solid colour — bypasses spectral system entirely
uniform float uSolid; // 0=tipo mode, 1=institutional solid
uniform vec3 uSolidCol; // the RGB institutional colour
vec3 nSpec(vec3 W,float off,float t){
// Institutional mode: return solid colour with subtle shading from normal
if(uSolid > .5){
float shade=clamp(W.y*.18+.88,0.72,1.08); // very subtle top/bottom shading
return uSolidCol*shade;
}
float baseH=fract(atan(W.z,W.x)/(6.28318)+.5+W.y*.25+off+t*.06);
float tipoH=hueBlend(uHueB,uHueA,W.y*.5+.5);
float h=hueBlend(baseH,tipoH,uSatA);
float sat=mix(.10,1.,uSatA);
float lit=mix(.93,.58,uSatA*uSatA);
return hsl(fract(h),sat,lit);
}
// Institutional solid colour — call at top of main() when uSolid>0.5
// Returns the solid colour with proper 3D diffuse shading
vec4 solidCol(vec3 N,vec3 V,float opa){
vec3 L0=normalize(vec3(2.2,3.5,2.5));
vec3 L1=normalize(vec3(-2.,-1.2,1.8));
vec3 L2=normalize(vec3(-.5,1.2,-2.8));
float d=clamp(max(0.,dot(N,L0))*.75+.25+max(0.,dot(N,L1))*.25+max(0.,dot(N,L2))*.1,0.15,1.2);
float spec=pow(max(0.,dot(normalize(V+L0),N)),32.)*.22;
float rim=pow(1.-max(0.,dot(N,V)),4.)*.1;
return vec4(clamp(uSolidCol*d+vec3(spec)+uSolidCol*rim,0.,1.),opa);
}
float fr(vec3 V,vec3 N,float pw){return pow(1.-max(0.,dot(V,N)),pw);}
vec3 sky(vec3 R){
float up=clamp(R.y*.5+.5,0.,1.);
return mix(vec3(.03,.03,.07),vec3(.45,.62,.9),up*up)
+vec3(1.,.88,.65)*pow(max(0.,dot(R,normalize(vec3(1.5,2.,1.)))),60.)*2.;
}
float sp(vec3 N,vec3 V,float sh){
vec3 L0=normalize(vec3(3.,4.,3.));vec3 L1=normalize(vec3(-3.,-2.,3.));
return pow(max(0.,dot(reflect(-L0,N),V)),sh)
+pow(max(0.,dot(reflect(-L1,N),V)),sh*.5)*.4;
}
`;
// Fragment shaders
// All COL_GLSL-based shaders share: hsl, hueBlend, nSpec, fr, sky, sp, uHueA, uHueB, uSatA
// They additionally declare their own: uA, uB, uE, uRough, uOpac, uTime, uEmit + varyings
const FRAG_SPECTRAL = COL_GLSL + `
uniform vec3 uA,uB,uE; uniform float uRough,uOpac,uTime,uEmit;
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vec3 N=normalize(vN),V=normalize(cameraPosition-vP),WN=normalize(vW);
if(uSolid>.5){gl_FragColor=solidCol(normalize(vN),normalize(cameraPosition-vP),uOpac);return;}
vec3 c1=nSpec(WN,0.,uTime),c2=nSpec(WN,.33,uTime);
vec3 col=mix(c1,c2,.4+vNoise*.3);
col*=clamp(vNoise*.5+.75,.3,1.1);
col+=vec3(sp(N,V,max(20.,160.-uRough*150.)))*.9;
col+=nSpec(WN,.5,uTime)*fr(V,N,2.5)*.8;
col=mix(col,col*uA*3.,uEmit*.3);
gl_FragColor=vec4(clamp(col,0.,1.),uOpac);
}`;
const FRAG_IRIDESCENT = COL_GLSL + `
uniform vec3 uA,uB,uE; uniform float uRough,uOpac,uTime,uEmit;
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vec3 N=normalize(vN),V=normalize(cameraPosition-vP),WN=normalize(vW);
if(uSolid>.5){gl_FragColor=solidCol(normalize(vN),normalize(cameraPosition-vP),uOpac);return;}
if(uSolid>.5){gl_FragColor=solidCol(normalize(vN),normalize(cameraPosition-vP),uOpac);return;}
float cosNV=max(0.,dot(N,V));
float h1=cosNV*4.2+vNoise*1.8+uTime*.22+vY*.7;
float h2=cosNV*6.5+vNoise*2.4-uTime*.15+vY*1.2;
// Tint iridescent hues towards active tipos
float ih1=hueBlend(hueBlend(uHueB,uHueA,cosNV),h1,1.-uSatA);
float ih2=hueBlend(uHueA,h2,1.-uSatA);
vec3 i1=hsl(fract(ih1),mix(.5,1.,uSatA),mix(.8,.55,uSatA));
vec3 i2=hsl(fract(ih2),mix(.4,.9,uSatA),.6)*.6;
float f1=fr(V,N,2.),f2=fr(V,N,4.5);
vec3 base=mix(uA,uB,clamp(vY*.5+.5,0.,1.)*.5+clamp(vNoise*.5+.5,0.,1.)*.5);
vec3 col=base*.2+sky(reflect(-V,N))*(1.-uRough)*.35;
col+=i1*(f1*.7+clamp(vNoise*.5+.5,0.,1.)*.25)+i2*f2*.5;
col+=vec3(sp(N,V,max(30.,180.-uRough*170.)));
col+=hsl(fract(ih1+.5),mix(.4,1.,uSatA),.7)*f2*.5+uE*uEmit*.7;
gl_FragColor=vec4(clamp(col,0.,1.),uOpac);
}`;
const FRAG_GLOW = COL_GLSL + `
uniform vec3 uA,uB,uE; uniform float uRough,uOpac,uTime,uEmit;
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vec3 N=normalize(vN),V=normalize(cameraPosition-vP),WN=normalize(vW);
if(uSolid>.5){gl_FragColor=solidCol(normalize(vN),normalize(cameraPosition-vP),uOpac);return;}
vec3 sc1=nSpec(WN,0.,uTime),sc2=nSpec(WN,.25,uTime);
float pulse=(.7+.3*sin(uTime*1.9+vY*2.5+vNoise*1.8))*(.8+.2*sin(uTime*3.1-vNoise*3.2));
float rb=clamp(vNoise*2.+.3,0.,1.);
vec3 emit=mix(sc1,sc2,.5)*uEmit*pulse*(1.5+rb*1.5);
emit+=nSpec(WN,uTime*.05,0.)*uEmit*.4*pulse;
float f1=fr(V,N,2.2),f2=fr(V,N,5.);
vec3 col=sc1*.05+emit+sc2*f1*uEmit*1.4+vec3(f2)*.4;
col+=vec3(sp(N,V,25.))*.4*mix(sc1,sc2,.5);
gl_FragColor=vec4(clamp(col,0.,1.),uOpac);
}`;
const FRAG_CHROME = COL_GLSL + `
uniform vec3 uA,uB,uE; uniform float uRough,uOpac,uTime,uEmit;
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vec3 N=normalize(vN+vec3(vNoise)*uRough*.25),V=normalize(cameraPosition-vP);
if(uSolid>.5){gl_FragColor=solidCol(normalize(vN),normalize(cameraPosition-vP),uOpac);return;}
vec3 WN=normalize(vW),R=reflect(-V,N);
vec3 nc=nSpec(WN,uTime*.07,0.);
vec3 col=sky(R)*(nc*.5+.5);
col+=vec3(pow(max(0.,dot(reflect(-normalize(vec3(2.,3.,2.)),N),V)),max(4.,220.-uRough*210.))*1.5);
col+=nc*fr(V,N,3.5)*.4;
col=mix(col,nc*.3,uRough*.6)+uE*uEmit*.4;
gl_FragColor=vec4(clamp(col,0.,1.),uOpac);
}`;
const FRAG_NEBULA = COL_GLSL + `
uniform vec3 uA,uB,uE; uniform float uRough,uOpac,uTime,uEmit;
varying vec3 vN,vP,vW; varying float vNoise,vY;
float h3(vec3 p){p=fract(p*vec3(443.8975,397.2973,491.1871));p+=dot(p,p.yzx+19.19);return fract((p.x+p.y)*p.z);}
float vnoise(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(mix(h3(i),h3(i+vec3(1,0,0)),f.x),mix(h3(i+vec3(0,1,0)),h3(i+vec3(1,1,0)),f.x),f.y),mix(mix(h3(i+vec3(0,0,1)),h3(i+vec3(1,0,1)),f.x),mix(h3(i+vec3(0,1,1)),h3(i+vec3(1,1,1)),f.x),f.y),f.z)*2.-1.;}
float brush(vec3 P,vec3 N,float freq){
vec3 up=abs(N.y)>.85?vec3(1.,0.,0.):vec3(0.,1.,0.);
vec3 T=normalize(cross(N,up));vec3 B=cross(N,T);
vec3 sp2=vec3(dot(P,T)*5.5*freq,dot(P,B)*1.1*freq,dot(P,N)*0.9*freq);
float s=vnoise(sp2);s+=vnoise(sp2*vec3(1.,2.1,1.3)+vec3(3.7,1.2,5.1))*.5;
s+=vnoise(sp2*vec3(1.,4.3,1.6)+vec3(7.3,2.8,1.4))*.25;return s*(1./1.75);
}
void main(){
vec3 N=normalize(vN),V=normalize(cameraPosition-vP);
if(uSolid>.5){gl_FragColor=solidCol(normalize(vN),normalize(cameraPosition-vP),uOpac);return;}
float tY=clamp(vY*.45+.5,0.,1.);
vec3 base=nSpec(normalize(vW),0.,uTime);
base=mix(base*1.05,base*.88,brush(vP,N,uRough*1.2+0.9)*.5+.5);
vec3 L0=normalize(vec3(1.5,2.8,1.2)),L1=normalize(vec3(-1.8,-1.2,1.6)),L2=normalize(vec3(0.,-2.,-.8));
float d0=dot(N,L0)*.65+.35,d1=dot(N,L1)*.3+.25,d2=max(0.,dot(N,L2))*.15;
vec3 col=base*(max(0.,d0)+max(0.,d1)+d2);
col+=nSpec(normalize(vW),.5,uTime)*fr(V,N,4.)*.15;
col+=uA*uEmit*.2;
gl_FragColor=vec4(clamp(col,0.,1.),uOpac);
}`;
const FRAG_HOLOGRAM = COL_GLSL + `
uniform vec3 uA,uB,uE; uniform float uRough,uOpac,uTime,uEmit;
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vec3 N=normalize(vN),V=normalize(cameraPosition-vP),WN=normalize(vW);
if(uSolid>.5){gl_FragColor=solidCol(normalize(vN),normalize(cameraPosition-vP),uOpac);return;}
float f1=fr(V,N,1.8),f2=fr(V,N,4.5);
float scan=abs(sin((vY*14.+uTime*1.2)*3.14159))*.6+.4;
scan*=abs(sin((vY*55.-uTime*.7)*3.14159))*.35+.65;
vec3 band=nSpec(WN,uTime*.12,0.);
float h2=hueBlend(uHueA,hueBlend(uHueB,uHueA,vY*.5+.5),uSatA*.5);
vec3 band2=hsl(fract(h2+vY*.3+uTime*.2),mix(.5,1.,uSatA),mix(.8,.55,uSatA));
vec3 col=mix(band,band2,.5)*scan;
col+=f1*(band*.6+vec3(.3,.7,1.)*.4)*1.4+f2*vec3(.9,.95,1.)*.7;
col+=vec3(pow(max(0.,dot(reflect(-normalize(vec3(1.5,2.5,2.)),N),V)),60.))*1.2;
col+=uE*uEmit*.5;
float alpha=f1*.85+scan*.12+clamp(vNoise*.3,0.,.3);
gl_FragColor=vec4(clamp(col,0.,1.),clamp(alpha,0.,1.)*uOpac);
}`;
const FRAG_DARKNEB = COL_GLSL + `
uniform vec3 uA,uB,uE; uniform float uRough,uOpac,uTime,uEmit;
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vec3 N=normalize(vN),V=normalize(cameraPosition-vP),WN=normalize(vW);
float cosNV=max(0.,dot(N,V));
float f1=fr(V,N,1.5),f2=fr(V,N,3.5),f3=fr(V,N,7.),f4=fr(V,N,12.);
float fog=clamp(vNoise*.5+.35,0.,1.);
vec3 interior=mix(vec3(.01,.01,.06),mix(vec3(.02,.03,.12)*fog,vec3(.04,.01,.08)*(1.-fog),.5),.7);
float hueIn=cosNV*1.8+uTime*.08+vNoise*.3;
// Tint inner glow towards tipo colours
vec3 iriIn=mix(hsl(fract(hueBlend(uHueB,uHueA,f1)+.62),mix(.5,.95,uSatA),.55),
hsl(fract(hueBlend(uHueA,uHueB,f1)+.78),mix(.5,.95,uSatA),.6),f1);
vec3 golden=nSpec(WN,uTime*.04,0.);
vec3 col=interior+iriIn*f1*1.1+nSpec(WN,.3,uTime)*f2*.7;
col+=golden*(f3*2.2+f4*4.5);
col+=vec3(sp(N,V,max(80.,200.-uRough*180.)))*.3+uE*uEmit*.4*golden;
float alpha=mix(f2*uOpac,uOpac,clamp(f1*1.5,0.,1.));
gl_FragColor=vec4(clamp(col,0.,1.),clamp(alpha,0.,1.));
}`;
const FRAG_GLASS = COL_GLSL + `
uniform vec3 uA,uB; uniform float uRough,uOpac,uTime;
varying vec3 vN,vP,vW; varying float vNoise,vY;
void main(){
vec3 N=normalize(vN),WN=normalize(vW),V=normalize(cameraPosition-vP),R=reflect(-V,N);
if(uSolid>.5){gl_FragColor=solidCol(normalize(vN),normalize(cameraPosition-vP),uOpac);return;}
float f1=fr(V,N,3.5),f2=fr(V,N,1.8);
float cosA=max(0.,dot(V,N));
// Glass iridescence tinted by tipo
vec3 iri=nSpec(WN,cosA*.8+uTime*.1,0.);
float h2=hueBlend(uHueA,uHueB,cosA*2.8);
vec3 iri2=hsl(fract(h2+uTime*.14),mix(.4,.9,uSatA),mix(.85,.6,uSatA));
float sh=max(8.,130.-uRough*120.);
vec3 L0=normalize(vec3(1.8,2.5,2.)),L1=normalize(vec3(-2.,-1.5,1.5));
float s0=pow(max(0.,dot(reflect(-L0,N),V)),sh);
float s1=pow(max(0.,dot(reflect(-L1,N),V)),sh*.6)*.5;
float c0=pow(max(0.,dot(WN,normalize(vec3(.4,.7,.6)))),10.)*.9;
float c1=pow(max(0.,dot(WN,normalize(vec3(-.5,.2,.8)))),14.)*.5;
vec3 col=(iri*.7+vec3(.8,.9,1.)*.3)*c0+(iri2*.6+vec3(.7,.85,1.)*.4)*c1;
col+=iri*f2*.6+sky(R)*f1*.4+vec3(s0+s1)+(iri2*.4+vec3(.9,.95,1.)*.6)*f1*.9;
float alpha=f1*.88+f2*.2+(s0+s1)*.45+(c0+c1)*.2;
gl_FragColor=vec4(clamp(col,0.,1.),clamp(alpha,0.,1.)*uOpac);
}`;
// Wire and Flat: standalone shaders — declare hsl, hueBlend, and tipo uniforms themselves
// ─── Shared GLSL helpers (for standalone shaders that don't use COL_GLSL) ─────
const SHARED_GLSL = `
vec3 hsl(float h,float s,float l){
vec3 rgb=clamp(abs(mod(h*6.+vec3(0.,4.,2.),6.)-3.)-1.,0.,1.);
return l+s*(rgb-.5)*(1.-abs(2.*l-1.));
}
float hueBlend(float a,float b,float t){float d=b-a;d=d-floor(d+.5);return fract(a+d*t);}
uniform float uHueA,uHueB,uSatA,uSolid;
uniform vec3 uSolidCol;
`;
const FRAG_WIRE = SHARED_GLSL + `
varying vec3 vBary,vW; varying float vNoise;
uniform float uOpac,uTime;
void main(){
float edge=min(vBary.x,min(vBary.y,vBary.z));
float alpha=1.-smoothstep(0.,.008,edge);
if(alpha<.01) discard;
vec3 WN=normalize(vW);
vec3 wcol;
if(uSolid>.5){
float shade=clamp(WN.y*.18+.88,0.72,1.08);
wcol=uSolidCol*shade;
} else {
float baseH=fract(atan(WN.z,WN.x)/6.28318+.5+WN.y*.25+uTime*.07);
float tipoH=hueBlend(uHueB,uHueA,WN.y*.5+.5);
float hue=hueBlend(baseH,tipoH,uSatA);
float sat=mix(.12,1.,uSatA); float lit=mix(.88,.58,uSatA*uSatA);
wcol=hsl(hue,sat,lit);
}
gl_FragColor=vec4(wcol,alpha*uOpac);
}`;
const FRAG_FLAT = SHARED_GLSL + `
varying vec3 vN,vP,vW; varying float vNoise,vY;
uniform float uOpac,uTime;
void main(){
vec3 WN=normalize(vW);
vec3 fcol;
if(uSolid>.5){
float shade=clamp(WN.y*.18+.88,0.72,1.08);
fcol=uSolidCol*shade;
} else {
float baseH=fract(atan(WN.z,WN.x)/6.28318+.5+WN.y*.25+uTime*.05);
float tipoH=hueBlend(uHueB,uHueA,WN.y*.5+.5);
float hue=hueBlend(baseH,tipoH,uSatA);
float sat=mix(.10,1.,uSatA); float lit=mix(.92,.58,uSatA*uSatA);
fcol=hsl(hue,sat,lit);
}
gl_FragColor=vec4(fcol,uOpac);
}`;
// ─── Points shaders (defined once, reused on every build) ─────────────────────
const FRAG_PTS = SHARED_GLSL + `
uniform float uOpac,uTime;
varying vec3 vW; varying float vNoise;
void main(){
vec2 c=gl_PointCoord*2.-1.; if(dot(c,c)>1.) discard;
vec3 WN=normalize(vW);
vec3 pcol;
if(uSolid>.5){
float shade=clamp(WN.y*.18+.88,0.72,1.08);
pcol=uSolidCol*shade;
} else {
float baseH=fract(atan(WN.z,WN.x)/6.28318+.5+WN.y*.25+uTime*.06);
float tipoH=hueBlend(uHueB,uHueA,WN.y*.5+.5);
float h=hueBlend(baseH,tipoH,uSatA);
float sat=mix(.10,1.,uSatA); float lit=mix(.93,.58,uSatA*uSatA);
pcol=hsl(h,sat,lit);
}
gl_FragColor=vec4(pcol,uOpac);
}`;
const FRAG_PTS_M = SHARED_GLSL + `
uniform float uOpac,uTime;
varying vec3 vW; varying float vNoise;
void main(){
vec2 c=gl_PointCoord*2.-1.; if(dot(c,c)>1.) discard;
vec3 WN=normalize(vW);
vec3 pcol;
if(uSolid>.5){
float shade=clamp(WN.y*.18+.88,0.72,1.08);
pcol=uSolidCol*shade;
} else {
float baseH=fract(atan(WN.z,WN.x)/6.28318+.5+WN.y*.25+uTime*.06);
float tipoH=hueBlend(uHueB,uHueA,WN.y*.5+.5);
float h=hueBlend(baseH,tipoH,uSatA);
float sat=mix(.10,1.,uSatA); float lit=mix(.93,.58,uSatA*uSatA);
pcol=hsl(h,sat,lit);
}
gl_FragColor=vec4(pcol,uOpac);
}`;
// VERT_PTS: VERT with gl_PointSize injected (built once from VERT at runtime)
const VERT_PTS = VERT.replace(
'gl_Position=projectionMatrix*modelViewMatrix*vec4(d,1.);',
'gl_PointSize=max(0.8,1.8-length((modelViewMatrix*vec4(d,1.)).xyz)*.2);\n gl_Position=projectionMatrix*modelViewMatrix*vec4(d,1.);'
);
const VERT_PTS_M = VERT_STATIC.replace(
'gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);',
'gl_PointSize=max(0.8,1.6-length((modelViewMatrix*vec4(position,1.)).xyz)*.18);\n gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);'
);
// ─── Three.js setup ────────────────────────────────────────────────────────────
const PW=()=>window.innerWidth-268, PH=()=>window.innerHeight;
const renderer=new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance',preserveDrawingBuffer:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(PW(),PH());
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.3;
document.body.appendChild(renderer.domElement);
const scene=new THREE.Scene();
scene.background=new THREE.Color(BGS[0]);
const camera=new THREE.PerspectiveCamera(48,PW()/PH(),.1,100);
camera.position.z=4.2;
const aL=new THREE.AmbientLight(0xffffff,.2); scene.add(aL);
const pLs=[
new THREE.PointLight(0xd4a870,5,22),new THREE.PointLight(0x7090b0,3,22),
new THREE.PointLight(0xc07040,2,22),new THREE.PointLight(0x90b070,1.5,22),
];
pLs.forEach(l=>scene.add(l));
// PMREMGenerator for env maps
const pmrem=new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
let envTex=null;
function makeEnv(cA,cB){
const sc=new THREE.Scene();
sc.add(new THREE.HemisphereLight(cA,cB,1.5));
const kl=new THREE.DirectionalLight(0xffffff,1.2); kl.position.set(3,5,3); sc.add(kl);
const fl=new THREE.DirectionalLight(cB,0.6); fl.position.set(-4,-2,2); sc.add(fl);
if(envTex) envTex.dispose();
envTex=pmrem.fromScene(sc).texture;
return envTex;
}
let grp=new THREE.Group(); scene.add(grp);
let uni=null, clusterUnis=[];
// ─── Geometry ──────────────────────────────────────────────────────────────────
function buildGeo(idx){
const geo=new THREE.SphereGeometry(1,112,112);
SEEDS[idx].build(geo);
geo.computeVertexNormals();
return geo;
}
function addBary(geo){
const g=geo.index?geo.toNonIndexed():geo.clone();
const n=g.attributes.position.count;
const b=new Float32Array(n*3);
for(let i=0;i{
if(m.map) m.map.dispose();
m.dispose();
});
}
}
// ─── Material factory (used by all sub-builders) ───────────────────────────────
// isStatic: use VERT_STATIC (no noise deform). opa: opacity override.
function makeMat(isStatic, opa, pal, mat, cA, cB, cE){
if(S.tex==='wire'){
return new THREE.MeshBasicMaterial({wireframe:true,vertexColors:true,transparent:true,opacity:opa});
}
if(S.tex==='points'){
const gu={uTime:{value:0},uOpac:{value:opa},
uHueA:{value:pal.hueA||0},uHueB:{value:pal.hueB||0},uSatA:{value:pal.satA||0},
uSolid:{value:pal.solid?1:0},
uSolidCol:{value:new THREE.Color(pal.solidR||1,pal.solidG||1,pal.solidB||1)}};
clusterUnis.push(gu);
return new THREE.ShaderMaterial({
vertexShader:VERT_PTS_M,fragmentShader:FRAG_PTS_M,
uniforms:gu,transparent:true,depthWrite:false});
}
if(S.tex==='flat'){
const gu={uTime:{value:0},uFreq:{value:S.freq},uAmp:{value:isStatic?0:S.dist},
uSpeed:{value:S.speed},uMode:{value:S.deform},uOpac:{value:opa},
uHueA:{value:pal.hueA||0},uHueB:{value:pal.hueB||0},uSatA:{value:pal.satA||0},
uSolid:{value:pal.solid?1:0},
uSolidCol:{value:new THREE.Color(pal.solidR||1,pal.solidG||1,pal.solidB||1)}};
clusterUnis.push(gu);
return new THREE.ShaderMaterial({
vertexShader:isStatic?VERT_STATIC:VERT,fragmentShader:FRAG_FLAT,
uniforms:gu,transparent:opa<1,side:THREE.DoubleSide});
}
// Solid materials
const FRAG_MAP={spectral:FRAG_SPECTRAL,iridescent:FRAG_IRIDESCENT,glow:FRAG_GLOW,
chrome:FRAG_CHROME,nebula:FRAG_NEBULA,hologram:FRAG_HOLOGRAM,darkneb:FRAG_DARKNEB};
if(mat.type==='glass'){
const gu={uA:{value:cA.clone()},uB:{value:cB.clone()},
uRough:{value:S.rough},uOpac:{value:opa},uTime:{value:0},
uHueA:{value:pal.hueA||0},uHueB:{value:pal.hueB||0},uSatA:{value:pal.satA||0},
uSolid:{value:pal.solid?1:0},
uSolidCol:{value:new THREE.Color(pal.solidR||1,pal.solidG||1,pal.solidB||1)}};
clusterUnis.push(gu);
return new THREE.ShaderMaterial({
vertexShader:VERT_STATIC,fragmentShader:FRAG_GLASS,
uniforms:gu,transparent:true,side:THREE.DoubleSide,depthWrite:false});
}
const frag=FRAG_MAP[mat.type]||FRAG_SPECTRAL;
const isTr=['hologram','darkneb'].includes(mat.type)||opa<1;
const gu={uTime:{value:0},uFreq:{value:S.freq},uAmp:{value:isStatic?0:S.dist},
uSpeed:{value:S.speed},uMode:{value:S.deform},
uA:{value:cA.clone()},uB:{value:cB.clone()},uE:{value:cE.clone()},
uEmit:{value:S.emit},uOpac:{value:opa},uRough:{value:S.rough},
uHueA:{value:pal.hueA||0},uHueB:{value:pal.hueB||0},uSatA:{value:pal.satA||0},
uSolid:{value:pal.solid?1:0},
uSolidCol:{value:new THREE.Color(pal.solidR||1,pal.solidG||1,pal.solidB||1)}};
if(!uni) uni=gu;
clusterUnis.push(gu);
return new THREE.ShaderMaterial({
vertexShader:isStatic?VERT_STATIC:VERT,fragmentShader:frag,
uniforms:gu,transparent:isTr,side:THREE.DoubleSide,depthWrite:!isTr});
}
// ─── Sub-builders (each handles one surface mode) ──────────────────────────────
function buildCluster(geo, pal, mat, cA, cB, cE){
const defs=[
[0,.1,0,.42,0,0,0],[.32,.1,.05,.28,0,0,0],[-.22,.18,-.08,.22,0,0,0],[.12,-.15,.1,.18,0,0,0],
[-.55,-.2,.1,.30,.35,.30,0],[.55,.45,-.05,.38,.28,.20,1.05],
[-.30,-.55,.2,.12,.55,.15,2.09],[.10,.60,0,.10,.45,.25,3.14],
[-.60,.35,.1,.055,.70,.18,.52],[.45,-.5,.05,.045,.65,.22,1.57],[-.15,-.7,.08,.038,.80,.12,4.19],
];
const cms=[];
const FRAG_MAP={spectral:FRAG_SPECTRAL,iridescent:FRAG_IRIDESCENT,glow:FRAG_GLOW,
chrome:FRAG_CHROME,nebula:FRAG_NEBULA,hologram:FRAG_HOLOGRAM,darkneb:FRAG_DARKNEB};
defs.forEach(([bx,by,bz,r,os,or_,op])=>{
const g=buildGeo(S.seed);
const p=g.attributes.position;
for(let v=0;v.15?S.opac:S.opac*.75;
const m=makeMat(false,opa,pal,mat,cA,cB,cE);
const mesh=(S.tex==='points')?new THREE.Points(g,m):new THREE.Mesh(g,m);
mesh.position.set(bx,by,bz);
grp.add(mesh);
if(mat.type==='glass'){
const bk={uA:{value:cA.clone()},uB:{value:cB.clone()},
uRough:{value:S.rough},uOpac:{value:opa*.45},uTime:{value:0}};
clusterUnis.push(bk);
const backMesh=new THREE.Mesh(g.clone(),new THREE.ShaderMaterial({
vertexShader:VERT_STATIC,fragmentShader:FRAG_GLASS,
uniforms:bk,transparent:true,side:THREE.BackSide,depthWrite:false}));
backMesh.position.set(bx,by,bz);
grp.add(backMesh);
}
cms.push({mesh,bx,by,bz,os,or:or_,op});
});
grp.userData.cms=cms;
grp.scale.setScalar(S.size*1.8);
}
function buildWire(geo, pal){
const wg=addBary(geo);
uni={uTime:{value:0},uFreq:{value:S.freq},uAmp:{value:S.dist},
uSpeed:{value:S.speed},uMode:{value:S.deform},uOpac:{value:S.opac},
uHueA:{value:pal.hueA||0},uHueB:{value:pal.hueB||0},uSatA:{value:pal.satA||0},
uSolid:{value:pal.solid?1:0},
uSolidCol:{value:new THREE.Color(pal.solidR||1,pal.solidG||1,pal.solidB||1)}};
grp.add(new THREE.Mesh(wg,new THREE.ShaderMaterial({
vertexShader:VERT_WIRE,fragmentShader:FRAG_WIRE,
uniforms:uni,transparent:true,depthWrite:false,side:THREE.DoubleSide})));
grp.scale.setScalar(S.size);
}
function buildPoints(geo, pal, cA, cB, cE){
uni={uTime:{value:0},uFreq:{value:S.freq},uAmp:{value:S.dist},
uSpeed:{value:S.speed},uMode:{value:S.deform},uOpac:{value:S.opac},
uHueA:{value:pal.hueA||0},uHueB:{value:pal.hueB||0},uSatA:{value:pal.satA||0},
uSolid:{value:pal.solid?1:0},
uSolidCol:{value:new THREE.Color(pal.solidR||1,pal.solidG||1,pal.solidB||1)},
uA:{value:cA.clone()},uB:{value:cB.clone()},uE:{value:cE.clone()},uEmit:{value:S.emit}};
grp.add(new THREE.Points(geo,new THREE.ShaderMaterial({
vertexShader:VERT_PTS,fragmentShader:FRAG_PTS,
uniforms:uni,transparent:true,depthWrite:false})));
grp.scale.setScalar(S.size);
}
function buildSolid(geo, pal, mat, cA, cB, cE){
if(mat.type==='glass'){
const gu={uA:{value:cA.clone()},uB:{value:cB.clone()},uRough:{value:S.rough},uOpac:{value:S.opac},uTime:{value:0}};
const bk={uA:{value:cA.clone()},uB:{value:cB.clone()},uRough:{value:S.rough},uOpac:{value:S.opac*.45},uTime:{value:0}};
grp.add(new THREE.Mesh(geo.clone(),new THREE.ShaderMaterial({vertexShader:VERT_STATIC,fragmentShader:FRAG_GLASS,uniforms:bk,transparent:true,side:THREE.BackSide,depthWrite:false})));
grp.add(new THREE.Mesh(geo,new THREE.ShaderMaterial({vertexShader:VERT_STATIC,fragmentShader:FRAG_GLASS,uniforms:gu,transparent:true,side:THREE.DoubleSide,depthWrite:false})));
grp.userData.glassUni=gu; grp.userData.glassBack=bk;
} else {
clusterUnis=[];
const m=makeMat(false,S.opac,pal,mat,cA,cB,cE);
grp.add(new THREE.Mesh(geo,m));
if(mat.type==='glow'){
const hg=buildGeo(S.seed);
grp.add(new THREE.Mesh(hg,new THREE.MeshBasicMaterial({color:cB,transparent:true,opacity:.06,side:THREE.BackSide})));
}
}
grp.scale.setScalar(S.size);
}
function build(){
// Dispose GPU resources before clearing
grp.children.forEach(disposeObj);
while(grp.children.length) grp.remove(grp.children[0]);
grp.userData={};
uni=null; clusterUnis=[];
PALS[0]=blendTipos(); currentPal=PALS[0];
const pal=PALS[0];
const mat=MATS[S.mat];
const cA=new THREE.Color(pal.a);
const cB=new THREE.Color(pal.b);
const cE=new THREE.Color(pal.e);
const env=makeEnv(cA.clone(),cB.clone());
scene.environment=env;
pLs[0].color.copy(cA); pLs[1].color.copy(cB); pLs[2].color.set(pal.e);
if(S.tex==='cluster'){
buildCluster(null,pal,mat,cA,cB,cE);
return;
}
const geo=buildGeo(S.seed);
if(S.tex==='wire') { buildWire(geo,pal); return; }
if(S.tex==='points') { buildPoints(geo,pal,cA,cB,cE); return; }
buildSolid(geo,pal,mat,cA,cB,cE);
}
function syncU(){
if(grp.userData.cms){build();return;}
PALS[0] = blendTipos(); currentPal = PALS[0];
const pal=PALS[0];
const cA=new THREE.Color(pal.a),cB=new THREE.Color(pal.b),cE=new THREE.Color(pal.e);
pLs[0].color.copy(cA);pLs[1].color.copy(cB);pLs[2].color.set(pal.e);
// Sync all tracked uniforms (covers both uni and clusterUnis)
const all=[uni,...clusterUnis].filter(Boolean);
all.forEach(u=>{
if(!u) return;
if(u.uA) u.uA.value.copy(cA);
if(u.uB) u.uB.value.copy(cB);
if(u.uE) u.uE.value.copy(cE);
if(u.uEmit!==undefined) u.uEmit.value=S.emit;
if(u.uHueA!==undefined) u.uHueA.value=pal.hueA||0;
if(u.uHueB!==undefined) u.uHueB.value=pal.hueB||0;
if(u.uSatA!==undefined) u.uSatA.value=pal.satA||0;
if(u.uSolid!==undefined) u.uSolid.value=pal.solid?1:0;
if(u.uSolidCol!==undefined) u.uSolidCol.value.setRGB(pal.solidR||1,pal.solidG||1,pal.solidB||1);
if(u.uOpac!==undefined) u.uOpac.value=S.opac;
if(u.uRough!==undefined) u.uRough.value=S.rough;
if(u.uFreq!==undefined) u.uFreq.value=S.freq;
if(u.uAmp!==undefined) u.uAmp.value=S.dist;
if(u.uSpeed!==undefined) u.uSpeed.value=S.speed;
if(u.uMode!==undefined) u.uMode.value=S.deform;
});
if(grp.userData.glassUni){
const g=grp.userData.glassUni,b=grp.userData.glassBack;
g.uA.value.copy(cA);g.uB.value.copy(cB);g.uRough.value=S.rough;g.uOpac.value=S.opac;
if(b){b.uA.value.copy(cA);b.uB.value.copy(cB);b.uRough.value=S.rough;b.uOpac.value=S.opac*.45;}
}
}
// ─── Orbit ─────────────────────────────────────────────────────────────────────
let drag=false,prev={x:0,y:0},rot={x:0,y:0},tgt={x:0,y:0};
renderer.domElement.addEventListener('mousedown',e=>{drag=true;prev={x:e.clientX,y:e.clientY}});
renderer.domElement.addEventListener('touchstart',e=>{drag=true;prev={x:e.touches[0].clientX,y:e.touches[0].clientY}});
window.addEventListener('mousemove',e=>{if(!drag)return;tgt.y+=(e.clientX-prev.x)*.005;tgt.x+=(e.clientY-prev.y)*.005;prev={x:e.clientX,y:e.clientY}});
window.addEventListener('touchmove',e=>{if(!drag)return;tgt.y+=(e.touches[0].clientX-prev.x)*.005;tgt.x+=(e.touches[0].clientY-prev.y)*.005;prev={x:e.touches[0].clientX,y:e.touches[0].clientY}});
window.addEventListener('mouseup',()=>drag=false);
window.addEventListener('touchend',()=>drag=false);
renderer.domElement.addEventListener('wheel',e=>{camera.position.z=Math.max(1.5,Math.min(9,camera.position.z+e.deltaY*.01));},{passive:true});
// ─── Animate ───────────────────────────────────────────────────────────────────
const clock=new THREE.Clock();
function animate(){
requestAnimationFrame(animate);
const t=clock.getElapsedTime();
if(uni) uni.uTime.value=t;
if(grp.userData.glassUni) grp.userData.glassUni.uTime.value=t;
if(grp.userData.glassBack) grp.userData.glassBack.uTime.value=t;
clusterUnis.forEach(u=>{if(u&&u.uTime)u.uTime.value=t;});
rot.x+=(tgt.x-rot.x)*.07; rot.y+=(tgt.y-rot.y)*.07;
if(!drag) tgt.y+=.0022;
grp.rotation.set(rot.x,rot.y,0);
grp.scale.setScalar((grp.scale.x>0?grp.scale.x:S.size)*(1+Math.sin(t*.55)*.008)/(1+Math.sin((t-.016)*.55)*.008));
// Scale breathing
const sc=S.size*(1+Math.sin(t*.55)*.008);
grp.scale.setScalar(sc);
// Cluster float
if(grp.userData.cms){
grp.userData.cms.forEach(({mesh,bx,by,bz,os,or:orr,op})=>{
const a=t*os+op;
mesh.position.set(bx+Math.cos(a)*orr,by+Math.sin(t*.4+op)*.05+Math.sin(t*os*.7)*orr*.4,bz+Math.sin(a)*orr*.6);
mesh.rotation.y=t*.3+op;mesh.rotation.x=t*.18+op*.5;
});
}
// Orbit lights
pLs.forEach((l,i)=>{const a=t*.2+i*1.57;l.position.set(Math.cos(a)*6,Math.sin(a*.65)*5,Math.sin(a)*4);});
renderer.render(scene,camera);
}
// ─── UI ────────────────────────────────────────────────────────────────────────
// Seeds
const seedGrid=document.getElementById('seedGrid');
const ICONS=[
``,
``,
``,
``,
``,
];
SEEDS.forEach((sd,i)=>{
const d=document.createElement('div');
d.className='sb'+(i===0?' active':'');
d.innerHTML=`${sd.name}`;
d.onclick=()=>{document.querySelectorAll('.sb').forEach(b=>b.classList.remove('active'));d.classList.add('active');S.seed=i;build();};
seedGrid.appendChild(d);
});
// Materials
const matGrid=document.getElementById('matGrid');
MATS.forEach((m,i)=>{
const pal=PALS[S.col];
const wrap=document.createElement('div');wrap.className='ms';
const circ=document.createElement('div');circ.className='mc'+(i===0?' active':'');
if(i===0) circ.style.background='conic-gradient(red,orange,yellow,lime,cyan,blue,violet,red)';
else circ.style.background=`radial-gradient(circle at 33% 33%,${pal.b},${pal.a} 55%,#050005)`;
circ.onclick=()=>{
document.querySelectorAll('.mc').forEach(c=>c.classList.remove('active'));
circ.classList.add('active');
S.mat=i;S.rough=m.rough;S.emit=m.emit||0.1;
document.getElementById('sRough').value=S.rough;document.getElementById('vRough').textContent=S.rough.toFixed(2);
document.getElementById('sEmit').value=S.emit;document.getElementById('vEmit').textContent=S.emit.toFixed(2);
build();
};
const lbl=document.createElement('p');lbl.className='mn';lbl.textContent=m.name;
wrap.appendChild(circ);wrap.appendChild(lbl);matGrid.appendChild(wrap);
});
// Colour mode switcher
window.setColMode = function(mode){
colMode = mode;
document.getElementById('tabTipo').classList.toggle('active', mode==='tipo');
document.getElementById('tabInst').classList.toggle('active', mode==='inst');
document.getElementById('paneTipo').style.display = mode==='tipo' ? '' : 'none';
document.getElementById('paneInst').style.display = mode==='inst' ? '' : 'none';
build();
};
// Institutional swatches
const instGrid = document.getElementById('instGrid');
INST_COLS.forEach((ic, i) => {
const sw = document.createElement('div');
sw.className = 'inst-swatch';
if(ic.dark) sw.dataset.instDark = '1';
sw.style.background = ic.hex;
sw.style.border = ic.hex === '#FFFFFF' ? '2px solid rgba(0,0,0,0.12)' : '2px solid transparent';
sw.title = ic.name;
sw.onclick = () => {
if(activeInst === i){
// Deselect → back to base colour
activeInst = null;
sw.classList.remove('active');
} else {
activeInst = i;
document.querySelectorAll('.inst-swatch').forEach(s => s.classList.remove('active'));
sw.classList.add('active');
}
build();
};
instGrid.appendChild(sw);
});
// Tipos
const tipoGrid=document.getElementById('tipoGrid');
const mixBar=document.getElementById('mixBar');
function updateMixBar(){
const active=[...activeTipos].map(i=>TIPOS[i].col);
if(active.length===0){
mixBar.style.background=BASE_COL;
mixBar.style.opacity='0.35';
} else if(active.length===1){
mixBar.style.background=`linear-gradient(90deg,${BASE_COL},${active[0]})`;
mixBar.style.opacity='0.75';
} else {
const allCols=[BASE_COL,...active];
const stops=allCols.map((c,i)=>`${c} ${Math.round(i/(allCols.length-1)*100)}%`).join(',');
mixBar.style.background=`linear-gradient(90deg,${stops})`;
mixBar.style.opacity='0.75';
}
}
TIPOS.forEach((tp,i)=>{
const btn=document.createElement('div');
btn.className='tipo-btn'; // no tipo active on load
btn.style.setProperty('--tipo-col',tp.col);
btn.innerHTML=`${tp.name}`;
btn.onclick=()=>{
if(activeTipos.has(i)){
// Allow full deselection — base colour state
activeTipos.delete(i);
btn.classList.remove('active');
} else {
activeTipos.add(i);
btn.classList.add('active');
}
updateMixBar();
build();
};
tipoGrid.appendChild(btn);
});
updateMixBar();
// Backgrounds
const bgRow=document.getElementById('bgRow');
BGS.forEach((bg,i)=>{
const d=document.createElement('div');d.className='bs'+(i===0?' active':'');
d.style.background=bg;
d.onclick=()=>{document.querySelectorAll('.bs').forEach(b=>b.classList.remove('active'));d.classList.add('active');scene.background=new THREE.Color(bg);};
bgRow.appendChild(d);
});
// Texture
window.setTex=(t,btn)=>{
document.querySelectorAll('.tb').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');S.tex=t;build();
};
// Deform
window.setDeform=(idx,btn)=>{
document.querySelectorAll('.db').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');S.deform=idx;
document.getElementById('shapeSliders').style.display='';
const d=DEFORM_DEF[idx];
S.dist=d.dist;S.freq=d.freq;S.speed=d.speed;
document.getElementById('sDist').value=d.dist;document.getElementById('vDist').textContent=d.dist.toFixed(2);
document.getElementById('sFreq').value=d.freq;document.getElementById('vFreq').textContent=d.freq.toFixed(2);
document.getElementById('sSpeed').value=d.speed;document.getElementById('vSpeed').textContent=d.speed.toFixed(2);
syncU();
};
// Sliders
function sl(id,vid,key,rb){
const el=document.getElementById(id),vo=document.getElementById(vid);
el.addEventListener('input',()=>{const v=parseFloat(parseFloat(el.value).toFixed(2));vo.textContent=v;S[key]=v;rb?build():syncU();});
}
sl('sDist','vDist','dist');sl('sFreq','vFreq','freq');sl('sSpeed','vSpeed','speed');
sl('sSize','vSize','size',true);sl('sRough','vRough','rough');sl('sEmit','vEmit','emit');sl('sOpac','vOpac','opac');
window.addEventListener('resize',()=>{camera.aspect=PW()/PH();camera.updateProjectionMatrix();renderer.setSize(PW(),PH());});
// ─── Capture ───────────────────────────────────────────────────────────────────
let seqOpen=false,seqRunning=false;
function flash(){const f=document.getElementById('flash');f.classList.add('on');setTimeout(()=>f.classList.remove('on'),120);}
// Render at 2K (2× pixel ratio) and restore afterwards
function renderHiRes(fn){
const origRatio = renderer.getPixelRatio();
// 2K target: enough to reach ~2560px on a 1280px viewport
const hiRatio = Math.max(origRatio, 2560 / Math.max(PW(), PH()));
renderer.setPixelRatio(hiRatio);
renderer.setSize(PW(), PH());
renderer.render(scene, camera);
fn();
// Restore original ratio
renderer.setPixelRatio(origRatio);
renderer.setSize(PW(), PH());
renderer.render(scene, camera);
}
window.capFrame=()=>{
flash();
renderHiRes(()=>{
const a=document.createElement('a');
a.href=renderer.domElement.toDataURL('image/png');
a.download='seed-'+new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')+'.png';
a.click();
});
};
window.toggleSeq=e=>{e.preventDefault();seqOpen=!seqOpen;document.getElementById('seqP').classList.toggle('open',seqOpen);};
document.addEventListener('click',e=>{if(seqOpen&&!e.target.closest('#seqP')&&!e.target.closest('#capBtn')){seqOpen=false;document.getElementById('seqP').classList.remove('open');}});
window.startSeq=async()=>{
if(seqRunning)return;seqRunning=true;
const btn=document.getElementById('seqBtn'),bw=document.getElementById('seqBar-wrap'),bar=document.getElementById('seqBar');
const frames=parseInt(document.getElementById('sFrames').value);
const delay=parseInt(document.getElementById('sDelay').value);
btn.disabled=true;btn.textContent='⏳ Capturando...';bw.style.display='block';bar.style.width='0%';
const ts=new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');
for(let i=0;isetTimeout(r,delay));
renderHiRes(()=>{
const a=document.createElement('a');a.href=renderer.domElement.toDataURL('image/png');
a.download='seed-'+ts+'-f'+String(i+1).padStart(3,'0')+'.png';a.click();
});
await new Promise(r=>setTimeout(r,80));
}
bar.style.width='100%';flash();
setTimeout(()=>{btn.disabled=false;btn.textContent='▶ Capturar sequência';bw.style.display='none';bar.style.width='0%';seqRunning=false;},600);
};
// ─── Randomize ────────────────────────────────────────────────────────────────
function pick(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
function rnd(min,max,step=0.01){
const steps=Math.round((max-min)/step);
return Math.round((min+Math.floor(Math.random()*(steps+1))*step)*100)/100;
}
function randomize(){
const btn=document.getElementById('rndBtn');
btn.classList.add('spinning');
setTimeout(()=>btn.classList.remove('spinning'),500);
// ── Pick random params ───────────────────────────────────────────────────
// Seed (form shape)
const newSeed=Math.floor(Math.random()*SEEDS.length);
S.seed=newSeed;
document.querySelectorAll('.sb').forEach((b,i)=>b.classList.toggle('active',i===newSeed));
// Surface — exclude cluster (it's a special mode, not truly random)
const texOptions=['solid','wire','flat','points'];
const newTex=pick(texOptions);
S.tex=newTex;
document.querySelectorAll('.tb').forEach(b=>{
const isActive=b.onclick?.toString().includes(`'${newTex}'`)||b.getAttribute('onclick')?.includes(`'${newTex}'`);
b.classList.toggle('active',isActive);
});
// Material
const newMat=Math.floor(Math.random()*MATS.length);
S.mat=newMat;
S.rough=MATS[newMat].rough;
S.emit=MATS[newMat].emit||0.1;
document.querySelectorAll('.mc').forEach((c,i)=>c.classList.toggle('active',i===newMat));
document.getElementById('sRough').value=S.rough;
document.getElementById('vRough').textContent=S.rough.toFixed(2);
document.getElementById('sEmit').value=S.emit;
document.getElementById('vEmit').textContent=S.emit.toFixed(2);
// Tipos — pick 1–3 randomly (at least 1)
const nTipos=Math.random()<.3?1:Math.random()<.6?2:Math.random()<.85?3:4;
const shuffled=[0,1,2,3].sort(()=>Math.random()-.5);
const chosen=new Set(shuffled.slice(0,nTipos));
activeTipos=chosen;
document.querySelectorAll('.tipo-btn').forEach((btn,i)=>{
btn.classList.toggle('active',chosen.has(i));
});
updateMixBar();
// Deform mode (skip cluster=7)
const newDeform=Math.floor(Math.random()*7);
S.deform=newDeform;
const def=DEFORM_DEF[newDeform];
S.dist=def.dist; S.freq=def.freq; S.speed=def.speed;
document.querySelectorAll('.db').forEach((b,i)=>b.classList.toggle('active',i===newDeform));
document.getElementById('sDist').value=S.dist; document.getElementById('vDist').textContent=S.dist.toFixed(2);
document.getElementById('sFreq').value=S.freq; document.getElementById('vFreq').textContent=S.freq.toFixed(2);
document.getElementById('sSpeed').value=S.speed; document.getElementById('vSpeed').textContent=S.speed.toFixed(2);
document.getElementById('shapeSliders').style.display='';
// Randomize within reasonable ranges
S.dist=rnd(Math.max(0,def.dist-.2), Math.min(1.2,def.dist+.25));
S.freq=rnd(Math.max(.3,def.freq-.5), Math.min(4,def.freq+.6),.05);
S.speed=rnd(Math.max(0,def.speed-.3), Math.min(2,def.speed+.4),.05);
S.size=rnd(.65,1.6,.05);
S.opac=Math.random()<.15?rnd(.55,.9):1.0;
// Update size slider
document.getElementById('sSize').value=S.size;
document.getElementById('vSize').textContent=S.size.toFixed(2);
document.getElementById('sOpac').value=S.opac;
document.getElementById('vOpac').textContent=S.opac.toFixed(2);
// Re-update dist/freq/speed display after jitter
document.getElementById('sDist').value=S.dist; document.getElementById('vDist').textContent=S.dist.toFixed(2);
document.getElementById('sFreq').value=S.freq; document.getElementById('vFreq').textContent=S.freq.toFixed(2);
document.getElementById('sSpeed').value=S.speed; document.getElementById('vSpeed').textContent=S.speed.toFixed(2);
// Rebuild
build();
}
// ─── GIF Recording ─────────────────────────────────────────────────────────────
const REC_MAX_MS = 3000; // 3 s max duration
const REC_FPS = 15; // frames per second (15 = smooth + reasonable size)
const REC_SCALE = 0.75; // canvas scale for GIF (0.75 = 75% of screen res)
let recState = 'idle'; // 'idle' | 'recording' | 'processing'
let recFrames = []; // raw ImageData frames
let recRafId = null;
let recStart = 0;
let recInterval = null;
let gifLib = null; // gif.js instance (lazy-loaded)
// Lazy-load gif.js worker from CDN
function loadGifJs(){
return new Promise((resolve, reject) => {
if(window.GIF){ resolve(); return; }
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
function toggleRecord(){
if(recState === 'idle') startRecord();
else if(recState === 'recording') stopRecord();
}
async function startRecord(){
// Load gif.js if not already available
try { await loadGifJs(); } catch(e) {
alert('Não foi possível carregar gif.js. Verifique sua conexão.'); return;
}
recState = 'recording';
recFrames = [];
recStart = performance.now();
// UI
const btn = document.getElementById('recBtn');
const lbl = document.getElementById('recLabel');
const arc = document.getElementById('recArc');
btn.classList.add('recording');
document.getElementById('recRing').classList.add('visible');
lbl.textContent = '3s';
const CIRC = 138;
// Capture a frame every (1000/FPS) ms
const frameIntervalMs = 1000 / REC_FPS;
function captureFrame(){
// Draw current Three.js frame to a temp canvas at reduced scale
const cw = Math.round(renderer.domElement.width * REC_SCALE);
const ch = Math.round(renderer.domElement.height * REC_SCALE);
const tmp = document.createElement('canvas');
tmp.width = cw; tmp.height = ch;
const ctx = tmp.getContext('2d');
ctx.drawImage(renderer.domElement, 0, 0, cw, ch);
recFrames.push({ data: ctx.getImageData(0, 0, cw, ch), w: cw, h: ch });
}
// Tick: update progress ring + countdown, capture frames
function tick(){
const elapsed = performance.now() - recStart;
const progress = Math.min(elapsed / REC_MAX_MS, 1);
arc.style.strokeDashoffset = CIRC * (1 - progress);
lbl.textContent = Math.ceil((REC_MAX_MS - elapsed) / 1000) || '…';
if(elapsed < REC_MAX_MS){
recRafId = requestAnimationFrame(tick);
} else {
stopRecord();
}
}
// Capture frames at fixed interval (independent of RAF to keep timing accurate)
recInterval = setInterval(captureFrame, frameIntervalMs);
recRafId = requestAnimationFrame(tick);
}
function stopRecord(){
if(recState !== 'recording') return;
recState = 'processing';
clearInterval(recInterval);
cancelAnimationFrame(recRafId);
document.getElementById('recLabel').textContent = '⏳';
buildGif();
}
function buildGif(){
if(recFrames.length === 0){ resetRecUI(); return; }
const { w, h } = recFrames[0];
const gif = new window.GIF({
workers: 2,
quality: 6, // 1=best, 30=worst — 6 is excellent/fast
width: w,
height: h,
workerScript: 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js',
dither: 'FloydSteinberg', // best colour accuracy for gradients
repeat: 0, // 0 = loop forever
});
const delayMs = Math.round(1000 / REC_FPS);
recFrames.forEach(({ data }) => gif.addFrame(data, { delay: delayMs, copy: true }));
gif.on('progress', p => {
const pct = Math.round(p * 100);
document.getElementById('recLabel').textContent = `${pct}%`;
document.getElementById('recArc').style.strokeDashoffset = 138 * (1 - p);
});
gif.on('finished', blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const ts = new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');
a.href = url;
a.download = `seed-loop-${ts}.gif`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 8000);
const f = document.getElementById('flash');
f.classList.add('on');
setTimeout(() => f.classList.remove('on'), 140);
resetRecUI();
});
gif.render();
}
function resetRecUI(){
recState = 'idle';
recFrames = [];
const btn = document.getElementById('recBtn');
const arc = document.getElementById('recArc');
btn.classList.remove('recording');
document.getElementById('recRing').classList.remove('visible');
arc.style.strokeDashoffset = '138';
document.getElementById('recLabel').textContent = 'Gravar GIF';
}
// ─── WebM Recording ────────────────────────────────────────────────────────────
const WEBM_MAX_MS = 5000; // 5 seconds
let webmState = 'idle'; // 'idle' | 'recording'
let webmRecorder = null;
let webmChunks = [];
let webmRafId = null;
let webmStart = 0;
function toggleWebm(){
if(webmState === 'idle') startWebm();
else if(webmState === 'recording') stopWebm(true);
}
function startWebm(){
if(!renderer.domElement.captureStream){
alert('Seu browser não suporta gravação de canvas (tente Chrome/Edge).'); return;
}
webmState = 'recording';
webmChunks = [];
webmStart = performance.now();
// ── Boost to 2K resolution before starting stream ─────────────────────────
const origRatio = renderer.getPixelRatio();
const hiRatio = Math.max(origRatio, 2560 / Math.max(PW(), PH()));
renderer.setPixelRatio(hiRatio);
renderer.setSize(PW(), PH());
renderer.render(scene, camera); // first frame at 2K
// Pick best supported codec
const mime = ['video/webm;codecs=vp9','video/webm;codecs=vp8','video/webm']
.find(t => MediaRecorder.isTypeSupported(t)) || '';
// 60 fps stream from the now-2K canvas
const stream = renderer.domElement.captureStream(60);
webmRecorder = new MediaRecorder(stream,
mime ? {mimeType:mime, videoBitsPerSecond:40_000_000} : {videoBitsPerSecond:40_000_000}
);
webmRecorder.ondataavailable = e => { if(e.data.size > 0) webmChunks.push(e.data); };
webmRecorder.onstop = () => {
// Restore resolution after recording ends
renderer.setPixelRatio(origRatio);
renderer.setSize(PW(), PH());
renderer.render(scene, camera);
finalizeWebm();
};
webmRecorder.start(100);
// UI
const btn = document.getElementById('webmBtn');
const lbl = document.getElementById('webmLabel');
btn.classList.add('recording');
lbl.textContent = '5s';
function tickWebm(){
const elapsed = performance.now() - webmStart;
const remaining = Math.ceil((WEBM_MAX_MS - elapsed) / 1000);
lbl.textContent = remaining > 0 ? `${remaining}s` : '…';
if(elapsed < WEBM_MAX_MS){ webmRafId = requestAnimationFrame(tickWebm); }
else { stopWebm(true); }
}
webmRafId = requestAnimationFrame(tickWebm);
}
function stopWebm(finalize){
if(webmState !== 'recording') return;
webmState = 'idle';
cancelAnimationFrame(webmRafId);
if(webmRecorder && webmRecorder.state !== 'inactive') webmRecorder.stop();
// finalizeWebm() fires via onstop
if(!finalize) resetWebmUI();
}
function finalizeWebm(){
const blob = new Blob(webmChunks, {type:'video/webm'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const ts = new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');
a.href = url;
a.download = `seed-loop-${ts}.webm`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 8000);
const f = document.getElementById('flash');
f.classList.add('on'); setTimeout(() => f.classList.remove('on'), 140);
resetWebmUI();
}
function resetWebmUI(){
webmState = 'idle';
webmChunks = [];
webmRecorder = null;
const btn = document.getElementById('webmBtn');
const lbl = document.getElementById('webmLabel');
btn.classList.remove('recording');
lbl.textContent = 'Gravar WebM';
}
build();
animate();