Shading Package
Appearance baked from geometry at scene-authoring time.
Luxar’s emissive geometry types — Points, Lines and GSplats — have shaders that
know nothing about neighbouring geometry, so shape cues a shaded renderer would
get for free have to be computed by the author and written into the scene. This
package holds those bakes; bake_ambient_occlusion() is the
sanctioned way to make a dense emissive cloud read as three-dimensional rather
than as a flat haze.
The dividing line is deliberate: bake what the geometry knows, leave to the shader what the camera knows. Ambient occlusion is a scalar function of the geometry alone, so a value computed offline is correct from every camera and belongs in the store. A key light depends on a direction relative to the viewer, so baking one fixes it in world space and it stops reading the moment the camera orbits — that belongs in a material, not in a store.
Appearance baked from geometry at scene-authoring time.
Luxar’s emissive geometry types — Points, Lines, GSplats — have shaders that know nothing about neighbouring geometry, so shape cues that a shaded renderer would get for free have to be computed by the author and written into the scene. This package holds those bakes.
The dividing line is deliberate and worth stating once: bake what the geometry knows, leave to the shader what the camera knows. Ambient occlusion is a scalar function of the geometry alone, so the value computed offline is correct from every camera and belongs here. A key light depends on a direction relative to the viewer, so baking one fixes it in world space and it stops reading the moment the camera orbits — that belongs in a material (see the mesh shader’s view-space key), not in a store.
This also sits on the appearance side of Luxar’s data/appearance split. A
.gsplats.zarr is a reconstruction; colormaps, tone mapping and occlusion are
authored on the way into a scene. Keeping the bake here rather than in the gsplat
toolbox avoids making it another per-element sidecar for reencode, lod,
decimate and refits to reorder or invalidate, and means the caller has the
scene’s Dimensions in hand to say which axes are
spatial.
Quick start
from luxar.shading import bake_ambient_occlusion
shade = bake_ambient_occlusion(positions, mass=amplitudes)
colors = (base_colors * shade[:, None]).astype(np.float32)
Everything in luxar.shading’s __all__ is re-exported from
luxar.shading.occlusion, so the members are documented once, below,
rather than twice under two names.
Occlusion
View-independent ambient occlusion baked from a point-sampled density field.
Luxar’s Points, Lines and GSplats are emissive: nothing in their shaders knows that neighbouring geometry exists, so a dense shell accumulates into a flat glow and the eye loses the shape. Ambient occlusion restores it by darkening elements that sit inside the mass and leaving exposed ones bright.
Emissivity as a function of ambient illumination
The useful way to read this is that it is not decoration bolted onto the
renderer, but the missing half of the transport it already implements.
Emission-absorption rendering has two terms. Luxar’s blending modes supply the
attenuation one: radiance is absorbed on its way OUT to the eye. The emission
term is the other, and for matter that is lit from outside rather than
genuinely glowing, the physically correct source is albedo x incident
irradiance — and the incident irradiance at a point is precisely what ambient
occlusion measures, the fraction of the surrounding environment that can reach
it. So an emissive scene is best understood as one whose emissivity is a function
of ambient illumination, and this module computes that function.
Three consequences follow, and they are why the API looks the way it does. The
result belongs multiplied into the emission (colour x intensity), never into
opacity or absorption, which are the other term. It composes with an absorbing
blending mode rather than double-counting it, because in-scattered source and
outgoing attenuation are different halves of one equation. And strength
stops being a taste knob: 1 - strength is the indirect ambient, the
multiply-scattered light that reaches even a fully enclosed point, which is the
same quantity demo_volumetric_cloud spends three tuned radiance terms on and
that demo_mandelbulb writes as its ambient floor of 0.32.
Method
A windowed Beer-Lambert column integral over a spherical direction set:
Splat each element’s mass onto a regular grid aligned so its third axis points along the sampled direction.
Integrate density along that axis over a finite window of
radiusworld units, exclusive of the element’s own cell.Map that column to a per-direction transmittance —
exp(-tau)for a medium, or a saturatingmax(0, 1 - tau)for an opaque surface (seeoccluder). The ambient term is the mean over all directions, cosine-weighted into the hemisphere a normal faces when normals are supplied.
Which mapping matters more than it sounds. Under Beer-Lambert a one-cell-thick
shell — what a surface sampled as points is made of — attenuates only by
exp(-k), so at any k gentle enough to keep solid regions readable a WALL
passes about half the light, and raising k until walls block properly
over-darkens everywhere thick. Saturation has no such trade: it reaches full
occlusion at one wall and stops. So volumetric data wants "density" and
surfaces want "opaque".
The finite window is what makes this occlusion rather than a depth map. An
unbounded integral (correct when you are chasing a real light, as
demo_volumetric_cloud does for its sun) reports how deep an element sits
inside the whole object, so two elements at equal depth read identically however
differently shaped their surroundings are — exactly the flat-sheet-versus-crevice
confusion AO is supposed to resolve.
Two details differ from the same integral written for a single light, and both are corrections rather than preferences. The cell size is fixed once from the data extent and reused for every direction, so no direction integrates at a coarser scale than another and the result carries no directional bias. And the anti-banding blur clamps at the grid edge instead of wrapping, so mass on one face of the object cannot leak onto the opposite one.
References
The transmittance-toward-a-direction formulation follows Harris & Lastra (2001), “Real-Time Cloud Rendering”, computed on a grid rather than by rendering from the light’s point of view — which suits a caller that already holds every element as an array.
- luxar.shading.occlusion.bake_ambient_occlusion(positions: ndarray, *, mass: ndarray | None = None, normals: ndarray | None = None, occluder: str = 'density', radius: float | None = None, n_directions: int | None = None, grid_cells: int = 64, spatial_dims: Sequence[int] = (0, 1, 2), group_by: ndarray | None = None, extinction: float | str = 'auto', strength: float = 0.7, floor: float = 0.0) ndarray[source]
Bake a per-element ambient-occlusion factor from the element density.
The result is a multiplier:
1.0where an element is out in the open, lower where it is enclosed. Multiply it into linear-light colour, or carry it alongside as a scalar so the strength stays adjustable.AO always lowers the mean brightness, so authored exposure has to absorb it —
result.mean()is the factor to compensate for. Judging a bake by whether the frame merely looks crisper is how a darkening gets mistaken for detail.- Parameters:
positions –
(N, D)element positions.mass –
(N,)occluding material per element. Defaults to ones, i.e. pure count density.This is the only channel through which an element’s own appearance enters. Nothing here reads a node’s radii, sharpness or opacity — per geometry type, the quantity to pass is:
GSplats
amplitudes(times per-splat alpha if RGBA)Points
radii ** 3when radii vary; else leaveNoneLines
widths ** 2 * segment_lengthper vertexMesh
leave
None— a vertex has no extent of its ownTwo things deliberately do NOT need folding in. A uniform factor — node opacity, or a constant radius or sharpness — cancels out entirely, because
extinction="auto"calibrates against the population’s own median. And sharpness only changes an element’s profile SHAPE, which is a modest constant unless it varies per element, in which case fold it intomassyourself.One real approximation to know about: mass is deposited at each element’s centre, so an element’s extent is a weight and not a footprint. That holds while the render radius is small next to the occlusion grid cell (
extent / grid_cells) — measured at 0.40, 0.18 and 0.44 of a cell in the three bundled point demos at their default resolutions. Raisegrid_cells, or splat pre-spread mass yourself, if your elements are large enough to span cells.normals –
Optional
(N, 3)outward normals, in thespatial_dimsframe. When given, directions are cosine-weighted into the hemisphere each normal faces instead of averaged over the full sphere — still entirely view-independent, since this needs a surface orientation and not a camera.Supply these whenever the data is surface-like and you have them (mesh normals, marching-cubes gradients, a distance-field gradient). On a thin shell the full sphere is dominated by the in-plane material every element shares, which washes the signal out: measured against the Mandelbulb’s own distance-estimator AO over its 27k surface points (
n_directions=24,grid_cells=96), correlation rises from+0.31to+0.61at a 0.05 radius and from+0.44to+0.68at 0.20.Left
None, no normals are needed or invented. That is the right default for genuinely volumetric data — a light-sheet fit, a cloud — where there is no surface to orient to, and it is deliberately not papered over with an estimate: normals from a local PCA of a volumetric point cloud are meaningless.occluder –
What the material is taken to BE, which decides how a column maps to transmittance.
"density"(default) is Beer-Lambert,exp(-depth)— correct for a medium, where twice the material attenuates twice as much without limit. Right for a light-sheet fit, a cloud, a filled molecular complex."opaque"is saturating,max(0, 1 - depth)— correct for a surface, where once a direction is blocked it cannot become more blocked, so a thick wall darkens exactly as much as a thin one. Prefer it whenever the subject is a surface sampled as points.The distinction is not cosmetic. Under Beer-Lambert a one-cell-thick shell — which is what a surface-sampled point cloud is made of — only attenuates by
exp(-k), so at anykgentle enough to keep solid regions readable a WALL passes about half the light, and raisingkuntil walls block properly over-darkens everywhere thick. Saturation has no such trade. Measured on the shipped gyroid shell,"opaque"carries about 10% more contrast than"density"at the demo settings and about 13% more at the library defaults, at the cost of clipping the darkest directions.radius – World-space occlusion radius — the scale of structure AO responds to. Defaults to
DEFAULT_RADIUS_FRACTIONof the bounding-box diagonal, and is the first thing to tune.n_directions – Sphere directions to average; rounded up to even. Defaults to 24 for a full-sphere bake and 48 when
normalsare supplied.grid_cells – Cells across the longest data axis.
spatial_dims – Which three
positionscolumns are the occluding axes. Everything else — time, channel — must be excluded, and is normally excluded viagroup_byas well.group_by –
(N,)integer labels; geometry is integrated independently per group so occlusion never crosses a non-spatial axis, while cell size, radius and auto extinction stay shared across the population. Pass the timepoint index for a timelapse.extinction – Occlusion per cell of typical material traversed — an O(1) quantity, unlike the directional function’s extinction, because the column here is normalized by the reference density. Useful explicit values sit around
0.3–1.0; much above that the field saturates against1 - strengthand stops discriminating. Or"auto"to solve for the value putting the median element atAUTO_TARGET_TRANSMITTANCE, which is the default because it makes the bake independent of the mass units and of how densely the object was sampled.strength – Fraction of the ambient illumination that is DIRECT, and so occludable;
1 - strengthis the indirect, multiply-scattered ambient that reaches even a fully enclosed element.0.0returns all ones (everything reached by indirect light alone).floor – Lower clamp, so fully enclosed elements keep some brightness.
- Returns:
(N,)float32 in[floor, 1].
- luxar.shading.occlusion.directional_optical_depth(positions: ndarray, mass: ndarray, direction: Sequence[float], *, radius: float | None = None, grid_cells: int = 64, extinction: float = 1.0, spatial_dims: Sequence[int] = (0, 1, 2)) ndarray[source]
Optical depth accumulated from each element toward
direction.The building block
bake_ambient_occlusion()averages over a direction set. Exposed because it is the honest primitive, not because it is a recommended appearance path: a single baked direction is locked to world space, so it stops reading correctly the moment the camera moves. Use it to model a light that genuinely is part of the subject (a sun above a cloud), and usebake_ambient_occlusion()for everything else.- Parameters:
positions –
(N, D)element positions.mass –
(N,)occluding material per element — GSplat amplitudes, or a Points radius cubed, or ones for pure count density.direction – Vector pointing from the elements toward the source.
radius – World-space integration window;
Noneintegrates all the way to the bounding box, which is the right choice for a real light.grid_cells – Cells across the longest data axis.
extinction – Extinction per unit of mass column.
spatial_dims – Which three
positionscolumns are the occluding axes.
- Returns:
(N,)float32 optical depth.
- luxar.shading.occlusion.sphere_directions(n_directions: int) ndarray[source]
Return
n_directionsroughly equidistributed unit vectors on a sphere.Directions come in opposed pairs, because a single grid pass yields the column integral in both
+dand-dfor free — the forward and backward windowed sums along the same axis. So the number of grid builds, which is what the runtime actually is, is half the direction count.- Parameters:
n_directions – Requested direction count; rounded up to an even number so the pairing is exact.
- Returns:
(n, 3)float64 unit vectors,neven and>= n_directions.
See Also
Mesh Package — the shaded geometry type, which needs no bake
Core Package —
luxar.core.Points,luxar.core.Linesandluxar.core.GSplats, the emissive types this package exists for