How to Create Custom BSDF Surfaces for New Material Types in LuisaRender
To create custom BSDF surfaces in LuisaRender, you must implement a Surface class for scene graph integration, an Instance class for interaction handling, and a Closure class containing the actual BSDF evaluation logic using LuisaRender's DSL.
LuisaRender models every material as a surface comprising two distinct components: a Surface class that lives in the scene graph and a Closure class that performs BSDF evaluation during shading. This architecture separates material properties from shading computations, enabling efficient GPU execution through LuisaRender's domain-specific language (DSL). The following guide walks through the complete implementation process using the actual source structure from the luisagroup/luisarender repository.
Understanding the Surface-Closure Architecture
Before writing code, it is essential to understand how LuisaRender separates material representation from shading logic.
The Surface class (defined in src/base/surface.h) acts as a factory and metadata container. It describes the material's capabilities—whether it is reflective, transmissive, or thin—and constructs the runtime instances used during rendering.
The Closure class contains the actual BSDF implementation. It executes on the GPU using LuisaRender's DSL (with types like Expr<float3> and functions like dot(), normalize(), and cosine_sample_hemisphere()). The Closure implements _evaluate() for BSDF value and PDF computation, and _sample() for importance sampling directions.
Step-by-Step Implementation Guide
Step 1: Create a New Surface Class
Create a class that inherits from luisa::render::Surface. This class defines the material's properties and constructs its runtime instance.
Key methods to implement:
properties(): Return a bitmask of capabilities (e.g.,property_reflective,property_transmissive)._build(): Construct and return the Surface's Instance object.
class MyLambertSurface final : public Surface {
public:
explicit MyLambertSurface(Scene *scene, const SceneNodeDesc *desc) noexcept
: Surface{scene, desc} {}
[[nodiscard]] uint properties() const noexcept override {
return property_reflective; // This material reflects light
}
protected:
[[nodiscard]] luisa::unique_ptr<Instance> _build(
Pipeline &pipeline,
CommandBuffer &command_buffer) const noexcept override;
};
Step 2: Define the Surface Instance
The Instance class (derived from Surface::Instance) bridges the Surface and Closure. It prepares the interaction data required for shading.
Critical method:
populate_closure(): Copy geometry and shading frame information into the closure before evaluation.
class MyLambertSurface::Instance final : public Surface::Instance {
public:
using Surface::Instance::Instance; // Inherit constructor
void populate_closure(
Surface::Closure *closure,
const Interaction &it,
Expr<float3> wo,
Expr<float> eta_i) const noexcept final {
// Forward interaction data to the closure base
closure->populate_closure_base(it, wo, eta_i);
}
};
Step 3: Implement the BSDF Closure
The Closure class contains the actual shading logic. It must implement _evaluate() and _sample() using LuisaRender's DSL.
class MyLambertClosure final : public Surface::Closure {
public:
MyLambertClosure(const Pipeline &pipeline,
const SampledWavelengths &swl,
Expr<float> time) noexcept
: Surface::Closure{pipeline, swl, time} {}
private:
// Evaluate BSDF value and PDF
[[nodiscard]] Evaluation _evaluate(
Expr<float3> wo, Expr<float3> wi,
TransportMode mode) const noexcept final {
auto eval = Evaluation::zero(swl().dimension());
// Lambertian BRDF: albedo / π
eval.f = SampledSpectrum{swl().dimension(), 1.0_f} / pi<float>;
eval.pdf = fabs(dot(it().shading().n(), wi)) / pi<float>;
return eval;
}
// Importance sampling
[[nodiscard]] Sample _sample(
Expr<float3> wo,
Expr<float> u_lobe, Expr<float2> u,
TransportMode mode) const noexcept final {
Sample sample = Sample::zero(swl().dimension());
// Cosine-weighted hemisphere sampling
sample.wi = cosine_sample_hemisphere(u);
sample.wi = it().shading().local_to_world(sample.wi);
sample.eval = _evaluate(wo, sample.wi, mode);
sample.event = Surface::event_reflect;
return sample;
}
};
Step 4: Register the Surface Type
To make the material available in scene files, register it in the scene node factory. In src/base/scene.cpp, locate the SceneNodeFactory switch and add:
case hash("my_lambert"):
return luisa::make_unique<MyLambertSurface>(scene, desc);
This maps the string "my_lambert" used in scene descriptions to your C++ class.
Step 5: Add Build System Configuration
Finally, add your new source files to src/surfaces/CMakeLists.txt:
target_sources(luisa_render PRIVATE
surfaces/my_lambert.cpp
# ... other surface sources ...
)
Key Files and Utilities for BSDF Development
When implementing custom BSDF surfaces, reference these core files in the luisagroup/luisarender repository:
| File | Purpose |
|---|---|
src/base/surface.h |
Defines the Surface, Surface::Instance, and Surface::Closure base classes. |
src/base/scene.cpp |
Contains the SceneNodeFactory where you register new surface types. |
src/util/scattering.h |
Provides sampling utilities like cosine_sample_hemisphere and uniform_sample_hemisphere. |
src/util/spec.h |
Defines SampledSpectrum and spectral operations for BSDF evaluation. |
src/surfaces/plastic.cpp |
Reference implementation showing a complete BSDF with roughness and specular components. |
src/surfaces/matte.cpp |
Simple diffuse reference implementation similar to the Lambertian example above. |
Summary
Creating custom BSDF surfaces for new material types in LuisaRender requires implementing three interconnected classes:
- Surface: Defines material properties and constructs the runtime instance via
_build(). - Instance: Prepares shading data through
populate_closure()and handles opacity queries. - Closure: Executes the BSDF algorithm on the GPU using
_evaluate()and_sample()with LuisaRender's DSL.
After implementing these classes, register the new material in src/base/scene.cpp and update src/surfaces/CMakeLists.txt to include the new source files.
Frequently Asked Questions
What is the difference between Surface and Closure in LuisaRender?
The Surface class acts as a host-side factory and metadata container that lives in the scene graph, while the Closure class contains the actual BSDF implementation that executes on the GPU during shading. The Surface creates an Instance that bridges to the Closure, which performs the actual light transport calculations using LuisaRender's DSL.
How do I register a new material type so it can be used in scene files?
Register your material by adding a case to the SceneNodeFactory switch in src/base/scene.cpp. Map a string identifier (e.g., "my_material") to your Surface class's static create method or constructor using luisa::make_unique. This allows scene files to instantiate your material by that name.
Can I reuse existing surface wrappers like opacity or normal mapping with my custom BSDF?
Yes. LuisaRender's architecture allows you to reuse existing surface wrappers such as opacity masks, normal maps, and two-sided shading with your custom BSDF. These wrappers typically operate on the Surface or Instance level, modifying the interaction data before it reaches your Closure's _evaluate() and _sample() methods.
What DSL functions are available for implementing BSDF evaluation?
LuisaRender provides a rich DSL for BSDF implementation including vector math functions (dot(), normalize(), cross(), make_float3()), sampling utilities (cosine_sample_hemisphere(), uniform_sample_hemisphere() from src/util/scattering.h), and spectral types (SampledSpectrum, Expr<float>). The DSL operates on GPU expressions (Expr<T>) rather than immediate values, allowing the compiler to generate efficient GPU kernels.
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 →