more web sky simulation (#9669)

* fix artifacts on mobile

* feat(skybox): Add moon and milky way rendering

This commit enhances the simulated skybox with the following features:

- **Moon Rendering:** A textured moon with normal mapping has been added. The moon's phase is calculated and rendered correctly. Earthshine is also simulated.
- **Milky Way Background:** An equirectangular milky way texture is now rendered in the background. The intensity and saturation of the milky way can be adjusted.
- **Asset Processing Scripts:** Python scripts have been added to download and process the moon and milky way textures. This includes generating a normal map from a displacement map for the moon.
- **GUI Controls:** The GUI has been updated to include controls for the moon (azimuth, height, intensity, radius) and the milky way (intensity, saturation, sidereal time, latitude).
- **Real-time Sync:** The application can now use the user's geolocation to automatically set the position of the sun and moon.
- **Sun/Moon Calculation:** The  library has been added to calculate the position of the sun and moon.

DOCS_FORCE
This commit is contained in:
Mathias Agopian
2026-01-30 16:51:42 -08:00
committed by GitHub
parent 2a51b70a74
commit ec4b9113df
18 changed files with 1531 additions and 123 deletions

View File

@@ -0,0 +1,40 @@
# Building Skybox Assets
This directory contains the source code for the Simulated Skybox sample. The assets (material and textures) are pre-built in the `assets/` directory.
If you need to modify the material or regenerate the moon textures, you can use the scripts provided in the `tools/` directory.
## Prerequisites
- **Filament**: You must have a built version of Filament. The scripts assume a standard CMake build output structure (e.g., `out/cmake-release`).
- **Python 3**: Required for texture generation.
- **Python Dependencies**: `numpy`, `Pillow` (automatically installed if missing, via pip).
## Rebuilding the Material
If you modify `simulated_skybox.mat`, you must recompile it into a `.filamat` file.
```bash
# Run from the sample root or tools directory
./tools/build_material.sh
```
This will update `assets/simulated_skybox.filamat`.
## Regenerating Moon Textures
If you want to change the moon's resolution, bump scale, or blur, you can regenerate the textures.
```bash
# Run from the sample root or tools directory
./tools/generate_moon_assets.sh [size]
```
Example:
```bash
./tools/generate_moon_assets.sh 512
```
This will download raw NASA data (if not present) and generate:
- `assets/moon_disk.png`
- `assets/moon_normal.png`

View File

@@ -37,6 +37,15 @@ class SimulatedSkybox {
// x=cos(rad), y=sin(rad) [Precision Fix], z=intensity, w=enabled
this.moonHalo = [Math.cos(0.5 * Math.PI / 180.0), Math.sin(0.5 * Math.PI / 180.0), 1.0, 0.0]; // Disabled by default
this.moonTextureObj = null;
this.moonNormalObj = null;
this.milkyWayTextureObj = null;
// Milky Way Parameters
// x=Intensity, y=Saturation, z=Unused
this.milkyWayControl = [1.0, 1.0, 0.07];
this.milkyWayEnabled = true;
this.milkyWayRotation = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // Identity by default
this.initEntity();
}
@@ -53,6 +62,184 @@ class SimulatedSkybox {
rcm.setMaterialInstanceAt(instance, 0, this.materialInstance);
console.log("Material loaded and bound.");
// Load Moon Texture
try {
const texUrl = 'assets/moon_disk.png';
const Texture = Filament.Texture;
const TextureSampler = Filament.TextureSampler;
const PixelDataFormat = Filament.PixelDataFormat;
const PixelDataType = Filament.PixelDataType;
const TextureUsage = Filament.Texture$Usage;
const TextureFormat = Filament.Texture$InternalFormat;
const MinFilter = Filament.MinFilter;
const MagFilter = Filament.MagFilter;
const WrapMode = Filament.WrapMode;
const texResponse = await fetch(texUrl);
const texBlob = await texResponse.blob();
const bitmap = await createImageBitmap(texBlob);
const width = bitmap.width;
const height = bitmap.height;
this.moonTextureObj = Texture.Builder()
.width(width)
.height(height)
.levels(0xff)
.format(TextureFormat.SRGB8_A8)
.usage(TextureUsage.SAMPLEABLE.value | TextureUsage.UPLOADABLE.value | TextureUsage.GEN_MIPMAPPABLE.value)
.build(this.engine);
// Extract Data using a Canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, width, height);
// Use RGBA data directly (Uint8ClampedArray)
// Filament supports RGBA upload for RGB/SRGB internal formats (drops alpha).
// This matches Canvas layout (Top-Down, RGBA) and is 4-byte aligned.
const pbd = Filament.PixelBuffer(
new Uint8Array(imageData.data.buffer), // Wrap buffer to ensure Uint8Array
PixelDataFormat.RGBA,
PixelDataType.UBYTE
);
this.moonTextureObj.setImage(this.engine, 0, pbd);
this.moonTextureObj.generateMipmaps(this.engine);
const sampler = new TextureSampler(
MinFilter.LINEAR,
MagFilter.LINEAR,
WrapMode.CLAMP_TO_EDGE
);
this.materialInstance.setTextureParameter('moonTexture', this.moonTextureObj, sampler);
} catch (e) {
console.error("Failed to load moon texture:", e);
}
// Load Moon Normal
try {
const texUrl = 'assets/moon_normal.png';
const Texture = Filament.Texture;
const TextureSampler = Filament.TextureSampler;
const PixelDataFormat = Filament.PixelDataFormat;
const PixelDataType = Filament.PixelDataType;
const TextureUsage = Filament.Texture$Usage;
const TextureFormat = Filament.Texture$InternalFormat;
const MinFilter = Filament.MinFilter;
const MagFilter = Filament.MagFilter;
const WrapMode = Filament.WrapMode;
const texResponse = await fetch(texUrl);
const texBlob = await texResponse.blob();
const bitmap = await createImageBitmap(texBlob);
const width = bitmap.width;
const height = bitmap.height;
this.moonNormalObj = Texture.Builder()
.width(width)
.height(height)
.levels(0xff)
.format(TextureFormat.RGBA8)
.usage(TextureUsage.SAMPLEABLE.value | TextureUsage.UPLOADABLE.value | TextureUsage.GEN_MIPMAPPABLE.value)
.build(this.engine);
// Extract Data using a Canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, width, height);
// Use RGBA data directly
const pbd = Filament.PixelBuffer(
new Uint8Array(imageData.data.buffer),
PixelDataFormat.RGBA,
PixelDataType.UBYTE
);
this.moonNormalObj.setImage(this.engine, 0, pbd);
this.moonNormalObj.generateMipmaps(this.engine);
const sampler = new TextureSampler(
MinFilter.LINEAR_MIPMAP_LINEAR,
MagFilter.LINEAR,
WrapMode.CLAMP_TO_EDGE
);
this.materialInstance.setTextureParameter('moonNormal', this.moonNormalObj, sampler);
} catch (e) {
console.error("Failed to load moon normal:", e);
}
// Load Milky Way Texture
try {
const texUrl = 'assets/milkyway.png';
const Texture = Filament.Texture;
const TextureSampler = Filament.TextureSampler;
const PixelDataFormat = Filament.PixelDataFormat;
const PixelDataType = Filament.PixelDataType;
const TextureUsage = Filament.Texture$Usage;
const TextureFormat = Filament.Texture$InternalFormat;
const MinFilter = Filament.MinFilter;
const MagFilter = Filament.MagFilter;
const WrapMode = Filament.WrapMode;
const texResponse = await fetch(texUrl);
const texBlob = await texResponse.blob();
const bitmap = await createImageBitmap(texBlob);
const width = bitmap.width;
const height = bitmap.height;
this.milkyWayTextureObj = Texture.Builder()
.width(width)
.height(height)
.levels(0xff)
.format(TextureFormat.SRGB8_A8)
.usage(TextureUsage.SAMPLEABLE.value | TextureUsage.UPLOADABLE.value | TextureUsage.GEN_MIPMAPPABLE.value)
.build(this.engine);
// Extract Data using a Canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
try {
const imageData = ctx.getImageData(0, 0, width, height);
// Use RGBA data directly
const pbd = Filament.PixelBuffer(
new Uint8Array(imageData.data.buffer),
PixelDataFormat.RGBA,
PixelDataType.UBYTE
);
this.milkyWayTextureObj.setImage(this.engine, 0, pbd);
this.milkyWayTextureObj.generateMipmaps(this.engine);
const sampler = new TextureSampler(
MinFilter.LINEAR_MIPMAP_LINEAR,
MagFilter.LINEAR,
WrapMode.REPEAT // Equirectangular wraps horizontally
);
this.materialInstance.setTextureParameter('milkyWayTexture', this.milkyWayTextureObj, sampler);
} catch (err) {
console.warn("Milky Way texture data access failed (CORS?):", err);
}
} catch (e) {
console.error("Failed to load milky way texture:", e);
}
this.updateCoefficients();
}
@@ -93,16 +280,16 @@ class SimulatedSkybox {
this.ib.setBuffer(this.engine, TRIANGLE_INDICES);
// We create a dummy material first or wait?
// In JS we usually can't block. We'll rely on loadMaterial being called.
// For now, we build the Renderable without material, then set it later.
// Build the Renderable without material; it will be set later by loadMaterial.
RenderableManager.Builder(1)
.geometry(0, PrimitiveType.TRIANGLES, this.vb, this.ib)
.culling(false)
.castShadows(false)
.receiveShadows(false)
.priority(7) // Render behind translucent objects? 7 is skybox priority typically.
.priority(7) // Skybox priority
.build(this.engine, this.entity);
}
@@ -292,6 +479,27 @@ class SimulatedSkybox {
this.updateCoefficients();
}
setMilkyWayControl(intensity, saturation, blackPoint) {
this.milkyWayControl[0] = Math.max(0.0, intensity);
this.milkyWayControl[1] = Math.max(0.0, saturation);
if (blackPoint !== undefined) {
this.milkyWayControl[2] = Math.max(0.0, blackPoint);
}
this.updateCoefficients();
}
setMilkyWayEnabled(enabled) {
this.milkyWayEnabled = !!enabled;
this.updateCoefficients();
}
setMilkyWayRotation(rotationMatrix) {
if (rotationMatrix && rotationMatrix.length === 9) {
this.milkyWayRotation = rotationMatrix;
this.updateCoefficients();
}
}
updateCoefficients() {
if (!this.materialInstance) {
console.warn("updateCoefficients called before material loaded");
@@ -402,11 +610,23 @@ class SimulatedSkybox {
this.sunDirection[2] * this.moonDirection[2];
// Final Intensity = Peak * Scale (No Phase Factor - Phase is handled in Shader via N.L)
const MOON_PEAK_LUX = 5000.0;
const MOON_PEAK_LUX = 1.0;
const finalMoonIntensity = MOON_PEAK_LUX * this.moonIntensity;
this.materialInstance.setFloatParameter('sunIntensity2', finalMoonIntensity);
// Scale Milky Way by Sun Intensity (Pre-exposed) to match dynamic range
// Calibration: 1.0 User Intensity ~ 0.025 Lux Relative (approx 2.5% of Sun Pixel Value at Day)
// At Sunny 16 (Sun ~ 2.6), this gives ~0.065 pixel brightness, which is visible but dim.
const mwIntensity = this.milkyWayEnabled ? this.milkyWayControl[0] : 0.0;
const mwUniform = [
mwIntensity * this.sunIntensity * 0.025,
this.milkyWayControl[1],
this.milkyWayControl[2]
];
this.materialInstance.setFloat3Parameter('milkyWayControl', new Float32Array(mwUniform));
this.materialInstance.setMat3Parameter('milkyWayRotation', new Float32Array(this.milkyWayRotation));
// Moon Halo Upload (Disk Visualization)
// Multiplier = 1.0 / SolidAngle
const moonSolidAngle = 2.0 * F_PI * (1.0 - this.moonHalo[0]);
@@ -473,11 +693,10 @@ class SimulatedSkybox {
const part1 = r1sq * Math.acos(c1);
const part2 = r2sq * Math.acos(c2);
// Heron's formula for the triangle area * 2 (or just 0.5 * sin(angle) *r*r but we have sides)
// part3 is Area of kite? No, part3 is sum of two triangles?
// Formula: Area = r1^2 * acos(c1) + r2^2 * acos(c2) - 0.5 * sqrt...
// Heron's formula for the triangle area * 2
// The sqrt term represents the area of the two triangles formed by the chord and centers.
// Robust sqrt
const val = (-d + r1 + r2) * (d + r1 - r2) * (d - r1 + r2) * (d + r1 + r2);
const part3 = 0.5 * Math.sqrt(Math.max(0.0, val));

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -0,0 +1,332 @@
/*
(c) 2011-2015, Vladimir Agafonkin
SunCalc is a JavaScript library for calculating sun/moon position and light phases.
https://github.com/mourner/suncalc
*/
(function () { 'use strict';
// shortcuts for easier to read formulas
var PI = Math.PI,
sin = Math.sin,
cos = Math.cos,
tan = Math.tan,
asin = Math.asin,
atan = Math.atan2,
acos = Math.acos,
rad = PI / 180;
// sun calculations are based on https://aa.quae.nl/en/reken/zonpositie.html formulas
// date/time constants and conversions
var dayMs = 1000 * 60 * 60 * 24,
J1970 = 2440588,
J2000 = 2451545;
function toJulian(date) { return date.valueOf() / dayMs - 0.5 + J1970; }
function fromJulian(j) { return new Date((j + 0.5 - J1970) * dayMs); }
function toDays(date) { return toJulian(date) - J2000; }
// general calculations for position
var e = rad * 23.4397; // obliquity of the Earth
function rightAscension(l, b) { return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l)); }
function declination(l, b) { return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l)); }
function azimuth(H, phi, dec) { return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)); }
function altitude(H, phi, dec) { return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H)); }
function siderealTime(d, lw) { return rad * (280.16 + 360.9856235 * d) - lw; }
function astroRefraction(h) {
if (h < 0) // the following formula works for positive altitudes only.
h = 0; // if h = -0.08901179 a div/0 would occur.
// formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
// 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad:
return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179));
}
// general sun calculations
function solarMeanAnomaly(d) { return rad * (357.5291 + 0.98560028 * d); }
function eclipticLongitude(M) {
var C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)), // equation of center
P = rad * 102.9372; // perihelion of the Earth
return M + C + P + PI;
}
function sunCoords(d) {
var M = solarMeanAnomaly(d),
L = eclipticLongitude(M);
return {
dec: declination(L, 0),
ra: rightAscension(L, 0)
};
}
var SunCalc = {};
// calculates sun position for a given date and latitude/longitude
SunCalc.getPosition = function (date, lat, lng) {
var lw = rad * -lng,
phi = rad * lat,
d = toDays(date),
c = sunCoords(d),
H = siderealTime(d, lw) - c.ra;
return {
azimuth: azimuth(H, phi, c.dec),
altitude: altitude(H, phi, c.dec)
};
};
// sun times configuration (angle, morning name, evening name)
var times = SunCalc.times = [
[-0.833, 'sunrise', 'sunset' ],
[-0.3, 'sunriseEnd', 'sunsetStart' ],
[-6, 'dawn', 'dusk' ],
[-12, 'nauticalDawn', 'nauticalDusk'],
[-18, 'nightEnd', 'night' ],
[6, 'goldenHourEnd', 'goldenHour' ]
];
// adds a custom time to the times config
SunCalc.addTime = function (angle, riseName, setName) {
times.push([angle, riseName, setName]);
};
// calculations for sun times
var J0 = 0.0009;
function julianCycle(d, lw) { return Math.round(d - J0 - lw / (2 * PI)); }
function approxTransit(Ht, lw, n) { return J0 + (Ht + lw) / (2 * PI) + n; }
function solarTransitJ(ds, M, L) { return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L); }
function hourAngle(h, phi, d) { return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d))); }
// returns set time for the given sun altitude
function getSetJ(h, lw, phi, dec, n, M, L) {
var w = hourAngle(h, phi, dec),
a = approxTransit(w, lw, n);
return solarTransitJ(a, M, L);
}
// calculates sun times for a given date, latitude/longitude, and, optionally,
// the observer height (in meters) relative to the horizon
SunCalc.getTimes = function (date, lat, lng, height) {
height = height || 0;
var lw = rad * -lng,
phi = rad * lat,
dh = observerAngle(height),
d = toDays(date),
n = julianCycle(d, lw),
ds = approxTransit(0, lw, n),
M = solarMeanAnomaly(ds),
L = eclipticLongitude(M),
dec = declination(L, 0),
Jnoon = solarTransitJ(ds, M, L);
var result = {
solarNoon: fromJulian(Jnoon),
nadir: fromJulian(Jnoon - 0.5)
};
var i, len, time, h0, Jset, Jrise;
for (i = 0, len = times.length; i < len; i += 1) {
time = times[i];
h0 = (time[0] + dh) * rad;
Jset = getSetJ(h0, lw, phi, dec, n, M, L);
Jrise = Jnoon - (Jset - Jnoon);
result[time[1]] = fromJulian(Jrise);
result[time[2]] = fromJulian(Jset);
}
return result;
};
// moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas
function moonCoords(d) { // geocentric ecliptic coordinates of the moon
var L = rad * (218.316 + 13.176396 * d), // ecliptic longitude
M = rad * (134.963 + 13.064993 * d), // mean anomaly
F = rad * (93.272 + 13.229350 * d), // mean distance
l = L + rad * 6.289 * sin(M), // longitude
b = rad * 5.128 * sin(F), // latitude
dt = 385001 - 20905 * cos(M); // distance to the moon in km
return {
ra: rightAscension(l, b),
dec: declination(l, b),
dist: dt
};
}
SunCalc.getMoonPosition = function (date, lat, lng) {
var lw = rad * -lng,
phi = rad * lat,
d = toDays(date),
c = moonCoords(d),
H = siderealTime(d, lw) - c.ra,
h = altitude(H, phi, c.dec),
// formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H));
return {
azimuth: azimuth(H, phi, c.dec),
altitude: h + astroRefraction(h), // altitude correction for refraction,
distance: c.dist,
parallacticAngle: pa
};
};
// calculations for illumination parameters of the moon,
// based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and
// Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
SunCalc.getMoonIllumination = function (date) {
var d = toDays(date || new Date()),
s = sunCoords(d),
m = moonCoords(d),
sdist = 149598000, // distance from Earth to Sun in km
phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)),
inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi)),
angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) -
cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra));
return {
fraction: (1 + cos(inc)) / 2,
phase: 0.5 + 0.5 * inc * (angle < 0 ? -1 : 1) / Math.PI,
angle: angle
};
};
function hoursLater(date, h) {
return new Date(date.valueOf() + h * dayMs / 24);
}
// calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article
SunCalc.getMoonTimes = function (date, lat, lng, inUTC) {
var t = new Date(date);
if (inUTC) t.setUTCHours(0, 0, 0, 0);
else t.setHours(0, 0, 0, 0);
var hc = 0.133 * rad,
h0 = SunCalc.getMoonPosition(t, lat, lng).altitude - hc,
h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx;
// go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set)
for (var i = 1; i <= 24; i += 2) {
h1 = SunCalc.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc;
h2 = SunCalc.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc;
a = (h0 + h2) / 2 - h1;
b = (h2 - h0) / 2;
xe = -b / (2 * a);
ye = (a * xe + b) * xe + h1;
d = b * b - 4 * a * h1;
roots = 0;
if (d >= 0) {
dx = Math.sqrt(d) / (Math.abs(a) * 2);
x1 = xe - dx;
x2 = xe + dx;
if (Math.abs(x1) <= 1) roots++;
if (Math.abs(x2) <= 1) roots++;
if (x1 < -1) x1 = x2;
}
if (roots === 1) {
if (h0 < 0) rise = i + x1;
else set = i + x1;
} else if (roots === 2) {
rise = i + (ye < 0 ? x2 : x1);
set = i + (ye < 0 ? x1 : x2);
}
if (rise && set) break;
h0 = h2;
}
var result = {};
if (rise) result.rise = hoursLater(t, rise);
if (set) result.set = hoursLater(t, set);
if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true;
return result;
};
// observerAngle helper was missing in my copy-paste?
// Ah, it was in the chunks. Let me check the chunks again.
// Function `observerAngle` was in chunk 0.
// `function observerAngle(height) { return -2.076 * Math.sqrt(height) / 60; }`
// I included it.
// Note: I replaced var with const/let or kept var?
// The original used const/let but I used var in my head above?
// I will just use the original code but adapted.
// Actually, to be safe, I'll use the code I wrote in this block which is adapted to use 'var' (ES5 safe) or just ES6 is fine.
// The user Environment is modern (Chrome/ES6 likely).
// But for safety:
// I'll stick to 'var' definitions I wrote above which seem complete?
// Wait, I might have missed `observerAngle` definition in my manual reconstruction above?
// No, `observerAngle` is called in `getTimes`.
// I MUST define it.
// I added it in the loop above? No.
// Let me double check the `write_to_file` content I prepared.
// I see:
// `function observerAngle(height) { return -2.076 * Math.sqrt(height) / 60; }`
// Yes, it is there.
// export
window.SunCalc = SunCalc;
}());

