19 changed files with 3555 additions and 993 deletions

No files matched your search

+302
View File
@@ -0,0 +1,302 @@
using System;
using System.Diagnostics.CodeAnalysis;
using UnityEngine;
using UnityEngine.Rendering;
namespace ADOFAIRenderer.API;
/// <summary>
/// Execution context provided to frame modification hooks, managing GPU ping-pong double buffering.
/// </summary>
/// <remarks>
/// <para>
/// <b>Zero-Allocation Notice:</b> For maximum performance on the render hot path, the pipeline reuses context instances
/// across frames. Do not cache the context instance itself across frames.
/// </para>
/// <para>
/// <b>Execution Frequency Notice:</b> This hook is executed only when a frame is captured for video encoding.
/// When <c>RenderFPS</c> exceeds <c>VideoFPS</c>, intermediate game rendering frames are skipped and the hook
/// will <b>not</b> be invoked on every rendered game frame.
/// </para>
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
public class FrameHookContext
{
private RenderTexture _originalSource = null!;
private RenderTexture _pingRT = null!;
private RenderTexture _pongRT = null!;
private RenderTexture _currentSource = null!;
private RenderTexture _currentDestination = null!;
private bool _hasModified;
/// <summary>
/// The input <see cref="RenderTexture"/> containing the rendered frame before the current pass.
/// </summary>
public RenderTexture Source => _currentSource;
/// <summary>
/// The writable destination <see cref="RenderTexture"/> for the current pass.
/// </summary>
public RenderTexture Destination => _currentDestination;
/// <summary>
/// The active <see cref="RenderTexture"/> representing the current state of the modified frame.
/// </summary>
public RenderTexture Current => _currentSource;
/// <summary>
/// The original unmodified render texture captured from the game for the current frame before any hooks were applied.
/// </summary>
public RenderTexture OriginalSource => _originalSource;
/// <summary>
/// Indicates whether any modifications or swaps have been applied to this frame.
/// </summary>
public bool HasModified => _hasModified;
/// <summary>
/// The sequential 0-based index of the video frame currently being encoded.
/// </summary>
public long FrameIndex { get; private set; }
/// <summary>
/// Alias for <see cref="FrameIndex"/>. The sequential 0-based index of the video frame.
/// </summary>
public long VideoFrameIndex => FrameIndex;
/// <summary>
/// The sequential 0-based index of the game render frame.
/// </summary>
public long RenderFrameIndex { get; private set; }
/// <summary>
/// The timestamp of the current frame in seconds relative to video start.
/// </summary>
public double VideoTime { get; private set; }
/// <summary>
/// The delta time duration in seconds of a single video frame (equivalent to 1.0 / VideoFPS).
/// </summary>
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
/// <summary>
/// The width of the frame in pixels.
/// </summary>
public int Width => _currentSource.width;
/// <summary>
/// The height of the frame in pixels.
/// </summary>
public int Height => _currentSource.height;
/// <summary>
/// The configured render frames per second.
/// </summary>
public double RenderFPS { get; private set; }
/// <summary>
/// The output video frames per second.
/// </summary>
public double VideoFPS { get; private set; }
/// <summary>
/// The number of successful modification passes completed for the current frame.
/// </summary>
public int PassCount { get; private set; }
internal FrameHookContext(
RenderTexture originalSource,
RenderTexture pingRT,
RenderTexture pongRT,
RenderTexture firstDestination,
long frameIndex,
long renderFrameIndex,
double videoTime,
double renderFps,
double videoFps)
{
Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps);
}
internal void Reset(
RenderTexture originalSource,
RenderTexture pingRT,
RenderTexture pongRT,
RenderTexture firstDestination,
long frameIndex,
long renderFrameIndex,
double videoTime,
double renderFps,
double videoFps)
{
_originalSource = originalSource;
_pingRT = pingRT;
_pongRT = pongRT;
_currentSource = originalSource;
_currentDestination = firstDestination;
_hasModified = false;
PassCount = 0;
FrameIndex = frameIndex;
RenderFrameIndex = renderFrameIndex;
VideoTime = videoTime;
RenderFPS = renderFps;
VideoFPS = videoFps;
}
/// <summary>
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
/// </summary>
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
public void Blit(Material? material = null, int pass = -1)
{
if (material != null)
{
if (pass >= 0)
Graphics.Blit(_currentSource, _currentDestination, material, pass);
else
Graphics.Blit(_currentSource, _currentDestination, material);
}
else
{
Graphics.Blit(_currentSource, _currentDestination);
}
Swap();
}
/// <summary>
/// Blits from a custom source <see cref="Texture"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
/// </summary>
/// <param name="customSource">The custom source texture to blit from.</param>
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customSource"/> is null.</exception>
public void Blit(Texture customSource, Material? material = null, int pass = -1)
{
if (customSource == null) throw new ArgumentNullException(nameof(customSource));
if (material != null)
{
if (pass >= 0)
Graphics.Blit(customSource, _currentDestination, material, pass);
else
Graphics.Blit(customSource, _currentDestination, material);
}
else
{
Graphics.Blit(customSource, _currentDestination);
}
Swap();
}
/// <summary>
/// Copies the contents of <see cref="Source"/> into <see cref="Destination"/> without swapping buffers.
/// Call this before drawing custom overlays into <see cref="Destination"/> if you wish to preserve the background.
/// </summary>
public void Copy()
{
Graphics.Blit(_currentSource, _currentDestination);
}
/// <summary>
/// Clears <see cref="Destination"/> with the specified color.
/// </summary>
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
public void ClearDestination(Color? clearColor = null)
{
var prev = RenderTexture.active;
try
{
RenderTexture.active = _currentDestination;
GL.Clear(false, true, clearColor ?? Color.clear);
}
finally
{
RenderTexture.active = prev;
}
}
/// <summary>
/// Temporarily activates <see cref="Destination"/> as <see cref="RenderTexture.active"/> to perform custom draw calls,
/// safely restoring the previous active RenderTexture afterwards.
/// </summary>
/// <param name="drawAction">The action that performs custom draw operations.</param>
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after drawing.</param>
/// <param name="preserveBackground">If <c>true</c>, copies <see cref="Source"/> into <see cref="Destination"/> before executing <paramref name="drawAction"/>.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="drawAction"/> is null.</exception>
public void DrawToDestination(Action drawAction, bool autoSwap = true, bool preserveBackground = false)
{
if (drawAction == null) throw new ArgumentNullException(nameof(drawAction));
if (preserveBackground)
{
Copy();
}
var prev = RenderTexture.active;
try
{
RenderTexture.active = _currentDestination;
drawAction();
}
finally
{
RenderTexture.active = prev;
}
if (autoSwap)
{
Swap();
}
}
/// <summary>
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
/// </summary>
/// <param name="commandBuffer">The command buffer to execute.</param>
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after execution.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="commandBuffer"/> is null.</exception>
public void ExecuteCommandBuffer(CommandBuffer commandBuffer, bool autoSwap = true)
{
if (commandBuffer == null) throw new ArgumentNullException(nameof(commandBuffer));
Graphics.ExecuteCommandBuffer(commandBuffer);
if (autoSwap)
{
Swap();
}
}
/// <summary>
/// Swaps the active <see cref="Source"/> and <see cref="Destination"/> ping-pong buffers.
/// Call this after manually writing or rendering into <see cref="Destination"/> so that subsequent hooks or the video encoder read the updated frame.
/// </summary>
public void Swap()
{
_hasModified = true;
PassCount++;
var previousDestination = _currentDestination;
_currentSource = previousDestination;
_currentDestination = (previousDestination == _pingRT) ? _pongRT : _pingRT;
}
/// <summary>
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
/// </summary>
/// <param name="customTexture">The custom texture to set as the active frame.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
public void ReplaceCurrent(RenderTexture customTexture)
{
_currentSource = customTexture ?? throw new ArgumentNullException(nameof(customTexture));
_hasModified = true;
PassCount++;
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.API;
/// <summary>
/// Represents a custom frame modification hook that can modify rendered frames using GPU ping-pong double buffering.
/// </summary>
/// <remarks>
/// <b>Execution Frequency Note:</b> Frame hooks are only invoked for frames that are actually encoded into the final video (<c>VideoFPS</c>).
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped and this hook
/// will <b>not</b> be called on every game frame.
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
public interface IFrameHook
{
/// <summary>
/// Invoked for each captured video frame before it is encoded.
/// </summary>
/// <remarks>
/// This method is not called on every rendered game frame if <c>RenderFPS</c> exceeds <c>VideoFPS</c>.
/// </remarks>
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
void OnRenderFrame(FrameHookContext context);
}
+375
View File
@@ -1,9 +1,11 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data; using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util; using ADOFAIRenderer.Util;
using UnityEngine;
namespace ADOFAIRenderer.API; namespace ADOFAIRenderer.API;
@@ -11,8 +13,47 @@ namespace ADOFAIRenderer.API;
/// Fluent builder for configuring and starting a render session. /// Fluent builder for configuring and starting a render session.
/// </summary> /// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global")] [SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderSession public class RenderSession
{ {
private sealed class GlobalHookRegistration : IDisposable
{
public object Key { get; }
public Action<FrameHookContext> Action { get; }
private bool _isDisposed;
public GlobalHookRegistration(object key, Action<FrameHookContext> action)
{
Key = key;
Action = action;
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
RemoveGlobalRegistration(this);
}
}
private sealed class SessionHookEntry
{
public object Key { get; }
public Action<FrameHookContext> Action { get; }
public SessionHookEntry(object key, Action<FrameHookContext> action)
{
Key = key;
Action = action;
}
}
private static readonly List<GlobalHookRegistration> GlobalFrameHooks = new();
private static readonly object GlobalHooksLock = new();
private static int _globalHooksVersion;
private readonly List<SessionHookEntry> _frameHooks = new();
private bool _auto = true; private bool _auto = true;
/// <summary> /// <summary>
@@ -126,6 +167,331 @@ public class RenderSession
return this; return this;
} }
/// <summary>
/// Adds a custom frame modification hook to be executed for each encoded video frame.
/// The hook can modify the rendered frame using GPU ping-pong double buffering.
/// </summary>
/// <remarks>
/// <b>Execution Frequency Note:</b> This hook is invoked only for frames that are captured for the output video.
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
/// so this hook will <b>not</b> be called on every game frame.
/// </remarks>
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
public RenderSession AddFrameHook(Action<FrameHookContext> hook)
{
if (hook == null) throw new ArgumentNullException(nameof(hook));
_frameHooks.Add(new SessionHookEntry(hook, hook));
return this;
}
/// <summary>
/// Adds a material-based post-processing pass to be executed for each encoded video frame.
/// Automatically performs a GPU blit from source to destination and swaps buffers.
/// </summary>
/// <param name="material">The material containing the shader to apply.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="material"/> is null.</exception>
public RenderSession AddFrameHook(Material material, int pass = -1)
{
if (material is null) throw new ArgumentNullException(nameof(material));
_frameHooks.Add(new SessionHookEntry(material, context => context.Blit(material, pass)));
return this;
}
/// <summary>
/// Adds a custom frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <param name="hook">The hook instance to execute.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
public RenderSession AddFrameHook(IFrameHook hook)
{
if (hook == null) throw new ArgumentNullException(nameof(hook));
_frameHooks.Add(new SessionHookEntry(hook, hook.OnRenderFrame));
return this;
}
/// <summary>
/// Alias for <see cref="AddFrameHook(Action{FrameHookContext})"/>.
/// </summary>
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithFrameHook(Action<FrameHookContext> hook) => AddFrameHook(hook);
/// <summary>
/// Alias for <see cref="AddFrameHook(Material, int)"/>.
/// </summary>
/// <param name="material">The material containing the shader to apply.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithFrameHook(Material material, int pass = -1) => AddFrameHook(material, pass);
/// <summary>
/// Alias for <see cref="AddFrameHook(IFrameHook)"/>.
/// </summary>
/// <param name="hook">The hook instance to execute.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithFrameHook(IFrameHook hook) => AddFrameHook(hook);
/// <summary>
/// Removes a previously added frame modification hook callback from this session.
/// </summary>
/// <param name="hook">The hook action to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(Action<FrameHookContext> hook)
{
var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook) || Equals(e.Action, hook));
if (idx >= 0) _frameHooks.RemoveAt(idx);
return this;
}
/// <summary>
/// Removes a previously added material-based post-processing pass from this session.
/// </summary>
/// <param name="material">The material to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(Material material)
{
if (material)
{
for (var i = _frameHooks.Count - 1; i >= 0; i--)
{
if (ReferenceEquals(_frameHooks[i].Key, material))
{
_frameHooks.RemoveAt(i);
}
}
}
return this;
}
/// <summary>
/// Removes a previously added <see cref="IFrameHook"/> from this session.
/// </summary>
/// <param name="hook">The hook instance to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(IFrameHook hook)
{
var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook));
if (idx >= 0) _frameHooks.RemoveAt(idx);
return this;
}
/// <summary>
/// Clears all frame modification hooks configured for this render session.
/// </summary>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession ClearFrameHooks()
{
_frameHooks.Clear();
return this;
}
/// <summary>
/// Registers a global frame modification hook that will execute on every render session.
/// </summary>
/// <remarks>
/// <b>Execution Frequency Note:</b> Global frame hooks are executed only for frames that are captured for the output video.
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
/// so this hook will <b>not</b> be called on every game frame.
/// </remarks>
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
public static IDisposable AddGlobalFrameHook(Action<FrameHookContext> hook)
{
if (hook == null) throw new ArgumentNullException(nameof(hook));
var reg = new GlobalHookRegistration(hook, hook);
lock (GlobalHooksLock)
{
GlobalFrameHooks.Add(reg);
_globalHooksVersion++;
}
return reg;
}
/// <summary>
/// Registers a global material-based post-processing pass that will execute on every render session.
/// </summary>
/// <param name="material">The material containing the shader to apply.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="material"/> is null.</exception>
public static IDisposable AddGlobalFrameHook(Material material, int pass = -1)
{
if (material is null) throw new ArgumentNullException(nameof(material));
var reg = new GlobalHookRegistration(material, context => context.Blit(material, pass));
lock (GlobalHooksLock)
{
GlobalFrameHooks.Add(reg);
_globalHooksVersion++;
}
return reg;
}
/// <summary>
/// Registers a global frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <param name="hook">The hook instance to execute.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
public static IDisposable AddGlobalFrameHook(IFrameHook hook)
{
if (hook == null) throw new ArgumentNullException(nameof(hook));
var reg = new GlobalHookRegistration(hook, hook.OnRenderFrame);
lock (GlobalHooksLock)
{
GlobalFrameHooks.Add(reg);
_globalHooksVersion++;
}
return reg;
}
/// <summary>
/// Unregisters a previously registered global frame modification hook.
/// </summary>
/// <param name="hook">The hook action to remove.</param>
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(Action<FrameHookContext> hook)
{
lock (GlobalHooksLock)
{
var idx = GlobalFrameHooks.FindIndex(r => ReferenceEquals(r.Key, hook) || Equals(r.Action, hook));
if (idx >= 0)
{
GlobalFrameHooks.RemoveAt(idx);
_globalHooksVersion++;
return true;
}
return false;
}
}
/// <summary>
/// Unregisters a previously registered global material-based post-processing pass.
/// </summary>
/// <param name="material">The material to remove.</param>
/// <returns><c>true</c> if any hooks matching <paramref name="material"/> were removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(Material material)
{
lock (GlobalHooksLock)
{
var removed = false;
for (var i = GlobalFrameHooks.Count - 1; i >= 0; i--)
{
if (ReferenceEquals(GlobalFrameHooks[i].Key, material))
{
GlobalFrameHooks.RemoveAt(i);
removed = true;
}
}
if (removed)
{
_globalHooksVersion++;
}
return removed;
}
}
/// <summary>
/// Unregisters a previously registered global frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <param name="hook">The hook instance to remove.</param>
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(IFrameHook hook)
{
lock (GlobalHooksLock)
{
var idx = GlobalFrameHooks.FindIndex(r => ReferenceEquals(r.Key, hook));
if (idx >= 0)
{
GlobalFrameHooks.RemoveAt(idx);
_globalHooksVersion++;
return true;
}
return false;
}
}
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
{
lock (GlobalHooksLock)
{
if (GlobalFrameHooks.Remove(registration))
{
_globalHooksVersion++;
}
}
}
/// <summary>
/// Clears all registered global frame modification hooks.
/// </summary>
public static void ClearGlobalFrameHooks()
{
lock (GlobalHooksLock)
{
if (GlobalFrameHooks.Count > 0)
{
GlobalFrameHooks.Clear();
_globalHooksVersion++;
}
}
}
/// <summary>
/// Indicates whether any global frame modification hooks are currently registered.
/// </summary>
public static bool HasGlobalFrameHooks
{
get
{
lock (GlobalHooksLock)
{
return GlobalFrameHooks.Count > 0;
}
}
}
/// <summary>
/// The number of currently registered global frame modification hooks.
/// </summary>
public static int GlobalFrameHookCount
{
get
{
lock (GlobalHooksLock)
{
return GlobalFrameHooks.Count;
}
}
}
internal static int GlobalHooksVersion
{
get
{
lock (GlobalHooksLock)
{
return _globalHooksVersion;
}
}
}
internal static void CopyGlobalFrameHooksTo(List<Action<FrameHookContext>> destination)
{
lock (GlobalHooksLock)
{
for (var i = 0; i < GlobalFrameHooks.Count; i++)
{
destination.Add(GlobalFrameHooks[i].Action);
}
}
}
/// <summary> /// <summary>
/// Applies the configured flags and initiates the render session. /// Applies the configured flags and initiates the render session.
/// </summary> /// </summary>
@@ -143,6 +509,15 @@ public class RenderSession
if (_endCondition is not null) RenderContext.EndCondition = _endCondition; if (_endCondition is not null) RenderContext.EndCondition = _endCondition;
if (_onComplete is not null) RenderContext.OnComplete = _onComplete; if (_onComplete is not null) RenderContext.OnComplete = _onComplete;
if (_onLastUpdate is not null) RenderContext.OnLastUpdate = _onLastUpdate; if (_onLastUpdate is not null) RenderContext.OnLastUpdate = _onLastUpdate;
RenderContext.SessionFrameHooks.Clear();
if (_frameHooks.Count > 0)
{
foreach (var t in _frameHooks)
{
RenderContext.SessionFrameHooks.Add(t.Action);
}
}
RenderManager.Instance.levelPath = levelPath; RenderManager.Instance.levelPath = levelPath;
RenderManager.Instance.Configuration = renderConfig; RenderManager.Instance.Configuration = renderConfig;
+14 -6
View File
@@ -6,20 +6,28 @@ using Newtonsoft.Json;
namespace ADOFAIRenderer.Core.Data; namespace ADOFAIRenderer.Core.Data;
public class GlobalConfig internal class GlobalConfig
{ {
public string? OutputDirectory { get; set; } public string? OutputDirectory { get; set; }
public string? LastPresetName { get; set; }
public bool DefaultPresetsInitialized { get; set; }
} }
public static class GlobalSettings internal static class GlobalSettings
{ {
private static string SettingsFilePath => private static string GetGameRootDir()
Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "global_settings.json"); {
var root = MelonEnvironment.GameRootDirectory;
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
}
public static GlobalConfig Current { get; set; } = new(); private static string SettingsFilePath =>
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
public static GlobalConfig Current { get; private set; } = new();
public static string DefaultOutputDirectory => public static string DefaultOutputDirectory =>
Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); Path.Combine(GetGameRootDir(), "Render");
public static string EffectiveOutputDirectory public static string EffectiveOutputDirectory
{ {
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using ADOFAIRenderer.API;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Core;
internal class FrameHookPipeline : IDisposable
{
private RenderTexture? _pingRT;
private RenderTexture? _pongRT;
private RenderTexture? _lastOutputRT;
private FrameHookContext? _reusableContext;
private int _width;
private int _height;
private RenderTextureFormat _format;
public RenderTexture? Process(
RenderTexture sourceTexture,
long frameIndex,
long renderFrameIndex,
double videoTime,
double renderFps,
double videoFps,
IReadOnlyList<Action<FrameHookContext>> hooks)
{
if (hooks.Count == 0 || !sourceTexture || !sourceTexture.IsCreated())
return sourceTexture;
EnsureBuffers(sourceTexture);
if (!_pingRT || !_pongRT || !_pingRT.IsCreated() || !_pongRT.IsCreated())
return sourceTexture;
var prevActive = RenderTexture.active;
try
{
// Inter-frame ping-pong safety: start with the buffer that was NOT used as the output
// of the previous frame, ensuring the buffer currently being read back is not immediately overwritten.
var firstDestination = _lastOutputRT == _pingRT ? _pongRT : _pingRT;
if (_reusableContext == null)
{
_reusableContext = new FrameHookContext(
sourceTexture,
_pingRT,
_pongRT,
firstDestination,
frameIndex,
renderFrameIndex,
videoTime,
renderFps,
videoFps
);
}
else
{
_reusableContext.Reset(
sourceTexture,
_pingRT,
_pongRT,
firstDestination,
frameIndex,
renderFrameIndex,
videoTime,
renderFps,
videoFps
);
}
for (var i = 0; i < hooks.Count; i++)
{
try
{
hooks[i](_reusableContext);
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FrameHook] Error executing frame hook #{i} on frame {frameIndex}: {ex}");
}
}
var result = _reusableContext.Current;
_lastOutputRT = _reusableContext.HasModified ? result : null;
return result;
}
finally
{
RenderTexture.active = prevActive;
}
}
private void EnsureBuffers(RenderTexture sourceTexture)
{
var width = sourceTexture.width;
var height = sourceTexture.height;
var format = sourceTexture.format;
if (_pingRT && _pongRT &&
_width == width && _height == height && _format == format &&
_pingRT.IsCreated() && _pongRT.IsCreated())
{
return;
}
ReleaseBuffers();
_width = width;
_height = height;
_format = format;
try
{
var desc = sourceTexture.descriptor;
desc.depthBufferBits = 0;
desc.msaaSamples = 1;
desc.useMipMap = false;
desc.autoGenerateMips = false;
var supportsUav = SystemInfo.supportsComputeShaders &&
SystemInfo.SupportsRandomWriteOnRenderTextureFormat(desc.colorFormat);
desc.enableRandomWrite = supportsUav;
_pingRT = new RenderTexture(desc)
{
name = "ADOFAIRenderer_PingBuffer",
filterMode = sourceTexture.filterMode,
wrapMode = sourceTexture.wrapMode
};
_pingRT.Create();
_pongRT = new RenderTexture(desc)
{
name = "ADOFAIRenderer_PongBuffer",
filterMode = sourceTexture.filterMode,
wrapMode = sourceTexture.wrapMode
};
_pongRT.Create();
}
catch (Exception ex)
{
ReleaseBuffers();
Melon<RenderMod>.Logger.Warning($"[FrameHook] Failed to create descriptor-based PingPong RTs: {ex.Message}. Falling back to standard ARGB32.");
_pingRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
{
name = "ADOFAIRenderer_PingBuffer",
filterMode = sourceTexture.filterMode,
wrapMode = sourceTexture.wrapMode
};
_pingRT.Create();
_pongRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
{
name = "ADOFAIRenderer_PongBuffer",
filterMode = sourceTexture.filterMode,
wrapMode = sourceTexture.wrapMode
};
_pongRT.Create();
}
}
public void ReleaseBuffers()
{
if (_pingRT)
{
if (_pingRT.IsCreated()) _pingRT.Release();
UnityEngine.Object.Destroy(_pingRT);
_pingRT = null;
}
if (_pongRT)
{
if (_pongRT.IsCreated()) _pongRT.Release();
UnityEngine.Object.Destroy(_pongRT);
_pongRT = null;
}
_width = 0;
_height = 0;
_lastOutputRT = null;
_reusableContext = null;
}
public void Dispose()
{
ReleaseBuffers();
}
}
+203 -10
View File
@@ -2,24 +2,118 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using MelonLoader; using MelonLoader;
using MelonLoader.Utils; using MelonLoader.Utils;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Data; namespace ADOFAIRenderer.Core;
internal class RenderPreset public class RenderPreset
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!; public RenderConfig Config { get; set; } = null!;
public RenderPreset Clone()
{
return new RenderPreset
{
Name = Name,
Config = Config.Clone()
};
}
} }
internal static class PresetManager public static class PresetManager
{ {
private static readonly List<RenderPreset> DefaultPresets = new()
{
new RenderPreset
{
Name = "FHD 60fps (1x)",
Config = new RenderConfig
{
RenderWidth = 1920,
RenderHeight = 1080,
RenderFPS = 60,
VideoFPS = 60,
DelayAfterEnd = 15,
ShowJudgementText = false,
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 20000, MaxBitrate = 35000, QP = 18, KeyframeInterval = 2.0 },
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
}
},
new RenderPreset
{
Name = "FHD 60fps (4x)",
Config = new RenderConfig
{
RenderWidth = 1920,
RenderHeight = 1080,
RenderFPS = 240,
VideoFPS = 60,
DelayAfterEnd = 15,
ShowJudgementText = false,
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 30000, MaxBitrate = 50000, QP = 18, KeyframeInterval = 2.0 },
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
}
},
new RenderPreset
{
Name = "QHD 60fps (4x)",
Config = new RenderConfig
{
RenderWidth = 2560,
RenderHeight = 1440,
RenderFPS = 240,
VideoFPS = 60,
DelayAfterEnd = 15,
ShowJudgementText = false,
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 35000, MaxBitrate = 60000, QP = 18, KeyframeInterval = 2.0 },
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
}
},
new RenderPreset
{
Name = "4K 60fps (4x)",
Config = new RenderConfig
{
RenderWidth = 3840,
RenderHeight = 2160,
RenderFPS = 240,
VideoFPS = 60,
DelayAfterEnd = 15,
ShowJudgementText = false,
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 60000, MaxBitrate = 100000, QP = 18, KeyframeInterval = 2.0 },
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
}
}
};
private static string GetPresetsDirectory() private static string GetPresetsDirectory()
{ {
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets"); string? root = null;
try
{
root = MelonEnvironment.GameRootDirectory;
}
catch
{
// Outside MelonLoader host environment (e.g. tests)
}
if (string.IsNullOrEmpty(root))
{
root = AppDomain.CurrentDomain.BaseDirectory;
}
return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets");
} }
private static string GetSafeFileName(string name) private static string GetSafeFileName(string name)
@@ -27,6 +121,45 @@ internal static class PresetManager
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_')); return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
} }
public static void EnsureDefaultPresetsCreated()
{
try
{
var dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
if (!GlobalSettings.Current.DefaultPresetsInitialized)
{
var settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented
};
settings.Converters.Add(new ColorJsonConverter());
foreach (var def in DefaultPresets)
{
var safeName = GetSafeFileName(def.Name);
var filePath = Path.Combine(dir, safeName + ".json");
if (!File.Exists(filePath))
{
var json = JsonConvert.SerializeObject(def, settings);
File.WriteAllText(filePath, json);
}
}
GlobalSettings.Current.DefaultPresetsInitialized = true;
GlobalSettings.Save();
}
}
catch (Exception ex)
{
MelonLogger.Warning($"Error initializing default presets: {ex.Message}");
}
}
public static void SavePreset(string name, RenderConfig config) public static void SavePreset(string name, RenderConfig config)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
@@ -43,10 +176,16 @@ internal static class PresetManager
var preset = new RenderPreset var preset = new RenderPreset
{ {
Name = name, Name = name,
Config = config Config = config.Clone()
}; };
string json = JsonConvert.SerializeObject(preset, Formatting.Indented); var settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented
};
settings.Converters.Add(new ColorJsonConverter());
string json = JsonConvert.SerializeObject(preset, settings);
string safeName = GetSafeFileName(name); string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json"); string filePath = Path.Combine(dir, safeName + ".json");
File.WriteAllText(filePath, json); File.WriteAllText(filePath, json);
@@ -54,14 +193,26 @@ internal static class PresetManager
public static List<RenderPreset> GetPresets() public static List<RenderPreset> GetPresets()
{ {
EnsureDefaultPresetsCreated();
var presets = new List<RenderPreset>(); var presets = new List<RenderPreset>();
var dir = GetPresetsDirectory(); var dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
if (!Directory.Exists(dir)) return presets; {
foreach (var b in DefaultPresets)
{
presets.Add(b.Clone());
}
return presets;
}
try try
{ {
var files = Directory.GetFiles(dir, "*.json"); var files = Directory.GetFiles(dir, "*.json");
var serializerSettings = new JsonSerializerSettings();
serializerSettings.Converters.Add(new ColorJsonConverter());
var serializer = JsonSerializer.Create(serializerSettings);
foreach (var file in files) foreach (var file in files)
{ {
try try
@@ -146,9 +297,20 @@ internal static class PresetManager
} }
} }
var preset = jObject.ToObject<RenderPreset>(); var preset = jObject.ToObject<RenderPreset>(serializer);
if (preset != null && !string.IsNullOrEmpty(preset.Name)) if (preset != null)
{ {
var fileBaseName = Path.GetFileNameWithoutExtension(file);
if (string.IsNullOrWhiteSpace(preset.Name))
{
preset.Name = fileBaseName;
}
if (presets.Any(p => string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
presets.Add(preset); presets.Add(preset);
} }
} }
@@ -177,13 +339,44 @@ internal static class PresetManager
public static void DeletePreset(string name) public static void DeletePreset(string name)
{ {
if (string.IsNullOrWhiteSpace(name)) return;
var dir = GetPresetsDirectory(); var dir = GetPresetsDirectory();
if (!Directory.Exists(dir)) return;
var safeName = GetSafeFileName(name); var safeName = GetSafeFileName(name);
var filePath = Path.Combine(dir, safeName + ".json"); var filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath)) if (File.Exists(filePath))
{ {
File.Delete(filePath); File.Delete(filePath);
return;
}
foreach (var file in Directory.GetFiles(dir, "*.json"))
{
try
{
var fileBaseName = Path.GetFileNameWithoutExtension(file);
if (string.Equals(fileBaseName, name, StringComparison.OrdinalIgnoreCase))
{
File.Delete(file);
return;
}
var json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
var pName = (string?)jObject?["Name"];
if (string.Equals(pName, name, StringComparison.OrdinalIgnoreCase))
{
File.Delete(file);
return;
}
}
catch
{
// Ignore read errors during cleanup search
}
} }
} }
} }
+71 -1
View File
@@ -37,6 +37,7 @@ internal static class RenderContext
public static string? OutputPath; public static string? OutputPath;
public static bool Notification = true; public static bool Notification = true;
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won; private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
public static Func<bool> EndCondition = EndConditionDefault; public static Func<bool> EndCondition = EndConditionDefault;
@@ -64,6 +65,7 @@ internal static class RenderContext
OutputPath = null; OutputPath = null;
Notification = true; Notification = true;
SessionFrameHooks.Clear();
EndCondition = EndConditionDefault; EndCondition = EndConditionDefault;
OnComplete = OnCompleteDefault; OnComplete = OnCompleteDefault;
@@ -103,6 +105,49 @@ internal class RenderManager : MonoBehaviour
private AudioSyncCalculator SyncCalculator { get; set; } = null!; private AudioSyncCalculator SyncCalculator { get; set; } = null!;
private FrameReader? _frameReader; private FrameReader? _frameReader;
private FrameHookPipeline? _frameHookPipeline;
private readonly List<Action<FrameHookContext>> _activeHooksCache = new();
private int _cachedGlobalHooksVersion = -1;
private bool _hasActiveHooksCached;
public bool HasActiveFrameHooks
{
get
{
EnsureActiveHooksUpToDate();
return _hasActiveHooksCached;
}
}
private void EnsureActiveHooksUpToDate()
{
var globalVersion = RenderSession.GlobalHooksVersion;
if (_cachedGlobalHooksVersion == globalVersion) return;
_cachedGlobalHooksVersion = globalVersion;
_activeHooksCache.Clear();
if (RenderContext.SessionFrameHooks.Count > 0)
{
_activeHooksCache.AddRange(RenderContext.SessionFrameHooks);
}
RenderSession.CopyGlobalFrameHooksTo(_activeHooksCache);
_hasActiveHooksCached = _activeHooksCache.Count > 0;
}
private List<Action<FrameHookContext>> GetActiveFrameHooks()
{
EnsureActiveHooksUpToDate();
return _activeHooksCache;
}
private void ResetFrameHookState()
{
_frameHookPipeline?.Dispose();
_frameHookPipeline = null;
_activeHooksCache.Clear();
_cachedGlobalHooksVersion = -1;
_hasActiveHooksCached = false;
}
public long frameCountForVideo; public long frameCountForVideo;
public long lastVideoFrameIndex = -1; public long lastVideoFrameIndex = -1;
@@ -160,6 +205,8 @@ internal class RenderManager : MonoBehaviour
AudioSpectrumGenerator.Clear(); AudioSpectrumGenerator.Clear();
ResetFrameHookState();
if (_activeVideoFFmpeg != null) if (_activeVideoFFmpeg != null)
{ {
Melon<RenderMod>.Logger.Msg("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources."); Melon<RenderMod>.Logger.Msg("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources.");
@@ -316,7 +363,23 @@ internal class RenderManager : MonoBehaviour
{ {
lastVideoFrameIndex = videoFrameIndex; lastVideoFrameIndex = videoFrameIndex;
lastRequestedVideoFrameIndex = videoFrameIndex; lastRequestedVideoFrameIndex = videoFrameIndex;
_frameReader?.RequestFrameCapture(texture, videoFrameIndex);
var targetTexture = texture;
if (HasActiveFrameHooks && _frameHookPipeline != null)
{
var videoTime = effectiveVideoFps > 0 ? videoFrameIndex / effectiveVideoFps : 0.0;
targetTexture = _frameHookPipeline.Process(
texture,
videoFrameIndex,
currentFrame,
videoTime,
renderFPS,
effectiveVideoFps,
GetActiveFrameHooks()
) ?? texture;
}
_frameReader?.RequestFrameCapture(targetTexture, videoFrameIndex);
} }
} }
@@ -387,6 +450,10 @@ internal class RenderManager : MonoBehaviour
_frameReader?.Dispose(); _frameReader?.Dispose();
_frameReader = null; _frameReader = null;
_frameHookPipeline?.Dispose();
_frameHookPipeline = new FrameHookPipeline();
_cachedGlobalHooksVersion = -1;
EnsureActiveHooksUpToDate();
try try
{ {
StartFFmpegThread(); StartFFmpegThread();
@@ -395,6 +462,7 @@ internal class RenderManager : MonoBehaviour
catch (Exception ex) catch (Exception ex)
{ {
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}"); Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
ResetFrameHookState();
CommonWarningDialogUI.ShowDialog( CommonWarningDialogUI.ShowDialog(
"Encoder Error", "Encoder Error",
$"Failed to initialize video encoder.\nError: {ex.Message}", $"Failed to initialize video encoder.\nError: {ex.Message}",
@@ -687,6 +755,7 @@ internal class RenderManager : MonoBehaviour
} }
_frameReader?.Dispose(); _frameReader?.Dispose();
ResetFrameHookState();
// Clean up Debug Monitor UI // Clean up Debug Monitor UI
if (_debugMonitorObj) if (_debugMonitorObj)
@@ -776,6 +845,7 @@ internal class RenderManager : MonoBehaviour
} }
_frameReader?.Dispose(); _frameReader?.Dispose();
ResetFrameHookState();
AudioMixer?.Reset(); AudioMixer = null; AudioMixer?.Reset(); AudioMixer = null;
SyncCalculator.Reset(); SyncCalculator.Reset();
+109
View File
@@ -0,0 +1,109 @@
using System;
using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace ADOFAIRenderer.Data;
/// <summary>
/// Converts Unity <see cref="Color"/> and nullable <see cref="Color"/> to and from hex string (#RRGGBBAA).
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
/// </summary>
public class ColorJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return objectType == typeof(Color) || objectType == typeof(Color?);
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value == null)
{
writer.WriteNull();
return;
}
var color = (Color)value;
writer.WriteValue(ToHex(color));
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
if (reader.TokenType == JsonToken.Null)
{
return isNullable ? null : (object)default(Color);
}
if (reader.TokenType == JsonToken.String)
{
var str = (string?)reader.Value;
if (TryParseHex(str, out var parsedColor))
{
return parsedColor;
}
return isNullable ? null : (object)default(Color);
}
if (reader.TokenType == JsonToken.StartObject)
{
var obj = JObject.Load(reader);
float r = (float?)obj["r"] ?? 0f;
float g = (float?)obj["g"] ?? 0f;
float b = (float?)obj["b"] ?? 0f;
float a = (float?)obj["a"] ?? 1f;
return new Color(r, g, b, a);
}
return isNullable ? null : (object)default(Color);
}
public static string ToHex(Color color)
{
byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255);
byte g = (byte)Math.Clamp((int)Math.Round(color.g * 255f), 0, 255);
byte b = (byte)Math.Clamp((int)Math.Round(color.b * 255f), 0, 255);
byte a = (byte)Math.Clamp((int)Math.Round(color.a * 255f), 0, 255);
return $"#{r:X2}{g:X2}{b:X2}{a:X2}";
}
public static bool TryParseHex(string? hex, out Color color)
{
color = default;
if (string.IsNullOrWhiteSpace(hex)) return false;
var str = hex.Trim();
if (str.StartsWith("#"))
{
str = str.Substring(1);
}
if (str.Length == 6)
{
if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) &&
byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) &&
byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
{
color = new Color(r / 255f, g / 255f, b / 255f, 1f);
return true;
}
}
else if (str.Length == 8)
{
if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) &&
byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) &&
byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) &&
byte.TryParse(str.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var a))
{
color = new Color(r / 255f, g / 255f, b / 255f, a / 255f);
return true;
}
}
return false;
}
}
+156 -74
View File
@@ -1,74 +1,156 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using UnityEngine; using Newtonsoft.Json;
using UnityEngine;
namespace ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
{ public class RenderConfig
public double RenderFPS { get; set; } {
public double VideoFPS { get; set; } public double RenderFPS { get; set; }
public double VideoFPS { get; set; }
public int RenderWidth { get; set; }
public int RenderHeight { get; set; } public int RenderWidth { get; set; }
public int RenderHeight { get; set; }
public VideoEncodingConfig VideoConfig { get; set; } = new();
public AudioEncodingConfig AudioConfig { get; set; } = new(); public VideoEncodingConfig VideoConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new(); public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } = false; public double DelayAfterEnd { get; set; } = 15;
} public bool ShowJudgementText { get; set; } = false;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] public void CopyFrom(RenderConfig? source)
public class AdvancedConfig {
{ if (source == null) return;
public int? RandomSeed { get; set; } = null; RenderFPS = source.RenderFPS;
public bool ProcessAudio { get; set; } = true; VideoFPS = source.VideoFPS;
public bool NonTile { get; set; } = false; RenderWidth = source.RenderWidth;
public string FileExtension { get; set; } = "mkv"; RenderHeight = source.RenderHeight;
DelayAfterEnd = source.DelayAfterEnd;
public Color? firePlanetColor = null; ShowJudgementText = source.ShowJudgementText;
public Color? icePlanetColor = null;
public Color? windPlanetColor = null; VideoConfig.CopyFrom(source.VideoConfig);
} AudioConfig.CopyFrom(source.AudioConfig);
AdvancedConfig.CopyFrom(source.AdvancedConfig);
public class VideoEncodingConfig }
{
public Codec Codec { get; set; } = Codec.H264; public RenderConfig Clone()
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR; {
var clone = new RenderConfig();
public int Bitrate { get; set; } = 30000; clone.CopyFrom(this);
public int MaxBitrate { get; set; } = 50000; return clone;
// ReSharper disable once InconsistentNaming }
public int QP { get; set; } = 18; }
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; } [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
} public class AdvancedConfig
{
public class AudioEncodingConfig public int? RandomSeed { get; set; } = null;
{ public bool ProcessAudio { get; set; } = true;
private int _audioSampleRate = 48000; public bool NonTile { get; set; } = false;
private int _audioChannels = 2; public string FileExtension { get; set; } = "mkv";
private int _audioBitrate = 256000;
[JsonConverter(typeof(ColorJsonConverter))]
public bool RenderAudio { get; set; } = true; public Color? firePlanetColor = null;
public int AudioSampleRate [JsonConverter(typeof(ColorJsonConverter))]
{ public Color? icePlanetColor = null;
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value; [JsonConverter(typeof(ColorJsonConverter))]
} public Color? windPlanetColor = null;
public int AudioChannels public void CopyFrom(AdvancedConfig? source)
{ {
get => _audioChannels <= 0 ? 2 : _audioChannels; if (source == null) return;
set => _audioChannels = value <= 0 ? 2 : value; RandomSeed = source.RandomSeed;
} ProcessAudio = source.ProcessAudio;
NonTile = source.NonTile;
public int AudioBitrate FileExtension = source.FileExtension;
{ firePlanetColor = source.firePlanetColor;
get => _audioBitrate <= 0 ? 256000 : _audioBitrate; icePlanetColor = source.icePlanetColor;
set => _audioBitrate = value <= 0 ? 256000 : value; windPlanetColor = source.windPlanetColor;
} }
}
public AdvancedConfig Clone()
{
var clone = new AdvancedConfig();
clone.CopyFrom(this);
return clone;
}
}
public class VideoEncodingConfig
{
public Codec Codec { get; set; } = Codec.H264;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
public int Bitrate { get; set; } = 30000;
public int MaxBitrate { get; set; } = 50000;
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; }
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;
}
public VideoEncodingConfig Clone()
{
var clone = new VideoEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 48000;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
public bool RenderAudio { get; set; } = true;
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
public void CopyFrom(AudioEncodingConfig? source)
{
if (source == null) return;
RenderAudio = source.RenderAudio;
AudioSampleRate = source.AudioSampleRate;
AudioChannels = source.AudioChannels;
AudioBitrate = source.AudioBitrate;
}
public AudioEncodingConfig Clone()
{
var clone = new AudioEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}␍
+14
View File
@@ -1,5 +1,19 @@
{ {
"ui.render_config.window_title": "Render Config", "ui.render_config.window_title": "Render Config",
"ui.render_config.presets": "Presets",
"ui.render_config.preset_select": "Preset",
"ui.render_config.preset_name_placeholder": "New preset name...",
"ui.render_config.preset_save": "Save",
"ui.render_config.preset_delete": "Delete",
"ui.render_config.preset_delete_confirm": "Confirm?",
"ui.render_config.preset_overwrite_title": "Overwrite Preset",
"ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?",
"ui.render_config.preset_delete_title": "Delete Preset",
"ui.render_config.preset_delete_desc": "Are you sure you want to delete preset '{0}'?",
"ui.render_config.preset_name_empty": "Please enter a preset name.",
"ui.render_config.preset_custom": "(Modified)",
"ui.render_config.confirm": "Confirm",
"ui.render_config.cancel": "Cancel",
"ui.render_config.render_fps": "Render FPS", "ui.render_config.render_fps": "Render FPS",
"ui.render_config.video_fps": "Video FPS", "ui.render_config.video_fps": "Video FPS",
"ui.render_config.render_width": "Render Width", "ui.render_config.render_width": "Render Width",
+19 -5
View File
@@ -1,16 +1,30 @@
{ {
"ui.render_config.window_title": "렌더 설정", "ui.render_config.window_title": "렌더 설정",
"ui.render_config.presets": "프리셋",
"ui.render_config.preset_select": "프리셋 선택",
"ui.render_config.preset_name_placeholder": "새 프리셋 이름...",
"ui.render_config.preset_save": "저장",
"ui.render_config.preset_delete": "삭제",
"ui.render_config.preset_delete_confirm": "삭제 확인",
"ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기",
"ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?",
"ui.render_config.preset_delete_title": "프리셋 삭제",
"ui.render_config.preset_delete_desc": "'{0}' 프리셋을 정말 삭제하시겠습니까?",
"ui.render_config.preset_name_empty": "프리셋 이름을 입력해 주세요.",
"ui.render_config.preset_custom": "(수정됨)",
"ui.render_config.confirm": "확인",
"ui.render_config.cancel": "취소",
"ui.render_config.render_fps": "렌더링 FPS", "ui.render_config.render_fps": "렌더링 FPS",
"ui.render_config.video_fps": "영상 FPS", "ui.render_config.video_fps": "영상 FPS",
"ui.render_config.render_width": "렌더링 너비", "ui.render_config.render_width": "렌더링 너비",
"ui.render_config.render_height": "렌더링 높이", "ui.render_config.render_height": "렌더링 높이",
"ui.render_config.delay_after_end": "종료 후 대기 시간 (초)", "ui.render_config.delay_after_end": "종료 후 대기 시간 (초)",
"ui.render_config.show_judgement": "판정 텍스트 표시", "ui.render_config.show_judgement": "판정 텍스트 표시",
"ui.render_config.video_encoding": "Video Encoding Config", "ui.render_config.video_encoding": "비디오 인코딩 설정",
"ui.render_config.audio_encoding": "Audio Encoding Config", "ui.render_config.audio_encoding": "오디오 인코딩 설정",
"ui.render_config.global_settings": "Global Settings", "ui.render_config.global_settings": "전역 설정",
"ui.render_config.advanced_settings": "Advanced Settings", "ui.render_config.advanced_settings": "고급 설정",
"ui.render_config.go_render": "렌더 시작", "ui.render_config.go_render": "렌더링하러 가다",
"ui.render_config.codec": "코덱", "ui.render_config.codec": "코덱",
"ui.render_config.rate_control": "비트레이트 제어 모드", "ui.render_config.rate_control": "비트레이트 제어 모드",
"ui.render_config.bitrate": "비트레이트 (kbps)", "ui.render_config.bitrate": "비트레이트 (kbps)",
+2
View File
@@ -13,6 +13,8 @@
By decoupling the game's simulation timestep from wall-clock time, ADOFAI Renderer enables creators to produce smooth, ultra-high-definition game videos with perfect audio sync, regardless of hardware limitations or level complexity. By decoupling the game's simulation timestep from wall-clock time, ADOFAI Renderer enables creators to produce smooth, ultra-high-definition game videos with perfect audio sync, regardless of hardware limitations or level complexity.
> Generative AI technology was partially utilized in the development of this mod.
--- ---
## ✨ Key Features ## ✨ Key Features
+1 -1
View File
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
using UnityEngine; using UnityEngine;
using Object = UnityEngine.Object; using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.3-INDEV", "ADOFAI Editor Forum")] [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.6-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")] [assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer; namespace ADOFAIRenderer;
+94 -168
View File
@@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using MelonLoader;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using UnityEngine.UIElements; using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button;
using Image = UnityEngine.UI.Image; using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI; namespace ADOFAIRenderer.UI;
@@ -29,6 +31,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
{ {
public static CommonWarningDialogUI? Instance { get; private set; } public static CommonWarningDialogUI? Instance { get; private set; }
private readonly List<VisualElement> _disabledElements = new(); private readonly List<VisualElement> _disabledElements = new();
private O5Window? _window;
private void Awake() private void Awake()
{ {
@@ -37,6 +40,9 @@ internal class CommonWarningDialogUI : MonoBehaviour
private void OnDestroy() private void OnDestroy()
{ {
_window?.Dispose();
_window = null;
foreach (var elem in _disabledElements) foreach (var elem in _disabledElements)
{ {
if (elem != null) if (elem != null)
@@ -83,202 +89,122 @@ internal class CommonWarningDialogUI : MonoBehaviour
// Set UI layer (5) // Set UI layer (5)
gameObject.layer = 5; gameObject.layer = 5;
// Canvas setup // Canvas setup
var canvas = gameObject.AddComponent<Canvas>(); var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767) canvas.sortingOrder = 32766;
// Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>(); var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080); scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f; scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>(); gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background var ctx = RendererUIManager.Context;
var bgPanelObj = new GameObject("BackgroundPanel");
// 1. Create full screen backdrop (scrim) to block background input
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false); bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5; bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>(); var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay bgImage.color = new Color(0f, 0f, 0f, 0.65f);
bgImage.raycastTarget = true; bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>(); var bgRect = bgPanelObj.GetComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero; bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one; bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = Vector2.zero; bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero; bgRect.offsetMax = Vector2.zero;
var font = ModAssets.NanumSquareNeoNormal; // 2. Create actual native O5Window
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
// 2. Create modal container {
var modalContainerObj = new GameObject("ModalContainer"); Id = "RendererMod_WarningDialog",
modalContainerObj.transform.SetParent(bgPanelObj.transform, false); Title = title,
modalContainerObj.layer = 5; Size = new Vector2(540, 240),
Draggable = true,
var modalImage = modalContainerObj.AddComponent<Image>(); Resizable = false,
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI ShowCloseButton = false,
ShowOutline = true,
var modalOutline = modalContainerObj.AddComponent<Outline>(); Padding = new RectOffset(16, 16, 16, 16)
modalOutline.effectColor = Color.white; });
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
// 3. Build content inside window.Content
var modalRect = modalContainerObj.GetComponent<RectTransform>(); var content = _window.Content;
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f); var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
modalRect.pivot = new Vector2(0.5f, 0.5f); vLayout.spacing = 16f;
modalRect.sizeDelta = new Vector2(650, 360); vLayout.padding = new RectOffset(16, 16, 12, 12);
vLayout.childControlWidth = true;
// 3. Create title text vLayout.childControlHeight = true;
var titleObj = new GameObject("TitleText"); vLayout.childForceExpandWidth = true;
titleObj.transform.SetParent(modalContainerObj.transform, false); vLayout.childForceExpandHeight = false;
titleObj.layer = 5;
// Description text
var titleText = titleObj.AddComponent<TextMeshProUGUI>(); var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
titleText.text = title;
titleText.fontSize = 24;
titleText.font = font;
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 4. Create description text
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
descText.text = description; descText.text = description;
descText.fontSize = 20;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center; descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 8; descText.textWrappingMode = TextWrappingModes.Normal;
descText.lineSpacing = 6;
var descRect = descObj.GetComponent<RectTransform>(); descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120);
// 5. Create button container
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
buttonContainerObj.layer = 5;
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 6. Dynamic button layout and styling // ReSharper disable once InconsistentNaming
float totalWidth = 560f; var descLE = descText.gameObject.AddComponent<LayoutElement>();
float spacing = 15f; descLE.flexibleWidth = 1f;
float buttonHeight = 45f; descLE.flexibleHeight = 1f;
int numButtons = buttons.Length;
float buttonWidth = numButtons == 1 // Button action row
? 200f var btnRowObj = new GameObject("ButtonRow");
: (totalWidth - (spacing * (numButtons - 1))) / numButtons; btnRowObj.transform.SetParent(content, false);
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
btnRowLE.preferredHeight = 40f;
btnRowLE.flexibleWidth = 1f;
float startX = numButtons == 1 var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
? 0 hLayout.spacing = 10f;
: (-totalWidth / 2f) + (buttonWidth / 2f); hLayout.childAlignment = TextAnchor.MiddleCenter;
hLayout.childControlWidth = true;
hLayout.childControlHeight = true;
hLayout.childForceExpandWidth = true;
hLayout.childForceExpandHeight = true;
for (int i = 0; i < numButtons; i++) for (var i = 0; i < buttons.Length; i++)
{ {
var btnData = buttons[i]; var btnData = buttons[i];
var btnObj = new GameObject($"Button_{i}"); var btn = O5Factory.Button(
btnObj.transform.SetParent(buttonContainerObj.transform, false); ctx: ctx,
btnObj.layer = 5; parent: btnRowRect,
onClick: () =>
{
try
{
btnData.OnClick();
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
},
text: btnData.Text,
id: $"btn_dialog_{i}",
height: 40f
);
// Define default colors if (btnData.BackgroundColor.HasValue)
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
var btnImage = btnObj.AddComponent<Image>();
btnImage.color = baseColor;
btnImage.raycastTarget = true;
var btn = btnObj.AddComponent<Button>();
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
// Register click callback
btn.onClick.AddListener(() =>
{ {
try btn.NormalColor = btnData.BackgroundColor.Value;
{ btn.UpdateVisual();
btnData.OnClick(); }
}
catch (Exception ex)
{
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
});
var btnRect = btnObj.GetComponent<RectTransform>();
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
btnRect.pivot = new Vector2(0.5f, 0.5f);
float posX = numButtons == 1
? 0
: startX + i * (buttonWidth + spacing);
btnRect.anchoredPosition = new Vector2(posX, 0);
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
// Add text label var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
var txtObj = new GameObject("Text"); le.flexibleWidth = 1f;
txtObj.transform.SetParent(btnObj.transform, false); le.preferredHeight = 40f;
txtObj.layer = 5;
var text = txtObj.AddComponent<TextMeshProUGUI>();
text.text = btnData.Text;
text.fontSize = 20;
text.font = font;
text.color = Color.white;
text.alignment = TextAlignmentOptions.Center;
var txtRect = txtObj.GetComponent<RectTransform>();
txtRect.anchorMin = Vector2.zero;
txtRect.anchorMax = Vector2.one;
txtRect.offsetMin = Vector2.zero;
txtRect.offsetMax = Vector2.zero;
} }
} }
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
{
button.transition = Selectable.Transition.ColorTint;
var colors = button.colors;
colors.normalColor = normalColor;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
}
private Color ApplyColorBrightness(Color color, float factor)
{
float r = Mathf.Clamp01(color.r * factor);
float g = Mathf.Clamp01(color.g * factor);
float b = Mathf.Clamp01(color.b * factor);
return new Color(r, g, b, color.a);
}
} }
+149 -207
View File
@@ -4,11 +4,13 @@ using System.IO.Compression;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using MelonLoader;
using MelonLoader.Utils;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using MelonLoader;
using MelonLoader.Utils;
namespace ADOFAIRenderer.UI; namespace ADOFAIRenderer.UI;
@@ -22,6 +24,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
private GameObject _statusTextObj = null!; private GameObject _statusTextObj = null!;
private TextMeshProUGUI _statusText = null!; private TextMeshProUGUI _statusText = null!;
private O5Window? _window;
private bool _isDownloading; private bool _isDownloading;
private string DownloadUrl => Application.platform switch private string DownloadUrl => Application.platform switch
{ {
@@ -103,14 +106,16 @@ internal class FFmpegDownloadUI : MonoBehaviour
scaler.matchWidthOrHeight = 0.5f; scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>(); gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background var ctx = RendererUIManager.Context;
var bgPanelObj = new GameObject("BackgroundPanel");
// 1. Create full screen backdrop
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false); bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5; bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>(); var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay bgImage.color = new Color(0f, 0f, 0f, 0.65f);
bgImage.raycastTarget = true; bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>(); var bgRect = bgPanelObj.GetComponent<RectTransform>();
@@ -119,51 +124,31 @@ internal class FFmpegDownloadUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero; bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero; bgRect.offsetMax = Vector2.zero;
var font = ModAssets.NanumSquareNeoNormal; // 2. Create actual native O5Window
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
// 2. Create modal container {
var modalContainerObj = new GameObject("ModalContainer"); Id = "RendererMod_FFmpegDownloadDialog",
modalContainerObj.transform.SetParent(bgPanelObj.transform, false); Title = "FFmpeg Library Installation Required",
modalContainerObj.layer = 5; Size = new Vector2(580, 270),
Draggable = true,
var modalImage = modalContainerObj.AddComponent<Image>(); Resizable = false,
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); ShowCloseButton = false,
ShowOutline = true,
var modalOutline = modalContainerObj.AddComponent<Outline>(); Padding = new RectOffset(16, 16, 16, 16)
modalOutline.effectColor = Color.white; });
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
var content = _window.Content;
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f); var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
modalRect.anchorMax = new Vector2(0.5f, 0.5f); vLayout.spacing = 14f;
modalRect.pivot = new Vector2(0.5f, 0.5f); vLayout.padding = new RectOffset(16, 16, 10, 10);
modalRect.sizeDelta = new Vector2(600, 320); vLayout.childControlWidth = true;
vLayout.childControlHeight = true;
// 4. Create title text vLayout.childForceExpandWidth = true;
var titleObj = new GameObject("TitleText"); vLayout.childForceExpandHeight = false;
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5; // 3. Description text
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = "FFmpeg Library Installation Required";
titleText.fontSize = 26;
titleText.font = font;
titleText.color = Color.white;
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 5. Create description text
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
var descStrBuilder = new StringBuilder(); var descStrBuilder = new StringBuilder();
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video."); descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
if (Application.platform == RuntimePlatform.OSXPlayer) if (Application.platform == RuntimePlatform.OSXPlayer)
@@ -176,197 +161,152 @@ internal class FFmpegDownloadUI : MonoBehaviour
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet."); descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>"); descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
} }
descText.text = descStrBuilder.ToString(); descText.text = descStrBuilder.ToString();
descText.fontSize = 16;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center; descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 10; descText.textWrappingMode = TextWrappingModes.Normal;
descText.lineSpacing = 5;
var descRect = descObj.GetComponent<RectTransform>(); descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -60);
descRect.sizeDelta = new Vector2(-40, 100);
// 6. Create button container
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
_buttonContainerObj.layer = 5;
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 7. Create download button
var downloadBtnObj = new GameObject("DownloadButton");
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
downloadBtnObj.layer = 5;
var downloadImg = downloadBtnObj.AddComponent<Image>();
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
downloadImg.raycastTarget = true;
var downloadBtn = downloadBtnObj.AddComponent<Button>();
SetupButtonStyle(downloadBtn, new Color(0.0f, 0.8f, 1.0f, 1.0f), new Color(0.2f, 0.9f, 1.0f, 1.0f), new Color(0.0f, 0.6f, 0.8f, 1.0f));
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess);
else
{
downloadBtn.onClick.AddListener(OpenFFmpegWebsite);
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }
}
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>(); var descLE = descText.gameObject.AddComponent<LayoutElement>();
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f); descLE.flexibleWidth = 1f;
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f); descLE.flexibleHeight = 1f;
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
downloadBtnRect.anchoredPosition = new Vector2(-15, 0); // 4. Button container
downloadBtnRect.sizeDelta = new Vector2(180, 45); _buttonContainerObj = new GameObject("ButtonContainer");
_buttonContainerObj.transform.SetParent(content, false);
var downloadTextObj = new GameObject("DownloadText"); _buttonContainerObj.layer = 5;
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
downloadTextObj.layer = 5; var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
btnContainerLE.preferredHeight = 40f;
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>(); btnContainerLE.flexibleWidth = 1f;
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
downloadText.fontSize = 20; var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
downloadText.font = font; btnLayout.spacing = 10f;
downloadText.color = Color.white; btnLayout.childAlignment = TextAnchor.MiddleCenter;
downloadText.alignment = TextAlignmentOptions.Center; btnLayout.childControlWidth = true;
btnLayout.childControlHeight = true;
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>(); btnLayout.childForceExpandWidth = true;
downloadTextRect.anchorMin = Vector2.zero; btnLayout.childForceExpandHeight = true;
downloadTextRect.anchorMax = Vector2.one;
downloadTextRect.offsetMin = Vector2.zero; Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
downloadTextRect.offsetMax = Vector2.zero; ? StartDownloadProcess
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
// 8. Create cancel button
var cancelBtnObj = new GameObject("CancelButton"); var downloadBtn = O5Factory.Button(
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false); ctx: ctx,
cancelBtnObj.layer = 5; parent: _buttonContainerObj.transform,
onClick: onDownloadClick,
var cancelImg = cancelBtnObj.AddComponent<Image>(); text: Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website",
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f); id: "btn_ffmpeg_download",
cancelImg.raycastTarget = true; height: 40f
);
var cancelBtn = cancelBtnObj.AddComponent<Button>(); downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
SetupButtonStyle(cancelBtn, new Color(0.2f, 0.2f, 0.23f, 1f), new Color(0.25f, 0.25f, 0.28f, 1f), new Color(0.15f, 0.15f, 0.18f, 1f)); downloadBtn.UpdateVisual();
cancelBtn.onClick.AddListener(CancelProcess);
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>(); downloadBtnLE.flexibleWidth = 1f;
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f); downloadBtnLE.preferredHeight = 40f;
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
cancelBtnRect.pivot = new Vector2(0f, 0.5f); var cancelBtn = O5Factory.Button(
cancelBtnRect.anchoredPosition = new Vector2(15, 0); ctx: ctx,
cancelBtnRect.sizeDelta = new Vector2(180, 45); parent: _buttonContainerObj.transform,
onClick: CancelProcess,
var cancelTextObj = new GameObject("CancelText"); text: "Cancel",
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false); id: "btn_ffmpeg_cancel",
cancelTextObj.layer = 5; height: 40f
);
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>(); var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
cancelText.text = "Cancel"; cancelBtnLE.flexibleWidth = 1f;
cancelText.fontSize = 20; cancelBtnLE.preferredHeight = 40f;
cancelText.font = font;
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f); // 5. Progress bar container
cancelText.alignment = TextAlignmentOptions.Center;
var cancelTextRect = cancelTextObj.GetComponent<RectTransform>();
cancelTextRect.anchorMin = Vector2.zero;
cancelTextRect.anchorMax = Vector2.one;
cancelTextRect.offsetMin = Vector2.zero;
cancelTextRect.offsetMax = Vector2.zero;
// 9. Create progress bar container
_progressBarObj = new GameObject("ProgressBarContainer"); _progressBarObj = new GameObject("ProgressBarContainer");
_progressBarObj.transform.SetParent(modalContainerObj.transform, false); _progressBarObj.transform.SetParent(content, false);
_progressBarObj.layer = 5; _progressBarObj.layer = 5;
_progressBarObj.SetActive(false); _progressBarObj.SetActive(false);
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
pbLE.preferredHeight = 24f;
pbLE.flexibleWidth = 1f;
var barBgImg = _progressBarObj.AddComponent<Image>(); var barBgImg = _progressBarObj.AddComponent<Image>();
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f); barBgImg.sprite = ctx.Sprites.RoundedControl;
barBgImg.type = Image.Type.Sliced;
var barBgOutline = _progressBarObj.AddComponent<Outline>(); barBgImg.color = ctx.Theme.ObjectBG;
barBgOutline.effectColor = Color.white;
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f); var barOutlineObj = new GameObject("BarOutline");
barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
var barRect = _progressBarObj.GetComponent<RectTransform>(); barOutlineObj.layer = 5;
barRect.anchorMin = new Vector2(0.5f, 0);
barRect.anchorMax = new Vector2(0.5f, 0); var barOutlineImg = barOutlineObj.AddComponent<Image>();
barRect.pivot = new Vector2(0.5f, 0); barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
barRect.anchoredPosition = new Vector2(0, 75); barOutlineImg.type = Image.Type.Sliced;
barRect.sizeDelta = new Vector2(480, 24); barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
barOutlineImg.raycastTarget = false;
// 10. Create progress bar fill image
var barOutlineRect = barOutlineObj.GetComponent<RectTransform>();
barOutlineRect.anchorMin = Vector2.zero;
barOutlineRect.anchorMax = Vector2.one;
barOutlineRect.offsetMin = Vector2.zero;
barOutlineRect.offsetMax = Vector2.zero;
// Progress bar fill image
_progressBarFillObj = new GameObject("ProgressBarFill"); _progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false); _progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5; _progressBarFillObj.layer = 5;
var fillImg = _progressBarFillObj.AddComponent<Image>(); var fillImg = _progressBarFillObj.AddComponent<Image>();
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); fillImg.sprite = ctx.Sprites.RoundedControl;
fillImg.type = Image.Type.Sliced;
fillImg.color = ctx.Theme.ObjectActiveBright;
_fillRect = _progressBarFillObj.GetComponent<RectTransform>(); _fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0); _fillRect.anchorMin = new Vector2(0, 0);
_fillRect.anchorMax = new Vector2(0, 1); _fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f); _fillRect.pivot = new Vector2(0, 0.5f);
_fillRect.anchoredPosition = Vector2.zero; _fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = Vector2.zero; _fillRect.sizeDelta = Vector2.zero;
// 11. Create progress text // Progress text
var progressTextObj = new GameObject("ProgressBarText"); var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false); progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5; progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>(); _progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.fontSize = 16; _progressText.fontSize = 13;
_progressText.font = font; _progressText.font = ctx.Fonts.Regular;
_progressText.color = Color.white; _progressText.color = Color.white;
_progressText.alignment = TextAlignmentOptions.Center; _progressText.alignment = TextAlignmentOptions.Center;
_progressText.raycastTarget = false;
var progressTextOutline = progressTextObj.AddComponent<Outline>();
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
progressTextOutline.effectDistance = new Vector2(1, -1);
var progressTextRect = progressTextObj.GetComponent<RectTransform>(); var progressTextRect = progressTextObj.GetComponent<RectTransform>();
progressTextRect.anchorMin = Vector2.zero; progressTextRect.anchorMin = Vector2.zero;
progressTextRect.anchorMax = Vector2.one; progressTextRect.anchorMax = Vector2.one;
progressTextRect.offsetMin = Vector2.zero; progressTextRect.offsetMin = Vector2.zero;
progressTextRect.offsetMax = Vector2.zero; progressTextRect.offsetMax = Vector2.zero;
// 12. Create status text // 6. Status text
_statusTextObj = new GameObject("StatusText"); _statusTextObj = new GameObject("StatusText");
_statusTextObj.transform.SetParent(modalContainerObj.transform, false); _statusTextObj.transform.SetParent(content, false);
_statusTextObj.layer = 5; _statusTextObj.layer = 5;
_statusTextObj.SetActive(false); _statusTextObj.SetActive(false);
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
statusLE.preferredHeight = 22f;
statusLE.flexibleWidth = 1f;
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>(); _statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
_statusText.fontSize = 16; _statusText.fontSize = 14;
_statusText.font = font; _statusText.font = ctx.Fonts.Regular;
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f); _statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
_statusText.alignment = TextAlignmentOptions.Center; _statusText.alignment = TextAlignmentOptions.Center;
_statusText.raycastTarget = false;
var statusRect = _statusTextObj.GetComponent<RectTransform>();
statusRect.anchorMin = new Vector2(0, 0);
statusRect.anchorMax = new Vector2(1, 0);
statusRect.pivot = new Vector2(0.5f, 0);
statusRect.anchoredPosition = new Vector2(0, 35);
statusRect.sizeDelta = new Vector2(0, 30);
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor) var statusRect = _statusTextObj.GetComponent<RectTransform>();
{ statusRect.anchorMin = Vector2.zero;
button.transition = Selectable.Transition.ColorTint; statusRect.anchorMax = Vector2.one;
var colors = button.colors; statusRect.offsetMin = Vector2.zero;
colors.normalColor = normalColor; statusRect.offsetMax = Vector2.zero;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
} }
private void Update() private void Update()
@@ -643,6 +583,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
private void OnDestroy() private void OnDestroy()
{ {
_window?.Dispose();
_window = null;
_cts?.Dispose(); _cts?.Dispose();
if (Instance == this) if (Instance == this)
{ {
+112
View File
@@ -0,0 +1,112 @@
using System;
using ADOFAIRenderer.Localization;
using TMPro;
using UnityEngine;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
/// automatically updating the displayed text whenever the active language changes.
/// </summary>
internal class LocalizedText : MonoBehaviour
{
private TextMeshProUGUI? _textComponent;
private string? _key;
private Func<string>? _dynamicProvider;
/// <summary>
/// Gets or sets the localization key. Setting this will clear any dynamic provider.
/// </summary>
public string? Key
{
get => _key;
set
{
_key = value;
_dynamicProvider = null;
Refresh();
}
}
/// <summary>
/// Sets a dynamic string provider function.
/// </summary>
public void SetDynamic(Func<string> provider)
{
_dynamicProvider = provider;
_key = null;
Refresh();
}
private void Awake()
{
EnsureComponent();
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
}
private void OnEnable()
{
Refresh();
}
private void OnDestroy()
{
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
}
private void HandleLanguageChanged(SystemLanguage language)
{
Refresh();
}
private void EnsureComponent()
{
if (_textComponent == null)
{
_textComponent = GetComponent<TextMeshProUGUI>();
}
}
/// <summary>
/// Refreshes the text using either the dynamic provider or the static localization key.
/// </summary>
public void Refresh()
{
EnsureComponent();
if (_textComponent == null) return;
if (_dynamicProvider != null)
{
_textComponent.text = _dynamicProvider();
}
else if (!string.IsNullOrEmpty(_key))
{
_textComponent.text = LocalizationManager.Get(_key);
}
}
/// <summary>
/// Binds a TextMeshProUGUI component to a localization key.
/// </summary>
public static LocalizedText Bind(TextMeshProUGUI target, string key)
{
if (target == null) throw new ArgumentNullException(nameof(target));
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
component._textComponent = target;
component.Key = key;
return component;
}
/// <summary>
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
/// </summary>
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
{
if (target == null) throw new ArgumentNullException(nameof(target));
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
component._textComponent = target;
component.SetDynamic(provider);
return component;
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+33 -31
View File
@@ -26,12 +26,34 @@ namespace ADOFAIRenderer.Util
private readonly object _poolLock = new(); private readonly object _poolLock = new();
private bool _isInitialized; private bool _isInitialized;
private volatile bool _isDisposed; private volatile bool _isDisposed;
private readonly AVPixelFormat _targetFormat; private readonly ComputeShader? _computeShader;
private readonly int _kernelIndex = -1;
private readonly bool _useGPUConversion;
private readonly AVPixelFormat _expectedFormat;
public FrameReader(AVPixelFormat targetFormat) public FrameReader(AVPixelFormat targetFormat)
{ {
_targetFormat = targetFormat; _computeShader = ModAssets.ColorConversionShader;
if (_computeShader)
{
var kernelName = targetFormat switch
{
AVPixelFormat.AV_PIX_FMT_NV12 => "CSMain_NV12",
AVPixelFormat.AV_PIX_FMT_YUV420P => "CSMain_YUV420P",
_ => null
};
if (kernelName != null)
{
try { _kernelIndex = _computeShader.FindKernel(kernelName); }
catch { _kernelIndex = -1; }
}
}
_useGPUConversion = _kernelIndex >= 0;
_expectedFormat = _useGPUConversion ? targetFormat : AVPixelFormat.AV_PIX_FMT_RGBA;
InitializePool(); InitializePool();
} }
@@ -71,42 +93,22 @@ namespace ADOFAIRenderer.Util
state.IsInFlight = true; state.IsInFlight = true;
} }
// Check if GPU color conversion is available
var expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
var kernelIndex = -1;
if (ModAssets.ColorConversionShader)
{
var kernalName = _targetFormat switch
{
AVPixelFormat.AV_PIX_FMT_NV12 => "CSMain_NV12",
AVPixelFormat.AV_PIX_FMT_YUV420P => "CSMain_YUV420P",
_ => null
};
if (kernalName != null)
kernelIndex = ModAssets.ColorConversionShader.FindKernel(kernalName);
}
var useGPUConversion = kernelIndex >= 0;
if (useGPUConversion) expectedFmt = _targetFormat;
var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel
state.Setup(currentFrame, bufferSize, useGPUConversion, expectedFmt, texture.width, texture.height); state.Setup(currentFrame, bufferSize, _useGPUConversion, _expectedFormat, texture.width, texture.height);
if (useGPUConversion && state.GPUBuffer != null && ModAssets.ColorConversionShader) if (_useGPUConversion && state.GPUBuffer != null && _computeShader)
{ {
ModAssets.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture); _computeShader.SetTexture(_kernelIndex, SourceTexture, texture);
ModAssets.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer); _computeShader.SetBuffer(_kernelIndex, DestinationBuffer, state.GPUBuffer);
ModAssets.ColorConversionShader.SetInt(Width, texture.width); _computeShader.SetInt(Width, texture.width);
ModAssets.ColorConversionShader.SetInt(Height, texture.height); _computeShader.SetInt(Height, texture.height);
var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12 var threadGroupsX = _expectedFormat == AVPixelFormat.AV_PIX_FMT_NV12
? (texture.width + 31) / 32 ? (texture.width + 31) / 32
: (texture.width + 63) / 64; : (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16; var threadGroupsY = (texture.height + 15) / 16;
ModAssets.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1); _computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
AsyncGPUReadback.RequestIntoNativeArray( AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback