Edit on GitHub

starplast.lighting

Soft interactive lighting and ray-marched shadows for a point cloud.

What this is, and what it is not

soft uses broad diffuse/specular illumination while ray traced additionally marches one shadow ray from every gene to the active light through the point-density volume. This is real volumetric shadow-ray tracing: occluding clusters attenuate the ray. It is deliberately not called hardware path tracing--there are no triangle surfaces, reflections or Vulkan renderer here.

The controls describe independent questions: which interaction places the light, which color mood it has, how points themselves are drawn, and whether shadow rays are traced. The point modes are few because a rendering choice that cannot be distinguished on the fixed comparison scene is not a choice worth presenting.

MODES = ('off', 'soft', 'ray traced', 'deep ray traced')
DEFAULT_MODE = 'deep ray traced'
POINT_MODES = {'flat': {'ambient': 1.2, 'diffuse': 0.25, 'specular': 0.0, 'shininess': 1.0, 'rim': 0.0, 'tint': 0.0, 'size': 1.0}, 'velvet 3D': {'ambient': 0.96, 'diffuse': 0.78, 'specular': 0.2, 'shininess': 2.0, 'rim': 0.86, 'tint': 0.3, 'size': 1.32}, 'glossy 3D': {'ambient': 0.88, 'diffuse': 0.68, 'specular': 1.0, 'shininess': 7.0, 'rim': 0.3, 'tint': 0.0, 'size': 1.3}, 'glass 3D': {'ambient': 0.72, 'diffuse': 0.3, 'specular': 1.6, 'shininess': 22.0, 'rim': 1.15, 'tint': 0.05, 'size': 1.38}, 'metallic 3D': {'ambient': 0.66, 'diffuse': 0.36, 'specular': 1.55, 'shininess': 11.0, 'rim': 0.82, 'tint': 1.0, 'size': 1.35}}
FINISHES = {'flat': {'ambient': 1.2, 'diffuse': 0.25, 'specular': 0.0, 'shininess': 1.0, 'rim': 0.0, 'tint': 0.0, 'size': 1.0}, 'velvet 3D': {'ambient': 0.96, 'diffuse': 0.78, 'specular': 0.2, 'shininess': 2.0, 'rim': 0.86, 'tint': 0.3, 'size': 1.32}, 'glossy 3D': {'ambient': 0.88, 'diffuse': 0.68, 'specular': 1.0, 'shininess': 7.0, 'rim': 0.3, 'tint': 0.0, 'size': 1.3}, 'glass 3D': {'ambient': 0.72, 'diffuse': 0.3, 'specular': 1.6, 'shininess': 22.0, 'rim': 1.15, 'tint': 0.05, 'size': 1.38}, 'metallic 3D': {'ambient': 0.66, 'diffuse': 0.36, 'specular': 1.55, 'shininess': 11.0, 'rim': 0.82, 'tint': 1.0, 'size': 1.35}}
LEGACY_POINT_MODES = {'2D': 'flat', 'matt': 'flat', 'satin': 'glossy 3D', 'glossy': 'glossy 3D', 'metallic': 'metallic 3D', 'pearl 3D': 'glossy 3D', 'brushed metal 3D': 'metallic 3D', 'silver 3D': 'metallic 3D'}
MIN_AMBIENT = 0.32
DEFAULT_POINT_MODE = 'glossy 3D'
DEFAULT_FINISH = 'glossy 3D'
SOURCES = ('mouse flashlight', 'selected gene', 'selected gene and its edges', 'wandering light', 'bouncing light')
DEFAULT_SOURCE = 'selected gene and its edges'
WANDER_SPEED = 0.85
BOUNCE_SPEED = 0.19
TRAVEL_GAIN = 2.6
TRAVEL_WIDTH = 0.3
INTERIOR = 0.88
POINTER_MODES = {'broad flashlight': {'inner': 18.0, 'outer': 34.0, 'gain': 1.0}, 'focused flashlight': {'inner': 7.0, 'outer': 17.0, 'gain': 1.28}, 'soft flashlight': {'inner': 28.0, 'outer': 52.0, 'gain': 0.78}, 'parallel wash': None}
DEFAULT_POINTER_MODE = 'broad flashlight'
RESPONSES = {'direct': 1.0, 'smooth': 0.24, 'cinematic': 0.09}
DEFAULT_RESPONSE = 'smooth'
TARGET_MARKERS = ('none', 'halo', 'beacon', 'pulse')
DEFAULT_TARGET_MARKER = 'halo'
LIGHT_MOODS = {'neutral': (0.92, 0.92, 0.9), 'cool blue': (0.66, 0.82, 1.0), 'warm': (1.0, 0.7, 0.46), 'daylight': (0.82, 0.9, 1.0), 'moonlight': (0.42, 0.58, 1.0), 'gold': (1.0, 0.82, 0.32), 'rose': (1.0, 0.54, 0.68), 'violet': (0.72, 0.52, 1.0), 'laboratory green': (0.5, 1.0, 0.72)}
DEFAULT_MOOD = 'neutral'
LIGHT_RANGE = (1, 6)
SPEED_RANGE = (0.05, 2.0)
DEFAULT_LIGHTS = 3
DEFAULT_SPEED = 0.35
LOCAL_GLOW = 0.62
LOCAL_WIDTH = 0.46
AMBIENT = 0.55
def normalize_point_mode(value: str | None) -> str:

Translate old saved finish names and refuse unknown render modes safely.

def normalize_source(value: str | None) -> str:

Translate the old mouse label and discard removed orbit/corner sources.

def mood_color(mood: str | None) -> numpy.ndarray:

