Thumb Textures (#3677)

Adds a universal way to get thumbnail images.

- `thumb:entities/sents/npc/scientist.sent` - resources/assets
- `thumb:mount://ns2/ns2/models/effects/exosuit_part1.model.vmdl` -mount files
- `thumb:facepunch.snowman` - packages

This greatly simplifies UI like the spawnmenu that needs to show thumbnails for these things.

We also add `AssetTypeFlags.IncludeThumbnails`. If this is set then when the package is published, any asset types with this flag will include a "[path].c.png" thumbnail image of it.

Also does Api++ protocol increase.
This commit is contained in:
Garry Newman
2025-12-27 17:55:57 +00:00
committed by GitHub
parent 41ac3ef13e
commit 1dfd1de087
18 changed files with 287 additions and 43 deletions

View File

@@ -98,13 +98,21 @@ public abstract class BaseGameMount
}
readonly Dictionary<string, ResourceLoader> _entries = [];
readonly Dictionary<string, ResourceLoader> _entries = new Dictionary<string, ResourceLoader>( StringComparer.OrdinalIgnoreCase );
/// <summary>
/// All of the resources in this game
/// </summary>
public IReadOnlyCollection<ResourceLoader> Resources => _entries.Values;
/// <summary>
/// Retrieves the resource loader associated with the specified path, if it exists.
/// </summary>
public ResourceLoader GetByPath( string path )
{
return _entries.TryGetValue( path, out var entry ) ? entry : default;
}
public ResourceFolder RootFolder { get; internal set; }
internal void RegisterFileInternal( ResourceLoader entry )

View File

@@ -45,4 +45,9 @@ internal unsafe interface IToolsDll
/// Called after the host network system is initialised, used to add additional package references etc. to dev servers
/// </summary>
public Task OnInitializeHost();
/// <summary>
/// Get a thumbnail for the specified asset.Can return null if not immediately available.
/// </summary>
Bitmap GetThumbnail( string filename );
}

View File

@@ -5,7 +5,7 @@ namespace Sandbox;
/// <summary>
/// Describes an item of clothing and implicitly which other items it can be worn with
/// </summary>
[AssetType( Name = "Clothing Definition", Extension = "clothing", Category = "citizen" )]
[AssetType( Name = "Clothing Definition", Extension = "clothing", Category = "citizen", Flags = AssetTypeFlags.IncludeThumbnails )]
public sealed partial class Clothing : GameResource
{

View File

@@ -54,7 +54,7 @@ public static class Directory
}
/// <summary>
/// Get information about all the current mounts
/// Get a specific mount by name
/// </summary>
public static BaseGameMount Get( string name )
{

View File

@@ -6,6 +6,33 @@ public static class MountUtility
static readonly List<RenderJob> _jobs = new();
static readonly HashSet<RenderJob> _activeJobs = new();
/// <summary>
/// Find a ResourceLoader by its mount path.
/// </summary>
public static ResourceLoader FindLoader( string loaderPath )
{
if ( !loaderPath.StartsWith( "mount://" ) ) return null;
var partIndex = loaderPath.IndexOf( '/', 8 );
if ( partIndex <= 8 ) return null;
var mountName = loaderPath[8..partIndex];
var mount = Directory.Get( mountName );
if ( mount is null ) return null;
return mount.GetByPath( loaderPath );
}
/// <summary>
/// Create a preview texture for the given resource loader.
/// </summary>
public static Texture GetPreviewTexture( string loaderPath )
{
var loader = FindLoader( loaderPath );
if ( loader is null ) return null;
return GetPreviewTexture( loader );
}
/// <summary>
/// Create a preview texture for the given resource loader.
/// </summary>

View File

@@ -8,7 +8,7 @@ public static class Protocol
/// <summary>
/// We cannot play packages with an Api version higher than this.
/// </summary>
public static int Api => 22;
public static int Api => 23;
/// <summary>
/// We cannot talk to servers or clients with a network protocol different to this.

View File

@@ -48,6 +48,11 @@ public enum AssetTypeFlags
/// it can only really exist as an asset file on disk, not inside another asset.
/// </summary>
NoEmbedding = 1 << 0,
/// <summary>
/// Include thumbnails when publishing as part of another package
/// </summary>
IncludeThumbnails = 1 << 1,
}
[Obsolete( "Use AssetType instead" )]

