Image Effects, Post-Processing Effects는 카메라에서 렌더링된 이미지를 가져와서 추가 처리 단계를 수행하여 이미지의 모양을 수정할 수 있게 해주는 많은 렌더링 시스템의 기능이다.
Render Textures
Render Texture는 카메라에 의해 렌더링되는 텍스처를 말한다.
렌더 텍스처는 스크린이나 게임이 실행중인 윈도우와 같은 사이즈이다.
그럼에도 더 작은 해상도에서 더 적은 메모리를 사용하며 Downsample 가능하기도 하다.
기본적으로, 유니티는 각 카메라가 보고 있는 것을 스크린으로 렌더링하는데
카메라들은 텍스처에 렌더링하는 것도 가능하다.
렌더 텍스처는 Editor를 이용하거나 프로그래밍적으로 사용 가능하다.
에디터에서 타겟 텍스처를 지정하여 카메라의 Output Texture 슬롯에 넣어 렌더링 시킨다.



렌더 텍스처는 런타임중에 스크립트를 통해 생성 되거나, 카메라 혹은 매트리얼에 등록 가능하다.
using UnityEngine;
public class RenderTextureSample : MonoBehaviour
{
public Camera cam;
public Material mat;
private RenderTexture renderTexture;
private void Start()
{
renderTexture = new RenderTexture(1920, 1080, 32, RenderTextureFormat.ARGB32);
renderTexture.Create();
cam.targetTexture = renderTexture;
mat.SetTexture("_MainTex", renderTexture);
}
}


Post-Processing Effects 에서는 어떤 식으로 사용 가능할까?
유니티는 렌더 텍스처를 프레임 버퍼의 상태를 제어하는데 사용한다.
화면에 띄우기 전에 결과 이미지를 출력하기 전에 렌더 텍스처를 사용한다는 의미이다.
렌더링 파이프라인와 다른 렌더링 루프 (Rendering Loop)의 특정 지점에서 Unity는 화면 텍스처를 노출하고 해당 내용을 수정할 수 있도록 한다.
이 부분이 바로 Image Effect Shaders들이 들어갈 자리이다.
Image Effect Shader 들은 Screen Texture를 가져와서 입력으로 사용하고 색을 변형한다. 그리고 다시 렌더링 루프로 반환한다.
Post-Processing Effects
Post-Processing Effects는 2가지의 접근 방식이 있다.
(1) C# 스크립팅 사이드
우리가 사용하는 렌더 텍스처와 이 렌더 텍스처에 적용하는 재질을 처리하는 방식
(2) Shader 사이드
렌더 텍스처의 내용을 얼마나 변형할지에 대한 처리 방식
쉐이더를 이용할 때는, 이 텍스처를 샘플링하고 컬러 처리 하는 단계가 필요하다.
Post-Processing Effects는 쉐이더에게 Quad Mesh를 제공한다. 이 메쉬는 가장자리(corners)들을 가지고 있으며 [0, 1] 사이로 커버가능한 UV를 갖고 있다. 단순한 형태이다 보니 Vertex Shader에서 다루는 방법은 매우 간단하다.
뿐만 아니라, 렌더 텍스처 또한 쉐이더에게 제공한다. 이를 Source Texture라고 한다.
Fragment Shader 내부에서 반드시 읽어와야 하고 수정 되어야 하는 텍스처이다.
그러니 Post-Processing Effects 에서 Fragment Shader를 다루는 부분에 대해 집중해야 한다.
Post-Processing은 다른 렌더 파이프라인들과 달리 지원 정도가 매우 달라 이질적이다.
- 빌트 인 렌더 파이프라인은 Post-Processing을 완전히 지원한다.
- URP에서는 공식적인 지원이 없다. 대신하여 구현할 방법이 필요하다. 오히려 빌트 인보다 Shader 단계에서 다루기 쉽다.
- HDRP는 커스텀 Post-Processing Shader를 지원한다.
- Shader Graph는 지원하지 않는다.
Grayscale Image Effect
이미지에서 각 픽셀의 색상을 가져오고, Grayscale 값으로 출력하는 이펙트이다.
사람의 눈은 녹색에 민감하고, 푸른색에는 덜 민감하다고 한다.
Grayscale Luminance Value는 다음 식을 만족한다.
Color_Output = Dot Product ( Color_Input, (0.213, 0.715, 0.072) )
Post-Processing은 사용하기 위해 스크립트와 쉐이더 둘 다 필요하다고 했다.
차근차근 생소한 부분들을 짚고 넘어가보자.
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
[System.Serializable, VolumeComponentMenu("LucidBoundary/Grayscale")]
public sealed class PPE_GrayscaleSetting: VolumeComponent, IPostProcessComponent
{
public ClampedFloatParameter strength = new ClampedFloatParameter(0.0f, 0.0f, 1.0f);
public bool IsActive() => strength.value > 0.0f && active;
public bool IsTileCompatible() => false;
}
Volume Component
볼륨 컴포넌트는 포스트 프로세싱 볼륨에 추가 될 수 있는 클래스를 말한다.
Menu 이름에 따라 생성되는 것을 확인 할 수 있다.
ClampedFloatParameter는 Volume Parameter 클래스의 일종이다.
Volume Component에서 다루어지는 파라미터들을 저장한다.
이름에서 알 수 있듯이, Clamped 된 Float을 저장하는 파라미터 혹은 인스턴스라 생각하자.
IPostProcessComponent 인터페이스는 비어있는 서식을 채워보면
IsActive(), IsTileCompatible()가 생성된다.
IsActive() 는 해당 포스트 프로세싱 효과가 동작해야 할지를 결정하는 boolean 함수
IsTileCompatible() 는 타일 기반의 렌더링 할 수 있는지를 결정하는 boolean 함수이다.
IsActive의 경우에는 strength의 값이 0.0f보다 크다면, VolumeComponent의 active가 True라면 효과를 렌더링 해도 된다라고 반환하게 된다.
[Serializable]
public class VolumeComponent : ScriptableObject, IApplyRevertPropertyContextMenuItemProvider
{
...
public bool active = true;
...
}
active의 경우에는 VolumeComponent로 들어가보니 디폴트로 True로 되어있다.


