3D Video Production/Unreal Engine (5.6.1)

UE 5.6 Material 심화: 물 Material 만들기 & 텍스처·Nanite 최적화

보별 2026. 8. 31. 15:21
반응형

지난 Material 포스트에 이어 이번에는 Unreal Engine에서 직접 물 Material을 만들고, 프로젝트의 텍스처와 모델링 데이터를 최적화하는 방법을 정리했다.

물 Material은 단순히 투명한 Material을 만드는 것에서 끝나지 않고 Fresnel, Refraction, Normal, Panner, World Position까지 여러 개념이 한꺼번에 사용된다.

후반부에는 Texture Streaming Pool과 LOD Bias, Nanite, Virtual Texture, UDIM까지 다뤘다.

이번 포스트에서 특히 중요한 부분은 단순히 화질을 낮추는 것이 최적화가 아니라, 카메라에서 필요한 품질만 남기고 불필요한 데이터를 줄이는 것이다.

 

물 Material

먼저 레벨 전체를 덮을 수 있는 Plane을 생성하고 물이 필요한 위치에 배치했다.

Plane을 바닥과 완전히 같은 높이에 배치하면 두 Surface가 겹치면서 Z-Fighting이 발생할 수 있기 때문에 Z축을 조금 높여 배치했다.

Material은 유리 Material과 비슷하게 Translucent로 설정했다.

이번 작업에서는 다음 값을 사용했다.

Blend Mode : Translucent
Translucency Lighting Mode : Surface TranslucencyVolume
Refraction Method : Index of Refraction

Base Color : 연한 회색
Metallic : 1.0
Roughness : 0.05
Opacity : 0.5

다만 Metallic 1.0은 이번 작업에서 사용한 값이고, 실제 물은 비금속이므로 PBR 기준에서는 Metallic 0이 맞다.
따라서 실제 물리 기반 Water Material을 제작할 때는 이 차이를 구분할 필요가 있다.

참고로 이번 작업에서는 굴절 원리를 확인하기 위해 Index of Refraction 방식을 사용했다.
다만 Epic 문서에서는 매우 넓은 수면의 경우 IOR 방식이 화면 바깥의 Scene Color를 참조하면서 문제가 생길 수 있어 상황에 따라 Pixel Normal Offset 방식이 더 적합할 수 있다고 설명한다.

물의 굴절 만들기: Fresnel

물과 유리는 빛이 통과하면서 굴절된다.

이를 표현하기 위해 IOR, Index of Refraction 값을 사용했다.

이번 작업에서는 두 개의 Scalar Parameter를 만들었다.

IOR_Base : 1.0
IOR : 1.3

1.0은 굴절이 거의 없는 상태이며 실제 물의 IOR은 약 1.33이다.

두 값을 Lerp의 A와 B에 연결하고 Alpha에는 Fresnel을 연결했다.

IOR_Base ─ A
IOR ────── B
Fresnel ── Alpha

Fresnel은 Material의 밝기를 직접 조절하는 노드가 아니라 표면과 카메라가 이루는 각도를 기준으로 값을 만들어 준다.

카메라와 정면으로 마주보는 부분에서는 값이 0에 가까워지고, 표면을 비스듬하게 바라볼수록 1에 가까워진다.

따라서 이번 구조에서는 정면에서는 IOR 1.0에 가까운 값이 사용되고, 비스듬하게 보이는 영역에서는 1.3에 가까워지면서 굴절이 더 강하게 나타난다.

처음에는 Fresnel을 단순히 가장자리를 밝게 만드는 노드라고 생각하기 쉬운데, 실제로는 시야각을 이용한 Mask처럼 생각하는 편이 훨씬 이해하기 쉬웠다.

물결 만들기: Normal Map

정적인 Plane에 물결을 표현하기 위해 Normal Map을 추가했다.

Normal Map은 실제 Mesh의 높이를 변경하지 않는다.

대신 표면의 Normal 방향을 바꿔 빛이나 굴절 계산이 달라지도록 만들기 때문에 표면에 작은 요철이 있는 것처럼 보인다.

