After swapping axes so lng runs along canvas Y (corridor long
axis), canvas height was still GROUND_CORRIDOR_PERP * 256 = 1280
(only 5 tiles tall). The 25-tile-long corridor was drawn off-canvas,
so only the back 5 tiles (lng -12..-7) were visible. That made
new tiles 'disappear' off the right side and the build appear 90
deg off from flight.
Set canvas height to corridor long (25 tiles = 6400 px) and
canvas width to perp (5 tiles = 1280 px). Now the full corridor
fits on canvas and the visual depth direction matches the
camera's flight direction along canvas Y.
Previous mapping placed the corridor's long axis (flight direction
+Z) on canvas X (horizontal). User observed this as '90deg to
flight direction' because the tiles appeared to scroll sideways on
screen instead of along depth.
Swap so the long axis is on canvas Y (vertical on canvas):
- drawTileAt: canvasX = (perp + halfPerp + 0.5) * TILE_SIZE,
canvasY = (lng + halfLong + 0.5) * TILE_SIZE
- shader gUv: gUv.x = rotated.y / uGroundSize.x + 0.5 (perp axis),
gUv.y = rotated.x / uGroundSize.y + 0.5 (long axis)
- uGroundSize call: (PERP, LONG) instead of (LONG, PERP)
Now the corridor's long axis runs vertically on canvas. The screen
mapping (uv.y -> forward distance) reads the long axis as a vertical
strip, matching user expectation that 'forward' tiles scroll top-
to-bottom (or bottom-to-top) as the camera flies.
With BUFFER_LONG = 1, refetch threshold was halfLong - 1 = 11,
which meant each refetch shifted the corridor centre by ~11 tiles.
From the camera's view that looked like the corridor suddenly
jumping 11 tiles forward — described as 'tiles jump to the right'.
BUFFER_LONG = 11 -> threshold = 1, so refetch fires as soon as
the camera crosses one tile beyond centre. Each refetch shifts by
~1 tile, which the user perceives as a smooth scroll rather than
a jump. Refetches now happen every 8 sec instead of every 92 sec,
but the load is small (only ~1 net new tile since browser caches
ESRI responses for 24h).
Shader maps world coords to UV via:
gUv = rotated / uGroundSize + 0.5
rotated = 0 -> canvas centre
But drawTileAt placed tiles at:
canvasX = (lng + halfLong) * TILE_SIZE
canvasY = (perp + halfPerp) * TILE_SIZE
With halfLong = 12 and lng iterating [-12..12], tile lng=0 lands
at canvas X = 3072 - but the shader wants gUv.x=0.5 (rotated=0)
at canvas centre X = 3200. Half a tile off.
Fix: shift drawing by +0.5 tiles so each tile CENTRE is at
(lng, perp), matching the shader's rotated-frame interpretation.
Tile centres now align with shader expectation; previous half-tile
diagonal wobble gone (which is what they meant by 'x und y
vertauscht' - the perpendicular axis was indeed offset).
Two visual issues reported: stripes from sides at tilts of view, and
tiles 'hopping' one tile per refetch.
Stripes: caused by gUv.y going OOB at sideways rays where hitPoint.x
is large; CLAMP_TO_EDGE returned the same edge colour across a wide
band, looking like a stripe. Added shader edgeFade that fades OOB
samples smoothly to uBg instead.
Hopping: each refetch shifts centerTile by many tiles (depends on
when in the cycle it fires). With BUFFER_LONG = 1 (refetch at
projLong > halfLong - 1 = 11), the centerTile only ever shifts by
about 1 tile per refetch. Refetches happen more often (~ every
8 sec instead of ~ every 96 sec) but each is tiny.
reverted partial-refetch optimisation: causes ghosting because old
tiles stay at old canvas positions but uGroundCenter moves, so
the shader samples stale content. Better to clear canvas and rely
on edgeFade to hide the brief fog state.
When I changed GROUND_TRAVEL_COS/SIN to (0, 1) (90 deg, +Z
direction) in JS, the shader still used the old (0.82, 0.57)
rotation (35 deg). The corridor was built along +Z but UVs were
computed as if the canvas were rotated 35 deg — meaning the
corridor was effectively at 35 deg from where tiles were loaded.
Tiles ended up diagonally to the flight path.
Camera speed was 0.45 units/sec which on 13km/tile tiles = ~5850
km/h = supersonic. User reported flying through tiles too fast.
- drift speed 0.45 -> 0.12 units/sec (~1500 km/h, fast plane)
- corridor 15 -> 25 tiles along travel (~3.5 min between refetches
at uSpeed=1, instead of ~12 sec)
- buffer 2 -> 3 tiles (refetch starts earlier so we always have
tiles loaded before the camera reaches the edge)
- Updated JS-side drift calculation to match the new shader speed
Camera now leisurely follows the corridor; refetch only once every
couple of minutes.
Camera was moving in (1, 0.7) but looking mostly in +Z — 55
deg between them meant tiles collected along motion weren't in
the user's forward view. Now:
- ro = (0, 2.2, drift) \u2014 motion purely in +Z
- GROUND_TRAVEL_COS/SIN = (0, 1) \u2014 corridor runs along +Z
- rd = mostly forward +Z, with downward tilt for the ground view
Everything (motion, look, tile collection) is now on the same
+Z axis. Tiles appear directly in front of the camera as it
moves forward, no more 45-55 deg offset.
Camera moves at uTime * uSpeed * 0.45 in +X with +X*0.7 in +Z.
Previous axis-aligned corridor (long in X, perp in Z) was
perpendicular to the actual travel direction, wasting tiles in
the +Z / -X corner where the camera never goes.
Now the corridor is rotated 35 deg to match travel:
- GROUND_TRAVEL_COS/SIN = 0.82/0.57 (cos/sin of 35 deg)
- GROUND_CORRIDOR_LONG = 15 (tiles along travel)
- GROUND_CORRIDOR_PERP = 5 (tiles perpendicular)
- rebuildGroundMosaic iterates (lng, perp) in rotated frame,
draws each tile at canvas position (lng, perp) but loads from
tile (dx, dz) rotated from (lng, perp) by travel angle
- Shader rotates hitPoint into corridor frame, then divides by
per-axis extent to get UV
The camera moves primarily in +X (drift = uTime * uSpeed * 0.45)
with a slight +Z component (drift * 0.7). A square 9x9 mosaic
wastes half its tiles behind the camera. A corridor shape with
13 tiles along travel (X) and 5 tiles perpendicular (Z) uses 65
tiles to cover the same useful area — and refetches happen only
when the camera approaches the forward edge, not all four sides.
- GROUND_CORRIDOR_X = 13 (≈13s of forward travel at uSpeed=1)
- GROUND_CORRIDOR_Z = 5
- GROUND_BUFFER_X = 2, GROUND_BUFFER_Z = 1
- Shader uniform uGroundSize is now vec2 (sizeX, sizeZ)
- UV mapping: gUv = (hitPoint - centre) / size + 0.5 with per-axis size
Tile coords were wrong — Passau at zoom 11 is (1099, 706), not
(1102, 710). My earlier hand-calc (1100/706) was close but
rounded in the wrong direction.
Mosaic 16x16 = 256 tiles took 5-10s to load due to browser
6-connection limit. Reduced to 9x9 = 81 tiles (~2s). Camera
already looks down enough that visible ground fits in 9 units.
Previous look direction had rd.y from -0.05 to -0.40 across
the screen, so most rays hit ground at tGround in [9, 44] — way
beyond the 16-unit mosaic. Those OOB pixels came back as solid
edge colour, creating the striped band of texture-then-edge
transitions.
New look: rd.y from -1.10 (bottom) to 0.20 (top). At uv.y=0
rd.y=-0.45 (26° below horizon) so the visible ground sits at
tGround < 6 — all within the 16-unit mosaic. Ground mask
threshold also tightened from -0.30 to -0.10 so the ground
fades to sky higher in the image (looks like atmospheric haze).
Fog distance 14 → matches the new closer-ground regime.
- GROUND_MOSAIC_SIZE 9 -> 16 (16x16 = 256 tiles, covers 16 world
units instead of 9 — wider safety margin at the mosaic edge)
- Fog: tGround / 30 -> tGround / 12 with full mix (was 75% mix)
so distant ground fully fades to sky instead of staying visible
with wrong UVs (which was the source of the striped look)
- Result: visible ground content matches mosaic; far ground
cleanly fades to sky, close ground shows clear satellite tiles
Last commit fixed gl calls inside the .then() but missed the
calls in the body of initGroundSystem itself. Replaced every
gl.X reference with groundGl.X in the new code.
The function used bare 'gl' but it's a module-level closure, so
gl wasn't defined inside the .then() callback. Added groundGl
module variable, set it in initGroundSystem, used it everywhere
in rebuildGroundMosaic instead of bare 'gl'.
Replace static ground.jpg with a dynamic tile-based system that
fetches new tiles from ESRI World Imagery as the camera moves
through the world.
- GROUND_TILE_Z = 11 (~13 km per tile)
- GROUND_MOSAIC_SIZE = 9 (9x9 = 81 tiles, 2304x2304 canvas)
- GROUND_BUFFER_TILES = 2 (refetch when within 2 tiles of edge)
- Passau tile at zoom 11: x=1102, y=710
Shader:
- Two new uniforms: uGroundCenter (mosaic centre in world units),
uGroundSize (mosaic side length = 9)
- gUv = (hitPoint - uGroundCenter) / uGroundSize + 0.5 so the
mosaic centre stays at UV (0.5, 0.5) as the camera drifts
Runtime:
- maybeRefetchGround() called every 1.5s with current camera
position; if camera is in the buffer zone, mosaic centre is
shifted and missing tiles re-fetched in parallel
- Canvas re-uploaded to GPU after each rebuild
Camera can now roam indefinitely — every time we approach the
mosaic edge, a fresh 9x9 of tiles is fetched.
gmask smoothstep end -0.10 -> -0.30: ground only fully fills when
ray Y is below -0.30 (about 17° below horizon). The horizon line
sits higher in the frame, more sky visible above.
Wrapped the multi-line horizon comment into a single quoted
string. Same bug as the earlier fix but in the new ground code.
Going forward I'll make sure all multi-line comments inside
FRAG_SRC are wrapped in quotes.
Instead of mapping rd.x/rd.y to UV directly (which gave a flat
orthographic look), compute where the ray actually intersects
the ground plane and sample the satellite texture at that world
(x,z) coordinate. As the camera drifts forward (uTime), the
ground scrolls, giving the illusion of flight over real terrain.
- tGround = -ro.y / rd.y (with rd.y < 0 guard)
- gUv = (hitPoint.x, hitPoint.z) * 0.04 + 0.5
- Atmospheric perspective: ground fades toward sky with distance
(fog starts at hit-distance 0, full at 30 world units)
- gmask 1.0 only when tGround > 0 (camera above ground AND ray
pointing down), 0 otherwise
loadImageTexture(s.gl, ...) referenced 's' before its 'var s =
canvas.__state' declaration later in the function. Used
canvas.__state.gl directly since __state was just assigned.
When I added the ground-texture code I left the comment as
multiple JS-line-comments ('// Horizon...') between strings.
Those are NOT inside any string, so JS sees:
'// Horizon: ... satellite <- opens string, never closes
// ground texture ... <- JS thinks these are line comments
...
The string stays open until the next ',, then the next string
line parses as a continuation of an unterminated string and the
whole file is rejected. Wrap the comment into one quoted string.
Pulled a 5x4 tile mosaic of the Passau region (zoom 10) from
ESRI's free ArcGIS World Imagery service, stitched + resized to
1024x768 JPEG (277 KB). Added to Dockerfile as ground.jpg.
Shader side:
- New uniform uGround (texture unit 1)
- horizonMask: rd.y in [-0.20, -0.02] -> ground fades in from 0
to 1 as we look further down
- gUv derived from rd.xz so looking forward/down scrolls across
the satellite image (parallax-like feel)
- Distant ground fades toward uBg via a 'fog' factor so the horizon
blends naturally with the sky gradient
Loading is async — clouds render before ground texture arrives.
Falls back to a black sample if ground.jpg fails to load.
- skyDarkness 0.85 -> 0.55 so night sky is deep but not pitch
black, leaving room for stars to show through
- skyMask widened (-0.10 -> -0.20 lower bound, 0.30 -> 0.40 upper)
so stars appear closer to the horizon in the cockpit view
- Star tint multiplier 0.9 -> 1.0 (full bright) for clearer dots
- fbm3: 4 -> 3 octaves
- cloudDensity: 4 layers (base + mid + fine1 + fine2) -> 2 layers
(base + mid at 20%)
Cost per pixel: 16 noise3 -> 6 noise3 per step, ~62% cheaper.
Visual: clouds still have secondary detail but the fine wispy
edges are gone — back to the cleaner silhouette.
'cloud' was referenced outside the for-loop where it was
defined. Use 1.0 - trans (transmittance) as a cloud mask
instead — same information, properly scoped.
clouds3d:
- Star twinkle: 3 Hz ±45% -> 0.5 Hz ±15% (gentle scintillation
instead of static-like flickering)
- Night darkening: applied only to sky pixels via the cloud mask,
not to clouds. The darkening factor jumped 0.45 -> 0.85 so the
night sky really does go black around moonlit clouds. Clouds
keep their uLight-tinted colour so they remain visible.
clouds2d:
- Same darkening approach but via multiplicative factor (no cloud
mask in the 2D shader): col *= 1 - nightFactor * 0.7
- Remove dive component from camY. Was mix(2.0, 1.2, diveT)
(occasional dips into cloud top); now fixed at 2.2, always above
the y=1.5 cloud ceiling. Cloud deck always visible below.
- WEATHER_DENSITY bumped again: sunny 0.55->0.65, cloudy 0.85->0.90,
night 0.65->0.75. Even 'sunny' is now a partly-cloudy sky.
- clouds3d: third fBm detail layer at 3.2x frequency (was 2 layers).
Contributions: base 1.0, mid 0.30, fine 0.12. Combined with the
tighter smoothstep (0.18 -> 0.16) the silhouettes have wispier,
more interesting edges instead of soft blobs.
- Same density bump in clouds2d for consistency.
- WEATHER_DENSITY bumped: sunny 0.20->0.55, cloudy 0.70->0.85,
night 0.45->0.65. Stormy stays at 1.0 (already maxed).
Even 'sunny' now has visible cloud structure, not just clear sky.
- clouds3d: add a second fBm layer at 4x frequency for fine detail,
blended at 18% into the base. Combined with the tighter smoothstep
(0.24 -> 0.18) the cloud silhouettes have more interesting edges
instead of soft blob shapes.
- Same density bump applied to clouds2d so swapping renderers
doesn't change the visual weather.
- config.renderer: clouds2d -> clouds3d (user request)
Rewrite clouds2d.js to sample a noise texture instead of computing
fBm with hash functions. Each octave is one texture lookup with
doubled UV scale and halved amplitude.
- Same visual range as before but ~4x cheaper on ALU
- REPEAT-wrapped texture tiles seamlessly when scrolled by time
- Per-pixel hash21 jitter keeps the regular tiling from flickering
- Exposes noise as a uniform, so multiple fragment shaders could
share it
- Theme-agnostic: pulls colours from __currentPalette
Also adds #renderer=clouds2d URL-hash override for quick A/B
testing between the volumetric clouds3d and the 2D version.
Three coordinated changes:
1. nightPaletteNew.cloud: void -> raised (slightly lighter than
sky) so clouds have contrast against the new-moon sky
2. nightPaletteFull.cloudShadow: panel -> void so cloud undersides
fade into the night sky, while the lit topsides (border) stand
out clearly
3. getNightColor() now keeps 'light' (moonlight) bright and only
partially darkens sky/bg. The previous version squashed
everything toward black, killing cloud structure.
Shader side:
- max(0.15, uDayProgress) keeps a baseline moonlight tint on the
lit side of clouds even when uDayProgress happens to be 0
- The final darken pass is now driven by nightFactor (sunAlt-based)
instead of the unreliable uDayProgress
The cloud horizon flickered when the camera was far from the
deck because the regular raymarch steps aligned with the cloud
boundary. Two cheap fixes:
- Per-pixel jitter (hash21 of gl_FragCoord + uTime) shifts each
ray's start by up to one stepSize; the brain averages the
random offsets into a smooth edge
- Wider smoothstep (0.18 -> 0.24) softens the binary cloud/no-cloud
boundary the jitter is now dithering across
- Cruise altitude 2.6 -> 2.0 (just above cloud ceiling y=1.5,
gives the 'just above the clouds' airliner feel)
- Expose __currentSunAlt (radians) from animate() so the
renderer can tell night from day without misreading
realDayProgress (which mixes pre-dawn + post-sunset hours)
- Replace (1 - uDayProgress) star gate by smoothstep over uSunAlt:
nightFactor = 1 when sun is well below horizon, 0 when above
- Widen sky band so stars are visible from the horizon upward
(cockpit-perspective frame is mostly below rd.y=0.3)
- Cloud ceiling lowered from y=1.8 to y=1.5 (thinner deck)
- Cruise raised from y=2.0 to y=2.6 (clear margin above clouds)
- Dive endpoint raised from y=0.9 to y=1.2 (skims top, never deep)
- Dive exponent 5 -> 8 (much rarer dives)
- Early-out and ceiling bounds updated to match new y=1.5
- Cruise altitude lowered to y=2.0 (just above cloud top at y=1.8)
- Look direction nearly level (rd.y ~ -0.05 at horizon line)
- Stars rendered as 3D hash-grid sphere, only above horizon and
only when uDayProgress < 1 (i.e. at night). Subtle sin twinkle
keeps them from looking like static dither.
Camera altitude is now driven by pow(sin,5) so the view stays at
y=2.6 most of the time and only briefly dips to y=0.9 to skim the
top of the cloud deck. Marching also skips the empty gap between
camera and cloud ceiling when looking down from above.
Reduce raymarch iterations from 28 to 18 and fBm octaves from
4 to 3 (with step size 0.55->0.85 to preserve cloud coverage).
Per-pixel cost drops by ~55% while visual density stays similar.
Raymarches a horizontal cloud layer (28 steps, 4-octave 3D fBm).
Camera path is sinusoidal in altitude so the view dips into the
cloud deck at intervals for a 'fly through the edges' feel.
Density threshold from weather: sunny -> sparse, stormy -> overcast.
Renderer is selected via CONFIG.renderer ('clouds2d' or 'clouds3d').
- Remove all hardcoded fallback hex/rgb values
- Skip draw until __currentPalette is published by parent
- applyWeatherTheme now also mixes cloudShadow (was dropped)
The 2D WebGL renderer now creates its own <canvas> overlay inside
the target element, instead of expecting the target to already be a
canvas. This fixes 'canvas.getContext is not a function' when the
target is a <div>.