{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  as published by the Free Software Foundation; either version 2
  of the License, or (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

unit SpectrumViewOpengl;

{
  OpenGL renderer for the spectrum pane.

  The class keeps the public TSpectrumView contract so MainForm can switch it
  with --opengl without changing DSP/web data flow. Waterfall, ruler and S-meter
  remain delegated to the base sub-views; only the spectrum paint path uses GL.
}

{$IFDEF FPC}
  {$MODE Delphi}
{$ENDIF}

interface

uses
  Classes, SysUtils, Graphics, Controls, Math, Types,
  OpenGLContextEx, GL,
  AppTheme, AlertOverlay, SpectrumView, VfoOverlay, BandPlanOverlay,
  DXSpotOverlay,
  WaterfallView, WaterfallViewOpengl;

type
  TGLTextureCache = record
    Tex: GLuint;
    W: Integer;
    H: Integer;
    Dirty: Boolean;
  end;

  TSpectrumViewOpenGL = class(TSpectrumView)
  private
    FGLControl: TOpenGLControl;
    FGridDirty: Boolean;
    FOverlayDirty: Boolean;
    FSampleOverlayDirty: Boolean;
    FVfoOverlayDirty: Boolean;
    FBandOverlayDirty: Boolean;
    FDXOverlayDirty: Boolean;
    FGridLabelTex: TGLTextureCache;
    FAGCLabelTex: TGLTextureCache;
    FAGCHangLabelTex: TGLTextureCache;
    FMarkerLabelTex: TGLTextureCache;
    FADCOverlayTex: TGLTextureCache;
    FSampleOverlayTex: TGLTextureCache;
    FVfoOverlayTex: TGLTextureCache;
    FBandOverlayTex: TGLTextureCache;
    FDXOverlayTex: TGLTextureCache;
    // Текстуры флагов слайсов (B+), привязка по указателю оверлея.
    FSliceTex: array of record
      Overlay: TVfoOverlay;
      Tex: TGLTextureCache;
      LastX, LastY, LastW, LastH: Integer;
    end;
    // Кэш текстур букв A..H для меток на полосе фильтра (грузятся лениво).
    FLetterTex: array[0..7] of TGLTextureCache;
    FLastAGCText: string;
    FLastAGCY: Integer;
    FLastAGCHangY: Integer;
    FLastMarkerText: string;
    FLastMarkerX: Integer;
    // 2TON/IMD: текстуры подписей маркеров (0..3 = кружки, 4 = сводка IMD3)
    FIMDLabelTex: array[0..4] of TGLTextureCache;
    FIMDLastText: array[0..4] of string;
    FLastADCVisible: Boolean;
    FLastSampleX: Integer;
    FLastSampleY: Integer;
    FLastSampleW: Integer;
    FLastSampleH: Integer;
    FLastVfoX: Integer;
    FLastVfoY: Integer;
    FLastVfoW: Integer;
    FLastVfoH: Integer;
    FLastBandW: Integer;
    FLastBandCenter: Double;
    FLastBandSpan: Double;
    FLastDXW: Integer;
    FLastDXRender: Int64;
    FGLSpectrumW: Integer;
    FGLSpectrumH: Integer;
    FSpY: array of Integer;

    procedure DeleteTexture(var T: TGLTextureCache);
    procedure UploadBitmap(var T: TGLTextureCache; B: TBitmap; Keyed: Boolean;
      FixedAlpha: Byte);
    procedure UploadAlphaMask(var T: TGLTextureCache; B: TBitmap; AColor: TColor);
    procedure UploadText(var T: TGLTextureCache; const S: string; AColor: TColor;
      FontSize: Integer; Bold: Boolean);
    procedure DrawTexture(const T: TGLTextureCache; X, Y: Integer);
    procedure DrawRect(X1, Y1, X2, Y2: Integer; C: TColor; Alpha: Single);
    procedure DrawLine(X1, Y1, X2, Y2: Integer; C: TColor; Width: Single;
      Stipple: Boolean);
    procedure DrawVerticalGradient(W, H: Integer);
    procedure DrawGrid(W, H: Integer; DBmax, DBmin, InvRange: Double);
    procedure DrawFilterAndCursors(W, H: Integer);
    procedure DrawAGCLines(W, H: Integer; DBmax, InvRange: Double);
    procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double);
    procedure DrawMarker(W, H: Integer);
    procedure DrawBeaconMarkersGL(W, H: Integer);
    procedure DrawDXSpotTicksGL(W, H: Integer);
    procedure DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
    procedure DrawIMDMarkersGL(W, H: Integer; DBmax, InvRange: Double);
    procedure DrawADCOverlay(W, H: Integer);
    procedure DrawCachedOverlays(W, H: Integer);
    procedure DrawSliceOverlays(W, H: Integer);
    procedure DrawSliceFilterMarkers(W, H: Integer);
    procedure DrawBandLetterGL(X1, X2: Integer; L: Char);
    // Y начала вертикали несущей, чтобы она не легла на букву (двойник
    // CPU-CarrierTopY; метрику берём из той же текстуры буквы).
    function  CarrierTopYGL(X1, X2, CarrierX: Integer; L: Char): Integer;
    function  SliceTexIndex(O: TVfoOverlay): Integer;   // индекс в FSliceTex, -1 если нет
    function FormatFreqGL(Hz: Double): string;
    function ActiveVfoFrequency: Double;
  public
    constructor Create;
    destructor Destroy; override;
    procedure AttachControl(C: TOpenGLControl);
    procedure AttachWaterfallControl(C: TOpenGLControl);
    procedure SetSpectrumBitmapSize(W, H: Integer); override;
    function SpectrumBitmapWidth: Integer; override;
    procedure DrawSpectrum; override;
    procedure PaintSpectrum(Sender: TObject); override;
    procedure InvalidateGridCache; override;
    procedure InvalidateOverlayCache; override;
    procedure InvalidateVfoOverlay; override;
    procedure ResetGLCache;
    procedure SetTheme(const T: TAppTheme); override;
  end;

implementation

const
  KEY_COLOR = TColor($00FF00FF);
  // Полоса фильтра передающего тракта (главный VFO или TX-слайс): красная.
  // TColor = $00BBGGRR (ColorToRGB читает младший байт как R).
  CLR_TX_BAND = TColor($003030E0);

function ClampI(V, Lo, Hi: Integer): Integer;
begin
  if V < Lo then Result := Lo
  else if V > Hi then Result := Hi
  else Result := V;
end;

procedure ColorToRGB(C: TColor; out R, G, B: GLFloat);
begin
  R := (C and $FF) / 255.0;
  G := ((C shr 8) and $FF) / 255.0;
  B := ((C shr 16) and $FF) / 255.0;
end;

constructor TSpectrumViewOpenGL.Create;
begin
  inherited Create;
  FWaterfall.Free;
  FWaterfall := TWaterfallViewOpenGL.Create;
  FWaterfall.WfFrameInterval := 1;
  FGridDirty := True;
  FOverlayDirty := True;
  FSampleOverlayDirty := True;
  FVfoOverlayDirty := True;
  FBandOverlayDirty := True;
  FDXOverlayDirty := True;
  FLastBandW := -MaxInt;
  FLastBandCenter := -1; FLastBandSpan := -1;
  FLastDXW := -MaxInt; FLastDXRender := -1;
  FLastAGCY := -MaxInt;
  FLastAGCHangY := -MaxInt;
  FLastMarkerX := -MaxInt;
  FLastADCVisible := False;
end;

destructor TSpectrumViewOpenGL.Destroy;
var i: Integer;
begin
  if Assigned(FGLControl) and FGLControl.MakeCurrent then
  begin
    DeleteTexture(FGridLabelTex);
    DeleteTexture(FAGCLabelTex);
    DeleteTexture(FAGCHangLabelTex);
    DeleteTexture(FMarkerLabelTex);
    DeleteTexture(FADCOverlayTex);
    DeleteTexture(FSampleOverlayTex);
    DeleteTexture(FVfoOverlayTex);
    DeleteTexture(FBandOverlayTex);
    DeleteTexture(FDXOverlayTex);
    for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex);
    for i := 0 to High(FLetterTex) do DeleteTexture(FLetterTex[i]);
    for i := 0 to High(FIMDLabelTex) do DeleteTexture(FIMDLabelTex[i]);
  end;
  SetLength(FSliceTex, 0);
  inherited Destroy;
end;

procedure TSpectrumViewOpenGL.AttachControl(C: TOpenGLControl);
begin
  FGLControl := C;
end;

procedure TSpectrumViewOpenGL.AttachWaterfallControl(C: TOpenGLControl);
begin
  if FWaterfall is TWaterfallViewOpenGL then
    TWaterfallViewOpenGL(FWaterfall).AttachControl(C);
end;

procedure TSpectrumViewOpenGL.DeleteTexture(var T: TGLTextureCache);
begin
  if T.Tex <> 0 then
    glDeleteTextures(1, @T.Tex);
  T.Tex := 0;
  T.W := 0;
  T.H := 0;
  T.Dirty := True;
end;

procedure TSpectrumViewOpenGL.UploadBitmap(var T: TGLTextureCache; B: TBitmap;
  Keyed: Boolean; FixedAlpha: Byte);
var
  X, Y, I: Integer;
  Src: PByte;
  Buf: array of Byte;
  IsKey: Boolean;
begin
  if (B = nil) or (B.Width <= 0) or (B.Height <= 0) then Exit;
  if T.Tex = 0 then glGenTextures(1, @T.Tex);
  T.W := B.Width;
  T.H := B.Height;
  SetLength(Buf, T.W * T.H * 4);
  B.BeginUpdate(False);
  try
    I := 0;
    for Y := 0 to T.H - 1 do
    begin
      Src := PByte(B.ScanLine[Y]);
      for X := 0 to T.W - 1 do
      begin
{$IFDEF DARWIN}
        // ARGB: byte0=A, byte1=R, byte2=G, byte3=B
        IsKey := Keyed and (Src[1] = $FF) and (Src[2] = $00) and (Src[3] = $FF);
        Buf[I] := Src[1]; Buf[I+1] := Src[2]; Buf[I+2] := Src[3];
{$ELSE}
        // BGRA: byte0=B, byte1=G, byte2=R
        IsKey := Keyed and (Src[0] = $FF) and (Src[1] = $00) and (Src[2] = $FF);
        Buf[I] := Src[2]; Buf[I+1] := Src[1]; Buf[I+2] := Src[0];
{$ENDIF}
        if IsKey then Buf[I + 3] := 0 else Buf[I + 3] := FixedAlpha;
        Inc(Src, 4);
        Inc(I, 4);
      end;
    end;
  finally
    B.EndUpdate(False);
  end;
  glBindTexture(GL_TEXTURE_2D, T.Tex);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, T.W, T.H, 0, GL_RGBA,
    GL_UNSIGNED_BYTE, @Buf[0]);
  glBindTexture(GL_TEXTURE_2D, 0);
  T.Dirty := False;
end;

procedure TSpectrumViewOpenGL.UploadAlphaMask(var T: TGLTextureCache; B: TBitmap;
  AColor: TColor);
var
  X, Y, I: Integer;
  Src: PByte;
  Buf: array of Byte;
  R, G, BB, A: Byte;
begin
  if (B = nil) or (B.Width <= 0) or (B.Height <= 0) then Exit;
  if T.Tex = 0 then glGenTextures(1, @T.Tex);
  T.W := B.Width;
  T.H := B.Height;
  R := AColor and $FF;
  G := (AColor shr 8) and $FF;
  BB := (AColor shr 16) and $FF;
  SetLength(Buf, T.W * T.H * 4);
  B.BeginUpdate(False);
  try
    I := 0;
    for Y := 0 to T.H - 1 do
    begin
      Src := PByte(B.ScanLine[Y]);
      for X := 0 to T.W - 1 do
      begin
{$IFDEF DARWIN}
        A := Max(Src[1], Max(Src[2], Src[3]));  // ARGB: R,G,B at bytes 1,2,3
{$ELSE}
        A := Max(Src[0], Max(Src[1], Src[2]));  // BGRA: B,G,R at bytes 0,1,2
{$ENDIF}
        Buf[I] := R;
        Buf[I + 1] := G;
        Buf[I + 2] := BB;
        Buf[I + 3] := A;
        Inc(Src, 4);
        Inc(I, 4);
      end;
    end;
  finally
    B.EndUpdate(False);
  end;
  glBindTexture(GL_TEXTURE_2D, T.Tex);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, T.W, T.H, 0, GL_RGBA,
    GL_UNSIGNED_BYTE, @Buf[0]);
  glBindTexture(GL_TEXTURE_2D, 0);
  T.Dirty := False;
end;

procedure TSpectrumViewOpenGL.UploadText(var T: TGLTextureCache; const S: string;
  AColor: TColor; FontSize: Integer; Bold: Boolean);
var
  B: TBitmap;
  TW, TH: Integer;
