Симуляция воды для игр на Go: простая физика частиц
- вторник, 2 декабря 2025 г. в 00:00:08
Команда Go for Devs подготовила перевод статьи о том, как на Go и raylib-go построить лёгкую симуляцию воды на клеточном автомате. Автор шаг за шагом добавляет гравитацию, боковой поток, диагональное давление и препятствия — и в итоге получает частичную физику, больше похожую на «песочный» движок для 2D-игр.
В этом посте мы будем использовать raylib-go, чтобы создать лёгкую симуляцию воды для 2D-игр. Цель — получить модель воды, которая выглядит естественно, «течёт» как настоящая и создаёт ощущение объёма. Тема симуляции жидкостей огромная, поэтому для упрощения мы будем обновлять каждую ячейку с помощью клеточного автомата.

Каждая ячейка будет подчиняться набору правил:
Гравитация — капли падают строго вниз, если там есть свободное место.
Боковой поток — если движение вниз заблокировано, вода растекается влево и вправо.
Давление — если заблокированы оба направления, она уходит по диагонали.
Для начала создаём Go-модуль и устанавливаем пакет raylib-go.
mkdir go-watersim
go mod init watersim
go get -v -u github.com/gen2brain/raylib-go/raylibМы создаём структуру Game, чтобы хранить состояние игры и управлять игровым циклом. Затем добавляем метод Draw(), который рисует текущее состояние на экране, вызывая метод Draw() у объекта Droplet, представляющего воду.
type Game struct {
Width int
Height int
State [][]Droplet // 2D grid of water droplets [y][x]
tileSize int
}
func (g *Game) Draw() {
// Loop through each cell and call the cells Draw() method
for y := range g.State {
for x := 0; x < len(g.State[y]); x++ {
g.State[y][x].Draw(x, y, g.tileSize)
}
}
}Далее создаём сущность Droplet и определяем метод Draw(), который рисует отдельную «каплю» воды на экране — это синий квадрат. Чтобы работать с клеточной симуляцией, мы переводим координаты в ячейки.
type Droplet struct {
volume float64 // How much water this cell contains (0.0 to 1.0)
size int
}
func (d *Droplet) Draw(x, y, tileSize int) {
// Convert grid coordinates to pixel coordinates
pixelX := x * tileSize
pixelY := y * tileSize
if d.volume > 0 {
// Draw the blue water rectangle
rl.DrawRectangle(int32(pixelX), int32(pixelY), int32(tileSize), int32(tileSize), rl.Blue)
}
}Теперь нам нужно создать объект Game и его начальное клеточное состояние. Чтобы упростить задачу, добавим вспомогательные функции: одну — для инициализации Game, другую — для формирования начального состояния.
func NewGame(width, height, tileSize int) *Game {
// Create a new game
g := &Game{Width: width, Height: height, tileSize: tileSize}
// Create the new game state
// divide pixel dimensions by tile size to get grid size
g.State = CreateGameState(g.Width/g.tileSize, g.Height/g.tileSize, tileSize)
return g
}
func CreateGameState(newWidth, newHeight, tileSize int) [][]Droplet {
// Create a new game state
newState := make([][]Droplet, newHeight)
// Loop through each row
for y := range newHeight {
// Create the columns
newState[y] = make([]Droplet, newWidth)
// Loop through each cell and create a Droplet
for x := range newState[y] {
newState[y][x] = Droplet{
size: tileSize,
}
}
}
return newState
}В завершение обновим функцию main(): она будет инициализировать окно raylib, использовать наши вспомогательные функции для создания игрового состояния и выводить его на экран.
// Setup the new game
var game = NewGame(800, 400, 10)
// Initialize Raylib graphics window
rl.InitWindow(int32(game.Width), int32(game.Height), "Water simulation")
defer rl.CloseWindow()
// Create a single water droplet and add it to the screen
droplet := Droplet{size: game.tileSize, volume: 1.0}
game.State[100/game.tileSize][400/game.tileSize] = droplet
// Setup the frame per second rate
rl.SetTargetFPS(20)
// Main loop
for !rl.WindowShouldClose() {
// Begin to draw and set the background to black
rl.BeginDrawing()
rl.ClearBackground(rl.Black)
// Draw the game state
game.Draw()
rl.EndDrawing()
}В итоге у нас появляется клеточная сетка и одна нарисованная на экране капля воды. Запустим программу, чтобы убедиться, что всё работает.
go run main.go
Мы видим окно Raylib с единственным синим квадратом — он и представляет нашу каплю воды. Пока что выглядит не слишком впечатляюще!
Код для этой части руководства доступен на GitHub.
Логические правила
Теперь оживим нашу игру, задав набор логических правил, которые заставят воду двигаться похоже на настоящую жидкость.

