I gave up on automatically finding the normals and decided to just let the user manually set the pivot position. Here is the shader I came up with:
Shader "Sprites/Diffuse-Distortion"
{
Properties
{
[PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
_Color ("Tint", Color) = (1,1,1,1)
_Cutoff ("Shadow alpha cutoff", Range(0,1)) = 0.5
//Distortion map; Only the red and green channel are read
_Distortion ("Distortion map (RGB)", 2D) = "white" {}
_SpeedX("Speed X", range(-1, 1))=0.1
_SpeedY("Speed Y", range(-1, 1))=0.1
//Scale of the distortion map
_Scale("Scale", range(0.0001, 10))=1
//How many pixels are in one unit in world space
_Pixel ("Pixel per unit", int)=16
//The maximum displacement in pixels
_DisplacementX("Displacement X", range(0, 128))=1
_DisplacementY("Displacement Y", range(0, 128))=1
//For sprites that are not centered
_PivotOffsetX ("Pivot offset X", Range(-1,1))=0
_PivotOffsetY ("Pivot offset Y", Range(-1,1))=0
//Optional parameters that are to be manipulated with external scripts to, for instance, offset the additional accrued distortion of moving objects
_PositionOffsetX ("Position offset X", float)=0
_PositionOffsetY ("Position offset Y", float)=0
//Interpolation of the pixels from the distortion map
[MaterialToggle] _Smooth("Smooth", Float) = 1
}
SubShader
{
Tags
{
"Queue"="Transparent"
"IgnoreProjector"="True"
"RenderType"="Transparent"
"PreviewType"="Plane"
"CanUseSpriteAtlas"="True"
"DisableBatching"="True"
}
LOD 200
Cull Off
Lighting On
ZWrite Off
Blend One OneMinusSrcAlpha
CGPROGRAM
#pragma surface surf Ramp vertex:vert alphatest:_Cutoff
sampler2D _MainTex;
sampler2D _Distortion;
float _SpeedX;
float _SpeedY;
float _Scale;
float _DisplacementX;
float _DisplacementY;
struct Input
{
float2 uv_MainTex;
float2 uv_Distortion;
float3 worldPos;
};
half4 LightingRamp (SurfaceOutput s, half3 lightDir, half atten)
{
half4 c;
c.rgb = s.Albedo;
c.a = s.Alpha;
return c;
}
float _Pixel;
float _PivotOffsetX;
float _PivotOffsetY;
void vert (inout appdata_full v)
{
const float2 displacement=float2(_DisplacementX,_DisplacementY);
float3 direction=sign(v.vertex).xyz;
if(direction.x==0 && _PivotOffsetX!=0)
{
direction.x=_PivotOffsetX;
}
if(direction.y==0 && _PivotOffsetY!=0)
{
direction.y=-_PivotOffsetY;
}
v.vertex.xy += direction*displacement.xy/_Pixel;
}
float Repeat (float value, float ceiling)
{
while(value<0)
{
value+=ceiling;
}
while(value>=ceiling)
{
value-=ceiling;
}
return value;
}
float2 Repeat (float2 value, float ceiling)
{
return float2(Repeat(value.x,ceiling),Repeat(value.y,ceiling));
}
float2 Repeat (float2 value, float2 ceiling)
{
return float2(Repeat(value.x,ceiling.x),Repeat(value.y,ceiling.y));
}
float4 Point(sampler2D tex, float2 uv, float4 texelSize)
{
float2 halfPixel=0.5*texelSize.xy;
uv*=texelSize.zw;
uv=floor(uv)+0.5;
uv*=texelSize.xy;
return tex2D(_Distortion, uv);
}
float4 Bilinear(sampler2D tex, float2 uv, float4 texelSize)
{
float2 halfPixel=0.5*texelSize.xy;
uv*=texelSize.zw;
float2 weight=uv;
uv=round(uv);
weight-=uv;
weight+=0.5;
uv*=texelSize.xy;
float4 pixel1=tex2D(_Distortion, Repeat(uv+float2(-halfPixel.x,-halfPixel.y),1));
float4 pixel2=tex2D(_Distortion, Repeat(uv+float2(halfPixel.x,-halfPixel.y),1));
float4 pixel3=tex2D(_Distortion, Repeat(uv+float2(-halfPixel.x,halfPixel.y),1));
float4 pixel4=tex2D(_Distortion, Repeat(uv+float2(halfPixel.x,halfPixel.y),1));
float4 pixel12=lerp(pixel1,pixel2,weight.x);
float4 pixel34=lerp(pixel3,pixel4,weight.x);
return lerp(pixel12,pixel34,weight.y);
}
float4 BilinearAtBorders(sampler2D tex, float2 uv, float4 texelSize)
{
float2 halfPixel=0.5*texelSize.xy;
if(uv.x<=halfPixel.x || uv.y<=halfPixel.y || uv.x>=1-halfPixel.x || uv.y>=1-halfPixel.y)
{
return Bilinear(tex,uv,texelSize);
}
return(tex2D(tex,uv));
}
float4 _Color;
float _Alpha;
float4 _MainTex_TexelSize;
float4 _Distortion_TexelSize;
float _PositionOffsetX;
float _PositionOffsetY;
float _Smooth;
void surf (Input IN, inout SurfaceOutput o)
{
const float2 displacement=float2(_DisplacementX,_DisplacementY);
float2 distortionSeed=float2(0,0);
float2 finalScale=_Scale*_Pixel*_Distortion_TexelSize.xy;
distortionSeed.x+=(_Time.g*_SpeedX)-_PositionOffsetX*finalScale.x;
distortionSeed.y+=(_Time.g*_SpeedY)-_PositionOffsetY*finalScale.y;
distortionSeed+=IN.worldPos.xy*finalScale;
distortionSeed=Repeat(distortionSeed,1);
float2 offset;
if(_Smooth!=0)
{
offset=BilinearAtBorders(_Distortion,distortionSeed,_Distortion_TexelSize).rg;
}
else
{
offset=Point(_Distortion,distortionSeed,_Distortion_TexelSize).rg;
}
offset-=0.5;
offset*=2*displacement*_MainTex_TexelSize.xy;
float2 uv=IN.uv_MainTex;
uv-=0.5;
uv*=_MainTex_TexelSize.xy*(_MainTex_TexelSize.zw+displacement*2);
uv+=0.5;
uv+=offset;
fixed4 c = tex2D (_MainTex, uv);
o.Albedo=c.rgb*_Color;
o.Alpha=c.a*_Color.a;
if(uv.x<0 || uv.y<0 || uv.x>1 || uv.y>1)
{
o.Alpha=0;
}
}
ENDCG
}
Fallback "Sprites/Diffuse"
}
Here’s the distortion map I use:

There’s still a little problem that is bugging me; I had to implement the bilinear interpolation for the borders of the distortion map myself and my solution causes ugly, “infinitely small” artifacts around them.
I have tried for a long time and I still don’t know how to get rid of them.
This error is not immediately apparent, but once you know it’s there you can’t help but notice.
If somebody is interested in trying to fix this (which I would be very grateful for) insert the following code snippet to better visualize this error:
void surf (Input IN, inout SurfaceOutput o)
{
const float2 displacement=float2(_DisplacementX,_DisplacementY);
float2 distortionSeed=float2(0,0);
float2 finalScale=_Scale*_Pixel*_Distortion_TexelSize.xy;
distortionSeed.x+=(_Time.g*_SpeedX)-_PositionOffsetX*finalScale.x;
distortionSeed.y+=(_Time.g*_SpeedY)-_PositionOffsetY*finalScale.y;
distortionSeed+=IN.worldPos.xy*finalScale;
distortionSeed=Repeat(distortionSeed,1);
float2 offset;
if(_Smooth!=0)
{
offset=BilinearAtBorders(_Distortion,distortionSeed,_Distortion_TexelSize).rg;
}
else
{
offset=Point(_Distortion,distortionSeed,_Distortion_TexelSize).rg;
}
float2 expose=offset;
offset-=0.5;
offset*=2*displacement*_MainTex_TexelSize.xy;
offset=0.5;
float2 uv=IN.uv_MainTex;
uv-=0.5;
uv*=_MainTex_TexelSize.xy*(_MainTex_TexelSize.zw+displacement*2);
uv+=0.5;
uv+=offset;
fixed4 c = tex2D (_MainTex, uv);
o.Albedo=c.rgb*_Color;
o.Alpha=c.a*_Color.a;
if(uv.x<0 || uv.y<0 || uv.x>1 || uv.y>1)
{
o.Alpha=0;
}
o.Albedo=expose.x;
}
Furthermore, use this (very tiny image) as the distortion map and enable the “Smooth” parameter in the shader.

If anyone has any suggestions for any further improvements, again, I’d be grateful, and if you have any questions, feel free to ask them.