Kage
aadmin2026-09-16 发布3D10 阅读

Kage

类型:3D · 分类:Landing Pages · 收录:2026-09-16 20:35:01 · 源码:会员可见

[hidecontent type="vip1"]
# Kage
The complete authored Kage temple experience, preserved as an interactive full-page document with its original navigation, scroll scenes, and local Three.js world.

> 来源:ThreeUI Community · https://threeui.com · MIT License(Meng To / Design+Code)
> 组件页:https://threeui.com/component/kage-landing-page · 预览动画:https://threeui.com/previews/kage-landing-page.webm
> 分类:Landing Pages

/* ===== public/landing-pages/kage.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Kage — Where stillness reveals the unseen</title>
<meta name="description" content="A five-chapter night walk through a Kyoto mountain temple. Charred cypress, lantern light and a vermilion moon, rendered live in WebGL.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2305070a'/%3E%3Ccircle cx='16' cy='17' r='8' fill='%23e0231c'/%3E%3Crect x='4' y='8' width='24' height='2.6' fill='%23dfe7e0'/%3E%3Crect x='7' y='13' width='18' height='2' fill='%23dfe7e0'/%3E%3C/svg%3E">
<link rel="stylesheet" href="secret-pathways-assets/fonts.css">
<style>
/* ============================================================= tokens */
:root{
  --ink:#05070a;          /* page black */
  --ink-2:#0a0e12;        /* raised black */
  --bone:#dfe7e0;         /* the pale sage white of the wordmark */
  --bone-dim:#aab4ad;
  --muted:#78837c;
  --line:rgba(223,231,224,.13);
  --line-soft:rgba(223,231,224,.07);
  --vermilion:#e0231c;
  --ember:#ff5a3c;
  --gold:#c9a24a;
  --pad:clamp(20px, 3.4vw, 56px);
  --nav-h:84px;
  --ease:cubic-bezier(.22,.61,.36,1);
  --ease-out:cubic-bezier(.16,1,.3,1);
  --ease-io:cubic-bezier(.65,0,.35,1);
}
*,*::before,*::after{box-sizing:border-box}
html{ -webkit-text-size-adjust:100%; scroll-behavior:auto; }
body{
  margin:0; background:var(--ink); color:var(--bone);
  font-family:'Onest',system-ui,-apple-system,'Helvetica Neue',sans-serif;
  font-weight:300; font-size:16px; line-height:1.6;
  -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;
  overflow-x:hidden;
}
body.is-locked{ overflow:hidden; height:100vh; }
::selection{ background:var(--vermilion); color:#fff; }
img{max-width:100%;display:block}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}

/* ============================================================= layers */
#gl{
  position:fixed; inset:0; width:100%; height:100%;
  display:block; z-index:0; background:#05070a;
}
#grain{
  position:fixed; inset:-1px; z-index:60; pointer-events:none;
  opacity:.055; mix-blend-mode:overlay;
  background-repeat:repeat; background-size:180px 180px;
}
#vignette{
  position:fixed; inset:0; z-index:55; pointer-events:none;
  background:radial-gradient(125% 95% at 50% 42%, transparent 40%, rgba(2,4,6,.55) 100%);
}
.page{ position:relative; z-index:10; }

