feat: projeta Via Láctea fotográfica em alta resolução

This commit is contained in:
vini 2026-07-19 13:33:25 -03:00
parent ea74b4f6cd
commit ec9814300a
3 changed files with 222 additions and 195 deletions

View file

@ -1264,195 +1264,133 @@ function glareFactor(x, y, W, H, on) {
return Math.max(0, Math.min(1, (d - 150) / 240));
}
/* Transposta da matriz equatorial->galáctica (Hipparcos, J2000) */
const G2E = [
[-0.0548755604, 0.4941094279, -0.8676661490],
[-0.8734370902, -0.4448296300, -0.1980763734],
[-0.4838350155, 0.7469822445, 0.4559837762],
/* Matriz equatorial -> galáctica (Hipparcos, J2000) */
const E2G = [
[-0.0548755604, -0.8734370902, -0.4838350155],
[ 0.4941094279, -0.4448296300, 0.7469822445],
[-0.8676661490, -0.1980763734, 0.4559837762],
];
/* ── Via Láctea procedural contínua ──
A textura é calculada no espaço galáctico em uma camada de baixa resolução.
Assim a faixa, o bojo e a poeira acompanham a projeção real sem denunciar
sprites circulares ou custar um passe em resolução cheia. */
const MW_RENDER_SCALE = 0.50;
const MW_NOISE_PERIODS = [5, 10, 20, 40, 80];
const mwLayer = document.createElement('canvas');
/* ── Via Láctea fotográfica em alta resolução ──
"Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO), reprojetada
para plate carrée por tools/build_skymap.py. A geometria é real; apenas
exposição, contraste e saturação são estilizados para o wallpaper. */
const MW_BLACK = 46;
const MW_EXPOSURE = 1.16;
const MW_SAMPLE_SCALE = 1;
const mwTex = { data: null, w: 0, h: 0 };
const mwCanvas = document.createElement('canvas');
let mwGrid = null;
function mwHash(ix, iy) {
let h = Math.imul(ix, 374761393) + Math.imul(iy, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
}
const mwImg = new Image();
mwImg.onload = () => {
const c = document.createElement('canvas');
c.width = mwImg.width;
c.height = mwImg.height;
const s = c.getContext('2d', { willReadFrequently: true });
s.drawImage(mwImg, 0, 0);
mwTex.data = s.getImageData(0, 0, c.width, c.height).data;
mwTex.w = c.width;
mwTex.h = c.height;
projectSky();
};
mwImg.onerror = () => console.error('[skeledance] falha ao carregar milkyway.jpg');
mwImg.src = 'milkyway.jpg';
function mwValueNoise(x, y, periodX) {
const fx = Math.floor(x), fy = Math.floor(y);
const tx0 = x - fx, ty0 = y - fy;
const tx = tx0 * tx0 * (3 - 2 * tx0);
const ty = ty0 * ty0 * (3 - 2 * ty0);
const x0 = ((fx % periodX) + periodX) % periodX;
const x1 = (x0 + 1) % periodX;
const a = mwHash(x0, fy);
const b = mwHash(x1, fy);
const c = mwHash(x0, fy + 1);
const d = mwHash(x1, fy + 1);
const top = a + (b - a) * tx;
const bot = c + (d - c) * tx;
return top + (bot - top) * ty;
}
/* Ruído periódico em longitude: não deixa emenda em l=0°/360°. */
function mwFbm(l, b) {
let value = 0, norm = 0, amp = 1;
for (const period of MW_NOISE_PERIODS) {
value += amp * mwValueNoise(l / 360 * period, (b + 90) / 360 * period, period);
norm += amp;
amp *= 0.50;
}
return value / norm;
}
function mwFineNoise(l, b) {
const n1 = mwValueNoise(l / 360 * 160, (b + 90) / 360 * 160, 160);
const n2 = mwValueNoise(l / 360 * 320, (b + 90) / 360 * 320, 320);
return n1 * 0.68 + n2 * 0.32;
}
function mwAngularDistance(l, target) {
const d = Math.abs(l - target);
return Math.min(d, 360 - d);
}
function mwCloud(l, b, cl, cb, wl, wb, strength) {
const x = mwAngularDistance(l, cl) / wl;
const y = (b - cb) / wb;
return strength * Math.exp(-0.5 * (x * x + y * y));
}
/* Densidade luminosa em coordenadas galácticas. O detalhe vem do ruído,
enquanto as formas grandes continuam astronomicamente reconhecíveis. */
function mwDensity(l, b) {
const dl = Math.min(l, 360 - l);
const core = Math.exp(-((dl / 72) ** 2));
const cloud = mwFbm(l, b);
const fine = mwFineNoise(l, b);
const warpedB = b - (cloud - 0.5) * (2.2 + 1.8 * core);
const width = 3.7 + 5.8 * core + 1.3 * cloud;
const plane = Math.exp(-0.5 * (warpedB / width) ** 2);
/* Luz integrada: halo largo, nuvens irregulares e grão fino de estrelas. */
let density = plane * (0.20 + 1.45 * cloud ** 2.1);
density += 0.12 * Math.exp(-0.5 * (warpedB / (width * 2.9)) ** 2) *
(0.35 + 0.65 * cloud);
density += plane * Math.max(0, fine - 0.46) ** 2 * 3.8;
/* Grandes nuvens estelares reais, exageradas como numa longa exposição. */
density += mwCloud(l, b, 0, 0.0, 20, 11, 1.55); // bojo de Sagitário
density += mwCloud(l, b, 25, 0.5, 10, 4, 0.62); // Scutum
density += mwCloud(l, b, 330, -0.7, 11, 5, 0.52);
density += mwCloud(l, b, 285, -0.4, 13, 5, 0.55); // Carina
density += mwCloud(l, b, 80, 0.8, 12, 4, 0.38); // Cygnus
/* Grande Fenda: duas trilhas estreitas, deformadas e com força irregular. */
const riftReach = 1 - Math.max(0, Math.min(1, (dl - 70) / 55));
const lr = l * DEG;
const riftA = 0.6 + 1.55 * Math.sin(lr * 2.1 + 0.5) + 0.55 * Math.sin(lr * 7.0);
const riftB = -2.5 + 0.85 * Math.sin(lr * 1.4 + 2.2);
const darkA = Math.exp(-0.5 * ((b - riftA) / (0.85 + 0.75 * fine)) ** 2) *
(0.58 + 0.22 * fine);
const darkB = Math.exp(-0.5 * ((b - riftB) / 1.10) ** 2) * 0.44;
/* Poeira reduz contraste, mas nunca perfura a faixa luminosa. */
density *= 1 - Math.min(0.38, (darkA + darkB) * riftReach);
density += plane * (0.045 + 0.075 * fine);
/* Saco de Carvão junto ao Cruzeiro do Sul. */
const coal = Math.exp(-0.5 * ((mwAngularDistance(l, 303) / 3.8) ** 2 +
((b + 1.5) / 2.8) ** 2));
density *= 1 - 0.38 * coal;
return Math.max(0, density * (0.36 + 0.64 * Math.exp(-((dl / 110) ** 2))));
}
function mwWriteRgb(pixels, offset, l, b) {
const dl = Math.min(l, 360 - l);
const t = Math.min(1, dl / 150);
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
let r, g, bl;
if (t < 0.5) {
r = 255 + (210 - 255) * u;
g = 178 + (181 - 178) * u;
bl = 112 + (246 - 112) * u;
} else {
r = 210 + (105 - 210) * u;
g = 181 + (197 - 181) * u;
bl = 246 + (255 - 246) * u;
}
/* Pequena separação cromática transversal: âmbar/magenta de um lado,
azul-ciano do outro, como numa astrofoto com exposição puxada. */
const tint = 0.5 + 0.5 * Math.sin(l * DEG * 1.7 + b * DEG * 8.0 + 1.1);
const edge = Math.min(1, Math.abs(b) / 14);
r += 24 * (1 - tint) * (0.35 + edge);
g -= 12 * (1 - tint) * edge;
bl += 18 * tint * (0.35 + edge);
pixels[offset] = Math.max(0, Math.min(255, r));
pixels[offset + 1] = Math.max(0, Math.min(255, g));
pixels[offset + 2] = Math.max(0, Math.min(255, bl));
}
function renderMilkyWay(W, H, horizonPx, lst, glareOn) {
const rw = Math.max(1, Math.ceil(W * MW_RENDER_SCALE));
const rh = Math.max(1, Math.ceil(horizonPx * MW_RENDER_SCALE));
if (mwLayer.width !== rw || mwLayer.height !== rh) {
mwLayer.width = rw;
mwLayer.height = rh;
}
const mctx = mwLayer.getContext('2d');
const image = mctx.createImageData(rw, rh);
const pixels = image.data;
const sl = Math.sin(obsLat * DEG), cl = Math.cos(obsLat * DEG);
const lstRad = lst * DEG, cosLst = Math.cos(lstRad), sinLst = Math.sin(lstRad);
const azSin = new Float32Array(rw), azCos = new Float32Array(rw);
for (let x = 0; x < rw; x++) {
const az = (VIEW_AZ + ((x + 0.5) / rw - 0.5) * FOV_AZ) * DEG;
azSin[x] = Math.sin(az);
azCos[x] = Math.cos(az);
}
let offset = 0;
for (let y = 0; y < rh; y++) {
const screenY = (y + 0.5) / rh * horizonPx;
const alt = ALT_TOP * (1 - screenY / horizonPx);
const sa = Math.sin(alt * DEG), ca = Math.cos(alt * DEG);
const ext = extinction(alt);
for (let x = 0; x < rw; x++, offset += 4) {
const north = ca * azCos[x], east = ca * azSin[x];
const hourX = -north * sl + sa * cl;
const ez = north * cl + sa * sl;
const ex = cosLst * hourX - sinLst * east;
const ey = sinLst * hourX + cosLst * east;
/* E2G = transposta de G2E. */
const gx = G2E[0][0] * ex + G2E[1][0] * ey + G2E[2][0] * ez;
const gy = G2E[0][1] * ex + G2E[1][1] * ey + G2E[2][1] * ez;
const gz = G2E[0][2] * ex + G2E[1][2] * ey + G2E[2][2] * ez;
const l = (Math.atan2(gy, gx) / DEG + 360) % 360;
const b = Math.asin(Math.max(-1, Math.min(1, gz))) / DEG;
if (Math.abs(b) > 35) continue;
const density = mwDensity(l, b);
if (density < 0.012) continue;
const screenX = (x + 0.5) / rw * W;
const glare = glareFactor(screenX, screenY, W, H, glareOn);
/* Curva de exposição comprime o núcleo sem apagar estruturas tênues. */
const exposure = 1 - Math.exp(-density * 0.72);
const alpha = Math.min(0.46, exposure * 0.42) * ext * glare;
if (alpha < 0.002) continue;
mwWriteRgb(pixels, offset, l, b);
pixels[offset + 3] = alpha * 255;
/* Vetores do viewport em LST=0. O passo de 30 s só aplica uma rotação e
amostra a fotografia; trigonometria e efeitos de tela ficam em cache. */
function buildMwGrid() {
const W = canvas.width, H = canvas.height;
if (!W || !H) { mwGrid = null; return; }
const horizonPx = H * HORIZON_F;
const gw = Math.ceil(W / MW_SAMPLE_SCALE);
const gh = Math.ceil(horizonPx / MW_SAMPLE_SCALE);
const n = gw * gh;
const wx = new Float32Array(n), wy = new Float32Array(n), wz = new Float32Array(n);
const ext = new Float32Array(n), glare = new Float32Array(n);
const cl = Math.cos(obsLat * DEG), sl = Math.sin(obsLat * DEG);
let i = 0;
for (let gy = 0; gy < gh; gy++) {
const sy = (gy + 0.5) * MW_SAMPLE_SCALE;
const alt = (1 - sy / horizonPx) * ALT_TOP;
const ca = Math.cos(alt * DEG), sa = Math.sin(alt * DEG);
for (let gx = 0; gx < gw; gx++, i++) {
const sx = (gx + 0.5) * MW_SAMPLE_SCALE;
const az = (VIEW_AZ + (sx / W - 0.5) * FOV_AZ) * DEG;
const north = ca * Math.cos(az), east = ca * Math.sin(az);
wx[i] = -sl * north + cl * sa;
wy[i] = -east;
wz[i] = cl * north + sl * sa;
ext[i] = extinction(alt);
glare[i] = glareFactor(sx, sy, W, H, true);
}
}
mctx.putImageData(image, 0, 0);
mwCanvas.width = gw;
mwCanvas.height = gh;
mwGrid = { w: gw, h: gh, wx, wy, wz, ext, glare,
img: mwCanvas.getContext('2d').createImageData(gw, gh) };
}
function renderMilkyWay(lstDeg, glareOn) {
if (!mwGrid || !mwTex.data) return false;
const { wx, wy, wz, ext, glare, img } = mwGrid;
const cL = Math.cos(lstDeg * DEG), sL = Math.sin(lstDeg * DEG);
const m00 = E2G[0][0] * cL + E2G[0][1] * sL;
const m01 = E2G[0][0] * sL - E2G[0][1] * cL;
const m02 = E2G[0][2];
const m10 = E2G[1][0] * cL + E2G[1][1] * sL;
const m11 = E2G[1][0] * sL - E2G[1][1] * cL;
const m12 = E2G[1][2];
const m20 = E2G[2][0] * cL + E2G[2][1] * sL;
const m21 = E2G[2][0] * sL - E2G[2][1] * cL;
const m22 = E2G[2][2];
const tw = mwTex.w, th = mwTex.h, tex = mwTex.data, px = img.data;
const inv2pi = 1 / (2 * Math.PI), invpi = 1 / Math.PI;
for (let i = 0, j = 0; i < wx.length; i++, j += 4) {
const gx = m00 * wx[i] + m01 * wy[i] + m02 * wz[i];
const gy = m10 * wx[i] + m11 * wy[i] + m12 * wz[i];
const gz = m20 * wx[i] + m21 * wy[i] + m22 * wz[i];
const l = Math.atan2(gy, gx);
const b = Math.asin(Math.max(-1, Math.min(1, gz)));
const absB = Math.abs(b / DEG);
if (absB >= 42) { px[j + 3] = 0; continue; }
let tx = ((Math.PI - l) * inv2pi * tw) | 0;
let ty = ((Math.PI / 2 - b) * invpi * th) | 0;
if (tx >= tw) tx = tw - 1;
if (ty >= th) ty = th - 1;
const k = (ty * tw + tx) * 4;
const sr = tex[k], sg = tex[k + 1], sb = tex[k + 2];
const lum = sr * 0.2126 + sg * 0.7152 + sb * 0.0722;
let signal = (lum - MW_BLACK) / (255 - MW_BLACK);
if (signal <= 0) { px[j + 3] = 0; continue; }
signal = Math.pow(Math.min(1, signal), 0.72);
let mask = 1;
if (absB > 18) {
mask = (42 - absB) / 24;
mask = mask * mask * (3 - 2 * mask);
}
const alpha = Math.min(1, signal * MW_EXPOSURE) * ext[i] *
(glareOn ? glare[i] : 1) * mask;
if (alpha < 0.004) { px[j + 3] = 0; continue; }
/* Revelação digital: preto profundo, saturação cromática e sombras frias. */
let r = Math.max(0, sr - 34) * 1.58;
let g = Math.max(0, sg - 34) * 1.58;
let bl = Math.max(0, sb - 34) * 1.58;
const outLum = r * 0.2126 + g * 0.7152 + bl * 0.0722;
r = outLum + (r - outLum) * 1.48 + 10 * (1 - signal) + 16 * signal;
g = outLum + (g - outLum) * 1.48;
bl = outLum + (bl - outLum) * 1.48 + 24 * (1 - signal);
px[j] = Math.max(0, Math.min(255, r));
px[j + 1] = Math.max(0, Math.min(255, g));
px[j + 2] = Math.max(0, Math.min(255, bl));
px[j + 3] = alpha * 255;
}
mwCanvas.getContext('2d').putImageData(img, 0, 0);
return true;
}
/* ── Passo de projeção: recalcula posições de tela (a cada 30s o céu gira
@ -1474,18 +1412,19 @@ function projectSky() {
!document.body.classList.contains('chuva');
twinkList = [];
renderMilkyWay(W, H, horizonPx, lst, glareOn);
bctx.imageSmoothingEnabled = true;
bctx.imageSmoothingQuality = 'high';
bctx.save();
bctx.globalCompositeOperation = 'lighter';
bctx.globalAlpha = 0.40;
bctx.filter = `blur(${Math.max(10, H * 0.014)}px) saturate(180%)`;
bctx.drawImage(mwLayer, 0, 0, W, horizonPx);
bctx.globalAlpha = 0.96;
bctx.filter = 'contrast(122%) saturate(150%)';
bctx.drawImage(mwLayer, 0, 0, W, horizonPx);
bctx.restore();
if (renderMilkyWay(lst, glareOn)) {
bctx.imageSmoothingEnabled = true;
bctx.imageSmoothingQuality = 'high';
bctx.save();
bctx.globalCompositeOperation = 'lighter';
bctx.globalAlpha = 0.22;
bctx.filter = `blur(${Math.max(7, H * 0.009)}px) saturate(150%)`;
bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
bctx.globalAlpha = 0.94;
bctx.filter = 'contrast(116%) saturate(158%)';
bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
bctx.restore();
}
for (let i = 0; i < STAR_DATA.length; i++) {
const [ra, dec, mag, ci] = STAR_DATA[i];
@ -1524,12 +1463,14 @@ function setObserverLocation(lat, lon) {
if (lat === obsLat && lon === obsLon) return;
obsLat = lat;
obsLon = lon;
buildMwGrid();
projectSky();
}
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
buildMwGrid();
projectSky();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 MiB