본문으로 건너뛰기

© 2026 Molayo

YouTube요약2026. 06. 10. 13:03

코딩 어드벤처: 유체 렌더링 (Rendering Fluids)

요약

Sebastian Lague가 입자 기반 유체 시뮬레이션을 연속적인 표면을 가진 메쉬로 변환하는 과정을 다룹니다. Marching Cubes 알고리즘을 사용하여 밀도 맵으로부터 유체의 경계를 추출하고 메쉬를 생성하는 기술적 구현을 설명합니다.

핵심 포인트

  • 입자 기반 시뮬레이션을 연속적인 표면 메쉬로 변환
  • Marching Cubes 알고리즘을 활용한 밀도장 경계 추출
  • 3D 텍스처와 밀도 샘플링을 통한 메쉬 생성 과정
  • 해상도 및 임계값 조절을 통한 렌더링 디테일 제어

영상: 코딩 어드벤처: 유체 렌더링 (Rendering Fluids)
채널: Sebastian Lague
길이: 58분 41초
출처: 자막 (수동, en-GB)

안녕하세요 여러분, Coding Adventures의 새로운 에피소드에 오신 것을 환영합니다. 오늘은 유체 렌더링 (fluid rendering)의 멋진 세계로 뛰어들어 보려고 합니다. 왜냐하면 약 1년 전쯤, 저는 여기서 입자 기반 (particle based)의 작은 유체 시뮬레이션을 작업했었거든요. 그래서 현재로서는 당연히 공처럼 보입니다. 하지만 우리는 수많은 작은 공들을 원하는 것이 아니라, 멋지고 연속적인 표면 (continuous surface)을 원합니다. 이를 달성하기 위해 가장 먼저 떠오르는 아이디어는 Marching Cubes입니다. 이것은 제가 과거에 3D 노이즈 (3D noise)로부터 지형 메쉬 (terrain meshes)를 생성할 때 많이 사용했던 방식이라, 이미 작성해 둔 코드를 재사용하여 여기서 꽤 빠르게 무언가를 실행할 수 있을 것입니다.

우선, 시뮬레이션에 이 3D 텍스처 (3D texture)를 추가했습니다. 그리고 매 프레임의 마지막에 정기적인 간격으로 유체의 밀도 (density)를 샘플링하여 그 값들을 텍스처에 기록합니다. 결과는 이와 같습니다. 해상도 (resolution)가 지금은 다소 낮은 편이지만, 적어도 작동은 하는 것 같네요. 이것은 우리가 보고 있는 볼륨 (volume)의 중간을 가로지르는 단일 슬라이스 (single slice)일 뿐이지만, 이 작은 뷰어에서 슬라이스 깊이 (slice depth)를 변경하여 다른 값들에서는 어떻게 보이는지 확인할 수 있습니다. Marching Cubes 알고리즘은 밀도장 (density field)의 경계, 즉 값이 0에서 0이 아닌 값으로 변하는 지점을 따라 메쉬 (mesh)를 생성할 것입니다. 또는 경계로 간주할 값을 정확하게 지정할 수도 있습니다. 하지만 우리는 이미 다른 에피소드에서 이 모든 것을 구현했기 때문에, 이제 밀도 맵 (density map)을 우리를 대신해 Marching Cubes를 수행해 주는 이 GenerateMesh 함수에 전달하기만 하면 되는 부러울 정도로 쉬운 작업만 남았습니다. 그런 다음 메쉬가 화면에 나타나도록 렌더러 (renderer)에 부착되었는지 확인하기만 하면 됩니다. 좋습니다, 한번 시도해 보죠.

