"""Ambient animated backdrop — soft motion behind every module screen.
The sequencing screen has its own backdrop (:mod:`spacr.qt.widgets.dna_rain`,
the ATGC cascade). This is the one for *everything else*: a slow, diffuse
animation that sits behind the settings form and the console, takes no focus
and no mouse events, and can be switched off entirely in Preferences.
Five data-art materials and three classic themes remain in the menu.
The default is ``data_art_impulse_lens`` (spaCR field). The other data-art choices
are spaCR advection, spaCR growth, spaCR waves and spaCR spinn.
The classic choices provide softer motion:
``blobs``
Big and small colour blobs drifting over the page, each pulsing in size on
its own period. They overlap and blend, so the result reads as soft colour
*fields* rather than as a bag of circles.
``aurora``
Three overlapping curtains of vertical rays, folding along their own
length. The folds are travelling waves — several superposed frequencies
running lengthwise along the arc — with brightness surges on a separate
schedule, a sharp lower edge, a diffuse top, and the real thing's
vertical colour order: green through the body, red high up, a violet
fringe underneath.
``drift``
A slow starfield in three parallax layers: small, dim, slow ones behind;
bigger, brighter, faster ones in front. The one crisp theme. It travels
up, down, or every which way — see :data:`DRIFT_DIRECTIONS`.
The older :class:`RippleEngine`, :class:`CellsEngine`, :class:`BokehEngine`
and :class:`ResonanceEngine` remain importable for direct callers, but are
no longer menu choices or factory entries.
There is also a private ``fractal`` engine: it is
not in :data:`AMBIENT_THEMES`, no menu lists it, no preference can hold it,
and the only way to see it is to start the application with the ``spaceout``
command instead of ``spacr``. See :data:`SPACEOUT_THEME` and
:class:`FractalEngine`.
The ``random`` direction of ``drift`` produces Brownian-style motion without
duplicating the starfield as a separate theme. Themes dominated by many
antialiased lines or per-pixel noise are omitted because their raster cost is
too high for an always-running backdrop.
Palettes
--------
Every theme declares the palettes it offers (:func:`palettes_for`), because a
palette that works as a 400 px blob does not necessarily work as a 2 px star.
:data:`PALETTE_SETS` holds the colours themselves; ``spacr`` uses the three
brand hues from the module-maturity legend, and ``okabe`` is the Okabe–Ito set
for red–green colour deficiency (see its note).
Both dark and light
-------------------
spaCR ships several themes, and a blob set tuned only for a near-black page turns
to mud on a white one. So the *composition mode follows the background*:
* dark page -> ``CompositionMode_Plus``. Overlapping blobs add up and glow,
which is what makes two circles read as one colour field. Additive over a
light page just clips to white and the whole effect vanishes.
* light page -> ``CompositionMode_Multiply``, with the palette colour mixed
toward white first. Multiply is the exact dual: overlaps get *darker* and
still blend hue-wise, so the same geometry reads the same way.
``SourceOver`` would let later blobs cover earlier ones, producing discrete
discs instead of a blended field.
:func:`AmbientWidget.set_background_color` re-derives all of that, so a live
theme switch is one call.
Cost
----
This paints behind every module screen, on machines that are simultaneously
running Cellpose on a GPU and a 40-plate pipeline, so cost is a correctness
requirement rather than a nicety. Two things get it there:
1. *The timer stops whenever the widget is not on screen* — hidden, on another
tab, or in a minimised window. Zero frames, zero CPU. These screens stay
open for hours, so this is the whole ball game.
2. *Diffuse themes are painted into a small reusable QImage and scaled up*.
The buffer's long edge is whatever the theme declares
(:attr:`_BufferedEngine.base_edge`) times the user's resolution setting,
so diffuse fields shade ~37 000 pixels instead of ~2 000 000. The aurora
and data-art materials preserve native display detail within the actual
screen-pixel budget. Aurora returns an owned frame, avoiding a second
full-size copy when a shading worker publishes it.
3. *The shading happens on its own thread* (:class:`_FrameProducer`), so the
GUI thread's whole share of a frame is one ``drawImage``. That is the next
section, and it is the one that matters while a pipeline is running.
While a run is going
--------------------
The animation can lag while a job is running, and the cause is not the obvious
one. Measurements on a real X server at 1920x1080 with a real ``ConsolePanel``
under the real stylesheet and a real Qt event loop, ``blobs`` at the shipped
24 fps cap, best of five interleaved rounds:
============================================== ========== ==========
condition delivered GUI paint
============================================== ========== ==========
idle 25.0 fps 2.04 ms
a numpy thread (1024² matmul + FFT), flat out 24.5 fps 2.10 ms
200 console lines a second, nothing else 24.9 fps 1.27 ms
**one pure-Python thread** 24.5 fps **17.21 ms**
a worker doing Python work *and* printing 17.3 fps 17.16 ms
============================================== ========== ==========
So **CPU saturation is not a cause**: numpy releases the interpreter lock and
a core burning flat out costs this module nothing. **A signal flood is not a
cause on its own**: 200 lines a second are free, and it only bites in the
thousands, where the console's own per-line work saturates the GUI thread and
nothing in *this* module can help. What is left is **the interpreter lock**:
identical drawing work, eleven times slower, because the shading pass is
Python and numpy and something else is holding the lock.
Translucent overlays can also trigger repaints outside the animation timer.
The console sits over this widget, so each new line can expose it and request
a full frame even while the animation timer is stopped. Expensive shading must
therefore remain off the GUI thread even when the frame rate is capped.
The frame is split at the seam where the cost occurs. The following historical
measurements predate native-resolution Aurora and serve as a comparison, not
as a current frame-rate claim. Per theme, milliseconds, idle against one
Python thread, min of nine interleaved rounds:
========= ===================== =====================
theme shading (moved) soften + blit (stays)
========= ===================== =====================
blobs 0.240 -> 0.572 0.651 -> 1.097
aurora 1.396 -> 7.176 0.797 -> 1.043
ripple 0.367 -> 0.589 0.644 -> 1.196
bokeh 0.663 -> 3.533 0.679 -> 1.008
cells 0.538 -> 26.179 0.663 -> 0.909
drift 0.528 -> 1.084 (no buffer)
resonance 1.072 -> see below 0.842 -> 1.115
========= ===================== =====================
``resonance`` is the one row whose contended figure is a range rather than a
number, and the shape of its shading is the reason. The others are one long
pass of ``QPainter`` calls; the plate is dozens of small NumPy calls, and
each one gives the lock back and then queues for it again, so what the cell
would measure is how often the shading thread was descheduled rather than
how much work the theme does. Four nine-round repeats of the protocol above
gave medians of 10, 174, 286 and 407 ms on the same machine. Two things make
that liveable and both are already here: the cost is paid on the producer
thread, so a late frame is a *repeated* frame
(:attr:`AmbientWidget.repeated_frames`) and never a slow interface; and
while a run is going the process holds
:data:`spacr.qt.gil_priority.BUSY_INTERVAL`, where the same measurement is 6
to 24 ms. Its row was taken later than the rest and on a busier machine —
``blobs`` read 0.262 and 0.749 -> 1.002 in the same run — so read it against
those rather than against the table.
The shading pass is sensitive to interpreter-lock contention, whereas the Qt
blit remains inexpensive. :meth:`_BufferedEngine.shade` therefore runs in
:class:`_FrameProducer`, while :meth:`_BufferedEngine.blit` stays on the GUI
thread. If a shaded frame is not ready, the widget repeats the previous frame
instead of blocking the interface.
In that historical ``blobs`` benchmark, the rate goes from 17.3 fps to 24.7 on
the same worker. What this
does **not** address is a genuinely chatty run: at 200 lines a second both
land at about 4 fps, because by then the GUI thread is inside ``ConsolePanel``
and not in here at all. Two levers finish the job and neither is in this file —
``sys.setswitchinterval(0.001)`` in the Qt bootstrap (measured independently:
32 % of the frame rate to 99 %, for about 6 % of the worker's throughput) and
coalescing ``PipelineWorker.line_ready``.
Three constraints shape the implementation. Animation periods are sampled
from continuous ranges rather than replayed from a precomputed loop, avoiding
visible jumps and large frame caches. Shading runs outside the GUI thread; if
a frame is late, the previous frame is repeated and counted by
:attr:`AmbientWidget.repeated_frames`. The animation clock remains on the GUI
thread, and rendered frames remain deterministic functions of
``(seed, clock, size)``.
``drift`` keeps synchronous drawing at full Detail. Lower Detail renders the
same particle population through a bounded image buffer; it does not reduce
Density. Its historical row above predates that buffer path.
Performance depends on hardware, display size, theme, and concurrent work.
Density controls population independently of Detail. Detail controls sampling
resolution, and buffers stay within the actual screen-pixel budget. Increasing
Detail does not trim the selected population. Legacy direct engine callers may
still set blur, but Preferences exposes no Blur control. Hidden widgets stop
rendering entirely. Historical timings above do not establish native 24 FPS
at current maximum controls.
The private growth producer uses ``_lineage`` to index reproducible wandering
tips and recursive front forks. Daughter branches remain connected to their
parent filaments; older trails recede as new colonies begin. This is decorative
mycelial artwork, not a model fitted to project measurements.
The field's background-only left drag uses ``_set_field_grab`` and
``_step_field_grab`` for a bounded local spring with continuous release
velocity. ``AmbientWidget._offer_field_grab`` publishes the latest handle
without waiting for shading, and ``AmbientWidget._field_grab_background``
excludes scientific canvases and interactive controls. Release returns the
patch without moving or reseeding its underlying material.
Aurora's ``buffer_size`` and ``buffer_scale`` retain crisp display sampling;
its private ``_shade`` returns a freshly owned frame. Drift's ``buffer_size``
bounds lower-Detail drawing, while its private ``_paint_dots`` paints the same
selected population into that buffer or directly at full Detail.
"""
from __future__ import annotations
import atexit
import logging
from .. import _after
import math
import os
import random
import sys
import threading
import time
import weakref
from dataclasses import dataclass
from functools import partial
from typing import (Callable, Dict, List, NamedTuple, Optional, Sequence,
Tuple, Union)
from PySide6.QtCore import (QElapsedTimer, QEvent, QObject, QPoint, QPointF,
QRect, QRectF, Qt, QTimer, Signal, Slot)
from PySide6.QtGui import (QBrush, QColor, QCursor, QImage,
QLinearGradient, QPainter, QPainterPath, QPen,
QPicture, QPixmap, QPolygonF, QRadialGradient,
QRegion, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView,
QAbstractSlider, QAbstractSpinBox, QApplication,
QComboBox, QFrame, QGraphicsView, QLabel,
QLineEdit, QMenu, QMenuBar, QPlainTextEdit,
QSizePolicy, QSplitterHandle, QStyle,
QStyleOption, QTabBar, QTextEdit, QWidget)
from ..gil_priority import _stop_watching_object_events, _watch_object_events
from ..theme import (advance_spaceout_drift, page_colour, palette_for,
relative_luminance, spaceout_enabled)
LOG = logging.getLogger(__name__)
__all__ = [
"AMBIENT_THEMES", "ANIMATION_CHOICES", "NO_ANIMATION",
"animation_label", "animation_note", "is_animation_choice",
"total_frames_painted",
"DEFAULT_THEME", "DEFAULT_PALETTE", "PALETTE_SETS",
"SPACEOUT_THEME", "SPACEOUT_PALETTE", "DEFAULT_SPACEOUT_THEME",
"DEFAULT_SPACEOUT_PALETTE", "dressed",
"AmbientWidget", "install_ambient", "theme_label", "theme_note",
"palettes_for", "palette_label", "palette_note", "palette_colors",
"default_palette_for", "is_valid_theme", "is_valid_palette",
"BLUR_RANGE", "SPEED_RANGE", "SIZE_RANGE", "RESOLUTION_RANGE",
"DENSITY_RANGE", "DEFAULT_BLUR", "DEFAULT_SPEED", "DEFAULT_SIZE",
"DEFAULT_RESOLUTION", "DEFAULT_DENSITY", "DRIFT_DIRECTIONS",
"DEFAULT_DRIFT_DIRECTION", "drift_direction_label",
"drift_direction_note", "is_valid_drift_direction", "Motion",
"preferred_motion",
]
AMBIENT_THEMES: Tuple[str, ...] = (
"data_art_impulse_lens",
"data_art_genetic_advection",
"data_art_point_atlas",
"blobs",
"drift",
)
SPACEOUT_ONLY_THEMES = ("data_art_spaceout_field", "data_art_fungal_growth",
"aurora", "data_art_tissue_facets")
DEFAULT_SPACEOUT_THEME = "data_art_spaceout_field"
DEFAULT_SPACEOUT_PALETTE = "spacr"
_FIELD_THEMES = frozenset(("data_art_impulse_lens", DEFAULT_SPACEOUT_THEME))
#: The animation the ``spaceout`` entry point paints, and the palette it
#: paints it in.
#:
SPACEOUT_THEME = "fractal"
SPACEOUT_PALETTE = "rainbow"
#: The animation choice that draws nothing and runs no timer.
#:
#: Not one more engine that happens to paint an empty frame — that would
#: still be a timer, a repaint and a composite sixty times a second for a
#: picture that is identical every time. It is the absence of the widget:
#: :func:`spacr.qt.preferences.get_ambient_enabled` reports ``False`` while
#: it is selected, and the three install sites (``AppScreen``, Home and
#: ``MainWindow._theme_screen``) all read that *before* they construct
#: anything. The cost is zero because nothing exists, which is the only kind
#: of zero worth claiming.
NO_ANIMATION = "none"
ANIMATION_CHOICES: Tuple[str, ...] = AMBIENT_THEMES + (NO_ANIMATION,)
#: Default ambient animation theme.
DEFAULT_THEME = "data_art_impulse_lens"
#: spaCR's own colours, likewise.
DEFAULT_PALETTE = "spacr"
_THEME_LABELS = {
"blobs": "spaCR blobs",
"aurora": "spaCR aurora",
"drift": "spaCR stratified",
"data_art_impulse_lens": "spaCR field",
"data_art_spaceout_field": "spaCR field phenomena",
"data_art_genetic_advection": "spaCR advection",
"data_art_fungal_growth": "spaCR growth",
"data_art_point_atlas": "spaCR waves",
"data_art_tissue_facets": "spaCR spinn",
SPACEOUT_THEME: "spaCR fractals",
}
_THEME_NOTES = {
"blobs": 'Soft colour blobs, large and small, drifting and slowly changing size.',
"aurora": 'Fine curtains of northern light ripple through softly layered folds.',
"drift": 'A slow starfield in three layers of depth.',
"data_art_impulse_lens": 'A crisp gravitational dot field with optional local mouse influence and expanding ripples.',
"data_art_spaceout_field": 'A crisp interactive field develops random gravitational landscapes, elastic releases, whirlwinds, density waves and flowing colour, with calm intervals and smooth transitions.',
"data_art_genetic_advection": 'Fine particles form evolving vortices and branching currents, with optional mouse gravity.',
"data_art_fungal_growth": 'Connected mycelial filaments grow from common origins, with wandering tips and recursively branching fronts. Older trails fade as new colonies begin.',
"data_art_point_atlas": 'An edge-free landscape of round points carries wide travelling waves.',
"data_art_tissue_facets": 'Fine paper facets move gently and respond locally to the mouse.',
SPACEOUT_THEME: ("A Julia set that morphs, turns and cycles colour — "
"the backdrop the spaceout launcher dresses the "
"application in."),
}
[docs]
class PaletteSpec(NamedTuple):
"""One named colour set: what to call it and what it is made of."""
label: str
colors: Tuple[str, ...]
note: str
#: The colour sets, shared across themes. A palette is a *set of hues*; how
#: strongly they are applied is the theme's business (a 2 px star needs a very
#: different alpha from a 400 px blob), which is why the alphas live in the
#: engines and not here.
PALETTE_SETS: Dict[str, PaletteSpec] = {
"custom": PaletteSpec(
"Custom colours", ("#3b82f6", "#ff00ff"),
"Your chosen primary and accent colours."),
"random": PaletteSpec(
"Random colours", tuple(QColor.fromHsv((index * 137 + 17) % 360,
175 + index % 45,
225 + index % 30).name()
for index in range(32)),
"Stable varied colours for individual elements."),
"spacr": PaletteSpec(
"spaCR",
("#3B82F6", "#FF00FF", "#00CEC8"),
"spaCR's own three colours — the blue, magenta and green-cyan that "
"mark a module stable, beta or alpha."),
"ember": PaletteSpec(
"Ember",
("#FF6B35", "#FFB020", "#E2374A", "#FF8FA3"),
"Warm — orange, amber and rose."),
"ocean": PaletteSpec(
"Ocean",
("#0EA5E9", "#22D3EE", "#2DD4BF", "#3B82F6"),
"Cool — teal, aqua and deep blue."),
"pastel": PaletteSpec(
"Pastel",
("#A8D8EA", "#FFB5E8", "#B5EAD7", "#FFDAC1"),
"Pale, low-contrast colours for a faint backdrop."),
"mono": PaletteSpec(
"Monochrome",
("#A3A3A3", "#CFCFCF", "#707070"),
"Greys only: the backdrop moves without colour."),
"okabe": PaletteSpec(
"Colour-blind safe",
("#0072B2", "#E69F00", "#009E73", "#56B4E9", "#D55E00", "#F0E442"),
"The Okabe–Ito set. Its colours stay distinguishable under "
"protanopia and deuteranopia — red–green deficiency, the common "
"kind — because no pair in it differs by red versus green alone."),
"borealis": PaletteSpec(
"Aurora borealis",
("#7CFC9E", "#FF3C5A", "#5B6BFF", "#D9FFA8"),
"Auroral emission lines: atomic oxygen at 557.7 nm (the dominant "
"green), atomic oxygen at 630.0 nm (the red that appears only at "
"high altitude), ionised nitrogen at 427.8 nm (the blue-violet "
"lower fringe), and the pale yellow-green where the green and the "
"red overlap."),
"rainbow": PaletteSpec(
"Rainbow",
("#FF0040", "#FF7A00", "#FFE000", "#00E05A", "#00C8FF", "#4030FF",
"#C02BFF"),
"The spectrum, closed into a ring: red through orange, yellow, "
"green, cyan and blue to violet, and back round to red."),
"fluor": PaletteSpec(
"Fluorescence",
("#3AA0FF", "#3DFF6E", "#FF5A3C", "#FFD24A"),
"Emission colours of a standard filter set: DAPI at 461 nm "
"(blue), FITC at 519 nm (green), TRITC at 576 nm (orange-red), and "
"the yellow where green and red overlap."),
"midnight": PaletteSpec(
"Midnight",
("#7185F4", "#B775F0", "#5EC8F8", "#C9CFFF"),
"Indigo, violet, ice blue and pale periwinkle."),
"dusk": PaletteSpec(
"Dusk",
("#EE779F", "#E87DDA", "#F6B98A", "#8C5BC7"),
"Rose, magenta, dusty gold and plum."),
"lowsun": PaletteSpec(
"Low sun",
("#F6D46F", "#F2A65A", "#E2D583", "#A8C46A"),
"Gold, amber, pale gold and moss green."),
"deepwater": PaletteSpec(
"Deep water",
("#3CD296", "#7EE7BD", "#2AA5C4", "#17A08A"),
"Sea green, mint and teal."),
}
_THEME_PALETTES: Dict[str, Tuple[str, ...]] = {
"blobs": ("spacr", "ember", "ocean", "pastel", "mono", "okabe",
"borealis", "fluor", "midnight", "dusk", "lowsun",
"deepwater"),
"aurora": ("spacr", "ember", "ocean", "pastel", "mono", "okabe",
"borealis", "midnight", "dusk"),
"ripple": ("spacr", "ember", "ocean", "mono", "okabe", "deepwater",
"midnight"),
"drift": ("spacr", "ember", "ocean", "mono", "okabe", "borealis",
"fluor", "midnight"),
"cells": ("spacr", "ember", "ocean", "pastel", "mono", "okabe", "fluor",
"lowsun", "deepwater"),
SPACEOUT_THEME: (SPACEOUT_PALETTE,),
}
for _data_art_key in (key for key in AMBIENT_THEMES + SPACEOUT_ONLY_THEMES
if key.startswith("data_art_")):
_THEME_PALETTES[_data_art_key] = tuple(
palette for palette in PALETTE_SETS
if palette != SPACEOUT_PALETTE)
for _classic_key in ("blobs", "aurora", "drift"):
_THEME_PALETTES[_classic_key] += ("random",)
_PAINTABLE_THEMES: Tuple[str, ...] = AMBIENT_THEMES + SPACEOUT_ONLY_THEMES + (SPACEOUT_THEME,)
[docs]
def is_valid_theme(name) -> bool:
"""True when ``name`` is one of :data:`AMBIENT_THEMES`.
The predicate exists so a caller validating stored preferences does not
have to catch :class:`ValueError` from the strict accessors below.
:param name: the value to test, typically a stored preference.
"""
return name in AMBIENT_THEMES
[docs]
def is_valid_palette(theme, palette) -> bool:
"""True when ``palette`` is offered by ``theme``. Never raises.
:param theme: the theme name; an unknown theme offers nothing.
:param palette: the palette name to look for among ``theme``'s palettes.
"""
return palette in _THEME_PALETTES.get(theme, ())
def _require_theme(name: str) -> str:
"""Validate a name that is about to be *painted*.
Against :data:`_PAINTABLE_THEMES`, not :data:`AMBIENT_THEMES`: the
spaceout fractal has an engine and must pass here, while
:func:`is_valid_theme` — which is what a stored preference is checked
with — goes on rejecting it.
"""
if name not in _PAINTABLE_THEMES:
raise ValueError(
f"unknown ambient theme {name!r}; expected one of "
f"{', '.join(_PAINTABLE_THEMES)}")
return name
def _require_palette(theme: str, name: str) -> str:
"""Validate ``name`` *for this theme*, loudly.
Two different failures, and they are worth telling apart in the message:
a palette nobody has ever heard of, and a real palette this theme does
not offer.
"""
_require_theme(theme)
offered = _THEME_PALETTES[theme]
if name in offered:
return name
if name in PALETTE_SETS:
raise ValueError(
f"the {theme!r} ambient theme does not offer the {name!r} "
f"palette; expected one of {', '.join(offered)}")
raise ValueError(
f"unknown ambient palette {name!r}; the {theme!r} theme expects one "
f"of {', '.join(offered)}")
[docs]
def theme_label(name: str) -> str:
"""Human label for ``name``, for a menu. Raises on an unknown theme.
:param name: a paintable theme name; any other raises
:class:`ValueError`.
"""
return _THEME_LABELS[_require_theme(name)]
[docs]
def theme_note(name: str) -> str:
"""One-line description of ``name``, for a tooltip.
:param name: a paintable theme name; any other raises
:class:`ValueError`.
"""
return _THEME_NOTES[_require_theme(name)]
[docs]
def is_animation_choice(name) -> bool:
"""True for anything the Animation preference may hold — including
:data:`NO_ANIMATION`, which :func:`is_valid_theme` rejects because it
cannot be painted.
:param name: the value to test, typically a stored preference.
"""
return name in ANIMATION_CHOICES
[docs]
def animation_label(name: str) -> str:
"""Human label for an entry of :data:`ANIMATION_CHOICES`.
"None" rather than "Off": the row is called Animation and this is one of
the animations it can be set to, the way a font size can be set to zero.
:param name: :data:`NO_ANIMATION` or a paintable theme name; any other
name raises :class:`ValueError`.
"""
if name == NO_ANIMATION:
return "None"
return theme_label(name)
[docs]
def animation_note(name: str) -> str:
"""One-line description of an animation choice, for a tooltip.
The note for "None" states the cost, because that is the only reason a
reader picks it — and the claim is asserted rather than advertised: see
``tests/qt/test_ambient_none.py``, which counts painted frames over a
real second instead of trusting this sentence.
:param name: :data:`NO_ANIMATION` or a paintable theme name; any other
name raises :class:`ValueError`.
"""
if name == NO_ANIMATION:
return ("No backdrop at all: nothing is drawn behind the module "
"pages and no animation timer runs anywhere in spaCR, so "
"the cost while idle is exactly zero rather than nearly "
"nothing. The page keeps its ordinary theme colour.")
return theme_note(name)
[docs]
def palettes_for(theme: str) -> Tuple[str, ...]:
"""The palette names ``theme`` offers, in menu order.
Never empty. Raises :class:`ValueError` on an unknown theme rather than
returning ``()``, because an empty tuple reads as "this theme has no
palettes" and would quietly leave a settings menu blank.
:param theme: a paintable theme name.
"""
return _THEME_PALETTES[_require_theme(theme)]
[docs]
def default_palette_for(theme: str) -> str:
"""The palette ``theme`` falls back to — :data:`DEFAULT_PALETTE` when it
is on offer, otherwise the first one listed.
:param theme: a paintable theme name; an unknown one raises
:class:`ValueError`.
"""
offered = palettes_for(theme)
return DEFAULT_PALETTE if DEFAULT_PALETTE in offered else offered[0]
[docs]
def palette_label(theme: str, palette: str) -> str:
"""Human label for ``palette`` as offered by ``theme``.
:param theme: a paintable theme name.
:param palette: a palette ``theme`` offers; an unknown theme, or a
palette the theme does not offer, raises :class:`ValueError`.
"""
return PALETTE_SETS[_require_palette(theme, palette)].label
[docs]
def palette_note(theme: str, palette: str) -> str:
"""One-line description of ``palette``, for a tooltip.
:param theme: a paintable theme name.
:param palette: a palette ``theme`` offers; an unknown theme, or a
palette the theme does not offer, raises :class:`ValueError`.
"""
return PALETTE_SETS[_require_palette(theme, palette)].note
[docs]
def palette_colors(theme: str, palette: str) -> Tuple[str, ...]:
"""The ``#rrggbb`` colours behind ``palette``, for ``theme``.
:param theme: a paintable theme name.
:param palette: a palette ``theme`` offers; an unknown theme, or a
palette the theme does not offer, raises :class:`ValueError`.
"""
selected = _require_palette(theme, palette)
if selected == "custom":
from ..preferences import _ambient_custom_colors
return _ambient_custom_colors()
return PALETTE_SETS[selected].colors
[docs]
def coerce_palette(theme: str, palette: str) -> str:
"""``palette`` if ``theme`` offers it, else that theme's default.
Only for *stored* values — a preferences file that still names the
palette the user picked under a different theme should not stop a screen
from being built. An unknown name is still an error: it is a bug, not a
stale setting.
"""
_require_theme(theme)
if palette not in PALETTE_SETS:
raise ValueError(
f"unknown ambient palette {palette!r}; expected one of "
f"{', '.join(sorted(PALETTE_SETS))}")
return palette if is_valid_palette(theme, palette) \
else default_palette_for(theme)
[docs]
def dressed(theme: str, palette: str) -> Tuple[str, str]:
"""Resolve the ambient theme and palette for the current launch mode.
Standard launches preserve the requested pair. A Spaceout launch uses
its own stored selection, or defaults to the evolving field before one is saved.
:param theme: the requested theme name, not validated here.
:param palette: the requested palette name, treated the same way.
"""
if spaceout_enabled():
from ..preferences import _settings, _KEY_SPACEOUT_ANIMATION
if _settings().contains(_KEY_SPACEOUT_ANIMATION):
return theme, coerce_palette(theme, palette)
return DEFAULT_SPACEOUT_THEME, DEFAULT_SPACEOUT_PALETTE
return theme, palette
#: Frame-rate cap: 30, the rate the tutorial capture records the field at.
DEFAULT_FPS = 30
FIELD_FPS = 60
#: Frame cap while a run goes: a pipeline holding
#: :func:`spacr.qt.gil_priority.active`, or a CPU-heavy job the Dynamic
#: animation policy yields to. Half the field's rate and the rate the
#: tutorial capture records at, so the backdrop keeps moving smoothly instead
#: of freezing (4 fps, and a stopped field for CPU jobs, read as a frozen
#: backdrop in lessons 07 and 08). Measured on lesson 08's Measure run, see
#: ``tests/qt/test_the_field_keeps_moving_during_a_run_20261013.py``.
_RUN_FPS = 30
MIN_FPS = 1
#: GUI-thread time, in ms, a field frame may take during a run before the
#: next one waits. Over the field every widget on screen repaints with each
#: frame; at 3840x2105 that is 17-28 ms idle and a median 39 ms during
#: lesson 13's run, where every return to Python then also waits for the
#: pipeline's hold on the interpreter lock. Idle frames are never held back.
_FRAME_BUDGET_MS = 20.0
#: The share of the GUI thread a frame over :data:`_FRAME_BUDGET_MS` may
#: keep: the next tick waits until the frame's own cost has been matched by
#: free time, so input, the console and progress get the rest. The field's
#: clock is wall time, so it moves at the same speed in fewer steps; frames
#: within budget keep the normal rate. During lesson 13's run this cut the
#: GUI thread's time in gaps over 50 ms by 14% and gaps over 200 ms by 41%.
_OVER_BUDGET_SHARE = 0.5
MAX_FPS = 60
#: The ordinary application installs a backdrop on every live screen. Its
#: motion is deliberately slow and diffuse, so spending the direct widget's
#: full frame budget while the application is otherwise idle only feeds
#: repaints that are visually redundant. Keep ``AmbientWidget``'s public
#: default unchanged for callers that explicitly construct one; the shared
#: production installer uses this lower idle budget unless a caller asks for
#: a particular rate.
_INSTALLED_FPS = 12
#: Largest simulation step accepted from the wall clock, in seconds. If the
#: app was busy for two seconds the animation resumes where it was instead of
#: teleporting.
MAX_DT = 0.25
#: Longest edge, at resolution 1.0, of the buffer a soft theme shades into.
#: This is the *diffuse* themes' figure. 256 px upscales to 1920 with nothing
#: measurably lost — a frame of ``blobs`` shaded here differs from the same
#: frame shaded at 1920x1080 by at most 2.9 luminance levels out of 255, and
#: ``ripple`` by 3.2 — because a diffuse gradient has no detail to lose.
#: Themes with an edge or a fine repeat in them declare their own, larger,
#: figure; see :attr:`_BufferedEngine.base_edge` and
#: :data:`AURORA_BUFFER_EDGE`.
BUFFER_MAX_EDGE = 256
#: Hard limits on the derived buffer edge. The low end is where bilinear
#: upscaling stops looking like softness and starts looking like blocks; the
#: high end is where a 4K canvas would be shaded at full resolution.
BUFFER_MIN_EDGE = 96
BUFFER_EDGE_CEILING = 2048
#: A second, absolute cost ceiling on the buffer, in pixels. The edge is a
#: *ratio* to the canvas, and a ratio alone lets a 5K display quietly ask for
#: a five-megapixel shading pass. Shading is per buffer pixel, so this is the
#: number that actually bounds the frame.
#:
#: 1920x1080 is the FALLBACK, and it is a guess at a monitor rather than a
#: measurement of one. What an engine actually uses is
#: :attr:`AmbientEngine.max_pixels`, which :class:`AmbientWidget` sets from
#: the screen the window is really on — see :func:`screen_pixels`. This is
#: what an engine built with no widget behind it gets: the tests, the theme
#: preview, and anything constructed before there is a window to ask.
BUFFER_MAX_PIXELS = 1920 * 1080
def _display_hz(widget: Optional[QWidget] = None, cap: float = FIELD_FPS) -> float:
"""The highest rate at or below ``cap`` that divides the screen's refresh.
A whole number of refreshes per frame keeps every frame on screen for the
same time; 60 on a 75 Hz panel would hold one frame in four twice.
"""
try:
from ..hidpi import screen_for_widget
screen = screen_for_widget(widget)
refresh = float(screen.refreshRate()) if screen is not None else 0.0
except Exception:
refresh = 0.0
if not math.isfinite(refresh) or refresh < 20.0 or refresh > 1000.0:
refresh = 60.0
return refresh / max(1, math.ceil(refresh / max(1.0, float(cap)) - 1e-6))
[docs]
def screen_pixels(widget: Optional[QWidget] = None) -> int:
"""Return the device-pixel count of the screen containing ``widget``.
The primary screen is used when no widget screen is available. Headless
or invalid screen information falls back to :data:`BUFFER_MAX_PIXELS`.
"""
try:
from ..hidpi import screen_for_widget
screen = screen_for_widget(widget)
if screen is None:
return BUFFER_MAX_PIXELS
size = screen.size()
ratio = float(screen.devicePixelRatio() or 1.0)
pixels = int(size.width() * ratio) * int(size.height() * ratio)
except Exception:
return BUFFER_MAX_PIXELS
return pixels if pixels >= BUFFER_MIN_EDGE ** 2 else BUFFER_MAX_PIXELS
#: How much detail is computed, as a multiplier on the theme's own buffer
#: edge. Above 1.0 costs quadratically more (shading is per buffer pixel);
#: below 1.0 is the escape hatch for a machine that cannot afford the
#: backdrop at all.
RESOLUTION_RANGE = (0.25, 2.0)
DEFAULT_RESOLUTION = 1.0
#: How soft the result is, in units of :data:`BLUR_UNIT_PX` screen pixels of
#: area averaging. 0.0 — the default — is no softening pass at all, which is
#: also why the default frame is still byte-for-byte the shipped one.
#:
#: This is *not* the old blur. The old one ran from 0.25 (sharp) through 1.0
#: (as shipped) to 3.0 (soft) and sharpened by enlarging the buffer; that job
#: now belongs to :data:`RESOLUTION_RANGE`, and this control only ever
#: softens. The rename of the meaning is deliberate and is called out in the
#: preferences module, which migrates a stored value from the old scale.
BLUR_RANGE = (0.0, 3.0)
DEFAULT_BLUR = 0.0
#: What one unit of blur is worth, in screen pixels. 8 is not arbitrary: it
#: is exactly the smoothing the diffuse themes shipped with, when a 240x135
#: buffer was stretched over 1920x1080. So blur 1.0 asks for "the softness
#: the backdrop always had" and gets it at whatever resolution is set —
#: which is the sharp-and-soft frame that could not be asked for before.
#:
#: Expressed in *screen* pixels rather than buffer pixels on purpose: in
#: buffer pixels the two controls would still be coupled, and raising the
#: resolution would silently sharpen the picture again.
BLUR_UNIT_PX = 8.0
#: A multiplier on the animation clock, so every per-theme period, drift rate
#: and travel speed scales together and nothing has to be re-tuned. Applied
#: in :meth:`AmbientEngine.advance`, never in :meth:`geometry` — changing the
#: speed must not teleport an animation that is already on screen.
SPEED_RANGE = (0.1, 4.0)
DEFAULT_SPEED = 1.0
#: A multiplier on each theme's own size range: blob radius, aurora curtain
#: height and ray spacing, ripple wavelength, starfield dot diameter.
SIZE_RANGE = (0.25, 2.5)
DEFAULT_SIZE = 1.0
#: A multiplier on how many *elements* a theme draws: blobs, curtains, ripple
#: sources, stars, bokeh discs, cells. Every engine rolls a pool big enough
#: for the top of this range once, at construction, and then paints a prefix
#: of it — so turning the slider never re-rolls the field and never makes the
#: animation jump.
DENSITY_RANGE = (0.01, 3.0)
DEFAULT_DENSITY = 0.1
BLINK_PERCENT_RANGE = (0.0, 10.0)
DEFAULT_BLINK_PERCENT = 0.0
WORK_BUDGET = 4.0
#: Which way the starfield goes.
#:
#: A preference on the theme rather than three entries in the theme menu.
#: They are one animation with one constant changed — the same particles,
#: pool, parallax layers, sway and twinkle — so three menu entries would put
#: two thirds of a list in front of the user to express one axis, and would
#: then have to answer what a palette or a density means "for Starfield
#: (down)" separately three times. It also composes: direction is orthogonal
#: to speed, size and density, and a menu entry is not.
DRIFT_DIRECTIONS: Tuple[str, ...] = ("up", "down", "random")
DEFAULT_DRIFT_DIRECTION = "up"
_DRIFT_DIRECTION_LABELS = {
"up": "Up",
"down": "Down",
"random": "Random",
}
_DRIFT_DIRECTION_NOTES = {
"up": "All specks move upward, as in the original starfield.",
"down": "All specks move downward.",
"random": ("Each speck moves in its own random direction (Brownian "
"motion) instead of one shared direction."),
}
[docs]
def is_valid_drift_direction(name) -> bool:
"""True when ``name`` is one of :data:`DRIFT_DIRECTIONS`. Never raises.
:param name: the value to test.
"""
return name in DRIFT_DIRECTIONS
def _require_drift_direction(name: str) -> str:
"""Validate a starfield drift direction.
:param name: the direction.
:returns: it unchanged.
:raises ValueError: if it is not one of the known directions, naming
them -- a mistyped direction would otherwise drift nowhere and look
like the animation had stopped.
"""
if name not in DRIFT_DIRECTIONS:
raise ValueError(
f"unknown starfield direction {name!r}; expected one of "
f"{', '.join(DRIFT_DIRECTIONS)}")
return name
[docs]
def drift_direction_label(name: str) -> str:
"""Human label for a starfield direction, for a menu.
:param name: one of :data:`DRIFT_DIRECTIONS`; any other value raises
:class:`ValueError`.
"""
return _DRIFT_DIRECTION_LABELS[_require_drift_direction(name)]
[docs]
def drift_direction_note(name: str) -> str:
"""One-line description of a starfield direction, for a tooltip.
:param name: one of :data:`DRIFT_DIRECTIONS`; any other value raises
:class:`ValueError`.
"""
return _DRIFT_DIRECTION_NOTES[_require_drift_direction(name)]
#: A background at or below this WCAG relative luminance is treated as dark,
#: which selects additive compositing. The five shipped themes measure 0.000
#: (dark), 0.002 (space), 0.002 (cell), 0.004 (glass) and 0.956 (light), so
#: the exact threshold only ever matters for a custom mid-grey; it sits high
#: because additive is the more forgiving of the two on a mid tone.
DARK_LUMINANCE_MAX = 0.30
#: How far a palette colour is mixed toward white before it is multiplied
#: onto a light page. Undiluted saturated hues multiply to something muddy
#: and far too strong.
LIGHT_TINT = 0.55
def _as_color(value: Union[QColor, str, None], fallback: QColor) -> QColor:
"""Coerce ``value`` to a valid opaque QColor, or fall back to a colour
that is one."""
if value is None:
color = QColor(fallback)
else:
color = QColor(value)
if not color.isValid():
color = QColor(fallback)
color.setAlpha(255)
return color
def _as_pixmap(value) -> Optional[QPixmap]:
"""Coerce a path / QPixmap / QImage to a usable QPixmap, or ``None``.
Never raises and never returns a null pixmap: a wallpaper deleted between
the stylesheet being built and this widget being constructed is a cosmetic
miss, not a crash.
"""
if value is None:
return None
try:
if isinstance(value, QPixmap):
pixmap = value
elif isinstance(value, QImage):
pixmap = QPixmap.fromImage(value)
else:
pixmap = QPixmap(str(value))
except Exception:
return None
return None if pixmap.isNull() else pixmap
def _covers_opaquely(pixmap: Optional[QPixmap]) -> bool:
"""Whether a backdrop leaves nothing under it showing through.
``None`` (the flat page colour) does, and so does a picture without an
alpha channel or with every pixel at full alpha. A picture with any
translucent pixel lets whatever is under the field show there.
"""
if pixmap is None or not pixmap.hasAlphaChannel():
return True
alpha = pixmap.toImage().convertToFormat(QImage.Format_Alpha8)
full = QImage(alpha.size(), QImage.Format_Alpha8)
full.fill(QColor(0, 0, 0, 255))
return alpha == full
def _mix(a: QColor, b: QColor, t: float) -> QColor:
"""Linear RGB mix, ``t=0`` -> ``a``, ``t=1`` -> ``b``."""
t = max(0.0, min(1.0, float(t)))
return QColor(
int(round(a.red() + (b.red() - a.red()) * t)),
int(round(a.green() + (b.green() - a.green()) * t)),
int(round(a.blue() + (b.blue() - a.blue()) * t)),
)
def _with_alpha(color: QColor, alpha: float) -> QColor:
"""Return a copy of a colour at a given alpha.
:param color: the colour to copy.
:param alpha: the alpha, clamped to ``[0, 1]``.
:returns: the new colour; the original is not modified.
"""
out = QColor(color)
out.setAlphaF(max(0.0, min(1.0, float(alpha))))
return out
[docs]
def is_dark_background(color: Union[QColor, str]) -> bool:
"""True when ``color`` is dark enough for additive compositing."""
return relative_luminance(_as_color(color, QColor("#000000")).name()) \
<= DARK_LUMINANCE_MAX
def _clamp_int(value, low: int, high: int) -> int:
"""Clamp a value into an integer range.
:param value: the value.
:param low: the lower bound.
:param high: the upper bound.
:returns: the clamped integer.
"""
return max(low, min(high, int(value)))
def _clamp(value: float, low: float, high: float) -> float:
"""Clamp a value into a float range.
:param value: the value.
:param low: the lower bound.
:param high: the upper bound.
:returns: the clamped float.
"""
return max(low, min(high, float(value)))
def _pool_size(base: int) -> int:
"""How many elements to roll for a theme whose own count is ``base``.
Enough for the top of :data:`DENSITY_RANGE`, rolled once at construction.
Density then paints a prefix of the pool, so the slider never re-rolls
the field, never makes what is on screen jump, and — because the extra
draws happen *after* the originals — never disturbs the numbers the
shipped elements were built from.
"""
return max(1, int(math.ceil(base * DENSITY_RANGE[1])))
def _theme_background() -> QColor:
"""The current theme's flat page colour, or the dark one if unavailable.
``page``, not ``bg``. The docstring said "page colour" all along and
the code read the *window* colour, which on the dark theme is
``#000000`` — so the flat fill under the animation was pure black,
and on the frames and in the gaps where the animation is thin that is
what reached the eye. See the ``page`` block in :mod:`spacr.qt.theme`.
"""
try:
from ..theme import active_page_colour
return QColor(active_page_colour())
except Exception:
return QColor(page_colour("dark"))
def _preferred_background() -> QColor:
"""Use the saved fill when chosen, otherwise the current page colour."""
try:
from ..preferences import (_ambient_background_choice,
_effective_ambient_background)
if _ambient_background_choice() is None:
return _theme_background()
return _effective_ambient_background()
except Exception:
return _theme_background()
#: NumPy, once it has been imported. See :func:`_numpy`.
_NUMPY = None
def _numpy():
"""NumPy, imported on first use rather than at module import.
:class:`FractalEngine` and the data-art materials need it only when their
producers shade a frame. This module is imported on the way to every
module screen, so an ordinary ``spacr`` start should not pay for NumPy
before a matching animation is selected.
"""
global _NUMPY
if _NUMPY is None:
import numpy
_NUMPY = numpy
return _NUMPY
def _smoothstep(np, low, high, value):
"""Hermite ease from 0 at ``low`` to 1 at ``high``, clamped outside."""
t = np.clip((np.asarray(value, dtype=np.float32) - low) / (high - low), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
def _outside_outline(np, segments, x, y, distance, aspect):
"""Weight an outline's wave so it never meets itself inside the outline.
Waves from opposite edges of a box meet on its centre lines and draw a
shrinking box there. A closed or moved window, popup or panel therefore
sends its wave outward only: weight 1 outside the box its segments
enclose, 0 inside. When the box is the whole canvas (the backdrop's own
window was moved or resized) there is no outside, so the wave goes
inward and fades out halfway to the nearest centre line. A single edge
encloses nothing and keeps both sides.
:param segments: axis-aligned ``(x0, y0, x1, y1)`` edges, normalised.
:param x: normalised lattice x.
:param y: normalised lattice y.
:param distance: aspect-corrected distance to the nearest segment.
:param aspect: ``(aspect_x, aspect_y)`` canvas scale to the shorter edge.
:returns: a float32 array shaped like ``x``.
"""
xs = [value for part in segments for value in (part[0], part[2])]
ys = [value for part in segments for value in (part[1], part[3])]
left, right, top, bottom = min(xs), max(xs), min(ys), max(ys)
inside = (x > left) & (x < right) & (y > top) & (y < bottom)
if left <= 0.0 and top <= 0.0 and right >= 1.0 and bottom >= 1.0:
half = 0.5 * min((right - left) * aspect[0], (bottom - top) * aspect[1])
kept = 1.0 - _smoothstep(np, 0.25 * half, 0.5 * half, distance)
return np.where(inside, kept, 1.0).astype(np.float32)
return np.where(inside, 0.0, 1.0).astype(np.float32)
RIPPLE_AMPLITUDE = 0.0095
RIPPLE_WAVENUMBER = 30.0
RIPPLE_SPEED = 0.26
RIPPLE_PACKET_WIDTH = 0.10
RIPPLE_GLOW = 0.15
RIPPLE_FLOOR = 0.25
RIPPLE_SOURCE_CALM = 0.10
RIPPLE_EASE_IN_S = 0.30
RIPPLE_LIFETIME_S = 5.0
RIPPLE_COLUMN_SOFTNESS = 0.03
FOLD_RINGS = 3
FOLD_RING_GAP_S = 0.24
FOLD_SPEED = 0.40
FOLD_LIFETIME_S = 2.6
FOLD_PACKET_WIDTH = 0.10
FOLD_GATHER = 0.92
FOLD_GLOW = 0.12
[docs]
class AmbientEngine:
"""Base class: a deterministic, time-parameterised painter.
An engine owns no widget and no timer. It holds the constants rolled once
from its seed, a clock (:attr:`time`), and whatever reusable buffer it
paints through. Everything it draws is a pure function of ``(seed, time,
width, height, colours, background)`` — which is what lets a test render
the same frame twice and compare it byte for byte, and what lets
:meth:`AmbientWidget.set_theme` swap engines without the animation
jumping.
Positions are rolled in *normalised* 0..1 units and multiplied up at paint
time, so a resize re-frames the animation instead of re-rolling it.
Every numeric parameter below is CLAMPED to its range rather than
rejected: these arrive from saved preferences, and a value that has drifted
outside its range should slow the animation down, not refuse to draw it.
:param colors: the palette to paint from, as :class:`QColor` or as any
string :class:`QColor` accepts.
:param background: the colour behind the palette.
:param seed: the roll that fixes this engine's constants. The same seed
gives the same animation, which is what lets a test compare two
renders byte for byte. ``None`` rolls a new one.
:param blur: softness of the painted shapes, 0.0 to 3.0.
:param speed: how fast :attr:`time` advances the animation, 0.1 to 4.0.
:param size: scale of the painted shapes, 0.25 to 2.5.
:param resolution: scale of the buffer painted through, 0.25 to 2.0.
Below 1.0 paints fewer pixels and scales them up, which is the lever
that makes the backdrop affordable on a weak GPU.
:param density: how many shapes are rolled, 0.01 to 3.0.
:param direction: which way the animation drifts. An unrecognised name
falls back to the default rather than raising, for the same reason
the numbers are clamped.
"""
name = ""
def __init__(self, colors: Sequence[Union[QColor, str]],
background: Union[QColor, str],
seed: Optional[int] = None,
blur: float = DEFAULT_BLUR,
speed: float = DEFAULT_SPEED,
size: float = DEFAULT_SIZE,
resolution: float = DEFAULT_RESOLUTION,
density: float = DEFAULT_DENSITY,
direction: str = DEFAULT_DRIFT_DIRECTION):
"""Roll the constants this engine paints from.
:param colors: the palette to paint with.
:param background: the colour behind the shapes.
:param seed: what makes the animation reproducible.
:param blur: how soft the shapes are drawn.
:param speed: the animation rate multiplier.
:param size: the shape scale.
:param resolution: the render resolution.
:param density: how many shapes there are.
:param direction: which way the field drifts.
"""
self.seed = seed
self.time = 0.0
self.frames = 0
#: Most pixels this engine's buffer may hold. The screen's, once a
#: widget has told it; :data:`BUFFER_MAX_PIXELS` until then.
self.max_pixels = BUFFER_MAX_PIXELS
self.blur = _clamp(blur, *BLUR_RANGE)
self.speed = _clamp(speed, *SPEED_RANGE)
self.size = _clamp(size, *SIZE_RANGE)
self.resolution = _clamp(resolution, *RESOLUTION_RANGE)
self.density = _clamp(density, *DENSITY_RANGE)
self.blink_percent = DEFAULT_BLINK_PERCENT
self.popup_wave_frequency = 0.0
self._blink_seed = random.Random(seed).randrange(2 ** 32)
self.direction = direction if is_valid_drift_direction(direction) \
else DEFAULT_DRIFT_DIRECTION
self._colors = self._coerce_colors(colors)
self._background = _as_color(background, QColor("#000000"))
self._configure(random.Random(seed))
self._restyle()
def _configure(self, rng: random.Random) -> None:
"""Roll the per-element constants. Called exactly once."""
[docs]
def set_blink_percent(self, value: float) -> None:
"""Set the percentage of dot centres flashing white, without reseeding."""
value = float(value)
self.blink_percent = _clamp(
value if math.isfinite(value) else DEFAULT_BLINK_PERCENT,
*BLINK_PERCENT_RANGE)
def _blinking_indices(self, count: int):
"""Select dot identities with fractional counts and a seeded clock."""
np = _numpy()
tick = int(math.floor(self.time * 4.0))
key = (tick, int(count), self.blink_percent)
cached = getattr(self, "_blink_selection", None)
if cached is None or cached[0] != key:
rng = np.random.default_rng((self._blink_seed + tick) % (2 ** 32))
expected = count * self.blink_percent / 100.0
amount = int(expected) + int(rng.random() < expected % 1.0)
cached = (key, rng.choice(count, size=amount, replace=False))
self._blink_selection = cached
return cached[1]
def _restyle(self) -> None:
"""Re-derive everything that depends on the colours or background."""
self.dark = is_dark_background(self._background)
self.mode = (QPainter.CompositionMode_Plus if self.dark
else QPainter.CompositionMode_Multiply)
#: The colour that leaves the layer underneath untouched under
#: :attr:`mode` — 0 adds nothing, white multiplies to identity.
self.identity = QColor(0, 0, 0) if self.dark else QColor(255, 255, 255)
self.paint_colors = [self._tint(c) for c in self._colors]
self._random_palette = tuple(c.name() for c in self._colors) \
== PALETTE_SETS["random"].colors
self._spacr_palette = tuple(c.name() for c in self._colors) \
== tuple(color.lower() for color in PALETTE_SETS["spacr"].colors)
def _tint(self, color: QColor) -> QColor:
"""The colour as actually painted, given the background."""
return QColor(color) if self.dark \
else _mix(color, QColor(255, 255, 255), LIGHT_TINT)
@staticmethod
def _coerce_colors(colors: Sequence[Union[QColor, str]]) -> List[QColor]:
"""Turn whatever the palette gave us into QColors.
Accepts names, hex and RGB tuples because the palette is written by
hand and a colour spelled the wrong way should not stop the backdrop.
:param colors: the palette entries.
:returns: one QColor per entry.
"""
out = [QColor(c) for c in colors or ()]
out = [c for c in out if c.isValid()]
return out or [QColor("#808080")]
@property
[docs]
def colors(self) -> List[QColor]:
"""The palette this engine paints with.
COPIES, so a caller cannot recolour the engine by mutating what it
was handed.
:returns: one QColor per palette entry.
"""
return [QColor(c) for c in self._colors]
@property
[docs]
def background(self) -> QColor:
"""The colour behind the shapes.
A copy, for the same reason as :meth:`colors`.
:returns: the background colour.
"""
return QColor(self._background)
[docs]
def set_colors(self, colors: Sequence[Union[QColor, str]]) -> None:
"""Swap the palette without disturbing the motion."""
self._colors = self._coerce_colors(colors)
self._restyle()
[docs]
def set_background(self, color: Union[QColor, str]) -> None:
"""Tell the engine what it is painting onto — this is what decides
additive versus multiply, so it must be called on a theme switch."""
self._background = _as_color(color, self._background)
self._restyle()
[docs]
def set_blur(self, value: float) -> None:
"""How much the finished picture is softened. 0.0 is untouched.
Clamped to :data:`BLUR_RANGE`. Engines that cache anything sized by
it drop that cache here, never per frame.
"""
value = _clamp(value, *BLUR_RANGE)
if value == self.blur:
return
self.blur = value
self._reblur()
[docs]
def set_resolution(self, value: float) -> None:
"""How many pixels the scene is shaded into, as a multiplier on this
theme's own buffer edge. Clamped to :data:`RESOLUTION_RANGE`."""
value = _clamp(value, *RESOLUTION_RANGE)
if value == self.resolution:
return
self.resolution = value
self._reresolve()
[docs]
def set_density(self, value: float) -> None:
"""How many elements the theme draws, as a multiplier on its own
count. Clamped to :data:`DENSITY_RANGE`.
Never re-rolls anything: the pool was built for the top of the range
at construction and this only changes how much of it is painted, so
the elements that were on screen stay exactly where they were.
"""
value = _clamp(value, *DENSITY_RANGE)
if value == self.density:
return
self.density = value
self._redensify()
[docs]
def set_direction(self, name: str) -> None:
"""Which way the elements travel, for the themes that have a way.
Silently ignores an unknown name rather than raising: this reaches
every engine, and most of them have nothing to do with it.
"""
if is_valid_drift_direction(name) and name != self.direction:
self.direction = name
self._redirect()
@property
[docs]
def work(self) -> float:
"""What this engine is asking for, as a multiple of its own default.
Shading cost is buffer pixels times elements. Resolution is a linear
scale on the buffer's edge, so it enters squared; density is linear
in the elements. Overridden by the one engine that has no buffer.
"""
return self.resolution ** 2 * self.density
[docs]
def effective_density(self) -> float:
"""The requested population, independently of render resolution.
Buffered engines enforce the work budget on pixel sampling instead
of removing elements when the user increases Detail.
"""
return self.density
[docs]
def element_count(self, base: int, pool: int) -> int:
"""How many of a pool of ``pool`` elements to draw, when the theme's
own count is ``base``. At least one: a density slider that can empty
the screen is an off switch wearing a disguise."""
return _clamp_int(round(base * self.effective_density()), 1, pool)
[docs]
def alpha_scale(self) -> float:
"""What to multiply every element's peak alpha by, given the density.
Additive compositing means N overlapping shapes are N times the
light, so a density control with no compensation is a *brightness*
control wearing a misleading name. Measured, on a page at 0.076:
mean frame lightness went from 0.135 at density 1.0 to 0.288 at 3.0.
The backdrop would have become the loudest thing behind a settings
form — which the alphas in this module were set on a rendered frame
specifically to prevent (see :data:`AURORA_ALPHA_DARK`).
So above 1.0 the field's light is *divided among* more elements
rather than added to it. Below 1.0 nothing is done: quadrupling the
alpha of a quarter as many blobs clips to white rather than
compensating, and a sparser field being a quieter one is the right
answer anyway.
Density therefore changes the *texture* of the field — how many
shapes it is made of, and how strongly each one states itself — and
not how loud the field is. That is the only reading of the control
that leaves the backdrop legible at both ends of its range.
"""
return 1.0 / max(1.0, self.effective_density())
def _fractional_alpha_scale(self, base: int) -> float:
"""Represent a fractional population when a coarse scene keeps one shape."""
return self.alpha_scale() * min(1.0, base * self.effective_density())
[docs]
def set_max_pixels(self, pixels: int) -> None:
"""Set the display-pixel ceiling used to size the render buffer."""
pixels = max(BUFFER_MIN_EDGE ** 2, int(pixels))
if pixels == self.max_pixels:
return
self.max_pixels = pixels
self._reresolve()
[docs]
def set_speed(self, value: float) -> None:
"""Multiply every motion in the theme. Clamped to :data:`SPEED_RANGE`.
Deliberately a clock multiplier rather than a factor inside
:meth:`geometry`: a user dragging the slider in Preferences changes
how fast the animation goes *from here*, and never makes what is
already on screen jump to a different place.
"""
self.speed = _clamp(value, *SPEED_RANGE)
[docs]
def set_size(self, value: float) -> None:
"""Scale every element's size. Clamped to :data:`SIZE_RANGE`."""
value = _clamp(value, *SIZE_RANGE)
if value == self.size:
return
self.size = value
self._resize()
def _reblur(self) -> None:
"""Drop whatever the blur setting sized. Default: nothing to do."""
def _reresolve(self) -> None:
"""Drop whatever the resolution setting sized. Default: nothing."""
def _redensify(self) -> None:
"""Drop whatever the density setting sized. Default: nothing."""
def _redirect(self) -> None:
"""React to a direction change. Default: nothing."""
def _resize(self) -> None:
"""Drop whatever the size setting sized. Default: nothing to do."""
[docs]
def advance(self, dt: float) -> None:
"""Step the clock by ``dt`` seconds. Negative steps are ignored.
The step is scaled by :attr:`speed`, so the clock counts *animation*
seconds rather than wall-clock ones: every period, rate and travel
speed in every theme is expressed against this clock and therefore
scales with one multiplier.
:attr:`frames` counts every call, including the ignored ones, so a
test can prove a hidden widget stopped *asking* for frames rather
than only proving that its clock stood still.
"""
if dt > 0:
self.time += float(dt) * self.speed
self.frames += 1
[docs]
def set_time(self, seconds: float) -> None:
"""Jump the clock — used by the tests, and to carry the clock across
a theme change."""
self.time = float(seconds)
[docs]
def paint(self, painter: QPainter, width: int, height: int) -> None:
"""Draw this engine's current frame.
:param painter: the painter to draw with.
:param width: the widget's width in pixels.
:param height: its height in pixels.
"""
raise NotImplementedError
[docs]
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""What this engine would draw right now, in pixels.
Every engine answers this and every engine paints *from* it, so a
test can assert on the geometry and know it is asserting on the
frame. The tuple shape is per engine and documented there.
"""
raise NotImplementedError
class _BufferedEngine(AmbientEngine):
"""An engine that paints a soft field into a small reusable QImage.
The buffer is allocated on the first paint and then only when the widget
size changes — never per frame. The field is composited into it with the
same mode used to composite the buffer onto the page, which is what makes
the layer background-agnostic: addition and multiplication are both
associative, so ``(black + blobs) + page`` and ``(white * blobs) * page``
give exactly the result of drawing the blobs straight onto the page. That
is what lets the same code paint over the flat themes *and* over the Space
and Cell wallpapers without hiding them.
"""
#: Longest buffer edge this theme wants at resolution 1.0. Diffuse
#: themes keep :data:`BUFFER_MAX_EDGE`; a theme with an edge or a fine
#: repeat in it raises its own.
base_edge = BUFFER_MAX_EDGE
def __init__(self, *args, **kwargs):
"""Start with no buffer -- the first shade allocates one."""
self._buffer: Optional[QImage] = None
super().__init__(*args, **kwargs)
def _reresolve(self) -> None:
"""A new resolution means a new buffer size — drop the old one now
rather than leaving the next paint to notice."""
self._buffer = None
def resolution_edge(self) -> int:
"""Longest buffer edge under the current resolution setting."""
detail = min(self.resolution, math.sqrt(WORK_BUDGET / self.density))
return _clamp_int(int(self.base_edge * detail),
BUFFER_MIN_EDGE, BUFFER_EDGE_CEILING)
def buffer_scale(self, width: int, height: int) -> int:
"""Screen pixels per buffer pixel, for a ``width`` x ``height``
canvas. Always a whole number: a fractional one puts the upscale
lattice on a beat with itself instead of on the pixel grid.
The second loop is :attr:`AmbientEngine.max_pixels` — the screen's
own pixel count once a widget has supplied it, and
:data:`BUFFER_MAX_PIXELS` until then. That is the ceiling that
actually bounds the cost: the edge alone is a ratio, and a ratio
does not know how big the display is.
"""
width, height = max(1, int(width)), max(1, int(height))
scale = max(1, int(math.ceil(max(width, height)
/ self.resolution_edge())))
while scale < 64 and \
(width // scale) * (height // scale) > self.max_pixels:
scale += 1
return scale
def buffer_size(self, width: int, height: int) -> Tuple[int, int]:
"""Buffer dimensions for a ``width`` x ``height`` canvas."""
scale = self.buffer_scale(width, height)
return (max(1, int(width) // scale), max(1, int(height) // scale))
def blur_scale(self, width: int, height: int) -> float:
"""How far the shaded buffer is averaged down before it goes to the
canvas, in buffer pixels. 1.0 means "untouched", and the default
setting means exactly that.
:data:`BLUR_UNIT_PX` is in *screen* pixels, so this divides by the
upscale factor: the same blur setting asks for the same softness on
screen whatever resolution it is shaded at, which is the property
that makes the two controls independent. It never returns less than
1.0 — the upscale on its own already softens by ``scale`` pixels, and
a downscale below 1.0 would be a sharpen, which no amount of
arithmetic can deliver.
"""
if self.blur <= 0.0:
return 1.0
return max(1.0, BLUR_UNIT_PX * self.blur
/ self.buffer_scale(width, height))
def _ensure_buffer(self, width: int, height: int) -> QImage:
"""The reusable frame buffer, reallocated only when the size changes.
ONCE ON RESIZE AND NEVER PER FRAME. A buffer allocated each frame is a
full-size QImage of garbage per tick at the frame rate, which is the cost
this whole class exists to avoid.
"""
bw, bh = self.buffer_size(width, height)
buf = self._buffer
if buf is None or buf.width() != bw or buf.height() != bh:
buf = QImage(bw, bh, QImage.Format_RGB32)
self._buffer = buf
return buf
def paint(self, painter: QPainter, width: int, height: int) -> None:
"""Shade a frame and put it on the canvas, both here and now.
This calls :meth:`_shade` followed by :meth:`blit` directly. The
default buffered path reuses its image until the canvas size changes.
Aurora, point, growth and rain subclasses return a freshly owned
image each frame. Both paths can draw synchronously without an
additional publication copy.
"""
if width <= 0 or height <= 0:
return
self.blit(painter, self._shade(width, height), width, height)
def _shade(self, width: int, height: int) -> QImage:
"""The finished field, in the engine's *own* buffer.
The default implementation returns its reusable buffer when blur is
off, so that result is only valid until the next call. Aurora, point,
growth and rain overrides return freshly owned images. Callers that
keep a frame use :meth:`shade`, which handles either ownership path.
"""
buf = self._ensure_buffer(width, height)
inner = QPainter(buf)
try:
inner.fillRect(buf.rect(), self.identity)
inner.setCompositionMode(self.mode)
inner.setPen(Qt.NoPen)
self._paint_field(inner, buf.width(), buf.height())
finally:
inner.end()
return self._soften(buf, width, height)
def shade(self, width: int, height: int) -> Optional[QImage]:
"""One finished frame as an image the caller owns. **Any thread.**
This is the half of a frame that does not have to happen on the GUI
thread, and the half that a Python worker makes expensive: a
``blobs`` field costs 0.240 ms idle and 0.572 ms with one Python
thread running, ``cells`` 0.538 ms and **26.179 ms** — 48.7 times —
because it is Python and numpy under the interpreter lock. Splitting
it out is what lets :class:`_FrameProducer` pay that on a thread
nobody is looking at. See the module docstring for the whole table.
A ``QImage`` and a ``QPainter`` over it are legal off the GUI thread
(a ``QWidget`` is not, and nothing here touches one), and the result
is byte-identical to the same clock shaded on the GUI thread — which
``test_the_backdrop_survives_a_run.py`` asserts for every buffered
theme rather than trusting this paragraph.
Returns ``None`` for an empty canvas. A reusable buffer is copied
before publication so a later shade cannot change the caller's frame.
A freshly owned subclass image is returned directly. On the default
buffered path, the copy costs 0.003 ms
for a soft theme such as ``blobs``. Native Aurora returns an owned
image instead of paying for a second full-screen copy.
"""
if width <= 0 or height <= 0:
return None
image = self._shade(width, height)
return image.copy() if image is self._buffer else image
def blit(self, painter: QPainter, image: Optional[QImage],
width: int, height: int) -> None:
"""Put a frame from :meth:`shade` on the canvas. **GUI thread only.**
The fixed remainder of a frame, and the half that has to stay here:
it is Qt's C++ raster engine with the interpreter lock released, so
it is bounded at ~1.2 ms even while a Python worker is running (1.7x
its idle cost, against 48.7x for the shading it replaces).
A ``None`` or empty image draws nothing rather than raising, because
the one caller that can hand it one is a widget whose shading thread
has not published yet.
"""
if image is None or image.isNull() or width <= 0 or height <= 0:
return
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
painter.setCompositionMode(self.mode)
painter.drawImage(QRect(0, 0, int(width), int(height)), image)
painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
def _soften(self, buf: QImage, width: int, height: int) -> QImage:
"""The blur: one area-averaging pass over the finished buffer.
``QImage.scaled(..., SmoothTransformation)`` box-filters on the way
down — it is a real low-pass, not a resample — and the blit that was
already there carries the result back up. So the whole blur is *one*
extra read of the buffer and a small write, and the picture makes
exactly two trips through a filter rather than the three a
down-up-blit would take.
A separable box blur at the buffer size was written and measured
first, because it is the honest answer: NumPy, two cumulative sums
per axis, 12.8 ms a frame on a 640x360 buffer at 1920x1080. That is
nine times this whole module's budget, so it is not what ships. The
cost of what does ship is 0.01-0.11 ms, which on most themes is
inside the run-to-run spread; see the table in the module docstring.
Returns the buffer itself when there is nothing to do, which is the
default and costs nothing.
"""
factor = self.blur_scale(width, height)
if factor <= 1.0:
return buf
return buf.scaled(max(2, int(round(buf.width() / factor))),
max(2, int(round(buf.height() / factor))),
Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Shade one frame into ``painter``. Subclasses implement it."""
raise NotImplementedError
#: Measured shading cost per theme, ``(pixels, density, cpu ms, gpu ms)``,
#: from the workstation audit features/data/684_animation_gpu_audit_r5_2026-10-11
#: (RTX 3090 Ti, tools/audit_animation_performance.py --gpu, median of 24
#: frames). Regenerate from the newest audit receipt with
#: ``tools/audit_animation_performance.py --cost-table RECEIPT``; a test
#: holds this table to that receipt. Automatic follows the nearest row.
_GRAPHICS_COST = {
'data_art_impulse_lens': (
(2073600, 0.25, 6.057, 0.82),
(2073600, 1.0, 1.869, 0.975),
(8294400, 0.25, 7.225, 2.717),
(8294400, 1.0, 6.032, 9.186),
),
'data_art_genetic_advection': (
(2073600, 0.1, 0.685, 1.195),
(2073600, 1.0, 6.806, 8.203),
(8294400, 0.1, 1.4, 3.013),
(8294400, 1.0, 9.417, 12.686),
),
'data_art_point_atlas': (
(2073600, 0.1, 0.408, 0.806),
(2073600, 1.0, 2.236, 1.984),
(8294400, 0.1, 7.21, 2.616),
(8294400, 1.0, 12.684, 3.436),
),
'blobs': (
(2073600, 0.1, 0.044, 0.039),
(2073600, 1.0, 0.253, 0.052),
(8294400, 0.1, 0.028, 0.041),
(8294400, 1.0, 0.274, 0.051),
),
'drift': (
(2073600, 0.1, 0.059, 0.675),
(2073600, 1.0, 0.464, 0.905),
(8294400, 0.1, 0.183, 12.717),
(8294400, 1.0, 1.343, 13.246),
),
'data_art_spaceout_field': (
(2073600, 0.1, 0.276, 0.747),
(2073600, 1.0, 0.61, 0.952),
(8294400, 0.1, 0.842, 2.542),
(8294400, 1.0, 5.181, 3.601),
),
'data_art_fungal_growth': (
(2073600, 0.1, 0.334, 1.249),
(2073600, 1.0, 0.532, 1.488),
(8294400, 0.1, 0.696, 2.982),
(8294400, 1.0, 1.698, 3.158),
),
'aurora': (
(2073600, 0.1, 5.818, 0.882),
(2073600, 1.0, 16.202, 1.293),
(8294400, 0.1, 15.159, 2.631),
(8294400, 1.0, 41.852, 3.078),
),
'data_art_tissue_facets': (
(2073600, 0.1, 3.868, 0.771),
(2073600, 1.0, 10.57, 1.444),
(8294400, 0.1, 14.64, 2.435),
(8294400, 1.0, 26.518, 3.142),
),
}
#: Automatic takes the graphics path only when it measured at least this
#: much faster than the CPU, so a near tie keeps the GPU free for analysis.
_GRAPHICS_GAIN = 0.9
def _measured_graphics_win(theme, pixels, density):
"""Whether the nearest measured row says the GPU is faster, or ``None``.
Canvases under half the smallest measured size stay on the CPU: the
measurements do not reach down to small widgets.
"""
rows = _GRAPHICS_COST.get(theme)
if not rows or pixels <= 0 or density <= 0:
return None
if pixels < 0.5 * min(row[0] for row in rows):
return False
nearest = min(rows, key=lambda row: abs(math.log(pixels / row[0]))
+ abs(math.log(density / row[1])))
return nearest[3] < _GRAPHICS_GAIN * nearest[2]
class _GraphicsBackend:
"""Optional N679 graphics renderer for one theme, with CPU fallback.
``_graphics_backend`` holds the Performance > Animation GPU choice:
``"cpu"`` never touches a graphics driver, ``"gpu"`` asks for the
renderer on every frame it supports, and ``"auto"`` asks only where
:meth:`_graphics_automatic` says the graphics path is expected to beat
the CPU. Every refusal keeps the CPU picture: a frame shaded off the
producer thread, a canvas above 3840 x 2160, Dynamic animation holding
decorative GPU work, a failed isolated probe, another process computing
on the GPU, a constructor error or a draw error. The last three are
remembered for the engine's lifetime as ``_graphics_failed``.
The renderer is :class:`_FlowPointGraphics`, created, used and released
on the producer thread that owns its context.
"""
_graphics_backend = 'cpu'
_point_graphics = None
_graphics_failed = False
_graphics_idle_until = 0.0
_graphics_idle = False
_graphics_refusal = None
def _graphics_automatic(self, width, height, work=0) -> bool:
"""Whether Automatic picks the graphics path for this frame.
The measured cost table decides where the theme has been measured
(:data:`_GRAPHICS_COST`); otherwise :meth:`_graphics_unmeasured`.
"""
measured = _measured_graphics_win(getattr(self, "name", ""), width * height,
float(getattr(self, "density", 1.0)))
if measured is not None:
return measured
return self._graphics_unmeasured(width, height, work)
def _graphics_unmeasured(self, width, height, work=0) -> bool:
"""Automatic's choice before a theme has GPU measurements: CPU.
The CPU frame is already cheap for most themes, and a full-frame
readback is not free.
"""
return False
def _release_point_graphics(self):
"""Release this producer's renderer without crossing thread ownership."""
renderer = self._point_graphics
if renderer is not None and renderer._owner == threading.get_ident():
renderer._close()
self._point_graphics = None
def _refuse(self, reason):
"""Record why this frame stays on the CPU; log each new reason once.
The warning names the renderer too: ``<theme> animation stays on the
CPU: <reason>``.
"""
if reason != self._graphics_refusal:
self._graphics_refusal = reason
key = (getattr(self, "name", type(self).__name__), reason)
if key not in _GRAPHICS_REFUSALS_LOGGED:
_GRAPHICS_REFUSALS_LOGGED.add(key)
LOG.warning('%s animation stays on the CPU: %s', *key)
return None
def _graphics_renderer(self, width, height, work=0):
"""Return the producer's renderer for this frame, or ``None`` for CPU.
Every ``None`` leaves its reason in ``_graphics_refusal``.
"""
mode = self._graphics_backend
if mode not in ('auto', 'gpu'):
self._release_point_graphics()
self._graphics_refusal = None
_log_renderer(self, 'CPU (Animation GPU is off)')
return None
if threading.current_thread().name != 'spacr-ambient-shade':
return None
if self._graphics_failed:
return None
if width * height > 3840 * 2160 or sys.byteorder != 'little':
self._release_point_graphics()
return self._refuse('canvas larger than 3840x2160 or big-endian host')
if mode == 'auto' and not self._graphics_automatic(width, height, work):
self._release_point_graphics()
self._graphics_refusal = 'automatic chose the CPU'
_log_renderer(self, 'CPU (Automatic chose the CPU)')
return None
if _decorative_gpu_blocked():
self._release_point_graphics()
return self._refuse('Dynamic animation holds the GPU for analysis')
if self._point_graphics is None and not _flow_graphics_preflight():
self._graphics_failed = True
return self._refuse('no hardware OpenGL 4.3 context (probe failed)')
now = time.monotonic()
if now >= self._graphics_idle_until:
self._graphics_idle = (not _dynamic_animation_on()
or _flow_compute_idle())
self._graphics_idle_until = now + 2.0
if not self._graphics_idle:
self._release_point_graphics()
return self._refuse('another process is computing on the GPU')
if self._point_graphics is None:
try:
self._point_graphics = _FlowPointGraphics()
_log_renderer(self, 'GPU (moderngl over %s, %s)' % (
_graphics_context_options().get('backend', 'the platform context'),
getattr(self._point_graphics, 'renderer', 'unknown renderer')))
except Exception as error:
LOG.debug('flow graphics unavailable; retaining CPU material',
exc_info=True)
self._graphics_failed = True
return self._refuse(f'context creation failed: '
f'{type(error).__name__}: {error}')
self._graphics_refusal = None
return self._point_graphics
def _graphics_frame(self, method, width, height, *args, work=0, build=None):
"""Draw one frame with renderer ``method``, or return ``None`` for CPU.
``build``, when given, returns the arguments; it runs only once a
renderer has accepted the frame, so the CPU path pays nothing for
graphics-only preparation.
"""
renderer = self._graphics_renderer(width, height, work)
if renderer is None:
return None
try:
if build is not None:
args = tuple(build())
return getattr(renderer, method)(width, height, *args)
except Exception as error:
LOG.debug('flow graphics unavailable; retaining CPU material', exc_info=True)
self._release_point_graphics()
self._graphics_failed = True
import traceback
where = traceback.extract_tb(error.__traceback__)[-1]
return self._refuse(f'{method} failed in {where.name} line {where.lineno}: '
f'{type(error).__name__}: {str(error)[:600]}')
#: ``(theme, reason)`` pairs already logged, so each is reported once.
_GRAPHICS_REFUSALS_LOGGED: set = set()
#: ``(theme, renderer)`` pairs already logged by :func:`_log_renderer`.
_RENDERERS_LOGGED: set = set()
def _log_renderer(engine, renderer: str) -> None:
"""Write which renderer ``engine``'s animation uses to ``spacr.log``, once.
:param engine: the engine whose theme is named in the line.
:param renderer: ``"GPU (...)"`` or ``"CPU (...)"`` with the reason.
"""
key = (getattr(engine, "name", type(engine).__name__), renderer)
if key not in _RENDERERS_LOGGED:
_RENDERERS_LOGGED.add(key)
LOG.info("%s animation renderer: %s", *key)
def _flow_graphics_possible() -> bool:
"""Cheap check whether a graphics renderer could exist in this process.
False once the isolated probe has failed or when moderngl is missing.
Used where the CPU path would otherwise change shape (the stratified
theme only gets a shading thread for its graphics path).
"""
if _FLOW_GRAPHICS_PROBE is False or os.environ.get("SPACR_NO_GL"):
return False
import importlib.util
return importlib.util.find_spec("moderngl") is not None
#: How many blobs. Cheap enough to raise (the shading happens over 37 000
#: buffer pixels), but past about twenty the fields merge into a single wash
#: and the individual motion stops being readable.
BLOB_COUNT = 14
#: Every third blob is a small one, so the field has a sense of scale.
BLOB_SMALL_EVERY = 3
BLOB_LARGE_RADIUS = (0.22, 0.46)
BLOB_SMALL_RADIUS = (0.07, 0.16)
#: Drift, as a fraction of the canvas, and the period of that drift. Long
#: periods are the point: this must never look like it is *moving*, only like
#: it has moved when you look back at it.
BLOB_DRIFT = (0.04, 0.12)
BLOB_DRIFT_PERIOD = (24.0, 70.0)
#: The pulse — "changing size" — as a fraction of the base radius.
BLOB_PULSE = (0.12, 0.35)
BLOB_PULSE_PERIOD = (7.0, 19.0)
#: Peak alpha at a blob's centre. Light needs more than dark because the
#: colour has been mixed 55 % toward white before it is multiplied — but not
#: as much more as the arithmetic suggests, because multiply keeps its
#: contrast where additive runs out of headroom.
BLOB_ALPHA_DARK = 0.30
BLOB_ALPHA_LIGHT = 0.46
#: The falloff, as ``(stop, alpha multiplier)``. Roughly Gaussian; the point
#: is that it reaches zero *before* the edge of the ellipse, so no blob ever
#: shows a rim.
BLOB_FALLOFF = ((0.0, 1.0), (0.35, 0.60), (0.70, 0.18), (1.0, 0.0))
@dataclass
[docs]
class Blob:
"""One drifting, pulsing blob, in normalised units."""
x: float
y: float
drift_x: float
drift_y: float
rate_x: float
rate_y: float
phase_x: float
phase_y: float
radius: float
pulse: float
pulse_rate: float
pulse_phase: float
color: int
[docs]
class BlobsEngine(_GraphicsBackend, _BufferedEngine):
"""Diffuse colour blobs, drifting and pulsing.
Motion is two independent sines per blob rather than a random walk, so
position is a pure function of the clock: no accumulated error, and
``set_time`` can jump anywhere.
:meth:`geometry` yields ``(cx, cy, radius)`` per blob, in pixels.
"""
name = "blobs"
def _configure(self, rng: random.Random) -> None:
"""Roll this theme's constants from the seed.
ONCE, at construction: an engine is deterministic, so the same seed and
the same call sequence always produce the same animation.
"""
cols, rows = 5, 3
cells = list(range(cols * rows))
rng.shuffle(cells)
self.blobs: List[Blob] = []
for i in range(_pool_size(BLOB_COUNT)):
cell = cells[i % len(cells)]
col, row = cell % cols, cell // cols
small = (i % BLOB_SMALL_EVERY) == 0
lo, hi = BLOB_SMALL_RADIUS if small else BLOB_LARGE_RADIUS
self.blobs.append(Blob(
x=(col + 0.15 + 0.7 * rng.random()) / cols,
y=(row + 0.15 + 0.7 * rng.random()) / rows,
drift_x=rng.uniform(*BLOB_DRIFT),
drift_y=rng.uniform(*BLOB_DRIFT),
rate_x=2 * math.pi / rng.uniform(*BLOB_DRIFT_PERIOD),
rate_y=2 * math.pi / rng.uniform(*BLOB_DRIFT_PERIOD),
phase_x=rng.uniform(0.0, 2 * math.pi),
phase_y=rng.uniform(0.0, 2 * math.pi),
radius=rng.uniform(lo, hi),
pulse=rng.uniform(*BLOB_PULSE),
pulse_rate=2 * math.pi / rng.uniform(*BLOB_PULSE_PERIOD),
pulse_phase=rng.uniform(0.0, 2 * math.pi),
color=i,
))
[docs]
def count(self) -> int:
"""How many blobs are painted right now."""
return self.element_count(BLOB_COUNT, len(self.blobs))
[docs]
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""The shapes to draw at the current time, for a widget this size.
GEOMETRY, NOT PAINTING, so the layout can be computed on a worker
thread and tested without a QPainter -- an engine owns no widget and
no timer, which is what makes the animation deterministic.
:param width: the widget's width in pixels.
:param height: its height in pixels.
:returns: one tuple per shape, in draw order.
"""
t = self.time
short = min(width, height)
out = []
for blob in self.blobs[:self.count()]:
cx = (blob.x + blob.drift_x
* math.sin(blob.rate_x * t + blob.phase_x)) * width
cy = (blob.y + blob.drift_y
* math.sin(blob.rate_y * t + blob.phase_y)) * height
radius = blob.radius * short * self.size * (
1.0 + blob.pulse
* math.sin(blob.pulse_rate * t + blob.pulse_phase))
out.append((cx, cy, max(1.0, radius)))
return tuple(out)
def _shade(self, width: int, height: int) -> QImage:
"""Shade on the optional graphics renderer, else on the CPU buffer."""
bw, bh = self.buffer_size(width, height)
image = self._graphics_frame(
'_draw_blobs', bw, bh,
build=lambda: (self._blob_rows(bw, bh), BLOB_FALLOFF, self.dark))
if image is None:
return super()._shade(width, height)
return self._soften(image, width, height)
def _blob_rows(self, width: int, height: int) -> list:
"""``cx, cy, radius, alpha, r, g, b`` per blob, as the CPU paints them."""
peak = (BLOB_ALPHA_DARK if self.dark else BLOB_ALPHA_LIGHT) \
* self._fractional_alpha_scale(BLOB_COUNT)
colors = self.paint_colors
rows = []
for blob, (cx, cy, radius) in zip(self.blobs,
self.geometry(width, height)):
color = colors[blob.color % len(colors)]
rows.append((cx, cy, radius, max(0.0, min(1.0, peak)),
color.redF(), color.greenF(), color.blueF()))
return rows
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Draw one frame's field of shapes.
:param painter: the painter to draw with.
:param width: the widget's width in pixels.
:param height: its height in pixels.
"""
peak = (BLOB_ALPHA_DARK if self.dark else BLOB_ALPHA_LIGHT) \
* self._fractional_alpha_scale(BLOB_COUNT)
colors = self.paint_colors
for blob, (cx, cy, radius) in zip(self.blobs,
self.geometry(width, height)):
color = colors[blob.color % len(colors)]
gradient = QRadialGradient(cx, cy, radius)
for stop, scale in BLOB_FALLOFF:
gradient.setColorAt(stop, _with_alpha(color, peak * scale))
painter.setBrush(gradient)
painter.drawEllipse(QPointF(cx, cy), radius, radius)
#: Three curtains at different depths. Two read as one curtain and a copy;
#: four stop being separable at these alphas.
AURORA_CURTAINS = 3
AURORA_BUFFER_EDGE = 960
#: Rows of the per-curtain ray texture, from its top (row 0) to its base.
_AURORA_TEXTURE_ROWS = 192
#: Ray length — how far up the sheet is lit — as a fraction of the canvas
#: height, scaled by the size setting. Comfortably deeper than the fold
#: reaches, or a fold crest would lift the sheet's lower edge past the green
#: and out of the top of its own colour ramp.
AURORA_THICKNESS = (0.32, 0.57)
#: Where each curtain's lower edge rests, as a fraction of the canvas height,
#: and the jitter around it. Spread down the frame so the three overlap in
#: depth rather than sitting on top of one another.
AURORA_BASE = (0.67, 0.48, 0.84)
AURORA_BASE_JITTER = 0.05
#: How far down the extra curtains a raised density asks for are pushed,
#: per tier of three. Slightly under half the spacing between the three
#: shipped bases, so a denser aurora interleaves with itself instead of
#: doubling up on the same three altitudes.
AURORA_TIER_OFFSET = 0.055
#: The arc's slope across the frame, as a fraction of the canvas height. An
#: arc that is exactly level reads as a horizon line. Small, because the
#: colour ramp is anchored to altitude: a steeply tilted arc would have one
#: end of it sitting in a different colour from the other.
AURORA_TILT = 0.06
#: How much wider than the canvas the arc is drawn. The folds have to enter
#: and leave the frame rather than terminating at its edges.
AURORA_OVERHANG = 1.12
#: The slow bob of the whole arc's altitude, and its period. This is the only
#: bulk motion the curtain has, and it is vertical: the folds do the rest.
AURORA_DRIFT = (0.05, 0.18)
AURORA_DRIFT_PERIOD = (30.0, 90.0)
AURORA_HUE_PERIOD = (18.0, 46.0)
#: The fold, as three superposed travelling waves: ``(amplitude as a fraction
#: of the canvas height, wavelength as a fraction of the arc's length, travel
#: speed in arc-lengths per second)``. Long slow fold, medium ripple, fine
#: ripple — the ratio between them is what stops it reading as a single sine,
#: and the speeds differ so the pattern never repeats itself.
AURORA_FOLDS = (
(0.080, 0.85, 0.012),
(0.043, 0.33, 0.027),
(0.018, 0.17, 0.043),
)
#: How far the fold can reach either way, which is what the colour ramp has
#: to be anchored below.
AURORA_FOLD_REACH = sum(amp for amp, _wl, _v in AURORA_FOLDS)
#: The brightness surge running along the arc — faster than any fold and on
#: its own wavelength. ``(depth, wavelength, speed)``.
AURORA_PULSE = (0.62, 0.34, 0.075)
#: How far up the curtain a surge reaches, as a share of the ray length, and
#: how strong it is against the curtain's own peak alpha. Surges brighten the
#: base of the sheet; the diffuse top does not pulse.
#:
#: The surge is painted over its own shorter path rather than over the whole
#: sheet. Above :data:`AURORA_PULSE_HEIGHT` its texture is transparent, and a
#: transparent source pixel still costs a read and a write of the destination
#: — 45 % of the curtain's area, for nothing. That one change took the pass
#: from 0.49 ms to 0.20.
AURORA_PULSE_HEIGHT = 0.55
AURORA_PULSE_GAIN = 0.85
#: Resolution of the per-frame surge image. It is stretched over the curtain
#: with bilinear filtering, so it only has to resolve the pulse: 16 samples
#: across an arc holding three wavelengths is five per wavelength, and the
#: gradient's linear interpolation between them is under 5 % off a sine. It
#: started at 40x16 and that cost 0.27 ms a frame in ``setColorAt`` calls
#: alone — three quarters of it thrown away by the bilinear filter.
AURORA_PULSE_TEXTURE = (16, 16)
#: How far past the curtain, as a share of the ray length, that image is
#: stretched. A texture brush *wraps*, and a bilinear sample taken on the
#: image's first row blends it with its last one — which drew a bright
#: hairline straight across the top of every curtain until this padding put
#: both rows outside the sheet, where nothing can sample them.
AURORA_PULSE_PAD = 0.1
#: How finely the surge image is cached. Its content is a pure function of
#: the pulse's phase, so it does not have to be rebuilt every frame — and
#: rebuilding it was 0.15 ms of a frame, nearly all of it spent constructing
#: gradient stops. 64 steps is one every 0.07 s at the shipped pulse speed,
#: which moves the pattern half a percent of the arc at a time.
AURORA_PULSE_STEPS = 64
AURORA_PULSE_CACHE = 256
#: Per curtain: ``(rate multiplier, ray-spacing multiplier, alpha
#: multiplier)``. Different rates are what stop the three from reading as one
#: thick curtain; the further ones have finer rays and less of them.
AURORA_DEPTHS = (
(1.00, 1.00, 1.00),
(0.62, 0.74, 0.72),
(1.45, 1.36, 0.55),
)
#: Spacing between ray centres as a fraction of the canvas width, scaled by
#: the size setting. Expressed against the *canvas*, not the buffer, so the
#: blur setting changes how soft the rays are and not how many there are.
AURORA_RAY_SPACING = 0.019
AURORA_RAY_MIN_PX = 1.5
#: Samples along the arc. 40 resolves the 0.17 fold (Nyquist wants 12) with
#: room to spare, and every one of them is Python arithmetic on every frame.
AURORA_COLUMNS = 40
#: The tile: three rays of different widths, so the comb repeats every third
#: ray instead of every ray and never reads as a picket fence. The rays sit
#: on a floor rather than on nothing, because the sheet between them still
#: glows — rays are a modulation of a curtain, not a row of separate bars.
#: ``(centre, half width, intensity)``, all as fractions of the tile.
#: Narrower half-widths and a lower floor create a sharper edge. Both are
#: needed: narrowing alone leaves thin rays sitting on a bright sheet, which
#: reads as a lighter curtain rather than as a defined ray.
AURORA_TILE_RAYS = ((0.17, 0.075, 1.00), (0.49, 0.055, 0.86),
(0.80, 0.065, 0.94))
AURORA_TILE_FLOOR = 0.34
#: How long each ray in the tile is, as a fraction of the full ray length,
#: and how fast it breathes. One entry per entry in AURORA_TILE_RAYS.
#:
#: Periods are deliberately not multiples of one another, or the three
#: would return to the same arrangement on a short cycle and the eye would
#: find it. 11, 17 and 7 seconds beat against each other for 21 minutes.
#:
#: Never reaching 1.0 for the longest, nor 0 for the shortest: a ray that
#: touches the full height reads as the curtain itself rather than as a ray
#: in it, and one that vanishes leaves a gap that looks like a rendering
#: fault rather than like weather.
AURORA_RAY_LIFE = ((11.0, 0.00), (17.0, 0.37), (7.0, 0.71))
AURORA_RAY_LENGTH = (0.55, 0.98)
#: Quantisation of the breathing, for the same reason the shimmer is
#: quantised: the tile is a cached texture, and a length that follows the
#: clock exactly would rebuild all three tiles every frame. Eight steps
#: across the range is about 5% of the ray length per step, which is below
#: what the eye resolves on a slow fade at this size.
AURORA_LENGTH_STEPS = 8
#: How much of a ray's tip is taper, as a fraction of the FULL ray length.
#: A square cut gives a shortened ray a flat top, which reads as a broken
#: ray and measurably sharpens the curtain's upper edge -- the asymmetry
#: between the hard lower edge and the diffuse top is as recognisable as
#: the colour, and there is a test on it.
AURORA_RAY_FEATHER = 0.22
#: Where in the tile the colour ramp sits, as fractions of its height. What
#: is left over at each end is a transparent guard band. A tiled brush
#: *repeats*, so the instant the sheet reached past the ramp it would wrap
#: round and paint the violet fringe along the top of the curtain. The guards
#: make that impossible rather than unlikely.
#:
#: There is no tile *size* here on purpose. The tile is built at exactly the
#: pixel size it will be painted at — one tile per ray period across, one ray
#: length plus its guards down — so the brush needs a translation and nothing
#: else. Measured, per curtain fill at 1920x1080: a brush carrying a scale
#: costs 0.106 ms, a pre-scaled one carrying only a translation costs 0.061,
#: which is what a flat colour costs. Qt's raster engine has a fast tiled
#: blit for ``TxTranslate`` brushes and a per-pixel inverse transform for
#: everything else, and this is the whole difference between them.
AURORA_TILE_RAMP = (0.10, 0.90)
#: Smallest tile, in pixels. Below about this the ray comb is finer than the
#: buffer can hold and turns into noise.
AURORA_TILE_MIN_PX = 3
#: How many distinct tiles to keep. Three curtains times twelve shimmer steps
#: is 36, and nine curtains — the top of the density range — times twelve is
#: 108, which is why this is not the 96 it started at: a cache one short of
#: the working set is a cache that is cleared every frame. The rest of the
#: headroom is for a window being resized, which changes the pixel size the
#: tiles are built at.
#: Raised for the breathing: the working set is now curtains x shimmer
#: steps x length steps, and a cache one short of the working set is a
#: cache that is cleared every frame -- which is the mistake this number
#: already carries a comment about.
#: 9 curtains x 12 shimmer steps x 8 length steps = 864, plus headroom for
#: a window being resized. 768 was the first guess and it was 96 SHORT of
#: the densest working set -- exactly the mistake the paragraph above
#: describes, made again while adding a dimension to it.
AURORA_TILE_CACHE = 1024
#: The vertical structure, lower edge upward: ``(height fraction, palette
#: role, alpha)``. Full strength immediately at the bottom — the sheet's lower
#: edge is a hard cut, and it is made by the polygon, not by the ramp. Above
#: the middle it fades out over half the ray length, which is the diffuse top.
#: The asymmetry between those two edges is as recognisable as the colour.
AURORA_RAMP = (
(0.00, "fringe", 0.66),
(0.05, "fringe", 0.92),
(0.11, "main", 1.00),
(0.42, "main", 0.74),
(0.62, "blend", 0.48),
(0.82, "high", 0.27),
(1.00, "high", 0.00),
)
#: How far the curtain's body colour is allowed to wander towards another
#: entry in the palette. Small on purpose: the body of an aurora is one
#: emission line and stays that colour — it shimmers, it does not turn red.
AURORA_HUE_BLEND = 0.28
#: Quantisation of that shimmer, so the ray tile is built a few dozen times
#: in the life of the widget instead of three times a frame.
AURORA_HUE_STEPS = 12
#: Higher than the old flat bands needed, because the ray comb, the ramp and
#: the depth multiplier each take a bite out of it before anything reaches
#: the page. Set on the mean lightness of a rendered frame rather than by
#: eye, because "does it look too strong" is exactly the judgement that goes
#: wrong on somebody else's monitor: 0.168 here, against 0.161 for blobs and
#: 0.151 for ripple on a page at 0.078. This paints behind a settings form
#: and is not allowed to be the loudest thing on it.
AURORA_ALPHA_DARK = 0.35
AURORA_ALPHA_LIGHT = 0.52
@dataclass
[docs]
class Curtain:
"""One aurora curtain, in normalised units."""
y: float
height: float
tilt: float
drift: float
rate: float
phase: float
hue_rate: float
hue_phase: float
fold_phase: Tuple[float, ...]
pulse_phase: float
depth: int
color: int
[docs]
class AuroraEngine(_GraphicsBackend, _BufferedEngine):
"""Folded curtains of vertical rays, rippling along their own length.
Rays rise from an irregular folded lower edge, fan gently toward the
sky, and breathe at different rates. A diffuse sheet joins the rays
without a flat rectangular top or repeated texture tiles. The frame
raster is native at ordinary Detail, within the
physical screen-pixel budget, and is returned with independent ownership.
:data:`AURORA_BUFFER_EDGE` remains the legacy comparison edge; it no
longer caps the active buffer.
:meth:`geometry` yields ``(x, y_bottom, visible_height, brightness)`` per
sampled column of every curtain, in pixels, ``AURORA_COLUMNS + 1`` of them
per curtain in curtain order. The painter builds its paths from exactly
those numbers, so a test that tracks a fold crest through ``geometry`` is
tracking the crest that is on screen. ``brightness`` is the travelling
surge. Seeded irregular ray positions and independent continuous length
and brightness cycles modulate the sampled sheet, with tapered tops.
"""
name = "aurora"
base_edge = AURORA_BUFFER_EDGE
def __init__(self, *args, **kwargs):
"""Roll the aurora's bands and their drift."""
self._tiles: Dict[Tuple[int, int], QImage] = {}
self._surges: Dict[int, QImage] = {}
self._pulse_mask: Optional[QImage] = None
self._ray_material = {}
super().__init__(*args, **kwargs)
[docs]
def buffer_size(self, width: int, height: int) -> Tuple[int, int]:
"""Sample native display pixels within the actual screen budget."""
detail = min(1.0, self.resolution)
bw, bh = max(1, int(width * detail)), max(1, int(height * detail))
scale = min(1.0, math.sqrt(self.max_pixels / (bw * bh)))
return max(1, int(bw * scale)), max(1, int(bh * scale))
[docs]
def buffer_scale(self, width: int, height: int) -> float:
"""Report the aurora sampling ratio for explicit detail controls."""
bw, bh = self.buffer_size(width, height)
return max(1.0, width / bw, height / bh)
def _shade(self, width: int, height: int) -> QImage:
"""Clear the owned raster before painting the current curtains.
The optional graphics renderer generates the same ray textures and
warps them along the same strips; any refusal paints on the CPU.
"""
bw, bh = self.buffer_size(width, height)
image = self._graphics_frame(
'_draw_aurora', bw, bh,
build=lambda: ([self._aurora_gpu_layer(layer)
for layer in self._aurora_layers(bw, bh)], self.dark))
if image is not None:
return self._soften(image, width, height)
buf = QImage(bw, bh, QImage.Format_RGB32)
buf.fill(self.identity)
inner = QPainter(buf)
try:
inner.setCompositionMode(self.mode)
inner.setPen(Qt.NoPen)
self._paint_field(inner, buf.width(), buf.height())
finally:
inner.end()
return self._soften(buf, width, height)
def _graphics_unmeasured(self, width, height, work=0) -> bool:
"""Until aurora's GPU cost is measured, Automatic takes 4K frames.
The 2026-10-10 audits measured 20-99 ms per 4K CPU frame, over the
frame budget; smaller frames stay on the CPU.
"""
return width * height >= 3840 * 2160
def _configure(self, rng: random.Random) -> None:
"""Roll this theme's constants from the seed.
ONCE, at construction: an engine is deterministic, so the same seed and
the same call sequence always produce the same animation.
"""
self.curtains: List[Curtain] = []
self._aurora_seed = rng.randrange(2 ** 32)
for i in range(_pool_size(AURORA_CURTAINS)):
base = (AURORA_BASE[i % len(AURORA_BASE)]
+ (i // len(AURORA_BASE)) * AURORA_TIER_OFFSET)
self.curtains.append(Curtain(
y=base + rng.uniform(-AURORA_BASE_JITTER, AURORA_BASE_JITTER),
height=rng.uniform(*AURORA_THICKNESS),
tilt=rng.uniform(-AURORA_TILT, AURORA_TILT),
drift=rng.uniform(*AURORA_DRIFT),
rate=2 * math.pi / rng.uniform(*AURORA_DRIFT_PERIOD),
phase=rng.uniform(0.0, 2 * math.pi),
hue_rate=2 * math.pi / rng.uniform(*AURORA_HUE_PERIOD),
hue_phase=rng.uniform(0.0, 2 * math.pi),
fold_phase=tuple(rng.uniform(0.0, 2 * math.pi)
for _ in AURORA_FOLDS),
pulse_phase=rng.uniform(0.0, 2 * math.pi),
depth=i,
color=i,
))
def _restyle(self) -> None:
"""Re-roll the colours after a palette change."""
super()._restyle()
self._tiles = {}
self._surges = {}
def _resize(self) -> None:
"""Re-lay the bands for a new widget size."""
self._tiles = {}
self._surges = {}
[docs]
def count(self) -> int:
"""How many curtains are painted right now."""
return self.element_count(AURORA_CURTAINS, len(self.curtains))
def _rate(self, curtain: Curtain) -> float:
"""How fast the bands drift, given the current speed setting.
:returns: the drift rate.
"""
return AURORA_DEPTHS[curtain.depth % len(AURORA_DEPTHS)][0]
[docs]
def fold(self, curtain: Curtain, u: float, t: float) -> float:
"""Fold displacement at position ``u`` along the arc, as a fraction
of the canvas height.
``u`` runs 0..1 from one end of the arc to the other. Each component
is ``sin(2*pi*(u - v*t)/lambda)``: at a fixed time it is a shape in
``u``, and as ``t`` advances that shape *slides along u* at ``v``
while the arc itself goes nowhere. That is the whole difference
between an aurora and a curtain being dragged sideways, and it is the
one property of this engine worth testing directly.
Scaled by the size setting along with everything else: a curtain half
the height with folds the same depth is a different phenomenon, not a
smaller one.
"""
rate = self._rate(curtain)
total = 0.0
for (amp, wavelength, speed), phase in zip(AURORA_FOLDS,
curtain.fold_phase):
total += amp * math.sin(
2 * math.pi * (u - speed * rate * t) / wavelength + phase)
return total * self.size
[docs]
def pulse(self, curtain: Curtain, u: float, t: float) -> float:
"""The surge's brightness at ``u``, in 0..1. Another travelling wave,
deliberately faster and shorter than every fold."""
depth, wavelength, speed = AURORA_PULSE
travelling = 0.5 + 0.5 * math.sin(
2 * math.pi * (u - speed * self._rate(curtain) * t) / wavelength
+ curtain.pulse_phase)
return 1.0 - depth + depth * travelling
[docs]
def anchor(self, curtain: Curtain, height: int) -> Tuple[float, float]:
"""``(ramp zero, ray length)`` for one curtain, in pixels.
The ramp's zero is the altitude the emission stops at, so it sits
below everything the fold and the tilt can do — that is what keeps
every column of the sheet inside its own colour ramp.
"""
base = curtain.y + curtain.drift * math.sin(
curtain.rate * self.time + curtain.phase)
reach = (AURORA_FOLD_REACH + abs(curtain.tilt) * 0.5) * self.size
return ((base + reach) * height,
max(1.0, curtain.height * self.size * height))
[docs]
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""Every travelling wave, evaluated along every arc.
The loop body is :meth:`fold` and :meth:`pulse` written out with
their constant parts hoisted — ``sin(2*pi*(u - v*t)/lambda + phi)``
is ``sin(k*u + (phi - k*v*t))``, and ``k`` and the bracket do not
depend on the column. It is the same arithmetic; it is here rather
than behind those two calls because this runs a hundred and twenty
times a frame and a Python call is not free. ``test_aurora_geometry_
is_the_model_it_documents`` holds the two forms together.
"""
t = self.time
out = []
sin = math.sin
two_pi = 2 * math.pi
span = width * AURORA_OVERHANG
left = (width - span) * 0.5
columns = AURORA_COLUMNS
p_depth, p_wavelength, p_speed = AURORA_PULSE
for curtain in self.curtains[:self.count()]:
rate = self._rate(curtain)
depth_alpha = AURORA_DEPTHS[
curtain.depth % len(AURORA_DEPTHS)][2]
zero, ray = self.anchor(curtain, height)
base = curtain.y + curtain.drift * math.sin(
curtain.rate * t + curtain.phase)
top = zero - ray
tilt = curtain.tilt * self.size
folds = [(amp * self.size, two_pi / wavelength,
phase - two_pi * speed * rate * t / wavelength)
for (amp, wavelength, speed), phase
in zip(AURORA_FOLDS, curtain.fold_phase)]
p_k = two_pi / p_wavelength
p_phase = (curtain.pulse_phase
- two_pi * p_speed * rate * t / p_wavelength)
for i in range(columns + 1):
u = i / columns
displacement = base + tilt * (u - 0.5)
for amp, k, phase in folds:
displacement += amp * sin(k * u + phase)
y = displacement * height
bright = depth_alpha * (
1.0 - p_depth + p_depth
* (0.5 + 0.5 * sin(p_k * u + p_phase)))
out.append((left + u * span, y,
y - top if y > top else 0.0, bright))
return tuple(out)
[docs]
def hue_phase(self, curtain: Curtain) -> float:
"""Where this curtain's slow colour shimmer stands, in 0..1."""
return 0.5 + 0.5 * math.sin(
curtain.hue_rate * self.time + curtain.hue_phase)
[docs]
def curtain_color(self, curtain: Curtain, quantised: bool = False
) -> QColor:
"""The curtain's body colour right now.
Always built from the palette's *first* colour, wandering up to
:data:`AURORA_HUE_BLEND` of the way towards one of the others and
back. Every curtain shares that body colour on purpose: the body of
an aurora is a single emission line — 557.7 nm oxygen — and the
palette's remaining entries are the top and the fringe, which the
ramp puts above and below it. Giving curtain two a red body and
curtain three a violet one, which is what indexing the palette by
curtain would do, is the one thing that stops the whole theme reading
as an aurora.
:param curtain: simulated curtain whose colour index and shimmer phase
choose the palette target and its current blend toward it.
:param quantised: snap the shimmer to :data:`AURORA_HUE_STEPS` so the
ray tile can be cached.
"""
colors = self.paint_colors
body = colors[0]
wander = colors[1 + curtain.color % (len(colors) - 1)] \
if len(colors) > 1 else body
u = self.hue_phase(curtain)
if quantised:
u = round(u * (AURORA_HUE_STEPS - 1)) / (AURORA_HUE_STEPS - 1)
return _mix(body, wander, AURORA_HUE_BLEND * u)
[docs]
def ramp_colors(self, curtain: Curtain, quantised: bool = False
) -> Dict[str, QColor]:
"""The four palette roles for one curtain: the body, the high red,
the low fringe, and the overlap between body and high.
Fixed roles rather than a rotation, because the vertical order is
physics. With ``borealis``, ``main`` is the 557.7 nm green, ``high``
the 630.0 nm red, ``fringe`` the 427.8 nm violet and ``blend`` the
pale yellow-green where the first two overlap. A palette with fewer
than four colours reuses what it has.
"""
colors = self.paint_colors
n = len(colors)
main = self.curtain_color(curtain, quantised=quantised)
high = colors[1 % n]
fringe = colors[2 % n]
blend = colors[3] if n > 3 else _mix(main, high, 0.5)
return {"main": main, "high": high, "fringe": fringe, "blend": blend}
[docs]
def ray_lengths(self, curtain: Curtain) -> Tuple[float, ...]:
"""Each ray's current length, as a fraction of the full one.
One value per entry in :data:`AURORA_TILE_RAYS`, quantised into
:data:`AURORA_LENGTH_STEPS` so the tile stays cacheable -- a length
that followed the clock exactly would rebuild every tile every
frame, which is the cost the tile cache exists to avoid.
The periods in :data:`AURORA_RAY_LIFE` are deliberately not
multiples of one another, and the curtain's own phase is added, so
two curtains never breathe together either.
"""
low, high = AURORA_RAY_LENGTH
out = []
for period, offset in AURORA_RAY_LIFE:
angle = (2 * math.pi * (self.time / period + offset)
+ curtain.pulse_phase)
unit = 0.5 * (1.0 + math.sin(angle))
stepped = round(unit * (AURORA_LENGTH_STEPS - 1)) \
/ (AURORA_LENGTH_STEPS - 1)
out.append(low + (high - low) * stepped)
return tuple(out)
def _tile(self, curtain: Curtain, peak: float, width: int,
height: int) -> QImage:
"""The ray comb crossed with the vertical colour ramp, as a tiling
texture, built at the exact pixel size it will be painted at.
Cached per (curtain, quantised shimmer, size). Nothing about it
changes from frame to frame: the ray period and the ray length are
fixed for a given canvas, and the curtain's slow colour shimmer is
quantised into :data:`AURORA_HUE_STEPS`. Three dozen of these get
built in the life of the widget, against three a frame if the tile
followed the clock.
"""
step = int(round(self.hue_phase(curtain) * (AURORA_HUE_STEPS - 1)))
lengths = self.ray_lengths(curtain)
key = (curtain.depth, step, width, height, lengths, peak)
tile = self._tiles.get(key)
if tile is not None:
return tile
if len(self._tiles) >= AURORA_TILE_CACHE:
self._tiles = {}
top_f, bottom_f = AURORA_TILE_RAMP
ramp_top = int(round(top_f * height))
ramp_bottom = max(ramp_top + 1, int(round(bottom_f * height)))
tile = QImage(width, height, QImage.Format_ARGB32_Premultiplied)
tile.fill(Qt.transparent)
roles = self.ramp_colors(curtain, quantised=True)
alpha = peak * AURORA_DEPTHS[curtain.depth % len(AURORA_DEPTHS)][2]
inner = QPainter(tile)
inner.setPen(Qt.NoPen)
gradient = QLinearGradient(0.0, float(ramp_bottom), 0.0,
float(ramp_top))
for stop, role, scale in AURORA_RAMP:
gradient.setColorAt(stop, _with_alpha(roles[role], alpha * scale))
inner.setBrush(gradient)
inner.drawRect(0, ramp_top, width, ramp_bottom - ramp_top)
inner.setCompositionMode(QPainter.CompositionMode_DestinationIn)
comb = QLinearGradient(0.0, 0.0, float(width), 0.0)
floor = QColor(0, 0, 0, int(round(255 * AURORA_TILE_FLOOR)))
comb.setColorAt(0.0, floor)
for centre, half, strength in AURORA_TILE_RAYS:
comb.setColorAt(max(0.0, centre - half), floor)
comb.setColorAt(centre,
QColor(0, 0, 0, int(round(255 * strength))))
comb.setColorAt(min(1.0, centre + half), floor)
comb.setColorAt(1.0, floor)
inner.setBrush(comb)
inner.drawRect(0, 0, width, height)
for (centre, half, _strength), length in zip(AURORA_TILE_RAYS,
lengths):
if length >= 1.0:
continue
left = int(round(max(0.0, centre - half) * width))
right = int(round(min(1.0, centre + half) * width))
if right <= left:
continue
kept = int(round((ramp_bottom - ramp_top) * length))
cut_bottom = ramp_bottom - kept
feather = max(1, int(round(
(ramp_bottom - ramp_top) * AURORA_RAY_FEATHER)))
if cut_bottom <= 0:
continue
fade = QLinearGradient(0.0, float(max(0, cut_bottom - feather)),
0.0, float(cut_bottom))
fade.setColorAt(0.0, QColor(0, 0, 0, 0))
fade.setColorAt(1.0, QColor(0, 0, 0, 255))
inner.setBrush(fade)
inner.drawRect(left, 0, right - left, cut_bottom)
inner.end()
self._tiles[key] = tile
return tile
def _mask(self) -> QImage:
"""The surge's vertical profile: solid along the lower edge, gone by
:data:`AURORA_PULSE_HEIGHT` of the way up. Built once, then reused as
the alpha of every per-frame surge image.
Positioned in the *padded* band (see :data:`AURORA_PULSE_PAD`), which
is why the stops are not at 0 and ``AURORA_PULSE_HEIGHT``: the
curtain's lower edge sits a padding's worth up from the bottom of the
image, and the ray length is a padded fraction of its height.
"""
if self._pulse_mask is None:
width, height = AURORA_PULSE_TEXTURE
pad = AURORA_PULSE_PAD
band = 1.0 + 2 * pad
edge = pad / band
reach = AURORA_PULSE_HEIGHT / band
mask = QImage(width, height, QImage.Format_ARGB32_Premultiplied)
mask.fill(Qt.transparent)
inner = QPainter(mask)
inner.setPen(Qt.NoPen)
fade = QLinearGradient(0.0, float(height), 0.0, 0.0)
fade.setColorAt(0.0, QColor(0, 0, 0, 255))
fade.setColorAt(edge, QColor(0, 0, 0, 255))
fade.setColorAt(edge + reach * 0.45, QColor(0, 0, 0, 185))
fade.setColorAt(min(1.0, edge + reach), QColor(0, 0, 0, 0))
fade.setColorAt(1.0, QColor(0, 0, 0, 0))
inner.setBrush(fade)
inner.drawRect(0, 0, width, height)
inner.end()
self._pulse_mask = mask
return self._pulse_mask
def _surge(self, curtain: Curtain, peak: float) -> QImage:
"""The travelling surge for one curtain, as a small image.
Horizontally it is the pulse; vertically it is the cached fade. It
has to be a two-dimensional texture rather than a gradient brush: a
horizontal gradient alone has no vertical falloff, so it would cut
off in a hard line across the curtain, and putting the falloff in the
path instead only moves the hard line somewhere else.
Cached on the pulse's phase, quantised, plus the curtain's shimmer
step — which is everything its content depends on, so the cache is a
memo and not an approximation of the model. It still steps in time,
and :data:`AURORA_PULSE_STEPS` is what decides how finely.
"""
width, height = AURORA_PULSE_TEXTURE
_depth, wavelength, speed = AURORA_PULSE
phase = (curtain.pulse_phase
- 2 * math.pi * speed * self._rate(curtain) * self.time
/ wavelength)
step = int(round(phase % (2 * math.pi)
/ (2 * math.pi) * AURORA_PULSE_STEPS))
hue = int(round(self.hue_phase(curtain) * (AURORA_HUE_STEPS - 1)))
key = (curtain.depth, step % AURORA_PULSE_STEPS, hue, peak)
image = self._surges.get(key)
if image is not None:
return image
if len(self._surges) >= AURORA_PULSE_CACHE:
self._surges = {}
image = QImage(width, height, QImage.Format_ARGB32_Premultiplied)
image.fill(Qt.transparent)
inner = QPainter(image)
inner.setPen(Qt.NoPen)
color = self.curtain_color(curtain, quantised=True)
gain = peak * AURORA_PULSE_GAIN
depth_alpha = AURORA_DEPTHS[curtain.depth % len(AURORA_DEPTHS)][2]
quantised = step % AURORA_PULSE_STEPS * 2 * math.pi \
/ AURORA_PULSE_STEPS
gradient = QLinearGradient(0.0, 0.0, float(width), 0.0)
stops = width
for k in range(stops):
u = k / (stops - 1)
bright = depth_alpha * self._pulse_at(u, quantised)
gradient.setColorAt(u, _with_alpha(color, gain * bright))
inner.setBrush(gradient)
inner.drawRect(0, 0, width, height)
inner.setCompositionMode(QPainter.CompositionMode_DestinationIn)
inner.drawImage(0, 0, self._mask())
inner.end()
self._surges[key] = image
return image
@staticmethod
def _pulse_at(u: float, phase: float) -> float:
"""The surge profile at ``u`` for a given travelling phase."""
depth, wavelength, _speed = AURORA_PULSE
return 1.0 - depth + depth * (
0.5 + 0.5 * math.sin(2 * math.pi * u / wavelength + phase))
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Draw an irregular ray texture warped along the luminous folds.
The texture has native horizontal sampling and a smooth vertical
emission profile. Affine strips bend it along the sheet without
painting hundreds of separate full-height gradients.
"""
self._paint_layers(painter, self._aurora_layers(width, height))
def _aurora_layers(self, width: int, height: int) -> list:
"""Per curtain: the ray material, its colour table and its strips.
Everything here is one-dimensional (per texture column or per
sampled fold column); the two-dimensional ray texture is built by
:meth:`_aurora_texture` on the CPU or by the graphics renderer.
"""
np = _numpy()
peak = (0.80 if self.dark else 0.65) * self.alpha_scale() * math.sqrt(
min(1.0, AURORA_CURTAINS * self.effective_density()))
samples = self.geometry(width, height)
stride = AURORA_COLUMNS + 1
layers = []
for index, curtain in enumerate(self.curtains[:self.count()]):
columns = samples[index * stride:(index + 1) * stride]
if len(columns) < 2:
continue
left, right = columns[0][0], columns[-1][0]
texture_width = max(1, math.ceil(right - left))
count = max(96, min(960, int(width / max(2.0, self.size * 4.0))))
key = (index, texture_width, count)
material = self._ray_material.get(key)
if material is None:
rng = random.Random(f"aurora:{self._aurora_seed}:{index}:{count}")
positions = np.array([(i + rng.uniform(0.1, 0.9)) / count
for i in range(count)], dtype=np.float32)
coordinates = (np.arange(texture_width, dtype=np.float32) + 0.5) / texture_width
upper = np.clip(np.searchsorted(positions, coordinates), 0, count - 1)
lower = np.maximum(0, upper - 1)
nearest = np.where(abs(coordinates - positions[lower]) <
abs(coordinates - positions[upper]), lower, upper)
distance = (coordinates - positions[nearest]) * count
constants = np.array([(rng.uniform(0.50, 1.0),
rng.uniform(0.4, 1.35),
rng.uniform(0.0, math.tau),
rng.uniform(0.045, 0.19))
for _ in range(count)], dtype=np.float32)
material = (coordinates, distance, constants[nearest])
if len(self._ray_material) >= 24:
self._ray_material.clear()
self._ray_material[key] = material
coordinates, distance, constants = material
length, weight, phase, rate = constants.T
ray_length = length * (0.82 + 0.18 * np.sin(self.time * rate + phase))
shimmer = 0.72 + 0.28 * np.sin(self.time * rate * 1.7 + phase)
surge = np.interp(coordinates, np.linspace(0.0, 1.0, stride),
[column[3] for column in columns]).astype(np.float32)
gain = peak * weight * shimmer * surge * np.sin(math.pi * coordinates) ** 0.65
roles = self.ramp_colors(curtain, quantised=False)
if self._spacr_palette:
roles = {"main": QColor("#6dff9d"), "blend": QColor("#b6ffc8"),
"high": QColor("#df69c6")}
colors = [roles[role] for role in ("blend", "main", "main", "high", "high")]
ramp = np.array([[color.red(), color.green(), color.blue()]
for color in colors], dtype=np.float32)
ramp_positions = (0.0, 0.12, 0.42, 0.75, 1.0)
values = np.arange(256, dtype=np.float32) / 255.0
lookup = np.stack([np.interp(values, ramp_positions, ramp[:, channel])
for channel in range(3)], axis=1).astype(np.float32)
if not self.dark:
lookup = 255.0 - lookup
color_ids = None
if self._random_palette:
colors = np.array([[color.red(), color.green(), color.blue()]
for color in self.paint_colors], dtype=np.float32)
color_ids = (phase * 1000).astype(np.int32) % len(colors)
lookup = colors
if not self.dark:
lookup = 255.0 - lookup
_, ray_height = self.anchor(curtain, height)
lean = width * 0.12 * math.sin(curtain.phase + self.time * 0.04)
strips = []
for first, second in zip(columns, columns[1:]):
x0, bottom0 = first[:2]
x1, bottom1 = second[:2]
extent = x1 - x0
shear = (bottom1 - bottom0) / extent
source_x = (x0 - left) / (right - left) * texture_width
source_width = extent / (right - left) * texture_width
strips.append((extent / source_width, shear * extent / source_width,
-lean / _AURORA_TEXTURE_ROWS,
ray_height / _AURORA_TEXTURE_ROWS,
x0 + lean, bottom0 - ray_height,
source_x, source_width))
layers.append({"width": texture_width, "distance": distance,
"ray_length": ray_length, "gain": gain,
"lookup": lookup, "color_ids": color_ids,
"strips": strips})
return layers
def _aurora_texture(self, layer: dict):
"""The CPU ray texture for one layer, with the array it borrows."""
np = _numpy()
vertical = np.linspace(1.0, 0.0, _AURORA_TEXTURE_ROWS,
dtype=np.float32)[:, None]
rise = vertical / layer["ray_length"][None, :]
beam_width = 0.14 + 0.28 * (1.0 - np.clip(rise, 0.0, 1.0))
rays = np.exp(-(layer["distance"][None, :] / beam_width) ** 2)
emission = (0.15 + 0.85 * np.maximum(0.0, 1.0 - rise) ** 0.8)
emission *= np.clip((1.0 - rise) / 0.12, 0.0, 1.0)
emission *= np.minimum(1.0, rise / 0.045)
alpha = emission * (0.20 + 0.80 * rays) * layer["gain"][None, :]
lookup = layer["lookup"]
if layer["color_ids"] is None:
color_index = np.clip(rise * 255.0, 0, 255).astype(np.uint8)
else:
color_index = np.broadcast_to(layer["color_ids"], alpha.shape)
levels = np.arange(256, dtype=np.float32) / 255.0
rgb = (lookup[None, :, :] * levels[:, None, None]).astype(np.uint32)
packed = (np.uint32(0xff000000) | (rgb[:, :, 0] << 16)
| (rgb[:, :, 1] << 8) | rgb[:, :, 2])
if not self.dark:
packed = packed ^ np.uint32(0x00ffffff)
alpha_index = np.clip(alpha * 255.0, 0, 255).astype(np.uint8)
words = np.ascontiguousarray(packed[alpha_index, color_index])
texture = QImage(words.data, layer["width"], _AURORA_TEXTURE_ROWS,
words.strides[0], QImage.Format_RGB32)
return texture, words
def _paint_layers(self, painter: QPainter, layers: list) -> None:
"""Warp each CPU ray texture along its fold strips."""
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
rows = _AURORA_TEXTURE_ROWS
for layer in layers:
texture, words = self._aurora_texture(layer)
for (m11, m12, m21, m22, dx, dy, source_x,
source_width) in layer["strips"]:
painter.save()
painter.setTransform(QTransform(m11, m12, m21, m22, dx, dy), True)
painter.drawImage(QRectF(0.0, 0.0, source_width + 0.5, rows),
texture, QRectF(source_x, 0.0, source_width + 0.5, rows))
painter.restore()
del texture, words
@staticmethod
def _aurora_gpu_layer(layer: dict) -> tuple:
"""Columns, colour table and strip vertices for the graphics renderer."""
np = _numpy()
columns = np.empty((layer["width"], 4), dtype=np.float32)
columns[:, 0] = layer["distance"]
columns[:, 1] = layer["ray_length"]
columns[:, 2] = layer["gain"]
columns[:, 3] = 0.0 if layer["color_ids"] is None else layer["color_ids"]
rows = float(_AURORA_TEXTURE_ROWS)
vertices = []
for m11, m12, m21, m22, dx, dy, source_x, source_width in layer["strips"]:
span = source_width + 0.5
corners = [(m11 * u + m21 * v + dx, m12 * u + m22 * v + dy,
source_x + u, v)
for u, v in ((0.0, 0.0), (span, 0.0), (0.0, rows), (span, rows))]
vertices.extend((corners[0], corners[1], corners[2],
corners[2], corners[1], corners[3]))
return (columns, layer["lookup"], layer["color_ids"] is not None,
np.asarray(vertices, dtype=np.float32).reshape(-1, 4))
@staticmethod
def _sheet(columns, top: float) -> QPainterPath:
"""The sheet as a closed path: along its folded lower edge, then
straight back across a flat top.
The top is flat, and that is not a shortcut. It sits exactly where
the colour ramp has faded to nothing, so the polygon's upper boundary
is invisible — which is the only way to get a *diffuse* top out of a
hard-edged polygon. All the visible shape is in the lower edge, which
is where a real curtain keeps it too.
"""
path = QPainterPath()
path.moveTo(columns[0][0], columns[0][1])
for x, y, _h, _b in columns[1:]:
path.lineTo(x, y)
path.lineTo(columns[-1][0], top)
path.lineTo(columns[0][0], top)
path.closeSubpath()
return path
RIPPLE_SOURCES = 3
RIPPLE_RINGS = 4
RIPPLE_PERIOD = (14.0, 26.0)
RIPPLE_REACH = (0.55, 0.95)
#: Ring thickness, as a fraction of its own radius. Started at 0.38, which
#: drew four crisp concentric circles per source and read as a dartboard.
#: Wide and soft is the point — it should look like something moved through
#: the page, not like a diagram.
RIPPLE_BAND = 0.72
RIPPLE_ALPHA_DARK = 0.20
RIPPLE_ALPHA_LIGHT = 0.38
@dataclass
[docs]
class Source:
"""One ripple origin, in normalised units."""
x: float
y: float
period: float
phase: float
reach: float
color: int
[docs]
class RippleEngine(_BufferedEngine):
"""Concentric rings expanding from a few sources and fading as they grow.
Each ring is a radial gradient annulus rather than a stroked circle: a
stroked one is line work, which is both expensive and far too crisp for
something meant to sit behind a settings form.
:meth:`geometry` yields ``(cx, cy, radius, fade)`` per ring, in pixels,
with ``fade`` in 0..1 — 0 as the ring is born and as it dies at the
edge of its reach, 1 halfway.
"""
name = "ripple"
def _configure(self, rng: random.Random) -> None:
"""Roll this theme's constants from the seed.
ONCE, at construction: an engine is deterministic, so the same seed and
the same call sequence always produce the same animation.
"""
anchors = ((0.24, 0.28), (0.76, 0.22), (0.5, 0.82))
self.sources: List[Source] = []
for i in range(_pool_size(RIPPLE_SOURCES)):
if i < len(anchors):
ax, ay = anchors[i]
else:
angle = 2 * math.pi * (i - len(anchors)) \
/ max(1, _pool_size(RIPPLE_SOURCES) - len(anchors))
ax = 0.5 + 0.36 * math.cos(angle)
ay = 0.5 + 0.30 * math.sin(angle)
self.sources.append(Source(
x=ax + rng.uniform(-0.08, 0.08),
y=ay + rng.uniform(-0.08, 0.08),
period=rng.uniform(*RIPPLE_PERIOD),
phase=rng.random(),
reach=rng.uniform(*RIPPLE_REACH),
color=i,
))
[docs]
def count(self) -> int:
"""How many ripple sources are painted right now."""
return self.element_count(RIPPLE_SOURCES, len(self.sources))
[docs]
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""The shapes to draw at the current time, for a widget this size.
GEOMETRY, NOT PAINTING, so the layout can be computed on a worker
thread and tested without a QPainter -- an engine owns no widget and
no timer, which is what makes the animation deterministic.
:param width: the widget's width in pixels.
:param height: its height in pixels.
:returns: one tuple per shape, in draw order.
"""
t = self.time
half_diagonal = 0.5 * math.hypot(width, height)
out = []
for source in self.sources[:self.count()]:
cx, cy = source.x * width, source.y * height
reach = source.reach * half_diagonal * self.size
for k in range(RIPPLE_RINGS):
u = (t / source.period + source.phase
+ k / RIPPLE_RINGS) % 1.0
out.append((cx, cy, max(1.0, u * reach),
math.sin(math.pi * u)))
return tuple(out)
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Draw one frame's field of shapes.
:param painter: the painter to draw with.
:param width: the widget's width in pixels.
:param height: its height in pixels.
"""
peak = (RIPPLE_ALPHA_DARK if self.dark else RIPPLE_ALPHA_LIGHT) \
* self.alpha_scale()
colors = self.paint_colors
inner = max(0.0, 1.0 - RIPPLE_BAND)
for index, (cx, cy, radius, fade) in enumerate(
self.geometry(width, height)):
color = colors[(index // RIPPLE_RINGS) % len(colors)]
gradient = QRadialGradient(cx, cy, radius)
gradient.setColorAt(0.0, _with_alpha(color, 0.0))
gradient.setColorAt(inner, _with_alpha(color, 0.0))
gradient.setColorAt(1.0 - RIPPLE_BAND * 0.5,
_with_alpha(color, peak * fade))
gradient.setColorAt(1.0, _with_alpha(color, 0.0))
painter.setBrush(gradient)
painter.drawEllipse(QPointF(cx, cy), radius, radius)
#: The particle pool. The whole pool is rolled once; how many of them are
#: actually painted depends on the canvas (see :data:`DRIFT_AREA_PER_PARTICLE`)
#: so that a small screen is not a snowstorm — but the pool itself never
#: changes, which keeps a resize from re-rolling the field.
DRIFT_POOL = 240
DRIFT_AREA_PER_PARTICLE = 9500
DRIFT_MIN_PARTICLES = 40
#: Three depth layers: ``(dot diameter in px, alpha, speed in canvas heights
#: per second)``. The parallax is the whole effect — one layer looks like
#: dust on the lens.
DRIFT_LAYERS = (
(1.4, 0.35, 0.006),
(2.4, 0.55, 0.011),
(3.6, 0.80, 0.018),
)
#: Sideways sway, as a fraction of the canvas width, and its period.
DRIFT_SWAY = (0.01, 0.05)
DRIFT_SWAY_PERIOD = (18.0, 52.0)
#: Slow per-layer breathing, quantised into this many alpha steps so the pen
#: cache stays small — a pen rebuilt per particle per frame is the one way to
#: make this theme expensive.
DRIFT_TWINKLE = 0.25
DRIFT_TWINKLE_PERIOD = (9.0, 17.0)
DRIFT_ALPHA_STEPS = 8
#: On a light page the dots are darkened instead of brightened, or they are
#: invisible; they are also drawn a little harder, since a dark dot on white
#: has less room than a bright dot on black.
DRIFT_DARKEN_ON_LIGHT = 0.35
DRIFT_LIGHT_BOOST = 1.25
#: Blur, for the one theme that is not painted through the blur buffer. A dot
#: cannot be softened by shading it over fewer pixels — it *is* one pixel — so
#: above 1.0 each one gets a second, wider, dimmer pass around it: a halo.
#: The widening and the dimming are tied together so the dot's total light
#: stays roughly constant, which is what "the same star, out of focus" means.
DRIFT_HALO_SPREAD = 1.6
DRIFT_HALO_ALPHA = 0.34
#: A cap, because this is the one theme whose cost is *area* rather than a
#: fixed buffer: two hundred dots at maximum blur and maximum size would
#: otherwise light a quarter of the page and cost 3.2 ms a frame, which is
#: more than the whole module is allowed. Blurrier than this looks the same
#: anyway — the dot is already a soft disc by then.
DRIFT_HALO_MAX_PX = 14.0
#: Below this *resolution*, the dots stop being antialiased: they lose their
#: soft rim entirely, which is the only way left to make a 2 px dot
#: harder-edged, and is the only thing a resolution setting can mean for a
#: theme with no buffer to resolve. (It used to hang off the blur control,
#: which is exactly the conflation this pair of settings exists to undo.)
DRIFT_HARD_EDGE_RESOLUTION = 0.8
#: The ``random`` direction's wander: how far a speck slides sideways off its
#: own heading, as a fraction of the canvas, and over what period. Two
#: incommensurate sines per axis, so the path never closes and never
#: repeats — which is what "Brownian-ish" has to mean here, because every
#: position in this module is a pure function of the clock and an accumulated
#: random walk is not.
DRIFT_WANDER = (0.02, 0.07)
DRIFT_WANDER_PERIOD = (11.0, 37.0)
@dataclass
[docs]
class Particle:
"""One drifting dot, in normalised units."""
x: float
y: float
layer: int
speed: float
sway: float
sway_rate: float
sway_phase: float
color: int
#: Heading for the ``random`` direction, in radians. Unused by ``up``
#: and ``down``, which share one heading between all of them.
heading: float = 0.0
wander: float = 0.0
wander_rate: float = 0.0
wander_phase: float = 0.0
class _StarsOnGraphics:
"""The stratified theme's optional shading-thread half (N684).
Kept off :class:`DriftEngine` itself: the theme's public drawing call
stays :meth:`DriftEngine.paint`, and only a widget with Animation GPU
On hands it to a shading thread through these methods.
"""
def shade(self, width: int, height: int) -> Optional[QImage]:
"""One transparent premultiplied frame for the graphics shading thread.
Only used when Animation GPU asks for the graphics renderer (see
:meth:`AmbientWidget._start_producer`); :meth:`paint` stays the CPU
path. The frame holds the dots alone, drawn source-over onto
transparency, so :meth:`blit` composites them onto the page exactly
as :meth:`paint` draws them. A refused renderer gives the same
frame from QPainter.
"""
if width <= 0 or height <= 0:
return None
bw, bh = self.buffer_size(width, height)
passes = self._dot_passes(width, height)
image = self._graphics_frame(
'_draw_stars', bw, bh,
build=partial(self._star_rows, passes, (bw / width, bh / height)))
if image is not None:
return image
image = QImage(bw, bh, QImage.Format_ARGB32_Premultiplied)
image.fill(Qt.transparent)
inner = QPainter(image)
try:
if (bw, bh) != (width, height):
inner.scale(bw / width, bh / height)
self._paint_dots(inner, width, height, passes)
finally:
inner.end()
return image
def blit(self, painter: QPainter, image: Optional[QImage],
width: int, height: int) -> None:
"""Composite a :meth:`shade` frame onto the page. **GUI thread only.**"""
if image is None or image.isNull() or width <= 0 or height <= 0:
return
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
painter.drawImage(QRect(0, 0, int(width), int(height)), image)
def _star_rows(self, passes, scale) -> tuple:
"""Disc rows, scale and edge mode for the graphics renderer."""
rows = []
for pen, points in passes:
color = pen.color()
radius = pen.widthF() * 0.5
red, green, blue = color.redF(), color.greenF(), color.blueF()
alpha = color.alphaF()
rows.extend((point.x(), point.y(), radius, red, green, blue, alpha)
for point in points)
return rows, scale, self.resolution > DRIFT_HARD_EDGE_RESOLUTION
[docs]
class DriftEngine(_StarsOnGraphics, _GraphicsBackend, AmbientEngine):
"""A slow starfield in three parallax layers.
At full Detail dots are drawn directly and stay crisp. Lower Detail
samples the same canvas population through a bounded image buffer.
Everything is batched into one ``drawPoints`` call per (colour, layer,
alpha step) bucket with a cached pen; the per-call overhead of a pen
change dominates this theme, not the pixels.
:meth:`geometry` yields ``(x, y, diameter)`` per painted particle, in
pixels.
"""
name = "drift"
def __init__(self, *args, **kwargs):
"""Roll the starfield's three parallax layers."""
self._pens: Dict[Tuple[int, int, int], QPen] = {}
self._detail_buffer: Optional[QImage] = None
super().__init__(*args, **kwargs)
def _configure(self, rng: random.Random) -> None:
"""Roll this theme's constants from the seed.
ONCE, at construction: an engine is deterministic, so the same seed and
the same call sequence always produce the same animation.
"""
self.particles: List[Particle] = []
def roll(i: int) -> Particle:
"""One particle, on the drift layer its index falls in."""
layer = i % len(DRIFT_LAYERS)
_, _, speed = DRIFT_LAYERS[layer]
return Particle(
x=rng.random(),
y=rng.random(),
layer=layer,
speed=speed * rng.uniform(0.8, 1.25),
sway=rng.uniform(*DRIFT_SWAY),
sway_rate=2 * math.pi / rng.uniform(*DRIFT_SWAY_PERIOD),
sway_phase=rng.uniform(0.0, 2 * math.pi),
color=i,
)
for i in range(DRIFT_POOL):
self.particles.append(roll(i))
self.twinkle_rates = [
2 * math.pi / rng.uniform(*DRIFT_TWINKLE_PERIOD)
for _ in DRIFT_LAYERS]
self.twinkle_phases = [rng.uniform(0.0, 2 * math.pi)
for _ in DRIFT_LAYERS]
for i in range(DRIFT_POOL, _pool_size(DRIFT_POOL)):
self.particles.append(roll(i))
for particle in self.particles:
particle.heading = rng.uniform(0.0, 2 * math.pi)
particle.wander = rng.uniform(*DRIFT_WANDER)
particle.wander_rate = 2 * math.pi / rng.uniform(
*DRIFT_WANDER_PERIOD)
particle.wander_phase = rng.uniform(0.0, 2 * math.pi)
def _restyle(self) -> None:
"""Re-roll the star colours after a palette change."""
super()._restyle()
self._pens = {}
def _reblur(self) -> None:
"""Rebuild the blur used on the far layers."""
self._pens = {}
def _reresolve(self) -> None:
"""Rebuild the buffers after a resolution change."""
self._pens = {}
self._detail_buffer = None
def _resize(self) -> None:
"""Re-lay the layers for a new widget size."""
self._pens = {}
def _tint(self, color: QColor) -> QColor:
"""The colour one star is drawn in, by layer depth.
:param color: the layer's base colour.
:returns: the colour.
"""
return QColor(color) if self.dark \
else _mix(color, QColor(0, 0, 0), DRIFT_DARKEN_ON_LIGHT)
[docs]
def dot_size(self, layer: int) -> float:
"""Diameter of a ``layer`` dot, in pixels, under the size setting."""
return DRIFT_LAYERS[layer][0] * self.size
[docs]
def halo_size(self, layer: int) -> float:
"""Diameter of the soft pass around a dot. Equal to the dot itself
at blur 0, which is how the default frame stays exactly what it
was."""
dot = self.dot_size(layer)
return min(DRIFT_HALO_MAX_PX,
dot * (1.0 + DRIFT_HALO_SPREAD * max(0.0, self.blur)))
@property
[docs]
def work(self) -> float:
"""Density only: sampling is capped at native display resolution."""
return self.density
[docs]
def alpha_scale(self) -> float:
"""Untouched, unlike every buffered theme.
The compensation on the base class exists because overlapping
translucent *fields* pile up additively. A starfield does not have
that problem — measured, its mean frame lightness moves from 0.076
to 0.077 across the whole density range, because a couple of hundred
dots light 0.65 % of the page and almost never land on each other.
Dividing their alpha by three would not un-brighten anything; it
would hide two thirds of the stars in the background.
"""
return 1.0
[docs]
def count_for(self, width: int, height: int) -> int:
"""How many of the pool this canvas gets.
Area-based, so a small window is not a snowstorm, and then scaled by
the density setting — which is the only one of the two the user
controls.
"""
wanted = int(width) * int(height) // DRIFT_AREA_PER_PARTICLE
wanted = _clamp_int(wanted, min(DRIFT_MIN_PARTICLES, DRIFT_POOL),
DRIFT_POOL)
return self.element_count(wanted, len(self.particles))
[docs]
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""``(x, y, diameter)`` per painted particle, in pixels.
Three directions, one expression each, and all three are pure
functions of the clock — no accumulated state, so ``set_time`` still
jumps anywhere and two engines on the same seed still agree.
``up`` and ``down`` are the same shared vector with opposite signs.
``random`` gives every speck its own heading and adds a slow wander
across it, which is a smooth wandering path rather than a straight
line with a wobble; the headings are isotropic, so the field spreads
and mixes instead of travelling.
"""
t = self.time
out = []
random_walk = self.direction == "random"
sign = 1.0 if self.direction == "down" else -1.0
for particle in self.particles[:self.count_for(width, height)]:
sway = particle.sway * math.sin(
particle.sway_rate * t + particle.sway_phase)
if random_walk:
travel = particle.speed * t
wander = particle.wander * math.sin(
particle.wander_rate * t + particle.wander_phase)
x = (particle.x + math.cos(particle.heading) * travel
+ sway + wander * math.sin(particle.heading))
y = (particle.y + math.sin(particle.heading) * travel
- wander * math.cos(particle.heading))
else:
x = particle.x + sway
y = particle.y + sign * particle.speed * t
out.append((x % 1.0 * width, y % 1.0 * height,
self.dot_size(particle.layer)))
return tuple(out)
def _alpha_step(self, layer: int) -> int:
"""The quantised alpha for ``layer`` right now, as a 0..N-1 step."""
_, alpha, _ = DRIFT_LAYERS[layer]
breath = 1.0 - DRIFT_TWINKLE + DRIFT_TWINKLE * (
0.5 + 0.5 * math.sin(self.twinkle_rates[layer] * self.time
+ self.twinkle_phases[layer]))
if not self.dark:
breath *= DRIFT_LIGHT_BOOST
value = _clamp(alpha * breath, 0.0, 1.0)
return _clamp_int(round(value * (DRIFT_ALPHA_STEPS - 1)),
0, DRIFT_ALPHA_STEPS - 1)
def _pen(self, color_index: int, layer: int, step: int,
halo: bool = False) -> QPen:
"""The pen one star is drawn with.
:param layer: which parallax layer.
:returns: the pen.
"""
key = (color_index, layer, step, halo)
pen = self._pens.get(key)
if pen is None:
colors = self.paint_colors
alpha = step / (DRIFT_ALPHA_STEPS - 1)
if halo:
alpha *= DRIFT_HALO_ALPHA
pen = QPen(_with_alpha(colors[color_index % len(colors)], alpha))
pen.setWidthF(self.halo_size(layer) if halo
else self.dot_size(layer))
pen.setCapStyle(Qt.RoundCap)
self._pens[key] = pen
return pen
[docs]
def buffer_size(self, width: int, height: int) -> Tuple[int, int]:
"""Sample dots at the chosen Detail within the display pixel ceiling."""
detail = min(1.0, self.resolution)
bw, bh = max(1, int(width * detail)), max(1, int(height * detail))
scale = min(1.0, math.sqrt(self.max_pixels / (bw * bh)))
return max(1, int(bw * scale)), max(1, int(bh * scale))
[docs]
def paint(self, painter: QPainter, width: int, height: int) -> None:
"""Draw the same population through the selected pixel resolution."""
if width <= 0 or height <= 0:
return
bw, bh = self.buffer_size(width, height)
if (bw, bh) == (width, height):
self._paint_dots(painter, width, height)
return
image = self._detail_buffer
if image is None or (image.width(), image.height()) != (bw, bh):
image = QImage(bw, bh, QImage.Format_RGB32)
self._detail_buffer = image
inner = QPainter(image)
try:
inner.fillRect(image.rect(), self.identity)
inner.setCompositionMode(self.mode)
inner.scale(bw / width, bh / height)
self._paint_dots(inner, width, height)
finally:
inner.end()
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
painter.setCompositionMode(self.mode)
painter.drawImage(QRect(0, 0, int(width), int(height)), image)
painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
def _dot_passes(self, width: int, height: int) -> list:
"""The ordered ``(pen, points)`` batches :meth:`_paint_dots` draws."""
n_colors = len(self.paint_colors)
steps = [self._alpha_step(i) for i in range(len(DRIFT_LAYERS))]
buckets: Dict[Tuple[int, int, int], List[QPointF]] = {}
geometry = self.geometry(width, height)
blinking = set(int(value) for value in self._blinking_indices(len(geometry)))
flashes = {}
for index, (particle, (x, y, _size)) in enumerate(zip(
self.particles, geometry)):
if index in blinking:
flashes.setdefault(particle.layer, []).append(QPointF(x, y))
continue
key = (particle.color % n_colors, particle.layer,
steps[particle.layer])
buckets.setdefault(key, []).append(QPointF(x, y))
passes = []
if self.blur > 0.0:
for key, points in buckets.items():
passes.append((self._pen(*key, halo=True), points))
for key, points in buckets.items():
passes.append((self._pen(*key), points))
for layer, points in flashes.items():
pen = QPen(QColor("white"))
pen.setWidthF(self.dot_size(layer))
pen.setCapStyle(Qt.RoundCap)
passes.append((pen, points))
return passes
def _paint_dots(self, painter: QPainter, width: int, height: int,
passes: Optional[list] = None) -> None:
"""Batch dots in canvas coordinates so Detail never changes population."""
painter.setRenderHint(QPainter.Antialiasing,
self.resolution > DRIFT_HARD_EDGE_RESOLUTION)
for pen, points in (self._dot_passes(width, height)
if passes is None else passes):
painter.setPen(pen)
painter.drawPoints(points)
#: How many discs. Fewer than blobs on purpose: each one has to stay
#: readable *as a disc*, and past about a dozen the rims start crossing
#: often enough that the field reads as a mesh.
BOKEH_COUNT = 11
#: Disc radius, as a fraction of the short edge. The wide range is the depth
#: of field: the far ones are big and flat, the near ones small and tight.
BOKEH_RADIUS = (0.05, 0.30)
#: Focus, 0 = far out of focus (flat, no rim), 1 = nearly sharp (bright
#: narrow rim, dark middle). Rolled per disc and *correlated with radius* in
#: :meth:`_configure`, because a big sharp-rimmed disc is not a thing an
#: objective can produce.
BOKEH_FOCUS = (0.15, 0.95)
#: Where the rim sits, as a fraction of the radius, and how far the disc
#: fades out past it. A real aperture image has a hard edge; this keeps a
#: couple of per-cent of softness so it does not alias.
BOKEH_RIM = 0.88
BOKEH_EDGE = 0.99
#: How much brighter the rim is than the middle, at full focus. At focus 0
#: the two are equal and the disc is flat.
BOKEH_RIM_GAIN = 2.4
#: Drift and its period, plus the slow independent brightness breathing that
#: keeps the field from looking like a still photograph.
BOKEH_DRIFT = (0.02, 0.09)
BOKEH_DRIFT_PERIOD = (26.0, 80.0)
BOKEH_BREATH = 0.30
BOKEH_BREATH_PERIOD = (9.0, 23.0)
#: Peak alpha. Lower than blobs: there are rims here, and a rim carries far
#: more attention per unit of alpha than a gradient does.
BOKEH_ALPHA_DARK = 0.22
BOKEH_ALPHA_LIGHT = 0.34
#: A rim is an edge, so this theme wants more resolution than the diffuse
#: ones — but much less than the aurora, because a rim is one edge per disc
#: at a radius of tens of pixels, not a comb repeating every 36.
BOKEH_BUFFER_EDGE = 512
@dataclass
[docs]
class Disc:
"""One out-of-focus point source, in normalised units."""
x: float
y: float
drift_x: float
drift_y: float
rate_x: float
rate_y: float
phase_x: float
phase_y: float
radius: float
focus: float
breath_rate: float
breath_phase: float
color: int
[docs]
class BokehEngine(_BufferedEngine):
"""Defocused points of light: flat discs with bright rims.
:meth:`geometry` yields ``(cx, cy, radius, focus)`` per disc, in pixels,
with ``focus`` in 0..1 — the same tuple the painter builds its gradients
from, so a test that asserts on it is asserting on the frame.
"""
name = "bokeh"
base_edge = BOKEH_BUFFER_EDGE
def _configure(self, rng: random.Random) -> None:
"""Roll this theme's constants from the seed.
ONCE, at construction: an engine is deterministic, so the same seed and
the same call sequence always produce the same animation.
"""
cols, rows = 4, 3
cells = list(range(cols * rows))
rng.shuffle(cells)
self.discs: List[Disc] = []
for i in range(_pool_size(BOKEH_COUNT)):
cell = cells[i % len(cells)]
col, row = cell % cols, cell // cols
lo, hi = BOKEH_RADIUS
radius = rng.uniform(lo, hi)
near = 1.0 - (radius - lo) / max(1e-6, hi - lo)
flo, fhi = BOKEH_FOCUS
focus = flo + (fhi - flo) * (0.35 + 0.65 * near) * rng.uniform(
0.75, 1.0)
self.discs.append(Disc(
x=(col + 0.12 + 0.76 * rng.random()) / cols,
y=(row + 0.12 + 0.76 * rng.random()) / rows,
drift_x=rng.uniform(*BOKEH_DRIFT),
drift_y=rng.uniform(*BOKEH_DRIFT),
rate_x=2 * math.pi / rng.uniform(*BOKEH_DRIFT_PERIOD),
rate_y=2 * math.pi / rng.uniform(*BOKEH_DRIFT_PERIOD),
phase_x=rng.uniform(0.0, 2 * math.pi),
phase_y=rng.uniform(0.0, 2 * math.pi),
radius=radius,
focus=_clamp(focus, 0.0, 1.0),
breath_rate=2 * math.pi / rng.uniform(*BOKEH_BREATH_PERIOD),
breath_phase=rng.uniform(0.0, 2 * math.pi),
color=i,
))
[docs]
def count(self) -> int:
"""How many discs are painted right now."""
return self.element_count(BOKEH_COUNT, len(self.discs))
[docs]
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""The shapes to draw at the current time, for a widget this size.
GEOMETRY, NOT PAINTING, so the layout can be computed on a worker
thread and tested without a QPainter -- an engine owns no widget and
no timer, which is what makes the animation deterministic.
:param width: the widget's width in pixels.
:param height: its height in pixels.
:returns: one tuple per shape, in draw order.
"""
t = self.time
short = min(width, height)
out = []
for disc in self.discs[:self.count()]:
cx = (disc.x + disc.drift_x
* math.sin(disc.rate_x * t + disc.phase_x)) * width
cy = (disc.y + disc.drift_y
* math.sin(disc.rate_y * t + disc.phase_y)) * height
out.append((cx, cy, max(1.0, disc.radius * short * self.size),
disc.focus))
return tuple(out)
@staticmethod
def _stops(focus: float, peak: float):
"""The radial profile of one defocused point, as gradient stops.
At ``focus`` 0 it is a flat disc: middle and rim the same, a soft
shoulder at the edge. At 1 the middle has dropped to a third and the
rim is :data:`BOKEH_RIM_GAIN` times brighter than it — the classic
doughnut an out-of-focus point makes through a clear aperture.
"""
middle = peak * (1.0 - 0.62 * focus)
rim = peak * (1.0 + (BOKEH_RIM_GAIN - 1.0) * focus)
return ((0.0, middle * 0.92), (0.55, middle),
(BOKEH_RIM, rim), (BOKEH_EDGE, rim * 0.35), (1.0, 0.0))
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Draw one frame's field of shapes.
:param painter: the painter to draw with.
:param width: the widget's width in pixels.
:param height: its height in pixels.
"""
painter.setRenderHint(QPainter.Antialiasing, True)
base = (BOKEH_ALPHA_DARK if self.dark else BOKEH_ALPHA_LIGHT) \
* self.alpha_scale()
colors = self.paint_colors
t = self.time
for disc, (cx, cy, radius, focus) in zip(
self.discs, self.geometry(width, height)):
breath = 1.0 - BOKEH_BREATH + BOKEH_BREATH * (
0.5 + 0.5 * math.sin(disc.breath_rate * t + disc.breath_phase))
color = colors[disc.color % len(colors)]
gradient = QRadialGradient(cx, cy, radius)
for stop, alpha in self._stops(focus, base * breath):
gradient.setColorAt(stop, _with_alpha(color, alpha))
painter.setBrush(gradient)
painter.drawEllipse(QPointF(cx, cy), radius, radius)
painter.setRenderHint(QPainter.Antialiasing, False)
#: How many cells. Nine reads as a sparse field at 1080p; the density
#: control is there for anyone who wants a confluent one.
CELL_COUNT = 9
#: Body radius along the major axis, as a fraction of the short edge, and how
#: much shorter the minor axis is.
CELL_RADIUS = (0.07, 0.17)
CELL_FLATTEN = (0.55, 0.92)
#: Drift and turn. The turn is a rate in radians per second, signed, so half
#: of them go one way.
CELL_DRIFT = (0.03, 0.11)
CELL_DRIFT_PERIOD = (30.0, 85.0)
CELL_TURN = (0.010, 0.055)
#: The nucleus: radius as a share of the body's minor axis, how far off
#: centre it sits as a share of the major axis, and how much brighter it is.
CELL_NUCLEUS = (0.34, 0.52)
CELL_NUCLEUS_OFFSET = 0.28
CELL_NUCLEUS_GAIN = 1.9
#: The membrane: where the body's own gradient brightens again before it
#: fades, and by how much. Small — a membrane that reads as an outline turns
#: the field into clip art.
CELL_MEMBRANE = 0.86
CELL_MEMBRANE_GAIN = 1.45
CELL_ALPHA_DARK = 0.20
CELL_ALPHA_LIGHT = 0.32
#: Same reasoning as bokeh: there is a rim in here, so it wants more than a
#: pure gradient field does and much less than the aurora.
CELL_BUFFER_EDGE = 512
@dataclass
[docs]
class Cell:
"""One drifting cell, in normalised units."""
x: float
y: float
drift_x: float
drift_y: float
rate_x: float
rate_y: float
phase_x: float
phase_y: float
radius: float
flatten: float
angle: float
turn: float
nucleus: float
nucleus_angle: float
color: int
[docs]
class CellsEngine(_BufferedEngine):
"""Cells drifting through the field, turning as they go.
:meth:`geometry` yields ``(cx, cy, major, minor, angle)`` per cell, in
pixels and radians.
"""
name = "cells"
base_edge = CELL_BUFFER_EDGE
def _configure(self, rng: random.Random) -> None:
"""Roll this theme's constants from the seed.
ONCE, at construction: an engine is deterministic, so the same seed and
the same call sequence always produce the same animation.
"""
cols, rows = 4, 3
cells = list(range(cols * rows))
rng.shuffle(cells)
self.cells: List[Cell] = []
for i in range(_pool_size(CELL_COUNT)):
cell = cells[i % len(cells)]
col, row = cell % cols, cell // cols
self.cells.append(Cell(
x=(col + 0.15 + 0.7 * rng.random()) / cols,
y=(row + 0.15 + 0.7 * rng.random()) / rows,
drift_x=rng.uniform(*CELL_DRIFT),
drift_y=rng.uniform(*CELL_DRIFT),
rate_x=2 * math.pi / rng.uniform(*CELL_DRIFT_PERIOD),
rate_y=2 * math.pi / rng.uniform(*CELL_DRIFT_PERIOD),
phase_x=rng.uniform(0.0, 2 * math.pi),
phase_y=rng.uniform(0.0, 2 * math.pi),
radius=rng.uniform(*CELL_RADIUS),
flatten=rng.uniform(*CELL_FLATTEN),
angle=rng.uniform(0.0, 2 * math.pi),
turn=rng.choice((-1.0, 1.0)) * rng.uniform(*CELL_TURN),
nucleus=rng.uniform(*CELL_NUCLEUS),
nucleus_angle=rng.uniform(0.0, 2 * math.pi),
color=i,
))
[docs]
def count(self) -> int:
"""How many cells are painted right now."""
return self.element_count(CELL_COUNT, len(self.cells))
[docs]
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""The shapes to draw at the current time, for a widget this size.
GEOMETRY, NOT PAINTING, so the layout can be computed on a worker
thread and tested without a QPainter -- an engine owns no widget and
no timer, which is what makes the animation deterministic.
:param width: the widget's width in pixels.
:param height: its height in pixels.
:returns: one tuple per shape, in draw order.
"""
t = self.time
short = min(width, height)
out = []
for cell in self.cells[:self.count()]:
cx = (cell.x + cell.drift_x
* math.sin(cell.rate_x * t + cell.phase_x)) * width
cy = (cell.y + cell.drift_y
* math.sin(cell.rate_y * t + cell.phase_y)) * height
major = max(1.0, cell.radius * short * self.size)
out.append((cx, cy, major, major * cell.flatten,
cell.angle + cell.turn * t))
return tuple(out)
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Draw one frame's field of shapes.
:param painter: the painter to draw with.
:param width: the widget's width in pixels.
:param height: its height in pixels.
"""
painter.setRenderHint(QPainter.Antialiasing, True)
peak = (CELL_ALPHA_DARK if self.dark else CELL_ALPHA_LIGHT) \
* self.alpha_scale()
colors = self.paint_colors
for cell, (cx, cy, major, minor, angle) in zip(
self.cells, self.geometry(width, height)):
color = colors[cell.color % len(colors)]
painter.save()
painter.translate(cx, cy)
painter.rotate(math.degrees(angle))
body = QRadialGradient(0.0, 0.0, major)
body.setColorAt(0.0, _with_alpha(color, peak * 0.55))
body.setColorAt(0.62, _with_alpha(color, peak * 0.72))
body.setColorAt(CELL_MEMBRANE,
_with_alpha(color, peak * CELL_MEMBRANE_GAIN))
body.setColorAt(0.97, _with_alpha(color, peak * 0.30))
body.setColorAt(1.0, _with_alpha(color, 0.0))
painter.setBrush(body)
painter.save()
painter.scale(1.0, minor / major)
painter.drawEllipse(QPointF(0.0, 0.0), major, major)
painter.restore()
offset = CELL_NUCLEUS_OFFSET * major
nx = offset * math.cos(cell.nucleus_angle)
ny = offset * math.sin(cell.nucleus_angle) * (minor / major)
radius = max(1.0, cell.nucleus * minor)
nucleus = QRadialGradient(nx, ny, radius)
nucleus.setColorAt(0.0,
_with_alpha(color, peak * CELL_NUCLEUS_GAIN))
nucleus.setColorAt(0.6,
_with_alpha(color, peak * CELL_NUCLEUS_GAIN
* 0.6))
nucleus.setColorAt(1.0, _with_alpha(color, 0.0))
painter.setBrush(nucleus)
painter.drawEllipse(QPointF(nx, ny), radius, radius)
painter.restore()
painter.setRenderHint(QPainter.Antialiasing, False)
#: Longest buffer edge the fractal shades into at resolution 1.0, before
#: the guard.
#:
#: ABOVE the diffuse themes' :data:`BUFFER_MAX_EDGE`, and it is the one
#: theme in the module that has to be. A blob is a gradient and has no
#: detail to lose to a small buffer; a fractal carries structure at every
#: scale, so the buffer's edge is visible in it as coarseness — which is the
#: whole of "the pattern needs to be higher resolution".
#:
#: 448 puts a 1920-wide canvas on a 384x216 buffer, four times the pixels
#: the edge below it shaded, and a 3840-wide one on 426x240. THE NUMBER IS
#: AN ASK, NOT A PROMISE: an escape-time field is shaded per pixel in NumPy,
#: so four times the pixels is four times the frame, and what is actually
#: allocated is this trimmed by :meth:`FractalEngine.afford` until the pass
#: fits :data:`FRACTAL_FRAME_SHARE`. On a machine that cannot pay for 448 it
#: settles lower on its own, which is what the request asks the guard to do
#: rather than stuttering.
FRACTAL_BUFFER_EDGE = 448
#: How many times the map is iterated per pixel.
#:
#: This is the fractal's *element count*, and it is what the density control
#: scales — which is the honest reading of that control here, because the
#: cost of a frame really is (buffer pixels) x (iterations). That is exactly
#: the shape :attr:`AmbientEngine.work` already assumes: resolution enters
#: squared, the element count enters linearly, and :data:`WORK_BUDGET`
#: therefore bounds this engine without a line of its own.
#:
#: Twenty is where the Julia boundary stops gaining structure at this buffer
#: size: the filaments a 40th iteration resolves are thinner than the ten
#: screen pixels each buffer pixel is stretched over on a 1080p canvas.
FRACTAL_ITERATIONS = 20
#: ``|z|^2`` past which the orbit is considered gone. 4.0 is |z| > 2, the
#: standard escape radius for ``z^2 + c``.
FRACTAL_BAILOUT = 4.0
#: Half the height of the view, in units of the complex plane, at size 1.0.
#: The interesting part of every Julia set in this family fits inside |z| < 2.
FRACTAL_SPAN = 1.6
#: Seconds for the Julia constant to travel once around the cardioid, and
#: how far inside it the constant is held, oscillating on its own period.
#:
#: The path is the boundary of the Mandelbrot set's main cardioid, pulled a
#: little way in toward the origin. That is not decoration: *on* the boundary
#: the Julia set is a dendrite with no interior, and outside it the set falls
#: apart into dust. Just inside, it is connected and filled for every angle,
#: so the animation morphs continuously through the whole family instead of
#: passing through stretches where there is nothing on screen.
FRACTAL_C_PERIOD = 96.0
FRACTAL_INSET = (0.035, 0.16)
FRACTAL_INSET_PERIOD = 41.0
#: Seconds per turn of the view, and the slow zoom: a swing of this fraction
#: of the span, on this period. Both are long, for the reason the blob drift
#: is long — a backdrop must never look like it is *moving*, only like it has
#: moved when you look back at it.
FRACTAL_SPIN_PERIOD = 240.0
FRACTAL_BREATH = 0.16
FRACTAL_BREATH_PERIOD = 57.0
#: How many iterations of escape one full traversal of the palette is worth,
#: and how many seconds one full scroll of those bands takes.
#:
#: The colour index is CYCLIC — ``survived`` modulo the ring rather than
#: ``survived`` divided by the iteration count — and that is what makes the
#: picture read as a rainbow instead of as one purple wash. The escape count
#: is violently skewed: at this view most of the canvas is gone inside two
#: iterations and the whole 0..20 range lives in a thin collar around the
#: set, so a palette stretched linearly over it spends 90 % of the screen on
#: one colour. Wrapped instead, every band of the collar is a different
#: colour AND the wide outer field gets its own sweep, which is what the
#: fractional first iteration below is for.
FRACTAL_ITERS_PER_CYCLE = 3.0
FRACTAL_CYCLE_PERIOD = 14.0
#: ``|z|^2`` at which an iteration stops contributing anything.
#:
#: Each iteration is worth a FRACTION of a count rather than a yes/no: one
#: while the orbit is inside :data:`FRACTAL_BAILOUT`, ramping to zero as it
#: passes this. That makes the escape field a continuous function of the
#: pixel, and continuity is not a nicety here — the buffer is upscaled
#: tenfold on a 1080p canvas, so an integer count puts a hard step every
#: ten screen pixels and the backdrop reads as a contour map with visibly
#: stepped edges. It also gives the far field, which at this view is most of
#: the canvas and escapes before the map has been applied at all, a real
#: gradient instead of one flat value.
FRACTAL_SOFT_LIMIT = 16.0
#: How far ``z`` is allowed to run before it is clamped, in each component.
#: Big enough to be far outside any escape test and small enough that its
#: square, and the sum of two of them, stay inside float32 by twenty orders
#: of magnitude.
FRACTAL_REACH = 1.0e9
#: What is left of the light inside the set. It is a solid region and would
#: otherwise be the loudest single shape on the screen; at a fifth of the
#: peak it reads as the silhouette it is.
FRACTAL_CORE = 0.20
#: Peak alpha of the field. Light needs more than dark for the reason every
#: other theme here does: the colour has been mixed toward white before it is
#: multiplied.
#:
#: Lower than the other themes' peaks look on the dark page, and it has to
#: be: those draw discrete shapes over a mostly-empty page, and this covers
#: every pixel of it. What reaches the eye is the whole canvas at this
#: alpha, not a few blobs at it.
FRACTAL_ALPHA_DARK = 0.30
FRACTAL_ALPHA_LIGHT = 0.40
#: How far a palette colour is mixed toward white before it is MULTIPLIED
#: onto a light page — this theme's own figure, well below the shared
#: :data:`LIGHT_TINT`.
#:
#: That constant is 0.55 because undiluted saturated hues multiply to
#: something muddy when shapes OVERLAP, and every other theme here is made
#: of overlapping shapes. This one is a single field with no overlap at all,
#: so most of that dilution buys nothing and costs the colour: measured on a
#: real frame over the dressed light page, 0.55 left every hue within
#: thirteen levels of neutral and the rainbow rendered as four neighbouring
#: pinks.
#:
#: This pair is where the sweep of (tint, alpha) lands with colour AND
#: headroom. The bar was the shipped default: text over the worst
#: text-line-sized region of a `blobs` frame on the same page clears its
#: WCAG minimum by a factor of 0.654, and this clears it by 0.689 while
#: carrying seven of the twelve hue families instead of four.
FRACTAL_LIGHT_TINT = 0.40
#: Entries in the colour table a frame is looked up through, and the last
#: one, which is reserved.
#:
#: 256 so the table is indexed by a ``uint8`` and the whole per-pixel
#: colouring pass is one cast and one gather. Entries 0..254 are the colour
#: ring; 255 is the *interior* of the set, which is why the ring is 255 long
#: and not 256 — the wrapped index can never land on it by accident.
FRACTAL_LEVELS = 256
FRACTAL_INTERIOR_LEVEL = 255
#: The wander's octaves, as ``(seconds per cell, weight)``.
#:
#: WHY THIS IS NOT A LOOP, in the strong sense rather than the "very long
#: period" sense. Each octave is a value hashed from ``(seed, channel, cell
#: number)`` and smoothed across the cell boundary, and the cell number
#: counts up forever — so the sequence is not a cycle at all and there is no
#: period to find, however long a watcher looks. Three octaves an octave and
#: a half apart give the shape a state should have: a slow tide, a swell on
#: it, and a little chop on that.
FRACTAL_STATE_OCTAVES: Tuple[Tuple[float, float], ...] = (
(73.0, 1.00), (29.0, 0.52), (11.0, 0.24))
#: The four states, as channel numbers into the wander.
#:
#: They are separate channels rather than one number because they have to be
#: able to disagree: deep and quiet, flat and busy, and every other corner.
#: One shared state would make the picture pass through the same handful of
#: looks, which is the fault this whole block exists to avoid.
FRACTAL_STATE_DEPTH = 0
FRACTAL_STATE_BUSY = 1
FRACTAL_STATE_PACE = 2
FRACTAL_STATE_CROWD = 3
#: The vortex, from flat to deep, as the ``depth`` state runs 0 to 1.
#:
#: TWO TERMS, and it takes both to read as a tunnel rather than as a spiral
#: drawn on a wall:
#:
#: * :data:`FRACTAL_SWIRL` twists the *sampling*, in radians per e-fold of
#: radius. It is a conformal map — the picture is turned by an amount that
#: grows as the centre is approached — so the fractal's own detail is not
#: distorted, it is wound into a spiral that converges at the middle. At
#: 0.0 it is exactly the plain rotation the engine had before, which is
#: what makes "sometimes not" free.
#: * :data:`FRACTAL_TUNNEL` puts the colour bands on the *log* of the
#: radius, in full traversals of the palette per e-fold. Bands spaced
#: geometrically are what perspective does to evenly spaced rings, and
#: scrolling them (:data:`FRACTAL_TUNNEL_RATE`) is what makes them travel
#: down it. This is the term that supplies the depth; the swirl supplies
#: the turning.
FRACTAL_SWIRL = (0.0, 1.25)
FRACTAL_TUNNEL = (0.0, 2.6)
#: Where the view is, and how wide, at the two ends of the depth state.
#:
#: THE DEEP STATE IS A DIFFERENT VIEW, and it has to be. A spiral twist
#: alone does not make a tunnel out of this picture, because at
#: :data:`FRACTAL_SPAN` the middle of the screen is the filled INTERIOR of
#: the set — a flat silhouette with no structure in it, and a vortex needs
#: something to converge into. So as the depth rises the view slides onto
#: the set's REPELLING FIXED POINT and closes in on it.
#:
#: That point is not a taste: ``beta = (1 + sqrt(1 - 4c)) / 2`` is on the
#: Julia set for every ``c``, and the set is asymptotically SELF-SIMILAR
#: around it — the map is locally ``z -> lambda(z - beta) + beta`` with
#: ``lambda = 2*beta``, so the structure repeats at a fixed ratio of scale
#: with a fixed turn between the copies. That is a logarithmic spiral of
#: fractals, drawn by the mathematics rather than by the shader, and
#: closing in on it is looking down it.
#:
#: THE VIEW IS ALWAYS ON THAT POINT and only the SPAN moves. Sliding the
#: centre with the depth was tried first and is worse: at half depth the
#: middle of the screen sits between the origin and ``beta``, which is
#: inside the filled set, so the closer the view got the more of the screen
#: was one flat silhouette. Anchored on the boundary the middle of the
#: picture always has structure in it, and the depth is free to be purely
#: how far INTO that structure the view is.
#:
#: At depth zero the span is the whole set and the swirl is nothing, which
#: is the "and sometimes not" half of the request.
FRACTAL_SPAN_DEEP = 0.16
#: How far along the way to that point the view is centred, so that the
#: middle of the screen is ON the boundary rather than a little inside it.
#: One, because the point itself is the boundary — the fraction is here to
#: be turned down if a future palette makes the exact point read badly.
FRACTAL_ORIGIN_DEEP = 1.0
#: The window of the raw wander that the depth state actually spends, and
#: what is outside it.
#:
#: A sum of smoothed noise is a bell: left alone it spends nearly all its
#: time in the middle of its range, so the picture would always be half
#: deep and would never be either thing. Stretching the middle two thirds
#: over the whole range and clamping the tails gives what "STATES it moves
#: between" means — real time spent flat, real time spent deep, and a
#: gradual transition between them rather than a permanent average.
FRACTAL_DEPTH_WINDOW = (0.30, 0.68)
#: E-folds of radius the tunnel's rings travel per second, before the pace
#: state. Slow: the rings should be noticed to have moved, not watched
#: moving.
FRACTAL_TUNNEL_RATE = 0.045
#: How fast the vortex turns, in turns per second, from the flattest state to
#: the deepest. Added to the view's own :data:`FRACTAL_SPIN_PERIOD`, which
#: goes on being the slow drift underneath.
FRACTAL_TURN = (0.004, 0.055)
#: How far the pace state may push a phase, in turns.
#:
#: THE PACE IS A PHASE OFFSET, NOT A RATE. A rate that wanders has to be
#: integrated to get a phase, and hashed noise has no closed-form integral —
#: so an engine that did it that way would have to accumulate, which is the
#: one thing this module cannot do (see the block above). A wandering offset
#: added to the phase gives the same thing at the eye: the apparent speed is
#: the base rate plus the offset's own slope, so the motion runs ahead,
#: falls back, stalls and briefly reverses, and never jumps.
FRACTAL_PACE_SWING = 0.35
#: The beat: base rate in beats per second, and the wander on that rate as
#: ``(share, period in seconds, phase in turns)``.
#:
#: Thirty-seven a minute at rest. The wander is applied to the RATE and the
#: phase is its integral in closed form — ``∫ R(1 + Σ a cos(2πt/P)) dt`` —
#: which is what lets the beat speed up and slow down while its phase stays
#: continuous through every change of speed. A heart does not skip when it
#: quickens, and neither does this.
FRACTAL_BEAT_RATE = 0.62
FRACTAL_BEAT_WANDER: Tuple[Tuple[float, float, float], ...] = (
(0.34, 41.0, 0.31), (0.19, 17.3, 0.77))
#: The beat's shape, as ``(position in the cycle, width, height)``.
#:
#: TWO THUMPS, not a sine. A sine spends as long rising as falling and reads
#: as breathing; a beat is a short strike, a shorter second one, and then
#: nothing until the next. The second thump at a little over half the first
#: is the systole/diastole shape, and it is the thing that makes a pulse
#: recognisable as a pulse.
FRACTAL_BEAT_THUMP: Tuple[Tuple[float, float, float], ...] = (
(0.00, 0.052, 1.00), (0.20, 0.080, 0.55))
#: What the beat moves: the zoom, as a share of the span, and the light, as a
#: share of the peak alpha it is allowed to take AWAY.
#:
#: The alpha only ever dips. The peak is where every readability measurement
#: of this backdrop was taken (see :data:`FRACTAL_ALPHA_DARK`), so a beat
#: that brightened past it would be quietly raising the ceiling those were
#: made against; a beat that darkens from it cannot.
FRACTAL_BEAT_ZOOM = 0.055
FRACTAL_BEAT_DIP = 0.22
#: How much the busy state moves the iteration count, as a multiplier range.
#: Below one is a smoother, softer field; above it, finer filaments. It rides
#: on top of the density control rather than replacing it.
FRACTAL_BUSY = (0.55, 1.45)
#: Extra iterations at full depth, as a share of the count.
#:
#: NOT DECORATION. The escape count is what resolves the boundary, and how
#: fine the boundary is depends on how far in the view is: at
#: :data:`FRACTAL_SPAN` twenty iterations resolve everything there is to
#: see, and at :data:`FRACTAL_SPAN_DEEP` they leave the spiral as a flat
#: wash because the orbits that separate its arms have not been given long
#: enough to separate. So the count follows the depth. It costs what it
#: costs and the guard is what pays for it.
FRACTAL_DEPTH_ITERS = 0.85
#: The iteration pool, which is what the density control paints a prefix of.
#:
#: :func:`_pool_size` sizes a pool for the top of :data:`DENSITY_RANGE` and
#: nothing else, which was right when the count was a constant. It is not
#: now: the busy state and the depth both multiply it before the density
#: does, and a pool that ignored them would clamp — a user at density 3.0
#: would get the same count as one at 2.0 whenever the picture happened to
#: be deep, which is a control that silently stops working.
FRACTAL_ITERATION_POOL = _pool_size(
int(math.ceil(FRACTAL_ITERATIONS * FRACTAL_BUSY[1]
* (1.0 + FRACTAL_DEPTH_ITERS))))
#: How many buds the engine can have in the air at once, and the seconds one
#: slot takes to come round.
#:
#: SLOTS RATHER THAN A LIST, because the bud population has to be a pure
#: function of the clock: slot ``k`` is on its own cycle, and which cycle it
#: is in — and therefore which bud, with which numbers — follows from the
#: time alone. The cycles are all different and mutually incommensurate, so
#: the population wanders between one form and six without ever repeating a
#: pattern of arrivals.
FRACTAL_BUD_SLOTS = 5
FRACTAL_BUD_CYCLE = (19.0, 47.0)
#: The share of its cycle a bud is alive for, and the chance a slot takes the
#: cycle at all.
#:
#: THE SKIP IS THE POINT. "Sometimes there is more happening than the thing
#: in the middle. Not always." A slot that always produced a bud would give a
#: population that breathed between four and six; one that may sit a cycle
#: out gives screens with one form on them, which is a state the picture is
#: supposed to pass through.
FRACTAL_BUD_LIFE = (0.30, 0.72)
FRACTAL_BUD_TAKES = 0.62
#: A bud's size and how far it travels, both as a share of the parent's rim.
#: It leaves at the rim and drifts outward for the rest of its life.
FRACTAL_BUD_RADIUS = (0.28, 0.62)
FRACTAL_BUD_TRAVEL = (0.35, 1.30)
#: Where the parent's rim is, as a share of half the buffer's short edge.
#: This is the circle a bud is born ON, which is what makes it read as having
#: separated from the form in the middle rather than faded in beside it.
FRACTAL_BUD_RIM = 0.62
#: How soft a bud's edge is, as a share of its radius.
#:
#: A BUBBLE, not a cut-out. The bud is the parent's own field re-sampled
#: through the bud's scale and vortex, and the two samplings are blended
#: across this band — so the rim refracts rather than clipping, which is what
#: the eye reads as a bubble sitting in front of the picture.
FRACTAL_BUD_FEATHER = 0.30
#: How much bigger a bud's scale is than its parent's, as a multiplier range
#: rolled per bud. Under one is a magnifier: fewer units of the plane across
#: the bud, so it holds a close-up of the same set.
FRACTAL_BUD_ZOOM = (0.35, 1.6)
#: What one shading pass may cost, as a share of the frame interval at
#: :data:`DEFAULT_FPS`.
#:
#: 9 % is 3.75 ms of a 41.7 ms frame. Chosen against what the backdrop is:
#: it runs on :class:`_FrameProducer` rather than the GUI thread, so the cost
#: is a share of one core and never a stutter — but it is Python holding the
#: interpreter lock in bursts, and a job running in the same process feels
#: that. The aurora, the most expensive theme on the Animation menu, measures
#: 1.3-2.6 ms; this engine is allowed rather more because a fractal carries
#: detail at every scale and is the one thing in the module that shows the
#: buffer's edge.
FRACTAL_FRAME_SHARE = 0.09
#: How far the guard may trim the buffer, as a share of its pixels.
#:
#: 0.15 of the pixels is 0.39 of the edge — 175 px on the 448 asked for,
#: which is where this engine was before the resolution was raised and is
#: therefore a floor with a measurement behind it rather than a guess. Below
#: that the answer is not a smaller buffer, it is a slower machine that
#: should turn the Animation preference down.
FRACTAL_AFFORD_FLOOR = 0.15
#: How fast the guard gives ground and how slowly it takes it back, as a
#: share of the gap closed per second of animation.
#:
#: ASYMMETRIC ON PURPOSE. An overrun is evidence about the machine and is
#: acted on quickly; an underrun may just be a quiet moment, and climbing
#: back at the same speed would make the buffer size oscillate — which is
#: visible, because the whole picture resamples when it changes.
FRACTAL_AFFORD_EASE = (6.0, 0.12)
def _hash01(salt: int, channel: int, index: int) -> float:
"""A repeatable 0..1 value for ``(salt, channel, index)``.
Integer avalanche rather than :mod:`random`, because it is called with a
cell number that counts up forever and has to answer for any of them
without keeping a sequence — which is exactly what makes the state
machine above a pure function of the clock.
"""
value = (salt * 0x9E3779B1) ^ (channel * 0x85EBCA77) ^ (index * 0xC2B2AE3D)
value &= 0xFFFFFFFF
value ^= value >> 15
value = (value * 0x2C1B3C6D) & 0xFFFFFFFF
value ^= value >> 12
value = (value * 0x297A2D39) & 0xFFFFFFFF
value ^= value >> 15
return value / 4294967296.0
def _lerp(span: Tuple[float, float], amount: float) -> float:
"""``span[0]`` at 0, ``span[1]`` at 1, clamped."""
amount = _clamp(float(amount), 0.0, 1.0)
return span[0] + (span[1] - span[0]) * amount
[docs]
class FractalEngine(_BufferedEngine):
"""Render a deterministic, animated Julia-set field.
The engine evaluates ``z <- z**2 + c`` for a primary form and optional
derived buds. Hashed continuous state controls depth, density, motion,
and population as a pure function of seed and animation time. Render cost
is measured between frames and used to reduce buffer resolution and bud
count when necessary. :meth:`geometry` returns the primary
:class:`Form` first.
"""
name = "fractal"
base_edge = FRACTAL_BUFFER_EDGE
def _tint(self, color: QColor) -> QColor:
"""The colour as painted. Overridden for the light page only — see
:data:`FRACTAL_LIGHT_TINT`."""
return QColor(color) if self.dark \
else _mix(color, QColor(255, 255, 255), FRACTAL_LIGHT_TINT)
def _configure(self, rng: random.Random) -> None:
"""Roll this theme's constants from the seed.
ONCE, at construction: an engine is deterministic, so the same seed and
the same call sequence always produce the same animation.
"""
self._phase_c = rng.random()
self._phase_inset = rng.random()
self._phase_spin = rng.random()
self._phase_breath = rng.random()
self._phase_cycle = rng.random()
self._phase_beat = rng.random()
self._spin = 1.0 if rng.random() < 0.5 else -1.0
#: What the wander and the bud schedule are hashed from. An int
#: rather than the seed itself because the seed may be None.
self._salt = rng.getrandbits(30) or 1
#: Polar grids, keyed by the buffer size they were built for.
#: Rebuilt on a resize and never per frame.
self._plane: Optional[Tuple[object, object, object]] = None
self._plane_size: Tuple[int, int] = (0, 0)
#: The 0..1 ramp the colour table is built over. Constant, so it
#: is built once and reused.
self._ring = None
#: What share of the asked-for buffer this machine can afford, and
#: the shading costs measured since the last :meth:`advance`.
self._afford = 1.0
self._spent: List[float] = []
[docs]
def wander(self, channel: int, at: Optional[float] = None) -> float:
"""Return continuous deterministic state noise for one channel.
:param channel: Independent state-channel index.
:param at: Animation time in seconds. ``None`` uses the engine clock.
:returns: Value in ``[0, 1]``.
"""
moment = self.time if at is None else float(at)
total = 0.0
weights = 0.0
for octave, (cell, weight) in enumerate(FRACTAL_STATE_OCTAVES):
seat = channel * 8 + octave
position = moment / cell + _hash01(self._salt, seat, -1)
index = math.floor(position)
fraction = position - index
low = _hash01(self._salt, seat, int(index))
high = _hash01(self._salt, seat, int(index) + 1)
ease = fraction * fraction * (3.0 - 2.0 * fraction)
total += weight * (low + (high - low) * ease)
weights += weight
return total / weights
[docs]
def swing(self, channel: int) -> float:
"""Return :meth:`wander` for ``channel`` mapped to ``[-1, 1]``."""
return 2.0 * self.wander(channel) - 1.0
[docs]
def depth(self) -> float:
"""Return the smoothed vortex-depth state in ``[0, 1]``."""
low, high = FRACTAL_DEPTH_WINDOW
raw = _clamp((self.wander(FRACTAL_STATE_DEPTH) - low)
/ max(1e-6, high - low), 0.0, 1.0)
return raw * raw * (3.0 - 2.0 * raw)
[docs]
def beat_phase(self) -> float:
"""Return the continuously integrated beat phase in cycles."""
moment = self.time
phase = moment * FRACTAL_BEAT_RATE
for share, period, offset in FRACTAL_BEAT_WANDER:
phase += FRACTAL_BEAT_RATE * share * period / (2.0 * math.pi) * (
math.sin(2.0 * math.pi * (moment / period + offset))
- math.sin(2.0 * math.pi * offset))
return phase + self._phase_beat
[docs]
def beat(self) -> float:
"""Return beat intensity from zero between peaks to one at a peak."""
position = self.beat_phase() % 1.0
value = 0.0
for at, width, height in FRACTAL_BEAT_THUMP:
gap = ((position - at + 0.5) % 1.0) - 0.5
value += height * math.exp(-(gap / width) ** 2)
return _clamp(value, 0.0, 1.0)
[docs]
def frame_budget(self) -> float:
"""Return the target duration of one shading pass in milliseconds."""
return FRACTAL_FRAME_SHARE * 1000.0 / DEFAULT_FPS
[docs]
def afford(self) -> float:
"""Return the guarded render-capacity fraction in
``[FRACTAL_AFFORD_FLOOR, 1]``."""
return self._afford
[docs]
def advance(self, dt: float) -> None:
"""Advance the clock and adapt render capacity from recent costs."""
super().advance(dt)
spent, self._spent = self._spent, []
if not spent or dt <= 0:
return
over = max(spent) / max(0.001, self.frame_budget())
ease = FRACTAL_AFFORD_EASE[0] if over > 1.0 else FRACTAL_AFFORD_EASE[1]
step = _clamp(float(dt) * ease, 0.0, 1.0)
want = _clamp(self._afford / over, FRACTAL_AFFORD_FLOOR, 1.0)
self._afford = _clamp(self._afford + (want - self._afford) * step,
FRACTAL_AFFORD_FLOOR, 1.0)
[docs]
def resolution_edge(self) -> int:
"""Return the guarded maximum render-buffer edge in pixels."""
return _clamp_int(round(self.base_edge * self.resolution
* math.sqrt(self._afford)),
BUFFER_MIN_EDGE, BUFFER_EDGE_CEILING)
[docs]
def iterations(self) -> int:
"""Return the guarded Julia-map iteration count for this frame."""
busy = _lerp(FRACTAL_BUSY, self.wander(FRACTAL_STATE_BUSY))
busy *= 1.0 + FRACTAL_DEPTH_ITERS * self.depth()
want = max(1, int(round(FRACTAL_ITERATIONS * busy)))
return self.element_count(want, FRACTAL_ITERATION_POOL)
[docs]
def constant(self) -> Tuple[float, float]:
"""Return the current Julia-set constant as ``(real, imaginary)``.
The constant follows an inset path along the main cardioid, with
continuous pace modulation.
"""
travel = (self.time / FRACTAL_C_PERIOD + self._phase_c
+ FRACTAL_PACE_SWING * self.swing(FRACTAL_STATE_PACE))
theta = 2.0 * math.pi * travel
swing = 0.5 * (1.0 - math.cos(
2.0 * math.pi * (self.time / FRACTAL_INSET_PERIOD
+ self._phase_inset)))
inside = 1.0 - (FRACTAL_INSET[0]
+ swing * (FRACTAL_INSET[1] - FRACTAL_INSET[0]))
return (inside * (0.5 * math.cos(theta) - 0.25 * math.cos(2 * theta)),
inside * (0.5 * math.sin(theta) - 0.25 * math.sin(2 * theta)))
[docs]
def fixed_point(self) -> Tuple[float, float]:
"""Return the repelling fixed point of the current Julia set.
The selected root is ``(1 + sqrt(1 - 4c)) / 2`` with
``|2 * beta| > 1``.
"""
c_re, c_im = self.constant()
real, imaginary = 1.0 - 4.0 * c_re, -4.0 * c_im
modulus = math.hypot(real, imaginary)
root_re = math.sqrt(max(0.0, (modulus + real) / 2.0))
root_im = math.copysign(math.sqrt(max(0.0, (modulus - real) / 2.0)),
imaginary or 1.0)
return ((1.0 + root_re) / 2.0, root_im / 2.0)
[docs]
def view(self, height: int) -> Tuple[float, float, float]:
"""Return ``(origin_re, origin_im, scale)`` for the primary form."""
deep = self.depth()
breath = 1.0 + FRACTAL_BREATH * math.sin(
2.0 * math.pi * (self.time / FRACTAL_BREATH_PERIOD
+ self._phase_breath))
breath *= 1.0 - FRACTAL_BEAT_ZOOM * self.beat()
span = (_lerp((FRACTAL_SPAN, FRACTAL_SPAN_DEEP), deep) * breath
/ max(0.01, self.size))
beta_re, beta_im = self.fixed_point()
return (beta_re * FRACTAL_ORIGIN_DEEP, beta_im * FRACTAL_ORIGIN_DEEP,
max(1.0, height / 2.0) / span)
[docs]
def bud_slots(self) -> int:
"""Return the number of bud slots allowed by state and render cost."""
room = int(round(FRACTAL_BUD_SLOTS * self._afford))
crowd = self.wander(FRACTAL_STATE_CROWD)
return _clamp_int(int(round(FRACTAL_BUD_SLOTS * crowd)), 0, room)
[docs]
def buds(self, width: int, height: int) -> Tuple[Form, ...]:
"""Return active bud geometries for a render-buffer size."""
if width <= 0 or height <= 0:
return ()
eligible = self.bud_slots()
if eligible <= 0:
return ()
rim = FRACTAL_BUD_RIM * min(width, height) / 2.0
centre = (width / 2.0, height / 2.0)
turn = self.turn()
c_re, c_im = self.constant()
iterations = self.iterations()
deep = self.depth()
beta_re, beta_im = self.fixed_point()
out: List[Form] = []
for slot in range(eligible):
cycle = _lerp(FRACTAL_BUD_CYCLE,
_hash01(self._salt, 100 + slot, 0))
position = self.time / cycle + _hash01(self._salt, 100 + slot, 1)
number = int(math.floor(position))
through = position - number
if _hash01(self._salt, 200 + slot, number) > FRACTAL_BUD_TAKES:
continue
life = _lerp(FRACTAL_BUD_LIFE,
_hash01(self._salt, 300 + slot, number))
if through >= life:
continue
age = through / life
heading = 2.0 * math.pi * _hash01(self._salt, 400 + slot, number)
travel = _lerp(FRACTAL_BUD_TRAVEL,
_hash01(self._salt, 500 + slot, number))
away = rim + rim * travel * age * age * (3.0 - 2.0 * age)
bulge = math.sin(math.pi * age) ** 0.65
radius = (rim * _lerp(FRACTAL_BUD_RADIUS,
_hash01(self._salt, 600 + slot, number))
* bulge)
if radius < 2.0:
continue
zoom = _lerp(FRACTAL_BUD_ZOOM,
_hash01(self._salt, 700 + slot, number))
spin = (1.0 if _hash01(self._salt, 800 + slot, number) < 0.5
else -1.0)
out.append(Form(
cx=centre[0] + away * math.cos(heading),
cy=centre[1] + away * math.sin(heading),
scale=max(1.0, radius / (FRACTAL_SPAN_DEEP * zoom)),
angle=heading + spin * 2.0 * math.pi * turn
* (1.0 + 2.0 * _hash01(self._salt, 900 + slot, number)),
c_re=c_re, c_im=c_im, iterations=iterations,
origin_re=beta_re * FRACTAL_ORIGIN_DEEP,
origin_im=beta_im * FRACTAL_ORIGIN_DEEP,
swirl=spin * _lerp(FRACTAL_SWIRL, max(deep, 0.5)),
tunnel=_lerp(FRACTAL_TUNNEL, max(deep, 0.5)),
scroll=-spin * FRACTAL_TUNNEL_RATE * turn * 8.0,
radius=radius, age=age, bud=True))
return tuple(out)
[docs]
def turn(self) -> float:
"""Return the current vortex rotation in turns."""
rate = _lerp(FRACTAL_TURN, self.depth())
return (self.time / FRACTAL_SPIN_PERIOD + self._phase_spin
+ self.time * rate
+ FRACTAL_PACE_SWING * self.swing(FRACTAL_STATE_PACE))
[docs]
def geometry(self, width: int, height: int) -> Tuple[Form, ...]:
"""Return frame geometries with the primary form first."""
deep = self.depth()
origin_re, origin_im, scale = self.view(height)
turn = self.turn()
c_re, c_im = self.constant()
main = Form(cx=width / 2.0, cy=height / 2.0, scale=scale,
angle=self._spin * 2.0 * math.pi * turn,
c_re=c_re, c_im=c_im, iterations=self.iterations(),
origin_re=origin_re, origin_im=origin_im,
swirl=self._spin * _lerp(FRACTAL_SWIRL, deep),
tunnel=_lerp(FRACTAL_TUNNEL, deep),
scroll=-self._spin * FRACTAL_TUNNEL_RATE * self.time,
radius=FRACTAL_BUD_RIM * min(width, height) / 2.0,
age=0.0, bud=False)
return (main,) + self.buds(width, height)
def _resize(self) -> None:
"""Nothing is cached by the size setting — the span is recomputed
every frame — but the grid is cached by the *buffer* size, and that
is what this hook's siblings drop."""
def _reresolve(self) -> None:
"""Rebuild the buffers after a resolution change."""
super()._reresolve()
self._plane = None
self._plane_size = (0, 0)
def _grid(self, width: int, height: int):
"""The polar grids the sampling is built from, as three float32
arrays: radius in buffer pixels, its natural log, and the angle.
POLAR RATHER THAN CARTESIAN, and cached, because the vortex is a
function of the radius and the angle and of nothing else: with these
in hand a frame's whole mapping is two multiply-adds and one
``cos``/``sin`` pair. Built on the first frame and on a resize,
never per frame.
"""
np = _numpy()
if self._plane is None or self._plane_size != (width, height):
rows, cols = np.mgrid[0:height, 0:width]
dx = (cols + 0.5 - width / 2.0).astype(np.float32)
dy = (rows + 0.5 - height / 2.0).astype(np.float32)
radius = np.hypot(dx, dy).astype(np.float32)
self._plane = (
np.ascontiguousarray(radius),
np.ascontiguousarray(np.log(radius + np.float32(1.0)),
dtype=np.float32),
np.ascontiguousarray(np.arctan2(dy, dx), dtype=np.float32))
self._plane_size = (width, height)
return self._plane
def _sample(self, form: "Form", radius, log_radius, angle):
"""``(zr, zi, bands)`` for one form, over whatever grids it is given.
THE VORTEX IS HERE, and it is two lines of it. ``angle + swirl *
log(radius)`` is a conformal log-spiral map: the picture is turned by
an amount that grows without bound as the centre is approached, so
the fractal's own structure winds into a spiral that never stops
converging — a tunnel, and one that turns as ``angle`` advances.
``bands`` puts the colour on the same log, so the rings are spaced
the way perspective spaces evenly spaced rings and travel down the
tunnel as ``scroll`` moves.
At ``swirl`` and ``tunnel`` zero this is exactly the plain rotation
the engine had before, which is what makes "sometimes not" cost
nothing.
"""
np = _numpy()
theta = angle + np.float32(form.angle)
if form.swirl:
theta = theta + log_radius * np.float32(form.swirl)
rho = radius * np.float32(1.0 / form.scale)
bands = None
if form.tunnel:
bands = ((log_radius + np.float32(form.scroll))
* np.float32(form.tunnel * FRACTAL_INTERIOR_LEVEL))
return (rho * np.cos(theta) + np.float32(form.origin_re),
rho * np.sin(theta) + np.float32(form.origin_im),
bands)
def _impress(self, form: "Form", zr, zi, bands, width: int, height: int):
"""Blend one bud's sampling into the main form's, and return
``bands`` — which the bud may have had to create.
THE RIM IS A BLEND, NOT A CLIP. The bud is the same field sampled
through its own scale and vortex, and the two samplings are crossed
over :data:`FRACTAL_BUD_FEATHER` of its radius, so the edge refracts
the way the edge of a bubble does. A hard swap gives a sticker.
Only the bud's own window is touched, so a bud costs its own area
and not the buffer's.
"""
np = _numpy()
reach = form.radius * (1.0 + FRACTAL_BUD_FEATHER)
left = max(0, int(math.floor(form.cx - reach)))
right = min(width, int(math.ceil(form.cx + reach)) + 1)
top = max(0, int(math.floor(form.cy - reach)))
bottom = min(height, int(math.ceil(form.cy + reach)) + 1)
if right - left < 2 or bottom - top < 2:
return bands
cols = (np.arange(left, right, dtype=np.float32)
+ np.float32(0.5 - form.cx))[None, :]
rows = (np.arange(top, bottom, dtype=np.float32)
+ np.float32(0.5 - form.cy))[:, None]
radius = np.hypot(cols, rows).astype(np.float32)
share = np.clip((reach - radius)
/ np.float32(max(1e-3, reach - form.radius)),
0.0, 1.0)
share = share * share * (np.float32(3.0)
- np.float32(2.0) * share)
share = share.astype(np.float32)
if not share.any():
return bands
keep = np.float32(1.0) - share
log_radius = np.log(radius + np.float32(1.0)).astype(np.float32)
angle = np.arctan2(rows, cols).astype(np.float32)
bud_zr, bud_zi, bud_bands = self._sample(form, radius, log_radius,
angle)
window = (slice(top, bottom), slice(left, right))
zr[window] = zr[window] * keep + bud_zr * share
zi[window] = zi[window] * keep + bud_zi * share
if bud_bands is not None:
if bands is None:
bands = np.zeros_like(zr)
bands[window] = bands[window] * keep + bud_bands * share
elif bands is not None:
bands[window] = bands[window] * keep
return bands
def _colour_table(self):
"""The colour-index -> pixel table for this frame.
Everything that varies per pixel is decided here, over 256 entries,
so the per-pixel pass is one cast and one gather. Entries are packed
``0xffRRGGBB`` — what ``Format_RGB32`` holds — and are already
composed against :attr:`identity`, so blitting the result under
:attr:`mode` gives exactly what painting that colour at that alpha
onto the page would have given, additive or multiply alike.
Entries 0..254 are the palette ring, rotated by the scroll phase;
entry :data:`FRACTAL_INTERIOR_LEVEL` is the inside of the set, at
:data:`FRACTAL_CORE` of the peak.
THE BEAT IS APPLIED HERE, and only downward: it takes away up to
:data:`FRACTAL_BEAT_DIP` of the peak between thumps and gives it
back at one. The peak itself is where the readability of this
backdrop was measured, so a beat that brightened past it would be
raising the ceiling those measurements were made against.
"""
np = _numpy()
if self._ring is None:
self._ring = (np.arange(FRACTAL_INTERIOR_LEVEL, dtype=np.float32)
/ float(FRACTAL_INTERIOR_LEVEL))
ring = self._ring
peak = FRACTAL_ALPHA_DARK if self.dark else FRACTAL_ALPHA_LIGHT
peak *= 1.0 - FRACTAL_BEAT_DIP * (1.0 - self.beat())
colours = np.array([[c.red(), c.green(), c.blue()]
for c in self.paint_colors], dtype=np.float32)
count = colours.shape[0]
phase = self.time / FRACTAL_CYCLE_PERIOD + self._phase_cycle
position = ((ring + phase) % 1.0) * count
low = position.astype(np.int32) % count
high = (low + 1) % count
blend = (position - np.floor(position))[:, None]
wheel = colours[low] * (1.0 - blend) + colours[high] * blend
identity = np.float32(0.0 if self.dark else 255.0)
table = np.empty(FRACTAL_LEVELS, dtype=np.uint32)
table[:FRACTAL_INTERIOR_LEVEL] = self._pack(
identity + np.float32(peak) * (wheel - identity))
table[FRACTAL_INTERIOR_LEVEL] = self._pack(
identity + np.float32(peak * FRACTAL_CORE)
* (wheel[:1] - identity))[0]
return table
@staticmethod
def _pack(rgb):
"""``(n, 3)`` of 0..255 floats -> ``(n,)`` of ``0xffRRGGBB``."""
np = _numpy()
levels = np.clip(rgb, 0.0, 255.0).astype(np.uint32)
return (np.uint32(0xFF000000) | (levels[:, 0] << 16)
| (levels[:, 1] << 8) | levels[:, 2])
def _paint_field(self, painter: QPainter, width: int,
height: int) -> None:
"""Draw one frame's field of shapes.
:param painter: the painter to draw with.
:param width: the widget's width in pixels.
:param height: its height in pixels.
"""
np = _numpy()
started = time.perf_counter()
forms = self.geometry(width, height)
main = forms[0]
iterations = main.iterations
radius, log_radius, angle = self._grid(width, height)
zr, zi, bands = self._sample(main, radius, log_radius, angle)
for form in forms[1:]:
bands = self._impress(form, zr, zi, bands, width, height)
survived = np.zeros((height, width), dtype=np.float32)
magnitude = np.empty_like(zr)
cross = np.empty_like(zr)
share = np.empty_like(zr)
c_re32, c_im32 = np.float32(main.c_re), np.float32(main.c_im)
soft_top = np.float32(FRACTAL_SOFT_LIMIT)
soft_scale = np.float32(1.0 / (FRACTAL_SOFT_LIMIT - FRACTAL_BAILOUT))
reach = np.float32(FRACTAL_REACH)
for _ in range(iterations):
zr2 = zr * zr
zi2 = zi * zi
np.add(zr2, zi2, out=magnitude)
np.subtract(soft_top, magnitude, out=share)
share *= soft_scale
np.clip(share, 0.0, 1.0, out=share)
survived += share
np.multiply(zr, zi, out=cross)
cross *= np.float32(2.0)
cross += c_im32
np.subtract(zr2, zi2, out=zr)
zr += c_re32
zi = cross
np.clip(zr, -reach, reach, out=zr)
np.clip(zi, -reach, reach, out=zi)
interior = survived >= np.float32(iterations - 0.5)
survived *= np.float32(FRACTAL_INTERIOR_LEVEL
/ FRACTAL_ITERS_PER_CYCLE)
if bands is not None:
survived += bands
np.mod(survived, np.float32(FRACTAL_INTERIOR_LEVEL), out=survived)
survived[interior] = np.float32(FRACTAL_INTERIOR_LEVEL)
frame = self._colour_table()[survived.astype(np.uint8)]
painter.drawImage(0, 0, QImage(frame.data, width, height,
int(frame.strides[0]),
QImage.Format_RGB32))
self._spent.append((time.perf_counter() - started) * 1000.0)
if len(self._spent) > 16:
del self._spent[:-16]
#: Longest buffer edge the Resonance plate is shaded at, at resolution 1.0.
#: Higher than the diffuse fields because the picture is made of POINTS and
#: a point in a 240 px buffer is an eighth of the screen's width across.
#: At 1080p this gives a 320x180 buffer and a particle six screen pixels
#: wide, which is what a grain of sand ought to look like.
RESONANCE_EDGE = 360
#: Particles on the plate at density 1.0. Enough that the nodal lines are
#: continuous where they gather and the field still reads as individual
#: grains where it does not; the cost is linear and small, so the ceiling
#: here is legibility rather than time.
RESONANCE_PARTICLES = 1300
#: Edge of the lattice :func:`spacr.qt.resonance.lattice` is evaluated on.
#: 128 puts a lattice point every 0.8 % of the plate, which is finer than
#: one buffer pixel at every size measured.
RESONANCE_GRID = 128
#: Relaxation rounds per frame. Each is one Newton step onto the nodal
#: line; eight takes ``|w|`` from 0.203 to 0.013 on the reference figure,
#: and more only moves particles that have already arrived.
RESONANCE_ROUNDS = 8
#: What the plate is doing when nothing is playing, and how much it
#: breathes, and over what period. THE SILENT CASE IS THE DEFAULT CASE:
#: sound is off on a fresh install, so almost everybody who ever sees this
#: backdrop sees this and nothing else. It has to be worth looking at on
#: its own.
RESONANCE_IDLE = 0.30
RESONANCE_IDLE_SWELL = 0.12
RESONANCE_IDLE_PERIOD = 23.0
#: Seconds the idle plate spends on each entry of
#: :data:`spacr.qt.resonance.MODES`. Long, for the reason every period in
#: this module is long: a backdrop must never look like it is MOVING, only
#: like it has moved when you look back at it.
RESONANCE_MODE_PERIOD = 17.0
#: How far up :data:`spacr.qt.resonance.MODES` a bright passage pushes the
#: figure. The spectral centroid is the driver, so brighter music draws a
#: busier figure -- which is the physical relation as well as the pretty
#: one: a plate driven at a higher frequency resonates in a higher mode.
RESONANCE_MODE_SPAN = 3.0
#: How far a particle's starting point wanders, as a fraction of the
#: plate, and over what range of periods. This is what keeps a figure alive
#: while the mode is unchanged: the relaxation is deterministic, so without
#: it a held figure would be a still photograph.
RESONANCE_WANDER = 0.085
RESONANCE_WANDER_PERIOD = (19.0, 53.0)
#: How far an onset throws the sand off the lines, as a fraction of the
#: plate. The bounce the request asks for, and the one thing in the picture
#: that is not a smooth function of anything.
RESONANCE_THROW = 0.06
#: How much of the plate the figure occupies at size 1.0, as a fraction of
#: the canvas's shorter edge. A SQUARE, because the field being solved is a
#: square plate's: stretching it to 16:9 would draw the nodal set of a
#: plate nobody has.
RESONANCE_PLATE = 0.78
#: Peak brightness of one particle. Light needs more than dark for the
#: reason given at :data:`BLOB_ALPHA_DARK`, and needs proportionally more
#: here because a point has no area over which to accumulate.
RESONANCE_ALPHA_DARK = 1.55
RESONANCE_ALPHA_LIGHT = 2.05
#: Brightness of the floor the sand lies on -- a soft pool of light under
#: the plate, and the only thing in this theme that is not a particle.
RESONANCE_FLOOR_DARK = 0.17
RESONANCE_FLOOR_LIGHT = 0.30
#: How sharply the floor falls off from the middle of the plate.
RESONANCE_FLOOR_FALLOFF = 2.4
[docs]
class ResonanceEngine(_BufferedEngine):
"""Chladni figures: sand on a plate that is driven by what is playing.
A floor with particles on it, and the particles are sand on a vibrating
plate. The physics is in :mod:`spacr.qt.resonance` and is worth one
paragraph here because it is what makes this theme different from a
particle system with a music-shaped wobble: a plate driven at one of
its resonances has lines that do not move, sand is thrown off
everywhere else and comes to rest along them, and the figure IS the
nodal set. So there is a right answer for where a particle goes, and
the drive decides which figure it is and how hard the sand is being
shaken rather than deciding the motion directly.
WHAT DRIVES IT. :func:`spacr.qt.resonance.playing_moment` -- the music
bed's own precomputed envelope and spectrum, read at the position the
bed is at. Nothing captures the machine's audio; nothing here can hear
anything spaCR is not playing. With sound off it returns silence and
the plate idles on its own clock, which is the case almost everybody
sees because sound is off on a fresh install.
WHY THE DRIVE IS READ IN :meth:`advance` AND NOT WHERE IT IS USED.
Every other engine promises that a frame is a pure function of
``(seed, clock, size)``, and two tests hold it down: one shades the same
clock on two threads and compares the bytes, another steps one engine
twelve times and jumps a second straight to the same clock. Reading a
real-time signal inside :meth:`shade` would break both, and it would
break them on the SHADING THREAD, where the failure is a picture that
differs from the one the GUI thread would have drawn. So the drive is
an INPUT, set on the GUI thread between frames under the engine lock
exactly as :meth:`set_palette` is, and the promise becomes "a pure
function of ``(seed, clock, size, drive)``" -- which is the same
promise while nothing is playing, and that is when the tests run.
:meth:`geometry` yields ``(x, y, brightness)`` per painted particle, in
pixels.
"""
name = "resonance"
base_edge = RESONANCE_EDGE
def __init__(self, *args, **kwargs):
"""Start with no floor and no canvas; the first shade builds both."""
self._floor = None
self._canvas = None
self._surface: Optional[Tuple[int, int]] = None
super().__init__(*args, **kwargs)
def _configure(self, rng: random.Random) -> None:
"""Roll the sand: where each grain starts, how it wanders, which
band it answers to and which way a beat throws it.
ONCE, at construction, exactly as every other engine rolls its
elements -- and here it matters more than usual, because these are
the fixed starting points the relaxation runs from every frame.
Move them and the figure would be a different figure; keep them and
the same seed always draws the same sand.
"""
np = _numpy()
from ..resonance import BANDS, silence
pool = _pool_size(RESONANCE_PARTICLES)
gen = np.random.default_rng(rng.getrandbits(63))
self.pool = pool
self.home_x = gen.random(pool).astype(np.float32)
self.home_y = gen.random(pool).astype(np.float32)
self.wander_phase = (gen.random(pool)
* (2.0 * math.pi)).astype(np.float32)
low, high = RESONANCE_WANDER_PERIOD
self.wander_rate = (2.0 * math.pi
/ (low + (high - low)
* gen.random(pool))).astype(np.float32)
angle = gen.random(pool) * (2.0 * math.pi)
self.throw_x = np.cos(angle).astype(np.float32)
self.throw_y = np.sin(angle).astype(np.float32)
self.spark = (0.55 + 0.45 * gen.random(pool)).astype(np.float32)
self.loose = (gen.random(pool) ** np.float32(1.7)).astype(np.float32)
self.band = (np.arange(pool) % len(BANDS)).astype(np.int32)
self.tone = np.arange(pool, dtype=np.int32)
self.drive = silence()
def _restyle(self) -> None:
"""Re-derive the palette as numbers, and drop the floor it tinted."""
super()._restyle()
np = _numpy()
self._rgb = np.array([[c.red(), c.green(), c.blue()]
for c in self.paint_colors], dtype=np.float32)
self._floor = None
def _reresolve(self) -> None:
"""A new buffer size is a new floor and a new canvas."""
super()._reresolve()
self._floor = None
self._canvas = None
self._surface = None
def _resize(self) -> None:
"""The size setting scales the plate, so the floor moves with it."""
self._floor = None
[docs]
def advance(self, dt: float) -> None:
"""Step the clock and read what is playing.
The one place a real-time signal enters this engine, and it is here
because this runs on the GUI thread between frames while the
shading thread is locked out -- see the class docstring. It costs a
lock, an index and two array reads; measured at 6.8 us with the
music bed playing, against the 41 ms a frame the cap allows.
What is read is damped by :meth:`answering` before it is stored, so
the Speed preference reaches the music-driven half of the theme as
well as the clock.
"""
super().advance(dt)
from ..resonance import playing_moment
self.drive = self.answering(playing_moment())
[docs]
def answering(self, moment):
"""``moment`` scaled by how much of it the Speed setting lets in.
THE CLOCK IS NOT THE WHOLE ANIMATION HERE, WHICH IS WHY THIS
EXISTS. :meth:`AmbientEngine.advance` multiplies ``dt`` by
:attr:`speed`, and that is the whole of the Speed preference for
every other theme, because every other theme's motion is a function
of the clock alone. Half of this one is a function of the music
instead: the throw on an onset, the per-band brightness and the
figure the spectrum's centre of mass asks for. At the bottom of
:data:`SPEED_RANGE` the breath, the mode walk and the wander all
crawl at a tenth, and a kick would still have thrown the sand
:data:`RESONANCE_THROW` of the plate twice a second -- leaving the
busiest movement in the theme running at full rate for somebody who
set the slider to its minimum precisely to stop that.
So the share let in is ``min(1, speed)``: the shipped setting and
anything above it hear the music in full, and turning the animation
down turns the reaction down with it, until at the minimum the
plate is the idle plate. Above 1.0 it is *not* scaled up, because
every field of a
:class:`~spacr.qt.resonance.Moment` is already 0 to 1 against the
loop's own loudest: there is nothing above full to give.
:param moment: what :func:`spacr.qt.resonance.playing_moment`
returned.
:returns: the same moment at speed 1.0 or above, a damped copy
below it.
"""
share = min(1.0, float(self.speed))
if share >= 1.0:
return moment
return moment._replace(
level=moment.level * share,
bands=tuple(band * share for band in moment.bands),
centroid=moment.centroid * share,
onset=moment.onset * share)
[docs]
def energy(self) -> float:
"""How hard the plate is being driven, 0 to 1.
The larger of the current drive level and the idle oscillation, so a
quiet passage never drives the picture below what silence would
have drawn. Idle animation and the response to audio therefore
use the same code path.
"""
idle = RESONANCE_IDLE + RESONANCE_IDLE_SWELL * math.sin(
2.0 * math.pi * self.time / RESONANCE_IDLE_PERIOD)
return float(max(idle, self.drive.level))
[docs]
def mode_position(self) -> float:
"""Where along :data:`spacr.qt.resonance.MODES` the figure sits."""
return (self.time / RESONANCE_MODE_PERIOD
+ self.drive.centroid * RESONANCE_MODE_SPAN)
[docs]
def plate(self, width: int, height: int) -> Tuple[float, float, float]:
"""The square the figure is drawn in: ``(left, top, side)`` in px."""
span = _clamp(RESONANCE_PLATE * self.size, 0.25, 1.0)
side = min(max(1, int(width)), max(1, int(height))) * span
return ((width - side) / 2.0, (height - side) / 2.0, side)
[docs]
def sand(self, count: Optional[int] = None):
"""Where the sand is now, and how brightly each grain shows.
The whole simulation, and it is four numpy passes: wander the
seeded starting points, build the lattice for the mode the drive
asks for, relax onto its nodal lines, and throw the result off them
by whatever the last onset was worth.
:param count: how many grains; the density setting's own count by
default.
:returns: ``(x, y, brightness)`` in plate units, x and y in 0..1.
"""
np = _numpy()
from .. import resonance as rs
n = int(self._count() if count is None else count)
moment = self.drive
energy = self.energy()
first, second, blend = rs.mode_blend(self.mode_position())
field, dx, dy = rs.lattice(first, second, blend, RESONANCE_GRID)
phase = self.wander_phase[:n] + self.wander_rate[:n] * self.time
start_x = np.clip(self.home_x[:n]
+ RESONANCE_WANDER * np.sin(phase), 0.0, 1.0)
start_y = np.clip(self.home_y[:n]
+ RESONANCE_WANDER * np.cos(phase), 0.0, 1.0)
tight = (0.24 + 0.68 * energy) * (0.12 + 0.88 * self.loose[:n])
x, y = rs.settle(start_x, start_y, field, dx, dy,
RESONANCE_ROUNDS, tight)
throw = RESONANCE_THROW * moment.onset
if throw > 0.0:
x = np.clip(x + throw * self.throw_x[:n], 0.0, 1.0)
y = np.clip(y + throw * self.throw_y[:n], 0.0, 1.0)
rest = np.minimum(np.abs(rs.sample(field, x, y)) * 3.0, 1.0)
bands = np.asarray(moment.bands, dtype=np.float32)
gain = (0.78 + 0.58 * bands)[self.band[:n]]
bright = (self.spark[:n] * (0.30 + 0.70 * energy) * gain
* (1.0 - 0.45 * rest) * self.alpha_scale())
return x, y, bright.astype(np.float32)
def _count(self) -> int:
"""How many grains the density setting asks for."""
return self.element_count(RESONANCE_PARTICLES, self.pool)
[docs]
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""Every grain as ``(x, y, brightness)`` in pixels.
:param width: canvas width in pixels.
:param height: canvas height.
:returns: one tuple per painted grain.
"""
if width <= 0 or height <= 0:
return ()
left, top, side = self.plate(width, height)
x, y, bright = self.sand()
return tuple((float(left + px * side), float(top + py * side),
float(b)) for px, py, b in zip(x, y, bright))
def _ensure_floor(self, width: int, height: int):
"""The pool of light the sand lies on, as a contribution map.
Held between frames and rebuilt only when the buffer size, the
palette, the background or the plate's size changes -- never per
frame. It is stored as ``weight * (colour - identity)`` so the
frame is one multiply by the glow and then the grains added on top,
with no branch anywhere for dark against light.
"""
np = _numpy()
if self._floor is not None and self._surface == (width, height):
return self._floor
left, top, side = self.plate(width, height)
ys = (np.arange(height, dtype=np.float32) - (top + side / 2.0)) \
/ max(side, 1.0)
xs = (np.arange(width, dtype=np.float32) - (left + side / 2.0)) \
/ max(side, 1.0)
radius = (xs[None, :] ** 2) * 0.82 + (ys[:, None] ** 2)
weight = np.exp(-RESONANCE_FLOOR_FALLOFF * 4.0 * radius)
peak = RESONANCE_FLOOR_DARK if self.dark else RESONANCE_FLOOR_LIGHT
identity = np.float32(0.0 if self.dark else 255.0)
tint = self._rgb.mean(axis=0) if self._rgb.size else \
np.zeros(3, dtype=np.float32)
self._floor = (weight[:, :, None] * np.float32(peak)
* (tint - identity)).astype(np.float32)
self._canvas = np.empty((height, width, 3), dtype=np.float32)
self._surface = (width, height)
return self._floor
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Shade one frame: the floor, then every grain added onto it.
Built as numbers and handed to Qt once, for the same reason
:class:`FractalEngine` does it: a per-particle ``drawPoint`` is a
Python call per grain and nine hundred of them is the whole frame
budget, where ``np.add.at`` puts all nine hundred in one call.
Everything is accumulated as ``colour - identity`` and added to
``identity`` at the end, which is the arithmetic that makes the same
code paint additively over a dark page and multiplicatively over a
light one -- see the module docstring.
"""
np = _numpy()
if width <= 0 or height <= 0:
return
floor = self._ensure_floor(width, height)
canvas = self._canvas
glow = np.float32(0.55 + 0.45 * self.energy())
np.multiply(floor, glow, out=canvas)
left, top, side = self.plate(width, height)
x, y, bright = self.sand()
peak = RESONANCE_ALPHA_DARK if self.dark else RESONANCE_ALPHA_LIGHT
identity = np.float32(0.0 if self.dark else 255.0)
columns = np.clip((left + x * side).astype(np.int32), 0, width - 1)
rows = np.clip((top + y * side).astype(np.int32), 0, height - 1)
count = x.size
tint = self._rgb[self.tone[:count] % max(1, self._rgb.shape[0])]
weight = (bright * np.float32(peak))[:, None]
np.add.at(canvas.reshape(-1, 3), rows * width + columns,
weight * (tint - identity))
frame = np.empty((height, width), dtype=np.uint32)
levels = np.clip(canvas + identity, 0.0, 255.0).astype(np.uint32)
np.bitwise_or(np.uint32(0xFF000000), levels[:, :, 0] << 16, out=frame)
np.bitwise_or(frame, levels[:, :, 1] << 8, out=frame)
np.bitwise_or(frame, levels[:, :, 2], out=frame)
painter.drawImage(0, 0, QImage(frame.data, width, height,
int(frame.strides[0]),
QImage.Format_RGB32))
_PACKED_SCATTER = None
_COLORED_SCATTER = None
_COLORED_SCATTER_FAILED = False
_COLOR_BLEND = None
_PACKED_SCATTER_STARTED = False
_PACKED_SCATTER_FAILED = False
_PACKED_SCATTER_LOCK = threading.Lock()
_AMBIENT_STARTUP_READY = threading.Event()
_AMBIENT_STARTUP_READY.set()
def _begin_ambient_startup() -> None:
"""Defer optional compiler imports until the application's first paint."""
_AMBIENT_STARTUP_READY.clear()
def _complete_ambient_startup() -> None:
"""Permit lazy CPU compilation after the actual interactive checkpoint."""
_AMBIENT_STARTUP_READY.set()
def _scatter_packed_grains(flat, px, py, intensities, lookup, axial, diagonal,
width, height, dark):
"""Combine nine round-grain samples with duplicate-safe integer max/min.
Centres have already been clipped by the material adapter. Every offset
retains its original palette/coverage lookup; max/min is commutative, so
point-major traversal preserves the NumPy scatter's exact packed pixels.
"""
for i in range(px.size):
x, y = px[i], py[i]
level = intensities[i]
destination = y * width + x
color = lookup[level]
if (dark and color > flat[destination]) or (not dark and color < flat[destination]):
flat[destination] = color
for sy in range(-1, 2):
ny = y + sy
if ny < 0 or ny >= height:
continue
for sx in range(-1, 2):
if sx == 0 and sy == 0:
continue
nx = x + sx
if nx < 0 or nx >= width:
continue
color = diagonal[level] if sx and sy else axial[level]
destination = ny * width + nx
if ((dark and color > flat[destination])
or (not dark and color < flat[destination])):
flat[destination] = color
def _scatter_colored_grains(flat, ranks, px, py, indices, lookup, axial, diagonal,
width, height, dark, spread):
"""Keep exact intensity/colour ordering in a private packed working word."""
for i in range(px.size):
x, y = px[i], py[i]
level = indices[i]
destination = y * width + x
color = lookup[level]
if (dark and color > flat[destination]) or (not dark and color < flat[destination]):
flat[destination] = color
if not spread:
continue
for dy in range(-1, 2):
ny = y + dy
if ny < 0 or ny >= height:
continue
for dx in range(-1, 2):
if dx == 0 and dy == 0:
continue
nx = x + dx
if nx < 0 or nx >= width:
continue
color = diagonal[level] if dx and dy else axial[level]
destination = ny * width + nx
if ((dark and color > flat[destination])
or (not dark and color < flat[destination])):
flat[destination] = color
def _blend_colored_words(output, base, blend):
"""Mix ``blend`` of 255 of ``output`` into ``base`` per channel, in place.
The colour-wave crossfade, rounded exactly as its NumPy twin in
:meth:`_SpaceoutFieldEngine._point_material` rounds it. ``output`` is the
coloured frame with its rank bytes still in the alpha channel and
``base`` the ordinary opaque frame, which becomes the result. A word
equal in both mixes to itself, so only the few words a grain touched are
written. Red and blue share one product in 16-bit lanes; every lane sum
stays below 65280, where ``(sum + 1 + (sum >> 8)) >> 8`` equals
``sum // 255`` and no lane carries into the next.
"""
keep = 255 - blend
for i in range(base.size):
colored = int(output[i]) | 0xff000000
previous = int(base[i])
if colored == previous:
continue
pair = (previous & 0xff00ff) * keep + (colored & 0xff00ff) * blend + 0x7f007f
pair = (pair + 0x10001 + (pair >> 8 & 0xff00ff)) >> 8 & 0xff00ff
green = (previous >> 8 & 255) * keep + (colored >> 8 & 255) * blend + 127
green = (green + 1 + (green >> 8)) >> 8
base[i] = 0xff000000 | pair | green << 8
_SUBPIXEL_SCATTER = None
_GRAIN_KNOTS = (0.0, 1.0, math.sqrt(2.0), 2.0)
_GRAIN_WEIGHTS = (1.0, 0.68, 0.24, 0.0)
_GRAIN_ENERGY = 1.0 + 4 * 0.68 + 4 * 0.24
def _scatter_subpixel_grains(flat, xs, ys, values, lookup, width, height, dark,
weights):
"""Stamp round grains at fractional centres with a constant-energy 4x4 kernel.
A centre on a pixel centre reproduces the nine-sample grain; between
pixels the same radial profile is sampled and renormalised to the same
total, so a grain glides instead of hopping a whole pixel.
"""
root2 = 1.4142135623730951
for i in range(xs.size):
fx = xs[i] - 0.5
fy = ys[i] - 0.5
jx = int(math.floor(fx))
jy = int(math.floor(fy))
ox = fx - jx
oy = fy - jy
total = 0.0
for a in range(4):
dy = (a - 1) - oy
for b in range(4):
dx = (b - 1) - ox
r = math.sqrt(dx * dx + dy * dy)
if r < 1.0:
w = 1.0 - 0.32 * r
elif r < root2:
w = 0.68 + (r - 1.0) * (0.24 - 0.68) / (root2 - 1.0)
elif r < 2.0:
w = 0.24 + (r - root2) * (0.0 - 0.24) / (2.0 - root2)
else:
w = 0.0
weights[a * 4 + b] = w
total += w
scale = values[i] * 255.0 * 4.68 / total
for a in range(4):
ny = jy + a - 1
if ny < 0 or ny >= height:
continue
for b in range(4):
nx = jx + b - 1
if nx < 0 or nx >= width:
continue
level = int(math.floor(weights[a * 4 + b] * scale + 0.5))
if level <= 0:
continue
if level > 255:
level = 255
color = lookup[level]
destination = ny * width + nx
if ((dark and color > flat[destination])
or (not dark and color < flat[destination])):
flat[destination] = color
def _subpixel_grains_numpy(np, flat, xs, ys, values, lookup, width, height, dark):
"""The NumPy twin of :func:`_scatter_subpixel_grains`, used until it compiles."""
if not len(xs):
return
fx = np.asarray(xs, dtype=np.float64) - 0.5
fy = np.asarray(ys, dtype=np.float64) - 0.5
jx, jy = np.floor(fx), np.floor(fy)
offsets = np.arange(-1.0, 3.0)
dx = offsets[None, :] - (fx - jx)[:, None]
dy = offsets[None, :] - (fy - jy)[:, None]
r = np.sqrt(dy[:, :, None] ** 2 + dx[:, None, :] ** 2)
weights = np.interp(r, _GRAIN_KNOTS, _GRAIN_WEIGHTS, right=0.0)
total = weights.sum(axis=(1, 2))
scale = np.where(total > 0.0, np.asarray(values, dtype=np.float64) * 255.0
* _GRAIN_ENERGY / np.maximum(total, 1e-12), 0.0)
levels = np.minimum(np.floor(weights * scale[:, None, None] + 0.5), 255.0)
columns = (jx.astype(np.int64)[:, None] + offsets.astype(np.int64)[None, :])
rows = (jy.astype(np.int64)[:, None] + offsets.astype(np.int64)[None, :])
columns = np.broadcast_to(columns[:, None, :], levels.shape)
rows = np.broadcast_to(rows[:, :, None], levels.shape)
keep = ((levels > 0) & (columns >= 0) & (columns < width)
& (rows >= 0) & (rows < height))
destinations = rows[keep] * width + columns[keep]
colors = lookup[levels[keep].astype(np.int64)]
(np.maximum.at if dark else np.minimum.at)(flat, destinations, colors)
def _ready_subpixel_scatter():
"""The compiled fractional-grain kernel once the shared warmup has built it."""
if _SUBPIXEL_SCATTER is None:
_ready_packed_scatter()
return _SUBPIXEL_SCATTER
_NUMBA_IMPORT_LOCK = threading.Lock()
def _import_numba():
"""Import numba once at a time, so two first importers cannot interleave.
The start-up warm thread and the kernel warmup thread can both be the
first to import it; numba imports itself in a cycle, and two threads
inside that cycle can see a half-initialised package.
"""
with _NUMBA_IMPORT_LOCK:
import numba
return numba
def _numba_cache_directory():
"""A writable per-user folder for compiled kernels, or ``None``.
``NUMBA_CACHE_DIR`` wins when the user set it; otherwise the folder is
``numba`` under spaCR's cache location, never beside the package source,
so frozen and read-only installs work. An unwritable folder means no
cache, silently.
"""
from ...logging_util import _numba_cache_folder
return _numba_cache_folder()
_PRIME_LOCK = threading.Lock()
_PRIME_TIMEOUT_S = 240
_PRECOMPILED_KERNELS = frozenset((
"spacr.qt.widgets.ambient._scatter_packed_grains",
"spacr.qt.widgets.ambient._scatter_subpixel_grains",
"spacr.qt.widgets.ambient._blend_colored_words",
"spacr.qt.widgets.ambient._scatter_colored_grains",
"spacr.qt.widgets.ambient._warp_satin_columns",
"spacr.qt.widgets.fractal_cascade.render_into",
"spacr.qt.widgets.fractal_space._object_color",
"spacr.qt.widgets.fractal_space.sample_space",
"spacr.qt.widgets.fractal_space.render_space_frame",
"spacr.qt.widgets.fractal_space._add_galaxies",
"spacr.qt.widgets.fractal_travel._render_into",
"spacr.qt.widgets.fractal_travel._blend_temporal",
"spacr.qt.widgets.fractal_travel._blend_weighted",
))
_PRECOMPILE_EXCLUDED = {
"spacr.qt.widgets.fractal_space._hash2":
"inline='always': compiled into its callers, never called alone",
"spacr.qt.widgets.fractal_space._atlas_texel":
"inline='always': compiled into _add_galaxies, never called alone",
}
_PRIMED_SOURCES = ("ambient", "fractal_cascade", "fractal_space",
"fractal_travel")
def _on_a_worker_thread() -> bool:
"""Whether the caller is a thread other than the interpreter's main one."""
import threading as _threads
return _threads.current_thread() is not _threads.main_thread()
def _priming_allowed() -> bool:
"""Whether kernels may be compiled in a helper process.
Not in a frozen build, whose executable is the application rather than
an interpreter, and not in a distribution smoke run.
"""
if getattr(sys, "frozen", False):
return False
return os.environ.get("SPACR_DISTRIBUTION_SMOKE", "").strip() != "1"
def _primed_marker(folder):
"""The file that says this source and numba have filled ``folder``."""
import hashlib
try:
import importlib.metadata as metadata
numba_version = metadata.version("numba")
except Exception:
numba_version = "?"
here = os.path.dirname(os.path.abspath(__file__))
stamp = f"{numba_version}|{sys.version_info[:2]}"
for name in _PRIMED_SOURCES:
path = os.path.join(here, name + ".py")
stat = os.stat(path)
stamp += f"|{path}|{stat.st_mtime_ns}|{stat.st_size}"
digest = hashlib.sha256(stamp.encode("utf-8")).hexdigest()[:16]
return folder / f"spacr-ambient-primed-{digest}"
def _prime_kernel_cache() -> int:
"""Compile every ambient kernel into the on-disk cache, in this process.
Run by :func:`_prime_in_helper_process` in a child interpreter, so the
compiler's hold on the interpreter lock is that process's and not the
application's.
:returns: ``0`` when the cache was filled, ``1`` otherwise.
"""
folder = _numba_cache_directory()
if folder is None:
return 1
_warm_packed_scatter()
_SATIN_COMPILER._warm()
if _PACKED_SCATTER is None or _SATIN_COMPILER.kernel is None:
return 1
try:
_prime_fractal_kernels()
except Exception:
LOG.debug("the fractal kernels did not compile", exc_info=True)
return 1
try:
_primed_marker(folder).touch()
except OSError:
return 1
return 0
def _prime_fractal_kernels() -> None:
"""Render one tiny frame of every night-theme engine, as the app calls it.
The arguments have the types the fractal worker sends, so the cached
signatures are the ones the application asks for.
"""
from .fractal_cascade import CascadeEngine
from .fractal_space import SpaceEngine, _add_galaxies, _galaxies_at
from .fractal_space import _galaxy_atlas, render_space_frame
from .fractal_travel import OrbitEngine
for samples in (1, 2, 3):
for engine in (CascadeEngine(1), SpaceEngine(1), OrbitEngine(1)):
engine.samples = samples
for _frame in range(samples * samples + 1):
engine.render(8, 6, 0.25, 1.0, 0.5, 8, 0.0, 0.0, 0.0, 0.0)
frame = render_space_frame(8, 6, 0.25, 1.0, 0.0, 0.0, 1)
_add_galaxies(frame, 0.25, 0.0, 0.0, _galaxies_at(0.25, 1.0),
_galaxy_atlas())
_PRIMED_ONCE = threading.Event()
def _prime_before_first_kernel() -> None:
"""Fill the kernel cache once, from a render worker, before it compiles.
The night-theme engines call this at the top of every render; after the
first call it is one flag test.
"""
if _PRIMED_ONCE.is_set():
return
_PRIMED_ONCE.set()
try:
if _on_a_worker_thread():
_prime_in_helper_process()
except Exception:
LOG.debug("the kernel cache could not be filled", exc_info=True)
def _prime_in_helper_process() -> bool:
"""Fill the kernel cache from a child interpreter unless it is filled.
Called on a compile thread, never on the GUI thread: waiting for the
child releases the interpreter lock, and the application then loads the
kernels from the cache instead of compiling them itself.
:returns: whether the cache is known to be filled for this source.
"""
if not _priming_allowed():
return False
folder = _numba_cache_directory()
if folder is None:
return False
with _PRIME_LOCK:
marker = _primed_marker(folder)
if marker.exists():
return True
import subprocess
from pathlib import Path
package_parent = str(Path(__file__).resolve().parents[3])
env = dict(os.environ)
env["NUMBA_CACHE_DIR"] = str(folder)
env["PYTHONPATH"] = os.pathsep.join(
p for p in (package_parent, env.get("PYTHONPATH", "")) if p)
env.setdefault("QT_QPA_PLATFORM", "offscreen")
code = ("import sys; from spacr.qt.widgets.ambient import "
"_prime_kernel_cache; sys.exit(_prime_kernel_cache())")
try:
subprocess.run([sys.executable, "-c", code], env=env,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=_PRIME_TIMEOUT_S,
check=False)
except Exception:
LOG.debug("the kernel cache helper did not run", exc_info=True)
return False
return marker.exists()
def _compiled_kernel(function):
"""``function`` as a nogil numba kernel, cached on disk when it can be."""
cached = _numba_cache_directory() is not None
njit = _import_numba().njit
if cached:
try:
return njit(nogil=True, cache=True)(function)
except Exception:
pass
return njit(nogil=True, cache=False)(function)
def _warm_packed_scatter():
"""Compile once using tiny owned CPU arrays, without Qt or package writes.
The renderer continues using its exact NumPy path while this daemon works.
Import/compiler errors and NUMBA_DISABLE_JIT leave that path active. Some
import/compiler phases hold the GIL briefly; this is not a no-stall claim.
"""
global _PACKED_SCATTER, _PACKED_SCATTER_FAILED, _PACKED_SCATTER_STARTED
global _COLORED_SCATTER, _COLORED_SCATTER_FAILED, _SUBPIXEL_SCATTER
global _COLOR_BLEND
if not _AMBIENT_STARTUP_READY.is_set():
with _PACKED_SCATTER_LOCK:
_PACKED_SCATTER_STARTED = False
return
if _on_a_worker_thread():
_prime_in_helper_process()
try:
np = _numpy()
flat = np.zeros(1, dtype=np.uint32)
coordinates = np.zeros(1, dtype=np.int32)
intensity = np.zeros(1, dtype=np.uint8)
lookup = np.arange(256, dtype=np.uint32)
kernel = _compiled_kernel(_scatter_packed_grains)
kernel(flat, coordinates, coordinates, intensity, lookup, lookup, lookup,
1, 1, True)
if not getattr(kernel, 'nopython_signatures', ()):
raise RuntimeError('Packed grain compiler did not produce a CPU kernel')
with _PACKED_SCATTER_LOCK:
_PACKED_SCATTER = kernel
try:
subpixel = _compiled_kernel(_scatter_subpixel_grains)
subpixel(flat, np.zeros(1, dtype=np.float32), np.zeros(1, dtype=np.float32),
np.zeros(1, dtype=np.float32), lookup, 1, 1, True,
np.zeros(16, dtype=np.float64))
if getattr(subpixel, 'nopython_signatures', ()):
with _PACKED_SCATTER_LOCK:
_SUBPIXEL_SCATTER = subpixel
except Exception:
pass
try:
blender = _compiled_kernel(_blend_colored_words)
blender(flat, flat.copy(), 1)
if getattr(blender, 'nopython_signatures', ()):
with _PACKED_SCATTER_LOCK:
_COLOR_BLEND = blender
except Exception:
pass
try:
colored = _compiled_kernel(_scatter_colored_grains)
table = lookup
colored(flat, np.zeros(1, dtype=np.uint8), coordinates, coordinates,
np.zeros(1, dtype=np.uint16), table, table, table, 1, 1, True, True)
if getattr(colored, 'nopython_signatures', ()):
with _PACKED_SCATTER_LOCK:
_COLORED_SCATTER = colored
else:
_COLORED_SCATTER_FAILED = True
except Exception:
_COLORED_SCATTER_FAILED = True
return
except Exception:
with _PACKED_SCATTER_LOCK:
_PACKED_SCATTER_FAILED = True
def _ready_packed_scatter():
"""Offer an already-compiled kernel, starting at most one CPU warmup thread.
A first frame may be shaded synchronously on the GUI thread. No import or
compilation occurs here, and a contended startup lock returns immediately.
"""
global _PACKED_SCATTER_STARTED, _PACKED_SCATTER_FAILED
if not _AMBIENT_STARTUP_READY.is_set():
return _PACKED_SCATTER
if (_PACKED_SCATTER is None and not _PACKED_SCATTER_STARTED
and not _PACKED_SCATTER_FAILED and _PACKED_SCATTER_LOCK.acquire(blocking=False)):
try:
if not _PACKED_SCATTER_STARTED and not _PACKED_SCATTER_FAILED:
_PACKED_SCATTER_STARTED = True
try:
threading.Thread(target=_warm_packed_scatter,
name='spacr-grain-compile', daemon=False).start()
except Exception:
_PACKED_SCATTER_FAILED = True
finally:
_PACKED_SCATTER_LOCK.release()
return _PACKED_SCATTER
def _ready_colored_scatter():
"""Reuse the same gated warmup for the optional multi-hue CPU kernel."""
if _COLORED_SCATTER_FAILED:
return None
if _COLORED_SCATTER is None:
_ready_packed_scatter()
return _COLORED_SCATTER
def _ready_color_blend():
"""The compiled colour-wave crossfade once the shared warmup has built it.
Without it a colour wave mixes two whole 2560x1440 frames in NumPy
chunks, 6-9 plain frames of shading; compiled, it writes only the words
a grain touched, without the interpreter lock.
"""
if _COLOR_BLEND is None:
_ready_colored_scatter()
return _COLOR_BLEND
def _warp_satin_columns(source, target, shifts, tops, bottoms, padding):
"""Move native premultiplied pixels along each column without filtering."""
height, width = target.shape
for start in range(0, width, 32):
end = min(width, start + 32)
lower, upper = height, 0
for column in range(start, end):
lower = min(lower, tops[column] + shifts[column] + padding)
upper = max(upper, bottoms[column] + shifts[column] + padding)
for row in range(max(0, lower), min(height, upper)):
for column in range(start, end):
source_row = row - shifts[column] - padding
if tops[column] <= source_row < bottoms[column]:
target[row, column] = source[source_row, column]
def _numpy_satin_columns(source, target, shifts, padding):
"""Restore the complete native wave layer using exact indexed CPU pixels."""
np = _numpy()
rows = (np.arange(target.shape[0], dtype=np.int32)[:, None]
- shifts[None, :] - padding)
inside = (rows >= 0) & (rows < source.shape[0])
np.clip(rows, 0, source.shape[0] - 1, out=rows)
rows *= source.shape[1]
rows += np.arange(source.shape[1], dtype=np.int32)[None, :]
np.take(source.ravel(), rows, out=target, mode="clip")
target[~inside] = 0
def _copy_wave_batch(kernel, tasks):
"""Finish a finite immutable group of independently owned pixel arrays."""
for arguments in tasks:
kernel(*arguments)
class _WaveCopyWorker:
"""Own one bounded CPU queue without retaining completed scene arrays."""
def __init__(self):
"""Start one bounded daemon without importing any compiler packages."""
from concurrent.futures import Future
from queue import Queue
self._future_type = Future
self._pending = Queue(maxsize=1)
threading.Thread(target=self._run, name="spacr-satin-copy",
daemon=True).start()
def submit(self, function, *arguments):
"""Offer one pure CPU job and return its completion ownership fence."""
future = self._future_type()
self._pending.put_nowait((function, arguments, future))
return future
def _run(self):
"""Complete or cancel each CPU job and release all submitted arrays."""
while True:
function, arguments, future = self._pending.get()
try:
if future.set_running_or_notify_cancel():
try:
future.set_result(function(*arguments))
except BaseException as error:
future.set_exception(error)
finally:
self._pending.task_done()
del function, arguments, future
class _SatinCompiler:
"""Compile one CPU wave-copy signature once away from the GUI thread."""
def __init__(self):
"""Keep one lazy compilation attempt and a shared bounded copy worker."""
self.kernel = None
self.started = False
self.failed = False
self.lock = threading.Lock()
self.copy_gate = threading.Lock()
self.pool = None
def _warm(self):
"""Compile owned tiny arrays only after application readiness permits it."""
if not _AMBIENT_STARTUP_READY.is_set():
with self.lock:
self.started = False
return
if _on_a_worker_thread():
_prime_in_helper_process()
try:
np = _numpy()
kernel = _compiled_kernel(_warp_satin_columns)
source = np.zeros((1, 1), dtype=np.uint32)
coordinates = np.zeros(1, dtype=np.int32)
kernel(source, source.copy(), coordinates, coordinates, coordinates + 1, 0)
strided = np.zeros((2, 2), dtype=np.uint32)[:, :1]
kernel(strided, np.zeros((2, 1), dtype=np.uint32), coordinates,
coordinates, coordinates + 2, 0)
if not getattr(kernel, 'nopython_signatures', ()):
raise RuntimeError("native wave compiler is disabled")
self.pool = _WaveCopyWorker()
self.kernel = kernel
except Exception:
self.failed = True
def ready(self):
"""Offer the exact fallback while startup defers compiler imports."""
if not _AMBIENT_STARTUP_READY.is_set():
return self.kernel
if self.kernel is not None or self.started or self.failed:
return self.kernel
if not self.lock.acquire(blocking=False):
return None
try:
if not self.started and not self.failed:
self.started = True
try:
threading.Thread(target=self._warm, name="spacr-satin-compile",
daemon=False).start()
except Exception:
self.failed = True
finally:
self.lock.release()
return self.kernel
def copy_waves(self, kernel, tasks):
"""Join at most one shared CPU batch before any Qt painter sees it.
One nonblocking gate bounds the global executor queue to one job.
Other producers use their synchronous kernel instead of queueing.
Both paths finish their owned arrays under the existing engine lock.
"""
if self.pool is None or len(tasks) < 2 or not self.copy_gate.acquire(blocking=False):
_copy_wave_batch(kernel, tasks)
return
try:
try:
future = self.pool.submit(_copy_wave_batch, kernel, tasks[::2])
except RuntimeError:
_copy_wave_batch(kernel, tasks)
else:
try:
_copy_wave_batch(kernel, tasks[1::2])
finally:
future.result()
finally:
self.copy_gate.release()
_SATIN_COMPILER = _SatinCompiler()
_FLOW_GRAPHICS_PROBE = None
_FLOW_GRAPHICS_PROBE_LOCK = threading.Lock()
#: Renderer names that are software rasterisers, refused as "GPU".
_SOFTWARE_RENDERERS = ("llvmpipe", "softpipe", "software", "swrast",
"microsoft basic render", "apple software")
#: Selectable decorative-animation renderers, in slider order.
GRAPHICS_BACKENDS: Tuple[str, ...] = ("cpu", "auto", "gpu")
DEFAULT_GRAPHICS_BACKEND = "gpu"
#: Per-theme density defaults for profiles that never saved a density.
#: spaCR field asked for 25 % (N684); every other theme keeps DEFAULT_DENSITY.
_THEME_DEFAULT_DENSITY = {"data_art_impulse_lens": 0.25}
def _default_density_for(theme) -> float:
"""Return the shipped density for ``theme`` when nothing was saved."""
return _THEME_DEFAULT_DENSITY.get(theme, DEFAULT_DENSITY)
def _graphics_context_options():
"""Return moderngl standalone options for this platform.
Linux uses headless EGL, which needs no window or X connection.
Windows (WGL) and macOS (CGL) use the default standalone backend;
macOS caps desktop OpenGL at 4.1, so the 4.3 storage-buffer shader
is refused there and the CPU renderer stays in use.
"""
if sys.platform.startswith("linux"):
return {"standalone": True, "require": 430, "backend": "egl"}
return {"standalone": True, "require": 430}
def _flow_graphics_preflight():
"""Probe an optional hardware OpenGL context in a separate process.
The result is cached for this application process. A missing
dependency, unsupported driver, software renderer, timeout or failed
child keeps the CPU renderer. The probe runs in a child so a driver
crash during context creation cannot take the application down.
``SPACR_NO_GL`` (safe mode, crash recovery) keeps it without probing.
macOS is never probed. Its OpenGL stops at 4.1, so the 4.3 context can
only fail, and creating one at start-up preceded three WindowServer
watchdog kills ("Display not ready") on an Intel Iris Mac running
macOS 26 on 2026-10-19.
"""
global _FLOW_GRAPHICS_PROBE
import importlib.util
import subprocess
with _FLOW_GRAPHICS_PROBE_LOCK:
if _FLOW_GRAPHICS_PROBE is None and (
sys.platform == "darwin"
or os.environ.get("SPACR_NO_GL")
or importlib.util.find_spec("moderngl") is None):
_FLOW_GRAPHICS_PROBE = False
if _FLOW_GRAPHICS_PROBE is None:
script = (
"import moderngl; "
f"c=moderngl.create_context(**{_graphics_context_options()!r}); "
"r=str(c.info['GL_RENDERER']).lower(); "
f"assert not any(x in r for x in {_SOFTWARE_RENDERERS!r}); "
"c.release(); print('hardware-gl-ok')"
)
try:
result = subprocess.run(
[sys.executable, '-c', script], capture_output=True,
text=True, timeout=5, check=False)
_FLOW_GRAPHICS_PROBE = (
result.returncode == 0
and result.stdout.strip() == 'hardware-gl-ok')
except (OSError, subprocess.TimeoutExpired):
_FLOW_GRAPHICS_PROBE = False
return _FLOW_GRAPHICS_PROBE
def _flow_compute_idle():
"""Whether no OTHER process is running compute work on an NVIDIA GPU.
This is the external-process half of Dynamic animation. NVML lists
compute processes on every device without importing PyTorch, so a
headless spaCR command line, a training script or another
application's CUDA job is seen even though spaCR did not start it.
This spaCR process is excluded: its own analysis jobs are tracked
through the run registry instead, and a CUDA context kept alive after
a finished job must not hold decoration off forever.
Without NVML (no NVIDIA driver, or the binding is not installed)
nothing external can be observed and the answer is True. A query
that fails after NVML started answers False, keeping the GPU for
analysis.
"""
import os
try:
import pynvml
except Exception:
return True
try:
pynvml.nvmlInit()
except Exception:
return True
own = os.getpid()
try:
for index in range(pynvml.nvmlDeviceGetCount()):
handle = pynvml.nvmlDeviceGetHandleByIndex(index)
for process in pynvml.nvmlDeviceGetComputeRunningProcesses(handle):
if getattr(process, "pid", None) != own:
return False
return True
except Exception:
return False
finally:
try:
pynvml.nvmlShutdown()
except Exception:
pass
_GL_QUAD = '''
vec2 quad_corner() {
return vec2(float(gl_VertexID & 1), float(gl_VertexID >> 1));
}
vec4 canvas_position(vec2 point, vec2 canvas) {
return vec4(point / canvas * 2.0 - 1.0, 0.0, 1.0);
}
'''
_GL_GRAIN_WEIGHT = '''
float grain_weight(vec2 d) {
const float root2 = 1.4142135623730951;
float r = sqrt(d.x * d.x + d.y * d.y);
if (r < 1.0) return 1.0 - 0.32 * r;
if (r < root2) return 0.68 + (r - 1.0) * (0.24 - 0.68) / (root2 - 1.0);
if (r < 2.0) return 0.24 + (r - root2) * (0.0 - 0.24) / (2.0 - root2);
return 0.0;
}
'''
_GL_HAIRLINE_VERTEX = '''
#version 430
in vec4 segment;
in float half_width;
in float caps;
uniform vec2 canvas;
flat out vec4 ends;
flat out float reach;
flat out float capped;
''' + _GL_QUAD + '''
void main() {
vec2 a = segment.xy;
vec2 b = segment.zw;
float span = length(b - a);
vec2 along = span > 1e-6 ? (b - a) / span : vec2(1.0, 0.0);
vec2 across = vec2(-along.y, along.x);
float extent = half_width + 1.5;
vec2 corner = quad_corner();
vec2 point = mix(a - along * extent, b + along * extent, corner.x)
+ across * extent * (corner.y * 2.0 - 1.0);
gl_Position = canvas_position(point, canvas);
ends = segment;
reach = half_width;
capped = caps;
}
'''
#: GLSL for the graphics paths of the CPU-only themes (N684). Coordinates
#: are QPainter canvas pixels with pixel centres at +0.5, and framebuffer
#: row ``r`` is image row ``r``, so the readback is the QImage unchanged.
#: Colours are written blue-green-red so the RGBA8 bytes are the RGB32 /
#: ARGB32 words Qt expects on a little-endian host.
_GL_SHADERS = {
'blobs': ('''
#version 430
in vec4 shape;
in vec3 tint;
uniform vec2 canvas;
flat out vec4 disc;
flat out vec3 colour;
''' + _GL_QUAD + '''
void main() {
vec2 point = shape.xy + (quad_corner() * 2.0 - 1.0) * (shape.z + 1.0);
gl_Position = canvas_position(point, canvas);
disc = shape;
colour = tint;
}
''', '''
#version 430
uniform vec2 stops[4];
flat in vec4 disc;
flat in vec3 colour;
out vec4 result;
void main() {
float d = distance(gl_FragCoord.xy, disc.xy) / disc.z;
if (d > 1.0) discard;
float a = stops[3].y;
for (int i = 1; i < 4; ++i) {
if (d <= stops[i].x) {
float f = (d - stops[i - 1].x) / max(1e-6, stops[i].x - stops[i - 1].x);
a = mix(stops[i - 1].y, stops[i].y, f);
break;
}
}
a *= disc.w;
result = vec4(colour.bgr * a, a);
}
'''),
'discs': ('''
#version 430
in vec3 disc;
in vec4 tint;
uniform vec2 canvas;
uniform vec2 scale;
flat out vec3 shape;
flat out vec4 colour;
''' + _GL_QUAD + '''
void main() {
float radius = disc.z * scale.x;
vec2 centre = disc.xy * scale;
vec2 point = centre + (quad_corner() * 2.0 - 1.0) * (radius + 1.5);
gl_Position = canvas_position(point, canvas);
shape = vec3(centre, radius);
colour = tint;
}
''', '''
#version 430
uniform bool smooth_edge;
flat in vec3 shape;
flat in vec4 colour;
out vec4 result;
void main() {
float cover = 0.0;
if (smooth_edge) {
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
vec2 sample_at = floor(gl_FragCoord.xy)
+ (vec2(float(i), float(j)) + 0.5) / 4.0;
cover += distance(sample_at, shape.xy) <= shape.z ? 0.0625 : 0.0;
}
}
} else {
cover = distance(gl_FragCoord.xy, shape.xy) <= shape.z ? 1.0 : 0.0;
}
if (cover <= 0.0) discard;
float a = colour.a * cover;
result = vec4(colour.bgr * a, a);
}
'''),
'aurora_texture': ('''
#version 430
void main() {
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
''', '''
#version 430
layout(std430, binding=3) readonly buffer Columns { vec4 columns[]; };
layout(std430, binding=4) readonly buffer Lookup { vec4 lookup[]; };
uniform bool per_column;
uniform bool dark;
uniform float rows;
out vec4 texel;
void main() {
int x = int(gl_FragCoord.x);
float row = floor(gl_FragCoord.y);
vec4 column = columns[x];
float vertical = 1.0 - row / (rows - 1.0);
float rise = vertical / column.y;
float beam = 0.14 + 0.28 * (1.0 - clamp(rise, 0.0, 1.0));
float spread = column.x / beam;
float rays = exp(-(spread * spread));
float emission = 0.15 + 0.85 * pow(max(0.0, 1.0 - rise), 0.8);
emission *= clamp((1.0 - rise) / 0.12, 0.0, 1.0);
emission *= min(1.0, rise / 0.045);
float alpha = emission * (0.20 + 0.80 * rays) * column.z;
uint level = uint(clamp(alpha * 255.0, 0.0, 255.0));
uint index = per_column ? uint(column.w)
: uint(clamp(rise * 255.0, 0.0, 255.0));
uvec3 rgb = uvec3(lookup[index].rgb * (float(level) / 255.0));
if (!dark) rgb = uvec3(255u) - rgb;
texel = vec4(vec3(rgb.bgr) / 255.0, 1.0);
}
'''),
'strips': ('''
#version 430
in vec4 vertex;
uniform vec2 canvas;
uniform vec2 source;
out vec2 uv;
void main() {
gl_Position = vec4(vertex.xy / canvas * 2.0 - 1.0, 0.0, 1.0);
uv = vertex.zw / source;
}
''', '''
#version 430
uniform sampler2D material;
in vec2 uv;
out vec4 result;
void main() {
result = vec4(texture(material, uv).rgb, 1.0);
}
'''),
'capsules': (_GL_HAIRLINE_VERTEX, '''
#version 430
flat in vec4 ends;
flat in float reach;
out float cover;
void main() {
vec2 p = gl_FragCoord.xy - ends.xy;
vec2 d = ends.zw - ends.xy;
float t = clamp(dot(p, d) / max(dot(d, d), 1e-12), 0.0, 1.0);
float r = length(p - d * t);
cover = clamp(min(r + reach, 0.5) - max(r - reach, -0.5), 0.0, 1.0);
}
'''),
'hairlines': (_GL_HAIRLINE_VERTEX, '''
#version 430
uniform vec4 colour;
flat in vec4 ends;
flat in float reach;
flat in float capped;
out vec4 result;
void main() {
vec2 d = ends.zw - ends.xy;
bool across = abs(d.x) >= abs(d.y);
float major = across ? d.x : d.y;
if (abs(major) < 1e-6) discard;
vec2 p = gl_FragCoord.xy;
float t = ((across ? p.x : p.y) - (across ? ends.x : ends.y)) / major;
if (t < 0.0 || t >= 1.0) discard;
int flags = int(capped + 0.5);
bool forward = major > 0.0;
bool low_capped = forward ? (flags & 1) != 0 : (flags & 2) != 0;
if (low_capped) {
float low = forward ? (across ? ends.x : ends.y) : (across ? ends.z : ends.w);
t += (fract(low) >= 0.5 ? 0.5 : -0.5) / major;
}
float line = (across ? ends.y : ends.x) + t * (across ? d.y : d.x);
float cover = max(0.0, 1.0 - abs((across ? p.y : p.x) - line));
float a = colour.a * cover * min(1.0, reach * 2.0);
if (a <= 0.0) discard;
result = vec4(colour.bgr * a, a);
}
'''),
'coverage': ('''
#version 430
uniform vec4 bounds;
uniform vec2 canvas;
''' + _GL_QUAD + '''
void main() {
gl_Position = canvas_position(mix(bounds.xy, bounds.zw, quad_corner()), canvas);
}
''', '''
#version 430
uniform sampler2D coverage;
uniform vec4 colour;
out vec4 result;
void main() {
float a = colour.a * texelFetch(coverage, ivec2(gl_FragCoord.xy), 0).r;
result = vec4(colour.bgr * a, a);
}
'''),
'tiles': ('''
#version 430
in vec3 place;
in vec4 rect;
in vec2 offset;
uniform vec2 canvas;
out vec2 texel;
flat out vec4 bounds;
''' + _GL_QUAD + '''
void main() {
vec2 corner = quad_corner() * rect.zw;
vec2 local = offset + corner;
float c = cos(place.z);
float s = sin(place.z);
vec2 point = place.xy + vec2(c * local.x - s * local.y, s * local.x + c * local.y);
gl_Position = canvas_position(point, canvas);
texel = rect.xy + corner;
bounds = vec4(rect.xy + 0.5, rect.xy + rect.zw - 0.5);
}
''', '''
#version 430
uniform sampler2D atlas;
in vec2 texel;
flat in vec4 bounds;
out vec4 result;
void main() {
vec2 at = clamp(texel, bounds.xy, bounds.zw);
result = vec4(texture(atlas, at / vec2(textureSize(atlas, 0))).rgb, 1.0);
}
'''),
'subpixel_grains': ('''
#version 430
in vec3 grain;
uniform vec2 canvas;
flat out vec2 base;
flat out vec2 offset;
flat out float scale;
''' + _GL_QUAD + _GL_GRAIN_WEIGHT + '''
void main() {
vec2 centre = grain.xy - 0.5;
base = floor(centre);
offset = centre - base;
float total = 0.0;
for (int a = 0; a < 4; ++a) {
for (int b = 0; b < 4; ++b) {
total += grain_weight(vec2(float(b - 1), float(a - 1)) - offset);
}
}
scale = grain.z * 255.0 * 4.68 / total;
gl_Position = canvas_position(base - 1.0 + quad_corner() * 4.0, canvas);
}
''', '''
#version 430
layout(std430,binding=2) readonly buffer Palette { uint palette[]; };
flat in vec2 base;
flat in vec2 offset;
flat in float scale;
out vec4 colour;
''' + _GL_GRAIN_WEIGHT + '''
void main() {
vec2 d = (floor(gl_FragCoord.xy) - base) - offset;
float level = floor(grain_weight(d) * scale + 0.5);
if (level <= 0.0) discard;
uint word = palette[uint(min(level, 255.0))];
colour = vec4(float(word & 255u), float((word >> 8u) & 255u),
float((word >> 16u) & 255u), float((word >> 24u) & 255u)) / 255.0;
}
'''),
}
class _FlowPointGraphics:
"""Render monotonic packed grains in a producer-owned OpenGL framebuffer.
The context, buffers and framebuffer belong to the constructing thread.
Each returned RGB32 image owns its pixels; later frames cannot modify it.
"""
def __init__(self):
"""Create the optional EGL context after a successful isolated probe."""
import moderngl
self._owner = threading.get_ident()
self._gl = moderngl
self._resources = []
self._context = None
self._framebuffer = None
self._texture = None
self._size = None
self._lookup_bytes = None
try:
self._context = moderngl.create_context(
**_graphics_context_options())
self._program = self._context.program(vertex_shader='''
#version 430
in ivec3 position;
uniform vec2 canvas;
uniform bool spread;
flat out uint level;
void main() {
gl_Position = vec4((vec2(position.xy) + vec2(0.5)) /
canvas * 2.0 - 1.0, 0.0, 1.0);
gl_PointSize = spread ? 3.0 : 1.0;
level = uint(position.z);
}
''', fragment_shader='''
#version 430
layout(std430,binding=2) readonly buffer Palette { uint palette[]; };
uniform bool spread;
flat in uint level;
out vec4 colour;
void main() {
ivec2 delta = spread ? ivec2(floor(gl_PointCoord * 3.0)) -
ivec2(1) : ivec2(0);
uint offset = (delta.x == 0 && delta.y == 0) ? 0u :
((delta.x != 0 && delta.y != 0) ? 512u : 256u);
uint word = palette[offset + level];
colour = vec4(float(word & 255u), float((word >> 8u) & 255u),
float((word >> 16u) & 255u), float((word >> 24u) & 255u)) / 255.0;
}
''')
self._resources.append(self._program)
self._vertices = self._context.buffer(reserve=12)
self._resources.append(self._vertices)
self._palette = self._context.buffer(reserve=768 * 4)
self._resources.append(self._palette)
self._vao = self._context.vertex_array(
self._program, [(self._vertices, '3i', 'position')])
self._resources.append(self._vao)
self._context.enable(moderngl.PROGRAM_POINT_SIZE | moderngl.BLEND)
try:
self.renderer = str(self._context.info.get('GL_RENDERER', 'unknown renderer'))
except Exception:
self.renderer = 'unknown renderer'
except Exception:
self._close()
raise
def _draw(self, width, height, px, py, intensities, lookup, spread, dark):
"""Stamp grains and read them directly into a newly owned QImage."""
if threading.get_ident() != self._owner:
raise RuntimeError('flow graphics context belongs to another thread')
np = _numpy()
if self._size != (width, height):
if self._framebuffer is not None:
self._framebuffer.release()
self._texture.release()
self._texture = self._context.texture((width, height), 4, dtype='f1')
self._framebuffer = self._context.framebuffer([self._texture])
self._size = width, height
levels = np.arange(256, dtype=np.uint8)
table = np.concatenate((lookup, lookup[np.rint(levels * .68).astype(np.uint8)],
lookup[np.rint(levels * .24).astype(np.uint8)]))
raw = table.tobytes()
if raw != self._lookup_bytes:
self._palette.write(raw)
self._lookup_bytes = raw
points = np.empty((len(px), 3), dtype=np.int32)
points[:, 0], points[:, 1], points[:, 2] = px, py, intensities
raw = points.tobytes()
if len(raw) > self._vertices.size:
self._vertices.orphan(len(raw))
if raw:
self._vertices.write(raw)
self._palette.bind_to_storage_buffer(2)
self._framebuffer.use()
self._context.viewport = (0, 0, width, height)
self._context.blend_equation = self._gl.MAX if dark else self._gl.MIN
background = 0.0 if dark else 1.0
self._framebuffer.clear(background, background, background, 1.0)
self._program['canvas'].value = float(width), float(height)
self._program['spread'].value = bool(spread)
if len(points):
self._vao.render(mode=self._gl.POINTS, vertices=len(points))
image = QImage(width, height, QImage.Format_RGB32)
self._framebuffer.read_into(image.bits(), components=4, alignment=1)
return image
def _draw_subpixel(self, width, height, xs, ys, values, lookup, dark):
"""Stamp grains at fractional centres like :func:`_scatter_subpixel_grains`.
Each grain is one 4x4-pixel quad over the CPU kernel's footprint,
weighted by the same radial profile and renormalised to the same
total, so a rippling grain glides on both paths. Colours come from
``lookup`` by rounded level and overlap by brightest (dark) or
darkest (light) channel, the CPU kernel's ordering for these
monotonic tables.
"""
np = _numpy()
background = 0.0 if dark else 1.0
self._target(width, height, (background, background, background, 1.0))
rows = np.empty((len(xs), 3), dtype=np.float32)
rows[:, 0], rows[:, 1], rows[:, 2] = xs, ys, values
program = self._shader('subpixel_grains')
self._context.blend_equation = self._gl.MAX if dark else self._gl.MIN
self._uniform(program, 'canvas', (float(width), float(height)))
self._stream('subpixel_palette', np.ascontiguousarray(lookup, dtype=np.uint32)
).bind_to_storage_buffer(2)
array = self._array(program, self._stream('subpixel_grains', rows), '3f/i', 'grain')
array.render(mode=self._gl.TRIANGLE_STRIP, vertices=4, instances=len(rows))
return self._read(width, height)
def _shader(self, name):
"""Compile one theme program from :data:`_GL_SHADERS` on first use."""
programs = self.__dict__.setdefault('_programs', {})
program = programs.get(name)
if program is None:
vertex, fragment = _GL_SHADERS[name]
program = self._context.program(vertex_shader=vertex.strip(),
fragment_shader=fragment.strip())
self._resources.append(program)
programs[name] = program
return program
@staticmethod
def _uniform(program, name, value):
"""Set a uniform the compiler kept; optimised-out names are ignored."""
try:
member = program[name]
except KeyError:
return
if isinstance(value, (bytes, bytearray)):
member.write(value)
else:
member.value = value
def _stream(self, name, data):
"""Write ``data`` into a reusable named buffer and return it."""
buffers = self.__dict__.setdefault('_streams', {})
raw = data.tobytes()
buffer = buffers.get(name)
if buffer is None:
buffer = self._context.buffer(reserve=max(64, len(raw)))
self._resources.append(buffer)
buffers[name] = buffer
if len(raw) > buffer.size:
buffer.orphan(len(raw))
if raw:
buffer.write(raw)
return buffer
@staticmethod
def _active_layout(program, layout, names):
"""Drop attributes the shader linker removed, keeping the stride.
A driver may optimise away an input whose value never reaches the
output, and a binding should not depend on that choice. Each such
attribute becomes padding.
"""
formats, _slash, divisor = layout.partition('/')
parts = formats.split()
kept, active = [], []
for part, name in zip(parts, names):
if name in program:
kept.append(part)
active.append(name)
else:
count = int(part[:-1] or 1)
size = {'f': 4, 'i': 4, 'u': 4}.get(part[-1], 4)
kept.append(f'{count * size}x')
layout = ' '.join(kept) + (f'/{divisor}' if divisor else '')
return layout, tuple(active)
def _array(self, program, buffer, layout, *names):
"""A vertex array over one stream, cached per program and layout."""
arrays = self.__dict__.setdefault('_arrays', {})
key = (id(program), id(buffer), layout, names)
array = arrays.get(key)
if array is None:
layout, names = self._active_layout(program, layout, names)
array = self._context.vertex_array(program, [(buffer, layout, *names)])
self._resources.append(array)
arrays[key] = array
return array
def _bare(self, program):
"""An attribute-free vertex array for a program that uses gl_VertexID."""
arrays = self.__dict__.setdefault('_arrays', {})
array = arrays.get((id(program), None))
if array is None:
array = self._context.vertex_array(program, [])
self._resources.append(array)
arrays[(id(program), None)] = array
return array
def _scratch(self, name, size, components=4, filtered=False):
"""A reusable named texture and framebuffer of ``size``."""
targets = self.__dict__.setdefault('_scratch_targets', {})
entry = targets.get(name)
if entry is None or entry[0].size != tuple(size):
if entry is not None:
entry[1].release()
entry[0].release()
texture = self._context.texture(tuple(size), components, dtype='f1')
texture.repeat_x = texture.repeat_y = False
linear = self._gl.LINEAR if filtered else self._gl.NEAREST
texture.filter = linear, linear
entry = texture, self._context.framebuffer([texture])
targets[name] = entry
return entry
def _target(self, width, height, clear):
"""Bind the shared frame target at this size and clear it to ``clear``."""
if threading.get_ident() != self._owner:
raise RuntimeError('flow graphics context belongs to another thread')
if self._size != (width, height):
if self._framebuffer is not None:
self._framebuffer.release()
self._texture.release()
self._texture = self._context.texture((width, height), 4, dtype='f1')
self._framebuffer = self._context.framebuffer([self._texture])
self._size = width, height
self._framebuffer.use()
self._context.viewport = (0, 0, width, height)
self._context.scissor = None
self._framebuffer.clear(*clear)
self._context.blend_equation = self._gl.FUNC_ADD
def _blend(self, composition):
"""Match a QPainter composition mode on premultiplied BGRA output.
``plus`` and ``multiply`` keep the opaque destination alpha of an
RGB32 frame; anything else is premultiplied source-over.
"""
gl = self._gl
self._context.blend_equation = gl.FUNC_ADD
if composition == 'plus':
self._context.blend_func = gl.ONE, gl.ONE, gl.ZERO, gl.ONE
elif composition == 'multiply':
self._context.blend_func = (gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA,
gl.ZERO, gl.ONE)
else:
self._context.blend_func = (gl.ONE, gl.ONE_MINUS_SRC_ALPHA,
gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
def _read(self, width, height, image_format=QImage.Format_RGB32):
"""Copy the frame target into a newly owned QImage."""
image = QImage(width, height, image_format)
self._framebuffer.read_into(image.bits(), components=4, alignment=1)
return image
def _discs(self, width, height, rows, scale=(1.0, 1.0), smooth=True):
"""Blend rows of ``x, y, radius, r, g, b, alpha`` discs in row order."""
np = _numpy()
rows = np.ascontiguousarray(rows, dtype=np.float32).reshape(-1, 7)
if not len(rows):
return
program = self._shader('discs')
self._uniform(program, 'canvas', (float(width), float(height)))
self._uniform(program, 'scale', (float(scale[0]), float(scale[1])))
self._uniform(program, 'smooth_edge', bool(smooth))
array = self._array(program, self._stream('discs', rows), '3f 4f/i',
'disc', 'tint')
array.render(mode=self._gl.TRIANGLE_STRIP, vertices=4, instances=len(rows))
def _draw_blobs(self, width, height, blobs, stops, dark):
"""Shade radial-gradient blobs from rows of ``cx, cy, radius, alpha, r, g, b``."""
np = _numpy()
identity = 0.0 if dark else 1.0
self._target(width, height, (identity, identity, identity, 1.0))
rows = np.ascontiguousarray(blobs, dtype=np.float32).reshape(-1, 7)
if len(rows):
program = self._shader('blobs')
self._blend('plus' if dark else 'multiply')
self._uniform(program, 'canvas', (float(width), float(height)))
self._uniform(program, 'stops', np.asarray(stops, dtype=np.float32).tobytes())
array = self._array(program, self._stream('blobs', rows), '4f 3f/i',
'shape', 'tint')
array.render(mode=self._gl.TRIANGLE_STRIP, vertices=4, instances=len(rows))
return self._read(width, height)
def _draw_stars(self, width, height, rows, scale, smooth):
"""Draw ordered star discs onto a transparent premultiplied frame."""
self._target(width, height, (0.0, 0.0, 0.0, 0.0))
self._blend('over')
self._discs(width, height, rows, scale, smooth)
return self._read(width, height, QImage.Format_ARGB32_Premultiplied)
def _draw_aurora(self, width, height, curtains, dark):
"""Generate each curtain's ray texture, then warp it along its folds.
``curtains`` holds ``(columns, lookup, per_column, strips)`` per
curtain: ``columns`` rows of ``distance, ray length, gain, colour``,
``lookup`` the colour table, and ``strips`` six vertices per strip
of ``x, y, source x, source y`` in texture pixels.
"""
np = _numpy()
identity = 0.0 if dark else 1.0
self._target(width, height, (identity, identity, identity, 1.0))
generator = self._shader('aurora_texture')
strips = self._shader('strips')
for index, (columns, lookup, per_column, vertices) in enumerate(curtains):
columns = np.ascontiguousarray(columns, dtype=np.float32).reshape(-1, 4)
vertices = np.ascontiguousarray(vertices, dtype=np.float32).reshape(-1, 4)
if not len(columns) or not len(vertices):
continue
size = len(columns), _AURORA_TEXTURE_ROWS
texture, framebuffer = self._scratch('aurora', size, filtered=True)
table = np.zeros((len(lookup), 4), dtype=np.float32)
table[:, :3] = lookup
self._stream('aurora_columns', columns).bind_to_storage_buffer(3)
self._stream('aurora_lookup', table).bind_to_storage_buffer(4)
framebuffer.use()
self._context.viewport = (0, 0) + size
self._context.disable(self._gl.BLEND)
self._uniform(generator, 'per_column', bool(per_column))
self._uniform(generator, 'dark', bool(dark))
self._uniform(generator, 'rows', float(_AURORA_TEXTURE_ROWS))
self._bare(generator).render(mode=self._gl.TRIANGLES, vertices=3)
self._context.enable(self._gl.BLEND)
self._framebuffer.use()
self._context.viewport = (0, 0, width, height)
self._blend('plus' if dark else 'multiply')
texture.use(location=0)
self._uniform(strips, 'material', 0)
self._uniform(strips, 'canvas', (float(width), float(height)))
self._uniform(strips, 'source', (float(size[0]), float(size[1])))
self._array(strips, self._stream('aurora_strips', vertices), '4f',
'vertex').render(mode=self._gl.TRIANGLES, vertices=len(vertices))
return self._read(width, height)
def _draw_filaments(self, width, height, groups, segments, tips, dark):
"""Stroke each filament group as one union, then the live tips.
``segments`` rows are ``x0, y0, x1, y1, half width, caps`` (caps:
1 when the first end starts a subpath, 2 when the second ends one,
already extended half a pixel as Qt's hairline caps are); each group is
``(first, count, left, top, right, bottom, r, g, b, alpha)`` over
them. A group's capsules are combined with MAX into a coverage
target first, so overlapping pieces of one QPainterPath are blended
once, as QPainter strokes a path.
"""
np = _numpy()
identity = 0.0 if dark else 1.0
self._target(width, height, (identity, identity, identity, 1.0))
composition = 'plus' if dark else 'multiply'
segments = np.ascontiguousarray(segments, dtype=np.float32).reshape(-1, 6)
if len(segments) and groups:
capsules = self._shader('capsules')
combine = self._shader('coverage')
hairlines = self._shader('hairlines')
coverage, cover_target = self._scratch('filaments', (width, height), 1)
quad = self._bare(combine)
self._uniform(capsules, 'canvas', (float(width), float(height)))
self._uniform(combine, 'canvas', (float(width), float(height)))
self._uniform(combine, 'coverage', 0)
for first, count, left, top, right, bottom, red, green, blue, alpha in groups:
piece = segments[int(first):int(first) + int(count)]
if len(piece) and piece[0, 4] * 2.0 <= 1.0:
self._framebuffer.use()
self._context.scissor = None
self._blend(composition)
self._uniform(hairlines, 'canvas', (float(width), float(height)))
self._uniform(hairlines, 'colour', (red, green, blue, alpha))
self._array(hairlines, self._stream('filaments', piece), '4f 1f 1f/i',
'segment', 'half_width', 'caps').render(
mode=self._gl.TRIANGLE_STRIP, vertices=4, instances=len(piece))
continue
left, top = max(0, int(left)), max(0, int(top))
right, bottom = min(width, int(math.ceil(right))), min(height, int(math.ceil(bottom)))
if right <= left or bottom <= top or count <= 0:
continue
box = left, top, right - left, bottom - top
cover_target.use()
self._context.scissor = box
cover_target.clear(0.0, 0.0, 0.0, 0.0)
self._context.blend_equation = self._gl.MAX
self._context.blend_func = self._gl.ONE, self._gl.ONE
self._array(capsules, self._stream('filaments', piece), '4f 1f 1f/i',
'segment', 'half_width', 'caps').render(
mode=self._gl.TRIANGLE_STRIP, vertices=4, instances=len(piece))
self._framebuffer.use()
self._context.scissor = box
self._blend(composition)
coverage.use(location=0)
self._uniform(combine, 'bounds', (float(left), float(top),
float(right), float(bottom)))
self._uniform(combine, 'colour', (red, green, blue, alpha))
quad.render(mode=self._gl.TRIANGLE_STRIP, vertices=4)
self._context.scissor = None
self._framebuffer.use()
self._blend(composition)
self._discs(width, height, tips)
return self._read(width, height)
def _draw_tiles(self, width, height, material, placements, dark):
"""Place cached paper tiles, rotated where the pointer spun them.
``material`` is the engine's tuple of cells (its QImage tiles are
packed into one atlas texture, rebuilt only when that tuple is
replaced); ``placements`` holds ``(index, x, y, degrees)``.
"""
np = _numpy()
atlas = self.__dict__.get('_atlas')
if atlas is None or atlas[0] is not material:
if atlas is not None:
atlas[1].release()
self.__dict__['_atlas'] = None
self.__dict__['_atlas'] = atlas = (material, *self._pack_tiles(material, dark))
_material, texture, rects = atlas
identity = 0.0 if dark else 1.0
self._target(width, height, (identity, identity, identity, 1.0))
rows = np.empty((len(placements), 9), dtype=np.float32)
for row, (index, x, y, degrees) in zip(rows, placements):
extent_x, extent_y = material[index][6], material[index][7]
left, top, tile_width, tile_height = rects[index]
if degrees == 0.0:
row[:] = (math.floor(x - extent_x + 0.5), math.floor(y - extent_y + 0.5),
0.0, left, top, tile_width, tile_height, 0.0, 0.0)
else:
row[:] = (x, y, math.radians(degrees), left, top, tile_width,
tile_height, -extent_x, -extent_y)
if len(rows):
program = self._shader('tiles')
self._blend('plus' if dark else 'multiply')
texture.use(location=0)
self._uniform(program, 'atlas', 0)
self._uniform(program, 'canvas', (float(width), float(height)))
self._array(program, self._stream('tiles', rows), '3f 4f 2f/i',
'place', 'rect', 'offset').render(
mode=self._gl.TRIANGLE_STRIP, vertices=4, instances=len(rows))
return self._read(width, height)
def _pack_tiles(self, material, dark):
"""Shelf-pack every cell tile into one bilinear atlas texture."""
np = _numpy()
limit = min(16384, int(self._context.info.get('GL_MAX_TEXTURE_SIZE', 8192)))
sizes = [(cell[5].width(), cell[5].height()) for cell in material]
if not sizes:
sizes = [(1, 1)]
shelf = max(max(w for w, _ in sizes) + 2, min(limit, 4096))
rects, x, y, line = [], 0, 0, 0
for tile_width, tile_height in sizes:
if x + tile_width + 2 > shelf:
x, y, line = 0, y + line, 0
rects.append((x + 1, y + 1, tile_width, tile_height))
x += tile_width + 2
line = max(line, tile_height + 2)
height = y + line
if shelf > limit or height > limit or shelf * height > 64 * 1024 ** 2:
raise RuntimeError('paper tiles exceed the graphics atlas budget')
fill = 0 if dark else 255
pixels = np.full((height, shelf, 4), fill, dtype=np.uint8)
pixels[:, :, 3] = 255
for cell, (left, top, tile_width, tile_height) in zip(material, rects):
tile = cell[5].convertToFormat(QImage.Format_RGB32)
raw = np.frombuffer(tile.constBits(), dtype=np.uint8).reshape(
tile_height, tile.bytesPerLine())[:, :tile_width * 4]
pixels[top:top + tile_height, left:left + tile_width] = raw.reshape(
tile_height, tile_width, 4)
texture = self._context.texture((shelf, height), 4, pixels.tobytes(), dtype='f1')
texture.repeat_x = texture.repeat_y = False
texture.filter = self._gl.LINEAR, self._gl.LINEAR
return texture, rects
def _close(self):
"""Release all graphics resources on the constructing thread."""
if threading.get_ident() != self._owner:
raise RuntimeError('flow graphics release belongs to another thread')
extras = [resource for pair in
self.__dict__.pop('_scratch_targets', {}).values()
for resource in reversed(pair)]
atlas = self.__dict__.pop('_atlas', None)
if atlas is not None:
extras.append(atlas[1])
for resource in (self._framebuffer, self._texture, *extras,
*reversed(self._resources), self._context):
if resource is not None:
try:
resource.release()
except Exception:
LOG.debug('could not release a flow graphics resource', exc_info=True)
for name in ('_programs', '_streams', '_arrays'):
self.__dict__.pop(name, None)
self._resources = []
self._context = self._framebuffer = self._texture = None
class _DataArtEngine(_GraphicsBackend, _BufferedEngine):
"""Retained crisp procedural materials with native display sampling.
The existing producer owns every shade pass. Reusable coordinates and
static material layers are cached per buffer size, while the clock and an
optional immutable local pointer shape the small moving layer.
:param family: one of the five retained data-art materials.
"""
base_edge = 2048
_families = ("point_atlas", "tissue_facets", "chromatin_ribbon",
"genetic_advection", "impulse_lens")
_interactive = frozenset(("point_atlas", "tissue_facets", "genetic_advection",
"impulse_lens"))
def __init__(self, *args, family: str, **kwargs):
"""Choose a material before the seeded configuration is rolled."""
if family not in self._families:
raise ValueError(f"unknown data art family {family!r}")
self.family = family
self.name = f"data_art_{family}"
self.interactive = family in self._interactive
self.pointer: Optional[Tuple[float, float]] = None
self.gravity_radius = 0.0
self.ripples_enabled = True
self._ripple_intensity = 1.0
self._graphics_backend = 'cpu'
self._point_graphics = None
self._graphics_failed = False
self._graphics_idle_until = 0.0
self._graphics_idle = False
super().__init__(*args, **kwargs)
def _graphics_unmeasured(self, width, height, work=0) -> bool:
"""Automatic before measurements: dense 4K points, or 4K spinning paper.
Spinn under a moving pointer cost 62 ms per 4K CPU frame in the
2026-10-10 audit; its resting frame is reused and costs nothing.
"""
if self.family == "tissue_facets":
return width * height >= 3840 * 2160
return width * height >= 3840 * 2160 and work >= 100000
def _graphics_point_image(self, width, height, px, py, intensities,
lookup, spread):
"""Use optional graphics only for supported producer-thread material."""
return self._graphics_frame('_draw', width, height, px, py, intensities,
lookup, spread, self.dark, work=len(px))
def _configure(self, rng: random.Random) -> None:
"""Keep a bounded seed pool and stable material identity."""
self._art_seed = rng.randrange(2 ** 32)
self._anchors = tuple((rng.random(), rng.random(), rng.random())
for _ in range(192))
self._material_cache: Dict[tuple, object] = {}
self._gravity_impulses = []
self._gravity_ripple_impulses = {}
self._popup_waves = []
self._column_waves = []
self._fold_waves = []
self._popup_wave_origin = None
self._popup_wave_window = None
self._popup_wave_elapsed = 0.0
self._pointer_impulse_time = -math.inf
self._pointer_impulse_origin = None
self._field_grab_origin = None
self._field_grab_center = None
self._field_grab_offset = (0.0, 0.0)
self._field_grab_velocity = (0.0, 0.0)
self._field_grab_target = (0.0, 0.0)
self._field_grab_held = False
self._field_grab_time = self.time
def _set_field_grab(self, grab, *, reset=False) -> None:
"""Offer a finite local handle without moving or reseeding material.
``grab`` is a normalized origin and shorter-edge displacement pair.
The displacement is bounded to 18 percent of that edge. A release
changes only the spring target; lifecycle cancellation clears state.
The caller holds the engine lock, including queued worker consumers.
"""
if self.family != "impulse_lens":
return
if reset:
self._field_grab_origin = self._field_grab_center = None
self._field_grab_offset = self._field_grab_velocity = (0.0, 0.0)
self._field_grab_time = self.time
self._field_grab_held = grab is not None
self._field_grab_target = (0.0, 0.0)
if grab is None:
return
origin, target = grab
if not all(math.isfinite(value) for value in (*origin, *target)):
self._field_grab_held = False
return
origin = tuple(max(0.0, min(1.0, value)) for value in origin)
length = math.hypot(*target)
scale = min(1.0, 0.18 / length) if length else 1.0
self._field_grab_target = tuple(value * scale for value in target)
if self._field_grab_center is None:
self._field_grab_center = origin
self._field_grab_time = self.time
self._field_grab_origin = origin
def _step_field_grab(self) -> None:
"""Evolve a bounded critically damped handle in animation seconds.
The exact constant-target spring solution uses frequency 6/s and
retained velocity, so release is continuous and splitting elapsed
time does not change it. A new handle center eases toward its origin
instead of teleporting a returning patch. No Qt objects are touched.
"""
elapsed = self.time - self._field_grab_time
self._field_grab_time = self.time
if self._field_grab_center is None or elapsed <= 0.0:
return
if not math.isfinite(elapsed) or elapsed > 120.0:
elapsed = 120.0
decay = math.exp(-6.0 * elapsed)
values, velocities = [], []
for value, velocity, target in zip(self._field_grab_offset,
self._field_grab_velocity,
self._field_grab_target):
difference = value - target
tangent = velocity + 6.0 * difference
values.append(target + (difference + tangent * elapsed) * decay)
velocities.append((velocity - 6.0 * tangent * elapsed) * decay)
length = math.hypot(*values)
scale = min(1.0, 0.18 / length) if length else 1.0
self._field_grab_offset = tuple(value * scale for value in values)
self._field_grab_velocity = tuple(value * scale for value in velocities)
self._field_grab_center = tuple(
target + (value - target) * decay
for value, target in zip(self._field_grab_center, self._field_grab_origin))
if (not self._field_grab_held and length < 1e-6
and math.hypot(*velocities) < 1e-6):
self._set_field_grab(None, reset=True)
def _restyle(self) -> None:
"""Invalidate rendered material when its palette or page changes."""
super()._restyle()
self._material_cache.clear()
def _reresolve(self) -> None:
"""Resize the producer buffer and discard size-bound coordinates."""
super()._reresolve()
self._material_cache.clear()
def buffer_size(self, width: int, height: int) -> Tuple[int, int]:
"""Sample native display pixels within the actual screen budget."""
detail = min(1.0, self.resolution)
bw, bh = max(1, int(width * detail)), max(1, int(height * detail))
scale = min(1.0, math.sqrt(self.max_pixels / (bw * bh)))
return max(1, int(bw * scale)), max(1, int(bh * scale))
def buffer_scale(self, width: int, height: int) -> float:
"""Report the native art sampling ratio for explicit blur controls."""
bw, bh = self.buffer_size(width, height)
return max(1.0, width / bw, height / bh)
def _ensure_buffer(self, width: int, height: int) -> QImage:
"""Release old-size material grids when the canvas is resized."""
previous = self._buffer
buffer = super()._ensure_buffer(width, height)
if buffer is not previous:
self._material_cache.clear()
return buffer
def _redensify(self) -> None:
"""Rebuild material density without rerolling its seed."""
self._material_cache.clear()
def _resize(self) -> None:
"""Rebuild size-dependent marks without rerolling the scene."""
self._material_cache.clear()
def set_pointer(self, point: Optional[Tuple[float, float]]) -> None:
"""Accept one finite normalized local pointer for interactive art."""
if not self.interactive:
return
if point is None:
self.pointer = None
return
x, y = point
self.pointer = (max(0.0, min(1.0, float(x))),
max(0.0, min(1.0, float(y)))) if (
math.isfinite(x) and math.isfinite(y)) else None
if (self.gravity_radius > 0.0 and self.family == "impulse_lens"
and self.pointer is not None):
previous = self._pointer_impulse_origin
moved = previous is None or math.hypot(
self.pointer[0] - previous[0], self.pointer[1] - previous[1]) > 0.006
if moved and self.time - self._pointer_impulse_time >= 0.06:
self._add_impulse(self.pointer, 0.24)
self._pointer_impulse_time = self.time
self._pointer_impulse_origin = self.pointer
def _add_impulse(self, point, strength: float = 1.0, ripple: bool = True) -> None:
"""Remember a bounded, finite gravity burst in animation time.
``ripple`` false keeps the burst without its travelling wave; click
waves are published separately so a container's own wave can
replace them.
"""
if (self.gravity_radius <= 0.0 or self.family != "impulse_lens"
or point is None):
return
x, y = point
if not all(math.isfinite(value) for value in (x, y, strength)):
return
point = (max(0.0, min(1.0, float(x))),
max(0.0, min(1.0, float(y))))
strength = max(0.0, min(2.0, float(strength)))
if strength == 0.0:
return
recent = [event for event in self._gravity_impulses
if 0.0 <= self.time - event[0] < 5.0]
impulse = (self.time, point, strength)
self._gravity_impulses = (recent + [impulse])[-24:]
self._gravity_ripple_impulses = {
id(event): event for event in self._gravity_impulses
if id(event) in self._gravity_ripple_impulses}
if self.ripples_enabled and ripple:
self._gravity_ripple_impulses[id(impulse)] = impulse
def _normalise_wave_origin(self, point):
"""Keep finite point origins or complete axis-aligned boundaries."""
if point is None:
return None
try:
if len(point) == 2 and not isinstance(point[0], (tuple, list)):
pair = tuple(float(value) for value in point)
if all(math.isfinite(value) and 0.0 <= value <= 1.0
for value in pair):
return pair
return None
if not 1 <= len(point) <= 4:
return None
segments = tuple(tuple(float(value) for value in part)
for part in point)
if not all(len(part) == 4 and all(
math.isfinite(value) and 0.0 <= value <= 1.0
for value in part) and
((part[0] == part[2]) != (part[1] == part[3]))
for part in segments):
return None
return segments
except (TypeError, ValueError, IndexError):
return None
def _set_popup_wave_origin(self, point, *, popup_id=None) -> None:
"""Accept GUI-resolved popup edges or a legacy point without Qt access."""
point = self._normalise_wave_origin(point)
opened = point is not None and (
self._popup_wave_origin is None or
(popup_id is not None and popup_id != self._popup_wave_window))
previous = self._popup_wave_origin
if point is None:
self._popup_wave_elapsed = 0.0
if previous is not None:
self._add_ripple(previous)
elif opened:
self._popup_wave_elapsed = 0.0
self._add_ripple(point)
self._popup_wave_origin = point
self._popup_wave_window = popup_id if point is not None else None
def advance(self, dt: float) -> None:
"""Advance material motion and bounded popup waves at their real-time rate."""
super().advance(dt)
self._popup_waves = [wave for wave in self._popup_waves
if 0.0 <= self.time - wave[0] < 5.0]
self._column_waves = [wave for wave in self._column_waves
if 0.0 <= self.time - wave[0] < RIPPLE_LIFETIME_S]
self._fold_waves = [wave for wave in getattr(self, "_fold_waves", ())
if self.time - wave[0] < FOLD_LIFETIME_S]
if (dt <= 0.0 or not self.ripples_enabled or self.family != "impulse_lens"
or self.popup_wave_frequency <= 0.0
or self._popup_wave_origin is None):
return
period = 60.0 / self.popup_wave_frequency
self._popup_wave_elapsed += dt
if self._popup_wave_elapsed >= period:
self._popup_wave_elapsed %= period
self._popup_waves = (self._popup_waves + [
(self.time, self._popup_wave_origin)])[-6:]
def _add_ripple(self, point) -> None:
"""Emit a bounded field wave independently of pointer gravity."""
if not self.ripples_enabled or self.family != "impulse_lens" or point is None:
return
if len(point) == 3 and point[0] == "rings":
try:
x, y = float(point[1]), float(point[2])
except (TypeError, ValueError):
return
if math.isfinite(x) and math.isfinite(y):
origin = (max(0.0, min(1.0, x)), max(0.0, min(1.0, y)))
self._fold_waves = (self._fold_waves + [
(self.time + ring * FOLD_RING_GAP_S, origin)
for ring in range(FOLD_RINGS)])[-3 * FOLD_RINGS:]
return
if len(point) == 2 and not isinstance(point[0], (tuple, list)):
x, y = point
if not all(math.isfinite(value) for value in (x, y)):
return
origin = (max(0.0, min(1.0, float(x))),
max(0.0, min(1.0, float(y))))
else:
origin = self._normalise_wave_origin(point)
if origin is None:
return
self._popup_waves = (self._popup_waves + [(self.time, origin)])[-6:]
def _add_column_wave(self, column) -> None:
"""Emit a wave that travels only up and down a settings column.
:param column: ``(left, right, edge)`` in normalised units: the
column's horizontal extent and the row the wave starts from.
"""
if not self.ripples_enabled or self.family != "impulse_lens" or column is None:
return
try:
left, right, edge = (float(value) for value in column)
except (TypeError, ValueError):
return
if not all(math.isfinite(value) for value in (left, right, edge)):
return
left, right = sorted((max(0.0, min(1.0, left)), max(0.0, min(1.0, right))))
if right <= left:
return
self._column_waves = (self._column_waves + [
(self.time, (left, right, max(0.0, min(1.0, edge))))])[-6:]
def _ripple_wave(self, distance, age, strength=1.0):
"""Shape one travelling wave as a gentle refraction of the field.
Units are fractions of the shorter canvas edge and seconds. The
``RIPPLE_*`` constants set amplitude, wavenumber, speed, packet width,
brightness change, the still zone at the source, ease-in, lifetime and
a column wave's soft side margin.
The wave moves dots along its own direction by at most a few pixels,
with a slope well below one so neighbouring dots never cross and no
gap opens. The source itself is still: the displacement grows in
smoothly over the first :data:`RIPPLE_SOURCE_CALM` of travel, so no
hole forms where the wave starts. It eases in over
:data:`RIPPLE_EASE_IN_S`, spreads out as it travels and fades out
before :data:`RIPPLE_LIFETIME_S`. Brightness follows the local
compression, lightening crests and dimming troughs alike, so the mean
field brightness does not change.
:param distance: aspect-corrected distance from the source.
:param age: seconds since the wave started.
:param strength: per-wave scale.
:returns: ``(shift, glow)``: outward displacement and brightness change.
"""
np = _numpy()
if not 0.0 <= age < RIPPLE_LIFETIME_S:
return 0.0, 0.0
ease = (_smoothstep(np, 0.0, RIPPLE_EASE_IN_S, age)
* (1.0 - _smoothstep(np, 0.45 * RIPPLE_LIFETIME_S, RIPPLE_LIFETIME_S, age)))
travelled = age * RIPPLE_SPEED
gain = self._ripple_intensity * strength * ease / math.sqrt(1.0 + travelled / 0.18)
front = distance - travelled
packet = np.exp(-(front / RIPPLE_PACKET_WIDTH) ** 2)
calm = _smoothstep(np, 0.0, RIPPLE_SOURCE_CALM, distance)
phase = front * RIPPLE_WAVENUMBER
envelope = gain * packet * calm
shift = RIPPLE_AMPLITUDE * envelope * np.sin(phase)
glow = RIPPLE_GLOW * envelope * np.cos(phase)
return shift, glow
def set_ripples_enabled(self, enabled: bool) -> None:
"""Enable field waves without changing gravity or the held field patch."""
self.ripples_enabled = bool(enabled)
if not self.ripples_enabled:
self._popup_waves.clear()
self._column_waves.clear()
self._fold_waves.clear()
self._gravity_ripple_impulses.clear()
self._popup_wave_elapsed = 0.0
for key, material in self._material_cache.items():
if key[0] == "impulse_lens":
material[3].clear()
def set_gravity_radius(self, radius: float) -> None:
"""Set finite mouse reach in fractions of the shorter screen edge."""
radius = float(radius)
radius = max(0.0, min(1.0, radius)) if math.isfinite(radius) else 0.0
if radius == self.gravity_radius:
return
self.gravity_radius = radius
self._gravity_impulses.clear()
self._gravity_ripple_impulses.clear()
self._pointer_impulse_time = -math.inf
self._pointer_impulse_origin = None
for key, material in tuple(self._material_cache.items()):
if key[0] == "impulse_lens":
material[2].clear()
elif key[0] == "point_atlas":
del self._material_cache[key]
def _pointer_field(self, x, y, width, height):
"""Return compact smooth reach and shorter-edge pointer distances."""
np = _numpy()
shorter = max(1, min(width, height))
dx = (x - self.pointer[0]) * width / shorter
dy = (y - self.pointer[1]) * height / shorter
squared = dx * dx + dy * dy
weight = np.maximum(0.0, 1.0 - squared / self.gravity_radius ** 2) ** 3
return dx, dy, squared, weight
def _bend_pointer(self, x, y, width, height):
"""Apply local gravity without changing any sample outside its reach."""
if self.pointer is None or self.gravity_radius <= 0.0:
return x, y
np = _numpy()
dx, dy, squared, weight = self._pointer_field(x, y, width, height)
direction = 1.0 if self.family == "point_atlas" else -1.0
strength = direction * 0.055 * weight / np.sqrt(squared + 0.013)
shorter = max(1, min(width, height))
return (x + dx * strength * shorter / width,
y + dy * strength * shorter / height)
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""Return deterministic sampled material anchors for the engine API.
:param width: destination width in pixels.
:param height: destination height in pixels.
:returns: bounded ``(x, y, depth)`` samples in seed order.
"""
if width <= 0 or height <= 0:
return ()
count = self.element_count(64, len(self._anchors))
family_phase = self._families.index(self.family) * 0.17
drift = self.time * 0.006
return tuple(((u + 0.015 * math.sin(drift + v * 8.0 + family_phase))
* width, (v + 0.014 * math.cos(drift + u * 7.0))
* height, depth) for u, v, depth in self._anchors[:count])
def _ink(self, index: int, alpha: float) -> QColor:
"""Return a page-aware palette colour with bounded transparency."""
return _with_alpha(self.paint_colors[index % len(self.paint_colors)],
alpha * self.alpha_scale())
def _point_material(self, width: int, height: int, x, y, light,
spread: bool = False) -> QImage:
"""Stamp circular antialiased grains without a full-size float field."""
if self._random_palette:
self._release_point_graphics()
image = self._colored_point_material(width, height, x, y, light, spread)
self._flicker_field_dots(image, x, y)
return image
np = _numpy()
subpixel = spread and self.family == "impulse_lens"
if subpixel:
fx = np.asarray(x, dtype=np.float32).reshape(-1)
fy = np.asarray(y, dtype=np.float32).reshape(-1)
px, py = np.floor(fx).astype(np.int32), np.floor(fy).astype(np.int32)
exact = np.clip(np.broadcast_to(np.asarray(light, dtype=np.float32)
* self.alpha_scale(), fx.shape), 0.0, 1.0)
else:
px = np.asarray(x, dtype=np.int32)
py = np.asarray(y, dtype=np.int32)
values = np.clip(np.asarray(light, dtype=np.float32)
* self.alpha_scale(), 0.0, 1.0)
inside = ((px >= 0) & (px < width) & (py >= 0) & (py < height))
px, py, values = px[inside], py[inside], values[inside]
intensities = np.rint(values * 255).astype(np.uint8)
levels = np.arange(256, dtype=np.float32) / 255.0
palette = self.paint_colors
lookup = np.full(256, np.uint32(0xFF000000), dtype=np.uint32)
for channel, shift in (("red", 16), ("green", 8), ("blue", 0)):
primary = getattr(palette[0], channel)()
accent = getattr(palette[min(1, len(palette) - 1)], channel)()
ink = 0.78 * primary + 0.22 * accent
value = ink * levels if self.dark else 255.0 - (255.0 - ink) * levels
lookup |= np.asarray(value, dtype=np.uint32) << shift
if subpixel:
near = (fx > -2.0) & (fx < width + 2.0) & (fy > -2.0) & (fy < height + 2.0)
fx, fy, exact = fx[near], fy[near], np.ascontiguousarray(exact[near])
image = self._graphics_frame('_draw_subpixel', width, height, fx, fy, exact,
lookup, self.dark, work=len(fx))
else:
image = self._graphics_point_image(
width, height, px, py, intensities, lookup, spread)
if image is not None:
self._flicker_field_dots(image, px, py)
return image
image = QImage(width, height, QImage.Format_RGB32)
flat = np.frombuffer(image.bits(), dtype=np.uint32,
count=width * height)
flat.fill(lookup[0])
if subpixel:
kernel = _ready_subpixel_scatter()
if kernel is not None:
kernel(flat, fx, fy, exact, lookup, width, height, self.dark,
np.zeros(16, dtype=np.float64))
else:
_subpixel_grains_numpy(np, flat, fx, fy, exact, lookup,
width, height, self.dark)
self._flicker_field_dots(image, px, py)
return image
if spread:
kernel = _ready_packed_scatter()
if kernel is not None:
level_ids = np.arange(256, dtype=np.uint8)
axial = np.rint(level_ids * 0.68).astype(np.uint8)
diagonal = np.rint(level_ids * 0.24).astype(np.uint8)
kernel(flat, px, py, intensities, lookup, lookup[axial], lookup[diagonal],
width, height, self.dark)
self._flicker_field_dots(image, px, py)
return image
combine = np.maximum.at if self.dark else np.minimum.at
combine(flat, py * width + px, lookup[intensities])
if spread:
for shift_y, shift_x in ((-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1)):
shifted_x = px + shift_x
shifted_y = py + shift_y
valid = ((shifted_x >= 0) & (shifted_x < width)
& (shifted_y >= 0) & (shifted_y < height))
coverage = 0.24 if shift_x and shift_y else 0.68
intensity = np.rint(intensities[valid] * coverage).astype(np.uint8)
destinations = shifted_y[valid] * width + shifted_x[valid]
combine(flat, destinations, lookup[intensity])
self._flicker_field_dots(image, px, py)
return image
def _colored_point_material(self, width, height, x, y, light, spread):
"""Colour stable grain identities with exact intensity-ranked overlap."""
global _COLORED_SCATTER, _COLORED_SCATTER_FAILED
np = _numpy()
px, py = np.asarray(x, np.int32), np.asarray(y, np.int32)
count = px.shape[-1]
identity_cache = self._material_cache.get("random_grain_identities")
if identity_cache is None or identity_cache[0] != count:
identities = np.arange(count, dtype=np.uint32)
mixed = identities * np.uint32(0x9e3779b1) + np.uint32(self._art_seed & 0xffffffff)
mixed ^= mixed >> 16
identity_cache = count, (mixed % len(self.paint_colors)).astype(np.uint16)
self._material_cache["random_grain_identities"] = identity_cache
values = np.clip(np.asarray(light, np.float32) * self.alpha_scale(), 0.0, 1.0)
inside = (px >= 0) & (px < width) & (py >= 0) & (py < height)
px, py = px[inside], py[inside]
hues = np.broadcast_to(identity_cache[1], inside.shape)[inside]
intensities = np.rint(values[inside] * 255).astype(np.uint16)
indices = hues * np.uint16(256) + intensities
key = ("random_grain_palette", self.dark)
tables = self._material_cache.get(key)
if tables is None:
colors = np.asarray([[color.red(), color.green(), color.blue()]
for color in self.paint_colors], np.float32)
levels = np.arange(256, dtype=np.float32) / 255.0
channels = colors[:, None, :] * levels[None, :, None] if self.dark else (
255.0 - (255.0 - colors[:, None, :]) * levels[None, :, None])
channels = channels.astype(np.uint32)
table = (channels[:, :, 0] << np.uint32(16)
| channels[:, :, 1] << np.uint32(8) | channels[:, :, 2])
ranks = np.arange(256, dtype=np.uint32)
table |= (ranks if self.dark else 255 - ranks)[None, :] << np.uint32(24)
table = table.ravel()
slots = np.arange(len(colors), dtype=np.uint16)[:, None] * np.uint16(256)
axial = table[(slots + np.rint(ranks * .68).astype(np.uint16)).ravel()]
diagonal = table[(slots + np.rint(ranks * .24).astype(np.uint16)).ravel()]
tables = table, axial, diagonal
self._material_cache[key] = tables
table, axial, diagonal = tables
image = QImage(width, height, QImage.Format_RGB32)
output = np.frombuffer(image.bits(), np.uint32, count=width * height)
output.fill(table[0])
kernel = _ready_colored_scatter()
if kernel is not None:
ranks = np.empty(0, dtype=np.uint8)
try:
kernel(output, ranks, px, py, indices, table, axial, diagonal,
width, height, self.dark, spread)
np.bitwise_or(output, np.uint32(0xff000000), out=output)
return image
except Exception:
_COLORED_SCATTER = None
_COLORED_SCATTER_FAILED = True
output.fill(table[0])
packed = output
combine = np.maximum.at if self.dark else np.minimum.at
combine(packed, py * width + px, table[indices])
if spread:
for dy, dx in ((-1, -1), (-1, 0), (-1, 1), (0, -1),
(0, 1), (1, -1), (1, 0), (1, 1)):
nx, ny = px + dx, py + dy
valid = (nx >= 0) & (nx < width) & (ny >= 0) & (ny < height)
selected = diagonal if dx and dy else axial
combine(packed, ny[valid] * width + nx[valid], selected[indices[valid]])
np.bitwise_or(output, np.uint32(0xff000000), out=output)
return image
def _shade(self, width: int, height: int) -> QImage:
"""Publish owned native points or a copy-on-write resting paper frame.
Paper has no autonomous motion at zero pointer influence. Reusing its
exact resting image does not skip clock/input acknowledgement: the
rotation timestamp follows each shade. Active paper is always drawn
into a fresh image, so previously published frames remain untouched.
The resting marker adds no raster storage and existing material-cache
invalidations discard it with palette, size, density or resolution.
"""
if self.family not in ("point_atlas", "impulse_lens", "genetic_advection",
"tissue_facets"):
return super()._shade(width, height)
bw, bh = self.buffer_size(width, height)
previous = self._buffer
if previous is None or previous.width() != bw or previous.height() != bh:
self._material_cache.clear()
if self.family == "tissue_facets":
resting = self.pointer is None or self.gravity_radius == 0.0
key = ("tissue_resting", bw, bh, self.size, self.density)
if resting and key in self._material_cache:
rotation_key = ("tissue_rotation", bw, bh, self.size, self.density)
_, angles = self._material_cache[rotation_key]
self._material_cache[rotation_key] = (self.time, angles)
return self._soften(QImage(previous), width, height)
self._material_cache.pop(key, None)
layout = self._tissue_layout(bw, bh)
image = self._graphics_frame('_draw_tiles', bw, bh, *layout, self.dark)
if image is None:
image = QImage(bw, bh, QImage.Format_RGB32)
inner = QPainter(image)
self._tissue_pending = layout
try:
inner.fillRect(image.rect(), self.identity)
inner.setCompositionMode(self.mode)
inner.setPen(Qt.NoPen)
self._paint_field(inner, bw, bh)
finally:
self._tissue_pending = None
inner.end()
self._buffer = image
if resting:
self._material_cache[key] = True
return self._soften(QImage(image), width, height)
image = getattr(self, f"_frame_{self.family}")(bw, bh)
self._buffer = image
return self._soften(image, width, height)
def shade(self, width: int, height: int) -> Optional[QImage]:
"""Return an independently owned point frame or the buffered material."""
if self.family not in ("point_atlas", "impulse_lens", "genetic_advection",
"tissue_facets"):
return super().shade(width, height)
if width <= 0 or height <= 0:
return None
return self._shade(width, height)
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Dispatch to one material painter without crossing into the GUI."""
painter.setRenderHint(QPainter.Antialiasing, True)
getattr(self, f"_paint_{self.family}")(painter, width, height)
def _paint_point_atlas(self, painter: QPainter, width: int,
height: int) -> None:
"""Blit the owned point frame for synchronous material callers."""
painter.drawImage(0, 0, self._frame_point_atlas(width, height))
def _frame_point_atlas(self, width: int,
height: int) -> QImage:
"""Light an unbounded waving terrain of densely sampled round grains."""
np = _numpy()
key = ("point_atlas", width, height, self.size, self.density)
points = self._material_cache.get(key)
if points is None:
spacing = max(2.4, 4.6 * self.size
/ math.sqrt(DENSITY_RANGE[1]))
columns = min(900, max(48, math.ceil(width * 1.65 / spacing)))
rows = min(520, max(32, math.ceil(height * 1.85 / spacing)))
population = math.sqrt(self.effective_density() / DENSITY_RANGE[1])
columns = max(3, math.ceil(columns * population))
rows = max(3, math.ceil(rows * population))
xx, zz = np.meshgrid(np.linspace(-0.33, 1.33, columns,
dtype=np.float32),
np.linspace(-0.43, 1.43, rows,
dtype=np.float32))
rng = np.random.default_rng(self._art_seed)
jitter = rng.uniform(-0.17, 0.17, size=(2, xx.size)).astype(np.float32)
xx = xx.ravel() + jitter[0] / columns
zz = zz.ravel() + jitter[1] / rows
shorter = max(1, min(width, height))
reach = 0.055 if self.gravity_radius > 0.0 else 0.0
margin_x = 0.062370 + 1.0 / width + reach * shorter / width
margin_z = 0.161001 + 1.0 / height + reach * shorter / height
visible = ((xx >= -margin_x) & (xx <= 1.0 + margin_x)
& (zz >= -margin_z) & (zz <= 1.0 + margin_z))
xx, zz = xx[visible], zz[visible]
points = (xx, zz, 9.0 * xx + 6.1 * zz,
12.3 * zz - 4.2 * xx,
18.0 * xx + 8.0 * zz + self._anchors[0][0] * math.tau)
self._material_cache[key] = points
xx, zz, base_a, base_b, base_c = points
warp_a = base_b - self.time * 0.11
warp_b = base_c + self.time * 0.09
phase_a = base_a + self.time * 0.25 + 0.32 * np.sin(warp_a)
phase_b = base_b - self.time * 0.17 + 0.24 * np.sin(warp_b)
phase_c = base_c + self.time * 0.12
amplitude_a = 0.085 * (0.80 + 0.20 * math.sin(self.time * 0.21))
amplitude_b = 0.060 * (0.78 + 0.22 * math.cos(self.time * 0.17))
sine_b = np.sin(phase_b)
cosine_c = np.cos(phase_c)
crest = (amplitude_a * np.sin(phase_a) + amplitude_b * np.cos(phase_b)
+ 0.016 * np.sin(phase_c))
cosine_a = np.cos(phase_a)
warp_cosine_a = np.cos(warp_a)
warp_cosine_b = np.cos(warp_b)
slope_x = (amplitude_a * cosine_a * (9.0 - 1.344 * warp_cosine_a)
- amplitude_b * sine_b * (-4.2 + 4.32 * warp_cosine_b)
+ 0.288 * cosine_c)
slope_z = (amplitude_a * cosine_a * (6.1 + 3.936 * warp_cosine_a)
- amplitude_b * sine_b * (12.3 + 1.92 * warp_cosine_b)
+ 0.128 * cosine_c)
normal = (0.90 - 0.30 * slope_x - 0.48 * slope_z) / np.sqrt(
1.0 + slope_x * slope_x + slope_z * slope_z)
light = np.clip(0.30 + 0.62 * normal, 0.20, 0.95)
sx, sy = self._bend_pointer(xx + 0.034 * slope_z, zz + crest,
width, height)
sx, sy = sx * width, sy * height
gain = max(1.0, self.effective_density())
return self._point_material(
width, height, sx, sy, light * gain, spread=True)
def _paint_tissue_facets(self, painter: QPainter, width: int,
height: int, layout: Optional[tuple] = None) -> None:
"""Spin crisp cached paper locally while mouse gravity is enabled."""
if layout is None:
layout = getattr(self, '_tissue_pending', None)
self._tissue_pending = None
material, placements = (self._tissue_layout(width, height)
if layout is None else layout)
for index, x, y, angle in placements:
_cx, _cy, _rx, _ry, _phase, tile, extent_x, extent_y = material[index]
if angle == 0.0:
painter.drawImage(QPointF(x - extent_x, y - extent_y), tile)
else:
painter.save()
try:
painter.translate(x, y)
painter.rotate(angle)
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
painter.drawImage(QPointF(-extent_x, -extent_y), tile)
finally:
painter.restore()
def _tissue_layout(self, width: int, height: int) -> tuple:
"""Build or reuse the paper tiles and advance this frame's spin.
Returns ``(material, placements)`` with one ``(index, x, y,
degrees)`` per cell; called once per frame, since it advances the
stored rotation.
"""
key = ("tissue_facets", width, height, self.size, self.density)
material = self._material_cache.get(key)
if material is None:
rng = random.Random(self._art_seed)
scale = math.sqrt(self.effective_density()) / self.size
columns = max(3, min(66, round(38 * scale)))
rows = max(2, min(42, round(columns * height / width)))
cell_width = width / columns
cell_height = height / rows
cells = []
for row in range(-1, rows + 1):
for column in range(-1, columns + 1):
cx = (column + 0.5 * (row % 2) + 0.25
+ rng.uniform(-0.18, 0.18)) * cell_width
cy = (row + 0.45 + rng.uniform(-0.18, 0.18)) * cell_height
rx = cell_width * rng.uniform(0.40, 0.56)
ry = cell_height * rng.uniform(0.40, 0.56)
peak = rng.uniform(0.20, 0.55)
height_scale = min(rx, ry)
corners = []
sides = rng.randrange(6, 10)
for side in range(sides):
angle = math.tau * side / sides + 0.12 * (row % 2)
reach = rng.uniform(0.82, 1.15)
corners.append((math.cos(angle) * rx * reach,
math.sin(angle) * ry * reach,
rng.uniform(-0.05, 0.09) * height_scale))
centre = (rx * rng.uniform(-0.22, 0.22),
ry * rng.uniform(-0.22, 0.22) - peak * ry * 0.42,
peak * height_scale * 1.4)
triangles = []
for side, a in enumerate(corners):
b = corners[(side + 1) % len(corners)]
vx, vy, vz = (a[index] - centre[index] for index in range(3))
wx, wy, wz = (b[index] - centre[index] for index in range(3))
nx, ny, nz = (vy * wz - vz * wy,
vz * wx - vx * wz,
vx * wy - vy * wx)
norm = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
light = max(0.0, min(1.0,
0.49 + (0.43 * nx - 0.49 * ny
+ 0.73 * nz) / norm * 0.46))
tone = ((row * columns + column) * 37 + self._art_seed) \
% len(self.paint_colors) if self._random_palette else (
1 if (row * columns + column) % 7 == 0 else 0)
color = _mix(self.identity, self.paint_colors[tone],
0.040 + 0.64 * light ** 3)
triangle = QPolygonF((QPointF(*centre[:2]),
QPointF(*a[:2]), QPointF(*b[:2])))
triangles.append((triangle, color))
outline = QPolygonF([QPointF(x, y) for x, y, _ in corners])
extent_x = math.ceil(max(abs(x) for x, _, _ in corners) + 2)
extent_y = math.ceil(max(abs(y) for _, y, _ in corners) + 2)
tile = QImage(extent_x * 2 + 1, extent_y * 2 + 1,
QImage.Format_RGB32)
tile.fill(self.identity)
inner = QPainter(tile)
try:
inner.setRenderHint(QPainter.Antialiasing, True)
inner.setCompositionMode(self.mode)
inner.translate(extent_x, extent_y)
inner.setPen(Qt.NoPen)
for triangle, color in triangles:
inner.setBrush(color)
inner.drawPolygon(triangle)
inner.setBrush(Qt.NoBrush)
inner.setPen(QPen(self._ink(0, 0.23), 0.65))
inner.drawPolygon(outline)
finally:
inner.end()
cells.append((cx, cy, rx, ry, rng.uniform(0, math.tau),
tile, extent_x, extent_y))
material = tuple(cells)
self._material_cache[key] = material
rotation_key = ("tissue_rotation", width, height, self.size, self.density)
rotation = self._material_cache.get(rotation_key)
if rotation is None:
rotation = (self.time, [0.0] * len(material))
previous_time, angles = rotation
step = max(0.0, min(MAX_DT * self.speed, self.time - previous_time))
active = self.pointer is not None and self.gravity_radius > 0.0
if not active or self.time < previous_time:
angles[:] = [0.0] * len(material)
self._material_cache[rotation_key] = (self.time, angles)
placements = []
for index, cell in enumerate(material):
cx, cy, rx, ry, phase, tile, extent_x, extent_y = cell
dx = dy = 0.0
if active:
shorter = max(1, min(width, height))
distance_x = (cx - self.pointer[0] * width) / shorter
distance_y = (cy - self.pointer[1] * height) / shorter
squared = distance_x ** 2 + distance_y ** 2
reach = max(0.0, 1.0 - squared / self.gravity_radius ** 2) ** 3
if reach > 0.0:
lift = reach * (0.55 + 0.15 * math.sin(phase))
dx = -distance_x * rx * lift
dy = -distance_y * ry * lift - ry * 0.28 * reach
proximity = max(0.0, 1.0 - math.sqrt(squared) / self.gravity_radius)
angles[index] = (angles[index] + step * 240.0 * proximity ** 2) % 360.0
placements.append((index, cx + dx, cy + dy, angles[index]))
return material, placements
def _paint_chromatin_ribbon(self, painter: QPainter, width: int,
height: int) -> None:
"""Deform fine native satin fibres into travelling transverse waves."""
np = _numpy()
key = ("chromatin_native_folds", width, height, self.resolution,
self.size, self.density)
ribbons = self._material_cache.get(key)
if ribbons is None:
count = self.element_count(7, 15)
seed_phase = self._anchors[1][0] * math.tau
samples = max(128, min(640, int(width / 5)))
along = np.linspace(-0.08, 1.08, samples, dtype=np.float32)
x = along * width
fibres = max(20, int(28 * self.resolution))
ratios = np.arange(1, fibres, dtype=np.float32)[:, None] / fibres
ribbons = []
for ribbon in range(count):
centre = height * (0.16 + 0.115 * ribbon)
thickness = height * (0.042 + 0.013 * (ribbon % 3)) * self.size
slope = height * (0.19 * ((ribbon % 3) - 1))
wave = (np.sin(7.0 * along + ribbon * 1.19 + seed_phase)
+ 0.47 * np.sin(19.0 * along - ribbon * 0.66
+ seed_phase * 0.71))
middle = slope * (along - 0.5) + height * 0.058 * wave
fold = thickness * (0.55 + 0.45 * np.cos(
11.0 * along + ribbon * 1.7))
upper_y, lower_y = middle - fold, middle + fold
upper = QPolygonF([QPointF(float(xx), float(yy))
for xx, yy in zip(x, upper_y)])
lower = [QPointF(float(xx), float(yy))
for xx, yy in zip(x[::-1], lower_y[::-1])]
origin_x = math.floor(float(x[0])) - 2
origin_y = math.floor(float(np.minimum(upper_y, lower_y).min())) - 2
tile_width = math.ceil(float(x[-1])) - origin_x + 3
tile_height = math.ceil(float(np.maximum(upper_y, lower_y).max())) - origin_y + 3
picture = QImage(tile_width, tile_height,
QImage.Format_ARGB32_Premultiplied)
picture.fill(Qt.transparent)
inner = QPainter(picture)
inner.setRenderHint(QPainter.Antialiasing, True)
inner.setCompositionMode(self.mode)
inner.translate(-origin_x, -origin_y)
shade = QLinearGradient(0, -thickness * 2.1,
0, thickness * 2.1)
shade.setColorAt(0.0, self._ink(ribbon, 0.02))
shade.setColorAt(0.22, self._ink(ribbon, 0.42))
shade.setColorAt(0.47, self._ink(ribbon + 1, 0.15))
shade.setColorAt(0.74, self._ink(ribbon + 2, 0.50))
shade.setColorAt(1.0, self._ink(ribbon, 0.015))
inner.setPen(Qt.NoPen)
inner.setBrush(QBrush(shade))
inner.drawPolygon(QPolygonF(list(upper) + lower))
inner.setBrush(Qt.NoBrush)
inner.setPen(QPen(self._ink(ribbon + 2, 0.51),
max(0.5, self.size * 0.75)))
inner.drawPolyline(upper)
positions = upper_y[None, :] + ratios * (2.0 * fold[None, :])
for fibre, ys in enumerate(positions, 1):
inner.setPen(QPen(self._ink(ribbon + fibre, 0.11), 0.55))
inner.drawPolyline(QPolygonF([
QPointF(float(xx), float(yy)) for xx, yy in zip(x, ys)]))
inner.end()
ribbons.append((centre, ribbon * 1.19 + seed_phase,
picture, origin_x, origin_y))
ribbons = tuple(ribbons)
self._material_cache[key] = ribbons
warp_key = ("chromatin_native_waves", width, height, self.resolution,
self.size, self.density)
warps = self._material_cache.get(warp_key)
if warps is None:
warps = []
padding = math.ceil(height * 0.035) + 2
for _, _, picture, origin_x, _ in ribbons:
source = np.frombuffer(picture.bits(), dtype=np.uint32).reshape(
picture.height(), picture.width())
along = (np.arange(source.shape[1], dtype=np.float32)
+ origin_x) / width
occupied = source != 0
present = np.any(occupied, axis=0)
tops = np.where(present, np.argmax(occupied, axis=0),
source.shape[0]).astype(np.int32)
bottoms = np.where(present, source.shape[0] - np.argmax(
occupied[::-1], axis=0), 0).astype(np.int32)
warps.append((source, along, tops, bottoms, padding))
self._material_cache[warp_key] = warps
kernel = _SATIN_COMPILER.ready()
tasks = []
images = []
for ribbon, warp in zip(ribbons, warps):
centre, phase, picture, origin_x, origin_y = ribbon
source, along, tops, bottoms, padding = warp
travel = height * 0.024 * (
np.sin(math.tau * along * 1.1 - self.time * 0.55 + phase)
+ 0.35 * np.sin(math.tau * along * 2.6
+ self.time * 0.31 + phase * 0.63))
shifts = np.rint(travel).astype(np.int32)
for start in range(0, source.shape[1], 128):
end = min(source.shape[1], start + 128)
strip_shifts = shifts[start:end]
strip_tops, strip_bottoms = tops[start:end], bottoms[start:end]
lower = max(0, int(np.min(strip_tops + strip_shifts + padding)))
upper = min(source.shape[0] + 2 * padding,
int(np.max(strip_bottoms + strip_shifts + padding)))
target = np.zeros((max(1, upper - lower), end - start), dtype=np.uint32)
image = QImage(target.data, target.shape[1], target.shape[0],
target.strides[0], QImage.Format_ARGB32_Premultiplied)
images.append((target, image, origin_x + start,
centre + origin_y - padding + lower))
strip_source = source[:, start:end]
strip_padding = padding - lower
if kernel is not None:
tasks.append((strip_source, target, strip_shifts, strip_tops,
strip_bottoms, strip_padding))
else:
_numpy_satin_columns(strip_source, target, strip_shifts, strip_padding)
if kernel is not None:
try:
_SATIN_COMPILER.copy_waves(kernel, tuple(tasks))
except Exception:
_SATIN_COMPILER.kernel = None
_SATIN_COMPILER.failed = True
for source, target, shifts, _, _, padding in tasks:
_numpy_satin_columns(source, target, shifts, padding)
opacity = painter.opacity()
painter.setOpacity(opacity * min(1.0, 7 * self.effective_density()))
try:
for _target, image, origin_x, origin_y in images:
painter.drawImage(QPointF(origin_x, origin_y), image)
finally:
painter.setOpacity(opacity)
def _paint_genetic_advection(self, painter: QPainter, width: int,
height: int) -> None:
"""Blit the owned point frame for synchronous material callers."""
painter.drawImage(0, 0, self._frame_genetic_advection(width, height))
def _frame_genetic_advection(self, width, height) -> QImage:
"""Advect trails through vortices and short cursor-attracted histories."""
np = _numpy()
key = ("wind_grains", width, height, self.size, self.density)
grains = self._material_cache.get(key)
if grains is None:
rng = np.random.default_rng(self._art_seed)
grains = (rng.random(60000, dtype=np.float32),
rng.random(60000, dtype=np.float32),
rng.random(60000, dtype=np.float32))
self._material_cache[key] = grains
u, v, depth = grains
density = self.density
count = round(34000 * density) if density <= 1.0 else round(
34000 + (density - 1.0) * 13000)
count = max(1, min(len(u), count))
u, v, depth = u[:count], v[:count], depth[:count]
phase = self._anchors[0][0] * math.tau
travel = (self.time * (0.013 + 0.009 * depth)
+ 0.046 * (np.sin(self.time * 0.23 + 4.0 * depth + phase)
- np.sin(4.0 * depth + phase)))
q = u + travel
samples = np.arange(3, dtype=np.float32)[:, None]
t = q[None, :] - samples * (0.0015 + 0.0012 * depth) * self.size
x = t - np.floor(t)
breathing = self.time * 0.11
y = (v + (0.10 + 0.045 * math.sin(breathing)) * np.sin(
math.tau * x + 7.0 * v + phase + breathing)
+ 0.07 * np.sin(2.0 * math.tau * x - 5.0 * v
+ phase * 0.7 - breathing * 0.83)
+ 0.035 * np.sin(5.0 * math.tau * x + 11.0 * v
+ breathing * 1.31))
y -= np.floor(y)
for vortex, (anchor_x, anchor_y, spin) in enumerate(self._anchors[2:5]):
cx = anchor_x + 0.12 * math.sin(self.time * 0.071 + spin * math.tau)
cy = anchor_y + 0.12 * math.cos(self.time * 0.063 + spin * math.tau)
dx, dy = x - cx, y - cy
dx -= np.rint(dx)
dy -= np.rint(dy)
reach = 0.15 + 0.025 * math.sin(self.time * 0.14 + vortex)
weight = np.maximum(0.0, 1.0 - (dx * dx + dy * dy) / reach ** 2) ** 2
rotation = weight * (2.7 + 1.4 * math.sin(
self.time * 0.19 + spin * math.tau)) * (1 if vortex % 2 else -1)
cosine, sine = np.cos(rotation), np.sin(rotation)
x = cx + dx * cosine - dy * sine
y = cy + dx * sine + dy * cosine
x -= np.floor(x)
y -= np.floor(y)
trails = np.arange(12, dtype=np.float32)[:, None]
positions = []
for coordinates in (x, y):
tangent = coordinates[0] - coordinates[1]
tangent -= np.rint(tangent)
previous = coordinates[1] - coordinates[2]
previous -= np.rint(previous)
curvature = np.clip(tangent - previous, -0.0003, 0.0003)
curve = (coordinates[0][None, :] - trails * tangent[None, :]
+ 0.5 * trails * (trails - 1.0) * curvature[None, :])
positions.append(curve - np.floor(curve))
x, y = positions
if self.pointer is not None and self.gravity_radius > 0.0:
dx, dy, _, weight = self._pointer_field(x, y, width, height)
exposure = weight.copy()
exposure[:-3] += weight[3:]
exposure[-3:] += weight[-1]
exposure[:-6] += weight[6:]
exposure[-6:] += weight[-1]
pull = 0.72 * np.sqrt(weight * exposure / 3.0)
shorter = max(1, min(width, height))
x -= dx * pull * shorter / width
y -= dy * pull * shorter / height
columns = np.clip((x * width).astype(np.int32), 0, width - 1)
rows = np.clip((y * height).astype(np.int32), 0, height - 1)
gain = 1.0 if density <= 1.0 else 34000 * density / count
intensity = ((0.25 + 0.60 * depth) * ((12 - trails) / 12) ** 1.3
* gain)
return self._point_material(
width, height, columns, rows, intensity)
def _paint_impulse_lens(self, painter: QPainter, width: int,
height: int) -> None:
"""Blit the owned point frame for synchronous material callers."""
painter.drawImage(0, 0, self._frame_impulse_lens(width, height))
def _frame_impulse_lens(self, width: int,
height: int) -> QImage:
"""Bend a round-dot gravity field with cursor wakes and burst waves."""
self._step_field_grab()
np = _numpy()
key = ("impulse_lens", width, height, self.size, self.density)
lattice = self._material_cache.get(key)
if lattice is None:
side = max(3.5, 8.0 * self.size
/ math.sqrt(self.effective_density()))
xx, yy = np.meshgrid(np.arange(-side, width + side, side,
dtype=np.float32),
np.arange(-side, height + side, side,
dtype=np.float32))
lattice = (xx.ravel() / width, yy.ravel() / height, {}, {})
self._material_cache[key] = lattice
x, y, impulse_fields, popup_fields = lattice
shorter = max(1, min(width, height))
aspect_x, aspect_y = width / shorter, height / shorter
cx = 0.5 + 0.20 * math.sin(
self.time * 0.10 + self._anchors[0][0])
cy = 0.5 + 0.18 * math.cos(
self.time * 0.083 + self._anchors[0][1])
dx, dy = (x - cx) * aspect_x, (y - cy) * aspect_y
radius = np.sqrt(dx * dx + dy * dy + 1e-6)
envelope = np.exp(-(radius / 0.34) ** 2)
gravity = -0.052 * envelope / np.sqrt(radius * radius + 0.013)
px = x + dx / aspect_x * gravity
py = y + dy / aspect_y * gravity
px, py = self._bend_pointer(px, py, width, height)
energy = envelope * 0.12
active_origins = {origin for started, origin, _ in self._gravity_impulses
if 0.0 <= self.time - started < 5.0}
for origin in tuple(impulse_fields):
if origin not in active_origins:
del impulse_fields[origin]
for impulse in self._gravity_impulses:
started, origin, strength = impulse
age = self.time - started
if age < 0.0 or age >= 5.0:
continue
field = impulse_fields.get(origin)
if field is None:
ex = (x - origin[0]) * aspect_x
ey = (y - origin[1]) * aspect_y
distance = np.sqrt(ex * ex + ey * ey + 1e-6)
selected = np.flatnonzero(distance < self.gravity_radius)
distance = distance[selected]
reach = np.maximum(0.0, 1.0 - (distance / self.gravity_radius) ** 2) ** 3
field = (selected, ex[selected] / aspect_x, ey[selected] / aspect_y,
distance, reach * np.exp(-(distance / 0.32) ** 2),
np.sqrt(distance * distance + 0.02), reach)
impulse_fields[origin] = field
selected, ex, ey, distance, burst_envelope, burst_softening, reach = field
decay = math.exp(-age * 0.90) * strength
burst = (-0.12 * decay * math.exp(-age * 3.0)
* burst_envelope / burst_softening)
front = distance - age * 0.26
packet = np.exp(-(front / 0.075) ** 2) * reach
has_ripple = (self.ripples_enabled
and id(impulse) in self._gravity_ripple_impulses)
px[selected] += ex * burst
py[selected] += ey * burst
if has_ripple:
shift, glow = self._ripple_wave(distance, age, strength * reach)
px[selected] += ex / distance * shift
py[selected] += ey / distance * shift
energy[selected] += glow
active_popups = {origin for started, origin in self._popup_waves
if 0.0 <= self.time - started < 5.0}
for origin in tuple(popup_fields):
if origin not in active_popups:
del popup_fields[origin]
for started, origin in self._popup_waves if self.ripples_enabled else ():
age = self.time - started
if not 0.0 <= age < 5.0:
continue
field = popup_fields.get(origin)
if field is None:
if isinstance(origin[0], tuple):
nearest = None
for x0, y0, x1, y1 in origin:
if x0 == x1:
ex = (x - x0) * aspect_x
ey = (y - np.clip(y, min(y0, y1), max(y0, y1))) * aspect_y
else:
ex = (x - np.clip(x, min(x0, x1), max(x0, x1))) * aspect_x
ey = (y - y0) * aspect_y
squared = ex * ex + ey * ey
if nearest is None:
nearest = (squared, ex, ey)
else:
old_squared, old_ex, old_ey = nearest
chosen = squared < old_squared
nearest = (np.minimum(old_squared, squared),
np.where(chosen, ex, old_ex),
np.where(chosen, ey, old_ey))
squared, ex, ey = nearest
distance = np.sqrt(squared + 1e-6)
outside = _outside_outline(np, origin, x, y, distance,
(aspect_x, aspect_y))
else:
ex = (x - origin[0]) * aspect_x
ey = (y - origin[1]) * aspect_y
distance = np.sqrt(ex * ex + ey * ey + 1e-6)
outside = 1.0
field = (ex / aspect_x, ey / aspect_y, distance, outside)
popup_fields[origin] = field
ex, ey, distance, outside = field
shift, glow = self._ripple_wave(distance, age)
shift, glow = shift * outside, glow * outside
px += ex / distance * shift
py += ey / distance * shift
energy += glow
for started, (left, right, edge) in (self._column_waves
if self.ripples_enabled else ()):
age = self.time - started
if not 0.0 <= age < RIPPLE_LIFETIME_S:
continue
across = np.minimum(x - left, right - x) * aspect_x
weight = _smoothstep(np, -RIPPLE_COLUMN_SOFTNESS, RIPPLE_COLUMN_SOFTNESS, across)
offset = (y - edge) * aspect_y
shift, glow = self._ripple_wave(np.abs(offset) + 1e-6, age)
py += np.sign(offset) * shift * weight / aspect_y
energy += glow * weight
for started, (fold_x, fold_y) in (getattr(self, "_fold_waves", ())
if self.ripples_enabled else ()):
age = self.time - started
if not 0.0 <= age < FOLD_LIFETIME_S:
continue
ex, ey = (x - fold_x) * aspect_x, (y - fold_y) * aspect_y
distance = np.sqrt(ex * ex + ey * ey + 1e-6)
travelled = age * FOLD_SPEED
ease = float(_smoothstep(np, 0.0, 0.12, age)
* (1.0 - _smoothstep(np, 0.45 * FOLD_LIFETIME_S,
FOLD_LIFETIME_S, age)))
intensity = self._ripple_intensity
gain = intensity * ease / math.sqrt(1.0 + travelled / 1.2)
front = distance - travelled
reach = FOLD_PACKET_WIDTH * math.sqrt(max(1.0, intensity))
packet = (np.exp(-(front / reach) ** 2)
* _smoothstep(np, 0.0, 0.04, distance))
gather = FOLD_GATHER * gain / max(1.0, intensity)
pull = min(0.94, gather) * front * packet
px -= ex / distance * pull / aspect_x
py -= ey / distance * pull / aspect_y
energy += FOLD_GLOW * gain * packet
if self._field_grab_offset != (0.0, 0.0):
center_x, center_y = self._field_grab_center
gx, gy = (x - center_x) * aspect_x, (y - center_y) * aspect_y
reach = np.maximum(0.0, 1.0 - (gx * gx + gy * gy) / 0.34 ** 2) ** 3
px += self._field_grab_offset[0] / aspect_x * reach
py += self._field_grab_offset[1] / aspect_y * reach
brilliance = np.clip(0.57 + energy, RIPPLE_FLOOR, 1.0)
gain = max(1.0, self.effective_density())
image = self._point_material(
width, height, px * width, py * height, brilliance * gain,
spread=True)
return image
def _flicker_field_dots(self, image, x, y, opaque: bool = True) -> None:
"""Flash the selected percentage of visible point-theme dots white.
Fractional expected counts are sampled rather than rounded up to one
dot. Very low percentages therefore give rare flashes, even in a
sparse field. Selection changes at four ticks per animation second.
``opaque=False`` is for a frame whose alpha bytes still hold grain
ranks: each word a flash compares against is made opaque first, so
the flash lands exactly as it does after a whole-frame opaque pass.
"""
np = _numpy()
width, height = image.width(), image.height()
px = np.asarray(x, dtype=np.int32).reshape(-1)
py = np.asarray(y, dtype=np.int32).reshape(-1)
visible = np.flatnonzero((px >= 0) & (px < width)
& (py >= 0) & (py < height))
tick = int(math.floor(self.time * 4.0))
selection = self._blinking_indices(len(visible))
count = len(selection)
if not count:
return
key = (tick, len(visible), count)
cached = getattr(self, "_field_flicker", None)
if cached is None or cached[0] != key:
cached = (key, selection)
self._field_flicker = cached
chosen = visible[cached[1]]
px, py = px[chosen], py[chosen]
flat = np.frombuffer(image.bits(), dtype=np.uint32, count=width * height)
np.maximum.at(flat, py * width + px, np.uint32(0xffffffff))
for dx, dy, ink in ((-1, 0, 0xffadadad), (1, 0, 0xffadadad),
(0, -1, 0xffadadad), (0, 1, 0xffadadad),
(-1, -1, 0xff3d3d3d), (1, -1, 0xff3d3d3d),
(-1, 1, 0xff3d3d3d), (1, 1, 0xff3d3d3d)):
nx, ny = px + dx, py + dy
inside = (nx >= 0) & (nx < width) & (ny >= 0) & (ny < height)
index = ny[inside] * width + nx[inside]
if not opaque:
flat[index] |= np.uint32(0xff000000)
np.maximum.at(flat, index, np.uint32(ink))
class _SpaceoutFieldEngine(_DataArtEngine):
"""Seeded field phenomena share the ordinary field's input and grain renderer.
Two independent seeded lanes choose durations, gaps and occasional idle
windows. Their unsynchronized calm boundaries permit direct clock seeks
without replaying a simulation or
retaining its history. Compact smooth potentials deform existing grains;
no phenomenon changes native resolution or removes population samples.
"""
_lane_periods = (29.0, 43.0)
_effect_keys = ("attractors", "relaxation", "elastic_release", "vortex",
"density_pulses", "density_waves", "color_waves", "spirals")
_event_keys = ("attractors", "vortex", "density_pulses", "density_waves",
"color_waves", "spirals")
def __init__(self, *args, **kwargs):
"""Start a separate field with all independently selectable effects on."""
self.field_effects = dict.fromkeys(self._effect_keys, True)
self._phenomena_schedule = None
self._phenomena_kernel_failed = False
super().__init__(*args, family="impulse_lens", **kwargs)
self.name = "data_art_spaceout_field"
def set_field_effects(self, mapping) -> None:
"""Copy recognized effect switches without reading frontend preferences."""
self.field_effects.update({key: bool(mapping[key]) for key in self._effect_keys
if key in mapping})
def _field_events(self):
"""Return up to two jittered lane events without retaining clock history."""
epochs = tuple(math.floor(self.time / period) for period in self._lane_periods)
cached = self._phenomena_schedule
if cached is None or cached[0] != epochs:
events = []
for lane, (epoch, period) in enumerate(zip(epochs, self._lane_periods)):
rng = random.Random((self._art_seed << 32) ^ (epoch * 3 + lane))
if lane == 1 and rng.random() < .08:
continue
if lane == 0:
kinds = ["attractors", "vortex"]
random.Random((self._art_seed << 32) ^ (epoch // 2)).shuffle(kinds)
kind = kinds[epoch % 2]
else:
kind = rng.choice(("density_pulses", "density_waves", "color_waves", "spirals"))
started = epoch * period + rng.uniform(.04, .18) * period
duration = rng.uniform(.40, .68) * period
regions = tuple((rng.uniform(.12, .88), rng.uniform(.12, .88),
rng.uniform(.16, .33),
(-1.0 if i % 2 else 1.0) * rng.uniform(.65, 1.0),
rng.uniform(0.0, math.tau))
for i in range(rng.randint(2, 5)))
events.append((kind, started, duration, regions, rng.uniform(0, math.tau)))
cached = epochs, tuple(events)
self._phenomena_schedule = cached
return tuple(event for event in cached[1] if self.field_effects[event[0]]
and 0.0 < self.time - event[1] < event[2])
def _event_strength(self, event):
"""Ease in continuously, then relax or release with bounded oscillation."""
age = (self.time - event[1]) / event[2]
if not 0.0 < age < 1.0:
return 0.0
if age < .3:
return math.sin(age / .3 * math.pi / 2) ** 2
tail = (age - .3) / .7
strength = (1.0 - tail * tail * (3.0 - 2.0 * tail)
if self.field_effects["relaxation"] else math.cos(tail * math.pi / 2) ** 2)
elastic = self.field_effects["elastic_release"] and (
not self.field_effects["relaxation"] or math.cos(event[4]) < 0.0)
if elastic:
strength *= math.cos(tail * math.tau * 1.25) * math.exp(-tail * tail * .65)
return strength
def _bend_pointer(self, x, y, width, height):
"""Superpose finite potential, vortex and spacing waves before hover gravity."""
events = self._field_events()
if not events:
return super()._bend_pointer(x, y, width, height)
np = _numpy()
shorter = max(1, min(width, height))
ax, ay = width / shorter, height / shorter
ux, uy = x * ax, y * ay
sx, sy = np.zeros_like(x), np.zeros_like(y)
for event in events:
kind, started, duration, regions, phase = event
strength = self._event_strength(event)
elapsed = self.time - started
if kind == "density_pulses":
pulse = .025 * strength * math.sin(elapsed * .45 + phase)
sx += pulse * np.sin(x * math.tau)
sy += pulse * np.sin(y * math.tau)
elif kind == "density_waves":
angle = phase + .12 * math.sin(elapsed * .13)
dx, dy = math.cos(angle), math.sin(angle)
travel = ux * dx + uy * dy - elapsed * .025
wave = .022 * strength * np.sin(travel * 13.0 + phase)
sx += dx * wave
sy += dy * wave
if kind in ("attractors", "vortex", "density_waves"):
for cx, cy, radius, polarity, local_phase in regions:
cx += .022 * math.sin(elapsed * .12 + local_phase)
cy += .022 * math.cos(elapsed * .09 + local_phase)
dx, dy = ux - cx * ax, uy - cy * ay
squared = dx * dx + dy * dy
reach = np.maximum(0.0, 1.0 - squared / radius ** 2) ** 3
if kind == "vortex":
twist = strength * polarity * 2.4 * reach
cosine, sine = np.cos(twist), np.sin(twist)
sx += dx * (cosine - 1.0) - dy * sine
sy += dx * sine + dy * (cosine - 1.0)
else:
amount = -.026 * polarity * strength * reach / np.sqrt(squared + .018)
if kind == "density_waves":
amount *= math.sin(elapsed * (.38 + radius) + local_phase)
sx += dx * amount
sy += dy * amount
depth, _gx, _gy = self._field_depth(x, y, width, height)
sx -= depth * .18
sy -= depth * .65
length = np.hypot(sx, sy)
scale = np.minimum(1.0, .14 / np.maximum(length, 1e-9))
boundary = (np.sin(np.clip(x, 0, 1) * math.pi) ** 2
* np.sin(np.clip(y, 0, 1) * math.pi) ** 2)
return super()._bend_pointer(x + sx * scale * boundary / ax,
y + sy * scale * boundary / ay, width, height)
def _field_depth(self, x, y, width, height):
"""Sample signed relief and surface slopes for projection and lighting."""
np = _numpy()
depth, gx, gy = np.zeros_like(x), np.zeros_like(x), np.zeros_like(x)
ax, ay = width / min(width, height), height / min(width, height)
for event in self._field_events():
kind, started, _duration, regions, _phase = event
if kind not in ("attractors", "vortex", "density_waves", "spirals"):
continue
strength = self._event_strength(event)
elapsed = self.time - started
for cx, cy, radius, polarity, phase in regions:
cx += .022 * math.sin(elapsed * .12 + phase)
cy += .022 * math.cos(elapsed * .09 + phase)
dx, dy = (x - cx) * ax, (y - cy) * ay
squared = dx * dx + dy * dy
reach = np.maximum(0.0, 1.0 - squared / radius ** 2)
amplitude = .14 * polarity * strength
if kind != "attractors":
amplitude *= math.sin(elapsed * .42 + phase)
depth += amplitude * reach ** 3
slope = -6.0 * amplitude * reach ** 2 / radius ** 2
gx += slope * dx
gy += slope * dy
boundary = (np.sin(np.clip(x, 0, 1) * math.pi) ** 2
* np.sin(np.clip(y, 0, 1) * math.pi) ** 2)
return depth * boundary, gx * boundary, gy * boundary
def _step_field_grab(self) -> None:
"""Keep held physics; optionally give release an exact damped elastic return."""
if (not self.field_effects["elastic_release"] or self._field_grab_held
or self._field_grab_center is None):
return super()._step_field_grab()
elapsed = self.time - self._field_grab_time
self._field_grab_time = self.time
if elapsed <= 0.0:
return
elapsed = min(120.0, elapsed) if math.isfinite(elapsed) else 120.0
damping, frequency = 2.2, math.sqrt(36.0 - 2.2 ** 2)
decay = math.exp(-damping * elapsed)
cosine, sine = math.cos(frequency * elapsed), math.sin(frequency * elapsed)
values, velocities = [], []
for value, velocity in zip(self._field_grab_offset, self._field_grab_velocity):
tangent = (velocity + damping * value) / frequency
values.append(decay * (value * cosine + tangent * sine))
velocities.append(decay * (velocity * cosine
- (damping * tangent + frequency * value) * sine))
length = math.hypot(*values)
scale = min(1.0, .18 / length) if length else 1.0
self._field_grab_offset = tuple(value * scale for value in values)
self._field_grab_velocity = tuple(value * scale for value in velocities)
self._field_grab_center = tuple(target + (value - target) * math.exp(-6 * elapsed)
for value, target in zip(self._field_grab_center,
self._field_grab_origin))
if length < 1e-6 and math.hypot(*velocities) < 1e-6:
self._set_field_grab(None, reset=True)
def _point_material(self, width, height, x, y, light, spread=False):
"""Travel supplied palette hues over crisp grains with ranked overlap."""
active = self._field_events()
np = _numpy()
shorter = max(1, min(width, height))
if any(event[0] in ("attractors", "vortex", "density_waves", "spirals")
for event in active):
depth, gx, gy = self._field_depth(np.asarray(x) / width,
np.asarray(y) / height, width, height)
diffuse = (.8 - .50 * gx - .65 * gy) / np.sqrt(1 + gx * gx + gy * gy)
lighting = np.clip(.45 + diffuse * .6875 + depth * 1.2, .20, 1.5)
light = np.asarray(light) * lighting
density_events = tuple(event for event in active
if event[0] in ("density_pulses", "density_waves"))
if density_events:
count = np.asarray(x).size
reservoir = self._material_cache.get("spaceout_population")
if reservoir is None or reservoir[0] != count:
identities = np.arange(count, dtype=np.uint32)
mixed = identities * np.uint32(0x9e3779b1) + np.uint32(self._art_seed)
mixed ^= mixed >> 16
reservoir = count, mixed.astype(np.float32) / float(2 ** 32)
self._material_cache["spaceout_population"] = reservoir
gate = np.ones_like(x)
for event in density_events:
kind, started, duration, regions, phase = event
elapsed = self.time - started
travel = elapsed * .45 + phase
if kind == "density_waves":
travel = ((x * math.cos(phase) + y * math.sin(phase))
/ shorter * 9.0 - elapsed * .24 + phase)
for cx, cy, radius, polarity, local_phase in regions:
distance = ((x - cx * width) ** 2 + (y - cy * height) ** 2) / shorter ** 2
reach = np.maximum(0.0, 1.0 - distance / radius ** 2) ** 2
travel += reach * np.sin(elapsed * (.3 + radius) + local_phase)
fraction = 1.0 - abs(self._event_strength(event)) * (.28 - .25 * np.sin(travel))
fade = np.clip((fraction + .12 - reservoir[1].reshape(np.shape(x))) / .12, 0, 1)
gate *= fade * fade * (3 - 2 * fade)
light = np.asarray(light) * gate
events = tuple(event for event in active if event[0] in ("color_waves", "spirals"))
amount = min(1.0, max((abs(self._event_strength(event)) for event in events), default=0.0))
blend = int(round(amount * 255))
if not events or blend == 0:
return super()._point_material(width, height, x, y, light, spread)
hue = np.zeros_like(x)
for event in events:
kind, started, duration, regions, phase = event
elapsed = self.time - started
strength = self._event_strength(event)
if kind == "color_waves":
travel = (x * math.cos(phase) + y * math.sin(phase)) / shorter
hue += strength * .75 * np.sin(travel * 9.0 - elapsed * .35 + phase)
else:
cx, cy, radius, polarity, local_phase = regions[0]
dx, dy = (x - cx * width) / shorter, (y - cy * height) / shorter
distance = np.sqrt(dx * dx + dy * dy)
reach = np.maximum(0.0, 1.0 - distance / (radius * 3.0)) ** 2
hue += strength * .75 * reach * np.sin(
np.arctan2(dy, dx) * 3.0 + distance * 23.0 - elapsed * .42 + local_phase)
tables = self._material_cache.get("spaceout_hues")
if tables is None:
colors = np.asarray([[c.red(), c.green(), c.blue()] for c in self.paint_colors], np.float32)
phase = np.arange(128, dtype=np.float32) * len(colors) / 128
first = phase.astype(np.int32)
fraction = (phase - first)[:, None]
colors = colors[first % len(colors)] * (1 - fraction) + colors[(first + 1) % len(colors)] * fraction
levels = np.arange(256, dtype=np.float32) / 255
channels = colors[:, None, :] * levels[None, :, None] if self.dark else (
255 - (255 - colors[:, None, :]) * levels[None, :, None])
channels = channels.astype(np.uint32)
table = channels[:, :, 0] << 16 | channels[:, :, 1] << 8 | channels[:, :, 2]
ranks = np.arange(256, dtype=np.uint32)
table |= (ranks if self.dark else 255 - ranks)[None, :] << 24
table = table.ravel()
slots = np.arange(128, dtype=np.uint16)[:, None] * 256
tables = (table, table[(slots + np.rint(ranks * .68).astype(np.uint16)).ravel()],
table[(slots + np.rint(ranks * .24).astype(np.uint16)).ravel()])
self._material_cache["spaceout_hues"] = tables
px, py = np.asarray(x, np.int32), np.asarray(y, np.int32)
values = np.clip(np.asarray(light, np.float32) * self.alpha_scale(), 0, 1)
baseline = np.full(px.shape, .11, dtype=np.float32)
if self._random_palette:
identity = np.arange(px.size, dtype=np.uint32).reshape(px.shape)
mixed = identity * np.uint32(0x9e3779b1) + np.uint32(self._art_seed)
mixed ^= mixed >> 16
baseline = (mixed % 128).astype(np.float32) / 128
hues = (np.mod(baseline + hue, 1.0) * 128).astype(np.uint16) % 128
inside = (px >= 0) & (px < width) & (py >= 0) & (py < height)
indices = hues[inside] * np.uint16(256) + np.rint(values[inside] * 255).astype(np.uint16)
px, py = px[inside], py[inside]
mixer = _ready_color_blend() if blend < 255 else None
if mixer is not None:
frame_key = ("spaceout_colour_frame", width, height)
image = self._material_cache.get(frame_key)
if image is None:
image = QImage(width, height, QImage.Format_RGB32)
self._material_cache[frame_key] = image
else:
image = QImage(width, height, QImage.Format_RGB32)
output = np.frombuffer(image.bits(), np.uint32, count=width * height)
table, axial, diagonal = tables
destinations = None
if mixer is not None:
if spread and not self._random_palette:
fx = np.asarray(x, np.float32).reshape(-1)
fy = np.asarray(y, np.float32).reshape(-1)
bx = np.floor(fx - .5).astype(np.int32)
by = np.floor(fy - .5).astype(np.int32)
offsets = np.arange(-1, 3, dtype=np.int32)
nx = np.broadcast_to(bx[None, None, :] + offsets[None, :, None],
(4, 4, bx.size))
ny = np.broadcast_to(by[None, None, :] + offsets[:, None, None], nx.shape)
valid = (nx >= 0) & (nx < width) & (ny >= 0) & (ny < height)
destinations = ny[valid] * width + nx[valid]
boundary = (px == 0) | (py == 0)
cx, cy = px[boundary], py[boundary]
else:
destinations = np.empty(0, np.int32)
cx, cy = px, py
if spread or self.blink_percent:
offsets = np.arange(-1, 2, dtype=np.int32)
nx = np.broadcast_to(cx[None, None, :] + offsets[None, :, None],
(3, 3, cx.size))
ny = np.broadcast_to(cy[None, None, :] + offsets[:, None, None], nx.shape)
valid = (nx >= 0) & (nx < width) & (ny >= 0) & (ny < height)
destinations = np.concatenate((destinations, ny[valid] * width + nx[valid]))
else:
destinations = np.concatenate((destinations, cy * width + cx))
output[destinations] = table[0]
else:
output.fill(table[0])
kernel = None if self._phenomena_kernel_failed else _ready_colored_scatter()
if kernel is not None:
try:
kernel(output, np.empty(0, np.uint8), px, py, indices, table, axial, diagonal,
width, height, self.dark, spread)
except Exception:
self._phenomena_kernel_failed = True
kernel = None
if kernel is None:
if destinations is None:
output.fill(table[0])
else:
output[destinations] = table[0]
combine = np.maximum.at if self.dark else np.minimum.at
combine(output, py * width + px, table[indices])
if spread:
for dy, dx in ((-1, -1), (-1, 0), (-1, 1), (0, -1),
(0, 1), (1, -1), (1, 0), (1, 1)):
nx, ny = px + dx, py + dy
valid = (nx >= 0) & (nx < width) & (ny >= 0) & (ny < height)
selected = diagonal if dx and dy else axial
combine(output, ny[valid] * width + nx[valid], selected[indices[valid]])
if mixer is not None:
self._flicker_field_dots(image, px, py, opaque=False)
ordinary = super()._point_material(width, height, x, y, light, spread)
base = np.frombuffer(ordinary.bits(), np.uint32, count=width * height)
previous = base[destinations]
mixer(output[destinations], previous, blend)
base[destinations] = previous
return ordinary
np.bitwise_or(output, np.uint32(0xff000000), out=output)
self._flicker_field_dots(image, px, py)
if blend < 255:
ordinary = super()._point_material(width, height, x, y, light, spread)
base = np.frombuffer(ordinary.constBits(), np.uint32, count=width * height)
for start in range(0, output.size, 65536):
stop = min(output.size, start + 65536)
previous, colored = base[start:stop], output[start:stop].copy()
mixed = np.full(stop - start, np.uint32(0xff000000), dtype=np.uint32)
for shift in (16, 8, 0):
channel = ((((previous >> shift) & 255) * (255 - blend)
+ ((colored >> shift) & 255) * blend + 127) // 255)
mixed |= channel << shift
output[start:stop] = mixed
return image
def _flatten_quadratic(x0, y0, cx, cy, x1, y1, level=5) -> list:
"""Points along a quadratic Bézier, split where QPainter's hairline splits.
The curve is raised to a cubic, as QPainterPath stores it, and halved
only while a control point lies a quarter of the chord's L1 length or
more from the chord, up to ``level`` times; a nearly straight curve
stays one line, which is how Qt's cosmetic stroker draws it.
"""
first = (x0 + 2.0 / 3.0 * (cx - x0), y0 + 2.0 / 3.0 * (cy - y0))
second = (x1 + 2.0 / 3.0 * (cx - x1), y1 + 2.0 / 3.0 * (cy - y1))
out = [(x0, y0)]
pending = [((x0, y0), first, second, (x1, y1), level)]
while pending:
a, b, c, d, depth = pending.pop()
dx, dy = d[0] - a[0], d[1] - a[1]
reach = 0.25 * (abs(dx) + abs(dy))
if depth and (abs(dx * (a[1] - c[1]) - dy * (a[0] - c[0])) >= reach
or abs(dx * (a[1] - b[1]) - dy * (a[0] - b[0])) >= reach):
ab = ((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5)
bc = ((b[0] + c[0]) * 0.5, (b[1] + c[1]) * 0.5)
cd = ((c[0] + d[0]) * 0.5, (c[1] + d[1]) * 0.5)
abc = ((ab[0] + bc[0]) * 0.5, (ab[1] + bc[1]) * 0.5)
bcd = ((bc[0] + cd[0]) * 0.5, (bc[1] + cd[1]) * 0.5)
middle = ((abc[0] + bcd[0]) * 0.5, (abc[1] + bcd[1]) * 0.5)
pending.append((middle, bcd, cd, d, depth - 1))
pending.append((a, ab, abc, middle, depth - 1))
else:
out.append(d)
return out
class _FungalGrowthEngine(_GraphicsBackend, _BufferedEngine):
"""Fine connected mycelial fans branch from common origins continuously.
Bright tips advance along irregular filaments and fork progressively.
Older connected colonies recede as new origins start, without a full
frame reset. Indexed finite trees make arbitrary clock seeks reproducible
without accumulating a simulation history.
"""
name = "data_art_fungal_growth"
base_edge = 2048
_interval = 30.0
_edge_lifetime = 75.0
def _shade(self, width: int, height: int) -> QImage:
"""Render the unchanged native field into a freshly owned image.
The optional graphics renderer strokes the same partial Béziers and
tips; any refusal keeps the QPainter path below.
"""
bw, bh = self.buffer_size(width, height)
image = self._graphics_frame('_draw_filaments', bw, bh,
build=lambda: self._fungal_strokes(bw, bh))
if image is not None:
return self._soften(image, width, height)
image = QImage(bw, bh, QImage.Format_RGB32)
painter = QPainter(image)
self._owned_fungal_image = image
try:
painter.fillRect(image.rect(), self.identity)
painter.setCompositionMode(self.mode)
painter.setPen(Qt.NoPen)
self._paint_field(painter, bw, bh)
finally:
self._owned_fungal_image = None
painter.end()
return self._soften(image, width, height)
def _fungal_strokes(self, width: int, height: int) -> tuple:
"""Flattened filament groups and tips for the graphics renderer.
Groups follow :meth:`_fungal_paths` (same keys, same order, same
colours); each partial quadratic Bézier is split into lines as Qt
splits it (:func:`_flatten_quadratic`).
"""
np = _numpy()
colors = self.paint_colors
curves: Dict[tuple, list] = {}
tips = []
for (x0, y0, cx, cy, x1, y1, progress,
alpha, stroke, hue) in self.geometry(width, height):
control_x = x0 + progress * (cx - x0)
control_y = y0 + progress * (cy - y0)
end_x = ((1.0 - progress) ** 2 * x0
+ 2.0 * (1.0 - progress) * progress * cx
+ progress ** 2 * x1)
end_y = ((1.0 - progress) ** 2 * y0
+ 2.0 * (1.0 - progress) * progress * cy
+ progress ** 2 * y1)
curves.setdefault((hue % len(colors), stroke, alpha), []).append(
(x0, y0, control_x, control_y, end_x, end_y))
if progress < 1.0:
tip = _with_alpha(colors[(hue + 1) % len(colors)],
min(0.85, alpha * 1.8))
tips.append((end_x, end_y, max(0.45, stroke * 0.8), tip.redF(),
tip.greenF(), tip.blueF(), tip.alphaF()))
groups, pieces, first = [], [], 0
for (hue, stroke, alpha), group in curves.items():
rows = []
for curve in group:
points = _flatten_quadratic(*curve)
last = len(points) - 2
for index in range(last + 1):
rows.append((*points[index], *points[index + 1], stroke * 0.5,
(1.0 if index == 0 else 0.0)
+ (2.0 if index == last else 0.0)))
segment = np.asarray(rows, dtype=np.float32).reshape(-1, 6)
if stroke <= 1.0 and len(segment):
for flag, head in ((1.0, 0), (2.0, 2)):
rows_ = np.flatnonzero(np.fmod(segment[:, 5], 2.0 * flag) >= flag)
delta = segment[rows_, 2:4] - segment[rows_, 0:2]
major = np.maximum(np.abs(delta).max(axis=1), 1e-6)
push = 0.5 * delta / major[:, None]
if head == 0:
segment[rows_, 0:2] -= push
else:
segment[rows_, 2:4] += push
margin = stroke * 0.5 + 2.0
color = _with_alpha(colors[hue], alpha)
xs = segment[:, 0:4:2]
ys = segment[:, 1:4:2]
groups.append((first, len(segment),
math.floor(float(xs.min()) - margin),
math.floor(float(ys.min()) - margin),
math.ceil(float(xs.max()) + margin),
math.ceil(float(ys.max()) + margin),
color.redF(), color.greenF(), color.blueF(),
color.alphaF()))
pieces.append(segment)
first += len(segment)
segments = (np.concatenate(pieces) if pieces
else np.zeros((0, 6), dtype=np.float32))
return groups, segments, tips, self.dark
def _configure(self, rng: random.Random) -> None:
"""Roll the first common origin and a seed for all indexed colonies."""
self._fungal_seed = rng.randrange(2 ** 63)
self._origin = (0.05, rng.uniform(0.43, 0.57))
self._lineage_cache: Dict[tuple, tuple] = {}
self._fungal_rasters: Dict[tuple, tuple] = {}
self._fungal_observed: Dict[tuple, tuple] = {}
self._fungal_raster_failed = False
self._owned_fungal_image = None
def _resize(self) -> None:
"""Rebuild paths at the chosen physical branch length."""
self._lineage_cache.clear()
self._fungal_rasters.clear()
self._fungal_observed.clear()
def buffer_scale(self, width: int, height: int) -> int:
"""Draw crisp native pixels when the physical screen budget allows."""
width, height = max(1, int(width)), max(1, int(height))
scale = max(1, int(math.ceil(1.0 / self.resolution)))
scale = min(scale, max(1, min(width, height) // BUFFER_MIN_EDGE))
while scale < 64 and (width // scale) * (height // scale) > self.max_pixels:
scale += 1
return scale
def blur_scale(self, width: int, height: int) -> float:
"""Allow a little softness without turning filaments into a wash."""
return min(1.3, super().blur_scale(width, height))
def _lineage(self, block: int, width: int, height: int) -> tuple:
"""Index persistent wandering tips and their recursive front forks."""
key = (block, width, height, self.size, self.density)
cached = self._lineage_cache.get(key)
if cached is not None:
return cached
seed = (self._fungal_seed ^ (block * 0xD1B54A32D192ED03)) & (2 ** 128 - 1)
rng = random.Random(seed)
direction = block % 4
center = self._origin[1] if block == 0 else rng.uniform(0.43, 0.57)
headings = (0.0, math.pi, math.pi / 2.0, -math.pi / 2.0)
start_heading = headings[direction]
colony_turn = (headings[(direction + 1) % 4] - start_heading
+ math.pi) % math.tau - math.pi
if abs(colony_turn + math.pi) < 1e-9 and block % 2 == 0:
colony_turn = math.pi
along = math.cos(start_heading), math.sin(start_heading)
frontier = []
for front, lane in enumerate((center - 0.05, center, center + 0.05)):
lane = center
if direction == 0:
x, y = 0.05 * width, lane * height
elif direction == 1:
x, y = 0.95 * width, lane * height
elif direction == 2:
x, y = lane * width, 0.05 * height
else:
x, y = lane * width, 0.95 * height
angle = math.atan2(along[1], along[0]) + rng.uniform(-1.1, 1.1)
frontier.append((x, y, math.cos(angle), math.sin(angle),
-1, front, 0, front * 128 + 1, -math.inf))
edges = []
axis = width if direction < 2 else height
tip_limit = max(5, min(96, round(48 * math.sqrt(self.effective_density()))))
for tick in range(144):
following = []
progress = min(1.0, tick / 105.0)
bias = 0.16 + 0.36 * progress
transition = max(0.0, min(1.0, (tick - 84) / 60.0))
transition = transition * transition * (3.0 - 2.0 * transition)
heading = start_heading + colony_turn * transition
target_x, target_y = math.cos(heading), math.sin(heading)
proposals = []
for x0, y0, vx, vy, rank, front, depth, code, ready in frontier:
branch_rng = random.Random(seed ^ (code * 0x9E3779B97F4A7C15)
^ (tick * 0xD1B54A32D192ED03))
local_bias = bias / (1.0 + 0.03 * min(depth, 12))
step_turn = branch_rng.uniform(-0.58, 0.58) * (1.0 - 0.40 * local_bias)
cosine, sine = math.cos(step_turn), math.sin(step_turn)
turned_x, turned_y = vx * cosine - vy * sine, vx * sine + vy * cosine
edge_x = (max(0.0, (0.20 * width - x0) / (0.20 * width))
- max(0.0, (x0 - 0.80 * width) / (0.20 * width)))
edge_y = (max(0.0, (0.20 * height - y0) / (0.20 * height))
- max(0.0, (y0 - 0.80 * height) / (0.20 * height)))
heading_x = ((1.0 - local_bias) * turned_x + local_bias * target_x
+ 1.8 * edge_x)
heading_y = ((1.0 - local_bias) * turned_y + local_bias * target_y
+ 1.8 * edge_y)
norm = math.hypot(heading_x, heading_y)
heading_x, heading_y = heading_x / norm, heading_y / norm
length = axis * self.size ** 0.3 / 120.0 * branch_rng.uniform(0.85, 1.45)
x1, y1 = x0 + heading_x * length, y0 + heading_y * length
if x1 < width * 0.025:
x1 = width * 0.05 - x1
heading_x = abs(heading_x)
elif x1 > width * 0.975:
x1 = width * 1.95 - x1
heading_x = -abs(heading_x)
if y1 < height * 0.025:
y1 = height * 0.05 - y1
heading_y = abs(heading_y)
elif y1 > height * 0.975:
y1 = height * 1.95 - y1
heading_y = -abs(heading_y)
if math.hypot(x1 - x0, y1 - y0) < 0.4:
if rank == -1:
following.append((x0, y0, target_x, target_y,
rank, front, depth, code, ready))
continue
bend = branch_rng.uniform(-0.015, 0.015)
control_x = (x0 + x1) * 0.5 - (y1 - y0) * bend
control_y = (y0 + y1) * 0.5 + (x1 - x0) * bend
born = max(ready, block * self._interval - 0.55
+ tick * self._interval / 108.0
+ branch_rng.uniform(-0.012, 0.012))
duration = branch_rng.uniform(0.26, 0.30)
edges.append((rank, x0, y0, control_x, control_y, x1, y1,
born, duration, front, depth))
following.append((x1, y1, heading_x, heading_y,
rank, front, depth, code, born + duration))
chance = 0.42 + 0.24 * progress ** 1.5 if tick < 140 else 0.0
if branch_rng.random() < chance:
side = -1 if branch_rng.random() < 0.5 else 1
fork = side * branch_rng.uniform(0.42, 1.05)
cosine, sine = math.cos(fork), math.sin(fork)
child_x = heading_x * cosine - heading_y * sine
child_y = heading_x * sine + heading_y * cosine
cosine, sine = math.cos(-fork * 0.55), math.sin(-fork * 0.55)
continuation_x = heading_x * cosine - heading_y * sine
continuation_y = heading_x * sine + heading_y * cosine
following[-1] = (x1, y1, continuation_x, continuation_y,
rank, front, depth + 1, code * 131 + tick + 7,
born + duration)
child_rank = min(999, max(0, rank) + branch_rng.randrange(8, 65))
proposals.append((x1, y1, child_x, child_y, child_rank,
front, depth + 1, code * 131 + tick + 1,
born + duration))
rng.shuffle(proposals)
quota = min(len(proposals), max(1, round(
(1 + 7 * progress ** 1.5) * tip_limit / 48)))
for child in proposals[:quota]:
if len(following) >= tip_limit:
replace = min((i for i, tip in enumerate(following)
if tip[4] != -1),
key=lambda i: (following[i][6], following[i][8]))
following.pop(replace)
following.append(child)
frontier = following
result = tuple(edges)
self._lineage_cache[key] = result
if len(self._lineage_cache) > 8:
del self._lineage_cache[next(iter(self._lineage_cache))]
return result
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""Visible front-first branches within a sparse pixel footprint."""
if width <= 0 or height <= 0:
return ()
width, height = int(width), int(height)
latest = max(0, math.floor((self.time + 0.55) / self._interval))
earliest = max(0, math.floor((self.time - self._edge_lifetime)
/ self._interval))
stroke = max(0.65, min(2.0, 1.45 * self.size
* (min(width, height) / 1080.0) ** 0.35))
density = self.density
density_alpha = min(1.0, 60.0 * density) / math.sqrt(max(1.0, density))
candidates = []
for block in range(earliest, latest + 1):
colony_age = self.time - (block * self._interval - 0.55)
colony_fade = min(1.0, (self._edge_lifetime - colony_age) / 32.0)
if colony_fade <= 0.0:
continue
for (index, x0, y0, cx, cy, x1, y1, born, duration,
hue, depth) in self._lineage(block, width, height):
age = self.time - born
if age <= 0.0:
continue
progress = min(1.0, age / duration)
fade = min(1.0, age / 0.35) * colony_fade
trailing_age = max(0.0, age - duration)
alpha = (0.50 + 0.75 * max(0.0, 1.0 - trailing_age / 9.0)) \
* fade * density_alpha
if alpha >= 0.006:
candidates.append((x0, y0, cx, cy, x1, y1, progress,
alpha, max(0.4, stroke * 0.90 ** depth), hue))
budget = width * height * 0.22 * min(
1.0, 0.5 + 0.5 * math.sqrt(self.effective_density() / 3.0))
costs = []
by_endpoint = {}
by_origin = {}
for index, edge in enumerate(candidates):
x0, y0, cx, cy, x1, y1, progress, _, thick, _ = edge
control_x = x0 + progress * (cx - x0)
control_y = y0 + progress * (cy - y0)
end_x = ((1.0 - progress) ** 2 * x0
+ 2.0 * (1.0 - progress) * progress * cx
+ progress ** 2 * x1)
end_y = ((1.0 - progress) ** 2 * y0
+ 2.0 * (1.0 - progress) * progress * cy
+ progress ** 2 * y1)
length = math.hypot(control_x - x0, control_y - y0) \
+ math.hypot(end_x - control_x, end_y - control_y)
radius = thick * 0.5 + 1.5
footprint = 2.0 * radius * length + math.pi * radius ** 2
if progress < 1.0:
footprint += math.pi * (thick * 0.8 + 1.5) ** 2
costs.append(footprint)
by_endpoint[(x1, y1)] = index
by_origin.setdefault((x0, y0), []).append(index)
parents = [by_endpoint.get(edge[:2]) for edge in candidates]
selected = set()
for index in reversed(range(len(candidates))):
if index not in selected and costs[index] > budget:
continue
chain = []
seen = set()
for sibling in by_origin[candidates[index][:2]]:
cursor = sibling
while cursor is not None and cursor not in selected and cursor not in seen:
seen.add(cursor)
chain.append(cursor)
cursor = parents[cursor]
footprint = sum(costs[item] for item in chain)
if footprint <= budget:
selected.update(chain)
budget -= footprint
return tuple(candidates[index] for index in sorted(selected))
def _fungal_paths(self, width: int, height: int) -> tuple:
"""Group the unchanged partial Béziers in their original paint order."""
colors = self.paint_colors
paths = {}
mature = {}
stable_alpha = (0.50 * min(1.0, 60.0 * self.density)
/ math.sqrt(max(1.0, self.density)))
tips = []
for (x0, y0, cx, cy, x1, y1, progress,
alpha, stroke, hue) in self.geometry(width, height):
control_x = x0 + progress * (cx - x0)
control_y = y0 + progress * (cy - y0)
end_x = ((1.0 - progress) ** 2 * x0
+ 2.0 * (1.0 - progress) * progress * cx
+ progress ** 2 * x1)
end_y = ((1.0 - progress) ** 2 * y0
+ 2.0 * (1.0 - progress) * progress * cy
+ progress ** 2 * y1)
key = (hue % len(colors), stroke, alpha)
mature[key] = mature.get(key, True) and progress == 1.0 and alpha == stable_alpha
path = paths.get(key)
if path is None:
path = QPainterPath()
paths[key] = path
path.moveTo(x0, y0)
path.quadTo(control_x, control_y, end_x, end_y)
if progress < 1.0:
tips.append((end_x, end_y, stroke, hue, alpha))
return paths, mature, tips
def _can_reuse_fungal_raster(self, painter: QPainter, width: int, height: int) -> bool:
"""Allow exact sparse additions only within this frame's private native image."""
device = painter.device()
return (not self._fungal_raster_failed and self.dark and self.size >= 2.0
and device is self._owned_fungal_image
and isinstance(device, QImage) and device.format() == QImage.Format_RGB32
and device.width() == width and device.height() == height
and painter.worldTransform().isIdentity()
and painter.deviceTransform().isIdentity()
and painter.opacity() == 1.0 and not painter.hasClipping()
and painter.compositionMode() == self.mode)
def _reuse_fungal_raster(self, key: tuple, path: QPainterPath,
color: QColor, target) -> bool:
"""Add an owned contribution only when Qt Plus cannot reach saturation."""
entry = self._fungal_rasters.get(key)
if entry is None or entry[0] != path:
return False
np = _numpy()
indices, source = entry[1], entry[2]
destination = target[indices]
ceiling = tuple(math.ceil(channel * color.alphaF() * 255.0) + 1
for channel in (color.blueF(), color.greenF(), color.redF()))
if not all(np.all(((destination >> shift) & 255) <= 255 - high)
for shift, high in zip((0, 8, 16), ceiling)):
return False
output = np.full(destination.shape, 0xff000000, dtype=np.uint32)
for shift in (0, 8, 16):
channel = ((destination >> shift) & 255) + ((source >> shift) & 255)
output |= np.minimum(channel, 255) << shift
target[indices] = output
del self._fungal_rasters[key]
self._fungal_rasters[key] = entry
return True
def _observe_fungal_path(self, key: tuple, path: QPainterPath) -> bool:
"""Require three unchanged observations and retain at most sixty-four paths."""
previous = self._fungal_observed.get(key)
stable = previous[1] + 1 if previous is not None and previous[0] == path else 1
self._fungal_observed[key] = (QPainterPath(path), stable)
if len(self._fungal_observed) > 64:
del self._fungal_observed[next(iter(self._fungal_observed))]
return stable >= 3
def _warm_fungal_raster(self, key: tuple, path: QPainterPath,
color: QColor, stroke: float, width: int, height: int) -> bool:
"""Cache exact native Qt pixels as owned sparse arrays within eight MiB."""
margin = math.ceil(stroke) + 2
region = path.boundingRect().toAlignedRect().adjusted(-margin, -margin, margin, margin)
region = region.intersected(QRect(0, 0, width, height))
if region.isEmpty():
return False
stage = QImage(width, height, QImage.Format_RGB32)
warm = QPainter(stage)
try:
warm.setClipRect(region)
warm.setCompositionMode(QPainter.CompositionMode_Source)
warm.fillRect(region, self.identity)
warm.setCompositionMode(self.mode)
warm.setRenderHint(QPainter.Antialiasing, True)
warm.setBrush(Qt.NoBrush)
warm.setPen(QPen(color, stroke, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
warm.drawPath(path)
finally:
warm.end()
np = _numpy()
pixels = np.frombuffer(stage.constBits(), dtype=np.uint32).reshape(height, width)
crop = pixels[region.y():region.y() + region.height(),
region.x():region.x() + region.width()]
yy, xx = np.nonzero(crop & 0x00ffffff)
indices = ((yy + region.y()) * width + xx + region.x()).astype(np.int32)
values = (crop[yy, xx] & 0x00ffffff).astype(np.uint32)
self._fungal_rasters[key] = (QPainterPath(path), indices, values)
while (len(self._fungal_rasters) > 64
or sum(v[1].nbytes + v[2].nbytes for v in self._fungal_rasters.values())
> 8 * 1024 ** 2):
del self._fungal_rasters[next(iter(self._fungal_rasters))]
return True
def _paint_fungal_paths(self, painter: QPainter, paths: dict) -> None:
"""Draw every original group using its unmodified Qt pen and insertion order."""
colors = self.paint_colors
pen = QPen(Qt.SolidLine)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
for (hue, stroke, alpha), path in paths.items():
color = _with_alpha(colors[hue], alpha)
pen.setColor(color)
pen.setWidthF(stroke)
painter.setPen(pen)
painter.drawPath(path)
def _paint_cached_fungal_paths(self, painter: QPainter, width: int, height: int,
paths: dict, mature: dict) -> None:
"""Reuse stable contributions or draw their original Qt groups, in order."""
device = painter.device()
np = _numpy()
target = np.frombuffer(device.bits(), dtype=np.uint32).reshape(height, width).ravel()
created = 0
colors = self.paint_colors
pen = QPen(Qt.SolidLine)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
for group, path in paths.items():
hue, stroke, alpha = group
color = _with_alpha(colors[hue], alpha)
pen.setColor(color)
pen.setWidthF(stroke)
painter.setPen(pen)
key = (group, color.getRgbF(), width, height)
if self._reuse_fungal_raster(key, path, color, target):
continue
painter.drawPath(path)
if (created < 1 and mature[group] and self._observe_fungal_path(key, path)):
created += self._warm_fungal_raster(key, path, color, stroke, width, height)
def _paint_fungal_tips(self, painter: QPainter, tips: list) -> None:
"""Paint the original live-tip discs after all of their filament groups."""
colors = self.paint_colors
painter.setPen(Qt.NoPen)
for end_x, end_y, stroke, hue, alpha in tips:
painter.setBrush(_with_alpha(colors[(hue + 1) % len(colors)],
min(0.85, alpha * 1.8)))
radius = max(0.45, stroke * 0.8)
painter.drawEllipse(QPointF(end_x, end_y), radius, radius)
painter.setBrush(Qt.NoBrush)
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Trace the original native filaments, recovering from optional-cache errors."""
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setBrush(Qt.NoBrush)
paths, mature, tips = self._fungal_paths(width, height)
if self._can_reuse_fungal_raster(painter, width, height):
device = painter.device()
try:
painter.save()
try:
self._paint_cached_fungal_paths(painter, width, height, paths, mature)
finally:
painter.restore()
except Exception:
self._fungal_raster_failed = True
self._fungal_rasters.clear()
self._fungal_observed.clear()
painter.setCompositionMode(QPainter.CompositionMode_Source)
painter.fillRect(device.rect(), self.identity)
painter.setCompositionMode(self.mode)
self._paint_fungal_paths(painter, paths)
else:
self._paint_fungal_paths(painter, paths)
self._paint_fungal_tips(painter, tips)
class _ThoreEngine(_BufferedEngine):
"""A cool rain field with occasional branching, restrained lightning.
Drops wrap independently, while each bolt is generated from its indexed
event seed. Neither the rain nor the sky has a frame-wide reset.
"""
name = "data_art_thore"
base_edge = 2048
_event_interval = 8.4
def _shade(self, width: int, height: int) -> QImage:
"""Render the unchanged native field into a freshly owned image."""
bw, bh = self.buffer_size(width, height)
image = QImage(bw, bh, QImage.Format_RGB32)
painter = QPainter(image)
try:
painter.fillRect(image.rect(), self.identity)
painter.setCompositionMode(self.mode)
painter.setPen(Qt.NoPen)
self._paint_field(painter, bw, bh)
finally:
painter.end()
return self._soften(image, width, height)
def _configure(self, rng: random.Random) -> None:
"""Keep immutable rain particles and one seed for indexed bolts."""
self._thore_seed = rng.randrange(2 ** 63)
self._rain = tuple((rng.random(), rng.random(),
rng.uniform(0.65, 1.45),
rng.uniform(0.65, 1.5), rng.randrange(5))
for _ in range(340))
self._bolt_cache: Dict[int, tuple] = {}
def buffer_scale(self, width: int, height: int) -> int:
"""Retain distinct streaks and fine bolt forks at native detail."""
return _FungalGrowthEngine.buffer_scale(self, width, height)
def _bolt(self, index: int) -> tuple:
"""One compact deterministic lightning tree, cached four events."""
cached = self._bolt_cache.get(index)
if cached is not None:
return cached
rng = random.Random((self._thore_seed ^
(index * 0xD1B54A32D192ED03)) & (2 ** 128 - 1))
horizontal = index % 3 == 1
along = (1.0, 0.0) if horizontal else (0.0, 1.0)
across = (0.0, 1.0) if horizontal else (1.0, 0.0)
x = -0.035 if horizontal else rng.uniform(0.21, 0.79)
y = rng.uniform(0.21, 0.79) if horizontal else -0.035
trunk = [(x, y)]
forks = []
for step in range(32):
stride = rng.uniform(0.018, 0.027)
zigzag = rng.uniform(-0.025, 0.025)
x += along[0] * stride + across[0] * zigzag
y += along[1] * stride + across[1] * zigzag
trunk.append((x, y))
if step in (9, 17, 24):
direction = rng.choice((-1, 1))
bx, by = x, y
branch = [(bx, by)]
for _ in range(7):
stride = rng.uniform(0.007, 0.018)
fork = direction * rng.uniform(0.010, 0.038)
bx += along[0] * stride + across[0] * fork
by += along[1] * stride + across[1] * fork
branch.append((bx, by))
forks.append(tuple(branch))
result = (tuple(trunk), tuple(forks))
self._bolt_cache[index] = result
if len(self._bolt_cache) > 4:
del self._bolt_cache[next(iter(self._bolt_cache))]
return result
def geometry(self, width: int, height: int) -> Tuple[tuple, ...]:
"""Colour-independent rain positions and the active lightning tree."""
if width <= 0 or height <= 0:
return ()
amount = min(len(self._rain), max(1, round(105 * self.density)))
length = min(width, height) * 0.025 * self.size
drops = []
for x0, phase, fall, span, hue in self._rain[:amount]:
progress = (phase + self.time * (0.085 + 0.040 * fall)) % 1.0
wind = (0.012 * math.sin(self.time * 0.23 + fall * math.tau)
+ 0.006 * math.sin(progress * math.tau + x0 * 9.0))
x = (x0 + wind) * width
y = progress * (height + 2.0 * length) - length
slant = length * (0.15 + 0.12 * math.sin(
self.time * 0.23 + fall * math.tau))
fade = min(1.0, progress / 0.018, (1.0 - progress) / 0.018)
drops.append((x, y, slant, length, (0.14 + 0.10 * span)
* self.alpha_scale() * fade, hue))
index = math.floor(self.time / self._event_interval)
age = self.time - index * self._event_interval
flash = ((math.sin(math.pi * age / 0.34) ** 2, *self._bolt(index))
if 0.0 <= age < 0.34 else ())
return tuple(drops), flash
def _paint_field(self, painter: QPainter, width: int, height: int) -> None:
"""Draw tapered advected rain and narrow, pale branching lightning."""
painter.setRenderHint(QPainter.Antialiasing, True)
palette = self.paint_colors
drops, flash = self.geometry(width, height)
for x, y, slant, length, alpha, hue in drops:
color = palette[hue % len(palette)]
taper = QLinearGradient(x, y, x + slant, y + length)
taper.setColorAt(0.0, _with_alpha(color, 0.0))
taper.setColorAt(0.55, _with_alpha(color, alpha * 0.34))
taper.setColorAt(1.0, _with_alpha(color, alpha))
painter.setPen(QPen(QBrush(taper), max(0.55, 0.72 * self.size), Qt.SolidLine,
Qt.RoundCap))
painter.drawLine(QPointF(x, y), QPointF(x + slant, y + length))
if flash:
pulse, trunk, forks = flash
wash = QLinearGradient(0.0, 0.0, 0.0, float(height))
wash.setColorAt(0.0, _with_alpha(palette[0], 0.018 * pulse))
wash.setColorAt(1.0, _with_alpha(palette[0], 0.0))
painter.setPen(Qt.NoPen)
painter.fillRect(0, 0, width, height, wash)
for points, fine in ((trunk, False),
*((branch, True) for branch in forks)):
path = QPainterPath(QPointF(points[0][0] * width,
points[0][1] * height))
for px, py in points[1:]:
path.lineTo(px * width, py * height)
bolt_color = _mix(palette[0], QColor("#ffffff"),
0.48 if self.dark else 0.0)
painter.setPen(QPen(_with_alpha(bolt_color,
(0.29 if fine else 0.49)
* pulse * self.alpha_scale()),
(0.70 if fine else 1.35) * self.size,
Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
painter.drawPath(path)
painter.setRenderHint(QPainter.Antialiasing, False)
_ENGINES = {
"blobs": BlobsEngine,
"aurora": AuroraEngine,
"ripple": RippleEngine,
"drift": DriftEngine,
"cells": CellsEngine,
"data_art_point_atlas": partial(_DataArtEngine, family="point_atlas"),
"data_art_tissue_facets": partial(_DataArtEngine, family="tissue_facets"),
"data_art_genetic_advection": partial(_DataArtEngine, family="genetic_advection"),
"data_art_impulse_lens": partial(_DataArtEngine, family="impulse_lens"),
"data_art_spaceout_field": _SpaceoutFieldEngine,
"data_art_fungal_growth": _FungalGrowthEngine,
SPACEOUT_THEME: FractalEngine,
}
[docs]
def make_engine(theme: str, palette: str, background: Union[QColor, str],
seed: Optional[int] = None,
blur: float = DEFAULT_BLUR,
speed: float = DEFAULT_SPEED,
size: float = DEFAULT_SIZE,
resolution: float = DEFAULT_RESOLUTION,
density: float = DEFAULT_DENSITY,
direction: str = DEFAULT_DRIFT_DIRECTION,
blink_percent: float = DEFAULT_BLINK_PERCENT,
popup_wave_frequency: float = 0.0) -> AmbientEngine:
"""Build the engine for ``theme``/``palette``. Raises on unknown names.
Everything after ``seed`` is a user control; the defaults are the shipped
animation exactly.
"""
_require_theme(theme)
_require_palette(theme, palette)
engine = _ENGINES[theme](palette_colors(theme, palette), background,
seed=seed, blur=blur, speed=speed, size=size,
resolution=resolution, density=density,
direction=direction)
engine.set_blink_percent(blink_percent)
engine.set_popup_wave_frequency(popup_wave_frequency)
return engine
[docs]
class Motion(NamedTuple):
"""Every user control that shapes the animation, in one value.
A named tuple rather than five arguments because the set grows: it went
from three to five in one change, and every install site that had
unpacked a plain tuple would have broken.
:param blur: softness of the painted shapes; the widget clamps it to
:data:`BLUR_RANGE`.
:param speed: animation-clock multiplier, clamped to :data:`SPEED_RANGE`.
:param size: element-size multiplier, clamped to :data:`SIZE_RANGE`.
:param resolution: detail (render buffer) multiplier, clamped to
:data:`RESOLUTION_RANGE`.
:param density: element-count multiplier, clamped to
:data:`DENSITY_RANGE`.
:param direction: starfield drift direction, one of
:data:`DRIFT_DIRECTIONS`; an unknown name falls back to the default.
"""
blur: float
speed: float
size: float
resolution: float
density: float
direction: str
[docs]
def preferred_motion(theme: Optional[str] = None) -> Motion:
"""The animation controls, from the user's preferences.
Read here rather than passed in by every install site, for the same
reason :func:`_theme_background` is: the two callers that build ambient
widgets are a module screen and Home, and neither of them has any
business knowing what the animation's knobs are called. Falls back to
the shipped defaults if preferences cannot be read at all.
:param theme: the theme being built, whose own default density applies
while no density was saved.
"""
fallback = Motion(DEFAULT_BLUR, DEFAULT_SPEED, DEFAULT_SIZE,
DEFAULT_RESOLUTION, _default_density_for(theme),
DEFAULT_DRIFT_DIRECTION)
try:
from ..preferences import (get_ambient_density,
get_ambient_drift_direction,
get_ambient_resolution, get_ambient_size,
get_ambient_speed)
return Motion(DEFAULT_BLUR, get_ambient_speed(),
get_ambient_size(), get_ambient_resolution(),
get_ambient_density(theme), get_ambient_drift_direction())
except Exception:
return fallback
def _preferred_gravity_radius() -> float:
"""Read the optional pointer radius without requiring Preferences at import."""
try:
from ..preferences import _ambient_gravity_radius
return _clamp(_ambient_gravity_radius(), 0.0, 1.0)
except Exception:
return 0.0
#: Frames painted by every ambient backdrop in this process, ever.
#:
#: Process-wide and not per widget, because the claim "None costs nothing"
#: is about the *application*, and the widget it would be counted on is the
#: one that does not exist. A test selects None, drives a real screen, and
#: asserts this number does not move.
_TOTAL_FRAMES = 0
[docs]
def total_frames_painted() -> int:
"""How many ambient frames this process has painted. For tests."""
return _TOTAL_FRAMES
#: How long :func:`_retire_producer` waits for a shading thread to notice it
#: has been asked to stop, in seconds.
#:
#: One loop iteration is at most one shading pass, and the sleep between
#: passes wakes on the stop event rather than expiring — so a healthy thread
#: is gone in microseconds and this only bounds a pathological one. A thread
#: that has not stopped by then is a daemon that will exit on its own, and
#: waiting longer for it on the GUI thread would be a worse bug than the one
#: it is guarding.
#:
#: The wait is on the GUI thread, in ``hideEvent`` — i.e. on every tab switch
#: — and it does cost something when the machine is busy: **47 ms worst of
#: eight**, hiding a ``cells`` backdrop at 1080p while a Python worker runs,
#: which is one shading pass already in flight finishing. That is the trade
#: this whole change makes and it is strongly the right way round: a one-off
#: 47 ms on a tab switch against 18-33 ms on *every frame* beforehand.
PRODUCER_JOIN_S = 2.0
class _QueuedArtInput:
"""Publish bounded immutable GUI input for consumption between frames.
Only the GUI offers snapshots. The cumulative elapsed counter and latest
pointer share one immutable tuple with at most sixteen numbered clicks.
A consumer holds the engine lock, whether it is the GUI or the producer;
its applied serials prevent advancing the same tick or click twice.
"""
def __init__(self):
"""Initialize bounded cumulative tick and click ownership counters."""
self._serial = 0
self._elapsed = 0.0
self._click_serial = 0
self._clicks = ()
self._snapshot = (0, 0.0, None, ())
self._applied_serial = 0
self._applied_elapsed = 0.0
self._applied_click_serial = 0
self._grab_snapshot = (0, None)
self._applied_grab_serial = 0
self._popup_snapshot = (0, None, None)
self._boundary_serial = 0
self._boundary_waves = ()
self._applied_boundary_serial = 0
def _offer_boundary_waves(self, points) -> None:
"""Publish bounded immutable boundary events without a shading lock."""
for point in points:
self._boundary_serial += 1
self._boundary_waves = (self._boundary_waves + (
(self._boundary_serial, point),))[-16:]
def _offer(self, step, pointer, clicks, *, grab=None, popup_origin=None,
popup_id=None) -> None:
"""Publish one GUI tick without taking or waiting for an engine lock."""
self._elapsed += step
for point in clicks:
self._click_serial += 1
self._clicks = (self._clicks + ((self._click_serial, point),))[-16:]
self._serial += 1
self._grab_snapshot = (self._serial, grab)
self._popup_snapshot = (self._serial, popup_origin, popup_id)
self._snapshot = (self._serial, self._elapsed, pointer, self._clicks)
def _consume(self, engine, *, discard_clicks=False, preserve_grab=False) -> None:
"""Apply newly offered GUI input while the caller owns the engine lock.
Lifecycle discards reset the handle; turning hover gravity off can
discard clicks while preserving an explicitly held material patch.
"""
serial, elapsed, pointer, clicks = self._snapshot
if isinstance(engine, _DataArtEngine) and engine.family == "impulse_lens":
if discard_clicks:
self._applied_boundary_serial = self._boundary_serial
else:
for wave_serial, point in self._boundary_waves:
if wave_serial > self._applied_boundary_serial:
if point and point[0] == "column":
engine._add_column_wave(point[1:])
elif point and point[0] == "rings":
engine._add_ripple(point)
else:
engine._add_ripple(point)
self._applied_boundary_serial = wave_serial
popup_serial, popup_origin, popup_id = self._popup_snapshot
if discard_clicks and not preserve_grab:
engine._set_popup_wave_origin(None)
elif popup_serial == serial:
engine._set_popup_wave_origin(popup_origin, popup_id=popup_id)
grab_serial, grab = self._grab_snapshot
if discard_clicks and not preserve_grab:
engine._set_field_grab(None, reset=True)
elif self._applied_grab_serial < grab_serial <= serial:
engine._set_field_grab(grab)
self._applied_grab_serial = grab_serial
if serial <= self._applied_serial:
return
if (isinstance(engine, _DataArtEngine) and engine.interactive
and not discard_clicks):
engine.set_pointer(pointer)
delta = elapsed - self._applied_elapsed
if delta > 0:
engine.advance(delta)
for click_serial, point in clicks:
if (click_serial > self._applied_click_serial and not discard_clicks
and engine.name in _FIELD_THEMES):
engine._add_impulse(point, strength=1.0, ripple=False)
if clicks:
self._applied_click_serial = clicks[-1][0]
self._applied_elapsed = elapsed
self._applied_serial = serial
class _FrameProducer:
"""The shading thread: turns ``(engine, size)`` into finished frames.
One thread per running backdrop, one frame deep. It calls
:meth:`_BufferedEngine.shade` under ``engine_lock`` and publishes the
result into a single slot; the GUI thread takes whatever is in that slot
and blits it. Nothing here touches a ``QWidget`` — a widget painted off
the GUI thread is undefined behaviour, and a ``QImage`` painted off it is
supported and is the whole reason this works.
The GUI owns elapsed time and input. Buffered themes publish bounded immutable
tick snapshots, which either the GUI or this producer consumes under the
engine lock before shading. Numbered cumulative counters prevent double
advancement. Consuming on the producer lets the clock and pointer keep
changing even when every shade exceeds the frame interval; a nonblocking
GUI lock attempt alone can otherwise lose every opportunity to advance.
No wall clock, widget or application is queried by this thread. Explicit
clock changes and controls flush offered input under the same lock.
A plain :class:`threading.Thread` and not a ``QThread``: it owns no
object with Qt thread affinity, it emits no signals, and
:mod:`spacr.qt.bridge` is a standing record of what QThread lifetime
costs when the thing on it does not need one.
**What it does not do is speed the shading up.** It is the same Python
under the same interpreter lock, so it takes the same 26 ms for ``cells``
under load that the GUI thread took. What changes is *who waits*: the GUI
thread stops shading, so its frame costs one ``drawImage`` (0.24-0.32 ms
at every load measured) and the animation degrades by repeating a frame
instead of by blocking the interface. That distinction is the fix.
:param engine: a :class:`_BufferedEngine`. Unbuffered engines
(``drift``) have no frame to hand over and keep the synchronous path.
:param engine_lock: the widget's lock over the engine. Held across the
shading pass here, and taken by every widget setter that mutates the
engine, so a live Preferences change cannot land in the middle of a
frame.
:param fps: frame-rate cap, matching the widget's timer.
:param size: ``(width, height)`` of the canvas.
:param queued_input: optional immutable GUI tick handoff for data art.
"""
def __init__(self, engine: "_BufferedEngine", engine_lock,
fps: int, size: Tuple[int, int], queued_input=None):
"""Prepare the shading thread: engine, lock, beat and size."""
self._engine = engine
self._engine_lock = engine_lock
self._queued_input = queued_input
self._interval = 1.0 / max(1, int(fps))
#: Canvas size, written by the GUI thread and read by this one.
#: A plain attribute holding an immutable tuple, deliberately: the
#: assignment is a single bytecode and the reader either sees the old
#: pair or the new one, never half of each, so a lock here would buy
#: nothing but a chance for the GUI thread to wait on it.
self.size: Tuple[int, int] = (int(size[0]), int(size[1]))
#: Frames actually shaded. Under load this falls below the frame
#: rate, which is the degradation this design chooses.
self.frames_shaded = 0
self._frame_lock = threading.Lock()
self._frame: Optional[QImage] = None
self._stop = threading.Event()
self._wake = threading.Event()
self.doorbell = None
self._doorbell_lock = threading.Lock()
self.tick_driven = False
self.urgent = False
self._thread: Optional[threading.Thread] = None
#: The card plan (:class:`_CardPlan`) each frame is composited
#: with, or ``None``. Set by the GUI thread; read once per frame.
self.cards: Optional[_CardPlan] = None
#: ``(plan, composited image, frame)`` for the newest published
#: frame, or ``None`` when it was published without cards.
self.composed = None
#: The owning backdrop's one-element producer box, or ``None``;
#: :func:`_retire_shading_threads` empties it when it retires this.
self.box: Optional[List[Optional["_FrameProducer"]]] = None
def start(self) -> None:
"""Begin shading. A second call is a no-op."""
if self._thread is not None:
return
self._thread = threading.Thread(
target=self._run, name="spacr-ambient-shade", daemon=True)
_LIVE_PRODUCERS.add(self)
self._thread.start()
def _ring_doorbell(self) -> None:
"""Ask the GUI thread to repaint, unless the doorbell has been taken.
Rung under the doorbell lock that :meth:`stop` takes to drop it, so
the shading thread never emits on a doorbell the widget has let go.
"""
with self._doorbell_lock:
bell = self.doorbell
if bell is None:
return
try:
bell.objectNameChanged.emit("")
except RuntimeError:
self.doorbell = None
def stop(self) -> None:
"""Ask the thread to finish the frame it is on and exit, then wait
up to :data:`PRODUCER_JOIN_S` for it.
The thread reference is *kept*, not cleared, so :meth:`is_alive` goes
on answering about the operating system's thread rather than about a
variable this method set to ``None`` — otherwise "the backdrop stopped
its thread" is a claim nothing can check. A producer is never
restarted (:meth:`AmbientWidget._start_producer` builds a new one), so
there is nothing to gain by forgetting it.
"""
with self._doorbell_lock:
self.doorbell = None
self._stop.set()
self._wake.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=PRODUCER_JOIN_S)
def is_alive(self) -> bool:
"""Whether the shading thread is actually running."""
thread = self._thread
return bool(thread is not None and thread.is_alive())
def set_fps(self, fps: int) -> None:
"""Shade no faster than ``fps`` frames a second. Takes effect after
the frame in flight."""
self._interval = 1.0 / max(1, int(fps))
def publish(self, image: QImage, composed=None) -> None:
"""Make ``image`` the frame the next paint will use.
:param composed: the same frame with the card plan composited into
it, as :func:`_compose_cards` returns it, or ``None``.
"""
with self._frame_lock:
self._frame = image
self.composed = composed
def latest(self) -> Optional[QImage]:
"""The newest published frame, or ``None``. **Never blocks.**
``None`` means "ask again next frame" and covers both cases the
caller has to survive: nothing has been shaded yet, and the slot is
being written this instant. Neither is worth waiting for — the
caller already holds the previous frame and repeating it is a frame
of animation, where waiting is a frozen interface.
"""
if not self._frame_lock.acquire(blocking=False):
return None
try:
return self._frame
finally:
self._frame_lock.release()
def _run(self) -> None:
"""Shade frames on the beat until stopped.
Waits on the STOP EVENT rather than sleeping, so stopping returns at once
instead of at the end of the beat. A pass that overran its beat gets no
wait at all, which is how this degrades under load: it keeps shading as
fast as it can rather than falling further behind a schedule it cannot
keep.
"""
try:
if getattr(self._engine, "_graphics_backend", "cpu") == "gpu":
_flow_graphics_preflight()
woken, woken_at = False, -math.inf
while not self._stop.is_set():
started = time.monotonic()
urgent, self.urgent = self.urgent, False
width, height = self.size
image = composed = None
if width > 0 and height > 0:
with self._engine_lock:
if self._queued_input is not None:
self._queued_input._consume(self._engine)
image = self._engine.shade(width, height)
plan = self.cards
if image is not None and plan is not None:
composed = _compose_cards(self._engine, image,
plan)
if self.tick_driven and self._queued_input is not None:
self._queued_input._consume(self._engine)
if image is not None:
self.publish(image, composed)
self.frames_shaded += 1
if woken and (urgent or not self.tick_driven):
self._ring_doorbell()
if woken:
woken_at = started
if self.tick_driven:
woken = self._wake.wait(2.0 * self._interval)
else:
remaining = self._interval - (time.monotonic() - started)
woken = self._wake.wait(remaining if remaining > 0 else 0.0)
self._wake.clear()
if woken and not self._stop.is_set():
gap = (0.75 if self.tick_driven else 1.0) / MAX_FPS
spacing = gap - (time.monotonic() - woken_at)
if spacing > 0:
self._stop.wait(spacing)
finally:
with self._engine_lock:
release = getattr(self._engine, '_release_point_graphics', None)
if release is not None:
release()
#: Every shading thread started in this process that may still be running.
#: A running thread holds its producer through its target, so a producer
#: leaves this set only once its thread has ended and nothing else keeps it.
_LIVE_PRODUCERS: "weakref.WeakSet[_FrameProducer]" = weakref.WeakSet()
#: Every backdrop and every spaceout fractal built in this process. The run
#: policy reads these instead of walking ``QApplication.allWidgets()``: that
#: walk wraps every widget of a 3840x2160 window in Python on each job
#: change, and it was a fifth of the GUI thread's samples in the seconds
#: after lesson 13's run ended.
_LIVE_AMBIENT_WIDGETS: "weakref.WeakSet[QWidget]" = weakref.WeakSet()
_LIVE_SPACEOUT_FRACTALS: "weakref.WeakSet[QWidget]" = weakref.WeakSet()
def _still_alive(widgets) -> list:
"""The widgets of a registry whose Qt object still exists."""
from shiboken6 import isValid
found = []
for widget in list(widgets):
try:
if isValid(widget):
found.append(widget)
except Exception: # noqa: BLE001
continue
return found
#: Set once the application is quitting: no backdrop starts a shading thread
#: (and with it a graphics context) after that.
_SHADING_RETIRED = [False]
#: How long the quit path waits, in all, for every shading thread to end.
SHADING_EXIT_BUDGET_S = 3.0
def _retire_shading_threads(timeout_s: float = SHADING_EXIT_BUDGET_S,
final: bool = False) -> int:
"""Stop every backdrop shading thread before the application is torn down.
Each thread releases its own GPU renderer (:class:`_FlowPointGraphics`:
the moderngl context over EGL, its framebuffer, textures and programs)
as it ends, on the thread that created and owns that context. Doing this
while ``QApplication`` and the interpreter are still whole is the point:
left to interpreter finalization, a daemon shading thread can still be
inside the driver, or the context is collected on another thread after
the GL libraries started unloading, and the process ends with SIGSEGV
("<no Python frame>") after a clean quit.
Called from ``aboutToQuit``, from :func:`spacr.qt.app.launch` once the
event loop has returned, and, ``final``, from an ``atexit`` hook
registered after PySide6's own, so it runs before Qt's teardown. Each
retired thread is taken out of its backdrop, so a backdrop that is still
shown afterwards (a test that emitted ``aboutToQuit``) shades on demand
or starts a new thread when it is next started; after the final call no
backdrop starts one.
:param timeout_s: total wait, in seconds, shared by every thread.
:param final: the interpreter is exiting: no shading thread starts again.
:returns: how many shading threads are still running afterwards.
"""
if final:
_SHADING_RETIRED[0] = True
producers = list(_LIVE_PRODUCERS)
for producer in producers:
producer._stop.set()
producer._wake.set()
box = producer.box
if box is not None and box and box[0] is producer:
box[0] = None
deadline = time.monotonic() + max(0.0, float(timeout_s))
current = threading.current_thread()
for producer in producers:
thread = producer._thread
if thread is not None and thread is not current and thread.is_alive():
thread.join(max(0.0, deadline - time.monotonic()))
alive = sum(1 for producer in producers if producer.is_alive())
if alive:
LOG.warning("%d backdrop shading thread(s) did not stop before exit",
alive)
return alive
def _retire_shading_at_exit() -> None:
"""``atexit`` hook: the final :func:`_retire_shading_threads`, never raising."""
try:
_retire_shading_threads(final=True)
except Exception:
pass
def _retire_shading_on_about_to_quit() -> None:
"""``aboutToQuit`` slot: :func:`_retire_shading_threads`, never raising."""
try:
_retire_shading_threads()
except Exception:
pass
atexit.register(_retire_shading_at_exit)
_QUIT_HOOKED = [None]
def _retire_shading_on_quit(app) -> None:
"""Connect :func:`_retire_shading_threads` to ``app``'s ``aboutToQuit`` once."""
if app is None or _QUIT_HOOKED[0] is app:
return
try:
app.aboutToQuit.connect(_retire_shading_on_about_to_quit)
_QUIT_HOOKED[0] = app
except (RuntimeError, TypeError, AttributeError):
pass
def _repaint_if_alive(owner) -> None:
"""Repaint the field a weak reference points at, if it still exists.
The shading thread's doorbell can ring after the widget's C++ half has
gone and before its producer is stopped, so the queued call checks both.
:param owner: a weak reference to the :class:`AmbientWidget`.
"""
from shiboken6 import isValid
widget = owner()
if widget is not None and isValid(widget):
widget.repaint()
def _retire_producer(box: List[Optional[_FrameProducer]]) -> None:
"""Stop and join whatever shading thread is in ``box``, and empty it.
A module function over a one-element list rather than a method, because
:attr:`QWidget.destroyed` is connected to it: a slot that captured the
widget would run against a Python wrapper whose C++ half is already gone,
which is the crash :mod:`spacr.qt.bridge` documents in another guise. The
box holds nothing but the thread, so the closure is safe to outlive
everything else.
"""
producer = box[0] if box else None
if box:
box[0] = None
if producer is not None:
producer.stop()
#: The application's events a field listens to: the pointer and its clicks.
_APPLICATION_EVENTS = (QEvent.MouseMove, QEvent.MouseButtonPress,
QEvent.MouseButtonRelease)
#: The event types :meth:`AmbientWidget.eventFilter` acts on, from the
#: application, the window it is in and its parent. Every widget above the
#: field is sent a paint event on every frame, so anything else returns at
#: once.
_FILTERED_EVENTS = frozenset((
QEvent.MouseMove, QEvent.MouseButtonPress, QEvent.MouseButtonRelease,
QEvent.WindowDeactivate, QEvent.Resize, QEvent.WindowStateChange,
QEvent.Hide, QEvent.Show, QEvent.Move, QEvent.ScreenChangeInternal))
class _CardPlan(NamedTuple):
"""The translucent card backgrounds the shading thread composites.
``items`` holds ``(thread picture, GUI picture, x, y, clip)`` per card
background or static part (:func:`_static_part`), in paint order.
Built on the GUI thread by :class:`_CardTints` from the
widgets' current geometry and style, and read-only after that, so the
shading thread can paint from it without touching a widget.
``origin`` is the backdrop's top left in its window. The composite is
painted at that place in an image the window's size up to the
backdrop's far edge (``base`` is that image's page), so every layer
is drawn at the device coordinates Qt draws it at in the window: a
gradient (the Glass theme's card bodies) is sampled from its device
position and differs by one level here and there when drawn elsewhere.
"""
width: int
height: int
base: QImage
items: tuple
origin: QPoint = QPoint()
#: Events that can move, resize, show, hide or restyle a composited card.
_CARD_EVENTS = frozenset((
QEvent.Move, QEvent.Resize, QEvent.Show, QEvent.Hide, QEvent.StyleChange,
QEvent.PaletteChange, QEvent.DynamicPropertyChange, QEvent.EnabledChange,
QEvent.ZOrderChange, QEvent.ParentChange))
#: The subset of :data:`_CARD_EVENTS` after which a background is redrawn.
_CARD_RESTYLES = frozenset((
QEvent.StyleChange, QEvent.PaletteChange, QEvent.DynamicPropertyChange,
QEvent.EnabledChange))
#: Ticks without a card event before the plan is rebuilt.
_CARD_SETTLE_TICKS = 2
#: Smallest visible area, in pixels, worth compositing.
_CARD_MIN_AREA = 8192
#: Edge of the probe a background is classified on.
_CARD_PROBE = 64
#: Widget states a composited background must not depend on.
_CARD_STATES = (QStyle.State_MouseOver, QStyle.State_HasFocus,
QStyle.State_Active)
#: Events after which a field's chrome may be drawn for another focus. They
#: reach the widget that takes the focus, which for an editable combo box
#: is the editor inside it, so any widget inside a planned part counts.
_CARD_FOCUS = frozenset((QEvent.FocusIn, QEvent.FocusOut))
class _ImagePlay:
"""An image played at the origin, the way a ``QPicture`` plays.
Field chrome is a raster the fade draws with ``drawPixmap``; drawn from
the same pixels with ``drawImage`` it takes the same blend. A
``QPicture`` would not do: copied for the second thread, it keeps only
the index of an image it holds, not the image.
"""
__slots__ = ("image",)
def __init__(self, image: QImage):
"""Hold ``image``; each thread gets its own instance and copy."""
self.image = image
def play(self, painter: QPainter) -> None:
"""Draw the image with its top left at the painter's origin."""
painter.drawImage(0, 0, self.image)
def _card_background(card, device, width: int, height: int, flip=None):
"""Paint ``card``'s stylesheet background onto ``device``.
The same primitive :class:`QWidget` draws before its paint event, with
the same option. Recorded into a ``QPicture`` and played back over the
page it gives exactly the pixels the widget paints there itself; an
image of it blended over the page does not, by one or two levels where
the border overlaps the fill.
:param flip: style state bits to invert, to ask whether the background
depends on them.
:returns: ``device``.
"""
painter = QPainter(device)
try:
option = QStyleOption()
option.initFrom(card)
option.rect = QRect(0, 0, width, height)
if flip is not None:
option.state ^= flip
card.style().drawPrimitive(QStyle.PE_Widget, option, painter, card)
finally:
painter.end()
return device
def _card_probe(card, width: int, height: int, flip=None) -> QImage:
""":func:`_card_background` on a transparent image, to classify it."""
image = QImage(width, height, QImage.Format_ARGB32_Premultiplied)
image.fill(0)
return _card_background(card, image, width, height, flip)
def _own_picture(picture: QPicture) -> QPicture:
"""A ``QPicture`` with its own data: two threads never play one."""
own = QPicture()
own.setData(picture.data())
return own
def _draw_cards(painter: QPainter, plan: _CardPlan, which: int) -> None:
"""Play every background in ``plan`` at its place, clipped as Qt would.
:param which: 0 for the shading thread's pictures, 1 for the GUI's.
"""
origin = painter.transform()
for item in plan.items:
x, y, clip = item[2:]
painter.setClipRect(clip)
painter.translate(x, y)
item[which].play(painter)
painter.setTransform(origin)
painter.setClipping(False)
def _compose_cards(engine, frame: QImage, plan: _CardPlan):
"""The page, ``frame`` and the cards of ``plan``, as one opaque image.
**Any thread**: the plan holds images, not widgets. Called with the
engine lock held, because :meth:`_BufferedEngine.blit` reads the
engine's composition mode.
:returns: ``(plan, image, frame)``, which is what a paint checks
against the plan and frame it is showing; ``image`` is laid out as
``plan.base``, with the backdrop at ``plan.origin``.
"""
image = plan.base.copy()
painter = QPainter(image)
try:
painter.translate(plan.origin.x(), plan.origin.y())
engine.blit(painter, frame, plan.width, plan.height)
_draw_cards(painter, plan, 0)
finally:
painter.end()
return plan, image, frame
def _static_part(widget):
"""The part of ``widget``'s paint the field may composite, if any.
Two kinds, both drawn before the widget's own paint and changed only by
events the plan watches: the chrome :mod:`spacr.qt.widgets.field_fade`
paints under a form field, and the track and knob of a widget that
offers them as a picture through ``_spacr_static_picture`` (the settings
switch, :class:`spacr.qt.widgets.toggle.Toggle`).
None under the spaceout dressing: both are painted from the palette of
the moment, which the spaceout drift re-hues as the clock runs, with no
event a plan could watch.
:returns: ``(thread player, GUI player)``, or ``None``.
"""
from .field_fade import _chrome_image
if spaceout_enabled():
return None
image = _chrome_image(widget)
if image is not None:
return _ImagePlay(image), _ImagePlay(image.copy())
offer = getattr(widget, "_spacr_static_picture", None)
picture = offer() if callable(offer) else None
if picture is None:
return None
return _own_picture(picture), _own_picture(picture)
def _lend_part(widget, give_back) -> None:
"""Take ``widget``'s static part off its own paint; see
:func:`_static_part`."""
from .field_fade import FIELD_TYPES, _lend_chrome
if isinstance(widget, FIELD_TYPES):
_lend_chrome(widget, give_back)
else:
widget._spacr_lend_static(give_back)
def _take_back_part(widget) -> None:
"""Let ``widget`` paint its static part again; a destroyed one is
skipped."""
from shiboken6 import isValid
from .field_fade import FIELD_TYPES, _take_back_chrome
if isinstance(widget, FIELD_TYPES):
_take_back_chrome(widget)
elif isValid(widget):
widget._spacr_take_back_static()
def _restore_card_backgrounds(cards: list, parts: list) -> None:
"""Let every card in ``cards`` paint its own background again, and every
widget in ``parts`` its static part."""
from shiboken6 import isValid
for card in cards:
if isValid(card):
card.setAttribute(Qt.WA_NoSystemBackground, False)
del cards[:]
for widget in parts:
_take_back_part(widget)
del parts[:]
#: The internal preference that turns card compositing on; see
#: :func:`_composite_cards_on`.
_COMPOSITE_CARDS_KEY = "prefs/composite_cards"
#: The environment variable that overrides :data:`_COMPOSITE_CARDS_KEY`.
_COMPOSITE_CARDS_ENV = "SPACR_COMPOSITE_CARDS"
def _composite_cards_on() -> bool:
"""Whether a new field composites its cards on the shading thread.
Off by default. On the workstation's GNOME capture host (2560x1440, a
real display, 2026-10-12) the composite cost 8-10 painted frames a
second: Home 48-50 fps with it against 57-58 without, Mask 41-43
against 51-52. The likely cause is the GIL: playing the plan's pictures
and drawing the frame on the shading thread do not release it, so the
GUI thread waits for them, which an offscreen CPU-per-frame measurement
does not show. Off, the field builds no plan and hears no card events,
and the cards, field chrome and switches paint themselves.
``SPACR_COMPOSITE_CARDS=1`` turns it on and any other value off, for
experiments and captures; without it the internal preference
``prefs/composite_cards`` decides. Read once, when a field is made.
"""
flag = os.environ.get(_COMPOSITE_CARDS_ENV, "").strip()
if flag:
return flag == "1"
try:
from ..preferences import _as_bool, _settings
return _as_bool(_settings().value(_COMPOSITE_CARDS_KEY, False), False)
except Exception:
return False
class _CardTints(QObject):
"""Moves the field's translucent card backgrounds off the GUI thread.
Made only while :func:`_composite_cards_on`; otherwise the field keeps
the classic paint and none of this runs.
Qt paints every widget above the field on every field frame, and the
rounded translucent card backgrounds are a per-pixel alpha blend over
the whole card: about 3.2 megapixels a frame on Mask Generation and 1.0
on Home, 5-6 ms of a loaded frame. Caching their shape does not help;
the blend is the cost.
So the backgrounds are recorded once into pictures, with their geometry,
clip and paint order, into a :class:`_CardPlan` that the shading
thread composites into each frame together with the page under the
field. Once a frame composited with the current plan is published the
cards stop painting their own backgrounds (``WA_NoSystemBackground``)
and the GUI thread draws one opaque image under the card contents.
Correctness is by invalidation. Any move, resize, show, hide or restyle
of a card, of a widget between it and the backdrop, or of the backdrop
puts the cards back to painting themselves *before* the next paint, and
the plan is rebuilt only after :data:`_CARD_SETTLE_TICKS` quiet ticks,
so a scroll, a fold or a theme change is painted classically until it
settles. Composited and classic frames are pixel-identical.
Only widgets whose background ignores hover, focus and window activation
are composited, and none under a widget that fills its background
opaquely. A backdrop with rounded corners or a device pixel ratio other
than one keeps the classic paint.
The ramped chrome :mod:`spacr.qt.widgets.field_fade` draws under each
form field, and the track and knob of each settings switch, are
composited the same way, at the widget's place in the paint order
(:func:`_static_part`), and the widget stops drawing them while they
are. The chrome is composited for fields without the focus, and the
fade's filter is taken off the field meanwhile
(:func:`spacr.qt.widgets.field_fade._lend_chrome`). A focus change, a
switch being flipped or dragged, or the fade being switched off, puts
them back before the next paint. Small fields and switches are
composited too, as long as nothing of their own is painted under them.
"""
#: Off keeps the classic paint, for comparing the two.
enabled = True
def __init__(self, backdrop: QWidget):
"""Watch the application for card events on behalf of ``backdrop``."""
super().__init__(backdrop)
self.plan: Optional[_CardPlan] = None
self.active = False
self._producer = None
self._quiet = 0
self._cards: list = []
self._parts: list = []
self._watched = frozenset()
self._images: dict = {}
shown = self._shown = []
lent = self._lent = []
backdrop.destroyed.connect(
lambda *_: _restore_card_backgrounds(shown, lent))
from ..gil_priority import _watch_application_events
_watch_application_events(QApplication.instance(), self,
_CARD_EVENTS | _CARD_FOCUS)
def eventFilter(self, watched, event) -> bool:
"""Invalidate on any event that can change what a card looks like."""
kind = event.type()
if kind in _CARD_FOCUS:
if self._holds_a_part(watched):
self.invalidate()
return False
if watched in self._watched or (
kind == QEvent.Show and self._may_hold_a_card(watched)):
if kind in _CARD_RESTYLES:
self._images.clear()
self.invalidate()
return False
def _holds_a_part(self, widget) -> bool:
"""Whether ``widget`` is, or is inside, a widget whose static part
the plan holds."""
return widget.isWidgetType() and any(
part is widget or part.isAncestorOf(widget)
for part in self._parts)
def _may_hold_a_card(self, widget) -> bool:
"""Whether a widget being shown could be or hold a new card."""
host = self.parent().parentWidget()
return bool(host is not None and widget.isWidgetType()
and (widget.testAttribute(Qt.WA_StyledBackground)
or self._paints_over(widget))
and host.isAncestorOf(widget))
def invalidate(self) -> None:
"""Back to classic painting now; rebuild once things settle."""
self._quiet = 0
self.plan = None
if self._producer is not None:
self._producer.cards = None
if self.active:
self.active = False
_restore_card_backgrounds(self._shown, self._lent)
self.parent().update()
def tick(self, producer) -> None:
"""Advance on a field tick: settle, rebuild, or switch over.
:param producer: the backdrop's shading thread, or ``None``.
"""
backdrop = self.parent()
usable = bool(producer is not None and self.enabled
and backdrop._corner_radius == 0
and backdrop.devicePixelRatioF() == 1.0
and backdrop.parentWidget() is not None)
if producer is not self._producer or not usable:
self.invalidate()
self._producer = producer
if not usable:
return
if self.plan is not None and self.plan.origin != backdrop.mapTo(
backdrop.window(), QPoint(0, 0)):
self.invalidate()
if self.plan is None:
self._quiet += 1
if self._quiet > _CARD_SETTLE_TICKS:
self._rebuild(backdrop, producer)
return
composed = producer.composed
if (not self.active and composed is not None
and composed[0] is self.plan):
for card in self._cards:
card.setAttribute(Qt.WA_NoSystemBackground, True)
self._shown[:] = self._cards
for widget in self._parts:
_lend_part(widget, self.invalidate)
self._lent[:] = self._parts
self.active = True
backdrop.update()
def _rebuild(self, backdrop: QWidget, producer) -> None:
"""Plan the cards from the widgets as they are now."""
host = backdrop.parentWidget()
width, height = backdrop.width(), backdrop.height()
items, cards, parts, images = [], [], [], {}
watched = {backdrop, host}
self._walk(host, backdrop, -backdrop.pos(), QRect(0, 0, width, height),
(), (items, cards, parts, watched, images, [QRegion()]))
origin = backdrop.mapTo(backdrop.window(), QPoint(0, 0))
base = QImage(origin.x() + width, origin.y() + height,
QImage.Format_ARGB32_Premultiplied)
base.fill(0)
painter = QPainter(base)
try:
painter.translate(origin.x(), origin.y())
backdrop._paint_base(painter, QRect(0, 0, width, height))
finally:
painter.end()
self.plan = _CardPlan(width, height, base, tuple(items), origin)
self._cards = cards
self._parts = parts
self._watched = frozenset(watched)
self._images = images
producer.cards = self.plan if items else None
def _walk(self, parent, backdrop, offset, clip, lineage, out) -> None:
"""Collect the cards under ``parent`` in the order Qt paints them.
``out[5]`` holds the region, in backdrop coordinates, that something
painted on the GUI thread already covers at this point of the paint
order: a widget that paints its whole area (:meth:`_paints_over`,
such as the DNA rain), or a background left classic. Nothing that
overlaps it is composited, since the field is under it and the
composite would land below what Qt paints first.
"""
items, cards, parts, watched, images, covered = out
for child in parent.children():
if (not child.isWidgetType() or child is backdrop
or child.isWindow() or not child.isVisible()):
continue
rect = child.geometry().translated(offset)
visible = clip.intersected(rect)
below = lineage + (child,)
if self._paints_over(child):
covered[0] = covered[0].united(visible)
watched.update(below)
continue
if visible.width() * visible.height() < _CARD_MIN_AREA:
if not visible.isEmpty():
self._add_part(child, rect, visible, below, out)
continue
image = self._background_of(child, rect.width(), rect.height(),
images)
if image is not None and (image is False
or covered[0].intersects(visible)):
covered[0] = covered[0].united(visible)
watched.update(below)
if image is False:
continue
elif image is not None:
items.append((_own_picture(image), _own_picture(image),
rect.x(), rect.y(), visible))
cards.append(child)
watched.update(below)
self._add_part(child, rect, visible, below, out, image)
self._walk(child, backdrop, rect.topLeft(), visible, below, out)
@staticmethod
def _paints_over(widget) -> bool:
"""Whether ``widget`` paints its whole area itself, hiding the field
and everything planned before it there.
A widget that fills its background or claims opaque painting does;
so does one that says so with ``_spacr_covers_its_area``, which the
DNA rain does because it clears every rect it paints whether or not
Qt sees it as opaque. A masked widget is counted too, being
unknown.
"""
return bool(widget.autoFillBackground()
or widget.testAttribute(Qt.WA_OpaquePaintEvent)
or not widget.mask().isEmpty()
or getattr(widget, "_spacr_covers_its_area", False))
def _add_part(self, widget, rect, visible, lineage, out,
background=None) -> None:
"""Plan ``widget``'s static part (:func:`_static_part`), if it has
one, nothing of its own is painted under it and nothing painted
before it covers it.
:param background: its background as :meth:`_background_of` answered,
or ``None`` to ask here.
"""
items, _cards, parts, watched, images, covered = out
part = _static_part(widget)
if part is None:
return
if covered[0].intersects(visible) or (
background is None and self._background_of(
widget, rect.width(), rect.height(), images) is not None):
covered[0] = covered[0].united(visible)
watched.update(lineage)
return
items.append(part + (rect.x(), rect.y(), visible))
parts.append(widget)
watched.update(lineage)
def _background_of(self, widget, width: int, height: int, images):
"""The background to composite for ``widget``, which does not
:meth:`_paints_over`.
:returns: its ``QPicture``; ``None`` when it paints no background;
``False`` when it paints one that cannot be composited, which
also keeps everything inside it classic.
"""
if (not widget.testAttribute(Qt.WA_StyledBackground)
or widget.testAttribute(Qt.WA_NoSystemBackground)):
return None
key = (width, height)
known = self._images.get(widget)
if known is None or known[0] != key:
known = (key, self._classify(widget, width, height))
images[widget] = known
return known[1]
@staticmethod
def _classify(widget, width: int, height: int):
"""Render and classify ``widget``'s background; see
:meth:`_background_of` for the answers."""
size = (min(width, _CARD_PROBE), min(height, _CARD_PROBE))
probe = _card_probe(widget, *size)
blank = QImage(probe.size(), probe.format())
blank.fill(0)
if probe == blank:
return None
if any(_card_probe(widget, *size, flip) != probe
for flip in _CARD_STATES):
return False
return _card_background(widget, QPicture(), width, height)
#: Field ticks with no widget shown in the window before
#: :class:`_QuietBackgrounds` looks for backgrounds that paint nothing.
_QUIET_SETTLE_TICKS = 30
#: Field ticks between two checks that every quieted widget is still quiet.
_QUIET_RECHECK_TICKS = 60
#: The events :class:`_QuietBackgrounds` hears. Neither is part of the
#: storm a style sheet sets off (palette, font, property, style changes),
#: which no watcher of the event hub may hear.
_QUIET_EVENTS = frozenset((QEvent.Show, QEvent.ParentChange))
#: Style states a quieted background paints nothing in, either way.
_QUIET_STATES = _CARD_STATES + (QStyle.State_Enabled,)
def _restyled_by_its_sheet(widget) -> bool:
"""Whether the style sheet sets ``WA_StyledBackground`` on ``widget``
again whenever it polishes it with a background to draw.
``QStyleSheetStyle::polish`` does that for a plain ``QWidget`` and for
the frames, tab bars and menu bars among the classes it lists, not for
a subclass of ``QWidget`` that set the attribute itself.
"""
return (widget.metaObject().className() == "QWidget"
or isinstance(widget, (QFrame, QTabBar, QMenuBar)))
def _paints_no_background(widget) -> bool:
"""Whether ``widget``'s style sheet background paints no pixel, in its
state now and with each of :data:`_QUIET_STATES` flipped."""
size = (min(widget.width(), _CARD_PROBE), min(widget.height(), _CARD_PROBE))
if size[0] <= 0 or size[1] <= 0:
return False
blank = QImage(size[0], size[1], QImage.Format_ARGB32_Premultiplied)
blank.fill(0)
return all(_card_probe(widget, *size, flip) == blank
for flip in (None,) + _QUIET_STATES)
def _hand_backgrounds_back(quieted: dict) -> None:
"""Let every widget in ``quieted`` paint its background again."""
from shiboken6 import isValid
for widget in quieted.values():
if isValid(widget):
widget.setAttribute(Qt.WA_StyledBackground, True)
quieted.clear()
class _QuietBackgrounds(QObject):
"""Stops the widgets over the field painting backgrounds that paint
nothing.
Qt paints every widget above the field on every field frame, and before
a widget with a style-sheet background paints it opens a painter and
asks the style for the background, even where the sheet says
``background: transparent``: the page's layout containers, scroll areas
and labels. Measured at 2560x1440 on 2026-10-12: 173 of the 184 styled
backgrounds on Home and 100 of 117 on Make Masks painted no pixel, and
not asking for them saved 0.48 ms of a 4.3 ms Home frame and of a 6.8 ms
Mask frame (cheapest GUI-thread CPU of a synchronous field frame, median
of six interleaved rounds).
So once the window has settled (:data:`_QUIET_SETTLE_TICKS` field ticks
with nothing shown in it), every visible widget whose background paints
nothing, in its state now and with hover, focus, window activation and
the enabled state flipped (:func:`_paints_no_background`), has
``WA_StyledBackground`` cleared, and Qt does not ask. Pixel-identical by
construction: what is left out is a background that draws nothing.
Only widgets whose attribute the style sheet sets again on every polish
that finds a background to draw (:func:`_restyled_by_its_sheet`), so a
restyle that gives one a background hands it back by itself, before the
paint that shows it; a restyle reaches a widget only through a polish.
A parent change hands it back too. Every :data:`_QUIET_RECHECK_TICKS`
ticks the quieted widgets are checked, and when a restyle has handed one
back or one is gone the window is looked at again once it settles.
"""
def __init__(self, backdrop: QWidget):
"""Watch the application for the window's shows on behalf of
``backdrop``."""
super().__init__(backdrop)
self._quiet = 0
self._settled = False
quieted = self._quieted = {}
backdrop.destroyed.connect(lambda *_: _hand_backgrounds_back(quieted))
from ..gil_priority import _watch_application_events
_watch_application_events(QApplication.instance(), self,
_QUIET_EVENTS)
def eventFilter(self, watched, event) -> bool:
"""Look again once a widget is shown in the window; hand a moved
widget's background back.
:param watched: the widget the event is for.
:param event: a show or a parent change.
:returns: ``False``; nothing is consumed.
"""
if event.type() == QEvent.Show:
if (watched.isWidgetType()
and watched.window() is self.parent().window()):
self._unsettle()
return False
widget = self._quieted.pop(id(watched), None)
if widget is not None:
widget.setAttribute(Qt.WA_StyledBackground, True)
self._unsettle()
return False
def _unsettle(self) -> None:
"""Start counting quiet ticks again."""
self._quiet = 0
self._settled = False
def tick(self) -> None:
"""Count a field tick: quiet the window once it has settled, and
check the quieted widgets every :data:`_QUIET_RECHECK_TICKS`."""
self._quiet += 1
if not self._settled:
if self._quiet > _QUIET_SETTLE_TICKS:
self._settled = True
self._quiet = 0
self._quiet_the_window()
return
if self._quiet >= _QUIET_RECHECK_TICKS:
self._quiet = 0
if not self._all_still_quiet():
self._unsettle()
def _all_still_quiet(self) -> bool:
"""Whether every quieted widget still exists and is still quiet."""
from shiboken6 import isValid
return all(isValid(widget)
and not widget.testAttribute(Qt.WA_StyledBackground)
for widget in self._quieted.values())
def _quiet_the_window(self) -> None:
"""Clear ``WA_StyledBackground`` on every widget over the field
whose background paints nothing."""
from shiboken6 import isValid
backdrop = self.parent()
window = backdrop.window()
for key, widget in list(self._quieted.items()):
if (not isValid(widget)
or widget.testAttribute(Qt.WA_StyledBackground)):
del self._quieted[key]
if window is backdrop:
return
for widget in window.findChildren(QWidget):
if (widget is not backdrop
and widget.testAttribute(Qt.WA_StyledBackground)
and widget.isVisible() and widget.window() is window
and _restyled_by_its_sheet(widget)
and _paints_no_background(widget)):
widget.setAttribute(Qt.WA_StyledBackground, False)
self._quieted[id(widget)] = widget
def _retire_fractals_on(host) -> int:
"""Shut down and unparent any fractal backdrop already on ``host``.
:returns: how many were retired, so a test can assert a number instead of
counting children by eye.
Called before a new one is installed. Never raises: failing to clean up
an old backdrop must not stop the new screen from getting one.
"""
retired = 0
try:
for child in list(host.findChildren(QWidget)):
if not hasattr(child, "backend_name"):
continue
try:
child.shutdown()
except Exception: # noqa: BLE001
LOG.debug("could not stop an old fractal", exc_info=True)
try:
child.setParent(None)
child.deleteLater()
except Exception: # noqa: BLE001
pass
retired += 1
except Exception: # noqa: BLE001
LOG.debug("could not look for old fractals", exc_info=True)
return retired
def _the_heavy_import_lock_is_free() -> bool:
"""Whether the heavy-import lock could be taken right now.
Asked without blocking and released immediately: a peek, not a
reservation. The spaceout backdrop's own constructor still takes the
lock properly, so the guarantee that a GL context is never built
while the preloader is bringing CUDA up is unchanged -- all this
decides is whether to try at all on this event-loop turn.
A tree with no lock to ask (the widget module is absent, or its
import failed) answers yes, so a machine without the backdrop
behaves exactly as it did before.
This is the cheap half of the pair. It cannot close the race on its
own -- the preloader re-takes the lock between two imports -- which
is what :func:`_the_backdrop_wants_a_retry` is for. Peeking first is
still worth it because it keeps a retry timer from paying the
bounded wait on every tick while a long import runs.
"""
if "spacr.qt.widgets.fractal_travel" not in sys.modules:
return True
try:
from .fractal_travel import _heavy_import_lock
lock = _heavy_import_lock()
except Exception: # noqa: BLE001
return True
if lock is None:
return True
if not lock.acquire(blocking=False):
return False
lock.release()
return True
def _the_backdrop_wants_a_retry(error: BaseException) -> bool:
"""Whether ``error`` from :func:`install_ambient` means "not yet".
The spaceout backdrop refuses to build while a heavy import holds
the lock its GL context needs, because waiting for it on the GUI
thread is a multi-second freeze. That refusal arrives as an
exception like any other, and a caller whose handler cannot tell it
apart would treat a two-second import as a permanently broken
backdrop -- which is a spaceout launch that quietly loses its
artwork for the rest of the session.
:param error: whatever :func:`install_ambient` raised.
:returns: ``True`` when the install should simply be attempted
again shortly, ``False`` for a real failure.
"""
try:
from .fractal_travel import _HeavyImportInProgress
except Exception: # noqa: BLE001
return False
return isinstance(error, _HeavyImportInProgress)
def _the_spaceout_fractal(host, theme=SPACEOUT_THEME):
"""The spaceout backdrop, or None when this is an ordinary launch.
Returns a widget already parented to ``host`` and lowered behind it, so
the caller can hand it straight back as though `install_ambient` had
built it. None means "not spaceout, or it could not be built" -- and the
caller then installs the selected ambient engine instead. If fractal
construction fails, that engine remains the fallback.
"""
try:
from ..theme import spaceout_enabled
if not spaceout_enabled() or theme != SPACEOUT_THEME:
return None
except Exception: # noqa: BLE001
return None
_retire_fractals_on(host)
try:
from ..preferences import get_fractal_settings
from .fractal_travel import _HeavyImportInProgress
widget = _build_the_spaceout_fractal(get_fractal_settings())
_place_the_spaceout_fractal(widget, host)
return widget
except _HeavyImportInProgress:
raise
except Exception: # noqa: BLE001
LOG.exception("Could not install the spaceout fractal")
return None
_BUILT_FROM_SETTINGS = ("pattern", "backend", "quality", "scale",
"supersampling", "max_iterations", "precision_digits")
"""The fractal settings a spaceout backdrop is BUILT from.
The pattern picks the shader and the backend picks GPU or CPU; quality and
scale go into the frozen `Settings` the renderer sizes itself from;
supersampling is compiled into the shader's sample grid and sizes the CPU
engine's (item 531); the Mandelbrot reference orbit is iterated once, at construction, to
`max_iterations` at `precision_digits`. Every other setting is either on the
`RuntimeControls` the canvas reads each frame or read from the store each
frame, so it needs no rebuild. See item 530.
"""
def _built_from(values: dict) -> tuple:
"""The construction-time part of ``values``, comparable with ``==``."""
return tuple(values.get(name) for name in _BUILT_FROM_SETTINGS)
def _build_the_spaceout_fractal(values: dict, controls=None):
"""Construct the spaceout backdrop from ``values``, unparented.
:param values: :func:`~spacr.qt.preferences.get_fractal_settings`.
:param controls: the `RuntimeControls` to hand it, or ``None`` for new
ones made from ``values``. A rebuild passes the old backdrop's, so
a zoom rate nudged with the arrow keys survives a pattern change.
:returns: the widget, remembering what it was built from.
"""
from .fractal_travel import (RuntimeControls, Settings,
create_fractal_widget)
if controls is None:
controls = RuntimeControls(
speed=values["speed"], dream=values["dream"],
variable_speed=values["variable_speed"],
speed_min=values["speed_min"],
speed_max=values["speed_max"],
speed_period=values["speed_period"],
follow_pointer=bool(values["pointer_gravity"]),
pointer_size=values["pointer_size"],
pointer_strength=values["pointer_strength"],
magnifier_size=values["magnifier_size"],
zoom_rate=values["zoom_rate"])
widget = create_fractal_widget(
Settings(pattern=values["pattern"], backend=values["backend"],
quality=values["quality"], scale=values["scale"],
supersampling=values.get("supersampling", 2)),
controls,
)
widget._spaceout_controls = controls
widget._spaceout_built_from = _built_from(values)
_LIVE_SPACEOUT_FRACTALS.add(widget)
return widget
def _place_the_spaceout_fractal(widget, host) -> None:
"""Parent ``widget`` to ``host``, fill it, lower it and keep it filling."""
_LIVE_SPACEOUT_FRACTALS.add(widget)
widget.setParent(host)
widget.setGeometry(host.rect())
widget.lower()
widget.show()
host.installEventFilter(_FractalTracksItsHost(widget, host))
def _live_ambient_widgets() -> list:
"""Every backdrop widget whose Qt object still exists."""
return _still_alive(_LIVE_AMBIENT_WIDGETS)
def _live_spaceout_fractals() -> list:
"""Every spaceout backdrop currently parented to something."""
from PySide6.QtWidgets import QApplication
if QApplication.instance() is None:
return []
found = []
for widget in _still_alive(_LIVE_SPACEOUT_FRACTALS):
try:
if (getattr(widget, "_spaceout_built_from", None) is not None
and widget.parentWidget() is not None):
found.append(widget)
except Exception: # noqa: BLE001
continue
return found
def _point_holders_at(old, new, host) -> int:
"""Swap every attribute that held ``old`` on ``host`` or its window.
The screens keep their backdrop as ``_ambient`` and the main window as
``_dock_backdrop``; pausing it for a run, retiring it and the screens'
"is there one already" guard all go through those names, so a rebuild
that left them on the retired widget would be a backdrop nothing can
pause and a second one built on top of it.
:returns: how many attributes were moved.
"""
moved = 0
holders = [host]
try:
window = host.window()
if window is not None and window is not host:
holders.append(window)
except Exception: # noqa: BLE001
pass
for holder in holders:
try:
names = [name for name, value in vars(holder).items()
if value is old]
except TypeError:
continue
for name in names:
setattr(holder, name, new)
moved += 1
return moved
def _retire_one_fractal(old, host) -> None:
"""Stop ``old``, drop the filter that sized it, and let Qt free it."""
try:
old.shutdown()
except Exception: # noqa: BLE001
LOG.debug("could not stop an old fractal", exc_info=True)
try:
for follower in host.findChildren(_FractalTracksItsHost):
if follower._widget is old:
host.removeEventFilter(follower)
follower._widget = None
follower.deleteLater()
except Exception: # noqa: BLE001
LOG.debug("could not drop an old fractal's filter", exc_info=True)
try:
old.setParent(None)
old.deleteLater()
except Exception: # noqa: BLE001
pass
[docs]
def rebuild_the_spaceout_backdrops() -> int:
"""Rebuild every running spaceout backdrop whose build settings changed.
:returns: how many were rebuilt.
WHY A SAVED PATTERN USED TO WAIT FOR A RESTART. The window
builds ONE backdrop behind the dock and the page and keeps it for the
session. Saving Preferences pushed the runtime numbers into it
(`apply_saved_controls`) but the pattern, backend, quality and scale are
fixed when it is constructed, and nothing constructed it again -- the
old note that "changing a screen" would do it stopped being true when
the backdrop moved from the screens to the window.
The replacement is built BEFORE the old one is retired, so a GPU that
cannot be had right now leaves the running backdrop on screen rather
than none. It inherits the old one's `RuntimeControls`, its paused
state and its visibility, and every reference to the old one on its
host or window is moved to it. A heavy import holding the GL lock is
waited out on a timer, as at startup.
"""
try:
from ..theme import spaceout_enabled
if not spaceout_enabled():
return 0
from ..preferences import get_fractal_settings
from .fractal_travel import _HeavyImportInProgress
values = get_fractal_settings()
except Exception: # noqa: BLE001
LOG.debug("could not read the fractal settings", exc_info=True)
return 0
wanted = _built_from(values)
rebuilt = 0
for old in _live_spaceout_fractals():
if getattr(old, "_spaceout_built_from", None) == wanted:
continue
host = old.parentWidget()
try:
paused = bool(old.is_paused())
except Exception: # noqa: BLE001
paused = False
hidden = old.isHidden()
try:
new = _build_the_spaceout_fractal(
values, getattr(old, "_spaceout_controls", None))
except _HeavyImportInProgress:
QTimer.singleShot(400, rebuild_the_spaceout_backdrops)
continue
except Exception: # noqa: BLE001
LOG.exception("Could not rebuild the spaceout fractal")
continue
_retire_one_fractal(old, host)
_place_the_spaceout_fractal(new, host)
if hidden:
new.hide()
if paused:
try:
new.pause()
except Exception: # noqa: BLE001
pass
_point_holders_at(old, new, host)
rebuilt += 1
return rebuilt
class _FractalTracksItsHost(QObject):
"""Keeps the spaceout backdrop the size of what it sits behind.
:param widget: the backdrop to resize.
:param host: the widget whose resizes drive it, and THE QOBJECT PARENT
-- so this dies with the host it follows rather than with the
backdrop it moves.
"""
def __init__(self, widget, host) -> None:
"""Take the host as parent and remember the widget to resize."""
super().__init__(host)
self._widget = widget
def eventFilter(self, watched, event) -> bool:
"""Resize the backdrop to match the widget it sits behind.
:param watched: the host being followed.
:param event: the event.
:returns: ``False`` -- the resize is observed, never consumed.
"""
try:
if event.type() == QEvent.Type.Resize:
self._widget.setGeometry(watched.rect())
except Exception: # noqa: BLE001
pass
return False
#: Container, window-edge and popup ripples published so far. A click
#: ripple is committed only if this did not change during its interaction.
_CONTAINER_RIPPLES = [0]
_CLICK_CONTROLS = (QAbstractButton, QAbstractItemView, QAbstractSlider,
QAbstractSpinBox, QComboBox, QGraphicsView, QLineEdit, QMenu,
QMenuBar, QPlainTextEdit, QSplitterHandle, QTabBar, QTextEdit)
_CLICK_HANDLERS = ("mousePressEvent", "mouseReleaseEvent",
"mouseDoubleClickEvent")
#: Milliseconds after a release before a click ripple is committed. Long
#: enough for the click's own handlers and their zero-delay container
#: ripples and for the next animation tick to notice a closed popup.
CLICK_RIPPLE_SETTLE_MS = 120
def _note_container_ripple() -> None:
"""Record that some container produced its own ripple just now."""
_CONTAINER_RIPPLES[0] += 1
#: Pipeline keys whose analysis runs on the GPU when one is present.
_GPU_JOB_KEYS = frozenset((
"mask", "timelapse", "cellpose_masks", "train_cellpose", "classify",
"classify_merged", "activation", "make_masks", "sam", "train",
"embeddings", "deep_spacr", "spatial_masks"))
#: Pipeline keys whose analysis is CPU bound.
_CPU_JOB_KEYS = frozenset((
"measure", "motility", "illumination", "umap", "map_barcodes",
"ml_analyze", "regression", "recruitment", "foreign", "align", "ops",
"convert", "invasion", "replication", "analyze_plaques", "barcode_qc",
"explain_cv", "anndata_export", "external_masks", "spatial_map"))
_RESOURCE_POLICY = None
def _analysis_gpu_present() -> bool:
"""Whether analysis jobs here can run on a GPU at all.
PyTorch is consulted only when something already imported it; NVML is
used otherwise, so asking never pulls a deep-learning stack into the
GUI. Without either, GPU-capable jobs run on the CPU and count as
CPU-heavy work.
"""
torch = sys.modules.get("torch")
if torch is not None:
try:
if torch.cuda.is_available():
return True
mps = getattr(getattr(torch, "backends", None), "mps", None)
return bool(mps is not None and mps.is_available())
except Exception:
return False
try:
import pynvml
pynvml.nvmlInit()
try:
return pynvml.nvmlDeviceGetCount() > 0
finally:
pynvml.nvmlShutdown()
except Exception:
return False
def _job_resource(app_key, gpu_present: bool) -> Optional[str]:
"""Classify one running job as ``"cpu"``, ``"gpu"`` or ``None``."""
key = str(app_key or "").split(":", 1)[0].strip().lower()
if key in _GPU_JOB_KEYS:
return "gpu" if gpu_present else "cpu"
if key in _CPU_JOB_KEYS:
return "cpu"
return None
class _AnimationResourcePolicy(QObject):
"""Dynamic animation: yield decorative work to running analysis.
Signals covered: every job started through
:func:`spacr.qt.bridge.make_thread` and every external job tracked with
``_track_external_job`` is registered in the process-wide run registry
and removed when it finishes, fails or is cancelled. The policy
recomputes from the full set on each ``changed`` signal, so one job
ending never releases a guard another job still holds. Housekeeping
jobs (``user_visible`` false) and unknown keys are ignored.
A CPU-heavy job pauses the spaceout fractals and paces every backdrop
to :data:`_RUN_FPS` (it keeps moving; a frozen backdrop reads as a hung
application); a GPU job switches decorative rendering to the CPU and
pauses GPU-backed spaceout fractals. CPU work
takes precedence when both run. Nothing is written to Preferences:
the user's saved theme and backend come back when the jobs end.
External or headless processes are seen only on NVIDIA hardware,
through NVML's compute-process list (see :func:`_flow_compute_idle`);
CPU load from other processes is not observed.
"""
changed = Signal()
def __init__(self, parent=None):
"""Read the saved switch and start with nothing running."""
super().__init__(parent)
try:
from ..preferences import _dynamic_animation_enabled
self.enabled = bool(_dynamic_animation_enabled())
except Exception:
self.enabled = True
self.cpu_busy = False
self.gpu_busy = False
self._attached = False
self._paused_fractals = []
def attach(self) -> None:
"""Follow the run registry; idempotent."""
if self._attached:
return
try:
from ..bridge import registry
registry().changed.connect(self._recompute)
self._attached = True
except Exception:
LOG.debug("could not follow the run registry", exc_info=True)
self._recompute()
def set_enabled(self, enabled: bool) -> None:
"""Turn the workload policy on or off and apply it at once."""
enabled = bool(enabled)
if enabled != self.enabled:
self.enabled = enabled
self._apply()
def pauses_animation(self) -> bool:
"""Whether CPU-heavy work currently pauses every animation."""
return self.enabled and self.cpu_busy
def blocks_gpu(self) -> bool:
"""Whether decorative GPU rendering must step aside right now."""
return self.enabled and (self.cpu_busy or self.gpu_busy)
@staticmethod
def _visible_handles() -> list:
"""The user-visible jobs running now."""
try:
from ..bridge import registry
handles = registry().active()
except Exception:
return []
return [handle for handle in handles
if getattr(handle, "user_visible", True)]
def _kinds(self, visible=None):
"""Resource kinds of the user-visible jobs running now.
:param visible: those jobs, when the caller has them already.
"""
if visible is None:
visible = self._visible_handles()
if not visible:
return set()
gpu_present = _analysis_gpu_present()
return {kind for kind in (_job_resource(getattr(handle, "app_key", ""),
gpu_present)
for handle in visible) if kind}
def _recompute(self, *_args) -> None:
"""Recount running work and apply any change.
A change that leaves the user-visible jobs as they were -- a
housekeeping job starting or ending, such as the figure gallery
refining the figure on show -- changes nothing here and is not
acted on. Walking every widget for fractals to hold on each one
took 115 ms of the GUI thread when a figure arrived during lesson
08's Measure run.
"""
visible = self._visible_handles()
seen = frozenset(id(handle) for handle in visible)
unchanged = seen == self.__dict__.get("_seen_jobs")
self._seen_jobs = seen
kinds = self._kinds(visible)
cpu, gpu = "cpu" in kinds, "gpu" in kinds
if (cpu, gpu) != (self.cpu_busy, self.gpu_busy):
self.cpu_busy, self.gpu_busy = cpu, gpu
self._apply()
elif self.blocks_gpu() and not unchanged:
self._hold_fractals()
def _apply(self) -> None:
"""Push the current decision to every live animation."""
app = QApplication.instance()
if app is not None:
for widget in _live_ambient_widgets():
if isinstance(widget, AmbientWidget):
try:
widget._sync_run_state()
except RuntimeError:
continue
if self.blocks_gpu():
self._hold_fractals()
else:
self._release_fractals()
self.changed.emit()
def _hold_fractals(self) -> None:
"""Pause fractals the current work forbids; remember only those."""
for widget in _live_spaceout_fractals():
try:
wanted = (self.pauses_animation()
or getattr(widget, "backend_name", "") == "gpu")
if (wanted and widget not in self._paused_fractals
and not widget.is_paused() and widget.pause()):
self._paused_fractals.append(widget)
except RuntimeError:
continue
except Exception:
LOG.debug("could not pause a fractal for a job", exc_info=True)
def _release_fractals(self) -> None:
"""Resume exactly the fractals this policy paused."""
held, self._paused_fractals = self._paused_fractals, []
for widget in held:
try:
widget.resume()
except RuntimeError:
continue
except Exception:
LOG.debug("could not resume a fractal", exc_info=True)
def _animation_resource_policy() -> Optional[_AnimationResourcePolicy]:
"""The process-wide Dynamic animation policy, once Qt is running."""
global _RESOURCE_POLICY
if _RESOURCE_POLICY is None:
app = QApplication.instance()
if app is None:
return None
_RESOURCE_POLICY = _AnimationResourcePolicy(app)
_RESOURCE_POLICY.attach()
return _RESOURCE_POLICY
def _dynamic_animation_on() -> bool:
"""Whether the workload policy is switched on, without creating it."""
policy = _RESOURCE_POLICY
return True if policy is None else policy.enabled
def _decorative_gpu_blocked() -> bool:
"""Whether running analysis currently forbids decorative GPU work."""
policy = _RESOURCE_POLICY
return bool(policy is not None and policy.blocks_gpu())
def _animation_paused_for_work() -> bool:
"""Whether CPU-heavy analysis currently pauses every animation."""
policy = _RESOURCE_POLICY
return bool(policy is not None and policy.pauses_animation())
EXPLICIT_RIPPLES_PROPERTY = "spacrExplicitRipples"
def _ripples_are_explicit(widget) -> bool:
"""Whether ``widget`` belongs to a window that ripples only on request.
Such a window has :data:`EXPLICIT_RIPPLES_PROPERTY` set: it ripples the
field when it opens and when it calls :func:`_field_ripple_for_popup`
(Preferences: Apply, Cancel, Save), and not for clicks inside it, its
close or the recurring popup waves.
A combo box's list or a sub-dialog is a window of its own, so the walk
continues through each window's owner.
"""
try:
current = widget if isinstance(widget, QWidget) else None
while current is not None:
window = current.window()
if window.property(EXPLICIT_RIPPLES_PROPERTY):
return True
current = window.parentWidget()
return False
except RuntimeError:
return False
def _field_ripple_for_popup(dialog, strength=1.0) -> None:
"""Send one wave from ``dialog``'s outline into its owner's backdrop.
:param dialog: a dialog parented to a window with a field backdrop.
:param strength: positive enables the wave; zero disables it.
:returns: None.
"""
_note_container_ripple()
try:
owner = dialog.parentWidget()
if owner is None:
return
for backdrop in owner.window().findChildren(AmbientWidget):
if backdrop.theme() not in _FIELD_THEMES or not backdrop._should_run():
continue
origin = backdrop.mapFromGlobal(dialog.mapToGlobal(dialog.rect().topLeft()))
backdrop._ripple_from_rect(QRect(origin, dialog.size()), strength)
except RuntimeError:
return
def _field_column_ripple_for_widget(widget, edge="bottom", strength=1.0) -> None:
"""Send one wave up and down ``widget``'s column (a settings category).
:param widget: the opened or closed category.
:param edge: ``"top"`` or ``"bottom"``: the row the wave starts on.
:param strength: positive enables the wave; zero disables it.
"""
_publish_widget_ripple(widget, edge, None, strength, vertical=True)
def _publish_widget_ripple(widget, edge, rect, strength, *, vertical,
rings=False) -> None:
"""Send ``widget``'s ripple to every running field backdrop of its window."""
_note_container_ripple()
try:
window = widget.window()
backdrops = window.findChildren(AmbientWidget)
for backdrop in backdrops:
if backdrop.theme() not in _FIELD_THEMES or not backdrop._should_run():
continue
if rings:
centre = backdrop.mapFromGlobal(widget.mapToGlobal(widget.rect().center()))
if backdrop.rect().contains(centre):
backdrop._publish_boundary_ripples((
("rings", (centre.x() + 0.5) / max(1, backdrop.width()),
(centre.y() + 0.5) / max(1, backdrop.height())),),
strength, wrapped=True)
continue
if edge is not None and widget is window and not vertical:
backdrop._ripple_from_edge(edge, strength)
continue
local = QRect(widget.rect() if rect is None else rect)
origin = backdrop.mapFromGlobal(widget.mapToGlobal(local.topLeft()))
if vertical:
backdrop._ripple_column(QRect(origin, local.size()),
edge or "bottom", strength)
else:
backdrop._ripple_from_rect(QRect(origin, local.size()), strength,
edge=edge)
except RuntimeError:
return
def _apply_spaceout_animation_choice(app) -> None:
"""Replace native fractals when the spaceout animation selection changes."""
if not spaceout_enabled():
return
from ..preferences import get_ambient_theme, get_ambient_palette, get_ambient_enabled
enabled = get_ambient_enabled()
selected = get_ambient_theme()
for old in list(app.allWidgets()):
try:
fractal = getattr(old, "_spaceout_built_from", None) is not None
ambient = isinstance(old, AmbientWidget)
if (not (fractal or ambient) or old.property("spacrPopupBackdrop")
or old.property("spacrSetupBackdrop")
or old.parentWidget() is None):
continue
if not enabled:
if fractal and not old.property("spacrPreferenceDisabled"):
old.setProperty("spacrPreferenceWasHidden", old.isHidden())
old.setProperty("spacrPreferenceWasPaused", old.is_paused())
old.setProperty("spacrPreferenceDisabled", True)
old.pause()
old.hide()
continue
if fractal and selected == SPACEOUT_THEME:
if old.property("spacrPreferenceDisabled"):
if not old.property("spacrPreferenceWasHidden"):
old.show()
if not old.property("spacrPreferenceWasPaused"):
old.resume()
old.setProperty("spacrPreferenceDisabled", False)
continue
if ambient and selected != SPACEOUT_THEME:
continue
host = old.parentWidget()
hidden = old.isHidden()
paused = (old.is_paused() if fractal else not old.is_animating())
if fractal and old.property("spacrPreferenceDisabled"):
hidden = bool(old.property("spacrPreferenceWasHidden"))
paused = bool(old.property("spacrPreferenceWasPaused"))
new = install_ambient(
host, theme=selected, palette=get_ambient_palette())
if ambient:
old.setProperty("spacrRetiringBackdrop", True)
old.stop()
old.hide()
old.setParent(None)
old.deleteLater()
else:
_retire_one_fractal(old, host)
_point_holders_at(old, new, host)
if hidden:
new.hide()
if paused:
if isinstance(new, AmbientWidget):
new.set_animating(False)
else:
new.pause()
except RuntimeError:
continue
except Exception as error:
if _the_backdrop_wants_a_retry(error):
QTimer.singleShot(400, lambda: _apply_spaceout_animation_choice(app))
else:
LOG.debug("could not switch the spaceout animation", exc_info=True)
[docs]
def install_ambient(host: QWidget, layout=None, *,
theme: str = DEFAULT_THEME,
palette: str = DEFAULT_PALETTE,
backdrop=None, corner_radius: int = 0,
**kwargs) -> AmbientWidget:
"""Put a live ambient backdrop behind ``host``.
The widget becomes a child of ``host``, tracks its geometry, and is
lowered to the bottom of the sibling stacking order so every screen
widget paints in front of it. It takes no focus and no mouse events, and
it does not tick until ``host`` is actually on screen.
Note that a backdrop is only as visible as its siblings are transparent:
under dark and light every container is an opaque page colour, and an
animation behind them reaches the eye through nothing but the few pixels
of layout spacing. The caller is responsible for clearing those surfaces
first — see ``AppScreen._clear_page_surfaces``.
:param host: the screen the animation sits behind.
:param layout: accepted for signature compatibility with
:func:`spacr.qt.widgets.dna_rain.install_dna_rain`, which appends its
settings bar to it. The ambient backdrop has no on-screen controls —
it is configured in Preferences — so nothing is added here, and the
two installers stay interchangeable at a call site.
:param theme: one of :data:`AMBIENT_THEMES`.
:param palette: one of :func:`palettes_for` for that theme.
:param backdrop: wallpaper to composite over; see
:meth:`AmbientWidget.set_backdrop`.
:param corner_radius: Radius in pixels used to clip the backdrop. The
default of zero leaves square corners. For a frameless dialog
containing a rounded card, use the card's radius so the backdrop does
not extend beyond its corners.
:param kwargs: forwarded to :class:`AmbientWidget` (``background``,
``fps``, ``seed``, ``blur``, ``speed``, ``size``, ``resolution``,
``density``, ``direction``). Everything from ``blur`` on defaults to
the user's preferences, so a caller that does not care about them
should not pass them.
:returns: the widget, already shown and lowered.
"""
replacement = _the_spaceout_fractal(host, theme)
if replacement is not None:
from ..preferences import _hold_still_when_motion_is_reduced
_hold_still_when_motion_is_reduced(replacement)
return replacement
auto_art_fps = "fps" not in kwargs
kwargs.setdefault("fps", DEFAULT_FPS if theme.startswith("data_art_") else _INSTALLED_FPS)
widget = AmbientWidget(host, theme=theme, palette=palette,
backdrop=backdrop, corner_radius=corner_radius,
**kwargs)
widget._auto_art_fps = auto_art_fps
if auto_art_fps and theme in _FIELD_THEMES:
widget._choose_auto_rate()
widget.follow_parent()
widget.show()
return widget