HLSL 수면 파동과 물 굴절 깊이감 셰이더 구현법

HLSL에서 동심원 Ripple 파동을 계산하고 화면 색상 굴절과 깊이 버퍼 기반 흡수·얕은 물 색상을 결합한다. 수식, 셰이더 코드, 디버깅과 최적화 기준까지 단계별로 정리한다.

✓ 복사 완료!
챕터셰이더 프로그래밍 (HLSL/ShaderGraph)
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
HLSL 수면 파동과 물 굴절 깊이감 셰이더 구현법

핵심 요약

HLSL 수면 셰이더의 파동은 월드 좌표의 수면 UV에서 충돌 지점까지의 거리로 원형 위상을 만들고 sin() 결과를 높이와 법선 교란에 함께 사용하면 구현할 수 있다. 물 굴절의 깊이감은 화면 깊이 버퍼에서 불투명 물체의 깊이를 읽어 수면 깊이와 빼고 그 거리로 굴절 세기·색상 흡수·투명도를 제어하는 방식이 가장 직접적이다.

이 글의 예시는 Shader Model 5.0 수준의 HLSL을 기준으로 한다. 엔진마다 깊이 텍스처의 좌표계와 선형화 함수는 다르지만 파동·굴절·흡수의 계산 구조는 동일하다.

수면 Ripple 셰이더는 어떤 데이터로 구성되는가?

한 개의 Ripple에는 최소한 다음 데이터가 필요하다.

파라미터의미권장 단위 또는 범위
centerXZ충돌 지점의 월드 XZ 좌표월드 단위
startTime파동이 시작된 시간초
amplitude파고0.01~0.15
frequency원형 파장의 밀도1~12
speed파동 전파 속도0.5~8
width파동 띠의 폭0.05~1
lifetime파동 유지 시간초

수면 위 월드 좌표를 p, Ripple 중심을 c, 현재 시간을 t, 시작 시간을 t0라고 하면 거리와 경과 시간은 다음과 같다.

d=∥pxz−cxz∥d=\left\|p_{xz}-c_{xz}\right\| e=t−t0e=t-t_0

전파하는 위상은 거리에서 시간에 따른 전파량을 뺀 값으로 계산한다.

phase=(d−e⋅speed)⋅frequencyphase=(d-e\cdot speed)\cdot frequency

파동 전체를 계속 흔드는 대신 원형 띠만 보이게 하려면 가우시안에 가까운 감쇠 항을 곱한다.

ring=exp⁡(−(d−e⋅speedwidth)2)ring=\exp\left(-\left(\frac{d-e\cdot speed}{width}\right)^2\right) height=sin⁡(phase)⋅amplitude⋅ring⋅fadeheight=\sin(phase)\cdot amplitude\cdot ring\cdot fade

fade는 Ripple의 수명이 끝날수록 0으로 줄이는 값이다. 이 구조는 충돌 지점에서 바깥으로 퍼지는 물결에 적합하며 시간만 바꿔도 별도의 메시 애니메이션 없이 파동이 진행된다.

동심원 Ripple의 중심 좌표, 전파 거리, 띠 폭을 설명하는 수면 셰이더 다이어그램

HLSL로 동심원 Ripple 파동을 어떻게 구현할까?

1. Ripple 파라미터를 CPU에서 전달한다

간단한 구현에서는 Ripple 수를 고정하고 StructuredBuffer로 전달한다. 충돌이 발생할 때마다 CPU가 빈 슬롯 또는 가장 오래된 슬롯을 갱신하면 된다.

struct Ripple
{
    float2 centerXZ;
    float startTime;
    float amplitude;
    float frequency;
    float speed;
    float width;
    float lifetime;
};

StructuredBuffer<Ripple> _Ripples;
int _RippleCount;
float _TimeSeconds;

실시간으로 Ripple 수가 크게 늘지 않는다면 8~16개로 제한하는 편이 낫다. 수면 픽셀마다 모든 Ripple을 평가하므로 개수가 곧 프래그먼트 셰이더 비용이 된다.