추가해보니 심플하게 Strength 하나 달려있다.
오랜만에 커스텀 에디터를 만지는 기분이다.
Scritable Render Pass
유니티 6 로 오면서 대격변을 겪어버린 Render Pass 부분이다.
Render Graph 시스템이 도입되면서, 비교적 최신 서적인데도 obsolete를 달고 있어야 하다니..
일단은 Legacy 기준으로 설명하고, Render Graph를 반영한 코드도 설명하겠다.
일반적으로 Scritable Render Pass가 하는 일은
포스트 프로세싱의 실질적인 업무를 담당하는 뇌 역할이다.
ECS에서도 자주 쓰이던 낯익은 물건들이 보인다.
using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.Universal;
public class PPE_GrayscaleRenderPass : ScriptableRenderPass
{
private Material material;
private PPE_GrayscaleSetting setting;
private RenderTargetIdentifier source;
private RenderTargetIdentifier mainTex;
private string profilerTag;
public void Setup(ScriptableRenderer renderer, string profilerTag)
{
this.profilerTag = profilerTag;
source = renderer.cameraColorTargetHandle;
VolumeStack stack = VolumeManager.instance.stack;
setting = stack.GetComponent<PPE_GrayscaleSetting>();
renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
if (setting != null && setting.IsActive())
{
renderer.EnqueuePass(this);
material = new Material(Shader.Find("LucidBoundary/BasicTextureShader_PostProcessing"));
}
}
// Unity 6 New Render Graph System
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
//if (setting == null) return;
//UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
//UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();
//int id = Shader.PropertyToID("_MainTex");
//mainTex = new RenderTargetIdentifier(id);
//var renderTexture = frameData.Get<RenderTexture>()
}
[Obsolete]
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
if (setting == null) return;
int id = Shader.PropertyToID("_MainTex");
mainTex = new RenderTargetIdentifier(id);
cmd.GetTemporaryRT(id, cameraTextureDescriptor);
base.Configure(cmd, cameraTextureDescriptor);
}
[Obsolete]
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (!setting.IsActive()) return;
CommandBuffer cmd = CommandBufferPool.Get(profilerTag);
cmd.Blit(source, mainTex);
material.SetFloat("_Strength", setting.strength.value);
cmd.Blit(mainTex, source, material);
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
CommandBufferPool.Release(cmd);
}
public override void FrameCleanup(CommandBuffer cmd)
{
cmd.ReleaseTemporaryRT(Shader.PropertyToID("_MainTex"));
}
}
구조로 접근하기
해당 스크립트는 우선, Scriptable Render Pass 클래스를 상속한다.
Scriptable Render Pass 내부에는 RecordRenderGraph (Unity6 한정), Configure, Execute 함수가 virtual 상태로 구현되어있다. 개발자는 이 함수들을 override 해서 사용해야 한다. (다른 함수들도 있다.)
함수 이름은 무척 직관적이다.
Configure해서 Pass를 준비하고, Execute에서 실질적인 동작을 하는 식이다.
Setup
Setup 함수는 임의로 만든 함수인데, Pass에 필요한 정보를 외부에서 받는 함수이다.
좀 더 파고들어보자.
public void Setup(ScriptableRenderer renderer, string profilerTag)
{
this.profilerTag = profilerTag;
source = renderer.cameraColorTargetHandle;
VolumeStack stack = VolumeManager.instance.stack;
setting = stack.GetComponent<PPE_GrayscaleSetting>();
renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
if (setting != null && setting.IsActive())
{
renderer.EnqueuePass(this);
material = new Material(Shader.Find("LucidBoundary/BasicTextureShader_PostProcessing"));
}
}
입력값으로 ScriptableRenderer와 ProfilerTag를 받고 있다.
(1) ProfilerTag
profilerTag는 Pass에서 사용될 이름이다. string 형식으로 되어있다. 나중에 커맨드 버퍼를 생성할 때 사용할 이름이다.
프로파일링 할 때 이름도 지정한다.
(2) ScriptableRenderer renderer
ScriptableRenderer는 URP에서 렌더링을 제어하는 오브젝트이다.
Legacy 기준으로, renderer.cameraColorTargetHandle은 Color Texture를 의미한다. 이 컬러 텍스처가 우리 쉐이더에 적용하고자 하는 것이다.
(3) RenderTargetIdentifier source
커맨드 버퍼를 위한 렌더 텍스처 식별자이다.
렌더 텍스처들은 여러가지 방식으로 정의될 수 있다.
RenderTexture Obejct, BuiltinRenderTexture Type, Temporary Render Texture (CommandBuffer.GetTemporaryRT로 생성)
이건 객체를 특정하는 방법으로 사용되며, 변환 오퍼레이터들을 가지고 있다.
(4) VolumeManager
볼륨 매니저는 글로벌로 사용 할 수 있는 매니저로, 현재 Scene에 로드되어 있는 모든 볼륨들을 추적(Track)하고 모든 보간 작업을
실행한다.
(5) VolumeManager의 Stack
볼륨에서 작업한 것을 저장한다. GetComponent를 통해, VolumeComponent를 찾을 수 있고, 해당 설정으로 업데이트 할 수 있게 한다.
(6) renderPassEvent
생성자 기준 RenderPassEvent.AfterRenderingOpaques가 디폴트이다.
렌더링 루프의 어디에서 이 렌더 패스를 실행 할 것인지 정할 수 있다.
현재 코드에서는 BeforeRenderingPostProcessing이다.
(7) ScriptableRenderer.EnqueuePass( ScrptableRendererPass pass)
ScriptableRendererFeature 에는 AddRenderPasses 라는 함수가 구현되는데, 이 Pass를 대기열에 등록한다.
그러면 Pass의 Configure, Execute가 자동으로 실행된다.
유니티6에서는 RecordRenderGraph 로 통합되었다.
많은 예제들을 보면, EnqueuePass를 Feature단에서 설정해주던데..
여기서는 Pass 내에서 하는게 좀 의아스럽다. Renderer를 따로 받게 되어있어서 그런가..
이제 시작인데 벌써부터 들어오는 정보량이 많다..
해당 부분은 Unity 6에서 RecordRenderGraph로 통합되었다.
Configure
[Obsolete]
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
if (setting == null) return;
int id = Shader.PropertyToID("_MainTex");
mainTex = new RenderTargetIdentifier(id);
cmd.GetTemporaryRT(id, cameraTextureDescriptor);
base.Configure(cmd, cameraTextureDescriptor);
}
Configure의 입력 파라미터부터 살펴보자.
(1) CommandBuffer
커맨드 버퍼는 렌더 타겟을 설정, 드로우 메시를 포함한 렌더링 커맨드 리스트를 포함하여 렌더링 파이프라인에서 원하는 지점에서 렌더링을 수행 할 수 있게 해준다.
(2) RenderTextureDescriptor
렌더 텍스처를 생성하기 위해 필요한 정보들을 갖고 있는 구조체이다.
(3) PropertyToID("_MainTex")
쉐이더의 프로퍼티 이름을 int형으로 변환한다.
이는 RenderTargetIdentifier인 mainTex를 인스턴스화 시키면서 임시 렌더 텍스처의 이름을 정해준다.
(4) CommandBuffer.GetTemporaryRT( ... )
커맨드 버퍼에서 임시로 렌더 텍스처를 생성한다.
Execute
[Obsolete]
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
// RTHandle ColorBuffer = renderingData.cameraData.renderer.cameraColorTargetHandle;
if (!setting.IsActive()) return;
CommandBuffer cmd = CommandBufferPool.Get(profilerTag);
cmd.Blit(source, mainTex);
material.SetFloat("_Strength", setting.strength.value);
cmd.Blit(mainTex, source, material);
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
CommandBufferPool.Release(cmd);
}
(1) ScriptableRenderContext
커스텀 렌더 파이프라인을 정의할 때 ScriptableRenderContext를 사용하여 상태를 업데이트하거나 Draw Command을 예약하고 GPU에 전송한다.
명령을 실행하면, 곧바로 실행하는게 아니라 렌더링 차례가 되었을 때, 예약해둔 함수를 실행하는 방식이다.
context.ExecuteCommandBuffer가 그러하다. 사용한 커맨드 버퍼는 Clear, Release해서 지워줘야 한다.
(2) RenderingData
렌더링 데이터 구조체는 카메라, 라이트 등 여러 데이터를 담고 있는 구조체이다.
Unity6 버전 기준으로 cameraTargetHandle을 ScriptableRenderer로부터 직접 가져오는건 안되면서, RenderingData를 통해 가져오는건 허용된다.
(3) CommandBuffer.Blit()
source RnederTargetIdenrtifier 정보를 mainTex RnederTargetIdenrtifier에 복사하도록, 커맨드 버퍼에 실행을 예약한다.
실질적으로 포스트 프로세싱을 해주는 예약 함수라고 생각하면 되겠다.
우리가 원하는건 프로세싱 된 source이다.
source 텍스처를 일단 임시 텍스처인 mainTex에 복사하고,
mainTex의 매트리얼을 건드려서 source로 다시 옮기는 작업이라고 생각하자.
FrameCleanup
public override void FrameCleanup(CommandBuffer cmd)
{
cmd.ReleaseTemporaryRT(Shader.PropertyToID("_MainTex"));
}
처리가 끝났으면, 정리도 해줘야 한다.
각 프레임이 끝날때마다 지워주는 코드이다.
Scriptable Render Feature
"뇌" 에 해당하는 Scriptable Render Pass 준비는 끝났다.
Scriptable Render Feature는 이펙트를 동작 시키기 위한 장소로 몸체라고 생각하자.
이 클래스를 사용하기 위해서는 Create 함수와 AddRenderPasses 함수를 실행시키면 된다.
using UnityEngine;
using UnityEngine.Rendering.Universal;
public class PPE_GrayscaleRenderFeature : ScriptableRendererFeature
{
PPE_GrayscaleRenderPass renderPass;
public override void Create()
{
this.name = "Grayscale Post Processing";
renderPass = new PPE_GrayscaleRenderPass();
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderPass.Setup(renderer, this.name);
}
}
코드 자체는 단순해보인다.
앞서 만들었던 RenderPass인 renderPass를 선언하고, Create 함수에서 명칭과 인스턴스 생성을 해준다.
AddRenderPasses는 Create에서 인스턴스화 시켰던 renderPass의 Setup을 통해, 필요한 정보를 전달해주고 있다.
GrayScale Shader
Shader "Lucid-Boundary/PPE_GrayscaleShader"
{
Properties
{
}
SubShader
{
Tags
{
"RenderType"="Opaque"
"RenderPipeline" = "UniversalPipeline"
}
Pass
{
ZTest Always ZWrite Off Cull Off Blend Off
Name "BlitTextureGrayScale"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
struct Attributes
{
uint vertexID : SV_VertexID;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 texcoord : TEXCOORD0;
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings FullscreenVertShared(Attributes input)
{
Varyings output;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
float4 pos = GetFullScreenTriangleVertexPosition(input.vertexID);
float2 uv = GetFullScreenTriangleTexCoord(input.vertexID);
output.positionCS = pos;
output.texcoord = uv;
return output;
}
texture2D _MainTex;
SamplerState sampler_MainTex;
CBUFFER_START(UnityPerMateria)
float4 _MainTex_ST;
float _Strength;
CBUFFER_END
Varyings vert(Attributes input)
{
return FullscreenVertShared(input);
}
half4 frag(Varyings input) : SV_Target
{
half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_LinearClamp, input.texcoord);
half3 grayScaleColor = dot(color.rgb, half3(0.3, 0.59, 0.11) * _Strength);
return half4(grayScaleColor.rgb, 1.0);
}
ENDHLSL
}
}
}
눈 여겨 보아야 할 껀, FullscreenVertShared 함수이다.
Position과 UV는 GetFullScreenTriangleVertexPosition, TexCoord 함수를 통해서 받아오고 있다.
일반적인 HClip 함수를 사용하면 제대로 적용되지 않는 문제가 있는데
Blit 처리가 삼각형으로 처리되기 때문이라 한다.
Legacy 버전에서는 문제 없었던 것 같은데.. 유니티 6 RenderGraph에서는 적용이 안된다.
유니티 6 Render Graph 시스템
크게 바뀌는 부분은 Render Pass 부분이다.
기존의 Configure, Execute 대신에 RecordRenderGraph 함수로 통합된다.
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
if (setting == null) return;
UniversalResourceData resourcesData = frameData.Get<UniversalResourceData>();
UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();
if (!material || !cameraData.postProcessEnabled) return;
// Setup RenderTextureHandle
var cameraColorTexture = resourcesData.cameraColor;
var copyDescriptor = renderGraph.GetTextureDesc(cameraColorTexture);
copyDescriptor.name = "CopiedTexture";
copyDescriptor.clearBuffer = false;
copyDescriptor.msaaSamples = MSAASamples.None;
copyDescriptor.depthBufferBits = 0;
var copyColorTexture = renderGraph.CreateTexture(copyDescriptor);
material.SetFloat("_Strength", setting.strength.value);
RenderGraphUtils.BlitMaterialParameters para = new(cameraColorTexture, copyColorTexture, material, 0);
renderGraph.AddBlitPass(para);
resourcesData.cameraColor = copyColorTexture;
}

렌더 패스 부분.. 업데이트 되고나서 혼동이 생겨 참 어려웠다....
'Game Dev > Unity Shader' 카테고리의 다른 글
| 21. Geometry Shader - 다니엘 릿 쉐이더 프로젝트 (0) | 2025.10.27 |
|---|---|
| 20. Tessellation Shader - 다니엘 릿 쉐이더 프로젝트 (0) | 2025.10.26 |
| 18. Shadow Casting - 다니엘 릿 쉐이더 프로젝트 (0) | 2025.10.21 |
| 17. Physically Based Rendering (PBR) - 다니엘 릿 쉐이더 프로젝트 (0) | 2025.10.21 |
| 16. Flat Shading, Gouraud Shading, Phong Shading - 다니엘 릿 쉐이더 프로젝트 (1) | 2025.10.19 |