메뉴
HN
Hacker News 4일 전

고도 엔진으로 배우는 유체 역학 시뮬레이션

IMP
6/10
핵심 요약

Godot(고도) 게임 엔진을 활용해 복잡한 Navier-Stokes 방정식 기반의 유체 시뮬레이션을 구현하는 과정을 다룬 실전 중심의 튜토리얼입니다. 성능 최적화보다는 코드의 가독성과 학습의 편의성에 초점을 맞추어, 게임 개발자들이 유체 역학의 원리를 쉽게 이해하고 적용할 수 있도록 돕습니다.

번역된 본문

2026년 5월 19일

고도 게임 엔진으로 설명하는 나비에-스토크스(Navier-Stokes) 유체 시뮬레이션

게임 개발을 하다 처음 유체 시뮬레이션을 접했을 때, 그 효과가 얼마나 훌륭한지에 감탄했습니다. 이것이 어떻게 작동하는지 정말 배우고 싶었지만, 놀랍게도 이 주제에 대한 학습 자료는 매우 부족했고 제가 찾은 자료들도 이해하기가 꽤 어려웠습니다. 그럼에도 불구하고 나는 직접 시도해보기로 결심했습니다. 그리고 하는 김에 블로그 포스트를 작성해서 다음에 도전하는 사람들에게 조금이나마 도움이 되길 바랐습니다.

시작하기 전에 몇 가지 강조하고 싶은 점이 있습니다.

  • 저는 수학자가 아닙니다. 제 설명에서 오류를 발견하시면 Bluesky에서 DM을 보내주시거나 이메일을 보내주세요. 수정하겠습니다.
  • 이 구현은 오직 학습 목적입니다. 이러한 이유로 성능면에서 최고가 아닌 방식으로 구현되었습니다. 계산은 전적으로 CPU에서 이루어집니다. 읽고 배우기 쉽게 하기 위해 너무 많은 변수를 도입했지, 최대의 FPS를 끌어내기 위함이 아니었습니다.

제가 참고한 학습 자료는 다음과 같습니다:

  • Jos Stam의 "Real-Time Fluid Dynamic for Games" (PDF)
  • Mike Ash의 "Fluid Simulation for Dummies"

이 저장소(github.com/rskupnik/godot-fluid-simulation-demo)에서 모든 코드를 찾을 수 있습니다. 저는 이 블로그 포스트의 챕터와 일치하는 코드 체크포인트를 표시하기 위해 git 커밋을 사용했습니다. 따라서 글을 읽으면서 코드를 직접 작성하고 싶지 않다면, 커밋 뷰를 통해 따라 가실 수 있습니다. 편의를 위해 각 챕터에 '프로젝트 스냅샷'과 'diff(차이점)' 링크를 포함시켰습니다. 이는 각각 논의된 시점의 코드베이스와 커밋 diff 보기로 이어집니다.

AI 고지: 이 블로그 포스트의 모든 단어와 이 코드베이스의 모든 코드 줄은 제가 직접 작성했습니다. 모든 다이어그램과 비디오는 제가 만들었습니다. AI는 연구 목적으로만 사용되었습니다.

마지막으로, 이런 작업이 마음에 드신다면 저에게 커피 한 잔 사주시는 것을 고려해 주세요 :)

기초

우리가 사용할 알고리즘은 유체 흐름에 대한 물리 방정식인 Navier-Stokes 방정식을 기반으로 합니다. 우리의 사용 사례는 게임 개발이므로 계산의 정밀도를 희생하여 속도를 선호합니다. 효과는 충분히 좋아야 하지만 지나치게 비용이 많이 들어서는 안 됩니다. 우리는 이것을 세 가지 방법으로 달성합니다. 첫째로, 우리는 큰 셀을 가진 비교적 작은 격자(grid)를 사용합니다. 둘째로, 우리는 임의의 시간 간격으로 시뮬레이션을 진행합니다. 마지막으로, 우리는 몇 가지 방정식에 대한 충분히 좋은 해답을 얻기 위해 근사 방정식(예: Gauss-Seidel 완화법)을 사용합니다.

