2026-09-25 14:42:07 +02:00
// Fill out your copyright notice in the Description page of Project Settings.
# include "WorldMapBuilder.h"
# include "GameObjects/OrthoMapCaptureActor.h"
# include "AssetCompilingManager.h"
# include "Engine/Scene.h"
# include "Engine/Texture2D.h"
# include "Engine/World.h"
# include "EngineUtils.h"
# include "HAL/FileManager.h"
# include "HAL/PlatformTime.h"
# include "ImageUtils.h"
# include "Misc/FileHelper.h"
# include "Misc/PackageName.h"
# include "Misc/Paths.h"
# include "UObject/Package.h"
# include "WorldPartition/WorldPartitionMiniMapHelper.h"
DEFINE_LOG_CATEGORY_STATIC ( LogWorldMapBuilder , Log , All ) ;
namespace
{
/** The Z range a region stated on the command line gets when the actor has no region to take one from. Only
* X and Y decide what the map covers , and this has to hold the ground and anything standing on it , which is
* why it is two kilometres deep rather than something rounder . */
constexpr double CommandLineRegionMinZ = - 100000.0 ;
constexpr double CommandLineRegionMaxZ = 100000.0 ;
/** The source every tile is captured through, which is deliberately not the actor's own CaptureSourceOverride.
*
* A bake captures with the post process off : the capture it goes through is the engine ' s own minimap
* capture , which turns the post process , the lighting , the atmosphere and the fog off before it renders ,
* because that is what a map is made of . SCS_FinalColorLDR reads the picture the post process produced , so
* with none of it running there is nothing for it to read and every tile arrives clear : a blank map , baked
* without a single error , which is the worst way for a bake to fail . Reading the base colour out of the
* g - buffer instead does not depend on any of that , and it is what the engine ' s own minimap capture asks for
* by default .
*
* The actor ' s setting still drives the actor ' s own interactive capture , where the post process does run . */
constexpr ESceneCaptureSource BakeCaptureSource = SCS_BaseColor ;
/** Reads a whole number out of one of the two numbers -WorldMapPiece is made of, digits and nothing else.
*
* FParse : : Value ' s own number reader takes the digits at the front of whatever it is handed and answers zero
* for the rest of it , and piece zero is a perfectly good piece : a mistyped - WorldMapPiece = 0.0 , 1 or = o , 1
* would quietly bake piece ( 0 , 1 ) instead of being reported . Reading the digits here turns both into a
* refusal . A limit of nine digits cannot overflow the accumulator and is more than any piece grid could
* have . */
bool TryParsePieceIndex ( const FString & Text , int32 & OutIndex )
{
// Trimmed here rather than trusted to the split above it, so that a space either side of one of the two
// numbers reads as the same thing as no space at all.
const FString Trimmed = Text . TrimStartAndEnd ( ) ;
if ( Trimmed . IsEmpty ( ) | | Trimmed . Len ( ) > 9 )
{
return false ;
}
for ( const TCHAR Character : Trimmed )
{
if ( ! FChar : : IsDigit ( Character ) )
{
return false ;
}
}
OutIndex = FCString : : Atoi ( * Trimmed ) ;
return true ;
}
}
void UWorldMapBuilder : : RestoreOverriddenSettings ( )
{
if ( ! bOverrodeSettings | | ! SettingsActor )
{
return ;
}
SettingsActor - > MapTextureSize = OriginalMapTextureSize ;
SettingsActor - > TilesPerAxis = OriginalTilesPerAxis ;
SettingsActor - > WorldMapBounds = OriginalWorldMapBounds ;
bOverrodeSettings = false ;
UE_LOG ( LogWorldMapBuilder , Display , TEXT ( " The actor's own world map settings are back in place. " ) ) ;
}
bool UWorldMapBuilder : : ReadSettings ( UWorld * World , FText & OutReason )
{
SettingsActor = nullptr ;
// The settings live on the actor in the map rather than on the builder, so there is exactly one place the
// region, the resolution and the flips are configured, and the game reads the same numbers the bake did.
2026-09-26 14:46:21 +02:00
const TActorIterator < AOrthoMapCaptureActor > SettingsIt ( World ) ;
if ( SettingsIt )
2026-09-25 14:42:07 +02:00
{
2026-09-26 14:46:21 +02:00
SettingsActor = * SettingsIt ;
2026-09-25 14:42:07 +02:00
}
if ( ! SettingsActor )
{
OutReason = NSLOCTEXT ( " EleriWorldMapBuilder " , " NoSettingsActor " ,
" No OrthoMapCaptureActor was found in the map. Place one, set its world map bounds with FitBoundsToWorldPartition, and run the bake again. " ) ;
return false ;
}
UE_LOG ( LogWorldMapBuilder , Display , TEXT ( " The world map settings come from %s, at %s. " ) ,
* SettingsActor - > GetActorNameOrLabel ( ) , * SettingsActor - > GetActorLocation ( ) . ToCompactString ( ) ) ;
// Remembered before anything is changed, so that whatever this run overrides is put back exactly as it was.
OriginalMapTextureSize = SettingsActor - > MapTextureSize ;
OriginalTilesPerAxis = SettingsActor - > TilesPerAxis ;
OriginalWorldMapBounds = SettingsActor - > WorldMapBounds ;
// A bake has to be told which part of the world the map covers, and on a map that has never been baked the
// actor has no region yet. There are two ways to say so without opening the editor, and the explicit one
// wins when both are given.
//
// -WorldMapBounds=minX,minY,maxX,maxY states the region outright. This is the one that always works in a
// commandlet: the world partition's editor bounds are built by the editor's own world partition systems,
// which a commandlet does not run, so asking the partition for them tends to answer with the whole world.
//
// -FitBounds takes the region from the world partition instead, squared and aligned by the actor's own
// fitting, which is what to use on a map whose partition does report real bounds.
bOverrodeSettings = false ;
// FParse::Value treats a comma as a separator by default, and a list of four numbers is made of commas, so
// the flag is taken with that turned off and the list arrives whole.
FString BoundsArgument ;
if ( FParse : : Value ( * GetBuilderArgs ( ) , TEXT ( " WorldMapBounds= " ) , BoundsArgument , /*bShouldStopOnSeparator=*/ false ) )
{
TArray < FString > Components ;
BoundsArgument . ParseIntoArray ( Components , TEXT ( " , " ) , true ) ;
if ( Components . Num ( ) ! = 4 )
{
UE_LOG ( LogWorldMapBuilder , Error ,
TEXT ( " -WorldMapBounds wants four comma separated numbers, minX,minY,maxX,maxY, and was given \" %s \" . " ) ,
* BoundsArgument ) ;
RestoreOverriddenSettings ( ) ;
return false ;
}
// Only X and Y decide what the map covers; Z is the height the captures look down from. A region that
// came off the actor keeps its own Z, and one that did not gets a range wide enough to hold the ground
// and whatever stands on it, because a camera placed below its own world sees nothing at all.
const bool bHasZFromActor = ( SettingsActor - > WorldMapBounds . IsValid ! = 0 ) ;
const double MinZ = bHasZFromActor ? SettingsActor - > WorldMapBounds . Min . Z : CommandLineRegionMinZ ;
const double MaxZ = bHasZFromActor ? SettingsActor - > WorldMapBounds . Max . Z : CommandLineRegionMaxZ ;
SettingsActor - > WorldMapBounds = FBox (
FVector ( FCString : : Atod ( * Components [ 0 ] ) , FCString : : Atod ( * Components [ 1 ] ) , MinZ ) ,
FVector ( FCString : : Atod ( * Components [ 2 ] ) , FCString : : Atod ( * Components [ 3 ] ) , MaxZ ) ) ;
bOverrodeSettings = true ;
UE_LOG ( LogWorldMapBuilder , Warning , TEXT ( " World map region taken from the command line: %s. " ) ,
* SettingsActor - > WorldMapBounds . ToString ( ) ) ;
}
else if ( HasParam ( TEXT ( " FitBounds " ) ) )
{
SettingsActor - > FitBoundsToWorldPartition ( ) ;
bOverrodeSettings = true ;
}
// A piece run bakes one window onto the region rather than the whole of it, and the window is cut out of the
// region the actor holds rather than stated as a region of its own: that is what keeps the pieces of a grid
// tiling the region exactly, edge on edge, whatever the region happens to be. See DerivePieceRegion.
//
// The piece is placed here, before the tile count below, because the tile count is never allowed to re-align
// a piece run: the pieces are windows onto the actor's region, and re-aligning that region would move every
// one of them with it. The piece's own box is squared up by the actor's own validator instead, which is the
// last thing to look at the grid before the walk starts.
FString PieceIndexArgument ;
const bool bPieceRequested = FParse : : Value (
* GetBuilderArgs ( ) , TEXT ( " WorldMapPiece= " ) , PieceIndexArgument , /*bShouldStopOnSeparator=*/ false ) ;
if ( bPieceRequested )
{
// A region stated on the command line replaces the actor's own, and the pieces are cut from the actor's.
// Allowing both would cut pieces from one region and then bake them against another, so the two are
// refused together rather than silently ordered.
if ( bOverrodeSettings )
{
UE_LOG ( LogWorldMapBuilder , Error ,
TEXT ( " -WorldMapPiece cuts its piece out of the region the settings actor holds, so it cannot be combined with -WorldMapBounds or -FitBounds. Either of those replaces that region with one the pieces were never cut from, which would move every piece and change what the pieces already baked cover. " ) ) ;
RestoreOverriddenSettings ( ) ;
return false ;
}
FText PieceReason ;
if ( ! DerivePieceRegion ( PieceIndexArgument , PieceReason ) )
{
RestoreOverriddenSettings ( ) ;
OutReason = PieceReason ;
return false ;
}
}
else
{
// A grid on its own places no piece in anything, which makes it the shape a forgotten piece index takes.
// Saying what the run is doing instead is cheaper than a bake of the whole map that was meant to be
// sixteen pieces.
FString UnusedGridArgument ;
if ( FParse : : Value (
* GetBuilderArgs ( ) , TEXT ( " WorldMapPieceGrid= " ) , UnusedGridArgument , /*bShouldStopOnSeparator=*/ false ) )
{
UE_LOG ( LogWorldMapBuilder , Warning ,
TEXT ( " -WorldMapPieceGrid was given without -WorldMapPiece, so this run bakes the whole map and the grid is ignored: a grid only means something once one of its pieces has been asked for. Pass -WorldMapPiece=<column>,<row> as well to bake one piece. " ) ) ;
}
}
// Trial runs. The resolution and the tile count change how long a bake takes and how big its output is, but
// not what it covers, so being able to override them for one run is the difference between checking the
// geometry in four tiles and waiting for sixty four.
//
// The override is written onto the actor's settings and left there for the length of the run rather than
// kept here, for two reasons: the actor's own validator is then what judges the combination, so there is no
// second copy of the rules about what a grid has to look like, and the mapping that the coverage checks go
// through reads the actor's bounds, so the builder and the actor have to agree about them throughout.
// Nothing is saved, and PostRun puts the original values back.
// The match has to carry its own '=': FParse::Value starts copying right after whatever was matched, so
// matching "TilesPerAxis" alone leaves the '=' at the front of the value, which reads back as zero. This is
// the same form the engine's own commandlets use for their switches.
const bool bOverrodeMapTextureSize = GetParamValue ( TEXT ( " MapTextureSize= " ) , SettingsActor - > MapTextureSize ) ;
const bool bOverrodeTilesPerAxis = GetParamValue ( TEXT ( " TilesPerAxis= " ) , SettingsActor - > TilesPerAxis ) ;
bOverrodeSettings | = bOverrodeMapTextureSize | | bOverrodeTilesPerAxis ;
if ( bOverrodeTilesPerAxis & & ! bPieceRun )
{
// A different tile count means different tile boundaries, so the region is put on them the same way
// FitBoundsToWorldPartition does. A trial run then covers the same part of the world, squared up for the
// grid it is actually walking rather than the one the actor was last aligned to.
//
// Not for a piece: its box is a window cut out of the region the pieces tile, so it is the tiles that
// follow the piece's box rather than the box that follows the tiles. A piece of a region whose tiles are
// whole comes out with whole tiles of its own, which makes this alignment nothing at all, and an
// alignment that is not nothing is a run baking a region that is not one of the pieces. The actor's own
// validator has the last word on the combination either way.
SettingsActor - > WorldMapBounds = SettingsActor - > GetAlignedWorldMapBounds ( SettingsActor - > WorldMapBounds ) ;
}
FText GridReason ;
const bool bGridIsValid = SettingsActor - > IsWorldMapGridValid ( GridReason ) ;
if ( ! bGridIsValid )
{
// Nothing else is going to look at the actor on a run that stops here, so the override goes back now
// rather than waiting for a PostRun that has nothing to report.
RestoreOverriddenSettings ( ) ;
OutReason = GridReason ;
return false ;
}
TilesPerAxis = SettingsActor - > TilesPerAxis ;
MapTextureSize = SettingsActor - > MapTextureSize ;
TilePixels = SettingsActor - > GetTilePixelSize ( ) ;
TileWorldSize = SettingsActor - > GetTileWorldSize ( ) ;
MapBounds = SettingsActor - > WorldMapBounds ;
// The framework's cells are index * cell size, so a cell size of one tile makes a cell index a tile index
// and CellInfo.Bounds the tile's own world box. The walk is then already the grid this bake wants, with no
// matrix, flooring or clipping left to get wrong.
IterativeCellSize = static_cast < int32 > ( TileWorldSize ) ;
IterativeWorldBounds = MapBounds ;
const FWorldBuilderCellCoord OriginCell = FCellInfo : : GetCellCoord ( MapBounds . Min , IterativeCellSize ) ;
GridOriginX = OriginCell . X ;
GridOriginY = OriginCell . Y ;
if ( ! DeriveOrientation ( * SettingsActor ) )
{
OutReason = NSLOCTEXT ( " EleriWorldMapBuilder " , " OrientationUndecidable " ,
" The world map mapping does not send the picture's two directions along the world's two horizontal axes, so a tile could not be placed in a map made from it. " ) ;
return false ;
}
if ( bOverrodeSettings )
{
UE_LOG ( LogWorldMapBuilder , Warning ,
TEXT ( " Settings overridden for this run: MapTextureSize=%d, TilesPerAxis=%d, so a tile is %d pixels and %.0f world units over %s. The actor's own settings go back when the run ends. " ) ,
MapTextureSize , TilesPerAxis , TilePixels , TileWorldSize , * MapBounds . ToString ( ) ) ;
}
UE_LOG ( LogWorldMapBuilder , Display ,
TEXT ( " World map bake: %d x %d pixels as %d x %d tiles of %d pixels, each tile %.0f world units, over %s. " ) ,
MapTextureSize , MapTextureSize , TilesPerAxis , TilesPerAxis , TilePixels , TileWorldSize , * MapBounds . ToString ( ) ) ;
// A piece gets a line of its own, because a piece run is a run that can be pointed at the wrong piece: the
// index, the grid it was read in, and both boxes on one line are what tells a bake of the piece that was
// asked for apart from a bake of the whole region, or of the piece next to it.
if ( bPieceRun )
{
UE_LOG ( LogWorldMapBuilder , Display ,
TEXT ( " This run bakes piece (%d, %d) of the %d x %d the region %s is cut into, so it covers %s: %d x %d tiles of %d pixels, each tile %.0f world units. Its map asset is the map's own name with _Piece%dx%d_%d_%d on the end. " ) ,
PieceColumn , PieceRow , PieceGrid , PieceGrid , * PieceParentBounds . ToString ( ) , * MapBounds . ToString ( ) ,
TilesPerAxis , TilesPerAxis , TilePixels , TileWorldSize , PieceGrid , PieceGrid , PieceColumn , PieceRow ) ;
}
UE_LOG ( LogWorldMapBuilder , Display ,
TEXT ( " First cell (%lld, %lld) at a cell size of %d, so %d x %d cells will be walked. " ) ,
GridOriginX , GridOriginY , IterativeCellSize , TilesPerAxis , TilesPerAxis ) ;
// The settings actor is placed where the map is interesting, so how far it sits from the middle of the
// region is the cheapest check there is on whether that region covers the world. A bake of a region with
// nothing in it renders a grid of clear tiles and says nothing else about it, and this is the one line that
// tells a bake of the wrong place apart from a bake of an empty one.
const FVector SettingsLocation = SettingsActor - > GetActorLocation ( ) ;
UE_LOG ( LogWorldMapBuilder , Display , TEXT ( " The settings actor is %.0f world units from the middle of the region and %s. " ) ,
FVector : : Dist2D ( SettingsLocation , MapBounds . GetCenter ( ) ) ,
MapBounds . IsInsideOrOn ( SettingsLocation ) ? TEXT ( " inside it " ) : TEXT ( " outside it " ) ) ;
return true ;
}
bool UWorldMapBuilder : : DerivePieceRegion ( const FString & PieceIndexArgument , FText & OutReason )
{
// How many pieces a side the region is cut into, read beside the index because the two only mean anything
// together: the same index in a different grid is a different piece, and the grid is part of every piece's
// name. A flag that does not read as a number lands as zero here, which the check below refuses.
GetParamValue ( TEXT ( " WorldMapPieceGrid= " ) , PieceGrid ) ;
if ( PieceGrid < 1 )
{
OutReason = FText : : Format (
NSLOCTEXT ( " EleriWorldMapBuilder " , " PieceGridNotWhole " ,
" -WorldMapPieceGrid is {0}, and a region can only be cut into a whole number of pieces a side. " ) ,
FText : : AsNumber ( PieceGrid ) ) ;
return false ;
}
// -WorldMapPiece=<column>,<row>, read the same way -WorldMapBounds is: FParse::Value treats a comma as a
// separator, so the flag was taken with that turned off and the pair arrives whole.
TArray < FString > Components ;
PieceIndexArgument . ParseIntoArray ( Components , TEXT ( " , " ) , true ) ;
int32 Column = 0 ;
int32 Row = 0 ;
if ( Components . Num ( ) ! = 2 | | ! TryParsePieceIndex ( Components [ 0 ] , Column ) | | ! TryParsePieceIndex ( Components [ 1 ] , Row ) )
{
OutReason = FText : : Format (
NSLOCTEXT ( " EleriWorldMapBuilder " , " PieceIndexMalformed " ,
" -WorldMapPiece wants two comma separated whole numbers, a column and a row, and was given \" {0} \" . " ) ,
FText : : AsCultureInvariant ( PieceIndexArgument ) ) ;
return false ;
}
if ( Column > = PieceGrid | | Row > = PieceGrid )
{
OutReason = FText : : Format (
NSLOCTEXT ( " EleriWorldMapBuilder " , " PieceIndexOutOfRange " ,
" Piece ({0}, {1}) is not in a {2} x {2} grid, which numbers its pieces 0 to {3}. Pieces are numbered from the region's own min corner: the column runs along +X and the row along +Y. " ) ,
FText : : AsNumber ( Column ) , FText : : AsNumber ( Row ) , FText : : AsNumber ( PieceGrid ) , FText : : AsNumber ( PieceGrid - 1 ) ) ;
return false ;
}
// The region the pieces are windows onto, and it has to be one a whole map could have been baked from,
// because a piece is baked as a region of its own: the checks from here down are the ones the settings
// actor's own validator makes of the whole region, made here so that a piece run says which of them it fell
// foul of rather than reporting one against a box that only exists because the region was cut up.
const FBox ParentBounds = SettingsActor - > WorldMapBounds ;
if ( ! ParentBounds . IsValid )
{
OutReason = NSLOCTEXT ( " EleriWorldMapBuilder " , " PieceNoParentRegion " ,
" The settings actor has no WorldMapBounds, and a piece is a window onto the region the actor holds, so there is nothing to cut one out of. Set the region, save the map, and bake the whole map once so that the pieces are cut from the region that bake used. " ) ;
return false ;
}
const FVector ParentExtent = ParentBounds . GetExtent ( ) ;
const double ParentSide = ParentExtent . X * 2.0 ;
const double ParentHeight = ParentExtent . Y * 2.0 ;
if ( ParentSide < = 0.0 | | ParentHeight < = 0.0 )
{
OutReason = NSLOCTEXT ( " EleriWorldMapBuilder " , " PieceFlatParentRegion " ,
" The settings actor's WorldMapBounds is flat along one of its axes, so there is no region for pieces to be cut out of. " ) ;
return false ;
}
// The actor's own tolerance, so that a region a whole map bake accepts is a region pieces can be cut from.
if ( ! FMath : : IsNearlyEqual ( ParentSide , ParentHeight , FMath : : Max ( ParentSide , ParentHeight ) * 1.e-4 ) )
{
OutReason = FText : : Format (
NSLOCTEXT ( " EleriWorldMapBuilder " , " PieceNonSquareParentRegion " ,
" The settings actor's WorldMapBounds is {0} by {1} world units, and a world map region has to be square: a piece of one is baked as a square region in its own right, so cutting up a region that is not square would leave gaps between the pieces. FitBoundsToWorldPartition squares it up. " ) ,
FText : : AsNumber ( FMath : : RoundToInt ( ParentSide ) ) , FText : : AsNumber ( FMath : : RoundToInt ( ParentHeight ) ) ) ;
return false ;
}
// Whole units a side, so that each piece's own box lands on whole world units. That is what lets the actor's
// own validator judge the piece's tile grid as it stands rather than a rounded version of it, and it is what
// makes the pieces tile the region edge on edge with nothing between them.
const double PieceSide = ParentSide / static_cast < double > ( PieceGrid ) ;
if ( ! FMath : : IsNearlyEqual ( PieceSide , FMath : : RoundToDouble ( PieceSide ) , 1.e-6 ) )
{
OutReason = FText : : Format (
NSLOCTEXT ( " EleriWorldMapBuilder " , " PieceSideNotWhole " ,
" The region is {0} world units across, which {1} pieces a side does not divide into whole units: a piece would be {2} units wide. Use a grid that divides the region's side exactly. " ) ,
FText : : AsNumber ( FMath : : RoundToInt ( ParentSide ) ) , FText : : AsNumber ( PieceGrid ) , FText : : AsNumber ( PieceSide ) ) ;
return false ;
}
// The piece's box, taken from the region's own min corner and carrying the region's own Z: Z is not part of
// the grid, it is the height the captures look down from, and it is the region's range that has to be looked
// down from whichever window of it is being baked.
const FVector PieceMin (
ParentBounds . Min . X + PieceSide * static_cast < double > ( Column ) ,
ParentBounds . Min . Y + PieceSide * static_cast < double > ( Row ) ,
ParentBounds . Min . Z ) ;
const FVector PieceMax ( PieceMin . X + PieceSide , PieceMin . Y + PieceSide , ParentBounds . Max . Z ) ;
// Nothing above this line has touched the actor or this builder's own idea of the region, so a run refused
// above leaves both as they were and the reason it reports is about the region as it stands.
PieceColumn = Column ;
PieceRow = Row ;
PieceParentBounds = ParentBounds ;
SettingsActor - > WorldMapBounds = FBox ( PieceMin , PieceMax ) ;
// The piece's box stands in for the region for the length of the run, so it is an override like any other:
// PostRun puts the region the pieces were cut from back, and the bake reads the piece's box as the region
// without being told that is what it is.
bPieceRun = true ;
bOverrodeSettings = true ;
return true ;
}
bool UWorldMapBuilder : : DeriveOrientation ( const AOrthoMapCaptureActor & Settings )
{
// A step of a quarter tile taken from the map's own corner: small enough to stay well inside the map,
// large enough that the difference is clear of the precision of the arithmetic.
const double Step = FMath : : Max ( 1.0 , TileWorldSize * 0.25 ) ;
const FVector Min = MapBounds . Min ;
const FVector2D Origin = Settings . WorldToBakedMapUV ( FVector ( Min . X , Min . Y , Min . Z ) ) ;
const FVector2D AlongX = Settings . WorldToBakedMapUV ( FVector ( Min . X + Step , Min . Y , Min . Z ) ) - Origin ;
const FVector2D AlongY = Settings . WorldToBakedMapUV ( FVector ( Min . X , Min . Y + Step , Min . Z ) ) - Origin ;
Orientation . bUFollowsX = FMath : : Abs ( AlongX . X ) > = FMath : : Abs ( AlongX . Y ) ;
Orientation . bVFollowsY = FMath : : Abs ( AlongY . Y ) > = FMath : : Abs ( AlongY . X ) ;
Orientation . bMirrorU = ( Orientation . bUFollowsX ? AlongX . X : AlongY . X ) < 0.0 ;
Orientation . bMirrorV = ( Orientation . bVFollowsY ? AlongY . Y : AlongX . Y ) < 0.0 ;
// The two picture directions have to follow two different world axes. If they follow one, two tiles would
// be sent to the same square of the map and another square would stay empty, which is worth refusing rather
// than baking.
if ( Orientation . bUFollowsX ! = Orientation . bVFollowsY )
{
UE_LOG ( LogWorldMapBuilder , Error ,
TEXT ( " The world map mapping sends both picture directions along world %s: a step along X moves the UV by (%.6f, %.6f) and a step along Y moves it by (%.6f, %.6f). " ) ,
Orientation . bUFollowsX ? TEXT ( " X " ) : TEXT ( " Y " ) , AlongX . X , AlongX . Y , AlongY . X , AlongY . Y ) ;
return false ;
}
UE_LOG ( LogWorldMapBuilder , Display ,
TEXT ( " Tile grid orientation read off the mapping: U follows world %s%s, V follows world %s%s. " ) ,
Orientation . bUFollowsX ? TEXT ( " X " ) : TEXT ( " Y " ) , Orientation . bMirrorU ? TEXT ( " backwards " ) : TEXT ( " " ) ,
Orientation . bVFollowsY ? TEXT ( " Y " ) : TEXT ( " X " ) , Orientation . bMirrorV ? TEXT ( " backwards " ) : TEXT ( " " ) ) ;
return true ;
}
FIntPoint UWorldMapBuilder : : GetTileIndices ( int32 CellX , int32 CellY ) const
{
const int32 OffsetX = CellX - static_cast < int32 > ( GridOriginX ) ;
const int32 OffsetY = CellY - static_cast < int32 > ( GridOriginY ) ;
return FIntPoint (
Orientation . bUFollowsX ? OffsetX : OffsetY ,
Orientation . bVFollowsY ? OffsetY : OffsetX ) ;
}
FIntPoint UWorldMapBuilder : : GetTilePixelOrigin ( const FIntPoint & TileIndices ) const
{
// A mirrored picture puts the tile at the far end of the map and counts back, because that is what a flip
// does to the mapping the tile positions are read from.
const int32 IndexU = Orientation . bMirrorU ? ( TilesPerAxis - 1 - TileIndices . X ) : TileIndices . X ;
const int32 IndexV = Orientation . bMirrorV ? ( TilesPerAxis - 1 - TileIndices . Y ) : TileIndices . Y ;
return FIntPoint ( IndexU * TilePixels , IndexV * TilePixels ) ;
}
bool UWorldMapBuilder : : PreRun ( UWorld * World , FPackageSourceControlHelper & PackageHelper )
{
RunStartTime = FPlatformTime : : Seconds ( ) ;
WalkedTileCount = 0 ;
// The whole command line, so a bake log carries what the run was actually asked to do rather than only what
// came of it.
UE_LOG ( LogWorldMapBuilder , Display , TEXT ( " Builder arguments: %s " ) , * GetBuilderArgs ( ) ) ;
FText Reason ;
if ( ! ReadSettings ( World , Reason ) )
{
UE_LOG ( LogWorldMapBuilder , Error , TEXT ( " The world map settings cannot be baked: %s " ) , * Reason . ToString ( ) ) ;
return false ;
}
// The framework reports the walk it is about to do, which is the cheapest possible confirmation that the
// grid this builder asked for is the grid it is going to be handed.
UE_LOG ( LogWorldMapBuilder , Display ,
TEXT ( " Walking the map's own bounds as whole tiles, which is %d x %d iterations of one capture. " ) ,
TilesPerAxis , TilesPerAxis ) ;
// A capture with the lighting stripped out is what a map is made of, and the capture this bake goes
// through cannot leave it in: it is the engine's own world partition minimap capture, which turns
// lighting, the atmosphere and the post process off before it renders. An actor set to keep them cannot be
// honoured here, so it is said out loud rather than quietly ignored.
if ( ! SettingsActor - > bUnlitCapture )
{
UE_LOG ( LogWorldMapBuilder , Warning ,
TEXT ( " bUnlitCapture is off on the settings actor, but a baked tile is always captured unlit: that setting applies to the actor's own interactive capture, not to this bake. " ) ) ;
}
// The same goes for the capture source, and it is the more dangerous of the two to get wrong: the post
// process is off in a bake, so a source that reads the post processed picture reads nothing at all and every
// tile comes back clear. See BakeCaptureSource.
if ( SettingsActor - > CaptureSourceOverride ! = BakeCaptureSource )
{
UE_LOG ( LogWorldMapBuilder , Warning ,
TEXT ( " CaptureSourceOverride is set to %s on the settings actor, but a baked tile is always captured as the base colour, which is what the engine's own minimap capture asks for. That setting applies to the actor's own interactive capture. " ) ,
* StaticEnum < ESceneCaptureSource > ( ) - > GetNameStringByValue ( SettingsActor - > CaptureSourceOverride ) ) ;
}
// Where the map goes is settled before the first tile is rendered rather than after the last one. A run
// that cannot open its own output has no business rendering four hundred tiles first, and the texture's one
// mip is what each tile is stitched into as it arrives.
FText DestinationReason ;
if ( ! OpenDestinationTexture ( World , DestinationReason ) )
{
// The overrides belong to a run that got as far as using them, and this one did not.
RestoreOverriddenSettings ( ) ;
UE_LOG ( LogWorldMapBuilder , Error , TEXT ( " The world map cannot be baked: %s " ) , * DestinationReason . ToString ( ) ) ;
return false ;
}
// Dumping the tiles is a switch of the run rather than a setting on the actor, because it is for looking at
// a bake while it is being worked on, not something a project configures and keeps.
bDebugCapture = HasParam ( TEXT ( " DebugCapture " ) ) ;
return true ;
}
bool UWorldMapBuilder : : ShouldSkipCell ( const FWorldBuilderCellCoord & CellCoord ) const
{
// The iteration bounds are the map's own bounds and the cell size is a tile, so the cells that arrive here
// are the tiles already. This is the belt to that pair of braces: a cell outside the grid would be one
// region captured and then written into another region's square of the map.
const int64 OffsetX = CellCoord . X - GridOriginX ;
const int64 OffsetY = CellCoord . Y - GridOriginY ;
return OffsetX < 0 | | OffsetY < 0 | | OffsetX > = TilesPerAxis | | OffsetY > = TilesPerAxis ;
}
bool UWorldMapBuilder : : CheckTileCoverage ( const FBox & TileBounds , const FIntPoint & PixelOrigin ) const
{
if ( ! SettingsActor )
{
return false ;
}
const FVector Min = TileBounds . Min ;
const FVector Max = TileBounds . Max ;
const FVector Corners [ ] =
{
FVector ( Min . X , Min . Y , Min . Z ) ,
FVector ( Max . X , Min . Y , Min . Z ) ,
FVector ( Min . X , Max . Y , Min . Z ) ,
FVector ( Max . X , Max . Y , Min . Z ) ,
} ;
// One pixel of slack: a corner projects to the very edge of its own tile, and no arithmetic promises to
// land on the same side of that edge twice.
constexpr double Tolerance = 1.0 ;
for ( const FVector & Corner : Corners )
{
const FVector2D MapUV = SettingsActor - > WorldToBakedMapUV ( Corner ) ;
const FVector2D MapPixel ( MapUV . X * MapTextureSize , MapUV . Y * MapTextureSize ) ;
const bool bInsideOwnTile =
MapPixel . X > = PixelOrigin . X - Tolerance & & MapPixel . X < = PixelOrigin . X + TilePixels + Tolerance & &
MapPixel . Y > = PixelOrigin . Y - Tolerance & & MapPixel . Y < = PixelOrigin . Y + TilePixels + Tolerance ;
if ( ! bInsideOwnTile )
{
UE_LOG ( LogWorldMapBuilder , Error ,
TEXT ( " Tile coverage check failed. World position %s, a corner of the tile the framework loaded, projects to map pixel (%.1f, %.1f), which is outside this tile's own square (%d, %d) to (%d, %d). The tile grid and the world map mapping disagree, so this bake would write tiles into the wrong squares of the map. " ) ,
* Corner . ToCompactString ( ) , MapPixel . X , MapPixel . Y ,
PixelOrigin . X , PixelOrigin . Y , PixelOrigin . X + TilePixels , PixelOrigin . Y + TilePixels ) ;
return false ;
}
UE_LOG ( LogWorldMapBuilder , Verbose , TEXT ( " corner %s lands on map pixel (%.1f, %.1f), inside its own tile. " ) ,
* Corner . ToCompactString ( ) , MapPixel . X , MapPixel . Y ) ;
}
return true ;
}
bool UWorldMapBuilder : : RunInternal ( UWorld * World , const FCellInfo & InCellInfo , FPackageSourceControlHelper & PackageHelper )
{
if ( ! SettingsActor )
{
UE_LOG ( LogWorldMapBuilder , Error , TEXT ( " The settings actor went away during the bake, so there is nothing left to bake from. " ) ) ;
return false ;
}
const int32 CellX = static_cast < int32 > ( InCellInfo . Location . X ) ;
const int32 CellY = static_cast < int32 > ( InCellInfo . Location . Y ) ;
const FIntPoint TileIndices = GetTileIndices ( CellX , CellY ) ;
const FIntPoint PixelOrigin = GetTilePixelOrigin ( TileIndices ) ;
+ + WalkedTileCount ;
UE_LOG ( LogWorldMapBuilder , Display ,
TEXT ( " [%d / %d] cell (%d, %d) is tile (%d, %d): world %s, map pixels (%d, %d) to (%d, %d). " ) ,
WalkedTileCount , TilesPerAxis * TilesPerAxis , CellX , CellY , TileIndices . X , TileIndices . Y ,
* InCellInfo . Bounds . ToString ( ) ,
PixelOrigin . X , PixelOrigin . Y , PixelOrigin . X + TilePixels , PixelOrigin . Y + TilePixels ) ;
// The one thing the log could not otherwise show and that would ruin a whole bake: that the region the
// framework loaded and the square of the map this tile is going into are the same place. Checked before
// anything is rendered and before anything is written into the map.
if ( ! CheckTileCoverage ( InCellInfo . Bounds , PixelOrigin ) )
{
return false ;
}
// The region that is actually in memory is the one that gets captured, because that is the only region the
// renderer can draw: a rectangle of the grid the framework had not loaded would come back with empty edges.
// The grid says the two are the same rectangle, which is what the check above has just been through all four
// corners of, but a disagreement found after the fact is worth hearing about rather than being baked into a
// map that is quietly out of alignment.
const FBox TileWorldBounds = GetTileWorldBounds ( CellX , CellY ) ;
if ( ! TileWorldBounds . Equals ( InCellInfo . Bounds , 1.0 ) )
{
UE_LOG ( LogWorldMapBuilder , Warning ,
TEXT ( " [%d / %d] cell (%d, %d) was loaded as %s but sits at %s in the grid. The capture follows what was loaded, because that is the region that is streamed in. " ) ,
WalkedTileCount , TilesPerAxis * TilesPerAxis , CellX , CellY ,
* InCellInfo . Bounds . ToString ( ) , * TileWorldBounds . ToString ( ) ) ;
}
// Made on the first tile of the run and reused by every tile after it, which is why this is a lookup rather
// than a local: the builder holds the one scratch texture the captures render into.
if ( ! GetTileTexture ( ) )
{
UE_LOG ( LogWorldMapBuilder , Error , TEXT ( " [%d / %d] there is no tile texture to capture into, so the bake cannot go on. " ) ,
WalkedTileCount , TilesPerAxis * TilesPerAxis ) ;
return false ;
}
const double CaptureStartTime = FPlatformTime : : Seconds ( ) ;
// The engine's own minimap capture, aimed by a projection matrix over exactly this tile rather than by an
// actor: straight down, with the lighting, the atmosphere and the post process off, and a few frames of
// ticking before the frame that counts so that whatever the cell is still streaming has a chance to arrive.
// Handing it a texture rather than nothing is what makes it read the pixels back into that texture's source
// instead of building one of its own, which is how the tile arrives on the CPU to be stitched.
FWorldPartitionMiniMapHelper : : CaptureBoundsMiniMapToTexture (
World ,
GetTransientPackage ( ) ,
static_cast < uint32 > ( TilePixels ) ,
static_cast < uint32 > ( TilePixels ) ,
TileTexture ,
TileTexture - > GetName ( ) ,
InCellInfo . Bounds ,
BakeCaptureSource ,
static_cast < uint32 > ( FMath : : Max ( 0 , SettingsActor - > WorldMapWarmupFrames ) ) ,
[ ] ( ) { } ) ;
const double CaptureSeconds = FPlatformTime : : Seconds ( ) - CaptureStartTime ;
if ( ! TileTexture - > Source . IsValid ( ) | | TileTexture - > Source . GetSizeX ( ) ! = TilePixels | | TileTexture - > Source . GetSizeY ( ) ! = TilePixels )
{
UE_LOG ( LogWorldMapBuilder , Error ,
TEXT ( " [%d / %d] tile (%d, %d) came back without a %d x %d picture in it, so there is nothing to stitch. A capture that leaves no image is what a renderer that was never started looks like. " ) ,
WalkedTileCount , TilesPerAxis * TilesPerAxis , CellX , CellY , TilePixels , TilePixels ) ;
return false ;
}
// A tile with nothing in it is allowed: the map is of a world, and part of a world is empty. It is worth one
// line, though, because a whole bake of tiles like this is a setting rather than a landscape, and this is
// the line that says which of the two it was.
const int32 VisiblePixels = CountVisiblePixels ( TileTexture ) ;
if ( VisiblePixels = = 0 )
{
+ + EmptyTileCount ;
UE_LOG ( LogWorldMapBuilder , Warning ,
TEXT ( " [%d / %d] tile (%d, %d) captured nothing visible over %s. Every pixel of it is clear, which is the region rather than the world: either nothing stands in that square, or the capture is looking at it from inside it. " ) ,
WalkedTileCount , TilesPerAxis * TilesPerAxis , CellX , CellY , * InCellInfo . Bounds . ToString ( ) ) ;
}
else
{
UE_LOG ( LogWorldMapBuilder , Verbose , TEXT ( " %d of %d pixels of that tile have something in them. " ) ,
VisiblePixels , TilePixels * TilePixels ) ;
}
if ( bDebugCapture )
{
// Named after the asset this run bakes rather than after the map alone, and after the tile's own place
// in the grid rather than after the scratch texture: the scratch texture is one object with one name
// for the whole walk, so sixty four tiles under its name would be one file overwritten sixty four
// times, and the tile index starts again at (0, 0) in every piece, so sixteen pieces under the map's
// name would be the same sixteen names sixteen times over. A bitmap of one tile is what says whether a
// seam between two pieces is a seam in the world or one the bake made, which it cannot say if the two
// tiles on either side of it were written to the same file.
SaveDebugImage (
FString : : Printf ( TEXT ( " %s-Tile_%d_%d.bmp " ) , * DestinationAssetName , TileIndices . X , TileIndices . Y ) ,
TileTexture - > Source . LockMipReadOnly ( 0 ) , TilePixels , TilePixels , /*bAsPng=*/ false ) ;
TileTexture - > Source . UnlockMip ( 0 ) ;
}
if ( ! StitchTile ( TileTexture , PixelOrigin ) )
{
return false ;
}
UE_LOG ( LogWorldMapBuilder , Display ,
TEXT ( " [%d / %d] tile (%d, %d) captured in %.2f seconds and stitched into map pixels (%d, %d) to (%d, %d). " ) ,
WalkedTileCount , TilesPerAxis * TilesPerAxis , CellX , CellY , CaptureSeconds ,
PixelOrigin . X , PixelOrigin . Y , PixelOrigin . X + TilePixels , PixelOrigin . Y + TilePixels ) ;
return true ;
}
UTexture2D * UWorldMapBuilder : : GetTileTexture ( )
{
if ( TileTexture )
{
return TileTexture ;
}
// Found before it is made, so that a second bake in the same process takes the same texture back rather
// than replacing the one the first run left behind.
TileTexture = FindObject < UTexture2D > ( GetTransientPackage ( ) , TEXT ( " WorldMapTile " ) ) ;
if ( ! TileTexture )
{
TileTexture = NewObject < UTexture2D > ( GetTransientPackage ( ) , FName ( TEXT ( " WorldMapTile " ) ) , RF_Transient ) ;
}
if ( ! TileTexture )
{
return nullptr ;
}
// Declared as BGRA8 before the first capture rather than after it: that is the format the helper's own
// readback arrives in and the one it checks for, so a source that already has it is one allocation instead
// of a conversion, and it is the format the tile is copied out of later on.
TileTexture - > Source . Init ( TilePixels , TilePixels , 1 , 1 , TSF_BGRA8 ) ;
// The same padding the finished map carries, since a tile is a piece of it: a tile of a map whose size is a
// power of two is a power of two already, and setting it here is what keeps the two agreeing about that
// whatever the settings are.
TileTexture - > PowerOfTwoMode = ETexturePowerOfTwoSetting : : PadToPowerOfTwo ;
return TileTexture ;
}
FBox UWorldMapBuilder : : GetTileWorldBounds ( int64 CellX , int64 CellY ) const
{
const int64 OffsetX = CellX - GridOriginX ;
const int64 OffsetY = CellY - GridOriginY ;
const FVector Min = MapBounds . Min + FVector (
static_cast < double > ( OffsetX ) * TileWorldSize ,
static_cast < double > ( OffsetY ) * TileWorldSize ,
0.0 ) ;
// The map's own Z range comes with it, because Z is not part of the grid: it only sets the height the
// capture looks down from, and taking it from the region is what puts the camera above everything the
// region holds.
return FBox ( Min , Min + FVector ( TileWorldSize , TileWorldSize , MapBounds . GetSize ( ) . Z ) ) ;
}
bool UWorldMapBuilder : : OpenDestinationTexture ( UWorld * World , FText & OutReason )
{
// Named after the map it was baked from, so that re-baking a map replaces its own map and nothing else, and
// two maps in one project never write over each other's picture.
MapName = World - > GetName ( ) ;
DestinationAssetName = FString : : Printf ( TEXT ( " WorldMap_%s " ) , * MapName ) ;
// A piece goes beside the whole map rather than over it: the same folder and the same map name, with the
// grid and the piece's own place in it on the end. Re-baking one piece then replaces that one piece, and the
// seventeen assets of a full bake can be told apart by name alone, without opening any of them.
if ( bPieceRun )
{
DestinationAssetName + = FString : : Printf (
TEXT ( " _Piece%dx%d_%d_%d " ) , PieceGrid , PieceGrid , PieceColumn , PieceRow ) ;
}
DestinationPackageName = SettingsActor - > OutputPackageFolder / DestinationAssetName ;
DestinationPackageFileName = FPackageName : : LongPackageNameToFilename (
DestinationPackageName , FPackageName : : GetAssetPackageExtension ( ) ) ;
// Loaded when a previous bake left one there rather than made fresh: the asset keeps its identity and its
// registry entry, and a re-bake reads as replacing a picture rather than as a second asset with a number on
// the end of its name. A package that cannot be loaded is one that does not exist yet, which is the first
// bake of a map and is not an error.
DestinationPackage = LoadPackage ( nullptr , * DestinationPackageName , LOAD_None ) ;
if ( ! DestinationPackage )
{
DestinationPackage = CreatePackage ( * DestinationPackageName ) ;
}
if ( ! DestinationPackage )
{
OutReason = FText : : Format (
NSLOCTEXT ( " EleriWorldMapBuilder " , " NoDestinationPackage " ,
" The package {0} could neither be loaded nor created, so there is nowhere to put the baked map. " ) ,
FText : : AsCultureInvariant ( DestinationPackageName ) ) ;
return false ;
}
DestinationTexture = FindObject < UTexture2D > ( DestinationPackage , * DestinationAssetName ) ;
if ( ! DestinationTexture )
{
DestinationTexture = NewObject < UTexture2D > (
DestinationPackage , FName ( * DestinationAssetName ) , RF_Public | RF_Standalone | RF_TextExportTransient ) ;
}
if ( ! DestinationTexture )
{
OutReason = FText : : Format (
NSLOCTEXT ( " EleriWorldMapBuilder " , " NoDestinationTexture " ,
" The texture {0} could not be made in {1}. " ) ,
FText : : AsCultureInvariant ( DestinationAssetName ) ,
FText : : AsCultureInvariant ( DestinationPackageName ) ) ;
return false ;
}
// The edit is announced before it is made, because a bake is an edit of an asset like any other and anything
// holding a reference to it is holding one to a texture that is being rewritten. The engine's own minimap
// does the same before it resizes its texture.
DestinationTexture - > PreEditChange ( nullptr ) ;
DestinationTexture - > Source . Init ( MapTextureSize , MapTextureSize , 1 , 1 , TSF_BGRA8 ) ;
DestinationTexture - > MipGenSettings = TMGS_SimpleAverage ;
// One lock for the whole walk rather than one per tile. Locking a mip is a reference count rather than a
// copy, so holding it costs nothing and leaves the address of the pixels stable for the length of the bake,
// which is what the tiles are stitched into as they arrive.
DestinationPixels = DestinationTexture - > Source . LockMip ( 0 ) ;
if ( ! DestinationPixels )
{
OutReason = FText : : Format (
NSLOCTEXT ( " EleriWorldMapBuilder " , " NoDestinationPixels " ,
" The {0} x {0} pixel mip of {1} could not be locked, so the tiles would have nowhere to be stitched. " ) ,
FText : : AsNumber ( MapTextureSize ) ,
FText : : AsCultureInvariant ( DestinationAssetName ) ) ;
return false ;
}
// Cleared once, at the start, so that what the map holds before the first tile arrives is a known thing
// rather than whatever was in that memory. Transparent black is also what each capture clears its own target
// to, so a square that never got a tile looks like a square with nothing in it rather than like a hole in
// the bake.
FMemory : : Memset ( DestinationPixels , 0 , DestinationTexture - > Source . CalcMipSize ( 0 ) ) ;
UE_LOG ( LogWorldMapBuilder , Display , TEXT ( " Baking a %d x %d world map into %s, to be saved as %s. " ) ,
MapTextureSize , MapTextureSize , * DestinationPackageName , * DestinationPackageFileName ) ;
return true ;
}
int32 UWorldMapBuilder : : CountVisiblePixels ( UTexture2D * TileTexture )
{
if ( ! TileTexture )
{
return 0 ;
}
const int64 PixelCount = TileTexture - > Source . GetSizeX ( ) * TileTexture - > Source . GetSizeY ( ) ;
// Anything but the format the capture helper produces cannot be counted this way, and a tile that cannot be
// counted is reported as full rather than as empty: a warning about an empty tile is worth having only when
// it is true.
if ( ! TileTexture - > Source . IsValid ( ) | | TileTexture - > Source . GetFormat ( ) ! = TSF_BGRA8 )
{
return static_cast < int32 > ( PixelCount ) ;
}
const uint8 * SourcePixels = TileTexture - > Source . LockMipReadOnly ( 0 ) ;
if ( ! SourcePixels )
{
return static_cast < int32 > ( PixelCount ) ;
}
// A pixel counts as something when any of its channels is set, and not only its alpha. A capture clears its
// target to transparent, so alpha is the channel that answers the question for a capture of the finished
// picture; a capture of the base colour, which is the one a bake uses, writes the colour of whatever it drew
// and leaves alpha where the clear put it. Both are read here, so this answers "did anything happen in this
// square" whichever source the tile was captured through.
int32 VisiblePixels = 0 ;
for ( int64 PixelIndex = 0 ; PixelIndex < PixelCount ; + + PixelIndex )
{
const uint8 * Pixel = SourcePixels + ( PixelIndex * 4 ) ;
if ( Pixel [ 0 ] ! = 0 | | Pixel [ 1 ] ! = 0 | | Pixel [ 2 ] ! = 0 | | Pixel [ 3 ] ! = 0 )
{
+ + VisiblePixels ;
}
}
TileTexture - > Source . UnlockMip ( 0 ) ;
return VisiblePixels ;
}
bool UWorldMapBuilder : : StitchTile ( UTexture2D * InTileTexture , const FIntPoint & PixelOrigin )
{
if ( ! InTileTexture | | ! DestinationTexture | | ! DestinationPixels )
{
UE_LOG ( LogWorldMapBuilder , Error ,
TEXT ( " A captured tile cannot be stitched: the bake has no destination texture, or none of its pixels are locked. " ) ) ;
return false ;
}
// The gate for the whole copy. A tile that does not sit wholly inside the map would write past the end of
// the pixel buffer, which is a gigabyte of memory with the rest of the process behind it.
if ( PixelOrigin . X < 0 | | PixelOrigin . Y < 0
| | PixelOrigin . X + TilePixels > MapTextureSize | | PixelOrigin . Y + TilePixels > MapTextureSize )
{
UE_LOG ( LogWorldMapBuilder , Error ,
TEXT ( " A tile at map pixels (%d, %d) of %d would run past the edge of the %d x %d map, so the tile grid and the map do not describe the same square. " ) ,
PixelOrigin . X , PixelOrigin . Y , TilePixels , MapTextureSize , MapTextureSize ) ;
return false ;
}
const int64 BytesPerPixel = InTileTexture - > Source . GetBytesPerPixel ( ) ;
const int64 SourceStrideBytes = static_cast < int64 > ( TilePixels ) * BytesPerPixel ;
const int64 DestinationStrideBytes = static_cast < int64 > ( MapTextureSize ) * BytesPerPixel ;
const int64 DestinationSizeBytes = DestinationTexture - > Source . CalcMipSize ( 0 ) ;
// Where the tile's own top left pixel sits in the map, which is the one offset the copy is addressed from.
// Rows are counted from the top in both, which is why this is a multiply and an add rather than anything
// cleverer.
const int64 TileOriginOffset =
( static_cast < int64 > ( PixelOrigin . Y ) * DestinationStrideBytes ) + ( static_cast < int64 > ( PixelOrigin . X ) * BytesPerPixel ) ;
const uint8 * SourcePixels = InTileTexture - > Source . LockMipReadOnly ( 0 ) ;
check ( SourcePixels ) ;
for ( int32 RowIndex = 0 ; RowIndex < TilePixels ; + + RowIndex )
{
// A mirrored map is mirrored inside its tiles as well as in the layout of them: GetTilePixelOrigin has
// already sent this tile to the far end of the map, and the pixels within it are copied back to front to
// match. Both flips are off for a map that reads the way the world does, which is the usual bake.
const int32 SourceRowIndex = Orientation . bMirrorV ? ( TilePixels - 1 - RowIndex ) : RowIndex ;
const int64 DestinationOffset = TileOriginOffset + ( static_cast < int64 > ( RowIndex ) * DestinationStrideBytes ) ;
check ( DestinationOffset > = 0 ) ;
check ( DestinationOffset + SourceStrideBytes < = DestinationSizeBytes ) ;
const uint8 * SourceRowPixels = SourcePixels + ( static_cast < int64 > ( SourceRowIndex ) * SourceStrideBytes ) ;
if ( ! Orientation . bMirrorU )
{
FMemory : : Memcpy ( DestinationPixels + DestinationOffset , SourceRowPixels , SourceStrideBytes ) ;
continue ;
}
for ( int32 ColumnIndex = 0 ; ColumnIndex < TilePixels ; + + ColumnIndex )
{
const int32 SourceColumnIndex = TilePixels - 1 - ColumnIndex ;
FMemory : : Memcpy (
DestinationPixels + DestinationOffset + ( static_cast < int64 > ( ColumnIndex ) * BytesPerPixel ) ,
SourceRowPixels + ( static_cast < int64 > ( SourceColumnIndex ) * BytesPerPixel ) ,
BytesPerPixel ) ;
}
}
InTileTexture - > Source . UnlockMip ( 0 ) ;
return true ;
}
void UWorldMapBuilder : : SaveDebugImage ( const FString & InFileName , const uint8 * Pixels , int32 Width , int32 Height , bool bAsPng ) const
{
if ( ! Pixels | | Width < = 0 | | Height < = 0 )
{
return ;
}
// Intermediate rather than Saved: these are for looking at while a bake is being worked on, and they are
// not something a project keeps or checks in. The directory is made here because a bake asked to dump its
// tiles is usually the first thing to want it there.
const FString DirectoryPath = FPaths : : ConvertRelativePathToFull ( FPaths : : ProjectIntermediateDir ( ) / TEXT ( " WorldMap " ) ) ;
IFileManager : : Get ( ) . MakeDirectory ( * DirectoryPath , /*Tree=*/ true ) ;
const FString FullPath = DirectoryPath / InFileName ;
// A tile is written as a bitmap, which is what the engine's own minimap dump is, and the whole stitched map
// as a PNG: sixteen thousand pixels square is a gigabyte of bitmap and a few tens of megabytes of PNG, and
// a gigabyte of debug file is a problem of its own.
if ( bAsPng )
{
// Copied with the alpha forced opaque before it is written. What the dump is for is looking at the map,
// and a base colour capture leaves the alpha of everything it drew at the clear value: written out as it
// stands, the whole picture is invisible against the light background it gets looked at on.
const int64 PixelCount = static_cast < int64 > ( Width ) * Height ;
const FColor * SourceColors = reinterpret_cast < const FColor * > ( Pixels ) ;
TArray64 < FColor > OpaqueImage ;
OpaqueImage . SetNumUninitialized ( PixelCount ) ;
for ( int64 PixelIndex = 0 ; PixelIndex < PixelCount ; + + PixelIndex )
{
FColor Color = SourceColors [ PixelIndex ] ;
Color . A = 255 ;
OpaqueImage [ PixelIndex ] = Color ;
}
TArray64 < uint8 > Compressed ;
FImageUtils : : PNGCompressImageArray ( Width , Height , TArrayView64 < const FColor > ( OpaqueImage . GetData ( ) , PixelCount ) , Compressed ) ;
UE_CLOG ( ! FFileHelper : : SaveArrayToFile ( Compressed , * FullPath ) , LogWorldMapBuilder , Warning ,
TEXT ( " The stitched map could not be written to %s for looking at. The bake itself is unaffected. " ) , * FullPath ) ;
return ;
}
UE_CLOG ( ! FFileHelper : : CreateBitmap ( * FullPath , Width , Height , reinterpret_cast < const FColor * > ( Pixels ) ) , LogWorldMapBuilder , Warning ,
TEXT ( " A captured tile could not be written to %s for looking at. The bake itself is unaffected. " ) , * FullPath ) ;
}
bool UWorldMapBuilder : : SaveBakedMap ( FPackageSourceControlHelper & PackageHelper )
{
if ( ! DestinationPackage | | ! DestinationTexture )
{
UE_LOG ( LogWorldMapBuilder , Error , TEXT ( " There is no baked map to save, which means the bake never opened one. " ) ) ;
return false ;
}
// Taken before the texture is finished and rebuilt, because it is the pixels that arrived from the captures
// that are worth looking at rather than the compressed copy the renderer makes of them. It is also the last
// moment the source is still in the format the captures wrote.
if ( bDebugCapture )
{
SaveDebugImage (
FString : : Printf ( TEXT ( " %s-%s.png " ) , * MapName , * DestinationAssetName ) ,
DestinationTexture - > Source . LockMipReadOnly ( 0 ) , MapTextureSize , MapTextureSize , /*bAsPng=*/ true ) ;
DestinationTexture - > Source . UnlockMip ( 0 ) ;
}
// The finishing a hand made world map texture would need, and the same the engine's own minimap gives its
// own. Virtual texturing is what lets the renderer sample a map this size at all, padding to a power of two
// is what virtual texturing asks of a texture, the minimum alpha is what stops the parts of the map nothing
// stood on from being punched out of the middle of the picture once it is compressed, and the UI group is
// the one that does not blur a picture that is meant to be read.
DestinationTexture - > PowerOfTwoMode = ETexturePowerOfTwoSetting : : PadToPowerOfTwo ;
DestinationTexture - > AdjustMinAlpha = 1.f ;
DestinationTexture - > LODGroup = TEXTUREGROUP_UI ;
DestinationTexture - > VirtualTextureStreaming = true ;
DestinationTexture - > PostEditChange ( ) ;
// A texture this size is compressed when it is edited, and saving it while that is still running writes the
// uncompressed source and the compressed copy over the top of each other.
FAssetCompilingManager : : Get ( ) . FinishAllCompilation ( ) ;
// Saved through the framework rather than by hand: that is what tells a package that already exists to be
// checked out and a new one to be added to revision control, and what resets the loaders a save needs.
TArray < UPackage * > PackagesToSave ;
PackagesToSave . Add ( DestinationPackage ) ;
if ( ! SavePackages ( PackagesToSave , PackageHelper ) )
{
UE_LOG ( LogWorldMapBuilder , Error , TEXT ( " The baked map could not be saved to %s. " ) , * DestinationPackageFileName ) ;
return false ;
}
// The file the save has just written goes to the changelist the framework is tracking, which is the same
// path whether or not there is a revision control provider behind it.
return OnFilesModified ( { DestinationPackageFileName } , FString : : Printf ( TEXT ( " Baked world map for %s " ) , * MapName ) ) ;
}
bool UWorldMapBuilder : : PostRun ( UWorld * World , FPackageSourceControlHelper & PackageHelper , const bool bInRunSuccess )
{
// First, before anything can be reported about this run: an override was about this run only.
RestoreOverriddenSettings ( ) ;
const double ElapsedSeconds = FPlatformTime : : Seconds ( ) - RunStartTime ;
const int32 TotalTiles = TilesPerAxis * TilesPerAxis ;
// The map's own mip was locked for the whole walk, so it is let go here before anything below reads those
// pixels back or the texture is edited again. A run that stopped early leaves a texture nobody is going to
// save, but the lock is still the builder's to give back, and a lock left open would leave the texture
// unwritable for whoever opened it next.
if ( DestinationTexture & & DestinationPixels )
{
DestinationTexture - > Source . UnlockMip ( 0 ) ;
DestinationPixels = nullptr ;
}
if ( ! bInRunSuccess )
{
UE_LOG ( LogWorldMapBuilder , Error ,
TEXT ( " The world map bake stopped after %d of %d tiles, %.1f seconds in. Nothing has been saved: the baked map is left as it was rather than written half stitched. " ) ,
WalkedTileCount , TotalTiles , ElapsedSeconds ) ;
return false ;
}
UE_LOG ( LogWorldMapBuilder , Display , TEXT ( " World map bake walked %d of %d tiles in %.1f seconds. " ) ,
WalkedTileCount , TotalTiles , ElapsedSeconds ) ;
if ( EmptyTileCount > 0 )
{
UE_LOG ( LogWorldMapBuilder , Warning ,
TEXT ( " %d of those %d tiles came back with nothing visible in them. That is a legitimate map of an empty world, and it is also what a bake of a region that does not cover the world looks like. " ) ,
EmptyTileCount , TotalTiles ) ;
}
if ( ! SaveBakedMap ( PackageHelper ) )
{
return false ;
}
UE_LOG ( LogWorldMapBuilder , Display , TEXT ( " The baked world map is %s, saved to %s. " ) ,
* DestinationPackageName , * DestinationPackageFileName ) ;
return true ;
}