Merge pull request #69 from EVEngine/codex/procgen-bush-generator
procgen: add procedural 3D small bush (mesh.bush)
This commit is contained in:
10 files changed
+581
No files matched your search
@@ -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 或简易网格
|
||||
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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 ¶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
|
||||
@@ -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 ¶ms, 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;
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
Reference in new issue
Block a user