diff --git a/BopVisualEffects/Core/EffectDefinitionRegistry.cs b/BopVisualEffects/Core/EffectDefinitionRegistry.cs
index 92c05f4..34d4552 100644
--- a/BopVisualEffects/Core/EffectDefinitionRegistry.cs
+++ b/BopVisualEffects/Core/EffectDefinitionRegistry.cs
@@ -13,6 +13,7 @@
using BopVisualEffects.Effects.Scanlines;
using BopVisualEffects.Effects.ScreenNoise;
using BopVisualEffects.Effects.Sepia;
+using BopVisualEffects.Effects.SpeedLines;
using BopVisualEffects.Effects.VerticalFlip;
using BopVisualEffects.Effects.Vignette;
using BopVisualEffects.Effects.ZoomIn;
@@ -65,6 +66,7 @@ public static void Initialize(string pluginGuid, ClassLogger log, ConfigFile con
registry.Register(new ScanlinesEffect());
registry.Register(new ScreenNoiseEffect());
registry.Register(new SepiaEffect());
+ registry.Register(new SpeedLinesEffect());
registry.Register(new VerticalFlipEffect());
registry.Register(new VignetteEffect());
registry.Register(new ZoomInEffect());
diff --git a/BopVisualEffects/Effects/SpeedLines/SpeedLinesEffect.cs b/BopVisualEffects/Effects/SpeedLines/SpeedLinesEffect.cs
new file mode 100644
index 0000000..573a56a
--- /dev/null
+++ b/BopVisualEffects/Effects/SpeedLines/SpeedLinesEffect.cs
@@ -0,0 +1,322 @@
+using System.Collections.Generic;
+using BopVisualEffects.Core;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+namespace BopVisualEffects.Effects.SpeedLines;
+
+///
+/// Effect definition for animated speed-line triangles radiating inward from the screen edges.
+///
+public sealed class SpeedLinesEffect : IVisualEffectDefinition
+{
+ private const float DefaultReach = 0.25f;
+
+ ///
+ public string Id => "speed lines";
+
+ ///
+ public string DisplayName => "Speed Lines";
+
+ ///
+ public string ConfigKey => "SpeedLines";
+
+ ///
+ public string Description => "Applies an animated, colored radial speed-line post-processing effect for a high-speed rush feeling.";
+
+ ///
+ public MixtapeEventTemplate CreateTemplate(string pluginGuid)
+ {
+ return new MixtapeEventTemplate
+ {
+ dataModel = $"{pluginGuid}/{Id}",
+ length = 2.0f,
+ resizable = true,
+ properties = new Dictionary
+ {
+ ["alpha"] = 0.8f,
+ ["count"] = 96.0f,
+ ["speed"] = 3.5f,
+ ["reach"] = DefaultReach,
+ ["color"] = new MixtapeEventTemplates.ColorField(Color.white),
+ ["ease_in"] = true,
+ ["ease_out"] = true
+ }
+ };
+ }
+
+ ///
+ public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader)
+ {
+ var log = ClassLogger.GetForClass();
+ var durationBeats = Mathf.Max(0.01f, entity.length);
+ var alpha = entity.GetFloat("alpha");
+ var count = entity.GetFloat("count");
+ var speed = entity.GetFloat("speed");
+ var reach = entity.GetFloat("reach");
+ var color = entity.GetColor("color");
+ var easeIn = entity.GetBool("ease_in", true);
+ var easeOut = entity.GetBool("ease_out", true);
+ var startBeat = entity.beat;
+ var endBeat = startBeat + durationBeats;
+ loader.scheduler.Schedule(startBeat, (System.Action?)SpawnAction);
+ log.Debug($"Scheduled '{DisplayName}' from beat {startBeat:0.###} to {endBeat:0.###}.");
+ return true;
+
+ void SpawnAction()
+ {
+ EffectRuntimeController.Instance.SpawnRunner(runner =>
+ runner.Initialize(loader, loader.jukebox, startBeat, endBeat, alpha, count, speed, reach, color, easeIn, easeOut));
+ }
+ }
+
+ private sealed class SpeedLinesRunner : MonoBehaviour
+ {
+ private bool _initialized;
+ private float _alpha;
+ private int _count;
+ private float _speed;
+ private float _reach;
+ private Color _color;
+ private float _startBeat;
+ private float _endBeat;
+ private MixtapeLoaderCustom? _loader;
+ private JukeboxScript? _jukebox;
+ private Camera? _camera;
+ private SpeedLinesRequest? _request;
+ private bool _easeIn;
+ private bool _easeOut;
+
+ ///
+ /// Initializes this runner with effect parameters.
+ ///
+ public void Initialize(MixtapeLoaderCustom loader, JukeboxScript? jukebox, float startBeat, float endBeat, float alpha, float count, float speed, float reach, Color color, bool easeIn, bool easeOut)
+ {
+ _loader = loader;
+ _jukebox = jukebox;
+ _startBeat = startBeat;
+ _endBeat = endBeat;
+ _alpha = Mathf.Clamp01(alpha);
+ _count = Mathf.Clamp(Mathf.RoundToInt(count), 24, 192);
+ _speed = Mathf.Max(0.1f, speed);
+ _reach = Mathf.Clamp01(reach);
+ _color = new Color(Mathf.Clamp01(color.r), Mathf.Clamp01(color.g), Mathf.Clamp01(color.b), Mathf.Clamp01(color.a));
+ _easeIn = easeIn;
+ _easeOut = easeOut;
+ InitializeOverlay();
+ }
+
+ ///
+ /// Stops this effect instance.
+ ///
+ public void Stop()
+ {
+ RemoveOverlay();
+ Destroy(this);
+ }
+
+ private void LateUpdate()
+ {
+ if (_jukebox is null)
+ {
+ Stop();
+ return;
+ }
+
+ if (!_initialized)
+ {
+ InitializeOverlay();
+ if (!_initialized)
+ return;
+ }
+
+ var currentBeat = _jukebox.CurrentBeat;
+ if (currentBeat >= _endBeat)
+ {
+ Stop();
+ return;
+ }
+
+ var progress = Mathf.InverseLerp(_startBeat, _endBeat, currentBeat);
+ var envelope = EffectEnvelope.Evaluate(progress, _easeIn, _easeOut, 0.15f, 0.85f);
+
+ if (_request != null)
+ _request.Alpha = _alpha * envelope;
+ }
+
+ private void OnDisable()
+ {
+ RemoveOverlay();
+ }
+
+ private void InitializeOverlay()
+ {
+ Camera? camera = EffectRuntimeController.ResolveEffectCamera(_loader);
+ if (camera is null)
+ return;
+
+ _camera = camera;
+ _request = SpeedLinesService.AddRequest(camera);
+ _request.Alpha = _alpha;
+ _request.Count = _count;
+ _request.Speed = _speed;
+ _request.Reach = _reach;
+ _request.Color = _color;
+ _initialized = true;
+ }
+
+ private void RemoveOverlay()
+ {
+ if (_camera != null && _request != null)
+ SpeedLinesService.RemoveRequest(_camera, _request);
+ _camera = null;
+ _request = null;
+ }
+ }
+
+ ///
+ /// Draws animated radial speed-line streaks in OnPostRender.
+ /// Each streak has an independent phase, speed, and length so the effect remains visible
+ /// while continuously moving toward the screen edge.
+ /// Must be attached to a Camera's GameObject.
+ ///
+ private sealed class SpeedLinesOverlay : MonoBehaviour
+ {
+ // Half-width of a streak at the outer end, in screen-height fractions.
+ private const float HalfWidth = 0.009f;
+
+ private static Material? _material;
+ private float _alpha;
+ private float _speed;
+ private float _reach;
+ private Color _color;
+ private int _count;
+ private float _seed;
+
+ ///
+ /// Generates the per-triangle angular positions and flicker phase offsets
+ /// using a deterministic seed so the layout is stable for each effect instance.
+ ///
+ public void Initialize(int count, float seed, float reach, Color color)
+ {
+ _count = count;
+ _seed = seed;
+ _reach = Mathf.Clamp01(reach);
+ _color = color;
+ }
+
+ ///
+ /// Updates the maximum opacity and flicker speed applied to all speed lines.
+ ///
+ public void SetParams(float alpha, float speed)
+ {
+ _alpha = alpha;
+ _speed = speed;
+ }
+
+ private static Material? GetMaterial()
+ {
+ if (!_material)
+ {
+ var shader = Shader.Find("Hidden/Internal-Colored");
+ if (shader is null)
+ return null;
+
+ _material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave };
+ _material.SetInt("_SrcBlend", (int)BlendMode.SrcAlpha);
+ _material.SetInt("_DstBlend", (int)BlendMode.OneMinusSrcAlpha);
+ _material.SetInt("_Cull", (int)CullMode.Off);
+ _material.SetInt("_ZWrite", 0);
+ }
+
+ return _material;
+ }
+
+ // Returns the screen-boundary intersection from the center (0.5, 0.5) in
+ // direction (dx, dy), expressed in GL ortho space (x: 0..1, y: 0..1).
+ private static void ScreenBoundary(float dx, float dy, out float bx, out float by)
+ {
+ var tx = (dx != 0f) ? 0.5f / Mathf.Abs(dx) : float.MaxValue;
+ var ty = (dy != 0f) ? 0.5f / Mathf.Abs(dy) : float.MaxValue;
+ var t = Mathf.Min(tx, ty);
+ bx = 0.5f + t * dx;
+ by = 0.5f + t * dy;
+ }
+
+ private static float Hash01(int x, int y)
+ {
+ var value = Mathf.Sin(x * 127.1f + y * 311.7f) * 43758.5453f;
+ return value - Mathf.Floor(value);
+ }
+
+ // Unity calls this on the camera's GameObject after it finishes rendering the scene.
+ private void OnPostRender()
+ {
+ if (_alpha <= 0f || _count <= 0 || _reach <= 0f)
+ return;
+
+ var mat = GetMaterial();
+ if (mat is null)
+ return;
+
+ var cam = GetComponent();
+ // invAspect is used to convert the visual direction to GL ortho space.
+ var aspect = (cam != null) ? cam.aspect : (16f / 9f);
+ var invAspect = (aspect > 0f) ? 1f / aspect : 1f;
+
+ var time = Time.time;
+
+ mat.SetPass(0);
+ GL.PushMatrix();
+ GL.LoadOrtho();
+ GL.Begin(GL.TRIANGLES);
+
+ var innerLimit = 1f - _reach;
+ for (var i = 0; i < _count; i++)
+ {
+ var angleNoise = Hash01(i, Mathf.FloorToInt(_seed * 100f));
+ var angle = 2f * Mathf.PI * (i + 0.5f + (angleNoise - 0.5f) * 0.7f) / _count;
+ var cosA = Mathf.Cos(angle);
+ var sinA = Mathf.Sin(angle);
+
+ // Direction and perpendicular in GL ortho space (x-axis scaled by invAspect
+ // so angles and widths appear visually correct on screen).
+ var dirX = cosA * invAspect;
+ var dirY = sinA;
+
+ // Find the screen-edge endpoint for this aspect-corrected visual ray.
+ ScreenBoundary(dirX, dirY, out var edgeX, out var edgeY);
+
+ // Perpendicular in GL ortho space (visual unit vector → GL representation).
+ var perpX = -sinA * invAspect;
+ var perpY = cosA;
+
+ var phase = Hash01(i + 17, Mathf.FloorToInt(_seed * 100f));
+ var rate = Mathf.Lerp(0.75f, 1.35f, Hash01(i + 31, Mathf.FloorToInt(_seed * 47f)));
+ var progress = Mathf.Repeat(time * _speed * rate + phase, 1f);
+ var innerT = Mathf.Lerp(innerLimit, 1f, progress);
+ var outerT = Mathf.Min(1f, innerT + Mathf.Lerp(0.14f, 0.32f, Hash01(i + 53, Mathf.FloorToInt(_seed * 71f))));
+ if (outerT - innerT <= 0.001f)
+ continue;
+
+ var innerX = Mathf.Lerp(0.5f, edgeX, innerT);
+ var innerY = Mathf.Lerp(0.5f, edgeY, innerT);
+ var outerX = Mathf.Lerp(0.5f, edgeX, outerT);
+ var outerY = Mathf.Lerp(0.5f, edgeY, outerT);
+ var innerWidth = HalfWidth * 0.12f;
+ var outerWidth = HalfWidth * Mathf.Lerp(0.7f, 1.15f, outerT);
+ var pulse = 0.75f + 0.25f * Mathf.Sin(time * _speed * rate * (2f * Mathf.PI) + phase * 2f * Mathf.PI);
+ GL.Color(new Color(_color.r, _color.g, _color.b, _alpha * _color.a * pulse));
+ GL.Vertex3(innerX + perpX * innerWidth, innerY + perpY * innerWidth, 0f);
+ GL.Vertex3(outerX + perpX * outerWidth, outerY + perpY * outerWidth, 0f);
+ GL.Vertex3(outerX - perpX * outerWidth, outerY - perpY * outerWidth, 0f);
+ GL.Vertex3(innerX + perpX * innerWidth, innerY + perpY * innerWidth, 0f);
+ GL.Vertex3(outerX - perpX * outerWidth, outerY - perpY * outerWidth, 0f);
+ GL.Vertex3(innerX - perpX * innerWidth, innerY - perpY * innerWidth, 0f);
+ }
+
+ GL.End();
+ GL.PopMatrix();
+ }
+ }
+}
diff --git a/BopVisualEffects/Effects/SpeedLines/SpeedLinesService.cs b/BopVisualEffects/Effects/SpeedLines/SpeedLinesService.cs
new file mode 100644
index 0000000..1379ed8
--- /dev/null
+++ b/BopVisualEffects/Effects/SpeedLines/SpeedLinesService.cs
@@ -0,0 +1,116 @@
+using System.Collections.Generic;
+using BopVisualEffects.Core;
+using UnityEngine;
+
+namespace BopVisualEffects.Effects.SpeedLines;
+
+internal sealed class SpeedLinesRequest
+{
+ public float Alpha;
+ public float Count;
+ public float Speed;
+ public float Reach;
+ public Color Color = Color.white;
+}
+
+internal static class SpeedLinesService
+{
+ private sealed class CameraState
+ {
+ public readonly List Requests = [];
+ public SpeedLinesOverlay? Overlay;
+ }
+
+ private static readonly Dictionary States = [];
+
+ public static SpeedLinesRequest AddRequest(Camera camera)
+ {
+ if (!States.TryGetValue(camera, out var state))
+ {
+ state = new CameraState();
+ States[camera] = state;
+ }
+
+ if (!state.Overlay)
+ {
+ state.Overlay = camera.gameObject.AddComponent();
+ state.Overlay.Requests = state.Requests;
+ }
+
+ var request = new SpeedLinesRequest();
+ state.Requests.Add(request);
+ return request;
+ }
+
+ public static void RemoveRequest(Camera camera, SpeedLinesRequest request)
+ {
+ if (!States.TryGetValue(camera, out var state))
+ return;
+
+ state.Requests.Remove(request);
+ if (state.Requests.Count == 0)
+ {
+ if (state.Overlay)
+ Object.Destroy(state.Overlay);
+ States.Remove(camera);
+ }
+ }
+
+ internal sealed class SpeedLinesOverlay : MonoBehaviour
+ {
+ private const string ShaderAssetPath = "Assets/Shaders/BopVisualEffects_SpeedLines.shader";
+ private static Material? _material;
+ private static bool _shaderUnavailable;
+ internal List? Requests;
+
+ private static Material? GetMaterial()
+ {
+ if (_material)
+ return _material;
+ if (_shaderUnavailable)
+ return null;
+
+ var bundle = ShaderBundleLoader.GetBundle();
+ var shader = bundle?.LoadAsset(ShaderAssetPath);
+ if (shader is null || !shader.isSupported)
+ {
+ _shaderUnavailable = true;
+ ClassLogger.GetForClass().Warning("[Speed Lines] Shader bundle unavailable or shader unsupported.");
+ return null;
+ }
+
+ _material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave };
+ return _material;
+ }
+
+ private void OnRenderImage(RenderTexture source, RenderTexture destination)
+ {
+ if (Requests is null)
+ {
+ Graphics.Blit(source, destination);
+ return;
+ }
+
+ SpeedLinesRequest? active = null;
+ foreach (var request in Requests)
+ {
+ if (request.Alpha > 0f && (active is null || request.Alpha > active.Alpha))
+ active = request;
+ }
+
+ var material = GetMaterial();
+ if (active is null || material is null)
+ {
+ Graphics.Blit(source, destination);
+ return;
+ }
+
+ material.SetColor("_Colour", active.Color);
+ material.SetFloat("_SpeedLinesTiling", Mathf.Clamp(active.Count * 2f, 24f, 384f));
+ material.SetFloat("_SpeedLinesAnimation", Mathf.Max(0.1f, active.Speed));
+ material.SetFloat("_Reach", Mathf.Clamp01(active.Reach));
+ material.SetFloat("_Intensity", Mathf.Clamp01(active.Alpha));
+ Graphics.Blit(source, destination, material);
+ }
+ }
+}
diff --git a/BopVisualEffects/Patches/MixtapeEditorScriptPlaybackPatch.cs b/BopVisualEffects/Patches/MixtapeEditorScriptPlaybackPatch.cs
index 04209fc..f8a9478 100644
--- a/BopVisualEffects/Patches/MixtapeEditorScriptPlaybackPatch.cs
+++ b/BopVisualEffects/Patches/MixtapeEditorScriptPlaybackPatch.cs
@@ -6,7 +6,7 @@
namespace BopVisualEffects.Patches;
///
-/// Stops all active visual effect runners when editor playback is stopped/paused.
+/// Stops all active visual effect runners when editor playback is stopped.
///
[HarmonyPatch]
public static class MixtapeEditorScriptPlaybackPatch
@@ -20,13 +20,13 @@ public static IEnumerable TargetMethods()
if (playOrStop is not null)
yield return playOrStop;
- MethodBase? pauseOrUnpause = AccessTools.Method(typeof(MixtapeEditorScript), "PauseOrUnpauseMixtape");
- if (pauseOrUnpause is not null)
- yield return pauseOrUnpause;
-
MethodBase? filePause = AccessTools.Method(typeof(MixtapeEditorScript), "FilePause");
if (filePause is not null)
yield return filePause;
+
+ MethodBase? pauseOrUnpause = AccessTools.Method(typeof(MixtapeEditorScript), "PauseOrUnpauseMixtape");
+ if (pauseOrUnpause is not null)
+ yield return pauseOrUnpause;
}
///
diff --git a/BopVisualEffects/Resources/bopvisualeffects_shaders_linux.assetbundle b/BopVisualEffects/Resources/bopvisualeffects_shaders_linux.assetbundle
index ade2e4e..e281918 100644
Binary files a/BopVisualEffects/Resources/bopvisualeffects_shaders_linux.assetbundle and b/BopVisualEffects/Resources/bopvisualeffects_shaders_linux.assetbundle differ
diff --git a/BopVisualEffects/Resources/bopvisualeffects_shaders_osx.assetbundle b/BopVisualEffects/Resources/bopvisualeffects_shaders_osx.assetbundle
index a2e5627..37060b1 100644
Binary files a/BopVisualEffects/Resources/bopvisualeffects_shaders_osx.assetbundle and b/BopVisualEffects/Resources/bopvisualeffects_shaders_osx.assetbundle differ
diff --git a/BopVisualEffects/Resources/bopvisualeffects_shaders_win.assetbundle b/BopVisualEffects/Resources/bopvisualeffects_shaders_win.assetbundle
index 03b66f8..059cce1 100644
Binary files a/BopVisualEffects/Resources/bopvisualeffects_shaders_win.assetbundle and b/BopVisualEffects/Resources/bopvisualeffects_shaders_win.assetbundle differ
diff --git a/BopVisualEffectsShaders/Assets/Editor/BuildAssetBundles.cs b/BopVisualEffectsShaders/Assets/Editor/BuildAssetBundles.cs
index 19bbd3a..016c8ef 100644
--- a/BopVisualEffectsShaders/Assets/Editor/BuildAssetBundles.cs
+++ b/BopVisualEffectsShaders/Assets/Editor/BuildAssetBundles.cs
@@ -37,6 +37,7 @@ public static void Build()
{
"Assets/Shaders/BopVisualEffects_HSL.shader",
"Assets/Shaders/BopVisualEffects_Sepia.shader",
+ "Assets/Shaders/BopVisualEffects_SpeedLines.shader",
}
}
};
diff --git a/BopVisualEffectsShaders/Assets/Shaders/BopVisualEffects_SpeedLines.shader b/BopVisualEffectsShaders/Assets/Shaders/BopVisualEffects_SpeedLines.shader
new file mode 100644
index 0000000..98569c9
--- /dev/null
+++ b/BopVisualEffectsShaders/Assets/Shaders/BopVisualEffects_SpeedLines.shader
@@ -0,0 +1,88 @@
+Shader "Hidden/BopVisualEffects_SpeedLines"
+{
+ Properties
+ {
+ _MainTex ("Texture", 2D) = "white" {}
+ _Colour ("Colour", Color) = (1,1,1,1)
+ _SpeedLinesTiling ("Speed Lines Tiling", Float) = 192
+ _SpeedLinesAnimation ("Speed Lines Animation", Float) = 3
+ _SpeedLinesRemap ("Speed Lines Remap", Range(0,1)) = 0.65
+ _SpeedLinesPower ("Speed Lines Power", Float) = 1
+ _Reach ("Reach", Range(0,1)) = 0.25
+ _Intensity ("Intensity", Range(0,1)) = 1
+ }
+ SubShader
+ {
+ ZTest Always Cull Off ZWrite Off
+ Pass
+ {
+ CGPROGRAM
+ #pragma vertex vert_img
+ #pragma fragment frag
+ #include "UnityCG.cginc"
+
+ sampler2D _MainTex;
+ float4 _Colour;
+ float _SpeedLinesTiling;
+ float _SpeedLinesAnimation;
+ float _SpeedLinesRemap;
+ float _SpeedLinesPower;
+ float _Reach;
+ float _Intensity;
+
+ float3 mod289(float3 x) { return x - floor(x / 289.0) * 289.0; }
+ float2 mod289(float2 x) { return x - floor(x / 289.0) * 289.0; }
+ float3 permute(float3 x) { return mod289((x * 34.0 + 1.0) * x); }
+
+ float snoise(float2 v)
+ {
+ const float4 C = float4(0.2113248654, 0.3660254038, -0.5773502692, 0.0243902439);
+ float2 i = floor(v + dot(v, C.yy));
+ float2 x0 = v - i + dot(i, C.xx);
+ float2 i1 = (x0.x > x0.y) ? float2(1, 0) : float2(0, 1);
+ float4 x12 = x0.xyxy + C.xxzz;
+ x12.xy -= i1;
+ i = mod289(i);
+ float3 p = permute(permute(i.y + float3(0, i1.y, 1)) + i.x + float3(0, i1.x, 1));
+ float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0);
+ m *= m; m *= m;
+ float3 x = 2.0 * frac(p * C.www) - 1.0;
+ float3 h = abs(x) - 0.5;
+ float3 ox = floor(x + 0.5);
+ float3 a0 = x - ox;
+ m *= 1.7928429 - 0.8537347 * (a0 * a0 + h * h);
+ float3 g;
+ g.x = a0.x * x0.x + h.x * x0.y;
+ g.yz = a0.yz * x12.xz + h.yz * x12.yw;
+ return 130.0 * dot(m, g);
+ }
+
+ fixed4 frag(v2f_img i) : SV_Target
+ {
+ float2 centered = i.uv - 0.5;
+ float aspect = _ScreenParams.x / _ScreenParams.y;
+ float2 aspectCorrected = float2(centered.x * aspect, centered.y);
+ float radial = length(aspectCorrected) * 2.0;
+ float angle = atan2(centered.x, centered.y) / 6.28318530718;
+
+ float2 noiseUv = float2(radial * 0.2, angle * _SpeedLinesTiling);
+ noiseUv.x -= _SpeedLinesAnimation * _Time.y;
+ float noise = snoise(noiseUv) * 0.5 + 0.5;
+ float lines = saturate((pow(noise, _SpeedLinesPower) - _SpeedLinesRemap) / max(0.001, 1.0 - _SpeedLinesRemap));
+
+ // Use normalized distance to the nearest rectangular screen edge for the
+ // reach mask. The radial coordinate can exceed 1 on a widescreen display;
+ // masking it as a circle would incorrectly remove the side edges.
+ float edgeDistance = max(abs(centered.x), abs(centered.y)) * 2.0;
+ float innerRadius = saturate(1.0 - _Reach);
+ float mask = smoothstep(innerRadius, min(1.0, innerRadius + 0.12), edgeDistance);
+ float effect = lines * mask * _Intensity * _Colour.a;
+
+ fixed4 scene = tex2D(_MainTex, i.uv);
+ scene.rgb = lerp(scene.rgb, _Colour.rgb, effect);
+ return scene;
+ }
+ ENDCG
+ }
+ }
+}
diff --git a/BopVisualEffectsShaders/Assets/Shaders/BopVisualEffects_SpeedLines.shader.meta b/BopVisualEffectsShaders/Assets/Shaders/BopVisualEffects_SpeedLines.shader.meta
new file mode 100644
index 0000000..e51ef30
--- /dev/null
+++ b/BopVisualEffectsShaders/Assets/Shaders/BopVisualEffects_SpeedLines.shader.meta
@@ -0,0 +1,9 @@
+fileFormatVersion: 2
+guid: 2e41f2f4fbe8472d9a2c338da5a1b0fe
+ShaderImporter:
+ externalObjects: {}
+ defaultTextures: []
+ nonModifiableTextures: []
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/BopVisualEffectsShaders/README.md b/BopVisualEffectsShaders/README.md
index db03d72..0b7821d 100644
--- a/BopVisualEffectsShaders/README.md
+++ b/BopVisualEffectsShaders/README.md
@@ -31,6 +31,7 @@ to their GL-based rendering path.
|---|---|
| `Assets/Shaders/BopVisualEffects_HSL.shader` | Per-pixel hue rotation, saturation scaling and lightness offset. |
| `Assets/Shaders/BopVisualEffects_Sepia.shader` | Per-pixel sepia tone using the standard Adobe/Kodak conversion matrix. |
+| `Assets/Shaders/BopVisualEffects_SpeedLines.shader` | Polar-noise radial speed-line post-processing effect based on Anime-Speed-Lines. |
## Build steps
diff --git a/docs/effects/README.md b/docs/effects/README.md
index cb96714..8728d23 100644
--- a/docs/effects/README.md
+++ b/docs/effects/README.md
@@ -202,6 +202,28 @@ Applies a warm vintage sepia-tone filter using a per-pixel shader (standard Adob
https://github.com/user-attachments/assets/ff1025b3-1f4b-44c8-982f-6691fe861277
+## Speed Lines
+
+**DisplayName:** `Speed Lines`
+
+**Config Key:** `SpeedLines.Enabled`
+
+Draws an animated, noise-driven radial speed-line vignette. Thin colored streak segments are procedurally distributed in polar space and continuously scroll toward the screen edges. Fades in and out smoothly.
+
+**Properties**
+- `alpha`: Maximum opacity of the speed lines (0–1; default `0.8`).
+- `count`: Angular density of the procedural speed-line field (24–192; default `96`).
+- `speed`: Animation rate of the procedural noise in cycles per second (default `3.5`). Higher values produce faster-moving streaks.
+- `reach`: How far each speed line reaches toward the screen center as a normalized edge-to-center fraction (0–1; default `0.25`). `0` keeps the effect at the screen edge; `1` reaches the center. The calculation accounts for the screen aspect ratio.
+- `color`: Color and base opacity of the speed lines (default white).
+- `ease_in`: Fade speed lines in over the first 15% of the event (default `true`).
+- `ease_out`: Fade speed lines out over the last 15% of the event (default `true`).
+- `length` (event length in editor): How long the effect lasts, in beats. This event is resizable in the timeline.
+
+**Preview**
+
+https://github.com/user-attachments/assets/edddc3f0-6b81-4b29-9956-da7df65da25b
+
## Vertical Flip
**DisplayName:** `Vertical Flip`