VRChat tricks, part 3

Giving a shader memory with Custom Render Textures

A shader forgets everything each frame. A CustomRenderTexture reads its own last frame, so yours can leave trails, smooth a value over time and keep numbers between frames.

  • Part 310 min dose
  • Intermediate

A shader draws every frame from scratch, a bit like a goldfish with a paintbrush. On its own it cannot leave a trail behind a moving hand. In VRChat, the usual way for an avatar to keep something from one frame to the next is a CustomRenderTexture, or CRT. Let’s look at how it remembers, what you can build with it, and how to keep one running on an avatar.

Brushes that leave trails

Turn on JavaScript to run this demo.

How much of last frame's picture fades away each frame.0.02
How bright the brush paints into the buffer.1.6
lib.hlsl and hero.hlsl draw the demo aboveUnity files
Fig. 1

Light painting with a memory, where each frame keeps most of the last one. Raise Decay δ and the trails get shorter. I owe you a small confession. A browser has no CRT, so these demos fake the memory. Each frame they redraw where things were a moment ago and fade them with the same rule a CRT uses.

A texture that reads itself

Each frame the screen is wiped and every material’s shader runs again, once per pixel. A shader can read textures and material values, but not what it drew a frame ago, because that picture has already been painted over. On its own, a shader has no memory at all.

A CRT is a texture with its own material, the update material. Each frame Unity runs that material’s shader once for every texel (a texel is one pixel of a texture). Here comes the tricky bit. A GPU cannot read a texture while it writes it, and that is why Unity keeps two copies with Double Buffered on. The update reads last frame from one, as _SelfTexture2D, and writes this frame into the other. Next frame they swap, and this back and forth is called ping-pong.

Two textures trading places

Turn on JavaScript to run this demo.

pingpong.hlsl draws the demo above
Fig. 2

Ping-pong in slow motion. The texture with the mint outline is read, the pink one is written with this frame’s new piece of trail, and then they trade places. Watch a few rounds until it clicks.

Trails fade a little and keep the brighter

My buffer update shader turns that memory into trails with one small rule, called peak hold:

St=max⁡((1−δ) St−1, g It)S_t = \max\big((1 - \delta)\,S_{t-1},\ g\,I_t\big)

tt counts frames. St−1S_{t-1} is last frame’s stored value and StS_t the new one. ItI_t is the input, here a soft brush. δ\delta, the decay, is the share that fades each frame (0.04 by default in my shader), and gg, the gain, sets how bright the brush paints. So each frame the old value fades a little, and the brighter of the two wins.

When the brush passes, a pixel jumps to g Itg\,I_t and then fades. After kk frames, (1−δ)k(1 - \delta)^k of it is left. Why max and not plus? With plus, a resting brush piles up to 25 times its brightness at the default δ, and your soft glow turns into a glaring blob.

Drag the ring, watch it fade
Drag the marked part, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

How much of last frame's value fades away each frame. 1 forgets everything.0.04
trail.hlsl draws the demo aboveUnity files
Fig. 3

The graph follows the pixel under the ring. It jumps each time the brush passes and fades in between. Drag the ring around, then set Decay δ to 1 and watch the buffer forget everything at once.

VRC_BufferUpdate.shaderRuns in Unity only
float4 prev = tex2D(_SelfTexture2D, uv);     // last frame's stored state
float4 inp  = tex2D(_InputTex, uv) * _Gain;  // new data to fold in
// ...
float4 buf = max(prev * (1.0 - _Decay), inp);
return buf;

The demos here are stand-ins, but the trail is real in Unity. These are the files I use for it, with the steps to set them up. A texture that holds a fading trail needs more than 8 bits, because with 4% fading per frame an 8 bit value stops dropping at about 5% brightness. So I use RGBA Half for it.

