diff --git a/ADOFAIRenderer.csproj b/ADOFAIRenderer.csproj index ba45748..ffd87e3 100644 --- a/ADOFAIRenderer.csproj +++ b/ADOFAIRenderer.csproj @@ -78,6 +78,8 @@ + + @@ -85,6 +87,8 @@ + + diff --git a/Bundle/Linux/shaders b/Bundle/Linux/shaders new file mode 100644 index 0000000..899ccba Binary files /dev/null and b/Bundle/Linux/shaders differ diff --git a/Bundle/Win64/shaders b/Bundle/Win64/shaders new file mode 100644 index 0000000..2667963 Binary files /dev/null and b/Bundle/Win64/shaders differ diff --git a/RenderMod.cs b/RenderMod.cs index 15aa485..c26f577 100644 --- a/RenderMod.cs +++ b/RenderMod.cs @@ -17,6 +17,8 @@ namespace ADOFAIRenderer public static AssetBundle FontBundle = null!; // ReSharper disable once MemberCanBePrivate.Global public static AssetBundle SceneBundle = null!; + public static AssetBundle ShaderBundle = null!; + public static ComputeShader ColorConversionShader = null!; public static string[] ModScenes = {}; public override void OnInitializeMelon() @@ -38,6 +40,16 @@ namespace ADOFAIRenderer FontBundle = BundleLoader.GetBundle("fonts"); SceneBundle = BundleLoader.GetBundle("scenes"); ModScenes = SceneBundle.GetAllScenePaths(); + try + { + ShaderBundle = BundleLoader.GetBundle("shaders"); + ColorConversionShader = ShaderBundle.LoadAsset("ColorConversion"); + Melon.Logger.Msg("컬러 변환 Compute Shader 로드 완료."); + } + catch (System.Exception ex) + { + Melon.Logger.Warning($"Shader 에셋번들을 로드할 수 없습니다. GPU 컬러 변환이 비활성화되며 CPU로 폴백합니다. 에러: {ex.Message}"); + } } public static void FixEventSystem() diff --git a/Util/FFmpeg/FFmpegNativeEncoder.cs b/Util/FFmpeg/FFmpegNativeEncoder.cs index 2d4e53c..d846ae6 100644 --- a/Util/FFmpeg/FFmpegNativeEncoder.cs +++ b/Util/FFmpeg/FFmpegNativeEncoder.cs @@ -166,6 +166,7 @@ namespace ADOFAIRenderer.Util.FFmpeg /// 매 프레임마다 할당/해제하는 오버헤드를 방지하기 위해 멤버 변수로 캐싱합니다. /// private AVFrame* _pHwFrame = null; + private bool _loggedConversionMode; /// 생성 시 전달받은 인코딩 설정값 (불변). private readonly EncoderSettings _settings; @@ -303,6 +304,7 @@ namespace ADOFAIRenderer.Util.FFmpeg // ── 기본 비디오 파라미터 설정 ── _pCodecContext->width = _settings.Width; _pCodecContext->height = _settings.Height; + _pCodecContext->color_range = AVColorRange.AVCOL_RANGE_JPEG; // thread_count = 0: FFmpeg이 시스템 CPU 코어 수에 맞춰 자동으로 스레드를 할당합니다. // libvvenc의 경우에도 FFmpeg 래퍼가 thread_count를 VVenC 내부 스레드 수로 전달하므로, // 0(자동)을 설정해야 모든 코어를 활용한 병렬 인코딩이 가능합니다. @@ -665,6 +667,7 @@ namespace ADOFAIRenderer.Util.FFmpeg _pDstFrame->format = (int)_dstPixFmt; _pDstFrame->width = _settings.Width; _pDstFrame->height = _settings.Height; + _pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG; if (ffmpeg.av_frame_get_buffer(_pDstFrame, 0) < 0) throw new Exception($"{_dstPixFmt} 프레임 버퍼를 할당하는 데 실패했습니다."); @@ -689,6 +692,7 @@ namespace ADOFAIRenderer.Util.FFmpeg throw new Exception("소프트웨어 스케일러를 초기화하는 데 실패했습니다."); _isInitialized = true; + _loggedConversionMode = false; } catch (Exception) { @@ -873,7 +877,24 @@ namespace ADOFAIRenderer.Util.FFmpeg lock (_lock) { if (_disposed || !_isInitialized) return; - WriteFrameFromBuffer((byte*)pointer.ToPointer()); + var preConvertedSize = _settings.Width * _settings.Height * 3 / 2; + + if (!_loggedConversionMode) + { + _loggedConversionMode = true; + Melon.Logger.Msg(length == preConvertedSize + ? "[인코더] 고속 GPU 컬러 변환 활성화 (NV12/YUV420P Direct Write, sws_scale 우회)" + : "[인코더] CPU 컬러 변환 폴백 활성화 (RGBA Input, sws_scale 사용)"); + } + + if (length == preConvertedSize) + { + WritePreConvertedFrame((byte*)pointer.ToPointer()); + } + else + { + WriteFrameFromBuffer((byte*)pointer.ToPointer()); + } } } @@ -888,11 +909,169 @@ namespace ADOFAIRenderer.Util.FFmpeg if (_disposed || !_isInitialized) return; fixed (byte* pBuffer = buffer) { - WriteFrameFromBuffer(pBuffer); + var preConvertedSize = _settings.Width * _settings.Height * 3 / 2; + + if (!_loggedConversionMode) + { + _loggedConversionMode = true; + Melon.Logger.Msg(length == preConvertedSize + ? "[인코더] 고속 GPU 컬러 변환 활성화 (NV12/YUV420P Direct Write, sws_scale 우회)" + : "[인코더] CPU 컬러 변환 폴백 활성화 (RGBA Input, sws_scale 사용)"); + } + + if (length == preConvertedSize) + { + WritePreConvertedFrame(pBuffer); + } + else + { + WriteFrameFromBuffer(pBuffer); + } } } } + /// + /// 이미 NV12 혹은 YUV420P로 변환된 프레임 데이터를 받아서 다이렉트로 복사하여 인코딩합니다. + /// + private void WritePreConvertedFrame(byte* buffer) + { + ffmpeg.av_frame_make_writable(_pDstFrame); + + var width = _settings.Width; + var height = _settings.Height; + + switch (_dstPixFmt) + { + case AVPixelFormat.AV_PIX_FMT_NV12 or AVPixelFormat.AV_PIX_FMT_QSV: + { + // Y Plane 복사 + var pSrcY = buffer; + var pDstY = _pDstFrame->data[0]; + var dstStrideY = _pDstFrame->linesize[0]; + + if (width == dstStrideY) + { + Buffer.MemoryCopy(pSrcY, pDstY, dstStrideY * height, width * height); + } + else + { + for (var y = 0; y < height; y++) + { + Buffer.MemoryCopy(pSrcY + y * width, pDstY + y * dstStrideY, width, width); + } + } + + // UV Plane 복사 (Y 평면 크기 이후 위치) + var pSrcUV = buffer + width * height; + var pDstUV = _pDstFrame->data[1]; + var dstStrideUV = _pDstFrame->linesize[1]; + var uvHeight = height / 2; + + if (width == dstStrideUV) + { + Buffer.MemoryCopy(pSrcUV, pDstUV, dstStrideUV * uvHeight, width * uvHeight); + } + else + { + for (var y = 0; y < uvHeight; y++) + { + Buffer.MemoryCopy(pSrcUV + y * width, pDstUV + y * dstStrideUV, width, width); + } + } + + break; + } + case AVPixelFormat.AV_PIX_FMT_YUV420P: + { + // Y Plane 복사 + var pSrcY = buffer; + var pDstY = _pDstFrame->data[0]; + var dstStrideY = _pDstFrame->linesize[0]; + + if (width == dstStrideY) + { + Buffer.MemoryCopy(pSrcY, pDstY, dstStrideY * height, width * height); + } + else + { + for (var y = 0; y < height; y++) + { + Buffer.MemoryCopy(pSrcY + y * width, pDstY + y * dstStrideY, width, width); + } + } + + // U Plane 복사 + var pSrcU = buffer + width * height; + var pDstU = _pDstFrame->data[1]; + var dstStrideU = _pDstFrame->linesize[1]; + var chromaWidth = width / 2; + var chromaHeight = height / 2; + + if (chromaWidth == dstStrideU) + { + Buffer.MemoryCopy(pSrcU, pDstU, dstStrideU * chromaHeight, chromaWidth * chromaHeight); + } + else + { + for (var y = 0; y < chromaHeight; y++) + { + Buffer.MemoryCopy(pSrcU + y * chromaWidth, pDstU + y * dstStrideU, chromaWidth, chromaWidth); + } + } + + // V Plane 복사 + var pSrcV = pSrcU + chromaWidth * chromaHeight; + var pDstV = _pDstFrame->data[2]; + var dstStrideV = _pDstFrame->linesize[2]; + + if (chromaWidth == dstStrideV) + { + Buffer.MemoryCopy(pSrcV, pDstV, dstStrideV * chromaHeight, chromaWidth * chromaHeight); + } + else + { + for (var y = 0; y < chromaHeight; y++) + { + Buffer.MemoryCopy(pSrcV + y * chromaWidth, pDstV + y * dstStrideV, chromaWidth, chromaWidth); + } + } + + break; + } + default: + throw new NotSupportedException($"이 픽셀 포맷({_dstPixFmt})은 사전 변환된 버퍼 다이렉트 복사를 지원하지 않습니다."); + } + + // QSV 가속 처리 또는 인코더 전송 + if (_isQsvEncoder) + { + ffmpeg.av_frame_unref(_pHwFrame); + try + { + int ret = ffmpeg.av_hwframe_get_buffer(_pCodecContext->hw_frames_ctx, _pHwFrame, 0); + if (ret < 0) + throw new Exception($"QSV 하드웨어 프레임 버퍼 할당 실패 — {GetFFmpegErrorString(ret)} (Code: {ret})"); + + ret = ffmpeg.av_hwframe_transfer_data(_pHwFrame, _pDstFrame, 0); + if (ret < 0) + throw new Exception($"QSV 프레임 업로드 실패 — {GetFFmpegErrorString(ret)} (Code: {ret})"); + + _pHwFrame->pts = _frameIndex++; + SendFrameAndWritePackets(_pHwFrame); + } + finally + { + ffmpeg.av_frame_unref(_pHwFrame); + } + } + else + { + _pDstFrame->pts = _frameIndex++; + SendFrameAndWritePackets(_pDstFrame); + } + } + /// /// 실제 프레임 인코딩의 핵심 로직. /// @@ -1008,7 +1187,7 @@ namespace ADOFAIRenderer.Util.FFmpeg private void SendFrameAndWritePackets(AVFrame* pFrame) { // 인코더 내부 큐에 프레임 전달 - int response = ffmpeg.avcodec_send_frame(_pCodecContext, pFrame); + var response = ffmpeg.avcodec_send_frame(_pCodecContext, pFrame); if (response < 0) { throw new Exception($"인코더에 프레임을 전송하는 도중 에러가 발생했습니다. (Code: {response})"); @@ -1027,7 +1206,8 @@ namespace ADOFAIRenderer.Util.FFmpeg { break; } - else if (response < 0) + + if (response < 0) { throw new Exception($"인코더에서 패킷을 수신하는 도중 에러가 발생했습니다. (Code: {response})"); } diff --git a/Util/FrameReader.cs b/Util/FrameReader.cs index 513cbe5..9ffbb10 100644 --- a/Util/FrameReader.cs +++ b/Util/FrameReader.cs @@ -3,6 +3,8 @@ using System.Buffers; using System.Collections.Generic; using System.Linq; using ADOFAIRenderer.Core; +using ADOFAIRenderer.Util.FFmpeg; +using FFmpeg.AutoGen; using MelonLoader; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; @@ -13,6 +15,10 @@ namespace ADOFAIRenderer.Util { public class FrameReader : IDisposable { + private static readonly int Height = Shader.PropertyToID("Height"); + private static readonly int Width = Shader.PropertyToID("Width"); + private static readonly int DestinationBuffer = Shader.PropertyToID("DestinationBuffer"); + private static readonly int SourceTexture = Shader.PropertyToID("SourceTexture"); private const int InitialBufferSize = 16; private readonly Stack _statePool = new(); @@ -42,6 +48,24 @@ namespace ADOFAIRenderer.Util } } + private static unsafe AVPixelFormat PredictPixelFormat(Codec codec) + { + var candidates = GPUUtil.GetPrioritizedEncoderCandidates(codec); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator + foreach (var encoderName in candidates) + { + var pCodec = ffmpeg.avcodec_find_encoder_by_name(encoderName); + if (pCodec == null) continue; + + if (encoderName.Contains("qsv") || encoderName.Contains("nvenc") || encoderName.Contains("amf")) + { + return AVPixelFormat.AV_PIX_FMT_NV12; + } + return encoderName == "libvvenc" ? AVPixelFormat.AV_PIX_FMT_YUV420P10LE : AVPixelFormat.AV_PIX_FMT_YUV420P; + } + return AVPixelFormat.AV_PIX_FMT_YUV420P; + } + public void RequestFrameCapture(Texture texture, long currentFrame) { if (!RenderManager.IsCapturingActive) return; @@ -62,15 +86,74 @@ namespace ADOFAIRenderer.Util state.IsInFlight = true; } - var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel - state.Setup(currentFrame, bufferSize); + // GPU 컬러 변환 활성화 여부 검사 + bool useGPUConversion = RenderMod.ColorConversionShader; + AVPixelFormat expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA; + + if (useGPUConversion && RenderManager.Instance) + { + expectedFmt = PredictPixelFormat(RenderManager.Instance.Configuration.Codec); + // Compute Shader는 NV12와 YUV420P만 지원하므로 그 외 포맷은 CPU 폴백 처리 + if (expectedFmt != AVPixelFormat.AV_PIX_FMT_NV12 && expectedFmt != AVPixelFormat.AV_PIX_FMT_YUV420P) + { + useGPUConversion = false; + } + } - AsyncGPUReadback.RequestIntoNativeArray( - ref state.NativeBuffer, - texture, - 0, - state.CachedCallback - ); + var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel + state.Setup(currentFrame, bufferSize, useGPUConversion, expectedFmt, texture.width, texture.height); + + if (useGPUConversion && state.GPUBuffer != null && RenderMod.ColorConversionShader) + { + int kernelIndex = -1; + if (expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12) + { + kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_NV12"); + } + else if (expectedFmt == AVPixelFormat.AV_PIX_FMT_YUV420P) + { + kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_YUV420P"); + } + + if (kernelIndex >= 0) + { + RenderMod.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture); + RenderMod.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer); + RenderMod.ColorConversionShader.SetInt(Width, texture.width); + RenderMod.ColorConversionShader.SetInt(Height, texture.height); + + var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12 + ? (texture.width + 31) / 32 + : (texture.width + 63) / 64; + var threadGroupsY = (texture.height + 15) / 16; + + RenderMod.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1); + + AsyncGPUReadback.Request( + state.GPUBuffer, + state.CachedCallback + ); + } + else + { + // 커널을 못 찾은 경우 기존 RGBA 텍스처 백업 기법으로 폴백 + AsyncGPUReadback.RequestIntoNativeArray( + ref state.NativeBuffer, + texture, + 0, + state.CachedCallback + ); + } + } + else + { + AsyncGPUReadback.RequestIntoNativeArray( + ref state.NativeBuffer, + texture, + 0, + state.CachedCallback + ); + } } internal void OnReadbackComplete(ReadbackRequestState state, AsyncGPUReadbackRequest request) @@ -101,7 +184,9 @@ namespace ADOFAIRenderer.Util } Melon.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame."); - var byteSize = state.NativeBuffer.Length; + var byteSize = state.IsGPUConversion + ? state.Width * state.Height * 3 / 2 + : state.Width * state.Height * 4; var blackFrame = ArrayPool.Shared.Rent(byteSize); Array.Clear(blackFrame, 0, byteSize); @@ -117,14 +202,32 @@ namespace ADOFAIRenderer.Util { unsafe { - var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr(); - var capFrame = new RenderManager.CapturedFrame + if (state.IsGPUConversion) { - NativePointer = ptr, - Length = state.NativeBuffer.Length, - ReadbackState = state - }; - PushToQueue(frame, capFrame); + var data = request.GetData(); + // 비동기 요청 버퍼 데이터를 지속성(NativeBuffer) 메모리로 복사 + NativeArray.Copy(data, state.NativeBuffer, data.Length); + + var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr(); + var capFrame = new RenderManager.CapturedFrame + { + NativePointer = ptr, + Length = state.NativeBuffer.Length, + ReadbackState = state + }; + PushToQueue(frame, capFrame); + } + else + { + var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr(); + var capFrame = new RenderManager.CapturedFrame + { + NativePointer = ptr, + Length = state.NativeBuffer.Length, + ReadbackState = state + }; + PushToQueue(frame, capFrame); + } } } } @@ -178,6 +281,11 @@ namespace ADOFAIRenderer.Util public NativeArray NativeBuffer; public bool IsInFlight { get; internal set; } + public ComputeBuffer? GPUBuffer; + public bool IsGPUConversion { get; private set; } + public int Width { get; private set; } + public int Height { get; private set; } + public readonly Action CachedCallback; public ReadbackRequestState(FrameReader owner) @@ -187,19 +295,34 @@ namespace ADOFAIRenderer.Util FrameIndex = -1; } - public void Setup(long frameIndex, int bufferSize) + public void Setup(long frameIndex, int bufferSize, bool isGPUConversion, AVPixelFormat pixelFormat, int width, int height) { FrameIndex = frameIndex; - - // 버퍼가 생성되지 않았거나 크기가 다른 경우 재생성 - if (!NativeBuffer.IsCreated || NativeBuffer.Length != bufferSize) + IsGPUConversion = isGPUConversion; + Width = width; Height = height; + + var readbackSize = isGPUConversion ? (width * height * 3 / 2) : bufferSize; + if (!NativeBuffer.IsCreated || NativeBuffer.Length != readbackSize) { - // 이전 메모리 해제 if (NativeBuffer.IsCreated) NativeBuffer.Dispose(); - NativeBuffer = new NativeArray(bufferSize, Allocator.Persistent, + NativeBuffer = new NativeArray(readbackSize, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); } + + if (isGPUConversion) + { + var computeBufferSize = readbackSize / 4; + if (GPUBuffer != null && GPUBuffer.count == computeBufferSize) return; + + GPUBuffer?.Dispose(); + GPUBuffer = new ComputeBuffer(computeBufferSize, 4, ComputeBufferType.Raw); + } + else + { + GPUBuffer?.Dispose(); + GPUBuffer = null; + } } private void OnComplete(AsyncGPUReadbackRequest request) @@ -215,6 +338,8 @@ namespace ADOFAIRenderer.Util public void Dispose() { if (NativeBuffer.IsCreated) NativeBuffer.Dispose(); + GPUBuffer?.Dispose(); + GPUBuffer = null; } } } \ No newline at end of file diff --git a/Util/GPUUtil.cs b/Util/GPUUtil.cs index b6a9293..5beebea 100644 --- a/Util/GPUUtil.cs +++ b/Util/GPUUtil.cs @@ -33,27 +33,43 @@ namespace ADOFAIRenderer.Util [Codec.VVC] = new List<(GPUVendor, string)> { (GPUVendor.Unknown, "libvvenc") } }; + private static GPUVendor _cachedVendor = GPUVendor.Unknown; + private static bool _hasCachedVendor = false; + private static GPUVendor GetActiveGPUVendor() { + if (_hasCachedVendor) return _cachedVendor; + var gpuName = UnityEngine.SystemInfo.graphicsDeviceName; Melon.Logger.Msg($"Active GPU: {gpuName}"); + _cachedVendor = GPUVendor.Unknown; foreach (Match unused in Regex.Matches(gpuName, NvidiaPattern)) { - return GPUVendor.NVIDIA; + _cachedVendor = GPUVendor.NVIDIA; + break; } - foreach (Match unused in Regex.Matches(gpuName, IntelPattern)) + if (_cachedVendor == GPUVendor.Unknown) { - return GPUVendor.Intel; + foreach (Match unused in Regex.Matches(gpuName, IntelPattern)) + { + _cachedVendor = GPUVendor.Intel; + break; + } } - foreach (Match unused in Regex.Matches(gpuName, AMDPattern)) + if (_cachedVendor == GPUVendor.Unknown) { - return GPUVendor.AMD; + foreach (Match unused in Regex.Matches(gpuName, AMDPattern)) + { + _cachedVendor = GPUVendor.AMD; + break; + } } - return GPUVendor.Unknown; + _hasCachedVendor = true; + return _cachedVendor; } public static List GetPrioritizedEncoderCandidates(Codec codec)