TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

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

jplee 2026. 4. 4. 01:09

역자의 말: 요 몇일 늦은 나이에 운전면허 따러다니고 고객사 일 돌보고 버그 수정 하느라 업데이트가 늦었습니다. 파트2는 바다 서피스 렌더링에 관한 내용들로 이루어 져 있습니다.

저자: 秋大叔

flux single layer water 셰이딩(착색)

드디어 셰이딩 파트까지 왔다. 이제부터는 앞에서 했던 것들보다 “눈에 보이는 보상(정답 체크)”이 훨씬 자주 나온다. 마치 그림 그릴 때, 고통스러운 스케치/선 따기 단계는 끝났고 이제 색칠 들어가는 느낌.

근데 아직은 완전히 시원하게 ‘색칠만’ 할 수는 없다. 명암 이분 같은 기본 광영 처리를 아직 안 했기 때문이다. 그 명암 변화들까지 다 처리하고 나면, 남는 건 진짜로 파라미터 조절하면서 쾌감 있게 색칠하는 단계가 된다.

Flux의 single layer water 셰이딩 과정을 크게 세 덩어리로 나누면:

  1. 주 광원(태양) 스페큘러 하이라이트
  2. 수중의 물리 기반 산란/굴절(단일 레이어 워터의 핵심)
  3. 환경광 + 스페큘러 반사(거울 반사)

그래픽스 로직도 아주 간단히만 정리해 두자.

전체 셰이딩은 여전히 PBR 흐름을 탄다.

  • 첫 번째 덩어리는 주 광원의 “반사(스페큘러)” 파트
  • 두 번째 덩어리는 주 광원의 “굴절/투과(수중 산란)” 파트로, 여기서는 이게 사실상 디퓨즈(난반사)를 대체한다. 그래서 이 셰이딩 흐름에서는 일반적인 의미의 디퓨즈가 등장하지 않는다.
  • 마지막 덩어리는 간접광 보강이다. 환경광 IBL + 수면 특유의 반사(거울 반사)를 함께 써서 간접광을 맞춰준다.

너무 깊게 이해하려고 애쓰진 말고, “익숙한 PBR 로직이랑 큰 틀은 비슷하다” 정도만 잡고 가면 된다. 어쨌든 우리는 이번에도 물리 기반 렌더링을 하고 있는 거니까.


주 광원(태양) 스페큘러 하이라이트

첫 번째 덩어리는 정말 간단하고, 구현도 금방 끝난다.

그냥 “일반적인 PBR 스페큘러”를 렌더링하는 방식 그대로 하면 된다.

  • 주 광원 방향/강도
  • 스페큘러 컬러(F0)
  • 러프니스

이 세 가지를 가지고, 프레넬 + GGX NDF + Smith 지오메트리 항으로 BRDF를 구성해서 최종 반사 값을 얻는다.

색과 러프니스는 파라미터로 열어두고 원하는 대로 조절하면 된다.

방금 한 문단이 너무 ‘고속 주문’처럼 느껴지면, PBR 기본 이론을 한 번 복습하자. 여기서는 더 깊게 풀진 않겠다.

float My_D_GGX( float a2, float NoH )
{
    float d = ( NoH * a2 - NoH ) * NoH + 1;
    return a2 / ( PI*d*d );
}

float Vis_SmithJointApprox( float a2, float NoV, float NoL )
{
    float a = sqrt(a2);
    float Vis_SmithV = NoL * ( NoV * ( 1 - a ) + a );
    float Vis_SmithL = NoV * ( NoL * ( 1 - a ) + a );
    return 0.5 * rcp( Vis_SmithV + Vis_SmithL );
}

float3 My_F_Schlick( float3 SpecularColor, float VoH )
{
    float Fc = Pow4( 1 - VoH ) * ( 1 - VoH );
    return saturate( 50.0 * SpecularColor.g ) * Fc + (1 - Fc) * SpecularColor;
}

float3 SpecularGGX( float Roughness, float3 SpecularColor, BxDFContext Context, float NoL)
{
    float a2 = Pow4( Roughness );
    // a2 = Roughness * Roughness;    float Energy = 1;
    float D = My_D_GGX( a2, Context.NoH ) * Energy;
    float Vis = Vis_SmithJointApprox( a2, Context.NoV, NoL );
    float3 F = My_F_Schlick( SpecularColor, Context.VoH );

    return (D * Vis) * F;
}

// F0
float3 F0 = _BaseColor;

// 러프니스
float roughness = _Roughness;

// 첫 번째 큰 덩어리
float3 mainLightSpecularColor = mainLightColor * brdfContext.NoL * SpecularGGX(roughness, F0, brdfContext, brdfContext.NoL);
color += mainLightSpecularColor;

코드는 UE 셰이더를 거의 그대로 옮겨 온 버전이다. 물론 Unity 기본 PBR 로직으로 바꿔서 써도 되고, 결과 차이는 대체로 크게 나지 않는다.

너무 클래식한 내용이라서 더 할 말이 별로 없다. PBR 지식이 좀 헷갈리면, 예전에 내가 정리해 둔 글 링크 하나만 던져 둔다. (이 사람 진짜 뻔뻔하네 ㅋㅋ)

그래픽스 PBR 머티리얼+IBL 환경광(OpenGL 기반 구현) - Zhihu

수중의 물리 기반 산란/굴절

여기가 single layer water의 핵심 파트다. UE에서 이 셰이딩 파트의 원본 코드를 보고 싶으면, RenderDoc으로 물 데모를 한 번 잡아서 EvaluateWaterVolumeLighting 함수를 찾으면 된다.

이 소절은 두 덩어리로 나눠서 얘기하자.

  1. Flux의 “전처리 파라미터” 계산(미리 뽑아두는 값들)
  2. single layer water의 단순화된 물리 산란 셰이딩 방식

본격적으로 수중 광조 계산에 들어가기 전에, 준비 작업이 꽤 많다.

https://dev.epicgames.com/documentation/zh-cn/unreal-engine/single-layer-water-shading-model?application_version=4.27

UE 공식 문서를 보면, 우리가 구해야 하는 파라미터가 5개다: 산란 계수, 흡수 계수, PhaseG, 수중 색조(water behind 쪽), 투명도.

근데 UE의 Single Layer Water 셰이딩 모델을 실제로 쓰면, specular(하이라이트 계수)도 추가로 넣어줘야 해서 총 6개가 된다.

그리고 이 6개 전처리 파라미터를 뽑아내는 게 Flux가 해주는 일이다.

우선 우리가 익숙한 두 파라미터부터 처리하자: opacity(불투명도)와 specular

// depthfloat depthDiff = screenDepthView - viewDepth;

// Specularfloat translucent = saturate(GetSpecularTranslucent(depthDiff, viewDir));
float specularHorAndFre = SpecularHorizonAndFresnel(_WorldSpaceCameraPos, brdfContext.NoV);
float specular = min(normalWS.y, translucent * specularHorAndFre);

