Shader Graph - Getting local sprite UV from sprite sheet

This implies that handwritten shaders would work with atlases, and it’s just a ShaderGraph thing. Or am I misunderstanding?

I can’t tell, I have 0 clue of how hand write shaders and relied heavily on shader graph since it came out, and that hit hard when we started porting to Nintendo Switch.

HLSL is certainly an acquired taste, that’s for sure.

After struggling with this problem for some time, I found a fairly simple workaround: you can grab the UV coordinates of a sprite in a script, and provide them to a Shader Graph via a MaterialPropertyBlock. In Shader Graph, you can then remap the UV coordinates with the min/max UV values the sprite covers. Here’s a sample script to do this:

using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class UVFromSprite : MonoBehaviour
{
    void Awake()
    {
        SpriteRenderer spriteRenderer = GetComponent<SpriteRenderer>();
        float minU = 1;
        float maxU = 0;
        float minV = 1;
        float maxV = 0;
        foreach (Vector2 uv in spriteRenderer.sprite.uv) {
            minU = Mathf.Min(uv.x, minU);
            maxU = Mathf.Max(uv.x, maxU);
            minV = Mathf.Min(uv.y, minV);
            maxV = Mathf.Max(uv.y, maxV);
        }

        MaterialPropertyBlock block = new MaterialPropertyBlock();
        spriteRenderer.GetPropertyBlock(block);
        block.SetVector("U", new Vector2(minU, maxU));
        block.SetVector("V", new Vector2(minV, maxV));
        spriteRenderer.SetPropertyBlock(block);
    }
}

For the sake of simplicity, the sample script just calculates the values in Awake(), but it should only take some minor modification to have the script precalculate the correct values in the editor to avoid extra computation during gameplay.

3 Likes

Hmm. Sounds pretty easy to work with. Only issue is… if I remember correctly, adding MaterialPropertyBlock breaks batching of that component, so it’s not that great for performance.
But if we go with a similar solution - SRP batcher may, actually, work better for such cases - it batches different materials with slightly different properties if they actually support SRP.

2 Likes

Yeah, this will break batching, but I don’t see a way to get around it apart from the method mentioned earlier where you generate a second texture to sample the local UV from. However this requires a lot of engineering effort and adds tons of new textures, depending on how many of your SpriteSheets need this effect on.
If used in moderation I think Lancival’s approach of calculating the UV Range via script is a cool solution! In case somebody wants to plug this into ShaderGraph:


You can use the resulting SpriteUV in place of the UV0. Keep in mind that the SampleTexture2D node still needs to use the original UV0!

When using this with sprite animations you might run into a problem where your UV is offset slightly for each frame, because the frames are cropped and therefore slightly different in size.
For this I came up with a solution where you define a fixed width&height for the UV and set it based on the pivot point of the Sprite. That way the UV is always the same “size” and always originates at the pivot point:

private Vector4 CalcUvRange(Sprite sprite)
{
    Vector2 textureSize = new(sprite.texture.width, sprite.texture.height);
    Vector2 fixedSize = spriteUvSize * sprite.pixelsPerUnit / textureSize;

    Vector2 spriteUvPos = CalcSpriteUvPos(sprite);
    spriteUvPos += sprite.pivot / textureSize;
    spriteUvPos += spriteUvOriginOffset * fixedSize;

    return new Vector4(
        spriteUvPos.x, spriteUvPos.x + fixedSize.x,
        spriteUvPos.y, spriteUvPos.y + fixedSize.y
    );
}

private static Vector2 CalcSpriteUvPos(Sprite sprite)
{
    Vector2 uvPos = Vector2.one;
    foreach (Vector2 uv in sprite.uv)
    {
        uvPos.x = Mathf.Min(uv.x, uvPos.x);
        uvPos.y = Mathf.Min(uv.y, uvPos.y);
    }

    return uvPos;
}
4 Likes

