feat(editor): biome vegetation auto-population system

One-click procedural object placement based on biome rules:

- 10 biome vegetation rulesets with density, scale range, and slope
  constraints per asset type (trees, bushes, rocks, ferns, etc.)
- Grassland: pine trees + bushes + rocks
- Forest: ashenvale trees + ferns + forest rocks (dense canopy)
- Jungle: palm trees + ferns + vines (high density)
- Desert: cacti + desert rocks + bones (sparse)
- Barrens: scattered trees + dry bushes + rocks
- Snow: snow pines + snowdrifts + rocks
- Swamp: dark trees + mushrooms + logs
- Rocky: rock formations + rock piles
- Beach: palm trees + beach rocks
- Volcanic: lava rocks + charred trees

Objects panel > Auto-Populate Biome: select biome, set seed, click
"Populate Zone" to fill the entire tile with biome-appropriate
vegetation at rule-defined densities.
This commit is contained in:
Kelsi 2026-05-05 16:42:41 -07:00
parent 0d401c3eb8
commit 110f250150
4 changed files with 143 additions and 0 deletions

View file

@ -1668,6 +1668,34 @@ void EditorUI::renderObjectPanel(EditorApp& app) {
}
}
if (ImGui::CollapsingHeader("Auto-Populate Biome")) {
static int popBiome = 0;
static uint32_t popSeed = 42;
const char* biomeNames[] = {"Grassland", "Forest", "Jungle", "Desert",
"Barrens", "Snow", "Swamp", "Rocky", "Beach", "Volcanic"};
ImGui::Combo("Biome##pop", &popBiome, biomeNames, 10);
int seed = static_cast<int>(popSeed);
if (ImGui::InputInt("Seed##pop", &seed)) popSeed = static_cast<uint32_t>(seed);
auto veg = getBiomeVegetation(static_cast<Biome>(popBiome));
ImGui::TextColored(ImVec4(0.6f,0.6f,0.6f,1), "%zu asset types, density-based",
veg.assets.size());
if (ImGui::Button("Populate Zone", ImVec2(-1, 0)) && app.hasTerrainLoaded()) {
auto* t = app.getTerrainEditor().getTerrain();
float tileSize = 533.33333f;
glm::vec3 origin(
(32.0f - t->coord.y) * tileSize,
(32.0f - t->coord.x) * tileSize, 0);
int n = placer.populateBiome(veg, tileSize, origin, popSeed);
app.markObjectsDirty();
app.showToast("Populated " + std::string(biomeNames[popBiome]) +
": " + std::to_string(n) + " objects");
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Auto-place trees, rocks, bushes based on biome rules");
}
ImGui::Separator();
// Bulk operations
if (ImGui::CollapsingHeader("Bulk Operations")) {

View file

@ -1,4 +1,5 @@
#include "object_placer.hpp"
#include "terrain_biomes.hpp"
#include "core/logger.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
@ -199,6 +200,45 @@ void ObjectPlacer::scatter(const glm::vec3& center, float radius, int count,
LOG_INFO("Scattered ", count, " objects in radius ", radius);
}
int ObjectPlacer::populateBiome(const BiomeVegetation& vegetation,
float tileSize, const glm::vec3& tileOrigin,
uint32_t seed) {
int placed = 0;
std::mt19937 rng(seed);
std::uniform_real_distribution<float> distPos(0.0f, 1.0f);
std::uniform_real_distribution<float> distRot(0.0f, 360.0f);
for (const auto& asset : vegetation.assets) {
// Calculate object count from density (per 100x100 area)
float areaFactor = (tileSize * tileSize) / 10000.0f;
int count = static_cast<int>(asset.density * areaFactor);
std::uniform_real_distribution<float> distScale(asset.minScale, asset.maxScale);
for (int i = 0; i < count; i++) {
float u = distPos(rng);
float v = distPos(rng);
glm::vec3 pos = tileOrigin + glm::vec3(
-u * tileSize, -v * tileSize, 0.0f);
PlacedObject obj;
obj.type = PlaceableType::M2;
obj.path = asset.path;
obj.nameId = 0;
obj.uniqueId = nextUniqueId();
obj.position = pos;
obj.rotation = glm::vec3(0.0f, distRot(rng), 0.0f);
obj.scale = distScale(rng);
obj.selected = false;
objects_.push_back(obj);
placed++;
}
}
LOG_INFO("Biome populated: ", vegetation.name, "", placed, " objects placed");
return placed;
}
void ObjectPlacer::undoLastPlace() {
if (undoStack_.empty()) return;
int idx = undoStack_.back();

View file

@ -82,6 +82,11 @@ public:
void scatter(const glm::vec3& center, float radius, int count,
float minScale, float maxScale);
// Procedural biome population: auto-place vegetation based on rules
int populateBiome(const struct BiomeVegetation& vegetation,
float tileSize, const glm::vec3& tileOrigin,
uint32_t seed = 42);
private:
uint32_t nextUniqueId();

View file

@ -109,5 +109,75 @@ inline const char* getBiomeName(Biome b) {
return getBiomeTextures(b).name;
}
// Vegetation rule: which M2 models to scatter per biome
struct VegetationAsset {
const char* path;
float density; // objects per 100x100 unit area
float minScale;
float maxScale;
float maxSlope; // max terrain slope (0-1, 0=flat only, 1=any)
float minHeight; // relative to base (-999=any)
float maxHeight; // relative to base (999=any)
};
struct BiomeVegetation {
const char* name;
std::vector<VegetationAsset> assets;
};
inline BiomeVegetation getBiomeVegetation(Biome biome) {
switch (biome) {
case Biome::Grassland: return {"Grassland", {
{"World\\Doodad\\Azeroth\\Elwynn\\PineTree\\ElwynnPineTree01.m2", 3.0f, 0.8f, 1.4f, 0.6f, -999, 999},
{"World\\Doodad\\Azeroth\\Elwynn\\ElwynnBush01.m2", 5.0f, 0.6f, 1.2f, 0.8f, -999, 999},
{"World\\Doodad\\Azeroth\\Rock\\Rock01.m2", 1.0f, 0.5f, 1.5f, 1.0f, -999, 999},
}};
case Biome::Forest: return {"Forest", {
{"World\\Doodad\\Azeroth\\Ashenvale\\AshenvaleTree01.m2", 6.0f, 0.7f, 1.5f, 0.5f, -999, 999},
{"World\\Doodad\\Azeroth\\Ashenvale\\AshenvaleTree02.m2", 4.0f, 0.8f, 1.3f, 0.5f, -999, 999},
{"World\\Doodad\\Azeroth\\Ashenvale\\AshenvaleFern01.m2", 8.0f, 0.4f, 0.9f, 0.7f, -999, 999},
{"World\\Doodad\\Azeroth\\Rock\\ForestRock01.m2", 1.5f, 0.6f, 1.8f, 1.0f, -999, 999},
}};
case Biome::Jungle: return {"Jungle", {
{"World\\Doodad\\Azeroth\\Stranglethorn\\StranglethornPalmTree01.m2", 5.0f, 0.8f, 1.4f, 0.5f, -999, 999},
{"World\\Doodad\\Azeroth\\Stranglethorn\\StranglethornFern01.m2", 10.0f, 0.3f, 0.8f, 0.8f, -999, 999},
{"World\\Doodad\\Azeroth\\Stranglethorn\\StranglethornVines01.m2", 3.0f, 0.7f, 1.2f, 0.6f, -999, 999},
}};
case Biome::Desert: return {"Desert", {
{"World\\Doodad\\Azeroth\\Tanaris\\TanarisCactus01.m2", 2.0f, 0.6f, 1.3f, 0.7f, -999, 999},
{"World\\Doodad\\Azeroth\\Rock\\DesertRock01.m2", 1.5f, 0.5f, 2.0f, 1.0f, -999, 999},
{"World\\Doodad\\Azeroth\\Tanaris\\TanarisBones01.m2", 0.5f, 0.8f, 1.2f, 0.5f, -999, 999},
}};
case Biome::Barrens: return {"Barrens", {
{"World\\Doodad\\Azeroth\\Barrens\\BarrensTree01.m2", 1.5f, 0.7f, 1.3f, 0.6f, -999, 999},
{"World\\Doodad\\Azeroth\\Barrens\\BarrensBush01.m2", 3.0f, 0.5f, 1.0f, 0.8f, -999, 999},
{"World\\Doodad\\Azeroth\\Rock\\BarrensRock01.m2", 1.0f, 0.6f, 1.5f, 1.0f, -999, 999},
}};
case Biome::Snow: return {"Snow", {
{"World\\Doodad\\Azeroth\\Winterspring\\WinterspringPine01.m2", 4.0f, 0.8f, 1.5f, 0.5f, -999, 999},
{"World\\Doodad\\Azeroth\\Winterspring\\WinterspringSnowDrift01.m2", 2.0f, 0.5f, 1.2f, 0.4f, -999, 999},
{"World\\Doodad\\Azeroth\\Rock\\SnowRock01.m2", 1.0f, 0.6f, 1.8f, 1.0f, -999, 999},
}};
case Biome::Swamp: return {"Swamp", {
{"World\\Doodad\\Azeroth\\Wetlands\\WetlandsTree01.m2", 4.0f, 0.7f, 1.3f, 0.5f, -999, 999},
{"World\\Doodad\\Azeroth\\Wetlands\\WetlandsMushroom01.m2", 6.0f, 0.3f, 0.7f, 0.8f, -999, 999},
{"World\\Doodad\\Azeroth\\Wetlands\\WetlandsLog01.m2", 1.5f, 0.8f, 1.2f, 0.4f, -999, 999},
}};
case Biome::Rocky: return {"Rocky", {
{"World\\Doodad\\Azeroth\\Rock\\Rock01.m2", 3.0f, 0.5f, 2.5f, 1.0f, -999, 999},
{"World\\Doodad\\Azeroth\\Rock\\RockPile01.m2", 2.0f, 0.6f, 1.5f, 1.0f, -999, 999},
}};
case Biome::Beach: return {"Beach", {
{"World\\Doodad\\Azeroth\\Stranglethorn\\StranglethornPalmTree01.m2", 2.0f, 0.7f, 1.3f, 0.4f, -999, 999},
{"World\\Doodad\\Azeroth\\Rock\\BeachRock01.m2", 1.5f, 0.5f, 1.5f, 0.6f, -999, 999},
}};
case Biome::Volcanic: return {"Volcanic", {
{"World\\Doodad\\Azeroth\\Rock\\LavaRock01.m2", 2.5f, 0.6f, 2.0f, 1.0f, -999, 999},
{"World\\Doodad\\Azeroth\\Burning Steppes\\BurningSteppesCharredTree01.m2", 1.0f, 0.8f, 1.2f, 0.5f, -999, 999},
}};
default: return {"Unknown", {}};
}
}
} // namespace editor
} // namespace wowee