时间:2026-01-17 20:55
人气:
作者:admin
最近在搓一个Lyricify Lite类似物,原本使用渐变画刷实现歌词高亮,但是发现视觉效果与Apple Music相去甚远:单纯使用白色渐变画刷缺乏“高亮”的光照感觉,而Apple Music的歌词高亮则更像是有光线投射在歌词上,形成一种柔和的发光效果。
受到吕毅大佬的文章使用 WPF 做一个可以逼真地照亮你桌面的高性能阳光 - walterlv启发,遂尝试使用HLSL编写一个简单的文本高亮着色器。先来看实装在LemonLite中的效果:



整体上高亮文本与背景混色自然,渐变过渡由WPF动画驱动,高亮部分就有了光感。
以下是本文的最小可运行示例:TwilightLemon/TextHighlighterTest: WPF 流光特效文本控件
简单说一下踩过的几个坑:
最终使用了第三种方案,并将其封装成一个用户控件。
如此一来,HLSL着色器要干的事情就很简单了:取一个高亮过渡位置pos,根据采样像素的X坐标计算光照强度,然后将这个强度与文本颜色做加法混合即可。
为了适配歌词高亮需求,我又追加了一些特性:
以下是完整的HLSL代码: (咋没有hlsl高亮呢? )
sampler2D input : register(s0);
float HighlightPos : register(c0);
float HighlightWidth : register(c1);
float4 HighlightColor : register(c2);
float UseAdditive : register(c3); // 0 = lerp, 1 = additive
float HighlightIntensity : register(c4);
float4 main(float2 uv : TEXCOORD) : COLOR
{
float4 color = tex2D(input, uv);
float d = max(0, uv.x - HighlightPos);
float glow = saturate(1 - d / HighlightWidth);
glow = glow * glow;
float intensity = glow * HighlightColor.a * HighlightIntensity;
float3 lerpResult = lerp(color.rgb, HighlightColor.rgb, intensity);
float lerpAlpha = lerp(color.a, 1.0, intensity);
float3 additiveResult = color.rgb + HighlightColor.rgb * intensity;
color.rgb = lerp(lerpResult, additiveResult, UseAdditive);
color.a = lerp(lerpAlpha, color.a, UseAdditive);
return color;
}
| 参数 | 作用 |
|---|---|
input |
输入纹理(文本像素) |
HighlightPos |
高亮过渡位置(0-1,从左到右, 当然也可以取负数) |
HighlightWidth |
高亮衰减宽度(0-1) |
HighlightColor |
高亮颜色(含透明度) |
UseAdditive |
混合模式开关(0=线性渐变,1=加法混合) |
HighlightIntensity |
高亮强度倍数 |
saturate(x) - 将值钳制在 [0, 1] 范围内step(edge, x) - 如果 x < edge 返回 0,否则返回 1lerp(a, b, t) - 线性插值,计算 a + (b-a)×ttex2D(sampler, uv) - 从纹理采样指定坐标的像素编译HLSL代码生成.ps文件,然后在WPF中使用PixelShader类加载:
fxc /T ps_3_0 /E main /Fo TextGlow.ps TextGlow.hlsl
封装成Effect类此处不做赘述,只需要严格按照参数顺序传递即可。
用一个 Rectangle 承载着色器效果,通过FormattedText生成文本的几何形状作为裁剪路径,这样WPF只会渲染文本区域并且保留清晰的文本边缘。
为了让高亮控件支持标准的文本属性(字体、大小、粗细等),使用了WPF的元数据覆写机制。在静态构造函数中对 FontFamily、FontSize、FontWeight 等属性进行 OverrideMetadata,绑定到统一的 OnTextPropertyChanged 回调。当这些属性变化时,触发文本裁剪的重新计算。类似地,Foreground 属性也被覆写,当文本颜色改变时直接更新 Rectangle 的填充颜色。
1 static HighlightTextBlock() 2 { 3 FontFamilyProperty.OverrideMetadata(typeof(HighlightTextBlock), 4 new FrameworkPropertyMetadata(SystemFonts.MessageFontFamily, OnTextPropertyChanged)); 5 FontSizeProperty.OverrideMetadata(typeof(HighlightTextBlock), 6 new FrameworkPropertyMetadata(14.0, OnTextPropertyChanged)); 7 FontWeightProperty.OverrideMetadata(typeof(HighlightTextBlock), 8 new FrameworkPropertyMetadata(FontWeights.Normal, OnTextPropertyChanged)); 9 ForegroundProperty.OverrideMetadata(typeof(HighlightTextBlock), 10 new FrameworkPropertyMetadata(Brushes.Black, OnForegroundChanged)); 11 } 12 13 private static void OnForegroundChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 14 { 15 if (d is HighlightTextBlock control) 16 control.PART_Rectangle.Fill = e.NewValue as Brush; 17 }
这样用户就可以像使用普通 TextBlock 一样使用这个控件,享受XAML的属性绑定和样式系统。
UpdateTextClip() 方法负责:
FormattedText 将文本按照控件的字体、大小、样式参数进行排版,获得与实际渲染完全一致的文本度量var formattedText = new FormattedText( Text, CultureInfo.CurrentCulture, FlowDirection, new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), FontSize, Brushes.Black, VisualTreeHelper.GetDpi(this).PixelsPerDip);
TextWrapping 时,从 Width、MaxWidth 或 ActualWidth 中取出约束宽度。只有在需要换行且有约束宽度的情况下,才设置 MaxTextWidth 让文本自动折行var constraintWidth = !double.IsNaN(Width) && Width > 0 ? Width : (!double.IsInfinity(MaxWidth) && MaxWidth > 0 ? MaxWidth : ActualWidth); if (TextWrapping != TextWrapping.NoWrap && constraintWidth > 0) formattedText.MaxTextWidth = constraintWidth;
Rectangle 的大小与实际文本范围匹配var containerWidth = TextWrapping == TextWrapping.NoWrap ? textWidth : (constraintWidth > 0 ? constraintWidth : textWidth);
TextAlignment 计算文本相对于容器的起始偏移(用于居中和右对齐),然后生成Rectangle时传入这个偏移量,同时更新 Rectangle 的水平对齐属性使其与文本对齐方式一致double offsetX = 0; if (containerWidth > textWidth) { offsetX = TextAlignment switch { TextAlignment.Center => (containerWidth - textWidth) / 2, TextAlignment.Right => containerWidth - textWidth, _ => 0 }; }
formattedText.BuildGeometry() 得到精确的文本轮廓Rectangle,设置为 Rectangle.Clip。这样着色器的输出就被限制在文本像素范围内var geometry = formattedText.BuildGeometry(new Point(offsetX, 0)); PART_Rectangle.Clip = geometry; PART_Rectangle.Width = containerWidth; PART_Rectangle.Height = textHeight;
高亮效果的三个关键参数(HighlightPos、HighlightWidth、HighlightColor)都被暴露为依赖属性,在属性变化回调中直接同步到着色器 Effect 对象:
public static readonly DependencyProperty HighlightPosProperty = DependencyProperty.Register( nameof(HighlightPos), typeof(double), typeof(HighlightTextBlock), new PropertyMetadata(0.0, OnHighlightPosChanged)); private static void OnHighlightPosChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is HighlightTextBlock c) c._effect.HighlightPos = (double)e.NewValue; }
这样就可以在XAML中轻松使用 Storyboard 和 DoubleAnimation 驱动 HighlightPos 的平滑变化,从而实现光线扫过文本的连贯动画效果。

对比Additive叠加和Lerp线性渐变两种模式,可以看到Additive模式下高亮部分更亮更有光感,而Lerp模式尾段偏灰。

尝试使用彩色高亮,变成了混色效果。
LemonLite正在龟速开发中,过程中遇到的各种问题和解决方案都会陆续写成博客分享出来,欢迎各位大佬持续关注。
什么!你问我开头的背景是怎么做的?见上一篇文章:WPF 使用GDI+提取图片主色调并生成Mica材质特效背景 - Twlm's Blog
参考资料:

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名TwilightLemon,不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。