a Physics Engine in Janet Part 1: Vectors

Introduction

I've had a specific game idea in my head for years. It's taken many forms, but one thing that has been consistent is its need for a stable 2d rope simulation. It turns out that simulating ropes well requires a pretty complete physics simulation! I finally feel like I have the skills and motivation to tackle this head on.

The goal is to have a couple posts covering these broad topics (which I know I need but don't fully understand yet 😅).

  1. Vectors (this post)
  2. AABB and Circle Collision
  3. Impulse Resolution
  4. Broad vs Narrow phase
  5. Joint & Rope Constraints

I've come across enough blogs and tutorials with broken examples, so I'll make a point to have completely working code written in Janet that you can run in TIC-80. Also, this will by no means be the fastest physics engine out there. Again my target is TIC-80, so I'm going for simple 2d scenes that follow good algorithms.

With that out of the way, let's get started!

Vectors

Vectors are geometric representations of magnitude and direction and can be expressed as arrows in two or three dimensions.
phys.libretexts.org

Vectors are one of the building blocks of physics. We'll be using 2d vectors to represent Position & Velocity of our objects (along with multiple other things). I've decided to use a Tuple to represent vectors, like this [x y]. They're easy to deconstruct, play nice with Janet’s splice special form, and as you'll see in minute make it easy for us to use a macro to get some nice ergonomics.

First, let's write a function that answers whether or not something is a vector. A vector will be a "indexable" of length 2, where both elements are numbers. Indexable means array or tuple, the only difference being immutability which we won't worry about.

(defn vector? [v]
  (and (indexed? v)
	(= (length v) 2)
	(all number? v)))

Easy.

Now let's get clever. Vectors can be added, subtracted, multiplied together, and divided by applying the operation across the X and Y "columns". Also, its super common to want to add/subtract/etc a number to a vector, applying it to both x and y coordinates. Wouldn't it be great if we could use the existing + - * and / functions in the Janet standard library? Welp inspired by this discussion in the Janet github repo, let's do just that!

(defmacro vectorize [fn]
  (let [original-fn (symbol :original/ fn)]
	~(upscope
	# save the original function under a new name
	(def ,original-fn ,fn)

	# now redefine the function
	(defn ,(symbol fn) [& args]
     # Use reduce to accumulate all the arguments with the function.
     # There are 4 possible situations to consider.
     # The comments below will use + as a standing for the provided function.
     (reduce2
	|(cond
	   # [x1 y1] + [x2 y2] => [(x1 + x2) (y1 + y2)]
	   (and (vector? $0) (vector? $1))
	   (tuple ;(map ,original-fn $0 $1))

	   # [x y] + n => [(x + n) (y + n)]
	   (and (vector? $0) (number? $1))
	   (tuple ;(map (fn [v] (,original-fn v $1)) $0))

	   # n + [x y]  => [(x + n) (y + n)]
	   (and (number? $0) (vector? $1))
	   (tuple ;(map (fn [v] (,original-fn v $0)) $1))

	   # n1 + n2  => n1 + n2
	   (and (number? $0) (number? $1))
	   (,original-fn $0 $1))
	args)))))

(vectorize +)
(vectorize -)
(vectorize /)
(vectorize *)

This macro allows us to treat vectors like numbers, and do stuff like this

(+ 1 2 [1 1] [2 3] 1) # => [7 8]
(- (* [1 1] 4) [2 4]) # => [2 0]

Next, let's add some other vector functions we'll definitely need. I won't explain each of them, but the names I used are pretty standard so a search online should be able to fill in any gaps.

(defn vec/clone [v] [;v])
(defn vec/length2 [v] (sum (map |(math/pow $ 2) v)))
(defn vec/length [v] (math/sqrt (vec/length2 v)))
(defn vec/distance2 [v1 v2] (vec/length2 (- v1 v2)))
(defn vec/distance [v1 v2] (math/sqrt (vec/distance2 v1 v2)))
(defn vec/angle [v] (math/atan2 (v 0) (v 1)))
(defn vec/angle-to [v1 v2] (vec/angle (- v1 v2)))
(defn vec/dot-product [v1 v2] (sum (* v1 v2)))

(defn vec/trim [v max-length]
  "Vector that points in same direction as v, but has length <= max-length"
  (when-let [len2 (vec/length2 v)
	non-zero? (> len2 0)
	s (* max-length (/ max-length len2))
	s (if (>= s 1) 1 (math/sqrt s))]
	(* v s)))

(defn vec/normalize [v]
  "Vector that points in same direction as v, but has length of 1"
  (if-let [v-len (vec/length v)
	greater-then-zero? (> v-len 0)]
	(/ v v-len)
	v))

The last thing we will write are 2 common init functions. One with sane default x & y values

(defn vec/init [&opt x y]
  [(default x 0) (default y x)])

and one that lets you define a vector based on an angle (in radians) and a radius (or length)

(defn vec/from-polar [angle &opt radius]
  (default radius 1)
  (vec/init (* (math/cos angle) radius)
	(* (math/sin angle) radius)))
Heres the complete (comments removed) code for your copy/paste pleasures
# 2D Vector, represented as a tuple [x y]
(defn vector? [v]
  (and (indexed? v)
	(= (length v) 2)
	(all number? v)))

(defmacro vectorize [fn]
  (let [original-fn (symbol :original/ fn)]
	~(upscope
	(def ,original-fn ,fn)
	(defn ,(symbol fn) [& args]
     (reduce2
	|(cond (and (vector? $0) (vector? $1))
	   (tuple ;(map ,original-fn $0 $1))

	   (and (vector? $0) (number? $1))
	   (tuple ;(map (fn [v] (,original-fn v $1)) $0))

	   (and (number? $0) (vector? $1))
	   (tuple ;(map (fn [v] (,original-fn v $0)) $1))

	   (and (number? $0) (number? $1))
	   (,original-fn $0 $1))
	args)))))

(vectorize +)
(vectorize -)
(vectorize /)
(vectorize *)

(defn vec/clone [v] [;v])
(defn vec/length2 [v] (sum (map |(math/pow $ 2) v)))
(defn vec/length [v] (math/sqrt (vec/length2 v)))
(defn vec/distance2 [v1 v2] (vec/length2 (- v1 v2)))
(defn vec/distance [v1 v2] (math/sqrt (vec/distance2 v1 v2)))
(defn vec/angle [v] (math/atan2 (v 0) (v 1)))
(defn vec/angle-to [v1 v2] (vec/angle (- v1 v2)))
(defn vec/dot-product [v1 v2] (sum (* v1 v2)))

(defn vec/trim [v max-length]
  (when-let [len2 (vec/length2 v)
	non-zero? (> len2 0)
	s (* max-length (/ max-length len2))
	s (if (>= s 1) 1 (math/sqrt s))]
	(* v s)))

(defn vec/normalize [v]
  (if-let [v-len (vec/length v)
	greater-then-zero? (> v-len 0)]
	(/ v v-len)
	v))

(defn vec/init [&opt x y]
  [(default x 0) (default y x)])

(defn vec/from-polar [angle &opt radius]
  (default radius 1)
  (vec/init (* (math/cos angle) radius)
	(* (math/sin angle) radius)))

Side note: If you need any additional vector functions, check out the classic lua humps vector implementation. They shouldn't be hard to translate.


Update October 9, 2023

I've been working on the next main physics post in this series, and ended up needing a couple more vector functions. Here they are!

# Round both values in the vec to integers
(defn vec/round [v] (map math/round v))

# calculate .. you guessed it .. the cross product
(defn vec/cross-product [[v1x v1y] [v2x v2y]]
  (- (* v1x v2y) (* v1y v2x)))

In addition to these 2 simple functions, we'll need to encapsulate the idea of a vector "transform". A transfor will first rotation the vector, then offset the position.. the order there is really important!

(defn vec/transformation [[x y] angle]
  {:pos (vec/init x y)
   :sin (math/sin angle)
   :cos (math/cos angle)})

(defn vec/transform [[x y] {:pos [tx ty] :sin tsin :cos tcos}]
  [(-> (- (* tcos x) (* tsin y))
       (+ tx))
   (-> (+ (* tsin x) (* tcos y))
       (+ ty))])