Shader "Ichni/UI/Expanding Contours" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _Color ("Graphic Tint", Color) = (1, 1, 1, 1) [Header(Line)] _LineColor ("Line Color", Color) = (1, 1, 1, 1) _Opacity ("Opacity", Range(0, 1)) = 0.5 _LineWidth ("Line Width", Range(0.0005, 0.02)) = 0.0005 _EdgeSoftness ("Edge Softness", Range(0.5, 4)) = 0.5 _ContourSpacing ("Contour Spacing", Range(0.01, 0.2)) = 0.1 _TrailLength ("Contour Trail Length", Range(0.02, 1.5)) = 0.5 _TrailFade ("Inner Line Fade", Range(0, 1)) = 1 [Header(Emission)] _EmitterCount ("Simultaneous Emitters", Range(1, 6)) = 4 _SpawnRate ("Event Cycles Per Second", Range(0.02, 1)) = 0.05 _MaxRadius ("Maximum Radius", Range(0.1, 2)) = 0.5 _ExpansionCurve ("Expansion Curve", Range(0.3, 3)) = 0.85 _FadeDuration ("Lifetime Fade", Range(0.01, 0.45)) = 0.16 _CenterMargin ("Random Center Margin", Range(0, 0.45)) = 0 _RandomSeed ("Random Seed", Float) = 0 [Header(Shape)] _Irregularity ("Shape Irregularity", Range(0, 0.45)) = 0.45 _ShapeDetail ("Shape Detail", Range(0, 1)) = 1 _MorphAmount ("Shape Morph Amount", Range(0, 1)) = 1 _ShapeVariation ("Per Shape Variation", Range(0, 1)) = 1 _FusionStrength ("Intersection Fusion", Range(0, 0.2)) = 0.04 _TopologyRelease ("Topology Release", Range(0, 2)) = 1 [Header(UI)] _StencilComp ("Stencil Comparison", Float) = 8 _Stencil ("Stencil ID", Float) = 0 _StencilOp ("Stencil Operation", Float) = 0 _StencilWriteMask ("Stencil Write Mask", Float) = 255 _StencilReadMask ("Stencil Read Mask", Float) = 255 _ColorMask ("Color Mask", Float) = 15 [Toggle(UNITY_UI_CLIP_RECT)] _UseClipRect ("Use UI Clip Rect", Float) = 0 [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use UI Alpha Clip", Float) = 0 } SubShader { Tags { "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" "PreviewType" = "Plane" "CanUseSpriteAtlas" = "True" } Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Pass { Name "Expanding Contours UI" CGPROGRAM #pragma target 3.0 #pragma vertex vert #pragma fragment frag #pragma multi_compile_local _ UNITY_UI_CLIP_RECT #pragma multi_compile_local _ UNITY_UI_ALPHACLIP #include "UnityCG.cginc" #include "UnityUI.cginc" #define MAX_EMITTERS 6 struct appdata_t { float4 vertex : POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 uv : TEXCOORD0; float4 worldPosition : TEXCOORD1; half4 mask : TEXCOORD2; UNITY_VERTEX_OUTPUT_STEREO }; fixed4 _Color; fixed4 _LineColor; float _Opacity; float _LineWidth; float _EdgeSoftness; float _ContourSpacing; float _TrailLength; float _TrailFade; float _EmitterCount; float _SpawnRate; float _MaxRadius; float _ExpansionCurve; float _FadeDuration; float _CenterMargin; float _RandomSeed; float _Irregularity; float _ShapeDetail; float _MorphAmount; float _ShapeVariation; float _FusionStrength; float _TopologyRelease; float4 _ClipRect; float _UIMaskSoftnessX; float _UIMaskSoftnessY; v2f vert(appdata_t v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); o.worldPosition = v.vertex; o.vertex = UnityObjectToClipPos(o.worldPosition); o.uv = v.texcoord; o.color = v.color * _Color; float2 pixelSize = o.vertex.w; pixelSize /= abs(mul((float2x2)UNITY_MATRIX_P, _ScreenParams.xy)); float4 clampedRect = clamp(_ClipRect, -2e10, 2e10); o.mask = half4( v.vertex.xy * 2 - clampedRect.xy - clampedRect.zw, 0.25 / (0.25 * half2(_UIMaskSoftnessX, _UIMaskSoftnessY) + abs(pixelSize.xy)) ); return o; } float Hash11(float value) { value = frac(value * 0.1031); value *= value + 33.33; value *= value + value; return frac(value); } float2 Hash21(float value) { float3 value3 = frac( float3(value, value, value) * float3(0.1031, 0.1030, 0.0973) ); value3 += dot(value3, value3.yzx + 33.33); return frac((value3.xx + value3.yz) * value3.zy); } float2 GetRandomDirection(float seed) { float2 direction = Hash21(seed) * 2.0 - 1.0; return direction * rsqrt(max(dot(direction, direction), 0.0001)); } float2 NormalizeSafe(float2 direction) { return direction * rsqrt(max(dot(direction, direction), 0.0001)); } float2 ComplexMultiply(float2 a, float2 b) { return float2( a.x * b.x - a.y * b.y, a.y * b.x + a.x * b.y ); } float GetRectAspect(float2 uv) { float uGradient = max(length(float2(ddx(uv.x), ddy(uv.x))), 0.000001); float vGradient = max(length(float2(ddx(uv.y), ddy(uv.y))), 0.000001); return clamp(vGradient / uGradient, 0.1, 10.0); } float GetIrregularDistance( float2 uv, float2 center, float aspect, float eventSeed, float life ) { float2 delta = uv - center; delta.x *= aspect; float2 shapeAxis = GetRandomDirection(eventSeed + 17.23); float2 perpendicularAxis = float2(-shapeAxis.y, shapeAxis.x); float axisStretch = lerp( 1.0, lerp(0.72, 1.28, Hash11(eventSeed + 21.91)), _ShapeVariation ); float parallelDistance = dot(delta, shapeAxis) / axisStretch; float perpendicularDistance = dot(delta, perpendicularAxis) * axisStretch; delta = shapeAxis * parallelDistance + perpendicularAxis * perpendicularDistance; float radialDistance = max(length(delta), 0.000001); float2 direction = delta / radialDistance; // Complex powers provide closed angular harmonics without // texture noise, atan2, or per-pixel trigonometry. float2 harmonic2 = ComplexMultiply(direction, direction); float2 harmonic3 = ComplexMultiply(harmonic2, direction); float2 harmonic5 = ComplexMultiply(harmonic3, harmonic2); float2 harmonic7 = ComplexMultiply(harmonic5, harmonic2); float morph = smoothstep(0.0, 1.0, life) * _MorphAmount; float2 phaseA = NormalizeSafe(lerp( GetRandomDirection(eventSeed + 2.31), GetRandomDirection(eventSeed + 102.31), morph )); float2 phaseB = NormalizeSafe(lerp( GetRandomDirection(eventSeed + 7.17), GetRandomDirection(eventSeed + 107.17), morph )); float2 phaseC = NormalizeSafe(lerp( GetRandomDirection(eventSeed + 13.73), GetRandomDirection(eventSeed + 113.73), morph )); float broadShape = 0.58 * dot(harmonic2, phaseA) + 0.29 * dot(harmonic3, phaseB); float fineShape = 0.09 * dot(harmonic5, phaseB) + 0.04 * dot(harmonic7, phaseC); float detailVariation = lerp( 1.0, lerp(0.6, 1.4, Hash11(eventSeed + 29.37)), _ShapeVariation ); float irregularityVariation = lerp( 1.0, lerp(0.65, 1.35, Hash11(eventSeed + 37.61)), _ShapeVariation ); float shape = broadShape + fineShape * _ShapeDetail * detailVariation; return radialDistance * (1.0 + shape * _Irregularity * irregularityVariation); } float2 GetEmitterField(float2 uv, float aspect, float emitterIndex, float activeEmitters) { float stagger = emitterIndex / activeEmitters; float timeline = _Time.y * _SpawnRate + stagger; float cycle = floor(timeline); float life = frac(timeline); // R2 low-discrepancy ordering keeps both consecutive events and // simultaneously active centers distributed across the UI. float sequenceIndex = cycle * activeEmitters + (activeEmitters - 1.0 - emitterIndex); float eventSeed = sequenceIndex * 17.17 + _RandomSeed * 101.13; float2 sequenceOffset = Hash21(_RandomSeed * 43.17 + 0.71); float2 randomCenter = frac( sequenceOffset + sequenceIndex * float2(0.754877666, 0.569840296) ); float centerRange = max(1.0 - _CenterMargin * 2.0, 0.001); float2 center = _CenterMargin + randomCenter * centerRange; float irregularDistance = GetIrregularDistance( uv, center, aspect, eventSeed, life ); float radiusVariation = lerp( 1.0, lerp(0.85, 1.15, Hash11(eventSeed + 41.93)), _ShapeVariation ); float expansion = pow(saturate(life), _ExpansionCurve); float currentRadius = _MaxRadius * radiusVariation * expansion; float fadeDuration = max(_FadeDuration, 0.01); half fadeIn = smoothstep(0.0, fadeDuration, life); half fadeOut = 1.0 - smoothstep(1.0 - fadeDuration, 1.0, life); half lifeFade = fadeIn * fadeOut; // Alpha fading alone leaves the full SDF in the smooth union // until the emitter is removed, which makes connected contours // snap closed. Erode the field continuously during fade-out so // its topology is already released before the final removal. float retirement = 1.0 - fadeOut; float releaseDistance = currentRadius + _TrailLength + _ContourSpacing + _FusionStrength; float releaseOffset = retirement * retirement * releaseDistance * _TopologyRelease; float releasedDistance = irregularDistance - currentRadius + releaseOffset; return float2(releasedDistance, lifeFade); } fixed4 frag(v2f i) : SV_Target { float aspect = GetRectAspect(i.uv); float activeEmitters = clamp(floor(_EmitterCount + 0.5), 1.0, MAX_EMITTERS); float unionDistance = 100000.0; half unionFade = 0; half hasEmitter = 0; [unroll] for (int emitter = 0; emitter < MAX_EMITTERS; emitter++) { UNITY_BRANCH if ((float)emitter < activeEmitters) { float2 emitterField = GetEmitterField( i.uv, aspect, (float)emitter, activeEmitters ); UNITY_BRANCH if (emitterField.y > 0.001) { if (hasEmitter < 0.5) { unionDistance = emitterField.x; unionFade = emitterField.y; hasEmitter = 1; } else if (_FusionStrength > 0.00001) { float blend = saturate( 0.5 + 0.5 * (emitterField.x - unionDistance) / _FusionStrength ); unionDistance = lerp(emitterField.x, unionDistance, blend) - _FusionStrength * blend * (1.0 - blend); unionFade = lerp(emitterField.y, unionFade, blend); } else if (emitterField.x < unionDistance) { unionDistance = emitterField.x; unionFade = emitterField.y; } } } } float contourDepth = -unionDistance; float spacing = max(_ContourSpacing, 0.0001); float ringCoordinate = contourDepth / spacing; float ringDistance = abs(frac(ringCoordinate + 0.5) - 0.5) * spacing; float antialiasing = max( fwidth(unionDistance) * _EdgeSoftness, 0.00001 ); half lineAlpha = 1.0 - smoothstep( _LineWidth, _LineWidth + antialiasing, ringDistance ); half outerMask = smoothstep( -antialiasing, antialiasing, contourDepth ); half innerMask = 1.0 - smoothstep( _TrailLength - antialiasing, _TrailLength + antialiasing, contourDepth ); half trailPosition = 1.0 - saturate( contourDepth / max(_TrailLength, 0.0001) ); half trailOpacity = lerp( 1.0 - _TrailFade, 1.0, trailPosition ); half contourAlpha = lineAlpha * outerMask * innerMask * trailOpacity * unionFade * hasEmitter; fixed4 color; color.rgb = _LineColor.rgb * i.color.rgb; color.a = saturate(contourAlpha) * _LineColor.a * _Opacity * i.color.a; #ifdef UNITY_UI_CLIP_RECT half2 clipMask = saturate( (_ClipRect.zw - _ClipRect.xy - abs(i.mask.xy)) * i.mask.zw ); color.a *= clipMask.x * clipMask.y; #endif #ifdef UNITY_UI_ALPHACLIP clip(color.a - 0.001); #endif return color; } ENDCG } } }