audio_code/math.rs
1//! Vectors, angles and direction helpers made for audio games.
2//!
3//! ## Coordinate system
4//!
5//! The engine uses a right-handed system with **z pointing up**:
6//!
7//! * `+x` is east (right on a map)
8//! * `+y` is north (forward on a map)
9//! * `+z` is up
10//!
11//! Angles ("yaw" or "facing") are **compass degrees**: 0 = north, 90 = east, 180 = south,
12//! 270 = west. A 2D side scroller simply uses `x` for left/right and `z` for up/down, a top
13//! down game uses `x` and `y`, and a full 3D game uses all three.
14//!
15//! The angle up or down ("pitch") is in degrees too: 0 = level with the horizon, 90 =
16//! straight up, -90 = straight down.
17//!
18//! ## Positions: `vec3(x, y, z)`
19//!
20//! A position is a [`Vec3`] with three numbers in meters, written `vec3(x, y, z)`:
21//!
22//! * `x`: how far east (positive) or west (negative)
23//! * `y`: how far north (positive) or south (negative)
24//! * `z`: how far up (positive) or down (negative)
25//!
26//! `vec3(3.0, -2.0, 1.5)` is 3 m east, 2 m south and 1.5 m up from the origin
27//! `vec3(0.0, 0.0, 0.0)` (also written `Vec3::ZERO`). The same type describes directions and
28//! velocities: `vec3(0.0, 2.0, 0.0)` as a velocity is 2 m/s north.
29//!
30//! ```
31//! use audio_code::math::*;
32//!
33//! let player = vec3(3.0, -2.0, 1.5);
34//! let east = player.x; // 3.0
35//! let above = player + vec3(0.0, 0.0, 2.0); // 2 m higher
36//! let ahead = player + forward(90.0) * 5.0; // 5 m to the east
37//! let distance = player.distance(above); // 2.0
38//! assert_eq!(east, 3.0);
39//! assert!((distance - 2.0).abs() < 1e-5);
40//! assert!((ahead.x - 8.0).abs() < 1e-5);
41//! ```
42
43pub use glam::{Vec2, Vec3, vec2, vec3};
44
45/// Wraps an angle into the range `0.0..360.0`.
46pub fn normalize_angle(degrees: f32) -> f32 {
47 degrees.rem_euclid(360.0)
48}
49
50/// Shortest signed difference `to - from` in degrees, in the range `-180.0..=180.0`.
51/// Positive means turning clockwise (to the right).
52pub fn angle_difference(from: f32, to: f32) -> f32 {
53 let d = normalize_angle(to - from);
54 if d > 180.0 { d - 360.0 } else { d }
55}
56
57/// Unit vector pointing in the compass direction `yaw` on the ground plane.
58pub fn forward(yaw: f32) -> Vec3 {
59 let r = yaw.to_radians();
60 Vec3::new(r.sin(), r.cos(), 0.0)
61}
62
63/// Unit vector pointing to the right of the compass direction `yaw`.
64pub fn right(yaw: f32) -> Vec3 {
65 forward(yaw + 90.0)
66}
67
68/// Unit vector pointing in compass direction `yaw`, tilted up by `pitch` degrees
69/// (90 = straight up, -90 = straight down). With `pitch` 0 it equals [`forward`].
70pub fn forward_3d(yaw: f32, pitch: f32) -> Vec3 {
71 let p = pitch.clamp(-90.0, 90.0).to_radians();
72 forward(yaw) * p.cos() + Vec3::Z * p.sin()
73}
74
75/// Unit vector pointing out of the top of the head of someone facing `yaw` and looking up
76/// by `pitch` degrees. With `pitch` 0 it is `+z`.
77pub fn up_3d(yaw: f32, pitch: f32) -> Vec3 {
78 let p = pitch.clamp(-90.0, 90.0).to_radians();
79 Vec3::Z * p.cos() - forward(yaw) * p.sin()
80}
81
82/// Angle in degrees from `from` up to `to`: 90 = straight above, 0 = same height,
83/// -90 = straight below.
84pub fn elevation(from: Vec3, to: Vec3) -> f32 {
85 let d = to - from;
86 let horizontal = d.x.hypot(d.y);
87 if horizontal == 0.0 && d.z == 0.0 {
88 return 0.0;
89 }
90 d.z.atan2(horizontal).to_degrees()
91}
92
93/// Compass direction (yaw) and pitch in degrees that point from `from` to `to`, e.g. to aim
94/// at a target or to make a body look at it.
95pub fn look_at(from: Vec3, to: Vec3) -> (f32, f32) {
96 (bearing(from, to), elevation(from, to))
97}
98
99/// Compass bearing in degrees from `from` to `to`, ignoring height.
100pub fn bearing(from: Vec3, to: Vec3) -> f32 {
101 let d = to - from;
102 if d.x == 0.0 && d.y == 0.0 {
103 return 0.0;
104 }
105 normalize_angle(d.x.atan2(d.y).to_degrees())
106}
107
108/// Moves `position` by `distance` in compass direction `yaw` on the ground plane.
109pub fn move_towards(position: Vec3, yaw: f32, distance: f32) -> Vec3 {
110 position + forward(yaw) * distance
111}
112
113/// Converts a compass angle to a name such as "north east". Uses 8 directions.
114pub fn direction_name(degrees: f32) -> &'static str {
115 const NAMES: [&str; 8] =
116 ["north", "north east", "east", "south east", "south", "south west", "west", "north west"];
117 let i = ((normalize_angle(degrees) + 22.5) / 45.0) as usize % 8;
118 NAMES[i]
119}
120
121/// Converts an angle relative to the player (0 = straight ahead) to a clock position,
122/// e.g. 90 degrees becomes 3 (o'clock). Returns 1..=12.
123pub fn clock_position(relative_degrees: f32) -> u32 {
124 let hour = ((normalize_angle(relative_degrees) + 15.0) / 30.0) as u32 % 12;
125 if hour == 0 { 12 } else { hour }
126}
127
128/// Describes where `target` is relative to someone at `from` facing `yaw`, ready to be
129/// spoken. The distance is the straight line in 3D. A height difference (z) of a meter or
130/// more is added:
131///
132/// * `"3 o'clock, 12 meters"`: same height
133/// * `"12 o'clock, 11 meters, 4 meters above"`
134/// * `"directly below, 5 meters"`: nothing to the side
135///
136/// Always English. [`Lang::describe_position`](crate::lang::Lang::describe_position) and
137/// [`Context::describe`](crate::Context::describe) use the current language.
138pub fn describe_position(from: Vec3, yaw: f32, target: Vec3) -> String {
139 crate::lang::Lang::new().describe_position(from, yaw, target)
140}
141
142/// Linear interpolation between `a` and `b`.
143pub fn lerp(a: f32, b: f32, t: f32) -> f32 {
144 a + (b - a) * t
145}
146
147/// Converts decibels to a linear volume factor (0 dB = 1.0, -6 dB ≈ 0.5).
148pub fn db_to_linear(db: f32) -> f32 {
149 10f32.powf(db / 20.0)
150}
151
152/// Converts a linear volume factor to decibels.
153pub fn linear_to_db(linear: f32) -> f32 {
154 20.0 * linear.max(1e-6).log10()
155}
156
157/// Random helpers so you do not need an extra crate for the basics.
158pub mod random {
159 /// Random float in `min..max`.
160 pub fn range(min: f32, max: f32) -> f32 {
161 min + fastrand::f32() * (max - min)
162 }
163
164 /// Random integer in `min..=max`.
165 pub fn int(min: i64, max: i64) -> i64 {
166 fastrand::i64(min..=max)
167 }
168
169 /// Returns `true` with the given probability (0.0 to 1.0).
170 pub fn chance(probability: f32) -> bool {
171 fastrand::f32() < probability
172 }
173
174 /// Picks a random element, or `None` for an empty slice.
175 pub fn pick<T>(items: &[T]) -> Option<&T> {
176 if items.is_empty() { None } else { Some(&items[fastrand::usize(..items.len())]) }
177 }
178
179 /// Seeds the random generator for reproducible results.
180 pub fn seed(seed: u64) {
181 fastrand::seed(seed);
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn compass() {
191 assert!((forward(90.0) - Vec3::X).length() < 1e-5);
192 assert!((bearing(Vec3::ZERO, Vec3::new(0.0, -5.0, 0.0)) - 180.0).abs() < 1e-3);
193 assert_eq!(direction_name(44.0), "north east");
194 assert_eq!(direction_name(359.0), "north");
195 assert_eq!(angle_difference(350.0, 10.0), 20.0);
196 assert_eq!(clock_position(90.0), 3);
197 assert_eq!(clock_position(0.0), 12);
198 assert_eq!(describe_position(Vec3::ZERO, 0.0, Vec3::new(5.0, 0.0, 0.0)), "3 o'clock, 5 meters");
199 }
200
201 #[test]
202 fn three_d() {
203 assert!((forward_3d(0.0, 90.0) - Vec3::Z).length() < 1e-5);
204 assert!((forward_3d(90.0, 0.0) - Vec3::X).length() < 1e-5);
205 assert!((up_3d(0.0, 0.0) - Vec3::Z).length() < 1e-5);
206 assert!((up_3d(0.0, 90.0) + Vec3::Y).length() < 1e-5, "looking up, the head points south");
207 assert!((elevation(Vec3::ZERO, Vec3::new(3.0, 0.0, 3.0)) - 45.0).abs() < 1e-3);
208 let (yaw, pitch) = look_at(Vec3::ZERO, Vec3::new(0.0, -4.0, -4.0));
209 assert!((yaw - 180.0).abs() < 1e-3 && (pitch + 45.0).abs() < 1e-3);
210 assert_eq!(describe_position(Vec3::ZERO, 0.0, Vec3::new(0.0, 10.0, 4.0)), "12 o'clock, 11 meters, 4 meters above");
211 assert_eq!(describe_position(Vec3::ZERO, 0.0, Vec3::new(0.0, 0.0, -5.0)), "directly below, 5 meters");
212 assert_eq!(describe_position(Vec3::ZERO, 0.0, Vec3::new(0.0, 1.0, 0.0)), "12 o'clock, 1 meter");
213 }
214}