rokojori_action_library/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs

897 lines
27 KiB
C#
Raw Normal View History

using System.Collections.Generic;
using Godot;
using System;
using Rokojori.Extensions;
namespace Rokojori
{
/** Renders a height map from the HeightMapDrawLayer nodes underneath it.
The node is the map's placement in the world: its global XZ is the map's
center, `size` is how many meters the map covers, and `resolution` is how
many texels that is. So a layer is positioned by <marked>moving it in the
3D viewport</marked> - the generator converts world XZ to texels, never the
other way round, and nothing in the system has a texel-space authoring
concept at all.
Height is stored normalized in the R channel and decoded by
addTerrainOffset ( Shading/Library/Terrain.gdshaderinc ) as
mix( minHeight, maxHeight, r ). minHeight/maxHeight are therefore part of
the contract between this node and the terrain material, which is what
PushToMaterials() exists to keep in sync.
Rendering is flag-driven, not immediate: a layer that moves or changes
calls SetDirty(), and the next _Process re-renders once. Several edits in
one frame - dragging a node, which moves it and re-emits every frame -
collapse into a single render. The render itself is GPU blits only, so at
the sizes this is meant for it is affordable per frame, which is what makes
dragging a layer around feel live in the editor.
Layers are collected by walking the children, so <marked>nesting is
grouping</marked>: put layers under a plain Node3D to move them together.
A nested HeightMapGenerator stops the walk - it owns its own map.
*/
[Tool]
[GlobalClass]
public partial class HeightMapGenerator:Node3D
{
static readonly Color White = new Color( 1, 1, 1, 1 );
static readonly Color Transparent = new Color( 0, 0, 0, 0 );
[ExportToolButton( "Render" )]
public Callable RenderButton => Callable.From( () => Render() );
Vector2 _size = new Vector2( 100, 100 );
/** The map's extent in world meters, on X and Z. */
[Export]
public Vector2 size
{
get => _size;
set { _size = value; SetDirty(); }
}
Vector2I _resolution = new Vector2I( 512, 512 );
/** The map's texture size. Combined with `size` this is the texel density -
512 over 100 m is roughly 0.2 m per texel.
*/
[Export]
public Vector2I resolution
{
get => _resolution;
set { _resolution = value; SetDirty(); }
}
float _minHeight = 0;
/** World height a stored 0 decodes to. Must match terrainMinHeight on the
material - PushToMaterials() does that.
*/
[Export]
public float minHeight
{
get => _minHeight;
set { _minHeight = value; SetDirty(); }
}
float _maxHeight = 100;
/** World height a stored 1 decodes to. Everything a layer writes is a
fraction of maxHeight - minHeight, so widening the range flattens the
existing layers rather than rescaling them.
*/
[Export]
public float maxHeight
{
get => _maxHeight;
set { _maxHeight = value; SetDirty(); }
}
float _baseHeight = 0;
/** The world height the map starts at before any layer draws. */
[Export]
public float baseHeight
{
get => _baseHeight;
set { _baseHeight = value; SetDirty(); }
}
TextureStackFormat _format = TextureStackFormat.RgbaHalf;
/** RgbaHalf by default, not Rgba8: 8 bits give 256 height steps over the
whole min-to-max range, which terraces visibly on any slope. Half is
smooth and still only 8 bytes per texel.
*/
[Export]
public TextureStackFormat format
{
get => _format;
set { _format = value; SetDirty(); }
}
/** The map itself. A DrawableTexture2D because that is what can be blitted
into; it is still a plain Texture2D to everything that samples it, so it
goes straight into a material's sampler slot.
Left empty, one is created on the first render - but then only this node
holds it, so the material has to be fed through `materials` below. Assign
a shared DrawableTexture2D here to reference the same map from a material
in the scene file instead.
*/
[Export]
public DrawableTexture2D heightMapTarget;
/** World-space normals baked out of the finished height map, encoded as
n * 0.5 + 0.5 - see HeightMapNormals.gdshader.
Left empty, no normal pass runs at all and a terrain material has to
take its own derivative per fragment. Assigned, the map is rendered once
after every layer has drawn and pushed to `materials` as
terrainNormalMap, which is both cheaper per fragment and the only way the
smoothing below can exist - a consumer taking its own 1-texel central
difference has no way to be smoothed.
*/
[Export]
public DrawableTexture2D normalMapTarget;
float _normalRadius = 1;
/** How far apart the normal pass's taps are, in texels. Widening the stencil
low-passes the slope, which is what removes texel stair-stepping without
touching the height map itself.
*/
[Export]
public float normalRadius
{
get => _normalRadius;
set { _normalRadius = value; SetDirty(); }
}
int _normalSamples = 1;
/** How many concentric stencils, at 1x / 2x / ... normalRadius, get
averaged. One wide stencil misses the detail between its taps; averaging
several keeps the fine slope and still drops the noise. Costs 8 taps
each, over the whole map.
*/
[Export( PropertyHint.Range, "1,8" )]
public int normalSamples
{
get => _normalSamples;
set { _normalSamples = value; SetDirty(); }
}
float _normalStrength = 1;
/** Scales the slope before it is normalized - below 1 flattens the shading,
above 1 exaggerates it. Shading only; the geometry is unaffected.
*/
[Export]
public float normalStrength
{
get => _normalStrength;
set { _normalStrength = value; SetDirty(); }
}
/** Materials to push terrainHeightMap / terrainNormalMap / terrainCenterXZ / terrainSizeXZ /
terrainMinHeight / terrainMaxHeight onto after every render.
This is the whole wiring between the generator and anything rendering the
terrain - GeometryTerrainOffset's uniform block, by name. Without it the
material would silently keep the size and height range it was authored
with while the generator used different ones.
*/
[Export]
public Material[] materials = new Material[ 0 ];
/** Off makes the map update only on the Render button, for a map too big to
recompute while dragging.
*/
[Export]
public bool autoRender = true;
2026-11-12 14:28:42 +00:00
[ExportGroup( "Mesh Atlas" )]
int _meshAtlasMaxSize = 4096;
/** The cap on the shared mesh-pass buffer - see HeightMapMeshAtlas. A layer
whose texel rect does not fit under this is skipped with a warning.
*/
[Export]
public int meshAtlasMaxSize
{
get => _meshAtlasMaxSize;
set { _meshAtlasMaxSize = value; SetDirty(); }
}
bool _meshAtlasHdr = true;
/** Asks the mesh pass's viewport for a float render target. The pass writes
UVs rather than colours, so an 8-bit target quantizes them to 256 steps
across a texture - leave this on unless it turns out not to work.
*/
[Export]
public bool meshAtlasHdr
{
get => _meshAtlasHdr;
set { _meshAtlasHdr = value; SetDirty(); }
}
bool _meshAtlasSrgbDecode = false;
/** The other half of the question above: a low dynamic range render target
is also sRGB-encoded on the way out, which bends the values the mesh pass
wrote. Turn this on if the ribbon's textures come out offset and
stretched towards one edge with meshAtlasHdr off.
*/
[Export]
public bool meshAtlasSrgbDecode
{
get => _meshAtlasSrgbDecode;
set { _meshAtlasSrgbDecode = value; SetDirty(); }
}
[ExportGroup( "Debug Info" )]
[Export]
public int renderedLayers;
/* THE DIRTY FLAG
------------------------------------------------------------------------
One bool, checked in _Process, rather than rendering straight out of the
setter. A drag emits a transform notification every frame and an inspector
slider emits one per pixel of mouse travel, so rendering per notification
would run several full renders per frame to show one result.
*/
bool _dirty = true;
public void SetDirty()
{
_dirty = true;
}
public bool isDirty => _dirty;
public override void _Ready()
{
SetDirty();
}
public override void _Process( double delta )
{
if ( ! _dirty || ! autoRender )
{
return;
}
Render();
}
/* COORDINATES
------------------------------------------------------------------------
Texel (0,0) is the map's minimum XZ corner. addTerrainOffset computes its
UV as ( worldXZ - ( center - size/2 ) ) / size and samples with texture(),
whose (0,0) is the image's first texel - and BlitRect's Rect2I origin is
that same first texel, with no Y flip. So image +Y is world +Z throughout,
and nothing here needs to mirror anything.
*/
/** World XZ of the map's center - the node's own global position. */
public Vector2 centerXZ
{
get
{
var p = GlobalPosition;
return new Vector2( p.X, p.Z );
}
}
public Vector2 minXZ => centerXZ - size / 2;
public Vector2I textureSize => new Vector2I( Mathf.Max( 1, resolution.X ), Mathf.Max( 1, resolution.Y ) );
public Vector2 metersPerTexel
{
get
{
var r = textureSize;
return new Vector2( size.X / r.X, size.Y / r.Y );
}
}
public Rect2I fullRect => new Rect2I( 0, 0, textureSize.X, textureSize.Y );
public Vector2 TexelToWorldXZ( Vector2 texel )
{
var m = metersPerTexel;
var origin = minXZ;
return new Vector2( origin.X + texel.X * m.X, origin.Y + texel.Y * m.Y );
}
public Vector2 WorldXZToTexel( Vector2 worldXZ )
{
var m = metersPerTexel;
var origin = minXZ;
return new Vector2( ( worldXZ.X - origin.X ) / m.X, ( worldXZ.Y - origin.Y ) / m.Y );
}
/** The texel rect a world-space XZ box covers, grown to whole texels and
clipped to the map. Empty when the box misses the map entirely.
*/
public Rect2I WorldBoxToRect( Vector2 worldMin, Vector2 worldMax )
{
var min = WorldXZToTexel( worldMin );
var max = WorldXZToTexel( worldMax );
var x0 = Mathf.FloorToInt( Mathf.Min( min.X, max.X ) );
var y0 = Mathf.FloorToInt( Mathf.Min( min.Y, max.Y ) );
var x1 = Mathf.CeilToInt( Mathf.Max( min.X, max.X ) );
var y1 = Mathf.CeilToInt( Mathf.Max( min.Y, max.Y ) );
return new Rect2I( x0, y0, x1 - x0, y1 - y0 ).Intersection( fullRect );
}
/** Convenience for a radially symmetric layer. */
public Rect2I WorldCircleToRect( Vector3 worldCenter, float worldRadius )
{
var c = new Vector2( worldCenter.X, worldCenter.Z );
var r = new Vector2( worldRadius, worldRadius );
return WorldBoxToRect( c - r, c + r );
}
/** The texel rect a rotated XZ quad covers - the axis-aligned bounds of its
four corners. A rotated layer therefore touches more texels than it
fills; the shader still rejects everything outside the quad itself.
*/
public Rect2I WorldQuadToRect( Vector2 center, Vector2 axisX, Vector2 axisZ, Vector2 size )
{
var x = axisX * ( size.X / 2 );
var z = axisZ * ( size.Y / 2 );
var a = center + x + z;
var b = center + x - z;
var c = center - x + z;
var d = center - x - z;
var min = new Vector2(
Mathf.Min( Mathf.Min( a.X, b.X ), Mathf.Min( c.X, d.X ) ),
Mathf.Min( Mathf.Min( a.Y, b.Y ), Mathf.Min( c.Y, d.Y ) )
);
var max = new Vector2(
Mathf.Max( Mathf.Max( a.X, b.X ), Mathf.Max( c.X, d.X ) ),
Mathf.Max( Mathf.Max( a.Y, b.Y ), Mathf.Max( c.Y, d.Y ) )
);
return WorldBoxToRect( min, max );
}
/* HEIGHT NORMALIZATION
------------------------------------------------------------------------
Layers author in meters; the map stores 0..1. These two are the only
conversion, and which one a layer uses is decided by its blend mode -
HeightMapBlendMode.IsAbsolute().
*/
public float heightRange
{
get
{
var range = maxHeight - minHeight;
return Mathf.Abs( range ) < 0.0001f ? 1 : range;
}
}
/** A height difference in meters as a fraction of the range, for Add and
Subtract.
*/
public float NormalizeDelta( float worldHeightDelta )
{
return worldHeightDelta / heightRange;
}
/** An absolute world height as a stored 0..1 value, for Replace. */
public float NormalizeLevel( float worldHeight )
{
return ( worldHeight - minHeight ) / heightRange;
}
public float baseLevel => NormalizeLevel( baseHeight );
/* RENDERING */
HeightMapShapeMaterial _shapeMaterial;
/** The shared content-pass material. Layers configure it in
RenderToHeightMap() and then call DrawShape(), rather than allocating a
material each.
*/
public HeightMapShapeMaterial shapeMaterial
{
get
{
if ( _shapeMaterial == null )
{
_shapeMaterial = new HeightMapShapeMaterial();
}
return _shapeMaterial;
}
}
BlitMaterial _blendBlit;
BlitMaterial _copyBlit;
/** Straight overwrite, for clearing. */
BlitMaterial copyBlit
{
get
{
if ( _copyBlit == null )
{
_copyBlit = new BlitMaterial();
_copyBlit.BlendMode = BlitMaterial.BlendModeEnum.Disabled;
}
return _copyBlit;
}
}
readonly List<HeightMapDrawLayer> _layers = new List<HeightMapDrawLayer>();
bool _rendering = false;
2026-11-12 14:28:42 +00:00
/* THE MESH PASS
------------------------------------------------------------------------
Created on demand and only by a layer that needs it, as an internal child
so it neither shows up in the scene tree nor gets saved with it - and so
CollectLayers, which walks the normal children, never sees it.
The first layer to ask for it in a given render starts the pass; the
generator ends it in PrepareLayers once every layer has contributed.
*/
HeightMapMeshAtlas _meshAtlas;
bool _meshAtlasUsed;
bool _meshAtlasRedrawQueued;
public HeightMapMeshAtlas meshAtlas
{
get
{
if ( _meshAtlas == null )
{
_meshAtlas = new HeightMapMeshAtlas();
_meshAtlas.Name = "HeightMapMeshAtlas";
AddChild( _meshAtlas, false, InternalMode.Front );
}
_meshAtlas.maxSize = Mathf.Clamp( meshAtlasMaxSize, 256, 16384 );
_meshAtlas.hdr = meshAtlasHdr;
if ( ! _meshAtlasUsed )
{
_meshAtlasUsed = true;
_meshAtlas.Begin();
}
return _meshAtlas;
}
}
/** Clears the map to baseHeight and lets every visible layer underneath draw
into it, in tree order - so the layer listed last wins, the same way the
3D viewport draws it.
*/
public void Render()
{
// A layer that calls SetDirty() from inside its own draw - a transform
// notification from a tool script, say - would otherwise recurse.
if ( _rendering )
{
return;
}
_dirty = false;
_rendering = true;
try
{
PrepareTarget();
_layers.Clear();
CollectLayers( this, _layers );
renderedLayers = 0;
2026-11-12 14:28:42 +00:00
PrepareLayers();
for ( var i = 0; i < _layers.Count; i++ )
{
var layer = _layers[ i ];
if ( ! layer.IsVisibleInTree() )
{
continue;
}
layer.RenderToHeightMap( this );
renderedLayers++;
}
RenderNormalMap();
PushToMaterials();
}
finally
{
_rendering = false;
}
2026-11-12 14:28:42 +00:00
// Outside the guard on purpose: the mesh pass renders at the end of this
// frame, so the blits above read the previous one. Flagging here makes the
// next frame redo them against the finished atlas, and because the pass
// reports no change the second time around, it settles after exactly one
// extra render.
if ( _meshAtlasRedrawQueued )
{
_meshAtlasRedrawQueued = false;
SetDirty();
}
}
/** The pre-pass: every visible layer gets to set up anything that cannot
happen inside the ordered draw loop, then the shared mesh pass runs once.
The atlas is only touched when a layer actually allocated a region, so a
map made of circles and images never creates a viewport at all.
*/
void PrepareLayers()
{
_meshAtlasUsed = false;
for ( var i = 0; i < _layers.Count; i++ )
{
var layer = _layers[ i ];
if ( ! layer.IsVisibleInTree() )
{
continue;
}
layer.PrepareRender( this );
}
if ( ! _meshAtlasUsed || _meshAtlas == null )
{
return;
}
_meshAtlasRedrawQueued = _meshAtlas.Flush();
}
void CollectLayers( Node node, List<HeightMapDrawLayer> into )
{
foreach ( var child in node.GetChildren() )
{
// Its own map, its own layers.
if ( child is HeightMapGenerator )
{
continue;
}
if ( child is HeightMapDrawLayer layer )
{
into.Add( layer );
}
CollectLayers( child, into );
}
}
/** One blit per layer pair: the shape into a rect-sized scratch buffer with
blending disabled, then the scratch onto the map with the blend mode's
hardware blend.
The scratch buffer is rect-sized rather than full-size because BlitRect
stretches its whole source across the destination rect - a full-size
source would be squeezed into the rect.
Called by layers after they have set up `shapeMaterial`; the rect and
blend-mode uniforms are set here, so a layer cannot get the world mapping
wrong.
2026-11-12 14:28:42 +00:00
The four textures fill the shader's hint_blit_source0 to
hint_blit_source3 - an image layer passes its texture and optionally a
mask, a spline layer passes the mesh atlas, a mask, its texture atlas and
its level table, a purely procedural shape passes nothing. Four is the
exact ceiling the spike measured, so there is no slot left over.
Every slot is always bound, to the shared white 1x1 stand-in when a layer
has nothing for it: an unbound blit source arrives black, so "no mask"
would otherwise have to be a flag, while white is already a no-op
multiplier.
*/
public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode )
{
2026-11-12 14:28:42 +00:00
DrawShape( rect, blendMode, null, null, null, null );
}
public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source )
{
2026-11-12 14:28:42 +00:00
DrawShape( rect, blendMode, source, null, null, null );
}
public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source, Texture2D mask )
2026-11-12 14:28:42 +00:00
{
DrawShape( rect, blendMode, source, mask, null, null );
}
public void DrawShape(
Rect2I rect, HeightMapBlendMode blendMode,
Texture2D source, Texture2D mask, Texture2D source2, Texture2D source3
)
{
if ( heightMapTarget == null || rect.Size.X <= 0 || rect.Size.Y <= 0 )
{
return;
}
var material = shapeMaterial;
var meters = metersPerTexel;
material.rectWorldMin.Set( TexelToWorldXZ( new Vector2( rect.Position.X, rect.Position.Y ) ) );
material.rectWorldSize.Set( new Vector2( rect.Size.X * meters.X, rect.Size.Y * meters.Y ) );
material.blendMode.Set( (int) blendMode );
var scratch = TextureStackBufferPool.shared.Rent( rect.Size.X, rect.Size.Y, format, Transparent );
try
{
_blitSources[ 0 ] = source != null ? source : (Texture2D) dummySource;
_blitSources[ 1 ] = mask != null ? mask : (Texture2D) dummySource;
2026-11-12 14:28:42 +00:00
_blitSources[ 2 ] = source2 != null ? source2 : (Texture2D) dummySource;
_blitSources[ 3 ] = source3 != null ? source3 : (Texture2D) dummySource;
// BlitRectMulti rather than BlitRect, because the explicit sources array
// is the documented way to fill more than source 0.
scratch.BlitRectMulti(
new Rect2I( 0, 0, rect.Size.X, rect.Size.Y ),
_blitSources, TextureStackCompositor.noExtraTargets, White, 0, material
);
if ( _blendBlit == null )
{
_blendBlit = new BlitMaterial();
}
_blendBlit.BlendMode = blendMode.ToBlitBlendMode();
heightMapTarget.BlitRect( rect, scratch, White, 0, _blendBlit );
}
finally
{
TextureStackBufferPool.shared.Return( scratch );
}
}
/** Reused rather than allocated per blit - a drag re-renders every frame. */
readonly Godot.Collections.Array<Texture2D> _blitSources
2026-11-12 14:28:42 +00:00
= new Godot.Collections.Array<Texture2D>(){ null, null, null, null };
DrawableTexture2D _dummySource;
DrawableTexture2D dummySource
{
get
{
if ( _dummySource == null )
{
_dummySource = new DrawableTexture2D();
_dummySource.Setup( 1, 1, DrawableTexture2D.DrawableFormat.Rgba8, White, false );
}
return _dummySource;
}
}
/* PREPARING THE MAP
------------------------------------------------------------------------
Setup() reallocates, and reallocating makes the engine emit the texture's
changed signal - which rebuilds the inspector. So it runs only when the
allocation actually has to change, and every other render clears by
blitting a 1x1 base-height texture across the map with blending disabled.
Same pixels, no reallocation, no signal. This is also what takes the one
expensive fixed cost out of a re-render and makes rendering per frame
affordable - the same finding the TextureStack compositor landed on.
*/
DrawableTexture2D _setupTarget;
Vector2I _setupSize;
TextureStackFormat _setupFormat;
DrawableTexture2D _clearSource;
Color _clearSourceColor;
TextureStackFormat _clearSourceFormat;
void PrepareTarget()
{
if ( heightMapTarget == null )
{
heightMapTarget = new DrawableTexture2D();
}
var level = baseLevel;
var clearColor = new Color( level, level, level, 1 );
var wanted = textureSize;
if ( _setupTarget != heightMapTarget || _setupSize != wanted || _setupFormat != format )
{
heightMapTarget.Setup( wanted.X, wanted.Y, format.ToDrawableFormat(), clearColor, false );
_setupTarget = heightMapTarget;
_setupSize = wanted;
_setupFormat = format;
return;
}
ClearTarget( clearColor );
}
void ClearTarget( Color clearColor )
{
if ( _clearSource == null || _clearSourceColor != clearColor || _clearSourceFormat != format )
{
if ( _clearSource == null )
{
_clearSource = new DrawableTexture2D();
}
_clearSource.Setup( 1, 1, format.ToDrawableFormat(), clearColor, false );
_clearSourceColor = clearColor;
_clearSourceFormat = format;
}
heightMapTarget.BlitRect( fullRect, _clearSource, White, 0, copyBlit );
}
/* THE NORMAL PASS
------------------------------------------------------------------------
One full-map blit with the finished height map as the source. It runs
after the layers rather than per layer, because a normal is a derivative
of the composited result - taking it per layer and blending the results
would be wrong wherever two layers overlap.
Nothing clears the normal map: the shader writes every texel with blending
disabled, so the previous render is fully overwritten.
*/
HeightMapNormalsMaterial _normalsMaterial;
HeightMapNormalsMaterial normalsMaterial
{
get
{
if ( _normalsMaterial == null )
{
_normalsMaterial = new HeightMapNormalsMaterial();
}
return _normalsMaterial;
}
}
DrawableTexture2D _setupNormalTarget;
Vector2I _setupNormalSize;
TextureStackFormat _setupNormalFormat;
/** Flat up, encoded - what an unrendered normal map should read as. */
static readonly Color FlatNormal = new Color( 0.5f, 1, 0.5f, 1 );
void RenderNormalMap()
{
if ( normalMapTarget == null || heightMapTarget == null )
{
return;
}
var wanted = textureSize;
// Same allocation rule as the height map - Setup() reallocates and that
// emits the texture's changed signal, which rebuilds the inspector.
if ( _setupNormalTarget != normalMapTarget
|| _setupNormalSize != wanted
|| _setupNormalFormat != format )
{
normalMapTarget.Setup( wanted.X, wanted.Y, format.ToDrawableFormat(), FlatNormal, false );
_setupNormalTarget = normalMapTarget;
_setupNormalSize = wanted;
_setupNormalFormat = format;
}
var material = normalsMaterial;
material.sizeXZ.Set( size );
material.minHeight.Set( minHeight );
material.maxHeight.Set( maxHeight );
material.radius.Set( normalRadius );
material.samples.Set( normalSamples );
material.strength.Set( normalStrength );
normalMapTarget.BlitRect( fullRect, heightMapTarget, White, 0, material );
}
/** Pushes the maps and their world mapping onto every assigned material. */
public void PushToMaterials()
{
if ( materials == null )
{
return;
}
for ( var i = 0; i < materials.Length; i++ )
{
var material = materials[ i ];
if ( material == null )
{
continue;
}
TerrainOffsetShader.heightMap.Set( material, heightMapTarget );
TerrainOffsetShader.normalMap.Set( material, normalMapTarget );
TerrainOffsetShader.centerXZ.Set( material, centerXZ );
TerrainOffsetShader.sizeXZ.Set( material, size );
TerrainOffsetShader.minHeight.Set( material, minHeight );
TerrainOffsetShader.maxHeight.Set( material, maxHeight );
}
}
/** Rough VRAM cost of the maps, in bytes. The scratch buffer is rect-sized
and pooled, so it is not counted - a layer covering the whole map would
add one more of these.
2026-11-12 14:28:42 +00:00
The mesh atlas is counted when one exists, since it is a full extra
buffer and sized by the layers rather than by the map.
*/
public long EstimateMemory()
{
var r = textureSize;
var maps = normalMapTarget != null ? 2 : 1;
2026-11-12 14:28:42 +00:00
var total = (long) r.X * r.Y * format.BytesPerPixel() * maps;
if ( _meshAtlas != null )
{
total += _meshAtlas.EstimateMemory();
}
return total;
}
}
}