feat: renderiza Via Láctea como textura procedural contínua

This commit is contained in:
vini 2026-07-19 13:15:06 -03:00
parent 96486b447c
commit d8d99903d6

View file

@ -1264,17 +1264,6 @@ 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;
};
}
/* Transposta da matriz equatorial->galáctica (Hipparcos, J2000) */
const G2E = [
[-0.0548755604, 0.4941094279, -0.8676661490],
@ -1282,94 +1271,151 @@ const G2E = [
[-0.4838350155, 0.7469822445, 0.4559837762],
];
/* ── Via Láctea estilizada: fita suave + poucas nuvens grandes ──
Âncora astronômica real (equador galáctico -> gira com o céu de verdade),
mas pintura surrealista: sprites grandes e macios, sem ruído de pontos */
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)];
}
const mwBand = []; // fita + nuvens (aditivo) {ra, dec, size, alpha, ci}
const mwShadow = []; // Grande Fenda (recorte) {ra, dec, size, alpha}
(function buildMilkyWay() {
/* fita contínua: sprites grandes bem sobrepostos ao longo de b~0 */
for (let l = 0; l < 360; l += 2.5) {
const dl = Math.min(l, 360 - l);
const central = Math.exp(-((dl / 80) ** 2));
const wobble = 1.2 * Math.sin(l * DEG * 3.1) + 0.8 * Math.sin(l * DEG * 1.3 + 2);
const eq = galToEq(l, wobble * 0.6);
mwBand.push({
ra: eq.ra, dec: eq.dec,
size: 170 + 170 * central,
alpha: 0.05 + 0.13 * central,
ci: mwColor(dl),
});
}
/* bojo + poucas nuvens grandes em regiões reais (Carina, Cygnus...) */
const clouds = [
{ l: 0, b: 0, s: 420, a: 0.20 }, // bojo central (Sagitário)
{ l: 8, b: -1.5, s: 300, a: 0.15 },
{ l: 352, b: 1.5, s: 300, a: 0.15 },
{ l: 25, b: 1, s: 240, a: 0.11 }, // Scutum
{ l: 330, b: -1, s: 260, a: 0.12 },
{ l: 55, b: 0.5, s: 210, a: 0.09 },
{ l: 285, b: -0.5, s: 230, a: 0.10 }, // Carina
{ l: 80, b: 1, s: 190, a: 0.08 }, // Cygnus
];
for (const c of clouds) {
const eq = galToEq(c.l, c.b);
mwBand.push({ ra: eq.ra, dec: eq.dec, size: c.s, alpha: c.a,
ci: mwColor(Math.min(c.l, 360 - c.l)) });
}
/* Grande Fenda: sombras grandes e macias serpenteando sobre a fita */
for (let l = -58; l <= 42; l += 6) {
const eq = galToEq((l + 360) % 360, -0.6 + 1.6 * Math.sin(l * 0.05 + 0.8));
mwShadow.push({
ra: eq.ra, dec: eq.dec,
size: 150 + 90 * Math.abs(Math.sin(l * 0.11 + 2)),
alpha: 0.45 + 0.30 * Math.abs(Math.sin(l * 0.07)),
});
}
/* Saco de Carvão — mancha escura junto ao Cruzeiro do Sul */
const cs = galToEq(303, -1.5);
mwShadow.push({ ra: cs.ra, dec: cs.dec, size: 170, alpha: 0.8 });
})();
/* Sprites macios (quente / neutro / frio) + sprite de sombra p/ recorte */
function makeMwSprite(r, g, b) {
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;
}
const mwSprites = [makeMwSprite(255, 226, 188), // quente (núcleo)
makeMwSprite(236, 224, 214), // neutro
makeMwSprite(188, 214, 242)]; // frio (anticentro)
const mwShadowSprite = makeMwSprite(255, 255, 255); // recorte via destination-out
/* camada própria da fita — a fenda é recortada antes de compor no céu */
/* ── 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.25;
const mwLayer = document.createElement('canvas');
function spriteFor(ci) { return mwSprites[ci[2] > ci[0] ? 2 : (ci[0] > 240 ? 0 : 1)]; }
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;
}
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 wrap = n => ((n % periodX) + periodX) % periodX;
const a = mwHash(wrap(fx), fy);
const b = mwHash(wrap(fx + 1), fy);
const c = mwHash(wrap(fx), fy + 1);
const d = mwHash(wrap(fx + 1), 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 [4, 8, 16, 32]) {
value += amp * mwValueNoise(l / 360 * period, (b + 90) / 360 * period, period);
norm += amp;
amp *= 0.52;
}
return value / norm;
}
function mwAngularDistance(l, target) {
const d = Math.abs(l - target);
return Math.min(d, 360 - d);
}
/* 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 width = 4.2 + 5.2 * core;
const cloud = mwFbm(l, b);
const fine = mwFbm((l * 2) % 360, b * 2.2);
let density = Math.exp(-0.5 * (b / width) ** 2) *
(0.34 + 1.05 * cloud ** 1.7);
density += 0.16 * Math.exp(-0.5 * (b / (width * 2.6)) ** 2) *
(0.45 + 0.55 * cloud);
density += 1.05 * Math.exp(-0.5 * ((dl / 19) ** 2 + (b / 11) ** 2)) *
(0.65 + 0.45 * cloud);
/* 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.7 + 1.45 * Math.sin(lr * 2.1 + 0.5) + 0.45 * Math.sin(lr * 7.0);
const riftB = -2.3 + 0.75 * Math.sin(lr * 1.4 + 2.2);
const darkA = Math.exp(-0.5 * ((b - riftA) / (1.0 + 0.7 * fine)) ** 2) *
(0.58 + 0.28 * fine);
const darkB = Math.exp(-0.5 * ((b - riftB) / 1.15) ** 2) * 0.48;
density *= 1 - Math.min(0.90, (darkA + darkB) * riftReach);
/* 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.84 * coal;
return Math.max(0, density * (0.24 + 0.76 * Math.exp(-((dl / 105) ** 2))));
}
function mwRgb(dl) {
const t = Math.min(1, dl / 145);
const warm = [255, 220, 181], neutral = [222, 226, 235], cool = [177, 207, 244];
const a = t < 0.5 ? warm : neutral;
const z = t < 0.5 ? neutral : cool;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return [a[0] + (z[0] - a[0]) * u,
a[1] + (z[1] - a[1]) * u,
a[2] + (z[2] - a[2]) * u];
}
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);
const alpha = Math.min(0.26, density * 0.145) * ext * glare;
if (alpha < 0.002) continue;
const rgb = mwRgb(Math.min(l, 360 - l));
pixels[offset] = rgb[0];
pixels[offset + 1] = rgb[1];
pixels[offset + 2] = rgb[2];
pixels[offset + 3] = alpha * 255;
}
}
mctx.putImageData(image, 0, 0);
}
/* ── 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)
@ -1390,34 +1436,10 @@ function projectSky() {
!document.body.classList.contains('chuva');
twinkList = [];
/* fita da Via Láctea na camada própria, fenda recortada, depois composta */
const sScale = H / 1080;
mwLayer.width = W;
mwLayer.height = H;
const mctx = mwLayer.getContext('2d');
mctx.globalCompositeOperation = 'lighter';
for (const p of mwBand) {
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.004) continue;
const s = p.size * sScale;
mctx.globalAlpha = a;
mctx.drawImage(spriteFor(p.ci), sc.x - s / 2, sc.y - s / 2, s, s);
}
mctx.globalCompositeOperation = 'destination-out';
for (const p of mwShadow) {
const aa = altAz(p.ra, p.dec, lst);
const sc = skyToScreen(aa.alt, aa.az, W, horizonPx);
if (!sc) continue;
const s = p.size * sScale;
mctx.globalAlpha = p.alpha;
mctx.drawImage(mwShadowSprite, sc.x - s / 2, sc.y - s / 2, s, s);
}
mctx.globalAlpha = 1;
mctx.globalCompositeOperation = 'source-over';
bctx.drawImage(mwLayer, 0, 0);
renderMilkyWay(W, H, horizonPx, lst, glareOn);
bctx.imageSmoothingEnabled = true;
bctx.imageSmoothingQuality = 'high';
bctx.drawImage(mwLayer, 0, 0, W, horizonPx);
for (let i = 0; i < STAR_DATA.length; i++) {
const [ra, dec, mag, ci] = STAR_DATA[i];