

FusLite
FusLite
FusLite
A physically based real-time renderer I wrote in Vulkan 1.3 (C++ and GLSL) with a modern Forward+ pipeline. Developed without a commercial engine and profiled on an NVIDIA RTX 2070 SUPER.
Rendering features are exposed as authorable parameters: shadow penumbra tint and masking, flake strength and clearcoat specularity, six switchable tonemappers, and exposure controls. An ImGui panel allows editing object transforms, per-object metallic and roughness, and per-light color, position, and radius at runtime.
Features I Implemented:
Physically Based Shading
Image-Based Lighting
Clustered Forward+ Lighting
PCSS Soft Shadows / Stylized Shadow Penumbra authoring
Temporal Antialiasing
Screen-Space Ambient Occlusion
Screen-Space Reflections with Hi-Z traversal
Configurable Tonemapping with async auto-exposure
Scene graph with hierarchical transforms, runtime object and light editing
A physically based real-time renderer I wrote in Vulkan 1.3 (C++ and GLSL) with a modern Forward+ pipeline. Developed without a commercial engine and profiled on an NVIDIA RTX 2070 SUPER.
Rendering features are exposed as authorable parameters: shadow penumbra tint and masking, flake strength and clearcoat specularity, six switchable tonemappers, and exposure controls. An ImGui panel allows editing object transforms, per-object metallic and roughness, and per-light color, position, and radius at runtime.
Features I Implemented:
Physically Based Shading
Image-Based Lighting
Clustered Forward+ Lighting
PCSS Soft Shadows / Stylized Shadow Penumbra authoring
Temporal Antialiasing
Screen-Space Ambient Occlusion
Screen-Space Reflections with Hi-Z traversal
Configurable Tonemapping with async auto-exposure
Scene graph with hierarchical transforms, runtime object and light editing
A physically based real-time renderer I wrote in Vulkan 1.3 (C++ and GLSL) with a modern Forward+ pipeline. Developed without a commercial engine and profiled on an NVIDIA RTX 2070 SUPER.
Rendering features are exposed as authorable parameters: shadow penumbra tint and masking, flake strength and clearcoat specularity, six switchable tonemappers, and exposure controls. An ImGui panel allows editing object transforms, per-object metallic and roughness, and per-light color, position, and radius at runtime.
Features I Implemented:
Physically Based Shading
Image-Based Lighting
Clustered Forward+ Lighting
PCSS Soft Shadows / Stylized Shadow Penumbra authoring
Temporal Antialiasing
Screen-Space Ambient Occlusion
Screen-Space Reflections with Hi-Z traversal
Configurable Tonemapping with async auto-exposure
Scene graph with hierarchical transforms, runtime object and light editing
Type
C++, GLSL, PBR, Clustered Forward+, Vulkan 1.3
Type
C++, GLSL, PBR, Clustered Forward+, Vulkan 1.3
Profilers
RenderDoc, Nvidia NSight
Profilers
RenderDoc, Nvidia NSight
Physically based Real-Time renderer built in Vulkan 1.3 (Profiled on NVIDIA RTX 2070 SUPER)
Physically based Real-Time renderer built in Vulkan 1.3 (Profiled on NVIDIA RTX 2070 SUPER)
Figure 1: FusLite in-game demo video
Figure 1: FusLite in-game demo video
Performance Results
Performance Results
128 dynamic lights: 4.5 ms to 1.2 ms
Scene pass: 4.81 ms to 2.22 ms after an Nsight-driven fix
11.66 ms frame, RTX 2070 SUPER
128 dynamic lights: 4.5 ms to 1.2 ms
Scene pass: 4.81 ms to 2.22 ms after an Nsight-driven fix
11.66 ms frame, RTX 2070 SUPER
Background
Background
FusLite uses clustered Forward+ shading. A compute prepass bins lights into a 3D cluster grid, so shading cost scales with the number of lights per cluster rather than the total light count. On the hero scene with 128 point lights, this cut lighting from 4.5 ms to 1.2 ms. I chose forward over deferred mainly for material flexibility. The car paint layers a clearcoat and metallic flakes over its base BRDF, and forward lets each material run its full shading model without a G-buffer channel for every input. It also avoids deferred's full G-buffer read/write, writing only the few targets the screen-space passes need.
FusLite uses clustered Forward+ shading. A compute prepass bins lights into a 3D cluster grid, so shading cost scales with the number of lights per cluster rather than the total light count. On the hero scene with 128 point lights, this cut lighting from 4.5 ms to 1.2 ms. I chose forward over deferred mainly for material flexibility. The car paint layers a clearcoat and metallic flakes over its base BRDF, and forward lets each material run its full shading model without a G-buffer channel for every input. It also avoids deferred's full G-buffer read/write, writing only the few targets the screen-space passes need.
Graphics Pipeline
Graphics Pipeline
Forward shading normally leaves nothing behind for screen-space effects to read. FusLite writes only the few extra targets those effects need, keeping forward's low cost while still supporting SSAO, SSR, and TAA.
Forward shading normally leaves nothing behind for screen-space effects to read. FusLite writes only the few extra targets those effects need, keeping forward's low cost while still supporting SSAO, SSR, and TAA.