begin
  if S = '' then
  begin
    DeleteTexture(T);
    Exit;
  end;
  B := TBitmap.Create;
  try
    B.PixelFormat := pf32bit;
    B.SetSize(8, 8);
    B.Canvas.Font.Name := 'Courier New';
    B.Canvas.Font.Size := FontSize;
    if Bold then B.Canvas.Font.Style := [fsBold] else B.Canvas.Font.Style := [];
    TW := Max(1, B.Canvas.TextWidth(S) + 4);
    TH := Max(1, B.Canvas.TextHeight(S) + 2);
    B.SetSize(TW, TH);
    B.Canvas.Brush.Color := clBlack;
    B.Canvas.Brush.Style := bsSolid;
    B.Canvas.Pen.Style := psClear;
    B.Canvas.FillRect(Rect(0, 0, TW, TH));
    B.Canvas.Brush.Style := bsClear;
    B.Canvas.Font.Name := 'Courier New';
    B.Canvas.Font.Size := FontSize;
    if Bold then B.Canvas.Font.Style := [fsBold] else B.Canvas.Font.Style := [];
    B.Canvas.Font.Color := clWhite;
    B.Canvas.TextOut(2, 1, S);
    UploadAlphaMask(T, B, AColor);
  finally
    B.Free;
  end;
end;

procedure TSpectrumViewOpenGL.DrawTexture(const T: TGLTextureCache; X, Y: Integer);
begin
  if (T.Tex = 0) or (T.W <= 0) or (T.H <= 0) then Exit;
  glEnable(GL_TEXTURE_2D);
  glBindTexture(GL_TEXTURE_2D, T.Tex);
  glColor4f(1, 1, 1, 1);
  glBegin(GL_QUADS);
    glTexCoord2f(0, 0); glVertex2f(X, Y);
    glTexCoord2f(1, 0); glVertex2f(X + T.W, Y);
    glTexCoord2f(1, 1); glVertex2f(X + T.W, Y + T.H);
    glTexCoord2f(0, 1); glVertex2f(X, Y + T.H);
  glEnd;
  glBindTexture(GL_TEXTURE_2D, 0);
  glDisable(GL_TEXTURE_2D);
end;

procedure TSpectrumViewOpenGL.DrawRect(X1, Y1, X2, Y2: Integer; C: TColor;
  Alpha: Single);
var R, G, B: GLFloat;
begin
  ColorToRGB(C, R, G, B);
  glColor4f(R, G, B, Alpha);
  glBegin(GL_QUADS);
    glVertex2f(X1, Y1); glVertex2f(X2, Y1);
    glVertex2f(X2, Y2); glVertex2f(X1, Y2);
  glEnd;
end;

procedure TSpectrumViewOpenGL.DrawLine(X1, Y1, X2, Y2: Integer; C: TColor;
  Width: Single; Stipple: Boolean);
var R, G, B: GLFloat;
begin
  ColorToRGB(C, R, G, B);
  glColor4f(R, G, B, 1);
  glLineWidth(Width);
  if Stipple then
  begin
    glEnable(GL_LINE_STIPPLE);
    glLineStipple(1, $0F0F);
  end;
  glBegin(GL_LINES);
    glVertex2f(X1 + 0.5, Y1 + 0.5);
    glVertex2f(X2 + 0.5, Y2 + 0.5);
  glEnd;
  if Stipple then glDisable(GL_LINE_STIPPLE);
  glLineWidth(1);
end;

procedure TSpectrumViewOpenGL.SetSpectrumBitmapSize(W, H: Integer);
begin
  if (W <= 0) or (H <= 0) then Exit;
  FGLSpectrumW := W;
  FGLSpectrumH := H;
  FGridDirty := True;
  FOverlayDirty := True;
  FSpectrumDirty := True;
  if Assigned(FGLControl) then FGLControl.Invalidate;
end;

function TSpectrumViewOpenGL.SpectrumBitmapWidth: Integer;
begin
  Result := FGLSpectrumW;
end;

procedure TSpectrumViewOpenGL.DrawSpectrum;
begin
  FSpectrumDirty := False;
  if Assigned(FGLControl) then FGLControl.Invalidate;
end;

procedure TSpectrumViewOpenGL.InvalidateGridCache;
begin
  inherited InvalidateGridCache;
  FGridDirty := True;
  FOverlayDirty := True;
end;

procedure TSpectrumViewOpenGL.InvalidateOverlayCache;
begin
  inherited InvalidateOverlayCache;
  FOverlayDirty := True;
  FSampleOverlayDirty := True;
  FVfoOverlayDirty := True;
end;

procedure TSpectrumViewOpenGL.InvalidateVfoOverlay;
begin
  // Только текстура главного флага — НЕ трогаем FOverlayDirty, иначе band/
  // samplerate/слайсы зря перезаливались бы на каждый тик S-метра главного.
  FVfoOverlayDirty := True;
end;

procedure TSpectrumViewOpenGL.SetTheme(const T: TAppTheme);
begin
  inherited SetTheme(T);
  FGridDirty := True;
  FOverlayDirty := True;
end;

// Контекст пересоздан (смена MSAA → RecreateWnd): все GL-имена текстур
// стали недействительны. Обнуляем кэши БЕЗ glDelete — старого контекста
// уже нет, а в новом эти id могут достаться другим текстурам.
procedure TSpectrumViewOpenGL.ResetGLCache;

  procedure Zap(var T: TGLTextureCache);
  begin
    T.Tex := 0; T.W := 0; T.H := 0; T.Dirty := True;
  end;

var
  i: Integer;
begin
  Zap(FGridLabelTex);
  Zap(FAGCLabelTex);
  Zap(FAGCHangLabelTex);
  Zap(FMarkerLabelTex);
  Zap(FADCOverlayTex);
  Zap(FSampleOverlayTex);
  Zap(FVfoOverlayTex);
  Zap(FDXOverlayTex);
  Zap(FBandOverlayTex);
  for i := 0 to High(FSliceTex) do Zap(FSliceTex[i].Tex);
  for i := 0 to High(FLetterTex) do Zap(FLetterTex[i]);
  for i := 0 to High(FIMDLabelTex) do
  begin
    Zap(FIMDLabelTex[i]);
    FIMDLastText[i] := '';
  end;
  FLastAGCText := '';
  FLastMarkerText := '';
  FGridDirty := True;
  FOverlayDirty := True;
  FSampleOverlayDirty := True;
  FVfoOverlayDirty := True;
  FBandOverlayDirty := True;
  FDXOverlayDirty := True;
  FGLSpectrumW := 0;
  FGLSpectrumH := 0;
  // Водопад — свой контекст/текстуры (история + маркер). При reparent (pop-out)
  // умирают ОБА контекста; при смене MSAA только спектровый — там сброс
  // водопада лишь пере-зальёт его текстуру (дёшево, история и так в CPU-буфере).
  if FWaterfall is TWaterfallViewOpenGL then
    TWaterfallViewOpenGL(FWaterfall).ResetGLCache;
end;

function TSpectrumViewOpenGL.FormatFreqGL(Hz: Double): string;
var Mhz: Int64; KHz, Rest: Integer;
begin
  Mhz := Trunc(Hz / 1000000);
  KHz := Trunc((Hz - Mhz * 1000000) / 1000);
  Rest := Trunc(Hz) mod 1000;
  Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]);
end;

function TSpectrumViewOpenGL.ActiveVfoFrequency: Double;
begin
  if FActiveVfo = 0 then Result := FVfoA else Result := FVfoB;
end;

procedure TSpectrumViewOpenGL.DrawVerticalGradient(W, H: Integer);
var
  R1, G1, B1, R2, G2, B2: GLFloat;
begin
  ColorToRGB(FTheme.SpecGradTop, R1, G1, B1);
  ColorToRGB(FTheme.SpecGradBot, R2, G2, B2);
  glBegin(GL_QUADS);
    glColor4f(R1, G1, B1, 1); glVertex2f(0, 0); glVertex2f(W, 0);
    glColor4f(R2, G2, B2, 1); glVertex2f(W, H); glVertex2f(0, H);
  glEnd;
  DrawRect(0, 0, 28, H, FTheme.SpecLabelBand, 1);
end;

procedure TSpectrumViewOpenGL.DrawGrid(W, H: Integer; DBmax, DBmin,
  InvRange: Double);
var
  I, Yp, GX, N, GridMult: Integer;
  DB, GridFreqS, GridLine, PixPerStep: Double;
  B: TBitmap;
begin
  DB := DBmax - FSpecGridStep;
  while DB >= DBmin do
  begin
    Yp := Round((DBmax - DB) * InvRange * H);
    DrawLine(0, Yp, W - 1, Yp, FTheme.SpecGrid, 1, False);
    DB := DB - FSpecGridStep;
  end;

  if FFMGridStepHz > 0 then
  begin
    GridFreqS := FCenterFreq - FSpanHz / 2;
    PixPerStep := W * FFMGridStepHz / FSpanHz;
    if PixPerStep >= 1.0 then GridMult := Max(1, Ceil(4.0 / PixPerStep))
    else GridMult := 0;
    if GridMult > 0 then
    begin
      N := Ceil(GridFreqS / FFMGridStepHz);
      GridLine := N * FFMGridStepHz;
      while GridLine <= FCenterFreq + FSpanHz / 2 + 0.5 do
      begin
        if (N mod GridMult) = 0 then
        begin
          GX := Round((GridLine - GridFreqS) / FSpanHz * W);
          if (GX >= 0) and (GX < W) then DrawLine(GX, 0, GX, H, FTheme.SpecGrid, 1, False);
        end;
        Inc(N);
        GridLine := GridLine + FFMGridStepHz;
      end;
    end;
  end
  else
    for I := 0 to 8 do
    begin
      GX := Round(I / 8 * W);
      DrawLine(GX, 0, GX, H, FTheme.SpecGrid, 1, False);
    end;

  if FGridDirty or FGridLabelTex.Dirty or (FGridLabelTex.H <> H) then
  begin
    B := TBitmap.Create;
    try
      B.PixelFormat := pf32bit;
      B.SetSize(28, H);
      B.Canvas.Brush.Color := clBlack;
      B.Canvas.Brush.Style := bsSolid;
      B.Canvas.Pen.Style := psClear;
      B.Canvas.FillRect(Rect(0, 0, 28, H));
      B.Canvas.Font.Name := 'Courier New';
      B.Canvas.Font.Size := 7;
      B.Canvas.Font.Color := clWhite;
      B.Canvas.Brush.Style := bsClear;
      DB := DBmax - FSpecGridStep;
      while DB >= DBmin do
      begin
        Yp := Round((DBmax - DB) * InvRange * H);
        B.Canvas.TextOut(1, Yp - 9, Format('%4.0f', [DB]));
        DB := DB - FSpecGridStep;
      end;
      UploadAlphaMask(FGridLabelTex, B, FTheme.SpecLabelText);
    finally
      B.Free;
    end;
    FGridDirty := False;
  end;
  DrawTexture(FGridLabelTex, 0, 0);
end;

procedure TSpectrumViewOpenGL.DrawFilterAndCursors(W, H: Integer);
var
  X1, X2, VfoX, TXX1, TXX2, TXVfoX, CarrY: Integer;
  MainLetter: Char;
begin
  TXX1 := 0;
  TXX2 := 0;
  TXVfoX := 0;
  // На передаче полоса активного VFO рисуется по TX-фильтру (см. CPU-путь).
  if FTXOverlay and (FTXVfoIndex = FActiveVfo) then
  begin
    if FActiveVfo = 0 then CalcTXBandX(FVfoA, W, X1, X2, VfoX)
    else CalcTXBandX(FVfoB, W, X1, X2, VfoX);
  end
  else if FActiveVfo = 0 then CalcFilterBandX(FVfoA, W, X1, X2, VfoX)
  else CalcFilterBandX(FVfoB, W, X1, X2, VfoX);

  if FTXOverlay then
  begin
    if FTXVfoIndex <> FActiveVfo then
    begin
      if X2 > X1 then DrawRect(X1, 0, X2, H, TColor($0030C030), 100 / 255);
      if FTXVfoIndex = 0 then CalcTXBandX(FVfoA, W, TXX1, TXX2, TXVfoX)
      else CalcTXBandX(FVfoB, W, TXX1, TXX2, TXVfoX);
      if TXX2 > TXX1 then DrawRect(TXX1, 0, TXX2, H, CLR_TX_BAND, 100 / 255);
    end
    else if X2 > X1 then DrawRect(X1, 0, X2, H, CLR_TX_BAND, 100 / 255);
  end
  else if X2 > X1 then
    // Как у слайсов: полупрозрачная заливка (раньше здесь была alpha=1, и
    // главная полоса выглядела плотным блоком рядом с просвечивающими).
    // Цвет — SpecFilterBand, свой у полосы (см. AppTheme).
    DrawRect(X1, 0, X2, H, FTheme.SpecFilterBand, SPEC_BAND_ALPHA / 255);

  // Буква главного флага (A) на его полосе — только когда есть слайсы. Саму
  // букву рисуем ниже, но знать о ней надо здесь: под неё уходит начало
  // вертикали несущей и её «шляпка».
  MainLetter := #0;
  if (SliceOverlayCount > 0) and (FActiveVfo = 0) and Assigned(FVfoOverlay) then
    MainLetter := FVfoOverlay.SliceLetter;

  DrawLine(X1, 0, X1, H, FTheme.SpecFilterEdge, 1, False);
  DrawLine(X2, 0, X2, H, FTheme.SpecFilterEdge, 1, False);
  CarrY := CarrierTopYGL(X1, X2, VfoX, MainLetter);
  DrawLine(VfoX, CarrY, VfoX, H - 12, FTheme.SpecVfoCursor, 2, False);
  DrawRect(VfoX - 5, CarrY, VfoX + 5, CarrY + 8, FTheme.SpecVfoCursor, 1);

  if FTXOverlay and (FTXVfoIndex <> FActiveVfo) then
  begin
    DrawLine(TXX1, 0, TXX1, H, TColor($002030E0), 1, False);
    DrawLine(TXX2, 0, TXX2, H, TColor($002030E0), 1, False);
    // Буква нарисована на RX-полосе (X1..X2) — с ней и сверяемся.
    CarrY := CarrierTopYGL(X1, X2, TXVfoX, MainLetter);
    DrawLine(TXVfoX, CarrY, TXVfoX, H - 12, TColor($002030E0), 2, False);
    DrawRect(TXVfoX - 5, CarrY, TXVfoX + 5, CarrY + 8, TColor($002030E0), 1);
  end;

  if MainLetter <> #0 then DrawBandLetterGL(X1, X2, MainLetter);

  DrawSliceFilterMarkers(W, H);