The trail in Unity
TrailUpdate.shader
// https://docme.dev/articles/custom-render-textures/
Shader "DocMe/CRT/TrailUpdate"
{
    Properties
    {
        _Decay ("Decay", Range(0.01, 1)) = 0.04
        _Gain ("Gain", Range(0, 8)) = 1
    }
    SubShader
    {
        Lighting Off
        Blend One Zero

        Pass
        {
            Name "Update"
            CGPROGRAM
            #include "UnityCustomRenderTexture.cginc"
            #pragma vertex CustomRenderTextureVertexShader
            #pragma fragment frag

            float _Decay;
            float _Gain;

            // Replace with your own input: where the brush is, from 0 to 1 across the texture.
            float2 BrushPosition(float t, float seed)
            {
                return float2(0.5 + 0.38 * sin(0.9 * t + seed * 2.1) + 0.08 * sin(2.3 * t + seed * 4.0),
                              0.5 + 0.34 * sin(1.37 * t + seed * 1.3 + 0.6));
            }

            float3 BrushColor(float t, float seed)
            {
                float k = 0.5 + 0.5 * sin(0.7 * t + seed * 2.5);
                return seed < 0.5 ? lerp(float3(1.0, 0.18, 0.62), float3(1.0, 0.55, 0.12), k)
                                  : lerp(float3(0.10, 0.75, 1.0), float3(0.45, 0.25, 1.0), k);
            }

            float4 frag(v2f_customrendertexture i) : SV_Target
            {
                float2 uv = i.localTexcoord.xy;
                float t = _Time.y;
                float3 paint = float3(0.0, 0.0, 0.0);
                for (int b = 0; b < 2; b++)
                {
                    float2 d = (uv - BrushPosition(t, b)) / 0.04;
                    paint = max(paint, BrushColor(t, b) * exp(-4.0 * dot(d, d)));
                }
                float4 prev = tex2D(_SelfTexture2D, uv);
                return max(prev * (1.0 - _Decay), float4(paint * _Gain, 0.0));
            }
            ENDCG
        }
    }
}
TrailDisplay.shader
// https://docme.dev/articles/custom-render-textures/
Shader "DocMe/CRT/TrailDisplay"
{
    Properties
    {
        _Trail ("Trail", 2D) = "black" {}
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            sampler2D _Trail;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            v2f vert(appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_OUTPUT(v2f, o);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                float3 t = tex2D(_Trail, i.uv).rgb;
                return float4(t * t * 1.4 + t * 0.35, 1.0);
            }
            ENDCG
        }
    }
}
Setup
  1. Put both shader files anywhere in your project.
  2. Right-click each one and choose Create > Material. Call them Trail Update and Trail Display.
  3. Choose Create > Rendering > Custom Render Texture and call it Trail CRT.
  4. Select Trail CRT. Set Width and Height to 512 and Color Format to RGBA Half.
  5. Set Material to Trail Update, Shader Pass to Update, and turn Double Buffered on.
  6. Set Initialization Mode to OnLoad, Update Mode to Realtime with Period 0, and leave Generate Mip Maps off.
  7. Select Trail Display and drag Trail CRT into its Trail slot.
  8. Put Trail Display on a quad or any mesh of your avatar. It is the material that holds the CRT in the upload.

Afterimages from a running average

For a whole picture, let’s blend instead of taking the max:

St=lerp⁡(Ft, St−1, σ)S_t = \operatorname{lerp}(F_t,\ S_{t-1},\ \sigma)

FtF_t is this frame’s picture. lerp⁡(a,b,σ)\operatorname{lerp}(a, b, \sigma) slides from aa to bb as σ\sigma goes from 0 to 1, so σ\sigma, the smear, is the share of the stored picture you keep. A frame that is kk frames old weighs (1−σ) σk(1 - \sigma)\,\sigma^k. The older, the fainter, and that’s why moving things drag a fading ghost. At 1 the picture freezes, ghost and all.

Bouncing balls with ghosts

Turn on JavaScript to run this demo.

How much of the stored picture each frame keeps. 0 shows the live picture.0.90
How often the buffer updates per second.
smear.hlsl draws the demo aboveUnity files
Fig. 4

Play with Smear σ. It stops at 0.9 here, since the demo redraws only 32 frames and cannot reach the freeze at 1. Set Frame rate to 30 fps and the copies spread apart. At 144 fps the ghost gets shorter, because the fade is counted in frames, not seconds.

Smoothing a value, and why the format matters

The same rule on a single texel also smooths a number, with an input II in place of the picture FF. A brightness read from the screen (more on that in In practice) can then make a glow adapt slowly.

Now a tricky part, so take it slow. An 8 bit channel stores 256 steps, and a change smaller than half a step rounds away. Near the target each frame’s change is tiny, so it rounds away and the value stalls. It stops short of its target by up to 0.5/(255 (1−σ))0.5 / (255\,(1 - \sigma)), which is 4% at σ=0.95\sigma = 0.95. For numbers, use RGBA Float (32 bit). Half keeps only about three digits, plenty for a smoothed colour but too few for times or counters.

