崔彬杰 發表於 2026-1-17 20:55:00

WPF 使用 HLSL + Clip 实现高亮歌词光照效果

<p>最近在搓一个Lyricify Lite类似物,原本使用渐变画刷实现歌词高亮,但是发现视觉效果与Apple Music相去甚远:单纯使用白色渐变画刷缺乏“高亮”的光照感觉,而Apple Music的歌词高亮则更像是有光线投射在歌词上,形成一种柔和的发光效果。</p>
<p>受到吕毅大佬的文章使用 WPF 做一个可以逼真地照亮你桌面的高性能阳光 - walterlv启发,遂尝试使用HLSL编写一个简单的文本高亮着色器。先来看实装在LemonLite中的效果:</p>
<p><img src="https://img2024.cnblogs.com/blog/1188749/202601/1188749-20260117211723011-1943026558.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/1188749/202601/1188749-20260117211733480-456993496.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/1188749/202601/1188749-20260117211742655-1018276252.png"></p>
<p>&nbsp;</p>
<p>整体上高亮文本与背景混色自然,渐变过渡由WPF动画驱动,高亮部分就有了光感。</p>
<p>以下是本文的最小可运行示例:TwilightLemon/TextHighlighterTest: WPF 流光特效文本控件</p>
<h2 id="一实现思路">一、实现思路</h2>
<p>简单说一下踩过的几个坑:</p>
<ol>
<li>直接给TextBlock焊上Effect会导致文本像素化,变得模糊而且难以处理文本边界</li>
<li>使用VisualBrush,内部套一个Rectangle with Effect, 然后用这个VisualBrush作为TextBlock的Foreground,性能极差</li>
<li>给Rectangle with Effect做一个Clip裁剪出文本形状,性能可以,但是需要复刻TextBlock的排版逻辑</li>
</ol>
<p>最终使用了第三种方案,并将其封装成一个用户控件。</p>
<p>如此一来,HLSL着色器要干的事情就很简单了:取一个高亮过渡位置pos,根据采样像素的X坐标计算光照强度,然后将这个强度与文本颜色做加法混合即可。</p>
<h2 id="二hlsl着色器代码">二、HLSL着色器代码</h2>
<p>为了适配歌词高亮需求,我又追加了一些特性:</p>
<ol>
<li>支持高亮颜色自定义</li>
<li>支持高亮宽度调整</li>
<li>支持切换高亮模式(加法混合/线性渐变)</li>
</ol>
<p>以下是完整的HLSL代码: (咋没有hlsl高亮呢? )</p>
<div class="cnblogs_code">
<pre>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;
}</pre>
</div>
<h3 id="着色器输入参数">着色器输入参数</h3>
<table>
<thead>
<tr><th>参数</th><th>作用</th></tr>
</thead>
<tbody>
<tr>
<td><code>input</code></td>
<td>输入纹理(文本像素)</td>
</tr>
<tr>
<td><code>HighlightPos</code></td>
<td>高亮过渡位置(0-1,从左到右, 当然也可以取负数)</td>
</tr>
<tr>
<td><code>HighlightWidth</code></td>
<td>高亮衰减宽度(0-1)</td>
</tr>
<tr>
<td><code>HighlightColor</code></td>
<td>高亮颜色(含透明度)</td>
</tr>
<tr>
<td><code>UseAdditive</code></td>
<td>混合模式开关(0=线性渐变,1=加法混合)</td>
</tr>
<tr>
<td><code>HighlightIntensity</code></td>
<td>高亮强度倍数</td>
</tr>
</tbody>
</table>
<h3 id="计算过程">计算过程</h3>
<ol>
<li>采样 → 读取当前像素颜色<br>color = tex2D(input, uv)</li>
<li>计算距离 → 像素X坐标与高亮位置的距离<br>d = max(0, uv.x - HighlightPos)</li>
<li>计算光晕强度 → 根据距离生成平滑衰减<br>glow = saturate(1 - d / HighlightWidth)<br>glow = glow * glow // 平方处理,使衰减更陡峭</li>
<li>最终强度 = 光晕 × 颜色透明度 × 强度倍数</li>
<li>混合处理 → 根据UseAdditive选择混合方式
<ul>
<li>线性渐变:在原色和高亮色间插值</li>
<li>加法混合:直接叠加高亮色</li>


</ul>


</li>


</ol>
<h4 id="函数说明">函数说明</h4>
<ul>
<li><code>saturate(x)</code>&nbsp;- 将值钳制在 范围内</li>
<li><code>step(edge, x)</code>&nbsp;- 如果 x &lt; edge 返回 0,否则返回 1</li>
<li><code>lerp(a, b, t)</code>&nbsp;- 线性插值,计算 a + (b-a)×t</li>
<li><code>tex2D(sampler, uv)</code>&nbsp;- 从纹理采样指定坐标的像素</li>


