Outlines via Postprocessing
Summary One of my favourite postprocessing effects are outlines. Doing outlines via postprocessing has many advantages. It’s better at detecting edges than the alternative (inverted hull outlines) and you don’t have to change all of your materials to g
www.ronja-tutorials.com
Ronja 님의 허락을 받고 번역한 튜토리얼입니다. 원문은 위 링크에서 확인하실 수 있습니다.
몇몇 부분은 생략·추가하였습니다.
의역과 오역이 넘쳐날 수 있으니 편하게 봐주시고 잘못된 부분은 알려주시면 감사하겠습니다!
목차
- 개요
- 깊이 기반 외곽선
- 노말 기반 외곽선
- 커스텀 가능한 외곽선들
- 소스 코드
개요
가장 좋아하는 포스트프로세싱 효과 중 하나는 외곽선 효과입니다. 포스트프로세싱을 통해 외곽선을 구현하는 방식에는 많은 장점이 있습니다. 대안적인 방법인 인버티드 헐(inverted hull) 외곽선보다 엣지를 감지하는 데 더 뛰어나며, 외곽선 효과를 적용하기 위해 모든 머티리얼을 수정할 필요도 없습니다.
포스트프로세싱으로 외곽선을 만드는 방법을 이해하려면, 먼저 씬의 깊이(depth)와 노말 값에 접근하는 방법을 이해하고 있는 편이 좋습니다.

깊이 기반 외곽선
먼저 노말을 사용하는 포스트프로세싱 튜토리얼에서 사용했던 셰이더와 C# 스크립트를 기반으로 시작하겠습니다.
첫 변경 사항은 'color on top' 셰이더에만 필요했던 프로퍼티와 변수들을 제거하는 것입니다. 따라서, 컷오프 값과 색상 변수를 삭제했습니다. 또한 외곽선은 월드 공간에서 특정한 회전을 가질 필요가 없기 때문에, 뷰-월드 행렬(view to world matrix)도 제거했습니다. 그 다음, 깊이와 노말을 계산하는 부분 이후에 있는 모든 코드도 함께 제거했습니다.
// 인스펙터 창에서 조절 가능한 값을 노출시킵니다.
Properties
{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
}
// ···
// 프래그먼트 셰이더
fixed4 frag(v2f i) : SV_TARGET
{
// 뎁스노말 값을 읽습니다.
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);
// 뎁스노말 값을 디코딩합니다.
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);
// 뎁스 값을 카메라로부터의 거리 값(unit 단위)으로 가져옵니다.
depth = depth * _ProjectionParams.z;
}
그 다음으로, C# 스크립트에서 카메라 행렬을 셰이더로 전달하던 부분을 제거하겠습니다.
// 카메라 렌더링이 끝난 후 유니티가 자동으로 호출하는 메소드
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
// 소스 텍스처의 픽셀을 데스티네이션 텍스처에 그려 넣습니다.
Graphics.Blit(source, destination, postprocessMaterial);
}
외곽선을 계산하는 방식은, 현재 렌더링하고 있는 픽셀 주변의 여러 픽셀을 읽어와 중심 픽셀과의 깊이값 및 노말 차이를 계산하는 것입니다. 이 차이가 클수록 외곽선은 더 강하게 나타납니다.
주변 픽셀의 위치를 계산하려면, 하나의 픽셀이 어느 정도의 크기를 가지는지 알아야 합니다. 다행히도, 특정한 이름을 가진 변수를 하나 추가하기만 하면 Unity가 그 크기를 자동으로 알려줍니다. 기술적으로는 텍스처 픽셀 단위로 작업하고 있기 때문에, 이를 텍셀 크기(texel size) 라고 부릅니다.
어떤 텍스처든 텍스처이름_TexelSize라는 변수를 생성하면, 해당 텍스처의 픽셀 크기 정보를 얻을 수 있습니다.
// 뎁스노말 텍스처
sampler2D _CameraDepthNormalsTexture;
// 뎁스노말 텍스처의 텍셀 사이즈
float4 _CameraDepthNormalsTexture_TexelSize;
그 다음으로, 깊이와 노말에 접근하는 코드를 그대로 복사하되, 변수 이름을 변경하고 텍스처를 약간 오른쪽 위치에서 샘플링하도록 수정하겠습니다.
// 근처 픽셀 읽기
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture,
uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
float3 neighborNormal;
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
neighborDepth = neighborDepth * _ProjectionParams.z;
이제 두 개의 샘플을 얻었으므로, 그 차이를 계산하여 화면에 출력할 수 있습니다.
float difference = depth - neightborDepth;
return difference;

