Port to fixed point math

This commit is contained in:
2026-08-24 21:54:23 +02:00
parent 91c5c4e05f
commit 73855ea354
27 changed files with 703 additions and 229 deletions
+79 -34
View File
@@ -1,12 +1,13 @@
use bevy::math::{Vec2, primitives::Segment2d};
use bevy::prelude::*;
use fixed::prelude::*;
use crate::math::vec::FGVec2;
type FGi32 = fixed::FixedI32<fixed::types::extra::U16>;
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, Copy, PartialEq, Reflect)]
pub struct FGSegment2d {
point1: FGVec2,
point2: FGVec2
@@ -29,14 +30,72 @@ impl FGSegment2d {
pub fn point2(&self) -> FGVec2 {
self.point2
}
}
pub trait SegmentIntersection {
fn segment_intersection(&self, rhs: &Segment2d) -> Option<FGVec2>;
}
/// Compute the normalized direction pointing from the first endpoint to the second endpoint.
///
/// For the non-panicking version, see [`FGVec2::try_direction`].
///
/// # Panics
///
/// Panics if a valid direction could not be computed, for example when the endpoints are coincident, NaN, or infinite.
#[inline]
pub fn direction(&self) -> FGVec2 {
self.try_direction().expect("Failed to compute the direction of a line segment")
}
impl SegmentIntersection for Segment2d {
fn segment_intersection(&self, rhs: &Segment2d) -> Option<Vec2> {
/// Try to compute the normalized direction pointing from the first endpoint to the second endpoint.
///
/// Returns [`Err(InvalidDirectionError)`](InvalidDirectionError) if a valid direction could not be computed,
/// for example when the endpoints are coincident, NaN, or infinite.
#[inline]
pub fn try_direction(&self) -> Option<FGVec2> {
self.point1.direction_to(self.point2)
}
/// Returns the point on the [`Segment2d`] that is closest to the specified `point`.
#[inline]
pub fn closest_point(&self, point: FGVec2) -> FGVec2 {
// `point`
// x
// ^|
// / |
//`offset`/ |
// / | `segment_vector`
// x----.-------------->x
// 0 t 1
let segment_vector = self.point2 - self.point1;
let offset = point - self.point1;
// The signed projection of `offset` onto `segment_vector`, scaled by the length of the segment.
let projection_scaled = segment_vector.dot(offset);
// `point` is too far "left" in the picture
if projection_scaled <= 0.0 {
return self.point1;
}
let length_squared = segment_vector.length_squared();
// `point` is too far "right" in the picture
if projection_scaled >= length_squared {
return self.point2;
}
// Point lies somewhere in the middle, we compute the closest point by finding the parameter along the line.
let t = projection_scaled / length_squared;
self.point1 + segment_vector * t
}
/// Compute the normalized counterclockwise normal on the left-hand side of the line segment.
///
/// For the non-panicking version, see [`Segment2d::try_left_normal`].
///
/// # Panics
///
/// Panics if a valid normal could not be computed, for example when the endpoints are coincident
pub fn left_normal(&self) -> FGVec2 {
self.point1.direction_to(self.point2).map(|o| { FGVec2::new(-o.y, o.x)}).expect("a valid normal could not be computed")
}
pub fn segment_intersection(&self, rhs: &FGSegment2d) -> Option<FGVec2> {
let p0 = self.point1();
let p1 = self.point2();
let p2 = rhs.point1();
@@ -48,45 +107,31 @@ impl SegmentIntersection for Segment2d {
let s2_y = p3.y - p2.y;
let denom = -s2_x * s1_y + s1_x * s2_y;
if denom.abs() < f32::EPSILON {
if denom == FGi32::ZERO {
return None; // Parallel or collinear
}
let s = (-s1_y * (p0.x - p2.x) + s1_x * (p0.y - p2.y)) / denom;
let t = ( s2_x * (p0.y - p2.y) - s2_y * (p0.x - p2.x)) / denom;
if s >= 0.0 && s <= 1.0 && t >= 0.0 && t <= 1.0 {
Some(Vec2::new(p0.x + (t * s1_x), p0.y + (t * s1_y)))
if s >= FGi32::ZERO && s <= FGi32::ONE && t >= FGi32::ZERO && t <= FGi32::ONE {
Some(FGVec2::new(p0.x + (t * s1_x), p0.y + (t * s1_y)))
} else {
None
}
}
}
impl SegmentIntersection for Segment2d {
fn segment_intersection(&self, rhs: &Segment2d) -> Option<Vec2> {
let p0 = self.point1();
let p1 = self.point2();
let p2 = rhs.point1();
let p3 = rhs.point2();
let s1_x = p1.x - p0.x;
let s1_y = p1.y - p0.y;
let s2_x = p3.x - p2.x;
let s2_y = p3.y - p2.y;
#[cfg(test)]
mod tests {
use super::*;
let denom = -s2_x * s1_y + s1_x * s2_y;
if denom.abs() < f32::EPSILON {
return None; // Parallel or collinear
}
#[test]
fn closest_point_projects_and_clamps_to_segment() {
let segment = FGSegment2d::new(FGVec2::lit("-4", "0"), FGVec2::lit("4", "0"));
let s = (-s1_y * (p0.x - p2.x) + s1_x * (p0.y - p2.y)) / denom;
let t = ( s2_x * (p0.y - p2.y) - s2_y * (p0.x - p2.x)) / denom;
if s >= 0.0 && s <= 1.0 && t >= 0.0 && t <= 1.0 {
Some(Vec2::new(p0.x + (t * s1_x), p0.y + (t * s1_y)))
} else {
None
}
assert_eq!(segment.closest_point(FGVec2::lit("2", "1")), FGVec2::lit("2", "0"));
assert_eq!(segment.closest_point(FGVec2::lit("-6", "1")), segment.point1());
assert_eq!(segment.closest_point(FGVec2::lit("6", "1")), segment.point2());
}
}
}
+206 -8
View File
@@ -1,14 +1,24 @@
use bevy::math::Vec2;
use std::ops::{Add, Div, Mul, Sub};
use bevy::prelude::*;
use fixed::types::I32F32;
use serde::{Deserialize, Serialize};
use std::ops::{AddAssign, Add, Div, Mul, Sub};
use super::int::FGi32;
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, Copy, PartialEq, Default, Debug, Reflect, Serialize, Deserialize)]
#[reflect(opaque)]
pub struct FGVec2 {
pub x: FGi32,
pub y: FGi32,
}
impl std::fmt::Display for FGVec2 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}, {}]", self.x, self.y)
}
}
impl FGVec2 {
pub const ZERO: Self = Self::new(FGi32::ZERO, FGi32::ZERO);
@@ -17,6 +27,11 @@ impl FGVec2 {
Self { x, y }
}
#[inline(always)]
pub const fn lit(x: &str, y: &str) -> Self {
Self { x: FGi32::lit(x), y: FGi32::lit(y) }
}
#[inline(always)]
pub fn from_i32(x: i32, y: i32) -> Self {
Self {
@@ -31,17 +46,78 @@ impl FGVec2 {
}
#[inline]
pub fn length(&self) -> FGi32 {
pub fn length(self) -> FGi32 {
(self.x * self.x + self.y * self.y).sqrt()
}
/// Returns the vector projection of `self` onto `rhs`.
///
/// `rhs` must be normalized.
///
/// # Panics
///
/// Will panic if `rhs` is not normalized when `glam_assert` is enabled.
#[inline]
pub fn normalized_or_zero(&self) -> Self {
let len = self.length();
if len.is_zero() {
Self::ZERO
#[must_use]
pub fn project_onto_normalized(self, rhs: Self) -> Self {
assert!(rhs.is_normalized());
rhs * self.dot(rhs)
}
const EPS: FGi32 = FGi32::from_bits(6);
#[inline]
#[must_use]
pub fn is_normalized(self) -> bool {
let diff = self.length() - FGi32::ONE;
diff.abs() <= Self::EPS
}
#[inline]
#[must_use]
pub fn dot(self, rhs: Self) -> FGi32 {
(self.x * rhs.x) + (self.y * rhs.y)
}
#[inline]
#[must_use]
pub fn project_onto(self, rhs: Self) -> Self {
let other_len_sq = rhs.dot(rhs);
assert!(other_len_sq != FGi32::ZERO);
rhs * (self.dot(rhs) / other_len_sq)
}
#[inline]
pub fn normalize_or_zero(&self) -> Self {
self.normalize().unwrap_or(FGVec2::ZERO)
}
#[inline]
pub fn direction_to(self, to: Self) -> Option<Self> {
(to - self).normalize()
}
#[inline]
#[must_use]
pub fn distance(self, to: Self) -> FGi32 {
(to - self).length()
}
#[inline]
pub fn normalize(&self) -> Option<Self> {
let x = I32F32::from_num(self.x);
let y = I32F32::from_num(self.y);
let length_squared = x * x + y * y;
if length_squared.is_zero() {
None
} else {
self / len
let length = length_squared.sqrt();
Some(Self::new(
FGi32::from_num(x / length),
FGi32::from_num(y / length),
))
}
}
@@ -78,6 +154,14 @@ impl Sub for FGVec2 {
}
}
impl Sub<&FGVec2> for &FGVec2 {
type Output = FGVec2;
#[inline]
fn sub(self, rhs: &FGVec2) -> FGVec2 {
(*self).sub(*rhs)
}
}
impl Div for FGVec2 {
type Output = Self;
@@ -113,3 +197,117 @@ impl Div<FGi32> for &FGVec2 {
(*self).div(rhs)
}
}
impl AddAssign for FGVec2 {
fn add_assign(&mut self, rhs: Self) {
*self = Self {
x: self.x + rhs.x,
y: self.y + rhs.y
}
}
}
#[cfg(test)]
mod is_normalized_tests {
use super::*;
use fixed::types::I16F16 as FGi32;
// Normalization is computed in Q32.32, so the Q16.16 error comes from the
// two final component conversions and length_squared()'s multiplications.
const EPS_FIXED_BITS: i32 = 4;
#[test]
fn normalizes_small_axis_vector_without_losing_squared_precision() {
let normalized = FGVec2::lit("0.01", "0").normalize().unwrap();
assert_eq!(normalized, FGVec2::lit("1", "0"));
assert!(normalized.is_normalized());
}
#[test]
fn measure_normalize_error_bound() {
let mut max_abs_diff_bits: i32 = 0;
let mut worst_case: Option<(f64, f64, i32)> = None;
let angle_steps = 3600; // 0.1 degree resolution
let magnitudes: &[f64] = &[
0.001, 0.01, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0, 1000.0, 30000.0,
];
for &mag in magnitudes {
for i in 0..angle_steps {
let theta = (i as f64) * std::f64::consts::TAU / (angle_steps as f64);
let x = mag * theta.cos();
let y = mag * theta.sin();
let Some(fx) = FGi32::checked_from_num(x) else { continue };
let Some(fy) = FGi32::checked_from_num(y) else { continue };
let v = FGVec2 { x: fx, y: fy };
if let Some(normalized) = v.normalize() {
let ls = normalized.length_squared();
let diff_bits = ls.to_bits() - FGi32::ONE.to_bits();
let abs_diff_bits = diff_bits.abs();
if abs_diff_bits > max_abs_diff_bits {
max_abs_diff_bits = abs_diff_bits;
worst_case = Some((x, y, abs_diff_bits));
}
}
}
}
let ulp_value = FGi32::DELTA.to_num::<f64>();
println!(
"Max |length_squared - 1.0| = {} raw bits ({:e} real), worst case: {:?}",
max_abs_diff_bits,
max_abs_diff_bits as f64 * ulp_value,
worst_case
);
assert!(
max_abs_diff_bits <= EPS_FIXED_BITS,
"measured error ({} bits) exceeds analytical bound ({} bits) — \
revisit the epsilon derivation",
max_abs_diff_bits,
EPS_FIXED_BITS
);
println!(
"Bound: {} bits, measured: {} bits (headroom: {} bits)",
EPS_FIXED_BITS,
max_abs_diff_bits,
EPS_FIXED_BITS - max_abs_diff_bits
);
}
#[test]
fn is_normalized_behaves_correctly() {
let angle_steps = 360;
for i in 0..angle_steps {
let theta = (i as f64) * std::f64::consts::TAU / (angle_steps as f64);
for &mag in &[0.01, 1.0, 100.0, 10000.0] {
let x = mag * theta.cos();
let y = mag * theta.sin();
let Some(fx) = FGi32::checked_from_num(x) else { continue };
let Some(fy) = FGi32::checked_from_num(y) else { continue };
let v = FGVec2 { x: fx, y: fy };
if let Some(normalized) = v.normalize() {
assert!(
normalized.is_normalized(),
"normalized({}, {}) failed is_normalized(): length_squared = {:?}",
x, y, normalized.length_squared()
);
}
}
}
let non_unit = FGVec2 { x: FGi32::from_num(2.0), y: FGi32::from_num(0.0) };
assert!(!non_unit.is_normalized());
let non_unit_small = FGVec2 { x: FGi32::from_num(0.5), y: FGi32::from_num(0.0) };
assert!(!non_unit_small.is_normalized());
}
}