diff --git a/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs b/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs index f3b3283f..5cddb40b 100644 --- a/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs +++ b/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs @@ -520,6 +520,7 @@ namespace Ink_Canvas && penType != 1 && e.Stroke?.DrawingAttributes != null && !e.Stroke.DrawingAttributes.IsHighlighter + && !e.Stroke.ContainsPropertyData(RealtimeVelocityBrushTipAppliedGuid) && e.Stroke.StylusPoints.Count >= 3) { ApplyVelocityBrushTipFromSpeed(e.Stroke); diff --git a/Ink Canvas/MainWindow_cs/MW_TouchEvents.cs b/Ink Canvas/MainWindow_cs/MW_TouchEvents.cs index aec603cd..b3cdcfd1 100644 --- a/Ink Canvas/MainWindow_cs/MW_TouchEvents.cs +++ b/Ink Canvas/MainWindow_cs/MW_TouchEvents.cs @@ -48,6 +48,198 @@ namespace Ink_Canvas private bool isPalmEraserActive; private bool palmEraserWasEnabledBeforeMultiTouch; private InkCanvasEditingMode palmEraserPreviousEditingMode = InkCanvasEditingMode.Ink; + private readonly Dictionary _realtimeBrushTipStates = new Dictionary(); + private readonly Guid RealtimeVelocityBrushTipAppliedGuid = new Guid("74E57D95-945F-4A8C-B52A-7D3EF2D4FD5B"); + + private sealed class OneEuroFilter + { + private readonly float _minCutoff; + private readonly float _beta; + private readonly float _dCutoff; + private bool _initialized; + private float _xPrev; + private float _dxPrev; + + public OneEuroFilter(float minCutoff, float beta, float dCutoff) + { + _minCutoff = minCutoff; + _beta = beta; + _dCutoff = dCutoff; + } + + public float Filter(float value, float dt, float speed) + { + if (!_initialized) + { + _initialized = true; + _xPrev = value; + _dxPrev = 0f; + return value; + } + + var dx = (value - _xPrev) / Math.Max(1e-6f, dt); + var aD = Alpha(_dCutoff, dt); + var dxHat = Lerp(_dxPrev, dx, aD); + var a = Alpha(_minCutoff + _beta * speed, dt); + var xHat = Lerp(_xPrev, value, a); + _xPrev = xHat; + _dxPrev = dxHat; + return xHat; + } + + private static float Alpha(float cutoff, float dt) + { + var tau = 1f / (2f * (float)Math.PI * Math.Max(1e-3f, cutoff)); + return 1f / (1f + tau / Math.Max(1e-6f, dt)); + } + + private static float Lerp(float a, float b, float t) => a + (b - a) * t; + } + + private sealed class RealtimeBrushTipState + { + public float LastRawX { get; set; } + public float LastRawY { get; set; } + public long LastTimestampMs { get; set; } + public float SmoothedSampleRateHz { get; set; } = 120f; + public bool HasSeed { get; set; } + public float LastSmoothX { get; set; } + public float LastSmoothY { get; set; } + public float LastSmoothPressure { get; set; } = 0.5f; + public OneEuroFilter FilterX { get; } = new OneEuroFilter(1.2f, 0.015f, 1f); + public OneEuroFilter FilterY { get; } = new OneEuroFilter(1.2f, 0.015f, 1f); + public OneEuroFilter FilterPressure { get; } = new OneEuroFilter(1f, 0.02f, 1f); + } + + private static long RealtimeNowMs() => Environment.TickCount64; + + private static float RealtimeClamp(float x, float min, float max) + { + if (x < min) return min; + if (x > max) return max; + return x; + } + + private bool ShouldUseRealtimeVelocityBrushTip() + { + return Settings.Canvas.InkStyle == 3 + && Settings.Canvas.VelocityBrushTipMix > 0 + && !Settings.Canvas.DisablePressure + && penType == 0; + } + + private void InitializeRealtimeBrushTipState(int stylusId, StylusDownEventArgs e) + { + if (!ShouldUseRealtimeVelocityBrushTip()) + { + _realtimeBrushTipStates.Remove(stylusId); + return; + } + + var startPoint = e.GetPosition(this); + _realtimeBrushTipStates[stylusId] = new RealtimeBrushTipState + { + LastRawX = (float)startPoint.X, + LastRawY = (float)startPoint.Y, + LastTimestampMs = RealtimeNowMs() + }; + } + + private void CleanupRealtimeBrushTipState(int stylusId) + { + _realtimeBrushTipStates.Remove(stylusId); + } + + private bool TryAppendRealtimeVelocityBrushTipPoints(StrokeVisual strokeVisual, StylusEventArgs e) + { + if (!ShouldUseRealtimeVelocityBrushTip() || strokeVisual == null || e?.StylusDevice == null) + return false; + + if (!_realtimeBrushTipStates.TryGetValue(e.StylusDevice.Id, out var state)) + return false; + + var stylusPointCollection = e.GetStylusPoints(this); + if (stylusPointCollection == null || stylusPointCollection.Count == 0) + return true; + + var mix = RealtimeClamp((float)Settings.Canvas.VelocityBrushTipMix, 0f, 1f); + var appended = false; + + foreach (StylusPoint rawPoint in stylusPointCollection) + { + var nowMs = RealtimeNowMs(); + var dtMs = Math.Max(1L, nowMs - state.LastTimestampMs); + var dt = dtMs / 1000f; + var sampleRate = 1f / Math.Max(1e-4f, dt); + state.SmoothedSampleRateHz = state.SmoothedSampleRateHz * 0.85f + sampleRate * 0.15f; + + var rawX = (float)rawPoint.X; + var rawY = (float)rawPoint.Y; + var dx = rawX - state.LastRawX; + var dy = rawY - state.LastRawY; + var dist = (float)Math.Sqrt(dx * dx + dy * dy); + var speed = dist / dt; + + var filteredX = state.FilterX.Filter(rawX, dt, speed); + var filteredY = state.FilterY.Filter(rawY, dt, speed); + + var speedPressure = RealtimeBrushTipMixRatePressureFromSpeed(GetPointSpeed( + new Point(state.LastRawX, state.LastRawY), + new Point(rawX, rawY), + new Point(filteredX, filteredY))); + var pressure = (1f - mix) * (float)rawPoint.PressureFactor + mix * speedPressure; + pressure = RealtimeClamp(pressure, 0.08f, 1f); + pressure = state.FilterPressure.Filter(pressure, dt, speed); + + // 高频采样时做最小距离门限,避免点爆炸导致实时重绘卡顿 + var minDist = state.SmoothedSampleRateHz > 160f ? 0.55f + : state.SmoothedSampleRateHz > 90f ? 0.4f + : 0.25f; + if (dist < minDist && state.HasSeed) + { + state.LastRawX = rawX; + state.LastRawY = rawY; + state.LastTimestampMs = nowMs; + continue; + } + + if (!state.HasSeed) + { + state.HasSeed = true; + state.LastSmoothX = filteredX; + state.LastSmoothY = filteredY; + state.LastSmoothPressure = pressure; + strokeVisual.Add(new StylusPoint(filteredX, filteredY, pressure)); + } + else + { + // 采用中点链减抖:保持实时笔锋同时降低折线锯齿 + var midX = (state.LastSmoothX + filteredX) * 0.5f; + var midY = (state.LastSmoothY + filteredY) * 0.5f; + var midPressure = (state.LastSmoothPressure + pressure) * 0.5f; + strokeVisual.Add(new StylusPoint(midX, midY, midPressure)); + state.LastSmoothX = filteredX; + state.LastSmoothY = filteredY; + state.LastSmoothPressure = pressure; + } + + state.LastRawX = rawX; + state.LastRawY = rawY; + state.LastTimestampMs = nowMs; + appended = true; + } + + var committedStroke = strokeVisual.Stroke; + if (appended && committedStroke != null) + { + if (committedStroke.DrawingAttributes != null) + committedStroke.DrawingAttributes.IgnorePressure = false; + if (!committedStroke.ContainsPropertyData(RealtimeVelocityBrushTipAppliedGuid)) + committedStroke.AddPropertyData(RealtimeVelocityBrushTipAppliedGuid, true); + } + + return true; + } /// /// 保存画布上的非笔画元素(如图片、媒体元素等) @@ -391,6 +583,7 @@ namespace Ink_Canvas || inkCanvas.EditingMode == InkCanvasEditingMode.EraseByStroke || inkCanvas.EditingMode == InkCanvasEditingMode.Select) return; + InitializeRealtimeBrushTipState(e.StylusDevice.Id, e); TouchDownPointsList[e.StylusDevice.Id] = InkCanvasEditingMode.None; } @@ -490,6 +683,7 @@ namespace Ink_Canvas StrokeVisualList.Remove(e.StylusDevice.Id); VisualCanvasList.Remove(e.StylusDevice.Id); TouchDownPointsList.Remove(e.StylusDevice.Id); + CleanupRealtimeBrushTipState(e.StylusDevice.Id); if (StrokeVisualList.Count == 0 || VisualCanvasList.Count == 0 || TouchDownPointsList.Count == 0) { // 只清除手写笔预览相关的Canvas,不清除所有子元素 @@ -552,28 +746,15 @@ namespace Ink_Canvas var strokeVisual = GetStrokeVisual(e.StylusDevice.Id); - var stylusPointCollection = e.GetStylusPoints(this); - foreach (var stylusPoint in stylusPointCollection) - strokeVisual.Add(new StylusPoint(stylusPoint.X, stylusPoint.Y, stylusPoint.PressureFactor)); + var isHandledByRealtime = TryAppendRealtimeVelocityBrushTipPoints(strokeVisual, e); + if (!isHandledByRealtime) + { + var stylusPointCollection = e.GetStylusPoints(this); + foreach (var stylusPoint in stylusPointCollection) + strokeVisual.Add(new StylusPoint(stylusPoint.X, stylusPoint.Y, stylusPoint.PressureFactor)); + } - // 实时笔锋:混合度 > 0 时在绘制过程中更新压感并整笔重绘预览;混合为 0 时与普通过程一致用增量 Redraw,避免每点 ForceRedraw 整笔清空(长笔画卡顿)。 - var committedStroke = strokeVisual.Stroke; - if (committedStroke != null - && Settings.Canvas.InkStyle == 3 - && Settings.Canvas.VelocityBrushTipMix > 0 - && !Settings.Canvas.DisablePressure - && penType == 0 - && committedStroke.DrawingAttributes != null - && !committedStroke.DrawingAttributes.IsHighlighter - && committedStroke.StylusPoints.Count >= 3) - { - ApplyVelocityBrushTipFromSpeed(committedStroke); - strokeVisual.ForceRedraw(); - } - else - { - strokeVisual.Redraw(); - } + strokeVisual.Redraw(); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine(ex); } }