Geometry Wars like Spring Grid
Inspiration
Somehow, I found myself watching Geometry Wars gameplay and was mesmerized by the fancy background grid.
It’s amazing how much the background enhances the impact / weight of certain attacks.
I felt like I could use something similar for my Circle Pushing game, or at the very least, I wanted to know how to create something similar even if I can’t find a use for it 😅
Research
In my research, I found this great tutorial. I recommend reading through it to understand the setup and perhaps delve deeper into some of the physics concepts.
TLDR: The grid is composed of points of mass connected by springs.
Implementation
I followed the tutorial and translated it into GDScript. In the process, I created three new scripts:
grid.gd
Initializes the grid:
- Creates and stores all
Springs
andPointMasses
. - Creates
Line2D
objects to connect eachPointMass
.
- Creates and stores all
Calls
spring.update()
andpoint.update()
in_process()
to calculate the physics.Updates the position of the sprite representing the
PointMass
and theLine2D
that connect everything.Provides methods to interact with the grid:
apply_directed_force()
apply_implosive_force()
apply_explosive_force()
Translates grid position to screen position with
to_vec_2()
.- By default, the grid space is per cell, so a 40 by 40 grid is
Vector2(40, 40)
.to_vec_2()
translates that to screen space.
- By default, the grid space is per cell, so a 40 by 40 grid is
Code
class_name Grid
extends Node
var size: Vector2
var spacing: Vector2
var springs: Array[Spring] = []
var points := []
var dots := []
var lines_vertical := []
var lines_horizontal := []
var dot := preload("res://stages/background/dot.tscn")
func _init(_size: Vector2, _spacing: Vector2) -> void:
size = _size
spacing = _spacing
var number_columns: int = size.x / spacing.x + 1
var number_rows: int = size.y / spacing.y + 1
var points_fixed := []
var counter_point_mass := 0
# Create the point masses
for row_i in size.x:
var new_line := Line2D.new()
new_line.width = 3.0
var new_horizontal_line := Line2D.new()
new_horizontal_line.width = 3.0
lines_vertical.push_back(new_line)
lines_horizontal.push_back(new_horizontal_line)
add_child(new_line)
for column_i in size.y:
add_child(new_horizontal_line)
if points.size() - 1 < row_i:
points.push_back([])
points[row_i].push_back(PointMass.new(Vector3(row_i, column_i, 0), 1, counter_point_mass))
if points_fixed.size() - 1 < row_i:
points_fixed.push_back([])
points_fixed[row_i].push_back(PointMass.new(Vector3(row_i, column_i, 0), 0, counter_point_mass))
if dots.size() - 1 < row_i:
dots.push_back([])
var new_dot := dot.instantiate()
new_dot.debug_text = "%s-%s" % [row_i, column_i]
dots[row_i].push_back(new_dot)
add_child(new_dot)
lines_vertical[row_i].add_point(Vector2.ZERO, column_i)
lines_horizontal[row_i].add_point(Vector2.ZERO, row_i)
counter_point_mass = counter_point_mass + 1
# Link the point masses with springs
for row_i in number_rows - 1:
for column_i in number_columns - 1:
const stiffness: float = 0.28
const damping: float = 0.06
if row_i == 0 or column_i == 0 or row_i == number_rows -1 or column_i == number_columns - 1: # anchor the border of the grid
springs.push_back(Spring.new(points_fixed[row_i][column_i], points[row_i][column_i], 0.1, 0.1))
elif row_i % 3 == 0 and column_i % 3 == 0:
springs.push_back(Spring.new(points_fixed[row_i][column_i], points[row_i][column_i], 0.002, 0.02))
if row_i > 0:
springs.push_back(Spring.new(points[row_i - 1][column_i], points[row_i][column_i], stiffness, damping))
if column_i > 0:
springs.push_back(Spring.new(points[row_i][column_i - 1], points[row_i][column_i], stiffness, damping))
handle_shader_data()
func _process(delta: float) -> void:
for spring in springs:
spring.update()
for row_i in points.size():
var row = points[row_i]
for column_i in row.size():
var mass = row[column_i]
mass.update()
dots[row_i][column_i].position = to_vec_2(mass.position)
lines_vertical[row_i].set_point_position(column_i, to_vec_2(mass.position))
lines_horizontal[column_i].set_point_position(row_i, to_vec_2(mass.position))
func to_vec_2(vec_3: Vector3) -> Vector2:
var factor: float = (vec_3.z + 2000) / 2000
var screen_size_half := get_viewport().get_visible_rect().size / 2.0
return ((Vector2(vec_3.x, vec_3.y) - screen_size_half) * factor + screen_size_half) * 60
func apply_directed_force(force: Vector3, position: Vector3, radius: float) -> void:
for row in points:
for mass in row:
if position.distance_squared_to(mass.position) < radius * radius:
mass.apply_force(10 * force / (10 + position.distance_to(mass.position)))
func apply_implosive_force(force: Vector3, position: Vector3, radius: float) -> void:
for row in points:
for mass in row:
var distance_squared := position.distance_squared_to(mass.position)
if distance_squared < radius * radius:
mass.apply_force(10 * force * (position - mass.position) / (100 + distance_squared))
mass.increase_damping(0.6)
func apply_explosive_force(force: Vector3, position: Vector3, radius: float) -> void:
for row in points:
for mass in row:
var distance_squared := position.distance_squared_to(mass.position)
if distance_squared < radius * radius:
mass.apply_force(100 * force * (mass.position - position) / (10000 + distance_squared))
mass.increase_damping(0.6)
poinMass.gd
- Represents a point of mass in the grid (🤯)
Code
class_name PointMass
extends RefCounted
var id: int
var position: Vector3
var velocity: Vector3
var inverse_mass: float
var acceleration: Vector3
var damping := 0.98
func _init(_position: Vector3, _inverse_mass: float, _id: int) -> void:
id = _id
position = _position
inverse_mass = _inverse_mass
func apply_force(force: Vector3) -> void:
acceleration = acceleration + force * inverse_mass
func increase_damping(factor: float) -> void:
damping = damping * factor
func update() -> void:
velocity = velocity + acceleration
position = position + velocity
acceleration = Vector3.ZERO
if is_zero_approx(velocity.length_squared()):
velocity = Vector3.ZERO
velocity = velocity * damping
damping = 0.98
spring.gd
- One
Spring
connects twoPointMass
objects. - In the
Spring
’supdate()
, Hooke’s law is applied, and the force is applied to each point.
Code
class_name Spring
extends RefCounted
var end_1: PointMass
var end_2: PointMass
var target_length: float
var stiffness: float
var damping: float
func _init(_end_1: PointMass, _end_2: PointMass, _stiffness: float, _damping: float) -> void:
end_1 = _end_1
end_2 = _end_2
stiffness = _stiffness
damping = _damping
# When we create a spring, we set the natural length of the spring
# to be just slightly less than the distance between the two end points.
# This keeps the grid taut even when at rest and improves the appearance somewhat.
target_length = _end_1.position.distance_to(end_2.position) * 0.95
func update() -> void:
var x := end_1.position - end_2.position
var length := x.length()
var dv: Vector3
var force: Vector3
if length <= target_length:
return
# https://en.wikipedia.org/wiki/Hooke's_law
x = (x / length) * (length - target_length)
dv = end_2.velocity - end_1.velocity
force = stiffness * x - dv * damping
end_1.apply_force(-force)
end_2.apply_force(force)
Now, add a new grid to a scene:
func _ready() -> void:
grid = Grid.new(Vector2(40, 40), Vector2(1, 1))
add_child(grid)
and apply some force to the grid to see the springs in action:
func _on_timer_timeout() -> void:
grid.apply_directed_force(Vector3(3.0, 2.0, 0.0), Vector3(10.0, 10.0, 0.0), 2.0)
The Result
It’s totally possible that I messed up something in the translation process. I didn’t spend too much time evaluating if the grid behaves as it should. The tutorial mentions additional steps to optimize the implementation. What’s apparent pretty quickly is that it’s a costly backdrop. The 40 by 40 grid and its resulting 1600 points drop the FPS to around 90 from 350.
Next Steps
This seems like a good point to start learning how to write compute shaders 😄 So, that’s exactly what I’m currently working on. I will try my best to move the simulation over to the GPU. I have already made some good progress but need a topic to write about next month, so we will have to wait and see if I can pull this off 👀
That’s it!
If you have any questions or feel offended by my bad code, find me on the Godot Modding Discord and ping me in #dev-general or on Twitter @KANAjetzt 👍