yaosio

Looming and pixleation

Sep 25th, 2026
87
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 76.37 KB | None | 0 0
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  6. <title>Looming showcase</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  9. <link href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@500;700&family=Work+Sans:wght@400;500;600&display=swap" rel="stylesheet">
  10. <style>
  11. :root {
  12. --dusk: rgba(26, 22, 46, 0.86);
  13. --dusk-edge: rgba(190, 176, 230, 0.22);
  14. --ink: #ece7f8;
  15. --ink-soft: #b3aad0;
  16. --lantern: #ffcf6a;
  17. --frost: #5cc8ff;
  18. --track: rgba(255, 255, 255, 0.16);
  19. box-sizing: border-box;
  20. padding-top: env(safe-area-inset-top, 0px);
  21. padding-bottom: env(safe-area-inset-bottom, 0px);
  22. color-scheme: dark;
  23. }
  24. @media (prefers-color-scheme: light) {
  25. :root:not([data-theme="dark"]) {
  26. --dusk: rgba(246, 243, 255, 0.9);
  27. --dusk-edge: rgba(60, 50, 100, 0.2);
  28. --ink: #221d38;
  29. --ink-soft: #5b5378;
  30. --lantern: #9a6200;
  31. --frost: #0a7fc0;
  32. --track: rgba(30, 20, 60, 0.18);
  33. color-scheme: light;
  34. }
  35. }
  36. :root[data-theme="dark"] {
  37. --dusk: rgba(26, 22, 46, 0.86);
  38. --dusk-edge: rgba(190, 176, 230, 0.22);
  39. --ink: #ece7f8;
  40. --ink-soft: #b3aad0;
  41. --lantern: #ffcf6a;
  42. --frost: #5cc8ff;
  43. --track: rgba(255, 255, 255, 0.16);
  44. }
  45. *, *::before, *::after { box-sizing: inherit; }
  46. html { scroll-padding-top: env(safe-area-inset-top, 0px); height: 100%; }
  47. body {
  48. height: 100%; margin: 0; overflow: hidden; background: #15122a;
  49. font-family: "Work Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
  50. color: var(--ink);
  51. }
  52. canvas#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }
  53. .pixel { font-family: "Pixelify Sans", "Work Sans", system-ui, sans-serif; }
  54.  
  55. .panel {
  56. position: fixed; left: 14px; top: calc(14px + env(safe-area-inset-top, 0px));
  57. width: 296px; max-width: calc(100vw - 28px); max-height: calc(100% - 28px); overflow-y: auto;
  58. background: var(--dusk); border: 1px solid var(--dusk-edge); border-radius: 10px;
  59. padding: 16px 16px 14px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  60. font-size: 13px; line-height: 1.45;
  61. }
  62. .panel.hidden { display: none; }
  63. .panel h1 { font-weight: 700; font-size: 23px; line-height: 1.1; margin: 0 0 2px; }
  64. .panel .sub { color: var(--ink-soft); margin: 0 0 10px; }
  65. .section { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--track); }
  66. .section h2 { font-size: 13px; font-weight: 600; margin: 0 0 6px; display: flex; justify-content: space-between; align-items: baseline; }
  67. .section h2 span { color: var(--ink-soft); font-weight: 400; font-variant-numeric: tabular-nums; }
  68. .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 7px 0; }
  69. .row label { flex: 1; }
  70. .row output { min-width: 58px; text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-soft); }
  71. input[type="range"] { width: 112px; accent-color: var(--lantern); }
  72. .toggle { display: flex; align-items: center; gap: 8px; margin: 6px 0; cursor: pointer; }
  73. .toggle input { accent-color: var(--lantern); width: 15px; height: 15px; margin: 0; }
  74. .toggle kbd { margin-left: auto; }
  75. .btns { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
  76. .btn {
  77. font: inherit; color: var(--ink); background: transparent; border: 1px solid var(--track);
  78. border-radius: 6px; padding: 4px 10px; cursor: pointer; display: inline-flex; gap: 6px; align-items: center;
  79. }
  80. .btn:hover { border-color: var(--lantern); }
  81. .btn.on { border-color: var(--lantern); color: var(--lantern); }
  82. .btn.wide { flex: 1; justify-content: center; }
  83. .stopname { font-weight: 600; margin: 2px 0 4px; }
  84. .progress { height: 3px; background: var(--track); border-radius: 2px; overflow: hidden; margin: 4px 0 8px; }
  85. .progress > div { height: 100%; width: 0; background: var(--lantern); }
  86. kbd {
  87. font-family: inherit; font-size: 11px; color: var(--ink-soft);
  88. border: 1px solid var(--track); border-radius: 4px; padding: 0 5px;
  89. }
  90. .keys { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--track); color: var(--ink-soft); font-size: 12px; }
  91. .keys p { margin: 3px 0; }
  92. .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
  93. .fps { color: var(--ink-soft); font-size: 12px; font-variant-numeric: tabular-nums; }
  94. :focus-visible { outline: 2px solid var(--lantern); outline-offset: 2px; }
  95.  
  96. .caption {
  97. position: fixed; left: 50%; bottom: calc(26px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%);
  98. width: min(560px, calc(100vw - 40px));
  99. background: var(--dusk); border: 1px solid var(--dusk-edge); border-radius: 10px;
  100. padding: 12px 18px 14px; text-align: center; pointer-events: none;
  101. transition: opacity 0.4s ease;
  102. }
  103. .caption.hidden { opacity: 0; }
  104. .caption .kicker { color: var(--lantern); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 2px; }
  105. .caption h3 { font-size: 24px; margin: 0 0 4px; font-weight: 700; }
  106. .caption p { margin: 0; color: var(--ink-soft); font-size: 14px; line-height: 1.45; }
  107.  
  108. .fade { position: fixed; inset: 0; background: #0d0b1a; opacity: 0; pointer-events: none; transition: opacity 0.6s ease; }
  109. .fade.on { opacity: 1; }
  110. .hint {
  111. position: fixed; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  112. background: var(--dusk); border: 1px solid var(--dusk-edge); border-radius: 8px;
  113. padding: 6px 12px; font-size: 12px; pointer-events: none; color: var(--ink-soft);
  114. }
  115. .hint.hidden { display: none; }
  116. .loading {
  117. position: fixed; inset: 0; display: grid; place-items: center; background: #15122a; color: #ece7f8;
  118. font-size: 24px;
  119. }
  120. .loading.hidden { display: none; }
  121. .error { font-family: "Work Sans", system-ui, sans-serif; font-size: 15px; max-width: 420px; text-align: center; line-height: 1.5; }
  122. </style>
  123. </head>
  124. <body>
  125. <canvas id="view"></canvas>
  126. <div class="fade" id="fade"></div>
  127.  
  128. <div class="caption hidden" id="caption">
  129. <p class="kicker" id="capKicker"></p>
  130. <h3 class="pixel" id="capTitle"></h3>
  131. <p id="capText"></p>
  132. </div>
  133.  
  134. <aside class="panel" id="panel" aria-label="Showcase controls">
  135. <h1 class="pixel">Looming showcase</h1>
  136. <p class="sub">Past the boundary, the world is pulled in but keeps its real height, and turns into a painted '90s backdrop.</p>
  137. <div class="fps" id="fps">–</div>
  138.  
  139. <div class="section">
  140. <h2>Tour <span id="tourStep">1 of 4</span></h2>
  141. <div class="stopname" id="tourName">The approach</div>
  142. <div class="progress"><div id="tourBar"></div></div>
  143. <div class="btns">
  144. <button class="btn" id="prevStop" type="button">Previous</button>
  145. <button class="btn" id="nextStop" type="button">Next <kbd>N</kbd></button>
  146. <button class="btn wide" id="roamBtn" type="button">Free roam</button>
  147. </div>
  148. </div>
  149.  
  150. <div class="section">
  151. <h2>Warp</h2>
  152. <div class="btns">
  153. <button class="btn" data-preset="subtle" type="button">Subtle <kbd>1</kbd></button>
  154. <button class="btn on" data-preset="dream" type="button">Dreamlike <kbd>2</kbd></button>
  155. <button class="btn" data-preset="extreme" type="button">Extreme <kbd>3</kbd></button>
  156. </div>
  157. <div class="row">
  158. <label for="dB">Boundary</label>
  159. <input type="range" id="dB" min="40" max="1200" step="10" value="200">
  160. <output id="dBOut">200 m</output>
  161. </div>
  162. <div class="row">
  163. <label for="soft">Softness</label>
  164. <input type="range" id="soft" min="0" max="1500" step="10" value="250">
  165. <output id="softOut">250 m</output>
  166. </div>
  167. <div class="btns">
  168. <button class="btn wide" id="revealBtn" type="button">Reveal true size <kbd>X</kbd></button>
  169. </div>
  170. <label class="toggle"><input type="checkbox" id="optRigid" checked> Airship keeps its shape <kbd>K</kbd></label>
  171. </div>
  172.  
  173. <div class="section">
  174. <h2>Style</h2>
  175. <label class="toggle"><input type="checkbox" id="optPaint" checked> Painted far field <kbd>P</kbd></label>
  176. <div class="row">
  177. <label for="block">Far pixel size</label>
  178. <input type="range" id="block" min="1" max="10" step="1" value="4">
  179. <output id="blockOut">4 px</output>
  180. </div>
  181. <div class="row">
  182. <label for="nblock">Near pixel size</label>
  183. <input type="range" id="nblock" min="1" max="6" step="1" value="2">
  184. <output id="nblockOut">2 px</output>
  185. </div>
  186. <label class="toggle"><input type="checkbox" id="optOutline" checked> Outlines <kbd>O</kbd></label>
  187. <label class="toggle"><input type="checkbox" id="optTint"> Show the compressed zone <kbd>B</kbd></label>
  188. </div>
  189.  
  190. <div class="section">
  191. <h2>Time of day <span id="todOut">16:30</span></h2>
  192. <div class="row">
  193. <label for="tod" class="sr">Time</label>
  194. <input type="range" id="tod" min="0" max="24" step="0.1" value="16.5" style="width: 100%">
  195. </div>
  196. <label class="toggle"><input type="checkbox" id="optCycle"> Let time pass <kbd>L</kbd></label>
  197. </div>
  198.  
  199. <div class="section">
  200. <h2>Movement</h2>
  201. <label class="toggle"><input type="checkbox" id="optFly"> Fly <kbd>F</kbd></label>
  202. <div class="row">
  203. <label for="speed">Speed</label>
  204. <input type="range" id="speed" min="2" max="60" step="1" value="10">
  205. <output id="speedOut">10 m/s</output>
  206. </div>
  207. </div>
  208.  
  209. <div class="keys">
  210. <p>In free roam, click the scene to look around. Esc releases the mouse. A ramp spirals up the spire, and a bridge runs from the top to the waterfall.</p>
  211. <p><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move, <kbd>Shift</kbd> ×3, <kbd>E</kbd>/<kbd>Q</kbd> up/down while flying, <kbd>H</kbd> hide panel</p>
  212. </div>
  213. </aside>
  214.  
  215. <div class="hint hidden" id="hint">Click to look around</div>
  216. <div class="loading pixel" id="loading">Building the world…</div>
  217.  
  218. <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
  219. <script>
  220. (() => {
  221. "use strict";
  222. const $ = (id) => document.getElementById(id);
  223. if (typeof THREE === "undefined") {
  224. $("loading").innerHTML = '<div class="error">The 3D library did not load. Check your connection and reload the page.</div>';
  225. return;
  226. }
  227.  
  228. // ---------------------------------------------------------------------------
  229. // Noise and helpers
  230. // ---------------------------------------------------------------------------
  231. function hash2(x, y) {
  232. let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263)) | 0;
  233. h = Math.imul(h ^ (h >>> 13), 1274126177);
  234. h ^= h >>> 16;
  235. return (h >>> 0) / 4294967296;
  236. }
  237. function vnoise(x, y) {
  238. const xi = Math.floor(x), yi = Math.floor(y);
  239. const xf = x - xi, yf = y - yi;
  240. const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
  241. const a = hash2(xi, yi), b = hash2(xi + 1, yi), c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);
  242. return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
  243. }
  244. function fbm(x, y, oct) {
  245. let s = 0, a = 0.5, f = 1, n = 0;
  246. for (let i = 0; i < oct; i++) { s += a * vnoise(x * f + i * 17.3, y * f - i * 9.1); n += a; a *= 0.5; f *= 2.03; }
  247. return s / n;
  248. }
  249. function ridged(x, y, oct) {
  250. let s = 0, a = 0.5, f = 1, n = 0;
  251. for (let i = 0; i < oct; i++) {
  252. let r = 1 - Math.abs(vnoise(x * f + i * 31.7, y * f + i * 5.3) * 2 - 1);
  253. r *= r; s += a * r; n += a; a *= 0.5; f *= 2.1;
  254. }
  255. return s / n;
  256. }
  257. const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
  258. const lerp = (a, b, t) => a + (b - a) * t;
  259. const ease = (t) => t * t * (3 - 2 * t);
  260. const angDiff = (a, b) => { let d = a - b; while (d > Math.PI) d -= Math.PI * 2; while (d < -Math.PI) d += Math.PI * 2; return d; };
  261. let seed = 4242;
  262. const rand = () => {
  263. seed = (seed + 0x6D2B79F5) | 0;
  264. let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  265. t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  266. return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  267. };
  268.  
  269. // ---------------------------------------------------------------------------
  270. // World layout. It is deliberately small: about 2 km from rim to rim.
  271. // ---------------------------------------------------------------------------
  272. const CENTER = { x: 0, z: -500 };
  273. const SPIRE = { x: 0, z: -700 };
  274. const STONES = { x: 520, z: -380 };
  275. const CLIFF = { x: -650, z: -1080 };
  276. const PATH_START = 260, PATH_END = -655;
  277. const pathX = (z) => 18 * Math.sin(z * 0.009) + 10 * Math.sin(z * 0.023 + 1.1);
  278.  
  279. function H(x, z) {
  280. let h = (fbm(x * 0.0035, z * 0.0035, 4) - 0.5) * 38;
  281. const side = Math.abs(x - pathX(z));
  282. if (z < PATH_START + 80 && z > PATH_END - 60) h *= 0.35 + 0.65 * smooth(8, 140, side);
  283. const ds = Math.hypot(x - SPIRE.x, z - SPIRE.z);
  284. h *= 0.3 + 0.7 * smooth(60, 300, ds);
  285. h += 80 * (1 - smooth(42, 340, ds)); // the spire's hill, flat on top
  286. const dt = Math.hypot(x - STONES.x, z - STONES.z);
  287. h *= 0.4 + 0.6 * smooth(60, 140, dt);
  288. const dc = Math.hypot(x - CLIFF.x, z - CLIFF.z) + (vnoise(x * 0.012, z * 0.012) - 0.5) * 50;
  289. h += 118 * (1 - smooth(215, 229, dc));
  290. const r = Math.hypot(x - CENTER.x, z - CENTER.z);
  291. const ring = smooth(1250, 2000, r);
  292. if (ring > 0) h += ring * (90 + 170 * ridged(x * 0.0014, z * 0.0014, 4));
  293. return h;
  294. }
  295.  
  296. // ---------------------------------------------------------------------------
  297. // The looming warp
  298. // f(r) = r inside the boundary D, then D + S * ln(1 + (r - D) / S) beyond it.
  299. // Horizontal distance from the player is compressed, height is kept.
  300. // ---------------------------------------------------------------------------
  301. const warp = { D: 200, S: 250, strength: 1, target: 1, from: 1, animT: 1 };
  302. const uLoom = { value: new THREE.Vector4(200, 250, 1, 0) };
  303. const uCam = { value: new THREE.Vector3() }; // passed explicitly: unlit materials don't get cameraPosition
  304. const uDetailFade = { value: 1 };
  305. const uShip = { value: new THREE.Vector3() }; // airship centre, for warping it as one rigid piece
  306. const uRigidOn = { value: 1 };
  307. function loomF(r) {
  308. if (r <= warp.D) return r;
  309. if (warp.S < 0.5) return warp.D;
  310. return warp.D + warp.S * Math.log(1 + (r - warp.D) / warp.S);
  311. }
  312.  
  313. const LOOM_VERT_DECL = `
  314. uniform vec4 uLoom;
  315. uniform vec3 uCam;
  316. #ifdef LOOM_RIGID
  317. uniform vec3 uRigid;
  318. uniform float uRigidOn;
  319. #endif
  320. varying float vLoomAmt;
  321. float loomF(float r) {
  322. float D = uLoom.x; float S = uLoom.y;
  323. if (r <= D) return r;
  324. if (S < 0.5) return D;
  325. return D + S * log(1.0 + (r - D) / S);
  326. }`;
  327. const LOOM_PROJECT = `
  328. vec4 wPos = vec4(transformed, 1.0);
  329. #ifdef USE_INSTANCING
  330. wPos = instanceMatrix * wPos;
  331. #endif
  332. wPos = modelMatrix * wPos;
  333. vec2 hOff = wPos.xz - uCam.xz;
  334. float hr = length(hOff);
  335. float hs = hr > 0.001 ? mix(1.0, loomF(hr) / hr, uLoom.z) : 1.0;
  336. vec3 disp = vec3(uCam.x + hOff.x * hs, wPos.y, uCam.z + hOff.y * hs);
  337. #ifdef LOOM_RIGID
  338. // Move the whole object by the warp of its centre, so it keeps its shape instead of being squashed
  339. vec2 rOff = uRigid.xz - uCam.xz;
  340. float rr = length(rOff);
  341. float rs = rr > 0.001 ? mix(1.0, loomF(rr) / rr, uLoom.z) : 1.0;
  342. vec3 dispR = wPos.xyz + vec3(rOff.x * (rs - 1.0), 0.0, rOff.y * (rs - 1.0));
  343. disp = mix(disp, dispR, uRigidOn);
  344. hr = mix(hr, rr, uRigidOn);
  345. #endif
  346. float slope = hr <= uLoom.x ? 1.0 : (uLoom.y < 0.5 ? 0.0 : uLoom.y / (uLoom.y + hr - uLoom.x));
  347. vLoomAmt = uLoom.z * (1.0 - slope);
  348. vec4 mvPosition = viewMatrix * wPos; // true position: lighting and fog
  349. gl_Position = projectionMatrix * (viewMatrix * vec4(disp, 1.0));`;
  350. const LOOM_FRAG_DECL = `
  351. varying float vLoomAmt;
  352. uniform float uDetailFade;
  353. float lb2(vec2 a) { a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
  354. float lb4(vec2 a) { return lb2(0.5 * a) * 0.25 + lb2(a); }`;
  355.  
  356. // detail = small props that a painter would leave out of a distant backdrop
  357. function patch(mat, detail = false, rigid = null) {
  358. if (detail) mat.defines = Object.assign({}, mat.defines, { LOOM_DETAIL: "" });
  359. if (rigid) mat.defines = Object.assign({}, mat.defines, { LOOM_RIGID: "" });
  360. mat.onBeforeCompile = (shader) => {
  361. shader.uniforms.uLoom = uLoom;
  362. shader.uniforms.uCam = uCam;
  363. shader.uniforms.uDetailFade = uDetailFade;
  364. if (rigid) { shader.uniforms.uRigid = rigid; shader.uniforms.uRigidOn = uRigidOn; }
  365. shader.vertexShader = shader.vertexShader
  366. .replace("#include <common>", "#include <common>\n" + LOOM_VERT_DECL)
  367. .replace("#include <project_vertex>", LOOM_PROJECT);
  368. shader.fragmentShader = shader.fragmentShader
  369. .replace("#include <common>", "#include <common>\n" + LOOM_FRAG_DECL)
  370. .replace("#include <clipping_planes_fragment>", `#include <clipping_planes_fragment>
  371. #ifdef LOOM_DETAIL
  372. if (smoothstep(0.15, 0.55, vLoomAmt) * uDetailFade > lb4(gl_FragCoord.xy)) discard;
  373. #endif`)
  374. .replace("#include <fog_fragment>", "#include <fog_fragment>\ngl_FragColor.a = clamp(vLoomAmt, 0.0, 1.0) * 0.9;");
  375. };
  376. return mat;
  377. }
  378. const flat = (color, extra = {}, detail = false, rigid = null) => patch(new THREE.MeshPhongMaterial(Object.assign({
  379. color, flatShading: true, shininess: 0, specular: 0x000000
  380. }, extra)), detail, rigid);
  381. const glow = (color, extra = {}, rigid = null) => patch(new THREE.MeshBasicMaterial(Object.assign({ color }, extra)), false, rigid);
  382. const shipFlat = (color, extra = {}) => flat(color, extra, false, uShip);
  383. const shipGlow = (color, extra = {}) => glow(color, extra, uShip);
  384.  
  385. // ---------------------------------------------------------------------------
  386. // Renderer, scene, camera, sky
  387. // ---------------------------------------------------------------------------
  388. const canvas = $("view");
  389. let renderer;
  390. try {
  391. renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: "high-performance" });
  392. } catch (err) {
  393. $("loading").innerHTML = '<div class="error">WebGL is not available in this browser, so the scene cannot render. Try a recent Chrome, Edge or Firefox with hardware acceleration on.</div>';
  394. return;
  395. }
  396. const DPR = Math.min(window.devicePixelRatio || 1, 1.5);
  397. renderer.setPixelRatio(1);
  398.  
  399. const scene = new THREE.Scene();
  400. scene.fog = new THREE.FogExp2(new THREE.Color("#a79cc6"), 0.00016);
  401. const camera = new THREE.PerspectiveCamera(64, 16 / 9, 0.4, 16000);
  402. camera.rotation.order = "YXZ";
  403.  
  404. const SUN_DIR = new THREE.Vector3(-0.5, 0.55, 0.65).normalize();
  405. const MOON_DIR = new THREE.Vector3(0.4, 0.6, -0.6).normalize();
  406. const hemi = new THREE.HemisphereLight(0xc9bdf0, 0x3b4a2c, 0.62);
  407. scene.add(hemi);
  408. const sun = new THREE.DirectionalLight(0xffe0bf, 0.9);
  409. sun.position.copy(SUN_DIR).multiplyScalar(1000);
  410. scene.add(sun);
  411.  
  412. const sky = new THREE.Mesh(
  413. new THREE.SphereGeometry(12000, 32, 16),
  414. new THREE.ShaderMaterial({
  415. side: THREE.BackSide, depthWrite: false, depthTest: false, fog: false,
  416. uniforms: { horizon: { value: new THREE.Color("#b3a6d0") }, zenith: { value: new THREE.Color("#2c2f69") }, sunDir: { value: SUN_DIR },
  417. moonDir: { value: MOON_DIR }, stars: { value: 0 }, sunGlow: { value: 1 } },
  418. vertexShader: `
  419. varying vec3 vDir;
  420. void main() { vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
  421. fragmentShader: `
  422. uniform vec3 horizon; uniform vec3 zenith; uniform vec3 sunDir;
  423. uniform vec3 moonDir; uniform float stars; uniform float sunGlow;
  424. varying vec3 vDir;
  425. void main() {
  426. vec3 d = normalize(vDir);
  427. float t = clamp(d.y * 1.4 + 0.04, 0.0, 1.0);
  428. vec3 c = mix(horizon, zenith, pow(t, 0.7));
  429. float s = max(dot(d, sunDir), 0.0);
  430. c += vec3(1.0, 0.8, 0.55) * (pow(s, 60.0) * 0.5 + pow(s, 5.0) * 0.1) * sunGlow;
  431. c += vec3(1.0, 0.95, 0.85) * smoothstep(0.9993, 0.9996, s) * sunGlow;
  432. if (stars > 0.01) {
  433. vec3 cell = floor(d * 160.0);
  434. float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 45.164))) * 43758.5453);
  435. c += vec3(0.85, 0.9, 1.0) * step(0.992, h) * smoothstep(0.02, 0.2, d.y) * stars * (0.5 + 0.5 * fract(h * 97.0));
  436. float m = max(dot(d, moonDir), 0.0);
  437. c += vec3(0.9, 0.93, 1.0) * (smoothstep(0.9990, 0.9993, m) * 0.9 + pow(m, 40.0) * 0.12) * stars;
  438. }
  439. gl_FragColor = vec4(c, 1.0); // alpha 1 marks sky for the backdrop pass
  440. }`
  441. })
  442. );
  443. sky.renderOrder = -1;
  444. scene.add(sky);
  445.  
  446. // ---------------------------------------------------------------------------
  447. // Geometry helpers
  448. // ---------------------------------------------------------------------------
  449. function mergeParts(parts) {
  450. const pos = [], col = [];
  451. const c = new THREE.Color();
  452. for (const p of parts) {
  453. const g = p.geo.index ? p.geo.toNonIndexed() : p.geo.clone();
  454. if (p.matrix) g.applyMatrix4(p.matrix);
  455. const a = g.attributes.position.array;
  456. c.set(p.color);
  457. for (let i = 0; i < a.length; i += 3) { pos.push(a[i], a[i + 1], a[i + 2]); col.push(c.r, c.g, c.b); }
  458. g.dispose();
  459. }
  460. const out = new THREE.BufferGeometry();
  461. out.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
  462. out.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
  463. out.computeVertexNormals();
  464. return out;
  465. }
  466. const at = (x, y, z, ry = 0, sx = 1, sy = 1, sz = 1, rx = 0, rz = 0) =>
  467. new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(sx, sy, sz));
  468.  
  469. // Walkways: a spiral ramp around the spire up to the balcony, and a bridge from the balcony to the waterfall
  470. const RAMP = { turns: 5, h0: 0.8, h1: 166.5, width: 6, start: Math.PI * 0.5 };
  471. const towerR = (h) => lerp(24, 13, Math.min(1, Math.max(0, h / 170)));
  472. const rampIn = (h) => towerR(h) + 0.2;
  473. const bridge = { s: new THREE.Vector3(), e: new THREE.Vector3(), half: 3.4 };
  474. const anim = { birds: [], clouds: null, ship: null, props: [], streaks: null, streakData: [] };
  475. const spireBase = { y: 0 };
  476. const waterfall = { top: new THREE.Vector3(), dir: new THREE.Vector2(), bottomY: 0 };
  477. const ship = { theta: 0, rx: 1150, rz: 950, y: 185, omega: 0.036, pos: new THREE.Vector3() };
  478. const SHIP_STAND_THETA = 0.3;
  479. const shipAt = (th, out) => out.set(CENTER.x + ship.rx * Math.cos(th), ship.y, CENTER.z + ship.rz * Math.sin(th));
  480.  
  481. // ---------------------------------------------------------------------------
  482. // Build the world
  483. // ---------------------------------------------------------------------------
  484. function buildWorld() {
  485. spireBase.y = H(SPIRE.x, SPIRE.z);
  486.  
  487. // Terrain: denser near the middle, coarser toward the rim
  488. {
  489. const N = 320, S = 2700, A = 0.3;
  490. const map = (u) => S * (A * u + (1 - A) * u * u * u);
  491. const verts = new Float32Array((N + 1) * (N + 1) * 3);
  492. let k = 0;
  493. for (let j = 0; j <= N; j++) {
  494. const z = CENTER.z + map(-1 + 2 * j / N);
  495. for (let i = 0; i <= N; i++) {
  496. const x = CENTER.x + map(-1 + 2 * i / N);
  497. verts[k++] = x; verts[k++] = H(x, z); verts[k++] = z;
  498. }
  499. }
  500. const idx = new Uint32Array(N * N * 6);
  501. k = 0;
  502. for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
  503. const a = j * (N + 1) + i, b = a + 1, c = a + N + 1, d = c + 1;
  504. idx[k++] = a; idx[k++] = c; idx[k++] = b; idx[k++] = b; idx[k++] = c; idx[k++] = d;
  505. }
  506. const g = new THREE.BufferGeometry();
  507. g.setAttribute("position", new THREE.BufferAttribute(verts, 3));
  508. g.setIndex(new THREE.BufferAttribute(idx, 1));
  509. g.computeVertexNormals();
  510. const nrm = g.attributes.normal.array, cols = new Float32Array(verts.length);
  511. const gA = new THREE.Color("#3f7536"), gB = new THREE.Color("#7fa947"), meadow = new THREE.Color("#9cc25a");
  512. const forest = new THREE.Color("#2c5130"), rock = new THREE.Color("#6a6384"), rockD = new THREE.Color("#47415f");
  513. const tmp = new THREE.Color();
  514. for (let v = 0; v < verts.length; v += 3) {
  515. const x = verts[v], h = verts[v + 1], z = verts[v + 2], ny = nrm[v + 1];
  516. const n = fbm(x * 0.02, z * 0.02, 2);
  517. tmp.copy(gA).lerp(gB, n);
  518. tmp.lerp(meadow, 0.5 * (1 - smooth(40, 220, Math.abs(x - pathX(z)))) * n);
  519. tmp.lerp(forest, 0.65 * smooth(0.5, 0.62, fbm(x * 0.006 + 5, z * 0.006 - 2, 3)));
  520. tmp.lerp(n > 0.5 ? rock : rockD, Math.max(smooth(0.3, 0.55, 1 - ny), smooth(260, 380, h + n * 80)));
  521. cols[v] = tmp.r; cols[v + 1] = tmp.g; cols[v + 2] = tmp.b;
  522. }
  523. g.setAttribute("color", new THREE.BufferAttribute(cols, 3));
  524. scene.add(new THREE.Mesh(g, flat(0xffffff, { vertexColors: true })));
  525. }
  526.  
  527. // Path
  528. {
  529. const pos = [], col = [];
  530. const stone = ["#d8c9a4", "#bfae8a", "#e6dcc0", "#a99a7c"].map((c) => new THREE.Color(c));
  531. let prev = null;
  532. for (let z = PATH_START; z >= PATH_END; z -= 2) {
  533. const x = pathX(z);
  534. const tx = pathX(z - 1) - x, len = Math.hypot(tx, 1);
  535. const nx = 1 / len, nz = tx / len;
  536. const w = 1.8 + 0.3 * Math.sin(z * 0.21);
  537. const L = [x + nx * w, 0, z + nz * w], R = [x - nx * w, 0, z - nz * w];
  538. L[1] = H(L[0], L[2]) + 0.25; R[1] = H(R[0], R[2]) + 0.25;
  539. if (prev) {
  540. const c1 = stone[hash2(z | 0, 1) * 4 | 0], c2 = stone[hash2(z | 0, 2) * 4 | 0];
  541. pos.push(...prev.L, ...prev.R, ...L, ...prev.R, ...R, ...L);
  542. for (let i = 0; i < 3; i++) col.push(c1.r, c1.g, c1.b);
  543. for (let i = 0; i < 3; i++) col.push(c2.r, c2.g, c2.b);
  544. }
  545. prev = { L, R };
  546. }
  547. const g = new THREE.BufferGeometry();
  548. g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
  549. g.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
  550. g.computeVertexNormals();
  551. scene.add(new THREE.Mesh(g, flat(0xffffff, { vertexColors: true, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, side: THREE.DoubleSide })));
  552. }
  553.  
  554. // The spire
  555. {
  556. const by = spireBase.y;
  557. const parts = [
  558. { geo: new THREE.CylinderGeometry(42, 44, 2, 24), color: "#c9bfa4", matrix: at(0, by - 0.2, 0) },
  559. { geo: new THREE.CylinderGeometry(24.4, 25, 6, 8), color: "#5a5578", matrix: at(0, by + 2, 0, Math.PI / 8) },
  560. { geo: new THREE.CylinderGeometry(13, 24, 170, 8), color: "#4a4668", matrix: at(0, by + 85, 0, Math.PI / 8) },
  561. { geo: new THREE.CylinderGeometry(22, 22, 3, 16), color: "#6d6890", matrix: at(0, by + 165, 0) },
  562. { geo: new THREE.CylinderGeometry(10, 13, 22, 8), color: "#4a4668", matrix: at(0, by + 177, 0, Math.PI / 8) },
  563. { geo: new THREE.ConeGeometry(12, 70, 8), color: "#2a2842", matrix: at(0, by + 223, 0, Math.PI / 8) }
  564. ];
  565. const m = new THREE.Mesh(mergeParts(parts), flat(0xffffff, { vertexColors: true }));
  566. m.position.set(SPIRE.x, 0, SPIRE.z);
  567. scene.add(m);
  568.  
  569. // Lit windows on the eight faces
  570. const wins = new THREE.InstancedMesh(new THREE.BoxGeometry(1.8, 3.4, 0.6), glow(0xffcf6a), 220);
  571. let nw = 0;
  572. for (let y = 12; y < 160 && nw < 220; y += 8) {
  573. for (let f = 0; f < 8; f++) {
  574. if (rand() < 0.55) continue;
  575. const a = (f + 0.5) * Math.PI / 4 + Math.PI / 8;
  576. const r = lerp(24, 13, y / 170) * Math.cos(Math.PI / 8) + 0.15;
  577. wins.setMatrixAt(nw++, at(SPIRE.x + Math.sin(a) * r, by + y, SPIRE.z + Math.cos(a) * r, a));
  578. }
  579. }
  580. for (let f = 0; f < 8; f++) {
  581. const a = (f + 0.5) * Math.PI / 4 + Math.PI / 8, r = 11.6;
  582. if (nw < 220) wins.setMatrixAt(nw++, at(SPIRE.x + Math.sin(a) * r, by + 178, SPIRE.z + Math.cos(a) * r, a, 1.2, 1.5, 1));
  583. }
  584. wins.count = nw;
  585. scene.add(wins);
  586.  
  587. // Birds circling the top
  588. const wingGeo = new THREE.BufferGeometry();
  589. wingGeo.setAttribute("position", new THREE.Float32BufferAttribute([0, 0, -1, 0, 0, 1, 1, 0, 0.2], 3));
  590. const birdMat = glow(0x1c1a2c, { side: THREE.DoubleSide });
  591. for (let i = 0; i < 14; i++) {
  592. const b = new THREE.Group();
  593. const s = 1.6 + rand() * 0.8;
  594. const l = new THREE.Mesh(wingGeo, birdMat), r = new THREE.Mesh(wingGeo, birdMat);
  595. l.scale.setScalar(s); r.scale.set(-s, s, s);
  596. b.add(l, r);
  597. b.userData = { l, r, cx: SPIRE.x, cy: by + 190 + rand() * 50, cz: SPIRE.z, rad: 35 + rand() * 60,
  598. speed: (0.25 + rand() * 0.2) * (rand() < 0.5 ? -1 : 1), phase: rand() * 6.28, flap: 4 + rand() * 2 };
  599. scene.add(b); anim.birds.push(b);
  600. }
  601. }
  602.  
  603. // Spiral ramp around the spire
  604. {
  605. const by = spireBase.y;
  606. const segs = RAMP.turns * 64, total = RAMP.turns * Math.PI * 2;
  607. const deck = [], dcol = [], wall = [];
  608. const cA = new THREE.Color("#b9b0c9"), cB = new THREE.Color("#a79ebb");
  609. const P = (ang, h, r) => [SPIRE.x + Math.cos(RAMP.start + ang) * r, by + h, SPIRE.z + Math.sin(RAMP.start + ang) * r];
  610. for (let i = 0; i < segs; i++) {
  611. const a0 = i / segs * total, a1 = (i + 1) / segs * total;
  612. const h0 = lerp(RAMP.h0, RAMP.h1, i / segs), h1 = lerp(RAMP.h0, RAMP.h1, (i + 1) / segs);
  613. const i0 = rampIn(h0), i1 = rampIn(h1), o0 = i0 + RAMP.width, o1 = i1 + RAMP.width;
  614. const A = P(a0, h0, i0), B = P(a0, h0, o0), C = P(a1, h1, i1), D = P(a1, h1, o1);
  615. deck.push(...A, ...C, ...B, ...B, ...C, ...D);
  616. const c = i % 2 ? cA : cB;
  617. for (let k = 0; k < 6; k++) dcol.push(c.r, c.g, c.b);
  618. // underside and outer parapet
  619. const Au = [A[0], A[1] - 1.2, A[2]], Bu = [B[0], B[1] - 1.2, B[2]], Cu = [C[0], C[1] - 1.2, C[2]], Du = [D[0], D[1] - 1.2, D[2]];
  620. const Bt = [B[0], B[1] + 1.1, B[2]], Dt = [D[0], D[1] + 1.1, D[2]];
  621. wall.push(...Au, ...Bu, ...Cu, ...Bu, ...Du, ...Cu);
  622. wall.push(...Bu, ...Bt, ...Du, ...Bt, ...Dt, ...Du);
  623. }
  624. const dg = new THREE.BufferGeometry();
  625. dg.setAttribute("position", new THREE.Float32BufferAttribute(deck, 3));
  626. dg.setAttribute("color", new THREE.Float32BufferAttribute(dcol, 3));
  627. dg.computeVertexNormals();
  628. scene.add(new THREE.Mesh(dg, flat(0xffffff, { vertexColors: true, side: THREE.DoubleSide })));
  629. const wg = new THREE.BufferGeometry();
  630. wg.setAttribute("position", new THREE.Float32BufferAttribute(wall, 3));
  631. wg.computeVertexNormals();
  632. scene.add(new THREE.Mesh(wg, flat(0x6d6890, { side: THREE.DoubleSide })));
  633. // lanterns along the parapet
  634. const lan = new THREE.InstancedMesh(new THREE.BoxGeometry(0.7, 0.9, 0.7), glow(0xffd98a), 200);
  635. let nl = 0;
  636. for (let i = 0; i < segs && nl < 200; i += 4) {
  637. const a = i / segs * total, h = lerp(RAMP.h0, RAMP.h1, i / segs);
  638. const p = P(a, h + 1.6, rampIn(h) + RAMP.width);
  639. lan.setMatrixAt(nl++, at(p[0], p[1], p[2]));
  640. }
  641. lan.count = nl;
  642. scene.add(lan);
  643. }
  644.  
  645. // Standing stones
  646. {
  647. const by = H(STONES.x, STONES.z);
  648. const parts = [{ geo: new THREE.CylinderGeometry(9, 10, 3, 10), color: "#8f89a6", matrix: at(0, by + 1, 0) }];
  649. for (let i = 0; i < 12; i++) {
  650. const a = i / 12 * Math.PI * 2;
  651. const h = 22 + rand() * 9;
  652. const x = Math.cos(a) * 55, z = Math.sin(a) * 55;
  653. parts.push({ geo: new THREE.BoxGeometry(6, h, 3.6), color: i % 2 ? "#8a84a1" : "#7a7493",
  654. matrix: at(x, H(STONES.x + x, STONES.z + z) + h / 2 - 1.5, z, -a + Math.PI / 2, 1, 1, 1, (rand() - 0.5) * 0.08, (rand() - 0.5) * 0.08) });
  655. if (i % 3 === 0) {
  656. const a2 = a + Math.PI / 12, x2 = Math.cos(a2) * 55, z2 = Math.sin(a2) * 55;
  657. parts.push({ geo: new THREE.BoxGeometry(18, 3.4, 4), color: "#6f6989",
  658. matrix: at(x2, H(STONES.x + x, STONES.z + z) + h - 0.5, z2, -a2 + Math.PI / 2) });
  659. }
  660. }
  661. const m = new THREE.Mesh(mergeParts(parts), flat(0xffffff, { vertexColors: true }));
  662. m.position.set(STONES.x, 0, STONES.z);
  663. scene.add(m);
  664. }
  665.  
  666. // Waterfall off the plateau edge, facing the spire. The water follows the actual cliff face,
  667. // so it can't disappear into the rock on the way down.
  668. {
  669. const d = new THREE.Vector2(SPIRE.x - CLIFF.x, SPIRE.z - CLIFF.z).normalize();
  670. const perp = new THREE.Vector2(-d.y, d.x);
  671. const pAt = (r) => H(CLIFF.x + d.x * r, CLIFF.z + d.y * r);
  672. const topY = pAt(185);
  673. let lowR = 185;
  674. for (let r = 185; r < 340; r++) { lowR = r; if (pAt(r) < topY - 112) break; }
  675. const lowY = pAt(lowR + 8);
  676. const drop = topY - lowY;
  677. const NP = 40, prof = [];
  678. let rr = 185;
  679. for (let k = 0; k <= NP; k++) {
  680. const y = topY - (k / NP) * drop;
  681. while (rr < lowR + 30 && pAt(rr) > y - 0.5) rr += 0.5;
  682. prof.push({ y: y + 0.4, out: rr + 2.2 + (k / NP) * 2.5 });
  683. }
  684. const facing = Math.atan2(d.x, d.y);
  685. waterfall.top.set(CLIFF.x + d.x * prof[0].out, topY, CLIFF.z + d.y * prof[0].out);
  686. waterfall.dir.copy(d); waterfall.bottomY = lowY;
  687. // the sheet of water, as a ribbon down the face
  688. const pos = [], col = [];
  689. const cTop = new THREE.Color("#7fb6e0"), cBot = new THREE.Color("#d7ecff"), cc = new THREE.Color();
  690. const HW = 11;
  691. for (let k = 0; k < NP; k++) {
  692. const A = prof[k], B = prof[k + 1];
  693. const ax = CLIFF.x + d.x * A.out, az = CLIFF.z + d.y * A.out, bx = CLIFF.x + d.x * B.out, bz = CLIFF.z + d.y * B.out;
  694. const w0 = HW * (1 + k / NP * 0.25), w1 = HW * (1 + (k + 1) / NP * 0.25);
  695. const p0 = [ax + perp.x * w0, A.y, az + perp.y * w0], p1 = [ax - perp.x * w0, A.y, az - perp.y * w0];
  696. const p2 = [bx + perp.x * w1, B.y, bz + perp.y * w1], p3 = [bx - perp.x * w1, B.y, bz - perp.y * w1];
  697. pos.push(...p0, ...p1, ...p2, ...p1, ...p3, ...p2);
  698. cc.copy(cTop).lerp(cBot, Math.pow(k / NP, 1.5) * (k % 2 ? 0.9 : 1));
  699. for (let i = 0; i < 6; i++) col.push(cc.r, cc.g, cc.b);
  700. }
  701. const rg = new THREE.BufferGeometry();
  702. rg.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
  703. rg.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
  704. rg.computeVertexNormals();
  705. scene.add(new THREE.Mesh(rg, flat(0xffffff, { vertexColors: true, emissive: 0x1d3a5a, side: THREE.DoubleSide })));
  706. // falling streaks
  707. const NS = 90;
  708. const streaks = new THREE.InstancedMesh(new THREE.BoxGeometry(1.3, 12, 0.4), glow(0xf2faff), NS);
  709. for (let i = 0; i < NS; i++) anim.streakData.push({ off: (rand() - 0.5) * 20, ph: rand(), sp: 0.26 + rand() * 0.1 });
  710. anim.streaks = streaks;
  711. anim.streakGeo = { prof, d, perp, facing };
  712. scene.add(streaks);
  713. // pool and spray at the foot
  714. const last = prof[NP];
  715. const px = CLIFF.x + d.x * (last.out + 5), pz = CLIFF.z + d.y * (last.out + 5);
  716. const poolY = Math.min(H(px, pz), last.y);
  717. const pool = new THREE.Mesh(new THREE.CylinderGeometry(24, 26, 2, 18), flat(0x6fa7d6, { emissive: 0x12304f }));
  718. pool.position.set(px, poolY + 0.2, pz);
  719. scene.add(pool);
  720. const puff = new THREE.IcosahedronGeometry(1, 1), sprayMat = flat(0xe9f1ff, { emissive: 0x2e3b56 });
  721. for (let i = 0; i < 12; i++) {
  722. const q = (rand() - 0.5) * 22, o = last.out - 1 + rand() * 5;
  723. const p = new THREE.Mesh(puff, sprayMat);
  724. p.position.set(CLIFF.x + d.x * o + perp.x * q, last.y + 1 + rand() * 7, CLIFF.z + d.y * o + perp.y * q);
  725. p.scale.set(6 + rand() * 5, 3 + rand() * 3, 6 + rand() * 5);
  726. scene.add(p);
  727. }
  728. }
  729.  
  730. // Bridge from the spire's balcony down to the top of the waterfall
  731. {
  732. const by = spireBase.y;
  733. const d = waterfall.dir, perp = new THREE.Vector2(-d.y, d.x);
  734. const ex = waterfall.top.x - d.x * 30 + perp.x * 26, ez = waterfall.top.z - d.y * 30 + perp.y * 26;
  735. const dir = new THREE.Vector2(ex - SPIRE.x, ez - SPIRE.z).normalize();
  736. bridge.s.set(SPIRE.x + dir.x * 20, by + RAMP.h1, SPIRE.z + dir.y * 20);
  737. bridge.e.set(ex, H(ex, ez) + 0.3, ez);
  738. const len = Math.hypot(bridge.e.x - bridge.s.x, bridge.e.z - bridge.s.z);
  739. const side = new THREE.Vector2(-dir.y, dir.x);
  740. const N = Math.ceil(len / 6), hw = bridge.half;
  741. const deck = [], col = [], rail = [];
  742. const cA = new THREE.Color("#c2b8cf"), cB = new THREE.Color("#b0a6c2");
  743. const pt = (t, o, dy = 0) => [lerp(bridge.s.x, bridge.e.x, t) + side.x * o, lerp(bridge.s.y, bridge.e.y, t) + dy, lerp(bridge.s.z, bridge.e.z, t) + side.y * o];
  744. for (let i = 0; i < N; i++) {
  745. const t0 = i / N, t1 = (i + 1) / N;
  746. const A = pt(t0, -hw), B = pt(t0, hw), C = pt(t1, -hw), D = pt(t1, hw);
  747. deck.push(...A, ...C, ...B, ...B, ...C, ...D);
  748. const c = i % 2 ? cA : cB;
  749. for (let k = 0; k < 6; k++) col.push(c.r, c.g, c.b);
  750. for (const o of [-hw, hw]) {
  751. const a0 = pt(t0, o, -1.6), a1 = pt(t0, o, 1.1), b0 = pt(t1, o, -1.6), b1 = pt(t1, o, 1.1);
  752. rail.push(...a0, ...a1, ...b0, ...a1, ...b1, ...b0);
  753. }
  754. const u0 = pt(t0, -hw, -1.6), u1 = pt(t0, hw, -1.6), u2 = pt(t1, -hw, -1.6), u3 = pt(t1, hw, -1.6);
  755. rail.push(...u0, ...u1, ...u2, ...u1, ...u3, ...u2);
  756. }
  757. const dg = new THREE.BufferGeometry();
  758. dg.setAttribute("position", new THREE.Float32BufferAttribute(deck, 3));
  759. dg.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
  760. dg.computeVertexNormals();
  761. scene.add(new THREE.Mesh(dg, flat(0xffffff, { vertexColors: true, side: THREE.DoubleSide })));
  762. const rg = new THREE.BufferGeometry();
  763. rg.setAttribute("position", new THREE.Float32BufferAttribute(rail, 3));
  764. rg.computeVertexNormals();
  765. scene.add(new THREE.Mesh(rg, flat(0x6d6890, { side: THREE.DoubleSide })));
  766. // stone piers down to the ground, and lanterns
  767. const piers = [];
  768. const lan = new THREE.InstancedMesh(new THREE.BoxGeometry(0.7, 0.9, 0.7), glow(0xffd98a), 120);
  769. let nl = 0;
  770. for (let t = 0.06; t < 0.97; t += 55 / len) {
  771. const [x, y, z] = pt(t, 0, -1.6);
  772. const g = H(x, z);
  773. if (y - g > 4) {
  774. const hgt = y - g + 2;
  775. piers.push({ geo: new THREE.BoxGeometry(5, hgt, 5), color: "#5f5a7e", matrix: at(x, g - 2 + hgt / 2, z, Math.atan2(dir.x, dir.y)) });
  776. piers.push({ geo: new THREE.BoxGeometry(8, 3, 8), color: "#6d6890", matrix: at(x, y - 1.2, z, Math.atan2(dir.x, dir.y)) });
  777. }
  778. }
  779. for (let t = 0.02; t < 1 && nl < 120; t += 24 / len) {
  780. for (const o of [-hw, hw]) { const p = pt(t, o, 1.6); if (nl < 120) lan.setMatrixAt(nl++, at(p[0], p[1], p[2])); }
  781. }
  782. lan.count = nl;
  783. if (piers.length) scene.add(new THREE.Mesh(mergeParts(piers), flat(0xffffff, { vertexColors: true })));
  784. scene.add(lan);
  785. }
  786.  
  787. // Village climbing the spire's hill, plus a few houses at the start
  788. {
  789. const houseGeo = mergeParts([
  790. { geo: new THREE.BoxGeometry(8, 8, 6), color: "#e6dbc0", matrix: at(0, 1, 0) },
  791. { geo: new THREE.ConeGeometry(6.4, 4.2, 4), color: "#a5432c", matrix: at(0, 7.1, 0, Math.PI / 4, 1, 1, 0.78) },
  792. { geo: new THREE.BoxGeometry(1, 3, 1), color: "#6c5a4c", matrix: at(2.2, 7.6, 1) },
  793. { geo: new THREE.BoxGeometry(1.4, 2.2, 0.2), color: "#4a3527", matrix: at(0, -1.9, 3.05) }
  794. ]);
  795. const MAXH = 70;
  796. const houses = new THREE.InstancedMesh(houseGeo, flat(0xffffff, { vertexColors: true }), MAXH);
  797. const placed = [];
  798. let n = 0, tries = 0;
  799. const tint = new THREE.Color();
  800. while (n < MAXH && tries++ < 6000) {
  801. let x, z;
  802. if (n < 58) {
  803. const a = rand() * Math.PI * 2, r = 56 + Math.pow(rand(), 1.4) * 250;
  804. x = SPIRE.x + Math.cos(a) * r; z = SPIRE.z + Math.sin(a) * r;
  805. } else {
  806. z = 250 - rand() * 180;
  807. x = pathX(z) + (rand() < 0.5 ? -1 : 1) * (14 + Math.pow(rand(), 1.5) * 120);
  808. }
  809. if (Math.abs(x - pathX(z)) < 9 && z < PATH_START && z > PATH_END - 10) continue;
  810. const h = H(x, z);
  811. if (Math.abs(H(x + 5, z) - h) > 2.2 || Math.abs(H(x, z + 5) - h) > 2.2) continue;
  812. if (placed.some((p) => Math.hypot(p[0] - x, p[1] - z) < 16)) continue;
  813. placed.push([x, z]);
  814. const s = 0.85 + rand() * 0.4;
  815. const face = n < 58 ? Math.atan2(SPIRE.x - x, SPIRE.z - z) : Math.atan2(pathX(z) - x, 0);
  816. houses.setMatrixAt(n, at(x, h + 3 * s - 0.6, z, face + (rand() - 0.5) * 0.6, s * (0.9 + rand() * 0.4), s, s));
  817. const b = 0.85 + rand() * 0.25;
  818. houses.setColorAt(n, tint.setRGB(b, b * (0.93 + rand() * 0.08), b * (0.88 + rand() * 0.12)));
  819. n++;
  820. }
  821. houses.count = n;
  822. scene.add(houses);
  823. }
  824.  
  825. // Trees, bushes and rocks
  826. {
  827. const conGeo = mergeParts([
  828. { geo: new THREE.CylinderGeometry(0.35, 0.5, 3.2, 5, 1, true), color: "#4a3527", matrix: at(0, 1.6, 0) },
  829. { geo: new THREE.ConeGeometry(3.3, 6, 7, 1, true), color: "#2c5536", matrix: at(0, 5.2, 0) },
  830. { geo: new THREE.ConeGeometry(2.6, 5, 7, 1, true), color: "#356640", matrix: at(0, 8.2, 0, 0.4) },
  831. { geo: new THREE.ConeGeometry(1.8, 4.2, 7, 1, true), color: "#417449", matrix: at(0, 11, 0, 0.8) }
  832. ]);
  833. const roundGeo = mergeParts([
  834. { geo: new THREE.CylinderGeometry(0.35, 0.5, 3.4, 5, 1, true), color: "#523b2a", matrix: at(0, 1.7, 0) },
  835. { geo: new THREE.IcosahedronGeometry(3.4, 0), color: "#4f8a3c", matrix: at(0, 5.6, 0, 0, 1, 0.9, 1) },
  836. { geo: new THREE.IcosahedronGeometry(2.2, 0), color: "#5f9a44", matrix: at(1.2, 7.4, 0.6, 0.7) }
  837. ]);
  838. const bushGeo = mergeParts([{ geo: new THREE.IcosahedronGeometry(1.4, 0), color: "#5c9a45", matrix: at(0, 0.8, 0, 0, 1, 0.75, 1) }]);
  839. const rockGeo = mergeParts([{ geo: new THREE.DodecahedronGeometry(1.2, 0), color: "#8d87a0", matrix: at(0, 0.5, 0, 0, 1, 0.6, 1) }]);
  840. const MAXC = 11000, MAXR = 5000, MAXB = 4000, MAXK = 1600;
  841. const con = new THREE.InstancedMesh(conGeo, flat(0xffffff, { vertexColors: true }), MAXC);
  842. const rnd = new THREE.InstancedMesh(roundGeo, flat(0xffffff, { vertexColors: true }), MAXR);
  843. const bush = new THREE.InstancedMesh(bushGeo, flat(0xffffff, { vertexColors: true }, true), MAXB);
  844. const rock = new THREE.InstancedMesh(rockGeo, flat(0xffffff, { vertexColors: true }, true), MAXK);
  845. let nc = 0, nr = 0, nb = 0, nk = 0;
  846. const tint = new THREE.Color();
  847. const put = (mesh, i, x, y, z, s, lean) => {
  848. mesh.setMatrixAt(i, at(x, y, z, rand() * 6.28, s * (0.9 + rand() * 0.2), s, s * (0.9 + rand() * 0.2), (rand() - 0.5) * lean, (rand() - 0.5) * lean));
  849. const b = 0.8 + rand() * 0.38;
  850. mesh.setColorAt(i, tint.setRGB(b * (0.95 + rand() * 0.1), b, b * (0.9 + rand() * 0.15)));
  851. };
  852. const clear = (x, z) => {
  853. if (z < PATH_START + 40 && z > PATH_END - 10 && Math.abs(x - pathX(z)) < 8) return false;
  854. if (Math.hypot(x - SPIRE.x, z - SPIRE.z) < 60) return false;
  855. if (Math.hypot(x - STONES.x, z - STONES.z) < 90) return false;
  856. const dw = Math.hypot(x - waterfall.top.x, z - waterfall.top.z);
  857. if (dw < 45) return false;
  858. if (Math.hypot(x - standXZ.x, z - standXZ.z) < 90) return false;
  859. if (Math.hypot(x - bridge.e.x, z - bridge.e.z) < 70) return false;
  860. return true;
  861. };
  862. const STEP = 9;
  863. for (let gz = 1100; gz > -2300; gz -= STEP) {
  864. for (let gx = -1950; gx < 1950; gx += STEP) {
  865. const x = gx + (rand() - 0.5) * STEP * 0.9, z = gz + (rand() - 0.5) * STEP * 0.9;
  866. const n = fbm(x * 0.006 + 5, z * 0.006 - 2, 3);
  867. let dens = 0.012 + smooth(0.5, 0.62, n) * 0.5;
  868. if (Math.hypot(x - pathX(z), 0) < 160 && z > -120 && z < 220) dens *= 0.25;
  869. dens *= 0.15 + 0.85 * smooth(200, 360, Math.hypot(x - SPIRE.x, z - SPIRE.z));
  870. if (rand() > dens || !clear(x, z)) continue;
  871. const h = H(x, z);
  872. if (h > 330) continue;
  873. if (Math.abs(H(x + 3, z) - h) > 2.4 || Math.abs(H(x, z + 3) - h) > 2.4) continue;
  874. if (rand() < 0.2) { if (nr < MAXR) put(rnd, nr++, x, h - 0.4, z, 0.8 + rand() * 0.7, 0.08); }
  875. else if (nc < MAXC) put(con, nc++, x, h - 0.4, z, 0.8 + rand() * 1.1, 0.06);
  876. }
  877. }
  878. for (let i = 0; i < 14000 && (nb < MAXB || nk < MAXK); i++) {
  879. const nearPath = rand() < 0.45;
  880. let x, z;
  881. if (nearPath) { z = PATH_START - rand() * (PATH_START - PATH_END); x = pathX(z) + (rand() < 0.5 ? -1 : 1) * (3 + Math.pow(rand(), 1.4) * 30); }
  882. else { x = (rand() - 0.5) * 3000; z = CENTER.z + (rand() - 0.5) * 2800; }
  883. if (!clear(x, z)) continue;
  884. const h = H(x, z);
  885. if (h > 330) continue;
  886. if (rand() < 0.7) { if (nb < MAXB) put(bush, nb++, x, h - 0.2, z, 0.6 + rand() * 0.9, 0.3); }
  887. else if (nk < MAXK) put(rock, nk++, x, h - 0.2, z, 0.5 + rand() * 1.6, 0.6);
  888. }
  889. for (const [m, n] of [[con, nc], [rnd, nr], [bush, nb], [rock, nk]]) {
  890. m.count = n;
  891. m.instanceMatrix.needsUpdate = true;
  892. if (m.instanceColor) m.instanceColor.needsUpdate = true;
  893. scene.add(m);
  894. }
  895. }
  896.  
  897. // Airship
  898. {
  899. const g = new THREE.Group();
  900. const env = new THREE.Mesh(new THREE.SphereGeometry(1, 18, 12), shipFlat(0xc9b089));
  901. env.scale.set(11, 11, 34);
  902. g.add(env);
  903. for (const zz of [-18, 0, 18]) {
  904. const band = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 2.2, 18, 1, true), shipFlat(0x9a3b2c, { side: THREE.DoubleSide }));
  905. const r = 11.15 * Math.sqrt(Math.max(0, 1 - (zz / 34) * (zz / 34)));
  906. band.scale.set(r, 1, r);
  907. band.rotation.x = Math.PI / 2;
  908. band.position.z = zz;
  909. g.add(band);
  910. }
  911. const gond = new THREE.Mesh(new THREE.BoxGeometry(6, 4.5, 18), shipFlat(0x5a4032));
  912. gond.position.set(0, -13.5, 2);
  913. g.add(gond);
  914. for (let i = 0; i < 3; i++) {
  915. const w = new THREE.Mesh(new THREE.BoxGeometry(0.4, 1.4, 1.8), shipGlow(0xffcf6a));
  916. w.position.set(3.1, -13.3, -3 + i * 5);
  917. g.add(w);
  918. const w2 = w.clone(); w2.position.x = -3.1; g.add(w2);
  919. }
  920. const finMat = shipFlat(0x9a3b2c);
  921. for (let i = 0; i < 4; i++) {
  922. const fin = new THREE.Mesh(new THREE.BoxGeometry(0.8, 12, 9), finMat);
  923. const a = i * Math.PI / 2;
  924. fin.position.set(Math.sin(a) * 8, Math.cos(a) * 8, 28);
  925. fin.rotation.z = -a;
  926. g.add(fin);
  927. }
  928. const props = [];
  929. for (const sx of [-1, 1]) {
  930. const hub = new THREE.Group();
  931. hub.position.set(sx * 6.5, -12, 12);
  932. const blade = new THREE.Mesh(new THREE.BoxGeometry(0.6, 7, 0.5), shipFlat(0x3a2b22));
  933. hub.add(blade);
  934. const blade2 = blade.clone(); blade2.rotation.z = Math.PI / 2; hub.add(blade2);
  935. g.add(hub); props.push(hub);
  936. }
  937. anim.props = props;
  938. g.scale.setScalar(1.4);
  939. anim.ship = g;
  940. scene.add(g);
  941. }
  942.  
  943. // Clouds drifting over the rim
  944. {
  945. const clouds = new THREE.Group();
  946. clouds.position.set(CENTER.x, 0, CENTER.z);
  947. const geo = new THREE.IcosahedronGeometry(1, 1), mat = flat(0xc7bedc, { emissive: 0x2a2442 });
  948. for (let i = 0; i < 30; i++) {
  949. const a = rand() * Math.PI * 2, r = 700 + rand() * 1700;
  950. const cl = new THREE.Group();
  951. cl.position.set(Math.cos(a) * r, 480 + rand() * 260, Math.sin(a) * r);
  952. cl.rotation.y = rand() * Math.PI;
  953. const n = 4 + (rand() * 4 | 0);
  954. for (let j = 0; j < n; j++) {
  955. const p = new THREE.Mesh(geo, mat);
  956. p.position.set((rand() - 0.5) * 160, (rand() - 0.5) * 18, (rand() - 0.5) * 70);
  957. p.scale.set(40 + rand() * 55, 15 + rand() * 18, 32 + rand() * 38);
  958. cl.add(p);
  959. }
  960. clouds.add(cl);
  961. }
  962. scene.add(clouds);
  963. anim.clouds = clouds;
  964. }
  965.  
  966. scene.traverse((o) => { o.frustumCulled = false; });
  967. }
  968.  
  969. // ---------------------------------------------------------------------------
  970. // Full-resolution render, then the painted-backdrop pass.
  971. // Materials write their compression amount into alpha (sky writes 1).
  972. // Where that amount is high, pixels are replaced with a '90s-style painted backdrop:
  973. // chunky pixels, flat depth bands, hue-shifted tone ramps, dithering and a light top edge.
  974. // ---------------------------------------------------------------------------
  975. let rt = null, W = 1, Hh = 1;
  976. const post = new THREE.ShaderMaterial({
  977. depthTest: false, depthWrite: false,
  978. uniforms: {
  979. tColor: { value: null }, res: { value: new THREE.Vector2(1, 1) },
  980. block: { value: 4 }, paintOn: { value: 1 }, tintOn: { value: 0 },
  981. tDepth: { value: null }, nearBlock: { value: 2 }, outlineOn: { value: 1 }, nearLevels: { value: 20 },
  982. cNear: { value: camera.near }, cFar: { value: camera.far },
  983. haze: { value: new THREE.Color("#a99ed0") }, shadowTint: { value: new THREE.Vector3(0.78, 0.8, 1.2) },
  984. lightTint: { value: new THREE.Vector3(1.08, 1.03, 0.92) }, rim: { value: new THREE.Color("#fff1d6") }
  985. },
  986. vertexShader: `
  987. varying vec2 vUv;
  988. void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
  989. fragmentShader: `
  990. uniform sampler2D tColor; uniform vec2 res; uniform float block; uniform float paintOn; uniform float tintOn;
  991. uniform vec3 haze; uniform vec3 shadowTint; uniform vec3 lightTint; uniform vec3 rim;
  992. uniform sampler2D tDepth; uniform float nearBlock; uniform float outlineOn; uniform float nearLevels;
  993. uniform float cNear; uniform float cFar;
  994. float depthAt(vec2 p) {
  995. float d = texture2D(tDepth, p / res).x;
  996. if (d > 0.99999) return cFar * 10.0;
  997. float z = d * 2.0 - 1.0;
  998. return (2.0 * cNear * cFar) / (cFar + cNear - z * (cFar - cNear));
  999. }
  1000. varying vec2 vUv;
  1001. float b2(vec2 a) { a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
  1002. float b4(vec2 a) { return b2(0.5 * a) * 0.25 + b2(a); }
  1003. float amtOf(vec4 c) { return c.a > 0.95 ? 1.0 : clamp(c.a / 0.9, 0.0, 1.0); }
  1004. void main() {
  1005. vec2 px = floor(vUv * res);
  1006. // near field: finer pixels, a light palette limit and dark outlines
  1007. vec2 nc = (floor(px / nearBlock) + 0.5) * nearBlock;
  1008. vec4 c = texture2D(tColor, nc / res);
  1009. vec3 col = c.rgb;
  1010. float amt = amtOf(c);
  1011. if (outlineOn > 0.5 && c.a < 0.95) {
  1012. float dC = depthAt(nc);
  1013. float dF = max(max(depthAt(nc + vec2(nearBlock, 0.0)), depthAt(nc - vec2(nearBlock, 0.0))),
  1014. max(depthAt(nc + vec2(0.0, nearBlock)), depthAt(nc - vec2(0.0, nearBlock))));
  1015. if (dF > dC * 1.12 + 0.6) col *= 0.55;
  1016. }
  1017. col = floor(col * nearLevels + 0.5) / nearLevels;
  1018. vec2 bp = floor(px / block);
  1019. vec2 bc = (bp + 0.5) * block;
  1020. vec4 cb = texture2D(tColor, bc / res);
  1021. // average a few samples so the painted pixel is a clean flat colour, not a noisy pick
  1022. vec3 avg = (texture2D(tColor, (bc + vec2(-0.25, -0.25) * block) / res).rgb +
  1023. texture2D(tColor, (bc + vec2( 0.25, -0.25) * block) / res).rgb +
  1024. texture2D(tColor, (bc + vec2(-0.25, 0.25) * block) / res).rgb +
  1025. texture2D(tColor, (bc + vec2( 0.25, 0.25) * block) / res).rgb) * 0.25;
  1026. float amtB = amtOf(cb);
  1027. float dth = b4(bp);
  1028. float t = smoothstep(0.12, 0.42, amtB) * paintOn;
  1029. if (t > dth && amt > 0.04) {
  1030. bool sky = cb.a > 0.95;
  1031. vec3 p = mix(cb.rgb, avg, 0.75);
  1032. if (!sky) {
  1033. float band = amtB < 0.5 ? 0.0 : (amtB < 0.66 ? 1.0 : (amtB < 0.8 ? 2.0 : 3.0));
  1034. p = mix(p, haze, band * 0.12);
  1035. }
  1036. float L = max(dot(p, vec3(0.299, 0.587, 0.114)), 0.001);
  1037. float lev = sky ? 14.0 : 6.0;
  1038. float Lq = max(floor(L * lev + 0.5 + (dth - 0.5) * 0.6), 0.6) / lev;
  1039. p = p * (Lq / L);
  1040. p = mix(p, p * shadowTint, (1.0 - Lq) * 0.75);
  1041. p = mix(p, p * lightTint, Lq * 0.55);
  1042. if (!sky) {
  1043. vec4 up = texture2D(tColor, (bc + vec2(0.0, block)) / res);
  1044. if (up.a > 0.95) p = mix(p, rim, 0.35);
  1045. }
  1046. col = floor(clamp(p, 0.0, 1.0) * 16.0 + 0.5) / 16.0;
  1047. }
  1048. if (tintOn > 0.5) col = mix(col, vec3(0.25, 0.72, 1.0), amt * 0.55 * (c.a > 0.95 ? 0.0 : 1.0));
  1049. gl_FragColor = vec4(col, 1.0);
  1050. }`
  1051. });
  1052. const postScene = new THREE.Scene();
  1053. const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), post);
  1054. quad.frustumCulled = false;
  1055. postScene.add(quad);
  1056. const postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  1057.  
  1058. // Time of day: keyframed palettes, sun and moon
  1059. const C = (h) => new THREE.Color(h);
  1060. const TOD = [
  1061. { t: 0, zen: "#070a1c", hor: "#1b2146", sun: "#7d8fd0", si: 0.32, hs: "#34407a", hg: "#0e1210", hi: 0.38, fog: "#161b38", haze: "#2a3160", rim: "#9fb3ff", st: 1 },
  1062. { t: 5.5, zen: "#141a3d", hor: "#4a3f6e", sun: "#8c90c8", si: 0.3, hs: "#4d4f86", hg: "#151a14", hi: 0.42, fog: "#3a3a62", haze: "#4a4a7a", rim: "#c0c4ff", st: 0.5 },
  1063. { t: 7, zen: "#3a4a8a", hor: "#f0a57e", sun: "#ffb88a", si: 0.75, hs: "#c8aecc", hg: "#3a3a2a", hi: 0.55, fog: "#d2a6a0", haze: "#d8aca8", rim: "#ffe0b8", st: 0 },
  1064. { t: 12, zen: "#3565b8", hor: "#b9d6f0", sun: "#fff6e6", si: 1.05, hs: "#d6e6ff", hg: "#4d5c33", hi: 0.72, fog: "#bcd2ea", haze: "#b8cde6", rim: "#ffffff", st: 0 },
  1065. { t: 16.5, zen: "#2c2f69", hor: "#b3a6d0", sun: "#ffe0bf", si: 0.9, hs: "#c9bdf0", hg: "#3b4a2c", hi: 0.62, fog: "#a79cc6", haze: "#a99ed0", rim: "#fff1d6", st: 0 },
  1066. { t: 19, zen: "#2a2a60", hor: "#f08c63", sun: "#ff9a5a", si: 0.75, hs: "#b48fb8", hg: "#3a2e2a", hi: 0.5, fog: "#c98e8e", haze: "#c99098", rim: "#ffd2a0", st: 0.05 },
  1067. { t: 20.5, zen: "#121739", hor: "#5a3f6a", sun: "#8a86c0", si: 0.35, hs: "#4a4a80", hg: "#141614", hi: 0.42, fog: "#3a3560", haze: "#4a4278", rim: "#c0b8ff", st: 0.6 },
  1068. { t: 24, zen: "#070a1c", hor: "#1b2146", sun: "#7d8fd0", si: 0.32, hs: "#34407a", hg: "#0e1210", hi: 0.38, fog: "#161b38", haze: "#2a3160", rim: "#9fb3ff", st: 1 }
  1069. ].map((k) => ({ ...k, zen: C(k.zen), hor: C(k.hor), sun: C(k.sun), hs: C(k.hs), hg: C(k.hg), fog: C(k.fog), haze: C(k.haze), rim: C(k.rim) }));
  1070. const tod = { t: 16.5, cycle: false };
  1071. const lightDir = new THREE.Vector3();
  1072. function setTime(t) {
  1073. tod.t = ((t % 24) + 24) % 24;
  1074. let i = 0;
  1075. while (i < TOD.length - 2 && TOD[i + 1].t <= tod.t) i++;
  1076. const a = TOD[i], b = TOD[i + 1], u = ease((tod.t - a.t) / (b.t - a.t));
  1077. const su = sky.material.uniforms;
  1078. su.zenith.value.copy(a.zen).lerp(b.zen, u);
  1079. su.horizon.value.copy(a.hor).lerp(b.hor, u);
  1080. su.stars.value = lerp(a.st, b.st, u);
  1081. sun.color.copy(a.sun).lerp(b.sun, u);
  1082. sun.intensity = lerp(a.si, b.si, u);
  1083. hemi.color.copy(a.hs).lerp(b.hs, u);
  1084. hemi.groundColor.copy(a.hg).lerp(b.hg, u);
  1085. hemi.intensity = lerp(a.hi, b.hi, u);
  1086. scene.fog.color.copy(a.fog).lerp(b.fog, u);
  1087. post.uniforms.haze.value.copy(a.haze).lerp(b.haze, u);
  1088. post.uniforms.rim.value.copy(a.rim).lerp(b.rim, u);
  1089. // the sun crosses from east (+x) at 6:00, behind the start (+z) at noon, to west at 18:00
  1090. const phi = Math.PI * (tod.t - 6) / 12;
  1091. const el = Math.sin(phi) * 1.0;
  1092. SUN_DIR.set(Math.cos(phi) * Math.cos(el), Math.sin(el), Math.sin(phi) * Math.cos(el)).normalize();
  1093. MOON_DIR.set(-Math.cos(phi) * 0.8, 0.6, -Math.sin(phi) * 0.8 - 0.2).normalize();
  1094. su.sunGlow.value = smooth(-0.08, 0.05, SUN_DIR.y);
  1095. const day = smooth(-0.05, 0.12, SUN_DIR.y);
  1096. lightDir.copy(MOON_DIR).lerp(SUN_DIR, day).normalize();
  1097. sun.position.copy(lightDir).multiplyScalar(1000);
  1098. const hh = Math.floor(tod.t), mm = Math.floor((tod.t - hh) * 60);
  1099. $("todOut").textContent = `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
  1100. $("tod").value = tod.t.toFixed(1);
  1101. }
  1102.  
  1103. const style = { paint: true, block: 4, nearBlock: 2, outline: true, tint: false };
  1104. function resize() {
  1105. W = Math.max(1, Math.round(window.innerWidth * DPR));
  1106. Hh = Math.max(1, Math.round(window.innerHeight * DPR));
  1107. renderer.setSize(W, Hh, false);
  1108. camera.aspect = W / Hh;
  1109. camera.updateProjectionMatrix();
  1110. if (rt) { if (rt.depthTexture) rt.depthTexture.dispose(); rt.dispose(); }
  1111. rt = new THREE.WebGLRenderTarget(W, Hh, {
  1112. minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, generateMipmaps: false,
  1113. format: THREE.RGBAFormat, depthBuffer: true
  1114. });
  1115. rt.depthTexture = new THREE.DepthTexture(W, Hh);
  1116. rt.depthTexture.type = THREE.UnsignedIntType;
  1117. post.uniforms.tColor.value = rt.texture;
  1118. post.uniforms.tDepth.value = rt.depthTexture;
  1119. post.uniforms.res.value.set(W, Hh);
  1120. post.uniforms.block.value = Math.max(1, Math.round(style.block * DPR));
  1121. post.uniforms.nearBlock.value = Math.max(1, Math.round(style.nearBlock * DPR));
  1122. }
  1123. window.addEventListener("resize", resize);
  1124.  
  1125. // ---------------------------------------------------------------------------
  1126. // Player and controls
  1127. // ---------------------------------------------------------------------------
  1128. const pos = new THREE.Vector3(pathX(40), 0, 40);
  1129. let yaw = 0, pitch = 0.12, locked = false;
  1130. const keys = Object.create(null);
  1131. const move = { fly: false, speed: 10 };
  1132. let mode = "tour";
  1133.  
  1134. function warpedXZ(x, z) {
  1135. const dx = x - pos.x, dz = z - pos.z, r = Math.hypot(dx, dz);
  1136. const s = r > 0.001 ? lerp(1, loomF(r) / r, warp.strength) : 1;
  1137. return [dx * s, dz * s];
  1138. }
  1139. // Aim the camera at where a world point appears after warping
  1140. function aimAt(x, y, z, rate, dt, instant = false) {
  1141. const [dx, dz] = warpedXZ(x, z);
  1142. const ty = Math.atan2(-dx, -dz);
  1143. const tp = Math.max(-1.45, Math.min(1.45, Math.atan2(y - pos.y, Math.hypot(dx, dz))));
  1144. const k = instant ? 1 : Math.min(1, dt * rate);
  1145. yaw += angDiff(ty, yaw) * k;
  1146. pitch += (tp - pitch) * k;
  1147. }
  1148. const groundY = (x, z) => H(x, z) + 1.7;
  1149. // Height of the highest walkable surface at (x, z) that the feet can step onto
  1150. function surfaceY(x, z, feet) {
  1151. let best = H(x, z);
  1152. const by = spireBase.y, reach = feet + 1.3;
  1153. const dx = x - SPIRE.x, dz = z - SPIRE.z, r = Math.hypot(dx, dz);
  1154. if (r < 42 && by + 0.8 <= reach) best = Math.max(best, by + 0.8);
  1155. if (r < 22.5 && by + RAMP.h1 <= reach) best = Math.max(best, by + RAMP.h1);
  1156. if (r < 40) {
  1157. const total = RAMP.turns * Math.PI * 2;
  1158. let a = Math.atan2(dz, dx) - RAMP.start;
  1159. a = ((a % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
  1160. for (let k = 0; k <= RAMP.turns; k++) {
  1161. const ang = a + k * Math.PI * 2;
  1162. if (ang > total) break;
  1163. const h = lerp(RAMP.h0, RAMP.h1, ang / total), ri = rampIn(h);
  1164. if (r >= ri - 0.4 && r <= ri + RAMP.width + 0.2 && by + h <= reach) best = Math.max(best, by + h);
  1165. }
  1166. }
  1167. const bx = bridge.e.x - bridge.s.x, bz = bridge.e.z - bridge.s.z, L2 = bx * bx + bz * bz;
  1168. if (L2 > 1) {
  1169. const t = ((x - bridge.s.x) * bx + (z - bridge.s.z) * bz) / L2;
  1170. if (t >= 0 && t <= 1) {
  1171. const lat = Math.abs((x - bridge.s.x) * bz - (z - bridge.s.z) * bx) / Math.sqrt(L2);
  1172. const y = lerp(bridge.s.y, bridge.e.y, t);
  1173. if (lat < bridge.half && y <= reach) best = Math.max(best, y);
  1174. }
  1175. }
  1176. return best;
  1177. }
  1178. // Keep the player out of the tower itself
  1179. function pushOutOfTower() {
  1180. const h = pos.y - 1.7 - spireBase.y;
  1181. if (h < -2 || h > 190) return;
  1182. const dx = pos.x - SPIRE.x, dz = pos.z - SPIRE.z, r = Math.hypot(dx, dz) || 0.001;
  1183. const minR = (h > 166 ? 13.4 : towerR(h)) + 0.5;
  1184. if (r < minR) { pos.x = SPIRE.x + dx / r * minR; pos.z = SPIRE.z + dz / r * minR; }
  1185. }
  1186. let fallV = 0;
  1187.  
  1188. canvas.addEventListener("click", () => { if (mode === "roam" && !locked && canvas.requestPointerLock) canvas.requestPointerLock(); });
  1189. document.addEventListener("pointerlockchange", () => {
  1190. locked = document.pointerLockElement === canvas;
  1191. $("hint").classList.toggle("hidden", locked || mode !== "roam");
  1192. });
  1193. document.addEventListener("mousemove", (e) => {
  1194. if (!locked) return;
  1195. yaw -= e.movementX * 0.0022;
  1196. pitch = Math.max(-1.5, Math.min(1.5, pitch - e.movementY * 0.0022));
  1197. });
  1198.  
  1199. // ---------------------------------------------------------------------------
  1200. // Warp presets and the reveal
  1201. // ---------------------------------------------------------------------------
  1202. const PRESETS = { subtle: [520, 900], dream: [200, 250], extreme: [90, 70] };
  1203. function applyWarp() { uLoom.value.set(warp.D, warp.S, warp.strength, 0); }
  1204. function setPreset(name) {
  1205. const [D, S] = PRESETS[name];
  1206. warp.D = D; warp.S = S;
  1207. $("dB").value = D; $("soft").value = S;
  1208. $("dBOut").textContent = `${D} m`; $("softOut").textContent = S === 0 ? "snap flat" : `${S} m`;
  1209. document.querySelectorAll("[data-preset]").forEach((b) => b.classList.toggle("on", b.dataset.preset === name));
  1210. applyWarp();
  1211. }
  1212. function setWarpTarget(v, seconds = 2.5) {
  1213. warp.from = warp.strength; warp.target = v; warp.animT = 0; warp.dur = seconds;
  1214. $("revealBtn").innerHTML = (v > 0.5 ? "Reveal true size" : "Bring the warp back") + " <kbd>X</kbd>";
  1215. }
  1216.  
  1217. // ---------------------------------------------------------------------------
  1218. // Guided tour
  1219. // ---------------------------------------------------------------------------
  1220. const tmpV = new THREE.Vector3();
  1221. const standXZ = shipAt(SHIP_STAND_THETA, new THREE.Vector3());
  1222. const outward = new THREE.Vector2(pathX(0) - SPIRE.x, 0 - SPIRE.z).normalize();
  1223. const STOPS = [
  1224. {
  1225. name: "The approach", dur: 44,
  1226. text: "The spire looms at its full height the whole way, and only settles to its true size once it crosses the boundary.",
  1227. setup() { pos.set(pathX(40), 0, 40); pos.y = groundY(pos.x, pos.z); yaw = 0; pitch = 0.12; aimAt(SPIRE.x, spireBase.y + 110, SPIRE.z, 0, 0, true); },
  1228. update(t, dt) {
  1229. const z = Math.max(PATH_END + 30, 40 - 15.5 * t);
  1230. pos.set(pathX(z), 0, z); pos.y = groundY(pos.x, pos.z);
  1231. aimAt(SPIRE.x, spireBase.y + 110, SPIRE.z, 1.8, dt);
  1232. }
  1233. },
  1234. {
  1235. name: "The climb", dur: 44,
  1236. text: "Rising above the world, the ground falls away. Everything below looks far more distant than it really is.",
  1237. setup() { this.u(0); },
  1238. u(t) {
  1239. let a = 0, e = 0;
  1240. if (t < 20) e = ease(t / 20); else { e = 1; a = ease(Math.min(1, (t - 20) / 22)) * Math.PI * 2; }
  1241. const ox = outward.x * Math.cos(a) - outward.y * Math.sin(a), oz = outward.x * Math.sin(a) + outward.y * Math.cos(a);
  1242. const rr = lerp(38, 25, e);
  1243. pos.set(SPIRE.x + ox * rr, spireBase.y + 1.7 + e * 166, SPIRE.z + oz * rr);
  1244. yaw = Math.atan2(-ox, -oz);
  1245. pitch = lerp(0.08, -0.36, e);
  1246. },
  1247. update(t) { this.u(t); }
  1248. },
  1249. {
  1250. name: "Passing through", dur: 42,
  1251. text: "Far away, the airship looms large and high in the sky. As it crosses the boundary overhead, it settles to its true size.",
  1252. setup() {
  1253. pos.set(standXZ.x, 0, standXZ.z); pos.y = groundY(pos.x, pos.z);
  1254. ship.theta = SHIP_STAND_THETA - 1.2;
  1255. shipAt(ship.theta, ship.pos);
  1256. aimAt(ship.pos.x, ship.pos.y, ship.pos.z, 0, 0, true);
  1257. },
  1258. update(t, dt) { aimAt(ship.pos.x, ship.pos.y, ship.pos.z, 2.5, dt); }
  1259. },
  1260. {
  1261. name: "The reveal", dur: 26,
  1262. text: "",
  1263. setup() {
  1264. const d = waterfall.dir;
  1265. pos.set(CLIFF.x + d.x * 150 - d.y * 40, 0, CLIFF.z + d.y * 150 + d.x * 40);
  1266. pos.y = groundY(pos.x, pos.z) + 1;
  1267. aimAt(STONES.x * 0.4 + SPIRE.x * 0.6, spireBase.y + 60, STONES.z * 0.4 + SPIRE.z * 0.6, 0, 0, true);
  1268. pitch = -0.02;
  1269. this.phase = -1;
  1270. },
  1271. update(t) {
  1272. const ph = t < 4 ? 0 : t < 13 ? 1 : t < 17 ? 2 : 3;
  1273. if (ph !== this.phase) {
  1274. this.phase = ph;
  1275. if (ph === 1) setWarpTarget(0, 3);
  1276. if (ph === 2) setWarpTarget(1, 3);
  1277. const texts = [
  1278. "This is the world with the warp on. Watch what happens when it's switched off.",
  1279. "Warp off: this is the world's true size. The spire and the hills around the rim are far smaller than they looked.",
  1280. "And back on. The same small world, stretched into a vast one.",
  1281. "That's the showcase. You're in free roam next: try flying up high, or switch presets."
  1282. ];
  1283. showCaption(4, this.name, texts[ph]);
  1284. }
  1285. }
  1286. }
  1287. ];
  1288. const tour = { i: 0, t: 0, fading: false };
  1289.  
  1290. function showCaption(n, title, text) {
  1291. $("capKicker").textContent = n ? `Stop ${n} of 4` : "Free roam";
  1292. $("capTitle").textContent = title;
  1293. $("capText").textContent = text;
  1294. $("caption").classList.remove("hidden");
  1295. }
  1296. function hideCaption() { $("caption").classList.add("hidden"); }
  1297.  
  1298. function withFade(fn) {
  1299. if (tour.fading) return;
  1300. tour.fading = true;
  1301. $("fade").classList.add("on");
  1302. setTimeout(() => { fn(); setTimeout(() => { $("fade").classList.remove("on"); tour.fading = false; }, 120); }, 650);
  1303. }
  1304. function startStop(i) {
  1305. withFade(() => {
  1306. mode = "tour";
  1307. if (document.pointerLockElement) document.exitPointerLock();
  1308. $("hint").classList.add("hidden");
  1309. warp.strength = 1; warp.target = 1; warp.animT = 1; applyWarp();
  1310. $("revealBtn").innerHTML = "Reveal true size <kbd>X</kbd>";
  1311. tour.i = i; tour.t = 0;
  1312. const s = STOPS[i];
  1313. s.setup();
  1314. if (s.text) showCaption(i + 1, s.name, s.text);
  1315. $("tourStep").textContent = `${i + 1} of 4`;
  1316. $("tourName").textContent = s.name;
  1317. $("roamBtn").textContent = "Free roam";
  1318. });
  1319. }
  1320. function toRoam(keepPlace = true) {
  1321. mode = "roam";
  1322. tour.t = 0;
  1323. if (!keepPlace) { pos.set(pathX(40), 0, 40); yaw = 0; pitch = 0.12; }
  1324. if (warp.target < 0.5) setWarpTarget(1, 1.5);
  1325. $("tourStep").textContent = "–";
  1326. $("tourName").textContent = "Free roam";
  1327. $("tourBar").style.width = "0%";
  1328. $("roamBtn").textContent = "Restart tour";
  1329. $("hint").classList.toggle("hidden", locked);
  1330. showCaption(0, "Your turn", "Click to look around, WASD to move, F to fly. Press X any time to reveal the world's true size.");
  1331. clearTimeout(toRoam.timer);
  1332. toRoam.timer = setTimeout(() => { if (mode === "roam") hideCaption(); }, 6000);
  1333. }
  1334.  
  1335. // ---------------------------------------------------------------------------
  1336. // UI wiring
  1337. // ---------------------------------------------------------------------------
  1338. function syncStyle() {
  1339. $("optPaint").checked = style.paint; $("optTint").checked = style.tint; $("optFly").checked = move.fly;
  1340. post.uniforms.paintOn.value = style.paint ? 1 : 0;
  1341. post.uniforms.tintOn.value = style.tint ? 1 : 0;
  1342. uDetailFade.value = style.paint ? 1 : 0;
  1343. post.uniforms.block.value = Math.max(1, Math.round(style.block * DPR));
  1344. post.uniforms.nearBlock.value = Math.max(1, Math.round(style.nearBlock * DPR));
  1345. post.uniforms.outlineOn.value = style.outline ? 1 : 0;
  1346. $("optOutline").checked = style.outline;
  1347. $("optRigid").checked = uRigidOn.value > 0.5;
  1348. }
  1349. document.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { setPreset(b.dataset.preset); b.blur(); }));
  1350. $("dB").addEventListener("input", (e) => { warp.D = +e.target.value; $("dBOut").textContent = `${warp.D} m`; document.querySelectorAll("[data-preset]").forEach((b) => b.classList.remove("on")); applyWarp(); });
  1351. $("soft").addEventListener("input", (e) => { warp.S = +e.target.value; $("softOut").textContent = warp.S === 0 ? "snap flat" : `${warp.S} m`; document.querySelectorAll("[data-preset]").forEach((b) => b.classList.remove("on")); applyWarp(); });
  1352. $("revealBtn").addEventListener("click", (e) => { setWarpTarget(warp.target > 0.5 ? 0 : 1); e.currentTarget.blur(); });
  1353. $("optPaint").addEventListener("change", (e) => { style.paint = e.target.checked; syncStyle(); });
  1354. $("optTint").addEventListener("change", (e) => { style.tint = e.target.checked; syncStyle(); });
  1355. $("optFly").addEventListener("change", (e) => { move.fly = e.target.checked; syncStyle(); });
  1356. $("tod").addEventListener("input", (e) => setTime(+e.target.value));
  1357. $("optCycle").addEventListener("change", (e) => { tod.cycle = e.target.checked; });
  1358. $("block").addEventListener("input", (e) => { style.block = +e.target.value; $("blockOut").textContent = `${style.block} px`; syncStyle(); });
  1359. $("nblock").addEventListener("input", (e) => { style.nearBlock = +e.target.value; $("nblockOut").textContent = `${style.nearBlock} px`; syncStyle(); });
  1360. $("optOutline").addEventListener("change", (e) => { style.outline = e.target.checked; syncStyle(); });
  1361. $("optRigid").addEventListener("change", (e) => { uRigidOn.value = e.target.checked ? 1 : 0; syncStyle(); });
  1362. $("speed").addEventListener("input", (e) => { move.speed = +e.target.value; $("speedOut").textContent = `${move.speed} m/s`; });
  1363. $("prevStop").addEventListener("click", (e) => { startStop(mode === "tour" ? Math.max(0, tour.i - 1) : 0); e.currentTarget.blur(); });
  1364. $("nextStop").addEventListener("click", (e) => { nextStop(); e.currentTarget.blur(); });
  1365. $("roamBtn").addEventListener("click", (e) => { if (mode === "tour") toRoam(); else startStop(0); e.currentTarget.blur(); });
  1366. function nextStop() {
  1367. if (mode !== "tour") { startStop(0); return; }
  1368. if (tour.i < STOPS.length - 1) startStop(tour.i + 1); else toRoam();
  1369. }
  1370.  
  1371. const MOVE_KEYS = ["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyE", "KeyQ"];
  1372. window.addEventListener("keydown", (e) => {
  1373. const tag = e.target && e.target.tagName;
  1374. if ((tag === "INPUT" || tag === "BUTTON") && !locked && !MOVE_KEYS.includes(e.code) && !e.code.startsWith("Shift")) return;
  1375. keys[e.code] = true;
  1376. if (e.repeat) return;
  1377. if (MOVE_KEYS.includes(e.code) && mode === "tour") toRoam();
  1378. if (e.code === "KeyX") setWarpTarget(warp.target > 0.5 ? 0 : 1);
  1379. else if (e.code === "KeyP") { style.paint = !style.paint; syncStyle(); }
  1380. else if (e.code === "KeyB") { style.tint = !style.tint; syncStyle(); }
  1381. else if (e.code === "KeyL") { tod.cycle = !tod.cycle; $("optCycle").checked = tod.cycle; }
  1382. else if (e.code === "KeyO") { style.outline = !style.outline; syncStyle(); }
  1383. else if (e.code === "KeyK") { uRigidOn.value = uRigidOn.value > 0.5 ? 0 : 1; syncStyle(); }
  1384. else if (e.code === "KeyF") { move.fly = !move.fly; syncStyle(); }
  1385. else if (e.code === "KeyH") $("panel").classList.toggle("hidden");
  1386. else if (e.code === "KeyN") nextStop();
  1387. else if (e.code === "Digit1") setPreset("subtle");
  1388. else if (e.code === "Digit2") setPreset("dream");
  1389. else if (e.code === "Digit3") setPreset("extreme");
  1390. });
  1391. window.addEventListener("keyup", (e) => { keys[e.code] = false; });
  1392. window.addEventListener("blur", () => { for (const k in keys) keys[k] = false; });
  1393.  
  1394. // ---------------------------------------------------------------------------
  1395. // Loop
  1396. // ---------------------------------------------------------------------------
  1397. const clock = new THREE.Clock();
  1398. let time = 0, frames = 0, fpsT = 0;
  1399.  
  1400. function update(dt) {
  1401. time += dt;
  1402.  
  1403. // warp strength animation (the reveal)
  1404. if (warp.animT < 1) {
  1405. warp.animT = Math.min(1, warp.animT + dt / (warp.dur || 2.5));
  1406. warp.strength = lerp(warp.from, warp.target, ease(warp.animT));
  1407. applyWarp();
  1408. }
  1409.  
  1410. if (tod.cycle) setTime(tod.t + dt * 0.1); // a full day every four minutes
  1411.  
  1412. // world animation
  1413. ship.theta += ship.omega * dt;
  1414. shipAt(ship.theta, ship.pos);
  1415. if (anim.ship) {
  1416. anim.ship.position.copy(ship.pos);
  1417. shipAt(ship.theta + 0.01, tmpV);
  1418. anim.ship.lookAt(tmpV);
  1419. anim.ship.rotateY(Math.PI);
  1420. for (const p of anim.props) p.rotation.z += dt * 9;
  1421. }
  1422. if (anim.clouds) anim.clouds.rotation.y += dt * 0.006;
  1423. for (const b of anim.birds) {
  1424. const u = b.userData, a = u.phase + time * u.speed;
  1425. b.position.set(u.cx + Math.cos(a) * u.rad, u.cy + Math.sin(time * 0.5 + u.phase) * 4, u.cz + Math.sin(a) * u.rad);
  1426. b.rotation.y = -a + (u.speed > 0 ? 0 : Math.PI);
  1427. const f = Math.sin(time * u.flap + u.phase) * 0.6;
  1428. u.l.rotation.z = f; u.r.rotation.z = -f;
  1429. }
  1430. if (anim.streaks) {
  1431. const g = anim.streakGeo, P = g.prof, L = P.length - 1;
  1432. for (let i = 0; i < anim.streakData.length; i++) {
  1433. const d = anim.streakData[i];
  1434. const f = (d.ph + time * d.sp) % 1;
  1435. const fi = f * L, i0 = Math.floor(fi), i1 = Math.min(L, i0 + 1), u = fi - i0;
  1436. const y = lerp(P[i0].y, P[i1].y, u), out = lerp(P[i0].out, P[i1].out, u) + 0.8;
  1437. anim.streaks.setMatrixAt(i, at(CLIFF.x + g.d.x * out + g.perp.x * d.off, y, CLIFF.z + g.d.y * out + g.perp.y * d.off, g.facing));
  1438. }
  1439. anim.streaks.instanceMatrix.needsUpdate = true;
  1440. }
  1441.  
  1442. // camera
  1443. if (mode === "tour" && !tour.fading) {
  1444. const s = STOPS[tour.i];
  1445. tour.t += dt;
  1446. s.update(tour.t, dt);
  1447. $("tourBar").style.width = `${Math.min(100, tour.t / s.dur * 100)}%`;
  1448. if (tour.t >= s.dur) nextStop();
  1449. } else if (mode === "roam") {
  1450. const v = move.speed * (keys.ShiftLeft || keys.ShiftRight ? 3 : 1);
  1451. let f = 0, st = 0, up = 0;
  1452. if (keys.KeyW || keys.ArrowUp) f += 1;
  1453. if (keys.KeyS || keys.ArrowDown) f -= 1;
  1454. if (keys.KeyD || keys.ArrowRight) st += 1;
  1455. if (keys.KeyA || keys.ArrowLeft) st -= 1;
  1456. if (keys.KeyE) up += 1;
  1457. if (keys.KeyQ) up -= 1;
  1458. const sy = Math.sin(yaw), cy = Math.cos(yaw);
  1459. if (move.fly) {
  1460. const cp = Math.cos(pitch), sp = Math.sin(pitch);
  1461. pos.x += (-sy * cp * f + cy * st) * v * dt;
  1462. pos.z += (-cy * cp * f - sy * st) * v * dt;
  1463. pos.y += (sp * f + up) * v * dt;
  1464. pos.y = Math.max(groundY(pos.x, pos.z), Math.min(2500, pos.y));
  1465. fallV = 0;
  1466. } else {
  1467. const len = Math.hypot(f, st) || 1;
  1468. pos.x += (-sy * f + cy * st) / len * v * dt;
  1469. pos.z += (-cy * f - sy * st) / len * v * dt;
  1470. pushOutOfTower();
  1471. const g = surfaceY(pos.x, pos.z, pos.y - 1.7) + 1.7;
  1472. if (pos.y > g + 0.05) {
  1473. fallV = Math.min(60, fallV + 25 * dt);
  1474. pos.y = Math.max(g, pos.y - fallV * dt);
  1475. } else { pos.y = g; fallV = 0; }
  1476. }
  1477. pos.x = Math.max(CENTER.x - 2400, Math.min(CENTER.x + 2400, pos.x));
  1478. pos.z = Math.max(CENTER.z - 2400, Math.min(CENTER.z + 2400, pos.z));
  1479. }
  1480. camera.position.copy(pos);
  1481. camera.rotation.set(pitch, yaw, 0);
  1482. camera.updateMatrixWorld();
  1483. sky.position.copy(pos);
  1484. uCam.value.copy(pos);
  1485. uShip.value.copy(ship.pos);
  1486. }
  1487.  
  1488. function frame() {
  1489. const dt = Math.min(clock.getDelta(), 0.1);
  1490. update(dt);
  1491. renderer.setRenderTarget(rt);
  1492. renderer.render(scene, camera);
  1493. renderer.setRenderTarget(null);
  1494. renderer.render(postScene, postCam);
  1495. frames++; fpsT += dt;
  1496. if (fpsT >= 0.5) { $("fps").textContent = `${Math.round(frames / fpsT)} fps`; frames = 0; fpsT = 0; }
  1497. requestAnimationFrame(frame);
  1498. }
  1499.  
  1500. setTimeout(() => {
  1501. try {
  1502. buildWorld();
  1503. } catch (err) {
  1504. console.error(err);
  1505. $("loading").innerHTML = '<div class="error">The world failed to build. Reload the page to try again.</div>';
  1506. return;
  1507. }
  1508. resize();
  1509. syncStyle();
  1510. setPreset("dream");
  1511. setTime(16.5);
  1512. const m = /stop=(\d)/.exec(location.hash);
  1513. tour.i = m ? Math.min(3, +m[1]) : 0;
  1514. const s = STOPS[tour.i];
  1515. s.setup();
  1516. const cm = /cam=([-\d.,]+)/.exec(location.hash);
  1517. if (cm) { const v = cm[1].split(",").map(Number); mode = "roam"; move.fly = true; pos.set(v[0], v[1], v[2]); yaw = v[3]; pitch = v[4]; hideCaption(); }
  1518. const tm = /t=(\d+)/.exec(location.hash);
  1519. if (tm) { tour.t = +tm[1]; ship.theta += ship.omega * tour.t; shipAt(ship.theta, ship.pos); }
  1520. showCaption(tour.i + 1, s.name, s.text || "");
  1521. $("tourStep").textContent = `${tour.i + 1} of 4`;
  1522. $("tourName").textContent = s.name;
  1523. uCam.value.copy(pos);
  1524. $("loading").classList.add("hidden");
  1525. clock.getDelta();
  1526. requestAnimationFrame(frame);
  1527. }, 40);
  1528. })();
  1529. </script>
  1530. </body>
  1531. </html>
  1532.  
Advertisement
Add Comment
Please, Sign In to add comment