HLSL로 구현하는 물결과 굴절, 거품 셰이더

HLSL로 구현하는 물결과 굴절, 거품 셰이더

여러 개의 원형 파동을 HLSL에서 합성하고 화면 색상 굴절과 해안선 거품을 결합해 물 표면을 만드는 방법을 정리한다. Unity URP/HDRP 또는 Unreal의 커스텀 HLSL 노드로 옮겨 쓸 수 있는 핵심 수식과 구현상의 주의점도 함께 다룬다.

목표와 구성

물 셰이더는 보통 한 가지 효과로 완성되지 않는다. 표면이 흔들리는 느낌은 노멀 또는 버텍스 변위에서 만들고 수면 아래가 흔들려 보이는 느낌은 굴절에서 만들며 얕은 물가의 흰 거품은 깊이 차이에서 판정한다.

이 글에서는 다음 세 요소를 분리해 구현한다.

  1. 충돌 지점에서 퍼지는 원형 물결 파문(Ripples)
  2. 화면 색상을 이용한 굴절(Refraction)
  3. 장면 깊이와 수면 깊이 차이를 이용한 거품(Foam)

물결 파문 중심에서 동심원 파동이 퍼지고 화면 배경이 굴절되며 물가에 거품이 나타나는 물 셰이더 개념도

리플을 원형 파동으로 모델링하기

물결 파문(Ripples) 중심의 월드 좌표를 rippleCenter, 현재 픽셀의 월드 좌표를 worldPos라고 하자. 수면이 XZ 평면에 놓인 경우 중심에서의 거리와 파동 위상은 다음처럼 계산할 수 있다.

d=worldPos.xzrippleCenter.xzd=\lVert worldPos.xz-rippleCenter.xz\rVert p=dfrequencytimespeedp=d\cdot frequency-time\cdot speed

여기서 frequency는 파동 간격, speed는 파동 진행 속도다. 단순한 사인파를 그대로 쓰면 화면 전체에 무한히 퍼지는 줄무늬가 생긴다. 리플 반경과 감쇠를 함께 적용해 필요한 영역에서만 보이게 해야 한다.

struct Ripple
{
    float2 centerXZ;
    float startTime;
    float amplitude;
    float radius;
};

float EvaluateRippleHeight(float2 worldXZ, Ripple ripple, float time)
{
    float age = time - ripple.startTime;
    if (age < 0.0)
        return 0.0;

    float distanceToCenter = length(worldXZ - ripple.centerXZ);
    float waveFront = age * 1.8;
    float localDistance = abs(distanceToCenter - waveFront);

    // 파동 전선 주변만 남기는 폭과, 시간이 지날수록 줄어드는 감쇠
    float ringMask = 1.0 - smoothstep(0.04, 0.22, localDistance);
    float rangeMask = 1.0 - smoothstep(ripple.radius * 0.7, ripple.radius, distanceToCenter);
    float attenuation = exp(-age * 1.4);

    float phase = distanceToCenter * 22.0 - age * 11.0;
    return sin(phase) * ripple.amplitude * ringMask * rangeMask * attenuation;
}

waveFront는 시간이 흐르며 커지므로 파동이 바깥으로 이동한다. localDistance를 기준으로 만든 ringMask는 현재 파동 전선 근처만 보이게 한다. 게임에서 리플을 여러 개 다룰 때는 CPU 또는 컴퓨트 셰이더에서 활성 리플을 관리하고 머티리얼에는 제한된 개수만 전달하는 방식이 실용적이다.

리플 높이에서 노멀 만들기

프래그먼트 셰이더에서 리플 높이를 직접 색에 더하는 것만으로는 입체감이 부족하다. 인접한 위치의 높이 차이로 기울기를 구해 노멀을 만들면 조명과 굴절에 같은 파동 정보를 사용할 수 있다.

float EvaluateCombinedRipple(float2 worldXZ, float time)
{
    float height = 0.0;

    [unroll]
    for (int i = 0; i < _RippleCount; ++i)
    {
        height += EvaluateRippleHeight(worldXZ, _Ripples[i], time);
    }

    return height;
}

float3 BuildRippleNormal(float2 worldXZ, float time, float sampleStep)
{
    float hL = EvaluateCombinedRipple(worldXZ - float2(sampleStep, 0.0), time);
    float hR = EvaluateCombinedRipple(worldXZ + float2(sampleStep, 0.0), time);
    float hD = EvaluateCombinedRipple(worldXZ - float2(0.0, sampleStep), time);
    float hU = EvaluateCombinedRipple(worldXZ + float2(0.0, sampleStep), time);

    float2 gradient = float2(hL - hR, hD - hU);
    return normalize(float3(gradient.x, 2.0 * sampleStep, gradient.y));
}

