Cyanilux

Game Dev Blog & Tutorials

Ultraviolet Lights & Invisible Ink

Intro

Lately I’ve been thinking about effects that involve revealing hidden objects (especially decals)

I’ve collected some examples from various games :

This post will provide example shaders/graphs of how to achieve effects similar to these. The concepts may translate to other pipelines but there is a heavy focus on the Universal Render Pipeline, the Decal Renderer Feature & Projectors and uses the Decal Graph.


Inital Decal Graph

For all the methods shown below, we’ll start by creating a basic Decal Graph.

We want a Texture2D property for the decal texture, use Sample Texture 2D on it, and connect the RGBA output to Base Color in the Master Stack. We can also provide a Color property for Emission (preferably with HDR enabled under Node Settings while property is selected).

(Image)

A Multiply node is used on the alpha channel of the texture before connecting to the Alpha. The B input of this will be where we handle the visibility of the decal - such as using light attenuation or some other mask. This will be where graphs below should connect - though you may also want to Saturate before that to keep alpha values in a 0-1 range!

Feel free to also use Normals, MAOS (metallic, AO, smoothness) if required.


Ultraviolet Lights

!

This method works best when the Decal Renderer Feature is set to Screen Space mode. (Or if you handle your own decals by rendering meshes after opaques and project the texture onto surfaces by reconstructing world positions from the depth texture, which is essentially the same idea).

In “DBuffer” mode it has some issues… the foldout below has more details if interested.

In this mode the decal itself does not typically handle shading. They instead are rendered as unlit into separate buffer(s), then sampled by opaque surfaces later which applies the shading. The decal shader pass that is used isn’t set up to properly handle lighting.

But during testing, I did find a workaround for the Forward+ path (untested in builds though), which involves manually adding keywords to the graph (though this does cause some warnings about duplicate keywords). Can do this either through the Blackboard or by adding these lines to the hlsl include file that we’ll set up below (this also creates warnings about unknown pragmas, but still works - a bit odd)

#pragma multi_compile _ _ADDITIONAL_LIGHTS
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile _ _FORWARD_PLUS

If you want to avoid any warnings, you’d probably have to convert the graph to code and add the keywords to the correct Pass instead. (Frame Debugger should tell you which pass is being used. Otherwise just make sure all passes have those keywords once)

Adding these keywords is enough in the Forward+ path, but for the regular Forward path it appears that unity_LightData and unity_LightIndices are not being set during decal rendering in DBuffer mode, so afaik not possible to get working for that rendering path. If you require Forward, use Screenspace decals instead.

The aim here is to make our decals appear only when spot or point lights shine on them -

(Image)

To read light data in URP & Shader Graph we’ll need a Custom Function node.

It’s likely that you may have other lights in the scene and only want specific lights to affect the decals in this way, so will also need a way to determine which light is which. There’s a few ways we could handle this, in particular - by comparing the light colour to a specific value, or using the Rendering Layers set on the Light component. Will explain both these options below.

!
In the below functions I’m also hardcoding the Shadowmask as half4(1,1,1,1). I’m unsure if it’s important, but if you need to support the Shadowmask Baked GI mode for the decals, you’ll want to provide it as an input param to the function (half4 Shadowmask before the out param) and connect the Sample Shadowmask subgraph from my Custom Lighting repo to that port.

Using Light Colour

A relatively simple way to determine which lights should reveal decals is by it’s colour. Ultraviolet lights are typically purplish, but let’s assume it could be any specific colour - and pass it into the function as a parameter to avoid hardcoding it.

To compare against that colour, we can take the distance() between it and the light’s colour, then compare that distance against some max value (named Range here)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// By Color
// e.g. To only reveal decals with roughly magenta/purple lights could use Color = (1,0,1) and Range = 0.5

