400 lines
13 KiB
JavaScript
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 };
|
|
})();
|