Figure 2: FusLite Components of Graphics Pipeline Diagram
Figure 2: FusLite Components of Graphics Pipeline Diagram
That decision shapes the whole frame. A compute prepass builds the cluster grid and culls lights into it, and a depth-only pass renders the sun's shadow map. Both feed the forward pass, which writes HDR color along with the motion, normal, material, and depth targets the later passes read. Imported models are decomposed into a node hierarchy, with transforms composed down the tree so a parent's movement propagates to its children.
From there, a compute pass builds a Hi-Z depth pyramid, and ambient occlusion and screen-space reflections (SSR) run against the forward pass's targets, with SSR marching the pyramid. Composite combines their results into the HDR target, TAA resolves against history, and tonemap writes to the swapchain using exposure metered from a previous frame. ImGui draws last, and the frame presents.
That decision shapes the whole frame. A compute prepass builds the cluster grid and culls lights into it, and a depth-only pass renders the sun's shadow map. Both feed the forward pass, which writes HDR color along with the motion, normal, material, and depth targets the later passes read. Imported models are decomposed into a node hierarchy, with transforms composed down the tree so a parent's movement propagates to its children.
From there, a compute pass builds a Hi-Z depth pyramid, and ambient occlusion and screen-space reflections (SSR) run against the forward pass's targets, with SSR marching the pyramid. Composite combines their results into the HDR target, TAA resolves against history, and tonemap writes to the swapchain using exposure metered from a previous frame. ImGui draws last, and the frame presents.
Screen Space Ambient Occlusion (SSAO)
Screen Space Ambient Occlusion (SSAO)
FusLite computes screen-space ambient occlusion from the scene pass target's normals and depth, then denoises it inside the existing composite pass so it costs no extra full-screen pass.
FusLite computes screen-space ambient occlusion from the scene pass target's normals and depth, then denoises it inside the existing composite pass so it costs no extra full-screen pass.
Figure 3: Before/After Comparison of SSAO on McLaren Model
Figure 3: Before/After Comparison of SSAO on McLaren Model
SSAO samples a hemisphere of points oriented along the fragment's normal from the scene pass targets, 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 scene pass targets, 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.
Temporal Anti Aliasing (TAA)
Temporal Anti Aliasing (TAA)
TAA resolves aliasing by jittering the camera each frame and accumulating the results, with variance clipping and luminance weighting to keep history from ghosting or sparkling. Jitter phase and blend alpha are exposed as parameters, controlling the offset into the Halton sequence and how heavily the current frame is weighted against history.
TAA resolves aliasing by jittering the camera each frame and accumulating the results, with variance clipping and luminance weighting to keep history from ghosting or sparkling. Jitter phase and blend alpha are exposed as parameters, controlling the offset into the Halton sequence and how heavily the current frame is weighted against history.
Figure 4: Before/After Comparison of TAA on Chevy Colorado Model
Figure 4: Before/After Comparison of TAA on Chevy Colorado Model
Samples accumulate into a ping pong history target, reprojected with per pixel motion vectors, but reprojection alone isn't enough. History that was valid last frame can be garbage this frame if the geometry behind it just became occluded or disoccluded. Two things handle that. 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 throws out stale color without the heavy handedness of a hard clamp. YCoCg works better than RGB here because luma and chroma separate cleanly, so the clip volume actually fits the color distribution. On top of that, a confidence heuristic catches disocclusion and drops the history weight wherever reprojection can't be trusted.
One case still got through. The metallic flake layer throws out isolated pixels far brighter than anything around them, and in a straight average a single firefly dominates the blend and never converges. The fix was a Karis's luminance weighted blend, which weights each input by 1/(1+luma) before mixing and divides the weighting back out afterward.
Samples accumulate into a ping pong history target, reprojected with per pixel motion vectors, but reprojection alone isn't enough. History that was valid last frame can be garbage this frame if the geometry behind it just became occluded or disoccluded. Two things handle that. 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 throws out stale color without the heavy handedness of a hard clamp. YCoCg works better than RGB here because luma and chroma separate cleanly, so the clip volume actually fits the color distribution. On top of that, a confidence heuristic catches disocclusion and drops the history weight wherever reprojection can't be trusted.
One case still got through. The metallic flake layer throws out isolated pixels far brighter than anything around them, and in a straight average a single firefly dominates the blend and never converges. The fix was a Karis's luminance weighted blend, which weights each input by 1/(1+luma) before mixing and divides the weighting back out afterward.
Percentage Closer Soft Shadows (PCSS)
Percentage Closer Soft Shadows (PCSS)
Real shadows sharpen at the contact point and widen as the caster moves away from the receiver, because the light source has area. FusLite estimates that penumbra width per pixel, and exposes it as an authorable field for tinting and stylizing shadow falloff.
Real shadows sharpen at the contact point and widen as the caster moves away from the receiver, because the light source has area. FusLite estimates that penumbra width per pixel, and exposes it as an authorable field for tinting and stylizing shadow falloff.
Figure 5: Percentage Closer Soft Shadows (PCSS) with/without Custom Penumbra Enabled (PCSS+)
Figure 5: Percentage Closer Soft Shadows (PCSS) with/without Custom Penumbra Enabled (PCSS+)
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. The result is a contact-hardening shadow that mimics how an area light physically behaves.
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, supporting a solid color, a gradient, or a texture mask such as halftone or crosshatch.
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. The result is a contact-hardening shadow that mimics how an area light physically behaves.
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, supporting a solid color, a gradient, or a texture mask such as halftone or crosshatch.
Automotive Clearcoat Material
Automotive Clearcoat Material
Real car paint has three layers: a pigmented base, metallic flakes suspended in it, and a smooth clearcoat on top. FusLite models each one separately, with flake strength and clearcoat specularity exposed as parameters.
Real car paint has three layers: a pigmented base, metallic flakes suspended in it, and a smooth clearcoat on top. FusLite models each one separately, with flake strength and clearcoat specularity exposed as parameters.