void RevealUsingColor_float(float3 WorldPosition, float4 Color, float Range, out float Out){
    half4 Shadowmask = half4(1,1,1,1);
    float totalAtten = 0;
#ifndef SHADERGRAPH_PREVIEW
    uint pixelLightCount = GetAdditionalLightsCount();

    InputData inputData = (InputData)0;
    float4 screenPos = ComputeScreenPos(TransformWorldToHClip(WorldPosition));
    inputData.normalizedScreenSpaceUV = screenPos.xy / screenPos.w;
    inputData.positionWS = WorldPosition;

    LIGHT_LOOP_BEGIN(pixelLightCount)
        Light light = GetAdditionalLight(lightIndex, WorldPosition, Shadowmask);
        float intensity = length(light.color.rgb);
        float atten = intensity * light.distanceAttenuation * light.shadowAttenuation;
        bool LightShouldReveal = distance(normalize(light.color.rgb), Color.rgb) < Range;
        totalAtten += LightShouldReveal ? atten : 0;
    LIGHT_LOOP_END
#endif
    Out = totalAtten;
}

In the graph, connect a Position node to the WorldPosition port, a Color node to the Color port, and set Range to 0.5. (Or use Color and Float properties to be able to control these from the Material)

(Image)


Using Light Layers

URP 2022.2+ introduced Rendering Layers. They have a few uses, but importantly they can be set for each Light, where the intended use is to make it possible for lights to affect certain meshes. Afaik that doesn’t really apply to decals though, as the Decal Projector component uses the same rendering layers to determine which surfaces they should project onto. But anyway, the light layer masks are still available in the shader.

I believe Rendering Layers should be enabled by default, but you might want to double-check that is the case on each URP Asset (it’s under Lighting heading - but may need to click the 3 dots on the right and “Show Additional Properties” to actually show it)

Under the URP Global Settings (can be found under Project Settings) we can configure these layers. By having two rendering layers, e.g. “Default” (Index 0) and “Ultraviolet” (Index 1) we can set any Lights that should reveal decals to use both these layers, while regular lights should only use the Default one. This should work fine even if there are other layers too, if you need them for other uses.

(Image)

One disadvantage of this approach is that newly created lights always use all layers - so we have to remember to change it!

In the function, we use IsMatchingLightLayer(light.layerMask, X). We’d normally use GetMeshRenderingLayer() as the second param to compare the rendering layer mask of the Light and MeshRenderer components, but here we just want to know if the Light has the “Ultraviolet” layer active, so need to use 1 << layerIndex.

layerIndex here will be 1 in my case, but rather than hardcoding values, I’ve passed in the layer index as an input param so it can be set from the graph.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// By Light Rendering Layer
// e.g. only reveal decals with lights that have a specific Layer set under it's Rendering Layers
// note that new lights default to using all layers / "Everything" so need to remember to change it

#pragma multi_compile _ _LIGHT_LAYERS

void RevealUsingLayer_float(float3 WorldPosition, float UltravioletLayerIndex, out float Out){
    half4 Shadowmask = half4(1,1,1,1);
    float totalAtten = 0;
#ifndef SHADERGRAPH_PREVIEW
    uint pixelLightCount = GetAdditionalLightsCount();
    uint ultravioletLayerMask = 1 << int(UltravioletLayerIndex);

    InputData inputData = (InputData)0;
    float4 screenPos = ComputeScreenPos(TransformWorldToHClip(WorldPosition));
    inputData.normalizedScreenSpaceUV = screenPos.xy / screenPos.w;
    inputData.positionWS = WorldPosition;

    LIGHT_LOOP_BEGIN(pixelLightCount)
        Light light = GetAdditionalLight(lightIndex, WorldPosition, Shadowmask);
    #ifdef _LIGHT_LAYERS
        if (IsMatchingLightLayer(light.layerMask, ultravioletLayerMask))
    #endif
        {
            float intensity = length(light.color.rgb);
            float atten = intensity * light.distanceAttenuation * light.shadowAttenuation;
            totalAtten += atten;
        }
    LIGHT_LOOP_END
#endif
    Out = totalAtten;
}

In the graph, connect a Position node to the WorldPosition port, and set LayerIndex to 1 (assuming “Ultraviolet” is Layer 1) or use a Float Property if you want to control the value from the Material. (Ideally also set the mode on that property to Integer under Node Settings while property is selected)

(Image)


Special Lens

Using Screenspace Circular Mask

While the intro examples don’t use this, it would be possible for our decals to calculate their visibility based their position on the screen. By comparing with a screenspace mask, they could appear only when inside a circle - and can provide a soft transition, unlike a stencil approach.

