SHA256
Compare commits
42
Commits
25b58eb52a
..
main
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
5efe1d0dd2
|
||
|
|
71e86c0262
|
||
|
|
f72a8acbac | ||
|
|
d90d06c095 | ||
|
|
b66d774cb9 | ||
|
|
d1ba5d5c18 | ||
|
|
7c6210d2c4 | ||
|
|
9e3941e3b5
|
||
|
|
78ea42b368
|
||
|
|
064bd816e8
|
||
|
|
46fc00df9c
|
||
|
|
769f368f66
|
||
|
|
ed6f990d3b
|
||
|
|
151c91bd86 | ||
|
|
961f78a332 | ||
|
|
0fc01a84de | ||
|
|
a389283d33 | ||
|
|
d2bd655118 | ||
|
|
8deb64fd4d | ||
|
|
354c42fdd9 | ||
|
|
19d0e258ac | ||
|
|
7bb47e4974 | ||
|
|
fab189ff3b | ||
|
|
abb6f729e4 | ||
|
|
c56175bbc8 | ||
|
|
ab8164a735 | ||
|
|
69d098e04c | ||
|
|
8e0639aafa | ||
|
|
1fdee67f9d | ||
|
|
a4dba82729 | ||
|
|
5136127e39 | ||
|
|
455436e744 | ||
|
|
89f15eca1e | ||
|
|
d0f6fa9ecd | ||
|
|
5055e4dfbb | ||
|
|
cb0fd1bd96 | ||
|
|
3d2ffe9dfe | ||
|
|
a21d86d3a6 | ||
|
|
7ea2d8fe3f | ||
|
|
35606d6e58 | ||
|
|
82776fc572 | ||
|
|
0a12c12ff5 |
No files matched your search
@@ -180,7 +180,7 @@ public class RenderSession
|
||||
/// Sets the custom condition function that determines when the render session should end.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The condition is polled on the main thread once per frame from the start of the countdown until the render ends.
|
||||
/// The condition is polled on the main Unity thread once per frame from the start of the countdown until the render ends.
|
||||
/// When it first returns <c>true</c>, the render continues for <see cref="RenderConfig.DelayAfterEnd"/> seconds of
|
||||
/// render time and then finishes successfully; it is not required to stay <c>true</c>. The default condition is
|
||||
/// that the level is cleared (<c>scrController.instance.state == States.Won</c>). Calling this method again
|
||||
@@ -231,7 +231,7 @@ public class RenderSession
|
||||
/// Adds a callback action to be invoked when the render session completes or fails.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called once per render on the main thread: on success after the output file has been finished, on cancel or
|
||||
/// Called once per render on the main Unity thread: on success after the output file has been finished, on cancel or
|
||||
/// failure after the unfinished file has been deleted, and before the game returns to the level editor.
|
||||
/// It is not called when the render stops because the game quits. Multiple calls combine the callbacks (invoked in
|
||||
/// the order added). The combined callback replaces the default one, which shows the desktop notification.
|
||||
@@ -249,7 +249,7 @@ public class RenderSession
|
||||
/// Adds a callback action to be invoked on the last update of each frame in the render session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called on the main thread every Unity frame after all other <c>LateUpdate</c> calls, from the moment the game scene
|
||||
/// Called on the main Unity thread every Unity frame after all other <c>LateUpdate</c> calls, from the moment the game scene
|
||||
/// of the render has loaded until the render ends (including the countdown, before capture starts).
|
||||
/// Multiple calls combine the callbacks. An exception thrown by a callback is logged.
|
||||
/// </remarks>
|
||||
|
||||
@@ -17,6 +17,11 @@ internal class GlobalConfig
|
||||
/// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
|
||||
/// </summary>
|
||||
public string? OutputDirectory { get; set; }
|
||||
/// <summary>
|
||||
/// File name format template chosen in the render configuration window; null or empty selects
|
||||
/// GlobalSettings.DefaultFileNameFormat ("{date} {time}").
|
||||
/// </summary>
|
||||
public string? FileNameFormat { get; set; }
|
||||
/// <summary>Name of the preset last selected or saved in the render configuration window; selected again when it opens.</summary>
|
||||
public string? LastPresetName { get; set; }
|
||||
/// <summary>
|
||||
@@ -72,6 +77,18 @@ internal static class GlobalSettings
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default file name format template: "{date} {time}".</summary>
|
||||
public const string DefaultFileNameFormat = "{date} {time}";
|
||||
|
||||
/// <summary>
|
||||
/// File name format used for renders without an explicit output path: Current.FileNameFormat when it is set,
|
||||
/// otherwise <see cref="DefaultFileNameFormat"/>.
|
||||
/// </summary>
|
||||
public static string EffectiveFileNameFormat =>
|
||||
string.IsNullOrWhiteSpace(Current.FileNameFormat)
|
||||
? DefaultFileNameFormat
|
||||
: Current.FileNameFormat;
|
||||
|
||||
static GlobalSettings()
|
||||
{
|
||||
Load();
|
||||
|
||||
@@ -39,7 +39,7 @@ public class RenderPreset
|
||||
public static class PresetManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 fps video.
|
||||
/// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 FPS video.
|
||||
/// "(4x)" presets render 240 game frames per second (4 per video frame); "(1x)" renders 60.
|
||||
/// Video bitrates are in kbps.
|
||||
/// </summary>
|
||||
|
||||
+36
-17
@@ -92,7 +92,7 @@ internal static class RenderContext
|
||||
}
|
||||
};
|
||||
/// <summary>
|
||||
/// Called once on the main thread when the render finishes (success) or is cancelled or fails.
|
||||
/// Called once on the main Unity thread when the render finishes (success) or is cancelled or fails.
|
||||
/// A session callback replaces the default one, so no notification is shown when a session sets OnComplete.
|
||||
/// Exceptions are caught and logged (RenderManager.TriggerOnComplete).
|
||||
/// </summary>
|
||||
@@ -101,7 +101,7 @@ internal static class RenderContext
|
||||
private static readonly Action OnLastUpdateDefault = () => { };
|
||||
|
||||
/// <summary>
|
||||
/// Called on the main thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the
|
||||
/// Called on the main Unity thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the
|
||||
/// game scene of the render has loaded) while the stage is Load or later.
|
||||
/// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate).
|
||||
/// </summary>
|
||||
@@ -154,7 +154,7 @@ internal class RenderManager : MonoBehaviour
|
||||
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of frames in ImageQueue. The main thread waits (throttles) while the queue holds more frames.
|
||||
/// Maximum number of frames in ImageQueue. The main Unity thread waits (throttles) while the queue holds more frames.
|
||||
/// </summary>
|
||||
public const int MaxQueuedFrames = 64;
|
||||
|
||||
@@ -179,7 +179,7 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
/// <summary>
|
||||
/// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked),
|
||||
/// read with Interlocked.Read by the main thread (result, debug monitor). Reset in SetupPlay.
|
||||
/// read with Interlocked.Read by the main Unity thread (result, debug monitor). Reset in SetupPlay.
|
||||
/// </summary>
|
||||
public long framesSent;
|
||||
|
||||
@@ -276,14 +276,14 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
/// <summary>
|
||||
/// Configuration of the current render: a copy made by RenderSession.Start, so values adjusted during the render
|
||||
/// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main thread, the render
|
||||
/// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main Unity thread, the render
|
||||
/// thread (audio settings) and the patches. Null until the first render starts.
|
||||
/// </summary>
|
||||
public RenderConfig Configuration = null!;
|
||||
|
||||
// Render Sessions State (OOP Instance Fields)
|
||||
|
||||
/// <summary>Current stage of the render. Written on the main thread; read by the render thread in TryDequeueNextFrame.</summary>
|
||||
/// <summary>Current stage of the render. Written on the main Unity thread; read by the render thread in TryDequeueNextFrame.</summary>
|
||||
public RenderStage stage = RenderStage.None;
|
||||
|
||||
/// <summary>Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.</summary>
|
||||
@@ -303,7 +303,7 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
/// <summary>
|
||||
/// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs.
|
||||
/// Created in SetupPlay. Commands are added on the main thread (OnPostFrame, audio patches), mixing runs on the
|
||||
/// Created in SetupPlay. Commands are added on the main Unity thread (OnPostFrame, audio patches), mixing runs on the
|
||||
/// render thread (WriteAudioForFrame); the mixer synchronizes the two internally.
|
||||
/// </summary>
|
||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||
@@ -392,10 +392,10 @@ internal class RenderManager : MonoBehaviour
|
||||
public long lastVideoFrameIndex = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Video frame index of the last capture request (-1 before the first). Written on the main thread, read by the
|
||||
/// Video frame index of the last capture request (-1 before the first). Written on the main Unity thread, read by the
|
||||
/// render thread to know when every requested frame has been sent after endRequested.
|
||||
/// </summary>
|
||||
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||||
// long cannot be volatile: access via Volatile.Read/Write (written on main Unity thread, read on render thread)
|
||||
public long lastRequestedVideoFrameIndex = -1;
|
||||
|
||||
/// <summary>
|
||||
@@ -404,10 +404,10 @@ internal class RenderManager : MonoBehaviour
|
||||
/// </summary>
|
||||
public int readbackFailureCount;
|
||||
|
||||
/// <summary>Number of frames on which the main thread had to wait for the render thread (throttle loop entries).</summary>
|
||||
/// <summary>Number of frames on which the main Unity thread had to wait for the render thread (throttle loop entries).</summary>
|
||||
public int throttleCount;
|
||||
|
||||
/// <summary>True while the main thread is inside the throttle wait of OnPostFrame.</summary>
|
||||
/// <summary>True while the main Unity thread is inside the throttle wait of OnPostFrame.</summary>
|
||||
public bool isThrottling;
|
||||
|
||||
/// <summary>
|
||||
@@ -438,7 +438,7 @@ internal class RenderManager : MonoBehaviour
|
||||
/// <summary>
|
||||
/// Captured frames waiting for the render thread, keyed by video frame index. Readbacks may complete in any order;
|
||||
/// the render thread takes them in index order (passCount). Every access locks the list itself.
|
||||
/// Frames are added by readback completions (main thread) and removed by the render thread, or released by the
|
||||
/// Frames are added by readback completions (main Unity thread) and removed by the render thread, or released by the
|
||||
/// cleanup in StopFFmpeg / RestoreRenderEnvironment.
|
||||
/// </summary>
|
||||
internal SortedList<long, CapturedFrame> ImageQueue = new();
|
||||
@@ -788,7 +788,7 @@ internal class RenderManager : MonoBehaviour
|
||||
if (!texture) return;
|
||||
|
||||
// [Backpressure Throttling]
|
||||
// The main thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
|
||||
// The main Unity thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
|
||||
// removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop
|
||||
// re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds).
|
||||
isThrottling = false;
|
||||
@@ -940,6 +940,14 @@ internal class RenderManager : MonoBehaviour
|
||||
// Cancelled or failed during the countdown: do not start the encoder
|
||||
if (stage != RenderStage.OnScene) yield break;
|
||||
|
||||
if (Configuration.AdvancedConfig.DisableTileCulling && scrLevelMaker.instance)
|
||||
{
|
||||
foreach (var floor in scrLevelMaker.instance.listFloors)
|
||||
{
|
||||
if (floor) floor.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
frameCount = 0; framesSent = 0;
|
||||
_renderStopwatch.Restart();
|
||||
|
||||
@@ -1045,9 +1053,20 @@ internal class RenderManager : MonoBehaviour
|
||||
var startTime = DateTime.Now;
|
||||
if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath;
|
||||
else {
|
||||
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") +
|
||||
$".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}";
|
||||
CurrentOutPath = Path.Combine(GlobalSettings.EffectiveOutputDirectory, fileName);
|
||||
var format = GlobalSettings.EffectiveFileNameFormat;
|
||||
var ext = Configuration.AdvancedConfig.FileExtension;
|
||||
var baseName = RenderFileNameUtil.FormatFileName(
|
||||
format,
|
||||
startTime,
|
||||
Configuration,
|
||||
GlobalSettings.Current.LastPresetName,
|
||||
levelPath
|
||||
);
|
||||
CurrentOutPath = RenderFileNameUtil.GetUniqueFilePath(
|
||||
GlobalSettings.EffectiveOutputDirectory,
|
||||
baseName,
|
||||
ext
|
||||
);
|
||||
}
|
||||
|
||||
FFmpegNativeEncoder? videoFFmpeg = null;
|
||||
@@ -1249,7 +1268,7 @@ internal class RenderManager : MonoBehaviour
|
||||
/// <summary>
|
||||
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
|
||||
/// The sample count per frame comes from SyncCalculator (sample rate / effective video FPS, with the remainder
|
||||
/// carried to later frames). Mixing waits until the main thread has committed the audio commands up to that time.
|
||||
/// carried to later frames). Mixing waits until the main Unity thread has committed the audio commands up to that time.
|
||||
/// Does nothing when audio rendering is off. Render thread.
|
||||
/// </summary>
|
||||
/// <exception cref="OperationCanceledException">The mixer was cancelled (render aborted) while waiting.</exception>
|
||||
|
||||
+272
-242
@@ -1,242 +1,272 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Settings of one render: resolution, frame rates, encoding and advanced options. Serialized to JSON in the preset
|
||||
/// files (PresetManager). <see cref="ADOFAIRenderer.API.RenderSession.Start"/> renders with a copy (<see cref="Clone"/>).
|
||||
/// </summary>
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
public class RenderConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame).
|
||||
/// Values above <see cref="VideoFPS"/> render extra frames between video frames, which are not captured
|
||||
/// (the game state then advances in smaller time steps).
|
||||
/// </summary>
|
||||
public double RenderFPS { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Frame rate of the output video. The effective rate is min(VideoFPS, RenderFPS).
|
||||
/// </summary>
|
||||
public double VideoFPS { get; set; }
|
||||
|
||||
/// <summary>Width of the rendered frames and the output video in pixels (also reported by the Screen.width patch).</summary>
|
||||
public int RenderWidth { get; set; }
|
||||
|
||||
/// <summary>Height of the rendered frames and the output video in pixels (also reported by the Screen.height patch).</summary>
|
||||
public int RenderHeight { get; set; }
|
||||
|
||||
/// <summary>Video encoder settings.</summary>
|
||||
public VideoEncodingConfig VideoConfig { get; set; } = new();
|
||||
|
||||
/// <summary>Audio rendering and encoder settings.</summary>
|
||||
public AudioEncodingConfig AudioConfig { get; set; } = new();
|
||||
|
||||
/// <summary>Advanced options (random seed, spectrum processing, tile hiding, container, planet colors).</summary>
|
||||
public AdvancedConfig AdvancedConfig { get; set; } = new();
|
||||
|
||||
/// <summary>Seconds of render time recorded after the end condition first holds (default 15).</summary>
|
||||
public double DelayAfterEnd { get; set; } = 15;
|
||||
|
||||
/// <summary>Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false.</summary>
|
||||
public bool ShowJudgementText { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Copies every value of <paramref name="source"/> into this object; the nested configs are copied value by value
|
||||
/// into the existing nested objects. Does nothing when <paramref name="source"/> is null.
|
||||
/// </summary>
|
||||
public void CopyFrom(RenderConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RenderFPS = source.RenderFPS;
|
||||
VideoFPS = source.VideoFPS;
|
||||
RenderWidth = source.RenderWidth;
|
||||
RenderHeight = source.RenderHeight;
|
||||
DelayAfterEnd = source.DelayAfterEnd;
|
||||
ShowJudgementText = source.ShowJudgementText;
|
||||
|
||||
VideoConfig.CopyFrom(source.VideoConfig);
|
||||
AudioConfig.CopyFrom(source.AudioConfig);
|
||||
AdvancedConfig.CopyFrom(source.AdvancedConfig);
|
||||
}
|
||||
|
||||
/// <summary>Returns a deep copy (nested configs are new objects).</summary>
|
||||
public RenderConfig Clone()
|
||||
{
|
||||
var clone = new RenderConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Advanced render options.</summary>
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
public class AdvancedConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Seed for UnityEngine.Random set before the game scene loads, so random visual effects repeat between renders;
|
||||
/// null leaves the random state unchanged.
|
||||
/// </summary>
|
||||
public int? RandomSeed { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// When true, AudioSource.GetSpectrumData is answered from the offline song audio at the render time
|
||||
/// (AudioSpectrumGenerator), so audio-reactive visuals follow the rendered audio. Default true.
|
||||
/// </summary>
|
||||
public bool ProcessAudio { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the level's floor tiles (including hold meshes and the portal particles of the level's floors) and the
|
||||
/// planets are hidden during the render, so only the background and decorations remain (scnGamePatch, scrFloorPatch,
|
||||
/// RenderManager.OnLastUpdate). Default false.
|
||||
/// </summary>
|
||||
public bool NonTile { get; set; } = false;
|
||||
|
||||
/// <summary>File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.</summary>
|
||||
public string FileExtension { get; set; } = "mkv";
|
||||
|
||||
/// <summary>Color of the fire (red) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? firePlanetColor = null;
|
||||
|
||||
/// <summary>Color of the ice (blue) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? icePlanetColor = null;
|
||||
|
||||
/// <summary>
|
||||
/// Color of the third (wind, planetGreen) planet during the render (PlanetarySystemPatch); null keeps the game's color.
|
||||
/// Serialized as "#RRGGBBAA".
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? windPlanetColor = null;
|
||||
|
||||
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||
public void CopyFrom(AdvancedConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RandomSeed = source.RandomSeed;
|
||||
ProcessAudio = source.ProcessAudio;
|
||||
NonTile = source.NonTile;
|
||||
FileExtension = source.FileExtension;
|
||||
firePlanetColor = source.firePlanetColor;
|
||||
icePlanetColor = source.icePlanetColor;
|
||||
windPlanetColor = source.windPlanetColor;
|
||||
}
|
||||
|
||||
/// <summary>Returns a copy of this object.</summary>
|
||||
public AdvancedConfig Clone()
|
||||
{
|
||||
var clone = new AdvancedConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Video encoder settings (passed to FFmpegNativeEncoder.EncoderSettings).</summary>
|
||||
public class VideoEncodingConfig
|
||||
{
|
||||
/// <summary>Video codec (default H.264). Also selects the audio codec (CodecUtil.AudioCodecMatch).</summary>
|
||||
public Codec Codec { get; set; } = Codec.H264;
|
||||
|
||||
/// <summary>Bitrate control mode (default CBR).</summary>
|
||||
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
|
||||
|
||||
/// <summary>Target video bitrate in kbps for CBR and VBR (default 30000 = 30 Mbps).</summary>
|
||||
public int Bitrate { get; set; } = 30000;
|
||||
|
||||
/// <summary>Peak video bitrate in kbps for VBR, used when larger than Bitrate (otherwise 1.5 × Bitrate). Default 50000.</summary>
|
||||
public int MaxBitrate { get; set; } = 50000;
|
||||
|
||||
/// <summary>
|
||||
/// Quality value for CQP: lower is higher quality. Clamped to the range of the selected encoder; a negative value
|
||||
/// selects 18. Default 18.
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public int QP { get; set; } = 18;
|
||||
|
||||
/// <summary>Keyframe interval in seconds (GOP length = round(effective video FPS × interval) frames, at least 1). Default 2.</summary>
|
||||
public double KeyframeInterval { get; set; } = 2.0;
|
||||
|
||||
/// <summary>When true, only software (CPU) encoders are tried; hardware encoders are skipped.</summary>
|
||||
public bool ForceSoftwareEncoder { get; set; }
|
||||
|
||||
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||
public void CopyFrom(VideoEncodingConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
Codec = source.Codec;
|
||||
RateControlMode = source.RateControlMode;
|
||||
Bitrate = source.Bitrate;
|
||||
MaxBitrate = source.MaxBitrate;
|
||||
QP = source.QP;
|
||||
KeyframeInterval = source.KeyframeInterval;
|
||||
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
|
||||
}
|
||||
|
||||
/// <summary>Returns a copy of this object.</summary>
|
||||
public VideoEncodingConfig Clone()
|
||||
{
|
||||
var clone = new VideoEncodingConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audio rendering and encoder settings. Non-positive values of the numeric settings read back as their defaults
|
||||
/// (48000 Hz, 2 channels, 256000 bps).
|
||||
/// </summary>
|
||||
public class AudioEncodingConfig
|
||||
{
|
||||
private int _audioSampleRate = 48000;
|
||||
private int _audioChannels = 2;
|
||||
private int _audioBitrate = 256000;
|
||||
|
||||
/// <summary>When true, the song and sound effects are mixed offline and encoded as an audio stream. Default true.</summary>
|
||||
public bool RenderAudio { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Output sample rate in Hz (default 48000). A rate the audio codec does not support in the mod
|
||||
/// (CodecUtil.AvailableSampleRate) is replaced with 48000 when the render starts.
|
||||
/// </summary>
|
||||
public int AudioSampleRate
|
||||
{
|
||||
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
|
||||
set => _audioSampleRate = value <= 0 ? 48000 : value;
|
||||
}
|
||||
|
||||
/// <summary>Number of output audio channels (default 2).</summary>
|
||||
public int AudioChannels
|
||||
{
|
||||
get => _audioChannels <= 0 ? 2 : _audioChannels;
|
||||
set => _audioChannels = value <= 0 ? 2 : value;
|
||||
}
|
||||
|
||||
/// <summary>Audio bitrate in bits per second (default 256000 = 256 kbps). Unit differs from the video bitrate (kbps).</summary>
|
||||
public int AudioBitrate
|
||||
{
|
||||
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
|
||||
set => _audioBitrate = value <= 0 ? 256000 : value;
|
||||
}
|
||||
|
||||
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||
public void CopyFrom(AudioEncodingConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RenderAudio = source.RenderAudio;
|
||||
AudioSampleRate = source.AudioSampleRate;
|
||||
AudioChannels = source.AudioChannels;
|
||||
AudioBitrate = source.AudioBitrate;
|
||||
}
|
||||
|
||||
/// <summary>Returns a copy of this object.</summary>
|
||||
public AudioEncodingConfig Clone()
|
||||
{
|
||||
var clone = new AudioEncodingConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}␍
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Settings of one render: resolution, frame rates, encoding and advanced options. Serialized to JSON in the preset
|
||||
/// files (PresetManager). <see cref="ADOFAIRenderer.API.RenderSession.Start"/> renders with a copy (<see cref="Clone"/>).
|
||||
/// </summary>
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
public class RenderConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame).
|
||||
/// Values above <see cref="VideoFPS"/> render extra frames between video frames, which are not captured
|
||||
/// (the game state then advances in smaller time steps).
|
||||
/// </summary>
|
||||
public double RenderFPS { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Frame rate of the output video. The effective rate is min(VideoFPS, RenderFPS).
|
||||
/// </summary>
|
||||
public double VideoFPS { get; set; }
|
||||
|
||||
/// <summary>Width of the rendered frames and the output video in pixels (also reported by the Screen.width patch).</summary>
|
||||
public int RenderWidth { get; set; }
|
||||
|
||||
/// <summary>Height of the rendered frames and the output video in pixels (also reported by the Screen.height patch).</summary>
|
||||
public int RenderHeight { get; set; }
|
||||
|
||||
/// <summary>Video encoder settings.</summary>
|
||||
public VideoEncodingConfig VideoConfig { get; set; } = new();
|
||||
|
||||
/// <summary>Audio rendering and encoder settings.</summary>
|
||||
public AudioEncodingConfig AudioConfig { get; set; } = new();
|
||||
|
||||
/// <summary>Advanced options (random seed, spectrum processing, tile hiding, container, planet colors).</summary>
|
||||
public AdvancedConfig AdvancedConfig { get; set; } = new();
|
||||
|
||||
/// <summary>Seconds of render time recorded after the end condition first holds (default 15).</summary>
|
||||
public double DelayAfterEnd { get; set; } = 15;
|
||||
|
||||
/// <summary>Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false.</summary>
|
||||
public bool ShowJudgementText { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Copies every value of <paramref name="source"/> into this object; the nested configs are copied value by value
|
||||
/// into the existing nested objects. Does nothing when <paramref name="source"/> is null.
|
||||
/// </summary>
|
||||
public void CopyFrom(RenderConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RenderFPS = source.RenderFPS;
|
||||
VideoFPS = source.VideoFPS;
|
||||
RenderWidth = source.RenderWidth;
|
||||
RenderHeight = source.RenderHeight;
|
||||
DelayAfterEnd = source.DelayAfterEnd;
|
||||
ShowJudgementText = source.ShowJudgementText;
|
||||
|
||||
VideoConfig.CopyFrom(source.VideoConfig);
|
||||
AudioConfig.CopyFrom(source.AudioConfig);
|
||||
AdvancedConfig.CopyFrom(source.AdvancedConfig);
|
||||
}
|
||||
|
||||
/// <summary>Returns a deep copy (nested configs are new objects).</summary>
|
||||
public RenderConfig Clone()
|
||||
{
|
||||
var clone = new RenderConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Advanced render options.</summary>
|
||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||
public class AdvancedConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Seed for UnityEngine.Random set before the game scene loads, so random visual effects repeat between renders;
|
||||
/// null leaves the random state unchanged.
|
||||
/// </summary>
|
||||
public int? RandomSeed { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// When true, AudioSource.GetSpectrumData is answered from the offline song audio at the render time
|
||||
/// (AudioSpectrumGenerator), so audio-reactive visuals follow the rendered audio. Default true.
|
||||
/// </summary>
|
||||
public bool ProcessAudio { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the level's floor tiles (including hold meshes and the portal particles of the level's floors) and the
|
||||
/// planets are hidden during the render, so only the background and decorations remain (scnGamePatch, scrFloorPatch,
|
||||
/// RenderManager.OnLastUpdate). Default false.
|
||||
/// </summary>
|
||||
public bool NonTile { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the game's bloom (<c>VideoBloom</c>) computes its blur size as if the frame were 720 pixels high, so the
|
||||
/// blur passes on the 198-pixel-high bloom buffer are the same as in a 720p frame at any render height
|
||||
/// (VideoBloomPatch). Default false.
|
||||
/// </summary>
|
||||
public bool HighResBloomCorrection { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When true, fixes in-game and render bugs during SetFramerate events (where objects disappear on the first frame
|
||||
/// or parallax/tiles lag behind) by deferring manual camera rendering to the end of the frame after all LateUpdates.
|
||||
/// Default true.
|
||||
/// </summary>
|
||||
public bool FixCustomFrameRate { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, prevents floor tiles from being disabled when outside the camera frustum (scrFloor.OnBecameInvisible),
|
||||
/// eliminating the 1-frame update lag/glitch when tiles enter the screen. Default false.
|
||||
/// </summary>
|
||||
public bool DisableTileCulling { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When true, hides placeholder decorations (default tree-in-circle sprite) that failed to load or are empty.
|
||||
/// Default false.
|
||||
/// </summary>
|
||||
public bool HideFailedDecorations { get; set; } = false;
|
||||
|
||||
/// <summary>File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.</summary>
|
||||
public string FileExtension { get; set; } = "mkv";
|
||||
|
||||
/// <summary>Color of the fire (red) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? firePlanetColor = null;
|
||||
|
||||
/// <summary>Color of the ice (blue) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? icePlanetColor = null;
|
||||
|
||||
/// <summary>
|
||||
/// Color of the third (wind, planetGreen) planet during the render (PlanetarySystemPatch); null keeps the game's color.
|
||||
/// Serialized as "#RRGGBBAA".
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(ColorJsonConverter))]
|
||||
public Color? windPlanetColor = null;
|
||||
|
||||
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||
public void CopyFrom(AdvancedConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RandomSeed = source.RandomSeed;
|
||||
ProcessAudio = source.ProcessAudio;
|
||||
NonTile = source.NonTile;
|
||||
HighResBloomCorrection = source.HighResBloomCorrection;
|
||||
FixCustomFrameRate = source.FixCustomFrameRate;
|
||||
DisableTileCulling = source.DisableTileCulling;
|
||||
HideFailedDecorations = source.HideFailedDecorations;
|
||||
FileExtension = source.FileExtension;
|
||||
firePlanetColor = source.firePlanetColor;
|
||||
icePlanetColor = source.icePlanetColor;
|
||||
windPlanetColor = source.windPlanetColor;
|
||||
}
|
||||
|
||||
/// <summary>Returns a copy of this object.</summary>
|
||||
public AdvancedConfig Clone()
|
||||
{
|
||||
var clone = new AdvancedConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Video encoder settings (passed to FFmpegNativeEncoder.EncoderSettings).</summary>
|
||||
public class VideoEncodingConfig
|
||||
{
|
||||
/// <summary>Video codec (default H.264). Also selects the audio codec (CodecUtil.AudioCodecMatch).</summary>
|
||||
public Codec Codec { get; set; } = Codec.H264;
|
||||
|
||||
/// <summary>Bitrate control mode (default CBR).</summary>
|
||||
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
|
||||
|
||||
/// <summary>Target video bitrate in kbps for CBR and VBR (default 30000 = 30 Mbps).</summary>
|
||||
public int Bitrate { get; set; } = 30000;
|
||||
|
||||
/// <summary>Peak video bitrate in kbps for VBR, used when larger than Bitrate (otherwise 1.5 × Bitrate). Default 50000.</summary>
|
||||
public int MaxBitrate { get; set; } = 50000;
|
||||
|
||||
/// <summary>
|
||||
/// Quality value for CQP: lower is higher quality. Clamped to the range of the selected encoder; a negative value
|
||||
/// selects 18. Default 18.
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public int QP { get; set; } = 18;
|
||||
|
||||
/// <summary>Keyframe interval in seconds (GOP length = round(effective video FPS × interval) frames, at least 1). Default 2.</summary>
|
||||
public double KeyframeInterval { get; set; } = 2.0;
|
||||
|
||||
/// <summary>When true, only software (CPU) encoders are tried; hardware encoders are skipped.</summary>
|
||||
public bool ForceSoftwareEncoder { get; set; }
|
||||
|
||||
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||
public void CopyFrom(VideoEncodingConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
Codec = source.Codec;
|
||||
RateControlMode = source.RateControlMode;
|
||||
Bitrate = source.Bitrate;
|
||||
MaxBitrate = source.MaxBitrate;
|
||||
QP = source.QP;
|
||||
KeyframeInterval = source.KeyframeInterval;
|
||||
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
|
||||
}
|
||||
|
||||
/// <summary>Returns a copy of this object.</summary>
|
||||
public VideoEncodingConfig Clone()
|
||||
{
|
||||
var clone = new VideoEncodingConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audio rendering and encoder settings. Non-positive values of the numeric settings read back as their defaults
|
||||
/// (48000 Hz, 2 channels, 256000 bps).
|
||||
/// </summary>
|
||||
public class AudioEncodingConfig
|
||||
{
|
||||
private int _audioSampleRate = 48000;
|
||||
private int _audioChannels = 2;
|
||||
private int _audioBitrate = 256000;
|
||||
|
||||
/// <summary>When true, the song and sound effects are mixed offline and encoded as an audio stream. Default true.</summary>
|
||||
public bool RenderAudio { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Output sample rate in Hz (default 48000). A rate the audio codec does not support in the mod
|
||||
/// (CodecUtil.AvailableSampleRate) is replaced with 48000 when the render starts.
|
||||
/// </summary>
|
||||
public int AudioSampleRate
|
||||
{
|
||||
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
|
||||
set => _audioSampleRate = value <= 0 ? 48000 : value;
|
||||
}
|
||||
|
||||
/// <summary>Number of output audio channels (default 2).</summary>
|
||||
public int AudioChannels
|
||||
{
|
||||
get => _audioChannels <= 0 ? 2 : _audioChannels;
|
||||
set => _audioChannels = value <= 0 ? 2 : value;
|
||||
}
|
||||
|
||||
/// <summary>Audio bitrate in bits per second (default 256000 = 256 kbps). Unit differs from the video bitrate (kbps).</summary>
|
||||
public int AudioBitrate
|
||||
{
|
||||
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
|
||||
set => _audioBitrate = value <= 0 ? 256000 : value;
|
||||
}
|
||||
|
||||
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||
public void CopyFrom(AudioEncodingConfig? source)
|
||||
{
|
||||
if (source == null) return;
|
||||
RenderAudio = source.RenderAudio;
|
||||
AudioSampleRate = source.AudioSampleRate;
|
||||
AudioChannels = source.AudioChannels;
|
||||
AudioBitrate = source.AudioBitrate;
|
||||
}
|
||||
|
||||
/// <summary>Returns a copy of this object.</summary>
|
||||
public AudioEncodingConfig Clone()
|
||||
{
|
||||
var clone = new AudioEncodingConfig();
|
||||
clone.CopyFrom(this);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,16 @@
|
||||
"ui.render_config.output_dir": "Output Directory",
|
||||
"ui.render_config.browse": "Browse...",
|
||||
"ui.render_config.reset_default": "Reset to Default",
|
||||
"ui.render_config.filename_format": "Filename Format",
|
||||
"ui.render_config.filename_format_hint": "Tags: {song}, {artist}, {author}, {date}, {time}, {resolution}, {fps}, {preset}",
|
||||
"ui.render_config.reset_filename_default": "Reset to Default",
|
||||
"ui.render_config.file_extension": "File Extension",
|
||||
"ui.render_config.process_audio": "Process Audio",
|
||||
"ui.render_config.non_tile": "Non-Tile Mode",
|
||||
"ui.render_config.high_res_bloom_correction": "High-Resolution Bloom Correction",
|
||||
"ui.render_config.fix_custom_frame_rate": "Fix Custom FPS Render Timing",
|
||||
"ui.render_config.disable_tile_culling": "Disable Tile Culling",
|
||||
"ui.render_config.hide_failed_decorations": "Hide Failed Decorations",
|
||||
"ui.render_config.random_seed": "Random Seed (Optional)",
|
||||
"ui.render_config.validation_title": "Invalid Settings",
|
||||
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
|
||||
|
||||
@@ -39,9 +39,16 @@
|
||||
"ui.render_config.output_dir": "출력 디렉토리",
|
||||
"ui.render_config.browse": "찾아보기...",
|
||||
"ui.render_config.reset_default": "기본값으로 초기화",
|
||||
"ui.render_config.filename_format": "파일 이름 형식",
|
||||
"ui.render_config.filename_format_hint": "태그: {song}, {artist}, {author}, {date}, {time}, {resolution}, {fps}, {preset}",
|
||||
"ui.render_config.reset_filename_default": "기본값으로 초기화",
|
||||
"ui.render_config.file_extension": "파일 확장자",
|
||||
"ui.render_config.process_audio": "오디오 처리",
|
||||
"ui.render_config.non_tile": "논타일 모드",
|
||||
"ui.render_config.high_res_bloom_correction": "고해상도 블룸 보정",
|
||||
"ui.render_config.fix_custom_frame_rate": "커스텀 FPS 렌더 타이밍 보정",
|
||||
"ui.render_config.disable_tile_culling": "타일 컬링 비활성화",
|
||||
"ui.render_config.hide_failed_decorations": "로딩 실패한 장식 숨기기",
|
||||
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
|
||||
"ui.render_config.validation_title": "설정 오류",
|
||||
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ADOFAIRenderer.Localization;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lookup order in <see cref="Get(string)"/>: active locale, then English, then the key itself.
|
||||
/// Intended for the Unity main thread; the dictionaries are filled once during initialization and only read afterwards.
|
||||
/// Intended for the main Unity thread; the dictionaries are filled once during initialization and only read afterwards.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class LocalizationManager
|
||||
|
||||
@@ -30,7 +30,7 @@ public static class AudioManagerFindOrLoadAudioClipExternalPatch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AudioManager._Play (private; AudioManager.Play forwards to it): registers each scheduled effect sound (hit sounds, PlaySound
|
||||
/// AudioManager._Play (private; AudioManager.Play forwards to it): registers each scheduled sound effect (hit sounds, PlaySound
|
||||
/// events, hold sounds without an end time) as an effect voice of the offline mixer and maps the returned AudioSource to it.
|
||||
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on. The original always runs: it creates the
|
||||
/// AudioSource the game keeps using (its PlayScheduled call is blocked during rendering by another patch), and the game sets
|
||||
@@ -46,7 +46,7 @@ public static class AudioManagerPlayPatch
|
||||
{
|
||||
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
||||
{
|
||||
// Cache the clip on the main thread
|
||||
// Cache the clip on the main Unity thread
|
||||
RenderManager.Instance.AudioMixer?.CacheClip(snd);
|
||||
}
|
||||
return true; // Let the original method execute to prevent NullReferenceException
|
||||
@@ -104,7 +104,7 @@ public static class PlayWithEndTimePatch
|
||||
var mixer = RenderManager.Instance.AudioMixer;
|
||||
if (mixer != null)
|
||||
{
|
||||
// Cache the clip on the main thread
|
||||
// Cache the clip on the main Unity thread
|
||||
mixer.CacheClip(snd);
|
||||
|
||||
// Convert absolute DSP times to relative times in seconds from render start
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ADOFAIRenderer.Patch.Engine;
|
||||
/// (game and mod scripts).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All prefixes run on the thread that calls the getter (normally the Unity main thread) and read <c>RenderManager</c> state
|
||||
/// All prefixes run on the thread that calls the getter (normally the main Unity thread) and read <c>RenderManager</c> state
|
||||
/// without locks.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
@@ -264,7 +264,7 @@ public static class RenderEnvironmentPatches
|
||||
/// that loads an external audio file of the level into <c>audioLib</c>): from stage Load with
|
||||
/// <c>AdvancedConfig.ProcessAudio</c> on, forces <c>stream</c> to false. The game passes <c>stream</c> to
|
||||
/// <c>DownloadHandlerAudioClip.streamAudio</c> for ogg/wav/aif/aiff files, so these clips are loaded as non-streaming
|
||||
/// clips; the mp3 path uses <c>mp3Streaming</c> and ignores <c>stream</c>.
|
||||
/// clips; the MP3 path uses <c>mp3Streaming</c> and ignores <c>stream</c>.
|
||||
/// Reference: https://docs.unity3d.com/ScriptReference/Networking.DownloadHandlerAudioClip-streamAudio.html
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Fixes the in-game and render bug where objects (floors, decorations, planets) disappear on the first frame of
|
||||
/// a SetFramerate event (due to camera Z=0 near clip culling) and where parallax/tiles lag behind on subsequent frames.
|
||||
/// <para>
|
||||
/// Defers the manual camera rendering (<c>scrCamera.UpdateCustomFrameRateScreen</c>) to the very end of the frame
|
||||
/// (execution order <c>int.MaxValue - 1</c>), after all components (<c>scrCamera</c>, <c>scrFloor</c>,
|
||||
/// <c>scrDecorationManager</c>, <c>scrPlanet</c>, <c>scrParallax</c>, and <c>scrController</c>) have finished their
|
||||
/// <c>LateUpdate</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[HarmonyPatch]
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public static class CustomFrameRateDeferPatch
|
||||
{
|
||||
private static bool _isExecutingDeferred;
|
||||
private static scrCamera? _pendingCamera;
|
||||
|
||||
private static readonly Action<scrCamera>? UpdateCustomFrameRateScreenInvoker =
|
||||
AccessTools.MethodDelegate<Action<scrCamera>>(AccessTools.Method(typeof(scrCamera), "UpdateCustomFrameRateScreen"));
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the SetFramerate render timing fix is enabled.
|
||||
/// Active if rendering session has the option enabled, or enabled by default during normal in-game play.
|
||||
/// </summary>
|
||||
public static bool IsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
if (RenderManager.IsActive && RenderManager.Instance?.Configuration?.AdvancedConfig != null)
|
||||
{
|
||||
return RenderManager.Instance.Configuration.AdvancedConfig.FixCustomFrameRate;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intercepts manual camera render calls from SetCustomFrameRate (Update phase) or CheckUpdateFrameRateScreen
|
||||
/// (LateUpdate phase). If not currently executing the deferred render pass, records the camera instance and
|
||||
/// suppresses the premature render.
|
||||
/// </summary>
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(scrCamera), "UpdateCustomFrameRateScreen")]
|
||||
private static bool DeferManualRender(scrCamera __instance)
|
||||
{
|
||||
if (!IsEnabled) return true;
|
||||
if (_isExecutingDeferred) return true;
|
||||
|
||||
_pendingCamera = __instance;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the runner component is attached to the camera when it starts.
|
||||
/// </summary>
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(scrCamera), "Start")]
|
||||
private static void AttachRunner(scrCamera __instance)
|
||||
{
|
||||
if (!__instance) return;
|
||||
if (!__instance.gameObject.GetComponent<CustomFrameRateDeferredRunner>())
|
||||
{
|
||||
__instance.gameObject.AddComponent<CustomFrameRateDeferredRunner>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked at the very end of LateUpdate (Order int.MaxValue - 1). If a manual render was requested during the frame,
|
||||
/// executes the camera's original UpdateCustomFrameRateScreen once with all objects fully prepared.
|
||||
/// </summary>
|
||||
internal static void ExecuteDeferredRender()
|
||||
{
|
||||
if (_pendingCamera == null) return;
|
||||
|
||||
var cam = _pendingCamera;
|
||||
_pendingCamera = null;
|
||||
|
||||
if (!cam) return;
|
||||
|
||||
_isExecutingDeferred = true;
|
||||
try
|
||||
{
|
||||
UpdateCustomFrameRateScreenInvoker?.Invoke(cam);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isExecutingDeferred = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runner component attached to scrCamera that executes deferred custom frame rate renders at the end of LateUpdate.
|
||||
/// Execution order <c>int.MaxValue - 1</c> guarantees it runs after all normal game components (including
|
||||
/// scrController at 400), but right before PostRenderHook at <c>int.MaxValue</c>.
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(int.MaxValue - 1)]
|
||||
internal class CustomFrameRateDeferredRunner : MonoBehaviour
|
||||
{
|
||||
private void LateUpdate()
|
||||
{
|
||||
CustomFrameRateDeferPatch.ExecuteDeferredRender();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Patches decoration rendering to hide decorations that failed to load (or are empty) and fell back to the default
|
||||
/// placeholder sprite (tree inside a circle) during a render when <c>AdvancedConfig.HideFailedDecorations</c> is enabled.
|
||||
/// </summary>
|
||||
public static class DecorationPatch
|
||||
{
|
||||
/// <summary>
|
||||
/// True while a render is loading or active and <c>HideFailedDecorations</c> is enabled in the configuration.
|
||||
/// </summary>
|
||||
public static bool IsEnabled =>
|
||||
RenderManager.Instance &&
|
||||
RenderManager.Instance.stage >= RenderStage.Load &&
|
||||
RenderManager.Instance.Configuration?.AdvancedConfig?.HideFailedDecorations == true;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given sprite is the default placeholder or not-found sprite of the decoration manager.
|
||||
/// </summary>
|
||||
public static bool IsPlaceholderSprite(Sprite? sprite, scrDecorationManager? manager = null)
|
||||
{
|
||||
if (!sprite) return false;
|
||||
manager ??= scrDecorationManager.instance;
|
||||
if (!manager) return false;
|
||||
|
||||
return sprite == manager.defaultSprite || (manager.notFoundSprite && sprite == manager.notFoundSprite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sweeps all visual decorations in <c>scrDecorationManager.instance.allDecorations</c> and clears any placeholder sprites.
|
||||
/// Called from <see cref="scnGamePatch.ProcessFloors"/> when <c>scnGame.Play</c> runs.
|
||||
/// </summary>
|
||||
public static void ProcessDecorations()
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
var manager = scrDecorationManager.instance;
|
||||
if (!manager || manager.allDecorations == null) return;
|
||||
|
||||
foreach (var dec in manager.allDecorations)
|
||||
{
|
||||
if (dec is scrVisualDecoration visualDec && visualDec.spriteRenderer)
|
||||
{
|
||||
if (IsPlaceholderSprite(visualDec.spriteRenderer.sprite, manager))
|
||||
{
|
||||
visualDec.SetSprite((Sprite)null!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefix of <c>scrVisualDecoration.SetSprite(Sprite)</c>: replaces placeholder default/not-found sprites with null
|
||||
/// when <see cref="IsEnabled"/> is true.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(scrVisualDecoration), nameof(scrVisualDecoration.SetSprite), typeof(Sprite))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class VisualDecorationSetSpritePatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
public static void Prefix(scrVisualDecoration __instance, ref Sprite? sprite)
|
||||
{
|
||||
if (!IsEnabled || !sprite) return;
|
||||
|
||||
if (IsPlaceholderSprite(sprite, __instance.manager))
|
||||
{
|
||||
sprite = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefix of <c>scrDecorationManager.ShowEmptyDecorations(bool)</c>: suppresses showing placeholder default sprites
|
||||
/// during render when <see cref="IsEnabled"/> is true.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(scrDecorationManager), nameof(scrDecorationManager.ShowEmptyDecorations))]
|
||||
public static class ShowEmptyDecorationsPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
public static bool Prefix(bool show)
|
||||
{
|
||||
if (show && IsEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace ADOFAIRenderer.Patch.Game;
|
||||
/// <c>Persistence.language</c> (it returns immediately when already initialized); and <c>RDString.ChangeLanguage(SystemLanguage)</c>,
|
||||
/// which stores the language in <c>Persistence.language</c>, clears the initialized flag and calls Setup again.
|
||||
/// </para>
|
||||
/// <para>Always active (independent of the render stage). Runs on the Unity main thread.</para>
|
||||
/// <para>Always active (independent of the render stage). Runs on the main Unity thread.</para>
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(RDString))]
|
||||
internal static class LanguageSyncPatch
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the game's bloom compute its blur size as if the frame were 720 pixels high
|
||||
/// ("High-Resolution Bloom Correction" option).
|
||||
/// <para>
|
||||
/// Patched member: <c>VideoBloom.OnRenderImage(RenderTexture source, RenderTexture destination)</c>. The game scales the
|
||||
/// frame down to a bloom buffer 198 pixels high (<c>scale = 198 / source.height</c>) and sets the blur radius in bloom
|
||||
/// buffer pixels to <c>KernelSize * MediumKernelScale * scale</c> (and <c>KernelSize * LargeKernelScale * scale</c>).
|
||||
/// <c>BloomBlit</c> turns the radius into the number of 5-tap blur passes and the blend amount of the last pass.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// While the render is active (<see cref="RenderManager.IsActive"/>) and <c>AdvancedConfig.HighResBloomCorrection</c> is
|
||||
/// enabled, the prefix multiplies <c>KernelSize</c> by <c>source.height / 720</c> for the duration of the call, so the
|
||||
/// radius equals <c>KernelSize * MediumKernelScale * 198 / 720</c> and the blur passes on the bloom buffer are the same
|
||||
/// as in a 720p frame. The finalizer restores the original <c>KernelSize</c>. The bloom buffer size and the downscale
|
||||
/// from the source frame keep the game's behavior.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(VideoBloom))]
|
||||
public class VideoBloomPatch
|
||||
{
|
||||
/// <summary>Frame height in pixels the blur size is computed for.</summary>
|
||||
private const float ReferenceHeight = 720f;
|
||||
|
||||
/// <summary>Prefix of <c>VideoBloom.OnRenderImage</c>.</summary>
|
||||
/// <param name="__instance">The bloom component on the game camera.</param>
|
||||
/// <param name="source">The camera frame; its height selects the multiplier.</param>
|
||||
/// <param name="__state">Original <c>KernelSize</c>, or null when it was not changed.</param>
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("OnRenderImage")]
|
||||
// ReSharper disable InconsistentNaming
|
||||
public static void ScaleKernelSize(VideoBloom __instance, RenderTexture source, out float? __state)
|
||||
// ReSharper restore InconsistentNaming
|
||||
{
|
||||
__state = null;
|
||||
if (!source || !RenderManager.IsActive || !RenderManager.Instance.Configuration.AdvancedConfig.HighResBloomCorrection)
|
||||
return;
|
||||
|
||||
__state = __instance.KernelSize;
|
||||
__instance.KernelSize *= source.height / ReferenceHeight;
|
||||
}
|
||||
|
||||
/// <summary>Finalizer of <c>VideoBloom.OnRenderImage</c>: restores <c>KernelSize</c> when the prefix changed it.</summary>
|
||||
/// <param name="__instance">The bloom component on the game camera.</param>
|
||||
/// <param name="__state">Original <c>KernelSize</c> stored by the prefix, or null.</param>
|
||||
[HarmonyFinalizer]
|
||||
[HarmonyPatch("OnRenderImage")]
|
||||
// ReSharper disable InconsistentNaming
|
||||
public static void RestoreKernelSize(VideoBloom __instance, float? __state)
|
||||
// ReSharper restore InconsistentNaming
|
||||
{
|
||||
if (__state.HasValue) __instance.KernelSize = __state.Value;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace ADOFAIRenderer.Patch.Game;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Always active except when the mod features are disabled (<see cref="RenderMod.IsFeatureDisabled"/>, set from the
|
||||
/// driver warning dialog). Runs on the Unity main thread each time the editor scene starts.
|
||||
/// driver warning dialog). Runs on the main Unity thread each time the editor scene starts.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(scnEditor))]
|
||||
|
||||
@@ -30,8 +30,21 @@ public static class scnGamePatch
|
||||
[HarmonyPatch("Play")]
|
||||
public static void ProcessFloors()
|
||||
{
|
||||
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
|
||||
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
|
||||
if (RenderManager.Instance &&
|
||||
RenderManager.Instance.stage >= RenderStage.Load &&
|
||||
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
|
||||
{
|
||||
foreach (var floor in scrLevelMaker.instance.listFloors)
|
||||
{
|
||||
if (floor) floor.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
DecorationPatch.ProcessDecorations();
|
||||
|
||||
var nonTile = RenderManager.Instance &&
|
||||
RenderManager.Instance.stage >= RenderStage.Load &&
|
||||
RenderManager.Instance.Configuration?.AdvancedConfig?.NonTile == true;
|
||||
|
||||
if (!nonTile) return;
|
||||
foreach (var floor in scrLevelMaker.instance.listFloors)
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ADOFAIRenderer.Patch.Game;
|
||||
/// already off.
|
||||
/// <para>
|
||||
/// Patched member: <c>scrCamera.SetCustomFrameRate(bool enable, float frame)</c> (level effect that updates the screen at a
|
||||
/// lower frame rate). Enabling assigns a new RenderTexture to the camera quad material and refreshes it at <c>frame</c> fps.
|
||||
/// lower frame rate). Enabling assigns a new RenderTexture to the camera quad material and refreshes it at <c>frame</c> FPS.
|
||||
/// Disabling calls <c>Release()</c> on the quad material's current <c>mainTexture</c> and assigns <c>camRT</c> back. When
|
||||
/// the effect was never enabled, <c>mainTexture</c> is <c>camRT</c> itself, so the original releases the live camera
|
||||
/// render texture.
|
||||
|
||||
@@ -33,4 +33,41 @@ public class scrFloorPatch
|
||||
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
|
||||
return !nonTile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Postfix of <c>scrFloor.Start</c>.
|
||||
/// The game sets <c>enabled = isVisible</c> in Start(), which disables all off-screen tiles right at spawn.
|
||||
/// When <c>DisableTileCulling</c> is enabled, force every floor to remain enabled.
|
||||
/// </summary>
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("Start")]
|
||||
public static void ForceEnableOnStart(scrFloor __instance)
|
||||
{
|
||||
if (RenderManager.Instance &&
|
||||
RenderManager.Instance.stage >= RenderStage.Load &&
|
||||
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
|
||||
{
|
||||
__instance.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefix of <c>scrFloor.OnBecameInvisible</c>.
|
||||
/// Skips disabling the floor component (<c>enabled = false</c>) while rendering with
|
||||
/// <c>AdvancedConfig.DisableTileCulling</c> enabled, preventing the 1-frame update lag/glitch
|
||||
/// when tiles enter the camera view.
|
||||
/// </summary>
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("OnBecameInvisible")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static bool PreventTileCulling(scrFloor __instance)
|
||||
{
|
||||
if (RenderManager.Instance &&
|
||||
RenderManager.Instance.stage >= RenderStage.Load &&
|
||||
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.4.0-BETA", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.3-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||
|
||||
namespace ADOFAIRenderer;
|
||||
|
||||
@@ -21,7 +21,7 @@ internal class DialogButton
|
||||
public string Text;
|
||||
|
||||
/// <summary>
|
||||
/// Action run on the Unity main thread when the button is clicked. The dialog is destroyed after the action
|
||||
/// Action run on the main Unity thread when the button is clicked. The dialog is destroyed after the action
|
||||
/// returns, also when the action throws (the exception is logged).
|
||||
/// </summary>
|
||||
public Action OnClick;
|
||||
@@ -49,7 +49,7 @@ internal class DialogButton
|
||||
/// canvas: a translucent full-screen backdrop that blocks input to everything below, and an O5Kit window on top of it.
|
||||
/// While the dialog exists, every enabled UI Toolkit root (<see cref="UIDocument"/>) is disabled so that it cannot
|
||||
/// receive input; the roots are enabled again in <see cref="OnDestroy"/>. Clicking any button runs its action and then
|
||||
/// destroys the dialog. All members run on the Unity main thread.
|
||||
/// destroys the dialog. All members run on the main Unity thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal class CommonWarningDialogUI : MonoBehaviour
|
||||
@@ -97,7 +97,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and shows a new dialog. Must be called on the Unity main thread.
|
||||
/// Creates and shows a new dialog. Must be called on the main Unity thread.
|
||||
/// </summary>
|
||||
/// <param name="title">Window title text.</param>
|
||||
/// <param name="description">Body text, centered and word-wrapped; TextMeshPro rich text tags are allowed.</param>
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ADOFAIRenderer.UI;
|
||||
/// <para>
|
||||
/// Created by <c>RenderManager.StartFFmpegThread</c> on a GameObject kept across scene loads, and reused for later
|
||||
/// renders. The overlay is drawn on the game screen only; it is not part of the encoded video frames.
|
||||
/// <see cref="Update"/> rebuilds the text every frame on the Unity main thread.
|
||||
/// <see cref="Update"/> rebuilds the text every frame on the main Unity thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal class DebugMonitorUI : MonoBehaviour
|
||||
@@ -194,7 +194,7 @@ internal class DebugMonitorUI : MonoBehaviour
|
||||
// Queue - frames waiting in ImageQueue for the render thread / queue frame limit
|
||||
// Rendered Frames - RenderManager.frameCount (frames produced by the game)
|
||||
// Frames Sent - RenderManager.framesSent (frames handed to the encoder; written by the render thread)
|
||||
// Throttling - RenderManager.throttleCount (times the main thread started waiting because ImageQueue
|
||||
// Throttling - RenderManager.throttleCount (times the main Unity thread started waiting because ImageQueue
|
||||
// held more frames than its limit)
|
||||
// Readback failures- RenderManager.readbackFailureCount (AsyncGPUReadback requests that completed with an error)
|
||||
// Mono heap - GC.GetTotalMemory(false), managed heap size in MiB without forcing a collection
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ADOFAIRenderer.UI;
|
||||
/// (measured in real time with a <see cref="Stopwatch"/>, independent of the patched game time) calls
|
||||
/// <c>RenderManager.CancelRender</c>. Releasing Escape earlier hides the panel and resets the countdown.
|
||||
/// The component destroys its GameObject as soon as <c>RenderManager.IsActive</c> is false (stage below OnScene).
|
||||
/// Runs on the Unity main thread.
|
||||
/// Runs on the main Unity thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal class EscapeHandlerUI : MonoBehaviour
|
||||
|
||||
@@ -25,9 +25,9 @@ namespace ADOFAIRenderer.UI;
|
||||
/// <c>RenderMod.OnInitializeMelon</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Threads: UI construction and all Unity object access happen on the Unity main thread. The download and extraction
|
||||
/// Threads: UI construction and all Unity object access happen on the main Unity thread. The download and extraction
|
||||
/// run on thread-pool threads (<see cref="Task.Run(Func{Task})"/>); they post UI updates to
|
||||
/// <see cref="_mainThreadQueue"/>, which <see cref="Update"/> drains on the main thread.
|
||||
/// <see cref="_mainThreadQueue"/>, which <see cref="Update"/> drains on the main Unity thread.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Flow: buttons -> (Download) progress bar + status text -> download to a temporary archive in UserLibs -> extract
|
||||
@@ -70,7 +70,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
|
||||
/// <summary>Cancellation for the running download; created per download, cancelled by Cancel, disposed in OnDestroy.</summary>
|
||||
private System.Threading.CancellationTokenSource? _cts;
|
||||
/// <summary>Actions posted from background threads, run in order on the main thread by <see cref="Update"/>.</summary>
|
||||
/// <summary>Actions posted from background threads, run in order on the main Unity thread by <see cref="Update"/>.</summary>
|
||||
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _mainThreadQueue = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -376,7 +376,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
/// <summary>Runs every action posted by the background download task, in posting order.</summary>
|
||||
private void Update()
|
||||
{
|
||||
// Execute queued actions on main thread
|
||||
// Execute queued actions on main Unity thread
|
||||
while (_mainThreadQueue.TryDequeue(out var action))
|
||||
{
|
||||
action?.Invoke();
|
||||
@@ -384,7 +384,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts an action to run on the main thread during the next <see cref="Update"/>. Safe to call from any thread.
|
||||
/// Posts an action to run on the main Unity thread during the next <see cref="Update"/>. Safe to call from any thread.
|
||||
/// Actions posted after the dialog is destroyed are never run.
|
||||
/// </summary>
|
||||
private void ExecuteOnMainThread(Action action)
|
||||
@@ -458,7 +458,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
// Keep the "complete" message visible for 1000 ms before closing
|
||||
await Task.Delay(1000);
|
||||
|
||||
// Destroy UI on main thread. FirstUpdateHook then sees Instance == null and checks the libraries again.
|
||||
// Destroy UI on main Unity thread. FirstUpdateHook then sees Instance == null and checks the libraries again.
|
||||
ExecuteOnMainThread(() =>
|
||||
{
|
||||
Destroy(gameObject);
|
||||
@@ -513,7 +513,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
Destroy(gameObject);
|
||||
}
|
||||
|
||||
/// <summary>Posts a status text and color change to the main thread. Callable from any thread.</summary>
|
||||
/// <summary>Posts a status text and color change to the main Unity thread. Callable from any thread.</summary>
|
||||
private void UpdateStatus(string message, Color color)
|
||||
{
|
||||
ExecuteOnMainThread(() =>
|
||||
@@ -526,7 +526,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Posts a progress bar update to the main thread. Callable from any thread.</summary>
|
||||
/// <summary>Posts a progress bar update to the main Unity thread. Callable from any thread.</summary>
|
||||
/// <param name="percent">Filled fraction 0..1 (not 0..100); becomes the fill's <c>anchorMax.x</c>.</param>
|
||||
/// <param name="text">Text shown on the bar.</param>
|
||||
private void UpdateProgress(float percent, string text)
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ namespace ADOFAIRenderer.UI;
|
||||
/// The text is set when the binding is made (<see cref="Bind(TextMeshProUGUI, string)"/>), when the component is enabled,
|
||||
/// and on every <see cref="LocalizationManager.OnLanguageChanged"/> event. A key and a dynamic provider are mutually
|
||||
/// exclusive: setting one clears the other. With neither set, <see cref="Refresh"/> leaves the text unchanged.
|
||||
/// Runs on the Unity main thread.
|
||||
/// Runs on the main Unity thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal class LocalizedText : MonoBehaviour
|
||||
@@ -44,7 +44,7 @@ internal class LocalizedText : MonoBehaviour
|
||||
/// The provider is called on every refresh (enable and language change), so it can combine several keys or
|
||||
/// runtime values, e.g. a label that depends on UI state.
|
||||
/// </summary>
|
||||
/// <param name="provider">Function returning the text to display; called on the Unity main thread.</param>
|
||||
/// <param name="provider">Function returning the text to display; called on the main Unity thread.</param>
|
||||
public void SetDynamic(Func<string> provider)
|
||||
{
|
||||
_dynamicProvider = provider;
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace ADOFAIRenderer.UI.Window;
|
||||
/// <para>
|
||||
/// Opened from the level editor through <c>RenderManager</c> (after the unsaved-changes check) with
|
||||
/// <see cref="CreateWindow"/>. The window is built once and then hidden/shown; its state lives in static fields for the
|
||||
/// whole game session. All members run on the Unity main thread.
|
||||
/// whole game session. All members run on the main Unity thread.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Data flow:
|
||||
@@ -135,10 +135,19 @@ internal static class RenderConfigWindow
|
||||
/// </summary>
|
||||
private static O5InputField? _outputDirInput;
|
||||
|
||||
/// <summary>
|
||||
/// File name format template input. Bound to <c>GlobalSettings.Current.FileNameFormat</c>.
|
||||
/// </summary>
|
||||
private static O5InputField? _fileNameFormatInput;
|
||||
|
||||
private static O5Dropdown<string>? _fileExtensionDropdown;
|
||||
private static O5InputField? _randomSeedInput;
|
||||
private static O5Toggle? _processAudioToggle;
|
||||
private static O5Toggle? _nonTileToggle;
|
||||
private static O5Toggle? _highResBloomCorrectionToggle;
|
||||
private static O5Toggle? _fixCustomFrameRateToggle;
|
||||
private static O5Toggle? _disableTileCullingToggle;
|
||||
private static O5Toggle? _hideFailedDecorationsToggle;
|
||||
|
||||
// Planet color overrides: the toggle enables the override, the picker holds the color. A disabled toggle stores
|
||||
// null in AdvancedConfig (no override is applied by PersistencePatch) and blocks the picker.
|
||||
@@ -644,7 +653,7 @@ internal static class RenderConfigWindow
|
||||
{
|
||||
var trimmed = val?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(trimmed) ||
|
||||
string.Equals(trimmed, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
string.Equals(trimmed, GlobalSettings.DefaultOutputDirectory, StringComparison.Ordinal))
|
||||
{
|
||||
GlobalSettings.Current.OutputDirectory = null;
|
||||
}
|
||||
@@ -658,7 +667,7 @@ internal static class RenderConfigWindow
|
||||
var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent);
|
||||
// Browse: opens the OS folder picker (FolderDialogUtil) at the current folder; a selected folder is written to
|
||||
// the input and saved to the global settings immediately. FolderDialogUtil runs the callback through the
|
||||
// caller's SynchronizationContext, i.e. on the Unity main thread; cancel or failure passes null (ignored here).
|
||||
// caller's SynchronizationContext, i.e. on the main Unity thread; cancel or failure passes null (ignored here).
|
||||
CreateButton(
|
||||
ctx, globalLeft,
|
||||
"ui.render_config.browse",
|
||||
@@ -694,10 +703,48 @@ internal static class RenderConfigWindow
|
||||
}
|
||||
);
|
||||
|
||||
// Filename format template: typing updates GlobalSettings.Current.FileNameFormat in memory only.
|
||||
_fileNameFormatInput = CreateFullWidthInputField(
|
||||
ctx, globalContent,
|
||||
"ui.render_config.filename_format",
|
||||
GlobalSettings.DefaultFileNameFormat,
|
||||
GlobalSettings.EffectiveFileNameFormat,
|
||||
"input_filename_format",
|
||||
val =>
|
||||
{
|
||||
var trimmed = val?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(trimmed) ||
|
||||
string.Equals(trimmed, GlobalSettings.DefaultFileNameFormat, StringComparison.Ordinal))
|
||||
{
|
||||
GlobalSettings.Current.FileNameFormat = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
GlobalSettings.Current.FileNameFormat = trimmed;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
var fnHint = CreateLabel(ctx, globalContent, "ui.render_config.filename_format_hint", 11f);
|
||||
fnHint.color = new Color(ctx.Theme.TextDim.r, ctx.Theme.TextDim.g, ctx.Theme.TextDim.b, ctx.Theme.TextDim.a * 0.7f);
|
||||
|
||||
CreateButton(
|
||||
ctx, globalContent,
|
||||
"ui.render_config.reset_filename_default",
|
||||
"btn_reset_filename_format",
|
||||
() =>
|
||||
{
|
||||
_fileNameFormatInput.Set(GlobalSettings.DefaultFileNameFormat);
|
||||
GlobalSettings.Current.FileNameFormat = null;
|
||||
GlobalSettings.Save();
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Section 6: Advanced Settings card (RenderConfig.AdvancedConfig)
|
||||
// File Extension | Random Seed
|
||||
// Process Audio | Non-Tile Mode
|
||||
// High-Resolution Bloom Correction |
|
||||
// "Planet Colors" label, then three columns: Fire | Ice | Wind (toggle + color picker each)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 7. Advanced Settings (Collapsible Card)
|
||||
@@ -739,6 +786,39 @@ internal static class RenderConfigWindow
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// High-Resolution Bloom Correction (AdvancedConfig.HighResBloomCorrection): the game's bloom computes its blur
|
||||
// size as if the frame were 720 pixels high (VideoBloomPatch)
|
||||
_highResBloomCorrectionToggle = CreateToggleField(
|
||||
ctx, CreateFieldSlot(advLeft),
|
||||
labelKey: "",
|
||||
defaultValue: false,
|
||||
value: config.AdvancedConfig.HighResBloomCorrection,
|
||||
toggleTextKey: "ui.render_config.high_res_bloom_correction",
|
||||
id: "toggle_high_res_bloom_correction",
|
||||
onChanged: val =>
|
||||
{
|
||||
config.AdvancedConfig.HighResBloomCorrection = val;
|
||||
MarkConfigModified();
|
||||
},
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// Disable Tile Culling (AdvancedConfig.DisableTileCulling): prevents tiles from being disabled outside the frustum
|
||||
_disableTileCullingToggle = CreateToggleField(
|
||||
ctx, CreateFieldSlot(advLeft),
|
||||
labelKey: "",
|
||||
defaultValue: false,
|
||||
value: config.AdvancedConfig.DisableTileCulling,
|
||||
toggleTextKey: "ui.render_config.disable_tile_culling",
|
||||
id: "toggle_disable_tile_culling",
|
||||
onChanged: val =>
|
||||
{
|
||||
config.AdvancedConfig.DisableTileCulling = val;
|
||||
MarkConfigModified();
|
||||
},
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// Random Seed (AdvancedConfig.RandomSeed): integer passed to UnityEngine.Random.InitState before the game scene
|
||||
// loads; empty text stores null (no seeding). Text that is neither empty nor an integer keeps the previous value.
|
||||
_randomSeedInput = CreateInputField(
|
||||
@@ -772,6 +852,38 @@ internal static class RenderConfigWindow
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// Fix SetFramerate Render Timing (AdvancedConfig.FixCustomFrameRate): defers manual camera rendering to frame end
|
||||
_fixCustomFrameRateToggle = CreateToggleField(
|
||||
ctx, CreateFieldSlot(advRight),
|
||||
labelKey: "",
|
||||
defaultValue: true,
|
||||
value: config.AdvancedConfig.FixCustomFrameRate,
|
||||
toggleTextKey: "ui.render_config.fix_custom_frame_rate",
|
||||
id: "toggle_fix_custom_frame_rate",
|
||||
onChanged: val =>
|
||||
{
|
||||
config.AdvancedConfig.FixCustomFrameRate = val;
|
||||
MarkConfigModified();
|
||||
},
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// Hide Failed Decorations (AdvancedConfig.HideFailedDecorations): hides placeholder tree decorations when decoration images fail to load
|
||||
_hideFailedDecorationsToggle = CreateToggleField(
|
||||
ctx, CreateFieldSlot(advRight),
|
||||
labelKey: "",
|
||||
defaultValue: false,
|
||||
value: config.AdvancedConfig.HideFailedDecorations,
|
||||
toggleTextKey: "ui.render_config.hide_failed_decorations",
|
||||
id: "toggle_hide_failed_decorations",
|
||||
onChanged: val =>
|
||||
{
|
||||
config.AdvancedConfig.HideFailedDecorations = val;
|
||||
MarkConfigModified();
|
||||
},
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// 7-1. Planet Color Settings (Optional).
|
||||
// For each planet: the toggle turns the override on/off (off stores null and blocks/collapses the picker);
|
||||
// the picker writes its color into the config only while the toggle is on.
|
||||
@@ -975,11 +1087,16 @@ internal static class RenderConfigWindow
|
||||
_audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false, noFilter: true);
|
||||
|
||||
_outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false);
|
||||
_fileNameFormatInput?.Set(GlobalSettings.EffectiveFileNameFormat, false);
|
||||
|
||||
_fileExtensionDropdown?.Set(config.AdvancedConfig.FileExtension, false);
|
||||
_randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false);
|
||||
_processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false);
|
||||
_nonTileToggle?.Set(config.AdvancedConfig.NonTile, false);
|
||||
_highResBloomCorrectionToggle?.Set(config.AdvancedConfig.HighResBloomCorrection, false);
|
||||
_fixCustomFrameRateToggle?.Set(config.AdvancedConfig.FixCustomFrameRate, false);
|
||||
_disableTileCullingToggle?.Set(config.AdvancedConfig.DisableTileCulling, false);
|
||||
_hideFailedDecorationsToggle?.Set(config.AdvancedConfig.HideFailedDecorations, false);
|
||||
|
||||
// Planet colors: toggle on when an override is stored; the picker shows the stored color or the default and is
|
||||
// blocked without an override
|
||||
@@ -1074,7 +1191,7 @@ internal static class RenderConfigWindow
|
||||
{
|
||||
var path = _outputDirInput.Value.Trim();
|
||||
if (string.IsNullOrWhiteSpace(path) ||
|
||||
string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.Ordinal))
|
||||
{
|
||||
GlobalSettings.Current.OutputDirectory = null;
|
||||
}
|
||||
@@ -1097,6 +1214,20 @@ internal static class RenderConfigWindow
|
||||
}
|
||||
}
|
||||
|
||||
if (_fileNameFormatInput != null)
|
||||
{
|
||||
var format = _fileNameFormatInput.Value.Trim();
|
||||
if (string.IsNullOrWhiteSpace(format) ||
|
||||
string.Equals(format, GlobalSettings.DefaultFileNameFormat, StringComparison.Ordinal))
|
||||
{
|
||||
GlobalSettings.Current.FileNameFormat = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
GlobalSettings.Current.FileNameFormat = format;
|
||||
}
|
||||
}
|
||||
|
||||
GlobalSettings.Save();
|
||||
SetWindowActive(false);
|
||||
|
||||
@@ -1161,6 +1292,10 @@ internal static class RenderConfigWindow
|
||||
}
|
||||
if (_processAudioToggle != null) target.AdvancedConfig.ProcessAudio = _processAudioToggle.Value;
|
||||
if (_nonTileToggle != null) target.AdvancedConfig.NonTile = _nonTileToggle.Value;
|
||||
if (_highResBloomCorrectionToggle != null) target.AdvancedConfig.HighResBloomCorrection = _highResBloomCorrectionToggle.Value;
|
||||
if (_fixCustomFrameRateToggle != null) target.AdvancedConfig.FixCustomFrameRate = _fixCustomFrameRateToggle.Value;
|
||||
if (_disableTileCullingToggle != null) target.AdvancedConfig.DisableTileCulling = _disableTileCullingToggle.Value;
|
||||
if (_hideFailedDecorationsToggle != null) target.AdvancedConfig.HideFailedDecorations = _hideFailedDecorationsToggle.Value;
|
||||
|
||||
// Planet Colors
|
||||
target.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null)
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ADOFAIRenderer.Util.Audio;
|
||||
/// The frame rate is converted to an exact fraction Numerator / Denominator (<see cref="RationalUtil.Double2MaxPrecisionInt"/>).
|
||||
/// Frame k receives floor((k + 1) * sampleRate * Denominator / Numerator) - floor(k * sampleRate * Denominator / Numerator) samples:
|
||||
/// the remainder of each division is carried into the next frame, so the sum over any number of frames equals the exact
|
||||
/// sample count of that duration rounded down (for example 44100 Hz at 60 fps gives 735 every frame; at 144 fps three frames of 306
|
||||
/// sample count of that duration rounded down (for example 44100 Hz at 60 FPS gives 735 every frame; at 144 FPS three frames of 306
|
||||
/// are followed by one of 307, 306.25 on average).
|
||||
/// Used by the render thread only (RenderManager.WriteAudioForFrame, once per frame sent to the encoder).
|
||||
/// </summary>
|
||||
|
||||
@@ -175,7 +175,7 @@ internal sealed class AudioClipSource
|
||||
/// <summary>
|
||||
/// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start,
|
||||
/// otherwise opens another stream up to this count, otherwise restarts the least recently used stream.
|
||||
/// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/mp3 with the clip harness).
|
||||
/// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/MP3 with the clip harness).
|
||||
/// </summary>
|
||||
public const int MaxDecodeStreams = 4;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
/// - Decoder thread (<see cref="Start"/>): the only thread that decodes, resamples, loads and frees chunks.
|
||||
/// Each step loads one chunk, the one with the lowest priority value over all sources
|
||||
/// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order).
|
||||
/// - Consumers (render thread, main thread for the spectrum): <see cref="Acquire"/> waits for the decoder thread and never loads.
|
||||
/// - Consumers (render thread, main Unity thread for the spectrum): <see cref="Acquire"/> waits for the decoder thread and never loads.
|
||||
/// - Main thread: <see cref="GetOrAdd"/> creates readers (for Unity clips this copies the clip data).
|
||||
///
|
||||
/// Memory has no limit; current and peak usage are tracked and logged by <see cref="LogMemoryUsage"/>.
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
/// <summary>
|
||||
/// Copies a Unity <see cref="AudioClip"/> into memory for clips without a file on disk
|
||||
/// (built-in hit sounds from Resources, official/DLC level audio).
|
||||
/// <see cref="AudioClip.GetData(float[], int)"/> runs on the main thread only, so the whole clip is copied once on the main thread
|
||||
/// <see cref="AudioClip.GetData(float[], int)"/> runs on the main Unity thread only, so the whole clip is copied once on the main Unity thread
|
||||
/// and the decoder thread reads the copy through a <see cref="MemoryPCMReader"/>.
|
||||
/// Streaming clips (<see cref="AudioClipLoadType.Streaming"/>) are rejected because GetData does not read them.
|
||||
/// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html
|
||||
|
||||
@@ -6,7 +6,7 @@ using ADOFAIRenderer.Util.Audio.Clip;
|
||||
namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||
|
||||
/// <summary>
|
||||
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one effect sound), created by <see cref="VoiceMixer.Play"/>.
|
||||
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one sound effect), created by <see cref="VoiceMixer.Play"/>.
|
||||
///
|
||||
/// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start,
|
||||
/// the time at which the game made the change). Changes of one voice must be queued in non-decreasing time order.
|
||||
@@ -43,7 +43,7 @@ internal sealed class Voice
|
||||
Stop
|
||||
}
|
||||
|
||||
/// <summary>One timed change of a voice, queued by the main thread and applied by the render thread.</summary>
|
||||
/// <summary>One timed change of a voice, queued by the main Unity thread and applied by the render thread.</summary>
|
||||
internal readonly struct Change
|
||||
{
|
||||
public readonly ChangeKind Kind;
|
||||
|
||||
@@ -35,7 +35,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
Change
|
||||
}
|
||||
|
||||
/// <summary>Command queued by the main thread and taken by the render thread at the start of <see cref="Mix"/>.</summary>
|
||||
/// <summary>Command queued by the main Unity thread and taken by the render thread at the start of <see cref="Mix"/>.</summary>
|
||||
private readonly struct Command
|
||||
{
|
||||
public readonly CommandKind Kind;
|
||||
@@ -55,7 +55,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
private readonly AudioClipStore _store;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
// Double-buffered command lists: the main thread appends to _commands under _commandLock; the render thread swaps the
|
||||
// Double-buffered command lists: the main Unity thread appends to _commands under _commandLock; the render thread swaps the
|
||||
// two lists under the lock and processes _takenCommands outside it, then clears it for the next swap.
|
||||
private readonly object _commandLock = new();
|
||||
private List<Command> _commands = new();
|
||||
@@ -126,7 +126,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Queues a change of <paramref name="voice"/> that takes effect at sample <see cref="ToSample"/>(<paramref name="time"/>).
|
||||
/// Called by the Voice Set methods on the main thread.
|
||||
/// Called by the Voice Set methods on the main Unity thread.
|
||||
/// </summary>
|
||||
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ using UnityEngine;
|
||||
namespace ADOFAIRenderer.Util.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Audio of one render: the song and effect sounds as <see cref="Voice"/>s of a <see cref="VoiceMixer"/>, clips from an
|
||||
/// Audio of one render: the song and sound effects as <see cref="Voice"/>s of a <see cref="VoiceMixer"/>, clips from an
|
||||
/// <see cref="AudioClipStore"/>, followed by the filter chain (RMS compressor → look-ahead limiter → hard clip → -1 dB gain).
|
||||
///
|
||||
/// Time: seconds relative to the render start (RenderManager.dspTimeBase); output sample n is at n / sample rate.
|
||||
@@ -21,15 +21,15 @@ namespace ADOFAIRenderer.Util.Audio;
|
||||
/// Threads:
|
||||
/// - Main thread: <see cref="InitializeBGM"/>, <see cref="CacheClip"/>, <see cref="RegisterSoundEvent"/>, <see cref="UpdateBGM"/>,
|
||||
/// source/voice mapping, and <see cref="Commit"/> after each frame (all commands with an earlier time have been queued).
|
||||
/// - Render thread: <see cref="GenerateAudioForFrame"/> waits until the main thread has committed the end of the samples it mixes,
|
||||
/// so every command takes effect at its own sample. <see cref="Complete"/> ends the waiting when the main thread stops.
|
||||
/// - Render thread: <see cref="GenerateAudioForFrame"/> waits until the main Unity thread has committed the end of the samples it mixes,
|
||||
/// so every command takes effect at its own sample. <see cref="Complete"/> ends the waiting when the main Unity thread stops.
|
||||
/// </summary>
|
||||
internal sealed class OfflineAudioMixer : IDisposable
|
||||
{
|
||||
/// <summary>Linear gain applied to every clip (song and effects), before the filter chain (0.5 ≈ -6 dB headroom).</summary>
|
||||
private const double ClipVolume = 0.5;
|
||||
|
||||
/// <summary>Additional gain of effect sounds (registered with <see cref="RegisterSoundEvent"/>).</summary>
|
||||
/// <summary>Additional gain of sound effects (registered with <see cref="RegisterSoundEvent"/>).</summary>
|
||||
private const double EffectVolume = 2.0 / 3.0;
|
||||
|
||||
/// <summary>
|
||||
@@ -62,7 +62,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
// Main thread.
|
||||
|
||||
/// <summary>
|
||||
/// Mapping of game AudioSources to the state of the effect sound last started on them. Weak keys: a destroyed source
|
||||
/// Mapping of game AudioSources to the state of the sound effect last started on them. Weak keys: a destroyed source
|
||||
/// does not keep its state alive. Replaced by a new table to drop every mapping at once.
|
||||
/// </summary>
|
||||
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
|
||||
@@ -240,7 +240,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an effect sound at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
|
||||
/// Starts a sound effect at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
|
||||
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
|
||||
/// </summary>
|
||||
/// <param name="source">Game AudioSource playing the sound, or null to map it later with <see cref="MapSource"/>.</param>
|
||||
@@ -283,7 +283,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
_sourceStates.Add(source, state);
|
||||
}
|
||||
|
||||
/// <summary>State of the effect sound last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary>
|
||||
/// <summary>State of the sound effect last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary>
|
||||
public bool TryGetSourceState(AudioSource? source, out SourceState state)
|
||||
{
|
||||
state = null!;
|
||||
@@ -291,7 +291,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops every effect sound at <paramref name="now"/>, including ones scheduled to start later
|
||||
/// Stops every sound effect at <paramref name="now"/>, including ones scheduled to start later
|
||||
/// (AudioManager.StopAllSounds destroys every AudioSource in liveSources, which holds the sources of
|
||||
/// AudioManager._Play and scrConductor.PlayWithEndTime). The song voice keeps playing.
|
||||
/// </summary>
|
||||
@@ -342,7 +342,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
|
||||
/// Blocks until the main thread has committed the voice samples it needs (<see cref="Commit"/>, <see cref="Complete"/>).
|
||||
/// Blocks until the main Unity thread has committed the voice samples it needs (<see cref="Commit"/>, <see cref="Complete"/>).
|
||||
/// </summary>
|
||||
/// <param name="mixedBuffer">Destination; must hold at least samplesNeeded * channels floats. Values beyond that are untouched.</param>
|
||||
/// <param name="samplesNeeded">Frames (samples per channel) to write.</param>
|
||||
@@ -377,7 +377,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mixes voices at samples [first, first + frames) into the reused double buffer, after the main thread committed them.</summary>
|
||||
/// <summary>Mixes voices at samples [first, first + frames) into the reused double buffer, after the main Unity thread committed them.</summary>
|
||||
private Span<double> MixBlock(long first, int frames)
|
||||
{
|
||||
WaitForCommit(first + frames);
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
///
|
||||
/// ── Threading ────────────────────────────────────────────────────────
|
||||
/// Every public method takes <c>_lock</c>, so calls from different threads are serialized.
|
||||
/// In the renderer, the main thread constructs and starts the encoder, and the render thread
|
||||
/// In the renderer, the main Unity thread constructs and starts the encoder, and the render thread
|
||||
/// (RenderManager.RenderThread) sends the video frames and audio samples, calls End() and Dispose().
|
||||
/// Dispose() may also be called from another thread to release an encoder whose render was stopped.
|
||||
/// After End() or Dispose(), the Write*/Send* methods return without doing anything.
|
||||
@@ -916,7 +916,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// Stream time base: unit of the timestamps stored in the container.
|
||||
// Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks.
|
||||
// When it is left unset, the muxer uses 1/90000, in which the interval of rates such as
|
||||
// 59.94 fps (1501.5 ticks), 23.976 fps (3753.75 ticks) or 165 fps (545.45 ticks) is not an integer,
|
||||
// 59.94 FPS (1501.5 ticks), 23.976 FPS (3753.75 ticks) or 165 FPS (545.45 ticks) is not an integer,
|
||||
// and the frame durations alternate between two values.
|
||||
// The muxer may still adjust the value in avformat_write_header (e.g. mp4 scales it to at least 10000);
|
||||
// packet timestamps are rescaled to the final value in SendFrameAndWritePackets.
|
||||
@@ -1080,6 +1080,14 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
{
|
||||
TrySetOption("x265-params", "hrd=1:strict-cbr=1");
|
||||
}
|
||||
// NVENC: without "rc", nvenc_setup_rate_control selects CONSTQP when "qp" is set and VBR otherwise;
|
||||
// bit_rate and rc_max_rate being equal does not select CBR. "rc=cbr" selects NV_ENC_PARAMS_RC_CBR.
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/nvenc.c (nvenc_setup_rate_control),
|
||||
// https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/nvenc_h264.c ("rc" option, "cbr" constant)
|
||||
else if (encoder.Kind == EncoderKind.NVENC)
|
||||
{
|
||||
TrySetOption("rc", "cbr");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ namespace ADOFAIRenderer.Util
|
||||
/// (RenderManager.SendVideoFrame, then <see cref="RecycleState"/>) or the frame was dropped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Threads: requests and readback completions run on the main thread; <see cref="RecycleState"/> is also called by
|
||||
/// Threads: requests and readback completions run on the main Unity thread; <see cref="RecycleState"/> is also called by
|
||||
/// the render thread. The pool and the disposed flag are guarded by _poolLock. One instance per render, created in
|
||||
/// RenderManager.SetupPlay with the pixel format of the opened encoder.
|
||||
/// </para>
|
||||
@@ -290,7 +290,7 @@ namespace ADOFAIRenderer.Util
|
||||
|
||||
/// <summary>
|
||||
/// Returns a state to the pool after its frame was sent or dropped, or disposes it when the reader is disposed.
|
||||
/// Called by the render thread (after sending) and by the main thread (abort, errors, queue cleanup).
|
||||
/// Called by the render thread (after sending) and by the main Unity thread (abort, errors, queue cleanup).
|
||||
/// </summary>
|
||||
internal void RecycleState(ReadbackRequestState state)
|
||||
{
|
||||
|
||||
+14
-10
@@ -18,11 +18,11 @@ namespace ADOFAIRenderer.Util.OS;
|
||||
/// </para>
|
||||
/// <para>- <b>macOS</b>: <c>osascript</c> running AppleScript <c>choose folder</c>.</para>
|
||||
/// <para>
|
||||
/// - <b>Linux</b>: <c>zenity --file-selection --directory</c>; when zenity does not return an existing directory (not installed,
|
||||
/// cancelled or failed), <c>kdialog --getexistingdirectory</c>.
|
||||
/// - <b>Linux</b>: <c>zenity --file-selection --directory</c>; when zenity is not installed or fails (exit code other than 0
|
||||
/// or 1), <c>kdialog --getexistingdirectory</c>. A cancelled zenity dialog (exit code 1) ends the selection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The dialog runs off the Unity main thread so the game keeps rendering frames while it is open. Only one dialog can be open
|
||||
/// The dialog runs off the main Unity thread so the game keeps rendering frames while it is open. Only one dialog can be open
|
||||
/// at a time. Failures are reported to the callback as null (same as cancel); exceptions are logged as warnings, except a
|
||||
/// zenity start failure, which silently moves on to kdialog.
|
||||
/// </para>
|
||||
@@ -43,7 +43,7 @@ internal static class FolderDialogUtil
|
||||
/// "FolderDialog-Win"; macOS/Linux: thread pool task) and <paramref name="onSelected"/> receives the result.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Callback thread: when the caller has a <see cref="SynchronizationContext"/> (the Unity main thread has one), the callback
|
||||
/// Callback thread: when the caller has a <see cref="SynchronizationContext"/> (the main Unity thread has one), the callback
|
||||
/// is posted to it and runs on the caller's thread; otherwise it runs on the dialog thread. Exceptions thrown by the
|
||||
/// callback are caught and logged.
|
||||
/// </para>
|
||||
@@ -688,13 +688,13 @@ internal static class FolderDialogUtil
|
||||
|
||||
/// <summary>
|
||||
/// Runs <c>zenity --file-selection --directory --title=... [--filename=initialDirectory]</c> and blocks until it exits.
|
||||
/// When zenity cannot be started, exits with a non-zero code (cancel or error) or prints a path that is not an existing
|
||||
/// directory, runs <c>kdialog --getexistingdirectory [initialDirectory] --title ...</c> instead. Both commands print the
|
||||
/// Exit code 0 returns the printed path when it is an existing directory, and exit code 1 (cancelled or closed) returns null
|
||||
/// without opening a second dialog. When zenity cannot be started or exits with any other code, runs <c>kdialog --getexistingdirectory [initialDirectory] --title ...</c> instead. Both commands print the
|
||||
/// selected path to standard output.
|
||||
/// </summary>
|
||||
/// <param name="initialDirectory">Folder shown first; used only when it is an existing directory.</param>
|
||||
/// <returns>
|
||||
/// The selected existing directory; null when both dialogs return nothing, when kdialog cannot be started, or on an
|
||||
/// The selected existing directory; null when the dialog is cancelled, when kdialog cannot be started, or on an
|
||||
/// exception (logged as a warning).
|
||||
/// </returns>
|
||||
private static string? OpenLinuxFolderBrowser(string initialDirectory)
|
||||
@@ -725,15 +725,19 @@ internal static class FolderDialogUtil
|
||||
var output = process.StandardOutput.ReadToEnd().Trim();
|
||||
process.WaitForExit();
|
||||
|
||||
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output))
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return output;
|
||||
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
|
||||
}
|
||||
|
||||
// zenity exit code 1: the dialog was cancelled or closed.
|
||||
// Reference: https://gitlab.gnome.org/GNOME/zenity/-/raw/master/src/util.c (ZENITY_OK_DEFAULT 0, ZENITY_CANCEL_DEFAULT 1, ZENITY_ESC_DEFAULT 1, ZENITY_ERROR_DEFAULT -1).
|
||||
if (process.ExitCode == 1) return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to kdialog if zenity fails to start
|
||||
// Falls back to kdialog when zenity is not installed or cannot be started.
|
||||
}
|
||||
|
||||
var kdialogInfo = new ProcessStartInfo
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ADOFAIRenderer.Core.Data;
|
||||
using ADOFAIRenderer.Data;
|
||||
|
||||
namespace ADOFAIRenderer.Util;
|
||||
|
||||
/// <summary>
|
||||
/// Formats and sanitizes render output file names according to customizable template patterns.
|
||||
/// </summary>
|
||||
internal static class RenderFileNameUtil
|
||||
{
|
||||
private static readonly Regex RichTextRegex = new(@"<[^>]+>", RegexOptions.Compiled);
|
||||
private static readonly Regex TokenRegex = new(@"\{([a-zA-Z0-9_]+)\}", RegexOptions.Compiled);
|
||||
|
||||
private static readonly HashSet<string> ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Removes Unity rich text tags (such as <color=...>, <b>, etc.) from the given text.
|
||||
/// </summary>
|
||||
public static string StripRichText(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return string.Empty;
|
||||
return RichTextRegex.Replace(input, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces invalid file name characters within a single token with an underscore.
|
||||
/// </summary>
|
||||
public static string SanitizeToken(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return string.Empty;
|
||||
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var sb = new StringBuilder(token.Length);
|
||||
foreach (var c in token)
|
||||
{
|
||||
sb.Append(invalidChars.Contains(c) ? '_' : c);
|
||||
}
|
||||
|
||||
return sb.ToString().Trim(' ', '.');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes the final formatted file name, ensuring no illegal characters or Windows reserved device names.
|
||||
/// </summary>
|
||||
public static string SanitizeFileName(string? fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return string.Empty;
|
||||
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var sb = new StringBuilder(fileName.Length);
|
||||
foreach (var c in fileName)
|
||||
{
|
||||
sb.Append(invalidChars.Contains(c) ? '_' : c);
|
||||
}
|
||||
|
||||
var result = sb.ToString().Trim(' ', '.');
|
||||
if (ReservedWindowsNames.Contains(result))
|
||||
{
|
||||
result = "_" + result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a file name without extension by substituting template variables.
|
||||
/// Available tokens: {song}, {title}, {artist}, {author}, {level}, {preset},
|
||||
/// {date}, {time}, {year}, {month}, {day}, {hour}, {minute}, {second},
|
||||
/// {resolution}, {width}, {height}, {fps}.
|
||||
/// </summary>
|
||||
public static string FormatFileName(
|
||||
string? formatTemplate,
|
||||
DateTime startTime,
|
||||
RenderConfig config,
|
||||
string? presetName = null,
|
||||
string? levelPath = null)
|
||||
{
|
||||
var scn = scnGame.instance;
|
||||
string rawSong = "";
|
||||
string rawArtist = "";
|
||||
string rawAuthor = "";
|
||||
|
||||
if (scn != null && scn.levelData != null)
|
||||
{
|
||||
rawSong = scn.levelData.song ?? "";
|
||||
rawArtist = scn.levelData.artist ?? "";
|
||||
rawAuthor = scn.levelData.author ?? "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawSong) && !string.IsNullOrWhiteSpace(levelPath))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(levelPath);
|
||||
rawSong = !string.IsNullOrWhiteSpace(dir)
|
||||
? Path.GetFileName(dir)
|
||||
: Path.GetFileNameWithoutExtension(levelPath);
|
||||
}
|
||||
|
||||
var song = SanitizeToken(StripRichText(rawSong));
|
||||
var artist = SanitizeToken(StripRichText(rawArtist));
|
||||
var author = SanitizeToken(StripRichText(rawAuthor));
|
||||
var level = !string.IsNullOrWhiteSpace(levelPath)
|
||||
? SanitizeToken(Path.GetFileNameWithoutExtension(levelPath))
|
||||
: song;
|
||||
var preset = SanitizeToken(presetName ?? "");
|
||||
|
||||
var date = startTime.ToString("yyyy-MM-dd");
|
||||
var time = startTime.ToString("HH-mm-ss");
|
||||
var year = startTime.ToString("yyyy");
|
||||
var month = startTime.ToString("MM");
|
||||
var day = startTime.ToString("dd");
|
||||
var hour = startTime.ToString("HH");
|
||||
var minute = startTime.ToString("mm");
|
||||
var second = startTime.ToString("ss");
|
||||
|
||||
var width = config.RenderWidth.ToString();
|
||||
var height = config.RenderHeight.ToString();
|
||||
var resolution = $"{config.RenderWidth}x{config.RenderHeight}";
|
||||
|
||||
var effectiveFps = Math.Min(config.VideoFPS, config.RenderFPS);
|
||||
var fps = Math.Abs(effectiveFps % 1) < 0.001
|
||||
? ((int)effectiveFps).ToString()
|
||||
: effectiveFps.ToString("0.##");
|
||||
|
||||
var tokens = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "song", song },
|
||||
{ "title", song },
|
||||
{ "artist", artist },
|
||||
{ "author", author },
|
||||
{ "level", level },
|
||||
{ "preset", preset },
|
||||
{ "date", date },
|
||||
{ "time", time },
|
||||
{ "year", year },
|
||||
{ "month", month },
|
||||
{ "day", day },
|
||||
{ "hour", hour },
|
||||
{ "minute", minute },
|
||||
{ "second", second },
|
||||
{ "width", width },
|
||||
{ "height", height },
|
||||
{ "resolution", resolution },
|
||||
{ "fps", fps }
|
||||
};
|
||||
|
||||
var template = string.IsNullOrWhiteSpace(formatTemplate)
|
||||
? GlobalSettings.DefaultFileNameFormat
|
||||
: formatTemplate;
|
||||
|
||||
var replaced = TokenRegex.Replace(template, match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
return tokens.TryGetValue(key, out var val) ? val : match.Value;
|
||||
});
|
||||
|
||||
var sanitized = SanitizeFileName(replaced);
|
||||
return string.IsNullOrWhiteSpace(sanitized)
|
||||
? startTime.ToString("yyyy-MM-dd HH-mm-ss")
|
||||
: sanitized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a collision-free output file path by appending (1), (2), etc. if a file with the given name already exists.
|
||||
/// </summary>
|
||||
public static string GetUniqueFilePath(string directory, string fileNameWithoutExt, string ext)
|
||||
{
|
||||
var normalizedExt = "." + ext.TrimStart('.').ToLowerInvariant();
|
||||
var fileName = $"{fileNameWithoutExt}{normalizedExt}";
|
||||
var fullPath = Path.Combine(directory, fileName);
|
||||
|
||||
var counter = 1;
|
||||
while (File.Exists(fullPath))
|
||||
{
|
||||
fileName = $"{fileNameWithoutExt} ({counter}){normalizedExt}";
|
||||
fullPath = Path.Combine(directory, fileName);
|
||||
counter++;
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user