improve:实时笔锋

This commit is contained in:
2026-04-25 17:27:28 +08:00
parent 004364c3a9
commit 77dff81217
2 changed files with 203 additions and 21 deletions
@@ -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);
+198 -17
View File
@@ -48,6 +48,198 @@ namespace Ink_Canvas
private bool isPalmEraserActive;
private bool palmEraserWasEnabledBeforeMultiTouch;
private InkCanvasEditingMode palmEraserPreviousEditingMode = InkCanvasEditingMode.Ink;
private readonly Dictionary<int, RealtimeBrushTipState> _realtimeBrushTipStates = new Dictionary<int, RealtimeBrushTipState>();
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;
}
/// <summary>
/// 保存画布上的非笔画元素(如图片、媒体元素等)
@@ -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,29 +746,16 @@ namespace Ink_Canvas
var strokeVisual = GetStrokeVisual(e.StylusDevice.Id);
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();
}
}
catch (Exception ex) { System.Diagnostics.Debug.WriteLine(ex); }
}