이렇게 하면 오브젝트의 왼쪽 가장자리에 외곽선이 나타나는 것을 이미 확인할 수 있습니다. 다음 예제로 넘어가기 전에, 동일한 코드를 네 번이나 작성하지 않도록 샘플을 읽고 중심 값과 비교하는 부분을 별도의 함수로 분리하고자 합니다. 이 함수는 중심 픽셀의 깊이값, 중심 픽셀의 UV 좌표, 그리고 오프셋(offset)을 인자로 받습니다. 오프셋은 읽기 쉽도록 픽셀 단위로 정의하겠습니다.
이를 위해 프래그먼트 함수에 있던 코드를 새 메서드로 그대로 복사한 뒤, 깊이값과 UV 변수 이름을 해당 인자 이름으로 교체합니다. 오프셋을 적용할 때는, 오프셋에 텍셀 크기의 x와 y 성분을 각각 곱한 다음, 이전과 동일한 방식으로 그 결과를 UV 좌표에 더해주면 됩니다.
새 메서드를 설정한 후에는, 프래그먼트 함수에서 이를 호출하고 그 결과를 화면에 출력하겠습니다.
void Compare(float baseDepth, float2 uv, float2 offset)
{
// 주변 픽셀 읽기
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture,
uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
float3 neighborNormal;
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
neighborDepth = neighborDepth * _ProjectionParams.z;
return baseDepth - neighborDepth;
}
float depthDifference = Compare(depth, i.uv, float2(1, 0));
return depthDifference;
}
결과는 이전과 정확히 동일하게 보이겠지만, 이제 셰이더를 확장하여 여러 방향에서 샘플을 읽는 작업이 훨씬 수월해졌습니다. 따라서 위쪽, 오른쪽, 아래쪽 방향의 픽셀들도 추가로 샘플링하고, 모든 결과값을 서로 더해주면 됩니다.
// 프래그먼트 셰이더
fixed4 frag(v2f i) : SV_TARGET
{
// 뎁스노말 값을 읽습니다.
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);
// 뎁스노말 값을 디코딩합니다.
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);
// 뎁스 값을 카메라로부터의 거리 값(unit 단위)으로 가져옵니다.
depth = depth * _ProjectionParams.z;
float depthDifference = Compare(depth, i.uv, float2(1, 0));
depthDifference = depthDifference + Compare(depth, i.uv, float2(0, 1));
depthDifference = depthDifference + Compare(depth, i.uv, float2(0, -1));
depthDifference = depthDifference + Compare(depth, i.uv, float2(-1, 0));
return depthDifference;
}

