Edit on GitHub

starplast.rays

CPU fallback for volumetric shadow-ray tracing through a point-density field.

What this does

A light in lighting reaches every gene that faces it, however much of the map is in the way. That is what makes a lit cloud look like a painted cloud: the far side of a dense cluster is as bright as its front, so nothing occludes anything and the eye gets no depth from the light. What a reader means by "point at it and it lights up" is the other thing -- a gene lights up when there is a clear line between it and the light, and stays dark when the cluster in front of it is in the way.

The primary glossy/metallic renderer uploads this grid as a 3D texture and marches the rays in its OpenGL vertex shader. This NumPy implementation remains for flat points, headless validation, and drivers that reject the shader. Both paths send one shadow ray per gene toward the active light. Because genes are point sprites rather than triangle surfaces, those rays integrate a density volume; claiming mirror reflection, refraction, hardware path tracing or Vulkan RT would be false.

Transport model

Voxel occupancy plus fixed-step marching. The cloud is binned once into a coarse grid -- how much of each cell is occupied -- and a shadow ray is answered by sampling that grid at a dozen points along the segment and multiplying the transmittance through. It is the same trick a volume renderer uses for a cloud or a candle flame, and a cloud of points is exactly that kind of subject: there is nothing here with a surface to intersect, so there is nothing to be exact about. Sampling the density along the line IS the right model, not an approximation of a better one.

The costs are honest and worth stating. It is soft, not sharp: a gene behind one lonely neighbour is barely shadowed, and one behind a dense cluster goes properly dark, with no hard edge anywhere in between. It cannot tell you which gene did the blocking. And it is coarse -- at 48 cells across, two genes in the same cell cannot shadow each other at all.

The older experimental bounce helpers remain private implementation utilities for reproducibility, but the display no longer exposes emitters or bounce/ray controls. The only ray option is the understandable one: traced volumetric shadows on or off through the light-render-mode control.

RESOLUTION = 48
OPAQUE = 6.0
SAMPLES = (12, 28)
ABSORB = 0.55
class Grid:

Where the map is solid, as a coarse box of densities.

Built once per set of coordinates and reused for every light and every frame -- the cloud does not move between redraws, and rebuilding this at 60 Hz would cost more than the shading it is there to inform.

Grid(occupancy, lo, cell)
occupancy
lo
cell
resolution: int

Cells along one side.

def cells_at(self, points) -> tuple:

Grid indices for an array of world points, clamped to the box.

def transmittance( self, coords, source, samples=None, absorb: float = 0.55) -> numpy.ndarray:

How much of the light from source survives the trip to each of coords, in [0, 1].

Sampled strictly BETWEEN the two ends. A sample at the gene's own position finds the gene itself and every gene shadows itself into darkness; a sample at the light's finds whatever the light is standing in and shadows the entire map at once.

The absorption per sample is scaled by how far the step covers, so a ray sampled coarsely and one sampled finely report the same shadow -- without it, capping the sample count would quietly brighten exactly the long rays that need the samples most.

def first_hit(self, origin, directions, reach: float, steps: int = 96):

Where each ray from origin first meets something, or nothing where it meets nothing.

Fixed-step marching rather than a DDA walk of the grid: a DDA visits every cell a ray crosses and is the right algorithm for an exact answer, and there is no exact answer here to be right about -- the grid is already a blur of a cloud that has no surfaces.

def build(coords, resolution: int = 48, opaque: float = 6.0) -> Grid:

Bin a cloud into a Grid. Cubic cells, so a shadow is the same width in every direction.

def cone(direction, count: int, spread: float = 0.35) -> numpy.ndarray:

count directions in a cone around direction, spaced evenly rather than at random.

Evenly, because random directions are a different set on every frame and the bounce would flicker between whatever each frame happened to hit. A fixed fan aimed by the pointer moves only when the pointer does.