// Opacityfloat opacity = translucent * _SurfaceOverlayAlpha;
float WaterVisibility = 1 - opacity;

// 함수 부분float GetSpecularTranslucent(float dpethDiff, float3 viewDir)
{
    float viewDir_y = abs(viewDir.y) * _WaterDepthUpwardBlend + 1;
    return viewDir_y * dpethDiff * _WaterShallowBlend;
}

float SpecualrFresnel(float NoV)
{
    float res = -NoV + 1;
    res = pow(res, _WaterSpecular.z);
    res = res * _WaterSpecular.y + _WaterSpecular.x;
    return saturate(res);
}

float SpecularHorizonAndFresnel(float3 cameraPos, float NoV)
{
    float cameraLength = length(cameraPos);
    float cameraHorizionDis = _WaterSpecularHorizonDistance - _WaterSpecularHorizonOffset*cameraPos.y;

    float horizonAffect = (cameraHorizionDis - cameraLength) / (cameraHorizionDis * 0.9);
    horizonAffect = saturate(horizonAffect);
    horizonAffect = horizonAffect * horizonAffect;

    float specularFresnel = SpecualrFresnel(NoV);

    return saturate(horizonAffect + _WaterSpecular.w) * specularFresnel;

}

이번에도 한 줄씩 뜯어보자.

먼저 translucent = saturate(GetSpecularTranslucent(depthDiff, viewDir)).

translucent는 “수중 빛이 얼마나 잘 통과하느냐(침투/투과 정도)” 정도로 이해하면 된다. 깊이와 시선 방향에 묶어, 물속에서 빛이 이동하는 거리를 간단히 피팅한 값이다.

여기서 depth는 view 공간에서의 “수면 depth”와 “수중 물체 depth”의 차이를 쓴다. 값이 클수록 빛이 물속에서 더 긴 거리를 지나야 한다는 뜻이고, 시선이 더 수평에 가까울수록(수면과의 각이 작을수록) 바닥까지 가는 길이가 더 길어지니까 결과적으로 투과가 더 억제되는 쪽으로 간다.

specularHorAndFre는 “카메라 높이 + 프레넬”이 specular에 주는 영향을 계산한다. 카메라가 높을수록 specular이 더 낮아져서 하이라이트가 덜 보이는 게 자연스럽다. 그리고 프레넬은 specular 계수에 직접 영향을 준다.

마지막으로 specular은 두 값을 합쳐서 specular = min(normalWS.y, translucent * specularHorAndFre)로 만든다.

이 값은 이름만 보면 ‘하이라이트’처럼 보이지만, UE에서 specular의 의미는 “(디퓨즈 쪽에 얹히는) 스페큘러 강도 계수”에 가깝다. 그리고 우리 물 셰이딩에서는 디퓨즈의 물리적 의미가 곧 “수중 산란/굴절”이니까, 여기서의 specular은 수중 산란/굴절 강도를 제어하는 계수 중 하나라고 보면 된다.

opacity = translucent * _SurfaceOverlayAlpha는 수중 투과도 위에 “사용자 정의 투명도 강도”를 한 번 더 곱해주는 형태다. 0~1 범위로 두면 되는데, 물의 opacity는 보통 꽤 낮게 잡아야 해서 일단 0.01 같은 값으로 시작해도 된다.

opacity의 주요 역할은 WaterVisibility = 1 - opacity를 제공하는 것이다. 즉 opacity가 더 ‘검을수록’(값이 낮을수록) 물 아래가 더 잘 보인다.

다만 _SurfaceOverlayAlpha가 보통 크게 설정되지 않기 때문에, 깊이로 인한 영향이 최종 셰이딩에서 엄청 드라마틱하게 보이진 않는다. 전체적으로 수중 가시성이 높은 편이고, 어떤 특수한 액체(예: 탁한 물)라면 이걸 높여서 수중이 잘 안 보이게 만들고 싶을 수도 있다.

Flux coastline 기본 설정

산란 계수(Scattering Coefficient)

산란 계수는 “산란 색(scattering color)”이라고 보면 된다. 즉 빛이 물속을 통과하면서 물리 산란을 겪은 뒤, 최종적으로 어떤 색으로 ‘물들어’ 보이길 원하는가(거의 고유색에 가까움)를 결정한다.

Flux가 쓰는 트릭은, 이 산란 색을 “해안과의 거리 + 깊이”에 따라 변하게 만드는 것이다.

먼저 해안 거리 성격을 가진 shoreline 파라미터 계산부터 보자.

// Shorelinefloat shoreline = GetShoreline(sdf_distance, (screenDepthView + i.posVSnoOffset.z) * _BehindWaterDepthStrength);
shoreline = 1 - shoreline;

float GetShoreline(float distance, float depthDiff)
{
    float shorelineDistance = saturate(distance/_CoastlineScattringDistance);
    float shorelineDepth = saturate(depthDiff/_CoastlineScattringHeight);

    return max(shorelineDistance, shorelineDepth);
}

여기서 screenDepthView + i.posVSnoOffset.z는 “버텍스 오프셋이 없다고 가정했을 때”의 view depth 차이(수면과 수중 물체의 거리차)다.

결국 SDF distance의 영향과 view depth의 영향을 max로 합쳐서(=범위 합집합) shoreline 범위를 만든다.

_CoastlineScattringDistance와 _CoastlineScattringHeight가 각각 거리/깊이 쪽의 범위 크기를 제어하는 파라미터다.

shoreline

위 그림은 참고용이다. 해안선 위치나 파라미터에 따라 형태가 꽤 달라지니까, “대충 SDF 거리 + 깊이에 묶인 값이구나” 정도만 잡으면 된다.

이제 산란 계수(산란 색) 계산식은 아래처럼 간다.

float3 scatteringColor = lerp( _SurfaceScattering0.xyz * 0.02 * _ScatteredLuminanceStrength, _SurfaceScatteringShoreline.xyz * 0.01 * _ScatteredLuminanceStrength, saturate(shoreline * shoreline));
float3 foamScattering = scatteringColor * _FoamScatteringScale;
float3 scattering = scatteringColor + foamScattering * waveFoam * min((shoreline+1/depthDiff) * 5, 1);
scattering *= _ScatteringScale;
scattering = -log(scattering);

핵심은 앞에서 계산한 shoreline로 “깊은 바다 쪽 산란 색”과 “해안 쪽 산란 색”을 lerp로 섞는 것이다.

그리고 foam 영역은 추가로 색을 더 얹는다.

마지막의 -log 처리는 내가 추가한 것이다. UE 셰이더 원본에서 동일한 처리를 직접 찾진 못했지만, Flux의 최종 느낌을 맞추려면 이 처리가 들어가야 더 그럴듯하다고 느꼈다. 왜 넣는지는 뒤에서 다시 언급하겠다.

