TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역] 제로에서 구현한 간편한 FluidFluxWater 물 렌더링 파트 1

jplee 2026. 4. 1. 17:24

역자의 말: 저자분이 언리얼 엔진 워터플러그인을 분석 해 나가면서 유니티로 재현해 나가는 과정을 담은 글인데요. 읽을 거리들이 꽤 많습니다. 그래서 공유 해 봅니다. 내용이 매우 길어서 스크롤 압박과 함께 지루해 질 법 하여 파트 1과 파트 2로 나눴습니다. 먼저 파트1 부터~


저자: 秋大叔

잡담

업무 때문에 어쩔 수 없이 한동안 ‘카툰 렌더’를 내려놓고, 이것저것 PBR을 파고들게 됐다. 그러다 일하다가 문득 UE의 물 플러그인 FluidFlux가 너무 궁금해져서, 이렇게 튜토리얼 글을 쓰게 됐다.

다행히 예전에 아주 기초적인 물 렌더링을 한 번 연구한 적이 있고, 그때 블로그로도 정리해 둔 적이 있다. 그런데 이번에 한동안 FluidFlux 물을 파고들어 보니… 예전에 했던 물 렌더링은 빠진 게 꽤 많더라. 그래서 아예 다시 한 세트를 새로 만들기로 했고, Unity에서 FluidFlux2의 효과를 최대한 재현해 보려고 했다.

일단 대략 복각한 결과는 아래 두 영상 정도의 느낌이다. 원본과 비교하면 아직 차이는 꽤 있지만, 주요 모듈들은 대부분 따라 했으니… 그냥 ‘간이판 FluidFlux’라고 부르자. 하하.

기존 물 렌더링과 비교하면, 이번에는 주로 이런 점이 달라졌다.

  1. 해안(근해) 파도 애니메이션이 추가됨
  2. 더 편한 FFT 애니메이션 구현 방식 사용
  3. UE single layer water 셰이딩 모델 기반의 물리 기반 수중 셰이딩 적용
  4. 완전히 새로운 디테일 처리 방식들

어차피 다 복현해 봤으니, 겸사겸사 글도 하나 써서 공유해 보려고 한다. 이전 글의 업그레이드 plus 버전쯤 되는 셈이다.

예전 글을 쓸 때랑 똑같이, 이번에도 ‘독자가 처음부터 끝까지 한 사이클을 완주할 수 있게’ 하는 게 목적이다. 그래서 0부터 단계별로 구현을 설명하고, 코드도 공유한다. 다만 텍스처/모델 리소스는 포함하지 않고, 코드만 완전하게 제공한다. (코드를 보면서 더 잘 이해하면 된다.)

qiudashu233/FluxWater: SimpleFluxWater Complete

그럼 잡담은 여기까지.


목차

앞으로 구현할 순서대로 정리하면, 대략 아래 기술들을 완성하게 된다.

  1. 베이크된 FFT 텍스처 기반의 원해(먼 바다) 애니메이션
  2. 변위 텍스처 기반의 해안(근해) 파도 애니메이션
    1. 변위 텍스처에 베이크된 애니메이션을 UV로 어떻게 가져오는가
    2. 애니메이션 형태/효과를 커스텀하게 조절할 수 있게 만들기
  3. 완전한 single layer water 셰이딩 + Flux 파라미터 계산
    1. BRDF 스페큘러 하이라이트
    2. 물리 기반 수중 단순 산란 렌더링
    3. 환경광 반사 + 반사(거울) 반사
  4. 디테일 추가
    1. 디테일 노말 텍스처
    2. 거품(foam)

이전의 기초 글에 비하면 이번 글은 plus 버전이라, 기술 정리도 좀 더 넓게 잡을 것이다. (예전처럼 너무 세세하게만 파고들진 않는다.)

이제 앞으로 구현할 것들의 큰 틀을 머릿속에 잡았으니, 본론 시작.


사전 준비

이 글은 ‘끝까지 따라갈 수 있게’ 쓰는 것이 목표라서, 본격적으로 셰이더를 구현하기 전에 필요한 준비를 먼저 적어 둔다.

물 렌더링은 보통 면적이 크다 보니, 패치 하나로 끝내지 않고 여러 면을 겹쳐 쓰는 경우가 대부분이다. 이때는 적절한 컬링 로직과 합리적인 LOD 설계가 꽤 중요하다. Flux의 물 메쉬 설계는 아래 글에서 구체적인 배치 방법을 참고할 수 있다.

서로 다른 해양 메쉬 밀도를 표현하기 위해 FF2는 “NS_InfiniteSurfaceMesh”라는 Niagara System을 사용한다. 카메라 거리 기반으로 64개의 동일한 3만 폴리곤 패치 “SM_FluxPlane128x128”를 배치해서 Mesh LOD를 만든다.

하지만 이번 글의 핵심은 전체 셰이딩 결과 쪽이라, 이 생성 로직은 일단 구현하지 않는다. 대충 필요한 폴리곤 수 감만 잡으면 된다.

중심에서 아주 가까운 영역은 3만 폴리곤 패치 4장(=약 12만 폴리곤)으로 구성되니, 단일 패치로 최소 12만 폴리곤은 맞추는 편이 좋다. (그 이하로 내려가면 애니메이션이 눈에 잘 안 보일 수도 있다.)

그리고 패치의 월드 위치는 0 0 0에 두자. 이후 월드 좌표를 기준으로 처리를 많이 할 예정이다.

셰이더를 새로 만들고, SubShader의 Queue를 투명(Transparent)로 바꾼 뒤, Pass에서 depth 텍스처를 선언해 둔다.

Tags { "RenderType"="Transparent" "Queue"="Transparent" }

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"

뒤에서 IBL + 노말맵까지 들어가게 되면, 버텍스 셰이더/프래그먼트 셰이더의 입출력에는 최소한 아래 정도는 포함돼 있어야 한다.

struct VertexInput
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
                float2 texcoord0 : TEXCOORD0;
                float2 staticLightmapUV : TEXCOORD1;
            };

            struct VertexOutput
            {
                float4 pos : SV_POSITION;
                float2 uv0 : TEXCOORD0;
                float4 posWS : TEXCOORD1;
                float4 posCS : TEXCOORD2;
                float3 normalWS : TEXCOORD3;
                float3 tangenWS : TEXCOORD4;
                float3 bitangenWS : TEXCOORD5;
                DECLARE_LIGHTMAP_OR_SH(staticLightmapUV, vertexSH, 6);
                float4 screenPos : TEXCOORD7;
            };

그리고 버텍스 셰이더에서 필요한 값을 계산해 출력으로 넘겨서, 프래그먼트 셰이더에서 제대로 받아 쓸 수 있게만 해 주면 된다. 구현은 대략 아래 같은 느낌.

