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)); return Math.max(0, Math.min(1, (d - 150) / 240));
} }
/* Transposta da matriz equatorial->galáctica (Hipparcos, J2000) */ /* Matriz equatorial -> galáctica (Hipparcos, J2000) */
const G2E = [ const E2G = [
[-0.0548755604, 0.4941094279, -0.8676661490], [-0.0548755604, -0.8734370902, -0.4838350155],
[-0.8734370902, -0.4448296300, -0.1980763734], [ 0.4941094279, -0.4448296300, 0.7469822445],
[-0.4838350155, 0.7469822445, 0.4559837762], [-0.8676661490, -0.1980763734, 0.4559837762],
]; ];
/* ── Via Láctea procedural contínua ── /* ── Via Láctea fotográfica em alta resolução ──
A textura é calculada no espaço galáctico em uma camada de baixa resolução. "Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO), reprojetada
Assim a faixa, o bojo e a poeira acompanham a projeção real sem denunciar para plate carrée por tools/build_skymap.py. A geometria é real; apenas
sprites circulares ou custar um passe em resolução cheia. */ exposição, contraste e saturação são estilizados para o wallpaper. */
const MW_RENDER_SCALE = 0.50; const MW_BLACK = 46;
const MW_NOISE_PERIODS = [5, 10, 20, 40, 80]; const MW_EXPOSURE = 1.16;
const mwLayer = document.createElement('canvas'); 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) { const mwImg = new Image();
let h = Math.imul(ix, 374761393) + Math.imul(iy, 668265263); mwImg.onload = () => {
h = Math.imul(h ^ (h >>> 13), 1274126177); const c = document.createElement('canvas');
return ((h ^ (h >>> 16)) >>> 0) / 4294967295; 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) { /* Vetores do viewport em LST=0. O passo de 30 s só aplica uma rotação e
const fx = Math.floor(x), fy = Math.floor(y); amostra a fotografia; trigonometria e efeitos de tela ficam em cache. */
const tx0 = x - fx, ty0 = y - fy; function buildMwGrid() {
const tx = tx0 * tx0 * (3 - 2 * tx0); const W = canvas.width, H = canvas.height;
const ty = ty0 * ty0 * (3 - 2 * ty0); if (!W || !H) { mwGrid = null; return; }
const x0 = ((fx % periodX) + periodX) % periodX; const horizonPx = H * HORIZON_F;
const x1 = (x0 + 1) % periodX; const gw = Math.ceil(W / MW_SAMPLE_SCALE);
const a = mwHash(x0, fy); const gh = Math.ceil(horizonPx / MW_SAMPLE_SCALE);
const b = mwHash(x1, fy); const n = gw * gh;
const c = mwHash(x0, fy + 1); const wx = new Float32Array(n), wy = new Float32Array(n), wz = new Float32Array(n);
const d = mwHash(x1, fy + 1); const ext = new Float32Array(n), glare = new Float32Array(n);
const top = a + (b - a) * tx; const cl = Math.cos(obsLat * DEG), sl = Math.sin(obsLat * DEG);
const bot = c + (d - c) * tx; let i = 0;
return top + (bot - top) * ty; for (let gy = 0; gy < gh; gy++) {
} const sy = (gy + 0.5) * MW_SAMPLE_SCALE;
const alt = (1 - sy / horizonPx) * ALT_TOP;
/* Ruído periódico em longitude: não deixa emenda em l=0°/360°. */ const ca = Math.cos(alt * DEG), sa = Math.sin(alt * DEG);
function mwFbm(l, b) { for (let gx = 0; gx < gw; gx++, i++) {
let value = 0, norm = 0, amp = 1; const sx = (gx + 0.5) * MW_SAMPLE_SCALE;
for (const period of MW_NOISE_PERIODS) { const az = (VIEW_AZ + (sx / W - 0.5) * FOV_AZ) * DEG;
value += amp * mwValueNoise(l / 360 * period, (b + 90) / 360 * period, period); const north = ca * Math.cos(az), east = ca * Math.sin(az);
norm += amp; wx[i] = -sl * north + cl * sa;
amp *= 0.50; wy[i] = -east;
} wz[i] = cl * north + sl * sa;
return value / norm; ext[i] = extinction(alt);
} glare[i] = glareFactor(sx, sy, W, H, true);
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;
} }
} }
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 /* ── 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'); !document.body.classList.contains('chuva');
twinkList = []; twinkList = [];
renderMilkyWay(W, H, horizonPx, lst, glareOn); if (renderMilkyWay(lst, glareOn)) {
bctx.imageSmoothingEnabled = true; bctx.imageSmoothingEnabled = true;
bctx.imageSmoothingQuality = 'high'; bctx.imageSmoothingQuality = 'high';
bctx.save(); bctx.save();
bctx.globalCompositeOperation = 'lighter'; bctx.globalCompositeOperation = 'lighter';
bctx.globalAlpha = 0.40; bctx.globalAlpha = 0.22;
bctx.filter = `blur(${Math.max(10, H * 0.014)}px) saturate(180%)`; bctx.filter = `blur(${Math.max(7, H * 0.009)}px) saturate(150%)`;
bctx.drawImage(mwLayer, 0, 0, W, horizonPx); bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
bctx.globalAlpha = 0.96; bctx.globalAlpha = 0.94;
bctx.filter = 'contrast(122%) saturate(150%)'; bctx.filter = 'contrast(116%) saturate(158%)';
bctx.drawImage(mwLayer, 0, 0, W, horizonPx); bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
bctx.restore(); bctx.restore();
}
for (let i = 0; i < STAR_DATA.length; i++) { for (let i = 0; i < STAR_DATA.length; i++) {
const [ra, dec, mag, ci] = STAR_DATA[i]; const [ra, dec, mag, ci] = STAR_DATA[i];
@ -1524,12 +1463,14 @@ function setObserverLocation(lat, lon) {
if (lat === obsLat && lon === obsLon) return; if (lat === obsLat && lon === obsLon) return;
obsLat = lat; obsLat = lat;
obsLon = lon; obsLon = lon;
buildMwGrid();
projectSky(); projectSky();
} }
function resizeCanvas() { function resizeCanvas() {
canvas.width = window.innerWidth; canvas.width = window.innerWidth;
canvas.height = window.innerHeight; canvas.height = window.innerHeight;
buildMwGrid();
projectSky(); projectSky();
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 MiB

86
tools/build_skymap.py Normal file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Gera a textura galáctica de alta resolução usada pelo wallpaper.
Fonte: "Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO),
8000x4000 em projeção Hammer e coordenadas galácticas. A saída é plate
carrée, adequada para amostragem direta por longitude/latitude no canvas.
O processamento é feito em blocos para não manter várias grades 6K inteiras
na memória. Requer Pillow e numpy.
"""
import io
import math
import urllib.request
from pathlib import Path
import numpy as np
from PIL import Image
URL = (
"https://upload.wikimedia.org/wikipedia/commons/e/ea/"
"Gaia%E2%80%99s_sky_in_colour_ESA393127.png"
)
CACHE = Path.home() / ".cache/skeledance/gaia_allsky.png"
OUT = Path(__file__).resolve().parent.parent / "plugin/contents/ui/milkyway.jpg"
SIZE = (6144, 3072)
BLOCK_ROWS = 96
QUALITY = 92
def fetch() -> bytes:
if CACHE.exists():
print(f"Usando cache {CACHE}")
return CACHE.read_bytes()
print(f"Baixando {URL} ...")
req = urllib.request.Request(URL, headers={"User-Agent": "skeledance-build/2.0"})
raw = urllib.request.urlopen(req, timeout=180).read()
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_bytes(raw)
print(f" {len(raw) / 1e6:.1f} MB (cacheado)")
return raw
def main() -> None:
Image.MAX_IMAGE_PIXELS = None
src = np.asarray(Image.open(io.BytesIO(fetch())).convert("RGB"))
sh, sw = src.shape[:2]
tw, th = SIZE
out = np.empty((th, tw, 3), dtype=np.uint8)
# Longitude cresce para a esquerda para coincidir com a imagem Gaia.
lon = np.deg2rad(180 - (np.arange(tw, dtype=np.float64) + 0.5) * 360 / tw)
for y_start in range(0, th, BLOCK_ROWS):
y_stop = min(th, y_start + BLOCK_ROWS)
lat = np.deg2rad(
90 - (np.arange(y_start, y_stop, dtype=np.float64) + 0.5) * 180 / th
)
lon_grid, lat_grid = np.meshgrid(lon, lat)
denom = np.sqrt(1 + np.cos(lat_grid) * np.cos(lon_grid / 2))
hx = 2 * math.sqrt(2) * np.cos(lat_grid) * np.sin(lon_grid / 2) / denom
hy = math.sqrt(2) * np.sin(lat_grid) / denom
sx = (1 - hx / (2 * math.sqrt(2))) * sw / 2
sy = (1 - hy / math.sqrt(2)) * sh / 2
# Amostragem bilinear preserva filamentos sem serrilhar a reprojeção.
x0 = np.clip(np.floor(sx).astype(np.int32), 0, sw - 1)
y0 = np.clip(np.floor(sy).astype(np.int32), 0, sh - 1)
x1 = np.minimum(x0 + 1, sw - 1)
y1 = np.minimum(y0 + 1, sh - 1)
fx = (sx - x0)[..., None]
fy = (sy - y0)[..., None]
top = src[y0, x0] * (1 - fx) + src[y0, x1] * fx
bottom = src[y1, x0] * (1 - fx) + src[y1, x1] * fx
out[y_start:y_stop] = np.clip(top * (1 - fy) + bottom * fy, 0, 255)
print(f"\rReprojetando: {y_stop * 100 // th:3d}%", end="", flush=True)
print()
Image.fromarray(out).save(OUT, "JPEG", quality=QUALITY, optimize=True)
print(f"{OUT} ({OUT.stat().st_size / 1024 / 1024:.1f} MB, {tw}x{th})")
if __name__ == "__main__":
main()