feat: projeta Via Láctea fotográfica em alta resolução
This commit is contained in:
parent
ea74b4f6cd
commit
ec9814300a
3 changed files with 222 additions and 195 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue