diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb64bb..44f6d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.3.1 - 2026-08-11 + +- Guard the selected frame cap and VSync divisor against later plugin overrides. +- Include live pacing settings and drift count in every frame report. + +## 0.3.0 - 2026-08-11 + +- Capture built-in Unity profiler markers on hitching frames for main-thread stage attribution. + +## 0.2.0 - 2026-08-11 + +- Automatically choose a VSync divisor near the configured frame-rate target. +- Correlate hitches with GC, Studio activity windows and BepInEx/Unity log bursts. + +## 0.1.3 - 2026-08-11 + +- Remove the last legacy-incompatible `System.Type` equality operator call. + +## 0.1.2 - 2026-08-11 + +- Avoid modern reflection comparison operators missing from Koikatu's legacy Mono runtime. + +## 0.1.1 - 2026-08-11 + +- Avoid modern BCL helpers so the plugin starts on Koikatu's legacy Unity Mono runtime. + ## 0.1.0 - 2026-08-11 - Initial Studio-only frame pacing, background-load budgeting and hitch diagnostics. diff --git a/README.md b/README.md index 9cbe0c4..1a2e5f6 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,18 @@ 面向 Koikatu CharaStudio 的低风险帧稳定与诊断插件。只在 `CharaStudio.exe` 加载。 -## 0.1.0 +## 0.3.1 - 使用 1 ms Windows 计时精度改善限帧节奏。 - 将 Studio 进程设为 AboveNormal,减少普通后台程序抢占。 - 降低 Unity 后台资源加载优先级,并限制异步上传的单帧预算。 - 将长暂停后的模拟追帧限制到 100 ms、粒子追帧限制到约 33 ms。 - 默认将 Studio 目标帧率设为 60,不强制修改 VSync。 +- 高刷新率且 VSync 开启时自动选择分频,避免 60 FPS 目标被 Unity 忽略后持续跑到约 150 FPS。 +- 每两秒校验一次目标帧率和 VSync,仅在后加载插件覆盖设置时恢复,报告中记录漂移次数。 - 每 10 秒输出 `SFS_FRAME`:平均 FPS、1% low、P95 帧时间、卡顿次数、最差帧、GC 卡顿及换人/换动作窗口卡顿。 +- 将卡顿进一步分为 GC、场景/动作活动、日志突发和未知主线程停顿。 +- 使用 Unity 内置低开销 Recorder 统计脚本 Update、LateUpdate、物理、动画、蒙皮、渲染和等待阶段。 - `F11` 显示或隐藏帧统计浮层。 配置文件首次启动后生成: @@ -19,4 +23,3 @@ BepInEx/config/codex.koikatu.studioframestabilizer.cfg ``` 所有优化项都可单独关闭。插件不会强制 GC、修改角色数据或改动物理参数。 - diff --git a/src/StudioFrameStabilizer/StudioFrameStabilizer.csproj b/src/StudioFrameStabilizer/StudioFrameStabilizer.csproj index aa91eee..3ae5abd 100644 --- a/src/StudioFrameStabilizer/StudioFrameStabilizer.csproj +++ b/src/StudioFrameStabilizer/StudioFrameStabilizer.csproj @@ -1,13 +1,13 @@ net472 - latest + 7.3 disable StudioFrameStabilizer StudioFrameStabilizer - 0.1.0 - 0.1.0.0 - 0.1.0.0 + 0.3.1 + 0.3.1.0 + 0.3.1.0 Z:\Koikatu diff --git a/src/StudioFrameStabilizer/StudioFrameStabilizerPlugin.cs b/src/StudioFrameStabilizer/StudioFrameStabilizerPlugin.cs index 790cd7f..094dbc2 100644 --- a/src/StudioFrameStabilizer/StudioFrameStabilizerPlugin.cs +++ b/src/StudioFrameStabilizer/StudioFrameStabilizerPlugin.cs @@ -1,15 +1,21 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Diagnostics; using System.Reflection; using System.Runtime.InteropServices; +using System.Threading; using BepInEx; using BepInEx.Configuration; +using BepInEx.Logging; using HarmonyLib; using Studio; using UnityEngine; +using UnityEngine.Profiling; using UnityEngine.SceneManagement; -namespace StudioFrameStabilizer; +namespace StudioFrameStabilizer +{ [BepInProcess("CharaStudio.exe")] [BepInPlugin(PluginGuid, PluginName, PluginVersion)] @@ -17,7 +23,7 @@ public sealed class StudioFrameStabilizerPlugin : BaseUnityPlugin { public const string PluginGuid = "codex.koikatu.studioframestabilizer"; public const string PluginName = "Studio Frame Stabilizer"; - public const string PluginVersion = "0.1.0"; + public const string PluginVersion = "0.3.1"; private const int FrameCapacity = 2048; private const int TimerResolutionMs = 1; @@ -28,6 +34,7 @@ public sealed class StudioFrameStabilizerPlugin : BaseUnityPlugin private ConfigEntry _enabled; private ConfigEntry _targetFrameRate; private ConfigEntry _vSyncCount; + private ConfigEntry _autoVSyncDivisor; private ConfigEntry _highResolutionTimer; private ConfigEntry _raiseProcessPriority; private ConfigEntry _lowerBackgroundLoadingPriority; @@ -37,12 +44,13 @@ public sealed class StudioFrameStabilizerPlugin : BaseUnityPlugin private ConfigEntry _asyncUploadBufferSize; private ConfigEntry _reportInterval; private ConfigEntry _hitchThresholdMs; + private ConfigEntry _logBurstThreshold; private ConfigEntry _logReports; private ConfigEntry _overlayKey; private Harmony _harmony; private ProcessPriorityClass _originalPriority; - private ThreadPriority _originalLoadingPriority; + private UnityEngine.ThreadPriority _originalLoadingPriority; private int _originalTargetFrameRate; private int _originalVSyncCount; private float _originalMaximumDeltaTime; @@ -50,13 +58,21 @@ public sealed class StudioFrameStabilizerPlugin : BaseUnityPlugin private bool _timerResolutionActive; private bool _priorityChanged; private bool _settingsApplied; + private int _desiredTargetFrameRate; + private int _desiredVSyncCount; + private float _pacingGuardElapsed; + private int _pacingDriftCount; private int _frameWriteIndex; private int _frameCount; private int _hitchCount; private int _gcHitchCount; private int _activityHitchCount; + private int _logHitchCount; + private int _unknownHitchCount; + private int _reportLogEventCount; private float _worstFrameMs; + private string _lastHitchLogSource = "none"; private float _reportElapsed; private float _activityWindowUntil; private int _lastGc0; @@ -65,6 +81,12 @@ public sealed class StudioFrameStabilizerPlugin : BaseUnityPlugin private bool _overlayVisible; private string _overlayText = "Studio Frame Stabilizer: collecting..."; private GUIStyle _overlayStyle; + private readonly FrameLogListener _frameLogListener = new FrameLogListener(); + private readonly List _profilerMarkers = + new List(); + private Coroutine _profilerCoroutine; + private readonly WaitForEndOfFrame _endOfFrame = new WaitForEndOfFrame(); + private bool _currentFrameHitch; private static StudioFrameStabilizerPlugin _instance; @@ -83,14 +105,17 @@ private void Awake() _lastGc2 = GC.CollectionCount(2); SceneManager.sceneLoaded += OnSceneLoaded; + BepInEx.Logging.Logger.Listeners.Add(_frameLogListener); _harmony = new Harmony(PluginGuid); _harmony.PatchAll(Assembly.GetExecutingAssembly()); + InitializeProfilerRecorders(); + _profilerCoroutine = StartCoroutine(CaptureProfilerFrames()); if (_enabled.Value) ApplySettings(); _enabled.SettingChanged += OnEnabledChanged; - Logger.LogInfo("Studio Frame Stabilizer 0.1.0 loaded. F11 toggles the frame overlay."); + Logger.LogInfo("Studio Frame Stabilizer 0.3.1 loaded. F11 toggles the frame overlay."); } private void BindConfig() @@ -99,16 +124,18 @@ private void BindConfig() "Apply frame pacing and background-load stability settings."); _targetFrameRate = Config.Bind("Frame pacing", "Target frame rate", 60, new ConfigDescription("Unity frame-rate cap. Set 0 to leave unchanged.", - new AcceptableValueRange(0, 240))); + new AcceptableValueRange(0, 240), new object[0])); _vSyncCount = Config.Bind("Frame pacing", "VSync count", -1, - new ConfigDescription("-1 keeps the current setting; 0 disables VSync; 1 enables it.", - new AcceptableValueRange(-1, 1))); + new ConfigDescription("-1 uses automatic/unchanged behavior; 0 disables VSync; positive values are the vertical-blank divisor.", + new AcceptableValueRange(-1, 4), new object[0])); + _autoVSyncDivisor = Config.Bind("Frame pacing", "Auto-select VSync divisor", true, + "When VSync is active, select a divisor that keeps the actual refresh rate near the target instead of ignoring the frame cap."); _maximumDeltaTime = Config.Bind("Frame pacing", "Maximum catch-up delta seconds", 0.10f, new ConfigDescription("Limits simulation catch-up after a long stall.", - new AcceptableValueRange(0.05f, 0.3333333f))); + new AcceptableValueRange(0.05f, 0.3333333f), new object[0])); _maximumParticleDeltaTime = Config.Bind("Frame pacing", "Maximum particle delta seconds", 0.0333333f, new ConfigDescription("Limits particle catch-up work after a hitch.", - new AcceptableValueRange(0.0166667f, 0.10f))); + new AcceptableValueRange(0.0166667f, 0.10f), new object[0])); _highResolutionTimer = Config.Bind("System", "Use 1 ms timer resolution", true, "Improves sleep and frame-cap timing precision while Studio is open."); @@ -118,21 +145,25 @@ private void BindConfig() "Trades slightly longer loads for fewer frame spikes while assets stream in."); _asyncUploadTimeSlice = Config.Bind("Loading", "Async upload time slice ms", 2, new ConfigDescription("Per-frame GPU upload CPU budget, when supported by this Unity build.", - new AcceptableValueRange(1, 8))); + new AcceptableValueRange(1, 8), new object[0])); _asyncUploadBufferSize = Config.Bind("Loading", "Async upload buffer MB", 32, new ConfigDescription("Upload ring buffer size, when supported by this Unity build.", - new AcceptableValueRange(16, 128))); + new AcceptableValueRange(16, 128), new object[0])); _reportInterval = Config.Bind("Diagnostics", "Report interval seconds", 10f, new ConfigDescription("Interval for rolling frame summaries.", - new AcceptableValueRange(5f, 60f))); + new AcceptableValueRange(5f, 60f), new object[0])); _hitchThresholdMs = Config.Bind("Diagnostics", "Hitch threshold ms", 50f, new ConfigDescription("Frames slower than this are counted as hitches.", - new AcceptableValueRange(25f, 250f))); + new AcceptableValueRange(25f, 250f), new object[0])); + _logBurstThreshold = Config.Bind("Diagnostics", "Log burst events per frame", 8, + new ConfigDescription("A hitching frame with at least this many log events is classified as a log burst.", + new AcceptableValueRange(2, 100), new object[0])); _logReports = Config.Bind("Diagnostics", "Log frame reports", true, "Write one compact SFS_FRAME report per interval."); _overlayKey = Config.Bind("Diagnostics", "Overlay key", - new KeyboardShortcut(KeyCode.F11), "Toggle the lightweight frame statistics overlay."); + new KeyboardShortcut(KeyCode.F11, new KeyCode[0]), + "Toggle the lightweight frame statistics overlay."); } private void ApplySettings() @@ -150,11 +181,21 @@ private void ApplySettings() Application.targetFrameRate = _targetFrameRate.Value; if (_vSyncCount.Value >= 0) QualitySettings.vSyncCount = _vSyncCount.Value; + else if (_autoVSyncDivisor.Value && _targetFrameRate.Value > 0 && + QualitySettings.vSyncCount > 0) + { + int refreshRate = Math.Max(1, Screen.currentResolution.refreshRate); + int divisor = Mathf.Clamp(Mathf.RoundToInt( + refreshRate / (float)_targetFrameRate.Value), 1, 4); + QualitySettings.vSyncCount = divisor; + } + _desiredTargetFrameRate = Application.targetFrameRate; + _desiredVSyncCount = QualitySettings.vSyncCount; Time.maximumDeltaTime = _maximumDeltaTime.Value; Time.maximumParticleDeltaTime = _maximumParticleDeltaTime.Value; if (_lowerBackgroundLoadingPriority.Value) - Application.backgroundLoadingPriority = ThreadPriority.Low; + Application.backgroundLoadingPriority = UnityEngine.ThreadPriority.Low; TrySetQualityProperty("asyncUploadTimeSlice", _asyncUploadTimeSlice.Value); TrySetQualityProperty("asyncUploadBufferSize", _asyncUploadBufferSize.Value); @@ -180,6 +221,7 @@ private void ApplySettings() _settingsApplied = true; Logger.LogInfo("Frame settings applied: target=" + Application.targetFrameRate + ", vSync=" + QualitySettings.vSyncCount + + ", refresh=" + Screen.currentResolution.refreshRate + ", maxDelta=" + Time.maximumDeltaTime.ToString("F3") + ", loading=" + Application.backgroundLoadingPriority + "."); } @@ -234,12 +276,41 @@ private void Update() if (delta <= 0f || delta > 5f) return; + if (_settingsApplied) + { + _pacingGuardElapsed += delta; + if (_pacingGuardElapsed >= 2f) + { + _pacingGuardElapsed = 0f; + bool drifted = false; + if (Application.targetFrameRate != _desiredTargetFrameRate) + { + Application.targetFrameRate = _desiredTargetFrameRate; + drifted = true; + } + if (QualitySettings.vSyncCount != _desiredVSyncCount) + { + QualitySettings.vSyncCount = _desiredVSyncCount; + drifted = true; + } + if (drifted) + _pacingDriftCount++; + } + } + _frameTimes[_frameWriteIndex] = delta; _frameWriteIndex = (_frameWriteIndex + 1) % FrameCapacity; if (_frameCount < FrameCapacity) _frameCount++; float frameMs = delta * 1000f; + _currentFrameHitch = frameMs >= _hitchThresholdMs.Value; + int frameLogEvents; + int frameWarningEvents; + string frameLogSource; + _frameLogListener.Consume(out frameLogEvents, out frameWarningEvents, + out frameLogSource); + _reportLogEventCount += frameLogEvents; int gc0 = GC.CollectionCount(0); int gc1 = GC.CollectionCount(1); int gc2 = GC.CollectionCount(2); @@ -253,8 +324,16 @@ private void Update() _hitchCount++; if (collected) _gcHitchCount++; - if (Time.realtimeSinceStartup <= _activityWindowUntil) + else if (Time.realtimeSinceStartup <= _activityWindowUntil) _activityHitchCount++; + else if (frameLogEvents >= _logBurstThreshold.Value || + frameWarningEvents >= 2) + { + _logHitchCount++; + _lastHitchLogSource = frameLogSource; + } + else + _unknownHitchCount++; if (frameMs > _worstFrameMs) _worstFrameMs = frameMs; } @@ -289,17 +368,43 @@ private void PublishReport() ? worstCount / worstOnePercentSum : 0d; float p95Ms = _sortBuffer[p95Index] * 1000f; + ProfilerMarkerStat topProfilerMarker = null; + double playerLoopHitchMs = 0d; + for (int i = 0; i < _profilerMarkers.Count; i++) + { + ProfilerMarkerStat marker = _profilerMarkers[i]; + if (string.Equals(marker.Name, "PlayerLoop", StringComparison.Ordinal)) + { + playerLoopHitchMs = marker.HitchMaxMs; + continue; + } + if (ReferenceEquals(topProfilerMarker, null) || + marker.HitchMaxMs > topProfilerMarker.HitchMaxMs) + topProfilerMarker = marker; + } + string topProfilerName = ReferenceEquals(topProfilerMarker, null) + ? "none" + : topProfilerMarker.Name; + double topProfilerMs = ReferenceEquals(topProfilerMarker, null) + ? 0d + : topProfilerMarker.HitchMaxMs; _overlayText = string.Format( - "SFS FPS {0:F1} | 1% low {1:F1} | P95 {2:F1} ms | hitches {3} | worst {4:F1} ms", - averageFps, onePercentLow, p95Ms, _hitchCount, _worstFrameMs); + "SFS FPS {0:F1} | 1% {1:F1} | P95 {2:F1} ms | hitch {3} | GC {4} | load {5} | log {6}", + averageFps, onePercentLow, p95Ms, _hitchCount, _gcHitchCount, + _activityHitchCount, _logHitchCount); if (_logReports.Value) { Logger.LogInfo(string.Format( - "SFS_FRAME fps={0:F1} low1={1:F1} p95_ms={2:F1} hitches={3} worst_ms={4:F1} gc_hitches={5} activity_hitches={6} mono_mb={7:F1}", + "SFS_FRAME fps={0:F1} low1={1:F1} p95_ms={2:F1} hitches={3} worst_ms={4:F1} gc_hitches={5} activity_hitches={6} log_hitches={7} unknown_hitches={8} log_events={9} last_log_source={10} profiler_top={11} profiler_top_ms={12:F1} playerloop_ms={13:F1} target={14} vsync={15} pacing_drifts={16} mono_mb={17:F1}", averageFps, onePercentLow, p95Ms, _hitchCount, _worstFrameMs, - _gcHitchCount, _activityHitchCount, GC.GetTotalMemory(false) / 1048576d)); + _gcHitchCount, _activityHitchCount, _logHitchCount, + _unknownHitchCount, _reportLogEventCount, _lastHitchLogSource, + topProfilerName, topProfilerMs, playerLoopHitchMs, + Application.targetFrameRate, QualitySettings.vSyncCount, + _pacingDriftCount, + GC.GetTotalMemory(false) / 1048576d)); } _reportElapsed = 0f; @@ -308,7 +413,47 @@ private void PublishReport() _hitchCount = 0; _gcHitchCount = 0; _activityHitchCount = 0; + _logHitchCount = 0; + _unknownHitchCount = 0; + _reportLogEventCount = 0; + _lastHitchLogSource = "none"; + _pacingDriftCount = 0; _worstFrameMs = 0f; + for (int i = 0; i < _profilerMarkers.Count; i++) + _profilerMarkers[i].ResetWindow(); + } + + private void InitializeProfilerRecorders() + { + string[] candidates = + { + "BehaviourUpdate", "LateBehaviourUpdate", "FixedBehaviourUpdate", + "Physics.Processing", "Physics.Simulate", "Animation.Update", + "Animator.Update", "MeshSkinning.Update", + "SkinnedMeshRenderer.UpdateSkinning", "Camera.Render", + "Render.OpaqueGeometry", "Render.TransparentGeometry", "GC.Collect", + "WaitForTargetFPS", "Gfx.WaitForPresent", "PlayerLoop" + }; + for (int i = 0; i < candidates.Length; i++) + { + Recorder recorder = Recorder.Get(candidates[i]); + if (!recorder.isValid) + continue; + recorder.enabled = true; + _profilerMarkers.Add(new ProfilerMarkerStat(candidates[i], recorder)); + } + Logger.LogInfo("Enabled " + _profilerMarkers.Count + + " built-in Unity profiler recorders for hitch attribution."); + } + + private IEnumerator CaptureProfilerFrames() + { + while (true) + { + yield return _endOfFrame; + for (int i = 0; i < _profilerMarkers.Count; i++) + _profilerMarkers[i].Capture(_currentFrameHitch); + } } private void OnGUI() @@ -347,7 +492,8 @@ private void TrySetQualityProperty(string propertyName, int value) { PropertyInfo property = typeof(QualitySettings).GetProperty(propertyName, BindingFlags.Public | BindingFlags.Static); - if (property != null && property.CanWrite && property.PropertyType == typeof(int)) + if (!ReferenceEquals(property, null) && property.CanWrite && + ReferenceEquals(property.PropertyType, typeof(int))) property.SetValue(null, value, null); } catch (Exception ex) @@ -359,8 +505,14 @@ private void TrySetQualityProperty(string propertyName, int value) private void OnDestroy() { SceneManager.sceneLoaded -= OnSceneLoaded; + BepInEx.Logging.Logger.Listeners.Remove(_frameLogListener); + _frameLogListener.Dispose(); if (_harmony != null) _harmony.UnpatchSelf(); + if (!ReferenceEquals(_profilerCoroutine, null)) + StopCoroutine(_profilerCoroutine); + for (int i = 0; i < _profilerMarkers.Count; i++) + _profilerMarkers[i].Recorder.enabled = false; RestoreSettings(); _instance = null; } @@ -394,4 +546,62 @@ private static void Prefix() MarkActivity(12f); } } + + private sealed class FrameLogListener : ILogListener + { + private int _eventCount; + private int _warningCount; + private string _lastSource = "none"; + + public void LogEvent(object sender, LogEventArgs eventArgs) + { + Interlocked.Increment(ref _eventCount); + if ((eventArgs.Level & (LogLevel.Fatal | LogLevel.Error | LogLevel.Warning)) != 0) + Interlocked.Increment(ref _warningCount); + if (!ReferenceEquals(eventArgs.Source, null)) + _lastSource = eventArgs.Source.SourceName; + } + + internal void Consume(out int events, out int warnings, out string source) + { + events = Interlocked.Exchange(ref _eventCount, 0); + warnings = Interlocked.Exchange(ref _warningCount, 0); + source = _lastSource; + } + + public void Dispose() + { + } + } + + private sealed class ProfilerMarkerStat + { + internal readonly string Name; + internal readonly Recorder Recorder; + internal double HitchMaxMs; + internal double HitchSumMs; + + internal ProfilerMarkerStat(string name, Recorder recorder) + { + Name = name; + Recorder = recorder; + } + + internal void Capture(bool hitch) + { + if (!hitch) + return; + double elapsedMs = Recorder.elapsedNanoseconds / 1000000d; + HitchSumMs += elapsedMs; + if (elapsedMs > HitchMaxMs) + HitchMaxMs = elapsedMs; + } + + internal void ResetWindow() + { + HitchMaxMs = 0d; + HitchSumMs = 0d; + } + } +} } diff --git a/tools/Build-StudioFrameStabilizer.ps1 b/tools/Build-StudioFrameStabilizer.ps1 index 90bd621..0122108 100644 --- a/tools/Build-StudioFrameStabilizer.ps1 +++ b/tools/Build-StudioFrameStabilizer.ps1 @@ -1,6 +1,6 @@ param( [string]$GameRoot = 'Z:\Koikatu', - [string]$Version = '0.1.0', + [string]$Version = '0.3.1', [switch]$Force ) @@ -33,4 +33,3 @@ $hash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant Set-Content -LiteralPath "$zip.sha256" -Value "$hash $([IO.Path]::GetFileName($zip))" -Encoding ASCII Write-Host "Archive: $zip" Write-Host "SHA256: $hash" -