여기서는 Flux의 기본값으로 쓰는 참조 산란 색(SurfaceScattering0, SurfaceScatteringShoreline)을 먼저 그대로 쓰면 된다.

Flux 산란 색

Flux 데모에서 산란 색을 조절하면 이런 결과도 낼 수 있다.

예를 들어 SurfaceScatteringShoreline을 분홍색으로 바꾸면, 해안 근처 수중 색도 같이 분홍 쪽으로 변한다. 이 파라미터가 무슨 일을 하는지 대충 감이 올 거다.

흡수 계수(Absorption Coefficient)

// Absorption
float3 absorptionColor = lerp(10 * _AbsorptionLuminanceStrength  / (_SurfaceAbsorption0.xyz * _SurfaceAbsorption0.w), 10 * _AbsorptionLuminanceStrength/ (_SurfaceAbsorptionShoreline.xyz * _SurfaceAbsorptionShoreline.w) , shoreline);

이것도 마찬가지로, shoreline을 기준으로 “깊은 바다 쪽 흡수 계수”와 “해안 쪽 흡수 계수”를 lerp로 섞는 구조다.

파라미터는 Flux 기본값을 그대로 따라가면 된다.

Flux 흡수 계수

PhaseG

// PhaseGfloat phaseG = lerp(_PhaseGDeepSunLow, _PhaseGDeepSunHigh, saturate(mainLightDir.y));
phaseG = clamp(phaseG, -1, 1);

태양광의 입사 각도에 따라 변하는 계수다. 원리는 대기 산란 쪽에서 많이 나오는데, “태양광 특유의 산란 방향 분포(형태)”를 잡아주는 역할이라고 보면 된다. 여기서는 Flux 로직 그대로 쓰면 된다.

기본값도 Flux의 디폴트를 그대로 사용하자.

_PhaseGDeepSunHigh ("PhaseGDeepSunHigh", Float) = 0.4
_PhaseGDeepSunLow ("PhaseGDeepSunLow", FLoat) = 0.6

이 계수는 보통 크게 건드릴 일이 없다. 그냥 ‘디테일 보강’ 정도로만 생각하면 된다.

수중 색조(water behind)

// waterBehindfloat3 waterBehind = float3(0.7,0.6,0.5) * 0.9;
waterBehind = lerp(1, max(0, waterBehind), saturate(depthDistance * 0.02f));

이 파라미터는 “수중에서 보이는 화면의 밝기/색감”을 결정한다.

여기서 depthDistance는 view depth 차이니까, 말 그대로 “수중 물체가 수면에서 멀수록(깊을수록) 더 잘 안 보이는 게 자연스럽다”는 걸 반영한다.

이것도 그냥 그대로 따라 쓰면 되고, 실제로는 이 부분만 따로 파라미터 하나로 빼도 충분하다. 본질은 수중 화면에 밝기 보정을 거는 거다.

예를 들어 Flux 데모에서 이 값을 크게 잡으면 아래처럼 변한다.


이제 본격적으로 “수중 셰이딩” 파트로 들어가 보자. 중간중간 위에서 구해 둔 전처리 파라미터들을 계속 쓰게 될 거다.

single layer water의 수중 셰이딩은 크게 세 덩어리로 나눌 수 있다.

  1. 수중 굴절(Refraction)용 UV 왜곡(distortion) 계산
  2. 수중에서 쓰일 3개의 ‘광원 강도’(luminance) 구하기
    1. 굴절된 화면(scene color)을 광원처럼 취급한 강도
    2. 태양광(주 광원) 강도
    3. 환경광(간접광) 강도
  3. 위 3개 광원에 대해 “수중 산란/흡수” 감쇠를 적용
    1. 광원 종류별로 감쇠 방식이 조금씩 다름

수중 굴절 왜곡 UV

이 단계도 물 렌더링에서 정말 클래식한 파트다.

흔히는 노말 기반으로 왜곡을 주거나, 노이즈 기반으로 왜곡을 주는 방식이 많다.

여기서는 single layer water 방식대로, ‘시차(parallax)’에 가까운 형태로 왜곡을 주는데, 좀 더 그럴듯한(물리적으로 납득 가능한) 왜곡을 만들 수 있다.

float2 GetViewportUVDistortion(float3 normalWS, float3 normalVertex, float refraction)
{
    float3 viewNormal = TransformWorldToView(normalWS);
    float3 viewVertexNormal = TransformWorldToView(normalVertex);

    float2 distortion = (viewVertexNormal.xz - viewNormal.xz) * (refraction-1);
    return distortion;
}

// DistortionUVfloat2 UVDistortion = GetViewportUVDistortion(normalWS, i.normalWS, refraction);
UVDistortion *= saturate((depthDistance) * 1.0 / 70.0f);
float2 DistortedUV = i.screenPos.xy / i.screenPos.w + UVDistortion * _WaterRefractionUVStrength;

이 UV로 그대로 sceneColor를 샘플링해서, 일단 어떤 그림이 나오는지 보자.

float3 sceneColor = SAMPLE_TEXTURE2D(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, DistortedUV).rgb;
finalColor = float4(sceneColor * _TestColor,1);

관찰하기 쉽도록 색 필터를 하나 얹었다.

파란 박스의 ‘수면 위’ 부분을 보면, 수면 위까지 같이 왜곡돼 버리는 문제가 생긴다.

이건 당연한 얘기다. 우리는 지금 수면 픽셀/수중 픽셀을 가리지 않고, 화면 UV 전체에 왜곡을 걸어버렸으니까.

그래서 이 왜곡은 반드시 “수중 영역에서만” 적용되도록 마스킹이 필요하다.

Flux(UE) 쪽도 같은 발상으로 처리하는데, 핵심은 아주 단순하다:

  • 수면과 수중 물체의 depth 차이(=depthDistance)가 0이면 수면 위이므로 왜곡을 0으로 만든다.
  • depthDistance가 커질수록(=수중으로 갈수록) 왜곡이 점점 살아나게 만든다.

그래서 앞에서 이미 했던 이 스케일링이 사실상 그 마스킹이다.

UVDistortion *= saturate(depthDistance / 70.0f);

이제 이 값을 파라미터로 열어두고, 프로젝트 스케일에 맞춰 튜닝하면 된다.

이건 당연한 얘기다. 우리는 지금 수면 픽셀/수중 픽셀을 가리지 않고, 화면 UV 전체에 왜곡을 걸어버렸으니까.

그래서 이 왜곡은 반드시 “수중 영역에서만” 적용되도록 마스킹이 필요하다.

Flux(UE) 쪽도 같은 발상으로 처리하는데, 핵심은 아주 단순하다:

  • 수면과 수중 물체의 depth 차이(=depthDistance)가 0이면 수면 위이므로 왜곡을 0으로 만든다.
  • depthDistance가 커질수록(=수중으로 갈수록) 왜곡이 점점 살아나게 만든다.

그래서 앞에서 이미 했던 이 스케일링이 사실상 그 마스킹이다.

