FusLite Engine

FusLite Engine

FusLite Engine

Physically based real-time renderer built from scratch in Vulkan 1.3 with a modern Forward+ pipeline. Developed and profiled on an NVIDIA RTX 2070 SUPER.


Contributions:

  • Physically Based Shading

  • Image-Based Lighting

  • Clustered Forward+ Lighting

  • PCSS Soft Shadows / Stylized Shadow Penumbra authoring

  • Temporal Antialiasing

  • Screen-Space Ambient Occlusion

  • Configurable Tonemapping with async auto-exposure


— Github Repo Link —

Physically based real-time renderer built from scratch in Vulkan 1.3 with a modern Forward+ pipeline. Developed and profiled on an NVIDIA RTX 2070 SUPER.


Contributions:

  • Physically Based Shading

  • Image-Based Lighting

  • Clustered Forward+ Lighting

  • PCSS Soft Shadows / Stylized Shadow Penumbra authoring

  • Temporal Antialiasing

  • Screen-Space Ambient Occlusion

  • Configurable Tonemapping with async auto-exposure


— Github Repo Link —

Physically based real-time renderer built from scratch in Vulkan 1.3 with a modern Forward+ pipeline. Developed and profiled on an NVIDIA RTX 2070 SUPER.


Contributions:

  • Physically Based Shading

  • Image-Based Lighting

  • Clustered Forward+ Lighting

  • PCSS Soft Shadows / Stylized Shadow Penumbra authoring

  • Temporal Antialiasing

  • Screen-Space Ambient Occlusion

  • Configurable Tonemapping with async auto-exposure


— Github Repo Link —

Type

C++, PBR, Clustered Forward+, Vulkan 1.3

Type

C++, PBR, Clustered Forward+, Vulkan 1.3

Profilers

RenderDoc, Nvidia NSight

Profilers

RenderDoc, Nvidia NSight

Real-Time Simulation of Bio-luminescent Light Propagation using Compute Shaders within Unreal Engine 5

Real-Time Simulation of Bio-luminescent Light Propagation using Compute Shaders within Unreal Engine 5

Figure 1: FusLite Engine in-game demo video

Figure 1: FusLite Engine in-game demo video

Background

Background

FusLite uses clustered Forward+ shading with a compute prepass that bins lights into a 3D cluster grid, so shading cost scales with lights for each cluster rather than total light count. Measured on the hero scene at 128 point lights, this took lighting from 4.5 ms to 1.2 ms. Forward shading keeps transparency straightforward and avoids the full G-buffer read/write of a deferred pipeline, while still maintaining deferred's light-count scaling.

FusLite uses clustered Forward+ shading with a compute prepass that bins lights into a 3D cluster grid, so shading cost scales with lights for each cluster rather than total light count. Measured on the hero scene at 128 point lights, this took lighting from 4.5 ms to 1.2 ms. Forward shading keeps transparency straightforward and avoids the full G-buffer read/write of a deferred pipeline, while still maintaining deferred's light-count scaling.

Graphics Pipeline

Graphics Pipeline

Forward shading computes lighting inline and discards everything it used, leaving screen space effects like ambient occulusion and reflections with nothing to read. A full G-buffer would solve this, but it pays deferred's read/write bandwidth on every channel including the ones nothing consumes. Instead I decided to write only the channels the screen-space passes actually touch, depth, geometric normal, and material as extra attachments on the forward pass itself, reducing the cost to a handful of additional MRT writes.


That decision shapes the whole frame. It opens with a compute prepass that builds the cluster grid, computing an AABB per cluster, then culls the scene's lights into per-cluster index lists so the forward shader only iterates lights that can actually reach it. A depth-only shadow pass renders the sun's view into a single fitted orthographic map. Both feed the scene forward pass, which rasterizes the scene and writes HDR color alongside the motion, normal, material, and depth targets the later passes depend on.


