terminalCode Example
UIDecal Component - Render Razor UI as a Decal
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
- Put this component on a GameObject that has a Decal component
- Create an empty Decal Definition asset in the Asset Browser
- Assign both to the UIDecal component properties
- Under Panel Source, choose one of two options:
- Adjust Capture settings:
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
Was this helpful?