diff --git a/BuildScripts/BakeWorldMap.bat b/BuildScripts/BakeWorldMap.bat new file mode 100644 index 00000000..b06d617a --- /dev/null +++ b/BuildScripts/BakeWorldMap.bat @@ -0,0 +1,248 @@ +@ECHO OFF + +REM Bakes the world map of the world partition map named at the end of each command below: the whole region as +REM one asset, and the same region cut into a grid of pieces as one asset each. Seventeen textures come out of a +REM full run, and the map UI in the game switches between them as it zooms: the whole region while the player is +REM looking at all of it, and the piece they are looking at once they are close enough for the whole map's pixel +REM to be a blur. +REM +REM The whole map is one texture over the whole region: on this map that is twelve thousand metres at 16384 +REM pixels, which is 0.73 metres of world per pixel of picture, a map to open and see where one is. The pieces +REM are the same region cut into a 4 x 4 grid, so three thousand metres a side, and each piece is baked at 8192 +REM pixels, which is 0.37 metres of world per pixel: half the world per pixel of the whole map, which is the +REM detail the zoomed in view is for. Both layers capture 2048 pixels of their own per tile, so a tile +REM of a piece costs the same to render as a tile of the whole map and the difference between the layers is only +REM how much world stands behind those pixels. +REM +REM The two layers are not a level of detail of each other, they are seventeen textures and the UI picks one, so +REM they have to agree about where the world is to the pixel: they are baked from the same region by the same +REM builder, and the piece a run bakes is the only thing about that the command lines below change. Nothing in +REM the game reads one of the seventeen; they are pictures, and where the world is inside one of them is a +REM question the settings actor answers separately. +REM +REM What the map covers is the WorldMapBounds of the OrthoMapCaptureActor in the map, which is the intended place +REM to set it: open the map, select the actor, type the bounds in and save the map. A commandlet reads the map +REM from disk, so a region that was never saved is not in what it reads. +REM +REM On this map the island stands on the origin and measures about 375 by 390 metres, centred within about 45 +REM metres of it, so the region is the twelve kilometre square Min=(-600000,-600000,-100000) +REM Max=(600000,600000,100000). The island is then about a thirtieth of the width of the picture and most of the +REM rest is sea: ten of the whole map's sixty four tiles have anything in them, which is the island and what +REM stands around it near the origin and a second group of tiles away to the west of it. What every run prints +REM about its empty tiles is how that is checked. Z is not part of what the map covers, it is the height the +REM captures look down from, and the Z in the actor's own box is where a run that passes no region gets one from. +REM +REM WorldMapBounds is also what the game reads when it turns a position on the map back into a world position, so +REM the actor's numbers are the numbers this bake used, and the two cannot be allowed to drift apart. All six of +REM them have to be on the actor before the run below, which passes no region of its own. +REM +REM Four flags exist for running without the editor. All apply to this run only and all override the actor: +REM +REM -WorldMapBounds=minX,minY,maxX,maxY states the region outright, keeping the actor's Z. It wins over the +REM actor's own bounds, so it is the way to try a region out before +REM committing it to the map. The whole map run below does not pass it: +REM what gets baked is what the actor says. +REM -FitBounds takes the region from the partition instead. Do not use it on this +REM map. A world partition reports the bounds of its own sparse octree +REM grid rather than the bounds of its content, and an actor in this +REM map has a world sized bound of its own, which pushes the octree up +REM to a level 30 cell either side of the origin. It answers with a box +REM of +/-13743895347200 units, and a tile of that at eight tiles is +REM 3435973836800 units across, far more than a cell can hold, so +REM IsWorldMapGridValid refuses the bake and says so. The button of the +REM same name on the actor's details panel has the same problem, so set +REM the region by hand. +REM -WorldMapPiece=column,row bakes one piece of the region instead of the whole of it. The +REM column counts along +X from the region's Min end and the row counts +REM along +Y from the same corner, so (0, 0) is the far corner of the +REM map and the last piece is at the near one. +REM -WorldMapPieceGrid=n says how many pieces a side that index is being read in: four for +REM the 4 x 4 grid this script bakes. It has to be given with +REM -WorldMapPiece, because the same index in another grid of the same +REM region is a different square of the world, and it is only warned +REM about when it arrives without one. +REM +REM A piece run refuses -WorldMapBounds and -FitBounds rather than combining them with a piece, because the two +REM say different things about the same region: one states the region the pieces are cut out of, and the other +REM states the piece itself. A region that has been stated can be cut into any grid at all, so there is no one +REM right answer to what a piece of it means. Set the region on the actor first and then ask for a piece of it. +REM A piece index outside the grid is refused too, and so is a piece of a region that is not a whole number of +REM pieces across, which is a piece whose edges are not where the game thinks they are. +REM +REM -MapTextureSize and -TilesPerAxis shrink the map for a trial run: 512 pixels in 2 x 2 tiles walks the whole +REM grid in four steps, and still covers the same region, because a region read off the actor is re-aligned to +REM whatever grid the run actually walks. Given with -WorldMapPiece the same two shrink one piece instead, and +REM that re-alignment is skipped: a piece's box is a window cut out of the region the pieces tile, so it is the +REM tiles that follow the piece and not the other way round, and aligning a window to its own tile grid would +REM quietly bake a region that is not one of the pieces. The whole map run below passes neither, so it bakes at +REM the resolution and tile count set on the actor, which for this map are 16384 in eight tiles; the piece runs +REM pass both, because 8192 in four tiles is what a piece is. +REM +REM The grid, and what the pieces are called. A piece goes beside the whole map in the same folder, named after it +REM with the grid and the piece's own place on the end, so this map's seventeen assets are +REM WorldMap_Persistent_GameWorld and WorldMap_Persistent_GameWorld_Piece4x4_0_0 through _Piece4x4_3_3. Column 0 +REM is the end of the region at its Min and row 0 is the same corner, which puts this map's grid like this: +REM +REM row 3: (0,3) (1,3) (2,3) (3,3) every piece 3000 x 3000 metres +REM row 2: (0,2) (1,2) (2,2) (3,2) the region Min=(-600000,-600000) Max=(600000,600000) +REM row 1: (0,1) (1,1) (2,1) (3,1) +X runs along a row, +Y up a column +REM row 0: (0,0) (1,0) (2,0) (3,0) the origin is the corner the four middle pieces share +REM +--------------------------+ +REM col 0 col 1 col 2 col 3 +REM +REM The island stands on the origin, so it falls across the four middle pieces (1,1) (2,1) (1,2) and (2,2), and +REM Min=(-300000,-300000) Max=(300000,300000) covers those four and no others. The other twelve pieces are open +REM sea: a bake of one of those that reports a tile with the island in it has the region wrong, and one of the +REM four that reports nothing at all has it wrong the other way. +REM +REM Where the baked map goes is set on the actor too, by OutputPackageFolder, and the asset in it is named after +REM the map it was baked from: this map bakes to /Game/Maps/Generated/WorldMap_Persistent_GameWorld, and baking +REM it again replaces that same asset rather than making a second one. A piece is named the same way with its +REM grid, column and row on the end, so the sixteen of them sit beside the whole map in the same folder and +REM re-baking one piece is a change to one asset. That folder and WorldMapWarmupFrames are read off the actor as +REM well and have to be set there, so a bake of a map whose actor has no output folder is refused before it +REM renders anything. +REM +REM -DebugCapture also writes every tile it captures to Intermediate\WorldMap as a bitmap, and the stitched map as +REM a PNG, and both are named after the asset the run is baking and after the tile's own place in the grid that +REM run walked: +REM +REM WorldMap_Persistent_GameWorld-Tile__.bmp the whole map, one file a tile +REM WorldMap_Persistent_GameWorld.png the whole map, stitched +REM WorldMap_Persistent_GameWorld_Piece4x4__-Tile__.bmp one piece, one file a tile +REM WorldMap_Persistent_GameWorld_Piece4x4__.png one piece, stitched +REM +REM Those two are the pair to look at when a bake comes out wrong: the tiles say whether each square captured +REM what it should have, and the stitched map says whether they were placed in the right squares. The asset name +REM in front of the tile index is what keeps the tiles of one piece from overwriting another's, which is in turn +REM what makes the strip of tiles along a shared edge worth comparing: a seam between two pieces that is in the +REM world reads as one line in each of them, and a seam the bake made reads as a difference. +REM +REM It is not on by default because it is a great deal of bitmap. A tile of this bake is 2048 pixels square at +REM four bytes a pixel, which is sixteen megabytes, so the sixty four of the whole map are most of a gigabyte and +REM the sixteen of one piece are a quarter of that. Add it to a command line below, or to the end of a manual +REM run, when there is something to look at, and to one piece at a time when it is a seam being looked at. +REM +REM A full run of this script is seventeen commandlet runs: sixty four tiles for the whole map and sixteen for +REM each of the sixteen pieces, which is three hundred and twenty tiles in all. On the machine this was written on +REM all seventeen took eight and a half minutes, first log line to last. Almost none of that is capture: the whole +REM map walks its sixty four tiles in seventeen seconds and a piece its sixteen in three to five, so the great +REM majority of every run is the editor starting, reading the map and building the texture. Building is where the +REM memory goes rather than the time: the whole map run peaks at eleven and a half gigabytes of physical memory, +REM against the twelve gigabytes its sixteen thousand pixel texture reports needing while it is built, and a piece +REM run peaks at five and a half against the three its eight thousand pixel one does. The runs are one after +REM another rather than at once for the same reason a bake is one at a time: two of them at once would be two +REM editors with a renderer each, holding two of these textures at the same time. +REM +REM What comes out is seventeen virtual textures, which is what lets a renderer hold a map of this size at all: a +REM sixteen thousand pixel one that covers the whole region and sixteen eight thousand pixel ones that each cover a +REM sixteenth of its area. They are small on disk because the region is all but empty. This map's own bake wrote the +REM whole map as one and a half megabytes, the six pieces that have anything in them as between a third of a +REM megabyte and two, and the ten that hold nothing but sea as fourteen kilobytes each, eight megabytes for the +REM seventeen together. Where a map this size is large is on the card rather than on disk: what showing one needs is +REM a virtual texture pool big enough for the part of it that is on screen, and this project's pool section gives it +REM sixty four megabytes by default and, with bPoolAutoGrowInEditor off, does not grow while the editor runs. Sixty +REM four megabytes is a great many of the pages these are cut into, and one of them on a screen is a small part of +REM the pool; the entry to raise, if a UI ever shows more of them at once than the pool holds, is +REM DefaultSizeInMegabyte in that section of Config\DefaultEngine.ini. +REM +REM The tiles that come back empty are counted out loud by every run, and that count is the quickest way to see a +REM region that no longer covers the world: a region that has shrunk onto the world reports few or none of them +REM empty, and one that has grown past the world reports all of them. On this map a whole map bake reports fifty +REM four of its sixty four tiles empty, so ten of them have something in them, and the sixteen pieces report +REM between ten and sixteen of their sixteen: ten of the pieces are all sea and come back entirely empty, the two +REM west of the island report ten, the two the island's own corner is in report thirteen and the two east of those +REM report fifteen. Those counts are of the world rather than of the bake, so they move when the map does; what is +REM worth reading them for is a count that has gone to all empty or all full, which is the region and not the bake. + +REM -AllowCommandletRendering is not optional. A builder that captures is refused without it, because a +REM commandlet has no renderer unless it is told to start one. +REM +REM The per corner coverage check is only as talkative as its own log category, which does not print its Verbose +REM lines by default. A corner that lands outside its own tile is an Error and stops the walk, so a run that +REM reaches the end of the grid has passed every corner of it. +REM +REM The editor commandlet has to be the same configuration the project modules were built in, or it will load +REM modules that do not contain this builder. DebugGame is what a local build produces; uncomment the +REM Development line to bake with a development build instead, which is the faster of the two. +REM +REM Every run below is logged to a file of its own under Intermediate\WorldMap as well as to the window: the whole +REM map to whole-map.log and a piece to piece--.log, which is what makes a bake of seventeen runs +REM readable after the fact. -abslog takes an absolute path, which is what %PROJECT_ROOT% below is for: the logs +REM land in the project's own intermediate folder whatever directory this file is run from. It also moves that +REM run's log out of the editor's Saved\Logs, and the file is written as the run goes rather than at its end, so +REM one can be read, or tailed, while its bake is still walking the grid. + +SET EDITOR_CMD=C:/Program Files/Epic Games/UE_5.8/Engine/Binaries/Win64/UnrealEditor-Win64-DebugGame-Cmd.exe +REM SET EDITOR_CMD=C:/Program Files/Epic Games/UE_5.8/Engine/Binaries/Win64/UnrealEditor-Cmd.exe + +SET PROJECT=C:/Projects/ProjectEleri/ProjectEleri.uproject + +REM The project's own folder, taken from the project file above so that this file can be run from anywhere: the +REM per run logs and the trial run both hang off it. +FOR %%I IN ("%PROJECT%") DO SET PROJECT_ROOT=%%~dpI + +REM What a run of this file bakes. The whole map is baked at whatever resolution and tile count the settings actor +REM carries, which for this map are 16384 in eight tiles; a piece is baked at the two numbers below, because 8192 +REM pixels over a quarter of the width is what a piece is. +SET MAP=/Game/Maps/Persistent_GameWorld +SET BUILDER=-run=WorldPartitionBuilderCommandlet -Builder=/Script/ProjectEleriEditor.WorldMapBuilder -AllowCommandletRendering -SCCProvider=None -unattended -Verbose +SET PIECE_SIZE=8192 +SET PIECE_TILES=4 +SET PIECE_GRID=8 +SET PIECE_ROOT=%PROJECT_ROOT%Intermediate\WorldMap + +REM Whether to bake the whole map as well as the pieces. Set to 0 to bake only the sixteen pieces, which is what +REM a run that changed the piece numbers and nothing else wants. +SET WHOLE_MAP=1 + +REM Bake one piece instead of all sixteen: its column and row as -WorldMapPiece takes them, or empty for all of +REM them. ONLY_PIECE=1,1 is the piece the island's own corner is in, and is the one to bake when the piece layer +REM is being tried out rather than built. +SET ONLY_PIECE= + +IF NOT EXIST "%PIECE_ROOT%" MKDIR "%PIECE_ROOT%" + +REM The geometry trial of one piece: four tiles of 512 pixels over the whole square a real piece covers, with a +REM bitmap of each to look at. It bakes one asset rather than seventeen and its log says which region that was, +REM so it is the cheap first thing to run when the piece arithmetic in the builder changes. +REM "%EDITOR_CMD%" "%PROJECT%" %BUILDER% %MAP% -WorldMapPiece=0,0 -WorldMapPieceGrid=%PIECE_GRID% -MapTextureSize=512 -TilesPerAxis=2 -DebugCapture -log -stdout + +IF "%WHOLE_MAP%"=="1" ( + ECHO "Baking the whole map..." + "%EDITOR_CMD%" "%PROJECT%" %BUILDER% %MAP% -abslog="%PIECE_ROOT%\whole-map.log" -log -stdout + IF ERRORLEVEL 1 GOTO :BAKE_FAILED +) + +ECHO "Baking the sixteen pieces..." +SET /A PIECE_LAST=%PIECE_GRID% - 1 +FOR /L %%R IN (0,1,%PIECE_LAST%) DO ( + FOR /L %%C IN (0,1,%PIECE_LAST%) DO ( + CALL :bake_piece %%C %%R + IF ERRORLEVEL 1 GOTO :BAKE_FAILED + ) +) + +ECHO "DONE! The whole map and the sixteen pieces of it are in the output folder set on the settings actor." +PAUSE +EXIT /B 0 + +:BAKE_FAILED +ECHO "A run failed, so the pieces after it were not baked. The run that failed says why in the log above." +ECHO "Fix it and run this file again: the runs that succeeded are saved already, and both the failed run and the" +ECHO "skipped ones replace what is there rather than making second assets. To bake one of them on its own, set" +ECHO "ONLY_PIECE at the top of this file to its column and row and set WHOLE_MAP to 0." +PAUSE +EXIT /B 1 + +REM One piece: the region comes from the actor and the piece from the two arguments, which are its column and its +REM row. A piece that is not the one ONLY_PIECE asks for is skipped without a word, which is what makes that +REM setting a way to bake one piece through the same loop as all sixteen. +:bake_piece +IF NOT "%ONLY_PIECE%"=="" IF NOT "%ONLY_PIECE%"=="%1,%2" EXIT /B 0 + +ECHO "Baking piece %1,%2..." +"%EDITOR_CMD%" "%PROJECT%" %BUILDER% %MAP% -WorldMapPiece=%1,%2 -WorldMapPieceGrid=%PIECE_GRID% -MapTextureSize=%PIECE_SIZE% -TilesPerAxis=%PIECE_TILES% -abslog="%PIECE_ROOT%\piece-%1-%2.log" -log -stdout + +IF ERRORLEVEL 1 EXIT /B 1 +EXIT /B 0 diff --git a/Content/Blueprints/BP_OrthoMapCaptureActor.uasset b/Content/Blueprints/BP_OrthoMapCaptureActor.uasset new file mode 100644 index 00000000..63aa72ab --- /dev/null +++ b/Content/Blueprints/BP_OrthoMapCaptureActor.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73b6e6d9dfefcba93bbb84f31f414bdd33759fbf0f83cb2c93515ae29394b311 +size 25834 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld.uasset new file mode 100644 index 00000000..3aeba881 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28dfec0af752b39f2c696ad7a4cb2a466f8ef88df08982232506cb4002e2c1c6 +size 1506917 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_0.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_0.uasset new file mode 100644 index 00000000..ab072d6e --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_0.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38dc5cdf1693f0b438bf15c147e77f3bbc4d26f3348992ed71729bfb2bdbabd5 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_1.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_1.uasset new file mode 100644 index 00000000..682d1f44 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddde8e31bf85575b000e0327e94f09af4efa12135cd74a0ed03c172633c60d2d +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_2.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_2.uasset new file mode 100644 index 00000000..4bf04c95 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fc79dab8a366be2bd8ea7b93df6e9b10f6b1611bbce8227496b5fbe8178415c +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_3.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_3.uasset new file mode 100644 index 00000000..6f457b4e --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ffbe2e9b663815fe085bccb8cd5c67932200a44d83a5d6b4b21214ca3a89cd3 +size 1329691 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_4.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_4.uasset new file mode 100644 index 00000000..179830fe --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_4.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bdd35b706cdcc4ec4805cfa424ec150cf2a5bb905af0a4ce4d4b196b3c24d97c +size 1330759 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_5.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_5.uasset new file mode 100644 index 00000000..8b068835 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_5.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:683087ee1e42c83fdc810372c265f613da7f894b02d4dfe056b657040952c0eb +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_6.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_6.uasset new file mode 100644 index 00000000..3da97143 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_6.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43c3869514bd9266e5bbbcebeed0704c9af5d398cd3dcaa24b698d6c65963377 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_7.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_7.uasset new file mode 100644 index 00000000..0fc0556d --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_0_7.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1ed669addf8515a1c6e36eab077861dd69e369bcb31bd3b26026f09734587e2 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_0.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_0.uasset new file mode 100644 index 00000000..b8f8d451 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_0.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c686536b7ea9528aa63eeec217b5d79334c89f4f39430670cdd671979dd3e379 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_1.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_1.uasset new file mode 100644 index 00000000..d5fd9dcc --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78e20e051854eeb3dab190231025dbef7cdef6da2cc78b380ade254d06a8e229 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_2.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_2.uasset new file mode 100644 index 00000000..0105095d --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f4ccfb096f5fa3296317195de1234de7e93114abfadb58b184e08c220c1c6c4 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_3.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_3.uasset new file mode 100644 index 00000000..03b2349f --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3626fe47ebdac7614643e107fef0d952647d39c886a42b1c502c3a2ecfba1a3c +size 3650986 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_4.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_4.uasset new file mode 100644 index 00000000..d0b69452 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_4.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:badfa8a521a32680a082b2b1db532f61e8f5b0e45c7b297ce5808f5a86351842 +size 3526973 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_5.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_5.uasset new file mode 100644 index 00000000..548e4f83 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_5.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa99051714b2267547ebf20e18d64a217fde0d90bf3cedf928d2a55107e94b1c +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_6.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_6.uasset new file mode 100644 index 00000000..9f05f0f5 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_6.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bc49ad3a585a7fd7829f936b695f795bf1b63321bb81434960ef40319116788 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_7.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_7.uasset new file mode 100644 index 00000000..067f548a --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_1_7.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43dc0274fe15ae81e67fc4f590f2f4ac6c9c55bde11b38ecc630919a0587bacc +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_0.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_0.uasset new file mode 100644 index 00000000..ce2c3388 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_0.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38317ec637861eca39bf65fc09183a5fdcf96ac08d31dd4b6cfd94124dd6ea7b +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_1.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_1.uasset new file mode 100644 index 00000000..94c9d513 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93af292088c2b4289266f27b04c40318756c7c1cce1428846fc1e7073094bf22 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_2.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_2.uasset new file mode 100644 index 00000000..8032444d --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0b0bf763550bccdb5ff544a32ab3f82013c4d0730826d707f4f6c5aca2b85fc +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_3.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_3.uasset new file mode 100644 index 00000000..9a210090 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6968e6508faed30ed7f1c014ae9e82e8c424bb59f637290b80d95c1bed6ffff +size 1944603 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_4.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_4.uasset new file mode 100644 index 00000000..abffca88 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_4.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3ad50066c019bae06fae00b74fa7098ed529a54347459f32c1a7a36aae8a447 +size 1690809 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_5.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_5.uasset new file mode 100644 index 00000000..84575ac7 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_5.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9cc6b7eef0cdb39dac94ce89a7c48e7318bab7d9461cc4d70f68765596a2176b +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_6.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_6.uasset new file mode 100644 index 00000000..8d5d09ff --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_6.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e332ca17da1f32ce06939ae1c0e4b6332b8518c484270c27b792732ea446b60 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_7.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_7.uasset new file mode 100644 index 00000000..81e5cb30 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_2_7.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8db9bbf55f3d7347637e9680522601f80e04d4779c894ccae88ba040038918e9 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_0.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_0.uasset new file mode 100644 index 00000000..3e5cb573 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_0.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aabf69dc7e450f84729e4328271a48c78ac1f81275c2585c4ec59feb0e7f675d +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_1.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_1.uasset new file mode 100644 index 00000000..adde2cf5 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0b639d06ce9b2371f5f8e34b8b7a7499e9966032ca2d49b77f3406f4bfb41fd +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_2.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_2.uasset new file mode 100644 index 00000000..215bf9c0 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ec35ab7f42e04ba5a573166bed5603c8b48f9065978079314d32ae1ba5e907f +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_3.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_3.uasset new file mode 100644 index 00000000..a1aa46f9 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b08644825dfa4ffda9725f57083a67c9b58600909743bc17dba935dd46d0a756 +size 582461 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_4.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_4.uasset new file mode 100644 index 00000000..8e75b75b --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_4.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98be6fa20fde3f3781d3ce8251bd0655613112b96d3ec86713a89fab4cd1c274 +size 1412628 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_5.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_5.uasset new file mode 100644 index 00000000..5fd9681c --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_5.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18fbe1ed5593c681acdc4750c15fedaaaebddfb089178f106f1c109a3d764790 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_6.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_6.uasset new file mode 100644 index 00000000..222314ae --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_6.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:455b9863e4f42891348aba91145b8ecf67092f03d726135d0d1cb75dca1ac3ab +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_7.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_7.uasset new file mode 100644 index 00000000..acf00e2a --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_3_7.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3acd4060f9a0f682ff2707cb56b1dc14ab855d0a4ba66b96c6ab79bae14586f +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_0.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_0.uasset new file mode 100644 index 00000000..f01b5161 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_0.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4783323d80a1bfa21f44052f9ccec649950d270e024d84263c64ecea07577b08 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_1.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_1.uasset new file mode 100644 index 00000000..b97a1778 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:844befd81761d840cc5b758ba3be97f742b211bff78874460dca86fa0da133b7 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_2.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_2.uasset new file mode 100644 index 00000000..c9bda29c --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edcc1a93ad83dac44fb0816ab0283fb798a45f006ddfd47f5d9b85e870aa22d7 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_3.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_3.uasset new file mode 100644 index 00000000..89622362 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d3cd8fc3c6bd71fa376741deac5e0735c5f3d20667d8f330f3e096f86e0505f +size 990203 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_4.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_4.uasset new file mode 100644 index 00000000..144409ca --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_4.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de547ea3d033f018adbebd78624187f7cd6566fbc2ac2388acc64b14734cf02b +size 1757779 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_5.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_5.uasset new file mode 100644 index 00000000..ecc987fc --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_5.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f93792a60ea11e2964389b24794362090cfa91cfcc2ff84d94f46afbbab0dde +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_6.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_6.uasset new file mode 100644 index 00000000..180fac5a --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_6.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1a5efa5eb0adba135b0a962c4356f6e827f6005db166bd6a3ed63969f65a49f +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_7.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_7.uasset new file mode 100644 index 00000000..65795252 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_4_7.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5872aafae0bb3ee6a08e71e05b4bef09a693326cbe9f8df8edaf13affac862c +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_0.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_0.uasset new file mode 100644 index 00000000..17cea174 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_0.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb248be77ae6d2da8d5385cf53697ac6c499cbc16eac2bcc7498544cebde4d41 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_1.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_1.uasset new file mode 100644 index 00000000..cc80d960 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69669f286b074204bc51d26a5924e6623a2528776621895dce43ae04b001b9fa +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_2.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_2.uasset new file mode 100644 index 00000000..dbec9054 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24d184135153ed2d3ae1d5c27e47d1c409bb2da6c5b3de8ddd0b9b5383514264 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_3.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_3.uasset new file mode 100644 index 00000000..7229af98 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4a6190a4df43ff8d5af70cfe088e35634994042778731a0ae9471632dc9c703 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_4.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_4.uasset new file mode 100644 index 00000000..1901c2ec --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_4.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04f2403d5f49af916db546ac2f80ce5695f7454493cf8d827452816c2ab37eff +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_5.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_5.uasset new file mode 100644 index 00000000..79124b81 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_5.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07445349207381d6dbd0297d75b41b9544829e8278a75b6ac9ebbd5d2f1be3d4 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_6.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_6.uasset new file mode 100644 index 00000000..f4d04479 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_6.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21ac43259fa4e50d63cbc03115b2a3b2d348fb284f6f7cac95d4392ca9d34265 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_7.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_7.uasset new file mode 100644 index 00000000..4581d3a2 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_5_7.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcc83c5d484ba684764dc22c5c9dffcadc59d68894735503c1db41d9ab4697fe +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_0.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_0.uasset new file mode 100644 index 00000000..ff170885 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_0.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c28e5355690d765a66716c9f9fcb965b94e84a0126e4ec2e16a2913338444344 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_1.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_1.uasset new file mode 100644 index 00000000..7c9aed8a --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f19223dd1ea07cf283aac50a61e8258b58c74255c906738aaa230d64f626b2c3 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_2.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_2.uasset new file mode 100644 index 00000000..9d9cfbdb --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9439bf203e95784e80d2a39b50a789dd45baba4174811d2004ba470c847b5fa7 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_3.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_3.uasset new file mode 100644 index 00000000..f7e1551d --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1eb8920025c605298840cefc73e4e6e73f5ca8db8d19a8d5fbb55924f4e6b15e +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_4.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_4.uasset new file mode 100644 index 00000000..3225e6fd --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_4.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:455175ec329637e04085cbe1744657f7a4ee1ce5de1a9562ffa64026396c0ac9 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_5.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_5.uasset new file mode 100644 index 00000000..902392a8 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_5.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c02e9ef0e6847f37d503cb5592acb620bfc708147078517e2e80337c2fc72231 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_6.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_6.uasset new file mode 100644 index 00000000..21fefbeb --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_6.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a7e97c5bd5bffc7616a62df1b51402d54461d798836de157a548aa809542278 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_7.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_7.uasset new file mode 100644 index 00000000..4f1da0c8 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_6_7.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f749879f7ff53a3b383ceeb1502cec0987640175166eb38e2067b947feba3421 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_0.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_0.uasset new file mode 100644 index 00000000..47ece284 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_0.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e96a8cddd74a82fdaaa99fa97fcaaca463b1b8c60e5668e2513b38bbae063702 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_1.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_1.uasset new file mode 100644 index 00000000..f0329748 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42ac206cf5c07819100dde62c09c880b3953443e44a012a46dc3307741d918f0 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_2.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_2.uasset new file mode 100644 index 00000000..0424ccd2 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a90952fce2adff053e5bbdc8451a9d4e2913054deffd1c7520ffdea613ed84c +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_3.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_3.uasset new file mode 100644 index 00000000..0e369798 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_3.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e35722ce44ed69a3e61d3df4f21d98431349e414f8c4eb5e09e5cd7f38b11408 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_4.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_4.uasset new file mode 100644 index 00000000..fb97a849 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_4.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2c194f5b0db89a6c494b5224cecd3f452fcbfa431418af06450451bbef59e2a +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_5.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_5.uasset new file mode 100644 index 00000000..53f34bc6 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_5.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e76099d352bd7cc9e1fe8b8abc0e600f404c7c439978550ddcd414364e315f9 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_6.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_6.uasset new file mode 100644 index 00000000..33b46abf --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_6.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b59730a4a385f52ffcbe65ddf633cb64c3c8453f71168b8b660c46f8cad5a2a2 +size 14481 diff --git a/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_7.uasset b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_7.uasset new file mode 100644 index 00000000..3473f7b1 --- /dev/null +++ b/Content/Maps/Generated/WorldMap_Persistent_GameWorld_Piece8x8_7_7.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7dcf794f6400e86c1399a65baaa46f5f4398dc54aa228eed9c6f615a0a36114 +size 14481 diff --git a/Content/Maps/Persistent_GameWorld.umap b/Content/Maps/Persistent_GameWorld.umap index 22d4e86c..2231b707 100644 --- a/Content/Maps/Persistent_GameWorld.umap +++ b/Content/Maps/Persistent_GameWorld.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33faf2a99ca3a6f8ffa5c04115a1deebe944a9f0ee74736b3b5bd86e9c9bc366 -size 41942 +oid sha256:f67c2111636eb4d88f2fd0fcf35ed4d5333db4f11bac9ced49cc87b5ac974be7 +size 41968 diff --git a/Content/Textures/Map/RT_MapCapture.uasset b/Content/Textures/Map/RT_MapCapture.uasset new file mode 100644 index 00000000..1ed23fee --- /dev/null +++ b/Content/Textures/Map/RT_MapCapture.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:888964a6dcc10779999ee3c31a9c3d93d24bb55984f30da46038cf97fda2ea4a +size 18616 diff --git a/Content/__ExternalActors__/Maps/Persistent_GameWorld/B/CX/PGN02K2I4F3187FT92CQTV.uasset b/Content/__ExternalActors__/Maps/Persistent_GameWorld/B/CX/PGN02K2I4F3187FT92CQTV.uasset new file mode 100644 index 00000000..cce6e128 --- /dev/null +++ b/Content/__ExternalActors__/Maps/Persistent_GameWorld/B/CX/PGN02K2I4F3187FT92CQTV.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfad9e97819bf8c86104533a553eb0d4f2ca5c14deaa5bdad628e8b89d3484c1 +size 4068 diff --git a/Source/ProjectEleri/GameObjects/OrthoMapCaptureActor.cpp b/Source/ProjectEleri/GameObjects/OrthoMapCaptureActor.cpp new file mode 100644 index 00000000..4eef49e2 --- /dev/null +++ b/Source/ProjectEleri/GameObjects/OrthoMapCaptureActor.cpp @@ -0,0 +1,865 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#include "OrthoMapCaptureActor.h" + +#include "Components/SceneCaptureComponent2D.h" +#include "Engine/Scene.h" +#include "Engine/TextureRenderTarget2D.h" +#include "EngineDefines.h" +#include "Math/OrthoMatrix.h" +#include "Math/RotationMatrix.h" +#include "Math/TranslationMatrix.h" +#include "Misc/PackageName.h" +#include "SceneView.h" +#include "WorldPartition/WorldPartition.h" + +namespace +{ + /** The axis swap every view matrix carries, and the one thing that is easy to leave out of one built by hand: + * a camera's own frame is forward, right, up, while the view space a projection matrix is written against is + * right, up, forward. This is the same matrix FGameplayStatics::CalculateViewProjectionMatricesFromMinimalView + * builds, and without it a capture and any mapping through the same matrices both come out a quarter turn + * sideways, with the world axis the camera looks along ending up across the picture instead of down it. */ + const FMatrix ViewSpaceSwizzle( + FPlane(0, 0, 1, 0), + FPlane(1, 0, 0, 0), + FPlane(0, 1, 0, 0), + FPlane(0, 0, 0, 1)); + + /** The rotation every world map capture is taken through: straight down, with the camera's up direction along + * -Y. It is the rotation the engine's own world partition minimap captures through, and keeping it one named + * value is what stops SetCaptureRegion and ComputeRegionMatrices pointing two different ways. */ + const FRotator WorldMapCaptureRotation(-90.0, 0.0, -90.0); +} + +AOrthoMapCaptureActor::AOrthoMapCaptureActor() +{ + // Nothing here needs a tick. The capture renders when it is moved or when it is asked to, and it is + // carried out by the renderer rather than by this actor's frame. + PrimaryActorTick.bCanEverTick = false; + + SceneRoot = CreateDefaultSubobject(TEXT("SceneRoot")); + SetRootComponent(SceneRoot); + // Movable rather than static: the whole point of this actor is that it gets dragged and turned, and + // a static root refuses to move in the editor. + SceneRoot->SetMobility(EComponentMobility::Movable); + + CaptureComponent = CreateDefaultSubobject(TEXT("CaptureComponent")); + CaptureComponent->SetupAttachment(SceneRoot); + CaptureComponent->SetMobility(EComponentMobility::Movable); + + // Defaults of the mirrored settings. ApplySettings is what copies them onto the component. + RenderTarget = nullptr; + CaptureSourceOverride = SCS_BaseColor; + OrthoWidth = 4096.0f; + bAutoCalculateOrthoPlanes = true; + bCaptureEveryFrame = false; + bCaptureOnMovement = true; + bUnlitCapture = true; + bFlipV = false; + bFlipU = false; + + // World map defaults: no region chosen yet, 16k of map cut into 8 tiles of 2048 pixels, which is 64 + // captures. The region is the one setting that cannot be guessed, and FitBoundsToWorldPartition is what + // fills it in from the world partition. + WorldMapBounds = FBox(ForceInit); + MapTextureSize = 16384; + TilesPerAxis = 8; + OutputPackageFolder = TEXT("/Game/Maps/Generated"); + WorldMapWarmupFrames = 5; + + // The plain settings go on in the constructor, so that the component is consistent even before the + // first OnConstruction. The show flags deliberately do not: SetShowFlagSettings rebuilds them + // against the archetype, which is not a question worth asking about an object that is still being + // constructed. + CaptureComponent->ProjectionType = ECameraProjectionMode::Orthographic; + CaptureComponent->OrthoWidth = OrthoWidth; + CaptureComponent->bAutoCalculateOrthoPlanes = bAutoCalculateOrthoPlanes; + CaptureComponent->CaptureSource = CaptureSourceOverride; + CaptureComponent->bCaptureEveryFrame = 0; + CaptureComponent->bCaptureOnMovement = 1; +} + +void AOrthoMapCaptureActor::ApplySettings() +{ + if (!CaptureComponent) + { + return; + } + + // Orthographic every time, and not only when it already is: a perspective capture that was mapped + // as if it were orthographic would put every position somewhere it does not belong, and the mapping + // below has no way to tell that it happened. + CaptureComponent->ProjectionType = ECameraProjectionMode::Orthographic; + CaptureComponent->OrthoWidth = FMath::Max(1.0f, OrthoWidth); + CaptureComponent->bAutoCalculateOrthoPlanes = bAutoCalculateOrthoPlanes; + + // CaptureSource is not a property of the component a caller can set from the editor or from + // Blueprint, so this forward is the only way in. + CaptureComponent->CaptureSource = CaptureSourceOverride; + + CaptureComponent->TextureTarget = RenderTarget; + CaptureComponent->bCaptureEveryFrame = bCaptureEveryFrame ? 1 : 0; + CaptureComponent->bCaptureOnMovement = bCaptureOnMovement ? 1 : 0; + + ApplyCaptureShowFlags(); +} + +void AOrthoMapCaptureActor::ApplyCaptureShowFlags() const +{ + if (!CaptureComponent) + { + return; + } + + // Written through the serialized setting list and not onto ShowFlags directly. ShowFlags is rebuilt + // from the archetype by every PostLoad (USceneCaptureComponent::UpdateShowFlags), so a direct write + // survives until the level is loaded again and then quietly stops applying, which is a capture that + // looks right in the editor and wrong in a build. + TArray ShowFlagSettings; + + if (bUnlitCapture) + { + // The features that give a captured region a mood instead of a shape. Left on, the map comes out + // shaded by whatever stands above it and tinted by the sky. The names have to match the show + // flag identifiers exactly, because a name that does not resolve is skipped without a word. + const TCHAR* FlagsToDisable[] = + { + TEXT("Lighting"), + TEXT("DynamicShadows"), + TEXT("SkyLighting"), + TEXT("Atmosphere"), + TEXT("Fog"), + TEXT("VolumetricFog"), + TEXT("PostProcessing"), + TEXT("AntiAliasing"), + }; + + for (const TCHAR* FlagName : FlagsToDisable) + { + FEngineShowFlagsSetting& Setting = ShowFlagSettings.AddDefaulted_GetRef(); + Setting.ShowFlagName = FlagName; + Setting.Enabled = false; + } + } + + // An empty list is how the component is put back onto its own defaults. + CaptureComponent->SetShowFlagSettings(ShowFlagSettings); + + // The vignette is applied per view, so with a capture it darkens the middle of the captured region + // rather than the edge of a screen, and a set of captures laid out next to each other reads as a + // grid of dark circles. The engine's own minimap capture turns it off for the same reason. + CaptureComponent->PostProcessSettings.bOverride_VignetteIntensity = 1; + CaptureComponent->PostProcessSettings.VignetteIntensity = 0.0f; +} + +void AOrthoMapCaptureActor::OnConstruction(const FTransform& Transform) +{ + Super::OnConstruction(Transform); + + // An instance placed in a level is built from a template before its own saved properties are read + // back, so this is where an edit made on that template reaches the component. It also runs on every + // property edit in the editor, which is what keeps the copies of these settings from drifting apart. + ApplySettings(); +} + +void AOrthoMapCaptureActor::BeginPlay() +{ + Super::BeginPlay(); + + // A level placed instance is put together before the game starts, and a spawned one is not built + // through OnConstruction at all, so this is the one point both paths share. + ApplySettings(); +} + +#if WITH_EDITOR +void AOrthoMapCaptureActor::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) +{ + Super::PostEditChangeProperty(PropertyChangedEvent); + + // Every property on this actor is really a setting of the capture component, and the details panel + // shows the component's own copies of them as well. An edit that is not pushed down leaves the two + // describing different things. + ApplySettings(); +} +#endif + +bool AOrthoMapCaptureActor::GetTargetSize(int32& OutSizeX, int32& OutSizeY) const +{ + OutSizeX = 0; + OutSizeY = 0; + + if (!RenderTarget || RenderTarget->SizeX <= 0 || RenderTarget->SizeY <= 0) + { + return false; + } + + OutSizeX = RenderTarget->SizeX; + OutSizeY = RenderTarget->SizeY; + return true; +} + +bool AOrthoMapCaptureActor::ComputeMapUV(const FVector& WorldLocation, FVector2D& OutUV) const +{ + OutUV = FVector2D::ZeroVector; + + if (!CaptureComponent) + { + return false; + } + + int32 SizeX = 0; + int32 SizeY = 0; + if (!GetTargetSize(SizeX, SizeY)) + { + // No warning here: this is the per frame path for anything that maps along with the capture, and + // a missing render target is reported where a person asks for a mapping instead. + return false; + } + + // The camera the renderer will build this capture's view from, asked for rather than assembled here + // out of the transform and OrthoWidth. Anything derived from the component this way stays in step + // with the picture, including the ortho clip planes and any offset the engine applies to them. + FMinimalViewInfo ViewInfo; + CaptureComponent->GetCameraView(0.0f, ViewInfo); + + ViewInfo.ProjectionMode = ECameraProjectionMode::Orthographic; + ViewInfo.OrthoWidth = FMath::Max(1.0f, CaptureComponent->OrthoWidth); + // The render target is the capture's viewport, so it, and not the camera, is what fixes the aspect + // ratio: the height of the captured rectangle is OrthoWidth * SizeY / SizeX. + ViewInfo.AspectRatio = static_cast(SizeX) / static_cast(SizeY); + ViewInfo.bConstrainAspectRatio = false; + + // A capture that renders through a projection matrix of its own has to be mapped through that same + // matrix, or the mapping and the picture would describe two different regions. + const FMatrix ProjectionMatrix = CaptureComponent->bUseCustomProjectionMatrix + ? CaptureComponent->CustomProjectionMatrix + : ViewInfo.CalculateProjectionMatrix(); + + const FMatrix ViewMatrix = FTranslationMatrix(-ViewInfo.Location) * FInverseRotationMatrix(ViewInfo.Rotation) * ViewSpaceSwizzle; + const FMatrix ViewProjectionMatrix = ViewMatrix * ProjectionMatrix; + + // The whole image is the viewport here, so the pixel a position projects to is also the pixel it + // occupies in the render target. + const FIntRect ViewRect(0, 0, SizeX, SizeY); + + // Asking for positions outside the view is on: landing outside the captured rectangle is an ordinary + // question with an ordinary answer (a UV outside 0-1) and not a failure. + FVector2D ScreenPosition; + FSceneView::ProjectWorldToScreen(WorldLocation, ViewRect, ViewProjectionMatrix, ScreenPosition, /*bShouldCalcOutsideViewPosition=*/true); + + OutUV.X = ScreenPosition.X / static_cast(SizeX); + OutUV.Y = ScreenPosition.Y / static_cast(SizeY); + + // ProjectWorldToScreen puts the view's up direction at a smaller pixel Y, and pixel Y counts down + // from the top of the image, so V 0 is the top row of the render target already. The flip is only + // there for the consumers that keep V the other way round. + if (bFlipV) + { + OutUV.Y = 1.0 - OutUV.Y; + } + + return true; +} + +FVector2D AOrthoMapCaptureActor::WorldToMapUV(FVector WorldLocation) const +{ + FVector2D UV; + ComputeMapUV(WorldLocation, UV); + return UV; +} + +bool AOrthoMapCaptureActor::WorldToMapUVChecked(FVector WorldLocation, FVector2D& OutUV, bool& bIsInsideCapture) const +{ + const bool bHasUV = ComputeMapUV(WorldLocation, OutUV); + + // Computed from the UV rather than taken from the projection, which reports whether a position is in + // front of the camera, not whether it is inside the captured rectangle. + bIsInsideCapture = bHasUV + && OutUV.X >= 0.0 && OutUV.X <= 1.0 + && OutUV.Y >= 0.0 && OutUV.Y <= 1.0; + + return bHasUV; +} + +bool AOrthoMapCaptureActor::IsWorldPointInCapture(FVector WorldLocation) const +{ + FVector2D UV; + bool bIsInsideCapture = false; + WorldToMapUVChecked(WorldLocation, UV, bIsInsideCapture); + return bIsInsideCapture; +} + +FVector2D AOrthoMapCaptureActor::GetCaptureWorldSize() const +{ + if (!CaptureComponent) + { + return FVector2D::ZeroVector; + } + + const double Width = FMath::Max(1.0f, CaptureComponent->OrthoWidth); + + // Without a target there is no aspect ratio to read, so the height is reported as equal to the width. + // That is a guess, which is why the mapping itself refuses to work in this state. + double AspectRatio = 1.0; + if (RenderTarget && RenderTarget->SizeX > 0 && RenderTarget->SizeY > 0) + { + AspectRatio = static_cast(RenderTarget->SizeX) / static_cast(RenderTarget->SizeY); + } + + return FVector2D(Width, Width / AspectRatio); +} + +//----- World map ----- +// +// The world map is this same capture repeated over a grid of tiles and stitched back together, and what +// makes that work is that the grid's geometry lives in exactly one place: ComputeRegionMatrices, the +// projection and view matrix pair a region is captured through. The baker aims the capture at a tile by +// handing that tile's box to SetCaptureRegion, and the mapping below projects positions through the same +// pair, so a tile's pixels and the UVs that describe them are two readings of one camera rather than two +// implementations that are meant to agree. +// +// Nothing in this section reads the component's transform or OrthoWidth. Those describe the free roaming +// capture the rest of the actor is for, and they are deliberately not what a map is built from. + +namespace +{ + /** Where a world position lands in an image drawn through these matrices: the same arithmetic + * FSceneView::ProjectWorldToScreen does, with the whole render target as the viewport. Going through the + * matrices rather than working the answer out from the rotation is what makes a UV computed here the UV + * the picture actually has. */ + FVector2D ProjectRegionUV(const FMatrix& ViewMatrix, const FMatrix& ProjectionMatrix, const FVector& WorldLocation) + { + const FVector4 ClipPosition = (ViewMatrix * ProjectionMatrix).TransformFVector4(FVector4(WorldLocation, 1.0)); + + // An orthographic projection does not divide by W, so W is 1 and the clip position is already the + // position on the image in the -1 to 1 space a viewport maps from. Y is turned round here because + // projection space counts upwards while a texture's V counts down from its top row. + const double W = FMath::IsNearlyZero(ClipPosition.W) ? 1.0 : ClipPosition.W; + + return FVector2D( + ClipPosition.X / W * 0.5 + 0.5, + 0.5 - ClipPosition.Y / W * 0.5); + } +} + +void AOrthoMapCaptureActor::ComputeRegionMatrices(const FBox& WorldRegion, FMatrix& OutViewMatrix, FMatrix& OutProjectionMatrix) const +{ + const FVector Center = WorldRegion.GetCenter(); + const FVector Extent = WorldRegion.GetExtent(); + + // Half extents, because that is what FReversedZOrthoMatrix takes: this is the same call the engine's own + // orthographic camera path makes, with OrthoWidth / 2 for the width. A square region therefore fills a + // square image with no distortion, which is the whole reason the world map region is required to be + // square rather than merely tidy. + const float HalfWidth = static_cast(FMath::Max(1.0, FMath::Abs(Extent.X))); + const float HalfHeight = static_cast(FMath::Max(1.0, FMath::Abs(Extent.Y))); + + // The near and far planes cannot be taken from the region: a map has to see everything from the camera + // down to the ground, and the camera deliberately sits above the region rather than at a usable near + // plane. This is the engine's own minimap answer to the same problem, and it leaves the visible depth + // range at half of UE_FLOAT_HUGE_DISTANCE either side of the camera, which is five kilometres of world + // below a camera that is only ever a few kilometres up. + const double ZOffset = UE_FLOAT_HUGE_DISTANCE / 2.0; + + OutProjectionMatrix = FReversedZOrthoMatrix( + HalfWidth, + HalfHeight, + 0.5f / ZOffset, + ZOffset); + + const FVector CameraLocation(Center.X, Center.Y, WorldRegion.Max.Z); + + OutViewMatrix = FTranslationMatrix(-CameraLocation) * FInverseRotationMatrix(WorldMapCaptureRotation) * ViewSpaceSwizzle; +} + +double AOrthoMapCaptureActor::GetTileWorldSize() const +{ + // The region the map covers is square, so either extent answers the question. X is the one the grid is + // anchored to, which is also the axis a world partition's cell indices run along. + return WorldMapBounds.GetExtent().X * 2.0 / static_cast(FMath::Max(1, TilesPerAxis)); +} + +int32 AOrthoMapCaptureActor::GetTilePixelSize() const +{ + return MapTextureSize / FMath::Max(1, TilesPerAxis); +} + +FVector2D AOrthoMapCaptureActor::WorldToBakedMapUV(FVector WorldLocation) const +{ + FMatrix ViewMatrix; + FMatrix ProjectionMatrix; + ComputeRegionMatrices(WorldMapBounds, ViewMatrix, ProjectionMatrix); + + FVector2D MapUV = ProjectRegionUV(ViewMatrix, ProjectionMatrix, WorldLocation); + + // The same two flags the bake honours, applied at the same point in the chain, so a UV reported here and + // a pixel written by the bake describe the same place in the same picture. + if (bFlipU) + { + MapUV.X = 1.0 - MapUV.X; + } + + if (bFlipV) + { + MapUV.Y = 1.0 - MapUV.Y; + } + + return MapUV; +} + +FVector AOrthoMapCaptureActor::BakedMapUVToWorld(FVector2D MapUV, double Height) const +{ + const FVector Min = WorldMapBounds.Min; + const FVector Max = WorldMapBounds.Max; + + // Three samples of the very mapping the forward direction uses, inverted rather than the algebra being + // written out a second time. That is what keeps the pair exact, flips and all: whatever + // WorldToBakedMapUV does, including the two flips and whichever world axes the region's picture turns out + // to run along, comes back out here. + const FVector2D UVAtMin = WorldToBakedMapUV(FVector(Min.X, Min.Y, Min.Z)); + const FVector2D UVAtMaxX = WorldToBakedMapUV(FVector(Max.X, Min.Y, Min.Z)); + const FVector2D UVAtMaxY = WorldToBakedMapUV(FVector(Min.X, Max.Y, Min.Z)); + + const FVector2D AlongX = UVAtMaxX - UVAtMin; + const FVector2D AlongY = UVAtMaxY - UVAtMin; + const double Determinant = AlongX.X * AlongY.Y - AlongX.Y * AlongY.X; + + if (FMath::IsNearlyZero(Determinant)) + { + UE_LOG(LogTemp, Warning, TEXT("%s: the world map mapping is degenerate, so a map position cannot be turned back into a world position. Check WorldMapBounds and MapTextureSize."), + *GetActorNameOrLabel()); + + return FVector(Min.X, Min.Y, Min.Z + Height); + } + + const FVector2D Delta = MapUV - UVAtMin; + const double FractionX = (Delta.X * AlongY.Y - Delta.Y * AlongY.X) / Determinant; + const double FractionY = (AlongX.X * Delta.Y - AlongX.Y * Delta.X) / Determinant; + + return FVector( + Min.X + FractionX * (Max.X - Min.X), + Min.Y + FractionY * (Max.Y - Min.Y), + Min.Z + Height); +} + +void AOrthoMapCaptureActor::SetRenderTarget(UTextureRenderTarget2D* InRenderTarget) +{ + RenderTarget = InRenderTarget; + + ApplySettings(); +} + +bool AOrthoMapCaptureActor::IsWorldMapGridValid(FText& OutReason) const +{ + auto Reject = [&OutReason](const FText& Reason) + { + OutReason = Reason; + return false; + }; + + if (TilesPerAxis < 1) + { + return Reject(NSLOCTEXT("EleriMapCapture", "WorldMapNoTiles", + "TilesPerAxis is zero or negative, so the world map would be cut into no tiles at all.")); + } + + if (MapTextureSize < 1) + { + return Reject(NSLOCTEXT("EleriMapCapture", "WorldMapNoPixels", + "MapTextureSize is zero or negative, so the world map has no pixels to bake into.")); + } + + if (MapTextureSize % TilesPerAxis != 0) + { + return Reject(FText::Format( + NSLOCTEXT("EleriMapCapture", "WorldMapTilePixelsNotWhole", + "MapTextureSize ({0}) does not divide evenly by TilesPerAxis ({1}), so a tile would be a fraction of a pixel wide and the tiles could not be stitched back together without a seam."), + FText::AsNumber(MapTextureSize), + FText::AsNumber(TilesPerAxis))); + } + + const int32 MaximumSize = UTexture::GetMaximumDimensionOfNonVT(); + if (MapTextureSize > MaximumSize) + { + return Reject(FText::Format( + NSLOCTEXT("EleriMapCapture", "WorldMapTooWide", + "MapTextureSize ({0}) is wider than the {1} a texture can be without being a virtual texture, and a world map is one stitched texture. Bake at {1} or less."), + FText::AsNumber(MapTextureSize), + FText::AsNumber(MaximumSize))); + } + + if (!WorldMapBounds.IsValid) + { + return Reject(NSLOCTEXT("EleriMapCapture", "WorldMapNoBounds", + "WorldMapBounds has no extent, so there is no region for the world map to cover. FitBoundsToWorldPartition seeds it from the world partition.")); + } + + const FVector Extent = WorldMapBounds.GetExtent(); + if (Extent.X <= 0.0 || Extent.Y <= 0.0) + { + return Reject(NSLOCTEXT("EleriMapCapture", "WorldMapFlatBounds", + "WorldMapBounds is flat along one of its axes, so there is no ground for the world map to cover.")); + } + + const double Width = Extent.X * 2.0; + const double Height = Extent.Y * 2.0; + if (!FMath::IsNearlyEqual(Width, Height, FMath::Max(Width, Height) * 1.e-4)) + { + return Reject(FText::Format( + NSLOCTEXT("EleriMapCapture", "WorldMapNotSquare", + "WorldMapBounds is {0} by {1} world units. It has to be square: the map texture is square and so is each capture that fills it, so a region twice as wide as it is tall would either be squashed into the map or baked with a third of the tiles blank."), + FText::AsNumber(FMath::RoundToInt(Width)), + FText::AsNumber(FMath::RoundToInt(Height)))); + } + + const double TileSize = Width / static_cast(TilesPerAxis); + + if (TileSize > MAX_int32) + { + return Reject(FText::Format( + NSLOCTEXT("EleriMapCapture", "WorldMapTileTooLarge", + "A tile would be {0} world units across, which is more than the baker's cell size can hold. Use a larger TilesPerAxis."), + FText::AsNumber(FMath::RoundToInt(TileSize)))); + } + + // The bake walks a grid of whole sized cells, so a tile edge that is not a whole number of units cannot + // land on a cell edge. A millionth of a unit is far below the precision a world position carries, so + // anything within that of whole is whole as far as this is concerned. + if (!FMath::IsNearlyEqual(TileSize, FMath::RoundToDouble(TileSize), 1.e-6)) + { + return Reject(FText::Format( + NSLOCTEXT("EleriMapCapture", "WorldMapTileSizeNotWhole", + "WorldMapBounds divided by TilesPerAxis gives a tile {0} world units wide, which is not a whole number of units. The tiles are walked as whole sized cells, so its edges would not fall on cell edges."), + FText::AsNumber(TileSize))); + } + + const double OffsetX = WorldMapBounds.Min.X / TileSize; + const double OffsetY = WorldMapBounds.Min.Y / TileSize; + if (!FMath::IsNearlyEqual(OffsetX, FMath::RoundToDouble(OffsetX), 1.e-6) + || !FMath::IsNearlyEqual(OffsetY, FMath::RoundToDouble(OffsetY), 1.e-6)) + { + return Reject(FText::Format( + NSLOCTEXT("EleriMapCapture", "WorldMapUnaligned", + "WorldMapBounds starts at {0}, which is not on a tile boundary: its min X and min Y both have to be whole multiples of the tile size ({1}). FitBoundsToWorldPartition puts it on the grid."), + FText::AsCultureInvariant(WorldMapBounds.Min.ToCompactString()), + FText::AsNumber(FMath::RoundToInt(TileSize)))); + } + + // Where the bake puts its result is part of whether these settings can be baked: a grid that cannot be + // saved is sixty four captures thrown away at the end of the run, and a bake that saves to the wrong + // place is worse than one that refuses to start. + if (OutputPackageFolder.IsEmpty()) + { + return Reject(NSLOCTEXT("EleriMapCapture", "WorldMapNoOutputFolder", + "OutputPackageFolder is empty, so the world map bake would have nowhere to put the map it built. It is the folder the baked asset is saved into, as a long package path such as /Game/Maps/Generated.")); + } + + FText FolderReason; + if (!FPackageName::IsValidLongPackageName(OutputPackageFolder, /*bIncludeReadOnlyRoots=*/true, &FolderReason)) + { + return Reject(FText::Format( + NSLOCTEXT("EleriMapCapture", "WorldMapOutputFolderInvalid", + "OutputPackageFolder ({0}) is not a long package path a baked map could be saved into: {1}"), + FText::AsCultureInvariant(OutputPackageFolder), + FolderReason)); + } + + return true; +} + +void AOrthoMapCaptureActor::CaptureNow() +{ + if (!CaptureComponent) + { + return; + } + + if (!RenderTarget) + { + UE_LOG(LogTemp, Warning, TEXT("%s: no render target is assigned, so the capture has nowhere to render."), *GetActorNameOrLabel()); + return; + } + + CaptureComponent->CaptureScene(); +} + +void AOrthoMapCaptureActor::RefreshCapture() +{ + // Modified first, so that this lands in the undo history like any other edit made from the details + // panel, rather than being an action that cannot be taken back. + Modify(); + + ApplySettings(); + CaptureNow(); +} + +FBox AOrthoMapCaptureActor::GetAlignedWorldMapBounds(const FBox& SourceBounds) const +{ + if (!SourceBounds.IsValid) + { + return WorldMapBounds; + } + + // The longer side decides the square, and the square is then rounded up to a whole number of tiles so a + // tile comes out a whole number of units wide. Rounding up rather than to nearest is what keeps the result + // a superset of the region it has to cover. + const FVector SourceExtent = SourceBounds.GetExtent(); + const double LongestSide = FMath::Max(SourceExtent.X, SourceExtent.Y) * 2.0; + const int32 Tiles = FMath::Max(1, TilesPerAxis); + const double TileSize = FMath::Max(1.0, FMath::CeilToDouble(LongestSide / static_cast(Tiles))); + const double AlignedSide = TileSize * static_cast(Tiles); + + // Centred on the source and then snapped outwards onto the grid, so the square still contains the source + // and its edges land on tile boundaries. Up to one tile of margin is the price of whole tile edges, and a + // margin of empty world is a much smaller cost than a map that misses its own corner. + const FVector SourceCenter = SourceBounds.GetCenter(); + const double MinX = FMath::FloorToDouble((SourceCenter.X - AlignedSide * 0.5) / TileSize) * TileSize; + const double MinY = FMath::FloorToDouble((SourceCenter.Y - AlignedSide * 0.5) / TileSize) * TileSize; + + return FBox( + FVector(MinX, MinY, SourceBounds.Min.Z), + FVector(MinX + AlignedSide, MinY + AlignedSide, SourceBounds.Max.Z)); +} + +void AOrthoMapCaptureActor::SetCaptureRegion(const FBox& WorldRegion) +{ + if (!CaptureComponent) + { + return; + } + + FMatrix ViewMatrix; + FMatrix ProjectionMatrix; + ComputeRegionMatrices(WorldRegion, ViewMatrix, ProjectionMatrix); + + // The transform and the matrix have to describe the same camera, which is why the rotation used here is + // the very one ComputeRegionMatrices built the view matrix from: one named value, two uses, and no way for + // the capture and the mapping to drift apart. + CaptureComponent->SetWorldLocationAndRotation( + FVector(WorldRegion.GetCenter().X, WorldRegion.GetCenter().Y, WorldRegion.Max.Z), + WorldMapCaptureRotation); + + CaptureComponent->bUseCustomProjectionMatrix = true; + CaptureComponent->CustomProjectionMatrix = ProjectionMatrix; +} + +bool AOrthoMapCaptureActor::IsCapturingRegion() const +{ + return CaptureComponent && CaptureComponent->bUseCustomProjectionMatrix; +} + +void AOrthoMapCaptureActor::ReportMapping() +{ + if (!CaptureComponent) + { + return; + } + + int32 SizeX = 0; + int32 SizeY = 0; + if (!GetTargetSize(SizeX, SizeY)) + { + UE_LOG(LogTemp, Warning, TEXT("%s: no render target is assigned, so there is no image for world positions to map into."), *GetActorNameOrLabel()); + return; + } + + const FVector2D WorldSize = GetCaptureWorldSize(); + const FTransform ComponentTransform = CaptureComponent->GetComponentTransform(); + + UE_LOG(LogTemp, Display, TEXT("%s: a %d x %d image covers %.1f x %.1f world units, centred on %s facing %s."), + *GetActorNameOrLabel(), SizeX, SizeY, WorldSize.X, WorldSize.Y, + *ComponentTransform.GetLocation().ToCompactString(), *ComponentTransform.Rotator().ToCompactString()); + + // The middle and the four edges of the captured rectangle. They are placed in the component's own + // frame rather than in world axes, so the report holds whatever way the actor has been turned. + struct FMappingProbe + { + const TCHAR* Label; + FVector LocalOffset; + }; + + const FMappingProbe Probes[] = + { + { TEXT("centre"), FVector::ZeroVector }, + { TEXT("local +Y (right)"), FVector(0.0, WorldSize.X * 0.5, 0.0) }, + { TEXT("local -Y (left)"), FVector(0.0, -WorldSize.X * 0.5, 0.0) }, + { TEXT("local +Z (up)"), FVector(0.0, 0.0, WorldSize.Y * 0.5) }, + { TEXT("local -Z (down)"), FVector(0.0, 0.0, -WorldSize.Y * 0.5) }, + }; + + for (const FMappingProbe& Probe : Probes) + { + const FVector WorldLocation = ComponentTransform.TransformPositionNoScale(Probe.LocalOffset); + + FVector2D UV; + bool bIsInsideCapture = false; + WorldToMapUVChecked(WorldLocation, UV, bIsInsideCapture); + + UE_LOG(LogTemp, Display, TEXT("%s: %s -> UV (%.4f, %.4f)"), *GetActorNameOrLabel(), Probe.Label, UV.X, UV.Y); + } + + // What the numbers above should read as, so this can be judged without a reference to compare against. + UE_LOG(LogTemp, Display, TEXT("%s: a correct mapping puts the centre at (0.5000, 0.5000), the two local Y probes on the U 0 and U 1 edges at V 0.5, and the two local Z probes on the V 0 and V 1 edges at U 0.5."), + *GetActorNameOrLabel()); +} + +void AOrthoMapCaptureActor::FitBoundsToWorldPartition() +{ +#if WITH_EDITOR + UWorld* World = GetWorld(); + UWorldPartition* WorldPartition = World ? World->GetWorldPartition() : nullptr; + if (!WorldPartition) + { + UE_LOG(LogTemp, Warning, TEXT("%s: the world is not partitioned, so it has no partition bounds to fit the world map to. Set WorldMapBounds by hand."), + *GetActorNameOrLabel()); + return; + } + + const FBox PartitionBounds = WorldPartition->GetEditorWorldBounds(); + if (!PartitionBounds.IsValid) + { + UE_LOG(LogTemp, Warning, TEXT("%s: the world partition reports no editor world bounds, so there is nothing for the world map to be fitted to."), + *GetActorNameOrLabel()); + return; + } + + Modify(); + WorldMapBounds = GetAlignedWorldMapBounds(PartitionBounds); + + UE_LOG(LogTemp, Display, TEXT("%s: world map bounds fitted to the partition bounds (min %s, max %s)."), + *GetActorNameOrLabel(), + *PartitionBounds.Min.ToCompactString(), *PartitionBounds.Max.ToCompactString()); + + ReportWorldMapMapping(); +#else + UE_LOG(LogTemp, Warning, TEXT("%s: the world partition's editor bounds only exist in the editor, so the world map bounds cannot be fitted to them here."), + *GetActorNameOrLabel()); +#endif +} + +void AOrthoMapCaptureActor::ReportWorldMapMapping() +{ + FText Reason; + + if (!IsWorldMapGridValid(Reason)) + { + UE_LOG(LogTemp, Error, TEXT("%s: the world map settings cannot be baked: %s"), *GetActorNameOrLabel(), *Reason.ToString()); + + // The suggestion is only worth printing when there is a region to suggest a fix for. An unset box is + // the state of every fresh actor, and FitBoundsToWorldPartition is the answer to it. + if (WorldMapBounds.IsValid && WorldMapBounds.GetExtent().X > 0.0 && WorldMapBounds.GetExtent().Y > 0.0) + { + const FBox Aligned = GetAlignedWorldMapBounds(WorldMapBounds); + + UE_LOG(LogTemp, Display, TEXT("%s: the same region on the tile grid would be min %s, max %s."), + *GetActorNameOrLabel(), *Aligned.Min.ToCompactString(), *Aligned.Max.ToCompactString()); + } + + return; + } + + UE_LOG(LogTemp, Display, TEXT("%s: world map: %d x %d pixels as %d x %d tiles of %d pixels, each tile %.0f world units, over %s."), + *GetActorNameOrLabel(), + MapTextureSize, MapTextureSize, + TilesPerAxis, TilesPerAxis, + GetTilePixelSize(), GetTileWorldSize(), + *WorldMapBounds.ToString()); + + // The corners of the region, named by the world axes they are made of, so that which way round the picture + // comes out can be read off the UVs: the pair that moves U is the world axis the map's columns count. + struct FWorldMapProbe + { + const TCHAR* Label; + FVector Location; + }; + + const FVector Min = WorldMapBounds.Min; + const FVector Max = WorldMapBounds.Max; + + const FWorldMapProbe Probes[] = + { + { TEXT("min X, min Y"), FVector(Min.X, Min.Y, Min.Z) }, + { TEXT("max X, min Y"), FVector(Max.X, Min.Y, Min.Z) }, + { TEXT("min X, max Y"), FVector(Min.X, Max.Y, Min.Z) }, + { TEXT("max X, max Y"), FVector(Max.X, Max.Y, Min.Z) }, + }; + + for (const FWorldMapProbe& Probe : Probes) + { + const FVector2D MapUV = WorldToBakedMapUV(Probe.Location); + const FVector RoundTrip = BakedMapUVToWorld(MapUV, Probe.Location.Z - Min.Z); + + UE_LOG(LogTemp, Display, TEXT("%s: %s -> UV (%.4f, %.4f), pixel (%d, %d), back to (%.1f, %.1f)"), + *GetActorNameOrLabel(), Probe.Label, MapUV.X, MapUV.Y, + FMath::RoundToInt(MapUV.X * MapTextureSize), FMath::RoundToInt(MapUV.Y * MapTextureSize), + RoundTrip.X, RoundTrip.Y); + } + + UE_LOG(LogTemp, Display, TEXT("%s: a correct world map puts the four corners on the four corners of the pixel grid, and each one reads back as the world position it came from."), + *GetActorNameOrLabel()); +} + +#if WITH_EDITOR +EDataValidationResult AOrthoMapCaptureActor::IsDataValid(FDataValidationContext& Context) const +{ + EDataValidationResult Result = Super::IsDataValid(Context); + + if (!RenderTarget) + { + Context.AddError(NSLOCTEXT("EleriMapCapture", "TargetMissing", + "No render target is assigned, so the capture has nowhere to render and WorldToMapUV has no image to map into. Assign one, and make it square unless the captured region is meant to be oblong.")); + Result = EDataValidationResult::Invalid; + } + + if (OrthoWidth <= 0.0f) + { + Context.AddError(NSLOCTEXT("EleriMapCapture", "OrthoWidthNotPositive", + "OrthoWidth is zero or negative, so the captured region has no width and the whole map collapses onto a line.")); + Result = EDataValidationResult::Invalid; + } + + if (RenderTarget && RenderTarget->SizeX > 0 && RenderTarget->SizeY > 0 && RenderTarget->SizeX != RenderTarget->SizeY) + { + Context.AddWarning(FText::Format( + NSLOCTEXT("EleriMapCapture", "TargetNotSquare", + "The render target is {0} x {1}, so the captured region is {2} x {3} world units rather than square. That is a legitimate shape, it just is not the one a map usually wants, and anything that assumes the region is square will be off by the difference."), + FText::AsNumber(RenderTarget->SizeX), + FText::AsNumber(RenderTarget->SizeY), + FText::AsNumber(FMath::RoundToInt(OrthoWidth)), + FText::AsNumber(FMath::RoundToInt(GetCaptureWorldSize().Y)))); + } + + // The world map settings are the tiled bake's input, and a set of them that cannot produce a coherent map + // is worth saying out loud rather than finding out halfway through a bake. An unset box is left alone: it is + // the state of every fresh actor, and the feature is only in use once a region has been chosen. + if (WorldMapBounds.IsValid) + { + FText Reason; + if (!IsWorldMapGridValid(Reason)) + { + Context.AddError(FText::Format( + NSLOCTEXT("EleriMapCapture", "WorldMapGridInvalid", + "The tiled world map cannot be baked with these settings: {0}"), + Reason)); + Result = EDataValidationResult::Invalid; + } + } + + // A capture with the post process turned off cannot show a source that reads the post processed picture: it + // comes out clear, which reads as a map of an empty world rather than as a misconfigured capture. The bake + // settles this on its own, but an actor set up this way shows a blank preview and is worth saying so. The + // three sources the renderer treats as needing the scene colour are the ones in question. + if (bUnlitCapture + && (CaptureSourceOverride == SCS_FinalColorLDR + || CaptureSourceOverride == SCS_FinalColorHDR + || CaptureSourceOverride == SCS_FinalToneCurveHDR)) + { + Context.AddError(NSLOCTEXT("EleriMapCapture", "SourceNeedsPostProcess", + "bUnlitCapture is on, which turns the post process off, but the capture source reads the picture the post process produces. Nothing comes out of that combination and the capture is blank. Set the capture source to Base Color, or turn bUnlitCapture off.")); + Result = EDataValidationResult::Invalid; + } + + return Result; +} +#endif diff --git a/Source/ProjectEleri/GameObjects/OrthoMapCaptureActor.h b/Source/ProjectEleri/GameObjects/OrthoMapCaptureActor.h new file mode 100644 index 00000000..0238c5c3 --- /dev/null +++ b/Source/ProjectEleri/GameObjects/OrthoMapCaptureActor.h @@ -0,0 +1,281 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "Engine/EngineTypes.h" +#include "Misc/DataValidation.h" + +#include "OrthoMapCaptureActor.generated.h" + +class USceneCaptureComponent2D; +class UTextureRenderTarget2D; + +/** + * Orthographic scene capture of a rectangular slice of the world, together with the conversion between + * world positions and the 0-1 UV space of the image that slice is rendered into. + * + * The actor is meant to be dragged around by hand: WorldToMapUV reads the capture component's live + * transform, so the rectangle it maps is wherever the actor has been moved and turned to, with nothing + * to re-synchronise afterwards. + * + * The settings mirrored on this actor are the ones that drive the capture. ApplySettings pushes them + * down onto the component, which is why the component's own copies of them are not worth editing: the + * next push overwrites them. + */ +UCLASS(BlueprintType) +class PROJECTELERI_API AOrthoMapCaptureActor : public AActor +{ + GENERATED_BODY() + +public: + AOrthoMapCaptureActor(); + + /** Pushes the mirrored settings down onto the capture. This is also the moment a level placed + * instance picks up an edit made on its archetype. No capture is started here on purpose: + * construction runs while a level is still being built, and the renderer is not in a state to + * draw a scene capture out of it. */ + virtual void OnConstruction(const FTransform& Transform) override; + +#if WITH_EDITOR + virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override; + virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override; +#endif + + //----- Components ----- + + /** Plain scene component as the root, so the actor keeps a transform even with the capture off. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Map Capture") + USceneComponent* SceneRoot; + + /** The capture itself. Its location and rotation are what WorldToMapUV measures from, and its + * forward axis is the direction it looks down. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Map Capture") + USceneCaptureComponent2D* CaptureComponent; + + //----- Capture settings ----- + + /** Image the capture renders into. Its resolution is also the aspect ratio the mapping uses, so the + * mapping is only trustworthy once this is assigned. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture") + UTextureRenderTarget2D* RenderTarget; + + /** The component's own CaptureSource is not exposed to the details panel or to Blueprint by the + * engine, so it is mirrored here and pushed down by ApplySettings. + * + * The base colour rather than the finished picture, because the finished picture is what a post process + * produces and this actor turns the post process off to make a map: a source that reads it then reads + * nothing at all and the capture comes out clear, whatever the world in front of it looks like. The base + * colour comes out of the g-buffer and does not care. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture") + TEnumAsByte CaptureSourceOverride; + + /** Width of the captured region in world units. The height follows from the render target's aspect + * ratio rather than being set here, which is why GetCaptureWorldSize reports both. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture", meta = (ClampMin = "1.0", UIMin = "1.0")) + float OrthoWidth; + + /** Lets the engine place the near and far clip planes from OrthoWidth instead of leaving them where + * they are. Worth keeping on for a capture that is moved around. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture") + bool bAutoCalculateOrthoPlanes; + + /** Capture every frame. Off by default, because a map of a region only changes when the region + * moves. Turning it on makes the capture cost a rendering pass per frame. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture") + bool bCaptureEveryFrame; + + /** Capture when the actor moves. This is what makes dragging the actor in the editor show the region + * it is covering. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture") + bool bCaptureOnMovement; + + /** Strips lighting, the atmosphere, fog and the post process out of the capture, so the result reads + * as a map rather than as a photograph of the level. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture") + bool bUnlitCapture; + + /** Flips the V that WorldToMapUV returns, for the consumers that expect V to run bottom to top. It + * is off by default because V is a texture coordinate: V 0 is the top row of the render target. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture") + bool bFlipV; + + /** Flips the U that the mapping returns, the horizontal counterpart of bFlipV. The tiled world map bake + * honours both flags as well, so the picture and the UVs cannot end up describing two different + * images. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture") + bool bFlipU; + + //----- World map ----- + + /** Region the baked world map covers. It has to be square, because the map texture is square and so is + * each of the captures that fill it, and its min X and Y have to sit on tile boundaries, because the + * bake walks a grid of whole sized cells. FitBoundsToWorldPartition sets both of those from the world + * partition, and IsWorldMapGridValid says when an edited box has broken either. Z only sets the height + * the captures look down from. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture|World Map") + FBox WorldMapBounds; + + /** Resolution of the whole baked map in pixels. 16384 is the widest a texture can be without being a + * virtual texture, which is the ceiling for an asset that is one stitched image. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture|World Map", meta = (ClampMin = "64", ClampMax = "16384", UIMin = "1024", UIMax = "16384")) + int32 MapTextureSize; + + /** The map is captured as this many tiles per axis, so a bake is TilesPerAxis squared captures of + * MapTextureSize / TilesPerAxis pixels each. Finer tiles keep each capture's render target and each + * loaded cell small; coarser tiles are fewer passes to stitch. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture|World Map", meta = (ClampMin = "1", ClampMax = "64", UIMin = "1", UIMax = "16")) + int32 TilesPerAxis; + + /** Folder the baked map is saved into, as a long package path. The asset is named after the map it was + * baked from, so re-baking a map replaces its own map and nothing else. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture|World Map") + FString OutputPackageFolder; + + /** Frames each tile is captured for before the one that counts. A tile whose cells are still streaming in + * renders as nothing until they have arrived, so a capture taken the instant a cell is loaded can come + * back empty; a few frames of ticking is what gives the streaming a chance to finish, and it is also most + * of what a bake spends its time on. Five is the number the engine's own world partition minimap captures + * with. Zero is allowed, and is a bake that trusts the cell to be fully resident already. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Map Capture|World Map", meta = (ClampMin = "0", UIMin = "0", UIMax = "20")) + int32 WorldMapWarmupFrames; + + //----- Mapping ----- + + /** Where a world position lands in the captured image, as UV. Values outside 0-1 are positions + * outside the captured rectangle, and are returned as they are rather than clamped: which of the + * two a caller wants is the caller's business. Returns (0,0) when there is no render target to map + * into, since there is no image for a position to land in. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Map Capture") + FVector2D WorldToMapUV(FVector WorldLocation) const; + + /** WorldToMapUV with the two things a caller usually needs to decide with, kept apart: whether the + * UV could be worked out at all (a render target is assigned) and whether the position is inside + * the captured rectangle. The return value reports the first, bIsInsideCapture the second. */ + UFUNCTION(BlueprintCallable, Category = "Map Capture") + bool WorldToMapUVChecked(FVector WorldLocation, FVector2D& OutUV, bool& bIsInsideCapture) const; + + /** Whether a world position is inside the captured rectangle, on its plane or not: the capture is + * orthographic, so only the two axes across the camera's view matter. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Map Capture") + bool IsWorldPointInCapture(FVector WorldLocation) const; + + /** Size of the region the capture covers, in world units: X across the image and Y down it. Y is + * OrthoWidth scaled by the render target's aspect ratio, which is why an unassigned target makes + * this a square guess rather than an answer. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Map Capture") + FVector2D GetCaptureWorldSize() const; + + //----- World map mapping ----- + + /** Where a world position lands in the baked world map, as a UV over the whole map. The UV comes from + * the same top down camera the tiled bake captures through, so it describes the picture that is + * actually baked rather than an approximation of it, and bFlipU and bFlipV are applied here as they + * are there. Different from WorldToMapUV on purpose: that one measures the live capture, this one + * measures the map, and they answer for different images. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Map Capture|World Map") + FVector2D WorldToBakedMapUV(FVector WorldLocation) const; + + /** The inverse of WorldToBakedMapUV. Height is measured up from the bottom of WorldMapBounds, so a + * height of zero is the map's own plane: a map position is a place on the ground, and only the caller + * knows how high above it the thing being placed belongs. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Map Capture|World Map") + FVector BakedMapUVToWorld(FVector2D MapUV, double Height) const; + + /** World size of one tile, which is also the side of the square region a single capture covers. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Map Capture|World Map") + double GetTileWorldSize() const; + + /** Pixel size of one tile: MapTextureSize / TilesPerAxis. A map is TilesPerAxis tiles of this size + * across, and the two divide evenly or the settings are rejected. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Map Capture|World Map") + int32 GetTilePixelSize() const; + + /** Whether the world map settings describe a grid that can be baked, and when they do not, why not. The + * tiled bake asks this before it does anything, so settings that cannot produce a coherent map are + * reported rather than quietly baked into something misaligned. Not a UFUNCTION because Blueprint has + * no tidy way to hand a reason back alongside an answer. */ + bool IsWorldMapGridValid(FText& OutReason) const; + + /** The square, tile aligned box that contains SourceBounds, for the current tile settings: the region a + * world map of that world should cover. FitBoundsToWorldPartition writes it onto WorldMapBounds, and + * the validator reports it as the fix when an edited box is off the grid. Pure arithmetic, so it is + * kept apart from the world partition it is usually applied to. */ + FBox GetAlignedWorldMapBounds(const FBox& SourceBounds) const; + + //----- Capture control ----- + + /** Assigns the render target and pushes the settings down, which is the whole job in one call for + * the callers that only have a target to hand over. */ + UFUNCTION(BlueprintCallable, Category = "Map Capture") + void SetRenderTarget(UTextureRenderTarget2D* InRenderTarget); + + /** Renders the capture once, whatever bCaptureEveryFrame and bCaptureOnMovement say. */ + UFUNCTION(BlueprintCallable, Category = "Map Capture") + void CaptureNow(); + + /** Applies the mirrored settings and then renders once. Editor facing: this is the button that makes + * an edited actor show its picture without having to nudge it. */ + UFUNCTION(BlueprintCallable, CallInEditor, Category = "Map Capture") + void RefreshCapture(); + + /** Logs where the centre and the four edges of the captured rectangle land in UV, so that the + * mapping can be checked against the picture on the render target without a scene to drive it. */ + UFUNCTION(BlueprintCallable, CallInEditor, Category = "Map Capture") + void ReportMapping(); + + /** Points the capture at exactly this world box by giving it a projection matrix of its own, whatever + * the actor's own transform happens to be. This is how the tiled world map bake aims the camera at one + * tile, and it is why the bake and the world map mapping agree: both go through this same matrix + * rather than through OrthoWidth and the target's aspect ratio. */ + UFUNCTION(BlueprintCallable, Category = "Map Capture") + void SetCaptureRegion(const FBox& WorldRegion); + + /** Whether the capture is being aimed by a projection matrix of its own rather than by the actor's + * transform. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Map Capture") + bool IsCapturingRegion() const; + + /** Seeds WorldMapBounds from the world partition's own bounds, squared up and put on tile boundaries, so + * the map covers everything the partition holds and no more than a tile of margin around it. Editor + * facing, because that is where the partition's editor bounds exist. */ + UFUNCTION(CallInEditor, Category = "Map Capture|World Map") + void FitBoundsToWorldPartition(); + + /** Logs the world map settings, the corners of the region they cover, and where each corner lands in the + * baked map, so that "is this going to line up?" can be answered without a bake. Each corner is also + * put back through BakedMapUVToWorld, which is the check that the two directions are inverses. */ + UFUNCTION(CallInEditor, Category = "Map Capture|World Map") + void ReportWorldMapMapping(); + + /** Copies the mirrored settings onto the capture component. Safe to call at any point in the object's + * life once it is constructed. */ + UFUNCTION(BlueprintCallable, Category = "Map Capture") + void ApplySettings(); + +protected: + + /** Overridden to apply the settings to an instance that arrived with saved properties instead of + * through construction, which is the path a level placed actor takes. Protected because that is + * what it is on AActor, and nothing outside the actor has a reason to start one. */ + virtual void BeginPlay() override; + + /** Sets the component's show flags, which is not the same as writing to its ShowFlags member: that + * member is rebuilt from the archetype every time the component is loaded or edited, so anything + * written to it directly is lost at the next load. The serialized setting list survives that. */ + void ApplyCaptureShowFlags() const; + + /** Resolution of the render target in pixels, or false and two zeroes when there is none to read. */ + bool GetTargetSize(int32& OutSizeX, int32& OutSizeY) const; + + /** Projects a world position through the same camera the capture renders with, and reports whether + * it could: the maths needs a render target, because the target is what fixes the aspect ratio. */ + bool ComputeMapUV(const FVector& WorldLocation, FVector2D& OutUV) const; + + /** The view and projection matrices a capture of WorldRegion is taken through. The projection covers the + * region exactly, which is what makes a tile's UVs run 0-1 across that tile, and the view matrix is the + * straight down camera the region is captured from. Kept in one place because everything about the + * world map, the bake and the mapping alike, is a consequence of these two matrices. */ + void ComputeRegionMatrices(const FBox& WorldRegion, FMatrix& OutViewMatrix, FMatrix& OutProjectionMatrix) const; +}; diff --git a/Source/ProjectEleri/ProjectEleri.Build.cs b/Source/ProjectEleri/ProjectEleri.Build.cs index 54cac7a1..e4de4b46 100644 --- a/Source/ProjectEleri/ProjectEleri.Build.cs +++ b/Source/ProjectEleri/ProjectEleri.Build.cs @@ -39,6 +39,12 @@ public class ProjectEleri : ModuleRules PrivateDependencyModuleNames.AddRange(new string[] { "OnlineSubsystem", "OnlineSubsystemUtils", "Landscape", "LTween" }); + // The game's own headers live in folders at the module root rather than under Public/, and an editor tool + // that drives a scene capture needs the capture actor: its world map settings are what the tiled bake + // reads. Declaring the module root as a public include root is what lets a module that depends on this + // one include them by their folder. + PublicIncludePaths.Add(ModuleDirectory); + // Uncomment if you are using Slate UI // PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" }); diff --git a/Source/ProjectEleriEditor/Private/OrthoMap/WorldMapBuilder.cpp b/Source/ProjectEleriEditor/Private/OrthoMap/WorldMapBuilder.cpp new file mode 100644 index 00000000..0ff79901 --- /dev/null +++ b/Source/ProjectEleriEditor/Private/OrthoMap/WorldMapBuilder.cpp @@ -0,0 +1,1163 @@ +// 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. + for (TActorIterator It(World); It; ++It) + { + SettingsActor = *It; + break; + } + + 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 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=, 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(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=,, 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 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(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(Column), + ParentBounds.Min.Y + PieceSide * static_cast(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(GridOriginX); + const int32 OffsetY = CellY - static_cast(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()->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(InCellInfo.Location.X); + const int32 CellY = static_cast(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(TilePixels), + static_cast(TilePixels), + TileTexture, + TileTexture->GetName(), + InCellInfo.Bounds, + BakeCaptureSource, + static_cast(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(GetTransientPackage(), TEXT("WorldMapTile")); + + if (!TileTexture) + { + TileTexture = NewObject(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(OffsetX) * TileWorldSize, + static_cast(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(DestinationPackage, *DestinationAssetName); + + if (!DestinationTexture) + { + DestinationTexture = NewObject( + 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(PixelCount); + } + + const uint8* SourcePixels = TileTexture->Source.LockMipReadOnly(0); + + if (!SourcePixels) + { + return static_cast(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(TilePixels) * BytesPerPixel; + const int64 DestinationStrideBytes = static_cast(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(PixelOrigin.Y) * DestinationStrideBytes) + (static_cast(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(RowIndex) * DestinationStrideBytes); + + check(DestinationOffset >= 0); + check(DestinationOffset + SourceStrideBytes <= DestinationSizeBytes); + + const uint8* SourceRowPixels = SourcePixels + (static_cast(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(ColumnIndex) * BytesPerPixel), + SourceRowPixels + (static_cast(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(Width) * Height; + const FColor* SourceColors = reinterpret_cast(Pixels); + + TArray64 OpaqueImage; + OpaqueImage.SetNumUninitialized(PixelCount); + + for (int64 PixelIndex = 0; PixelIndex < PixelCount; ++PixelIndex) + { + FColor Color = SourceColors[PixelIndex]; + Color.A = 255; + + OpaqueImage[PixelIndex] = Color; + } + + TArray64 Compressed; + FImageUtils::PNGCompressImageArray(Width, Height, TArrayView64(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(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 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; +} diff --git a/Source/ProjectEleriEditor/Private/OrthoMap/WorldMapBuilder.h b/Source/ProjectEleriEditor/Private/OrthoMap/WorldMapBuilder.h new file mode 100644 index 00000000..199c240e --- /dev/null +++ b/Source/ProjectEleriEditor/Private/OrthoMap/WorldMapBuilder.h @@ -0,0 +1,217 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "WorldPartition/WorldPartitionBuilder.h" + +#include "WorldMapBuilder.generated.h" + +class AOrthoMapCaptureActor; +class UTexture2D; + +/** Which way round the world's two horizontal axes end up in the baked picture. + * + * None of this is worked out from the capture rotation. The builder asks the settings actor's own + * WorldToBakedMapUV where a step along each world axis lands, because that mapping is what the game will use + * to turn a world position into a map position, and a bake that arranged its tiles any other way would + * disagree with it. A negative move means that axis is mirrored in the picture, which the stitch has to undo + * while it copies the tile's own pixels in. + */ +struct FWorldMapOrientation +{ + /** True when the picture's U runs along world X, false when it runs along world Y. */ + bool bUFollowsX = true; + + /** True when the picture's V runs along world Y, false when it runs along world X. */ + bool bVFollowsY = true; + + /** True when U grows towards smaller world X or Y, which is what the actor's bFlipU does to the mapping. */ + bool bMirrorU = false; + + /** True when V grows towards smaller world Y or X, which is what the actor's bFlipV does to the mapping. */ + bool bMirrorV = false; +}; + +/** + * Bakes one world map texture for a world partition world, by capturing the region a tile at a time and + * stitching the tiles back into a single asset. + * + * Run it as a commandlet: + * + * UnrealEditor-Cmd.exe -run=WorldPartitionBuilderCommandlet + * -Builder=/Script/ProjectEleriEditor.WorldMapBuilder -AllowCommandletRendering -SCCProvider=None + * /Game/Maps/Persistent_GameWorld -log + * + * The region, the resolution and the flips all live on the AOrthoMapCaptureActor in the map rather than on this + * builder, so that the picture that gets baked and the mapping the game reads map positions back through are + * the same numbers read twice rather than two settings that have to be kept in step by hand. + */ +UCLASS(MinimalAPI) +class UWorldMapBuilder : public UWorldPartitionBuilder +{ + GENERATED_BODY() + +public: + /** Nothing can be captured without a renderer, and the engine insists a commandlet be told so. */ + virtual bool RequiresCommandletRendering() const override { return true; } + + /** One capture per tile: the framework loads each tile's own region and unloads it again afterwards, which + * is what keeps a map of a world partition world inside a sane memory budget rather than loading all of + * it at once. */ + virtual ELoadingMode GetLoadingMode() const override { return ELoadingMode::IterativeCells2D; } + +protected: + virtual bool PreRun(UWorld* World, FPackageSourceControlHelper& PackageHelper) override; + virtual bool RunInternal(UWorld* World, const FCellInfo& InCellInfo, FPackageSourceControlHelper& PackageHelper) override; + virtual bool PostRun(UWorld* World, FPackageSourceControlHelper& PackageHelper, const bool bInRunSuccess) override; + virtual bool ShouldSkipCell(const FWorldBuilderCellCoord& CellCoord) const override; + +private: + /** Finds the settings actor and works the grid out from it, including handing the framework the cell size + * and bounds that make a cell index a tile index. Reports why not when the settings cannot be baked. */ + bool ReadSettings(UWorld* World, FText& OutReason); + + /** Cuts the region the settings actor holds into the piece grid a piece run asks for, and points the actor at + * the one piece this run is to bake. Reads -WorldMapPiece=, and -WorldMapPieceGrid=, and + * refuses anything about the pair, or about the region they are cut from, that would not give pieces tiling + * that region exactly. Reports why not. */ + bool DerivePieceRegion(const FString& PieceIndexArgument, FText& OutReason); + + /** Asks the mapping which way round the world axes land in the baked picture. Fails when both picture + * directions turn out to follow the same world axis, which would put two tiles in one square of the map. */ + bool DeriveOrientation(const AOrthoMapCaptureActor& Settings); + + /** The tile's position in the map's own grid of tiles: X is the tile index along the picture's U and Y the + * index along its V, whichever world axes those turn out to be. */ + FIntPoint GetTileIndices(int32 CellX, int32 CellY) const; + + /** Where a tile's pixels belong in the baked texture, flips and all. */ + FIntPoint GetTilePixelOrigin(const FIntPoint& TileIndices) const; + + /** Checks that the tile's four world corners land inside this tile's own square of the map, which is what + * says the grid and the mapping agree, before anything expensive is rendered. */ + bool CheckTileCoverage(const FBox& TileBounds, const FIntPoint& PixelOrigin) const; + + /** The world region one tile covers, worked out from the grid rather than from the cell the framework + * reported, so that what is captured can be checked against what the settings say before it is captured. + * Z is the map's own Z range, which only sets the height the capture looks down from. */ + FBox GetTileWorldBounds(int64 CellX, int64 CellY) const; + + /** Opens the package the baked map is saved into, creates the texture inside it or takes over the one a + * previous bake left there, and locks that texture's only mip so the tiles have somewhere to be stitched + * as they arrive. Until this has succeeded there is nothing to bake into. */ + bool OpenDestinationTexture(UWorld* World, FText& OutReason); + + /** The texture every capture renders into, made on the first tile of the run and reused for the rest of it. + * The capture helper re-initialises whatever texture it is handed, and a map's worth of dead sixteen + * megabyte textures is a gigabyte the transient package cannot give back in the middle of a bake. */ + UTexture2D* GetTileTexture(); + + /** Copies one captured tile's pixels into the square of the map that tile owns, undoing whichever of the + * two flips the settings asked for while it copies. */ + bool StitchTile(UTexture2D* InTileTexture, const FIntPoint& PixelOrigin); + + /** Counts the pixels of a captured tile that have anything in them, which is how a tile that came back + * empty is told apart from one that simply holds nothing but sky. */ + static int32 CountVisiblePixels(UTexture2D* TileTexture); + + /** Writes a locked BGRA8 buffer out as an image under the project's intermediate files, which is what + * -DebugCapture is for: a bitmap per tile, and a PNG for the whole stitched map, since sixteen thousand + * pixels square is a gigabyte of bitmap and a few tens of megabytes of PNG. */ + void SaveDebugImage(const FString& InFileName, const uint8* Pixels, int32 Width, int32 Height, bool bAsPng) const; + + /** Gives the baked texture the settings a world map asset needs, saves the package, and reports the file + * to the framework. The last thing a successful bake does. */ + bool SaveBakedMap(FPackageSourceControlHelper& PackageHelper); + + /** Puts the actor's own settings back after a run that overrode them. Harmless to call when nothing was + * overridden. */ + void RestoreOverriddenSettings(); + + /** The actor the settings come from, kept referenced so that a collection while the builder runs cannot + * leave this dangling. The numbers below are what a run actually uses. */ + UPROPERTY() + TObjectPtr SettingsActor; + + FBox MapBounds = FBox(ForceInit); + int64 GridOriginX = 0; + int64 GridOriginY = 0; + int32 TilesPerAxis = 1; + int32 MapTextureSize = 0; + int32 TilePixels = 0; + double TileWorldSize = 0.0; + FWorldMapOrientation Orientation; + + /** Tiles walked so far and when the walk started, so that a run can say how it went at the end. */ + int32 WalkedTileCount = 0; + double RunStartTime = 0.0; + + /** The actor's own settings before any -MapTextureSize, -TilesPerAxis or region alignment override, and + * whether they were changed. An override stays in place for the whole run, because the mapping the + * coverage checks go through reads the actor's bounds, so putting them back happens in PostRun. */ + bool bOverrodeSettings = false; + int32 OriginalMapTextureSize = 0; + int32 OriginalTilesPerAxis = 0; + FBox OriginalWorldMapBounds = FBox(ForceInit); + + //----- A bake of one piece of the region ----- + + /** Whether this run bakes one piece of the region rather than the whole of it. A piece is a window onto the + * region the actor holds, so its own box takes that region's place on the actor for the length of the run + * and everything below reads it as the region without knowing the difference. */ + bool bPieceRun = false; + + /** How many pieces a side the region is cut into, and which of them this run bakes. Four a side is the + * default because that is how the in game map pages the island: the island stands where the middle four + * pieces meet, and a UI that pages those four together shows all of it. + * + * The pieces are numbered by world axes rather than by the picture: the column runs along +X and the row + * along +Y from the region's own min corner, so a piece's index says where it is in the world and does not + * move when the map's own flips do. */ + int32 PieceColumn = 0; + int32 PieceRow = 0; + int32 PieceGrid = 4; + + /** The region the pieces are cut out of. Kept because the piece's box takes that region's place on the actor + * for the length of the run, and a log line that could not say what a piece is a piece of would be a log + * line about a region nobody asked for. */ + FBox PieceParentBounds = FBox(ForceInit); + + //----- The asset being baked ----- + + /** Name of the map the settings were read from, which is what the baked asset is named after. */ + FString MapName; + + /** Where the baked map goes, held on the builder for the length of the run: the package, the texture + * inside it, and the pixels of that texture's only mip. The mip is locked once for the whole walk rather + * than once per tile, so the lock opens in PreRun and closes in PostRun whatever happens in between: a + * lock left open would leave the texture unwritable for anyone who opened it next. */ + UPROPERTY() + TObjectPtr DestinationPackage; + + UPROPERTY() + TObjectPtr DestinationTexture; + + uint8* DestinationPixels = nullptr; + FString DestinationAssetName; + FString DestinationPackageName; + FString DestinationPackageFileName; + + /** The texture a capture renders into, held across the whole walk rather than made per tile. It is a + * scratch buffer of one tile's pixels and nothing else: it is never saved, never registered, and lives in + * the transient package. A plain pointer rather than a TObjectPtr because the capture helper takes the + * texture by reference and may hand a different one back, which is a raw pointer the reference binds to and + * a TObjectPtr is not. */ + UPROPERTY() + UTexture2D* TileTexture = nullptr; + + /** Whether -DebugCapture was given, in which case every tile and the stitched map are written out as + * images as well. A bake costs a capture per tile either way; this only adds files to look at. */ + bool bDebugCapture = false; + + /** Tiles that came back with nothing visible in them. A map is allowed to be mostly empty, but a tile + * that rendered nothing at all is usually a setting rather than a stretch of landscape, and this count + * is the one number that says which kind of bake this was. */ + int32 EmptyTileCount = 0; +}; diff --git a/Source/ProjectEleriEditor/ProjectEleriEditor.Build.cs b/Source/ProjectEleriEditor/ProjectEleriEditor.Build.cs index 6e6e86d4..3bc1b10f 100644 --- a/Source/ProjectEleriEditor/ProjectEleriEditor.Build.cs +++ b/Source/ProjectEleriEditor/ProjectEleriEditor.Build.cs @@ -22,6 +22,10 @@ public class ProjectEleriEditor : ModuleRules "ToolMenus", "AssetRegistry", "ContentBrowser", + // The world map builder drives a scene capture and reads the render target back on the CPU, which + // is where the render thread and the pixel formats it copies between come in. + "RenderCore", + "RHI", "ProjectEleri"}); } } \ No newline at end of file