View File

@@ -434,6 +434,18 @@ public static class ResourceLibrary
return default;
}
/// <summary>
/// Render a thumbnail for this resource
/// </summary>
public static async Task<Bitmap> GetThumbnail( string path, int width = 256, int height = 256 )
{
var resource = await ResourceLibrary.LoadAsync<Resource>( path );
if ( resource is null ) return default;
// try to render it
return resource.RenderThumbnail( new() { Width = width, Height = height } );
}
public interface IEventListener
{
/// <summary>

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Caching.Memory;
using NativeEngine;
using Steamworks;
using Steamworks.Data;
@@ -9,6 +10,11 @@ namespace Sandbox.TextureLoader;
/// </summary>
internal static class Avatar
{
/// <summary>
/// Entries are cached on a sliding window, they will be released if not used for 10 minutes
/// </summary>
static readonly MemoryCache _cache = new( new MemoryCacheOptions() );
internal static bool IsAppropriate( string url )
{
return url.StartsWith( "avatar:" ) || url.StartsWith( "avatarbig:" ) || url.StartsWith( "avatarsmall:" );
@@ -18,15 +24,17 @@ internal static class Avatar
{
try
{
//
// Create a 1x1 placeholder texture
//
var placeholder = Texture.Create( 1, 1 ).WithName( "avatar" ).WithData( new byte[4] { 0, 0, 0, 0 } ).Finish();
placeholder.IsLoaded = false;
return _cache.GetOrCreate( filename, entry =>
{
entry.SetSlidingExpiration( TimeSpan.FromMinutes( 10 ) );
_ = LoadIntoTexture( filename, placeholder );
var placeholder = Texture.Create( 1, 1 ).WithName( "avatar" ).WithData( new byte[4] { 0, 0, 0, 0 } ).Finish();
placeholder.IsLoaded = false;
return placeholder;
_ = LoadIntoTexture( filename, placeholder );
return placeholder;
} );
}
catch ( System.Exception e )
{

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Caching.Memory;
using System.IO;
using System.Net.Http;
@@ -6,9 +7,9 @@ namespace Sandbox.TextureLoader;
internal static class ImageUrl
{
/// <summary>
/// For textures loaded from the web we want to keep them around a bit longer
/// Entries are cached on a sliding window, they will be released if not used for 10 minutes
/// </summary>
static CaseInsensitiveDictionary<Texture> Loaded = new();
static readonly MemoryCache _cache = new( new MemoryCacheOptions() );
internal static bool IsAppropriate( string url )
{
@@ -17,22 +18,19 @@ internal static class ImageUrl
internal static Texture Load( string filename, bool warnOnMissing )
{
if ( Loaded.TryGetValue( filename, out var cachedTexture ) )
{
return cachedTexture;
}
try
{
//
// Create a 1x1 placeholder texture
//
var placeholder = Texture.Create( 1, 1 ).WithName( "httpimg-placeholder" ).WithData( new byte[4] { 0, 0, 0, 0 } ).Finish();
_ = placeholder.ReplacementAsync( LoadFromUrl( filename ) );
return _cache.GetOrCreate<Texture>( filename, entry =>
{
//
// Create a 1x1 placeholder texture
//
var placeholder = Texture.Create( 1, 1 ).WithName( "httpimg-placeholder" ).WithData( new byte[4] { 0, 0, 0, 0 } ).Finish();
_ = placeholder.ReplacementAsync( LoadFromUrl( filename ) );
Loaded[filename] = placeholder;
return placeholder;
entry.SlidingExpiration = TimeSpan.FromMinutes( 10 );
return placeholder;
} );
}
catch ( System.Exception e )
{

View File

@@ -0,0 +1,128 @@
using Microsoft.Extensions.Caching.Memory;
using Sandbox.Engine;
using Sandbox.Mounting;
namespace Sandbox.TextureLoader;
/// <summary>
/// Loads a thumbnail of an entity or something
/// </summary>
internal static class ThumbLoader
{
/// <summary>
/// Entries are cached on a sliding window, they will be released if not used for 10 minutes
/// </summary>
static readonly MemoryCache _cache = new( new MemoryCacheOptions() );
internal static bool IsAppropriate( string url )
{
return url.StartsWith( "thumb:" );
}
internal static Texture Load( string filename )
{
try
{
return _cache.GetOrCreate( filename, entry =>
{
entry.SetSlidingExpiration( TimeSpan.FromMinutes( 10 ) );
var placeholder = Texture.Create( 1, 1 )
.WithName( "thumb" )
.WithData( new byte[4] { 0, 0, 0, 0 } )
.Finish();
placeholder.IsLoaded = false;
_ = LoadIntoTexture( filename, placeholder );
return placeholder;
} );
}
catch ( System.Exception e )
{
Log.Warning( $"Couldn't Load Thumb {filename} ({e.Message})" );
return null;
}
}
internal static async Task LoadIntoTexture( string url, Texture placeholder )
{
try
{
var filename = url[(url.IndexOf( ':' ) + 1)..];
// One day we'll support things like ?width=512 and ?mode=wide ?mode=tall
//
// if it's from a mount then get it from the mount system
//
if ( filename.StartsWith( "mount:" ) )
{
var t = MountUtility.GetPreviewTexture( filename );
placeholder.CopyFrom( t );
return;
}
//
// if it looks like a package, try to load the thumb from there
//
if ( filename.Count( x => x == '/' || x == '\\' ) == 0 && filename.Count( '.' ) == 1 && Package.TryParseIdent( filename, out var ident ) )
{
var packageInfo = await Package.FetchAsync( $"{ident.org}.{ident.package}", true );
if ( packageInfo == null ) return;
var thumb = await ImageUrl.LoadFromUrl( packageInfo.Thumb );
if ( thumb == null ) return;
placeholder.CopyFrom( thumb );
return;
}
//
// if it's a resource, it can generate itself
//
{
// Load it from disk, if it exists!
{
var fn = filename.EndsWith( "_c" ) ? filename : $"{filename}_c"; // needs to end in _c
string imageFile = $"{fn}.t.png";
if ( FileSystem.Mounted.FileExists( imageFile ) )
{
using var bitmap = Bitmap.CreateFromBytes( await FileSystem.Mounted.ReadAllBytesAsync( imageFile ) );
placeholder.CopyFrom( bitmap.ToTexture() );
return;
}
}
{
var bitmap = IToolsDll.Current?.GetThumbnail( filename );
if ( bitmap != null )
{
using var downscaled = bitmap.Resize( 256, 256, true );
var t = downscaled.ToTexture();
placeholder.CopyFrom( t );
return;
}
}
// last resort - generate it!
{
using var bitmap = await ResourceLibrary.GetThumbnail( filename, 512, 512 );
if ( bitmap != null )
{
using var downscaled = bitmap.Resize( 256, 256, true );
var t = downscaled.ToTexture();
placeholder.CopyFrom( t );
return;
}
}
}
}
finally
{
placeholder.IsLoaded = true;
}
}
}

View File

@@ -191,6 +191,14 @@ public partial class Texture
return TextureLoader.Avatar.Load( filepath );
}
//
// Thumb loader
//
if ( TextureLoader.ThumbLoader.IsAppropriate( filepath ) )
{
return TextureLoader.ThumbLoader.Load( filepath );
}
//Precache.Add( filename );
//

View File

@@ -157,6 +157,10 @@ public sealed partial class ModelPhysics
foreach ( var part in physics.Parts )
{
var bone = bones.GetBone( part.BoneName );
if ( bone is null )
continue;
if ( !boneObjects.TryGetValue( bone, out var go ) )
continue;

View File

@@ -130,11 +130,7 @@ static class AssetThumbnail
var fullPath = GetThumbnailFile( asset, true );
await Task.Run( () =>
{
if ( pix.HasAlpha ) pix.SavePng( fullPath );
else pix.SaveJpg( fullPath );
} );
await Task.Run( () => pix.SavePng( fullPath ) );
EditorEvent.RunInterface<AssetSystem.IEventListener>( x => x.OnAssetThumbGenerated( asset ) );
}

View File

@@ -8,6 +8,9 @@ public class AssetType
{
internal static Dictionary<int, AssetType> AssetTypeCache = new Dictionary<int, AssetType>();
TypeDescription _typeDescription;
AssetTypeAttribute _assetTypeAttribute;
/// <summary>
/// All currently registered asset types, including the base types such as models, etc.
/// </summary>
@@ -143,6 +146,11 @@ public class AssetType
/// </summary>
public Color Color { get; internal set; } = Color.Magenta;
/// <summary>
/// Flags for this asset type
/// </summary>
public AssetTypeFlags Flags => _assetTypeAttribute?.Flags ?? default;
public override string ToString() => FriendlyName;
internal string IconPathSmall { get; set; }
@@ -241,17 +249,20 @@ public class AssetType
Icon64 = Icon128.Resize( 64, 64 );
}
private void Init( TypeDescription type, AssetTypeAttribute gr )
private void Init( TypeDescription type, AssetTypeAttribute attribute )
{
IsGameResource = true;
_typeDescription = type;
_assetTypeAttribute = attribute;
ResourceType = type.TargetType;
Category = gr.Category;
FriendlyName = gr.Name;
FileExtension = gr.Extension;
Category = attribute.Category;
FriendlyName = attribute.Name;
FileExtension = attribute.Extension;
GenerateGlyphs( gr );
GenerateGlyphs( attribute );
// For game resources, use the background color specified in the attribute
Color = "#67ac5c";

View File

@@ -13,8 +13,6 @@ internal class TerrainMaterialCompiler : ResourceCompiler
var filename = Context.AbsolutePath;
var jsonString = File.ReadAllText( filename );
Log.Info( $"tryna compile {filename}" );
var docOptions = new JsonDocumentOptions();
docOptions.MaxDepth = 512;

View File

@@ -305,4 +305,17 @@ internal class ToolsDll : IToolsDll
}
}
public Bitmap GetThumbnail( string filename )
{
var asset = AssetSystem.FindByPath( filename );
if ( asset is null ) return null;
var thumb = asset.GetAssetThumb( true );
// Sorry - we have no fast GetPixels
var pixels = thumb.GetPng();
return Bitmap.CreateFromBytes( pixels );
}
}

View File

@@ -323,11 +323,37 @@ public partial class ProjectPublisher
// Add this file
await AddFile( abs, rel );
// Should we add the thumbnail?
await TryAddThumbnail( asset );
}
return true;
}
async Task TryAddThumbnail( Asset asset )
{
if ( asset.AssetType == null ) return;
// don't do thumbs for built in assets, except models
if ( !asset.AssetType.IsGameResource && (asset.AssetType != AssetType.Model) )
return;
// they should explicitly opt into this
if ( asset.AssetType.IsGameResource && !asset.AssetType.Flags.Contains( AssetTypeFlags.IncludeThumbnails ) )
return;
var rel = asset.GetCompiledFile( false );
var thumb = asset.GetAssetThumb( true );
if ( thumb is null ) return;
var thumbName = $"{rel}.t.png";
var png = thumb.GetPng();
await AddFile( png, thumbName );
}
private async Task AddFile( string absPath, string relativePath )
{
if ( !System.IO.File.Exists( absPath ) )
@@ -353,7 +379,7 @@ public partial class ProjectPublisher
AbsolutePath = absPath
};
// run in a thread to make it super fast
// run in a thread to make it happen in the background
await Task.Run( async () =>
{
using ( var stream = info.OpenRead() )
@@ -400,10 +426,7 @@ public partial class ProjectPublisher
/// This really exists only to dissallow dangerous extensions like .exe etc.
/// So feel free to add anything non dangerous to this list.
/// </summary>
public static string[] DissallowedExtensions = new string[]
{
".dll", ".exe", ".csproj", ".sln", ".user", ".slnx"
};
public static string[] DissallowedExtensions = [".dll", ".exe", ".csproj", ".sln", ".user", ".slnx", ".pdb"];
public static bool LooseFileAllowed( string file, bool allowSourceFiles )
{