If your CRT has to be 8 bit, it can split a number over two channels, with the remainder times 255 in the second (Unity’s EncodeFloatRG). That fine part does not survive a trip through the screen, though.

Smoothing a jumpy input

Turn on JavaScript to run this demo.

How much of the stored value each frame keeps. 0 follows the input at once.0.95
Whether the CRT's texture format holds a float or 256 steps per channel.
smooth.hlsl draws the demo above
Fig. 5

Higher Smoothing σ follows more slowly. Now set Storage to 8 bit and push the slider near 0.98. The pink line stalls short of each level, while the dashed float result keeps going.

Storing numbers in pixels

A texel (one pixel of the CRT, remember) holds four numbers: red, green, blue and alpha. So a 4 × 1 RGBA Float CRT keeps sixteen. Let’s give each texel a job and read it at its centre. Here’s my example:

#include "UnityCustomRenderTexture.cginc"
#pragma vertex CustomRenderTextureVertexShader
#pragma fragment frag

float _Smooth; // a fixed setting on the update material

float4 frag(v2f_customrendertexture i) : SV_Target
{
    float2 uv = i.localTexcoord.xy;
    float v = tex2D(_SelfTexture2D, uv).x;     // last frame's number
    int slot = (int)(uv.x * 4.0);              // which texel this is
    float x = ReadInput();                     // see In practice
    if (slot == 0) v = lerp(x, v, _Smooth);    // a smoothed value
    else if (slot == 1 && x > 0.5) v += unity_DeltaTime.x; // a timer
    else if (slot == 2) v = _Time.y;           // a heartbeat
    return float4(v, 0, 0, 0);
}

Texel 0 smooths the input with the rule from the last section. Texel 1 adds the frame time, unity_DeltaTime.x, while the input is on, so it counts seconds. Adding is how a CRT accumulates. Texel 2 copies the clock, _Time.y, and I think of it as the buffer’s heartbeat. When it falls more than half a second behind the clock, the material that shows the effect treats the buffer as stopped and falls back to a look without memory. Plainer, but not frozen.

In practice

An avatar has no scripts, so the CRT asset must run all on its own. These settings make that work:

SettingValueWhy
Materialyour update materialIts shader runs each frame.
Double BufferedonGives _SelfTexture2D.
Update ModeRealtime, period 0Every frame. OnDemand needs a script.
Initialization ModeOnLoadRealtime wipes it every frame.
Color FormatRGBA Float for numbers, RGBA 8 bit for picturesSee above.
Filter ModePoint for numbersNeighbouring texels never blend.
MipmapsoffThey add a third more memory, rebuilt every update.
  • Keeping it running. A material on the avatar must use the CRT, usually the one that shows the result, so it comes along in the upload. The animator cannot reach the update material (it sits on no mesh). Each viewer runs their own copy and nothing is synced. Here’s a funny side effect. Two wearers of one avatar share one CRT, so their trails mix. PC only.
  • The update bug. A bug report from 2019, still marked tracked, says avatar CRTs can freeze after an avatar switch or an instance join until VRChat restarts. The heartbeat texel from above catches that.
  • Frame rate. Remember that the fade is counted in frames? At the default decay, half a trail is gone after 17 frames. That takes 0.28 s at 60 fps but only 0.12 s at 144 fps. For the same fade everywhere, raise (1−δ)(1 - \delta) to the power 60 Δt60\,\Delta t, with Δt\Delta t the frame time in seconds.
  • Cost. The update runs once per texel every frame on every viewer’s GPU. For a little data CRT that’s next to nothing, while a screen sized one costs as much as a full screen effect. Memory is width × height × bytes per texel, twice for the two ping-pong copies:
CRTFormatMemory
4 × 1, a few numbersRGBA Float, 16 bytes128 bytes
512 × 512, a trail mapRGBA 8 bit, 4 bytes2 MB
2048 × 1024, a screen copyRGBA 8 bit, 4 bytes16 MB

So where does the input come from? Besides the globals every shader sees (time, VRChat’s globals, AudioLink in worlds that have it), the avatar can send its own data in two ways:

  • Read the screen. A GrabPass copies the screen into a texture. Give it a name, GrabPass { "_MyGrab" }, and it makes a global texture that any shader can read, the CRT update included. CRTs update before the cameras draw, so the copy is one frame old. It comes from whichever camera drew last (a mirror or the photo camera too), and only when the mesh with the GrabPass was drawn.
  • Signal pixels. The avatar’s own material, which the animator can drive, paints numbers as colours into a corner of the screen, and the CRT reads them back from the grab. Since other cameras draw too, I start the block with a fixed colour the CRT checks first, like a secret handshake. I store 16 levels per channel, which survive 8 bit and float screens, and draw over the block again so nobody sees it.

