Ryan Brucks Improved UV Dilation Unity Implementation

In the quest to fix seam lighting with normal maps, I found Ryan Brucks had done an apparently really effective solution, but my port of that is bugged.

Can you cast your eyes over this and spot where the problem might be? I’m getting white spots and a generally bad result and it’s probably something I missed when porting.

Original Code:

//////////////// UV Positional Dilation ///////////////////////////
//** Tex **// Input Texture Object storing Volume Data
//** UV **// Input float2 for UVs
//** TextureSize **// Resolution of render target
//** MaxSteps **// Pixel Radius to search


float texelsize = 1 / TextureSize;
float mindist = 10000000;
float2 offsets[8] = {float2(-1,0), float2(1,0), float2(0,1), float2(0,-1), float2(-1,1), float2(1,1), float2(1,-1), float2(-1,-1)};

float3 sample = Tex.SampleLevel(TexSampler, UV, 0);
float3 curminsample = sample;

if(sample.x == 0 && sample.y == 0 && sample.z == 0)
{
    int i = 0;
    while(i < MaxSteps)
    {
        i++;
        int j = 0;
        while (j < 8)
        {
            float2 curUV = UV + offsets[j] * texelsize * i;
            float3 offsetsample = Tex.SampleLevel(TexSampler, curUV, 0);

            if(offsetsample.x != 0 || offsetsample.y != 0 || offsetsample.z != 0)
            {
                float curdist = length(UV - curUV);

                if (curdist < mindist)
                {
                    float2 projectUV = curUV + offsets[j] * texelsize * i * 0.25;
                    float3 direction = Tex.SampleLevel(TexSampler, projectUV, 0);
                    mindist = curdist;

                    if(direction.x != 0 || direction.y != 0 || direction.z != 0)
                    {
                        float3 delta = offsetsample - direction;
                        curminsample = offsetsample + delta * 4
                    }

                   else
                    {
                        curminsample = offsetsample;
                    }
                }
            }
            j++;
        }
    }
}

return curminsample;

My port:

Shader "Internal/Dilate"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float _TexelSize;
            int _MaxSteps;

            float3 Sample(float2 uv, float lod)
            {
                return tex2Dlod(_MainTex, float4(uv, 0, lod)).rgb;
            }

            float3 SimpleDilate(float2 uv)
            {   
                float2 offsets[8] = { float2(-1,0), float2(1,0), float2(0,1), float2(0,-1), float2(-1,1), float2(1,1), float2(1,-1), float2(-1,-1) };
                float minDist = 10000000;
                float3 samp = Sample(uv, 0);
                float3 currentMinSample = samp;

                if (samp.x == 0 && samp.y == 0 && samp.z == 0)
                {
                    int i = 0;
                    while (i < _MaxSteps)
                    {
                        i++;
                        int j = 0;
                        while (j < 8)
                        {
                            float2 offsetUV = uv + offsets[j] * _TexelSize * i;
                            float3 offsetSample = Sample(offsetUV, 0);

                            if (offsetSample.x != 0 || offsetSample.y != 0 || offsetSample.z != 0)
                            {
                                float dist = length(uv - offsetUV);
                                if (dist <= minDist)
                                {
                                    minDist = dist;
                                    float2 extrapolatedUV = offsetUV + offsets[j] *_TexelSize * i * 0.25;
                                    float3 direction = Sample(extrapolatedUV, 0);

                                    if (direction.x != 0 || direction.y != 0 || direction.z != 0)
                                    {
                                        float3 delta = offsetSample - direction;
                                        currentMinSample = offsetSample + delta * 4;
                                    }
                                    else
                                    {
                                        currentMinSample = offsetSample;
                                    }
                                }
                            }
                            j++;
                        }
                    }
                }

                return currentMinSample;
            }

            v2f vert (appdata v)
            {
                v2f o;
                o.uv = v.uv;
                o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                return float4(SimpleDilate(i.uv), 1);
            }
            ENDCG
        }
    }
}

Original article and explanation:

Thanks for any insights :slight_smile:

I’m basically assuming linear textures here, for both UE4 and Unity, but I don’t know if somehow the texels are wrong or, it just seems odd that it wouldn’t work as expected since the code is so simple. Could it be a difference in how UE4 vs Unity sample textures? Any suggestions will help at this point I think.

Seems to work okay? What results are you getting?

The breakthrough was when I realized it probably needs higher precision source, since if it’s extrapolating, lack of precision gets amplified and makes results look a bit weird.

Hmmm nevermind, with weirder black gaps it does produce white texels for me too.

