diff --git a/README.md b/README.md index fd37277d..2a549de9 100644 --- a/README.md +++ b/README.md @@ -113,3 +113,13 @@ Sol is not for navigation, occultation prediction, mission safety or operational space-weather warnings. `space_weather_operational` remains false. Project code is MIT OR Apache-2.0; third-party data attribution and outstanding coefficient notice correspondence are documented separately and are not resolved by that project license. + +### Optional Enhanced Earth + +Solar System → View controls → Earth layers → **Enhanced Earth** adds darker +offshore oceans and an elevated historical NASA cloud layer with Sun-directed +shadows. Clouds drift independently during Play time and freeze when paused. +The option is off by default; cloud height, motion and lighting are illustrative. +MODIS swaths and the scientific sea-ice palette suspend the enhancement. +See [RFC 0008](docs/rfcs/0008-enhanced-earth.md) and the +[validation record](docs/validation/enhanced-earth/README.md). diff --git a/apps/web/index.html b/apps/web/index.html index 5f757864..bd797410 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -25,6 +25,8 @@ + + @@ -455,6 +457,8 @@

Tools

Earth layers
+ +

Deeper oceans and raised clouds with Sun-aligned shadows. Clouds drift independently during Play time. Color, nominal 8 km cloud height and motion are illustrative; the NASA imagery keeps its historical date. Uses the Blue Marble cloud layer; suspended for MODIS swaths and the sea-ice analysis palette.

