How Volumetric Rendering Works with Media and Phase Functions in LuisaRender
LuisaRender implements volumetric rendering through a Medium abstraction that manages optical properties and phase functions, using a MediumTracker to handle nested media during path tracing.
Volumetric rendering in LuisaRender treats participating media such as fog, smoke, and liquids as first-class objects sampled during rendering exactly like surfaces. The system combines optical property evaluation, angular scattering distributions, and nested medium tracking to produce physically accurate light transport through participating media according to the luisagroup/luisarender source code.
Core Concepts: Medium, PhaseFunction, and MediumTracker
The Medium Base Class and Closure Interface
The Medium abstract base class in src/base/medium.h defines the interface for volumetric interactions. It creates a per-ray Closure that encapsulates optical properties including absorption coefficient (σₐ), scattering coefficient (σₛ), and emission (Lₑ). The closure also holds a reference to the PhaseFunction governing angular scattering distributions.
Key implementation details from src/base/medium.h (lines 25-55) show the Medium class providing the build method that constructs medium instances with texture handles for spatially varying properties.
Phase Functions and Angular Scattering
Phase functions describe the probability distribution of scattering directions. In src/phasefunctions/henyey_greenstein.cpp, the Henyey-Greenstein phase function implements the standard anisotropic scattering model used by default in LuisaRender.
The PhaseFunction interface requires three methods:
p(wo, wi)– evaluates the phase function valuesample_p(wo, u)– importance-samples a scattering directionpdf(wo, wi)– returns the probability density
The implementation (lines 28-48 in henyey_greenstein.cpp) computes the phase function value and samples a direction based on the asymmetry parameter g, where g = 0 represents isotropic scattering, g > 0 forward scattering, and g < 0 backward scattering.
Tracking Nested Media with MediumTracker
The MediumTracker class in src/util/medium_tracker.cpp manages nested participating media using a priority-ordered stack. When rays traverse geometry boundaries, the tracker records enter and exit events to determine the current active medium.
Key operations include:
enter(priority, info)– pushes a medium onto the stack (line 51 inmega_volume_path.cpp)exit(priority, info)– removes a medium from the stack (lines 55-58)current()– returns the active medium tag for closure evaluation
The tracker ensures correct handling of overlapping media by maintaining the highest-priority medium at the top of the stack. The vacuum() method indicates whether the ray is in empty space.
Sampling Media in the Rendering Pipeline
Homogeneous Medium Implementation
Concrete medium implementations specialize the abstract interface. The HomogeneousMedium in src/media/homogeneous.cpp represents uniform participating media with constant optical properties throughout the volume.
The _build method constructs texture handles for absorption (sigma_a), scattering (sigma_s), and emission (Le), then creates a HomogeneousMediumInstance that provides the closure method packaging per-ray data:
auto sigma_a = pipeline.build_texture(command_buffer, _sigma_a);
auto sigma_s = pipeline.build_texture(command_buffer, _sigma_s);
auto Le = pipeline.build_texture(command_buffer, _le);
auto phase_fn = pipeline.build_phasefunction(command_buffer, _phase_function);
return luisa::make_unique<HomogeneousMediumInstance>(
pipeline, this, sigma_a, sigma_s, Le, phase_fn);
The instance's closure method (lines 45-55) packages the evaluated textures, wavelength set, and phase function pointer into a HomogeneousMediumClosure.
Distance Sampling and Event Types
The Medium::Closure::sample method implements delta tracking to sample distances and event types. For homogeneous media, the algorithm follows these steps as implemented in HomogeneousMediumClosure::sample:
- Channel selection: Choose a spectral channel proportional to extinction
σₜ = σₐ + σₛ - Distance sampling: Draw
t = -log(1-u) / σₜ[channel]whereuis a uniform random number - Surface hit: If
t > t_max, return transmittanceTr = exp(-σₜ·t) - Medium interaction: Otherwise decide between absorption and scattering using the channel-wise albedo ratio
- Scattering direction: Call the attached
PhaseFunctionto obtain a new direction
Implementation references:
- Channel selection and distance sampling: lines 58-71 of
homogeneous.cpp - Scattering/absorption decision: lines 84-99
- Phase function invocation: line 103 where
phase_function()->sample_p(...)is invoked
Phase Function Sampling
When scattering occurs, the medium closure delegates direction sampling to its attached phase function. The HenyeyGreensteinInstance::sample_p method implements importance sampling for the Henyey-Greenstein phase function:
virtual Float p(Expr<float3> wo, Expr<float3> wi) const = 0;
virtual PhaseFunctionSample sample_p(Expr<float3> wo, Expr<float2> u) const = 0;
virtual Float pdf(Expr<float3> wo, Expr<float3> wi) const = 0;
The implementation (lines 28-48 in henyey_greenstein.cpp) computes the phase function value and samples a direction based on the asymmetry parameter g, where g = 0 represents isotropic scattering, g > 0 forward scattering, and g < 0 backward scattering.
Integrating Volumetric Effects in Path Tracing
The Megakernel Volumetric Path Tracer
The MegakernelVolumetricPathTracing integrator in src/integrators/mega_volume_path.cpp orchestrates the complete volumetric rendering pipeline. This integrator handles both surface and medium interactions within a unified path tracing loop.
The algorithm initializes the MediumTracker with the environment medium, then iterates through geometry intersections while maintaining the medium stack. When the tracker indicates an active medium (!medium_tracker.vacuum()), the integrator samples the medium using sampleT_maj (majorant-based ratio tracking).
Key code locations:
- Environment medium initialization: lines 13-16
- Geometry intersection and tracker updates: lines 34-66
- Majorant sampling entry point: lines 6-27 of the second main loop (starting at line 86)
Handling Medium Events
Within the volumetric sampling loop, the integrator processes four possible medium events:
- Surface hit (
event_surface): The ray reaches a geometry boundary; proceed with surface BSDF sampling - Absorption (
event_absorb): The photon is absorbed; terminate the path or update weights - Scattering (
event_scatter): The photon scatters; sample a new direction using the phase function and update throughput weights (beta,r_u,r_l) - Null collision (
event_null): No interaction occurs; continue tracking
The integrator updates path throughput and Russian roulette weights based on the event type. For scattering events, it calls phase_function()->sample_p to determine the new ray direction, as shown in lines 60-98 of the volumetric sampling block.
Surface handling follows the standard path tracing logic with BSDF evaluation and direct lighting, implemented in lines 30-80 of the second main loop.
Code Examples
Declaring a Homogeneous Medium in JSON
Scene files define participating media using the HomogeneousMedium plugin with constant or textured optical properties:
{
"type": "medium",
"name": "haze",
"plugin": "HomogeneousMedium",
"properties": {
"eta": 1.0,
"sigma_a": { "type": "const", "value": [0.01, 0.01, 0.01] },
"sigma_s": { "type": "const", "value": [0.2, 0.2, 0.2] },
"Le": null,
"phasefunction": { "type": "phasefunction", "plugin": "HenyeyGreenstein", "g": 0.0 }
}
}
This configuration creates an isotropic scattering medium (g = 0) with constant absorption and scattering coefficients. LuisaRender loads this via HomogeneousMedium as implemented in src/media/homogeneous.cpp.
Manual Medium Sampling in C++
Custom integrators can manually sample media by dispatching to the medium instance and invoking the closure interface:
// Assume we have a valid `Pipeline &pipeline`, `Scene *scene`, and a `Ray ray`.
auto medium_tag = pipeline.environment_medium_tag();
pipeline.media().dispatch(medium_tag, [&](auto *medium) noexcept {
auto closure = medium->closure(ray, swl, time); // swl = sampled wavelengths
// Sample a distance and a scattering event
auto sample = closure->sample(t_max, rng);
if (sample.medium_event == Medium::event_scatter) {
// New direction given by the phase function
auto ps = closure->phase_function()->sample_p(-ray->direction(),
make_float2(rng.uniform_float(),
rng.uniform_float()));
// Continue tracing with the new direction...
}
});
Key calls include pipeline.media().dispatch to fetch the medium instance, closure->sample to return a Medium::Sample (see HomogeneousMediumClosure::sample), and closure->phase_function()->sample_p to draw a new direction based on the phase function (see HenyeyGreensteinInstance::sample_p).
Using MediumTracker for Nested Media
The MediumTracker manages nested media in shader-style code:
MediumTracker tracker;
auto env_tag = pipeline.environment_medium_tag();
pipeline.media().dispatch(env_tag, [&](auto *m) {
tracker.enter(m->priority(),
make_medium_info(m->priority(), env_tag));
});
// ... intersect geometry, get a shape with a medium
if (shape.has_medium()) {
auto tag = shape.medium_tag();
pipeline.media().dispatch(tag, [&](auto *m) {
// Enter the inner medium (higher priority overrides the outer one)
tracker.enter(m->priority(),
make_medium_info(m->priority(), tag));
});
}
// Later, to query the current medium:
auto cur = tracker.current(); // cur.medium_tag holds the active medium
The tracker automatically keeps the highest-priority medium on top, and vacuum() indicates whether the ray is in empty space.
Key Source Files
| File | What it Provides | Link |
|---|---|---|
src/base/medium.h |
Abstract medium class, Closure interface, sampling helpers (sampleT_maj) |
medium.h |
src/base/medium.cpp |
Minimal implementation of Medium constructors and build wrapper |
medium.cpp |
src/media/homogeneous.cpp |
Concrete homogeneous medium, texture handling, closure creation | homogeneous.cpp |
src/phasefunctions/henyey_greenstein.cpp |
Henyey-Greenstein phase function implementation | henyey_greenstein.cpp |
src/util/medium_tracker.cpp |
Stack-like data structure for nested media, enter/exit logic | medium_tracker.cpp |
src/integrators/mega_volume_path.cpp |
Full volumetric path-tracer that drives medium sampling, tracker updates, and phase-function usage | mega_volume_path.cpp |
These files together constitute the volumetric rendering pipeline in LuisaRender: media definitions → per-ray closures → phase-function sampling → medium stack handling → integrator's sampling loop.
Summary
- Medium Abstraction: The
Mediumbase class insrc/base/medium.hdefines the closure interface for optical properties (σₐ,σₛ,Lₑ) and phase function attachment. - Phase Functions: The
PhaseFunctioninterface insrc/phasefunctions/henyey_greenstein.cppprovides angular scattering distributions, with Henyey-Greenstein as the default implementation. - Nested Media:
MediumTrackerinsrc/util/medium_tracker.cppmaintains a priority-ordered stack of media for correct handling of overlapping volumes. - Sampling Algorithm:
HomogeneousMediuminsrc/media/homogeneous.cppimplements delta tracking to sample distances and events (absorption, scattering, or surface hit). - Integration: The
MegakernelVolumetricPathTracingintegrator insrc/integrators/mega_volume_path.cpporchestrates the full pipeline, handling medium events and phase function sampling within the path tracing loop.
Frequently Asked Questions
How does LuisaRender handle multiple overlapping media?
LuisaRender uses the MediumTracker class to manage nested media through a priority-ordered stack. When a ray enters a medium, MediumTracker::enter pushes the medium onto the stack; when exiting, MediumTracker::exit removes it. The current() method always returns the highest-priority active medium, ensuring correct light transport calculations when media overlap or contain embedded surfaces.
What phase function does LuisaRender use by default?
The default phase function implementation is the Henyey-Greenstein model defined in src/phasefunctions/henyey_greenstein.cpp. This phase function provides anisotropic scattering controlled by the asymmetry parameter g, where g = 0 produces isotropic scattering, positive values favor forward scattering, and negative values favor backward scattering. The implementation provides analytical evaluation and importance sampling via the sample_p method.
How does the volumetric path tracer decide between surface and medium interactions?
The MegakernelVolumetricPathTracing integrator in src/integrators/mega_volume_path.cpp uses delta tracking via Medium::Closure::sample. The algorithm samples a tentative distance t based on the extinction coefficient σₜ = σₐ + σₛ. If t exceeds the distance to the next surface (t_max), the integrator processes a surface hit; otherwise, it processes a medium event (absorption, scattering, or null collision) at distance t. This decision occurs in the sampling routine implemented in src/media/homogeneous.cpp (lines 58-99).
Can custom media be implemented in LuisaRender?
Yes, developers can implement custom media by inheriting from the Medium base class in src/base/medium.h and implementing the build method to create a specialized Medium::Instance. The instance must provide a closure method that returns a custom Medium::Closure implementation defining sample, transmittance, and phase_function accessors. The homogeneous medium in src/media/homogeneous.cpp serves as the reference implementation for uniform media properties.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →