For Unity there’s basically two different ways to do shaders (well, 4, but one is effectively deprecated, and the other is only for platform specific stuff), surface shader and vert / frag shader. Behind the scenes Unity convers surface shaders into expanded vert / frag shaders, which then get converted into platform specific shaders, which then get compiled into the final shader code that is what’s sent to the drivers which convert that into the final form the GPU actually uses. It’s a deep rabbit hole so we’ll stick to the top two levels. The deprecated method is the “fixed function” shaders if you’re curious. This is what shaders used to look like before you could do arbitrary math, but these two now just get converted into vert / frag shaders.
So, lets start with vert frag for now. The basic rotation in the vertex shader.
Shader "Unlit/Unlit UV Rotation in vertex"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Rotation ("Rotation", Range(0,360)) = 0.0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float _Rotation;
v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
// rotating UV
const float Deg2Rad = (UNITY_PI * 2.0) / 360.0;
float rotationRadians = _Rotation * Deg2Rad; // convert degrees to radians
float s = sin(rotationRadians); // sin and cos take radians, not degrees
float c = cos(rotationRadians);
float2x2 rotationMatrix = float2x2( c, -s, s, c); // construct simple rotation matrix
v.uv -= 0.5; // offset UV so we rotate around 0.5 and not 0.0
v.uv = mul(rotationMatrix, v.uv); // apply rotation matrix
v.uv += 0.5; // offset UV again so UVs are in the correct location
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}
This is the default “new Shader > Unlit” modified to add rotation. This is probably where you’re at now, at least something similar. The form is a little different from most of the other threads on UV rotation because most people get confused by the degree to radian conversion (ie: they don’t do it) and they’re doing the mul in the wrong order.
So, now we want multiple textures and UV sets with different rotations. This is just a matter of adding additional UVs to the v2f struct and doing the math multiple times.
Shader "Unlit/Unlit UV Rotation of multiple textures in vertex"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_RotatedTexA ("Texture", 2D) = "white" {}
_RotationA ("Rotation", Range(0,360)) = 0.0
_RotatedTexB ("Texture", 2D) = "white" {}
_RotationB ("Rotation", Range(0,360)) = 0.0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
float2 rotateUV(float2 uv, float degrees)
{
// rotating UV
const float Deg2Rad = (UNITY_PI * 2.0) / 360.0;
float rotationRadians = degrees * Deg2Rad; // convert degrees to radians
float s = sin(rotationRadians); // sin and cos take radians, not degrees
float c = cos(rotationRadians);
float2x2 rotationMatrix = float2x2( c, -s, s, c); // construct simple rotation matrix
uv -= 0.5; // offset UV so we rotate around 0.5 and not 0.0
uv = mul(rotationMatrix, uv); // apply rotation matrix
uv += 0.5; // offset UV again so UVs are in the correct location
return uv;
}
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 uv2 : TEXCOORD1; // Addition additional UV to pass
UNITY_FOG_COORDS(2) // changed from 1 to 2 since uv2 is using TEXCOORD1 now
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _RotatedTexA;
float4 _RotatedTexA_ST;
float _RotationA;
sampler2D _RotatedTexB;
float4 _RotatedTexB_ST;
float _RotationB;
v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.uv2.xy = TRANSFORM_TEX(rotateUV(v.uv, _RotationA), _RotatedTexA);
o.uv2.zw = TRANSFORM_TEX(rotateUV(v.uv, _RotationB), _RotatedTexB);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
// sample rotated textures
fixed4 colA = tex2D(_RotatedTexA, i.uv2.xy);
fixed4 colB = tex2D(_RotatedTexB, i.uv2.zw);
// adding the textures together just so you can see them all
col = (col + colA + colB) / 3.0;
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}
The rotation code is now a separate function so we can reuse it. We also have a second UV set, a float4 instead of a float2, to the v2f struct and we’re using the xy and zw components to pack two UV sets into a single parameter for efficiency.
Now what about doing the rotation in the fragment shader?
Shader "Unlit/Unlit UV Rotation of multiple textures in fragment"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_RotatedTexA ("Texture", 2D) = "white" {}
_RotationA ("Rotation", Range(0,360)) = 0.0
_RotatedTexB ("Texture", 2D) = "white" {}
_RotationB ("Rotation", Range(0,360)) = 0.0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
float2 rotateUV(float2 uv, float degrees)
{
// rotating UV
const float Deg2Rad = (UNITY_PI * 2.0) / 360.0;
float rotationRadians = degrees * Deg2Rad; // convert degrees to radians
float s = sin(rotationRadians); // sin and cos take radians, not degrees
float c = cos(rotationRadians);
float2x2 rotationMatrix = float2x2( c, -s, s, c); // construct simple rotation matrix
uv -= 0.5; // offset UV so we rotate around 0.5 and not 0.0
uv = mul(rotationMatrix, uv); // apply rotation matrix
uv += 0.5; // offset UV again so UVs are in the correct location
return uv;
}
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _RotatedTexA;
float4 _RotatedTexA_ST;
float _RotationA;
sampler2D _RotatedTexB;
float4 _RotatedTexB_ST;
float _RotationB;
v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.uv;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
float2 mainTex_uv = TRANSFORM_TEX(i.uv, _MainTex);
fixed4 col = tex2D(_MainTex, mainTex_uv);
// sample rotated textures
float2 uvA = TRANSFORM_TEX(rotateUV(i.uv, _RotationA), _RotatedTexA);
float2 uvB = TRANSFORM_TEX(rotateUV(i.uv, _RotationB), _RotatedTexB);
fixed4 colA = tex2D(_RotatedTexA, uvA);
fixed4 colB = tex2D(_RotatedTexB, uvB);
// adding the textures together just so you can see them all
col = (col + colA + colB) / 3.0;
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}
All of the UV code is now in the fragment shader, almost literally just copy and pasted (apart from changing v.uv to i.uv and assigning them to different variables). In the vert function we’re just passing the mesh’s UV on and nothing else. The last two shaders are identical in function, though the vertex one will generally be faster as the math is being done only for each vertex instead of every pixel.
Now you can do stuff like mix these two. Do the TRANSFORM_TEX in the vertex shader for the main tex (this is applying the scale and offset values you see in the editor, which is stored in the float4 _MainTex_ST variable), and pass along the untransformed UVs as the zw components, then do the rotation. If you don’t need or want the in editor scale and offset you can add [NoScaleOffset] in front of the texture properties at the start of the shader then remove the float4 _***_ST and TRANSFORM_TEX stuff for that texture. You can also just share those offsets, etc. Whatever you want to do. But I’ll leave that for you to figure out.
Now if you want to do this in a surface shader you can do it like the fragment version pretty easily. Just copy the rotateUV function and apply the rotation in the surf function. If you want to do it at the vertex level you’ll need to add a custom vertex function to your surf shader and add another UV set to the Input struct.
Shader "Custom/Surface UV Rotation in vertex" {
Properties {
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
[NoScaleOffset] _RotatedTex ("Texture", 2D) = "white" {}
_Rotation ("Rotation", Range(0,360)) = 0.0
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
// Physically based Standard lighting model, and enable shadows on all light types
#pragma surface surf Standard fullforwardshadows vertex:vert
// Use shader model 3.0 target, to get nicer looking lighting
#pragma target 3.0
float2 rotateUV(float2 uv, float degrees)
{
// rotating UV
const float Deg2Rad = (UNITY_PI * 2.0) / 360.0;
float rotationRadians = degrees * Deg2Rad; // convert degrees to radians
float s = sin(rotationRadians); // sin and cos take radians, not degrees
float c = cos(rotationRadians);
float2x2 rotationMatrix = float2x2( c, -s, s, c); // construct simple rotation matrix
uv -= 0.5; // offset UV so we rotate around 0.5 and not 0.0
uv = mul(rotationMatrix, uv); // apply rotation matrix
uv += 0.5; // offset UV again so UVs are in the correct location
return uv;
}
sampler2D _MainTex;
sampler2D _RotatedTex;
struct Input {
float2 uv_MainTex;
float2 rotatedUV;
};
half _Glossiness;
half _Metallic;
fixed4 _Color;
float _Rotation;
void vert (inout appdata_full v, out Input o) {
UNITY_INITIALIZE_OUTPUT(Input,o);
o.rotatedUV = rotateUV(v.texcoord, _Rotation);
}
void surf (Input IN, inout SurfaceOutputStandard o) {
// Albedo comes from a texture tinted by color
fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
// rotated texture
fixed4 c2 = tex2D(_RotatedTex, IN.rotatedUV);
// blend the two together so we can see them
c = (c + c2) / 2.0;
o.Albedo = c.rgb;
// Metallic and smoothness come from slider variables
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
I also disabled the in editor texture scaling and offset for the rotated texture, just because that adds another layer of weirdness in surface shaders.