feat: Via Láctea real — mapa all-sky do Gaia amostrado por pixel

Substitui a simulação procedural pelo mapa "Gaia's sky in colour"
(ESA/Gaia/DPAC, CC BY-SA 3.0 IGO): tools/build_skymap.py baixa o
original 8000x4000 em projeção Hammer, reprojeta para plate carrée e
gera milkyway.jpg (3072x1536). O dancer.html amostra a textura por
pixel em (l,b) galácticas reais — grade pré-computada por resolução/
latitude, com só a rotação sideral variando por passo (matriz E2G·Rz
combinada). Nível de preto remove o véu de fundo do mapa; extinção e
clarão aplicados por pixel. Remove mwGlow/mwGrain/poeira procedural.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HtPHsTnDcLVSCiF5FGHZj
This commit is contained in:
vini 2026-07-19 11:23:32 -03:00
parent 2014dcf9b7
commit c4c9574946
4 changed files with 168 additions and 171 deletions

View file

@ -1264,161 +1264,99 @@ function glareFactor(x, y, W, H, on) {
return Math.max(0, Math.min(1, (d - 150) / 240));
}
/* ── Via Láctea: partículas geradas em coords galácticas (RNG com semente,
estável entre reloads) e convertidas p/ equatorial J2000 uma única vez ── */
function mulberry32(seed) {
return function () {
seed |= 0; 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;
};
}
/* ── Via Láctea REAL: mapa all-sky do Gaia ("Gaia's sky in colour",
ESA/Gaia/DPAC, CC BY-SA 3.0 IGO — reprojetado por tools/build_skymap.py).
Cada pixel do céu é amostrado em (l,b) galácticas reais via tempo sideral:
é a banda fotografada, nascendo/girando/se pondo com o céu de verdade ── */
const MW_INTENSITY = 0.62; // ganho global da banda
const MW_BLACK = 52; // nível de preto (remove o véu cinza do mapa)
const MW_SCALE = 4; // amostra a 1/4 da resolução (difuso -> upscale ok)
/* 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],
];
/* Visual de astrofoto de longa exposição: grão estelar fino + brilho difuso
com gradiente térmico (núcleo âmbar -> anticentro azul) + faixas de poeira */
function galToEq(l, b) {
const cb = Math.cos(b * DEG);
const gx = cb * Math.cos(l * DEG), gy = cb * Math.sin(l * DEG), gz = Math.sin(b * DEG);
return {
ra: (Math.atan2(
G2E[1][0] * gx + G2E[1][1] * gy + G2E[1][2] * gz,
G2E[0][0] * gx + G2E[0][1] * gy + G2E[0][2] * gz) / DEG + 360) % 360,
dec: Math.asin(G2E[2][0] * gx + G2E[2][1] * gy + G2E[2][2] * gz) / DEG,
};
}
/* Cor por distância angular ao centro galáctico: creme-âmbar -> neutro -> azul */
function mwColor(dl) {
const t = Math.min(1, dl / 140);
const mix = (a, b2, c) => Math.round(t < 0.5 ? a + (b2 - a) * t * 2 : b2 + (c - b2) * (t - 0.5) * 2);
return [mix(255, 236, 186), mix(226, 224, 205), mix(188, 214, 242)];
}
/* Nuvens escuras discretas em posições reais aproximadas
(Saco de Carvão junto ao Cruzeiro, região do Cachimbo em Ofiúco...) */
const MW_DARKS = [
{ l: 303, b: -1.5, rl: 3.5, rb: 3.0, s: 0.85 }, // Saco de Carvão
{ l: 357, b: 4.5, rl: 5.0, rb: 2.2, s: 0.80 }, // Cachimbo (Ofiúco)
{ l: 31, b: -1.0, rl: 4.0, rb: 1.8, s: 0.75 },
{ l: 16, b: 2.5, rl: 3.0, rb: 1.6, s: 0.70 },
{ l: 344, b: -2.5, rl: 3.2, rb: 1.8, s: 0.70 },
];
/* Contribuição de uma faixa de poeira com borda suave (0 = fora, s = centro) */
function laneDark(b, center, halfw, strength) {
const d = Math.abs(b - center) / Math.max(halfw, 0.3);
return d >= 1 ? 0 : strength * (1 - d * d) ** 2;
}
/* Poeira: 1 = céu livre, ->0.08 = poeira densa. Caminhos e larguras
irregulares (senos incomensuráveis) + escuridão em manchas, não uniforme */
function mwDust(l, b, dl) {
if (dl > 105) return 1;
const fade = Math.min(1, (105 - dl) / 35); // fenda some longe do centro
const x = l * DEG;
const wob1 = Math.sin(x * 2.2) + 0.6 * Math.sin(x * 4.9 + 1.7) +
0.35 * Math.sin(x * 9.3 + 0.6);
const wob2 = Math.sin(x * 1.4 + 1.7) + 0.6 * Math.sin(x * 3.7 + 4.2);
const patch1 = 0.62 + 0.38 * Math.sin(x * 1.1 + 2.9) * Math.sin(x * 2.6 + 0.4);
const patch2 = 0.55 + 0.45 * Math.sin(x * 0.9 + 5.1);
let dark = laneDark(b, 0.9 * wob1, 2.1 + 0.9 * Math.sin(x * 3.3 + 2.2),
0.9 * patch1);
dark = Math.max(dark, laneDark(b, -2.4 + 0.7 * wob2,
1.4 + 0.7 * Math.sin(x * 2.8 + 1.1),
0.8 * patch2));
for (const d of MW_DARKS) {
const dlc = Math.min(Math.abs(l - d.l), 360 - Math.abs(l - d.l));
const dd = (dlc / d.rl) ** 2 + ((b - d.b) / d.rb) ** 2;
if (dd < 1) dark = Math.max(dark, d.s * (1 - dd));
}
return 1 - Math.min(0.92, dark * fade);
}
/* Nuvens estelares (Scutum, Sagitário, Carina...) — adensamentos fixos na banda */
const MW_CLOUDS = [];
const mwGlow = []; // blobs difusos {ra,dec,size,alpha,ci}
const mwGrain = []; // grão estelar fino {ra,dec,alpha,rgb}
(function buildMilkyWay() {
const rand = mulberry32(0x5EEDED);
const gauss = () => {
const u = Math.max(rand(), 1e-9), v = rand();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
for (let i = 0; i < 14; i++) {
const l = (rand() * 160 - 80 + 360) % 360; // nuvens no lado do centro
MW_CLOUDS.push({ l, b: gauss() * 2.5, r: 2 + rand() * 4 });
}
const cloudBoost = (l, b) => {
let boost = 0;
for (const c of MW_CLOUDS) {
const dlc = Math.min(Math.abs(l - c.l), 360 - Math.abs(l - c.l));
const d2 = (dlc / c.r) ** 2 + ((b - c.b) / c.r) ** 2;
if (d2 < 1) boost = Math.max(boost, 1 - d2);
}
return boost;
};
let guard = 0;
while (mwGlow.length < 3600 && guard++ < 300000) {
const l = rand() * 360;
const dl = Math.min(l, 360 - l);
const central = Math.exp(-((dl / 75) ** 2));
if (rand() > 0.28 + 0.72 * central) continue;
/* bojo central: banda mais gorda perto do núcleo */
const b = gauss() * (5 + 3 * central + 4 * Math.exp(-((dl / 22) ** 2)));
const dust = mwDust(l, b, dl);
const boost = cloudBoost(l, b);
const alpha = (0.35 + 0.65 * central) * dust * (1 + 1.2 * boost) *
(0.028 + rand() * 0.05);
if (alpha < 0.005) continue;
const eq = galToEq(l, b);
mwGlow.push({ ra: eq.ra, dec: eq.dec, size: 14 + rand() * 30,
alpha, ci: mwColor(dl) });
}
guard = 0;
while (mwGrain.length < 9500 && guard++ < 500000) {
const l = rand() * 360;
const dl = Math.min(l, 360 - l);
const central = Math.exp(-((dl / 80) ** 2));
if (rand() > 0.22 + 0.78 * central) continue;
const b = gauss() * (4.5 + 2.5 * central + 3.5 * Math.exp(-((dl / 22) ** 2)));
const dust = mwDust(l, b, dl);
if (rand() > dust + 0.06) continue; // poeira esconde o grão
const boost = cloudBoost(l, b);
if (boost < 0.3 && rand() < 0.25 * central) continue;
const eq = galToEq(l, b);
mwGrain.push({ ra: eq.ra, dec: eq.dec,
alpha: 0.10 + rand() * 0.30 + 0.2 * boost,
ci: mwColor(dl) });
}
})();
/* Sprites do brilho difuso: quente / neutro / frio (escolhido por cor) */
function makeMwSprite(r, g, b) {
const mwTex = { data: null, w: 0, h: 0 };
const mwImg = new Image();
mwImg.onload = () => {
const c = document.createElement('canvas');
c.width = c.height = 64;
const s = c.getContext('2d');
const grad = s.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, `rgba(${r},${g},${b},1)`);
grad.addColorStop(0.5, `rgba(${r},${g},${b},0.4)`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
s.fillStyle = grad;
s.fillRect(0, 0, 64, 64);
return c;
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(); // primeira renderização já com a textura
};
mwImg.src = 'milkyway.jpg';
/* Grade pré-computada por pixel (a 1/MW_SCALE): vetor equatorial no frame
LST=0 + extinção + clarão. Por passo só muda a rotação sideral (Rz) */
const mwCanvas = document.createElement('canvas');
let mwGrid = null;
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_SCALE), gh = Math.ceil(horizonPx / MW_SCALE);
const n = gw * gh;
const wx = new Float32Array(n), wy = new Float32Array(n), wz = new Float32Array(n);
const ext = new Float32Array(n), glr = 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_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_SCALE;
const az = (VIEW_AZ + (sx / W - 0.5) * FOV_AZ) * DEG;
const xh = ca * Math.cos(az), yh = ca * Math.sin(az);
wx[i] = -sl * xh + cl * sa; // cosδ·cosH
wy[i] = -yh; // cosδ·sinH
wz[i] = cl * xh + sl * sa; // sinδ
ext[i] = extinction(alt);
glr[i] = glareFactor(sx, sy, W, H, true);
}
}
mwCanvas.width = gw; mwCanvas.height = gh;
mwGrid = { w: gw, h: gh, wx, wy, wz, ext, glare: glr,
img: mwCanvas.getContext('2d').createImageData(gw, gh) };
}
/* Amostra o mapa galáctico para o LST dado e pinta o mwCanvas */
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);
/* galáctico = E2G · Rz\'(LST), com RA = LST H */
const m00 = E2G[0][0] * cL + E2G[0][1] * sL, m01 = E2G[0][0] * sL - E2G[0][1] * cL, m02 = E2G[0][2];
const m10 = E2G[1][0] * cL + E2G[1][1] * sL, m11 = E2G[1][0] * sL - E2G[1][1] * cL, m12 = E2G[1][2];
const m20 = E2G[2][0] * cL + E2G[2][1] * sL, m21 = E2G[2][0] * sL - E2G[2][1] * cL, m22 = E2G[2][2];
const tw = mwTex.w, thh = mwTex.h, tex = mwTex.data, px = img.data;
const INV2PI = 1 / (2 * Math.PI), INVPI = 1 / Math.PI, HPI = Math.PI / 2;
for (let i = 0, j = 0; i < wx.length; i++, j += 4) {
const gx2 = m00 * wx[i] + m01 * wy[i] + m02 * wz[i];
const gy2 = m10 * wx[i] + m11 * wy[i] + m12 * wz[i];
const gz2 = m20 * wx[i] + m21 * wy[i] + m22 * wz[i];
const l = Math.atan2(gy2, gx2); // rad, + p/ esquerda no mapa
const b = Math.asin(Math.max(-1, Math.min(1, gz2)));
let tx = ((Math.PI - l) * INV2PI * tw) | 0;
let ty = ((HPI - b) * INVPI * thh) | 0;
if (tx >= tw) tx = tw - 1;
if (ty >= thh) ty = thh - 1;
const k = (ty * tw + tx) * 4;
const f = ext[i] * (glareOn ? glare[i] : 1) * MW_INTENSITY;
px[j] = Math.max(0, tex[k] - MW_BLACK) * f;
px[j + 1] = Math.max(0, tex[k + 1] - MW_BLACK) * f;
px[j + 2] = Math.max(0, tex[k + 2] - MW_BLACK) * f;
px[j + 3] = 255;
}
mwCanvas.getContext('2d').putImageData(img, 0, 0);
return true;
}
const mwSprites = [makeMwSprite(255, 226, 188), // quente (núcleo)
makeMwSprite(236, 224, 214), // neutro
makeMwSprite(188, 214, 242)]; // frio (anticentro)
function spriteFor(ci) { return mwSprites[ci[2] > ci[0] ? 2 : (ci[0] > 240 ? 0 : 1)]; }
/* ── Passo de projeção: recalcula posições de tela (a cada 30s o céu gira
~0.125°, imperceptível). Camada estática (Via Láctea + estrelas fracas)
@ -1439,27 +1377,12 @@ function projectSky() {
!document.body.classList.contains('chuva');
twinkList = [];
bctx.globalCompositeOperation = 'lighter';
for (const p of mwGlow) {
const aa = altAz(p.ra, p.dec, lst);
const sc = skyToScreen(aa.alt, aa.az, W, horizonPx);
if (!sc) continue;
const a = p.alpha * extinction(aa.alt) * glareFactor(sc.x, sc.y, W, H, glareOn);
if (a < 0.002) continue;
bctx.globalAlpha = a;
bctx.drawImage(spriteFor(p.ci), sc.x - p.size / 2, sc.y - p.size / 2, p.size, p.size);
if (renderMilkyWay(lst, glareOn)) {
bctx.globalCompositeOperation = 'lighter';
bctx.imageSmoothingEnabled = true;
bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
bctx.globalCompositeOperation = 'source-over';
}
bctx.globalAlpha = 1;
for (const p of mwGrain) {
const aa = altAz(p.ra, p.dec, lst);
const sc = skyToScreen(aa.alt, aa.az, W, horizonPx);
if (!sc) continue;
const a = p.alpha * extinction(aa.alt) * glareFactor(sc.x, sc.y, W, H, glareOn);
if (a < 0.01) continue;
bctx.fillStyle = `rgba(${p.ci[0]},${p.ci[1]},${p.ci[2]},${a.toFixed(3)})`;
bctx.fillRect(sc.x - 0.5, sc.y - 0.5, 1, 1);
}
bctx.globalCompositeOperation = 'source-over';
for (let i = 0; i < STAR_DATA.length; i++) {
const [ra, dec, mag, ci] = STAR_DATA[i];
@ -1498,12 +1421,14 @@ function setObserverLocation(lat, lon) {
if (lat === obsLat && lon === obsLon) return;
obsLat = lat;
obsLon = lon;
buildMwGrid(); // grade depende da latitude
projectSky();
}
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
buildMwGrid();
projectSky();
}