use osu's bezier approximation

This commit is contained in:
MaxOhn
2021-11-04 21:37:54 +01:00
parent 3f8d023e71
commit 8cb14d88ba
11 changed files with 298 additions and 54 deletions
+1
View File
@@ -1,6 +1,7 @@
/target
Cargo.lock
/maps
output
/pp-gen/.env
/pp-gen/target
/pp-gen/input/*
+245 -21
View File
@@ -3,6 +3,8 @@
all(feature = "osu", not(feature = "no_sliders_no_leniency"))
))]
use std::{borrow::Cow, cmp::Ordering, convert::identity};
use crate::{
math_util,
parse::{PathType, Pos2},
@@ -27,7 +29,10 @@ impl Points {
}
pub(crate) enum Curve<'p> {
Bezier(Points),
Bezier {
path: Vec<Pos2>,
lengths: Vec<f32>,
},
Catmull(Points),
Linear(&'p [Pos2]),
Perfect {
@@ -37,52 +42,237 @@ pub(crate) enum Curve<'p> {
},
}
struct BezierBuffers {
buf1: Vec<Pos2>,
buf2: Vec<Pos2>,
buf3: Vec<Pos2>,
}
impl BezierBuffers {
fn new(len: usize) -> Self {
Self {
buf1: vec![Pos2::zero(); len],
buf2: vec![Pos2::zero(); (len - 1) * 2 + 1],
buf3: vec![Pos2::zero(); len],
}
}
}
impl<'p> Curve<'p> {
#[inline]
pub(crate) fn new(points: &'p [Pos2], kind: PathType) -> Self {
pub(crate) fn new(points: &'p [Pos2], kind: PathType, expected_len: f32) -> Self {
match kind {
PathType::Bezier => Self::bezier(points),
PathType::Bezier => Self::bezier(points, expected_len),
PathType::Catmull => Self::catmull(points),
PathType::Linear => Self::Linear(points),
PathType::PerfectCurve => Self::perfect(points),
}
}
fn bezier(points: &[Pos2]) -> Self {
if points.len() == 1 {
return Self::Bezier(Points::Single(points[0]));
fn bezier(points: &[Pos2], expected_len: f32) -> Self {
let points: Vec<_> = points
.iter()
.copied()
.map(|point| point - points[0])
.collect();
let len = points.len();
if len == 1 {
return Self::Bezier {
path: points.to_owned(),
lengths: vec![0.0],
};
}
// First calculate a path of coordinates
let mut start = 0;
let mut result = Vec::new();
let mut path = Vec::new();
let mut bufs = BezierBuffers::new(len);
for (end, (curr, next)) in (1..).zip(points.iter().zip(points.iter().skip(1))) {
if end - start > 1 && curr == next {
Self::_bezier(&mut result, &points[start..end]);
Self::bezier_subpath(&mut path, &points[start..end], &mut bufs);
start = end;
}
}
Self::_bezier(&mut result, &points[start..]);
Self::bezier_subpath(&mut path, &points[start..], &mut bufs);
let last_point = &points[len - 1];
path.push(*last_point);
Self::Bezier(Points::Multi(result))
// Then calculated cumulative lenghts
let mut calculated_len = 0.0;
let mut cumulative_len = Vec::new();
cumulative_len.push(0.0);
for i in 0..path.len() - 1 {
let diff = path[i + 1] - path[i];
calculated_len += diff.length();
cumulative_len.push(calculated_len);
}
if (expected_len - calculated_len).abs() > f32::EPSILON {
// * In osu-stable, if the last two control points of a slider are equal, extension is not performed
if points
.get(len - 2)
.filter(|&p| p == last_point && expected_len > calculated_len)
.is_some()
{
cumulative_len.push(calculated_len);
return Self::Bezier {
path,
lengths: cumulative_len,
};
}
// * The last length is always incorrect
cumulative_len.pop();
let mut path_end_idx = path.len() - 1;
if calculated_len > expected_len {
// * The path will be shortened further, in which case we should trim
// * any more unnecessary lengths and their associated path segments
while cumulative_len
.last()
.filter(|&l| *l > expected_len)
.is_some()
{
cumulative_len.pop();
path.remove(path_end_idx);
path_end_idx -= 1;
}
}
if path_end_idx == 0 {
// * The expected distance is negative or zero
// * Perhaps negative path lengths should be disallowed altogether
cumulative_len.push(0.0);
return Self::Bezier {
path,
lengths: cumulative_len,
};
}
// * The direction of the segment to shorten or lengthen
let dir = (path[path_end_idx] - path[path_end_idx - 1]).normalize();
path[path_end_idx] =
path[path_end_idx - 1] + dir * (expected_len - cumulative_len.last().unwrap());
cumulative_len.push(expected_len);
}
Self::Bezier {
path,
lengths: cumulative_len,
}
}
fn _bezier(result: &mut Vec<Pos2>, points: &[Pos2]) {
let step = (BEZIER_TOLERANCE / points.len() as f32).max(0.01);
let mut i = 0.0;
let n = points.len() as i32 - 1;
fn bezier_subpath(result: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
let p = points.len();
while i < 1.0 + step {
let point = (0..).zip(points).fold(Pos2::zero(), |point, (p, curr)| {
point + *curr * math_util::cpn(p, n) * (1.0 - i).powi(n - p) * i.powi(p)
});
let mut to_flatten = Vec::new();
let mut free_bufs = Vec::with_capacity(1);
result.push(point);
i += step;
// In osu!lazer's code, `p` is always 0 when approximating bezier
// so the first big `if` can be omitted
to_flatten.push(Cow::Borrowed(points));
// * "toFlatten" contains all the curves which are not yet approximated well enough.
// * We use a stack to emulate recursion without the risk of running into a stack overflow.
// * (More specifically, we iteratively and adaptively refine our curve with a
// * <a href="https://en.wikipedia.org/wiki/Depth-first_search">Depth-first search</a>
// * over the tree resulting from the subdivisions we make.)
let mut left_child = bufs.buf2.to_owned();
while let Some(mut parent) = to_flatten.pop() {
if Self::bezier_is_flat_enough(&parent) {
// * If the control points we currently operate on are sufficiently "flat", we use
// * an extension to De Casteljau's algorithm to obtain a piecewise-linear approximation
// * of the bezier curve represented by our control points, consisting of the same amount
// * of points as there are control points.
Self::bezier_approximate(&parent, result, bufs);
free_bufs.push(parent);
continue;
}
// * If we do not yet have a sufficiently "flat" (in other words, detailed) approximation we keep
// * subdividing the curve we are currently operating on.
let mut right_child = free_bufs
.pop()
.unwrap_or_else(|| Cow::Owned(vec![Pos2::zero(); p]));
Self::bezier_subdivide(
&parent,
&mut left_child,
right_child.to_mut(),
&mut bufs.buf1,
);
// * We re-use the buffer of the parent for one of the children, so that we save one allocation per iteration.
parent.to_mut().copy_from_slice(&left_child[..p]);
to_flatten.push(right_child);
to_flatten.push(parent);
}
}
fn bezier_is_flat_enough(points: &[Pos2]) -> bool {
let limit = BEZIER_TOLERANCE * BEZIER_TOLERANCE * 4.0;
!points
.iter()
.zip(points.iter().skip(1))
.zip(points.iter().skip(2))
.any(|((&prev, &curr), &next)| (prev - curr * 2.0 + next).length_squared() > limit)
}
fn bezier_subdivide(points: &[Pos2], l: &mut [Pos2], r: &mut [Pos2], buf: &mut [Pos2]) {
let count = points.len();
let midpoints = buf;
midpoints[..count].copy_from_slice(&points[..count]);
for i in (1..count).rev() {
l[count - i - 1] = midpoints[0];
r[i] = midpoints[i];
for j in 0..i {
midpoints[j] = (midpoints[j] + midpoints[j + 1]) / 2.0;
}
}
l[count - 1] = midpoints[0];
r[0] = midpoints[0];
}
// * https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm
fn bezier_approximate(points: &[Pos2], output: &mut Vec<Pos2>, bufs: &mut BezierBuffers) {
let count = points.len();
let r = &mut bufs.buf1;
let l = &mut bufs.buf2;
Self::bezier_subdivide(points, l, r, &mut bufs.buf3);
l[count..2 * count - 1].copy_from_slice(&r[1..count]);
output.push(points[0]);
let new_points = l
.iter()
.skip(1)
.zip(l.iter().skip(2))
.zip(l.iter().skip(3))
.step_by(2)
.take(count.saturating_sub(2))
.map(|((&prev, &curr), &next)| (prev + curr * 2.0 + next) * 0.25);
output.extend(new_points);
}
fn catmull(points: &[Pos2]) -> Self {
let len = points.len();
@@ -164,9 +354,43 @@ impl<'p> Curve<'p> {
}
}
fn interpolate_vertices(path: &[Pos2], lengths: &[f32], i: usize, d: f32) -> Pos2 {
if path.is_empty() {
return Pos2::zero();
}
if i == 0 {
return path[0];
} else if i >= path.len() {
return path[path.len() - 1];
}
let p0 = path[i - 1];
let p1 = path[i];
let d0 = lengths[i - 1];
let d1 = lengths[i];
// * Avoid division by an almost-zero number in case
// * two points are extremely close to each other
if (d0 - d1).abs() <= f32::EPSILON {
return p0;
}
let w = (d - d0) / (d1 - d0);
p0 + (p1 - p0) * w
}
pub(crate) fn point_at_distance(&self, dist: f32) -> Pos2 {
match self {
Self::Bezier(points) => points.point_at_distance(dist),
Self::Bezier { path, lengths } => {
let idx = lengths
.binary_search_by(|len| len.partial_cmp(&dist).unwrap_or(Ordering::Equal))
.map_or_else(identity, identity);
Self::interpolate_vertices(path, lengths, idx, dist)
}
Self::Catmull(points) => points.point_at_distance(dist),
Self::Linear(points) => math_util::point_at_distance(points, dist),
Self::Perfect {
+2 -2
View File
@@ -92,7 +92,7 @@ pub fn stars(
/ 100.0;
// Build the curve w.r.t. the curve points
let curve = Curve::new(curve_points, *path_type);
let curve = Curve::new(curve_points, *path_type, *pixel_len);
let mut current_distance = tick_distance;
let time_add = duration * (tick_distance / (*pixel_len * *repeats as f32));
@@ -323,7 +323,7 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
/ 100.0;
// Build the curve w.r.t. the curve points
let curve = Curve::new(curve_points, *path_type);
let curve = Curve::new(curve_points, *path_type, *pixel_len);
let mut current_distance = tick_distance;
let time_add = duration * (tick_distance / (*pixel_len * *repeats as f32));
+1 -18
View File
@@ -11,23 +11,6 @@ pub(crate) use fruits_osu::*;
mod fruits_osu {
use crate::parse::Pos2;
#[inline]
pub(crate) fn cpn(mut p: i32, n: i32) -> f32 {
if p < 0 || p > n {
return 0.0;
}
p = p.min(n - p);
let diff = n - p;
let mut out = 1.0;
for i in 1..=p {
out *= (diff + i) as f32 / i as f32;
}
out
}
pub(crate) fn point_at_distance(points: &[Pos2], dist: f32) -> Pos2 {
if points.len() < 2 {
return Pos2::zero();
@@ -43,7 +26,7 @@ mod fruits_osu {
let mut new_dist = 0.0;
for (&curr, &next) in points.iter().zip(points.iter().skip(1)) {
new_dist = (curr - next).length();
new_dist = (curr - next).length().max(f32::EPSILON);
curr_dist += new_dist;
if dist <= curr_dist {
+1 -1
View File
@@ -346,7 +346,7 @@ impl OsuPPInner {
PerformanceAttributes {
attributes: self.attributes,
pp_acc: aim_value,
pp_acc: acc_value,
pp_aim: aim_value,
pp_flashlight: flashlight_value,
pp_speed: speed_value,
+24
View File
@@ -337,3 +337,27 @@ pub fn strains(map: &Beatmap, mods: impl Mods) -> Strains {
strains,
}
}
#[test]
fn custom_osu() {
let file = std::fs::File::open("E:Games/osu!/beatmaps/2753127_.osu").unwrap();
// let file = std::fs::File::open("E:Games/osu!/beatmaps/2571051.osu").unwrap();
let map = Beatmap::parse(file).unwrap();
let start = std::time::Instant::now();
let result = crate::OsuPP::new(&map).mods(0).calculate();
let iters = 500;
let accum = start.elapsed();
// let mut accum = accum;
// for _ in 0..iters {
// let start = std::time::Instant::now();
// let _result = crate::OsuPP::new(&map).mods(0).calculate();
// accum += start.elapsed();
// }
println!("{:#?}", result);
println!("Average: {:?}", accum / iters);
}
+2 -1
View File
@@ -70,7 +70,7 @@ impl OsuObject {
let span_duration = duration / *repeats as f32;
// Build the curve w.r.t. the curve points
let curve = Curve::new(curve_points, *path_type);
let curve = Curve::new(curve_points, *path_type, *pixel_len);
// Called on each slider object except for the head.
// Increases combo and adjusts `end_pos` and `travel_dist`
@@ -95,6 +95,7 @@ impl OsuObject {
if dist > approx_follow_circle_radius {
dist -= approx_follow_circle_radius;
lazy_end_pos += diff.normalize() * dist;
travel_dist += dist;
}
};
+1 -1
View File
@@ -70,7 +70,7 @@ impl Skill {
self.strain_peaks
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in &self.strain_peaks {
for &strain in self.strain_peaks.iter() {
difficulty += strain * weight;
weight *= decay_weight;
}
+3 -3
View File
@@ -159,9 +159,9 @@ impl SkillKind {
let mut small_dist_nerf = 1.0;
let mut result = 0.0;
let mut cumulative_strain_time = 0.0;
let mut history = history.iter();
let mut history_iter = history.iter();
if let Some(prev) = history.next() {
if let Some(prev) = history_iter.next() {
// Handle first entry distinctly for slight optimization
if !prev.is_spinner {
let jump_dist = (curr.base.pos - prev.end_pos).length();
@@ -180,7 +180,7 @@ impl SkillKind {
let factors = iter::successors(Some(0.8), |s| Some(s * 0.8));
for (factor, prev) in factors.zip(history) {
for (factor, prev) in factors.zip(history_iter) {
if !prev.is_spinner {
let jump_dist = (curr.base.pos - prev.end_pos).length();
cumulative_strain_time += prev.strain_time;
+3
View File
@@ -28,6 +28,7 @@ pub enum ParseError {
InvalidPathType,
InvalidTimingSignature,
MissingField(&'static str),
TooManyRepeats,
UnknownHitObjectKind,
#[cfg(not(all(
@@ -54,6 +55,7 @@ impl fmt::Display for ParseError {
Self::InvalidPathType => f.write_str("invalid path type"),
Self::InvalidTimingSignature => f.write_str("invalid timing signature"),
Self::MissingField(field) => write!(f, "missing field `{}`", field),
Self::TooManyRepeats => f.write_str("repeat count is way too high"),
Self::UnknownHitObjectKind => f.write_str("unsupported hitobject kind"),
#[cfg(not(all(
@@ -84,6 +86,7 @@ impl StdError for ParseError {
Self::InvalidPathType => None,
Self::InvalidTimingSignature => None,
Self::MissingField(_) => None,
Self::TooManyRepeats => None,
Self::UnknownHitObjectKind => None,
#[cfg(not(all(
+15 -7
View File
@@ -419,9 +419,9 @@ macro_rules! parse_hitobjects_body {
y: next_field!(split.next(), "y position").parse()?,
};
let time = next_field!(split.next(), "hitobject time")
let time: f32 = next_field!(split.next(), "hitobject time")
.trim()
.parse::<f32>()?;
.parse()?;
validate_float!(time);
@@ -449,6 +449,17 @@ macro_rules! parse_hitobjects_body {
let mut curve_point_iter = next_field!(split.next(), "curve points").split('|');
let mut repeats: usize = next_field!(split.next(), "repeats")
.parse()?;
if repeats > 9000 {
return Err(ParseError::TooManyRepeats);
}
// * osu-stable treated the first span of the slider
// * as a repeat, but no repeats are happening
repeats = repeats.saturating_sub(1);
let mut path_type: PathType =
next_field!(curve_point_iter.next(), "path kind").parse()?;
@@ -499,10 +510,7 @@ macro_rules! parse_hitobjects_body {
if curve_points.is_empty() {
HitObjectKind::Circle
} else {
let repeats = next_field!(split.next(), "repeats")
.parse::<usize>()?
.min(9000);
// TODO: Should be Option<f32>?
let pixel_len = next_field!(split.next(), "pixel len")
.parse::<f32>()?
.max(0.0)
@@ -552,7 +560,7 @@ macro_rules! parse_hitobjects_body {
pos,
start_time: time,
kind,
sound,
sound, // TODO: omit if not taiko?
});
prev_time = time;