From there a second compute pass reduces depth into a min/max Hi-Z pyramid, which lets SSR skip empty space in large strides instead of stepping uniformly. SSR marches that pyramid and falls back to the IBL cubemap where the screen runs out. Composite folds both into the HDR target and blurs AO inline rather than paying for a standalone blur pass, TAA resolves against the reprojected history, and tonemap maps the result to the swapchain. Exposure sits off to the side rather than in the chain since it meters the resolved HDR image through an async readback and applies at tonemap, a frame or two behind, which is what keeps metering off the critical path. ImGui draws last and presents as the final step.

Forward shading computes lighting inline and discards everything it used, leaving screen space effects like ambient occulusion and reflections with nothing to read. A full G-buffer would solve this, but it pays deferred's read/write bandwidth on every channel including the ones nothing consumes. Instead I decided to write only the channels the screen-space passes actually touch, depth, geometric normal, and material as extra attachments on the forward pass itself, reducing the cost to a handful of additional MRT writes.


That decision shapes the whole frame. It opens with a compute prepass that builds the cluster grid, computing an AABB per cluster, then culls the scene's lights into per-cluster index lists so the forward shader only iterates lights that can actually reach it. A depth-only shadow pass renders the sun's view into a single fitted orthographic map. Both feed the scene forward pass, which rasterizes the scene and writes HDR color alongside the motion, normal, material, and depth targets the later passes depend on.


From there a second compute pass reduces depth into a min/max Hi-Z pyramid, which lets SSR skip empty space in large strides instead of stepping uniformly. SSR marches that pyramid and falls back to the IBL cubemap where the screen runs out. Composite folds both into the HDR target and blurs AO inline rather than paying for a standalone blur pass, TAA resolves against the reprojected history, and tonemap maps the result to the swapchain. Exposure sits off to the side rather than in the chain since it meters the resolved HDR image through an async readback and applies at tonemap, a frame or two behind, which is what keeps metering off the critical path. ImGui draws last and presents as the final step.

Figure 2: FusLite Components of Graphics Pipeline Diagram

Figure 2: FusLite Components of Graphics Pipeline Diagram

Percentage Closer Soft Shadows (PCSS)

Percentage Closer Soft Shadows (PCSS)

A single-tap shadow map produces a hard edge at every distance, which isn't physically accurate. Real shadows sharpen at the contact point and widen as the caster moves away from the receiver, because the light source has area. To solve this a blocker search samples the shadow map around the fragment to find the average depth of occluders between it and the light. That average feeds a penumbra width estimate from the similar triangles relationship between light size, blocker distance, and receiver distance. The filter then runs a Poisson disk PCF kernel scaled to that width, with the disk rotated per pixel by a hash of screen position so the sample pattern breaks into noise rather than banding. This produces a soft shadow, a much physically closer result of shadows to a light emitter.


Since the blocker search already produces a penumbra width per pixel, that value is available for more than filtering. Mapping it through a color ramp gives direct artistic control over penumbra tint. Allowing for authoring over a solid color, gradient, and mask by a texture (halftone, crosshatch)

A single-tap shadow map produces a hard edge at every distance, which isn't physically accurate. Real shadows sharpen at the contact point and widen as the caster moves away from the receiver, because the light source has area. To solve this a blocker search samples the shadow map around the fragment to find the average depth of occluders between it and the light. That average feeds a penumbra width estimate from the similar triangles relationship between light size, blocker distance, and receiver distance. The filter then runs a Poisson disk PCF kernel scaled to that width, with the disk rotated per pixel by a hash of screen position so the sample pattern breaks into noise rather than banding. This produces a soft shadow, a much physically closer result of shadows to a light emitter.


Since the blocker search already produces a penumbra width per pixel, that value is available for more than filtering. Mapping it through a color ramp gives direct artistic control over penumbra tint. Allowing for authoring over a solid color, gradient, and mask by a texture (halftone, crosshatch)

