Texture-space Decals

I wanted to write up an interesting method of projected decal rendering I’ve been working on recently.

decal_many

The basic idea is (relatively) straightforward. To place a decal onto a mesh:
– Render the decal into a texture, projected onto the mesh’s UVs.
– Render the mesh using the decal texture as an overlay, mask, etc.

Naturally, the details get a bit more complex.

(I implemented this in Unity; unlike the rest of this blog, code samples will be using C# and Cg.)

Texture-space rendering

Rendering into texture space is not a new technique, but it is rather uncommon. (The canonical runtime usage is subsurface scattering: render diffuse lighting into a texture, blur it, and sample the result in the main shading pass.)

It is simpler than it may seem: we base the vertex shader output on the mesh UV coordinates, rather than positions:

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

struct v2f
{
    float4 vertex : SV_POSITION;
};

v2f vert (appdata v)
{
    v2f o;
    // Remap UV from [0,1] to [-1,1] clip space (inverting Y along the way.)
    o.vertex = float4(v.uv.x * 2.0 - 1.0, 1.0 - v.uv.y * 2.0, 0.0, 1.0);
    return o;
}

(The Y coordinate of UVs is inverted here; this may vary depending on your renderer.)

In order to project the decal into the texture, we add a matrix uniform for the projection and a texture sampler for the decal texture itself. The vertex shader then projects the vertex position using this matrix, which gives us a UV coordinate for sampling the decal texture.

(Yes, input UVs become output positions and input positions become output UVs here. It’s a bit confusing.)

The fragment shader takes this projected UV coordinate, and performs a range check. If every component is within the range [0, 1], then the we sample the decal texture. Otherwise, the fragment is outside the projection and the shader discards the fragment.

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

struct v2f
{
    float3 uvw0 : TEXCOORD0;
    float4 vertex : SV_POSITION;
};

sampler2D _MainTex;
float4 _MainTex_ST;
float4 _Color;

float4x4 _DecalProjection0;

v2f vert (appdata v)
{
    v2f o;
    // Remap UV from [0,1] to [-1,1] clip space (inverting Y along the way.)
    o.vertex = float4(v.uv.x * 2.0 - 1.0, 1.0 - v.uv.y * 2.0, 0.0, 1.0);

    float4 world_vert = mul(unity_ObjectToWorld, v.vertex);
    // We assume the decal projection is orthographic, and omit the divide by w here.
    o.uvw0 = mul(_DecalProjection0, world_vert).xyz * 0.5 + 0.5;
    return o;
}

bool CheckBox(float3 uvw)
{
    return !(uvw.x < 0.0 || uvw.y < 0.0 || uvw.z  1.0 || uvw.y > 1.0 || uvw.z > 1.0);
}

fixed4 frag (v2f i) : SV_Target
{
    bool in0 = CheckBox(i.uvw0);

    // Don't touch this pixel if we're outside the decal bounds.
    if (!in0) discard;

    // In this instance, _MainTex contains an alpha mask for the decal.
    return tex2D(_MainTex, i.uvw0.xy).a * _Color;
}

(While this shader uses the world-space position, using object-space positions will give better results in practice, as it will maintain precision for objects far away from the world origin.)

On the CPU side, we drive the decal rendering from script:

public void SplatDecal(Material decal_material, Matrix4x4 decal_projection)
{
    int pass_idx = decal_material.FindPass("DECALSPLATPASS");
    if (pass_idx == -1)
    {
        Debug.LogFormat("Shader for decal splat material '{0}' does not have a pass for DecalSplatPass.", decal_material.name);
        return;
    }

    RenderTexture active_rt = RenderTexture.active;
    m_DecalTexture.MarkRestoreExpected();
    Graphics.SetRenderTarget(m_DecalTexture);
    decal_material.SetMatrix("_DecalProjection0", decal_projection);
    if (!decal_material.SetPass(pass_idx))
    {
         Debug.Log("Decal splat material SetPass failed.");
         return;
    }
    Graphics.DrawMeshNow(m_Mesh, transform.position, transform.rotation);
    Graphics.SetRenderTarget(active_rt);
}

Producing the decal projection matrix is an interesting problem in and of itself, but I’m not going to get into detail here. In general, we want an orthographic projection matrix corresponding to the oriented bounding box of the decal in world space. (Again, in a production setting, applying decals in object space is likely to produce better results.)

Visual Artifacts

The primary visual artifact resulting from this method is visible UV seams:
decal_seam

The reason this occurs is that our decal rendering and final mesh rendering disagree slightly on what texels are affected by the UVs of a given triangle. This is due to the GPU’s rasterization fill rules: fragments along the edge of a triangle are deemed in or out of the triangle based on a consistent rule so that when two triangles share an edge the fragment is only shaded once. While absolutely the correct thing to do for general rendering, it bites us here.

Conservative rasterization extensions should fix this issue. However, Unity does not expose this extension to users yet, and in any case it is not widely available.

The fix I implemented is an image pass that grows the boundaries of UV islands by one texel.

First, we render a single-channel mask texture, using the same texture-space projection as above, and output a constant 1. The mask now contains 1 on every texel touched by UVs, and 0 everywhere else. This mask is rendered only once, at initialization. (Really, we could even bake this offline.)

Next, after rendering a decal, we run a pass over the entire texture, creating a second decal texture. This pass samples the mask texture at each texel: if the sample is 1, the texel is passed through unchanged into the output. However, if the sample is 0, we sample the neighboring texels. If any of those are 1, the corresponding color samples are averaged together and output.

The resulting fragment shader looks like this:

sampler2D _MainTex;
float4 _MainTex_TexelSize;
sampler2D _MaskTex;

fixed4 frag (v2f i) : SV_Target
{
    float mask_p11 = tex2D(_MaskTex, i.uv).r;
    if (mask_p11 > 0.5)
    {
        // This pixel is inside the UV borders, pass through the original color
        return tex2D(_MainTex, i.uv);
    }
    else
    {
        // This pixel is outside the UV border.  Check for neighbors inside the border and output their average color.
        //
        //   0 1 2
        // 0   -
        // 1 - X -
        // 2   -
        //
        float2 uv_p01 = i.uv + float2(-1, 0) * _MainTex_TexelSize.xy;
        float2 uv_p21 = i.uv + float2(1, 0) * _MainTex_TexelSize.xy;

        float2 uv_p10 = i.uv + float2(0, -1) * _MainTex_TexelSize.xy;
        float2 uv_p12 = i.uv + float2(0, 1) * _MainTex_TexelSize.xy;

        float mask_p01 = tex2D(_MaskTex, uv_p01);
        float mask_p21 = tex2D(_MaskTex, uv_p21);
        float mask_p10 = tex2D(_MaskTex, uv_p10);
        float mask_p12 = tex2D(_MaskTex, uv_p12);

        fixed4 col = fixed4(0, 0, 0, 0);
        float total_weight = 0.0;
        if (mask_p01 > 0.5) {
            col += tex2D(_MainTex, uv_p01);
            total_weight += 1.0;
        }
        if (mask_p21 > 0.5) {
            col += tex2D(_MainTex, uv_p21);
            total_weight += 1.0;
        }
        if (mask_p10 > 0.5) {
            col += tex2D(_MainTex, uv_p10);
            total_weight += 1.0;
        }
        if (mask_p12 > 0.5) {
            col += tex2D(_MainTex, uv_p12);
            total_weight += 1.0;
        }

        if (total_weight > 0.0) {
            return col / total_weight;
        }
        else {
            return col;
        }
    }
}

Note the mask comparisons are “greater than 0.5”, not “equal to 1”. Depending on the layout and packing of UVs, you may be able to get away with storing the mask texture in half-resolution and using bilinear filtering to reconstruct the edge. This produces artifacts where UV islands are within a few texels of each other (as they merge on the lower-resolution rendering).

Seams are filled:
decal_seam_fix

On the CPU side, this is a straightforward image pass:

public void DoImageSpacePasses()
{
    int pass_idx = DecalImageEffectMaterial.FindPass("DECALUVBORDEREXPAND");
    if (pass_idx == -1)
    {
        Debug.Log("Could not find decal UV border expansion shader pass.");
        return;
    }

    // We replace the existing decal texture after the blit.  Copying the result back into
    // the original texture takes time and causes flickering where the texture is used.
    RenderTexture new_rt = new RenderTexture(m_DecalTexture);

    DecalImageEffectMaterial.SetTexture("_MaskTex", m_UVMaskTexture);
    Graphics.Blit(m_DecalTexture, new_rt, DecalImageEffectMaterial, pass_idx);

    m_DecalTexture.Release();
    m_DecalTexture = new_rt;
    m_Material.SetTexture("_DecalTex", m_DecalTexture);
}

Of course, in doing this, we create a new render target each time we run this pass. Therefore, it’s not a great idea to run the image pass after every decal is rendered. Instead, render a batch of decals and run the image pass afterwards.

Advantages

Okay, so what does all of this *get* us, in comparison to existing decal techniques?

– The computation cost of a decal is low. There is a fixed per-frame overhead in using the decal texture in the main shader. Each decal rendered is a one-time cost as it renders to the texture. (The UV fixup can be amortized across a batch of decals, making it even cheaper.)

Contrast deferred decals, which require extra draw calls every frame. Permanent decals are therefore very expensive over time. (Forward+ decals encounter similar issues.)

The price paid, of course, is memory: the decal texture takes memory, the mask texture takes memory. The full decal texture must be kept around, even for distant objects, because we must render into the top mip at all times. (Otherwise, we’re stuck needing to upsample the rendered decal when the object gets closer.)

– Support for skinned meshes.

Deferred decals do not work for skinned meshes. While you can in theory attach them to bones and move the decal around with animation, the decal will not deform with the mesh, and so will appear to slide across the surface.

This is not a problem for the texture-space approach; the decal texture will stretch and squash around joints just like any other texture.

– Avoids UV distortion compared to simply “stamping” decals into the texture. By projecting the decal using the mesh vertex positions, we maintain size and shape, regardless of the topology of the UVs underneath. (As well as wrapping around UV seams.) This means texture-space decals still work well on organic shapes, where UVs may be stretched, skewed, etc. Do note this comes at the cost of inconsistent texel density over a decal in these cases; we can’t have everything.

Tradeoffs

This technique does come with some important caveats.

– No UV overlap.

Overlapping UVs will cause a decal in one area to show up elsewhere. (And since the overlap is almost always along a UV island boundary, one of these will have a sharp edge.)

– Sensitive to UV distortion.

While the texture projection can compensate for minor distortion in UVs, severe stretching will result in wildly varying texel density across the decal.

Both of these combine to make texture-space decals generally inadvisable for environments. (As environment art leans heavily on both UV overlap and stretching across low-detail regions.)

– Unique texture per object.

Each object using this decal method needs its own texture to store the decal layer. These can be combined into a texture atlas to reduce the impact on render batching, but the memory cost is still significant.

Conclusion

This is not a generally-applicable decal method. Deferred / Forward+ decal rendering still solves the general case much better.

However, this *is* a useful tool for decals on a relatively small number of meshes, in cases where general methods aren’t optimal: skinned meshes, permanent decals, large numbers of decals. As an example, a recent trend in FPS games is decal overlays for first-person weapon meshes (i.e, blood splatter on a recently-used melee weapon). Rather than pre-author them, this technique would allow those overlays to be generated dynamically.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s