end;

// Маркер фильтра каждого слайса (B+) на спектре: полупрозрачная полоса
// пропускания + центральная несущая. Цвет — по букве слайса (как его бейдж).
procedure TSpectrumViewOpenGL.DrawSliceFilterMarkers(W, H: Integer);
var
  i, SX1, SX2, SVfoX: Integer;
  O: TVfoOverlay;
  Clr: TColor;
begin
  for i := 0 to SliceOverlayCount - 1 do
  begin
    O := SliceOverlayAt(i);
    if (O = nil) or (not O.Visible) then Continue;
    // Передающий слайс — красная полоса (как у главного при TX), иначе оттенок бейджа.
    if O.TxActive then Clr := CLR_TX_BAND
    else               Clr := SliceColor(O.SliceLetter);
    CalcSliceBandX(O, W, SX1, SX2, SVfoX);
    if SX2 > SX1 then
      DrawRect(SX1, 0, SX2, H, Clr,
               IfThen(O.TxActive, SPEC_BAND_ALPHA_TX, SPEC_BAND_ALPHA) / 255);
    DrawLine(SX1, 0, SX1, H, Clr, 1, False);
    DrawLine(SX2, 0, SX2, H, Clr, 1, False);
    // Несущая — под буквой, если она приходится на неё (AM/FM/DSB).
    DrawLine(SVfoX, CarrierTopYGL(SX1, SX2, SVfoX, O.SliceLetter),
             SVfoX, H - 12, Clr, 2, False);
    DrawBandLetterGL(SX1, SX2, O.SliceLetter);
  end;
end;

// Буква слайса по центру полосы фильтра сверху (аналог CPU DrawBandLetter).
procedure TSpectrumViewOpenGL.DrawBandLetterGL(X1, X2: Integer; L: Char);
var
  idx, cx: Integer;
begin
  if (L < 'A') or (L > 'H') then Exit;
  if X2 <= X1 then Exit;
  idx := Ord(L) - Ord('A');
  if FLetterTex[idx].Tex = 0 then
    UploadText(FLetterTex[idx], L, SliceColor(L), 8, True);   // цвет слайса
  cx := (X1 + X2) div 2;
  DrawTexture(FLetterTex[idx], cx - FLetterTex[idx].W div 2, 0);
end;

function TSpectrumViewOpenGL.CarrierTopYGL(X1, X2, CarrierX: Integer;
  L: Char): Integer;
// То же правило, что в CPU-пути: в модуляциях с несущей (AM/FM/DSB) вертикаль
// приходится ровно на букву — начинаем её ПОД буквой; в SSB/CW несущая у кромки
// полосы, пересечения нет и зазор нулевой. Текстуру буквы при необходимости
// заливаем здесь же: она нужна нам раньше, чем до неё дойдёт DrawBandLetterGL
// (порядок вызовов в кадре — линии, потом буквы).
const
  CLEAR_X = 3;
  CLEAR_Y = 2;
var
  idx: Integer;
begin
  Result := 0;
  if (L < 'A') or (L > 'H') or (X2 <= X1) then Exit;
  idx := Ord(L) - Ord('A');
  if FLetterTex[idx].Tex = 0 then
    UploadText(FLetterTex[idx], L, SliceColor(L), 8, True);
  if FLetterTex[idx].H <= 0 then Exit;
  if Abs(CarrierX - (X1 + X2) div 2) <= FLetterTex[idx].W div 2 + CLEAR_X then
    Result := FLetterTex[idx].H + CLEAR_Y;
end;

procedure TSpectrumViewOpenGL.DrawAGCLines(W, H: Integer; DBmax,
  InvRange: Double);
var
  AGCY, AGCHangY: Integer;
  Text: string;
begin
  if FWDSPReady then
  begin
    AGCY := Round((DBmax - FAGCThresh) * InvRange * H);
    Text := 'AGC T';
    if (AGCY >= 0) and (AGCY < H) then
    begin
      DrawLine(0, AGCY, W, AGCY, FTheme.SpecAgcColor, 1, True);
      if (Text <> FLastAGCText) or (AGCY <> FLastAGCY) or FAGCLabelTex.Dirty then
      begin
        UploadText(FAGCLabelTex, Text, FTheme.SpecAgcColor, 6, False);
        FLastAGCText := Text;
        FLastAGCY := AGCY;
      end;
      DrawTexture(FAGCLabelTex, 4, AGCY - 9);
    end;
    AGCHangY := Round((DBmax - FAGCHangLevel) * InvRange * H);
    if (AGCHangY >= 0) and (AGCHangY < H) and (Abs(AGCHangY - AGCY) > 4) then
    begin
      DrawLine(0, AGCHangY, W, AGCHangY, FTheme.SpecAgcHangColor, 1, True);
      if (AGCHangY <> FLastAGCHangY) or FAGCHangLabelTex.Dirty then
      begin
        UploadText(FAGCHangLabelTex, 'AGC H', FTheme.SpecAgcHangColor, 6, False);
        FLastAGCHangY := AGCHangY;
      end;
      DrawTexture(FAGCHangLabelTex, 4, AGCHangY + 2);
    end;
  end
  else
  begin
    AGCY := Round((DBmax - (-FAGCTop)) * InvRange * H);
    Text := Format('AGC -%ddB', [FAGCTop]);
    if (AGCY >= 0) and (AGCY < H) then
    begin
      DrawLine(0, AGCY, W, AGCY, FTheme.SpecAgcColor, 1, True);
      if (Text <> FLastAGCText) or (AGCY <> FLastAGCY) or FAGCLabelTex.Dirty then
      begin
        UploadText(FAGCLabelTex, Text, FTheme.SpecAgcColor, 6, False);
        FLastAGCText := Text;
        FLastAGCY := AGCY;
      end;
      DrawTexture(FAGCLabelTex, 4, AGCY - 9);
    end;
  end;
end;

procedure TSpectrumViewOpenGL.DrawSpectrumCurve(W, H: Integer; DBmax,
  InvRange: Double);
var
  I, SrcCount, S0, S1: Integer;
  SrcF, Frac, DBV: Double;
  R, G, B: GLFloat;