Thanks for this, I think the solution of Lancival is quite elegant and it works perfectly! And thanks for clarifying how to use the resulting values in shadergraph!
I do have a question about your code though, first a small fix for textureSize, it should be new Vector2() :slight_smile:
But I do have two variables which are not declared : spriteUvSize and spriteUvOriginOffset, could you add those to your code? I can figure it out myself but it’s also easier for others who stumble upon this thread in the future:-)

Hi, “new(…)” is called a “target-typed new expressions” and is valid syntax since C# 9. :slight_smile: The type declaration already provides the Vector2 typing so writing it again for the constructor call is not necessary.

As for spriteUvSize and spriteUvOriginOffset, they are both Vector2 fields and where they come from heavily depends on your code structure and how you want to use the snippet. I use this method in a MonoBehaviour and have them defined as a SerializeField like this:

[SerializeField] private Vector2 fixedUvSize;
[SerializeField] private Vector2 spriteUvOriginOffset;

My snippet was not supposed to be a copy&paste solution, but an approach to make it work in your own code base. You basically need to configure both values so it looks good with your current animation, there is no “correct” way to calculate them as it depends on multiple frames.

With that said you can calculate the fixedUvSize & spriteUvOriginOffset for the current Sprite like this:

[Button, UsedImplicitly]
private void SetFixedParamsFromCurrentSprite()
{
    Sprite spr = spriteRenderer.sprite;
    Vector4 calculatedUVRange = CalcSpriteUvRange(spr);
    Vector2 minUV = new(calculatedUVRange.x, calculatedUVRange.z);
    Vector2 maxUV = new(calculatedUVRange.y, calculatedUVRange.w);

    Vector2 textureSize = new(spr.texture.width, spr.texture.height);
    Vector2 uvSize = maxUV - minUV;
    fixedUvSize = uvSize * textureSize / spr.pixelsPerUnit;

    Vector2 spriteUvPos = CalcSpriteUvPos(spr);
    spriteUvPos += spr.pivot / textureSize;
    spriteUvOriginOffset = (minUV - spriteUvPos) / uvSize;
}

private static Vector4 CalcSpriteUvRange(Sprite sprite)
{
    Vector4 range = new(1, 0, 1, 0);
    foreach (Vector2 uv in sprite.uv)
    {
        if (uv.x < range.x) range.x = uv.x;
        if (uv.x > range.y) range.y = uv.x;
        if (uv.y < range.z) range.z = uv.y;
        if (uv.y > range.w) range.w = uv.y;
    }

    return range;
}

private static Vector2 CalcSpriteUvPos(Sprite sprite)
{
    Vector2 uvPos = Vector2.one;
    foreach (Vector2 uv in sprite.uv)
    {
        if (uv.x < uvPos.x) uvPos.x = uv.x;
        if (uv.y < uvPos.y) uvPos.y = uv.y;
    }

    return uvPos;
}

Just keep in mind that this is only the UV for this one sprite and not every sprite in your animation.

I think the shadergraph node here is mixed up. I used Lancival’s code, and then constructed the shader graph from your picture. The remap is actually the reverse of what is needed. I put the “U” and “V” vectors in the “Out Min Max”, instead of the “In Min Max”. The “In Min Max” is then set to “0, 1” range Then the operation worked as expected.

1 Like

Hi guys just changed the script a bit to prevent breaking the SRP batching by passing the information to the alternate UV infos.

using Standard_Assets.Attributes;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.U2D;

namespace Utilities.SpriteUtilities
{
    [ExecuteAlways]
    [RequireComponent(typeof(SpriteRenderer))]
    public class URPSpriteUVTexCoord : MonoBehaviour
    {
        [SerializeField] private SpriteRenderer spriteRenderer;
        
        private void Awake()
        {
            spriteRenderer = GetComponent<SpriteRenderer>();
            InitParams();
            
            if( spriteRenderer )
                spriteRenderer.RegisterSpriteChangeCallback( SpriteChanged );
        }
        
        private void OnEnable()
        {
            if( !spriteRenderer )
            {
                spriteRenderer = GetComponent<SpriteRenderer>();
                if( spriteRenderer )
                    spriteRenderer.RegisterSpriteChangeCallback( SpriteChanged );
            }
            
            InitParams();
        }