VertexOutput vert (VertexInput v)
            {
                VertexOutput o = (VertexOutput)0;
                
                o.uv0 = v.texcoord0;
                o.pos = TransformObjectToHClip( v.vertex.xyz );
                o.posWS = mul(GetObjectToWorldMatrix(), v.vertex);
                o.posCS = TransformWorldToHClip(o.posWS.xyz);
                o.normalWS = TransformObjectToWorldNormal(v.normal);
                o.tangenWS = normalize( mul( GetObjectToWorldMatrix(), float4( v.tangent.xyz, 0.0 ) ).xyz );
                o.bitangenWS = normalize(cross(o.normalWS, o.tangenWS)*v.tangent.w * unity_WorldTransformParams.w);

                // GI
                OUTPUT_LIGHTMAP_UV(v.staticLightmapUV, unity_LightmapST, o.staticLightmapUV);
                OUTPUT_SH(o.normalWS.xyz, o.vertexSH);
                
                // 스크린 공간
                o.screenPos = ComputeScreenPos(o.pos);

                return o;
            }

뒤에서 프래그먼트 쪽 입력이 더 필요해지면, 그때 VertexOutput에 필요한 변수를 추가하면 된다.

베이크된 FFT 텍스처 기반 원해(먼 바다) 애니메이션

자, 이제 물 애니메이션부터 먼저 구현해 보자. 셰이딩 파트에 비하면 훨씬 단일하고 깔끔한 편이다.

물 애니메이션은 대충 두 종류로 나눌 수 있다.

  • 해안(근해)의 파도: 예를 들면 모래사장에 밀려오는 잔파도. 구현 방식이 다양하고, 보기 좋고 ‘진짜처럼’ 만들려면 손이 꽤 많이 간다.
  • 원해(먼 바다)의 너울: 바다 특유의 파도 출렁임. 보통 FFT 시뮬레이션이나, 여러 개의 Gerstner 파로 구현하는 경우가 많고, 상황에 맞춰 선택하면 된다.

Flux 데모의 효과는 대략 이런 느낌.

근해 파도 효과

원해 파도 효과

기술 난이도로만 따지면 사실 원해 애니메이션이 더 복잡하다. FFT를 ‘베이크’하는 과정이 들어가기 때문이다.

베이크를 하려면 당연히 먼저 FFT를 구현해야 하는데, FFT 자체 구현은 예전에 내가 썼던 글을 참고하면 된다.

문제는 FFT 구현 + 베이크 파이프라인까지 전부 만들면 작업량이 근해 파도보다 훨씬 커진다는 것. 하지만 이미 베이크된 결과를 쓸 수 있다면? 그 순간 난이도는 확 떨어진다. 그냥 남이 구워놓은 걸 갖다 쓰면 되니까.

복잡한 기술 루트를 연구할 때, 나는 보통 쉬운 블록부터 쌓고 그다음 어려운 블록을 얹는 식을 좋아한다. 앞부분을 구현하면서 기술 흐름과 구조에 익숙해지면, 뒤쪽 학습 효율이 확 올라가거든.

그래서 우리 ‘간이판 Flux 물’도, 우선 원해 애니메이션부터 시작한다.


말 그대로 제목 그대로다. 원해 애니메이션은 ‘베이크된 FFT 텍스처’를 기반으로 한다.

우리가 아는 FFT 애니메이션은 역푸리에(IFFT)를 돌려서, 해양 스펙트럼 방정식 기반으로 실시간 계산해 얻는 ‘진짜 바다’ 움직임이다. FFT를 베이크한다는 건, 그 실시간 계산 결과를 어떻게든 저장해 둔다는 뜻이다.

FFT 계산 흐름이 아직 기억난다면, 마지막에 주파수 영역의 여러 스펙트럼(여기서는 8장)을 얻고, IFFT를 통해 XYZ 변위 + 편미분(노말 계산용) 같은 결과를 뽑아낸다는 걸 떠올릴 것이다.

이번 절에서는 ‘FFT를 어떻게 베이크하느냐’ 자체는 다루지 않지만, 그래도 한 번 생각해 보자. 실시간으로 계산되는 저 값들을, 도대체 무엇을 어떻게 저장해야 할까?

애니메이션이려면 결국 ‘시퀀스 이미지’로 저장해야 한다. 필립(Phillips) 스펙트럼은 주기 운동 함수이니, 한 주기 안에서 적당한 간격으로 샘플링해서 저장만 해두면 자연스럽게 루프(무봉합 연결)되는 시퀀스를 만들 수 있다.

그럼 샘플링할 시간 지점을 정했다고 치고, 이제 무엇을 저장할까?

  • 높이(Height) 변위는 무조건 저장해야 한다. 이게 핵심이다.
  • 노말도 있어야 한다. 광원 디테일은 전부 노말에 달려 있으니까.
  • 그러면 텍스처 채널을 거의 다 쓴다. 그럼 마지막 채널에는 뭘 넣지?

거품(foam) 위치다. 거품 계산은 편미분에 걸려 있어서, 편미분을 그대로 저장하는 건 양이 너무 많다. 그래서 애초에 ‘거품이 생길 위치’를 판정해서 그 마스크를 저장해 두는 쪽이 낫다.

정리하면, 4채널에 담을 내용이 결정됐다.

Flux 프로젝트를 열고 T_OceanWave 텍스처를 찾아보자. 이게 바로 베이크된 FFT 텍스처이고, 4채널 내용은 방금 얘기한 것과 대응된다.

노말

높이 변위

거품 위치

확대해 보면 타일(tile)들이 쭉 이어 붙은 형태인데, 즉 애니메이션 시퀀스다. 우리는 시간 순서대로 타일을 샘플링해 주기만 하면 된다.

Unity에 임포트할 때는 3D 텍스처로 설정하고, 타일이 8x8이니 행/열을 8x8로 잡는다. 샘플링 범위를 넘어갈 때는 Repeat로.

3D 텍스처로 만들면 이런 타일 시퀀스를 샘플링하기가 정말 편하다.

이제 셰이더에서 3D 텍스처를 선언하고, 버텍스 셰이더에서 샘플링해 보자.

// shaderlab
_WaveMainTex ("WaveTexture", 3D) = "white" {}

// HLSL
sampler3D _WaveMainTex;

// vertex shader
float3 UVs = float3(o.posWS.x * _WaveTextureScaleX, o.posWS.z * _WaveTextureScaleY, _Time.y * _TimeSpeed); // 월드 좌표 샘플링 + 스케일 영향
float4 mainTex_var = tex3Dlod(_WaveMainTex, float4(UVs,0));

월드 좌표를 스케일해서 샘플링하고, 3번째 축은 시간으로 준다.

월드 좌표 값은 UV에 비해 너무 크기 때문에, 스케일 값은 보통 아주 작게 들어간다. 그냥 0.000x 같은 값을 줘도 된다.

이제 B 채널(높이 변화)을 그대로 버텍스 위치에 적용해 보면 된다. (여기는 너무 기본이라 코드는 생략.)

값에 스케일 파라미터를 하나 걸어 두는 걸 잊지 말자. 평면이 크면 변위 값도 그에 맞게 몇 배로 키워야 눈에 보인다.

보면 전체가 위로만 떠오르는 것처럼 보일 텐데, 우리가 원하는 건 해수면을 기준으로 위아래로 흔들리는 변위다.

이건 텍스처 저장 특성상 음수를 담을 수 없어서 생기는 문제다. 그래서 높이에 대해 -0.5 보정을 해 줘야 한다. Flux 머티리얼 블루프린트에도 동일한 처리 노드가 있다.

이렇게 하면 정상적으로 ‘해수면 기준 상하 변위’가 된다.

