# Objective Certain classes of games, usually those with enormous worlds, require some amount of support for double-precision. Libraries like `big_space` exist to allow for large worlds while integrating cleanly with Bevy's primarily single-precision ecosystem, but even then, games will often still work directly in double-precision throughout the part of the pipeline that feeds into the Bevy interface. Currently, working with double-precision types in Bevy is a pain. `glam` provides types like `DVec3`, but Bevy doesn't provide double-precision analogs for `glam` wrappers like `Dir3`. This is mostly because doing so involves one of: - code duplication - generics - templates (like `glam` uses) - macros Each of these has issues that are enough to be deal-breakers as far as maintainability, usability or readability. To work around this, I'm putting together `bevy_dmath`, a crate that duplicates `bevy_math` types and functionality to allow downstream users to enjoy the ergonomics and power of `bevy_math` in double-precision. For the most part, it's a smooth process, but in order to fully integrate, there are some necessary changes that can only be made in `bevy_math`. ## Solution This PR addresses the first and easiest issue with downstream double-precision math support: `VectorSpace` currently can only represent vector spaces over `f32`. This automatically closes the door to double-precision curves, among other things. This restriction can be easily lifted by allowing vector spaces to specify the underlying scalar field. This PR adds a new trait `ScalarField` that satisfies the properties of a scalar field (the ones that can be upheld statically) and adds a new associated type `type Scalar: ScalarField` to `VectorSpace`. It's mostly an unintrusive change. The biggest annoyances are: - it touches a lot of curve code - `bevy_math::ops` doesn't support `f64`, so there are some annoying workarounds As far as curves code, I wanted to make this change unintrusive and bite-sized, so I'm trying to touch as little code as possible. To prove to myself it can be done, I went ahead and (*not* in this PR) migrated most of the curves API to support different `ScalarField`s and it went really smoothly! The ugliest thing was adding `P::Scalar: From<usize>` in several places. There's an argument to be made here that we should be using `num-traits`, but that's not immediately relevant. The point is that for now, the smallest change I could make was to go into every curve impl and make them generic over `VectorSpace<Scalar = f32>`. Curves work exactly like before and don't change the user API at all. # Follow-up - **Extend `bevy_math::ops` to work with `f64`.** `bevy_math::ops` is used all over, and if curves are ever going to support different `ScalarField` types, we'll need to be able to use the correct `std` or `libm` ops for `f64` types as well. Adding an `ops64` mod turned out to be really ugly, but I'll point out the maintenance burden is low because we're not going to be adding new floating-point ops anytime soon. Another solution is to build a floating-point trait that calls the right op variant and impl it for `f32` and `f64`. This reduces maintenance burden because on the off chance we ever *do* want to go modify it, it's all tied together: you can't change the interface on one without changing the trait, which forces you to update the other. A third option is to use `num-traits`, which is basically option 2 but someone else did the work for us. They already support `no_std` using `libm`, so it would be more or less a drop-in replacement. They're missing a couple floating-point ops like `floor` and `ceil`, but we could make our own floating-point traits for those (there's even the potential for upstreaming them into `num-traits`). - **Tweak curves to accept vector spaces over any `ScalarField`.** Curves are ready to support custom scalar types as soon as the bullet above is addressed. I will admit that the code is not as fun to look at: `P::Scalar` instead of `f32` everywhere. We could consider an alternate design where we use `f32` even to interpolate something like a `DVec3`, but personally I think that's a worse solution than parameterizing curves over the vector space's scalar type. At the end of the day, it's not really bad to deal with in my opinion... `ScalarType` supports enough operations that working with them is almost like working with raw float types, and it unlocks a whole ecosystem for games that want to use double-precision.
103 lines
2.8 KiB
Rust
103 lines
2.8 KiB
Rust
use benches::bench;
|
|
use bevy_math::{prelude::*, VectorSpace};
|
|
use core::hint::black_box;
|
|
use criterion::{
|
|
criterion_group, measurement::Measurement, BatchSize, BenchmarkGroup, BenchmarkId, Criterion,
|
|
};
|
|
|
|
criterion_group!(benches, segment_ease, curve_position, curve_iter_positions);
|
|
|
|
fn segment_ease(c: &mut Criterion) {
|
|
let segment = black_box(CubicSegment::new_bezier_easing(
|
|
vec2(0.25, 0.1),
|
|
vec2(0.25, 1.0),
|
|
));
|
|
|
|
c.bench_function(bench!("segment_ease"), |b| {
|
|
let mut t = 0;
|
|
|
|
b.iter_batched(
|
|
|| {
|
|
// Increment `t` by 1, but use modulo to constrain it to `0..=1000`.
|
|
t = (t + 1) % 1001;
|
|
|
|
// Return time as a decimal between 0 and 1, inclusive.
|
|
t as f32 / 1000.0
|
|
},
|
|
|t| segment.ease(t),
|
|
BatchSize::SmallInput,
|
|
);
|
|
});
|
|
}
|
|
|
|
fn curve_position(c: &mut Criterion) {
|
|
/// A helper function that benchmarks calling [`CubicCurve::position()`] over a generic [`VectorSpace`].
|
|
fn bench_curve<M: Measurement, P: VectorSpace<Scalar = f32>>(
|
|
group: &mut BenchmarkGroup<M>,
|
|
name: &str,
|
|
curve: CubicCurve<P>,
|
|
) {
|
|
group.bench_with_input(BenchmarkId::from_parameter(name), &curve, |b, curve| {
|
|
b.iter(|| curve.position(black_box(0.5)));
|
|
});
|
|
}
|
|
|
|
let mut group = c.benchmark_group(bench!("curve_position"));
|
|
|
|
let bezier_2 = CubicBezier::new([[
|
|
vec2(0.0, 0.0),
|
|
vec2(0.0, 1.0),
|
|
vec2(1.0, 0.0),
|
|
vec2(1.0, 1.0),
|
|
]])
|
|
.to_curve()
|
|
.unwrap();
|
|
|
|
bench_curve(&mut group, "vec2", bezier_2);
|
|
|
|
let bezier_3 = CubicBezier::new([[
|
|
vec3(0.0, 0.0, 0.0),
|
|
vec3(0.0, 1.0, 0.0),
|
|
vec3(1.0, 0.0, 0.0),
|
|
vec3(1.0, 1.0, 1.0),
|
|
]])
|
|
.to_curve()
|
|
.unwrap();
|
|
|
|
bench_curve(&mut group, "vec3", bezier_3);
|
|
|
|
let bezier_3a = CubicBezier::new([[
|
|
vec3a(0.0, 0.0, 0.0),
|
|
vec3a(0.0, 1.0, 0.0),
|
|
vec3a(1.0, 0.0, 0.0),
|
|
vec3a(1.0, 1.0, 1.0),
|
|
]])
|
|
.to_curve()
|
|
.unwrap();
|
|
|
|
bench_curve(&mut group, "vec3a", bezier_3a);
|
|
|
|
group.finish();
|
|
}
|
|
|
|
fn curve_iter_positions(c: &mut Criterion) {
|
|
let bezier = CubicBezier::new([[
|
|
vec3a(0.0, 0.0, 0.0),
|
|
vec3a(0.0, 1.0, 0.0),
|
|
vec3a(1.0, 0.0, 0.0),
|
|
vec3a(1.0, 1.0, 1.0),
|
|
]])
|
|
.to_curve()
|
|
.unwrap();
|
|
|
|
c.bench_function(bench!("curve_iter_positions"), |b| {
|
|
b.iter(|| {
|
|
for x in bezier.iter_positions(black_box(100)) {
|
|
// Discard `x`, since we just care about `iter_positions()` being consumed, but make
|
|
// the compiler believe `x` is being used so it doesn't eliminate the iterator.
|
|
black_box(x);
|
|
}
|
|
});
|
|
});
|
|
}
|