        private void OnDestroy()
        {
            spriteRenderer.UnregisterSpriteChangeCallback( SpriteChanged );
            if( spriteRenderer )
                spriteRenderer.SetPropertyBlock( null );
        }
        
        private bool InitWaiting;
        private void Update()
        {
            if( InitWaiting )
                InitParams();
        }

        private void InitParams()
        {
            if( !spriteRenderer || !spriteRenderer.sprite )
            {
                InitWaiting = true;
                return;
            }
            
            InitWaiting = false;
            
            float minU = 1;
            float maxU = 0;
            float minV = 1;
            float maxV = 0;
            foreach( var uv in spriteRenderer.sprite.uv ) 
            {
                minU = Mathf.Min(uv.x, minU);
                maxU = Mathf.Max(uv.x, maxU);
                minV = Mathf.Min(uv.y, minV);
                maxV = Mathf.Max(uv.y, maxV);
            }
            
            // calculer la position de l'uv du sprite lui meme dans ses coordonnées locales et l'assigner en UV1
            var uv1 = new NativeArray<Vector2>(spriteRenderer.sprite.uv.Length, Allocator.Temp);
            for( var i = 0; i < uv1.Length; i++ )
            {
                var uvactu = spriteRenderer.sprite.uv[i];
                var u1     = Mathf.InverseLerp( minU, maxU, uvactu.x );
                var v1     = Mathf.InverseLerp( minV, maxV, uvactu.y );
                uv1[i]     = new Vector2( u1,v1 );
            }
            spriteRenderer.sprite.SetVertexAttribute( VertexAttribute.TexCoord1, uv1 );
            
            // assigner minU,maxU dans UV2
            var u = new NativeArray<Vector2>(spriteRenderer.sprite.uv.Length, Allocator.Temp);
            for( var i = 0; i < u.Length; i++ )
                u[i] = new Vector2( minU,maxU );
            spriteRenderer.sprite.SetVertexAttribute( VertexAttribute.TexCoord2, u );
            
            // assigner minV,maxV dans UV3
            var v = new NativeArray<Vector2>(spriteRenderer.sprite.uv.Length, Allocator.Temp);
            for( var i = 0; i < u.Length; i++ )
                v[i] = new Vector2( minV,maxV );
            spriteRenderer.sprite.SetVertexAttribute( VertexAttribute.TexCoord3, v );
        }
        
        private void Start()                            { InitParams(); }
        private void Reset()                            { InitParams(); }
        private void OnDidApplyAnimationProperties()    { InitParams(); }
        private void SpriteChanged( SpriteRenderer sr ) { InitParams(); }
    }
}

after that you will have the local coordinate of the sprite in UV1, the min-max U range in UV2 and the min-max V range in UV3

2 Likes

I saw your mention of Flipbook and used it to solve my problem. Thanks!

Flipbook note look at a single tile in the sprite sheet, instead of using current renderer2D sprite like _MainTex, so UV changes only affect a single. Now the shader need a script to know when the sprite change in the renderer2d.

First I add the Flipbook node to my Shader Graph, then with script (on Update()) I take the current sprite name from the renderer2D, turn it’s number into Int, and pass that Int into the Flipbook Tile.

And yes it only works if sprite sheet has same size sprites.

This discussion was really helpful to me, thanks!

I thought I’d share my shader which adds support for repeating textures:

This depends on UV2 and UV3 as provided by the code in @BBO_Lagoon’s post above. Also, I have filtering set to Point since I’m working with pixel art. As far as I can tell, if you wanted to support filtered textures with mipmaps you’d have to do some additional work, but I didn’t need that for my game.

Try this works perfectly.


[ExecuteAlways]
public class AtlasRemap : MonoBehaviour
{
    [SerializeField] SpriteRenderer sr;
    [SerializeField] Vector4 UVRemap;

