Add Custom Frame Modification Hook API for Video Rendering

This commit is contained in:
copecone committed 2026-10-09 20:31:23 +09:00
1 parent 5d805b85ad
commit 34d766cb4d
5 files changed
+961 -1

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.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;
+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();
}
}
+71 -1
View File
@@ -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();