spaceship/shaders/lunar_tiled.gdshader
Loïc Wisniewski 2f161621c3 #84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00

79 lines
3.8 KiB
Text

shader_type canvas_item;
// The lunar backdrop as it is actually drawn every frame: one texture fetch
// plus a dither, and nothing else.
//
// Forgejo #68. lunar_surface.gdshader derives the crater field procedurally,
// which costs a 9-sample Worley scan on every pixel of a fullscreen rect,
// every frame, forever - and it recomputes the exact same answer each time,
// because the field is a fixed function of world position. So it is
// evaluated ONCE, into a square texture covering one period of the field
// (LunarBackground.gd does the bake at scene load), and this shader scrolls
// that texture with repeat wrapping. The canvas stays infinite; the per-
// frame cost stops scaling with how complicated the field is.
//
// Two things pay for that, both stated plainly because they are real:
//
// 1. The field REPEATS, every tile_cells * cell_size world units. It is
// seamless (lunar_surface.gdshader wraps the cell index before hashing,
// so the periodicity is exact rather than blended), but a player who
// flies far enough passes the same crater arrangement again. Lunar
// regolith is about the most forgiving possible content for that: no
// landmarks, no horizon, no orientation cues, nothing to anchor a
// memory of "I have seen this crater before" to.
// 2. The texture has a fixed resolution, so zooming in past its native
// density softens the craters rather than resolving finer ones. The
// field is smooth gradients by construction - the sharpest thing in it
// is the rim band, and that still spans several texels - so bilinear
// magnification reproduces it closely instead of showing blocks.
//
// Mipmaps handle the other direction (zooming out), which is where a
// high-frequency crater field would otherwise alias into shimmer.
uniform sampler2D field_texture : filter_linear_mipmap, repeat_enable;
uniform vec2 camera_cell = vec2(0.0, 0.0);
uniform vec2 camera_frac = vec2(0.0, 0.0);
uniform float camera_zoom = 1.0;
uniform vec2 viewport_size = vec2(720.0, 1280.0);
uniform float cell_size = 220.0;
uniform float tile_cells = 32.0;
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
// Same camera-relative construction as the procedural shader, and for
// the same reason: an absolute world position reaches the tens of
// thousands, where a mobile float has lost the fractional bits this
// needs. camera_cell arrives ALREADY WRAPPED into [0, tile_cells) -
// LunarBackground.gd does that wrap in GDScript's double precision, so
// the large number never enters the shader at all.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 uv = (camera_cell + rel) / tile_cells;
// repeat_enable does the wrapping, so uv running outside [0,1] is not
// merely tolerated, it is the mechanism.
vec3 color = texture(field_texture, uv).rgb;
// Tiny per-pixel dither to mask 8-bit colour-quantization banding: the
// crater shading is a smooth gradient over a fairly large area, which
// can visibly "step" into faint bands on real hardware. A small random
// jitter well under one visible step breaks the stepping up without
// being perceptible as noise. Doubly needed here - the baked texture is
// itself 8-bit, so it quantizes the gradient a second time.
//
// Deliberately keyed to the SCREEN pixel, not the world position: it has
// to stay fixed to the display or it turns into crawling noise as the
// camera moves, and it must not be baked into the tile or it would
// repeat along with everything else.
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
COLOR = vec4(color, 1.0);
}