Figure 3: PCSS Soft Shadows with/without Custom Shadow Penumbra Enabled

Figure 3: PCSS Soft Shadows with/without Custom Shadow Penumbra Enabled

Automotive Clearcoat Material

Automotive Clearcoat Material

Car paint contains a pigmented base layer, a scattering of metallic flakes suspended in it, and a smooth clearcoat over the top. Rendering it with just a single GGX lobe produces a surface that resembles plastic. Instead the base layer is Cook-Torrance with GGX distribution, Smith geometry, and Fresnel-Schlick, driven by the standard metallic-roughness inputs. Flakes are a per-pixel normal perturbation applied to the base lobe only. Since the perturbation is high frequency and the base lobe is fairly smooth, it results in bright specular points that shift as the camera moves, resembling metallic flakes.

Car paint contains a pigmented base layer, a scattering of metallic flakes suspended in it, and a smooth clearcoat over the top. Rendering it with just a single GGX lobe produces a surface that resembles plastic. Instead the base layer is Cook-Torrance with GGX distribution, Smith geometry, and Fresnel-Schlick, driven by the standard metallic-roughness inputs. Flakes are a per-pixel normal perturbation applied to the base lobe only. Since the perturbation is high frequency and the base lobe is fairly smooth, it results in bright specular points that shift as the camera moves, resembling metallic flakes.

Figure 4: Automotive Clearcoat Snapshot on Shaderball Mesh

Figure 4: Automotive Clearcoat Snapshot on Shaderball Mesh

Screen Space Reflections (SSR)

Screen Space Reflections (SSR)

The first traversal implementation marched in view space, reprojecting to screen space at every step to sample depth. That cost a perspective divide per step, and the non-uniform screen-space step size meant more iterations before a hit. The current implementation projects both ray endpoints into screen space up front, so the ray is a straight line with the divide computed once at setup.


On top of that, traversal walks a min/max depth pyramid rather than stepping uniformly. The hierarchy lets the ray skip empty space in large strides and descend only where geometry might be, which is where the iteration savings actually come from. This choice outperforms other traversal schemes such as linear marching, conservative DDA, and more.

The first traversal implementation marched in view space, reprojecting to screen space at every step to sample depth. That cost a perspective divide per step, and the non-uniform screen-space step size meant more iterations before a hit. The current implementation projects both ray endpoints into screen space up front, so the ray is a straight line with the divide computed once at setup.


On top of that, traversal walks a min/max depth pyramid rather than stepping uniformly. The hierarchy lets the ray skip empty space in large strides and descend only where geometry might be, which is where the iteration savings actually come from. This choice outperforms other traversal schemes such as linear marching, conservative DDA, and more.

Figure 5: Before/After Comparison of SSR on Shaderball

Figure 5: Before/After Comparison of SSR on Shaderball

Screen Space Ambient Occlusion (SSAO)

Screen Space Ambient Occlusion (SSAO)

SSAO samples a hemisphere of points oriented along the fragment's normal from the auxiliary buffer, projects each into screen space, and compares its depth against the depth buffer to count how many are occluded. The kernel is distributed to bias samples toward the origin, so nearby occluders contribute more than distant ones, and a range check rejects comparisons across large depth discontinuities to avoid haloing at silhouettes.


Raw SSAO is noisy, and the usual fix is a dedicated blur pass. Here the blur is folded into the composite pass instead. Composite already reads AO and depth to combine SSR and occlusion into the HDR target, so running a depth-weighting blur inline reuses those fetches rather than paying a separate full-screen pass with its own bandwidth and barriers.

SSAO samples a hemisphere of points oriented along the fragment's normal from the auxiliary buffer, projects each into screen space, and compares its depth against the depth buffer to count how many are occluded. The kernel is distributed to bias samples toward the origin, so nearby occluders contribute more than distant ones, and a range check rejects comparisons across large depth discontinuities to avoid haloing at silhouettes.