노말 기반 외곽선
깊이값만 사용해도 이미 꽤 좋은 외곽선을 얻을 수 있지만, 여기에 노말 정보를 함께 활용하면 한 단계 더 나아갈 수 있습니다. 이를 위해 비교 함수(compare function)에서 노말 역시 함께 샘플링하겠습니다. 다만 HLSL에서는 함수가 하나의 값만 반환할 수 있기 때문에, 이 경우 반환값을 그대로 사용할 수는 없습니다.
대신 inout 키워드를 사용한 두 개의 새로운 인자를 추가하겠습니다. 이 키워드를 사용하면 함수에 전달된 값을 함수 내부에서 수정할 수 있으며, 그 변경 사항은 함수 안에서만 적용되는 것이 아니라, 함수에 전달된 원래 변수에도 그대로 반영됩니다.
또한 노말을 이용해 외곽선을 생성하려면 중심 픽셀의 노말 정보도 필요하므로, 이 역시 함수 인자 목록에 추가하겠습니다.
void Compare(inout float depthOutline, inout float normalOutline,
float baseDepth, float3 baseNormal, float2 uv, float2 offset)
{
이제 외곽선 변수에 대해 완전히 제어할 수 있게 되었으므로, 메서드 내부에서 기존 외곽선 값에 더하는 처리도 함께 수행할 수 있습니다. 이 부분을 수정한 뒤에는 다시 프래그먼트 메서드로 돌아가 노말 차이를 저장할 새로운 변수를 생성하고, 변경된 인자 구조에 맞게 비교 메서드를 호출하는 방식을 수정하겠습니다.
void Compare(inout float depthOutline, inout float normalOutline,
float baseDepth, float3 baseNormal, float2 uv, float2 offset)
{
// 주변 픽셀을 읽습니다.
float4 neighborDepthnormal = tex2D(
_CameraDepthNormalsTexture,
uv + _CameraDepthNormalsTexture_TexelSize.xy * offset
);
float3 neighborNormal;
float neighborDepth;
// 뎁스노말 값을 디코딩합니다.
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
// 뎁스 값을 카메라로부터의 거리 값(unit 단위)으로 변환합니다.
neighborDepth = neighborDepth * _ProjectionParams.z;
// 중심 픽셀과 주변 픽셀의 뎁스 차이를 계산합니다.
float depthDifference = baseDepth - neighborDepth;
// 기존 뎁스 외곽선 값에 차이 값을 누적해 계산합니다.
depthOutline = depthOutline + depthDifference;
}
float depthDifference = 0;
float normalDifference = 0;
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(1, 0));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, 1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, -1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(-1, 0));
return depthDifference;
이 변경 역시 메서드의 출력 결과를 바꾸지는 않지만, 새로운 구조를 통해 노말 차이값 역시 함께 조정할 수 있게 되었습니다. 두 개의 정규화된 벡터를 빠르고 간단하게 비교하는 방법으로는 내적(dot product) 을 사용하는 방식이 있습니다.
다만 내적의 경우, 두 벡터가 같은 방향을 가리킬 때 값이 1이 되고, 서로 멀어질수록 값이 작아지는데, 이는 우리가 원하는 결과와는 반대입니다. 이를 해결하기 위해 내적 결과를 1에서 빼주는 방식을 사용합니다. 이렇게 하면 내적 값이 1일 때 전체 결과는 0이 되고, 내적 값이 작아질수록 전체 결과는 증가하게 됩니다.
노말 차이를 계산한 뒤에는 이를 전체 차이값에 더해주고, 지금은 노말 차이가 잘 보일 수 있게 출력 결과를 노말 차이값으로 변경하겠습니다.
float3 normalDifference = baseNormal - neighborNormal;
normalDifference = normalDifference.r + normalDifference.g + normalDifference.b;
normalOutline = normalOutline + normalDifference;
return normalDifference;

이러한 변경을 통해 외곽선을 확인할 수 있지만, 이번에는 깊이가 아니라 노말을 기준으로 생성된 외곽선이기 때문에 이전과는 다른 형태의 외곽선이 나타납니다. 이후 이 두 가지 외곽선을 결합하여 하나의 합쳐진 외곽선을 생성할 수 있습니다.
return depthDifference + normalDifference;

커스텀 가능한 외곽선들
다음 단계에서는 외곽선을 더욱 커스텀 가능하도록 만들어보겠습니다. 이를 위해 뎁스 외곽선과 노말 외곽선 각각에 대해 두 개의 변수를 추가합니다. 하나는 외곽선을 더 강하거나 약하게 보이도록 조절하는 배율 값이고, 다른 하나는 원치 않는 회색빛 영역을 제거할 수 있도록 해주는 바이어스 값입니다.
// 인스펙터 창에서 조절 가능한 값을 노출시킵니다.
Properties
{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_NormalMult ("Normal Outline Multiplier", Range(0,4)) = 1
_NormalBias ("Normal Outline Bias", Range(1,4)) = 1
_DepthMult ("Depth Outline Multiplier", Range(0,4)) = 1
_DepthBias ("Depth Outline Bias", Range(1,4)) = 1
}
// ···
// 효과를 커스터마이징 하는 변수들
float _NormalMult;
float _NormalBias;
float _DepthMult;
float _DepthBias;
이 변수들을 사용하기 위해, 모든 샘플 차이값을 더한 이후에 차이값 변수에 각각 배율 값을 곱해줍니다. 그 다음 값을 0과 1 사이로 제한한 뒤, 바이어스 값만큼 거듭제곱합니다. 이때 0과 1 사이로 값을 제한하는 과정은 매우 중요한데, 음수 값에 대해 거듭제곱을 수행할 경우 올바르지 않은 결과가 나올 수 있기 때문입니다. HLSL에는 값을 0과 1 사이로 제한해주는 전용 함수인 saturate가 있습니다.
depthDifference = depthDifference * _DepthMult;
depthDifference = saturate(depthDifference);
depthDifference = pow(depthDifference, _DepthBias);
normalDifference = normalDifference * _NormalMult;
normalDifference = saturate(normalDifference);
normalDifference = pow(normalDifference, _NormalBias);
return depthDifference + normalDifference;
이제 인스펙터에서 외곽선 값을 어느 정도 조절할 수 있게 되었습니다.
저는 노말 외곽선과 뎁스 외곽선을 모두 약간 강화하고, 바이어스 값을 함께 높여 노이즈를 줄였지만, 직접 설정을 이것저것 바꿔보면서 자신의 씬에 가장 잘 어울리는 값을 찾아보는 것이 가장 좋습니다.