As we’re using Screen Position and the screen aspect ratio isn’t necessarily square, the resulting circle would be stretched. To fix this, we can use a ratio of the Screen Width and Height to scale the coordinates, as shown below.

(Image)

By using the Center mode on the Screen Position (and Center of (0, 0) on the Sphere Mask) we ensure the circle stays in the center of the screen.

In the above, I’ve used the Sphere Mask node which handles the circle calculations for us, while providing a soft falloff (unlike the Ellipse node). Alternatively, below shows what this is doing :

(Image)

This kind of method could work for other shapes too by swapping the Circle SDF group out for a different function, see Inigo Quilez’s article on 2D SDFs. Can replicate those in nodes, or convert the code to HLSL and use a Custom Function

With just this masking in the decal shader, it may be difficult to see when the lens is actually active - so you may want to match this up with some kind of Lens Mesh, UI or Fullscreen effect (e.g. using this same circular mask inside a Fullscreen Graph to tint screen like the Lens of Truth does)


Using Stencils

While the above is good for a static lens that stays attached to the screen center, it may be difficult to use that method if the lens is an animated mesh or of a more complex shape that can’t be easily replicated in SDFs. In these cases, Stencil operations may work better.

In short, the process is :

This approach is a bit convoluted when using Shader Graph, since it doesn’t actually have proper support for adjusting these stencil values, but there are workarounds.

!

As render order is important, this method seems to only work correctly when the Decal Renderer Feature is set to Screen Space mode. (Or if you handle your own decals by rendering meshes after opaques and project them onto surfaces by reconstructing world positions from the depth texture, which is essentially the same idea)

(Technically it should be possible even in DBuffer mode, but it seems there’s some depth/stencil buffer copying/clearing going on which is messing with things)

Rendering the Lens with Stencil

You’ll need some kind of mesh to use as a lens - in my case I used a magnifying glass split into two submeshes (to apply separate materials to the handle and glass). Only the glass part is actually important here.

To make this render to the stencil buffer, we can either :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
Shader "Custom/StencilLens" {
    Properties { }
    SubShader {
        Tags { 
            "Queue"="AlphaTest+50" // Render before Decals (Screenspace mode)
        }

        Pass {
            Cull Off        // Make double sided (optional)
            ZWrite Off      // Don't render to depth buffer
            ColorMask 0     // Don't render to colour buffer
            Stencil {
                Ref 1           // Value to compare with (and write, if using Replace)
                Comp Always     // No comparison, always pass
                Pass Replace    // Replace value in stencil buffer with Ref above
            }
        }
    }
}

In the above shader I’ve hardcoded a value of 1, but if you prefer it’s also possible to use properties to adjust the stencil (and other ShaderLab functions) from the Material - see FAQ : Using properties with ShaderLab operations.

Note if you have other features using stencils, you may need use a different value or additionally make use of the ReadMask/WriteMask to avoid the features interfering with eachother.

Rendering Objects/Decals with Stencil

For regular meshes, we can use stencil overrides on the RenderObjects feature again :

But this doesn’t work for Decal Projectors as they are already rendered using a different feature

While the graph asset is selected in the Project window, the Inspector will have buttons at the top of the asset to show the generated shader code.

(Image)

Once open, you can move the file from the temporary folder (or File → Save As…) into your assets. Close the file and reopen the one in your assets.

We can insert our Stencil block in the SubShader (after it’s Tags), before the first Pass. It’ll look something like this :

...
SubShader
{
    Tags
    {
        ...
    }

    // ---- start of edits to generated code ----
    Stencil
    {
        Ref 1
        Comp Equal
    }
    // ---- end of edits to generated code ----

    Pass
    {
...

I’d also recommend changing the name of the shader, from Shader Graphs/Decal to something like Generated From Graph/Decal so it’s clear which version is which on the Shaders dropdown. Create a Material using this new shader.

Any decals using this material should now only be drawn through the lens. (Or can use NotEqual comparison for objects that should be hidden by the lens)


Thanks for reading! 😊

If this post helped, consider sharing a link with others!


License / Usage Cookies & Privacy RSS Feed