diff --git a/API/FrameHookContext.cs b/API/FrameHookContext.cs
new file mode 100644
index 0000000..cec727f
--- /dev/null
+++ b/API/FrameHookContext.cs
@@ -0,0 +1,302 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+namespace ADOFAIRenderer.API;
+
+///
+/// Execution context provided to frame modification hooks, managing GPU ping-pong double buffering.
+///
+///
+///
+/// Zero-Allocation Notice: For maximum performance on the render hot path, the pipeline reuses context instances
+/// across frames. Do not cache the context instance itself across frames.
+///
+///
+/// Execution Frequency Notice: This hook is executed only when a frame is captured for video encoding.
+/// When RenderFPS exceeds VideoFPS, intermediate game rendering frames are skipped and the hook
+/// will not be invoked on every rendered game frame.
+///
+///
+[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;
+
+ ///
+ /// The input containing the rendered frame before the current pass.
+ ///
+ public RenderTexture Source => _currentSource;
+
+ ///
+ /// The writable destination for the current pass.
+ ///
+ public RenderTexture Destination => _currentDestination;
+
+ ///
+ /// The active representing the current state of the modified frame.
+ ///
+ public RenderTexture Current => _currentSource;
+
+ ///
+ /// The original unmodified render texture captured from the game for the current frame before any hooks were applied.
+ ///
+ public RenderTexture OriginalSource => _originalSource;
+
+ ///
+ /// Indicates whether any modifications or swaps have been applied to this frame.
+ ///
+ public bool HasModified => _hasModified;
+
+ ///
+ /// The sequential 0-based index of the video frame currently being encoded.
+ ///
+ public long FrameIndex { get; private set; }
+
+ ///
+ /// Alias for . The sequential 0-based index of the video frame.
+ ///
+ public long VideoFrameIndex => FrameIndex;
+
+ ///
+ /// The sequential 0-based index of the game render frame.
+ ///
+ public long RenderFrameIndex { get; private set; }
+
+ ///
+ /// The timestamp of the current frame in seconds relative to video start.
+ ///
+ public double VideoTime { get; private set; }
+
+ ///
+ /// The delta time duration in seconds of a single video frame (equivalent to 1.0 / VideoFPS).
+ ///
+ public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
+
+ ///
+ /// The width of the frame in pixels.
+ ///
+ public int Width => _currentSource.width;
+
+ ///
+ /// The height of the frame in pixels.
+ ///
+ public int Height => _currentSource.height;
+
+ ///
+ /// The configured render frames per second.
+ ///
+ public double RenderFPS { get; private set; }
+
+ ///
+ /// The output video frames per second.
+ ///
+ public double VideoFPS { get; private set; }
+
+ ///
+ /// The number of successful modification passes completed for the current frame.
+ ///
+ 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;
+ }
+
+ ///
+ /// Blits from to using the specified and automatically swaps ping-pong buffers.
+ ///
+ /// The material containing the shader to apply, or null for a standard copy.
+ /// The shader pass index to use, or -1 to execute all passes.
+ 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();
+ }
+
+ ///
+ /// Blits from a custom source to using the specified and automatically swaps ping-pong buffers.
+ ///
+ /// The custom source texture to blit from.
+ /// The material containing the shader to apply, or null for a standard copy.
+ /// The shader pass index to use, or -1 to execute all passes.
+ /// Thrown when is null.
+ 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();
+ }
+
+ ///
+ /// Copies the contents of into without swapping buffers.
+ /// Call this before drawing custom overlays into if you wish to preserve the background.
+ ///
+ public void Copy()
+ {
+ Graphics.Blit(_currentSource, _currentDestination);
+ }
+
+ ///
+ /// Clears with the specified color.
+ ///
+ /// The color to clear with, or null for .
+ 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;
+ }
+ }
+
+ ///
+ /// Temporarily activates as to perform custom draw calls,
+ /// safely restoring the previous active RenderTexture afterwards.
+ ///
+ /// The action that performs custom draw operations.
+ /// If true (default), automatically calls after drawing.
+ /// If true, copies into before executing .
+ /// Thrown when is null.
+ 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();
+ }
+ }
+
+ ///
+ /// Executes a against the GPU and optionally swaps buffers.
+ ///
+ /// The command buffer to execute.
+ /// If true (default), automatically calls after execution.
+ /// Thrown when is null.
+ public void ExecuteCommandBuffer(CommandBuffer commandBuffer, bool autoSwap = true)
+ {
+ if (commandBuffer == null) throw new ArgumentNullException(nameof(commandBuffer));
+ Graphics.ExecuteCommandBuffer(commandBuffer);
+ if (autoSwap)
+ {
+ Swap();
+ }
+ }
+
+ ///
+ /// Swaps the active and ping-pong buffers.
+ /// Call this after manually writing or rendering into so that subsequent hooks or the video encoder read the updated frame.
+ ///
+ public void Swap()
+ {
+ _hasModified = true;
+ PassCount++;
+
+ var previousDestination = _currentDestination;
+ _currentSource = previousDestination;
+ _currentDestination = (previousDestination == _pingRT) ? _pongRT : _pingRT;
+ }
+
+ ///
+ /// Replaces the current modified frame texture with a custom .
+ ///
+ /// The custom texture to set as the active frame.
+ /// Thrown when is null.
+ public void ReplaceCurrent(RenderTexture customTexture)
+ {
+ _currentSource = customTexture ?? throw new ArgumentNullException(nameof(customTexture));
+ _hasModified = true;
+ PassCount++;
+ }
+}
diff --git a/API/IFrameHook.cs b/API/IFrameHook.cs
new file mode 100644
index 0000000..179547f
--- /dev/null
+++ b/API/IFrameHook.cs
@@ -0,0 +1,24 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace ADOFAIRenderer.API;
+
+///
+/// Represents a custom frame modification hook that can modify rendered frames using GPU ping-pong double buffering.
+///
+///
+/// Execution Frequency Note: Frame hooks are only invoked for frames that are actually encoded into the final video (VideoFPS).
+/// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped and this hook
+/// will not be called on every game frame.
+///
+[SuppressMessage("ReSharper", "UnusedType.Global")]
+public interface IFrameHook
+{
+ ///
+ /// Invoked for each captured video frame before it is encoded.
+ ///
+ ///
+ /// This method is not called on every rendered game frame if RenderFPS exceeds VideoFPS.
+ ///
+ /// The frame hook context providing access to ping-pong textures and frame metadata.
+ void OnRenderFrame(FrameHookContext context);
+}
diff --git a/API/RenderSession.cs b/API/RenderSession.cs
index ba28895..680c508 100644
--- a/API/RenderSession.cs
+++ b/API/RenderSession.cs
@@ -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.
///
[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 Action { get; }
+ private bool _isDisposed;
+
+ public GlobalHookRegistration(object key, Action 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 Action { get; }
+
+ public SessionHookEntry(object key, Action action)
+ {
+ Key = key;
+ Action = action;
+ }
+ }
+
+ private static readonly List GlobalFrameHooks = new();
+ private static readonly object GlobalHooksLock = new();
+ private static int _globalHooksVersion;
+
+ private readonly List _frameHooks = new();
private bool _auto = true;
///
@@ -126,6 +167,331 @@ public class RenderSession
return this;
}
+ ///
+ /// 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.
+ ///
+ ///
+ /// Execution Frequency Note: This hook is invoked only for frames that are captured for the output video.
+ /// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped,
+ /// so this hook will not be called on every game frame.
+ ///
+ /// The callback action that receives a .
+ /// The current instance for method chaining.
+ /// Thrown when is null.
+ public RenderSession AddFrameHook(Action hook)
+ {
+ if (hook == null) throw new ArgumentNullException(nameof(hook));
+ _frameHooks.Add(new SessionHookEntry(hook, hook));
+ return this;
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The material containing the shader to apply.
+ /// The shader pass index to use, or -1 to execute all passes.
+ /// The current instance for method chaining.
+ /// Thrown when is null.
+ 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;
+ }
+
+ ///
+ /// Adds a custom frame modification hook implementing .
+ ///
+ /// The hook instance to execute.
+ /// The current instance for method chaining.
+ /// Thrown when is null.
+ public RenderSession AddFrameHook(IFrameHook hook)
+ {
+ if (hook == null) throw new ArgumentNullException(nameof(hook));
+ _frameHooks.Add(new SessionHookEntry(hook, hook.OnRenderFrame));
+ return this;
+ }
+
+ ///
+ /// Alias for .
+ ///
+ /// The callback action that receives a .
+ /// The current instance for method chaining.
+ public RenderSession WithFrameHook(Action hook) => AddFrameHook(hook);
+
+ ///
+ /// Alias for .
+ ///
+ /// The material containing the shader to apply.
+ /// The shader pass index to use, or -1 to execute all passes.
+ /// The current instance for method chaining.
+ public RenderSession WithFrameHook(Material material, int pass = -1) => AddFrameHook(material, pass);
+
+ ///
+ /// Alias for .
+ ///
+ /// The hook instance to execute.
+ /// The current instance for method chaining.
+ public RenderSession WithFrameHook(IFrameHook hook) => AddFrameHook(hook);
+
+ ///
+ /// Removes a previously added frame modification hook callback from this session.
+ ///
+ /// The hook action to remove.
+ /// The current instance for method chaining.
+ public RenderSession RemoveFrameHook(Action hook)
+ {
+ var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook) || Equals(e.Action, hook));
+ if (idx >= 0) _frameHooks.RemoveAt(idx);
+ return this;
+ }
+
+ ///
+ /// Removes a previously added material-based post-processing pass from this session.
+ ///
+ /// The material to remove.
+ /// The current instance for method chaining.
+ 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;
+ }
+
+ ///
+ /// Removes a previously added from this session.
+ ///
+ /// The hook instance to remove.
+ /// The current instance for method chaining.
+ public RenderSession RemoveFrameHook(IFrameHook hook)
+ {
+ var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook));
+ if (idx >= 0) _frameHooks.RemoveAt(idx);
+ return this;
+ }
+
+ ///
+ /// Clears all frame modification hooks configured for this render session.
+ ///
+ /// The current instance for method chaining.
+ public RenderSession ClearFrameHooks()
+ {
+ _frameHooks.Clear();
+ return this;
+ }
+
+ ///
+ /// Registers a global frame modification hook that will execute on every render session.
+ ///
+ ///
+ /// Execution Frequency Note: Global frame hooks are executed only for frames that are captured for the output video.
+ /// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped,
+ /// so this hook will not be called on every game frame.
+ ///
+ /// The callback action that receives a .
+ /// An registration handle that unregisters the hook when disposed.
+ /// Thrown when is null.
+ public static IDisposable AddGlobalFrameHook(Action hook)
+ {
+ if (hook == null) throw new ArgumentNullException(nameof(hook));
+ var reg = new GlobalHookRegistration(hook, hook);
+ lock (GlobalHooksLock)
+ {
+ GlobalFrameHooks.Add(reg);
+ _globalHooksVersion++;
+ }
+ return reg;
+ }
+
+ ///
+ /// Registers a global material-based post-processing pass that will execute on every render session.
+ ///
+ /// The material containing the shader to apply.
+ /// The shader pass index to use, or -1 to execute all passes.
+ /// An registration handle that unregisters the hook when disposed.
+ /// Thrown when is null.
+ 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;
+ }
+
+ ///
+ /// Registers a global frame modification hook implementing .
+ ///
+ /// The hook instance to execute.
+ /// An registration handle that unregisters the hook when disposed.
+ /// Thrown when is null.
+ 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;
+ }
+
+ ///
+ /// Unregisters a previously registered global frame modification hook.
+ ///
+ /// The hook action to remove.
+ /// true if the hook was successfully removed; otherwise, false.
+ public static bool RemoveGlobalFrameHook(Action 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;
+ }
+ }
+
+ ///
+ /// Unregisters a previously registered global material-based post-processing pass.
+ ///
+ /// The material to remove.
+ /// true if any hooks matching were removed; otherwise, false.
+ 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;
+ }
+ }
+
+ ///
+ /// Unregisters a previously registered global frame modification hook implementing .
+ ///
+ /// The hook instance to remove.
+ /// true if the hook was successfully removed; otherwise, false.
+ 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++;
+ }
+ }
+ }
+
+ ///
+ /// Clears all registered global frame modification hooks.
+ ///
+ public static void ClearGlobalFrameHooks()
+ {
+ lock (GlobalHooksLock)
+ {
+ if (GlobalFrameHooks.Count > 0)
+ {
+ GlobalFrameHooks.Clear();
+ _globalHooksVersion++;
+ }
+ }
+ }
+
+ ///
+ /// Indicates whether any global frame modification hooks are currently registered.
+ ///
+ public static bool HasGlobalFrameHooks
+ {
+ get
+ {
+ lock (GlobalHooksLock)
+ {
+ return GlobalFrameHooks.Count > 0;
+ }
+ }
+ }
+
+ ///
+ /// The number of currently registered global frame modification hooks.
+ ///
+ public static int GlobalFrameHookCount
+ {
+ get
+ {
+ lock (GlobalHooksLock)
+ {
+ return GlobalFrameHooks.Count;
+ }
+ }
+ }
+
+ internal static int GlobalHooksVersion
+ {
+ get
+ {
+ lock (GlobalHooksLock)
+ {
+ return _globalHooksVersion;
+ }
+ }
+ }
+
+ internal static void CopyGlobalFrameHooksTo(List> destination)
+ {
+ lock (GlobalHooksLock)
+ {
+ for (var i = 0; i < GlobalFrameHooks.Count; i++)
+ {
+ destination.Add(GlobalFrameHooks[i].Action);
+ }
+ }
+ }
+
///
/// Applies the configured flags and initiates the render session.
///
@@ -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;
diff --git a/Core/FrameHookPipeline.cs b/Core/FrameHookPipeline.cs
new file mode 100644
index 0000000..cf59da4
--- /dev/null
+++ b/Core/FrameHookPipeline.cs
@@ -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> 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.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.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();
+ }
+}
diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs
index 7240379..f5c827b 100644
--- a/Core/RenderManager.cs
+++ b/Core/RenderManager.cs
@@ -37,6 +37,7 @@ internal static class RenderContext
public static string? OutputPath;
public static bool Notification = true;
+ public static readonly List> SessionFrameHooks = new();
private static readonly Func EndConditionDefault = () => scrController.instance.state == States.Won;
public static Func 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> _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> 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.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.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();