Files
zhila/reference/demo/terrain.js
T

400 lines
13 KiB
JavaScript

/* жила · рельеф
A single contour field over the whole surface: domain-warped fbm for the strata,
a radial well under the eye, perspective compression below the horizon,
and black pools cut where the interface needs to speak. */
(function () {
'use strict';
const P = {
scale: 1 / 1.35, // field resolution vs. screen
density: 17, // contour levels per unit of the field
freq: 4.7, // base frequency of the strata
oct: 5,
warp: 0.66,
well: 2.35,
line: 0.68, // half-width of a contour, in pixels
bloom: 0.5
};
const SCALE = P.scale;
const canvas = document.getElementById('terrain');
const ctx = canvas.getContext('2d', { alpha: false });
const spiresCanvas = document.getElementById('spires');
if (!canvas) return;
const anchors = {
eye: { x: 0.5, y: 0.3, sig: 0.13 },
hor: 0.6
};
/* ————— noise ————— */
function hash(x, y) {
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function noise(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);
const v = yf * yf * (3 - 2 * yf);
const a = hash(xi, yi), b = hash(xi + 1, yi);
const c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
const ab = a + (b - a) * u;
const cd = c + (d - c) * u;
return ab + (cd - ab) * v;
}
function fbm(x, y, oct) {
let s = 0, amp = 0.5, f = 1, norm = 0;
for (let i = 0; i < oct; i++) {
s += amp * noise(x * f, y * f);
norm += amp;
f *= 2.04;
amp *= 0.5;
}
return s / norm;
}
const smooth = (a, b, x) => {
let t = (x - a) / (b - a);
t = t < 0 ? 0 : t > 1 ? 1 : t;
return t * t * (3 - 2 * t);
};
const clamp01 = v => v < 0 ? 0 : v > 1 ? 1 : v;
/* ————— anchors: where the eye and the horizon actually are ————— */
function measure() {
const voidEl = document.querySelector('.void');
const stage = document.getElementById('stage');
const h = window.innerHeight;
if (voidEl) {
const r = voidEl.getBoundingClientRect();
anchors.eye.x = (r.left + r.width / 2) / h;
anchors.eye.y = (r.top + r.height / 2) / h;
anchors.eye.sig = (r.width * 0.95) / h;
}
if (stage) {
const sr = stage.getBoundingClientRect();
anchors.hor = (sr.top + sr.height * 0.28) / h;
}
}
/* ————— black pools around the interface ————— */
function blob(g, x, y, w, h, seed) {
const pad = 15;
x -= pad; y -= pad; w += pad * 2; h += pad * 2;
let s = seed * 9301 + 49297;
const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
const jit = 9;
const step = 34;
const nx = Math.max(2, Math.round(w / step));
const ny = Math.max(2, Math.round(h / step));
const pts = [];
for (let i = 0; i <= nx; i++) pts.push([x + (w * i) / nx, y + (rnd() - 0.5) * jit]);
for (let i = 1; i <= ny; i++) pts.push([x + w + (rnd() - 0.5) * jit, y + (h * i) / ny]);
for (let i = nx - 1; i >= 0; i--) pts.push([x + (w * i) / nx, y + h + (rnd() - 0.5) * jit]);
for (let i = ny - 1; i > 0; i--) pts.push([x + (rnd() - 0.5) * jit, y + (h * i) / ny]);
g.beginPath();
g.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
g.closePath();
}
let maskCache = null;
function buildMask(mw, mh) {
const c = document.createElement('canvas');
c.width = mw; c.height = mh;
const g = c.getContext('2d');
g.clearRect(0, 0, mw, mh);
g.fillStyle = '#fff';
g.filter = 'blur(11px)';
const els = Array.prototype.slice.call(document.querySelectorAll('[data-pool]'));
els.forEach((el, i) => {
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) return;
if (r.bottom < -40 || r.top > window.innerHeight + 40) return;
g.globalAlpha = parseFloat(el.dataset.pool) || 0.9;
blob(g, r.left * SCALE, r.top * SCALE, r.width * SCALE, r.height * SCALE, i + 3);
g.fill();
});
g.filter = 'none';
const data = g.getImageData(0, 0, mw, mh).data;
maskCache = data;
return data;
}
/* ————— the field ————— */
function render() {
measure();
const W = canvas.clientWidth || window.innerWidth;
const H = canvas.clientHeight || window.innerHeight;
const dpr = Math.min(1.6, window.devicePixelRatio || 1);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#080504';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const mw = Math.max(8, Math.round(W * SCALE));
const mh = Math.max(8, Math.round(H * SCALE));
const mask = buildMask(mw, mh);
const e = anchors.eye;
const hor = anchors.hor;
const sig2 = 2 * e.sig * e.sig;
const field = new Float32Array(mw * mh);
/* pass 1: the terrain itself */
for (let j = 0; j < mh; j++) {
const py = (j + 0.5) / mh;
const below = smooth(hor - 0.01, hor + 0.12, py);
const dd = Math.max(py - hor, 0.006);
let persp = 1.55 / dd;
if (persp > 2.15) persp = 2.15;
const sy = 1 + below * (persp - 1) * 0.8;
const sx = 1 + below * 0.3;
for (let i = 0; i < mw; i++) {
const px = (i + 0.5) / mh;
const wx = fbm(px * 2.05 + 13.7, py * 2.42 + 4.2, 3);
const wy = fbm(px * 2.05 + 7.1, py * 2.42 + 9.6, 3);
const qx = px + (wx - 0.5) * P.warp;
const qy = py + (wy - 0.5) * P.warp;
const dx = px - e.x, dy = py - e.y;
const well = Math.exp(-(dx * dx + dy * dy) / sig2);
let n = fbm(qx * P.freq * sx + 19.1, qy * P.freq * sy + 2.7, P.oct);
n -= P.well * well;
field[j * mw + i] = n;
}
}
/* pass 2: contours with a constant ink width */
const img = new ImageData(mw, mh);
const data = img.data;
const px2 = 1 / mh;
for (let j = 0; j < mh; j++) {
const py = (j + 0.5) / mh;
const below = smooth(hor - 0.01, hor + 0.12, py);
const dim = 1 - 0.4 * below;
const j0 = Math.max(0, j - 1), j1 = Math.min(mh - 1, j + 1);
for (let i = 0; i < mw; i++) {
const idx = j * mw + i;
const n = field[idx];
const i0 = Math.max(0, i - 1), i1 = Math.min(mw - 1, i + 1);
const gx = (field[j * mw + i1] - field[j * mw + i0]) * 0.5;
const gy = (field[j1 * mw + i] - field[j0 * mw + i]) * 0.5;
let g = Math.sqrt(gx * gx + gy * gy);
if (g < 0.00035) g = 0.00035;
const v = n * P.density;
const fr = v - Math.floor(v);
const edge = (fr < 0.5 ? fr : 1 - fr) / P.density; // distance to the line, in field units
const ink = P.line * g * (0.82 + 0.5 * Math.min(1, Math.abs(n) * 2));
let line = 1 - edge / ink;
if (line < 0) line = 0;
line = Math.pow(line, 0.8);
const px = (i + 0.5) / mh;
const dx = px - e.x, dy = py - e.y;
const well = Math.exp(-(dx * dx + dy * dy) / sig2);
const m = 1 - (mask[idx * 4 + 3] / 255) * 0.975;
const vy = 1 - Math.abs(py - 0.12) * 0.9;
const a = clamp01(line * m * dim * (1 + 0.4 * well) * (0.82 + 0.3 * vy));
const wash = clamp01((0.018 + 0.13 * well) * m * dim * (1 - line) * (0.7 + 0.6 * vy));
const hot = clamp01(well * 1.1 + n * 0.42);
const p = idx * 4;
data[p] = 196 + 52 * hot;
data[p + 1] = 40 + 30 * hot + 14 * n;
data[p + 2] = 28 + 16 * hot;
data[p + 3] = (a + wash * (1 - a)) * 255;
}
}
const tmp = document.createElement('canvas');
tmp.width = mw; tmp.height = mh;
tmp.getContext('2d').putImageData(img, 0, 0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = P.bloom;
ctx.filter = 'blur(8px)';
ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height);
ctx.filter = 'none';
ctx.globalAlpha = 1;
ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height);
ctx.restore();
const g = ctx.createRadialGradient(
canvas.width * 0.5, canvas.height * 0.36, 0,
canvas.width * 0.5, canvas.height * 0.36, Math.max(canvas.width, canvas.height) * 0.78
);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,.38)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
/* ————— the city at the end of the road ————— */
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function spire(g, x, base, h, w, rnd, alpha) {
const tipX = x + (rnd() - 0.5) * w * 0.32;
const tipY = base - h;
const halfAt = function (t) {
let k = Math.pow(1 - t, 0.62);
k *= 1 + 0.1 * Math.sin(t * 7 + w * 0.5);
if (t > 0.34) k *= 1 - 0.15 * (t - 0.34);
return Math.max(0.5, w * 0.5 * k);
};
const cxAt = function (t) { return x + (tipX - x) * Math.pow(t, 1.6); };
const N = 20;
const pts = [];
for (let i = 0; i <= N; i++) {
const t = i / N;
pts.push([cxAt(t) - halfAt(t), base - h * t]);
}
pts.push([tipX, tipY]);
for (let i = N; i >= 0; i--) {
const t = i / N;
pts.push([cxAt(t) + halfAt(t), base - h * t]);
}
const body = new Path2D();
body.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) body.lineTo(pts[i][0], pts[i][1]);
body.closePath();
g.fillStyle = 'rgba(58,42,35,.34)';
g.fill(body);
g.strokeStyle = 'rgba(220,206,190,' + (0.5 * alpha).toFixed(2) + ')';
g.lineWidth = 0.8;
g.stroke(body);
/* ribs: the vertical grain of the crystal */
const ribs = 4;
g.lineWidth = 0.6;
g.strokeStyle = 'rgba(208,194,180,' + (0.26 * alpha).toFixed(2) + ')';
for (let k = -ribs; k <= ribs; k++) {
const f = k / ribs;
g.beginPath();
g.moveTo(x + f * w * 0.48, base);
g.lineTo(cxAt(1) + f * 1.6, tipY + 1);
g.stroke();
}
/* strata, straight: a wireframe, not a vase */
const bands = 7 + Math.floor(rnd() * 5);
g.strokeStyle = 'rgba(208,194,180,' + (0.16 * alpha).toFixed(2) + ')';
for (let i = 1; i <= bands; i++) {
const t = Math.pow(i / (bands + 1), 1.1);
const cx = cxAt(t);
const y = base - h * t;
const hw = halfAt(t) * 0.97;
g.beginPath();
g.moveTo(cx - hw, y);
g.lineTo(cx + hw, y);
g.stroke();
}
/* a few braces, to catch the light */
g.strokeStyle = 'rgba(208,194,180,' + (0.13 * alpha).toFixed(2) + ')';
for (let i = 1; i <= 2; i++) {
const t0 = 0.16 + i * 0.22;
const t1 = t0 + 0.12;
const hw0 = halfAt(t0), hw1 = halfAt(t1);
g.beginPath();
g.moveTo(cxAt(t0) - hw0, base - h * t0);
g.lineTo(cxAt(t1) + hw1, base - h * t1);
g.moveTo(cxAt(t0) + hw0, base - h * t0);
g.lineTo(cxAt(t1) - hw1, base - h * t1);
g.stroke();
}
if (rnd() > 0.35) {
g.beginPath();
g.moveTo(tipX, tipY + 2);
g.lineTo(tipX, tipY - h * 0.14);
g.strokeStyle = 'rgba(224,210,194,' + (0.4 * alpha).toFixed(2) + ')';
g.lineWidth = 0.7;
g.stroke();
}
}
function drawSpires() {
if (!spiresCanvas) return;
const W = spiresCanvas.clientWidth;
const H = spiresCanvas.clientHeight;
if (!W || !H) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
spiresCanvas.width = Math.round(W * dpr);
spiresCanvas.height = Math.round(H * dpr);
const g = spiresCanvas.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, W, H);
const horizon = H * 0.7;
const dark = g.createLinearGradient(0, 0, 0, horizon);
dark.addColorStop(0, 'rgba(5,3,2,.62)');
dark.addColorStop(0.72, 'rgba(5,3,2,.2)');
dark.addColorStop(1, 'rgba(5,3,2,0)');
g.fillStyle = dark;
g.fillRect(0, 0, W, horizon + 2);
const haze = g.createLinearGradient(0, horizon - H * 0.55, 0, horizon + H * 0.14);
haze.addColorStop(0, 'rgba(232,64,42,0)');
haze.addColorStop(1, 'rgba(232,64,42,.1)');
g.fillStyle = haze;
g.fillRect(0, horizon - H * 0.55, W, H);
const layers = [
{ hMin: 0.14, hMax: 0.42, alpha: 0.42, wid: 0.8, seed: 41, spread: 0.045 },
{ hMin: 0.3, hMax: 1.0, alpha: 1, wid: 1, seed: 94, spread: 0.05 }
];
const clusters = [0.045, 0.15, 0.27, 0.335, 0.665, 0.73, 0.85, 0.955];
layers.forEach(function (L) {
const rnd = mulberry32(L.seed);
const picked = [];
clusters.forEach(function (cx) {
const n = 1 + Math.floor(rnd() * 2.6);
for (let i = 0; i < n; i++) {
const t = cx + (rnd() - 0.5) * L.spread * 2;
if (t < 0.02 || t > 0.98) continue;
if (t > 0.39 && t < 0.61) continue;
const edge = Math.min(1, Math.abs(t - 0.5) * 2.1);
picked.push({
x: t * W,
h: H * (L.hMin + rnd() * (L.hMax - L.hMin)) * (0.55 + 0.6 * edge),
w: (7 + rnd() * 16) * L.wid * (0.75 + 0.5 * edge),
a: L.alpha * (0.75 + rnd() * 0.25)
});
}
});
picked.sort(function (a, b) { return a.h - b.h; });
picked.forEach(function (p) { spire(g, p.x, horizon + 2, p.h, p.w, rnd, p.a); });
});
}
window.ZHILA_TERRAIN = { render, drawSpires, measure, P };
})();