How to Implement Custom Filters for Image Post-Processing in LuisaRender

Implement a custom image post-processing filter in LuisaRender by subclassing luisa::render::Filter, overriding the evaluate() method to define the kernel function, and registering the implementation with LUISA_RENDER_REGISTER_FILTER to make it selectable in scene files.

LuisaRender treats pixel reconstruction and tone-mapping kernels as first-class Filter scene nodes that are sampled per-pixel during camera ray generation. To implement custom filters for image post-processing in LuisaRender, you extend the abstract base class defined in src/base/filter.h, provide the kernel evaluation logic, and expose the filter through the scene description JSON. This guide covers the architecture, implementation steps, and registration process using actual source paths from the luisagroup/luisarender repository.

Filter Architecture and Runtime Flow

The filter system in LuisaRender separates the scene-node definition from the GPU-side sampling instance. Understanding this separation is critical for implementing custom kernels correctly.

Core Components

  • luisa::render::Filter (abstract base): Defined in src/base/filter.h, this class specifies the filter's radius, shift, and the pure virtual evaluate(x) kernel function. It also declares the build() method that creates a runtime instance.
  • Filter::Instance: Implemented in src/base/filter.cpp, this holds a 64-entry lookup-table (LUT) and alias-sampling tables for fast GPU evaluation. It provides the sample(u) method that returns a sub-pixel offset and weight for a 2-D random sample.
  • Pipeline::build_filter: Located in src/base/pipeline.cpp, this method caches built filter instances in a _filters map, ensuring each unique filter node is constructed only once per render.
  • Camera: As implemented in src/base/camera.cpp, the camera holds a pointer to a Filter node and calls filter()->sample(u_filter) during ray generation to obtain sampling offsets.

Typical Execution Flow

  1. Scene parsing: Scene::load_filter (in src/base/scene.cpp) reads the "filter" node from JSON/YAML and constructs the appropriate derived Filter object.
  2. Pipeline build: Pipeline::build_filter invokes Filter::build, which constructs a Filter::Instance containing the precomputed LUT and alias tables.
  3. Camera sampling: During ray generation, the camera draws a 2-D uniform sample u_filter and calls filter()->sample(u_filter) to retrieve the offset and weighting for that pixel.

Creating a Custom Filter Implementation

To add a new reconstruction kernel, create a header and implementation pair in src/filters/, such as myfilter.h and myfilter.cpp.

Header Definition

The header must inherit from Filter and implement the evaluate() method. You may optionally override build() if your filter requires custom GPU resources beyond the standard LUT.

// src/filters/myfilter.h
#pragma once
#include <base/filter.h>

namespace luisa::render {

class MyFilter final : public Filter {
public:
    explicit MyFilter(Scene *scene, const SceneNodeDesc *desc) noexcept
        : Filter{scene, desc} {
        // Read custom parameters from the scene description
        _radius = desc->property_float_or_default("radius", 1.5f);
    }

    // Kernel evaluation: x is distance from center normalized to [0,1]
    [[nodiscard]] float evaluate(float x) const noexcept override {
        const float sigma = 2.0f;
        const float s = x * sigma;
        if (s < 1.0f) {
            return 1.0f - 2.0f * s * s + s * s * s;
        }
        if (s < 2.0f) {
            const float t = 2.0f - s;
            return t * t * t;
        }
        return 0.0f;
    }

    // Optional: override only if you need custom GPU resources
    [[nodiscard]] luisa::unique_ptr<Instance> build(
        Pipeline &pipeline, CommandBuffer &command_buffer) const noexcept override {
        return Filter::build(pipeline, command_buffer); // Use base implementation
    }
};

}// namespace luisa::render

Implementation and Registration

The implementation file registers the filter with the scene-node factory using the LUISA_RENDER_REGISTER_FILTER macro.

// src/filters/myfilter.cpp
#include <filters/myfilter.h>

namespace luisa::render {

LUISA_RENDER_REGISTER_FILTER("MyFilter", MyFilter)

}// namespace luisa::render