diff --git a/apps/web/js/atmosphereColumnField.js b/apps/web/js/atmosphereColumnField.js index f957c67a..7925e9fa 100644 --- a/apps/web/js/atmosphereColumnField.js +++ b/apps/web/js/atmosphereColumnField.js @@ -213,16 +213,16 @@ vec3 atmosphereCombinedTail(float impact,float x){ // Rationalize altitude before lookup: subtracting a rounded body-sized radius // quantizes a short interval's height increment and corrupts its column mass. float radius=length(vec2(impact,x)),height=((impact-u_atmosphereRadiusKm)*(impact+u_atmosphereRadiusKm)+x*x)/(radius+u_atmosphereRadiusKm); - vec3 packed; + vec3 columnValues; if(height<0.0){ float ground=sqrt(max(0.0,u_atmosphereRadiusKm*u_atmosphereRadiusKm-impact*impact)); float below=max(0.0,ground-x); - packed=atmosphereOutwardPacked(0.0,ground/u_atmosphereRadiusKm)+vec3(below,below,below*atmosphereOzoneDensity(0.0)); - }else packed=atmosphereOutwardPacked(height,x/max(radius,1e-9)); + columnValues=atmosphereOutwardPacked(0.0,ground/u_atmosphereRadiusKm)+vec3(below,below,below*atmosphereOzoneDensity(0.0)); + }else columnValues=atmosphereOutwardPacked(height,x/max(radius,1e-9)); // A uniform scale, not a literal zero, keeps the admitted sampler from being // constant-folded out of the generator. The runtime value stays 0. - packed.xy+=texelFetch(u_atmosphereColumnField,ivec2(0),0).rg*u_atmosphereColumnKeep; - return packed; + columnValues.xy+=texelFetch(u_atmosphereColumnField,ivec2(0),0).rg*u_atmosphereColumnKeep; + return columnValues; } vec2 atmosphereOutwardColumns(float height,float mu){return atmosphereOutwardPacked(height,mu).rg;} float atmosphereOzoneOutward(float height,float mu){return atmosphereOutwardPacked(height,mu).b;} diff --git a/apps/web/js/atmosphereColumnManifest.js b/apps/web/js/atmosphereColumnManifest.js index 12d8bcce..ee7e6dde 100644 --- a/apps/web/js/atmosphereColumnManifest.js +++ b/apps/web/js/atmosphereColumnManifest.js @@ -13,7 +13,7 @@ export const ATMOSPHERE_COLUMN_FIELDS=Object.freeze({ "profile_encoding": "atmosphere-profile-binary32-v1", "profile_sha256": "e15e53579417f828a4f02b401d2c9ddebd78c6f956f5177d1def336ac7d7bed8", "generator_source_sha256": "6c7c90d4a4b72fc4d9c38bdb191889780e2f3a2cf44375cfe00f87c82c2fb48c", - "field_source_sha256": "26f9171d8db660af0bbe46b7631ec89b86895e143a181aaa13c2d6afce2115f1", + "field_source_sha256": "7da48dcd0061e244de4365de8e8ba1acbce7d27a79a42cbebcda6784ae05ed44", "solver_source_sha256": "282d74036c31b418ca8e1e43f9fdb4f975cf3ec03e3217da801d54ec945aa635", "profile_source_sha256": "b8ad270b14c4ef16affc7c31bd7fb5111895bc9cc6332cbe7c8c171d4582b965" }, @@ -30,7 +30,7 @@ export const ATMOSPHERE_COLUMN_FIELDS=Object.freeze({ "profile_encoding": "atmosphere-profile-binary32-v1", "profile_sha256": "28e25e74130b1a426f07004ebdc66ca6c8f1094433f305302dab4c7338b15d7e", "generator_source_sha256": "6c7c90d4a4b72fc4d9c38bdb191889780e2f3a2cf44375cfe00f87c82c2fb48c", - "field_source_sha256": "26f9171d8db660af0bbe46b7631ec89b86895e143a181aaa13c2d6afce2115f1", + "field_source_sha256": "7da48dcd0061e244de4365de8e8ba1acbce7d27a79a42cbebcda6784ae05ed44", "solver_source_sha256": "282d74036c31b418ca8e1e43f9fdb4f975cf3ec03e3217da801d54ec945aa635", "profile_source_sha256": "b8ad270b14c4ef16affc7c31bd7fb5111895bc9cc6332cbe7c8c171d4582b965" } diff --git a/apps/web/js/earthOceanMask.js b/apps/web/js/earthOceanMask.js new file mode 100644 index 00000000..bc7d25fb --- /dev/null +++ b/apps/web/js/earthOceanMask.js @@ -0,0 +1,6 @@ +// GENERATED by tools/prepare_earth_ocean_mask.py. Public-domain Natural Earth. +// Conservative offshore display mask, not a qualified coastline or reflectance map. +export const OCEAN_MASK_WIDTH=512,OCEAN_MASK_HEIGHT=256; +export const OCEAN_MASK_SOURCES={"ne_110m_land.geojson":"9e0729ee253ca7d7a5c4ae9395fb1902264c5377c52e224d13dd85010e2835d9","ne_110m_glaciated_areas.geojson":"e61e3f45e22f087f861ac2fda6258fc15c849a216d74c27ccf0164eeafbd950a"}; +export const OCEAN_MASK_SHA256='0644b40d075dc4da2ed10928a6dbc40f2924ceaff9a300948b2f2c8781ca2bdf'; +export const OCEAN_MASK_RUNS=[0,3782,24,422,38,17,41,408,104,404,120,150,5,234,123,42,11,28,9,55,13,231,123,31,25,25,9,55,15,216,7,6,123,30,26,25,9,55,20,211,136,30,26,25,9,55,23,197,8,3,131,35,26,25,9,55,23,186,19,3,128,38,26,90,24,182,152,38,22,50,13,34,30,173,152,40,20,44,19,27,38,29,15,128,67,5,80,43,8,1,8,41,22,22,43,29,23,117,70,6,79,44,6,50,23,22,43,29,23,117,71,7,75,101,24,14,58,2,10,10,23,116,72,22,60,99,20,3,13,4,72,8,23,115,76,21,57,99,16,8,89,9,22,40,5,70,81,17,55,100,13,9,123,36,8,21,15,34,84,15,54,60,13,27,11,10,136,24,8,18,32,12,95,13,52,55,19,27,11,10,136,11,7,6,8,16,142,12,52,53,27,21,11,1,177,12,146,13,51,50,35,16,191,10,149,10,51,48,40,2,7,1,196,8,150,9,49,49,42,1,210,2,125,2,26,7,45,9,6,36,381,2,26,7,42,1,17,34,383,1,27,7,35,8,18,32,384,1,27,7,33,10,18,31,413,8,30,12,18,29,414,9,26,16,18,26,250,2,12,1,149,14,21,21,16,24,251,6,9,2,148,14,20,23,14,24,252,10,5,2,111,1,36,15,19,26,6,29,252,15,113,6,31,16,16,63,251,16,110,9,32,16,14,64,248,19,109,18,25,16,13,64,244,23,110,17,26,19,9,49,7,8,241,26,31,2,78,16,27,20,6,50,9,6,240,35,20,11,77,11,27,76,9,7,228,46,17,16,77,9,29,74,9,7,20,2,175,16,15,44,19,22,77,3,30,73,10,10,16,2,174,17,15,44,19,23,113,65,15,9,190,19,15,44,12,31,112,63,18,8,192,17,15,44,10,33,114,61,19,2,201,13,13,46,6,37,114,61,222,13,13,89,114,61,222,13,12,90,114,61,222,14,10,92,113,61,223,13,9,93,5,1,107,61,223,13,7,101,110,64,217,13,6,102,111,63,217,121,111,63,217,123,95,1,13,65,215,124,108,65,214,127,106,65,214,127,96,2,8,67,212,127,96,4,6,68,214,124,96,68,224,124,94,70,224,124,91,73,58,2,151,1,12,124,89,75,210,2,12,124,82,82,208,4,12,124,82,82,21,4,178,9,9,127,82,82,21,4,177,10,7,129,77,87,19,6,6,1,169,10,8,129,76,88,18,7,176,9,9,130,75,88,17,4,180,6,12,130,74,89,41,1,159,6,12,131,72,90,40,3,158,1,16,133,70,91,39,4,7,2,139,2,24,134,70,91,33,1,5,5,6,2,139,2,24,135,69,92,32,22,134,4,24,136,68,91,37,6,6,10,130,4,24,136,67,92,39,2,10,8,131,3,22,141,62,93,41,1,149,7,14,147,59,94,191,7,10,151,57,96,192,7,6,155,55,96,193,7,6,155,55,95,194,7,5,157,55,92,196,169,42,1,12,91,197,170,33,2,5,6,8,90,197,171,31,15,8,89,198,171,30,16,8,89,198,172,29,17,11,84,199,175,26,17,11,83,200,175,26,15,13,83,200,175,25,15,14,82,113,6,82,110,5,62,23,9,21,81,113,7,81,110,7,61,23,5,25,80,113,8,80,110,8,60,5,1,18,4,27,78,113,9,71,2,5,111,8,67,17,4,34,71,113,9,67,122,8,67,30,8,19,69,112,11,29,3,31,127,6,67,30,9,18,69,111,16,25,3,31,9,7,112,5,67,30,9,18,70,109,18,23,5,30,9,7,112,5,68,29,9,18,69,110,18,21,8,28,10,7,186,32,5,18,69,109,19,20,10,27,10,7,189,31,3,7,2,5,73,108,20,19,11,25,12,7,190,30,90,107,21,17,13,25,12,7,193,27,90,103,25,17,13,25,12,9,194,24,90,102,27,13,17,24,12,10,199,18,12,5,73,100,29,13,21,20,12,11,199,17,10,9,71,101,29,12,21,20,13,11,201,13,8,14,69,101,29,12,21,20,13,11,204,11,7,24,59,101,29,12,22,19,10,14,205,41,59,101,30,12,21,19,9,15,206,41,59,100,30,12,21,19,9,16,206,42,59,98,31,12,20,18,10,16,206,43,58,97,32,12,20,7,2,7,12,16,208,42,59,95,33,11,20,15,11,7,1,10,209,45,55,95,33,11,20,14,11,6,3,10,212,45,52,94,35,10,16,15,13,9,1,10,213,45,52,93,38,7,16,15,11,11,1,10,218,41,53,90,40,6,16,15,11,11,3,8,218,42,53,21,1,67,62,15,10,12,4,6,217,44,54,17,4,66,63,16,6,15,227,45,55,6,14,64,64,16,4,17,8,5,213,46,80,58,66,15,3,32,211,47,80,58,67,14,3,32,211,49,78,57,69,13,3,32,210,53,75,55,72,12,3,40,202,55,73,54,74,12,2,46,196,58,70,52,76,13,1,48,194,63,65,52,77,37,1,27,190,65,63,51,79,12,1,23,1,30,186,67,62,50,80,12,2,22,1,31,5,6,174,70,60,48,82,11,2,22,1,32,1,9,174,70,61,46,84,12,2,20,1,45,171,70,62,45,85,22,2,9,10,37,170,71,61,44,87,18,5,9,12,35,171,70,62,43,88,20,2,9,12,40,166,70,62,43,90,34,7,28,1,11,167,69,63,42,90,34,12,20,5,13,165,68,63,43,91,32,12,22,8,8,165,67,65,43,95,27,12,22,8,8,166,66,65,43,104,18,5,5,6,18,9,7,166,65,66,43,108,12,4,13,3,6,1,9,9,7,167,64,65,44,7,6,96,10,4,14,2,7,3,7,183,63,66,44,7,6,110,14,2,7,194,61,66,45,6,7,104,20,2,9,192,60,67,45,5,9,102,21,2,10,26,5,161,58,68,45,3,11,101,22,2,10,26,5,16,2,143,58,68,45,1,13,100,24,1,10,26,6,11,6,144,57,68,45,1,13,98,38,25,6,11,6,145,56,68,45,1,13,98,38,25,6,9,8,146,55,68,44,2,13,97,39,26,5,9,8,148,52,69,43,3,12,97,41,25,5,9,6,152,50,69,41,5,11,95,46,37,6,153,49,69,40,6,11,92,50,18,5,13,6,154,48,69,39,6,12,90,52,18,6,172,48,70,37,7,12,89,54,17,8,170,47,71,37,7,11,89,56,16,8,170,46,73,36,7,11,89,57,15,8,170,46,73,36,7,11,88,59,15,7,170,45,75,35,7,10,89,60,15,6,169,45,76,35,7,10,89,61,189,41,80,34,8,10,89,61,189,39,83,33,8,9,90,61,189,37,85,31,11,8,90,61,189,36,86,30,110,61,188,37,86,30,110,61,188,37,87,29,111,60,188,37,87,28,112,60,188,37,88,27,113,59,188,36,90,25,114,59,188,35,91,24,116,58,188,34,93,22,117,58,188,33,95,21,117,58,187,33,96,19,119,22,2,33,25,5,158,32,97,18,120,18,9,30,25,6,156,33,97,16,122,16,12,28,26,6,156,32,98,9,129,10,18,27,27,8,153,31,239,7,23,24,27,8,153,28,274,21,28,12,149,28,276,19,29,11,149,28,276,18,31,10,149,27,277,18,31,10,149,26,281,13,30,13,149,21,290,9,29,13,149,22,290,9,28,13,150,21,291,9,27,13,151,20,292,9,25,14,152,20,292,9,23,14,154,20,293,7,23,14,154,19,295,6,24,13,155,18,326,12,156,18,326,11,157,18,326,10,157,19,187,5,134,9,158,19,187,6,300,19,187,6,300,18,188,6,300,16,5,9,176,6,301,15,5,9,176,6,301,14,6,9,483,15,5,9,483,17,3,9,484,17,495,17,496,16,498,14,501,11,4101,6,502,10,499,13,498,14,224,5,41,5,220,17,151,12,40,5,14,10,4,10,18,12,218,14,151,18,34,18,1,72,204,11,150,34,16,97,204,11,133,5,7,43,9,105,193,16,133,56,8,115,184,17,109,8,11,60,2,126,179,18,91,96,1,131,171,23,68,261,121,12,26,24,66,264,118,14,6,5,15,24,64,266,84,6,1,5,22,30,2,33,59,270,84,6,1,17,10,65,59,270,68,46,4,65,58,271,57,126,54,274,58,126,46,281,55,129,41,284,45,138,41,285,41,143,19,11,8,292,39,137,24,13,7,292,39,131,14,5,9,15,7,292,39,133,5,13,4,19,7,292,39,151,4,19,5,294,40,8,1,141,4,314,53,142,1,317,52,466,23,8,15,471,9,23,12,5085]; diff --git a/apps/web/js/enhancedEarth.js b/apps/web/js/enhancedEarth.js new file mode 100644 index 00000000..d1252f4b --- /dev/null +++ b/apps/web/js/enhancedEarth.js @@ -0,0 +1,77 @@ +// Illustrative presentation only. Source images, epochs and ephemerides are immutable. +import {OCEAN_MASK_WIDTH,OCEAN_MASK_HEIGHT,OCEAN_MASK_RUNS} from './earthOceanMask.js'; +import {rotationDisplayStepSeconds} from './orreryTime.js'; + +export const EARTH_CLOUD_HEIGHT_KM=8; +export const ENHANCED_EARTH_UNIFORMS=['u_earthEnhanced','u_earthCloudShadow','u_cloudPhase','u_cloudScale','u_oceanMask']; + +export function enhancedEarthSelected(state){ + return state.earthEnhanced===true&&state.useTextures!==false&&state.earthCloudSource!=='daily'&&state.earthIce!==true&&!state.galaxy; +} + +export function advanceEarthCloudPhase(phase,realSeconds,simulatedSeconds,rotationHours,enabled){ + if(!enabled||!Number.isFinite(realSeconds)||realSeconds<=0)return phase; + const seconds=rotationDisplayStepSeconds(realSeconds,simulatedSeconds,rotationHours); + if(seconds===0)return phase; + const drift=seconds/(Math.abs(rotationHours)*3600)*.03; + if(!Number.isFinite(drift))return phase; + const step=Math.sign(drift)*Math.min(Math.abs(drift),.002*realSeconds); + return ((phase+step)%1+1)%1; +} + +export function oceanMaskPixels(){ + const pixels=new Uint8Array(OCEAN_MASK_WIDTH*OCEAN_MASK_HEIGHT); + let offset=0,value=0; + for(const run of OCEAN_MASK_RUNS){pixels.fill(value,offset,offset+run);offset+=run;value=255-value;} + return pixels; +} + +export function uploadOceanMask(gl){ + const texture=gl.createTexture(); + if(!texture)throw new Error('Ocean mask allocation failed'); + try{ + gl.activeTexture(gl.TEXTURE0+11);gl.bindTexture(gl.TEXTURE_2D,texture); + gl.texImage2D(gl.TEXTURE_2D,0,gl.R8,OCEAN_MASK_WIDTH,OCEAN_MASK_HEIGHT,0,gl.RED,gl.UNSIGNED_BYTE,oceanMaskPixels()); + gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); + if(gl.getError()!==gl.NO_ERROR)throw new Error('Ocean mask upload failed'); + return texture; + }catch(error){gl.deleteTexture(texture);throw error;} + finally{gl.activeTexture(gl.TEXTURE0);} +} + +// Input p is on the unit sphere before oblate scaling. Return a point on the +// homothetic cloud ellipsoid in those same unflattened coordinates. The rational +// root avoids subtractive cancellation for a thin shell and an overhead Sun. +export const EARTH_CLOUD_GEOMETRY_GLSL=` +vec3 earthCloudHit(vec3 p,vec3 light,float oblate,float shell){ + vec3 d=vec3(light.xy,light.z/oblate); + float a=dot(d,d),b=dot(p,d),c=dot(p,p)-shell*shell; + float root=sqrt(max(0.0,b*b-a*c)); + float t=b>=0.0 ? -c/max(root+b,1e-12) : (root-b)/max(a,1e-12); + return p+d*t; +} +`; + +// 0 on the night side, and 1 only after the cloud is actually lit. The 0.05 +// start keeps a dark twilight veil from covering city lights while cloud RGB +// is still the 0.001 night floor. +export const EARTH_CLOUD_COVER_GLSL=` +float earthCloudCover(float sun){ + return smoothstep(0.05, 0.22, max(sun, 0.0)); +} +`; + +// The mask excludes coarse land/ice/coastal margins. Dark-blue gating additionally +// protects bright ice, land-like colors and islands omitted by the coarse vectors. +export const EARTH_OCEAN_GLSL=` +vec3 enhancedOceanColor(vec3 c,float ocean){ + float blue=max(0.0,c.z-max(c.x,c.y)); + float bright=max(c.x,max(c.y,c.z)); + float weight=ocean*min(1.0,blue/.03)*min(1.0,max(0.0,(.25-bright)/.1)); + float luminance=dot(c,vec3(.2126,.7152,.0722)); + return c*(1.0-weight)+(c*.8+vec3(luminance)*.2)*.55*weight; +} +`; diff --git a/apps/web/js/orrery.js b/apps/web/js/orrery.js index a6756a61..02b644bb 100644 --- a/apps/web/js/orrery.js +++ b/apps/web/js/orrery.js @@ -17,6 +17,7 @@ import { store } from "./store.js?v=dcca6290db"; import { createShaderPrograms } from "./shaderPrograms.js"; import { linearFilterReference } from './materialColor.js'; +import {EARTH_CLOUD_HEIGHT_KM,ENHANCED_EARTH_UNIFORMS,enhancedEarthSelected,advanceEarthCloudPhase,uploadOceanMask} from './enhancedEarth.js'; import { createHdrPresentation } from './hdrPresentation.js'; import { srgbToLinear } from './surfaceMapping.js'; import { appearanceReference, appearanceReferences, appearanceUniforms, appearanceFallbackColor, earthLayerDescription, earthCloudRole, surfaceReferenceShown } from "./planetAppearance.js"; @@ -160,6 +161,7 @@ const state = (store.orrery = { galSpeed: 2, // galaxy-view rate (millions of years per real second), decoupled from the planetary rate showOrbits: true, showSky: true, showConst: false, showLabels: true, showSunEq: false, useTextures: true, galaxy: false, earthNight: true, earthWeather: true, earthIce: false, earthCloudSource: 'composite', venusRadar: false, + earthEnhanced:false, earthCloudPhase:0, earthMaskStatus:'deferred', appearanceStatus: {}, terrainEnabled:true, opticsEnabled:true, terrainStatus:{}, terrainRendered:{}, opticsStatus:{}, scatteringStatus:{}, scatteringFrame:null, programStatus:{base:'deferred',physical:'deferred'},programDiagnostics:{}, @@ -194,6 +196,7 @@ const DRAW_LIST = ["Sun", ...PLANET_ORDER, "Moon"]; // live in orreryMath.js; GLSL sources in orreryShaders.js — both imported above.) // ---------------------------------------------------------------- WebGL2 renderer +let earthOceanMask=null; let gl, P = {}, sphere, quadBuf, cel, celBufs = {}, particles = null; let shaderPrograms=null,programContextGeneration=0; let hdrPresentation=null,hdrFailure=null,contextGeneration=0,sceneSerial=0,linearFrame=false,graphicsLifecycle=0; @@ -864,7 +867,7 @@ function finishGL(){ P.glowU = uloc(P.glow, ["u_vp", "u_center", "u_right", "u_up", "u_size", "u_color", "u_pow"]); Object.assign(P.sphereU,uloc(P.sphere,[...ATMOSPHERE_UNIFORMS,...INCIDENT_FIELD_UNIFORMS,'u_atmosphereColumnField','u_atmosphereOzoneField','u_bodyRadiusKm','u_terrainHeight','u_terrainShadowEnabled','u_terrainShape','u_terrainPoles'])); P.solarU=uloc(P.solar,['u_mvp','u_camObj','u_pass','u_extent','u_atlas','u_quiet','u_frameMix','u_phase','u_displayGain','u_coronaGlow','u_cmeProgress','u_cmeAxis','u_sourceBasis0','u_sourceBasis1','u_projection0','u_projection1','u_observerRadii','u_loopNormal[0]','u_loopTangent[0]','u_loopGain[0]']); - Object.assign(P.sphereU,uloc(P.sphere,['u_textureLinear'])); + Object.assign(P.sphereU,uloc(P.sphere,['u_textureLinear',...ENHANCED_EARTH_UNIFORMS])); for(const name of ['sphere','line','ring','pt','glow','solar']) Object.assign(P[`${name}U`],uloc(P[name],['u_linearOutput'])); @@ -1933,6 +1936,55 @@ function updateEarthLayerStatus() { if (caption) caption.textContent = `${ice?.label || 'Sea ice unavailable'} · ${ice?.observation_label || 'Date unavailable'}. Transparent areas have no displayed data.`; } +function prepareEarthEnhancement(body,referenceReady) { + const selected=body==='Earth'&&referenceReady&&enhancedEarthSelected(state); + if(selected&&!earthOceanMask&&state.earthMaskStatus!=='unavailable'){ + try{earthOceanMask=uploadOceanMask(gl);state.earthMaskStatus='ready';} + catch(error){state.earthMaskStatus='unavailable';console.warn('Enhanced Earth:',error.message);} + updateEarthLayerStatus(); + } + const asset=appearanceReference('Earth','cloud-composite'); + const clouds=selected&&state.earthWeather&&referenceTextures[asset.id]?.ready ? referenceTextures[asset.id] : null; + return {selected,clouds,phase:state.earthCloudPhase,scale:1+EARTH_CLOUD_HEIGHT_KM/BODY.Earth.radiusKm}; +} + +function bindEarthEnhancement(enhancement,locations) { + gl.uniform1i(locations.u_earthEnhanced,enhancement.selected&&earthOceanMask?1:0); + gl.uniform1i(locations.u_earthCloudShadow,enhancement.clouds?1:0); + gl.uniform1f(locations.u_cloudPhase,enhancement.phase); + gl.uniform1f(locations.u_cloudScale,enhancement.scale); + gl.activeTexture(gl.TEXTURE0+11);gl.bindTexture(gl.TEXTURE_2D,earthOceanMask||whiteTex); + gl.uniform1i(locations.u_oceanMask,11);gl.activeTexture(gl.TEXTURE0); + // Keep its sampler bound for the shadow, but remove the flattened cloud blend. + if(enhancement.clouds)gl.uniform1i(locations.u_earthWeather,0); +} + +function drawEarthClouds(enhancement,pos,rot,rEq,rPol,vp,eye,light,normals,reference,shell) { + const u=P.sphereU,model=mul(translate(pos),mul(rot,scaleM([rEq*enhancement.scale,rEq*enhancement.scale,rPol*enhancement.scale]))); + gl.useProgram(P.sphere);setAtmosphereUniforms(gl,u,null); + gl.uniform1i(u.u_linearOutput,linearFrame?1:0); + gl.uniformMatrix4fv(u.u_mvp,false,new Float32Array(mul(vp,model))); + gl.uniformMatrix4fv(u.u_model,false,new Float32Array(model)); + gl.uniformMatrix3fv(u.u_nmat,false,new Float32Array(normals)); + gl.uniform3fv(u.u_cam,new Float32Array(eye));gl.uniform3fv(u.u_light,new Float32Array(light)); + gl.uniform1f(u.u_oblate,BODY.Earth.polarKm/BODY.Earth.radiusKm); + gl.uniform1i(u.u_mode,3);gl.uniform1i(u.u_style,-1); + gl.uniform1i(u.u_earthNight,0);gl.uniform1i(u.u_earthIce,0);gl.uniform1i(u.u_earthWeather,0); + gl.uniform1i(u.u_earthEnhanced,0);gl.uniform1i(u.u_earthCloudShadow,0); + gl.uniform1f(u.u_cloudPhase,enhancement.phase);gl.uniform1f(u.u_cloudScale,enhancement.scale); + const uniforms=appearanceUniforms(reference); + gl.uniform4fv(u.u_map,new Float32Array(uniforms.map)); + gl.uniform4fv(u.u_mapLat,new Float32Array(uniforms.lat)); + gl.uniform4fv(u.u_mapWindow,new Float32Array(uniforms.window)); + gl.activeTexture(gl.TEXTURE0+3);gl.bindTexture(gl.TEXTURE_2D,enhancement.clouds.tex);gl.uniform1i(u.u_weatherTex,3); + gl.activeTexture(gl.TEXTURE0); + setHazeUniforms(gl,u,shell?null:getAtmosphereProfile('Earth')); + bindBodyMesh();gl.enable(gl.CULL_FACE);gl.cullFace(gl.BACK);gl.depthMask(false); + gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); + gl.drawElements(gl.TRIANGLES,sphere.count,gl.UNSIGNED_SHORT,0); + gl.depthMask(true);gl.disable(gl.CULL_FACE); +} + function bindEarthTextures(enabled,locations=P.sphereU) { for (const [role, active, flag, sampler, unit] of /** @type {[string,boolean,string,string,number][]} */ ([ ['night-lights', state.earthNight, 'u_earthNight', 'u_nightTex', 2], @@ -2067,6 +2119,8 @@ function drawBody(b, vp, eye) { gl.uniform1i(sphereUniforms.u_mapNoData, uniforms.nodata); } bindEarthTextures(!!referenceTex && b.name === 'Earth',sphereUniforms); + const earthEnhancement=prepareEarthEnhancement(b.name,!!referenceTex); + bindEarthEnhancement(earthEnhancement,sphereUniforms); gl.uniform3fv(sphereUniforms.u_sunA, new Float32Array(sunTexd ? sunDiskBasis() : [1, 0, 0])); // Ring-shadow inputs: the light direction expressed in the BODY frame (Rᵀ·light — rot's // upper 3×3 is orthonormal, column-major), the annulus radii in equatorial-radius units, the @@ -2097,6 +2151,9 @@ function drawBody(b, vp, eye) { gl.drawElements(gl.TRIANGLES,mesh.count,mesh.indexType||gl.UNSIGNED_SHORT,0); gl.disable(gl.CULL_FACE); + const atmosphereShell=!!profile; + if(earthEnhancement.clouds)queueTransparent(pos,eye,()=>drawEarthClouds(earthEnhancement,pos,rot,rEq,rPol,vp,eye,light,normals,reference,atmosphereShell)); + // atmosphere limb halo (additive shell, slightly larger, no depth write) if (atmoStr > 0 && b.name !== "Sun" && b.name !== "Earth" && !profile) { // A restrained illustrative optical limb, not an atmospheric-height measurement. @@ -2291,6 +2348,7 @@ function drawMoons(parentName, parentPos, parentDisplayAU, vp, eye, drawn) { gl.uniform1i(P.sphereU.u_mapNoData, uniforms.nodata); } bindEarthTextures(false); // auxiliary layers must never leak from Earth onto moons + bindEarthEnhancement({selected:false,clouds:null,phase:0,scale:1},P.sphereU); gl.uniform1f(P.sphereU.u_oblate, 1); gl.uniform2fv(P.sphereU.u_ringRad, new Float32Array([0, 0])); // no ring shadow on moons — clear the parent's state // Nor a moon shadow ON a moon: mutual Galilean events are real but they need each moon's @@ -2676,6 +2734,10 @@ function tick(now) { finishPositionUpdate(); stepParticles(dt); updateRotationDisplay(dt); + const cloudAsset=appearanceReference('Earth','cloud-composite'); + const drift=enhancedEarthSelected(state)&&state.earthWeather&&!prefersReducedMotion() + &&referenceVisible.get('Earth')>=2&&referenceTextures[cloudAsset.id]?.ready; + state.earthCloudPhase=advanceEarthCloudPhase(state.earthCloudPhase,dt,state.simStepSeconds,BODY.Earth.rotationHours,drift); } } } @@ -3347,8 +3409,12 @@ async function showFallback(msg) { if(solarDetail?.status('reference')==='unavailable')solarDetail.retry('reference'); syncSolarPlaybackControls();paint(); }); - for (const [id, key] of [['orreryEarthNight', 'earthNight'], ['orreryEarthWeather', 'earthWeather'], ['orreryEarthIce', 'earthIce']]) { - bind(id, 'change', e => { state[key] = inputTarget(e).checked; updateEarthLayerStatus(); paint(); updateOrreryAccuracy(); }); + for (const [id, key] of [['orreryEarthEnhanced', 'earthEnhanced'], ['orreryEarthNight', 'earthNight'], ['orreryEarthWeather', 'earthWeather'], ['orreryEarthIce', 'earthIce']]) { + bind(id, 'change', e => { + state[key] = inputTarget(e).checked; + if(key==='earthEnhanced'&&state[key]&&state.earthMaskStatus==='unavailable')state.earthMaskStatus='deferred'; + updateEarthLayerStatus(); paint(); updateOrreryAccuracy(); + }); } bind('orreryVenusRadar', 'change', e => { state.venusRadar = inputTarget(e).checked; updatePhysicalAppearance(); paint(); updateOrreryAccuracy(); }); bind('orreryEarthCloudSource', 'change', e => { @@ -3427,6 +3493,7 @@ async function showFallback(msg) { scatteringTargets?.dispose();scatteringTargets=null;scatteringFrame=null;state.scatteringFrame=null;state.scatteringStatus={}; hdrPresentation?.dispose();hdrPresentation=null;linearFrame=false;state.hdrFrame=null; state.hdrStatus={state:'deferred',reason:'Graphics context lost.'}; + if(earthOceanMask)gl.deleteTexture(earthOceanMask);earthOceanMask=null;state.earthMaskStatus='deferred'; gl = null; P = {}; textures = {}; sunTex = { ready: false, tex: null }; ringTex = { ready: false, tex: null }; cancelPendingReferenceTextures(); diff --git a/apps/web/js/orreryShaders.js b/apps/web/js/orreryShaders.js index f42c1cd7..77056a48 100644 --- a/apps/web/js/orreryShaders.js +++ b/apps/web/js/orreryShaders.js @@ -8,6 +8,8 @@ import { INCIDENT_FIELD_GLSL } from './atmosphereIncident.js'; import { TERRAIN_SHADOW_GLSL } from './terrainShadowShaders.js'; import { DISPLAY_COMPOSITION_GLSL } from './materialColor.js'; import { RING_TRANSPORT_GLSL } from './ringTransportShaders.js'; +import {EARTH_CLOUD_GEOMETRY_GLSL,EARTH_CLOUD_COVER_GLSL,EARTH_OCEAN_GLSL} from './enhancedEarth.js'; +export {EARTH_CLOUD_GEOMETRY_GLSL,EARTH_CLOUD_COVER_GLSL,EARTH_OCEAN_GLSL}; const NOISE = ` float h31(vec3 p){ p=fract(p*0.3183099+0.1); p*=17.0; return fract(p.x*p.y*p.z*(p.x+p.y+p.z)); } @@ -75,6 +77,9 @@ uniform vec4 u_map; uniform vec4 u_mapLat; uniform vec4 u_mapWindow; uniform int uniform int u_earthNight; uniform int u_earthWeather; uniform int u_earthIce; uniform sampler2D u_nightTex; uniform sampler2D u_weatherTex; uniform sampler2D u_iceTex; uniform int u_textureLinear; +uniform int u_earthEnhanced,u_earthCloudShadow; +uniform float u_cloudPhase,u_cloudScale; +uniform sampler2D u_oceanMask; uniform vec3 u_hazeRayleighTau; uniform vec3 u_hazeAerosol; // Illustrative atmospheric haze for the distant, non-physical path. See // atmosphereOptics.js for the profile columns and the cited closed forms. @@ -170,6 +175,9 @@ ${ATMOSPHERE_GLSL} ${TERRAIN_SHADOW_GLSL} ${RING_TRANSPORT_GLSL} ${NOISE} +${EARTH_CLOUD_GEOMETRY_GLSL} +${EARTH_CLOUD_COVER_GLSL} +${EARTH_OCEAN_GLSL} // Every registered layer of a body shares one source grid. main computes this // once per fragment (u, v, source latitude) and passes it to each lookup, so the // same atan pair is not re-evaluated for every layer and coverage test. @@ -201,6 +209,11 @@ float referenceCoverage(vec3 grid,vec4 sampleColor){ if(u_mapNoData==1&&max(sampleColor.r,max(sampleColor.g,sampleColor.b))<0.00392157) return 0.0; return u_mapNoData==2 ? sampleColor.a : 1.0; } +vec4 earthCloudSample(vec3 p){ + vec2 uv=referenceGrid(p).xy; + uv.x=fract(uv.x-u_cloudPhase); + return referenceSample(u_weatherTex,p,uv); +} float spotGroup(vec3 p,vec4 s){ if(s.w<=0.0) return 0.0; float ang=acos(clamp(dot(normalize(p),normalize(s.xyz)),-1.0,1.0)); @@ -247,6 +260,18 @@ void main(){ // bright full-circumference ring that made every planet look like an annular eclipse. float day=smoothstep(-0.32,0.22,dot(N,normalize(u_light))); o=vec4(displayOutput(u_atmo*pow(1.0-clamp(dot(N,V),0.0,1.0),2.2)*u_atmoStr*1.4*(0.04+0.96*day)), 1.0); return; } + if(u_mode==3){ + // A separate, raised transparent shell. This branch is removed from the + // physical ground specialization; cloud haze remains explicitly illustrative. + vec4 clouds=earthCloudSample(p); + float alpha=clouds.a*earthCloudCover(dot(N,normalize(u_light))); + if(alpha<=0.001) discard; + float lambert=max(dot(N,normalize(u_light)),0.0); + vec3 color=decodeSRGB(coveredRGB(clouds))*(.001+.999*lambert); + vec3 path=hazeOverSurface(color,N,V,normalize(u_light),1.0); + color+=path; + o=vec4(u_linearOutput==1?color:encodeSRGB(color),alpha);return; + } if(u_mode==1){ if(u_style<0){ float limb=pow(clamp(dot(N,V),0.0,1.0),0.45); @@ -314,6 +339,7 @@ void main(){ vec4 mapped=referenceSample(u_tex,p,referenceGridValue.xy); vec3 sourceRGB=u_mapNoData==2 ? coveredRGB(mapped) : mapped.rgb; col=mix(decodeSRGB(u_base),u_textureLinear==1 ? sourceRGB : decodeSRGB(sourceRGB),referenceCoverage(referenceGridValue,mapped)); + if(u_earthEnhanced==1) col=enhancedOceanColor(col,referenceSample(u_oceanMask,p,referenceGridValue.xy).r); // Earth auxiliaries share the documented WGS84 pixel-area grid, 180W..180E. // Weather is the provider's dated clouds-and-surface image, never inferred clouds. if(u_earthWeather==1){ @@ -493,6 +519,13 @@ void main(){ } } sunVis*=terrainSunVisibility(surfaceBodyKm,incidentBody); + float hazeSunVis=sunVis; + if(reference&&u_earthCloudShadow==1&&dot(N,normalize(u_light))>0.0){ + // Same ellipsoid and phase as the visible clouds. Trace straight geometric + // sunlight, not a fixed UV offset; alpha is a display opacity, not optical depth. + vec3 hit=earthCloudHit(p,normalize(u_lightObj),u_oblate,u_cloudScale); + sunVis*=1.0-.35*earthCloudSample(normalize(hit)).a; + } // The shadow removes DIRECT sunlight only. The 0.05 floor is the light a planet's own // atmosphere scatters into it, which is why Io's shadow reads as very dark grey rather than // as a hole in the planet. @@ -543,7 +576,7 @@ void main(){ // shared encode below handles it; a procedural display recipe is decoded and // re-encoded so its own downstream handling stays exactly as it was. vec3 surface=reference ? col : decodeSRGB(col); - vec3 path=hazeOverSurface(surface,N,V,normalize(u_light),sunVis); + vec3 path=hazeOverSurface(surface,N,V,normalize(u_light),hazeSunVis); col=reference ? surface+path : encodeSRGB(surface+path); displayLimb=vec3(0); } @@ -618,7 +651,7 @@ export function physicalEnabledSource(source) { ' // Direct reflected sunlight sees the incident atmospheric column. The\n // single-scattering mode has no invented diffuse-ambient weather term.\n col*=lambert*sunVis*(refracted ? v_incidentTransmission : atmosphereSunTransmission(surfaceBodyKm))\n *u_atmosphereSolarScale*u_atmosphereExposure;'], [' if(u_atmosphereEnabled==1) col=atmosphereSurfaceColor(',' col=atmosphereSurfaceColor('], [' if(u_atmosphereEnabled==0)return vec3(0);\n',''], - [' if(u_atmosphereEnabled==0&&dot(u_hazeRayleighTau,vec3(1))>0.0){\n // Path radiance is linear light. A registered map is still linear here and the\n // shared encode below handles it; a procedural display recipe is decoded and\n // re-encoded so its own downstream handling stays exactly as it was.\n vec3 surface=reference ? col : decodeSRGB(col);\n vec3 path=hazeOverSurface(surface,N,V,normalize(u_light),sunVis);\n col=reference ? surface+path : encodeSRGB(surface+path);\n displayLimb=vec3(0);\n }\n',''], + [' if(u_atmosphereEnabled==0&&dot(u_hazeRayleighTau,vec3(1))>0.0){\n // Path radiance is linear light. A registered map is still linear here and the\n // shared encode below handles it; a procedural display recipe is decoded and\n // re-encoded so its own downstream handling stays exactly as it was.\n vec3 surface=reference ? col : decodeSRGB(col);\n vec3 path=hazeOverSurface(surface,N,V,normalize(u_light),hazeSunVis);\n col=reference ? surface+path : encodeSRGB(surface+path);\n displayLimb=vec3(0);\n }\n',''], ]; for(const [from,to] of rewrites){ if(source.split(from).length!==2)throw new Error('Physical enabled-flag boundary changed'); diff --git a/apps/web/js/planetAppearance.js b/apps/web/js/planetAppearance.js index c80022d7..bd26f462 100644 --- a/apps/web/js/planetAppearance.js +++ b/apps/web/js/planetAppearance.js @@ -73,7 +73,7 @@ export function appearanceSummary(body, state = {}) { export function earthLayerDescription(state = {}, compact = false) { const roles = /** @type {[string, boolean][]} */ ([['night-lights', state.earthNight !== false], [earthCloudRole(state), state.earthWeather !== false], ['sea-ice', state.earthIce === true]]); - return roles.filter(([, enabled]) => enabled).map(([role]) => { + const layers=roles.filter(([, enabled]) => enabled).map(([role]) => { const asset = appearanceReference('Earth', role); if (!asset) return `${role}: unavailable`; const status = state.appearanceStatus?.[asset.id]; @@ -82,4 +82,14 @@ export function earthLayerDescription(state = {}, compact = false) { return `${label} · ${asset.observation_label}${status === 'ready' ? '' : status === 'unavailable' ? ' · unavailable' : status === 'deferred' || !status ? ' · loads when Earth is in view at a useful scale' : status === 'queued' ? ' · queued' : ' · loading'}${limits}`; }).join(' · '); + return [layers,enhancedEarthDescription(state)].filter(Boolean).join(' · '); +} + +export function enhancedEarthDescription(state){ + if(!state.earthEnhanced)return ''; + if(state.earthCloudSource==='daily')return 'Enhanced Earth suspended for MODIS swaths containing the ground.'; + if(state.earthIce===true)return 'Enhanced Earth suspended to preserve the scientific sea-ice palette.'; + if(state.useTextures===false)return 'Enhanced Earth suspended while source textures are off.'; + const ocean=state.earthMaskStatus==='unavailable'?'Ocean grading unavailable. ':'Darker offshore oceans. '; + return `Enhanced Earth · illustrative: ${ocean}Cloud height (nominal 8 km), shadows and drift are display choices, not measured weather. Cloud lighting uses approximate haze. Drift follows Play time; reduced motion holds it.`; } diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 7ebf3355..9fdb4e97 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1,7 +1,7 @@ # Implementation plan Status: active -Updated: 2026-09-11 +Updated: 2026-09-23 This plan describes delivery work, while `STATUS.md` describes implemented product behavior and `ROADMAP.md` records longer-horizon product direction. @@ -47,6 +47,17 @@ Exit criteria: ## Next product increments +### Enhanced Earth review candidate + +The user approved the bounded optional mode in [RFC 0008](rfcs/0008-enhanced-earth.md). +Implementation includes guarded ocean grading, a nominal 8 km cloud shell, +Sun-directed shadows, independent rotation-gated drift, lifecycle recovery and +release-fingerprint coverage for the new rendering modules. The default remains off. +See [validation evidence](validation/enhanced-earth/README.md) for the comparison and +exact scope. Next gates are draft review, CI Rust/WASM/browser and coverage checks, +then native/mobile appearance and performance. Local source completion does not +authorize merge or production promotion. + ### Operational evidence - Calibrate physical magnetic units and publish the method. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 5e04306e..9886b696 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -31,6 +31,7 @@ unrecognized status, and incomplete workflow enforcement. | `SOL-VIS-005` | Numeric terrain datum, finite geometry, physical extents and source identity | | `SOL-VIS-006` | Dimensioned optical transfer with distinct reflection, scattering and emission | | `SOL-VIS-007` | Source band, frame, coverage and finite dynamic appearance playback | +| `SOL-VIS-009` | Opt-in Enhanced Earth with disclosed ocean grading, raised clouds, aligned shadows and gated drift | | `SOL-CI-001` | SHA-pinned actions, least privilege, and deployment of the tested SHA | | `SOL-DOC-001` | Documentation, plans, instructions, and traceability updated with code | | `SOL-SUPPLY-001` | Locked dependencies and automated ecosystem update review | diff --git a/docs/SPEC.md b/docs/SPEC.md index 630bd24a..883c1675 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -32,6 +32,19 @@ reuse the existing view transform without moving physical bodies. Scientific and source claims remain anchored to public methods and data: NOAA/SWPC products, Helioviewer quicklook imagery and metadata, IERS Earth-orientation data, JPL Horizons/DE441 validation, published analytic ephemerides, and NASA/IAU constants. Sol does not claim proprietary JPL, NOAA, or commercial forecasting algorithms. +[RFC 0008](rfcs/0008-enhanced-earth.md) adds an optional Enhanced Earth presentation, +off by default. It retains NASA maps and their epochs, grades dark offshore ocean +pixels, raises the historical cloud layer by a nominal 8 km, and samples that same +shell/phase for Sun-directed display shadows. Independent drift advances only with +visible Earth during Play time; pause, hidden/inactive views and reduced motion +hold its phase. MODIS ground-containing swaths and the scientific sea-ice palette +suspend enhancement. Cloud height, drift, opacity-based shadows and cloud haze +are illustrative; they are not measured weather or volumetric optical transfer. +The ground keeps its existing reference atmosphere. Source-qualified defaults, +other bodies, engine snapshots and observation dates remain unchanged. See the +[validation record](validation/enhanced-earth/README.md) for local evidence and +outstanding browser/device qualification. + Planetary appearance follows [RFC 0004](rfcs/0004-registered-planetary-appearance.md). Registered, dated mission display maps are separate from calibrated or complete observations. Earth layers distinguish land/ice, annual night lights, dated satellite diff --git a/docs/STATUS.md b/docs/STATUS.md index 8f92ffb0..c442d083 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Sol status -Updated: 2026-09-13. Scope: inspected local implementation, not production activation. +Updated: 2026-09-23. Scope: inspected local implementation, not production activation. ## Implementation versus qualification @@ -37,6 +37,14 @@ while a scoped, tested implementation can be submitted for code review. ## User-visible behavior implemented locally +The optional Enhanced Earth candidate (SOL-VIS-009 / Accepted RFC 0008) preserves +the default view and adds darker ocean colors, raised historical clouds, shared-phase +Sun-directed ground shadows and drift during Play time. Scientific sea-ice and MODIS +views suspend it. [Candidate validation](validation/enhanced-earth/README.md) records +renderer lifecycle checks, deterministic mask generation and an actual offscreen +production-shader comparison. Native browser/mobile and release qualification remain +open; this candidate does not change the deployed site. + Sun, My Sky and Solar System expose selected non-canvas facts, native object controls, visible mode/source/epoch/limitations and bounded lists. The timeline distinguishes idealized cycle frames from observed data and treats missing frames as gaps. diff --git a/docs/requirements.json b/docs/requirements.json index da7e9cda..4fd1eb6b 100644 --- a/docs/requirements.json +++ b/docs/requirements.json @@ -496,6 +496,29 @@ "ci_gates": [ "CI / Web, provider, and browser validation" ] + }, + { + "id": "SOL-VIS-009", + "status": "implemented", + "statement": "Enhanced Earth MUST be opt-in, preserve original NASA sources and engine state, keep cloud geometry and Sun-directed shadows phase-aligned, freeze drift outside visible animation, and suspend visual edits for ground-containing swaths or scientific sea-ice palettes. Height, motion, ocean grading and cloud lighting MUST remain disclosed as illustrative.", + "source": "docs/rfcs/0008-enhanced-earth.md#requirements", + "implementation": [ + "apps/web/js/enhancedEarth.js", + "apps/web/js/earthOceanMask.js", + "apps/web/js/orrery.js", + "apps/web/js/orreryShaders.js", + "apps/web/js/planetAppearance.js", + "apps/web/index.html", + "tools/prepare_earth_ocean_mask.py" + ], + "verification": [ + "tests/web/enhancedEarth.test.mjs", + "tests/python/test_earth_ocean_mask.py", + "tools/render_enhanced_earth_probe.py" + ], + "ci_gates": [ + "CI / Web, provider, and browser validation" + ] } ] } diff --git a/docs/rfcs/0008-enhanced-earth.md b/docs/rfcs/0008-enhanced-earth.md new file mode 100644 index 00000000..3089c579 --- /dev/null +++ b/docs/rfcs/0008-enhanced-earth.md @@ -0,0 +1,109 @@ +# RFC 0008: Optional Enhanced Earth presentation + +- Status: Accepted +- Authors: Codex for Protonmatter +- Created: 2026-09-23 +- Target: Enhanced Earth review candidate +- Requirements: `SOL-VIS-009` + +## Summary + +Add an opt-in Enhanced Earth checkbox: deeper oceans, a raised historical cloud +layer, Sun-directed offset shadows and independent drift during Play time. +The owner approved this design in conversation on 2026-09-23. Acceptance authorizes +implementation; it does not qualify native graphics, merge or deployment. + +## Context + +Earth already uses NASA surface, cloud, night-light and sea-ice maps. Clouds are +currently composited on the ground. This change retains those immutable sources +and their dates. It does not import the lab's artistic Earth or simulate weather. + +## Requirements + +`SOL-VIS-009`: Enhanced Earth MUST be opt-in and disclosed as illustrative. It +MUST preserve source-qualified defaults, engine state and non-Earth materials. +Cloud geometry, shadows and drift MUST share a single map phase. Pausing time, +hidden/inactive views and reduced motion MUST stop drift. Camera redraws MUST NOT +advance phase. MODIS clouds-and-ground swaths MUST NOT be lifted or drifted. +Unavailable cloud imagery MUST NOT create opaque fallback clouds or shadows. + +## Design + +Use the existing surface and cloud resources. Grade only dark blue pixels inside +a conservative offshore mask derived offline from the committed Natural Earth +land/ice polygons. Protect bright ice; suspend enhancement for the sea-ice palette. +The mask is display support, not a qualified coastline or ocean reflectance map. + +Raise the cloud ellipsoid homothetically by a nominal 8 km at the equator +(slightly less at the poles). Reuse the sphere mesh and base program in a +transparent cloud pass, back-face culled, depth-tested and without depth writes. +The base program's existing haze approximation lights the cloud shell; this is +not dense-cloud or volumetric optical transfer. Night-side coverage falls off so +Black Marble remains on the ground pass, and a close view with the physical +atmosphere shell does not also add illustrative cloud haze. The ground retains +its existing reference atmosphere. Cloud shadows trace straight sunlight rays +from the oblate ground to this same shell and sample the same phase, attenuating +direct sunlight only. No fixed screen-space or longitude offset is used. + +Drift adds 3% of the visible Earth rotation, capped at 0.002 turns per real second, +using a bounded phase advanced once per successful animation tick. The clock is +illustrative and never alters observation dates, positions or Earth orientation. +Daily MODIS or scientific sea-ice palette selection suspends the enhancement; toggling back resumes its phase. + +## UX and accessibility + +Earth layers contains a native unchecked Enhanced Earth checkbox, a concise help +paragraph and status describing active, suspended, loading or unavailable layers. +It is independent of the other planets' appearance mode. Keyboard/touch behavior +uses existing controls; reduced motion suppresses independent cloud drift. + +## Security and privacy + +No new runtime dependencies, external requests, user data or schemas. Existing +bounded/cancellable reference loaders own NASA imagery. One 512x256 R8 mask is +uploaded lazily, retained per context and disposed on context loss. Upload failure +leaves oceans ungraded with visible status. The generated mask pins input hashes. + +## Alternatives + +A fixed UV shadow offset contradicts a moving Sun. Full volumetric clouds need +different data and performance qualification. Replacing scientific defaults would +hide display edits; a separate toggle permits direct comparison and rollback. + +## Risks + +Cloud height, opacity-to-shadow strength and drift are artistic choices, not +retrieved meteorology. Coarse vectors omit small islands; blue/brightness guards +and an offshore margin restrict grading, but the mask is not survey accuracy. +Cloud scattering uses illustrative haze; it is not the ground's qualified optical +transfer. Physical-height depth is subtle at globe scale. Native GPU/mobile visual +and performance qualification remain required; CPU draw tests cannot establish it. + +## Acceptance criteria + +1. Default/off, non-Earth, no textures and MODIS paths retain their original draws. +2. Ready composite clouds appear once on the shell; missing/off clouds cast no shadow. +3. Executed shader geometry hits the correct raised oblate shell toward the Sun; + tangent/polar/night cases are finite, and opposite light directions reverse offsets. +4. Draw tests prove shared cloud/shadow phase, elevation, depth/culling/blending, + no night-light leakage, and pause/camera/context continuity. +5. Ocean math preserves masked land, neutral/bright ice and non-blue pixels; + deterministic mask regeneration and known land/ocean/polar samples pass. + +## Validation + +Run focused shader/helper and full-renderer lifecycle tests, all Node tests, +mask regeneration, Python tests, type/static/doc/SDLC/UX gates and the web build. +Required CI retains native WebGL2, browser visual, coverage, WASM and release gates. + +## Rollout and rollback + +Submit a separate draft PR based on the existing default branch. Default is off. +Merge/deploy only after normal review and required gates. Uncheck Enhanced Earth +for immediate comparison/rollback; revert the feature commit for full removal. + +## Documentation + +Update SPEC, requirements catalogue/traceability, README, status and validation +evidence with the actual local results and remaining qualification limits. diff --git a/docs/validation/enhanced-earth/README.md b/docs/validation/enhanced-earth/README.md new file mode 100644 index 00000000..1ccef71c --- /dev/null +++ b/docs/validation/enhanced-earth/README.md @@ -0,0 +1,94 @@ +# Enhanced Earth validation record + +Date: 2026-09-23. Scope: local review candidate for RFC 0008 / SOL-VIS-009. +This record does not claim merge, deployment or native-device qualification. + +## Behavior + +Earth layers → Enhanced Earth is off by default. It reuses NASA surface/cloud +maps, protects coarse land/ice with a conservative offshore mask, and darkens +only dark blue water colors in linear light. A homothetic cloud shell is nominally +8 km above the equator. Ground shadows intersect that shell toward the Sun and +use the same drifting UV phase. The cloud pass uses illustrative haze, while the +ground retains reference optical transfer. Night-side coverage falls off so +Black Marble remains on the ground pass, and a close view with the physical +atmosphere shell does not also add illustrative cloud haze. No source image, +observation epoch, ephemeris or other body's appearance is modified. + +Cloud drift follows 3% of displayed Earth rotation, capped at 0.002 turns per real +second. Pause, hidden/inactive scenes and reduced motion freeze the phase; camera +movement never advances it. MODIS ground-containing swaths and the scientific +sea-ice palette suspend enhancement. Missing/off clouds have no shell or shadow. +Failed mask upload leaves the cloud view usable and reports ocean grading as +unavailable; switching Enhanced Earth off/on retries once. + +## Automated evidence + +- `tests/web/enhancedEarth.test.mjs` executes production renderer lifecycle and + shader geometry/color helpers. Cases cover defaults, scientific-state identity, + shell elevation, shared phase, transparent draw state, non-Earth reset, missing + maps, swath/sea-ice suspension, drift and rate caps, pause/reduced motion, active + hidden/inactive suspension, context loss and explicit upload retry. +- `tests/python/test_earth_ocean_mask.py` verifies exact offline regeneration and + rejects changed source data. The generator is standard-library-only; its + Natural Earth source hashes and the decoded pixel hash are committed. +- `tools/render_enhanced_earth_probe.py` compiles and links the actual base and + physical shaders on Linux EGL/Mesa. It renders a controlled base-haze comparison + with original NASA maps. Metamorphic checks require visible changes when + enhancement, drift or shadows change. A black-ground test verifies that cloud + shadows do not attenuate atmospheric path radiance. + +The probe initially exposed the existing GLSL local name `packed`, rejected as +a reserved token by Mesa. Renaming that local to `columnValues` changes no +formula. `node tools/prepare_atmosphere_columns.mjs` regenerated the pinned +manifest from the actual source; both 2 MiB numerical field files reproduced +byte-for-byte. No source-identity check was bypassed or weakened. + +Final candidate checks on 2026-09-23: + +| Command | Observed result | +| --- | --- | +| `npm test` | 1,323 tests passed; no failures or skips. | +| `PYTHONPATH=tools python -m unittest discover -s tests/python -p 'test_*.py' -v` | 390 tests ran; 5 errors from missing `cargo`/`rustc`, 3 skips, no assertion failures. | +| `python tools/typecheck_web.py` | 114 files clean. | +| `python tools/validate_web_static.py` | Passed. | +| `python tools/validate_sdlc.py` | 24 requirements and workflow/traceability checks passed. | +| `python tools/validate_docs.py` | 140 Markdown files passed. | +| `python tools/validate_ux_contract.py` | Structure checks passed. | +| `python tools/prepare_earth_ocean_mask.py --check` | Exact reproduction passed. | +| `python tools/render_enhanced_earth_probe.py` | Both production programs compiled/linked; image, drift, shadow and haze-isolation assertions passed. | +| `git diff --check` | Passed. | + +The five Python errors are three native snapshot subcases, the native hybrid-time +case, and local Rust toolchain evidence capture. An earlier broad rerun ended +without a summary; the complete confirmation run above finished in 46.666 seconds. +Release-artifact and ocean-mask cases passed in that full run. The new module +classification regression was also observed failing before the classifier update +and passing afterward. The release builder requires source/run metadata and valid +WASM inputs; no qualified release build was produced without the Rust/WASM toolchain. +Independent review found two minor issues (haze shadowing and an insufficiently +active suspension test); both were fixed and confirmed in focused follow-up review. + +## Reproduction + +```sh +python tools/prepare_earth_ocean_mask.py --check +node --experimental-vm-modules --test tests/web/enhancedEarth.test.mjs +PYTHONPATH=tools python -m unittest tests.python.test_earth_ocean_mask -v +python tools/render_enhanced_earth_probe.py +``` + +The optional EGL probe requires Linux libEGL, Node, NumPy, Pillow and DejaVu Sans. +It is a diagnostic, not the repository's browser/native graphics gate. The +[comparison](comparison.jpg) holds maps, camera and Sun direction constant. +[Probe metadata](gl-probe.json) records renderer, shader/source hashes and changed +pixel counts. Those counts prove an effect, not its perceptual quality or accuracy. + +## Remaining qualification + +Native WebGL2/ANGLE, Safari/iOS/mobile appearance, physical-path visual quality, +whole-web/Rust coverage and frame-time measurements require the normal CI/device +gates. CPU lifecycle tests and Mesa images do not substitute for those gates. +The cloud layer has no measured heights, evolving meteorology, multiple +scattering or volumetric structure. The offshore mask is deliberately coarse and +omits small islands; it is not qualified for glint, coastlines or ocean analysis. diff --git a/docs/validation/enhanced-earth/comparison.jpg b/docs/validation/enhanced-earth/comparison.jpg new file mode 100644 index 00000000..28d0a77d Binary files /dev/null and b/docs/validation/enhanced-earth/comparison.jpg differ diff --git a/docs/validation/enhanced-earth/gl-probe.json b/docs/validation/enhanced-earth/gl-probe.json new file mode 100644 index 00000000..0b03cb56 --- /dev/null +++ b/docs/validation/enhanced-earth/gl-probe.json @@ -0,0 +1,29 @@ +{ + "renderer": "llvmpipe (LLVM 20.1.2, 256 bits)", + "version": "OpenGL ES 3.2 Mesa 25.2.8-0ubuntu0.24.04.2", + "shader_sha256": { + "base": [ + "3c07618144d40f22ddc7172915f581e8d04e1467125c75010e57ac53670a2b8c", + "b84bb0171c0951170b6bbeb8e333bdab4a476c0121bb1cfe76aed821a3804703" + ], + "physical": [ + "c1a6e6515c9c8d28d159e2fd08036edae0af72ddb69f57923a431a9cee8f2733", + "97b25bca0dfb9e0b858e390172da0f1a7004923e51205f691c254cf1fea9b767" + ] + }, + "programs_compiled": [ + "base", + "physical" + ], + "width": 768, + "height": 768, + "changed_pixels": 254959, + "drift_changed_pixels": 270921, + "shadow_changed_pixels": 63090, + "scope": "Controlled offscreen OpenGL ES software rendering of production shaders with original NASA maps; base haze path, no browser/native-device qualification.", + "source_sha256": { + "apps/web/textures/reference/earth-land-2004.jpg": "99f5faad74efe985fbf1714c8be7296ca9999759a1215b65f99b7f1df278dde5", + "apps/web/textures/reference/earth-blue-marble-2002-cloud-layer.png": "82005bba2cec05b41137b766985d516eaf6b66ea045191d8869d45abd05f7995", + "apps/web/js/earthOceanMask.js": "53fcc6ba7460e0ee701893f05b924c8fa4002fe74c31a8a6647f00a66a687e7a" + } +} diff --git a/tests/python/test_earth_ocean_mask.py b/tests/python/test_earth_ocean_mask.py new file mode 100644 index 00000000..baf7652c --- /dev/null +++ b/tests/python/test_earth_ocean_mask.py @@ -0,0 +1,24 @@ +"""The committed display mask must reproduce from pinned geography without network.""" +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import prepare_earth_ocean_mask as mask + + +class EarthOceanMaskTests(unittest.TestCase): + def test_committed_mask_reproduces(self): + self.assertEqual(mask.generate(), mask.OUTPUT.read_text()) + + def test_changed_source_is_rejected_before_rasterization(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) + (source / 'ne_110m_land.geojson').write_text('{"features": []}') + with patch.object(mask, 'SOURCE', source): + with self.assertRaisesRegex(ValueError, 'Source hash mismatch'): + mask.mask_pixels() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/python/test_release_artifact.py b/tests/python/test_release_artifact.py index 5fbda153..28d54179 100644 --- a/tests/python/test_release_artifact.py +++ b/tests/python/test_release_artifact.py @@ -69,6 +69,7 @@ def test_rendering_dependency_changes_invalidate_science_fingerprint(self): "surfaceReflection.js", "surfaceReflectionShaders.js", "terrainResources.js", "shaderPrograms.js", "referenceDemand.js", "hdrPresentation.js", "hdrPresentationShaders.js", + "enhancedEarth.js", "earthOceanMask.js", ) actual_web = Path(__file__).resolve().parents[2] / "apps/web/js" folder = self.source / "js" diff --git a/tests/web/enhancedEarth.test.mjs b/tests/web/enhancedEarth.test.mjs new file mode 100644 index 00000000..c32d0e8c --- /dev/null +++ b/tests/web/enhancedEarth.test.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as shaders from '../../apps/web/js/orreryShaders.js'; +import {geometryInterpreter} from './fixtures/glslFloat32Geometry.mjs'; +import {orreryHarness} from './helpers/orreryHarness.mjs'; +import {appearanceReference} from '../../apps/web/js/planetAppearance.js'; +import {advanceEarthCloudPhase,oceanMaskPixels} from '../../apps/web/js/enhancedEarth.js'; +import {OCEAN_MASK_WIDTH,OCEAN_MASK_HEIGHT,OCEAN_MASK_SHA256} from '../../apps/web/js/earthOceanMask.js'; +import {createHash} from 'node:crypto'; + +const cloudDraws=h=>h.gpuSubmissions.filter(d=>d.kind==='elements'&&d.uniforms.u_mode===3); +async function boot(t,options={}){ + const h=await orreryHarness(t,{controls:true,...options}); + await h.enterOrrery();h.setAnimate(false);h.input('orreryAnchor','Earth','change');await h.settle(); + t.after(()=>h.leaveOrrery());return h; +} +function load(h,role){ + const asset=appearanceReference('Earth',role),img=h.images.find(i=>i.src?.includes(asset.path)); + assert.ok(img,`${role} requested`);[img.width,img.height]=asset.dimensions;img.onload(); +} + +test('enhancement is opt-in and never advances the scientific clock or changes other bodies',async t=>{ + const h=await boot(t);assert.equal(cloudDraws(h).length,0); + const identity=JSON.stringify([h.state.renderUnix,h.state.bodies]); + h.check('orreryEarthEnhanced',true); + assert.equal(h.state.earthEnhanced,true); + assert.equal(JSON.stringify([h.state.renderUnix,h.state.bodies]),identity); + assert.match(h.nodes.orreryEarthLayerStatus.textContent,/illustrative/i); + h.check('orreryEarthEnhanced',false);assert.equal(h.state.earthEnhanced,false); +}); + +test('raised cloud draw shares shadow phase, omits ground clouds and keeps transparent depth state',async t=>{ + const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite'); + h.gpuSubmissions.length=0;h.check('orreryEarthEnhanced',true); + const cloud=cloudDraws(h).at(-1);assert.ok(cloud,'ready cloud map gets its own draw'); + const ground=h.gpuSubmissions.find(d=>d.kind==='elements'&&d.uniforms.u_earthEnhanced===1&&d.uniforms.u_mode===0); + assert.ok(ground);assert.equal(ground.uniforms.u_earthWeather,0,'no double cloud layer'); + assert.equal(ground.uniforms.u_earthCloudShadow,1); + assert.equal(cloud.uniforms.u_cloudPhase,ground.uniforms.u_cloudPhase); + assert.equal(cloud.uniforms.u_cloudScale,ground.uniforms.u_cloudScale); + const a=ground.uniforms.u_model,b=cloud.uniforms.u_model; + const length=m=>Math.hypot(m[0],m[1],m[2]); + assert.ok(length(b)>length(a));assert.ok(length(b)/length(a)<1.002,'height is small, not a balloon'); + assert.equal(cloud.depthWrites,false);assert.ok(cloud.enabled.has(h.gl.DEPTH_TEST)); + assert.ok(cloud.enabled.has(h.gl.CULL_FACE));assert.deepEqual(cloud.blend,[h.gl.SRC_ALPHA,h.gl.ONE_MINUS_SRC_ALPHA]); + assert.equal(cloud.uniforms.u_earthNight,0);assert.equal(cloud.uniforms.u_earthIce,0); + assert.equal(cloud.textures.get(3),ground.textures.get(3)); + assert.ok(cloud.uniforms.u_hazeRayleighTau.some(value=>value!==0),'distant clouds keep illustrative haze'); +}); + +test('cloud off, missing maps and daily ground-containing swaths cannot lift or cast cloud shadows',async t=>{ + const h=await boot(t);h.check('orreryEarthEnhanced',true); + assert.equal(cloudDraws(h).length,0); + load(h,'surface');await h.settle();load(h,'cloud-composite'); + h.gpuSubmissions.length=0;h.check('orreryEarthWeather',false); + assert.equal(cloudDraws(h).length,0); + assert.ok(h.gpuSubmissions.filter(d=>d.kind==='elements'&&d.uniforms.u_mode===0).every(d=>d.uniforms.u_earthCloudShadow===0)); + h.check('orreryEarthWeather',true);h.gpuSubmissions.length=0; + h.input('orreryEarthCloudSource','daily','change'); + assert.equal(cloudDraws(h).length,0);assert.match(h.nodes.orreryEarthLayerStatus.textContent,/suspended/i); + assert.ok(h.gpuSubmissions.filter(d=>d.kind==='elements'&&d.uniforms.u_mode===0).every(d=>d.uniforms.u_earthEnhanced===0)); +}); + +test('drift advances only with visible animated Earth and survives paused camera redraws',async t=>{ + const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');h.check('orreryEarthEnhanced',true); + const initial=cloudDraws(h).at(-1)?.uniforms.u_cloudPhase; + assert.equal(initial,0);h.setAnimate(true);h.frame(100);h.frame(116); + const moved=cloudDraws(h).at(-1).uniforms.u_cloudPhase;assert.ok(moved>0&&moved<0.001); + h.setAnimate(false);h.resize(810,605);h.event('orreryCanvas','keydown',{key:'ArrowLeft'}); + assert.equal(cloudDraws(h).at(-1).uniforms.u_cloudPhase,moved); + h.setWindowReducedMotion(true);h.setAnimate(true);h.frame(132); + assert.equal(cloudDraws(h).at(-1).uniforms.u_cloudPhase,moved); +}); + +test('night cloud cover stays clear until the shell is actually lit',()=>{ + const g=geometryInterpreter(shaders.EARTH_CLOUD_COVER_GLSL||''); + const cover=sun=>g.run('earthCloudCover',[sun]).value; + assert.equal(cover(-0.2),0); + assert.equal(cover(0),0); + assert.equal(cover(1),1); + assert.equal(cover(0.05),0); +}); + +test('executed shadow geometry follows the Sun and hits the elevated ellipsoid at grazing angles',()=>{ + const g=geometryInterpreter(shaders.EARTH_CLOUD_GEOMETRY_GLSL||''); + const hit=(p,l,oblate=1)=>g.run('earthCloudHit',[p,l,oblate,1.01]).value; + const near=(a,b)=>assert.ok(Math.abs(a-b)<2e-6,`${a} != ${b}`); + const overhead=hit([1,0,0],[1,0,0]);near(overhead[0],1.01);near(overhead[1],0); + const east=hit([1,0,0],[0,1,0]),west=hit([1,0,0],[0,-1,0]); + near(east[1],Math.sqrt(.0201));near(east[1],-west[1]);near(Math.hypot(...east),1.01); + const polar=hit([0,0,1],[1,0,0],.997);near(Math.hypot(...polar),1.01); +}); + +test('executed ocean grading preserves masked land, bright ice and neutral colors',()=>{ + const g=geometryInterpreter(shaders.EARTH_OCEAN_GLSL||''); + const grade=(c,m)=>g.run('enhancedOceanColor',[c,m]).value; + for(const [c,m] of [[[.01,.04,.12],0],[[.8,.9,1],1],[[.1,.1,.1],1],[[.3,.2,.1],1]]) + assert.deepEqual(grade(c,m),c.map(Math.fround)); + const c=grade([.01,.04,.12],1),want=[.00873378,.02193378,.05713378]; + c.forEach((v,i)=>assert.ok(Math.abs(v-want[i])<1e-7)); +}); + +test('phase is continuous through wrapping, frame subdivision, rate limiting and invalid steps',()=>{ + const step=(phase,dt,sim,enabled=true)=>advanceEarthCloudPhase(phase,dt,sim,24,enabled); + assert.ok(Math.abs(step(.999,1,86400)-.001)<1e-12,'capped drift wraps smoothly'); + assert.ok(Math.abs(step(0,1,3600)-.00125)<1e-12,'drift is relative to displayed rotation'); + assert.ok(Math.abs(step(step(0,.5,1800),.5,1800)-step(0,1,3600))<1e-12); + for(const [dt,sim,enabled] of [[1,3600,false],[0,3600,true],[-1,3600,true],[NaN,3600,true],[1,NaN,true]]) + assert.equal(step(.3,dt,sim,enabled),.3); + assert.ok(Math.abs(step(.001,1,-86400)-.999)<1e-12); +}); + +test('mask bytes match their generated hash and preserve land/polar ice while admitting open oceans',()=>{ + const pixels=oceanMaskPixels();assert.equal(pixels.length,OCEAN_MASK_WIDTH*OCEAN_MASK_HEIGHT); + assert.equal(createHash('sha256').update(pixels).digest('hex'),OCEAN_MASK_SHA256); + const at=(lon,lat)=>pixels[Math.min(OCEAN_MASK_HEIGHT-1,Math.floor((90-lat)/180*OCEAN_MASK_HEIGHT))*OCEAN_MASK_WIDTH + +Math.floor((((lon+180)%360+360)%360)/360*OCEAN_MASK_WIDTH)]; + for(const [lon,lat] of [[20,10],[-100,40],[100,40],[-45,75],[0,-89],[140,-25]])assert.equal(at(lon,lat),0); + for(const [lon,lat] of [[-140,0],[-30,0],[80,-30],[179,0],[-179,0]])assert.equal(at(lon,lat),255); +}); + +test('physical ground program gets enhancement uniforms while cloud rendering stays in its own disclosed pass',async t=>{ + const h=await boot(t,{incidentField:async()=>({values:new Float32Array(4*257*195),width:257,height:195, + domain:{minHeightKm:0,maxHeightKm:16,quadratic:true}})}); + load(h,'surface');await h.settle();load(h,'cloud-composite'); + h.gpuSubmissions.length=0;h.check('orreryEarthEnhanced',true); + const ground=h.gpuSubmissions.find(d=>d.uniforms.u_scatteringReady===1&&d.uniforms.u_earthEnhanced===1); + assert.ok(ground,'physical atmosphere and enhanced ground coexist'); + assert.equal(ground.uniforms.u_earthCloudShadow,1);assert.equal(ground.uniforms.u_earthWeather,0); + const cloud=cloudDraws(h).at(-1);assert.ok(cloud);assert.equal(cloud.uniforms.u_atmosphereEnabled,0); + assert.deepEqual(cloud.uniforms.u_hazeRayleighTau,[0,0,0]); + assert.equal(cloud.uniforms.u_cloudPhase,ground.uniforms.u_cloudPhase); + assert.deepEqual(h.errors,[]); +}); + +test('turning off, hiding, leaving and restoring cannot advance clouds or retain dead mask textures',async t=>{ + const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');h.check('orreryEarthEnhanced',true); + h.setAnimate(true);h.frame(100);h.setAnimate(false); + const phase=h.state.earthCloudPhase; + const mask=h.gpuSubmissions.find(d=>d.uniforms.u_earthEnhanced===1).textures.get(11); + h.setHidden(true);h.setHidden(false);assert.equal(h.state.earthCloudPhase,phase); + h.leaveOrrery();await h.enterOrrery();assert.equal(h.state.earthCloudPhase,phase); + h.event('orreryCanvas','webglcontextlost');assert.ok(h.deletedTextures.includes(mask)); + h.event('orreryCanvas','webglcontextrestored');await h.settle(); + assert.equal(h.state.earthCloudPhase,phase);assert.equal(h.state.earthEnhanced,true); + h.gpuSubmissions.length=0;h.check('orreryEarthEnhanced',false); + assert.equal(cloudDraws(h).length,0); + assert.ok(h.gpuSubmissions.filter(d=>d.kind==='elements'&&d.uniforms.u_mode===0).every(d=>d.uniforms.u_earthEnhanced===0)); +}); + +test('animated suspension cancels frames and resumes without catching up hidden elapsed time',async t=>{ + const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');h.check('orreryEarthEnhanced',true); + h.setAnimate(true);h.frame(100);const phase=h.state.earthCloudPhase; + h.setHidden(true);assert.equal(h.frames.size,0,'hidden animated view cancels its queued tick'); + h.resize(805,602);assert.equal(h.state.earthCloudPhase,phase); + h.setHidden(false);h.frame(100000); + assert.ok(h.state.earthCloudPhase>phase&&h.state.earthCloudPhase-phase<.0001,'resume takes a single small step'); + const resumed=h.state.earthCloudPhase;h.leaveOrrery();assert.equal(h.frames.size,0); + h.resize(807,604);assert.equal(h.state.earthCloudPhase,resumed,'inactive redraw cannot move clouds'); +}); + +test('mask upload failure leaves a usable cloud layer and can be retried explicitly',async t=>{ + const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite'); + h.setTextureUploadError(pixels=>ArrayBuffer.isView(pixels)&&pixels.byteLength===512*256?h.gl.OUT_OF_MEMORY:0); + h.check('orreryEarthEnhanced',true); + assert.equal(h.state.earthMaskStatus,'unavailable');assert.ok(cloudDraws(h).length); + assert.match(h.nodes.orreryEarthLayerStatus.textContent,/Ocean grading unavailable/); + const uploads=h.textureUploads.length;h.resize(810,605);assert.equal(h.textureUploads.length,uploads,'no automatic failure retry loop'); + h.setTextureUploadError(0);h.check('orreryEarthEnhanced',false);h.check('orreryEarthEnhanced',true); + assert.equal(h.state.earthMaskStatus,'ready'); +}); + +test('scientific sea-ice colors suspend enhancement instead of being dimmed by raised clouds',async t=>{ + const h=await boot(t);load(h,'surface');await h.settle();load(h,'cloud-composite');h.check('orreryEarthEnhanced',true); + h.gpuSubmissions.length=0;h.check('orreryEarthIce',true); + assert.equal(cloudDraws(h).length,0);assert.match(h.nodes.orreryEarthLayerStatus.textContent,/suspended.*sea.ice/i); +}); diff --git a/tests/web/fixtures/glslFloat32Geometry.mjs b/tests/web/fixtures/glslFloat32Geometry.mjs index 66ee0806..2f2b48c1 100644 --- a/tests/web/fixtures/glslFloat32Geometry.mjs +++ b/tests/web/fixtures/glslFloat32Geometry.mjs @@ -28,6 +28,10 @@ export function geometryInterpreter(glsl){ const functions=new Map(parsed.statements.filter(ts.isFunctionDeclaration).map(node=>[node.name.text,node])); const builtins={...Object.fromEntries([...structs].map(([name,fields])=>[name,(...args)=>Object.fromEntries(fields.map((field,i)=>[field,args[i]]))])),vec2:(...a)=>vector(2,a),vec3:(...a)=>vector(3,a),sqrt:x=>f(Math.sqrt(x)), min:(a,b)=>zip(a,b,Math.min),max:(a,b)=>zip(a,b,Math.max),length:a=>f(Math.sqrt(dot(a,a))), + smoothstep:(edge0,edge1,x)=>{ + const t=Math.min(1,Math.max(0,f(f(f(x)-f(edge0))/f(f(edge1)-f(edge0))))); + return f(f(f(t)*f(t))*f(f(3)-f(f(2)*f(t)))); + }, dot,abs:Math.abs, normalize:a=>{const n=f(Math.sqrt(dot(a,a)));return a.map(v=>f(v/n));}, floatBitsToUint:x=>{view.setFloat32(0,x,true);return view.getUint32(0,true);}, diff --git a/tests/web/helpers/orreryHarness.mjs b/tests/web/helpers/orreryHarness.mjs index d48c5083..b864ca87 100644 --- a/tests/web/helpers/orreryHarness.mjs +++ b/tests/web/helpers/orreryHarness.mjs @@ -206,6 +206,7 @@ export async function orreryHarness(t, options = {}) { } for (const id of ["Textures", "EarthNight", "EarthWeather"]) nodes[`orrery${id}`].checked = true; nodes.orreryEarthIce.checked = false; + nodes.orreryEarthEnhanced = node({ checked: false }); nodes.orreryVenusRadar = node({ checked: false }); nodes.orreryEarthCloudSource = node({ value: "composite" }, "select"); nodes.orrerySpeedUnit.value = "1"; diff --git a/tests/web/illustrativeHaze.test.mjs b/tests/web/illustrativeHaze.test.mjs index e333c073..fda7f5ad 100644 --- a/tests/web/illustrativeHaze.test.mjs +++ b/tests/web/illustrativeHaze.test.mjs @@ -111,7 +111,7 @@ test('aerosol path radiance converges toward the limb instead of diverging', () test('only the illustrative program carries the haze, and it replaces the rim rather than stacking', () => { for (const source of [shaders.SPHERE_FS, shaders.BASE_SPHERE_FS]) { assert.match(source, /uniform vec3 u_hazeRayleighTau;/); - assert.ok(source.includes('hazeOverSurface(surface,N,V,normalize(u_light),sunVis)')); + assert.ok(source.includes('hazeOverSurface(surface,N,V,normalize(u_light),hazeSunVis)')); assert.ok(source.includes('displayLimb=vec3(0);'), 'a haze body drops the illustrative rim'); assert.ok(source.includes('vec3 displayLimb=u_atmo*fres*u_atmoStr'), 'other bodies keep the rim recipe'); } diff --git a/tools/build_web.py b/tools/build_web.py index 849fea8c..6b81f39e 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -55,7 +55,7 @@ def validate_visual_source(source_root: Path) -> frozenset[str]: "solarAppearance.js", "solarAppearanceManifest.js", "solarVolumeShaders.js", "atmosphereOptics.js", "atmosphereShaders.js", "atmosphereIncident.js", "atmosphereIncidentManifest.js", "atmosphereColumnField.js", "atmosphereColumnManifest.js", "atmosphereScattering.js", "scatteringTargets.js", "planetPhenomena.js", "planetPhenomenaManifest.js", "solarAssetLoader.js", "physicalRendering.js", - "illustrativeHaze.js", + "illustrativeHaze.js", "enhancedEarth.js", "earthOceanMask.js", # render.js decides how an observed solar frame reaches the canvas: drawObservedBase() # chooses between a registered photospheric disk and a whole browse frame, and owns the # clip and limb treatment that go with each. That is observation rendering, so its hash diff --git a/tools/enhanced_earth_probe.mjs b/tools/enhanced_earth_probe.mjs new file mode 100644 index 00000000..9f2c23e0 --- /dev/null +++ b/tools/enhanced_earth_probe.mjs @@ -0,0 +1,14 @@ +// Input export for the optional offscreen GLES diagnostic. Run through render_enhanced_earth_probe.py. +import fs from 'node:fs'; +import * as sh from '../apps/web/js/orreryShaders.js'; +import {buildSphere,lookAt,perspective,mul,scaleM,norm} from '../apps/web/js/orreryMath.js'; +import {BODY} from '../apps/web/js/bodyData.js'; +import {hazeUniformValues} from '../apps/web/js/illustrativeHaze.js'; +import {getAtmosphereProfile} from '../apps/web/js/atmosphereOptics.js'; +import {oceanMaskPixels,EARTH_CLOUD_HEIGHT_KM} from '../apps/web/js/enhancedEarth.js'; +const eye=[2.3,-2.3,1],oblate=BODY.Earth.polarKm/BODY.Earth.radiusKm,cloudScale=1+EARTH_CLOUD_HEIGHT_KM/BODY.Earth.radiusKm; +const vp=mul(perspective(42*Math.PI/180,1,.01,100),lookAt(eye,[0,0,0],[0,0,1])); +const model=scaleM([1,1,oblate]),cloudModel=scaleM([cloudScale,cloudScale,cloudScale*oblate]),mesh=buildSphere(48,96); +const output={programs:{base:[sh.BASE_SPHERE_VS,sh.BASE_SPHERE_FS],physical:[sh.SCATTERING_SPHERE_VS,sh.SCATTERING_SPHERE_FS]},mesh:{pos:[...mesh.pos],idx:[...mesh.idx]},mask:[...oceanMaskPixels()],cloudModel,cloudMvp:mul(vp,cloudModel),uniforms:{ + u_mvp:mul(vp,model),u_model:model,u_nmat:[1,0,0,0,1,0,0,0,1/oblate],u_cam:eye,u_light:norm([1,-.15,.2]),u_lightObj:norm([1,-.15,.2]),u_oblate:oblate,u_bodyRadiusKm:BODY.Earth.radiusKm,u_map:[.5,1,2,0],u_mapLat:[-Math.PI/2,Math.PI/2,-Math.PI/2,Math.PI/2],u_mapWindow:[1,1,0,0],u_mapNoData:0,u_useTex:1,u_texMode:3,u_tex:0,u_textureLinear:1,u_weatherTex:3,u_nightTex:2,u_iceTex:4,u_oceanMask:11,u_ringTex:1,u_terrainHeight:5,u_atmosphereIncidentField:6,u_atmosphereColumnField:7,u_atmosphereOzoneField:10,u_style:-1,u_mode:0,u_earthWeather:1,u_earthNight:0,u_earthIce:0,u_earthEnhanced:0,u_earthCloudShadow:0,u_cloudScale:cloudScale,u_cloudPhase:0,u_linearOutput:0,u_atmosphereEnabled:0,u_terrainShadowEnabled:0,u_atmo:[.35,.6,1],u_base:[.2,.2,.2],...hazeUniformValues(getAtmosphereProfile('Earth'))}}; +fs.writeFileSync(process.argv[2],JSON.stringify(output)); diff --git a/tools/prepare_earth_ocean_mask.py b/tools/prepare_earth_ocean_mask.py new file mode 100644 index 00000000..01e38a21 --- /dev/null +++ b/tools/prepare_earth_ocean_mask.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Offline, conservative offshore display mask; never a scientific ocean product. + +Uses committed public-domain Natural Earth 1:110m land and glaciated-area rings. +Interior holes remain protected (inland water is not an ocean). A two-pixel +offshore margin and the shader's dark-blue gate protect the coarse coastline. +Run with --check to verify deterministic regeneration. +""" +import argparse +import hashlib +import json +import math +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ROOT / "tools/ephemeris-data/geography" +OUTPUT = ROOT / "apps/web/js/earthOceanMask.js" +WIDTH, HEIGHT = 512, 256 +INPUTS = { + "ne_110m_land.geojson": "9e0729ee253ca7d7a5c4ae9395fb1902264c5377c52e224d13dd85010e2835d9", + "ne_110m_glaciated_areas.geojson": "e61e3f45e22f087f861ac2fda6258fc15c849a216d74c27ccf0164eeafbd950a", +} + + +def unwrapped(ring): + lon = (ring[0][0] + 180) % 360 - 180 + points = [((lon + 180) * WIDTH / 360, (90 - ring[0][1]) * HEIGHT / 180)] + for a, b in zip(ring, ring[1:]): + delta = b[0] - a[0] + if delta > 180: + delta -= 360 + elif delta < -180: + delta += 360 + lon += delta + points.append(((lon + 180) * WIDTH / 360, (90 - b[1]) * HEIGHT / 180)) + return points + + +def mask_pixels(): + # Integer bit rows keep rasterization/dilation deterministic without image libraries. + rows = [0] * HEIGHT + for name, digest in INPUTS.items(): + data = (SOURCE / name).read_bytes() + if hashlib.sha256(data).hexdigest() != digest: + raise ValueError(f"Source hash mismatch: {name}") + for feature in json.loads(data)["features"]: + geometry = feature["geometry"] + polygons = [geometry["coordinates"]] if geometry["type"] == "Polygon" else geometry["coordinates"] + for polygon in polygons: + ring = polygon[0] + points = unwrapped(ring) + if abs(points[-1][0] - points[0][0]) > WIDTH * .99: + # Close polar rings at their westernmost vertex, through the pole. + start = min(range(len(points) - 1), key=lambda i: points[i][0]) + ring = ring[start:-1] + ring[:start] + [ring[start]] + points = unwrapped(ring) + pole = HEIGHT if sum(p[1] for p in ring) < 0 else 0 + points += [(points[-1][0], pole), (points[0][0], pole)] + for offset in (-WIDTH, 0, WIDTH): + shifted = [(x + offset, y) for x, y in points] + edges = list(zip(shifted, shifted[1:] + shifted[:1])) + for y in range(max(0, math.floor(min(p[1] for p in shifted))), + min(HEIGHT, math.ceil(max(p[1] for p in shifted)))): + scan = y + .5 + hits = sorted(a[0] + (scan - a[1]) * (b[0] - a[0]) / (b[1] - a[1]) + for a, b in edges if (a[1] > scan) != (b[1] > scan)) + for left, right in zip(hits[::2], hits[1::2]): + lo, hi = max(0, math.ceil(left - .5)), min(WIDTH, math.ceil(right - .5)) + if hi > lo: + rows[y] |= ((1 << (hi - lo)) - 1) << lo + # Two-pixel dilation, wrapping longitude and clamping the poles. + full = (1 << WIDTH) - 1 + protected = [] + for y in range(HEIGHT): + row = 0 + for source in rows[max(0, y - 2):min(HEIGHT, y + 3)]: + row |= source + expanded = row + for shift in (1, 2): + expanded |= ((row << shift) | (row >> (WIDTH - shift))) & full + expanded |= ((row >> shift) | (row << (WIDTH - shift))) & full + protected.extend(0 if expanded & (1 << x) else 255 for x in range(WIDTH)) + return bytes(protected) + + +def generate(): + pixels = mask_pixels() + runs, previous, length = [], 0, 0 + for value in pixels: + if value != previous: + runs.append(length) + previous, length = value, 0 + length += 1 + runs.append(length) + return ( + "// GENERATED by tools/prepare_earth_ocean_mask.py. Public-domain Natural Earth.\n" + "// Conservative offshore display mask, not a qualified coastline or reflectance map.\n" + f"export const OCEAN_MASK_WIDTH={WIDTH},OCEAN_MASK_HEIGHT={HEIGHT};\n" + f"export const OCEAN_MASK_SOURCES={json.dumps(INPUTS, separators=(',', ':'))};\n" + f"export const OCEAN_MASK_SHA256='{hashlib.sha256(pixels).hexdigest()}';\n" + f"export const OCEAN_MASK_RUNS={json.dumps(runs, separators=(',', ':'))};\n" + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + content = generate() + if args.check: + if not OUTPUT.exists() or OUTPUT.read_text() != content: + raise SystemExit("Earth ocean mask differs; regenerate it") + print("OK: Earth ocean mask reproduces exactly") + else: + OUTPUT.write_text(content) + print(f"Wrote {OUTPUT.relative_to(ROOT)} ({len(content)} bytes)") diff --git a/tools/render_enhanced_earth_probe.py b/tools/render_enhanced_earth_probe.py new file mode 100644 index 00000000..ca4861a8 --- /dev/null +++ b/tools/render_enhanced_earth_probe.py @@ -0,0 +1,126 @@ +"""Optional Linux EGL/Mesa diagnostic using production shaders, not browser qualification. + +Requires system libEGL, Node, NumPy and Pillow. No network or browser automation. +Outputs controlled comparisons and shader/source hashes to --output (default: docs/validation/enhanced-earth). +""" +import argparse +import ctypes as c +import hashlib +import json +import subprocess +import tempfile +from pathlib import Path +import numpy as np +from PIL import Image, ImageDraw, ImageFont +ROOT=Path(__file__).resolve().parent.parent +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('--output',type=Path,default=ROOT/'docs/validation/enhanced-earth') +args=parser.parse_args() +OUT=args.output +OUT.mkdir(parents=True,exist_ok=True) +with tempfile.TemporaryDirectory() as tmp: + data=Path(tmp)/'probe.json' + subprocess.run(['node',str(ROOT/'tools/enhanced_earth_probe.mjs'),str(data)],check=True) + D=json.loads(data.read_text()) +W=768 +E=c.CDLL('libEGL.so.1');E.eglGetProcAddress.argtypes=[c.c_char_p];E.eglGetProcAddress.restype=c.c_void_p + +def egl(name,result,*types): + f=getattr(E,name);f.restype=result;f.argtypes=types;return f + +def gl(name,result,*types): + addr=E.eglGetProcAddress(name.encode());assert addr,name + return c.CFUNCTYPE(result,*types)(addr) + +I,U,P,F=c.c_int,c.c_uint,c.c_void_p,c.c_float +platform=c.CFUNCTYPE(P,U,P,c.POINTER(I))(E.eglGetProcAddress(b'eglGetPlatformDisplayEXT')) +d=platform(0x31DD,None,None);ma,mi=I(),I();assert egl('eglInitialize',U,P,c.POINTER(I),c.POINTER(I))(d,c.byref(ma),c.byref(mi)) +assert egl('eglBindAPI',U,U)(0x30A0) +attrs=(I*15)(0x3033,1,0x3040,0x40,0x3024,8,0x3023,8,0x3022,8,0x3021,8,0x3025,24,0x3038) +cfg=P();n=I();assert egl('eglChooseConfig',U,P,c.POINTER(I),c.POINTER(P),I,c.POINTER(I))(d,attrs,c.byref(cfg),1,c.byref(n)) and n.value +surface=egl('eglCreatePbufferSurface',P,P,P,c.POINTER(I))(d,cfg,(I*5)(0x3057,W,0x3056,W,0x3038)) +ctx=egl('eglCreateContext',P,P,P,P,c.POINTER(I))(d,cfg,None,(I*3)(0x3098,3,0x3038));assert ctx +assert egl('eglMakeCurrent',U,P,P,P,P)(d,surface,surface,ctx) +renderer=gl('glGetString',c.c_char_p,U)(0x1F01).decode();version=gl('glGetString',c.c_char_p,U)(0x1F02).decode();print(renderer,version,flush=True) +create=gl('glCreateShader',U,U);source=gl('glShaderSource',None,U,I,c.POINTER(c.c_char_p),P);compile_=gl('glCompileShader',None,U);getshader=gl('glGetShaderiv',None,U,U,c.POINTER(I));slog=gl('glGetShaderInfoLog',None,U,I,P,c.c_char_p) +cp=gl('glCreateProgram',U);attach=gl('glAttachShader',None,U,U);link=gl('glLinkProgram',None,U);getprog=gl('glGetProgramiv',None,U,U,c.POINTER(I));plog=gl('glGetProgramInfoLog',None,U,I,P,c.c_char_p) +programs={} +for name,shaders in D['programs'].items(): + p=cp() + for kind,text in zip((0x8B31,0x8B30),shaders): + s=create(kind);raw=c.c_char_p(text.encode());source(s,1,c.byref(raw),None);compile_(s);ok=I();getshader(s,0x8B81,c.byref(ok)) + if not ok.value: + msg=c.create_string_buffer(50000);slog(s,len(msg),None,msg);raise RuntimeError(msg.value.decode()) + attach(p,s) + link(p);ok=I();getprog(p,0x8B82,c.byref(ok)) + if not ok.value: + msg=c.create_string_buffer(50000);plog(p,len(msg),None,msg);raise RuntimeError(msg.value.decode()) + programs[name]=p;print('compiled and linked',name,flush=True) +p=programs['base'];gl('glUseProgram',None,U)(p) +getloc=gl('glGetUniformLocation',I,U,c.c_char_p) +ints={'u_mode','u_style','u_useTex','u_texMode','u_mapNoData','u_textureLinear','u_tex','u_weatherTex','u_nightTex','u_iceTex','u_oceanMask','u_ringTex','u_terrainHeight','u_atmosphereIncidentField','u_atmosphereColumnField','u_atmosphereOzoneField','u_earthWeather','u_earthNight','u_earthIce','u_earthEnhanced','u_earthCloudShadow','u_linearOutput','u_atmosphereEnabled','u_terrainShadowEnabled'} +def uniform(name,value): + loc=getloc(p,name.encode()) + if loc<0:return + if isinstance(value,list): + arr=(F*len(value))(*value) + if len(value) in (9,16):gl('glUniformMatrix'+('3' if len(value)==9 else '4')+'fv',None,I,I,U,c.POINTER(F))(loc,1,0,arr) + else:gl(f'glUniform{len(value)}fv',None,I,I,c.POINTER(F))(loc,1,arr) + elif name in ints:gl('glUniform1i',None,I,I)(loc,value) + else:gl('glUniform1f',None,I,F)(loc,value) +for name,value in D['uniforms'].items():uniform(name,value) +# All actual sampler2D uniforms get distinct, valid texture bindings. +gen=gl('glGenTextures',None,I,c.POINTER(U));bind=gl('glBindTexture',None,U,U);active=gl('glActiveTexture',None,U);param=gl('glTexParameteri',None,U,U,I);teximage=gl('glTexImage2D',None,U,I,I,I,I,I,U,U,P);mip=gl('glGenerateMipmap',None,U) +def texture(unit,array,internal=0x8058,fmt=0x1908): + tex=U();gen(1,c.byref(tex));active(0x84C0+unit);bind(0x0DE1,tex.value) + a=np.ascontiguousarray(array,dtype=np.uint8);h,w=a.shape[:2];teximage(0x0DE1,0,internal,w,h,0,fmt,0x1401,a.ctypes.data) + param(0x0DE1,0x2801,0x2703);param(0x0DE1,0x2800,0x2601);param(0x0DE1,0x2802,0x2901);param(0x0DE1,0x2803,0x812F);mip(0x0DE1) +for unit in range(12):texture(unit,np.full((1,1,4),255,dtype=np.uint8)) +texture(0,np.array(Image.open(ROOT/'apps/web/textures/reference/earth-land-2004.jpg').convert('RGBA')),0x8C43) +cloud=np.array(Image.open(ROOT/'apps/web/textures/reference/earth-blue-marble-2002-cloud-layer.png').convert('RGBA')) +cloud[:,:,:3]=((cloud[:,:,:3].astype(np.uint16)*cloud[:,:,3,None]+127)//255).astype(np.uint8) +texture(3,cloud) +texture(11,np.array(D['mask'],dtype=np.uint8).reshape(256,512),0x8229,0x1903) +genbuf=gl('glGenBuffers',None,I,c.POINTER(U));bindbuf=gl('glBindBuffer',None,U,U);bufdata=gl('glBufferData',None,U,c.c_ssize_t,P,U) +a=np.array(D['mesh']['pos'],dtype=np.float32);ind=np.array(D['mesh']['idx'],dtype=np.uint16) +for target,array in [(0x8892,a),(0x8893,ind)]: + b=U();genbuf(1,c.byref(b));bindbuf(target,b);bufdata(target,array.nbytes,array.ctypes.data,0x88E4) +for loc in (0,1): + gl('glEnableVertexAttribArray',None,U)(loc);gl('glVertexAttribPointer',None,U,I,U,U,I,P)(loc,3,0x1406,0,12,None) +enable=gl('glEnable',None,U);disable=gl('glDisable',None,U);depth=gl('glDepthMask',None,U);blend=gl('glBlendFunc',None,U,U) +gl('glViewport',None,I,I,I,I)(0,0,W,W);enable(0x0B71);enable(0x0B44);gl('glCullFace',None,U)(0x0405);enable(0x0BE2);blend(0x0302,0x0303) +# SOL sphere triangles use the same winding and back-face culling as production. +draw=gl('glDrawElements',None,U,I,U,P);read=gl('glReadPixels',None,I,I,I,I,U,U,P) +geterror=gl('glGetError',U) +assert geterror()==0,'setup GL error' +def render(enhanced,phase=0,shadows=True): + gl('glClearColor',None,F,F,F,F)(.008,.01,.017,1);depth(1);gl('glClear',None,U)(0x4000|0x0100) + uniform('u_model',D['uniforms']['u_model']);uniform('u_mvp',D['uniforms']['u_mvp']);uniform('u_mode',0) + uniform('u_cloudPhase',phase);uniform('u_earthWeather',int(not enhanced));uniform('u_earthEnhanced',int(enhanced));uniform('u_earthCloudShadow',int(enhanced and shadows)) + draw(4,len(ind),0x1403,None) + if enhanced: + uniform('u_model',D['cloudModel']);uniform('u_mvp',D['cloudMvp']);uniform('u_mode',3);depth(0);draw(4,len(ind),0x1403,None);depth(1) + gl('glFinish',None)();pixels=np.empty((W,W,4),dtype=np.uint8);read(0,0,W,W,0x1908,0x1401,pixels.ctypes.data) + assert geterror()==0,'render GL error' + return Image.fromarray(pixels[::-1,:,:3]) +original=render(False);enhanced=render(True);drifted=render(True,.02);unshadowed=render(True,shadows=False) +canvas=Image.new('RGB',(W*2,W+88),(8,10,16));canvas.paste(original,(0,70));canvas.paste(enhanced,(W,70));pd=ImageDraw.Draw(canvas) +font='/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';f=ImageFont.truetype(font,22);small=ImageFont.truetype(font,14) +pd.text((25,12),'Original Earth',font=f,fill='white');pd.text((W+25,12),'Enhanced Earth',font=f,fill='white') +pd.text((25,44),'Same NASA maps, camera and Sun direction',font=small,fill='#adb6c4');pd.text((W+25,44),'Darker oceans · 8 km cloud shell · Sun-aligned shadows',font=small,fill='#adb6c4') +pd.text((25,W+64),'Offscreen OpenGL ES software probe · production base shaders / illustrative haze · not a browser qualification',font=small,fill='#adb6c4') +canvas.save(OUT/'comparison.jpg',quality=92) +arrays=[np.array(im,dtype=np.int16) for im in (original,enhanced,drifted,unshadowed)] +# A black ground has no reflected direct light to shadow. Cloud shadows must +# therefore leave its haze contribution unchanged (both passes use identical clouds). +black=np.zeros((1,1,4),dtype=np.uint8);black[:,:,3]=255;texture(0,black,0x8C43) +haze_shadow=np.array(render(True,shadows=True));haze_clear=np.array(render(True,shadows=False)) +assert np.array_equal(haze_shadow,haze_clear),'cloud shadows incorrectly attenuate atmospheric haze' +assert np.any(arrays[0]!=arrays[1]),'enhancement has no visible effect' +assert np.any(arrays[1]!=arrays[2]),'cloud phase has no visible effect' +assert np.any(arrays[1]!=arrays[3]),'cloud shadows have no visible effect' +result={'renderer':renderer,'version':version,'shader_sha256':{name:[hashlib.sha256(stage.encode()).hexdigest() for stage in stages] for name,stages in D['programs'].items()},'programs_compiled':list(programs),'width':W,'height':W, + 'changed_pixels':int(np.any(arrays[0]!=arrays[1],axis=2).sum()),'drift_changed_pixels':int(np.any(arrays[1]!=arrays[2],axis=2).sum()),'shadow_changed_pixels':int(np.any(arrays[1]!=arrays[3],axis=2).sum()), + 'scope':'Controlled offscreen OpenGL ES software rendering of production shaders with original NASA maps; base haze path, no browser/native-device qualification.'} +result['source_sha256']={str(path.relative_to(ROOT)):hashlib.sha256(path.read_bytes()).hexdigest() for path in [ROOT/'apps/web/textures/reference/earth-land-2004.jpg',ROOT/'apps/web/textures/reference/earth-blue-marble-2002-cloud-layer.png',ROOT/'apps/web/js/earthOceanMask.js']} +(OUT/'gl-probe.json').write_text(json.dumps(result,indent=2)+'\n');print(json.dumps(result),flush=True)