이 노멀은 월드 공간 기준이다. 탄젠트 공간 노멀 맵과 합칠 때는 공간을 통일해야 한다. 메시의 기본 노멀과 탄젠트 행렬을 사용해 월드 공간으로 변환한 뒤 합성하거나 리플 노멀을 탄젠트 공간으로 변환해야 한다. 서로 다른 공간의 벡터를 바로 더하면 시점이나 메시 방향에 따라 조명이 어색해진다.

화면 색상으로 굴절 만들기

굴절은 물 뒤에 보이는 장면 색상을 수면 노멀 방향으로 살짝 이동시켜 샘플링하는 효과다. Unity에서는 카메라 컬러 텍스처, Unreal에서는 Scene Color 입력이 이에 해당한다. 플랫폼과 렌더 파이프라인에 따라 텍스처 이름과 샘플링 함수는 달라지지만 계산 원리는 같다.

float2 ComputeRefractionUV(float2 screenUV, float3 waterNormalWS)
{
    float2 distortion = waterNormalWS.xz * _RefractionStrength;
    return saturate(screenUV + distortion);
}

float3 SampleRefractedColor(float2 screenUV, float3 waterNormalWS)
{
    float2 refractUV = ComputeRefractionUV(screenUV, waterNormalWS);
    return _CameraOpaqueTexture.Sample(_LinearClampSampler, refractUV).rgb;
}

_RefractionStrength는 화면 UV 단위이므로 해상도와 카메라 투영에 따라 체감 강도가 달라질 수 있다. 특히 물체 가장자리에서는 이동한 UV가 물 표면 밖 배경을 샘플링해 테두리가 번질 수 있다. saturate는 화면 밖 접근을 막지만 가장자리 품질 문제 자체를 해결하지는 않는다. 가능한 경우 굴절 강도를 깊이 차이에 따라 줄이는 편이 자연스럽다.

수면 노멀 방향으로 화면 색상 샘플 좌표가 이동해 굴절이 생기는 과정

깊이에 따라 굴절 강도 줄이기

얕은 물은 바닥과 수면의 거리가 짧아 굴절이 과하면 바닥이 과도하게 흔들려 보인다. 반대로 깊은 물은 색 흡수와 함께 더 안정적인 왜곡을 줄 수 있다. 장면 깊이와 수면 깊이의 차이를 구해 굴절에 마스크를 적용할 수 있다.

float ComputeWaterDepth(float sceneDepth, float waterSurfaceDepth)
{
    return max(sceneDepth - waterSurfaceDepth, 0.0);
}

float ComputeRefractionMask(float waterDepth)
{
    return smoothstep(_MinRefractionDepth, _MaxRefractionDepth, waterDepth);
}

중요한 점은 깊이 값을 같은 기준으로 비교하는 것이다. 많은 엔진의 깊이 텍스처는 비선형 장치 깊이값을 저장한다. 이 값을 수면 깊이와 바로 빼면 원근에 따라 판정이 달라진다. 엔진이 제공하는 함수로 두 값을 모두 선형 뷰 공간 깊이 또는 선형 아이 깊이로 변환한 뒤 차이를 계산해야 한다.

깊이 차이로 거품 마스크 만들기

거품(Foam)은 물이 지형이나 오브젝트와 만나는 얕은 구간에서 가장 설득력 있게 보인다. 장면 깊이에서 수면 깊이를 뺀 값을 waterDepth로 두고 일정 깊이 이하면 거품이 나타나도록 만든다.

foamMask=1smoothstep(foamStart,foamEnd,waterDepth)foamMask=1-smoothstep(foamStart,foamEnd,waterDepth)

smoothstep을 사용하면 경계가 부드러워진다. 그러나 이 마스크만 쓰면 지나치게 균일한 흰 띠가 된다. 노이즈 텍스처와 시간에 따른 스크롤을 곱해 거품의 끊김과 움직임을 만든다.

float ComputeFoamMask(float waterDepth, float2 worldXZ)
{
    float shoreMask = 1.0 - smoothstep(_FoamShallowDepth, _FoamDeepDepth, waterDepth);

    float2 noiseUV0 = worldXZ * _FoamTiling + _Time.y * float2(0.03, 0.02);
    float2 noiseUV1 = worldXZ * (_FoamTiling * 1.7) - _Time.y * float2(0.02, 0.04);
    float noise0 = _FoamNoise.Sample(_LinearRepeatSampler, noiseUV0).r;
    float noise1 = _FoamNoise.Sample(_LinearRepeatSampler, noiseUV1).r;
    float foamNoise = noise0 * noise1;

    float brokenEdge = smoothstep(_FoamNoiseCutoff, 1.0, foamNoise);
    return shoreMask * brokenEdge;
}

float3 ApplyFoam(float3 waterColor, float foamMask)
{
    float3 foamColor = _FoamColor.rgb;
    return lerp(waterColor, foamColor, foamMask * _FoamColor.a);
}