UVDistortion *= saturate(depthDistance / 70.0f);

이제 이 값을 파라미터로 열어두고, 프로젝트 스케일에 맞춰 튜닝하면 된다.

다.

이건 당연한 얘기다. 우리는 지금 수면 픽셀/수중 픽셀을 가리지 않고, 화면 UV 전체에 왜곡을 걸어버렸으니까.

그래서 이 왜곡은 반드시 “수중 영역에서만” 적용되도록 마스킹이 필요하다.

Flux(UE) 쪽도 같은 발상으로 처리하는데, 핵심은 아주 단순하다:

  • 수면과 수중 물체의 depth 차이(=depthDistance)가 0이면 수면 위이므로 왜곡을 0으로 만든다.
  • depthDistance가 커질수록(=수중으로 갈수록) 왜곡이 점점 살아나게 만든다.

그래서 앞에서 이미 했던 이 스케일링이 사실상 그 마스킹이다.

UVDistortion *= saturate(depthDistance / 70.0f);

이제 이 값을 파라미터로 열어두고, 프로젝트 스케일에 맞춰 튜닝하면 된다.

이건 당연한 얘기다. 우리는 지금 수면 픽셀/수중 픽셀을 가리지 않고, 화면 UV 전체에 왜곡을 걸어버렸으니까.

그래서 이 왜곡은 반드시 “수중 영역에서만” 적용되도록 마스킹이 필요하다.

Flux(UE) 쪽도 같은 발상으로 처리하는데, 핵심은 아주 단순하다:

  • 수면과 수중 물체의 depth 차이(=depthDistance)가 0이면 수면 위이므로 왜곡을 0으로 만든다.
  • depthDistance가 커질수록 수중으로 갈수록 왜곡이 점점 살아나게 만든다.

그래서 앞에서 이미 했던 이 스케일링이 사실상 그 마스킹이다.

UVDistortion *= saturate(depthDistance / 70.0f);

이제 이 값을 파라미터로 열어두고, 프로젝트 스케일에 맞춰 튜닝하면 된다.

이게 이 방법의 단점이다. 왜 그런지는 여기서는 더 파고들지 않겠다.

바로 해결책으로 가자.

float4 SceneDeviceZ4 = GATHER_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, DistortedUV);
float4 SceneDepth4 = float4(LinearEyeDepth(SceneDeviceZ4.x, _ZBufferParams), LinearEyeDepth(SceneDeviceZ4.y, _ZBufferParams), LinearEyeDepth(SceneDeviceZ4.z, _ZBufferParams), LinearEyeDepth(SceneDeviceZ4.w, _ZBufferParams));

if(any(SceneDepth4 < viewDepth))
{
 DistortedUV = i.screenPos.xy / i.screenPos.w;
}

GATHER_TEXTURE2D를 쓰면 주변 4개 픽셀의 depth를 한 번에 가져올 수 있다. 그리고 그 depth로 “지금 렌더링하는 위치가 수면 위쪽(또는 수면 위 물체 근처)이냐”를 판정한다.

주변 픽셀 중 하나라도 depth가 수면 depth(viewDepth)보다 작다면, 그건 수면 위쪽 물체가 걸쳐 있다는 뜻이니까(=물 밖 영역이 섞여 있음) 왜곡을 적용하지 않고 원래 UV로 되돌린다.

위 코드를 추가하면, 수면 위 오염이 제거된 ‘정상적인 시차 왜곡’ 결과를 얻을 수 있다.


수중 3개 광원 강도 구하기

여기부터는 약간 지루한(?) 그래픽스 이론 시간이다. 수중 셰이딩이라는 건 결국 “빛이 굴절해서 물속으로 들어간 뒤, 그 다음에 무슨 일이 벌어지냐”를 모델링하는 거고, 결론만 말하면 산란이다.

근데 산란을 계산하기 전에, 애초에 어떤 빛이 물속으로 들어오냐부터 생각해야 한다.

  • 첫 번째는 주 광원, 즉 태양광. 이건 당연히 물속으로 들어온다.
  • 두 번째는 환경광(간접광). 이것도 수면으로 들어온다고 보는 게 자연스럽다.

그래서 우선 이 두 광원의 “수중에서의 강도”를 계산해 두자.

광원 강도를 구한다는 건, 당연히 몇 가지 추가 처리가 들어간다는 뜻이다.

// 태양광
float3 ExtinctionCoeff = scattering + absorptionColor;
float IorFrom = 1.0f;
float relativeIOR = IorFrom / iorWater;
float3 UnderWaterRayDir = 0;
float DirLightPhaseValue = 0.0f;
if (WaterRefract(viewDir, normalWS, relativeIOR, UnderWaterRayDir))
{
	DirLightPhaseValue = SchlickPhase(phaseG, dot(-mainLightDir, UnderWaterRayDir));
}

float SchlickPhase(float G, float CosTheta)
{
	const float K = 1.55f * G - 0.55f * G * G * G;
	return SchlickPhaseFromK(K, CosTheta);
}

float SchlickPhaseFromK(float K, float CosTheta)
{
	const float SchlickPhaseFactor = 1.0f + K * CosTheta;
	const float PhaseValue = (1.0f - K * K) / (4.0f * PI * SchlickPhaseFactor * SchlickPhaseFactor);
	return PhaseValue;
}

float3 SunScattLuminance = DirLightPhaseValue * mainLightColor * mainLightIntensity;


먼저 태양광 강도를 보면,

SunScattLuminance = DirLightPhaseValue * mainLightColor * mainLightIntensity;

보면 알 수 있듯이, 일반적인 색상 강도 위에 추가로 DirLightPhaseValue가 곱해진다.

이것이 바로 phaseG가 태양광에 작용하는 효과로, 태양광이 서로 다른 각도로 수면에 입사할 때 각각 다른 산란 효과가 발생한다.

Schlick Phase 함수란,

  • 위상 함수(Phase Function)는
  • 빛이 참여 매질(공기, 물, 연기 등) 속에서 산란될 때의 방향 분포
  • 빛이 각도별로 산란될 확률
  • 비등방성 산란(전방 산란 또는 후방 산란 등)

을 나타낸다.

우리가 새로 계산한 광원 강도는, 광원이 수중으로 들어간 이후의 강도 변화를 계산한 것으로, 산란 감쇠와는 다소 차이가 있다.

다음은 환경광을 살펴보자.

// 환경광float IsotropicPhase()
{
    return 1.0f / (4.0f * PI);
}

float3 AmbScattLuminance = IsotropicPhase() * bakedGI;

여기서 bakedGI는 Unity에서 가져오는 환경광(간접광) 부분이다. 환경광이 수중 산란 처리에 들어가는 방식이 태양광(주광원)과는 다르다는 것을 확인할 수 있다. 너무 깊게 생각할 필요는 없고, ‘태양광과 같은 목적(빛 기여)을 다른 방식으로 처리한다’ 정도로만 이해하면 된다.