Raw SSAO is noisy, and the usual fix is a dedicated blur pass. Here the blur is folded into the composite pass instead. Composite already reads AO and depth to combine SSR and occlusion into the HDR target, so running a depth-weighting blur inline reuses those fetches rather than paying a separate full-screen pass with its own bandwidth and barriers.

Figure 6: Before/After Comparison of SSAO on McLaren Model

Figure 6: Before/After Comparison of SSAO on McLaren Model

Temporal Anti Aliasing (TAA)

Temporal Anti Aliasing (TAA)

TAA jitters the projection matrix by a sub-pixel offset each frame along a Halton(2,3) sequence, so the same geometry is sampled at different points within each pixel over time. The jitter is applied only to the projection used for rasterization and excluded from motion vector computation, since including it would inject the offset into every velocity and smear the entire image.


Accumulation happens against a ping-pong history target, reprojected with per-pixel motion vectors, but this alone isn't sufficient. History that was valid last frame may be invalid now because it belongs to geometry that is newly occluded or disoccluded. Two mechanisms handle this. Neighborhood variance clipping builds an AABB from the current frame's 3×3 neighborhood in YCoCg space and clips the history sample into it, which rejects stale color without the over aggressive rejection of a hard clamp. YCoCg is used rather than RGB because luma and chroma separate cleanly there, making the clip volume a better fit to the actual color distribution. On top of that, a confidence heuristic detects disocclusion and reduces the history weight where reprojection is untrustworthy.


One case however survived all of that. The metallic flake layer produces isolated pixels far brighter than their neighbors, and in a straight average a single firefly dominates the blend and never converges. The fix is Karis's luminance weighted blend where each input is weighted by 1/(1+luma) before mixing, then the weighting is divided back out.

TAA jitters the projection matrix by a sub-pixel offset each frame along a Halton(2,3) sequence, so the same geometry is sampled at different points within each pixel over time. The jitter is applied only to the projection used for rasterization and excluded from motion vector computation, since including it would inject the offset into every velocity and smear the entire image.


Accumulation happens against a ping-pong history target, reprojected with per-pixel motion vectors, but this alone isn't sufficient. History that was valid last frame may be invalid now because it belongs to geometry that is newly occluded or disoccluded. Two mechanisms handle this. Neighborhood variance clipping builds an AABB from the current frame's 3×3 neighborhood in YCoCg space and clips the history sample into it, which rejects stale color without the over aggressive rejection of a hard clamp. YCoCg is used rather than RGB because luma and chroma separate cleanly there, making the clip volume a better fit to the actual color distribution. On top of that, a confidence heuristic detects disocclusion and reduces the history weight where reprojection is untrustworthy.


One case however survived all of that. The metallic flake layer produces isolated pixels far brighter than their neighbors, and in a straight average a single firefly dominates the blend and never converges. The fix is Karis's luminance weighted blend where each input is weighted by 1/(1+luma) before mixing, then the weighting is divided back out.

Figure 7: Before/After Comparison of TAA on Chevy Colorado Model

Figure 7: Before/After Comparison of TAA on Chevy Colorado Model

Auto Exposure

Auto Exposure

The HDR target is blitted down to a 1×1 image with a linear filter, using the hardware downsample to average scene luminance. Exposure is then key / avgLuminance, where the key value is the middle-grey target, clamped between a min and max the way a real camera bounds its gain.


Adaptation is an asymmetric, so the scene responds quickly when it gets brighter and slowly when it darkens, matching how eyes and cameras actually behave. The readback is asynchronous, per-frame-in-flight staging buffers read after that slot's fence has already signalled, so the value is one to two frames stale and costs no vkDeviceWaitIdle.


However this full-frame averaging is insensitive to localized changes, a bright light on a zoomed subject barely moves the average because the unchanging background dominates the frame. A fix to this would be a center-weighted metering to the exposure averaging.

