using UnityEngine; using UnityEngine.Experimental.Rendering; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.Rendering.RenderGraphModule; namespace YAMO { public class NiloToonCharToneAdjustFeature : ScriptableRendererFeature { public enum DebugMode { [InspectorName("Character Only (스텐실 마스킹)")] Normal, [InspectorName("Full Screen (전체 화면, 디버그)")] FullScreen, [InspectorName("Stencil View (마스크 시각화, 디버그)")] StencilView, } [System.Serializable] public class Settings { [Tooltip("YAMO/NiloToonCharToneAdjust 셰이더를 할당해주세요.")] public Shader shader; [Space] [Tooltip("Normal: 캐릭터 픽셀에만 적용 (Pass 1 + StencilFill)\n" + "FullScreen: 전체 화면에 적용 (Pass 2, 디버그)\n" + "StencilView: 스텐실 마스크를 빨간 오버레이로 시각화 (디버그)")] public DebugMode debugMode = DebugMode.Normal; } public Settings settings = new Settings(); CharToneAdjustPass _pass; // ── Auto Match 원샷 요청 (에디터에서 설정, Pass에서 처리) ──── public static bool autoMatchRequested; public static NiloToonCharToneAdjustVolume autoMatchTarget; public static float autoMatchBrightnessStrength = 0.5f; public static float autoMatchTintStrength = 0.5f; public static float autoMatchSaturationStrength = 0.5f; public override void Create() { if (settings.shader == null) { Debug.LogWarning("[NiloToonCharToneAdjust] Shader이 할당되지 않았습니다."); return; } _pass = new CharToneAdjustPass(settings.shader); _pass.renderPassEvent = (RenderPassEvent)((int)RenderPassEvent.AfterRenderingTransparents + 1); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (_pass == null) return; var cameraType = renderingData.cameraData.cameraType; if (cameraType == CameraType.Preview || cameraType == CameraType.Reflection) return; var volume = VolumeManager.instance.stack.GetComponent(); if (volume == null || !volume.IsActive()) return; _pass.debugMode = settings.debugMode; renderer.EnqueuePass(_pass); } protected override void Dispose(bool disposing) => _pass?.Dispose(); // ══════════════════════════════════════════════════════════════ class CharToneAdjustPass : ScriptableRenderPass { static readonly ShaderTagId _stencilFillTagId = new ShaderTagId("NiloToonCharacterAreaStencilBufferFill"); readonly Material _material; // ── Pass 인덱스 ───────────────────────────────────────── const int k_PassCopy = 0; const int k_PassToneAdjust = 1; const int k_PassToneAdjustFull = 2; const int k_PassStencilView = 3; const int k_PassDebugStencilFill = 4; const int k_PassMeshStencilFill = 5; const int k_PassMaskedDownsample = 7; const int k_PassWeightedDownsample = 8; const int k_PassStencilToMask = 9; public DebugMode debugMode = DebugMode.Normal; // ── 셰이더 프로퍼티 ID ────────────────────────────────── static readonly int _MainTexId = Shader.PropertyToID("_MainTex"); static readonly int _MainTex_TexelSizeId = Shader.PropertyToID("_MainTex_TexelSize"); static readonly int _MaskTexId = Shader.PropertyToID("_MaskTex"); static readonly int _InvertMaskId = Shader.PropertyToID("_InvertMask"); static readonly int _LiftId = Shader.PropertyToID("_CharToneAdjust_Lift"); static readonly int _GammaId = Shader.PropertyToID("_CharToneAdjust_Gamma"); static readonly int _GainId = Shader.PropertyToID("_CharToneAdjust_Gain"); static readonly int _ShadowsId = Shader.PropertyToID("_CharToneAdjust_Shadows"); static readonly int _MidtonesId = Shader.PropertyToID("_CharToneAdjust_Midtones"); static readonly int _HighlightsId = Shader.PropertyToID("_CharToneAdjust_Highlights"); static readonly int _SMHRangeId = Shader.PropertyToID("_CharToneAdjust_SMHRange"); static readonly int _SaturationId = Shader.PropertyToID("_CharToneAdjust_Saturation"); static readonly int _PostExposureId = Shader.PropertyToID("_CharToneAdjust_PostExposure"); static readonly int _BlendAmountId = Shader.PropertyToID("_CharToneAdjust_BlendAmount"); static readonly Vector3 LumaW = new Vector3(0.2126729f, 0.7151522f, 0.0721750f); // ── 원샷 분석 상태 ────────────────────────────────────── enum AnalysisPhase { Idle, Running, ReadbackPending } AnalysisPhase _analysisPhase = AnalysisPhase.Idle; int _analysisStartFrame; RenderTexture _charResult1x1; RenderTexture _bgResult1x1; Color _capturedCharAvg; Color _capturedBgAvg; int _readbackCount; bool _charValid, _bgValid; // ── PassData ──────────────────────────────────────────── class PassData { public TextureHandle colorHandle; public TextureHandle depthHandle; public TextureHandle tempHandle; public RendererListHandle stencilList; public Material material; public NiloToonCharToneAdjustVolume volume; public DebugMode debugMode; // 원샷 분석 (Match 버튼) public bool runAnalysis; public TextureHandle maskHandle; public TextureHandle down64, down16, down4; public RenderTexture charResult1x1, bgResult1x1; public int cameraWidth, cameraHeight; } // ── 생성/파기 ─────────────────────────────────────────── public CharToneAdjustPass(Shader shader) { _material = CoreUtils.CreateEngineMaterial(shader); } public void Dispose() { CoreUtils.Destroy(_material); if (_charResult1x1 != null) { _charResult1x1.Release(); Object.DestroyImmediate(_charResult1x1); } if (_bgResult1x1 != null) { _bgResult1x1.Release(); Object.DestroyImmediate(_bgResult1x1); } } void EnsureAnalysisRTs() { if (_charResult1x1 != null) return; var desc = new RenderTextureDescriptor(1, 1, RenderTextureFormat.ARGBFloat, 0); _charResult1x1 = new RenderTexture(desc) { name = "_CharResult1x1", filterMode = FilterMode.Point }; _bgResult1x1 = new RenderTexture(desc) { name = "_BgResult1x1", filterMode = FilterMode.Point }; _charResult1x1.Create(); _bgResult1x1.Create(); } // ── 원샷 리드백 요청 ─────────────────────────────────── void RequestOneShotReadback() { _readbackCount = 0; _charValid = false; _bgValid = false; _capturedCharAvg = Color.black; _capturedBgAvg = Color.black; AsyncGPUReadback.Request(_charResult1x1, 0, req => { if (!req.hasError) { var data = req.GetData(); if (data.Length > 0 && data[0].a > 0.001f) { _capturedCharAvg = data[0]; _charValid = true; } } _readbackCount++; if (_readbackCount >= 2) OnReadbackComplete(); }); AsyncGPUReadback.Request(_bgResult1x1, 0, req => { if (!req.hasError) { var data = req.GetData(); if (data.Length > 0 && data[0].a > 0.001f) { _capturedBgAvg = data[0]; _bgValid = true; } } _readbackCount++; if (_readbackCount >= 2) OnReadbackComplete(); }); } // ── 리드백 완료 → 값 적용 ───────────────────────────── void OnReadbackComplete() { _analysisPhase = AnalysisPhase.Idle; if (!_charValid || !_bgValid) { Debug.LogWarning("[CharToneAdjust] Auto Match: 리드백 실패 (유효 픽셀 부족)"); autoMatchTarget = null; return; } var volume = autoMatchTarget; autoMatchTarget = null; if (volume == null) return; float sBright = autoMatchBrightnessStrength; float sTint = autoMatchTintStrength; float sSat = autoMatchSaturationStrength; Vector3 cRGB = new Vector3(_capturedCharAvg.r, _capturedCharAvg.g, _capturedCharAvg.b); Vector3 bRGB = new Vector3(_capturedBgAvg.r, _capturedBgAvg.g, _capturedBgAvg.b); float cLum = Vector3.Dot(cRGB, LumaW); float bLum = Vector3.Dot(bRGB, LumaW); Debug.Log($"[CharToneAdjust AutoMatch] char=({cRGB.x:F3},{cRGB.y:F3},{cRGB.z:F3}) lum={cLum:F3}" + $" | bg=({bRGB.x:F3},{bRGB.y:F3},{bRGB.z:F3}) lum={bLum:F3}" + $" | strength B={sBright:F2} T={sTint:F2} S={sSat:F2}"); if (cLum < 1e-4f || bLum < 1e-4f) { Debug.LogWarning("[CharToneAdjust] Auto Match: 휘도가 너무 낮아 보정 불가"); return; } #if UNITY_EDITOR UnityEditor.Undo.RecordObject(volume, "Auto Match Tone Adjust"); #endif // 밝기 보정 → PostExposure (EV) if (sBright > 0.001f) { float ev = Mathf.Log(bLum / cLum, 2f) * sBright; volume.postExposure.value = Mathf.Clamp(ev, -5f, 5f); volume.postExposure.overrideState = true; } // 색조 보정 → Midtones if (sTint > 0.001f) { Vector3 cTint = cRGB / cLum; Vector3 bTint = bRGB / bLum; Vector3 diff = (bTint - cTint) * sTint; volume.midtones.value = new Vector4( 1f + Mathf.Clamp(diff.x, -0.5f, 0.5f), 1f + Mathf.Clamp(diff.y, -0.5f, 0.5f), 1f + Mathf.Clamp(diff.z, -0.5f, 0.5f), 0f); volume.midtones.overrideState = true; } // 채도 보정 → Saturation if (sSat > 0.001f) { float cChroma = (Mathf.Max(cRGB.x, cRGB.y, cRGB.z) - Mathf.Min(cRGB.x, cRGB.y, cRGB.z)) / Mathf.Max(cLum, 1e-4f); float bChroma = (Mathf.Max(bRGB.x, bRGB.y, bRGB.z) - Mathf.Min(bRGB.x, bRGB.y, bRGB.z)) / Mathf.Max(bLum, 1e-4f); float satRatio = bChroma / Mathf.Max(cChroma, 1e-4f); volume.saturation.value = Mathf.Clamp(Mathf.Lerp(1f, satRatio, sSat), 0f, 2f); volume.saturation.overrideState = true; } #if UNITY_EDITOR UnityEditor.EditorUtility.SetDirty(volume); #endif Debug.Log($"[CharToneAdjust AutoMatch] 적용 완료 — " + $"PostExposure={volume.postExposure.value:F3}, " + $"Midtones={volume.midtones.value}, " + $"Saturation={volume.saturation.value:F3}"); } // ── RecordRenderGraph ─────────────────────────────────── public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { if (_material == null) return; var resourceData = frameData.Get(); var renderingData = frameData.Get(); var cameraData = frameData.Get(); if (cameraData.camera.cameraType == CameraType.Preview) return; var volume = VolumeManager.instance.stack.GetComponent(); if (volume == null || !volume.IsActive()) return; // ── 원샷 분석: 대상 카메라 판별 ──────────────────── // Play 모드 → Game 카메라, Edit 모드 → SceneView 카메라 var camType = cameraData.camera.cameraType; bool isTargetCamera = Application.isPlaying ? camType == CameraType.Game : camType == CameraType.SceneView; // ── 원샷 분석 상태 머신 ──────────────────────────── if (_analysisPhase == AnalysisPhase.Running && isTargetCamera) { if (Time.frameCount >= _analysisStartFrame + 2) { RequestOneShotReadback(); _analysisPhase = AnalysisPhase.ReadbackPending; } #if UNITY_EDITOR else { UnityEditor.EditorApplication.QueuePlayerLoopUpdate(); } #endif } // 새 요청 수락 (대상 카메라 + Idle 상태 + FullScreen이 아닐 때) bool runAnalysis = false; if (autoMatchRequested && _analysisPhase == AnalysisPhase.Idle && debugMode != DebugMode.FullScreen && isTargetCamera) { autoMatchRequested = false; runAnalysis = true; _analysisPhase = AnalysisPhase.Running; _analysisStartFrame = Time.frameCount; EnsureAnalysisRTs(); } var colorHandle = resourceData.activeColorTexture; var depthHandle = resourceData.activeDepthTexture; var camDesc = cameraData.cameraTargetDescriptor; // 임시 컬러 텍스처 var tempHandle = renderGraph.CreateTexture(new TextureDesc(camDesc.width, camDesc.height) { format = camDesc.graphicsFormat, filterMode = FilterMode.Bilinear, name = "_CharToneAdjustTemp", }); // 스텐실 재마킹용 RendererList (항상 Pass 5: stencil only) var sortSettings = new SortingSettings(cameraData.camera) { criteria = SortingCriteria.CommonOpaque }; var drawSettings = new DrawingSettings(_stencilFillTagId, sortSettings) { overrideMaterial = _material, overrideMaterialPassIndex = k_PassMeshStencilFill, }; var filterSettings = new FilteringSettings(RenderQueueRange.opaque); var rlParams = new RendererListParams(renderingData.cullResults, drawSettings, filterSettings); var stencilList = renderGraph.CreateRendererList(rlParams); // 분석용 텍스처 (Match 요청 시에만 생성) TextureHandle maskHandle = TextureHandle.nullHandle; TextureHandle down64 = TextureHandle.nullHandle; TextureHandle down16 = TextureHandle.nullHandle; TextureHandle down4 = TextureHandle.nullHandle; if (runAnalysis) { maskHandle = renderGraph.CreateTexture(new TextureDesc(camDesc.width, camDesc.height) { format = GraphicsFormat.R8_UNorm, filterMode = FilterMode.Bilinear, name = "_CharMask", }); var fmt = GraphicsFormat.R16G16B16A16_SFloat; down64 = renderGraph.CreateTexture(new TextureDesc(64, 64) { format = fmt, filterMode = FilterMode.Bilinear, name = "_Down64" }); down16 = renderGraph.CreateTexture(new TextureDesc(16, 16) { format = fmt, filterMode = FilterMode.Bilinear, name = "_Down16" }); down4 = renderGraph.CreateTexture(new TextureDesc(4, 4) { format = fmt, filterMode = FilterMode.Bilinear, name = "_Down4" }); } // ── UnsafePass 등록 ───────────────────────────────── using (var builder = renderGraph.AddUnsafePass( "NiloToonCharToneAdjust", out var pd)) { pd.colorHandle = colorHandle; pd.depthHandle = depthHandle; pd.tempHandle = tempHandle; pd.stencilList = stencilList; pd.material = _material; pd.volume = volume; pd.debugMode = debugMode; pd.runAnalysis = runAnalysis; pd.maskHandle = maskHandle; pd.down64 = down64; pd.down16 = down16; pd.down4 = down4; pd.charResult1x1 = _charResult1x1; pd.bgResult1x1 = _bgResult1x1; pd.cameraWidth = camDesc.width; pd.cameraHeight = camDesc.height; builder.UseTexture(colorHandle, AccessFlags.ReadWrite); builder.UseTexture(depthHandle, AccessFlags.ReadWrite); builder.UseTexture(tempHandle, AccessFlags.ReadWrite); builder.UseRendererList(stencilList); if (runAnalysis) { builder.UseTexture(maskHandle, AccessFlags.ReadWrite); builder.UseTexture(down64, AccessFlags.ReadWrite); builder.UseTexture(down16, AccessFlags.ReadWrite); builder.UseTexture(down4, AccessFlags.ReadWrite); } builder.AllowPassCulling(false); builder.SetRenderFunc(static (PassData data, UnsafeGraphContext ctx) => { var cmd = ctx.cmd; SetMaterialProperties(data.volume, data.material); if (data.debugMode == DebugMode.FullScreen) ExecuteFullScreen(data, cmd); else ExecuteStencilMode(data, cmd); }); } } // ── FullScreen 모드 렌더링 ────────────────────────────── static void ExecuteFullScreen(PassData data, UnsafeCommandBuffer cmd) { cmd.SetRenderTarget(data.tempHandle, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store); cmd.SetGlobalTexture(_MainTexId, data.colorHandle); cmd.DrawProcedural(Matrix4x4.identity, data.material, k_PassCopy, MeshTopology.Triangles, 3); cmd.SetRenderTarget(data.colorHandle, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store); cmd.SetGlobalTexture(_MainTexId, data.tempHandle); cmd.DrawProcedural(Matrix4x4.identity, data.material, k_PassToneAdjustFull, MeshTopology.Triangles, 3); } // ── Stencil(Normal/StencilView) 모드 렌더링 ───────────── static void ExecuteStencilMode(PassData data, UnsafeCommandBuffer cmd) { // ── Step 1: 스텐실 재마킹 (Pass 5, ColorMask 0) ──── cmd.SetRenderTarget( data.colorHandle, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store, data.depthHandle, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store); cmd.DrawRendererList(data.stencilList); // ── Step 1b+2: 원샷 분석 (Match 버튼 요청 시) ────── if (data.runAnalysis) { // 스텐실 → 마스크 변환 (Pass 9) cmd.SetRenderTarget( data.maskHandle, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store, data.depthHandle, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store); cmd.ClearRenderTarget(false, true, Color.clear); cmd.DrawProcedural(Matrix4x4.identity, data.material, k_PassStencilToMask, MeshTopology.Triangles, 3); // 캐릭터 분석: full-res → 64 → 16 → 4 → 1 RunAnalysisChain(data, cmd, invertMask: 0f, resultRT: data.charResult1x1); // 배경 분석: full-res → 64 → 16 → 4 → 1 RunAnalysisChain(data, cmd, invertMask: 1f, resultRT: data.bgResult1x1); } // ── Step 3: color → temp 복사 ─────────────────────── cmd.SetRenderTarget(data.tempHandle, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store); cmd.SetGlobalTexture(_MainTexId, data.colorHandle); cmd.DrawProcedural(Matrix4x4.identity, data.material, k_PassCopy, MeshTopology.Triangles, 3); // ── Step 4: temp → color 색조 보정 (스텐실) ───────── int passIdx = data.debugMode == DebugMode.StencilView ? k_PassStencilView : k_PassToneAdjust; cmd.SetRenderTarget( data.colorHandle, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store, data.depthHandle, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store); cmd.SetGlobalTexture(_MainTexId, data.tempHandle); cmd.DrawProcedural(Matrix4x4.identity, data.material, passIdx, MeshTopology.Triangles, 3); } // ── 분석 다운샘플 체인 ────────────────────────────────── static void RunAnalysisChain(PassData data, UnsafeCommandBuffer cmd, float invertMask, RenderTexture resultRT) { var mat = data.material; // 첫 단계: MaskedDownsample (full-res → 64x64) cmd.SetGlobalVector(_MainTex_TexelSizeId, new Vector4(1f / data.cameraWidth, 1f / data.cameraHeight, data.cameraWidth, data.cameraHeight)); cmd.SetRenderTarget(data.down64, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store); cmd.SetGlobalTexture(_MainTexId, data.colorHandle); cmd.SetGlobalTexture(_MaskTexId, data.maskHandle); cmd.SetGlobalFloat(_InvertMaskId, invertMask); cmd.DrawProcedural(Matrix4x4.identity, mat, k_PassMaskedDownsample, MeshTopology.Triangles, 3); // 64 → 16 cmd.SetGlobalVector(_MainTex_TexelSizeId, new Vector4(1f/64, 1f/64, 64, 64)); cmd.SetRenderTarget(data.down16, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store); cmd.SetGlobalTexture(_MainTexId, data.down64); cmd.DrawProcedural(Matrix4x4.identity, mat, k_PassWeightedDownsample, MeshTopology.Triangles, 3); // 16 → 4 cmd.SetGlobalVector(_MainTex_TexelSizeId, new Vector4(1f/16, 1f/16, 16, 16)); cmd.SetRenderTarget(data.down4, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store); cmd.SetGlobalTexture(_MainTexId, data.down16); cmd.DrawProcedural(Matrix4x4.identity, mat, k_PassWeightedDownsample, MeshTopology.Triangles, 3); // 4 → 1 (persistent RT) cmd.SetGlobalVector(_MainTex_TexelSizeId, new Vector4(1f/4, 1f/4, 4, 4)); cmd.SetRenderTarget(resultRT, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store); cmd.SetGlobalTexture(_MainTexId, data.down4); cmd.DrawProcedural(Matrix4x4.identity, mat, k_PassWeightedDownsample, MeshTopology.Triangles, 3); } // ── 셰이더 프로퍼티 설정 ─────────────────────────────── static void SetMaterialProperties(NiloToonCharToneAdjustVolume v, Material mat) { Vector4 lv = v.lift.value; mat.SetVector(_LiftId, new Vector4(lv.x-1f+lv.w, lv.y-1f+lv.w, lv.z-1f+lv.w, 0f)); Vector4 gv = v.gamma.value; mat.SetVector(_GammaId, new Vector4(gv.x+gv.w, gv.y+gv.w, gv.z+gv.w, 0f)); Vector4 gn = v.gain.value; mat.SetVector(_GainId, new Vector4(gn.x+gn.w, gn.y+gn.w, gn.z+gn.w, 0f)); Vector4 sv = v.shadows.value; mat.SetVector(_ShadowsId, new Vector4(sv.x-1f+sv.w, sv.y-1f+sv.w, sv.z-1f+sv.w, 0f)); Vector4 mv = v.midtones.value; mat.SetVector(_MidtonesId, new Vector4(mv.x-1f+mv.w, mv.y-1f+mv.w, mv.z-1f+mv.w, 0f)); Vector4 hv = v.highlights.value; mat.SetVector(_HighlightsId, new Vector4(hv.x-1f+hv.w, hv.y-1f+hv.w, hv.z-1f+hv.w, 0f)); mat.SetVector(_SMHRangeId, new Vector4( v.shadowsStart.value, v.shadowsEnd.value, v.highlightsStart.value, v.highlightsEnd.value)); mat.SetFloat(_SaturationId, v.saturation.value); mat.SetFloat(_PostExposureId, Mathf.Pow(2f, v.postExposure.value)); mat.SetFloat(_BlendAmountId, v.blendAmount.value); } } } }