summaryrefslogtreecommitdiffstats
path: root/index.html
blob: a1da06e894c2a25743e0cd5f04ecdafaf1570b65 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Latens</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 fill=%27%23050505%27/%3E%3Cpath d=%27M12 8H28V38H56V56H12Z%27 fill=%27%23f2efe9%27/%3E%3Ccircle cx=%2733%27 cy=%2733%27 r=%2710.5%27 fill=%27%23050505%27/%3E%3C/svg%3E">
<meta name="description" content="The extraction layer of latent variables for AGI infrastructure.">
<meta name="theme-color" content="#050505">
<meta property="og:type" content="website">
<meta property="og:url" content="https://latens.ca/">
<meta property="og:site_name" content="Latens">
<meta property="og:title" content="Latens">
<meta property="og:description" content="The extraction layer of latent variables for AGI infrastructure.">
<meta property="og:image" content="https://latens.ca/og.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="The Latens logo and one-liner over a nebula on a black background">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Latens">
<meta name="twitter:description" content="The extraction layer of latent variables for AGI infrastructure.">
<meta name="twitter:image" content="https://latens.ca/og.jpg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500&family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;1,6..72,300&display=swap" rel="stylesheet">
<script defer src="https://analytics.ugmi.ca/script.js" data-website-id="73f90368-e4c7-43d3-adbe-2ceb0e44853d"></script>
<style>
:root{
  --ground:#050505;
  --ink:#f2efe9;
  --ink-soft:rgba(242,239,233,.72);
  --ink-faint:rgba(242,239,233,.28);
  --disk:#e8b679;
  --gutter:clamp(16px,4.4vw,56px);
  --serif:'Newsreader',Georgia,'Times New Roman',serif;
  --sans:'Hanken Grotesk','Helvetica Neue',sans-serif;
  --ease:cubic-bezier(.16,1,.3,1);
  color-scheme:dark;
}
*{box-sizing:border-box}
html{background:var(--ground);scrollbar-width:thin;scrollbar-color:var(--ink-faint) transparent}
html,body{margin:0;overflow-x:hidden;overscroll-behavior-y:none}
body{background:var(--ground);color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.45;-webkit-font-smoothing:antialiased}
::selection{background:var(--disk);color:var(--ground)}
#track{height:800vh}
#stage{position:fixed;inset:0;height:100lvh;overflow:hidden;background:var(--ground)}
/* SCROLL FIX (css) start: scroll an inner full-screen box, not the document, so phone browser toolbars never show, hide or resize the screen */
html,body{height:100%;overflow:hidden}
#scroller{position:fixed;inset:0;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;touch-action:pan-y;outline:none;scrollbar-width:thin;scrollbar-color:var(--ink-faint) transparent}
#track{height:700%}   /* total scroll stays the same: the pinned #stage takes the first screen */
#stage{position:sticky;inset:auto;top:0;height:100vh;height:100dvh}   /* pinned inside #scroller: a fixed element would pass wheel and touch scrolls to the page, which cannot scroll */
#stage .layer{position:absolute}
/* SCROLL FIX (css) end */
#gl{position:absolute;inset:0;width:100%;height:100%;display:block;opacity:0;transition:opacity .8s var(--ease)}
#gl.on{opacity:1}
.layer{position:fixed;left:0;right:0;padding:0 var(--gutter);pointer-events:none;opacity:0;will-change:opacity,transform}
.layer p{margin:0}
/* Text format copied from anoria.com: a three column header with the wordmark centered and one uppercase nav word,
   32px side and 28px top margins on phones, 48px and 32px on desktop, and one light sans line as the only copy. */