이 블로그 포스트에서 우리가 할 일에 대한 수학적 설명으로 시작하겠습니다. 이 설명은 겁먹게 들릴 수 있지만, 걱정하지 마세요 - 진행하면서 모든 것을 설명할 것입니다. 자, 여기 있습니다: 우리는 벡터 속도 장(velocity field)을 통해 스칼라 밀도 장(density field)을 이동시켜 유체 흐름을 시뮬레이션할 것입니다. 우리는 속도 확산(diffusion)과 이류(advection)뿐만 아니라 밀도 확산과 이류도 시뮬레이션할 것입니다. 그런 다음 유체가 질량 보존 법칙을 따르도록 하기 위해 속도 투영(projection)을 추가할 것입니다 - 이는 발산(divergence)을 압력 장(pressure field)과 균형을 맞춤으로써 발생할 것입니다. 우리는 필요한 곳의 값을 근사하기 위해 이중 선형 보간법(bilinear interpolation)과 Gauss-Seidel 완화법을 사용할 것입니다.

자, 모든 준비가 끝났으니 시작합시다!

여정은 격자(Grid)에서 시작됩니다

프로젝트 스냅샷

새로운 Godot 프로젝트를 만들고 Node2D를 추가합니다. 저는 mine을 "FluidGrid"라고 불렀습니다. 여기에 스크립트를 연결합니다. 모든 코드는 그 스크립트 안에 들어갑니다.

첫째로, 격자를 정의해 봅시다:

@export var N := 16 @export var cell_size := 32 var size := 0

여기서 N은 행과 열의 셀 개수, 즉 기본적으로 격자의 크기이며, cell_size는 픽셀 단위의 단일 셀 크기입니다. 우리는 또한 곧 초기화할 size를 정의합니다. 우리는 테두리도 원하기 때문에 이것을 N+2로 설정할 것입니다.

다음으로, 실제 데이터를 저장하기 위한 배열을 추가합시다.

density는 '이 셀이 얼마나 많은 물질을 포함하고 있는가'를 의미합니다.

var density : PackedFloat32Array var density_prev : PackedFloat32Array

"u"는 수평 속도(x 방향)를 저장합니다.

var u : PackedFloat32Array var u_prev : PackedFloat32Array

"v"는 수직 속도(y 방향)를 저장합니다.

var v : PackedFloat32Array var v_prev : PackedFloat32Array

