-
Notifications
You must be signed in to change notification settings - Fork 66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
A curve trait for general interoperation #80
Conversation
The proposal looks great so far. Some thoughts:
|
Might have to have option/result samples. I think the general domain is worth considering. |
Also do we want this to be general enough to capture parametric surfaces? Because I think we can do this if T is not Ord (a Vec2 for example). |
Ah, I'm glad you brought this up. The basic form of As for (2) — good question. Once again, the animation RFC I linked suggested that sampling would always return a value, but that the way that out-of-bounds samples might be formed would be left implementation-specific. Perhaps @james7132 might chime in with some insight there. I think that it's tempting to believe that returning an error or |
I guess I'm a little confused by this since there are no |
I had no idea, that's pretty unfortunate. What if sampling outside was done by means of a wrapper? For example a |
A random thought on the range business: we could implement our own
I think something like that could work, but it would still be a little annoying to have to unwrap its sampling output all the time despite knowing it would always succeed. |
I was assuming fn clamp<T>(x: Result<T, OutOfBounds<T>>) -> T {
match x {
Ok(x) => x,
Err(out_of_bounds) => todo!(), // `out_of_bounds` has all the info we need to extrapolate out-of-bounds sampling
}
}
let curve = function_curve((0.0..=1.0), |x| x);
let out_of_bounds_sample = clamp(curve.sample(2.0)); // returns 2.0 |
FYI, this is slated to change over either the 2024 or 2027 edition boundary: https://github.com/pitaj/rfcs/blob/new-range/text/3550-new-range.md |
That's good to know! Honestly, I tinkered around and found that (This wouldn't be very heavy, but would enforce non-emptiness and include methods like |
Okay, here again to explain a couple changes: Firstly, I added
Secondly, with the addition of
Outside of this, I don't really care whether |
Read through the updates. Looks really good. The interval type makes sense, and I agree Range doesn't seem like quite the right fit. Among other things, allowing curves over arbitrary intervals could simplify thinking about segments of splines as curves in their own right. I think we may want to investigate functions for sticking/appending curves together based on their domains or splitting them apart. I do worry that requiring an interval domain will cause problems if we want to work with derivatives. It would be nice to be able to represent the first and second derivatives as "functional curves" where they can be determined analytically (eg. when working with splines), but most Bezier splines aren't generally C2 (and some aren't even C1). I suppose a tangent curve could be a One possible solution might be to allow curves over arbitrary measurable sets (or otherwise generalize Setting derivatives aside, I agree with you're points about domain checking. My preference would be for By the way, I can think of two other traits that also provide interpolation: Also, please disregard my earlier comment about |
Yeah, this could be a good idea; I am a bit wary of scope creep at this point, since this seems like something that can be adjudicated outside of the RFC proper, but I agree that it's definitely worth investigating and seriously considering things in this direction.
My thoughts on this have yet to materialize into concrete implementation details, but as far as I can tell, we are mostly in the clear; the main thing I would like to do with our spline code in order to adapt it to this interface is to differentiate classes of curves a bit more at the level of types. All of the spline constructions that we currently support, for instance, are globally C1; in my ideal world, this would mean that they naturally produce something that looks like Then, for instance, for the B-spline construction, the produced type would actually be slightly different from the previous one; in addition to implementing the I am unsure on the finer points of what that type-level differentiation would look like (kind of next on my list to investigate), but I guess my angle here is this: most of the things you would want to do with spline output are only going to care about the So, to be brief, my vision for actual implementation involves reifying the quality of spline constructions more strongly at the type level; I hope that makes sense. |
Quite right. There's very little I would add to the proposal at this point, and I'm not pushing for any of this to be added to the RFC. But I do think it's worth noting future work items here as well as evaluating the sorts of things the RFC lets us build. Please let me know if you think I'm derailing the discussion, that's not my intention.
Bezier splines are only C1 within curve segments, and may or may not have smooth transitions between segments depending on the position of the control points. I don't think we can or should assume all our curves will be C1. Part of the problem is that "tangents" can mean like four different things depending on the underlying curve: "Functional curves" are generally going to be either C1 or piecewise C1, which is the difference between a As I see it:
The idea of multiple implementations of fn foo<C>(curve: C)
where C: Curve<C1<Vec3>> + Curve<C2<Vec3>>
{
let (pos, acc) = <C as Curve<C1<Vec3>>>::sample(t)
let vel = <C as Curve<C2<Vec3>>>::sample(t)
} Would something like the following work? // These all have blanket implementations of curve and other Cn/Pn traits
trait C2C1Curve<T> { ... } // C2 + C1: Curve<(T, T, T)>
trait P2C1Curve<T> { ... } // Piecewise C2 + C1: Curve<T, T, Option<T>>
trait P2P1Curve<T> { ... } // Piecewise C2 + Piecewise C1: Curve<(T, Option<T>, Option<T>)>
trait C1Curve<T> { ... } // C1: Curve<(T, T)>
trait P1Curve<T> { ... } // Piecewise C1: Curve<(T, Option<T>)>
fn foo(curve: impl P2C1Curve<Vec3>) {
if let (pos, vel, Some(acc)) = curve::sample(t) {
...
}
let acc = curve.sample_acc(t);
let pos = curve.sample_pos(t);
} New curves which provide tangents/acceleration would implement the strongest trait they can. The types are a bit cumbersome but I think it would avoid the qualified syntax. We don't have to spec anything out as part of the RFC, but I'd like a better idea of what this would look like to make sure we aren't locking ourselves out of anything in the future. |
You're quite fine. :)
It seems I actually misspoke, since the cubic NURBS construction is only C0 (in full generality). What I was trying to get at was really that only the
Yeah, I agree here. My main thing is that I would prefer for, e.g., For instance, with how things are currently set up, something like
This is true, but I suppose I see "finite differences" as a "promotion" procedure, something like: fn numerical_derivative(position_curve: SampleCurve<Position>) -> SampleCurve<PositionAndVelocity> hence When you start with a
Agreed on all counts.
Having sat with it a little, I don't like it much either; I think this is a situation where explicit methods producing
I think this is reasonable, although I am a little wary of actually putting I think what I'd like to do now is to sit down when I have time and actually prototype this (at least to the point where we can convince ourselves we won't be stepping on our own toes); I think we are mostly on the same page, though. |
It seems to me like that is an issue with interpolation. We shouldn't try to interpolate across a discontinuity, and if we want to represent derivatives directly using curves we can only assume they are piecewise continuous. Maybe the concrete curve representations need to know about their discontinuities and treat them as boundaries for interpolation. Does that make sense? I'll try to expand on this more when I have time. |
It does make sense, and I am curious where this line of inquiry leads — especially in the matter of how such a thing would be distinct from a vector of curves. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like the API, and see the need for this. I've left some further comments on areas that I feel could be improved.
While I fully agree with the need for precise mathematical language when talking about this domain, I think we can and should do a better job making this approachable, by sprinkling in more tangible examples, explaining concepts in simple language first and so on :)
Co-authored-by: Alice Cecile <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remarkably clear and thought through. You've sold me on the value of this API, and the abstractions chosen seem natural and powerful. We will need so many examples to make this tangible and useful to non-mathematicians, but that's fine.
Before I approve, there are a couple of straightforward edits to be made. More importantly, I want to make sure that we record why f32
is used as the base numerical type for t
, rather than making this generic. I agree with that decision, but it's an important design consideration that should be documented as it has come up repeatedly in other contexts.
Co-authored-by: Alice Cecile <[email protected]>
This has now been substantially rewritten. The goals of this rewrite were as follows:
I think that this ends up pushing the Curve API to be more flexible and less closely married to the original machinations of |
# Objective Partially address #13408 Rework of #13613 Unify the very nice forms of interpolation specifically present in `bevy_math` under a shared trait upon which further behavior can be based. The ideas in this PR were prompted by [Lerp smoothing is broken by Freya Holmer](https://www.youtube.com/watch?v=LSNQuFEDOyQ). ## Solution There is a new trait `StableInterpolate` in `bevy_math::common_traits` which enshrines a quite-specific notion of interpolation with a lot of guarantees: ```rust /// A type with a natural interpolation that provides strong subdivision guarantees. /// /// Although the only required method is `interpolate_stable`, many things are expected of it: /// /// 1. The notion of interpolation should follow naturally from the semantics of the type, so /// that inferring the interpolation mode from the type alone is sensible. /// /// 2. The interpolation recovers something equivalent to the starting value at `t = 0.0` /// and likewise with the ending value at `t = 1.0`. /// /// 3. Importantly, the interpolation must be *subdivision-stable*: for any interpolation curve /// between two (unnamed) values and any parameter-value pairs `(t0, p)` and `(t1, q)`, the /// interpolation curve between `p` and `q` must be the *linear* reparametrization of the original /// interpolation curve restricted to the interval `[t0, t1]`. /// /// The last of these conditions is very strong and indicates something like constant speed. It /// is called "subdivision stability" because it guarantees that breaking up the interpolation /// into segments and joining them back together has no effect. /// /// Here is a diagram depicting it: /// ```text /// top curve = u.interpolate_stable(v, t) /// /// t0 => p t1 => q /// |-------------|---------|-------------| /// 0 => u / \ 1 => v /// / \ /// / \ /// / linear \ /// / reparametrization \ /// / t = t0 * (1 - s) + t1 * s \ /// / \ /// |-------------------------------------| /// 0 => p 1 => q /// /// bottom curve = p.interpolate_stable(q, s) /// ``` /// /// Note that some common forms of interpolation do not satisfy this criterion. For example, /// [`Quat::lerp`] and [`Rot2::nlerp`] are not subdivision-stable. /// /// Furthermore, this is not to be used as a general trait for abstract interpolation. /// Consumers rely on the strong guarantees in order for behavior based on this trait to be /// well-behaved. /// /// [`Quat::lerp`]: crate::Quat::lerp /// [`Rot2::nlerp`]: crate::Rot2::nlerp pub trait StableInterpolate: Clone { /// Interpolate between this value and the `other` given value using the parameter `t`. /// Note that the parameter `t` is not necessarily clamped to lie between `0` and `1`. /// When `t = 0.0`, `self` is recovered, while `other` is recovered at `t = 1.0`, /// with intermediate values lying between the two. fn interpolate_stable(&self, other: &Self, t: f32) -> Self; } ``` This trait has a blanket implementation over `NormedVectorSpace`, where `lerp` is used, along with implementations for `Rot2`, `Quat`, and the direction types using variants of `slerp`. Other areas may choose to implement this trait in order to hook into its functionality, but the stringent requirements must actually be met. This trait bears no direct relationship with `bevy_animation`'s `Animatable` trait, although they may choose to use `interpolate_stable` in their trait implementations if they wish, as both traits involve type-inferred interpolations of the same kind. `StableInterpolate` is not a supertrait of `Animatable` for a couple reasons: 1. Notions of interpolation in animation are generally going to be much more general than those allowed under these constraints. 2. Laying out these generalized interpolation notions is the domain of `bevy_animation` rather than of `bevy_math`. (Consider also that inferring interpolation from types is not universally desirable.) Similarly, this is not implemented on `bevy_color`'s color types, although their current mixing behavior does meet the conditions of the trait. As an aside, the subdivision-stability condition is of interest specifically for the [Curve RFC](bevyengine/rfcs#80), where it also ensures a kind of stability for subsampling. Importantly, this trait ensures that the "smooth following" behavior defined in this PR behaves predictably: ```rust /// Smoothly nudge this value towards the `target` at a given decay rate. The `decay_rate` /// parameter controls how fast the distance between `self` and `target` decays relative to /// the units of `delta`; the intended usage is for `decay_rate` to generally remain fixed, /// while `delta` is something like `delta_time` from an updating system. This produces a /// smooth following of the target that is independent of framerate. /// /// More specifically, when this is called repeatedly, the result is that the distance between /// `self` and a fixed `target` attenuates exponentially, with the rate of this exponential /// decay given by `decay_rate`. /// /// For example, at `decay_rate = 0.0`, this has no effect. /// At `decay_rate = f32::INFINITY`, `self` immediately snaps to `target`. /// In general, higher rates mean that `self` moves more quickly towards `target`. /// /// # Example /// ``` /// # use bevy_math::{Vec3, StableInterpolate}; /// # let delta_time: f32 = 1.0 / 60.0; /// let mut object_position: Vec3 = Vec3::ZERO; /// let target_position: Vec3 = Vec3::new(2.0, 3.0, 5.0); /// // Decay rate of ln(10) => after 1 second, remaining distance is 1/10th /// let decay_rate = f32::ln(10.0); /// // Calling this repeatedly will move `object_position` towards `target_position`: /// object_position.smooth_nudge(&target_position, decay_rate, delta_time); /// ``` fn smooth_nudge(&mut self, target: &Self, decay_rate: f32, delta: f32) { self.interpolate_stable_assign(target, 1.0 - f32::exp(-decay_rate * delta)); } ``` As the documentation indicates, the intention is for this to be called in game update systems, and `delta` would be something like `Time::delta_seconds` in Bevy, allowing positions, orientations, and so on to smoothly follow a target. A new example, `smooth_follow`, demonstrates a basic implementation of this, with a sphere smoothly following a sharply moving target: https://github.com/bevyengine/bevy/assets/2975848/7124b28b-6361-47e3-acf7-d1578ebd0347 ## Testing Tested by running the example with various parameters.
Okay. After refactoring the draft library, I ended up with some shared interpolation interfaces which seem pretty useful for implementors. (I used them in the I would say that now I'm reasonably happy with this in terms of completeness (with the new approach in mind), at least until someone changes my mind. :) |
This RFC seems mainly focused on animation, but I'd like to drop a link to https://www.forrestthewoods.com/blog/tech_of_planetary_annihilation_chrono_cam/ , a blog post which discusses using a time series Curve type as the primary means of communicating state from the server to the client in a multiplayer game. In this approach the client state is a big collection of Curve which get updated according to new data from the server (which runs the actual simulation and tracks all the data persistently) and client-side predictions which can override stale server data. Perhaps a similar approach could work well with Bevy in the future, it seems very ECS-like (though I ended up using a different approach in my project). |
|
||
/// Create an [`Interval`] by intersecting this interval with another. Returns an error if the | ||
/// intersection would be empty (hence an invalid interval). | ||
pub fn intersect(self, other: Interval) -> Result<Interval, InvalidIntervalError> { //... } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow-up: I think we want a union method too, but that will need a new error arm for non-contiguous intervals.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that sounds useful.
The `Curve::sample` method is not intrinsically constrained by the curve's `domain` interval. Instead, | ||
implementors of `Curve<T>` are free to determine how samples drawn from outside the `domain` will behave. | ||
However, variants of `sample` (as well as other important methods) use the `domain` explicitly: | ||
```rust |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: I think this section / API will be clearer if we explicitly provide an extrapolate
method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was inclined to agree and have the invariant extrapolate(x) == Some(sample(x))
, but this would be bad for readability of code, because then users will use extrapolate to sample the curve. At that point, you're better of having sample return Option, but that is cumbersome for users. Furthermore, a sample(x)
that panics outside its domain will cause issues with floating point rounding errors at the boundary of the domain.
That begs the question: are there use cases for curves where the curve is undefined for some value of x (outside its domain)? If not, we can enforce that curves are defined for all finite values of f32. Curves for which this requirement is a problem can perhaps use clamping to satisfy this constraint.
|
||
### Other ways of making curves | ||
|
||
The curve-creation functions `constant_curve` and `function_curve` that we have been using in examples are in fact |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: I think that linear_curve
is another valuable convenience API of this sort.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely a good idea. I think that "common sense" constructors for doing things like joining a couple points by a curve (and variations on that) are on the short list as far as library functions go.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent work: this is exactly what I hoped to see from both the RFC process and working groups. I'm particularly grateful for the careful thinking around object-safety: this is a key requirement, and very easy to accidentally overlook in a hard-to-fix way.
I've left a few small suggestions, some functional, and some merely copy-editing. None of them are blocking, and I'm personally confident that this is ready for implementation. I'd like @mockersf's sign-off on this before marking this as fully blessed, but I don't think there's serious risk at this stage.
When developing and shipping this feature, the main challenge is going to be teaching this feature to less mathematical users. I think it's valuable and correct to use the standard, rigorous terms, but this is something that e.g. nalgebra
has struggled with in practice. To help alleviate that, I'd like to see a glossary of terms that we can aggressively link to for things like "domain", "preimage", "interpolation" and so on: anything beyond Grade 8 math. Pairing that with extensive practical examples / doc tests for why these APIs might be useful like you've done in this RFC gives me confidence that we can make these powerful tools broadly accessible.
Co-authored-by: Alice Cecile <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is my first time commenting on an RFC. I hope my comments are useful and not too harsh. Feel free to ignore any of my comments that you disagree with.
pub fn new(start: f32, end: f32) -> Result<Self, InvalidIntervalError> { //... } | ||
|
||
/// Get the start of this interval. | ||
pub fn start(self) -> f32 { //... } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename start
to begin
. It's better to not mix the logical pairs begin/end (which are positions) and start/stop (which are actions). For reference: CppCon 2017: Kate Gregory “Naming is Hard: Let's Do Better”.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe "beginning"? We're a bit more verbose usually.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I named them start
and end
because that's what Range
does. I don't really feel strongly about it though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In c++ they use the begin/end pair. In rust they unfortunately choose start/end. I had not realized that when I wrote this. In that case staying consistent with rust is probably better. Unless, you want to have an animation type that has both start/stop methods to control whether the animation is active, and begin/end methods to access the begin and end time of the animation.
P.S. even in the documentation for Pair at some point they use begin/end: https://doc.rust-lang.org/core/ops/struct.Range.html#impl-SliceIndex%3Cstr%3E-for-Range%3Cusize%3E.
rfcs/80-curve-trait.md
Outdated
|
||
/// Get the linear map which maps this curve onto the `other` one. Returns an error if either | ||
/// interval is infinite. | ||
pub fn linear_map_to(self, other: Self) -> Result<impl Fn(f32) -> f32, InfiniteIntervalError> { //... } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand this is used to implement Curve::reparametrize_linear
. Is there a reason this should be exposed in the public API?
The comment here refers to the interval as a "curve", that should be "interval".
For the documentation: I'd write something like, "Returns a linear function f
such that f(self.begin) == other.begin
and f(self.end) == other.end
. This makes clear that f
takes values within the domain of self
.
I would suggest to have its return type be Curve<f32>
rather than Fn(f32)
such that its type can carry continuity information that can be used to determine the resulting continuity when it is chained with another curve.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand this is used to implement
Curve::reparametrize_linear
. Is there a reason this should be exposed in the public API?
Probably not!
The comment here refers to the interval as a "curve", that should be "interval".
👍
For the documentation: I'd write something like, "Returns a linear function
f
such thatf(self.begin) == other.begin
andf(self.end) == other.end
. This makes clear thatf
takes values within the domain ofself
.I would suggest to have its return type be
Curve<f32>
rather thanFn(f32)
such that its type can carry continuity information that can be used to determine the resulting continuity when it is chained with another curve.
That's probably a good idea!
rfcs/80-curve-trait.md
Outdated
|
||
The `Interval` type also implements `TryFrom<RangeInclusive>`, which may be desirable if you want to use | ||
the `start..=end` syntax. One of the primary benefits of `Interval` (in addition to these methods) is | ||
that it is `Copy`, so it is easy to take intervals and throw them around. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add here that Interval
enforces that the interval is non-empty. I would like to mention here that Range
is iterable, while Interval
should not be.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure. Hopefully one day Range
won't be iterable either (but IntoIterator
), and paradise will truly be ours.
rfcs/80-curve-trait.md
Outdated
pub fn spaced_points( | ||
self, | ||
points: usize, | ||
) -> Result<impl Iterator<Item = f32>, SpacedPointsError> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Imho corner cases should be avoided if there are sensible defaults. Corner case handling is very prone to introduce bugs.
For points = 0
, this can return an empty iterator; and
for points = 1
this can return an iterator with only the start
value.
For example: this is useful for playing an animation for 1 frame (Godot uses this for their RESET animation track).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That probably makes sense for this, yeah. I was just modeling this after the resample
method, which is probably also going to change.
The `Curve::sample` method is not intrinsically constrained by the curve's `domain` interval. Instead, | ||
implementors of `Curve<T>` are free to determine how samples drawn from outside the `domain` will behave. | ||
However, variants of `sample` (as well as other important methods) use the `domain` explicitly: | ||
```rust |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was inclined to agree and have the invariant extrapolate(x) == Some(sample(x))
, but this would be bad for readability of code, because then users will use extrapolate to sample the curve. At that point, you're better of having sample return Option, but that is cumbersome for users. Furthermore, a sample(x)
that panics outside its domain will cause issues with floating point rounding errors at the boundary of the domain.
That begs the question: are there use cases for curves where the curve is undefined for some value of x (outside its domain)? If not, we can enforce that curves are defined for all finite values of f32. Curves for which this requirement is a problem can perhaps use clamping to satisfy this constraint.
Often, one will want to define a curve using some kind of interpolation over discrete data or, conversely, | ||
extract lists of samples that approximate a curve, or convert a curve to one which has been discretized. | ||
|
||
For the first of these, there is the type `SampleCurve<T, I>`, whose constructor looks like this: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about the name of SampleCurve<T,I>
. It does not really convey what it does. It could also be called a PiecewiseCurve<T,I>
or InterpolatedCurve<T,I>
. But those names come with their own issues.
Considering that I
interpolates between two samples and without information about derivatives, the interpolation options are very limited (e.g. step, nearest, linear and perhaps some smoothstep). Because of that, I'd suggest tot have the name of SampleCurve<T,I>
be an implementation detail and only expose specific curve types e.g. PiecewiseConstantCurve
for I=step
, PiecewiseLinearCurve
for I=lerp
.
A constant curve is still a perfectly valid curve for when only 1 sample is provided. If this can be implemented with negligible overhead, this is preferred over introducing a corner case where a sensible default is available.
Scope creep / things that we may want to support in a future version:
It makes sense to have a generic class for piecewise/interpolated curves as there's a lot of common code between these types of curves. There's two ways in which
SampleCurve
can be extended to support things likeBezierCurve
.
- Allow the interpolant to access more than 2 samples. In this case we have to define how end points are dealt with.
- Allow samples to include derivative information. (i.e.
samples: impl Into<Vec<Sample<T>>
, where Sample indicates what derivative information is available. Then implementimpl<T> From<T> for Sample<T>
for ease of use.)Both are useful when resampling curves, while maintaining continuity properties.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about the name of
SampleCurve<T,I>
. It does not really convey what it does. It could also be called aPiecewiseCurve<T,I>
orInterpolatedCurve<T,I>
. But those names come with their own issues.
Yeah, at one point I called this "SampleInterpolatedCurve", which is what I kind of want to convey, but it's also too long :P
Considering that
I
interpolates between two samples and without information about derivatives, the interpolation options are very limited (e.g. step, nearest, linear and perhaps some smoothstep). Because of that, I'd suggest tot have the name ofSampleCurve<T,I>
be an implementation detail and only expose specific curve types e.g.PiecewiseConstantCurve
forI=step
,PiecewiseLinearCurve
forI=lerp
.
Well, in principle these can use any information from the sample values at each point, which could make them considerably more complex. On the other hand, the purpose of making this just take an Fn
is explicitly to move the choice of interpolation modes "upward" to consumers in general (for example, to make interpolation guided by an enum or similar).
A constant curve is still a perfectly valid curve for when only 1 sample is provided. If this can be implemented with negligible overhead, this is preferred over introducing a corner case where a sensible default is available.
My thoughts on this right now are that we should probably use the number of segments instead of samples
, which seems more intuitive to me. That also has the side-effect of leaving only the error case of zero segments, which seems kind of unavoidable regardless (if only it were ergonomic for users to provide a NonZeroUsize
....).
Scope creep / things that we may want to support in a future version:
It makes sense to have a generic class for piecewise/interpolated curves as there's a lot of common code between these types of curves.
Yeah; perhaps this could be a part of the story for the analogue of FromIterator
/collect
at some point.
- Allow samples to include derivative information. (i.e.
samples: impl Into<Vec<Sample<T>>
, where Sample indicates what derivative information is available. Then implementimpl<T> From<T> for Sample<T>
for ease of use.)
Yeah, there is some ongoing work on including derivative information as well; I think some general form of Hermite approximation for differentiable curves would be quite useful.
```rust | ||
/// Extract an iterator over evenly-spaced samples from this curve. If `samples` is less than 2 | ||
/// or if this curve has unbounded domain, then an error is returned instead. | ||
fn samples(&self, samples: usize) -> Result<impl Iterator<Item = T>, ResamplingError> { //... } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably take an Into<Iterator>
.
Minor scope creep:
For
T : NormedVectorSpace
it is useful to have a convenience functionfn distance(points: impl Iterator<T>)
that takes and returns an iterator that computes the cumulative distance. This can be used to remap a Curve into an approximately constant velocity Curve, which is a common use case for curves.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably take an
Into<Iterator>
.
I wrote in the document about why this isn't the case: basically, you can just .map
over your iterator if that's what you want to do, and the behavior of requested samples that aren't within the curve domain seems like it might be sensitive to the problem at hand. The benefit of having this method as-is is that it's just really simple: for example, if I want to take my curve and get a vector of points to render into a linestrip with gizmos, I can just do something like this without giving it much thought:
gizmos.linestrip(my_curve.samples(100));
As for the distance function: this is a short-term goal, but I think it would be best addressed when we start trying to do geometry with curves :)
/// A total of `samples` samples are used, although at least two samples are required to produce | ||
/// well-formed output. If fewer than two samples are provided, or if this curve has an unbounded | ||
/// domain, then a [`ResamplingError`] is returned. | ||
fn resample<I>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be a static member function of the destination curve type. I.e. defined as fn resample(impl Curve<T>, samples:usize) -> Self
. For example, a user might want to resample the curve as a bezier curve. This API forces a specific output type.
Sidenote: I thought that maybe samples can be a Vec in case the sampling should not happen uniformly over t
, However, that functionality is already available through remapping.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, in the interest of "minimality" this is as it is presently, but I think there is a reasonable chance that we end up trying to mirror the situation of FromIterator
/collect
with resampled curve types in the future; I just wanted for things to remain relatively simple for the time being.
{ //... } | ||
``` | ||
|
||
This story has a parallel in `UnevenSampleCurve`, which behaves more like keyframes in that the samples need not be evenly spaced: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With a curve that maps timestamps to equidistant points (e.g. the SampleCurve.inverted()
I suggested earlier) and curve chaining, this can replace UnevenSampleCurve
entirely.
rfcs/80-curve-trait.md
Outdated
/// with outputs of the same type. The domain of the other curve is translated so that its start | ||
/// coincides with where this curve ends. A [`CompositionError`] is returned if this curve's domain | ||
/// doesn't have a finite right endpoint or if `other`'s domain doesn't have a finite left endpoint. | ||
fn compose<C>(self, other: C) -> Result<impl Curve<T>, CompositionError> { //... } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like this function needs a bit more thought. What is the behavior if self.end() != other.begin()
?
My suggestion: ignore the domains and have the user specify where the breakpoint is.
What is the resulting type of composing curves that themselves are composed of curves?
My suggestion: have that be
PiecewiseCurve<impl Curve<T>>
. This allows composing a variable number of curves at runtime.
I want to point out that there is some similarity between piecewise composed curves and SampleCurve<T,I>
. Especially as type I
is very similar to a curve. However implementing SampleCurve
as a PiecewiseCurve
would cause storage overhead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like this function needs a bit more thought. What is the behavior if
self.end() != other.begin()
?
Right now, it just translates the second curve so that its start coincides with the end of the first. If you mean in terms of values — there are no guarantees: a Curve
isn't necessarily continuous, and in general this operator will combine two continuous curves into a discontinuous one.
What is the resulting type of composing curves that themselves are composed of curves?
My suggestion: have that be
PiecewiseCurve<impl Curve<T>>
. This allows composing a variable number of curves at runtime.
Yeah, there is probably more to be done here; the goal of the minimal version of end-to-end composition in the API is to work with arbitrary curves, so the output type contains those of the input curves as generic parameters. The story for longer sequences of curves is a little thornier, since we don't really have HList
s. One idea is to do what you're saying with piecewise constructions, but this only works if every curve in the sequence has exactly the same type. In its most general form, there probably isn't any way to get around using Box<dyn Curve<T>>
as the curve type in such a thing presently.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the generic case dynamic dispatch is practically unavoidable. Even a Composed<Curve,Curve> has a function call stuck behind a branch, that the compiler probably can't do much with.
I'm not sure whether the PiecewiseCurve I suggest here can actually be implemented in rust. If I'm correct, you cannot have:
fn chain(self, other: Curve<T>) -> PiecewiseCurve<Curve<T>;
in the generic case; and
fn chain(self, other: PiecewiseCurve<Curve<T>>) -> PiecewiseCurve<Curve<T>;
as specialization.
This may be possible with either negative trait bounds or specialization, but both are still unstable features and might still not provide the needed flexibility.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can do something like that with the caveat that you use an opaque impl Curve<T>
return type in the trait definition (and do the specialization on self
rather than other
). I have personally gone back and forth with these sorts of optimizations, since they basically get destroyed the moment you do anything other than call the same method twice.
RENDERED
This RFC describes a trait API for general curves within the Bevy ecosystem, abstracting over their low-level implementations.
It has a partial implementation in this draft PR.
Integration with
bevy_animation
has a proof-of-concept prototype in this draft PR.Integration with
bevy_math
's cubic splines has a proof-of-concept prototype in this draft PR.