Postprocessing with Normal Texture
Summary Another piece of information we can easily get our hands on thats very useful for postprocessing is the normals of the scene. They show in which direction the surface at any given pixel is pointing. To understand how to get and use the normals of t
www.ronja-tutorials.com
Ronja 님의 허락을 받고 번역한 튜토리얼입니다. 원문은 위 링크에서 확인하실 수 있습니다.
몇몇 부분은 생략·추가하였습니다.
의역과 오역이 넘쳐날 수 있으니 편하게 봐주시고 잘못된 부분은 알려주시면 감사하겠습니다!
목차
- 개요
- 뎁스 값과 노말 값 읽기
- 윗면 색칠하기
- 소스 코
개요
후처리에 아주 유용하면서도, 손쉽게 얻을 수 있는 또 다른 정보는 씬의 노말(normal)입니다. 노은 각 픽셀에서 표면이 어느 방향을 향하고 있는지를 보여줍니다.
씬의 노멀을 어떻게 가져오고 활용하는지 이해하려면, 먼저 씬 깊이(scene depth) 에 접근하는 방법을 아는 것이 좋습니다. 그 부분에 대해서는 이전의 튜토리얼에서 다루고 있습니다.

뎁스 값과 노말 값 읽기
이번 튜토리얼은 이전의 뎁스 값을 활용한 포스트프로세싱 튜토리얼에서 사용했던 파일을 기반으로 시작하고, 필요에 따라 내용을 확장해 나가려고 니다.
첫 번째 변경점은, 이전 튜토리얼에서 파동 효과를 제어하기 위해 사용했던 C# 스크립트의 모든 코드를 제거하는 것입니다. 그리고 이제는 카메라에 오브젝트의 깊이만 렌더링하도록 지시하지 않고, 깊이와 노멀을 모두 포함하는 텍스처를 렌더링하도록 지시합니다.
private void Start()
{
// 카메라를 가져오고, 뎁스노말 텍스처를 렌더하도록 명령합니다.
cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}
이것으로 노말 값에 접근하기 위한 준비는 모두 끝났습니다. 이제 셰이더를 수정해봅시다.
여기에서도 마찬가지로, 파동 함수를 위해 사용했던 모든 코드를 제거합니다. 그리고 _CameraDepthTexture를 _CameraDepthNormalsTexture로 이름을 바꿔 주어, Unity가 해당 텍스처에 값을 기록하도록 합니다.
// 인스펙터 창에서 조절 가능한 값을 노출시킵니다.
Properties
{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
}
// ···
// 뎁스 노말 텍스처 변수
sampler2D _CameraDepthNormalsTexture;
이렇게 설정해주면 프래그먼트 셰이더에서 DepthNormals 텍스처를 읽어올 수 있게 되었습니다. 그 값을 읽고 화면에 그대로 출력하기만 해도, 흥미로운 결과를 확인하실 수 있습니다.
// 프래그먼트 셰이더
fixed4 frag(v2f i) : SV_TARGET
{
// 뎁스노말 값을 읽습니다.
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);
return depthnormal;
}

하지만 우리가 화면에서 보는 것은 원하는 모습이 아닙니다. 빨간색과 초록색 값만 보이고, 멀리에는 약간의 파란색만 보일 뿐이죠. 이것은 이름에서 알 수 있듯이, 이 텍스처가 노멀뿐만 아니라 깊이 정보도 함께 담고 있기 때문입니다. 따라서 먼저 디코딩 과정을 거쳐야 합니다. 다행히 Unity는 이를 수행해주는 메소드를 제공합니다. 우리는 그 함수에 depthnormal 값과, 깊이와 노멀 값을 기록할 두 개의 변수를 넘겨주기만 하면 됩니다.
깊이 텍스처와 달리, 지금 얻은 깊이 값은 이미 카메라와 원거리 클리핑 평면(far plane) 사이에서 선형(linear)으로 보정된 값입니다. 따라서 이전 튜토리얼에서 사용했던 코드를 약간 수정하기만 하면, 다시 카메라로부터의 거리를 손쉽게 구할 수 있습니다.
// 프래그먼트 셰이더
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;
return depth;
}

하지만 다시 노말을 사용하는 부분으로 돌아가 봅시다. 단순히 노말 값을 색상으로 화면에 출력해주기만 해도, 이미 꽤 그럴듯한 결과를 얻을 수 있습니다.
// 프래그먼트 셰이더
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;
return float4(normal, 1);
}