다음은 노말이다. RG 채널에 노말 정보가 들어 있으니, 일반적인 노말 처리 로직대로 완전한 노말을 복원해서 프래그먼트 셰이더로 넘겨 주면 된다.

// vertex shader
float2 normalDerivate = (mainTex_var.rg-0.5)*2;
normal = float3(normalDerivate, sqrt(1-dot(normalDerivate,normalDerivate)));
normal.z = max(0.01, normal.z); // 0이 되어 완전히 검게 나오는 경우 방지
normal = lerp(float3(0,0,1), normal, _WaveNormalStrength);

// pixel shader
float3x3 tangentTransform = float3x3( i.tangenWS, i.bitangenWS, i.normalWS); // TBN 행렬
float3 normalWS = normalize(mul(normalMapVar, tangentTransform));

나는 보통 먼저 NoL로 노말이 제대로 나오고 있는지 확인하는 편이다. 아래는 NoL 결과.

강도 1

강도 0.35

광원 방향은 예상대로고, 역광 영역이 어둡게 나온다. 그리고 강도를 적당히 낮춰서 노말 보정이 과하지 않게 만들면 결과가 더 좋아진다.

다시 Flux 플러그인 블루프린트로 돌아가서, 플러그인이 제공하는 노말 디코딩/처리가 어떻게 돼 있는지도 한 번 보자. 아래는 그걸 코드 형태로 옮긴 것이다.

float2 DecodeNormalDerivate(float normalScale, float2 normalXY)
{
    float2 normal_xy = (normalXY - 128/255) * 1/normalScale;
    float normal_z =  sqrt(1 - saturate(dot(normal_xy, normal_xy)));
    // float3 normal = normalize(float3(normal_xy, normal_z)); // 미사용

    return normal_xy;
}

void WaveSampleDecode(float4 sample_var, inout float3 normal, inout float3 offset, inout float waveFoam)
{
    float2 _WaveLength = float2(_WaveTextureScaleX, _WaveTextureScaleY);
    
    float2 derivateXY = DecodeNormalDerivate(_WaveTextureDecodeNormal, sample_var.rg);
    float waveNormalScale = _WaveLength * _WaveTextureDecodeHeight * _WaveTextureHeight;

    // normal
    normal = normalize(float3(derivateXY * waveNormalScale, 1));

    // offset
    float sampleHeight = sample_var.b;
    float sampleFoam = sample_var.a;

    float waveOffsetZ = (sampleHeight - 0.5) * _WaveTextureHeight + _WaveTextureOffsetZ;
    offset = float3(derivateXY * (1-sampleHeight) * -_WaveTextureChoppiness, waveOffsetZ);
    waveFoam = sampleFoam;
}

먼저 -0.5가 아니라 128/255를 빼고 있다. 솔직히 이건 좀 더 엄밀한 처리라서, 나 같은 사람은 잘 안 떠올린다.

그리고 x2 같은 처리는 안 하는데, 아마 노말 보정이 너무 세지지 않게 하려는 의도일 것이다.

여러 파라미터로 노말 강도를 연결해 둔 것도 꽤 많다. 여기서 내가 하나 꼭 짚고 싶은 건, offset과 normal이 동시에 _WaveTextureHeight에 연결돼 있다는 점이다.

즉 변위가 커질수록 노말 보정도 더 강해진다. 이건 물리적으로도 굉장히 자연스러운(합리적인) 설계다.

그리고 노말의 핵심 처리는 이 부분.

normal = normalize(float3(derivateXY * waveNormalScale, 1));

보면 Z를 그냥 1로 두어서, 수면의 ‘큰 방향성(위로 향하는 경향)’을 유지하게 만든다.

이 Flux 원본 로직을 그대로 써 보면 이런 느낌이 나온다.

보면 변위가 커질수록 노말 보정도 점점 강해진다. 확실히 더 좋은 파라미터 제어 방식이다. 우리도 Flux 원본의 변위 처리 + 노말 처리 방식을 그대로 가져가면 된다.

다만 float waveNormalScale = _WaveLength * _WaveTextureDecodeHeight * _WaveTextureHeight; 이 부분에서, 텍스처 스케일(WaveLength)까지 노말 보정에 같이 걸리는 건 개인적으로는 빼도 된다고 본다.

마지막으로 주요 파라미터 의미를 정리해 두면:

  • _WaveTextureHeight: FFT 변위(높이) 크기
  • _WaveTextureDecodeHeigh: 노말 강도
  • _WaveTextureChoppiness: 변위 ‘뾰족함/가팔라짐’(choppy)

여기서 Choppiness가 하나 더 튀어나오는데, 이 파라미터가 꽤 재미있다. 높이 변위 값에 따라 파도의 형태를 ‘퍼지게/좁아지게’ 바꿔 준다. Gerstner 파가 XY까지 건드려서 파형을 더 예쁘게 만드는 것과 비슷한 방향이라고 보면 된다.

근데 Flux는 베이크 FFT 기반이라 이미 형태 디테일이 꽤 들어가 있다. 그래서 내 느낌엔 Choppiness는 약간 트릭 성격이 강하다. 확실히 꼭대기를 더 뾰족하게 만들어 주긴 하는데, 너무 키우면 형태 자체가 많이 바뀐다. 값을 보수적으로 주면 원래 변위랑 큰 차이는 없다.

Choppiness 영향

디테일 보강용으로는 충분히 쓸만하지만, 나는 일단 이 파라미터를 0으로 두고(=영향 없음) 넘어가겠다.

마지막으로 foam도 꺼내서 프래그먼트로 넘겨 주자. 그리고 스케일 + 오프셋 파라미터로 형태를 조절할 수 있게 해 두면 좋다.

float waveFoam = (i.waveFoam + _WaveTextureFoamOffset) * _WaveTextureFoamScale; waveFoam = saturate(waveFoam);

그러면 이런 마스크 형태가 나오고, 나중에 거품 처리에서 그대로 써먹으면 된다.

여기까지가 원해 애니메이션에서 우리가 처리해야 할 내용의 대부분이다.


변위 텍스처 기반 해안(근해) 파도 애니메이션

기초 지식 + 실습

Flux의 이 근해 파도 애니메이션은 나름 ‘클래식’한 구현 방식이다. 여러 게임에서 비슷한 효과를 볼 수 있는데, 예를 들면 명조(鸣潮) 해안 파도도 이런 계열이다.

원리 자체는 사실 그렇게 복잡하진 않다. 근데 “보기 좋게” 만드는 건 진짜 손이 좀 간다. 파라미터를 만지다 보면 금방 ‘마녀의 약 제조’처럼 돼서, 이것저것 막 섞어 넣다가 어느 순간 결과가 하나 튀어나오는 그런 느낌이 된다.

원리 설명은 아래 두 글이 내가 지금부터 할 설명보다 훨씬 명확하고 깊다. (진짜로.)

Fluid Flux2.0海浪原理拆解321 赞同 · 20 评论 文章

FluidFlux2近岸海浪原理详解102 赞同 · 13 评论 文章

나는 여기서는 “원리 설명”을 길게 늘어놓기보단, 실제로 한 단계씩 구현해 가는 쪽에 더 초점을 둘 거다. 위 두 글로 큰 그림을 잡은 다음에, 이 뒤 내용을 따라오면 이해가 훨씬 편할 것이다.