Начнём с действия гравитации: вода падает вниз и заполняет клетку под собой. Если вода полностью стекла — на этом взаимодействие заканчивается. Если часть объёма осталась, капля начнёт растекаться в стороны и по диагонали.
Гравитация тянет каплю воды вниз с постоянной скоростью. В нашей симуляции это означает: нужно проверить, есть ли свободное место в клетке прямо под каплей, и если оно есть — передать туда часть объёма.
Чтобы гарантировать, что место действительно свободно, мы перебираем состояние сетки снизу вверх. Это позволяет сначала обработать нижние клетки и не допустить «телепортации» воды сквозь уже обновлённые ячейки.
Мы добавляем метод Update() в структуру Game, чтобы обрабатывать состояние на каждом кадре. Он применяет правила к каждой ячейке, а затем рисует обновлённое состояние на экран.
func (g *Game) Update() {
// Create a new state to avoid modifying the current state while reading it
newState := CreateGameState(len(g.State[0]), len(g.State), g.tileSize)
// Copy current state to new state
for y := range g.State {
copy(newState[y], g.State[y])
}
// Process the simulation from the bottom upwards
for y := len(g.State) - 1; y >= 0; y-- {
for x := range g.State[y] {
// Only process cells that contain water
if g.State[y][x].volume > 0 {
// Check if we are at the bottom boundary
if y+1 < len(g.State) {
processWaterCell(x, y, &newState)
}
}
}
}
// Replace old state with new calculated state
g.State = newState
}Мы создаём новый кадр и копируем в него текущее состояние. Перебор выполняем снизу вверх, чтобы избежать переполнения клеток.
Для каждой ячейки вызываем метод processWaterCell(), который применяет логические правила к объекту Droplet.
func processWaterCell(x, y int, newState *[][]Droplet) {
// Try to flow downwards, as if by gravity
fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5)
}Пока что эта функция простая: она просто передаёт объём из текущей ячейки в ячейку под ней.
Мы используем функцию fill(), которая переносит объём из одной ячейки в другую, а функция remainder() вычисляет, сколько свободного места остаётся в целевой ячейке перед переносом. Чтобы симуляция выглядела плавнее, мы ограничиваем скорость переноса параметром flowRate.
// Calculate how much more water a droplet can hold
func remainder(droplet Droplet, maxVolume float64) float64 {
return maxVolume - droplet.volume
}
// Fill transfers water between two droplets at a controlled rate
func fill(current, target *Droplet, maxVolume, flowRate float64) {
// Calculate how much water can be transferred
transfer := remainder(*target, maxVolume)
// Limit transfer to the flow rate (prevents instant teleportation)
if transfer > flowRate {
transfer = flowRate
}
// Move water from source to target
current.volume -= transfer
target.volume += transfer
}Наконец, мы добавляем метод Update() в наш игровой цикл, чтобы обновлять состояние капель на каждом кадре.
// Draw the game state
game.Draw()
// Update the game state based on the rules
game.Update()
rl.EndDrawing()Теперь можно запустить симуляцию и убедиться, что гравитация действительно влияет на нашу каплю воды.
go run .
Работает!
Капля падает вниз и останавливается, когда достигает нижней границы экрана.
Однако при падении она как будто «дублируется»: две соседние клетки выглядят заполненными.
Исправить это можно, если менять высоту заливки клетки в зависимости от её объёма и проверять, есть ли вода в ячейке сверху. Если сверху есть объём — начинаем заливку сверху, если нет — заполняем снизу.
func (g *Game) Draw() {
// Loop through each cell and call the cells Draw() method
for y := range g.State {
for x := 0; x < len(g.State[y]); x++ {
// Check if there is water above this cell
hasWaterAbove := y > 0 && g.State[y-1][x].volume > 0
g.State[y][x].Draw(x, y, g.tileSize, hasWaterAbove)
}
}
}Мы обновляем метод Game.Draw(), чтобы он проверял наличие воды в верхней клетке. Это значение передаётся в Droplet.Draw(), который решает, откуда начинать заполнение.
func (d *Droplet) Draw(x, y, tileSize int, hasWaterAbove bool) {
// Convert grid coordinates to pixel coordinates
pixelX := x * tileSize
pixelY := y * tileSize
if d.volume > 0 {
// Calculate visual height based on water volume
// Full volume (1.0) = full tile height, half volume (0.5) = half height
height := int(float64(tileSize) * d.volume)
// Fill up from the bottom
offsetY := tileSize - height
// If water above, fill from the top instead
if hasWaterAbove {
offsetY = 0
}
// Draw the blue water rectangle
rl.DrawRectangle(int32(pixelX), int32(pixelY+offsetY), int32(tileSize), int32(height), rl.Blue)
} Метод Droplet.Draw() вычисляет высоту заполнения клетки по объёму и смещает рисование так, чтобы заливка шла снизу. Если сверху есть вода, заливаем сверху.
Теперь запускаем игру и смотрим результат:
go run .
Теперь движение выглядит куда естественнее: капля падает непрерывно и без «рывков».
Код для этой части доступен в репозитории на GitHub.
Запускаем поток воды
Сейчас мы создаём только одну каплю. Но нам нужен именно поток воды, чтобы увидеть, как капли взаимодействуют друг с другом.
Для этого мы добавляем вспомогательную функцию, которая создаёт объекты Droplet.
func CreateWaterGenerator(x, y, tileSize int, state *[][]Droplet) {
droplet := Droplet{size: tileSize, volume: 1.0}
(*state)[y][x] = droplet
}Затем заменяем ручное создание Droplet в основном игровом цикле и начинаем считать кадры, чтобы регулярно добавлять воду.
- // Create a single water droplet and add it to the screen
- droplet := Droplet{size: game.tileSize, volume: 1.0}
- game.State[100/game.tileSize][400/game.tileSize] = droplet
+ // Set up a counter, so we can spawn new water at a rate
+ frameCount := 0
+ flowStartX := 100 / game.tileSize
+ flowStartY := 100 / game.tileSize
+ CreateWaterGenerator(flowStartX, flowStartY, game.tileSize, &game.State)
// Setup the frame per second rate
Rfhj rl.SetTargetFPS(20)
// Main loop
for !rl.WindowShouldClose() {
+ frameCount++
// Begin to draw and set the background to black
rl.BeginDrawing()
rl.ClearBackground(rl.Black)
+ // Add new water every 5 frames (creates continuous water stream)
+ if frameCount%5 == 0 {
+ CreateWaterGenerator(flowStartX, flowStartY, game.tileSize, &game.State)
+ CreateWaterGenerator(flowStartX+1, flowStartY, game.tileSize, &game.State)
+ CreateWaterGenerator(flowStartX-1, flowStartY, game.tileSize, &game.State)
+ }
+Теперь, когда мы запускаем программу, мы ожидаем, что вода будет капать из одной точки в верхней части экрана.
go run .
С хорошей стороны — наши объекты Droplet действительно появляются постоянно. С плохой — они просто складываются друг на друга. В следующем разделе мы решим эту проблему, добавив боковой поток.
Код для этой части также доступен в репозитории на GitHub.
Боковой поток (Sideways)
Столбление происходит потому, что капли несжимаемы и больше не могут стекать вниз. Чтобы исправить это, обновим код так, чтобы вода могла перетекать в стороны.
Начнём с проверки, что в ячейке всё ещё есть вода. Если да, мы пытаемся направить её в соседние боковые ячейки.
func processWaterCell(x, y int, newState *[][]Droplet) {
// Try to flow downwards, as if by gravity
fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5)
+
+ // If all water flowed down, no need to continue
+ if (*newState)[y][x].volume == 0 {
+ return
+ }
+
+ // If water can still flow down, don't try other directions yet
+ if canFlowDown(x, y, newState) {
+ return
+ }
+
+ // Water spreads sideways when blocked below
+ tryHorizontalFlow(x, y, newState)
+
}Метод canFlowDown() проверяет, есть ли ещё пространство для стока вниз; если есть — выходим и обрабатываем остаток на следующем кадре.
Метод tryHorizontalFlow() распространяет воду влево и вправо. Он проверяет следующие три ячейки и добавляет в них часть оставшегося объёма. Это позволяет воде «устраиваться», когда она больше не может течь вниз.
func canFlowDown(x, y int, state *[][]Droplet) bool {
return y+1 < len(*state) && (*state)[y+1][x].volume < 1.0
}
func tryHorizontalFlow(x, y int, state *[][]Droplet) {
current := &(*state)[y][x]
// Only cascade if there's water below
hasWaterBelow := y+1 < len(*state) && (*state)[y+1][x].volume > 0.5
if !hasWaterBelow {
return
}
// Cascade right - distribute to multiple cells
for offset := 1; offset <= 3 && x+offset < len((*state)[y]); offset++ {
target := &(*state)[y][x+offset]
if target.volume < current.volume {
flowRate := (current.volume - target.volume) * 0.1 / float64(offset)
fill(current, target, 1.0, flowRate)
}
}
// Cascade left - distribute to multiple cells
for offset := 1; offset <= 3 && x-offset >= 0; offset++ {
target := &(*state)[y][x-offset]
if target.volume < current.volume {
flowRate := (current.volume - target.volume) * 0.1 / float64(offset)
fill(current, target, 1.0, flowRate)
}
}
}Теперь, когда мы запускаем симуляцию, мы ожидаем, что вода будет растекаться в стороны, а соседние ячейки будут получать некоторый процент от общего объёма.
go run .
Отлично, вода действительно течёт в стороны, растекается и заполняет нижнюю часть экрана.
Однако стобление всё ещё есть, и по краям эффект слишком «квадратный». Это происходит из-за того, что часть объёма остаётся в текущей ячейке после движения вниз и в стороны. Исправим это в следующем разделе.
Код для этого раздела можно найти в репозитории на GitHub.
Давление (диагональ)
Чтобы сделать поток воды более плавным, можно добавить дополнительную динамику давления. Поскольку вода несжимаема, нам нужно, чтобы она стекала по всем возможным нисходящим траекториям. Обновим функцию processWaterCell(), чтобы она пыталась выполнять диагональный перенос, если в ячейке всё ещё остаётся объём.
func processWaterCell(x, y int, newState *[][]Droplet) {
// Try to flow downwards, as if by gravity
fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5)
// If all water flowed down, no need to continue
if (*newState)[y][x].volume == 0 {
return
}
// If water can still flow down, don't try other directions yet
if canFlowDown(x, y, newState) {
return
}
// Water spreads sideways when blocked below
tryHorizontalFlow(x, y, newState)
if (*newState)[y][x].volume > 0 {
tryDiagonalFlow(x, y, newState)
}
}Мы добавляем функцию tryDiagonalFlow(), которая отвечает за диагональный переток. Она проверяет, что целевая ячейка находится в пределах сетки, и переносит объём, если там есть место.
func tryDiagonalFlow(x, y int, state *[][]Droplet) {
current := &(*state)[y][x]
// Flow diagonally down-right if space is available
if x+1 < len((*state)[y]) && y+1 < len(*state) && (*state)[y+1][x+1].volume < 1.0 {
fill(current, &(*state)[y+1][x+1], 1.0, 0.25)
}
// Flow diagonally down-left if space is available
if x > 0 && y+1 < len(*state) && (*state)[y+1][x-1].volume < 1.0 {
fill(current, &(*state)[y+1][x-1], 1.0, 0.25)
}
}Запустим программу. Мы ожидаем, что поток воды станет более плавным и менее «квадратным».
go run .
Отлично! Поток стал мягче, вода постепенно заполняет экран маленькими порциями.
Код для этого раздела можно найти в репозитории на GitHub.
Препятствия (Obstacles)
Сейчас вода просто падает вниз и растекается. Выглядит это не слишком впечатляюще.
Давайте обновим сцену и добавим препятствия, с которыми вода будет взаимодействовать. Она должна упираться в препятствия и переливаться через них, как настоящая вода.
Начнём с добавления в ячейку флага, обозначающего, что она является препятствием.
type Droplet struct {
volume float64 // How much water this cell contains (0.0 to 1.0)
size int
volume float64 // How much water this cell contains (0.0 to 1.0)
size int
isObstacle bool // Is this cell an obstacle?
}Мы обновили метод Draw(), чтобы препятствия отображались коричневым цветом вместо синего.
func (d *Droplet) Draw(x, y, tileSize int, hasWaterAbove bool) {
// Convert grid coordinates to pixel coordinates
pixelX := x * tileSize
pixelY := y * tileSize
if d.isObstacle {
// Draw obstacle as brown rectangle
rl.DrawRectangle(int32(pixelX), int32(pixelY), int32(tileSize), int32(tileSize), rl.Brown)
}
if d.volume > 0 {
// Calculate visual height based on water volume
// Full volume (1.0) = full tile height, half volume (0.5) = half height
height := int(float64(tileSize) * d.volume)
}Далее обновим логику коллизий в правилах, чтобы вода текла только в те ячейки, которые не являются препятствиями.
Начнём с функции canFlowDown:
func canFlowDown(x, y int, state *[][]Droplet) bool {
return y+1 < len(*state) && (*state)[y+1][x].volume < 1.0
return y+1 < len(*state) && (*state)[y+1][x].volume < 1.0 && !(*state)[y+1][x].isObstacle
}Затем — tryHorizontalFlow():
func tryHorizontalFlow(x, y int, state *[][]Droplet) {
current := &(*state)[y][x]
// Only cascade if there's water below
hasWaterBelow := y+1 < len(*state) && (*state)[y+1][x].volume > 0.5
if !hasWaterBelow {
return
}
// Cascade right - distribute to multiple cells
for offset := 1; offset <= 3 && x+offset < len((*state)[y]); offset++ {
target := &(*state)[y][x+offset]
if target.volume < current.volume {
if target.volume < current.volume && !target.isObstacle {
flowRate := (current.volume - target.volume) * 0.1 / float64(offset)
fill(current, target, 1.0, flowRate)
}
}
// Cascade left - distribute to multiple cells
for offset := 1; offset <= 3 && x-offset >= 0; offset++ {
target := &(*state)[y][x-offset]
if target.volume < current.volume {
if target.volume < current.volume && !target.isObstacle {
flowRate := (current.volume - target.volume) * 0.1 / float64(offset)
fill(current, target, 1.0, flowRate)
}
}
}И, наконец, tryDiagonalFlow():
func tryDiagonalFlow(x, y int, state *[][]Droplet) {
current := &(*state)[y][x]
// Flow diagonally down-right if space is available
if x+1 < len((*state)[y]) && y+1 < len(*state) && (*state)[y+1][x+1].volume < 1.0 {
if x+1 < len((*state)[y]) && y+1 < len(*state) && (*state)[y+1][x+1].volume < 1.0 && !(*state)[y+1][x+1].isObstacle {
fill(current, &(*state)[y+1][x+1], 1.0, 0.25)
}
// Flow diagonally down-left if space is available
if x > 0 && y+1 < len(*state) && (*state)[y+1][x-1].volume < 1.0 {
if x > 0 && y+1 < len(*state) && (*state)[y+1][x-1].volume < 1.0 && !(*state)[y+1][x-1].isObstacle {
fill(current, &(*state)[y+1][x-1], 1.0, 0.25)
}
}Также нужно обновить основную логику гравитации в функции processWaterCell():
func processWaterCell(x, y int, newState *[][]Droplet) {
// Try to flow downwards, as if by gravity
fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5)
// Try to flow downwards, as if by gravity (but not into obstacles)
if y+1 < len(*newState) && !(*newState)[y+1][x].isObstacle {
fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5)
}
}Готово!
Теперь создадим несколько вспомогательных функций для размещения препятствий на экране.
func CreateHorizontalObstacle(x, y, size int, state *[][]Droplet) {
for offset := range size {
(*state)[y][x+offset].isObstacle = true
(*state)[y+1][x+offset].isObstacle = true
(*state)[y+2][x+offset].isObstacle = true
}
}
func CreateVerticleObstacle(x, y, size int, state *[][]Droplet) {
for offset := range size {
(*state)[y+offset][x].isObstacle = true
(*state)[y+offset][x+1].isObstacle = true
(*state)[y+offset][x+2].isObstacle = true
}
}Раз уж мы занимаемся созданием объектов, упростим и генерацию воды, используя тот же способ циклического заполнения.
func CreateWaterGenerator(x, y, tileSize int, state *[][]Droplet) {
droplet := Droplet{size: tileSize, volume: 1.0}
(*state)[y][x] = droplet
for xOffset := 0; xOffset <= 4; xOffset++ {
droplet := Droplet{size: tileSize, volume: 1.0} (*state)[y][x+xOffset] = droplet
}
}Наконец, обновим функцию main(), чтобы добавить препятствия, блокирующие путь воды, и заодно убрать дублирование в коде генерации воды:
func main() {
// Setup the new game
var game = NewGame(800, 400, 10)
// Initialize Raylib graphics window
rl.InitWindow(int32(game.Width), int32(game.Height), "Water simulation")
defer rl.CloseWindow()
// Set up a counter, so we can spawn new water at a rate
frameCount := 0
- flowStartX := 100 / game.tileSize
+ flowStartX := 400 / game.tileSize
flowStartY := 100 / game.tileSize
CreateWaterGenerator(flowStartX, flowStartY, game.tileSize, &game.State)
+ CreateVerticleObstacle(30, 20, 10, &game.State)
+
+ CreateHorizontalObstacle(0, 30, 50, &game.State)
+ CreateHorizontalObstacle(40, 20, 40, &game.State)
+
// Setup the frame per second rate
rl.SetTargetFPS(20)
// Main loop
for !rl.WindowShouldClose() {
frameCount++
// Begin to draw and set the background to black
rl.BeginDrawing()
rl.ClearBackground(rl.Black)
// Add new water every 5 frames (creates continuous water stream)
- if frameCount%5 == 0 {
+ if frameCount%3 == 0 {
CreateWaterGenerator(flowStartX, flowStartY, game.tileSize, &game.State)
- CreateWaterGenerator(flowStartX+1, flowStartY, game.tileSize, &game.State)
- CreateWaterGenerator(flowStartX-1, flowStartY, game.tileSize, &game.State)
}Момент истины…
Запустим код и посмотрим, будет ли вода перетекать через препятствия так, как мы ожидаем:
go run .
Потрясающе: вода течёт вниз, пока не упирается в препятствие, затем растекается по его верхней части, пока не достигнет края экрана или следующего препятствия.
Однако при падении воды на другую воду стекание по-прежнему присутствует.
Наблюдение: это не совсем похоже на воду.
То, что мы построили, ведёт себя скорее как песок, а не как вода. Такой подход лёгкий и отлично подходит для игр, где нужны падающие частицы — песок, пыль, лава.
Вероятно, такую симуляцию стоило бы назвать Sand Simulation!

Код для этого раздела можно найти в репозитории на GitHub.
Этот метод отлично подходит для простой игры, где нужна физика песка. Например, для лавины из песка в сайд-скроллере, которая засыпает игрока.
Но реалистичная симуляция воды потребовала бы доработок. Нужно учитывать давление, скорость и импульс, чтобы получить естественное поведение жидкости.
Можно зайти ещё глубже: добавить поверхностное натяжение, вязкость и турбулентность. Полноценная симуляция должна решать уравнения Навье—Стокса, описывающие поведение потоков. Прекрасный пример — проект Себастиана Лаге Simulating Fluids.

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
В этом статье мы:
Создали простой симулятор воды/песка на базе raylib-go
Использовали набор простых правил для создания сложного поведения
Добавили препятствия, по которым вода переливается
Если материал оказался полезным, поставьте лайк или поделитесь! Также можете заглянуть в репозиторий на GitHub и попробовать изменить правила, чтобы получить эффекты лавы, дыма или песка.