하지만 카메라를 회전시켜 보면, 표면의 한 지점이 항상 같은 노말 값을 가지는 것은 아니라는 걸 알 수 있습니다. 그 이유는 노멀이 카메라를 기준으로 저장되기 때문입니다. 따라서 월드 좌표계에서의 노말을 얻고 싶다면 추가적인 과정이 필요합니다.
뷰 스페이스 노멀을 월드 스페이스로 변환하는 것은 어렵지 않습니다. 하지만 아쉽게도 Unity는 이를 위한 함수를 제공하지 않으므로, 우리가 직접 셰이더로 보내 처리해야 합니다. 이를 위해 다시 C# 스크립트로 돌아가 구현을 진행하겠습니다.
먼저 카메라에 대한 참조를 얻습니다. Start 메서드에서 이미 카메라를 가져오기 때문에, 그 값을 클래스 변수에 바로 저장해 둘 수 있습니다. 그리고 OnRenderImage 메소드에서는 카메라로부터 뷰 스페이스 → 월드 스페이스 변환 행렬을 가져와 셰이더로 전달합니다. 이 행렬을 Start() 메소드에서 한 번만 셰이더에 전달하지 않고 매번 업데이트해야 하는 이유는, 효과가 시작된 이후에도 카메라를 이동하거나 회전할 수 있고, 그때마다 행렬 값이 달라지기 때문입니다.
using UnityEngine;
// 메인 카메라가 있는 게임오브젝트에 부착되어야 합니다.
public class NormalPostprocessing : MonoBehaviour
{
// 포스트 프로세싱에 적용될 메테리얼
[SerializeField] private Material postprocessMaterial;
private Camera cam;
private void Start()
{
// 카메라를 가져오고, 뎁스노말 텍스처를 렌더하도록 명령합니다.
cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}
// 카메라 렌더링이 끝난 후 유니티가 자동으로 호출하는 메소드
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
// 뷰 스페이스 → 월드 스페이스 변환 행렬을 가져와 셰이더에 전달
Matrix4x4 viewToWorld = cam.cameraToWorldMatrix;
postprocessMaterial.SetMatrix("_viewToWorld", viewToWorld);
// 소스 텍스처의 픽셀을 데스티네이션 텍스처에 그려 넣습니다.
Graphics.Blit(source, destination, postprocessMaterial);
}
}
이제 이 행렬을 셰이더에서 활용할 수 있습니다. 이를 위해 _viewToWorld 라는 새로운 행렬 변수를 추가하고, 노말 값을 사용하기 전에 해당 행렬과 곱해줍니다. 곱하기 전에 행렬을 3×3 행렬로 변환(cast)하는데, 이렇게 하면 위치 변환은 적용되지 않고 회전만 적용되게 됩니다. 우리가 필요한 글로벌 노말 값 사용을 위해서 회전 정보만 필요하기 때문에 이것으로 충분합니다.
// 뷰 스페이스에서 월드 스페이스로 변환하는 행렬
float4x4 _viewToWorld;
// ···
normal = mul((float3x3)_viewToWorld, normal);
return float4(normal, 1);
}

윗면 색칠하기
이제 월드 스페이스 노멀을 얻었으니, 이를 친숙하게 사용하기 위해 간단한 효과를 구현해봅시다. 씬 내 모든 오브젝트의 윗면을 특정 색으로 칠하는 것입니다.
이를 위해서는 노멀과 위쪽 방향 벡터(Up Vector)를 단순히 내적 (dot product) 을 사용해 비교하면 됩니다. 내적 연산은 두 벡터가 정규화(normalize)되어 있을 때, 같은 방향을 가리키면 1을 반환합니다 (즉, 표면이 평평하게 위를 향할 때). 서로 직교할 경우에는 0을 반환합니다(현재 씬의 경우, 벽면이 그렇습니다). 반대 방향을 가리키면 -1을 반환합니다 (카메라 위에 있는 천장의 노말).
// ···
float up = dot(float3(0,1,0), normal);
return up;
}

무엇이 ‘윗면’이고 무엇이 아닌지를 더 명확하게 보기 위해, 이제 0~1 사이의 부드러운 값을 step() 함수를 이용해 단계를 나누어(윗면과 윗면이 아닌 면을 구분하도록) 처리할 수 있습니다. step() 함수 내 두 번째 매개변수 값이 첫 번째 매개변수 값보다 더 작으면 결과는 0이 되어 검은색으로 보이고, 더 크면 흰색으로 보이게 됩니다.
// ···
float up = dot(float3(0,1,0), normal);
up = step(0.5, up);
return up;
// ···

다음 단계는, 윗면으로 정의하지 않은 표면에는 원래 색을 다시 적용하는 것입니다. 이를 위해 메인 텍스처에서 색상값을 읽어온 뒤, 그 색상과 우리가 윗면에 지정한 색상 (현재는 흰색) 사이를 선형 보간합니다.
// ···
float up = dot(float3(0,1,0), normal);
up = step(0.5, up);
float4 source = tex2D(_MainTex, i.uv);
float4 col = lerp(source, float4(1,1,1,1), up);
return col;
// ···

