CCSLetterbox.cs

A post-processing component that draws letterbox black bars. It exposes presets and manual controls for aspect ratio, computes bar thicknesses, updates an AspectRatio property, and sets shader attributes before blitting a postprocess material.

Native Interop
using Sandbox;
using System;
using Sandbox.Rendering;

/// <summary>
/// Crop the screen using black bars like you are watching a DVD on an old tv.
/// </summary>
[Title( "Letterboxing" )]
[Category( "Post Processing" )]
[Icon( "theaters" )]
public sealed class CCSLetterbox : BasePostProcess<CCSLetterbox>
{
    [Property]
    public Color LetterboxColor { get; set; } = Color.Black;
    
    private static readonly float[] AspectRatios = { 0.00f, 2.39f, 1.85f, 1.33f, 1.50f, 1.00f, 0.5625f, 0.80f, 2.76f, 3.50f, 4.00f };

    private AspectPreset _aspectSelection;
    
    [Property, Title("Aspect Ratio Preset")] 
    public AspectPreset AspectSelection
    {
        get => _aspectSelection;
        set => _aspectSelection = value;
    }
    
    public enum AspectPreset
    {
        None,
        [Description( "2.39:1 Widescreen film" )]
        Scope,
        [Description( "1.85:1 Theatrical film" )]
        Flat,
        [Description( "4:3 CLASSIC" )]
        TV,
        [Description( "3:2 35mm Photo" )]
        FilmPhoto,
        [Description( "1:1 Perfection" )]
        Square,
        [Description( "9:16 Vertical Video" )]
        Vertical,
        [Description( "4:5 Slightly less ugly" )]
        InstaVertical,
        [Description( "2.76:1 The Cinema is yours" )]
        UltraPanavision70,
        [Description( "3.5:1 aka 32:9, dual monitor" )]
        SuperUltraWide,
        [Description( "4:1 Napoléon [1927]" )]
        Polyvision
    }

    private bool _manualSettings;
    
    [Property, Title("Manual Settings"), ToggleGroup("ManualSettings", Label = "Manual Settings")]
    public bool ManualSettings
    {
        get => _manualSettings;
        set => _manualSettings = value;
    }

    [Property, Title("Aspect Ratio"), ReadOnly, Group("ManualSettings")]
    public float AspectRatio { get; private set; } = 1.777f;

    [Property, Title("Horizontal Bar Thickness"), Range(0, 100.0f, 0, true), Group("ManualSettings")]
    public float vPercent { get; set; } = 33.3f;

    [Property, Title("Vertical Bar Thickness"), Range(0, 100.0f, 0, true), Group("ManualSettings")]
    public float hPercent { get; set; } = 0.0f;

    private void OnAspectPresetChanged()
    {
        if (AspectSelection != AspectPreset.None)
        {
            _manualSettings = false;
        }
    }

    private void OnManualSettingsChanged()
    {
        if (_manualSettings)
        {
            AspectSelection = AspectPreset.None;
        }
    }

    protected override void OnUpdate()
    {
        var cc = Components.Get<CameraComponent>(true);
        if (cc == null || cc.ScreenRect.Width <= 0 || cc.ScreenRect.Height <= 0)
            return;

        // Если выбран пресет, вычисляем проценты и обновляем свойства
        if (AspectSelection != AspectPreset.None)
        {
            float cameraAspect = cc.ScreenRect.Width / cc.ScreenRect.Height;
            float desiredAspect = AspectRatios[(int)AspectSelection];
            
            if (desiredAspect > cameraAspect)
            {
                vPercent = 100.0f * (1.0f - (cameraAspect / desiredAspect));
                hPercent = 0.0f;
            }
            else if (desiredAspect < cameraAspect)
            {
                hPercent = 100.0f * (1.0f - (desiredAspect / cameraAspect));
                vPercent = 0.0f;
            }
            else
            {
                vPercent = 0.0f;
                hPercent = 0.0f;
            }
        }
        
        // Обновляем AspectRatio на основе текущих значений vPercent и hPercent
        float effectiveWidth = cc.ScreenRect.Width * (1.0f - hPercent / 100.0f);
        float effectiveHeight = cc.ScreenRect.Height * (1.0f - vPercent / 100.0f);
        
        if (effectiveHeight > 0.0001f)
        {
            AspectRatio = effectiveWidth / effectiveHeight;
        }
        else
        {
            // Если высота слишком мала, используем оригинальный aspect ratio камеры
            AspectRatio = cc.ScreenRect.Width / cc.ScreenRect.Height;
        }
    }

    public override void Render()
    {
        // Используем GetWeighted для поддержки PostProcess Volume
        float v = GetWeighted(x => x.vPercent);
        float h = GetWeighted(x => x.hPercent);
        Color color = GetWeighted(x => x.LetterboxColor);
        
        // Если эффект не нужен, выходим
        if (v.AlmostEqual(0.0f) && h.AlmostEqual(0.0f))
            return;
        
        // Передаем значения в шейдер
        Attributes.Set("vPercent", v);
        Attributes.Set("hPercent", h);
        // Конвертируем Color в Vector4 для шейдера (float4)
        Attributes.Set("LetterboxColor", new Vector4(color.r, color.g, color.b, color.a));
        
        var shader = Material.FromShader("postprocess/ccs_letterbox.shader");
        var blit = BlitMode.WithBackbuffer(shader, Stage.AfterPostProcess, 21000, true);
        Blit(blit, "CCSLetterbox");
    }
}