博客 / 詳情

返回

WPF 使用 HLSL + Clip 實現高亮歌詞光照效果

最近在搓一個Lyricify Lite類似物,原本使用漸變畫刷實現歌詞高亮,但是發現視覺效果與Apple Music相去甚遠:單純使用白色漸變畫刷缺乏“高亮”的光照感覺,而Apple Music的歌詞高亮則更像是有光線投射在歌詞上,形成一種柔和的發光效果。

受到呂毅大佬的文章使用 WPF 做一個可以逼真地照亮你桌面的高性能陽光 - walterlv啓發,遂嘗試使用HLSL編寫一個簡單的文本高亮着色器。先來看實裝在LemonLite中的效果:

image

image

image

 

整體上高亮文本與背景混色自然,漸變過渡由WPF動畫驅動,高亮部分就有了光感。

以下是本文的最小可運行示例:TwilightLemon/TextHighlighterTest: WPF 流光特效文本控件

一、實現思路

簡單説一下踩過的幾個坑:

  1. 直接給TextBlock焊上Effect會導致文本像素化,變得模糊而且難以處理文本邊界
  2. 使用VisualBrush,內部套一個Rectangle with Effect, 然後用這個VisualBrush作為TextBlock的Foreground,性能極差
  3. 給Rectangle with Effect做一個Clip裁剪出文本形狀,性能可以,但是需要復刻TextBlock的排版邏輯

最終使用了第三種方案,並將其封裝成一個用户控件。

如此一來,HLSL着色器要乾的事情就很簡單了:取一個高亮過渡位置pos,根據採樣像素的X座標計算光照強度,然後將這個強度與文本顏色做加法混合即可。

二、HLSL着色器代碼

為了適配歌詞高亮需求,我又追加了一些特性:

  1. 支持高亮顏色自定義
  2. 支持高亮寬度調整
  3. 支持切換高亮模式(加法混合/線性漸變)

以下是完整的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 高亮強度倍數

計算過程

  1. 採樣 → 讀取當前像素顏色
    color = tex2D(input, uv)
  2. 計算距離 → 像素X座標與高亮位置的距離
    d = max(0, uv.x - HighlightPos)
  3. 計算光暈強度 → 根據距離生成平滑衰減
    glow = saturate(1 - d / HighlightWidth)
    glow = glow * glow // 平方處理,使衰減更陡峭
  4. 最終強度 = 光暈 × 顏色透明度 × 強度倍數
  5. 混合處理 → 根據UseAdditive選擇混合方式
    • 線性漸變:在原色和高亮色間插值
    • 加法混合:直接疊加高亮色

函數説明

  • saturate(x) - 將值鉗制在 [0, 1] 範圍內
  • step(edge, x) - 如果 x < edge 返回 0,否則返回 1
  • lerp(a, b, t) - 線性插值,計算 a + (b-a)×t
  • tex2D(sampler, uv) - 從紋理採樣指定座標的像素

編譯並加載着色器

編譯HLSL代碼生成.ps文件,然後在WPF中使用PixelShader類加載:

fxc /T ps_3_0 /E main /Fo TextGlow.ps TextGlow.hlsl

封裝成Effect類此處不做贅述,只需要嚴格按照參數順序傳遞即可。

三、WPF用户控件封裝

核心出裝

用一個 Rectangle 承載着色器效果,通過FormattedText生成文本的幾何形狀作為裁剪路徑,這樣WPF只會渲染文本區域並且保留清晰的文本邊緣。

屬性繼承與元數據覆寫

為了讓高亮控件支持標準的文本屬性(字體、大小、粗細等),使用了WPF的元數據覆寫機制。在靜態構造函數中對 FontFamilyFontSizeFontWeight 等屬性進行 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() 方法負責:

  1. 創建排版對象 - 通過 FormattedText 將文本按照控件的字體、大小、樣式參數進行排版,獲得與實際渲染完全一致的文本度量
var formattedText = new FormattedText(
    Text,
    CultureInfo.CurrentCulture,
    FlowDirection,
    new Typeface(FontFamily, FontStyle, FontWeight, FontStretch),
    FontSize,
    Brushes.Black,
    VisualTreeHelper.GetDpi(this).PixelsPerDip);
  1. 處理換行與寬度約束 - 當啓用 TextWrapping 時,從 WidthMaxWidth 或 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;
  1. 計算容器尺寸 - NoWrap下容器寬度就是文本寬度,換行模式下則為約束寬度,以確保 Rectangle 的大小與實際文本範圍匹配
var containerWidth = TextWrapping == TextWrapping.NoWrap
    ? textWidth
    : (constraintWidth > 0 ? constraintWidth : textWidth);
  1. 處理文本對齊 - 根據 TextAlignment 計算文本相對於容器的起始偏移(用於居中和右對齊),然後生成Rectangle時傳入這個偏移量,同時更新 Rectangle 的水平對齊屬性使其與文本對齊方式一致
double offsetX = 0;
if (containerWidth > textWidth)
{
    offsetX = TextAlignment switch
    {
        TextAlignment.Center => (containerWidth - textWidth) / 2,
        TextAlignment.Right => containerWidth - textWidth,
        _ => 0
    };
}
  1. 生成並應用裁剪 - 調用 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;

高亮參數的動畫驅動

高亮效果的三個關鍵參數(HighlightPosHighlightWidthHighlightColor)都被暴露為依賴屬性,在屬性變化回調中直接同步到着色器 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 的平滑變化,從而實現光線掃過文本的連貫動畫效果。

四、看看效果

image

對比Additive疊加和Lerp線性漸變兩種模式,可以看到Additive模式下高亮部分更亮更有光感,而Lerp模式尾段偏灰。

 

image

嘗試使用彩色高亮,變成了混色效果。

寫在最後

LemonLite正在龜速開發中,過程中遇到的各種問題和解決方案都會陸續寫成博客分享出來,歡迎各位大佬持續關注。
什麼!你問我開頭的背景是怎麼做的?見上一篇文章:WPF 使用GDI+提取圖片主色調並生成Mica材質特效背景 - Twlm's Blog

參考資料:

    • WPF 像素着色器入門:使用 Shazzam Shader Editor 編寫 HLSL 像素着色器代碼 - walterlv
    • 使用 WPF 做一個可以逼真地照亮你桌面的高性能陽光 - walterlv

 

  本作品採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。歡迎轉載、使用、重新發布,但務必保留文章署名TwilightLemon,不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。

 

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.