Aollero/river preset generator (#5650)
* First steps on river generation * Region of interest base sckeleton * Vegetation ROIs back logic implementation * Region of interest for vegetation integrated into widget * Some more improvements in River generation * ROI selection clicking on preiew heightmap * ROIs visual preview selection and support for many Rois * Persistent widget state * Widget bugs fixed * Soil tab * Landscape smooth tool and widget init bug fixed * Weather tab finished and some river generation progress * Flatening tiles that contains rivers * Widget updates * Missing references * Deleted unnecessary assets * Some progresses on Rivers but not fully working * Terrain ROIs Widget adaptations * First steps on Terrain ROIs * Format fixed
This commit is contained in:
parent
5daeb4d63d
commit
9bb8f41f3e
|
@ -130,6 +130,7 @@ void ACarlaGameModeBase::InitGame(
|
|||
UGameplayStatics::GetActorOfClass(GetWorld(), AWeather::StaticClass());
|
||||
if (WeatherActor != nullptr) {
|
||||
UE_LOG(LogCarla, Log, TEXT("Existing weather actor. Doing nothing then!"));
|
||||
Episode->Weather = static_cast<AWeather*>(WeatherActor);
|
||||
}
|
||||
else if (WeatherClass != nullptr) {
|
||||
Episode->Weather = World->SpawnActor<AWeather>(WeatherClass);
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"isPersistentState": true,
|
||||
"mapName": "",
|
||||
"workingPath": "/Game/MyMaps",
|
||||
"activeTabName": "",
|
||||
"terrainGeneralSize": 4,
|
||||
"terrainGeneralSlope": 0.5,
|
||||
"terrainGeneralHeight": 3,
|
||||
"terrainGeneralMinHeight": 0,
|
||||
"terrainGeneralMaxHeight": 1,
|
||||
"terrainGeneralInvert": 0,
|
||||
"terrainOverallSeed": 700,
|
||||
"terrainOverallScale": 26,
|
||||
"terrainOverallSlope": 2.5,
|
||||
"terrainOverallHeight": 1,
|
||||
"terrainOverallMinHeight": 0,
|
||||
"terrainOverallMaxHeight": 1,
|
||||
"terrainOverallInvert": 0,
|
||||
"terrainDetailedSeed": 4300,
|
||||
"terrainDetailedScale": 10,
|
||||
"terrainDetailedSlope": 1,
|
||||
"terrainDetailedHeight": 2,
|
||||
"terrainDetailedMinHeight": 0,
|
||||
"terrainDetailedMaxHeight": 1,
|
||||
"terrainDetailedInvert": 0
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"isPersistentState": true,
|
||||
"mapName": "",
|
||||
"workingPath": "/Game/MyMaps",
|
||||
"activeTabName": "",
|
||||
"terrainGeneralSize": 4,
|
||||
"terrainGeneralSlope": 0.5,
|
||||
"terrainGeneralHeight": 3,
|
||||
"terrainGeneralMinHeight": 0,
|
||||
"terrainGeneralMaxHeight": 1,
|
||||
"terrainGeneralInvert": 0,
|
||||
"terrainOverallSeed": 700,
|
||||
"terrainOverallScale": 26,
|
||||
"terrainOverallSlope": 2.5,
|
||||
"terrainOverallHeight": 1,
|
||||
"terrainOverallMinHeight": 0,
|
||||
"terrainOverallMaxHeight": 1,
|
||||
"terrainOverallInvert": 0,
|
||||
"terrainDetailedSeed": 4300,
|
||||
"terrainDetailedScale": 10,
|
||||
"terrainDetailedSlope": 1,
|
||||
"terrainDetailedHeight": 2,
|
||||
"terrainDetailedMinHeight": 0,
|
||||
"terrainDetailedMaxHeight": 1,
|
||||
"terrainDetailedInvert": 0
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -60,7 +60,9 @@ public class CarlaTools : ModuleRules
|
|||
"Foliage",
|
||||
"FoliageEdit",
|
||||
"Carla",
|
||||
"PhysXVehicles"
|
||||
"PhysXVehicles",
|
||||
"Json",
|
||||
"JsonUtilities"
|
||||
// ... add private dependencies that you statically link with here ...
|
||||
}
|
||||
);
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
#include "ActorFactories/ActorFactory.h"
|
||||
#include "AssetRegistryModule.h"
|
||||
#include "Carla/MapGen/LargeMapManager.h"
|
||||
#include "Carla/Weather/Weather.h"
|
||||
#include "Components/SplineComponent.h"
|
||||
#include "Editor/FoliageEdit/Public/FoliageEdMode.h"
|
||||
#include "EditorLevelLibrary.h"
|
||||
|
@ -19,6 +20,8 @@
|
|||
#include "Kismet/KismetMathLibrary.h"
|
||||
#include "Landscape.h"
|
||||
#include "LandscapeProxy.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/CString.h"
|
||||
#include "ProceduralFoliageComponent.h"
|
||||
#include "ProceduralFoliageVolume.h"
|
||||
#include "Runtime/Engine/Classes/Engine/ObjectLibrary.h"
|
||||
|
@ -31,6 +34,11 @@
|
|||
#include "UObject/UObjectGlobals.h"
|
||||
#include "UObject/ObjectMacros.h"
|
||||
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "JsonObjectConverter.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonReader.h"
|
||||
|
||||
#define CUR_CLASS_FUNC (FString(__FUNCTION__))
|
||||
#define CUR_LINE (FString::FromInt(__LINE__))
|
||||
#define CUR_CLASS_FUNC_LINE (CUR_CLASS_FUNC + "::" + CUR_LINE)
|
||||
|
@ -219,9 +227,14 @@ AActor* UMapGeneratorWidget::GenerateWater(TSubclassOf<class AActor> RiverClass)
|
|||
|
||||
UWorld* World = GetWorld();
|
||||
|
||||
float ActorZCoord = GetLandscapeSurfaceHeight(World, 0, 0, false);
|
||||
FVector Location(20000, 20000, ActorZCoord); // Auxiliar values for x and y coords
|
||||
FRotator Rotation(0,0,0);
|
||||
float XCoord = 2000;
|
||||
float YCoord = 2000;
|
||||
|
||||
float ZRot = 50;
|
||||
|
||||
float ActorZCoord = GetLandscapeSurfaceHeight(World, XCoord, YCoord, false);
|
||||
FVector Location(XCoord, YCoord, ActorZCoord+5); // Auxiliar values for x and y coords
|
||||
FRotator Rotation(0, ZRot, 0);
|
||||
FActorSpawnParameters SpawnInfo;
|
||||
|
||||
|
||||
|
@ -245,6 +258,68 @@ AActor* UMapGeneratorWidget::GenerateWater(TSubclassOf<class AActor> RiverClass)
|
|||
return RiverActor;
|
||||
}
|
||||
|
||||
bool UMapGeneratorWidget::GenerateWaterFromWorld(UWorld* RiversWorld, TSubclassOf<class AActor> RiverClass)
|
||||
{
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Log, TEXT("%s: Starting Generating Rivers from world %s"),
|
||||
*CUR_CLASS_FUNC_LINE, *RiversWorld->GetMapName());
|
||||
|
||||
TArray<AActor*> RiversActors;
|
||||
UGameplayStatics::GetAllActorsOfClass(RiversWorld, RiverClass, RiversActors);
|
||||
|
||||
if(RiversActors.Num() <= 0)
|
||||
{
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Error, TEXT("%s: No Rivers Found in %s"),
|
||||
*CUR_CLASS_FUNC_LINE, *RiversWorld->GetMapName());
|
||||
return false;
|
||||
}
|
||||
|
||||
float RiverSurfaceDisplacement = 100.0f;
|
||||
for(AActor* RiverActor : RiversActors)
|
||||
{
|
||||
USplineComponent* RiverSpline = dynamic_cast<USplineComponent*>(RiverActor->GetComponentByClass(USplineComponent::StaticClass()));
|
||||
for(int i = 0; i < RiverSpline->GetNumberOfSplinePoints(); i++)
|
||||
{
|
||||
FVector SplinePosition = RiverSpline->GetWorldLocationAtSplinePoint(i);
|
||||
SplinePosition.Z = GetLandscapeSurfaceHeight(RiversWorld, SplinePosition.X, SplinePosition.Y, false) + RiverSurfaceDisplacement;
|
||||
RiverSpline->SetWorldLocationAtSplinePoint(i, SplinePosition);
|
||||
}
|
||||
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
UWorld* UMapGeneratorWidget::DuplicateWorld(FString BaseWorldPath, FString TargetWorldPath, const FString NewWorldName)
|
||||
{
|
||||
UWorld* DuplicateWorld;
|
||||
|
||||
UWorld* BaseWorld = LoadObject<UWorld>(nullptr, *BaseWorldPath);
|
||||
if(BaseWorld == nullptr)
|
||||
{
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Error, TEXT("%s: No World Found in %s"),
|
||||
*CUR_CLASS_FUNC_LINE, *BaseWorldPath);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const FString PackageName = TargetWorldPath + "/" + NewWorldName;
|
||||
UPackage* WorldPackage = CreatePackage(*PackageName);
|
||||
|
||||
FObjectDuplicationParameters Parameters(BaseWorld, WorldPackage);
|
||||
Parameters.DestName = FName(*NewWorldName);
|
||||
Parameters.DestClass = BaseWorld->GetClass();
|
||||
Parameters.DuplicateMode = EDuplicateMode::World;
|
||||
Parameters.PortFlags = PPF_Duplicate;
|
||||
|
||||
DuplicateWorld = CastChecked<UWorld>(StaticDuplicateObjectEx(Parameters));
|
||||
|
||||
const FString PackageFileName = FPackageName::LongPackageNameToFilename(
|
||||
PackageName,
|
||||
FPackageName::GetMapPackageExtension());
|
||||
UPackage::SavePackage(WorldPackage, DuplicateWorld, EObjectFlags::RF_Public | EObjectFlags::RF_Standalone,
|
||||
*PackageFileName, GError, nullptr, true, true, SAVE_NoError);
|
||||
|
||||
return DuplicateWorld;
|
||||
}
|
||||
|
||||
AActor* UMapGeneratorWidget::AddWeatherToExistingMap(TSubclassOf<class AActor> WeatherActorClass,
|
||||
const FMapGeneratorMetaInfo& MetaInfo, const FString SelectedWeather)
|
||||
{
|
||||
|
@ -268,12 +343,112 @@ AActor* UMapGeneratorWidget::AddWeatherToExistingMap(TSubclassOf<class AActor> W
|
|||
const FString WorldToLoadPath = MapCompletePath + "." + MetaInfo.MapName;
|
||||
UWorld* World = LoadObject<UWorld>(nullptr, *WorldToLoadPath);
|
||||
|
||||
AActor* WeatherActor = World->SpawnActor<AActor>(WeatherActorClass);
|
||||
AActor* WeatherActor = UGameplayStatics::GetActorOfClass(World, AWeather::StaticClass());
|
||||
|
||||
if(WeatherActor == nullptr)
|
||||
{
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Log, TEXT("%s: Creating a new weather actor to world"),
|
||||
*CUR_CLASS_FUNC_LINE);
|
||||
|
||||
WeatherActor = World->SpawnActor<AActor>(WeatherActorClass);
|
||||
}
|
||||
|
||||
return WeatherActor;
|
||||
|
||||
}
|
||||
|
||||
TMap<FRoiTile, FVegetationROI> UMapGeneratorWidget::CreateVegetationRoisMap(TArray<FVegetationROI> VegetationRoisArray)
|
||||
{
|
||||
TMap<FRoiTile, FVegetationROI> ResultMap;
|
||||
for(FVegetationROI VegetationRoi : VegetationRoisArray)
|
||||
{
|
||||
for(FRoiTile VegetationRoiTile : VegetationRoi.TilesList)
|
||||
{
|
||||
ResultMap.Add(VegetationRoiTile, VegetationRoi);
|
||||
}
|
||||
}
|
||||
return ResultMap;
|
||||
}
|
||||
|
||||
TMap<FRoiTile, FTerrainROI> UMapGeneratorWidget::CreateTerrainRoisMap(TArray<FTerrainROI> TerrainRoisArray)
|
||||
{
|
||||
TMap<FRoiTile, FTerrainROI> ResultMap;
|
||||
for(FTerrainROI TerrainRoi : TerrainRoisArray)
|
||||
{
|
||||
for(FRoiTile TerrainRoiTile : TerrainRoi.TilesList)
|
||||
{
|
||||
ResultMap.Add(TerrainRoiTile, TerrainRoi);
|
||||
}
|
||||
}
|
||||
return ResultMap;
|
||||
}
|
||||
|
||||
bool UMapGeneratorWidget::DeleteAllVegetationInMap(const FString Path, const FString MapName)
|
||||
{
|
||||
TArray<FAssetData> AssetsData;
|
||||
const FString TilesPath = Path;
|
||||
bool success = LoadWorlds(AssetsData, TilesPath);
|
||||
if(!success || AssetsData.Num() <= 0)
|
||||
{
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Error, TEXT("No Tiles found in %s. Vegetation cooking Aborted!"), *TilesPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cook vegetation for each of the maps
|
||||
for(FAssetData AssetData : AssetsData)
|
||||
{
|
||||
UWorld* World = GetWorldFromAssetData(AssetData);
|
||||
TArray<AActor*> FoliageVolumeActors;
|
||||
UGameplayStatics::GetAllActorsOfClass(World, AProceduralFoliageVolume::StaticClass(), FoliageVolumeActors);
|
||||
for(AActor* FoliageActor : FoliageVolumeActors)
|
||||
{
|
||||
FoliageActor->Destroy();
|
||||
}
|
||||
|
||||
SaveWorld(World);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UMapGeneratorWidget::GenerateWidgetStateFileFromStruct(FMapGeneratorWidgetState WidgetState, const FString JsonPath)
|
||||
{
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Log, TEXT("%s: Creating Widget State JSON"),
|
||||
*CUR_CLASS_FUNC_LINE);
|
||||
|
||||
TSharedRef<FJsonObject> OutJsonObject = MakeShareable(new FJsonObject());
|
||||
FJsonObjectConverter::UStructToJsonObject(FMapGeneratorWidgetState::StaticStruct(), &WidgetState, OutJsonObject, 0, 0);
|
||||
|
||||
FString OutputJsonString;
|
||||
TSharedRef<TJsonWriter<>> JsonWriter = TJsonWriterFactory<>::Create(&OutputJsonString);
|
||||
FJsonSerializer::Serialize(OutJsonObject, JsonWriter);
|
||||
|
||||
FFileHelper::SaveStringToFile(OutputJsonString, *JsonPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
FMapGeneratorWidgetState UMapGeneratorWidget::LoadWidgetStateStructFromFile(const FString JsonPath)
|
||||
{
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Log, TEXT("%s: Creating Widget State Struct from JSON"),
|
||||
*CUR_CLASS_FUNC_LINE);
|
||||
|
||||
FMapGeneratorWidgetState WidgetState;
|
||||
FString File;
|
||||
FFileHelper::LoadFileToString(File, *JsonPath);
|
||||
|
||||
TSharedRef<TJsonReader<TCHAR>> JsonReader = TJsonReaderFactory<TCHAR>::Create(*File);
|
||||
TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject);
|
||||
bool bDeserializeSuccess = FJsonSerializer::Deserialize(JsonReader, JsonObject, FJsonSerializer::EFlags::None);
|
||||
|
||||
if (bDeserializeSuccess && JsonObject.IsValid())
|
||||
{
|
||||
FJsonObjectConverter::JsonObjectToUStruct(JsonObject.ToSharedRef(), FMapGeneratorWidgetState::StaticStruct(), &WidgetState, 1, 0);
|
||||
}
|
||||
|
||||
return WidgetState;
|
||||
}
|
||||
|
||||
bool UMapGeneratorWidget::LoadWorlds(TArray<FAssetData>& WorldAssetsData, const FString& BaseMapPath, bool bRecursive)
|
||||
{
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Log, TEXT("%s: Loading Worlds from %s"),
|
||||
|
@ -442,8 +617,26 @@ bool UMapGeneratorWidget::CreateTilesMaps(const FMapGeneratorMetaInfo& MetaInfo)
|
|||
UE_LOG(LogCarlaToolsMapGenerator, Warning, TEXT("%s: Heightmap detected with dimensions %dx%d"),
|
||||
*CUR_CLASS_FUNC_LINE, HeightRT->SizeX, HeightRT->SizeY);
|
||||
TArray<uint16> HeightData;
|
||||
// TODO: UTexture2D and GetMipData
|
||||
UpdateTileRT(i, MetaInfo.SizeY-j-1);
|
||||
|
||||
FMapGeneratorTileMetaInfo TileMetaInfo;
|
||||
TileMetaInfo.IndexX = i;
|
||||
TileMetaInfo.IndexY = MetaInfo.SizeY-j-1;
|
||||
TileMetaInfo.MapMetaInfo = MetaInfo;
|
||||
|
||||
|
||||
// River Management
|
||||
if(FMath::RandRange(0.0f, 100.0f) < MetaInfo.RiverChanceFactor)
|
||||
{
|
||||
TileMetaInfo.ContainsRiver = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
TileMetaInfo.ContainsRiver = false;
|
||||
}
|
||||
|
||||
// Update and get heightmap from texture
|
||||
UpdateTileRT(TileMetaInfo);
|
||||
|
||||
FTextureRenderTargetResource* RenderTargetResource = HeightRT->GameThread_GetRenderTargetResource();
|
||||
FIntRect Rect = FIntRect(0, 0, HeightRT->SizeX, HeightRT->SizeY);
|
||||
TArray<FLinearColor> HeightmapColor;
|
||||
|
@ -457,6 +650,106 @@ bool UMapGeneratorWidget::CreateTilesMaps(const FMapGeneratorMetaInfo& MetaInfo)
|
|||
HeightData.Add((uint16)(LinearColor.R * 255 * 255 + LinearColor.G * 255));
|
||||
}
|
||||
|
||||
// Terrain ROI
|
||||
FRoiTile ThisTileIndex(i, j);
|
||||
if(FRegionOfInterest::IsTileInRegionsSet(ThisTileIndex, MetaInfo.TerrainRoisMap))
|
||||
{
|
||||
FTerrainROI TileRegion = MetaInfo.TerrainRoisMap[ThisTileIndex];
|
||||
|
||||
// Update ROI RT with ROI material
|
||||
UpdateTileRoiRT(TileMetaInfo, TileRegion.RoiMaterialInstance);
|
||||
|
||||
UTextureRenderTarget2D* RoiHeightRT = TileRegion.RoiHeightmapRenderTarget;
|
||||
TArray<uint16> RoiHeightData;
|
||||
FTextureRenderTargetResource* RoiRenderTargetResource = RoiHeightRT->GameThread_GetRenderTargetResource();
|
||||
FIntRect RoiRect = FIntRect(0, 0, RoiHeightRT->SizeX, RoiHeightRT->SizeY);
|
||||
TArray<FLinearColor> RoiHeightmapColor;
|
||||
RoiHeightmapColor.Reserve(RoiRect.Width() * RoiRect.Height());
|
||||
RoiRenderTargetResource->ReadLinearColorPixels(RoiHeightmapColor, FReadSurfaceDataFlags(RCM_MinMax, CubeFace_MAX), RoiRect);
|
||||
RoiHeightData.Reserve(RoiHeightmapColor.Num());
|
||||
|
||||
for(FLinearColor RoiLinearColor : RoiHeightmapColor)
|
||||
{
|
||||
RoiHeightData.Add((uint16)(RoiLinearColor.R * 255 * 255 + RoiLinearColor.G * 255));
|
||||
}
|
||||
|
||||
|
||||
int FlateningMargin = 30; // Not flatened
|
||||
int FlateningFalloff = 100; // Transition from actual and flat value
|
||||
int TileSize = 1009; // Should be calculated by sqrt(HeightData.Num())
|
||||
|
||||
for(int X = FlateningMargin; X < (TileSize - FlateningMargin); X++)
|
||||
{
|
||||
for(int Y = FlateningMargin; Y < (TileSize - FlateningMargin); Y++)
|
||||
{
|
||||
float TransitionFactor = 1.0f;
|
||||
|
||||
if(X < (FlateningMargin + FlateningFalloff))
|
||||
{
|
||||
TransitionFactor *= (X - FlateningMargin) / (float) FlateningFalloff;
|
||||
}
|
||||
if(Y < (FlateningMargin + FlateningFalloff))
|
||||
{
|
||||
TransitionFactor *= (Y - FlateningMargin) / (float) FlateningFalloff;
|
||||
}
|
||||
if(X > (TileSize - FlateningMargin - FlateningFalloff))
|
||||
{
|
||||
TransitionFactor *= 1 - ((X - (TileSize - FlateningMargin - FlateningFalloff)) / (float) FlateningFalloff);
|
||||
}
|
||||
if(Y > (TileSize - FlateningMargin - FlateningFalloff))
|
||||
{
|
||||
TransitionFactor *= 1 - ((Y - (TileSize - FlateningMargin - FlateningFalloff)) / (float) FlateningFalloff);
|
||||
}
|
||||
HeightData[(X * TileSize) + Y] = (RoiHeightData[(X * TileSize) + Y]) * TransitionFactor + HeightData[(X * TileSize) + Y] * (1-TransitionFactor);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Flatening if contains river
|
||||
// TODO: Move this if to a function
|
||||
// TODO: Check and fix flatening algorithm
|
||||
if(TileMetaInfo.ContainsRiver)
|
||||
{
|
||||
int FlateningMargin = 30; // Not flatened
|
||||
int FlateningFalloff = 100; // Transition from actual and flat value
|
||||
int TileSize = 1009; // Should be calculated by sqrt(HeightData.Num())
|
||||
|
||||
for(int X = FlateningMargin; X < (TileSize - FlateningMargin); X++)
|
||||
{
|
||||
for(int Y = FlateningMargin; Y < (TileSize - FlateningMargin); Y++)
|
||||
{
|
||||
float TransitionFactor = 1.0f;
|
||||
|
||||
if(X < (FlateningMargin + FlateningFalloff))
|
||||
{
|
||||
TransitionFactor *= (X - FlateningMargin) / (float) FlateningFalloff;
|
||||
}
|
||||
if(Y < (FlateningMargin + FlateningFalloff))
|
||||
{
|
||||
TransitionFactor *= (Y - FlateningMargin) / (float) FlateningFalloff;
|
||||
}
|
||||
if(X > (TileSize - FlateningMargin - FlateningFalloff))
|
||||
{
|
||||
TransitionFactor *= 1 - ((X - (TileSize - FlateningMargin - FlateningFalloff)) / (float) FlateningFalloff);
|
||||
}
|
||||
if(Y > (TileSize - FlateningMargin - FlateningFalloff))
|
||||
{
|
||||
TransitionFactor *= 1 - ((Y - (TileSize - FlateningMargin - FlateningFalloff)) / (float) FlateningFalloff);
|
||||
}
|
||||
|
||||
// HeightData[(X * TileSize) + Y] = (HeightData[(X * TileSize) + Y] * MetaInfo.RiverFlateningFactor) * TransitionFactor + HeightData[(X * TileSize) + Y] * (1-TransitionFactor);
|
||||
}
|
||||
}
|
||||
DuplicateWorld("/CarlaTools/MapGenerator/Rivers/RiverPresets/River01/RiverPreset01.RiverPreset01",
|
||||
MetaInfo.DestinationPath + "/Rivers", MapName + "_River");
|
||||
}
|
||||
|
||||
// Smooth process
|
||||
TArray<uint16> SmoothedData;
|
||||
SmoothHeightmap(HeightData, SmoothedData);
|
||||
HeightData = SmoothedData;
|
||||
|
||||
FVector LandscapeScaleVector(100.0f, 100.0f, 100.0f);
|
||||
Landscape->CreateLandscapeInfo();
|
||||
Landscape->SetActorTransform(FTransform(FQuat::Identity, FVector(), LandscapeScaleVector));
|
||||
|
@ -492,6 +785,8 @@ bool UMapGeneratorWidget::CreateTilesMaps(const FMapGeneratorMetaInfo& MetaInfo)
|
|||
*CUR_CLASS_FUNC_LINE, *ErrorUnloadingStr.ToString());
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Instantiate water if needed
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -546,8 +841,24 @@ bool UMapGeneratorWidget::CookVegetationToTiles(const FMapGeneratorMetaInfo& Met
|
|||
return false;
|
||||
}
|
||||
|
||||
// ROI checks
|
||||
int TileIndexX, TileIndexY;
|
||||
ExtractCoordinatedFromMapName(World->GetMapName(), TileIndexX, TileIndexY);
|
||||
|
||||
FRoiTile ThisTileIndex(TileIndexX, TileIndexY);
|
||||
TArray<UProceduralFoliageSpawner*> FoliageSpawnersToCook;
|
||||
if(FRegionOfInterest::IsTileInRegionsSet(ThisTileIndex, MetaInfo.VegetationRoisMap))
|
||||
{
|
||||
FVegetationROI TileRegion = MetaInfo.VegetationRoisMap[ThisTileIndex];
|
||||
FoliageSpawnersToCook = TileRegion.GetFoliageSpawners();
|
||||
}
|
||||
else
|
||||
{
|
||||
FoliageSpawnersToCook = MetaInfo.FoliageSpawners;
|
||||
}
|
||||
|
||||
// Cook vegetation to world
|
||||
bool bVegetationSuccess = CookVegetationToWorld(World, MetaInfo.FoliageSpawners);
|
||||
bool bVegetationSuccess = CookVegetationToWorld(World, FoliageSpawnersToCook);
|
||||
if(!bVegetationSuccess){
|
||||
UE_LOG(LogCarlaToolsMapGenerator, Error, TEXT("%s: Error Cooking Vegetation in %s"),
|
||||
*CUR_CLASS_FUNC_LINE, *MapNameToLoad);
|
||||
|
@ -629,39 +940,73 @@ float UMapGeneratorWidget::GetLandscapeSurfaceHeight(UWorld* World, float x, flo
|
|||
{
|
||||
if(World)
|
||||
{
|
||||
FVector RayStartingPoint(x, y, 999999);
|
||||
FVector RayEndPoint(x, y, -999999);
|
||||
|
||||
// Raytrace
|
||||
FHitResult HitResult;
|
||||
World->LineTraceSingleByObjectType(
|
||||
OUT HitResult,
|
||||
RayStartingPoint,
|
||||
RayEndPoint,
|
||||
FCollisionObjectQueryParams(ECollisionChannel::ECC_WorldStatic),
|
||||
FCollisionQueryParams());
|
||||
|
||||
// Draw debug line.
|
||||
if (bDrawDebugLines)
|
||||
{
|
||||
FColor LineColor;
|
||||
|
||||
if (HitResult.GetActor()) LineColor = FColor::Red;
|
||||
else LineColor = FColor::Green;
|
||||
|
||||
DrawDebugLine(
|
||||
World,
|
||||
RayStartingPoint,
|
||||
RayEndPoint,
|
||||
LineColor,
|
||||
true,
|
||||
5.f,
|
||||
0.f,
|
||||
10.f);
|
||||
}
|
||||
|
||||
// Return Z Location.
|
||||
if (HitResult.GetActor()) return HitResult.ImpactPoint.Z;
|
||||
ALandscape* Landscape = (ALandscape*) UGameplayStatics::GetActorOfClass(
|
||||
World,
|
||||
ALandscape::StaticClass());
|
||||
|
||||
FVector Location(x, y, 0);
|
||||
TOptional<float> Height = Landscape->GetHeightAtLocation(Location);
|
||||
// TODO: Change function return type to TOptional<float>
|
||||
return Height.IsSet() ? Height.GetValue() : 0.0f;
|
||||
}
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
void UMapGeneratorWidget::ExtractCoordinatedFromMapName(const FString MapName, int& X, int& Y)
|
||||
{
|
||||
FString Name, Coordinates;
|
||||
MapName.Split(TEXT("_Tile_"), &Name, &Coordinates);
|
||||
|
||||
FString XStr, YStr;
|
||||
Coordinates.Split(TEXT("_"), &XStr, &YStr);
|
||||
|
||||
X = FCString::Atoi(*XStr);
|
||||
Y = FCString::Atoi(*YStr);
|
||||
}
|
||||
|
||||
void UMapGeneratorWidget::SmoothHeightmap(TArray<uint16> HeightData, TArray<uint16>& OutHeightData)
|
||||
{
|
||||
TArray<uint16> SmoothedData(HeightData);
|
||||
|
||||
// Prepare Gaussian Kernel
|
||||
int KernelSize = 5;
|
||||
int KernelWeight = 273;
|
||||
float Kernel[] = {1, 4, 7, 4, 1,
|
||||
4, 16, 26, 16, 4,
|
||||
7, 26, 41, 26, 7,
|
||||
4, 16, 26, 16, 4,
|
||||
1, 4, 7, 4, 1};
|
||||
|
||||
TArray<float> SmoothKernel;
|
||||
for(int i = 0; i < KernelSize*KernelSize; i++)
|
||||
{
|
||||
SmoothKernel.Add(Kernel[i] / KernelWeight);
|
||||
}
|
||||
|
||||
// Apply kernel to height data
|
||||
int TileMargin = 2;
|
||||
int TileSize = 1009; // Should be calculated by sqrt(HeightData.Num())
|
||||
|
||||
for(int X = TileMargin; X < (TileSize - TileMargin); X++)
|
||||
{
|
||||
for(int Y = TileMargin; Y < (TileSize - TileMargin); Y++)
|
||||
{
|
||||
int Value = 0;
|
||||
|
||||
for(int i = -2; i <= 2; i++)
|
||||
{
|
||||
for(int j = -2; j <=2; j++)
|
||||
{
|
||||
float KernelValue = SmoothKernel[(i+2)*2 + (j+2)];
|
||||
int HeightValue = HeightData[ (X+i) * TileSize + (Y+j) ];
|
||||
|
||||
Value += (int) ( KernelValue * HeightValue );
|
||||
}
|
||||
}
|
||||
|
||||
SmoothedData[(X * TileSize) + Y] = Value;
|
||||
}
|
||||
}
|
||||
|
||||
OutHeightData = SmoothedData;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
#include "EditorUtilityWidget.h"
|
||||
#include "Engine/TextureRenderTarget2D.h"
|
||||
#include "ProceduralFoliageSpawner.h"
|
||||
#include "RegionOfInterest.h"
|
||||
#include "UnrealString.h"
|
||||
|
||||
#include "MapGeneratorWidget.generated.h"
|
||||
|
@ -40,6 +41,21 @@ struct CARLATOOLS_API FMapGeneratorMetaInfo
|
|||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
UTextureRenderTarget2D* GlobalHeightmap;
|
||||
|
||||
// UPROPERTY(BlueprintReadWrite)
|
||||
// UTextureRenderTarget2D* PROVISIONALROIHEIGHTMAP;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
TMap<FRoiTile, FTerrainROI> TerrainRoisMap;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
TMap<FRoiTile, FVegetationROI> VegetationRoisMap;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
float RiverChanceFactor;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
float RiverFlateningFactor;
|
||||
};
|
||||
|
||||
/// Struct used as container of basic tile information
|
||||
|
@ -59,6 +75,92 @@ struct CARLATOOLS_API FMapGeneratorTileMetaInfo
|
|||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
int IndexY;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
bool ContainsRiver;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
FString RiverPreset;
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct CARLATOOLS_API FMapGeneratorWidgetState
|
||||
{
|
||||
GENERATED_USTRUCT_BODY();
|
||||
|
||||
// General Fields
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
bool IsPersistentState;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
FString MapName;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
FString WorkingPath;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
FString ActiveTabName;
|
||||
|
||||
// Terrain
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainGeneralSize;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainGeneralSlope;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainGeneralHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainGeneralMinHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainGeneralMaxHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainGeneralInvert;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainOverallSeed;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainOverallScale;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainOverallSlope;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainOverallHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainOverallMinHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainOverallMaxHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainOverallInvert;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainDetailedSeed;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainDetailedScale;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainDetailedSlope;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainDetailedHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainDetailedMinHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainDetailedMaxHeight;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="MapGenerator|JsonLibrary")
|
||||
float TerrainDetailedInvert;
|
||||
};
|
||||
|
||||
/// Class UMapGeneratorWidget extends the functionality of UEditorUtilityWidget
|
||||
|
@ -74,9 +176,15 @@ public:
|
|||
UFUNCTION(BlueprintImplementableEvent)
|
||||
void AssignLandscapeMaterial(ALandscape* Landscape);
|
||||
|
||||
UFUNCTION(BlueprintImplementableEvent)
|
||||
void InstantiateRiverSublevel(UWorld* World, const FMapGeneratorTileMetaInfo TileMetaInfo);
|
||||
|
||||
/// PROVISIONAL
|
||||
UFUNCTION(BlueprintImplementableEvent)
|
||||
void UpdateTileRT(int OffsetX, int OffsetY);
|
||||
void UpdateTileRT(const FMapGeneratorTileMetaInfo& TileMetaInfo);
|
||||
|
||||
UFUNCTION(BlueprintImplementableEvent)
|
||||
void UpdateTileRoiRT(const FMapGeneratorTileMetaInfo& TileMetaInfo, UMaterialInstanceDynamic* RoiMeterialInstance);
|
||||
|
||||
/// Function called by Widget Blueprint which generates all tiles of map
|
||||
/// @a mapName, and saves them in @a destinationPath
|
||||
|
@ -118,12 +226,34 @@ public:
|
|||
UFUNCTION(Category="MapGenerator", BlueprintCallable)
|
||||
AActor* GenerateWater(TSubclassOf<class AActor> RiverClass);
|
||||
|
||||
UFUNCTION(Category="MapGenerator", BlueprintCallable)
|
||||
bool GenerateWaterFromWorld(UWorld* RiversWorld, TSubclassOf<class AActor> RiverClass);
|
||||
|
||||
UFUNCTION(Category="MapGenerator", BlueprintCallable)
|
||||
UWorld* DuplicateWorld(const FString BaseWorldPath, const FString TargetWorldPath, const FString NewWorldName);
|
||||
|
||||
/// Adds weather actor of type @a WeatherActorClass and sets the @a SelectedWeather
|
||||
/// to the map specified in @a MetaInfo
|
||||
/// to the map specified in @a MetaInfo. Ifthe actor already exists on the map
|
||||
/// then it is returned so only one weather actor is spawned in each map
|
||||
UFUNCTION(Category="MapGenerator", BlueprintCallable)
|
||||
AActor* AddWeatherToExistingMap(TSubclassOf<class AActor> WeatherActorClass,
|
||||
const FMapGeneratorMetaInfo& MetaInfo, const FString SelectedWeather);
|
||||
|
||||
UFUNCTION(Category="MapGenerator", BlueprintCallable)
|
||||
TMap<FRoiTile, FVegetationROI> CreateVegetationRoisMap(TArray<FVegetationROI> VegetationRoisArray);
|
||||
|
||||
UFUNCTION(Category="MapGenerator", BlueprintCallable)
|
||||
TMap<FRoiTile, FTerrainROI> CreateTerrainRoisMap(TArray<FTerrainROI> TerrainRoisArray);
|
||||
|
||||
UFUNCTION(Category="MapGenerator", BlueprintCallable)
|
||||
bool DeleteAllVegetationInMap(const FString Path, const FString MapName);
|
||||
|
||||
UFUNCTION(Category="MapGenerator|JsonLibrary", BlueprintCallable)
|
||||
bool GenerateWidgetStateFileFromStruct(FMapGeneratorWidgetState WidgetState, const FString JsonPath);
|
||||
|
||||
UFUNCTION(Category="MapGenerator|JsonLibrary", BlueprintCallable)
|
||||
FMapGeneratorWidgetState LoadWidgetStateStructFromFile(const FString JsonPath);
|
||||
|
||||
private:
|
||||
/// Loads a bunch of world objects located in @a BaseMapPath and
|
||||
/// returns them in @a WorldAssetsData.
|
||||
|
@ -136,6 +266,9 @@ private:
|
|||
UFUNCTION()
|
||||
bool SaveWorld(UWorld* WorldToBeSaved);
|
||||
|
||||
// UFUNCTION()
|
||||
// bool SaveWorldPackage
|
||||
|
||||
/// Takes the name of the map from @a MetaInfo and created the main map
|
||||
/// including all the actors needed by large map system
|
||||
UFUNCTION()
|
||||
|
@ -174,4 +307,10 @@ private:
|
|||
/// @a x and @a y.
|
||||
UFUNCTION()
|
||||
float GetLandscapeSurfaceHeight(UWorld* World, float x, float y, bool bDrawDebugLines);
|
||||
|
||||
UFUNCTION()
|
||||
void ExtractCoordinatedFromMapName(const FString MapName, int& X, int& Y);
|
||||
|
||||
UFUNCTION()
|
||||
void SmoothHeightmap(TArray<uint16> HeightData, TArray<uint16>& OutHeightData);
|
||||
};
|
||||
|
|
|
@ -3,7 +3,14 @@
|
|||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
#include "Containers/Array.h"
|
||||
#include "Containers/EnumAsByte.h"
|
||||
#include "Materials/MaterialInstanceDynamic.h"
|
||||
#include "ProceduralFoliageSpawner.h"
|
||||
#include "Templates/UnrealTypeTraits.h"
|
||||
#include "UObject/NoExportTypes.h"
|
||||
|
||||
#include "RegionOfInterest.generated.h"
|
||||
|
||||
|
||||
|
@ -11,17 +18,137 @@ UENUM(BlueprintType)
|
|||
enum ERegionOfInterestType
|
||||
{
|
||||
NONE,
|
||||
TERRAIN_REGION,
|
||||
WATERBODIES_REGION,
|
||||
TERRAIN_REGION,
|
||||
WATERBODIES_REGION, // Not Supported yet
|
||||
VEGETATION_REGION
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct CARLATOOLS_API FRoiTile
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
int X;
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
int Y;
|
||||
|
||||
public:
|
||||
FRoiTile() : X(-1), Y(-1)
|
||||
{};
|
||||
|
||||
FRoiTile(int X, int Y)
|
||||
{
|
||||
this->X = X;
|
||||
this->Y = Y;
|
||||
};
|
||||
|
||||
FRoiTile(const FRoiTile& Other)
|
||||
: FRoiTile(Other.X, Other.Y)
|
||||
{}
|
||||
|
||||
bool operator==(const FRoiTile& Other) const
|
||||
{
|
||||
return Equals(Other);
|
||||
}
|
||||
|
||||
bool Equals(const FRoiTile& Other) const
|
||||
{
|
||||
return (this->X == Other.X) && (this->Y == Other.Y);
|
||||
}
|
||||
};
|
||||
|
||||
FORCEINLINE uint32 GetTypeHash(const FRoiTile& Thing)
|
||||
{
|
||||
uint32 Hash = FCrc::MemCrc32(&Thing, sizeof(FRoiTile));
|
||||
return Hash;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
UCLASS()
|
||||
class CARLATOOLS_API URegionOfInterest : public UObject
|
||||
USTRUCT(BlueprintType)
|
||||
struct CARLATOOLS_API FRegionOfInterest
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
TArray<FRoiTile> TilesList;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
TEnumAsByte<ERegionOfInterestType> RegionType = ERegionOfInterestType::NONE;
|
||||
|
||||
FRegionOfInterest()
|
||||
{
|
||||
TilesList.Empty();
|
||||
}
|
||||
|
||||
void AddTile(int X, int Y)
|
||||
{
|
||||
FRoiTile Tile(X,Y);
|
||||
TilesList.Add(Tile);
|
||||
}
|
||||
|
||||
TEnumAsByte<ERegionOfInterestType> GetRegionType()
|
||||
{
|
||||
return this->RegionType;
|
||||
}
|
||||
|
||||
template <typename R>
|
||||
static FORCEINLINE bool IsTileInRegionsSet(FRoiTile RoiTile, TMap<FRoiTile, R> RoisMap)
|
||||
{
|
||||
static_assert(TIsDerivedFrom<R, FRegionOfInterest>::IsDerived,
|
||||
"ROIs Map Value type is not an URegionOfInterest derived type.");
|
||||
return RoisMap.Contains(RoiTile);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct CARLATOOLS_API FVegetationROI : public FRegionOfInterest
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
TArray<UProceduralFoliageSpawner*> FoliageSpawners;
|
||||
|
||||
FVegetationROI() : FRegionOfInterest()
|
||||
{
|
||||
this->FoliageSpawners.Empty();
|
||||
}
|
||||
|
||||
void AddFoliageSpawner(UProceduralFoliageSpawner* Spawner)
|
||||
{
|
||||
FoliageSpawners.Add(Spawner);
|
||||
}
|
||||
|
||||
void AddFoliageSpawners(TArray<UProceduralFoliageSpawner*> Spawners)
|
||||
{
|
||||
for(UProceduralFoliageSpawner* Spawner : Spawners)
|
||||
{
|
||||
AddFoliageSpawner(Spawner);
|
||||
}
|
||||
}
|
||||
|
||||
TArray<UProceduralFoliageSpawner*> GetFoliageSpawners()
|
||||
{
|
||||
return this->FoliageSpawners;
|
||||
}
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct CARLATOOLS_API FTerrainROI : public FRegionOfInterest
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
UMaterialInstanceDynamic* RoiMaterialInstance;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite)
|
||||
UTextureRenderTarget2D* RoiHeightmapRenderTarget;
|
||||
|
||||
FTerrainROI() : FRegionOfInterest(), RoiMaterialInstance()
|
||||
{}
|
||||
|
||||
// TODO: IsEdge() funtion to avoid transition between tiles that belongs to the same ROI
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue