게임 그래픽을 다루다 보면 결국 Shader를 만나게 된다.
Material의 색을 바꾸는 것부터 시작해서 투명도, 텍스처, 법선, 조명, 그림자, 후처리, 물, Dissolve, Outline 같은 대부분의 그래픽 효과는 Shader와 연결되어 있다.
Unity에서 Shader를 이해하려면 단순히 코드를 작성하는 것보다 GPU가 어떤 데이터를 받아 어떤 과정을 거쳐 화면에 픽셀을 출력하는지를 먼저 이해해야 한다.
Unity 6에서는 특히 Built-in Render Pipeline의 오래된 Shader 문법과 URP/HDRP 기반의 Shader 구조를 구분해야 한다.
핵심 흐름은 다음과 같다.
Mesh -> Vertex Shader -> Rasterization -> Fragment Shader -> Frame Buffer -> Screen
Unity Shader는 이 GPU Pipeline의 일부를 프로그래머가 정의하는 코드라고 보면 된다.
Shader란
Shader는 GPU에서 실행되는 프로그램이다.
CPU에서 실행되는 일반적인 C# 코드와 달리 Shader는 GPU의 병렬 처리 구조를 활용해 수많은 Vertex와 Pixel을 동시에 계산한다.
예를 들어 캐릭터의 Mesh가 10만 개의 Vertex를 가지고 있다면 Vertex Shader는 각 Vertex의 위치, UV, Normal 등의 정보를 기반으로 화면에 표시될 위치를 계산한다.
이후 Rasterization 과정을 거쳐 Fragment가 생성되고 Fragment Shader에서 최종 색상을 계산한다.
Mesh Data -> Vertex Shader -> Rasterization -> Fragment Shader -> Final Color
따라서 Shader의 핵심 역할은 크게 두 가지로 볼 수 있다.
Vertex Shader -> Vertex의 위치와 관련 데이터 계산
Fragment Shader -> 화면에 출력할 색상 계산
실제 렌더링 과정에서는 이보다 훨씬 많은 단계가 존재하지만 Shader를 이해하기 위한 핵심은 이 두 단계다.
GPU Rendering Pipeline
Shader를 제대로 이해하려면 먼저 렌더링 파이프라인을 알아야 한다.
일반적인 흐름을 단순화하면 다음과 같다.
CPU -> Draw Call -> GPU -> Vertex Processing -> Rasterization -> Fragment Processing -> Frame Buffer -> Display
CPU는 GPU에게 Mesh를 렌더링하라는 명령을 전달한다.
GPU는 Vertex Shader를 실행해 Vertex를 처리하고, 처리된 Vertex를 기반으로 삼각형을 구성한다.
그다음 Rasterization을 통해 삼각형이 화면의 어떤 Pixel에 해당하는지 계산하고 Fragment Shader를 실행한다.
최종적으로 계산된 색상이 Frame Buffer에 기록된다.
Vertex Shader
Vertex Shader는 Mesh의 Vertex 단위로 실행된다.
대표적으로 다음과 같은 데이터를 다룬다.
Position
Normal
Tangent
UV
Color
가장 기본적인 역할은 Object Space의 Vertex 위치를 Clip Space로 변환하는 것이다.
개념적으로는 다음과 같은 흐름이다.
Object Space -> World Space -> View Space -> Clip Space
Unity에서는 Transform Matrix를 통해 이 변환이 이루어진다.
Vertex Position
-> Object To World
-> World To View
-> View To Projection
-> Clip Space
Vertex Shader에서는 위치뿐만 아니라 Fragment Shader에서 사용할 데이터를 함께 넘길 수도 있다.
예를 들어 UV를 넘길 수 있다.
Vertex Shader
-> Position 계산
-> UV 전달
-> Normal 전달
-> Color 전달
-> Fragment Shader
Fragment Shader
Fragment Shader는 Rasterization 이후 생성된 Fragment를 처리한다.
일반적으로 화면에 표시될 색상을 계산하는 역할을 한다.
Texture
+ Color
+ Lighting
+ Normal
+ Other Parameters
-> Fragment Shader
-> Final Color
예를 들어 단순한 텍스처 Shader라면 UV 좌표를 사용해 Texture에서 색상을 읽고 그 값을 출력한다.
UV -> Texture Sampling -> Color -> Output
여기에 조명 계산이 추가되면 훨씬 복잡해진다.
Texture
+ Normal
+ Light Direction
+ View Direction
+ Material Parameters
-> Lighting Calculation
-> Final Color
Vertex와 Fragment의 차이
둘은 서로 다른 목적을 가진다.
| 구분 | Vertex Shader | Fragment Shader |
|---|---|---|
| 실행 단위 | Vertex | Fragment |
| 주요 역할 | 위치 및 Vertex 데이터 계산 | 최종 색상 계산 |
| 주요 입력 | Position, Normal, UV 등 | Interpolated 데이터, Texture 등 |
| 대표 작업 | Transform | Texture Sampling, Lighting |
| 실행 횟수 | Vertex 수에 비례 | 화면에 영향을 주는 Fragment 수에 비례 |
그래서 Fragment Shader의 비용은 특히 중요하다.
해상도가 증가하면 처리해야 할 Fragment가 크게 증가하기 때문이다.
ShaderLab
Unity에서 Shader를 작성할 때 사용하는 대표적인 문법이 ShaderLab이다.
ShaderLab 자체가 GPU에서 실행되는 Shader 언어는 아니다.
ShaderLab은 Unity가 Shader를 구성하고 렌더링 파이프라인에 연결하기 위한 선언 및 설정 문법이다.
개념적으로 다음과 같이 볼 수 있다.
ShaderLab
-> Shader 구조 정의
-> Properties 정의
-> Render State 설정
-> Pass 정의
-> HLSL Shader 연결
예를 들어 Shader의 기본 구조는 다음과 같은 형태를 가진다.
Shader "Custom/MyShader"
{
Properties
{
_Color ("Color", Color) = (1, 1, 1, 1)
}
SubShader
{
Pass
{
// HLSL Shader Code
}
}
}
여기서 중요한 것은 ShaderLab과 HLSL의 역할이 다르다는 것이다.
ShaderLab -> Unity에게 Shader가 어떻게 구성되는지 설명
HLSL -> GPU에서 실제 계산을 수행
Properties
Properties는 Unity Editor와 Material에서 사용할 값을 선언하는 영역이다.
예를 들어 다음과 같이 작성할 수 있다.
Properties
{
_Color ("Color", Color) = (1, 1, 1, 1)
_MainTex ("Texture", 2D) = "white" {}
_Metallic ("Metallic", Range(0, 1)) = 0
}
이 값들은 Material Inspector에 노출된다.
Shader Properties -> Material -> Runtime / Inspector에서 값 변경
다만 Properties에 선언했다고 해서 자동으로 GPU 계산에 사용되는 것은 아니다.
Shader 코드에서 해당 값을 실제로 참조해야 한다.
Material과 Shader의 관계
Shader와 Material을 혼동하면 안 된다.
Shader는 어떻게 렌더링할지 정의하는 프로그램이고 Material은 그 Shader에 사용할 값을 저장하는 객체다.
Shader -> Rendering Logic
Material -> Rendering Parameters
예를 들어 하나의 Shader가 다음과 같은 Property를 가지고 있다고 하자.
_Color
_MainTex
_Metallic
_Roughness
Material A는 빨간색을 사용할 수 있고 Material B는 파란색을 사용할 수 있다.
Shader
-> Material A -> Red
-> Material B -> Blue
-> Material C -> Green
따라서 같은 Shader를 여러 Material이 공유하면서 서로 다른 결과를 만들어낼 수 있다.
SubShader
Shader 안에는 하나 이상의 SubShader를 정의할 수 있다.
SubShader는 특정 렌더링 환경에서 사용할 실제 렌더링 구현을 담는다.
개념적으로는 다음과 같다.
Shader
-> SubShader
-> Pass
-> HLSL
여러 SubShader를 정의할 경우 Unity는 현재 환경에서 사용할 수 있는 SubShader를 선택한다.
이 구조는 서로 다른 GPU 또는 렌더링 환경에 대응하기 위한 용도로 사용되어 왔다.
Pass
Pass는 실제 렌더링 작업 단위다.
하나의 Shader가 반드시 하나의 Pass만 사용하는 것은 아니다.
예를 들어 다음과 같은 구성이 가능하다.
Shader -> Pass 1 / Pass 2 / Pass 3
각 Pass마다 서로 다른 렌더링 작업을 수행할 수 있다.
예를 들어 Outline을 구현할 때 별도의 Pass를 사용하거나 Shadow 관련 렌더링에서 별도의 Pass가 사용될 수 있다.
다만 Pass가 늘어난다는 것은 추가적인 렌더링 작업이 발생할 수 있다는 의미이므로 성능 측면에서도 고려해야 한다.
Render State
Shader는 색상 계산뿐만 아니라 GPU의 렌더링 상태도 정의할 수 있다.
대표적인 항목은 다음과 같다.
Blend
ZTest
ZWrite
Cull
Stencil
이들은 Shader 자체의 계산식과는 다른 개념이다.
HLSL -> 색상 및 데이터 계산
Render State -> GPU가 결과를 어떻게 처리할지 결정
Cull
Cull은 Backface Culling과 관련된 설정이다.
대표적으로 다음과 같은 옵션이 있다.
Cull Back
Cull Front
Cull Off
일반적인 3D Mesh에서는 뒷면을 렌더링하지 않는 Cull Back이 많이 사용된다.
양면 렌더링이 필요한 잎사귀나 종이와 같은 Mesh에서는 Cull Off를 사용할 수 있다.
Cull Back -> 뒷면 제거
Cull Front -> 앞면 제거
Cull Off -> 양면 렌더링
양면 렌더링은 필요 이상의 Fragment 처리량을 증가시킬 수 있으므로 무조건 사용하는 것은 좋지 않다.
ZTest와 ZWrite
깊이 처리는 3D 렌더링에서 핵심적인 부분이다.
ZTest는 현재 Fragment가 기존 Depth 값과 비교했을 때 통과할지 결정한다.
ZWrite는 렌더링 결과의 Depth 값을 Depth Buffer에 기록할지 결정한다.
ZTest -> Depth 비교
ZWrite -> Depth 기록
불투명 오브젝트에서는 일반적으로 Depth를 기록하고 깊이 테스트를 수행한다.
반면 투명 오브젝트는 렌더링 순서와 Depth 처리 방식이 복잡해질 수 있다.
Blend
Blend는 새로 계산된 색상과 기존 Frame Buffer의 색상을 어떻게 합칠지 결정한다.
투명 Shader에서 대표적으로 사용된다.
개념적으로는 다음과 같다.
Source Color + Destination Color -> Blending -> Final Color
예를 들어 Alpha Blending은 다음과 같은 형태로 동작한다.
Final Color = Source * Alpha + Destination * (1 - Alpha)
따라서 투명도를 구현한다고 해서 Fragment Shader에서 단순히 Alpha 값을 변경하는 것만으로 끝나는 것이 아니다.
Render Queue, ZWrite, ZTest, Blend 등이 함께 고려되어야 한다.
Render Queue
Unity는 오브젝트를 특정 순서에 따라 렌더링한다.
대표적인 Render Queue는 다음과 같다.
Background
-> Geometry
-> AlphaTest
-> Transparent
-> Overlay
정확한 값과 세부 동작은 사용하는 Render Pipeline에 따라 확인해야 하지만, 개념적으로는 어떤 오브젝트를 먼저 렌더링할 것인가를 결정하는 시스템이라고 보면 된다.
투명 오브젝트가 특히 Render Queue와 관련이 깊다.
Opaque -> Transparent
일반적으로 불투명 오브젝트를 먼저 처리하고 투명 오브젝트를 이후에 처리하는 방식이 사용된다.
HLSL
현대 Unity Shader에서 실제 GPU 연산을 작성할 때 핵심이 되는 언어가 HLSL이다.
HLSL은 Microsoft가 개발한 High-Level Shader Language다.
Unity에서는 ShaderLab 내부에 HLSL 기반 코드를 작성하거나, Shader Graph가 생성한 Shader 코드에서도 HLSL 기반 구현을 확인할 수 있다.
개념적으로 다음과 같다.
ShaderLab -> Shader 구조와 Render State
HLSL -> GPU 연산
Vertex / Fragment Shader 구조
기본적인 HLSL Vertex/Fragment Shader는 다음과 같은 형태로 구성할 수 있다.
struct Attributes
{
float3 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
Varyings Vert(Attributes input)
{
Varyings output;
output.positionCS = TransformObjectToHClip(input.positionOS);
output.uv = input.uv;
return output;
}
half4 Frag(Varyings input) : SV_Target
{
return half4(1, 1, 1, 1);
}
핵심 흐름은 다음과 같다.
Attributes -> Vertex Shader -> Varyings -> Rasterization -> Fragment Shader -> SV_Target
Semantic
HLSL에서 POSITION, TEXCOORD0, NORMAL, SV_POSITION, SV_Target 같은 것을 Semantic이라고 한다.
GPU와 Shader 사이에서 데이터의 의미를 지정한다.
예를 들어:
float3 positionOS : POSITION;
float2 uv : TEXCOORD0;
는 Vertex Input에서 Position과 UV 데이터를 받는다는 의미다.
반대로:
float4 positionCS : SV_POSITION;
는 Vertex Shader가 출력하는 Clip Space 위치를 의미한다.
POSITION -> Vertex Position
TEXCOORD0 -> UV
NORMAL -> Normal
SV_POSITION -> Vertex Position for Rasterization
SV_Target -> Render Target Output
Interpolation
Vertex Shader에서 Fragment Shader로 전달되는 값은 Vertex 단위 데이터가 Fragment 단계에서 자동으로 보간된다.
예를 들어 삼각형의 세 Vertex가 각각 다른 색을 가지고 있다고 하자.
Vertex A -> Red
Vertex B -> Green
Vertex C -> Blue
Rasterization 과정에서 내부 Fragment의 색상은 주변 Vertex 값을 기반으로 보간된다.
Vertex Data -> Rasterization -> Interpolated Data -> Fragment Shader
UV 역시 같은 방식으로 보간된다.
이 때문에 Fragment Shader에서는 각 Pixel마다 별도의 UV 계산을 하지 않아도 Vertex에서 전달한 UV를 사용할 수 있다.
Texture Sampling
Texture는 Shader에서 가장 흔하게 사용되는 데이터 중 하나다.
기본적인 흐름은 다음과 같다.
UV -> Texture Sampling -> Texture Color -> Fragment Output
예를 들어 Albedo Texture를 샘플링하면 다음과 같은 형태가 된다.
half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
여기서 중요한 것은 Texture 자체와 UV 좌표를 구분하는 것이다.
Texture -> 실제 이미지 데이터
UV -> Texture에서 어느 위치를 읽을지 지정하는 좌표
Normal
Normal은 표면이 어느 방향을 향하고 있는지를 나타내는 벡터다.
조명 계산에서 핵심적인 데이터다.
가장 기본적인 Diffuse Lighting은 표면 Normal과 Light Direction의 내적을 이용해 계산할 수 있다.
개념적으로는 다음과 같다.
Normal + Light Direction -> Dot Product -> Light Intensity
두 벡터가 같은 방향을 바라볼수록 값이 커지고, 서로 수직에 가까워질수록 값이 작아진다.
따라서 Normal은 단순한 방향 데이터가 아니라 표면이 빛을 얼마나 받을 것인지 계산하기 위한 핵심 입력값이다.
PBR
현대 Unity의 물리 기반 렌더링에서는 PBR이 중요하다.
PBR은 Physically Based Rendering의 약자다.
대표적으로 다음과 같은 Material Parameter를 사용한다.
Base Color
Metallic
Smoothness
Normal
Occlusion
Emission
Unity의 Render Pipeline에 따라 정확한 Property 명칭과 구현 방식은 달라질 수 있다.
하지만 개념적인 구조는 다음과 같다.
Material Parameters
+ Surface Normal
+ Light
+ View Direction
-> BRDF / Lighting
-> Final Color
Shader를 공부한다는 것은 결국 이런 렌더링 계산을 이해하는 과정이기도 하다.
URP Shader
Unity 6에서 새로운 프로젝트를 구성한다면 URP를 사용하는 경우가 많다.
URP에서는 과거 Built-in Pipeline에서 사용하던 Shader 구조를 그대로 가져와 사용하는 것이 적절하지 않은 경우가 많다.
특히 다음 개념을 구분해야 한다.
Built-in Render Pipeline
-> Legacy ShaderLab / Surface Shader
URP
-> URP Shader / HLSL
-> Shader Graph
Surface Shader는 Built-in Render Pipeline의 대표적인 기능이며 URP의 현대적인 Shader 작성 방식과 동일하게 생각하면 안 된다.
Shader Graph
직접 HLSL을 작성하지 않고 Shader Graph를 사용해 Shader를 만들 수도 있다.
Shader Graph는 노드 기반으로 Shader의 연산을 구성한다.
Input
-> Mathematical Operation
-> Texture / Normal / Lighting
-> Output
예를 들어 Dissolve 효과는 다음과 같은 구조로 만들 수 있다.
Noise Texture -> Threshold -> Alpha Clip -> Surface Output
Shader Graph의 장점은 복잡한 HLSL 코드를 직접 작성하지 않고도 시각적으로 Shader 로직을 구성할 수 있다는 것이다.
반대로 매우 세밀한 GPU 최적화나 Pipeline 수준의 제어가 필요한 경우에는 직접 HLSL을 작성해야 할 수 있다.
Shader Keyword와 Variant
Unity Shader를 다룰 때 성능과 빌드 크기에서 중요한 개념이 Shader Variant다.
하나의 Shader가 여러 기능을 지원한다고 하자.
Normal Map
Shadow
Emission
Additional Light
Fog
각 기능의 활성화 여부에 따라 서로 다른 Shader Variant가 생성될 수 있다.
예를 들어 기능이 5개이고 각각 ON/OFF라면 단순화했을 때 최대 다음과 같은 조합이 가능하다.
2^5 = 32 Variants
기능이 증가하면 Variant 수는 빠르게 증가한다.
Keywords -> Variant Combinations -> Build Size / Compilation Cost / Runtime Selection
따라서 Shader 기능을 무작정 하나의 거대한 Shader에 집어넣는 것은 좋지 않다.
Shader의 성능
Shader 성능을 볼 때는 CPU 코드와 다른 관점이 필요하다.
특히 Fragment Shader는 화면 해상도와 Overdraw의 영향을 크게 받는다.
예를 들어 같은 Shader라도 다음 상황에서는 처리량이 달라진다.
1920x1080 -> 약 207만 Pixel
3840x2160 -> 약 829만 Pixel
4K는 Full HD보다 Pixel 수가 약 4배다.
따라서 화면 전체를 처리하는 Fullscreen Shader의 비용은 해상도에 민감하다.
또한 투명 오브젝트가 여러 장 겹쳐 있으면 같은 화면 영역을 여러 번 처리하게 된다.
Transparent A
-> Transparent B
-> Transparent C
-> Fragment Processing 증가
이를 Overdraw라고 한다.
ALU와 Texture Sampling
Shader 비용은 단순히 코드 줄 수로 판단할 수 없다.
대표적으로 고려해야 할 것이 ALU 연산과 Texture Sampling이다.
ALU -> 수학 연산
Texture Sampling -> Texture Memory Access
복잡한 수학 연산이 많거나 Texture Sampling이 지나치게 많으면 Shader 비용이 증가할 수 있다.
하지만 실제 비용은 GPU 아키텍처, 메모리 접근 패턴, 해상도, 병목 위치 등에 따라 달라진다.
따라서 최적화에서는 추측보다 Profiler와 GPU 분석 도구를 이용한 측정이 우선이다.
Shader와 Draw Call
Shader 자체가 Draw Call을 발생시키는 것은 아니다.
하지만 Material과 Shader 설정은 렌더링 배칭과 GPU 처리 효율에 영향을 줄 수 있다.
개념적으로 렌더링은 다음과 같이 연결된다.
GameObject -> Renderer -> Material -> Shader -> GPU
여러 오브젝트가 서로 다른 Material을 사용하거나 Shader 상태가 달라지면 렌더링을 묶기 어려워질 수 있다.
Unity의 SRP Batcher와 GPU Instancing 같은 시스템을 이해하면 이 관계가 더 명확해진다.
Shader 최적화 기본 원칙
Shader 최적화는 무조건 코드를 짧게 만드는 작업이 아니다.
먼저 병목을 찾아야 한다.
GPU Profiling -> Bottleneck 확인 -> Shader 분석 -> 수정 -> 재측정
대표적으로 확인할 수 있는 항목은 다음과 같다.
Fragment Cost
Texture Sampling
Overdraw
Shader Variant
Pass Count
Transparent Rendering
Fullscreen Effect
Material 변경
특히 모바일 GPU에서는 Desktop GPU와 병목 특성이 다를 수 있으므로 실제 타깃 디바이스에서 측정해야 한다.
Shader를 이해하는 핵심 구조
Unity Shader를 공부할 때는 개별 문법을 암기하기보다 계층을 잡는 것이 중요하다.
Unity Rendering Pipeline
-> ShaderLab
-> Pass
-> HLSL
-> Vertex Shader
-> Rasterization
-> Fragment Shader
-> Render Target
그리고 데이터는 다음과 같이 흐른다.
Mesh -> Vertex Input -> Vertex Shader -> Varyings -> Rasterization -> Fragment Shader -> Render Target
Material 데이터는 별도로 들어온다.
Material Properties -> Shader Parameters -> Vertex / Fragment Calculation
최종적으로는 다음 구조로 생각하면 된다.
Mesh + Material + Camera + Light -> Render Pipeline -> Shader -> GPU -> Screen
Built-in과 URP를 구분해야 하는 이유
Unity Shader 관련 오래된 자료를 보면 다음과 같은 키워드를 자주 볼 수 있다.
CGPROGRAM
Surface Shader
CGINC
Fixed Function
SetTexture
이런 자료가 Shader의 역사와 개념을 이해하는 데는 도움이 되지만, Unity 6에서 새로운 Shader를 작성하는 기준으로 그대로 따라가면 문제가 생길 수 있다.
현재는 프로젝트의 Render Pipeline을 먼저 확인해야 한다.
Built-in -> Built-in Shader 구조
URP -> URP Shader / HLSL / Shader Graph
HDRP -> HDRP Shader 구조 / Shader Graph / HLSL
따라서 “Unity Shader 문법” 하나만 공부하는 방식보다 어떤 Render Pipeline에서 어떤 Shader 시스템을 사용하는가를 먼저 확인하는 것이 맞다.
최종 정리
Unity Shader의 본질은 GPU에게 어떤 데이터를 입력하고 어떤 계산을 수행해 화면에 무엇을 출력할 것인가를 정의하는 것이다.
전체 구조를 하나로 정리하면 다음과 같다.
Mesh -> Vertex Shader -> Rasterization -> Fragment Shader -> Render Target -> Screen
Unity의 관점에서는 다음과 같이 연결된다.
GameObject -> Renderer -> Material -> ShaderLab -> Pass -> HLSL -> GPU
Shader를 이해하기 위한 핵심 개념은 다음과 같다.
Shader
-> ShaderLab
-> HLSL
-> Vertex Shader
-> Fragment Shader
-> Texture
-> UV
-> Normal
-> Lighting
-> Render State
-> Pass
-> Render Queue
-> Shader Variant
-> GPU Performance
여기까지 이해하면 Shader 코드를 단순히 “그래픽 효과를 만드는 코드”로 보는 단계에서 벗어날 수 있다.
Shader는 결국 GPU 렌더링 파이프라인에서 데이터를 받아 변환하고, 최종적으로 화면에 기록할 결과를 계산하는 프로그램이다.
Unity 6에서 Shader를 제대로 다루려면 다음 순서로 공부하는 것이 효율적이다.
GPU Rendering Pipeline -> ShaderLab -> HLSL -> Vertex/Fragment -> Texture/Normal -> Lighting/PBR -> URP -> Shader Graph -> Shader Optimization
이 순서를 잡아두면 이후 Dissolve, Outline, Toon Shader, Water Shader, Post Processing 같은 효과를 구현할 때도 각각의 코드가 무엇을 하고 있는지 구조적으로 이해할 수 있다.