begin
  if Length(FSpY) <> W then SetLength(FSpY, W);
  SrcCount := ClampI(FSpectrumBufCount, 2, WF_MAX_PIXELS);
  for I := 0 to W - 1 do
  begin
    if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then
    begin
      SrcF := FCenterFreq - FSpanHz * 0.5 + I * FSpanHz / Max(1, W - 1);
      Frac := SrcF - FTXFreq;
      if (Frac < -FTXSpanHz * 0.5) or (Frac > FTXSpanHz * 0.5) then DBV := -200.0
      else
      begin
        SrcF := (Frac + FTXSpanHz * 0.5) / FTXSpanHz * (SrcCount - 1);
        S0 := Min(Trunc(SrcF), SrcCount - 1);
        S1 := Min(S0 + 1, SrcCount - 1);
        Frac := SrcF - S0;
        DBV := FSpectrumBuf[S0] * (1.0 - Frac) + FSpectrumBuf[S1] * Frac;
      end;
    end
    else
    begin
      SrcF := I * (SrcCount - 1.0) / Max(1, W - 1);
      S0 := Min(Trunc(SrcF), SrcCount - 1);
      S1 := Min(S0 + 1, SrcCount - 1);
      Frac := SrcF - S0;
      DBV := FSpectrumBuf[S0] * (1.0 - Frac) + FSpectrumBuf[S1] * Frac;
    end;
    FSpY[I] := ClampI(Round((DBmax - DBV) * InvRange * H), 0, H - 1);
  end;

  ColorToRGB(TColor((FTheme.SpecGradB shl 16) or
    (FTheme.SpecGradG shl 8) or FTheme.SpecGradR), R, G, B);
  glBegin(GL_TRIANGLE_STRIP);
    for I := 0 to W - 1 do
    begin
      glColor4f(R, G, B, 200 / 255);
      glVertex2f(I + 0.5, FSpY[I] + 0.5);
      glColor4f(R, G, B, 0);
      glVertex2f(I + 0.5, H);
    end;
  glEnd;

  ColorToRGB(FTheme.SpecLine, R, G, B);
  glColor4f(R, G, B, 1);
  glBegin(GL_LINE_STRIP);
    for I := 0 to W - 1 do glVertex2f(I + 0.5, FSpY[I] + 0.5);
  glEnd;
end;

procedure TSpectrumViewOpenGL.DrawMarker(W, H: Integer);
var
  MX: Integer;
  MarkerFreq: Double;
  MarkerText: string;
begin
  if not FMarkerActive then Exit;
  MX := Round(FMarkerX / 1000.0 * W);
  if (MX < 0) or (MX >= W) then Exit;
  DrawLine(MX, 0, MX, H, TColor($004444FF), 1, False);
  MarkerFreq := (FCenterFreq - FSpanHz / 2) + FMarkerX / 1000.0 * FSpanHz;
  MarkerText := FormatFreqGL(Round(MarkerFreq));
  if (MarkerText <> FLastMarkerText) or (MX <> FLastMarkerX) or FMarkerLabelTex.Dirty then
  begin
    UploadText(FMarkerLabelTex, MarkerText, TColor($004444FF), 7, False);
    FLastMarkerText := MarkerText;
    FLastMarkerX := MX;
  end;
  if MX + 4 + FMarkerLabelTex.W < W then DrawTexture(FMarkerLabelTex, MX + 4, 4)
  else DrawTexture(FMarkerLabelTex, MX - 4 - FMarkerLabelTex.W, 4);
end;

