#125: slow the prologue down, and give the breach a real black hole

Five things, all of them "this happens too fast or looks too thin".

PACING. Every beat had a completion test that could be satisfied while the
guide was still mid-sentence, which is why everything landed seconds after
the thing before it. Beats now have a FLOOR as well as a deadline - a
minimum length - and a beat whose point is the guide talking will not end
while a line is still on screen. One rule in one place rather than tuning
each condition. The run is 3400px instead of 1500, which at cruise was about
four seconds, and a held line is 6.5s instead of 4.5.

THE CONVOY. Escorts at the four corners of a box, holds packed in the middle
of it, roughly three times the spread of the fleet's own wedge - this is
freight under guard, not a fighting formation. CONVOY and CONVOY_FORMATION
pair index-for-index, which is all it takes for spawn_ships to seat the guns
on the outside, and the test pins that every hold sits further in than the
furthest-out escort.

THE DIALOG. Cut corners, brackets holding the short edges open, a tick strip
along the bottom and a caret in the corner - geometry rather than art, so it
scales with the banner and costs nothing.

THE BREACH. It was a few lines turning around a point. It is now two halves
driven by one number: DimensionalBreach, a growing black hole out in the
world built on BlackHoleVisual's idiom - empty core, counter-rotating arc
segments hotter toward the centre - with infalling streamers it does not
have, because a planet's disc is steady and this one has to say "now"; and
breach.gdshader over the top, bending the ACTUAL SCENE around it. Ships,
rocks and the starfield all get dragged in, which is most of what makes a
hole in space read as a hole rather than as a decal drawn on one.

Inside the horizon the shader samples nothing at all - the same decision
BlackHoleVisual makes, for the same reason: a black hole with something
visible in the middle stops being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-24 13:25:10 +02:00
commit d455361328
8 changed files with 527 additions and 57 deletions

72
shaders/breach.gdshader Normal file
View file

@ -0,0 +1,72 @@
shader_type canvas_item;
// #125: the dimensional breach, as a screen-space lens.
//
// The prologue's warp used to be a handful of lines turning around a point.
// This bends the ACTUAL SCENE around the breach instead - the ships, the
// rocks and the starfield all get dragged into it - which is most of what
// makes a hole in space read as a hole rather than as a decal drawn on top
// of one.
//
// Two effects, both radial and both fading to nothing at the edge of their
// reach, so the shader is invisible outside its own radius and costs a
// clamped texture fetch everywhere else:
//
// swirl angle offset that grows toward the centre, so the scene winds
// around the hole rather than merely sliding toward it
// pull inward displacement, strongest just outside the horizon, which
// is the lensing itself
//
// Inside the horizon nothing is sampled at all: the core is drawn as empty,
// the same decision BlackHoleVisual makes for the black-hole planet, and
// for the same reason - a "black hole" with something visible in the middle
// stops reading as one.
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
// Where the breach is, in SCREEN pixels, and how big it has grown.
uniform vec2 breach_center = vec2(360.0, 640.0);
uniform vec2 viewport_size = vec2(720.0, 1280.0);
// The event horizon: everything inside is void.
uniform float horizon_radius = 0.0;
// How far the lens reaches past the horizon. Beyond this the scene is
// untouched.
uniform float lens_radius = 1.0;
// 0 while the breach is opening, 1 at full strength. Scales both effects
// together so one knob fades the whole thing in.
uniform float strength = 0.0;
uniform float swirl_turns = 2.4;
uniform vec4 rim_color : source_color = vec4(0.72, 0.45, 1.0, 1.0);
void fragment() {
vec2 pixel = UV * viewport_size;
vec2 offset = pixel - breach_center;
float distance_to_centre = length(offset);
if (distance_to_centre >= lens_radius || strength <= 0.001) {
COLOR = texture(screen_texture, SCREEN_UV);
} else if (distance_to_centre <= horizon_radius) {
// The core. Not black-with-alpha: genuinely nothing, so whatever is
// behind the breach is gone rather than dimmed.
COLOR = vec4(0.0, 0.0, 0.0, 1.0);
} else {
// 1 at the horizon, 0 at the edge of the lens. Squared so the bend
// is concentrated near the hole instead of smeared evenly outward.
float falloff = 1.0 - clamp(
(distance_to_centre - horizon_radius) / max(lens_radius - horizon_radius, 1.0),
0.0, 1.0);
float bend = falloff * falloff * strength;
float angle = atan(offset.y, offset.x) + bend * swirl_turns;
float pulled = distance_to_centre - bend * (lens_radius - horizon_radius) * 0.55;
pulled = max(pulled, horizon_radius);
vec2 sampled = breach_center + vec2(cos(angle), sin(angle)) * pulled;
COLOR = texture(screen_texture, sampled / viewport_size);
// A hot rim where the lensing is fiercest, which is what gives the
// horizon an edge rather than a fade.
float rim = smoothstep(0.72, 1.0, falloff) * strength;
COLOR.rgb = mix(COLOR.rgb, rim_color.rgb, rim * 0.65);
}
}