환경광은 여기서 SH를 그대로 호출하면 된다. 즉,

float3 bakedGI = GetEnvSH(i.vertexSH, i.normalWS);

수중 파트는 본질적으로 디퓨즈(난반사)를 대체하는 것이므로, 디퓨즈용 환경광—즉 SH—를 동일하게 사용한다.

이제 강도(인텐시티) 변화를 추가했을 때 두 광원(태양광/환경광)의 차이를 살펴보자.

주 광원 정상

주 광원 강도 변화

환경광

환경광 강도 변화


세 번째 광원은 무엇일까?

우리가 물속 화면을 볼 수 있는 이유를 간단히 생각해 보면, 물속 화면에서 반사된 빛이 다시 우리 눈으로 들어오기 때문이다.

그래서 이 부분도 하나의 광원으로 근사해서 볼 수 있고, 그 광원의 강도는 물의 깊이와 관련된다.

당연히 얕은 곳일수록 더 선명하게 보이므로 강도도 더 강하다고 볼 수 있다.

따라서 구현 코드는 아래와 같다.

float3 BehindWaterSceneWorldPos = ComputeWorldSpacePosition(i.screenPos.xy / i.screenPos.w, screenDepth, UNITY_MATRIX_I_VP);
float DistanceFromScenePixelToWaterTop = max(0.0, i.posWS.y - BehindWaterSceneWorldPos.y);
float3 MeanTransmittanceToLightSources = exp(-DistanceFromScenePixelToWaterTop * ExtinctionCoeff / _DistancePixelToWaterTopStrength);
float3 BehindWaterSceneLuminance = SAMPLE_TEXTURE2D(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, DistortedUV).rgb;
BehindWaterSceneLuminance = MeanTransmittanceToLightSources * BehindWaterSceneLuminance;

이때 첫 번째 산란 감쇠 공식이 등장한다. 즉, exp(-DistanceFromScenePixelToWaterTop * ExtinctionCoeff / _DistancePixelToWaterTopStrength)이다.

이는 단순화된 산란 감쇠이며, 산란 감쇠는 exp라는 기본 함수의 형태를 따른다는 것만 알면 된다.

여기 들어가는 계수는 깊이와 앞에서 계산한 감쇠 계수(산란 색 + 흡수 색)로 구성된다.)

float3 ExtinctionCoeff = scattering + absorptionColor;

의미는, 거리가 멀어질수록 산란 감쇠도 지수적으로 증가하며, 그 감쇠는 산란 색과 흡수 색의 영향을 동시에 받는다는 것이다.

산란 감쇠의 원리를 명확하게 설명하는 일은 결코 쉽지 않지만, 그래도 추상적인 관점에서 먼저 이해를 시도해볼 수 있다.

체적 렌더링 수학 원리 - Zhihu

빛이 어떤 물체를 많이 관통할 수 있을 때, 일반적인 반사 외에도 빛이 물체 내부로 들어가면서 새로운 광학 현상이 나타난다. 이 현상은 비교적 복잡하지만, 크게 두 가지로 나눠서 생각할 수 있다.

산란과 흡수

산란은 원래의 빛이 진행 방향에서 벗어나도록 만들어 에너지 감쇠를 일으키고, 흡수는 빛의 에너지가 다른 형태로 전환되면서 또 한 번의 에너지 감쇠를 일으킨다.

우리가 물속 화면을 볼 수 있는 과정에서, 빛의 경로는 대략 다음과 같다.

태양광 등 외부 광원이 표면에서 굴절되어 수면 아래로 들어감 → 물속에서 산란과 흡수를 거쳐 수중 바닥에 도달 → 다시 반사가 발생함(이때 수중 바닥은 더 이상 굴절하지 못한다고 가정) → 물속에서 위로 올라와 수면 밖으로 다시 빠져나감. 이 과정에서도 산란과 흡수로 인한 감쇠가 다시 한 번 적용된다.

우리가 계산하는 세 번째 광원은, 빛이 수중 바닥에 도달할 때까지 겪는 산란 감쇠의 정도이며, 동시에 수중 바닥 화면 부분을 하나의 새로운 광원으로 간주해 처리하는 것이다.

여기까지가 3개 광원 강도(유효 luminance) 계산 파트의 끝이다.

물론 여전히 많은 문제가 남아 있다. 예를 들어 왜 수중 바닥 화면을 새로운 광원으로 취급해야 하는지, 외부 광원의 에너지에서 분리해 생각해야 하는 것이 분명하다.

이 부분은 다음 절에서 답을 제시하겠다.

여기서는 수중 바닥에 도달한 빛의 일부를 sceneColor 색으로 근사했다고 이해하면 된다.

수중 sceneColor

산란 감쇠가 적용된 수중 sceneColor


마지막 셰이딩 단계에 들어오면, 세 가지 광원은 모두 한 번씩 산란 감쇠를 거치게 되는데, 이 과정은 세 광원이 물속을 지나 수면 밖으로 빠져나오는 과정을 시뮬레이션한다.

// 수중 통합 산란/흡수 감쇠                
float3 OpticalDepth = ExtinctionCoeff * depthDistance / _VolumeDepthStrength;
float3 Transmittance = exp(-OpticalDepth); // 핵심 감쇠 함수. 물리적 의미: 일정 거리를 통과한 뒤 남는 에너지
float3 IncomingLuminance = (AmbScattLuminance + SunScattLuminance );
float3 SafeScatteringAmount = saturate(scattering * (1.0f - Transmittance) / max(ExtinctionCoeff, 1e-5)); // 태양광/환경광 감쇠
float3 ScatteredLuminance = IncomingLuminance * SafeScatteringAmount; // 태양광/환경광 산란 에너지 계산 결과
ScatteredLuminance *= (1 - envBRDF); // 반사 에너지 분배 적용

BehindWaterSceneLuminance = Transmittance * BehindWaterSceneLuminance * waterBehind ; // 수중 화면 광원: 산란 감쇠 + 수중 색조 영향 적용

float3 waterVolumeColor = WaterVisibility * (ScatteredLuminance + BehindWaterSceneLuminance);
color += waterVolumeColor;

산란 감쇠 방식은 앞에서 설명한 것과 같고, 유일한 차이는 깊이를 다루는 방식이 다르다는 점이다. 또한 서로 다른 깊이 제어 파라미터를 사용한다. 깊이 값을 바꾸면 산란 감쇠 효과가 눈에 띄게 달라진다.

이제 세 가지 광원이 어떻게 수면 밖으로 나와 우리가 보게 되는지를 생각해 보자.

수중의 빛을 아주 추상적으로 두 종류로 나눌 수 있다. 하나는 직선으로 전파되는 빛이고, 즉 수중 sceneColor라는 광원이다.

다른 하나는 직선을 유지할 수 없는 빛으로, 태양광과 환경광이다.