구현에 들어가기 전에, 그래도 원리를 아주 간단히만 정리해 보자.

우리는 이런 텍스처가 하나 필요하다. 이것도 플러그인 안에서 그대로 꺼내올 수 있다.

여기서 RG 채널에는 애니메이션(두 방향 오프셋)이 들어 있고, B 채널에는 foam 위치가 들어 있다.

그럼 질문: 예전 FFT 텍스처처럼 “타일 시퀀스”가 없는 이 한 장짜리 텍스처에, 애니메이션을 대체 어떻게 저장해 둔 걸까?

답은 간단하다. 2D 평면 위의 애니메이션을 저장한 게 아니라, 1D 애니메이션을 저장해 둔 거다.

원래 우리는 높이 변화를 ‘해수면’ 기준에서 생각했지? 근데 해안 파도는 그렇게 크게 필요 없으니까, 시점을 좀 바꿔서 관찰해 보자.

아래 GIF는 Da哥 글에서 인용한 것(허가 받음).

전에는 XY 평면에서의 변화를 다루었지만, 여기서는 “X축 방향으로의 시간 변화” 같은 1차원 애니메이션만 저장한다고 생각하면 된다. 그러면 그 ‘시간 축’을 텍스처의 Y축으로 세워서, 한 장의 2D 텍스처 안에 넣을 수 있다.

그래서 이 텍스처의

  • 가로(Width)는 우리가 저장하는 파도 길이,
  • 세로(Height)는 시간 시퀀스,
  • 그리고 R/G 채널에는 매 시점마다의 2개 방향 오프셋 강도가 들어간다.

자, 그럼 “애니메이션이 저장되는 방식”은 알았다. 다음 문제는 ‘이걸 어떻게 샘플링해서 실제로 써먹느냐’다.

여기서 난점이 2개 있다.

  1. 해안 파도는 단순히 무한 반복되는 애니메이션이 아니라, ‘해안선’이라는 특수한 위치에 고정돼 있어야 한다.
  2. 모델 UV 기반으로 하면 안 된다. 바다는 여러 패치가 이어서 렌더링되니까, 패치 경계에서 끊김이 없으려면 월드 기준으로 연속적인 좌표계를 써야 한다.

근데 이걸 “처음부터 내가 발명”하려고 하면 머리 아프다. 그냥 Flux가 제공하는 방식 그대로 보는 게 제일 빠르다. 다만, 어떤 방법을 쓸 때는 “왜 이 방법을 쓰는지” 정도는 알고 쓰는 게 좋다.

Flux는 샘플링을 위해, 베이크된 SDF 텍스처를 사용한다.

  • SDF 거리는 월드 좌표에서 계산되니까, 여러 패치에 걸쳐도 이음새 문제 없이 연속적으로 동작한다. (난점 2 해결)
  • 그리고 SDF에는 아주 선명한 경계선이 있는데, 그 경계선이 곧 해안선 위치다. (난점 1 해결)

차원 관점으로 봐도 잘 맞는다. SDF는 ‘1차원 정보’고, 우리가 저장한 해안 파도 애니메이션도 ‘1차원 저장’이다. 완전 딱 맞는다.

말 그대로 “SDF에 맞춰서 애니메이션을 구워 둔” 느낌이다. (아마 진짜 그랬을지도)

SDF 예시

자, 이제 “SDF를 기반으로 샘플링한다”는 건 확정됐다. 그다음은 이 SDF를 실제로 어떻게 써서 해안 파도 애니메이션 텍스처를 샘플링하느냐가 핵심이다.

여기서 텍스처 축을 이렇게 생각하면 된다.

  • X: 텍스처의 가로(파도 길이 방향)
  • Y: 텍스처의 세로(시간 시퀀스)

즉,

  • 세로(Y)는 시간 축이니까 Time과 엮어야 한다. 그리고 애니메이션은 주기 루프여야 하니까, frac(time) 같은 방식이든 sin(time) 같은 방식이든, 어쨌든 주기적으로 반복되게 만들어야 한다.
  • 가로(X)는 “파도가 나타날 위치”를 결정한다. 여기서 X까지 반복 샘플링(Repeat)을 해버리면 화면에 파도가 무한히 찍혀서 난리가 난다. 그래서 SDF의 경계(해안선) 근처에서만 유효하게 보이도록, 파도 범위를 제한해야 한다.

텍스트 설명은 일단 여기까지만 하고, Unity에서 먼저 ‘손으로 만져 보면서’ 감을 잡자. 추상 개념을 바로 코드/화면으로 내리는 게 제일 빠르다.

참고로 이 글에서는 진짜로 베이크된 SDF를 쓰진 않는다. 내 생각엔 이건 물 셰이딩 자체랑은 좀 결이 다른 지식이라서, 학습 목적이라면 “가짜 SDF”만 만들어도 충분하다.

나는 여기서 한 줄(레이)을 기준으로 월드를 이분하는 방식으로 가짜 SDF를 만들 거고, 동시에 수직 방향(법선 방향)을 통해 파도의 전진 방향도 정해 줄 거다. (RG 채널이 하나는 높이/하나는 전방 오프셋이라서 방향 정보가 필요하다.)

// distance
float sdf_distance = 0;
float2 origin = float2(_DistanceX, _DistanceY);
 float theta = _Theta/180 * PI;
float2 originNormalDir = float2(cos(theta), sin(theta)) ;

float distanceToOri = dot(originNormalDir, i.posWSnoOffset.xz)  - dot(origin, originNormalDir);
sdf_distance = distanceToOri * _DistanceScale;
float2 sdf_dir = -originNormalDir; // 이동 방향

앞에서 말했듯, 패치를 0,0,0에 두기로 했기 때문에(월드 원점 기준), 모든 파라미터를 기본값 0으로 놓으면 이런 ‘경계선’이 하나 생긴다.

그리고 회전 각도(theta)랑 원점(origin) 위치를 조절하면, 원하는 형태의 경계선을 만들 수 있다. 일단은 지금은 이걸 “가짜 SDF 경계”로 두고 진행하자.

이제 우리가 앞에서 정리한 논리대로 해보면,

  • SDF로 텍스처 X(가로)를 샘플링하고,
  • 시간(frac(Time))으로 텍스처 Y(세로)를 샘플링하면,

여러 개의 파도가 반복되면서 움직이는 효과가 나올 것이다.

직접 해보자.

float2 testMapUV = float2(-sdf_distance * 0.01, frac(_Time.y * 0.1));
float4 testMap_var = tex2Dlod(_WaveProfileMap, float4(testMapUV,0,0));
profile_offset = float3(sdf_dir.x,0,sdf_dir.y) * testMap_var.r + float3(0,0,1) * testMap_var.g;
profile_offset *= 40;

이건 그냥 감 잡는 테스트니까, 값 몇 개는 손으로 대충 조절해도 된다.

여기서 한 가지 주의: distance는 음수 방향으로 뒤집어 줘야 한다. 이건 뒤에서 Flux 원본 처리 방식 이야기할 때 다시 언급할 건데, 일단 지금은 “아 그렇구나” 하고 붙여 두면 된다.