Texture Sample을 Material의 Normal에 연결한 뒤 TexCoord와 Scale Parameter를 연결해 물결의 크기를 조절했다.

물결 움직이기: Panner

다음으로 물이 계속 흐르는 것처럼 보이도록 Texture Sample 앞에 Panner를 추가했다.

Panner는 Texture Coordinate를 시간에 따라 이동시키는 노드다.

Speed X : 가로 방향 이동
Speed Y : 세로 방향 이동

Time을 연결하면 시간이 흐르면서 Texture Coordinate가 계속 이동하기 때문에 Normal Texture가 흘러가는 것처럼 보인다.

하지만 Normal 하나만 일정한 방향으로 움직이면 물이라기보다 하나의 Texture가 흘러가는 느낌이 강했다.

서로 다른 두 개의 Normal을 움직이기

좀 더 불규칙한 물결을 만들기 위해 기존 Normal 구조를 하나 더 복사했다.

두 Normal에는 서로 다른 Scale과 Speed를 사용했다.

예를 들면 한쪽 Normal은 X 방향으로 움직이고, 다른 Normal은 반대 방향으로 움직이도록 설정할 수 있다.

Normal A Scale : 10
Speed X : 0.1
Normal B Scale : 7
Speed X : -0.2

두 Texture가 서로 다른 속도와 크기로 겹치면서 일정한 반복 패턴이 깨지고 훨씬 자연스러운 물결이 만들어진다.

이번 작업에서는 두 Normal 값을 Add해서 사용했다.

Normal Map은 일반적인 색상 Texture와 달리 표면의 방향 정보를 담고 있기 때문에 두 값을 Multiply하면 각 채널이 서로 곱해지면서 Normal 방향이 의도하지 않게 약해지거나 왜곡될 수 있다.

이번에는 서로 다른 방향으로 움직이는 두 Normal의 변화를 간단하게 겹치기 위해 Add를 사용했다.
다만 Add 역시 Normal을 수학적으로 정확하게 합성하는 방식은 아니며, 보다 정확한 결과가 필요하다면 BlendAngleCorrectedNormals 같은 Normal 전용 Blend 함수를 사용하는 것이 좋다.

TexCoord 대신 Absolute World Position 사용하기

처음에는 TexCoord를 이용해 Normal의 크기를 조절했지만 이후 Absolute World Position 방식으로 변경했다.

Absolute World Position은 현재 픽셀의 위치를 월드 좌표로 반환한다.

출력값은 XYZ이기 때문에 Component Mask를 연결한 뒤 R과 G만 활성화했다.

R = X
G = Y
B = Z

따라서 R과 G만 사용하면 수평면의 X/Y 좌표를 Texture Coordinate처럼 사용할 수 있다.

구조는 다음과 같다.

Absolute World Position → Component Mask (R, G) → Multiply → Panner → Texture Sample

이 방식의 가장 큰 장점은 Mesh의 UV나 Plane의 크기보다는 월드 공간을 기준으로 물결 크기를 맞출 수 있다는 것이다.

서로 크기가 다른 Plane에 같은 Material을 사용하더라도 물결의 실제 크기를 일정하게 유지하기 쉬워진다.

실제로 넓은 바닥을 작업할 때 UV마다 크기를 다시 맞추는 것보다 월드 기준으로 조절하는 방식이 훨씬 직관적이었다.

World Position에서는 Scale을 아주 작게 사용해야 하는 이유

TexCoord를 사용할 때는 Scale을 7이나 10 정도로 사용했지만, Absolute World Position으로 변경한 뒤에는 값이 너무 컸다.

그래서 이번에는 0.001, 0.002 정도의 작은 값을 사용했다.

Unreal의 기본 월드 단위는 1cm이기 때문이다.

예를 들어

World Position × 0.001

을 사용하면 약 1000cm, 즉 10m를 이동할 때 UV가 한 번 반복된다.

반대로 Scale 10을 넣으면 약 0.1cm마다 Texture가 반복되는 매우 높은 주파수의 패턴이 된다.