Key Implementation Requirements

  • Base class: Must inherit from luisa::render::Filter.
  • Constructor signature: Must accept (Scene *scene, const SceneNodeDesc *desc) to receive scene context and property access.
  • evaluate(): Must return non-negative weights and handle the normalized distance x such that the function returns zero for |x| > 1.0 (i.e., outside the radius).
  • build(): Most filters can rely on the default base implementation in src/base/filter.cpp. Override only to allocate additional GPU buffers or textures.
  • Registration: The LUISA_RENDER_REGISTER_FILTER("<ImplName>", <Class>) macro (defined in the scene registration system) makes the filter selectable via the "impl" string in scene files.

Registering and Configuring the Filter

Once registered, the filter is available in scene descriptions using the "filter" node within compatible scene elements like cameras.

Scene JSON Configuration

Specify the filter implementation name and any custom parameters defined in your constructor:

{
  "camera": {
    "type": "Perspective",
    "filter": {
      "impl": "MyFilter",
      "radius": 2.0
    }
  }
}

The parser in src/base/scene.cpp reads the "impl" field, looks up the registered constructor, and passes the node description to your filter's constructor.

Runtime Sampling and GPU Integration

When the pipeline builds your filter, the base class automatically handles performance-critical sampling infrastructure.

LUT and Alias Sampling Construction

During Filter::build (in src/base/filter.cpp), the system:

  1. Samples your evaluate(x) function uniformly over [0, radius] to populate a 64-entry LUT (_lut).
  2. Constructs discrete alias tables (_pdf, _alias_probs, _alias_indices) enabling O(1) importance sampling of the kernel distribution.

Camera Integration

At render time, the camera (in src/base/camera.cpp) calls Instance::sample(u) with a 2-D uniform random sample u. This method uses the alias table to select a LUT entry and returns the corresponding sub-pixel offset and weight, applying your custom kernel without per-sample kernel evaluation overhead.

Summary

  • Filters are scene nodes: They are parsed from JSON, built into GPU instances by the pipeline, and sampled by the camera during ray generation.
  • Minimal implementation: Subclass Filter, implement evaluate() for the kernel function, and use the base class build() method unless you need custom GPU resources.
  • Registration: Use LUISA_RENDER_REGISTER_FILTER("<Name>", <Class>) in your .cpp file to expose the filter to the scene loader.
  • Automatic optimization: The base class handles LUT generation and alias sampling in src/base/filter.cpp, providing fast GPU sampling without additional code.
  • Scene usage: Reference your filter in the camera configuration using "impl": "<Name>" and any custom properties defined in your constructor.

Frequently Asked Questions

What is the difference between the Filter class and Filter::Instance?

The Filter class (defined in src/base/filter.h) is the scene-side node that holds construction parameters and the evaluate() kernel definition. The Filter::Instance (created in src/base/filter.cpp) is the GPU-side runtime object that holds precomputed lookup tables and implements the sample() method used during rendering. The pipeline caches instances via Pipeline::build_filter to avoid redundant construction.

How does the radius parameter affect filter behavior?

The radius determines the spatial support of the filter in world units. In your constructor, you read this from the scene description via desc->property_float_or_default(). The evaluate(x) method receives x normalized to [0,1], where 1.0 corresponds to the full radius. The filter must return 0.0 when x > 1.0 to indicate no contribution outside the support region.

Can I use textures or buffers in my custom filter?

Yes. If your filter requires additional GPU resources (e.g., a pre-computed kernel texture or neural network weights), override the build() method in your header. Instead of calling Filter::build(), allocate your resources using the provided Pipeline and CommandBuffer, then construct and return a custom Instance subclass that holds these resources. Most simple kernels can rely on the default base implementation.

Where should I place the LUISA_RENDER_REGISTER_FILTER macro?

Place the macro in your .cpp implementation file, not in the header. This ensures the registration occurs exactly once during linkage. Existing filters like Box, Gaussian, and Mitchell in src/filters/ follow this pattern; examine src/filters/gaussian.cpp for a reference implementation. The macro registers your class with the scene loader in src/base/scene.cpp, enabling lookup by the "impl" string.

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 →