</ul>
<h3 id="编译并加载着色器">编译并加载着色器</h3>
<p>编译HLSL代码生成<code>.ps</code>文件,然后在WPF中使用<code>PixelShader</code>类加载:</p>
<div class="relative code-block">
<div data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden">
<div class="cnblogs_code">
<pre>fxc /T ps_3_0 /E main /Fo TextGlow.<span style="color: rgba(0, 0, 255, 1)">ps</span> TextGlow.hlsl</pre>
</div>
</div>
</div>
<p>封装成Effect类此处不做赘述,只需要严格按照参数顺序传递即可。</p>
<h2 id="三wpf用户控件封装">三、WPF用户控件封装</h2>
<h3 id="核心出装">核心出装</h3>
<p>用一个&nbsp;<code>Rectangle</code>&nbsp;承载着色器效果,通过<code>FormattedText</code>生成文本的几何形状作为裁剪路径,这样WPF只会渲染文本区域并且保留清晰的文本边缘。</p>
<h3 id="属性继承与元数据覆写">属性继承与元数据覆写</h3>
<p>为了让高亮控件支持标准的文本属性(字体、大小、粗细等),使用了WPF的元数据覆写机制。在静态构造函数中对&nbsp;<code>FontFamily</code>、<code>FontSize</code>、<code>FontWeight</code>&nbsp;等属性进行&nbsp;<code>OverrideMetadata</code>,绑定到统一的&nbsp;<code>OnTextPropertyChanged</code>&nbsp;回调。当这些属性变化时,触发文本裁剪的重新计算。类似地,<code>Foreground</code>&nbsp;属性也被覆写,当文本颜色改变时直接更新&nbsp;<code>Rectangle</code>&nbsp;的填充颜色。</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 128, 1)"> 1</span> <span style="color: rgba(0, 0, 255, 1)">static</span><span style="color: rgba(0, 0, 0, 1)"> HighlightTextBlock()
</span><span style="color: rgba(0, 128, 128, 1)"> 2</span> <span style="color: rgba(0, 0, 0, 1)">{
</span><span style="color: rgba(0, 128, 128, 1)"> 3</span>   FontFamilyProperty.OverrideMetadata(<span style="color: rgba(0, 0, 255, 1)">typeof</span><span style="color: rgba(0, 0, 0, 1)">(HighlightTextBlock),
</span><span style="color: rgba(0, 128, 128, 1)"> 4</span>         <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> FrameworkPropertyMetadata(SystemFonts.MessageFontFamily, OnTextPropertyChanged));
</span><span style="color: rgba(0, 128, 128, 1)"> 5</span>   FontSizeProperty.OverrideMetadata(<span style="color: rgba(0, 0, 255, 1)">typeof</span><span style="color: rgba(0, 0, 0, 1)">(HighlightTextBlock),
</span><span style="color: rgba(0, 128, 128, 1)"> 6</span>         <span style="color: rgba(0, 0, 255, 1)">new</span> FrameworkPropertyMetadata(<span style="color: rgba(128, 0, 128, 1)">14.0</span><span style="color: rgba(0, 0, 0, 1)">, OnTextPropertyChanged));
</span><span style="color: rgba(0, 128, 128, 1)"> 7</span>   FontWeightProperty.OverrideMetadata(<span style="color: rgba(0, 0, 255, 1)">typeof</span><span style="color: rgba(0, 0, 0, 1)">(HighlightTextBlock),
</span><span style="color: rgba(0, 128, 128, 1)"> 8</span>         <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> FrameworkPropertyMetadata(FontWeights.Normal, OnTextPropertyChanged));
</span><span style="color: rgba(0, 128, 128, 1)"> 9</span>   ForegroundProperty.OverrideMetadata(<span style="color: rgba(0, 0, 255, 1)">typeof</span><span style="color: rgba(0, 0, 0, 1)">(HighlightTextBlock),
</span><span style="color: rgba(0, 128, 128, 1)">10</span>         <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> FrameworkPropertyMetadata(Brushes.Black, OnForegroundChanged));
</span><span style="color: rgba(0, 128, 128, 1)">11</span> <span style="color: rgba(0, 0, 0, 1)">}
</span><span style="color: rgba(0, 128, 128, 1)">12</span>
<span style="color: rgba(0, 128, 128, 1)">13</span> <span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> OnForegroundChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
</span><span style="color: rgba(0, 128, 128, 1)">14</span> <span style="color: rgba(0, 0, 0, 1)">{
</span><span style="color: rgba(0, 128, 128, 1)">15</span>   <span style="color: rgba(0, 0, 255, 1)">if</span> (d <span style="color: rgba(0, 0, 255, 1)">is</span><span style="color: rgba(0, 0, 0, 1)"> HighlightTextBlock control)
</span><span style="color: rgba(0, 128, 128, 1)">16</span>         control.PART_Rectangle.Fill = e.NewValue <span style="color: rgba(0, 0, 255, 1)">as</span><span style="color: rgba(0, 0, 0, 1)"> Brush;
</span><span style="color: rgba(0, 128, 128, 1)">17</span> }</pre>
</div>
<p>这样用户就可以像使用普通&nbsp;<code>TextBlock</code>&nbsp;一样使用这个控件,享受XAML的属性绑定和样式系统。</p>
<h3 id="文本排版与裁剪">文本排版与裁剪</h3>
<p><code>UpdateTextClip()</code>&nbsp;方法负责:</p>
<ol>
<li>创建排版对象&nbsp;- 通过&nbsp;<code>FormattedText</code>&nbsp;将文本按照控件的字体、大小、样式参数进行排版,获得与实际渲染完全一致的文本度量</li>
</ol>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">var</span> formattedText = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> FormattedText(
    Text,
    CultureInfo.CurrentCulture,
    FlowDirection,
    </span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> Typeface(FontFamily, FontStyle, FontWeight, FontStretch),
    FontSize,
    Brushes.Black,
    VisualTreeHelper.GetDpi(</span><span style="color: rgba(0, 0, 255, 1)">this</span>).PixelsPerDip);</pre>