원문 보기
원문 보기 (영어)
19 May 2026 Navier-Stokes fluid simulation explained with Godot game engine When I first stumbled upon fluid simulations in game dev I was amazed on how good the effect could be. I really wanted to learn how this works, but learning materials on this topic are suprisingly sparse - and those which I found were pretty difficult to understand. Nonetheless, I decided to give it a try; and - while I’m at it - why not create a blog post out of it to hopefully make it easier for the next guy? Before we begin, I want to stress a few points: I’m not a mathematician. If you find errors in my explanations, please DM me on Bluesky or send an email and I’ll correct it This implementation is for learning purposes only. For that reason it is implemented in a way that is not the best performance-wise. Calculations are made solely on the CPU. We introduce way too many variables. All of that to make it easier to read and learn, not necessarily to squeeze the most FPS. Learning materials I used: “Real-Time Fluid Dynamic for Games” by Jos Stam (PDF) “Fluid Simulation for Dummies” by Mike Ash You can find all of the code in this repository: github.com/rskupnik/godot-fluid-simulation-demo I used git commits to mark code checkpoints matching the chapters of this blog post, so if you don’t want to write the code alongside reading, you can make use of the commit view to follow along. For your convenience, I include a “project snapshot” and a “diff” link at each chapter, which lead to, respectively: the codebase at the discussed point and the commit diff view AI disclosure: Every word of this blog post and every line of this codebase is written by me. All the diagrams and videos were created by me. AI was only used for research. Finally, if you appreciate such work then consider buying me a coffee :) Foundations The algorithms we will use are based on physical equations for fluid flow - the Navier Stokes equations. Our use case is a game dev one - so we want to sacrifice precision of these calculations in favour of speed. The effect needs to be good enough but not overly expensive. We achieve this in three ways. First of all, we use a relatively small grid with large cells. Second - we advance the simulation in arbitrary time steps. Finally, we use approximation equations (such as Gauss-Seidel relaxation) to arrive at good-enough solutions to some equations. Let me start with the mathematical description of what we will do in this blog post. This description might sound daunting, but don’t worry - we’ll explain everything as we go. Here goes: we will simulate fluid flow by moving a scalar density field through a vector velocity field. We’ll simulate velocity diffusion and advection as well as density diffusion and advection. Then we will add velocity projection with the goal of making the fluid obey the law of mass conservation - which will happen by balancing divergence with a pressure field. We will use bilinear interpolation and Gauss-Seidel relaxation for approximating values where needed. Alright, with all of that out of the way, let’s begin! The journey begins with a grid Project snapshot Create a new Godot project and add a Node2D. I called mine “FluidGrid”. Attach a script to it. All the code goes into that script. First, let’s define the grid: @ export var N : = 16 @ export var cell_size : = 32 var size : = 0 Here, N is the amount of cell in a row and column - basically the size of the grid - and cell_size is the size of a single cell in pixels. We also define size , which we will soon initialize. We will set it to N+2, because we want borders as well. Next, let’s add arrays for storing the actual data. # Density means "how much material does this cell contain" var density : PackedFloat32Array var density_prev : PackedFloat32Array # "u" stores the horizontal velocity (x direction) var u : PackedFloat32Array var u_prev : PackedFloat32Array # "v" stores the vertical velocity (y direction) var v : PackedFloat32Array var v_prev : PackedFloat32Array For now, we need three arrays - density will store the density, u will store horizontal velocity and v - vertical velocity. Density tells us how much material does a given cell contain - it will range between 0.0 and 1.0 , where 0 is empty and 1 is full. Technically, it can go above 1.0 but we will only display up to 1.0. The way we display density is simple - with color. A cell full of density will be fully white and without density - fully transparent. The velocity arrays also store floats and they describe velocity at a given cell. A velocity of 0 means no movement, then it can go positive or negative which corressponds to going right or left (for horizontal) and down or up (for vertical). Combined, they tell us the velocity at a given cell. We could just store the velocities as a single array of Vector2f , but it will be much easier for calculations if we separate them as two float arrays. When it comes to displaying this information - we will draw small blue arrows for each cell. You might also wonder why each array has a _prev equivalent - that’s because for some of the calculations we will iterate over the real arrays and modify the data live - and in those cases we need to “snapshot” the data before we start iterating, so we can see what the values were before we started modifying them. That will be mostly used in the approximating equations. I follow the naming convention of Stam’s paper with this _prev name, although I believe _snapshot would be a more descriptive name. Alright, let’s move on. Time to initialize all of these! func _ready (): # Resize all the arrays properly # We use a single-dimensional array to store the grid, which is why we need to multiply N # The "+2" is added for borders, because there are two for each dimension (x, and y) # For x dimension, there's a single cell border on the left and on the right, hence "+2". Same for the y direction size = ( N + 2 ) * ( N + 2 ) density . resize ( size ) density_prev . resize ( size ) u . resize ( size ) v . resize ( size ) u_prev . resize ( size ) v_prev . resize ( size ) queue_redraw () We could use a two-dimensional array, but it will be simpler to work with a single-dimensional one. We just need one helper function to make it easier to index this array. # This is a helper function that makes it easier to work with a grid when it is # packed into a single-dimension array # We can call it with the cell index (i and j) and it will translate it into # an index in the single-dimension array func IX ( i : int , j : int ) -> int : return i + ( N + 2 ) * j With all of that, we can now implement the _draw() function to display the grid. # This is the standard Godot function used for drawing # We want to draw a simple grid of (N+2)*(N+2) rectangles of size cell_size func _draw (): for j in range ( 0 , N + 2 ): for i in range ( 0 , N + 2 ): var x : = i * cell_size # this translates the index into pixel position on screen var y : = j * cell_size var rect : = Rect2 ( x , y , cell_size , cell_size ) var is_boundary : = i == 0 or j == 0 or i == N + 1 or j == N + 1 var fill : = Color ( 0.16 , 0.08 , 0.08 ) if is_boundary else Color ( 0.08 , 0.08 , 0.08 ) draw_rect ( rect , fill , true ) draw_rect ( rect , Color ( 0.35 , 0.35 , 0.35 ), false ) It’s pretty self-explanatory. We iterate over the grid and draw each cell as a simple Rect2 , changing the color slightly for the border cells. You can now run the project and you should see this: Putting “fluid” in “fluid simulation” Project snapshot Diff Now that we have a grid, let’s add some fluid to it. We will start very simple - we’ll make it possible to add density to a cell by clicking it with a mouse. Then we will make the density slowly fade away - this will be useful later so we can experiment without making the grid fill with fluid and requiring a restart. # This helper function translates the position we clicked on with the mouse # into the cell coordinates # So if we click somewhere in the grid, it will return a Vec