    void OnEnable()
    {
        if (Application.isPlaying)
        {
            sr.material.SetVector("_UVRemap", UVRemap);
        }
        else
        {
            sr = GetComponent<SpriteRenderer>();
            UVRemap = new(
            sr.sprite.textureRect.x * sr.sprite.texture.texelSize.x,
            sr.sprite.textureRect.y * sr.sprite.texture.texelSize.y,
            sr.sprite.textureRect.width * sr.sprite.texture.texelSize.x,
            sr.sprite.textureRect.height * sr.sprite.texture.texelSize.y
            );
            var block = new MaterialPropertyBlock();
            sr.GetPropertyBlock(block);
            block.SetVector("_UVRemap", UVRemap);
            sr.SetPropertyBlock(block);
        }
    }
}

Subgraph Set Up

Node Set Up (Just use it in place of the regular UV Node)

Enjoy :slight_smile:

A simpler way that doesn’t require having custom scripts running is to use the built-in Bounds value. This is available in ShaderGraph versions 14 and up.

It will produce a 0-1 range UV value across the sprite itself, instead of the whole sheet. (And also account for scale)


To make it even more efficient, you can setup a vertex interpolator so the processing only happens per-vertex.

You can right-click the “Vertex” block and add a “Custom Interpolator”. Then click that newly added interpolator, set the name you want, and set it’s “Type” to “Vector 2”. Then plug the result of the SpriteUV graph into it.

Now you will have a new node in your Create Node list to use this “sprite UV” value wherever you need in the graph.

1 Like

I didn’t test it, but I doubt it will work with batching, since all batched sprites are treated as a single object.

If you’re using URP and SRP Batcher is enabled (which it should be by default), then that’s not the behavior sprites will have anymore. They’ll be using instanced props and be batched together by shader, if the shader is SRP compatible (properties are defined in proper CBUFFERS).

I’ve tested this with hundreds of sprites on screen, in static and dynamic mode, lights, with different scaling and sprites applied and the local-UV value remained consistent for each of them. Only when SRP Batcher gets disabled are they then batched the traditional combination way.

So I did test this and it works, until you try to rotate the sprite and then you get this weird scaling deformation.

UPDATE/EDIT: Added ways to convert texture coordinates from shader.

I iterated upon @BBO_Lagoon’s very clever approach of packing the local sprite UVs in the secondary channels, but instead of doing it at the SpriteRenderer level, I do it on sprite import with an AssetPostProcessor, meaning all sprites will get it (after a reimport).

The benefits are:

  • No additional component needed
  • No runtime cost
  • No breaking of batching
  • Automatically available on all sprites
  • Works with rotation/scaling
  • Can convert between texture and sprite UVs at shader level

I cleaned up the naming and formatting a bit to my taste, so here’s how it looks:

using Unity.Collections;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.U2D;

