mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-04-20 14:28:17 -04:00
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:
@@ -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 )
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
|
||||
@@ -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 )
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" )]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 )
|
||||
{
|
||||
|
||||
@@ -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 )
|
||||
{
|
||||
|
||||
128
engine/Sandbox.Engine/Resources/Textures/Loader/ThumbLoader.cs
Normal file
128
engine/Sandbox.Engine/Resources/Textures/Loader/ThumbLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
|
||||
//
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ) );
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 )
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user