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`