The RGB illumination for a named mood, with neutral as the safe fallback.

def lights( t: float, n: int = 3, speed: float = 0.35, radius: float = 90.0, colors=None) -> list:

Where each light is at time t, and what colour it is.

Lissajous paths with incommensurable frequencies, so the lights never fall into a repeating formation -- three lights orbiting in step read as one light, and the point of several is that the shading changes as they pass each other. Position is a function of the clock for the same reason the blobs are: any frame can be drawn at any time, and a dropped frame costs nothing.

The curve gives the DIRECTION and the radius is fixed, which it was not at first. Taken as a position, a Lissajous figure ranges over the whole cube it is inscribed in: measured, the lights swung between 0.2 and 1.7 times the intended radius and spent about 4% of frames inside the cloud itself. That reads as the map pulsing -- overall brightness pumping up and down, and every so often a light surfacing among the genes to blow out its neighbours and leave the rest dark. Which is what "the orbit looks strange" was describing. On a shell, only the direction changes.

def fixed(direction, radius: float, color=(1.0, 0.97, 0.92)) -> list:

One light, far away in a given direction -- the corner presets and the pointer both use this.

Far away rather than at the point: a light placed among the genes lights the near side of a few and leaves the rest black, which reads as data missing rather than as a light.

CORNERS = {'top left': (-1.0, 1.0, 0.8), 'top right': (1.0, 1.0, 0.8), 'bottom left': (-1.0, -1.0, 0.8), 'bottom right': (1.0, -1.0, 0.8)}
def wandering( coords, t: float, radius: float, n: int = 1, speed: float = 0.85, color=(1.0, 0.97, 0.92)) -> list:

Lights drifting through the interior of the cloud, lighting whatever they pass.

Positioned against the cloud's own extent rather than a fixed radius, so the light stays among the genes at any zoom and in any embedding -- a map twice the size gets a light that wanders twice as far rather than one that leaves through the side.

def bouncing( coords, t: float, radius: float, n: int = 1, speed: float = 0.19, color=(1.0, 0.97, 0.92)) -> list:

Lights bouncing off the walls of the map's own bounding box.

Each axis runs at its own rate, and the rates share no common multiple, so the ball crosses the volume on a path that does not close -- one that repeated would sweep the same corridor forever and leave most of the map permanently dark.

Bouncing off the GENES themselves is not what this does, and the difference is worth stating: an elastic collision with a cluster needs the path integrated forward from a start, which is the state this deliberately avoids. What reads as a bounce off the data is the density shadowing already in rays -- the ball dims as it passes behind a cluster and brightens as it clears it.

def at_points(coords, indices, radius: float, color=(1.0, 0.95, 0.85)) -> list:

A light sitting on each of the given genes.

Used for "light the gene I clicked" and "light it and everything it is connected to", where the light IS the answer to a question about those genes -- so it goes where they are rather than outside the cloud, and its falloff does the rest.

def shade( coords, colors, lit, specular: bool = False, ambient: float = 0.55, shininess: float = 24.0, finish: str = None, point_mode: str = None, eye=None) -> numpy.ndarray:

Point colours under a set of lights. Returns RGBA in the shape it was given.

The normal is the outward direction from the centre of the cloud, because a point has no surface of its own. Distance falloff is gentle -- inverse linear rather than inverse square -- since the map is not a physical scene and a physically correct falloff makes everything past the first light black.

def on_screen(where, basis, centre, radius: float, color=(1.0, 0.97, 0.92)) -> list:

A light placed relative to the CAMERA rather than to the data.

where is (x, y, z) in the screen's own frame: x right, y up, z toward the viewer. basis is (eye, right, up, forward) from the renderer. This is what makes "top left" mean the top left of the picture and keep meaning it while the map is orbited -- placed in world coordinates instead, a corner light drifts to the other side of the cloud as soon as anything moves, which looks like a light that does not work rather than one that is somewhere else.

def torch( anchor, basis, radius: float, color=(1.0, 0.97, 0.92), stand_off: float = 0.08) -> list:

A light hanging just in front of a given gene, between it and the viewer.

This is what "point at it and it lights up" has to mean once the map has depth. A light placed outside the cloud on the viewer's side can only ever light the near face -- it is behind everything else -- so pointing into a cluster deep in the map lit the front of the map instead, which is what was reported. Anchored on the gene under the pointer, the light is IN the cloud at that depth and its falloff does the rest: the genes around it are lit whether they are at the front or the back.

Held off the gene rather than sitting on it, because a light exactly on a point makes that point a white dot and its neighbours a hard black shell. The stand-off is a fraction of the map's own radius, so the pool of light stays the same size relative to the data at any zoom.

def flashlight( pointer, basis, centre, radius: float, cone: dict, color=(1.0, 0.97, 0.92), fov: float = 60.0, aspect: float = 1.0) -> list:

A camera-origin spotlight following continuous screen coordinates.

No gene is queried here. The cursor defines one ray through the camera frustum, so crossing two overlapping points at different depths does not change either the origin or direction. The light target is the intersection with the plane through the cloud center, used only for drawing an optional marker.

def light_at( coords, source: str, t: float, n: int, speed: float, radius: float, pointer=None, basis=None, selected=None, neighbours=None, anchor=None, color=None, pointer_mode: str = 'broad flashlight', fov: float = 60.0, aspect: float = 1.0) -> list:

The lights for one frame, for whichever source was chosen.

The screen-relative sources need basis -- the camera's own axes -- because "top left" and "where the pointer is" are statements about the picture, not about the data. anchor is the anchor remains accepted for saved callers but is deliberately ignored: mouse illumination is a continuous camera ray, never the identity or depth of the nearest gene.