// Writes per-sprite UV coordinates in secondary UV channels:
// UV1: Sprite local UVs
// UV2: Sprite min UVs
// UV3: Sprite max UVs
// Credits for approach to BBO_Lagoon: https://discussions.unity.com/t/785133/30
public sealed class SpriteLocalUVPostProcessor : AssetPostprocessor
{
	private void OnPostprocessSprites(Texture2D texture, Sprite[] sprites)
	{
		foreach (var sprite in sprites)
		{
			// Cache sprite UV array fetch native call
			var spriteUVs = sprite.uv;

			// Find min/max sprite UVs
			float minSpriteU = 1;
			float maxSpriteU = 0;
			float minSpriteV = 1;
			float maxSpriteV = 0;

			foreach (var spriteCornerUV in spriteUVs)
			{
				minSpriteU = Mathf.Min(spriteCornerUV.x, minSpriteU);
				maxSpriteU = Mathf.Max(spriteCornerUV.x, maxSpriteU);
				minSpriteV = Mathf.Min(spriteCornerUV.y, minSpriteV);
				maxSpriteV = Mathf.Max(spriteCornerUV.y, maxSpriteV);
			}

			var spriteLocalUVs = new NativeArray<Vector2>(spriteUVs.Length, Allocator.Temp);
			var spriteMinUVs = new NativeArray<Vector2>(spriteUVs.Length, Allocator.Temp);
			var spriteMaxUVs = new NativeArray<Vector2>(spriteUVs.Length, Allocator.Temp);

			for (var spriteCornerIndex = 0; spriteCornerIndex < spriteLocalUVs.Length; spriteCornerIndex++)
			{
				// Local UVs
				var spriteCornerUV = spriteUVs[spriteCornerIndex];
				var spriteCornerLocalU = Mathf.InverseLerp(minSpriteU, maxSpriteU, spriteCornerUV.x);
				var spriteCornerLocalV = Mathf.InverseLerp(minSpriteV, maxSpriteV, spriteCornerUV.y);
				spriteLocalUVs[spriteCornerIndex] = new Vector2(spriteCornerLocalU, spriteCornerLocalV);

				// Min UVs
				spriteMinUVs[spriteCornerIndex] = new Vector2(minSpriteU, minSpriteV);

				// Max UVs
				spriteMaxUVs[spriteCornerIndex] = new Vector2(maxSpriteU, maxSpriteV);
			}

			// UV1: Sprite local UVs
			sprite.SetVertexAttribute(VertexAttribute.TexCoord1, spriteLocalUVs);

			// UV2: Sprite min UVs
			sprite.SetVertexAttribute(VertexAttribute.TexCoord2, spriteMinUVs);

			// UV3: Sprite max UVs
			sprite.SetVertexAttribute(VertexAttribute.TexCoord3, spriteMaxUVs);
		}
	}
}

How to use:

Add this to your project (in an editor folder/assembly), then right click > Reimport your sprite textures, and they’ll have their local UVs in the UV1 channel accessible from ShaderGraph:

How to convert between sprite & texture UVs in shaders:

If you want to manipulate sprite UVs, usually, in a shader you’ll want to:

  1. Convert the texture-UV to a sprite-UV
  2. Modify the sprite-UV for your effects
  3. Convert the sprite-UV back to a texture-UV for sampling

To do those coordinate conversions, we needed the min-max sprite UVs for remapping, which is why they’re packed in UV2 / UV3.

What I recommend doing to make conversion convenient in your shaders is to create two sub-graphs:

  • TextureToSpriveUV
  • SpriteToTextureUV

Each subgraph will take a Vector2 as an input and have a Vector2 as an output.

Screenshots:

TextureToSpriteUV:

SpriteToTextureUV:

Careful, they’re very similar! The only difference is the order of connections in the the Remap nodes.

Usage:

Enjoy!

5 Likes

Adding to my solution: when using this, you’ll notice UV1 / UV2 / UV3 all return zero in the Unity UI. That is because the Image component does not pass the secondary texcoords when building the mesh, which I presume is just an oversight because those are normally unused.

Thankfully, there are overloads to the vertex helper class that allow us to pass them, so it’s fixable!

You’ll have to create a component derived from Image and override the following methods, like so:

The important parts are marked PATCH:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.U2D;
using UnityEngine.UI;

[AddComponentMenu("UI/Image (With Tex Coords)")]
public sealed class ImageWithTexCoords : Image
{
	private Sprite activeSprite => overrideSprite != null ? overrideSprite : sprite;

	protected override void Reset()
	{
		base.Reset();
		useSpriteMesh = true;
	}

	protected override void OnPopulateMesh(VertexHelper toFill)
	{
		if (activeSprite != null && type == Type.Simple && useSpriteMesh)
		{
			GenerateSprite(toFill, preserveAspect);
		}
		else
		{
			base.OnPopulateMesh(toFill);
		}
	}

	// The methods below are copy-pasted from Unity's Image implementation (on Unity 6000.0.40f1)
	// Only replacing the AddVert method with a different one that includes the secondary UV coordinates

