Gallery
No video available.
Facing the "volumetric corruption" requirement, this post documents why we ruled out heavy solutions like Uber Shaders and RT-driven approaches, pivoting to a lightweight Native Decal Projector with mathematical static/dynamic decoupling.
When receiving Mike’s requirement for “volumetric corruption with dynamic wind feedback,” the initial instinct was to lean towards complex, cutting-edge techniques. However, during the actual R&D and testing phases, I had to make pragmatic trade-offs between performance and visual fidelity.
This post-mortem not only documents how our final Shader is constructed, but more importantly, why we chose NOT to use those “advanced-sounding” solutions, and how we left the door open for future extensions.
In the early stages of the project, to completely solve the lack of volumetric depth in 2D decals, I evaluated several highly ambitious technical routes:
Route A: Uber Shader Injection Injecting the corruption logic directly into the Master Node of all terrain and prop materials via a Subgraph, utilizing true world-space coordinates for vertex-level influence.
Route B: Mesh Decal Proxy
Spawning a real semi-transparent sphere mesh at the corruption site and calculating soft blending at intersections by reading the CameraDepthTexture.
Route C: Render Texture Channel Packing (Enrico’s Proposal) Discarding global variable parameters in favor of a real-time Render Texture (RT). By packing the corruption shape, wind perturbation, and footprint state into different channels, this would allow for fully independent physical interactions for every corruption ring in the scene.
Theoretically, all these approaches are perfectly valid and represent standard practices for environmental VFX in AAA engines. However, following a rigorous Profiler audit, I decided to put them on hold.
The primary reasons for discarding the above solutions boil down to a poor Performance-to-Visual Ratio and exorbitant maintenance costs.
Transparent render queue, meaning they cannot benefit from Early-Z culling. If multiple corruption spheres overlap in a narrow corridor, the fragment shader (frag) overdraw would instantly blow through our performance budget.Based on these considerations, I decided to pivot to the most lightweight, non-invasive solution available: HDRP Native Decal Projector.
Having committed to Native Decals, the new challenge emerged: Using purely mathematical logic, how do we make a 2D projection feel like an organic fluid torn by the wind, without clipping awkwardly into the environment?
Through a synchronized effort between C# and Shader Graph, we tackled three major pain points:
Initially, I multiplied Time * WindSpeed directly inside the Shader to pan the noise. The fatal flaw? Whenever wind speed changed smoothly, the output of Time would jump drastically, causing the VFX to aggressively flicker and snap.
The Solution (C# Phase Accumulation): Strip the Shader of its time-calculation duties. Instead, use Time.deltaTime in the C# script to calculate the incremental physical distance traveled each frame. We pass this stabilized, continuously accumulating vector (_CustomWindOffset) to the Shader, while applying modulo operations to prevent floating-point precision loss over long play sessions.
If we applied the wind offset to the global UVs directly, the entire corruption pit would slide across the floor like a skateboard.
The Solution (Static/Dynamic Decoupling): Strictly isolate the static and dynamic components within the Shader. Static UVs are exclusively used to generate the central black hole mask; meanwhile, the wind perturbation doesn’t shift the UVs but is instead added to the Radius of the Distance node. This locks the pit in place, while allowing only the outer flames to stretch downwind.

Visual: Before vs. After Static/Dynamic Decoupling (Left: Incorrect sliding; Right: Correct stretching)
When a decal is projected near a tree trunk or steep wall, the 2D planar projection inevitably creates hideous vertical stretching.

The Problem: Severe stretching artifacts caused by Decal projection on vertical surfaces.
We deployed a “Dual Defense Mechanism” to handle this:
Decal Layer Mask. This takes effect at the base Culling stage, serving as a zero-cost hard isolation.
The Solution: Vertical stretching is smoothly eliminated after applying the Height Fade Mask.
To execute the concepts above, the Shader is cleanly modularized into several functional groups:

Shader Graph Global Overview: Clean functional modularization
Receives the accumulated variable _CustomWindOffset from C#.

This is the anchor for the underlying corruption pit, ensuring the core doesn’t drift.
UV coupled with a center point of (0.5, 0.5) fed into a Distance node to calculate a perfectly static radial gradient. Inside this group, introducing any wind variables is strictly prohibited.This is the heart of the deformation and restriction logic. I split it into two dimensions: planar perturbation and height clamping.
Dimension 1: Downwind Surface Stretching (XZ Noise) Handles fluid deformation on the 2D surface.

Simple Noise nodes. By independently adjusting the XZ and Y scales, we artificially create a directional bias—elongated in the downwind direction and compressed on the flanks.Subtract node to subtract 0.5 from the 0~1 noise. This shifts pure positive numbers into a range containing negatives, ensuring the flames oscillate back and forth across the original boundary, preventing the entire ring from migrating unidirectionally.Dimension 2: Height Clamping (Y Mask) Specifically addresses the “tree-climbing” stretching artifact (Pain Point 3).

Position (World) node. This is fed into a Remap node to define the falloff zone, then a Power node controls the harshness of the fade, and finally, a Clamp restricts it between 0 and 1.M * (1-M) * 4 (Emission Edge Extraction & Final Blend)M.M * (1 - M), we extract a brilliant peak exclusively along the light-to-dark transition boundary. We take this razor-sharp flame ring, multiply it by our previously calculated Y Mask (to trim excess vertical climbing), and finally multiply it by our target color before feeding it into the Emission channel.
Final Composite: Blending the mathematical edge extraction with the height mask

The Final In-Game Result
Currently, passing parameters via Shader.SetGlobalVector is incredibly performant and offers the best bang for our buck. However, keeping Enrico’s advice in mind, this setup can smoothly transition if environmental interaction demands increase.
Triggers for a Pipeline Upgrade:
When that day arrives, because our current Shader architecture already solidifies the underlying logic for “Static/Dynamic Decoupling” and “Planar/Height Separation”, we only need to swap out the few nodes receiving C# global variables. By replacing them with nodes that sample specific channels of a global Render Texture (e.g., reading the G channel for wind strength, B channel for footprint masks), we can seamlessly plug into a holistic environmental ecosystem like TVE.
Stepping back from a complex AAA architectural R&D phase to a streamlined mathematical implementation has been the most valuable lesson of this pipeline selection process. As Technical Artists, we must avoid the trap of treating everything like a nail just because we hold a powerful hammer.
By leveraging precise mathematical decoupling and height masking, we traded a handful of cheap ALU instructions for excellent volumetric visuals. More importantly, we clearly defined the boundaries of this solution while actively paving a smooth transition path toward a more robust RT architecture in the future.
No video available.