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.

Why this belongs at authoring time

Occlusion is a scalar function of the geometry alone — “how enclosed is this element” — so it is identical from every camera. That is what makes it safe to bake: the answer computed once offline is the answer for every frame. A directional key light is deliberately NOT offered here. Baking one fixes it in world space, so orbiting to the unlit side darkens the scene for no reason; a key light has to follow the camera, which makes it a shader concern (see the mesh material’s view-space key) rather than an authoring one.

Normals are optional, and nothing here invents them. Averaged over the full sphere, occlusion needs no surface orientation at all, which is the right reading for genuinely volumetric data — a light-sheet fit, a cloud — and avoids estimating normals by local PCA, which is meaningless on such data. But where the subject really is a surface and the caller already holds normals (mesh normals, marching-cubes gradients, a distance-field gradient), passing them switches to a cosine-weighted hemisphere and roughly doubles the discrimination: on a thin shell the full sphere is dominated by the in-plane material every element shares. Either way the result stays view-independent — a normal describes the surface, not the camera.

Method

A windowed Beer-Lambert column integral over a spherical direction set:

  1. Splat each element’s mass onto a regular grid aligned so its third axis points along the sampled direction.

  2. Integrate density along that axis over a finite window of radius world units, exclusive of the element’s own cell.

  3. Map that column to a per-direction transmittance — exp(-tau) for a medium, or a saturating max(0, 1 - tau) for an opaque surface (see occluder). 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.0 where 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 ** 3 when radii vary; else leave None

    Lines

    widths ** 2 * segment_length per vertex

    Mesh

    leave None — a vertex has no extent of its own

    Two 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 into mass yourself.

    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. Raise grid_cells, or splat pre-spread mass yourself, if your elements are large enough to span cells.

  • normals –

    Optional (N, 3) outward normals, in the spatial_dims frame. 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.31 to +0.61 at a 0.05 radius and from +0.44 to +0.68 at 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 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. 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_FRACTION of 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 normals are supplied.

  • grid_cells – Cells across the longest data axis.

  • spatial_dims – Which three positions columns are the occluding axes. Everything else — time, channel — must be excluded, and is normally excluded via group_by as 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 against 1 - strength and stops discriminating. Or "auto" to solve for the value putting the median element at AUTO_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 - strength is the indirect, multiply-scattered ambient that reaches even a fully enclosed element. 0.0 returns 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 use bake_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; None integrates 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 positions columns are the occluding axes.

Returns:

(N,) float32 optical depth.

luxar.shading.occlusion.sphere_directions(n_directions: int) → ndarray[source]

Return n_directions roughly equidistributed unit vectors on a sphere.

Directions come in opposed pairs, because a single grid pass yields the column integral in both +d and -d for 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, n even and >= n_directions.

See Also