마지막 단계로 커스텀 가능하도록 몇 가지를 추가하겠습니다. 컷오프 값과 윗면 색상을 조절하는 프로퍼티와 전역변수를 추가해 줍니다.
_upCutoff ("up cutoff", Range(0,1)) = 0.7
_topColor ("top color", Color) = (1,1,1,1)
// ···
// 효과 커스텀화
float _upCutoff;
float4 _topColor;
// ···
그다음에는, 이전에 컷오프 값으로 사용했던 고정된 0.5 대신 새로 만든 컷오프 변수를 사용하고, 고정된 흰색이 아니라 지정한 윗면 색상 _topColor 로 선형 보간을 합니다. 그리고 lerp() 내에서 윗면 색상을 해당 색의 알파 값과 곱해줄 수도 있습니다. 이렇게 하면 알파 값을 낮췄을 때 윗면에 원래의 색이 일부 비쳐 보이게 됩니다.
// ···
float up = dot(float3(0,1,0), normal);
up = step(_upCutoff, up);
float4 source = tex2D(_MainTex, i.uv);
float4 col = lerp(source, _topColor, up * _topColor.a);
return col;
}

이 효과는 뎁스노말 텍스처가 어떻게 사용되는지를 주로 보여주기 위해 만들어졌습니다. 만약 눈이 쌓이는 효과를 만들고 싶다면, 포스트프로세싱 효과를 사용하는 것보다 오브젝트 셰이더 내에서 만드는 것이 더 적절할 것입니다. 더 좋은 예제로 설명하지 못해 아쉽습니다.
소스 코드
using UnityEngine;
// 메인 카메라가 있는 게임오브젝트에 부착되어야 합니다.
public class NormalPostprocessing : MonoBehaviour
{
// 포스트 프로세싱에 적용될 메테리얼
[SerializeField] private Material postprocessMaterial;
private Camera cam;
private void Start()
{
// 카메라를 가져오고, 뎁스노말 텍스처를 렌더하도록 명령합니다.
cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}
// 카메라 렌더링이 끝난 후 유니티가 자동으로 호출하는 메소드
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
// 뷰 스페이스 → 월드 스페이스 변환 행렬을 가져와 셰이더에 전달
Matrix4x4 viewToWorld = cam.cameraToWorldMatrix;
postprocessMaterial.SetMatrix("_viewToWorld", viewToWorld);
// 소스 텍스처의 픽셀을 데스티네이션 텍스처에 그려 넣습니다.
Graphics.Blit(source, destination, postprocessMaterial);
}
}
Shader "Tutorial/018_Normal_Postprocessing"
{
// 인스펙터 창에서 조절 가능한 값을 노출시킵니다.
Properties
{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_upCutoff ("up cutoff", Range(0,1)) = 0.7
_topColor ("top color", Color) = (1,1,1,1)
}
SubShader
{
// 컬링이나 뎁스 버퍼 읽기/쓰기가 필요 없음을 지정하는 마커
Cull Off
ZWrite Off
ZTest Always
Pass
{
CGPROGRAM
// 유용한 셰이더 함수들을 포함해줍니다.
#include "UnityCG.cginc"
// 버텍스와 프래그먼트 셰이더 정의
#pragma vertex vert
#pragma fragment frag
// 마지막에 렌더된 메인 텍스처
sampler2D _MainTex;
// 뷰 스페이스에서 월드 스페이스로 변환하는 행렬
float4x4 _viewToWorld;
// 뎁스 노말 텍스처 변수
sampler2D _CameraDepthNormalsTexture;
// 효과 커스텀화
float _upCutoff;
float4 _topColor;
// 버텍스 셰이더에 전달되는 오브젝트 데이터
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;
}
// 프래그먼트 셰이더
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;
normal = mul((float3x3)_viewToWorld, normal);
float up = dot(float3(0,1,0), normal);
up = step(_upCutoff, up);
float4 source = tex2D(_MainTex, i.uv);
float4 col = lerp(source, _topColor, up * _topColor.a);
return col;
}
ENDCG
}
}
}
'Graphics > Ronja's Unity Shader tutorials' 카테고리의 다른 글
| 뎁스 텍스처를 활용한 포스트프로세싱 (0) | 2025.08.31 |
|---|---|
| 포스트프로세싱 기초 (0) | 2025.08.04 |
| 버텍스 디스플레이스먼트 (0) | 2025.07.13 |
| 폴리곤 클리핑 (0) | 2025.03.22 |
| 커스텀 라이팅 (램프 효과) (0) | 2025.02.23 |