이 GIF처럼, ‘해안 파도 애니메이션’ 느낌이 나는 걸 확인할 수 있을 거다. 우리가 이론으로 예상했던 결과랑 일치한다.

이제 문제는, 이 파도가 ‘반복’되면서 여기저기 무한히 찍힌다는 거다.

우리가 원하는 건 경계선(해안선) 근처에서 딱 한 번만 나타나는 파도다.

텍스처 샘플링의 유효 범위는 0~1이고, 그 밖은 Repeat로 돌아버린다. 그러면 답은 간단하다.

  • 0~1 구간이 “경계선 근처”에만 걸리게 만들고
  • 나머지(0~1 밖) 구간은 반복 샘플링이 일어나지 않게(=클램프) 해버리면 된다.

그래서 min/max로 클램프를 걸어보자. 결과를 보면 바로 감이 온다.

float2 testMapUV = float2(max(min(-sdf_distance * 0.01,1),0), frac(_Time.y * 0.1));

이제 파도가 딱 한 군데(고정 위치)에만 나타나게 된다. 그리고 SDF 형태를 바꾸면, 그에 맞춰서 불규칙한 형태의 해안 파도도 만들 수 있다.

좋아, 여기까지가 ‘초기 실습’이다. 방금 한 건 어디까지나 “이 애니메이션 텍스처를 어떻게 써먹는지” 감 잡기용이다. 뒤에서 설명하는 내용들을 이해하기 위한 최소한의 발판이라고 보면 된다.

근데 이런 초급 방식만으로는 당연히 부족하다.

  • 애니메이션 루프 전환이 너무 뻣뻣하고
  • 파도의 시작/끝 구간이 아무 변화 없이 똑같이 끊기고
  • 파도 형태를 어떻게 컨트롤할지(디자인 파라미터)가 없다

그래서 이제부터가 본게임이다. Flux가 이 해안 파도 애니메이션 텍스처를 ‘어떻게 더 효율적으로’ 쓰는지, 제대로 파보자.


Flux의 UV 생성 방식

Flux 블루프린트에서 해당 위치를 찾아보면, UV 계산은 MF_CoastlineProfileUV에 들어 있다.

이 함수 노드를 그냥 코드로 직역해서 Unity 쪽으로 옮겨 오면 된다.

void CoastlineOffsets(float distance, float2 direction, float2 posWS, inout float time, inout float detailes)
{

    // P.y 취득    // float Py = posWS.y + direction.y * distance ;    float Py = posWS.y  * 1 + direction.y * distance * 1;
    float3 Py_Offset = Py * float3(0.121, 0.242 * _WaveSinFrequency, 0.7517 * _WaveCosFrequency);

    // time    time = Py_Offset.r + sin(Py_Offset.g) * -0.135 * _WaveSinStrength + cos(Py_Offset.b) * -0.0375 * _WaveCosStrength;
    float timeNoise = tex2Dlod(_NoiseTexture, float4(posWS * 0.5 * _NoiseMapSampleScale,0,0)).r - 0.5; // time 처리    timeNoise = timeNoise * saturate(distance*0.35-0.05) * _NoiseTime;
    time += lerp( 0, timeNoise, _IsNeedNoise); // 노이즈 추가
    // Detail    Py_Offset = Py * float3(0.121, 0.242 * _WaveDetailSinFrequency * 0.2, 0.7517 * _WaveDetailCosFrequency * 0.2);
    detailes = sin(Py_Offset.g) * -0.25 * _WaveDetailSinStrength + cos(Py_Offset.b) * 0.15 * _WaveDetailCosStrength;
    float detailNoise = tex2Dlod(_NoiseTexture, float4(posWS * 1.5 / _NoiseMapSampleScale,0,0));
    detailNoise = (detailNoise * 0.5 - 0.5) * _NoiseScale ;
    detailes += lerp(0, detailNoise, _IsNeedNoise);
}

float2 GetProfileUV(float3 posWS, Coastline coastline)
{
    // time + detail 취득    float time;
    float detail;

    float distance = coastline.distance;
    float2 direction = coastline.direction;

    CoastlineOffsets(distance, direction, posWS.xz, time, detail);

    // U    float profileU = _WaveProfileDistance - distance / _WaveProfileWidth;
    float profileU_Time = _Time.y * _WaveProfileSpeed  * _IsNeedTime+ _TimeOffset + _WaveTimeStrength * time;
    float final_profileU = profileU - profileU_Time;

    // V    float profileV = profileU + lerp(0, detail, _IsNeedDetail);
    float final_profileV = profileV - frac(final_profileU);
    final_profileV *= _WaveProfileAnimationSpeed;

    float2 profileUV = float2(final_profileU, final_profileV);

    return profileUV;
}

여기 코드에는 파라미터가 엄청 많이 등장한다. Flux 원본 파라미터도 있고, 내가 분석하면서 추가로 달아 놓은 파라미터도 있다. 솔직히 그중 몇 개는 없어도 된다.

일단은 이 두 함수가 뭘 하는지부터 이해하자. 그리고 이해가 끝나면, 본인 구현에 맞게 파라미터를 적당히 쳐내면서(정리하면서) 가져가면 된다.

  • GetProfileUV가 메인 함수다. 최종적으로 ‘해안 파도 애니메이션 텍스처’를 샘플링하기 위한 UV를 돌려준다.
  • float distance = coastline.distance; float2 direction = coastline.direction; 는 우리가 앞에서 만든 SDF distance와 전진 방향(dir)이다.
  • time과 detail은 Flux 쪽에서 나오는 “정체불명의” 파라미터인데, time은 U에 영향을 주고 detail은 V에 영향을 준다. 둘 다 CoastlineOffsets에서 계산된다.

먼저 바깥쪽(U/V)의 계산 로직부터 보자.

// Ufloat profileU = _WaveProfileDistance - distance / _WaveProfileWidth;
float profileU_Time = _Time.y * _WaveProfileSpeed  * _IsNeedTime+ _TimeOffset + _WaveTimeStrength * time;
float final_profileU = profileU - profileU_Time;

이제 한 줄씩 뜯어 보자. 첫 줄은 SDF distance에 대한 처리다.

왜 이런 처리가 필요한지 이해하려면, 杨超大佬 블로그에서 분석한 내용을 같이 보는 게 도움이 된다.

여기까지 오면 distance와 direction의 의미는 명확해진다. 하나는(육지 기준으로) “해안까지의 거리”, 다른 하나는(육지 기준으로) “해안을 향하는 방향”이다.

Flux에서는 SDF distance와 dir가 내부에서 0.5를 빼는 처리(센터링 같은 처리)를 한 번 더 거친다. 상세 과정은 여기서는 생략하고, 그 처리를 거친 SDF가 어떤 모양이 되는지만 보자. (아까 봤던 SDF 예시 그림 그대로 사용)

그림을 보면, 육지 쪽은 음수로 바뀌고 바다 쪽은 양수로 바뀐다. 즉, 杨超大佬가 말한 “해안까지의 거리” 규칙이 된다. 그리고 전진 방향 dir은 당연히 바다에서 육지 쪽으로 전진하는 방향이 된다. 이건 그냥 결론부터 박아도 된다.