</div>
<ol start="2">
<li>处理换行与宽度约束&nbsp;- 当启用&nbsp;<code>TextWrapping</code>&nbsp;时,从&nbsp;<code>Width</code>、<code>MaxWidth</code>&nbsp;或&nbsp;<code>ActualWidth</code>&nbsp;中取出约束宽度。只有在需要换行且有约束宽度的情况下,才设置&nbsp;<code>MaxTextWidth</code>&nbsp;让文本自动折行</li>
</ol>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">var</span> constraintWidth = !<span style="color: rgba(0, 0, 255, 1)">double</span>.IsNaN(Width) &amp;&amp; Width &gt; <span style="color: rgba(128, 0, 128, 1)">0</span>
    ?<span style="color: rgba(0, 0, 0, 1)"> Width
    : (</span>!<span style="color: rgba(0, 0, 255, 1)">double</span>.IsInfinity(MaxWidth) &amp;&amp; MaxWidth &gt; <span style="color: rgba(128, 0, 128, 1)">0</span> ?<span style="color: rgba(0, 0, 0, 1)"> MaxWidth : ActualWidth);

</span><span style="color: rgba(0, 0, 255, 1)">if</span> (TextWrapping != TextWrapping.NoWrap &amp;&amp; constraintWidth &gt; <span style="color: rgba(128, 0, 128, 1)">0</span><span style="color: rgba(0, 0, 0, 1)">)
    formattedText.MaxTextWidth </span>= constraintWidth;</pre>
</div>
<ol start="3">
<li>计算容器尺寸&nbsp;- NoWrap下容器宽度就是文本宽度,换行模式下则为约束宽度,以确保&nbsp;<code>Rectangle</code>&nbsp;的大小与实际文本范围匹配</li>
</ol>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">var</span> containerWidth = TextWrapping ==<span style="color: rgba(0, 0, 0, 1)"> TextWrapping.NoWrap
    </span>?<span style="color: rgba(0, 0, 0, 1)"> textWidth
    : (constraintWidth </span>&gt; <span style="color: rgba(128, 0, 128, 1)">0</span> ? constraintWidth : textWidth);</pre>
