Skip to content

브라우저에서 오픈 월드 만들기, 26부: 물을 만드는 세 가지 방법

Oleg Sidorkin, Cinevva CTO 겸 공동창업자

처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 편을 링크해 둡니다.

25부에서는 아바타에게 옷을 입혔습니다. 이번 편은 물 이야기이고, spike가 세 개인 이유는 물이 바로 그런 표면이기 때문입니다. 값싼 지름길과 올바른 답이 스크린샷에서는 똑같아 보이지만 움직이면 완전히 달라지는 표면이죠. Spike 51은 스크린 스페이스 방식으로 반사를 만듭니다. 솔깃한 방식이지만 곧장 내장된 한계에 부딪칩니다. Spike 52는 출시되는 모든 게임이 실제로 쓰는 방법으로 바꿉니다. Spike 53은 완성된 물 라이브러리를 그대로 넣어서 "완성"이 지금 우리 위치에서 얼마나 떨어져 있는지 봅니다. 셋 다 굴절 레이어 하나를 공유하므로, 앞의 두 개 사이에서 변하는 유일한 변수는 반사를 어떻게 계산하느냐입니다.

이미 가지고 있는 화면에서 반사를 얻는다

새 탭에서 Spike 51 열기 ↗ · 소스 보기

스크린 스페이스 반사는 이미 렌더링한 프레임을 재활용합니다. 각 물 픽셀마다 시선 광선을 표면에서 반사시키고, 그 반사된 광선을 깊이 버퍼를 따라 진행시키다가, 광선이 기록된 어떤 표면 뒤로 지나가는 순간 물이 무엇을 반사하는지 찾아낸 것이고, 그 값을 컬러 버퍼에서 바로 샘플링합니다. 이 포팅은 three.js의 SSRNode를 한 줄 한 줄 그대로 따라갔고, 그쪽은 다시 lettier의 SSR 입문서를 따릅니다. 진행 과정은 스크린 스페이스에서의 DDA 워크입니다. 광선의 시작점과 끝점을 픽셀 좌표로 투영하고, 더 긴 축을 따라 한 픽셀당 한 번씩 탭하며 나아갑니다. 각 스텝에서 반사 광선의 깊이는 원근 보정 보간이 필요합니다. 11/z0+s(1/z11/z0) 형태인데, view-Z를 선형 보간하는 것은 그냥 틀린 방법이고 엉뚱한 위치에 히트를 만들어내기 때문입니다.

두 가지가 이 방식을 슬라이드쇼가 아니라 쓸 만하게 만듭니다. 거친 진행은 64스텝으로 제한합니다. 천 픽셀에 걸쳐 투영되는 긴 광선은 그러지 않으면 프래그먼트마다 수백 번씩 반복하게 되고, 백만 개 프래그먼트짜리 물 평면에 수백 번 반복에 텍스처 샘플 몇 번을 곱하면 그건 30fps짜리 장면이 됩니다. 품질 설정은 반복 횟수가 아니라 그 한도 안에서의 실효 보폭을 조절합니다. 그리고 제한된 거친 진행은 눈에 보이는 계단형 밴딩을 남기므로, 6회 반복의 이진 정제가 마지막 미스와 히트 사이의 구간을 이등분합니다. 이는 64배 서브 스텝 정확도이고, 이웃한 물 프래그먼트들이 같은 거친 히트 위치에 들러붙는 것을 멈출 만큼 충분합니다. 마지막으로 점과 직선 사이의 거리 검사가, 후보가 단지 같은 깊이에 있을 뿐인지 아니면 정말로 반사 광선 위에 있는지를 확인합니다. 두께 허용치는 해당 깊이에서 한 픽셀의 view 공간 폭에 맞춰 자동으로 스케일링되어 가까이서는 더 빡빡하고 멀리서는 더 느슨해집니다.

이 spike의 솔직한 부분은 그 자신의 주석에 적혀 있습니다. SSR은 메인 카메라가 한 번도 샘플링하지 않은 것을 반사할 수 없습니다. 나무의 아랫면, 화면 밖의 무엇이든, 가려진 무엇이든, 그 어느 것도 버퍼에 존재하지 않으므로 반사에 나타날 수 없습니다. 그것이 바로 진행 품질을 아무리 높여도 고칠 수 없는 "잘못된 쪽 정보 손실"이고, 다음 spike가 존재하는 이유가 바로 이것입니다.