Although, even in one of his examples there are some weird white artifacts as well, (see : https://storage.googleapis.com/wzukusers/user-22455410/images/57db548291436yaCoBNI/DilatedBP.jpg ), so maybe it’s a byproduct of the method?

Brain’s too tired to figure out if the white artifacts somehow make sense.

EDIT: I wonder if you switched it to using an alpha channel instead of pure black pixels if it would improve things.

Yeah, I don’t think you got anything wrong. I think his original technique is mildly flawed. For some use cases it might produce slightly better results than the traditional dilate, and possibly worse in others. Even his “look how much better this looks” example is still clearly not right. Better, but not right.

The real solution to those kinds of UV seam issues would be something that actually looks at the texture on the other side of the seam and tries to fit the two edges together.
http://miciwan.com/SIGGRAPH2013/Lighting Technology of The Last Of Us.pdf
https://www.sebastiansylvan.com/post/LeastSquaresTextureSeams/
I’ve seen more on this topic before, but I can’t track down the links for some reason. However it should also be called out that Unity already has this functionality, at least for its own Progressive Lightmapper lightmaps.

Too bad this doesn’t seem to be exposed anywhere as something you could just call on a mesh w/ an existing texture. Though it those techniques would totally mess up tangent space normal maps. Really you’d want to convert to world space normals, do the edge fixup, and then transform back into tangent space, which gets tricky when you’re talking about the tangent space outside of a triangle’s bounds, and since you may round out intentional hard edges.

This sounds like a horror story ahead of me to be honest…

Some more resources:

https://github.com/zfergus/seam-erasure-js
(hot as hell results but thoughts? they claim normals would be OK)

https://github.com/qiankanglai/seamoptimizer
(a unity implementation of the last of us approach, untested though)

Having a think about those or doing some experimental compute shader lark. Does seem a shame we can’t get the pixels in other uv islands based on some tangent traversal… then blend them.

I would assume world space normal maps are being used, in which case it’d totally work.

The main thing to realize is the areas outside of the UV’d area might be ugly, and that’s totally okay. There’s an undue focus on making sure the dilated texture itself looks nice and is free of hard edges. If you look at a lightmap that’s had some forms of seam correction applied, it can look like total chaos in the areas outside of the triangle bounds. Weird bright and dark spots that are clearly wrong … but actually aren’t. They exist because they’re there to get the interpolated values on the seam itself to match up, not to try to get the texture that you’ll never see to look nice. The papers with those techniques were the ones I was thinking of for this topic rather than the two I linked to. Not sure where the ones I was thinking of are unfortunately. I don’t think they weren’t explicitly on the topic of lightmap seams, but rather general UV seams when doing 3D painting.

All that said, those bright spots in the above are actually wrong. Though I can’t figure out what the cause is just running the code over in my head.

The bright spots get reduced (although some weirdness stays) if I switch the original image to point filtering (although then the result has a lot more banding), which makes me think that with bilinear, it somehow samples a value that is interpolated towards black a bit, so it extrapolates much more than it needs to…? Or something like that.

This seems to be based on this : https://cragl.cs.gmu.edu/seamless/

From a quick glance, it seems like it touches a lot more texels than just the ones around the seams, so it’s like multiple steps further than the least squares method. Examples look good though.

Although I feel a bit weird on how much different the “fixed” version of this normal map is (at around 0:38):

https://www.youtube.com/watch?v=kCryf9n82Y8

Yeah but the seam looks a bit more weird than not so I’d probably go for that. They have a python version and Unity can run python scripts… maybe it’s just safer porting the js version though since I’ve 0 python expertise and would just cock it up completely.

Re: white spots, I also tried your suggestion of storing in .a but that led to absolutely no difference as you can imagine.

Do the following:

Switch the masking to .a

But then instead of checking if (sample.a == 0)

do

if (sample.a < 1.0)

and also change the two following if statements to

if (offsetSample.a == 1)

if (direction.a == 1)

This makes it so that it rejects values that are interpolated towards black even in the slightest.

This improved things a lot for me (although some artifacts still remain, the one on the top left is quite visible, but maybe this pushes it into “good enough” territory?)

6819800--792110--improved.jpg

In case anyone is interested I also wrote a simple editor script that writes the dilated results to a *****_dilated.png file.

You can find it under Tools / Dilate Texture

6827699–793619–texturedilation.unitypackage (2.4 KB)

I tried your tweaks and they do help! I think this thread and your latest package will help others too, so thank you! Very cool of you to add a package.

As for me, it helps for runtime texture edits, but for the final game I’m going to have to at some point implement a proper (slow) edge blend on the editor side. A shame Unity doesn’t expose code that already does this.