The HDR target is blitted down to a 1×1 image with a linear filter, using the hardware downsample to average scene luminance. Exposure is then key / avgLuminance, where the key value is the middle-grey target, clamped between a min and max the way a real camera bounds its gain.


Adaptation is an asymmetric, so the scene responds quickly when it gets brighter and slowly when it darkens, matching how eyes and cameras actually behave. The readback is asynchronous, per-frame-in-flight staging buffers read after that slot's fence has already signalled, so the value is one to two frames stale and costs no vkDeviceWaitIdle.


However this full-frame averaging is insensitive to localized changes, a bright light on a zoomed subject barely moves the average because the unchanging background dominates the frame. A fix to this would be a center-weighted metering to the exposure averaging.

Figure 8: Video Demonstrating Auto Exposure Implementation

Figure 8: Video Demonstrating Auto Exposure Implementation

Tonemapping (ACES/GT7/AgX/Reinhard+)

Tonemapping (ACES/GT7/AgX/Reinhard+)

Six operators are implemented: Reinhard, Reinhard Extended, ACES, AgX, AgX Punchy, and GT7. They were implemented side by side on the same frame for comparison reasons. Reinhard desaturates as it rolls off. ACES is the film emulation standard and pushes saturation into highlights. AgX preserves hue through the shoulder much better, which matters on saturated automotive paint where ACES tends to skew reds toward orange as they clip.

GT7 compresses along a perceptually uniform intensity axis rather than per-channel, so it desaturates far less at the top end.

Six operators are implemented: Reinhard, Reinhard Extended, ACES, AgX, AgX Punchy, and GT7. They were implemented side by side on the same frame for comparison reasons. Reinhard desaturates as it rolls off. ACES is the film emulation standard and pushes saturation into highlights. AgX preserves hue through the shoulder much better, which matters on saturated automotive paint where ACES tends to skew reds toward orange as they clip.

GT7 compresses along a perceptually uniform intensity axis rather than per-channel, so it desaturates far less at the top end.

Figure 9: Comparison Video of Tonemapping Techniques on McLaren Model

Figure 9: Comparison Video of Tonemapping Techniques on McLaren Model

Profiling (NVIDIA NSight)

Profiling (NVIDIA NSight)

All measurements taken on Nsight GPU Trace run. On a fixed frame containing a Chevy Colorado, the frame measured 11.66 ms, with the scene pass at 4.81 ms. Nsight's throughput chart put L1TEX at 88.5%, the highest unit in the chart, and therefore the bottleneck. The dominant consumer was the PCSS shadow filter's manual tap loop, every tap was a separate texture fetch plus a depth comparison in the shader.


Switching to sampler2DShadow moved the comparison and the 2×2 bilinear filter into the texture unit's fixed-function hardware, so one instruction returns what previously took four sample-and-compare operations. That dropped the scene pass dropped from 4.81 ms to 2.22 ms (~53.85% improvement)

All measurements taken on Nsight GPU Trace run. On a fixed frame containing a Chevy Colorado, the frame measured 11.66 ms, with the scene pass at 4.81 ms. Nsight's throughput chart put L1TEX at 88.5%, the highest unit in the chart, and therefore the bottleneck. The dominant consumer was the PCSS shadow filter's manual tap loop, every tap was a separate texture fetch plus a depth comparison in the shader.


Switching to sampler2DShadow moved the comparison and the 2×2 bilinear filter into the texture unit's fixed-function hardware, so one instruction returns what previously took four sample-and-compare operations. That dropped the scene pass dropped from 4.81 ms to 2.22 ms (~53.85% improvement)

Figure 10: Snapshot of 2D Shadow Sampler Switch and Scene G-Buffer measurement reduction

Figure 10: Snapshot of 2D Shadow Sampler Switch and Scene G-Buffer measurement reduction

Thank You for Playing!

Thank You for Playing!

Thank You for Playing!