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.
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.
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;
}
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()
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. 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.
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
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.