From 34d766cb4d243fee1413661a10ed689cf3de79bf8ea6e549c64a1811a40c0f68 Mon Sep 17 00:00:00 2001 From: copecone Date: Fri, 9 Oct 2026 20:31:23 +0900 Subject: [PATCH] Add Custom Frame Modification Hook API for Video Rendering --- API/FrameHookContext.cs | 302 ++++++++++++++++++++++++++++++ API/IFrameHook.cs | 24 +++ API/RenderSession.cs | 375 ++++++++++++++++++++++++++++++++++++++ Core/FrameHookPipeline.cs | 189 +++++++++++++++++++ Core/RenderManager.cs | 72 +++++++- 5 files changed, 961 insertions(+), 1 deletion(-) create mode 100644 API/FrameHookContext.cs create mode 100644 API/IFrameHook.cs create mode 100644 Core/FrameHookPipeline.cs 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();