거짓말할 수 없는 거울

새 탭에서 Spike 52 열기 ↗ · 소스 보기

평면 반사는 물 평면을 기준으로 거울처럼 뒤집은 카메라에서 장면을 한 번 더 오프스크린 타깃에 렌더링하고, 물 셰이더가 그 타깃을 샘플링합니다. 이것이 정석적인 패턴이고, UE5 Water, three.js 자체의 WaterMesh, ABZÛ, Sea of Thieves가 모두 쓰는 방식입니다. 장면의 모든 픽셀을 그릴 재료로 가지고 있기 때문인데, SSR이 절대 볼 수 없는 지오메트리까지 포함합니다. TSL에서는 거의 김빠질 만큼 간단합니다. reflector()가 거울 헬퍼 카메라와 그 렌더 타깃을 할당하고, 그 타깃을 메시에 추가하면 매 프레임 갱신되며, 그 색을 샘플링하면 됩니다. 나중에 파도가 들어오면, 리플렉터의 UV 노드에 왜곡 오프셋을 더해서 반사가 일렁이게 됩니다. 이건 정확히 WaterMesh가 쓰는 그 라인입니다.

기록해 둘 만한 버그는 거울이 아니라 안전망에 있었습니다. 이전 버전은 리플렉터의 출력을 절차적 하늘과 폴백으로 블렌딩했는데, 반사 색의 크기에 따라 가중치를 줬습니다. 거의 0에 가까운 반사는 타깃에 아무것도 없다는 뜻이라는 이론에서였죠. 그런데 어두운 나무 그늘도 크기가 작아서, 클램프가 풀 강도에 도달하지 못했고 진짜로 어두운 그 픽셀들이 밝은 하늘과 섞여버렸습니다. 사용자가 잡아낸 증상은 정확했습니다. 디버그 모드에서 원본 거울 타깃은 완벽하게 어두운 나무를 보여주는데 합성된 렌더는 반사가 바래 있었고, 진단은 셰이더가 "어두운 색을 사라지게 한다"는 것이었습니다. 해결책은 삭제였습니다. 리플렉터 타깃은 첫 프레임 이후로 믿을 수 있으므로 폴백 자체가 필요 없습니다. 두 spike는 그 아래에 같은 굴절을 공유합니다. 표면 뒤의 장면을 샘플링하고, 각 픽셀이 수면 아래로 얼마나 깊이 있는지 재구성하고, 채널별 Beer-Lambert 소광을 적용해서 빨강은 몇 미터 안에 죽고 파랑은 살아남게 하며, 먼 평면 배경이 안개에 덮이지 않도록 하늘 마스크를 둡니다. Schlick Fresnel은 물을 똑바로 내려다볼 때의 굴절과 가로질러 볼 때의 반사를 섞어줍니다.

완성된 모습은 이렇다

새 탭에서 Spike 53 열기 ↗ · 소스 보기

Spike 53은 직접 만들 것인가 사 올 것인가를 확인하는 작업입니다. threejs-water-pro를 출시된 그대로 넣습니다. 열대 프리셋, 기본 바다 클립맵, 카메라 추적, Rayleigh 하늘까지 함께 넣고, 라이브러리 자체 데모가 쓰는 것과 같은 섬 glTF를 로드합니다. 핵심은 직접 짠 평면 물 셰이더와 완전한 바다 시스템 사이의 격차를 보는 것인데, 그 격차는 큽니다. 이쪽에는 배 선체 아래 여러 지점에서 파도 높이를 샘플링해 배가 그냥 위아래로 까딱이는 게 아니라 피칭과 롤링을 하게 하는 부력 시스템, 항적 생성기, 해안선과 표면 거품, 그리고 배 선체 안쪽의 물 렌더링을 억제해서 잔물결이 갑판을 통해 새어 나오지 않게 하는 마스크 패스가 있습니다.