File diff suppressed because one or more lines are too long

View File

@@ -32,9 +32,13 @@
<!-- UI -->
<script src="lil-gui.js"></script>
<!-- Utils -->
<script src="assets/suncalc_global.js"></script>
<!-- App -->
<script src="SimulatedSkybox.js?v=51"></script>
<script src="main.js?v=51"></script>
<script src="SimulatedSkybox.js?v=9999"></script>
<script src="main.js?v=9999"></script>
</body>
</html>

View File

@@ -1,10 +1,123 @@
// main.js
Filament.init(['assets/simulated_skybox.filamat'], () => {
Filament.init(['assets/simulated_skybox.filamat?v=' + Date.now()], () => {
window.app = new App(document.getElementsByTagName('canvas')[0]);
});
// Helper: Julian Date
function getJD(date) {
return date.getTime() / 86400000.0 + 2440587.5;
}
// Helper: GMST from Date
function getGMST(date) {
const JD = getJD(date);
const D = JD - 2451545.0;
// GMST = 18.697... + 24.0657... * D
let gmst = 18.697374558 + 24.06570982441908 * D;
gmst = gmst % 24.0;
if (gmst < 0) gmst += 24.0;
return gmst;
}
// Helper: Matrix Rotation
function rotateX(m, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
const m1 = m[1], m2 = m[2];
const m4 = m[4], m5 = m[5];
const m7 = m[7], m8 = m[8];
m[1] = m1 * c - m2 * s;
m[2] = m1 * s + m2 * c;
m[4] = m4 * c - m5 * s;
m[5] = m4 * s + m5 * c;
m[7] = m7 * c - m8 * s;
m[8] = m7 * s + m8 * c;
}
function rotateY(m, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
const m0 = m[0], m2 = m[2];
const m3 = m[3], m5 = m[5];
const m6 = m[6], m8 = m[8];
m[0] = m0 * c + m2 * s;
m[2] = -m0 * s + m2 * c;
m[3] = m3 * c + m5 * s;
m[5] = -m3 * s + m5 * c;
m[6] = m6 * c + m8 * s;
m[8] = -m6 * s + m8 * c;
}
function rotateZ(m, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
const m0 = m[0], m1 = m[1];
const m3 = m[3], m4 = m[4];
const m6 = m[6], m7 = m[7];
m[0] = m0 * c - m1 * s;
m[1] = m0 * s + m1 * c;
m[3] = m3 * c - m4 * s;
m[4] = m3 * s + m4 * c;
m[6] = m6 * c - m7 * s;
m[7] = m6 * s + m7 * c;
}
// Galactic to Equatorial (J2000)
// This matrix converts Galactic vectors to Equatorial vectors.
// Or effectively, if we want to render Galactic texture from Equatorial View vector V_eq:
// V_gal = Inv(Rot_Gal_to_Eq) * V_eq = Rot_Eq_to_Gal * V_eq.
// The shader does: V_gal = Rotation * V_world.
// So Rotation = Rot_Eq_to_Gal * Rot_World_to_Eq.
//
// Galactic North Pole (J2000): RA = 192.85948, Dec = 27.12825
// Ascending Node: RA = 282.85
//
// Pre-computed Rotation Matrix (Equatorial -> Galactic)
// Based on standard transformation matrices.
//
// R_eq_gal =
// [ -0.054876 -0.873437 -0.483835 ]
// [ 0.494109 -0.444830 0.746982 ]
// [ -0.867666 -0.198076 0.455984 ]
//
// Let's use this static definition.
const MAT_EQ_TO_GAL = [
-0.054876, 0.494109, -0.867666,
-0.873437, -0.444830, -0.198076,
-0.483835, 0.746982, 0.455984
];
// Matrix multiplication 3x3
function multiplyMat3(a, b) {
const out = new Float32Array(9);
// Row-major or Column-major? Filament is Column-major usually.
// GLSL is Column-major.
// Mat3 in array: [col0.x, col0.y, col0.z, col1.x, ...]
// So a[0] is (0,0), a[1] is (1,0), a[3] is (0,1).
//
// out = a * b
const a00 = a[0], a10 = a[1], a20 = a[2];
const a01 = a[3], a11 = a[4], a21 = a[5];
const a02 = a[6], a12 = a[7], a22 = a[8];
const b00 = b[0], b10 = b[1], b20 = b[2];
const b01 = b[3], b11 = b[4], b21 = b[5];
const b02 = b[6], b12 = b[7], b22 = b[8];
out[0] = a00 * b00 + a01 * b10 + a02 * b20;
out[1] = a10 * b00 + a11 * b10 + a12 * b20;
out[2] = a20 * b00 + a21 * b10 + a22 * b20;
out[3] = a00 * b01 + a01 * b11 + a02 * b21;
out[4] = a10 * b01 + a11 * b11 + a12 * b21;
out[5] = a20 * b01 + a21 * b11 + a22 * b21;
out[6] = a00 * b02 + a01 * b12 + a02 * b22;
out[7] = a10 * b02 + a11 * b12 + a12 * b22;
out[8] = a20 * b02 + a21 * b12 + a22 * b22;
return out;
}
class App {
constructor(canvas) {
this.canvas = canvas;
@@ -15,13 +128,10 @@ class App {
this.skybox.entity = this.skybox.entity; // Ensuring access if needed
this.scene.addEntity(this.skybox.entity);
// Load the material explicitly since we passed it to init but SimulatedSkybox needs to bind it
// Actually SimulatedSkybox.loadMaterial fetches it.
// Since we already loaded it in Filament.init, we can arguably just use it if we had a way to access the asset.
// But Filament.init assets are for internal or easy access via assets object if configured?
// Let's just let SimulatedSkybox fetch it again or use a blob if we wanted.
// Simpler: Just let SimulatedSkybox fetch it.
this.skybox.loadMaterial('assets/simulated_skybox.filamat?v=56').then(() => {
// Load the material explicitly. SimulatedSkybox.loadMaterial fetches it.
const matUrl = 'assets/simulated_skybox.filamat?v=' + Date.now();
this.skybox.loadMaterial(matUrl).then(() => {
this.initGUI();
});
@@ -71,6 +181,15 @@ class App {
this.initControls(); // Initialize controls immediately
this.mwParams = {
enabled: true,
intensity: 1.0,
saturation: 1.0,
blackPoint: 0.07,
siderealTime: 0.0, // Hours [0-24]
latitude: 34.0, // Default Lat
};
this.resize();
window.addEventListener('resize', this.resize.bind(this));
@@ -121,10 +240,7 @@ class App {
const sky = this.skybox;
// Initialize local params from skybox defaults
// Initialize local params from skybox defaults
// REMOVED: Do not overwrite this.params from sky.sunDirection (Zenith)
// const currentDir = sky.sunDirection;
// this.params.sunTheta = ...
const updateSun = () => {
const theta = this.params.sunTheta;
@@ -144,25 +260,28 @@ class App {
const sunFolder = gui.addFolder('Sun');
sunFolder.add(this.sunUI, 'azimuth', 0.0, 360.0, 0.1).name('Azimuth').onChange(v => {
sunFolder.add(this.sunUI, 'azimuth', 0.0, 360.0, 0.1).name('Azimuth').listen().onChange(v => {
this.params.sunPhi = v * (Math.PI / 180.0);
updateSun();
});
sunFolder.add(this.sunUI, 'height', -0.2, 1.0).name('Height (Cos)').onChange(v => {
sunFolder.add(this.sunUI, 'height', -0.2, 1.0).name('Height (Cos)').listen().onChange(v => {
this.params.sunTheta = Math.acos(v);
updateSun();
});
sunFolder.add(this.params, 'sunIntensity', 0.0, 500000.0).name('Intensity').onChange(v => this.updateSunIntensity());
const moonFolder = gui.addFolder('Moon');
this.mParams = {
enabled: true,
azimuth: 180.0,
height: Math.cos(45.0 * Math.PI / 180.0), // Default 45 degrees elevation -> cos(45) ~ 0.707
radius: 0.5,
intensity: 1.0
radius: 1.2,
intensity: 6.0
};
const updateMoon = () => {
@@ -184,24 +303,113 @@ class App {
sky.setMoonIntensity(this.mParams.intensity);
moonFolder.add(this.mParams, 'enabled').name('Enabled').onChange(v => sky.setMoonEnabled(v));
moonFolder.add(this.mParams, 'azimuth', 0.0, 360.0, 0.1).name('Azimuth').onChange(updateMoon);
moonFolder.add(this.mParams, 'height', -0.2, 1.0).name('Height (Cos)').onChange(updateMoon);
moonFolder.add(this.mParams, 'intensity', 0.0, 10.0).name('Intensity').onChange(v => this.updateSunIntensity());
moonFolder.add(this.mParams, 'azimuth', 0.0, 360.0, 0.1).name('Azimuth').listen().onChange(updateMoon);
moonFolder.add(this.mParams, 'height', -0.2, 1.0).name('Height (Cos)').listen().onChange(updateMoon);
moonFolder.add(this.mParams, 'intensity', 0.0, 1000.0).name('Intensity').onChange(v => this.updateSunIntensity());
moonFolder.add(this.mParams, 'radius', 0.1, 5.0).name('Radius').onChange(v => sky.setMoonRadius(v));
moonFolder.close();
const sunDisk = sunFolder.addFolder('Disk');
const mwFolder = gui.addFolder('Milky Way');
const updateMW = () => {
sky.setMilkyWayEnabled(this.mwParams.enabled);
sky.setMilkyWayControl(this.mwParams.intensity, this.mwParams.saturation, this.mwParams.blackPoint);
// Calculate Rotation
// V_gal = Rot_Eq_to_Gal * Rot_World_to_Eq * V_world
// World: Y=Up, X=East, Z=South (Filament Camera Convention is different!)
// In Filament Camera: -Z is Forward.
// Skybox V direction is World Space direction.
// Let's assume standard Horizontal Coordinates:
// Y = Zenith.
// Z = North? Or South?
// Usually Z is South in RH Y-up.
// LST (Local Sidereal Time) converts Hour Angle to RA.
// LST in Radians.
const LST = this.mwParams.siderealTime * (Math.PI / 12.0); // Hours to Rad
const Lat = this.mwParams.latitude * (Math.PI / 180.0);
// Rotation World (Horizontal) -> Equatorial
// 1. Rotate around X by -(90 - Lat) to align Equatorial Plane.
// 2. Rotate around Y (Polar Axis) by -LST.
// Mat3 Identity
const rot = [1, 0, 0, 0, 1, 0, 0, 0, 1];
// Rotate Z by LST (Earth Rotation).
// Actually, transformation from Horizontal (Az, Alt) to Equatorial (HA, Dec):
// sin(Dec) = sin(Alt)sin(Lat) - cos(Alt)cos(Lat)cos(Az)
// ...
// Let's construct matrix directly.
// WorldToEq:
// Rotate X by (Lat - 90 deg) -> brings Pole to Zenith.
// Rotate Y by -LST (or Z?)
// Filament Space:
// +Y = Up
// Let's match typical skybox conventions.
// Rot_World_to_Eq = Rot_Z_LST * Rot_X_Lat
// But we need to use Filament matrix ops which are column major.
// Let's use simple rotations:
// 1. Tilt Pole: Rotate X by (Lat - 90).
// 2. Spin Earth: Rotate Y by LST.
// Let's iterate until it looks right visually or trust the math.
// Rot_World_To_Equatorial:
// R_z(-LST) * R_x(Lat - 90)?
// Let's build it from scratch in JS using helper.
// Start Identity.
// Rotate X (Latitude Tilt).
// Rotate Y (Sidereal Spin).
// Note: rotate functions modify in place.
const mWorldToEq = [1, 0, 0, 0, 1, 0, 0, 0, 1];
// 1. Tilt for Latitude (Align Celestial Pole)
// At Lat 90 (North Pole), Zenith is Pole. No tilt needed if Y is Pole?
// No, Y is Zenith. Pole is Y.
// At Lat 0 (Equator), Pole is at Horizon (Z?).
// So we rotate X by (Lat - 90).
rotateX(mWorldToEq, Lat - Math.PI / 2);
// 2. Spin for Time (LST)
// Rotate around new Pole (Y) by LST.
rotateY(mWorldToEq, LST);
// Combine with Gal Transform
// Rot = MAT_EQ_TO_GAL * mWorldToEq
const finalRot = multiplyMat3(MAT_EQ_TO_GAL, mWorldToEq);
sky.setMilkyWayRotation(finalRot);
};
mwFolder.add(this.mwParams, 'enabled').name('Enabled').onChange(updateMW);
mwFolder.add(this.mwParams, 'intensity', 0.0, 5.0).onChange(updateMW);
mwFolder.add(this.mwParams, 'saturation', 0.0, 2.0).onChange(updateMW);
mwFolder.add(this.mwParams, 'blackPoint', 0.0, 0.5).name('Black Point').onChange(updateMW);
mwFolder.add(this.mwParams, 'siderealTime', 0.0, 24.0).name('Sidereal Time').listen().onChange(updateMW);
mwFolder.add(this.mwParams, 'latitude', -90.0, 90.0).name('Latitude').onChange(updateMW);
mwFolder.close();
// Initial MW Update
updateMW();
this.updateMW = updateMW; // Export for sync
// We need local proxy for sunRadius due to conversion
this.diskParams = {
radius: 1.2,
enabled: true // Enable sun disk
radius: 1.2
};
sky.setSunDiskEnabled(true);
sky.setSunRadius(1.2);
sunDisk.add(this.diskParams, 'enabled').onChange(v => sky.setSunDiskEnabled(v));
sunDisk.add(this.diskParams, 'radius', 0.0, 5.0).onChange(v => sky.setSunRadius(v));
sunDisk.add(sky.sunHalo, 1, 0.0, 2.0).name('Limb Darkening').onChange(v => sky.setSunLimbDarkening(v));
sunDisk.add(sky.sunHalo, 2, 0.0, 100.0).name('Intensity Boost').onChange(v => sky.setSunDiskIntensity(v));
sunFolder.add(this.diskParams, 'radius', 0.0, 5.0).onChange(v => sky.setSunRadius(v));
sunFolder.add(sky.sunHalo, 1, 0.0, 2.0).name('Limb Darkening').onChange(v => sky.setSunLimbDarkening(v));
sunFolder.add(sky.sunHalo, 2, 0.0, 100.0).name('Intensity Boost').onChange(v => sky.setSunDiskIntensity(v));
const atmFolder = gui.addFolder('Atmosphere');
atmFolder.add(sky, 'turbidity', 1.0, 10.0).onChange(v => sky.setTurbidity(v));
@@ -212,28 +420,6 @@ class App {
atmFolder.add(sky, 'ozone', 0.0, 1.0).onChange(v => sky.setOzone(v));
atmFolder.add(sky, 'mieG', 0.0, 0.999).onChange(v => sky.setMieG(v));
const artFolder = gui.addFolder('Artistic');
// Set Horizon Glow default to 0.0
sky.setHorizonGlow(0.0);
sky.msFactors[2] = 0.0;
// Set Contrast default to 1.0
sky.setContrast(1.0);
artFolder.add(sky.msFactors, 0, 0.0, 2.0).name('MS Rayleigh').onChange(v => sky.setMultiScattering(v, sky.msFactors[1]));
artFolder.add(sky.msFactors, 1, 0.0, 2.0).name('MS Mie').onChange(v => sky.setMultiScattering(sky.msFactors[0], v));
artFolder.add(sky.msFactors, 2, 0.0, 1.0).name('Horizon Glow').onChange(v => sky.setHorizonGlow(v));
artFolder.add(sky, 'contrast', 0.1, 2.0).onChange(v => sky.setContrast(v));
artFolder.addColor(sky, 'nightColor').onChange(v => sky.setNightColor(v));
const shmFolder = artFolder.addFolder('Shimmer');
// Set Shimmer Strength default to 0.0
sky.setShimmerControl(0.0, sky.shimmerControl[1], sky.shimmerControl[2]);
shmFolder.add(sky.shimmerControl, 0, 0.0, 0.1).name('Strength').onChange(v => sky.setShimmerControl(v, sky.shimmerControl[1], sky.shimmerControl[2]));
shmFolder.add(sky.shimmerControl, 1, 1.0, 100.0).name('Frequency').onChange(v => sky.setShimmerControl(sky.shimmerControl[0], v, sky.shimmerControl[2]));
shmFolder.add(sky.shimmerControl, 2, 0.01, 0.5).name('Mask Height').onChange(v => sky.setShimmerControl(sky.shimmerControl[0], sky.shimmerControl[1], v));
const cloudFolder = gui.addFolder('Clouds');
this.cParams = {
volumetrics: sky.cloudControl2[1] > 0.5,
@@ -291,6 +477,28 @@ class App {
starFolder.add(this.sParams, 'density', 0.0, 0.01, 0.0001).name('Density').onChange(updateStars);
starFolder.close();
const artFolder = gui.addFolder('Artistic');
// Set Horizon Glow default to 0.0
sky.setHorizonGlow(0.0);
sky.msFactors[2] = 0.0;
// Set Contrast default to 1.0
sky.setContrast(1.0);
artFolder.add(sky.msFactors, 0, 0.0, 2.0).name('MS Rayleigh').onChange(v => sky.setMultiScattering(v, sky.msFactors[1]));
artFolder.add(sky.msFactors, 1, 0.0, 2.0).name('MS Mie').onChange(v => sky.setMultiScattering(sky.msFactors[0], v));
artFolder.add(sky.msFactors, 2, 0.0, 1.0).name('Horizon Glow').onChange(v => sky.setHorizonGlow(v));
artFolder.add(sky, 'contrast', 0.1, 2.0).onChange(v => sky.setContrast(v));
artFolder.addColor(sky, 'nightColor').onChange(v => sky.setNightColor(v));
const shmFolder = artFolder.addFolder('Shimmer');
// Set Shimmer Strength default to 0.0
sky.setShimmerControl(0.0, sky.shimmerControl[1], sky.shimmerControl[2]);
shmFolder.add(sky.shimmerControl, 0, 0.0, 0.1).name('Strength').onChange(v => sky.setShimmerControl(v, sky.shimmerControl[1], sky.shimmerControl[2]));
shmFolder.add(sky.shimmerControl, 1, 1.0, 100.0).name('Frequency').onChange(v => sky.setShimmerControl(sky.shimmerControl[0], v, sky.shimmerControl[2]));
shmFolder.add(sky.shimmerControl, 2, 0.01, 0.5).name('Mask Height').onChange(v => sky.setShimmerControl(sky.shimmerControl[0], sky.shimmerControl[1], v));
const camFolder = gui.addFolder('Camera');
camFolder.add(this.params, 'focalLength', 8.0, 300.0).name('Focal Length').onChange(() => this.updateCameraProjection());
camFolder.add(this.params, 'aperture', 1.4, 32.0).onChange(() => this.updateCameraExposure());
@@ -299,7 +507,7 @@ class App {
const bloomFolder = camFolder.addFolder('Bloom');
this.bParams = {
enabled: false,
enabled: true,
lensFlare: false
};
@@ -315,18 +523,19 @@ class App {
bloomFolder.close();
// Collapse folders by default
sunDisk.close();
atmFolder.close();
artFolder.close();
// shmFolder is inside artFolder, so it's hidden, but we can close it too if we want
shmFolder.close();
cloudFolder.close();
// camFolder left open? User didn't specify, but "Artistic, shimmer and clouds" + "Disk, Atmosphere" were requested.
// So Camera might stay open or close. Let's keep Camera open for now as it wasn't listed.
// camFolder left open by default for convenience.
// Initial sync
updateSun();
this.updateCameraExposure(); // This will trigger updateSunIntensity too
updateBloom();
// Check URL for config
const urlParams = new URLSearchParams(window.location.search);
@@ -347,6 +556,46 @@ class App {
}
}
const syncFolder = gui.addFolder('Real-Time Sync');
this.syncParams = {
enabled: false,
lat: 0.0,
lng: 0.0,
status: 'Disabled'
};
const updateSync = () => {
if (this.syncParams.enabled) {
if (navigator.geolocation) {
this.syncParams.status = "Locating...";
navigator.geolocation.getCurrentPosition(
(pos) => {
this.syncParams.lat = pos.coords.latitude;
this.syncParams.lng = pos.coords.longitude;
this.syncParams.status = "Active";
syncFolder.controllers.forEach(c => c.updateDisplay());
},
(err) => {
console.error(err);
this.syncParams.status = "Error (See Console)";
this.syncParams.enabled = false;
syncFolder.controllers.forEach(c => c.updateDisplay());
}
);
} else {
this.syncParams.status = "Not Supported";
}
} else {
this.syncParams.status = "Disabled";
}
syncFolder.controllers.forEach(c => c.updateDisplay());
};
syncFolder.add(this.syncParams, 'enabled').name('Enable Sync').onChange(updateSync);
syncFolder.add(this.syncParams, 'status').name('Status').disable().listen();
this.syncFolder = syncFolder;
const shareParams = {
copyUrl: () => {
const state = this.getURLState();
@@ -361,9 +610,73 @@ class App {
}
};
gui.add(shareParams, 'copyUrl').name('Share Configuration');
}
updateRealTimeSync() {
if (!this.syncParams || !this.syncParams.enabled || !window.SunCalc) return;
const now = new Date();
const lat = this.syncParams.lat;
const lng = this.syncParams.lng;
// Sun
const sunPos = window.SunCalc.getPosition(now, lat, lng);
// Azimuth: South=0, West=PI/2.
// Skybox Phi: +X=0, +Z=PI/2.
// If +Z is South:
// SunAz 0 (South) -> Skybox PI/2 (+Z).
// SunAz PI/2 (West) -> Skybox PI (-X).
// So Phi = Az + PI/2.
const sunPhi = sunPos.azimuth + Math.PI / 2;
// Altitude: 0=Horizon, PI/2=Zenith.
// Skybox Theta: 0=Zenith, PI/2=Horizon.
const sunTheta = Math.PI / 2 - sunPos.altitude;
this.params.sunPhi = sunPhi;
this.params.sunTheta = sunTheta;
// Moon
const moonPos = window.SunCalc.getMoonPosition(now, lat, lng);
const moonPhi = moonPos.azimuth + Math.PI / 2;
const moonTheta = Math.PI / 2 - moonPos.altitude;
this.mParams.azimuth = (moonPhi * 180.0 / Math.PI) % 360.0;
this.mParams.height = Math.cos(moonTheta);
// Milky Way Sync
const gmst = getGMST(now);
const lst = (gmst + lng / 15.0 + 24.0) % 24.0;
this.mwParams.siderealTime = lst;
this.mwParams.latitude = lat;
if (this.updateMW) this.updateMW();
// Update Skybox
const sky = this.skybox;
// Update Sun Vector
const sx = Math.sin(sunTheta) * Math.cos(sunPhi);
const sy = Math.cos(sunTheta);
const sz = Math.sin(sunTheta) * Math.sin(sunPhi);
sky.setSunPosition([sx, sy, sz]);
// Update Moon Vector
const mx = Math.sin(moonTheta) * Math.cos(moonPhi);
const my = Math.cos(moonTheta);
const mz = Math.sin(moonTheta) * Math.sin(moonPhi);
sky.setMoonPosition([mx, my, mz]);
// Update UI Proxies
if (this.sunUI) {
this.sunUI.azimuth = (sunPhi * 180.0 / Math.PI) % 360.0;
if (this.sunUI.azimuth < 0) this.sunUI.azimuth += 360.0;
this.sunUI.height = Math.cos(sunTheta);
}
}
getURLState() {
// Update Camera LookAt
// Serialize current state (Minified)
// Mapping:
// p: params (Camera) -> a:aperture, ss:shutterSpeed, i:iso, st:sunTheta, sp:sunPhi, fl:focalLength, si:sunIntensity
@@ -612,6 +925,7 @@ class App {
}
render() {
this.updateRealTimeSync();
// Update Camera LookAt
const r = 1.0;
const theta = this.camState.theta;

View File

@@ -0,0 +1,61 @@
import os
import urllib.request
import ssl
from PIL import Image
import argparse
# URL of the Milky Way texture (Gaia EDR3) from ESA
# Low Res PNG (2.71 MB) is sufficient for our 1024x512 target
URL = "https://www.esa.int/var/esa/storage/images/esa_multimedia/images/2020/12/the_colour_of_the_sky_from_gaia_s_early_data_release_3/22358049-1-eng-GB/The_colour_of_the_sky_from_Gaia_s_Early_Data_Release_3.png"
OUTPUT_DIR = "assets"
OUTPUT_FILENAME = "milkyway.png"
TARGET_WIDTH = 1024
TARGET_HEIGHT = 512
def main():
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
output_path = os.path.join(OUTPUT_DIR, OUTPUT_FILENAME)
temp_path = os.path.join(OUTPUT_DIR, "temp_milkyway.png")
print(f"Downloading Milky Way texture from {URL}...")
# Bypass SSL verification globally
if hasattr(ssl, '_create_unverified_context'):
ssl._create_default_https_context = ssl._create_unverified_context
try:
req = urllib.request.Request(URL, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req) as response, open(temp_path, 'wb') as out_file:
out_file.write(response.read())
except Exception as e:
print(f"Failed to download: {e}")
return
if not os.path.exists(temp_path):
print("Error: Download failed.")
return
print("Processing image...")
with Image.open(temp_path) as img:
# Convert to RGB (remove alpha if present, though this is likely opaque)
img = img.convert("RGB")
# Resize to user requested dimensions
print(f"Resizing to {TARGET_WIDTH}x{TARGET_HEIGHT}...")
img = img.resize((TARGET_WIDTH, TARGET_HEIGHT), Image.Resampling.LANCZOS)
# Save
print(f"Saving to {output_path}...")
img.save(output_path, "PNG")
# Cleanup
if os.path.exists(temp_path):
os.remove(temp_path)
print("Done!")
if __name__ == "__main__":
main()

View File

@@ -3,11 +3,13 @@ material {
parameters : [
{
type : float3,
name : sunDirection
name : sunDirection,
precision : high
},
{
type : float3,
name : sunDirection2
name : sunDirection2,
precision : high
},
{
type : float3,
@@ -93,10 +95,36 @@ material {
type : float4,
name : starControl, // x=Density, y=Enabled, z=Frequency, w=PixelScale
precision : high
},
{
type : sampler2d,
name : moonTexture
},
{
type : sampler2d,
name : moonNormal
},
{
type : sampler2d,
name : milkyWayTexture
},
{
type : float3,
name : milkyWayControl, // x=Intensity, y=Saturation, z=Unused
precision : high
},
{
type : mat3,
name : milkyWayRotation,
precision : high
}
],
variables : [
eyeDirection
{
name : "eyeDirection",
precision : "high"
}
],
vertexDomain : device,
depthWrite : false,
@@ -168,8 +196,8 @@ fragment {
// Rayleigh Phase Function: Scattering distribution for small particles (air molecules)
// Lord Rayleigh (1871)
// Normalized to integrate to 4*PI (Boosting brightness by factor PI vs standard 1-normalization)
highp float rayleighPhase(highp float cosTheta) {
const highp float THREE_SIXTEENTH = (3.0 / 16.0);
float rayleighPhase(float cosTheta) {
const float THREE_SIXTEENTH = (3.0 / 16.0);
return THREE_SIXTEENTH * (1.0 + cosTheta * cosTheta);
}
@@ -177,11 +205,11 @@ fragment {
// Henyey & Greenstein (1941)
// Controls the forward scattering peak (sun halo) via anisotropy parameter 'g'
// Optimized: params.x = (1 + g^2), params.y = (-2 * g)
highp float hgPhase(highp float cosTheta, highp vec2 params) {
const highp float ONE_FOURTH = (1.0 / 4.0);
float hgPhase(float cosTheta, vec2 params) {
const float ONE_FOURTH = (1.0 / 4.0);
// Recover (1 - g^2) => 2.0 - (1 + g^2)
highp float oneMinusG2 = 2.0 - params.x;
highp float inverse = 1.0 / pow(params.x + params.y * cosTheta, 1.5);
float oneMinusG2 = 2.0 - params.x;
float inverse = 1.0 / pow(params.x + params.y * cosTheta, 1.5);
return ONE_FOURTH * (oneMinusG2 * inverse);
}
@@ -192,15 +220,38 @@ fragment {
return fract((p3.x + p3.y) * p3.z);
}
// Safe mod for negative values
// Why 289?
// 1. Historical: 17*17 = 289. Used in identifying permutations in standard GLSL noise (Ashima/stegu).
// 2. Precision: Small enough to prevent float precision loss during internal calculations (e.g. squaring).
// 3. Quality: Large enough to hide repetition and coprime to standard powers-of-two (like 256), avoiding grid resonance.
highp vec3 mod289(highp vec3 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
highp float noise(highp vec3 p) {
// Wrap input to [0, 289] to preserve precision of fract() when p is large.
// This fixes grid artifacts on mobile caused by large world coordinates.
p = mod289(p);
highp vec3 i = floor(p);
highp vec3 f = fract(p);
// Cubic Hermite Interpolation
highp vec3 u = f*f*(3.0-2.0*f);
return mix(mix(mix(hash13(i + vec3(0,0,0)), hash13(i + vec3(1,0,0)), u.x),
mix(hash13(i + vec3(0,1,0)), hash13(i + vec3(1,1,0)), u.x), u.y),
mix(mix(hash13(i + vec3(0,0,1)), hash13(i + vec3(1,0,1)), u.x),
mix(hash13(i + vec3(0,1,1)), hash13(i + vec3(1,1,1)), u.x), u.y), u.z);
// i is already wrapped by p=mod289(p), but mod289(i) is safe (identity for 0..289).
// Actually mod289(p) ensures p is 0..289.
// So floor(p) is 0..288.
// mod289(i) is redundant but harmless.
highp vec3 i0 = i;
highp vec3 i1 = mod289(i + 1.0);
// Wrap neighbors to ensure valid domain [0, 289] and seamless scrolling
// Optimized to use precomputed wrapped indices (2 mods instead of 9)
return mix(mix(mix(hash13(vec3(i0.x, i0.y, i0.z)), hash13(vec3(i1.x, i0.y, i0.z)), u.x),
mix(hash13(vec3(i0.x, i1.y, i0.z)), hash13(vec3(i1.x, i1.y, i0.z)), u.x), u.y),
mix(mix(hash13(vec3(i0.x, i0.y, i1.z)), hash13(vec3(i1.x, i0.y, i1.z)), u.x),
mix(hash13(vec3(i0.x, i1.y, i1.z)), hash13(vec3(i1.x, i1.y, i1.z)), u.x), u.y), u.z);
}
// Fractal Brownian Motion (4 Octaves)
@@ -419,7 +470,7 @@ fragment {
// @param transmittance Atmospheric Transmittance (0..1).
// @return Radiance of the sun disk (if visible and enabled).
// ------------------------------------------------------------------------
highp vec3 getSunDisk(highp vec3 V, highp vec3 L, highp vec4 sunParams,
vec3 getSunDisk(highp vec3 V, highp vec3 L, highp vec4 sunParams,
highp float sunIntensity, highp vec3 transmittance) {
highp float sunCosRadius = sunParams.x;
@@ -477,10 +528,10 @@ fragment {
// @param outDensity Output: Cloud pixel density (0..1) for compositing.
// @return Cloud Radiance (Lit color * attenuation * shading).
// ------------------------------------------------------------------------
highp vec3 getCloudLayer(highp vec3 V, highp vec3 L,
vec3 getCloudLayer(highp vec3 V, highp vec3 L,
highp vec4 control, highp vec4 control2, highp vec4 geometry,
highp float sunIntensity, highp vec3 transmittance,
out highp float outDensity) {
out float outDensity) {
outDensity = 0.0;
highp float cloudCoverage = control.x;
@@ -594,8 +645,8 @@ fragment {
// @param contrast Maximum contrast exponent (at horizon). e.g. 1.5.
// @return Tone mapped color.
// ------------------------------------------------------------------------
highp vec3 applyDynamicToneMapping(highp vec3 color, highp vec3 L, highp float contrast) {
float c = saturate(L.y);
vec3 applyDynamicToneMapping(vec3 color, highp vec3 L, float contrast) {
highp float c = saturate(L.y);
// Exponent blends from 'contrast' (at L.y=0) to 1.0 (at L.y=1)
float exponent = mix(contrast, 1.0, sqrt(c));
return pow(max(vec3(0.0), color), vec3(exponent));
@@ -612,7 +663,7 @@ fragment {
return fract((p4.xxyz + p4.yzzw) * p4.zywx);
}
highp float getStars(highp vec3 V) {
float getStars(highp vec3 V) {
bool enabled = materialParams.starControl.y > 0.5;
if (!enabled) return 0.0;
@@ -669,7 +720,7 @@ fragment {
}
// New helper to handle Star Compositing (Fade, Rotation, Occlusion)
highp vec3 getStarLayer(highp vec3 V, highp vec3 L, highp float cloudDensity, highp vec3 transmittance, highp vec4 starControl) {
vec3 getStarLayer(highp vec3 V, highp vec3 L, float cloudDensity, highp vec3 transmittance, highp vec4 starControl) {
// starControl.x = Density, .y = Enabled
if (starControl.y < 0.5) return vec3(0.0);
@@ -698,6 +749,43 @@ fragment {
return vec3(starVal) * transmittance * starFade * cloudOcclusion * STAR_CLOUD_OCCLUSION;
}
// ------------------------------------------------------------------------
// Milky Way
// ------------------------------------------------------------------------
// Renders the Milky Way background from an equirectangular texture.
//
// @param V Normalized View Vector.
// @param rotation Rotation matrix (Galactic -> World).
// @return Milky Way Color.
// ------------------------------------------------------------------------
vec3 getMilkyWay(highp vec3 V, highp mat3 rotation, sampler2D tex, highp vec3 control) {
highp float intensity = control.x;
if (intensity <= 0.0) return vec3(0.0);
// Rotate V into Galactic coordinates
highp vec3 Vg = rotation * V;
// Equirectangular mapping
// u = atan(z, x) / 2pi + 0.5
// v = asin(y) / pi + 0.5
highp float u = atan(Vg.z, Vg.x) * (0.1591549) + 0.5; // 1/(2*PI)
highp float v = asin(clamp(Vg.y, -1.0, 1.0)) * (0.3183098) + 0.5; // 1/PI
// Sample
highp vec3 color = texture(tex, vec2(u, v)).rgb;
// Black Point (Remove baked-in haze)
highp float blackPoint = control.z;
color = max(vec3(0.0), color - blackPoint);
// Saturation
highp float saturation = control.y;
highp float gray = dot(color, vec3(0.299, 0.587, 0.114));
color = mix(vec3(gray), color, saturation);
return color * intensity;
}
// ------------------------------------------------------------------------
// Procedural Moon Disk (Phased)
// ------------------------------------------------------------------------
@@ -714,7 +802,7 @@ fragment {
// ------------------------------------------------------------------------
highp vec3 getMoonDisk(highp vec3 V, highp vec3 L_moon, highp vec3 L_sun,
highp vec4 moonParams, highp float moonIntensity,
highp vec3 transmittance) {
highp vec3 transmittance, sampler2D moonTex, sampler2D moonNormal) {
highp float moonCosRadius = moonParams.x;
highp float moonSinRadius = moonParams.y; // Used for precision
@@ -758,8 +846,6 @@ fragment {
// the normal component along L_moon is negative.
highp vec3 N_sphere = N_perp - L_moon * sqrt(max(0.0, 1.0 - nPerpSq));
highp float NdotL = max(0.0, dot(N_sphere, L_sun));
// Earthshine (Ambient)
// Earth Phase (approx):
// When Sun & Moon are same direction (dot=1), Earth is Full (from Moon).
@@ -767,20 +853,77 @@ fragment {
// EarthPhase ~ 0.5 * (1.0 + dot(L_sun, L_moon)).
highp float earthPhase = 0.5 * (1.0 + dot(L_sun, L_moon));
// Intensity relative to Sun ~ 0.0001 to 0.01 depending on albedo/size.
// We use 0.002 as a base scale.
// Visibility: The dark side faces Earth?
// Yes, dot(N_sphere, -L_moon) > 0 covers the face seen from Earth.
// But simpler: Is this pixel in shadow (NdotL == 0)?
// Earthshine adds ambient to the whole sphere, but is overwhelmed by Sun.
highp float earthshine = MOON_EARTHSHINE * earthPhase * clamp(dot(N_sphere, -L_moon) + 1.0, 0.0, 1.0); // Hemisphere ambient
// Texture Mapping
// The moon texture is an orthographic projection of the Near Side.
// We align the texture using a stable "Up" vector (World Up projected onto the Moon plane).
return ((NdotL + earthshine) * moonIntensity * moonDiskIntensity * moonProfile) * transmittance;
highp vec3 Up = vec3(0.0, 1.0, 0.0);
if (abs(L_moon.y) > 0.99) Up = vec3(0.0, 0.0, 1.0);
highp vec3 Right = normalize(cross(Up, L_moon));
highp vec3 MoonUp = cross(L_moon, Right);
// Project N_sphere onto the Right/MoonUp plane to get UVs.
highp float u = dot(N_sphere, Right) * 0.5 + 0.5;
highp float v = dot(N_sphere, MoonUp) * 0.5 + 0.5;
// Sample Texture
// We assume the texture is stored such that (0,0) is bottom-left.
// Sample Albedo
// Flip V here as well. Flip U to fix horizontal mirroring.
highp vec3 moonColorSample = texture(moonTex, vec2(1.0 - u, 1.0 - v)).rgb;
// The original code used a float 'moonAlbedo' derived from .rrr.
// To support "3 channels" properly if it had color, we use the full RGB sample.
highp vec3 moonAlbedo = moonColorSample;
// Normal Mapping
// Convert Tangent Space Normal (from texture) to World Space
// Tangent Basis: T=Right, B=MoonUp, N=N_sphere
// We need to invert V because texturing coordinates are usually bottom-up
// but our procedural disk generation (and the original texture) might be top-down oriented
// relative to the sphere mapping we did.
highp vec3 N_map = texture(moonNormal, vec2(1.0 - u, 1.0 - v)).rgb * 2.0 - 1.0;
// Scale down the normal map strength (it was too strong)
N_map.xy *= 0.2;
// Important: Since we flipped U (1.0 - u), we effectively mirrored the texture.
// A slope that was "right" is now "left" visually, but the normal map data still points "right".
// We must invert the X component of the normal to match the mirrored visual geometry.
N_map.x *= -1.0;
// Perturb N_sphere
// N_final = N_map.x * T + N_map.y * B + N_map.z * N
// Note: N_map.z is the component along the surface normal.
N_sphere = normalize(N_map.x * Right + N_map.y * MoonUp + N_map.z * N_sphere);
highp float NdotL = max(0.0, dot(N_sphere, L_sun));
highp vec3 litColor = moonIntensity * transmittance * NdotL * moonAlbedo;
highp vec3 unlitColor = moonIntensity * transmittance * MOON_EARTHSHINE * earthPhase * moonAlbedo;
// Smooth transition at terminator to avoid hard lines
return mix(unlitColor, litColor, smoothstep(0.0, 0.1, NdotL)) * moonDiskIntensity * moonProfile;
}
return vec3(0.0);
}
// ------------------------------------------------------------------------
// Phase Factor (Lambertian Sphere)
// ------------------------------------------------------------------------
// Calculates the integrated flux factor for a lit sphere based on phase angle.
// Normalized to 1.0 at Full Moon (Phase=0) and 0.0 at New Moon (Phase=PI).
//
// @param L_sun Normalized vector to Sun
// @param L_moon Normalized vector to Moon
// @return Phase factor [0.0, 1.0]
// ------------------------------------------------------------------------
highp float getPhaseFactor(highp vec3 L_sun, highp vec3 L_moon) {
highp float cosAlpha = -dot(L_sun, L_moon);
highp float alpha = acos(clamp(cosAlpha, -1.0, 1.0));
return (1.0 / PI) * (sin(alpha) + (PI - alpha) * cosAlpha);
}
// ------------------------------------------------------------------------
// Procedural Water Surface
// ------------------------------------------------------------------------
@@ -817,7 +960,8 @@ fragment {
highp vec4 multiScatParams, highp vec2 miePhaseParams,
highp vec4 sunHalo, highp vec4 cloudControl, highp vec4 cloudControl2,
highp vec4 shimmerControl, highp vec4 waterControl,
highp vec3 L2, highp float sunIntensity2, highp vec4 sunHalo2) {
highp vec3 L2, highp float sunIntensity2, highp vec4 sunHalo2,
sampler2D moonTex, sampler2D moonNormal) {
// Project to plane y=0
highp float t = WATER_PLANE_HEIGHT / min(V.y, -0.0002); // Reduced clamp to minimize "wall" artifact
@@ -851,14 +995,14 @@ fragment {
} else {
// Finite Difference (Standard, 3 taps)
// More expensive but analytically correct in world space (independent of view resolution/derivatives)
float eps = 0.02; // Epsilon for gradient
vec3 p = vec3(uv, time * 0.1 * speed);
float hx = fbm(p + vec3(eps, 0.0, 0.0), octaves);
float hy = fbm(p + vec3(0.0, eps, 0.0), octaves);
highp float eps = 0.02; // Epsilon for gradient
highp vec3 p = vec3(uv, time * 0.1 * speed);
highp float hx = fbm(p + vec3(eps, 0.0, 0.0), octaves);
highp float hy = fbm(p + vec3(0.0, eps, 0.0), octaves);
// Gradient
float dx = (hx - h) / eps;
float dy = (hy - h) / eps;
highp float dx = (hx - h) / eps;
highp float dy = (hy - h) / eps;
// Construct World Space Perturbation
// Gradient (dx, dy) acts on XZ plane.
@@ -893,10 +1037,11 @@ fragment {
// Add Moon Scattering to Reflection
if (sunHalo2.w > 0.5) {
reflection += getSecondarySunScattering(R, L2, sunIntensity2,
depthR, depthM, ozone,
miePhaseParams,
outTransmittance);
float phaseFactor = getPhaseFactor(L, L2);
reflection += getSecondarySunScattering(R, L2, sunIntensity2 * phaseFactor,
depthR, depthM, ozone,
miePhaseParams,
outTransmittance);
}
// Clouds in reflection
@@ -937,7 +1082,7 @@ fragment {
// Add Moon Disk to reflection
if (sunHalo2.w > 0.5) {
reflection += getMoonDisk(R, L2, L, sunHalo2, sunIntensity2, outTransmittance);
reflection += getMoonDisk(R, L2, L, sunHalo2, sunIntensity2, outTransmittance, moonTex, moonNormal);
}
// Apply clouds to reflection
@@ -989,14 +1134,15 @@ fragment {
}
highp float sunIntensityLit = materialParams.sunIntensity * safeEclipseFactor;
// 1. Atmosphere Scattering
// 2. Atmospheric Scattering
highp vec3 color = getAtmosphere(V, L, sunIntensityLit, materialParams.depthR, materialParams.depthM, materialParams.ozone,
materialParams.multiScatParams, materialParams.miePhaseParams, transmittance);
// 2. Secondary Sun/Moon Scattering
// 3. Secondary Scattering (Moon)
if (materialParams.sunHalo2.w > 0.5) {
float phaseFactor = getPhaseFactor(L, L2);
inScatter2 = getSecondarySunScattering(V, L2,
materialParams.sunIntensity2,
materialParams.sunIntensity2 * phaseFactor,
materialParams.depthR,
materialParams.depthM,
materialParams.ozone,
@@ -1005,12 +1151,12 @@ fragment {
color += inScatter2;
}
// 3. Clouds
// 4. Clouds
highp float cloudDensityVal;
highp vec3 clouds = getCloudLayer(V, L, materialParams.cloudControl, materialParams.cloudControl2, materialParams.shimmerControl,
sunIntensityLit, transmittance, cloudDensityVal);
// 4. Sun Disk (Direct)
// 5. Sun Disk (Direct)
// Use ORIGINAL sunIntensity (Un-dimmed) so the disk looks bright
// We mask it by Moon Occlusion AND Cloud Occlusion
highp vec3 sunDisk = getSunDisk(V, L, materialParams.sunHalo, materialParams.sunIntensity, transmittance);
@@ -1028,33 +1174,54 @@ fragment {
// Apply Masks
// Note: moonOcclusion is 1.0 if occluded. So we want (1.0 - moonOcclusion).
// Since we logic-ed it as: if dist < rad, occlusion = 1.0. Correct.
sunDisk *= (1.0 - moonOcclusion) * sunAccess;
color += sunDisk;
// 5. Moon Disk
// 6. Moon Disk
if (materialParams.sunHalo2.w > 0.5) {
color += getMoonDisk(V, L2, L, materialParams.sunHalo2,
materialParams.sunIntensity2, transmittance) * sunAccess;
materialParams.sunIntensity2, transmittance, materialParams_moonTexture, materialParams_moonNormal) * sunAccess;
}
// 6. Stars
// 7. Stars
// Add stars before clouds (clouds cover stars)
color += getStarLayer(V, L, cloudDensityVal, transmittance, materialParams.starControl) * (1.0 - moonOcclusion);
highp vec3 starColor = getStarLayer(V, L, cloudDensityVal, transmittance, materialParams.starControl);
// 7b. Milky Way
// Add Milky Way behind stars (conceptually) but handled similarly
// We fade it by Sun Elevation just like stars
// Re-use starFade logic implicity or calculate it?
// Let's use the starControl.y (enabled) check inside getMilkyWay if needed, or just assume it's always on if intensity > 0.
// We reuse the fade from stars for consistency:
// STAR_FADE_SUN_ELV_HIGH...
// We recalculate fade:
highp float mwFade = 1.0 - smoothstep(STAR_FADE_SUN_ELV_LOW, STAR_FADE_SUN_ELV_HIGH, L.y);
mwFade *= mwFade;
highp vec3 milkyWay = vec3(0.0);
if (mwFade > 0.0) {
milkyWay = getMilkyWay(V, materialParams.milkyWayRotation, materialParams_milkyWayTexture, materialParams.milkyWayControl);
milkyWay *= mwFade * transmittance * (1.0 - moonOcclusion);
// Apply cloud occlusion
milkyWay *= (1.0 - smoothstep(0.0, 1.0, pow(cloudDensityVal, 0.1)));
}
// 7. Composite Clouds
color += (starColor + milkyWay) * (1.0 - moonOcclusion);
// 8. Composite Clouds
// Clouds are alpha blended on top of the sky (Atmos + Stars + SunDisk)
// The cloud color `clouds` includes its own ambient and forward scattering terms.
color = mix(color, clouds, cloudDensityVal);
// 8. Night Sky Offset
// 9. Night Sky Offset
color += materialParams.nightColor;
// 9. Dynamic Tone Mapping
// 10. Dynamic Tone Mapping
color = applyDynamicToneMapping(color, L, materialParams.contrast);
// 10. Water Surface (Reflection)
// 11. Water Surface (Reflection)
if (V.y < 0.0) {
color = getWaterColor(V, L, sunIntensityLit, materialParams.sunIntensity,
materialParams.depthR, materialParams.depthM,
@@ -1064,7 +1231,8 @@ fragment {
materialParams.cloudControl, materialParams.cloudControl2,
materialParams.shimmerControl,
materialParams.waterControl,
L2, materialParams.sunIntensity2, materialParams.sunHalo2);
L2, materialParams.sunIntensity2, materialParams.sunHalo2,
materialParams_moonTexture, materialParams_moonNormal);
color = applyDynamicToneMapping(color, L, materialParams.contrast);
}

View File

@@ -1,10 +1,10 @@
# Result: /Users/mathias/sources/git/filament/out/cmake-release/tools/matc/matc
MATC="../../../../out/cmake-release/tools/matc/matc"
MATC="../../../../../out/cmake-release/tools/matc/matc"
# Navigate to script directory to ensure relative paths work
cd "$(dirname "$0")"
set -e
$MATC -a opengl -p mobile -o assets/simulated_skybox.filamat simulated_skybox.mat
$MATC -a opengl -p mobile -o ../assets/simulated_skybox.filamat ../simulated_skybox.mat
echo "Material recompiled to assets/simulated_skybox.filamat"

View File

@@ -0,0 +1,21 @@
#!/bin/bash
set -e
# Default size
SIZE=${1:-256}
OUTPUT_COLOR="../assets/moon_disk.png"
OUTPUT_NORMAL="../assets/moon_normal.png"
# Navigate to script directory
cd "$(dirname "$0")"
# Check dependencies
if ! python3 -c "import numpy, PIL" 2>/dev/null; then
echo "Installing dependencies (numpy, Pillow)..."
pip3 install numpy Pillow
fi
echo "Generating Moon Asset (Size: ${SIZE}x${SIZE})..."
python3 process_moon.py --size $SIZE --supersample 4 --blur 1.0 --output-color $OUTPUT_COLOR --output-normal $OUTPUT_NORMAL
echo "Generated $OUTPUT_COLOR and $OUTPUT_NORMAL"

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

View File

@@ -0,0 +1,249 @@
import argparse
import urllib.request
import os
import sys
import math
import ssl
from PIL import Image
# Ensure numpy is available
try:
import numpy as np
except ImportError:
print("Error: numpy is required. Please install it with: pip install numpy")
sys.exit(1)
# Default URLs
COLOR_URL = "https://svs.gsfc.nasa.gov/vis/a000000/a004700/a004720/lroc_color_2k.jpg"
DISP_URL = "https://svs.gsfc.nasa.gov/vis/a000000/a004700/a004720/ldem_4.tif"
SRC_COLOR_FILENAME = "lroc_color_2k.jpg"
SRC_DISP_FILENAME = "ldem_4.tif"
def download_file(url, filename):
if os.path.exists(filename):
print(f"File {filename} already exists. Skipping download.")
return
print(f"Downloading {url} to {filename}...")
# Create unverified context to avoid potential SSL cert issues
context = ssl._create_unverified_context()
try:
with urllib.request.urlopen(url, context=context) as response, open(filename, 'wb') as out_file:
data = response.read()
out_file.write(data)
print(f"Download of {filename} complete.")
except Exception as e:
print(f"Error downloading file {filename}: {e}")
# Don't exit hard if it's just one file, maybe?
# But for this script, we likely need it.
sys.exit(1)
def equirectangular_to_orthographic(src_img, size, mode=None):
"""
Reprojects an equirectangular image to an orthographic projection (sphere view).
src_img: PIL Image (Equirectangular)
size: Output size (width, height) - usually square
"""
print(f"Reprojecting to {size}x{size} Orthographic Disk...")
width, height = size, size
src_w, src_h = src_img.size
# Create coordinate grid centered at 0,0 (-1 to 1)
y, x = np.mgrid[size/2:-size/2:-1, -size/2:size/2] # Note: y goes high to low
# Normalize to -1..1
x = x / (size / 2)
y = y / (size / 2)
# Mask for points outside the circle
r2 = x*x + y*y
mask = r2 <= 1.0
# Calculate sphere coordinates (z > 0 for front face)
z = np.zeros_like(r2)
z[mask] = np.sqrt(1.0 - r2[mask])
# Vector P = (x, y, z) on unit sphere
# Lat = asin(y)
# Lon = atan2(x, z)
# Apply mask to avoid invalid calculations
lat = np.arcsin(y * mask)
lon = np.arctan2(x * mask, z * mask)
# Map to UV [0, 1]
u = (lon / (2 * math.pi)) + 0.5
v = (lat / math.pi) + 0.5
# Map to Source Pixels
u = np.clip(u, 0, 1)
v = np.clip(v, 0, 1)
src_x = (u * (src_w - 1)).astype(np.int32)
src_y = ((1.0 - v) * (src_h - 1)).astype(np.int32) # Flip V for image coords
# Sample pixels
src_array = np.array(src_img)
# Handle dimensions
if len(src_array.shape) == 2:
# Grayscale / Single channel
out_channels = 1
src_array = src_array[:, :, np.newaxis] # Expand for consistent indexing
else:
out_channels = src_array.shape[2]
out_array = np.zeros((height, width, out_channels), dtype=src_array.dtype)
# Advanced indexing
valid_y, valid_x = np.where(mask)
# Extract coordinates for valid pixels
sx = src_x[valid_y, valid_x]
sy = src_y[valid_y, valid_x]
out_array[valid_y, valid_x] = src_array[sy, sx]
# Squeeze if single channel
if out_channels == 1:
out_array = out_array.squeeze(axis=2)
return Image.fromarray(out_array, mode or src_img.mode)
def compute_normal_map(height_img, scale=1.0, blur_radius=0.0):
print("Computing Normal Map from Height Map...")
# Convert to float array
h = np.array(height_img).astype(np.float32)
# Apply Blur if requested
if blur_radius > 0:
try:
from scipy.ndimage import gaussian_filter
print(f"Applying Gaussian Blur (Radius: {blur_radius})...")
h = gaussian_filter(h, sigma=blur_radius)
except ImportError:
print("Warning: scipy not found. Skipping Gaussian Blur.")
# Normalize height to 0..1 for consistent gradient scale regardless of input depth
h_min, h_max = h.min(), h.max()
print(f"Height Map Range: {h_min} to {h_max}")
if h_max > h_min:
h_norm = (h - h_min) / (h_max - h_min)
else:
h_norm = h
# Gradients
dy, dx = np.gradient(h_norm)
# Pre-emphasis scale
bump_scale = scale
# Normal vector components
# Map is Top-Down Y.
nx = -dx * bump_scale
ny = -dy * bump_scale
nz = np.ones_like(nx)
# Mask out normals where r > 0.96 (avoid edge cliff artifacts)
rows, cols = h.shape
y, x = np.ogrid[:rows, :cols]
center_y, center_x = rows/2.0, cols/2.0
# max radius is size/2
radius_sq = (min(rows, cols) / 2.0)**2
dist_sq = (x - center_x)**2 + (y - center_y)**2
mask = dist_sq < (radius_sq * 0.96 * 0.96)
nx[~mask] = 0
ny[~mask] = 0
nz[~mask] = 1
# Normalize
len_n = np.sqrt(nx*nx + ny*ny + nz*nz)
# Avoid divide by zero
len_n[len_n == 0] = 1.0
nx /= len_n
ny /= len_n
nz /= len_n
# Pack to 0..255
# [-1, 1] -> [0, 255]
out_x = ((nx + 1.0) * 0.5 * 255.0).astype(np.uint8)
out_y = ((ny + 1.0) * 0.5 * 255.0).astype(np.uint8)
out_z = ((nz + 1.0) * 0.5 * 255.0).astype(np.uint8)
out_rgb = np.dstack((out_x, out_y, out_z))
return Image.fromarray(out_rgb, 'RGB')
def main():
parser = argparse.ArgumentParser(description='Process Moon Texture')
parser.add_argument('--size', type=int, default=256, help='Output resolution (square)')
parser.add_argument('--supersample', type=int, default=2, help='Internal processing resolution multiplier')
parser.add_argument('--blur', type=float, default=1.0, help='Gaussian blur radius for height map')
parser.add_argument('--bump-scale', type=float, default=60.0, help='Normal map bump scale')
parser.add_argument('--output-color', type=str, default='assets/moon_disk.png', help='Output color filename')
parser.add_argument('--output-normal', type=str, default='assets/moon_normal.png', help='Output normal filename')
parser.add_argument('--skip-download', action='store_true', help='Skip downloading files')
args = parser.parse_args()
internal_size = args.size * args.supersample
# Ensure assets dir exists
os.makedirs(os.path.dirname(args.output_color) or '.', exist_ok=True)
# 1. Download
if not args.skip_download:
download_file(COLOR_URL, SRC_COLOR_FILENAME)
download_file(DISP_URL, SRC_DISP_FILENAME)
# 2. Process Color
print(f"Processing Color Map (Internal Size: {internal_size}x{internal_size})...")
try:
img_color = Image.open(SRC_COLOR_FILENAME).convert('RGB')
out_color = equirectangular_to_orthographic(img_color, internal_size)
if args.supersample > 1:
print(f"Downsampling Color to {args.size}x{args.size}...")
out_color = out_color.resize((args.size, args.size), Image.LANCZOS)
out_color.save(args.output_color)
print(f"Saved {args.output_color}")
except Exception as e:
print(f"Error processing color: {e}")
# 3. Process Normal
print(f"Processing Displacement Map (Internal Size: {internal_size}x{internal_size})...")
try:
from PIL import ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True
# Check if file exists
if not os.path.exists(SRC_DISP_FILENAME):
print(f"Displacement map {SRC_DISP_FILENAME} not found!")
return
img_disp = Image.open(SRC_DISP_FILENAME)
out_disp = equirectangular_to_orthographic(img_disp, internal_size)
# Compute Normals
out_normal = compute_normal_map(out_disp, scale=args.bump_scale, blur_radius=args.blur)
if args.supersample > 1:
print(f"Downsampling Normal to {args.size}x{args.size}...")
out_normal = out_normal.resize((args.size, args.size), Image.LANCZOS)
out_normal.save(args.output_normal)
print(f"Saved {args.output_normal}")
except Exception as e:
print(f"Error processing normal: {e}")
import traceback
traceback.print_exc()
print("Done.")
if __name__ == "__main__":
main()