using System.Collections.Generic; using Godot; using System; using Rokojori.Extensions; namespace Rokojori { /** A TextureStack that lives on disk. The user's answer to open question 4, built as asked: it behaves like a TextureStackTexture2D, but it defines a file as its target and always writes its result there. The difference from TextureStackTexture2D is where the pixels are, and it changes what each one is for: - TextureStackTexture2D is a DrawableTexture2D. Its pixels only exist on the GPU, so they have to be recomposited every single time the resource loads - in the editor and in a shipped game alike. That is the price of being live: edit a layer and it recomposites. - BakedTexture2D is an ImageTexture. It composites once, when something asks it to, and from then on it loads a file like any other texture. No compositing at load, no GPU buffers held, no stack cost in a shipped build - and the stack is still there as the editable source. So: author with a TextureStackTexture2D, ship with a BakedTexture2D, or use one directly for anything heavy enough that recompositing on load is not wanted. bakeOnChange makes it behave like the live one while authoring, at the cost of a disk write per edit. The file is a normal project asset, so Godot imports, compresses and mipmaps it the way it does any other texture. That is the main practical reason to bake: a composited DrawableTexture2D is always uncompressed RGBA8 in VRAM. */ [Tool] [GlobalClass] public partial class BakedTexture2D:ImageTexture { TextureStack _stack; [Export] public TextureStack stack { get => _stack; set { _stack = value; OnChanged(); } } string _path = ""; /** Where the result is written, and read back from afterwards. A project path with an image extension - .png, .exr for HDR, .webp or .jpg. Textures.Save picks the encoder off the extension. */ [Export( PropertyHint.SaveFile, "*.png,*.exr,*.webp,*.jpg" )] public string path { get => _path; set { _path = value; OnChanged(); } } int _width = 1024; [Export] public int width { get => _width; set { _width = value; OnChanged(); } } int _height = 1024; [Export] public int height { get => _height; set { _height = value; OnChanged(); } } /** Rgba8 by default, same as TextureStackTexture2D and for the same reason - see the note on its format property. */ TextureStackFormat _format = TextureStackFormat.Rgba8; [Export] public TextureStackFormat format { get => _format; set { _format = value; OnChanged(); } } Color _clearColor = new Color( 0, 0, 0, 0 ); [Export] public Color clearColor { get => _clearColor; set { _clearColor = value; OnChanged(); } } bool _mipmaps = true; /** Mipmaps on the baked image. The imported texture's own mipmap setting takes over once the file exists, so this only matters for the image this writes. */ [Export] public bool mipmaps { get => _mipmaps; set { _mipmaps = value; OnChanged(); } } [ExportToolButton( "Bake" )] public Callable BakeButton => Callable.From( () => Bake() ); [ExportToolButton( "Load Baked" )] public Callable LoadButton => Callable.From( () => LoadBaked() ); [ExportGroup( "Baking" )] bool _bakeOnChange = false; /** On: every edit to the stack rebakes and rewrites the file, so it behaves like a live TextureStackTexture2D. Off - the default - only the Bake button and the BakeTextureStack Action write anything, and a change just reloads whatever is on disk. Off by default because a bake is a disk write and a reimport, which is not something to do per slider pixel. */ [Export] public bool bakeOnChange { get => _bakeOnChange; set { _bakeOnChange = value; OnChanged(); } } float _quality = 1; /** For the lossy formats only - jpg and webp. png and exr ignore it; 1 makes webp lossless. */ [Export( PropertyHint.Range, "0,1" )] public float quality { get => _quality; set { _quality = value; } } [ExportGroup( "Info" )] [Export] public string info = ""; public Vector2I size => new Vector2I( Mathf.Max( 1, _width ), Mathf.Max( 1, _height ) ); bool _loaded = false; /** True once an image is in here, baked or loaded. */ public bool isLoaded => _loaded; /** Composites the stack, writes it to path, and shows the result. */ public bool Bake() { if ( _stack == null || _stack.numLayers == 0 ) { info = "Nothing to bake - no stack."; return false; } var image = TextureStackBaker.Render( _stack, size, _format, _clearColor, _mipmaps ); if ( image == null ) { info = "Composite failed."; return false; } if ( _mipmaps ) { image.GenerateMipmaps(); } Apply( image ); if ( string.IsNullOrEmpty( _path ) ) { info = size.X + "x" + size.Y + " " + _format + ", baked in memory - no path set."; return true; } TextureStackBaker.Save( image, _path, _quality ); info = size.X + "x" + size.Y + " " + _format + ", baked to " + _path; return true; } /** Reads the file back without compositing anything. What a load does. */ public bool LoadBaked() { var image = TextureStackBaker.Load( _path ); if ( image == null ) { return false; } Apply( image ); info = image.GetWidth() + "x" + image.GetHeight() + ", loaded from " + _path; return true; } /** SetImage handles both cases - it creates the texture when there is none yet and replaces the RID when there is. It also emits changed, which collapses the inspector; unavoidable here and not worth avoiding, since the pixels really are a new texture and a bake is always explicit. */ void Apply( Image image ) { SetImage( image ); _loaded = true; } /* SCHEDULING -------------------------------------------------------------------------- Same deferred-setter mechanism as TextureStackTexture2D, and for the same reason: there is no resource-load callback to hook, so every property setter schedules and a flag coalesces the burst of writes a .tres load produces. What runs at the end of it differs. A TextureStackTexture2D has to composite, because its pixels do not survive being saved. This one only has to load its file, which is the whole point - unless bakeOnChange asks for the live behaviour. */ bool _scheduled = false; void OnChanged() { if ( _scheduled ) { return; } _scheduled = true; // By name, not from a lambda - a delegate Callable does not survive a C# // assembly reload. See the change-hooks note below. CallDeferred( MethodName.Flush ); } /** Public and named because it is called by name through CallDeferred. */ public void Flush() { _scheduled = false; if ( ! GodotObject.IsInstanceValid( this ) ) { return; } if ( _bakeOnChange ) { Bake(); return; } LoadBaked(); EnsureChangeHooks(); } /* CHANGE HOOKS -------------------------------------------------------------------------- Only needed for bakeOnChange, and only then - without it an edit to a layer changes nothing about this texture until someone bakes. Same mechanism and the same reasons as TextureStackTexture2D's, including connecting by method name rather than from a lambda, because a delegate Callable's GCHandle is released by a C# assembly reload. See the note there and on TextureStackChangeSource. No draw index is bound here: a bake is a full composite into a throwaway target, so there is no accumulation to replay from and nothing an incremental index could do. */ bool _hooked = false; readonly List _hookedResources = new List(); readonly List _hookedCallables = new List(); readonly List _changeSources = new List(); void EnsureChangeHooks() { if ( _hooked || _stack == null ) { return; } ClearChangeHooks(); _hooked = true; Hook( _stack ); _changeSources.Clear(); _stack.CollectChangeSources( _changeSources ); for ( var i = 0; i < _changeSources.Count; i++ ) { Hook( _changeSources[ i ].resource ); } _changeSources.Clear(); } void Hook( Resource resource ) { if ( resource == null || _hookedResources.Contains( resource ) ) { return; } var callable = new Callable( this, MethodName.OnChangeSourceChanged ); if ( ! resource.IsConnected( Resource.SignalName.Changed, callable ) ) { resource.Connect( Resource.SignalName.Changed, callable ); } _hookedResources.Add( resource ); _hookedCallables.Add( callable ); } /** Called by name from the `changed` signal of everything in the stack. Public because Godot calls it; the name is part of the connection. */ public void OnChangeSourceChanged() { _hooked = false; OnChanged(); } void ClearChangeHooks() { for ( var i = 0; i < _hookedResources.Count; i++ ) { var resource = _hookedResources[ i ]; if ( resource == null || ! GodotObject.IsInstanceValid( resource ) ) { continue; } var callable = _hookedCallables[ i ]; if ( resource.IsConnected( Resource.SignalName.Changed, callable ) ) { resource.Disconnect( Resource.SignalName.Changed, callable ); } } _hookedResources.Clear(); _hookedCallables.Clear(); } } }