우리도 이 규칙을 그대로 따른다. 지금 우리 distance는 오른쪽이 흰색, 왼쪽이 검정이니까, 오른쪽을 바다, 왼쪽을 육지로 간주한다.

여기서 파도의 “정방향/역방향” 문제가 한 번 더 나온다. 앞의 실습에서는 V를 time으로 써서 별생각이 없었는데, U는 distance의 부호/방향을 어떻게 잡느냐에 따라 파도 텍스처가 뒤집혀 버린다.

해안 파도 애니메이션 텍스처

이건 그냥 룰이다. 노란색이 파도 시작, 보라색이 파도 끝이다. 즉 노란색은 바다 쪽에, 보라색은 해안 쪽에 와야 한다.

노란색은 0에 가까운 쪽, 보라색은 1에 가까운 쪽이니까,

  • 작은 값이 노란색(바다 쪽)
  • 큰 값이 보라색(해안 쪽)

근데 지금 우리 distance는 오른쪽(바다)이 흰색이라 값이 크다. 바다가 오히려 “작은 값”이어야 하는데 반대로 돼 있지.

그래서 올바른 파도 진행 방향을 얻으려면 distance를 반전시켜야 한다. 이때 같이 오프셋/스케일도 같이 처리해 준다.

여기서 스케일인 _WaveProfileWidth는 파도 띠의 “폭(너비)”을 의미하고, 이렇게 변환된 distance가 최종적으로 U의 베이스(기본값)가 된다.

둘째 줄 profileU_Time은 U의 “오프셋(시간에 따른 이동량)” 파트다. 보면 그냥 두 덩어리를 더한 형태다.

  • _Time: 선형 시간
  • CoastlineOffsets에서 계산되는 그 “수상한” 파라미터 time

참고로 이 변수명이 내가 일부러 겹치게 지은 게 아니다. Flux 플러그인 쪽에서 원래 이렇게 써서… 이름 때문에 열 받으면 일단 잠깐만 참아줘. hhhhh

셋째 줄 final_profileU가 최종 U다. (distance 기반의 베이스 값)에서 (방금 계산한 U 오프셋) 을 빼는 구조다.

일단 “수상한 파라미터 time”은 무시하고 보면, 핵심은 U에 대해서도 시간 오프셋을 걸어 준다는 점이다. 이건 우리가 앞에서 했던 방식이랑 조금 다르다. 앞에서 이미 U가 파도 위치를 결정한다는 걸 알고 있으니까, U에 시간 오프셋이 걸리면 파도 위치가 계속 전진(앞으로 이동)하게 된다. 대신 애니메이션의 재생 자체는 여전히 V(시간 축)와 바인딩돼 있다.

그리고 _Time을 그대로 빼고 있으니 값은 계속 작아진다. 그래서 U는 그대로는 관찰이 어렵고, frac을 씌워서 보자.

예상한 대로, 파도 위치가 전방으로 밀려 나가는 형태가 된다. 이 처리는 파도 “모양 애니메이션”만 재생되는 게 아니라, 파도 자체가 전진하는 느낌까지 같이 만들어 주기 때문에 실제 파도 효과에 더 잘 맞는다.

그럼 이제 그 “수상한 파라미터 time”이 대체 뭔지 보자.

// P.y 취득
float Py = posWS.y  * 1 + direction.y * distance * 1;
float3 Py_Offset = Py * float3(0.121, 0.242 * _WaveSinFrequency, 0.7517 * _WaveCosFrequency);
                
// time
time = Py_Offset.r + sin(Py_Offset.g) * -0.135 * _WaveSinStrength + cos(Py_Offset.b) * -0.0375 * _WaveCosStrength;
float timeNoise = tex2Dlod(_NoiseTexture, float4(posWS * 0.5 * _NoiseMapSampleScale,0,0)).r - 0.5; // time 처리
timeNoise = timeNoise * saturate(distance*0.35-0.05) * _NoiseTime;
time += lerp( 0, timeNoise, _IsNeedNoise); // 노이즈 추가

여기에는 노이즈 텍스처 샘플링이 하나 들어가는데, 노이즈를 추가하는 건 메인 효과를 크게 바꾸진 않으니 일단 무시하고(핵심만 보자) 본체만 보면 된다.

즉 핵심은 이 라인:

time = Py_Offset.r + sin(Py_Offset.g) -0.135 _WaveSinStrength + cos(Py_Offset.b) -0.0375 _WaveCosStrength;

Py_Offset의 본질은 posWS.y다. Unity 좌표계로 보면(여기서는 XZ를 평면으로 쓰고 있으니까) 사실상 Z축 값에 해당한다. 즉, 이 “수상한 time”은 결국 “pos의 Z 기반 값”이라고 보면 된다.

현재 좌표축 기준으로는, 아래로 내려갈수록(time이) 작아져야 한다. 그리고 U에서 이 값을 빼면, 위아래 방향으로 U에 오프셋이 생기면서 결과적으로 아래처럼 “대각선 형태”가 만들어진다.

자, 그럼 이제 이 “수상한 time”의 나머지 두 항도 계속 보자.

float3 Py_Offset = Py * float3(0.121, 0.242 * _WaveSinFrequency, 0.7517 * _WaveCosFrequency);
time = Py_Offset.r + sin(Py_Offset.g) * -0.135 * _WaveSinStrength + cos(Py_Offset.b) * -0.0375 * _WaveCosStrength;

내가 붙여 둔 파라미터 이름만 봐도 감이 올 텐데, sin/cos 계산이다. 기존의 pos Z 기반 값 위에 서로 다른 주기의 sin/cos 함수를 두 개 더 얹고, 각각 다른 frequency/strength를 주는 구조다. 앞에 붙은 마법 계수들은 그냥 Flux에서 그대로 베껴 온 값이다.

sin/cos를 더하는 거니까, 독자도 대충 클래식한 “물결 형태”가 섞인 결과를 상상할 수 있을 거다. 실제로는 이런 느낌이 나온다.

U 쪽 처리는 여기까지. 다음은 V 쪽 처리를 보자.

float profileV = profileU + lerp(0, detail, _IsNeedDetail);
float final_profileV = profileV - frac(final_profileU);

profileU는 앞에서 반전 처리해 둔 distance(=U 베이스)에 “수상한 파라미터 detail”을 더한 값이다. detail은 일단 또 잠깐 스킵하고(뒤에서 따로 뜯어봄), 핵심만 보면 결국

  • distance - frac(U)

이걸 하고 있는 셈이다. 사실 이건 우리가 위에서 시각화했던 결과와 같은 계열이다.

“어떤 값에서 자기 자신의 frac을 빼면 뭐가 나오냐?”는 머리로만 상상하기가 쉽지 않다. 그냥 출력해서 결과를 보는 게 제일 빠르다.

U에서 파생된 값이라서, U가 갖고 있던 “전방 이동 특성”과 “sin/cos에 의한 물결 형태”를 그대로 공유한다.

다만 차이가 있다면,

  • 경계가 확실히 생기고
  • 경계가 딱 끊기는 게 아니라 꽤 매끈하게 그라데이션으로 넘어간다

왜 자기 자신 frac을 빼면 이런 형태가 되는지까지 굳이 깊게 파고들 필요는 없다. 그냥 “이런 모양은 이런 수학 처리로 만들 수 있다” 정도로 기억해두면 된다.