2. 높이와 기울기를 한 번에 누적한다

높이만 바꾸면 조명은 평평하게 남는다. 거리 방향의 기울기를 함께 누적해 법선을 교란해야 물결이 빛을 받는다.

void AccumulateRipple(
    float2 worldXZ,
    Ripple ripple,
    inout float height,
    inout float2 slope)
{
    float elapsed = _TimeSeconds - ripple.startTime;
    if (elapsed < 0.0 || elapsed > ripple.lifetime)
        return;

    float2 delta = worldXZ - ripple.centerXZ;
    float distanceToCenter = length(delta);
    float2 direction = delta / max(distanceToCenter, 1e-4);

    float radius = elapsed * ripple.speed;
    float normalizedOffset = (distanceToCenter - radius) / max(ripple.width, 1e-4);
    float ring = exp(-normalizedOffset * normalizedOffset);
    float fade = 1.0 - saturate(elapsed / ripple.lifetime);
    float phase = (distanceToCenter - radius) * ripple.frequency;

    float wave = sin(phase) * ripple.amplitude * ring * fade;
    height += wave;

    // 거리 방향 미분의 근사값이다. 조명용 법선 교란에 사용한다.
    float derivative = cos(phase) * ripple.frequency * ripple.amplitude * ring * fade;
    slope += direction * derivative;
}

3. 기울기에서 수면 법선을 만든다

수면이 XZ 평면에 놓였다고 가정하면 누적한 기울기로 다음 법선을 만들 수 있다.

float3 BuildWaterNormal(float2 slope, float normalStrength)
{
    float3 normalWS = float3(
        -slope.x * normalStrength,
        1.0,
        -slope.y * normalStrength);

    return normalize(normalWS);
}

정확한 미분을 모두 구하는 방법도 있지만 일반적인 게임 수면에서는 위 근사로 충분한 하이라이트와 굴절 왜곡을 얻는다. 파동이 매우 가파르면 메시 정점 변위와 프래그먼트 법선 계산의 불일치가 드러날 수 있으므로 그때는 동일한 파동 함수를 버텍스와 프래그먼트 단계에서 사용한다.

물 굴절은 왜 깊이 버퍼가 있어야 자연스러운가?

법선만으로 화면 색상 UV를 밀면 물 아래가 얼마나 깊은지와 관계없이 같은 양만 굴절된다. 그 결과 얕은 웅덩이의 바닥과 깊은 수면이 같은 왜곡을 보여 평면처럼 보인다.

깊이 버퍼를 사용하면 수면 뒤의 불투명 물체 깊이와 현재 수면 깊이의 차이를 구할 수 있다.

waterDepth=max⁡(0,sceneDepth−surfaceDepth)waterDepth=\max(0,sceneDepth-surfaceDepth)

이 waterDepth로 굴절 UV 오프셋을 제한하고 얕은 물과 깊은 물의 색을 보간한다. 광학적으로 완전한 굴절은 아니지만 화면 공간 물 셰이더에서 비용 대비 효과가 좋은 근사다.

깊이 기반 항목얕은 물깊은 물
굴절 왜곡작거나 제한적법선에 따라 더 뚜렷함
물 색바닥색이 잘 보임흡수색 비중이 커짐
투명도낮음높음 또는 흡수색 우세
가장자리부드러운 접촉선 필요접촉선 영향이 적음

얕은 물과 깊은 물에서 깊이 버퍼 차이로 굴절과 흡수색이 달라지는 화면 공간 비교

깊이 기반 굴절과 색상 흡수는 어떻게 구현할까?

1. 깊이 값을 같은 공간으로 선형화한다

가장 중요한 조건은 sceneDepth와 surfaceDepth를 같은 선형 깊이 공간으로 비교하는 것이다. 아래 코드는 엔진별 선형화 함수를 별도로 제공한다는 가정의 의사 HLSL이다.

Texture2D _CameraOpaqueTexture;
Texture2D _CameraDepthTexture;
SamplerState sampler_LinearClamp;