#top{position:absolute;top:0;left:0;right:0;z-index:2;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:calc(28px + env(safe-area-inset-top)) 32px 24px;pointer-events:none}
#top a{pointer-events:auto}
.mark{grid-column:2;display:inline-flex;flex-wrap:nowrap;align-items:center;gap:.3em;font:300 clamp(26px,2.8vw,38px)/1 var(--serif);letter-spacing:-.015em;white-space:nowrap;margin:0;color:var(--ink)}
.mark .mk{width:.95em;height:.95em;flex:none;transform:translateY(-.12em)}   /* lifts the L so its foot sits on the text baseline: centering the boxes left the letters about 4px above the L */
.nav{grid-column:1;justify-self:start;color:var(--ink);text-decoration:none;text-transform:uppercase;font:300 14px/24px var(--sans);letter-spacing:.15em;transition:color .2s var(--ease)}
.nav:hover{color:var(--disk)}
.nav:focus-visible{outline:1px solid var(--ink);outline-offset:6px}
@media (min-width:768px){#top{padding:calc(32px + env(safe-area-inset-top)) 48px 24px}.nav{font-size:16px}}
#s1{top:50%;transform:translateY(-50%);text-align:center;opacity:1}
#s1 p{font:300 clamp(1.9rem,3.6vw,3.2rem)/1.15 var(--serif);letter-spacing:-.01em;margin:0 auto;max-width:20ch;text-wrap:balance}
#s1 p br{display:none}
@media (min-width:768px){#s1 p{max-width:none}#s1 p br{display:inline}}
/* Readability over the bright nebula: each text block sits on a soft local patch of the page black that fades with it,
   and the letters carry a faint 1px black shadow. The background is never blurred and the core is never dimmed. */
.shade{position:relative;display:inline-block}
.shade{padding:.55em .8em;background:rgba(5,5,5,.55);border-radius:2px}
.mark.shade,.nav.shade{padding:0;background:none}
.mark,.nav,.line,#s1 p{text-shadow:0 1px 1px rgba(5,5,5,.55)}
#top,.layer{isolation:isolate}
#down{position:absolute;left:50%;bottom:calc(26px + env(safe-area-inset-bottom));width:28px;height:28px;margin-left:-14px;color:var(--ink);opacity:.9;pointer-events:none;animation:float 2.6s ease-in-out infinite;transition:opacity .3s var(--ease)}
#down svg{width:100%;height:100%;display:block;filter:drop-shadow(0 1px 1px rgba(5,5,5,.6))}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(8px)}}
.line{font-family:var(--serif);font-weight:300;font-size:clamp(1.55rem,2.7vw,2.4rem);line-height:1.12;letter-spacing:-.01em;max-width:17ch;text-wrap:balance}
#s2{top:0;bottom:0;display:flex;flex-direction:column;justify-content:center;text-align:center}   /* centered on the screen, same method as #s5 */
#s2 .line,#s2 .line[class]{margin:0 auto;max-width:36ch}   /* wider than the other lines so it spreads across the eye; [class] outranks the phone rule that caps .line at 16ch */
#s3,#s4{top:50%;margin-top:-1.2em}   /* line 4 sits where line 3 does */
#s4 .line,#s4 .line[class]{max-width:22ch}   /* wide enough for three lines; [class] outranks the phone cap */
#s5{top:0;bottom:0;display:flex;flex-direction:column;justify-content:center;text-align:center}   /* centered on the screen; flex instead of a transform because the script animates this layer's transform */
#s5 .line{margin:0 auto;max-width:none}
.cta{display:inline-block;margin-top:clamp(20px,3.4svh,32px);padding:14px 28px;background:var(--ink);color:var(--ground);font:500 15px/1 var(--sans);letter-spacing:.12em;text-transform:uppercase;text-decoration:none;border-radius:2px;pointer-events:auto;transition:background-color .2s var(--ease)}
.cta:hover{background:#fff}
@media (max-width:700px){
  #s3,#s4{top:auto;margin-top:0;bottom:calc(13svh + env(safe-area-inset-bottom))}
  #s2 .line,#s3 .line,#s4 .line{max-width:16ch}
  #s2 .line[class]{max-width:24.5ch;font-size:1.71rem}   /* on phones line 2's left and right edges line up with line 1's: its natural three-line break needs this size to reach the same width */
  #s3 .line[class]{max-width:22ch}   /* three lines on phones */
  #s5 .line{font-size:clamp(1.15rem,5.2vw,1.35rem)}   /* the closing line is long: a smaller size keeps it to three lines on phones */
}
@media (prefers-reduced-motion:reduce){#gl,.nav{transition:none}#down{animation:none}}
</style>
</head>
<body>
<div id="scroller" tabindex="-1"><!-- SCROLL FIX (markup): everything that was in body now sits inside #scroller -->
<div id="stage" aria-hidden="false">
  <canvas id="gl" aria-hidden="true"></canvas>
<header id="top">
  <a class="nav" href="mailto:eric@latens.ca" data-umami-event="contact">Contact</a>
  <h1 class="mark"><svg class="mk" viewBox="0 0 64 64" aria-hidden="true"><mask id="bite"><rect width="64" height="64" fill="#fff"/><circle cx="33" cy="33" r="10.5" fill="#000"/></mask><path d="M12 8H28V38H56V56H12Z" fill="currentColor" mask="url(#bite)"/></svg><span>Latens</span></h1>
</header>
<div id="down" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9l7 7 7-7"/></svg></div>
<section class="layer" id="s1"><p>The extraction layer of latent variables<br> for AGI infrastructure.</p></section>
<section class="layer" id="s2"><p class="line">The eyes are called the window to the soul, but nobody can read them.</p></section>
<section class="layer" id="s3"><p class="line">Most people don't know what they want, let alone telling an agent.</p></section>
<section class="layer" id="s4"><p class="line">AGI will act for billions of people, so we need to find out what they want.</p></section>
<section class="layer" id="s5"><p class="line">Astronomers find black holes from the stars around them, and we find revealed preferences from human behaviour.</p><p><a class="cta" href="mailto:eric@latens.ca" data-umami-event="contact">Contact</a></p></section>
</div>
<div id="track"></div>
</div>

<script id="fs" type="x-shader/x-fragment">#version 300 es
precision highp float;
uniform vec2 uRes;
uniform float uTime, uOct;
uniform vec2 uMouse;
uniform vec3 uBH, uP, uA, uB, uC;   // uBH: black hole units at screen center, z per p unit. uP: pupil units at screen center, z per p unit. uA/uB/uC: image uv at screen center, z per p unit
uniform float uCosA, uAA, uBA, uCA, uDim, uStarIn, uHoleIn, uText;
uniform sampler2D tA, tB, tC;
uniform vec4 uDilA, uDilB;   // eye in still A and still B: pupil center xy, iris radius, pupil radius in the image
out vec4 outColor;

// measured on the stills, in uv with y down
const vec2 C_EARTH = vec2(.496,.484);// Earth center in still C

float h21(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); }
vec2 h22(vec2 p){ float n=h21(p); return vec2(n,h21(p+n+17.17)); }
float vnoise(vec2 p){ vec2 i=floor(p), f=fract(p); vec2 u=f*f*(3.-2.*f);
  return mix(mix(h21(i),h21(i+vec2(1,0)),u.x),mix(h21(i+vec2(0,1)),h21(i+vec2(1,1)),u.x),u.y); }
float hP(vec2 i,float P){ i.x=mod(i.x,P); return h21(i); }
float vnoiseP(vec2 p,float P){ vec2 i=floor(p), f=fract(p); vec2 u=f*f*(3.-2.*f);
  return mix(mix(hP(i,P),hP(i+vec2(1,0),P),u.x),mix(hP(i+vec2(0,1),P),hP(i+vec2(1,1),P),u.x),u.y); }
float fbm(vec2 p){ float a=.5,s=0.; for(int i=0;i<5;i++){ s+=a*vnoise(p); p=p*2.03+vec2(1.7,9.2); a*=.5; } return s; }
const mat2 OR=mat2(.8,.6,-.6,.8);
float fbmN(vec2 p){ float a=.5,s=0.,w=0.; for(int i=0;i<6;i++){ if(float(i)>=uOct) break; s+=a*vnoise(p); w+=a; p=OR*p*2.04+vec2(1.7,9.2); a*=.5; } return s/w; }

vec3 stars(vec2 q,float px,float seed){
  vec3 c=vec3(0);
  for(int l=0;l<4;l++){
    float fl=float(l);
    float g=3.*pow(2.4,fl);
    float vis=1.-smoothstep(.12,.45,g*px);
    if(vis<=0.) continue;
    vec2 cq=q*g, id=floor(cq);
    vec2 r=h22(id+vec2(seed+fl*17.,3.1));
    if(r.x<.3) continue;
    vec2 sp=id+.15+.7*h22(id+vec2(9.7,seed+fl*5.));
    float d=length(cq-sp)/g;
    float rad=1.15*px;
    float b=pow(h21(id+seed*1.3+fl),7.)*2.6+.12;
    b*=.8+.2*sin(uTime*(.8+r.y*2.)+r.x*40.);
    vec3 tint=mix(vec3(.74,.82,1.),vec3(1.,.84,.68),h21(id+4.2));
    c+=tint*b*vis*exp(-d*d/(rad*rad));
  }
  return c;
}

// nebula in pupil units: the core sits at q=0, the pupil edge at r=1
vec3 cosmos(vec2 q,float px,float ck){
  float t=uTime;
  vec2 p=(q+uMouse*.012)*3.6;
  float r=length(p), th=atan(p.y,p.x);
  float sw=t*.02+1.4/(r+.5);                   // inner gas turns faster than the outer arms
  float cs=cos(sw), sn=sin(sw);
  vec2 w=mat2(cs,-sn,sn,cs)*p;
  vec2 o=vec2(fbmN(w*1.2+vec2(0.,t*.008)), fbmN(w*1.2+vec2(5.2,1.3)));
  float n=fbmN(w*2.2+o*1.6);
  float ph=2.*th-4.2*log(r+.05)+t*.04+2.2*(o.y-.5);   // two logarithmic spiral arms
  float arm=pow(.5+.5*cos(ph),2.6);
  float lane=pow(.5+.5*cos(ph-1.05),6.)*smoothstep(.12,.5,r)*smoothstep(.3,.7,fbmN(w*3.+o*2.));
  float fall=exp(-r*r*.16);
  float dens=smoothstep(.2,.75,n)*(.12+.88*arm)*fall;
  vec3 blue=vec3(.06,.10,.21), rust=vec3(.40,.20,.11);
  vec3 gas=mix(blue,rust,smoothstep(.35,.75,o.x)*smoothstep(1.6,.25,r));
  vec3 col=gas*dens*3.*(1.-.75*lane);
  col+=vec3(.012,.016,.03)*fall;
  float core=exp(-r*r*16.)*.95+exp(-r*3.)*.22;
  col+=vec3(1.,.8,.6)*core*(1.-.45*lane)*ck;
  col+=vec3(1.,.95,.88)*exp(-r*r*260.)*.5*ck;
  vec3 st=stars(q+uMouse*.03,px,1.)+stars(q*1.7+uMouse*.012,px*1.7,7.)*.5;
  col+=st*(1.-.7*smoothstep(.08,.5,dens))*(1.-.6*lane);
  return 1.-exp(-col*1.25);
}

vec3 hole(vec2 q,float px){
  float r=length(q), t=uTime;
  vec3 col=vec3(0);
  vec2 src=q*(1.-1.7/max(r*r,1.05));
  col+=stars(src+uMouse*.04,px,3.)*.85+vec3(.012,.013,.018)*fbm(src*.7);
  float sh=smoothstep(1.-px*1.5,1.+px*1.5,r);
  col*=sh;
  col+=stars(q*9.+uMouse*.02,px*9.,11.)*uStarIn*(1.-sh)*.7;
  const float inc=.075;
  vec2 dq=vec2(q.x,q.y/inc); float rd=length(dq);
  float ang=atan(dq.y,dq.x)/6.2831853+.5;
  float band=smoothstep(1.5,2.,rd)*(1.-smoothstep(2.4,5.,rd));
  float sw=t*.05/pow(rd,1.5);
  float tur=vnoiseP(vec2((ang+sw)*48.,rd*4.),48.)*.6+vnoiseP(vec2((ang+sw*1.3)*110.,rd*9.),110.)*.4;
  float dop=1.+.65*(-dq.x/max(rd,.001));
  float I=band*(.3+.9*tur)*dop*pow(1.95/max(rd,.5),1.6);
  vec3 dc=mix(vec3(.95,.42,.16),vec3(1.,.86,.66),clamp(I*.7,0.,1.));
  col+=dc*I*(q.y<0.?1.:sh)*1.25;
  float angR=atan(q.y,q.x)/6.2831853+.5;
  float arc=smoothstep(1.02,1.1,r)*(1.-smoothstep(1.22,1.75,r));
  float upper=smoothstep(-.25,.7,q.y/max(r,.001));
  float tur2=vnoiseP(vec2((angR+t*.02)*40.,r*6.),40.);
  float dop2=1.+.55*(-q.x/max(r,.001));
  col+=vec3(1.,.64,.34)*arc*(upper*.8+.1)*(.45+.6*tur2)*dop2;
  col+=vec3(1.,.82,.6)*exp(-pow((r-1.035)/(.012+px),2.))*.9*(.6+.4*dop2);
  return 1.-exp(-col*1.15);
}

// widen the pupil: inside the iris, pull samples toward the center so the pupil grows to PUP_TO of the iris radius and the iris edge stays put
const float PUP_TO=.44;
vec2 dilate(vec2 uv,vec4 D){
  vec2 d=uv-D.xy; float l=length(d), rq=PUP_TO*D.z;
  if(l>=D.z || l<1e-6) return uv;
  float s=l<rq ? l*D.w/rq : D.w+(l-rq)*(D.z-D.w)/(D.z-rq);
  return D.xy+d*(s/l);
}
vec2 img(vec3 U,vec2 p){ return U.xy+vec2(p.x,-p.y)*U.z; }
bool inside(vec2 uv){ return all(greaterThanEqual(uv,vec2(0))) && all(lessThanEqual(uv,vec2(1))); }

void main(){
  float k=.5*min(uRes.x,uRes.y);
  vec2 p=(gl_FragCoord.xy-.5*uRes)/k;
  float pk=1./k;
  vec3 col=vec3(0);
  // the black hole scene sits behind everything; Earth fades into it as the camera pulls out
  col=hole(uBH.xy+p*uBH.z,pk*uBH.z)*uHoleIn;   // always drawn: Earth is cut to its sphere, so the scene around it shows from the start
  if(uCA>0.){
    vec2 uv=img(uC,p);
    if(inside(uv)){
      float m=smoothstep(.425,.408,length(uv-C_EARTH));   // cut to the sphere, so the space around Earth in the still never hides the black hole behind it
      col=mix(col,texture(tC,uv).rgb,uCA*m);
    }
  }
  col*=1.-.85*uDim;
  if(uBA>0.){
    vec2 uv=img(uB,p);
    if(inside(uv)){
      float m=smoothstep(.5,.34,length(uv-.5));
      col=mix(col,texture(tB,dilate(uv,uDilB)).rgb,uBA*m);
    }
  }
  if(uAA>0.){
    vec2 uv=img(uA,p);
    if(inside(uv)){
      float m=smoothstep(.49,.36,length(uv-.5));   // still A is cut from still B, so its soft edge blends into the same pixels
      col=mix(col,texture(tA,dilate(uv,uDilA)).rgb,uAA*m);
    }
  }
  if(uCosA>0.){
    vec2 q=uP.xy+p*uP.z;
    float r=length(q);
    if(r<1.){
      float m=smoothstep(1.,.86,r);
      col=mix(col,cosmos(q,pk*uP.z,1.),uCosA*m);
    }
  }
  vec2 v=gl_FragCoord.xy/uRes-.5;
  col*=1.-.35*dot(v,v);
  col+=(h21(gl_FragCoord.xy+fract(uTime))-.5)/255.;
  col*=1.-.5*uText;
  outColor=vec4(col,1.);
}
</script>
<script>
(function(){
  'use strict';
  var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  var layers = [1,2,3,4,5].map(function(i){ return document.getElementById('s'+i); });
  var down = document.getElementById('down');
  var textDim = 1;
  var canvas = document.getElementById('gl');
  var shown = false;

  // World in black hole units: shadow radius 1, Earth centered at 0 with radius .06.
  // Each still is placed by its world center and world width. The numbers below were measured on the stills.
  var EARTH_UV=[.496,.484], EARTH_R=.414;          // still C: Earth center and radius, in image widths
  var FACE_IRIS=.0190;                             // still B: iris radius of the lit eye
  var A_CROP=[.338068,.395241,.113991];           // still A is a square crop of still B: left, top and width in still B uv
  var A_PUP=[.5034,.5001], A_PR=.0600;             // still A: pupil center and pupil radius
  function at(img,uv){ return [img.c[0]+(uv[0]-.5)*img.s, img.c[1]-(uv[1]-.5)*img.s]; }
  var W={};
  W.C={s:.06/EARTH_R}; W.C.c=[(.5-EARTH_UV[0])*W.C.s, -(.5-EARTH_UV[1])*W.C.s];
  W.B={s:.30*W.C.s, c:[0,0]};
  W.A={s:A_CROP[2]*W.B.s, c:at(W.B,[A_CROP[0]+A_CROP[2]/2, A_CROP[1]+A_CROP[2]/2])};
  var A_IR=FACE_IRIS/A_CROP[2], PUP_TO=.44;        // iris radius in still A; the shader widens the pupil to PUP_TO of it
  W.P={c:at(W.A,A_PUP), r:PUP_TO*A_IR*W.A.s};

  function keys(){
    var w = innerWidth, h = innerHeight, land = w >= h, a = land ? w/h : h/w;
    var end = land ? Math.max(3, 5.2/a) : 4.6;   // final view width, so the whole accretion disk fits on screen
    return [
      {a:0,   b:.05, v:.32*W.P.r,  c:W.P.c},
      {a:.18, b:.25, v:1.0947*FACE_IRIS*W.B.s, c:W.P.c},
      {a:.37, b:.44, v:.42*W.B.s,  c:W.B.c},
      {a:.56, b:.63, v:1.45*.06,   c:[0,0]},
      {a:.86, b:1,   v:end,        c:[0,0]}   // pulled out until Earth is a point beside the black hole
    ];
  }
  var K = keys();
  function ease(s){ return s<.5 ? 4*s*s*s : 1-Math.pow(-2*s+2,3)/2; }
  function camera(t){
    var i, k, n, s, v0, v1, v, w;
    for(i=0;i<K.length;i++){
      k=K[i];
      if(t>=k.a && t<=k.b){ s=(t-k.a)/(k.b-k.a||1); return {v:k.v*(1+.07*s), c:k.c}; }
      n=K[i+1];
      if(n && t>k.b && t<n.a){
        s=(t-k.b)/(n.a-k.b);
        s=n===K[K.length-1] ? .5-.5*Math.cos(Math.PI*s) : ease(s);   // gentler curve on the long pull out to the black hole, so the shadow edge never passes in one jump
        v0=k.v*1.07; v1=n.v;
        v=Math.exp(Math.log(v0)+(Math.log(v1)-Math.log(v0))*s);
        w=(v1-v)/(v1-v0);
        return {v:v, c:[n.c[0]+(k.c[0]-n.c[0])*w, n.c[1]+(k.c[1]-n.c[1])*w]};
      }
    }
    k=K[K.length-1]; return {v:k.v*1.07, c:k.c};
  }
  function ss(a,b,x){ x=Math.min(1,Math.max(0,(x-a)/(b-a))); return x*x*(3-2*x); }

  function textAt(t){
    var L=4, o=[1-ss(K[0].b-.01,K[0].b+.05,t), 0,0,0, ss(K[4].a-.06,K[4].a-.005,t)];   // the last line comes in as the black hole settles
    for(var i=1;i<L;i++){ var k=K[i]; o[i]=ss(k.a-.05,k.a-.005,t)*(1-ss(k.b+.005,k.b+.05,t)); }
    for(i=0;i<=L;i++){
      var el=layers[i];
      el.style.opacity=o[i].toFixed(3);
      (i===0?el.firstElementChild:el).style.transform='translate3d(0,'+((1-o[i])*10).toFixed(1)+'px,0)';
      if(i===0) down.style.opacity=(o[0]*.9).toFixed(3);
      if(i===0||i===L) textDim=Math.max(o[0],o[L]);
    }
    layers[L].style.visibility = o[L] > .01 ? 'visible' : 'hidden';
  }

  // SCROLL FIX (js) start: read progress from #scroller, follow the finger directly on touch screens, resize on rotation
  var scroller=document.getElementById('scroller');
  var touch=matchMedia('(pointer: coarse)').matches;
  function progress(){ var m=scroller.scrollHeight-scroller.clientHeight; return m>0 ? Math.min(1,Math.max(0,scroller.scrollTop/m)) : 0; }
  scroller.addEventListener('scroll', function(){ target=progress(); if(touch) cur=target; }, {passive:true});
  addEventListener('orientationchange', function(){ setTimeout(function(){ K=keys(); target=progress(); resize(); }, 120); });
  scroller.focus({preventScroll:true});   // arrow keys and space scroll the box on desktop
  // SCROLL FIX (js) end
  var target=progress(), cur=target;
  var mouse=[0,0], mt=[0,0];
  addEventListener('pointermove', function(e){ if(e.pointerType==='mouse'){ mt[0]=e.clientX/innerWidth*2-1; mt[1]=-(e.clientY/innerHeight*2-1); } }, {passive:true});
  addEventListener('resize', function(){ K=keys(); target=progress(); resize(); });
  textAt(cur);

  var gl, prog, U={}, tex={}, scale=1, dpr=Math.min(devicePixelRatio||1, 1.5);
  function resize(){
    if(!gl) return;
    var w=Math.round(canvas.clientWidth*dpr*scale), h=Math.round(canvas.clientHeight*dpr*scale);
    if(canvas.width!==w || canvas.height!==h){ canvas.width=w; canvas.height=h; gl.viewport(0,0,w,h); }
    boxW=canvas.clientWidth; boxH=canvas.clientHeight;
  }
  // ASPECT FIX: phone toolbars can change the canvas box without a matching window resize event, which left the drawing buffer at the old height and squashed every stage vertically.
  // Resize whenever the canvas box itself changes, from three sources, so rotation and the address bar showing or hiding always keep circles round.
  var boxW=0, boxH=0;
  if(window.ResizeObserver) new ResizeObserver(function(){ resize(); }).observe(canvas);
  if(window.visualViewport) visualViewport.addEventListener('resize', function(){ K=keys(); target=progress(); resize(); });
  function makeTex(unit){
    var t=gl.createTexture(); gl.activeTexture(gl.TEXTURE0+unit); gl.bindTexture(gl.TEXTURE_2D,t);
    gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,1,1,0,gl.RGBA,gl.UNSIGNED_BYTE,new Uint8Array([0,0,0,255]));
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
    return t;
  }
  function init(){
    gl=canvas.getContext('webgl2',{antialias:false,alpha:false,powerPreference:'high-performance'});
    if(!gl) return;
    var vs=gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vs,'#version 300 es\nin vec2 a;void main(){gl_Position=vec4(a,0.,1.);}');
    gl.compileShader(vs);
    var fs=gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fs,document.getElementById('fs').textContent);
    gl.compileShader(fs);
    prog=gl.createProgram(); gl.attachShader(prog,vs); gl.attachShader(prog,fs); gl.linkProgram(prog);
    if(!gl.getProgramParameter(prog,gl.LINK_STATUS)){ console.error(gl.getShaderInfoLog(fs)); gl=null; return; }
    gl.useProgram(prog);
    var b=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,b);
    gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),gl.STATIC_DRAW);
    var loc=gl.getAttribLocation(prog,'a'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);
    ['uRes','uTime','uOct','uMouse','uBH','uP','uA','uB','uC','uCosA','uAA','uBA','uCA','uDim','uStarIn','uHoleIn','uText','tA','tB','tC','uDilA','uDilB'].forEach(function(n){ U[n]=gl.getUniformLocation(prog,n); });
    ['A','B','C'].forEach(function(n,i){ tex[n]=makeTex(i); gl.uniform1i(U['t'+n],i); });
    gl.uniform4f(U.uDilA,A_PUP[0],A_PUP[1],A_IR,A_PR);
    gl.uniform4f(U.uDilB,A_CROP[0]+A_PUP[0]*A_CROP[2],A_CROP[1]+A_PUP[1]*A_CROP[2],FACE_IRIS,A_PR*A_CROP[2]);
    for(var n in pending) upload(n,pending[n]);
    resize();
  }

  // Stills load behind stage 1 with no indicator (Eric asked for the loading line to go).
  var STILLS=[['A','img/pupil.webp'],['B','img/face.webp'],['C','img/earth.webp']];
  var pending={};
  function upload(name,bmp){
    if(!gl || !tex[name]){ pending[name]=bmp; return; }
    var unit=['A','B','C'].indexOf(name);
    gl.activeTexture(gl.TEXTURE0+unit); gl.bindTexture(gl.TEXTURE_2D,tex[name]);
    gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,bmp);
    gl.generateMipmap(gl.TEXTURE_2D);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
  }
  function finished(){}
  function fetchStill(i){
    var x=new XMLHttpRequest(); x.open('GET',STILLS[i][1]); x.responseType='blob';
    x.onload=function(){
      var im=new Image(); im.decoding='async';
      im.onload=function(){ upload(STILLS[i][0],im); finished(); };
      im.onerror=finished;
      im.src=URL.createObjectURL(x.response);
    };
    x.onerror=finished;
    x.send();
  }
  function local(cam,o){ return [(cam.c[0]-o.c[0])/o.s+.5, .5-(cam.c[1]-o.c[1])/o.s, cam.v/o.s]; }

  var last=performance.now(), t0=last, slow=0, frames=0;
  function frame(now){
    var dt=Math.min(.1,(now-last)/1000); last=now;
    if(gl && (canvas.clientWidth!==boxW || canvas.clientHeight!==boxH)) resize();   // ASPECT FIX: catch any box change the events missed
    cur += (target-cur)*(1-Math.exp(-dt*(reduce||touch?40:6.5)));   // SCROLL FIX: no smoothing lag on touch
    if(Math.abs(target-cur)<1e-5) cur=target;
    mouse[0]+=(mt[0]-mouse[0])*(1-Math.exp(-dt*2)); mouse[1]+=(mt[1]-mouse[1])*(1-Math.exp(-dt*2));
    textAt(cur);
    if(gl && !document.hidden){
      var cam=camera(cur);
      cam.c=[cam.c[0]+mouse[0]*.012*cam.v, cam.c[1]+mouse[1]*.012*cam.v];
      var v=cam.v;
      gl.uniform2f(U.uRes,canvas.width,canvas.height);
      gl.uniform1f(U.uTime,reduce?0:(now-t0)/1000);
      gl.uniform1f(U.uOct,innerWidth<700?3:5);
      gl.uniform2f(U.uMouse,mouse[0],mouse[1]);
      gl.uniform3f(U.uBH,cam.c[0],cam.c[1],v);
      gl.uniform3f(U.uP,(cam.c[0]-W.P.c[0])/W.P.r,(cam.c[1]-W.P.c[1])/W.P.r,v/W.P.r);
      gl.uniform3fv(U.uA,local(cam,W.A)); gl.uniform3fv(U.uB,local(cam,W.B)); gl.uniform3fv(U.uC,local(cam,W.C));
      // crossfades run on the size of each still on screen, so each handoff happens at matched scale
      gl.uniform1f(U.uCosA,ss(.45,1.2,W.P.r/v));
      gl.uniform1f(U.uAA,ss(1.2,3,W.A.s/v));
      gl.uniform1f(U.uBA,ss(.6,1.5,W.B.s/v));
      gl.uniform1f(U.uDim,ss(.8,2,W.B.s/v));
      gl.uniform1f(U.uCA,ss(.03,.2,W.C.s/v));   // Earth stays until the photon ring is on screen, so the pull out never passes through a black frame
      gl.uniform1f(U.uStarIn,1-ss(.15,.9,v));
      gl.uniform1f(U.uHoleIn,.25+.75*ss(.5,2,v));   // the photon ring and disk brighten as they come into view instead of switching on
      gl.uniform1f(U.uText,textDim);
      gl.drawArrays(gl.TRIANGLES,0,3);
      if(!shown){ shown=true; canvas.classList.add('on'); }
      frames++;
      if(frames>20){ if(dt>.03) slow++; else slow=Math.max(0,slow-1);
        if(slow>40 && scale>.55){ scale*=.8; slow=0; resize(); } }
    }
    requestAnimationFrame(frame);
  }
  for(var i=0;i<3;i++) fetchStill(i);
  requestAnimationFrame(function(){ setTimeout(function(){ init(); requestAnimationFrame(frame); }, 0); });
})();
</script>
</body>
</html>