Merge pull request #69 from EVEngine/codex/procgen-bush-generator

procgen: add procedural 3D small bush (mesh.bush)
This commit is contained in:
西风逍遥游 authored and GitHub committed 2026-08-15 22:02:27 +08:00
commit f8ea8a9d96
10 files changed
+581

No files matched your search

+3
View File
@@ -133,6 +133,7 @@ OutputSpec {
| `wfc.simple` | 约束 | 简易 Wave Function Collapse(preset: dungeon/cave/terrain) |
| `mesh.marchingcubes` | 网格 | Marching Cubes 等值面(sphere/torus/noise/terrain 标量场 → MeshBuild) |
| `mesh.hexplanet` | 网格 | 细分二十面体的对偶网格:12 个五边形 + 其余六边形,输出球面单元 MeshBuild |
| `mesh.bush` | 网格 | 低模小灌木:圆顶下聚簇压扁叶椭圆 + 若干木质小枝 + 可选叶卡;确定性输出 MeshBuild |
| `mesh.fence` | 网格 | 线性可拼接木栅栏(柱 + 横栏) |
| `mesh.stonewall` | 网格 | 线性可拼接石头墙(墙身 + 压顶) |
| `mesh.bridge` | 网格 | 线性可拼接桥(桥面 + 扶手 + 横梁) |
@@ -266,7 +267,9 @@ procgen.generate* → Grid2D
- [x] Marching Cubes:`mesh.marchingcubes` → `MeshBuild` / `Graphics::newMeshFromArrays`
- [x] 六边形星球:`mesh.hexplanet` → `MeshBuild` / `Graphics::newMeshFromArrays`
- [x] 小灌木:`mesh.bush` → `MeshBuild` / `Graphics::newMeshFromArrays`(圆顶聚簇叶椭圆 + 小枝 + 叶卡;示例见 `examples/bush-generator/`,使用 Kenney CC0 mini-forest 色板派生贴图渲染)
- [x] Wave Function Collapse:`wfc.simple`(tiled 邻接约束,内置 preset)
- [ ] 低模树/石/草 billboard 或简易网格(树、石、灌木已就绪;草/装饰散布待补)
- [x] 摩天大楼:`mesh.skyscraper` → `MeshBuild` / `Graphics::newMeshFromArrays`
- [x] 线性可拼接结构:`mesh.fence` / `mesh.stonewall` / `mesh.bridge` / `mesh.greatwall` / `mesh.hedge` / `mesh.chevaldefrise`
- [ ] 低模树/石/草 billboard 或简易网格
+77
View File
@@ -0,0 +1,77 @@
# Bush Lab — procedural small 3D bush (`mesh.bush`)
`mesh.bush` is a native EVEngine procedural mesh recipe that generates a small,
low-poly 3D bush deterministically. The same seed and parameters always produce
the identical mesh (positions, normals, UVs and indices).
Run from the repository root:
```sh
make run/win32-debug GAME=examples/bush-generator # Windows Debug
make run GAME=examples/bush-generator # current host debug
```
## How it works
`procgen.generateMesh("mesh.bush", p, gfx)` returns a GPU `Mesh`; use
`procgen.buildMesh("mesh.bush", p)` for the CPU `MeshBuild`. The generator
clusters a set of squashed foliage ellipsoids under a dome silhouette, adds a
few woody twigs poking out, and optionally scatters loose leaf cards.
The mesh uses a two-region atlas, matching the `mesh.tree` convention:
| Region | UV.u | Content |
|--------|-------|---------|
| Twigs / bark | `[0.0, 0.45]` | woody emergent stems |
| Foliage | `[0.55, 1.0]` | lobes + leaf cards |
## Parameters
| Key | Default | Meaning |
|-----|---------|---------|
| `seed` | (from `newParams`) | deterministic source |
| `style` | `mound` | `mound` (flattened dome) or `sphere` (round ball) |
| `leafMode` | `mixed` | `blobs` \| `cards` \| `mixed` \| `none` |
| `height` | `1.4` | overall height |
| `width` | `2.2` | overall width (diameter) |
| `blobs` | `9` | number of foliage lobes |
| `rings` | `3` | lobe ring resolution |
| `radialSegments` | `7` | lobe radial resolution |
| `leafDensity` | `0.62` | fraction of loose leaf cards |
| `leafSize` | `0.16·height` | leaf-card size |
| `twigs` | `4` | emergent woody twigs (0 disables) |
| `twigLength` | `0.30·height` | twig length |
Squirrel example:
```squirrel
local p = procgen.newParams();
p.setSeed(20260815);
p.setString("style", "mound");
p.setString("leafMode", "mixed");
p.setFloat("height", 1.7);
local mesh = procgen.generateMesh("mesh.bush", p, gfx);
```
## Controls
| Key | Action |
|-----|--------|
| `R` | new seed |
| `1` / `2` | mound / sphere style |
| `L` | cycle leaf mode (mixed → blobs → cards → none) |
| `C` | toggle leaf cards |
| `[` / `]` | leaf density down / up |
| `T` | reload texture from disk |
## Assets & attribution
The default texture `assets/bush_atlas.png` is a two-tone atlas (brown twig
half + green foliage half) **derived from the color palette** of the
[Kenney "Mini Forest"](https://kenney.nl/assets/mini-forest) pack. The original
`assets/kenney/kenney_mini-forest_colormap.png` and its
`assets/kenney/LICENSE.txt` are included.
> Kenney assets are released under **CC0 1.0 (public domain)** — no
> attribution required, but appreciated. See `assets/kenney/LICENSE.txt` for the
> full license text.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,28 @@
Mini Forest (1.0)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 14-07-2026 12:13
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You can use this content for personal, educational, and commercial purposes.
Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)
------------------------------
• Website : www.kenney.nl
• Donate : www.kenney.nl/donate
• Patreon : patreon.com/kenney
Follow on social media for updates:
• Twitter: twitter.com/KenneyNL
• BlueSky: kenney.bsky.social
• Instagram: instagram.com/kenney_nl
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+96
View File
@@ -0,0 +1,96 @@
// Bush Lab — interactive showcase for the procedural `mesh.bush` recipe.
// R regenerate seed, 1/2 mound/sphere, L leafMode, C cards toggle, [/] density, T texture.
// The default texture is derived from Kenney's CC0 mini-forest colormap
// (assets/bush_atlas.png); press T to reload it from disk at any time.
if (!("bushSeed" in getroottable())) bushSeed <- 20260815;
if (!("bushStyle" in getroottable())) bushStyle <- "mound";
if (!("bushLeafMode" in getroottable())) bushLeafMode <- "mixed";
if (!("bushDensity" in getroottable())) bushDensity <- 0.62;
if (!("bushMesh" in getroottable())) bushMesh <- null;
if (!("bushObject" in getroottable())) bushObject <- null;
if (!("bushTexture" in getroottable())) bushTexture <- null;
if (!("bushCamera" in getroottable())) bushCamera <- null;
if (!("bushYaw" in getroottable())) bushYaw <- 0.0;
if (!("prevBushKeys" in getroottable())) prevBushKeys <- {};
function pressed(k) {
local down = keyboard.isDown(k);
local old = k in prevBushKeys ? prevBushKeys[k] : false;
prevBushKeys[k] <- down;
return down && !old;
}
function loadBushTexture() {
// Derived from Kenney's CC0 mini-forest palette: left half is twig bark,
// right half is foliage. See assets/bush_atlas.png and assets/kenney/.
return gfx.newTextureFromFile("assets/bush_atlas.png");
}
function rebuildBush() {
local p = procgen.newParams();
p.setSeed(bushSeed);
p.setString("style", bushStyle);
p.setString("leafMode", bushLeafMode);
p.setFloat("leafDensity", bushDensity);
p.setFloat("height", 1.7);
p.setFloat("width", 2.6);
p.setInt("blobs", 12);
p.setInt("rings", 3);
p.setInt("radialSegments", 8);
p.setFloat("leafSize", 0.32);
p.setInt("twigs", 6);
bushMesh = procgen.generateMesh("mesh.bush", p, gfx);
if (bushObject == null) {
bushObject = eve.Renderable3D();
bushObject.setPosition(1.4, -3.0, 0.0);
bushObject.setTint(1.0, 1.0, 1.0, 1.0);
bushObject.setRoughness(0.9);
bushObject.setCastShadow(true);
if (bushTexture == null) bushTexture = loadBushTexture();
if (bushTexture != null) bushObject.setTexture(bushTexture);
}
bushObject.setMesh(bushMesh);
}
if (bushCamera == null) {
bushCamera = eve.Camera3D();
bushCamera.setEye(5.4, 3.0, 6.0);
bushCamera.setTarget(0.6, 0.0, 0.0);
bushCamera.setUp(0.0, 1.0, 0.0);
bushCamera.setFov(38.0);
bushCamera.setAmbient(0.34, 0.40, 0.32);
bushCamera.setActive(true);
gfx.setDirectionalLight(-0.45, -1.0, -0.35, 1.35, 1.24, 1.02);
}
if (bushMesh == null) rebuildBush();
function update(dt) {
bushYaw += dt * 7.0;
bushObject.setYaw(bushYaw);
if (pressed("r") || pressed("R")) { bushSeed += 1; rebuildBush(); }
if (pressed("1")) { bushStyle = "mound"; rebuildBush(); }
if (pressed("2")) { bushStyle = "sphere"; rebuildBush(); }
if (pressed("l") || pressed("L")) {
bushLeafMode = bushLeafMode == "mixed" ? "blobs" :
bushLeafMode == "blobs" ? "cards" :
bushLeafMode == "cards" ? "none" : "mixed";
rebuildBush();
}
if (pressed("c") || pressed("C")) {
bushLeafMode = bushLeafMode == "cards" || bushLeafMode == "mixed" ? "blobs" : "mixed";
rebuildBush();
}
if (pressed("t") || pressed("T")) {
bushTexture = loadBushTexture();
if (bushTexture != null) bushObject.setTexture(bushTexture);
}
if (pressed("leftbracket")) { bushDensity = max(0.0, bushDensity - 0.1); rebuildBush(); }
if (pressed("rightbracket")) { bushDensity = min(1.0, bushDensity + 0.1); rebuildBush(); }
}
function draw() {
gfx.clear(0.055, 0.075, 0.07, 1.0);
gfx.render3D();
gfx.drawSolidRect(28, 28, 320, 200, 0.92, 0.94, 0.86, 0.96);
}
+216
View File
@@ -0,0 +1,216 @@
#include "procgen/algorithms/BushMesh.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <random>
#include <vector>
namespace eve::procgen {
namespace {
constexpr float kPi = 3.14159265358979323846f;
// Shared with the tree atlas: the left half is bark/stems, the right half foliage.
// A bush is almost entirely foliage, so lobes/cards sample the right half while
// the few emergent twigs sample the left half.
constexpr float kFoliageUMin = 0.55f;
constexpr float kFoliageUMax = 1.0f;
constexpr float kBarkUMin = 0.0f;
constexpr float kBarkUMax = 0.45f;
struct V3 {
float x = 0.f, y = 0.f, z = 0.f;
};
V3 add(V3 a, V3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
V3 sub(V3 a, V3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
V3 mul(V3 a, float s) { return {a.x * s, a.y * s, a.z * s}; }
float dot(V3 a, V3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
V3 cross(V3 a, V3 b) { return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; }
V3 norm(V3 a) {
const float n = std::sqrt(std::max(1e-12f, dot(a, a)));
return mul(a, 1.f / n);
}
float randomRange(std::mt19937 &rng, float lo, float hi) { return std::uniform_real_distribution<float>(lo, hi)(rng); }
float random01(std::mt19937 &rng) { return randomRange(rng, 0.f, 1.f); }
void basisFor(V3 axis, V3 &right, V3 &forward) {
axis = norm(axis);
const V3 helper = std::fabs(axis.y) < 0.92f ? V3{0.f, 1.f, 0.f} : V3{1.f, 0.f, 0.f};
right = norm(cross(helper, axis));
forward = norm(cross(axis, right));
}
// Closed ellipsoid. The per-vertex normal is the analytic ellipsoid normal so the
// bush shades smoothly under directional light. UVs fall inside [uMin, uMax].
void addEllipsoidBlob(MeshBuild &out, V3 c, V3 r, int rings, int sides, float uMin, float uMax) {
const uint32_t base = uint32_t(out.getVertexCount());
for (int y = 0; y <= rings; ++y) {
const float v = float(y) / float(rings);
const float phi = v * kPi;
for (int x = 0; x < sides; ++x) {
const float u = float(x) / float(sides);
const float theta = u * 2.f * kPi;
const V3 n = {std::sin(phi) * std::cos(theta), std::cos(phi), std::sin(phi) * std::sin(theta)};
const V3 p = {c.x + n.x * r.x, c.y + n.y * r.y, c.z + n.z * r.z};
// Ellipsoid normal = N/R divided by its length (finite since radii > 0).
const V3 normal = norm({n.x / r.x, n.y / r.y, n.z / r.z});
out.addVertex(p.x, p.y, p.z, normal.x, normal.y, normal.z, uMin + u * (uMax - uMin), v);
}
}
for (int y = 0; y < rings; ++y) {
for (int x = 0; x < sides; ++x) {
const int nx = (x + 1) % sides;
const uint32_t a = base + uint32_t(y * sides + x);
const uint32_t b = base + uint32_t(y * sides + nx);
const uint32_t c = base + uint32_t((y + 1) * sides + x);
const uint32_t d = base + uint32_t((y + 1) * sides + nx);
out.addTriangle(a, c, b);
out.addTriangle(b, c, d);
}
}
}
// Open tapered cylinder (a woody twig). Vertices use the bark UV half.
void addTwig(MeshBuild &out, V3 a, V3 b, float r0, float r1, int sides, float uMin, float uMax) {
const V3 axis = norm(sub(b, a));
V3 right, forward;
basisFor(axis, right, forward);
const uint32_t base = uint32_t(out.getVertexCount());
for (int ring = 0; ring < 2; ++ring) {
const V3 center = ring ? b : a;
const float radius = ring ? r1 : r0;
for (int i = 0; i < sides; ++i) {
const float t = float(i) / float(sides);
const float angle = t * 2.f * kPi;
const V3 radial = add(mul(right, std::cos(angle)), mul(forward, std::sin(angle)));
const V3 p = add(center, mul(radial, radius));
out.addVertex(p.x, p.y, p.z, radial.x, radial.y, radial.z, uMin + t * (uMax - uMin), float(ring));
}
}
for (int i = 0; i < sides; ++i) {
const uint32_t n = uint32_t((i + 1) % sides);
const uint32_t i0 = base + uint32_t(i), i1 = base + n;
const uint32_t i2 = base + uint32_t(sides) + uint32_t(i);
const uint32_t i3 = base + uint32_t(sides) + n;
out.addTriangle(i0, i2, i1);
out.addTriangle(i1, i2, i3);
}
}
// A two-triangle leaf card. Facing the given direction with a random twist.
void addLeafCard(MeshBuild &out, std::mt19937 &rng, V3 c, V3 direction, float size, float uMin, float uMax) {
V3 right, up;
basisFor(norm(direction), right, up);
const float twist = randomRange(rng, 0.f, 2.f * kPi);
right = add(mul(right, std::cos(twist)), mul(up, std::sin(twist)));
up = norm(cross(norm(direction), right));
const V3 r = mul(right, size * 0.5f), u = mul(up, size);
const V3 normal = norm(cross(right, up));
const uint32_t base = uint32_t(out.getVertexCount());
const V3 points[4] = {sub(sub(c, r), u), add(sub(c, u), r), add(add(c, r), u), add(sub(c, r), u)};
const float uv[4][2] = {{0, 0}, {1, 0}, {1, 1}, {0, 1}};
for (int i = 0; i < 4; ++i)
out.addVertex(points[i].x, points[i].y, points[i].z, normal.x, normal.y, normal.z,
uMin + uv[i][0] * (uMax - uMin), uv[i][1]);
out.addTriangle(base, base + 1, base + 2);
out.addTriangle(base, base + 2, base + 3);
out.addTriangle(base + 2, base + 1, base);
out.addTriangle(base + 3, base + 2, base);
}
} // namespace
bool generateBushMesh(const Params &params, MeshBuild &out, std::string &error) {
const std::string style = params.getString("style", "mound");
const std::string leafMode = params.getString("leafMode", "mixed");
if (style != "mound" && style != "sphere") {
error = "mesh.bush: style must be mound|sphere";
return false;
}
if (leafMode != "blobs" && leafMode != "cards" && leafMode != "mixed" && leafMode != "none") {
error = "mesh.bush: leafMode must be blobs|cards|mixed|none";
return false;
}
const float height = std::max(0.3f, params.getFloat("height", 1.4f));
const float width = std::max(0.4f, params.getFloat("width", 2.2f));
const float halfW = width * 0.5f;
const int blobs = std::clamp(params.getInt("blobs", 9), 1, 40);
const int rings = std::clamp(params.getInt("rings", 3), 2, 10);
const int sides = std::clamp(params.getInt("radialSegments", 7), 4, 24);
const float density = std::clamp(params.getFloat("leafDensity", 0.62f), 0.f, 1.f);
const float leafSize = std::max(0.02f, params.getFloat("leafSize", height * 0.16f));
const int twigs = std::clamp(params.getInt("twigs", 4), 0, 16);
const float twigLen = std::max(0.05f, params.getFloat("twigLength", height * 0.30f));
const bool sphere = style == "sphere";
std::mt19937 rng(params.getSeed());
out.clear();
// Cluster squashed lobes under a dome silhouette so the bush reads as one
// rounded mound rather than a set of disconnected balls.
for (int i = 0; i < blobs; ++i) {
const float radial = halfW * std::sqrt(random01(rng));
const float theta = randomRange(rng, 0.f, 2.f * kPi);
const float heightFactor = 1.f - (radial / halfW) * (radial / halfW);
const float cy = height * (sphere ? 0.5f + 0.20f * random01(rng)
: heightFactor * (0.45f + 0.45f * random01(rng)));
const float rx = halfW * 0.30f * randomRange(rng, 0.70f, 1.25f);
const float ry = height * (sphere ? 0.24f : 0.28f) * randomRange(rng, 0.60f, 1.05f);
const float rz = rx * randomRange(rng, 0.80f, 1.20f);
const V3 center{std::cos(theta) * radial, cy, std::sin(theta) * radial};
addEllipsoidBlob(out, center, {rx, ry, rz}, rings, sides, kFoliageUMin, kFoliageUMax);
}
// Always cap the top so the dome has no gap at its peak.
const float topRx = halfW * 0.22f;
addEllipsoidBlob(out, {0.f, height * (sphere ? 0.62f : 0.72f), 0.f}, {topRx, height * 0.20f, topRx},
rings, sides, kFoliageUMin, kFoliageUMax);
// Emergent woody twigs plus a small foliage tuft on each tip.
const float twigR0 = width * 0.015f;
for (int i = 0; i < twigs; ++i) {
const float angle = float(i) * 2.399963f;
const float radial = halfW * randomRange(rng, 0.15f, 0.60f);
const V3 base{std::cos(angle) * radial, height * randomRange(rng, 0.10f, 0.45f),
std::sin(angle) * radial};
V3 dir = norm(add(V3{0.f, 1.f, 0.f}, {randomRange(rng, -0.35f, 0.35f), 0.f, randomRange(rng, -0.35f, 0.35f)}));
const float len = twigLen * randomRange(rng, 0.60f, 1.10f);
const V3 tip = add(base, mul(dir, len));
addTwig(out, base, tip, twigR0, twigR0 * 0.35f, std::max(3, sides - 3), kBarkUMin, kBarkUMax);
if (leafMode == "cards" || leafMode == "mixed") {
addEllipsoidBlob(out, tip, {leafSize * 0.8f, leafSize * 0.9f, leafSize * 0.8f}, 2, std::max(4, sides - 2),
kFoliageUMin, kFoliageUMax);
}
}
// Optional loose leaf cards across the canopy for a fuller look.
if (leafMode == "cards" || leafMode == "mixed") {
const int cards = std::max(1, int(std::round(float(blobs) * 3.5f * density)));
for (int i = 0; i < cards; ++i) {
const float radial = halfW * std::sqrt(random01(rng));
const float theta = randomRange(rng, 0.f, 2.f * kPi);
const float heightFactor = 1.f - (radial / halfW) * (radial / halfW);
const V3 c{std::cos(theta) * radial, height * heightFactor * randomRange(rng, 0.45f, 0.90f),
std::sin(theta) * radial};
const V3 face = norm({randomRange(rng, -1.f, 1.f), randomRange(rng, 0.2f, 0.9f),
randomRange(rng, -1.f, 1.f)});
addLeafCard(out, rng, c, face, leafSize * randomRange(rng, 0.70f, 1.20f), kFoliageUMin, kFoliageUMax);
}
}
out.setMeta("recipe", "mesh.bush");
out.setMeta("style", style);
out.setMeta("leafMode", leafMode);
out.setMeta("seed", std::to_string(params.getSeed()));
if (out.empty()) {
error = "mesh.bush: generated an empty mesh";
return false;
}
return true;
}
} // namespace eve::procgen
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "procgen/MeshBuild.h"
#include "procgen/Params.h"
#include <string>
namespace eve::procgen {
/** Build a deterministic procedural small bush. Registered as the `mesh.bush` recipe. */
bool generateBushMesh(const Params &params, MeshBuild &out, std::string &error);
} // namespace eve::procgen
@@ -2,6 +2,7 @@
#include "procgen/algorithms/RockMesh.h"
#include "procgen/algorithms/SkyscraperMesh.h"
#include "procgen/algorithms/TreeMesh.h"
#include "procgen/algorithms/BushMesh.h"
#include "procgen/algorithms/LinearStructure.h"
#include <algorithm>
@@ -474,6 +475,7 @@ void MeshRecipeRegistry::registerBuiltins() {
registerRecipe("mesh.rock", generateRockMesh);
registerRecipe("mesh.hexplanet", generateHexPlanetMesh);
registerRecipe("mesh.tree", generateTreeMesh);
registerRecipe("mesh.bush", generateBushMesh);
registerRecipe("mesh.skyscraper", generateSkyscraperMesh);
registerLinearStructureRecipes(*this);
builtinsRegistered_ = true;
+146
View File
@@ -518,6 +518,152 @@ TEST_CASE("procgen.mesh.tree.renderDump") {
win->close();
}
TEST_CASE("procgen.mesh.bush.reproducibleAndStyles") {
MeshRecipeRegistry::instance().registerBuiltins();
Params p;
p.setSeed(20260815u);
p.setString("style", "mound");
p.setString("leafMode", "mixed");
MeshBuild a, b;
std::string err;
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", p, a, err));
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", p, b, err));
CHECK(a.getVertexCount() > 0);
CHECK(a.positions() == b.positions());
CHECK(a.indices() == b.indices());
CHECK(meshIndicesInRange(a));
CHECK(meshPositionsFinite(a));
CHECK(meshNormalsFiniteUnit(a));
CHECK_EQ(a.getMeta("recipe", ""), "mesh.bush");
CHECK_EQ(a.getMeta("style", ""), "mound");
CHECK_EQ(a.getMeta("leafMode", ""), "mixed");
// Sphere style produces a different silhouette (rounder, taller lobes).
Params sphere = p;
sphere.setString("style", "sphere");
MeshBuild round;
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", sphere, round, err));
CHECK(round.getVertexCount() > 0);
CHECK(round.positions() != a.positions());
CHECK(meshIndicesInRange(round));
CHECK(meshNormalsFiniteUnit(round));
CHECK_EQ(round.getMeta("style", ""), "sphere");
// Bare foliage still yields a closed mound (blobs only, no cards/twigs tufts).
Params blobsOnly = p;
blobsOnly.setString("leafMode", "blobs");
blobsOnly.setInt("twigs", 0);
MeshBuild mounds;
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", blobsOnly, mounds, err));
CHECK(mounds.getVertexCount() > 0);
CHECK(meshIndicesInRange(mounds));
CHECK(meshNormalsFiniteUnit(mounds));
// "none" drops cards but keeps the blobs; fewer vertices than the mixed default.
Params bare = p;
bare.setString("leafMode", "none");
bare.setInt("twigs", 0);
MeshBuild noCards;
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", bare, noCards, err));
CHECK(noCards.getVertexCount() > 0);
CHECK(noCards.getVertexCount() < a.getVertexCount());
CHECK(meshNormalsFiniteUnit(noCards));
// Lower resolution should be cheaper than the default.
Params lod = p;
lod.setInt("rings", 2);
lod.setInt("radialSegments", 5);
lod.setInt("blobs", 5);
MeshBuild cheap;
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", lod, cheap, err));
CHECK(cheap.getVertexCount() < a.getVertexCount());
CHECK(meshIndicesInRange(cheap));
CHECK(meshNormalsFiniteUnit(cheap));
}
TEST_CASE("procgen.mesh.bush.validatesOptions") {
MeshRecipeRegistry::instance().registerBuiltins();
Params p;
p.setString("style", "bonsai");
MeshBuild mesh;
std::string err;
CHECK(!MeshRecipeRegistry::instance().generate("mesh.bush", p, mesh, err));
CHECK(err.find("style") != std::string::npos);
p.setString("style", "mound");
p.setString("leafMode", "marching");
CHECK(!MeshRecipeRegistry::instance().generate("mesh.bush", p, mesh, err));
CHECK(err.find("leafMode") != std::string::npos);
}
TEST_CASE("procgen.mesh.bush.renderDump") {
const char *outputPath = std::getenv("EVENGINE_BUSH_RENDER_PNG");
if (!outputPath || !outputPath[0]) return;
auto *win = eve::window::Window::create();
auto *gfx = Graphics::create();
REQUIRE(win != nullptr);
REQUIRE(gfx != nullptr);
win->setGraphics(gfx);
eve::window::WindowSettings ws;
ws.width = 900;
ws.height = 700;
ws.centered = true;
REQUIRE(win->setWindowSettings(ws));
Params params;
params.setSeed(20260815u);
params.setString("style", "mound");
params.setString("leafMode", "mixed");
params.setFloat("height", 1.7f);
params.setFloat("width", 2.6f);
params.setInt("blobs", 12);
params.setInt("rings", 3);
params.setInt("radialSegments", 8);
params.setFloat("leafDensity", 0.8f);
params.setFloat("leafSize", 0.32f);
params.setInt("twigs", 6);
Procgen generator;
Mesh *bushMesh = generator.generateMesh("mesh.bush", &params, gfx);
REQUIRE(bushMesh != nullptr);
// A tiny two-tone foliage atlas (dark base / light highlight), like the tree test.
const uint8_t atlasPixels[] = {
52, 86, 40, 255, 84, 132, 60, 255,
};
Texture *atlas = gfx->newTexture(2, 1, atlasPixels);
REQUIRE(atlas != nullptr);
auto *bush = Renderable3D::create();
bush->setMesh(bushMesh);
bush->setTexture(atlas);
bush->setTint(1.f, 1.f, 1.f, 1.f);
bush->setRoughness(0.9f);
bush->setRotation(-14.f, 0.f, 0.f);
auto *camera = Camera3D::createCamera();
camera->setEye(5.2f, 3.2f, 5.6f);
camera->setTarget(0.f, 0.9f, 0.f);
camera->setUp(0.f, 1.f, 0.f);
camera->setFov(36.f);
camera->setAmbient(0.34f, 0.38f, 0.30f);
camera->setActive(true);
gfx->setScreenReadbackEnabled(true);
gfx->setBackgroundColor(Color(0.075f, 0.105f, 0.095f, 1.f));
RenderSystem3D::setDirectionalLight(-0.55f, -1.f, -0.35f, 1.45f, 1.32f, 1.08f);
for (int frame = 0; frame < 4; ++frame) {
RenderSystem3D::render(*gfx);
RenderSystem::render(*gfx);
}
std::unique_ptr<eve::image::ImageData> image(gfx->newImageData());
REQUIRE(image.get() != nullptr);
REQUIRE(saveImagePng(*image, outputPath));
std::printf("bush render saved: %s\n", outputPath);
win->close();
}
TEST_CASE("procgen.dungeon.bsp.reproducible") {
Params p;
p.setSeed(42);