마지막으로, 외곽선을 단순히 별도의 결과로 두는 것이 아니라 씬에 실제로 합성하고자 합니다. 이를 위해 먼저 외곽선 색상을 프로퍼티와 셰이더 변수로 선언하겠습니다.
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
// ···
float4 _OutlineColor;
외곽선을 적용하기 위해 프래그먼트 함수의 마지막에서 소스 텍스처를 읽어온 뒤, 결합된 외곽선 값을 사용하여 소스 색상에서 외곽선 색상으로 선형 보간(linear interpolation)을 수행합니다. 이렇게 하면 이전에 검은색이었던 픽셀은 소스 색상으로 표시되고, 흰색이었던 픽셀에는 외곽선 색상이 적용됩니다.
float outline = normalDifference + depthDifference;
float4 sourceColor = tex2D(_MainTex, i.uv);
float4 color = lerp(sourceColor, _OutlineColor, outline);
return color;

포스트프로세싱 외곽선의 주요 단점으로는, 씬에 존재하는 모든 오브젝트에 효과를 적용해야 한다는 점과, 어떤 부분을 외곽선으로 판단할지에 대한 기준이 의도한 스타일과 맞지 않을 수 있다는 점, 그리고 계단 현상(aliasing, 눈에 보이는 계단 모양 아티팩트)이 비교적 쉽게 발생한다는 점이 있습니다.
앞의 두 가지 문제에 대해서는 간단한 해결책이 없지만, 마지막 문제인 계단 현상은 FXAA나 TXAA와 같은 안티앨리어싱을 포스트프로세싱 단계에서 적용함으로써 어느 정도 완화할 수 있습니다. (Unity 포스트프로세싱 스택에서는 이러한 기능을 제공하지만, v2를 사용하는 경우에는 해당 효과를 스택 내의 이펙트로 다시 구현해야 합니다.)
또 하나 중요한 점은, 이 외곽선 방식에 적합한 3d 모델을 사용해야 한다는 것입니다. 모델의 지오메트리에 지나치게 많은 디테일이 포함되어 있으면, 이 효과가 오브젝트의 대부분을 검은색으로 칠해버릴 수 있습니다.
소스 코드
Shader "Tutorial/019_OutlinesPostprocessed"
{
// 인스펙터 창에서 조절 가능한 값을 노출시킵니다.
Properties
{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
_NormalMult ("Normal Outline Multiplier", Range(0,4)) = 1
_NormalBias ("Normal Outline Bias", Range(1,4)) = 1
_DepthMult ("Depth Outline Multiplier", Range(0,4)) = 1
_DepthBias ("Depth Outline Bias", Range(1,4)) = 1
}
SubShader
{
// 컬링 및 뎁스 버퍼 비교/쓰기 작업이 필요 없음을 지정하는 마커
Cull Off
ZWrite Off
ZTest Always
Pass
{
CGPROGRAM
// 유용한 셰이더 함수들을 포함해줍니다.
#include "UnityCG.cginc"
// 버텍스 셰이더와 프래그먼트 셰이더를 정의합니다.
#pragma vertex vert
#pragma fragment frag
// 현재까지 렌더링된 화면 텍스처
sampler2D _MainTex;
// 뎁스노말 텍스처
sampler2D _CameraDepthNormalsTexture;
// 뎁스노말 텍스처의 텍셀 크기
float4 _CameraDepthNormalsTexture_TexelSize;
// 효과 커스텀화를 위한 변수들
float4 _OutlineColor;
float _NormalMult;
float _NormalBias;
float _DepthMult;
float _DepthBias;
// 버텍스 셰이더에 전달되는 오브젝트 데이터
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
// 프래그먼트를 생성할 때 사용되며, 프래그먼트 셰이더에서 읽을 수 있는 데이터
struct v2f
{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};
// 버텍스 셰이더
v2f vert(appdata v)
{
v2f o;
// 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환합니다.
o.position = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
void Compare(inout float depthOutline, inout float normalOutline,
float baseDepth, float3 baseNormal, float2 uv, float2 offset)
{
// 주변 픽셀을 읽습니다.
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture,
uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
float3 neighborNormal;
float neighborDepth;
// 뎁스노말 값을 디코딩합니다.
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
// 뎁스 값을 카메라로부터의 거리 값(unit 단위)으로 변환합니다.
neighborDepth = neighborDepth * _ProjectionParams.z;
// 중심 픽셀과 주변 픽셀의 뎁스 차이를 계산합니다.
float depthDifference = baseDepth - neighborDepth;
// 기존 뎁스 외곽선 값에 차이 값을 누적해 계산합니다.
depthOutline = depthOutline + depthDifference;
// 중심 픽셀과 주변 픽셀의 노말 차이를 계산합니다.
float3 normalDifference = baseNormal - neighborNormal;
// 노말 차이를 하나의 스칼라 값으로 변환합니다.
normalDifference = normalDifference.r + normalDifference.g + normalDifference.b;
// 기존 노말 외곽선 값에 차이 값을 누적해 계산합니다.
normalOutline = normalOutline + normalDifference;
}
// 프래그먼트 셰이더
fixed4 frag(v2f i) : SV_TARGET
{
// 뎁스노말 값을 읽습니다.
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);
// 뎁스노말 값을 디코딩합니다.
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);
// 뎁스 값을 카메라로부터의 거리 값(unit 단위)으로 가져옵니다.
depth = depth * _ProjectionParams.z;
float depthDifference = 0;
float normalDifference = 0;
// 네 방향의 주변 픽셀을 샘플링하여 차이 값을 누적합니다.
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(1, 0));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, 1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, -1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(-1, 0));
// 뎁스 외곽선 강도를 조절합니다.
depthDifference = depthDifference * _DepthMult;
depthDifference = saturate(depthDifference);
depthDifference = pow(depthDifference, _DepthBias);
// 노말 외곽선 강도를 조절합니다.
normalDifference = normalDifference * _NormalMult;
normalDifference = saturate(normalDifference);
normalDifference = pow(normalDifference, _NormalBias);
// 노말 외곽선과 뎁스 외곽선을 결합합니다.
float outline = normalDifference + depthDifference;
// 소스 텍스처를 읽어옵니다.
float4 sourceColor = tex2D(_MainTex, i.uv);
// 소스 색상과 외곽선 색상을 외곽선 값으로 보간합니다.
float4 color = lerp(sourceColor, _OutlineColor, outline);
return color;
}
ENDCG
}
}
}
using UnityEngine;
using System;
// 메인 카메라와 동일한 게임 오브젝트에 붙어 있어야 하는 컴포넌트
public class OutlinesPostprocessed : MonoBehaviour
{
// 포스트프로세싱 시 적용할 머티리얼
[SerializeField]
private Material postprocessMaterial;
private Camera cam;
private void Start()
{
// 카메라 컴포넌트를 가져오고, 뎁스노말 텍스처를 렌더링하도록 설정합니다.
cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}
// 카메라 렌더링이 끝난 뒤 Unity에서 자동으로 호출되는 메서드
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
// 소스 텍스처의 픽셀을 대상 텍스처로 포스트프로세싱 머티리얼을 사용해 그립니다.
Graphics.Blit(source, destination, postprocessMaterial);
}
}
'Graphics > Ronja's Unity Shader tutorials' 카테고리의 다른 글
| Hull 외곽선 (0) | 2026.02.22 |
|---|---|
| 노말 텍스처를 활용한 포스트프로세싱 (0) | 2025.09.16 |
| 뎁스 텍스처를 활용한 포스트프로세싱 (0) | 2025.08.31 |
| 포스트프로세싱 기초 (0) | 2025.08.04 |
| 버텍스 디스플레이스먼트 (0) | 2025.07.13 |