이 정도로 패턴이 작아지면 화면 Pixel보다 세부 정보가 훨씬 작아져 Mip과 Filtering 과정에서 뭉개질 수 있고, Panner가 계속 움직이고 있어도 거의 멈춰 있는 것처럼 보일 수 있다.

TexCoord와 World Position에 같은 Scale 값을 사용할 수 없는 이유도 여기에서 나온다.

물결 강도 조절하기: FlattenNormal

두 Normal을 Add한 뒤에는 FlattenNormal을 연결했다.

FlattenNormal은 Normal Map이 만드는 요철의 강도를 조절할 때 사용할 수 있다.

Flatness 값이 0일 때는 원래 Normal이 그대로 유지되고, 값이 1에 가까워질수록 Normal이 평평한 (0, 0, 1) 방향에 가까워지면서 요철 표현이 약해진다.

이번 Material에서는 Normal의 강도가 높을수록 물결이 강해지는 직관적인 Parameter로 사용하기 위해 OneMinus를 거쳐 FlattenNormal에 연결했다.

Normal Intensity → OneMinus → FlattenNormal

이렇게 하면 Material Instance에서 Normal Intensity라는 직관적인 이름으로 물결의 강도를 조절할 수 있다.
따라서 Normal Intensity 값을 높이면 FlattenNormal에 전달되는 Flatness 값은 반대로 낮아져 Normal 효과가 강해지는 방향으로 동작한다.

이번 MI에서는 Normal Intensity 값을 2 정도까지 높여 비교적 강한 물결을 만들었다.

이 경우 OneMinus를 거친 Flatness 값은 음수가 되어 Normal이 더 강해지지만, 과도하게 높이면 Normal 표현이 왜곡될 수 있으므로 결과를 확인하면서 조절하는 것이 좋다.

Fresnel을 이용한 Opacity 변화

IOR에 사용했던 Fresnel 구조를 Opacity에도 사용했다.

Opacity_Base ─ A
Opacity ────── B
Fresnel ────── Alpha

여기서 Fresnel이 직접 반사를 조절하는 것은 아니다.

Opacity에 연결되어 있으므로 실제로 바뀌는 것은 투명도다.

정면에서는 상대적으로 투명하게 보이고, 비스듬한 각도에서는 더 불투명해지면서 물과 유리에서 나타나는 Fresnel 특성을 시각적으로 강조할 수 있다.

IOR과 Opacity가 같은 Fresnel을 공유하는 구조를 보니 하나의 시야각 정보가 Material의 여러 속성에 활용될 수 있다는 점이 재미있었다.

최종 Material 노드
최종 Material이 적용된 Plane

Material Instance에서 물 조절하기

완성한 Material을 부모로 Material Instance를 생성했다.

Material Instance에서는 다음 값을 바로 수정할 수 있도록 만들었다.

Water Color
Specular
Roughness
Opacity
IOR
Normal Intensity
Scale
Speed X
Speed Y

이렇게 Master Material을 한 번 만들어 두면 Material을 다시 Compile하지 않고도 물의 모습을 빠르게 변경할 수 있다.

색상을 갈색으로 바꾸고 Roughness와 Opacity, Normal 강도 등을 조절하면 깨끗한 물에서 탁한 물까지 다양한 분위기를 만들 수 있다.

다만 물리적으로 접근한다면 탁한 물을 만들기 위해 Metallic 값을 변경하기보다는 Base Color, Roughness, Opacity와 Normal 등의 값을 조절하는 편이 적절하다.

 

Texture 최적화

Material 작업 후에는 프로젝트의 메모리 사용량을 확인해보는 것이 좋다.

특히 Texture는 프로젝트 VRAM에서 큰 비중을 차지할 수 있기 때문에 고해상도 Texture를 무조건 사용하는 것은 좋지 않다.

Stat Memory 확인하기

콘솔에서 다음 명령어를 입력한다.

stat memory

이번 프로젝트에서는 Streaming Texture Pool이 1000MB로 설정되어 있었다.