procedure TSpectrumViewOpenGL.DrawBeaconMarkersGL(W, H: Integer);
// QO-100 beacon-маркеры: опорная частота (зелёная) + отслеживаемый центроид (оранж).
  procedure Vline(FreqHz: Double; Col: TColor);
  var x: Integer;
  begin
    if FSpanHz <= 0 then Exit;
    x := Round((FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
    if (x < 0) or (x >= W) then Exit;
    DrawLine(x, 0, x, H, Col, 1, False);
  end;
begin
  if FBeaconMarkActive then
  begin
    Vline(FBeaconRefFreq, clLime);             // где маяк ДОЛЖЕН быть
    Vline(FBeaconTrkFreq, TColor($000AA5FF));  // отслеживаемый центроид (оранж)
  end;
  if FBeaconDecActive then
  begin
    Vline(FBeaconDecFreq - FBeaconDecHalf, TColor($0020D0FF));  // грань фильтра
    Vline(FBeaconDecFreq + FBeaconDecHalf, TColor($0020D0FF));
    Vline(FBeaconDecFreq,                  TColor($0020D0FF));  // центр наведения
  end;
end;

procedure TSpectrumViewOpenGL.DrawDXSpotTicksGL(W, H: Integer);
// GL-двойник DrawDXSpotTicksRaw: те же X и цвета из раскладки оверлея, только
// линиями GL. EnsureRendered здесь и держит раскладку свежей — текстуру полосы
// DrawCachedOverlays перезальёт по RenderVersion, кто бы ни пересобрал кэш.
var
  i, X, Y0: Integer;
  Col: TColor;
begin
  if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
  FDXSpotOverlay.EnsureRendered(W);
  Y0 := FDXSpotOverlay.BandHeight;
  // Низкий пан: полоса подписей не влезла (её текстуру DrawCachedOverlays тоже
  // пропускает) — штрихам не от чего идти.
  if H <= Y0 + 2 then Exit;
  for i := 0 to FDXSpotOverlay.TickCount - 1 do
  begin
    FDXSpotOverlay.Tick(i, X, Col);
    if (X < 0) or (X >= W) then Continue;
    DrawLine(X, Y0, X, H, Col, 1, True);   // пунктир — как в CPU-пути
  end;
end;

procedure TSpectrumViewOpenGL.DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
// Залитый кружок (triangle fan, 20 сегментов) — маркер пика 2TON/IMD.
var
  CR, CG, CB: GLFloat;
  a: Integer;
  Ang: Single;
begin
  ColorToRGB(C, CR, CG, CB);
  glColor4f(CR, CG, CB, 1);
  glBegin(GL_TRIANGLE_FAN);
    glVertex2f(CX + 0.5, CY + 0.5);
    for a := 0 to 20 do
    begin
      Ang := a * 2 * Pi / 20;
      glVertex2f(CX + 0.5 + R * Cos(Ang), CY + 0.5 + R * Sin(Ang));
    end;
  glEnd;
end;

procedure TSpectrumViewOpenGL.DrawIMDMarkersGL(W, H: Integer;
  DBmax, InvRange: Double);
// GL-вариант DrawIMDMarkersRaw: замер общий (ComputeIMDMarkers), подписи —
// текстуры с кэшем по строке (перезаливка только при смене значения).
const
  CLR_TONE = TColor($0050FF50);   // зелёный — основные тона
  CLR_IMD  = TColor($000A8CFF);   // оранжевый — продукты IMD3
var
  M: array[0..3] of TIMDMarker;
  N, i, X, Y, TX, TY, BX1, BX2, BVX: Integer;
  Col: TColor;
begin
  if FSpanHz <= 0 then Exit;
  N := ComputeIMDMarkers(M);
  if N = 0 then Exit;
  for i := 0 to N - 1 do
  begin
    if M[i].LevelDB < -500 then Continue;   // частота вне окна буфера
    X := Round((M[i].FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
    if (X < 0) or (X >= W) then Continue;
    Y := Max(0, Min(H - 1, Round((DBmax - M[i].LevelDB) * InvRange * H)));
    // Все маркеры зелёные (dBm-замеры); оранжевый — только вычисленная
    // сводка dBc, чтобы цвета не путались.
    Col := CLR_TONE;
    DrawCircleGL(X, Y, 4.0, Col);
    if (M[i].Caption <> FIMDLastText[i]) or FIMDLabelTex[i].Dirty then
    begin
      UploadText(FIMDLabelTex[i], M[i].Caption, Col, 7, True);
      FIMDLastText[i] := M[i].Caption;
    end;
    // Подпись по центру над своим кружком; у верхней кромки — под ним.
    TY := Y - 16;
    if TY < 2 then TY := Y + 8;
    TX := Max(2, Min(W - FIMDLabelTex[i].W - 2, X - FIMDLabelTex[i].W div 2));
    DrawTexture(FIMDLabelTex[i], TX, TY);
  end;
  // Сводка слева от полосы фильтра, чтобы не прятаться за SampleRateOverlay
  // в левом верхнем углу и сразу бросаться в глаза рядом с сигналом.
  if FIMDSummary <> '' then
  begin
    if (FIMDSummary <> FIMDLastText[4]) or FIMDLabelTex[4].Dirty then
    begin
      UploadText(FIMDLabelTex[4], FIMDSummary, CLR_IMD, 11, True);
      FIMDLastText[4] := FIMDSummary;
    end;
    if FActiveVfo = 0 then CalcFilterBandX(FVfoA, W, BX1, BX2, BVX)
    else                   CalcFilterBandX(FVfoB, W, BX1, BX2, BVX);
    TX := Max(2, Min(W - FIMDLabelTex[4].W - 2,
                     BX1 - FIMDLabelTex[4].W - 8));
    DrawTexture(FIMDLabelTex[4], TX, 6);
  end;
end;

procedure TSpectrumViewOpenGL.DrawADCOverlay(W, H: Integer);
// Тот же образ, что CPU-путь (AlertOverlay.BuildAlertImage — единственный
// источник внешнего вида): грузится текстурой ОДИН раз при появлении плашки
// (кэш FADCOverlayTex), в кадре — только готовый DrawTexture.
var
  Img: TAlertImage;
begin
  if not FADCOverloadVisible then Exit;
  if (W < 220) or (H < 60) then Exit;   // как CPU-путь
  if (not FLastADCVisible) or FADCOverlayTex.Dirty then
  begin
    BuildAlertImage('ADC OVERLOAD', 'Input clipping detected', Img);
    if FADCOverlayTex.Tex = 0 then glGenTextures(1, @FADCOverlayTex.Tex);
    FADCOverlayTex.W := Img.W;
    FADCOverlayTex.H := Img.H;
    glBindTexture(GL_TEXTURE_2D, FADCOverlayTex.Tex);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, Img.W, Img.H, 0, GL_RGBA,
      GL_UNSIGNED_BYTE, @Img.RGBA[0]);
    glBindTexture(GL_TEXTURE_2D, 0);
    FADCOverlayTex.Dirty := False;
  end;
  FLastADCVisible := True;
  DrawTexture(FADCOverlayTex, W - FADCOverlayTex.W - ALERT_PAD, ALERT_PAD);
end;

procedure TSpectrumViewOpenGL.DrawCachedOverlays(W, H: Integer);
var
  B: TBitmap;
  OW, OH: Integer;
begin
  // Бэндплан QO-100 — полоска внизу спектра (над ruler), под панелями оверлеев.
  if Assigned(BandPlanOverlay) and BandPlanOverlay.Active then
  begin
    if FOverlayDirty or FBandOverlayDirty or FBandOverlayTex.Dirty or
       (FLastBandW <> W) or
       (Abs(FLastBandCenter - BandPlanOverlay.CenterFreq) >= 1.0) or
       (Abs(FLastBandSpan - BandPlanOverlay.SpanHz) >= 1.0) then
    begin
      B := TBitmap.Create;
      try
        BandPlanOverlay.DrawOverlayBitmap(B, W);
        UploadBitmap(FBandOverlayTex, B, False, BANDPLAN_ALPHA);
      finally
        B.Free;
      end;
      FLastBandW := W;
      FLastBandCenter := BandPlanOverlay.CenterFreq;
      FLastBandSpan := BandPlanOverlay.SpanHz;
      FBandOverlayDirty := False;
    end;
    DrawTexture(FBandOverlayTex, 0, H - FBandOverlayTex.H);
  end;

  // Полоса подписей DX-спотов — вверху спектра. Тот же битмап, что в CPU-пути,
  // заливается текстурой ТОЛЬКО при dirty (новый спот меняет CacheDirty
  // оверлея, вид — центр/спан/ширину).
  // Низкому пану полоса подписей не по росту — пропускаем её целиком (тот же
  // гейт, что в CPU-пути DrawOverlay).
  if Assigned(FDXSpotOverlay) and FDXSpotOverlay.Active and
     (H > FDXSpotOverlay.BandHeight) then
  begin
    if FOverlayDirty or FDXOverlayDirty or FDXOverlayTex.Dirty or
       (FLastDXW <> W) or (FLastDXRender <> FDXSpotOverlay.RenderVersion) then
    begin
      B := TBitmap.Create;
      try
        FDXSpotOverlay.DrawOverlayBitmap(B, W);
        UploadBitmap(FDXOverlayTex, B, True, DXSPOT_ALPHA);
      finally
        B.Free;
      end;
      FLastDXW      := W;
      FLastDXRender := FDXSpotOverlay.RenderVersion;
      FDXOverlayDirty := False;
    end;
    DrawTexture(FDXOverlayTex, 0, 0);
  end;

  if Assigned(FSampleRateOverlay) then
  begin
    OW := Min(FSampleRateOverlay.Width, W - FSampleRateOverlay.Left);
    OH := Min(FSampleRateOverlay.Height, H - FSampleRateOverlay.Top);
    if (OW > 0) and (OH > 0) then
    begin
      if FOverlayDirty or FSampleOverlayDirty or FSampleOverlayTex.Dirty or
         (FLastSampleX <> FSampleRateOverlay.Left) or
         (FLastSampleY <> FSampleRateOverlay.Top) or
         (FLastSampleH <> OH) then
      begin
        B := TBitmap.Create;
        try
          FSampleRateOverlay.DrawOverlayBitmap(B, OW, OH);
          UploadBitmap(FSampleOverlayTex, B, False, 255);
        finally
          B.Free;
        end;
        FLastSampleX := FSampleRateOverlay.Left;
        FLastSampleY := FSampleRateOverlay.Top;
        FLastSampleW := FSampleOverlayTex.W;
        FLastSampleH := OH;
        FSampleOverlayDirty := False;
      end;
      DrawTexture(FSampleOverlayTex, FSampleRateOverlay.Left, FSampleRateOverlay.Top);
    end;
  end;

  if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
  begin
    if FOverlayDirty or FVfoOverlayDirty or FVfoOverlayTex.Dirty or
       FVfoOverlay.CacheDirty or FVfoOverlay.MeterDirty or
       (FLastVfoX <> FVfoOverlay.Left) or (FLastVfoY <> FVfoOverlay.Top) or
       (FLastVfoW <> FVfoOverlay.Width) or (FLastVfoH <> FVfoOverlay.Height) then
    begin
      UploadBitmap(FVfoOverlayTex, FVfoOverlay.EnsureRendered, True, VFO_OVERLAY_ALPHA);
      FLastVfoX := FVfoOverlay.Left;
      FLastVfoY := FVfoOverlay.Top;
      FLastVfoW := FVfoOverlay.Width;
      FLastVfoH := FVfoOverlay.Height;
      FVfoOverlayDirty := False;
    end;
    DrawTexture(FVfoOverlayTex, FVfoOverlay.Left, FVfoOverlay.Top);
  end;

  DrawSliceOverlays(W, H);
  FOverlayDirty := False;
end;

function TSpectrumViewOpenGL.SliceTexIndex(O: TVfoOverlay): Integer;
var i: Integer;
begin
  Result := -1;
  for i := 0 to High(FSliceTex) do
    if FSliceTex[i].Overlay = O then Exit(i);
end;

// Флаги слайсов (B+): своя GL-текстура на флаг (привязка по указателю оверлея).
// Текстуры «сирот» (флаг удалён) вычищаются здесь же.
procedure TSpectrumViewOpenGL.DrawSliceOverlays(W, H: Integer);
var
  n, i, ti, j: Integer;
  O: TVfoOverlay;
  Alive: Boolean;
begin
  // 1. Убрать текстуры флагов, которых уже нет в списке.
  i := 0;
  while i <= High(FSliceTex) do
  begin
    Alive := False;
    for j := 0 to SliceOverlayCount - 1 do
      if SliceOverlayAt(j) = FSliceTex[i].Overlay then begin Alive := True; Break; end;
    if not Alive then
    begin
      DeleteTexture(FSliceTex[i].Tex);
      for j := i to High(FSliceTex) - 1 do FSliceTex[j] := FSliceTex[j + 1];
      SetLength(FSliceTex, Length(FSliceTex) - 1);
    end
    else
      Inc(i);
  end;

  // 2. Нарисовать каждый видимый флаг, при нужде (пере)залив текстуру.
  n := SliceOverlayCount;
  for i := 0 to n - 1 do
  begin
    O := SliceOverlayAt(i);
    if (O = nil) or (not O.Visible) then Continue;
    ti := SliceTexIndex(O);
    if ti < 0 then
    begin
      SetLength(FSliceTex, Length(FSliceTex) + 1);
      ti := High(FSliceTex);
      FSliceTex[ti].Overlay := O;
      FSliceTex[ti].Tex.Dirty := True;
      FSliceTex[ti].LastX := MaxInt;   // форсируем первую заливку
    end;
    // Перезаливаем текстуру слайса ТОЛЬКО когда изменился он сам (CacheDirty:
    // режим/громкость, MeterDirty: S-метр) или его позиция — не по глобальному
    // FOverlayDirty. При MeterDirty EnsureRendered перерисует лишь полоску метра.
    if O.CacheDirty or O.MeterDirty or FSliceTex[ti].Tex.Dirty or
       (FSliceTex[ti].LastX <> O.Left) or (FSliceTex[ti].LastY <> O.Top) or
       (FSliceTex[ti].LastW <> O.Width) or (FSliceTex[ti].LastH <> O.Height) then
    begin
      UploadBitmap(FSliceTex[ti].Tex, O.EnsureRendered, True, VFO_OVERLAY_ALPHA);
      FSliceTex[ti].LastX := O.Left;
      FSliceTex[ti].LastY := O.Top;
      FSliceTex[ti].LastW := O.Width;
      FSliceTex[ti].LastH := O.Height;
    end;
    DrawTexture(FSliceTex[ti].Tex, O.Left, O.Top);
  end;
end;

procedure TSpectrumViewOpenGL.PaintSpectrum(Sender: TObject);
var
  W, H: Integer;
  DBmax, DBmin, InvRange: Double;
begin
  if Sender is TOpenGLControl then
    FGLControl := TOpenGLControl(Sender);
  if (FGLControl = nil) or (not FGLControl.MakeCurrent) then Exit;
  W := FGLControl.Width;
  H := FGLControl.Height;
  if (W <= 0) or (H <= 0) then Exit;

  // HiDPI: viewport в физических пикселях FBO, координаты — логические
  glViewport(0, 0, Round(W * FGLControl.PixelScale),
             Round(H * FGLControl.PixelScale));
  glMatrixMode(GL_PROJECTION);
  glLoadIdentity;
  glOrtho(0, W, H, 0, -1, 1);
  glMatrixMode(GL_MODELVIEW);
  glLoadIdentity;
  glDisable(GL_DEPTH_TEST);
  glEnable(GL_BLEND);
  glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
  glClearColor(0, 0, 0, 1);
  glClear(GL_COLOR_BUFFER_BIT);

  DBmax := FSpecRefLevel;
  DBmin := FSpecRefLevel - FSpecRange;
  InvRange := 1.0 / (DBmax - DBmin);

  DrawVerticalGradient(W, H);
  DrawGrid(W, H, DBmax, DBmin, InvRange);
  DrawFilterAndCursors(W, H);
  DrawAGCLines(W, H, DBmax, InvRange);
  DrawSpectrumCurve(W, H, DBmax, InvRange);
  DrawMarker(W, H);
  if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H);
  DrawDXSpotTicksGL(W, H);
  // 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
  // 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
  if FIMDActive then DrawIMDMarkersGL(W, H, DBmax, InvRange);
  DrawADCOverlay(W, H);
  DrawCachedOverlays(W, H);

  FLastADCVisible := FADCOverloadVisible;
  FGLControl.SwapBuffers;
end;

end.