여기서 “태양광과 환경광에도 직선으로 전파되는 성분이 없나?”라고 할 수 있는데, 있다. 앞에서 수면 아래 바닥에 도달한 빛의 일부를 sceneColor 색으로 근사했는데, 실제로 그 부분이 바로 태양광과 환경광의 직선 전파 성분이다.

직선을 유지할 수 없는 성분이 우리가 보이려면, 결국 산란을 통해 밖으로 산란되어 나와야 하고, 동시에 흡수의 영향도 제거해야 한다. 그래서 이 두 광원의 감쇠 과정을 얻을 수 있다.

float3 SafeScatteringAmount = saturate(scattering * (1.0f - Transmittance) / max(ExtinctionCoeff, 1e-5)); // 太阳光 환경광的衰减
float3 ScatteredLuminance = IncomingLuminance * SafeScatteringAmount;

정리하면, **산란 / (산란 + 흡수) × (1 - 산란 감쇠 이후 남은 에너지)**이다. 즉, 산란과 흡수로 소모된 에너지의 비율을 의미한다.

또한 수중으로 들어가는 광원은 태양광환경광이므로, 이 두 광원은 **(1 - envBRDF)**로 한 번 더 에너지 분배를 해줘야 한다.

아래에 envBRDF를 구하는 방법을 붙여둔다. 이것도 UE 셰이더에서 그대로 가져온 것이다.

float3 envBRDF = EnvBRDF(F0, roughness, brdfContext.NoV);

float3 EnvBRDF( float3 SpecularColor, float Roughness, float NoV )
{
    float2 AB = SAMPLE_TEXTURE2D( _PreIntegratedGFTexture, sampler_LinearClamp, float2( NoV, Roughness )).rg;
    float3 GF = SpecularColor * AB.x + saturate( 50.0 * SpecularColor.g ) * AB.y;
    return GF;
}

다시 우리의 세 번째 광원, 즉 수중 sceneColor 부분을 살펴보자. 이 빛은 물밑에서 수면으로 직선으로 올라오며, 이때 전체 산란 감쇠를 거친다. 외부 광원이 아니기 때문에 1-envBRDF 항의 영향을 받을 필요가 없다. 여기서 waterBehind는 수중 색조, 즉 증강 계수다.

BehindWaterSceneLuminance = Transmittance * BehindWaterSceneLuminance * waterBehind ;

두 부분을 더한 뒤 waterVisibility의 영향을 적용해 최종 수중 셰이딩 결과를 얻는다.

조절할 수 있는 파라미터가 정말 많다. 산란 색, 흡수 색, 깊이 강도 등등. 이 ‘순수하게 파라미터 만지면서 쾌감 느끼는 시간’은 뒤에서 계속하기로 하고, 아직 글이 끝난 건 아니다. 그림에서 거품(foam) 영역의 색이 유독 튀는 이유는, 앞에서 거품 영역에 산란 색을 추가로 더해 줬기 때문이다. 일종의 Flux 작은 트릭이라고 보면 된다.


수중 셰이딩을 마무리하기 전에, 앞에서 언급하지 못했던 지식을 몇 가지 보충해 보자.

  1. 에너지 분배(보존)는 어떻게 되는가?

외부 광원이 수면에 들어오면, 직진하는 광선과 직진하지 않는(산란되는) 광선으로 나뉜다. 이때 직진 광선은 수면을 통과해 수중 바닥까지 도달하고, 그 결과로 ‘세 번째 광원’이 생긴다고 볼 수 있다.

sceneColor는 외부 광원이 정상적으로 들어가서 만들어진 에너지 값이라고 가정해 보자. 이를 수중으로 옮겨오면, 바닥에서의 산란 감쇠를 거쳐야 한다.

이 논리는 타당하지만, 빠진 부분이 하나 있다. 바로 직진 광선과 비직진(산란) 광선 사이의 에너지 분배가 명시적으로 들어가 있지 않다는 점이다.

세 번째 파트의 산란 감쇠에서, 비직진 광선 성분에 대해 (1 - 산란 감쇠)를 에너지 비중으로 사용하고, 수중 sceneColor가 수면 밖으로 나갈 때도 산란 감쇠를 거치게 하면, 이 과정이 직진 광선과 비직진 광선의 에너지 분배와 어느 정도 맞아떨어진다고 볼 수도 있다. 강박적인 마음을 잠시 달래는 ‘진통제’ 정도로는 쓸 수 있다.

  1. 그럼 왜 산란 색 계수에 -log 연산이 들어가 있을까?

산란 색 계수가 광원에 적용될 때 exp 처리를 거친다는 걸 알고 있다면, 한 번 생각해 보자.

우리가 빨간색을 준다고 했을 때, 산란 감쇠를 거친 뒤에도 결과가 여전히 ‘빨간색’일까? Flux 데모에서는 “산란 색으로 무엇을 지정하든, 최종 결과도 그 산란 색처럼 보이게” 설계돼 있다.

그래서 내 -log 처리는, 산란 감쇠 이후에 남는 색이 우리가 지정한 산란 색 계수와 일치하도록 맞추기 위한 것이다.


환경광 하이라이트와 거울 반사

셰이딩의 마지막 큰 블록은 사실 물 렌더링에서 꽤 기본적인 부분이기도 하다. 내가 예전에 쓴 물 렌더링 글에서도 똑같이 들어가 있던 파트다.

바로 코드부터 가자.

// 세 번째 블록: 환경광 + SSR 하이라이트 반사 파트float mip_level = ComputeReflectionCaptureMipFromRoughness(roughness, 4);
float3 R = reflect(-viewDir, normalWS);
float3 reflectNormalWS = lerp( i.normalWS, normalWS, step(0, R.y));
R = reflect(-viewDir, reflectNormalWS);

float3 skyLight = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0,samplerunity_SpecCube0,R,mip_level);
float3 SSRColor = SAMPLE_TEXTURE2D(_SSRTexture, sampler_LinearClamp, DistortedUV);
float3 envSSRColor = lerp(skyLight, SSRColor, _EnvSSRBlend) * envBRDF * saturate(depthDistance * _EnvSSRDepthScale * 0.02);

IBL 하이라이트 + 반사 RT의 결합

IBL 하이라이트는 클래식하게 러프니스 기반 환경맵 mip 샘플링 + 프리인티그레이티드 BRDF 조합이다.

반사 RT를 얻는 방법은 내가 예전에 쓴 물 렌더링 글을 참고하면 된다. 대칭 행렬 기반 카메라 캡처 방식인데, 요약하면 성능은 일단 무시하고 밀어붙이는 폭력적인 방법이다.

반사 RT도 IBL 하이라이트의 광원 성분으로 끼워 넣어 계산하고, 블렌드 가중치 파라미터 하나만 추가하면 된다.

덤으로 이 하이라이트 파트도 깊이의 영향을 받아야 한다. 얕은 물 영역에서는 굴절이 주가 되고, 물 셰이딩에서 깊이 차이는 진짜 처음부터 끝까지 관통하는 키워드다.

