From cf4b1880d2e688e3965211f348e56342f1ef5d20 Mon Sep 17 00:00:00 2001 From: Aleksey Olokhtonov Date: Sun, 12 Oct 2025 01:08:17 +0300 Subject: [PATCH] Actually perfect smoothing. Amazing, good, Demetri.y --- client/config.js | 8 +++- client/math.js | 84 ++++++++++++++++++++++++++++++++++++--- client/webgl_geometry.js | 85 ++++++++++++++++++++++++++++------------ 3 files changed, 146 insertions(+), 31 deletions(-) diff --git a/client/config.js b/client/config.js index ddb3cc3..3724741 100644 --- a/client/config.js +++ b/client/config.js @@ -33,7 +33,13 @@ const config = { }, debug_force_lod: null, demetri_ms: 40, - p: 'demetri', + p: 'avg', + avg_window: 10, + + // WR is the weight ratio of the most recent point vs the oldest point used + // to fit, linearly interpolated for the rest; 1.0 means even weighting of all + // data, 5.0 means fitting the most recent point matters 5x in the optimization + wr: 1.0, /* * points of interest (desk/zoomlevel/x/y) diff --git a/client/math.js b/client/math.js index e1262d7..28cceb1 100644 --- a/client/math.js +++ b/client/math.js @@ -297,6 +297,16 @@ function dotn(a, b) { return r; } +function dotn3(a, b, c) { + let r = 0; + + for (let i = 0; i < a.length; ++i) { + r += a[i] * b[i] * c[i]; + } + + return r; +} + function mix(a, b, t) { return a * t + b * (1 - t); } @@ -478,9 +488,75 @@ function stroke_intersects_capsule(state, stroke, a, b, radius) { return false; } -function estimate_next_point(ts, xs, ys, dt=0) { +function proj_remove_x_from_y(xs, ys, ws) { + const dxx = dotn3(xs, xs, ws); + const dxy = dotn3(xs, ys, ws); + + if (dxx < 1e-6) { + return [0, ys]; + } + + const c = dxy / dxx; + const res = []; + + for (let i = 0; i < xs.length; ++i) { + res.push(ys[i] - c * xs[i]); + } + + return [c, res]; +} + +function estimate_next_point2(ts, xs, ys, wr=1.0) { const N = ts.length; + if (N < 2) { + return [xs[N - 1], ys[N - 1]]; + } + + // inner product weight, power-law ramp over time from W0 to 1.0 (max) + const t0 = ts[0]; + const t1 = ts[N - 1]; + const ws = []; + + // constant and quadratic basis terms + const q0 = []; + const ss = []; + + for (const t of ts) { + ws.push(1 + ((wr - 1) * (t - t0) / (t1 - t0))); + q0.push(1); + ss.push(t * t); + } + + // constant term + const [c0x, xs_0] = proj_remove_x_from_y(q0, xs, ws); + const [c0y, ys_0] = proj_remove_x_from_y(q0, ys, ws); + const [c0t, ts_0] = proj_remove_x_from_y(q0, ts, ws); + const [c0s, ss_0] = proj_remove_x_from_y(q0, ss, ws); + + // linear term + const [c01x, xs_01] = proj_remove_x_from_y(ts_0, xs_0, ws); + const [c01y, ys_01] = proj_remove_x_from_y(ts_0, ys_0, ws); + // don't need to do ts here because it's guaranteed to go to zero + const [c01s, ss_01] = proj_remove_x_from_y(ts_0, ss_0, ws); + + // quadratic term + const [c012x, xs_012] = proj_remove_x_from_y(ss_01, xs_0, ws); + const [c012y, ys_012] = proj_remove_x_from_y(ss_01, ys_0, ws); + + const reconstructed_x = c0x * 1 + c01x * ts_0[N - 1] + c012x * ss_01[N - 1]; + const reconstructed_y = c0y * 1 + c01y * ts_0[N - 1] + c012y * ss_01[N - 1]; + + return [reconstructed_x, reconstructed_y]; +} + +function estimate_next_point1(ts, xs, ys, dt=0) { + const N = ts.length; + + if (N < 2) { + return [xs[N - 1], ys[N - 1]]; + } + // mean values let mx = 0, my = 0, mt = 0; @@ -494,10 +570,6 @@ function estimate_next_point(ts, xs, ys, dt=0) { mx /= N; my /= N; - if (N < 2) { - return [xs[N - 1], ys[N - 1]]; - } - // orthogonalize against constant term for (let i = 0; i < N; ++i) { ts[i] -= mt; @@ -548,7 +620,7 @@ function smooth_curve(points, window_ms=config.demetri_ms, dt=0) { y_window.push(p.y); } - const [nx, ny] = estimate_next_point(t_window, x_window, y_window, dt); + const [nx, ny] = estimate_next_point2(t_window, x_window, y_window, config.wr); result.push({ 'x': nx, diff --git a/client/webgl_geometry.js b/client/webgl_geometry.js index dd0bfcb..a227b9d 100644 --- a/client/webgl_geometry.js +++ b/client/webgl_geometry.js @@ -252,50 +252,87 @@ function geometry_add_prepoint_raw(state, context, player_id, point, is_pen, raw recompute_dynamic_data(state, context); } +function average_screen_speed(state, points, last, range=10) { + let screen_last = canvas_to_screen(state, points[points.length - 1]); + let sum_speed = 0; + let n = 0; + + for (let i = points.length - 1; i >= 0 && i >= points.length - range; --i) { + const point = points[i]; + const screen_this = canvas_to_screen(state, point); + const screen_dx = Math.abs(screen_this.x - screen_last.x); + const screen_dy = Math.abs(screen_this.y - screen_last.y); + const screen_speed = Math.sqrt(screen_dx * screen_dx + screen_dy * screen_dy); + + sum_speed += screen_speed; + n++; + + screen_last = screen_this; + } + + if (n === 0) { + return 0; + } + + return sum_speed / n; +} + function geometry_add_prepoint_new(state, context, player_id, point, is_pen, raw = false) { if (!state.online) return; const player = state.players[player_id]; const stroke = player.strokes[player.strokes.length - 1]; const points = stroke.points; + const raw_points = stroke.raw_points; if (point.pressure < config.min_pressure) { point.pressure = config.min_pressure; } - const exp_window = 3; - - if (points.length > exp_window) { - let xsum = 0; - let ysum = 0; + let avg_window = 0; - const screen_last = canvas_to_screen(state, points[points.length - 1]); - const screen_this = canvas_to_screen(state, point); + if (raw_points.length > 0) { + const screen_speed = average_screen_speed(state, raw_points, point); - const screen_dx = screen_this.x - screen_last.x; - const screen_dy = screen_this.y - screen_last.y; + // Empirically chosen. + // TODO: dpi scaling? + const bot = 1; + const top = 10; - // TODO: Higher (screen space!) speed gives more weight to the new point - const weight_x = 1; - const weight_y = 1; + const max_avg_window = config.avg_window; - for (let i = points.length - exp_window; i < points.length; ++i) { - xsum += points[i].x * weight_x; - ysum += points[i].y * weight_y; + if (screen_speed <= bot) { + avg_window = max_avg_window; + } else if (screen_speed >= top) { + avg_window = 0; + } else { + const onezero = 1.0 - (screen_speed - bot) / (top - bot); + avg_window = Math.floor(onezero * max_avg_window); } + } - xsum += point.x * weight_x; - ysum += point.y * weight_y + avg_window = Math.min(avg_window, raw_points.length); - points.push({ - 'x': xsum / (exp_window + 1), - 'y': ysum / (exp_window + 1), - 'pressure': point.pressure - }); - } else { - points.push(point); + let xsum = 0; + let ysum = 0; + + for (let i = raw_points.length - avg_window; i < raw_points.length; ++i) { + xsum += raw_points[i].x; + ysum += raw_points[i].y; } + xsum += point.x; + ysum += point.y; + + points.push({ + 'x': xsum / (avg_window + 1), + 'y': ysum / (avg_window + 1), + 'pressure': point.pressure + }); + + stroke.raw_points.push(point); + + recompute_dynamic_data(state, context); }