Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ Items with a **→ details** link have a full write-up in [`backlog/`](backlog/)
- [ ] **Saturn ring brightness** `ready` — the ring reads too dim next to the new limb-darkened disc; retune ring albedo/exposure (surfaced in the planet-atmospherics per-body visual pass).
- [ ] **Atmosphere limb transparent seam** `needs-investigation` — thin fully-transparent ring between a body's surface and its atmosphere shell (seen on Mars). → [details](backlog/2026-07-24-atmosphere-limb-transparent-seam.md)
- [ ] **Body-texture colour calibration** `needs-design` — Mars reads over-saturated; the `sss` sources are enhanced, not colorimetric, and no target appearance is recorded. → [details](backlog/2026-07-24-mars-texture-colour-calibration.md)
- [ ] **Body texture meridian registration (non-Earth)** `deferred` — every non-Earth map renders 180° rotated about its spin axis (invisible without a phase ground truth; Earth fixed in #472). → [details](backlog/2026-07-21-body-texture-meridian-registration.md)
- [ ] **Body-texture store consolidation** `needs-design` — four renderers (textured, Earth, ring, cloud-shell) each hand-roll map storage + placeholder fallback separately. → [details](backlog/2026-07-24-body-texture-store-consolidation.md)
- [ ] **Photoreal-Earth follow-ups** `deferred` — drift traps + fidelity gaps from plans A–E (equirect-uv mirror, setMap kind table, shared proxy-sphere idiom). → [details](backlog/2026-07-19-photoreal-earth-followups.md)
- [ ] **Titan atmosphere** `needs-design` — minimal params-row-over-flat-sphere vs full Venus-style cloud-as-surface + limb treatment (needs a texture through the fetch/build pipeline). → [details](backlog/2026-07-19-titan-atmosphere.md)
Expand Down
27 changes: 0 additions & 27 deletions docs/backlog/2026-07-21-body-texture-meridian-registration.md

This file was deleted.

34 changes: 0 additions & 34 deletions src/data/bodies/earthTexturePrimeMeridianU.ts

This file was deleted.

37 changes: 37 additions & 0 deletions src/data/bodies/texturePrimeMeridianU.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* TEXTURE_PRIME_MERIDIAN_U — the equirectangular-map registration offset that
* puts the prime meridian (geographic longitude 0) at the texture's CENTRE
* column, where every standard planetary map (Blue Marble, the Solar System
* Scope set, the USGS Galilean maps) paints it. A raw `u = lon / 2π` would land
* the ANTIMERIDIAN on the local +x axis the IAU rotation aims a body's prime
* meridian at, rotating the whole surface 180° about the pole — continents on
* the wrong hemisphere, the day/night terminator reading inverted against a live
* clock, and a tidally-locked Moon showing its FAR side to Earth. Adding this
* offset re-registers the map.
*
* ### One convention, three sites — greppable, not importable
*
* The same `+0.5` fact is encoded in three places that cannot share this symbol,
* because one of them is a WESL shader and WESL cannot import a TS constant:
*
* 1. `src/utils/math/uvSphereMesh.ts` — bakes it into the shared sphere's
* vertex `u` (the textured planets, the Moon and the Galileans, Earth's
* cloud deck).
* 2. `src/utils/math/cubeSphereMesh.ts` — bakes it into the Earth-only mesh's
* vertex `u`.
* 3. `src/services/gpu/shaders/bodies/earth/fragment.wesl` — `dirToEquirectUv`
* re-encodes it to sample the identical texel a mesh vertex would, for the
* ground-shadow crossing point that has no interpolated uv.
*
* Sites 1 and 2 import this constant and are pinned by `uvSphereMesh.test.ts`
* (lon 0 → u 0.5) and `tests/services/engine/frame/earthTerminator.test.ts` (the
* terminator reads correct only if the mesh uv is registered right). The shader
* half (site 3) has no TS-importable link, so it is guarded the honest minimum
* way: that same terminator test exercises the shading path, and its comment
* names the literal token `TEXTURE_PRIME_MERIDIAN_U` plus the other two sites, so
* a `grep` for the token surfaces every place that must move together if the
* map-authoring convention ever changes.
*/

/** Equirectangular u offset registering longitude 0 to the map centre (u = 0.5). */
export const TEXTURE_PRIME_MERIDIAN_U = 0.5;
34 changes: 11 additions & 23 deletions src/services/gpu/shaders/bodies/cloudShell/fragment.wesl
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,12 @@
// The cloud map is equirectangular, same v-orientation as the surface albedo:
// the renderer uploads it flipY:true so texture v=0 is the image's bottom (south)
// row, matching 'uvSphereMesh's south-first v. HANDEDNESS reads raw (longitude on
// +Y winds east to screen-right for an outside viewer). The u ORIGIN needs a +0.5
// though: the map paints the prime meridian (geographic longitude 0) at its CENTRE
// (u=0.5), while 'uvSphereMesh' emits u=0 at the local +x prime meridian. The
// Earth SURFACE rides the Earth-only 'cubeSphereMesh', which now bakes this same
// +0.5 into its uv; the deck rides the SHARED 'uvSphereMesh' (also serving the
// distant planets), so the correction stays local to this Earth shader rather than
// shifting every planet's mesh. Without it the deck sits 180deg off the surface
// beneath it. See 'earth/fragment.wesl' for the full derivation.
// +Y winds east to screen-right for an outside viewer). The u ORIGIN needs no
// correction here: 'uvSphereMesh' bakes TEXTURE_PRIME_MERIDIAN_U into its vertex
// u, registering the map's centre column (geographic longitude 0) on the local +x
// prime meridian -- the same offset 'cubeSphereMesh' bakes for the Earth SURFACE
// this deck rides above, so the two agree by construction. See
// 'earth/fragment.wesl' for the full derivation.
//
// ## Blend target
//
Expand All @@ -80,21 +78,11 @@ import package::lib::sphere::CloudShellUniforms;

@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
// RGB = cloud colour (sRGB de-gammad on read), .a = coverage. The +0.5 on u is
// the EARTH_TEXTURE_PRIME_MERIDIAN_U convention: it puts the map centre
// (geographic longitude 0) on the local +x prime meridian, matching the surface
// (see the '## uv / orientation' note); the shared uvSphereMesh emits u=0 there,
// so the origin correction lives here. WESL cannot import the TS constant, so
// this offset is re-encoded; the other two sites of the same 0.5 are
// 'cubeSphereMesh.ts' (the TS home exporting EARTH_TEXTURE_PRIME_MERIDIAN_U) and
// 'earth/fragment.wesl'. Grep the token to find all three.
// No explicit fract() wrap: the sampler uses addressModeU 'repeat', so a u of
// 0.5..1.5 wraps correctly in hardware. Wrapping in-shader with fract() instead
// breaks the quad derivatives at the u=0.5 seam (the value jumps 1->0), which
// forces the coarsest mip along a ~1px antimeridian line; leaving u continuous
// keeps ddx/ddy well-behaved and lets repeat addressing do the wrap.
let uv = vec2<f32>(in.uv.x + 0.5, in.uv.y);
let c = textureSample(cloudTexture, cloudSampler, uv);
// RGB = cloud colour (sRGB de-gammad on read), .a = coverage. The mesh uv is
// already registered on the prime meridian (see the '## uv / orientation' note),
// so it samples straight through. Its u runs 0.5..1.5 unwrapped and the sampler
// uses addressModeU 'repeat', so the hardware closes the turn.
let c = textureSample(cloudTexture, cloudSampler, in.uv);
// Sun-relative day/night term, decomposed so 'sunIrradiance' scales the DIRECT
// (sun-lit) portion only -- mirroring the surface, whose direct term is scaled
// by the same irradiance while its ambient floor is not. The 'u.ambientLight'
Expand Down
11 changes: 6 additions & 5 deletions src/services/gpu/shaders/bodies/earth/fragment.wesl
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,15 @@ import package::lib::math::TAU;
//
// u = east longitude / TAU + 0.5. 'cubeSphereMesh' puts longitude on +Y, so
// lon = atan2(dir.y, dir.x) (0 at +X, +pi/2 at +Y). The +0.5 is the
// EARTH_TEXTURE_PRIME_MERIDIAN_U convention: the prime meridian (lon 0)
// TEXTURE_PRIME_MERIDIAN_U convention: the prime meridian (lon 0)
// samples the map CENTRE u=0.5, where standard equirectangular planetary
// maps paint geographic longitude 0 (see the '## uv / u-orientation' note).
// WESL cannot import the TS constant, so this offset is re-encoded here; the
// other two sites of the same 0.5 are 'cubeSphereMesh.ts' (the TS home that
// exports EARTH_TEXTURE_PRIME_MERIDIAN_U) and 'cloudShell/fragment.wesl'.
// Grep the token to find all three. 'fract' wraps to [0, 1) -- the same
// wrap the sampler's 'repeat' addressing applies.
// other two sites of the same 0.5 are the meshes that bake it into their
// vertex u -- 'cubeSphereMesh.ts' (Earth's, and the TS home that exports
// TEXTURE_PRIME_MERIDIAN_U) and 'uvSphereMesh.ts' (every other body plus
// this Earth's cloud deck). Grep the token to find all three. 'fract' wraps
// to [0, 1) -- the same wrap the sampler's 'repeat' addressing applies.
// v = latitude mapped south (0) to north (1): lat = asin(dir.z) in
// [-pi/2, pi/2], so v = lat/PI + 0.5 matches the mesh's south-first v
// (v=0 south pole, v=1 north pole) that flipY:true co-registers with.
Expand Down
12 changes: 6 additions & 6 deletions src/utils/math/cubeSphereMesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@

import type { CubeSphereMesh } from '../../@types/math/CubeSphereMesh';
import type { Vec3 } from '../../@types/math/Vec3';
import { EARTH_TEXTURE_PRIME_MERIDIAN_U } from '../../data/bodies/earthTexturePrimeMeridianU';
import { TEXTURE_PRIME_MERIDIAN_U } from '../../data/bodies/texturePrimeMeridianU';

// Six cube faces (index 0..5 = +x, -x, +y, -y, +z, -z). Each carries its outward
// normal and two in-plane axes chosen so sAxis × tAxis = normal — which makes the
Expand Down Expand Up @@ -149,7 +149,7 @@ export function cubeSphereMesh(
const lon = Math.atan2(y, x); // Right Ascension, (-π, π]
const lat = Math.asin(Math.max(-1, Math.min(1, z))); // Declination, [-π/2, π/2]
// Base u in [0,1); per-triangle shifts restore continuity across the seam
// below. EARTH_TEXTURE_PRIME_MERIDIAN_U puts the PRIME MERIDIAN (lon 0, the
// below. TEXTURE_PRIME_MERIDIAN_U puts the PRIME MERIDIAN (lon 0, the
// local +x the IAU rotation orients Earth's Greenwich to) at u=0.5 — the
// image CENTRE, where every standard equirectangular planetary map (Blue
// Marble and the rest) paints geographic longitude 0. Without it a raw
Expand All @@ -158,10 +158,10 @@ export function cubeSphereMesh(
// day/night terminator reads inverted against a live clock (mid-afternoon
// Europe shown in night). The seam (u wrap) moves to lon=±π accordingly;
// windowShifts re-continuizes it per triangle wherever it lands, and the
// +u=east tangent below is unchanged. The two shader sites that re-encode
// this same offset (they can't import a TS constant) are named in the
// constant's docblock.
let u = lon / (2 * Math.PI) + EARTH_TEXTURE_PRIME_MERIDIAN_U;
// +u=east tangent below is unchanged. `uvSphereMesh` bakes the same offset
// for every other body; the one shader site that re-encodes it (it can't
// import a TS constant) is named in the constant's docblock.
let u = lon / (2 * Math.PI) + TEXTURE_PRIME_MERIDIAN_U;
u = u - Math.floor(u);
const v = lat / Math.PI + 0.5;
uvs.push(u, v);
Expand Down
35 changes: 27 additions & 8 deletions src/utils/math/uvSphereMesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,34 @@
* equatorial frame means. Earth's 23.4° tilt relative to the ecliptic is then
* the frame relationship itself, not a rotation this mesh has to apply.
*
* A static Earth (the renderer applies no diurnal spin) models no rotation, so
* WHICH meridian faces which RA is an arbitrary construction choice, not a
* physical fact to reproduce: the prime meridian (lon 0) is fixed along +x, the
* vernal equinox direction. Longitude winds x→y (eastward, CCW seen from above
* the north pole) — matching both increasing RA and Earth's real eastward spin.
* The mesh's own longitude origin (lon 0) sits along +x; each body's IAU
* rotation (`rotationFromIau`) then swings that meridian to where the body's
* prime meridian actually points, so lon 0 IS the body's prime meridian.
* Longitude winds x→y (eastward, CCW seen from above the north pole) — matching
* both increasing RA and a prograde body's real eastward spin.
*
* x = cos(lat)·cos(lon) (lat = Dec, lon = RA)
* y = cos(lat)·sin(lon)
* z = sin(lat) (north pole = +z)
*
* UV mapping is equirectangular:
* u = longitude / (2π) → [0, 1] west-to-east
* v = latitude / π + 0.5 → [0, 1] south-to-north
* u = longitude / (2π) + TEXTURE_PRIME_MERIDIAN_U → [0.5, 1.5] west-to-east
* v = latitude / π + 0.5 → [0, 1] south-to-north
*
* ## Registering the map on the prime meridian
*
* `TEXTURE_PRIME_MERIDIAN_U` is what makes lon 0 sample the map's CENTRE column,
* where every standard planetary map paints longitude 0. Without it the map's
* antimeridian lands on the prime meridian and the whole surface reads 180° round
* — the Moon, tidally locked with its prime meridian aimed at Earth, showing its
* FAR side. `cubeSphereMesh` (Earth's) bakes the same offset from the same
* constant, so both meshes register identically.
*
* The offset is NOT wrapped back into [0, 1]: u runs 0.5 → 1.5 monotonically and
* the samplers address u as `repeat`, so the hardware does the wrap. Wrapping
* here instead would jump u by a whole turn mid-sphere, and that discontinuity
* breaks the fragment quad's derivatives at the seam — forcing the coarsest mip
* along a ~1 px antimeridian line.
*
* ## Winding
*
Expand All @@ -45,6 +60,7 @@
* a→c→b restores the outward normal. See the per-quad comment for the layout.
*/

import { TEXTURE_PRIME_MERIDIAN_U } from '../../data/bodies/texturePrimeMeridianU';
import type { UvSphereMesh } from '../../@types/math/UvSphereMesh';

export function uvSphereMesh(segments: number, rings: number): UvSphereMesh {
Expand All @@ -69,7 +85,10 @@ export function uvSphereMesh(segments: number, rings: number): UvSphereMesh {
const lon = (2 * Math.PI * s) / segments;
const cosLon = Math.cos(lon);
const sinLon = Math.sin(lon);
const u = s / segments; // [0,1] west-to-east
// [0.5, 1.5] west-to-east: the map's centre column registers on lon 0 (the
// body's prime meridian), and the turn is left unwrapped for the sampler's
// `repeat` addressing to close — see the module header.
const u = s / segments + TEXTURE_PRIME_MERIDIAN_U;

// Unit-sphere position in the equatorial J2000 frame (see module header):
// x = cos(lat)*cos(lon), y = cos(lat)*sin(lon), z = sin(lat)
Expand Down
38 changes: 27 additions & 11 deletions tests/utils/math/uvSphereMesh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,32 @@ describe('uvSphereMesh', () => {
expect(dot).toBeGreaterThan(0);
});

it('uv ranges are within [0,1]', () => {
const { uvs } = uvSphereMesh(12, 6);
const vertexCount = uvs.length / 2;
for (let i = 0; i < vertexCount; i++) {
const u = uvs[i * 2] as number;
const v = uvs[i * 2 + 1] as number;
expect(u).toBeGreaterThanOrEqual(0);
expect(u).toBeLessThanOrEqual(1);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
it('registers the map centre on the prime meridian (lon 0 → u 0.5)', () => {
// Standard planetary maps paint geographic longitude 0 at the image CENTRE,
// and the IAU rotation aims a body's prime meridian at local +x (lon 0). A
// raw u = lon/2π would land the map's ANTIMERIDIAN there — the whole surface
// rotated 180° about the pole (the Moon showing its far side from Earth).
const segments = 12;
const rings = 6;
const { uvs } = uvSphereMesh(segments, rings);
const uAt = (r: number, s: number) => uvs[(r * (segments + 1) + s) * 2] as number;

expect(uAt(0, 0)).toBeCloseTo(0.5); // lon 0 → map centre
expect(uAt(3, segments / 4)).toBeCloseTo(0.75); // lon 90°E → a quarter turn east
// The seam vertex closes exactly one turn; u stays continuous (never wrapped
// back to 0), so the sampler's `repeat` addressing does the wrap and the
// quad derivatives stay well-behaved across the antimeridian.
expect(uAt(3, segments)).toBeCloseTo(1.5);
});

it('v runs south-to-north over [0,1]', () => {
const rings = 6;
const segments = 12;
const { uvs } = uvSphereMesh(segments, rings);
const vAt = (r: number) => uvs[r * (segments + 1) * 2 + 1] as number;

expect(vAt(0)).toBeCloseTo(0); // south pole
expect(vAt(rings / 2)).toBeCloseTo(0.5); // equator
expect(vAt(rings)).toBeCloseTo(1); // north pole
});
});
Loading