다만 stat memory에서 Pool 옆에 표시되는 100%만 보고 VRAM을 전부 사용하고 있다고 판단해서는 안 된다.

Texture Streaming 상태를 자세히 확인하려면 다음 명령을 사용하는 편이 좋다.

stat streaming

Required Pool 확인하기

Texture Streaming을 볼 때 중요한 값 중 하나가 Required Pool이다.

Required Pool은 현재 View와 Texture Streaming 조건을 기준으로 Texture Streamer가 필요하다고 판단한 Mip 데이터의 양이다.

따라서 Required Pool이 Streaming Pool 이하라면 비교적 여유가 있는 상태다.

반대로 Required Pool이 Streaming Pool 초과라면 모든 Texture를 원하는 Mip으로 올릴 메모리가 부족하다는 상태다.

이 경우 Unreal은 일부 Texture의 Mip 해상도를 낮추면서 메모리를 맞춘다.

가까이 있는 Texture까지 이유 없이 흐릿하게 보인다면 Texture 자체의 문제뿐 아니라 Streaming Pool이 부족한지도 확인할 필요가 있다.

r.Streaming.PoolSize

Texture Streaming Pool의 크기는 콘솔에서 변경할 수 있다.

r.Streaming.PoolSize 3000

3000은 MB 단위이므로 약 3GB의 Texture Pool을 지정한다는 의미다.

이번 작업에서는 편의를 위해 3000 정도를 사용했지만 이것이 모든 프로젝트의 권장값은 아니다.

GPU 메모리는 Texture뿐 아니라 Lumen, Nanite, Shadow, Render Target 등 여러 데이터가 함께 사용하기 때문에 최종적으로는 목표 GPU의 VRAM을 기준으로 조정해야 한다.

Texture 크기 줄이기: LOD Bias

Texture Editor의 LOD Bias를 이용하면 원본 Texture를 다시 편집하지 않고 최대 사용 해상도를 낮출 수 있다.

예를 들어 2048×2048 Texture라면

LOD Bias 0 : 2048 × 2048
LOD Bias 1 : 1024 × 1024
LOD Bias 2 : 512 × 512

처럼 사용할 수 있다.

LOD Bias가 1 증가할 때마다 가로와 세로 길이가 각각 절반으로 줄어들기 때문에 전체 Pixel 수는 약 1/4이 된다.

카메라 가까이에서 크게 촬영되는 Asset은 높은 Texture를 유지하고, 멀리 있는 배경 Asset부터 낮추는 것이 효과적이다.

최적화를 시작할 때 가까운 주인공 Asset보다 화면에서 가장 작게 보이는 배경부터 확인하는 이유가 여기에 있다.

Texture 크기 줄이기: Maximum Texture Size

Texture의 Compression - Advanced - Maximum Texture Size를 이용해 최대 Texture Resolution을 직접 제한할 수도 있다.

여러 Texture를 한꺼번에 수정하려면 여러 Texture 선택한 다음, 우클릭 - Asset Actions - Edit Selection in Property Matrix를 사용한다.

Property Matrix에서는 여러 Texture의 Maximum Texture Size를 한 번에 바꿀 수 있다.

다만 많은 Texture를 한꺼번에 수정하면 재빌드에 시간이 오래 걸릴 수 있으므로 여러 번 나누어 적용하는 편이 안전하다.

NoMipmaps 사용 시 주의

Mip Gen Settings에는 NoMipmaps도 있다.

하지만 먼 곳의 Texture가 흐릿해진다는 이유만으로 NoMipmaps를 사용하는 것은 권장하지 않는다.

Mipmap은 거리에 따라 낮은 Resolution Texture를 사용해 메모리와 Bandwidth를 줄이는 것뿐 아니라, 멀리 있는 Texture에서 발생하는 Aliasing과 Shimmering을 줄이는 역할도 한다.

따라서 일반적인 3D Asset에서는 Mipmap을 유지하는 것이 좋다.