My screen smear prototype reads the copy made by a GrabPass named _DocMeCRTGrab. It’s the afterimage rule from earlier, now on the real screen:

CRTGrabPass.shaderRuns in Unity only
sampler2D _DocMeCRTGrab;
// ...
    float4 fresh = tex2D(_DocMeCRTGrab, uv);   // this frame's screen (1-frame latency)
    float4 prev  = tex2D(_SelfTexture2D,  uv);  // previous buffer (CRT is Double Buffered)
    // ...
    return lerp(fresh, prev, _Smear);

Here are the complete files, a CRT and a material that shows it. The mesh needs the GrabPass, so the display shader carries it.

The afterimage in Unity
AfterimageUpdate.shader
// https://docme.dev/articles/custom-render-textures/
Shader "DocMe/CRT/AfterimageUpdate"
{
    Properties
    {
        _Smear ("Smear", Range(0, 0.99)) = 0.9
    }
    SubShader
    {
        Lighting Off
        Blend One Zero

        Pass
        {
            Name "Update"
            CGPROGRAM
            #include "UnityCustomRenderTexture.cginc"
            #pragma vertex CustomRenderTextureVertexShader
            #pragma fragment frag

            sampler2D _DocMeCRTGrab;
            float _Smear;

            float4 frag(v2f_customrendertexture i) : SV_Target
            {
                float2 uv = i.localTexcoord.xy;
                float4 fresh = tex2D(_DocMeCRTGrab, uv);
                float4 prev = tex2D(_SelfTexture2D, uv);
                return lerp(fresh, prev, _Smear);
            }
            ENDCG
        }
    }
}
AfterimageDisplay.shader
// https://docme.dev/articles/custom-render-textures/
Shader "DocMe/CRT/AfterimageDisplay"
{
    Properties
    {
        _Afterimage ("Afterimage", 2D) = "black" {}
    }
    SubShader
    {
        Tags { "Queue" = "Transparent" "RenderType" = "Transparent" }
        ZWrite Off

        GrabPass { "_DocMeCRTGrab" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            sampler2D _Afterimage;

            struct appdata
            {
                float4 vertex : POSITION;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float4 grabPos : TEXCOORD0;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            v2f vert(appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_OUTPUT(v2f, o);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.pos = UnityObjectToClipPos(v.vertex);
                o.grabPos = ComputeGrabScreenPos(o.pos);
                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                return float4(tex2Dproj(_Afterimage, i.grabPos).rgb, 1.0);
            }
            ENDCG
        }
    }
}
Setup
  1. Put both shader files anywhere in your project.
  2. Right-click each one and choose Create > Material. Call them Afterimage Update and Afterimage Display.
  3. Choose Create > Rendering > Custom Render Texture and call it Afterimage CRT.
  4. Select Afterimage CRT. Set Width to 1024, Height to 512 and Color Format to RGBA 8 bit.
  5. Set Material to Afterimage Update, Shader Pass to Update, and turn Double Buffered on.
  6. Set Initialization Mode to OnLoad, Update Mode to Realtime with Period 0, and leave Generate Mip Maps off.
  7. Select Afterimage Display and drag Afterimage CRT into its Afterimage slot.
  8. Put Afterimage Display on a quad in front of the camera or on a large mesh of your avatar. It shows what is behind it with the ghost. Use it on one mesh only, because every mesh with the GrabPass copies the screen.

Credits

  • CustomRenderTexture, _SelfTexture2D and double buffering are Unity features.
  • Keeping values in a CRT and stamping them with the time follows the video decoder in lox9973’s ShaderMotion.

Takeaways

  • A CRT reads last frame through _SelfTexture2D and writes this frame. Make it Double Buffered, Realtime and initialized once, and your shader has a memory.
  • Peak hold makes fading trails, a running average makes afterimages and smooth values, and adding makes counters.
  • Store numbers in RGBA Float, send input through the screen, and keep a heartbeat so you notice when the buffer stops.
Search the lessons

Type any word from the lessons, like normalize or smoothstep. The start of a word is enough.