spaceship/shaders/lunar_tiled.gdshader

79 lines
3.8 KiB
Text
Raw Permalink Normal View History

Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68) The crater shader ran on every pixel of a fullscreen rect every frame and was by far the most expensive thing in the surface scene. Nothing else came close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid per-frame queue_redraw() for transform-only changes, and LunarBackground.gd is a handful of scalar ops. Three reductions to the field itself: - 5x5 -> 3x3 neighbour scan. That width was never the geometric answer; it was insurance against craters seen clipping at cell boundaries on-device, i.e. a crater reaching past its nominal radius because a hash returned a value outside [0,1] and extrapolated mix(). Clamping the crater CENTRE into its own cell, alongside the radius clamp already there, bounds the reach whatever the hash returns - so a crater two cells out is >= 1.0 cells away, a normalized distance of >= 2.63, past every threshold the shading reacts to (the last saturates at 1.15). The outer ring provably cannot change a pixel. This removes the clipping bug's cause instead of hiding it behind 16 extra samples. - One hash42 per cell instead of hash21 twice plus hash22, decorrelating on output lanes rather than input offsets. - Squared distances in the loop, square-rooted twice at the end rather than once per sample. Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction. Then the larger change: the field is a fixed function of world position, so every frame recomputed an identical answer. It is now made periodic (the cell index is wrapped before hashing, which makes a Worley field tile exactly rather than merely blend), baked once at load into one tile, and scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch per pixel. Two honest costs: the field repeats every 7040 world units, and a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is forgiving of both, having no landmarks and being smooth gradients already. The bake waits a frame and asks the material whether it actually samples a field_texture, so a gas giant - which swaps in its own background shader (#51) - does not pay for a 2048-square tile it will never read. Headless runs skip it entirely, having nothing to rasterize. tests/test_lunar_field.gd guards the reasoning, since a fragment shader cannot be asserted on directly: the premises are still in the source, the worst-case ring-2 geometry stays clear of every threshold independent of the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points, the field repeats exactly in both axes, and the miss sentinel still cannot overflow mediump into +Inf and then NaN. test_surface_profiles.gd asserted "a rocky landing leaves the background alone" by naming lunar_surface.gdshader; it now reads the shader from the scene, which is the property it actually cares about. PlanetSurface.tscn's edit also carries the planet-name relocation described in the HUD commit - same scene file, so the two cannot be split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
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);
}