Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
- <title>Looming showcase</title>
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@500;700&family=Work+Sans:wght@400;500;600&display=swap" rel="stylesheet">
- <style>
- :root {
- --dusk: rgba(26, 22, 46, 0.86);
- --dusk-edge: rgba(190, 176, 230, 0.22);
- --ink: #ece7f8;
- --ink-soft: #b3aad0;
- --lantern: #ffcf6a;
- --frost: #5cc8ff;
- --track: rgba(255, 255, 255, 0.16);
- box-sizing: border-box;
- padding-top: env(safe-area-inset-top, 0px);
- padding-bottom: env(safe-area-inset-bottom, 0px);
- color-scheme: dark;
- }
- @media (prefers-color-scheme: light) {
- :root:not([data-theme="dark"]) {
- --dusk: rgba(246, 243, 255, 0.9);
- --dusk-edge: rgba(60, 50, 100, 0.2);
- --ink: #221d38;
- --ink-soft: #5b5378;
- --lantern: #9a6200;
- --frost: #0a7fc0;
- --track: rgba(30, 20, 60, 0.18);
- color-scheme: light;
- }
- }
- :root[data-theme="dark"] {
- --dusk: rgba(26, 22, 46, 0.86);
- --dusk-edge: rgba(190, 176, 230, 0.22);
- --ink: #ece7f8;
- --ink-soft: #b3aad0;
- --lantern: #ffcf6a;
- --frost: #5cc8ff;
- --track: rgba(255, 255, 255, 0.16);
- }
- *, *::before, *::after { box-sizing: inherit; }
- html { scroll-padding-top: env(safe-area-inset-top, 0px); height: 100%; }
- body {
- height: 100%; margin: 0; overflow: hidden; background: #15122a;
- font-family: "Work Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
- color: var(--ink);
- }
- canvas#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }
- .pixel { font-family: "Pixelify Sans", "Work Sans", system-ui, sans-serif; }
- .panel {
- position: fixed; left: 14px; top: calc(14px + env(safe-area-inset-top, 0px));
- width: 296px; max-width: calc(100vw - 28px); max-height: calc(100% - 28px); overflow-y: auto;
- background: var(--dusk); border: 1px solid var(--dusk-edge); border-radius: 10px;
- padding: 16px 16px 14px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
- font-size: 13px; line-height: 1.45;
- }
- .panel.hidden { display: none; }
- .panel h1 { font-weight: 700; font-size: 23px; line-height: 1.1; margin: 0 0 2px; }
- .panel .sub { color: var(--ink-soft); margin: 0 0 10px; }
- .section { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--track); }
- .section h2 { font-size: 13px; font-weight: 600; margin: 0 0 6px; display: flex; justify-content: space-between; align-items: baseline; }
- .section h2 span { color: var(--ink-soft); font-weight: 400; font-variant-numeric: tabular-nums; }
- .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 7px 0; }
- .row label { flex: 1; }
- .row output { min-width: 58px; text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-soft); }
- input[type="range"] { width: 112px; accent-color: var(--lantern); }
- .toggle { display: flex; align-items: center; gap: 8px; margin: 6px 0; cursor: pointer; }
- .toggle input { accent-color: var(--lantern); width: 15px; height: 15px; margin: 0; }
- .toggle kbd { margin-left: auto; }
- .btns { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
- .btn {
- font: inherit; color: var(--ink); background: transparent; border: 1px solid var(--track);
- border-radius: 6px; padding: 4px 10px; cursor: pointer; display: inline-flex; gap: 6px; align-items: center;
- }
- .btn:hover { border-color: var(--lantern); }
- .btn.on { border-color: var(--lantern); color: var(--lantern); }
- .btn.wide { flex: 1; justify-content: center; }
- .stopname { font-weight: 600; margin: 2px 0 4px; }
- .progress { height: 3px; background: var(--track); border-radius: 2px; overflow: hidden; margin: 4px 0 8px; }
- .progress > div { height: 100%; width: 0; background: var(--lantern); }
- kbd {
- font-family: inherit; font-size: 11px; color: var(--ink-soft);
- border: 1px solid var(--track); border-radius: 4px; padding: 0 5px;
- }
- .keys { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--track); color: var(--ink-soft); font-size: 12px; }
- .keys p { margin: 3px 0; }
- .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
- .fps { color: var(--ink-soft); font-size: 12px; font-variant-numeric: tabular-nums; }
- :focus-visible { outline: 2px solid var(--lantern); outline-offset: 2px; }
- .caption {
- position: fixed; left: 50%; bottom: calc(26px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%);
- width: min(560px, calc(100vw - 40px));
- background: var(--dusk); border: 1px solid var(--dusk-edge); border-radius: 10px;
- padding: 12px 18px 14px; text-align: center; pointer-events: none;
- transition: opacity 0.4s ease;
- }
- .caption.hidden { opacity: 0; }
- .caption .kicker { color: var(--lantern); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 2px; }
- .caption h3 { font-size: 24px; margin: 0 0 4px; font-weight: 700; }
- .caption p { margin: 0; color: var(--ink-soft); font-size: 14px; line-height: 1.45; }
- .fade { position: fixed; inset: 0; background: #0d0b1a; opacity: 0; pointer-events: none; transition: opacity 0.6s ease; }
- .fade.on { opacity: 1; }
- .hint {
- position: fixed; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom, 0px));
- background: var(--dusk); border: 1px solid var(--dusk-edge); border-radius: 8px;
- padding: 6px 12px; font-size: 12px; pointer-events: none; color: var(--ink-soft);
- }
- .hint.hidden { display: none; }
- .loading {
- position: fixed; inset: 0; display: grid; place-items: center; background: #15122a; color: #ece7f8;
- font-size: 24px;
- }
- .loading.hidden { display: none; }
- .error { font-family: "Work Sans", system-ui, sans-serif; font-size: 15px; max-width: 420px; text-align: center; line-height: 1.5; }
- </style>
- </head>
- <body>
- <canvas id="view"></canvas>
- <div class="fade" id="fade"></div>
- <div class="caption hidden" id="caption">
- <p class="kicker" id="capKicker"></p>
- <h3 class="pixel" id="capTitle"></h3>
- <p id="capText"></p>
- </div>
- <aside class="panel" id="panel" aria-label="Showcase controls">
- <h1 class="pixel">Looming showcase</h1>
- <p class="sub">Past the boundary, the world is pulled in but keeps its real height, and turns into a painted '90s backdrop.</p>
- <div class="fps" id="fps">–</div>
- <div class="section">
- <h2>Tour <span id="tourStep">1 of 4</span></h2>
- <div class="stopname" id="tourName">The approach</div>
- <div class="progress"><div id="tourBar"></div></div>
- <div class="btns">
- <button class="btn" id="prevStop" type="button">Previous</button>
- <button class="btn" id="nextStop" type="button">Next <kbd>N</kbd></button>
- <button class="btn wide" id="roamBtn" type="button">Free roam</button>
- </div>
- </div>
- <div class="section">
- <h2>Warp</h2>
- <div class="btns">
- <button class="btn" data-preset="subtle" type="button">Subtle <kbd>1</kbd></button>
- <button class="btn on" data-preset="dream" type="button">Dreamlike <kbd>2</kbd></button>
- <button class="btn" data-preset="extreme" type="button">Extreme <kbd>3</kbd></button>
- </div>
- <div class="row">
- <label for="dB">Boundary</label>
- <input type="range" id="dB" min="40" max="1200" step="10" value="200">
- <output id="dBOut">200 m</output>
- </div>
- <div class="row">
- <label for="soft">Softness</label>
- <input type="range" id="soft" min="0" max="1500" step="10" value="250">
- <output id="softOut">250 m</output>
- </div>
- <div class="btns">
- <button class="btn wide" id="revealBtn" type="button">Reveal true size <kbd>X</kbd></button>
- </div>
- <label class="toggle"><input type="checkbox" id="optRigid" checked> Airship keeps its shape <kbd>K</kbd></label>
- </div>
- <div class="section">
- <h2>Style</h2>
- <label class="toggle"><input type="checkbox" id="optPaint" checked> Painted far field <kbd>P</kbd></label>
- <div class="row">
- <label for="block">Far pixel size</label>
- <input type="range" id="block" min="1" max="10" step="1" value="4">
- <output id="blockOut">4 px</output>
- </div>
- <div class="row">
- <label for="nblock">Near pixel size</label>
- <input type="range" id="nblock" min="1" max="6" step="1" value="2">
- <output id="nblockOut">2 px</output>
- </div>
- <label class="toggle"><input type="checkbox" id="optOutline" checked> Outlines <kbd>O</kbd></label>
- <label class="toggle"><input type="checkbox" id="optTint"> Show the compressed zone <kbd>B</kbd></label>
- </div>
- <div class="section">
- <h2>Time of day <span id="todOut">16:30</span></h2>
- <div class="row">
- <label for="tod" class="sr">Time</label>
- <input type="range" id="tod" min="0" max="24" step="0.1" value="16.5" style="width: 100%">
- </div>
- <label class="toggle"><input type="checkbox" id="optCycle"> Let time pass <kbd>L</kbd></label>
- </div>
- <div class="section">
- <h2>Movement</h2>
- <label class="toggle"><input type="checkbox" id="optFly"> Fly <kbd>F</kbd></label>
- <div class="row">
- <label for="speed">Speed</label>
- <input type="range" id="speed" min="2" max="60" step="1" value="10">
- <output id="speedOut">10 m/s</output>
- </div>
- </div>
- <div class="keys">
- <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>
- <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>
- </div>
- </aside>
- <div class="hint hidden" id="hint">Click to look around</div>
- <div class="loading pixel" id="loading">Building the world…</div>
- <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
- <script>
- (() => {
- "use strict";
- const $ = (id) => document.getElementById(id);
- if (typeof THREE === "undefined") {
- $("loading").innerHTML = '<div class="error">The 3D library did not load. Check your connection and reload the page.</div>';
- return;
- }
- // ---------------------------------------------------------------------------
- // Noise and helpers
- // ---------------------------------------------------------------------------
- function hash2(x, y) {
- let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263)) | 0;
- h = Math.imul(h ^ (h >>> 13), 1274126177);
- h ^= h >>> 16;
- return (h >>> 0) / 4294967296;
- }
- function vnoise(x, y) {
- const xi = Math.floor(x), yi = Math.floor(y);
- const xf = x - xi, yf = y - yi;
- const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
- const a = hash2(xi, yi), b = hash2(xi + 1, yi), c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);
- return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
- }
- function fbm(x, y, oct) {
- let s = 0, a = 0.5, f = 1, n = 0;
- 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; }
- return s / n;
- }
- function ridged(x, y, oct) {
- let s = 0, a = 0.5, f = 1, n = 0;
- for (let i = 0; i < oct; i++) {
- let r = 1 - Math.abs(vnoise(x * f + i * 31.7, y * f + i * 5.3) * 2 - 1);
- r *= r; s += a * r; n += a; a *= 0.5; f *= 2.1;
- }
- return s / n;
- }
- const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
- const lerp = (a, b, t) => a + (b - a) * t;
- const ease = (t) => t * t * (3 - 2 * t);
- 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; };
- let seed = 4242;
- const rand = () => {
- seed = (seed + 0x6D2B79F5) | 0;
- let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
- // ---------------------------------------------------------------------------
- // World layout. It is deliberately small: about 2 km from rim to rim.
- // ---------------------------------------------------------------------------
- const CENTER = { x: 0, z: -500 };
- const SPIRE = { x: 0, z: -700 };
- const STONES = { x: 520, z: -380 };
- const CLIFF = { x: -650, z: -1080 };
- const PATH_START = 260, PATH_END = -655;
- const pathX = (z) => 18 * Math.sin(z * 0.009) + 10 * Math.sin(z * 0.023 + 1.1);
- function H(x, z) {
- let h = (fbm(x * 0.0035, z * 0.0035, 4) - 0.5) * 38;
- const side = Math.abs(x - pathX(z));
- if (z < PATH_START + 80 && z > PATH_END - 60) h *= 0.35 + 0.65 * smooth(8, 140, side);
- const ds = Math.hypot(x - SPIRE.x, z - SPIRE.z);
- h *= 0.3 + 0.7 * smooth(60, 300, ds);
- h += 80 * (1 - smooth(42, 340, ds)); // the spire's hill, flat on top
- const dt = Math.hypot(x - STONES.x, z - STONES.z);
- h *= 0.4 + 0.6 * smooth(60, 140, dt);
- const dc = Math.hypot(x - CLIFF.x, z - CLIFF.z) + (vnoise(x * 0.012, z * 0.012) - 0.5) * 50;
- h += 118 * (1 - smooth(215, 229, dc));
- const r = Math.hypot(x - CENTER.x, z - CENTER.z);
- const ring = smooth(1250, 2000, r);
- if (ring > 0) h += ring * (90 + 170 * ridged(x * 0.0014, z * 0.0014, 4));
- return h;
- }
- // ---------------------------------------------------------------------------
- // The looming warp
- // f(r) = r inside the boundary D, then D + S * ln(1 + (r - D) / S) beyond it.
- // Horizontal distance from the player is compressed, height is kept.
- // ---------------------------------------------------------------------------
- const warp = { D: 200, S: 250, strength: 1, target: 1, from: 1, animT: 1 };
- const uLoom = { value: new THREE.Vector4(200, 250, 1, 0) };
- const uCam = { value: new THREE.Vector3() }; // passed explicitly: unlit materials don't get cameraPosition
- const uDetailFade = { value: 1 };
- const uShip = { value: new THREE.Vector3() }; // airship centre, for warping it as one rigid piece
- const uRigidOn = { value: 1 };
- function loomF(r) {
- if (r <= warp.D) return r;
- if (warp.S < 0.5) return warp.D;
- return warp.D + warp.S * Math.log(1 + (r - warp.D) / warp.S);
- }
- const LOOM_VERT_DECL = `
- uniform vec4 uLoom;
- uniform vec3 uCam;
- #ifdef LOOM_RIGID
- uniform vec3 uRigid;
- uniform float uRigidOn;
- #endif
- varying float vLoomAmt;
- float loomF(float r) {
- float D = uLoom.x; float S = uLoom.y;
- if (r <= D) return r;
- if (S < 0.5) return D;
- return D + S * log(1.0 + (r - D) / S);
- }`;
- const LOOM_PROJECT = `
- vec4 wPos = vec4(transformed, 1.0);
- #ifdef USE_INSTANCING
- wPos = instanceMatrix * wPos;
- #endif
- wPos = modelMatrix * wPos;
- vec2 hOff = wPos.xz - uCam.xz;
- float hr = length(hOff);
- float hs = hr > 0.001 ? mix(1.0, loomF(hr) / hr, uLoom.z) : 1.0;
- vec3 disp = vec3(uCam.x + hOff.x * hs, wPos.y, uCam.z + hOff.y * hs);
- #ifdef LOOM_RIGID
- // Move the whole object by the warp of its centre, so it keeps its shape instead of being squashed
- vec2 rOff = uRigid.xz - uCam.xz;
- float rr = length(rOff);
- float rs = rr > 0.001 ? mix(1.0, loomF(rr) / rr, uLoom.z) : 1.0;
- vec3 dispR = wPos.xyz + vec3(rOff.x * (rs - 1.0), 0.0, rOff.y * (rs - 1.0));
- disp = mix(disp, dispR, uRigidOn);
- hr = mix(hr, rr, uRigidOn);
- #endif
- float slope = hr <= uLoom.x ? 1.0 : (uLoom.y < 0.5 ? 0.0 : uLoom.y / (uLoom.y + hr - uLoom.x));
- vLoomAmt = uLoom.z * (1.0 - slope);
- vec4 mvPosition = viewMatrix * wPos; // true position: lighting and fog
- gl_Position = projectionMatrix * (viewMatrix * vec4(disp, 1.0));`;
- const LOOM_FRAG_DECL = `
- varying float vLoomAmt;
- uniform float uDetailFade;
- float lb2(vec2 a) { a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
- float lb4(vec2 a) { return lb2(0.5 * a) * 0.25 + lb2(a); }`;
- // detail = small props that a painter would leave out of a distant backdrop
- function patch(mat, detail = false, rigid = null) {
- if (detail) mat.defines = Object.assign({}, mat.defines, { LOOM_DETAIL: "" });
- if (rigid) mat.defines = Object.assign({}, mat.defines, { LOOM_RIGID: "" });
- mat.onBeforeCompile = (shader) => {
- shader.uniforms.uLoom = uLoom;
- shader.uniforms.uCam = uCam;
- shader.uniforms.uDetailFade = uDetailFade;
- if (rigid) { shader.uniforms.uRigid = rigid; shader.uniforms.uRigidOn = uRigidOn; }
- shader.vertexShader = shader.vertexShader
- .replace("#include <common>", "#include <common>\n" + LOOM_VERT_DECL)
- .replace("#include <project_vertex>", LOOM_PROJECT);
- shader.fragmentShader = shader.fragmentShader
- .replace("#include <common>", "#include <common>\n" + LOOM_FRAG_DECL)
- .replace("#include <clipping_planes_fragment>", `#include <clipping_planes_fragment>
- #ifdef LOOM_DETAIL
- if (smoothstep(0.15, 0.55, vLoomAmt) * uDetailFade > lb4(gl_FragCoord.xy)) discard;
- #endif`)
- .replace("#include <fog_fragment>", "#include <fog_fragment>\ngl_FragColor.a = clamp(vLoomAmt, 0.0, 1.0) * 0.9;");
- };
- return mat;
- }
- const flat = (color, extra = {}, detail = false, rigid = null) => patch(new THREE.MeshPhongMaterial(Object.assign({
- color, flatShading: true, shininess: 0, specular: 0x000000
- }, extra)), detail, rigid);
- const glow = (color, extra = {}, rigid = null) => patch(new THREE.MeshBasicMaterial(Object.assign({ color }, extra)), false, rigid);
- const shipFlat = (color, extra = {}) => flat(color, extra, false, uShip);
- const shipGlow = (color, extra = {}) => glow(color, extra, uShip);
- // ---------------------------------------------------------------------------
- // Renderer, scene, camera, sky
- // ---------------------------------------------------------------------------
- const canvas = $("view");
- let renderer;
- try {
- renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: "high-performance" });
- } catch (err) {
- $("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>';
- return;
- }
- const DPR = Math.min(window.devicePixelRatio || 1, 1.5);
- renderer.setPixelRatio(1);
- const scene = new THREE.Scene();
- scene.fog = new THREE.FogExp2(new THREE.Color("#a79cc6"), 0.00016);
- const camera = new THREE.PerspectiveCamera(64, 16 / 9, 0.4, 16000);
- camera.rotation.order = "YXZ";
- const SUN_DIR = new THREE.Vector3(-0.5, 0.55, 0.65).normalize();
- const MOON_DIR = new THREE.Vector3(0.4, 0.6, -0.6).normalize();
- const hemi = new THREE.HemisphereLight(0xc9bdf0, 0x3b4a2c, 0.62);
- scene.add(hemi);
- const sun = new THREE.DirectionalLight(0xffe0bf, 0.9);
- sun.position.copy(SUN_DIR).multiplyScalar(1000);
- scene.add(sun);
- const sky = new THREE.Mesh(
- new THREE.SphereGeometry(12000, 32, 16),
- new THREE.ShaderMaterial({
- side: THREE.BackSide, depthWrite: false, depthTest: false, fog: false,
- uniforms: { horizon: { value: new THREE.Color("#b3a6d0") }, zenith: { value: new THREE.Color("#2c2f69") }, sunDir: { value: SUN_DIR },
- moonDir: { value: MOON_DIR }, stars: { value: 0 }, sunGlow: { value: 1 } },
- vertexShader: `
- varying vec3 vDir;
- void main() { vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
- fragmentShader: `
- uniform vec3 horizon; uniform vec3 zenith; uniform vec3 sunDir;
- uniform vec3 moonDir; uniform float stars; uniform float sunGlow;
- varying vec3 vDir;
- void main() {
- vec3 d = normalize(vDir);
- float t = clamp(d.y * 1.4 + 0.04, 0.0, 1.0);
- vec3 c = mix(horizon, zenith, pow(t, 0.7));
- float s = max(dot(d, sunDir), 0.0);
- c += vec3(1.0, 0.8, 0.55) * (pow(s, 60.0) * 0.5 + pow(s, 5.0) * 0.1) * sunGlow;
- c += vec3(1.0, 0.95, 0.85) * smoothstep(0.9993, 0.9996, s) * sunGlow;
- if (stars > 0.01) {
- vec3 cell = floor(d * 160.0);
- float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 45.164))) * 43758.5453);
- 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));
- float m = max(dot(d, moonDir), 0.0);
- c += vec3(0.9, 0.93, 1.0) * (smoothstep(0.9990, 0.9993, m) * 0.9 + pow(m, 40.0) * 0.12) * stars;
- }
- gl_FragColor = vec4(c, 1.0); // alpha 1 marks sky for the backdrop pass
- }`
- })
- );
- sky.renderOrder = -1;
- scene.add(sky);
- // ---------------------------------------------------------------------------
- // Geometry helpers
- // ---------------------------------------------------------------------------
- function mergeParts(parts) {
- const pos = [], col = [];
- const c = new THREE.Color();
- for (const p of parts) {
- const g = p.geo.index ? p.geo.toNonIndexed() : p.geo.clone();
- if (p.matrix) g.applyMatrix4(p.matrix);
- const a = g.attributes.position.array;
- c.set(p.color);
- 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); }
- g.dispose();
- }
- const out = new THREE.BufferGeometry();
- out.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
- out.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
- out.computeVertexNormals();
- return out;
- }
- const at = (x, y, z, ry = 0, sx = 1, sy = 1, sz = 1, rx = 0, rz = 0) =>
- 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));
- // Walkways: a spiral ramp around the spire up to the balcony, and a bridge from the balcony to the waterfall
- const RAMP = { turns: 5, h0: 0.8, h1: 166.5, width: 6, start: Math.PI * 0.5 };
- const towerR = (h) => lerp(24, 13, Math.min(1, Math.max(0, h / 170)));
- const rampIn = (h) => towerR(h) + 0.2;
- const bridge = { s: new THREE.Vector3(), e: new THREE.Vector3(), half: 3.4 };
- const anim = { birds: [], clouds: null, ship: null, props: [], streaks: null, streakData: [] };
- const spireBase = { y: 0 };
- const waterfall = { top: new THREE.Vector3(), dir: new THREE.Vector2(), bottomY: 0 };
- const ship = { theta: 0, rx: 1150, rz: 950, y: 185, omega: 0.036, pos: new THREE.Vector3() };
- const SHIP_STAND_THETA = 0.3;
- const shipAt = (th, out) => out.set(CENTER.x + ship.rx * Math.cos(th), ship.y, CENTER.z + ship.rz * Math.sin(th));
- // ---------------------------------------------------------------------------
- // Build the world
- // ---------------------------------------------------------------------------
- function buildWorld() {
- spireBase.y = H(SPIRE.x, SPIRE.z);
- // Terrain: denser near the middle, coarser toward the rim
- {
- const N = 320, S = 2700, A = 0.3;
- const map = (u) => S * (A * u + (1 - A) * u * u * u);
- const verts = new Float32Array((N + 1) * (N + 1) * 3);
- let k = 0;
- for (let j = 0; j <= N; j++) {
- const z = CENTER.z + map(-1 + 2 * j / N);
- for (let i = 0; i <= N; i++) {
- const x = CENTER.x + map(-1 + 2 * i / N);
- verts[k++] = x; verts[k++] = H(x, z); verts[k++] = z;
- }
- }
- const idx = new Uint32Array(N * N * 6);
- k = 0;
- for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
- const a = j * (N + 1) + i, b = a + 1, c = a + N + 1, d = c + 1;
- idx[k++] = a; idx[k++] = c; idx[k++] = b; idx[k++] = b; idx[k++] = c; idx[k++] = d;
- }
- const g = new THREE.BufferGeometry();
- g.setAttribute("position", new THREE.BufferAttribute(verts, 3));
- g.setIndex(new THREE.BufferAttribute(idx, 1));
- g.computeVertexNormals();
- const nrm = g.attributes.normal.array, cols = new Float32Array(verts.length);
- const gA = new THREE.Color("#3f7536"), gB = new THREE.Color("#7fa947"), meadow = new THREE.Color("#9cc25a");
- const forest = new THREE.Color("#2c5130"), rock = new THREE.Color("#6a6384"), rockD = new THREE.Color("#47415f");
- const tmp = new THREE.Color();
- for (let v = 0; v < verts.length; v += 3) {
- const x = verts[v], h = verts[v + 1], z = verts[v + 2], ny = nrm[v + 1];
- const n = fbm(x * 0.02, z * 0.02, 2);
- tmp.copy(gA).lerp(gB, n);
- tmp.lerp(meadow, 0.5 * (1 - smooth(40, 220, Math.abs(x - pathX(z)))) * n);
- tmp.lerp(forest, 0.65 * smooth(0.5, 0.62, fbm(x * 0.006 + 5, z * 0.006 - 2, 3)));
- tmp.lerp(n > 0.5 ? rock : rockD, Math.max(smooth(0.3, 0.55, 1 - ny), smooth(260, 380, h + n * 80)));
- cols[v] = tmp.r; cols[v + 1] = tmp.g; cols[v + 2] = tmp.b;
- }
- g.setAttribute("color", new THREE.BufferAttribute(cols, 3));
- scene.add(new THREE.Mesh(g, flat(0xffffff, { vertexColors: true })));
- }
- // Path
- {
- const pos = [], col = [];
- const stone = ["#d8c9a4", "#bfae8a", "#e6dcc0", "#a99a7c"].map((c) => new THREE.Color(c));
- let prev = null;
- for (let z = PATH_START; z >= PATH_END; z -= 2) {
- const x = pathX(z);
- const tx = pathX(z - 1) - x, len = Math.hypot(tx, 1);
- const nx = 1 / len, nz = tx / len;
- const w = 1.8 + 0.3 * Math.sin(z * 0.21);
- const L = [x + nx * w, 0, z + nz * w], R = [x - nx * w, 0, z - nz * w];
- L[1] = H(L[0], L[2]) + 0.25; R[1] = H(R[0], R[2]) + 0.25;
- if (prev) {
- const c1 = stone[hash2(z | 0, 1) * 4 | 0], c2 = stone[hash2(z | 0, 2) * 4 | 0];
- pos.push(...prev.L, ...prev.R, ...L, ...prev.R, ...R, ...L);
- for (let i = 0; i < 3; i++) col.push(c1.r, c1.g, c1.b);
- for (let i = 0; i < 3; i++) col.push(c2.r, c2.g, c2.b);
- }
- prev = { L, R };
- }
- const g = new THREE.BufferGeometry();
- g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
- g.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
- g.computeVertexNormals();
- scene.add(new THREE.Mesh(g, flat(0xffffff, { vertexColors: true, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, side: THREE.DoubleSide })));
- }
- // The spire
- {
- const by = spireBase.y;
- const parts = [
- { geo: new THREE.CylinderGeometry(42, 44, 2, 24), color: "#c9bfa4", matrix: at(0, by - 0.2, 0) },
- { geo: new THREE.CylinderGeometry(24.4, 25, 6, 8), color: "#5a5578", matrix: at(0, by + 2, 0, Math.PI / 8) },
- { geo: new THREE.CylinderGeometry(13, 24, 170, 8), color: "#4a4668", matrix: at(0, by + 85, 0, Math.PI / 8) },
- { geo: new THREE.CylinderGeometry(22, 22, 3, 16), color: "#6d6890", matrix: at(0, by + 165, 0) },
- { geo: new THREE.CylinderGeometry(10, 13, 22, 8), color: "#4a4668", matrix: at(0, by + 177, 0, Math.PI / 8) },
- { geo: new THREE.ConeGeometry(12, 70, 8), color: "#2a2842", matrix: at(0, by + 223, 0, Math.PI / 8) }
- ];
- const m = new THREE.Mesh(mergeParts(parts), flat(0xffffff, { vertexColors: true }));
- m.position.set(SPIRE.x, 0, SPIRE.z);
- scene.add(m);
- // Lit windows on the eight faces
- const wins = new THREE.InstancedMesh(new THREE.BoxGeometry(1.8, 3.4, 0.6), glow(0xffcf6a), 220);
- let nw = 0;
- for (let y = 12; y < 160 && nw < 220; y += 8) {
- for (let f = 0; f < 8; f++) {
- if (rand() < 0.55) continue;
- const a = (f + 0.5) * Math.PI / 4 + Math.PI / 8;
- const r = lerp(24, 13, y / 170) * Math.cos(Math.PI / 8) + 0.15;
- wins.setMatrixAt(nw++, at(SPIRE.x + Math.sin(a) * r, by + y, SPIRE.z + Math.cos(a) * r, a));
- }
- }
- for (let f = 0; f < 8; f++) {
- const a = (f + 0.5) * Math.PI / 4 + Math.PI / 8, r = 11.6;
- 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));
- }
- wins.count = nw;
- scene.add(wins);
- // Birds circling the top
- const wingGeo = new THREE.BufferGeometry();
- wingGeo.setAttribute("position", new THREE.Float32BufferAttribute([0, 0, -1, 0, 0, 1, 1, 0, 0.2], 3));
- const birdMat = glow(0x1c1a2c, { side: THREE.DoubleSide });
- for (let i = 0; i < 14; i++) {
- const b = new THREE.Group();
- const s = 1.6 + rand() * 0.8;
- const l = new THREE.Mesh(wingGeo, birdMat), r = new THREE.Mesh(wingGeo, birdMat);
- l.scale.setScalar(s); r.scale.set(-s, s, s);
- b.add(l, r);
- b.userData = { l, r, cx: SPIRE.x, cy: by + 190 + rand() * 50, cz: SPIRE.z, rad: 35 + rand() * 60,
- speed: (0.25 + rand() * 0.2) * (rand() < 0.5 ? -1 : 1), phase: rand() * 6.28, flap: 4 + rand() * 2 };
- scene.add(b); anim.birds.push(b);
- }
- }
- // Spiral ramp around the spire
- {
- const by = spireBase.y;
- const segs = RAMP.turns * 64, total = RAMP.turns * Math.PI * 2;
- const deck = [], dcol = [], wall = [];
- const cA = new THREE.Color("#b9b0c9"), cB = new THREE.Color("#a79ebb");
- const P = (ang, h, r) => [SPIRE.x + Math.cos(RAMP.start + ang) * r, by + h, SPIRE.z + Math.sin(RAMP.start + ang) * r];
- for (let i = 0; i < segs; i++) {
- const a0 = i / segs * total, a1 = (i + 1) / segs * total;
- const h0 = lerp(RAMP.h0, RAMP.h1, i / segs), h1 = lerp(RAMP.h0, RAMP.h1, (i + 1) / segs);
- const i0 = rampIn(h0), i1 = rampIn(h1), o0 = i0 + RAMP.width, o1 = i1 + RAMP.width;
- const A = P(a0, h0, i0), B = P(a0, h0, o0), C = P(a1, h1, i1), D = P(a1, h1, o1);
- deck.push(...A, ...C, ...B, ...B, ...C, ...D);
- const c = i % 2 ? cA : cB;
- for (let k = 0; k < 6; k++) dcol.push(c.r, c.g, c.b);
- // underside and outer parapet
- 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]];
- const Bt = [B[0], B[1] + 1.1, B[2]], Dt = [D[0], D[1] + 1.1, D[2]];
- wall.push(...Au, ...Bu, ...Cu, ...Bu, ...Du, ...Cu);
- wall.push(...Bu, ...Bt, ...Du, ...Bt, ...Dt, ...Du);
- }
- const dg = new THREE.BufferGeometry();
- dg.setAttribute("position", new THREE.Float32BufferAttribute(deck, 3));
- dg.setAttribute("color", new THREE.Float32BufferAttribute(dcol, 3));
- dg.computeVertexNormals();
- scene.add(new THREE.Mesh(dg, flat(0xffffff, { vertexColors: true, side: THREE.DoubleSide })));
- const wg = new THREE.BufferGeometry();
- wg.setAttribute("position", new THREE.Float32BufferAttribute(wall, 3));
- wg.computeVertexNormals();
- scene.add(new THREE.Mesh(wg, flat(0x6d6890, { side: THREE.DoubleSide })));
- // lanterns along the parapet
- const lan = new THREE.InstancedMesh(new THREE.BoxGeometry(0.7, 0.9, 0.7), glow(0xffd98a), 200);
- let nl = 0;
- for (let i = 0; i < segs && nl < 200; i += 4) {
- const a = i / segs * total, h = lerp(RAMP.h0, RAMP.h1, i / segs);
- const p = P(a, h + 1.6, rampIn(h) + RAMP.width);
- lan.setMatrixAt(nl++, at(p[0], p[1], p[2]));
- }
- lan.count = nl;
- scene.add(lan);
- }
- // Standing stones
- {
- const by = H(STONES.x, STONES.z);
- const parts = [{ geo: new THREE.CylinderGeometry(9, 10, 3, 10), color: "#8f89a6", matrix: at(0, by + 1, 0) }];
- for (let i = 0; i < 12; i++) {
- const a = i / 12 * Math.PI * 2;
- const h = 22 + rand() * 9;
- const x = Math.cos(a) * 55, z = Math.sin(a) * 55;
- parts.push({ geo: new THREE.BoxGeometry(6, h, 3.6), color: i % 2 ? "#8a84a1" : "#7a7493",
- 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) });
- if (i % 3 === 0) {
- const a2 = a + Math.PI / 12, x2 = Math.cos(a2) * 55, z2 = Math.sin(a2) * 55;
- parts.push({ geo: new THREE.BoxGeometry(18, 3.4, 4), color: "#6f6989",
- matrix: at(x2, H(STONES.x + x, STONES.z + z) + h - 0.5, z2, -a2 + Math.PI / 2) });
- }
- }
- const m = new THREE.Mesh(mergeParts(parts), flat(0xffffff, { vertexColors: true }));
- m.position.set(STONES.x, 0, STONES.z);
- scene.add(m);
- }
- // Waterfall off the plateau edge, facing the spire. The water follows the actual cliff face,
- // so it can't disappear into the rock on the way down.
- {
- const d = new THREE.Vector2(SPIRE.x - CLIFF.x, SPIRE.z - CLIFF.z).normalize();
- const perp = new THREE.Vector2(-d.y, d.x);
- const pAt = (r) => H(CLIFF.x + d.x * r, CLIFF.z + d.y * r);
- const topY = pAt(185);
- let lowR = 185;
- for (let r = 185; r < 340; r++) { lowR = r; if (pAt(r) < topY - 112) break; }
- const lowY = pAt(lowR + 8);
- const drop = topY - lowY;
- const NP = 40, prof = [];
- let rr = 185;
- for (let k = 0; k <= NP; k++) {
- const y = topY - (k / NP) * drop;
- while (rr < lowR + 30 && pAt(rr) > y - 0.5) rr += 0.5;
- prof.push({ y: y + 0.4, out: rr + 2.2 + (k / NP) * 2.5 });
- }
- const facing = Math.atan2(d.x, d.y);
- waterfall.top.set(CLIFF.x + d.x * prof[0].out, topY, CLIFF.z + d.y * prof[0].out);
- waterfall.dir.copy(d); waterfall.bottomY = lowY;
- // the sheet of water, as a ribbon down the face
- const pos = [], col = [];
- const cTop = new THREE.Color("#7fb6e0"), cBot = new THREE.Color("#d7ecff"), cc = new THREE.Color();
- const HW = 11;
- for (let k = 0; k < NP; k++) {
- const A = prof[k], B = prof[k + 1];
- 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;
- const w0 = HW * (1 + k / NP * 0.25), w1 = HW * (1 + (k + 1) / NP * 0.25);
- const p0 = [ax + perp.x * w0, A.y, az + perp.y * w0], p1 = [ax - perp.x * w0, A.y, az - perp.y * w0];
- const p2 = [bx + perp.x * w1, B.y, bz + perp.y * w1], p3 = [bx - perp.x * w1, B.y, bz - perp.y * w1];
- pos.push(...p0, ...p1, ...p2, ...p1, ...p3, ...p2);
- cc.copy(cTop).lerp(cBot, Math.pow(k / NP, 1.5) * (k % 2 ? 0.9 : 1));
- for (let i = 0; i < 6; i++) col.push(cc.r, cc.g, cc.b);
- }
- const rg = new THREE.BufferGeometry();
- rg.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
- rg.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
- rg.computeVertexNormals();
- scene.add(new THREE.Mesh(rg, flat(0xffffff, { vertexColors: true, emissive: 0x1d3a5a, side: THREE.DoubleSide })));
- // falling streaks
- const NS = 90;
- const streaks = new THREE.InstancedMesh(new THREE.BoxGeometry(1.3, 12, 0.4), glow(0xf2faff), NS);
- for (let i = 0; i < NS; i++) anim.streakData.push({ off: (rand() - 0.5) * 20, ph: rand(), sp: 0.26 + rand() * 0.1 });
- anim.streaks = streaks;
- anim.streakGeo = { prof, d, perp, facing };
- scene.add(streaks);
- // pool and spray at the foot
- const last = prof[NP];
- const px = CLIFF.x + d.x * (last.out + 5), pz = CLIFF.z + d.y * (last.out + 5);
- const poolY = Math.min(H(px, pz), last.y);
- const pool = new THREE.Mesh(new THREE.CylinderGeometry(24, 26, 2, 18), flat(0x6fa7d6, { emissive: 0x12304f }));
- pool.position.set(px, poolY + 0.2, pz);
- scene.add(pool);
- const puff = new THREE.IcosahedronGeometry(1, 1), sprayMat = flat(0xe9f1ff, { emissive: 0x2e3b56 });
- for (let i = 0; i < 12; i++) {
- const q = (rand() - 0.5) * 22, o = last.out - 1 + rand() * 5;
- const p = new THREE.Mesh(puff, sprayMat);
- p.position.set(CLIFF.x + d.x * o + perp.x * q, last.y + 1 + rand() * 7, CLIFF.z + d.y * o + perp.y * q);
- p.scale.set(6 + rand() * 5, 3 + rand() * 3, 6 + rand() * 5);
- scene.add(p);
- }
- }
- // Bridge from the spire's balcony down to the top of the waterfall
- {
- const by = spireBase.y;
- const d = waterfall.dir, perp = new THREE.Vector2(-d.y, d.x);
- const ex = waterfall.top.x - d.x * 30 + perp.x * 26, ez = waterfall.top.z - d.y * 30 + perp.y * 26;
- const dir = new THREE.Vector2(ex - SPIRE.x, ez - SPIRE.z).normalize();
- bridge.s.set(SPIRE.x + dir.x * 20, by + RAMP.h1, SPIRE.z + dir.y * 20);
- bridge.e.set(ex, H(ex, ez) + 0.3, ez);
- const len = Math.hypot(bridge.e.x - bridge.s.x, bridge.e.z - bridge.s.z);
- const side = new THREE.Vector2(-dir.y, dir.x);
- const N = Math.ceil(len / 6), hw = bridge.half;
- const deck = [], col = [], rail = [];
- const cA = new THREE.Color("#c2b8cf"), cB = new THREE.Color("#b0a6c2");
- 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];
- for (let i = 0; i < N; i++) {
- const t0 = i / N, t1 = (i + 1) / N;
- const A = pt(t0, -hw), B = pt(t0, hw), C = pt(t1, -hw), D = pt(t1, hw);
- deck.push(...A, ...C, ...B, ...B, ...C, ...D);
- const c = i % 2 ? cA : cB;
- for (let k = 0; k < 6; k++) col.push(c.r, c.g, c.b);
- for (const o of [-hw, hw]) {
- 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);
- rail.push(...a0, ...a1, ...b0, ...a1, ...b1, ...b0);
- }
- 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);
- rail.push(...u0, ...u1, ...u2, ...u1, ...u3, ...u2);
- }
- const dg = new THREE.BufferGeometry();
- dg.setAttribute("position", new THREE.Float32BufferAttribute(deck, 3));
- dg.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
- dg.computeVertexNormals();
- scene.add(new THREE.Mesh(dg, flat(0xffffff, { vertexColors: true, side: THREE.DoubleSide })));
- const rg = new THREE.BufferGeometry();
- rg.setAttribute("position", new THREE.Float32BufferAttribute(rail, 3));
- rg.computeVertexNormals();
- scene.add(new THREE.Mesh(rg, flat(0x6d6890, { side: THREE.DoubleSide })));
- // stone piers down to the ground, and lanterns
- const piers = [];
- const lan = new THREE.InstancedMesh(new THREE.BoxGeometry(0.7, 0.9, 0.7), glow(0xffd98a), 120);
- let nl = 0;
- for (let t = 0.06; t < 0.97; t += 55 / len) {
- const [x, y, z] = pt(t, 0, -1.6);
- const g = H(x, z);
- if (y - g > 4) {
- const hgt = y - g + 2;
- 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)) });
- piers.push({ geo: new THREE.BoxGeometry(8, 3, 8), color: "#6d6890", matrix: at(x, y - 1.2, z, Math.atan2(dir.x, dir.y)) });
- }
- }
- for (let t = 0.02; t < 1 && nl < 120; t += 24 / len) {
- 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])); }
- }
- lan.count = nl;
- if (piers.length) scene.add(new THREE.Mesh(mergeParts(piers), flat(0xffffff, { vertexColors: true })));
- scene.add(lan);
- }
- // Village climbing the spire's hill, plus a few houses at the start
- {
- const houseGeo = mergeParts([
- { geo: new THREE.BoxGeometry(8, 8, 6), color: "#e6dbc0", matrix: at(0, 1, 0) },
- { geo: new THREE.ConeGeometry(6.4, 4.2, 4), color: "#a5432c", matrix: at(0, 7.1, 0, Math.PI / 4, 1, 1, 0.78) },
- { geo: new THREE.BoxGeometry(1, 3, 1), color: "#6c5a4c", matrix: at(2.2, 7.6, 1) },
- { geo: new THREE.BoxGeometry(1.4, 2.2, 0.2), color: "#4a3527", matrix: at(0, -1.9, 3.05) }
- ]);
- const MAXH = 70;
- const houses = new THREE.InstancedMesh(houseGeo, flat(0xffffff, { vertexColors: true }), MAXH);
- const placed = [];
- let n = 0, tries = 0;
- const tint = new THREE.Color();
- while (n < MAXH && tries++ < 6000) {
- let x, z;
- if (n < 58) {
- const a = rand() * Math.PI * 2, r = 56 + Math.pow(rand(), 1.4) * 250;
- x = SPIRE.x + Math.cos(a) * r; z = SPIRE.z + Math.sin(a) * r;
- } else {
- z = 250 - rand() * 180;
- x = pathX(z) + (rand() < 0.5 ? -1 : 1) * (14 + Math.pow(rand(), 1.5) * 120);
- }
- if (Math.abs(x - pathX(z)) < 9 && z < PATH_START && z > PATH_END - 10) continue;
- const h = H(x, z);
- if (Math.abs(H(x + 5, z) - h) > 2.2 || Math.abs(H(x, z + 5) - h) > 2.2) continue;
- if (placed.some((p) => Math.hypot(p[0] - x, p[1] - z) < 16)) continue;
- placed.push([x, z]);
- const s = 0.85 + rand() * 0.4;
- const face = n < 58 ? Math.atan2(SPIRE.x - x, SPIRE.z - z) : Math.atan2(pathX(z) - x, 0);
- 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));
- const b = 0.85 + rand() * 0.25;
- houses.setColorAt(n, tint.setRGB(b, b * (0.93 + rand() * 0.08), b * (0.88 + rand() * 0.12)));
- n++;
- }
- houses.count = n;
- scene.add(houses);
- }
- // Trees, bushes and rocks
- {
- const conGeo = mergeParts([
- { geo: new THREE.CylinderGeometry(0.35, 0.5, 3.2, 5, 1, true), color: "#4a3527", matrix: at(0, 1.6, 0) },
- { geo: new THREE.ConeGeometry(3.3, 6, 7, 1, true), color: "#2c5536", matrix: at(0, 5.2, 0) },
- { geo: new THREE.ConeGeometry(2.6, 5, 7, 1, true), color: "#356640", matrix: at(0, 8.2, 0, 0.4) },
- { geo: new THREE.ConeGeometry(1.8, 4.2, 7, 1, true), color: "#417449", matrix: at(0, 11, 0, 0.8) }
- ]);
- const roundGeo = mergeParts([
- { geo: new THREE.CylinderGeometry(0.35, 0.5, 3.4, 5, 1, true), color: "#523b2a", matrix: at(0, 1.7, 0) },
- { geo: new THREE.IcosahedronGeometry(3.4, 0), color: "#4f8a3c", matrix: at(0, 5.6, 0, 0, 1, 0.9, 1) },
- { geo: new THREE.IcosahedronGeometry(2.2, 0), color: "#5f9a44", matrix: at(1.2, 7.4, 0.6, 0.7) }
- ]);
- const bushGeo = mergeParts([{ geo: new THREE.IcosahedronGeometry(1.4, 0), color: "#5c9a45", matrix: at(0, 0.8, 0, 0, 1, 0.75, 1) }]);
- const rockGeo = mergeParts([{ geo: new THREE.DodecahedronGeometry(1.2, 0), color: "#8d87a0", matrix: at(0, 0.5, 0, 0, 1, 0.6, 1) }]);
- const MAXC = 11000, MAXR = 5000, MAXB = 4000, MAXK = 1600;
- const con = new THREE.InstancedMesh(conGeo, flat(0xffffff, { vertexColors: true }), MAXC);
- const rnd = new THREE.InstancedMesh(roundGeo, flat(0xffffff, { vertexColors: true }), MAXR);
- const bush = new THREE.InstancedMesh(bushGeo, flat(0xffffff, { vertexColors: true }, true), MAXB);
- const rock = new THREE.InstancedMesh(rockGeo, flat(0xffffff, { vertexColors: true }, true), MAXK);
- let nc = 0, nr = 0, nb = 0, nk = 0;
- const tint = new THREE.Color();
- const put = (mesh, i, x, y, z, s, lean) => {
- 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));
- const b = 0.8 + rand() * 0.38;
- mesh.setColorAt(i, tint.setRGB(b * (0.95 + rand() * 0.1), b, b * (0.9 + rand() * 0.15)));
- };
- const clear = (x, z) => {
- if (z < PATH_START + 40 && z > PATH_END - 10 && Math.abs(x - pathX(z)) < 8) return false;
- if (Math.hypot(x - SPIRE.x, z - SPIRE.z) < 60) return false;
- if (Math.hypot(x - STONES.x, z - STONES.z) < 90) return false;
- const dw = Math.hypot(x - waterfall.top.x, z - waterfall.top.z);
- if (dw < 45) return false;
- if (Math.hypot(x - standXZ.x, z - standXZ.z) < 90) return false;
- if (Math.hypot(x - bridge.e.x, z - bridge.e.z) < 70) return false;
- return true;
- };
- const STEP = 9;
- for (let gz = 1100; gz > -2300; gz -= STEP) {
- for (let gx = -1950; gx < 1950; gx += STEP) {
- const x = gx + (rand() - 0.5) * STEP * 0.9, z = gz + (rand() - 0.5) * STEP * 0.9;
- const n = fbm(x * 0.006 + 5, z * 0.006 - 2, 3);
- let dens = 0.012 + smooth(0.5, 0.62, n) * 0.5;
- if (Math.hypot(x - pathX(z), 0) < 160 && z > -120 && z < 220) dens *= 0.25;
- dens *= 0.15 + 0.85 * smooth(200, 360, Math.hypot(x - SPIRE.x, z - SPIRE.z));
- if (rand() > dens || !clear(x, z)) continue;
- const h = H(x, z);
- if (h > 330) continue;
- if (Math.abs(H(x + 3, z) - h) > 2.4 || Math.abs(H(x, z + 3) - h) > 2.4) continue;
- if (rand() < 0.2) { if (nr < MAXR) put(rnd, nr++, x, h - 0.4, z, 0.8 + rand() * 0.7, 0.08); }
- else if (nc < MAXC) put(con, nc++, x, h - 0.4, z, 0.8 + rand() * 1.1, 0.06);
- }
- }
- for (let i = 0; i < 14000 && (nb < MAXB || nk < MAXK); i++) {
- const nearPath = rand() < 0.45;
- let x, z;
- 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); }
- else { x = (rand() - 0.5) * 3000; z = CENTER.z + (rand() - 0.5) * 2800; }
- if (!clear(x, z)) continue;
- const h = H(x, z);
- if (h > 330) continue;
- if (rand() < 0.7) { if (nb < MAXB) put(bush, nb++, x, h - 0.2, z, 0.6 + rand() * 0.9, 0.3); }
- else if (nk < MAXK) put(rock, nk++, x, h - 0.2, z, 0.5 + rand() * 1.6, 0.6);
- }
- for (const [m, n] of [[con, nc], [rnd, nr], [bush, nb], [rock, nk]]) {
- m.count = n;
- m.instanceMatrix.needsUpdate = true;
- if (m.instanceColor) m.instanceColor.needsUpdate = true;
- scene.add(m);
- }
- }
- // Airship
- {
- const g = new THREE.Group();
- const env = new THREE.Mesh(new THREE.SphereGeometry(1, 18, 12), shipFlat(0xc9b089));
- env.scale.set(11, 11, 34);
- g.add(env);
- for (const zz of [-18, 0, 18]) {
- const band = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 2.2, 18, 1, true), shipFlat(0x9a3b2c, { side: THREE.DoubleSide }));
- const r = 11.15 * Math.sqrt(Math.max(0, 1 - (zz / 34) * (zz / 34)));
- band.scale.set(r, 1, r);
- band.rotation.x = Math.PI / 2;
- band.position.z = zz;
- g.add(band);
- }
- const gond = new THREE.Mesh(new THREE.BoxGeometry(6, 4.5, 18), shipFlat(0x5a4032));
- gond.position.set(0, -13.5, 2);
- g.add(gond);
- for (let i = 0; i < 3; i++) {
- const w = new THREE.Mesh(new THREE.BoxGeometry(0.4, 1.4, 1.8), shipGlow(0xffcf6a));
- w.position.set(3.1, -13.3, -3 + i * 5);
- g.add(w);
- const w2 = w.clone(); w2.position.x = -3.1; g.add(w2);
- }
- const finMat = shipFlat(0x9a3b2c);
- for (let i = 0; i < 4; i++) {
- const fin = new THREE.Mesh(new THREE.BoxGeometry(0.8, 12, 9), finMat);
- const a = i * Math.PI / 2;
- fin.position.set(Math.sin(a) * 8, Math.cos(a) * 8, 28);
- fin.rotation.z = -a;
- g.add(fin);
- }
- const props = [];
- for (const sx of [-1, 1]) {
- const hub = new THREE.Group();
- hub.position.set(sx * 6.5, -12, 12);
- const blade = new THREE.Mesh(new THREE.BoxGeometry(0.6, 7, 0.5), shipFlat(0x3a2b22));
- hub.add(blade);
- const blade2 = blade.clone(); blade2.rotation.z = Math.PI / 2; hub.add(blade2);
- g.add(hub); props.push(hub);
- }
- anim.props = props;
- g.scale.setScalar(1.4);
- anim.ship = g;
- scene.add(g);
- }
- // Clouds drifting over the rim
- {
- const clouds = new THREE.Group();
- clouds.position.set(CENTER.x, 0, CENTER.z);
- const geo = new THREE.IcosahedronGeometry(1, 1), mat = flat(0xc7bedc, { emissive: 0x2a2442 });
- for (let i = 0; i < 30; i++) {
- const a = rand() * Math.PI * 2, r = 700 + rand() * 1700;
- const cl = new THREE.Group();
- cl.position.set(Math.cos(a) * r, 480 + rand() * 260, Math.sin(a) * r);
- cl.rotation.y = rand() * Math.PI;
- const n = 4 + (rand() * 4 | 0);
- for (let j = 0; j < n; j++) {
- const p = new THREE.Mesh(geo, mat);
- p.position.set((rand() - 0.5) * 160, (rand() - 0.5) * 18, (rand() - 0.5) * 70);
- p.scale.set(40 + rand() * 55, 15 + rand() * 18, 32 + rand() * 38);
- cl.add(p);
- }
- clouds.add(cl);
- }
- scene.add(clouds);
- anim.clouds = clouds;
- }
- scene.traverse((o) => { o.frustumCulled = false; });
- }
- // ---------------------------------------------------------------------------
- // Full-resolution render, then the painted-backdrop pass.
- // Materials write their compression amount into alpha (sky writes 1).
- // Where that amount is high, pixels are replaced with a '90s-style painted backdrop:
- // chunky pixels, flat depth bands, hue-shifted tone ramps, dithering and a light top edge.
- // ---------------------------------------------------------------------------
- let rt = null, W = 1, Hh = 1;
- const post = new THREE.ShaderMaterial({
- depthTest: false, depthWrite: false,
- uniforms: {
- tColor: { value: null }, res: { value: new THREE.Vector2(1, 1) },
- block: { value: 4 }, paintOn: { value: 1 }, tintOn: { value: 0 },
- tDepth: { value: null }, nearBlock: { value: 2 }, outlineOn: { value: 1 }, nearLevels: { value: 20 },
- cNear: { value: camera.near }, cFar: { value: camera.far },
- haze: { value: new THREE.Color("#a99ed0") }, shadowTint: { value: new THREE.Vector3(0.78, 0.8, 1.2) },
- lightTint: { value: new THREE.Vector3(1.08, 1.03, 0.92) }, rim: { value: new THREE.Color("#fff1d6") }
- },
- vertexShader: `
- varying vec2 vUv;
- void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
- fragmentShader: `
- uniform sampler2D tColor; uniform vec2 res; uniform float block; uniform float paintOn; uniform float tintOn;
- uniform vec3 haze; uniform vec3 shadowTint; uniform vec3 lightTint; uniform vec3 rim;
- uniform sampler2D tDepth; uniform float nearBlock; uniform float outlineOn; uniform float nearLevels;
- uniform float cNear; uniform float cFar;
- float depthAt(vec2 p) {
- float d = texture2D(tDepth, p / res).x;
- if (d > 0.99999) return cFar * 10.0;
- float z = d * 2.0 - 1.0;
- return (2.0 * cNear * cFar) / (cFar + cNear - z * (cFar - cNear));
- }
- varying vec2 vUv;
- float b2(vec2 a) { a = floor(a); return fract(dot(a, vec2(0.5, a.y * 0.75))); }
- float b4(vec2 a) { return b2(0.5 * a) * 0.25 + b2(a); }
- float amtOf(vec4 c) { return c.a > 0.95 ? 1.0 : clamp(c.a / 0.9, 0.0, 1.0); }
- void main() {
- vec2 px = floor(vUv * res);
- // near field: finer pixels, a light palette limit and dark outlines
- vec2 nc = (floor(px / nearBlock) + 0.5) * nearBlock;
- vec4 c = texture2D(tColor, nc / res);
- vec3 col = c.rgb;
- float amt = amtOf(c);
- if (outlineOn > 0.5 && c.a < 0.95) {
- float dC = depthAt(nc);
- float dF = max(max(depthAt(nc + vec2(nearBlock, 0.0)), depthAt(nc - vec2(nearBlock, 0.0))),
- max(depthAt(nc + vec2(0.0, nearBlock)), depthAt(nc - vec2(0.0, nearBlock))));
- if (dF > dC * 1.12 + 0.6) col *= 0.55;
- }
- col = floor(col * nearLevels + 0.5) / nearLevels;
- vec2 bp = floor(px / block);
- vec2 bc = (bp + 0.5) * block;
- vec4 cb = texture2D(tColor, bc / res);
- // average a few samples so the painted pixel is a clean flat colour, not a noisy pick
- vec3 avg = (texture2D(tColor, (bc + vec2(-0.25, -0.25) * block) / res).rgb +
- texture2D(tColor, (bc + vec2( 0.25, -0.25) * block) / res).rgb +
- texture2D(tColor, (bc + vec2(-0.25, 0.25) * block) / res).rgb +
- texture2D(tColor, (bc + vec2( 0.25, 0.25) * block) / res).rgb) * 0.25;
- float amtB = amtOf(cb);
- float dth = b4(bp);
- float t = smoothstep(0.12, 0.42, amtB) * paintOn;
- if (t > dth && amt > 0.04) {
- bool sky = cb.a > 0.95;
- vec3 p = mix(cb.rgb, avg, 0.75);
- if (!sky) {
- float band = amtB < 0.5 ? 0.0 : (amtB < 0.66 ? 1.0 : (amtB < 0.8 ? 2.0 : 3.0));
- p = mix(p, haze, band * 0.12);
- }
- float L = max(dot(p, vec3(0.299, 0.587, 0.114)), 0.001);
- float lev = sky ? 14.0 : 6.0;
- float Lq = max(floor(L * lev + 0.5 + (dth - 0.5) * 0.6), 0.6) / lev;
- p = p * (Lq / L);
- p = mix(p, p * shadowTint, (1.0 - Lq) * 0.75);
- p = mix(p, p * lightTint, Lq * 0.55);
- if (!sky) {
- vec4 up = texture2D(tColor, (bc + vec2(0.0, block)) / res);
- if (up.a > 0.95) p = mix(p, rim, 0.35);
- }
- col = floor(clamp(p, 0.0, 1.0) * 16.0 + 0.5) / 16.0;
- }
- 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));
- gl_FragColor = vec4(col, 1.0);
- }`
- });
- const postScene = new THREE.Scene();
- const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), post);
- quad.frustumCulled = false;
- postScene.add(quad);
- const postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
- // Time of day: keyframed palettes, sun and moon
- const C = (h) => new THREE.Color(h);
- const TOD = [
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 },
- { 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 }
- ].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) }));
- const tod = { t: 16.5, cycle: false };
- const lightDir = new THREE.Vector3();
- function setTime(t) {
- tod.t = ((t % 24) + 24) % 24;
- let i = 0;
- while (i < TOD.length - 2 && TOD[i + 1].t <= tod.t) i++;
- const a = TOD[i], b = TOD[i + 1], u = ease((tod.t - a.t) / (b.t - a.t));
- const su = sky.material.uniforms;
- su.zenith.value.copy(a.zen).lerp(b.zen, u);
- su.horizon.value.copy(a.hor).lerp(b.hor, u);
- su.stars.value = lerp(a.st, b.st, u);
- sun.color.copy(a.sun).lerp(b.sun, u);
- sun.intensity = lerp(a.si, b.si, u);
- hemi.color.copy(a.hs).lerp(b.hs, u);
- hemi.groundColor.copy(a.hg).lerp(b.hg, u);
- hemi.intensity = lerp(a.hi, b.hi, u);
- scene.fog.color.copy(a.fog).lerp(b.fog, u);
- post.uniforms.haze.value.copy(a.haze).lerp(b.haze, u);
- post.uniforms.rim.value.copy(a.rim).lerp(b.rim, u);
- // the sun crosses from east (+x) at 6:00, behind the start (+z) at noon, to west at 18:00
- const phi = Math.PI * (tod.t - 6) / 12;
- const el = Math.sin(phi) * 1.0;
- SUN_DIR.set(Math.cos(phi) * Math.cos(el), Math.sin(el), Math.sin(phi) * Math.cos(el)).normalize();
- MOON_DIR.set(-Math.cos(phi) * 0.8, 0.6, -Math.sin(phi) * 0.8 - 0.2).normalize();
- su.sunGlow.value = smooth(-0.08, 0.05, SUN_DIR.y);
- const day = smooth(-0.05, 0.12, SUN_DIR.y);
- lightDir.copy(MOON_DIR).lerp(SUN_DIR, day).normalize();
- sun.position.copy(lightDir).multiplyScalar(1000);
- const hh = Math.floor(tod.t), mm = Math.floor((tod.t - hh) * 60);
- $("todOut").textContent = `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
- $("tod").value = tod.t.toFixed(1);
- }
- const style = { paint: true, block: 4, nearBlock: 2, outline: true, tint: false };
- function resize() {
- W = Math.max(1, Math.round(window.innerWidth * DPR));
- Hh = Math.max(1, Math.round(window.innerHeight * DPR));
- renderer.setSize(W, Hh, false);
- camera.aspect = W / Hh;
- camera.updateProjectionMatrix();
- if (rt) { if (rt.depthTexture) rt.depthTexture.dispose(); rt.dispose(); }
- rt = new THREE.WebGLRenderTarget(W, Hh, {
- minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, generateMipmaps: false,
- format: THREE.RGBAFormat, depthBuffer: true
- });
- rt.depthTexture = new THREE.DepthTexture(W, Hh);
- rt.depthTexture.type = THREE.UnsignedIntType;
- post.uniforms.tColor.value = rt.texture;
- post.uniforms.tDepth.value = rt.depthTexture;
- post.uniforms.res.value.set(W, Hh);
- post.uniforms.block.value = Math.max(1, Math.round(style.block * DPR));
- post.uniforms.nearBlock.value = Math.max(1, Math.round(style.nearBlock * DPR));
- }
- window.addEventListener("resize", resize);
- // ---------------------------------------------------------------------------
- // Player and controls
- // ---------------------------------------------------------------------------
- const pos = new THREE.Vector3(pathX(40), 0, 40);
- let yaw = 0, pitch = 0.12, locked = false;
- const keys = Object.create(null);
- const move = { fly: false, speed: 10 };
- let mode = "tour";
- function warpedXZ(x, z) {
- const dx = x - pos.x, dz = z - pos.z, r = Math.hypot(dx, dz);
- const s = r > 0.001 ? lerp(1, loomF(r) / r, warp.strength) : 1;
- return [dx * s, dz * s];
- }
- // Aim the camera at where a world point appears after warping
- function aimAt(x, y, z, rate, dt, instant = false) {
- const [dx, dz] = warpedXZ(x, z);
- const ty = Math.atan2(-dx, -dz);
- const tp = Math.max(-1.45, Math.min(1.45, Math.atan2(y - pos.y, Math.hypot(dx, dz))));
- const k = instant ? 1 : Math.min(1, dt * rate);
- yaw += angDiff(ty, yaw) * k;
- pitch += (tp - pitch) * k;
- }
- const groundY = (x, z) => H(x, z) + 1.7;
- // Height of the highest walkable surface at (x, z) that the feet can step onto
- function surfaceY(x, z, feet) {
- let best = H(x, z);
- const by = spireBase.y, reach = feet + 1.3;
- const dx = x - SPIRE.x, dz = z - SPIRE.z, r = Math.hypot(dx, dz);
- if (r < 42 && by + 0.8 <= reach) best = Math.max(best, by + 0.8);
- if (r < 22.5 && by + RAMP.h1 <= reach) best = Math.max(best, by + RAMP.h1);
- if (r < 40) {
- const total = RAMP.turns * Math.PI * 2;
- let a = Math.atan2(dz, dx) - RAMP.start;
- a = ((a % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
- for (let k = 0; k <= RAMP.turns; k++) {
- const ang = a + k * Math.PI * 2;
- if (ang > total) break;
- const h = lerp(RAMP.h0, RAMP.h1, ang / total), ri = rampIn(h);
- if (r >= ri - 0.4 && r <= ri + RAMP.width + 0.2 && by + h <= reach) best = Math.max(best, by + h);
- }
- }
- const bx = bridge.e.x - bridge.s.x, bz = bridge.e.z - bridge.s.z, L2 = bx * bx + bz * bz;
- if (L2 > 1) {
- const t = ((x - bridge.s.x) * bx + (z - bridge.s.z) * bz) / L2;
- if (t >= 0 && t <= 1) {
- const lat = Math.abs((x - bridge.s.x) * bz - (z - bridge.s.z) * bx) / Math.sqrt(L2);
- const y = lerp(bridge.s.y, bridge.e.y, t);
- if (lat < bridge.half && y <= reach) best = Math.max(best, y);
- }
- }
- return best;
- }
- // Keep the player out of the tower itself
- function pushOutOfTower() {
- const h = pos.y - 1.7 - spireBase.y;
- if (h < -2 || h > 190) return;
- const dx = pos.x - SPIRE.x, dz = pos.z - SPIRE.z, r = Math.hypot(dx, dz) || 0.001;
- const minR = (h > 166 ? 13.4 : towerR(h)) + 0.5;
- if (r < minR) { pos.x = SPIRE.x + dx / r * minR; pos.z = SPIRE.z + dz / r * minR; }
- }
- let fallV = 0;
- canvas.addEventListener("click", () => { if (mode === "roam" && !locked && canvas.requestPointerLock) canvas.requestPointerLock(); });
- document.addEventListener("pointerlockchange", () => {
- locked = document.pointerLockElement === canvas;
- $("hint").classList.toggle("hidden", locked || mode !== "roam");
- });
- document.addEventListener("mousemove", (e) => {
- if (!locked) return;
- yaw -= e.movementX * 0.0022;
- pitch = Math.max(-1.5, Math.min(1.5, pitch - e.movementY * 0.0022));
- });
- // ---------------------------------------------------------------------------
- // Warp presets and the reveal
- // ---------------------------------------------------------------------------
- const PRESETS = { subtle: [520, 900], dream: [200, 250], extreme: [90, 70] };
- function applyWarp() { uLoom.value.set(warp.D, warp.S, warp.strength, 0); }
- function setPreset(name) {
- const [D, S] = PRESETS[name];
- warp.D = D; warp.S = S;
- $("dB").value = D; $("soft").value = S;
- $("dBOut").textContent = `${D} m`; $("softOut").textContent = S === 0 ? "snap flat" : `${S} m`;
- document.querySelectorAll("[data-preset]").forEach((b) => b.classList.toggle("on", b.dataset.preset === name));
- applyWarp();
- }
- function setWarpTarget(v, seconds = 2.5) {
- warp.from = warp.strength; warp.target = v; warp.animT = 0; warp.dur = seconds;
- $("revealBtn").innerHTML = (v > 0.5 ? "Reveal true size" : "Bring the warp back") + " <kbd>X</kbd>";
- }
- // ---------------------------------------------------------------------------
- // Guided tour
- // ---------------------------------------------------------------------------
- const tmpV = new THREE.Vector3();
- const standXZ = shipAt(SHIP_STAND_THETA, new THREE.Vector3());
- const outward = new THREE.Vector2(pathX(0) - SPIRE.x, 0 - SPIRE.z).normalize();
- const STOPS = [
- {
- name: "The approach", dur: 44,
- text: "The spire looms at its full height the whole way, and only settles to its true size once it crosses the boundary.",
- 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); },
- update(t, dt) {
- const z = Math.max(PATH_END + 30, 40 - 15.5 * t);
- pos.set(pathX(z), 0, z); pos.y = groundY(pos.x, pos.z);
- aimAt(SPIRE.x, spireBase.y + 110, SPIRE.z, 1.8, dt);
- }
- },
- {
- name: "The climb", dur: 44,
- text: "Rising above the world, the ground falls away. Everything below looks far more distant than it really is.",
- setup() { this.u(0); },
- u(t) {
- let a = 0, e = 0;
- if (t < 20) e = ease(t / 20); else { e = 1; a = ease(Math.min(1, (t - 20) / 22)) * Math.PI * 2; }
- const ox = outward.x * Math.cos(a) - outward.y * Math.sin(a), oz = outward.x * Math.sin(a) + outward.y * Math.cos(a);
- const rr = lerp(38, 25, e);
- pos.set(SPIRE.x + ox * rr, spireBase.y + 1.7 + e * 166, SPIRE.z + oz * rr);
- yaw = Math.atan2(-ox, -oz);
- pitch = lerp(0.08, -0.36, e);
- },
- update(t) { this.u(t); }
- },
- {
- name: "Passing through", dur: 42,
- text: "Far away, the airship looms large and high in the sky. As it crosses the boundary overhead, it settles to its true size.",
- setup() {
- pos.set(standXZ.x, 0, standXZ.z); pos.y = groundY(pos.x, pos.z);
- ship.theta = SHIP_STAND_THETA - 1.2;
- shipAt(ship.theta, ship.pos);
- aimAt(ship.pos.x, ship.pos.y, ship.pos.z, 0, 0, true);
- },
- update(t, dt) { aimAt(ship.pos.x, ship.pos.y, ship.pos.z, 2.5, dt); }
- },
- {
- name: "The reveal", dur: 26,
- text: "",
- setup() {
- const d = waterfall.dir;
- pos.set(CLIFF.x + d.x * 150 - d.y * 40, 0, CLIFF.z + d.y * 150 + d.x * 40);
- pos.y = groundY(pos.x, pos.z) + 1;
- aimAt(STONES.x * 0.4 + SPIRE.x * 0.6, spireBase.y + 60, STONES.z * 0.4 + SPIRE.z * 0.6, 0, 0, true);
- pitch = -0.02;
- this.phase = -1;
- },
- update(t) {
- const ph = t < 4 ? 0 : t < 13 ? 1 : t < 17 ? 2 : 3;
- if (ph !== this.phase) {
- this.phase = ph;
- if (ph === 1) setWarpTarget(0, 3);
- if (ph === 2) setWarpTarget(1, 3);
- const texts = [
- "This is the world with the warp on. Watch what happens when it's switched off.",
- "Warp off: this is the world's true size. The spire and the hills around the rim are far smaller than they looked.",
- "And back on. The same small world, stretched into a vast one.",
- "That's the showcase. You're in free roam next: try flying up high, or switch presets."
- ];
- showCaption(4, this.name, texts[ph]);
- }
- }
- }
- ];
- const tour = { i: 0, t: 0, fading: false };
- function showCaption(n, title, text) {
- $("capKicker").textContent = n ? `Stop ${n} of 4` : "Free roam";
- $("capTitle").textContent = title;
- $("capText").textContent = text;
- $("caption").classList.remove("hidden");
- }
- function hideCaption() { $("caption").classList.add("hidden"); }
- function withFade(fn) {
- if (tour.fading) return;
- tour.fading = true;
- $("fade").classList.add("on");
- setTimeout(() => { fn(); setTimeout(() => { $("fade").classList.remove("on"); tour.fading = false; }, 120); }, 650);
- }
- function startStop(i) {
- withFade(() => {
- mode = "tour";
- if (document.pointerLockElement) document.exitPointerLock();
- $("hint").classList.add("hidden");
- warp.strength = 1; warp.target = 1; warp.animT = 1; applyWarp();
- $("revealBtn").innerHTML = "Reveal true size <kbd>X</kbd>";
- tour.i = i; tour.t = 0;
- const s = STOPS[i];
- s.setup();
- if (s.text) showCaption(i + 1, s.name, s.text);
- $("tourStep").textContent = `${i + 1} of 4`;
- $("tourName").textContent = s.name;
- $("roamBtn").textContent = "Free roam";
- });
- }
- function toRoam(keepPlace = true) {
- mode = "roam";
- tour.t = 0;
- if (!keepPlace) { pos.set(pathX(40), 0, 40); yaw = 0; pitch = 0.12; }
- if (warp.target < 0.5) setWarpTarget(1, 1.5);
- $("tourStep").textContent = "–";
- $("tourName").textContent = "Free roam";
- $("tourBar").style.width = "0%";
- $("roamBtn").textContent = "Restart tour";
- $("hint").classList.toggle("hidden", locked);
- 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.");
- clearTimeout(toRoam.timer);
- toRoam.timer = setTimeout(() => { if (mode === "roam") hideCaption(); }, 6000);
- }
- // ---------------------------------------------------------------------------
- // UI wiring
- // ---------------------------------------------------------------------------
- function syncStyle() {
- $("optPaint").checked = style.paint; $("optTint").checked = style.tint; $("optFly").checked = move.fly;
- post.uniforms.paintOn.value = style.paint ? 1 : 0;
- post.uniforms.tintOn.value = style.tint ? 1 : 0;
- uDetailFade.value = style.paint ? 1 : 0;
- post.uniforms.block.value = Math.max(1, Math.round(style.block * DPR));
- post.uniforms.nearBlock.value = Math.max(1, Math.round(style.nearBlock * DPR));
- post.uniforms.outlineOn.value = style.outline ? 1 : 0;
- $("optOutline").checked = style.outline;
- $("optRigid").checked = uRigidOn.value > 0.5;
- }
- document.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { setPreset(b.dataset.preset); b.blur(); }));
- $("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(); });
- $("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(); });
- $("revealBtn").addEventListener("click", (e) => { setWarpTarget(warp.target > 0.5 ? 0 : 1); e.currentTarget.blur(); });
- $("optPaint").addEventListener("change", (e) => { style.paint = e.target.checked; syncStyle(); });
- $("optTint").addEventListener("change", (e) => { style.tint = e.target.checked; syncStyle(); });
- $("optFly").addEventListener("change", (e) => { move.fly = e.target.checked; syncStyle(); });
- $("tod").addEventListener("input", (e) => setTime(+e.target.value));
- $("optCycle").addEventListener("change", (e) => { tod.cycle = e.target.checked; });
- $("block").addEventListener("input", (e) => { style.block = +e.target.value; $("blockOut").textContent = `${style.block} px`; syncStyle(); });
- $("nblock").addEventListener("input", (e) => { style.nearBlock = +e.target.value; $("nblockOut").textContent = `${style.nearBlock} px`; syncStyle(); });
- $("optOutline").addEventListener("change", (e) => { style.outline = e.target.checked; syncStyle(); });
- $("optRigid").addEventListener("change", (e) => { uRigidOn.value = e.target.checked ? 1 : 0; syncStyle(); });
- $("speed").addEventListener("input", (e) => { move.speed = +e.target.value; $("speedOut").textContent = `${move.speed} m/s`; });
- $("prevStop").addEventListener("click", (e) => { startStop(mode === "tour" ? Math.max(0, tour.i - 1) : 0); e.currentTarget.blur(); });
- $("nextStop").addEventListener("click", (e) => { nextStop(); e.currentTarget.blur(); });
- $("roamBtn").addEventListener("click", (e) => { if (mode === "tour") toRoam(); else startStop(0); e.currentTarget.blur(); });
- function nextStop() {
- if (mode !== "tour") { startStop(0); return; }
- if (tour.i < STOPS.length - 1) startStop(tour.i + 1); else toRoam();
- }
- const MOVE_KEYS = ["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyE", "KeyQ"];
- window.addEventListener("keydown", (e) => {
- const tag = e.target && e.target.tagName;
- if ((tag === "INPUT" || tag === "BUTTON") && !locked && !MOVE_KEYS.includes(e.code) && !e.code.startsWith("Shift")) return;
- keys[e.code] = true;
- if (e.repeat) return;
- if (MOVE_KEYS.includes(e.code) && mode === "tour") toRoam();
- if (e.code === "KeyX") setWarpTarget(warp.target > 0.5 ? 0 : 1);
- else if (e.code === "KeyP") { style.paint = !style.paint; syncStyle(); }
- else if (e.code === "KeyB") { style.tint = !style.tint; syncStyle(); }
- else if (e.code === "KeyL") { tod.cycle = !tod.cycle; $("optCycle").checked = tod.cycle; }
- else if (e.code === "KeyO") { style.outline = !style.outline; syncStyle(); }
- else if (e.code === "KeyK") { uRigidOn.value = uRigidOn.value > 0.5 ? 0 : 1; syncStyle(); }
- else if (e.code === "KeyF") { move.fly = !move.fly; syncStyle(); }
- else if (e.code === "KeyH") $("panel").classList.toggle("hidden");
- else if (e.code === "KeyN") nextStop();
- else if (e.code === "Digit1") setPreset("subtle");
- else if (e.code === "Digit2") setPreset("dream");
- else if (e.code === "Digit3") setPreset("extreme");
- });
- window.addEventListener("keyup", (e) => { keys[e.code] = false; });
- window.addEventListener("blur", () => { for (const k in keys) keys[k] = false; });
- // ---------------------------------------------------------------------------
- // Loop
- // ---------------------------------------------------------------------------
- const clock = new THREE.Clock();
- let time = 0, frames = 0, fpsT = 0;
- function update(dt) {
- time += dt;
- // warp strength animation (the reveal)
- if (warp.animT < 1) {
- warp.animT = Math.min(1, warp.animT + dt / (warp.dur || 2.5));
- warp.strength = lerp(warp.from, warp.target, ease(warp.animT));
- applyWarp();
- }
- if (tod.cycle) setTime(tod.t + dt * 0.1); // a full day every four minutes
- // world animation
- ship.theta += ship.omega * dt;
- shipAt(ship.theta, ship.pos);
- if (anim.ship) {
- anim.ship.position.copy(ship.pos);
- shipAt(ship.theta + 0.01, tmpV);
- anim.ship.lookAt(tmpV);
- anim.ship.rotateY(Math.PI);
- for (const p of anim.props) p.rotation.z += dt * 9;
- }
- if (anim.clouds) anim.clouds.rotation.y += dt * 0.006;
- for (const b of anim.birds) {
- const u = b.userData, a = u.phase + time * u.speed;
- 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);
- b.rotation.y = -a + (u.speed > 0 ? 0 : Math.PI);
- const f = Math.sin(time * u.flap + u.phase) * 0.6;
- u.l.rotation.z = f; u.r.rotation.z = -f;
- }
- if (anim.streaks) {
- const g = anim.streakGeo, P = g.prof, L = P.length - 1;
- for (let i = 0; i < anim.streakData.length; i++) {
- const d = anim.streakData[i];
- const f = (d.ph + time * d.sp) % 1;
- const fi = f * L, i0 = Math.floor(fi), i1 = Math.min(L, i0 + 1), u = fi - i0;
- const y = lerp(P[i0].y, P[i1].y, u), out = lerp(P[i0].out, P[i1].out, u) + 0.8;
- 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));
- }
- anim.streaks.instanceMatrix.needsUpdate = true;
- }
- // camera
- if (mode === "tour" && !tour.fading) {
- const s = STOPS[tour.i];
- tour.t += dt;
- s.update(tour.t, dt);
- $("tourBar").style.width = `${Math.min(100, tour.t / s.dur * 100)}%`;
- if (tour.t >= s.dur) nextStop();
- } else if (mode === "roam") {
- const v = move.speed * (keys.ShiftLeft || keys.ShiftRight ? 3 : 1);
- let f = 0, st = 0, up = 0;
- if (keys.KeyW || keys.ArrowUp) f += 1;
- if (keys.KeyS || keys.ArrowDown) f -= 1;
- if (keys.KeyD || keys.ArrowRight) st += 1;
- if (keys.KeyA || keys.ArrowLeft) st -= 1;
- if (keys.KeyE) up += 1;
- if (keys.KeyQ) up -= 1;
- const sy = Math.sin(yaw), cy = Math.cos(yaw);
- if (move.fly) {
- const cp = Math.cos(pitch), sp = Math.sin(pitch);
- pos.x += (-sy * cp * f + cy * st) * v * dt;
- pos.z += (-cy * cp * f - sy * st) * v * dt;
- pos.y += (sp * f + up) * v * dt;
- pos.y = Math.max(groundY(pos.x, pos.z), Math.min(2500, pos.y));
- fallV = 0;
- } else {
- const len = Math.hypot(f, st) || 1;
- pos.x += (-sy * f + cy * st) / len * v * dt;
- pos.z += (-cy * f - sy * st) / len * v * dt;
- pushOutOfTower();
- const g = surfaceY(pos.x, pos.z, pos.y - 1.7) + 1.7;
- if (pos.y > g + 0.05) {
- fallV = Math.min(60, fallV + 25 * dt);
- pos.y = Math.max(g, pos.y - fallV * dt);
- } else { pos.y = g; fallV = 0; }
- }
- pos.x = Math.max(CENTER.x - 2400, Math.min(CENTER.x + 2400, pos.x));
- pos.z = Math.max(CENTER.z - 2400, Math.min(CENTER.z + 2400, pos.z));
- }
- camera.position.copy(pos);
- camera.rotation.set(pitch, yaw, 0);
- camera.updateMatrixWorld();
- sky.position.copy(pos);
- uCam.value.copy(pos);
- uShip.value.copy(ship.pos);
- }
- function frame() {
- const dt = Math.min(clock.getDelta(), 0.1);
- update(dt);
- renderer.setRenderTarget(rt);
- renderer.render(scene, camera);
- renderer.setRenderTarget(null);
- renderer.render(postScene, postCam);
- frames++; fpsT += dt;
- if (fpsT >= 0.5) { $("fps").textContent = `${Math.round(frames / fpsT)} fps`; frames = 0; fpsT = 0; }
- requestAnimationFrame(frame);
- }
- setTimeout(() => {
- try {
- buildWorld();
- } catch (err) {
- console.error(err);
- $("loading").innerHTML = '<div class="error">The world failed to build. Reload the page to try again.</div>';
- return;
- }
- resize();
- syncStyle();
- setPreset("dream");
- setTime(16.5);
- const m = /stop=(\d)/.exec(location.hash);
- tour.i = m ? Math.min(3, +m[1]) : 0;
- const s = STOPS[tour.i];
- s.setup();
- const cm = /cam=([-\d.,]+)/.exec(location.hash);
- 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(); }
- const tm = /t=(\d+)/.exec(location.hash);
- if (tm) { tour.t = +tm[1]; ship.theta += ship.omega * tour.t; shipAt(ship.theta, ship.pos); }
- showCaption(tour.i + 1, s.name, s.text || "");
- $("tourStep").textContent = `${tour.i + 1} of 4`;
- $("tourName").textContent = s.name;
- uCam.value.copy(pos);
- $("loading").classList.add("hidden");
- clock.getDelta();
- requestAnimationFrame(frame);
- }, 40);
- })();
- </script>
- </body>
- </html>
Advertisement
Add Comment
Please, Sign In to add comment