거품이 파도 위에 떠 있는 느낌을 강화하려면 리플 높이 또는 리플 기울기를 추가 마스크로 활용할 수 있다. 예를 들어 기울기가 큰 구간에 약한 거품을 더하면 충돌 리플의 가장자리가 조금 더 살아난다. 다만 모든 리플에 거품을 강하게 적용하면 비누 거품처럼 보이므로 얕은 물 거품보다 훨씬 약하게 두는 편이 좋다.

최종 색상 결합 순서

아래 순서는 효과의 책임을 분리하고 디버깅하기 쉽다.

flowchart TD
    A[월드 위치와 화면 UV 계산] --> B[리플 높이와 월드 공간 노멀 계산]
    B --> C[깊이 텍스처 선형화]
    C --> D[수면 깊이 차이 계산]
    B --> E[노멀 기반 굴절 UV 계산]
    D --> E
    E --> F[굴절된 장면 색상 샘플링]
    D --> G[해안선 거품 마스크 계산]
    F --> H[물 색과 투과색 결합]
    G --> H
    H --> I[프레넬 반사와 조명 적용]

실제 셰이더에서는 물의 고유 색과 굴절 색을 깊이로 보간한 뒤 거품을 적용하는 구성이 다루기 쉽다. 마지막으로 프레넬 항을 사용해 수면을 비스듬히 볼수록 반사 또는 환경색 비중이 커지게 하면 평평한 투명 판처럼 보이는 문제를 줄일 수 있다.

float3 ComposeWaterColor(
    float3 refractedColor,
    float3 waterTint,
    float waterDepth,
    float foamMask,
    float3 normalWS,
    float3 viewDirWS)
{
    float absorption = 1.0 - exp(-waterDepth * _AbsorptionDensity);
    float3 transmission = lerp(refractedColor, waterTint, absorption);

    float fresnel = pow(1.0 - saturate(dot(normalWS, viewDirWS)), _FresnelPower);
    float3 surfaceColor = lerp(transmission, _ReflectionColor.rgb, fresnel * _ReflectionColor.a);

    return ApplyFoam(surfaceColor, foamMask);
}

엔진별 적용 시 확인할 점

Unity

URP에서 화면 굴절을 사용하려면 카메라의 Opaque Texture 설정이 필요하다. 깊이 기반 거품에는 Depth Texture도 필요하다. Shader Graph의 Custom Function으로 옮길 경우에도 깊이 선형화와 화면 UV 변환은 URP가 제공하는 노드를 우선 사용하는 편이 안전하다.

HDRP는 굴절과 투명 표면을 위한 기능을 더 많이 제공하지만 렌더 순서와 투명 물체가 서로를 굴절하는 방식은 별도로 확인해야 한다. 특히 투명 패스에서 이미 그려진 화면 색상만 샘플링할 수 있는 경우가 많다.

Unreal Engine

머티리얼의 SceneDepth와 PixelDepth를 비교할 때도 같은 공간과 단위를 사용해야 한다. Custom 노드에서 HLSL을 작성한다면 월드 위치, 뷰 방향, 화면 UV를 엔진이 제공하는 입력에서 명시적으로 받아 사용하는 편이 이식성과 디버깅에 유리하다.

Unreal의 Translucent 머티리얼은 정렬 문제와 조명 제약을 가질 수 있다. 먼 물체와 겹치는 대형 수면에서는 굴절보다 반사, 색 흡수, 거품을 먼저 안정화한 뒤 굴절 강도를 작게 추가하는 접근이 안전하다.

성능과 품질 조절

  • 리플 수는 고정 상한을 두고 보이지 않거나 감쇠가 끝난 리플은 CPU에서 제거한다.
  • length 호출이 부담되는 구간에서는 제곱 거리를 이용한 빠른 범위 제거를 먼저 적용한다.
  • 거품 노이즈는 두 장을 샘플링하면 품질은 좋아지지만 텍스처 샘플 수가 늘어난다. 모바일에서는 한 장의 노이즈와 작은 왜곡부터 시작하는 편이 좋다.
  • 굴절은 화면 색상 복사본을 요구할 수 있으므로 전체 화면 비용을 확인한다. 약한 노멀 왜곡만으로도 충분한 장면이 많다.
  • 깊이 기반 효과는 물 표면과 다른 지오메트리가 거의 같은 깊이에 있을 때 깜빡일 수 있다. foamStart, foamEnd 사이 간격을 너무 좁게 잡지 않는다.

마무리

물결 파문(Ripples), 굴절, 거품(Foam)은 서로 다른 문제처럼 보이지만 모두 수면의 위치와 방향 그리고 깊이 차이를 공유한다. 먼저 리플로 안정적인 월드 공간 노멀을 만들고 깊이값을 올바르게 선형화한 뒤 굴절과 거품을 각각 독립된 마스크로 조절하면 엔진이 달라져도 유지보수하기 쉬운 물 셰이더를 구성할 수 있다.

#HLSL#셰이더#Unity#Unreal Engine#물 렌더링

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs