terminalCode Example

UIDecal Component - Render Razor UI as a Decal

calendar_today May 11, 2026 schedule ~2 min read person patrickjr verified 50

A Render Component for s&box that allows you to project Razor UI onto surfaces as a Decal. Great for sticking UI onto rounded walls, curved surfaces, or any geometry where a flat World Panel won't work.

Setup

  1. Put this component on a GameObject that has a Decal component
  2. Create an empty Decal Definition asset in the Asset Browser
  3. Assign both to the UIDecal component properties
  4. Under Panel Source, choose one of two options:
- Panel Prefab: A prefab containing the target UI Component and any other components needed for the UI to function - Panel Type Name: The class name of the UI Component directly (recommended for simple panels)
  1. Adjust Capture settings:
- Texture Width / Height: Controls the UI's ratio in world space (similar to World Panel) - Panel Local Scale: How much of the Decal space the UI fills. Useful for different size ratios - Panel Render Scale: Adjusts render resolution (similar to World Panel) - Background Color: Default is red to help visualize scale. Set to transparent when done
CSHARP
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Renders a chosen Razor Component as a Decal.
/// <para/>
/// <b>Created by Lag_Spike.</b> Check out my s&box games:
/// <b>"Nutcracker"</b> and <b>"Claw Colosseum"</b>.
/// </summary>
[Title( "UI Decal" )]
[Category( "UI" )]
[Icon( "wallpaper" )]
public sealed class UIDecal : Component
{
    /// <summary>
    /// Decal Component you want to affect.
    /// </summary>
    [Property] public Decal TargetDecal { get; set; }

    /// <summary>
    /// Empty Decal Definition that will be overwritten
    /// </summary>
    [Property] public DecalDefinition TemplateDecal { get; set; }

    /// <summary>
    /// Panel Prefab you can use if the Panel needs other components to work
    /// </summary>
    [Property, Header( "Panel Source" )]
    public GameObject PanelPrefab { get; set; }

    /// <summary>
    /// Use this if the UI doesn't need other components
    /// </summary>
    [Property]
    public string PanelTypeName { get; set; } = "";

    public PanelComponent CapturedPanel { get; private set; }

    [Property, Header( "Capture" ), Range( 128, 4096, 1 )] public int TextureWidth { get; set; } = 1024;
    [Property, Range( 128, 4096, 1 )] public int TextureHeight { get; set; } = 1440;
    [Property, Range( 0.1f, 100f, 0.5f )] public float PanelLocalScale { get; set; } = 25f;

    /// <summary>
    /// Resolution of UI, similar to how Render Scale works on a World Panel
    /// </summary>
    [Property, Range( 0.25f, 4f, 0.25f )] public float PanelRenderScale { get; set; } = 2f;

    /// <summary>
    /// Background to the UI, use red to find appropriate scale, then change to transparent
    /// </summary>
    [Property] public Color BackgroundColor { get; set; } = Color.Red;

    [Property, Header( "Debug" )] public bool DebugLogging { get; set; } = true;
    [Property] public Texture DebugStaticTexture { get; set; }

    private Texture _renderTarget;
    private GameObject _captureRoot;
    private GameObject _panelGo;
    private CameraComponent _captureCamera;
    private Sandbox.WorldPanel _captureWorldPanel;
    private const string CaptureTag = "uidecal_capture";

    protected override void OnStart()
    {
        if ( !TargetDecal.IsValid() )
            TargetDecal = Components.Get<Decal>( FindMode.EverythingInSelfAndChildren );

        if ( !TargetDecal.IsValid() )
        {
            Log.Warning( "[UIDecal] No Decal component found on this GameObject." );
            return;
        }

        if ( TemplateDecal == null )
        {
            Log.Warning( "[UIDecal] No TemplateDecal assigned. Create a .decal asset in " +
                "the Asset Browser and drag it into the 'Template Decal' field." );
            return;
        }

        if ( PanelPrefab == null && string.IsNullOrWhiteSpace( PanelTypeName ) )
        {
            Log.Warning( "[UIDecal] Either PanelPrefab or PanelTypeName must be set." );
            return;
        }

        BuildCaptureRig();
        WireDecal();

        if ( TargetDecal.Size.x <= 2f || TargetDecal.Size.y <= 2f )
        {
            Log.Warning( $"[UIDecal] Decal.Size is {TargetDecal.Size} — that's tiny. " +
                $"Set Size on the Decal component to your physical footprint in world units." );
        }
    }

    protected override void OnDestroy()
    {
        if ( _captureRoot.IsValid() )
            _captureRoot.Destroy();
        _captureRoot = null;
        _renderTarget?.Dispose();
        _renderTarget = null;
        CapturedPanel = null;
    }

    protected override void OnUpdate()
    {
        if ( _panelGo.IsValid() )
            _panelGo.LocalScale = PanelLocalScale;

        if ( _captureWorldPanel.IsValid() )
            _captureWorldPanel.RenderScale = PanelRenderScale;

        if ( TemplateDecal != null )
        {
            TemplateDecal.ColorTexture = DebugStaticTexture.IsValid()
                ? DebugStaticTexture
                : _renderTarget;
        }
    }

    private void BuildCaptureRig()
    {
        _renderTarget = Texture.CreateRenderTarget()
            .WithSize( TextureWidth, TextureHeight )
            .Create();

        if ( DebugLogging )
            Log.Info( $"[UIDecal] Created render target {TextureWidth}x{TextureHeight}" );

        _captureRoot = new GameObject( true, "UIDecalCaptureRig" );
        _captureRoot.Flags = GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;

        int idHash = Math.Abs( GameObject.Id.GetHashCode() );
        float offsetX = (idHash % 1000) * 50f;
        float offsetY = ((idHash / 1000) % 1000) * 50f;
        _captureRoot.WorldPosition = new Vector3( offsetX, offsetY, -8000 );

        _panelGo = new GameObject( true, "Panel" );
        _panelGo.Flags = GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
        _panelGo.SetParent( _captureRoot, false );
        _panelGo.LocalScale = PanelLocalScale;
        _panelGo.Tags.Add( CaptureTag );

        _captureWorldPanel = _panelGo.Components.Create<Sandbox.WorldPanel>();
        _captureWorldPanel.PanelSize = new Vector2( TextureWidth, TextureHeight );
        _captureWorldPanel.RenderScale = PanelRenderScale;
        _captureWorldPanel.LookAtCamera = false;

        CapturedPanel = SpawnPanel( _panelGo );

        var camGo = new GameObject( true, "Camera" );
        camGo.Flags = GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
        camGo.SetParent( _captureRoot, false );

        const float camDistance = 256f;
        camGo.LocalPosition = Vector3.Forward * camDistance;
        camGo.LocalRotation = Rotation.LookAt( -Vector3.Forward );

        _captureCamera = camGo.Components.Create<CameraComponent>();
        _captureCamera.IsMainCamera = false;
        _captureCamera.Orthographic = true;
        _captureCamera.OrthographicHeight = TextureHeight;
        _captureCamera.BackgroundColor = BackgroundColor;
        _captureCamera.ZNear = 1f;
        _captureCamera.ZFar = camDistance * 4f;
        _captureCamera.RenderTags.Add( CaptureTag );
        _captureCamera.RenderTarget = _renderTarget;
        _captureCamera.Enabled = true;
    }

    private PanelComponent SpawnPanel( GameObject host )
    {
        if ( PanelPrefab != null )
        {
            var clone = PanelPrefab.Clone();
            clone.Flags = GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
            clone.SetParent( host, false );
            clone.LocalPosition = Vector3.Zero;
            clone.LocalRotation = Rotation.Identity;
            return clone.Components.Get<PanelComponent>( FindMode.EverythingInSelfAndChildren );
        }

        if ( !string.IsNullOrWhiteSpace( PanelTypeName ) )
        {
            var typeDesc = TypeLibrary.GetType( PanelTypeName );
            if ( typeDesc == null )
            {
                Log.Warning( $"[UIDecal] Could not resolve type '{PanelTypeName}'." );
                return null;
            }

            if ( !typeof( PanelComponent ).IsAssignableFrom( typeDesc.TargetType ) )
            {
                Log.Warning( $"[UIDecal] Type '{PanelTypeName}' is not a PanelComponent." );
                return null;
            }

            return host.Components.Create( typeDesc ) as PanelComponent;
        }

        return null;
    }

    private void WireDecal()
    {
        Texture decalTexture = DebugStaticTexture.IsValid() ? DebugStaticTexture : _renderTarget;
        TemplateDecal.ColorTexture = decalTexture;
        TargetDecal.Decals = new List<DecalDefinition> { TemplateDecal };
        TargetDecal.Enabled = false;
        TargetDecal.Enabled = true;
    }
}

How It Works

The component creates a hidden capture rig far below the scene (-8000 units on Z). It spawns a WorldPanel with your chosen Razor UI, renders it with an orthographic camera to a render target texture, then assigns that texture to the Decal Definition. The decal projects the UI onto whatever geometry it touches, conforming to curved surfaces unlike a flat WorldPanel.

Tips

  • Use the red background color initially to dial in PanelLocalScale, then set it to transparent
  • Multiple UIDecal instances are offset from each other using a hash of the GameObject ID to avoid capture overlap
  • The capture rig is marked NotSaved | NotNetworked so it won't pollute your scene or replicate
Author: Lag_Spike — Check out "Nutcracker" and "Claw Colosseum" on s&box!
Was this helpful?