최종 결과


SSR 부분과 앞의 두 부분의 융합

여기서 RenderDoc으로 잡아본 블렌딩 방식도 한 번 보자.

내가 직접 해보니 반사가 너무 세더라. 그래서 결국 폭력적인 ‘주관적 블렌딩’으로 바꿨다.

color = envSSRColor * _EnvSSRStrength + color * _EnvRefractionStrength;

그냥 add만 하면 안 된다. 어쨌든 이거 하나만 기억해라. 안 그러면 밝기가 쉽게 터진다.

이를 예로 들면, 이제 전체 셰이딩 과정을 모두 이해했을 것이다. 세 가지 셰이딩 블록이라는 큰 논리를 유지한 채로, 자기만의 트릭과 각종 파라미터를 마음껏 추가한 뒤에는 순수하게 색을 입히며 즐기는 시간이다.

원하는 화면 효과가 나올 때까지 조정해 보고, 마음에 드는 파라미터 값은 꼭 잘 저장해 두자.


디테일 추가

드디어 마지막 단계까지 왔다. 디테일 파트에서는 두 가지를 완성해야 한다.

  • 디테일 노말(법선) 텍스처 추가
  • 거품(foam) 효과 추가

디테일 노말(법선) 텍스처

방법은 여전히 직관적이다. 디테일 노말 맵을 샘플링해서, 기존 노말에 섞어주면 된다.

먼저 노말 맵이 어떻게 생겼는지 보자.

음, 색을 보면 대충 감이 오지? RG 2채널 노말이다.

특별히 설명할 건 없다. 정상적으로 샘플링해서 노말을 복원하면 끝이다.

문제는 이 디테일 방향이 ‘흘러야’ 한다는 점이다. 효과 비교를 보면 바로 알 거다.

디테일 방향 고정
디테일 방향 운동

텍스처가 움직이면 피할 수 없는 주제가 하나 있다. 반복감을 어떻게 지우냐는 거다.

한 방향으로 계속 오프셋 샘플링하면, 고정된 이동이 너무 티가 난다.

그래서 Flux는 클래식한 방법을 쓴다. 한 번 샘플링이 반복되면, 여러 번 샘플링해서 섞어버리면 되지.

// Detail Wave
float3 detailWaveWeight;
float2 uv1, uv2, uv3;
AdvectUV3_DetailWave(i.posWSnoOffset.xz, detailWaveWeight, uv1, uv2, uv3);

float3 detailWaveColor1 = SAMPLE_TEXTURE2D(_DetailWaveTexture, sampler_LinearRepeat, uv1);
float3 detailWaveColor2 = SAMPLE_TEXTURE2D(_DetailWaveTexture, sampler_LinearRepeat, uv2);
float3 detailWaveColor3 = SAMPLE_TEXTURE2D(_DetailWaveTexture, sampler_LinearRepeat, uv3);

float2 derivate_1 = CalculateDerivate(detailWaveColor1);
float2 derivate_2 = CalculateDerivate(detailWaveColor2);
float2 derivate_3 = CalculateDerivate(detailWaveColor3);
float2 detailDerivate = derivate_1 * detailWaveWeight.x + derivate_2 * detailWaveWeight.y + derivate_3 * detailWaveWeight.z;

float3 derivateNormal = DerivateConvertNormal(i.normalWave, detailDerivate);
float3 normalMapVar = lerp(i.normalWave, derivateNormal, _IsNeedDetailWave);

세 번 샘플링한 뒤, 가중치로 섞는다. 이 과정은 꽤 탄탄한 수학 이론이 받쳐준다. 자세한 건 아래의 flow map 클래식 글을 참고하자.

Texture Distortioncatlikecoding.com/unity/tutorials/flow/texture-distortion/

Flux의 원리는 아마 이것을 기반으로 한 것 같지만, 블루프린트를 번역해 보니 반복되는 느낌을 충분히 줄이지 못해서, 이 원리를 바탕으로 직접 간단한 가중치 분배를 다시 만들었다.

오프셋된 주기 함수가 더해졌을 때 합이 일정한 값이 되도록 보장하면 되고, 최종적으로 아래와 같은 함수를 얻는다.

void AdvectUV3_DetailWave(float2 uv , inout float3 weight, inout float2 uv1, inout float2 uv2, inout float2 uv3)
{
    float3 outOffset = _Time.y * _DetailWaveTimeSpeed + float3(0,1,2)/3;
    float3 fracTime = frac(outOffset);
    weight = (cos(2*PI*fracTime) * 0.5 + 0.5 )* 2/3;

    uv1 = uv / _DetailWaveTextureUVScale + outOffset.x * _DetailWaveTextureOffsetStrength * float2(1,1);
    uv2 = uv / _DetailWaveTextureUVScale + outOffset.y * _DetailWaveTextureOffsetStrength * float2(0.5,1);
    uv3 = uv / _DetailWaveTextureUVScale + outOffset.z * _DetailWaveTextureOffsetStrength * float2(1,0.5);
}

float2 CalculateDerivate(float3 color)
{
    float2 normalXY = (color.xy - 0.5) * 2 * _DetailWaveNormalScale;
    float normalZ = sqrt(1 - normalXY.x * normalXY.x - normalXY.y * normalXY.y);
    float2 derivate = normalXY / normalZ / _DetailWaveDerivateScale;

    return derivate;
}

오프셋 속도를 꽤 빠르게 해도 frac 때문에 생기는 반복감이 그렇게 강하지 않다. 이 방법은 나름 괜찮다고 본다.

참고로 Flux 쪽 처리도 같이 붙여 둔다. 위치는 MF_AdvectionData 함수 노드다.


거품 구현

Flux의 거품은 실제로 꽤 복잡하고, 디테일한 처리도 많다. 하지만 아쉽게도 글쓴이는 거품을 더 깊게 연구해 완전히 정리하지는 못했다.

그래서 여기서는 핵심 로직 하나만 분해해 소개하며, 많은 디테일은 연구하거나 재현하지 못했다.

먼저 거품에는 4채널 거품 텍스처 한 장이 쓰인다. 이건 꽤 특이하다. 보통 거품은 단일 채널 마스크 한 장이면 충분하거든.

여기서 RG 채널은 노말, B 채널은 Flux에서 soft라고 부르는 값, A 채널은 일반적인 거품 마스크다.

Flux의 구현 방식에서는, 반복 문제를 해결하기 위해 여전히 3회 샘플링을 사용하지만, 동시에 4개 채널을 서로 다르게 처리한다.

그리고 계산된 거품(foam) 영역에서는 baseColor, opacity, roughness, specular 등을 변경한다.

하지만 나는 그렇게 하지 않았다.

나는 3회 샘플링 방식은 그대로 유지했고, B 채널과 알파 채널 처리만 일부 남겼으며, 나머지 부분은 내 구현에 포함하지 않았다.

