rokojori_action_library/Runtime/Procedural/Textures/TextureStack/BakedTexture2D.cs

379 lines
10 KiB
C#
Raw Normal View History

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 <marked>defines a file as its target and
always writes its result there</marked>.
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. <code>bakeOnChange</code> 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 <marked>load its file</marked>, 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<Resource> _hookedResources = new List<Resource>();
readonly List<Callable> _hookedCallables = new List<Callable>();
readonly List<TextureStackChangeSource> _changeSources
= new List<TextureStackChangeSource>();
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();
}
}
}