여전히 여기서 입자 (particles)들을 볼 수 있지만, 이제 더 이상 주인공이 아니므로 좀 더 은은한 회색 (grey)으로 만들어 보겠습니다. 그런 다음 메쉬 렌더러 (mesh renderer)를 활성화하고 시뮬레이션 (simulation)을 실행합니다. 좋습니다, 잘 작동하는 것 같네요. 비록 메쉬 (mesh)의 측면이 실제로 채워져 있다면 더 좋을 것 같다는 생각이 들긴 하지만요. 그건 충분히 쉬울 것 같습니다. 마칭 큐브 (marching cubes) 코드로 빠르게 들어가 보면, 특정 좌표의 밀도 맵 (density map)을 샘플링 (sampling)하기 위한 작은 헬퍼 함수 (helper function)가 여기 있습니다. 따라서 해당 좌표가 맵의 가장자리에 있다고 감지되면, 알고리즘이 우리가 유체의 경계에 있다고 믿도록 그곳의 밀도 값 (density value)을 단순히 속일 수 있습니다. 좋습니다, 한번 시도해 보죠. 아주 잘 작동하네요. 그렇다면 해상도 (resolution)를 그냥 높여봐야 할 것 같습니다. 현재는 30 x 18 x 18인데, 이것을 두 배로 늘려보겠습니다. 그리고 아마도 확인할 수 있도록 시뮬레이션을 리셋 (reset)하겠습니다. 나쁘지 않네요 — 벌써 훨씬 더 세밀해 보입니다. 참, 아까 이 임계값 (threshold value)을 어떻게 제어할 수 있는지 언급했었죠 — 그러니 재미 삼아 빠르게 이것을 가지고 놀아봅시다. 값을 700 정도로 높이면, 가장 밀도가 높은 영역만 렌더링 (rendered)되는 것을 볼 수 있습니다. 그리고 이 값을 0 근처까지 완전히 낮추면 거의 모든 것이 렌더링되는데, 이는 다소 덩어리져 보입니다. 시뮬레이션은 모든 곳에서 밀도를 동일하게 유지하려고 최선을 다하고 있지만, 분명 완벽하게 해내지는 못하고 있습니다. 어쨌든, 70 정도가 적당해 보였으니 다시 그 값으로 되돌려 놓고, 해상도를 다시 한번 두 배로 늘려보겠습니다. 자, 어떻게 되는지 봅시다 — 그리고 꽤 좋게 보... 음, 시뮬레이션이 폭발해 버렸네요. 이것은 프레임레이트 (framerate)가 너무 낮아질 때 발생하는데, 누구를 탓해야 할지 아주 잘 알 것 같습니다. 물론 제 탓이죠. 그 녀석은 항상 제 삶을 힘들게 만드니까요.

이번에는 그가 GPU 상에서 Marching Cubes (마칭 큐브)를 구현하는 수고를 다 해놓고는, 메쉬 (Mesh)를 구성하기 위해 데이터를 CPU로 다시 가져오느라 얼마나 많은 클록 사이클 (Clock cycles)을 낭비했는지 모릅니다. 그렇게 만들어진 메쉬는 당연히 렌더링 (Rendering)을 위해 다시 그래픽 카드로 전송되어야 하니까요.

그래서 저는 이러한 데이터 트래픽을 피하기 위해 새롭고 개선된 렌더링 함수를 작업해 왔습니다. 기본적으로, Marching Cubes (마칭 큐브)를 실행한 후, 그것이 생성한 삼각형 버퍼 (Triangle buffer)를 커스텀 셰이더 (Custom shader)가 적용된 재질 (Material)에 할당합니다. 그런 다음 일부 정보를 저장하는 렌더 인자 (Render arguments)를 생성해야 하는데, 가장 중요한 것은 삼각형의 개수입니다.

이 작업이 완료되면, CPU로 데이터를 다시 읽어올 필요 없이 다음과 같이 드로우 콜 (Draw call)을 호출할 수 있습니다. 아이디어는 이렇습니다. 이 드로우 재질 (Draw material)에서 우리의 커스텀 셰이더 (Custom shader)가 현재 작업 중인 정점 (Vertex)의 인덱스 (Index)를 요청하면, 우리가 제공한 버퍼로부터 해당 정점의 위치 (Position)를 수동으로 읽을 수 있게 하는 것입니다.

좋습니다, 이 방식은 기존 방식보다 훨씬 효율적이어야 합니다. 그럼 새로운 버전으로 전환해 보겠습니다... 그리고 당연하게도 전혀 작동하지 않네요. 사실 이상하게도 기존 버전조차 이제는 고장 난 것처럼 보입니다. 제 의심스러운 코드 때문에 GPU가 깊은 모욕을 느낀 것 같군요.

좋아요 — 약간의 만지작거림 (Tinkering) 끝에 문제를 해결했다고 믿었습니다. 아뇨, 아닙니다.

음, 조금 더 깊이 조사해 본 결과, 제가 Marching Cubes (마칭 큐브) 설정을 어떻게 했는지 방금 깨달았습니다. 정점 (Vertices)은 단순한 위치 (Positions)가 아니라, 위치와 법선 벡터 (Normal vector)의 쌍입니다. 그래서 이제 이를 고려하도록 여기서 셰이더 (Shader)를 수정했습니다.

그리고 마침내, 작동합니다. 이제 적절한 해상도로 이것을 렌더링할 수 있게 되었습니다. 꽤... 부드러워 보이네요. 물론 아직도 우리가 할 수 있는 최적화 (Optimizations)는 많이 남아있을 것이라 확신합니다!