특정 Texture의 품질이 너무 빨리 떨어진다면 NoMipmaps를 사용하기 전에 LOD Bias와 Streaming Pool 설정부터 확인하는 편이 좋다.

 

모델링 최적화

Draw Call

화면에 Mesh를 그리기 위해 CPU가 GPU에 Rendering 작업을 요청하는 단위를 Draw Call이라고 한다.

Mesh가 많거나 서로 다른 Material Section이 많으면 Draw Call도 늘어날 수 있다.

Draw Call은 다음 명령어로 확인할 수 있다.

stat RHI

Merge Actors

UE4 시절부터 많이 사용하던 방법 중 하나가 여러 Static Mesh Actor를 하나로 Merge하는 것이다.

같은 Material을 사용하는 10개의 Mesh가 각각 별도로 그려지고 있다면 하나의 Mesh로 합쳐 Draw Call을 줄일 수 있다.

다만 Mesh를 하나로 합쳐도 Material Slot이 여러 개라면 Material Section별 Draw Call은 여전히 필요할 수 있다.

그래서 가능하면 같은 Material을 사용하는 배경 Mesh를 중심으로 합치는 것이 좋다.

카메라에서 아주 멀리 떨어진 도시 전경처럼 실제 Geometry의 Detail이 거의 보이지 않는 경우라면 Geometry 전체를 유지하는 대신 미리 Render한 Image나 Billboard 계열 방식으로 대체하는 것도 하나의 방법이다.

단 이런 방법은 카메라 이동에 따른 Parallax가 사라지므로 촬영 구도가 제한된 배경에 더 적합하다.

병합한 Mesh 다시 분리하기

Merge를 통해 하나의 Mesh로 합친 뒤 다시 Geometry를 분리해야 한다면 Modeling Mode의 Split 기능을 사용할 수 있다.

Modeling Mode - XForm - Split

Split을 실행한 뒤 Split Method를 By Vertex Overlap으로 설정하면 서로 연결되어 있거나 Vertex가 겹쳐 있는 Geometry 단위를 기준으로 각각의 Mesh로 분리할 수 있다.

다만 이 기능은 Merge 이전의 Actor를 그대로 복구하는 기능은 아니다. 병합된 Mesh 내부의 분리된 Geometry를 다시 여러 Mesh로 나누는 방식이므로 기존 Actor의 이름이나 개별 Transform 등의 정보까지 원래 상태로 돌아가는 것은 아니다.

Nanite

UE5에서는 Nanite가 Geometry 최적화의 중요한 축이 되었다.

Nanite는 Mesh를 작은 Cluster 단위로 관리하고 화면에서 필요한 세부 정보만 렌더링하기 때문에 수동 LOD 제작 부담과 전통적인 Geometry Draw Call 부담을 크게 줄일 수 있다.

하지만 Nanite를 사용한다고 모든 최적화가 끝나는 것은 아니다.

Texture Memory, Material Complexity, Lighting, Translucency 등은 별도로 관리해야 한다.

결국 UE5 최적화는 Nanite 하나의 문제가 아니라 각 Rendering Cost를 나누어 관리하는 과정에 가깝다.

 

Virtual Texture

대형 Texture를 사용할 때는 Virtual Texture도 활용할 수 있다.

Streaming Virtual Texture는 Texture를 고정 크기의 Tile로 나눈 뒤 실제 화면에서 필요한 Tile만 GPU Memory에 가져오는 방식이다.

따라서 8K 이상의 대형 Texture나 UDIM을 사용할 때 특히 유용하다.

Virtual Texture는 크게 RVT와 SVT로 나뉜다.

RVT
Runtime Virtual Texture
→ 런타임에서 GPU가 Texture 데이터를 생성
→ Landscape와 Material Blend 등에 활용

SVT
Streaming Virtual Texture
→ 이미 만들어진 Texture Tile을 Disk에서 Streaming
→ 고해상도 Texture, UDIM 등에 활용

Virtual Texture는 고해상도 Texture의 Memory Footprint를 관리하는 데 유리하지만 일반 Texture Sample보다 추가 비용이 발생하기 때문에 모든 Texture를 무조건 VT로 만드는 것이 최적화는 아니다.

