Lesson: vectors9.nim

← Back to all lessons

Run Code Download Code

Source Code

# ****************************************************************************************
#
#   raylib [vectors] lesson 9 - The Unit Circle
#
#   This lesson demonstrates:
#   - The direct relationship between a point rotating on a circle and the
#     sine and cosine functions.
#   - How sin(x) corresponds to the Y-coordinate of the point.
#   - How cos(x) corresponds to the X-coordinate of the point.
#   - Animating a value over time to generate the graphs.
#
# ****************************************************************************************

import raylib, raymath
import math
import strformat
from deques import Deque, addLast, popFirst, len, `[]`

const
  screenWidth = 1000
  screenHeight = 600
  maxWavePoints = 500

proc main =
  initWindow(screenWidth, screenHeight, "raylib [vectors] lesson 9 - The Unit Circle")
  setTargetFPS(60)

  # --- Visualization Setup ---
  let 
    circleCenter = Vector2(x: 800, y: screenHeight / 2)
    circleRadius = 100.0

  # Both waves will share a single origin point
  let 
    graphOrigin = Vector2(x: 450, y: screenHeight / 2)
    graphScale = Vector2(x: 50.0, y: 100.0) # x-scale, y-scale (amplitude)

  # We use a Deque (Double-Ended Queue) to efficiently store the wave points.
  # It lets us add to one end and remove from the other in constant time.
  var 
    sinWavePoints: Deque[Vector2]
    cosWavePoints: Deque[Vector2]

  var 
    angle = 0.0'f32
    prevAngle = 0.0'f32
    isPaused = false

  # We'll update this text less frequently to make it readable.
  var 
    frameCounter = 0
    continuousAngleText = fmt"Continuous Angle (for graph): {angle / PI:.2f}pi rad"
  # Use `mod` from the math module for float modulo.
  let wrappedAngleRad = math.mod(angle, TAU)
  var wrappedAngleText =
    fmt"Effective Angle (for circle): {wrappedAngleRad * 360.0 / TAU:.1f}°"

  let font = getFontDefault()

  # Main game loop
  # --------------------------------------------------------------------------------------
  while not windowShouldClose():
    # Update
    # ----------------------------------------------------------------------------------
    if isKeyPressed(Space): # Manual pause/unpause has priority
      isPaused = not isPaused
      if not isPaused:
        # When we manually unpause, immediately advance the angle
        # to prevent the auto-pause from re-triggering on the same frame.
        prevAngle = angle
        angle += 0.03
    elif not isPaused: # Only run animation logic if not manually paused
      prevAngle = angle
      angle += 0.03 # Increment the angle each frame to animate

      # --- Auto-pause logic ---
      # Check if we crossed a 45-degree (PI/4) boundary
      let boundary = PI / 4.0
      let prevStep = floor(prevAngle / boundary)
      let currentStep = floor(angle / boundary)

      if currentStep > prevStep:
        # We crossed a boundary! Pause the animation.
        isPaused = true
        # Snap the angle to the boundary for perfect alignment
        angle = currentStep * boundary

      # Calculate current sin and cos values
      let sinValue = sin(angle)
      let cosValue = cos(angle)

      # Add new points to our wave history
      sinWavePoints.addLast(Vector2(x: angle, y: sinValue))
      cosWavePoints.addLast(Vector2(x: angle, y: cosValue))

      # Prune the history if it gets too long
      if sinWavePoints.len() > maxWavePoints:
        sinWavePoints.popFirst()
      if cosWavePoints.len() > maxWavePoints:
        cosWavePoints.popFirst()

    # Calculate the position of the point on the unit circle
    let pointOnCircle = Vector2(
      x: circleCenter.x + cos(angle) * circleRadius,
      y: circleCenter.y - sin(angle) * circleRadius # Y is inverted
    )

    # Update the angle text every 6 frames (10 times per second at 60 FPS)
    frameCounter += 1
    if frameCounter mod 6 == 0:
      continuousAngleText = fmt"Continuous Angle (for graph): {angle / PI:.2f}pi rad"
      # Use `mod` from math module for float modulo
      let wrappedAngleRad = math.mod(angle, TAU)
      wrappedAngleText =
        fmt"Effective Angle (for circle): {wrappedAngleRad * 360.0 / TAU:.1f}°"

    # Draw
    # ------------------------------------------------------------------------------------
    beginDrawing()
    clearBackground(RayWhite)

    # --- Draw UI and Explanations ---
    drawText(font, "The Unit Circle and Trigonometry",
             Vector2(x: 20, y: 20), 30.0, 1.0, DarkGray)
    if isPaused:
      drawText(font, "Press [Space] to resume",
               Vector2(x: 20, y: screenHeight - 40), 20.0, 1.0, DarkGray)

      # When paused, show the exact numerical values
      # TAU is a constant equal to 2*PI, representing a full circle in radians.
      # It's often clearer than writing 2*PI everywhere.
      let angleDeg = math.mod(angle, TAU) * (360.0 / TAU)
      let sinVal = sin(angle)
      let cosVal = cos(angle)

      # Helper to format the values intelligently
      proc formatTrigValue(val: float32): string =
        const epsilon = 1e-6
        let roundedVal = round(val)
        # Check if the value is very close to a whole number
        if abs(val - roundedVal) < epsilon:
          if roundedVal == 0.0: return "0" # Explicitly handle zero to avoid "-0"
          return fmt"{roundedVal:.0f}"
        else:
          return fmt"{val:.3f}"

      let sinValText = fmt"sin({angleDeg:.0f}°) = {formatTrigValue(sinVal)}"
      let cosValText = fmt"cos({angleDeg:.0f}°) = {formatTrigValue(cosVal)}"
      drawText(font, sinValText,
               Vector2(x: graphOrigin.x - 200, y: graphOrigin.y + 100), 20.0, 1.0, Red)
      drawText(font, cosValText,
               Vector2(x: graphOrigin.x - 200, y: graphOrigin.y + 125), 20.0, 1.0, Blue)
    else:
      drawText(font, "Press [Space] to pause",
               Vector2(x: 20, y: screenHeight - 40), 20.0, 1.0, LightGray)
    drawText(font, wrappedAngleText,
             Vector2(x: circleCenter.x - 320, y: circleCenter.y + circleRadius + 20),
             20.0, 1.0, Black)
    drawText(font, continuousAngleText,
             Vector2(x: circleCenter.x - 320, y: circleCenter.y + circleRadius + 45),
             20.0, 1.0, Gray)

    # --- Draw Unit Circle Visualization (Left) ---
    drawCircleLines(circleCenter, circleRadius, LightGray)
    let xAxisStart = Vector2(x: circleCenter.x - circleRadius - 20, y: circleCenter.y)
    let xAxisEnd = Vector2(x: circleCenter.x + circleRadius + 20, y: circleCenter.y)
    drawLine(xAxisStart, xAxisEnd, LightGray) # X-axis
    let yAxisStart = Vector2(x: circleCenter.x, y: circleCenter.y - circleRadius - 20)
    let yAxisEnd = Vector2(x: circleCenter.x, y: circleCenter.y + circleRadius + 20)
    drawLine(yAxisStart, yAxisEnd, LightGray) # Y-axis

    # Draw the rotating radius line
    drawLine(circleCenter, pointOnCircle, 2.0, Black)
    drawCircle(pointOnCircle, 8.0, Black)

    # --- Draw Combined Graph Visualization ---
    drawText(font, "y = sin(angle)",
             Vector2(x: graphOrigin.x - 200, y: graphOrigin.y - 150), 20.0, 1.0, Red)
    drawText(font, "y = cos(angle)",
             Vector2(x: graphOrigin.x - 200, y: graphOrigin.y - 125), 20.0, 1.0, Blue)

    # Draw the -1, 0, and 1 horizontal lines
    let yZero = graphOrigin.y
    let yPlusOne = graphOrigin.y - 1 * graphScale.y
    let yMinusOne = graphOrigin.y + 1 * graphScale.y
    let lineStartX = graphOrigin.x - maxWavePoints
    let lineEndX = graphOrigin.x + maxWavePoints
    drawLine(Vector2(x: lineStartX, y: yZero), Vector2(x: lineEndX, y: yZero), LightGray)
    drawLine(Vector2(x: lineStartX, y: yPlusOne), Vector2(x: lineEndX, y: yPlusOne),
             colorAlpha(LightGray, 0.5))
    drawLine(Vector2(x: lineStartX, y: yMinusOne), Vector2(x: lineEndX, y: yMinusOne),
             colorAlpha(LightGray, 0.5))

    # Add labels for the horizontal lines
    let labelX = circleCenter.x + circleRadius + 20
    drawText(font, "1", Vector2(x: labelX, y: yPlusOne - 10), 20.0, 1.0, Gray)
    drawText(font, "0", Vector2(x: labelX, y: yZero - 10), 20.0, 1.0, Gray)
    drawText(font, "-1", Vector2(x: labelX, y: yMinusOne - 10), 20.0, 1.0, Gray)

    # Draw Sine Wave
    for i in 0 ..< sinWavePoints.len() - 1:
      # Draw the wave relative to the current angle, so it scrolls left.
      let p1 = Vector2(
        x: graphOrigin.x + (sinWavePoints[i].x - angle) * graphScale.x, 
        y: graphOrigin.y - sinWavePoints[i].y * graphScale.y)
      let p2 = Vector2(
        x: graphOrigin.x + (sinWavePoints[i+1].x - angle) * graphScale.x, 
        y: graphOrigin.y - sinWavePoints[i+1].y * graphScale.y)
      drawLine(p1, p2, 2.0, Red)

    # Draw Cosine Wave
    for i in 0 ..< cosWavePoints.len() - 1:
      let p1 = Vector2(
        x: graphOrigin.x + (cosWavePoints[i].x - angle) * graphScale.x, 
        y: graphOrigin.y - cosWavePoints[i].y * graphScale.y)
      let p2 = Vector2(
        x: graphOrigin.x + (cosWavePoints[i+1].x - angle) * graphScale.x, 
        y: graphOrigin.y - cosWavePoints[i+1].y * graphScale.y)
      drawLine(p1, p2, 2.0, Blue)

    # --- Draw the Connecting Lines ---
    # Line from circle's Y to the start of the sine wave
    let sinStartPoint =
      Vector2(x: graphOrigin.x, y: graphOrigin.y - sin(angle) * graphScale.y)
    drawLine(Vector2(x: pointOnCircle.x, y: pointOnCircle.y),
             Vector2(x: sinStartPoint.x, y: pointOnCircle.y), 2.0, colorAlpha(Red, 0.5))
    drawLine(Vector2(x: sinStartPoint.x, y: pointOnCircle.y),
             sinStartPoint, 2.0, colorAlpha(Red, 0.5))
    drawCircle(sinStartPoint, 5.0, Red)

    # Line from circle's X to the start of the cosine wave
    let cosStartPoint =
      Vector2(x: graphOrigin.x, y: graphOrigin.y - cos(angle) * graphScale.y)
    drawLine(Vector2(x: pointOnCircle.x, y: pointOnCircle.y),
             Vector2(x: pointOnCircle.x, y: cosStartPoint.y), 2.0, colorAlpha(Blue, 0.5))
    drawLine(Vector2(x: pointOnCircle.x, y: cosStartPoint.y),
             cosStartPoint, 2.0, colorAlpha(Blue, 0.5))
    drawCircle(cosStartPoint, 5.0, Blue)

    # --- Draw the triangle inside the circle and label the sides ---
    let projectionPoint = Vector2(x: pointOnCircle.x, y: circleCenter.y)
    # Draw the vertical "sin" side
    drawLine(pointOnCircle, projectionPoint, 2.0, Red)
    drawText(font, "sin",
             Vector2(x: pointOnCircle.x + 10,
                     y: circleCenter.y + (pointOnCircle.y - circleCenter.y)/2),
             20.0, 1.0, Red)
    # Draw the horizontal "cos" side
    drawLine(circleCenter, projectionPoint, 2.0, Blue)
    drawText(font, "cos",
             Vector2(x: circleCenter.x + (pointOnCircle.x - circleCenter.x)/2,
                     y: circleCenter.y + 10),
             20.0, 1.0, Blue)

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

  # De-Initialization
  # --------------------------------------------------------------------------------------
  closeWindow()
  # --------------------------------------------------------------------------------------

main()

#[
Key takeaways from this lesson:

- Sine and Cosine are geometric functions. They directly describe the coordinates
  of a point on a circle of radius 1 as it rotates.
- sin(angle) is the Y-coordinate.
- cos(angle) is the X-coordinate.
- The wave shape is what you get when you "unroll" the circle's motion over time.
- The `deques` module is very useful for managing a fixed-size list of historical
  data, like the points of our wave, because adding to one end and removing from
  the other is very fast.
]#