앞에서 U가 “형태 변화 + 전방 이동”을 제공한다는 건 확인했다. 그럼 V를 이렇게 만드는 의미는 뭘까? 텍스처를 실제로 샘플링해서 보면 바로 감이 온다.

이제 파도 텍스처의 “완전한 형태”가 어떤 고정된 위치에 나타나고, 동시에 주기적으로 반복되는 게 보인다. 이게 V를 저렇게 만든 의미다.

위에서 했던 작은 실습을 떠올려 보면, 우리는 U에 min/max를 걸어서 “고정 위치”를 만들었다. 근데 더 나은 방식은 V에서 고정 위치를 만들고, 그 고정 위치의 앞/뒤에도 텍스처 정보가 자연스럽게 존재하도록 만드는 것이다.

그리고 U의 “대각선 형태 변화”는 여기서 또 하나의 역할을 한다. 텍스처의 완전한 형태가 화면에 제대로 나타나게 하는 역할이다.

여기까지 정리하면:

U:

  1. SDF distance 반전으로 올바른 파도 진행 방향 확보
  2. _Time 시간 오프셋으로 파도 전방 이동 효과
  3. pos Z 기반 오프셋으로 대각선 변화 생성
  4. sin/cos 함수 추가로 물결 형태 변화 생성

V:

  1. distance - frac(U)로 고정 위치 + 주기 반복 효과 생성
  2. U의 대각선 변화 기반으로 텍스처의 완전한 형태가 나타나게 함

다양한 애니메이션 효과 생성

여기서 또 하나의 핵심 질문이 남는다. “애니메이션은 대체 어디 있냐?”

애니메이션이 성립하려면, 저장돼 있는 “시간 축”을 시간으로 순차 샘플링해야 한다. 근데 지금 우리가 만든 UV로 샘플링해서 버텍스 오프셋을 걸어 보면, 결국은 ‘정적인 파도 무늬가 계속 굴러가는’ 효과만 나오게 된다.

지금의 V는 “시간에 따라 이동”하긴 한다. 근데 U 쪽에서 들어간 대각선 성분 때문에, 원래라면 순서대로 재생돼야 할 시간이 어긋나 버리고, 결과적으로는 ‘정적인 오프셋’처럼 보이게 된다.

이 과정을 굳이 끝까지 파헤칠 필요는 없다. 중요한 건 “지금 V만으로는 우리가 원하는 애니메이션 재생이 안 나온다”는 사실이다.

V를 개선해서 애니메이션을 만들 방법은, 나도 다른 사람 글을 보면서 이해했다.

  • 杨超大佬 쪽 설명은: 노이즈를 추가해서, 파도 줄무늬가 밀려 보이는(오프셋) 느낌을 약화시키는 방식이다. 결과적으로는 완전히 맞는 얘기다.
  • 하지만 나는 Da哥 글에서 설명한 이해 방식이 더 좋았다. 핵심은 “파도의 V를 위치의 가운데를 기준으로 대칭 샘플링”하는 것이다.

이걸 구현하는 방법은 간단하다. V의 계산식에 “수상한 파라미터 detail”을 하나 더 얹는 거다.

// Detail
// Py_Offset = Py * float3(0.121, 0.242 * _WaveDetailSinFrequency * 0.2, 0.7517 * _WaveDetailCosFrequency * 0.2);
detailes = sin(Py_Offset.g) * -0.25 * _WaveDetailSinStrength + cos(Py_Offset.b) * 0.15 * _WaveDetailCosStrength;

자세히 보면, 아까의 “수상한 파라미터 time”이랑 크게 다르지 않다. 다만 핵심 차이가 하나 있는데, 여기서는 pos Z 기반의 오프셋 항이 없고, 순수하게 sin/cos 항의 영향만 들어간다는 점이다.

그리고 V 계산식은 이렇게 된다.

float profileV = profileU + lerp(0, detail, _IsNeedDetail);
float final_profileV = profileV - frac(final_profileU);

U에서의 sin/cos는 “삼각함수 형태(물결 모양)”를 만들어냈다. 이제 우리가 확인해야 하는 건, 이걸 V에 더하면 어떤 일이 일어나느냐는 거다.

먼저 sin만 넣어 보자. cosStrength는 0으로 두고, sin의 주파수도 낮춘다(여기서는 0.24로 설정). 그러면 V는 아래처럼 나온다.

이 상태에서, 한쪽으로 대각선 이동하는 흐름은 유지되면서 동시에 “대칭 그라데이션”이 생기는 걸 볼 수 있다. 아래는 Da哥 글에서 인용한 그림과 설명이다.

핵심은 UV를 어떻게 계산하느냐다. 아래 그림을 보자. 나는 흰색 박스와 주황색 박스로, 그림 A가 그림 B에서 어디에 대응되는지를 표시해 두었다. 보면 그림 A의 흰색 박스는 그림 B에서 두 위치에 대응된다. 이게 무슨 뜻이냐? V 성분의 값이 ‘대칭’이라는 뜻이다.

즉, sin을 더해 주면 V가 대칭 그라데이션 성질을 갖게 되고, 그게 곧 “기본적인 해안 파도 애니메이션”의 출발점이 된다.

이제 “수상한 detail”의 남은 부분을 보자. 뒤이어 추가하는 cos는, 방금 만들었던 대칭 그라데이션에 ‘다양성’을 더해 주는 역할을 한다.

그리고 여기에 노이즈(Noise) 텍스처 영향까지 얹으면, V가 훨씬 더 다양하게 흔들리면서 ‘자연스러운 어수선함’이 생긴다.

이렇게 하면 애니메이션 “재생” 목적도 달성되고, 동시에 애니메이션의 “형태(모양)”를 커스텀하게 조절하는 것도 가능해진다.

sin / cos / noise를 더하는 방식은 정말 여러 가지가 있다. 내가 위에 제공한 코드는 Flux 블루프린트의 방식을 기반으로 한 거고, 여기에 더 많은 컨트롤 파라미터를 추가해서 조절 가능하게 해 둔 것이다. 원리를 이해한 다음에는, 본인이 원하는 결과에 맞게 이 세 가지를 어떻게 섞을지 직접 설계해보는 걸 추천한다.

사실 대부분의 경우엔, sin 하나로 만드는 “대칭 그라데이션”만으로도 충분하다.

마녀의 솥에 이것저것 마구 집어넣듯이 섞다 보면… 진짜 별별 결과가 다 나온다.

긴 파도, 짧은 파도, 앞뒤로 겹치는 파도… 다 만들 수 있다.

근데 “예쁘냐/안 예쁘냐”는 기술 그 자체와는 별 상관이 없고, 결국 내가 원하는 화면을 ‘잘’ 맞춰내는(피팅하는) 파라미터를 찾아낼 수 있느냐의 문제일 때가 많다.


법선 생성

하… Flux 해안 파도 파트 왜 이렇게 내용이 많냐. ㅋㅋ

근데 법선은 또 안 할 수가 없다. 디테일 표현의 상당 부분이 결국 법선에 달려 있으니까.

원리 자체는 간단하다. 우리가 흔히 쓰는 방식 그대로, dx/dy(혹은 ddx/ddy)로 편미분을 구하고 cross로 법선을 만든다.