Figure 6: Automotive Clearcoat Snapshot on Shaderball Mesh
Figure 6: Automotive Clearcoat Snapshot on Shaderball Mesh
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.
Screen Space Reflections (SSR)
Screen Space Reflections (SSR)
Screen-space reflections add reflections of nearby geometry by marching rays through the depth buffer. They're cheap and convincing, but they can only reflect what's already on screen.
Screen-space reflections add reflections of nearby geometry by marching rays through the depth buffer. They're cheap and convincing, but they can only reflect what's already on screen.
Figure 7: Before/After Comparison of SSR on Shaderball
Figure 7: Before/After Comparison of SSR on Shaderball
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 leads to iteration savings.
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 leads to iteration savings.
Tonemapping (ACES/GT7/AgX/Reinhard+)
Tonemapping (ACES/GT7/AgX/Reinhard+)
FusLite implements six tonemapping operators: Reinhard, Reinhard Extended, ACES, AgX, AgX Punchy, and GT7. You can flip between these on the same frame, which is really the only way to tell how differently they behave.
FusLite implements six tonemapping operators: Reinhard, Reinhard Extended, ACES, AgX, AgX Punchy, and GT7. You can flip between these on the same frame, which is really the only way to tell how differently they behave.
Figure 8: Comparison Video of Tonemapping Techniques on McLaren Model
Figure 8: Comparison Video of Tonemapping Techniques on McLaren Model
Saturated car paint is a good stress test for this, since it pushes right into the region where operators start to disagree.
Reinhard: desaturates as it rolls off, because compressing each channel on its own drags the brightest one down toward the rest. Reinhard Extended adds a white point so the curve actually reaches white instead of creeping toward it forever.
ACES: the film emulation standard and looks great in highlights, though it tends to swing reds toward orange as they clip.
AgX: holds hue through the shoulder much better, which shows up immediately on red paint.
GT7: is a port of Polyphony's Gran Turismo 7 operator. Instead of working per channel it compresses along a perceptually uniform intensity axis in ICtCp, so it barely desaturates at the top end.
Saturated car paint is a good stress test for this, since it pushes right into the region where operators start to disagree.
Reinhard: desaturates as it rolls off, because compressing each channel on its own drags the brightest one down toward the rest. Reinhard Extended adds a white point so the curve actually reaches white instead of creeping toward it forever.
ACES: the film emulation standard and looks great in highlights, though it tends to swing reds toward orange as they clip.
AgX: holds hue through the shoulder much better, which shows up immediately on red paint.
GT7: is a port of Polyphony's Gran Turismo 7 operator. Instead of working per channel it compresses along a perceptually uniform intensity axis in ICtCp, so it barely desaturates at the top end.
Auto Exposure
Auto Exposure
Auto exposure keeps the scene readable as the camera moves between bright and dark areas, adapting gradually the way an eye does. Exposure limits, adaptation speed, and a manual override are all exposed as parameters.
Auto exposure keeps the scene readable as the camera moves between bright and dark areas, adapting gradually the way an eye does. Exposure limits, adaptation speed, and a manual override are all exposed as parameters.
Figure 9: Video Demonstrating Auto Exposure Implementation
Figure 9: Video Demonstrating Auto Exposure Implementation
The HDR target is downsampled through a mip chain to a 1×1 image, using hardware linear filtering at each step 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 asymmetric, so the scene responds quickly when it gets brighter and slowly when it darkens, matching how eyes adapt and cameras behave. The readback is asynchronous: each frame-in-flight has its own staging buffer, read only after that slot's fence has signalled, so the value is one to two frames stale and the CPU never blocks on a vkDeviceWaitIdle.
However, full-frame averaging is insensitive to localized changes. A bright light on a zoomed-in subject barely moves the average because the unchanged background dominates the frame. Center-weighted metering would address this.
The HDR target is downsampled through a mip chain to a 1×1 image, using hardware linear filtering at each step 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 asymmetric, so the scene responds quickly when it gets brighter and slowly when it darkens, matching how eyes adapt and cameras behave. The readback is asynchronous: each frame-in-flight has its own staging buffer, read only after that slot's fence has signalled, so the value is one to two frames stale and the CPU never blocks on a vkDeviceWaitIdle.
However, full-frame averaging is insensitive to localized changes. A bright light on a zoomed-in subject barely moves the average because the unchanged background dominates the frame. Center-weighted metering would address this.
Profiling (NVIDIA NSight)
Profiling (NVIDIA NSight)
FusLite runs at 110–120 FPS on an RTX 2070 Super. Profiling in Nsight traced the main bottleneck to texture fetches in the PCSS shadow filter, and switching to hardware shadow comparisons cut the scene pass by 54% (4.81 ms to 2.22 ms).
FusLite runs at 110–120 FPS on an RTX 2070 Super. Profiling in Nsight traced the main bottleneck to texture fetches in the PCSS shadow filter, and switching to hardware shadow comparisons cut the scene pass by 54% (4.81 ms to 2.22 ms).


Figure 10: Snapshot of 2D Shadow Sampler Switch and Scene Pass (Labeled G-Buffer in codebase) Measurement Reduction
Figure 10: Snapshot of 2D Shadow Sampler Switch and Scene Pass (Labeled G-Buffer in codebase) Measurement Reduction
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 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.
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 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.
Thank You for Playing!
Thank You for Playing!
Thank You for Playing!