</div>
<ol start="4">
<li>处理文本对齐&nbsp;- 根据&nbsp;<code>TextAlignment</code>&nbsp;计算文本相对于容器的起始偏移(用于居中和右对齐),然后生成Rectangle时传入这个偏移量,同时更新&nbsp;<code>Rectangle</code>&nbsp;的水平对齐属性使其与文本对齐方式一致</li>
</ol>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">double</span> offsetX = <span style="color: rgba(128, 0, 128, 1)">0</span><span style="color: rgba(0, 0, 0, 1)">;
</span><span style="color: rgba(0, 0, 255, 1)">if</span> (containerWidth &gt;<span style="color: rgba(0, 0, 0, 1)"> textWidth)
{
    offsetX </span>= TextAlignment <span style="color: rgba(0, 0, 255, 1)">switch</span><span style="color: rgba(0, 0, 0, 1)">
    {
      TextAlignment.Center </span>=&gt; (containerWidth - textWidth) / <span style="color: rgba(128, 0, 128, 1)">2</span><span style="color: rgba(0, 0, 0, 1)">,
      TextAlignment.Right </span>=&gt; containerWidth -<span style="color: rgba(0, 0, 0, 1)"> textWidth,
      _ </span>=&gt; <span style="color: rgba(128, 0, 128, 1)">0</span><span style="color: rgba(0, 0, 0, 1)">
    };
}</span></pre>
</div>
<ol start="5">
<li>生成并应用裁剪&nbsp;- 调用&nbsp;<code>formattedText.BuildGeometry()</code>&nbsp;得到精确的文本轮廓Rectangle,设置为&nbsp;<code>Rectangle.Clip</code>。这样着色器的输出就被限制在文本像素范围内</li>
</ol>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">var</span> geometry = formattedText.BuildGeometry(<span style="color: rgba(0, 0, 255, 1)">new</span> Point(offsetX, <span style="color: rgba(128, 0, 128, 1)">0</span><span style="color: rgba(0, 0, 0, 1)">));
PART_Rectangle.Clip </span>=<span style="color: rgba(0, 0, 0, 1)"> geometry;
PART_Rectangle.Width </span>=<span style="color: rgba(0, 0, 0, 1)"> containerWidth;
PART_Rectangle.Height </span>= textHeight;</pre>
</div>
<h3 id="高亮参数的动画驱动">高亮参数的动画驱动</h3>
<p>高亮效果的三个关键参数(<code>HighlightPos</code>、<code>HighlightWidth</code>、<code>HighlightColor</code>)都被暴露为依赖属性,在属性变化回调中直接同步到着色器 Effect 对象:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">readonly</span> DependencyProperty HighlightPosProperty =<span style="color: rgba(0, 0, 0, 1)">
    DependencyProperty.Register(
      nameof(HighlightPos),
      </span><span style="color: rgba(0, 0, 255, 1)">typeof</span>(<span style="color: rgba(0, 0, 255, 1)">double</span><span style="color: rgba(0, 0, 0, 1)">),
      </span><span style="color: rgba(0, 0, 255, 1)">typeof</span><span style="color: rgba(0, 0, 0, 1)">(HighlightTextBlock),
      </span><span style="color: rgba(0, 0, 255, 1)">new</span> PropertyMetadata(<span style="color: rgba(128, 0, 128, 1)">0.0</span><span style="color: rgba(0, 0, 0, 1)">, OnHighlightPosChanged));

</span><span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> OnHighlightPosChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (d <span style="color: rgba(0, 0, 255, 1)">is</span><span style="color: rgba(0, 0, 0, 1)"> HighlightTextBlock c)
      c._effect.HighlightPos </span>= (<span style="color: rgba(0, 0, 255, 1)">double</span><span style="color: rgba(0, 0, 0, 1)">)e.NewValue;
}</span></pre>
</div>
<p>这样就可以在XAML中轻松使用&nbsp;<code>Storyboard</code>&nbsp;和&nbsp;<code>DoubleAnimation 驱动&nbsp;<code>HighlightPos</code>&nbsp;的平滑变化,从而实现光线扫过文本的连贯动画效果。</code></p>
<h2 id="四看看效果">四、看看效果</h2>
<p><img src="https://img2024.cnblogs.com/blog/1188749/202601/1188749-20260117211801312-760359975.png"></p>
<p>对比Additive叠加和Lerp线性渐变两种模式,可以看到Additive模式下高亮部分更亮更有光感,而Lerp模式尾段偏灰。</p>
<p>&nbsp;</p>
<p><img src="https://img2024.cnblogs.com/blog/1188749/202601/1188749-20260117211812759-2145659902.png"></p>
<p>尝试使用彩色高亮,变成了混色效果。</p>
<h2 id="写在最后">写在最后</h2>
<p>LemonLite正在龟速开发中,过程中遇到的各种问题和解决方案都会陆续写成博客分享出来,欢迎各位大佬持续关注。<br>什么!你问我开头的背景是怎么做的?见上一篇文章:WPF 使用GDI+提取图片主色调并生成Mica材质特效背景 - Twlm's Blog<br></p>
<p>参考资料:</p>
<ul>
<ul>
<li>WPF 像素着色器入门:使用 Shazzam Shader Editor 编写 HLSL 像素着色器代码 - walterlv</li>
<li>使用 WPF 做一个可以逼真地照亮你桌面的高性能阳光 - walterlv</li>


</ul>

</ul>
<p>&nbsp;</p>
<p><img alt="" class="lazyloaded lazyload" loading="lazy" src="https://img2024.cnblogs.com/blog/1188749/202407/1188749-20240702112134529-1920703459.png"></p>
<p>&nbsp; 本作品采用&nbsp;知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议&nbsp;进行许可。欢迎转载、使用、重新发布,但务必保留文章署名TwilightLemon,不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。</p>
<p>&nbsp;</p><br><br>
来源:https://www.cnblogs.com/TwilightLemon/p/19497125
頁: [1]
查看完整版本: WPF 使用 HLSL + Clip 实现高亮歌词光照效果