다만 Flux는 구현 방식이 약간 다르다. 더 자세한 설명은 계속 杨超大佬 글을 참고하자.

우선 Unity에서 제일 흔히 쓰는 ddx/ddy로, 법선을 뽑으면 어떤 결과가 나오는지부터 보자.

float3 testNormal = -normalize(cross(ddx(i.posWS), ddy(i.posWS)));
float testNoL = dot(testNormal, mainLightDir);

멀리서 보면 그럴싸한데, 가까이서 보면 ddx/ddy의 한계가 그대로 드러난다. 사실 이건 “방법이 틀려서”가 아니라, 그래픽스 API가 우리에게 노출해주는 ddx/ddy 자체가 그 정도 정밀도라는 얘기다.

그래서 더 높은 정밀도로 계산하는 다른 방법이 필요해진다. 다만 요구 품질이 그렇게 높지 않거나, 성능 제약이 강하면 ddx/ddy만으로도 충분히 쓸만하다.

그리고 ddx/ddy 방식에는 또 하나 엄청 중요한 역할이 있다. 뒤에서 더 정밀한 방식으로 법선을 만들어도, “큰 방향성/추세”는 최소한 ddx/ddy로 얻은 결과와 대체로 일치해야 한다는 ‘정답 참고’가 되어 준다.

자, 그럼 Flux가 실제로 어떤 방식으로 구현했는지 바로 뜯어 보자.

float2 normalOffset_dx = float2(_CoastlineNormalRange, 0);
float2 normalOffset_dy = float2(0, _CoastlineNormalRange);
Coastline coastline_dx = GetOffsetCoastline(normalOffset_dx, coastline);
 Coastline coastline_dy = GetOffsetCoastline(normalOffset_dy, coastline);

float2 profileUV_dx = GetProfileUV(i.posWSnoOffset + float3(_CoastlineNormalRange,0,0), coastline_dx);
float2 profileUV_dy = GetProfileUV(i.posWSnoOffset + float3(0,0,_CoastlineNormalRange), coastline_dy);

float2 forwardUpward_dx = SampleProfileMap(profileUV_dx,t);
float2 forwardUpward_dy = SampleProfileMap(profileUV_dy,t);

float3 offset_dx = GetFluxOffset(forwardUpward_dx, coastline_dx) + float3(_CoastlineNormalRange,0,0);
float3 offset_dy = GetFluxOffset(forwardUpward_dy, coastline_dy) + float3(0,0,_CoastlineNormalRange);
float3 coastlineNormal = -TriangleNormal( offset
                    , offset_dx
                    , offset_dy ) ;

Coastline GetOffsetCoastline(float2 offset, Coastline coastline)
{
    coastline.distance = coastline.distance - dot(offset, coastline.direction);
    return coastline;
}

코드만 봐도 바로 알 수 있듯이, 해안 파도 애니메이션 텍스처를 “추가로 2번 더” 샘플링하고 있다. 원래의 posWS를 기준으로, dx / dz 방향으로 살짝 이동한 두 지점에서 다시 샘플링하는 구조다.

  • i.posWSnoOffset + float3(_CoastlineNormalRange, 0, 0)
  • i.posWSnoOffset + float3(0, 0, _CoastlineNormalRange)

우리의 버텍스 변형(변위)은 전부 이 해안 파도 애니메이션 텍스처 샘플 결과에 의존한다. 그러니까 주변 위치도 같은 방식으로 샘플링해 주면, 결국 우리가 “원하는 해상도”로 ddx/ddy에 해당하는 미분을 직접 만들어낼 수 있다.

게다가 이건 우리가 직접 컨트롤하는 방식이라서, dx/dz 오프셋 크기를 임의로 지정할 수 있다. 더 작게 주면 더 높은 정밀도 결과를 기대할 수 있다(물론 너무 작게 주면 또 다른 아티팩트가 생길 수 있고).

전체 흐름은 크게 복잡할 건 없다.

  • 두 번 더 샘플링해서 offset_dx, offset_dy를 얻고
  • 이 세 점(offset, offset_dx, offset_dy)으로 삼각형 법선을 만든다.

여기서 “법선 계산”은 b-a, c-a만 있으면 되기 때문에(차 벡터 2개), 굳이 기존 posWS에 offset을 더해서 “절대 위치”를 만들 필요는 없고, 코드처럼 차분 계산이 가능한 형태로 단순화해도 된다. 이해가 애매하면, 직접 수식으로 한 번 풀어쓰면 바로 정리될 거다.

그리고 한 가지 더 중요한 포인트.

해안 파도 텍스처 샘플링은 결국 distance 정보에 크게 의존한다(이건 앞에서 계속 봤지). 그래서 posWS를 이동시켜서 샘플링 위치를 바꿨다면, 그 위치에 대응되는 distance도 같이 보정해 줘야 한다.

그걸 해주는 게 GetOffsetCoastline이고, 이 작업을 빼먹으면 결과가 틀어진다.

이제 dx/dz를 1로 두고 결과를 보자.

확실히 근거리 디테일이 훨씬 좋아진다. 그리고 큰 방향성은 ddx/ddy 결과와도 대체로 일치하니, “계산이 완전히 틀어진” 건 아니라는 걸 확인할 수 있다.

다만 오프셋이 너무 작아 보정이 과하게 들어가는 탓인지, 왼쪽(해안 경계) 쪽에 약간의 검은 테두리 같은 아티팩트가 보인다.

그래서 dx/dz를 너무 작게 주는 건 추천하지 않는다. Flux 원본에서는 오프셋 거리를 8로 두는데, 내 느낌에도 그 정도가 꽤 밸런스가 좋다.

좋아. 해안 파도 파트는 여기까지면 거의 끝이다.

마지막으로 잊지 말아야 할 건, 텍스처의 foam 마스크도 같이 뽑아서 프래그먼트 셰이더로 넘겨줘야 한다는 것.

foam


내가 최대한 전체 해안 파도(해변 파도) 구현 과정을 “처음부터 끝까지” 설명해 보려고 했지만, 아무래도 중간중간 빠진 포인트가 있을 수밖에 없다.

따라 하다가 코드가 이해 안 되는 부분이 생기면, 내가 공유한 코드랑 이 글의 앞뒤 문맥을 같이 보면서 천천히 맞춰 보면 된다.

파라미터도 사실 “가장 핵심적인 것들”만 골라서 설명한 거라, 못 다룬 파라미터 의미들이 꽤 있다. 그 부분은 구현하면서 독자가 직접 손에 익히는 걸로.

마지막으로, 근해(해안 파도)와 원해(FFT 파도)를 섞는 방식만 하나 덧붙이고 끝내자.

// 해안(근해) / 내해(원해) 블렌드 마스크
float blendMask = saturate((sdf_distance + _ShoreWaveWidth) / _ShoreWaveScale);

SDF distance를 기반으로, 오프셋을 한 번 주고 강하게 스케일링해서 경계 부근에서 0~1 블렌드 가중치를 얻는 아주 단순한 방식이다.

버텍스 셰이더에서 이 blendMask로 버텍스 오프셋, foam, 법선을 lerp해 주기만 하면 된다.


파트 2 에서 계속...