float LinearEyeDepthFromRaw(float rawDepth);

float GetWaterDepth(float2 screenUV, float surfaceRawDepth)
{
    float sceneRawDepth = _CameraDepthTexture.Sample(
        sampler_LinearClamp, screenUV).r;

    float sceneDepth = LinearEyeDepthFromRaw(sceneRawDepth);
    float surfaceDepth = LinearEyeDepthFromRaw(surfaceRawDepth);
    return max(0.0, sceneDepth - surfaceDepth);
}

_CameraDepthTexture는 대개 물보다 먼저 렌더링된 불투명 오브젝트의 깊이를 담는다. 따라서 투명 오브젝트끼리의 정확한 굴절은 이 방식만으로 해결되지 않는다.

2. 법선으로 화면 UV를 이동한다

굴절 오프셋은 화면 공간 법선의 XY 성분을 사용한다. 깊이에 비례시키되 상한을 두면 깊은 영역에서 화면이 과하게 찢어지는 문제를 줄일 수 있다.

float2 GetRefractionUV(
    float2 screenUV,
    float3 normalVS,
    float waterDepth,
    float refractionStrength,
    float maxOffset)
{
    float depthFactor = saturate(waterDepth * 0.25);
    float2 offset = normalVS.xy * refractionStrength * depthFactor;
    offset = clamp(offset, -maxOffset, maxOffset);

    return saturate(screenUV + offset);
}

normalVS는 뷰 공간 법선이다. 월드 공간 법선을 그대로 쓰면 카메라가 회전할 때 굴절 방향이 화면과 자연스럽게 맞지 않는다.

3. Beer-Lambert 근사로 깊은 물 색을 만든다

물속 이동 거리가 길수록 바닥색이 더 많이 흡수된다고 보고 투과율을 계산한다.

transmittance=e−absorption⋅waterDepthtransmittance=e^{-absorption\cdot waterDepth}
float3 ApplyWaterAbsorption(
    float3 refractedColor,
    float3 deepWaterColor,
    float waterDepth,
    float3 absorption)
{
    float3 transmittance = exp(-absorption * waterDepth);
    return lerp(deepWaterColor, refractedColor, transmittance);
}

absorption의 RGB 값을 다르게 두면 붉은빛이 먼저 줄고 청록 계열이 남는 물색을 만들 수 있다. 값은 월드 스케일에 강하게 의존하므로 아트 팀이 조절 가능한 머티리얼 파라미터로 노출하는 편이 좋다.

전체 프래그먼트 셰이더 흐름

다음 예시는 파동 법선, 깊이, 굴절, 흡수를 하나로 묶은 핵심 흐름이다. TransformWorldToViewNormal과 GetSurfaceRawDepth는 사용하는 엔진의 변환 함수로 교체한다.

float4 FragWater(
    float3 worldPosition,
    float2 screenUV,
    float surfaceRawDepth) : SV_Target
{
    float height = 0.0;
    float2 slope = 0.0;

    [loop]
    for (int i = 0; i < _RippleCount; i++)
        AccumulateRipple(worldPosition.xz, _Ripples[i], height, slope);

    float3 normalWS = BuildWaterNormal(slope, _NormalStrength);
    float3 normalVS = TransformWorldToViewNormal(normalWS);

    float waterDepth = GetWaterDepth(screenUV, surfaceRawDepth);
    float2 refractUV = GetRefractionUV(
        screenUV,
        normalVS,
        waterDepth,
        _RefractionStrength,
        _MaxRefractionOffset);

    float3 sceneColor = _CameraOpaqueTexture.Sample(
        sampler_LinearClamp, refractUV).rgb;
    float3 waterColor = ApplyWaterAbsorption(
        sceneColor,
        _DeepWaterColor.rgb,
        waterDepth,
        _Absorption.rgb);

    float edgeFade = saturate(waterDepth / _EdgeFadeDistance);
    float alpha = lerp(_ShallowAlpha, _DeepAlpha, edgeFade);
    return float4(waterColor, alpha);
}

