diff --git a/docs/dev/程序化生成模块设计.md b/docs/dev/程序化生成模块设计.md index b4ddd623..27b808d8 100644 --- a/docs/dev/程序化生成模块设计.md +++ b/docs/dev/程序化生成模块设计.md @@ -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 或简易网格 diff --git a/examples/bush-generator/README.md b/examples/bush-generator/README.md new file mode 100644 index 00000000..0ee4e06f --- /dev/null +++ b/examples/bush-generator/README.md @@ -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. diff --git a/examples/bush-generator/assets/bush_atlas.png b/examples/bush-generator/assets/bush_atlas.png new file mode 100644 index 00000000..b495cc70 Binary files /dev/null and b/examples/bush-generator/assets/bush_atlas.png differ diff --git a/examples/bush-generator/assets/kenney/LICENSE.txt b/examples/bush-generator/assets/kenney/LICENSE.txt new file mode 100644 index 00000000..8d6794ef --- /dev/null +++ b/examples/bush-generator/assets/kenney/LICENSE.txt @@ -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 \ No newline at end of file diff --git a/examples/bush-generator/assets/kenney/kenney_mini-forest_colormap.png b/examples/bush-generator/assets/kenney/kenney_mini-forest_colormap.png new file mode 100644 index 00000000..907a47fe Binary files /dev/null and b/examples/bush-generator/assets/kenney/kenney_mini-forest_colormap.png differ diff --git a/examples/bush-generator/main.nut b/examples/bush-generator/main.nut new file mode 100644 index 00000000..cde7567f --- /dev/null +++ b/examples/bush-generator/main.nut @@ -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); +} diff --git a/src/modules/procgen/algorithms/BushMesh.cpp b/src/modules/procgen/algorithms/BushMesh.cpp new file mode 100644 index 00000000..9f4b267f --- /dev/null +++ b/src/modules/procgen/algorithms/BushMesh.cpp @@ -0,0 +1,216 @@ +#include "procgen/algorithms/BushMesh.h" + +#include +#include +#include +#include +#include + +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(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 ¶ms, 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 diff --git a/src/modules/procgen/algorithms/BushMesh.h b/src/modules/procgen/algorithms/BushMesh.h new file mode 100644 index 00000000..5fcb0458 --- /dev/null +++ b/src/modules/procgen/algorithms/BushMesh.h @@ -0,0 +1,13 @@ +#pragma once + +#include "procgen/MeshBuild.h" +#include "procgen/Params.h" + +#include + +namespace eve::procgen { + +/** Build a deterministic procedural small bush. Registered as the `mesh.bush` recipe. */ +bool generateBushMesh(const Params ¶ms, MeshBuild &out, std::string &error); + +} // namespace eve::procgen diff --git a/src/modules/procgen/algorithms/MarchingCubes.cpp b/src/modules/procgen/algorithms/MarchingCubes.cpp index 88e629bd..7a09f955 100644 --- a/src/modules/procgen/algorithms/MarchingCubes.cpp +++ b/src/modules/procgen/algorithms/MarchingCubes.cpp @@ -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 @@ -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; diff --git a/test/procgen.cpp b/test/procgen.cpp index 894df8f6..d62132f3 100644 --- a/test/procgen.cpp +++ b/test/procgen.cpp @@ -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", ¶ms, 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 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);