	private void GenerateSprite(VertexHelper vh, bool lPreserveAspect)
	{
		var spriteSize = new Vector2(activeSprite.rect.width, activeSprite.rect.height);

		// Covert sprite pivot into normalized space.
		var spritePivot = activeSprite.pivot / spriteSize;
		var rectPivot = rectTransform.pivot;
		var r = GetPixelAdjustedRect();

		if (lPreserveAspect & (spriteSize.sqrMagnitude > 0.0f))
		{
			PreserveSpriteAspectRatio(ref r, spriteSize);
		}

		var drawingSize = new Vector2(r.width, r.height);
		var spriteBoundSize = activeSprite.bounds.size;

		// Calculate the drawing offset based on the difference between the two pivots.
		var drawOffset = (rectPivot - spritePivot) * drawingSize;

		var color32 = color;
		vh.Clear();

		var vertices = activeSprite.vertices;
		var uvs = activeSprite.uv;

		// PATCH: Fetch secondary texture coordinates
		var uv1 = activeSprite.GetVertexAttribute<Vector2>(VertexAttribute.TexCoord1);
		var uv2 = activeSprite.GetVertexAttribute<Vector2>(VertexAttribute.TexCoord2);
		var uv3 = activeSprite.GetVertexAttribute<Vector2>(VertexAttribute.TexCoord3);

		for (var i = 0; i < vertices.Length; ++i)
		{
			// PATCH: Pass them to vertices
			vh.AddVert
			(
				position: new Vector3((vertices[i].x / spriteBoundSize.x) * drawingSize.x - drawOffset.x, (vertices[i].y / spriteBoundSize.y) * drawingSize.y - drawOffset.y),
				color: color32,
				uv0: uvs[i],
				uv1: uv1[i],
				uv2: uv2[i],
				uv3: uv3[i],
				normal: Vector3.back, // VertexHelper.s_DefaultNormal
				tangent: new Vector4(1.0f, 0.0f, 0.0f, -1.0f) // VertexHelper.s_DefaultTangent
			);
		}

		var triangles = activeSprite.triangles;

		for (var i = 0; i < triangles.Length; i += 3)
		{
			vh.AddTriangle(triangles[i + 0], triangles[i + 1], triangles[i + 2]);
		}
	}

	// Unchanged
	private void PreserveSpriteAspectRatio(ref Rect rect, Vector2 spriteSize)
	{
		var spriteRatio = spriteSize.x / spriteSize.y;
		var rectRatio = rect.width / rect.height;

		if (spriteRatio > rectRatio)
		{
			var oldHeight = rect.height;
			rect.height = rect.width * (1.0f / spriteRatio);
			rect.y += (oldHeight - rect.height) * rectTransform.pivot.y;
		}
		else
		{
			var oldWidth = rect.width;
			rect.width = rect.height * spriteRatio;
			rect.x += (oldWidth - rect.width) * rectTransform.pivot.x;
		}
	}
}

Usage

  • Remove the Image component
  • Add the Image (With Texcoords) component instead
  • Important: in your Canvas, set Additional Shader Channels to include TexCoord 1-2-3. Note that this seems to fail when in Screen Space - Overlay (due to a Unity bug?), so use Screen Space - Camera instead.

Limitations

  • Only works with Simple mesh types and Use Sprite Mesh enabled (default)

It could probably be improved to pass secondary texcoords for other types such sliced and so on, but that’s a lot more math work so I didn’t bother for my use case.

1 Like

Which version of unity, shader graph and sprite atlas do you use?

I’m getting some weird issue with a similar approach (UV1 - normalized sprite offset, UV2 - normalized sprite size).

  • Asset PostProcessor is only triggered for individual sprites. In my case they are full rect, so I’m writing UV1 as an array of (0, 0) and UV2 as an array of (1, 1)
  • When sprite is packed with SpriteAtlas V2 only the UV0 is updated. UV1 and UV2 are unchanged and cover the full texture instead of an individual sprite.
  • ShaderGraph editor doesn’t read UV1/UV2 for sprite textures, so previewing is broken. But it works fine in scene and play mode (but just for individual sprites due non-updated UV1/UV2).