DEV Community

Cover image for Technical Showcase: Breakable Glass
Justin
Justin

Posted on

Technical Showcase: Breakable Glass

Half-Life 2 Inspired Glass Breaking Mechanic

Introduction

There's a node in the Half-Life 2 director's commentary that explains:

"When designing early levels where there are fewer mechanics in the player's toolkit, we found that it's just as engaging to spotlight new technical features instead, as we did here with breakable glass. For the shattering effect, glass was divided into a grid of small squares, allowing us to track which were intact or broken and to blow out larger sections once enough squares were no longer connected. Each square was rendered with a unique texture based on the status of its neighboring squares, creating the sharp, jagged edges between broken and intact glass. While breaking glass is now commonplace in games, it was something we hadn't seen much back when we were making Half-Life 2 and were incredibly happy with the level of fidelity and dynamism it brought to the game."

I've been amassing a small collection of game prototypes, or self-contained functionality demos, that I or others could use in future projects. I thought the glass breaking mechanic described here would be both versatile and engaging, so I decided to try implementing it myself.

Technical Overview

Since this is a prototype, we're going to be keeping the design deliberately minimal. We just need enough structure to start, and see what actually matters during implementation.

Requirements

We can extract a list of requirements just from the commentary node's description. We need:

  • A pane of glass subdivided into sections
  • Sections have 2 states, broken or intact
  • The player can change the state of each section from intact to broken
  • Larger sections will break themselves once enough squares are no longer connected

This list implies additional requirements, like player input and visual output, but we're going to limit the scope to just the glass pane system for now.

Design

Thankfully, the requirements list also gives us a pretty comprehensive design outline:

  1. Make a grid representing the glass pane, in which each element is a section of glass
  2. The value of each element is either Broken or Intact
  3. We need a function for transitioning each element from Intact to Broken
  4. This function also needs to check and enact the grid's connectivity rules, noting which cells are no longer connected to the frame of the window and transitioning them to Broken.
    1. Implement flood-fill algorithm to identify which clusters of intact cells are still connected to the window frame. This part is the core of the breaking functionality, so we'll cover it in more detail in the implementation section.

And after the core system has been implemented:

  1. Render each square with a unique texture based on the status of its neighbors
  2. Create a glue class responsible for translating player input to cell state changes

Implementation

GlassPane

The most obvious place to start is the 2D grid. I've always thought manipulating 2D arrays was annoying, so this time I'm going to make a reusable Grid class that simplifies the operations of initializing, setting, and getting grid elements. Now in all future projects, if I ever need 2D array functionality, I can reuse this class.

class_name Grid

var _cells:Array = []
var _width:int
var _height:int

func _init(w:int, h:int, initial_value:Variant) -> void:
    _width = w
    _height = h

    for x in _width:
        var list:Array[GridCell] = []
        _cells.append(list)
        for y in _height:
            list.append(GridCell.new(Vector2i(x,y),initial_value))


func get_width() -> int:
    return _width


func get_height() -> int:
    return _height


func get_cellv(pos:Vector2i) -> GridCell:
    return get_cell(pos.x, pos.y)


func get_cell(x:int, y:int) -> GridCell:
    if x < 0 or x >= _width or y < 0 or y >= _height:
        return null
    return _cells[x][y]

...
Enter fullscreen mode Exit fullscreen mode

Each glass square/element of the grid has 2 states: broken or intact. You could use a bool, but given the potential eventuality a third state might arise (Weakened? Reinforced?), an Enum would be more future-proof.

enum GLASS_STATE {BROKEN, INTACT}
Enter fullscreen mode Exit fullscreen mode

Now we can make a GlassPane class that manages this grid and the breaking/state transition functionality.

class_name GlassPane

var grid:Grid
enum GLASS_STATE {BROKEN, INTACT}

signal state_changed


func _init(width:int, height) -> void:
    grid = Grid.new(width, height, GLASS_STATE.INTACT)


func break_piece(x:int, y:int) -> void:
    pass
Enter fullscreen mode Exit fullscreen mode

Changing a piece of glass from intact to broken just involves setting the Enum value at its grid position.

grid.set_cell(x, y, GLASS_STATE.BROKEN)
Enter fullscreen mode Exit fullscreen mode

Now, this is where things get technical. If an "island" of glass, a group of adjacent pieces, isn't anchored to something solid like the window's frame, we would expect it to fall and shatter.

Flood fill is an algorithm "that determines the area connected to a given node in a multi-dimensional array with some matching attribute." In our case, it allows us to mark all the intact pieces that are and aren't connected to an edge piece.

  1. Get all the frame/edge cells
  2. Apply flood-fill algorithm
    1. Initialize a grid to track visited cells
    2. Add all frame cells to to_visit list (frame cells aren't always connected to each other, so we need to visit each one individually)
    3. While to_visit isn't empty,
      1. Pop the current cell from the front of to_visit
      2. Set the cell as visited on the visitation grid
      3. Check all 4 of the cells neighbors
        1. If the neighbor is intact and unvisited, add it to to_visit
const VISITED = true
var visitation_grid = Grid.new(grid.get_width(), grid.get_height(), false)
var to_visit = _get_frame_cells()

while !to_visit.is_empty():
    var current = to_visit.pop_front()
    visitation_grid.set_cellv(current, VISITED)

    # Check all 4 neighbors
    for direction in Grid.neighbor_directions:
        var neighbor_pos = current + direction
        var neighbor_cell = grid.get_cellv(neighbor_pos)
        var visited_cell = visitation_grid.get_cellv(neighbor_pos)

        # If neighbor exists, is intact, and hasn't been visited yet
        if neighbor_cell != null and neighbor_cell.value == GLASS_STATE.INTACT and visited_cell != null and visited_cell.value == false:
            to_visit.append(neighbor_pos)
Enter fullscreen mode Exit fullscreen mode

After we apply flood fill, we traverse the visitation grid and break (change the state of) all the pieces that were unvisited and are thus not connected to the window frame. Finally, we leverage Godot's observer pattern and emit a signal indicating the state of the entire window pane was changed.

# Break all intact cells that weren't visited (islands)
for cell in grid.get_all_cells():
    var visited_cell = visitation_grid.get_cellv(cell.pos)
    if cell.value == GLASS_STATE.INTACT and visited_cell.value == false:
        grid.set_cell(cell.pos.x, cell.pos.y, GLASS_STATE.BROKEN)
emit_signal("state_changed")
Enter fullscreen mode Exit fullscreen mode

And now we have a grid with cells that can be switched from intact to broken with automatic shattering behavior. But it'd be difficult to appreciate this in a game without being able to see it, so lets make it 3D.

Visuals

To visualize the window in 3D, we need a class that mediates between the GlassPane class and grid of textures. Revisiting the earlier design points, we need to align the sprite squares in a grid to match the GlassPane grid, and render each square with a unique texture based on the status of its neighbors.

Sprite Grid

Aligning squares in a grid is pretty simple:

for x in grid_width:
    for y in grid_height:
        sprite.position = Vector3(x * sprite_size, y * sprite_size, 0)
Enter fullscreen mode Exit fullscreen mode

Finding the sprite size gets a little complicated because we need to consider the resolution of the texture png, the sprite to meter ratio, and the density of sprites to meters.

In the Godot Engine, pixels are 0.01 meters, and I arbitrarily chose to make each png 8x8px (to make drawing 16 of them easier). Therefore, the default sprite/meter density is:

meters / sprite_resolution = 1m / 8px = 12.5 pixels per meter
Enter fullscreen mode Exit fullscreen mode

But using an export variable, we can change the window to any desired density. So, to calculate the sprite size based on density:

sprite_size = 1 / sprites_per_meter
Enter fullscreen mode Exit fullscreen mode

And then add that to our alignment loop:

for x in grid_width:
    for y in grid_height:
        sprite.pixel_size = sprite_size / SPRITE_PIXELS
        sprite.position = Vector3(x * sprite_size, y * sprite_size, 0)
Enter fullscreen mode Exit fullscreen mode

Neighbor-Based Texturing

Now for the neighbor status tracking. Each square has 4 neighbors, each with 2 states (broken or intact), so we have 16 total combinations of neighbor status.

Combination Top Right Bottom Left
0 Broken Broken Broken Broken
1 Broken Broken Broken Intact
2 Broken Broken Intact Broken
3 Broken Broken Intact Intact
4 Broken Intact Broken Broken
5 Broken Intact Broken Intact
6 Broken Intact Intact Broken
7 Broken Intact Intact Intact
8 Intact Broken Broken Broken
9 Intact Broken Broken Intact
10 Intact Broken Intact Broken
11 Intact Broken Intact Intact
12 Intact Intact Broken Broken
13 Intact Intact Broken Intact
14 Intact Intact Intact Broken
15 Intact Intact Intact Intact

The most convenient way to track the combination of each texture is a bitmask.

var dir = {
    Vector2i.UP: 1,
    Vector2i.DOWN: 2,
    Vector2i.LEFT: 4,
    Vector2i.RIGHT: 8,
}
Enter fullscreen mode Exit fullscreen mode

The bitmask value represents the intact-ness of the cell's neighbors - a value of 15 would imply that the up, down, left and right (1 + 2 + 4 + 8 = 15) neighbors are all intact, so we'd want sprite 15 to be unbroken glass. A value of 10 would mean that right and bottom neighbors are intact while the left and top neighbors are broken, so it should look diagonally jagged.

Also worth noting that the bitmask isn't actually a bit, its an int. This is purely for convenience; it makes it easier to keep track of the state value while debugging, makes the math operations easier, and it makes it easier to access the file path. I intend on making it an actual bitmask in the future.

Then we need to actually make the broken glass textures. I use Paint.net.

Texture Folder

Not sure why I went with pink, but it provides nice contrast so I stuck with it.

Since the state value is an int, retrieving the corresponding texture is as simple as casting it to a string and inserting it into the texture file path.

func _texture(flag:int) -> Texture:
    return load("res://Textures/" + str(flag) + ".png")
Enter fullscreen mode Exit fullscreen mode

To update the state, we visit each of the neighbors. If the neighbor is broken, we subtract that direction value from the bitmask. After all the neighbors have been visited, we retrieve the new texture.

for direction in Grid.neighbor_directions:
    var neighbor_pos = cell.pos + direction
    # Check if neighbor is out of bounds or broken
    var neighbor_cell = pane.grid.get_cellv(neighbor_pos)
    # If there's no neighbor, its not broken on that side, its just the edge of the window
    if neighbor_cell == null:
        continue
    if neighbor_cell.value == GlassPane.GLASS_STATE.BROKEN:
        flag -= dir[direction]

var sprite = sprite_grid.get_cellv(cell.pos).value
sprite.texture = _texture(flag)
Enter fullscreen mode Exit fullscreen mode

To know when we need to update the textures, we connect the GlassPane signal from earlier:

pane.state_changed.connect(_state_changed)
Enter fullscreen mode Exit fullscreen mode

As mentioned before, the GlassPane class doesn't emit signals for individual tile changes, it only signals that the state of the grid has changed. Therefore, even if only a single piece of glass is broken, we have to update the entire grid of sprites. This may be inefficient for our worst case (only one piece is changed), but is more performant for the average case (multiple pieces are broken are in the same frame). If an island of glass is broken, several pieces break in the same frame. If we were to update each sprite as the island was breaking, we'd end up redundantly visiting and updating the states of multiple sprites that are about to be broken in the same frame anyway.

Player Input

Comparing this approach to the MVC pattern, we have the model and view classes, and just need a way for a character controller to interface with the GlassPane. At its most basic, the controller needs to be something that calls:

func break_piece(x:int, y:int) -> void:
Enter fullscreen mode Exit fullscreen mode

on a GlassPane object. Since I originally pulled this idea from Half-Life 2, I decided to go with a basic first person controller. I pulled the movement functionality from a previous project, and added an un-reusable glue class that mediates between the player's input, look position, and glass pane visual. It attaches to a RayCast3D that's a child of the player camera, and converts the global position to a glass grid position.

func break_glass(global_pos: Vector3) -> void:
    var local_pos = to_local(global_pos)

    var sprite_size = get_sprite_size()
    var grid_x = int(floor((local_pos.x + sprite_size * 0.5) / sprite_size))
    var grid_y = int(floor((local_pos.y + sprite_size * 0.5) / sprite_size))

    if grid_x < 0 or grid_x >= get_grid_width() or grid_y < 0 or grid_y >= get_grid_height():
        print("Position out of bounds: ", grid_x, ", ", grid_y)
        return

    pane.break_piece(grid_x, grid_y)
Enter fullscreen mode Exit fullscreen mode

Putting it all together

To see if this all works, we set up a quick 3D scene, complete with our visual, player character, and some other presentation elements, like a very robust and rustic looking wall with a frame to fit a window in.

Basic 3D Scene

While looking through the Quixel marketplace for the wall asset, I got distracted by the material library. I thought the denim material was cool but realized I'd probably never have a real reason to use it, so I decided to add a denim cube to our scene.

The one. The only. Denim cube

Running the scene and using LeftMouse while looking at the window gives us this result:

Gif showing that the upper neighbor is broken at the top and the bottom neighbor is broken at the bottom, the opposite of how it should look

Not quite right! We would expect the upper neighbor to be broken on the bottom and the bottom neighbor to be broken on the top, but it appears to be the other way around. Thankfully, this is just a peculiarity in Godot's coordinate space. Vector2i.UP is actually Vector2i(0, -1), because in Godot's 2D space, -y is up and +y is down. But since we're in 3D space, -y is down and +y is up, so we need to switch the values in the dictionary. I thought keeping the constant names while swapping the ints was a bit confusing:

    Vector2i.UP: 2,
    Vector2i.DOWN: 1,
Enter fullscreen mode Exit fullscreen mode

So I decided to just write out the explicit values instead.

var dir = {
    Vector2i(0, 1): 1, # Flipped up and down for 3D space
    Vector2i(0, -1): 2,
    Vector2i.LEFT: 4,
    Vector2i.RIGHT: 8,
}
Enter fullscreen mode Exit fullscreen mode

Checking again:

Broken glass looks correct this time

Much better! Now lets check the island-shattering functionality (aiming without a reticle is harder than you might think):

Breaking a circle in the window causes the center pieces to break

And lastly, highlighting that pieces still attached to the frame don't shatter:

Breaking a circle around a piece still connected to the edge doesn't cause it to break

Reflection/Conclusion

While I'm happy with the way this came out, there's always room for improvement.

In future iterations, I want to emphasize performance. I think preoptimizing is bad and always try to avoid it, but given that this is supposed to an "oh, neat" feature that probably won't be consciously recognized by most players, its performance footprint needs to be as minimal as possible. I think redoing it in C# or even C++ would provide the biggest benefit. Outside of that, the visual refresh system still bothers me. I think I made the right choice by having all sprites update at once instead of individually to avoid redundant checks, but I still think there's got to be a better way to do it, so I hope to revisit it in the future.

That said, revisiting the sprite system might not be necessary because I'd also like to make the visuals more dynamic and procedural. The marching squares algorithm is something I came across while making this, and I think it would fit this system well. Or just skip that entirely and go straight to a physics simulation. A lot of avenues to explore!

Another improvement would involve moving from flood search to a more sophisticated connectivity system. In its current state, an entire window could be supported in air by a tiny piece of glass, as long as its connected to a frame. Figuring out a weight/strength comparison system could definitely lend some realism to these windows.

Overall, I'd say this was a good exercise in software development principles and problem solving. And I got a very versatile Grid class out of it that I intend to use in future projects. And a cool wall. And some denim.

Thank you for reading!

Top comments (0)