Sampling Strategies in luisa-render: Sobol, PMJ02, and Custom Implementation

luisa-render provides five built-in sampling strategies—including Sobol, ZSobol, PaddedSobol, Independent, and PMJ02BN—and exposes an abstract Sampler interface in src/base/sampler.h that allows developers to implement custom low-discrepancy generators by subclassing Sampler and Sampler::Instance.

The luisa-render framework ships with a flexible, GPU-accelerated sampling system for generating quasi-random numbers required by path tracing integrators. Understanding these sampling strategies and their implementation patterns is essential for optimizing rendering convergence and noise characteristics. This article examines the built-in samplers and provides a complete guide to extending the system with custom variants.

Built-in Sampling Strategies

luisa-render implements both low-discrepancy sequences and independent random generators. All classes inherit from luisa::render::Sampler defined in src/base/sampler.h.

SobolSampler

The SobolSampler generates the classic Sobol sequence with optional Owen scrambling, operating on a per-pixel basis with support for arbitrary dimensions. The core logic resides in src/samplers/sobol.cpp, where the template call _sobol_sample<true> applies scrambling to raw Sobol values, and _sobol_interval_to_index converts pixel intervals to sequence indices.

ZSobolSampler

The ZSobolSampler variant optimizes memory locality for 2D sampling by storing Sobol matrices in pairs (2 × SobolMatrixSize). As implemented in src/samplers/zsobol.cpp, the constructor copies matrices using the command buffer syntax: command_buffer << _sobol_matrices.copy_from(SobolMatrices32);.

PaddedSobolSampler

To prevent out-of-bounds memory access when dimension indices wrap around, the PaddedSobolSampler adds one extra column to the Sobol matrix. This safety padding is defined in src/samplers/padded_sobol.cpp.

IndependentSampler

For pure Monte-Carlo sampling without correlation, the IndependentSampler utilizes a per-pixel XorShift RNG. This generator produces statistically independent random numbers and is implemented in src/samplers/independent.cpp.

PMJ02BNSampler

The PMJ02BNSampler provides Progressive Multi-Jittered blue-noise sampling using pre-computed tables (PMJ02bnSamples) supporting up to 65,536 samples per pixel. The main sampling logic appears in src/samplers/pmj02bn.cpp, while the lookup tables are defined in src/util/pmj02tables.cpp and exposed via src/util/pmj02tables.h.

Implementing a Custom Sampler

Adding a new sampling strategy requires implementing two classes: a factory Sampler subclass and a stateful Sampler::Instance subclass. The factory creates GPU resources, while the instance generates samples per pixel.

1. Define the Sampler Subclass

Create a new .cpp file in src/samplers/ that subclasses Sampler and implements the build factory method and impl_type identifier:

class MySampler final : public Sampler {
public:
    MySampler(Scene *scene, const SceneNodeDesc *desc) noexcept 
        : Sampler{scene, desc} {}
    [[nodiscard]] luisa::unique_ptr<Instance> 
    build(Pipeline&, CommandBuffer&) const noexcept override;
    [[nodiscard]] luisa::string_view impl_type() const noexcept override { 
        return LUISA_RENDER_PLUGIN_NAME; 
    }
};

Reference src/samplers/sobol.cpp lines 15-22 for the canonical pattern.

2. Implement the Instance Interface

The Sampler::Instance subclass must implement virtual methods called by integrators each frame:

  • reset – Allocate or resize GPU buffers, compute lookup tables, and store resolution-dependent data
  • start – Set the pixel location and sample index for the current pixel
  • save_state / load_state – Serialize the internal RNG index and per-pixel counters so progressive rendering can resume
  • generate_1d and generate_2d – Return uniformly distributed values (or low-discrepancy values) in [0,1)

See src/samplers/sobol.cpp lines 31-78 for a complete implementation of these methods. For table-lookup sampling patterns, reference src/samplers/pmj02bn.cpp around line 50.

3. Register the Plugin

Expose the sampler to the scene graph using the registration macro at the bottom of your source file:

LUISA_RENDER_MAKE_SCENE_NODE_PLUGIN(luisa::render::MySampler)

This macro is used at the bottom of every built-in sampler, such as src/samplers/sobol.cpp line 184.

4. Update the Build System

Add the new source file to src/samplers/CMakeLists.txt to ensure it compiles and links with the rest of the framework.

Complete Custom Sampler Example

The following implementation demonstrates a minimal uniform random sampler using XorShift hashing. It allocates a state buffer in reset, seeds the RNG by pixel index in start, and generates 1D/2D samples:

// file: src/samplers/my_uniform.cpp
#include <base/sampler.h>
#include <base/pipeline.h>
#include <dsl/sugar.h>

