Search the source of every open source package.
1933 results
namespace LobbySystem;
/// <summary>
/// Auto-hosts a lobby so Steam friends can join, and keeps one networked pawn per connection plus optional
/// bots by cloning <see cref="PlayerPrefab"/>. The pawn only has to implement <see cref="ILobbyAgent"/>.
/// Spawning is de-duped and runs in OnUpdate so a join can't fire mid-enumeration.
/// </summary>
public sealed class LobbyNetworkManager : Component, Component.INetworkListener
{
[Property] public GameObject PlayerPrefab { get; set; }
[Property] public int BotCount { get; set; } = 1;
/// <summary>When true, bots only exist during an active round.</summary>
[Property] public bool BotsOnlyDuringRound { get; set; } = true;
[Property] public Color BotTint { get; set; } = new Color( 1f, 0.35f, 0.3f );
// Lobby spawn ring, used before a round map loads.
readonly Vector3[] _spawns =
{
new Vector3( 0f, -300f, 40f ), new Vector3( 300f, 0f, 40f ),
new Vector3( 0f, 300f, 40f ), new Vector3( -300f, 0f, 40f ),
new Vector3( 250f, 250f, 40f ), new Vector3( -250f, -250f, 40f ),
};
int _spawnIndex;
readonly Dictionary<Guid, GameObject> _pawns = new();
readonly List<GameObject> _bots = new();
bool _reconcileNow;
TimeUntil _nextReconcile;
TimeUntil _nextSweep;
protected override async Task OnLoad()
{
// When joining a friend the engine is mid-connect and IsActive is briefly false, so poll for a
// moment before hosting. Otherwise a joiner would spin up its own solo lobby.
if ( Networking.IsActive ) return;
for ( int i = 0; i < 6 && !Networking.IsActive; i++ )
await Task.DelayRealtimeSeconds( 0.1f );
if ( !Networking.IsActive )
Networking.CreateLobby( new() );
}
void INetworkListener.OnActive( Connection channel ) => _reconcileNow = true;
protected override void OnUpdate()
{
if ( !Networking.IsHost || PlayerPrefab is null ) return;
if ( !_reconcileNow && _nextReconcile > 0f ) return;
_reconcileNow = false;
_nextReconcile = 0.25f;
try
{
bool wantBots = !BotsOnlyDuringRound || (LobbyDirector.Current?.State == LobbyState.Active);
ReconcileBots( wantBots ? Math.Max( 0, BotCount ) : 0 );
foreach ( var conn in Connection.All.ToList() )
{
if ( conn is null || !conn.IsActive ) continue;
if ( _pawns.TryGetValue( conn.Id, out var pawn ) && pawn.IsValid() ) continue;
var id = conn.Id;
_pawns[id] = FindConnectionPawn( id ) ?? SpawnPawn( false, conn.DisplayName, conn );
}
Sweep();
}
catch
{
// Connection or scene list changed during the pass; retry next frame.
}
}
void ReconcileBots( int target )
{
_bots.RemoveAll( b => !b.IsValid() );
while ( _bots.Count > target )
{
var b = _bots[_bots.Count - 1];
_bots.RemoveAt( _bots.Count - 1 );
if ( b.IsValid() ) b.Destroy();
}
while ( _bots.Count < target )
_bots.Add( SpawnPawn( true, "Bot", null ) );
}
GameObject FindConnectionPawn( Guid id )
{
foreach ( var a in Scene.GetAllComponents<ILobbyAgent>() )
{
if ( !a.IsValid() || a.IsBot ) continue;
if ( a is Component c && c.Network.OwnerId == id ) return c.GameObject;
}
return null;
}
void Sweep()
{
if ( _nextSweep > 0f ) return;
_nextSweep = 1f;
foreach ( var key in _pawns.Where( kv => !kv.Value.IsValid() ).Select( kv => kv.Key ).ToList() )
_pawns.Remove( key );
}
GameObject SpawnPawn( bool isBot, string displayName, Connection owner )
{
var go = PlayerPrefab.Clone( NextSpawn() );
go.Name = isBot ? "Bot" : $"Player - {displayName}";
go.Enabled = true;
var agent = go.Components.Get<ILobbyAgent>() ?? go.Components.GetInChildren<ILobbyAgent>();
agent?.InitAgent( isBot, displayName );
if ( isBot )
{
var rend = go.Components.GetInChildren<SkinnedModelRenderer>();
if ( rend is not null ) rend.Tint = BotTint;
}
if ( owner is not null ) go.NetworkSpawn( owner );
else go.NetworkSpawn();
return go;
}
Vector3 NextSpawn()
{
int idx = _spawnIndex++;
var dir = LobbyDirector.Current;
if ( dir is not null && dir.UseRoundMap && dir.MapReady )
return dir.RoundSpawnPoint( idx );
return _spawns[idx % _spawns.Length];
}
}
namespace LobbySystem;
/// <summary>Lifecycle of the lobby: Lobby, then Active, then Ended before looping back.</summary>
public enum LobbyState
{
/// <summary>Free roam before and after a round. The mode button works here.</summary>
Lobby,
/// <summary>A round is running.</summary>
Active,
/// <summary>Round finished; results show before returning to the lobby.</summary>
Ended
}
namespace LobbySystem;
/// <summary>
/// In-world button that opens the mode menu for the host, or a local suggestion menu for a client. It needs
/// a ModelRenderer to be visible and is hidden while a round is live. When the local player is within
/// <see cref="UseRange"/> and presses Use, the menu opens.
/// </summary>
public sealed class LobbyModeButton : Component
{
[Property] public float UseRange { get; set; } = 130f;
[Property] public bool GlowWhenInRange { get; set; } = true;
[Property] public Color IdleTint { get; set; } = new Color( 0.85f, 0.4f, 0.15f );
[Property] public Color ActiveTint { get; set; } = new Color( 1f, 0.85f, 0.3f );
ModelRenderer _renderer;
ILobbyAgent _me;
protected override void OnStart()
{
_renderer = Components.Get<ModelRenderer>() ?? Components.GetInChildren<ModelRenderer>();
if ( _renderer is not null ) _renderer.Tint = IdleTint;
}
protected override void OnUpdate()
{
var dir = LobbyDirector.Current;
bool inLobby = dir is null || !dir.RoundLive;
if ( _renderer is not null && _renderer.Enabled != inLobby )
_renderer.Enabled = inLobby;
if ( !inLobby ) return;
var me = LocalPlayer();
bool inRange = me is not null && WorldPosition.Distance( me.WorldPosition ) <= UseRange;
if ( GlowWhenInRange && _renderer is not null )
_renderer.Tint = inRange ? ActiveTint : IdleTint;
if ( inRange && Input.Pressed( "Use" ) )
dir?.RequestModeMenu();
}
ILobbyAgent LocalPlayer()
{
if ( _me is not null && _me.IsValid() && !_me.IsBot && !_me.IsProxy ) return _me;
try { _me = Scene.GetAllComponents<ILobbyAgent>().FirstOrDefault( c => c.IsValid() && !c.IsBot && !c.IsProxy ); }
catch { _me = null; }
return _me;
}
}
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "MC Clouds" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "mcclouds" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "trend" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "trend.mcclouds" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "26" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-06-16T17:04:05.4666731Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.124.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.124.0")]using Sandbox;
using Sandbox.UI;
using System;
namespace SbTween;
public static class LightExtensions
{
public static BaseTween TweenLightColor( this Light Light, Color target, float duration )
{
Color start = Light.LightColor;
var tween = new BaseTween( duration );
tween.Target = Light.GameObject;
return TweenManager.Instance.AddTween( tween
.OnStart( () => start = Light.LightColor )
.OnUpdate( p => Light.LightColor = Color.Lerp( start, target, p ) ) );
}
public static BaseTween TweenRadius( this SpotLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.Radius;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.Radius )
.OnUpdate( p => light.Radius = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenConeOuter( this SpotLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.ConeOuter;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.ConeOuter )
.OnUpdate( p => light.ConeOuter = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenInnerCone( this SpotLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.ConeInner;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.ConeInner )
.OnUpdate( p => light.ConeInner = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenAttenuation( this PointLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.Attenuation;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.Attenuation )
.OnUpdate( p => light.Attenuation = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenRadius( this PointLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.Radius;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.Radius )
.OnUpdate( p => light.Radius = MathX.Lerp( start, target, p ) ) );
}
public static BaseTween TweenAttenuation( this SpotLight light, float target, float duration )
{
if ( !light.IsValid() ) return null;
float start = light.Attenuation;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnStart( () => start = light.Attenuation )
.OnUpdate( p => light.Attenuation = MathX.Lerp( start, target, p ) ) );
}
//FLICKERING LIGHT
public static BaseTween TweenFlickerLight( this PointLight light, float minBrightness, float maxBrightness, float duration, float speed = 10f )
{
if ( !light.IsValid() ) return null;
float time = 0f;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnUpdate( _ =>
{
time += Time.Delta * speed;
float noise = MathF.Sin( time * 1.3f ) * MathF.Sin( time * 2.7f ) * MathF.Sin( time * 0.9f );
float t = (noise + 1f) * 0.5f;
light.Attenuation = MathX.Lerp( minBrightness, maxBrightness, t );
} ) );
}
public static BaseTween TweenFlickerLight( this SpotLight light, float minBrightness, float maxBrightness, float duration, float speed = 10f )
{
if ( !light.IsValid() ) return null;
float time = 0f;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnUpdate( _ =>
{
time += Time.Delta * speed;
float noise = MathF.Sin( time * 1.3f ) * MathF.Sin( time * 2.7f ) * MathF.Sin( time * 0.9f );
float t = (noise + 1f) * 0.5f;
light.Attenuation = MathX.Lerp( minBrightness, maxBrightness, t );
} ) );
}
//FLICKERING Color
public static BaseTween TweenFlickerColor( this PointLight light, Color colorA, Color colorB, float duration, float speed = 10f )
{
if ( !light.IsValid() ) return null;
float time = 0f;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnUpdate( _ =>
{
time += Time.Delta * speed;
float noise = MathF.Sin( time * 1.3f ) * MathF.Sin( time * 2.7f ) * MathF.Sin( time * 0.9f );
float t = (noise + 1f) * 0.5f;
light.LightColor = Color.Lerp( colorA, colorB, t );
} ) );
}
public static BaseTween TweenFlickerColor( this SpotLight light, Color colorA, Color colorB, float duration, float speed = 10f )
{
if ( !light.IsValid() ) return null;
float time = 0f;
return TweenManager.Instance.AddTween( new BaseTween( duration )
.OnUpdate( _ =>
{
time += Time.Delta * speed;
float noise = MathF.Sin( time * 1.3f ) * MathF.Sin( time * 2.7f ) * MathF.Sin( time * 0.9f );
float t = (noise + 1f) * 0.5f;
light.LightColor = Color.Lerp( colorA, colorB, t );
} ) );
}
}
using Sandbox;
using System;
namespace SbTween;
public static class AudioExtensions
{
public static BaseTween TweenVolume( this SoundPointComponent sound, float targetVolume, float duration )
{
float startVolume = sound.Volume;
var tween = new BaseTween( duration );
tween.Target = sound.GameObject;
return TweenManager.Instance.AddTween( tween
.OnStart( () => startVolume = sound.Volume )
.OnUpdate( p =>
{
if ( !sound.IsValid() ) return;
sound.Volume = MathX.Lerp( startVolume, targetVolume, p );
} )
);
}
public static BaseTween TweenPitch( this SoundPointComponent sound, float targetPitch, float duration )
{
float startPitch = sound.Pitch;
var tween = new BaseTween( duration );
tween.Target = sound.GameObject;
return TweenManager.Instance.AddTween( tween
.OnStart( () => startPitch = sound.Pitch )
.OnUpdate( p =>
{
if ( !sound.IsValid() ) return;
sound.Pitch = MathX.Lerp( startPitch, targetPitch, p );
} )
);
}
}
using Sandbox;
using System;
namespace SbTween;
public static class MathTweenExtensions
{
public static BaseTween TweenInCircle( this GameObject obj, float duration, Vector3 axis, float range, float speed, bool snapping = false )
{
Vector3 centerPos = obj.WorldPosition;
var tween = new BaseTween( duration );
tween.Target = obj;
Vector3 normal = axis.Normal;
Vector3 v1 = Vector3.Cross( normal, MathF.Abs( normal.z ) < 0.9f ? Vector3.Up : Vector3.Forward ).Normal;
Vector3 v2 = Vector3.Cross( normal, v1 ).Normal;
return TweenManager.Instance.AddTween( tween
.OnUpdate( p =>
{
if ( !obj.IsValid() ) return;
float angleDegrees = p * 360f * speed;
float angleRadians = angleDegrees * (MathF.PI / 180f);
float cos = MathF.Cos( angleRadians ) * range;
float sin = MathF.Sin( angleRadians ) * range;
Vector3 rotatedOffset = (v1 * cos) + (v2 * sin);
obj.WorldPosition = centerPos + rotatedOffset;
} )
);
}
public static BaseTween TweenSpiral( this GameObject obj, float duration, Vector3 axis, float speed, float frequency )
{
Vector3 startPos = obj.WorldPosition;
var tween = new BaseTween( duration );
tween.Target = obj;
return TweenManager.Instance.AddTween( tween
.OnStart( () => startPos = obj.WorldPosition )
.OnUpdate( p =>
{
if ( !obj.IsValid() ) return;
float angle = p * MathF.PI * 2f * frequency;
float currentRadius = p * speed;
float x = MathF.Cos( angle ) * currentRadius;
float y = MathF.Sin( angle ) * currentRadius;
Vector3 axisOffset = axis * p;
Vector3 circleOffset = new Vector3( x, y, 0 );
obj.WorldPosition = startPos + axisOffset + circleOffset;
} )
);
}
public static BaseTween TweenPunchFloat( this GameObject obj, float v, float amplitude, float duration, int vibrations = 5, float elasticity = 1f, Action<float> setter = null )
{
var tween = new BaseTween( duration );
tween.Target = obj;
return TweenManager.Instance.AddTween( tween
.OnUpdate( p =>
{
if ( !obj.IsValid() ) return;
if ( p >= 1.0f )
{
setter?.Invoke( v );
return;
}
float decay = MathF.Pow( 1f - p, elasticity * 3f );
float omega = vibrations * MathF.PI * 2f;
float oscillation = MathF.Sin( p * omega );
float currentOffset = amplitude * oscillation * decay;
setter?.Invoke( v + currentOffset );
} )
.OnComplete( () => setter?.Invoke( v ) )
);
}
public static BaseTween TweenShakeFloat( this GameObject obj, float baseline, float strength, float duration, Action<float> setter = null )
{
var tween = new BaseTween( duration );
tween.Target = obj;
return TweenManager.Instance.AddTween( tween
.OnUpdate( p =>
{
if ( !obj.IsValid() ) return;
if ( p >= 1.0f )
{
setter?.Invoke( baseline );
return;
}
float currentStrength = strength * (1.0f - p);
float randomOffset = Game.Random.Float( -currentStrength, currentStrength );
setter?.Invoke( baseline + randomOffset );
} )
.OnComplete( () => setter?.Invoke( baseline ) )
);
}
}
namespace Sandbox.UiPro;
public enum HorizontalAlignment
{
Left,
Center,
Right
}
public enum VerticalAlignment
{
Top,
Center,
Bottom
}
[Title( "Text Node - UI Pro" ), Category( "UI Pro" ), Icon( "text_fields" )]
public class TextNode : NodeComponent
{
[Property, InlineEditor, Group( "Layout Settings" ), Order( -999 )] public override NodeStyle Style { get; set; } = GetDefaultStyle();
[Property, Group("Text Settings")] public HorizontalAlignment HorizontalAlignment { get; set; } = HorizontalAlignment.Center;
[Property, Group( "Text Settings" )] public VerticalAlignment VerticalAlignment { get; set; } = VerticalAlignment.Center;
[Property, InlineEditor, Group( "Text Settings" )] public TextRendering.Scope TextScope { get; set; } = TextRendering.Scope.Default;
private static NodeStyle GetDefaultStyle()
{
return new NodeStyle()
{
Anchor = NodePoint.CenterMiddle,
Pivot = NodePoint.CenterMiddle,
Offset = Vector2.Zero,
Size = new Vector2( 100, 100 ),
ChildPadding = Vector2.Zero,
ClipChildren = false,
StretchHorizontal = false,
StretchVertical = false,
CornerRadius = 0,
BorderWidth = 0,
BorderColor = Color.Black,
Texture = Texture.White,
Tint = Color.White,
UvScale = Vector2.One,
UvOffset = Vector2.Zero
};
}
protected override void UpdateStyle(float scaleFactor)
{
Style.BorderWidth = 0; // for debugging
Style.BorderColor = Color.White;
TextRendering.Scope scope = TextScope;
scope.FontSize *= scaleFactor;
Texture texture = TextRendering.GetOrCreateTexture( scope );
Style.Texture = texture;
Vector2 scale = (Layout.Outer.Size * scaleFactor) / texture.Size;
Style.UvScale = scale;
float alignX = HorizontalAlignment switch
{
HorizontalAlignment.Left => 0f,
HorizontalAlignment.Center => 0.5f,
HorizontalAlignment.Right => 1f,
_ => 0.5f,
};
float alignY = VerticalAlignment switch
{
VerticalAlignment.Top => 0f,
VerticalAlignment.Center => 0.5f,
VerticalAlignment.Bottom => 1f,
_ => 0.5f,
};
float offsetX = alignX * (1f / scale.x - 1f);
float offsetY = alignY * (1f / scale.y - 1f);
Style.UvOffset = new Vector2( offsetX, offsetY );
}
}
using Sandbox.UiPro;
namespace Sandbox;
// Hooks up the Button's OnClick event and responds
// by updating the TextNode
public class ExampleButtonController : Component
{
[Property] public Button MyButton { get; set; }
[Property] public TextNode MyText { get; set; }
[Property, ReadOnly] public int ClickCount { get; set; } = 0;
protected override void OnStart()
{
if ( !MyButton.IsValid() ) return;
MyButton.OnClick = OnButtonClicked;
}
private void OnButtonClicked()
{
ClickCount++;
if ( !MyText.IsValid() ) return;
TextRendering.Scope scope = MyText.TextScope;
scope.Text = $"Clicked {ClickCount} Times";
MyText.TextScope = scope;
}
}
using System;
namespace Sandbox.UiPro;
public enum NodePoint
{
TopLeft, TopMiddle, TopRight,
CenterLeft, CenterMiddle, CenterRight,
BottomLeft, BottomMiddle, BottomRight,
}
public class NodeStyle
{
[Property] public NodePoint Anchor { get; set; }
[Property] public NodePoint Pivot { get; set; }
[Property] public Vector2 Offset { get; set; }
[Property] public Vector2 Size { get; set; }
[Property] public Vector4 ChildPadding { get; set; }
[Property] public bool ClipChildren { get; set; }
[Property] public bool StretchHorizontal { get; set; }
[Property] public bool StretchVertical { get; set; }
[Property, Hide] public float CornerRadius { get; set; }
[Property, Hide] public float BorderWidth { get; set; }
[Property, Hide] public Color BorderColor { get; set; }
[Property, Hide] public Texture Texture { get; set; }
[Property, Hide] public Color Tint { get; set; }
[Property, Hide] public Vector2 UvScale { get; set; }
[Property, Hide] public Vector2 UvOffset { get; set; }
}
public class NodeLayout
{
public Rect Outer { get; private set; }
public Rect Inner { get; private set; }
public Rect ClipRect { get; private set; }
public float ClipRadius { get; private set; }
public Rect ChildClipRect { get; private set; }
public float ChildClipRadius { get; private set; }
public static NodeLayout GetRootLayout( Vector2 size )
{
Rect rootRect = new Rect( Vector2.Zero, size );
NodeLayout layout = new NodeLayout()
{
Outer = rootRect,
Inner = rootRect,
ClipRect = rootRect,
ClipRadius = 0,
ChildClipRect = rootRect,
ChildClipRadius = 0
};
return layout;
}
public void Compute( NodeLayout parentLayout, NodeStyle style )
{
Vector2 anchor = AnchorFraction( style.Anchor );
Vector2 pivot = AnchorFraction( style.Pivot );
float width = style.StretchHorizontal ? parentLayout.Inner.Size.x : style.Size.x;
float height = style.StretchVertical ? parentLayout.Inner.Size.y : style.Size.y;
float x = style.StretchHorizontal
? parentLayout.Inner.Position.x + style.Offset.x
: parentLayout.Inner.Position.x + anchor.x * parentLayout.Inner.Size.x - pivot.x * width + style.Offset.x;
float y = style.StretchVertical
? parentLayout.Inner.Position.y + style.Offset.y
: parentLayout.Inner.Position.y + anchor.y * parentLayout.Inner.Size.y - pivot.y * height + style.Offset.y;
Outer = new Rect( new Vector2( x, y ), new Vector2( width, height ) );
float innerW = Math.Max( 0f, width - style.ChildPadding.x - style.ChildPadding.z );
float innerH = Math.Max( 0f, height - style.ChildPadding.y - style.ChildPadding.w );
Inner = new Rect( new Vector2( x + style.ChildPadding.x, y + style.ChildPadding.y ), new Vector2( innerW, innerH ) );
ClipRect = parentLayout.ChildClipRect;
ClipRadius = parentLayout.ChildClipRadius;
if ( style.ClipChildren )
{
float bw = Math.Max( 0f, style.BorderWidth );
float clipW = Math.Max( 0f, Outer.Size.x - bw * 2f );
float clipH = Math.Max( 0f, Outer.Size.y - bw * 2f );
ChildClipRect = new Rect( new Vector2( Outer.Position.x + bw, Outer.Position.y + bw ), new Vector2( clipW, clipH ) );
ChildClipRadius = Math.Max( 0f, style.CornerRadius - bw );
}
else
{
ChildClipRect = parentLayout.ChildClipRect;
ChildClipRadius = parentLayout.ChildClipRadius;
}
}
private static Vector2 AnchorFraction( NodePoint point )
{
float fx = point switch
{
NodePoint.TopLeft or NodePoint.CenterLeft or NodePoint.BottomLeft => 0f,
NodePoint.TopMiddle or NodePoint.CenterMiddle or NodePoint.BottomMiddle => 0.5f,
_ => 1f,
};
float fy = point switch
{
NodePoint.TopLeft or NodePoint.TopMiddle or NodePoint.TopRight => 0f,
NodePoint.CenterLeft or NodePoint.CenterMiddle or NodePoint.CenterRight => 0.5f,
_ => 1f,
};
return new Vector2( fx, fy );
}
}
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Dreams.UltimateLightManager;
[Library( "UltimateLightManager" )]
[Title( "Ultimate Light Manager" )]
[Description( "Advanced light component with presets, runtime controls, grouping, and an integrated S&box editor workflow." )]
[Category( "Light" )]
[Icon( "tungsten" )]
public class UltimateLightManager : Component, Component.ExecuteInEditor
{
public enum LightTypeEnum
{
Point,
Spot
}
public enum LightPreset
{
Custom,
Candle,
Torch,
Neon,
Alarm,
BrokenLamp,
SciFi,
StreetLight
}
public static void SetGroupState( string groupName, bool isEnabled )
{
foreach ( var light in GetLightsInGroup( groupName ) )
{
light.SetEnabledState( isEnabled );
}
}
public static void SetGroupBrightness( string groupName, float brightness )
{
brightness = Math.Max( brightness, 0f );
foreach ( var light in GetLightsInGroup( groupName ) )
{
light.SetBrightnessLevel( brightness );
}
}
public static void SetGroupColor( string groupName, Color color )
{
foreach ( var light in GetLightsInGroup( groupName ) )
{
light.SetLightColorValue( color );
}
}
public static void ApplyPresetToGroup( string groupName, LightPreset preset )
{
foreach ( var light in GetLightsInGroup( groupName ) )
{
light.ApplyPreset( preset );
}
}
public static void TriggerGroupFlash( string groupName, float duration = 0.15f, float brightnessMultiplier = 2f )
{
foreach ( var light in GetLightsInGroup( groupName ) )
{
light.TriggerFlash( duration, brightnessMultiplier );
}
}
public static void TriggerGroupAlarm( string groupName, float duration = 2f )
{
foreach ( var light in GetLightsInGroup( groupName ) )
{
light.TriggerAlarm( duration );
}
}
public static void SetPowerGridState( string powerGridTag, bool isPowered )
{
foreach ( var light in GetLightsInPowerGrid( powerGridTag ) )
{
light.SetPowered( isPowered );
}
}
private static IEnumerable<UltimateLightManager> GetLightsInGroup( string groupName )
{
return EnumerateAllLights().Where( light => string.Equals( light.LightGroup, groupName, StringComparison.OrdinalIgnoreCase ) );
}
private static IEnumerable<UltimateLightManager> GetLightsInPowerGrid( string powerGridTag )
{
return EnumerateAllLights().Where( light => string.Equals( light.PowerGridTag, powerGridTag, StringComparison.OrdinalIgnoreCase ) );
}
private static IEnumerable<UltimateLightManager> EnumerateAllLights()
{
var visitedScenes = new HashSet<Scene>();
var visitedLights = new HashSet<UltimateLightManager>();
foreach ( var scene in EnumerateCandidateScenes() )
{
if ( scene == null || !visitedScenes.Add( scene ) )
{
continue;
}
foreach ( var light in scene.GetAllComponents<UltimateLightManager>() )
{
if ( light != null && visitedLights.Add( light ) )
{
yield return light;
}
}
}
}
private static IEnumerable<Scene> EnumerateCandidateScenes()
{
if ( Game.ActiveScene != null )
{
yield return Game.ActiveScene;
}
foreach ( var scene in Scene.All )
{
if ( scene != null )
{
yield return scene;
}
}
}
[Property, Order( -1 ), Group( "Management" )]
public string LightGroup { get; set; } = "Default";
[Property, Group( "Management" )]
public string PowerGridTag { get; set; } = string.Empty;
[Property, Group( "Management" )]
public float StartDelay { get; set; } = 0.0f;
[Property, Group( "Management" )]
public bool AutoDesync { get; set; } = true;
[Property, Group( "Management" )]
public bool ShowDebugGizmos { get; set; } = false;
[Property, Group( "Management" )]
public bool ForceNetworkObjectMode { get; set; } = true;
[Property, Group( "General" ), Sync( SyncFlags.FromHost )]
public LightTypeEnum TargetLightType { get; set; } = LightTypeEnum.Point;
[Property, Group( "General" ), Sync( SyncFlags.FromHost )]
public bool IsEnabled { get; set; } = true;
[Property, Group( "General" ), Sync( SyncFlags.FromHost )]
public Color LightColor { get; set; } = Color.White;
[Property, Group( "General" ), Range( 0, 100 ), Sync( SyncFlags.FromHost | SyncFlags.Interpolate )]
public float Brightness { get; set; } = 1.0f;
[Property, Group( "General" ), Range( 0, 10 )]
public float VolumetricBoost { get; set; } = 1.0f;
[Property, Group( "General" )]
public bool CastShadows { get; set; } = true;
[Property, Group( "Presets" ), Sync( SyncFlags.FromHost )]
public LightPreset SelectedPreset { get; set; } = LightPreset.Custom;
[Property, Group( "Presets" ), Sync( SyncFlags.FromHost )]
public bool AutoApplyPreset { get; set; } = true;
[Property, Group( "Transitions" )]
public bool EnableFade { get; set; } = false;
[Property, Group( "Transitions" )]
public float FadeInDuration { get; set; } = 0.2f;
[Property, Group( "Transitions" )]
public float FadeOutDuration { get; set; } = 0.2f;
[Property, Group( "Audio" )]
public SoundEvent AmbientSound { get; set; }
[Property, Group( "Audio" )]
public SoundEvent ToggleOnSound { get; set; }
[Property, Group( "Audio" )]
public SoundEvent ToggleOffSound { get; set; }
[Property, Group( "Audio" )]
public bool ModulateVolumeWithLight { get; set; } = true;
[Property, Group( "Audio" )]
public bool ModulatePitchWithLight { get; set; } = false;
[Property, Group( "Audio" ), Range( 0, 5 )]
public float BaseVolume { get; set; } = 1.0f;
[Property, Group( "Audio" ), Range( 0.5f, 2f )]
public float MinPitch { get; set; } = 0.9f;
[Property, Group( "Audio" ), Range( 0.5f, 2f )]
public float MaxPitch { get; set; } = 1.1f;
[Property, Group( "Optimization" )]
public float MaxDistance { get; set; } = 2500.0f;
[Property, Group( "Optimization" )]
public float ShadowMaxDistance { get; set; } = 800.0f;
[Property, Group( "Optimization" )]
public bool EnableCulling { get; set; } = true;
[Property, Group( "Optimization" )]
public bool EnableAdaptiveUpdates { get; set; } = false;
[Property, Group( "Optimization" ), Range( 1, 120 )]
public float NearUpdateRate { get; set; } = 60.0f;
[Property, Group( "Optimization" ), Range( 1, 120 )]
public float FarUpdateRate { get; set; } = 12.0f;
[Property, Group( "Gameplay" )]
public float DefaultAlarmDuration { get; set; } = 2.0f;
[Property, Group( "Gameplay" ), Sync( SyncFlags.FromHost )]
public Color AlarmColor { get; set; } = new Color( 1.0f, 0.15f, 0.1f );
[Property, Group( "Gameplay" )]
public float AlarmStrobeSpeed { get; set; } = 8.0f;
[Property, Group( "Gameplay" )]
public float AlarmBrightnessMultiplier { get; set; } = 1.5f;
[Property, FeatureEnabled( "Fire & Candle" )]
public bool EnableFire { get; set; } = false;
[Property, Feature( "Fire & Candle" )]
public float FireSpeed { get; set; } = 12.0f;
[Property, Feature( "Fire & Candle" ), Range( 0, 1 )]
public float FireIntensity { get; set; } = 0.3f;
[Property, Feature( "Fire & Candle" ), Range( 0, 2 )]
public float FireChaos { get; set; } = 1.0f;
[Property, FeatureEnabled( "Horror Mode" )]
public bool EnableHorror { get; set; } = false;
[Property, Feature( "Horror Mode" )]
public float MinFlickerDelay { get; set; } = 0.05f;
[Property, Feature( "Horror Mode" )]
public float MaxFlickerDelay { get; set; } = 0.4f;
[Property, Feature( "Horror Mode" ), Range( 0, 1 )]
public float DamageSeverity { get; set; } = 0.8f;
[Property, Feature( "Horror Mode" )]
public SoundEvent SparkSound { get; set; }
[Property, FeatureEnabled( "Disco Mode" )]
public bool EnableDisco { get; set; } = false;
[Property, Feature( "Disco Mode" )]
public float DiscoSpeed { get; set; } = 20.0f;
[Property, Feature( "Disco Mode" ), Range( 0, 1 )]
public float DiscoSaturation { get; set; } = 1.0f;
[Property, Feature( "Disco Mode" ), Range( 0, 1 )]
public float DiscoValue { get; set; } = 1.0f;
[Property, FeatureEnabled( "Color Transition" )]
public bool EnableColorTransition { get; set; } = false;
[Property, Feature( "Color Transition" ), Sync( SyncFlags.FromHost )]
public Color SecondaryColor { get; set; } = new Color( 0.2f, 0.85f, 1.0f );
[Property, Feature( "Color Transition" )]
public float ColorTransitionSpeed { get; set; } = 1.0f;
[Property, FeatureEnabled( "Proximity Sensor" )]
public bool EnableSensor { get; set; } = false;
[Property, Feature( "Proximity Sensor" )]
public float SensorRange { get; set; } = 300.0f;
[Property, Feature( "Proximity Sensor" ), Range( 0, 1 )]
public float SensorMinBrightness { get; set; } = 0.0f;
[Property, Feature( "Proximity Sensor" ), Range( 0, 1 )]
public float SensorMaxBrightness { get; set; } = 1.0f;
[Property, Feature( "Proximity Sensor" ), Range( 1, 20 )]
public float SensorSmoothness { get; set; } = 5.0f;
[Property, Feature( "Proximity Sensor" )]
public bool InvertSensor { get; set; } = false;
[Property, FeatureEnabled( "Motion Sway" )]
public bool EnableSway { get; set; } = false;
[Property, Feature( "Motion Sway" )]
public float SwaySpeedPitch { get; set; } = 1.0f;
[Property, Feature( "Motion Sway" )]
public float SwayAmountPitch { get; set; } = 5.0f;
[Property, Feature( "Motion Sway" )]
public float SwaySpeedRoll { get; set; } = 0.7f;
[Property, Feature( "Motion Sway" )]
public float SwayAmountRoll { get; set; } = 3.0f;
[Property, FeatureEnabled( "Flicker Pattern" )]
public bool EnablePattern { get; set; } = false;
[Property, Feature( "Flicker Pattern" )]
public string Pattern { get; set; } = "mmnmmommommnonmmonqnmmo";
[Property, Feature( "Flicker Pattern" )]
public float PatternSpeed { get; set; } = 10.0f;
[Property, FeatureEnabled( "Pulse" )]
public bool EnablePulse { get; set; } = false;
[Property, Feature( "Pulse" )]
public float PulseSpeed { get; set; } = 1.0f;
[Property, Feature( "Pulse" ), Range( 0, 1 )]
public float PulseMin { get; set; } = 0.2f;
[Property, FeatureEnabled( "Strobe" )]
public bool EnableStrobe { get; set; } = false;
[Property, Feature( "Strobe" )]
public float StrobeSpeed { get; set; } = 10.0f;
[Property, Feature( "Strobe" ), Range( 0.1f, 0.9f )]
public float StrobeDutyCycle { get; set; } = 0.5f;
[Property, FeatureEnabled( "Kelvin" )]
public bool EnableKelvin { get; set; } = false;
[Property, Feature( "Kelvin" ), Range( 1000, 12000 )]
public int KelvinTemperature { get; set; } = 4500;
[Property, FeatureEnabled( "Power Surge" )]
public bool EnablePowerSurge { get; set; } = false;
[Property, Feature( "Power Surge" )]
public float SurgeMinInterval { get; set; } = 4.0f;
[Property, Feature( "Power Surge" )]
public float SurgeMaxInterval { get; set; } = 10.0f;
[Property, Feature( "Power Surge" )]
public float SurgeDuration { get; set; } = 0.15f;
[Property, Feature( "Power Surge" )]
public float SurgeBrightnessMultiplier { get; set; } = 1.8f;
public bool Powered => PoweredState;
public float ExternalBrightnessMultiplier => ExternalBrightnessMultiplierState;
public bool HasColorOverride => HasExternalColorOverrideState;
public bool AlarmActive => AlarmEndTimeState > RealTime.Now;
private PointLight _pointLight;
private SpotLight _spotLight;
private Light ActiveLight => TargetLightType == LightTypeEnum.Point ? (Light)_pointLight : _spotLight;
private int _lastKelvin = -1;
private Color _cachedKelvinColor = Color.White;
private Rotation _baseRotation;
private bool _isInitialized;
private bool _hasAppliedPreset;
private LightPreset _lastAppliedPreset = LightPreset.Custom;
private LightPreset _lastObservedPreset = LightPreset.Custom;
private bool _lastObservedAutoApplyPreset = true;
private LightTypeEnum _lastSyncedLightType = LightTypeEnum.Point;
private float _brokenMultiplier = 1.0f;
private float _nextFlicker;
private float _sensorWeightTarget = 1.0f;
private float _sensorWeightCurrent = 1.0f;
private float _randomTimeOffset;
private float _creationTime;
private float _lastUpdateTimestamp;
private float _enabledBlend = 1.0f;
private float _nextAdaptiveUpdateTime;
private float _nextViewerCameraRefreshTime;
private float _nextSurgeTime;
private float _surgeEndTime;
private bool _hasOutputState;
private bool _lastOutputEnabled;
private CameraComponent _cachedViewerCamera;
private SoundHandle _ambientSoundHandle;
[Sync( SyncFlags.FromHost )]
private bool PoweredState { get; set; } = true;
[Sync( SyncFlags.FromHost | SyncFlags.Interpolate )]
private float ExternalBrightnessMultiplierState { get; set; } = 1.0f;
[Sync( SyncFlags.FromHost )]
private bool HasExternalColorOverrideState { get; set; }
[Sync( SyncFlags.FromHost )]
private Color ExternalColorOverrideState { get; set; } = Color.White;
[Sync( SyncFlags.FromHost )]
private float FlashEndTimeState { get; set; }
[Sync( SyncFlags.FromHost )]
private float FlashBrightnessMultiplierState { get; set; } = 1.0f;
[Sync( SyncFlags.FromHost )]
private float AlarmEndTimeState { get; set; }
protected override void OnAwake()
{
EnsureNetworkMode();
}
protected override void OnStart()
{
EnsureNetworkMode();
_baseRotation = LocalRotation;
_creationTime = RealTime.Now;
_lastUpdateTimestamp = _creationTime;
_enabledBlend = IsEnabled ? 1.0f : 0.0f;
if ( AutoDesync )
{
_randomTimeOffset = Game.Random.Float( 0f, 100f );
}
SyncComponentsIfNeeded( force: true );
if ( AutoApplyPreset )
{
ApplyPresetInternal( SelectedPreset );
}
ScheduleNextSurge( _creationTime );
_lastObservedPreset = SelectedPreset;
_lastObservedAutoApplyPreset = AutoApplyPreset;
_isInitialized = true;
}
protected override void OnUpdate()
{
if ( !_isInitialized )
{
return;
}
SyncComponentsIfNeeded();
SyncPresetSelectionIfNeeded();
var light = ActiveLight;
if ( light == null )
{
return;
}
bool isPlaying = Game.IsPlaying;
float absoluteTime = RealTime.Now;
float effectTime = (isPlaying ? Time.Now : absoluteTime) + _randomTimeOffset;
float deltaTime = GetFrameDelta( absoluteTime );
if ( isPlaying && StartDelay > 0f && (absoluteTime - _creationTime) < StartDelay )
{
DisableOutput( light );
return;
}
var viewerCam = GetViewerCamera( absoluteTime );
float distSq = viewerCam != null ? WorldPosition.DistanceSquared( viewerCam.WorldPosition ) : 0f;
if ( ShouldSkipAdaptiveUpdate( isPlaying, viewerCam, distSq, absoluteTime ) )
{
UpdateAmbientSoundPosition();
return;
}
UpdateSensorWeight( viewerCam, distSq, deltaTime );
UpdateEnabledBlend( deltaTime );
UpdateSway( effectTime );
if ( isPlaying && EnableCulling && viewerCam != null && distSq > MaxDistance * MaxDistance )
{
DisableOutput( light );
return;
}
float fx = 1.0f;
fx *= EvaluatePulseAndStrobe( effectTime );
fx *= EvaluatePattern( effectTime );
fx *= EvaluateFire( effectTime );
fx *= EvaluateHorror( effectTime, isPlaying );
fx *= EvaluatePowerSurge( absoluteTime );
fx *= EvaluateAlarm( effectTime, absoluteTime );
if ( absoluteTime < FlashEndTimeState )
{
fx *= FlashBrightnessMultiplierState;
}
float finalBrightness = Brightness * fx * _sensorWeightCurrent * _enabledBlend * ExternalBrightnessMultiplierState;
finalBrightness = Math.Max( finalBrightness, 0f );
bool shouldBeEnabled = finalBrightness > 0.001f;
light.Enabled = shouldBeEnabled;
Color resolvedColor = ResolveLightColor( effectTime, absoluteTime );
light.LightColor = resolvedColor * finalBrightness;
light.Shadows = CastShadows && ( !isPlaying || distSq < ShadowMaxDistance * ShadowMaxDistance );
light.FogStrength = VolumetricBoost;
UpdateOutputState( shouldBeEnabled, true );
ManageAudio( shouldBeEnabled, finalBrightness / Math.Max( Brightness, 0.01f ) );
}
public void TurnOn()
{
SetEnabledState( true );
}
[Button]
public void ApplySelectedPreset()
{
ApplyPreset( SelectedPreset );
}
public void TurnOff()
{
SetEnabledState( false );
}
public void Toggle()
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
IsEnabled = !IsEnabled;
}
public void SetPowered( bool powered )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
PoweredState = powered;
}
public void SetExternalBrightness( float multiplier )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
ExternalBrightnessMultiplierState = Math.Max( multiplier, 0f );
}
public void ResetExternalBrightness()
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
ExternalBrightnessMultiplierState = 1.0f;
}
public void SetColorOverride( Color color )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
HasExternalColorOverrideState = true;
ExternalColorOverrideState = color;
}
public void ClearColorOverride()
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
HasExternalColorOverrideState = false;
ExternalColorOverrideState = Color.White;
}
public void TriggerFlash( float duration = 0.15f, float brightnessMultiplier = 2f )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
FlashEndTimeState = RealTime.Now + Math.Max( duration, 0.01f );
FlashBrightnessMultiplierState = Math.Max( brightnessMultiplier, 1.0f );
}
public void TriggerAlarm( float duration = -1f )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
if ( duration <= 0f )
{
duration = DefaultAlarmDuration;
}
AlarmEndTimeState = Math.Max( AlarmEndTimeState, RealTime.Now + duration );
}
public void ClearAlarm()
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
AlarmEndTimeState = 0f;
}
[Button]
public void PreviewFlash()
{
TriggerFlash();
}
[Button]
public void PreviewAlarm()
{
TriggerAlarm();
}
[Button]
public void ResetRuntimeOverrides()
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
ClearAlarm();
ClearColorOverride();
ResetExternalBrightness();
SetPowered( true );
}
public void ApplyPreset( LightPreset preset )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
ApplyPresetInternal( preset );
}
public void SetEnabledState( bool isEnabled )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
IsEnabled = isEnabled;
}
public void SetBrightnessLevel( float brightness )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
Brightness = Math.Max( brightness, 0f );
}
public void SetLightColorValue( Color color )
{
if ( ShouldIgnoreNetworkMutation() )
{
return;
}
LightColor = color;
}
private void ApplyPresetInternal( LightPreset preset )
{
SelectedPreset = preset;
ResetPresetControlledFeatures();
switch ( preset )
{
case LightPreset.Candle:
Brightness = 0.75f;
LightColor = new Color( 1.0f, 0.76f, 0.5f );
SecondaryColor = new Color( 1.0f, 0.66f, 0.35f );
EnableKelvin = true;
KelvinTemperature = 1800;
EnableFire = true;
FireSpeed = 10.0f;
FireIntensity = 0.18f;
FireChaos = 0.6f;
VolumetricBoost = 0.6f;
break;
case LightPreset.Torch:
Brightness = 1.35f;
LightColor = new Color( 1.0f, 0.72f, 0.38f );
SecondaryColor = new Color( 1.0f, 0.45f, 0.2f );
EnableKelvin = true;
KelvinTemperature = 2200;
EnableFire = true;
FireSpeed = 13.0f;
FireIntensity = 0.28f;
FireChaos = 1.0f;
VolumetricBoost = 1.4f;
break;
case LightPreset.Neon:
Brightness = 1.15f;
LightColor = new Color( 0.2f, 0.95f, 1.0f );
SecondaryColor = new Color( 1.0f, 0.2f, 0.85f );
EnableColorTransition = true;
ColorTransitionSpeed = 0.65f;
EnablePulse = true;
PulseSpeed = 1.2f;
PulseMin = 0.75f;
CastShadows = false;
VolumetricBoost = 0.25f;
break;
case LightPreset.Alarm:
Brightness = 2.0f;
LightColor = new Color( 1.0f, 0.18f, 0.12f );
AlarmColor = LightColor;
EnableStrobe = true;
StrobeSpeed = 7.0f;
StrobeDutyCycle = 0.45f;
CastShadows = false;
VolumetricBoost = 1.1f;
break;
case LightPreset.BrokenLamp:
Brightness = 1.0f;
LightColor = new Color( 1.0f, 0.93f, 0.82f );
EnableHorror = true;
MinFlickerDelay = 0.04f;
MaxFlickerDelay = 0.25f;
DamageSeverity = 0.85f;
EnableKelvin = true;
KelvinTemperature = 3400;
break;
case LightPreset.SciFi:
Brightness = 1.6f;
LightColor = new Color( 0.35f, 0.78f, 1.0f );
SecondaryColor = new Color( 0.1f, 1.0f, 0.8f );
EnableColorTransition = true;
ColorTransitionSpeed = 1.15f;
EnablePulse = true;
PulseSpeed = 0.85f;
PulseMin = 0.55f;
VolumetricBoost = 2.0f;
break;
case LightPreset.StreetLight:
Brightness = 1.4f;
LightColor = new Color( 1.0f, 0.84f, 0.68f );
EnableKelvin = true;
KelvinTemperature = 3500;
MaxDistance = 4500.0f;
ShadowMaxDistance = 1200.0f;
VolumetricBoost = 0.55f;
break;
case LightPreset.Custom:
default:
break;
}
_lastAppliedPreset = preset;
_hasAppliedPreset = true;
}
private void ResetPresetControlledFeatures()
{
EnableFire = false;
EnableHorror = false;
EnableDisco = false;
EnableColorTransition = false;
EnablePulse = false;
EnableStrobe = false;
EnableKelvin = false;
}
private void SyncPresetSelectionIfNeeded()
{
bool autoApplyChanged = AutoApplyPreset != _lastObservedAutoApplyPreset;
bool presetChanged = SelectedPreset != _lastObservedPreset;
_lastObservedAutoApplyPreset = AutoApplyPreset;
_lastObservedPreset = SelectedPreset;
if ( !AutoApplyPreset )
{
return;
}
if ( autoApplyChanged || presetChanged || !_hasAppliedPreset || SelectedPreset != _lastAppliedPreset )
{
ApplyPresetInternal( SelectedPreset );
}
}
private void SyncComponentsIfNeeded( bool force = false )
{
bool missingActiveLight = TargetLightType == LightTypeEnum.Point ? _pointLight == null : _spotLight == null;
if ( !force && !missingActiveLight && TargetLightType == _lastSyncedLightType )
{
return;
}
Component createdComponent = null;
if ( TargetLightType == LightTypeEnum.Point )
{
if ( _pointLight == null )
{
_pointLight = Components.GetOrCreate<PointLight>();
createdComponent = _pointLight;
}
if ( _spotLight != null && _spotLight.Enabled )
{
_spotLight.Enabled = false;
}
}
else
{
if ( _spotLight == null )
{
_spotLight = Components.GetOrCreate<SpotLight>();
createdComponent = _spotLight;
}
if ( _pointLight != null && _pointLight.Enabled )
{
_pointLight.Enabled = false;
}
}
_lastSyncedLightType = TargetLightType;
if ( createdComponent != null && Game.IsPlaying && GameObject.NetworkMode == NetworkMode.Object )
{
GameObject.Network.Refresh( createdComponent );
}
}
private CameraComponent GetViewerCamera( float absoluteTime )
{
var sceneCamera = Scene.Camera;
if ( sceneCamera != null )
{
_cachedViewerCamera = sceneCamera;
_nextViewerCameraRefreshTime = absoluteTime + 0.25f;
return sceneCamera;
}
if ( _cachedViewerCamera != null && absoluteTime < _nextViewerCameraRefreshTime )
{
return _cachedViewerCamera;
}
_nextViewerCameraRefreshTime = absoluteTime + 0.25f;
_cachedViewerCamera = Scene.GetAllComponents<CameraComponent>().FirstOrDefault( camera => camera != null && camera.Enabled );
return _cachedViewerCamera;
}
private void EnsureNetworkMode()
{
if ( !ForceNetworkObjectMode || !Game.IsPlaying || GameObject.NetworkMode == NetworkMode.Object )
{
return;
}
GameObject.NetworkMode = NetworkMode.Object;
}
private bool ShouldIgnoreNetworkMutation()
{
return Game.IsPlaying && IsProxy;
}
private float GetFrameDelta( float absoluteTime )
{
float delta = Math.Clamp( absoluteTime - _lastUpdateTimestamp, 0.0001f, 0.25f );
_lastUpdateTimestamp = absoluteTime;
return delta;
}
private bool ShouldSkipAdaptiveUpdate( bool isPlaying, CameraComponent viewerCam, float distSq, float absoluteTime )
{
if ( !EnableAdaptiveUpdates || !isPlaying || viewerCam == null )
{
return false;
}
if ( absoluteTime < _nextAdaptiveUpdateTime )
{
return true;
}
float maxDistanceSq = Math.Max( MaxDistance * MaxDistance, 1f );
float distRatio = Math.Clamp( distSq / maxDistanceSq, 0f, 1f );
float nearInterval = 1f / Math.Max( NearUpdateRate, 1f );
float farInterval = 1f / Math.Max( FarUpdateRate, 1f );
_nextAdaptiveUpdateTime = absoluteTime + MathX.Lerp( nearInterval, farInterval, distRatio );
return false;
}
private void UpdateSensorWeight( CameraComponent viewerCam, float distSq, float deltaTime )
{
if ( EnableSensor && viewerCam != null && SensorRange > 0.01f )
{
float distRatio = Math.Clamp( 1.0f - (MathF.Sqrt( distSq ) / SensorRange), 0f, 1f );
float rawWeight = InvertSensor ? 1.0f - distRatio : distRatio;
_sensorWeightTarget = MathX.Lerp( SensorMinBrightness, SensorMaxBrightness, rawWeight );
}
else
{
_sensorWeightTarget = 1.0f;
}
_sensorWeightCurrent = MathX.Lerp( _sensorWeightCurrent, _sensorWeightTarget, Math.Clamp( deltaTime * SensorSmoothness, 0f, 1f ) );
}
private void UpdateEnabledBlend( float deltaTime )
{
float target = IsEnabled && PoweredState ? 1.0f : 0.0f;
if ( !EnableFade )
{
_enabledBlend = target;
return;
}
float duration = target > _enabledBlend ? Math.Max( FadeInDuration, 0.0001f ) : Math.Max( FadeOutDuration, 0.0001f );
float lerp = Math.Clamp( deltaTime / duration, 0f, 1f );
_enabledBlend = MathX.Lerp( _enabledBlend, target, lerp );
if ( Math.Abs( _enabledBlend - target ) < 0.001f )
{
_enabledBlend = target;
}
}
private void UpdateSway( float effectTime )
{
if ( EnableSway )
{
float pitch = MathF.Sin( effectTime * SwaySpeedPitch ) * SwayAmountPitch;
float roll = MathF.Cos( effectTime * SwaySpeedRoll ) * SwayAmountRoll;
LocalRotation = _baseRotation * Rotation.From( pitch, 0f, roll );
return;
}
LocalRotation = _baseRotation;
}
private float EvaluatePulseAndStrobe( float effectTime )
{
if ( EnableStrobe )
{
float cycle = (effectTime * StrobeSpeed) % 1.0f;
return cycle < StrobeDutyCycle ? 1.0f : 0.0f;
}
if ( EnablePulse )
{
float sine = (MathF.Sin( effectTime * PulseSpeed * 2.0f ) + 1.0f) * 0.5f;
return MathX.Lerp( PulseMin, 1.0f, sine );
}
return 1.0f;
}
private float EvaluatePattern( float effectTime )
{
if ( !EnablePattern || string.IsNullOrWhiteSpace( Pattern ) )
{
return 1.0f;
}
int index = (int)(effectTime * PatternSpeed) % Pattern.Length;
float value = Math.Max( 0, (char.ToLower( Pattern[index] ) - 'a') / 12.0f );
return value;
}
private float EvaluateFire( float effectTime )
{
if ( !EnableFire )
{
return 1.0f;
}
float noise = MathF.Sin( effectTime * FireSpeed ) + MathF.Sin( effectTime * FireSpeed * 0.5f );
if ( FireChaos > 0f )
{
noise += MathF.Sin( effectTime * FireSpeed * 1.5f ) * FireChaos;
}
return 1.0f - (noise * 0.15f * FireIntensity);
}
private float EvaluateHorror( float effectTime, bool isPlaying )
{
if ( !EnableHorror )
{
return 1.0f;
}
if ( effectTime > _nextFlicker )
{
bool isDamaged = Game.Random.Float( 0f, 1f ) < DamageSeverity;
_brokenMultiplier = isDamaged ? Game.Random.Float( 0.0f, 0.4f ) : 1.0f;
_nextFlicker = effectTime + Game.Random.Float( MinFlickerDelay, MaxFlickerDelay );
if ( isPlaying && isDamaged && SparkSound != null && _brokenMultiplier < 0.2f )
{
Sound.Play( SparkSound, WorldPosition );
}
}
return _brokenMultiplier;
}
private float EvaluatePowerSurge( float absoluteTime )
{
if ( !EnablePowerSurge )
{
return 1.0f;
}
if ( _nextSurgeTime <= 0f )
{
ScheduleNextSurge( absoluteTime );
}
if ( absoluteTime >= _nextSurgeTime )
{
_surgeEndTime = absoluteTime + Math.Max( SurgeDuration, 0.01f );
ScheduleNextSurge( _surgeEndTime );
}
return absoluteTime < _surgeEndTime ? Math.Max( SurgeBrightnessMultiplier, 1.0f ) : 1.0f;
}
private float EvaluateAlarm( float effectTime, float absoluteTime )
{
if ( absoluteTime >= AlarmEndTimeState )
{
return 1.0f;
}
float cycle = (effectTime * AlarmStrobeSpeed) % 1.0f;
float gate = cycle < 0.5f ? 1.0f : 0.15f;
return gate * Math.Max( AlarmBrightnessMultiplier, 0f );
}
private void ScheduleNextSurge( float absoluteTime )
{
float minInterval = Math.Min( SurgeMinInterval, SurgeMaxInterval );
float maxInterval = Math.Max( SurgeMinInterval, SurgeMaxInterval );
_nextSurgeTime = absoluteTime + Game.Random.Float( Math.Max( minInterval, 0.01f ), Math.Max( maxInterval, 0.01f ) );
}
private Color ResolveLightColor( float effectTime, float absoluteTime )
{
Color color = LightColor;
if ( EnableKelvin )
{
if ( KelvinTemperature != _lastKelvin )
{
_cachedKelvinColor = KelvinToColor( KelvinTemperature );
_lastKelvin = KelvinTemperature;
}
color = _cachedKelvinColor;
}
if ( EnableColorTransition )
{
float lerp = (MathF.Sin( effectTime * ColorTransitionSpeed ) + 1.0f) * 0.5f;
color = Color.Lerp( color, SecondaryColor, lerp, true );
}
if ( EnableDisco )
{
color = new ColorHsv( (effectTime * DiscoSpeed) % 360f, DiscoSaturation, DiscoValue ).ToColor();
}
if ( absoluteTime < AlarmEndTimeState )
{
color = Color.Lerp( color, AlarmColor, 0.85f, true );
}
if ( HasExternalColorOverrideState )
{
color = ExternalColorOverrideState;
}
return color;
}
private void UpdateOutputState( bool shouldBeEnabled, bool playOneShot )
{
if ( !_hasOutputState )
{
_hasOutputState = true;
_lastOutputEnabled = shouldBeEnabled;
return;
}
if ( _lastOutputEnabled == shouldBeEnabled )
{
return;
}
if ( playOneShot && Game.IsPlaying )
{
if ( shouldBeEnabled && ToggleOnSound != null )
{
Sound.Play( ToggleOnSound, WorldPosition );
}
else if ( !shouldBeEnabled && ToggleOffSound != null )
{
Sound.Play( ToggleOffSound, WorldPosition );
}
}
_lastOutputEnabled = shouldBeEnabled;
}
private void DisableOutput( Light light )
{
light.Enabled = false;
UpdateOutputState( false, false );
ManageAudio( false, 0f );
}
private void UpdateAmbientSoundPosition()
{
if ( _ambientSoundHandle != null && !_ambientSoundHandle.IsStopped )
{
_ambientSoundHandle.Position = WorldPosition;
}
}
private void ManageAudio( bool isLightEnabled, float intensityRatio )
{
if ( !Game.IsPlaying || AmbientSound == null )
{
return;
}
if ( isLightEnabled )
{
if ( _ambientSoundHandle == null || _ambientSoundHandle.IsStopped )
{
_ambientSoundHandle = Sound.Play( AmbientSound, WorldPosition );
}
if ( _ambientSoundHandle != null )
{
_ambientSoundHandle.Position = WorldPosition;
_ambientSoundHandle.Volume = BaseVolume * (ModulateVolumeWithLight ? intensityRatio : 1.0f);
_ambientSoundHandle.Pitch = ModulatePitchWithLight ? MathX.Lerp( MinPitch, MaxPitch, intensityRatio ) : 1.0f;
}
}
else if ( _ambientSoundHandle != null )
{
_ambientSoundHandle.Stop();
_ambientSoundHandle = null;
}
}
private Color KelvinToColor( int kelvin )
{
float temperature = kelvin / 100.0f;
float red;
float green;
float blue;
if ( temperature <= 66f )
{
red = 255f;
green = Math.Clamp( 99.47f * MathF.Log( temperature ) - 161.11f, 0f, 255f );
}
else
{
red = Math.Clamp( 329.698f * MathF.Pow( temperature - 60f, -0.133f ), 0f, 255f );
green = Math.Clamp( 288.12f * MathF.Pow( temperature - 60f, -0.075f ), 0f, 255f );
}
if ( temperature >= 66f )
{
blue = 255f;
}
else if ( temperature <= 19f )
{
blue = 0f;
}
else
{
blue = Math.Clamp( 138.51f * MathF.Log( temperature - 10f ) - 305.04f, 0f, 255f );
}
return new Color( red / 255f, green / 255f, blue / 255f );
}
protected override void DrawGizmos()
{
if ( !ShowDebugGizmos )
{
return;
}
Gizmo.Draw.Text( $"Group: {LightGroup}", new Transform( Vector3.Up * 20f ), size: 12 );
if ( !string.IsNullOrWhiteSpace( PowerGridTag ) )
{
Gizmo.Draw.Text( $"Grid: {PowerGridTag}", new Transform( Vector3.Up * 34f ), size: 12 );
}
if ( EnableSensor )
{
Gizmo.Draw.Color = Color.Cyan.WithAlpha( 0.2f );
Gizmo.Draw.SolidSphere( Vector3.Zero, SensorRange );
Gizmo.Draw.Color = Color.Cyan;
Gizmo.Draw.LineSphere( Vector3.Zero, SensorRange );
}
if ( EnableCulling )
{
Gizmo.Draw.Color = Color.Red.WithAlpha( 0.05f );
Gizmo.Draw.LineSphere( Vector3.Zero, MaxDistance );
Gizmo.Draw.Text( $"Cull: {MaxDistance}", new Transform( Vector3.Up * (MaxDistance * 0.9f) ), size: 14 );
}
}
protected override void OnDestroy()
{
if ( _ambientSoundHandle != null )
{
_ambientSoundHandle.Stop();
}
}
}
using System;
using Sandbox;
using Sandbox.ui;
public sealed class SceneGrassComponent : Component
{
public static bool EditorPainterActive { get; set; }
public GrassRenderObject Renderer { get; private set; }
[Property] public GrassDensityMapResource DensityMapResource { get; set; }
[Property] public float ChunkSize { get; set; } = 256.0f;
[Property] public int ChunkResolution { get; set; } = 64;
[Property] public int RenderRadius { get; set; } = 6;
[Property] public float StreamingRadius { get; set; } = 1536.0f;
[Property] public float LodCutoff { get; set; } = 2048.0f;
[Property] public float DistanceCutoff { get; set; } = 4096.0f;
[Property] public float LodTransitionRange { get; set; } = 200.0f;
[Property] public float DistanceTransitionRange { get; set; } = 200.0f;
[Property] public float DisplacementStrength { get; set; } = 200.0f;
[Property] public float TerrainProbeTop { get; set; } = 4096.0f;
[Property] public float TerrainProbeBottom { get; set; } = -4096.0f;
[Property] public float TerrainHeightOffset { get; set; } = 0.0f;
[Property] public float GrassHeightPadding { get; set; } = 128.0f;
[Property] public float FallbackHeight { get; set; } = 0.0f;
[Property] public float InteractionStrength { get; set; } = 8.0f;
[Property] public float InteractionStampRate { get; set; } = 36.0f;
[Property] public float InteractionBendHoldDuration { get; set; } = 0.5f;
[Property] public float InteractionDecayUpdateInterval { get; set; } = 0.05f;
[Property] public float CutDuration { get; set; } = 8.0f;
protected override void OnAwake()
{
base.OnAwake();
using ( Scene.Push() )
{
Renderer = new GrassRenderObject( Scene.SceneWorld );
}
SyncRendererSettings();
}
private void SyncRendererSettings()
{
if ( Renderer == null )
return;
float streamingRadius = StreamingRadius;
if ( EditorPainterActive )
streamingRadius *= 10.0f;
Renderer.ChunkSize = ChunkSize;
Renderer.ChunkResolution = ChunkResolution;
float requiredStreamingRadius = Math.Max( streamingRadius, Math.Max( LodCutoff, DistanceCutoff ) + ChunkSize );
Renderer.RenderRadius = Math.Max( 1, MathX.CeilToInt( requiredStreamingRadius / Math.Max( ChunkSize, 0.001f ) ) );
Renderer.LodCutoff = LodCutoff;
Renderer.LodTransitionRange = LodTransitionRange;
Renderer.DistanceTransitionRange = DistanceTransitionRange;
Renderer.DistanceCutoff = DistanceCutoff;
Renderer.DisplacementStrength = DisplacementStrength;
Renderer.TerrainProbeTop = TerrainProbeTop;
Renderer.TerrainProbeBottom = TerrainProbeBottom;
Renderer.TerrainHeightOffset = TerrainHeightOffset;
Renderer.GrassHeightPadding = GrassHeightPadding;
Renderer.FallbackHeight = FallbackHeight;
Renderer.InteractionStrength = InteractionStrength;
Renderer.InteractionStampRate = InteractionStampRate;
Renderer.InteractionBendHoldDuration = InteractionBendHoldDuration;
Renderer.CutDuration = CutDuration;
Renderer.InteractionDecayUpdateInterval = InteractionDecayUpdateInterval;
Renderer.SetDensityResource( DensityMapResource );
Renderer.CullingCamera = Scene.Camera;
}
protected override void OnUpdate()
{
base.OnUpdate();
UpdateRenderer();
}
private void UpdateRenderer()
{
if ( Renderer == null )
return;
if ( Scene.Camera == null )
return;
SyncRendererSettings();
Renderer.SetDensityResource( DensityMapResource );
Renderer.CullingCamera = Scene.Camera;
Renderer.UpdateInteractionField( Scene.GetAllComponents<GrassInteractionSourceComponent>(), Time.Delta );
Vector3 camPos = Scene.Camera.WorldPosition;
Renderer.UpdateStreaming( camPos );
Renderer.ProcessPendingDestroy();
}
protected override void OnDisabled()
{
base.OnDisabled();
Renderer?.Disable();
Renderer = null;
}
protected override void DrawGizmos()
{
base.DrawGizmos();
UpdateRenderer();
if ( !EditorPainterActive )
return;
if ( Renderer == null )
return;
Gizmo.Draw.Color = Color.Yellow;
foreach ( var pair in Renderer.ActiveChunks )
{
BBox bounds = BBox.FromPositionAndSize( pair.Value.Bounds.Center, pair.Value.Bounds.Size.WithZ( 0 ) );
Gizmo.Draw.LineBBox( bounds );
}
}
}
using System;
using System.Collections.Generic;
using System.Text;
namespace Saandy.Tilemapper;
public interface ITilemapSceneEvent : ISceneEvent<ITilemapSceneEvent>
{
/// <summary>
/// Called when tilemap was changed this frame.
/// </summary>
void OnTilemapChanged() { }
/// <summary>
/// Called when the tilemap has been updated recently and stopped being updated.
/// </summary>
void OnTilemapStable() { }
}
using PanelRenderTarget;
using Sandbox;
using Sandbox.UI;
using System;
using System.Linq;
public class TargetScreen : Component, Component.DontExecuteOnServer, ITargetScreen
{
[Property, Feature( "interaction" ), Description( "Enable verbose logs for screen mouse detection" )]
public bool DebugMouseTrace { get; set; } = false;
[Property] public string ScreenMaterialName { get; set; } = "screen-01";
[Property] public Material ScreenMaterial { get; set; } = Material.Load( "materials/screen.vmat" );
[Property] public Vector2Int ScreenTextureSize { get; set; } = new( 1280, 720 );
[Property] public float TraceDistance { get; set; } = 200f;
[Property] public bool ForceUpdate { get; set; } = false;
[Property] public PanelTypeReference PanelType { get; set; } = new();
[Property, Feature( "interaction" ), Description( "Small UV offset applied after triangle interpolation to correct slight drift" )]
public Vector2 ScreenUvOffset { get; set; } = Vector2.Zero;
[Property, Feature("interaction"), Description("interact with 2d mouse cursor")]
public bool ScreenCursorInteraction { get; set; } = false;
[Property,Feature("interaction"), Description("whether to show the virtual cursor") ]
public bool ShowVirtualCursor { get; set; } = true;
[Property, Feature( "Optimisation" ), Description("fps when the panel is focused") ]
public int UpdateRateFocus { get; set; } = 60;
[Property, Feature("Optimisation")]
public bool UpdateWhenNotFocused { get; set; } = false;
[Property, Feature( "Optimisation" ), Description( "fps update rate when the panel is visible in camera" ) ]
public int UpdateRateNotFocused { get; set; } = 30;
[Property, Feature( "Optimisation" ), Description("the distance of the update rate visible but not focus") ]
public int UpdateDistanceMax { get; set; } = 500;
private bool _firstUpdate = false;
private readonly TargetPanelInput _input = new();
public ModelRenderer Renderer { get; private set; }
private Material _screenMaterialCopy;
private Texture _screenTexture;
private TargetRootPanel _rootPanel;
private PanelSceneObject _panelObject;
private Vertex[] _cachedVertices;
private uint[] _cachedIndices;
private Model _cachedMeshModel;
private TriangleMaterialRange[] _cachedTriangleMaterialRanges = Array.Empty<TriangleMaterialRange>();
private double _tracePerfAccumMs;
private double _tracePerfMaxMs;
private double _tracePerfCacheAccumMs;
private double _tracePerfTrianglesAccumMs;
private int _tracePerfSamples;
private TimeSince _tracePerfLogTimer;
private readonly struct TriangleMaterialRange
{
public int StartTriangle { get; init; }
public int EndTriangleExclusive { get; init; }
public Material Material { get; init; }
}
protected Panel Panel { get; private set; }
public TargetRootPanel RootPanel => _rootPanel;
public PanelSceneObject PanelObject => _panelObject;
protected Texture ScreenTexture => _screenTexture;
protected override void OnPreRender()
{
//ensure sceneobject have transform and correct bound for engine culling
_panelObject.Transform = Renderer.Transform.World;
_panelObject.Bounds = Renderer.Bounds;
}
protected override void OnEnabled()
{
base.OnEnabled();
Renderer = Components.Get<ModelRenderer>();
if ( !Renderer.IsValid() )
{
Log.Warning( "No ModelRenderer found." );
Enabled = false;
return;
}
RefreshMeshCache();
CreateTexture();
CreatePanel();
SetupMaterial();
CreatePanelObject();
OnPanelCreated( Panel );
var panelComponent = Components.Get<PanelComponent>();
TargetPanelSystem.Current.RegisterScreen( this );
}
public Panel GetPanel()
{
return Panel;
}
protected virtual void OnPanelCreated( Panel panel )
{
}
private void CreateTexture()
{
_screenTexture = Texture.CreateRenderTarget()
.WithSize( ScreenTextureSize.x, ScreenTextureSize.y )
.WithInitialColor( Color.Black )
.WithMips()
.Create();
}
private void CreatePanel()
{
var bounds = new Rect( 0, 0, ScreenTextureSize.x, ScreenTextureSize.y );
_rootPanel = new TargetRootPanel
{
RenderedManually = true,
FixedBounds = bounds,
FixedScale = 1f,
PanelBounds = bounds,
MouseVisibility = ScreenCursorInteraction ? MouseVisibility.Visible : MouseVisibility.Hidden
};
_rootPanel.Style.Width = Length.Pixels( ScreenTextureSize.x );
_rootPanel.Style.Height = Length.Pixels( ScreenTextureSize.y );
var type = PanelType?.Resolve();
if ( type?.TargetType is null || !typeof( Panel ).IsAssignableFrom( type.TargetType ) )
{
Log.Warning( $"Invalid panel type: {PanelType?.TypeName}" );
return;
}
Panel = type.Create<Panel>();
_rootPanel.AddChild( Panel );
Panel.Style.Width = Length.Percent( 100 );
Panel.Style.Height = Length.Percent( 100 );
}
private void CreatePanelObject()
{
_panelObject = new PanelSceneObject(
GameObject.GetBounds(),
Scene.SceneWorld,
_rootPanel,
_screenTexture,
this
);
}
public void Tick()
{
if ( !Renderer.IsValid() || Renderer.Model is null || !Panel.IsValid() )
return;
var camera = Scene.Camera;
if ( camera is null )
return;
if ( !TryGetPanelPosition( camera, out var panelPos ) )
{
ClearInput();
return;
}
_panelObject.CursorPosition = panelPos;
_input.Tick(
_rootPanel,
panelPos,
Input.Down( "attack1" ),
Input.MouseWheel
);
}
private bool TryGetPanelPosition( CameraComponent camera, out Vector2 panelPos )
{
panelPos = default;
var screenCenter = new Vector2(
camera.ScreenRect.Size.x * 0.5f,
camera.ScreenRect.Size.y * 0.5f
);
var ray = camera.ScreenPixelToRay( screenCenter );
if( ScreenCursorInteraction )
{
ray = camera.ScreenPixelToRay( Mouse.Position );
}
return TryGetPanelPositionFromMeshRaycast( ray, out panelPos );
}
private bool TryGetPanelPositionFromMeshRaycast( Ray ray, out Vector2 panelPos )
{
using var scope = Sandbox.Diagnostics.Performance.Scope( "TargetScreen.MouseTrace" );
var totalTimer = Sandbox.Diagnostics.FastTimer.StartNew();
panelPos = default;
var cacheTimer = Sandbox.Diagnostics.FastTimer.StartNew();
RefreshMeshCache();
var cacheMs = cacheTimer.ElapsedMilliSeconds;
if ( _cachedVertices is null || _cachedIndices is null || _cachedIndices.Length < 3 )
{
if ( DebugMouseTrace )
Log.Info( $"[TargetScreen] Reject mesh cache: vertices={_cachedVertices?.Length ?? 0} indices={_cachedIndices?.Length ?? 0}" );
UpdateDebugTracePerf( totalTimer.ElapsedMilliSeconds, cacheMs, 0f );
return false;
}
var localOrigin = Renderer.Transform.World.PointToLocal( ray.Position );
var localEnd = Renderer.Transform.World.PointToLocal( ray.Position + ray.Forward * TraceDistance );
var localDirection = (localEnd - localOrigin).Normal;
var bestDistance = float.MaxValue;
var bestUv = Vector2.Zero;
var bestTriangle = -1;
var triangleTimer = Sandbox.Diagnostics.FastTimer.StartNew();
for ( int triStart = 0; triStart + 2 < _cachedIndices.Length; triStart += 3 )
{
var triangleIndex = triStart / 3;
if ( !IsTriangleMaterialMatch( triangleIndex ) )
continue;
var i0 = _cachedIndices[triStart + 0];
var i1 = _cachedIndices[triStart + 1];
var i2 = _cachedIndices[triStart + 2];
if ( i0 >= _cachedVertices.Length || i1 >= _cachedVertices.Length || i2 >= _cachedVertices.Length )
continue;
var v0 = _cachedVertices[i0];
var v1 = _cachedVertices[i1];
var v2 = _cachedVertices[i2];
if ( !TryRayTriangleIntersection( localOrigin, localDirection, v0.Position, v1.Position, v2.Position, out var distance, out var barycentric ) )
continue;
if ( distance > TraceDistance || distance >= bestDistance )
continue;
var triangleUv =
v0.TexCoord0 * barycentric.x +
v1.TexCoord0 * barycentric.y +
v2.TexCoord0 * barycentric.z;
bestDistance = distance;
bestUv = triangleUv;
bestTriangle = triangleIndex;
}
var triangleMs = triangleTimer.ElapsedMilliSeconds;
if ( bestTriangle < 0 )
{
if ( DebugMouseTrace )
Log.Info( $"[TargetScreen] No triangle hit. vertices={_cachedVertices.Length} indices={_cachedIndices.Length}" );
UpdateDebugTracePerf( totalTimer.ElapsedMilliSeconds, cacheMs, triangleMs );
return false;
}
if ( DebugMouseTrace )
Log.Info( $"[TargetScreen] Mesh hit triangle={bestTriangle} distance={bestDistance} rawUv={bestUv}" );
var uv = new Vector2(
bestUv.x - MathF.Floor( bestUv.x ),
bestUv.y - MathF.Floor( bestUv.y )
);
uv += ScreenUvOffset;
panelPos = new Vector2(
Math.Clamp( uv.x, 0f, 1f ) * ScreenTextureSize.x,
Math.Clamp( uv.y, 0f, 1f ) * ScreenTextureSize.y
);
if ( DebugMouseTrace )
Log.Info( $"[TargetScreen] Final UV={uv} panelPos={panelPos}" );
UpdateDebugTracePerf( totalTimer.ElapsedMilliSeconds, cacheMs, triangleMs );
return true;
}
private void UpdateDebugTracePerf( double totalMs, double cacheMs, double triangleMs )
{
_tracePerfAccumMs += totalMs;
_tracePerfCacheAccumMs += cacheMs;
_tracePerfTrianglesAccumMs += triangleMs;
_tracePerfMaxMs = Math.Max( _tracePerfMaxMs, totalMs );
_tracePerfSamples++;
if ( _tracePerfLogTimer < 5f )
return;
var avgTotalMs = _tracePerfSamples > 0 ? _tracePerfAccumMs / _tracePerfSamples : 0d;
var avgCacheMs = _tracePerfSamples > 0 ? _tracePerfCacheAccumMs / _tracePerfSamples : 0d;
var avgTrianglesMs = _tracePerfSamples > 0 ? _tracePerfTrianglesAccumMs / _tracePerfSamples : 0d;
var callsPerSecond = _tracePerfLogTimer > 0f ? _tracePerfSamples / _tracePerfLogTimer : 0f;
var triangleCount = _cachedIndices?.Length / 3 ?? 0;
Log.Info( $"[TargetScreen] Trace perf avg={avgTotalMs:F3}ms max={_tracePerfMaxMs:F3}ms cache={avgCacheMs:F3}ms triangles={avgTrianglesMs:F3}ms calls={callsPerSecond:F1}/s tris={triangleCount}" );
_tracePerfAccumMs = 0d;
_tracePerfCacheAccumMs = 0d;
_tracePerfTrianglesAccumMs = 0d;
_tracePerfMaxMs = 0d;
_tracePerfSamples = 0;
_tracePerfLogTimer = 0f;
}
private void RefreshMeshCache()
{
var model = Renderer?.Model;
if ( model is null || model == _cachedMeshModel )
return;
_cachedMeshModel = model;
_cachedVertices = model.GetVertices();
_cachedIndices = model.GetIndices();
_cachedTriangleMaterialRanges = BuildTriangleMaterialRanges( model );
if ( DebugMouseTrace )
Log.Info( $"[TargetScreen] Refreshed mesh cache for {model.Name}: vertices={_cachedVertices?.Length ?? 0} indices={_cachedIndices?.Length ?? 0} materialRanges={_cachedTriangleMaterialRanges.Length}" );
}
private TriangleMaterialRange[] BuildTriangleMaterialRanges( Model model )
{
var meshInfo = model.MeshInfo;
if ( meshInfo?.Meshes is null )
return Array.Empty<TriangleMaterialRange>();
var ranges = new System.Collections.Generic.List<TriangleMaterialRange>();
var triangleCursor = 0;
foreach ( var mesh in meshInfo.Meshes )
{
if ( mesh?.DrawCalls is null )
continue;
foreach ( var drawCall in mesh.DrawCalls )
{
var triangleCount = Math.Max( 0, drawCall.Indices / 3 );
if ( triangleCount == 0 )
continue;
ranges.Add( new TriangleMaterialRange
{
StartTriangle = triangleCursor,
EndTriangleExclusive = triangleCursor + triangleCount,
Material = drawCall.Material
} );
if ( DebugMouseTrace )
Log.Info( $"[TargetScreen] Material range {triangleCursor}->{triangleCursor + triangleCount} material={drawCall.Material?.Name}" );
triangleCursor += triangleCount;
}
}
return ranges.ToArray();
}
private bool IsTriangleMaterialMatch( int triangleIndex )
{
if ( string.IsNullOrWhiteSpace( ScreenMaterialName ) || _cachedTriangleMaterialRanges.Length == 0 )
return true;
foreach ( var range in _cachedTriangleMaterialRanges )
{
if ( triangleIndex < range.StartTriangle || triangleIndex >= range.EndTriangleExclusive )
continue;
var match = range.Material?.Name?.Contains( ScreenMaterialName, StringComparison.OrdinalIgnoreCase ) == true;
if ( DebugMouseTrace && triangleIndex == range.StartTriangle )
Log.Info( $"[TargetScreen] Triangle {triangleIndex} material={range.Material?.Name} match={match}" );
return match;
}
return true;
}
private static bool TryRayTriangleIntersection( Vector3 origin, Vector3 direction, Vector3 a, Vector3 b, Vector3 c, out float distance, out Vector3 barycentric )
{
distance = 0f;
barycentric = default;
const float epsilon = 0.0001f;
var edge1 = b - a;
var edge2 = c - a;
var pvec = Vector3.Cross( direction, edge2 );
var det = Vector3.Dot( edge1, pvec );
if ( MathF.Abs( det ) < epsilon )
return false;
var invDet = 1f / det;
var tvec = origin - a;
var v = Vector3.Dot( tvec, pvec ) * invDet;
if ( v < 0f || v > 1f )
return false;
var qvec = Vector3.Cross( tvec, edge1 );
var w = Vector3.Dot( direction, qvec ) * invDet;
if ( w < 0f || v + w > 1f )
return false;
distance = Vector3.Dot( edge2, qvec ) * invDet;
if ( distance < 0f )
return false;
barycentric = new Vector3( 1f - v - w, v, w );
return true;
}
private void SetupMaterial()
{
var oldMaterial = Renderer.Model.Materials
.FirstOrDefault( x => x.Name.Contains( ScreenMaterialName ) );
var index = Renderer.Model.Materials.IndexOf( oldMaterial );
if ( index < 0 )
{
Log.Warning( $"Screen material not found: {ScreenMaterialName}" );
return;
}
_screenMaterialCopy = ScreenMaterial.CreateCopy();
_screenMaterialCopy.Set( "g_tColor", _screenTexture );
Renderer.Materials.SetOverride( index, _screenMaterialCopy );
}
private void ClearInput()
{
_input.Clear();
}
protected override void OnDisabled()
{
base.OnDisabled();
ClearInput();
_panelObject?.Delete();
_panelObject = null;
_rootPanel?.Delete( true );
_rootPanel = null;
_screenTexture?.Dispose();
_screenTexture = null;
_screenMaterialCopy = null;
Panel = null;
Renderer = null;
TargetPanelSystem.Current.UnregisterScreen( this );
}
}
@using Sandbox;
@using Sandbox.UI;
@using System.Threading.Tasks;
@using System.Collections.Generic;
@using System;
@inherits PanelComponent
<root class="@(IsFadingOut ? "fade-out" : "fade-in")" style="background-image: @(!string.IsNullOrEmpty(BackgroundImage) ? $"url({BackgroundImage})" : "none");">
<div class="content">
@* Logo Image (.png / .jpg) *@
@if (!string.IsNullOrEmpty(LogoImage))
{
<img class="logo" src="@LogoImage" />
}
@* Text Lines *@
@if (TextLines != null && TextLines.Count > 0)
{
<div class="text-container">
@foreach (var line in TextLines)
{
<label style="color: @line.TextColor.Hex; font-size: @(line.FontSize)px;">
@line.Text
</label>
}
</div>
}
</div>
</root>
@code {
// === CUSTOM DATA CLASS FOR TEXT LINES ===
public class SplashTextLine
{
[Property, Description("The text to display.")]
public string Text { get; set; } = "NEW LINE";
[Property, Description("Text color for this specific line.")]
public Color TextColor { get; set; } = Color.White;
[Property, Description("Font size for this specific line.")]
public float FontSize { get; set; } = 80f;
}
// === IMAGE SETTINGS ===
[Property, ImageAssetPath, Group("Images"), Description("Supports .png and .jpg. If empty, the background will be black.")]
public string BackgroundImage { get; set; }
[Property, ImageAssetPath, Group("Images"), Description("Main logo image (.png / .jpg). Appears above the text if both are set.")]
public string LogoImage { get; set; }
// === TEXT SETTINGS ===
[Property, Group("Text"), Description("Add text lines with individual settings (color, size).")]
public List<SplashTextLine> TextLines { get; set; } = new();
// === AUDIO & SCENE SETTINGS ===
[Property, Group("Audio"), Description("Select a Sound Event (.sound) that contains your .mp3 or .ogg file.")]
public SoundEvent SplashSound { get; set; }
[Property, Group("Scene"), Description("The scene to load after the splash screen finishes.")]
public SceneFile NextScene { get; set; }
// === LOGIC ===
public bool IsFadingOut { get; set; } = false;
protected override void OnStart()
{
base.OnStart();
// Start the asynchronous sequence
_ = RunSplashSequence();
}
private async Task RunSplashSequence()
{
// 1. Wait half a second before starting to avoid stuttering during load
await Task.Delay(500);
// 2. Play the assigned sound (.mp3 / .ogg via Sound Event)
if (SplashSound != null)
{
Sound.Play(SplashSound);
}
// 3. Wait while the logo/text is visible on the screen (3 seconds)
await Task.Delay(3000);
// 4. Trigger the fade-out animation
IsFadingOut = true;
StateHasChanged(); // Notify the UI to update CSS classes
// 5. Wait for the fade-out animation to finish (matches the CSS transition time)
await Task.Delay(2000);
// 6. Load the next scene
if (NextScene != null)
{
Scene.Load(NextScene);
}
else
{
Log.Warning("Next Scene is not assigned in the Splash Screen component!");
GameObject.Destroy(); // Destroy the component if no scene is assigned
}
}
}using System;
using Sandbox;
namespace Goo.Animation;
public record struct SmoothVector2
{
public Vector2 Current;
public Vector2 Target;
public Vector2 Velocity;
public float SmoothTime;
public SmoothVector2(Vector2 initial, float smoothTime)
{
Current = initial;
Target = initial;
Velocity = default;
SmoothTime = smoothTime;
}
public void Update(float dt)
{
float vx = Velocity.x, vy = Velocity.y;
Current = new Vector2(
MathX.SmoothDamp(Current.x, Target.x, ref vx, SmoothTime, dt),
MathX.SmoothDamp(Current.y, Target.y, ref vy, SmoothTime, dt));
Velocity = new Vector2(vx, vy);
}
public bool IsSettled =>
MathF.Abs(Target.x - Current.x) < 0.0001f &&
MathF.Abs(Target.y - Current.y) < 0.0001f &&
MathF.Abs(Velocity.x) < 0.0001f &&
MathF.Abs(Velocity.y) < 0.0001f;
/// <summary>Advances by dt and returns true while still moving; chain calls with | (not ||) so every damper advances each frame.</summary>
public bool Tick(float dt) { Update(dt); return !IsSettled; }
}
using System;
namespace Goo.Animation;
public readonly record struct Tween
{
public Sandbox.Utility.Easing.Function Easing { get; init; }
public float Duration { get; init; }
public float Delay { get; init; }
public float SpeedScale { get; init; }
public int Iterations { get; init; }
public bool PingPong { get; init; }
public bool Reversed { get; init; }
public Tween(Sandbox.Utility.Easing.Function easing, float duration, float delay = 0f)
{
Easing = easing;
Duration = duration;
Delay = delay;
SpeedScale = 1f;
Iterations = 1;
PingPong = false;
Reversed = false;
}
/// <summary>
/// Bridge a designer-authored Sandbox.Curve (authored over [0, 1]) into a Tween.
/// Allocates one delegate per call; cache the result in a static readonly field.
/// </summary>
public static Tween FromCurve(Sandbox.Curve curve, float duration, float delay = 0f)
=> new Tween(curve.Evaluate, duration, delay);
public float Eval(float elapsedSec)
{
if (Duration <= 0f) return Reversed ? 0f : 1f;
float t = (elapsedSec - Delay) * SpeedScale;
if (t <= 0f) return Reversed ? 1f : 0f;
float cycleDuration = PingPong ? 2f * Duration : Duration;
if (Iterations > 0 && t >= cycleDuration * Iterations)
{
float endLocal = PingPong ? 0f : 1f;
if (Reversed) endLocal = 1f - endLocal;
return Easing(endLocal);
}
float cycleT = t % cycleDuration;
float local = PingPong
? (cycleT < Duration ? cycleT / Duration : 1f - (cycleT - Duration) / Duration)
: cycleT / Duration;
if (Reversed) local = 1f - local;
return Easing(local);
}
}
public static class TweenExtensions
{
public static Tween Loop(this Tween t) => t with { Iterations = -1 };
public static Tween Times(this Tween t, int n) => t with { Iterations = n };
public static Tween PingPong(this Tween t) => t with { PingPong = true };
public static Tween Scale(this Tween t, float speed) => t with { SpeedScale = speed };
public static Tween WithDelay(this Tween t, float s) => t with { Delay = s };
public static Tween Reverse(this Tween t) => t with { Reversed = true };
}
using System;
using System.Collections;
namespace Goo;
public sealed class Children : IEnumerable
{
internal FrameList? _list;
internal int _buildId;
internal Children() { }
internal int Count { get { EnsureValid(); return _list!.Count; } }
internal ref Frame this[int i] { get { EnsureValid(); return ref _list![i]; } }
public void Add<T>(in T child) where T : struct, IBlob
{
EnsureValid();
ref Frame slot = ref _list!.Reserve();
child.WriteTo(ref slot);
}
void EnsureValid()
{
var ctx = BuildContext._current;
if (ctx == null || _list == null || _buildId != ctx._currentBuildId)
throw new InvalidOperationException(
"Container reused across rebuilds. The same Container instance cannot survive " +
"past the Build() it was created in. Build a new one inside Build(), or extract " +
"a helper function that returns a fresh Container each call.");
}
// IEnumerable is required by C# collection-initializer syntax; iteration is not a
// use case for Children, so this returns an empty enumerator.
IEnumerator IEnumerable.GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
namespace Goo;
/// <summary>Compile-time constraint for blob struct types. Never use as storage, return, or parameter type (boxes the struct, destroys per-Rebuild allocation profile); only valid in where T : struct, IBlob.</summary>
public interface IBlob
{
static abstract BlobKind Kind { get; }
string? Key { get; }
internal void WriteTo(ref Frame frame);
}
/// <summary>Returns the single root Blob for a GooView build. A named delegate rather than
/// Func<IBlob> because IBlob has a static-abstract member (Kind) and C# bars such interfaces
/// as generic type arguments (CS8920). Consequence for Razor markup: a bare method group cannot
/// bind (its natural type is the illegal Func<IBlob>), so write
/// <c>Build=@(new BlobBuilder(MyBuild))</c>. See docs/site/docs/gotchas.md.</summary>
public delegate IBlob BlobBuilder();
// <auto-generated />
// Generated by tools/StyleFacadeEmit. Do not edit by hand.
// Source of truth: tools/StyleFacadeEmit/style-manifest.json
using Sandbox;
using Sandbox.Rendering;
using Sandbox.UI;
namespace Goo;
public readonly partial record struct Text
{
public Length? Width { init => _style = StyleAccumulator.Add(_style, StyleField.Width, value); }
public Length? Height { init => _style = StyleAccumulator.Add(_style, StyleField.Height, value); }
public Length? Margin { init => _style = StyleAccumulator.Add(_style, StyleField.Margin, value); }
public Length? MarginLeft { init => _style = StyleAccumulator.Add(_style, StyleField.MarginLeft, value); }
public Length? MarginTop { init => _style = StyleAccumulator.Add(_style, StyleField.MarginTop, value); }
public Length? MarginRight { init => _style = StyleAccumulator.Add(_style, StyleField.MarginRight, value); }
public Length? MarginBottom { init => _style = StyleAccumulator.Add(_style, StyleField.MarginBottom, value); }
public Color? BackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundColor, value, StyleValue.FromColor); }
public Color? BackgroundTint { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundTint, value, StyleValue.FromColor); }
public Length? FlexBasis { init => _style = StyleAccumulator.Add(_style, StyleField.FlexBasis, value); }
public float? FlexGrow { init => _style = StyleAccumulator.Add(_style, StyleField.FlexGrow, value); }
public float? FlexShrink { init => _style = StyleAccumulator.Add(_style, StyleField.FlexShrink, value); }
public Color? FontColor { init => _style = StyleAccumulator.Add(_style, StyleField.FontColor, value, StyleValue.FromColor); }
public string? FontFamily { init => _style = StyleAccumulator.Add(_style, StyleField.FontFamily, value); }
public Length? FontSize { init => _style = StyleAccumulator.Add(_style, StyleField.FontSize, value); }
public FontSmooth? FontSmooth { init => _style = StyleAccumulator.Add(_style, StyleField.FontSmooth, value, StyleValue.FromFontSmooth); }
public FontStyle? FontStyle { init => _style = StyleAccumulator.Add(_style, StyleField.FontStyle, value, StyleValue.FromFontStyle); }
public FontVariantNumeric? FontVariantNumeric { init => _style = StyleAccumulator.Add(_style, StyleField.FontVariantNumeric, value, StyleValue.FromFontVariantNumeric); }
public int? FontWeight { init => _style = StyleAccumulator.Add(_style, StyleField.FontWeight, value); }
public Length? LetterSpacing { init => _style = StyleAccumulator.Add(_style, StyleField.LetterSpacing, value); }
public Length? LineHeight { init => _style = StyleAccumulator.Add(_style, StyleField.LineHeight, value); }
public Length? MaxHeight { init => _style = StyleAccumulator.Add(_style, StyleField.MaxHeight, value); }
public Length? MaxWidth { init => _style = StyleAccumulator.Add(_style, StyleField.MaxWidth, value); }
public Length? MinHeight { init => _style = StyleAccumulator.Add(_style, StyleField.MinHeight, value); }
public Length? MinWidth { init => _style = StyleAccumulator.Add(_style, StyleField.MinWidth, value); }
public float? Opacity { init => _style = StyleAccumulator.Add(_style, StyleField.Opacity, value); }
public TextAlign? TextAlign { init => _style = StyleAccumulator.Add(_style, StyleField.TextAlign, value, StyleValue.FromTextAlign); }
public Length? TextBackgroundAngle { init => _style = StyleAccumulator.Add(_style, StyleField.TextBackgroundAngle, value); }
public Color? TextDecorationColor { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationColor, value, StyleValue.FromColor); }
public TextDecoration? TextDecorationLine { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationLine, value, StyleValue.FromTextDecoration); }
public TextSkipInk? TextDecorationSkipInk { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationSkipInk, value, StyleValue.FromTextSkipInk); }
public TextDecorationStyle? TextDecorationStyle { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationStyle, value, StyleValue.FromTextDecorationStyle); }
public Length? TextDecorationThickness { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationThickness, value); }
public FilterMode? TextFilter { init => _style = StyleAccumulator.Add(_style, StyleField.TextFilter, value, StyleValue.FromFilterMode); }
public Length? TextLineThroughOffset { init => _style = StyleAccumulator.Add(_style, StyleField.TextLineThroughOffset, value); }
public TextOverflow? TextOverflow { init => _style = StyleAccumulator.Add(_style, StyleField.TextOverflow, value, StyleValue.FromTextOverflow); }
public Length? TextOverlineOffset { init => _style = StyleAccumulator.Add(_style, StyleField.TextOverlineOffset, value); }
public Color? TextStrokeColor { init => _style = StyleAccumulator.Add(_style, StyleField.TextStrokeColor, value, StyleValue.FromColor); }
public Length? TextStrokeWidth { init => _style = StyleAccumulator.Add(_style, StyleField.TextStrokeWidth, value); }
public TextTransform? TextTransform { init => _style = StyleAccumulator.Add(_style, StyleField.TextTransform, value, StyleValue.FromTextTransform); }
public Length? TextUnderlineOffset { init => _style = StyleAccumulator.Add(_style, StyleField.TextUnderlineOffset, value); }
public Goo.PanelTransform? Transform { init => _style = StyleAccumulator.Add(_style, StyleField.Transform, value, StyleValue.FromPanelTransform); }
public WhiteSpace? WhiteSpace { init => _style = StyleAccumulator.Add(_style, StyleField.WhiteSpace, value, StyleValue.FromWhiteSpace); }
public WordBreak? WordBreak { init => _style = StyleAccumulator.Add(_style, StyleField.WordBreak, value, StyleValue.FromWordBreak); }
public Length? WordSpacing { init => _style = StyleAccumulator.Add(_style, StyleField.WordSpacing, value); }
public Color? HoverBackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.HoverBackgroundColor, value, StyleValue.FromColor); }
public Color? ActiveBackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.ActiveBackgroundColor, value, StyleValue.FromColor); }
public Color? FocusBackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.FocusBackgroundColor, value, StyleValue.FromColor); }
public Color? HoverFontColor { init => _style = StyleAccumulator.Add(_style, StyleField.HoverFontColor, value, StyleValue.FromColor); }
public Color? ActiveFontColor { init => _style = StyleAccumulator.Add(_style, StyleField.ActiveFontColor, value, StyleValue.FromColor); }
public Color? FocusFontColor { init => _style = StyleAccumulator.Add(_style, StyleField.FocusFontColor, value, StyleValue.FromColor); }
public int? TransitionMs { init => _style = StyleAccumulator.Add(_style, StyleField.TransitionMs, value); }
}
// <auto-generated />
// Generated by tools/StyleFacadeEmit. Do not edit by hand.
// Source of truth: tools/StyleFacadeEmit/style-manifest.json
using Sandbox;
using Sandbox.Rendering;
using Sandbox.UI;
namespace Goo;
public readonly partial record struct TextEntry
{
public FlexDirection? FlexDirection { init => _style = StyleAccumulator.Add(_style, StyleField.FlexDirection, value, StyleValue.FromFlexDirection); }
public Justify? JustifyContent { init => _style = StyleAccumulator.Add(_style, StyleField.JustifyContent, value, StyleValue.FromJustify); }
public Align? AlignItems { init => _style = StyleAccumulator.Add(_style, StyleField.AlignItems, value, StyleValue.FromAlign); }
public DisplayMode? Display { init => _style = StyleAccumulator.Add(_style, StyleField.Display, value, StyleValue.FromDisplay); }
public Length? Width { init => _style = StyleAccumulator.Add(_style, StyleField.Width, value); }
public Length? Height { init => _style = StyleAccumulator.Add(_style, StyleField.Height, value); }
public Length? Padding { init => _style = StyleAccumulator.Add(_style, StyleField.Padding, value); }
public Length? PaddingLeft { init => _style = StyleAccumulator.Add(_style, StyleField.PaddingLeft, value); }
public Length? PaddingTop { init => _style = StyleAccumulator.Add(_style, StyleField.PaddingTop, value); }
public Length? PaddingRight { init => _style = StyleAccumulator.Add(_style, StyleField.PaddingRight, value); }
public Length? PaddingBottom { init => _style = StyleAccumulator.Add(_style, StyleField.PaddingBottom, value); }
public Length? Margin { init => _style = StyleAccumulator.Add(_style, StyleField.Margin, value); }
public Length? MarginLeft { init => _style = StyleAccumulator.Add(_style, StyleField.MarginLeft, value); }
public Length? MarginTop { init => _style = StyleAccumulator.Add(_style, StyleField.MarginTop, value); }
public Length? MarginRight { init => _style = StyleAccumulator.Add(_style, StyleField.MarginRight, value); }
public Length? MarginBottom { init => _style = StyleAccumulator.Add(_style, StyleField.MarginBottom, value); }
public Length? Gap { init => _style = StyleAccumulator.Add(_style, StyleField.Gap, value); }
public Length? RowGap { init => _style = StyleAccumulator.Add(_style, StyleField.RowGap, value); }
public Length? ColumnGap { init => _style = StyleAccumulator.Add(_style, StyleField.ColumnGap, value); }
public Color? BackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundColor, value, StyleValue.FromColor); }
public Length? BorderRadius { init => _style = StyleAccumulator.Add(_style, StyleField.BorderRadius, value); }
public Length? BorderTopLeftRadius { init => _style = StyleAccumulator.Add(_style, StyleField.BorderTopLeftRadius, value); }
public Length? BorderTopRightRadius { init => _style = StyleAccumulator.Add(_style, StyleField.BorderTopRightRadius, value); }
public Length? BorderBottomRightRadius { init => _style = StyleAccumulator.Add(_style, StyleField.BorderBottomRightRadius, value); }
public Length? BorderBottomLeftRadius { init => _style = StyleAccumulator.Add(_style, StyleField.BorderBottomLeftRadius, value); }
public Align? AlignContent { init => _style = StyleAccumulator.Add(_style, StyleField.AlignContent, value, StyleValue.FromAlign); }
public Align? AlignSelf { init => _style = StyleAccumulator.Add(_style, StyleField.AlignSelf, value, StyleValue.FromAlign); }
public float? AspectRatio { init => _style = StyleAccumulator.Add(_style, StyleField.AspectRatio, value); }
public Length? BackdropFilterBlur { init => _style = StyleAccumulator.Add(_style, StyleField.BackdropFilterBlur, value); }
public Length? BackdropFilterBrightness { init => _style = StyleAccumulator.Add(_style, StyleField.BackdropFilterBrightness, value); }
public Length? BackdropFilterContrast { init => _style = StyleAccumulator.Add(_style, StyleField.BackdropFilterContrast, value); }
public Length? BackdropFilterHueRotate { init => _style = StyleAccumulator.Add(_style, StyleField.BackdropFilterHueRotate, value); }
public Length? BackdropFilterInvert { init => _style = StyleAccumulator.Add(_style, StyleField.BackdropFilterInvert, value); }
public Length? BackdropFilterSaturate { init => _style = StyleAccumulator.Add(_style, StyleField.BackdropFilterSaturate, value); }
public Length? BackdropFilterSepia { init => _style = StyleAccumulator.Add(_style, StyleField.BackdropFilterSepia, value); }
public Length? BackgroundAngle { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundAngle, value); }
public string? BackgroundBlendMode { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundBlendMode, value); }
public Texture? BackgroundImage { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundImage, value); }
public bool? BackgroundPlaybackPaused { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundPlaybackPaused, value); }
public Length? BackgroundPositionX { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundPositionX, value); }
public Length? BackgroundPositionY { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundPositionY, value); }
public BackgroundRepeat? BackgroundRepeat { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundRepeat, value, StyleValue.FromBackgroundRepeat); }
public Length? BackgroundSizeX { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundSizeX, value); }
public Length? BackgroundSizeY { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundSizeY, value); }
public Color? BackgroundTint { init => _style = StyleAccumulator.Add(_style, StyleField.BackgroundTint, value, StyleValue.FromColor); }
public Color? BorderBottomColor { init => _style = StyleAccumulator.Add(_style, StyleField.BorderBottomColor, value, StyleValue.FromColor); }
public Color? BorderColor { init => _style = StyleAccumulator.Add(_style, StyleField.BorderColor, value, StyleValue.FromColor); }
public Color? BorderLeftColor { init => _style = StyleAccumulator.Add(_style, StyleField.BorderLeftColor, value, StyleValue.FromColor); }
public Color? BorderRightColor { init => _style = StyleAccumulator.Add(_style, StyleField.BorderRightColor, value, StyleValue.FromColor); }
public Color? BorderTopColor { init => _style = StyleAccumulator.Add(_style, StyleField.BorderTopColor, value, StyleValue.FromColor); }
public BorderImageFill? BorderImageFill { init => _style = StyleAccumulator.Add(_style, StyleField.BorderImageFill, value, StyleValue.FromBorderImageFill); }
public BorderImageRepeat? BorderImageRepeat { init => _style = StyleAccumulator.Add(_style, StyleField.BorderImageRepeat, value, StyleValue.FromBorderImageRepeat); }
public Texture? BorderImageSource { init => _style = StyleAccumulator.Add(_style, StyleField.BorderImageSource, value); }
public Color? BorderImageTint { init => _style = StyleAccumulator.Add(_style, StyleField.BorderImageTint, value, StyleValue.FromColor); }
public Length? BorderImageWidthBottom { init => _style = StyleAccumulator.Add(_style, StyleField.BorderImageWidthBottom, value); }
public Length? BorderImageWidthLeft { init => _style = StyleAccumulator.Add(_style, StyleField.BorderImageWidthLeft, value); }
public Length? BorderImageWidthRight { init => _style = StyleAccumulator.Add(_style, StyleField.BorderImageWidthRight, value); }
public Length? BorderImageWidthTop { init => _style = StyleAccumulator.Add(_style, StyleField.BorderImageWidthTop, value); }
public Length? BorderBottomWidth { init => _style = StyleAccumulator.Add(_style, StyleField.BorderBottomWidth, value); }
public Length? BorderLeftWidth { init => _style = StyleAccumulator.Add(_style, StyleField.BorderLeftWidth, value); }
public Length? BorderRightWidth { init => _style = StyleAccumulator.Add(_style, StyleField.BorderRightWidth, value); }
public Length? BorderTopWidth { init => _style = StyleAccumulator.Add(_style, StyleField.BorderTopWidth, value); }
public Length? BorderWidth { init => _style = StyleAccumulator.Add(_style, StyleField.BorderWidth, value); }
public Length? Bottom { init => _style = StyleAccumulator.Add(_style, StyleField.Bottom, value); }
public Length? Left { init => _style = StyleAccumulator.Add(_style, StyleField.Left, value); }
public Length? Right { init => _style = StyleAccumulator.Add(_style, StyleField.Right, value); }
public Length? Top { init => _style = StyleAccumulator.Add(_style, StyleField.Top, value); }
public Color? CaretColor { init => _style = StyleAccumulator.Add(_style, StyleField.CaretColor, value, StyleValue.FromColor); }
public string? Cursor { init => _style = StyleAccumulator.Add(_style, StyleField.Cursor, value); }
public Length? FilterBlur { init => _style = StyleAccumulator.Add(_style, StyleField.FilterBlur, value); }
public Color? FilterBorderColor { init => _style = StyleAccumulator.Add(_style, StyleField.FilterBorderColor, value, StyleValue.FromColor); }
public Length? FilterBorderWidth { init => _style = StyleAccumulator.Add(_style, StyleField.FilterBorderWidth, value); }
public Length? FilterBrightness { init => _style = StyleAccumulator.Add(_style, StyleField.FilterBrightness, value); }
public Length? FilterContrast { init => _style = StyleAccumulator.Add(_style, StyleField.FilterContrast, value); }
public Length? FilterHueRotate { init => _style = StyleAccumulator.Add(_style, StyleField.FilterHueRotate, value); }
public Length? FilterInvert { init => _style = StyleAccumulator.Add(_style, StyleField.FilterInvert, value); }
public Length? FilterSaturate { init => _style = StyleAccumulator.Add(_style, StyleField.FilterSaturate, value); }
public Length? FilterSepia { init => _style = StyleAccumulator.Add(_style, StyleField.FilterSepia, value); }
public Color? FilterTint { init => _style = StyleAccumulator.Add(_style, StyleField.FilterTint, value, StyleValue.FromColor); }
public Length? FlexBasis { init => _style = StyleAccumulator.Add(_style, StyleField.FlexBasis, value); }
public float? FlexGrow { init => _style = StyleAccumulator.Add(_style, StyleField.FlexGrow, value); }
public float? FlexShrink { init => _style = StyleAccumulator.Add(_style, StyleField.FlexShrink, value); }
public Wrap? FlexWrap { init => _style = StyleAccumulator.Add(_style, StyleField.FlexWrap, value, StyleValue.FromWrap); }
public Color? FontColor { init => _style = StyleAccumulator.Add(_style, StyleField.FontColor, value, StyleValue.FromColor); }
public string? FontFamily { init => _style = StyleAccumulator.Add(_style, StyleField.FontFamily, value); }
public Length? FontSize { init => _style = StyleAccumulator.Add(_style, StyleField.FontSize, value); }
public FontSmooth? FontSmooth { init => _style = StyleAccumulator.Add(_style, StyleField.FontSmooth, value, StyleValue.FromFontSmooth); }
public FontStyle? FontStyle { init => _style = StyleAccumulator.Add(_style, StyleField.FontStyle, value, StyleValue.FromFontStyle); }
public FontVariantNumeric? FontVariantNumeric { init => _style = StyleAccumulator.Add(_style, StyleField.FontVariantNumeric, value, StyleValue.FromFontVariantNumeric); }
public int? FontWeight { init => _style = StyleAccumulator.Add(_style, StyleField.FontWeight, value); }
public ImageRendering? ImageRendering { init => _style = StyleAccumulator.Add(_style, StyleField.ImageRendering, value, StyleValue.FromImageRendering); }
public Length? LetterSpacing { init => _style = StyleAccumulator.Add(_style, StyleField.LetterSpacing, value); }
public Length? LineHeight { init => _style = StyleAccumulator.Add(_style, StyleField.LineHeight, value); }
public Length? MaskAngle { init => _style = StyleAccumulator.Add(_style, StyleField.MaskAngle, value); }
public Texture? MaskImage { init => _style = StyleAccumulator.Add(_style, StyleField.MaskImage, value); }
public MaskMode? MaskMode { init => _style = StyleAccumulator.Add(_style, StyleField.MaskMode, value, StyleValue.FromMaskMode); }
public Length? MaskPositionX { init => _style = StyleAccumulator.Add(_style, StyleField.MaskPositionX, value); }
public Length? MaskPositionY { init => _style = StyleAccumulator.Add(_style, StyleField.MaskPositionY, value); }
public BackgroundRepeat? MaskRepeat { init => _style = StyleAccumulator.Add(_style, StyleField.MaskRepeat, value, StyleValue.FromBackgroundRepeat); }
public MaskScope? MaskScope { init => _style = StyleAccumulator.Add(_style, StyleField.MaskScope, value, StyleValue.FromMaskScope); }
public Length? MaskSizeX { init => _style = StyleAccumulator.Add(_style, StyleField.MaskSizeX, value); }
public Length? MaskSizeY { init => _style = StyleAccumulator.Add(_style, StyleField.MaskSizeY, value); }
public Length? MaxHeight { init => _style = StyleAccumulator.Add(_style, StyleField.MaxHeight, value); }
public Length? MaxWidth { init => _style = StyleAccumulator.Add(_style, StyleField.MaxWidth, value); }
public Length? MinHeight { init => _style = StyleAccumulator.Add(_style, StyleField.MinHeight, value); }
public Length? MinWidth { init => _style = StyleAccumulator.Add(_style, StyleField.MinWidth, value); }
public string? MixBlendMode { init => _style = StyleAccumulator.Add(_style, StyleField.MixBlendMode, value); }
public float? Opacity { init => _style = StyleAccumulator.Add(_style, StyleField.Opacity, value); }
public int? Order { init => _style = StyleAccumulator.Add(_style, StyleField.Order, value); }
public Color? OutlineColor { init => _style = StyleAccumulator.Add(_style, StyleField.OutlineColor, value, StyleValue.FromColor); }
public Length? OutlineOffset { init => _style = StyleAccumulator.Add(_style, StyleField.OutlineOffset, value); }
public Length? OutlineWidth { init => _style = StyleAccumulator.Add(_style, StyleField.OutlineWidth, value); }
public OverflowMode? Overflow { init => _style = StyleAccumulator.Add(_style, StyleField.Overflow, value, StyleValue.FromOverflowMode); }
public OverflowMode? OverflowX { init => _style = StyleAccumulator.Add(_style, StyleField.OverflowX, value, StyleValue.FromOverflowMode); }
public OverflowMode? OverflowY { init => _style = StyleAccumulator.Add(_style, StyleField.OverflowY, value, StyleValue.FromOverflowMode); }
public Length? PerspectiveOriginX { init => _style = StyleAccumulator.Add(_style, StyleField.PerspectiveOriginX, value); }
public Length? PerspectiveOriginY { init => _style = StyleAccumulator.Add(_style, StyleField.PerspectiveOriginY, value); }
public PointerEvents? PointerEvents { init => _style = StyleAccumulator.Add(_style, StyleField.PointerEvents, value, StyleValue.FromPointerEvents); }
public PositionMode? Position { init => _style = StyleAccumulator.Add(_style, StyleField.Position, value, StyleValue.FromPositionMode); }
public string? SoundIn { init => _style = StyleAccumulator.Add(_style, StyleField.SoundIn, value); }
public string? SoundOut { init => _style = StyleAccumulator.Add(_style, StyleField.SoundOut, value); }
public TextAlign? TextAlign { init => _style = StyleAccumulator.Add(_style, StyleField.TextAlign, value, StyleValue.FromTextAlign); }
public Length? TextBackgroundAngle { init => _style = StyleAccumulator.Add(_style, StyleField.TextBackgroundAngle, value); }
public Color? TextDecorationColor { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationColor, value, StyleValue.FromColor); }
public TextDecoration? TextDecorationLine { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationLine, value, StyleValue.FromTextDecoration); }
public TextSkipInk? TextDecorationSkipInk { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationSkipInk, value, StyleValue.FromTextSkipInk); }
public TextDecorationStyle? TextDecorationStyle { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationStyle, value, StyleValue.FromTextDecorationStyle); }
public Length? TextDecorationThickness { init => _style = StyleAccumulator.Add(_style, StyleField.TextDecorationThickness, value); }
public FilterMode? TextFilter { init => _style = StyleAccumulator.Add(_style, StyleField.TextFilter, value, StyleValue.FromFilterMode); }
public Length? TextLineThroughOffset { init => _style = StyleAccumulator.Add(_style, StyleField.TextLineThroughOffset, value); }
public TextOverflow? TextOverflow { init => _style = StyleAccumulator.Add(_style, StyleField.TextOverflow, value, StyleValue.FromTextOverflow); }
public Length? TextOverlineOffset { init => _style = StyleAccumulator.Add(_style, StyleField.TextOverlineOffset, value); }
public Color? TextStrokeColor { init => _style = StyleAccumulator.Add(_style, StyleField.TextStrokeColor, value, StyleValue.FromColor); }
public Length? TextStrokeWidth { init => _style = StyleAccumulator.Add(_style, StyleField.TextStrokeWidth, value); }
public TextTransform? TextTransform { init => _style = StyleAccumulator.Add(_style, StyleField.TextTransform, value, StyleValue.FromTextTransform); }
public Length? TextUnderlineOffset { init => _style = StyleAccumulator.Add(_style, StyleField.TextUnderlineOffset, value); }
public Goo.PanelTransform? Transform { init => _style = StyleAccumulator.Add(_style, StyleField.Transform, value, StyleValue.FromPanelTransform); }
public Length? TransformOriginX { init => _style = StyleAccumulator.Add(_style, StyleField.TransformOriginX, value); }
public Length? TransformOriginY { init => _style = StyleAccumulator.Add(_style, StyleField.TransformOriginY, value); }
public WhiteSpace? WhiteSpace { init => _style = StyleAccumulator.Add(_style, StyleField.WhiteSpace, value, StyleValue.FromWhiteSpace); }
public WordBreak? WordBreak { init => _style = StyleAccumulator.Add(_style, StyleField.WordBreak, value, StyleValue.FromWordBreak); }
public Length? WordSpacing { init => _style = StyleAccumulator.Add(_style, StyleField.WordSpacing, value); }
public int? ZIndex { init => _style = StyleAccumulator.Add(_style, StyleField.ZIndex, value); }
public Color? HoverBackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.HoverBackgroundColor, value, StyleValue.FromColor); }
public Color? ActiveBackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.ActiveBackgroundColor, value, StyleValue.FromColor); }
public Color? FocusBackgroundColor { init => _style = StyleAccumulator.Add(_style, StyleField.FocusBackgroundColor, value, StyleValue.FromColor); }
public Color? HoverFontColor { init => _style = StyleAccumulator.Add(_style, StyleField.HoverFontColor, value, StyleValue.FromColor); }
public Color? ActiveFontColor { init => _style = StyleAccumulator.Add(_style, StyleField.ActiveFontColor, value, StyleValue.FromColor); }
public Color? FocusFontColor { init => _style = StyleAccumulator.Add(_style, StyleField.FocusFontColor, value, StyleValue.FromColor); }
public int? TransitionMs { init => _style = StyleAccumulator.Add(_style, StyleField.TransitionMs, value); }
}
using Goo;
using Sandbox.UI;
namespace Sandbox;
public class CounterUI : GooPanel<Container>
{
private int _count;
protected override Container Build() => new Container
{
Padding = 16,
Width = 128,
Height = 128,
BackgroundColor = Color.White,
BorderRadius = 12,
FlexDirection = FlexDirection.Row,
Gap = 12,
AlignItems = Align.Center,
Children =
{
new Text(_count.ToString()),
new Container
{
Padding = 8,
BackgroundColor = Color.Orange,
HoverBackgroundColor = Color.Cyan,
BorderRadius = 6,
OnClick = e => { _count++; Rebuild(); },
Children = { new Text("+") },
},
},
};
}using System;
using System.Collections.Generic;
using Sandbox;
namespace Goo.Input;
// Polls a curated key catalog once per frame for rising-edge presses + modifier state. ReemitHeldOnModifierRise re-emits a held key when a modifier rises (so "hold W, press Ctrl" reads as a chord).
public sealed class KeyTracker
{
public bool ReemitHeldOnModifierRise { get; set; } = false;
public ModifierState Modifiers { get; private set; }
public IReadOnlyList<KeyDescriptor> JustPressed => _justPressed;
readonly IReadOnlyList<KeyDescriptor> _catalog;
readonly HashSet<string> _downLastFrame = new();
readonly List<KeyDescriptor> _justPressed = new();
public KeyTracker() : this( KnownKeys.All ) { }
public KeyTracker( IReadOnlyList<KeyDescriptor> catalog )
{
_catalog = catalog;
}
public void Reset()
{
_downLastFrame.Clear();
_justPressed.Clear();
Modifiers = default;
}
static readonly Func<string, bool> s_engineDown = Sandbox.Input.Keyboard.Down;
public void Poll() => Poll( s_engineDown );
// isDown seam exists because engine Input statics throw outside a running engine process.
public void Poll( Func<string, bool> isDown )
{
_justPressed.Clear();
var prev = Modifiers;
for ( int i = 0; i < _catalog.Count; i++ )
{
var d = _catalog[i];
bool down = isDown( d.EngineName );
if ( down && !_downLastFrame.Contains( d.EngineName ) )
_justPressed.Add( d );
if ( down ) _downLastFrame.Add( d.EngineName );
else _downLastFrame.Remove( d.EngineName );
}
Modifiers = new ModifierState(
_downLastFrame.Contains( "ctrl" ),
_downLastFrame.Contains( "shift" ),
_downLastFrame.Contains( "alt" ),
_downLastFrame.Contains( "win" ) );
if ( !ReemitHeldOnModifierRise ) return;
bool modRose = ( Modifiers.Ctrl && !prev.Ctrl ) || ( Modifiers.Shift && !prev.Shift )
|| ( Modifiers.Alt && !prev.Alt ) || ( Modifiers.Meta && !prev.Meta );
if ( !modRose ) return;
for ( int i = 0; i < _catalog.Count; i++ )
{
var d = _catalog[i];
if ( d.Class == KeyClass.Modifier ) continue;
if ( !_downLastFrame.Contains( d.EngineName ) ) continue;
if ( _justPressed.Contains( d ) ) continue;
_justPressed.Add( d );
}
}
/// <summary>True if the named key had a rising edge (just-pressed, not held) this frame; call Poll first.</summary>
public bool Pressed( string engineName )
{
for ( int i = 0; i < _justPressed.Count; i++ )
if ( _justPressed[i].EngineName == engineName ) return true;
return false;
}
}
using System;
using Sandbox;
using Sandbox.UI;
namespace Goo.Internal;
internal sealed class StatefulLabel : Label, IStatefulHost, IStatefulEventHost
{
StateController? _state;
internal Action<MousePanelEvent>? _onClick;
internal Action<MousePanelEvent>? _onRightClick;
internal Action<MousePanelEvent>? _onMiddleClick;
internal Action<MousePanelEvent>? _onMouseEnter;
internal Action<MousePanelEvent>? _onMouseLeave;
internal Action<MousePanelEvent>? _onMouseDown;
internal Action<MousePanelEvent>? _onMouseUp;
internal Action<MousePanelEvent>? _onMouseMove;
internal bool _userSetPointerEvents;
internal Action? _requestRebuild;
public Action? RequestRebuild { set => _requestRebuild = value; }
public void ApplyStateVariants(
Color? baseBg, Color? baseFg,
Color? hoverBg, Color? activeBg, Color? focusBg,
Color? hoverFg, Color? activeFg, Color? focusFg,
int? transitionMs)
{
_state ??= new StateController(this);
_state.ApplyVariants(
baseBg, baseFg,
hoverBg, activeBg, focusBg,
hoverFg, activeFg, focusFg,
transitionMs);
}
public void ClearStateVariants() => _state?.ClearVariants();
public bool HasActiveStateVariants => _state?.HasActiveVariants ?? false;
public void ApplyEvents(in BlobEvents events)
{
_onClick = events.OnClick;
_onRightClick = events.OnRightClick;
_onMiddleClick = events.OnMiddleClick;
_onMouseEnter = events.OnMouseEnter;
_onMouseLeave = events.OnMouseLeave;
_onMouseDown = events.OnMouseDown;
_onMouseUp = events.OnMouseUp;
_onMouseMove = events.OnMouseMove;
}
public bool HasEventHandlers =>
_onClick != null || _onRightClick != null || _onMiddleClick != null || _onMouseEnter != null || _onMouseLeave != null ||
_onMouseDown != null || _onMouseUp != null || _onMouseMove != null;
public bool UserSetPointerEvents
{
get => _userSetPointerEvents;
set => _userSetPointerEvents = value;
}
protected override void OnClick(MousePanelEvent e)
{
base.OnClick(e);
EventDispatch.Fire(_onClick, e, _requestRebuild);
}
protected override void OnRightClick(MousePanelEvent e)
{
base.OnRightClick(e);
EventDispatch.Fire(_onRightClick, e, _requestRebuild);
}
protected override void OnMiddleClick(MousePanelEvent e)
{
base.OnMiddleClick(e);
EventDispatch.Fire(_onMiddleClick, e, _requestRebuild);
}
protected override void OnMouseOver(MousePanelEvent e)
{
base.OnMouseOver(e);
EventDispatch.Fire(_onMouseEnter, e, _requestRebuild);
}
protected override void OnMouseOut(MousePanelEvent e)
{
base.OnMouseOut(e);
EventDispatch.Fire(_onMouseLeave, e, _requestRebuild);
}
protected override void OnMouseDown(MousePanelEvent e)
{
base.OnMouseDown(e);
EventDispatch.Fire(_onMouseDown, e, _requestRebuild);
}
protected override void OnMouseUp(MousePanelEvent e)
{
base.OnMouseUp(e);
EventDispatch.Fire(_onMouseUp, e, _requestRebuild);
}
protected override void OnMouseMove(MousePanelEvent e)
{
base.OnMouseMove(e);
EventDispatch.Fire(_onMouseMove, e, _requestRebuild);
}
}
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.Rendering;
using Sandbox.UI;
namespace Goo;
/// <summary>
/// A custom shader applied to a Blob. Point it at a compiled .shader; it parses the shader
/// source's Attribute() declarations to validate uniform names and to reset uniforms other
/// panels have set (panels share one CommandList attribute namespace), and pushes the uniform
/// bag every frame. Set uniforms with the collection initializer (<c>["Name"] = value</c>);
/// a value may be a literal or a per-frame Func. Subclass and override <see cref="Apply"/>
/// only for bespoke per-frame CPU logic.
/// </summary>
public record ShaderEffect
{
static readonly Dictionary<object, Material> _materialCache = new();
static readonly Dictionary<object, ShaderSchemaInfo?> _schemaCache = new();
// Every uniform name any ShaderEffect has pushed this session, with the last value pushed
// (its runtime type drives the reset conversion). Panels share one CommandList attribute
// namespace, so a uniform set by one panel persists into every later panel's draw; an
// effect that does not set a seen uniform must reset it to its shader's declared default
// or it inherits the other panel's value (view-5u5m). Touched only from Apply (render thread).
static readonly Dictionary<string, object> _seenUniforms = new();
internal sealed class ShaderSchemaInfo
{
public required HashSet<string> Names; // declared attribute names
public required Dictionary<string, (Vector4 Floats, Vector4 Ints)> Defaults;
}
readonly string? _path;
readonly Shader? _shader;
readonly GrabMode _grab;
readonly Dictionary<string, UniformValue> _bag = new();
bool _validated;
/// <summary>For subclasses that supply their own <see cref="Material"/> and <see cref="Apply"/>.</summary>
protected ShaderEffect() { }
/// <summary>Apply the shader at <paramref name="shaderPath"/> (e.g. "shaders/ui_dither.shader").</summary>
public ShaderEffect( string shaderPath, GrabMode grab = GrabMode.None )
{
_path = shaderPath;
_grab = grab;
}
/// <summary>Apply a shader resource (drag-droppable in the inspector).</summary>
public ShaderEffect( Shader shader, GrabMode grab = GrabMode.None )
{
_shader = shader;
_grab = grab;
}
/// <summary>The shader asset path, or null when constructed from a <see cref="T:Sandbox.Shader"/> resource.</summary>
public string? ShaderPath => _path;
/// <summary>How this effect grabs the framebuffer behind its panel.</summary>
public GrabMode Grab => _grab;
/// <summary>Get or set a uniform by its shader attribute name.</summary>
public UniformValue this[string name]
{
get => _bag[name];
set => _bag[name] = value;
}
object CacheKey => (object?)_path ?? _shader!;
/// <summary>The material this effect draws with, cached so it is excluded from record equality.</summary>
public virtual Material Material
{
get
{
var key = CacheKey;
if ( !_materialCache.TryGetValue( key, out var mat ) )
{
mat = _path is not null ? Material.FromShader( _path ) : Material.FromShader( _shader! );
_materialCache[key] = mat;
}
return mat;
}
}
/// <summary>Create the Material now, on the calling thread. Call on the main thread; Draw runs on the render thread and must only read the cache.</summary>
public void Warm() => _ = Material;
/// <summary>Set this effect's shader attributes for the frame, then grab the framebuffer per <see cref="Grab"/>.</summary>
protected internal virtual void Apply( CommandList cl, Rect rect )
{
cl.Attributes.Set( "BoxSize", new Vector2( rect.Width, rect.Height ) );
// Subclass escape hatch: a derived effect calling base.Apply gets only BoxSize.
if ( _path is null && _shader is null ) return;
Validate();
// Reset seen-but-unset uniforms to this shader's declared defaults so another panel's
// attribute writes do not bleed into this draw (view-5u5m).
var schema = SchemaFor( CacheKey );
if ( schema is not null )
{
foreach ( var (name, sample) in _seenUniforms )
{
if ( _bag.ContainsKey( name ) ) continue;
if ( !schema.Defaults.TryGetValue( name, out var def ) ) continue;
if ( ResetValue( sample, def.Floats, def.Ints ) is { } reset )
SetAttribute( cl, name, reset );
}
}
foreach ( var (name, value) in _bag )
{
var resolved = value.Resolve();
SetAttribute( cl, name, resolved );
if ( resolved is not Texture )
_seenUniforms[name] = resolved;
}
switch ( _grab )
{
case GrabMode.Sharp:
cl.Attributes.GrabFrameTexture( "FrameBufferCopyTexture" );
break;
case GrabMode.Blurred:
cl.Attributes.GrabFrameTexture( "FrameBufferCopyTexture", Graphics.DownsampleMethod.GaussianBlur );
break;
}
}
static void SetAttribute( CommandList cl, string name, object value )
{
switch ( value )
{
case float f: cl.Attributes.Set( name, f ); break;
case bool b: cl.Attributes.Set( name, b ); break;
case Vector2 v: cl.Attributes.Set( name, v ); break;
case Vector3 v: cl.Attributes.Set( name, v ); break;
case Vector4 v: cl.Attributes.Set( name, v ); break;
case Color c: cl.Attributes.Set( name, c ); break;
case Texture t: cl.Attributes.Set( name, t ); break;
}
}
// Reads the shader's declared attribute names once per instance and warns on uniform names
// the shader does not declare. Skipped silently when the source is unavailable.
void Validate()
{
if ( _validated ) return;
_validated = true;
var valid = SchemaFor( CacheKey )?.Names;
if ( valid is null || valid.Count == 0 ) return;
foreach ( var name in _bag.Keys )
if ( !valid.Contains( name ) )
Sandbox.Internal.GlobalSystemNamespace.Log.Warning(
$"ShaderEffect for \"{_path ?? _shader?.ResourcePath}\": uniform \"{name}\" is not declared by the shader " +
$"(valid: {string.Join( ", ", valid )}). Ignored." );
}
// Names + defaults come from parsing the .shader SOURCE, which ships with the project and
// declares every Attribute() with its Default(). The engine's Shader.Schema is editor-only
// plumbing: at game runtime it throws ("Load must be called on the main thread!" — Apply
// runs on the render thread) so it is not consulted at all (view-5u5m probe, 2026-06-11).
// Null when the source is unreadable (e.g. unit tests, published build without raw
// .shader files); reset and validation both skip then.
static ShaderSchemaInfo? SchemaFor( object key )
{
if ( _schemaCache.TryGetValue( key, out var info ) ) return info;
info = null;
if ( (key as string ?? (key as Shader)?.ResourcePath) is { } srcPath )
{
try
{
info = ParseShaderSource( FileSystem.Mounted.ReadAllText( srcPath ) );
}
catch
{
info = null;
}
}
_schemaCache[key] = info;
return info;
}
static readonly System.Text.RegularExpressions.Regex _declRegex = new(
@"\b(?<type>float[234]?|bool|int|Texture2D)\s+\w+\s*<(?<block>[^>]*)>",
System.Text.RegularExpressions.RegexOptions.Compiled );
static readonly System.Text.RegularExpressions.Regex _attrRegex = new(
@"Attribute\s*\(\s*""(?<name>[^""]+)""\s*\)",
System.Text.RegularExpressions.RegexOptions.Compiled );
static readonly System.Text.RegularExpressions.Regex _defaultRegex = new(
@"Default[234]?\s*\(\s*(?<args>[^)]*)\)",
System.Text.RegularExpressions.RegexOptions.Compiled );
// Pure: extracts Attribute()-bound uniform declarations and their Default() values from
// .shader source. Defaults are stored in both vector slots so ResetValue can convert by the
// bled value's runtime type. Texture declarations contribute a name (for validation) but no
// default. Only the main file is scanned; #include'd uniforms (BoxSize, DpiScale) are
// framework-managed and excluded anyway. Missing Default() = zeros, matching the engine's
// unset-attribute behavior.
internal static ShaderSchemaInfo? ParseShaderSource( string source )
{
var names = new HashSet<string>();
var defaults = new Dictionary<string, (Vector4 Floats, Vector4 Ints)>();
foreach ( System.Text.RegularExpressions.Match m in _declRegex.Matches( source ) )
{
var block = m.Groups["block"].Value;
var attr = _attrRegex.Match( block );
if ( !attr.Success ) continue;
var name = attr.Groups["name"].Value;
names.Add( name );
if ( name is "BoxSize" or "DpiScale" ) continue; // framework-managed per draw
if ( m.Groups["type"].Value == "Texture2D" ) continue; // no resettable default
Vector4 v = default;
var def = _defaultRegex.Match( block );
if ( def.Success )
{
var parts = def.Groups["args"].Value.Split( ',' );
for ( int i = 0; i < parts.Length && i < 4; i++ )
{
if ( !float.TryParse( parts[i].Trim(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var f ) ) continue;
switch ( i )
{
case 0: v.x = f; break;
case 1: v.y = f; break;
case 2: v.z = f; break;
case 3: v.w = f; break;
}
}
}
defaults[name] = (v, v);
}
return names.Count > 0 ? new ShaderSchemaInfo { Names = names, Defaults = defaults } : null;
}
// Pure: converts a shader's declared default (schema FloatDefault/IntDefault vectors) to the
// runtime type of the value that bled in, so the reset lands in the same attribute slot type.
// Null = no safe reset (unsupported type; textures are never recorded so never reach this).
internal static object? ResetValue( object sample, Vector4 floats, Vector4 ints ) => sample switch
{
float => floats.x,
bool => ints.x != 0f,
Vector2 => new Vector2( floats.x, floats.y ),
Vector3 => new Vector3( floats.x, floats.y, floats.z ),
Color => new Color( floats.x, floats.y, floats.z, floats.w ),
Vector4 => floats,
_ => null,
};
public virtual bool Equals( ShaderEffect? other )
{
if ( other is null ) return false;
if ( ReferenceEquals( this, other ) ) return true;
if ( EqualityContract != other.EqualityContract ) return false;
return _path == other._path
&& ReferenceEquals( _shader, other._shader )
&& _grab == other._grab
&& BagEquals( _bag, other._bag );
}
public override int GetHashCode()
{
var hc = new HashCode();
hc.Add( EqualityContract );
hc.Add( _path );
hc.Add( _grab );
hc.Add( _bag.Count );
return hc.ToHashCode();
}
static bool BagEquals( Dictionary<string, UniformValue> a, Dictionary<string, UniformValue> b )
{
if ( a.Count != b.Count ) return false;
foreach ( var (k, v) in a )
if ( !b.TryGetValue( k, out var bv ) || !v.Equals( bv ) ) return false;
return true;
}
}
using Sandbox.Citizen;
namespace ShrimpleCharacterController;
[Hide]
public sealed class ShrimpleFlyer : Component
{
[RequireComponent]
public ShrimpleCharacterController Controller { get; set; }
public GameObject Camera { get; set; }
[Property]
[Range(400f, 1600f)]
public float WalkSpeed { get; set; } = 800f;
[Property]
[Range(800f, 4000f)]
public float RunSpeed { get; set; } = 2400f;
public Angles EyeAngles { get; set; }
protected override void OnStart()
{
base.OnStart();
Camera = new GameObject(true, "Camera");
Camera.SetParent(GameObject);
var cameraComponent = Camera.Components.Create<CameraComponent>();
cameraComponent.ZFar = 32768f;
}
protected override void OnFixedUpdate()
{
base.OnFixedUpdate();
var isDucking = Input.Down("Duck");
var isRunning = Input.Down("Run");
var ascending = Input.Down("Jump") ? 1f : 0f;
var descending = Input.Down("Duck") ? -1f : 0f;
var wishSpeed = isRunning ? RunSpeed : WalkSpeed;
var wishDirection = (Input.AnalogMove + Vector3.Up * (ascending + descending)).Normal * EyeAngles.ToRotation();
Controller.WishVelocity = wishDirection * wishSpeed;
Controller.Move();
}
protected override void OnUpdate()
{
base.OnUpdate();
EyeAngles += Input.AnalogLook;
EyeAngles = EyeAngles.WithPitch(MathX.Clamp(EyeAngles.pitch, -40f, 40f));
var cameraOffset = Vector3.Up * 70f + Vector3.Backward * 760f;
Camera.WorldRotation = EyeAngles.ToRotation();
Camera.LocalPosition = cameraOffset * Camera.WorldRotation;
}
}
using System;
using System.Linq;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RoadIntersectionComponent
{
[Property, Feature("Terrain", Icon = "landscape", Tint = EditorTint.Green), Hide]
private Terrain TerrainTarget { get; set; }
[Property, Feature("Terrain"), Range(0f, 2000f)]
public float TerrainFalloffRadius { get; set; } = 500f;
[Property, Feature("Terrain"), Range(-10f, 10f)]
public float TerrainHeightOffset { get; set; } = 0f;
[Property, Feature("Terrain"), Range(0f, 100f)]
public float TerrainRoadInset { get; set; } = 10f;
[Property, Feature("Terrain"), Group("Texture"), Range(100f, 1000f)]
public float TerrainEdgeRadius { get; set; } = 500f;
[Property, Feature("Terrain"), Group("Texture")]
public TerrainTextureLayer TerrainTargetLayer { get; set; } = TerrainTextureLayer.Overlay;
[Property, Feature("Terrain"), Group("Texture"), Range(0f, 1f)]
public float TerrainTextureNoise { get; set; } = 0.2f;
[Property, Feature("Terrain"), Group("Texture")]
public TerrainMaterial[] TerrainEdgeMaterials { get; set; } = Array.Empty<TerrainMaterial>();
[Property, Feature("Terrain"), Group("Texture")]
public Gradient TerrainEdgeBlendGradient = new Gradient(
new Gradient.ColorFrame(0, Color.White),
new Gradient.ColorFrame(1, Color.White.WithAlpha(0f))
);
[Button("Apply to the Ground"), Feature("Terrain")]
private void ApplyTerrainToGround()
{
if (!Scene.IsEditor)
return;
AdaptTerrainToIntersection();
}
public void AdaptTerrainToIntersection()
{
if (!TerrainTarget.IsValid())
{
// Always take the closest terrain
TerrainTarget = Scene.GetAllComponents<Terrain>().OrderBy(x => x.WorldPosition.DistanceSquared(WorldPosition)).FirstOrDefault();
}
if (!TerrainTarget.IsValid())
{
Log.Warning("RoadTool: No Terrain found in scene.");
return;
}
var storage = TerrainTarget.Storage;
if (storage == null || storage.HeightMap == null) return;
// 1. Setup Parameters
int resolution = storage.Resolution;
float terrainSize = storage.TerrainSize;
float terrainMaxHeight = storage.TerrainHeight;
float halfSize = terrainSize * 0.5f;
// Calculate bounds including falloff
float boundSize = (Shape == IntersectionShape.Rectangle ? Math.Max(Width, Length) * 0.5f : Radius) + TerrainFalloffRadius;
BBox worldBounds = new BBox(WorldPosition - new Vector3(boundSize), WorldPosition + new Vector3(boundSize));
var heightMap = storage.HeightMap;
// Capture initial state for Undo
bool hasModified = false;
// Initialize buffers for height calculation
var updatedHeights = new float[heightMap.Length];
var bestDistance = new float[heightMap.Length];
for (int i = 0; i < heightMap.Length; i++)
{
// Decode: Map [0..1] ushort to [0 .. MaxHeight] to match RoadComponent
updatedHeights[i] = (heightMap[i] / (float)ushort.MaxValue) * terrainMaxHeight;
bestDistance[i] = float.MaxValue;
}
BuildRectangleExitCorridors();
// 2. Grid Traversal
for (int ix = 0; ix < resolution; ix++)
{
for (int iy = 0; iy < resolution; iy++)
{
// 1. Adaptive coordinate detection (Center vs Corner) matching RoadComponent
float nodeLocalX_corner = (ix / (float)(resolution - 1)) * terrainSize;
float nodeLocalY_corner = (iy / (float)(resolution - 1)) * terrainSize;
float nodeLocalX = nodeLocalX_corner;
float nodeLocalY = nodeLocalY_corner;
// Check if the intersection is in the centered range
var checkPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);
if (checkPos.x < 0f || checkPos.x > terrainSize || checkPos.y < 0f || checkPos.y > terrainSize)
{
nodeLocalX = nodeLocalX_corner - halfSize;
nodeLocalY = nodeLocalY_corner - halfSize;
}
Vector3 pixelWorldPos = TerrainTarget.Transform.World.PointToWorld(new Vector3(nodeLocalX, nodeLocalY, 0));
if (!worldBounds.Contains(pixelWorldPos)) continue;
int index = iy * resolution + ix;
// 2. Distance to intersection shape
Vector3 relativePos = WorldTransform.PointToLocal(pixelWorldPos);
float distance = GetDistanceToIntersectionShape(relativePos.WithZ(0));
if (distance > TerrainFalloffRadius) continue;
// 3. Target height matching RoadComponent (0 to MaxHeight range)
Vector3 intersectionLocalPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);
float roadSurfaceHeight = Math.Clamp(intersectionLocalPos.z + TerrainHeightOffset, 0f, terrainMaxHeight);
float roadInsetHeight = Math.Clamp(roadSurfaceHeight - TerrainRoadInset, 0f, terrainMaxHeight);
float currentPixelHeight = (heightMap[index] / (float)ushort.MaxValue) * terrainMaxHeight;
float candidateHeight;
if (distance <= 0) // Inside the intersection — sink terrain below road to prevent Z-fighting
{
candidateHeight = roadInsetHeight;
}
else if (SidewalkWidth > 0f && distance <= SidewalkWidth) // Sidewalk ring — flush with road surface
{
candidateHeight = roadSurfaceHeight;
}
else // Falloff — blend from surface/inset back to original terrain
{
float transitionStart = SidewalkWidth > 0f ? SidewalkWidth : 0f;
float transitionBaseHeight = SidewalkWidth > 0f ? roadSurfaceHeight : roadInsetHeight;
float t = Math.Clamp((distance - transitionStart) / TerrainFalloffRadius, 0f, 1f);
float smoothT = t * t * (3f - 2f * t);
candidateHeight = MathX.Lerp(transitionBaseHeight, currentPixelHeight, smoothT);
}
if (distance < bestDistance[index])
{
bestDistance[index] = distance;
updatedHeights[index] = candidateHeight;
hasModified = true;
}
}
}
if (hasModified)
{
// 4. Final encoding to ushort (Mapping back to 0..1 without the 0.5 offset)
for (int i = 0; i < heightMap.Length; i++)
{
heightMap[i] = (ushort)MathF.Round(Math.Clamp(updatedHeights[i], 0f, terrainMaxHeight) / terrainMaxHeight * ushort.MaxValue);
}
storage.HeightMap = heightMap;
storage.StateHasChanged();
TerrainTarget.Create();
}
}
public void PaintTerrainToIntersection()
{
if (!TerrainTarget.IsValid() || TerrainEdgeMaterials == null || TerrainEdgeMaterials.Length == 0) return;
var storage = TerrainTarget.Storage;
if (storage == null || storage.ControlMap == null) return;
int resolution = storage.Resolution;
float terrainSize = storage.TerrainSize;
float halfSize = terrainSize * 0.5f;
// Identify all material indices in the terrain storage
bool materialsAdded = false;
var materialIndices = new int[TerrainEdgeMaterials.Length];
for (int m = 0; m < TerrainEdgeMaterials.Length; m++)
{
if (TerrainEdgeMaterials[m] == null) continue;
int idx = storage.Materials.IndexOf(TerrainEdgeMaterials[m]);
if (idx == -1)
{
storage.Materials.Add(TerrainEdgeMaterials[m]);
idx = storage.Materials.Count - 1;
materialsAdded = true;
}
if (idx > 31)
{
Log.Error($"RoadTool: Terrain has too many materials ({idx}). Material '{TerrainEdgeMaterials[m].ResourceName}' cannot be painted.");
idx = 0;
}
materialIndices[m] = idx;
}
if (materialsAdded)
{
storage.StateHasChanged();
TerrainTarget.Create();
}
float boundSize = (Shape == IntersectionShape.Rectangle ? Math.Max(Width, Length) * 0.5f : Radius) + TerrainEdgeRadius; // This line is unchanged
BBox worldBounds = new BBox(WorldPosition - new Vector3(boundSize), WorldPosition + new Vector3(boundSize)); // This line is unchanged
BuildRectangleExitCorridors();
var controlMap = storage.ControlMap;
bool hasModified = false;
for (int ix = 0; ix < resolution; ix++)
{
for (int iy = 0; iy < resolution; iy++)
{
float nodeLocalX = (ix / (float)(resolution - 1)) * terrainSize;
float nodeLocalY = (iy / (float)(resolution - 1)) * terrainSize;
var checkPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);
if (checkPos.x < 0f || checkPos.x > terrainSize || checkPos.y < 0f || checkPos.y > terrainSize)
{
nodeLocalX -= halfSize;
nodeLocalY -= halfSize;
}
Vector3 pixelWorldPos = TerrainTarget.Transform.World.PointToWorld(new Vector3(nodeLocalX, nodeLocalY, 0));
if (!worldBounds.Contains(pixelWorldPos)) continue;
Vector3 relativePos = WorldTransform.PointToLocal(pixelWorldPos);
float distance = GetDistanceToIntersectionShape(relativePos.WithZ(0));
if (distance > TerrainEdgeRadius) continue;
int index = iy * resolution + ix;
float t = Math.Clamp(distance / TerrainEdgeRadius, 0f, 1f);
float blendStrength = TerrainEdgeBlendGradient.Evaluate(t).a;
if (blendStrength > 0.01f)
{
// Add deterministic noise to blend textures together (Dithering)
float pixelNoise = ((float)((index * 1103515245 + 12345) & 0x7FFFFFFF) / 0x7FFFFFFF) * TerrainTextureNoise - (TerrainTextureNoise * 0.5f);
float noisyT = Math.Clamp(t + pixelNoise, 0f, 1f);
float noisyDistance = distance + (pixelNoise * TerrainEdgeRadius);
int materialIndex;
if (noisyDistance <= 0)
{
materialIndex = materialIndices[0];
}
else
{
int edgeMatCount = materialIndices.Length - 1;
// Using noisyT for index selection
int edgeIdx = edgeMatCount > 0 ? Math.Clamp((int)(noisyT * edgeMatCount), 0, edgeMatCount - 1) + 1 : 0;
materialIndex = materialIndices[edgeIdx];
}
uint packed = controlMap[index];
var mat = new CompactTerrainMaterial(packed);
if (TerrainTargetLayer == TerrainTextureLayer.Base)
{
mat.BaseTextureId = (byte)materialIndex;
mat.BlendFactor = (byte)MathX.Lerp(mat.BlendFactor, 0, blendStrength);
}
else
{
// Otherwise, we place it in Overlay and increase the BlendFactor to display it
mat.OverlayTextureId = (byte)materialIndex;
mat.BlendFactor = (byte)MathX.Lerp(mat.BlendFactor, 255, blendStrength);
}
controlMap[index] = mat.Packed;
hasModified = true;
}
}
}
if (hasModified)
{
storage.ControlMap = controlMap;
storage.StateHasChanged();
TerrainTarget.SyncGPUTexture();
}
}
// Per-opening exit corridors in local space, rebuilt once per flatten so the per-pixel distance test stays cheap.
// Each entry is an opening's road-edge centre, its outward direction, its lateral direction, and half its width.
private (Vector3 Center, Vector3 Outward, Vector3 Lateral, float Half)[] m_RectangleExitCorridors = Array.Empty<(Vector3, Vector3, Vector3, float)>();
private void BuildRectangleExitCorridors()
{
if (Shape != IntersectionShape.Rectangle)
{
m_RectangleExitCorridors = Array.Empty<(Vector3, Vector3, Vector3, float)>();
return;
}
EnsureRectangleExits();
m_RectangleExitCorridors = Exits
.Where(exit => exit != null)
.Select(exit =>
{
Transform t = GetRectangleExitLocalTransform(exit.Side, false, exit.Offset);
return (t.Position, t.Rotation.Forward, t.Rotation.Right, exit.Width * 0.5f);
})
.ToArray();
}
private float GetDistanceToIntersectionShape(Vector3 localPixelPos)
{
if (Shape == IntersectionShape.Rectangle)
{
float hl = Length * 0.5f;
float hw = Width * 0.5f;
float dx = MathF.Max(MathF.Abs(localPixelPos.x) - hl, 0);
float dy = MathF.Max(MathF.Abs(localPixelPos.y) - hw, 0);
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Treat each open exit's corridor as inside, so terrain doesn't poke up through a road opening. A corridor is
// the band beyond an opening's road edge (outward) and within that opening's width (lateral) — one per opening.
if (dist > 0)
{
foreach (var corridor in m_RectangleExitCorridors)
{
Vector3 toPixel = localPixelPos - corridor.Center;
if (Vector3.Dot(toPixel, corridor.Outward) >= 0.0f && MathF.Abs(Vector3.Dot(toPixel, corridor.Lateral)) <= corridor.Half)
return 0;
}
}
return dist;
}
// Circle
float radDist = MathF.Max(localPixelPos.WithZ(0).Length - Radius, 0);
if (radDist > 0 && CircleExits != null && CircleExits.Length > 0)
{
Vector3 pixelDir = localPixelPos.WithZ(0);
if (pixelDir.LengthSquared > 0.0001f)
pixelDir = pixelDir.Normal;
foreach (var exit in CircleExits)
{
// Use dot product to stay independent of angle conventions
Vector3 exitDir = Rotation.FromYaw(exit.AngleDegrees).Forward;
float cosHalfAngle = MathF.Cos(MathF.Atan(exit.RoadWidth / Radius));
if (Vector3.Dot(pixelDir, exitDir) >= cosHalfAngle)
return 0;
}
}
return radDist;
}
}
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RoadIntersectionComponent
{
/// <summary>
/// A single drivable exit of an intersection, expressed in world space.
/// <see cref="Transform"/>.Forward points outward (away from the intersection), matching the snap targets.
/// </summary>
public readonly struct TrafficExit
{
public Transform Transform { get; init; }
public float RoadWidth { get; init; }
}
/// <summary>
/// When enabled, this intersection is ignored by the traffic system: vehicles will not route through it.
/// </summary>
[Property, Feature("General"), Category("Traffic"), Order(2)] public bool ExcludeTraffic { get; set; } = false;
/// <summary>Speed limit for traffic crossing this intersection, in km/h.</summary>
[Property, Feature("General"), Category("Traffic"), Order(2), Range(5.0f, 130.0f)] public float SpeedLimit { get; set; } = 30.0f;
/// <summary>
/// Enumerates every active exit of this intersection (rectangle or circle) as a world transform plus road width.
/// These are the same outer-edge positions that <see cref="SnapNearbyRoads"/> snaps roads to, so the traffic
/// graph can match road endpoints against them by proximity.
/// </summary>
public List<TrafficExit> GetTrafficExits()
{
var exits = new List<TrafficExit>();
if (Shape == IntersectionShape.Rectangle)
{
EnsureRectangleExits();
foreach (var exit in Exits)
{
if (exit is null)
continue;
exits.Add(new TrafficExit
{
Transform = GetRectangleExitTransform(exit.Side, true, exit.Offset),
RoadWidth = exit.Width
});
}
}
else
{
var circleExits = CircleExits ?? Array.Empty<CircleExit>();
for (int i = 0; i < circleExits.Length; i++)
{
exits.Add(new TrafficExit
{
Transform = GetCircleExitTransform(i, true),
RoadWidth = circleExits[i].RoadWidth
});
}
}
return exits;
}
}
using System;
namespace RedSnail.RoadTool;
public partial class RoadIntersectionComponent
{
// Computes a quadratic Bezier control point at the intersection of the two tangent lines.
// Returns true if the lines intersect, false if parallel (in which case the midpoint is used as a fallback).
// The control distance along _StartTan is clamped to the chord length so asymmetric tangents (e.g. an
// off-grid exit angle whose disc tangent points well past the outer corner) don't drive the bezier past
// the outer endpoint — overshoot produces samples beyond the endpoint and flips downstream triangle winding.
private static bool TryBezierControl(Vector3 _Start, Vector3 _StartTan, Vector3 _End, Vector3 _EndTan, out Vector3 _Control)
{
float det = _StartTan.x * _EndTan.y - _EndTan.x * _StartTan.y;
if (MathF.Abs(det) < 0.0001f)
{
_Control = (_Start + _End) * 0.5f;
return false;
}
Vector3 d = _End - _Start;
float r = (d.x * _EndTan.y - _EndTan.x * d.y) / det;
float rMax = d.Length;
float rClamped = Math.Clamp(r, 0.0f, rMax);
_Control = _Start + rClamped * _StartTan;
return true;
}
private static Vector3 SampleQuadBezier(Vector3 _B0, Vector3 _B1, Vector3 _B2, float _T)
{
float u = 1.0f - _T;
return u * u * _B0 + 2.0f * u * _T * _B1 + _T * _T * _B2;
}
}
using System;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// A primitive control surface the traffic AI (<see cref="TrafficVehicle"/>) uses to drive ONE vehicle. It's a plain
/// bag of delegates — there is no interface for a vehicle controller to implement, and your vehicle code never needs
/// to reference this library.
///
/// The seam is filled in by whoever uses both this tool AND a vehicle controller — i.e. your GAME — via
/// <see cref="RoadManager.ResolveVehicleDriver"/>. The game maps whatever its controller looks like onto these few
/// delegates. Any field left null is simply skipped. The demo wires <see cref="DemoCarController"/> automatically.
/// </summary>
public sealed class RoadVehicleDriver
{
/// <summary>True while a player is at the wheel — the AI then hands this car over for good and never reclaims it.</summary>
public Func<bool> IsPlayerDriving;
/// <summary>The vehicle body's world velocity. The brain reads it to chase a target speed and to detect being jammed.</summary>
public Func<Vector3> Velocity;
/// <summary>Tell the controller whether the AI is currently driving this vehicle (vs parked / player-driven). Pushed every frame.</summary>
public Action<bool> SetAiControlled;
/// <summary>Push the AI's per-frame inputs: throttle and steer in [-1, 1] (steer + = left), plus handbrake.</summary>
public Action<float, float, bool> Drive;
/// <summary>Optional: max steering angle in degrees, used to widen the entity look-ahead toward where the car is turning.</summary>
public Func<float> MaxSteering;
}
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Reactivity" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "reactivity" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "igor" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "igor.reactivity" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "26" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-06-27T21:09:21.7423840Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.111.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.111.0")]using System.Runtime.CompilerServices;
namespace Sandbox.Reactivity.Internals.Runtimes;
internal sealed class Runtime : IDisposable
{
/// <summary>
/// Effects that are waiting to run due to reactivity changes.
/// </summary>
private readonly Queue<Effect> _pendingEffects = new(16);
/// <summary>
/// How many effects have run during a flush operation.
/// </summary>
private uint _flushDepth;
/// <summary>
/// Whether pending effects are currently being run.
/// </summary>
private bool _isFlushing;
/// <summary>
/// The currently executing effect.
/// </summary>
public Effect? CurrentEffect { get; set; }
/// <summary>
/// The currently executing reaction.
/// </summary>
public IReaction? CurrentReaction { get; set; }
/// <summary>
/// A monotonically increasing counter that's incremented when an <see cref="IProducer" /> updates its current
/// value.
/// </summary>
public uint Version { get; set; } = 1;
/// <summary>
/// Whether dependency tracking is currently disabled.
/// </summary>
public bool IsUntracking { get; set; }
/// <summary>
/// Whether a flush was scheduled to run at the end of the frame.
/// </summary>
public bool IsFlushScheduled { get; private set; }
/// <summary>
/// Whether an effect is currently executing its teardown function. This is used by producers to skip any
/// recomputation when accessed to ensure the previous value is returned.
/// </summary>
public bool IsRunningTeardown { get; set; }
public void Dispose()
{
_pendingEffects.Clear();
CurrentEffect = null;
CurrentReaction = null;
Version = uint.MaxValue;
IsUntracking = true;
IsFlushScheduled = false;
_isFlushing = false;
}
/// <summary>
/// Returns the currently executing effect.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown if there is no effect that is currently executing.
/// </exception>
public Effect EnsureCurrentEffect([CallerMemberName] string name = "Effect")
{
return CurrentEffect ?? throw new InvalidOperationException(name + " must be created in an effect root");
}
public void ScheduleEffect(Effect effect)
{
_pendingEffects.Enqueue(effect);
if (!IsFlushScheduled && !_isFlushing)
{
IsFlushScheduled = true;
}
}
/// <summary>
/// Empties the queue of effects that are scheduled to run due to one of their dependencies changing. This should
/// only be run when you want an effect to re-run immediately after changing a reactive value.
/// </summary>
public void Flush()
{
if (_isFlushing)
{
return;
}
_isFlushing = true;
IsFlushScheduled = false;
try
{
while (_pendingEffects.TryDequeue(out var effect))
{
if (_flushDepth++ > 1000)
{
_pendingEffects.Clear();
_pendingEffects.TrimExcess(16);
#if DEBUG
var exception = new InfiniteLoopException(_effectExecutions);
OnFlushInfiniteLoop?.Invoke(exception);
throw exception;
#else
throw new InfiniteLoopException();
#endif
}
if (effect.ShouldRun)
{
effect.Run();
#if DEBUG
_effectExecutions[effect] = _effectExecutions.GetValueOrDefault(effect) + 1;
#endif
}
}
}
finally
{
_flushDepth = 0;
_isFlushing = false;
#if DEBUG
_effectExecutions.Clear();
#endif
}
}
#if DEBUG
/// <summary>
/// Which effects have executing during the current flush, and how many times they've executed.
/// </summary>
private readonly Dictionary<Effect, int> _effectExecutions = [];
/// <summary>
/// Called when an infinite loop occurred during a flush.
/// </summary>
public static event Action<InfiniteLoopException>? OnFlushInfiniteLoop;
#endif
}
#if SANDBOX
using System.Diagnostics;
using Sandbox.Reactivity.Internals;
using Sandbox.UI;
using static Sandbox.Reactivity.Reactive;
#if JETBRAINS_ANNOTATIONS
using JetBrains.Annotations;
#endif
namespace Sandbox.Reactivity;
/// <summary>
/// The reactive counterpart to <see cref="Panel" /> that allows usage of reactive properties.
/// </summary>
/// <remarks>
/// Make sure you set up an effect root using <see cref="PanelRoot" /> at the top of your razor markup:
/// <code>
/// @{ using var _ = PanelRoot(); }
/// </code>
/// Engine limitations prevent this from being done automatically.
/// </remarks>
#if JETBRAINS_ANNOTATIONS
[PublicAPI]
#endif
public class ReactivePanel : Panel, IReactivePropertyContainer, IReactivePanel
{
private Effect? _effectRoot;
private Effect? _renderEffectRoot;
private int _version;
public ReactivePanel()
{
var parent = Runtime.CurrentEffect;
_effectRoot = new Effect([StackTraceHidden] [DebuggerStepThrough]() =>
{
OnActivate();
return null;
},
parent,
false);
_effectRoot.SetDebugInfo(DisplayInfo.For(this).Name,
DisplayInfo.For(this).Icon,
new CallLocation(GetType(), nameof(OnActivate)),
parent ?? (object?)this);
_effectRoot.Run();
}
Effect? IReactivePanel.RenderEffectRoot
{
get => _renderEffectRoot;
set => _renderEffectRoot = value;
}
int IReactivePanel.Version
{
get => _version;
set => _version = value;
}
Dictionary<int, IProducer> IReactivePropertyContainer.Producers { get; } = [];
protected ReactivePanelScope PanelRoot()
{
return new ReactivePanelScope(this);
}
public sealed override void Delete(bool immediate = false)
{
_renderEffectRoot?.Dispose();
_renderEffectRoot = null;
_effectRoot?.Dispose();
_effectRoot = null;
base.Delete(immediate);
}
protected sealed override int BuildHash()
{
return _version;
}
/// <summary>
/// Called inside an effect root when this panel is instantiated, allowing for effects to be created. When this
/// panel is deleted, the effect root (and all of its descendants) are disposed.
/// </summary>
protected virtual void OnActivate()
{
}
}
#endif
#if SANDBOX
using Sandbox.Reactivity.Internals;
#if JETBRAINS_ANNOTATIONS
using JetBrains.Annotations;
#endif
// we can't wrap the BuildRenderTree method for razor components, so we need something that can set up the proper
// scope inside the markup itself
namespace Sandbox.Reactivity;
/// <summary>
/// A disposable that's used to enable reactivity for a <see cref="ReactivePanelComponent" /> or
/// <see cref="ReactivePanel" /> during rendering.
/// </summary>
#if JETBRAINS_ANNOTATIONS
[PublicAPI]
#endif
public readonly ref struct ReactivePanelScope : IDisposable
{
private readonly Effect.ExecutionScope _executionScope;
internal ReactivePanelScope(IReactivePanel panel)
{
if (panel.RenderEffectRoot is { } previousRoot)
{
// don't teardown previous root since we're already building the render tree by this point
previousRoot.Dispose(false);
}
// nested panels don't render immediately when a containing panel's tree is rendering, so the parent is
// always null anyway
var effectRoot = new Effect(null, null, true, () => panel.Version++);
effectRoot.SetDebugInfo(panel.GetType().ToSimpleString(false) + " (Render)",
panel is ReactivePanel ? "view_quilt" : "monitor",
new CallLocation(2),
panel is ReactivePanel reactive ? reactive.GameObject?.GetComponent<IReactivePanel>() : panel);
panel.RenderEffectRoot = effectRoot;
_executionScope = new Effect.ExecutionScope(effectRoot);
}
public void Dispose()
{
_executionScope.Dispose();
}
}
#endif
#if SANDBOX
namespace Sandbox.Reactivity.Internals;
/// <summary>
/// Maintains a list of types that are assignable to the given type.
/// </summary>
internal static class TypeHierarchy<T>
{
/// <summary>
/// All types that are assignable to <typeparamref name="T"/>.
/// </summary>
[SkipHotload]
// ReSharper disable once StaticMemberInGenericType
public static readonly IEnumerable<Type> Types;
static TypeHierarchy()
{
// since this is most likely going to be used for simple event types, we're going to assume that the hierarchy
// won't be very large and that checking a list would be faster than hashing for a set
var next = typeof(T);
var hierarchy = new List<Type>();
while (next != null)
{
hierarchy.Add(next);
foreach (var type in next.GetInterfaces())
{
if (!hierarchy.Contains(type))
{
hierarchy.Add(type);
}
}
next = next.BaseType;
if (next == typeof(object))
{
break;
}
}
Types = hierarchy;
}
}
#endif
#if JETBRAINS_ANNOTATIONS
#endif
namespace Sandbox.Reactivity;
/// <summary>
/// An object that contains a reactive value. Reading the value inside an effect will cause it to re-run when it
/// changes.
/// </summary>
/// <typeparam name="T">The type of value this object contains.</typeparam>
/// <remarks>
/// This can be used to abstract over a <see cref="State{T}" /> or <see cref="Derived{T}" /> as needed.
/// </remarks>
#if JETBRAINS_ANNOTATIONS
#endif
public interface IState<T>
{
/// <summary>
/// The current value.
/// </summary>
T Value { get; set; }
}
/// <inheritdoc cref="IState{T}" />
#if JETBRAINS_ANNOTATIONS
#endif
public interface IReadOnlyState<out T>
{
/// <inheritdoc cref="IState{T}.Value" />
T Value { get; }
}
using Sandbox;
using Sandbox.Volumes;
namespace RedSnail.WaterTool;
/// <summary>
/// Calms the water inside a volume: wave displacement (and the surface normals that
/// come from it) smoothly fade to flat. Affects every water surface — WaterQuad,
/// WaterBodyRenderer and WaterFlow — so it's the clean way to blend two of them
/// together. The classic use is a river mouth meeting an ocean: drop a calm volume
/// over the junction, set both surfaces to the same height there, and the wave
/// mismatch (ocean chop poking above the river, seams) disappears.
///
/// Purely visual — it doesn't touch buoyancy, swimming or the flow current.
/// </summary>
[Title("Water Calm Volume")]
[Category("Volumes")]
[Icon("water")]
public sealed class WaterCalmVolume : VolumeComponent, Component.ExecuteInEditor
{
// 0 = no effect, 1 = perfectly flat at the core. Lets a volume only partially
// settle the water if you want some residual motion.
[Property, Range(0.0f, 1.0f)] public float Strength { get; set; } = 1.0f;
// Fraction of the volume (from each face inward) over which the calming ramps in.
// 0 = hard edge (a visible crease), 1 = ramps all the way from the center.
[Property, Range(0.05f, 1.0f)] public float Falloff { get; set; } = 0.4f;
protected override void OnEnabled()
{
WaterManager.Current?.RefreshWaterCalmVolumesList();
}
protected override void OnDisabled()
{
WaterManager.Current?.RefreshWaterCalmVolumesList();
}
protected override void DrawGizmos()
{
base.DrawGizmos();
if (!Gizmo.IsSelected)
return;
// Faint fill so calm volumes read differently from exclusion volumes
BBox box = SceneVolume.GetBounds();
Gizmo.Draw.Color = Color.Cyan.WithAlpha(0.06f);
Gizmo.Draw.SolidBox(box);
}
public (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()
{
BBox local = SceneVolume.GetBounds();
Vector3 center = WorldTransform.PointToWorld(local.Center);
Vector3 halfExtents = local.Size * 0.5f;
return (center, WorldRotation.Forward, WorldTransform.Up, halfExtents);
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.Rendering;
using RenderStage = Sandbox.Rendering.Stage;
namespace RedSnail.WaterTool;
[Title("Water Manager")]
public partial class WaterManager : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer, IHotloadManaged
{
private SceneCustomObject m_SceneObject;
[SkipHotload] public static WaterManager Current { get; private set; } = null;
[Property(Title = "Ocean"), Group("Profile"), Order(0)] public WaterDefinition OceanWaveProfile { get; set; }
[Property(Title = "Lake"), Group("Profile")] public WaterDefinition LakeWaveProfile { get; set; }
[Property(Title = "River"), Group("Profile")] public WaterDefinition RiverWaveProfile { get; set; }
[Property(Title = "Pool"), Group("Profile")] public WaterDefinition PoolWaveProfile { get; set; }
[Property(Title = "Custom"), Group("Profile")] public WaterDefinition CustomWaveProfile { get; set; }
[Property(Title = "Underwater Volume"), Group("Post Processing")] public PostProcessVolume UnderwaterPostProcessVolume { get; set; }
private ComputeShader m_ComputeShader;
// Double-buffered command lists. We BUILD into the disabled "back" list on the main
// thread (FinishUpdate); the camera EXECUTES the enabled "front" list on a render
// worker thread. Because the recorded list and the executing list are never the same
// instance in a frame, the engine never iterates a list while we're resetting it -
// which is the multithreaded "CommandList was null" crash. Both stay attached to the
// camera for its lifetime; each frame we just flip which one is Enabled.
private CommandList m_CommandList = new("Water Quads");
private Vector3 m_CameraPosition;
private WaterDefinition m_DefaultProfile;
private List<WaterQuad> Quads { get; } = [];
private List<WaterBodyRenderer> QuadRenderers { get; } = [];
public List<WaterBody> Bodies { get; } = [];
public List<WaterFlow> Flows { get; } = [];
public List<WaterExclusionVolume> ExclusionVolumes { get; } = [];
public List<HullWaterExclusionVolume> HullExclusionVolumes { get; } = [];
protected override void OnAwake()
{
Current = Scene.Get<WaterManager>();
m_ComputeShader = new ComputeShader("water_clipmap_cs");
m_DefaultProfile = new WaterDefinition();
}
protected override void OnEnabled()
{
m_SceneObject = new SceneCustomObject(Scene.SceneWorld)
{
RenderOverride = RenderAll,
Transform = new Transform(Vector3.Zero, Rotation.Identity),
Flags =
{
IsOpaque = false,
IsTranslucent = true,
WantsFrameBufferCopy = false,
WantsPrePass = false
}
};
Scene.Camera?.AddCommandList(m_CommandList, RenderStage.AfterTransparent);
RefreshWaterQuadsList();
RefreshWaterBodyRenderersList();
RefreshWaterBodiesList();
RefreshWaterExclusionVolumesList();
RefreshWaterHullExclusionVolumesList();
}
protected override void OnDisabled()
{
m_SceneObject?.Delete();
m_SceneObject = null;
m_RippleBuffer?.Dispose();
m_RippleBuffer = null;
ClearCalmVolumes();
Scene.Camera?.RemoveCommandList(m_CommandList);
}
void IHotloadManaged.Destroyed(Dictionary<string, object> _State)
{
_State["IsActive"] = Current == this;
}
void IHotloadManaged.Created(IReadOnlyDictionary<string, object> _State)
{
if (_State.GetValueOrDefault("IsActive") is true)
Current = this;
}
private void RenderAll(SceneObject _)
{
if (Graphics.LayerType != SceneLayerType.Translucent)
return;
m_CommandList.Reset();
bool hasAnythingToRender = false;
foreach (var renderer in QuadRenderers)
{
if (!renderer.IsValid() || !renderer.ParticipatesInRendering)
continue;
hasAnythingToRender = true;
renderer.RecordCompute(m_CommandList, m_ComputeShader, m_CameraPosition);
}
foreach (var quad in Quads)
{
if (!quad.IsValid() || !quad.ParticipatesInRendering)
continue;
hasAnythingToRender = true;
quad.RecordCompute(m_CommandList, m_ComputeShader, m_CameraPosition);
}
// Flows build their mesh on the CPU (no compute pass or barrier needed)
foreach (var flow in Flows)
{
if (!flow.IsValid() || !flow.ParticipatesInRendering)
continue;
hasAnythingToRender = true;
}
if (hasAnythingToRender)
{
foreach (var renderer in QuadRenderers)
{
if (!renderer.IsValid() || !renderer.ParticipatesInRendering)
continue;
renderer.BarrierTransition(m_CommandList);
}
foreach (var quad in Quads)
{
if (!quad.IsValid() || !quad.ParticipatesInRendering)
continue;
quad.BarrierTransition(m_CommandList);
}
m_CommandList.Attributes.GrabFrameTexture("FrameBufferCopyTexture");
foreach (var renderer in QuadRenderers)
{
if (!renderer.IsValid() || !renderer.ParticipatesInRendering)
continue;
renderer.Draw(m_CommandList);
}
foreach (var quad in Quads)
{
if (!quad.IsValid() || !quad.ParticipatesInRendering)
continue;
quad.Draw(m_CommandList);
}
foreach (var flow in Flows)
{
if (!flow.IsValid() || !flow.ParticipatesInRendering)
continue;
flow.Draw(m_CommandList);
}
}
}
protected override void OnUpdate()
{
// We've to make sure it's always correct while in the editor
// (S&box is a complete mess when it comes to managing a singleton properly on a component that execute in the editor, bcs its reference get constantly swapped between
// gameplay and editor, we've to do this non sense !)
if (Scene.IsEditor)
Current = Scene.Get<WaterManager>();
if (Game.IsPlaying)
{
m_CameraPosition = Scene.Camera?.WorldPosition ?? Vector3.Zero;
}
else
{
m_CameraPosition = Application.Editor.Camera.WorldPosition;
}
if (UnderwaterPostProcessVolume.IsValid())
UnderwaterPostProcessVolume.Enabled = IsPositionInsideAny(m_CameraPosition);
UpdateRipples();
UpdateCalmVolumes();
}
/// <summary>
/// We have to do all this non sense bcs using a Register/Unregister logic with OnEnabled/OnDisabled is a complete
/// mess to manage when we enter play mode/stop play mode in the editor, the references get duplicated etc... Otherwise we've to check by gameobject id...
/// It's just way too annoying, refreshing the whole list is safer and we're always sure to have the proper count of components
/// </summary>
public void RefreshWaterQuadsList()
{
if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
return;
Quads.Clear();
Quads.AddRange(Scene.GetAll<WaterQuad>());
}
public void RefreshWaterBodyRenderersList()
{
if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
return;
QuadRenderers.Clear();
QuadRenderers.AddRange(Scene.GetAll<WaterBodyRenderer>());
}
public void RefreshWaterBodiesList()
{
if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
return;
Bodies.Clear();
Bodies.AddRange(Scene.GetAll<WaterBody>());
}
public void RefreshWaterFlowsList()
{
if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
return;
Flows.Clear();
Flows.AddRange(Scene.GetAll<WaterFlow>());
}
public void RefreshWaterExclusionVolumesList()
{
if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
return;
ExclusionVolumes.Clear();
ExclusionVolumes.AddRange(Scene.GetAll<WaterExclusionVolume>());
}
public void RefreshWaterHullExclusionVolumesList()
{
if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
return;
HullExclusionVolumes.Clear();
HullExclusionVolumes.AddRange(Scene.GetAll<HullWaterExclusionVolume>());
}
private WaterDefinition GetWaveProfileForType(WaterBodyType waterType) => waterType switch
{
WaterBodyType.Ocean => OceanWaveProfile,
WaterBodyType.Lake => LakeWaveProfile,
WaterBodyType.River => RiverWaveProfile,
WaterBodyType.Pool => PoolWaveProfile,
_ => CustomWaveProfile
};
public static WaterDefinition GetWaveProfile(WaterBodyType _WaterType)
{
if (Current == null)
return null;
WaterDefinition profile = Current.GetWaveProfileForType(_WaterType);
if (profile.IsValid())
return profile;
Log.Warning("[WaterTool] No water profile found in the 'Water Manager', please add a water profile for the specified water type ! (Project Settings > Water Manager > 'Assign the profiles')");
return Current.m_DefaultProfile;
}
}
namespace AutoRig.Dl.Nn;
using AutoRig.Dl;
/// <summary>
/// Transformer building blocks over <see cref="Tensor"/> ([N, C] rows), matching
/// PyTorch inference semantics (eps defaults, tanh-approx GELU where models use it).
/// Foundation for the UniRig-family ports (plan 8).
/// </summary>
public static class TransformerOps
{
/// <summary>LayerNorm over the last dim: (x - mean) / sqrt(var + eps) * g + b.</summary>
public static Tensor LayerNorm( Tensor x, Tensor gamma, Tensor beta, float eps = 1e-5f )
{
var (rows, cols) = (x.Shape[0], x.Shape[1]);
var result = new float[x.Count];
for ( var r = 0; r < rows; r++ )
{
float mean = 0;
for ( var c = 0; c < cols; c++ )
mean += x.Data[r * cols + c];
mean /= cols;
float variance = 0;
for ( var c = 0; c < cols; c++ )
{
var d = x.Data[r * cols + c] - mean;
variance += d * d;
}
variance /= cols;
var inv = 1f / MathF.Sqrt( variance + eps );
for ( var c = 0; c < cols; c++ )
result[r * cols + c] =
(x.Data[r * cols + c] - mean) * inv * (gamma?.Data[c] ?? 1f)
+ (beta?.Data[c] ?? 0f); // null = no affine (DiT final norm)
}
return Tensor.From( result, rows, cols );
}
/// <summary>RMSNorm: x / rms(x) * g (LLaMA-style, no mean subtraction).</summary>
public static Tensor RmsNorm( Tensor x, Tensor gamma, float eps = 1e-6f )
{
var (rows, cols) = (x.Shape[0], x.Shape[1]);
var result = new float[x.Count];
for ( var r = 0; r < rows; r++ )
{
float meanSquare = 0;
for ( var c = 0; c < cols; c++ )
{
var v = x.Data[r * cols + c];
meanSquare += v * v;
}
meanSquare /= cols;
var inv = 1f / MathF.Sqrt( meanSquare + eps );
for ( var c = 0; c < cols; c++ )
result[r * cols + c] = x.Data[r * cols + c] * inv * gamma.Data[c];
}
return Tensor.From( result, rows, cols );
}
/// <summary>Exact GELU: x · Φ(x) via erf (PyTorch nn.GELU default).</summary>
public static Tensor Gelu( Tensor x )
{
var result = new float[x.Count];
for ( var i = 0; i < x.Count; i++ )
result[i] = x.Data[i] * 0.5f * (1f + Erf( x.Data[i] * 0.70710678f ));
return Tensor.From( result, x.Shape );
}
/// <summary>Tanh-approximated GELU (PyTorch approximate="tanh" / GPT-style).</summary>
public static Tensor GeluTanh( Tensor x )
{
var result = new float[x.Count];
for ( var i = 0; i < x.Count; i++ )
{
var v = x.Data[i];
result[i] = 0.5f * v * (1f + MathF.Tanh(
0.7978845608f * (v + 0.044715f * v * v * v) ));
}
return Tensor.From( result, x.Shape );
}
/// <summary>SiLU / swish: x · sigmoid(x).</summary>
public static Tensor Silu( Tensor x )
{
var result = new float[x.Count];
for ( var i = 0; i < x.Count; i++ )
result[i] = x.Data[i] / (1f + MathF.Exp( -x.Data[i] ));
return Tensor.From( result, x.Shape );
}
/// <summary>
/// Multi-head scaled-dot-product attention. q: [Nq, H·D], k/v: [Nk, H·D]
/// (already projected). Causal masks query i from keys j > i + (Nk − Nq)
/// (standard KV-cache offset). Returns [Nq, H·D].
/// </summary>
public static Tensor Attention( Tensor q, Tensor k, Tensor v, int heads, bool causal )
{
var nq = q.Shape[0];
var nk = k.Shape[0];
var headDim = q.Shape[1] / heads;
var scale = 1f / MathF.Sqrt( headDim );
var offset = nk - nq;
var result = new float[nq * heads * headDim];
void Head( int h )
{
var scores = new float[nk]; // per-worker buffer
var headBase = h * headDim;
for ( var i = 0; i < nq; i++ )
{
var limit = causal ? Math.Min( nk, i + offset + 1 ) : nk;
var max = float.MinValue;
for ( var j = 0; j < limit; j++ )
{
float dot = 0;
for ( var d = 0; d < headDim; d++ )
dot += q.Data[i * heads * headDim + headBase + d]
* k.Data[j * heads * headDim + headBase + d];
scores[j] = dot * scale;
if ( scores[j] > max )
max = scores[j];
}
float total = 0;
for ( var j = 0; j < limit; j++ )
{
scores[j] = MathF.Exp( scores[j] - max );
total += scores[j];
}
for ( var j = 0; j < limit; j++ )
{
var weight = scores[j] / total;
if ( weight == 0f )
continue;
for ( var d = 0; d < headDim; d++ )
result[i * heads * headDim + headBase + d] +=
weight * v.Data[j * heads * headDim + headBase + d];
}
}
}
// Heads write disjoint column ranges — safe to parallelize; skip the
// overhead for tiny single-token decode steps.
if ( (long)nq * nk * headDim >= 1 << 16 )
Concurrency.For( 0, heads, Head );
else
for ( var h = 0; h < heads; h++ )
Head( h );
return Tensor.From( result, nq, heads * headDim );
}
/// <summary>
/// Rotary position embedding (LLaMA/Qwen half-split convention): for each head,
/// x' = x·cos(θ) + rotate_half(x)·sin(θ) with rotate_half = (−x₂, x₁) over the
/// half-dim split; θ_i = pos · base^(−2i/d).
/// </summary>
public static Tensor Rope( Tensor x, int heads, int[] positions, float thetaBase = 1e6f )
{
var rows = x.Shape[0];
var headDim = x.Shape[1] / heads;
var half = headDim / 2;
var result = new float[x.Count];
var invFreq = new float[half];
for ( var i = 0; i < half; i++ )
invFreq[i] = MathF.Pow( thetaBase, -2f * i / headDim );
for ( var r = 0; r < rows; r++ )
for ( var h = 0; h < heads; h++ )
{
var at = r * heads * headDim + h * headDim;
for ( var i = 0; i < half; i++ )
{
var angle = positions[r] * invFreq[i];
var (sin, cos) = (MathF.Sin( angle ), MathF.Cos( angle ));
var a = x.Data[at + i];
var b = x.Data[at + half + i];
result[at + i] = a * cos - b * sin;
result[at + half + i] = b * cos + a * sin;
}
}
return Tensor.From( result, rows, heads * headDim );
}
/// <summary>
/// Grouped-query attention: q has <paramref name="qHeads"/>, k/v have
/// <paramref name="kvHeads"/> (each kv head serves qHeads/kvHeads queries).
/// Same causal/KV-cache-offset semantics as <see cref="Attention"/>.
/// </summary>
public static Tensor AttentionGqa(
Tensor q, Tensor k, Tensor v, int qHeads, int kvHeads, bool causal )
{
var nq = q.Shape[0];
var nk = k.Shape[0];
var headDim = q.Shape[1] / qHeads;
var scale = 1f / MathF.Sqrt( headDim );
var offset = nk - nq;
var group = qHeads / kvHeads;
var result = new float[nq * qHeads * headDim];
void Head( int h )
{
var scores = new float[nk];
var kvHead = h / group;
var qBase = h * headDim;
var kvBase = kvHead * headDim;
for ( var i = 0; i < nq; i++ )
{
var limit = causal ? Math.Min( nk, i + offset + 1 ) : nk;
var max = float.MinValue;
for ( var j = 0; j < limit; j++ )
{
float dot = 0;
for ( var d = 0; d < headDim; d++ )
dot += q.Data[i * qHeads * headDim + qBase + d]
* k.Data[j * kvHeads * headDim + kvBase + d];
scores[j] = dot * scale;
if ( scores[j] > max )
max = scores[j];
}
float total = 0;
for ( var j = 0; j < limit; j++ )
{
scores[j] = MathF.Exp( scores[j] - max );
total += scores[j];
}
for ( var j = 0; j < limit; j++ )
{
var weight = scores[j] / total;
if ( weight == 0f )
continue;
for ( var d = 0; d < headDim; d++ )
result[i * qHeads * headDim + qBase + d] +=
weight * v.Data[j * kvHeads * headDim + kvBase + d];
}
}
}
if ( (long)nq * nk * headDim >= 1 << 16 )
Concurrency.For( 0, qHeads, Head );
else
for ( var h = 0; h < qHeads; h++ )
Head( h );
return Tensor.From( result, nq, qHeads * headDim );
}
/// <summary>Per-head RMSNorm over each head's slice (Qwen3 q_norm/k_norm).</summary>
public static Tensor RmsNormPerHead( Tensor x, Tensor gamma, int heads, float eps = 1e-6f )
{
var rows = x.Shape[0];
var headDim = x.Shape[1] / heads;
var result = new float[x.Count];
for ( var r = 0; r < rows; r++ )
for ( var h = 0; h < heads; h++ )
{
var at = r * heads * headDim + h * headDim;
float meanSquare = 0;
for ( var d = 0; d < headDim; d++ )
meanSquare += x.Data[at + d] * x.Data[at + d];
var inv = 1f / MathF.Sqrt( meanSquare / headDim + eps );
for ( var d = 0; d < headDim; d++ )
result[at + d] = x.Data[at + d] * inv * gamma.Data[d];
}
return Tensor.From( result, rows, heads * headDim );
}
/// <summary>SwiGLU feed-forward core: silu(gate) ⊙ up (caller applies down proj).</summary>
public static Tensor SwiGlu( Tensor gate, Tensor up )
{
var result = new float[gate.Count];
for ( var i = 0; i < gate.Count; i++ )
result[i] = gate.Data[i] / (1f + MathF.Exp( -gate.Data[i] )) * up.Data[i];
return Tensor.From( result, gate.Shape );
}
/// <summary>Embedding lookup: ids → rows of the [V, C] table.</summary>
public static Tensor Embed( Tensor table, int[] ids )
{
var cols = table.Shape[1];
var result = new float[ids.Length * cols];
for ( var i = 0; i < ids.Length; i++ )
Array.Copy( table.Data, ids[i] * cols, result, i * cols, cols );
return Tensor.From( result, ids.Length, cols );
}
/// <summary>Greedy decode step: argmax of the final row of logits.</summary>
public static int Argmax( Tensor logits )
{
var cols = logits.Shape[1];
var lastRow = (logits.Shape[0] - 1) * cols;
var best = 0;
for ( var c = 1; c < cols; c++ )
if ( logits.Data[lastRow + c] > logits.Data[lastRow + best] )
best = c;
return best;
}
/// <summary>Abramowitz–Stegun erf (max error ~1.5e-7, plenty for fp32 goldens).</summary>
internal static float Erf( float x )
{
var sign = x < 0 ? -1f : 1f;
x = MathF.Abs( x );
var t = 1f / (1f + 0.3275911f * x);
var y = 1f - (((((1.061405429f * t - 1.453152027f) * t) + 1.421413741f) * t
- 0.284496736f) * t + 0.254829592f) * t * MathF.Exp( -x * x );
return sign * y;
}
}
namespace AutoRig.Dl.Puppeteer;
using AutoRig.Dl;
/// <summary>Binds PartField from the weights embedded in Puppeteer's skin
/// checkpoint (point_embed.model.*, verified in skin_keys.json).</summary>
public static class PartFieldLoader
{
public static PartFieldModel Load( IReadOnlyDictionary<string, Tensor> tensors, string prefix )
{
var pf = $"{prefix}point_embed.model.";
var enc = $"{pf}pvcnn.pc_encoder.encoder.0.";
var unet = $"{pf}pvcnn.unet_encoder.";
var tri = $"{pf}triplane_transformer.";
PartFieldModel.UnetBlock Block( string b, int cin, int cout ) => new()
{
Norm1G = Get( tensors, $"{b}norm1.weight" ),
Norm1B = Get( tensors, $"{b}norm1.bias" ),
Conv1W = Get( tensors, $"{b}conv1.weight" ),
Conv1B = Get( tensors, $"{b}conv1.bias" ),
NormMidG = Get( tensors, $"{b}norm_mid.weight" ),
NormMidB = Get( tensors, $"{b}norm_mid.bias" ),
AwareW = new[]
{
Get( tensors, $"{b}conv_3daware.plane_convs.0.weight" ),
Get( tensors, $"{b}conv_3daware.plane_convs.1.weight" ),
Get( tensors, $"{b}conv_3daware.plane_convs.2.weight" ),
},
AwareB = new[]
{
Get( tensors, $"{b}conv_3daware.plane_convs.0.bias" ),
Get( tensors, $"{b}conv_3daware.plane_convs.1.bias" ),
Get( tensors, $"{b}conv_3daware.plane_convs.2.bias" ),
},
Norm2G = Get( tensors, $"{b}norm2.weight" ),
Norm2B = Get( tensors, $"{b}norm2.bias" ),
Conv2W = Get( tensors, $"{b}conv2.weight" ),
Conv2B = Get( tensors, $"{b}conv2.bias" ),
ShortcutW = tensors.TryGetValue( $"{b}nin_shortcut.weight", out var sw ) ? sw : null,
ShortcutB = tensors.TryGetValue( $"{b}nin_shortcut.bias", out var sb ) ? sb : null,
CIn = cin,
COut = cout,
};
var layers = new List<PartFieldModel.TriLayer>();
while ( tensors.ContainsKey( $"{tri}transformer.layers.{layers.Count}.norm1.weight" ) )
{
var l = $"{tri}transformer.layers.{layers.Count}.";
layers.Add( new PartFieldModel.TriLayer
{
Norm1G = Get( tensors, $"{l}norm1.weight" ),
Norm1B = Get( tensors, $"{l}norm1.bias" ),
InProj = Get( tensors, $"{l}self_attn.in_proj_weight" ),
OutProj = Get( tensors, $"{l}self_attn.out_proj.weight" ),
Norm2G = Get( tensors, $"{l}norm2.weight" ),
Norm2B = Get( tensors, $"{l}norm2.bias" ),
Mlp0W = Get( tensors, $"{l}mlp.0.weight" ),
Mlp0B = Get( tensors, $"{l}mlp.0.bias" ),
Mlp3W = Get( tensors, $"{l}mlp.3.weight" ),
Mlp3B = Get( tensors, $"{l}mlp.3.bias" ),
} );
}
if ( layers.Count == 0 )
throw new FormatException( "PartField: no triplane transformer layers found." );
var posEmbed = Get( tensors, $"{tri}pos_embed" );
return new PartFieldModel
{
VoxConv0W = Get( tensors, $"{enc}voxel_layers.0.weight" ),
VoxConv0B = Get( tensors, $"{enc}voxel_layers.0.bias" ),
VoxConv3W = Get( tensors, $"{enc}voxel_layers.3.weight" ),
VoxConv3B = Get( tensors, $"{enc}voxel_layers.3.bias" ),
PointMlpW = Get( tensors, $"{enc}point_features.layers.0.weight" ),
PointMlpB = Get( tensors, $"{enc}point_features.layers.0.bias" ),
DownBlocks = new[]
{
Block( $"{unet}down_convs.0.block.", 256, 32 ),
Block( $"{unet}down_convs.1.block.", 32, 64 ),
Block( $"{unet}down_convs.2.block.", 64, 128 ),
},
UpBlocks = new[]
{
Block( $"{unet}up_convs.0.block.", 128 + 64, 64 ),
Block( $"{unet}up_convs.1.block.", 64 + 32, 32 ),
},
UpNorm1G = new[]
{
Get( tensors, $"{unet}up_convs.0.norm1.weight" ),
Get( tensors, $"{unet}up_convs.1.norm1.weight" ),
},
UpNorm1B = new[]
{
Get( tensors, $"{unet}up_convs.0.norm1.bias" ),
Get( tensors, $"{unet}up_convs.1.norm1.bias" ),
},
UnetNormOutG = Get( tensors, $"{unet}norm_out.weight" ),
UnetNormOutB = Get( tensors, $"{unet}norm_out.bias" ),
UnetFinalW = Get( tensors, $"{unet}conv_final.weight" ),
UnetFinalB = Get( tensors, $"{unet}conv_final.bias" ),
Down0W = Get( tensors, $"{tri}downsampler.0.weight" ),
Down0B = Get( tensors, $"{tri}downsampler.0.bias" ),
Down3W = Get( tensors, $"{tri}downsampler.3.weight" ),
Down3B = Get( tensors, $"{tri}downsampler.3.bias" ),
PosEmbed = Tensor.From( posEmbed.Data, posEmbed.Data.Length / 1024, 1024 ),
TriLayers = layers.ToArray(),
TriNormG = Get( tensors, $"{tri}transformer.norm.weight" ),
TriNormB = Get( tensors, $"{tri}transformer.norm.bias" ),
UpsamplerW = Get( tensors, $"{tri}upsampler.weight" ),
UpsamplerB = Get( tensors, $"{tri}upsampler.bias" ),
Mlp0W = Get( tensors, $"{tri}mlp.0.weight" ),
Mlp0B = Get( tensors, $"{tri}mlp.0.bias" ),
Mlp2W = Get( tensors, $"{tri}mlp.2.weight" ),
Mlp2B = Get( tensors, $"{tri}mlp.2.bias" ),
};
}
internal static Tensor Get( IReadOnlyDictionary<string, Tensor> tensors, string key )
=> tensors.TryGetValue( key, out var tensor )
? tensor
: throw new FormatException( $"Puppeteer skin checkpoint is missing tensor '{key}'." );
}
using AutoRig.Dl.RigNet;
using AutoRig.Mesh;
namespace AutoRig.Dl.UniRig;
using AutoRig.Dl;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// SkinTokens' input pipeline (checkpoint hparams predict_transform + the
/// encoder's eval path): bbox-normalize to [-1,1]³, SamplerMix a 54000-point
/// cloud (16384 vertex picks + area-weighted surface samples), then the
/// perceiver reduction with use_full_input=TRUE — the constant seed-0
/// 2048-subsample is fps'd down to 512 queries, but those queries cross-attend
/// against the WHOLE 54000-point cloud (unlike UniRig, which attends its
/// 4096-point subsample).
/// </summary>
public static class SkinTokensInput
{
public const int NumSamples = 54000;
public const int VertexSamples = 16384;
public const int PreCount = 2048; // token_num 512 · 4
public const int LatentCount = 512; // fps ratio 1/4 of the pre-cloud
public sealed class Prepared
{
/// <summary>The FULL 54000-point cloud (perceiver "data", use_full_input).</summary>
public required Vector3[] Points;
public required Vector3[] Normals;
/// <summary>fps picks (512) as indices into Points (perceiver queries).</summary>
public required int[] SampledIndices;
/// <summary>Undo the normalization: world = p * Scale + Center.</summary>
public required Vector3 Center;
public required float Scale;
}
public static Prepared Prepare( RigMesh mesh )
{
ArgumentNullException.ThrowIfNull( mesh );
var (points, normals, center, scale) =
UniRigInput.SampleCloud( mesh, NumSamples, VertexSamples );
// fps runs over the seed-0 pre-subsample; its picks map back into the
// full cloud (queries are literal rows of the data in the reference too).
var pre = new float[PreCount * 3];
for ( var i = 0; i < PreCount; i++ )
{
var p = points[SkinTokensSubsample.Indices[i]];
pre[i * 3 + 0] = p.X;
pre[i * 3 + 1] = p.Y;
pre[i * 3 + 2] = p.Z;
}
var fps = PointNet.FarthestPointSample( Tensor.From( pre, PreCount, 3 ), ratio: 0.25f );
var sampled = new int[fps.Length];
for ( var i = 0; i < fps.Length; i++ )
sampled[i] = SkinTokensSubsample.Indices[fps[i]];
return new Prepared
{
Points = points,
Normals = normals,
SampledIndices = sampled,
Center = center,
Scale = scale,
};
}
}
namespace AutoRig.Dl.UniRig;
using Vector3 = System.Numerics.Vector3;
/// <summary>Decoded skeleton from a UniRig token sequence.</summary>
public sealed class UniRigSkeleton
{
public required Vector3[] Joints;
public required int[] Parents; // -1 = root
public required Vector3[] Tails; // per-joint tail (leaf/branch tails extruded)
}
/// <summary>
/// UniRig's skeleton tokenizer (transcribed from src/tokenizer/tokenizer_part.py +
/// spec.make_skeleton, config tokenizer_parts_articulationxl_256): 256 coordinate
/// bins in [-1,1], BOS/EOS/branch/pad/spring/part/class tokens, the constrained-
/// decode state machine, and detokenization to joints/parents/tails.
/// </summary>
public sealed class UniRigTokenizer
{
public const int NumDiscrete = 256;
public const float RangeLo = -1f, RangeHi = 1f;
public const int TokenBranch = NumDiscrete + 0; // 256
public const int TokenBos = NumDiscrete + 1; // 257
public const int TokenEos = NumDiscrete + 2; // 258
public const int TokenPad = NumDiscrete + 3; // 259
public const int TokenSpring = NumDiscrete + 4; // 260
public const int TokenPartBody = NumDiscrete + 5; // 261 (parts: body 0, hand 1)
public const int TokenPartHand = NumDiscrete + 6; // 262
public const int TokenClsNone = NumDiscrete + 7; // 263
public const int TokenClsVroid = NumDiscrete + 8; // 264
public const int TokenClsMixamo = NumDiscrete + 9; // 265
public const int TokenClsArticulationXl = NumDiscrete + 10; // 266
public const int VocabSize = NumDiscrete + 11; // 267
public static int Discretize( float value )
{
var t = (value - RangeLo) / (RangeHi - RangeLo) * NumDiscrete;
return Math.Clamp( (int)MathF.Round( t ), 0, NumDiscrete - 1 );
}
public static float Undiscretize( int bin )
=> (bin + 0.5f) / NumDiscrete * (RangeHi - RangeLo) + RangeLo;
enum State
{
ExpectBos, ExpectClsOrPartOrJoint, ExpectPartOrJoint,
ExpectJoint, ExpectJoint2, ExpectJoint3, ExpectBranchOrPartOrJoint,
}
static bool IsCls( int id ) => id is >= TokenClsVroid and <= TokenClsArticulationXl;
static bool IsPart( int id ) => id is TokenPartBody or TokenPartHand;
static State Advance( State state, int id ) => state switch
{
State.ExpectBos => State.ExpectClsOrPartOrJoint,
State.ExpectClsOrPartOrJoint => id < NumDiscrete
? State.ExpectJoint2
: id == TokenClsNone || IsCls( id )
? State.ExpectPartOrJoint
: State.ExpectJoint,
State.ExpectPartOrJoint => id < NumDiscrete ? State.ExpectJoint2 : State.ExpectPartOrJoint,
State.ExpectJoint2 => State.ExpectJoint3,
State.ExpectJoint3 => State.ExpectBranchOrPartOrJoint,
State.ExpectBranchOrPartOrJoint => id == TokenBranch
? State.ExpectJoint
: id < NumDiscrete
? State.ExpectJoint2
: State.ExpectJoint,
State.ExpectJoint => State.ExpectJoint2,
_ => throw new FormatException( $"UniRig tokenizer: bad state {state}" ),
};
/// <summary>
/// Allowed next tokens given the sequence so far — the constrained-decode mask
/// (next_posible_token). Greedy argmax over logits restricted to this set
/// reproduces UniRig's LogitsProcessor deterministically.
/// </summary>
public static List<int> NextPossibleTokens( IReadOnlyList<int> ids )
{
if ( ids.Count == 0 )
return new List<int> { TokenBos };
var state = State.ExpectBos;
foreach ( var id in ids )
{
if ( state == State.ExpectBos && id != TokenBos )
throw new FormatException( "UniRig tokenizer: sequence does not start with BOS." );
state = Advance( state, id );
}
var allowed = new List<int>();
void AddCls()
{
allowed.Add( TokenClsNone );
allowed.Add( TokenClsVroid );
allowed.Add( TokenClsMixamo );
allowed.Add( TokenClsArticulationXl );
}
void AddPart()
{
allowed.Add( TokenSpring );
allowed.Add( TokenPartBody );
allowed.Add( TokenPartHand );
}
void AddJoint()
{
for ( var i = 0; i < NumDiscrete; i++ )
allowed.Add( i );
}
switch ( state )
{
case State.ExpectClsOrPartOrJoint:
AddCls(); AddPart(); AddJoint();
break;
case State.ExpectPartOrJoint:
AddPart(); AddJoint(); allowed.Add( TokenEos );
break;
case State.ExpectJoint or State.ExpectJoint2 or State.ExpectJoint3:
AddJoint();
break;
case State.ExpectBranchOrPartOrJoint:
AddJoint(); AddPart(); allowed.Add( TokenBranch ); allowed.Add( TokenEos );
break;
default:
throw new FormatException( $"UniRig tokenizer: bad decode state {state}" );
}
return allowed;
}
/// <summary>Completed bones in a (possibly partial) sequence — the reference's
/// bones_in_sequence: a bone completes on its 3rd coordinate token, but a
/// branch's FIRST triple (the re-stated parent position) does NOT count, so
/// the result equals the number of joints.</summary>
public static int BonesInSequence( IReadOnlyList<int> ids )
{
var bones = 0;
var isBranch = false;
var state = State.ExpectBos;
foreach ( var id in ids )
{
if ( state == State.ExpectBos && id != TokenBos )
throw new FormatException( "UniRig tokenizer: sequence does not start with BOS." );
if ( state == State.ExpectBranchOrPartOrJoint && id == TokenBranch )
isBranch = true;
var previous = state;
state = Advance( state, id );
if ( previous == State.ExpectJoint3 )
{
if ( !isBranch )
bones++;
isBranch = false;
}
if ( id == TokenEos )
break;
}
return bones;
}
/// <summary>detokenize + make_skeleton: token sequence → joints/parents/tails.</summary>
public static UniRigSkeleton Detokenize( IReadOnlyList<int> ids )
{
if ( ids.Count < 2 || ids[0] != TokenBos )
throw new FormatException( "UniRig tokenizer: sequence must start with BOS." );
var end = ids.Count;
while ( end > 0 && ids[end - 1] == TokenPad )
end--;
if ( ids[end - 1] != TokenEos )
throw new FormatException( "UniRig tokenizer: sequence must end with EOS." );
var joints = new List<Vector3>();
var parentPositions = new List<Vector3>();
var tailsByBone = new Dictionary<int, Vector3>();
var isBranch = false;
Vector3? lastJoint = null;
Vector3 Coords( int at ) => new(
Undiscretize( ids[at] ), Undiscretize( ids[at + 1] ), Undiscretize( ids[at + 2] ) );
var i = 1;
while ( i < end - 1 )
{
var id = ids[i];
if ( id < NumDiscrete )
{
Vector3 current;
if ( isBranch )
{
parentPositions.Add( Coords( i ) );
current = Coords( i + 3 );
joints.Add( current );
i += 6;
}
else
{
current = Coords( i );
joints.Add( current );
parentPositions.Add( lastJoint ?? current ); // root parents itself
i += 3;
}
if ( lastJoint is not null )
tailsByBone[joints.Count - 2] = current;
lastJoint = current;
isBranch = false;
}
else if ( id == TokenBranch )
{
isBranch = true;
lastJoint = null;
i++;
}
else if ( id == TokenSpring || IsPart( id ) || IsCls( id ) || id == TokenClsNone )
{
i++; // parts/class recorded upstream; geometry unaffected
}
else
{
throw new FormatException( $"UniRig tokenizer: unexpected token {id}." );
}
}
// make_skeleton: parent of joint i = earlier bone whose joint is nearest to
// the recorded parent position (scan i-1 → 0, strictly-smaller wins, so the
// LATER index wins ties — reversed scan order, matching the reference).
var count = joints.Count;
var parents = new int[count];
for ( var j = 0; j < count; j++ )
{
if ( j == 0 )
{
parents[0] = -1;
continue;
}
var best = -1;
var bestDistance = float.MaxValue;
for ( var k = j - 1; k >= 0; k-- )
{
var d = Vector3.DistanceSquared( joints[k], parentPositions[j] );
if ( d < bestDistance )
{
bestDistance = d;
best = k;
}
}
parents[j] = best;
}
// Tails: recorded child position, else extruded for leaves/branches
// (extrude_scale 0.5 along the bone direction; z-up fallback).
var childCount = new int[count];
foreach ( var p in parents )
if ( p >= 0 )
childCount[p]++;
var tails = new Vector3[count];
for ( var j = 0; j < count; j++ )
{
if ( childCount[j] == 0 ) // leaf
{
var direction = parents[j] >= 0 ? joints[j] - joints[parents[j]] : default;
if ( direction.LengthSquared() <= 1e-18f )
direction = new Vector3( 0f, 0f, 1f );
tails[j] = joints[j] + direction * 0.5f;
}
else if ( childCount[j] > 1 ) // branch
{
Vector3 direction;
if ( parents[j] < 0 )
{
float averageLength = 0;
for ( var c = 0; c < count; c++ )
if ( parents[c] == j )
averageLength += Vector3.Distance( joints[j], joints[c] );
averageLength /= childCount[j];
tails[j] = joints[j] + new Vector3( 0f, 0f, 0.5f * averageLength );
continue;
}
direction = joints[j] - joints[parents[j]];
if ( direction.LengthSquared() <= 1e-18f )
direction = new Vector3( 0f, 0f, 1f );
tails[j] = joints[j] + direction * 0.5f;
}
else
{
tails[j] = tailsByBone.TryGetValue( j, out var tail ) ? tail : joints[j];
}
}
return new UniRigSkeleton
{
Joints = joints.ToArray(),
Parents = parents,
Tails = tails,
};
}
}
using AutoRig.Analyze;
using AutoRig.Rig;
namespace AutoRig.Solve;
/// <summary>Which solver family to use. Auto lets the classifier route.</summary>
public enum RigMode
{
Auto,
Mechanical,
Organic,
Floor,
/// <summary>Neural rigging; needs an installed model (see <see cref="RigNetBundle"/>).</summary>
DeepLearning,
/// <summary>Copy skeleton + weights from a rigged donor model (see <see cref="Rig.DonorRig"/>).</summary>
Transfer,
}
/// <summary>
/// The solver façade: routes an analyzed mesh to the right solver and guarantees a
/// valid result for any mesh that passed <see cref="Mesh.RigMesh.Validate"/> — solver
/// failures degrade to the floor rig instead of throwing.
/// </summary>
public static class AutoRigger
{
/// <param name="deepLearning">Loaded neural model; required for
/// <see cref="RigMode.DeepLearning"/>. Without one that mode degrades to the
/// geometric route (marked Degraded with an explanation).</param>
/// <param name="donor">Rigged donor; required for <see cref="RigMode.Transfer"/>
/// (same degrade rule).</param>
public static RigResult Rig(
AnalysisResult analysis, RigMode mode = RigMode.Auto,
RigNetBundle deepLearning = null, Rig.DonorRig donor = null )
{
ArgumentNullException.ThrowIfNull( analysis );
if ( (mode == RigMode.DeepLearning && deepLearning is null)
|| (mode == RigMode.Transfer && donor is null) )
{
var geometric = Rig( analysis, RigMode.Auto );
return new RigResult
{
Skeleton = geometric.Skeleton,
Weights = geometric.Weights,
SolverName = geometric.SolverName,
Degraded = true,
Explanation = (mode == RigMode.Transfer
? "No donor model was picked - used the geometric solver instead. "
: "No deep-learning model is installed - used the geometric solver instead. ")
+ geometric.Explanation,
};
}
try
{
var result = mode switch
{
RigMode.Mechanical => MechanicalSolver.Rig( analysis ),
RigMode.Organic => OrganicSolver.Rig( analysis ),
RigMode.Floor => FloorSolver.Rig( analysis ),
RigMode.DeepLearning => DeepLearningSolver.Rig( analysis, deepLearning ),
RigMode.Transfer => TransferSolver.Rig( analysis, donor ),
_ => analysis.Classification.Kind == MeshKind.Mechanical
? MechanicalSolver.Rig( analysis )
: OrganicSolver.Rig( analysis ),
};
result.Skeleton.Validate();
result.Weights.Validate( analysis.Mesh, result.Skeleton );
return result;
}
catch ( Exception e )
{
// Failed neural/transfer rigs fall back to the geometric route first.
if ( mode is RigMode.DeepLearning or RigMode.Transfer )
{
var geometric = Rig( analysis, RigMode.Auto );
return new RigResult
{
Skeleton = geometric.Skeleton,
Weights = geometric.Weights,
SolverName = geometric.SolverName,
Degraded = true,
Explanation = $"The {mode} solver failed ({FirstLine( e.Message )}) - "
+ "used the geometric solver instead.",
};
}
var floor = FloorSolver.Rig( analysis );
return new RigResult
{
Skeleton = floor.Skeleton,
Weights = floor.Weights,
SolverName = floor.SolverName,
Degraded = true,
Explanation = $"The {mode} solver failed ({FirstLine( e.Message )}) - "
+ "produced a single-bone rig instead.",
};
}
}
static string FirstLine( string text )
{
var i = text.IndexOfAny( [ '\r', '\n' ] );
return i < 0 ? text : text[..i];
}
}
using AutoRig.Rig;
namespace AutoRig.Solve.Organic;
/// <summary>
/// Builds a generic rig straight from the curve skeleton: one joint per graph node,
/// rooted at the core, chains named per branch ("limb1_01", …). Used for organic
/// categories without a dedicated template.
/// </summary>
public static class GraphSkeletonBuilder
{
public static RigSkeleton Build( SkeletonGraph graph )
{
ArgumentNullException.ThrowIfNull( graph );
var skeleton = new RigSkeleton();
if ( graph.Nodes.Count == 0 )
throw new FormatException( "Curve skeleton has no nodes." );
var jointOf = new int[graph.Nodes.Count];
Array.Fill( jointOf, -1 );
// BFS from core so parents precede children.
var queue = new Queue<int>();
queue.Enqueue( graph.Core );
jointOf[graph.Core] = 0;
skeleton.Joints.Add( new RigJoint
{
Name = "root",
Parent = -1,
Position = graph.Nodes[graph.Core].Position,
} );
var branchCounter = 0;
var branchOf = new int[graph.Nodes.Count]; // branch id per node
var linkOf = new int[graph.Nodes.Count]; // index within its branch chain
branchOf[graph.Core] = -1;
while ( queue.Count > 0 )
{
var current = queue.Dequeue();
foreach ( var next in graph.Nodes[current].Neighbors.OrderBy( n => n ) )
{
if ( jointOf[next] >= 0 )
continue;
// New branch starts when leaving the core or a junction.
int branch, link;
if ( current == graph.Core || graph.Nodes[current].Neighbors.Count >= 3 )
{
branch = ++branchCounter;
link = 1;
}
else
{
branch = branchOf[current];
link = linkOf[current] + 1;
}
branchOf[next] = branch;
linkOf[next] = link;
jointOf[next] = skeleton.Joints.Count;
skeleton.Joints.Add( new RigJoint
{
Name = $"limb{branch}_{link:00}",
Parent = jointOf[current],
Position = graph.Nodes[next].Position,
} );
queue.Enqueue( next );
}
}
skeleton.Validate();
return skeleton;
}
}
using AutoRig.Analyze;
using AutoRig.Dl.RigNet;
using AutoRig.Rig;
using AutoRig.Voxel;
namespace AutoRig.Solve;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// The transfer solver (spec §5.4): fits a rigged donor's skeleton into the target
/// mesh and carries its skin weights across. Both models are normalized into the
/// same unit space, the donor is stretched per-axis to the target's proportions,
/// joints are snapped into the target's solid, and each target vertex blends the
/// weights of its nearest donor vertices (inverse-squared distance, then one-ring
/// smoothing).
/// </summary>
public static class TransferSolver
{
const int NearestDonorVertices = 4;
const float SmoothingBlend = 0.5f;
public static RigResult Rig( AnalysisResult analysis, DonorRig donor )
{
ArgumentNullException.ThrowIfNull( analysis );
ArgumentNullException.ThrowIfNull( donor );
var target = analysis.Mesh;
// ---- shared unit space + per-axis affine donor → target ----
var (targetNormalized, targetPivot, targetScale) = RigNetInput.Normalize( target.Positions );
var (donorNormalized, donorPivot, donorScale) = RigNetInput.Normalize( donor.Mesh.Positions );
var targetBounds = Mesh.Aabb3.FromPoints( targetNormalized );
var donorBounds = Mesh.Aabb3.FromPoints( donorNormalized );
var stretch = new Vector3(
SafeRatio( targetBounds.Size.X, donorBounds.Size.X ),
SafeRatio( targetBounds.Size.Y, donorBounds.Size.Y ),
SafeRatio( targetBounds.Size.Z, donorBounds.Size.Z ) );
var offset = targetBounds.Min - donorBounds.Min * stretch;
Vector3 Fit( Vector3 donorPoint ) => donorPoint * stretch + offset;
var donorVertices = new Vector3[donorNormalized.Length];
for ( var v = 0; v < donorNormalized.Length; v++ )
donorVertices[v] = Fit( donorNormalized[v] );
// Donor joints through the same normalize (mesh-derived pivot/scale) + fit.
var joints = new Vector3[donor.Skeleton.Joints.Count];
for ( var j = 0; j < joints.Length; j++ )
joints[j] = Fit( (donor.Skeleton.Joints[j].Position - donorPivot) * donorScale );
// ---- snap joints into the target's solid ----
var voxels = BuildVoxels( target, targetNormalized );
if ( voxels is not null )
for ( var j = 0; j < joints.Length; j++ )
joints[j] = SnapInside( joints[j], voxels );
// ---- weights: inverse-squared-distance blend of nearest donor vertices ----
var jointCount = donor.Skeleton.Joints.Count;
var raw = new float[targetNormalized.Length][];
var nearest = new int[NearestDonorVertices];
var nearestDistance = new float[NearestDonorVertices];
for ( var v = 0; v < targetNormalized.Length; v++ )
{
var count = 0;
for ( var d = 0; d < donorVertices.Length; d++ )
{
var distance = Vector3.DistanceSquared( targetNormalized[v], donorVertices[d] );
if ( count < NearestDonorVertices )
{
nearest[count] = d;
nearestDistance[count] = distance;
count++;
}
else
{
var worst = 0;
for ( var k = 1; k < NearestDonorVertices; k++ )
if ( nearestDistance[k] > nearestDistance[worst] )
worst = k;
if ( distance < nearestDistance[worst] )
{
nearest[worst] = d;
nearestDistance[worst] = distance;
}
}
}
var row = new float[jointCount];
float weightSum = 0;
for ( var k = 0; k < count; k++ )
weightSum += 1f / MathF.Max( nearestDistance[k], 1e-12f );
for ( var k = 0; k < count; k++ )
{
var blend = 1f / MathF.Max( nearestDistance[k], 1e-12f ) / weightSum;
var donorVertex = nearest[k];
for ( var slot = 0; slot < 4; slot++ )
{
var weight = donor.Weights.Weights[donorVertex * 4 + slot];
if ( weight > 0f )
row[donor.Weights.BoneIndices[donorVertex * 4 + slot]] += blend * weight;
}
}
raw[v] = row;
}
SmoothOneRing( raw, target.Triangles );
// ---- assemble ----
var skeleton = new RigSkeleton();
for ( var j = 0; j < jointCount; j++ )
{
skeleton.Joints.Add( new RigJoint
{
Name = donor.Skeleton.Joints[j].Name,
Parent = donor.Skeleton.Joints[j].Parent,
Position = joints[j] / targetScale + targetPivot,
} );
}
var indices = new int[targetNormalized.Length * 4];
var weights = new float[targetNormalized.Length * 4];
var top = new int[4];
for ( var v = 0; v < targetNormalized.Length; v++ )
{
var row = raw[v];
var count = 0;
for ( var j = 0; j < jointCount; j++ )
{
if ( row[j] <= 0f )
continue;
if ( count < 4 )
top[count++] = j;
else
{
var weakest = 0;
for ( var k = 1; k < 4; k++ )
if ( row[top[k]] < row[top[weakest]] )
weakest = k;
if ( row[j] > row[top[weakest]] )
top[weakest] = j;
}
}
float total = 0;
for ( var k = 0; k < count; k++ )
total += row[top[k]];
if ( count == 0 || total <= 0f )
{
indices[v * 4] = NearestJoint( joints, targetNormalized[v] );
weights[v * 4] = 1f;
continue;
}
for ( var k = 0; k < count; k++ )
{
indices[v * 4 + k] = top[k];
weights[v * 4 + k] = row[top[k]] / total;
}
}
return new RigResult
{
Skeleton = skeleton,
Weights = new SkinWeights { BoneIndices = indices, Weights = weights },
SolverName = "transfer",
Degraded = false,
Explanation = $"Transferred {jointCount} joints and their skin weights "
+ $"from '{donor.Mesh.SourceName}'.",
};
}
static float SafeRatio( float target, float donor )
=> donor > 1e-6f ? target / donor : 1f;
static VoxelGrid BuildVoxels( Mesh.RigMesh target, Vector3[] normalizedPositions )
{
try
{
var proxy = new Mesh.RigMesh
{
SourceName = target.SourceName,
Positions = normalizedPositions,
Normals = target.Normals,
Uvs = target.Uvs,
Triangles = target.Triangles,
TriangleTags = target.TriangleTags,
Tags = target.Tags,
};
return VoxelGrid.Build( proxy, 64 );
}
catch ( FormatException )
{
return null; // degenerate target — skip snapping
}
}
/// <summary>Nearest solid voxel center by outward ring search (unchanged when
/// the joint is already inside).</summary>
static Vector3 SnapInside( Vector3 point, VoxelGrid voxels )
{
var (x, y, z) = voxels.VoxelOf( point );
if ( voxels.IsSolid( x, y, z ) )
return point;
var maxRadius = Math.Max( voxels.SizeX, Math.Max( voxels.SizeY, voxels.SizeZ ) );
for ( var radius = 1; radius <= maxRadius; radius++ )
{
var best = point;
var bestDistance = float.MaxValue;
for ( var dx = -radius; dx <= radius; dx++ )
for ( var dy = -radius; dy <= radius; dy++ )
for ( var dz = -radius; dz <= radius; dz++ )
{
if ( Math.Max( Math.Abs( dx ), Math.Max( Math.Abs( dy ), Math.Abs( dz ) ) ) != radius )
continue; // shell only
if ( !voxels.IsSolid( x + dx, y + dy, z + dz ) )
continue;
var center = voxels.CenterOf( x + dx, y + dy, z + dz );
var distance = Vector3.DistanceSquared( center, point );
if ( distance < bestDistance )
{
bestDistance = distance;
best = center;
}
}
if ( bestDistance < float.MaxValue )
return best;
}
return point;
}
/// <summary>Blend each vertex row toward its one-ring neighborhood mean.</summary>
static void SmoothOneRing( float[][] rows, int[] triangles )
{
var vertexCount = rows.Length;
var neighbors = new HashSet<int>[vertexCount];
for ( var v = 0; v < vertexCount; v++ )
neighbors[v] = new HashSet<int>();
for ( var t = 0; t < triangles.Length; t += 3 )
{
int a = triangles[t], b = triangles[t + 1], c = triangles[t + 2];
neighbors[a].Add( b ); neighbors[a].Add( c );
neighbors[b].Add( a ); neighbors[b].Add( c );
neighbors[c].Add( a ); neighbors[c].Add( b );
}
var jointCount = rows[0].Length;
var smoothed = new float[vertexCount][];
for ( var v = 0; v < vertexCount; v++ )
{
if ( neighbors[v].Count == 0 )
{
smoothed[v] = rows[v];
continue;
}
var mean = new float[jointCount];
foreach ( var n in neighbors[v] )
for ( var j = 0; j < jointCount; j++ )
mean[j] += rows[n][j];
var row = new float[jointCount];
for ( var j = 0; j < jointCount; j++ )
row[j] = rows[v][j] * (1f - SmoothingBlend)
+ mean[j] / neighbors[v].Count * SmoothingBlend;
smoothed[v] = row;
}
for ( var v = 0; v < vertexCount; v++ )
rows[v] = smoothed[v];
}
static int NearestJoint( Vector3[] joints, Vector3 point )
{
var best = 0;
for ( var j = 1; j < joints.Length; j++ )
if ( Vector3.DistanceSquared( joints[j], point )
< Vector3.DistanceSquared( joints[best], point ) )
best = j;
return best;
}
}
using AutoRig.Analyze;
using AutoRig.Mesh;
namespace AutoRig.Voxel;
// s&box compat: the engine defines Vector2/Vector3 in the GLOBAL namespace, which
// shadows using-directive imports - alias explicitly to System.Numerics.
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// A solid voxelization of a mesh: conservative surface rasterization plus interior
/// fill (exterior flood from the padded border; whatever the flood cannot reach and
/// is not surface is interior). Solid = surface ∪ interior.
/// </summary>
public sealed class VoxelGrid
{
public int SizeX { get; private init; }
public int SizeY { get; private init; }
public int SizeZ { get; private init; }
public float CellSize { get; private init; }
/// <summary>World position of voxel (0,0,0)'s min corner.</summary>
public Vector3 Origin { get; private init; }
public int SolidCount { get; private set; }
bool[] _solid = [];
public bool IsSolid( int x, int y, int z )
=> x >= 0 && x < SizeX && y >= 0 && y < SizeY && z >= 0 && z < SizeZ
&& _solid[Index( x, y, z )];
public Vector3 CenterOf( int x, int y, int z )
=> Origin + new Vector3( (x + 0.5f) * CellSize, (y + 0.5f) * CellSize, (z + 0.5f) * CellSize );
public (int X, int Y, int Z) VoxelOf( Vector3 world )
{
var p = (world - Origin) / CellSize;
return (Math.Clamp( (int)p.X, 0, SizeX - 1 ),
Math.Clamp( (int)p.Y, 0, SizeY - 1 ),
Math.Clamp( (int)p.Z, 0, SizeZ - 1 ));
}
public int Index( int x, int y, int z ) => (z * SizeY + y) * SizeX + x;
/// <exception cref="FormatException">Empty mesh or degenerate bounds.</exception>
public static VoxelGrid Build( RigMesh mesh, int maxDimension )
{
ArgumentNullException.ThrowIfNull( mesh );
if ( mesh.Positions.Length == 0 || mesh.TriangleCount == 0 )
throw new FormatException( "Cannot voxelize an empty mesh." );
if ( maxDimension < 4 )
throw new FormatException( $"Voxel maxDimension {maxDimension} too small (need >= 4)." );
var bounds = mesh.ComputeBounds();
var size = bounds.Size;
var longest = MathF.Max( size.X, MathF.Max( size.Y, size.Z ) );
if ( longest <= 0f )
throw new FormatException( "Cannot voxelize a mesh with zero extent." );
const int padding = 2;
var cell = longest / maxDimension;
var sizeX = Math.Max( 2, (int)MathF.Ceiling( size.X / cell ) ) + padding * 2;
var sizeY = Math.Max( 2, (int)MathF.Ceiling( size.Y / cell ) ) + padding * 2;
var sizeZ = Math.Max( 2, (int)MathF.Ceiling( size.Z / cell ) ) + padding * 2;
var grid = new VoxelGrid
{
SizeX = sizeX,
SizeY = sizeY,
SizeZ = sizeZ,
CellSize = cell,
Origin = bounds.Min - new Vector3( padding * cell ),
};
grid._solid = new bool[sizeX * sizeY * sizeZ];
var surface = grid._solid; // filled as surface first
// ---- conservative surface rasterization ----
var reach = cell * 0.87f; // ~half the cell diagonal: cell centers within this of a triangle are surface
for ( var t = 0; t < mesh.Triangles.Length; t += 3 )
{
var a = mesh.Positions[mesh.Triangles[t]];
var b = mesh.Positions[mesh.Triangles[t + 1]];
var c = mesh.Positions[mesh.Triangles[t + 2]];
var min = Vector3.Min( a, Vector3.Min( b, c ) ) - new Vector3( reach );
var max = Vector3.Max( a, Vector3.Max( b, c ) ) + new Vector3( reach );
var (x0, y0, z0) = grid.VoxelOf( min );
var (x1, y1, z1) = grid.VoxelOf( max );
for ( var z = z0; z <= z1; z++ )
for ( var y = y0; y <= y1; y++ )
for ( var x = x0; x <= x1; x++ )
{
var i = grid.Index( x, y, z );
if ( surface[i] )
continue;
var center = grid.CenterOf( x, y, z );
var closest = PartContacts.ClosestPointOnTriangle( center, a, b, c );
if ( Vector3.DistanceSquared( center, closest ) <= reach * reach )
surface[i] = true;
}
}
// ---- exterior flood (6-connectivity) from every border voxel ----
var exterior = new bool[surface.Length];
var queue = new Queue<(int X, int Y, int Z)>();
void Push( int x, int y, int z )
{
var i = grid.Index( x, y, z );
if ( exterior[i] || surface[i] )
return;
exterior[i] = true;
queue.Enqueue( (x, y, z) );
}
for ( var x = 0; x < sizeX; x++ )
for ( var y = 0; y < sizeY; y++ )
{
Push( x, y, 0 );
Push( x, y, sizeZ - 1 );
}
for ( var x = 0; x < sizeX; x++ )
for ( var z = 0; z < sizeZ; z++ )
{
Push( x, 0, z );
Push( x, sizeY - 1, z );
}
for ( var y = 0; y < sizeY; y++ )
for ( var z = 0; z < sizeZ; z++ )
{
Push( 0, y, z );
Push( sizeX - 1, y, z );
}
while ( queue.Count > 0 )
{
var (x, y, z) = queue.Dequeue();
if ( x > 0 ) Push( x - 1, y, z );
if ( x < sizeX - 1 ) Push( x + 1, y, z );
if ( y > 0 ) Push( x, y - 1, z );
if ( y < sizeY - 1 ) Push( x, y + 1, z );
if ( z > 0 ) Push( x, y, z - 1 );
if ( z < sizeZ - 1 ) Push( x, y, z + 1 );
}
// ---- solid = surface ∪ interior (not exterior) ----
var solidCount = 0;
for ( var i = 0; i < surface.Length; i++ )
{
surface[i] = surface[i] || !exterior[i];
if ( surface[i] )
solidCount++;
}
grid.SolidCount = solidCount;
return grid;
}
}
using System.Numerics;
namespace AutoRig.Analyze;
// s&box compat: the engine defines Vector2/Vector3 in the GLOBAL namespace, which
// shadows using-directive imports - alias explicitly to System.Numerics.
using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;
/// <summary>A detected bilateral symmetry plane.</summary>
public readonly record struct SymmetryPlane( Vector3 Origin, Vector3 Normal, float Score );
using AutoRig.Mesh;
namespace AutoRig.Dl.RigNet;
using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// Builds RigNet's network inputs from a RigMesh, following quick_start's
/// preparation: normalize to a unit-height space, reduce to a proxy mesh of a few
/// thousand vertices (vertex clustering instead of quadric decimation — weights
/// transfer back by nearest proxy vertex, exactly like the original's remesh flow),
/// one-ring topology edges and geodesic-ball edges over a sampled surface
/// geodesic. Randomized reference steps (poisson sampling, random ball picks) are
/// replaced with deterministic equivalents.
/// </summary>
public static class RigNetInput
{
/// <summary>normalize_obj: scale 1/longest-dim; pivot (center x, min y, center z).</summary>
public static (Vector3[] Positions, Vector3 Pivot, float Scale) Normalize( Vector3[] positions )
{
var bounds = Aabb3.FromPoints( positions );
var size = bounds.Size;
var scale = 1f / MathF.Max( size.X, MathF.Max( size.Y, size.Z ) );
var pivot = new Vector3(
(bounds.Min.X + bounds.Max.X) * 0.5f, bounds.Min.Y, (bounds.Min.Z + bounds.Max.Z) * 0.5f );
var result = new Vector3[positions.Length];
for ( var i = 0; i < positions.Length; i++ )
result[i] = (positions[i] - pivot) * scale;
return (result, pivot, scale);
}
/// <summary>
/// Vertex-clustering decimation: bucket vertices on a uniform grid sized so the
/// proxy stays at or under <paramref name="targetVertices"/>, average each
/// cluster, drop collapsed triangles. Returns the proxy plus each original
/// vertex's proxy index (for weight transfer back).
/// </summary>
public static (RigMesh Proxy, int[] VertexMap) Decimate( RigMesh mesh, int targetVertices )
{
ArgumentNullException.ThrowIfNull( mesh );
if ( mesh.Positions.Length <= targetVertices )
return (mesh, Enumerable.Range( 0, mesh.Positions.Length ).ToArray());
var bounds = mesh.ComputeBounds();
var size = bounds.Size;
var longest = MathF.Max( size.X, MathF.Max( size.Y, size.Z ) );
// Shrink the grid until the cluster count fits the budget.
var resolution = (int)MathF.Ceiling( MathF.Cbrt( targetVertices ) ) * 2;
int[] map;
Vector3[] proxyPositions;
while ( true )
{
var cell = longest / resolution;
var clusters = new Dictionary<(int, int, int), int>();
map = new int[mesh.Positions.Length];
var sums = new List<Vector3>();
var counts = new List<int>();
for ( var v = 0; v < mesh.Positions.Length; v++ )
{
var p = (mesh.Positions[v] - bounds.Min) / cell;
var key = ((int)p.X, (int)p.Y, (int)p.Z);
if ( !clusters.TryGetValue( key, out var id ) )
{
id = sums.Count;
clusters[key] = id;
sums.Add( Vector3.Zero );
counts.Add( 0 );
}
map[v] = id;
sums[id] += mesh.Positions[v];
counts[id]++;
}
if ( sums.Count <= targetVertices || resolution <= 4 )
{
proxyPositions = new Vector3[sums.Count];
for ( var i = 0; i < sums.Count; i++ )
proxyPositions[i] = sums[i] / counts[i];
break;
}
resolution = (int)(resolution * 0.8f);
}
var triangles = new List<int>();
for ( var t = 0; t < mesh.TriangleCount; t++ )
{
int a = map[mesh.Triangles[t * 3]], b = map[mesh.Triangles[t * 3 + 1]],
c = map[mesh.Triangles[t * 3 + 2]];
if ( a != b && b != c && a != c )
{
triangles.Add( a );
triangles.Add( b );
triangles.Add( c );
}
}
var proxy = new RigMesh
{
SourceName = mesh.SourceName,
Positions = proxyPositions,
Normals = new Vector3[proxyPositions.Length],
Uvs = new Vector2[proxyPositions.Length],
Triangles = triangles.ToArray(),
TriangleTags = new int[triangles.Count / 3],
Tags = mesh.Tags,
};
return (proxy, map);
}
/// <summary>get_tpl_edges: one-ring mesh edges, both directions, no self-loops.</summary>
public static (int From, int To)[] TopologyEdges( int vertexCount, int[] triangles )
{
var neighbors = new HashSet<int>[vertexCount];
for ( var v = 0; v < vertexCount; v++ )
neighbors[v] = new HashSet<int>();
for ( var t = 0; t < triangles.Length; t += 3 )
{
int a = triangles[t], b = triangles[t + 1], c = triangles[t + 2];
neighbors[a].Add( b ); neighbors[a].Add( c );
neighbors[b].Add( a ); neighbors[b].Add( c );
neighbors[c].Add( a ); neighbors[c].Add( b );
}
var edges = new List<(int, int)>();
for ( var v = 0; v < vertexCount; v++ )
foreach ( var n in neighbors[v].OrderBy( n => n ) )
edges.Add( (v, n) );
return edges.ToArray();
}
/// <summary>
/// get_geo_edges: for each vertex, edges to vertices within surface-geodesic
/// distance 0.06 (up to 10, evenly strided instead of the original's random
/// sample), both stored as (from vertex, to neighbor).
/// </summary>
public static (int From, int To)[] GeodesicEdges( SurfaceGeodesic geodesic, int vertexCount )
{
var edges = new List<(int, int)>();
var ball = new List<int>();
for ( var v = 0; v < vertexCount; v++ )
{
ball.Clear();
for ( var u = 0; u < vertexCount; u++ )
if ( u != v && geodesic.Distance( v, u ) <= 0.06f )
ball.Add( u );
if ( ball.Count > 10 )
{
var strided = new List<int>( 10 );
for ( var i = 0; i < 10; i++ )
strided.Add( ball[i * ball.Count / 10] );
ball = strided;
}
foreach ( var u in ball )
edges.Add( (v, u) );
}
return edges.ToArray();
}
}
/// <summary>
/// Sampled surface geodesic distances (calc_surface_geodesic): deterministic
/// area-weighted surface samples, a 5-NN graph filtered by normal agreement
/// (cos > -0.5), all-pairs Dijkstra, vertices mapped to their nearest sample.
/// Disconnected pairs fall back to 8 + euclidean, like the reference.
/// </summary>
public sealed class SurfaceGeodesic
{
readonly float[] _sampleDistances; // [S*S]
readonly int[] _vertexSample; // vertex → sample id
readonly Vector3[] _samplePositions;
readonly int _sampleCount;
public float Distance( int v0, int v1 )
{
int s0 = _vertexSample[v0], s1 = _vertexSample[v1];
var d = _sampleDistances[s0 * _sampleCount + s1];
return float.IsInfinity( d )
? 8f + Vector3.Distance( _samplePositions[s0], _samplePositions[s1] )
: d;
}
SurfaceGeodesic( float[] sampleDistances, int[] vertexSample, Vector3[] samplePositions )
{
_sampleDistances = sampleDistances;
_vertexSample = vertexSample;
_samplePositions = samplePositions;
_sampleCount = samplePositions.Length;
}
public static SurfaceGeodesic Build( RigMesh mesh, int sampleCount = 1024 )
{
ArgumentNullException.ThrowIfNull( mesh );
var (samples, normals) = SampleSurface( mesh, sampleCount );
var s = samples.Length;
// 5-NN graph, keeping neighbors whose normals do not oppose (cos > -0.5).
var adjacency = new List<(int To, float Weight)>[s];
for ( var i = 0; i < s; i++ )
adjacency[i] = new List<(int, float)>();
var order = new int[s];
var distances = new float[s];
for ( var i = 0; i < s; i++ )
{
for ( var j = 0; j < s; j++ )
{
order[j] = j;
distances[j] = Vector3.DistanceSquared( samples[i], samples[j] );
}
Array.Sort( distances, order );
for ( var pick = 1; pick <= 5 && pick < s; pick++ )
{
var j = order[pick];
var cos = Vector3.Dot( normals[i], normals[j] )
/ (normals[i].Length() * normals[j].Length() + 1e-10f);
if ( cos > -0.5f )
{
var weight = MathF.Sqrt( distances[pick] );
adjacency[i].Add( (j, weight) );
adjacency[j].Add( (i, weight) );
}
}
}
// All-pairs Dijkstra over the sample graph (inline binary heap — no
// PriorityQueue, it is not proven against the s&box whitelist).
var all = new float[s * s];
var heap = new MinHeap( s * 8 );
for ( var source = 0; source < s; source++ )
{
var dist = new float[s];
Array.Fill( dist, float.PositiveInfinity );
dist[source] = 0f;
heap.Count = 0;
heap.Push( source, 0f );
while ( heap.TryPop( out var u, out var du ) )
{
if ( du > dist[u] )
continue;
foreach ( var (to, weight) in adjacency[u] )
{
var candidate = du + weight;
if ( candidate < dist[to] )
{
dist[to] = candidate;
heap.Push( to, candidate );
}
}
}
Array.Copy( dist, 0, all, source * s, s );
}
// Vertex → nearest sample.
var vertexSample = new int[mesh.Positions.Length];
for ( var v = 0; v < mesh.Positions.Length; v++ )
{
var best = 0;
var bestDistance = float.MaxValue;
for ( var i = 0; i < s; i++ )
{
var d = Vector3.DistanceSquared( mesh.Positions[v], samples[i] );
if ( d < bestDistance )
{
bestDistance = d;
best = i;
}
}
vertexSample[v] = best;
}
return new SurfaceGeodesic( all, vertexSample, samples );
}
/// <summary>A minimal (id, key) binary min-heap for Dijkstra.</summary>
sealed class MinHeap
{
int[] _ids;
float[] _keys;
public int Count;
public MinHeap( int capacity )
{
_ids = new int[Math.Max( capacity, 16 )];
_keys = new float[Math.Max( capacity, 16 )];
}
public void Push( int id, float key )
{
if ( Count == _ids.Length )
{
Array.Resize( ref _ids, Count * 2 );
Array.Resize( ref _keys, Count * 2 );
}
var i = Count++;
while ( i > 0 )
{
var parent = (i - 1) / 2;
if ( _keys[parent] <= key )
break;
_ids[i] = _ids[parent];
_keys[i] = _keys[parent];
i = parent;
}
_ids[i] = id;
_keys[i] = key;
}
public bool TryPop( out int id, out float key )
{
if ( Count == 0 )
{
id = 0;
key = 0f;
return false;
}
id = _ids[0];
key = _keys[0];
Count--;
var lastId = _ids[Count];
var lastKey = _keys[Count];
var i = 0;
while ( true )
{
var child = i * 2 + 1;
if ( child >= Count )
break;
if ( child + 1 < Count && _keys[child + 1] < _keys[child] )
child++;
if ( _keys[child] >= lastKey )
break;
_ids[i] = _ids[child];
_keys[i] = _keys[child];
i = child;
}
_ids[i] = lastId;
_keys[i] = lastKey;
return true;
}
}
/// <summary>Deterministic area-weighted surface sampling with low-discrepancy
/// barycentrics (stands in for poisson-disk sampling).</summary>
static (Vector3[] Points, Vector3[] Normals) SampleSurface( RigMesh mesh, int sampleCount )
{
var triangleCount = mesh.TriangleCount;
var cumulative = new float[triangleCount];
float total = 0f;
for ( var t = 0; t < triangleCount; t++ )
{
var a = mesh.Positions[mesh.Triangles[t * 3]];
var b = mesh.Positions[mesh.Triangles[t * 3 + 1]];
var c = mesh.Positions[mesh.Triangles[t * 3 + 2]];
total += Vector3.Cross( b - a, c - a ).Length() * 0.5f;
cumulative[t] = total;
}
if ( total <= 0f )
throw new FormatException( "Cannot sample a mesh with zero surface area." );
var points = new Vector3[sampleCount];
var normals = new Vector3[sampleCount];
for ( var i = 0; i < sampleCount; i++ )
{
var target = (i + 0.5f) / sampleCount * total;
var t = Array.BinarySearch( cumulative, target );
if ( t < 0 )
t = ~t;
t = Math.Min( t, triangleCount - 1 );
var a = mesh.Positions[mesh.Triangles[t * 3]];
var b = mesh.Positions[mesh.Triangles[t * 3 + 1]];
var c = mesh.Positions[mesh.Triangles[t * 3 + 2]];
// Low-discrepancy barycentric from the sample index (plastic constants).
var r1 = (i * 0.7548776662466927f) % 1f;
var r2 = (i * 0.5698402909980532f) % 1f;
if ( r1 + r2 > 1f )
{
r1 = 1f - r1;
r2 = 1f - r2;
}
points[i] = a + (b - a) * r1 + (c - a) * r2;
var normal = Vector3.Cross( b - a, c - a );
normals[i] = normal.Length() > 1e-12f ? Vector3.Normalize( normal ) : Vector3.UnitY;
}
return (points, normals);
}
}
using System.IO.Compression;
using System.Text;
namespace AutoRig.Dl;
/// <summary>
/// Reads PyTorch checkpoints (both the zip format and the pre-1.6 LEGACY stream
/// format) into named float32 tensors. The embedded pickles run on a RESTRICTED
/// stack machine: only the opcodes a tensor state dict uses, and only three
/// resolvable globals (torch._utils._rebuild_tensor_v2, torch.FloatStorage,
/// collections.OrderedDict). Anything else throws FormatException - arbitrary
/// pickle code can never run.
/// </summary>
public static class TorchCheckpoint
{
/// <exception cref="FormatException">Unsupported container, pickle content, or
/// inconsistent tensor data.</exception>
public static IReadOnlyDictionary<string, Tensor> Parse( byte[] data )
{
ArgumentNullException.ThrowIfNull( data );
if ( data.Length > 2 && data[0] == 0x50 && data[1] == 0x4b )
return ParseZip( data );
if ( data.Length > 2 && data[0] == 0x80 )
return ParseLegacy( data );
throw new FormatException( "torch checkpoint: neither a zip archive nor a legacy pickle stream." );
}
static IReadOnlyDictionary<string, Tensor> ParseZip( byte[] data )
{
var entries = ReadZip( data );
var pickleEntry = entries.Keys.FirstOrDefault( k => k.EndsWith( "/data.pkl", StringComparison.Ordinal ) )
?? throw new FormatException( "torch checkpoint: no data.pkl entry found." );
var prefix = pickleEntry[..^"data.pkl".Length];
var unpickler = new Unpickler( entries[pickleEntry], 0 );
var result = unpickler.Run( out _ );
return Materialize( result, key =>
{
if ( !entries.TryGetValue( $"{prefix}data/{key}", out var bytes ) )
throw new FormatException( $"torch checkpoint: storage '{key}' missing." );
var type = unpickler.Storages.TryGetValue( key, out var info )
? info.Type : "FloatStorage";
return ToFloat32Bytes( bytes, type );
} );
}
/// <summary>
/// Streams a zip-format checkpoint — required for files past the 2GB array
/// limit (e.g. MagicArticulate's 4.4GB training checkpoint, which is also
/// ZIP64). The stream must be seekable. <paramref name="keepTensor"/> filters
/// by full dotted name BEFORE storage bytes are read, so skipping e.g.
/// optimizer state costs no memory or IO.
/// </summary>
public static IReadOnlyDictionary<string, Tensor> Parse(
Stream file, Func<string, bool> keepTensor = null )
{
ArgumentNullException.ThrowIfNull( file );
var entries = ReadZipIndex( file );
var pickleEntry = entries.Keys.FirstOrDefault( k => k.EndsWith( "/data.pkl", StringComparison.Ordinal ) )
?? throw new FormatException( "torch checkpoint: no data.pkl entry found." );
var prefix = pickleEntry[..^"data.pkl".Length];
var unpickler = new Unpickler( ReadZipEntry( file, entries[pickleEntry] ), 0 );
var result = unpickler.Run( out _ );
return Materialize( result, key =>
{
if ( !entries.TryGetValue( $"{prefix}data/{key}", out var entry ) )
throw new FormatException( $"torch checkpoint: storage '{key}' missing." );
var type = unpickler.Storages.TryGetValue( key, out var info )
? info.Type : "FloatStorage";
return ToFloat32Bytes( ReadZipEntry( file, entry ), type );
}, keepTensor );
}
/// <summary>Normalizes accepted storage payloads to raw float32 bytes (bf16/f16
/// checkpoints — e.g. SkinTokens' bfloat16 model — widen at load).</summary>
internal static byte[] ToFloat32Bytes( byte[] bytes, string storageType )
{
switch ( storageType )
{
case "BFloat16Storage":
{
var result = new byte[bytes.Length * 2];
for ( var i = 0; i < bytes.Length / 2; i++ )
{
// bf16 = the high 16 bits of an f32 (little-endian layout).
result[i * 4 + 2] = bytes[i * 2];
result[i * 4 + 3] = bytes[i * 2 + 1];
}
return result;
}
case "HalfStorage":
{
var result = new byte[bytes.Length * 2];
for ( var i = 0; i < bytes.Length / 2; i++ )
{
var value = (float)BitConverter.ToHalf( bytes, i * 2 );
BitConverter.TryWriteBytes( result.AsSpan( i * 4, 4 ), value );
}
return result;
}
default:
return bytes; // FloatStorage — already f32
}
}
/// <summary>
/// Legacy stream: four consecutive pickles (magic, protocol, sys_info, object),
/// then a pickle listing storage keys, then per key an i64 element count followed
/// by the raw float32 storage bytes.
/// </summary>
static IReadOnlyDictionary<string, Tensor> ParseLegacy( byte[] data )
{
var offset = 0;
object result = null;
Unpickler objectUnpickler = null;
for ( var p = 0; p < 4; p++ )
{
objectUnpickler = new Unpickler( data, offset );
result = objectUnpickler.Run( out offset );
}
var keysObject = new Unpickler( data, offset ).Run( out offset );
if ( keysObject is not List<object> keyList )
throw new FormatException( "torch checkpoint: legacy storage-key list missing." );
var storages = new Dictionary<string, byte[]>( StringComparer.Ordinal );
foreach ( var keyObject in keyList )
{
if ( keyObject is not string key )
throw new FormatException( "torch checkpoint: legacy storage key is not a string." );
if ( offset + 8 > data.Length )
throw new FormatException( "torch checkpoint: legacy stream truncated at storage sizes." );
var numel = BitConverter.ToInt64( data, offset );
offset += 8;
// Element width comes from the storage TYPE recorded at its persistent id
// (optimizer state mixes Long/Int storages between the float tensors).
var width = objectUnpickler.Storages.TryGetValue( key, out var info ) ? info.Width : 4;
var byteCount = numel * width;
if ( numel < 0 || offset + byteCount > data.Length )
throw new FormatException( $"torch checkpoint: legacy storage '{key}' overruns the file." );
if ( info.Type is "FloatStorage" or "BFloat16Storage" or "HalfStorage" )
{
var bytes = new byte[byteCount];
Array.Copy( data, offset, bytes, 0, byteCount );
storages[key] = ToFloat32Bytes( bytes, info.Type );
}
offset += (int)byteCount;
}
return Materialize( result, key =>
storages.TryGetValue( key, out var bytes )
? bytes
: throw new FormatException( $"torch checkpoint: legacy storage '{key}' missing." ) );
}
/// <summary>Converts the unpickled graph's tensor stubs into tensors.</summary>
static IReadOnlyDictionary<string, Tensor> Materialize(
object root, Func<string, byte[]> storageBytes,
Func<string, bool> keep = null, string prefix = "" )
{
if ( root is not Dictionary<object, object> dict )
throw new FormatException( "torch checkpoint: top-level pickle value is not a dict." );
var tensors = new Dictionary<string, Tensor>( StringComparer.Ordinal );
foreach ( var (key, value) in dict )
{
if ( key is not string name )
continue;
var full = prefix + name;
if ( value is TensorStub stub )
{
if ( keep is not null && !keep( full ) )
continue;
var bytes = storageBytes( stub.StorageKey );
long count = 1;
foreach ( var d in stub.Shape )
count *= d;
if ( (stub.StorageOffset + count) * 4L > bytes.Length )
throw new FormatException(
$"torch checkpoint: storage '{stub.StorageKey}' too small for shape "
+ $"[{string.Join( ",", stub.Shape )}] at offset {stub.StorageOffset}." );
var values = new float[count];
Buffer.BlockCopy( bytes, stub.StorageOffset * 4, values, 0, (int)count * 4 );
tensors[full] = stub.Shape.Length == 0
? Tensor.From( values, 1 )
: Tensor.From( values, stub.Shape );
}
else if ( value is Dictionary<object, object> nested )
{
// Nested dicts ("state_dict"/"model" wrappers in training checkpoints).
foreach ( var (innerKey, innerValue) in Materialize( nested, storageBytes, keep, $"{full}." ) )
tensors[innerKey] = innerValue;
}
}
return tensors;
}
// ================================================================== zip
/// <summary>Reads a plain zip archive (entry name → bytes). Public so model
/// bundles (e.g. the RigNet checkpoints zip) can be read without extraction.</summary>
public static IReadOnlyDictionary<string, byte[]> ReadArchive( byte[] data ) => ReadZip( data );
// ---- streaming zip index (ZIP64-aware) — for checkpoints past 2GB ----
readonly record struct ZipIndexEntry( long LocalHeaderOffset, long CompressedSize, ushort Method );
static Dictionary<string, ZipIndexEntry> ReadZipIndex( Stream file )
{
// EOCD scan over the file tail (comment can pad up to 64KB).
var tailLength = (int)Math.Min( file.Length, 66_000 );
var tail = ReadAt( file, file.Length - tailLength, tailLength );
var eocd = -1;
for ( var i = tailLength - 22; i >= 0; i-- )
{
if ( tail[i] == 0x50 && tail[i + 1] == 0x4b && tail[i + 2] == 0x05 && tail[i + 3] == 0x06 )
{
eocd = i;
break;
}
}
if ( eocd < 0 )
throw new FormatException( "torch checkpoint: not a zip archive (no end-of-central-directory)." );
long count = BitConverter.ToUInt16( tail, eocd + 10 );
long cdOffset = BitConverter.ToUInt32( tail, eocd + 16 );
if ( count == 0xFFFF || cdOffset == 0xFFFFFFFF )
{
// ZIP64: locator sits 20 bytes before the EOCD.
var locatorAt = eocd - 20;
if ( locatorAt < 0 || BitConverter.ToUInt32( tail, locatorAt ) != 0x07064b50 )
throw new FormatException( "torch checkpoint: ZIP64 locator missing." );
var eocd64Offset = BitConverter.ToInt64( tail, locatorAt + 8 );
var eocd64 = ReadAt( file, eocd64Offset, 56 );
if ( BitConverter.ToUInt32( eocd64, 0 ) != 0x06064b50 )
throw new FormatException( "torch checkpoint: corrupt ZIP64 end-of-central-directory." );
count = BitConverter.ToInt64( eocd64, 32 );
cdOffset = BitConverter.ToInt64( eocd64, 48 );
}
// Central directory can itself be large — read it in one buffer (it is
// tiny relative to the payload: ~100 bytes per entry).
var cdLength = file.Length - cdOffset;
if ( cdLength > int.MaxValue )
throw new FormatException( "torch checkpoint: central directory too large." );
var cd = ReadAt( file, cdOffset, (int)cdLength );
var entries = new Dictionary<string, ZipIndexEntry>( StringComparer.Ordinal );
var at = 0;
for ( long e = 0; e < count; e++ )
{
if ( at + 46 > cd.Length || BitConverter.ToUInt32( cd, at ) != 0x02014b50 )
throw new FormatException( "torch checkpoint: corrupt central directory." );
var method = BitConverter.ToUInt16( cd, at + 10 );
long compressedSize = BitConverter.ToUInt32( cd, at + 20 );
long uncompressedSize = BitConverter.ToUInt32( cd, at + 24 );
var nameLength = BitConverter.ToUInt16( cd, at + 28 );
var extraLength = BitConverter.ToUInt16( cd, at + 30 );
var commentLength = BitConverter.ToUInt16( cd, at + 32 );
long localOffset = BitConverter.ToUInt32( cd, at + 42 );
var name = Encoding.UTF8.GetString( cd, at + 46, nameLength );
// ZIP64 extra field 0x0001: u64 values, in order, only for the
// fixed fields that overflowed to 0xFFFFFFFF.
var extraAt = at + 46 + nameLength;
var extraEnd = extraAt + extraLength;
while ( extraAt + 4 <= extraEnd )
{
var id = BitConverter.ToUInt16( cd, extraAt );
var size = BitConverter.ToUInt16( cd, extraAt + 2 );
if ( id == 0x0001 )
{
var v = extraAt + 4;
if ( uncompressedSize == 0xFFFFFFFF )
{
uncompressedSize = BitConverter.ToInt64( cd, v );
v += 8;
}
if ( compressedSize == 0xFFFFFFFF )
{
compressedSize = BitConverter.ToInt64( cd, v );
v += 8;
}
if ( localOffset == 0xFFFFFFFF )
localOffset = BitConverter.ToInt64( cd, v );
}
extraAt += 4 + size;
}
entries[name] = new ZipIndexEntry( localOffset, compressedSize, method );
at += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
static byte[] ReadZipEntry( Stream file, ZipIndexEntry entry )
{
var header = ReadAt( file, entry.LocalHeaderOffset, 30 );
if ( BitConverter.ToUInt32( header, 0 ) != 0x04034b50 )
throw new FormatException( "torch checkpoint: corrupt local header." );
var nameLength = BitConverter.ToUInt16( header, 26 );
var extraLength = BitConverter.ToUInt16( header, 28 );
var dataOffset = entry.LocalHeaderOffset + 30 + nameLength + extraLength;
if ( entry.CompressedSize > int.MaxValue )
throw new FormatException( "torch checkpoint: single zip entry past 2GB is not supported." );
var raw = ReadAt( file, dataOffset, (int)entry.CompressedSize );
if ( entry.Method == 0 )
return raw;
if ( entry.Method != 8 )
throw new FormatException( $"torch checkpoint: unsupported compression method {entry.Method}." );
using var inflater = new DeflateStream( new MemoryStream( raw ), CompressionMode.Decompress );
using var output = new MemoryStream();
inflater.CopyTo( output );
return output.ToArray();
}
static byte[] ReadAt( Stream file, long offset, int count )
{
file.Seek( offset, SeekOrigin.Begin );
var buffer = new byte[count];
file.ReadExactly( buffer );
return buffer;
}
/// <summary>Central-directory zip walk (stored + deflate).</summary>
static Dictionary<string, byte[]> ReadZip( byte[] data )
{
// EOCD scan from the end.
var eocd = -1;
for ( var i = data.Length - 22; i >= 0; i-- )
{
if ( data[i] == 0x50 && data[i + 1] == 0x4b && data[i + 2] == 0x05 && data[i + 3] == 0x06 )
{
eocd = i;
break;
}
}
if ( eocd < 0 )
throw new FormatException( "torch checkpoint: not a zip archive (no end-of-central-directory)." );
var count = BitConverter.ToUInt16( data, eocd + 10 );
var cdOffset = BitConverter.ToUInt32( data, eocd + 16 );
var entries = new Dictionary<string, byte[]>( StringComparer.Ordinal );
var at = (int)cdOffset;
for ( var e = 0; e < count; e++ )
{
if ( at + 46 > data.Length || BitConverter.ToUInt32( data, at ) != 0x02014b50 )
throw new FormatException( "torch checkpoint: corrupt central directory." );
var method = BitConverter.ToUInt16( data, at + 10 );
var compressedSize = BitConverter.ToUInt32( data, at + 20 );
var uncompressedSize = BitConverter.ToUInt32( data, at + 24 );
var nameLength = BitConverter.ToUInt16( data, at + 28 );
var extraLength = BitConverter.ToUInt16( data, at + 30 );
var commentLength = BitConverter.ToUInt16( data, at + 32 );
var localOffset = BitConverter.ToUInt32( data, at + 42 );
var name = Encoding.UTF8.GetString( data, at + 46, nameLength );
at += 46 + nameLength + extraLength + commentLength;
// Local header carries its own name/extra lengths.
var lh = (int)localOffset;
if ( lh + 30 > data.Length || BitConverter.ToUInt32( data, lh ) != 0x04034b50 )
throw new FormatException( $"torch checkpoint: corrupt local header for '{name}'." );
var localNameLength = BitConverter.ToUInt16( data, lh + 26 );
var localExtraLength = BitConverter.ToUInt16( data, lh + 28 );
var dataStart = lh + 30 + localNameLength + localExtraLength;
if ( dataStart + compressedSize > data.Length )
throw new FormatException( $"torch checkpoint: entry '{name}' overruns the file." );
byte[] content;
if ( method == 0 )
{
content = new byte[compressedSize];
Array.Copy( data, dataStart, content, 0, (int)compressedSize );
}
else if ( method == 8 )
{
content = new byte[uncompressedSize];
using var ms = new MemoryStream( data, dataStart, (int)compressedSize );
using var deflate = new DeflateStream( ms, CompressionMode.Decompress );
var read = 0;
while ( read < content.Length )
{
var n = deflate.Read( content, read, content.Length - read );
if ( n <= 0 )
throw new FormatException( $"torch checkpoint: truncated deflate data in '{name}'." );
read += n;
}
}
else
{
throw new FormatException( $"torch checkpoint: unsupported zip method {method} for '{name}'." );
}
entries[name] = content;
}
return entries;
}
// ================================================================== pickle
sealed class StorageRef
{
public required string Key;
public required long Numel;
}
sealed class GlobalRef
{
public required string Module;
public required string Name;
}
/// <summary>An unknown global resolved as INERT DATA - nothing is ever executed.</summary>
sealed class OpaqueRef
{
public required string What;
}
/// <summary>A tensor recorded during unpickling, materialized once storages are read.</summary>
sealed class TensorStub
{
public required string StorageKey;
public required int StorageOffset;
public required int[] Shape;
}
sealed class Unpickler
{
readonly byte[] _data;
readonly List<object> _stack = new();
readonly Dictionary<int, object> _memo = new();
int _pos;
/// <summary>Every storage seen via persistent ids: key → (type name, element width).</summary>
public Dictionary<string, (string Type, int Width)> Storages { get; } = new( StringComparer.Ordinal );
static readonly object MarkSentinel = new();
public Unpickler( byte[] pickle, int startOffset )
{
_data = pickle;
_pos = startOffset;
}
byte Next()
{
if ( _pos >= _data.Length )
throw new FormatException( "torch checkpoint: pickle stream truncated." );
return _data[_pos++];
}
byte[] NextBytes( int count )
{
if ( _pos + count > _data.Length )
throw new FormatException( "torch checkpoint: pickle stream truncated." );
var span = new byte[count];
Array.Copy( _data, _pos, span, 0, count );
_pos += count;
return span;
}
string ReadLine()
{
var start = _pos;
while ( _pos < _data.Length && _data[_pos] != (byte)'\n' )
_pos++;
if ( _pos >= _data.Length )
throw new FormatException( "torch checkpoint: unterminated pickle text line." );
var line = Encoding.ASCII.GetString( _data, start, _pos - start );
_pos++;
return line;
}
void Push( object value ) => _stack.Add( value );
object Pop()
{
if ( _stack.Count == 0 )
throw new FormatException( "torch checkpoint: pickle stack underflow." );
var value = _stack[^1];
_stack.RemoveAt( _stack.Count - 1 );
return value;
}
List<object> PopToMark()
{
var items = new List<object>();
while ( true )
{
var value = Pop();
if ( ReferenceEquals( value, MarkSentinel ) )
break;
items.Add( value );
}
items.Reverse();
return items;
}
public object Run( out int endOffset )
{
var result = RunInner();
endOffset = _pos;
return result;
}
object RunInner()
{
while ( true )
{
var op = Next();
switch ( op )
{
case 0x80: Next(); break; // PROTO n
case 0x95: NextBytes( 8 ); break; // FRAME len
case (byte)'}': Push( new Dictionary<object, object>() ); break; // EMPTY_DICT
case (byte)']': Push( new List<object>() ); break; // EMPTY_LIST
case (byte)'(': Push( MarkSentinel ); break; // MARK
case (byte)'N': Push( null ); break; // NONE
case 0x88: Push( true ); break; // NEWTRUE
case 0x89: Push( false ); break; // NEWFALSE
case (byte)'X': // BINUNICODE
{
var length = BitConverter.ToInt32( NextBytes( 4 ), 0 );
Push( Encoding.UTF8.GetString( NextBytes( length ) ) );
break;
}
case 0x8c: // SHORT_BINUNICODE
{
int length = Next();
Push( Encoding.UTF8.GetString( NextBytes( length ) ) );
break;
}
case (byte)'U': // SHORT_BINSTRING (legacy sys_info)
{
int length = Next();
Push( Encoding.Latin1.GetString( NextBytes( length ) ) );
break;
}
case (byte)'T': // BINSTRING
{
var length = BitConverter.ToInt32( NextBytes( 4 ), 0 );
Push( Encoding.Latin1.GetString( NextBytes( length ) ) );
break;
}
case (byte)'J': Push( BitConverter.ToInt32( NextBytes( 4 ), 0 ) ); break; // BININT
case (byte)'K': Push( (int)Next() ); break; // BININT1
case (byte)'M': Push( (int)BitConverter.ToUInt16( NextBytes( 2 ), 0 ) ); break; // BININT2
case 0x8a: // LONG1
{
int length = Next();
var bytes = NextBytes( length );
long value = 0;
for ( var i = length - 1; i >= 0; i-- )
value = (value << 8) | bytes[i];
// little-endian two's complement; small values only in practice
Push( (int)value );
break;
}
case (byte)'q': _memo[Next()] = Peek(); break; // BINPUT
case (byte)'r': _memo[BitConverter.ToInt32( NextBytes( 4 ), 0 )] = Peek(); break; // LONG_BINPUT
case 0x94: _memo[_memo.Count] = Peek(); break; // MEMOIZE
case (byte)'h': Push( Memo( Next() ) ); break; // BINGET
case (byte)'j': Push( Memo( BitConverter.ToInt32( NextBytes( 4 ), 0 ) ) ); break; // LONG_BINGET
case (byte)'t': Push( PopToMark().ToArray() ); break; // TUPLE
case (byte)')': Push( Array.Empty<object>() ); break; // EMPTY_TUPLE
case 0x85: Push( new[] { Pop() } ); break; // TUPLE1
case 0x86: // TUPLE2
{
var b = Pop(); var a = Pop();
Push( new[] { a, b } );
break;
}
case 0x87: // TUPLE3
{
var c = Pop(); var b = Pop(); var a = Pop();
Push( new[] { a, b, c } );
break;
}
case (byte)'c': // GLOBAL
{
var module = ReadLine();
var name = ReadLine();
Push( ResolveGlobal( module, name ) );
break;
}
case 0x93: // STACK_GLOBAL
{
var name = Pop() as string ?? throw Bad( "STACK_GLOBAL name" );
var module = Pop() as string ?? throw Bad( "STACK_GLOBAL module" );
Push( ResolveGlobal( module, name ) );
break;
}
case (byte)'Q': // BINPERSID
{
Push( ResolvePersistentId( Pop() ) );
break;
}
case (byte)'R': // REDUCE
{
var args = Pop() as object[] ?? throw Bad( "REDUCE args" );
var callable = Pop();
Push( Invoke( callable, args ) );
break;
}
case (byte)'s': // SETITEM
{
var value = Pop();
var key = Pop();
(Peek() as Dictionary<object, object> ?? throw Bad( "SETITEM target" ))[key] = value;
break;
}
case (byte)'u': // SETITEMS
{
var items = PopToMark();
var dict = Peek() as Dictionary<object, object> ?? throw Bad( "SETITEMS target" );
for ( var i = 0; i + 1 < items.Count; i += 2 )
dict[items[i]] = items[i + 1];
break;
}
case (byte)'a': // APPEND (single)
{
var item = Pop();
(Peek() as List<object> ?? throw Bad( "APPEND target" )).Add( item );
break;
}
case (byte)'0': Pop(); break; // POP
case (byte)'e': // APPENDS
{
var items = PopToMark();
var list = Peek() as List<object> ?? throw Bad( "APPENDS target" );
list.AddRange( items );
break;
}
case (byte)'G': // BINFLOAT (big-endian f64)
{
var raw = NextBytes( 8 );
var swapped = new byte[8];
for ( var i = 0; i < 8; i++ )
swapped[i] = raw[7 - i];
Push( BitConverter.ToDouble( swapped, 0 ) );
break;
}
case 0x81: // NEWOBJ: cls(*args) - inert
{
Pop(); // args
var cls = Pop();
Push( cls is GlobalRef g
? Invoke( g, Array.Empty<object>() )
: new OpaqueRef { What = "NEWOBJ instance" } );
break;
}
case (byte)'b': // BUILD: apply state - inert merge
{
var state = Pop();
if ( Peek() is Dictionary<object, object> targetDict
&& state is Dictionary<object, object> stateDict )
{
foreach ( var (k, v) in stateDict )
targetDict[k] = v;
}
// Opaque targets: state discarded, nothing executed.
break;
}
case (byte)'.': // STOP
return Pop();
default:
throw new FormatException(
$"torch checkpoint: unsupported pickle opcode 0x{op:X2} at {_pos - 1} "
+ "(the restricted reader accepts tensor state dicts only)." );
}
}
}
object Peek() => _stack.Count > 0 ? _stack[^1] : throw Bad( "empty stack" );
object Memo( int slot )
=> _memo.TryGetValue( slot, out var value ) ? value : throw Bad( $"memo slot {slot}" );
static FormatException Bad( string what )
=> new( $"torch checkpoint: malformed pickle ({what})." );
static object ResolveGlobal( string module, string name ) => (module, name) switch
{
("torch._utils", "_rebuild_tensor_v2") => new GlobalRef { Module = module, Name = name },
("torch", "FloatStorage") => new GlobalRef { Module = module, Name = name },
("collections", "OrderedDict") => new GlobalRef { Module = module, Name = name },
// Anything else becomes INERT DATA: nothing is looked up or executed, and
// values built from it are dropped at materialization. Training
// checkpoints carry optimizer state and framework metadata we must
// tolerate without running.
_ => new OpaqueRef { What = $"{module}.{name}" },
};
static int StorageWidth( string typeName ) => typeName switch
{
"DoubleStorage" or "LongStorage" => 8,
"FloatStorage" or "IntStorage" => 4,
"HalfStorage" or "ShortStorage" or "BFloat16Storage" => 2,
_ => 1, // Byte/Char/Bool
};
object ResolvePersistentId( object pid )
{
if ( pid is not object[] tuple || tuple.Length < 5
|| tuple[0] is not string kind || kind != "storage"
|| tuple[2] is not string key )
throw Bad( "persistent id" );
var typeName = tuple[1] switch
{
GlobalRef g => g.Name,
OpaqueRef o => o.What.Contains( '.' ) ? o.What[(o.What.LastIndexOf( '.' ) + 1)..] : o.What,
_ => throw Bad( "storage type" ),
};
var numel = tuple[4] switch { int i => (long)i, long l => l, _ => throw Bad( "storage numel" ) };
Storages[key] = (typeName, StorageWidth( typeName ));
if ( typeName is not ("FloatStorage" or "BFloat16Storage" or "HalfStorage") )
return new OpaqueRef { What = $"non-float storage {typeName}" };
return new StorageRef { Key = key, Numel = numel };
}
object Invoke( object callable, object[] args )
{
if ( callable is OpaqueRef opaque )
return opaque; // inert: nothing executed, value dropped later
if ( callable is not GlobalRef global )
throw Bad( "REDUCE callable" );
if ( global is { Module: "collections", Name: "OrderedDict" } )
return new Dictionary<object, object>();
if ( global is { Module: "torch._utils", Name: "_rebuild_tensor_v2" } )
{
if ( args.Length < 4
|| args[1] is not int storageOffset
|| args[2] is not object[] sizeTuple
|| args[3] is not object[] strideTuple )
throw Bad( "_rebuild_tensor_v2 args" );
if ( args[0] is OpaqueRef )
return new OpaqueRef { What = "tensor on unsupported storage" };
if ( args[0] is not StorageRef storage )
throw Bad( "_rebuild_tensor_v2 storage" );
var shape = sizeTuple
.Select( s => s is int v ? v : throw Bad( "size" ) ).ToArray();
var stride = strideTuple
.Select( s => s is int v ? v : throw Bad( "stride" ) ).ToArray();
// Contiguous row-major only.
var expected = 1;
for ( var d = shape.Length - 1; d >= 0; d-- )
{
if ( stride[d] != expected )
throw new FormatException(
"torch checkpoint: non-contiguous tensor storage is not supported." );
expected *= shape[d];
}
return new TensorStub
{
StorageKey = storage.Key,
StorageOffset = storageOffset,
Shape = shape,
};
}
throw Bad( $"REDUCE of {global.Module}.{global.Name}" );
}
}
}
using AutoRig.Mesh;
using AutoRig.Rig;
namespace AutoRig.Export;
/// <summary>Everything a caller needs to write a rigged model to disk.</summary>
public sealed class ExportBundle
{
public required byte[] Fbx { get; init; }
public required string Vmdl { get; init; }
public required string FbxFileName { get; init; }
public required string VmdlFileName { get; init; }
/// <summary>Companion files (texture image + generated .vmat), written into the
/// same folder as the fbx/vmdl. Empty when the source had no textures.</summary>
public IReadOnlyList<(string FileName, byte[] Bytes)> ExtraFiles { get; init; }
= Array.Empty<(string, byte[])>();
}
/// <summary>
/// Export façade: RigResult → binary FBX bytes + vmdl text. No file IO here (Code/
/// discipline) — the editor layer writes the files where the user chose.
/// </summary>
public static class RigExporter
{
/// <param name="assetFolder">Project-relative folder the caller will write the
/// bundle into (used for texture/vmat references inside the generated files).</param>
public static ExportBundle Export(
RigMesh mesh, RigResult rig, string modelName, string assetFolder = "models/autorig" )
{
ArgumentNullException.ThrowIfNull( mesh );
ArgumentNullException.ThrowIfNull( rig );
ArgumentNullException.ThrowIfNull( modelName );
var name = NameUtil.Sanitize( modelName );
var fbxFileName = $"{name}.fbx";
var folder = assetFolder.Replace( '\\', '/' ).Trim( '/' );
// Place the model where ModelDoc expects it: centered on the origin's
// ground plane. Off-origin sources otherwise appear shoved to one side
// (X/Z offset) or sunk through the floor (Y offset). Center the footprint
// (X, Z at the bbox center → 0) and floor-snap so the lowest point sits at
// y = 0. Mesh AND joints move together so the rig stays aligned.
var bounds = mesh.ComputeBounds();
var center = bounds.Center;
var lift = new System.Numerics.Vector3( -center.X, -bounds.Min.Y, -center.Z );
if ( lift.Length() > 1e-4f )
{
var lifted = new Mesh.RigMesh
{
SourceName = mesh.SourceName,
Positions = mesh.Positions.Select( p => p + lift ).ToArray(),
Normals = mesh.Normals,
Uvs = mesh.Uvs,
Triangles = mesh.Triangles,
TriangleTags = mesh.TriangleTags,
Tags = mesh.Tags,
Materials = mesh.Materials,
TriangleMaterials = mesh.TriangleMaterials,
};
var liftedSkeleton = new Rig.RigSkeleton();
foreach ( var j in rig.Skeleton.Joints )
liftedSkeleton.Joints.Add( new Rig.RigJoint
{
Name = j.Name,
Parent = j.Parent,
Position = j.Position + lift,
HingeAxis = j.HingeAxis,
} );
mesh = lifted;
rig = new Rig.RigResult
{
Skeleton = liftedSkeleton,
Weights = rig.Weights,
SolverName = rig.SolverName,
Degraded = rig.Degraded,
Explanation = rig.Explanation,
};
}
// Texture passthrough (v1: whole model bound to the first textured source
// material — the FBX carries a single material named "{name}_mat").
var extras = new List<(string, byte[])>();
string remapFrom = null, remapTo = null;
var source = mesh.Materials.FirstOrDefault( m => m.BaseColorImage is not null )
?? mesh.Materials.FirstOrDefault();
if ( source is not null )
{
var vmat = $"// generated by auto_rig from '{mesh.SourceName}'\nLayer0\n{{\n"
+ "\tshader \"shaders/complex.shader\"\n";
if ( source.BaseColorImage is { } image )
{
var extension = image.Length > 2 && image[0] == 0xFF && image[1] == 0xD8
? "jpg" : "png";
var imageFileName = $"{name}_color.{extension}";
extras.Add( (imageFileName, image) );
vmat += $"\tTextureColor \"{folder}/{imageFileName}\"\n";
}
if ( source.Tint != new System.Numerics.Vector3( 1f, 1f, 1f ) )
vmat += $"\tg_vColorTint \"[{source.Tint.X:0.###} {source.Tint.Y:0.###} {source.Tint.Z:0.###}]\"\n";
vmat += "}\n";
var vmatFileName = $"{name}_mat.vmat";
extras.Add( (vmatFileName, System.Text.Encoding.UTF8.GetBytes( vmat )) );
remapFrom = $"{name}_mat.vmat"; // FBX material name
remapTo = $"{folder}/{vmatFileName}";
}
return new ExportBundle
{
Fbx = FbxRigWriter.Write( mesh, rig, name ),
Vmdl = VmdlGenerator.Generate( fbxFileName, name, remapFrom, remapTo ),
FbxFileName = fbxFileName,
VmdlFileName = $"{name}.vmdl",
ExtraFiles = extras,
};
}
}