SHA256
Add Custom Frame Modification Hook API for Video Rendering
This commit is contained in:
5 files changed
+961
-1
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user