Ether

August 10, 2023

A realtime DirectX12 hybrid raytracing renderer

Introduction

Ether is a 3D renderer written with DirectX 12 and C++, meant to be used as a platform for creating and testing graphics rendering techniques.

Features

  • Physically Based Rendering (Physical Light Units, Microfacet BRDFs, Linear Lighting Pipeline)
  • Raytracing with DXR (ReSTIR GI, Pathtracing)
  • Post Processing (TAA, Bloom, Tonemapping, etc.)
  • Render Graph (GPU Resource Management, Bindless Textures, Render Pass Abstractions)
  • Shader Hot Reloading
  • Asset Pipeline with Tool Integration with Matcha Editor

Lighting Model

image

By default, the Unreal Engine 4 BRDF is used as the main lighting model. Ether only supports metallic workflow.

Raytracing (DXR)

As a hybrid renderer, much of the lighting in Ether is raytraced.

ReSTIR GI

Global illumination in Ether is handled with ReSTIR GI.

History Clamping

A minor modification to the temporal resampling is done to fix a bug where old samples can get stuck in the reservoir leading to issues with dynamic lighting.

The paper proposed clamping to address this issue:

We clamp the value of in the temporal reservoirs to 30 and in the spatial reservoirs to 500 so that the reservoirs do not become stuck with a particular sample and be unlikely to replace it as grows large.

The temporal resampling algorithm is described as:

Original Temporal Resampling
for each pixel q do
    S ← InitialSampleBuffer[q]
    R ← TemporalReservoirBuffer[q]
    w ← pˆq(S)/pq(S)
    R.UPDATE(S,w)
    R.W ← R.w/(R.M · pˆ(R.z))
    TemporalReservoirBuffer[q] ← R

Clamping is only mathematically correct if the correct is clamped. Well, which should be clamped? Unfortunately, the only in the above algorithm is the reciprocal of the RIS weight , which definitely does not make sense to be clamped.

Instead, the that is multiplied into the weight when merging a reservoir should be clamped:

The reasoning goes back to why we need to multiply here in the first place: is used to modulate the weight of a reservoir in order to retain the knowledge that this could potentially be a much better sample since it could have been the result of proposal samples.

In other words, we actually should flip how the initial and temporal reservoirs are combined:

Updated Temporal Resampling
for each pixel q do
    S ← InitialSampleBuffer[q]
    R ← TemporalReservoirBuffer[q]
    w ← pˆq(S)/pq(S)
    S.MERGE(R, p^q(R.z))
    S.W ← S.w/(S.M · pˆ(S.z))
    TemporalReservoirBuffer[q] ← S

Note that internally, MERGE calls UPDATE with the PDF of p^q(R.z) · R.W · min(R.M, MAX_HISTORY).

This makes it such that it clamping is actually meaningful since historical samples will be underweighed during reservoir merge, rather than clamping it for a single new sample, where M will always be 1. This prevents old samples from being stuck in the reservoir, which leads to phantom lights with dynamic lighting.

Specular Support

Some modifications were also made to better accomodate specular highlights:

  • The proposal samples are drawn by importance sampling the UE4 BRDF, rather than uniform hemisphere
  • The final evaluated outgoing radiance is used as the target PDF , rather than just incoming radiance

These modifications allow ReSTIR GI to work quite well, even with highly specular surfaces.

Sampling the UE4 BRDF

Ether implements Next Event Estimation (NEE) and multiple importance sampling (MIS) at each vertex as its primary sampling strategy.

When sampling the UE4 BRDF, there are two terms that comes into play, the diffuse and specular terms. These two terms have very different contributions to the final radiance and their sampling methods are also different. The diffuse term can be sampled by sampling a cosine hemisphere around the normal, and the specular term can be sampled by importance sampling GGX.

For the sake of performance, each term is weighted and only one term is sampled at each vertex. The weights are computed as follows:

const float diffuseWeight = lerp(lerp(0.5, 1.0, roughness), 0.0, metalness);
const float specularWeight = 1.0 - diffuseWeight;