하지만, 이것이 정말 오늘 제가 가고자 하는 방식인지에 대해 약간의 재고를 하고 있습니다. 왜냐하면 이것을 단순히 주변을 감싸는 '껍데기 (shell)' 형태가 아니라, 실제 유체의 부피 (volume)처럼 보이게 만드는 방법을 잘 모르겠기 때문입니다. 메쉬 (mesh)를 레이트레이싱 (raytracing) 하는 것이 방법이 될 수 있겠지만, 이를 실시간 (realtime)으로 수행하려면 제가 최근에 실험했던 BVH와 같은 가속 구조 (acceleration structure)를 구축해야 합니다. 다만 매 프레임마다 완전히 GPU 상에서 구축해야 한다는 점이 상황을 조금 더 까다롭게 만듭니다. 사실 이 주제에 대해 이미 북마크해 둔 논문들이 몇 개 있지만, 그것은 아마 그 자체로 하나의 완전한 프로젝트가 될 것입니다.

따라서 이것을 실험해 보는 것도 즐거웠지만, 마칭 큐브 (marching cubes)로 메쉬를 생성하는 대신, 제가 과거에 구름이나 대기와 같은 볼륨 효과 (volumetric effects)를 위해 실험했던 것과 동일한 기술을 사용하여 밀도장 (density field)을 레이마칭 (raymarching)으로 직접 그려보는 시도를 해볼 수 있을 것 같습니다.

좋습니다, 이제 매우 단순한 레이마칭 함수를 포함한 포스트 프로세싱 셰이더 (post processing shader)를 설정했습니다. 우선 현재 픽셀을 통해 바라보는 방향을 계산하는 것으로 시작하여, 유체 시뮬레이션 (fluid simulation)의 경계 상자 (bounding box)와 교차하는지 테스트함으로써 우리가 여정을 시작할 위치를 파악합니다. 다음으로 주어진 단계 크기 (step size)로 경계 상자를 통과하며 전진하는 작은 루프가 있습니다. 각 단계마다 3D 맵에서 밀도 (density)를 찾아내고, 그 값을 단계 크기와 곱하여 본질적으로 이 단계에서 얼마나 많은 유체를 통과했는지에 대한 추정치를 얻습니다. 이 값들을 모두 합산하면 시선 (view ray)을 따라 전체 밀도에 대한 추정치를 얻게 되며, 결과가 어떻게 보이는지 확인해 봅시다.

좋습니다, 현재는 완전히 흰색이지만, 여기서 이 densityMultiplier 값을 줄여서 더 흥미로운 결과를 얻을 수 있는지 시도해 볼 수 있습니다.

하지만 지금은 매우 조각조각 나 있는 것처럼 보이는데, 이는 우리의 스텝 사이즈 (step size)가 아마 너무 높다는 것을 의미하므로, 그것도 줄여보도록 하겠습니다. 이제 훨씬 좋아졌습니다. 확실히 색상이 부족하긴 하지만, 우선은 대기 렌더링 (atmospheric rendering)에서 기억나는 내용을 바탕으로 시도해 보려고 합니다. 물에 적용하기에는 아마 완전히 올바른 접근 방식은 아니겠지만, 어떻게 진행될지 궁금하네요.

다시 코드로 돌아가서, 각 스텝마다 태양으로부터 오는 빛이 이 지점에 도달한다고 가정하고, 이곳의 밀도 (density)에 따라 일정량이 카메라를 향해 산란 (scatter)될 수 있다는 몇 줄의 코드를 추가했습니다. 그 양은 또한 산란 계수 (scattering coefficients)에 의해 결정되며, 우리는 빛의 빨강, 초록, 파랑 파장(wavelengths)에 대해 이를 개별적으로 조정할 수 있습니다. 엄밀히 말하면 각도 (angle)에도 의존해야 하지만, 이 작은 실험을 위해 그 부분은 무시하겠습니다.

어쨌든, 카메라를 향해 튕겨 나가는 그 빛은 카메라에 도달하기 위해 유체 (fluid)를 통과해야 하며, 그 경로를 따라 빛의 일부는 흡수 (absorbed)되거나 다른 곳으로 산란 (scattered away)될 것입니다. 우리는 이를 지수적 감쇠 (exponential decay)로 모델링할 수 있습니다. 여기 투과율 (transmittance) — 즉 매질을 실제로 통과하는 빛의 비율 — 이 산란 또는 흡수 계수를 높임에 따라 어떻게 감소하는지를 보여주는 작은 시각화 자료가 있습니다.

