A retro-reflective shader for Unity

Two materials with the exact same parameters. On the left my retroreflective shader is used and on the right the URP's Standard Lit shader.

One of the most basic and universal assumptions in rendering is the law of reflection: the angle of incidence equals the angle of reflection. Every reflection obeys it. However, real world materials can reflect light multiple times such that, from a macro perspective, a ray is directly reflected back at the light source. This phenomenon, known as retro-reflection, is probably best known from cat's eyes, warning vests, pylons, street signs, or the reflectors clamped into bicycle wheels. This article aims to tell the story of me fighting with Unity's Universal Render Pipeline (URP) to write a shader emulating this behavior.

By skimming through this implementation of the Standard Lit shader, I noticed that modifications are only necessary in two places to make it retroreflective: in the specular BRDF used to calculate the lighting contribution of scene lights and the one used for the environment map. The first one is calculated inside LightingPhysicallyBased(), which is a function the URP provides.

As a quick reminder, the Bidirectional reflective distribution function (BRDF) calculates how much light from a given incoming direction is reflected towards a given outgoing direction. Conventional ones return a high value when the outgoing (view) vector aligns with the reflected incoming (light) vector. We ask: "How far is the direction from which we see the surface off from the direction in which most of the light is reflected?".

In case of a retroreflective BRDF we instead ask: "How far is the direction from which we see the surface off from the direction from which most of the light came?". My first attempt to obtain this behavior was to bend the normal vector passed into LightingPhysicallyBased() towards the light direction. Intuitively this means that the surface reflects in all directions like a conventional surface reflects rays perpendicular to it (when light and normal directions are parallel).

This already creates the most distinctive look of retroreflective materials: as soon as the camera is close to the light source and approximately looks in the same direction, the viewer is blinded with a sudden flash of light. Although easy to implement, this hack also has two big shortcomings:

First, the skewed normal messes up angular light attenuation. Light hitting the surface from the side shouldn't have the same strength as hitting it straight on, but now the shader can't distinguish between those two cases anymore. And second, we loose normal detail. Because the normals are skewed towards the light, two normals once pointed in different directions end up pointing in the same. We blend between retro-reflectivity and the normal map.

Before showing a better approach, I need to explain one thing: Modern specular BRDF implementations, Unity's included, use the Blinn-Phong reflection model, which calculates the amount of light reflected in the outgoing direction via the so-called half vector, obtained by averaging the light and view vector. The smaller the angle between it and the normal, the higher the returned value. This operation is equivalent to comparing the angle between reflected light and view vector, as in the older Phong model, but easier to compute and less prone to visual artifacts.

We can make the BRDF retroreflective by replacing the half vector with the so-called back vector [1]. This is done by reflecting the view direction before averaging it with the light vector. The normal is used as mirror axis for the reflection. This way we can still use the LightingPhysicallyBased() function, but compared to our first approach we manipulate the view vector instead of the normal. Our new view vector is calculated via HLSL's built-in reflect function: viewDir = reflect(-viewDir, normal). Ignore the negation, this is just because view vectors usually look away from the surface, but the function expects it to looks towards the surface. This approach doesn't suffer from the same problems as the first attempt, because light and normal vectors are left untouched.

The entire vector zoo: reflected view direction R, back vector B, light direction L, normal N, half vector H, view direction V, and incident angle a.

However, one last caveat remains. Real-life measurements show that retro-reflectivity only occurs at incident angles (the angle between the incoming light ray and the normal) between 0 and 45 degrees [2]. Even at angle zero, only about 65% of light is retroreflected, the remaining percentages are either normally reflected or absorbed. At 30 degrees it's only about 25%. That shows that any material will always exhibit some amount of standard reflection. We can't just replace the reflection code, we need to add retroreflection on top of it.

But no big deal, we can just call LightingPhysicallyBased() once with the original parameters and again with the reflected view direction. The only question: how do we combine the results? Just adding them together would disregard energy conservation: the retroreflective materials would be around twice as bright as their conventionally lit counterparts. Therefore, I did the next best thing: I averaged both BRDFs. In reality, this mixing function is dependent on the incident angle, but I found averaging to be the best trade-off between realism and performance. This would also be a good place to incorporate a grayscale retro-reflectivity texture to use as a mask. A black texel would correspond to normal reflective behavior, a white one to full (unrealistic) retro-reflectivity, and a 50% gray one to averaging.

For environment reflections I used a similar approach as for the specular reflections of scene lights. In addition to sampling the environment cube map only along the view direction, we sample it a second time along the reflected view direction and average both colors. This is not physically correct but provides good results for a few more instructions.

To save a few instructions, I packed all changes to the standard lit shader up in two functions: one for scene lights and one for global illumination. They are designed as drop-in replacements for LightingPhysicallyBased() and GlobalIllumination(), respectively. The result is a shader with the same interface as the standard lit shader, just that it exhibits retroreflective behavior. You can find the full source code here. In case you are interested in the yellow reflector substance, you can find it for a small price in my store.

In the bottom row, every object has a material with the standard lit shader applied. The left two objects use textures suited for a reflector, whereas the right two objects use a Material without textures whatsoever. In the top row, you see the corresponding materials with the retroreflective shader, using the same textures and parameter values as their respective counterparts.

References:
[1] Belcour et al. "BRDF Measurements and Analysis of Retroreflective Materials" (2014)
[2] Jie Gao "A retroreflective BRDF model based on prismatic sheeting and microfacet theory" (2018)

Comments

Popular Posts