통합 과정에서, README를 읽어서가 아니라 라이브러리를 실제로 써봐야만 배우게 되는 종류의 디테일들이 드러났습니다. 거품 텍스처는 프리셋에서 파일명으로 참조되지만 그것을 로드하는 일은 사용하는 쪽의 몫이고, 그게 없으면 해안선이 부서지는 파도가 아니라 딱딱한 수면 경계선으로 보입니다. 섬은 그 수중 지오메트리가 바다 바닥 아래로 떨어지도록 배치되어, 라이브러리의 바닥 메시가 모델의 바깥 테두리를 가려서 평면 가장자리가 보이지 않게 합니다. 이것이 라이브러리가 의도한 패턴입니다. 3D 모델을 가져오되 지형을 합성하지는 말라는 거죠. 그리고 안티앨리어싱은 렌더러에서 일부러 끄고 대신 후처리 FXAA 패스를 씁니다. MSAA는 깊이 인식 대기 안개가 돌기 전에 가장자리 프래그먼트를 배경과 블렌딩해서, 지오메트리와 안개가 만나는 곳마다 얇고 어두운 테두리를 남기기 때문입니다. 안개 전이 아니라 후에 앨리어싱을 해소하면 그 테두리가 사라집니다. 이것이 줄여주는 리스크는 결정 그 자체입니다. 프로덕션 바다는 크고 특화된 시스템이고, 그게 필요한 경우라면 항적과 부력과 거품을 처음부터 다시 만드는 것보다 유지보수되는 라이브러리를 도입하는 편이 낫습니다. 한편 spike 52의 평면 거울 셰이더는 크리에이터가 자기 월드에 두는 더 작은 내륙 물에는 여전히 옳은 답으로 남습니다.

이 장에서 언급한 기술

스크린 스페이스 반사 물. 반사된 시선 광선이 DDA로 스크린 스페이스에서 깊이 버퍼를 진행하며, 원근 보정 1/z 보간, 프래그먼트당 비용을 묶는 강한 스텝 제한, 그리고 제한된 진행이 남기는 밴딩을 없애는 이진 정제 패스를 씁니다. 깊이 스케일 두께를 적용한 점과 직선 확인이 거짓 히트를 걸러냅니다. 이 방식의 절대적 한계는 메인 카메라가 이미 샘플링한 지오메트리만 반사할 수 있다는 것이어서, 화면 밖이나 잘못된 쪽 표면은 절대 나타나지 않습니다. 지형 머티리얼을 참고하세요.

평면 거울 반사. 물 평면을 기준으로 거울처럼 뒤집은 헬퍼 카메라가 장면을 오프스크린 타깃에 렌더링하고 물 셰이더가 그것을 샘플링해서, SSR이 볼 수 없는 지오메트리까지 포함한 픽셀 단위로 완벽한 반사를 줍니다. 이것이 UE5 Water와 three.js WaterMesh가 쓰는 패턴이고, 파도 왜곡은 리플렉터의 UV 노드에 오프셋으로 적용됩니다. 크기 가중 하늘 폴백은 어두운 반사 픽셀을 잘못 지워버렸고, 리플렉터 타깃은 첫 프레임 이후 믿을 수 있으므로 폴백을 제거하는 것이 해결책이었습니다.

Beer-Lambert 깊이 틴트 굴절. 두 셰이더 모두 표면 뒤의 장면을 샘플링하고, 각 배경 픽셀의 수면 아래 깊이를 재구성하고, 채널별 지수 소광(빨강은 미터 단위로 죽고 파랑은 살아남음)을 적용해 물 안개 색 쪽으로 합성하며, 먼 평면이 안개에 덮이지 않도록 하늘 마스크를 둡니다. Schlick Fresnel은 수직 입사에서의 굴절과 스치는 각도에서의 반사를 섞습니다.

프로덕션 물 라이브러리 도입. threejs-water-pro는 바다 클립맵, Rayleigh 하늘, 배의 피칭과 롤링을 위한 다중 지점 부력, 항적, 거품, 그리고 선체 마스크 패스를 함께 제공합니다. 사용하는 쪽의 디테일이 중요합니다. 거품 텍스처는 명시적으로 로드해야 하고, 섬의 수중 지오메트리는 바다 바닥 아래로 떨어뜨려 바닥이 그 가장자리를 가리게 해야 하며, 지오메트리 가장자리의 어두운 안개 테두리를 피하려면 안티앨리어싱은 MSAA가 아니라 후처리 FXAA 패스로 돌려야 합니다.


29부 중 26부. 이전: 25부 - 골격 하나, 모든 의상 다음: 27부 - 노이즈에서 만든 섬, 땅처럼 보이는 땅 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide