Lesson: graphics7.nim

← Back to all lessons

Run Code Download Code

Source Code

# ****************************************************************************************
#
#   raylib [graphics] lesson 7 - Particle Physics and Bouncing
#
#   This lesson combines particle systems with transformed polygons to create
#   a simple physics simulation.
#
#   It demonstrates:
#   - Applying gravity to particles.
#   - Detecting collisions between particles and rotated polygons.
#   - Calculating the surface normal of the polygon edge that was hit.
#   - Using vector reflection to make particles "bounce" realistically.
#   - Applying a damping factor to simulate energy loss on each bounce.
#
# ****************************************************************************************

import raylib
import raymath
import math
import random
import strformat
import rlgl # For matrix transformations
# from deques import Deque, addLast, popFirst, popLast, len, `[]`, items, `[]=`
import deques

const
  screenWidth = 1000
  screenHeight = 700

# --- Type Definitions ---

type
  Particle = object
    position: Vector2
    velocity: Vector2
    color: Color
    radius: float32
    bounces: int

  PolygonCollider = object
    position: Vector2
    vertices: seq[Vector2] # Model-space vertices
    rotation: float32
    color: Color

# --- Helper Procedures ---

# Calculates the closest point on a line segment (p1 to p2) to a given point (p).
proc getClosestPointOnSegment(p, p1, p2: Vector2): Vector2 =
  let edge = p2 - p1
  let lenSq = lengthSqr(edge)
  if lenSq == 0.0: return p1 # The segment is just a point.

  # Project the point onto the line defined by the segment.
  # The 't' value is how far along the line the projection is.
  # A value of 0.0 means the closest point is p1, 1.0 means p2.
  let t = dotProduct(p - p1, edge) / lenSq

  # Clamp 't' to the 0.0 to 1.0 range to stay on the segment.
  # If t was < 0 or > 1, the closest point would be off the segment.
  let clampedT = clamp(t, 0.0'f32, 1.0'f32)
  return p1 + edge * clampedT

proc main =
  initWindow(screenWidth, screenHeight, "raylib [graphics] lesson 7 - Particle Physics")
  setTargetFPS(60)
  randomize()

  var particles: Deque[Particle]
  let font = getFontDefault()

  # A list of colors for our particles to choose from.
  let colors = [Red, Orange, Yellow, Green, Blue, Purple, Pink]

  # LESSON 1: CREATING COLLIDERS
  # These are static polygons that our particles will bounce off of.
  # We define their shapes as simple rectangles in their own model space.
  var colliders = @[
    PolygonCollider(
      position: Vector2(x: screenWidth / 2, y: screenHeight - 50),
      vertices: @[
        Vector2(x: -400, y: -25), Vector2(x: 400, y: -25),
        Vector2(x: 400, y: 25), Vector2(x: -400, y: 25)
      ],
      rotation: 0.0,
      color: DarkGray
    ),
    PolygonCollider(
      position: Vector2(x: 200, y: 450),
      vertices: @[
        Vector2(x: -150, y: -15), Vector2(x: 150, y: -15),
        Vector2(x: 150, y: 15), Vector2(x: -150, y: 15)
      ],
      rotation: 25.0,
      color: DarkGray
    ),
    PolygonCollider(
      position: Vector2(x: 750, y: 350),
      vertices: @[
        Vector2(x: -100, y: -15), Vector2(x: 100, y: -15),
        Vector2(x: 100, y: 15), Vector2(x: -100, y: 15)
      ],
      rotation: -35.0,
      color: DarkGray
    )
  ]

  let emitterPos = Vector2(x: screenWidth / 2, y: 50)
  var frameCounter = 0
  var time: float32 = 0.0

  # Main game loop
  # --------------------------------------------------------------------------------------
  while not windowShouldClose():
    # Update
    # ----------------------------------------------------------------------------------
    let dt = getFrameTime()
    time += dt
    frameCounter += 1

    # --- Animate Colliders ---
    # Make the two side platforms oscillate using sin and cos for a see-saw effect.
    # The floor (index 0) remains static.
    colliders[1].rotation = 25.0 + sin(time * 0.8) * 20.0
    colliders[2].rotation = -35.0 + cos(time * 0.6) * 25.0

    # --- Particle Emitter ---
    # Emit a new particle every few frames.
    if frameCounter mod 4 == 0: # Emit a particle every 4th frame
      let newParticle = Particle(
        position: emitterPos,
        velocity: Vector2(x: rand(-200.0 .. 200.0), y: rand(-50.0 .. 50.0)),
        color: colors[rand(0 ..< colors.len)],
        radius: 3.0,
        bounces: 0
      )
      particles.addLast(newParticle)

    # --- Particle Update and Physics ---
    const gravity = 400.0
    const damping = 0.7 # Energy lost on bounce (1.0 = perfect bounce, 0.0 = no bounce)
    var i = 0
    while i < particles.len:
      let currentPos = particles[i].position
      # Apply gravity
      particles[i].velocity.y += gravity * dt
      # Update position
      let nextPos = currentPos + particles[i].velocity * dt
      
      var collidedThisFrame = false
      # LESSON 2: COLLISION DETECTION AND RESPONSE
      for collider in colliders:
        # We need the collider's vertices in World Space for the check.
        let rotationMatrix = rotateZ(degToRad(collider.rotation))
        let translationMatrix = translate(collider.position.x, collider.position.y, 0)
        let modelMatrix = multiply(rotationMatrix, translationMatrix)
        
        var worldVertices: seq[Vector2]
        for v in collider.vertices:
          worldVertices.add(transform(v, modelMatrix))

        # Check if the particle's NEXT position is inside the polygon. This helps
        # catch "tunneling" where a particle moves through an object in one frame.
        if checkCollisionPointPoly(nextPos, worldVertices):
          # Collision detected!
          particles[i].bounces += 1

          # Find the closest point on the polygon's boundary to the particle's CURRENT position.
          var closestPoint = worldVertices[0]
          var closestDistSq = float32.high
          for j in 0 ..< worldVertices.len:
            let p1 = worldVertices[j]
            let p2 = worldVertices[(j + 1) mod worldVertices.len]
            let pointOnEdge = getClosestPointOnSegment(currentPos, p1, p2)
            let distSq = distanceSqr(currentPos, pointOnEdge)
            if distSq < closestDistSq:
              closestDistSq = distSq
              closestPoint = pointOnEdge

          # The surface normal is the direction from the closest point to the particle's center.
          let surfaceNormal = normalize(currentPos - closestPoint)

          # LESSON 4: REFLECT THE VELOCITY
          particles[i].velocity = reflect(particles[i].velocity, surfaceNormal) * damping

          # Nudge the particle to be exactly on the surface to prevent it from getting stuck.
          # We place it at the point of impact for this frame.
          particles[i].position = closestPoint
          # We must break the inner loop to avoid multiple collision checks in one frame.
          collidedThisFrame = true
          break

      # If after checking all colliders, no collision occurred, then we can
      # safely move the particle to its next position.
      if not collidedThisFrame:
        particles[i].position = nextPos

      # Remove particles that fall off-screen or have bounced too many times.
      if particles[i].position.y > screenHeight + 20 or particles[i].bounces > 5:
        particles[i] = particles[particles.len - 1]
        particles.popLast()
      else:
        i += 1

    # Draw
    # ------------------------------------------------------------------------------------
    beginDrawing()
    clearBackground(Black)

    # --- Draw Colliders ---
    for collider in colliders:
      pushMatrix()
      translatef(collider.position.x, collider.position.y, 0)
      rotatef(collider.rotation, 0, 0, 1)
      # We draw the polygon manually to use our model-space vertices
      for i in 0 ..< collider.vertices.len:
        let p1 = collider.vertices[i]
        let p2 = collider.vertices[(i + 1) mod collider.vertices.len]
        drawLine(p1, p2, 2.0, LightGray)
      popMatrix()

    # --- Draw Particles ---
    for p in particles:
      drawCircle(p.position, p.radius, p.color)

    # --- Draw UI ---
    drawText(font, "A simple particle physics simulation", 
             Vector2(x: 20, y: 20), 20.0, 1.0, Gray)
    drawText(font, fmt"Particles: {particles.len}", 
             Vector2(x: 20, y: 50), 20.0, 1.0, Gray)
    drawCircle(emitterPos, 5.0, White)

    endDrawing()
    # ------------------------------------------------------------------------------------

  closeWindow()

main()

#[
Key takeaways from this lesson:

- Physics Simulation: The core loop of a physics simulation is:
  1. Apply forces (like gravity) to update velocity.
  2. Update position based on velocity.
  3. Check for collisions.
  4. Resolve collisions (like bouncing).

- Surface Normal: The "normal" is a vector that points perpendicular to a
  surface. It's essential for calculating how light and objects should bounce.

- Vector Reflection: The `reflect(vector, normal)` function is a powerful tool
  provided by `raymath`. It takes an incoming vector and a surface normal and
  calculates the resulting "bounce" vector.
]#