좋습니다, 마지막으로 카메라에 도달하는 이 모든 빛을 합산하여 그 결과를 반환하기만 하면 됩니다. 이제 시뮬레이션으로 돌아가서, 색상이 나타나도록 계수들을 조정해 보겠습니다. 이것들을 조금 만져보겠습니다 — 여기 꽤 독성 있어 보이는 유체가 있고 — 여기는 꽤 뜨거워 보이는 유체가 있습니다. 하지만 현재 상당히 큰 단순화가 이루어지고 있는데, 그것은 유체 내부의 한 지점에 도달하는 빛이 항상 순수한 흰색이라고 가정하는 것입니다.

하지만 우리는 태양으로부터 오는 빛이 이곳에 도달하기 위해 유체를 통과해야 한다는 사실과, 그 여정 속에서 빛의 일부가 손실될 것이라는 점을 고려하지 않고 있습니다. 따라서 우리가 할 수 있는 일은 이 지점과 태양 사이에 유체가 얼마나 존재하는지 계산하는 것입니다. 지금까지 해온 것처럼 볼륨 (Volume)을 따라 전진 (marching)하며 계산하되, 그래픽 카드가 타버리는 것을 방지하기 위해 훨씬 더 큰 단계 크기 (step size)를 사용하면 됩니다. 일단 그 값을 구하면, 동일한 지수적 감쇠 (exponential decay)를 적용하여 투과율 (transmittance)을 계산할 수 있습니다. 그러면 당연히 이전에 가정했던 일정한 흰색 빛을 대체하게 될 것입니다.

자, 결과가 어떻게 보이는지 봅시다! 시뮬레이션을 시작하면, 꽤 분홍빛이 도는 가장자리를 가진 다소 구름 같은 큐브가 나타납니다. 약간 이상해 보이기도 하지만 멋지기도 하네요. 아래쪽에서 보면 어떤 모습일지 궁금하므로 빠르게 다시 시작해 보겠습니다.

좋습니다, 색상을 다시 한번 조절해 보고 싶어서 계수 (coefficients)를 초기화했습니다. 여기서 조금 만져볼 수 있도록 시간을 느리게 설정하고, 아마도 빨간색 빛의 산란 (scattering)을 늘리는 것부터 시작해 보겠습니다. 더 많은 파장의 빛이 카메라 쪽으로 산란됨에 따라, 유체의 얇은 가장자리 부분이 이제 다소 붉게 변한 것을 볼 수 있습니다. 하지만 산란이 높다는 것은 더 많은 빨간색 빛이 손실된다는 의미이기도 합니다. 그래서 빛이 유체를 통해 더 멀리 이동해야 할 때, 결국 파란색과 초록색 빛만 약간 비치는 것을 볼 수 있습니다.

어쨌든, 이 값들을 가지고 계속 조금씩 조절해 보겠습니다. 매우 천상적인 안개 같은 느낌이 들어서 상당히 흥미로워 보이지만, 분명히 물과 같은 전형적인 유체와는 닮지 않았습니다. 만약 우리가 그 방향으로 더 나아가고 싶다면, 빛이 사방으로 산란되게 하기보다는 빛이 어떻게 반사 (reflect)되고 굴절 (refract)되는지에 더 집중해야 할 것 같습니다.

자, 공기 중을 지나 물웅덩이로 들어가는 빛이 있다고 가정해 봅시다. 언젠가 제대로 이해하고 싶은 이유 때문이기도 하지만, 빛은 물속에서 더 느리게 이동하며 이로 인해 방향에 약간의 변화가 생깁니다. 이것이 바로 우리가 굴절 (refraction)이라고 부르는 현상입니다. 하지만 아직 구현하지 않았기 때문에 지금 당장은 그 변화가 보이지는 않습니다.

이를 위해서는 스넬의 법칙 (Snell’s law)을 가져와야 합니다. 이 법칙은 표면 법선 (surface normal)을 기준으로 두 가지 각도, 즉 입사각 (angle of incidence)과 알 수 없는 굴절각 (angle of refraction)을 고려하라고 알려줍니다. 스넬리우스 (Snellius)와 그 이전의 학자들에 따르면, 이 알 수 없는 각도의 사인 (sin) 값은 입사각의 사인 값에 공기 중에서의 빛의 속도 대비 물속에서의 빛의 속도 비율을 곱한 것과 같습니다.

좋습니다, 방금 이를 위해 매우 비효율적인 코드를 작성했습니다. 이 코드는 빛의 방향과 표면 법선 (surface norm

AI 자동 생성 콘텐츠

본 콘텐츠는 YouTube Sebastian Lague (절차적 생성)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0