diff --git a/plugin/contents/ui/dancer.html b/plugin/contents/ui/dancer.html index 2129cda..2cfa64d 100644 --- a/plugin/contents/ui/dancer.html +++ b/plugin/contents/ui/dancer.html @@ -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(); } diff --git a/plugin/contents/ui/milkyway.jpg b/plugin/contents/ui/milkyway.jpg new file mode 100644 index 0000000..8c7a411 Binary files /dev/null and b/plugin/contents/ui/milkyway.jpg differ diff --git a/tools/build_skymap.py b/tools/build_skymap.py new file mode 100644 index 0000000..a96447d --- /dev/null +++ b/tools/build_skymap.py @@ -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()