SHA256
Compare commits
11
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
34d766cb4d
|
||
|
|
5d805b85ad
|
||
|
|
ba83935190
|
||
|
|
d2390665af
|
||
|
|
ceb3190274
|
||
|
|
08b13460ae
|
||
|
|
ed7dd5dba4 | ||
|
|
eb648bbe09
|
||
|
|
f1d56f5f9d
|
||
|
|
ed61cef4bb
|
||
|
|
2cfd14e348
|
No files matched your search
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using ADOFAIRenderer.Core;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Util;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.API;
|
||||
|
||||
@@ -11,8 +13,47 @@ namespace ADOFAIRenderer.API;
|
||||
/// Fluent builder for configuring and starting a render session.
|
||||
/// </summary>
|
||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
@@ -126,6 +167,331 @@ public class RenderSession
|
||||
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>
|
||||
/// Applies the configured flags and initiates the render session.
|
||||
/// </summary>
|
||||
@@ -143,6 +509,15 @@ public class RenderSession
|
||||
if (_endCondition is not null) RenderContext.EndCondition = _endCondition;
|
||||
if (_onComplete is not null) RenderContext.OnComplete = _onComplete;
|
||||
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.Configuration = renderConfig;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+71
-1
@@ -37,6 +37,7 @@ internal static class RenderContext
|
||||
public static string? OutputPath;
|
||||
|
||||
public static bool Notification = true;
|
||||
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
|
||||
|
||||
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
|
||||
public static Func<bool> EndCondition = EndConditionDefault;
|
||||
@@ -64,6 +65,7 @@ internal static class RenderContext
|
||||
OutputPath = null;
|
||||
|
||||
Notification = true;
|
||||
SessionFrameHooks.Clear();
|
||||
|
||||
EndCondition = EndConditionDefault;
|
||||
OnComplete = OnCompleteDefault;
|
||||
@@ -103,6 +105,49 @@ internal class RenderManager : MonoBehaviour
|
||||
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
||||
|
||||
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 lastVideoFrameIndex = -1;
|
||||
@@ -160,6 +205,8 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
AudioSpectrumGenerator.Clear();
|
||||
|
||||
ResetFrameHookState();
|
||||
|
||||
if (_activeVideoFFmpeg != null)
|
||||
{
|
||||
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;
|
||||
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 = null;
|
||||
_frameHookPipeline?.Dispose();
|
||||
_frameHookPipeline = new FrameHookPipeline();
|
||||
_cachedGlobalHooksVersion = -1;
|
||||
EnsureActiveHooksUpToDate();
|
||||
try
|
||||
{
|
||||
StartFFmpegThread();
|
||||
@@ -395,6 +462,7 @@ internal class RenderManager : MonoBehaviour
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
||||
ResetFrameHookState();
|
||||
CommonWarningDialogUI.ShowDialog(
|
||||
"Encoder Error",
|
||||
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
||||
@@ -687,6 +755,7 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
_frameReader?.Dispose();
|
||||
ResetFrameHookState();
|
||||
|
||||
// Clean up Debug Monitor UI
|
||||
if (_debugMonitorObj)
|
||||
@@ -776,6 +845,7 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
_frameReader?.Dispose();
|
||||
ResetFrameHookState();
|
||||
AudioMixer?.Reset(); AudioMixer = null;
|
||||
SyncCalculator.Reset();
|
||||
|
||||
|
||||
@@ -32,5 +32,9 @@
|
||||
"ui.render_config.validation_title": "Invalid Settings",
|
||||
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
|
||||
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}",
|
||||
"ui.render_config.planet_colors": "Planet Colors (Optional)",
|
||||
"ui.render_config.fire_planet": "Fire Planet",
|
||||
"ui.render_config.ice_planet": "Ice Planet",
|
||||
"ui.render_config.wind_planet": "Wind Planet",
|
||||
"ui.render_config.expired": "(Expired)"
|
||||
}
|
||||
@@ -32,5 +32,9 @@
|
||||
"ui.render_config.validation_title": "설정 오류",
|
||||
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
|
||||
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
|
||||
"ui.render_config.planet_colors": "행성 색상 설정 (선택 사항)",
|
||||
"ui.render_config.fire_planet": "불 행성",
|
||||
"ui.render_config.ice_planet": "얼음 행성",
|
||||
"ui.render_config.wind_planet": "바람 행성",
|
||||
"ui.render_config.expired": "(만료됨)"
|
||||
}
|
||||
@@ -18,4 +18,20 @@ public static class PersistencePatch
|
||||
__result = SkipIntroBehavior.Off;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("GetPlayerColor")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static void OverridePlayerColor(bool red, ref PlanetColor __result)
|
||||
{
|
||||
if (!RenderManager.Instance || RenderManager.Instance.stage == RenderStage.None) return;
|
||||
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
|
||||
|
||||
__result = red switch
|
||||
{
|
||||
true when advConf.firePlanetColor.HasValue => new PlanetColor(advConf.firePlanetColor.Value),
|
||||
false when advConf.icePlanetColor.HasValue => new PlanetColor(advConf.icePlanetColor.Value),
|
||||
_ => __result
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(PlanetRenderer))]
|
||||
public static class PlanetRendererPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SetColor")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
private static void OverrideColor(PlanetRenderer __instance, ref PlanetColor planetColor)
|
||||
{
|
||||
if (RenderManager.Instance.stage == RenderStage.None) return;
|
||||
|
||||
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
|
||||
ApplyColor(ref planetColor, scrController.instance.planetRed, advConf.firePlanetColor);
|
||||
ApplyColor(ref planetColor, scrController.instance.planetBlue, advConf.icePlanetColor);
|
||||
ApplyColor(ref planetColor, scrController.instance.planetGreen, advConf.windPlanetColor);
|
||||
|
||||
return;
|
||||
|
||||
void ApplyColor(ref PlanetColor planetColor, scrPlanet? targetPlanet, Color? customColor)
|
||||
{
|
||||
if (targetPlanet != null && __instance == targetPlanet.planetRenderer && customColor != null)
|
||||
planetColor = new PlanetColor(customColor.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ADOFAIRenderer.Core;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(PlanetarySystem))]
|
||||
public static class PlanetarySystemPatch
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("Init")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static void OnInit(PlanetarySystem __instance)
|
||||
{
|
||||
ApplyWindPlanetColor(__instance);
|
||||
}
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch("ApplyMultiplanetColors")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static void OnApplyMultiplanetColors(PlanetarySystem __instance)
|
||||
{
|
||||
ApplyWindPlanetColor(__instance);
|
||||
}
|
||||
|
||||
private static void ApplyWindPlanetColor(PlanetarySystem planetarySystem)
|
||||
{
|
||||
if (RenderManager.Instance == null || RenderManager.Instance.stage == RenderStage.None) return;
|
||||
|
||||
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
|
||||
if (advConf.windPlanetColor.HasValue && planetarySystem.planetGreen)
|
||||
{
|
||||
planetarySystem.planetGreen.planetRenderer.SetColor(new PlanetColor(advConf.windPlanetColor.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ADOFAIRenderer.Patch.Game;
|
||||
|
||||
[HarmonyPatch(typeof(scrDecoration))]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static class scrDecorationPatch
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SetRotation")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static bool SetRotationPrefix(scrDecoration __instance)
|
||||
{
|
||||
return __instance != null && __instance && __instance.pivotTrans != null;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SetScale")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static bool SetScalePrefix(scrDecoration __instance)
|
||||
{
|
||||
return __instance != null && __instance && __instance.pivotTrans != null;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("SetOpacity")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static bool SetOpacityPrefix(scrDecoration __instance)
|
||||
{
|
||||
return __instance != null && __instance;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch("UpdatePosition")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public static bool UpdatePositionPrefix(scrDecoration __instance)
|
||||
{
|
||||
return __instance != null && __instance && __instance.pivotTrans != null;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
> Generative AI technology was partially utilized in the development of this mod.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.2-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.4-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||
|
||||
namespace ADOFAIRenderer;
|
||||
|
||||
+320
-25
@@ -8,8 +8,6 @@ using ADOFAIRenderer.Core.Data;
|
||||
using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Localization;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using HarmonyLib;
|
||||
using MelonLoader;
|
||||
using O5Kit.Control;
|
||||
using O5Kit.Core;
|
||||
@@ -34,8 +32,17 @@ internal static class RenderConfigWindow
|
||||
private static O5Dropdown<Codec>? _codecDropdown;
|
||||
private static O5Dropdown<RateControlMode>? _rateControlDropdown;
|
||||
private static O5InputField? _bitrateInput;
|
||||
private static TMPro.TextMeshProUGUI? _bitrateLabel;
|
||||
private static ITweenHandle? _bitrateLabelTween;
|
||||
|
||||
private static O5InputField? _maxBitrateInput;
|
||||
private static TMPro.TextMeshProUGUI? _maxBitrateLabel;
|
||||
private static ITweenHandle? _maxBitrateLabelTween;
|
||||
|
||||
private static O5InputField? _qpInput;
|
||||
private static TMPro.TextMeshProUGUI? _qpLabel;
|
||||
private static ITweenHandle? _qpLabelTween;
|
||||
|
||||
private static O5Slider? _keyframeIntervalSlider;
|
||||
private static O5Toggle? _forceSoftwareToggle;
|
||||
|
||||
@@ -51,6 +58,17 @@ internal static class RenderConfigWindow
|
||||
private static O5Toggle? _processAudioToggle;
|
||||
private static O5Toggle? _nonTileToggle;
|
||||
|
||||
private static O5Toggle? _firePlanetToggle;
|
||||
private static O5ColorPicker? _firePlanetPicker;
|
||||
private static O5Toggle? _icePlanetToggle;
|
||||
private static O5ColorPicker? _icePlanetPicker;
|
||||
private static O5Toggle? _windPlanetToggle;
|
||||
private static O5ColorPicker? _windPlanetPicker;
|
||||
|
||||
private static readonly Color DefaultFirePlanetColor = new(1f, 0f, 0f, 1f); // #FF0000
|
||||
private static readonly Color DefaultIcePlanetColor = new(0f, 0f, 1f, 1f); // #0000FF
|
||||
private static readonly Color DefaultWindPlanetColor = new(0.3f, 0.7f, 0f, 1f); // #4DB300
|
||||
|
||||
public static void CreateWindow()
|
||||
{
|
||||
if (_configWindow == null)
|
||||
@@ -65,6 +83,7 @@ internal static class RenderConfigWindow
|
||||
|
||||
_configWindow.CloseRequested += win =>
|
||||
{
|
||||
CloseAllPickers();
|
||||
win.Rect.gameObject.SetActive(false);
|
||||
};
|
||||
|
||||
@@ -205,14 +224,14 @@ internal static class RenderConfigWindow
|
||||
c =>
|
||||
{
|
||||
config.VideoConfig.Codec = c;
|
||||
UpdateVideoCodec(c);
|
||||
UpdateVideoCodecUI(c, config);
|
||||
});
|
||||
|
||||
_bitrateInput = CreateInputField(
|
||||
ctx, CreateFieldSlot(videoLeft),
|
||||
LocalizationManager.Get("ui.render_config.bitrate"),
|
||||
"30000", config.VideoConfig.Bitrate.ToString(),
|
||||
"input_bitrate",
|
||||
"input_bitrate", out _bitrateLabel,
|
||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||
val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; }
|
||||
);
|
||||
@@ -221,7 +240,7 @@ internal static class RenderConfigWindow
|
||||
ctx, CreateFieldSlot(videoLeft),
|
||||
LocalizationManager.Get("ui.render_config.qp"),
|
||||
"18", config.VideoConfig.QP.ToString(),
|
||||
"input_qp",
|
||||
"input_qp", out _qpLabel,
|
||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||
val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; }
|
||||
);
|
||||
@@ -233,14 +252,18 @@ internal static class RenderConfigWindow
|
||||
RateControlMode.CBR, config.VideoConfig.RateControlMode,
|
||||
rateModes, m => m.ToString(),
|
||||
"dd_rate_control",
|
||||
m => config.VideoConfig.RateControlMode = m
|
||||
m =>
|
||||
{
|
||||
config.VideoConfig.RateControlMode = m;
|
||||
UpdateRateControlUI(m);
|
||||
}
|
||||
);
|
||||
|
||||
_maxBitrateInput = CreateInputField(
|
||||
ctx, CreateFieldSlot(videoRight),
|
||||
LocalizationManager.Get("ui.render_config.max_bitrate"),
|
||||
"50000", config.VideoConfig.MaxBitrate.ToString(),
|
||||
"input_max_bitrate",
|
||||
"input_max_bitrate", out _maxBitrateLabel,
|
||||
TMPro.TMP_InputField.CharacterValidation.Integer,
|
||||
val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; }
|
||||
);
|
||||
@@ -250,7 +273,7 @@ internal static class RenderConfigWindow
|
||||
false, config.VideoConfig.ForceSoftwareEncoder,
|
||||
LocalizationManager.Get("ui.render_config.force_software"),
|
||||
"toggle_force_software",
|
||||
val => config.AudioConfig.RenderAudio = val,
|
||||
val => config.VideoConfig.ForceSoftwareEncoder = val,
|
||||
generateLabel: true
|
||||
);
|
||||
|
||||
@@ -436,7 +459,107 @@ internal static class RenderConfigWindow
|
||||
generateLabel: false
|
||||
);
|
||||
|
||||
// TODO: Fire Planet, Ice Planet, Wind Planet Color Settings (Each Optional)
|
||||
// 7-1. Planet Color Settings (Optional)
|
||||
var planetColorsLabel = O5Factory.ControlText(ctx, advContent, 13f, noPad: true);
|
||||
planetColorsLabel.text = LocalizationManager.Get("ui.render_config.planet_colors");
|
||||
planetColorsLabel.color = ctx.Theme.TextDim;
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var planetLabelLE = planetColorsLabel.gameObject.AddComponent<LayoutElement>();
|
||||
planetLabelLE.preferredHeight = 18f;
|
||||
planetLabelLE.minHeight = 18f;
|
||||
|
||||
var planetCols = CreateMultiColumnPanel(advContent, 3);
|
||||
var (fireCol, iceCol, windCol) = (planetCols[0], planetCols[1], planetCols[2]);
|
||||
|
||||
var fireSlot = CreateFieldSlot(fireCol);
|
||||
_firePlanetToggle = CreateToggleField(
|
||||
ctx, fireSlot, "",
|
||||
false, config.AdvancedConfig.firePlanetColor.HasValue,
|
||||
LocalizationManager.Get("ui.render_config.fire_planet"),
|
||||
"toggle_fire_planet",
|
||||
val =>
|
||||
{
|
||||
_firePlanetPicker?.SetBlocked(!val);
|
||||
config.AdvancedConfig.firePlanetColor = val ? _firePlanetPicker?.Value : null;
|
||||
if (!val) _firePlanetPicker?.SetExpanded(false);
|
||||
},
|
||||
generateLabel: false
|
||||
);
|
||||
_firePlanetPicker = CreateColorPickerField(
|
||||
ctx, fireSlot,
|
||||
DefaultFirePlanetColor,
|
||||
config.AdvancedConfig.firePlanetColor ?? DefaultFirePlanetColor,
|
||||
"cp_fire_planet",
|
||||
val =>
|
||||
{
|
||||
if (_firePlanetToggle?.Value == true)
|
||||
config.AdvancedConfig.firePlanetColor = val;
|
||||
}
|
||||
);
|
||||
if (!config.AdvancedConfig.firePlanetColor.HasValue)
|
||||
{
|
||||
_firePlanetPicker.SetBlocked(true, true);
|
||||
}
|
||||
|
||||
var iceSlot = CreateFieldSlot(iceCol);
|
||||
_icePlanetToggle = CreateToggleField(
|
||||
ctx, iceSlot, "",
|
||||
false, config.AdvancedConfig.icePlanetColor.HasValue,
|
||||
LocalizationManager.Get("ui.render_config.ice_planet"),
|
||||
"toggle_ice_planet",
|
||||
val =>
|
||||
{
|
||||
_icePlanetPicker?.SetBlocked(!val);
|
||||
config.AdvancedConfig.icePlanetColor = val ? _icePlanetPicker?.Value : null;
|
||||
if (!val) _icePlanetPicker?.SetExpanded(false);
|
||||
},
|
||||
generateLabel: false
|
||||
);
|
||||
_icePlanetPicker = CreateColorPickerField(
|
||||
ctx, iceSlot,
|
||||
DefaultIcePlanetColor,
|
||||
config.AdvancedConfig.icePlanetColor ?? DefaultIcePlanetColor,
|
||||
"cp_ice_planet",
|
||||
val =>
|
||||
{
|
||||
if (_icePlanetToggle?.Value == true)
|
||||
config.AdvancedConfig.icePlanetColor = val;
|
||||
}
|
||||
);
|
||||
if (!config.AdvancedConfig.icePlanetColor.HasValue)
|
||||
{
|
||||
_icePlanetPicker.SetBlocked(true, true);
|
||||
}
|
||||
|
||||
var windSlot = CreateFieldSlot(windCol);
|
||||
_windPlanetToggle = CreateToggleField(
|
||||
ctx, windSlot, "",
|
||||
false, config.AdvancedConfig.windPlanetColor.HasValue,
|
||||
LocalizationManager.Get("ui.render_config.wind_planet"),
|
||||
"toggle_wind_planet",
|
||||
val =>
|
||||
{
|
||||
_windPlanetPicker?.SetBlocked(!val);
|
||||
config.AdvancedConfig.windPlanetColor = val ? _windPlanetPicker?.Value : null;
|
||||
if (!val) _windPlanetPicker?.SetExpanded(false);
|
||||
},
|
||||
generateLabel: false
|
||||
);
|
||||
_windPlanetPicker = CreateColorPickerField(
|
||||
ctx, windSlot,
|
||||
DefaultWindPlanetColor,
|
||||
config.AdvancedConfig.windPlanetColor ?? DefaultWindPlanetColor,
|
||||
"cp_wind_planet",
|
||||
val =>
|
||||
{
|
||||
if (_windPlanetToggle?.Value == true)
|
||||
config.AdvancedConfig.windPlanetColor = val;
|
||||
}
|
||||
);
|
||||
if (!config.AdvancedConfig.windPlanetColor.HasValue)
|
||||
{
|
||||
_windPlanetPicker.SetBlocked(true, true);
|
||||
}
|
||||
|
||||
// 8. Go Render Button
|
||||
O5Factory.Button(
|
||||
@@ -465,11 +588,6 @@ internal static class RenderConfigWindow
|
||||
|
||||
double Snap(double[] array, double value) =>
|
||||
array.OrderBy(n => Math.Abs(n - value)).First();
|
||||
|
||||
void UpdateVideoCodec(Codec codec)
|
||||
{
|
||||
_sampleRateDropdown?.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureDefaultConfig(RenderConfig config)
|
||||
@@ -511,6 +629,24 @@ internal static class RenderConfigWindow
|
||||
_randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false);
|
||||
_processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false);
|
||||
_nonTileToggle?.Set(config.AdvancedConfig.NonTile, false);
|
||||
|
||||
var fireColor = config.AdvancedConfig.firePlanetColor;
|
||||
_firePlanetToggle?.Set(fireColor.HasValue, false);
|
||||
_firePlanetPicker?.Set(fireColor ?? DefaultFirePlanetColor, false);
|
||||
_firePlanetPicker?.SetBlocked(!fireColor.HasValue, true);
|
||||
|
||||
var iceColor = config.AdvancedConfig.icePlanetColor;
|
||||
_icePlanetToggle?.Set(iceColor.HasValue, false);
|
||||
_icePlanetPicker?.Set(iceColor ?? DefaultIcePlanetColor, false);
|
||||
_icePlanetPicker?.SetBlocked(!iceColor.HasValue, true);
|
||||
|
||||
var windColor = config.AdvancedConfig.windPlanetColor;
|
||||
_windPlanetToggle?.Set(windColor.HasValue, false);
|
||||
_windPlanetPicker?.Set(windColor ?? DefaultWindPlanetColor, false);
|
||||
_windPlanetPicker?.SetBlocked(!windColor.HasValue, true);
|
||||
|
||||
UpdateVideoCodecUI(config.VideoConfig.Codec, config);
|
||||
UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true);
|
||||
}
|
||||
|
||||
private static void TryGoRender()
|
||||
@@ -544,6 +680,17 @@ internal static class RenderConfigWindow
|
||||
}
|
||||
}
|
||||
|
||||
// Sync Planet Colors
|
||||
config.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null)
|
||||
? _firePlanetPicker.Value
|
||||
: null;
|
||||
config.AdvancedConfig.icePlanetColor = (_icePlanetToggle?.Value == true && _icePlanetPicker != null)
|
||||
? _icePlanetPicker.Value
|
||||
: null;
|
||||
config.AdvancedConfig.windPlanetColor = (_windPlanetToggle?.Value == true && _windPlanetPicker != null)
|
||||
? _windPlanetPicker.Value
|
||||
: null;
|
||||
|
||||
// Sync Output Directory
|
||||
if (_outputDirInput != null)
|
||||
{
|
||||
@@ -588,16 +735,18 @@ internal static class RenderConfigWindow
|
||||
);
|
||||
}
|
||||
|
||||
// 카드(또는 상단 섹션)당 하나의 좌/우 컨테이너를 만들어
|
||||
// 가로 한 줄 두 필드 사이 중앙이 행마다 어긋나지 않도록 한다.
|
||||
// - 패널 1개 = 중앙선 1개 (행마다 HLG를 만들던 기존 방식의 P1-P2 어긋남 방지)
|
||||
// - 좌/우 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 50:50
|
||||
// 카드(또는 상단 섹션)당 하나의 다열 컨테이너를 만들어
|
||||
// 가로 한 줄 각 필드 사이 중앙이 행마다 어긋나지 않도록 한다.
|
||||
// - 패널 1개 = 균등 분할 (행마다 HLG를 만들던 기존 방식의 어긋남 방지)
|
||||
// - 각 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 1:1:...:1 균등 분할
|
||||
// - 각 필드는 Field 슬롯(label 18 + 간격 3 + 컨트롤 50)으로 높이를 고정해
|
||||
// 좌/우 스택을 써도 행 단위 Y 정렬이 유지되도록 한다.
|
||||
private static (RectTransform left, RectTransform right) CreateTwoColumnPanel(
|
||||
Transform parent, float spacing = 8f, float columnSpacing = 8f)
|
||||
private static RectTransform[] CreateMultiColumnPanel(
|
||||
Transform parent, int columnCount, float spacing = 8f, float columnSpacing = 8f)
|
||||
{
|
||||
var panelObj = new GameObject("TwoColPanel");
|
||||
if (columnCount < 1) throw new ArgumentOutOfRangeException(nameof(columnCount), "Column count must be at least 1.");
|
||||
|
||||
var panelObj = new GameObject($"{columnCount}ColPanel");
|
||||
panelObj.transform.SetParent(parent, false);
|
||||
|
||||
panelObj.AddComponent<RectTransform>();
|
||||
@@ -615,10 +764,20 @@ internal static class RenderConfigWindow
|
||||
var panelLE = panelObj.AddComponent<LayoutElement>();
|
||||
panelLE.flexibleWidth = 1f;
|
||||
|
||||
var left = CreateColumn(panelObj.transform, "LeftCol", columnSpacing);
|
||||
var right = CreateColumn(panelObj.transform, "RightCol", columnSpacing);
|
||||
var columns = new RectTransform[columnCount];
|
||||
for (var i = 0; i < columnCount; i++)
|
||||
{
|
||||
columns[i] = CreateColumn(panelObj.transform, $"Col{i + 1}", columnSpacing);
|
||||
}
|
||||
|
||||
return (left, right);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static (RectTransform left, RectTransform right) CreateTwoColumnPanel(
|
||||
Transform parent, float spacing = 8f, float columnSpacing = 8f)
|
||||
{
|
||||
var cols = CreateMultiColumnPanel(parent, 2, spacing, columnSpacing);
|
||||
return (cols[0], cols[1]);
|
||||
}
|
||||
|
||||
private static RectTransform CreateColumn(Transform parent, string name, float spacing)
|
||||
@@ -678,7 +837,21 @@ internal static class RenderConfigWindow
|
||||
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
|
||||
Action<string>? onChanged = null)
|
||||
{
|
||||
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||
return CreateInputField(ctx, colParent, labelText, defaultValue, value, id, out _, characterValidation, onChanged);
|
||||
}
|
||||
|
||||
private static O5InputField CreateInputField(
|
||||
O5Context ctx,
|
||||
Transform colParent,
|
||||
string labelText,
|
||||
string? defaultValue,
|
||||
string value,
|
||||
string id,
|
||||
out TMPro.TextMeshProUGUI label,
|
||||
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
|
||||
Action<string>? onChanged = null)
|
||||
{
|
||||
label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
|
||||
label.text = labelText;
|
||||
label.color = ctx.Theme.TextDim;
|
||||
|
||||
@@ -702,6 +875,57 @@ internal static class RenderConfigWindow
|
||||
return inputField;
|
||||
}
|
||||
|
||||
private static void SetFieldBlocked(
|
||||
O5InputField? input,
|
||||
TMPro.TextMeshProUGUI? label,
|
||||
ref ITweenHandle? tweenHandle,
|
||||
bool blocked,
|
||||
bool noAnimate = false)
|
||||
{
|
||||
input?.SetBlocked(blocked, noAnimate);
|
||||
|
||||
if (label == null) return;
|
||||
|
||||
tweenHandle?.Kill();
|
||||
var ctx = RendererUIManager.Context;
|
||||
var baseColor = ctx.Theme.TextDim;
|
||||
var targetColor = blocked
|
||||
? new Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a * 0.4f)
|
||||
: baseColor;
|
||||
|
||||
if (noAnimate)
|
||||
{
|
||||
label.color = targetColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
tweenHandle = ctx.Tween.TweenColor(label, targetColor, 0.2f);
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateRateControlUI(RateControlMode mode, bool noAnimate = false)
|
||||
{
|
||||
bool blockBitrate = mode is RateControlMode.CQP;
|
||||
bool blockMaxBitrate = mode is RateControlMode.CBR or RateControlMode.CQP;
|
||||
bool blockQp = mode is RateControlMode.CBR or RateControlMode.VBR;
|
||||
|
||||
SetFieldBlocked(_bitrateInput, _bitrateLabel, ref _bitrateLabelTween, blockBitrate, noAnimate);
|
||||
SetFieldBlocked(_maxBitrateInput, _maxBitrateLabel, ref _maxBitrateLabelTween, blockMaxBitrate, noAnimate);
|
||||
SetFieldBlocked(_qpInput, _qpLabel, ref _qpLabelTween, blockQp, noAnimate);
|
||||
}
|
||||
|
||||
private static void UpdateVideoCodecUI(Codec codec, RenderConfig? config = null)
|
||||
{
|
||||
if (_sampleRateDropdown == null) return;
|
||||
|
||||
_sampleRateDropdown.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]);
|
||||
|
||||
if (config != null && _sampleRateDropdown.Value is var currentRate)
|
||||
{
|
||||
config.AudioConfig.AudioSampleRate = currentRate;
|
||||
}
|
||||
}
|
||||
|
||||
private static O5InputField CreateFullWidthInputField(
|
||||
O5Context ctx,
|
||||
Transform parent,
|
||||
@@ -819,8 +1043,79 @@ internal static class RenderConfigWindow
|
||||
);
|
||||
}
|
||||
|
||||
private static O5ColorPicker CreateColorPickerField(
|
||||
O5Context ctx,
|
||||
Transform colParent,
|
||||
Color? defaultValue,
|
||||
Color value,
|
||||
string id,
|
||||
Action<Color>? onChanged = null)
|
||||
{
|
||||
var cpContainer = new GameObject($"CP_{id}");
|
||||
cpContainer.transform.SetParent(colParent, false);
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
var cpLE = cpContainer.AddComponent<LayoutElement>();
|
||||
var height = ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody);
|
||||
cpLE.preferredHeight = height;
|
||||
cpLE.minHeight = height;
|
||||
|
||||
var canvasRect = RendererUIManager.Instance.WindowManager.Canvas.Root.GetComponent<RectTransform>();
|
||||
|
||||
var picker = O5Factory.ColorPicker(
|
||||
ctx: ctx,
|
||||
parent: cpContainer.transform,
|
||||
canvasRect: canvasRect,
|
||||
canvasCamera: null,
|
||||
defaultValue: defaultValue,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
onComplete: onChanged,
|
||||
id: id
|
||||
);
|
||||
|
||||
// 3열 등 다열 레이아웃 환경에 맞춰 O5ColorPicker 헤더 내부 비율 및 폰트 크기 조정
|
||||
// O5Kit 기본값(Preview: 0~0.5, HexInput: 0.62~1.0, fontSize: 19)은 너비가 좁을 때
|
||||
// Hex 문자열(8자리 RGBA) 앞부분이 잘려 보이는 현상이 발생하므로 공간을 재분배합니다.
|
||||
if (picker.Rect.Find("Header") is RectTransform header)
|
||||
{
|
||||
if (header.Find("Preview") is RectTransform preview)
|
||||
{
|
||||
preview.anchorMax = new Vector2(0.28f, 1f);
|
||||
}
|
||||
|
||||
if (header.Find("ToggleArea") is RectTransform toggleArea)
|
||||
{
|
||||
toggleArea.anchorMax = new Vector2(0.30f, 1f);
|
||||
}
|
||||
|
||||
if (header.Find("HexInput") is RectTransform hexInput)
|
||||
{
|
||||
hexInput.anchorMin = new Vector2(0.32f, 0f);
|
||||
var tmpInput = hexInput.GetComponentInChildren<TMPro.TMP_InputField>();
|
||||
if (tmpInput != null && tmpInput.textComponent != null)
|
||||
{
|
||||
tmpInput.textComponent.fontSize = 15f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return picker;
|
||||
}
|
||||
|
||||
private static void SetWindowActive(bool enabled)
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
CloseAllPickers();
|
||||
}
|
||||
_configWindow?.Rect.gameObject.SetActive(enabled);
|
||||
}
|
||||
|
||||
private static void CloseAllPickers()
|
||||
{
|
||||
_firePlanetPicker?.SetExpanded(false, true);
|
||||
_icePlanetPicker?.SetExpanded(false, true);
|
||||
_windPlanetPicker?.SetExpanded(false, true);
|
||||
}
|
||||
}
|
||||
+938
-296
File diff suppressed because it is too large.
Load diff
+33
-31
@@ -26,12 +26,34 @@ namespace ADOFAIRenderer.Util
|
||||
private readonly object _poolLock = new();
|
||||
private bool _isInitialized;
|
||||
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)
|
||||
{
|
||||
_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();
|
||||
}
|
||||
|
||||
@@ -71,42 +93,22 @@ namespace ADOFAIRenderer.Util
|
||||
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
|
||||
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);
|
||||
ModAssets.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
|
||||
ModAssets.ColorConversionShader.SetInt(Width, texture.width);
|
||||
ModAssets.ColorConversionShader.SetInt(Height, texture.height);
|
||||
_computeShader.SetTexture(_kernelIndex, SourceTexture, texture);
|
||||
_computeShader.SetBuffer(_kernelIndex, DestinationBuffer, state.GPUBuffer);
|
||||
_computeShader.SetInt(Width, texture.width);
|
||||
_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 + 63) / 64;
|
||||
var threadGroupsY = (texture.height + 15) / 16;
|
||||
|
||||
ModAssets.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1);
|
||||
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
||||
|
||||
AsyncGPUReadback.RequestIntoNativeArray(
|
||||
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
|
||||
|
||||
Reference in new issue
Block a user