/* ============================================================= type */
.eyebrow{
  font-size:10px; font-weight:500; letter-spacing:.22em; text-transform:uppercase;
  color:var(--bone-dim); text-shadow:0 1px 16px rgba(3,6,8,.9);
}
.jp{ font-family:'NotoJP','Onest',sans-serif; font-weight:400; }
h1,h2,h3{ font-weight:400; margin:0; letter-spacing:-.005em; }
.display{
  text-transform:uppercase; line-height:1.055; letter-spacing:-.012em;
  font-weight:400; text-shadow:0 2px 34px rgba(3,6,8,.72);
}
.h-hero{ font-size:clamp(26px,3.05vw,46px); }
.h-sec{ font-size:clamp(30px,4.0vw,60px); }
.body-lg{ font-size:clamp(14px,1.02vw,17px); line-height:1.72; color:#b4bfb7; font-weight:300; text-shadow:0 1px 20px rgba(3,6,8,.88); }
.body{ font-size:14px; line-height:1.68; color:#9aa5a0; font-weight:300; text-shadow:0 1px 18px rgba(3,6,8,.85); }
.num{ font-size:clamp(26px,2.5vw,36px); font-weight:300; letter-spacing:-.02em; color:var(--bone); font-variant-numeric:tabular-nums; }

/* ============================================================= reveal */
[data-rv]{ opacity:0; }
[data-rv="up"]{ transform:translate3d(0,26px,0); }
[data-rv="fade"]{ transform:none; }
[data-rv].rv-in{ opacity:1; transform:none;
  transition:opacity .9s var(--ease-out), transform 1.05s var(--ease-out); }
.mask-line{ display:block; overflow:hidden; }
.mask-line:not(.word-reveal) > span{ display:block; transform:translate3d(0,110%,0); }
.rv-in .mask-line:not(.word-reveal) > span, .mask-line:not(.word-reveal).rv-in > span{ transform:none; }
.mask-line:not(.word-reveal) > span{ transition:transform 1.05s var(--ease-out); }
.word-mask{ display:inline-block; overflow:hidden; vertical-align:top; }
.word{
  display:inline-block; opacity:0; transform:translate3d(0,112%,0); will-change:transform,opacity;
  transition:transform .82s var(--ease-out), opacity .5s var(--ease-out);
  transition-delay:var(--word-delay,0ms);
}
.word-reveal.rv-in .word{ opacity:1; transform:none; }

/* ============================================================= preloader */
#pre{
  position:fixed; inset:0; z-index:100; background:#05070a;
  display:flex; align-items:center; justify-content:center;
  transition:opacity .8s var(--ease), visibility .8s;
}
#pre.done{ opacity:0; visibility:hidden; pointer-events:none; }
.pre-in{ width:min(420px,74vw); text-align:center; }
.pre-mark{ margin:0 auto 26px; width:44px; height:44px; opacity:.9; }
.pre-mark svg{ width:100%; height:100%; display:block; }
.pre-jp{ font-size:12px; letter-spacing:.5em; color:var(--bone-dim); margin-bottom:20px; }
.pre-bar{ position:relative; height:1px; background:rgba(223,231,224,.14); overflow:hidden; }
.pre-bar i{ position:absolute; inset:0 100% 0 0; background:var(--bone); transition:right .35s linear; }
.pre-meta{ display:flex; justify-content:space-between; margin-top:12px;
  font-size:10px; letter-spacing:.2em; text-transform:uppercase; color:var(--muted); }
.pre-meta b{ font-weight:500; color:var(--bone-dim); font-variant-numeric:tabular-nums; }

/* ============================================================= nav */
.nav{
  /* Width comes from --vw, not from right:0. A fixed element resolves its
     inset against the initial containing block, and the page's own horizontal
     overflow makes that block wider than the screen — 437 against a 390 phone,
     860 against a 768 tablet — which carried the burger off the right edge.
     --vw is the layout viewport, written on every resize. */
  position:fixed; top:0; left:0; width:var(--vw, 100%); height:var(--nav-h); z-index:50;
  display:flex; align-items:center; gap:24px; padding:0 var(--pad);
  transition:transform .55s var(--ease);
}
/* The sticky wash sits on its own layer rather than on the bar. A
   backdrop-filter promotes its element to the containing block for any
   position:fixed descendant, and the bar holds the mobile menu sheet — put the
   blur here and the sheet was measured against an 84px bar instead of the
   frame, for as long as the blur took to fade. */
.nav::before{
  content:''; position:absolute; inset:0; z-index:-1; pointer-events:none;
  background:rgba(5,7,10,.62);
  backdrop-filter:blur(14px) saturate(1.1); -webkit-backdrop-filter:blur(14px) saturate(1.1);
  opacity:0; transition:opacity .5s linear;
}
.nav.stuck::before{ opacity:1; }
.nav::after{
  content:''; position:absolute; left:var(--pad); right:var(--pad); bottom:0; height:1px;
  background:var(--line-soft); opacity:0; transition:opacity .5s;
}
.nav.stuck::after{ opacity:1; }
.nav.hide{ transform:translate3d(0,-101%,0); }
.brand{ display:flex; align-items:center; gap:11px; flex:0 0 auto; }
.brand svg{ width:34px; height:34px; display:block; }
.brand-tx{ display:flex; flex-direction:column; line-height:1; gap:3px; }
.brand-tx b{ font-size:12px; font-weight:500; letter-spacing:.26em; }
.brand-tx i{ font-style:normal; font-size:8px; letter-spacing:.34em; color:var(--muted); }
.nav-links{ display:flex; gap:clamp(18px,2.6vw,46px); margin-left:auto; }
.nav-link{
  position:relative; font-size:11px; font-weight:500; letter-spacing:.2em; text-transform:uppercase;
  color:var(--bone-dim); display:block; height:15px; line-height:15px; overflow:hidden;
}
.nav-link span{ display:block; height:15px; line-height:15px; transition:transform .55s var(--ease-out), color .3s; }
.nav-link .alt{ position:absolute; inset:0; transform:translate3d(0,100%,0); color:var(--bone); letter-spacing:.32em; }
.nav-link:hover span{ transform:translate3d(0,-100%,0); }
.nav-link:hover .alt{ transform:none; }
.nav-link.on{ color:var(--bone); }
.nav-link.on::after{ content:''; position:absolute; left:0; bottom:0; width:100%; height:1px; background:var(--vermilion); }
.nav-burger{ margin-left:clamp(18px,2.6vw,42px); width:26px; height:16px; position:relative; flex:0 0 auto; }
.nav-burger i{ position:absolute; right:0; height:1.5px; background:var(--bone); transition:width .45s var(--ease-out), transform .45s var(--ease-out); }
.nav-burger i:nth-child(1){ top:4px; width:26px; }
.nav-burger i:nth-child(2){ top:11px; width:17px; }
.nav-burger:hover i:nth-child(2){ width:26px; }
.nav-burger:hover i:nth-child(1){ width:17px; }

/* ============================================================= hero */
.hero{ position:relative; min-height:100svh; padding:0 var(--pad); display:flex; flex-direction:column; }
.hero::before{ content:''; position:absolute; left:0; right:0; top:0; height:46%; pointer-events:none;
  background:linear-gradient(rgba(3,6,9,.72), rgba(3,6,9,.34) 46%, transparent); }
.hero-top{ padding-top:calc(var(--nav-h) + clamp(12px,2.6vh,34px)); max-width:min(560px,46vw); position:relative; z-index:2; }
.hero-top .eyebrow{ display:flex; align-items:center; gap:10px; margin-bottom:20px; }
.hero-top .eyebrow .dot{ width:5px; height:5px; border-radius:50%; background:var(--vermilion); box-shadow:0 0 10px var(--vermilion); }
.hero h1{ margin-bottom:18px; }
.hero-sub{ max-width:322px; text-shadow:0 1px 26px rgba(3,6,8,.95); }
.hero-spacer{ flex:1 1 auto; min-height:clamp(140px,26vh,300px); }
.hero-foot{ padding-bottom:clamp(22px,4.2vh,42px); position:relative; z-index:2; }
.hero-cue{ display:flex; align-items:center; justify-content:flex-end; gap:12px; margin-bottom:14px;
  font-size:9px; letter-spacing:.3em; text-transform:uppercase; color:var(--muted); }
.hero-cue .track{ width:54px; height:1px; background:var(--line); position:relative; overflow:hidden; }
.hero-cue .track i{ position:absolute; inset:0; background:var(--bone); transform-origin:left;
  animation:cue 2.8s var(--ease-io) infinite; }
.chapters{
  display:grid; grid-template-columns:repeat(4,1fr); gap:clamp(14px,2.4vw,40px);
  border-top:1px solid var(--line-soft); padding-top:18px;
}
.chip{ display:flex; gap:14px; align-items:flex-start; cursor:pointer; }
.chip .num{ line-height:1; flex:0 0 auto; transition:color .4s, transform .5s var(--ease-out); }
.chip .tx{ min-width:0; padding-top:3px; }
.chip b{ display:block; font-size:10px; font-weight:500; letter-spacing:.2em; text-transform:uppercase;
  color:var(--bone-dim); margin-bottom:6px; transition:color .4s;
  text-shadow:0 1px 16px rgba(3,6,8,.9); }
.chip p{ margin:0; font-size:11px; line-height:1.5; color:#7d8781; max-width:22ch;
  transition:color .4s; }
.chip:hover .num{ color:var(--ember); transform:translate3d(0,-2px,0); }
.chip:hover b{ color:var(--bone); }
.chip:hover p{ color:var(--bone-dim); }
.chip.on .num{ color:var(--ember); }
.chip.on b{ color:var(--bone); }
/* the floating window: a generated Sanmon still carried over the hero */
.peek{
  position:absolute; z-index:2; right:clamp(78px,11vw,190px); top:clamp(132px,25vh,238px);
  width:clamp(150px,16vw,262px); display:block;
  transition:transform .8s var(--ease-out);
}
.peek-fr{ display:block; aspect-ratio:16/10;
  background:linear-gradient(180deg, rgba(3,6,9,.04) 24%, rgba(3,6,9,.48) 100%),
    url('secret-pathways-assets/generated/kage-sanmon-preview.webp') center / cover no-repeat;
  outline:1px solid rgba(223,231,224,.16); outline-offset:-1px;
  box-shadow:0 34px 70px -30px rgba(0,0,0,.9); transition:outline-color .5s; }
.peek:hover{ transform:translate3d(0,-5px,0); }
.peek:hover .peek-fr{ outline-color:rgba(223,231,224,.42); }
.peek-play{ position:absolute; top:0; right:0; left:0; bottom:auto; aspect-ratio:16/10;
  display:grid; place-items:center; pointer-events:none; }
.peek-play svg{ width:clamp(30px,3.2vw,46px); height:auto; filter:drop-shadow(0 2px 12px rgba(0,0,0,.7));
  transition:transform .55s var(--ease-out); }
.peek:hover .peek-play svg{ transform:scale(1.14); }
.peek-cap{ display:flex; align-items:baseline; gap:9px; margin-top:9px; }
.peek-cap b{ font-size:11px; font-weight:400; letter-spacing:.24em; color:var(--bone-dim); }
.peek-cap i{ font-style:normal; font-size:9px; letter-spacing:.2em; text-transform:uppercase; color:var(--muted); }
@media (max-width:820px){ .peek{ display:none; } }

.hero-side{
  position:absolute; z-index:2; right:var(--pad); top:50%; transform:translateY(-50%);
  display:flex; flex-direction:column; align-items:center; gap:14px; pointer-events:none;
}
.hero-side .v{ writing-mode:vertical-rl; font-size:13px; letter-spacing:.62em; color:rgba(223,231,224,.5); }
@keyframes cue{ 0%{transform:scaleX(0)} 42%{transform:scaleX(1)} 100%{transform:scaleX(1) translateX(100%)} }

/* ============================================================= sections */
.sec{ position:relative; padding:clamp(88px,15vh,190px) var(--pad); }
.sec::before{
  content:''; position:absolute; inset:-22% -4%; z-index:-1; pointer-events:none;
  background:radial-gradient(110% 62% at 30% 50%, rgba(4,7,10,.88), rgba(4,7,10,.62) 42%, rgba(4,7,10,.18) 74%, rgba(4,7,10,0));
  /* A chapter's scrim is there to hold its own copy off the sanctuary, and it
     is inset -22% so its edges never land as a seam. That overshoot, plus the
     section's top padding, means it was washing most of a screen of scene
     above the chapter — the frame stayed masked long after the hero had let
     go of it. The mask takes the top of it back off: nothing at the very top,
     full strength by the time the chapter's own first line arrives. */
  -webkit-mask-image:linear-gradient(transparent, #000 44%);
          mask-image:linear-gradient(transparent, #000 44%);
}
#pathways::before{ background:radial-gradient(108% 64% at 50% 50%, rgba(4,7,10,.70), rgba(4,7,10,.44) 50%, rgba(4,7,10,0)); }
#eternity::before{ background:radial-gradient(82% 58% at 50% 46%, rgba(4,7,10,.26), rgba(4,7,10,.66) 56%, rgba(4,7,10,.90) 82%, rgba(4,7,10,0)); }
.foot::before{
  content:''; position:absolute; inset:0; z-index:-1; pointer-events:none;
  background:linear-gradient(rgba(4,7,10,.55), rgba(4,7,10,.94) 40%, rgba(4,7,10,.98));
}
.sec-head{ display:flex; align-items:baseline; gap:16px; margin-bottom:clamp(30px,5vh,66px); }
.sec-head .k{ font-size:10px; font-weight:500; letter-spacing:.24em; text-transform:uppercase; color:var(--muted); }
.sec-head .k b{ color:var(--vermilion); font-weight:500; }
.rule{ flex:1 1 auto; height:1px; background:var(--line-soft); }

/* — chapter I: the gate */
.gate-grid{ display:grid; grid-template-columns:minmax(0,1.02fr) minmax(0,1fr); gap:clamp(28px,5vw,90px); align-items:start; }
.gate-grid h2{ max-width:11ch; }
.gate-copy{ padding-top:6px; }
.gate-copy .lead{ font-size:clamp(15px,1.16vw,19px); line-height:1.66; color:#c2cdc5; text-shadow:0 1px 20px rgba(3,6,8,.9); }
.gate-copy .lead + p{ margin-top:20px; }
.arrowlink{ display:inline-flex; align-items:center; gap:12px; margin-top:34px;
  font-size:11px; font-weight:500; letter-spacing:.2em; text-transform:uppercase; color:var(--bone); }
.arrowlink .ar{ width:34px; height:34px; border:1px solid var(--line); border-radius:50%;
  display:grid; place-items:center; transition:background .45s var(--ease), border-color .45s, transform .5s var(--ease-out); }
.arrowlink .ar svg{ width:13px; height:13px; transition:transform .5s var(--ease-out); }
.arrowlink:hover .ar{ background:var(--bone); border-color:var(--bone); }
.arrowlink:hover .ar svg{ transform:translate3d(2px,-2px,0); }
.arrowlink:hover .ar svg path{ stroke:#05070a; }
.gate-stats{ display:flex; gap:clamp(24px,4vw,62px); margin-top:clamp(46px,8vh,96px); border-top:1px solid var(--line-soft); padding-top:24px; }
.gate-stats div b{ display:block; font-size:clamp(22px,2.1vw,32px); font-weight:300; letter-spacing:-.02em; }
.gate-stats div span{ font-size:10px; letter-spacing:.2em; text-transform:uppercase; color:var(--muted); }

/* — chapter II: pathways cards */
.cards{ display:grid; grid-template-columns:repeat(3,1fr); gap:clamp(10px,1.4vw,22px); align-items:start; }
.card{ position:relative; cursor:pointer; }
.card:nth-child(1){ transform:translateY(0); }
.card:nth-child(2){ transform:translateY(clamp(26px,5vw,74px)); }
.card:nth-child(3){ transform:translateY(clamp(52px,10vw,148px)); }
/* the frame is a hole punched in the page — the scene is rendered straight
   into this rectangle with a scissored viewport, so each card is a live
   window onto a different corner of the sanctuary */
.card-fr{
  position:relative; aspect-ratio:4/5; background:transparent;
  outline:1px solid var(--line-soft); outline-offset:-1px;
  transition:outline-color .5s var(--ease);
}
.card:hover .card-fr{ outline-color:rgba(223,231,224,.30); }
.card:nth-child(1) .card-fr{
  background:
    linear-gradient(180deg,rgba(3,6,9,.04) 36%,rgba(3,6,9,.72) 100%),
    url('secret-pathways-assets/generated/kage-approach.webp') center / cover no-repeat;
}
.card:nth-child(2) .card-fr{
  background:
    linear-gradient(180deg,rgba(3,6,9,.06) 36%,rgba(3,6,9,.74) 100%),
    url('secret-pathways-assets/generated/kage-lantern-court.webp') center / cover no-repeat;
}
.card:nth-child(3) .card-fr{
  background:
    linear-gradient(180deg,rgba(3,6,9,.05) 36%,rgba(3,6,9,.74) 100%),
    url('secret-pathways-assets/generated/kage-moonwater.webp') center / cover no-repeat;
}
.card-fr::after{ content:''; position:absolute; inset:0;
  background:linear-gradient(transparent 46%, rgba(4,6,9,.80)); pointer-events:none; }
/* The fabric overdraws its card by CL_BLEED on every side so the billow and
   the contact shadow have somewhere to go, so the frame cannot clip it. The
   card's outline is not here at all: it is drawn in the cloth shader, where it
   can follow the folds instead of tracing the flat box behind them. */
.cloth-out{ position:absolute; pointer-events:none; z-index:1; }
.on-cloth{ background:none !important; outline:0 !important; overflow:visible; }
.on-cloth::after{ display:none; }

/* ------------------------------------------------- the lights in the stills
   The three plates each have one source in them — two blood moons and a lit
   stone lantern — and they are the only warm thing in an otherwise cold set of
   images. A glow laid over each one, screened so it adds light rather than
   painting over it, is enough to make a flat still look like it is burning.

   Two tracks per light, at durations that do not divide into each other: the
   wrapper swells, the gradient inside it pulses. One track on its own reads as
   a loop within a couple of seconds; two beating against each other do not
   come back around inside the time anyone looks at a card.

   The moon and the lantern are not the same event. A flame gutters — short,
   irregular, deep — so the lantern's track is written unevenly by hand. A moon
   only breathes through moving air, so the moons get a slow, shallow swell and
   nothing more. Giving them both a flicker is what makes this kind of thing
   look like a novelty; the difference is the whole effect. */
.card-fr{ isolation:isolate; }        /* keep the screen blend inside the card */
.glow{
  position:absolute; z-index:1; pointer-events:none;
  left:var(--gx); top:var(--gy); width:calc(var(--gr) * 2); aspect-ratio:1;
  translate:-50% -50%;                /* leaves transform free for the swell */
  mix-blend-mode:screen;
  animation:glow-swell var(--gt2) var(--ease-io) infinite alternate;
}
.glow::before{
  content:''; position:absolute; inset:0; border-radius:50%;
  background:radial-gradient(closest-side, var(--gc1), var(--gc2) 40%, transparent 72%);
  animation:glow-pulse var(--gt) var(--ease-io) infinite;
}
@keyframes glow-swell{ from{ transform:scale(.93); } to{ transform:scale(1.07); } }
@keyframes glow-pulse{ from{ opacity:.78; } 50%{ opacity:1; } to{ opacity:.78; } }
/* a flame does not breathe, it gutters */
@keyframes glow-flame{
  0%{ opacity:.74 }   6%{ opacity:.97 }  12%{ opacity:.63 }  19%{ opacity:.90 }
  27%{ opacity:.55 }  34%{ opacity:.94 } 42%{ opacity:.71 }  51%{ opacity:1 }
  58%{ opacity:.60 }  66%{ opacity:.88 } 74%{ opacity:.67 }  83%{ opacity:.96 }
  91%{ opacity:.72 } 100%{ opacity:.74 }
}
.glow--flame::before{ animation-name:glow-flame; }
.glow--flame{ animation-duration:var(--gt2); }
@media (prefers-reduced-motion:reduce){
  .glow, .glow::before{ animation:none; }
}
.no-webgl .card-fr{ background:linear-gradient(150deg,#101a1d,#0a0d10 62%,#1a0c0b); }
/* If WebGL will not start, the page stays a page: the scene simply becomes a
   still, and the wordmark moves from the 3-D layer into the document. */
.word-fb{ display:none; }
.no-webgl #gl, .no-webgl .peek-play{ display:none; }
/* fixed, or the gradient is measured against a 6000-pixel body box and the
   moon ends up somewhere around the curriculum table */
.no-webgl body{ background:
  radial-gradient(11vw 11vw at 73% 17%, rgba(232,52,28,.95), rgba(158,20,16,.62) 44%, rgba(96,12,12,.20) 64%, transparent 72%),
  radial-gradient(38vw 32vw at 50% 46%, rgba(228,104,24,.40), rgba(122,42,10,.18) 54%, transparent 74%),
  radial-gradient(120% 80% at 50% 0%, rgba(120,150,158,.16), transparent 60%), #05070a;
  background-attachment:fixed; }
.no-webgl .peek-fr{ background:linear-gradient(160deg,#162326,#0b1013 55%,#241010); }
.no-webgl .word-fb{
  display:block; position:absolute; left:0; right:0; bottom:16%; z-index:1;
  font-family:'Wordmark','Onest',sans-serif; font-weight:600; font-size:21.4vw; line-height:.78;
  letter-spacing:.052em; text-align:center; pointer-events:none;
  background:linear-gradient(#e2ece5, #97a89c); -webkit-background-clip:text; background-clip:text;
  color:transparent;
}
/* The live hero has no foot scrim, but the fallback still needs one: there it
   stands on a bright CSS sky rather than a night scene, and the chips and the
   wordmark have nothing else holding them off it. So this carries the whole
   declaration now rather than re-colouring one it used to inherit. */
.no-webgl .hero::after{ content:''; position:absolute; left:0; right:0; bottom:0; height:34%;
  pointer-events:none; background:linear-gradient(transparent, rgba(3,5,8,.72) 52%, rgba(3,5,8,.96)); }
.no-webgl .sec::before, .no-webgl #pathways::before, .no-webgl #eternity::before{ background:none; }
.card-lab{ position:absolute; left:16px; right:16px; bottom:14px; z-index:2;
  display:flex; align-items:flex-end; justify-content:space-between; gap:10px; }
.card-lab b{ font-size:clamp(13px,1.15vw,17px); font-weight:400; letter-spacing:.02em; text-transform:uppercase; }
.card-lab .jp{ font-size:11px; letter-spacing:.3em; color:rgba(223,231,224,.62); }
.card-ar{ position:absolute; top:14px; right:14px; z-index:2; width:26px; height:26px;
  opacity:0; transform:translate3d(-4px,4px,0); transition:.5s var(--ease-out); }
.card:hover .card-ar{ opacity:1; transform:none; }
.card-meta{ display:flex; justify-content:space-between; margin-top:12px;
  font-size:10px; letter-spacing:.16em; text-transform:uppercase; color:var(--muted); }

/* — chapter III: curriculum */
.cur-head{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,.72fr); gap:clamp(28px,5vw,90px); align-items:end; margin-bottom:clamp(34px,6vh,72px); }
.cur-head h2{ max-width:13ch; }
.cur{ border-top:1px solid var(--line-soft); }
.les{
  position:relative; display:grid; align-items:center;
  grid-template-columns:64px minmax(0,1.05fr) minmax(0,1.25fr) 86px;
  gap:clamp(14px,2.4vw,40px);
  padding:clamp(20px,2.4vw,30px) 0; border-bottom:1px solid var(--line-soft);
  cursor:pointer; transition:padding .5s var(--ease-out);
}
.les::before{ content:''; position:absolute; left:calc(var(--pad) * -1); right:0; top:0; bottom:0;
  background:linear-gradient(90deg, rgba(224,35,28,.09), transparent 46%);
  opacity:0; transition:opacity .55s var(--ease); pointer-events:none; }
.les:hover::before{ opacity:1; }
.les:hover{ padding-left:clamp(8px,1.2vw,18px); }
.les .k{ font-size:11px; letter-spacing:.16em; color:var(--muted); font-variant-numeric:tabular-nums; transition:color .4s; }
.les:hover .k{ color:var(--vermilion); }
.les h3{ font-size:clamp(16px,1.5vw,23px); font-weight:400; letter-spacing:-.005em; }
.les h3 em{ font-style:normal; font-family:'NotoJP',sans-serif; font-size:.72em; color:var(--muted); margin-left:12px; letter-spacing:.24em; }
.les p{ margin:0; font-size:13px; line-height:1.6; color:#909b95; text-shadow:0 1px 16px rgba(3,6,8,.92); }
.les .t{ text-align:right; font-size:11px; letter-spacing:.14em; color:#8b958f; font-variant-numeric:tabular-nums; text-shadow:0 1px 16px rgba(3,6,8,.92); }
.les .bar{ position:absolute; left:0; bottom:-1px; height:1px; width:100%; background:var(--vermilion);
  transform:scaleX(0); transform-origin:left; transition:transform .7s var(--ease-out); }
.les:hover .bar{ transform:scaleX(1); }

/* — chapter IV: eternity */
.fin{ min-height:100svh; display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; }
.fin .eyebrow{ margin-bottom:26px; }
.fin h2{ font-size:clamp(38px,7.4vw,124px); line-height:.94; letter-spacing:-.03em; text-transform:uppercase; }
.fin p{ max-width:44ch; margin:26px auto 0; }
.cta{
  position:relative; display:inline-flex; align-items:center; gap:14px; margin-top:44px;
  padding:17px 30px; border:1px solid var(--line); border-radius:100px; overflow:hidden;
  font-size:11px; font-weight:500; letter-spacing:.22em; text-transform:uppercase;
  transition:color .45s var(--ease), border-color .45s;
}
.cta i{ position:absolute; inset:0; background:var(--bone); transform:translate3d(0,101%,0);
  transition:transform .62s var(--ease-out); z-index:-1; }
.cta span,.cta svg{ position:relative; z-index:1; }
.cta:hover{ color:#05070a; border-color:var(--bone); }
.cta:hover i{ transform:none; }
.cta:hover svg path{ stroke:#05070a; }

/* — footer */
.foot{ position:relative; padding:clamp(50px,8vh,96px) var(--pad) clamp(26px,4vh,40px);
  border-top:1px solid var(--line-soft); }
.foot-grid,.foot-base{ position:relative; }
.foot-grid{ display:grid; grid-template-columns:minmax(0,1.4fr) repeat(3,minmax(0,.6fr)); gap:clamp(22px,4vw,60px); }
.foot h4{ margin:0 0 16px; font-size:10px; font-weight:500; letter-spacing:.22em; text-transform:uppercase; color:var(--muted); }
.foot ul{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:9px; }
.foot li a{ font-size:13px; color:#8f9a93; transition:color .35s; }
.foot li a:hover{ color:var(--bone); }
.foot-brand p{ margin:16px 0 0; max-width:34ch; font-size:13px; color:#79847e; }
.foot-base{ display:flex; justify-content:space-between; align-items:center; gap:20px; flex-wrap:wrap;
  margin-top:clamp(38px,6vh,74px); padding-top:20px; border-top:1px solid var(--line-soft);
  font-size:10px; letter-spacing:.16em; text-transform:uppercase; color:var(--muted); }

/* ============================================================= progress rail */
.rail{ position:fixed; right:calc(var(--pad) - 4px); top:50%; transform:translateY(-50%); z-index:45;
  display:flex; flex-direction:column; gap:12px; align-items:center; }
.rail button{ width:22px; height:10px; display:grid; place-items:center; }
.rail i{ display:block; width:14px; height:1px; background:rgba(223,231,224,.26); transition:.5s var(--ease-out); }
.rail button.on i{ width:22px; background:var(--bone); }
.rail button:hover i{ width:22px; background:var(--bone-dim); }

/* ============================================================= cursor */
.cur-dot{ position:fixed; z-index:80; top:0; left:0; width:26px; height:26px; margin:-13px 0 0 -13px;
  border:1px solid rgba(223,231,224,.42); border-radius:50%; pointer-events:none;
  transition:width .35s var(--ease-out), height .35s var(--ease-out), margin .35s var(--ease-out),
             background .35s, border-color .35s, opacity .3s; opacity:0; }
.cur-dot.act{ width:52px; height:52px; margin:-26px 0 0 -26px; background:rgba(223,231,224,.07); border-color:rgba(223,231,224,.6); }
@media (hover:hover) and (pointer:fine){ .cur-dot{ opacity:1 } }

/* ============================================================= responsive */
@media (max-width:1080px){
  .hero-top{ max-width:none; }
  .gate-grid{ grid-template-columns:1fr; gap:26px; }
  .cur-head{ grid-template-columns:1fr; gap:18px; }
  .les{ grid-template-columns:44px minmax(0,1fr) 74px; }
  .les p{ display:none; }
  .foot-grid{ grid-template-columns:1fr 1fr; }
}
@media (max-width:820px){
  .nav-links{
    display:flex; position:fixed; top:0; right:0; bottom:0; width:min(84vw,360px);
    padding:calc(var(--nav-h) + 28px) var(--pad) 34px; flex-direction:column; align-items:flex-start; gap:0;
    background:rgba(3,6,9,.98); border-left:1px solid var(--line-soft);
    transform:translate3d(100%,0,0); opacity:0; visibility:hidden; pointer-events:none;
    transition:transform .65s var(--ease-out), opacity .4s, visibility 0s linear .65s;
  }
  .nav.menu-open .nav-links{
    transform:none; opacity:1; visibility:visible; pointer-events:auto; transition-delay:0s;
  }
  .nav-links .nav-link{
    width:100%; height:auto; padding:17px 0; overflow:visible; border-bottom:1px solid var(--line-soft);
    font-size:17px; line-height:1.2;
  }
  .nav-links .nav-link span{ height:auto; line-height:1.2; transform:none !important; }
  .nav-links .nav-link .alt{ position:static; margin-top:6px; font-size:10px; color:var(--muted); }
  /* The links panel leaves the flow to become the slide-in sheet, taking its
     margin-left:auto with it, so without this the burger sits tucked against
     the wordmark instead of out at the edge where a thumb reaches. */
  /* A transform *or* a backdrop-filter makes an element the containing block
     for its position:fixed descendants. This panel is a child of the bar, so
     once the bar went sticky and started hiding on scroll the open panel was
     measured against the bar instead of the viewport: 146px tall, dragged off
     the top, with the page showing through beside it. Neither may apply while
     the sheet is open — it covers the frame, so there is nothing left to blur. */
  /* A hide transform still promotes the bar, so drop it the instant the sheet
     opens. Without transition:none it eases out over .55s and the sheet spends
     that whole time measured against the bar. */
  .nav.menu-open{ transform:none; transition:none; }
  .nav-burger{ margin-left:auto; }
  .nav-burger.active i:nth-child(1){ top:7px; width:26px; transform:rotate(45deg); }
  .nav-burger.active i:nth-child(2){ top:7px; width:26px; transform:rotate(-45deg); }
  html.nav-open,html.nav-open body{ overflow:hidden; }
  .rail{ display:none; }
  .hero-side{ display:none; }
  .chapters{ grid-template-columns:repeat(2,1fr); gap:18px 14px; }
  .chip p{ font-size:10px; }
  .cards{ grid-template-columns:1fr; gap:14px; }
  .card:nth-child(2),.card:nth-child(3){ transform:none; }
  .gate-stats{ flex-wrap:wrap; gap:20px 34px; }
  .foot-grid{ grid-template-columns:1fr; }
  .hero-cue{ display:none; }
}
/* ================================================== section layouts (B set)
   The six sections ship their B compositions — hero split, story spread,
   gardens mosaic, chapter atlas, gallery closing, manifesto footer. They are
   keyed off stable body data attributes so the rules above keep reading as
   the plain grammar of the page and every B override stays in one block.   */

/* — hero B · split: an editorial reading column holds the left, a compact
     numbered index sits under it, and the live scene window becomes a low,
     right-hand counterweight. The scene itself remains untouched.

     The column carried a 90° scrim over the left three-quarters of the frame.
     It has gone: the sanctuary is already near-black behind the reading, and
     measured against it the copy loses almost nothing without it — the
     headline 15.98:1 to 15.47:1, the standfirst 7.94:1 to 7.75:1, the index
     16.11:1 to 16.08:1, all far past what either needs. It was darkening the
     approach, the gate and the lower steps to buy half a point of contrast
     nobody was short of. */
body[data-layout-hero="b"] .hero-top{ margin-left:0; max-width:min(590px,46vw); }
body[data-layout-hero="b"] .hero-foot{ width:min(480px,40vw); margin-left:0; }
body[data-layout-hero="b"] .hero-cue{ justify-content:flex-start; }
body[data-layout-hero="b"] .chapters{ grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
body[data-layout-hero="b"] .chip{ position:relative; flex-direction:column; gap:0; }
body[data-layout-hero="b"] .chip .num{ font-size:clamp(29px,3.1vw,48px); }
body[data-layout-hero="b"] .chip .tx{
  position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0);
  clip-path:inset(50%); white-space:nowrap;
}
/* the live window drops into the open right corner, away from the reading
   column and the oversized 3-D wordmark */
body[data-layout-hero="b"] .peek{
  left:auto; right:var(--pad); top:auto; bottom:clamp(38px,7vh,96px);
}
body[data-layout-hero="b"] .peek-cap b.jp{ font-size:clamp(20px,2.2vw,34px); letter-spacing:.18em; }
/* The Japanese title reads down the right edge, hung under the navigation —
   the one mark on that side of the frame now that the romanization has gone. */
body[data-layout-hero="b"] .hero-side{
  left:auto; right:var(--pad); top:calc(var(--nav-h) + clamp(14px,3vh,42px));
  bottom:auto; transform:none; display:block;
}
body[data-layout-hero="b"] .hero-side .v{
  position:static; transform:none;
  font-size:clamp(30px,3.2vw,54px); letter-spacing:.12em; color:rgba(223,231,224,.62);
}

/* — story B · spread: the title crosses two columns, the reading column is
     held to the right, and the figures become a ruled band. */
body[data-layout-story="b"] .gate-grid{
  grid-template-columns:minmax(120px,.38fr) minmax(0,1.08fr) minmax(280px,.88fr);
  gap:clamp(20px,3vw,54px);
}
body[data-layout-story="b"] .gate-grid h2{ grid-column:1 / 3; max-width:13ch; }
body[data-layout-story="b"] .gate-copy{ grid-column:3; }
body[data-layout-story="b"] .gate-stats{ display:grid; grid-template-columns:repeat(4,1fr); gap:0; }
body[data-layout-story="b"] .gate-stats div{ min-height:108px; padding:18px; border-left:1px solid var(--line-soft); }
body[data-layout-story="b"] .gate-stats div:last-child{ border-right:1px solid var(--line-soft); }

/* — gardens B · mosaic: one immersive live window beside two stacked ones.
     The cards stretch to their rows and the frames flex, so the two small
     viewports meet the tall one exactly instead of drifting apart. */
body[data-layout-gallery="b"] .cards{
  grid-template-columns:minmax(0,1.55fr) minmax(260px,.75fr);
  grid-template-rows:repeat(2,minmax(0,1fr));
  gap:14px; align-items:stretch;
}
body[data-layout-gallery="b"] .card{ transform:none; display:flex; flex-direction:column; min-height:0; }
body[data-layout-gallery="b"] .card-fr{ aspect-ratio:auto; flex:1 1 auto; min-height:0; }
body[data-layout-gallery="b"] .card:first-child{ grid-row:1 / 3; }
body[data-layout-gallery="b"] .card:first-child .card-fr{ flex:0 0 auto; height:min(72vh,760px); }
body[data-layout-gallery="b"] .card:not(:first-child) .card-fr{ min-height:200px; }

/* — chapters B · atlas: a two-then-three plate grid instead of a syllabus.
     Number and title sit at the top of each plate, the running time drops to
     the foot of it. */
body[data-layout-curriculum="b"] .cur-head{ grid-template-columns:minmax(0,.72fr) minmax(0,1.2fr); align-items:start; }
body[data-layout-curriculum="b"] .cur{
  display:grid; grid-template-columns:repeat(6,1fr); gap:1px;
  background:var(--line-soft); border:1px solid var(--line-soft);
}
body[data-layout-curriculum="b"] .les{
  display:flex; flex-direction:column; align-items:flex-start; min-height:250px;
  padding:24px; gap:14px; border:0; background:rgba(5,7,10,.84); overflow:hidden;
}
body[data-layout-curriculum="b"] .les:nth-child(-n+2){ grid-column:span 3; }
body[data-layout-curriculum="b"] .les:nth-child(n+3){ grid-column:span 2; }
body[data-layout-curriculum="b"] .les::before{ left:0; background:linear-gradient(145deg,rgba(224,35,28,.13),transparent 58%); }
body[data-layout-curriculum="b"] .les:hover{ padding-left:32px; }
body[data-layout-curriculum="b"] .les h3{ font-size:clamp(20px,2vw,30px); }
body[data-layout-curriculum="b"] .les p{ display:block; max-width:34ch; }
body[data-layout-curriculum="b"] .les .t{ text-align:left; margin-top:auto; }

/* — closing B · gallery: the title hangs vertically on the right while the
     chapter mark, the last line and the call to action run down the left. */
body[data-layout-closing="b"] .fin{
  display:grid; grid-template-columns:minmax(260px,.7fr) minmax(0,1.3fr); grid-template-rows:auto auto auto;
  align-content:center; align-items:end; text-align:left;
  column-gap:clamp(40px,7vw,120px); row-gap:clamp(16px,2.6vh,30px);
}
body[data-layout-closing="b"] .fin .eyebrow{ grid-column:1; grid-row:1; margin:0; }
body[data-layout-closing="b"] .fin h2{
  grid-column:2; grid-row:1 / 4; align-self:center; justify-self:end;
  font-size:clamp(52px,6vw,82px); writing-mode:vertical-rl;
}
body[data-layout-closing="b"] .fin p{ grid-column:1; grid-row:2; margin:0; }
body[data-layout-closing="b"] .fin .cta{ grid-column:1; grid-row:3; justify-self:start; margin-top:4px; }

/* — footer B · manifesto: the statement leads, the navigation becomes a
     compact ruled index beneath it. */
body[data-layout-footer="b"] .foot-grid{ grid-template-columns:repeat(3,1fr); }
body[data-layout-footer="b"] .foot-brand{
  grid-column:1 / -1; display:grid; grid-template-columns:70px minmax(0,1fr); align-items:center;
  padding-bottom:34px; margin-bottom:8px; border-bottom:1px solid var(--line-soft);
}
body[data-layout-footer="b"] .foot-brand svg{ width:54px; height:54px; }
body[data-layout-footer="b"] .foot-brand p{
  margin:0; max-width:48ch; font-size:clamp(18px,2vw,30px); line-height:1.35; color:var(--bone-dim);
}
body[data-layout-footer="b"] .foot-grid > div:nth-child(n+3){
  padding-left:clamp(18px,2.2vw,34px); border-left:1px solid var(--line-soft);
}
body[data-layout-footer="b"] .foot-base{ display:grid; grid-template-columns:1fr auto 1fr; }
body[data-layout-footer="b"] .foot-base span:last-child{ text-align:right; }

/* ================================================= foreground layers (KAGE)
   The transparent PNG library in secret-pathways-assets/foreground/png is
   introduced as a near plane. Each section carries one .fg box holding a few
   cut-outs that stand between the live sanctuary and the reading: the scene
   itself is untouched, this is only the garden that grows in front of it.

   The cut-outs are the nearest plane on the page, so they sit in front of the
   reading rather than behind it. Inside .page — itself z-index:10, above the
   #gl canvas at 0:
     -1   .sec::before / .foot::before   the section scrims
      2   headings, copy, links, cards, stats, CTA
      3   .fg                            the cut-outs: aria-hidden, inert
   .page caps everything it holds at tier 10, which is under the fixed
   furniture (rail 45, nav 50), so the chapter's live near plane is moved out
   to #fg-sky at 52 — over the layout and the navigation both, and still under
   the passes that treat the frame as one image: vignette 55, grain 60,
   cursor 80.

   A stage is only ever shown while its own chapter holds the viewport, and
   only in the fixed bottom-anchored form. Parked back in its section it is
   inert and invisible: a near plane that scrolls with the page reads as
   wallpaper printed on the document rather than as something standing between
   the camera and the scene, so there is no second, section-owned appearance.
   Every piece is therefore placed from the bottom edge of that fixed plane —
   left/right and bottom, never top.

   The entrance is driven by the chapter, not by the scroll observer: each
   [data-fg-in] piece rises from the edge it is anchored to when its stage goes
   live, one every 90ms in DOM order, and the reduced-motion block at the foot
   of this sheet flattens it. The wrapper owns the entrance and animates
   opacity and transform only; the artwork itself remains at full opacity, with
   the reading protected by the layer order rather than a translucent
   foreground treatment. */
.fg{
  position:absolute; inset:0; z-index:3;
  overflow:visible; pointer-events:none;
}
/* Nothing inside a section may widen the document. The foreground cut-outs and
   the cloth canvases are placed to hang past the frame edges on purpose, and as
   ordinary in-flow descendants they dragged the page box out with them — 194px
   past a 1440 frame. Clip the horizontal axis at the section, which is exactly
   viewport-wide, so each overhang is cut precisely where it leaves the screen.
   `clip` pairs with `visible` without coercing it, so no scroll container is
   created and every bit of vertical bleed survives. */
.sec, .foot{ overflow-x:clip; overflow-y:visible; }
/* the host for whichever chapter currently owns the near plane — no box of
   its own, since every stage it holds is position:fixed to the viewport */
#fg-sky{
  position:fixed; left:0; top:0; width:0; height:0;
  z-index:52; pointer-events:none;
}
.sec > *:not(.fg), .foot > *:not(.fg){ position:relative; z-index:2; }
.fg-el{ position:absolute; display:block; opacity:0; }
.fg-el > img{
  width:100%; height:auto;   /* the width/height attrs hold the box before the lazy image lands */
  opacity:1;
  filter:saturate(.88) brightness(.86);
}
.fg-el--flip > img{ transform:scaleX(-1); }
/* each piece waits off the edge it is anchored to until its chapter is called */
[data-fg-in="up"]   { transform:translate3d(0,13%,0); }
[data-fg-in="left"] { transform:translate3d(-11%,6%,0); }
[data-fg-in="right"]{ transform:translate3d(11%,6%,0); }
/* one slow breath, on the two lightest layers only */
@keyframes fg-sway{
  from{ transform:rotate(-.45deg) translate3d(0,2px,0); }
  to  { transform:rotate(.45deg)  translate3d(0,-6px,0); }
}
.fg-el--sway > img{ animation:fg-sway 21s var(--ease-io) infinite alternate; transform-origin:0% 45%; }

/* The active chapter owns a bottom-anchored near plane. Its former stage is
   retained only long enough to dissolve into the next chapter's scene. */
.fg.fg-active,.fg.fg-retiring{
  position:fixed; left:0; right:0; top:auto; bottom:0; height:min(56svh,680px);
  z-index:1; overflow:visible; pointer-events:none;
}
.fg.fg-active .fg-el{
  opacity:1; transform:none;
  transition:opacity 1.2s var(--ease-out), transform 1.5s var(--ease-out);
  transition-delay:var(--fg-delay,0ms);
}
.fg .fg-el:nth-child(2){ --fg-delay:90ms; }
.fg .fg-el:nth-child(3){ --fg-delay:180ms; }
.fg .fg-el:nth-child(4){ --fg-delay:270ms; }
.fg.fg-retiring .fg-el{
  opacity:0; transform:translate3d(0,8vh,0) scale(.98); filter:blur(18px);
  transition:opacity .68s var(--ease-out),transform .78s var(--ease-out),filter .78s var(--ease-out);
}

/* Placement is keyed off [data-fg] rather than the section id, because the
   stage of the live chapter is re-parented into #fg-sky and stops being a
   descendant of its own section for as long as it holds the near plane.

   Every piece is anchored from the bottom of that plane, which is the bottom
   of the viewport. The cut-outs in png/ are trimmed flush to their own lowest
   opaque row, so a negative bottom here is a real amount of artwork buried off
   the foot of the frame, not slack in the file — the ground-sitting pieces
   carry enough of it that their base line never lifts off the edge. */

/* — I · the gate: the wall runs along the lower-left frame, a pine holds the
     far edge, and one bank of grass closes the foot of the chapter */
/* Wall against the left edge, pine against the right: the chapter is framed
   by the two of them with the court open between.

   left:0, not a negative offset — the plate is trimmed to its own artwork, so
   pulling it off-frame cuts the gate itself rather than trimming empty margin.
   Its left edge sits on the viewport's. */
[data-fg="gate"] .fg-wall  { left:0; bottom:-30px; width:clamp(430px,47vw,780px); }
[data-fg="gate"] .fg-pine  { right:-4%; bottom:-46px; width:clamp(190px,21vw,370px); }
[data-fg="gate"] .fg-grass { left:3%;   bottom:-150px; width:clamp(420px,60vw,1050px); }

/* — II · still gardens: sakura and maple sweep in from the two lower corners
     and carry their whole length, a lit lantern and a low bush hold the edges */
[data-fg="pathways"] .fg-sakura { left:-6%;  bottom:-86px;  width:clamp(430px,50vw,880px); }
[data-fg="pathways"] .fg-leaves { right:-6%; bottom:-62px;  width:clamp(330px,38vw,670px); }
[data-fg="pathways"] .fg-lantern{ left:-1%;  bottom:-96px;  width:clamp(120px,13vw,215px); }
[data-fg="pathways"] .fg-bush   { right:-4%; bottom:-38px;  width:clamp(250px,30vw,530px); }
/* the lantern is the one lit object in the library — it keeps its ember */
[data-fg="pathways"] .fg-lantern > img{ filter:saturate(.96) brightness(1); }
[data-fg="pathways"] .fg-leaves > img{ transform-origin:100% 40%; }

/* — III · sacred craft: basalt and a low wall fragment bracket the atlas and
     a stand of grass ties them together, all three below the plate grid */
[data-fg="lessons"] .fg-wall   { right:-8%; bottom:-78px; width:clamp(390px,50vw,870px); }
[data-fg="lessons"] .fg-stones { left:-4%;  bottom:-48px; width:clamp(250px,30vw,530px); }
[data-fg="lessons"] .fg-grass  { left:24%;  bottom:-84px; width:clamp(280px,33vw,580px); }

/* — IV · afterlight: a far hill closes the horizon, the shrine ruins stand at
     the left of it, grass banks the right, one branch hangs into the corner */
[data-fg="eternity"] .fg-hill   { left:-8%; width:min(150vh,116%); bottom:calc(-1 * min(20vh,15.5vw)); }
[data-fg="eternity"] .fg-ruins  { left:-5%;  bottom:-32px; width:clamp(240px,28vw,500px); }
[data-fg="eternity"] .fg-grass  { right:-7%; bottom:-60px; width:clamp(300px,37vw,670px); }
[data-fg="eternity"] .fg-sakura { left:-15%; bottom:-70px; width:clamp(280px,33vw,580px); }

/* — colophon: one restrained planting along the lower edge, nothing above it */
[data-fg="foot"] .fg-bush   { left:-6%;  bottom:-110px; width:clamp(230px,28vw,480px); }
[data-fg="foot"] .fg-grass  { right:-5%; bottom:-98px;  width:clamp(255px,30vw,530px); }
[data-fg="foot"] .fg-stones { left:36%;  bottom:-104px; width:clamp(190px,22vw,370px); }

@media (max-width:1080px){
  /* the columns have collapsed and the copy has taken the width back, so the
     framing layers step down rather than crowd it */
  [data-fg="gate"] .fg-wall      { width:clamp(390px,42vw,600px); }
  [data-fg="pathways"] .fg-sakura{ width:clamp(400px,46vw,660px); }
  [data-fg="pathways"] .fg-leaves{ width:clamp(310px,35vw,500px); }
  [data-fg="lessons"] .fg-wall   { width:clamp(360px,45vw,640px); }
  [data-fg="eternity"] .fg-sakura{ width:clamp(260px,30vw,430px); }
}
@media (max-width:820px){
  /* one or two anchors per section: the stacked cards, the atlas plates and
     the call to action own the whole width here, so every duplicated piece
     of ornament goes and what stays is smaller */
  [data-fg="gate"] .fg-pine, [data-fg="pathways"] .fg-sakura,
  [data-fg="pathways"] .fg-leaves, [data-fg="lessons"] .fg-wall,
  [data-fg="eternity"] .fg-sakura, [data-fg="foot"] .fg-stones{ display:none; }
  [data-fg="gate"] .fg-wall     { left:-14%; bottom:-22px; width:min(82vw,440px); }
  [data-fg="gate"] .fg-grass    { left:auto; right:-10%; bottom:-76px; width:min(94vw,530px); }
  [data-fg="pathways"] .fg-lantern{ left:-4%;   bottom:-58px; width:clamp(80px,20vw,128px); }
  [data-fg="pathways"] .fg-bush   { right:-10%; bottom:-38px; width:min(72vw,400px); }
  [data-fg="lessons"] .fg-stones  { left:-8%; bottom:-34px; width:min(66vw,360px); }
  [data-fg="lessons"] .fg-grass   { left:auto; right:-8%; bottom:-44px; width:min(70vw,380px); }
  [data-fg="eternity"] .fg-ruins  { left:-8%;   bottom:-25px; width:min(54vw,300px); }
  [data-fg="eternity"] .fg-grass  { right:-12%; bottom:-34px; width:min(80vw,440px); }
  [data-fg="foot"] .fg-bush  { left:-12%;  bottom:-26px; width:min(68vw,340px); }
  [data-fg="foot"] .fg-grass { right:-12%; bottom:-30px; width:min(74vw,380px); }
}
@media (prefers-reduced-motion:reduce){
  .fg-el--sway > img{ animation:none; }
  .fg.fg-retiring .fg-el{ transition:none !important; filter:none !important; }
}

@media (max-width:1080px){
  body[data-layout-hero="b"] .chapters{ gap:16px 20px; }
  body[data-layout-story="b"] .gate-grid{ grid-template-columns:1fr; }
  body[data-layout-story="b"] .gate-grid h2,
  body[data-layout-story="b"] .gate-copy{ grid-column:auto; }
  body[data-layout-story="b"] .gate-grid h2{ max-width:16ch; }
  body[data-layout-curriculum="b"] .les:nth-child(n){ grid-column:span 3; }
  body[data-layout-curriculum="b"] .les:last-child{ grid-column:span 6; }
}
@media (max-width:820px){
  /* the split collapses and the copy takes the page back */
  body[data-layout-hero="b"] .hero-top{ margin-left:0; max-width:none; }
  body[data-layout-hero="b"] .hero-foot{ width:100%; }
  /* The four-column strip is set on a selector specific enough to survive the
     plain .chapters override below 820, so it has to be unset here by name or
     the chips stay four across on a phone and wrap a word to a line. */
  body[data-layout-hero="b"] .chapters{ grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px 14px; }
  body[data-layout-hero="b"] .chip{ flex-direction:row; gap:14px; }
  /* Same shape of bug: the layout selector outranks the plain single-column
     rule, so the heading kept a 37% column while its words are set at 30px —
     "CHAPTERS." alone is wider than that, and it spilled over the paragraph. */
  body[data-layout-curriculum="b"] .cur-head{ grid-template-columns:1fr; gap:18px; align-items:start; }
  body[data-layout-hero="b"] .chip .num{ font-size:clamp(26px,8vw,36px); }
  body[data-layout-hero="b"] .chip .tx{
    position:static; width:auto; height:auto; overflow:visible; clip:auto;
    clip-path:none; white-space:normal;
  }

  body[data-layout-story="b"] .gate-stats{ grid-template-columns:repeat(2,1fr); border-right:1px solid var(--line-soft); }
  body[data-layout-story="b"] .gate-stats div{ min-height:86px; padding:14px; }
  body[data-layout-story="b"] .gate-stats div:nth-child(-n+2){ border-bottom:1px solid var(--line-soft); }
  body[data-layout-story="b"] .gate-stats div:last-child{ border-right:0; }

  /* the mosaic unstacks, but the rhythm survives: the lead window stays the
     tall one and the two field notes stay letterbox */
  body[data-layout-gallery="b"] .cards{ grid-template-columns:1fr; grid-template-rows:auto; }
  body[data-layout-gallery="b"] .card:first-child{ grid-row:auto; }
  body[data-layout-gallery="b"] .card-fr{ flex:0 0 auto; height:auto; }
  body[data-layout-gallery="b"] .card:first-child .card-fr{ height:auto; aspect-ratio:4 / 5; }
  body[data-layout-gallery="b"] .card:not(:first-child) .card-fr{ aspect-ratio:16 / 10; min-height:0; }

  body[data-layout-curriculum="b"] .cur{ grid-template-columns:1fr; }
  body[data-layout-curriculum="b"] .les:nth-child(n){ grid-column:auto; min-height:0; padding:20px; }
  body[data-layout-curriculum="b"] .les:hover{ padding-left:20px; }

  body[data-layout-closing="b"] .fin{ display:flex; align-items:center; text-align:center; gap:0; }
  body[data-layout-closing="b"] .fin .eyebrow{ margin:0 0 26px; }
  body[data-layout-closing="b"] .fin h2{ writing-mode:horizontal-tb; font-size:clamp(38px,7.4vw,124px); }
  body[data-layout-closing="b"] .fin p{ margin:26px auto 0; }
  body[data-layout-closing="b"] .fin .cta{ margin-top:40px; }

  body[data-layout-footer="b"] .foot-grid{ grid-template-columns:1fr; }
  body[data-layout-footer="b"] .foot-brand{ grid-column:auto; grid-template-columns:minmax(0,1fr); row-gap:16px; }
  body[data-layout-footer="b"] .foot-grid > div:nth-child(n+3){ padding-left:0; border-left:0; }
  body[data-layout-footer="b"] .foot-base{ display:flex; }
  body[data-layout-footer="b"] .foot-base span:last-child{ text-align:left; }
}

/* On a phone two columns leave the chip's copy about 110px once the numeral
   and its gutter are taken out, which is narrow enough to wrap a single word
   to a line. Standing the numeral above the label gives the copy the whole
   column back. */
@media (max-width:560px){
  body[data-layout-hero="b"] .chip{ flex-direction:column; gap:4px; }
  body[data-layout-hero="b"] .chip .num{ font-size:clamp(22px,7.4vw,30px); }
  body[data-layout-hero="b"] .chip .tx{ padding-top:0; }
  body[data-layout-hero="b"] .chip p{ max-width:none; }
}

@media (prefers-reduced-motion:reduce){
  *{ animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
  [data-rv]{ opacity:1 !important; transform:none !important; }
  .mask-line > span,.word{ opacity:1 !important; transform:none !important; }
}
</style>
</head>
<body data-layout-hero="b" data-layout-story="b" data-layout-gallery="b"
      data-layout-curriculum="b" data-layout-closing="b" data-layout-footer="b">

<canvas id="gl" aria-hidden="true"></canvas>
<div id="vignette"></div>
<div id="grain"></div>
<div class="cur-dot" id="cursor"></div>

<!-- ============================================================ preloader -->
<div id="pre">
  <div class="pre-in">
    <div class="pre-mark">
      <svg viewBox="0 0 44 44" fill="none" aria-hidden="true">
        <circle cx="22" cy="24" r="9.5" stroke="#e0231c" stroke-width="1.2"/>
        <path d="M6 12h32M9.5 17h25M22 8v28" stroke="#dfe7e0" stroke-width="1.2"/>
      </svg>
    </div>
    <div class="pre-jp jp">影の道</div>
    <div class="pre-bar"><i id="pre-fill"></i></div>
    <div class="pre-meta">
      <span>Raising the mountain temple</span><b><span id="pre-pct">0</span>%</b>
    </div>
  </div>
</div>

<!-- ============================================================ nav -->
<header class="nav" id="nav">
  <a class="brand" href="#top" data-cursor>
    <svg viewBox="0 0 44 44" fill="none" aria-hidden="true">
      <circle cx="22" cy="25" r="8.6" fill="#e0231c" fill-opacity=".9"/>
      <path d="M5 13h34M9 18.4h26M22 8.5v27" stroke="#dfe7e0" stroke-width="1.5"/>
      <path d="M14 35.5h16" stroke="#dfe7e0" stroke-width="1.2" stroke-opacity=".6"/>
    </svg>
    <span class="brand-tx"><b>KAGE</b><i>HIDDEN REALMS OF KYOTO</i></span>
  </a>
  <nav class="nav-links" id="navlinks">
    <a class="nav-link" href="#gate" data-cursor><span>Temples</span><span class="alt">伽藍</span></a>
    <a class="nav-link" href="#pathways" data-cursor><span>Gardens</span><span class="alt">庭園</span></a>
    <a class="nav-link" href="#lessons" data-cursor><span>Rituals</span><span class="alt">神事</span></a>
    <a class="nav-link" href="#eternity" data-cursor><span>Afterlight</span><span class="alt">残光</span></a>
  </nav>
  <button class="nav-burger" aria-label="Menu" data-cursor><i></i><i></i></button>
</header>

<div class="page" id="top">

<!-- ============================================================ hero -->
<section class="hero" id="hero" data-cam="0">
  <div class="hero-top">
    <div class="eyebrow" data-rv="fade"><span class="dot"></span> Chapter 00 — The Hidden Gate</div>
    <h1 class="display h-hero">
      <span class="mask-line"><span>Where stillness</span></span>
      <span class="mask-line"><span>reveals the</span></span>
      <span class="mask-line"><span>unseen.</span></span>
    </h1>
    <p class="hero-sub body" data-rv="up">Enter Kyoto through its quiet thresholds, where ritual,
      craft, and memory shape the path.</p>
  </div>

  <div class="hero-spacer"></div>

  <div class="hero-foot">
    <div class="hero-cue" data-rv="fade"><span>Scroll to enter</span><span class="track"><i></i></span></div>
    <div class="chapters" id="chips">
      <div class="chip" data-chip="0" data-rv="up" data-cursor><span class="num">01</span>
        <span class="tx"><b>Thresholds</b><p>Discover the hidden gates that open on to deeper paths.</p></span></div>
      <div class="chip" data-chip="1" data-rv="up" data-cursor><span class="num">02</span>
        <span class="tx"><b>Still Gardens</b><p>Witness the courts where silence gently unfolds.</p></span></div>
      <div class="chip" data-chip="2" data-rv="up" data-cursor><span class="num">03</span>
        <span class="tx"><b>Sacred Craft</b><p>Embrace the hands and heritage that shape devotion.</p></span></div>
      <div class="chip" data-chip="3" data-rv="up" data-cursor><span class="num">04</span>
        <span class="tx"><b>Night Rituals</b><p>Explore the rites that awaken when the day is done.</p></span></div>
    </div>
  </div>

  <a class="peek" href="#pathways" data-view="3" data-rv="fade" data-cursor aria-label="Preview: Sanmon, before the bell">
    <!-- data-frame, or the live view is blitted over the whole anchor and
         runs on down behind the caption instead of stopping at the frame -->
    <span class="peek-fr" data-frame></span>
    <span class="peek-play"><svg viewBox="0 0 22 22" fill="none"><path d="M8 5.6 16.4 11 8 16.4z" fill="#dfe7e0"/></svg></span>
    <span class="peek-cap"><b class="jp">山門</b><i>Sanmon — before the bell</i></span>
  </a>

  <div class="word-fb" aria-hidden="true">KAGE</div>

  <div class="hero-side" data-rv="up">
    <span class="v jp">影の道</span>
  </div>
</section>

<!-- ============================================================ chapter I -->
<section class="sec" id="gate" data-cam="1">
  <!-- foreground: the wall arrives first, the pine at the edge, the grass last -->
  <div class="fg" data-fg="gate" aria-hidden="true">
    <span class="fg-el fg-wall" data-fg-in="left">
      <img src="secret-pathways-assets/foreground/png/temple-wall.webp" alt="" width="1536" height="884" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-pine" data-fg-in="right">
      <img src="secret-pathways-assets/foreground/png/pine-tree.webp" alt="" width="1024" height="1438" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-grass" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/tall-grass.webp" alt="" width="1717" height="916" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="sec-head" data-rv="fade">
    <span class="k"><b>01</b> — The Sanmon</span><span class="rule"></span><span class="k jp">山門</span>
  </div>
  <div class="gate-grid">
    <h2 class="display h-sec" data-rv="up">Charred cypress, worn stone, one gate left open.</h2>
    <div class="gate-copy">
      <p class="lead" data-rv="up">Kage begins where the city stops: a mountain gate of cedar burned black,
        standing in its own weather. The soot is not decoration. It is how a board is taught to survive a
        hundred rainy seasons, and the first thing this place asks you to understand.</p>
      <p class="body" data-rv="up">Climb the worn steps and the worship hall lifts out of the mist, its paper
        screens lit from inside like a lantern the size of a house. Above the eaves a vermilion moon holds
        its place, patient, half hidden. Nothing here is in a hurry. Neither, for the next ninety minutes,
        are you.</p>
      <a class="arrowlink" href="#pathways" data-rv="fade" data-cursor>
        <span>Cross the threshold</span>
        <span class="ar"><svg viewBox="0 0 14 14" fill="none"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg></span>
      </a>
    </div>
  </div>
  <div class="gate-stats" data-rv="up">
    <div><b>05</b><span>Chapters</span></div>
    <div><b>92</b><span>Minutes</span></div>
    <div><b>1611</b><span>Hall raised</span></div>
    <div><b>∞</b><span>Stillness</span></div>
  </div>
</section>

<!-- ============================================================ chapter II -->
<section class="sec" id="pathways" data-cam="2">
  <!-- foreground: the garden frames the mosaic from the sides and the two lower corners -->
  <div class="fg" data-fg="pathways" aria-hidden="true">
    <span class="fg-el fg-sakura fg-el--sway" data-fg-in="left">
      <img src="secret-pathways-assets/foreground/png/sakura-branch.webp" alt="" width="1536" height="1024" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-leaves fg-el--sway" data-fg-in="right">
      <img src="secret-pathways-assets/foreground/png/maple-leaves.webp" alt="" width="1536" height="1024" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-lantern" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/stone-lantern.webp" alt="" width="1024" height="1499" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-bush" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/garden-bush.webp" alt="" width="1717" height="876" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="sec-head" data-rv="fade">
    <span class="k"><b>02</b> — Still Gardens</span><span class="rule"></span><span class="k jp">庭園</span>
  </div>
  <div class="cards" id="cards">
    <article class="card" data-rv="up" data-view="0" data-cursor>
      <div class="card-fr" data-frame>
                <span class="card-ar"><svg viewBox="0 0 14 14" fill="none"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg></span>
        <i class="glow" style="--gx:80.2%; --gy:23.9%; --gr:22%; --gt:6.1s; --gt2:9.7s; --gc1:rgba(255,142,108,.50); --gc2:rgba(212,56,38,.24)"></i>
        <div class="card-lab"><b>Approach</b><span class="jp">参道</span></div>
      </div>
      <div class="card-meta"><span>The long climb</span><span>01 / 03</span></div>
    </article>
    <article class="card" data-rv="up" data-view="1" data-cursor>
      <div class="card-fr" data-frame>
                <span class="card-ar"><svg viewBox="0 0 14 14" fill="none"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg></span>
        <i class="glow glow--flame" style="--gx:70.5%; --gy:47.2%; --gr:14%; --gt:3.7s; --gt2:5.3s; --gc1:rgba(255,198,124,.62); --gc2:rgba(226,118,40,.30)"></i>
        <div class="card-lab"><b>Lanterns</b><span class="jp">灯籠</span></div>
      </div>
      <div class="card-meta"><span>Lantern court</span><span>02 / 03</span></div>
    </article>
    <article class="card" data-rv="up" data-view="2" data-cursor>
      <div class="card-fr" data-frame>
                <span class="card-ar"><svg viewBox="0 0 14 14" fill="none"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg></span>
        <i class="glow" style="--gx:48.0%; --gy:16.8%; --gr:20%; --gt:7.3s; --gt2:11.2s; --gc1:rgba(255,138,104,.52); --gc2:rgba(208,54,36,.24)"></i>
        <div class="card-lab"><b>Moonwater</b><span class="jp">月影</span></div>
      </div>
      <div class="card-meta"><span>The wet court</span><span>03 / 03</span></div>
    </article>
  </div>
</section>

<!-- ============================================================ chapter III -->
<section class="sec" id="lessons" data-cam="3">
  <!-- foreground: a low periphery for the atlas — wall fragment, basalt, one stand of grass -->
  <div class="fg" data-fg="lessons" aria-hidden="true">
    <span class="fg-el fg-wall fg-el--flip" data-fg-in="right">
      <img src="secret-pathways-assets/foreground/png/temple-wall.webp" alt="" width="1536" height="884" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-stones" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/basalt-stones.webp" alt="" width="1536" height="996" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-grass" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/tall-grass.webp" alt="" width="1717" height="916" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="sec-head" data-rv="fade">
    <span class="k"><b>03</b> — Sacred Craft</span><span class="rule"></span><span class="k jp">手業</span>
  </div>
  <div class="cur-head">
    <h2 class="display h-sec" data-rv="up">Five chapters. Ninety minutes. One quiet mind.</h2>
    <p class="body-lg" data-rv="up">Each chapter is a walk, not a lecture. You arrive at the gate, climb the
      steps, sit with the lantern, and leave with one thing worth keeping.</p>
  </div>
  <div class="cur" id="cur">
    <div class="les" data-les="0" data-cursor>
      <span class="k">01</span>
      <h3>The Hidden Gate<em class="jp">山門</em></h3>
      <p>Why a gate is a sentence, and what you agree to when you walk under one.</p>
      <span class="t">14 min</span><i class="bar"></i>
    </div>
    <div class="les" data-les="1" data-cursor>
      <span class="k">02</span>
      <h3>Borrowed Scenery<em class="jp">借景</em></h3>
      <p>Shakkei: composing with a mountain you will never own.</p>
      <span class="t">18 min</span><i class="bar"></i>
    </div>
    <div class="les" data-les="2" data-cursor>
      <span class="k">03</span>
      <h3>Charred Cypress<em class="jp">焼杉</em></h3>
      <p>Yakisugi: burning a board black so the weather will let it live.</p>
      <span class="t">21 min</span><i class="bar"></i>
    </div>
    <div class="les" data-les="3" data-cursor>
      <span class="k">04</span>
      <h3>Lantern Light<em class="jp">灯籠</em></h3>
      <p>How a single ember decides the scale of everything around it.</p>
      <span class="t">17 min</span><i class="bar"></i>
    </div>
    <div class="les" data-les="4" data-cursor>
      <span class="k">05</span>
      <h3>The Vermilion Moon<em class="jp">朱月</em></h3>
      <p>Why the moon burns red over the valley, and what the garden does with it.</p>
      <span class="t">22 min</span><i class="bar"></i>
    </div>
  </div>
</section>

<!-- ============================================================ chapter IV -->
<section class="sec fin" id="eternity" data-cam="4">
  <!-- foreground: the closing horizon: hill, ruins, grass, and the last branch -->
  <div class="fg" data-fg="eternity" aria-hidden="true">
    <span class="fg-el fg-hill" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/hill.webp" alt="" width="1774" height="887" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-ruins" data-fg-in="left">
      <img src="secret-pathways-assets/foreground/png/shrine-ruins.webp" alt="" width="1536" height="1001" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-grass" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/tall-grass.webp" alt="" width="1717" height="916" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-sakura" data-fg-in="left">
      <img src="secret-pathways-assets/foreground/png/sakura-branch.webp" alt="" width="1536" height="1024" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="eyebrow" data-rv="fade">Chapter 04 — Afterlight</div>
  <h2 class="display" data-rv="up">Afterlight</h2>
  <p class="body-lg" data-rv="up">The gate does not close behind you. Take the walk whenever the noise
    gets loud — it is always the same path, and never the same light.</p>
  <a class="cta" href="#top" data-rv="fade" data-cursor>
    <i></i><span>Begin the walk</span>
    <svg viewBox="0 0 14 14" fill="none" width="13" height="13"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg>
  </a>
</section>

<!-- ============================================================ footer -->
<footer class="foot" data-cam="5">
  <!-- foreground: one planting along the lower edge of the colophon -->
  <div class="fg" data-fg="foot" aria-hidden="true">
    <span class="fg-el fg-bush" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/garden-bush.webp" alt="" width="1717" height="876" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-grass" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/tall-grass.webp" alt="" width="1717" height="916" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-stones" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/basalt-stones.webp" alt="" width="1536" height="996" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="foot-grid">
    <div class="foot-brand">
      <svg viewBox="0 0 44 44" fill="none" width="34" height="34" aria-hidden="true">
        <circle cx="22" cy="25" r="8.6" fill="#e0231c" fill-opacity=".9"/>
        <path d="M5 13h34M9 18.4h26M22 8.5v27" stroke="#dfe7e0" stroke-width="1.5"/>
      </svg>
      <p>A five-chapter night walk through a Kyoto mountain temple. Three illustrated garden field notes
        sit inside a live Three.js sanctuary.</p>
    </div>
    <div><h4>Chapters</h4><ul>
      <li><a href="#gate" data-cursor>The Sanmon</a></li>
      <li><a href="#pathways" data-cursor>Still Gardens</a></li>
      <li><a href="#lessons" data-cursor>Sacred Craft</a></li>
      <li><a href="#eternity" data-cursor>Afterlight</a></li>
    </ul></div>
    <div><h4>Practice</h4><ul>
      <li><a href="#lessons" data-cursor>Borrowed scenery</a></li>
      <li><a href="#lessons" data-cursor>Lantern light</a></li>
      <li><a href="#lessons" data-cursor>Charred cypress</a></li>
      <li><a href="#lessons" data-cursor>Raked gravel</a></li>
    </ul></div>
    <div><h4>Elsewhere</h4><ul>
      <li><a href="#top" data-cursor>Journal</a></li>
      <li><a href="#top" data-cursor>Field notes</a></li>
      <li><a href="#top" data-cursor>Colophon</a></li>
    </ul></div>
  </div>
  <div class="foot-base">
    <span>© 2026 Kage — Kage no Michi</span>
    <span class="jp">静けさは一つの技である</span>
    <span>WebGL · Onest · Kyoto</span>
  </div>
</footer>

</div><!-- /.page -->

<!-- The near plane of the active chapter is parked here rather than inside its
     section: .page is a stacking context at z-index 10, so nothing living in it
     can rise past the nav. #fg-sky sits above the whole layout instead. -->
<div id="fg-sky" aria-hidden="true"></div>

<div class="rail" id="rail"></div>

<script src="secret-pathways-assets/three.min.js"></script>
<script>
/* =====================================================================
   KAGE — a live Kyoto mountain temple, after dark.
   Everything on this page is generated at runtime: no photographs,
   no video, no external assets beyond three.js and two subset fonts.
   ===================================================================== */
(function () {
'use strict';

/* ------------------------------------------------------------ 0 · basics */
const Q      = new URLSearchParams(location.search);
const qs     = (k, d) => { const v = Q.get(k); return v === null ? d : v; };
const qn     = (k, d) => { const v = Q.get(k); return v === null ? d : parseFloat(v); };
const REDUCE = matchMedia('(prefers-reduced-motion: reduce)').matches;
const COARSE = matchMedia('(hover: none)').matches;

const clamp  = (v, a, b) => v < a ? a : (v > b ? b : v);
const sat    = v => clamp(v, 0, 1);
const lerp   = (a, b, t) => a + (b - a) * t;
const smooth = (e0, e1, x) => { const t = sat((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); };
const easeOut= t => 1 - Math.pow(1 - t, 3);
const easeIO = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const TAU    = Math.PI * 2;
/* frame-rate independent damping */
const damp   = (cur, to, rate, dt) => lerp(cur, to, 1 - Math.exp(-rate * dt));

function mulberry32(a) {
  return function () {
    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;
  };
}
/* classic 2-D gradient noise */
function noise2D(seed) {
  const rnd = mulberry32(seed), p = new Uint8Array(256), perm = new Uint8Array(512);
  for (let i = 0; i < 256; i++) p[i] = i;
  for (let i = 255; i > 0; i--) { const j = (rnd() * (i + 1)) | 0, t = p[i]; p[i] = p[j]; p[j] = t; }
  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
  const G = [[1, 1], [-1, 1], [1, -1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]];
  const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
  return function (x, y) {
    const xi = Math.floor(x), yi = Math.floor(y);
    const X = xi & 255, Y = yi & 255, xf = x - xi, yf = y - yi;
    const u = fade(xf), v = fade(yf);
    const g = (h, dx, dy) => { const q = G[h & 7]; return q[0] * dx + q[1] * dy; };
    const aa = perm[perm[X] + Y], ab = perm[perm[X] + Y + 1];
    const ba = perm[perm[X + 1] + Y], bb = perm[perm[X + 1] + Y + 1];
    return lerp(lerp(g(aa, xf, yf), g(ba, xf - 1, yf), u),
                lerp(g(ab, xf, yf - 1), g(bb, xf - 1, yf - 1), u), v);
  };
}
function fbm(n, x, y, oct, lac, gain) {
  let a = .5, f = 1, s = 0, m = 0;
  for (let i = 0; i < (oct || 4); i++) { s += a * n(x * f, y * f); m += a; a *= (gain || .5); f *= (lac || 2); }
  return s / m;                                    /* −1 … 1 */
}

/* ------------------------------------------------------------ 1 · canvas */
function cvs(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
const hex = (r, g, b) => 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')';

/* stacked, up-scaled value noise — 30× faster than a per-pixel fbm and
   indistinguishable once it is multiplied under a dark base colour */
function fbmCanvas(W, H, seed, octaves, baseCells, contrast) {
  const out = cvs(W, H), o = out.getContext('2d');
  o.fillStyle = '#808080'; o.fillRect(0, 0, W, H);
  let cells = baseCells || 3, alpha = 1;
  for (let i = 0; i < (octaves || 5); i++) {
    const n = cvs(cells, cells), nx = n.getContext('2d');
    const im = nx.createImageData(cells, cells), d = im.data, r = mulberry32(seed + i * 977);
    for (let k = 0; k < cells * cells; k++) {
      const v = 128 + (r() - .5) * 255 * (contrast || 1);
      d[k * 4] = d[k * 4 + 1] = d[k * 4 + 2] = clamp(v, 0, 255); d[k * 4 + 3] = 255;
    }
    nx.putImageData(im, 0, 0);
    o.globalAlpha = alpha;
    o.globalCompositeOperation = i === 0 ? 'source-over' : 'overlay';
    o.imageSmoothingEnabled = true; o.imageSmoothingQuality = 'high';
    o.drawImage(n, 0, 0, W, H);
    cells *= 2; alpha *= .62;
  }
  o.globalAlpha = 1; o.globalCompositeOperation = 'source-over';
  return out;
}

/* height → tangent-space normal map (blur first, then Sobel) */
function normalFromHeight(hc, strength) {
  const W = hc.width, H = hc.height;
  const b = cvs(W, H), bx = b.getContext('2d');
  bx.filter = 'blur(1.1px)'; bx.drawImage(hc, 0, 0); bx.filter = 'none';
  const src = bx.getImageData(0, 0, W, H).data;
  const out = cvs(W, H), ox = out.getContext('2d');
  const im = ox.createImageData(W, H), d = im.data;
  const at = (x, y) => src[(((y + H) % H) * W + ((x + W) % W)) * 4] / 255;
  const s = strength || 2.4;
  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
    const gx = (at(x + 1, y) - at(x - 1, y)) * s;
    const gy = (at(x, y + 1) - at(x, y - 1)) * s;
    let nx = -gx, ny = gy, nz = 1;
    const il = 1 / Math.hypot(nx, ny, nz);
    const i = (y * W + x) * 4;
    d[i]     = (nx * il * .5 + .5) * 255;
    d[i + 1] = (ny * il * .5 + .5) * 255;
    d[i + 2] = (nz * il * .5 + .5) * 255;
    d[i + 3] = 255;
  }
  ox.putImageData(im, 0, 0);
  return out;
}

/* ------------------------------------------------------- 2 · surfaces */
/* long, wet, board-formed concrete — the sanctuary walls */
function texWall() {
  const W = 1024, H = 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  x.fillStyle = '#10161a'; x.fillRect(0, 0, W, H);

  /* mottling */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .82;
  x.drawImage(fbmCanvas(W, H, 41, 6, 3, 1), 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* board-form seams every sixth */
  const rnd = mulberry32(7);
  for (let i = 1; i < 6; i++) {
    const y = (H / 6) * i;
    x.fillStyle = 'rgba(0,0,0,.45)'; x.fillRect(0, y - 1.5, W, 3);
    x.fillStyle = 'rgba(190,205,205,.05)'; x.fillRect(0, y + 2, W, 2);
  }
  /* form-tie dimples */
  for (let i = 0; i < 6; i++) for (let j = 0; j < 4; j++) {
    const cx2 = (W / 4) * (j + .5) + (rnd() - .5) * 14, cy = (H / 6) * (i + .5);
    const g = x.createRadialGradient(cx2, cy, 1, cx2, cy, 11);
    g.addColorStop(0, 'rgba(0,0,0,.5)'); g.addColorStop(.7, 'rgba(0,0,0,.18)'); g.addColorStop(1, 'rgba(0,0,0,0)');
    x.fillStyle = g; x.beginPath(); x.arc(cx2, cy, 11, 0, TAU); x.fill();
  }
  /* rain streaks running the full height */
  for (let i = 0; i < 190; i++) {
    const sx = rnd() * W, w = .6 + rnd() * 3.4, top = rnd() * H * .5, len = H * (.4 + rnd() * .7);
    const g = x.createLinearGradient(0, top, 0, top + len);
    const dark = rnd() > .45;
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(.25, dark ? 'rgba(0,0,0,.20)' : 'rgba(170,195,200,.045)');
    g.addColorStop(1, 'rgba(0,0,0,0)');
    x.fillStyle = g; x.fillRect(sx, top, w, len);
  }
  /* fine tooth */
  x.globalAlpha = .16; x.globalCompositeOperation = 'overlay';
  x.drawImage(fbmCanvas(512, 512, 91, 3, 128, 1.4), 0, 0, W, H);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* height for the normal map: seams + streaks only */
  const h = cvs(W, H), hx = h.getContext('2d');
  hx.fillStyle = '#808080'; hx.fillRect(0, 0, W, H);
  hx.globalAlpha = .5; hx.drawImage(fbmCanvas(W, H, 41, 5, 6, 1), 0, 0); hx.globalAlpha = 1;
  for (let i = 1; i < 6; i++) { hx.fillStyle = '#2a2a2a'; hx.fillRect(0, (H / 6) * i - 2, W, 4); }
  return { map: c, normal: normalFromHeight(h, 2.0) };
}

/* wet slate paving — big slabs, tight joints, standing water */
function texFloor() {
  const W = 1024, H = 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  const rnd = mulberry32(23);
  x.fillStyle = '#0a0f12'; x.fillRect(0, 0, W, H);
  const N = 4, S = W / N;
  for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
    const t = .82 + rnd() * .36;
    x.fillStyle = hex(12 * t, 17 * t, 20 * t);
    x.fillRect(i * S + 1.5, j * S + 1.5, S - 3, S - 3);
  }
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .55;
  x.drawImage(fbmCanvas(W, H, 63, 6, 4, 1), 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
  /* joints */
  x.strokeStyle = 'rgba(0,0,0,.72)'; x.lineWidth = 3;
  for (let i = 0; i <= N; i++) {
    x.beginPath(); x.moveTo(i * S, 0); x.lineTo(i * S, H); x.stroke();
    x.beginPath(); x.moveTo(0, i * S); x.lineTo(W, i * S); x.stroke();
  }
  /* height map: joints recessed, grain */
  const h = cvs(W, H), hx = h.getContext('2d');
  hx.fillStyle = '#8c8c8c'; hx.fillRect(0, 0, W, H);
  hx.globalAlpha = .35; hx.drawImage(fbmCanvas(W, H, 63, 5, 8, 1), 0, 0); hx.globalAlpha = 1;
  hx.strokeStyle = '#303030'; hx.lineWidth = 5;
  for (let i = 0; i <= N; i++) {
    hx.beginPath(); hx.moveTo(i * S, 0); hx.lineTo(i * S, H); hx.stroke();
    hx.beginPath(); hx.moveTo(0, i * S); hx.lineTo(W, i * S); hx.stroke();
  }
  /* roughness: mostly glass, drier patches where the rain missed */
  const r = cvs(512, 512), rx = r.getContext('2d');
  rx.fillStyle = '#1c1c1c'; rx.fillRect(0, 0, 512, 512);
  rx.globalAlpha = .95; rx.globalCompositeOperation = 'lighten';
  rx.drawImage(fbmCanvas(512, 512, 77, 4, 3, 1.5), 0, 0);
  rx.globalAlpha = 1; rx.globalCompositeOperation = 'source-over';
  return { map: c, normal: normalFromHeight(h, 1.5), rough: r };
}

/* --------------------------------------------------- temple timber · cedar
   Boards, not a noise field. Three things make sawn wood read as wood and
   none of them is fbm:

     1 · ring geometry. A plank is a flat cut through a round log, so the
         rings meet the face as two branches either side of the pith, and
         wherever the cut runs shallow across a ring the branches close into
         the cathedral arch everyone recognises. Solving the circle for it —
         x = pith ± √(r² − d(y)²), with the cut depth d wandering down the
         board — hands over the arches for free. Drawn strokes only ever give
         stripes, which is what the first pass here did, and stripes on a
         column read as brushed metal.
     2 · the boards themselves. Joints, and a different tone on either side of
         one. A wall with no joint in it is a wall of plastic.
     3 · pores, so the face has tooth rather than a painted finish.

   Broad fbm overlays used to do the work here and they are exactly why the
   hall looked like it was standing under a cloud: at this camera distance a
   noise blotch is dirt, and dirt is not grain. Everything below is structure.

   boards:0 gives a single timber for the columns, sills and rails, which are
   each one stick of wood and must not carry a joint. */
function texWood(seed, opt) {
  const o = opt || {}, W = 512, H = 512;
  const c = cvs(W, H), x = c.getContext('2d');
  const h = cvs(W, H), hx = h.getContext('2d');
  const r = cvs(W, H), rx = r.getContext('2d');
  const rnd = mulberry32(seed || 3);
  const base = o.base || [30, 23, 19];
  x.fillStyle = hex(base[0], base[1], base[2]); x.fillRect(0, 0, W, H);
  hx.fillStyle = '#808080'; hx.fillRect(0, 0, W, H);
  rx.fillStyle = o.rough || '#d6d6d6'; rx.fillRect(0, 0, W, H);

  /* board layout — widths jittered, then normalised so the run closes on the
     tile edge and the map still wraps */
  const nb = o.boards === undefined ? 7 : o.boards;
  const cuts = [0];
  if (nb > 0) {
    const ws = []; let sum = 0;
    for (let i = 0; i < nb; i++) { const v = .70 + rnd() * .60; ws.push(v); sum += v; }
    let acc = 0;
    ws.forEach(v => { acc += v / sum * W; cuts.push(acc); });
  } else cuts.push(W);

  const stroke = (pts, ctx, style, w) => {
    if (pts.length < 2) return;
    ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
    for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
    ctx.strokeStyle = style; ctx.lineWidth = w; ctx.lineCap = 'round'; ctx.stroke();
  };

  for (let b = 0; b < cuts.length - 1; b++) {
    const x0 = cuts[b], x1 = cuts[b + 1], bw = x1 - x0;
    /* every board came off a different part of the log */
    const tone = .80 + rnd() * .44;
    const pith = x0 + bw * (rnd() * 2.8 - .9);        /* usually outside it */
    const d0 = bw * (.12 + rnd() * 1.7);              /* how deep the cut ran */
    const amp = bw * (.10 + rnd() * .40);
    const per = 1 + ((rnd() * 2) | 0), ph = rnd() * TAU;
    const dAt = y => d0 + Math.sin(y / H * TAU * per + ph) * amp;
    const gap = 2.4 + rnd() * 5.0;                    /* ring spacing */

    [x, hx, rx].forEach(d => { d.save(); d.beginPath(); d.rect(x0, 0, bw, H); d.clip(); });
    x.fillStyle = hex(base[0] * tone, base[1] * tone, base[2] * tone);
    x.fillRect(x0, 0, bw, H);

    for (let k = 1; k * gap < bw * 3.4 + d0 + amp; k++) {
      const rr = k * gap * (.88 + rnd() * .24);
      const dark = .42 + rnd() * .38, wide = .8 + rnd() * 1.9;
      for (const side of [-1, 1]) {
        let pts = [];
        const flush = () => {
          /* latewood: a narrow dark band, with the pale earlywood just inside */
          stroke(pts, x, 'rgba(0,0,0,' + dark.toFixed(2) + ')', wide);
          stroke(pts, hx, 'rgba(0,0,0,' + (dark * .8).toFixed(2) + ')', wide);
          stroke(pts.map(q => [q[0] + side * (wide + .6), q[1]]), x,
                 'rgba(150,120,96,' + (dark * .30).toFixed(2) + ')', wide * .7);
          pts = [];
        };
        for (let y = -3; y <= H + 3; y += 3) {
          const d = dAt(y), q = rr * rr - d * d;
          if (q <= 0) { flush(); continue; }
          pts.push([pith + side * Math.sqrt(q), y]);
        }
        flush();
      }
    }
    /* pores — the open tooth of cedar, elongated along the grain */
    for (let i = 0; i < 220; i++) {
      const px2 = x0 + rnd() * bw, py = rnd() * H, ln = 1.5 + rnd() * 6;
      x.fillStyle = 'rgba(0,0,0,' + (.10 + rnd() * .22) + ')';
      x.fillRect(px2, py, .7 + rnd() * .7, ln);
      hx.fillStyle = 'rgba(0,0,0,.22)'; hx.fillRect(px2, py, .8, ln);
    }
    /* the odd knot, where a branch left the trunk */
    if (rnd() > .55) {
      const kx = x0 + bw * (.2 + rnd() * .6), ky = rnd() * H, kr = 2.5 + rnd() * 5;
      for (let i = 5; i >= 1; i--) {
        const t = i / 5;
        x.strokeStyle = 'rgba(0,0,0,' + (.5 - t * .25).toFixed(2) + ')'; x.lineWidth = 1.1;
        x.beginPath(); x.ellipse(kx, ky, kr * t * 1.5, kr * t * 2.4, 0, 0, TAU); x.stroke();
        hx.strokeStyle = 'rgba(0,0,0,.30)'; hx.lineWidth = 1.1;
        hx.beginPath(); hx.ellipse(kx, ky, kr * t * 1.5, kr * t * 2.4, 0, 0, TAU); hx.stroke();
      }
    }
    /* checking: the splits a dried board opens, always along the grain */
    for (let i = 0; i < 5; i++) {
      const sx = x0 + rnd() * bw, sy = rnd() * H, ln = 20 + rnd() * 120;
      const pts = [];
      for (let y = sy; y < sy + ln; y += 6) pts.push([sx + Math.sin(y * .07) * 1.4, y]);
      stroke(pts, x, 'rgba(0,0,0,.62)', .7 + rnd() * 1.2);
      stroke(pts, hx, 'rgba(0,0,0,.55)', .7 + rnd() * 1.2);
    }
    [x, hx, rx].forEach(d => d.restore());

    /* the joint, and the board edge that catches light beside it */
    if (nb > 0) {
      x.fillStyle = 'rgba(0,0,0,.80)'; x.fillRect(x1 - 1.1, 0, 2.2, H);
      x.fillStyle = 'rgba(158,130,106,.14)'; x.fillRect(x1 + 1.1, 0, 1.1, H);
      hx.fillStyle = 'rgba(0,0,0,.85)'; hx.fillRect(x1 - 1.3, 0, 2.6, H);
      hx.fillStyle = 'rgba(255,255,255,.35)'; hx.fillRect(x1 + 1.3, 0, 1.6, H);
      rx.fillStyle = 'rgba(255,255,255,.5)'; rx.fillRect(x1 - 1.6, 0, 3.2, H);
    }
  }

  /* soot, running down the boards rather than pooling in clouds — this is the
     one weathering pass, and it follows the grain because water does */
  for (let i = 0; i < 90; i++) {
    const sx = rnd() * W, top = rnd() * H, ln = H * (.15 + rnd() * .5);
    const g = x.createLinearGradient(0, top, 0, top + ln);
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(.3, 'rgba(0,0,0,' + (.10 + rnd() * .16) + ')');
    g.addColorStop(1, 'rgba(0,0,0,0)');
    x.fillStyle = g; x.fillRect(sx, top, .8 + rnd() * 3.4, ln);
  }
  return { map: c, normal: normalFromHeight(h, o.relief || 2.4), rough: r };
}

/* ------------------------------------------------------------ cut granite
   The lanterns are carved out of one block, so this is a monolith face and
   not coursed masonry: no joints, no bond pattern. What sells it instead is
   grain and wear —

     1 · the speckle. Three mineral populations at three tones: pale feldspar,
         mid quartz, black mica. A granite without visible crystal is grey
         plastic, and it is the one thing a colour ramp cannot fake.
     2 · the chisel. Parallel bruising left by a point on a dressed face, each
         mark a dark gouge with a lit shoulder below it.
     3 · pitting and fracture, and moss keyed to *those* — moss grows where
         water sits, which is the low ground, so it is read out of the height
         field rather than dropped on as its own cloud. That cloud is what
         made this look like weather rather than stone. */
function texStone(seed, opt) {
  const o = opt || {}, W = 512, H = 512;
  const c = cvs(W, H), x = c.getContext('2d');
  const h = cvs(W, H), hx = h.getContext('2d');
  const r = cvs(W, H), rx = r.getContext('2d');
  const rnd = mulberry32(seed || 17);
  const base = o.base || [46, 51, 53];
  x.fillStyle = hex(base[0], base[1], base[2]); x.fillRect(0, 0, W, H);
  hx.fillStyle = '#808080'; hx.fillRect(0, 0, W, H);
  rx.fillStyle = '#e8e8e8'; rx.fillRect(0, 0, W, H);

  /* 1 · crystal */
  const speck = (n, fill, smin, smax, hgt) => {
    for (let i = 0; i < n; i++) {
      const s = smin + rnd() * (smax - smin), px = rnd() * W, py = rnd() * H;
      x.fillStyle = fill(rnd);
      x.beginPath(); x.ellipse(px, py, s, s * (.55 + rnd() * .85), rnd() * TAU, 0, TAU); x.fill();
      if (hgt) {
        hx.fillStyle = hgt;
        hx.beginPath(); hx.ellipse(px, py, s, s * .8, 0, 0, TAU); hx.fill();
      }
    }
  };
  speck(3400, q => 'rgba(206,210,204,' + (.06 + q() * .20) + ')', .6, 2.8, 'rgba(255,255,255,.13)');
  speck(2000, q => 'rgba(126,136,134,' + (.07 + q() * .20) + ')', .8, 3.4, null);
  speck(1300, q => 'rgba(8,10,12,'     + (.14 + q() * .38) + ')', .5, 2.4, 'rgba(0,0,0,.16)');

  /* 2 · the chisel */
  for (let i = 0; i < 220; i++) {
    const py = rnd() * H, px = rnd() * W, len = 5 + rnd() * 24, ang = -.26 + rnd() * .52;
    [[x, 'rgba(0,0,0,.22)', 'rgba(210,216,212,.08)'], [hx, 'rgba(0,0,0,.34)', 'rgba(255,255,255,.26)']]
      .forEach(([d, dk, lt]) => {
        d.save(); d.translate(px, py); d.rotate(ang);
        d.fillStyle = dk; d.fillRect(0, 0, len, 1.5);
        d.fillStyle = lt; d.fillRect(0, 1.5, len, 1.0);
        d.restore();
      });
  }
  /* 3 · pitting and fracture */
  for (let i = 0; i < 260; i++) {
    const px = rnd() * W, py = rnd() * H, rr = .8 + rnd() * rnd() * 5;
    const g = x.createRadialGradient(px - rr * .3, py - rr * .3, rr * .1, px, py, rr);
    g.addColorStop(0, 'rgba(0,0,0,.42)'); g.addColorStop(.7, 'rgba(0,0,0,.16)');
    g.addColorStop(1, 'rgba(198,204,198,.10)');
    x.fillStyle = g; x.beginPath(); x.arc(px, py, rr, 0, TAU); x.fill();
    hx.fillStyle = 'rgba(0,0,0,.40)'; hx.beginPath(); hx.arc(px, py, rr * .8, 0, TAU); hx.fill();
  }
  for (let i = 0; i < 16; i++) {
    let px = rnd() * W, py = rnd() * H, a = rnd() * TAU;
    const pts = [[px, py]];
    for (let k = 0; k < 24; k++) {
      a += (rnd() - .5) * .9; px += Math.cos(a) * 9; py += Math.sin(a) * 9;
      pts.push([px, py]);
    }
    [[x, 'rgba(0,0,0,.46)'], [hx, 'rgba(0,0,0,.55)']].forEach(([d, st]) => {
      d.beginPath(); d.moveTo(pts[0][0], pts[0][1]);
      pts.forEach(q => d.lineTo(q[0], q[1]));
      d.strokeStyle = st; d.lineWidth = .7 + rnd() * .9; d.stroke();
    });
  }

  /* moss, read out of the low ground of the height field */
  const hd = hx.getImageData(0, 0, W, H).data;
  const im = x.getImageData(0, 0, W, H), rm = rx.getImageData(0, 0, W, H);
  const amt = o.moss === undefined ? 1 : o.moss;
  for (let i = 0; i < W * H; i++) {
    const t = clamp((.5 - hd[i * 4] / 255) * 4.2, 0, 1) * amt;
    if (t <= 0) continue;
    const j = i * 4;
    im.data[j]     = lerp(im.data[j],     30, t * .75);
    im.data[j + 1] = lerp(im.data[j + 1], 44, t * .95);
    im.data[j + 2] = lerp(im.data[j + 2], 28, t * .85);
    /* moss is matte — through the same buffer, because a fillRect per pixel
       is a quarter of a million canvas calls and costs more than the rest of
       this generator put together */
    rm.data[j] = rm.data[j + 1] = rm.data[j + 2] = lerp(rm.data[j], 255, t * .5);
  }
  x.putImageData(im, 0, 0); rx.putImageData(rm, 0, 0);
  return { map: c, normal: normalFromHeight(h, o.relief || 3.2), rough: r };
}

/* ------------------------------------------- weathered vermilion · the gate
   Not a red cylinder. The gate is a painted timber that has stood out in the
   weather: the lacquer has crazed into a fine net, flaked off the edges back to
   the black wood underneath, and run in streaks where the rain came down it.
   So the wood is drawn first and the paint is laid over it at less than full
   coverage — the grain has to read *through* the red, which is what stops it
   looking like plastic. */
function texLacquer() {
  const W = 512, H = 512;
  const wood = texWood(131, { base: [34, 22, 17], boards: 0 });
  const c = cvs(W, H), x = c.getContext('2d');
  const h = cvs(W, H), hx = h.getContext('2d');
  const r = cvs(W, H), rx = r.getContext('2d');
  const rnd = mulberry32(5);
  x.drawImage(wood.map, 0, 0);
  hx.fillStyle = '#808080'; hx.fillRect(0, 0, W, H);
  rx.fillStyle = '#8c8c8c'; rx.fillRect(0, 0, W, H);   /* lacquer half-holds a sheen */

  /* the coat */
  x.globalAlpha = .80; x.fillStyle = '#7c1610'; x.fillRect(0, 0, W, H);
  x.globalAlpha = 1;
  /* pull the grain back through it */
  x.globalCompositeOperation = 'multiply'; x.globalAlpha = .42;
  x.drawImage(wood.map, 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
  /* uneven pigment: the brush left it thicker in places */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .5;
  x.drawImage(fbmCanvas(W, H, 313, 5, 3, 1), 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* craquelure — a wandering net of hairline cracks in the paint film */
  for (let i = 0; i < 190; i++) {
    let px = rnd() * W, py = rnd() * H, a = rnd() * TAU;
    x.strokeStyle = 'rgba(24,8,6,' + (.28 + rnd() * .4) + ')';
    hx.strokeStyle = 'rgba(0,0,0,.42)';
    x.lineWidth = hx.lineWidth = .5 + rnd() * .7;
    x.beginPath(); hx.beginPath(); x.moveTo(px, py); hx.moveTo(px, py);
    for (let k = 0; k < 5 + rnd() * 9; k++) {
      a += (rnd() - .5) * 1.5; px += Math.cos(a) * (5 + rnd() * 9); py += Math.sin(a) * (5 + rnd() * 9);
      x.lineTo(px, py); hx.lineTo(px, py);
    }
    x.stroke(); hx.stroke();
  }

  /* flaking: islands where the coat has lifted, showing the burnt wood, each
     with the pale shoulder of paint still standing at its edge */
  for (let i = 0; i < 40; i++) {
    const px = rnd() * W, py = rnd() * H, rad = 2 + rnd() * rnd() * 17;
    const pts = [];
    for (let k = 0; k < 9; k++) {
      const a = k / 9 * TAU, rr = rad * (.55 + rnd() * .75);
      pts.push([px + Math.cos(a) * rr, py + Math.sin(a) * rr]);
    }
    const path = d => {
      d.beginPath(); d.moveTo(pts[0][0], pts[0][1]);
      for (let k = 1; k < pts.length; k++) {
        const p = pts[k], q = pts[(k + 1) % pts.length];
        d.quadraticCurveTo(p[0], p[1], (p[0] + q[0]) / 2, (p[1] + q[1]) / 2);
      }
      d.closePath();
    };
    x.save(); path(x); x.clip(); x.drawImage(wood.map, 0, 0);
    x.fillStyle = 'rgba(0,0,0,.22)'; x.fillRect(0, 0, W, H); x.restore();
    path(x); x.strokeStyle = 'rgba(196,110,76,.30)'; x.lineWidth = 1.2; x.stroke();
    path(hx); hx.fillStyle = 'rgba(0,0,0,.34)'; hx.fill();
    hx.strokeStyle = 'rgba(255,255,255,.30)'; hx.lineWidth = 1.6; hx.stroke();
    path(rx); rx.fillStyle = 'rgba(240,240,240,.75)'; rx.fill();   /* bare wood, matte */
  }

  /* rain running down the column */
  for (let i = 0; i < 120; i++) {
    const sx = rnd() * W, w = .6 + rnd() * 2.6, top = rnd() * H, len = H * (.2 + rnd() * .6);
    const g = x.createLinearGradient(0, top, 0, top + len);
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(.3, rnd() > .5 ? 'rgba(12,4,4,.24)' : 'rgba(210,150,120,.05)');
    g.addColorStop(1, 'rgba(0,0,0,0)');
    x.fillStyle = g; x.fillRect(sx, top, w, len);
  }
  rx.globalAlpha = .6; rx.globalCompositeOperation = 'multiply';
  rx.drawImage(fbmCanvas(W, H, 401, 4, 6, 1.2), 0, 0);
  rx.globalAlpha = 1; rx.globalCompositeOperation = 'source-over';
  return { map: c, normal: normalFromHeight(h, 2.2), rough: r };
}

/* the shoji screen the sun burns through */
function texShoji() {
  const W = 1024, H = 768, c = cvs(W, H), x = c.getContext('2d');
  x.clearRect(0, 0, W, H);
  x.fillStyle = 'rgba(228,222,206,.055)'; x.fillRect(0, 0, W, H);
  x.strokeStyle = 'rgba(10,8,7,.88)';
  const cols = 12, rows = 9;
  x.lineWidth = 5;
  for (let i = 1; i < cols; i++) { x.beginPath(); x.moveTo(W / cols * i, 0); x.lineTo(W / cols * i, H); x.stroke(); }
  for (let j = 1; j < rows; j++) { x.beginPath(); x.moveTo(0, H / rows * j); x.lineTo(W, H / rows * j); x.stroke(); }
  x.lineWidth = 13; x.strokeStyle = 'rgba(8,6,5,.95)';
  x.strokeRect(0, 0, W, H);
  x.beginPath(); x.moveTo(W / 2, 0); x.lineTo(W / 2, H); x.stroke();
  return c;
}

/* one maple leaf, white on transparent — tinted per instance */
function texLeaf() {
  const S = 128, c = cvs(S, S), x = c.getContext('2d');
  x.translate(S / 2, S * .92); x.scale(S / 2.2, -S / 2.2);
  x.beginPath();
  const lobes = 5, spread = 1.9;
  for (let i = 0; i < lobes; i++) {
    const a = -spread / 2 + spread * (i / (lobes - 1)) + Math.PI / 2;
    const len = i === 2 ? .96 : (i === 1 || i === 3 ? .82 : .60);
    const wob = .17;
    x.moveTo(0, .02);
    x.lineTo(Math.cos(a - wob) * len * .55, Math.sin(a - wob) * len * .55);
    x.lineTo(Math.cos(a) * len, Math.sin(a) * len);
    x.lineTo(Math.cos(a + wob) * len * .55, Math.sin(a + wob) * len * .55);
    x.closePath();
  }
  x.fillStyle = '#fff'; x.fill();
  x.lineWidth = .05; x.strokeStyle = '#fff'; x.stroke();
  x.setTransform(1, 0, 0, 1, 0, 0);
  x.globalCompositeOperation = 'destination-out';
  const rnd = mulberry32(3);
  for (let i = 0; i < 40; i++) { x.beginPath(); x.arc(rnd() * S, rnd() * S, rnd() * 3, 0, TAU); x.fill(); }
  return c;
}

/* the night sky itself: black at altitude, going to the fog colour at the
   horizon so the backdrop and the depth cue meet without a seam */
function texSky() {
  const W = 512, H = 512, c = cvs(W, H), x = c.getContext('2d');
  const g = x.createLinearGradient(0, 0, 0, H);
  g.addColorStop(0, 'rgb(6,10,15)');    g.addColorStop(.34, 'rgb(13,22,31)');
  g.addColorStop(.66, 'rgb(17,26,34)'); g.addColorStop(.88, 'rgb(24,35,42)');
  g.addColorStop(1, 'rgb(14,22,28)');
  x.fillStyle = g; x.fillRect(0, 0, W, H);
  /* cloud, and a low warm bloom off the valley behind the ridge */
  x.globalAlpha = .34; x.globalCompositeOperation = 'overlay';
  x.drawImage(fbmCanvas(W, H, 313, 5, 3, .9), 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
  const wg = x.createRadialGradient(W * .68, H * .95, 4, W * .68, H * .95, W * .44);
  wg.addColorStop(0, 'rgba(150,66,26,.30)'); wg.addColorStop(.5, 'rgba(96,44,22,.12)');
  wg.addColorStop(1, 'rgba(0,0,0,0)');
  x.fillStyle = wg; x.fillRect(0, 0, W, H);
  /* a scatter of stars, faint enough to survive the bloom */
  const rnd = mulberry32(881);
  for (let i = 0; i < 420; i++) {
    const sx = rnd() * W, sy = rnd() * H * .78, r = .5 + rnd() * rnd() * 1.7;
    x.fillStyle = 'rgba(214,232,240,' + (.12 + rnd() * .42) * (1 - sy / H) + ')';
    x.beginPath(); x.arc(sx, sy, r, 0, TAU); x.fill();
  }
  return c;
}

/* the wooded ridge the valley closes with — black, ragged, mostly fog */
function texRidge() {
  const W = 2048, H = 512, c = cvs(W, H), x = c.getContext('2d');
  const n = noise2D(1207), rnd = mulberry32(1207);
  x.beginPath(); x.moveTo(0, H);
  for (let i = 0; i <= W; i += 4) {
    const t = i / W;
    const ridge = .46 + .30 * (fbm(n, t * 2.4, .5, 4, 2.1, .55) * .5 + .5)
                      + .16 * (fbm(n, t * 7.5, 3.1, 3, 2.2, .5) * .5 + .5);
    x.lineTo(i, H - ridge * H * .84);
  }
  x.lineTo(W, H); x.closePath();
  x.fillStyle = '#050809'; x.fill();
  /* conifer spikes along the crest so the silhouette reads as forest */
  x.fillStyle = '#050809';
  for (let i = 0; i < 460; i++) {
    const px = rnd() * W, t = px / W;
    const ridge = .46 + .30 * (fbm(n, t * 2.4, .5, 4, 2.1, .55) * .5 + .5)
                      + .16 * (fbm(n, t * 7.5, 3.1, 3, 2.2, .5) * .5 + .5);
    const by = H - ridge * H * .84, hh = 8 + rnd() * 30, ww = 3 + rnd() * 6;
    x.beginPath(); x.moveTo(px, by - hh); x.lineTo(px + ww, by + 4); x.lineTo(px - ww, by + 4);
    x.closePath(); x.fill();
  }
  return c;
}

/* glazed roof tile — half-round caps running down the slope, weathered */
function texRoof() {
  const W = 512, H = 512, c = cvs(W, H), x = c.getContext('2d');
  const h = cvs(W, H), hx = h.getContext('2d');
  x.fillStyle = '#151c20'; x.fillRect(0, 0, W, H);
  hx.fillStyle = '#606060'; hx.fillRect(0, 0, W, H);
  const ribs = 14, s = W / ribs;
  for (let i = 0; i < ribs; i++) {
    const g = x.createLinearGradient(i * s, 0, (i + 1) * s, 0);
    g.addColorStop(0, 'rgba(0,0,0,.62)');  g.addColorStop(.30, 'rgba(148,178,192,.13)');
    g.addColorStop(.66, 'rgba(84,110,124,.05)'); g.addColorStop(1, 'rgba(0,0,0,.62)');
    x.fillStyle = g; x.fillRect(i * s, 0, s, H);
    x.fillStyle = 'rgba(0,0,0,.50)'; x.fillRect(i * s - 3, 0, 6, H);
    x.fillStyle = 'rgba(168,196,208,.09)'; x.fillRect(i * s - 1.2, 0, 1.6, H);
    /* the half-round cap itself, as height: the valley between two tiles is
       the only reason a tiled roof reads as tiled at this distance */
    const hg = hx.createLinearGradient(i * s, 0, (i + 1) * s, 0);
    hg.addColorStop(0, '#2c2c2c'); hg.addColorStop(.5, '#eaeaea'); hg.addColorStop(1, '#2c2c2c');
    hx.fillStyle = hg; hx.fillRect(i * s, 0, s, H);
  }
  /* the courses running down the slope — each tile overlaps the one below */
  const rnd = mulberry32(211);
  for (let y = 0; y < H; y += H / 9) {
    x.fillStyle = 'rgba(0,0,0,.34)'; x.fillRect(0, y, W, 3.5);
    hx.fillStyle = 'rgba(0,0,0,.55)'; hx.fillRect(0, y, W, 3.5);
    hx.fillStyle = 'rgba(255,255,255,.28)'; hx.fillRect(0, y + 3.5, W, 2);
    for (let i = 0; i < 26; i++) {                     /* chipped, mossed edges */
      x.fillStyle = rnd() > .5 ? 'rgba(0,0,0,.30)' : 'rgba(96,116,92,.10)';
      x.fillRect(rnd() * W, y + 1, 2 + rnd() * 9, 2 + rnd() * 3);
    }
  }
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .46;
  x.drawImage(fbmCanvas(256, 256, 211, 5, 3, 1), 0, 0, W, H);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
  hx.globalAlpha = .3; hx.drawImage(fbmCanvas(256, 256, 213, 4, 16, 1.2), 0, 0, W, H);
  hx.globalAlpha = 1;
  return { map: c, normal: normalFromHeight(h, 2.2) };
}

/* -------------------------------------------------------- the blood moon
   A real lunar disc, not a glow sprite and not a noise field. Three things do
   the work, in this order of importance:

     1 · the maria. The dark seas are the Moon's signature and they are not
         fbm — they are a handful of large, lobed, soft-edged basins in a
         layout everyone has known since childhood. Drawn at their real
         near-side positions and blurred, they are what makes the disc read as
         the Moon at a glance instead of as a planet.
     2 · the ray systems. Fine bright streaks thrown out of Tycho and
         Copernicus, straight across the maria. Nothing else on the surface
         looks like this, so nothing else identifies it as fast.
     3 · the craters, thickest in the bright highlands, each with a rim lit
         from the same side as the rest of the frame.

   The tint is measured off the reference plate, not invented: a blood moon
   sits at G/R ≈ B/R ≈ .48 — a rose red with green and blue level. Grading it
   as an orange (blue well under green) is what makes a CG moon read as a
   fireball, and that is what this was doing. The disc is authored here in
   near-neutral albedo and the colour is applied once, on the material. */
function texMoon() {
  const S = 512, c = cvs(S, S), x = c.getContext('2d');
  const R = S / 2 - 1, rnd = mulberry32(91);
  const px = (u, v) => [S / 2 + u * R, S / 2 + v * R];        /* disc coords */

  x.beginPath(); x.arc(S / 2, S / 2, R, 0, TAU); x.closePath();
  x.save(); x.clip();

  /* highland base. The reference disc is very slightly brighter at the limb,
     not darker — during totality the rim keeps its forward scatter — so this
     ramp opens outward instead of falling off. */
  const g = x.createRadialGradient(S * .46, S * .44, S * .05, S / 2, S / 2, R);
  g.addColorStop(0, 'rgb(150,150,150)'); g.addColorStop(.55, 'rgb(158,158,158)');
  g.addColorStop(.86, 'rgb(178,178,178)'); g.addColorStop(1, 'rgb(196,196,196)');
  x.fillStyle = g; x.fillRect(0, 0, S, S);

  /* fine highland mottle */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .5;
  x.drawImage(fbmCanvas(256, 256, 517, 6, 4, 1.1), 0, 0, S, S);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* --- 1 · the maria, at their near-side places (u right, v down) --------- */
  const seas = [
    [-.52, -.06, .46, .80],   /* Oceanus Procellarum */
    [-.26, -.38, .31, .92],   /* Imbrium             */
    [ .13, -.31, .20, .88],   /* Serenitatis         */
    [ .30, -.08, .23, .84],   /* Tranquillitatis     */
    [ .45,  .12, .15, .78],   /* Fecunditatis        */
    [ .27,  .27, .12, .74],   /* Nectaris            */
    [ .57, -.30, .12, .95],   /* Crisium, the one that stands on its own */
    [-.27,  .30, .19, .70],   /* Nubium              */
    [-.47,  .25, .13, .72],   /* Humorum             */
  ];
  const sea = cvs(S, S), sx = sea.getContext('2d');
  seas.forEach(([u, v, rad, dk]) => {
    /* a basin is a cluster of lobes, never a circle */
    for (let i = 0; i < 22; i++) {
      const a = rnd() * TAU, off = rnd() * rad * .66;
      const [bx, by] = px(u + Math.cos(a) * off, v + Math.sin(a) * off * .8);
      const rr = rad * R * (.30 + rnd() * .46);
      const bg = sx.createRadialGradient(bx, by, rr * .2, bx, by, rr);
      bg.addColorStop(0, 'rgba(0,0,0,' + (dk * .14).toFixed(3) + ')');
      bg.addColorStop(1, 'rgba(0,0,0,0)');
      sx.fillStyle = bg; sx.beginPath(); sx.arc(bx, by, rr, 0, TAU); sx.fill();
    }
  });
  /* a sea is dark, not black: the real mare/highland contrast is about 4:3,
     and anything heavier turns the disc into a skull */
  x.save(); x.filter = 'blur(9px)'; x.globalAlpha = .90;
  x.drawImage(sea, 0, 0); x.restore();
  /* mottle riding on top of the basins so their floors are not flat washes */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .18;
  x.drawImage(fbmCanvas(256, 256, 811, 4, 11, 1.2), 0, 0, S, S);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* --- 2 · ray systems, thrown clear across the disc ---------------------- */
  const ray = cvs(S, S), rx2 = ray.getContext('2d');
  [[-.10, .54, 150, 1], [-.28, -.07, 80, .6], [-.46, -.04, 60, .45]].forEach(([u, v, n, str]) => {
    const [ox, oy] = px(u, v);
    for (let i = 0; i < n; i++) {
      const a = rnd() * TAU, len = R * (.30 + rnd() * rnd() * 1.3);
      /* start clear of the crater — rays converging on a point read as a
         lens flare, and the real ones begin outside the ejecta blanket */
      const t0 = R * (.08 + rnd() * .06);
      const p0 = [ox + Math.cos(a) * t0, oy + Math.sin(a) * t0];
      const p1 = [ox + Math.cos(a) * len, oy + Math.sin(a) * len];
      const rg = rx2.createLinearGradient(p0[0], p0[1], p1[0], p1[1]);
      rg.addColorStop(0, 'rgba(255,255,255,' + (.085 * str).toFixed(3) + ')');
      rg.addColorStop(.4, 'rgba(255,255,255,' + (.055 * str).toFixed(3) + ')');
      rg.addColorStop(1, 'rgba(255,255,255,0)');
      rx2.strokeStyle = rg; rx2.lineWidth = .8 + rnd() * 2.4; rx2.lineCap = 'round';
      rx2.beginPath(); rx2.moveTo(p0[0], p0[1]);
      rx2.quadraticCurveTo(ox + Math.cos(a + .06) * len * .55, oy + Math.sin(a + .06) * len * .55,
                           p1[0], p1[1]);
      rx2.stroke();
    }
  });
  x.save(); x.filter = 'blur(2.4px)'; x.globalCompositeOperation = 'lighter';
  x.globalAlpha = .62; x.drawImage(ray, 0, 0); x.restore();

  /* --- 3 · the crater field ----------------------------------------------
     A crater is not a ring. Stroked with a gradient running across it, the
     same circle gives a rim lit on the sun side and thrown into shadow on the
     other — which is the read — where a radial ring gives a bubble. */
  const inSea = (u, v) => seas.some(([su, sv, rad]) =>
    Math.hypot(u - su, (v - sv) * 1.15) < rad * .82);
  for (let i = 0; i < 620; i++) {
    const a = rnd() * TAU, rr = Math.sqrt(rnd()) * .97;
    const u = Math.cos(a) * rr, v = Math.sin(a) * rr;
    /* the seas are young and nearly unmarked — that contrast is the point */
    if (inSea(u, v) && rnd() > .12) continue;
    const [cx2, cy] = px(u, v);
    const big = rnd() > .975;
    const r = (1 + rnd() * rnd() * rnd() * (big ? 34 : 11)) * (S / 512);
    const fade = .55 + .45 * Math.sqrt(Math.max(0, 1 - rr * rr));   /* limb falloff */
    /* foreshortened toward the limb, like anything on a sphere */
    const sq = Math.sqrt(Math.max(0, 1 - rr * rr)) * .72 + .28;
    x.save(); x.translate(cx2, cy); x.rotate(Math.atan2(v, u)); x.scale(sq, 1);
    x.rotate(-Math.atan2(v, u));                       /* keep the sun direction */
    const rimW = Math.max(.8, r * .26);
    const lg = x.createLinearGradient(-r, -r, r, r);   /* sun sits up-left */
    lg.addColorStop(0, 'rgba(255,255,255,' + (.34 * fade).toFixed(3) + ')');
    lg.addColorStop(.5, 'rgba(255,255,255,0)');
    lg.addColorStop(1, 'rgba(0,0,0,' + (.38 * fade).toFixed(3) + ')');
    x.strokeStyle = lg; x.lineWidth = rimW;
    x.beginPath(); x.arc(0, 0, Math.max(.6, r - rimW * .5), 0, TAU); x.stroke();
    if (r > 3) {                                        /* the floor it encloses */
      const fg = x.createLinearGradient(-r, -r, r, r);
      fg.addColorStop(0, 'rgba(0,0,0,' + (.21 * fade).toFixed(3) + ')');
      fg.addColorStop(1, 'rgba(255,255,255,' + (.07 * fade).toFixed(3) + ')');
      x.fillStyle = fg; x.beginPath(); x.arc(0, 0, r - rimW, 0, TAU); x.fill();
    }
    if (r > 13) {                                       /* central peak */
      x.fillStyle = 'rgba(255,255,255,' + (.14 * fade).toFixed(3) + ')';
      x.beginPath(); x.arc(-r * .04, -r * .04, r * .11, 0, TAU); x.fill();
    }
    x.restore();
  }

  /* surface tooth: genuinely fine, or it just adds another layer of cloud
     and the basins disappear under it */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .13;
  x.drawImage(fbmCanvas(S, S, 977, 2, 210, 1.3), 0, 0, S, S);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* the terminator side: the disc is not lit dead-on */
  const sh = x.createRadialGradient(S * .40, S * .38, S * .18, S * .52, S * .56, S * .72);
  sh.addColorStop(0, 'rgba(0,0,0,0)'); sh.addColorStop(1, 'rgba(0,0,0,.30)');
  x.fillStyle = sh; x.fillRect(0, 0, S, S);
  x.restore();

  /* a one-pixel feather on the limb so it is a moon in air, not a cut disc */
  const fe = x.createRadialGradient(S / 2, S / 2, R - 2.5, S / 2, S / 2, R);
  fe.addColorStop(0, 'rgba(0,0,0,1)'); fe.addColorStop(1, 'rgba(0,0,0,0)');
  x.globalCompositeOperation = 'destination-in';
  x.fillStyle = fe; x.fillRect(0, 0, S, S);
  x.globalCompositeOperation = 'source-over';
  return c;
}

/* soft round falloff — haze, embers, glows */
function texGlow(inner, mid) {
  const S = 256, c = cvs(S, S), x = c.getContext('2d');
  const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
  g.addColorStop(0, inner || 'rgba(255,255,255,1)');
  g.addColorStop(.28, mid || 'rgba(255,255,255,.36)');
  g.addColorStop(.62, 'rgba(255,255,255,.07)');
  g.addColorStop(1, 'rgba(255,255,255,0)');
  x.fillStyle = g; x.fillRect(0, 0, S, S);
  return c;
}

/* one wisp — a round mote, not a streak. The whole read is a hard bright
   point sitting in a soft halo, so the falloff is deliberately uneven: almost
   all of the light is spent inside the first eighth of the radius and the rest
   is a wide, very faint bloom. A smooth gradient across the full radius gives
   an evenly lit blob with no centre to it. */
function texWisp() {
  const S = 128, c = cvs(S, S), x = c.getContext('2d');
  const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
  g.addColorStop(0,   'rgba(255,255,255,1)');      /* the point itself */
  g.addColorStop(.07, 'rgba(236,250,250,.92)');
  g.addColorStop(.16, 'rgba(190,230,238,.40)');
  g.addColorStop(.34, 'rgba(132,192,212,.13)');
  g.addColorStop(.62, 'rgba(88,146,172,.035)');
  g.addColorStop(1,   'rgba(70,120,142,0)');
  x.fillStyle = g; x.fillRect(0, 0, S, S);
  return c;
}

/* ---------------------------------------------- 3 · the foreground cut-outs
   These are the "transparent PNG" layers: painted at high resolution with
   a ragged alpha edge, then hung in front of the type as real geometry so
   they parallax and sway.                                                */
function texGrassCutout(seed, opt) {
  opt = opt || {};
  const W = opt.w || 2048, H = opt.h || 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  const rnd = mulberry32(seed), n = noise2D(seed * 13 + 5);

  /* --- silhouette ------------------------------------------------- */
  const crest = opt.crest !== undefined ? opt.crest : .46;
  const peak  = opt.peak  !== undefined ? opt.peak  : .60;
  const wide  = opt.wide  !== undefined ? opt.wide  : .40;
  const prof = new Float32Array(W);
  for (let i = 0; i < W; i++) {
    const t = i / W;
    let m = Math.exp(-Math.pow((t - crest) / wide, 2) * 2.1);
    m += .46 * Math.exp(-Math.pow((t - crest - (opt.crest2 || .40)) / (wide * .62), 2) * 3.1);
    m += .30 * Math.exp(-Math.pow((t - crest + (opt.crest3 || .46)) / (wide * .70), 2) * 3.4);
    const g = fbm(n, t * 4.2, .5, 4, 2.05, .52) * .5 + .5;
    prof[i] = m * (.80 + .38 * g);
  }
  let pk = 0; for (let i = 0; i < W; i++) pk = Math.max(pk, prof[i]);
  for (let i = 0; i < W; i++) prof[i] *= H * peak / pk;
  const surf = i => H - prof[clamp(i | 0, 0, W - 1)];

  /* --- body ------------------------------------------------------- */
  x.beginPath(); x.moveTo(0, H);
  for (let i = 0; i < W; i += 3) x.lineTo(i, surf(i));
  x.lineTo(W, surf(W - 1)); x.lineTo(W, H); x.closePath();
  const bg = x.createLinearGradient(0, H - H * peak, 0, H);
  bg.addColorStop(0, '#1a2416'); bg.addColorStop(.38, '#0e150c'); bg.addColorStop(1, '#040604');
  x.fillStyle = bg; x.fill();
  x.save(); x.clip();
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .5;
  x.drawImage(fbmCanvas(1024, 512, seed + 3, 5, 3, 1), 0, 0, W, H);
  x.restore();
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* --- blades ----------------------------------------------------- */
  const LIGHT = opt.light || [-.42, -.91];            /* light comes from up-left */
  const N = opt.blades || 15000;
  const blades = [];
  for (let k = 0; k < N; k++) {
    const i = (rnd() * W) | 0;
    const s = surf(i);
    /* bias the population toward the silhouette so the edge stays ragged */
    const depth = Math.pow(rnd(), 2.3);
    const by = s + depth * (H - s) + (rnd() - .5) * 6;
    if (by > H + 20) continue;
    blades.push({ i: i, by: by, depth: depth, r: rnd(), r2: rnd(), r3: rnd() });
  }
  blades.sort((a, b) => a.by - b.by);                 /* far (high) first */

  const LEN = (opt.len || 46) * (W / 2048);
  for (let k = 0; k < blades.length; k++) {
    const b = blades[k];
    const bx = b.i + (b.r - .5) * 5;
    const grow = 1 - .52 * b.depth;
    const len = LEN * (.36 + 1.05 * b.r2 * b.r2) * grow;
    let lean = (b.r3 - .5) * 1.5 + (opt.wind || .16);
    /* blades on the sunward flank lean into frame */
    const tipx = bx + lean * len * .95, tipy = b.by - len;
    const cx2 = bx + lean * len * .30, cy2 = b.by - len * .62;
    const w = (.9 + 1.9 * b.r) * grow * (W / 2048);

    /* shade: facing the key light, plus exposure to the sky at the crest */
    const dx = tipx - bx, dy = tipy - b.by, il = 1 / Math.hypot(dx, dy);
    const ndl = sat((-(dx * il) * LIGHT[0] - (dy * il) * LIGHT[1]) * .5 + .5);
    const open = Math.pow(1 - b.depth, 1.35);
    let l = .10 + .46 * open + .34 * ndl * open;
    const warm = sat(open * 1.25 - .42) * (opt.warm !== undefined ? opt.warm : 1);
    const r = (10 + 78 * l + 44 * warm) * (opt.tintR || 1);
    const g = (16 + 106 * l + 24 * warm) * (opt.tintG || 1);
    const bl = (12 + 80 * l + 16 * warm) * (opt.tintB || 1);
    x.fillStyle = hex(r, g, bl);
    x.beginPath();
    x.moveTo(bx - w, b.by);
    x.quadraticCurveTo(cx2 - w * .35, cy2, tipx, tipy);
    x.quadraticCurveTo(cx2 + w * .35, cy2, bx + w, b.by);
    x.closePath(); x.fill();
  }

  /* --- grade: red bounce from the sun, cold sky on the crest ------- */
  x.globalCompositeOperation = 'source-atop';
  const rb = x.createLinearGradient(opt.bounceFrom || W, 0, opt.bounceTo || 0, 0);
  rb.addColorStop(0, 'rgba(180,40,16,.24)'); rb.addColorStop(.55, 'rgba(140,32,14,.07)'); rb.addColorStop(1, 'rgba(0,0,0,0)');
  x.fillStyle = rb; x.fillRect(0, 0, W, H);
  const sk = x.createLinearGradient(0, H - H * peak * 1.05, 0, H);
  sk.addColorStop(0, 'rgba(146,182,180,.13)'); sk.addColorStop(.5, 'rgba(0,0,0,0)'); sk.addColorStop(1, 'rgba(0,0,0,.70)');
  x.fillStyle = sk; x.fillRect(0, 0, W, H);
  x.globalCompositeOperation = 'source-over';
  return c;
}

/* wet foreground boulders */
function texRockCutout(seed, opt) {
  opt = opt || {};
  const W = opt.w || 1536, H = opt.h || 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  const rnd = mulberry32(seed), n = noise2D(seed * 7 + 11);
  const blobs = opt.blobs || [[.30, .70, .40, .34], [.66, .82, .38, .28], [.46, .96, .52, .34]];

  blobs.forEach((b, bi) => {
    const cx2 = b[0] * W, cy = b[1] * H, rx = b[2] * W * .5, ry = b[3] * H * .8;
    x.beginPath();
    for (let a = 0; a <= 128; a++) {
      const t = a / 128 * TAU;
      const k = 1 + .17 * fbm(n, Math.cos(t) * 1.7 + bi * 9, Math.sin(t) * 1.7, 4, 2.1, .55);
      const px = cx2 + Math.cos(t) * rx * k, py = cy + Math.sin(t) * ry * k;
      a === 0 ? x.moveTo(px, py) : x.lineTo(px, py);
    }
    x.closePath();
    const g = x.createLinearGradient(cx2 - rx, cy - ry, cx2 + rx * .4, cy + ry);
    const s = 1 - bi * .16;
    g.addColorStop(0, hex(30 * s, 39 * s, 40 * s));
    g.addColorStop(.42, hex(14 * s, 19 * s, 20 * s));
    g.addColorStop(1, hex(6, 9, 9));
    x.fillStyle = g; x.fill();
    x.save(); x.clip();
    x.globalCompositeOperation = 'overlay'; x.globalAlpha = .62;
    x.drawImage(fbmCanvas(768, 512, seed + bi * 31, 6, 4, 1.1), 0, 0, W, H);
    x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
    /* facets */
    for (let f = 0; f < 26; f++) {
      x.beginPath();
      const fx = cx2 + (rnd() - .5) * rx * 2, fy = cy + (rnd() - .5) * ry * 1.6;
      x.moveTo(fx, fy);
      for (let v = 0; v < 3; v++) x.lineTo(fx + (rnd() - .5) * rx * .8, fy + (rnd() - .5) * ry * .6);
      x.closePath();
      x.fillStyle = rnd() > .5 ? 'rgba(255,255,255,.030)' : 'rgba(0,0,0,.14)';
      x.fill();
    }
    /* wet highlight along the upper rim */
    x.lineWidth = 3 + rnd() * 4; x.strokeStyle = 'rgba(178,206,206,.20)';
    x.beginPath();
    for (let a = 0; a <= 60; a++) {
      const t = Math.PI + a / 60 * Math.PI * .78;
      const k = 1 + .17 * fbm(n, Math.cos(t) * 1.7 + bi * 9, Math.sin(t) * 1.7, 4, 2.1, .55);
      const px = cx2 + Math.cos(t) * rx * k * .97, py = cy + Math.sin(t) * ry * k * .97;
      a === 0 ? x.moveTo(px, py) : x.lineTo(px, py);
    }
    x.stroke();
    x.restore();
  });

  /* a little moss where they meet the ground */
  x.globalCompositeOperation = 'source-atop';
  for (let i = 0; i < 2600; i++) {
    const px = rnd() * W, py = H - Math.pow(rnd(), 1.7) * H * .5;
    x.fillStyle = 'rgba(' + (40 + rnd() * 40 | 0) + ',' + (60 + rnd() * 46 | 0) + ',34,' + (.05 + rnd() * .22) + ')';
    x.fillRect(px, py, 1.6 + rnd() * 2.4, 3 + rnd() * 9);
  }
  const rb = x.createLinearGradient(W, 0, 0, 0);
  rb.addColorStop(0, 'rgba(190,48,22,.16)'); rb.addColorStop(.7, 'rgba(0,0,0,0)');
  x.fillStyle = rb; x.fillRect(0, 0, W, H);
  x.globalCompositeOperation = 'source-over';
  return c;
}

/* a maple bough leaning into the top of frame */
function texBranchCutout(seed) {
  const W = 1536, H = 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  const rnd = mulberry32(seed);
  const leaves = [];

  function bough(px, py, ang, len, wid, depth) {
    const steps = 7;
    let cx2 = px, cy = py, a = ang;
    x.beginPath(); x.moveTo(cx2, cy);
    for (let s = 0; s < steps; s++) {
      a += (rnd() - .5) * .30;
      cx2 += Math.cos(a) * len / steps; cy += Math.sin(a) * len / steps;
      x.lineTo(cx2, cy);
    }
    x.lineCap = 'round'; x.lineWidth = wid;
    x.strokeStyle = 'rgba(' + (26 + depth * 5 | 0) + ',' + (22 + depth * 4 | 0) + ',' + (22 + depth * 4 | 0) + ',1)';
    x.stroke();
    if (depth < 4 && len > 34) {
      const k = depth === 0 ? 3 : 2;
      for (let i = 0; i < k; i++) bough(cx2, cy, a + (rnd() - .5) * 1.25, len * (.58 + rnd() * .18), wid * .58, depth + 1);
    } else {
      for (let i = 0; i < 9; i++)
        leaves.push([cx2 + (rnd() - .5) * 78, cy + (rnd() - .5) * 78, rnd() * TAU, 12 + rnd() * 20, rnd()]);
    }
  }
  bough(W * 1.02, H * .06, Math.PI * .78, 420, 26, 0);
  bough(W * .86, -H * .02, Math.PI * .62, 330, 18, 1);

  const leafImg = texLeaf();
  const tint = cvs(128, 128), tx = tint.getContext('2d');
  leaves.forEach(l => {
    tx.clearRect(0, 0, 128, 128);
    tx.drawImage(leafImg, 0, 0);
    tx.globalCompositeOperation = 'source-in';
    const v = l[4];
    tx.fillStyle = hex(96 + v * 96, 14 + v * 22, 16 + v * 18);
    tx.fillRect(0, 0, 128, 128);
    tx.globalCompositeOperation = 'source-over';
    x.save(); x.translate(l[0], l[1]); x.rotate(l[2]);
    x.globalAlpha = .78 + v * .22;
    x.drawImage(tint, -l[3], -l[3], l[3] * 2, l[3] * 2);
    x.restore();
  });
  x.globalAlpha = 1;
  return c;
}
/* ================================================================= 4 · gl */
const canvas = document.getElementById('gl');
/* The layout viewport, not the window. On mobile `innerWidth` follows the
   initial containing block, which can run wider than the visual viewport —
   measured at 437 against a 390 screen — while `clientWidth` tracks what CSS
   actually lays out against. Sizing the canvas from `innerWidth` therefore
   renders a frame wider than the phone shows and drops the right edge of the
   scene off-screen, which is how the wordmark lost its last letter. Every
   size, aspect and pointer mapping below goes through these. */
const vpW = () => document.documentElement.clientWidth  || innerWidth;
const vpH = () => document.documentElement.clientHeight || innerHeight;
let renderer, scene, camera, maxAniso = 1;
const HI = qs('q', COARSE ? 'low' : 'high');
const LOW = HI === 'low';
const WANT_POST    = qs('post', '1') !== '0';
const WANT_SHADOW  = qs('shadow', LOW ? '0' : '1') !== '0';
const DPR_CAP      = qn('dpr', LOW ? 1.4 : 1.8);
/* the renderer trades resolution for frame rate on its own — the scene is
   fill-bound (five big alpha-blended veils plus a bloom chain), so pixels
   are the only knob worth turning on unknown hardware */
const PERF = { scale: 1, acc: 0, n: 0, locked: qs('adapt', '1') === '0' };

function initGL() {
  if (qs('nogl', '0') !== '0' || !window.THREE) throw new Error('webgl disabled');
  renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: !WANT_POST, alpha: false, powerPreference: 'high-performance' });
  renderer.setPixelRatio(Math.min(devicePixelRatio || 1, DPR_CAP));
  /* updateStyle stays on: the stylesheet sizes the canvas at 100% of the
     initial containing block, which is the very box that runs wide on a
     phone. Writing the CSS size in pixels pins the element to the layout
     viewport the buffer was built for. */
  renderer.setSize(vpW(), vpH(), true);
  renderer.outputEncoding = WANT_POST ? THREE.LinearEncoding : THREE.sRGBEncoding;
  renderer.toneMapping = WANT_POST ? THREE.NoToneMapping : THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.0;
  renderer.setClearColor(0x05070a, 1);
  if (WANT_SHADOW) { renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; }
  maxAniso = renderer.capabilities.getMaxAnisotropy();
  scene = new THREE.Scene();
    /* Density puts the hall back in the air; the colour is what decides how
     dark it is back there. At this depth most of the building's pixel is
     fog rather than its own material — darkening the timber and the tiles
     moved the hall by 0.18 of a luminance unit, the fog colour moves it
     properly. And it reaches by distance, so the foreground does not
     follow it down. */
  scene.fog = new THREE.FogExp2(0x050a0e, 0.0168);
  scene.background = new THREE.Color(0x060a0d);
  camera = new THREE.PerspectiveCamera(36, vpW() / vpH(), .35, 220);
  scene.add(camera);
}

function tx(canvasEl, o) {
  o = o || {};
  const t = new THREE.CanvasTexture(canvasEl);
  t.wrapS = t.wrapT = o.wrap || THREE.ClampToEdgeWrapping;
  if (o.repeat) t.repeat.set(o.repeat[0], o.repeat[1]);
  t.anisotropy = Math.min(o.aniso || 8, maxAniso);
  if (o.srgb !== false) t.encoding = THREE.sRGBEncoding;
  t.needsUpdate = true;
  return t;
}
/* HDR-bright emitters: values above 1 survive because the scene is rendered
   into a half-float buffer, and they are what the bloom feeds on */
const hdr = (r, g, b) => new THREE.Color().setRGB(r, g, b);

/* A {map, normal, rough} set hung on a standard material at one tiling. The
   colour is a multiplier on an already-dark albedo, so it is how a shared
   board or block library is re-tinted per use rather than regenerated.
   roughness stays at 1: the map carries the variation, and anything less
   scales the whole surface toward a polish it should not have. */
function surface(t, rep, o) {
  o = o || {};
  const wrap = THREE.RepeatWrapping, aniso = o.aniso || 8;
  const m = new THREE.MeshStandardMaterial({
    map: tx(t.map, { wrap: wrap, repeat: rep, aniso: aniso }),
    normalMap: tx(t.normal, { wrap: wrap, repeat: rep, srgb: false, aniso: aniso }),
    normalScale: new THREE.Vector2(o.normal === undefined ? .8 : o.normal,
                                   o.normal === undefined ? .8 : o.normal),
    color: o.color === undefined ? 0xffffff : o.color,
    roughness: o.roughness === undefined ? 1 : o.roughness,
    metalness: o.metalness === undefined ? .02 : o.metalness
  });
  if (t.rough) m.roughnessMap = tx(t.rough, { wrap: wrap, repeat: rep, srgb: false });
  return m;
}
/* The board and block libraries are generated once and shared. buildLantern
   alone is called six times, and a granite per lantern is six pointless
   canvases and six normal-map passes at load. */
const LIB = {};
const lib = (k, f) => (LIB[k] || (LIB[k] = f()));
/* the walls are boarded; a column, a sill and a rail are each one stick of
   wood and must not carry a joint down the middle of them */
const wallWood = () => lib('wallWood', () => texWood(3, { boards: 7 }));
const postWood = () => lib('postWood', () => texWood(29, { boards: 0 }));

/* sweep a polygonal cross-section along a path — kasagi, shimaki, beams */
function sweepPoly(points, profile) {
  const segs = points.length, np = profile.length;
  const pos = [], nor = [], uv = [], idx = [];
  const T = new THREE.Vector3(), N = new THREE.Vector3(), B = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0);
  for (let i = 0; i < segs; i++) {
    const p = points[i], a = points[Math.max(0, i - 1)], b = points[Math.min(segs - 1, i + 1)];
    T.subVectors(b, a).normalize();
    B.crossVectors(T, up).normalize();
    N.crossVectors(B, T).normalize();
    for (let j = 0; j < np; j++) {
      const u = profile[j][0], v = profile[j][1], l = Math.hypot(u, v) || 1;
      pos.push(p.x + B.x * u + N.x * v, p.y + B.y * u + N.y * v, p.z + B.z * u + N.z * v);
      nor.push(B.x * u / l + N.x * v / l, B.y * u / l + N.y * v / l, B.z * u / l + N.z * v / l);
      uv.push(j / np, i / (segs - 1));
    }
  }
  for (let i = 0; i < segs - 1; i++) for (let j = 0; j < np; j++) {
    const j2 = (j + 1) % np, a = i * np + j, b = i * np + j2, c = (i + 1) * np + j2, d = (i + 1) * np + j;
    idx.push(a, b, c, a, c, d);
  }
  /* caps */
  [0, segs - 1].forEach((ring, k) => {
    const base = pos.length / 3, p = points[ring];
    pos.push(p.x, p.y, p.z); nor.push(0, 0, k ? 1 : -1); uv.push(.5, .5);
    for (let j = 0; j < np; j++) {
      const a = ring * np + j, b = ring * np + (j + 1) % np;
      k ? idx.push(base, a, b) : idx.push(base, b, a);
    }
  });
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
  g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
  g.setIndex(idx);
  return g;
}
/* A temple roof. The height is a concave function of the Chebyshev distance
   from the ridge out to the eave, and the last fifth of that run flares back
   upward — that lift at the corners is the whole silhouette. Emitted solid:
   a top shell, an underside shell, and a fascia stitching the two rims. */
function roofGeo(A, B, R, Hr, thick, flare) {
  const NX = 52, NZ = 34, FL = flare === undefined ? .30 : flare, e = 1e-3;
  /* The lift has to be weighted toward the corners. Applied evenly along the
     eave it produces a raised rim the whole way round and the roof reads as a
     saucer; concentrated where the two runs meet, it reads as a temple. */
  const hAt = (x, z) => {
    const cx = Math.min(1, Math.abs(x) / A), cz = Math.min(1, Math.abs(z) / B);
    const tx = Math.max(0, (Math.abs(x) - R) / Math.max(A - R, 1e-4));
    const t = Math.min(1, Math.max(tx, cz));
    return Hr * Math.pow(1 - t, 1.45)
         + FL * Hr * smooth(.72, 1, t) * (.52 + .68 * Math.min(cx, cz));
  };
  const pos = [], nor = [], uv = [], idx = [];
  const N = new THREE.Vector3();
  const VPS = (NX + 1) * (NZ + 1);
  for (let k = 0; k < 2; k++) {
    for (let j = 0; j <= NZ; j++) for (let i = 0; i <= NX; i++) {
      const x = -A + 2 * A * i / NX, z = -B + 2 * B * j / NZ;
      N.set(-(hAt(x + e, z) - hAt(x - e, z)) / (2 * e), 1,
            -(hAt(x, z + e) - hAt(x, z - e)) / (2 * e)).normalize();
      if (k) N.negate();
      pos.push(x, hAt(x, z) - (k ? thick : 0), z);
      nor.push(N.x, N.y, N.z);
      /* one tile every seven metres: at half a metre the ribs land inside a
         pixel at this range and the mip chain averages them to flat grey */
      uv.push(x * .14, z * .14);
    }
    for (let j = 0; j < NZ; j++) for (let i = 0; i < NX; i++) {
      const a = k * VPS + j * (NX + 1) + i, b = a + 1, c = a + NX + 2, d = a + NX + 1;
      k ? idx.push(a, c, b, a, d, c) : idx.push(a, b, c, a, c, d);
    }
  }
  /* fascia: its own ring of vertices so the eave edge keeps a sideways normal */
  const per = [];
  for (let i = 0; i <= NX; i++) per.push([i, 0]);
  for (let j = 1; j <= NZ; j++) per.push([NX, j]);
  for (let i = NX - 1; i >= 0; i--) per.push([i, NZ]);
  for (let j = NZ - 1; j >= 1; j--) per.push([0, j]);
  const base = pos.length / 3;
  per.forEach(p => {
    const x = -A + 2 * A * p[0] / NX, z = -B + 2 * B * p[1] / NZ;
    const nx = p[0] === 0 ? -1 : (p[0] === NX ? 1 : 0);
    const nz = p[1] === 0 ? -1 : (p[1] === NZ ? 1 : 0);
    const l = Math.hypot(nx, nz) || 1;
    const y = hAt(x, z);
    pos.push(x, y, z, x, y - thick, z);
    nor.push(nx / l, 0, nz / l, nx / l, 0, nz / l);
    uv.push(0, 0, 0, 1);
  });
  for (let i = 0; i < per.length; i++) {
    const a = base + i * 2, b = a + 1;
    const c = base + ((i + 1) % per.length) * 2, d = c + 1;
    idx.push(a, b, d, a, d, c);
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
  g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
  g.setIndex(idx);
  return g;
}

function mergeGeos(list) {
  let vN = 0, iN = 0;
  list.forEach(g => { vN += g.attributes.position.count; iN += g.index.count; });
  const pos = new Float32Array(vN * 3), nor = new Float32Array(vN * 3), uv = new Float32Array(vN * 2);
  const idx = vN > 65535 ? new Uint32Array(iN) : new Uint16Array(iN);
  let vo = 0, io = 0;
  list.forEach(g => {
    pos.set(g.attributes.position.array, vo * 3);
    nor.set(g.attributes.normal.array, vo * 3);
    uv.set(g.attributes.uv.array, vo * 2);
    const gi = g.index.array;
    for (let i = 0; i < gi.length; i++) idx[io + i] = gi[i] + vo;
    io += gi.length; vo += g.attributes.position.count;
    g.dispose();
  });
  const out = new THREE.BufferGeometry();
  out.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  out.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
  out.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
  out.setIndex(new THREE.BufferAttribute(idx, 1));
  return out;
}

/* ========================================================== 5 · the world */
const WORLD = {};                 /* named handles the page can animate */
const swayers = [];               /* things the wind touches */

/* ---- the site plan, in metres ----------------------------------------
   Everything downstream — camera waypoints, lantern rows, the moon — is
   pinned to these, so the approach can be re-proportioned in one place. */
const PODIUM    = 7.0;            /* how far the hall stands above the court */
const STEPS     = 40;
const STAIR_Z0  = -11.0;          /* the bottom riser                        */
const STAIR_RUN = .55;            /* → the flight tops out at z = −33        */
const STAIR_W   = 8.4;
const TEMPLE_Z  = -44;            /* the hall's centre line                   */

function buildShell() {
  const wallT = texWall();
  const wallMap = tx(wallT.map, { wrap: THREE.RepeatWrapping, repeat: [4, 1.4] });
  const wallNrm = tx(wallT.normal, { wrap: THREE.RepeatWrapping, repeat: [4, 1.4], srgb: false });
  const wallMat = new THREE.MeshStandardMaterial({
    map: wallMap, normalMap: wallNrm, normalScale: new THREE.Vector2(.85, .85),
    roughness: .78, metalness: .05, color: 0x525c60
  });
  /* The court used to be a roofless box. It is now an open mountain approach:
     nothing encloses the frame but the night, the ridge and the fog. */

  /* the sky, hung far enough back that the whole frustum sits inside it */
  const sky = new THREE.Mesh(new THREE.PlaneGeometry(360, 190),
    new THREE.MeshBasicMaterial({ color: hdr(.60, .70, .80), map: tx(texSky()),
      depthWrite: false, fog: false, toneMapped: false }));
  sky.position.set(0, 62, -108); sky.renderOrder = 0; scene.add(sky);
  WORLD.sky = sky;

  /* the wooded ridge that closes the valley — three-quarters eaten by fog,
     which is the only reason the ground plane can just stop out there */
  /* z, centre y, width, height, x — kept low: a crest that climbs past the
     hall's eaves stops reading as distance and starts reading as a cut-out */
  const ridgeMap = tx(texRidge(), { wrap: THREE.RepeatWrapping, repeat: [1.7, 1] });
  [[-90, 13, 300, 26, 0], [-63, 9.5, 210, 19, 16]].forEach((r, i) => {
    const m = new THREE.Mesh(new THREE.PlaneGeometry(r[2], r[3]),
      /* fog off: at a hundred metres it would lift a black silhouette to 80%
         of the fog colour, and the treeline came back as a *pale* band */
      new THREE.MeshBasicMaterial({ map: i ? tx(texRidge()) : ridgeMap, transparent: true,
        color: i ? 0x0a1015 : 0x06090d, depthWrite: false, fog: false }));
    m.position.set(r[4], r[1], r[0]); m.renderOrder = 1; scene.add(m);
  });

  /* floor + podium */
  const fT = texFloor();
  const floorMat = new THREE.MeshStandardMaterial({
    map: tx(fT.map, { wrap: THREE.RepeatWrapping, repeat: [7, 7], aniso: 16 }),
    normalMap: tx(fT.normal, { wrap: THREE.RepeatWrapping, repeat: [7, 7], srgb: false, aniso: 16 }),
    roughnessMap: tx(fT.rough, { wrap: THREE.RepeatWrapping, repeat: [3.4, 3.4], srgb: false }),
    normalScale: new THREE.Vector2(.30, .30),
    /* The court used to carry a planar mirror and this was tuned to feed it —
       near-polished, and metal enough to hold a reflection. With the mirror
       gone those numbers only made the paving read as sheet metal, so it goes
       back to being wet stone: rough, barely metallic, and lit rather than
       reflecting. */
    roughness: .74, metalness: .06, color: 0x69757a
  });
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(150, 150), floorMat);
  floor.rotation.x = -Math.PI / 2; floor.position.set(0, 0, -18); floor.receiveShadow = true;
  scene.add(floor);
  WORLD.floor = floor; WORLD.floorMat = floorMat;

  /* The podium and the flight use their own material: the mirror shader below
     is only ever attached to the water-level floor, and sharing it would make
     the reflection buffer sample itself. It is also deliberately matte — with
     the floor's metalness the forty risers strobed as the camera drifted. */
  const platMat = new THREE.MeshStandardMaterial({
    map: tx(fT.map, { wrap: THREE.RepeatWrapping, repeat: [3, 1.2], aniso: 16 }),
    normalMap: tx(fT.normal, { wrap: THREE.RepeatWrapping, repeat: [3, 1.2], srgb: false, aniso: 16 }),
    normalScale: new THREE.Vector2(.18, .18), roughness: .93, metalness: .02, color: 0x58636a
  });
  /* the podium the hall stands on */
  const plat = new THREE.Mesh(new THREE.BoxGeometry(42, PODIUM, 24), platMat);
  plat.position.set(0, PODIUM / 2, -45); plat.receiveShadow = true; plat.castShadow = true;
  scene.add(plat);
  const cope = new THREE.Mesh(new THREE.BoxGeometry(43, .34, 25), wallMat);
  cope.position.set(0, PODIUM - .17, -45); cope.receiveShadow = true; scene.add(cope);

  /* The flight. Twenty-six risers merged into one buffer — as separate meshes
     it is twenty-six draw calls for a shape nobody ever gets close to. */
  const treads = [], cheeks = [];
  for (let i = 0; i < STEPS; i++) {
    const y = (i + 1) * (PODIUM / STEPS), z = STAIR_Z0 - (i + .5) * STAIR_RUN;
    const w = STAIR_W + (STEPS - i) * .052;               /* splayed at the foot */
    treads.push(new THREE.BoxGeometry(w, PODIUM / STEPS + .04, STAIR_RUN + .04)
      .translate(0, y - (PODIUM / STEPS) / 2, z));
    [-1, 1].forEach(s => cheeks.push(new THREE.BoxGeometry(.9, 1.5, STAIR_RUN + .06)
      .translate(s * (w / 2 + .45), y - .3, z)));
  }
  const stair = new THREE.Mesh(mergeGeos(treads), platMat);
  stair.receiveShadow = true; stair.castShadow = true; scene.add(stair);
  const rail = new THREE.Mesh(mergeGeos(cheeks), wallMat);
  rail.receiveShadow = true; rail.castShadow = true; scene.add(rail);
}

/* ------------------------------------------------------- the worship hall
   A two-storey Sanmon on the podium at the head of the flight: charred
   timber, two flaring tiled roofs, a bracket band under each eave, and a
   ground floor lit from inside so the lattice reads as the only warm thing
   for two hundred metres.                                                 */
function buildTemple() {
  const g = new THREE.Group();
  /* One board library, sampled at two scales: the hall's flanks want the grain
     long and lying down, the columns want it running their own length. */
  const timber = surface(wallWood(), [4, 1.6], { color: 0x565150, normal: 1.5 });
  const post   = surface(postWood(), [1.1, 1.0], { color: 0x8a746d, normal: 1.05, metalness: .03 });
  const gold   = new THREE.MeshStandardMaterial({ color: 0x8f6f2e, roughness: .38, metalness: .78 });
  const rf = lib('roof', () => texRoof());
  /* Nearly a silhouette: the roof carries almost no colour of its own, and
     what reads on it is relief rather than tone — so the normal goes up as
     the albedo comes down. */
  const tileMat = surface(rf, [1, 1], { color: 0x2b343a, roughness: .74, metalness: .10, normal: 1.4 });
  /* the paper the lamps burn through — the only thing in the hall that is
     allowed to be bright, and even then only just above white */
  const paper = new THREE.MeshBasicMaterial({ color: hdr(1.06, .48, .18), fog: true, toneMapped: false });
  const grid  = new THREE.MeshBasicMaterial({ map: tx(texShoji()), transparent: true,
    depthWrite: false, fog: true });
  WORLD.paper = paper;

  /* one lit bay: a glowing sheet with a lattice hung just in front of it */
  function bay(w, h, x, y, z) {
    const p = new THREE.Mesh(new THREE.PlaneGeometry(w, h), paper);
    p.position.set(x, y, z); g.add(p);
    const s = new THREE.Mesh(new THREE.PlaneGeometry(w, h), grid);
    s.position.set(x, y, z + .06); s.renderOrder = 3; g.add(s);
  }
  /* The block-and-arm band that carries every eave. The pieces are merged, so
     they carry absolute coordinates — TEMPLE_Z has to go in here, not on the
     mesh, or the whole band ends up hanging in the air by the camera. */
  function brackets(halfW, halfD, y, step) {
    const parts = [];
    for (let x = -halfW; x <= halfW + 1e-3; x += step) {
      [-halfD, halfD].forEach(dz => {
        parts.push(new THREE.BoxGeometry(.34, .46, .34).translate(x, y, TEMPLE_Z + dz));
        parts.push(new THREE.BoxGeometry(.92, .17, .24).translate(x, y + .30, TEMPLE_Z + dz));
      });
    }
    for (let dz = -halfD + step; dz < halfD - 1e-3; dz += step) {
      [-halfW, halfW].forEach(x => {
        parts.push(new THREE.BoxGeometry(.34, .46, .34).translate(x, y, TEMPLE_Z + dz));
        parts.push(new THREE.BoxGeometry(.24, .17, .92).translate(x, y + .30, TEMPLE_Z + dz));
      });
    }
    const m = new THREE.Mesh(mergeGeos(parts), post);
    m.castShadow = false; g.add(m);
  }

  const F = PODIUM;                                   /* the hall's floor level */

  /* ---- ground storey: six bays of lit lattice between charred columns --- */
  const core = new THREE.Mesh(new THREE.BoxGeometry(13.6, 5.0, 8.2), timber);
  core.position.set(0, F + 2.5, TEMPLE_Z); core.castShadow = true; g.add(core);
  for (let i = 0; i < 5; i++) {
    const x = -5.6 + i * 2.8;
    bay(1.55, 2.5, x, F + 2.6, TEMPLE_Z + 4.16);
  }
  for (let i = 0; i < 6; i++) {
    const c = new THREE.Mesh(new THREE.CylinderGeometry(.30, .34, 5.0, 14), post);
    c.position.set(-7.0 + i * 2.8, F + 2.5, TEMPLE_Z + 4.24); c.castShadow = true; g.add(c);
  }
  const sill = new THREE.Mesh(new THREE.BoxGeometry(14.6, .40, 8.9), post);
  sill.position.set(0, F + .20, TEMPLE_Z); g.add(sill);
  brackets(7.0, 4.4, F + 5.15, 1.40);

  /* ---- the pent roof over the ground storey ---------------------------- */
  const lower = new THREE.Mesh(roofGeo(9.6, 6.4, 3.2, 2.9, .40, .26), tileMat);
  lower.position.set(0, F + 5.6, TEMPLE_Z); lower.castShadow = true; g.add(lower);

  /* ---- upper storey: railed gallery, dark boards, the temple's name ----- */
  const up = new THREE.Mesh(new THREE.BoxGeometry(10.0, 3.4, 6.0), timber);
  up.position.set(0, F + 9.4, TEMPLE_Z); up.castShadow = true; g.add(up);
  const deck = new THREE.Mesh(new THREE.BoxGeometry(12.4, .26, 8.4), post);
  deck.position.set(0, F + 7.72, TEMPLE_Z); g.add(deck);
  [.10, .94].forEach(dy => [4.2, -4.2].forEach(dz => {
    const r = new THREE.Mesh(new THREE.BoxGeometry(12.4, .17, .17), post);
    r.position.set(0, F + 7.85 + dy, TEMPLE_Z + dz); g.add(r);
  }));
  for (let i = 0; i <= 16; i++) {
    const b = new THREE.Mesh(new THREE.BoxGeometry(.10, 1.05, .10), post);
    b.position.set(-6.2 + i * .775, F + 8.42, TEMPLE_Z + 4.2); g.add(b);
  }
  for (let i = 0; i < 4; i++)
    bay(1.0, 1.35, -4.5 + i * 3.0, F + 9.5, TEMPLE_Z + 3.06);
  /* the plaque */
  const plq = new THREE.Mesh(new THREE.BoxGeometry(1.9, 1.30, .16), gold);
  plq.position.set(0, F + 9.7, TEMPLE_Z + 3.12); g.add(plq);
  const plqIn = new THREE.Mesh(new THREE.BoxGeometry(1.52, .98, .18), timber);
  plqIn.position.set(0, F + 9.7, TEMPLE_Z + 3.16); g.add(plqIn);
  brackets(5.2, 3.3, F + 11.2, 1.30);

  /* ---- the main roof: the silhouette the whole page hangs on ----------- */
  const upper = new THREE.Mesh(roofGeo(10.8, 7.2, 3.6, 5.2, .48, .26), tileMat);
  upper.position.set(0, F + 11.7, TEMPLE_Z); upper.castShadow = true; g.add(upper);
  const ridge = new THREE.Mesh(new THREE.BoxGeometry(7.6, .60, 1.05), tileMat);
  ridge.position.set(0, F + 17.10, TEMPLE_Z); g.add(ridge);
  [-1, 1].forEach(s => {
    const oni = new THREE.Mesh(new THREE.ConeGeometry(.46, 1.15, 4), tileMat);
    oni.position.set(s * 3.9, F + 17.6, TEMPLE_Z); oni.rotation.y = Math.PI / 4; g.add(oni);
  });
  WORLD.templeTop = F + 18.2;

  /* ---- the two flanking wings, lit the same way ------------------------ */
  [-1, 1].forEach(s => {
    const w = new THREE.Mesh(new THREE.BoxGeometry(7.6, 3.4, 5.2), timber);
    w.position.set(s * 10.6, F + 1.7, TEMPLE_Z + 1.4); w.castShadow = true; g.add(w);
    for (let i = 0; i < 3; i++)
      bay(1.3, 1.6, s * 10.6 + (i - 1) * 2.4, F + 1.8, TEMPLE_Z + 4.06);
    const r = new THREE.Mesh(roofGeo(5.0, 4.0, 1.4, 1.9, .32, .26), tileMat);
    r.position.set(s * 10.6, F + 3.4, TEMPLE_Z + 1.4); r.castShadow = true; g.add(r);
  });

  scene.add(g); WORLD.temple = g;

  /* the glow the hall pushes out over its own steps */
  const spill = new THREE.Mesh(new THREE.PlaneGeometry(30, 16),
    new THREE.MeshBasicMaterial({ map: tx(texGlow('rgba(255,150,66,.80)', 'rgba(240,96,26,.24)')),
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, opacity: .30 }));
  spill.position.set(0, F + 3.0, TEMPLE_Z + 5.6); spill.renderOrder = 2;
  scene.add(spill); WORLD.hallHalo = spill;

  /* the band of mist that separates the hall from the trees behind it — the
     one thing that stops the whole background reading as a single flat cut-out */
  const mist = new THREE.Mesh(new THREE.PlaneGeometry(64, 20),
    new THREE.MeshBasicMaterial({ map: tx(texGlow('rgba(150,178,190,.62)', 'rgba(104,138,154,.20)')),
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, opacity: .17 }));
  mist.position.set(0, F - 1.4, TEMPLE_Z + 10); mist.renderOrder = 2; scene.add(mist);
}

/* --------------------------------------------------- the vermilion moon
   Hung well behind the hall so the roof crosses its lower left — near
   enough to the ridge to sit in the mist, far enough that it never moves
   against the buildings when the rig drifts.                             */
const MOON = { x: 17.9, y: 31.9, z: -72, r: 8.6 };
function buildMoon() {
  const disc = new THREE.Mesh(new THREE.PlaneGeometry(MOON.r * 2, MOON.r * 2),
    /* The whole blood-moon grade lives here, on a near-neutral albedo. The
       plate measures G/R ≈ B/R ≈ .48, but that is a *display* ratio: the map
       is decoded out of sRGB before this multiplies it, so the tint has to be
       written in linear, where the same look is ≈ .21. Reading the ratio
       straight off the reference is what left the moon a pale pink. */
    new THREE.MeshBasicMaterial({ map: tx(texMoon()), color: hdr(3.6, .64, .61),
      transparent: true, depthWrite: false, fog: false, toneMapped: false }));
  disc.position.set(MOON.x, MOON.y, MOON.z); disc.renderOrder = 1;
  scene.add(disc); WORLD.moon = disc;

  /* the air around it — a wide, weak corona that the bloom picks up */
  const halo = new THREE.Mesh(new THREE.PlaneGeometry(MOON.r * 6.4, MOON.r * 6.4),
    new THREE.MeshBasicMaterial({ map: tx(texGlow('rgba(255,124,112,.90)', 'rgba(206,52,48,.26)')),
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, opacity: .44 }));
  halo.position.set(MOON.x, MOON.y, MOON.z - .3); halo.renderOrder = 0;
  scene.add(halo); WORLD.moonHalo = halo;
}
/* On a tall frame the rig steps back and its horizontal reach narrows, which
   would slide the moon off the right edge — draw it in by the same amount. */
function placeMoon() {
  if (!WORLD.moon) return;
  const x = MOON.x * (1 - .40 * aspectFix());
  WORLD.moon.position.x = x;
  WORLD.moonHalo.position.x = x;
}

function buildTorii() {
  /* the weathering costs the gate a lot of its chroma, so the tint puts it
     back: red lifted well past green and blue rather than a flat brightening,
     or the vermilion comes out of the grade as brick */
  const lac = surface(lib('lacquer', () => texLacquer()), [2, 2],
    { color: hdr(1.72, 1.02, .94), roughness: .92, metalness: .05, normal: .75 });
  const gold = new THREE.MeshStandardMaterial({ color: 0x7a5d2a, roughness: .50, metalness: .60 });
  const g = new THREE.Group();
  const BASE = .78, H = 8.2, SPAN = 3.55;

  [-1, 1].forEach(s => {
    const col = new THREE.Mesh(new THREE.CylinderGeometry(.30, .38, H, 26), lac);
    col.position.set(s * SPAN, BASE + H / 2, 0); col.castShadow = true; g.add(col);
    const foot = new THREE.Mesh(new THREE.CylinderGeometry(.46, .50, .52, 26), gold);
    foot.position.set(s * SPAN, BASE + .26, 0); foot.castShadow = true; g.add(foot);
    const collar = new THREE.Mesh(new THREE.CylinderGeometry(.345, .345, .26, 26), gold);
    collar.position.set(s * SPAN, BASE + H - 1.42, 0); g.add(collar);
    /* the ornate bracket where the beams cross the column */
    const br = new THREE.Mesh(new THREE.BoxGeometry(.86, .30, .86), gold);
    br.position.set(s * SPAN, BASE + H - .72, 0); g.add(br);
    const br2 = new THREE.Mesh(new THREE.BoxGeometry(.60, .52, .60), gold);
    br2.position.set(s * SPAN, BASE + H - .34, 0); g.add(br2);
    [-1, 1].forEach(d => {
      const wing = new THREE.Mesh(new THREE.BoxGeometry(.24, .70, .18), gold);
      wing.position.set(s * SPAN + d * .40, BASE + H - .62, .30); g.add(wing);
      const wing2 = wing.clone(); wing2.position.z = -.30; g.add(wing2);
    });
  });

  /* nuki — the straight beam that pierces the columns */
  const nuki = new THREE.Mesh(new THREE.BoxGeometry(9.4, .52, .46), lac);
  nuki.position.set(0, BASE + H - 2.15, 0); nuki.castShadow = true; g.add(nuki);

  /* gakuzuka — the little strut at the centre */
  const gak = new THREE.Mesh(new THREE.BoxGeometry(.46, 1.22, .40), lac);
  gak.position.set(0, BASE + H + .10, 0); g.add(gak);
  const gakG = new THREE.Mesh(new THREE.BoxGeometry(.60, .18, .50), gold);
  gakG.position.set(0, BASE + H + .68, 0); g.add(gakG);

  /* shimaki + kasagi — the two crowning beams, swept upward at the ends */
  function beamPath(half, rise, power) {
    const p = [];
    for (let i = 0; i <= 26; i++) {
      const u = i / 26 * 2 - 1;
      p.push(new THREE.Vector3(u * half, Math.pow(Math.abs(u), power) * rise, 0));
    }
    return p;
  }
  const shimaki = new THREE.Mesh(sweepPoly(beamPath(5.05, .40, 2.6),
    [[-.30, -.19], [.30, -.19], [.32, .06], [.28, .21], [-.28, .21], [-.32, .06]]), lac);
  shimaki.position.set(0, BASE + H - .52, 0); shimaki.castShadow = true; g.add(shimaki);

  const kasagi = new THREE.Mesh(sweepPoly(beamPath(5.85, .62, 2.4),
    [[-.42, -.24], [.42, -.24], [.46, .04], [.30, .28], [-.30, .28], [-.46, .04]]), lac);
  kasagi.position.set(0, BASE + H + .96, 0); kasagi.castShadow = true; g.add(kasagi);
  /* a black lacquer cap along the top of the kasagi */
  const cap = new THREE.Mesh(sweepPoly(beamPath(5.95, .62, 2.4),
    [[-.48, .22], [.48, .22], [.48, .34], [-.48, .34]]),
    new THREE.MeshStandardMaterial({ color: 0x120c0c, roughness: .42, metalness: .14 }));
  cap.position.set(0, BASE + H + .96, 0); g.add(cap);

  /* the gate stands at the foot of the flight, not over the court — small
     enough that the hall keeps the top of the frame to itself */
  /* The lowest thing on the gate is the foot of each column, and it starts
     at BASE — so anchoring the group at y=0 left the whole torii hanging
     BASE*scale above the court. Drop it by exactly that and the feet land. */
  const GS = .72;
  g.position.set(0, -BASE * GS, -8.6); g.scale.setScalar(GS);
  scene.add(g); WORLD.torii = g;
}

function buildLantern(x, z, s, y) {
  const stone = lib('lanternStone', () => surface(lib('granite', () => texStone(17)), [1.5, 1.5],
    { color: 0x9aa5a5, metalness: 0, normal: 1.45 }));
  const dark  = lib('lanternDark', () => surface(postWood(), [.9, .9],
    { color: 0xd8c2b6, metalness: .1, normal: .7 }));
  const g = new THREE.Group();
  const base = new THREE.Mesh(new THREE.CylinderGeometry(.44, .52, .26, 20), stone);
  base.position.y = .13; base.castShadow = true; g.add(base);
  const post = new THREE.Mesh(new THREE.CylinderGeometry(.15, .18, 1.02, 16), stone);
  post.position.y = .77; post.castShadow = true; g.add(post);
  const shelf = new THREE.Mesh(new THREE.CylinderGeometry(.42, .34, .13, 20), stone);
  shelf.position.y = 1.34; shelf.castShadow = true; g.add(shelf);

  const box = new THREE.Mesh(new THREE.BoxGeometry(.50, .50, .50), dark);
  box.position.y = 1.66; box.castShadow = true; g.add(box);
  const paneMat = new THREE.MeshBasicMaterial({ color: hdr(2.3, .30, .085), fog: false, toneMapped: false });
  [[0, 0, .256, 0], [0, 0, -.256, Math.PI], [.256, 0, 0, Math.PI / 2], [-.256, 0, 0, -Math.PI / 2]].forEach(p => {
    const pane = new THREE.Mesh(new THREE.PlaneGeometry(.34, .34), paneMat);
    pane.position.set(p[0], 1.66, p[2]); pane.rotation.y = p[3]; g.add(pane);
  });
  WORLD.lanternPane = WORLD.lanternPane || paneMat;

  const roof = new THREE.Mesh(new THREE.CylinderGeometry(.10, .62, .34, 4, 1), stone);
  roof.position.y = 2.06; roof.rotation.y = Math.PI / 4; roof.castShadow = true; g.add(roof);
  const brim = new THREE.Mesh(new THREE.CylinderGeometry(.66, .70, .07, 4, 1), stone);
  brim.position.y = 1.93; brim.rotation.y = Math.PI / 4; g.add(brim);
  const knob = new THREE.Mesh(new THREE.SphereGeometry(.09, 12, 10), stone);
  knob.position.y = 2.28; g.add(knob);
  const tip = new THREE.Mesh(new THREE.ConeGeometry(.055, .16, 10), stone);
  tip.position.y = 2.4; g.add(tip);

  const glow = new THREE.Mesh(new THREE.PlaneGeometry(3.4, 3.4),
    new THREE.MeshBasicMaterial({ map: tx(texGlow('rgba(255,120,60,.9)', 'rgba(255,60,24,.28)')),
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, opacity: .5 }));
  glow.position.y = 1.66; glow.renderOrder = 2; g.add(glow);
  glow.userData.billboard = true;

  const lt = new THREE.PointLight(0xff5a24, 2.6, 9, 2);
  lt.position.set(0, 1.66, 0); g.add(lt);
  WORLD.lanternLights = WORLD.lanternLights || [];
  WORLD.lanternLights.push(lt); WORLD.lanternGlows = WORLD.lanternGlows || []; WORLD.lanternGlows.push(glow);

  g.position.set(x, y || 0, z); g.scale.setScalar(s || 1);
  scene.add(g);
  return g;
}

function buildMaple(seed, x, z, scale) {
  const rnd = mulberry32(seed);
  const parts = [], tips = [];
  const M = new THREE.Matrix4(), Q = new THREE.Quaternion(), UP = new THREE.Vector3(0, 1, 0);
  const dir = new THREE.Vector3(), pos = new THREE.Vector3();

  function seg(from, to, r0, r1) {
    dir.subVectors(to, from);
    const len = dir.length(); dir.normalize();
    const geo = new THREE.CylinderGeometry(r1, r0, len, 6, 1, true);
    Q.setFromUnitVectors(UP, dir);
    pos.addVectors(from, to).multiplyScalar(.5);
    M.compose(pos, Q, new THREE.Vector3(1, 1, 1));
    geo.applyMatrix4(M);
    parts.push(geo);
  }
  function branch(from, dirV, len, rad, depth) {
    const to = from.clone().addScaledVector(dirV, len);
    to.y += len * .10;
    seg(from, to, rad, rad * .68);
    if (depth >= 4 || len < .34) { tips.push(to.clone()); return; }
    const n = depth < 2 ? 3 : 2;
    for (let i = 0; i < n; i++) {
      const d = dirV.clone();
      d.x += (rnd() - .5) * 1.25; d.z += (rnd() - .5) * 1.25; d.y += .30 + rnd() * .5;
      d.normalize();
      branch(to, d, len * (.62 + rnd() * .16), rad * .66, depth + 1);
    }
  }
  const root = new THREE.Vector3(0, 0, 0);
  seg(root, new THREE.Vector3(0, 1.5, 0), .22, .16);
  for (let i = 0; i < 4; i++) {
    const a = i / 4 * TAU + rnd();
    branch(new THREE.Vector3(0, 1.5, 0), new THREE.Vector3(Math.cos(a) * .8, .8, Math.sin(a) * .8).normalize(), 1.45, .155, 0);
  }
  const trunk = new THREE.Mesh(mergeGeos(parts),
    new THREE.MeshStandardMaterial({ color: 0x171413, roughness: .94, metalness: .0, side: THREE.DoubleSide }));
  trunk.castShadow = true;

  const leafGeo = new THREE.PlaneGeometry(.36, .36);
  const leafMat = new THREE.MeshStandardMaterial({
    map: tx(texLeaf()), color: 0x2b0406, alphaTest: .42, side: THREE.DoubleSide,
    roughness: .86, metalness: 0, emissive: 0x080000, emissiveIntensity: .12
  });
  const per = LOW ? 5 : 9;
  const inst = new THREE.InstancedMesh(leafGeo, leafMat, tips.length * per);
  const m4 = new THREE.Matrix4(), e = new THREE.Euler(), q2 = new THREE.Quaternion(), sc = new THREE.Vector3();
  let k = 0;
  tips.forEach(t => {
    for (let i = 0; i < per; i++) {
      const p = new THREE.Vector3(t.x + (rnd() - .5) * .95, t.y + (rnd() - .5) * .8, t.z + (rnd() - .5) * .95);
      e.set(rnd() * TAU, rnd() * TAU, rnd() * TAU);
      q2.setFromEuler(e);
      const s = .7 + rnd() * .75; sc.set(s, s, s);
      m4.compose(p, q2, sc);
      inst.setMatrixAt(k++, m4);
    }
  });
  inst.instanceMatrix.needsUpdate = true;
  inst.castShadow = false; inst.frustumCulled = false;

  const g = new THREE.Group();
  g.add(trunk); g.add(inst);
  g.position.set(x, 0, z); g.scale.setScalar(scale || 1);
  g.rotation.y = rnd() * TAU;
  scene.add(g);
  /* leaves breathe in the wind */
  leafMat.onBeforeCompile = sh => {
    sh.uniforms.uT = WORLD.uT;
    sh.vertexShader = 'uniform float uT;n' + sh.vertexShader.replace('#include <begin_vertex>',
      '#include <begin_vertex>n' +
      'vec3 wp = (instanceMatrix * vec4(0.0,0.0,0.0,1.0)).xyz;n' +
      'float ph = wp.x*1.7 + wp.z*1.3 + wp.y*.7;n' +
      'transformed.x += sin(uT*1.35 + ph)*0.055;n' +
      'transformed.z += cos(uT*1.05 + ph*1.3)*0.045;n');
  };
  return g;
}

function buildRocks() {
  const mat = new THREE.MeshStandardMaterial({ color: 0x141a1c, roughness: .46, metalness: .10 });
  const rnd = mulberry32(404), n = noise2D(88);
  const spots = [[-6.2, -5.4, .95], [-7.4, -2.1, .7], [6.6, -7.2, .8], [8.0, -4.0, 1.05],
                 [-9.0, -8.4, 1.15], [9.6, -9.2, .9], [3.4, -7.8, .55], [-3.2, -8.6, .6]];
  spots.forEach((s, i) => {
    const geo = new THREE.IcosahedronGeometry(s[2], 2);
    const p = geo.attributes.position;
    for (let v = 0; v < p.count; v++) {
      const vx = p.getX(v), vy = p.getY(v), vz = p.getZ(v);
      const d = 1 + .34 * fbm(n, vx * 1.6 + i * 7, vz * 1.6 + vy, 3, 2.2, .5);
      p.setXYZ(v, vx * d, vy * d * .68, vz * d);
    }
    geo.computeVertexNormals();
    const m = new THREE.Mesh(geo, mat);
    m.position.set(s[0], s[2] * .30, s[1]);
    m.rotation.set(rnd(), rnd() * TAU, rnd() * .4);
    m.castShadow = true; m.receiveShadow = true;
    scene.add(m);
  });
}

/* ------------------------------- the transparent-PNG foreground layers */
function cutoutMaterial(canvasEl, sway) {
  const mat = new THREE.MeshBasicMaterial({
    map: tx(canvasEl, { aniso: 16 }), transparent: true, depthWrite: true,
    alphaTest: .012, side: THREE.DoubleSide, fog: true, color: 0xffffff
  });
  mat.onBeforeCompile = sh => {
    sh.uniforms.uT = WORLD.uT;
    sh.uniforms.uSway = { value: sway || .07 };
    sh.vertexShader = 'uniform float uT;nuniform float uSway;n' + sh.vertexShader.replace(
      '#include <begin_vertex>',
      '#include <begin_vertex>n' +
      'float h = uv.y;n' +
      'float ph = position.x*0.30 + float(gl_InstanceID)*0.0;n' +
      'transformed.x += sin(uT*0.85 + ph)*uSway*h*h;n' +
      'transformed.y += cos(uT*0.62 + ph*1.7)*uSway*0.35*h*h;n');
    /* These plates are drawn to the edge of their own canvas — the grass runs
       straight off the left and the bottom — so every sheet ends on a hard
       straight line wherever its plane stops. Feathering the alpha at the
       edges lets each one dissolve into the scene instead of being cut out of
       it. Sides get the widest margin because that is where a bank of grass
       is read as ending; the crest needs none, the blades already taper. */
    sh.fragmentShader = sh.fragmentShader.replace('#include <alphatest_fragment>',
      'float feX = smoothstep(0.0, 0.11, vUv.x) * (1.0 - smoothstep(0.89, 1.0, vUv.x));n' +
      'float feY = smoothstep(0.0, 0.07, vUv.y);n' +
      'diffuseColor.a *= feX * feY;n' +
      '#include <alphatest_fragment>');
  };
  return mat;
}

function buildForeground() {
  /* the plane widths are sized against the frustum at their own depth: a
     26-unit sheet two metres from the lens only shows its middle tenth */
  const layers = [
    /* name        canvas                                                                     x     y     z     w     h   sway */
    ['grassFar',  texGrassCutout(101, { crest: .30, peak: .46, wide: .42, blades: 11000, len: 34, warm: .70, crest2: .46, crest3: .40 }),
                  -3.4,  2.85, -0.4, 24, 12, .040],
    ['rockLeft',  texRockCutout(404, { blobs: [[.24, .78, .48, .38], [.58, .94, .46, .30], [.84, 1.06, .42, .28]] }),
                  -7.2,  2.55,  1.8, 17, 11.3, .010],
    ['grassMid',  texGrassCutout(202, { crest: .60, peak: .55, wide: .40, blades: 13000, len: 40, warm: .95, crest2: -.44, crest3: .34 }),
                   3.9,  2.86,  3.6, 15,  7.5, .062],
    ['grassNear', texGrassCutout(303, { crest: .42, peak: .62, wide: .48, blades: 16000, len: 50, warm: .85, crest2: .42, crest3: .44 }),
                  -1.9,  2.92,  5.6, 12,  6, .092],
    ['bough',     texBranchCutout(505), 4.6, 2.95, 6.4, 13, 8.7, .045],
    ['rockNear',  texRockCutout(606, { blobs: [[.18, .86, .54, .44], [.54, 1.02, .50, .36], [.86, .92, .40, .32]] }),
                  -6.6,  1.70,  6.0, 9, 6, .008]
  ];
  WORLD.fg = [];
  layers.forEach(L => {
    const m = new THREE.Mesh(new THREE.PlaneGeometry(L[5], L[6], 12, 12), cutoutMaterial(L[1], L[7]));
    m.position.set(L[2], L[3], L[4]);
    m.renderOrder = 20 + WORLD.fg.length;
    m.name = L[0];
    m.frustumCulled = false;
    scene.add(m);
    WORLD.fg.push(m);
  });
}

/* --------------------------------------------------- the giant wordmark */
const WORD_Z = 3.0;
function buildWordmark() {
  /* The word is set to the frame width, so a short word is a *taller* word.
     Four letters at the old tracking put a 400-pixel cap height across the
     scene; the letters are spaced out instead, which keeps the type edge to
     edge while the caps come back down to the height of the headline block. */
  const SZ = 320, TRACK = .40, PAD = 26;
  const m = cvs(4, 4).getContext('2d');
  m.font = '600 ' + SZ + 'px Wordmark, sans-serif';
  m.textBaseline = 'alphabetic'; m.textAlign = 'left';
  const word = 'KAGE', gl = [];
  let pen = 0, ascMax = 0, descMax = 0, xMin = 1e9, xMax = -1e9;
  for (const ch of word) {
    const t = m.measureText(ch);
    const g = { ch: ch, adv: t.width, asc: t.actualBoundingBoxAscent, desc: t.actualBoundingBoxDescent,
                l: t.actualBoundingBoxLeft, r: t.actualBoundingBoxRight, pen: pen };
    gl.push(g);
    ascMax = Math.max(ascMax, g.asc); descMax = Math.max(descMax, g.desc);
    xMin = Math.min(xMin, pen - g.l); xMax = Math.max(xMax, pen + g.r);
    pen += t.width + TRACK * SZ;
  }
  const group = new THREE.Group();
  WORD.glyphs = [];
  gl.forEach((g, i) => {
    const cw = Math.ceil(g.l + g.r) + PAD * 2, chh = Math.ceil(g.asc + g.desc) + PAD * 2;
    const c = cvs(cw, chh), x = c.getContext('2d');
    x.font = '600 ' + SZ + 'px Wordmark, sans-serif';
    x.textBaseline = 'alphabetic'; x.textAlign = 'left';
    /* the vertical wash is baked per glyph so the whole word shares one ramp */
    const gy0 = PAD + g.asc - ascMax, gy1 = PAD + g.asc + descMax * .4;
    const grad = x.createLinearGradient(0, gy0, 0, gy1);
    grad.addColorStop(0, 'rgb(226,236,229)');
    grad.addColorStop(.52, 'rgb(198,214,203)');
    grad.addColorStop(1, 'rgb(150,170,157)');
    x.fillStyle = grad;
    x.fillText(g.ch, PAD + g.l, PAD + g.asc);

    const mesh = new THREE.Mesh(new THREE.PlaneGeometry(cw, chh),
      new THREE.MeshBasicMaterial({ map: tx(c, { aniso: 16 }), transparent: true,
        depthWrite: false, side: THREE.DoubleSide, fog: true, opacity: 1 }));
    mesh.position.set(g.pen + (g.r - g.l) / 2, (g.asc - g.desc) / 2 + (PAD - PAD) , 0);
    mesh.position.y = (g.asc - g.desc) / 2;
    mesh.renderOrder = 12;
    mesh.frustumCulled = false;
    mesh.userData.baseY = mesh.position.y;
    group.add(mesh);
    WORD.glyphs.push(mesh);
  });
  group.position.z = WORD_Z;
  scene.add(group);
  WORD.group = group;
  WORD.ink = { xMin: xMin, xMax: xMax, cx: (xMin + xMax) / 2, w: xMax - xMin, asc: ascMax };
}
const WORD = { glyphs: [], group: null, ink: null, reveal: 0 };

/* --------------------------------------------------------- atmospherics */
function buildAtmosphere() {
  /* drifting haze slabs */
  const hazeTex = tx(texGlow('rgba(160,205,210,.55)', 'rgba(110,165,175,.18)'));
  WORLD.haze = [];
  const rnd = mulberry32(66);
  for (let i = 0; i < (LOW ? 4 : 6); i++) {
    const s = 12 + rnd() * 15;
    const h = new THREE.Mesh(new THREE.PlaneGeometry(s, s * .55),
      new THREE.MeshBasicMaterial({ map: hazeTex, transparent: true, blending: THREE.AdditiveBlending,
        depthWrite: false, fog: false, opacity: .05 + rnd() * .07 }));
    h.position.set((rnd() - .5) * 44, 1.5 + rnd() * 10, -38 + rnd() * 40);
    h.renderOrder = 4;
    h.userData = { sp: .06 + rnd() * .12, ph: rnd() * TAU, x0: h.position.x };
    scene.add(h); WORLD.haze.push(h);
  }

  /* embers around the lantern and the disc */
  const N = LOW ? 220 : 460;
  const pos = new Float32Array(N * 3), seed = new Float32Array(N);
  for (let i = 0; i < N; i++) {
    pos[i * 3] = (rnd() - .5) * 30; pos[i * 3 + 1] = rnd() * 11; pos[i * 3 + 2] = -26 + rnd() * 36;
    seed[i] = rnd();
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  g.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1));
  const emb = new THREE.Points(g, new THREE.ShaderMaterial({
    uniforms: { uT: WORLD.uT, uTex: { value: tx(texGlow('rgba(255,190,140,1)', 'rgba(255,120,60,.35)')) },
      uSize: { value: vpH() * .5 } },
    transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
    vertexShader:
      'attribute float aSeed; uniform float uT; uniform float uSize; varying float vA;n' +
      'void main(){ vec3 p=position;n' +
      ' p.y = mod(p.y + uT*(0.14+aSeed*0.28), 11.5);n' +
      ' p.x += sin(uT*0.36 + aSeed*22.0)*0.85;n' +
      ' p.z += cos(uT*0.29 + aSeed*17.0)*0.7;n' +
      ' vec4 mv = modelViewMatrix * vec4(p,1.0);n' +
      ' vA = (0.25+aSeed*0.75) * smoothstep(11.5,7.0,p.y) * smoothstep(0.0,1.4,p.y);n' +
      ' gl_PointSize = uSize*(0.010+aSeed*0.020)/max(-mv.z,0.6);n' +
      ' gl_Position = projectionMatrix * mv; }',
    fragmentShader:
      'uniform sampler2D uTex; varying float vA;n' +
      'void main(){ vec4 t=texture2D(uTex, gl_PointCoord);n' +
      ' gl_FragColor = vec4(t.rgb*vec3(1.6,0.78,0.42), t.a*vA*0.75); }'
  }));
  emb.frustumCulled = false; emb.renderOrder = 5;
  scene.add(emb); WORLD.embers = emb;

  /* rain in the slot of open sky */
  if (!LOW) {
    const D = 900, rp = new Float32Array(D * 2 * 3), rt = new Float32Array(D * 2), rs = new Float32Array(D * 2), rl = new Float32Array(D * 2);
    for (let i = 0; i < D; i++) {
      const x = (rnd() - .5) * 40, z = -30 + rnd() * 34, y = rnd() * 17, sp = 7 + rnd() * 9, ln = .30 + rnd() * .55;
      for (let k = 0; k < 2; k++) {
        const o = (i * 2 + k) * 3;
        rp[o] = x; rp[o + 1] = y; rp[o + 2] = z;
        rt[i * 2 + k] = k; rs[i * 2 + k] = sp; rl[i * 2 + k] = ln;
      }
    }
    const rg = new THREE.BufferGeometry();
    rg.setAttribute('position', new THREE.BufferAttribute(rp, 3));
    rg.setAttribute('aTop', new THREE.BufferAttribute(rt, 1));
    rg.setAttribute('aSpeed', new THREE.BufferAttribute(rs, 1));
    rg.setAttribute('aLen', new THREE.BufferAttribute(rl, 1));
    const rain = new THREE.LineSegments(rg, new THREE.ShaderMaterial({
      uniforms: { uT: WORLD.uT },
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
      vertexShader:
        'attribute float aTop; attribute float aSpeed; attribute float aLen; uniform float uT; varying float vA;n' +
        'void main(){ vec3 p=position;n' +
        ' float y = mod(p.y - uT*aSpeed, 17.0);n' +
        ' p.y = y + aTop*aLen;n' +
        ' vA = smoothstep(0.0,3.0,y)*smoothstep(17.0,11.0,y);n' +
        ' gl_Position = projectionMatrix * modelViewMatrix * vec4(p,1.0); }',
      fragmentShader: 'varying float vA;nvoid main(){ gl_FragColor = vec4(0.55,0.74,0.82, vA*0.024); }'
    }));
    rain.frustumCulled = false; rain.renderOrder = 6;
    scene.add(rain); WORLD.rain = rain;
  }

  /* ripples on the standing water */
  const ringC = cvs(256, 256), rx2 = ringC.getContext('2d');
  const rg2 = rx2.createRadialGradient(128, 128, 84, 128, 128, 128);
  rg2.addColorStop(0, 'rgba(255,255,255,0)'); rg2.addColorStop(.62, 'rgba(255,255,255,.55)');
  rg2.addColorStop(.86, 'rgba(255,255,255,.22)'); rg2.addColorStop(1, 'rgba(255,255,255,0)');
  rx2.fillStyle = rg2; rx2.fillRect(0, 0, 256, 256);
  const ringTex = tx(ringC);
  WORLD.ripples = [];
  for (let i = 0; i < (LOW ? 6 : 13); i++) {
    const r = new THREE.Mesh(new THREE.PlaneGeometry(1, 1),
      new THREE.MeshBasicMaterial({ map: ringTex, transparent: true, blending: THREE.AdditiveBlending,
        depthWrite: false, fog: true, opacity: 0 }));
    r.rotation.x = -Math.PI / 2; r.renderOrder = 7;
    r.userData = { t: rnd() * 4, x: (rnd() - .5) * 22, z: -8 + rnd() * 22, sp: .5 + rnd() * .5 };
    scene.add(r); WORLD.ripples.push(r);
  }
}

/* ====================================================== 6b · the leaf fall
   The maples shed all the way through the chapter. The fall is anchored to
   the rig rather than to the court: every leaf keeps a real world position so
   its drift is honest, but any leaf that ends up further than LEAF_R from the
   camera in x or z is wrapped back across it. That way the count is what one
   screenful needs — a hundred and seventy — instead of what filling the whole
   valley would cost, and the frame is never empty however far the walk goes.

   A falling leaf is read by its tumble, not by its path. Each one turns on
   two axes at its own rate, so it presents its face, thins to an edge and
   comes back — which is the whole reason these are instanced quads and not
   points, since a point sprite cannot turn away from the camera. */
/* LEAF_AHEAD/LEAF_SPREAD is the disc leaves are recycled into, hung down the
   camera's own sight line; LEAF_R is only the far backstop for a rig that has
   walked out from under its weather. */
const LEAF_AHEAD = 11, LEAF_SPREAD = 12, LEAF_R = 30;
function buildLeafFall() {
  const N = LOW ? 110 : 260;
  const mat = new THREE.MeshStandardMaterial({
    map: tx(texLeaf()), alphaTest: .42, side: THREE.DoubleSide,
    color: 0x40080a, roughness: .84, metalness: 0,
    /* green and blue driven to nothing: the composite tone-maps at the end,
       and an emissive red with any green in it comes back out of that pink */
    emissive: 0x780200, emissiveIntensity: .72
  });
  const inst = new THREE.InstancedMesh(new THREE.PlaneGeometry(.40, .40), mat, N);
  inst.frustumCulled = false; inst.renderOrder = 5;
  inst.castShadow = inst.receiveShadow = false;
  const rnd = mulberry32(404), list = [];
  for (let i = 0; i < N; i++) list.push({
    x: (rnd() - .5) * 2 * LEAF_R, z: (rnd() - .5) * 2 * LEAF_R, y: rnd() * 26,
    fall: .5 + rnd() * .9,
    sway: .45 + rnd() * 1.5, swayPh: rnd() * TAU, swayAmp: .30 + rnd() * .95,
    spin: (rnd() - .5) * 2.6, roll: rnd() * TAU, rollSp: .5 + rnd() * 2.0,
    tilt: rnd() * TAU, s: .55 + rnd() * .9
  });
  scene.add(inst);
  WORLD.leaves = { mesh: inst, list: list };
}
const LEAF_M = new THREE.Matrix4(), LEAF_Q = new THREE.Quaternion();
const LEAF_E = new THREE.Euler(), LEAF_P = new THREE.Vector3(), LEAF_S = new THREE.Vector3();
const LEAF_F = new THREE.Vector3();
function updateLeaves(dt) {
  const LV = WORLD.leaves; if (!LV) return;
  const cy = camera.position.y, L = LV.list;
  /* Respawn ahead of the rig, not around it. A band centred on the camera
     spends nearly all of itself behind and beside the frustum — of a couple of
     hundred leaves only a dozen were ever on screen. Recycling them into the
     cone the camera is actually looking down puts the same count in frame
     several times over, and costs nothing. */
  camera.getWorldDirection(LEAF_F);
  LEAF_F.y = 0;
  if (LEAF_F.lengthSq() < 1e-6) LEAF_F.set(0, 0, -1); else LEAF_F.normalize();
  const fx = camera.position.x + LEAF_F.x * LEAF_AHEAD;
  const fz = camera.position.z + LEAF_F.z * LEAF_AHEAD;
  const seed = !LV.seeded; LV.seeded = true;
  for (let i = 0; i < L.length; i++) {
    const l = L[i];
    l.y -= l.fall * dt;
    l.roll += l.rollSp * dt;
    l.tilt += l.spin * dt;
    if (seed || l.y < cy - 10) {                /* back to the top of the band */
      l.y = seed ? cy - 10 + Math.random() * 26 : cy + 16;
      const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * LEAF_SPREAD;
      l.x = fx + Math.cos(a) * r;
      l.z = fz + Math.sin(a) * r;
    }
    /* the far wrap is a backstop for a rig that has walked away from its own
       leaves — it sits well outside the fog, so nothing is seen to jump */
    const cx = camera.position.x, cz = camera.position.z;
    if (l.x - cx > LEAF_R) l.x -= 2 * LEAF_R; else if (l.x - cx < -LEAF_R) l.x += 2 * LEAF_R;
    if (l.z - cz > LEAF_R) l.z -= 2 * LEAF_R; else if (l.z - cz < -LEAF_R) l.z += 2 * LEAF_R;
    /* the side-slip of a leaf shedding air off one edge, then the other */
    const sw = Math.sin(clock * l.sway + l.swayPh);
    LEAF_P.set(l.x + sw * l.swayAmp, l.y,
               l.z + Math.cos(clock * l.sway * .7 + l.swayPh) * l.swayAmp * .6);
    LEAF_E.set(l.roll, l.tilt, sw * .55);
    LEAF_Q.setFromEuler(LEAF_E);
    LEAF_S.setScalar(l.s);
    LEAF_M.compose(LEAF_P, LEAF_Q, LEAF_S);
    LV.mesh.setMatrixAt(i, LEAF_M);
  }
  LV.mesh.instanceMatrix.needsUpdate = true;
}

/* ==================================================== 6c · the cursor wisps
   A drift of cold motes that follows the pointer.

   It hangs as a child of the camera, so the pointer maps straight into camera
   space through the frustum's own half-height. Unprojecting onto a world
   plane each frame would instead pin the trail to the court, and the rig is
   always drifting — the wisps would swim across the screen whenever the
   camera moved rather than staying under the hand.

   Emission is by distance travelled, not elapsed time. A slow hand then lays
   a continuous drift and a fast one throws the motes apart, which is how a
   moving source actually sheds them; emitting on a timer gives an evenly
   spaced string of beads at every speed. */
const WISP_D = 3.4;
const WISP = { list: [], i: 0, acc: 0, ex: 0, ey: 0, lx: 0, ly: 0, idle: 0, seen: false };
function buildWisps() {
  if (COARSE) return;
  /* The motes are round, so nothing here is oriented: no per-particle angle,
     no rotated lookup. They are also small — a few pixels of core inside a
     faint halo — which is what lets the count go up without the additive fill
     that a screenful of big sprites costs. */
  const N = LOW ? 90 : 190;
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
  g.setAttribute('aA', new THREE.BufferAttribute(new Float32Array(N), 1));
  g.setAttribute('aS', new THREE.BufferAttribute(new Float32Array(N), 1));
  const pts = new THREE.Points(g, new THREE.ShaderMaterial({
    uniforms: { uTex: { value: tx(texWisp()) }, uPx: { value: vpH() } },
    transparent: true, blending: THREE.AdditiveBlending,
    depthWrite: false, depthTest: false, fog: false,
    vertexShader:
      'attribute float aA; attribute float aS;n' +
      'uniform float uPx; varying float vA;n' +
      'void main(){ vA = aA;n' +
      ' vec4 mv = modelViewMatrix * vec4(position,1.0);n' +
      ' gl_PointSize = uPx * aS / max(-mv.z, 0.4);n' +
      ' gl_Position = projectionMatrix * mv; }',
    fragmentShader:
      'uniform sampler2D uTex; varying float vA;n' +
      'void main(){ if (vA <= 0.0) discard;n' +
      ' vec4 t = texture2D(uTex, gl_PointCoord);n' +
      ' gl_FragColor = vec4(t.rgb, t.a * vA); }'
  }));
  pts.frustumCulled = false; pts.renderOrder = 9;
  pts.layers.set(1);                    /* near the lens: never in the mirror */
  camera.add(pts);
  WISP.mesh = pts;
  for (let i = 0; i < N; i++) WISP.list.push({ life: 0, max: 1, vx: 0, vy: 0, sz: 0, ph: 0 });
}
/* the pointer, in camera space, on the plane the trail hangs on */
function wispPoint(nx, ny) {
  const hh = Math.tan(camera.fov * Math.PI / 360) * WISP_D;
  return [nx * hh * camera.aspect, ny * hh];
}
function updateWisps(dt) {
  if (!WISP.mesh) return;
  const g = WISP.mesh.geometry;
  const P = g.attributes.position.array, A = g.attributes.aA.array;
  const S = g.attributes.aS.array;
  const L = WISP.list, N = L.length;

  const pt = wispPoint(RIG.tmx, RIG.tmy);
  if (!WISP.seen) { WISP.ex = WISP.lx = pt[0]; WISP.ey = WISP.ly = pt[1]; WISP.seen = true; }
  /* the emitter trails the pointer slightly, which is what gives the drift
     its lag on a fast flick instead of snapping rigidly to the cursor */
  WISP.ex = damp(WISP.ex, pt[0], 16, dt);
  WISP.ey = damp(WISP.ey, pt[1], 16, dt);

  const dx = WISP.ex - WISP.lx, dy = WISP.ey - WISP.ly;
  const moved = Math.hypot(dx, dy);
  const ang = moved > 1e-5 ? Math.atan2(dy, dx) : 0;
  const spawn = (x, y, a, weak) => {
    const i = WISP.i; WISP.i = (i + 1) % N;      /* slot and state must pair:
       advancing first would put the position in the next slot and the life in
       this one, and every mote appears where the one before it started */
    const w = L[i], k = i * 3;
    /* The plane the trail hangs on is only about 2.2 units tall, so the
       scatter has to be read against that: a few hundredths of a unit is a
       thread stitched to the cursor, not a drift of motes. */
    P[k] = x + (Math.random() + Math.random() - 1) * .30;
    P[k + 1] = y + (Math.random() + Math.random() - 1) * .30;
    P[k + 2] = -WISP_D + (Math.random() - .5) * .9;
    w.life = 0; w.max = (weak ? 2.1 : 1.45) + Math.random() * 1.3;
    w.vx = -Math.cos(a) * .09 + (Math.random() - .5) * .38;
    w.vy = -Math.sin(a) * .09 + (Math.random() - .5) * .32 + .02;
    w.sz = (weak ? .018 : .024) + Math.random() * .026;
    w.ph = Math.random() * TAU;
  };

  WISP.acc += moved;
  const STEP = .030;
  let guard = 0;
  while (WISP.acc >= STEP && guard++ < 14) {
    WISP.acc -= STEP;
    /* lay each one at the distance along the segment it is actually owed, so a
       flick draws a spaced-out line instead of a clump at one end of it */
    const t = moved > 1e-6 ? Math.min(1, guard * STEP / moved) : 0;
    spawn(WISP.lx + dx * t, WISP.ly + dy * t, ang, false);
  }
  /* the faintest breath when the hand is still, so the cursor keeps an aura.
     Rarely, and barely buoyant: emit often here and a stationary pointer
     grows a permanent column of smoke up the middle of the frame. */
  WISP.idle += dt;
  if (WISP.idle > .42) { WISP.idle = 0; spawn(WISP.ex, WISP.ey, Math.random() * TAU, true); }
  WISP.lx = WISP.ex; WISP.ly = WISP.ey;

  for (let i = 0; i < N; i++) {
    const w = L[i], k = i * 3;
    if (w.life >= w.max) { A[i] = 0; continue; }
    w.life += dt;
    const u = w.life / w.max;
    /* curl, so the drift frays rather than blowing along one straight line */
    P[k]     += (w.vx + Math.sin(clock * 1.3 + w.ph) * .17) * dt;
    P[k + 1] += (w.vy + Math.cos(clock * 1.1 + w.ph * 1.7) * .14) * dt;
    /* light damping: they have to coast to carry the scatter outward, where
       the old rate stopped every mote within a tenth of a unit of its spawn */
    w.vx *= 1 - .5 * dt; w.vy = w.vy * (1 - .5 * dt) + .022 * dt;    /* it rises */
    A[i] = smooth(0, .12, u) * (1 - smooth(.22, 1, u)) * .9;
    S[i] = w.sz * (1 + u * .55);       /* a mote softens, it does not swell */
  }
  g.attributes.position.needsUpdate = true;
  g.attributes.aA.needsUpdate = true;
  g.attributes.aS.needsUpdate = true;
}

/* ====================================================== cloth · the plates
   Canvas UI's Cloth, ported off React and off the experimental html-in-canvas
   path it ships with. That path exists so the effect can hang arbitrary live
   HTML on the fabric — it captures the subtree with drawElementImage(), which
   is Chrome-behind-a-flag only, and the component falls back to a flat render
   everywhere else.

   Here the fabric only ever carries a still, so the capture is not needed at
   all: the plate is drawn cover-cropped into a 2-D canvas with the card's own
   two scrims baked on, and that canvas is uploaded straight to the texture.
   The simulation, both shader passes and the option set are the component's,
   unchanged. The result is that the effect runs in every WebGL2 browser
   rather than one flag.

   The grid is the component's 96 squared. Three of these on one page is a
   third of a million cell updates a second, so each one hard-stops when its
   card leaves the viewport and again once the wind dies and the fabric has
   settled — both already in the component, and both load-bearing here. */
const CLOTH_VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aGrid;
layout(location = 1) in vec4 aData;
layout(location = 2) in vec2 aOffset;
uniform vec2 uRes; uniform vec2 uOut; uniform float uBleed; uniform float uFocal;
out vec2 vUv; out vec3 vNormal; out float vFold; out vec2 vLocal;
void main () {
  vUv = aGrid;
  float z = aData.x;
  vec2 nxy = aData.yz;
  vNormal = vec3(nxy, sqrt(max(1.0 - dot(nxy, nxy), 0.04)));
  vFold = aData.w;
  vLocal = aGrid * uRes;
  vec2 px = vLocal + aOffset + vec2(uBleed);
  vec2 ndc = (px / uOut) * 2.0 - 1.0;
  ndc.y = -ndc.y;
  float w = (uFocal - z) / uFocal;
  gl_Position = vec4(ndc, -z / uFocal, w);
}`;
const CLOTH_SDF = `
float fabricDist (vec2 p, vec2 size, float radius) {
  vec2 half_ = size * 0.5;
  float r = min(radius, min(half_.x, half_.y));
  vec2 q = abs(p - half_) - (half_ - vec2(r));
  return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r;
}`;
const CLOTH_FRAG = `#version 300 es
precision highp float;
in vec2 vUv; in vec3 vNormal; in float vFold; in vec2 vLocal;
out vec4 outColor;
uniform sampler2D uContent; uniform float uMaxX; uniform float uLight;
uniform float uSheen; uniform vec3 uBacking; uniform vec2 uRes;
uniform float uRadius; uniform float uDark; uniform float uEdge;
${CLOTH_SDF}
void main () {
  vec2 uv = clamp(vUv, vec2(0.001), vec2(uMaxX - 0.001, 0.999));
  vec4 tex = texture(uContent, uv);
  vec3 fabric = mix(uBacking, tex.rgb, tex.a);
  vec3 n = normalize(vNormal);
  vec3 lightDir = normalize(vec3(-0.3, 0.42, 0.86));
  float diffFlat = 0.58 + 0.42 * lightDir.z;
  float diff = 0.58 + 0.42 * dot(n, lightDir);
  float shade = mix(1.0, (diff / diffFlat) * vFold, uLight);
  vec3 lit = fabric * shade;
  vec3 halfway = normalize(lightDir + vec3(0.0, 0.0, 1.0));
  float specFlat = pow(halfway.z, 34.0);
  float spec = max(pow(max(dot(n, halfway), 0.0), 34.0) - specFlat, 0.0) / (1.0 - specFlat);
  lit += uSheen * spec * mix(vec3(1.0), fabric, 0.35);
  float broadFlat = pow(halfway.z, 6.0);
  float broad = max(pow(max(dot(n, halfway), 0.0), 6.0) - broadFlat, 0.0) / (1.0 - broadFlat);
  lit += uDark * uLight * 0.3 * broad * vec3(1.0);
  float d = fabricDist(vLocal, uRes, uRadius);
  float hemT = smoothstep(0.0, 6.0, -d);
  lit *= mix(1.0, mix(0.93, 1.0, hemT), uLight * (1.0 - uDark));
  lit += vec3(uDark * uLight * 0.08 * (1.0 - hemT));
  /* The card's outline, drawn here rather than as a rule on the box: off the
     same distance field that cuts the fabric out, so it rides every fold,
     takes the perspective with it and rounds itself on the corners. A CSS
     outline can only ever trace the flat rectangle the cloth has left. */
  /* A hairline: the band is under a pixel wide and pulled tight against the
     cut, so it reads as an edge on the fabric rather than a drawn stroke. */
  float rim = smoothstep(1.05, 0.2, abs(d + 0.7));
  lit += rim * uEdge * vec3(0.874, 0.906, 0.878);

  float alpha = clamp(0.5 - d, 0.0, 1.0);
  outColor = vec4(clamp(lit, 0.0, 1.0), 1.0) * alpha;
}`;
const CLOTH_SHADOW_VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aGrid;
layout(location = 1) in vec4 aData;
layout(location = 2) in vec2 aOffset;
uniform vec2 uRes; uniform vec2 uOut; uniform float uBleed;
out vec2 vLocal; out float vLift;
void main () {
  float z = aData.x;
  vLift = z;
  vLocal = aGrid * uRes;
  vec2 px = vLocal + aOffset + vec2(uBleed) + vec2(10.0, 14.0) + vec2(0.3, 0.42) * z;
  vec2 ndc = (px / uOut) * 2.0 - 1.0;
  ndc.y = -ndc.y;
  gl_Position = vec4(ndc, 0.0, 1.0);
}`;
const CLOTH_SHADOW_FRAG = `#version 300 es
precision highp float;
in vec2 vLocal; in float vLift;
out vec4 outColor;
uniform float uShadow; uniform vec2 uRes; uniform float uRadius; uniform float uDark;
${CLOTH_SDF}
void main () {
  float d = fabricDist(vLocal, uRes, uRadius);
  float a = uShadow * smoothstep(0.0, 30.0, -d);
  a *= mix(1.0, 0.55, clamp(vLift / 50.0, 0.0, 1.0));
  a *= mix(1.0, 0.55, uDark);
  outColor = vec4(vec3(uDark) * a, a);
}`;

const CL_SEG = 96, CL_NODES = CL_SEG + 1, CL_DT = 1 / 120;
const CL_WAVE = 30, CL_STIFF = 0.55, CL_GAIN = 5.0, CL_BLEED = 48;
const CLOTH_DEFAULTS = {
  pin: 'top', wind: 3, speed: .5, amplitude: 30, drape: 40, brush: 2.05,
  brushSize: 150, damping: 1, light: .5, sheen: .1, shadow: .25,
  cornerRadius: 20, backing: 'auto', perspective: 1200
};

function createCloth(output, plate, options) {
  const config = Object.assign({}, CLOTH_DEFAULTS, options || {});
  const wrapper = output.parentElement || output;
  output.style.top = output.style.left = -CL_BLEED + 'px';
  output.style.width = 'calc(100% + ' + CL_BLEED * 2 + 'px)';
  output.style.height = 'calc(100% + ' + CL_BLEED * 2 + 'px)';

  const gl = output.getContext('webgl2', { alpha: true, depth: false, stencil: false,
    antialias: true, premultipliedAlpha: true });
  if (!gl || gl.isContextLost()) return null;

  const compile = (type, text) => {
    const sh = gl.createShader(type);
    gl.shaderSource(sh, text); gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) console.error('Cloth:', gl.getShaderInfoLog(sh));
    return sh;
  };
  const link = (v, f) => {
    const prog = gl.createProgram();
    const vs = compile(gl.VERTEX_SHADER, v), fs = compile(gl.FRAGMENT_SHADER, f);
    gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog);
    const u = {}, n = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(prog, i);
      u[info.name] = gl.getUniformLocation(prog, info.name); }
    return { program: prog, vert: vs, frag: fs, uniforms: u };
  };
  const cloth = link(CLOTH_VERT, CLOTH_FRAG);
  const shadow = link(CLOTH_SHADOW_VERT, CLOTH_SHADOW_FRAG);

  const gridVerts = new Float32Array(CL_NODES * CL_NODES * 2);
  for (let y = 0; y < CL_NODES; y++) for (let x = 0; x < CL_NODES; x++) {
    const i = (y * CL_NODES + x) * 2;
    gridVerts[i] = x / CL_SEG; gridVerts[i + 1] = y / CL_SEG;
  }
  const idx = new Uint32Array(CL_SEG * CL_SEG * 6);
  let o = 0;
  for (let y = 0; y < CL_SEG; y++) for (let x = 0; x < CL_SEG; x++) {
    const a = y * CL_NODES + x, b = a + 1, c = a + CL_NODES, d = c + 1;
    idx[o++] = a; idx[o++] = c; idx[o++] = b; idx[o++] = b; idx[o++] = c; idx[o++] = d;
  }
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);
  const gridBuf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
  gl.bufferData(gl.ARRAY_BUFFER, gridVerts, gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
  const dataBuf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, dataBuf);
  gl.bufferData(gl.ARRAY_BUFFER, CL_NODES * CL_NODES * 16, gl.DYNAMIC_DRAW);
  gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0);
  const offBuf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, offBuf);
  gl.bufferData(gl.ARRAY_BUFFER, CL_NODES * CL_NODES * 8, gl.DYNAMIC_DRAW);
  gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 0, 0);
  const idxBuf = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
  gl.bindVertexArray(null);

  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
    new Uint8Array([0, 0, 0, 0]));

  /* the one place this differs from the component: the plate goes straight in */
  function upload() {
    const c = plate();
    if (!c || !c.width) return;
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
  }

  function syncSize() {
    const dpr = Math.min(devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.round(output.clientWidth * dpr));
    const h = Math.max(1, Math.round(output.clientHeight * dpr));
    if (output.width !== w || output.height !== h) { output.width = w; output.height = h; }
  }

  let hCur = new Float32Array(CL_NODES * CL_NODES);
  let hPrev = new Float32Array(CL_NODES * CL_NODES);
  let hNext = new Float32Array(CL_NODES * CL_NODES);
  const vData = new Float32Array(CL_NODES * CL_NODES * 4);
  const oData = new Float32Array(CL_NODES * CL_NODES * 2);
  const zF = new Float32Array(CL_NODES * CL_NODES);
  const rowF = new Float32Array(CL_NODES), colF = new Float32Array(CL_NODES);
  const hang = new Float32Array(CL_NODES);
  for (let a = 0; a < CL_NODES; a++) hang[a] = Math.pow(a / CL_SEG, 1.3);

  let simTime = Math.random() * 60, gust = .5, energy = 1;
  let edge = .048, edgeTo = .048;   /* barely there at rest, found on hover */
  const ptr = { x: -1e5, y: -1e5, inside: false };
  const touch = { x: -1e5, y: -1e5, vx: 0, vy: 0, s: 0 };
  const axisA = (x, y) => config.pin === 'top' ? y : config.pin === 'bottom' ? CL_SEG - y
    : config.pin === 'left' ? x : CL_SEG - x;
  const axisB = (x, y) => (config.pin === 'top' || config.pin === 'bottom') ? x : y;

  function stepSim(dt) {
    simTime += dt * Math.max(config.speed, 0);
    const t = simTime, windAmp = CL_GAIN * Math.max(config.wind, 0) * gust;
    const kb1 = (Math.PI * 2) / (CL_SEG / 1.5), kb2 = (Math.PI * 2) / (CL_SEG / 3.8);
    const ka = (Math.PI * 2) / (CL_SEG / 2.2);
    const w1 = CL_WAVE * kb1, w2 = CL_WAVE * kb2, drift = 1.8 * Math.sin(.23 * t);
    for (let b = 0; b < CL_NODES; b++)
      rowF[b] = Math.sin(kb1 * b - w1 * t + drift) + .45 * Math.sin(kb2 * b + w2 * t * .8 + 3);
    for (let a = 0; a < CL_NODES; a++)
      colF[a] = (.7 + .3 * Math.sin(ka * a - 1.7 * t)) * hang[a];
    const c2 = CL_WAVE * CL_WAVE, dt2 = dt * dt;
    const decay = Math.exp(-Math.min(Math.max(config.damping, .05), 8) * dt);
    for (let y = 0; y < CL_NODES; y++) {
      const up = Math.max(y - 1, 0) * CL_NODES, down = Math.min(y + 1, CL_SEG) * CL_NODES;
      const row = y * CL_NODES;
      for (let x = 0; x < CL_NODES; x++) {
        const i = row + x;
        const h = hCur[i];
        const lap = hCur[row + Math.max(x - 1, 0)] + hCur[row + Math.min(x + 1, CL_SEG)]
          + hCur[up + x] + hCur[down + x] - 4 * h;
        const force = windAmp * rowF[axisB(x, y)] * colF[axisA(x, y)];
        const next = 2 * h - hPrev[i] + dt2 * (c2 * lap - CL_STIFF * h + force);
        let v = h + (next - h) * decay;
        if (v > 3.5) v = 3.5; else if (v < -3.5) v = -3.5;
        hNext[i] = v;
      }
    }
    for (let b = 0; b < CL_NODES; b++) {
      let x = b, y = 0;
      if (config.pin === 'bottom') y = CL_SEG;
      else if (config.pin === 'left') { x = 0; y = b; }
      else if (config.pin === 'right') { x = CL_SEG; y = b; }
      hNext[y * CL_NODES + x] = 0;
    }
    const spent = hPrev; hPrev = hCur; hCur = hNext; hNext = spent;
  }

  function imprint(delta, width, height) {
    if (config.brush <= 0 || touch.s < .01) return;
    const cw = width / CL_SEG, ch = height / CL_SEG;
    const rx = Math.max(config.brushSize, 12) / cw, ry = Math.max(config.brushSize, 12) / ch;
    const gx = touch.x / cw, gy = touch.y / ch;
    const x0 = Math.max(Math.ceil(gx - 2.5 * rx), 0), x1 = Math.min(Math.floor(gx + 2.5 * rx), CL_SEG);
    const y0 = Math.max(Math.ceil(gy - 2.5 * ry), 0), y1 = Math.min(Math.floor(gy + 2.5 * ry), CL_SEG);
    const lift = 1.1 * Math.min(config.brush, 3) * touch.s, rate = Math.min(delta * 4, 1);
    for (let y = y0; y <= y1; y++) {
      const oy = (y - gy) / ry, row = y * CL_NODES;
      for (let x = x0; x <= x1; x++) {
        const ox = (x - gx) / rx, g = Math.exp(-(ox * ox + oy * oy));
        if (g < .02) continue;
        const i = row + x, pull = rate * g, goal = lift * g;
        hCur[i] += (goal - hCur[i]) * pull;
        hPrev[i] += (goal - hPrev[i]) * pull;
      }
    }
  }

  function foreshorten(stride, lineStride, ds, anchor, comp) {
    const ds2 = ds * ds;
    for (let l = 0; l < CL_NODES; l++) {
      const base = l * lineStride;
      oData[(base + anchor * stride) * 2 + comp] = 0;
      let cum = 0;
      for (let k = anchor + 1; k < CL_NODES; k++) {
        const i = base + k * stride, dz = zF[i] - zF[i - stride];
        cum += ds - Math.sqrt(Math.max(ds2 - dz * dz, 0));
        oData[i * 2 + comp] = -cum;
      }
      cum = 0;
      for (let k = anchor - 1; k >= 0; k--) {
        const i = base + k * stride, dz = zF[i] - zF[i + stride];
        cum += ds - Math.sqrt(Math.max(ds2 - dz * dz, 0));
        oData[i * 2 + comp] = cum;
      }
    }
  }

  function compose(width, height) {
    const amp = Math.max(config.amplitude, 0);
    const drape = config.drape * (.3 + .7 * gust);
    const cw = width / CL_SEG, ch = height / CL_SEG;
    let e = 0;
    for (let y = 0; y < CL_NODES; y++) {
      const row = y * CL_NODES;
      for (let x = 0; x < CL_NODES; x++) {
        const i = row + x, h = hCur[i];
        if (Math.abs(h) > e) e = Math.abs(h);
        zF[i] = amp * Math.tanh(h) + drape * hang[axisA(x, y)];
      }
    }
    energy = e;
    for (let y = 0; y < CL_NODES; y++) {
      const up = Math.max(y - 1, 0) * CL_NODES, down = Math.min(y + 1, CL_SEG) * CL_NODES;
      const row = y * CL_NODES;
      for (let x = 0; x < CL_NODES; x++) {
        const i = row + x;
        const l = row + Math.max(x - 1, 0), r = row + Math.min(x + 1, CL_SEG);
        const dzdx = (zF[r] - zF[l]) / (2 * cw), dzdy = (zF[down + x] - zF[up + x]) / (2 * ch);
        const inv = 1 / Math.hypot(dzdx, dzdy, 1);
        const curve = zF[l] + zF[r] + zF[up + x] + zF[down + x] - 4 * zF[i];
        let fold = 1 - curve * .01;
        if (fold < .86) fold = .86; else if (fold > 1.06) fold = 1.06;
        const q = i * 4;
        vData[q] = zF[i]; vData[q + 1] = -dzdx * inv; vData[q + 2] = -dzdy * inv; vData[q + 3] = fold;
      }
    }
    const mid = CL_SEG >> 1;
    if (config.pin === 'top' || config.pin === 'bottom') {
      foreshorten(CL_NODES, 1, ch, config.pin === 'top' ? 0 : CL_SEG, 1);
      foreshorten(1, CL_NODES, cw, mid, 0);
    } else {
      foreshorten(1, CL_NODES, cw, config.pin === 'left' ? 0 : CL_SEG, 0);
      foreshorten(CL_NODES, 1, ch, mid, 1);
    }
  }

  const backing = config.backing === 'auto' ? [.02, .026, .035] : config.backing;

  function draw() {
    const resW = Math.max(wrapper.clientWidth, 1), resH = Math.max(wrapper.clientHeight, 1);
    const outW = Math.max(output.clientWidth, 1), outH = Math.max(output.clientHeight, 1);
    const light = Math.min(Math.max(config.light, 0), 1);
    const radius = Math.max(config.cornerRadius, 0);
    const lum = .299 * backing[0] + .587 * backing[1] + .114 * backing[2];
    const dark = Math.min(Math.max((.5 - lum) / .35, 0), 1);

    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, output.width, output.height);
    gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

    gl.bindVertexArray(vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, dataBuf); gl.bufferSubData(gl.ARRAY_BUFFER, 0, vData);
    gl.bindBuffer(gl.ARRAY_BUFFER, offBuf); gl.bufferSubData(gl.ARRAY_BUFFER, 0, oData);

    gl.useProgram(shadow.program);
    gl.uniform2f(shadow.uniforms.uRes, resW, resH);
    gl.uniform2f(shadow.uniforms.uOut, outW, outH);
    gl.uniform1f(shadow.uniforms.uBleed, CL_BLEED);
    gl.uniform1f(shadow.uniforms.uShadow, Math.min(Math.max(config.shadow, 0), 1));
    gl.uniform1f(shadow.uniforms.uRadius, radius);
    gl.uniform1f(shadow.uniforms.uDark, dark);
    gl.drawElements(gl.TRIANGLES, idx.length, gl.UNSIGNED_INT, 0);

    gl.useProgram(cloth.program);
    gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.uniform1i(cloth.uniforms.uContent, 0);
    gl.uniform2f(cloth.uniforms.uRes, resW, resH);
    gl.uniform2f(cloth.uniforms.uOut, outW, outH);
    gl.uniform1f(cloth.uniforms.uBleed, CL_BLEED);
    gl.uniform1f(cloth.uniforms.uFocal, Math.max(config.perspective, 200));
    gl.uniform1f(cloth.uniforms.uMaxX, 1);
    gl.uniform1f(cloth.uniforms.uLight, light);
    gl.uniform1f(cloth.uniforms.uSheen, Math.max(config.sheen, 0));
    gl.uniform1f(cloth.uniforms.uRadius, radius);
    gl.uniform1f(cloth.uniforms.uDark, dark);
    gl.uniform1f(cloth.uniforms.uEdge, edge);
    gl.uniform3f(cloth.uniforms.uBacking, backing[0], backing[1], backing[2]);
    gl.drawElements(gl.TRIANGLES, idx.length, gl.UNSIGNED_INT, 0);
    gl.bindVertexArray(null);
  }

  let raf = 0, last = performance.now(), debt = 0, running = false, visible = false, dead = false;
  function frame(now) {
    if (dead) return;
    if (!visible) { running = false; return; }
    const delta = Math.min((now - last) / 1000, 1 / 20);
    last = now;
    const width = Math.max(wrapper.clientWidth, 1), height = Math.max(wrapper.clientHeight, 1);
    if (!REDUCE) {
      const t = simTime;
      const target = Math.max(.55 + .35 * Math.sin(t * .31 + 1.3)
        + .25 * Math.sin(t * .83) * (.5 + .5 * Math.sin(t * .17)), .15);
      gust += (target - gust) * Math.min(delta * 2, 1);
      const sT = ptr.inside && config.brush > 0 ? 1 : 0;
      touch.s += (sT - touch.s) * Math.min(delta * (ptr.inside ? 8 : 2.5), 1);
      const om = 14;
      touch.vx += ((ptr.x - touch.x) * om * om - 2 * om * touch.vx) * delta;
      touch.vy += ((ptr.y - touch.y) * om * om - 2 * om * touch.vy) * delta;
      touch.x += touch.vx * delta; touch.y += touch.vy * delta;
      imprint(delta, width, height);
      edge += (edgeTo - edge) * Math.min(delta * 5, 1);
      debt = Math.min(debt + delta, CL_DT * 5);
      while (debt >= CL_DT) { stepSim(CL_DT); debt -= CL_DT; }
    }
    compose(width, height);
    draw();
    if (REDUCE || (config.wind <= .001 && energy < .004 && touch.s < .01)) { running = false; return; }
    raf = requestAnimationFrame(frame);
  }
  function start() {
    if (dead || running || !visible) return;
    running = true; last = performance.now(); raf = requestAnimationFrame(frame);
  }

  const ro = new ResizeObserver(() => { syncSize(); upload(); start(); });
  ro.observe(output);
  const io = new IntersectionObserver(es => {
    visible = es[es.length - 1] ? es[es.length - 1].isIntersecting : false;
    if (visible) start();
  });
  io.observe(output);
  const onMove = e => {
    const r = wrapper.getBoundingClientRect();
    const x = e.clientX - r.left, y = e.clientY - r.top;
    if (touch.s < .01) { touch.x = x; touch.y = y; touch.vx = touch.vy = 0; }
    ptr.x = x; ptr.y = y; ptr.inside = true; start();
  };
  const onLeave = () => { ptr.inside = false; };
  wrapper.addEventListener('pointermove', onMove, { passive: true });
  wrapper.addEventListener('pointerleave', onLeave, { passive: true });
  const onHidden = () => { if (document.hidden) { running = false; cancelAnimationFrame(raf); } else start(); };
  document.addEventListener('visibilitychange', onHidden);

  syncSize(); upload(); compose(Math.max(wrapper.clientWidth, 1), Math.max(wrapper.clientHeight, 1));
  return { refresh(){ syncSize(); upload(); start(); }, wake: start,
           setEdge(v){ edgeTo = v; start(); } };
}

/* the plate: the card's own still, cover-cropped, carrying the two scrims the
   CSS used to lay over it — baked in so they ripple with the cloth instead of
   sitting flat on top of it */
function clothPlate(img, w, h) {
  const c = cvs(Math.max(1, w | 0), Math.max(1, h | 0)), x = c.getContext('2d');
  const s = Math.max(c.width / img.width, c.height / img.height);
  const dw = img.width * s, dh = img.height * s;
  x.drawImage(img, (c.width - dw) / 2, (c.height - dh) / 2, dw, dh);
  let g = x.createLinearGradient(0, 0, 0, c.height);
  g.addColorStop(.36, 'rgba(3,6,9,.05)'); g.addColorStop(1, 'rgba(3,6,9,.73)');
  x.fillStyle = g; x.fillRect(0, 0, c.width, c.height);
  g = x.createLinearGradient(0, 0, 0, c.height);
  g.addColorStop(.46, 'rgba(4,6,9,0)'); g.addColorStop(1, 'rgba(4,6,9,.80)');
  x.fillStyle = g; x.fillRect(0, 0, c.width, c.height);
  return c;
}

function buildCardCloth() {
  if (COARSE) return;                     /* no pointer to brush it with */
  $$('.cards .card-fr').forEach(fr => {
    const url = (getComputedStyle(fr).backgroundImage.match(/url(["']?([^"')]+)/) || [])[1];
    if (!url) return;
    const img = new Image();
    img.onload = () => {
      const dpr = Math.min(devicePixelRatio || 1, 2);
      let plate = null, pw = 0, ph = 0;
      const get = () => {
        const w = Math.round(fr.clientWidth * dpr), h = Math.round(fr.clientHeight * dpr);
        if (!plate || w !== pw || h !== ph) { plate = clothPlate(img, w, h); pw = w; ph = h; }
        return plate;
      };
      const out = document.createElement('canvas');
      out.className = 'cloth-out'; out.setAttribute('aria-hidden', 'true');
      fr.appendChild(out);
      const inst = createCloth(out, get, { wind: 3, speed: .5, amplitude: 30, drape: 40,
        brush: 2.05, brushSize: 150, damping: 1, light: .5, sheen: .1, shadow: .25,
        cornerRadius: 20, perspective: 1200, pin: 'top' });
      /* only hide the card's own painting once the fabric is really carrying it */
      if (!inst) { out.remove(); return; }
      fr.classList.add('on-cloth');
      const card = fr.closest('.card') || fr;
      card.addEventListener('pointerenter', () => inst.setEdge(.185), { passive: true });
      card.addEventListener('pointerleave', () => inst.setEdge(.048), { passive: true });
    };
    img.onerror = () => {};
    img.src = url;
  });
}

function buildLights() {
  scene.add(new THREE.HemisphereLight(0x53838f, 0x060a08, .13));

  const key = new THREE.DirectionalLight(0xb6dbe4, 1.22);
  key.position.set(2.6, 21, 2.5);
  key.target.position.set(0, 2.2, -12.5); scene.add(key.target);
  if (WANT_SHADOW) {
    key.castShadow = true;
    const S = LOW ? 1024 : 2048;
    key.shadow.mapSize.set(S, S);
    const c = key.shadow.camera;
    c.left = -26; c.right = 26; c.top = 34; c.bottom = -16; c.near = 3; c.far = 78;
    key.shadow.bias = -0.0012; key.shadow.normalBias = .035; key.shadow.radius = 2.2;
  }
  scene.add(key); WORLD.key = key;

  /* moonlight: no shadow, no fill, purely a rim down the right-hand slope of
     every roof — without it the hall is a flat black cut-out against the sky */
  const moonKey = new THREE.DirectionalLight(0xff6a42, .52);
  moonKey.position.set(26, 30, -60);
  moonKey.target.position.set(0, 8, -40); scene.add(moonKey.target);
  scene.add(moonKey);

  /* The hall's own lamps. They light the podium and the top of the flight and
     nothing else — a range that reaches the roof turns the whole building into
     a paper lantern and the silhouette disappears. */
  const hallL = new THREE.PointLight(0xff8a26, 2.3, 15, 2);
  hallL.position.set(0, PODIUM + 1.2, TEMPLE_Z + 8.6); scene.add(hallL); WORLD.hallLight = hallL;
  [-1, 1].forEach(s => {
    const w = new THREE.PointLight(0xff8420, 2.2, 11, 2);
    w.position.set(s * 11.4, PODIUM + 1.2, TEMPLE_Z + 5.6); scene.add(w);
  });

  /* the moon throws almost nothing, but a trace of red high on the right
     keeps it attached to the scene instead of floating on top of it */
  const moonL = new THREE.PointLight(0xff3a1c, 3.0, 46, 2);
  moonL.position.set(11.0, 17.0, -24.0); scene.add(moonL); WORLD.moonLight = moonL;

  const fill = new THREE.PointLight(0x86c6d2, 0.95, 30, 2);
  fill.position.set(-1, 13.5, -16.0); scene.add(fill);

  /* the flight is the spine of the composition — without a lamp of its own it
     falls into the same black as the ground and the eye has nothing to climb */
  const stairL = new THREE.PointLight(0xffa049, 4.2, 17, 2);
  stairL.position.set(0, 7.6, -26.0); scene.add(stairL);
}
/* ====================================================== 6 · planar mirror */
/* ================================================== 7 · post-processing */
const POST = { levels: [] };
const QUAD_VS = 'varying vec2 vUv;nvoid main(){ vUv = uv; gl_Position = vec4( position.xy, 0.0, 1.0 ); }';

function initPost() {
  POST.cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  POST.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), null);
  POST.quad.frustumCulled = false;
  POST.qScene = new THREE.Scene(); POST.qScene.add(POST.quad);
  POST.up = new THREE.ShaderMaterial({
    uniforms: { tS: { value: null }, uAmt: { value: 1 } }, vertexShader: QUAD_VS,
    fragmentShader: 'uniform sampler2D tS; uniform float uAmt; varying vec2 vUv;nvoid main(){ gl_FragColor = vec4(texture2D(tS,vUv).rgb*uAmt, 1.0); }',
    blending: THREE.AdditiveBlending, transparent: true, depthTest: false, depthWrite: false
  });
  if (!WANT_POST) return;
  const w = renderer.domElement.width, h = renderer.domElement.height;
  const O = { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, type: THREE.HalfFloatType, depthBuffer: false, stencilBuffer: false };
  POST.scene = new THREE.WebGLRenderTarget(w, h, Object.assign({}, O, { depthBuffer: true, samples: LOW ? 0 : 2 }));
  let lw = Math.max(2, w >> 1), lh = Math.max(2, h >> 1);
  const N = 4;
  for (let i = 0; i < N; i++) {
    POST.levels.push({ a: new THREE.WebGLRenderTarget(lw, lh, O), b: new THREE.WebGLRenderTarget(lw, lh, O), w: lw, h: lh });
    lw = Math.max(2, lw >> 1); lh = Math.max(2, lh >> 1);
  }
  POST.bright = new THREE.ShaderMaterial({
    uniforms: { tS: { value: null }, uThr: { value: .86 }, uKnee: { value: .50 } },
    vertexShader: QUAD_VS,
    fragmentShader:
      'uniform sampler2D tS; uniform float uThr; uniform float uKnee; varying vec2 vUv;n' +
      'void main(){ vec3 c = texture2D(tS, vUv).rgb;n' +
      ' float l = dot(c, vec3(0.2126,0.7152,0.0722));n' +
      ' float k = smoothstep(uThr, uThr+uKnee, l);n' +
      ' gl_FragColor = vec4(c*k, 1.0); }'
  });
  POST.blur = new THREE.ShaderMaterial({
    uniforms: { tS: { value: null }, uDir: { value: new THREE.Vector2(1, 0) } },
    vertexShader: QUAD_VS,
    fragmentShader:
      'uniform sampler2D tS; uniform vec2 uDir; varying vec2 vUv;n' +
      'void main(){ vec3 c = texture2D(tS, vUv).rgb * 0.2270270270;n' +
      ' c += texture2D(tS, vUv + uDir*1.3846153846).rgb * 0.3162162162;n' +
      ' c += texture2D(tS, vUv - uDir*1.3846153846).rgb * 0.3162162162;n' +
      ' c += texture2D(tS, vUv + uDir*3.2307692308).rgb * 0.0702702703;n' +
      ' c += texture2D(tS, vUv - uDir*3.2307692308).rgb * 0.0702702703;n' +
      ' gl_FragColor = vec4(c, 1.0); }'
  });
  POST.comp = new THREE.ShaderMaterial({
    uniforms: {
      tS: { value: null }, tB: { value: null }, uRes: { value: new THREE.Vector2(w, h) },
      uT: { value: 0 }, uBloom: { value: .34 }, uCA: { value: 1 }, uGrain: { value: .020 },
      uVig: { value: 1 }, uExp: { value: .62 }, uFade: { value: 1 }, uSat: { value: 1.05 }
    },
    vertexShader: QUAD_VS,
    fragmentShader:
      'uniform sampler2D tS; uniform sampler2D tB; uniform vec2 uRes;n' +
      'uniform float uT, uBloom, uCA, uGrain, uVig, uExp, uFade, uSat;n' +
      'varying vec2 vUv;n' +
      'vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0.0, 1.0); }n' +
      'void main(){n' +
      ' vec2 d = vUv - 0.5; float r2 = dot(d,d);n' +
      ' float ca = uCA * (0.30 + r2*2.6) * 0.0013;n' +
      ' vec3 c;n' +
      ' c.r = texture2D(tS, vUv + d*ca).r;n' +
      ' c.g = texture2D(tS, vUv).g;n' +
      ' c.b = texture2D(tS, vUv - d*ca).b;n' +
      ' c += texture2D(tB, vUv).rgb * uBloom;n' +
      ' c *= uExp;n' +
      ' c = aces(c);n' +
      ' float l = dot(c, vec3(0.2126,0.7152,0.0722));n' +
      ' c = mix(vec3(l), c, uSat);n' +
      ' c = mix(c, c*vec3(0.74,1.03,1.11), smoothstep(0.55,0.0,l)*0.80);n' +    /* teal shadows */
      ' c = mix(c, c*vec3(1.035,0.995,0.968), smoothstep(0.50,1.0,l)*0.26);n' + /* warm highs   */
      ' float v = smoothstep(1.22, 0.26, length(d*vec2(1.0,0.94))*1.42);n' +
      ' c *= mix(1.0, v, uVig);n' +
      ' float g = fract(sin(dot(vUv*uRes + uT*137.0, vec2(12.9898,78.233)))*43758.5453);n' +
      ' c += (g-0.5)*uGrain;n' +
      ' c *= uFade;n' +
      ' vec3 e = pow(max(c,0.0), vec3(1.0/2.2));n' +
      /* A touch of contrast, applied after the encode rather than before it:
         in linear the same curve mostly crushes the shadows, and this frame is
         nearly all shadow. Pivoted low, at 0.30, so it deepens the night
         without pulling the lit paper of the hall down with it. */
      ' e = clamp((e - 0.30) * 1.00 + 0.30, 0.0, 1.0);n' +
      ' gl_FragColor = vec4( e, 1.0 );n' +
      '}'
  });
}
function pass(mat, target, additive) {
  POST.quad.material = mat;
  renderer.setRenderTarget(target || null);
  if (!additive) renderer.clear(true, false, false);
  renderer.render(POST.qScene, POST.cam);
}
function renderPost() {
  const L = POST.levels;
  POST.bright.uniforms.tS.value = POST.scene.texture;
  pass(POST.bright, L[0].a);
  for (let i = 0; i < L.length; i++) {
    if (i > 0) { POST.up.blending = THREE.NoBlending; POST.up.uniforms.uAmt.value = 1; POST.up.uniforms.tS.value = L[i - 1].a; pass(POST.up, L[i].a); }
    POST.blur.uniforms.tS.value = L[i].a; POST.blur.uniforms.uDir.value.set(1 / L[i].w, 0); pass(POST.blur, L[i].b);
    POST.blur.uniforms.tS.value = L[i].b; POST.blur.uniforms.uDir.value.set(0, 1 / L[i].h); pass(POST.blur, L[i].a);
  }
  POST.up.blending = THREE.AdditiveBlending; POST.up.uniforms.uAmt.value = .52;
  for (let i = L.length - 1; i > 0; i--) { POST.up.uniforms.tS.value = L[i].a; pass(POST.up, L[i - 1].a, true); }
  POST.comp.uniforms.tS.value = POST.scene.texture;
  POST.comp.uniforms.tB.value = L[0].a;
  pass(POST.comp, null);
}

/* ================================================= 8 · the camera rig */
const CAM = [
  { p: [  0.0, 4.05, 13.6 ], t: [  0.0, 6.60, -18.0 ], fov: 36 },  /* 0 hero        */
  { p: [ -5.6, 2.35, 11.6 ], t: [  1.2, 5.60, -14.0 ], fov: 48 },  /* 1 the sanmon  */
  { p: [  1.2, 3.60,  2.2 ], t: [ -0.6, 7.50, -22.0 ], fov: 40 },  /* 2 gardens     */
  { p: [  5.2, 2.10, -3.4 ], t: [ -2.6, 7.00, -20.0 ], fov: 46 },  /* 3 craft       */
  { p: [  0.0, 7.60, -16.0 ], t: [  0.0, 13.0, -40.0 ], fov: 42 }, /* 4 afterlight  */
  { p: [  0.0, 10.5, -20.0 ], t: [  0.0, 3.00, -34.0 ], fov: 46 }  /* 5 footer      */
];
const RIG = { prog: 0, smooth: 0, mx: 0, my: 0, tmx: 0, tmy: 0, intro: 0, focus: -1, focusAmt: 0 };
let curveP, curveT, tmpCam;

function buildRig() {
  curveP = new THREE.CatmullRomCurve3(CAM.map(c => new THREE.Vector3(c.p[0], c.p[1], c.p[2])), false, 'catmullrom', .42);
  curveT = new THREE.CatmullRomCurve3(CAM.map(c => new THREE.Vector3(c.t[0], c.t[1], c.t[2])), false, 'catmullrom', .42);
  tmpCam = new THREE.PerspectiveCamera(CAM[0].fov, vpW() / vpH(), .35, 220);
  camera.layers.enable(1); camera.layers.enable(2);
}
const _p = new THREE.Vector3(), _t = new THREE.Vector3(), _d = new THREE.Vector3();
/* Every waypoint is composed for a wide frame. On a tall one the same
   numbers crop the gate in half, so the rig steps back along its own view
   axis and opens up a little instead of letting the sides fall away. */
function aspectFix() { return clamp((1.62 - vpW() / vpH()) / 1.05, 0, 1); }
function fitAspect(p, t, fov) {
  const nf = aspectFix();
  if (nf <= 0) return fov;
  _d.subVectors(p, t).normalize();
  p.addScaledVector(_d, nf * 8.2);
  p.y += nf * 1.1;
  return fov * (1 + nf * .40);
}
function applyCamera() {
  const N = CAM.length - 1;
  const u = clamp(RIG.smooth / N, 0, 1);
  curveP.getPoint(u, _p); curveT.getPoint(u, _t);
  const i = clamp(Math.floor(RIG.smooth), 0, N - 1), f = clamp(RIG.smooth - i, 0, 1);
  let fov = lerp(CAM[i].fov, CAM[i + 1].fov, f);

  fov = fitAspect(_p, _t, fov);

  /* the opening dolly: a long lens easing in from further back */
  const io = 1 - RIG.intro;
  _p.z += io * 5.6; _p.y += io * 0.65; fov += io * 8;

  /* parallax — a hand-held drift, never enough to break the frame */
  const par = 1 - smooth(0, 1.6, RIG.smooth) * .55;
  _p.x += RIG.mx * 0.62 * par; _p.y += RIG.my * 0.34 * par;
  _t.x -= RIG.mx * 0.20 * par; _t.y -= RIG.my * 0.12 * par;

  camera.position.copy(_p);
  camera.lookAt(_t);
  if (Math.abs(camera.fov - fov) > 1e-4) { camera.fov = fov; camera.updateProjectionMatrix(); }
}

/* ============================================== 9 · scroll ↔ chapters */
const SECS = [].slice.call(document.querySelectorAll('[data-cam]'));
let anchors = [], maxScroll = 1, activeSec = 0;
function measure() {
  maxScroll = Math.max(1, document.documentElement.scrollHeight - vpH());
  anchors = SECS.map((el, i) => {
    if (i === 0) return 0;
    if (i === SECS.length - 1) return maxScroll;
    return clamp(el.offsetTop + el.offsetHeight * .5 - vpH() * .5, 0, maxScroll);
  });
  for (let i = 1; i < anchors.length; i++) anchors[i] = Math.max(anchors[i], anchors[i - 1] + 1);
}
function progressFor(y) {
  if (y <= anchors[0]) return 0;
  for (let i = 0; i < anchors.length - 1; i++)
    if (y <= anchors[i + 1]) return i + (y - anchors[i]) / (anchors[i + 1] - anchors[i]);
  return anchors.length - 1;
}

/* ============================================ 10 · the wordmark layout */
function layoutWord() {
  if (!WORD.group) return;
  const c = CAM[0];
  const hp = new THREE.Vector3(c.p[0], c.p[1], c.p[2]);
  const ht = new THREE.Vector3(c.t[0], c.t[1], c.t[2]);
  tmpCam.fov = fitAspect(hp, ht, c.fov);
  tmpCam.aspect = vpW() / vpH();
  tmpCam.position.copy(hp);
  tmpCam.lookAt(ht);
  tmpCam.updateProjectionMatrix(); tmpCam.updateMatrixWorld(true);
  const hit = (nx, ny) => {
    const v = new THREE.Vector3(nx, ny, .5).unproject(tmpCam).sub(tmpCam.position).normalize();
    return tmpCam.position.clone().addScaledVector(v, (WORD_Z - tmpCam.position.z) / v.z);
  };
  const L = hit(-1, 0), R = hit(1, 0);
  /* Judge the frame by its shape, not its width. What forces the word up and
     in is a *tall* frame, and a 768-wide tablet held upright is as tall as a
     phone — on a width test it took the desktop baseline and sat below the
     fold with only the top of the G showing. */
  const narrow = vpW() / vpH() < 1.05;
  /* Four wide-tracked letters reach the frame edge exactly; pushing past it,
     as the six-letter word could afford to, decapitates the K and the E. The
     narrow branch is held a little inside the edge because a phone frame has
     no gutter to lose the tracking that trails the final letter into. */
  const fill = narrow ? .96 : 1.00;
  const s = (R.x - L.x) * fill / WORD.ink.w;
  /* on a tall frame the mounds sit near mid-height, so the baseline has to
     ride above them or the whole word disappears into the grass */
  const base = hit(0, narrow ? -.16 : -.585);
  WORD.group.scale.setScalar(s);
  WORD.group.position.set(-WORD.ink.cx * s, base.y, WORD_Z);
  WORD.rise = WORD.ink.asc * s * 1.25;
}

/* =================================================== 11 · page wiring */
const $  = s => document.querySelector(s);
const $$ = s => [].slice.call(document.querySelectorAll(s));
const nav = $('#nav'), preEl = $('#pre'), preFill = $('#pre-fill'), prePct = $('#pre-pct');

function wireReveals() {
  splitHeadingWords();
  const groups = new Map();
  const items = $$('[data-rv], .mask-line');
  items.forEach(el => {
    const key = el.parentElement;
    const arr = groups.get(key) || []; arr.push(el); groups.set(key, arr);
  });
  groups.forEach(arr => arr.forEach((el, i) => el.dataset.rvd = i * 85));
  const io = new IntersectionObserver(es => {
    es.forEach(e => {
      if (!e.isIntersecting) return;
      io.unobserve(e.target);
      const d = parseFloat(e.target.dataset.rvd || 0);
      setTimeout(() => e.target.classList.add('rv-in'), REDUCE ? 0 : d);
    });
  }, { rootMargin: '0px 0px -10% 0px', threshold: .04 });
  items.forEach(el => { if (!el.closest('#hero')) io.observe(el); });
}

/* Word-level masks let every display heading arrive at a human reading pace.
   The original text remains the accessible label, while the visual words are
   explicitly presentational. The existing section observer still controls
   when each heading starts. */
function splitHeadingWords() {
  if (REDUCE) return;
  $$('h1.display, h2.display').forEach(heading => {
    const lines = heading.querySelectorAll('.mask-line');
    const targets = lines.length ? [].slice.call(lines) : [heading];
    targets.forEach(target => {
      if (target.dataset.wordReady === 'true') return;
      const phrase = target.textContent.replace(/s+/g, ' ').trim();
      if (!phrase) return;
      target.dataset.wordReady = 'true';
      target.classList.add('word-reveal');
      target.setAttribute('aria-label', phrase);
      target.textContent = '';
      phrase.split(' ').forEach((word, i) => {
        if (i) target.appendChild(document.createTextNode(' '));
        const mask = document.createElement('span');
        const inner = document.createElement('span');
        mask.className = 'word-mask'; mask.setAttribute('aria-hidden', 'true');
        inner.className = 'word'; inner.textContent = word;
        inner.style.setProperty('--word-delay', (i * 72) + 'ms');
        mask.appendChild(inner); target.appendChild(mask);
      });
    });
  });
}

/* Foreground PNGs are section-owned until their chapter is called. At that
   point the active stage becomes a fixed lower viewport plane; its predecessor
   remains there just long enough to blur and fade out before returning home.

   While it holds that plane the stage is re-parented into #fg-sky, which lives
   outside .page and so is not capped by its stacking context — that is the
   only way a cut-out can pass in front of the nav and the chapter rail. The
   move costs nothing in layout: the stage is position:fixed either way, and
   its placement rules key off [data-fg], not off the section it came from. */
function wireForegroundStages() {
  const pairs = $$('.sec .fg, .foot .fg').map(stage => ({
    section:stage.closest('.sec, .foot'), stage
  })).filter(pair => pair.section);
  if (!pairs.length) return;

  const sky = $('#fg-sky');
  const ratios = new Map(pairs.map(pair => [pair.section, 0]));
  const homes = new WeakMap(pairs.map(pair => [pair.stage, pair.section]));
  const timers = new WeakMap();
  let activeStage = null;

  const lift = stage => {
    if (!sky || stage.parentNode === sky) return;
    sky.appendChild(stage);
    /* A re-inserted element has no previous computed style, so the entrance
       would land already finished. Resolve the parked state in its new parent
       first and the pieces have something to rise from. */
    void stage.offsetWidth;
  };
  const park = stage => {
    const home = homes.get(stage);
    if (home && stage.parentNode !== home) home.insertBefore(stage, home.firstChild);
  };

  const retire = stage => {
    if (!stage || stage === activeStage) return;
    const pending = timers.get(stage);
    if (pending) clearTimeout(pending);
    stage.classList.remove('fg-active');
    if (REDUCE) { park(stage); return; }
    stage.classList.add('fg-retiring');
    timers.set(stage, setTimeout(() => {
      stage.classList.remove('fg-retiring');
      timers.delete(stage);
      park(stage);
    }, 820));
  };

  const activate = stage => {
    if (!stage || stage === activeStage) return;
    const pending = timers.get(stage);
    if (pending) clearTimeout(pending);
    stage.classList.remove('fg-retiring');
    lift(stage);
    stage.classList.add('fg-active');
    const prior = activeStage;
    activeStage = stage;
    retire(prior);
  };

  const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => ratios.set(entry.target, entry.isIntersecting ? entry.intersectionRatio : 0));
    const next = pairs.reduce((best, pair) =>
      (ratios.get(pair.section) || 0) > (ratios.get(best.section) || 0) ? pair : best
    );
    const nextRatio = ratios.get(next.section) || 0;
    if (nextRatio > 0) activate(next.stage);
    else if (activeStage) {
      const prior = activeStage;
      activeStage = null;
      retire(prior);
    }
  }, { rootMargin:'-12% 0px -12% 0px', threshold:[0, .12, .32, .55] });

  pairs.forEach(pair => observer.observe(pair.section));
}

/* ------------------------------------------------------ leaving the hero
   Everything along the foot of the hero is spent the moment the walk starts:
   the cue has been read, the four chapters have been offered, the preview has
   been seen, and the scrim under them is only there to hold the type off the
   sanctuary. Letting the block slide away together reads as the page moving;
   giving each piece its own window empties the foot one element at a time
   instead, which reads as the hero standing down.

   The preview window is not staggered with the rest. It is the largest,
   flattest thing in the frame and a slow fade just dims it in place, so it
   goes early, goes quickly, and dissolves rather than fades: the blur is what
   makes the frame look like it is letting go of it rather than turning it
   down.

   Nothing is written until the scroll actually leaves zero, and everything is
   handed back the moment it returns: these elements carry the page's own
   entrance reveal, and an inline opacity set at rest would pre-empt it. */
function wireHeroExit() {
  const hero = $('#hero'); if (!hero) return;
  /* at = where in the exit it starts, span = how long it takes to go */
  const seq = [
    { el: $('.peek'),      at: .00, span: .26, blur: 10 },
    { el: $('.hero-cue'),  at: .10, span: .30, shift: true },
    ...$$('.chip').map((el, i) => ({ el: el, at: .20 + i * .10, span: .30, shift: true })),
    { el: $('.chapters'),  at: .60, span: .30 },  /* shifting it carries the chips twice */
    { el: $('.hero-side'), at: .70, span: .30 }   /* its transform is the centring -50% */
  ].filter(o => o.el);
  let on = false;
  const apply = () => {
    const t = clamp(scrollY / Math.max(1, vpH() * .58), 0, 1);
    if (t <= 0) {
      if (!on) return;
      seq.forEach(o => {
        o.el.style.opacity = ''; o.el.style.transform = ''; o.el.style.filter = '';
        o.el.style.pointerEvents = ''; o.el.style.transition = '';
      });
      on = false; return;
    }
    on = true;
    seq.forEach(o => {
      /* these carry the reveal's own .9s opacity transition, which would
         animate every value written here — the fade would then trail the
         scroll by most of a second and stop reading as scroll-driven */
      o.el.style.transition = 'none';
      const a = 1 - smooth(o.at, o.at + o.span, t);
      o.el.style.opacity = a.toFixed(3);
      if (o.shift) o.el.style.transform = 'translate3d(0,' + ((1 - a) * 15).toFixed(1) + 'px,0)';
      if (o.blur) o.el.style.filter = a > .999 ? '' : 'blur(' + ((1 - a) * o.blur).toFixed(1) + 'px)';
      o.el.style.pointerEvents = a < .05 ? 'none' : '';
    });
  };
  addEventListener('scroll', apply, { passive: true });
  addEventListener('resize', apply, { passive: true });
  apply();
}

function wireNav() {
  let last = 0;
  const rail = $('#rail');
  const names = ['The Hidden Gate', 'The Sanmon', 'Still Gardens', 'Sacred Craft', 'Afterlight', 'Colophon'];
  SECS.forEach((s, i) => {
    const b = document.createElement('button');
    b.innerHTML = '<i></i>'; b.title = names[i] || '';
    b.setAttribute('aria-label', names[i] || 'section');
    b.addEventListener('click', () => scrollTo({ top: anchors[i], behavior: REDUCE ? 'auto' : 'smooth' }));
    rail.appendChild(b);
  });
  const dots = $$('#rail button');
  const links = $$('.nav-link');
  const burger = $('.nav-burger');
  const closeMenu = () => {
    nav.classList.remove('menu-open');
    burger.classList.remove('active');
    burger.setAttribute('aria-expanded', 'false');
    document.documentElement.classList.remove('nav-open');
  };
  const setMenu = open => {
    /* clear the scroll-hide before the sheet opens: leaving it set would ease
       the bar back down on close, promoting it again under the outgoing sheet */
    if (open) nav.classList.remove('hide');
    nav.classList.toggle('menu-open', open);
    burger.classList.toggle('active', open);
    burger.setAttribute('aria-expanded', String(open));
    document.documentElement.classList.toggle('nav-open', open);
  };
  burger.setAttribute('aria-controls', 'navlinks');
  burger.setAttribute('aria-expanded', 'false');
  burger.addEventListener('click', () => {
    if (vpW() > 820) return;
    setMenu(!nav.classList.contains('menu-open'));
  });
  links.forEach(link => link.addEventListener('click', closeMenu));
  addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
  addEventListener('resize', () => { if (vpW() > 820) closeMenu(); }, { passive:true });
  /* Which chapter each link actually points at, read off its href. The old
     rule was positional — link i lit for section i+1 — which silently assumed
     one link per section in matching order. With five links over four chapters
     every entry past the third lit for its neighbour: standing in Afterlight
     highlighted Stories. Reading the destination cannot drift. */
  const linkSec = links.map(l => SECS.indexOf(document.querySelector(l.getAttribute('href'))));
  window.addEventListener('scroll', () => {
    const y = scrollY;
    nav.classList.toggle('stuck', y > 40);
    /* and don't even mark it hidden while the menu is open, or the class lands
       the moment the sheet closes and the bar blinks away under the thumb */
    nav.classList.toggle('hide',
      !nav.classList.contains('menu-open') && y > last + 4 && y > vpH() * .8);
    last = y;
    const a = Math.round(progressFor(y));
    if (a !== activeSec) {
      activeSec = a;
      dots.forEach((d, i) => d.classList.toggle('on', i === a));
      links.forEach((l, i) => l.classList.toggle('on', linkSec[i] === a));
    }
  }, { passive: true });
  $$('a[href^="#"]').forEach(a => a.addEventListener('click', e => {
    const t = document.querySelector(a.getAttribute('href'));
    if (!t) return;
    e.preventDefault();
    scrollTo({ top: a.getAttribute('href') === '#top' ? 0 : t.offsetTop - 40, behavior: REDUCE ? 'auto' : 'smooth' });
  }));
}

function wireFocus() {
  const set = i => { RIG.focus = i; };
  $$('[data-chip]').forEach(el => {
    el.addEventListener('mouseenter', () => { set(+el.dataset.chip); $$('[data-chip]').forEach(o => o.classList.toggle('on', o === el)); });
    el.addEventListener('mouseleave', () => { set(-1); el.classList.remove('on'); });
  });
  $$('[data-les]').forEach(el => {
    el.addEventListener('mouseenter', () => set(+el.dataset.les % 4));
    el.addEventListener('mouseleave', () => set(-1));
  });
}

function wireCursor() {
  const dot = $('#cursor');
  if (COARSE) { dot.style.display = 'none'; return; }
  let x = vpW() / 2, y = vpH() / 2, tx2 = x, ty = y;
  addEventListener('pointermove', e => {
    tx2 = e.clientX; ty = e.clientY;
    RIG.tmx = (e.clientX / vpW()) * 2 - 1;
    RIG.tmy = -((e.clientY / vpH()) * 2 - 1);
  }, { passive: true });
  $$('[data-cursor]').forEach(el => {
    el.addEventListener('mouseenter', () => dot.classList.add('act'));
    el.addEventListener('mouseleave', () => dot.classList.remove('act'));
  });
  (function tick() {
    x = lerp(x, tx2, .18); y = lerp(y, ty, .18);
    dot.style.transform = 'translate3d(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px,0)';
    requestAnimationFrame(tick);
  })();
}

function makeGrain() {
  const S = 180, c = cvs(S, S), x = c.getContext('2d');
  const im = x.createImageData(S, S), d = im.data, r = mulberry32(9);
  for (let i = 0; i < S * S; i++) {
    const v = 110 + r() * 90;
    d[i * 4] = d[i * 4 + 1] = d[i * 4 + 2] = v; d[i * 4 + 3] = 255;
  }
  x.putImageData(im, 0, 0);
  $('#grain').style.backgroundImage = 'url(' + c.toDataURL('image/png') + ')';
}

/* ============================================ 12 · the card viewports */
const CARDS = [];
function buildCards() {
  const defs = [
    { p: [-2.0, 1.60, -2.0], t: [0, 10.0, -34.0], fov: 40 },    /* the long climb */
    { p: [4.2, 2.90, -9.5], t: [6.4, 2.90, -14.4], fov: 40 },   /* lantern court  */
    { p: [3.4, 2.40, 2.0], t: [-1.0, -.60, -6.0], fov: 40 },    /* the wet court  */
    { p: [0.6, 3.40, -12.0], t: [0, 12.0, -40.0], fov: 26 }     /* hero window    */
  ];
  $$('[data-view]').forEach(el => {
    const d = defs[+el.dataset.view]; if (!d) return;
    const c = new THREE.PerspectiveCamera(d.fov, 4 / 5, .3, 200);
    c.position.set(d.p[0], d.p[1], d.p[2]);
    c.lookAt(d.t[0], d.t[1], d.t[2]);
    c.layers.set(0);
    c.userData = { home: c.position.clone(), look: new THREE.Vector3(d.t[0], d.t[1], d.t[2]), push: 0, want: 0 };
    CARDS.push({ cam: c, el: el.querySelector('[data-frame]') || el, rect: null });
    el.addEventListener('mouseenter', () => c.userData.want = 1);
    el.addEventListener('mouseleave', () => c.userData.want = 0);
  });
}
/* A render target keeps its own viewport/scissor — renderer.setScissor()
   only ever reaches the default framebuffer, so the region has to be set on
   whichever surface we are actually drawing into, and re-bound to take. */
const _push = new THREE.Vector3();
function setRegion(rt, x, y, w, h) {
  if (rt) {
    const pr = renderer.getPixelRatio();
    rt.viewport.set(x * pr, y * pr, w * pr, h * pr);
    rt.scissor.set(x * pr, y * pr, w * pr, h * pr);
    rt.scissorTest = true;
    renderer.setRenderTarget(rt);
  } else {
    renderer.setViewport(x, y, w, h);
    renderer.setScissor(x, y, w, h);
    renderer.setScissorTest(true);
  }
}
function clearRegion(rt) {
  if (rt) {
    rt.viewport.set(0, 0, rt.width, rt.height);
    rt.scissor.set(0, 0, rt.width, rt.height);
    rt.scissorTest = false;
    renderer.setRenderTarget(rt);
  } else {
    renderer.setScissorTest(false);
    renderer.setViewport(0, 0, vpW(), vpH());
    renderer.setScissor(0, 0, vpW(), vpH());
  }
}
function cardBuffer(C, w, h) {
  const pr = Math.min(renderer.getPixelRatio(), 2);
  const W = Math.max(8, Math.round(w * pr)), H = Math.max(8, Math.round(h * pr));
  if (C.rt && C.rt.width === W && C.rt.height === H) return C.rt;
  if (C.rt) C.rt.dispose();
  C.rt = new THREE.WebGLRenderTarget(W, H, {
    minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter,
    type: THREE.HalfFloatType, depthBuffer: true, stencilBuffer: false
  });
  C.dirty = true;
  return C.rt;
}
function aimCard(C, aspect) {
  const c = C.cam, u = c.userData;
  c.aspect = aspect; c.updateProjectionMatrix();
  _push.subVectors(u.look, u.home).normalize();
  c.position.copy(u.home).addScaledVector(_push, u.push * .55);
  c.lookAt(u.look);
}
/* Each viewport keeps its own buffer and only re-renders when it is moving
   or when its slot in the refresh rota comes round — the views are static,
   so paying for four scene traversals every frame buys nothing. */
/* The blit lands in the composite at the element's screen rect, and the canvas
   knows nothing about the DOM stacked over it — so a card the page has faded
   goes on painting a live view into the frame with no card around it, which is
   what put a second lit hall in the corner once the hero exit started dimming
   the preview. Cull on the element's effective opacity, not just its rect.

   Below full opacity is the right place to stop: the card's own still is
   opaque, so the view behind it only ever shows through by (1 − alpha). At
   alpha just under one that is nothing, and the cut is invisible. */
function cardAlpha(el) {
  let a = 1;
  for (let n = el; n && n !== document.body; n = n.parentElement) {
    const s = getComputedStyle(n);
    if (s.display === 'none' || s.visibility === 'hidden') return 0;
    a *= +s.opacity;
    if (a < .001) return 0;
  }
  return a;
}
function renderCards(rt) {
  if (!CARDS.length) return;
  let drew = false;
  for (let i = 0; i < CARDS.length; i++) {
    const C = CARDS[i], r = C.el.getBoundingClientRect();
    if (r.bottom < -40 || r.top > vpH() + 40 || r.width < 4) continue;
    if (cardAlpha(C.el) < .995) continue;
    /* a card carrying cloth has no background left of its own, so the blit
       would show through the fabric's rounded corners and past its edges */
    if (C.el.classList.contains('on-cloth')) continue;
    if (!WANT_POST) {                              /* debug path: straight in */
      setRegion(rt, r.left, vpH() - r.bottom, r.width, r.height);
      renderer.clear(true, true, false);
      aimCard(C, r.width / r.height);
      renderer.render(scene, C.cam);
      drew = true; continue;
    }
    const buf = cardBuffer(C, r.width, r.height);
    const u = C.cam.userData;
    if (C.dirty || Math.abs(u.push - u.want) > .002 || (FRAME + i * 7) % 24 === 0) {
      aimCard(C, r.width / r.height);
      renderer.setRenderTarget(buf);
      renderer.clear(true, true, false);
      renderer.render(scene, C.cam);
      C.dirty = false;
    }
    setRegion(rt, r.left, vpH() - r.bottom, r.width, r.height);
    POST.up.blending = THREE.NoBlending;
    POST.up.uniforms.uAmt.value = 1;
    POST.up.uniforms.tS.value = buf.texture;
    pass(POST.up, rt);
    drew = true;
  }
  if (drew) clearRegion(rt);
}

/* ==================================================== 13 · the machine */
let running = false, tPrev = 0, clock = 0, fadeIn = 0;
const INTRO = { t0: 0 };

function resize() {
  const w = vpW(), h = vpH();
  document.documentElement.style.setProperty('--vw', w + 'px');
  renderer.setPixelRatio(Math.min(devicePixelRatio || 1, DPR_CAP) * PERF.scale);
  renderer.setSize(w, h, true);
  camera.aspect = w / h; camera.updateProjectionMatrix();
  const pw = renderer.domElement.width, ph = renderer.domElement.height;
  if (WANT_POST && POST.scene) {
    POST.scene.setSize(pw, ph);
    POST.comp.uniforms.uRes.value.set(pw, ph);
    let lw = Math.max(2, pw >> 1), lh = Math.max(2, ph >> 1);
    POST.levels.forEach(L => { L.a.setSize(lw, lh); L.b.setSize(lw, lh); L.w = lw; L.h = lh; lw = Math.max(2, lw >> 1); lh = Math.max(2, lh >> 1); });
  }
  /* multisampling is the first thing to go when the budget tightens */
  if (POST.scene) {
    const want = (!LOW && PERF.scale > .78) ? 2 : 0;
    if (POST.scene.samples !== want) { POST.scene.samples = want; POST.scene.dispose(); }
  }
  CARDS.forEach(C => { C.dirty = true; });
  if (WANT_SHADOW && WORLD.key) WORLD.key.shadow.needsUpdate = true;
  if (WORLD.embers) WORLD.embers.material.uniforms.uSize.value = h * renderer.getPixelRatio() * .5;
  if (WISP.mesh) WISP.mesh.material.uniforms.uPx.value = h * renderer.getPixelRatio();
  placeMoon();
  layoutWord();
  measure();
}

function updateWorld(dt) {
  WORLD.uT.value = clock;

  /* the focused chapter warms the lanterns and swells the disc */
  RIG.focusAmt = damp(RIG.focusAmt, RIG.focus >= 0 ? 1 : 0, 5, dt);
  const pulse = Math.sin(clock * 1.9) * .5 + .5;
  const f = RIG.focusAmt;
  if (WORLD.hallHalo) WORLD.hallHalo.material.opacity = .30 + f * .14 + Math.sin(clock * .6) * .035;
  if (WORLD.hallLight) WORLD.hallLight.intensity = 3.4 * (1 + f * .30) * (1 + Math.sin(clock * .43) * .045);
  /* the moon only breathes — the haze in front of it is what actually moves */
  if (WORLD.moonHalo) WORLD.moonHalo.material.opacity = .44 + f * .10 + Math.sin(clock * .34) * .05;
  if (WORLD.lanternLights) WORLD.lanternLights.forEach((l, i) => {
    l.intensity = 2.6 * (1 + f * .55) * (.86 + .22 * Math.sin(clock * (2.3 + i * .7) + i * 2.1) + .1 * pulse);
  });
  if (WORLD.lanternGlows) WORLD.lanternGlows.forEach(g => { g.quaternion.copy(camera.quaternion); });

  /* haze slides across the courtyard */
  if (WORLD.haze) WORLD.haze.forEach(h => {
    h.position.x = h.userData.x0 + Math.sin(clock * h.userData.sp + h.userData.ph) * 5.5;
    h.quaternion.copy(camera.quaternion);
  });

  /* ripples on the standing water */
  if (WORLD.ripples) WORLD.ripples.forEach(r => {
    const u = r.userData;
    u.t += dt * u.sp;
    if (u.t > 4) { u.t = 0; u.x = (Math.random() - .5) * 22; u.z = -8 + Math.random() * 22; }
    const k = u.t / 4;
    const s = .3 + k * 4.2;
    r.scale.set(s, s, 1);
    r.position.set(u.x, .02, u.z);
    r.material.opacity = Math.sin(k * Math.PI) * .16;
  });

  /* the type rises from behind the grass, then dissolves as you close on it */
  if (WORD.group) {
    const near = smooth(0.02, 0.92, RIG.smooth);
    WORD.glyphs.forEach((g, i) => {
      const st = clamp((WORD.reveal - i * .075) / .62, 0, 1);
      const e = easeOut(st);
      g.position.y = g.userData.baseY - (1 - e) * (WORD.ink.asc * 1.15);
      g.material.opacity = e * (1 - near * .96);
      g.visible = g.material.opacity > .004;
    });
  }
  /* the cut-out layers dissolve as the camera reaches them, so the path can
     walk straight through the garden instead of steering around the veil */
  if (WORLD.fg) WORLD.fg.forEach(m => {
    const a = smooth(.9, 4.6, camera.position.z - m.position.z);
    m.material.opacity = a;
    m.visible = a > .006;
  });
  updateLeaves(dt);
  updateWisps(dt);
  if (CARDS.length) CARDS.forEach(C => { const u = C.cam.userData; u.push = damp(u.push, u.want, 3.4, dt); });
}

let FRAME = 0;
function render() {
  FRAME++;
  renderer.setRenderTarget(WANT_POST ? POST.scene : null);
  renderer.clear(true, true, false);
  renderer.render(scene, camera);
  renderCards(WANT_POST ? POST.scene : null);
  renderer.setRenderTarget(null);
  if (WANT_POST) {
    POST.comp.uniforms.uT.value = clock;
    POST.comp.uniforms.uFade.value = fadeIn;
    renderPost();
  }
}

function frame(now) {
  if (!running) return;
  const raw = (now - tPrev) / 1000 || 0;
  const dt = Math.min(raw, .05);              /* animation never jumps … */
  tPrev = now; clock += dt;
  fadeIn = INTRO.t0 ? sat((now - INTRO.t0) / 700) : 1;

  if (!PERF.locked && clock > 2.2) {
    PERF.acc += raw; PERF.n++;      /* … but the governor reads the truth */
    if (PERF.n >= 40 || PERF.acc > .9) {
      const avg = PERF.acc / PERF.n; PERF.acc = 0; PERF.n = 0;
      if (avg > .0230 && PERF.scale > .55) { PERF.scale = Math.max(.55, PERF.scale * (avg > .05 ? .64 : .85)); resize(); }
      else if (avg < .0138 && PERF.scale < 1) { PERF.scale = Math.min(1, PERF.scale + .08); resize(); }
    }
  }
  RIG.prog = progressFor(scrollY);
  RIG.smooth = REDUCE ? RIG.prog : damp(RIG.smooth, RIG.prog, 5.2, dt);
  RIG.mx = damp(RIG.mx, RIG.tmx, 2.6, dt);
  RIG.my = damp(RIG.my, RIG.tmy, 2.6, dt);
  if (INTRO.t0) {
    const el = (now - INTRO.t0) / 1000;
    RIG.intro = sat(el / 2.4);
    WORD.reveal = Math.min(1.2, el / 1.5);
  }

  applyCamera();
  updateWorld(dt);
  render();
  queue();
}
const TIMER = qs('driver', 'raf') === 'timer';
function queue() { TIMER ? setTimeout(() => frame(performance.now()), 16) : requestAnimationFrame(frame); }

/* ======================================================== 14 · booting */
const JOBS = [
  ['Reading the type', () => document.fonts && document.fonts.load('600 320px Wordmark')],
  ['Pouring the ground', () => { initGL(); WORLD.uT = { value: 0 }; buildRig(); buildLights(); }],
  ['Cutting the approach', () => buildShell()],
  ['Raising the hall', () => buildTemple()],
  ['Hanging the moon', () => buildMoon()],
  ['Setting the gate', () => buildTorii()],
  ['Placing the stones', () => {
    buildRocks();
    buildLantern(7.4, -7.0, 1.15); buildLantern(-7.6, -5.2, 1.0);
    /* The pairs flanking the flight were given flat heights — 2 and PODIUM —
       and the flight does not reach either at those depths, so both pairs hung
       in the air. Solve the step under each one from the same constants the
       treads and cheeks are built from, and they stand on the rail instead of
       beside it. */
    const stepAt   = z => Math.max(0, (STAIR_Z0 - z) / STAIR_RUN - .5);
    const cheekTop = z => (stepAt(z) + 1) * (PODIUM / STEPS) + .45;
    const cheekX   = z => (STAIR_W + (STEPS - stepAt(z)) * .052) / 2 + .45;
    [-14.4, -23.5].forEach((z, i) => {
      const sc = i ? .95 : .90;
      buildLantern( cheekX(z), z, sc, cheekTop(z));
      buildLantern(-cheekX(z), z, sc, cheekTop(z));
    });
  }],
  ['Growing the maples', () => {
    buildMaple(71, 12.6, -13.0, 1.05); buildMaple(72, -11.8, -9.4, .95);
    buildMaple(73, 9.2, -19.0, .82);   buildMaple(74, -14.5, -17.5, 1.0);
    buildMaple(75, 16.5, -6.0, .88);
  }],
  ['Painting the near grass', () => buildForeground()],
  ['Cutting the word', () => buildWordmark()],
  ['Raising the mist', () => { buildAtmosphere(); buildLeafFall(); buildWisps(); }],
  ['Polishing the water', () => {
    initPost(); buildCards(); buildCardCloth();
    WORLD.fg.forEach(m => m.layers.set(1));
    WORD.glyphs.forEach(m => m.layers.set(2));
    if (WORLD.rain) WORLD.rain.layers.set(1);
    /* the fall is all around the rig, including behind it — mirroring it
       would drop leaves into the water that are nowhere near the water */
    if (WORLD.leaves) WORLD.leaves.mesh.layers.set(1);
    WORLD.ripples.forEach(r => r.layers.set(1));
    layoutWord(); measure();
    /* nothing that casts a shadow ever moves, so bake it once */
    if (WANT_SHADOW && WORLD.key) { WORLD.key.shadow.autoUpdate = false; WORLD.key.shadow.needsUpdate = true; }
  }]
];

function boot() {
  makeGrain();
  wireReveals(); wireForegroundStages(); wireNav(); wireHeroExit(); wireFocus(); wireCursor();
  document.body.classList.add('is-locked');
  let i = 0;
  const step = () => {
    const j = JOBS[i];
    const done = () => {
      i++;
      const p = i / JOBS.length;
      preFill.style.right = ((1 - p) * 100).toFixed(1) + '%';
      prePct.textContent = Math.round(p * 100);
      if (i < JOBS.length) setTimeout(step, 16); else setTimeout(start, 220);
    };
    let r;
    try { r = j[1](); }
    catch (err) {
      console.error('[kage] job "' + j[0] + '" failed', err);
      if (i <= 1) return fallback(err);          /* no renderer, no scene */
    }
    (r && r.then) ? r.then(done, done) : done();
  };
  setTimeout(step, 60);
}

function fallback(err) {
  document.documentElement.classList.add('no-webgl');
  document.body.classList.add('no-webgl');
  document.body.classList.remove('is-locked');
  preEl.classList.add('done');
  $$('[data-rv], .mask-line').forEach(e => e.classList.add('rv-in'));
  window.__kage = window.__secret = { fallback: true, error: String(err && err.message || err) };
}

function start() {
  addEventListener('resize', () => { resize(); }, { passive: true });
  addEventListener('orientationchange', () => setTimeout(resize, 250));
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) { running = false; }
    else if (!running) { running = true; tPrev = performance.now(); queue(); }
  });
  resize();

  const shot = Q.get('shot');
  if (shot !== null) {                       /* deterministic states for review */
    RIG.intro = 1; WORD.reveal = 1.2; fadeIn = 1; INTRO.t0 = 0;
    const n = clamp(parseInt(shot, 10) || 0, 0, SECS.length - 1);
    scrollTo(0, anchors[n]);
    RIG.smooth = RIG.prog = progressFor(anchors[n]);
    $$('[data-rv], .mask-line').forEach(e => e.classList.add('rv-in'));
    document.body.classList.remove('is-locked');
    preEl.classList.add('done');
  } else {
    preEl.classList.add('done');
    setTimeout(() => {
      document.body.classList.remove('is-locked');
      $('#hero').querySelectorAll('[data-rv], .mask-line').forEach((e, i) =>
        setTimeout(() => e.classList.add('rv-in'), REDUCE ? 0 : 120 + i * 95));
    }, REDUCE ? 0 : 340);
  }
  running = true; tPrev = performance.now();
  INTRO.t0 = shot !== null ? 0 : (REDUCE ? performance.now() - 4000 : performance.now());
  queue();
  window.__kage = window.__secret = { RIG: RIG, WORLD: WORLD, WORD: WORD, CAM: CAM, POST: POST, renderer: renderer, scene: scene, camera: camera, anchors: () => anchors };
}

if (document.readyState === 'complete' || document.readyState === 'interactive') setTimeout(boot, 0);
else addEventListener('DOMContentLoaded', boot);
})();
</script>
</body>
</html>
[/hidecontent]
← 返回首页