379 lines
10 KiB
C#
379 lines
10 KiB
C#
|
|
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();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|