"""Forward flight through a dark star field, with sparse celestial objects.
The third spaceout pattern, beside the orbit fold and the fold-inversion
cascade. It is the quiet one: predominantly black, with a very low-amplitude
broad hue field, six parallax star layers travelling toward the viewer, and
three object slots that pass by -- mostly stars, occasionally a lit planet or
a bright sun with a halo.
WHY IT SUITS A BACKDROP better than the other two. The orbit fold and the
cascade fill the frame with structure, which is what they are for and also
what makes them compete with the interface in front of them. Space is mostly
empty, so the thing a user is reading stays the brightest object on screen.
Both backends draw the same scene: the GLSL below and the Numba kernels in
`fractal_travel`'s CPU path share the star-field and object maths, so a
machine without a GPU sees the same flight rather than a different one.
"""
from __future__ import annotations
import math
from typing import Final
import numpy as np
try:
from numba import njit, prange
except Exception:
njit = None
prange = range
#: Slots that carry a passing object. Three, because the scene is meant to be
#: sparse: the star field is the constant and an object is an event.
OBJECT_SLOTS: Final[int] = 3
#: Parallax layers in the star field. Each is a plane at its own depth, so
#: near stars sweep past while far ones barely move -- which is the whole
#: cue that says "forward" rather than "drifting".
STAR_LAYERS: Final[int] = 6
#: Above this hash value a cell holds a star. High, because the field has to
#: read as sparse: at 0.87 the grid becomes a texture rather than stars.
STAR_THRESHOLD: Final[float] = 0.935
#: The most galaxies on screen at once. Each slot holds one galaxy from far
#: away until it has passed, so this caps both what a frame costs and how
#: crowded the sky gets -- far sparser than the stars.
GALAXY_SLOTS: Final[int] = 4
#: Distinct galaxy pictures, drawn once and reused with a different size,
#: tilt, turn, colour and brightness every time one passes.
_GALAXY_SPRITES: Final[int] = 8
#: Side of one galaxy picture, in texels.
_GALAXY_SPRITE_SIZE: Final[int] = 128
#: The galaxy pictures' kinds, in atlas order: two-armed spirals, barred
#: spirals, a three-armed one, a many-armed flocculent one and ellipticals.
_GALAXY_KINDS: Final[tuple] = ("spiral", "barred", "spiral", "elliptical",
"spiral", "barred", "elliptical", "flocculent")
#: Seconds of flight, at speed 1, from a galaxy appearing to its passing.
_GALAXY_LIFETIME: Final[float] = 70.0
#: Depths a galaxy starts and ends its approach at, in the scene's units.
_GALAXY_FAR: Final[float] = 7.0
_GALAXY_NEAR: Final[float] = 0.35
def _galaxy_sprite(kind: str, seed: int,
size: int = _GALAXY_SPRITE_SIZE) -> np.ndarray:
"""One galaxy, face on, as soft light on black.
:param kind: ``"spiral"``, ``"barred"``, ``"flocculent"`` or
``"elliptical"``.
:param seed: picks the arms' pitch, phase, star-forming knots and the
rest, so the same seed always draws the same galaxy.
:param size: side in texels.
:returns: ``(size, size, 3)`` float32, fading to black before the edge.
A spiral is an exponential disc with logarithmic arms, a dust lane on
each arm's inner side, a bright bulge, and blue knots strung along the
arms; a barred one adds a bar the arms leave from. An elliptical is a
de Vaucouleurs glow, warmer and smoother. Light is tone-mapped, so a
core is bright without being clipped flat.
"""
rng = np.random.default_rng(int(seed))
axis = (np.arange(size, dtype=np.float64) + 0.5) / size * 2.0 - 1.0
x, y = np.meshgrid(axis, axis)
r = np.hypot(x, y)
theta = np.arctan2(y, x)
fade = np.clip((0.98 - r) / 0.3, 0.0, 1.0)
fade = fade * fade * (3.0 - 2.0 * fade)
if kind == "elliptical":
flattening = rng.uniform(0.6, 1.0)
effective = rng.uniform(0.24, 0.32)
stretched = np.hypot(x, y / flattening)
light = np.exp(-7.67 * ((stretched / effective + 1e-9) ** 0.25
- 1.0))
warm = np.array([1.0, 0.8, 0.55])
core = np.array([1.0, 0.93, 0.8])
mix = np.exp(-(stretched / (0.5 * effective)) ** 2)[..., None]
colour = light[..., None] * (warm * (1.0 - mix) + core * mix)
rgb = 1.0 - np.exp(-0.16 * colour)
return (rgb * fade[..., None]).astype(np.float32)
arms = 4 if kind == "flocculent" else int(rng.choice((2, 2, 3)))
pitch = math.radians(rng.uniform(13.0, 24.0))
winding = 1.0 / math.tan(pitch)
start = rng.uniform(0.0, 2.0 * math.pi)
scale = rng.uniform(0.17, 0.24)
sharpness = 1.6 if kind == "flocculent" else rng.uniform(3.0, 5.0)
inner = 0.22 if kind == "barred" else 0.06
phase = arms * (theta - winding * np.log(r + 0.03)) + start
disc = np.exp(-r / scale)
arm = (0.5 + 0.5 * np.cos(phase)) ** sharpness
lane = (0.5 + 0.5 * np.cos(phase - 0.7)) ** 8
begins = np.clip((r - 0.5 * inner) / max(inner, 1e-3), 0.0, 1.0)
arm_light = disc * (0.18 + 1.7 * arm * begins)
arm_light *= 1.0 - 0.5 * lane * begins * np.clip(r / 0.15, 0.0, 1.0)
if kind == "flocculent":
grain = rng.normal(0.0, 1.0, (size, size))
for _ in range(3):
grain = (grain + np.roll(grain, 1, 0) + np.roll(grain, -1, 0)
+ np.roll(grain, 1, 1) + np.roll(grain, -1, 1)) / 5.0
arm_light *= np.clip(1.0 + 1.6 * grain, 0.2, 2.0)
bulge_size = rng.uniform(0.05, 0.085)
bulge = 1.6 * np.exp(-(r / bulge_size) ** 2) \
+ 0.35 * np.exp(-r / (1.6 * bulge_size))
if kind == "barred":
turn = start / arms
along = x * math.cos(turn) + y * math.sin(turn)
across = -x * math.sin(turn) + y * math.cos(turn)
bulge = bulge + 0.55 * np.exp(-(along / inner) ** 2
- (across / 0.04) ** 2)
knots = np.zeros_like(r)
for _ in range(int(rng.integers(26, 44))):
radius = rng.uniform(0.1, 0.62)
branch = int(rng.integers(0, arms))
angle = (winding * math.log(radius + 0.03)
+ (2.0 * math.pi * branch - start) / arms
+ rng.normal(0.0, 0.08))
cx, cy = radius * math.cos(angle), radius * math.sin(angle)
width = rng.uniform(0.008, 0.018)
knots += rng.uniform(0.4, 1.1) * np.exp(
-((x - cx) ** 2 + (y - cy) ** 2) / (width * width))
bulge_colour = np.array([1.0, 0.84, 0.6])
arm_colour = np.array([0.6, 0.73, 1.0])
knot_colour = (np.array([0.72, 0.8, 1.0]) if seed % 2
else np.array([1.0, 0.62, 0.8]))
colour = (bulge[..., None] * bulge_colour
+ arm_light[..., None] * arm_colour
+ 0.8 * knots[..., None] * knot_colour
* disc[..., None] ** 0.3)
rgb = 1.0 - np.exp(-1.1 * colour)
return (rgb * fade[..., None]).astype(np.float32)
_ATLAS_CACHE: list = []
def _galaxy_atlas() -> np.ndarray:
"""Every galaxy picture side by side, drawn once per process.
:returns: ``(size, size * count, 3)`` float32, sprite ``i`` in columns
``i * size`` to ``(i + 1) * size``.
Built on first use and kept: the pictures cost a few tenths of a second
to draw, which is fine once and not every frame.
"""
if not _ATLAS_CACHE:
_ATLAS_CACHE.append(np.ascontiguousarray(np.concatenate(
[_galaxy_sprite(kind, 7919 * (index + 1))
for index, kind in enumerate(_GALAXY_KINDS[:_GALAXY_SPRITES])],
axis=1)))
return _ATLAS_CACHE[0]
def _galaxy_hash(value: float, salt: float) -> float:
"""A deterministic 0-1 value, the same shader-idiom hash as the stars.
:param value: which galaxy.
:param salt: which of its properties.
"""
raw = math.sin(value * 127.1 + salt * 311.7) * 43758.5453123
return raw - math.floor(raw)
def _galaxy_texture() -> np.ndarray:
"""The atlas as the shader reads it: RGBA bytes holding sqrt(light).
:returns: ``(size, size * count, 4)`` uint8. The square root spends the
eight bits where the eye needs them -- on the faint outer disc -- and
the shader squares it back.
"""
atlas = _galaxy_atlas()
texture = np.empty(atlas.shape[:2] + (4,), dtype=np.uint8)
texture[..., :3] = np.round(
255.0 * np.sqrt(np.clip(atlas, 0.0, 1.0))).astype(np.uint8)
texture[..., 3] = 255
return texture
def _galaxy_uniforms(rows: np.ndarray) -> dict:
"""The shader's per-galaxy uniforms for rows from :func:`_galaxies_at`.
:param rows: ``(GALAXY_SLOTS, 12)`` galaxy rows.
:returns: ``{"u_galaxy0_place": (x, y, half, brightness), ...}`` with a
``_shape`` and a ``_tint`` entry for each slot.
"""
values = {}
for slot in range(GALAXY_SLOTS):
row = [float(value) for value in rows[slot]]
values[f"u_galaxy{slot}_place"] = tuple(row[0:4])
values[f"u_galaxy{slot}_shape"] = tuple(row[4:8])
values[f"u_galaxy{slot}_tint"] = tuple(row[8:11])
return values
def _galaxies_at(t: float, speed: float) -> np.ndarray:
"""Where every galaxy slot is at ``t``, for both renderers.
:param t: the flight's clock.
:param speed: multiplier on the flight's forward motion.
:returns: ``(GALAXY_SLOTS, 12)`` float64 rows of ``x, y, half_size,
brightness, cos_turn, sin_turn, squash, sprite, red, green, blue,
depth`` in scene coordinates. A row whose brightness is 0 is empty.
Each slot carries one galaxy from far ahead to past the camera, then a
new one. Its depth falls steadily, so its place on screen -- its
sideways offset divided by depth -- moves outward ever faster and its
size grows the same way: perspective. Galaxies at different offsets and
depths cross at different rates, which is the parallax. It fades in
from the distance and out as it passes. Like the stars, the position is
the clock: nothing is stored between frames.
"""
rows = np.zeros((GALAXY_SLOTS, 12), dtype=np.float64)
pace = (0.35 + 1.05 * float(speed)) / (1.4 * _GALAXY_LIFETIME)
for slot in range(GALAXY_SLOTS):
travel = float(t) * pace + (slot + 0.37) / GALAXY_SLOTS
epoch = math.floor(travel)
phase = travel - epoch
which = epoch * GALAXY_SLOTS + slot + 11.0
if _galaxy_hash(which, 2.9) < 0.18:
continue
depth = _GALAXY_FAR + (_GALAXY_NEAR - _GALAXY_FAR) * phase
angle = 2.0 * math.pi * _galaxy_hash(which, 1.3)
offset = 0.5 + 1.3 * _galaxy_hash(which, 4.1)
size = 0.3 + 0.6 * _galaxy_hash(which, 6.7) ** 1.5
enter = min(1.0, max(0.0, phase / 0.18))
enter = enter * enter * (3.0 - 2.0 * enter)
leave = min(1.0, max(0.0, (1.0 - phase) / 0.16))
leave = leave * leave * (3.0 - 2.0 * leave)
turn = 2.0 * math.pi * _galaxy_hash(which, 8.3) + 0.04 * phase
tint = _galaxy_hash(which, 9.7)
warmth = 0.75 + 0.5 * _galaxy_hash(which, 3.3)
rows[slot] = (
offset * math.cos(angle) / depth,
offset * math.sin(angle) / depth,
size / depth,
(0.75 + 0.6 * _galaxy_hash(which, 5.9)) * enter * leave,
math.cos(turn), math.sin(turn),
0.22 + 0.78 * _galaxy_hash(which, 7.1),
float(int(_galaxy_hash(which, 0.7) * _GALAXY_SPRITES)
% _GALAXY_SPRITES),
min(1.2, warmth * (1.0 + 0.15 * tint)),
0.95 + 0.05 * tint,
min(1.2, (2.0 - warmth) * (1.0 - 0.1 * tint)),
depth,
)
return rows
FRAGMENT_SHADER: Final[str] = r"""
uniform vec2 u_resolution;
uniform float u_pointer_x;
uniform float u_pointer_y;
uniform float u_pull;
uniform float u_push;
uniform float u_lens;
uniform float u_time;
uniform float u_speed;
uniform float u_intensity;
uniform float u_palette_phase;
uniform float u_tx;
uniform float u_ty;
uniform float u_rotation;
uniform float u_shear_x;
uniform float u_shear_y;
uniform float u_stretch_x;
uniform float u_stretch_y;
uniform int u_detail;
uniform sampler2D u_galaxies;
uniform vec4 u_galaxy0_place;
uniform vec4 u_galaxy0_shape;
uniform vec3 u_galaxy0_tint;
uniform vec4 u_galaxy1_place;
uniform vec4 u_galaxy1_shape;
uniform vec3 u_galaxy1_tint;
uniform vec4 u_galaxy2_place;
uniform vec4 u_galaxy2_shape;
uniform vec3 u_galaxy2_tint;
uniform vec4 u_galaxy3_place;
uniform vec4 u_galaxy3_shape;
uniform vec3 u_galaxy3_tint;
// ONE PASSING GALAXY, read from the atlas of pictures drawn once on the
// CPU. `place` is centre, half-size and brightness; `shape` is the turn's
// cosine and sine, the tilt's squash and which picture. The texels hold the
// square root of the light, so eight bits do not band a faint disc.
vec3 galaxy_light(vec2 p, vec4 place, vec4 shape, vec3 tint) {
if (place.w <= 0.0 || place.z <= 0.0) return vec3(0.0);
vec2 d = (p - place.xy) / place.z;
vec2 local = vec2(shape.x * d.x + shape.y * d.y,
(-shape.y * d.x + shape.x * d.y) / max(shape.z, 0.05));
if (abs(local.x) >= 1.0 || abs(local.y) >= 1.0) return vec3(0.0);
vec2 at = vec2((shape.w + 0.5 + 0.5 * local.x) / 8.0, 0.5 + 0.5 * local.y);
vec3 texel = texture2D(u_galaxies, at).rgb;
return texel * texel * tint * place.w;
}
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
vec2 rotate2(vec2 p, float a) {
float cs = cos(a);
float sn = sin(a);
return vec2(cs * p.x - sn * p.y, sn * p.x + cs * p.y);
}
vec3 space_object(vec2 uv, float slot, float travel) {
float phase = fract(travel + 0.33 * slot);
float epoch = floor(travel + 0.33 * slot);
float object_id = epoch * 3.0 + slot;
float seed = hash21(vec2(object_id, 17.31));
float type_value = hash21(vec2(object_id + 7.1, 3.77));
float z = mix(7.2, 0.16, phase);
float angle = 6.28318 * hash21(vec2(object_id + 2.2, 8.8));
float radius = 0.70 + 1.00 * hash21(vec2(object_id + 4.7, 5.6));
vec2 world = radius * vec2(cos(angle), sin(angle));
vec2 projected = world / max(z, 0.16);
vec2 local = (uv - projected) * z;
float enter = smoothstep(0.02, 0.12, phase);
float pass_out = 1.0 - smoothstep(0.90, 0.995, phase);
float r = length(local) + 1e-5;
float a = atan(local.y, local.x);
vec3 color = vec3(0.0);
if (type_value < 0.45) {
float disc_radius = 0.34 + 0.12 * seed;
float body = exp(-8.2 * r * r);
float disc = step(r, disc_radius);
float nz = sqrt(max(0.0, 1.0 - min(1.0, r * r / (disc_radius * disc_radius))));
vec3 normal = normalize(vec3(local.x, local.y, nz));
vec3 light_dir = normalize(vec3(-0.82, 0.25, 0.52));
float light = max(0.0, dot(normal, light_dir)) * 0.92 + 0.08;
float band = 0.86 + 0.14 * sin(6.2 * local.y + seed * 6.0);
vec3 base = mix(vec3(0.40, 0.30, 0.16), vec3(0.78, 0.66, 0.40), seed);
color += base * body * disc * light * band;
color += vec3(0.10, 0.12, 0.20) * body * disc * (1.0 - light) * 0.65;
color += vec3(0.94, 0.88, 0.70) * exp(-340.0 * pow(abs(r - disc_radius), 2.0)) * 0.16;
} else {
float core = exp(-920.0 * r * r);
float halo = exp(-16.0 * r * r);
float rays = exp(-36.0 * abs(local.x)) * exp(-8.0 * abs(local.y))
+ exp(-36.0 * abs(local.y)) * exp(-8.0 * abs(local.x));
float cloud = exp(-2.4 * r * r)
* (0.60 + 0.40 * sin(4.2 * a + 2.4 * log(r + 0.03) + seed * 6.0));
vec3 star_col = mix(vec3(1.00, 0.92, 0.76), vec3(0.86, 0.90, 1.00), seed);
vec3 cloud_col = mix(vec3(0.10, 0.12, 0.26), vec3(0.18, 0.20, 0.34), seed);
color += cloud_col * cloud * 0.34;
color += star_col * (2.9 * core + 0.95 * halo + 0.10 * rays);
}
return color * enter * pass_out;
}
vec3 space_star_field(vec2 uv, float depth) {
vec3 color = vec3(0.0);
for (int layer = 0; layer < 6; ++layer) {
float lf = float(layer);
float phase = fract(depth * (0.20 + 0.045 * lf) + 0.19 * lf);
float z = mix(2.5, 0.12, phase);
vec2 drift = vec2(
0.060 * u_time * (0.10 + 0.025 * lf),
-0.035 * u_time * (0.08 + 0.020 * lf)
);
vec2 plane = uv * z * 12.0 + drift + vec2(1.9 * lf, -1.4 * lf);
vec2 base = floor(plane);
float near_factor = 1.0 / (0.18 + z);
for (int jy = -1; jy <= 1; ++jy) {
for (int ix = -1; ix <= 1; ++ix) {
vec2 cell = base + vec2(float(ix), float(jy));
float h = hash21(cell + vec2(3.3 * lf, 7.9));
if (h > 0.935) {
vec2 star = cell + vec2(
hash21(cell + vec2(1.2, 9.3)),
hash21(cell + vec2(5.4, 2.7))
);
vec2 d = (plane - star) / z;
float size = 0.0035 + 0.018 * near_factor;
float core = exp(-dot(d, d) / (size * size));
float ray_x = exp(-abs(d.x) / (0.008 + 0.025 * near_factor))
* exp(-abs(d.y) / (0.0014 + 0.006 * near_factor));
float ray_y = exp(-abs(d.y) / (0.008 + 0.025 * near_factor))
* exp(-abs(d.x) / (0.0014 + 0.006 * near_factor));
float brightness = (2.8 * core + 0.08 * (ray_x + ray_y))
* (0.38 + 0.62 * h) * pow(near_factor, 1.18);
vec3 star_color = mix(
vec3(1.00, 0.88, 0.74),
vec3(0.70, 0.83, 1.00),
hash21(cell + vec2(8.1, 1.4))
);
color += star_color * brightness;
}
}
}
}
return color;
}
// THE POINTER IS THE POINT EVERYTHING FLOWS TO. Shifting the coordinate
// ORIGIN toward the cursor moves the centre the pattern radiates from,
// rather than adding a second warp on top of the one it already has --
// which would read as a smear rather than as a centre. A click pushes the
// origin away instead, so the flow reverses around it.
vec2 toward_pointer(vec2 uv) {
// THE POINTER BENDS THE PLANE, IT DOES NOT MOVE THE CAMERA.
//
// This used to be `uv - target * pull`, a uniform translation of the
// whole plane -- towing the viewport. The shift grew with the
// pointer's distance from centre, so near an edge the whole picture
// was dragged, and when the pointer left the widget the pull decayed
// to zero and it sprang back: "if the mouse is to close to the sides
// of the screen the camera snapps back".
//
// The CPU orbit fold never had that problem, and this is its warp
// transliterated so every renderer bends the picture the same way:
// displacement TOWARD the pointer, falling off as 1/r^2, so it is
// firm under the cursor and gone by the far corner. Distant pixels
// stay where they were, so there is no global shift to spring back
// from. A click reverses it.
float lens = u_lens > 0.0 ? max(u_lens, 0.05) : 1.0;
vec2 target = vec2(u_pointer_x, u_pointer_y);
vec2 to_pointer = (target - uv) / lens;
float distance2 = dot(to_pointer, to_pointer) + 0.05;
float strength = (0.55 * u_pull - 0.95 * u_push) / distance2;
strength = clamp(strength, -1.4, 0.9);
return uv + strength * to_pointer * lens;
}
vec3 render_sample(vec2 fragment_position) {
float denominator = min(u_resolution.x, u_resolution.y);
vec2 uv = (2.0 * fragment_position - u_resolution) / denominator;
uv *= 1.08;
uv = toward_pointer(uv);
float depth = u_time * u_speed / 14.0;
float roll = 0.025 * sin(0.009 * u_time);
vec2 p = rotate2(uv, roll);
p += 0.025 * vec2(sin(0.006 * u_time), cos(0.005 * u_time + 0.7));
float hue = 0.5 + 0.5 * sin(
0.38 * p.x - 0.31 * p.y + 0.004 * u_time + 0.7 * sin(0.55 * p.y));
vec3 color = mix(vec3(0.0015, 0.0022, 0.0060),
vec3(0.0055, 0.0028, 0.0105), hue);
color += space_star_field(p, depth);
color += galaxy_light(p, u_galaxy0_place, u_galaxy0_shape, u_galaxy0_tint);
color += galaxy_light(p, u_galaxy1_place, u_galaxy1_shape, u_galaxy1_tint);
color += galaxy_light(p, u_galaxy2_place, u_galaxy2_shape, u_galaxy2_tint);
color += galaxy_light(p, u_galaxy3_place, u_galaxy3_shape, u_galaxy3_tint);
float object_travel = u_time * (0.35 + 1.05 * u_speed) / 520.0;
color += space_object(p, 0.0, object_travel);
color += 0.82 * space_object(p, 1.0, object_travel + 0.27);
color += 0.60 * space_object(p, 2.0, object_travel + 0.61);
float vignette = 1.0 - smoothstep(1.0, 2.2, length(p));
color *= 0.86 + 0.14 * vignette;
return clamp(color, 0.0, 1.0);
}
void main() {
vec3 color = vec3(0.0);
color += render_sample(gl_FragCoord.xy + vec2(-0.25, -0.25));
color += render_sample(gl_FragCoord.xy + vec2( 0.25, -0.25));
color += render_sample(gl_FragCoord.xy + vec2(-0.25, 0.25));
color += render_sample(gl_FragCoord.xy + vec2( 0.25, 0.25));
gl_FragColor = vec4(0.25 * color, 1.0);
}
"""
if njit is not None:
@njit(cache=True, fastmath=True, inline="always")
def _hash2(x: float, y: float) -> float:
"""A deterministic 0-1 value from a 2-D position.
The shader-idiom hash, kept because it needs no state and no table:
a real PRNG would have to be seeded and carried through a kernel
that has no place to put it, and a lookup table would be a memory
access in the innermost loop. The constants are arbitrary and only
have to be irrational-looking; what matters is that the SAME
position always yields the same star, so the field does not
shimmer between frames.
"""
value = math.sin(x * 127.1 + y * 311.7) * 43758.5453123
return value - math.floor(value)
@njit(cache=True, fastmath=True)
def _object_color(x: float, y: float, t: float, speed: float, slot: int):
"""The contribution of one drifting object at one point.
`slot` separates the three concurrent objects so they do not share
a birth time: without the `0.33 * slot` offset all three would
appear and fade together and read as one blinking shape rather
than as a field with depth.
The epoch/phase split is what lets an object be born, cross and
die without any per-object state -- the position IS the clock, so
nothing has to be stored between frames.
"""
travel = t * (0.35 + 1.05 * speed) / 520.0 + 0.33 * slot
epoch = math.floor(travel)
phase = travel - epoch
object_id = epoch * 3.0 + slot
seed = _hash2(object_id, 17.31)
type_value = _hash2(object_id + 7.1, 3.77)
z = 7.2 + (0.16 - 7.2) * phase
angle = 2.0 * math.pi * _hash2(object_id + 2.2, 8.8)
radius = 0.70 + 1.00 * _hash2(object_id + 4.7, 5.6)
projected_x = radius * math.cos(angle) / max(z, 0.16)
projected_y = radius * math.sin(angle) / max(z, 0.16)
local_x = (x - projected_x) * z
local_y = (y - projected_y) * z
enter = max(0.0, min(1.0, (phase - 0.02) / 0.10))
pass_out = 1.0 - max(0.0, min(1.0, (phase - 0.90) / 0.095))
r = math.sqrt(local_x * local_x + local_y * local_y) + 1e-5
a = math.atan2(local_y, local_x)
red = green = blue = 0.0
if type_value < 0.45:
disc_radius = 0.34 + 0.12 * seed
body = math.exp(-8.2 * r * r)
disc = 1.0 if r <= disc_radius else 0.0
denom = max(1e-6, disc_radius * disc_radius)
nz = math.sqrt(max(0.0, 1.0 - min(1.0, r * r / denom)))
nx = local_x / max(disc_radius, 1e-5)
ny = local_y / max(disc_radius, 1e-5)
light = max(0.0, nx * -0.82 + ny * 0.25 + nz * 0.52) * 0.92 + 0.08
band = 0.86 + 0.14 * math.sin(6.2 * local_y + seed * 6.0)
lit = body * disc * light * band
shadow = body * disc * (1.0 - light) * 0.65
red += (0.40 * (1.0 - seed) + 0.78 * seed) * lit + 0.10 * shadow
green += (0.30 * (1.0 - seed) + 0.66 * seed) * lit + 0.12 * shadow
blue += (0.16 * (1.0 - seed) + 0.40 * seed) * lit + 0.20 * shadow
rim = math.exp(-340.0 * (abs(r - disc_radius) ** 2)) * 0.16
red += 0.94 * rim
green += 0.88 * rim
blue += 0.70 * rim
else:
core = math.exp(-920.0 * r * r)
halo = math.exp(-16.0 * r * r)
rays = (math.exp(-36.0 * abs(local_x)) * math.exp(-8.0 * abs(local_y))
+ math.exp(-36.0 * abs(local_y)) * math.exp(-8.0 * abs(local_x)))
cloud = math.exp(-2.4 * r * r) * (
0.60 + 0.40 * math.sin(4.2 * a + 2.4 * math.log(r + 0.03)
+ seed * 6.0))
glow = 2.9 * core + 0.95 * halo + 0.10 * rays
red += (0.10 * (1.0 - seed) + 0.18 * seed) * cloud * 0.34 \
+ (1.00 * (1.0 - seed) + 0.86 * seed) * glow
green += (0.12 * (1.0 - seed) + 0.20 * seed) * cloud * 0.34 \
+ (0.92 * (1.0 - seed) + 0.90 * seed) * glow
blue += (0.26 * (1.0 - seed) + 0.34 * seed) * cloud * 0.34 \
+ (0.76 * (1.0 - seed) + 1.00 * seed) * glow
return red * enter * pass_out, green * enter * pass_out, \
blue * enter * pass_out
@njit(cache=True, fastmath=True)
def sample_space(x: float, y: float, t: float, speed: float):
"""One pixel of the flight, in scene coordinates.
Kept a free function so a test can compare it with the shader and so
the frame kernel below stays a loop and nothing else.
When Numba is unavailable, the public ``sample_space`` name instead
accepts arbitrary positional and keyword arguments and raises
``RuntimeError``.
:param x: horizontal scene coordinate; ``0`` is the frame centre and
the shorter frame edge lies at about ``±1.08``.
:param y: vertical scene coordinate, positive upwards, on the same
scale.
:param t: elapsed animation time used to advance the flight.
:param speed: multiplier applied to the flight's forward motion.
:returns: ``(red, green, blue)``, each clamped to ``[0, 1]``.
"""
depth = t * speed / 14.0
roll = 0.025 * math.sin(0.009 * t)
cs = math.cos(roll)
sn = math.sin(roll)
px = cs * x - sn * y + 0.025 * math.sin(0.006 * t)
py = sn * x + cs * y + 0.025 * math.cos(0.005 * t + 0.7)
hue = 0.5 + 0.5 * math.sin(
0.38 * px - 0.31 * py + 0.004 * t + 0.7 * math.sin(0.55 * py))
red = 0.0015 * (1.0 - hue) + 0.0055 * hue
green = 0.0022 * (1.0 - hue) + 0.0028 * hue
blue = 0.0060 * (1.0 - hue) + 0.0105 * hue
for layer in range(6):
lf = float(layer)
phase = (depth * (0.20 + 0.045 * lf) + 0.19 * lf) % 1.0
z = 2.5 + (0.12 - 2.5) * phase
plane_x = px * z * 12.0 + 0.060 * t * (0.10 + 0.025 * lf) + 1.9 * lf
plane_y = py * z * 12.0 - 0.035 * t * (0.08 + 0.020 * lf) - 1.4 * lf
base_x = math.floor(plane_x)
base_y = math.floor(plane_y)
near_factor = 1.0 / (0.18 + z)
for jy in range(-1, 2):
for ix in range(-1, 2):
cell_x = base_x + ix
cell_y = base_y + jy
h = _hash2(cell_x + 3.3 * lf, cell_y + 7.9)
if h > 0.935:
star_x = cell_x + _hash2(cell_x + 1.2, cell_y + 9.3)
star_y = cell_y + _hash2(cell_x + 5.4, cell_y + 2.7)
dx = (plane_x - star_x) / z
dy = (plane_y - star_y) / z
size = 0.0035 + 0.018 * near_factor
core = math.exp(-(dx * dx + dy * dy) / (size * size))
ray_x = math.exp(-abs(dx) / (0.008 + 0.025 * near_factor)) \
* math.exp(-abs(dy) / (0.0014 + 0.006 * near_factor))
ray_y = math.exp(-abs(dy) / (0.008 + 0.025 * near_factor)) \
* math.exp(-abs(dx) / (0.0014 + 0.006 * near_factor))
brightness = (2.8 * core + 0.08 * (ray_x + ray_y)) \
* (0.38 + 0.62 * h) * (near_factor ** 1.18)
mixv = _hash2(cell_x + 8.1, cell_y + 1.4)
red += (1.00 * (1.0 - mixv) + 0.70 * mixv) * brightness
green += (0.88 * (1.0 - mixv) + 0.83 * mixv) * brightness
blue += (0.74 * (1.0 - mixv) + 1.00 * mixv) * brightness
r0, g0, b0 = _object_color(px, py, t, speed, 0)
r1, g1, b1 = _object_color(px, py, t, speed, 1)
r2, g2, b2 = _object_color(px, py, t, speed, 2)
red += r0 + 0.82 * r1 + 0.60 * r2
green += g0 + 0.82 * g1 + 0.60 * g2
blue += b0 + 0.82 * b1 + 0.60 * b2
radius = math.sqrt(px * px + py * py)
vignette = 1.0 - max(0.0, min(1.0, (radius - 1.0) / 1.2))
fac = 0.86 + 0.14 * vignette
return (max(0.0, min(1.0, red * fac)),
max(0.0, min(1.0, green * fac)),
max(0.0, min(1.0, blue * fac)))
@njit(cache=True, parallel=True, fastmath=True, nogil=True)
def render_space_frame(width: int, height: int, t: float, speed: float,
offset_x: float, offset_y: float,
samples: int) -> np.ndarray:
"""A whole frame of the flight.
``nogil`` because this runs on the shading thread and the GUI thread
has to keep answering while it does.
When Numba is unavailable, the public ``render_space_frame`` name
instead accepts arbitrary positional and keyword arguments and raises
``RuntimeError``.
:param width: frame width in pixels.
:param height: frame height in pixels.
:param t: elapsed animation time used to advance the flight.
:param speed: multiplier applied to the flight's forward motion.
:param offset_x: horizontal shift added to every pixel's scene
coordinate, which steers the flight's heading.
:param offset_y: vertical shift added likewise.
:param samples: ``1`` or less takes one sample at each pixel centre;
anything larger averages a ``samples`` x ``samples`` grid of
samples per pixel (it used to be 2 x 2 whatever the
number said).
:returns: ``(height, width, 3)`` uint8 RGB array.
"""
out = np.empty((height, width, 3), dtype=np.uint8)
denominator = float(min(width, height))
for row in prange(height):
for col in range(width):
if samples <= 1:
x = (2.0 * (col + 0.5) - width) / denominator * 1.08
y = (height - 2.0 * (row + 0.5)) / denominator * 1.08
r, g, b = sample_space(x + offset_x, y + offset_y,
t, speed)
out[row, col, 0] = int(255.0 * r)
out[row, col, 1] = int(255.0 * g)
out[row, col, 2] = int(255.0 * b)
else:
ar = ag = ab = 0.0
step = 1.0 / samples
for sy in range(samples):
for sx in range(samples):
ox = step * (sx + 0.5)
oy = step * (sy + 0.5)
x = (2.0 * (col + ox) - width) / denominator * 1.08
y = (height - 2.0 * (row + oy)) / denominator * 1.08
r, g, b = sample_space(x + offset_x,
y + offset_y, t, speed)
ar += r
ag += g
ab += b
out[row, col, 0] = int(255.0 * ar / float(samples * samples))
out[row, col, 1] = int(255.0 * ag / float(samples * samples))
out[row, col, 2] = int(255.0 * ab / float(samples * samples))
return out
@njit(cache=True, fastmath=True, inline="always")
def _atlas_texel(atlas, column: float, row: float, channel: int) -> float:
"""Bilinear read of one channel of the galaxy atlas.
:param atlas: ``(size, size * count, 3)`` float32.
:param column: horizontal texel coordinate, pixel centres at ``.5``
removed by the caller.
:param row: vertical texel coordinate, likewise.
:param channel: 0, 1 or 2.
"""
height = atlas.shape[0]
width = atlas.shape[1]
left = int(math.floor(column))
top = int(math.floor(row))
across = column - left
down = row - top
left = min(max(left, 0), width - 1)
top = min(max(top, 0), height - 1)
right = min(left + 1, width - 1)
bottom = min(top + 1, height - 1)
upper = (atlas[top, left, channel] * (1.0 - across)
+ atlas[top, right, channel] * across)
lower = (atlas[bottom, left, channel] * (1.0 - across)
+ atlas[bottom, right, channel] * across)
return upper * (1.0 - down) + lower * down
@njit(cache=True, parallel=True, fastmath=True, nogil=True)
def _add_galaxies(frame, t: float, offset_x: float, offset_y: float,
galaxies, atlas) -> None:
"""Lay the passing galaxies over a finished frame, in place.
Only each galaxy's own box of pixels is visited, so a frame with
four small galaxies costs almost nothing beyond the star field. The
pixel-to-scene mapping, the roll and the drift are the ones
`sample_space` uses, so a galaxy sits in the same sky as the stars.
:param frame: ``(height, width, 3)`` uint8, from `render_space_frame`.
:param t: the flight's clock.
:param offset_x: the pointer's horizontal heading offset.
:param offset_y: its vertical one.
:param galaxies: rows from :func:`_galaxies_at`.
:param atlas: the pictures, from :func:`_galaxy_atlas`.
"""
height, width, _channels = frame.shape
denominator = float(min(width, height))
roll = 0.025 * math.sin(0.009 * t)
cs = math.cos(roll)
sn = math.sin(roll)
drift_x = 0.025 * math.sin(0.006 * t)
drift_y = 0.025 * math.cos(0.005 * t + 0.7)
size = atlas.shape[0]
for index in range(galaxies.shape[0]):
centre_x = galaxies[index, 0]
centre_y = galaxies[index, 1]
half = galaxies[index, 2]
brightness = galaxies[index, 3]
if brightness <= 0.0 or half <= 0.0:
continue
turn_cos = galaxies[index, 4]
turn_sin = galaxies[index, 5]
squash = max(galaxies[index, 6], 0.05)
sprite = galaxies[index, 7]
reach = 1.42 * half
back_x = centre_x - drift_x
back_y = centre_y - drift_y
screen_x = cs * back_x + sn * back_y
screen_y = -sn * back_x + cs * back_y
first_col = int(math.floor(((screen_x - reach - offset_x) / 1.08
* denominator + width) / 2.0 - 1.0))
last_col = int(math.ceil(((screen_x + reach - offset_x) / 1.08
* denominator + width) / 2.0))
first_row = int(math.floor((height - (screen_y + reach - offset_y)
/ 1.08 * denominator) / 2.0 - 1.0))
last_row = int(math.ceil((height - (screen_y - reach - offset_y)
/ 1.08 * denominator) / 2.0))
first_col = max(first_col, 0)
last_col = min(last_col, width - 1)
first_row = max(first_row, 0)
last_row = min(last_row, height - 1)
if first_col > last_col or first_row > last_row:
continue
for row in prange(first_row, last_row + 1):
y = (height - 2.0 * (row + 0.5)) / denominator * 1.08 \
+ offset_y
for col in range(first_col, last_col + 1):
x = (2.0 * (col + 0.5) - width) / denominator * 1.08 \
+ offset_x
px = cs * x - sn * y + drift_x
py = sn * x + cs * y + drift_y
dx = (px - centre_x) / half
dy = (py - centre_y) / half
local_x = turn_cos * dx + turn_sin * dy
local_y = (-turn_sin * dx + turn_cos * dy) / squash
if abs(local_x) >= 1.0 or abs(local_y) >= 1.0:
continue
column = (sprite + 0.5 + 0.5 * local_x) * size - 0.5
texel_row = (0.5 + 0.5 * local_y) * size - 0.5
for channel in range(3):
light = _atlas_texel(atlas, column, texel_row,
channel)
value = frame[row, col, channel] + 255.0 * light \
* galaxies[index, 8 + channel] * brightness
frame[row, col, channel] = min(255, int(value))
else:
def sample_space(*_args, **_kwargs):
"""Refuse: the CPU space renderer needs Numba, which is absent.
:param _args: whatever the caller would have sampled.
:param _kwargs: likewise.
:raises RuntimeError: always.
"""
raise RuntimeError("numba is required for the CPU space renderer")
def render_space_frame(*_args, **_kwargs):
"""Refuse: the CPU space renderer needs Numba, which is absent.
:param _args: whatever the caller would have rendered.
:param _kwargs: likewise.
:raises RuntimeError: always.
"""
raise RuntimeError("numba is required for the CPU space renderer")
[docs]
class SpaceEngine:
"""The CPU side of the flight, driven exactly like the other engines.
:param thread_count: worker threads numba may use.
`samples` is how many samples a side each pixel takes, set by the
widget from the Supersampling setting. ``None`` -- an engine
nobody configured -- keeps the old rule of two on a small frame and one
on a large one.
The widget builds and calls every pattern engine the same way, so this
takes the same arguments even where the scene has no use for one. A
pattern that needed a different call would put a branch in the one place
all three are meant to look alike.
"""
def __init__(self, thread_count: int) -> None:
"""Create the space renderer and cap numba's thread pool to match.
:param thread_count: worker threads to render with; clamped to at least
one. A numba that cannot be configured is tolerated -- the renderer
still works, it just shares the default pool.
"""
self.thread_count = max(1, int(thread_count))
self.samples = None
try:
from numba import set_num_threads
set_num_threads(self.thread_count)
except Exception: # noqa: BLE001
pass
[docs]
def render(self, width: int, height: int, t: float, speed: float,
dream: float = 0.0, iterations: int = 0,
pointer_x: float = 0.0, pointer_y: float = 0.0,
pull: float = 0.0, push: float = 0.0) -> np.ndarray:
"""One finished frame as ``(height, width, 3)`` uint8.
:param width: width of the finished frame in pixels.
:param height: height of the finished frame in pixels.
:param t: elapsed animation time used to advance the flight.
:param speed: multiplier applied to the flight's forward motion.
:param dream: unused. The flight has no dream term -- the scene is a
star field, and warping it toward a hallucination is what the
other two patterns are for.
:param iterations: unused. The cost here is six parallax layers and
three object slots, all fixed, so there is no depth to trade.
:param pointer_x: where the pointer is, in scene coordinates.
:param pointer_y: as above.
:param pull: how strongly the pointer draws the flight toward it.
:param push: a click's shove, decaying.
THE POINTER STEERS RATHER THAN WARPS. The other patterns bend their
field toward the cursor; bending a star field would make the stars
curve, which reads as a fault rather than as attention. Here it
nudges the flight's heading, so the field slides the way a camera
pans and every star stays a point.
"""
offset_x = float(pointer_x) * float(pull) * 0.22 - float(push) * float(pointer_x) * 0.35
offset_y = float(pointer_y) * float(pull) * 0.22 - float(push) * float(pointer_y) * 0.35
frame = render_space_frame(
max(1, int(width)), max(1, int(height)), float(t), float(speed),
float(offset_x), float(offset_y), self._samples_for(width, height))
galaxies = _galaxies_at(float(t), float(speed))
if (galaxies[:, 3] > 0.0).any():
_add_galaxies(frame, float(t), float(offset_x), float(offset_y),
galaxies, _galaxy_atlas())
return frame
def _samples_for(self, width: int, height: int) -> int:
"""The saved samples a side, or the size rule when none was given.
:param width: frame width in pixels.
:param height: frame height in pixels.
:returns: samples a side, at least one.
"""
if self.samples is None:
return self._samples(width, height)
return max(1, int(self.samples))
@staticmethod
def _samples(width: int, height: int) -> int:
"""Two samples a side on a small frame, one on a large one.
A star is a sub-pixel point, so it aliases worse than anything the
other patterns draw -- but supersampling a big frame costs four
times as much for a backdrop nobody is looking straight at.
"""
return 2 if width * height <= 320_000 else 1