namespace luisa::render {

class MyUniformSampler final : public Sampler {
public:
    MyUniformSampler(Scene *scene, const SceneNodeDesc *desc) noexcept
        : Sampler{scene, desc} {}
    [[nodiscard]] luisa::unique_ptr<Instance>
    build(Pipeline &pipeline, CommandBuffer &command_buffer) const noexcept override;
    [[nodiscard]] luisa::string_view impl_type() const noexcept override {
        return LUISA_RENDER_PLUGIN_NAME;
    }
};

class MyUniformSamplerInstance final : public Sampler::Instance {
private:
    uint2 _resolution;
    uint _pixel_dim{};
    Buffer<uint4> _state_buf;

public:
    explicit MyUniformSamplerInstance(const Pipeline &pipeline,
                                     CommandBuffer &cmd,
                                     const MyUniformSampler *s) noexcept
        : Sampler::Instance{pipeline, s} {
        _state_buf = pipeline.device().create_buffer<uint4>(1);
    }

    void reset(CommandBuffer &, uint2 resolution,
               uint state_count, uint) noexcept override {
        _resolution = resolution;
        if (!_state_buf || _state_buf.size() < state_count) {
            _state_buf = pipeline().device().create_buffer<uint4>(next_pow2(state_count));
        }
    }

    void start(Expr<uint2> pixel, Expr<uint>) noexcept override {
        _pixel_dim = pixel.x + pixel.y * _resolution.x;
    }

    void save_state(Expr<uint>) noexcept override {}
    void load_state(Expr<uint>) noexcept override {}

    [[nodiscard]] Float generate_1d() noexcept override {
        auto state = make_uint4(_pixel_dim, 0u, 0u, 0u);
        auto r = xxhash32(state.xyz());
        return cast<float>(r) * (1.f / 0xffffffffu);
    }

    [[nodiscard]] Float2 generate_2d() noexcept override {
        Float x = generate_1d();
        Float y = generate_1d();
        return make_float2(x, y);
    }
};

luisa::unique_ptr<Sampler::Instance>
MyUniformSampler::build(Pipeline &p, CommandBuffer &c) const noexcept {
    return luisa::make_unique<MyUniformSamplerInstance>(p, c, this);
}

LUISA_RENDER_MAKE_SCENE_NODE_PLUGIN(luisa::render::MyUniformSampler)

} // namespace luisa::render

Using Samplers in Scene Descriptions

Integrators retrieve the active sampler via scene->sampler() and interact only with the abstract Sampler::Instance interface. This design allows you to switch between sampling strategies without code modifications:

// In your integrator (e.g., src/integrators/megasampler.cpp)
auto sampler = scene->sampler();
sampler->reset(command_buffer, resolution, state_count, spp);
for (uint2 pixel : image_pixels) {
    sampler->start(pixel, frame_index);
    Float2 u = sampler->generate_2d();
    // Use u for importance sampling...
}

Configure the specific sampler declaratively in your scene YAML:

render:
  sampler:
    impl: Sobol
    seed: 12345

Valid impl values include Sobol, ZSobol, PaddedSobol, Independent, and PMJ02BN.

Summary

  • luisa-render provides five built-in sampling strategies: SobolSampler, ZSobolSampler, PaddedSobolSampler, IndependentSampler, and PMJ02BNSampler
  • All samplers inherit from Sampler in src/base/sampler.h and implement the Sampler::Instance interface
  • Required implementation methods: reset, start, save_state, load_state, generate_1d, and generate_2d
  • Register custom samplers with LUISA_RENDER_MAKE_SCENE_NODE_PLUGIN and add them to src/samplers/CMakeLists.txt
  • Integrators call the virtual interface, allowing hot-swapping between Sobol, PMJ02BN, or custom samplers via scene configuration

Frequently Asked Questions

What is the difference between SobolSampler and ZSobolSampler?

ZSobolSampler stores Sobol matrices in pairs (2 × matrix size) to improve GPU memory locality when performing 2D sampling operations, whereas SobolSampler uses the standard matrix layout optimized for arbitrary dimensions. Both are implemented in src/samplers/sobol.cpp and src/samplers/zsobol.cpp respectively.

How many samples per pixel does PMJ02BNSampler support?

The PMJ02BNSampler supports up to 65,536 samples per pixel using pre-computed tables defined in src/util/pmj02tables.cpp. These tables provide progressive multi-jittered blue-noise values without runtime generation overhead.

Do I need to modify integrator code when switching between samplers?

No. Integrators interact with the abstract Sampler::Instance interface through methods like generate_2d(), reset(), and start(). You can switch between Sobol, PMJ02BN, or custom samplers by changing the impl field in the scene description YAML without recompiling integrator code.

Where are the Sobol direction numbers defined?

The Sobol matrices (SobolMatrices32, VdCSobolMatrices) are declared in src/util/sobolmatrices.h and defined in src/util/sobolmatrices.cpp. These constants are used by SobolSampler and its variants to generate low-discrepancy sequences.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →