Add support for interpolated color transitions for lights, with debug-only synced overrides

This commit is contained in:
ivan tkachenko 2025-07-16 13:22:09 +03:00
parent ad77530b6d
commit a8761bf679
1 changed files with 238 additions and 5 deletions

View File

@ -139,6 +139,33 @@ namespace MuzikaGromche
public static readonly Language RUSSIAN = new("RU", "Russian");
}
public readonly record struct Easing(string Name, Func<float, float> Eval)
{
public static Easing Linear = new("Linear", static x => x);
public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f));
public static Easing InCubic = new("InCubic", static x => x * x * x);
public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f);
public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f));
public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x));
public static Easing InOutExpo = new("InOutExpo", static x =>
x == 0f
? 0f
: x == 1f
? 1f
: x < 0.5f
? Mathf.Pow(2f, 20f * x - 10f) / 2f
: (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f);
public static Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo];
public static string[] AllNames => [.. All.Select(easing => easing.Name)];
public static Easing FindByName(string Name)
{
return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First();
}
}
public record Palette(Color[] Colors)
{
public static Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]);
@ -203,6 +230,39 @@ namespace MuzikaGromche
_ => "",
};
// Offset of beats. Bigger offset => colors will change later.
public float _BeatsOffset = 0f;
public float BeatsOffset
{
get => Config.BeatsOffsetOverride ?? _BeatsOffset;
set => _BeatsOffset = value;
}
// Duration of color transition, measured in beats.
public float _ColorTransitionIn = 0.25f;
public float ColorTransitionIn
{
get => Config.ColorTransitionInOverride ?? _ColorTransitionIn;
set => _ColorTransitionIn = value;
}
public float _ColorTransitionOut = 0.25f;
public float ColorTransitionOut
{
get => Config.ColorTransitionOutOverride ?? _ColorTransitionOut;
set => _ColorTransitionOut = value;
}
// Easing function for color transitions.
public Easing _ColorTransitionEasing = Easing.OutExpo;
public Easing ColorTransitionEasing
{
get => Config.ColorTransitionEasingOverride != null
? Easing.FindByName(Config.ColorTransitionEasingOverride)
: _ColorTransitionEasing;
set => _ColorTransitionEasing = value;
}
public float CalculateBeat(AudioSource start, AudioSource loop)
{
// If popped, calculate which beat the music is currently at.
@ -227,15 +287,16 @@ namespace MuzikaGromche
var normalized = Mod.Positive(elapsed / LoadedLoop.length, 1f);
var beat = normalized * (float)Beats;
var offset = Mod.Positive(beat - BeatsOffset, (float)Beats);
#if DEBUG
var color = ColorAtBeat(beat);
Debug.LogFormat("MuzikaGromche t={0,10:N4} d={1,7:N4} Start[{2}{3,8:N4} ==0f? {4}] Loop[{5}{6,8:N4}] norm={7,6:N4} beat={8,7:N4} color={9}",
Debug.LogFormat("MuzikaGromche t={0,10:N4} d={1,7:N4} Start[{2}{3,8:N4} ==0f? {4}] Loop[{5}{6,8:N4}] norm={7,6:N4} beat={8,7:N4} {9,7:N4} color={10}",
Time.realtimeSinceStartup, Time.deltaTime,
(start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'),
(loop.isPlaying ? '+' : ' '), loop.time,
normalized, beat, color);
normalized, beat, offset, color);
#endif
return beat;
return offset;
}
public Palette _Palette = Palette.DEFAULT;
@ -247,8 +308,64 @@ namespace MuzikaGromche
public Color ColorAtBeat(float beat)
{
int beatIndex = Mod.Positive(Mathf.FloorToInt(beat), Beats);
return Mod.Index(Palette.Colors, beatIndex);
// Imagine the timeline as a sequence of clips without gaps where each clip is a whole beat long.
// Transition is when two adjacent clips need to be combined with some blend function(t)
// where t is a factor in range 0..1 expressed as (time - Transition.Start) / Transition.Length;
//
// How to find a transition at a given time?
// First, we need to find the current clip's start and length.
// - Length is always 1 beat, and
// - start is just time rounded down.
//
// If time interval from the start of the clip is less than Transition.Out
// then blend between previous and current clips.
//
// Else if time interval to the end of the clip is less than Transition.In
// then blend between current and next clips.
//
// Otherwise there is no transition running at this time.
const float currentClipLength = 1f;
var currentClipStart = Mathf.Floor(beat);
var currentClipEnd = currentClipStart + currentClipLength;
float transitionLength = ColorTransitionIn + ColorTransitionOut;
if (Config.EnableColorAnimations.Value)
{
if (transitionLength > /* epsilon */ 0.01)
{
if (beat - currentClipStart < ColorTransitionOut)
{
return ColorTransition(currentClipStart);
}
else if (currentClipEnd - beat < ColorTransitionIn)
{
return ColorTransition(currentClipEnd);
}
}
}
// default
return ColorAtWholeBeat(beat);
Color ColorTransition(float clipsBoundary)
{
var transitionStart = clipsBoundary - ColorTransitionIn;
var transitionEnd = clipsBoundary + ColorTransitionOut;
var x = (beat - transitionStart) / transitionLength;
var t = Mathf.Clamp(ColorTransitionEasing.Eval(x), 0f, 1f);
if (ColorTransitionIn == 0.0f)
{
// Subtract an epsilon, so we don't use the same beat twice
transitionStart -= 0.01f;
}
return Color.Lerp(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), t);
}
Color ColorAtWholeBeat(float beat)
{
int beatIndex = Mod.Positive(Mathf.FloorToInt(beat), Beats);
return Mod.Index(Palette.Colors, beatIndex);
}
}
}
@ -376,14 +493,25 @@ namespace MuzikaGromche
public class Config : SyncedConfig2<Config>
{
public static ConfigEntry<bool> EnableColorAnimations { get; private set; }
public static ConfigEntry<float> AudioOffset { get; private set; }
public static bool ShouldSkipWindingPhase { get; private set; } = false;
public static Palette PaletteOverride { get; private set; } = null;
public static float? BeatsOffsetOverride { get; private set; } = null;
public static float? ColorTransitionInOverride { get; private set; } = null;
public static float? ColorTransitionOutOverride { get; private set; } = null;
public static string ColorTransitionEasingOverride { get; private set; } = null;
public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID)
{
EnableColorAnimations = configFile.Bind("General", "Enable Color Animations", true,
new ConfigDescription("Smooth light color transitions are known to cause performance issues on some setups.\n\nTurn them off if you experience lag spikes."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(EnableColorAnimations, requiresRestart: false));
AudioOffset = configFile.Bind("General", "Audio Offset", 0f, new ConfigDescription(
"Adjust audio offset (in seconds).\n\nIf you are playing with Bluetooth headphones and experiencing a visual desync, try setting this to about negative 0.2.\n\nIf your video output has high latency (like a long HDMI cable etc.), try positive values instead.",
new AcceptableValueRange<float>(-0.5f, 0.5f)));
@ -392,6 +520,7 @@ namespace MuzikaGromche
#if DEBUG
SetupEntriesToSkipWinding(configFile);
SetupEntriesForPaletteOverride(configFile);
SetupEntriesForTimingsOverride(configFile);
#endif
var chanceRange = new AcceptableValueRange<int>(0, 100);
@ -579,6 +708,110 @@ namespace MuzikaGromche
}
}
}
private void SetupEntriesForTimingsOverride(ConfigFile configFile)
{
const string section = "Timings";
var colorTransitionRange = new AcceptableValueRange<float>(0f, 1f);
// Declare and initialize early to avoid "Use of unassigned local variable"
SyncedEntry<bool> overrideTimingsSyncedEntry = null;
SyncedEntry<float> beatsOffsetSyncedEntry = null;
SyncedEntry<float> colorTransitionInSyncedEntry = null;
SyncedEntry<float> colorTransitionOutSyncedEntry = null;
SyncedEntry<string> colorTransitionEasingSyncedEntry = null;
var loadButton = new GenericButtonConfigItem(section, "Load Timings from the Current Track",
"Override custom timings with the built-in timings of the current track.", "Load", load);
loadButton.ButtonOptions.CanModifyCallback = CanModifyIfHost;
LethalConfigManager.AddConfigItem(loadButton);
overrideTimingsSyncedEntry = configFile.BindSyncedEntry(section, "Override Timings", false,
new ConfigDescription("If checked, custom timings override track's own built-in timings."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(overrideTimingsSyncedEntry.Entry, new BoolCheckBoxOptions
{
RequiresRestart = false,
CanModifyCallback = CanModifyIfHost,
}));
CSyncHackAddSyncedEntry(overrideTimingsSyncedEntry);
overrideTimingsSyncedEntry.Changed += (sender, args) => apply();
overrideTimingsSyncedEntry.SyncHostToLocal();
beatsOffsetSyncedEntry = configFile.BindSyncedEntry(section, "Beats Offset", 0f,
new ConfigDescription("How much to offset the whole beat. More is later", new AcceptableValueRange<float>(-0.5f, 0.5f)));
colorTransitionInSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition In", 0.25f,
new ConfigDescription("Fraction of a beat *before* the whole beat when the color transition should start.", colorTransitionRange));
colorTransitionOutSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition Out", 0.25f,
new ConfigDescription("Fraction of a beat *after* the whole beat when the color transition should end.", colorTransitionRange));
colorTransitionEasingSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition Easing", Easing.Linear.Name,
new ConfigDescription("Interpolation/easing method to use for color transitions", new AcceptableValueList<string>(Easing.AllNames)));
var floatSliderOptions = new FloatSliderOptions
{
RequiresRestart = false,
CanModifyCallback = CanModifyIfHost,
};
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(beatsOffsetSyncedEntry.Entry, floatSliderOptions));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionInSyncedEntry.Entry, floatSliderOptions));
LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionOutSyncedEntry.Entry, floatSliderOptions));
LethalConfigManager.AddConfigItem(new TextDropDownConfigItem(colorTransitionEasingSyncedEntry.Entry, new TextDropDownOptions
{
RequiresRestart = false,
CanModifyCallback = CanModifyIfHost,
}));
CSyncHackAddSyncedEntry(beatsOffsetSyncedEntry);
CSyncHackAddSyncedEntry(colorTransitionInSyncedEntry);
CSyncHackAddSyncedEntry(colorTransitionOutSyncedEntry);
CSyncHackAddSyncedEntry(colorTransitionEasingSyncedEntry);
beatsOffsetSyncedEntry.SyncHostToLocal();
colorTransitionInSyncedEntry.SyncHostToLocal();
colorTransitionOutSyncedEntry.SyncHostToLocal();
colorTransitionEasingSyncedEntry.SyncHostToLocal();
beatsOffsetSyncedEntry.Changed += (sender, args) => apply();
colorTransitionInSyncedEntry.Changed += (sender, args) => apply();
colorTransitionOutSyncedEntry.Changed += (sender, args) => apply();
colorTransitionEasingSyncedEntry.Changed += (sender, args) => apply();
void load()
{
// if track is null, set everything to defaults
var track = Plugin.CurrentTrack;
if (track == null)
{
beatsOffsetSyncedEntry.LocalValue = 0f;
colorTransitionInSyncedEntry.LocalValue = 0f;
colorTransitionOutSyncedEntry.LocalValue = 0f;
colorTransitionEasingSyncedEntry.LocalValue = Easing.Linear.Name;
}
else
{
beatsOffsetSyncedEntry.LocalValue = track._BeatsOffset;
colorTransitionInSyncedEntry.LocalValue = track._ColorTransitionIn;
colorTransitionOutSyncedEntry.LocalValue = track._ColorTransitionOut;
colorTransitionEasingSyncedEntry.LocalValue = track._ColorTransitionEasing.Name;
}
}
void apply()
{
if (!overrideTimingsSyncedEntry.Value)
{
BeatsOffsetOverride = null;
ColorTransitionInOverride = null;
ColorTransitionOutOverride = null;
ColorTransitionEasingOverride = null;
}
else
{
BeatsOffsetOverride = beatsOffsetSyncedEntry.Value;
ColorTransitionInOverride = colorTransitionInSyncedEntry.Value;
ColorTransitionOutOverride = colorTransitionOutSyncedEntry.Value;
ColorTransitionEasingOverride = colorTransitionEasingSyncedEntry.Value;
}
}
}
}
// farAudio is during windup, Start overrides popGoesTheWeaselTheme