UDIM

UDIM은 하나의 Mesh가 여러 개의 UV Tile을 사용할 수 있도록 하는 규칙이다.

예를 들어 Texture 파일을 아래와 같이 구성한다.

Character.1001.png
Character.1002.png
Character.1003.png

Unreal은 같은 Base Name을 가진 UDIM 파일을 찾아 하나의 Virtual Texture Asset으로 가져올 수 있다.

하나의 0~1 UV에 모든 Detail을 넣기 어려운 고해상도 Character나 대형 Asset을 작업할 때 유용하다.

Virtual Texture 활성화

Virtual Texture를 사용하려면 아래와 같이 Project Settings에서 확인할 수 있다.

Project Settings - Engine - Rendering - Virtual Textures - Enable Virtual Texture Support
 

버전이나 프로젝트에 따라 기본값이 다를 수 있기 때문에 자동으로 켜져 있을 것이라고 생각하기보다는 직접 확인하는 편이 좋다.

Texture를 Virtual Texture로 변환하기

일반 Texture를 선택한 뒤, 우클릭 - Convert to Virtual Texture를 사용하면 SVT Texture로 변환할 수 있다.

공식 Conversion 기능을 사용하면 관련 Material의 Texture Sample도 Virtual Sampler Type으로 함께 변경할 수 있다.

반대로 Virtual Texture를 다시 일반 Texture로 변환하는 것도 지원된다.

다만 프로젝트 중간에 많은 Texture를 한꺼번에 변환하면 Material Parameter와 Sampler Type이 뒤섞일 수 있기 때문에 가능한 한 프로젝트 초기에 사용 여부를 정해 두는 것이 편하다.

Virtual Texture 디버깅

Virtual Texture Tile을 직접 확인하고 싶다면 다음 명령어를 사용할 수 있다.

r.VT.Borders 1

화면에 Virtual Texture의 Mip Tile이 표시된다.

끄려면 아래의 명령어를 사용하면 된다.

r.VT.Borders 0

Virtual Texture Cache가 이상하게 보인다면 아래의 명령어를 이용해 Physical Cache를 비울 수도 있다.

r.VT.Flush

Virtual Texture 변환 후 Texture Sample 오류

프로젝트 도중 Texture의 Virtual Texture Streaming 옵션을 직접 변경하거나, Texture와 Texture Sample의 Sampler Type이 맞지 않으면 Material에서 오류가 발생할 수 있다.

일반 Texture와 Virtual Texture는 사용하는 Sampler Type이 다르기 때문이다.

Color → Virtual Color
Normal → Virtual Normal
Masks → Virtual Masks

따라서 VT 변환 이후 Material에 Error가 나타난다면 Texture 자체만 확인하지 말고 Texture Sample의 Sampler Type도 함께 확인하는 것이 좋다.

 

이번 포스트에서는 물 Material을 만들면서 Fresnel, Refraction, Panner, World Position 등 Material에서 자주 사용하는 개념을 한꺼번에 다뤘다.

특히 TexCoord를 Absolute World Position으로 변경하면서 같은 Texture라도 어떤 좌표계를 사용하는지에 따라 Scale의 의미가 크게 달라진다는 점을 확인할 수 있었다.

후반부의 최적화에서는 단순히 Texture Resolution을 낮추는 것보다 stat streaming을 통해 실제 Texture Streaming 상황을 확인하고 카메라에서 중요하지 않은 Asset부터 비용을 줄이는 것이 중요했다.

Nanite와 Virtual Texture 역시 무조건 프로젝트를 가볍게 만드는 기능이라기보다는 각각 Geometry와 Texture 데이터를 효율적으로 관리하기 위한 기술이라고 이해하는 편이 좋다.

결국 최적화는 기능을 켜는 것보다 실제 프로젝트에서 어떤 데이터가 병목을 만들고 있는지 확인한 뒤 필요한 부분만 줄여 나가는 과정에 가깝다.