height는 이 프래그먼트 코드에서 법선을 만들기 위해 간접적으로만 쓰였다. 실제로 수면 실루엣까지 흔들려면 버텍스 셰이더에서도 같은 AccumulateRipple() 원리를 사용해 정점 Y 좌표에 height를 더해야 한다.

수면 셰이더 제작 순서

  1. 평평한 수면과 깊이 텍스처를 먼저 연결한다. 깊이 차이를 회색조로 출력해 얕은 영역과 깊은 영역이 기대대로 구분되는지 확인한다.
  2. Ripple 하나만 추가하고 centerXZ, speed, width를 조절한다. 이 단계에서는 굴절을 끄고 원형 띠가 바깥으로 이동하는지만 본다.
  3. 기울기 기반 법선으로 조명 또는 굴절 UV를 교란한다. UV 오프셋은 작은 값부터 시작하고 maxOffset으로 상한을 둔다.
  4. 마지막으로 Beer-Lambert 흡수색과 접촉선 알파를 더한다. 바닥이 잘 보이는 얕은 물은 투명하게, 깊은 물은 흡수색이 남도록 조정한다.

수면 Ripple과 굴절에서 흔히 발생하는 문제는 왜 생기는가?

굴절이 화면 가장자리에서 늘어나거나 검게 보인다

굴절된 UV가 화면 밖을 샘플링하기 때문이다. 예시처럼 saturate() 또는 엔진의 화면 경계 처리로 UV를 제한한다. 다만 화면 가장자리에서 눌린 듯한 결과가 남을 수 있으므로 품질이 중요하면 가장자리 거리로 굴절 세기를 0으로 감쇠한다.

얕은 물인데도 너무 진한 색이 나온다

대개 깊이 값이 선형화되지 않았거나 수면 깊이와 씬 깊이를 서로 다른 공간에서 빼고 있기 때문이다. 원본 깊이값을 직접 뺀 결과는 원근 투영에서 거리와 비례하지 않는다. 두 값 모두 eye depth 또는 linear depth로 변환한 뒤 차이를 구해야 한다.

Ripple이 중심에서 뾰족하거나 노이즈처럼 보인다

distanceToCenter가 0에 가까울 때 정규화한 방향 벡터가 불안정해질 수 있다. max(distanceToCenter, 1e-4) 같은 작은 하한으로 나눗셈을 보호하고 너무 좁은 width와 과도한 frequency 조합은 피한다.

자주 묻는 질문 (FAQ)

Ripple마다 렌더 텍스처가 필요한가?

아니다. 작은 수의 충돌 파동은 중심·시간·세기 파라미터만 GPU에 전달해 프래그먼트 셰이더에서 합산하는 편이 단순하다. 지속적인 흔적, 다수의 상호작용, 유체 시뮬레이션이 필요할 때만 Render Texture 기반 높이장 시뮬레이션을 고려한다.

깊이 텍스처만 있으면 물 아래 투명 오브젝트도 굴절되는가?

대개 아니다. 일반적인 카메라 깊이 텍스처는 불투명 패스 기준이다. 투명 오브젝트까지 포함하려면 렌더 순서와 별도 컬러·깊이 캡처 패스를 설계해야 한다.

모바일에서도 exp() 기반 흡수를 써도 되는가?

Ripple 수가 적고 수면 화면 점유율이 낮다면 대체로 가능하다. 비용이 문제라면 exp() 호출을 줄이기보다 Ripple 개수와 화면 해상도, 굴절용 opaque texture 사용 여부를 먼저 프로파일링한다.

정리

HLSL 수면 셰이더는 거리 기반 원형 파동으로 법선을 만들고 선형화한 깊이 차이로 화면 굴절과 색상 흡수를 제어하면 작은 코드 규모로도 깊이감 있는 결과를 낼 수 있다. 구현 순서는 깊이 시각화, 단일 Ripple, 법선 굴절, 흡수색 순으로 유지하면 좌표계와 렌더 순서 문제를 빠르게 분리할 수 있다.