// Detail Wave BaseColor
float3 waveFoamWeight;
float2 uv1Foam, uv2Foam, uv3Foam;
AdvectUV3_WaveFoam(i.posWSnoOffset.xz, waveFoamWeight, uv1Foam, uv2Foam, uv3Foam);
float4 waveFoamColor1 = SAMPLE_TEXTURE2D(_WaveFoamTexture, sampler_LinearRepeat, uv1Foam);
float4 waveFoamColor2 = SAMPLE_TEXTURE2D(_WaveFoamTexture, sampler_LinearRepeat, uv2Foam);
float4 waveFoamColor3 = SAMPLE_TEXTURE2D(_WaveFoamTexture, sampler_LinearRepeat, uv3Foam);
float4 waveFoamColor = waveFoamColor1 * waveFoamWeight.x + waveFoamColor2 * waveFoamWeight.y + waveFoamColor3 * waveFoamWeight.z;
float waveFoamSoft = waveFoamColor.b;
float waveFoamHeight = waveFoamColor.a;

float foamHeightAffect = waveFoamHeight * saturate( + waveFoam) * 1.6;
float foamSoftAffect = waveFoamSoft * saturate( + waveFoam);

float foamOpacity = saturate(max(saturate(foamHeightAffect), saturate(foamSoftAffect)));
foamOpacity = saturate(pow(foamOpacity, _WaveFoamPow) * _WaveFoamInstensity );
float3 foamBaseColor = _WaveFoamColor;

float3 baseColor;
float newOpacity;
FluidFoamCombineTraslucent(foamBaseColor, foamOpacity, _BaseColor, opacity, baseColor, newOpacity);
float4 waveFoamShallow = SAMPLE_TEXTURE2D(_WaveFoamTexture, sampler_LinearRepeat, i.posWSnoOffset.xz /_WaveFoamTextureUVScale + normalWS.xz * 0.3 );
float foamShallow = FluidFoamShallow(waveFoamShallow.a, depthDistance);
float foamShallowMask = saturate((1-foamShallow) * _WaveFoamShallowIntensity);
                
color += lerp(0, foamBaseColor * foamOpacity * 0.5, _IsNeedWaveFoam); // 正常泡沫
color += lerp(0, pow(waveFoamShallow.a, _WaveFoamPow) * foamShallowMask * foamBaseColor, _IsNeedWaveFoam);

여기서 세 번 샘플링하는 로직은 디테일 노말 텍스처를 처리하는 로직과 동일하고, 추가로 들어간 처리는 B 채널과 A 채널을 max로 한 번 더 합친 것이다.

대략 아래와 같은 효과가 나온다.

max로 합친 후

alpha 채널을 그대로 쓰면 이렇게 됩니다

Flux의 블루프린트에서는 B 채널에 몇 가지 파라미터 제어를 더해서 조정한 뒤에야 계속 사용할 수 있는 것처럼 보였는데, 당시에는 파라미터가 너무 많아서 그냥 전부 제거해 버렸다.

이 형태를 가장 앞단의 foam mask에 적용해서 foamOpacity를 얻으면, 이것이 foam의 최종 마스크가 된다. 그 다음은 거품을 직접 추가하든, 거품 영역에서 머티리얼 속성 파라미터를 수정하든 원하는 대로 하면 된다.

나는 foam을 최종 결과에 바로 더했고, 해안 쪽 foam은 따로 분리했다. 해안 foam이 너무 강하게 흐르는 느낌은 개인적으로 보기 좋지 않다고 느꼈다.

바다 거품
해안 거품


에필로그

어느새 또 한 편의 초장문을 써 버렸다.

사실 친구도 “너무 긴 글은 보기 힘들다”라고 말한 적이 있고, 나도 그 말이 맞다는 걸 안다.

뭐랄까, 내가 지금 하고 있는 직무 기술을 처음 접했을 때도 어떤 고수의 이런 장편 연재 글을 따라 읽는 것에서 시작했다. 영상 튜토리얼 같은 건 거의 없었고, 글을 보며 한 단계씩 따라 해 보면서 천천히 배웠다. 한 편을 익히는 데 시간이 오래 걸리긴 했지만, 이상하게도 나는 이런 방식이 좋았다.

그때 생각했다. 나도 나중에는 이런 글을 한 번쯤 써 보고 싶다고. 혹시라도 내 이런 장편 시리즈 글을 보고 누군가 “나도 해 볼까?”라는 마음이 생기고, 조금씩 따라 배우는 과정에서 이 기술을 좋아하게 된다면, 그보다 더 좋은 일이 있을까.

이제 물 렌더링은 아마 더 올리지 않을 것 같다. 다음에는 예전에 정리해 둔 카툰 렌더링 노트와 하늘+구름 노트를 조금 더 다듬어서, 시간 날 때 천천히 써서 올려 보겠다.

바다와 푸른 하늘을 만드는 건 확실히 손맛이 좋다. 그래도 애니메이션을 좋아하는 사람으로서, 카툰 렌더링을 다시 만지고 싶은 마음을 참을 수가 없다. 안 되겠다. 시간을 내서 카툰 렌더링도 새로운 것들을 좀 더 연구해 봐야겠다.

마지막으로, 늘 하던 대로 모두의 공부와 일이 순조롭길 바란다.

인생은 늘 예상치 못한 놀라움으로 가득하다. 예를 들면 9월 4일 그 ‘신비한 게임’이 드디어 공개됐다. 나는 정말 운이 좋다. 5년을 기다려서 드디어 X의 노래를 할 수 있게 됐다.

그리고 관례의 추천 코너. 내 스팀을 보니 최근 2주 동안 한 게임은 ‘검은 고리’ 하나뿐이라, 결국 추천은 클래식 애니 ‘사무라이 참프루’ 정도밖에 못 하겠다.

와타나베의 신작은 솔직히 말해 대단하다고 하긴 어렵고, 그냥 졸작이라고밖에 못 하겠다. 와타나베야 와타나베야, 대체 뭘 하고 있는 거냐.

‘카우보이 비밥’이 미래 SF의 분위기에서 오는 우주적이고 아득한 고독감을 줬다면, ‘사무라이 참프루’는 작품 내내 꽤 떠들썩하고, 사건의 원인도 더 현실적이고 사실적이다. 그런데도 이야기의 끝에서 세 사람이 헤어지고 각자 사람들 속으로 걸어 들어가는 그 순간이, 내게는 가장 깊은 고독의 충격으로 남았다. 그리고 엔딩곡 ‘사계(四季ノ唄)’는 지금도 여전히 선명하게 기억난다.

그러니까 와타나베는 도대체 뭘 하고 있는 거냐. 물론 시나리오를 쓰는 사람은 아니지만… 하, 됐다. 분노는 여기까지. 다들 다음에 또 보자.

원문

https://zhuanlan.zhihu.com/p/1942643591932510814?share_code=stXMl4GTwqTd&utm_psn=2021531218898891600