This choice derives from the observation that the diffuse and specular contributions can directly be derived from the roughness and metalness parameters.

Since the weights sum to one, it can safely be used as an unbiased MIS estimator.

Lighting Pipeline

Ether's entire pipeline is linear and gamma-corrected at the end with an sRGB frame buffer.

  1. sRGB textures are first linearized and have mips generated
  2. Linearized textures are used with PBR lights (physical light units) to compute lighting in DXGI_FORMAT_R32G32B32A32_FLOAT textures.
  3. Post-processing is performed
  4. Exposure adjustment + Tonemapping is performed
  5. The final image is gamma corrected by outputting to a DXGI_FORMAT_R8G8B8A8_UNORM_SRGB frame buffer.

Post Process Effects

Temporal AA

Ether relies on temporal AA with variance clipping to denoise the indirect and specular lighting.

PBR Bloom

Since the entire lighting pipeline is physically based and photometric units are used for light sources, threshold-less PBR bloom is trivially implemented:

Ether's implementation of Bloom uses Dual Filtering for downsampling and blurring of the HDR color buffer.

Misc Features

Render Graph

Even as a small renderer, the complexity of managing various render passes and resources can become unmanageable quickly. The render graph system in Ether addresses this by automatically handling resource and descriptor creation, renderpass scheduling, PSO management, etc. The system is largely inspired by Ubisoft's Producer System.

Producer System

A producer is the producer of one or more graphic resources. For example, the gbufferproducer produces all the textures in the gbuffer pass. For example:

gbufferproducer.cpp
DEFINE_GFX_PA(GBufferProducer)
DEFINE_GFX_DS(GBufferDepthStencil)
DEFINE_GFX_RT(GBufferTexture0)
DEFINE_GFX_RT(GBufferTexture1)
DEFINE_GFX_RT(GBufferTexture2)

DECLARE_GFX_CB(GlobalRingBuffer)
DECLARE_GFX_SR(MaterialTable)

This producer declares that it will produce 3 render targets and one depth stencil target. At the same time, it also consumes a constant buffer and a shader resource from other producers.

These resource usages can then be registered:

gbufferproducer.cpp
void Ether::Graphics::GBufferProducer::GetInputOutput(ScheduleContext& schedule, ResourceContext& rc)
{
    ethVector2u resolution = GraphicCore::GetGraphicConfig().GetResolution();

    schedule.NewDS(ACCESS_GFX_DS(GBufferDepthStencil), resolution.x, resolution.y, DepthBufferFormat);
    schedule.NewRT(ACCESS_GFX_RT(GBufferTexture0), resolution.x, resolution.y, RhiFormat::R8G8B8A8Unorm);
    schedule.NewRT(ACCESS_GFX_RT(GBufferTexture1), resolution.x, resolution.y, RhiFormat::R32G32B32A32Float);
    schedule.NewRT(ACCESS_GFX_RT(GBufferTexture2), resolution.x, resolution.y, RhiFormat::R16G16B16A16Float);

    schedule.Read(ACCESS_GFX_CB(GlobalRingBuffer));
    schedule.Read(ACCESS_GFX_SR(MaterialTable));
}

The declaration of usage does not actually create any GPU resources. The render graph system will eventually process all producers, determine their dependencies, and then schedule them as render passes. Only then can resources be created/recreated since the resource usage information for the frame is fully known at that point.

Shader Hot Reloading

Shader hot reloading (and subsequently PSO recompiling and caching) is supported. With the -shaderdaemon command line argument, any shader files can be saved during runtime and be refreshed.

Matcha Editor

Ether is also integrated with Matcha Editor. The two programs communicate over TCP and their behaviours are fully data-driven.

Final Thoughts

Ether is still very much a small project that I'm actively working on, so features and implementation detail can change drastically over time. That said, building it has been an invaluable experience for me and has enabled a platform for rapid prototyping of rendering techniques that I come across.

You can check out the full source code for Ether here.