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..81bdd1d
--- /dev/null
+++ b/BopVisualEffects/Effects/SpeedLines/SpeedLinesEffect.cs
@@ -0,0 +1,309 @@
+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
+{
+ ///
+ public string Id => "speed lines";
+
+ ///
+ public string DisplayName => "Speed Lines";
+
+ ///
+ public string ConfigKey => "SpeedLines";
+
+ ///
+ public string Description => "Draws animated black triangles radiating inward from the screen edges 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"] = 20.0f,
+ ["speed"] = 3.5f
+ }
+ };
+ }
+
+ ///
+ 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 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));
+ }
+ }
+
+ private sealed class SpeedLinesRunner : MonoBehaviour
+ {
+ private bool _initialized;
+ private float _alpha;
+ private int _count;
+ private float _speed;
+ private float _startBeat;
+ private float _endBeat;
+ private MixtapeLoaderCustom? _loader;
+ private JukeboxScript? _jukebox;
+ private SpeedLinesOverlay? _overlay;
+
+ ///
+ /// Initializes this runner with effect parameters.
+ ///
+ public void Initialize(MixtapeLoaderCustom loader, JukeboxScript? jukebox, float startBeat, float endBeat, float alpha, float count, float speed)
+ {
+ _loader = loader;
+ _jukebox = jukebox;
+ _startBeat = startBeat;
+ _endBeat = endBeat;
+ _alpha = Mathf.Clamp01(alpha);
+ _count = Mathf.Clamp(Mathf.RoundToInt(count), 4, 64);
+ _speed = Mathf.Max(0.1f, speed);
+ 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;
+ }
+
+ // Fade in over first 15%, hold, fade out over last 15%.
+ var progress = Mathf.InverseLerp(_startBeat, _endBeat, currentBeat);
+ float envelope;
+ if (progress < 0.15f)
+ envelope = Mathf.InverseLerp(0f, 0.15f, progress);
+ else if (progress > 0.85f)
+ envelope = 1f - Mathf.InverseLerp(0.85f, 1f, progress);
+ else
+ envelope = 1f;
+
+ if (_overlay != null)
+ _overlay.SetParams(_alpha * envelope, _speed);
+ }
+
+ private void OnDisable()
+ {
+ RemoveOverlay();
+ }
+
+ private void InitializeOverlay()
+ {
+ Camera? camera = EffectRuntimeController.ResolveEffectCamera(_loader);
+ if (camera is null)
+ return;
+
+ _overlay = camera.gameObject.AddComponent();
+ _overlay.Initialize(_count, _startBeat);
+ _overlay.SetParams(_alpha, _speed);
+ _initialized = true;
+ }
+
+ private void RemoveOverlay()
+ {
+ if (_overlay != null)
+ Destroy(_overlay);
+ _overlay = null;
+ }
+ }
+
+ ///
+ /// Draws animated black speed-line triangles in OnPostRender.
+ /// Each triangle points inward from the screen edge toward the center,
+ /// flickering at an independently randomized phase to create a high-speed rush effect.
+ /// Must be attached to a Camera's GameObject.
+ ///
+ private sealed class SpeedLinesOverlay : MonoBehaviour
+ {
+ // Distance from screen center to the triangle tip, in screen-height fractions.
+ private const float InnerRadius = 0.12f;
+
+ // Half-width of the triangle base at the screen boundary, in screen-height fractions.
+ private const float HalfWidth = 0.025f;
+
+ private static Material? _material;
+ private float _alpha;
+ private float _speed;
+ private float[]? _angles;
+ private float[]? _phases;
+
+ ///
+ /// 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)
+ {
+ var rng = new System.Random(Mathf.FloorToInt(seed * 100f) + count * 7919);
+ _angles = new float[count];
+ _phases = new float[count];
+ for (var i = 0; i < count; i++)
+ {
+ // Stratified random angles: distribute evenly around the screen with a small
+ // random jitter so the layout looks natural but not perfectly uniform.
+ var baseAngle = 2f * Mathf.PI * i / count;
+ var jitter = (float)(rng.NextDouble() - 0.5) * (2f * Mathf.PI / count) * 0.5f;
+ _angles[i] = baseAngle + jitter;
+ _phases[i] = (float)(rng.NextDouble() * 2.0 * System.Math.PI);
+ }
+ }
+
+ ///
+ /// 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;
+ }
+
+ // Unity calls this on the camera's GameObject after it finishes rendering the scene.
+ private void OnPostRender()
+ {
+ if (_alpha <= 0f || _angles is null || _phases is null)
+ 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);
+
+ for (var i = 0; i < _angles.Length; i++)
+ {
+ // Squaring the sine value produces sharper bright pulses separated by longer dark
+ // periods, reinforcing the high-speed feel. Each triangle uses a unique phase
+ // so they flash at different times rather than all at once.
+ var squaredSin = Mathf.Sin(time * _speed * (2f * Mathf.PI) + _phases[i]);
+ squaredSin *= squaredSin;
+ var flickerAlpha = squaredSin * _alpha;
+ if (flickerAlpha <= 0.005f)
+ continue;
+
+ var angle = _angles[i];
+ 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;
+
+ // Triangle tip: InnerRadius away from screen center along the visual direction.
+ var tipX = 0.5f + cosA * InnerRadius * invAspect;
+ var tipY = 0.5f + sinA * InnerRadius;
+
+ // Triangle base: centered on the screen boundary at this angle.
+ ScreenBoundary(dirX, dirY, out var bx, out var by);
+
+ // Perpendicular in GL ortho space (visual unit vector → GL representation).
+ var perpX = -sinA * invAspect;
+ var perpY = cosA;
+
+ // Offset the base center by ±HalfWidth along the perpendicular.
+ var base1X = bx + perpX * HalfWidth;
+ var base1Y = by + perpY * HalfWidth;
+ var base2X = bx - perpX * HalfWidth;
+ var base2Y = by - perpY * HalfWidth;
+
+ var color = new Color(0f, 0f, 0f, flickerAlpha);
+ GL.Color(color);
+ GL.Vertex3(tipX, tipY, 0f);
+ GL.Vertex3(base1X, base1Y, 0f);
+ GL.Vertex3(base2X, base2Y, 0f);
+ }
+
+ GL.End();
+ GL.PopMatrix();
+ }
+ }
+}
diff --git a/docs/effects/README.md b/docs/effects/README.md
index f9cb92a..584b74c 100644
--- a/docs/effects/README.md
+++ b/docs/effects/README.md
@@ -186,6 +186,24 @@ 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 animated black triangles radiating inward from the screen edges to convey a feeling of high speed. Each triangle independently flickers at its own phase, creating a dynamic rush effect. Fades in and out smoothly.
+
+**Properties**
+- `alpha`: Maximum opacity of the triangles (0–1; default `0.8`).
+- `count`: Number of speed-line triangles (4–64; default `20`).
+- `speed`: Flicker rate of each triangle in cycles per second (default `3.5`). Higher values produce more frantic flickering; lower values give slower, more rhythmic pulses.
+- `length` (event length in editor): How long the effect lasts, in beats. This event is resizable in the timeline.
+
+**Preview**
+
+
+
## Vertical Flip
**DisplayName:** `Vertical Flip`