From 702f40de5a2eedad13afa8aaf758f7c3a1d8fa8b Mon Sep 17 00:00:00 2001 From: Lars Ivar Hatledal Date: Sat, 15 Aug 2026 17:26:35 +0200 Subject: [PATCH 1/4] boids wip [skip ci] --- examples/extras/CMakeLists.txt | 1 + examples/extras/fauna/CMakeLists.txt | 1 + examples/extras/fauna/flock_demo.cpp | 798 +++++ include/threepp/extras/fauna/BirdGeometry.hpp | 1154 +++++++ include/threepp/extras/fauna/Flock.hpp | 2894 +++++++++++++++++ include/threepp/extras/fauna/PerchIndex.hpp | 1452 +++++++++ 6 files changed, 6300 insertions(+) create mode 100644 examples/extras/fauna/CMakeLists.txt create mode 100644 examples/extras/fauna/flock_demo.cpp create mode 100644 include/threepp/extras/fauna/BirdGeometry.hpp create mode 100644 include/threepp/extras/fauna/Flock.hpp create mode 100644 include/threepp/extras/fauna/PerchIndex.hpp diff --git a/examples/extras/CMakeLists.txt b/examples/extras/CMakeLists.txt index feaed5567..f712073ff 100644 --- a/examples/extras/CMakeLists.txt +++ b/examples/extras/CMakeLists.txt @@ -2,6 +2,7 @@ add_subdirectory(architecture) add_subdirectory(core) add_subdirectory(curves) +add_subdirectory(fauna) add_subdirectory(sensors) add_subdirectory(terrain) add_subdirectory(vegetation) diff --git a/examples/extras/fauna/CMakeLists.txt b/examples/extras/fauna/CMakeLists.txt new file mode 100644 index 000000000..2274ddf1f --- /dev/null +++ b/examples/extras/fauna/CMakeLists.txt @@ -0,0 +1 @@ +add_example(NAME "flock_demo" LINK_IMGUI) diff --git a/examples/extras/fauna/flock_demo.cpp b/examples/extras/fauna/flock_demo.cpp new file mode 100644 index 000000000..31b5e30aa --- /dev/null +++ b/examples/extras/fauna/flock_demo.cpp @@ -0,0 +1,798 @@ +// Ambient bird flock — the drop-in demo. +// +// A hillside with two rooftops, a tilted rail and three trees, and eighteen +// birds that decide for themselves when to fly, where to land, how long to +// stand about and when to leave. Nothing here scripts a bird. The whole +// example is scenery plus a panel; the flock is three lines. +// +// WHAT THIS EXAMPLE IS ACTUALLY DOCUMENTING is the two questions the subsystem +// generates: "my birds don't move" (answered by updateCount/stalledUpdates in +// the panel) and "my birds never land" (answered by perchCount() and the +// perch-marker toggle — you can SEE that the bake found nothing, which no +// amount of staring at the sky will tell you). +// +// SCENE ORDER IS LOAD-BEARING, AND IT IS THE ONE NON-OBVIOUS THING IN HERE. +// The perch table has a hard cap (PerchIndex::Params::maxPerches, 4096) and is +// filled first-come-first-served in scene-traversal order. A 120 m ground plane +// probed on a 1 m grid offers about fourteen thousand candidates, so adding it +// first would fill the table with grass and leave the rooftops, the rail and +// the trees — the perches anyone actually looks at — with none at all. The +// ground therefore goes in LAST. Nothing crashes if you reorder it; the birds +// just stop using the interesting furniture, which is a far worse bug to have +// to notice. +// +// The foliage is excluded from the bake with setPerchFilter: leaf cards are +// double-sided quads with no inside, so a downward probe lands a bird on a leaf +// three metres from the branch that would be holding it up. +// +// Screenshot mode (--shot / --shoot) runs on a FIXED dt rather than the wall +// clock, so the same binary writes byte-reproducible PNGs, and forces the GL +// backend so it never blocks on createRenderer's interactive prompt. It also +// implies --fast-perch: a forty-second capture cannot show a perch cycle whose +// shortest leg is twenty-five seconds. +// +// flock_demo interactive; orbit, and click to scare +// flock_demo --selftest headless assertions, PASS/FAIL, exit code +// flock_demo --shoot six PNGs into , then exits +// flock_demo --shot the same, into /aaa_caps/ +// --birds N --seed N --frames N --no-ui --fast-perch --gl --vulkan +// --cam x,y,z --look x,y,z override the capture framing (capture_util) + +#include "capture_util.hpp" + +#include "threepp/extras/fauna/Flock.hpp" +#include "threepp/extras/imgui/RendererSettings.hpp" +#include "threepp/extras/vegetation/TreeGenerator.hpp" +#include "threepp/extras/vegetation/TreeTextures.hpp" +#include "threepp/lights/AmbientLight.hpp" +#include "threepp/lights/DirectionalLight.hpp" +#include "threepp/materials/MeshPhongMaterial.hpp" +#include "threepp/threepp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace threepp; + +namespace { + + // ── Scenery ────────────────────────────────────────────────────────── + + constexpr float kGroundSize = 120.f;// m, square + constexpr float kFixedDt = 1.f / 60.f; + + // Two sines, deliberately incommensurate, so the ground is never flat + // enough to hide a bird's feet floating and never steep enough to reject + // every ground perch on slope. The Flock reads the height from the BAKED + // heightfield, not from this function — it exists here only to displace the + // plane, and the two agree because the bake samples the mesh this produces. + [[nodiscard]] float groundHeight(float x, float z) { + + return 0.85f * std::sin(x * 0.047f) * std::cos(z * 0.039f) + + 0.40f * std::sin(x * 0.113f + 1.7f) + + 0.25f * std::cos(z * 0.091f - 0.4f); + } + + [[nodiscard]] std::shared_ptr makeGround() { + + auto geometry = PlaneGeometry::create(kGroundSize, kGroundSize, 40u, 40u); + + // PlaneGeometry lies in local XY with normal +Z; the -90° rotation about + // X below maps local +Z onto world +Y, so displacing local z IS the + // world height. Local y maps onto world -z, hence the negation. + auto* position = geometry->getAttribute("position"); + for (int i = 0, n = position->count(); i < n; ++i) { + const auto vi = static_cast(i); + const float lx = position->getX(vi); + const float ly = position->getY(vi); + position->setXYZ(vi, lx, ly, groundHeight(lx, -ly)); + } + geometry->computeVertexNormals(); + geometry->computeBoundingSphere(); + + auto material = MeshPhongMaterial::create( + MeshPhongMaterial::Params{} + .color(Color(0.24f, 0.31f, 0.17f)) + .shininess(4.f) + .specular(Color(0x0a0a0a))); + + auto ground = Mesh::create(geometry, material); + ground->rotation.x = -math::PI / 2.f; + ground->receiveShadow = true; + ground->name = "ground"; + return ground; + } + + [[nodiscard]] std::shared_ptr makeBlock(const Vector3& size, const Vector3& centre, const Color& colour) { + + auto material = MeshPhongMaterial::create( + MeshPhongMaterial::Params{} + .color(colour) + .shininess(8.f) + .specular(Color(0x111111))); + + auto block = Mesh::create(BoxGeometry::create(size.x, size.y, size.z), material); + block->position.copy(centre); + block->castShadow = true; + block->receiveShadow = true; + return block; + } + + // The thin-perch case, and the reason it is TILTED rather than level. + // + // PerchIndex classifies a spot as walkable purely by slope, so a level rail + // reads as walkable ground and the birds try to stroll along a 10 cm beam. + // Tipping the section 30° puts the top face between walkableSlope (25°) and + // maxSlope (35°): still a legal perch, no longer a walkable one. The birds + // land in a row and stay put, which is what a bird on a wire does. + [[nodiscard]] std::shared_ptr makeRail() { + + auto rail = makeBlock({14.f, 0.10f, 0.10f}, {-6.f, 3.2f, 12.f}, Color(0.30f, 0.30f, 0.33f)); + rail->rotation.x = math::degToRad(30.f); + rail->name = "rail"; + return rail; + } + + struct TreeMeshes { + std::shared_ptr trunk; + std::shared_ptr foliage; + }; + + // Real branching geometry for the bake to meet — the flat-roof case is easy + // and proves nothing. Fixed seeds, so the scene is the same every run. + [[nodiscard]] TreeMeshes makeTree(unsigned int seed, float height, const Vector3& at) { + + vegetation::TreeParams tp; + vegetation::applyPreset(0, tp);// Oak + tp.seed = seed; + tp.trunkHeight = height; + tp.trunkRadius = 0.16f; + tp.crownRadiusX = 2.8f; + tp.crownRadiusZ = 2.8f; + tp.crownHeight = 4.2f; + tp.attractorCount = 380;// modest: the bake builds a BVH per trunk + tp.leafSize = 0.55f; + + vegetation::TreeGenerator gen(tp.seed); + gen.buildSkeleton(tp); + + auto bark = vegetation::makeBarkTextures(256, tp.seed, tp.barkColor, tp.barkStyle); + bark.first->repeat.set(3.f, 0.5f); + bark.second->repeat.set(3.f, 0.5f); + + auto barkMat = MeshPhongMaterial::create( + MeshPhongMaterial::Params{}.color(Color::white).shininess(3.f)); + barkMat->map = bark.first; + barkMat->normalMap = bark.second; + + auto leafMat = MeshPhongMaterial::create( + MeshPhongMaterial::Params{}.color(Color::white).shininess(2.f)); + leafMat->map = vegetation::makeLeafClusterTexture(256, tp.seed, tp.leafColor, tp.leafShape); + leafMat->alphaTest = 0.4f;// below the antialiased margin of the leaflets + leafMat->side = Side::Double; + leafMat->vertexColors = true;// baked canopy occlusion + + TreeMeshes out; + out.trunk = Mesh::create(gen.makeTrunkGeometry(tp), barkMat); + out.foliage = Mesh::create(gen.makeLeafGeometry(tp), leafMat); + out.trunk->name = "branches"; + out.foliage->name = "foliage";// setPerchFilter keys on this + for (auto* m : {out.trunk.get(), out.foliage.get()}) { + m->position.set(at.x, groundHeight(at.x, at.z), at.z); + m->castShadow = true; + m->receiveShadow = true; + } + return out; + } + + // Everything except the flock. Props first, ground LAST — see the banner. + void buildScenery(Object3D& scene) { + + scene.add(makeBlock({12.f, 6.f, 9.f}, {14.f, 3.f, -8.f}, Color(0.52f, 0.47f, 0.42f))); + scene.add(makeBlock({9.f, 11.f, 9.f}, {-13.f, 5.5f, -14.f}, Color(0.46f, 0.44f, 0.46f))); + scene.add(makeRail()); + + struct TreePlan { + unsigned int seed; + float height; + Vector3 at; + }; + const std::array plans{ + TreePlan{4021u, 5.4f, {6.f, 0.f, 9.f}}, + TreePlan{9137u, 4.2f, {-19.f, 0.f, 4.f}}, + TreePlan{2255u, 6.1f, {20.f, 0.f, 16.f}}}; + + for (const auto& plan : plans) { + const auto tree = makeTree(plan.seed, plan.height, plan.at); + scene.add(tree.trunk); + scene.add(tree.foliage); + } + + scene.add(makeGround()); + } + + // ── Perch markers ──────────────────────────────────────────────────── + // + // The answer to "my birds never land". An empty cloud means the bake found + // nothing, which is a scene problem, not a flock problem — and it takes one + // glance instead of an afternoon. + [[nodiscard]] std::shared_ptr makePerchMarkers(const std::vector& spots) { + + std::vector position; + std::vector colour; + position.reserve(spots.size() * 3u); + colour.reserve(spots.size() * 3u); + + for (const auto& s : spots) { + // Lifted off the surface so the marker is not z-fighting the very + // face it was baked from. + position.push_back(s.position.x + s.normal.x * 0.03f); + position.push_back(s.position.y + s.normal.y * 0.03f); + position.push_back(s.position.z + s.normal.z * 0.03f); + + const bool ground = s.ground; + const bool walk = s.walkable; + colour.push_back(walk ? (ground ? 0.25f : 0.35f) : 1.00f); + colour.push_back(walk ? (ground ? 0.85f : 1.00f) : 0.55f); + colour.push_back(walk ? (ground ? 0.35f : 0.45f) : 0.10f); + } + + auto geometry = BufferGeometry::create(); + geometry->setAttribute("position", FloatBufferAttribute::create(std::move(position), 3)); + geometry->setAttribute("color", FloatBufferAttribute::create(std::move(colour), 3)); + geometry->computeBoundingSphere(); + + auto material = PointsMaterial::create( + PointsMaterial::Params{}.color(Color::white).size(0.22f).sizeAttenuation(true)); + material->vertexColors = true; + + return Points::create(geometry, material); + } + + // ── Command line ───────────────────────────────────────────────────── + + struct Options { + int birds = 18; + unsigned int seed = 1337u; + bool noUi = false; + bool fastPerch = false; + bool selfTest = false; + std::string shotPrefix;// --shot PREFIX → /aaa_caps/ + std::string shootDir; // --shoot DIR → DIR/ + int frames = 2401; + std::optional api;// unset → interactive backend prompt + }; + + [[nodiscard]] Options parseOptions(int argc, char** argv) { + + Options o; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--birds" && i + 1 < argc) o.birds = std::atoi(argv[++i]); + else if (a == "--seed" && i + 1 < argc) o.seed = static_cast(std::max(0, std::atoi(argv[++i]))); + else if (a == "--shot" && i + 1 < argc) o.shotPrefix = argv[++i]; + else if (a == "--shoot" && i + 1 < argc) o.shootDir = argv[++i]; + else if (a == "--frames" && i + 1 < argc) o.frames = std::atoi(argv[++i]); + else if (a == "--no-ui") o.noUi = true; + else if (a == "--fast-perch") o.fastPerch = true; + else if (a == "--selftest") o.selfTest = true; + else if (a == "--gl") o.api = GraphicsAPI::OpenGL; + else if (a == "--vulkan") o.api = GraphicsAPI::Vulkan; + } + + // A capture is forty seconds long and the stock perch cycle is 25–90 s + // aloft, so an uncompressed capture is forty seconds of cruising and + // proves nothing about the half of the state machine anyone doubts. + // --shot / --shoot therefore imply --fast-perch. + if (!o.shotPrefix.empty() || !o.shootDir.empty()) o.fastPerch = true; + + return o; + } + + [[nodiscard]] Flock::Params makeFlockParams(const Options& o) { + + Flock::Params p; + p.seed = o.seed; + p.birdCount = std::clamp(o.birds, 0, 256); + // Slightly tighter and lower than the stock territory so the flock stays + // over the scenery instead of loitering off the edge of the frame. + // + // DO NOT SHRINK roamRadius MUCH FURTHER. A startled flock overshoots the + // territory by a roughly FIXED distance in metres (evade runs at 1.5x + // target speed for up to a second), not by a fixed fraction of the + // radius, so the "inside 1.5x roamRadius" guarantee that --selftest + // checks gets tighter as the radius gets smaller. At 30 m it is already + // down to half a metre of slack. + p.home.set(0.f, 14.f, 0.f); + p.roamRadius = 38.f; + p.cruiseAltitude = 12.f;// just over the tall roof — they use the furniture + + // A jackdaw rather than the default starling: at the 30–50 m this scene + // is watched from, a 0.42 m starling is six pixels of dark smudge and + // every cue this subsystem spends its budget on — the wingtip wash, the + // spanwise twist, the tail fan — is below the resolution of the image. + // massKg drives the beat allometrically, so the bigger bird also gets + // the slower, heavier stroke that goes with it (~5.2 Hz, not 8.5). + p.shape.bodyLength = 0.32f; + p.shape.bodyRadius = 0.042f; + p.shape.wingSpan = 0.66f; + p.massKg = 0.35f; + + p.birdsCastShadow = true;// the deformation is in the verts, so it is correct + + // A 1 m probe grid finds the branch tops; 1.2 m thinning keeps the + // ground from reading as a regular lattice of landing pads. + p.perch.probeSpacing = 1.0f; + p.perch.perchMinSeparation = 1.2f; + + if (o.fastPerch) { + p.perchIntervalMin = 4.f; + p.perchIntervalMax = 10.f; + p.restIntervalMin = 3.f; + p.restIntervalMax = 8.f; + } + return p; + } + + // Leaf cards have no inside; a probe that lands on one perches a bird three + // metres from the branch that should be holding it up. + [[nodiscard]] std::function perchFilter() { + + return [](const Mesh& m) { return m.name != "foliage"; }; + } + + [[nodiscard]] const char* stateName(Flock::BirdState s) { + + switch (s) { + case Flock::BirdState::Cruise: return "Cruise"; + case Flock::BirdState::Approach: return "Approach"; + case Flock::BirdState::Flare: return "Flare"; + case Flock::BirdState::Perched: return "Perched"; + case Flock::BirdState::Launch: return "Launch"; + case Flock::BirdState::Evade: return "Evade"; + } + return "?"; + } + + [[nodiscard]] std::array stateHistogram(const Flock& flock) { + + std::array counts{}; + for (int i = 0; i < flock.birdCount(); ++i) { + counts[static_cast(flock.stateOf(i))]++; + } + return counts; + } + + // ── --selftest ─────────────────────────────────────────────────────── + // + // No window, no renderer, no wall clock. Builds the same scenery, bakes it + // blocking, and runs the assertions that a positions-only replay cannot + // see — chiefly that a landing ever fires at all. A state machine that + // compiles, runs, produces no NaN and never once perches is exactly the bug + // this catches. + [[nodiscard]] int runSelfTest(const Options& o) { + + bool pass = true; + const auto check = [&pass](const char* what, bool ok) { + std::cout << (ok ? "PASS " : "FAIL ") << what << std::endl; + if (!ok) pass = false; + }; + + Scene scene; + buildScenery(scene); + + auto flock = Flock::create(makeFlockParams(o)); + flock->setPerchFilter(perchFilter()); + scene.add(flock); + flock->bakePerchesBlocking(scene); + + const auto& params = flock->params(); + const float homeY = params.home.y; + const float bound = 1.5f * params.roamRadius; + const int cap = static_cast(std::ceil(params.maxPerchedFraction * + static_cast(flock->birdCount()))); + + const auto finite = [&](const Flock& f) { + for (int i = 0; i < f.birdCount(); ++i) { + const Vector3& p = f.birdPosition(i); + const Vector3& v = f.birdVelocity(i); + if (!std::isfinite(p.x) || !std::isfinite(p.y) || !std::isfinite(p.z)) return false; + if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) return false; + } + return true; + }; + + // Pass A — settled soak. Items 6 and 10. + check("perchCount() > 0 after a blocking bake", flock->perchCount() > 0); + int perchedMin = flock->birdCount() + 1; + int perchedMax = 0; + bool allFinite = true; + for (int step = 0; step < 3600; ++step) { + flock->update(kFixedDt); + allFinite = allFinite && finite(*flock); + perchedMin = std::min(perchedMin, flock->perchedCount()); + perchedMax = std::max(perchedMax, flock->perchedCount()); + } + const bool populated = flock->birdCount() > 0; + check("soak: every position and velocity finite", allFinite); + check("soak: at least one bird perched at some point", !populated || perchedMax > 0); + check("soak: the sky never emptied", !populated || perchedMin < flock->birdCount()); + check("soak: perched never exceeded maxPerchedFraction", perchedMax <= cap); + + // Pass B — repeated startles. Item 8: the territory has to hold. + float worstRadius = 0.f; + allFinite = true; + for (int step = 0; step < 3600; ++step) { + if (step % 100 == 0) flock->startle(params.home, 1e9f); + flock->update(kFixedDt); + allFinite = allFinite && finite(*flock); + for (int i = 0; i < flock->birdCount(); ++i) { + const Vector3& p = flock->birdPosition(i); + const float dx = p.x - params.home.x; + const float dz = p.z - params.home.z; + worstRadius = std::max(worstRadius, std::sqrt(dx * dx + dz * dz)); + } + } + check("startled: every position and velocity finite", allFinite); + { + std::ostringstream m; + m << "startled: flock stayed inside 1.5x roamRadius (" << worstRadius << " m of " << bound << ")"; + check(m.str().c_str(), worstRadius <= bound); + } + + // Pass C — no perches at all. Item 5: birds fly, nothing NaNs, nothing + // lands, nothing falls out of the world, nothing logs. + { + auto lonely = Flock::create(makeFlockParams(o)); + bool everPerched = false; + bool aboveFloor = true; + allFinite = true; + for (int step = 0; step < 600; ++step) { + lonely->update(kFixedDt); + allFinite = allFinite && finite(*lonely); + for (int i = 0; i < lonely->birdCount(); ++i) { + everPerched = everPerched || lonely->stateOf(i) == Flock::BirdState::Perched; + aboveFloor = aboveFloor && lonely->birdPosition(i).y > homeY - 200.f; + } + } + check("no perches: perchCount() == 0", lonely->perchCount() == 0); + check("no perches: every position and velocity finite", allFinite); + check("no perches: no bird ever perched", !everPerched); + check("no perches: no bird fell out of the world", aboveFloor); + } + + std::cout << (pass ? "PASS" : "FAIL") << " flock_demo --selftest" << std::endl; + return pass ? 0 : 1; + } + +}// namespace + + +int main(int argc, char** argv) { + + // ── The literal drop-in, verbatim and before any tuning ────────────── + // + // auto birds = Flock::create(); + // scene->add(birds); + // canvas.animate([&] { birds->update(clock.getDelta()); renderer->render(*scene, *camera); }); + // + // plus `birds->bakePerches(*scene);` if you want them to land on anything. + // That is the entire contract. Everything below is this demo showing its + // working: flags, a panel, click-to-scare and a reproducible capture path. + + const Options options = parseOptions(argc, argv); + const capture::Args shotArgs = capture::parseArgs(argc, argv); + + if (options.selfTest) return runSelfTest(options); + + const bool capturing = !options.shotPrefix.empty() || !options.shootDir.empty(); + const std::string prefix = options.shotPrefix.empty() ? std::string("flock") : options.shotPrefix; + const int lastFrame = shotArgs.frames.value_or(options.frames); + + // A capture must never sit on createRenderer's interactive prompt, where + // only the literal "2" selects Vulkan and nothing at all selects a default. + std::optional api = options.api; + if (capturing && !api) api = GraphicsAPI::OpenGL; + + Canvas canvas("Ambient Flock", {{"vsync", !capturing}, {"aa", 4}}); + auto renderer = createRenderer(canvas, api); + renderer->setClearColor(Color(0.62f, 0.72f, 0.84f)); + renderer->shadowMap().enabled = true; + renderer->shadowMap().type = ShadowMap::PFCSoft; + renderer->toneMapping = ToneMapping::ACESFilmic; + renderer->toneMappingExposure = 1.05f; + + // The whole rendering design of this subsystem exists so that it survives + // both backends, so say which one is on screen. + const bool isGl = dynamic_cast(renderer.get()) != nullptr; + std::cout << "[flock] backend: " << (isGl ? "OpenGL" : "Vulkan") << std::endl; + + // DECLARED BEFORE THE SCENE ON PURPOSE. setObserver() takes a raw + // non-owning pointer that is dereferenced once per bird per frame, and the + // scene owns the flock — so the camera has to outlive the scene, which in a + // block-scoped main means it has to be declared first. + // High and well back: the loiter volume is a 30 m sphere and the birds + // drift inside it, so a camera framed on the scenery alone loses the flock + // within a minute. + PerspectiveCamera camera(50.f, canvas.aspect(), 0.1f, 400.f); + camera.position.set(26.f, 12.f, 26.f); + + Scene scene; + scene.background = Color(0.62f, 0.72f, 0.84f); + // Fog is what stops distant birds being distractingly crisp; it is the main + // reason they sit IN the scene instead of on top of it. + scene.fog = Fog(Color(0.66f, 0.75f, 0.86f), 25.f, 140.f); + + scene.add(AmbientLight::create(Color(0.62f, 0.70f, 0.82f), 0.55f)); + + // A low sun on purpose: a grazing key is what makes the spanwise wing twist + // scintillate across the flock, and that scintillation is the strongest + // single argument for baking these vertices on the CPU at all. + auto sun = DirectionalLight::create(Color(1.0f, 0.94f, 0.82f), 2.2f); + sun->position.set(-38.f, 22.f, 26.f); + sun->castShadow = true; + { + auto* shadowCam = sun->shadow->camera->as(); + shadowCam->left = shadowCam->bottom = -45.f; + shadowCam->right = shadowCam->top = 45.f; + shadowCam->nearPlane = 1.f; + shadowCam->farPlane = 160.f; + sun->shadow->mapSize.set(2048, 2048); + sun->shadow->bias = -0.0005f; + } + scene.add(sun); + + buildScenery(scene); + + Flock::Params params = makeFlockParams(options); + std::shared_ptr flock; + std::shared_ptr markers; + bool showMarkers = false; + bool markersDirty = true; + + const auto refreshMarkers = [&] { + if (markers) { + scene.remove(*markers); + markers.reset(); + } + // A bake that found nothing leaves the cloud NULL rather than adding an + // empty one — which is the honest rendering of "there is nowhere to + // land", and it is the whole point of the toggle. + const auto& spots = flock->perchIndex().spots(); + if (spots.empty()) return; + + markers = makePerchMarkers(spots); + markers->visible = showMarkers; + markers->name = "perchMarkers"; + scene.add(markers); + }; + + // Params are fixed at construction — there is no setCount and no live + // reseed, deliberately (topology is immutable for the object's lifetime), + // so every knob that is not wind rebuilds the flock and re-bakes. + const auto rebuild = [&] { + if (flock) { + flock->setObserver(nullptr); + flock->setDisturbanceSource(nullptr); + scene.remove(*flock); + } + flock = Flock::create(params); + flock->setPerchFilter(perchFilter()); + scene.add(flock); + flock->setObserver(capturing ? nullptr : &camera); + if (capturing) flock->bakePerchesBlocking(scene); + else flock->bakePerches(scene); + markersDirty = true; + }; + + rebuild(); + + // --cam / --look are applied BEFORE OrbitControls is constructed: the + // controls derive their spherical state from the camera they are handed, so + // moving the camera afterwards would be undone by the first update(). + Vector3 target{0.f, 10.f, 0.f}; + if (shotArgs.camPos) camera.position.copy(*shotArgs.camPos); + if (shotArgs.camTarget) target.copy(*shotArgs.camTarget); + camera.lookAt(target); + + OrbitControls controls{camera, canvas}; + controls.target.copy(target); + controls.enabled = !capturing; + controls.update(); + + canvas.onWindowResize([&](WindowSize size) { + camera.aspect = size.aspect(); + camera.updateProjectionMatrix(); + renderer->setSize(size); + }); + + // ── Click to scare ─────────────────────────────────────────────────── + // + // One Raycaster, one startle(). recursive MUST be true: Raycaster's default + // is false and the trees are nested, so a non-recursive test quietly misses + // half the scene (cf. examples/misc/raycast.cpp). + Raycaster raycaster; + MouseDownListener clickToScare([&](int button, const Vector2& pos) { + if (button != 0 || !flock) return; + const auto size = canvas.size(); + const Vector2 ndc{(pos.x / static_cast(size.width())) * 2.f - 1.f, + -(pos.y / static_cast(size.height())) * 2.f + 1.f}; + raycaster.setFromCamera(ndc, camera); + const auto hits = raycaster.intersectObjects(scene.children, true); + if (hits.empty()) return; + flock->startle(hits.front().point, 40.f); + }); + if (!capturing) canvas.addMouseListener(clickToScare); + + // ── Panel ──────────────────────────────────────────────────────────── + bool rebuildRequested = false; + const auto touched = [&](bool changed) { rebuildRequested = rebuildRequested || changed; }; + + std::unique_ptr ui; + if (!capturing && !options.noUi) { + ui = std::make_unique(canvas, *renderer, [&] { + const auto counts = stateHistogram(*flock); + + ImGui::Text("birds %d perched %d flying %d", + flock->birdCount(), flock->perchedCount(), flock->flyingCount()); + ImGui::Text("perches %llu", static_cast(flock->perchCount())); + if (!flock->bakeComplete()) { + ImGui::ProgressBar(flock->bakeProgress(), ImVec2(-1, 0), "baking"); + } + ImGui::Text("updates %llu stalled %llu", + static_cast(flock->updateCount()), + static_cast(flock->stalledUpdates())); + + ImGui::SeparatorText("States"); + for (std::size_t s = 0; s < counts.size(); ++s) { + const float frac = flock->birdCount() > 0 + ? static_cast(counts[s]) / static_cast(flock->birdCount()) + : 0.f; + char label[32]; + std::snprintf(label, sizeof(label), "%d", counts[s]); + ImGui::ProgressBar(frac, ImVec2(-120, 0), label); + ImGui::SameLine(); + ImGui::TextUnformatted(stateName(static_cast(s))); + } + + ImGui::SeparatorText("Population (rebuilds)"); + touched(ImGui::SliderInt("Birds", ¶ms.birdCount, 0, 256)); + { + int seed = static_cast(params.seed); + if (ImGui::InputInt("Seed", &seed)) { + params.seed = static_cast(std::max(seed, 0)); + rebuildRequested = true; + } + } + touched(ImGui::SliderFloat("Cruise speed", ¶ms.cruiseSpeed, 2.f, 16.f, "%.1f m/s")); + touched(ImGui::SliderFloat("Wingbeat", ¶ms.wingbeatHz, 0.f, 20.f, "%.1f Hz (0 = allometric)")); + touched(ImGui::SliderFloat("Perch interval min", ¶ms.perchIntervalMin, 1.f, 120.f, "%.0f s")); + touched(ImGui::SliderFloat("Perch interval max", ¶ms.perchIntervalMax, 1.f, 180.f, "%.0f s")); + touched(ImGui::SliderFloat("Rest interval min", ¶ms.restIntervalMin, 1.f, 120.f, "%.0f s")); + touched(ImGui::SliderFloat("Rest interval max", ¶ms.restIntervalMax, 1.f, 180.f, "%.0f s")); + + ImGui::SeparatorText("Disturb"); + if (ImGui::Button("Scare", ImVec2(-1, 0))) { + flock->startle(params.home, 1e9f); + } + if (ImGui::Checkbox("Perch markers", &showMarkers) && markers) { + markers->visible = showMarkers; + } + }, + "Ambient Flock"); + } + + if (!capturing) { + std::cout << "[flock] orbit: drag to rotate, wheel to zoom, left-click the scene to scare them." + << std::endl; + } + + // ── Capture script ─────────────────────────────────────────────────── + // + // Fixed dt, fixed camera, blocking bake before frame 1, so the whole run is + // a pure function of the binary and the seed. + struct Shot { + int frame; + const char* suffix; + bool close; + }; + constexpr std::array kShots{{ + {90, "f0090_formed", false}, + {900, "f0900_approach", false}, + {1500, "f1500_close", true}, + {1800, "f1800_mixed", false}, + {2100, "f2100_ripple", false}, + {2400, "f2400_reformed", false}, + }}; + constexpr int kStartleFrame = 2070; + + const auto outputPath = [&](const char* suffix) { + const std::string file = prefix + "_" + suffix + ".png"; + if (!options.shootDir.empty()) { + std::filesystem::path p = std::filesystem::path(options.shootDir) / file; + std::filesystem::create_directories(p.parent_path()); + return p; + } + return capture::shotOutputPath(file); + }; + + // A second camera for the close-up so the wide framing is never disturbed: + // the simulation does not know a picture is being taken. + PerspectiveCamera closeCam(34.f, canvas.aspect(), 0.05f, 300.f); + + Clock clock; + int frame = 0; + + canvas.animate([&] { + if (capturing) { + + if (frame == kStartleFrame) flock->startle({6.f, 4.f, -3.f}, 45.f); + + flock->update(kFixedDt); + renderer->render(scene, camera); + + for (const auto& shot : kShots) { + if (shot.frame != frame) continue; + + if (shot.close) { + // Frame a perched bird if there is one — folded wings, legs + // down and a planted foot is the pose worth inspecting. + int subject = 0; + for (int i = 0; i < flock->birdCount(); ++i) { + if (flock->stateOf(i) == Flock::BirdState::Perched) { + subject = i; + break; + } + } + const Vector3 p = flock->birdPosition(subject); + closeCam.aspect = camera.aspect; + closeCam.updateProjectionMatrix(); + closeCam.position.set(p.x + 0.62f, p.y + 0.36f, p.z + 0.62f); + closeCam.lookAt(p); + renderer->render(scene, closeCam); + } + + const auto path = outputPath(shot.suffix); + renderer->writeFramebuffer(path); + std::cout << "wrote " << std::filesystem::absolute(path).string() << std::endl; + } + + if (++frame >= lastFrame) { + std::cout << "birds=" << flock->birdCount() + << " perched=" << flock->perchedCount() + << " flying=" << flock->flyingCount() + << " perches=" << flock->perchCount() + << " stalled=" << flock->stalledUpdates() + << std::endl; + std::exit(0); + } + return; + } + + controls.update(); + + if (rebuildRequested && !ImGui::IsAnyItemActive()) { + rebuild(); + rebuildRequested = false; + } + if (markersDirty && flock->bakeComplete()) { + refreshMarkers(); + markersDirty = false; + } + + flock->update(clock.getDelta()); + renderer->render(scene, camera); + if (ui) ui->render(); + }); +} diff --git a/include/threepp/extras/fauna/BirdGeometry.hpp b/include/threepp/extras/fauna/BirdGeometry.hpp new file mode 100644 index 000000000..203d0c7cc --- /dev/null +++ b/include/threepp/extras/fauna/BirdGeometry.hpp @@ -0,0 +1,1154 @@ +// Procedural low-poly bird — rest-pose template, static index buffer, baked +// countershading, and the per-frame pose bake. +// +// One bird is 94 vertices / 152 triangles. The geometry exists to carry a +// SILHOUETTE and a MOTION, not surface detail: at the 15–80 m the flock is +// meant to be seen from, the head is two pixels and the legs are one. Every +// vertex is therefore spent on wing planform, tail outline and the parts that +// move. +// +// SHADING IS PAINTED IN, NOT COMPUTED. The look comes from a per-vertex colour +// attribute written once — dark back, pale belly, dark primaries, pale +// underwing, dark tail band. Per-vertex colour multiplies albedo on BOTH +// backends (color_fragment.glsl:8; vulkan/shaders/gbuffer.frag:683), which +// makes it the one shading tool guaranteed to survive whichever renderer the +// host picks at the prompt — and it means a host scene with weak lighting still +// reads as a bird rather than a grey dart. `flatShading` has ZERO occurrences in +// the Vulkan backend, so it is never used here; the wing's hard leading- and +// trailing-edge creases come from split vertices instead. +// +// Header-only, dependency-free beyond threepp core. +// +// Coordinate convention: local +Z forward (bill), +Y up (dorsal). +X is the +// side of wing index 0; -X is wing index 1. The two wings are exact mirrors and +// every asymmetry is expressed per wing INDEX, so anatomical handedness never +// enters the code. +// +// ── Three things a reader will want to know before trusting this file ──── +// +// WINDING IS VERIFIED PER TRIANGLE, NOT ASSUMED. The layout tables give ring +// traversals; whether a given traversal is counter-clockwise *as seen from +// outside* depends on the sign of the ring parameterisation, and getting it +// wrong is invisible under Side::DoubleSide and catastrophic under +// Side::FrontSide — half the bird vanishes and the other half turns inside out +// only when the camera crosses a plane. Every quad and fan below was checked by +// evaluating (b−a)×(c−a) against the outward direction at that face's own +// position, and the emitters carry the answer as a flag. Triangle COUNT and the +// per-part index RANGES are exactly as tabulated; only the corner order is the +// verified one. The mirrored wing is the interesting case: mirroring across X +// flips handedness, so wing 1's quads take the opposite corner order from wing +// 0's. The legs are NOT mirrored — both hip rings use the same ψ table and only +// the anchor's x differs — so both legs take the same order as each other. +// +// THE WING IS A CHAIN, NOT FIVE ROTATIONS ABOUT ONE PIVOT. Rotating each +// spanwise station independently about the shoulder pins every vertex to its +// rest radius and makes the distance between stations breathe by 4–8 % every +// beat: the wing pumps and fans instead of whipping. Here a running orthonormal +// frame walks outward one fixed-length segment at a time, so the wing CANNOT +// stretch whatever the constants say, and the vertex normal falls out of the +// same frame for free — which is why computeVertexNormals() is never called. +// +// THE ONE DISCONTINUOUS INPUT IS `BirdPose::feetPlanted`. Everything else in +// BirdPose enters the vertex positions continuously, so every state blend the +// caller drives (flap → glide, glide → fold, tuck → extend) is C¹ for free. +// `feetPlanted` is a bool and flipping it teleports the foot from the hanging +// position to `footWorld`. The caller MUST flip it on the frame those two +// coincide; that is the whole anti-skate contract and this file cannot enforce +// it. + +#ifndef THREEPP_EXTRAS_FAUNA_BIRDGEOMETRY_HPP +#define THREEPP_EXTRAS_FAUNA_BIRDGEOMETRY_HPP + +#include "threepp/core/Assert.hpp" +#include "threepp/math/Color.hpp" +#include "threepp/math/MathUtils.hpp" +#include "threepp/math/Vector3.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace threepp::fauna { + + // ── Fixed topology. These are compile-time constants, not knobs. ───── + inline constexpr int kVertsPerBird = 94; + inline constexpr int kTrisPerBird = 152; + inline constexpr int kIndicesPerBird = 456; + inline constexpr int kWingStations = 5;// per wing + inline constexpr int kBodyRings = 4; + inline constexpr int kBodyRadial = 5; + + // Vertex-range tags. See the layout table below for the exact index ranges + // these name. + enum class BirdPart : std::uint8_t { + Body = 0, + Head = 1, + Wing0 = 2, + Wing1 = 3, + Tail = 4, + Leg0 = 5, + Leg1 = 6, + }; + + // ── Proportions of the unit-scale bird, metres ─────────────────────── + struct BirdShape { + float bodyLength = 0.22f; // m, bill tip to tail tip + float bodyRadius = 0.030f;// m, max half-width of the body spindle + float wingSpan = 0.42f; // m, tip to tip, fully extended + float tailFork = 0.0f; // -1 forked .. +1 wedge; 0 = square + + bool operator==(const BirdShape&) const = default; + }; + + // ── Plumage. LINEAR-space RGB — these multiply albedo directly. ────── + struct BirdPlumage { + Color back{0.125f, 0.118f, 0.112f}; // dorsal + Color belly{0.700f, 0.680f, 0.640f};// ventral + Color cap{0.055f, 0.055f, 0.065f}; // crown + bill + Color leg{0.330f, 0.265f, 0.190f}; + float wingtipDark = 0.55f; // 0..1 multiplier at the primaries + float tailBandDark = 0.45f; // 0..1 multiplier over the rear 25% of the tail + float capStrength = 0.60f; // 0..1 blend of `cap` into the crown + float lightnessJitter = 0.06f;// ± per-bird value scatter + + bool operator==(const BirdPlumage&) const = default; + }; + + // ── The rest-pose template. Built ONCE, shared by every bird. ──────── + struct BirdTemplate { + std::array pos{}; // rest position, body space + std::array nrm{}; // rest normal, body space, unit length + std::array span{}; // wing verts: s in 0..1 (0 shoulder, 1 tip). 0 elsewhere. + std::array station{};// wing verts: 0..4. 0 elsewhere. + std::array part{}; + + Vector3 neckPivot{}; // body space + std::array wingRoot{}; // body space, index 0 then 1 + std::array hipAnchor{};// body space, index 0 then 1 + Vector3 tailRoot{}; // body space + float legLength = 0.f; // m, hip to foot at full extension + float tailLength = 0.f; // m + std::array segSpan{};// m; segSpan[0] is unused (0) + }; + + // ── Wingbeat kinematics ────────────────────────────────────────────── + // + // THESE LIVE HERE BECAUSE poseBird() IS A PURE FUNCTION OF (template, pose). + // The five-argument poseBird() below is the frozen A↔C contract and it takes + // no parameter block, so the stroke constants cannot be read out of the + // simulation's Params at call time. They are defaulted to exactly the values + // the flock's own "Wingbeat kinematics" block carries; a caller that wants to + // expose them as live knobs routes them through the six-argument overload + // instead of editing this struct. Keeping them in one named aggregate — with + // an operator== like every other leaf here — is what stops the two copies + // silently drifting apart. + struct BirdKinematics { + float strokeDown = 0.88f; // rad below horizontal (50°) + float strokeUp = 0.58f; // rad above horizontal (33°) + float downstrokeFrac = 0.42f;// fraction of the cycle spent going down + float spanLagCycles = 0.13f; // root→tip travelling-wave lag + float twistAmp = 0.34f; // rad tip twist — the scintillation + float wristFlex = 1.00f; // rad hand fold at mid-upstroke + float glideDihedral = 0.10f; // rad, static dihedral held while gliding + float perchSweep = 1.35f; // rad, folded-wing sweep + float perchLift = 0.20f; // rad, folded-wing lift + + bool operator==(const BirdKinematics&) const = default; + }; + + inline constexpr BirdKinematics kDefaultKinematics{}; + + // ── The A↔C contract. Everything the pose bake needs, and nothing else. ── + // Filled by the flock's update(), consumed by poseBird(). poseBird() reads it + // and writes vertices; it never reads or writes flock state, never allocates, + // and never calls back. + struct BirdPose { + // World placement. bx/by/bz are the ORTHONORMAL body basis columns + // (bx = the +X side, by = up, bz = forward), already carrying bank and + // pitch. det[bx by bz] == +1 always; a mirrored basis is a bug. + Vector3 pos{}; + Vector3 bx{1, 0, 0}; + Vector3 by{0, 1, 0}; + Vector3 bz{0, 0, 1}; + float scale = 1.f;// per-bird size multiplier, 0.90..1.10 + + // Wingbeat + float cyclePos = 0.f; // 0..1, 0 = top of stroke + float flapWeight = 1.f;// 0..1 amplitude envelope; 0 = glide + float beatAmp = 1.f; // per-bird amplitude multiplier + float wingAsym = 0.f; // added to wing 0's amplitude, subtracted from wing 1's + float perchFold = 0.f; // 0..1, wings folded against the flank + + // Head (radians, about the neck pivot) + float headYaw = 0.f; + float headPitch = 0.f; + float headRoll = 0.f; + float headLead = 0.f;// m, walking head bob: +Z offset of the whole head + + // Tail + float tailSpread = 0.10f;// 0..1 + float tailPitch = 0.f; // rad, positive = trailing edge down + float tailRoll = 0.f; // rad + + // Legs. Feet are WORLD positions and are written straight through — + // this is what stops a walking bird skating. + std::array footWorld{}; + float legExtend = 0.f; // 0..1; 0 = tucked flush inside the body + bool feetPlanted = false;// false => footWorld is ignored, legs hang from the hip + + // Body + float bodyLift = 0.f;// m, vertical offset in the body frame (landing spring, hop arc) + }; + + + namespace detail { + + // ── Layout tables. Retyping one of these wrong is the single most + // likely defect in this file, so every one of them is asserted against a + // geometric invariant at the bottom of makeBirdTemplate(). ─────────── + + // Body: ring i, vertex j at index 5i+j. θ_j = j·72° measured from +Y. + inline constexpr std::array kRingZ{-0.26f, -0.09f, 0.08f, 0.20f};// × bodyLength + inline constexpr std::array kRingF{0.42f, 1.00f, 0.95f, 0.60f}; // radius factor + inline constexpr std::array kRingY{-0.05f, -0.02f, 0.06f, 0.20f};// × bodyRadius + + // The body cross-section is an ellipse, taller than it is wide — a bird + // seen head-on is a keel, not a tube. + inline constexpr float kEllipseX = 0.90f; + inline constexpr float kEllipseY = 1.05f; + + // Wing: station t, corner c at index 4t+c, c = {0 LEtop, 1 TEtop, 2 TEbot, 3 LEbot}. + inline constexpr std::array kStationS{0.00f, 0.26f, 0.52f, 0.78f, 1.00f}; + inline constexpr std::array kStationChord{1.00f, 0.94f, 0.74f, 0.46f, 0.11f}; // × rootChord + inline constexpr std::array kStationZLE{0.34f, 0.40f, 0.30f, 0.02f, -0.34f}; // × rootChord + inline constexpr std::array kStationThick{0.055f, 0.045f, 0.030f, 0.018f, 0.006f};// × rootChord + inline constexpr std::array kSegSpanFrac{0.00f, 0.26f, 0.26f, 0.26f, 0.22f}; // × halfSpan + + // s^1.3, the travelling-wave lag exponent. s is a FIXED table, so this is + // a constant — evaluating std::pow here per station per wing per bird per + // frame is ten pow() calls a bird, about a third of the whole pose budget, + // for a number that never changes. + inline constexpr std::array kSpanLagPow{0.f, 0.173566f, 0.427370f, 0.723967f, 1.f}; + + inline constexpr float kRootChordFrac = 0.42f;// rootChord / halfSpan + inline constexpr float kTailLengthFrac = 0.22f;// tailLength / bodyLength + inline constexpr float kLegLengthFrac = 0.26f; // legLength / bodyLength + + // Tail fan half-angle at tailSpread 0 and 1. + inline constexpr float kTailAngleMin = 0.14f;// rad + inline constexpr float kTailAngleMax = 0.72f;// rad + + // Vertices 0..81 are posed in body space and then transformed as a block; + // 82..93 (the legs) are written straight to world because their feet are. + inline constexpr int kLocalVerts = 82; + + // (x, y) in units of bodyRadius against ring `i`'s elliptical shell. + // < 1 means strictly inside. Written as a constexpr function rather than + // a run of locals so the invariant checks below leave nothing behind in a + // release build, where THREEPP_ASSERT does not evaluate its argument. + [[nodiscard]] inline constexpr float shellTest(float x, float y, int i) { + + const std::size_t ii = static_cast(i); + const float u = x / (kEllipseX * kRingF[ii]); + const float v = (y - kRingY[ii]) / (kEllipseY * kRingF[ii]); + return u * u + v * v; + } + + // Radius factor of the tail cone at `zFrac` · bodyLength, interpolating + // ring 0 toward the tail apex at -0.34 L. + [[nodiscard]] inline constexpr float tailConeFactor(float zFrac) { + + const float t = (zFrac - kRingZ[0]) / (-0.34f - kRingZ[0]); + return kRingF[0] * (1.f - t); + } + + [[nodiscard]] inline bool isFinite(const Vector3& v) { + + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); + } + + // frac01 handles a NEGATIVE argument, which std::fmod does not: fmod keeps + // the sign, so a station running a lagged cycle would jump to −0.87 and + // read the stroke backwards from a discontinuity at 0. + [[nodiscard]] inline float frac01(float x) { + + return x - std::floor(x); + } + + // Rodrigues on one vector. All three components are computed from the + // ORIGINAL v; writing them back one at a time as you go silently rotates + // about a moving target. + inline void rodrigues(Vector3& v, const Vector3& axis, float s, float c, float k) { + + const float d = axis.dot(v); + const float cx = axis.y * v.z - axis.z * v.y; + const float cy = axis.z * v.x - axis.x * v.z; + const float cz = axis.x * v.y - axis.y * v.x; + + v.set(v.x * c + cx * s + axis.x * d * k, + v.y * c + cy * s + axis.y * d * k, + v.z * c + cz * s + axis.z * d * k); + } + + // Rotate an orthonormal frame (three column vectors) about a unit axis. + // + // `axis` is taken BY VALUE on purpose. The head and the wing chain both + // rotate a frame about one of its OWN columns, and a reference parameter + // would alias the column this call is halfway through overwriting — the + // second and third columns would then rotate about a partially updated + // axis. Twelve bytes of copy buys immunity from a bug that only shows up + // as a slowly shearing wing. + inline void rotateAboutAxis(Vector3& cx, Vector3& cy, Vector3& cz, Vector3 axis, float angle) { + + // Bit-exact identity, not an approximation: cos(0) == 1 and sin(0) == 0 + // exactly, so skipping is free of drift. Worth it — a gliding or + // perched bird has zero deltas at most stations. + if (angle == 0.f) return; + + const float s = std::sin(angle); + const float c = std::cos(angle); + const float k = 1.f - c; + + rodrigues(cx, axis, s, c, k); + rodrigues(cy, axis, s, c, k); + rodrigues(cz, axis, s, c, k); + } + + // Map a body-space offset through a frame given as three columns. + [[nodiscard]] inline Vector3 mapFrame(const Vector3& cx, const Vector3& cy, const Vector3& cz, const Vector3& o) { + + return {cx.x * o.x + cy.x * o.y + cz.x * o.z, + cx.y * o.x + cy.y * o.y + cz.y * o.z, + cx.z * o.x + cy.z * o.y + cz.z * o.z}; + } + + [[nodiscard]] inline Vector3 safeNormalized(const Vector3& v, const Vector3& fallback) { + + // Vector3::normalize() divides by length with a NaN guard but NOT a + // zero guard, so a zero-length input yields inf/NaN and one NaN vertex + // blows the flock's bounding sphere for the rest of the run. + const float l2 = v.lengthSq(); + if (!(l2 > 1e-20f)) return fallback; + + Vector3 out = v; + out.multiplyScalar(1.f / std::sqrt(l2)); + return out; + } + + // ── The static index buffer for one bird ───────────────────────── + // + // Emission order is fixed so that each part occupies the index range the + // layout table promises: body 0–119, head 120–167, wing 0 168–269, + // wing 1 270–371, tail 372–413, leg 0 414–434, leg 1 435–455. + [[nodiscard]] inline std::array buildBirdIndexTemplate() { + + std::array idx{}; + int n = 0; + + const auto tri = [&](int a, int b, int c) { + idx[static_cast(n++)] = static_cast(a); + idx[static_cast(n++)] = static_cast(b); + idx[static_cast(n++)] = static_cast(c); + }; + + // A quad given in ring-traversal order (a, b, c, d). `mirrored` is set + // for parts whose positions are mirrored across X — mirroring flips + // handedness, so the same traversal produces the opposite face. Both + // branches emit two triangles; only the corner order differs. + const auto quad = [&](int a, int b, int c, int d, bool mirrored) { + if (mirrored) { + tri(a, b, c); + tri(a, c, d); + } else { + tri(a, c, b); + tri(a, d, c); + } + }; + + // 1. Body bands — 30 tris. + for (int i = 0; i < kBodyRings - 1; ++i) { + for (int j = 0; j < kBodyRadial; ++j) { + const int j1 = (j + 1) % kBodyRadial; + quad(5 * i + j, 5 * i + j1, 5 * (i + 1) + j1, 5 * (i + 1) + j, false); + } + } + // 2. Body tail fan — 5 tris. Apex v20 sits aft of ring 0. + for (int j = 0; j < kBodyRadial; ++j) tri(20, j, (j + 1) % kBodyRadial); + // 3. Body neck fan — 5 tris. Apex v21 IS the neck pivot. + for (int j = 0; j < kBodyRadial; ++j) tri(21, 15 + (j + 1) % kBodyRadial, 15 + j); + // 4. Head neck cap fan — 4 tris. + for (int k = 0; k < 4; ++k) tri(22, 23 + k, 23 + (k + 1) % 4); + // 5. Head band — 8 tris. + for (int k = 0; k < 4; ++k) { + const int k1 = (k + 1) % 4; + quad(23 + k, 23 + k1, 27 + k1, 27 + k, false); + } + // 6. Head bill fan — 4 tris. + for (int k = 0; k < 4; ++k) tri(31, 27 + (k + 1) % 4, 27 + k); + // 7/8. Wings — 34 tris each. Wing 1 is the mirror, hence the flag. + for (int w = 0; w < 2; ++w) { + const int W = 32 + 20 * w; + const bool mirrored = (w == 1); + for (int t = 0; t < kWingStations - 1; ++t) { + const int a = W + 4 * t; + const int b = W + 4 * (t + 1); + quad(a + 0, a + 1, b + 1, b + 0, mirrored);// top sheet + quad(a + 1, a + 2, b + 2, b + 1, mirrored);// trailing edge + quad(a + 2, a + 3, b + 3, b + 2, mirrored);// bottom sheet + quad(a + 3, a + 0, b + 0, b + 3, mirrored);// leading edge + } + quad(W + 16 + 0, W + 16 + 1, W + 16 + 2, W + 16 + 3, mirrored);// tip cap + } + // 9. Tail upper sheet — 3 tris. + tri(72, 74, 73); + tri(72, 75, 74); + tri(72, 76, 75); + // 10. Tail lower sheet — 3 tris. + tri(77, 78, 79); + tri(77, 79, 80); + tri(77, 80, 81); + // 11. Tail rim — 8 tris. Four of the tail's five perimeter edges; the + // fifth is the root, deliberately left open inside the body. Without + // this rim the tail is a bare sheet that vanishes under + // Side::FrontSide the moment the camera drops below the flock. + quad(73, 78, 79, 74, false); + quad(74, 79, 80, 75, false); + quad(75, 80, 81, 76, false); + quad(76, 81, 77, 72, false); + // 12/13. Legs — 7 tris each. NOT mirrored: both hip rings use the same + // ψ table and only the anchor's x differs, so the handedness is the + // same on both sides and so is the winding. + for (int g = 0; g < 2; ++g) { + const int Lg = 82 + 6 * g; + for (int m = 0; m < 3; ++m) { + const int m1 = (m + 1) % 3; + quad(Lg + m, Lg + m1, Lg + 3 + m1, Lg + 3 + m, false); + } + tri(Lg + 3, Lg + 5, Lg + 4);// foot cap, facing down + } + + THREEPP_ASSERT_MSG(n == kIndicesPerBird, "bird index template is not 456 indices long"); + return idx; + } + + }// namespace detail + + + // ── Rest pose ──────────────────────────────────────────────────────── + // + // Deterministic; no RNG. Pure function of `shape`. + // + // Degenerate input returns a ZERO-INITIALISED template rather than a + // half-built one: every downstream consumer then produces a finite (if + // invisible) bird instead of a NaN that propagates into the flock's bounding + // sphere and, on Vulkan, into a BLAS refit. + [[nodiscard]] inline BirdTemplate makeBirdTemplate(const BirdShape& shape) { + + BirdTemplate t{}; + + const float L = shape.bodyLength; + const float R = shape.bodyRadius; + const float S = shape.wingSpan; + + if (!std::isfinite(L) || !std::isfinite(R) || !std::isfinite(S) || !std::isfinite(shape.tailFork)) return t; + if (L <= 0.f || R <= 0.f || S <= 0.f) return t; + + const float H = 0.5f * S; // half span + const float C0 = detail::kRootChordFrac * H; // root chord + const float T = detail::kTailLengthFrac * L; // tail length + const float legLen = detail::kLegLengthFrac * L; + + t.legLength = legLen; + t.tailLength = T; + + // ── Body, v0..v21 ──────────────────────────────────────────────── + for (int i = 0; i < kBodyRings; ++i) { + + // Surface slope along z, by central difference where it exists. It is + // what tilts the ring normal fore/aft — without it the body shades as + // a cylinder and the taper toward the tail reads as a paint job. + const int im = std::max(i - 1, 0); + const int ip = std::min(i + 1, kBodyRings - 1); + const float dz = (detail::kRingZ[static_cast(ip)] - detail::kRingZ[static_cast(im)]) * L; + const float dr = (detail::kRingF[static_cast(ip)] - detail::kRingF[static_cast(im)]) * R; + const float slope = (std::abs(dz) > 1e-9f) ? dr / dz : 0.f; + + const float f = detail::kRingF[static_cast(i)]; + const float yOff = detail::kRingY[static_cast(i)] * R; + const float z = detail::kRingZ[static_cast(i)] * L; + + for (int j = 0; j < kBodyRadial; ++j) { + + const float th = math::degToRad(72.f * static_cast(j)); + const float sn = std::sin(th); + const float cs = std::cos(th); + + const std::size_t v = static_cast(5 * i + j); + t.pos[v].set(f * R * detail::kEllipseX * sn, + yOff + f * R * detail::kEllipseY * cs, + z); + + Vector3 n = detail::safeNormalized({sn / detail::kEllipseX, cs / detail::kEllipseY, 0.f}, {0, 1, 0}); + n.z -= slope; + t.nrm[v] = detail::safeNormalized(n, {0, 1, 0}); + } + } + t.pos[20].set(0.f, -0.03f * R, -0.34f * L); + t.nrm[20] = detail::safeNormalized({0.f, -0.15f, -0.99f}, {0, 0, -1}); + t.pos[21].set(0.f, 0.26f * R, 0.24f * L); + t.nrm[21] = detail::safeNormalized({0.f, 0.40f, 0.92f}, {0, 0, 1}); + + t.neckPivot = t.pos[21]; + + // ── Head, v22..v31 — a socket ring and a crown ring about the pivot ── + { + const Vector3& P = t.neckPivot; + + t.pos[22].set(P.x, P.y, P.z - 0.015f * L); + t.nrm[22].set(0.f, 0.f, -1.f); + + for (int k = 0; k < 4; ++k) { + + const float ph = math::degToRad(90.f * static_cast(k) + 45.f); + const float sn = std::sin(ph); + const float cs = std::cos(ph); + + const std::size_t vn = static_cast(23 + k); + t.pos[vn].set(P.x + 0.42f * R * sn, P.y + 0.42f * R * cs, P.z); + t.nrm[vn] = detail::safeNormalized({sn, cs, -0.25f}, {0, 1, 0}); + + const std::size_t vc = static_cast(27 + k); + t.pos[vc].set(P.x + 0.46f * R * sn, P.y + 0.10f * R + 0.46f * R * cs, P.z + 0.11f * L); + t.nrm[vc] = detail::safeNormalized({sn, cs, 0.30f}, {0, 1, 0}); + } + + t.pos[31].set(P.x, P.y + 0.02f * R, P.z + 0.26f * L); + t.nrm[31].set(0.f, 0.f, 1.f); + } + + // ── Wings, v32..v51 (index 0) and v52..v71 (index 1) ───────────── + // + // Top and bottom are SPLIT VERTICES sharing a position but not a normal. + // That is where the hard leading- and trailing-edge crease comes from, and + // it is why `flatShading` — which does not exist on the Vulkan backend at + // all — is never needed. + for (int w = 0; w < 2; ++w) { + + const float sx = (w == 0) ? 1.f : -1.f; + t.wingRoot[static_cast(w)].set(sx * 0.55f * R, 0.55f * R, 0.04f * L); + const Vector3& root = t.wingRoot[static_cast(w)]; + + for (int st = 0; st < kWingStations; ++st) { + + const std::size_t si = static_cast(st); + const float s = detail::kStationS[si]; + const float chord = detail::kStationChord[si] * C0; + const float zLE = detail::kStationZLE[si] * C0; + const float ht = detail::kStationThick[si] * C0; + const float x = root.x + sx * s * H; + + const int base = 32 + 20 * w + 4 * st; + const std::array corner{ + Vector3{x, root.y + ht, root.z + zLE}, // 0 LEtop + Vector3{x, root.y + ht, root.z + zLE - chord},// 1 TEtop + Vector3{x, root.y - ht, root.z + zLE - chord},// 2 TEbot + Vector3{x, root.y - ht, root.z + zLE}}; // 3 LEbot + + for (int c = 0; c < 4; ++c) { + const std::size_t v = static_cast(base + c); + t.pos[v] = corner[static_cast(c)]; + t.nrm[v].set(0.f, (c <= 1) ? 1.f : -1.f, 0.f); + t.span[v] = s; + t.station[v] = static_cast(st); + } + } + } + + for (int st = 0; st < kWingStations; ++st) { + t.segSpan[static_cast(st)] = detail::kSegSpanFrac[static_cast(st)] * H; + } + + // ── Tail, v72..v81 — a slab, upper sheet then lower ────────────── + // + // A SLAB, NOT A SHEET: the rim quads close the trailing outline, so the + // tail survives Side::FrontSide from below instead of disappearing every + // time the camera drops beneath the flock. Only the ROOT edge is left + // open, and it is the third and last of the buried holes (wing root, hip + // ring, tail root) — the body cone is 0.315 R at z = -0.28 L against the + // slab's 0.30 R, so the opening sits inside the body and the 4 mm slot is + // sub-pixel long before it could be looked into. + { + const float ht = 0.010f * L; + const float zRoot = -0.28f * L; + const float zTip = zRoot - T; + const float halfW = 0.30f * R; + const float Wt = T * std::tan(detail::kTailAngleMin);// rest pose is spread = 0 + const float zC = zTip - shape.tailFork * 0.12f * T; + + t.tailRoot.set(0.f, 0.02f * R, zRoot); + + for (int sheet = 0; sheet < 2; ++sheet) { + + const float y = (sheet == 0) ? ht : -ht; + const int base = 72 + 5 * sheet; + + t.pos[static_cast(base + 0)].set(halfW, y, zRoot); // rootL + t.pos[static_cast(base + 1)].set(-halfW, y, zRoot);// rootR + t.pos[static_cast(base + 2)].set(-Wt, y, zTip); // tipR + t.pos[static_cast(base + 3)].set(0.f, y, zC); // tipC + t.pos[static_cast(base + 4)].set(Wt, y, zTip); // tipL + + for (int k = 0; k < 5; ++k) { + t.nrm[static_cast(base + k)].set(0.f, (sheet == 0) ? 1.f : -1.f, 0.f); + } + } + } + + // ── Legs, v82..v87 (index 0) and v88..v93 (index 1) ────────────── + // + // No knee, deliberately. At the distances this system targets a leg is one + // to three pixels; it exists so a perched body floats a leg-length above + // the surface instead of melting into it, and so a hop has a visible push. + // Between planted feet the leg simply stretches. + for (int g = 0; g < 2; ++g) { + + const float sx = (g == 0) ? 1.f : -1.f; + t.hipAnchor[static_cast(g)].set(sx * 0.34f * R, -0.72f * R, -0.10f * L); + const Vector3& hip = t.hipAnchor[static_cast(g)]; + + const int base = 82 + 6 * g; + for (int m = 0; m < 3; ++m) { + + const float ps = math::degToRad(120.f * static_cast(m)); + const float sn = std::sin(ps); + const float cs = std::cos(ps); + const Vector3 n = detail::safeNormalized({sn, 0.25f, cs}, {0, 1, 0}); + + const std::size_t vh = static_cast(base + m); + t.pos[vh].set(hip.x + 0.110f * R * sn, hip.y, hip.z + 0.110f * R * cs); + t.nrm[vh] = n; + + const std::size_t vf = static_cast(base + 3 + m); + t.pos[vf].set(hip.x + 0.055f * R * sn, hip.y - legLen, hip.z + 0.055f * R * cs); + t.nrm[vf] = n; + } + } + + // ── Part tags ──────────────────────────────────────────────────── + for (int v = 0; v < kVertsPerBird; ++v) { + BirdPart p = BirdPart::Body; + if (v >= 88) p = BirdPart::Leg1; + else if (v >= 82) p = BirdPart::Leg0; + else if (v >= 72) p = BirdPart::Tail; + else if (v >= 52) p = BirdPart::Wing1; + else if (v >= 32) p = BirdPart::Wing0; + else if (v >= 22) p = BirdPart::Head; + t.part[static_cast(v)] = p; + } + + // ── Invariants. Cheap, and they catch the whole class of "I retyped + // the table wrong" defect that no downstream test can localise. ── + // Total length is exactly bodyLength: bill tip at +0.50 L, tail tip at + // -0.50 L. Every other proportion in the tables is expressed as a fraction + // of one of those two, so if these two hold the silhouette is the intended + // one. + THREEPP_ASSERT_MSG(std::abs(t.pos[31].z - 0.50f * L) < 1e-5f * L, + "bill apex must sit at z = +0.50 * bodyLength"); + THREEPP_ASSERT_MSG(shape.tailFork != 0.f || std::abs(t.pos[75].z + 0.50f * L) < 1e-5f * L, + "tail tip must sit at z = -0.50 * bodyLength when tailFork == 0"); + + // Wing root and hip anchor must lie strictly INSIDE the body shell. Each + // opens a hole in its own tube — the wing has no shoulder cap and the leg + // has no hip cap — and those holes are invisible only because the closed + // opaque body encloses them. Move either anchor outward and Side::FrontSide + // starts showing the inside of the bird through the gap. + THREEPP_ASSERT_MSG(detail::shellTest(0.55f, 0.55f, 2) < 1.f, + "wing root escapes the body shell at ring 2"); + THREEPP_ASSERT_MSG(detail::shellTest(0.34f, -0.72f, 1) < 1.f, + "hip anchor escapes the body shell at ring 1"); + + // The tail slab's root edge must meet a body at least as tall as itself. + // (In x the cone is 0.90 × that, so the root corners sit a fraction of a + // millimetre proud — the slab is opaque and it does not read.) + THREEPP_ASSERT_MSG(0.30f < detail::tailConeFactor(-0.28f) * detail::kEllipseY, + "tail root is wider than the body cone at z = -0.28 * bodyLength"); + + THREEPP_ASSERT_MSG(std::abs(t.segSpan[1] + t.segSpan[2] + t.segSpan[3] + t.segSpan[4] - H) < 1e-5f * H, + "wing segment spans must sum to the half span"); + + return t; + } + + + // ── Index buffer ───────────────────────────────────────────────────── + // + // Bird b occupies [b*kIndicesPerBird, (b+1)*kIndicesPerBird) with every value + // offset by b*kVertsPerBird. Emitted as `unsigned int` to match + // IntBufferAttribute; hand the result to BufferGeometry::setIndex through the + // move overload so a 256-bird buffer is not copied on the way in. + [[nodiscard]] inline std::vector makeFlockIndices(int birdCount) { + + std::vector out; + if (birdCount <= 0) return out; + + const auto base = detail::buildBirdIndexTemplate(); + + out.resize(static_cast(birdCount) * kIndicesPerBird); + for (int b = 0; b < birdCount; ++b) { + const unsigned int vOff = static_cast(b) * kVertsPerBird; + const std::size_t iOff = static_cast(b) * kIndicesPerBird; + for (std::size_t k = 0; k < static_cast(kIndicesPerBird); ++k) { + out[iOff + k] = base[k] + vOff; + } + } + + THREEPP_ASSERT_MSG(out.size() == static_cast(birdCount) * kIndicesPerBird, + "flock index buffer length mismatch"); + THREEPP_ASSERT_MSG(*std::max_element(out.begin(), out.end()) == + static_cast(birdCount * kVertsPerBird - 1), + "flock index buffer does not reference the last vertex"); + return out; + } + + + // ── Colour bake ────────────────────────────────────────────────────── + // + // Written once at construction, never touched again. `seed` drives the + // per-bird lightness jitter ONLY — the per-vertex pattern is identical on + // every bird, so it is computed once and then scaled. + // + // THE DARK WINGTIP OVER A PALE UNDERWING IS THE HIGHEST-VALUE LINE IN HERE. + // It is what the eye tracks through the beat: the underside flashes pale on + // the upstroke and the tip stays dark, which is the whole reason a distant + // flock reads as birds and not as drifting confetti. Without it a uniformly + // pale wing reads as paper. + [[nodiscard]] inline std::vector makeFlockColors(const BirdTemplate& tmpl, + const BirdPlumage& plumage, + int birdCount, + unsigned int seed) { + + std::vector out; + if (birdCount <= 0) return out; + + std::array base{}; + + for (int v = 0; v < kVertsPerBird; ++v) { + + const std::size_t vi = static_cast(v); + const BirdPart p = tmpl.part[vi]; + + // Countershading: dark where the surface looks up, pale where it looks + // down. The -0.6 lower edge, rather than -1, keeps the flanks from + // going fully belly-pale and losing the body's roundness. + float shade = math::smoothstep(-0.6f, 1.0f, tmpl.nrm[vi].y); + float wash = 1.f; + + if (p == BirdPart::Wing0 || p == BirdPart::Wing1) { + + // Corner index within the station: 0/1 are the top sheet, 2/3 the + // bottom. A wing sheet is flat, so its geometric normal carries no + // countershading information at all — the two sheets have to be + // painted, not shaded. + const int c = (v - ((p == BirdPart::Wing0) ? 32 : 52)) % 4; + shade = (c <= 1) ? 1.00f : 0.15f; + const float s = tmpl.span[vi]; + wash = math::lerp(1.f, plumage.wingtipDark, s * s); + + } else if (p == BirdPart::Tail) { + + shade = (v <= 76) ? 1.00f : 0.15f; + const bool tip = (v >= 74 && v <= 76) || (v >= 79 && v <= 81); + if (tip) wash = plumage.tailBandDark; + } + + Color col; + col.lerpColors(plumage.belly, plumage.back, shade); + col.multiplyScalar(wash); + + if (p == BirdPart::Head && v >= 27) col.lerp(plumage.cap, plumage.capStrength); + if (p == BirdPart::Leg0 || p == BirdPart::Leg1) col = plumage.leg; + + base[vi] = col; + } + + // One draw per bird, hoisted into its own named const — two draws inside a + // single expression would make "deterministic for a fixed seed" depend on + // the compiler's argument evaluation order. The [0,1) conversion is done + // by hand rather than through uniform_real_distribution, whose mapping is + // implementation-defined even though mt19937's output sequence is not. + std::mt19937 rng(seed ? seed : 1u); + const float jitterRange = std::max(plumage.lightnessJitter, 0.f); + + out.resize(static_cast(birdCount) * kVertsPerBird * 3u); + + for (int b = 0; b < birdCount; ++b) { + + const float u = static_cast(rng() >> 8) * (1.f / 16777216.f); + const float jitter = (u * 2.f - 1.f) * jitterRange; + const float k = 1.f + jitter; + + std::size_t o = static_cast(b) * kVertsPerBird * 3u; + for (int v = 0; v < kVertsPerBird; ++v) { + const Color& c = base[static_cast(v)]; + out[o++] = std::clamp(c.r * k, 0.f, 1.f); + out[o++] = std::clamp(c.g * k, 0.f, 1.f); + out[o++] = std::clamp(c.b * k, 0.f, 1.f); + } + } + + return out; + } + + + // ── The per-frame pose bake ────────────────────────────────────────── + // + // Writes exactly kVertsPerBird positions and kVertsPerBird normals in WORLD + // space into posOut/nrmOut at [vertBase*3, (vertBase+94)*3). + // Allocation-free. Deterministic. Called once per bird per baked frame. + // + // Three passes: (1) pose vertices 0..81 in body space, (2) transform them to + // world through the body basis, (3) write the legs 82..93 straight to world, + // because their feet already are. + inline void poseBird(const BirdTemplate& tmpl, + const BirdPose& pose, + const BirdKinematics& kin, + std::vector& posOut, + std::vector& nrmOut, + int vertBase) { + + if (vertBase < 0) return; + + const std::size_t need = (static_cast(vertBase) + kVertsPerBird) * 3u; + if (posOut.size() < need || nrmOut.size() < need) return; + + // A non-finite pose writes 94 non-finite vertices, and ONE of those blows + // the flock's bounding sphere for the rest of the session (and can wreck a + // Vulkan BLAS refit). Leaving last frame's vertices in place is the + // harmless failure: the bird freezes, nothing else notices. + if (!detail::isFinite(pose.pos) || !std::isfinite(pose.scale) || + !detail::isFinite(pose.bx) || !detail::isFinite(pose.by) || !detail::isFinite(pose.bz)) return; + + std::array lp{}; + std::array ln{}; + + // bodyLift is a WHOLE-BODY offset, not a body-rings-only one: the landing + // spring compresses the bird toward its planted feet. Applying it to + // vertices 0..21 alone shears the head off the neck by up to a neck radius + // at the exact moment the eye is watching the touchdown. + const float lift = pose.bodyLift; + const float flapWeight = std::clamp(pose.flapWeight, 0.f, 1.f); + const float perchFold = std::clamp(pose.perchFold, 0.f, 1.f); + + // ── Pass 1a: body, v0..v21 ─────────────────────────────────────── + for (int v = 0; v <= 21; ++v) { + const std::size_t vi = static_cast(v); + lp[vi] = tmpl.pos[vi]; + lp[vi].y += lift; + ln[vi] = tmpl.nrm[vi]; + } + + // ── Pass 1b: head, v22..v31, about the neck pivot ──────────────── + { + Vector3 Hx{1, 0, 0}, Hy{0, 1, 0}, Hz{0, 0, 1}; + detail::rotateAboutAxis(Hx, Hy, Hz, Vector3{0, 1, 0}, pose.headYaw); + detail::rotateAboutAxis(Hx, Hy, Hz, Hx, pose.headPitch); + detail::rotateAboutAxis(Hx, Hy, Hz, Hz, pose.headRoll); + + for (int v = 22; v <= 31; ++v) { + const std::size_t vi = static_cast(v); + const Vector3 o = tmpl.pos[vi] - tmpl.neckPivot; + lp[vi] = tmpl.neckPivot + detail::mapFrame(Hx, Hy, Hz, o); + lp[vi].y += lift; + lp[vi].z += pose.headLead; + ln[vi] = detail::mapFrame(Hx, Hy, Hz, tmpl.nrm[vi]); + } + } + + // ── Pass 1c: wings, v32..v71 ───────────────────────────────────── + for (int w = 0; w < 2; ++w) { + + const float sx = (w == 0) ? 1.f : -1.f; + const float asym = (w == 0) ? pose.wingAsym : -pose.wingAsym; + const float A = std::clamp(flapWeight * pose.beatAmp * (1.f - perchFold) + asym, 0.f, 1.6f); + const float kD = std::clamp(kin.downstrokeFrac, 0.05f, 0.95f); + + std::array theta{}, twist{}, sweepExtra{}, liftExtra{}, spanScale{}; + + for (int t = 0; t < kWingStations; ++t) { + + const std::size_t ti = static_cast(t); + const std::size_t v0 = static_cast(32 + 20 * w + 4 * t); + const float s = tmpl.span[v0]; + + // SPANWISE TRAVELLING WAVE. The tip reaches its extreme about an + // eighth of a cycle after the root, exactly as a flexible + // cantilever does. One subtraction, and it is the difference + // between a wing and a plank. + const float p_t = detail::frac01(pose.cyclePos - kin.spanLagCycles * detail::kSpanLagPow[ti]); + + // DUTY WARP: the downstroke is the power stroke. A raw cosine gives + // a symmetric wing-wiper; a real wing snaps down and eases up. + // + // The warp's breakpoint lands at q = 0.5 — the bottom of the + // stroke — where sin(2πq) is exactly zero. That is what keeps the + // DIHEDRAL C¹ across it: dk/dp carries a factor of sin(2πq) and so + // vanishes from both sides, no matter how lopsided dq/dp is. The + // elevation, which is the whole silhouette at range, therefore has + // no kink. Twist and wrist fold do have one there and at the 0/1 + // wrap — see the fold below; that break is deliberate and it is + // where a real wrist actually snaps. + const float q_t = (p_t < kD) ? 0.5f * p_t / kD + : 0.5f + 0.5f * (p_t - kD) / (1.f - kD); + + const float k_t = std::cos(math::TWO_PI * q_t);// +1 top, -1 bottom + const float sn = std::sin(math::TWO_PI * q_t); + + // ARC ASYMMETRY, C¹. The wing goes ~50° below horizontal and only + // ~33° above. Blending the two MAGNITUDES by (0.5 + 0.5k) and + // multiplying by k keeps the derivative continuous at the + // horizontal crossing; a `k > 0 ? a : b` branch steps dθ/dk by 50 % + // exactly where the wing is fastest, twice a beat, which the eye + // reads as a mechanical tick. + const float ampMag = math::lerp(kin.strokeDown, kin.strokeUp, 0.5f + 0.5f * k_t); + + theta[ti] = A * ampMag * k_t * (0.55f + 0.45f * s) + + (1.f - flapWeight) * kin.glideDihedral * (0.3f + 0.7f * s) + + perchFold * kin.perchLift * s; + + // TWIST IS IN QUADRATURE WITH DIHEDRAL, NOT IN PHASE. Feathering + // has to peak at mid-stroke where the wing is fastest and pass + // through zero at the top and bottom where it is momentarily + // stationary. In phase, the wing pronates when it should be neutral + // and the whole cue collapses into an unmotivated roll wobble. + twist[ti] = A * kin.twistAmp * s * s * sn;// + = leading edge down + + // WRIST FOLD, AND IT MUST BE ON THE UPSTROKE HALF. max(0, -sin) is + // exactly zero through the entire downstroke and peaks at + // mid-upstroke. This term alone produces the tip's closed loop + // (outer arc down at full span, inner arc up at reduced span), + // which is why there is no separate sweep term: a sweep in phase + // with elevation traces a line, not an ellipse. + // + // The max() puts a slope break in the fold RATE at the bottom of + // the stroke and again at the 0/1 wrap: position and velocity stay + // finite (measured max |dp/dcyclePos| ≈ 0.93 m per cycle) but + // acceleration jumps. That is the one place in this file where the + // pose is C⁰-with-bounded-derivative rather than C¹, it is + // deliberate, and it is at the exact phase where the dihedral rate + // is zero — so the hand starts folding out of a stationary wing, + // which is what a wrist snap looks like. Every STATE blend the + // caller drives (flapWeight, perchFold, tailSpread, legExtend) + // stays C¹; do not confuse the two when reading §4's C¹ claim. + const float u = std::max(0.f, -sn); + const float fold = A * kin.wristFlex * u * ((s >= 0.5f) ? 1.f : 0.25f); + + sweepExtra[ti] = fold + perchFold * kin.perchSweep * s; + liftExtra[ti] = 0.35f * fold; + spanScale[ti] = 1.f - 0.45f * perchFold * ((t >= 2) ? 1.f : 0.33f); + } + + // A RUNNING FRAME, ONE FIXED-LENGTH SEGMENT AT A TIME. Fixed segment + // lengths mean the wing cannot stretch whatever the constants say, and + // the normal falls out of the same orthonormal frame. + // + // The fold shortens the PROJECTED span from 0.445 m to 0.263 m over a + // beat — a 41 % pulse, measured, not estimated. Past ~40 m the wing's + // shape is no longer resolvable but its horizontal extent still is, and + // a non-pulsing extent reads as a flapping cross-shaped kite. Do not + // remove this term to save a max(). + Vector3 Fx{sx, 0, 0}, Fy{0, 1, 0}, Fz{0, 0, 1}; + Vector3 P = tmpl.wingRoot[static_cast(w)]; + + for (int t = 0; t < kWingStations; ++t) { + + const std::size_t ti = static_cast(t); + const float dTheta = theta[ti] - (t ? theta[ti - 1] : 0.f); + const float dTwist = twist[ti] - (t ? twist[ti - 1] : 0.f); + const float dSweep = sweepExtra[ti] - (t ? sweepExtra[ti - 1] : 0.f); + const float dLift = liftExtra[ti] - (t ? liftExtra[ti - 1] : 0.f); + + // ROTATION ORDER IS FIXED: dihedral+lift about Fz, sweep about Fy, + // twist about Fx — all applied to the RUNNING frame, in that order. + // Signs are per wing INDEX via sx, so the two wings are exact + // mirrors and anatomical handedness never enters the code. The + // sweep sign is the one worth checking by hand: for wing 0 a + // positive dSweep must move Fx toward -Z (aft), which is what + // rotating the frame about +Y by +dSweep does. + detail::rotateAboutAxis(Fx, Fy, Fz, Fz, sx * (dTheta + dLift)); + detail::rotateAboutAxis(Fx, Fy, Fz, Fy, sx * dSweep); + detail::rotateAboutAxis(Fx, Fy, Fz, Fx, sx * dTwist); + + if (t > 0) P.addScaledVector(Fx, tmpl.segSpan[ti] * spanScale[ti]); + + // The station's own origin: the span point on the wing axis. All + // four corners of a station share it, so reading its x off any of + // them keeps this independent of halfSpan. + const std::size_t v0 = static_cast(32 + 20 * w + 4 * t); + const Vector3 origin{tmpl.pos[v0].x, + tmpl.wingRoot[static_cast(w)].y, + tmpl.wingRoot[static_cast(w)].z}; + + for (int c = 0; c < 4; ++c) { + const std::size_t vi = v0 + static_cast(c); + const Vector3 o = tmpl.pos[vi] - origin; + lp[vi] = P + detail::mapFrame(Fx, Fy, Fz, o); + lp[vi].y += lift; + ln[vi] = detail::mapFrame(Fx, Fy, Fz, tmpl.nrm[vi]); + } + } + } + + // ── Pass 1d: tail, v72..v81 ────────────────────────────────────── + { + const float spread = std::clamp(pose.tailSpread, 0.f, 1.f); + const float halfAngle = math::lerp(detail::kTailAngleMin, detail::kTailAngleMax, spread); + const float Wt = tmpl.tailLength * std::tan(halfAngle); + + Vector3 Tx{1, 0, 0}, Ty{0, 1, 0}, Tz{0, 0, 1}; + detail::rotateAboutAxis(Tx, Ty, Tz, Vector3{1, 0, 0}, -pose.tailPitch); + detail::rotateAboutAxis(Tx, Ty, Tz, Vector3{0, 0, 1}, pose.tailRoll); + + for (int v = 72; v <= 81; ++v) { + + const std::size_t vi = static_cast(v); + Vector3 r = tmpl.pos[vi]; + + // Fanning the tail moves only the two outer tip vertices of each + // sheet; the centre tip carries the fork and does not spread. + if (v == 74 || v == 79) r.x = -Wt; + else if (v == 76 || v == 81) r.x = Wt; + + const Vector3 o = r - tmpl.tailRoot; + lp[vi] = tmpl.tailRoot + detail::mapFrame(Tx, Ty, Tz, o); + lp[vi].y += lift; + ln[vi] = detail::mapFrame(Tx, Ty, Tz, tmpl.nrm[vi]); + } + } + + // ── Pass 2: body space → world, v0..v81 ────────────────────────── + // + // The result is already unit length — the basis is orthonormal and the + // local normals are unit. DO NOT renormalise: it is a wasted sqrt per + // vertex and, worse, a zero-length input would turn a harmless stale + // normal into a NaN. + { + const Vector3& bx = pose.bx; + const Vector3& by = pose.by; + const Vector3& bz = pose.bz; + const float sc = pose.scale; + + for (int v = 0; v < detail::kLocalVerts; ++v) { + + const std::size_t vi = static_cast(v); + const Vector3& l = lp[vi]; + const Vector3& n = ln[vi]; + const std::size_t o = (static_cast(vertBase) + vi) * 3u; + + posOut[o + 0] = pose.pos.x + (bx.x * l.x + by.x * l.y + bz.x * l.z) * sc; + posOut[o + 1] = pose.pos.y + (bx.y * l.x + by.y * l.y + bz.y * l.z) * sc; + posOut[o + 2] = pose.pos.z + (bx.z * l.x + by.z * l.y + bz.z * l.z) * sc; + + nrmOut[o + 0] = bx.x * n.x + by.x * n.y + bz.x * n.z; + nrmOut[o + 1] = bx.y * n.x + by.y * n.y + bz.y * n.z; + nrmOut[o + 2] = bx.z * n.x + by.z * n.y + bz.z * n.z; + } + } + + // ── Pass 3: legs, v82..v93, written straight to world ──────────── + // + // IN FLIGHT legExtend is 0 and feetPlanted is false, which puts the whole + // leg inside the closed opaque body: invisible, and never degenerate. + // There is no collapsed vertex anywhere in this system, which is why the + // zero-length-normal question never arises. + { + const float sc = pose.scale; + const float extend = std::clamp(pose.legExtend, 0.f, 1.f); + + for (int g = 0; g < 2; ++g) { + + const std::size_t gi = static_cast(g); + + Vector3 hipLocal = tmpl.hipAnchor[gi]; + hipLocal.y += lift; + Vector3 hipW = pose.pos + detail::mapFrame(pose.bx, pose.by, pose.bz, hipLocal) * sc; + + Vector3 footW; + const bool planted = pose.feetPlanted && detail::isFinite(pose.footWorld[gi]); + if (planted) { + footW = pose.footWorld[gi]; + } else { + const float len = tmpl.legLength * sc * math::lerp(0.15f, 1.f, extend); + footW = hipW; + footW.addScaledVector(pose.by, -len); + } + + // Ring orientation from the hip→foot axis. Every normalisation here + // has an explicit fallback: a bird whose foot lands exactly on its + // hip is rare, and a NaN leg is forever. + Vector3 axis = footW - hipW; + { + Vector3 down = pose.by; + down.negate(); + axis = detail::safeNormalized(axis, down); + } + Vector3 side = pose.bx; + side.addScaledVector(axis, -side.dot(axis)); + side = detail::safeNormalized(side, pose.bz); + + Vector3 fwd; + fwd.crossVectors(axis, side); + + // (side, up, fwd) is the rest frame's (X, Y, Z) carried onto the + // posed leg, so rest offsets and rest normals map through unchanged. + Vector3 up = axis; + up.negate(); + + const int base = 82 + 6 * g; + const Vector3& hipRest = tmpl.hipAnchor[gi]; + Vector3 footRest = hipRest; + footRest.y -= tmpl.legLength; + + for (int m = 0; m < 3; ++m) { + for (int ring = 0; ring < 2; ++ring) { + + const std::size_t vi = static_cast(base + 3 * ring + m); + const Vector3& anchorRest = (ring == 0) ? hipRest : footRest; + const Vector3& anchorW = (ring == 0) ? hipW : footW; + + const Vector3 o = tmpl.pos[vi] - anchorRest; + const Vector3 r = detail::mapFrame(side, up, fwd, o); + const Vector3 n = detail::mapFrame(side, up, fwd, tmpl.nrm[vi]); + + const std::size_t io = (static_cast(vertBase) + vi) * 3u; + posOut[io + 0] = anchorW.x + r.x * sc; + posOut[io + 1] = anchorW.y + r.y * sc; + posOut[io + 2] = anchorW.z + r.z * sc; + + nrmOut[io + 0] = n.x; + nrmOut[io + 1] = n.y; + nrmOut[io + 2] = n.z; + } + } + } + } + } + + // The frozen five-argument form: identical behaviour with the stock stroke + // constants. Callers that expose the kinematics as live knobs use the + // overload above. + inline void poseBird(const BirdTemplate& tmpl, + const BirdPose& pose, + std::vector& posOut, + std::vector& nrmOut, + int vertBase) { + + poseBird(tmpl, pose, kDefaultKinematics, posOut, nrmOut, vertBase); + } + +}// namespace threepp::fauna + +#endif// THREEPP_EXTRAS_FAUNA_BIRDGEOMETRY_HPP diff --git a/include/threepp/extras/fauna/Flock.hpp b/include/threepp/extras/fauna/Flock.hpp new file mode 100644 index 000000000..7a4bcd3d2 --- /dev/null +++ b/include/threepp/extras/fauna/Flock.hpp @@ -0,0 +1,2894 @@ +// A drop-in ambient bird flock: boids that fly, perch, walk about and lift off +// on their own. Not the centre-point of a scene — the whole design is about +// being cheap and unobtrusive while surviving being looked at directly, because +// someone always will. +// +// Three lines in the host: +// +// auto birds = Flock::create(); +// scene->add(birds); +// canvas.animate([&] { birds->update(clock.getDelta()); renderer->render(*scene, *camera); }); +// +// Add `birds->bakePerches(*scene);` and they will find surfaces to land on. +// +// RENDERING: one merged BufferGeometry rebaked on the CPU every frame, drawn as +// a single Mesh with a stock MeshStandardMaterial. This is the one animated-mesh +// shape both backends already agree on — the host picks GL or Vulkan at a +// runtime prompt (RendererFactory.cpp:16-59), and the Vulkan backend has no +// generic ShaderMaterial path, so anything shader-driven renders there as a +// flat grey non-flapping blob with no warning at all. `flatShading` is likewise +// never touched: it has zero occurrences in the Vulkan backend, so setting it +// would make the birds look materially different depending on which renderer +// the user picked at the prompt. Every crease in this mesh comes from split +// vertices, which work identically on both. +// +// THE FLOCK NODE SHOULD STAY AT IDENTITY. The simulation runs in world space +// and vertices are written through a cached inverse of *matrixWorld, so a +// transformed node is correct — but with ONE FRAME OF MATRIX LAG, because +// update() runs before the renderer refreshes matrices. The error is a fraction +// of a bird-length even under motion, and it is stated here rather than fixed: +// the alternative is for this subsystem to call updateMatrixWorld() on someone +// else's node, which is a far worse thing to do to a host scene than being one +// frame stale. A parent with NON-UNIFORM scale additionally shears the birds, +// because the body basis is orthonormalised on the way into local space. +// +// TOPOLOGY IS IMMUTABLE FOR THE OBJECT'S LIFETIME. birdCount is fixed at +// construction; there is no setCount(), no capacity/live split and no parked +// birds. That is what makes the DrawUsage::Dynamic hint safe to set exactly +// once (gl/GLAttributes.cpp:38-56 captures it at glBufferData time, so an +// attribute REPLACED later silently reverts to Static), and it is why there is +// no degenerate parked vertex anywhere in the system to poison a normal. +// +// DETERMINISM: bit-identical for the same binary, the same seed, and the same +// dt sequence. Cross-toolchain agreement is ~1e-4 — sin/cos/atan2/pow are not +// bit-identical across libm implementations. -ffast-math breaks it entirely. +// +// FOUR PLACES WHERE THIS FILE DOES NOT DO WHAT THE DESIGN LITERALLY SAID, each +// because doing so was measured and did not work. They are called out at their +// own sites too; this is the index. +// · The approach HANDS OFF from the gate to the perch (goalTarget()). Steering +// at the gate for the whole approach converges on a point 2–3 m short of +// every perch, and the Approach → Flare test — which measures range to the +// SPOT — then never fires. Measured: 0 landings in 20 000 steps. +// · abortChance is rolled ONCE per approach, not once per decision tick. An +// approach spans six to eight ticks, so per-tick it turns 0.12 into a 64 % +// abort rate. +// · The territory force carries a RADIAL DAMPER and the home drift is 0.35 × +// roamRadius in TOTAL rather than per axis. A pure position spring overshoots +// by v²/2a whatever its gain; without both the flock reaches 1.9 × roamRadius +// from home and keeps going. +// · The altitude spring is switched OFF while a bird is committed to a landing. +// A ground perch sits a full cruiseAltitude below the preferred height, so +// otherwise the altitude force out-pulls the goal force and no bird can +// descend to the ground at all. +// +// Known limitations, stated rather than fixed: +// · Neighbour search is O(N²) and birdCount is hard-clamped to 256. A uniform +// grid is deliberately not in v1: it is the largest single source of +// divergence for a subsystem that will never run 500 birds. 256² distance +// tests is ≈ 0.25 ms; the default of 18 is 324 tests. +// · The obstacle field is 2 m cells (PerchIndex's default), so a bird may +// clip a bare twig or a wire. The ground floor comes from the heightfield +// and is much finer. +// · The perch table is a SNAPSHOT. Move a perched object and the bird floats. +// Call bakePerches() again — there is no dirty tracking, deliberately, +// because the bake's output holds no pointer into the scene. +// · No soaring, no thermalling, no V-formations, no foot IK, no knee. +// +// Header-only, dependency-free beyond threepp core. + +#ifndef THREEPP_FLOCK_HPP +#define THREEPP_FLOCK_HPP + +#include "threepp/cameras/Camera.hpp" +#include "threepp/core/BufferAttribute.hpp" +#include "threepp/core/BufferGeometry.hpp" +#include "threepp/extras/fauna/BirdGeometry.hpp" +#include "threepp/extras/fauna/PerchIndex.hpp" +#include "threepp/materials/MeshStandardMaterial.hpp" +#include "threepp/math/MathUtils.hpp" +#include "threepp/math/Matrix4.hpp" +#include "threepp/math/Sphere.hpp" +#include "threepp/math/Vector2.hpp" +#include "threepp/math/Vector3.hpp" +#include "threepp/objects/Mesh.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace threepp { + + class Flock: public Mesh { + + public: + enum class BirdState : std::uint8_t { + Cruise = 0, // airborne, boids steering + Approach = 1,// committed to a claimed perch, steering to the gate + Flare = 2, // last 1.2 m, landing controller + Perched = 3, // feet planted; hops/walks when the spot is walkable + Launch = 4, // crouch, leg impulse, three boosted beats + Evade = 5, // startled, overriding steering + }; + + enum class BirdRole : std::uint8_t { Follower = 0, + Leader = 1, + Loner = 2 }; + + enum class Gait : std::uint8_t { Hop = 0, + Walk = 1 }; + + struct Params { + unsigned int seed = 1337u; + + // ── Population ─────────────────────────────────────────────── + // 18 birds over a 42 m radius reads as "a place where birds live". + // 200 reads as "a bird simulation" and drags the eye to exactly + // where this subsystem is not supposed to put it. Raise it knowing + // that. Hard-clamped to [0, 256]: neighbour search is O(N²) and a + // uniform grid is deliberately not in v1. + int birdCount = 18; + + // ── Territory (world, metres) ──────────────────────────────── + Vector3 home{0.f, 14.f, 0.f};// centre of the loiter volume; DRIFTS at runtime + float roamRadius = 42.f; // soft; the bounds force is zero inside 0.75× this + float cruiseAltitude = 14.f; // m above the baked ground under `home` + float altitudeSpread = 0.35f;// ± fraction, per bird — breaks the flock plane + float homeDriftRate = 0.012f;// Hz; the territory migrates so it is never a bowl + + // ── Species ────────────────────────────────────────────────── + float massKg = 0.078f; // drives wingbeatHz allometrically + float wingbeatHz = 0.f; // 0 ⇒ 8.5 · (massKg/0.078)^(-1/3) + bool nyquistGuard = true;// clamp effective beat to 1/(6·dtSmoothed) + Gait gait = Gait::Walk; // starlings/crows/pigeons walk; finches/sparrows hop + + // ── Flight ─────────────────────────────────────────────────── + float cruiseSpeed = 9.0f; // m/s + float minSpeed = 4.5f; // m/s; below this the bird stall-flares + float maxSpeed = 17.0f; // m/s + float maxAccelAlong = 3.0f; // m/s² — birds accelerate slowly + float maxAccelLateral = 9.0f;// m/s² — and turn hard. The asymmetry matters. + float speedDrag = 1.8f; // 1/s toward target speed; a soft law, never a clamp + float maxTurnRate = 3.2f; // rad/s, heading slew cap + float maxBank = 1.05f; // rad (60°) + float bankGain = 1.25f; // birds over-bank a coordinated turn + float bankTau = 0.18f; // s, roll response time constant + float pitchTau = 0.20f; // s + + // ── Wingbeat kinematics (radians / cycles) ─────────────────── + float strokeDown = 0.88f; // rad below horizontal (50°) + float strokeUp = 0.58f; // rad above horizontal (33°) + float downstrokeFrac = 0.42f;// fraction of the cycle spent going down + float spanLagCycles = 0.13f; // root→tip travelling-wave lag + float twistAmp = 0.34f; // rad tip twist — the scintillation + float wristFlex = 1.00f; // rad hand fold at mid-upstroke + float glideDihedral = 0.10f; // rad, static dihedral held while gliding + float perchSweep = 1.35f; // rad, folded-wing sweep + float perchLift = 0.20f; // rad, folded-wing lift + + // ── Boids ──────────────────────────────────────────────────── + // Topological, not metric (Ballerini et al., PNAS 2008): a fixed + // NUMBER of nearest neighbours, so the flock keeps cohesion whether + // it compresses or spreads. That density independence is what lets + // ONE set of weights work in a scene whose bird count and volume + // the host chose, not us. + int neighbourCount = 7; + float neighbourRadius = 8.0f; // m, candidate gather only + float rearBlindAngle = 0.52f; // rad, half-cone behind that is ignored + float separationDistance = 1.6f;// m + float cohesionDeadZone = 1.2f; // m; without it a bird at the centroid jitters + float wSeparation = 1.80f; + float wAlignment = 0.60f; + float wCohesion = 0.45f; + float wWander = 0.80f; + float wBounds = 1.20f; + float wAltitude = 0.60f; + float wObstacle = 3.50f; + float wGround = 4.00f; + float wGoal = 1.20f; + float wObserver = 0.15f; // soft repulsion from setObserver()'s camera + float leaderFraction = 0.08f; + float lonerFraction = 0.20f; + float leaderReassign = 7.0f;// s; leadership is a POSITION, not a trait + + // ── Obstacles ──────────────────────────────────────────────── + float lookaheadTime = 0.55f; // s, obstacle field sample distance + float obstacleMargin = 6.0f; // m, clearance below which the field repels + float minGroundClearance = 1.2f;// m above heightAt() + + // ── Perching ───────────────────────────────────────────────── + bool perching = true; + float perchSearchRadius = 32.f; // m + float perchIntervalMin = 25.f; // s aloft before a bird wants down + float perchIntervalMax = 90.f; + float restIntervalMin = 12.f; // s perched before it wants up + float restIntervalMax = 70.f; + float maxPerchedFraction = 0.55f;// 0..1; the sky is never allowed to empty + float perchContagion = 0.35f; // 0..1, land-because-others-landed + float launchContagion = 0.55f; // 0..1, leave-because-a-neighbour-left + float contagionRadius = 4.0f; // m + float flareDistance = 1.2f; // m + float settleTime = 0.35f; // s, contact → wings fully folded + float abortChance = 0.12f; // approaches that go around — a FEATURE + float groundBias = 0.30f; // 0..1 chance a walkable perch gets walked about + fauna::PerchIndex::Params perch{}; + + // ── Disturbance ────────────────────────────────────────────── + float flightInitiationDistance = 6.0f;// m, × per-bird boldness + float startleWaveSpeed = 25.f; // m/s, contagion propagation + float postFlushCalmMin = 20.f; // s before a flushed flock will land again + float postFlushCalmMax = 60.f; + + // ── Look ───────────────────────────────────────────────────── + fauna::BirdShape shape{}; + fauna::BirdPlumage plumage{}; + float sizeVariation = 0.10f; // ± fraction, per bird + bool birdsCastShadow = false; // correct when enabled — the deformation is in the verts + float lodFarDistance = 60.f; // m; beyond this a bird is re-baked every 2nd frame + Vector2 wind{0.7f, 0.7f}; // world XZ; perched birds face into it + + bool operator==(const Params&) const = default; + }; + + explicit Flock(const Params& params) + : Mesh(BufferGeometry::create(), defaultMaterial()), + params_(sanitise(params)), + tmpl_(fauna::makeBirdTemplate(params_.shape)) { + + build(); + } + + // Two overloads, NOT a defaulted argument: GCC rejects a default + // argument naming a nested class's defaults before the outer class is + // complete (cf. FireEffect.hpp:348-363). + static std::shared_ptr create(const Params& params) { + + return std::make_shared(params); + } + + static std::shared_ptr create() { + + return create(Params{}); + } + + [[nodiscard]] std::string type() const override { + + return "Flock"; + } + + // Advance the simulation and rebake the geometry. CALL ONCE PER FRAME. + // + // dt is clamped internally to [0, 0.05] s. A debugger pause or a loading + // hitch otherwise teleports the whole flock through the scene in one + // step, and at 0.05 s a 17 m/s bird still moves less than half an + // obstacle cell (0.85 m against the 2 m default), so it cannot tunnel + // through geometry either. There is no sub-stepping: a hitch costs the + // flock a little travel, which nobody can see, rather than a variable + // number of integrations, which nobody can reproduce. + // dt <= 0 is a no-op that increments stalledUpdates(). + void update(float dt) { + + if (!(dt > 0.f) || !std::isfinite(dt)) { + ++stalled_; + return; + } + stalled_ = 0; + + dt = std::min(dt, kMaxStep); + lastDt_ = dt; + time_ += dt; + ++frame_; + ++updates_; + + // A FIXED EMA, not a wall-clock measurement: the Nyquist guard reads + // it, so it has to be a pure function of the dt sequence or the + // wingbeat frequency — and therefore every wing vertex — would + // depend on how fast the machine happened to be running. + dtSmoothed_ = 0.9f * dtSmoothed_ + 0.1f * dt; + + if (bakeRequested_ && !perch_.complete()) { + if (perch_.step()) { + bakeRequested_ = false; + hasField_ = true; + syncClaims(); + } + } + + if (birds_.empty()) return; + + refreshWorldInverse(); + updateAggregates(); + + for (int i = 0; i < count_; ++i) gatherNeighbours(i); + for (int i = 0; i < count_; ++i) decide(i, dt); + for (int i = 0; i < count_; ++i) accel_[static_cast(i)] = steer(i); + for (int i = 0; i < count_; ++i) integrate(i, dt); + + // DOUBLE BUFFERED. Steps 4, 6 and 7 above all read prev_ and only + // the integrator writes next_. Without this, bird 5 would see bird + // 0's new position and bird 6's old one — still deterministic, but + // it makes the neighbour relation asymmetric in a way nobody can + // reason about, and it silently couples the result to loop order in + // every future refactor. This is not an optimisation to undo. + std::swap(prev_, next_); + + bakeVertices(); + } + + // One-time scene scan: perch spots, obstacle field, ground heightfield. + // Amortised over frames at Params::perch.bakeWorkPerFrame; until it + // completes the birds simply fly. Excludes this Flock automatically, so + // add() order does not matter. + // + // THE HOST MUST CALL THIS AGAIN AFTER CHANGING THE SCENE. There is no + // dirty tracking, deliberately: the bake's output holds no pointer into + // the scene, so a stale bake leaves a bird perched in mid-air rather + // than dereferencing freed geometry. + void bakePerches(Object3D& sceneRoot) { + + releaseAllClaims(); + perch_.begin(sceneRoot, params_.perch, this, filter_); + bakeRequested_ = true; + hasField_ = false; + syncClaims(); + } + + void bakePerchesBlocking(Object3D& sceneRoot) { + + releaseAllClaims(); + perch_.bakeBlocking(sceneRoot, params_.perch, this, filter_); + bakeRequested_ = false; + hasField_ = true; + syncClaims(); + } + + [[nodiscard]] bool bakeComplete() const { + + return perch_.complete(); + } + + [[nodiscard]] float bakeProgress() const { + + return perch_.progress(); + } + + [[nodiscard]] std::size_t perchCount() const { + + return perch_.spots().size(); + } + + // Kept OUT of Params on purpose: std::function has no operator==, so a + // std::function member would silently DELETE the defaulted operator== + // the house style requires — and the deletion is only diagnosed at the + // first use site, far from the cause. + void setPerchFilter(std::function filter) { + + filter_ = std::move(filter); + } + + // Authored perches — the escape hatch for a huge scene, or a designer + // who knows exactly which three railings matter. Call instead of + // bakePerches(); the obstacle field and heightfield stay empty, so + // birds get no obstacle avoidance and a flat floor at home.y - 100. + void setPerches(std::vector spots) { + + releaseAllClaims(); + perch_.setSpots(std::move(spots)); + bakeRequested_ = false; + hasField_ = false; + syncClaims(); + } + + void addPerch(const Vector3& worldPos, const Vector3& worldNormal, bool walkable) { + + fauna::PerchSpot spot; + spot.position = worldPos; + spot.normal = fauna::detail::safeNormalized(worldNormal, {0, 1, 0}); + if (spot.normal.y < 0.f) spot.normal.negate(); + spot.walkable = walkable; + spot.ground = false; + + releaseAllClaims(); + perch_.addSpot(spot); + bakeRequested_ = false; + syncClaims(); + } + + // ── Events ─────────────────────────────────────────────────────── + // A wave of agitation at `epicentre`, propagating outward at + // Params::startleWaveSpeed. Perched birds launch as it reaches them; + // flying birds break away. Twelve lines for one of the most + // recognisable behaviours in nature — and the wave is the whole point: + // a flock that flushes on one frame reads as a scripted cut, while the + // same flock flushing over 0.4 s reads as alarm spreading through it. + // + // Safe to call from outside update(): it only queues per-bird reaction + // TIMES and touches nothing the steering or integration steps read. + void startle(const Vector3& epicentre, float radius = 1e9f, float strength = 1.f) { + + if (birds_.empty() || !fauna::detail::isFinite(epicentre)) return; + + const float r2 = radius * radius; + const float waveSpeed = std::max(params_.startleWaveSpeed, 0.1f); + + for (int i = 0; i < count_; ++i) { + + Bird& b = birds_[static_cast(i)]; + const float d2 = prev_[static_cast(i)].pos.distanceToSquared(epicentre); + if (d2 > r2) continue; + + // Even the bypass gets a per-bird latency. A wave that arrives + // at the speed of sound and is acted on in the same frame is + // still a simultaneous flush wherever the birds are close + // together, which is exactly where a row of them is. + const float latency = 0.05f + 0.20f * roll(b, i); + const float at = time_ + std::sqrt(d2) / waveSpeed + latency; + + if (b.reactAt < 0.f || at < b.reactAt) { + b.reactAt = at; + b.startleFrom = epicentre; + b.startleStrength = strength; + } + } + } + + // Non-owning; perched birds flush when it enters their personal flight- + // initiation distance. Pass nullptr to clear. THE CALLER MUST CLEAR IT + // BEFORE DESTROYING THE NODE — it is dereferenced once per bird per frame. + void setDisturbanceSource(const Object3D* source) { + + disturbance_ = source; + } + + // Non-owning; enables the distance LOD and camera shyness. nullptr clears. + // Same lifetime rule as above, and the same reason: the dereference is in + // the hot path, so a dangling pointer here is a crash in the render loop + // rather than a stale frame. + void setObserver(const Camera* camera) { + + observer_ = camera; + } + + void setWind(const Vector2& dirXZ) { + + params_.wind = dirXZ; + } + + // ── Query ──────────────────────────────────────────────────────── + [[nodiscard]] int birdCount() const { + + return count_; + } + + [[nodiscard]] int perchedCount() const { + + return perchedNow_; + } + + [[nodiscard]] int flyingCount() const { + + return count_ - perchedNow_; + } + + [[nodiscard]] BirdState stateOf(int i) const { + + if (i < 0 || i >= count_) return BirdState::Cruise; + return birds_[static_cast(i)].state; + } + + [[nodiscard]] BirdRole roleOf(int i) const { + + if (i < 0 || i >= count_) return BirdRole::Follower; + return birds_[static_cast(i)].role; + } + + [[nodiscard]] const Vector3& birdPosition(int i) const { + + if (i < 0 || i >= count_) return zero_; + return prev_[static_cast(i)].pos; + } + + [[nodiscard]] const Vector3& birdVelocity(int i) const { + + if (i < 0 || i >= count_) return zero_; + return prev_[static_cast(i)].vel; + } + + [[nodiscard]] const fauna::PerchIndex& perchIndex() const { + + return perch_; + } + + [[nodiscard]] const Params& params() const { + + return params_; + } + + // Diagnostics. These turn the two bug reports this subsystem will + // actually generate — "my birds don't move" and "my birds never land" — + // into a ten-second answer instead of a support thread. + [[nodiscard]] std::uint64_t updateCount() const { + + return updates_; + } + + [[nodiscard]] std::uint64_t stalledUpdates() const { + + return stalled_; + } + + // The material this Flock was built with. vertexColors is true and + // color is white; if you swap the material, keep both, or every bird + // renders black (a vertexColors material with no colour attribute gets + // the generic attribute default 0,0,0,1 — and this one HAS the + // attribute, so the failure is the other way round: drop vertexColors + // and every bird turns into an untextured white dart). + // MESHSTANDARDMATERIAL, NOT MESHPHONGMATERIAL, AND THE REASON IS THE + // SAME ONE THAT BANS flatShading HERE. + // + // The host chooses its backend at runtime, so anything this material + // says has to survive both. The Vulkan backend builds its material + // record by asking for exactly four interfaces — MaterialWithColor, + // MaterialWithRoughness, MaterialWithMetalness, MaterialWithEmissive + // (VulkanCoreScene.cpp materialFromMesh). MeshPhongMaterial implements + // MaterialWithSpecular and none of the middle two, so a Phong flock + // keeps its albedo and its vertex colours and then silently falls back + // to the default roughness 0.5 / metalness 0: every bit of shininess + // and specular tuning is dropped on the floor, with no warning, and the + // plumage reads differently depending on which renderer the host + // happened to pick. MeshStandardMaterial's roughness and metalness are + // read by BOTH backends, so what is tuned here is what is seen. + // + // vertexColors is true and color is white; if you swap the material, + // keep both. Dropping vertexColors turns every bird into an untextured + // white dart. + [[nodiscard]] static std::shared_ptr defaultMaterial() { + + auto m = MeshStandardMaterial::create(); + m->color = Color(0xffffff);// the vertex colour IS the albedo + m->vertexColors = true; + // Every part is a closed solid except two buried holes per bird + // (wing root, hip ring) that sit inside the opaque body, so the + // cheap side is also the correct one. + m->side = Side::Front; + // Matte plumage. Feathers are not glossy, and a low roughness here + // puts a moving highlight on every bird that draws the eye straight + // to the thing that is meant to stay in the background. + m->roughness = 0.65f; + m->metalness = 0.f; + m->name = "flockPlumage"; + return m; + } + + ~Flock() override = default; + + private: + // ── Tuning that is not a knob ──────────────────────────────────── + static constexpr float kMaxStep = 0.05f; // s, the dt ceiling (see update()) + static constexpr float kGravity = 9.81f; // m/s², used by bounds/bounding/leaps only + static constexpr int kMaxBirds = 256; // O(N²) neighbours; see the banner + static constexpr int kMaxNeighbours = 24; + static constexpr float kStepTime = 0.13f; // s, one walking step's swing phase + static constexpr float kStepRate = 3.0f; // Hz + static constexpr float kGroundSpeed = 0.38f;// m/s, walking + static constexpr float kHopCycle = 0.38f; // s + static constexpr float kSaccade = 0.045f; // s — the head SNAPS; see below + static constexpr float kBalanceHold = 0.12f;// s, wings held half-raised after contact + static constexpr float kLiftOmega = 22.f; // rad/s, landing spring + static constexpr float kLiftZeta = 0.55f; + + // Position and velocity, and nothing else: this is the only state that + // is double-buffered, because it is the only state one bird reads off + // another. + struct Dyn { + Vector3 pos{}; + Vector3 vel{}; + }; + + struct Bird { + // ── Personality, drawn once (§5.4) ─────────────────────────── + float beatRate = 1.f; + float beatAmp = 1.f; + float size = 1.f; + float speedScale = 1.f; + float boldness = 1.f; + float sociability = 1.f; + float restlessness = 1.f; + float yPrefFrac = 1.f; + float rollTrim = 0.f; + float wingAsym = 0.f; + float decisionPeriod = 0.5f; + Vector3 wanderAxis{0, 1, 0}; + Vector3 wanderDir{0, 0, 1}; + float gaitPhase = 0.f; + std::array idlePhase{}; + + // ── Runtime ────────────────────────────────────────────────── + BirdState state = BirdState::Cruise; + BirdRole role = BirdRole::Follower; + std::uint32_t seq = 0;// event sequence; advanced only inside decision ticks + float stateTime = 0.f; + float nextDecision = 0.f; + bool baked = false; + + Vector3 fwd{0, 0, 1};// heading, unit + float bank = 0.f; + float pitch = 0.f; + float targetSpeed = 9.f; + + float cyclePos = 0.f; + float flapWeight = 1.f; + float perchFold = 0.f; + float tailSpread = 0.10f; + float tailPitch = 0.f; + float tailRoll = 0.f; + float legExtend = 0.f; + bool feetPlanted = false; + std::array footWorld{}; + + float bodyLift = 0.f; + float liftX = 0.f;// landing-spring displacement + float liftV = 0.f;// landing-spring velocity + + // Head. Gaze is stabilised by the NECK, not by eye movement — the + // head snaps to a target and holds it. + float headYaw = 0.f, headPitch = 0.f, headRoll = 0.f, headLead = 0.f; + float headYawFrom = 0.f, headPitchFrom = 0.f; + float headYawTo = 0.f, headPitchTo = 0.f; + float saccadeStart = -1.f; + float saccadeNext = 0.f; + + // Idle timers (four incommensurate periods per bird, so two perched + // birds essentially never twitch together). + float flickAt = 0.f, flickStart = -1.f; + float shuffleAt = 0.f, shuffleStart = -1.f; + float preenAt = 0.f, preenUntil = -1.f; + + // Perching + float perchUrge = 0.f, restUrge = 0.f; + float perchInterval = 50.f, restInterval = 30.f; + float perchSuppress = 0.f;// s of enforced airtime + int claim = -1; // index into perch_.spots(), -1 = none + Vector3 spotPos{}, spotNormal{0, 1, 0}, gate{}; + bool spotWalkable = false; + + // Perched stance + Vector3 anchor{};// world foot-contact point + Vector3 stagger{}; + Vector3 groundGoal{}; + bool walker = false; + bool leaped = false; + bool abortRolled = false; + bool gatePassed = false; + float pauseUntil = 0.f; + float stepStart = -1.f; + float gaitPitch = 0.f; + float gaitLift = 0.f; + int swingFoot = 0; + Vector3 stepFrom{}, stepTo{}; + + // Flap-bounding + bool bounding = false; + float boundT = 0.f; + float beatsLeft = 5.f; + + // Startle + float reactAt = -1.f; + Vector3 startleFrom{}; + float startleStrength = 0.f; + Vector3 evadeAway{1, 0, 0}; + float evadeUntil = 0.f; + }; + + // ── Deterministic stateless hash ───────────────────────────────── + // + // Copied from FireEffect.cpp:15-34. It lives here as private statics + // rather than in an anonymous namespace, which is what the spec's prose + // says: an anonymous namespace inside a HEADER gives every translation + // unit its own copy, and an inline member function that referenced one + // would be an ODR violation the linker is free not to diagnose. + // + // A decision stream is a pure function of (seed, bird, sequence) and is + // therefore independent of frame ordering, of how many frames a bake + // took, and of wall time — none of which are reproducible. + [[nodiscard]] static std::uint32_t hashU(std::uint32_t x) { + + x ^= x >> 16; + x *= 0x7feb352du; + x ^= x >> 15; + x *= 0x846ca68bu; + x ^= x >> 16; + return x; + } + + [[nodiscard]] static float rnd01(std::uint32_t seed, std::uint32_t slot, std::uint32_t stream) { + + const std::uint32_t h = hashU(slot * 0x9e3779b9u + stream * 0x85ebca6bu + seed); + // 24 bits -> [0,1). Exact in fp32, and never reaches 1.0. + return static_cast(h >> 8) * (1.f / 16777216.f); + } + + // One draw for one bird, advancing that bird's own sequence. Never call + // it twice inside a single expression: argument evaluation order is + // unspecified, and two draws in one argument list is exactly how + // "deterministic for a fixed seed" comes to mean different things on + // different toolchains. + float roll(Bird& b, int i) const { + + return rnd01(params_.seed, static_cast(i), b.seq++); + } + + float rollRange(Bird& b, int i, float lo, float hi) const { + + return lo + (hi - lo) * roll(b, i); + } + + // ── Parameter hygiene ──────────────────────────────────────────── + // + // Clamped rather than trusted, because three of these turn a typo into + // something that does not look like a typo: a birdCount of 5000 turns an + // ambient background into a 6-second frame, a zero perchInterval makes + // every bird want down on the first tick, and a negative separation + // distance inverts the boids force so the flock implodes into one point. + [[nodiscard]] static Params sanitise(const Params& in) { + + Params p = in; + + p.birdCount = std::clamp(p.birdCount, 0, kMaxBirds); + p.roamRadius = std::max(p.roamRadius, 1.f); + p.cruiseAltitude = std::max(p.cruiseAltitude, 0.5f); + p.altitudeSpread = std::clamp(p.altitudeSpread, 0.f, 0.9f); + p.massKg = std::max(p.massKg, 1e-3f); + + p.maxSpeed = std::max(p.maxSpeed, 1.f); + p.cruiseSpeed = std::clamp(p.cruiseSpeed, 0.5f, p.maxSpeed); + p.minSpeed = std::clamp(p.minSpeed, 0.1f, p.cruiseSpeed); + p.maxAccelAlong = std::max(p.maxAccelAlong, 0.1f); + p.maxAccelLateral = std::max(p.maxAccelLateral, 0.1f); + p.maxTurnRate = std::max(p.maxTurnRate, 0.05f); + p.maxBank = std::clamp(p.maxBank, 0.f, 1.4f); + p.bankTau = std::max(p.bankTau, 1e-3f); + p.pitchTau = std::max(p.pitchTau, 1e-3f); + + p.neighbourCount = std::clamp(p.neighbourCount, 0, kMaxNeighbours); + p.neighbourRadius = std::max(p.neighbourRadius, 0.f); + p.rearBlindAngle = std::clamp(p.rearBlindAngle, 0.f, math::PI); + p.separationDistance = std::max(p.separationDistance, 1e-2f); + p.cohesionDeadZone = std::max(p.cohesionDeadZone, 0.f); + p.leaderFraction = std::clamp(p.leaderFraction, 0.f, 1.f); + p.lonerFraction = std::clamp(p.lonerFraction, 0.f, 1.f - p.leaderFraction); + p.leaderReassign = std::max(p.leaderReassign, 0.5f); + + p.obstacleMargin = std::max(p.obstacleMargin, 1e-2f); + p.minGroundClearance = std::max(p.minGroundClearance, 0.f); + + p.perchIntervalMin = std::max(p.perchIntervalMin, 0.5f); + p.perchIntervalMax = std::max(p.perchIntervalMax, p.perchIntervalMin); + p.restIntervalMin = std::max(p.restIntervalMin, 0.5f); + p.restIntervalMax = std::max(p.restIntervalMax, p.restIntervalMin); + p.maxPerchedFraction = std::clamp(p.maxPerchedFraction, 0.f, 1.f); + p.perchContagion = std::clamp(p.perchContagion, 0.f, 1.f); + p.launchContagion = std::clamp(p.launchContagion, 0.f, 1.f); + p.contagionRadius = std::max(p.contagionRadius, 1e-2f); + p.flareDistance = std::max(p.flareDistance, 0.05f); + p.settleTime = std::max(p.settleTime, 1e-2f); + p.abortChance = std::clamp(p.abortChance, 0.f, 1.f); + p.groundBias = std::clamp(p.groundBias, 0.f, 1.f); + + p.postFlushCalmMin = std::max(p.postFlushCalmMin, 0.f); + p.postFlushCalmMax = std::max(p.postFlushCalmMax, p.postFlushCalmMin); + + p.sizeVariation = std::clamp(p.sizeVariation, 0.f, 0.5f); + p.lodFarDistance = std::max(p.lodFarDistance, 1.f); + + return p; + } + + // ── Construction ───────────────────────────────────────────────── + void build() { + + count_ = params_.birdCount; + seedRng(); + buildGeometry(); + buildBirds(); + bakeVertices(); + } + + void buildGeometry() { + + // The nine live wingbeat knobs are routed through BirdGeometry's + // six-argument poseBird(). Without this the five-argument form would + // quietly use the stock constants and Params' whole "Wingbeat + // kinematics" block would be dead code that still autocompletes. + kin_.strokeDown = params_.strokeDown; + kin_.strokeUp = params_.strokeUp; + kin_.downstrokeFrac = params_.downstrokeFrac; + kin_.spanLagCycles = params_.spanLagCycles; + kin_.twistAmp = params_.twistAmp; + kin_.wristFlex = params_.wristFlex; + kin_.glideDihedral = params_.glideDihedral; + kin_.perchSweep = params_.perchSweep; + kin_.perchLift = params_.perchLift; + + const auto verts = static_cast(count_) * fauna::kVertsPerBird; + + std::vector positions(verts * 3u, 0.f); + std::vector normals(verts * 3u, 0.f); + // A zero normal on an unwritten vertex is the only safe default: it + // shades black rather than NaN, and every vertex is overwritten by + // the first bakeVertices() call in the constructor anyway. + for (std::size_t v = 0; v < verts; ++v) normals[v * 3u + 1u] = 1.f; + + auto geo = geometry_; + geo->setAttribute("position", FloatBufferAttribute::create(std::move(positions), 3)); + geo->setAttribute("normal", FloatBufferAttribute::create(std::move(normals), 3)); + geo->setAttribute("color", FloatBufferAttribute::create( + fauna::makeFlockColors(tmpl_, params_.plumage, count_, params_.seed), 3)); + geo->setIndex(fauna::makeFlockIndices(count_)); + + posAttr_ = geo->getAttribute("position"); + nrmAttr_ = geo->getAttribute("normal"); + + // SET BEFORE THE FIRST RENDER, ONCE. gl/GLAttributes.cpp:38-56 + // captures the usage hint at glBufferData time, so a hint set after + // the first upload does nothing at all. Topology is immutable + // (§0.5), so these attributes are never replaced and this call never + // needs repeating — which is precisely why topology is immutable. + posAttr_->setUsage(DrawUsage::Dynamic); + nrmAttr_->setUsage(DrawUsage::Dynamic); + + // A geometry with no explicit bounding sphere is frustum-culled + // against an empty optional that NOTHING recomputes + // (BufferGeometry.hpp:38; Frustum.cpp:68-72). Populate it here so + // even a never-updated Flock renders, and refresh it every frame so + // frustumCulled can stay TRUE — which is better than the usual + // workaround of switching culling off and paying for the draw + // whichever way the camera is facing. + geo->boundingSphere = Sphere(Vector3{}, 0.f); + + castShadow = params_.birdsCastShadow; + receiveShadow = false; + name = "Flock"; + } + + void seedRng() { + + std::mt19937 rng(params_.seed ? params_.seed : 1u); + + // [0,1) by hand rather than through uniform_real_distribution, whose + // mapping is implementation-defined even though mt19937's output + // sequence is not. Same reasoning as makeFlockColors(). + auto u01 = [&rng] { return static_cast(rng() >> 8) * (1.f / 16777216.f); }; + + for (int m = 0; m < 3; ++m) { + // Every draw in its own named const. Three of them in one set() + // call would make the flock's territory drift differ per + // toolchain, and it would take a very long afternoon to find. + const float ax = u01() * 2.f - 1.f; + const float ay = (u01() * 2.f - 1.f) * 0.25f; + const float az = u01() * 2.f - 1.f; + const float ph = u01() * math::TWO_PI; + driftAxis_[static_cast(m)] = + fauna::detail::safeNormalized({ax, ay, az}, {1, 0, 0}); + driftPhase_[static_cast(m)] = ph; + } + + rngDraws_.clear(); + rngDraws_.reserve(static_cast(count_) * 24u); + for (int i = 0; i < count_ * 24; ++i) rngDraws_.push_back(u01()); + } + + void buildBirds() { + + birds_.assign(static_cast(count_), Bird{}); + prev_.assign(static_cast(count_), Dyn{}); + next_.assign(static_cast(count_), Dyn{}); + accel_.assign(static_cast(count_), Vector3{}); + nbrIdx_.assign(static_cast(count_) * kMaxNeighbours, -1); + nbrCount_.assign(static_cast(count_), 0); + + // ROLES BY DETERMINISTIC INDEX RATIO, not by RNG: the counts must be + // exactly reproducible even when the personality draws are not being + // consumed in the same order (they are, but this removes the + // question). + leaderCount_ = static_cast(std::lround(static_cast(count_) * params_.leaderFraction)); + lonerCount_ = static_cast(std::lround(static_cast(count_) * params_.lonerFraction)); + leaderCount_ = std::clamp(leaderCount_, 0, count_); + lonerCount_ = std::clamp(lonerCount_, 0, count_ - leaderCount_); + + for (int i = 0; i < count_; ++i) { + + Bird& b = birds_[static_cast(i)]; + const std::size_t d = static_cast(i) * 24u; + + // phase01 — THE SINGLE CHEAPEST ANTI-TELL IN THE DESIGN. + // Without it every bird beats in lockstep and the flock reads as + // one organism cloned N times, which is the first thing anyone + // notices and the last thing they can name. + b.cyclePos = draw(d + 0); + // Two birds at 8.5 and 9.2 Hz drift a half-cycle apart in 0.7 s, + // so even a genuine synchronising event decorrelates within ~2 s + // with no special handling anywhere. + b.beatRate = 0.88f + 0.24f * draw(d + 1); + b.beatAmp = 0.92f + 0.16f * draw(d + 2); + b.size = 1.f + (draw(d + 3) * 2.f - 1.f) * params_.sizeVariation; + b.speedScale = 0.88f + 0.24f * draw(d + 4); + b.boldness = 0.30f + 0.70f * draw(d + 5); + b.sociability = 0.40f + 0.90f * draw(d + 6); + b.restlessness = 0.50f + 1.10f * draw(d + 7); + // The axis most implementations forget, because separation is + // always tuned looking down from above: without an altitude + // spread the flock is a disc. + b.yPrefFrac = 1.f + (draw(d + 8) * 2.f - 1.f) * params_.altitudeSpread; + b.rollTrim = (draw(d + 9) * 2.f - 1.f) * 0.03f; + b.wingAsym = (draw(d + 10) * 2.f - 1.f) * 0.03f; + b.decisionPeriod = 0.25f + 0.65f * draw(d + 11); + b.gaitPhase = draw(d + 12); + b.idlePhase[0] = draw(d + 13); + b.idlePhase[1] = draw(d + 14); + b.idlePhase[2] = draw(d + 15); + b.idlePhase[3] = draw(d + 16); + + const float wx = draw(d + 17) * 2.f - 1.f; + const float wy = draw(d + 18) * 2.f - 1.f; + const float wz = draw(d + 19) * 2.f - 1.f; + b.wanderAxis = fauna::detail::safeNormalized({wx, wy, wz}, {0, 1, 0}); + + const float hx = draw(d + 20) * 2.f - 1.f; + const float hz = draw(d + 21) * 2.f - 1.f; + b.fwd = fauna::detail::safeNormalized({hx, 0.f, hz}, {0, 0, 1}); + b.wanderDir = b.fwd; + + // Two birds therefore react to the same stimulus 0–0.9 s apart. + // That stagger IS what a flock's ripple looks like, it costs + // nothing, and it is the cheapest general answer to "N agents + // reacting on the same frame". + b.nextDecision = draw(d + 22) * b.decisionPeriod; + + b.role = (i < leaderCount_) ? BirdRole::Leader + : (i < leaderCount_ + lonerCount_) ? BirdRole::Loner + : BirdRole::Follower; + + b.perchInterval = math::lerp(params_.perchIntervalMin, params_.perchIntervalMax, + std::clamp(1.6f - b.restlessness, 0.f, 1.f) / 1.1f); + b.restInterval = math::lerp(params_.restIntervalMin, params_.restIntervalMax, + std::clamp(b.restlessness, 0.f, 1.6f) / 1.6f); + b.perchUrge = draw(d + 23) * 0.6f; + b.targetSpeed = params_.cruiseSpeed * b.speedScale; + + // Initial placement: a shell around home, so the flock is + // already a flock on frame 0 rather than a point that explodes. + const float ang = draw(d + 0) * math::TWO_PI; + const float rad = params_.roamRadius * (0.15f + 0.45f * draw(d + 4)); + Dyn& dyn = prev_[static_cast(i)]; + dyn.pos.set(params_.home.x + std::cos(ang) * rad, + params_.home.y + (draw(d + 8) * 2.f - 1.f) * params_.cruiseAltitude * 0.25f, + params_.home.z + std::sin(ang) * rad); + dyn.vel = b.fwd; + dyn.vel.multiplyScalar(b.targetSpeed); + next_[static_cast(i)] = dyn; + + // Four incommensurate idle periods per bird, so two perched + // birds essentially never coincide and a row on a ridge never + // twitches in unison. + b.saccadeNext = b.idlePhase[1] * 2.f; + b.flickAt = 2.f + b.idlePhase[1] * 4.f; + b.shuffleAt = 8.f + b.idlePhase[2] * 12.f; + b.preenAt = 15.f + b.idlePhase[3] * 45.f; + } + } + + [[nodiscard]] float draw(std::size_t k) const { + + return k < rngDraws_.size() ? rngDraws_[k] : 0.5f; + } + + [[nodiscard]] float baseBeatHz() const { + + if (params_.wingbeatHz > 0.f) return params_.wingbeatHz; + // Allometric: a heavier bird beats slower, as f ∝ m^(-1/3). It is one + // pow() at construction and it means "make them pigeons" is a mass, + // not a frequency nobody knows the value of. + return 8.5f * std::pow(params_.massKg / 0.078f, -1.f / 3.f); + } + + // ── World ↔ local ──────────────────────────────────────────────── + // + // The simulation is world-space; the vertices have to land in the node's + // own space. Rather than transforming 94 positions and 94 normals per + // bird, the BirdPose itself is carried into local space — four vectors + // and a scalar — and poseBird() then writes local coordinates directly. + // For a rigid or uniformly-scaled parent the two are identical; under + // non-uniform scale the basis is re-orthonormalised by + // transformDirection() and the birds shear, which is documented in the + // banner and not worth 188 transforms a bird to fix. + void refreshWorldInverse() { + + const Matrix4& w = *matrixWorld; + + identityWorld_ = true; + for (unsigned int k = 0; k < 16u; ++k) { + const float want = (k % 5u == 0u) ? 1.f : 0.f; + if (std::abs(w.elements[k] - want) > 1e-6f) { + identityWorld_ = false; + break; + } + } + if (identityWorld_) { + invScale_ = 1.f; + return; + } + + invWorld_.copy(w).invert(); + + Vector3 s; + s.setFromMatrixScale(invWorld_); + invScale_ = (s.x + s.y + s.z) / 3.f; + if (!std::isfinite(invScale_) || invScale_ <= 0.f) invScale_ = 1.f; + } + + void toLocal(fauna::BirdPose& pose) const { + + if (identityWorld_) return; + + pose.pos.applyMatrix4(invWorld_); + pose.bx.transformDirection(invWorld_); + pose.by.transformDirection(invWorld_); + pose.bz.transformDirection(invWorld_); + pose.footWorld[0].applyMatrix4(invWorld_); + pose.footWorld[1].applyMatrix4(invWorld_); + pose.scale *= invScale_; + } + + // ── Scene queries with an empty-index answer ───────────────────── + // + // A never-baked index answers heightAt() with 0, which for a scene whose + // ground is at y = 0 is right by accident and for one at y = 40 is a + // floor forty metres below the birds. Substituting home.y - 100 instead + // makes the ground force provably inert until there is a real + // heightfield to consult — "no data" has to mean "no force", never + // "force toward zero". + [[nodiscard]] float groundAt(float x, float z) const { + + if (!hasField_) return params_.home.y - 100.f; + return perch_.heightAt(x, z); + } + + // The floor a given bird is entitled to, in world Y. ONE definition, + // consulted by both the ground force in steer() and the hard clamp in + // the integrators — when those two disagreed, the force pushed up from + // one height while the clamp held at another and birds sat vibrating on + // the seam. + // + // A committing bird gets a LOWER floor: its perch itself, with 10 cm of + // slack for the flare. Nothing may stop the descent above the perch; + // below it, everything does. + [[nodiscard]] float floorFor(const Bird& b, float x, float z) const { + + const float g = groundAt(x, z); + if (b.state == BirdState::Approach || b.state == BirdState::Flare) { + return std::min(g, b.spotPos.y) - 0.10f; + } + return g; + } + + // THE FLOOR IS A CONSTRAINT AND A FORCE CANNOT ENFORCE ONE. The ground + // spring in steer() recovers a bird descending at 7 m/s over roughly a + // fifth of a second, and in that fifth of a second it is 0.8 m under the + // mesh with its body visibly through the floor — which is one of the + // exact tells this whole subsystem exists to avoid. + // + // So after integrating, a flying bird is placed back on the floor and + // its DOWNWARD velocity is removed (the horizontal component is kept, so + // it skims rather than stopping dead, and a bird that was climbing is + // untouched). Above the floor this is a no-op, so ordinary flight never + // sees it. + void clampToFloor(const Bird& b, Dyn& q) const { + + if (!hasField_) return; + + const float floorY = floorFor(b, q.pos.x, q.pos.z); + if (q.pos.y >= floorY) return; + + q.pos.y = floorY; + if (q.vel.y < 0.f) q.vel.y = 0.f; + } + + // THE ALTITUDE PREFERENCE NEEDS ITS OWN REFERENCE, AND THIS IS NOT A + // TIDINESS ARGUMENT. groundAt() answers "how low may I go", so with no + // heightfield it answers home.y − 100 precisely in order to make the + // ground force inert. Feeding that same number to the altitude spring + // makes the preferred height home.y − 100 + cruiseAltitude, and the + // whole flock calmly descends eighty metres into the basement and + // loiters there — no NaN, no warning, nothing in the logs, and the + // symptom is "my birds vanished". Without a bake the notional ground is + // one cruiseAltitude below `home`, so the flock loiters at `home`. + [[nodiscard]] float altitudeRef(float x, float z) const { + + if (!hasField_) return params_.home.y - params_.cruiseAltitude; + return perch_.heightAt(x, z); + } + + // ── Per-frame aggregates ───────────────────────────────────────── + void updateAggregates() { + + centroid_.set(0, 0, 0); + meanVel_.set(0, 0, 0); + perchedNow_ = 0; + committed_ = 0; + + for (int i = 0; i < count_; ++i) { + const Dyn& d = prev_[static_cast(i)]; + centroid_.add(d.pos); + meanVel_.add(d.vel); + const BirdState s = birds_[static_cast(i)].state; + if (s == BirdState::Perched) ++perchedNow_; + if (s == BirdState::Perched || s == BirdState::Approach || s == BirdState::Flare) ++committed_; + } + const float inv = 1.f / static_cast(count_); + centroid_.multiplyScalar(inv); + meanVel_.multiplyScalar(inv); + + // HOME DRIFT. Three incommensurate sinusoids on three fixed axes. + // Without it the flock orbits one point for ever and the territory + // reads as a bowl someone drew; with it the whole population + // migrates slowly across the scene the way a real one does. + // + // 0.35 · roamRadius is the amplitude of the SUM, not of each term. + // Three independent 0.35 R sinusoids peak at 1.05 R of drift, which + // stacks on top of the birds' own ~1.0 R spread around the drifted + // centre and puts the outliers past 1.9 R from `home` — outside the + // 1.5 R containment the flock is supposed to honour, and far enough + // to walk a background flock out of the scene it was placed in. + homeDrifted_ = params_.home; + static constexpr std::array rate{1.0f, 0.43f, 0.27f}; + for (int m = 0; m < 3; ++m) { + const std::size_t mi = static_cast(m); + const float phase = math::TWO_PI * params_.homeDriftRate * time_ * rate[mi] + driftPhase_[mi]; + homeDrifted_.addScaledVector(driftAxis_[mi], + (0.35f / 3.f) * params_.roamRadius * std::sin(phase)); + } + + // Decayed ONCE per frame, not once per bird: a per-bird decay makes + // the contagion window N times shorter and therefore makes the + // behaviour depend on bird count, which is the one thing this + // subsystem's parameters are meant not to. + if (launchedRecently_ > 0.f) launchedRecently_ = std::max(0.f, launchedRecently_ - lastDt_); + + if (time_ >= nextLeaderVote_) { + nextLeaderVote_ = time_ + params_.leaderReassign; + reassignLeaders(); + } + + if (claimedBy_.size() != perch_.spots().size()) syncClaims(); + } + + // LEADERSHIP IS A POSITION, NOT A TRAIT. A fixed Leader personality + // gives the flock a permanent visible boss that the eye picks up within + // a minute of watching; re-electing whoever is furthest forward along + // the mean velocity produces the same steering benefit and none of that. + void reassignLeaders() { + + if (leaderCount_ <= 0) return; + + Vector3 dir = fauna::detail::safeNormalized(meanVel_, {0, 0, 1}); + + for (int i = leaderCount_ + lonerCount_; i < count_; ++i) { + birds_[static_cast(i)].role = BirdRole::Follower; + } + for (int i = 0; i < leaderCount_; ++i) { + birds_[static_cast(i)].role = BirdRole::Follower; + } + + for (int k = 0; k < leaderCount_; ++k) { + + int best = -1; + float bestScore = -std::numeric_limits::infinity(); + + for (int i = 0; i < count_; ++i) { + Bird& b = birds_[static_cast(i)]; + if (b.role != BirdRole::Follower) continue;// Loners never lead; already-picked leaders skip + if (b.state == BirdState::Perched) continue; + + Vector3 rel = prev_[static_cast(i)].pos; + rel.sub(centroid_); + const float score = rel.dot(dir); + // Ties break on the LOWEST index, which is why this is a + // strict >: the first bird to reach a score keeps it. + if (score > bestScore) { + bestScore = score; + best = i; + } + } + if (best < 0) break; + birds_[static_cast(best)].role = BirdRole::Leader; + } + } + + // ── Neighbours (§5.3, from prev_) ──────────────────────────────── + void gatherNeighbours(int i) { + + const int k = params_.neighbourCount; + nbrCount_[static_cast(i)] = 0; + if (k <= 0) return; + + const Dyn& di = prev_[static_cast(i)]; + const Bird& bi = birds_[static_cast(i)]; + const float r2 = params_.neighbourRadius * params_.neighbourRadius; + const float blind = -std::cos(params_.rearBlindAngle); + + std::array bestD{}; + int n = 0; + const std::size_t base = static_cast(i) * kMaxNeighbours; + + for (int j = 0; j < count_; ++j) { + + if (j == i) continue; + + Vector3 off = prev_[static_cast(j)].pos; + off.sub(di.pos); + const float d2 = off.lengthSq(); + if (d2 > r2 || d2 < 1e-12f) continue; + + // One dot product, and besides matching what a bird can actually + // see it kills the artificial conga lines that metric boids form + // when a follower locks onto the tail of the bird in front. + const float inv = 1.f / std::sqrt(d2); + if ((off.x * bi.fwd.x + off.y * bi.fwd.y + off.z * bi.fwd.z) * inv < blind) continue; + + // Fixed-size insertion sort, ties broken by ascending bird index + // — which falls out of `<` rather than `<=` on an ascending scan. + int slot = n; + while (slot > 0 && d2 < bestD[static_cast(slot - 1)]) --slot; + if (slot >= k) continue; + + const int last = std::min(n, k - 1); + for (int m = last; m > slot; --m) { + bestD[static_cast(m)] = bestD[static_cast(m - 1)]; + nbrIdx_[base + static_cast(m)] = nbrIdx_[base + static_cast(m - 1)]; + } + bestD[static_cast(slot)] = d2; + nbrIdx_[base + static_cast(slot)] = j; + if (n < k) ++n; + } + + nbrCount_[static_cast(i)] = n; + } + + // ── Steering (§5.3) ────────────────────────────────────────────── + // + // The ten forces are accumulated in the numbered order, each into its + // own named intermediate. Reordering them changes float accumulation and + // breaks the determinism contract for no benefit whatsoever. + [[nodiscard]] Vector3 steer(int i) { + + const Bird& b = birds_[static_cast(i)]; + const Dyn& d = prev_[static_cast(i)]; + + Vector3 a{0, 0, 0}; + + if (b.state == BirdState::Perched) return a; + + const std::size_t base = static_cast(i) * kMaxNeighbours; + const int n = nbrCount_[static_cast(i)]; + + // A COMMITTED BIRD IS NO LONGER FLOCKING, AND THE ALTITUDE SPRING IS + // THE ONE THAT HAS TO GO. A perch on the ground sits a full + // cruiseAltitude below the preferred height, so at the default + // weights the altitude force is 5.4 m/s² UP against the goal force's + // 4.8 m/s² down: every approach is flown, every approach is aborted, + // perchedCount() reads 0 for ever, and nothing anywhere logs a word. + // The remaining flock forces are attenuated rather than cut, so a + // landing bird still avoids its neighbours on the way in. + const bool committing = (b.state == BirdState::Approach || b.state == BirdState::Flare); + const float flockScale = committing ? 0.3f : 1.f; + + // 1 — separation + Vector3 fSep{0, 0, 0}; + for (int m = 0; m < n; ++m) { + const int j = nbrIdx_[base + static_cast(m)]; + Vector3 off = d.pos; + off.sub(prev_[static_cast(j)].pos); + const float dist = off.length(); + if (dist >= params_.separationDistance || dist < 1e-4f) continue; + const float t = 1.f - dist / params_.separationDistance; + fSep.addScaledVector(off, t * t / std::max(dist, 0.05f)); + } + a.addScaledVector(fSep, params_.wSeparation); + + // 2 — alignment + Vector3 fAli{0, 0, 0}; + if (n > 0 && b.role != BirdRole::Loner) { + for (int m = 0; m < n; ++m) { + fAli.add(prev_[static_cast(nbrIdx_[base + static_cast(m)])].vel); + } + fAli.multiplyScalar(1.f / static_cast(n)); + fAli.sub(d.vel); + fAli.multiplyScalar(b.sociability); + } + a.addScaledVector(fAli, params_.wAlignment * flockScale); + + // 3 — cohesion. Leaders skip it entirely (they are the front, and a + // front that is pulled back into the centroid is not a front). + Vector3 fCoh{0, 0, 0}; + if (n > 0 && b.role != BirdRole::Leader) { + Vector3 c{0, 0, 0}; + float wsum = 0.f; + for (int m = 0; m < n; ++m) { + const int j = nbrIdx_[base + static_cast(m)]; + const float w = birds_[static_cast(j)].role == BirdRole::Leader ? 3.f : 1.f; + c.addScaledVector(prev_[static_cast(j)].pos, w); + wsum += w; + } + c.multiplyScalar(1.f / wsum); + c.sub(d.pos); + // THE DEAD ZONE IS NOT OPTIONAL. Without it a bird sitting on + // the centroid gets a force that flips sign every frame and + // jitters at the integration rate, which at 144 Hz is a visible + // shimmer through the whole flock. + const float dist = c.length(); + if (dist > params_.cohesionDeadZone) { + c.multiplyScalar((dist - params_.cohesionDeadZone) / dist); + c.multiplyScalar(b.sociability * (b.role == BirdRole::Loner ? 0.25f : 1.f)); + fCoh = c; + } + } + a.addScaledVector(fCoh, params_.wCohesion * flockScale); + + // 4 — wander + Vector3 fWan = b.wanderDir; + fWan.multiplyScalar(b.role == BirdRole::Leader ? 2.5f : 1.f); + a.addScaledVector(fWan, params_.wWander * flockScale); + + // 5 — altitude, a spring/damper rather than a target: a hard + // altitude hold makes every bird sit on one plane, which is what a + // flock never does. + Vector3 fAlt{0, 0, 0}; + { + const float yPref = altitudeRef(d.pos.x, d.pos.z) + params_.cruiseAltitude * b.yPrefFrac; + const float omega = 0.8f; + fAlt.y = omega * omega * (yPref - d.pos.y) - 2.f * 0.9f * omega * d.vel.y; + } + a.addScaledVector(fAlt, committing ? 0.f : params_.wAltitude); + + // 6 — bounds. QUADRATIC, so there is no wall to bounce off — only a + // region that gets progressively less comfortable. + // + // Plus a RADIAL DAMPER, which is not decoration. A pure position + // spring overshoots by v²/2a whatever its gain: at 9 m/s against the + // 9 m/s² the anisotropic clamp allows, that is nine metres past + // wherever the force finally saturates, and the flock's true extent + // ends up a tuning accident rather than a property. Damping the + // OUTWARD RATE — and only the outward one, so a bird flying home is + // never slowed — bounds the excursion directly. It is still a region + // that gets progressively less comfortable; the discomfort now + // includes not being able to keep going. + Vector3 fBnd{0, 0, 0}; + { + Vector3 toHome = homeDrifted_; + toHome.sub(d.pos); + toHome.y = 0.f;// altitude is force 5's job + const float dist = toHome.length(); + const float soft = 0.75f * params_.roamRadius; + if (dist > soft) { + const float t = (dist - soft) / (0.25f * params_.roamRadius); + const float inv = 1.f / std::max(dist, 1e-3f); + fBnd = toHome; + fBnd.multiplyScalar(t * t * inv); + + const float outward = -(toHome.x * d.vel.x + toHome.z * d.vel.z) * inv; + if (outward > 0.f) { + fBnd.addScaledVector(toHome, 1.4f * outward * std::min(t, 3.f) * inv); + } + } + } + a.addScaledVector(fBnd, params_.wBounds * flockScale); + + // 7 — obstacle + Vector3 fObs{0, 0, 0}; + if (hasField_) { + Vector3 look = d.pos; + look.addScaledVector(d.vel, params_.lookaheadTime); + const float c = perch_.clearanceAt(look); + if (c < params_.obstacleMargin) { + Vector3 grad; + perch_.clearanceGradient(look, grad); + const float t = 1.f - c / params_.obstacleMargin; + fObs = grad; + fObs.multiplyScalar(t * t); + } + } + a.addScaledVector(fObs, params_.wObstacle); + + // 8 — ground. A COMMITTED BIRD STILL GETS A FLOOR, just a lower one. + // Suppressing this outright during a landing is what lets a bird + // that misses its perch keep descending: it is no longer flocking, + // nothing else looks down, and it ends up ten metres under the + // terrain still politely steering toward a branch above it. + Vector3 fGrd{0, 0, 0}; + float belowFloor = 0.f; + if (hasField_) { + const float floorY = floorFor(b, d.pos.x, d.pos.z); + const float clearance = committing ? 0.f : params_.minGroundClearance; + const float h = d.pos.y - floorY; + if (h < clearance || h < 0.f) { + const float span = std::max(clearance, 0.5f); + const float t = std::clamp(1.f - h / span, 0.f, 1.f); + fGrd.y = 4.f * t; + } + belowFloor = std::max(0.f, -h); + } + a.addScaledVector(fGrd, params_.wGround); + + // 9 — goal (Approach/Flare only) + Vector3 fGoal{0, 0, 0}; + if (committing) { + Vector3 target = goalTarget(b); + target.sub(d.pos); + const float dist = target.length(); + if (dist > 1e-3f) { + fGoal = target; + // Saturating at 8 m of error rather than 4: the capture, not + // the cruise leg, is what sets this number. At 4 m the goal + // force tops out at 4.8 m/s², which cannot bend a 6 m/s + // approach onto a point, and nine out of ten approaches + // become fly-bys. + fGoal.multiplyScalar(std::min(dist, 8.f) / dist); + } + } + a.addScaledVector(fGoal, params_.wGoal); + + // 10 — observer. Birds are shy of the camera, very slightly. Any + // more and the flock visibly parts around the viewer, which reads as + // the scene knowing where you are. + Vector3 fObserver{0, 0, 0}; + if (observer_) { + Vector3 eye; + eye.setFromMatrixPosition(*observer_->matrixWorld); + Vector3 away = d.pos; + away.sub(eye); + const float dist = away.length(); + if (dist < 8.f && dist > 1e-3f) { + fObserver = away; + fObserver.multiplyScalar((1.f - dist / 8.f) / dist); + } + } + a.addScaledVector(fObserver, params_.wObserver); + + // Evade overrides the lot with a hard turn away from the epicentre. + if (b.state == BirdState::Evade) { + + a.multiplyScalar(0.25f); + + // …EXCEPT THE TERRITORY, WHICH IS RESTORED TO FULL STRENGTH. A + // startled bird runs at 1.5× cruise for up to a second; with the + // bounds force quartered along with everything else that is + // twenty metres of unopposed flight outward, and a flock + // startled repeatedly walks itself out of the scene one flush at + // a time. A real bird flushes WITHIN its territory — it turns + // away from the threat, it does not emigrate. + a.addScaledVector(fBnd, params_.wBounds * flockScale * 0.75f); + + a.addScaledVector(b.evadeAway, params_.maxAccelLateral * (0.8f + 0.4f * b.startleStrength)); + a.y += 2.5f; + } + + // ANISOTROPIC CLAMP. Birds turn hard and accelerate slowly, and the + // asymmetry is what removes the boids sprint-stop yo-yo: an + // isotropic clamp lets a bird go 4.5 → 17 m/s in half a second, + // which reads as surging. + const float along = std::clamp(a.dot(b.fwd), -params_.maxAccelAlong, params_.maxAccelAlong); + Vector3 cross = a; + cross.addScaledVector(b.fwd, -a.dot(b.fwd)); + cross.clampLength(0.f, params_.maxAccelLateral); + a = cross; + a.addScaledVector(b.fwd, along); + + // THE FLOOR IS A CONSTRAINT, NOT A PREFERENCE. Below the terrain the + // ground force is re-added outside the anisotropic clamp, because + // inside it a bird already descending at 7 m/s recovers over 2.7 m + // and spends that whole time under the mesh. Above the floor this + // term is exactly zero, so the ordinary steering is untouched. + if (belowFloor > 0.f) { + a.y += params_.wGround * 4.f * std::min(1.f + belowFloor, 6.f); + } + + // A SOFT LAW, NEVER A CLAMP. Speed breathes ±8 % instead of pinning + // every bird at one obviously-authored rate. + const float speed = d.vel.length(); + float want = b.targetSpeed; + if (b.state == BirdState::Evade) want *= 1.5f; + + if (committing) { + // A CLOSING LAW, not a fraction of cruise speed. At 6.75 m/s and + // the goal force's 4.8 m/s² the turn radius is 9.5 m, so a bird + // aimed at a perch simply orbits it at nine metres and never gets + // inside flareDistance — it flies a perfect approach for ever. + // Scaling the demand with range shrinks the radius as it closes + // (1.9 m at 3 m/s), which is the difference between a landing and + // a holding pattern. + const float dist = d.pos.distanceTo(goalTarget(b)); + want = std::clamp(0.9f * dist, 1.2f, b.targetSpeed); + } else { + // minSpeed is a floor on the DEMAND, not a clamp on the state: a + // bird genuinely slowed below it keeps its real speed and the + // drag pulls it back up, which is a stall recovery rather than a + // teleport to the minimum. It does NOT apply to a landing, where + // going slow is the entire objective. + want = std::max(want, params_.minSpeed); + } + a.addScaledVector(b.fwd, params_.speedDrag * (want - speed)); + + return a; + } + + // Where the BODY sits when the feet are on `spot`: a leg-length above + // it along the spot normal. Every landing target in the system is + // expressed this way, so the bird arrives standing rather than arriving + // with its belly in the branch. + [[nodiscard]] Vector3 standPoint(const Bird& b) const { + + Vector3 p = b.spotPos; + p.addScaledVector(b.spotNormal, standHeight(b)); + return p; + } + + [[nodiscard]] float standHeight(const Bird& b) const { + + return (0.72f * params_.shape.bodyRadius + tmpl_.legLength) * b.size; + } + + // THE GATE IS A WAYPOINT, AND SOMETHING HAS TO HAND OFF FROM IT. Steering + // at the gate for the whole approach means the bird converges on a point + // 2.2–3 m short of the perch and settles into an orbit around it: the + // Approach → Flare test measures range to the SPOT, which never falls + // below flareDistance, so the state machine runs a flawless approach + // that can never complete. `gatePassed` latches so the target cannot + // flicker back and forth across the handoff radius. + [[nodiscard]] Vector3 goalTarget(const Bird& b) const { + + if (b.state == BirdState::Flare || b.gatePassed) return standPoint(b); + return b.gate; + } + + // ── Decisions (§5.1, §5.2) ─────────────────────────────────────── + void decide(int i, float dt) { + + Bird& b = birds_[static_cast(i)]; + const Dyn& d = prev_[static_cast(i)]; + + b.stateTime += dt; + if (b.perchSuppress > 0.f) b.perchSuppress = std::max(0.f, b.perchSuppress - dt); + + // The startle wave is the ONLY thing that bypasses the decision + // cadence, and even it carries the per-bird latency drawn in + // startle(). + if (b.reactAt >= 0.f && time_ >= b.reactAt) { + b.reactAt = -1.f; + triggerEvade(i, b, d, b.startleFrom); + return; + } + + // Disturbance source: the same test every field guide states as a + // flight-initiation distance, scaled by the bird's own boldness so a + // walker flushes the bold ones last. + if (disturbance_) { + Vector3 src; + src.setFromMatrixPosition(*disturbance_->matrixWorld); + const float fid = params_.flightInitiationDistance * b.boldness; + if (d.pos.distanceToSquared(src) < fid * fid) { + triggerEvade(i, b, d, src); + return; + } + } + + if (time_ < b.nextDecision) return; + + // += rather than = time_ + period: the phase a bird was given at + // construction is the whole point, and resetting from `now` would + // let two birds converge onto the same tick and stay there. + b.nextDecision += b.decisionPeriod * (isFar(i) ? 2.f : 1.f); + if (b.nextDecision <= time_) b.nextDecision = time_ + b.decisionPeriod; + + switch (b.state) { + case BirdState::Cruise: decideCruise(i, b, d); break; + case BirdState::Approach: decideApproach(i, b, d); break; + case BirdState::Perched: decidePerched(i, b, d); break; + default: break; + } + } + + void decideCruise(int i, Bird& b, const Dyn& d) { + + // Wander turn: a per-bird unit vector rotating about a per-bird + // axis. Drawn on the decision tick so it is a pure function of + // (seed, bird, sequence), never of frame count. + const float turn = (roll(b, i) * 2.f - 1.f) * 0.35f * b.decisionPeriod; + b.wanderDir.applyAxisAngle(b.wanderAxis, turn); + b.wanderDir = fauna::detail::safeNormalized(b.wanderDir, b.fwd); + + if (!params_.perching || b.perchSuppress > 0.f) return; + if (b.perchUrge <= 1.f) return; + if (perch_.spots().empty()) return; + + // The sky is never allowed to empty. maxPerchedFraction counts every + // bird already committed to a landing, not just the ones on the + // ground — otherwise N birds all commit in the same second and the + // fraction only bites once they have all arrived. + const float frac = static_cast(committed_) / static_cast(count_); + if (frac >= params_.maxPerchedFraction) return; + + if (!claimSpot(i, b, d)) { + b.perchUrge = 0.6f; + return; + } + b.state = BirdState::Approach; + b.abortRolled = false; + b.gatePassed = false; + b.stateTime = 0.f; + } + + void decideApproach(int i, Bird& b, const Dyn& d) { + + if (b.claim < 0) { + abortApproach(b); + return; + } + + // REAL BIRDS GO AROUND CONSTANTLY. An approach that always succeeds + // is one of the loudest tells in the whole system, and this is one + // line to buy the opposite. + // + // ROLLED ONCE PER APPROACH, ON THE FIRST TICK AFTER COMMITTING, NOT + // ONCE PER TICK. An approach spans six to eight decision ticks, so a + // per-tick roll turns abortChance = 0.12 into a SIXTY-FOUR PER CENT + // abort rate and the flock spends the session circling a perch it + // never reaches. The parameter has to mean what its name says. + if (!b.abortRolled) { + b.abortRolled = true; + if (roll(b, i) < params_.abortChance) { + abortApproach(b); + return; + } + } + + // Bearing is judged against the CURRENT goal — the gate first, the + // perch after the handoff. Judging it against the perch while the + // bird is still flying the gate leg aborts the very approaches whose + // whole point is to arrive from below and short. + Vector3 to = goalTarget(b); + to.sub(d.pos); + const float dist = to.length(); + if (b.gatePassed && dist > 1e-3f) { + to.multiplyScalar(1.f / dist); + if (to.dot(b.fwd) < 0.f && dist > params_.flareDistance * 2.f) { + abortApproach(b); + return; + } + } + + // A hard ceiling on the attempt. Everything above is a soft test and + // a soft test can, on some geometry, never fire — and an approach + // that never resolves is a bird permanently removed from the flock + // with no state anyone would think to look at. + if (b.stateTime > 12.f) abortApproach(b); + } + + void decidePerched(int i, Bird& b, const Dyn& d) { + + (void) d; + + // Launch contagion — a neighbour left, so this bird thinks about + // leaving. Positive feedback, damped by restIntervalMin and + // maxPerchedFraction; §7.3's soak test is what catches a limit cycle + // in it. + if (launchedRecently_ > 0.f && roll(b, i) < params_.launchContagion) { + const std::size_t base = static_cast(i) * kMaxNeighbours; + const int n = nbrCount_[static_cast(i)]; + for (int m = 0; m < n; ++m) { + const int j = nbrIdx_[base + static_cast(m)]; + const Bird& o = birds_[static_cast(j)]; + if (o.state != BirdState::Launch) continue; + if (prev_[static_cast(i)].pos.distanceToSquared(prev_[static_cast(j)].pos) < + params_.contagionRadius * params_.contagionRadius) { + beginLaunch(b); + return; + } + } + } + + if (b.restUrge > 1.f) { + beginLaunch(b); + return; + } + + if (!b.walker) return; + + // Ground micro-goals with vigilance pauses between them. Ground- + // feeding birds spend more time with the head up than moving, and + // getting that ratio wrong is what makes a walking bird look like a + // wind-up toy. + if (time_ < b.pauseUntil) return; + if (b.anchor.distanceToSquared(b.groundGoal) > 0.04f) return; + + const float ang = roll(b, i) * math::TWO_PI; + const float rad = 0.3f + 1.7f * roll(b, i); + const float pause = 0.5f + 2.5f * roll(b, i); + + Vector3 g = b.spotPos; + g.x += std::cos(ang) * rad; + g.z += std::sin(ang) * rad; + // Never wander further than 0.8 m from the spot the bake vouched + // for: outside it there is no evidence the surface even exists. + Vector3 off = g; + off.sub(b.spotPos); + off.y = 0.f; + const float dist = off.length(); + if (dist > 0.8f) off.multiplyScalar(0.8f / dist); + b.groundGoal = b.spotPos; + b.groundGoal.add(off); + b.pauseUntil = time_ + pause; + } + + void triggerEvade(int i, Bird& b, const Dyn& d, const Vector3& from) { + + releaseClaim(b); + + Vector3 away = d.pos; + away.sub(from); + away.y = 0.f; + b.evadeAway = fauna::detail::safeNormalized(away, b.fwd); + + if (b.state == BirdState::Perched) { + // A perched bird cannot simply turn: it has to get off the + // branch first, so the startle routes through the same launch + // the bird would have used voluntarily — with the crouch cut + // short, which is what an alarmed take-off actually looks like. + beginLaunch(b); + b.stateTime = 0.10f; + } else { + b.state = BirdState::Evade; + b.stateTime = 0.f; + b.feetPlanted = false; + } + + b.evadeUntil = time_ + 0.4f + 0.6f * roll(b, i); + b.perchSuppress = std::max(b.perchSuppress, + rollRange(b, i, params_.postFlushCalmMin, params_.postFlushCalmMax)); + b.perchUrge = 0.f; + } + + void beginLaunch(Bird& b) { + + releaseClaim(b); + b.state = BirdState::Launch; + b.stateTime = 0.f; + b.restUrge = 0.f; + b.perchUrge = 0.f; + b.perchSuppress = std::max(b.perchSuppress, 1.5f); + launchedRecently_ = 0.6f; + } + + void abortApproach(Bird& b) { + + releaseClaim(b); + b.state = BirdState::Cruise; + b.stateTime = 0.f; + b.perchUrge = 0.6f; + } + + // ── Perch claims and selection (§5.1) ──────────────────────────── + void syncClaims() { + + claimedBy_.assign(perch_.spots().size(), -1); + for (auto& b : birds_) { + if (b.claim < 0) continue; + b.claim = -1; + if (b.state == BirdState::Approach || b.state == BirdState::Flare) { + b.state = BirdState::Cruise; + b.stateTime = 0.f; + } else if (b.state == BirdState::Perched) { + // A bird already standing on a spot that no longer exists + // keeps standing there until its rest urge fires. That is + // the documented worst case of a stale bake: a bird in + // mid-air, not a dereference of freed geometry. + b.restUrge = std::max(b.restUrge, 0.8f); + } + } + } + + void releaseAllClaims() { + + for (auto& b : birds_) releaseClaim(b); + } + + void releaseClaim(Bird& b) { + + if (b.claim >= 0 && static_cast(b.claim) < claimedBy_.size()) { + claimedBy_[static_cast(b.claim)] = -1; + } + b.claim = -1; + } + + bool claimSpot(int i, Bird& b, const Dyn& d) { + + spotScratch_.clear(); + perch_.querySpots(d.pos, params_.perchSearchRadius, spotScratch_); + if (spotScratch_.empty()) return false; + + const auto& spots = perch_.spots(); + const bool wantGround = roll(b, i) < params_.groundBias; + + int best = -1; + float bestScore = std::numeric_limits::infinity(); + + // The ≤ 8 nearest CLAIMABLE spots, scored. Bearing is gated at 75° + // of the heading: a bird does not turn round to land, it picks + // somewhere it was already going. + int considered = 0; + for (const int s : spotScratch_) { + + if (considered >= 8) break; + const std::size_t si = static_cast(s); + if (si >= claimedBy_.size() || claimedBy_[si] >= 0) continue; + + // A BIRD DOES NOT LEAVE ITS TERRITORY TO LAND. perchSearchRadius + // is measured from the BIRD, so without this a bird already out + // near the soft boundary can commit to a spot another 32 m + // beyond it — and while it is committed the bounds force is + // attenuated, so nothing pulls it home until it has arrived. + // Repeat that and an ambient flock walks out of the scene it was + // placed in, one landing at a time. + Vector3 fromHome = spots[si].position; + fromHome.sub(homeDrifted_); + fromHome.y = 0.f; + if (fromHome.lengthSq() > params_.roamRadius * params_.roamRadius) continue; + + Vector3 to = spots[si].position; + to.sub(d.pos); + const float dist = to.length(); + if (dist < 1e-3f) continue; + if (to.dot(b.fwd) / dist < 0.2588f) continue;// cos 75° + ++considered; + + const float occ = occupancy(s); + const float lonerBias = (b.role == BirdRole::Loner) ? 3.f : 1.f; + const float jitter = 1.f + 0.4f * roll(b, i); + float score = dist * dist * (1.f + 3.f * occ * lonerBias) * jitter; + if (wantGround && !spots[si].ground) score *= 2.5f; + // Contagion, the other way round: a spot near birds already down + // is MORE attractive, up to perchContagion. + score *= 1.f - params_.perchContagion * std::min(occ, 1.f) * 0.5f; + + if (score < bestScore) { + bestScore = score; + best = s; + } + } + + if (best < 0) return false; + + const std::size_t bi = static_cast(best); + claimedBy_[bi] = i; + b.claim = best; + b.spotNormal = spots[bi].normal; + b.spotWalkable = spots[bi].walkable; + + // ±0.06 m so birds are not standing on exact bake centres. An + // unjittered flock lands on a lattice, and a lattice is the one + // thing no observer ever mistakes for wildlife. + const float jx = (roll(b, i) * 2.f - 1.f) * 0.06f; + const float jz = (roll(b, i) * 2.f - 1.f) * 0.06f; + b.spotPos = spots[bi].position; + b.spotPos.x += jx; + b.spotPos.z += jz; + + b.walker = spots[bi].walkable && roll(b, i) < params_.groundBias; + b.groundGoal = b.spotPos; + b.gate = approachGate(b, d); + return true; + } + + [[nodiscard]] float occupancy(int spot) const { + + const auto& spots = perch_.spots(); + const std::size_t si = static_cast(spot); + if (si >= spots.size()) return 0.f; + + int taken = 0; + int near = 0; + const float r2 = params_.contagionRadius * params_.contagionRadius; + for (std::size_t k = 0; k < spots.size(); ++k) { + if (spots[k].position.distanceToSquared(spots[si].position) > r2) continue; + ++near; + if (k < claimedBy_.size() && claimedBy_[k] >= 0) ++taken; + } + return near > 0 ? static_cast(taken) / static_cast(near) : 0.f; + } + + // THE GATE IS BELOW AND SHORT OF THE SPOT, NOT AT IT. Steering straight + // at a perch produces a dive that has to be cancelled in the last metre; + // aiming 0.45 m below and 2.2 m short converts the final approach into a + // swoop UP, which is both what birds do and what makes the flare read as + // deceleration rather than as a brake. + [[nodiscard]] Vector3 approachGate(const Bird& b, const Dyn& d) const { + + Vector3 dir = b.spotPos; + dir.sub(d.pos); + dir.y = 0.f; + dir = fauna::detail::safeNormalized(dir, b.fwd); + + Vector3 gate = standPoint(b); + + // A SPOT'S OWN COLUMN CANNOT ANSWER "IS THIS ELEVATED", AND ASKING + // IT MADE THE SWOOP ABOVE DEAD CODE FOR EVERY PERCH IN THE SCENE. + // + // heightAt() reports the HIGHEST sampled surface in a column, and a + // perch is by definition ON the highest surface of its own column. + // So `spotPos.y - groundAt(spotPos)` is ~0 for a branch forty metres + // up exactly as it is for a kerbstone, this test was never once + // true, and every landing in the flock — rooftop, fencepost, bough — + // flew the shallow ground glide. The symptom is subtle and damning: + // birds descend onto branches like helicopters instead of arriving + // from below, which is the single tell the gate exists to remove. + // + // Sample the ground the bird is ARRIVING OVER instead. Three metres + // back along the approach, a post top stands four metres proud and a + // ground spot is level with itself. + const float backX = b.spotPos.x - dir.x * 3.0f; + const float backZ = b.spotPos.z - dir.z * 3.0f; + const bool elevated = hasField_ && (b.spotPos.y - groundAt(backX, backZ)) > 1.0f; + + if (elevated) { + gate.addScaledVector(b.spotNormal, -0.45f); + gate.addScaledVector(dir, -2.2f); + } else { + // Ground spot: 3 m out on a 6° descent. + gate.addScaledVector(dir, -3.0f); + gate.y += 3.0f * 0.105f; + } + return gate; + } + + // ── Integration (§5.5 step 7) ──────────────────────────────────── + void integrate(int i, float dt) { + + Bird& b = birds_[static_cast(i)]; + const Dyn& p = prev_[static_cast(i)]; + Dyn& q = next_[static_cast(i)]; + q = p; + + switch (b.state) { + case BirdState::Perched: integratePerched(i, b, q, dt); break; + case BirdState::Launch: integrateLaunch(i, b, p, q, dt); break; + case BirdState::Flare: integrateFlare(i, b, p, q, dt); break; + default: integrateFlight(i, b, p, q, dt); break; + } + + advanceBeat(i, b, q, dt); + advanceHead(i, b, dt); + advanceSprings(i, b, dt); + advanceState(i, b, q, dt); + + // ONE NaN VERTEX BLOWS THE BOUNDING SPHERE FOR THE REST OF THE + // SESSION, and on Vulkan it can wreck a BLAS refit. Respawning is + // ugly; a flock that silently disappears twenty minutes into a demo + // is worse, and much harder to report. + if (!fauna::detail::isFinite(q.pos) || !fauna::detail::isFinite(q.vel)) { + q.pos = homeDrifted_; + q.vel.set(0, 0, 0); + b.state = BirdState::Cruise; + b.stateTime = 0.f; + b.feetPlanted = false; + b.claim = -1; + b.cyclePos = rnd01(params_.seed, static_cast(i), 0xF10Cu); + } + } + + void integrateFlight(int i, Bird& b, const Dyn& p, Dyn& q, float dt) { + + Vector3 v = p.vel; + v.addScaledVector(accel_[static_cast(i)], dt); + + // Flap-bounding: lift withdrawn, gravity unopposed. Applied AFTER + // the anisotropic clamp on purpose — it is not a steering choice, + // it is what happens when the wings stop. + if (b.bounding) v.y -= kGravity * dt * 0.55f; + + float speed = v.length(); + Vector3 dir = (speed > 1e-4f) ? Vector3{v.x / speed, v.y / speed, v.z / speed} : b.fwd; + + // HEADING SLEW CAP. Without it a large lateral acceleration can flip + // the heading through 180° in one step and the bird visibly + // teleports its own orientation. + const float turnScale = (b.state == BirdState::Evade) ? 1.8f : 1.f; + slewHeading(b, dir, params_.maxTurnRate * turnScale * dt); + + speed = std::clamp(speed, 0.f, params_.maxSpeed); + // A stall is a real event, not an error: below minSpeed the bird + // drops its nose, which the pitch term picks up for free. + v = b.fwd; + v.multiplyScalar(speed); + + q.vel = v; + q.pos = p.pos; + q.pos.addScaledVector(v, dt); + clampToFloor(b, q); + + b.feetPlanted = false; + b.legExtend = approachLegExtend(b, q); + updateBankPitch(i, b, dt); + } + + void integrateFlare(int i, Bird& b, const Dyn& p, Dyn& q, float dt) { + + // THE ONE PLACE A SPEED IS COMMANDED RATHER THAN STEERED. A soft + // drag cannot guarantee an arrival speed, and a landing that + // overshoots the branch by half a metre reads as broken software + // rather than as a bad landing. kFlare is solved implicitly here: + // the law itself carries v(flareDistance) → 0.4 m/s. + Vector3 target = standPoint(b); + Vector3 to = target; + to.sub(p.pos); + const float dist = to.length(); + + const float want = 0.4f + (params_.cruiseSpeed * 0.55f - 0.4f) * + std::clamp(dist / params_.flareDistance, 0.f, 1.f); + + Vector3 dir = fauna::detail::safeNormalized(to, b.fwd); + slewHeading(b, dir, params_.maxTurnRate * 2.f * dt); + + float speed = p.vel.length(); + speed = math::damp(speed, want, 6.f, dt); + + Vector3 v = dir; + v.multiplyScalar(speed); + q.vel = v; + q.pos = p.pos; + q.pos.addScaledVector(v, dt); + clampToFloor(b, q); + + b.feetPlanted = false; + b.legExtend = 1.f; + // Pitch bias +0.85: nose up, wings cupped, tail fanned into the + // airflow. This is the pose everyone recognises and nobody animates. + updateBankPitch(i, b, dt, 0.85f); + } + + void integrateLaunch(int i, Bird& b, const Dyn& p, Dyn& q, float dt) { + + const float t = b.stateTime; + + if (t < 0.15f) { + // CROUCH. Ease-in t², legs flexing to 0.55 of full extension, + // body dropping a body-depth. A take-off that starts with the + // wings is a helicopter; a take-off that starts with the legs is + // a bird. + const float e = (t / 0.15f) * (t / 0.15f); + b.legExtend = math::lerp(1.f, 0.55f, e); + b.bodyLift = -0.9f * params_.shape.bodyRadius * e; + b.feetPlanted = true; + q.pos = b.anchor; + q.pos.addScaledVector(b.spotNormal, standHeight(b) * math::lerp(1.f, 0.62f, e)); + q.vel.set(0, 0, 0); + plantStance(b); + return; + } + + if (!b.leaped) { + b.leaped = true; + Vector3 v = b.spotNormal; + v.multiplyScalar(2.6f); + v.addScaledVector(b.fwd, 1.5f); + q.vel = v; + // THE ONLY LEGITIMATE cyclePos RESET IN THE ENTIRE SYSTEM. Top + // of stroke, so the first thing that happens is a downstroke. + // Launches are staggered per bird, so it never re-synchronises + // the flock — and resetting phase anywhere else is forbidden. + b.cyclePos = 0.f; + b.feetPlanted = false; + } + + Vector3 v = q.vel; + v.addScaledVector(accel_[static_cast(i)], dt); + v.y -= kGravity * dt * 0.35f;// the climb-out is not free + + const float speed = std::clamp(v.length(), 0.f, params_.maxSpeed); + Vector3 dir = fauna::detail::safeNormalized(v, b.fwd); + // Climb out at ~35°, which is what a startled bird does with the + // whole of its energy budget. + dir.y = std::max(dir.y, 0.45f); + dir = fauna::detail::safeNormalized(dir, b.fwd); + slewHeading(b, dir, params_.maxTurnRate * 1.5f * dt); + + v = b.fwd; + v.multiplyScalar(speed); + q.vel = v; + q.pos = p.pos; + q.pos.addScaledVector(v, dt); + + b.legExtend = std::max(0.f, 1.f - (t - 0.15f) / 0.35f); + b.feetPlanted = false; + updateBankPitch(i, b, dt, 0.3f); + } + + void integratePerched(int i, Bird& b, Dyn& q, float dt) { + + // Velocity is NOT zeroed on contact. The residual bleeds into a + // short forward stagger, which is the difference between a bird + // landing and a bird being placed. + b.stagger.addScaledVector(q.vel, dt); + b.stagger.multiplyScalar(std::exp(-dt / 0.09f)); + b.stagger.clampLength(0.f, 0.09f); + q.vel.multiplyScalar(std::exp(-dt / 0.09f)); + + if (b.walker) groundGait(b, dt); + else plantStance(b); + + q.pos = b.anchor; + q.pos.addScaledVector(b.spotNormal, standHeight(b)); + q.pos.add(b.stagger); + + b.feetPlanted = true; + b.legExtend = 1.f; + + // Perched birds face into the wind, with ±35° of scatter. Free + // realism, and it explains the residual alignment: a row of birds + // roughly facing one way reads as birds in wind, whereas exactly one + // way reads as copy-paste and pure random reads as noise. + Vector3 into{-params_.wind.x, 0.f, -params_.wind.y}; + if (into.lengthSq() < 1e-8f) into.set(0, 0, 1); + into.normalize(); + const float scatter = (rnd01(params_.seed, static_cast(i), 0x5EEDu) * 2.f - 1.f) * 0.61f; + into.applyAxisAngle(Vector3{0, 1, 0}, scatter); + slewHeading(b, into, 2.0f * dt); + + b.bank = math::damp(b.bank, 0.f, 6.f, dt); + // Tracked hard rather than damped gently: the hop's nose-down / + // nose-up swing is a kinematic fact of the cycle, and a slow damp + // would smear it into a gentle nod. 40 ms against a 380 ms cycle. + b.pitch = math::damp(b.pitch, 0.06f + b.gaitPitch, 25.f, dt); + } + + // FEET HAVE WORLD PLANT POSITIONS AND DO NOT SLIDE. A step arcs the + // swing foot to its new plant over kStepTime on a parabola; between + // steps the foot is locked, and the arc ENDS EXACTLY ON THE PLANT, which + // is what lets BirdPose::feetPlanted stay true the whole time. Writing + // the swing foot here rather than at pose time is deliberate: a foot + // interpolated in two places snaps back the frame the two disagree. + void groundGait(Bird& b, float dt) { + + Vector3 to = b.groundGoal; + to.sub(b.anchor); + to.y = 0.f; + const float dist = to.length(); + + const bool moving = dist > 0.05f && time_ >= b.pauseUntil; + if (!moving) { + // Between micro-goals: head up, faster saccades. That is + // vigilance behaviour, and it is what ground-feeding birds + // actually spend most of their time doing. + b.headLead = math::damp(b.headLead, 0.f, 12.f, dt); + b.stepStart = -1.f; + b.gaitLift = 0.f; + b.gaitPitch = 0.f; + plantStance(b); + return; + } + + to.multiplyScalar(1.f / dist); + + if (params_.gait == Gait::Hop) { + hopCycle(b, to, dist); + return; + } + b.gaitPitch = 0.f; + b.gaitLift = 0.f; + + const float period = 1.f / kStepRate; + const float advance = std::min(kGroundSpeed * dt, dist); + b.anchor.addScaledVector(to, advance); + + if (b.stepStart < 0.f || time_ - b.stepStart >= period) { + // Commit the finished step BEFORE choosing the next one, or the + // foot that just landed is still remembered at its old plant. + if (b.stepStart >= 0.f) b.footWorld[static_cast(b.swingFoot)] = b.stepTo; + b.stepStart = time_ - std::fmod(std::max(0.f, time_ - b.stepStart - period), period); + b.swingFoot = 1 - b.swingFoot; + b.stepFrom = b.footWorld[static_cast(b.swingFoot)]; + b.stepTo = stanceFoot(b, b.swingFoot); + b.stepTo.addScaledVector(to, kGroundSpeed * period * 0.6f); + } + + const float tStep = std::max(0.f, time_ - b.stepStart); + const float e = std::clamp(tStep / std::min(kStepTime, period), 0.f, 1.f); + Vector3 f = b.stepFrom; + f.lerp(b.stepTo, e); + // Parabola of height 0.25 × step length. A foot that slides flat + // between plants reads as a puppet on a rail. + f.addScaledVector(b.spotNormal, b.stepFrom.distanceTo(b.stepTo) * e * (1.f - e)); + b.footWorld[static_cast(b.swingFoot)] = f; + b.footWorld[static_cast(1 - b.swingFoot)] = stanceFoot(b, 1 - b.swingFoot); + + // THE HEAD BOB IS DERIVED FROM GAZE STABILISATION, NOT GUESSED AS A + // SINUSOID. For the first 65 % of the step the head holds a fixed + // WORLD position, so its body-space offset is exactly the distance + // the body has travelled; over the remaining 35 % it eases back. At + // 0.38 m/s and 3 Hz that is an 0.082 m excursion, which is the + // measured pigeon figure — and the correct derivation is also the + // cheaper code. + const float hold = 0.65f * period; + if (tStep < hold) { + b.headLead = -kGroundSpeed * tStep; + } else { + const float ease = math::smoothstep(hold, period, tStep); + b.headLead = math::lerp(-kGroundSpeed * hold, 0.f, ease); + } + } + + // BOTH FEET TOGETHER, and a real ballistic phase. Finches and sparrows + // hop; starlings, crows and pigeons walk. Getting the pair the wrong way + // round is the kind of error nobody can name and everybody notices. + // + // The feet are carried through the ballistic arc WITH the body rather + // than left on the ground: at 0.25 × step height the leg would otherwise + // stretch to twice its length for a fifth of a second, which reads as + // the bird being lifted by a wire instead of pushing off. + void hopCycle(Bird& b, const Vector3& dir, float dist) { + + static constexpr float kCrouchEnd = 0.10f / kHopCycle;// 0.10 s crouch + static constexpr float kFlightEnd = 0.28f / kHopCycle;// 0.18 s ballistic + + const float hopLen = std::min(0.22f, dist); + + if (b.stepStart < 0.f) { + b.stepStart = time_ - b.gaitPhase * kHopCycle; + b.stepFrom = b.anchor; + } + + float tau = (time_ - b.stepStart) / kHopCycle; + if (tau >= 1.f) { + b.stepFrom.addScaledVector(dir, hopLen); + b.stepStart = time_; + b.liftX = -0.10f * tmpl_.legLength;// the landing compression + b.liftV = 0.f; + tau = 0.f; + } + + b.anchor = b.stepFrom; + float arc = 0.f; + + if (tau < kCrouchEnd) { + const float e = (tau / kCrouchEnd) * (tau / kCrouchEnd); + b.gaitLift = -0.55f * params_.shape.bodyRadius * b.size * e; + } else if (tau < kFlightEnd) { + const float e = (tau - kCrouchEnd) / (kFlightEnd - kCrouchEnd); + b.anchor.addScaledVector(dir, hopLen * e); + arc = 0.25f * hopLen * 4.f * e * (1.f - e); + b.gaitLift = arc; + } else { + b.anchor.addScaledVector(dir, hopLen); + b.gaitLift = 0.f; + } + + // Nose-down at push-off, up at landing — one cosine, and it is what + // makes a hop read as effort rather than as a translation. + b.gaitPitch = 0.26f * std::cos(math::TWO_PI * tau); + b.headLead = 0.f; + + for (int g = 0; g < 2; ++g) { + Vector3 f = stanceFoot(b, g); + f.addScaledVector(b.spotNormal, arc); + b.footWorld[static_cast(g)] = f; + } + } + + // Feet directly under the hips, in the body's OWN frame, so a perched + // bird's stance turns with it rather than staying pinned to world axes — + // which is what makes a bird that shuffles round to face the wind look + // like it turned rather than like it rotated. + [[nodiscard]] Vector3 stanceFoot(const Bird& b, int g) const { + + Vector3 bx, by, bz; + bodyBasis(b, bx, by, bz); + Vector3 f = b.anchor; + f.addScaledVector(bx, (g == 0 ? 1.f : -1.f) * 0.34f * params_.shape.bodyRadius * b.size); + return f; + } + + void plantStance(Bird& b) { + + for (int g = 0; g < 2; ++g) { + b.footWorld[static_cast(g)] = stanceFoot(b, g); + } + } + + void slewHeading(Bird& b, const Vector3& want, float maxAngle) { + + Vector3 dir = fauna::detail::safeNormalized(want, b.fwd); + const float c = std::clamp(b.fwd.dot(dir), -1.f, 1.f); + const float ang = std::acos(c); + if (!(ang > 1e-5f)) { + b.fwd = dir; + return; + } + if (ang <= maxAngle) { + b.fwd = dir; + return; + } + + Vector3 axis; + axis.crossVectors(b.fwd, dir); + // Exactly antiparallel: the cross product is degenerate and any + // perpendicular axis is as good as another. Picking one keeps a bird + // that has been reversed by a hard evade from freezing its heading. + axis = fauna::detail::safeNormalized(axis, {0, 1, 0}); + b.fwd.applyAxisAngle(axis, maxAngle); + b.fwd = fauna::detail::safeNormalized(b.fwd, dir); + } + + void updateBankPitch(int i, Bird& b, float dt, float pitchBias = 0.f) { + + // BANK COMES FROM THE COMMANDED LATERAL ACCELERATION, never from the + // measured yaw rate. The measured rate lags by a frame and is noisy + // at low speed, which produces a wobble the eye reads as a damaged + // bird; the command is exactly what the bird "intends" and arrives + // one frame early, which is what a real animal's roll does. + const Vector3& a = accel_[static_cast(i)]; + + Vector3 bx, by, bz; + bodyBasisFlat(b, bx, by, bz); + + const float side = a.dot(bx); + const float target = std::clamp(-params_.bankGain * side / kGravity, + -params_.maxBank, params_.maxBank); + b.bank = math::damp(b.bank, target + b.rollTrim, 1.f / params_.bankTau, dt); + + const Dyn& d = next_[static_cast(i)]; + const float speed = d.vel.length(); + const float climb = speed > 1e-3f ? std::asin(std::clamp(d.vel.y / speed, -1.f, 1.f)) : 0.f; + b.pitch = math::damp(b.pitch, climb * 0.6f + pitchBias, 1.f / params_.pitchTau, dt); + } + + // The unbanked, unpitched frame: bz along the heading, bx = up × bz. + // Matrix4::lookAt is FORBIDDEN here — its z column points away from the + // target and its x column is up × z, which silently mirrors the bird and + // produces a flock whose wings all beat the wrong way round. + void bodyBasisFlat(const Bird& b, Vector3& bx, Vector3& by, Vector3& bz) const { + + bz = b.fwd; + bx.crossVectors(Vector3{0, 1, 0}, bz); + bx = fauna::detail::safeNormalized(bx, {1, 0, 0}); + by.crossVectors(bz, bx); + } + + void bodyBasis(const Bird& b, Vector3& bx, Vector3& by, Vector3& bz) const { + + bodyBasisFlat(b, bx, by, bz); + + // Pitch about bx. Rotating bz about +bx by +θ tips the nose DOWN, + // so the sign is inverted here to keep "positive pitch = nose up" + // meaning what everyone assumes it means. + if (b.pitch != 0.f) { + by.applyAxisAngle(bx, -b.pitch); + bz.applyAxisAngle(bx, -b.pitch); + } + if (b.bank != 0.f) { + bx.applyAxisAngle(bz, b.bank); + by.applyAxisAngle(bz, b.bank); + } + } + + // ── Wingbeat, head, springs, state exits ───────────────────────── + void advanceBeat(int i, Bird& b, const Dyn& q, float dt) { + + const float speedNorm = std::clamp(q.vel.length() / std::max(params_.cruiseSpeed, 1e-3f), 0.f, 2.f); + + // Named beatScale, not scale: Object3D::scale is in this class's + // scope and a bare `scale` here shadows it (/W4 C4458). Harmless + // today, and a genuinely nasty bug the day someone edits this + // function expecting to touch the node's transform. + float beatScale = 1.f; + switch (b.state) { + case BirdState::Cruise: beatScale = 0.75f + 0.35f * speedNorm; break; + case BirdState::Approach: beatScale = 0.90f; break; + case BirdState::Flare: beatScale = 0.85f; break; + case BirdState::Launch: beatScale = 1.35f; break; + case BirdState::Evade: beatScale = 1.20f; break; + case BirdState::Perched: beatScale = 0.75f; break; + } + + float f = baseBeatHz() * b.beatRate * beatScale; + + // THE NYQUIST GUARD IS NOT OPTIONAL. At 30 fps an unguarded 8.5 Hz + // beat is 3.5 samples per cycle, so the stroke extremes land on + // random phases and the amplitude visibly flickers. Guarded, a 30 fps + // host gets a smooth 5 Hz beat: a slightly slow bird reads as a + // bigger bird, a strobing bird reads as broken software. + if (params_.nyquistGuard && dtSmoothed_ > 1e-5f) { + f = std::min(f, 1.f / (6.f * dtSmoothed_)); + } + + // PHASE IS ADVANCED, NEVER EVALUATED AS sin(2π·f(t)·t). The moment f + // changes, the closed form teleports the wing; the accumulator + // cannot. This one line is why "flap harder", "ease into a glide" + // and "resume from a glide" are all free of pops. + b.cyclePos = fauna::detail::frac01(b.cyclePos + f * dt); + + // Flap-bounding. Suppressed while climbing, banked, near an obstacle + // or in any state but Cruise — real birds flap continuously when + // they need control authority, and that suppression is what stops + // the undulation itself from becoming metronomic. + const bool suppress = b.state != BirdState::Cruise || + q.vel.y > 1.f || + std::abs(b.bank) > 0.44f || + (hasField_ && perch_.clearanceAt(q.pos) < params_.obstacleMargin); + + if (suppress) { + b.bounding = false; + b.boundT = 0.f; + } else if (b.bounding) { + b.boundT += dt; + if (b.boundT > 0.30f) { + b.bounding = false; + // Bursts of 5 ± 1 beats. A fixed burst length turns the + // undulation itself into a metronome, which is the very + // thing flap-bounding was added to break. + b.beatsLeft = 4.f + 2.f * rnd01(params_.seed, static_cast(i), b.seq++); + } + } else { + b.beatsLeft -= f * dt; + if (b.beatsLeft <= 0.f) { + b.bounding = true; + b.boundT = 0.f; + } + } + + float target = 1.f; + switch (b.state) { + case BirdState::Cruise: target = b.bounding ? 0.f : 1.f; break; + case BirdState::Approach: { + // (1 − settle)²: amplitude falls to zero BEFORE the feet + // touch. At 0.6 m it is already 25 %, and a bird that aborts + // picks the envelope back up from wherever it fell to, so + // the transition is continuous in both directions. + const float dist = q.pos.distanceTo(standPoint(b)); + const float settle = math::smoothstep(0.f, 1.f, 1.f - dist / std::max(params_.flareDistance * 4.f, 1e-3f)); + target = (1.f - settle) * (1.f - settle); + break; + } + case BirdState::Flare: target = 0.f; break; + case BirdState::Perched: + // THE BALANCE HOLD: wings pinned half-raised for 0.12 s after + // contact before folding. Every real bird does it and nobody + // animates it; without it the fold is a single-frame pop the + // eye catches at 40 m. + target = (b.stateTime < kBalanceHold) ? 0.30f : 0.f; + break; + case BirdState::Launch: target = (b.stateTime < 0.15f) ? 0.f + : 1.f + 0.25f * std::exp(-(b.stateTime - 0.15f) / 0.30f); + break; + case BirdState::Evade: target = 1.f; break; + } + b.flapWeight = math::damp(b.flapWeight, target, 12.f, dt); + + // perchFold: 0 → 1 over the 0.23 s that follow the balance hold, and + // back over 0.20 s at the leap. Nothing in the pose function + // switches; every one of these blends is C¹ by construction. + float foldTarget = 0.f; + if (b.state == BirdState::Perched) { + foldTarget = (b.stateTime < kBalanceHold) ? 0.f : 1.f; + if (b.shuffleStart >= 0.f && time_ - b.shuffleStart < 0.35f) foldTarget = 0.75f; + } else if (b.state == BirdState::Launch && b.stateTime < 0.15f) { + foldTarget = 1.f; + } + // settleTime is contact → wings fully folded, and the balance hold + // eats the first 0.12 s of it, so the fold itself gets what remains. + const float foldSpan = std::max(params_.settleTime - kBalanceHold, 0.05f); + const float foldRate = (b.state == BirdState::Launch) ? 1.f / 0.20f : 1.f / foldSpan; + b.perchFold = math::damp(b.perchFold, foldTarget, 3.f * foldRate, dt); + + float spread = 0.10f; + switch (b.state) { + case BirdState::Approach: spread = 0.55f; break; + case BirdState::Flare: spread = 1.00f; break; + case BirdState::Perched: spread = 0.05f; break; + case BirdState::Launch: spread = 0.60f; break; + case BirdState::Evade: spread = 0.75f; break; + default: break; + } + b.tailSpread = math::damp(b.tailSpread, spread, 8.f, dt); + } + + void advanceHead(int i, Bird& b, float dt) { + + // BIRDS HAVE ALMOST NO EYE MOVEMENT IN THE SOCKET, so gaze is + // stabilised by the neck: the head SNAPS and HOLDS. It never sweeps. + // Lerping toward a moving target is forbidden — a 0.25 s eased head + // turn reads as a lizard, and this is the motion that gets inspected + // when someone finally looks straight at the flock. + if (b.saccadeStart < 0.f && time_ >= b.saccadeNext) { + + b.headYawFrom = b.headYaw; + b.headPitchFrom = b.headPitch; + + const float yawU = rnd01(params_.seed, static_cast(i), b.seq++); + const float pitchU = rnd01(params_.seed, static_cast(i), b.seq++); + const float holdU = rnd01(params_.seed, static_cast(i), b.seq++); + + const bool preening = (b.preenUntil > time_); + b.headYawTo = preening ? (yawU < 0.5f ? -1.15f : 1.15f) : (yawU * 2.f - 1.f) * 1.22f; + b.headPitchTo = preening ? -0.6f : (pitchU * 2.f - 1.f) * 0.44f; + + const float hold = preening ? 1.f + 2.f * holdU + : (b.state == BirdState::Perched && b.walker && time_ < b.pauseUntil + ? 0.35f + 0.6f * holdU + : 0.35f + 2.15f * holdU); + b.saccadeStart = time_; + b.saccadeNext = time_ + kSaccade + hold; + } + + if (b.saccadeStart >= 0.f) { + const float e = math::smoothstep(0.f, kSaccade, time_ - b.saccadeStart); + b.headYaw = math::lerp(b.headYawFrom, b.headYawTo, e); + b.headPitch = math::lerp(b.headPitchFrom, b.headPitchTo, e); + if (e >= 1.f) b.saccadeStart = -1.f; + } + + if (b.state != BirdState::Perched) { + // In flight the head stays level while the body banks 50°, which + // is the single most legible cue that the thing is alive. + b.headYaw = math::damp(b.headYaw, 0.f, 6.f, dt); + b.headPitch = math::damp(b.headPitch, 0.f, 6.f, dt); + } + b.headRoll = std::clamp(-0.7f * b.bank, -0.6f, 0.6f); + } + + void advanceSprings(int i, Bird& b, float dt) { + + const auto slot = static_cast(i); + + // Second-order landing spring, ω = 22 rad/s, ζ = 0.55 — one visible + // rebound, settled in ~0.35 s. THE INITIAL DISPLACEMENT IS NEGATIVE: + // a landing compresses first. A +cos impulse pops the body UP on the + // exact frame the feet meet the branch and reads as the bird being + // struck from below. + const float acc = -kLiftOmega * kLiftOmega * b.liftX - 2.f * kLiftZeta * kLiftOmega * b.liftV; + b.liftV += acc * dt; + b.liftX += b.liftV * dt; + + if (b.state == BirdState::Perched) { + + // The gait's own vertical offset, the landing spring and the + // breathing bob are three INDEPENDENT contributions summed here + // rather than three functions each assigning bodyLift — which is + // how a hop arc quietly stops existing the day someone adds a + // fourth. + b.bodyLift = b.liftX + b.gaitLift; + // Continuous 0.35 Hz breathing, 4 mm. Below the threshold of + // conscious notice and above the threshold of "that model is + // frozen". + b.bodyLift += 0.004f * std::sin(math::TWO_PI * (0.35f * time_ + b.idlePhase[0])); + + if (b.flickStart >= 0.f) { + const float e = time_ - b.flickStart; + b.tailPitch = (e < 0.18f) ? -0.25f * (1.f - math::smoothstep(0.f, 0.18f, e)) : 0.f; + if (e >= 0.18f) b.flickStart = -1.f; + } else if (time_ >= b.flickAt) { + b.flickStart = time_; + b.flickAt = time_ + 2.f + 4.f * rnd01(params_.seed, slot, b.seq++); + } + + if (b.shuffleStart >= 0.f && time_ - b.shuffleStart > 0.35f) b.shuffleStart = -1.f; + if (b.shuffleStart < 0.f && time_ >= b.shuffleAt) { + b.shuffleStart = time_; + b.shuffleAt = time_ + 8.f + 12.f * rnd01(params_.seed, slot, b.seq++); + } + if (time_ >= b.preenAt) { + // Hoisted, never two draws in one expression: argument + // evaluation order is unspecified and this is exactly how a + // fixed seed comes to mean two different things. + const float hold = rnd01(params_.seed, slot, b.seq++); + const float gap = rnd01(params_.seed, slot, b.seq++); + b.preenUntil = time_ + 1.f + 2.f * hold; + b.preenAt = time_ + 15.f + 45.f * gap; + } + + } else if (b.state == BirdState::Launch && b.stateTime < 0.15f) { + // bodyLift already written by the crouch. + } else { + b.bodyLift = math::damp(b.bodyLift, 0.f, 10.f, dt); + b.tailPitch = math::damp(b.tailPitch, b.state == BirdState::Flare ? -0.35f : 0.f, 8.f, dt); + } + + b.tailRoll = math::damp(b.tailRoll, -0.35f * b.bank, 6.f, dt); + } + + void advanceState(int i, Bird& b, Dyn& q, float dt) { + + switch (b.state) { + + case BirdState::Cruise: + b.perchUrge += dt / std::max(b.perchInterval, 0.5f); + break; + + case BirdState::Approach: { + if (!b.gatePassed) { + b.gate = approachGate(b, q); + // Latched at 1.5 m, or as soon as the gate is behind the + // wing line — a bird that overshoots the waypoint must + // not turn round for it. + Vector3 toGate = b.gate; + toGate.sub(q.pos); + if (toGate.lengthSq() < 2.25f || toGate.dot(b.fwd) < 0.f) b.gatePassed = true; + } + Vector3 to = standPoint(b); + to.sub(q.pos); + const float dist = to.length(); + if (dist < params_.flareDistance && dist > 1e-4f && + (to.x * b.fwd.x + to.y * b.fwd.y + to.z * b.fwd.z) / dist > 0.6f) { + b.state = BirdState::Flare; + b.stateTime = 0.f; + } + break; + } + + case BirdState::Flare: { + const Vector3 target = standPoint(b); + const float footHeight = q.pos.y - standHeight(b) - b.spotPos.y; + const bool arrived = q.pos.distanceToSquared(target) < 0.0036f; + const bool bounced = q.vel.y > 0.f && b.stateTime > 0.15f; + if (footHeight < 0.02f || arrived || bounced || b.stateTime > 2.5f) land(i, b, q); + break; + } + + case BirdState::Perched: + b.restUrge += dt / std::max(b.restInterval, 0.5f); + break; + + case BirdState::Launch: + if (b.stateTime >= 0.85f || q.vel.length() > 0.9f * params_.cruiseSpeed) { + b.state = BirdState::Cruise; + b.stateTime = 0.f; + b.leaped = false; + b.beatsLeft = 5.f; + } + break; + + case BirdState::Evade: + if (time_ >= b.evadeUntil) { + b.state = BirdState::Cruise; + b.stateTime = 0.f; + } + break; + } + } + + void land(int i, Bird& b, Dyn& q) { + + b.state = BirdState::Perched; + b.stateTime = 0.f; + b.restUrge = 0.f; + b.perchUrge = 0.f; + b.leaped = false; + + b.anchor = b.spotPos; + b.groundGoal = b.spotPos; + b.stagger = q.vel; + b.stagger.multiplyScalar(0.04f); + b.stagger.clampLength(0.f, 0.09f); + + // The compression spring, kicked downward. See advanceSprings(). + b.liftX = -0.18f * tmpl_.legLength; + b.liftV = 0.f; + + b.stepStart = -1.f; + b.pauseUntil = time_ + 0.4f; + b.saccadeNext = time_ + 0.15f; + + q.pos = b.anchor; + q.pos.addScaledVector(b.spotNormal, standHeight(b)); + plantStance(b); + + (void) i; + } + + [[nodiscard]] float approachLegExtend(const Bird& b, const Dyn& q) const { + + if (b.state != BirdState::Approach) return 0.f; + const float dist = q.pos.distanceTo(standPoint(b)); + // Legs down over the last two metres. Any earlier and the bird looks + // like it is dangling; any later and the gear appears in one frame. + return std::clamp(1.f - (dist - params_.flareDistance) / 2.f, 0.f, 1.f); + } + + [[nodiscard]] bool isFar(int i) const { + + if (!observer_) return false; + Vector3 eye; + eye.setFromMatrixPosition(*observer_->matrixWorld); + return prev_[static_cast(i)].pos.distanceToSquared(eye) > + params_.lodFarDistance * params_.lodFarDistance; + } + + // ── The vertex bake (§5.5 steps 9-11) ──────────────────────────── + void bakeVertices() { + + if (!posAttr_ || !nrmAttr_) return; + + auto& pos = posAttr_->array(); + auto& nrm = nrmAttr_->array(); + + if (birds_.empty()) { + geometry_->boundingSphere = Sphere(Vector3{}, 0.f); + return; + } + + Vector3 lo{std::numeric_limits::max(), std::numeric_limits::max(), std::numeric_limits::max()}; + Vector3 hi{-std::numeric_limits::max(), -std::numeric_limits::max(), -std::numeric_limits::max()}; + + Vector3 eye; + const bool lod = observer_ != nullptr; + if (lod) eye.setFromMatrixPosition(*observer_->matrixWorld); + const float lodFar2 = params_.lodFarDistance * params_.lodFarDistance; + + fauna::BirdPose pose; + + for (int i = 0; i < count_; ++i) { + + Bird& b = birds_[static_cast(i)]; + const Dyn& d = prev_[static_cast(i)]; + + // THE AABB COMES FROM SIMULATION POSITIONS, NEVER FROM THE + // WRITTEN VERTICES, so the LOD's skipped bakes cannot make the + // bounding sphere stale — which would frustum-cull exactly the + // distant birds the LOD exists to serve. + lo.x = std::min(lo.x, d.pos.x); + lo.y = std::min(lo.y, d.pos.y); + lo.z = std::min(lo.z, d.pos.z); + hi.x = std::max(hi.x, d.pos.x); + hi.y = std::max(hi.y, d.pos.y); + hi.z = std::max(hi.z, d.pos.z); + + // THE ONE AND ONLY LOD. A far bird's position still integrates + // every frame; only the vertex write is skipped. NEVER drop the + // wingbeat with distance — frequency and amplitude are the + // entire read at range, and a distant bird whose beat is + // decimated stops being a bird and becomes a moving dot. + if (lod && b.baked && d.pos.distanceToSquared(eye) > lodFar2 && + ((frame_ + static_cast(i)) % 2u) != 0u) { + continue; + } + + fillPose(i, b, d, pose); + toLocal(pose); + fauna::poseBird(tmpl_, pose, kin_, pos, nrm, i * fauna::kVertsPerBird); + b.baked = true; + } + + Vector3 centre{(lo.x + hi.x) * 0.5f, (lo.y + hi.y) * 0.5f, (lo.z + hi.z) * 0.5f}; + Vector3 half{(hi.x - lo.x) * 0.5f, (hi.y - lo.y) * 0.5f, (hi.z - lo.z) * 0.5f}; + + // One bird radius of slack, scaled by the largest bird: the sphere + // is built from body ORIGINS, and a wing tip reaches half a span + // past one. + const float pad = 0.6f * std::max(params_.shape.wingSpan, params_.shape.bodyLength) * + (1.f + params_.sizeVariation); + float radius = half.length() + pad; + + if (!identityWorld_) { + centre.applyMatrix4(invWorld_); + radius *= invScale_; + } + if (!std::isfinite(radius) || radius < 0.f) radius = 0.f; + if (!fauna::detail::isFinite(centre)) centre.set(0, 0, 0); + + // ASSIGNED EVERY FRAME. boundingSphere is a std::optional that + // NOTHING recomputes once populated (BufferGeometry.hpp:38; + // Frustum.cpp:68-72), so a stale one pops the whole flock out of + // view the first time it travels. Because it is assigned, + // frustumCulled stays TRUE — which is strictly better than the usual + // workaround of switching culling off. + geometry_->boundingSphere = Sphere(centre, radius); + + posAttr_->needsUpdate(); + nrmAttr_->needsUpdate(); + } + + void fillPose(int i, const Bird& b, const Dyn& d, fauna::BirdPose& pose) const { + + bodyBasis(b, pose.bx, pose.by, pose.bz); + pose.pos = d.pos; + pose.scale = b.size; + + pose.cyclePos = b.cyclePos; + pose.flapWeight = std::clamp(b.flapWeight, 0.f, 1.f); + pose.beatAmp = b.beatAmp; + pose.wingAsym = b.wingAsym; + pose.perchFold = std::clamp(b.perchFold, 0.f, 1.f); + + pose.headYaw = b.headYaw; + pose.headPitch = b.headPitch; + pose.headRoll = b.headRoll; + pose.headLead = b.headLead; + + pose.tailSpread = std::clamp(b.tailSpread, 0.f, 1.f); + pose.tailPitch = b.tailPitch; + pose.tailRoll = b.tailRoll; + + pose.legExtend = std::clamp(b.legExtend, 0.f, 1.f); + // THE ONE DISCONTINUOUS INPUT IN THE WHOLE POSE CONTRACT. Flipping + // it teleports the foot from the hanging position to footWorld, so + // it is only ever flipped on the frame those two coincide — at + // touchdown, where legExtend is already 1 and the foot is already on + // the surface. Everything else the caller drives is C¹. + pose.feetPlanted = b.feetPlanted; + // The swing foot is written once, in groundGait(), and simply copied + // here. Interpolating it in two places is how a foot comes to snap + // back a frame after it lands. + pose.footWorld = b.footWorld; + + pose.bodyLift = b.bodyLift; + (void) i; + } + + // ── State ──────────────────────────────────────────────────────── + Params params_; + fauna::BirdTemplate tmpl_; + fauna::BirdKinematics kin_{}; + + int count_ = 0; + std::vector birds_; + std::vector prev_, next_; + std::vector accel_; + std::vector nbrIdx_; + std::vector nbrCount_; + std::vector rngDraws_; + + fauna::PerchIndex perch_; + std::vector claimedBy_; + std::vector spotScratch_; + std::function filter_; + bool bakeRequested_ = false; + bool hasField_ = false; + + FloatBufferAttribute* posAttr_ = nullptr; + FloatBufferAttribute* nrmAttr_ = nullptr; + + Matrix4 invWorld_; + float invScale_ = 1.f; + bool identityWorld_ = true; + + const Object3D* disturbance_ = nullptr; + const Camera* observer_ = nullptr; + + Vector3 centroid_{}, meanVel_{}, homeDrifted_{}; + std::array driftAxis_{}; + std::array driftPhase_{}; + int leaderCount_ = 0, lonerCount_ = 0; + int perchedNow_ = 0, committed_ = 0; + float nextLeaderVote_ = 0.f; + float launchedRecently_ = 0.f; + + float time_ = 0.f; + float lastDt_ = 0.f; + float dtSmoothed_ = 1.f / 60.f; + std::uint64_t frame_ = 0; + std::uint64_t updates_ = 0; + std::uint64_t stalled_ = 0; + + Vector3 zero_{}; + }; + +}// namespace threepp + +#endif// THREEPP_FLOCK_HPP diff --git a/include/threepp/extras/fauna/PerchIndex.hpp b/include/threepp/extras/fauna/PerchIndex.hpp new file mode 100644 index 000000000..e43243d75 --- /dev/null +++ b/include/threepp/extras/fauna/PerchIndex.hpp @@ -0,0 +1,1452 @@ +// One-time scene bake for the ambient flock: where a bird may land, where it +// must not fly, and how high the ground is. +// +// THE OUTPUT IS A SNAPSHOT, AND THAT IS THE WHOLE POINT. +// +// The obvious design keeps a BVH per host geometry alive and probes it a few +// rays per frame. BVH caches a raw `const BufferGeometry*` with no dirty +// tracking (BVH.hpp:96), so the first time the host regenerates a terrain tile +// or frees a tree, the next probe dereferences freed memory — an intermittent +// crash in the render loop, in a subsystem nobody will suspect. Every BVH built +// here is destroyed when the bake completes; the products below are plain +// world-space values holding no pointer and no transform. The worst a scene +// edit can do afterwards is leave a bird perched in mid-air, which the host +// fixes by calling the bake again. +// +// The bake is amortised over frames by a WORK-UNIT COUNT, never a millisecond +// budget: a time budget would make the number of frames the bake takes — and +// therefore every bird's trajectory — depend on machine speed, silently +// breaking the deterministic-for-a-fixed-seed contract. bakeBlocking() and an +// amortised begin()/step() sequence produce BIT-IDENTICAL perch tables, because +// the budget decides only when step() returns, never what order anything is +// visited in. +// +// THE HOST DOES NOT HAVE TO CALL updateMatrixWorld() FIRST — begin() calls it. +// Both renderers refresh world matrices themselves, but only inside render(), +// which runs AFTER update() in the canonical animate lambda. A host that does +// mesh->position.set(10, 0, 5); scene->add(mesh); index.bakeBlocking(*scene, …); +// before entering the loop would otherwise bake every perch as if the scene were +// collapsed at the origin, and a headless test hits that 100 % of the time. +// Worse for an amortised bake: rays cast on frame 0 would use identity matrices +// and rays on frame 3 real ones, stitching one perch table out of two different +// coordinate systems. +// +// ZERO PERCHES IS A NORMAL ANSWER, NOT AN ERROR. A sky-only scene, a scene of +// nothing but steep roofs, or a scene whose meshes the filter rejected all bake +// to an empty spot table. Nothing logs, nothing throws, every query returns its +// wide-open answer and the flock simply flies. `spots().empty()` is the answer +// to "my birds never land", and it is meant to be read, not prevented. +// +// Known limitations, stated rather than fixed: +// · The obstacle field is 2 m cells by default. A bird may clip a bare twig, a +// wire or a lamp post. The ground floor, which is what actually matters, +// comes from the heightfield and is much finer. +// · `walkable` is a pure slope test. The flat top of a 0.08 m rail is +// therefore "walkable" even though nothing could walk on it; the flock is +// expected to treat a spot's surroundings, not this flag, as the authority +// on whether to take a step. +// · `heightAt` is the HIGHEST sampled surface in its column, not the terrain +// under an overpass. That is deliberate — it is what the flock uses as a +// floor, and a floor that ignores roofs would fly birds through them. +// · An InstancedMesh is a Mesh, so the traversal finds one — and bakes its +// base geometry once, at the NODE's transform, ignoring every instance +// matrix. Scattered vegetation authored that way therefore contributes one +// copy of itself at the origin of the field. Reject it with the `filter` +// predicate, or bake a proxy; there is no in-tree way to enumerate instance +// transforms cheaply enough to be worth doing implicitly. +// +// Header-only, dependency-free beyond threepp core (+ threepp/utils/BVH.hpp, +// which is public and compiled into the library but is NOT pulled in by +// threepp.hpp). + +#ifndef THREEPP_EXTRAS_FAUNA_PERCHINDEX_HPP +#define THREEPP_EXTRAS_FAUNA_PERCHINDEX_HPP + +#include "threepp/core/BufferGeometry.hpp" +#include "threepp/core/Object3D.hpp" +#include "threepp/math/Box3.hpp" +#include "threepp/math/Matrix3.hpp" +#include "threepp/math/Matrix4.hpp" +#include "threepp/math/Ray.hpp" +#include "threepp/math/Vector3.hpp" +#include "threepp/objects/Mesh.hpp" +#include "threepp/utils/BVH.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace threepp::fauna { + + namespace detail { + + // ── Deterministic bit tools ────────────────────────────────────── + // + // Both of these are pure functions of their arguments. Nothing here + // decides WHETHER a spot is accepted — mix64 only chooses where a key + // lands in the thinning table (linear probing then makes membership + // order-independent), and morton3 only chooses the final sort order. + // Keeping them pure is what lets the bake stay bit-reproducible without + // a single ordered container in the hot path. + + inline std::uint64_t mix64(std::uint64_t x) { + + x += 0x9e3779b97f4a7c15ULL; + x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL; + x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL; + return x ^ (x >> 31); + } + + // Spread the low 21 bits of `v` so that bit i lands at bit 3i. + inline std::uint64_t spread21(std::uint64_t v) { + + v &= 0x1fffffULL; + v = (v | (v << 32)) & 0x1f00000000ffffULL; + v = (v | (v << 16)) & 0x1f0000ff0000ffULL; + v = (v | (v << 8)) & 0x100f00f00f00f00fULL; + v = (v | (v << 4)) & 0x10c30c30c30c30c3ULL; + v = (v | (v << 2)) & 0x1249249249249249ULL; + return v; + } + + inline std::uint64_t morton3(std::uint32_t x, std::uint32_t y, std::uint32_t z) { + + return spread21(x) | (spread21(y) << 1) | (spread21(z) << 2); + } + + inline std::size_t nextPow2(std::size_t v) { + + std::size_t n = 1; + while (n < v) n <<= 1; + return n; + } + + // Quantise a world position onto a separation lattice and pack the three + // signed cell indices into one 64-bit key. 21 bits per axis at the 0.45 m + // default reaches ±470 km from the origin, which is several orders of + // magnitude past any scene this ships in. + inline std::uint64_t latticeKey(const Vector3& p, float invSpacing) { + + const auto ix = static_cast( + static_cast(std::floor(p.x * invSpacing)) & 0x1fffffLL); + const auto iy = static_cast( + static_cast(std::floor(p.y * invSpacing)) & 0x1fffffLL); + const auto iz = static_cast( + static_cast(std::floor(p.z * invSpacing)) & 0x1fffffLL); + + return (ix << 42) | (iy << 21) | iz; + } + + }// namespace detail + + // A baked landing site. Pointer-free and transform-free by design. + struct PerchSpot { + Vector3 position{}; // world + Vector3 normal{0, 1, 0};// world, unit, already flipped upward + bool walkable = false; // near-flat and with room to step + bool ground = false; // within groundEpsilon of the local ground height + + bool operator==(const PerchSpot&) const = default; + }; + + class PerchIndex { + + public: + struct Params { + // ── Sampling ───────────────────────────────────────────────── + float probeSpacing = 1.0f; // m, downward ray grid pitch + int maxProbesPerAxis = 128; // hard cap; a huge scene gets a coarser grid + int maxPerches = 4096; // hard cap on the spot table + float perchMinSeparation = 0.45f;// m, thinning distance between accepted spots + + // ── Acceptance ─────────────────────────────────────────────── + float maxSlope = 0.61f; // rad (35°); keep spots with normal.y > cos(this) + float headroomLow = 0.30f; // m above the spot that must be clear + float headroomHigh = 0.80f; // m above the spot that must be clear + float walkableSlope = 0.44f; // rad (25°); flatter than this ⇒ walkable + float groundEpsilon = 0.60f; // m; spot within this of the column height ⇒ ground + + // ── Obstacle field ─────────────────────────────────────────── + float cellSize = 2.0f;// m, obstacle grid cell + int maxCellsX = 64; // hard caps on the 3-D grid + int maxCellsY = 32; + int maxCellsZ = 64; + int chamferPasses = 8;// saturating distance, in cells + int heightGrid = 128; // XZ resolution of the ground heightfield + + // ── Cost control ───────────────────────────────────────────── + int bakeWorkPerFrame = 30000; // work units per step(); 0 ⇒ blocking + int bvhMaxTriangles = 40000; // meshes above this get no BVH (see banner) + int maxSamplesPerTriangleAxis = 16;// barycentric sampling cap for big triangles + + bool operator==(const Params&) const = default; + }; + + PerchIndex() = default; + + // Begin (or restart) a bake over `root`. `exclude` is skipped by pointer + // identity along with all of its descendants — pass the Flock itself. + // `filter` may be empty; when set, a Mesh is considered only if it + // returns true. Calls root.updateMatrixWorld() itself: nothing else + // refreshes it before the first render, and a bake against identity + // matrices puts every perch at the world origin. + // + // PHASE 0 (COLLECT) RUNS HERE, SYNCHRONOUSLY, AND IS DELIBERATELY NOT + // AMORTISED. Spreading the traversal over frames would mean holding an + // Object3D* across frames — the exact dangling-pointer hazard this whole + // file exists to remove. Everything after the traversal works from + // values that have already been copied out, so the rest of the bake can + // be spread as thin as the host likes. + void begin(Object3D& root, const Params& params, + const Object3D* exclude, + const std::function& filter) { + + clear(); + params_ = sanitise(params); + + root.updateMatrixWorld(); + + // Gate on the Mesh cast FIRST. Frustum::intersectsObject and friends + // dereference object.geometry() with no null check (Frustum.cpp:68-70) + // and geometry() returns nullptr for Group and Light nodes; the same + // trap sits waiting for anything that walks a scene by hand. + root.traverseType([&](Mesh& mesh) { + if (excluded(mesh, exclude)) return; + if (filter && !filter(mesh)) return; + + const auto geometry = mesh.geometry(); + if (!geometry) return; + + const auto* index = geometry->getIndex(); + const auto* position = geometry->getAttribute("position"); + if (!index || !position) return; + + const int triCount = index->count() / 3; + const int vertCount = position->count(); + if (triCount <= 0 || vertCount <= 0) return; + + MeshEntry entry; + entry.geometry = geometry; + entry.worldMatrix.copy(*mesh.matrixWorld); + entry.triCount = triCount; + entry.vertCount = vertCount; + + // The world AABB is computed fresh from the attribute rather than + // read from geometry->boundingBox: that optional is a cache the + // host may never have filled, and if it did fill it before + // deforming the mesh it is now a lie. We also do not write it + // back — a bake has no business mutating the scene it reads. + Box3 local; + local.makeEmpty(); + Vector3 v; + for (int i = 0; i < vertCount; ++i) { + v.set(position->getX(static_cast(i)), + position->getY(static_cast(i)), + position->getZ(static_cast(i))); + if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) continue; + local.expandByPoint(v); + } + if (local.isEmpty()) return; + + entry.worldBox.copy(local).applyMatrix4(entry.worldMatrix); + if (entry.worldBox.isEmpty()) return; + + bounds_.union_(entry.worldBox); + sampleTriTotal_ += triCount; + meshes_.push_back(std::move(entry)); + }); + + phase_ = Phase::GridAlloc; + } + + // Advance the bake by up to Params::bakeWorkPerFrame work units. + // Returns true when the bake is complete. Safe to call after completion + // (returns true, does nothing) and before begin() (returns false and + // does nothing — there is no bake to advance). + bool step() { + + if (complete_) return true; + if (phase_ == Phase::Idle) return false; + + const bool blocking = params_.bakeWorkPerFrame <= 0; + std::int64_t budget = blocking + ? std::numeric_limits::max() + : static_cast(params_.bakeWorkPerFrame); + + while (budget > 0 && !complete_) { + + std::int64_t spent = 0; + switch (phase_) { + case Phase::GridAlloc: spent = allocateGrids(); break; + case Phase::Sample: spent = sampleTriangles(budget); break; + case Phase::Chamfer: spent = relaxChamfer(budget); break; + case Phase::RayGrid: spent = castProbeGrid(budget); break; + case Phase::Finalise: spent = finalise(); break; + default: spent = budget; break; + } + budget -= (spent > 0 ? spent : 1); + } + + return complete_; + } + + // Run the whole bake now. Equivalent to begin() then step() until done. + void bakeBlocking(Object3D& root, const Params& params, + const Object3D* exclude, + const std::function& filter) { + + begin(root, params, exclude, filter); + while (!step()) {} + } + + // Discard everything. complete() becomes false, all queries return their + // empty-scene answers. + void clear() { + + params_ = Params{}; + phase_ = Phase::Idle; + complete_ = false; + + spots_.clear(); + spots_.shrink_to_fit(); + bounds_.makeEmpty(); + + dist_.clear(); + dist_.shrink_to_fit(); + chamferScratch_.clear(); + chamferScratch_.shrink_to_fit(); + nx_ = ny_ = nz_ = 0; + cell_ = 0.f; + invCell_ = 0.f; + saturation_ = 0; + gridMin_.set(0, 0, 0); + + height_.clear(); + height_.shrink_to_fit(); + heightN_ = 0; + heightCellX_ = heightCellZ_ = 0.f; + + clearScratch(); + clearSpotGrid(); + } + + [[nodiscard]] bool complete() const { + + return complete_; + } + + [[nodiscard]] float progress() const { + + if (complete_) return 1.f; + + switch (phase_) { + case Phase::Idle: return 0.f; + case Phase::GridAlloc: return kwCollect; + case Phase::Sample: + return kwCollect + kwSample * ratio(sampleTriDone_, sampleTriTotal_); + case Phase::Chamfer: + return kwCollect + kwSample + kwChamfer * ratio(chamferPass_, std::max(1, saturation_)); + case Phase::RayGrid: + return kwCollect + kwSample + kwChamfer + + kwRayGrid * ratio(static_cast(rayMesh_), static_cast(meshes_.size())); + default: return 1.f - kwFinalise; + } + } + + [[nodiscard]] const std::vector& spots() const { + + return spots_; + } + + [[nodiscard]] const Box3& sceneBounds() const { + + return bounds_; + } + + // ── Runtime queries. O(1), no allocation, no scene access. ─────── + + // Ground height under (x, z). Returns sceneBounds().min.y where nothing + // was sampled, and 0 when the index is empty. + [[nodiscard]] float heightAt(float x, float z) const { + + if (height_.empty()) return 0.f; + + const float floorY = bounds_.isEmpty() ? 0.f : bounds_.min().y; + + const int ix = static_cast(std::floor((x - bounds_.min().x) / heightCellX_)); + const int iz = static_cast(std::floor((z - bounds_.min().z) / heightCellZ_)); + if (ix < 0 || ix >= heightN_ || iz < 0 || iz >= heightN_) return floorY; + + const float h = height_[static_cast(iz) * heightN_ + ix]; + return h > -kUnsampled ? h : floorY; + } + + // Saturating distance to the nearest occupied cell, in METRES. + // Returns a large value (chamferPasses · cellSize) outside the grid and + // when the index is empty — i.e. "wide open", never "blocked". + // + // A 0-MEANS-UNKNOWN CONVENTION WOULD BE A DISASTER HERE: the flock's + // obstacle force reads this directly, so an unbaked scene would behave + // like solid rock and every bird would spend the run shoving itself away + // from nothing. + [[nodiscard]] float clearanceAt(const Vector3& worldPos) const { + + if (dist_.empty()) return emptyClearance(); + + const float open = static_cast(saturation_) * cell_; + + int ix, iy, iz; + if (!cellOf(worldPos.x, worldPos.y, worldPos.z, ix, iy, iz)) return open; + + return static_cast(dist_[cellIndex(ix, iy, iz)]) * cell_; + } + + // Central-difference gradient of clearanceAt, normalised, pointing AWAY + // from geometry. Writes (0,0,0) when the sample is already clear or the + // index is empty. + void clearanceGradient(const Vector3& worldPos, Vector3& out) const { + + out.set(0, 0, 0); + if (dist_.empty()) return; + + int ix, iy, iz; + if (!cellOf(worldPos.x, worldPos.y, worldPos.z, ix, iy, iz)) return; + if (dist_[cellIndex(ix, iy, iz)] >= saturation_) return; + + const float gx = tap(ix + 1, iy, iz) - tap(ix - 1, iy, iz); + const float gy = tap(ix, iy + 1, iz) - tap(ix, iy - 1, iz); + const float gz = tap(ix, iy, iz + 1) - tap(ix, iy, iz - 1); + + // Vector3::normalize() divides by length with only a NaN guard, not a + // zero guard (Vector3.cpp:403-409), so a flat plateau in the field + // would hand the caller an infinity. Test the length first, always. + const float len2 = gx * gx + gy * gy + gz * gz; + if (len2 < 1e-12f) return; + + const float inv = 1.f / std::sqrt(len2); + out.set(gx * inv, gy * inv, gz * inv); + } + + // Indices into spots() whose position is within `radius` of `p`. + // Appends; caller clears. Results are in ascending spot-index order. + void querySpots(const Vector3& p, float radius, std::vector& out) const { + + if (spots_.empty() || !(radius > 0.f)) return; + + const std::size_t first = out.size(); + const float r2 = radius * radius; + + if (qStart_.empty()) { + // No acceleration grid (a degenerate spot set — every spot at one + // point). Linear, and already in ascending index order. + for (std::size_t i = 0; i < spots_.size(); ++i) { + if (spots_[i].position.distanceToSquared(p) <= r2) out.push_back(static_cast(i)); + } + return; + } + + const int lo[3]{qClamp(p.x - radius, 0), qClamp(p.y - radius, 1), qClamp(p.z - radius, 2)}; + const int hi[3]{qClamp(p.x + radius, 0), qClamp(p.y + radius, 1), qClamp(p.z + radius, 2)}; + + for (int iz = lo[2]; iz <= hi[2]; ++iz) { + for (int iy = lo[1]; iy <= hi[1]; ++iy) { + for (int ix = lo[0]; ix <= hi[0]; ++ix) { + const auto c = static_cast((iz * qn_ + iy) * qn_ + ix); + for (int k = qStart_[c]; k < qStart_[c + 1]; ++k) { + const int s = qIndex_[static_cast(k)]; + if (spots_[s].position.distanceToSquared(p) <= r2) out.push_back(s); + } + } + } + } + + // A spot lives in exactly one bucket, so there are no duplicates — + // only the cell-major visit order to undo. std::sort over the range + // this call appended is in place and allocation-free. + std::sort(out.begin() + static_cast(first), out.end()); + } + + // Replace the perch table with an authored one. Marks the index + // complete; leaves the obstacle field and heightfield untouched. + // + // Calling this while a bake is in flight CANCELS that bake — the scene + // scratch is dropped and no further spot will be appended behind the + // caller's back. The obstacle field keeps whatever it had reached, which + // for a mid-bake call means a partial field; call clear() first if that + // matters. + void setSpots(std::vector spots) { + + clearScratch(); + spots_ = std::move(spots); + buildSpotGrid(); + phase_ = Phase::Done; + complete_ = true; + } + + void addSpot(const PerchSpot& spot) { + + clearScratch(); + spots_.push_back(spot); + buildSpotGrid(); + phase_ = Phase::Done; + complete_ = true; + } + + private: + // ── Bake state machine ─────────────────────────────────────────── + enum class Phase : std::uint8_t { + Idle = 0, // nothing begun + GridAlloc = 1,// size the obstacle grid and the heightfield + Sample = 2, // barycentric triangle sampling → occupancy + height + Chamfer = 3, // saturating distance transform + RayGrid = 4, // per-mesh BVH + downward probe grid + Finalise = 5, // sort, index, drop every pointer + Done = 6, + }; + + // Progress weights. Sampling and the ray grid are the two phases that + // actually cost anything; the rest are rounding error on a real scene. + static constexpr float kwCollect = 0.03f; + static constexpr float kwSample = 0.42f; + static constexpr float kwChamfer = 0.10f; + static constexpr float kwRayGrid = 0.42f; + static constexpr float kwFinalise = 0.03f; + + static constexpr float kUnsampled = 1e30f;// heightfield "never written" sentinel + static constexpr int kMaxGridCells = 2000000; + + // Scene scratch. EVERY MEMBER IN THIS BLOCK IS DEAD BY THE TIME step() + // RETURNS TRUE — that is the single invariant this file exists to + // enforce. The geometry is held by shared_ptr rather than raw pointer for + // the duration of the bake, so a host that frees a mesh half way through + // an amortised bake gets a stale perch rather than a use-after-free. + struct MeshEntry { + std::shared_ptr geometry; + Matrix4 worldMatrix; + Box3 worldBox; + int triCount = 0; + int vertCount = 0; + }; + + struct Candidate { + Vector3 position; + Vector3 normal; + }; + + Params params_{}; + Phase phase_ = Phase::Idle; + bool complete_ = false; + + // ── Products (pointer-free, transform-free, survive the bake) ──── + std::vector spots_; + Box3 bounds_; + + // Obstacle chamfer field. dist_[c] == 0 ⇔ the cell is occupied, and it + // stays that way for ever: the relaxation writes 1 + min(neighbours), + // which can never reach 0 for an unoccupied cell. That invariant is what + // lets the headroom test be two array reads instead of a second raycast, + // and it is why there is no separate occupancy array. + std::vector dist_; + std::vector chamferScratch_; + int nx_ = 0, ny_ = 0, nz_ = 0; + float cell_ = 0.f; + float invCell_ = 0.f; + int saturation_ = 0; + Vector3 gridMin_{}; + + // XZ ground heightfield over the same AABB. + std::vector height_; + int heightN_ = 0; + float heightCellX_ = 0.f; + float heightCellZ_ = 0.f; + + // Spot lookup acceleration (rebuilt whenever the table changes). + std::vector qStart_; + std::vector qIndex_; + int qn_ = 0; + Vector3 qMin_{}; + Vector3 qCell_{1, 1, 1}; + + // ── Bake scratch ───────────────────────────────────────────────── + std::vector meshes_; + std::vector pending_; + std::vector thinSlots_; + std::size_t thinMask_ = 0; + std::optional bvh_; + + std::size_t meshCursor_ = 0; + int triCursor_ = 0; + int sampleTriDone_ = 0; + int sampleTriTotal_ = 0; + + int chamferPass_ = 0; + int chamferCell_ = 0; + + std::size_t rayMesh_ = 0; + bool bvhReady_ = false; + int probeCursor_ = 0; + int probeNX_ = 0; + int probeNZ_ = 0; + float probeStepX_ = 0.f; + float probeStepZ_ = 0.f; + + // ── Parameter hygiene ──────────────────────────────────────────── + // + // Clamped on the way in rather than trusted, because two of these turn a + // typo into a subsystem that quietly does the opposite of its job: + // chamferPasses ≤ 0 makes the saturated clearance 0, i.e. the whole world + // reads as solid, and an unbounded cell cap lets a 10 km scene ask for a + // 4 GB grid. + [[nodiscard]] static Params sanitise(const Params& in) { + + Params p = in; + p.probeSpacing = std::max(p.probeSpacing, 1e-3f); + p.maxProbesPerAxis = std::clamp(p.maxProbesPerAxis, 1, 512); + p.maxPerches = std::clamp(p.maxPerches, 0, 1 << 20); + p.perchMinSeparation = std::max(p.perchMinSeparation, 1e-3f); + p.maxSlope = std::clamp(p.maxSlope, 0.f, 1.5707f); + p.walkableSlope = std::clamp(p.walkableSlope, 0.f, 1.5707f); + p.headroomLow = std::max(p.headroomLow, 0.f); + p.headroomHigh = std::max(p.headroomHigh, p.headroomLow); + p.cellSize = std::max(p.cellSize, 1e-3f); + p.maxCellsX = std::clamp(p.maxCellsX, 4, 256); + p.maxCellsY = std::clamp(p.maxCellsY, 4, 256); + p.maxCellsZ = std::clamp(p.maxCellsZ, 4, 256); + p.chamferPasses = std::clamp(p.chamferPasses, 1, 200); + p.heightGrid = std::clamp(p.heightGrid, 1, 1024); + p.bvhMaxTriangles = std::max(p.bvhMaxTriangles, 0); + p.maxSamplesPerTriangleAxis = std::clamp(p.maxSamplesPerTriangleAxis, 1, 16); + return p; + } + + [[nodiscard]] static bool excluded(const Mesh& mesh, const Object3D* exclude) { + + if (!exclude) return false; + + // Pointer identity up the parent chain: the flock adds itself to the + // scene like anything else, so add() order must not matter. + for (const Object3D* p = &mesh; p; p = p->parent) { + if (p == exclude) return true; + } + return false; + } + + [[nodiscard]] static float ratio(int done, int total) { + + if (total <= 0) return 1.f; + return std::clamp(static_cast(done) / static_cast(total), 0.f, 1.f); + } + + [[nodiscard]] float emptyClearance() const { + + return static_cast(std::max(1, params_.chamferPasses)) * std::max(params_.cellSize, 1e-3f); + } + + // ── Grid addressing ────────────────────────────────────────────── + [[nodiscard]] int cellIndex(int ix, int iy, int iz) const { + + return (iz * ny_ + iy) * nx_ + ix; + } + + [[nodiscard]] bool cellOf(float x, float y, float z, int& ix, int& iy, int& iz) const { + + if (dist_.empty()) return false; + + ix = static_cast(std::floor((x - gridMin_.x) * invCell_)); + iy = static_cast(std::floor((y - gridMin_.y) * invCell_)); + iz = static_cast(std::floor((z - gridMin_.z) * invCell_)); + + return ix >= 0 && ix < nx_ && iy >= 0 && iy < ny_ && iz >= 0 && iz < nz_; + } + + // Out-of-range taps read as the saturated maximum — the same convention + // the relaxation itself uses, so the gradient at the edge of the grid + // points outward instead of inventing a wall. + [[nodiscard]] float tap(int ix, int iy, int iz) const { + + if (ix < 0 || ix >= nx_ || iy < 0 || iy >= ny_ || iz < 0 || iz >= nz_) { + return static_cast(saturation_); + } + return static_cast(dist_[cellIndex(ix, iy, iz)]); + } + + // ── Phase 1 — grid allocation ──────────────────────────────────── + std::int64_t allocateGrids() { + + if (bounds_.isEmpty() || meshes_.empty()) { + // Box3::getCenter/getSize guard isEmpty() and return (0,0,0) + // (Box3.cpp:137-153), so an empty scene produces no NaN — but a + // 1-cell grid at the origin would still be a lie. Skip straight + // to the finish and let every query answer "wide open". + phase_ = Phase::Finalise; + return 1; + } + + const Vector3 size = bounds_.getSize(); + + // Reserve two cells of padding per axis so a surface flush with the + // scene AABB still has a neighbour cell above it for the headroom + // test, then grow the cell until the caps AND the total-cell budget + // are both satisfied. A cap that silently truncated the grid would + // leave geometry outside it reading as open sky. + float cell = std::max({params_.cellSize, + size.x / static_cast(params_.maxCellsX - 2), + size.y / static_cast(params_.maxCellsY - 2), + size.z / static_cast(params_.maxCellsZ - 2)}); + + auto dim = [&](float extent, int cap) { + return std::min(cap, static_cast(std::floor(extent / cell)) + 3); + }; + + nx_ = dim(size.x, params_.maxCellsX); + ny_ = dim(size.y, params_.maxCellsY); + nz_ = dim(size.z, params_.maxCellsZ); + + while (static_cast(nx_) * ny_ * nz_ > kMaxGridCells) { + cell *= 1.26f; + nx_ = dim(size.x, params_.maxCellsX); + ny_ = dim(size.y, params_.maxCellsY); + nz_ = dim(size.z, params_.maxCellsZ); + } + + cell_ = cell; + invCell_ = 1.f / cell_; + gridMin_.set(bounds_.min().x - cell_, bounds_.min().y - cell_, bounds_.min().z - cell_); + saturation_ = params_.chamferPasses; + + dist_.assign(static_cast(nx_) * ny_ * nz_, + static_cast(saturation_)); + + heightN_ = params_.heightGrid; + heightCellX_ = std::max(size.x, 1e-3f) / static_cast(heightN_); + heightCellZ_ = std::max(size.z, 1e-3f) / static_cast(heightN_); + height_.assign(static_cast(heightN_) * heightN_, -kUnsampled); + + // Thinning table: open-addressed, linearly probed, fixed capacity. + // NEVER std::unordered_set — even though this set is only ever + // membership-tested, the house rule keeps unordered containers out of + // the deterministic path entirely, because the day someone iterates + // one to "just check something" is the day the bake stops being + // reproducible and nobody knows why. + const std::size_t capacity = detail::nextPow2( + static_cast(std::max(16, params_.maxPerches)) * 4); + thinSlots_.assign(capacity, 0); + thinMask_ = capacity - 1; + + phase_ = Phase::Sample; + return 1; + } + + // ── Phase 2 — triangle sampling ────────────────────────────────── + // + // EVERY mesh is sampled, including the ones far above bvhMaxTriangles + // that phase 4 will refuse to build a BVH for. A skipped 2 M-triangle + // terrain would leave the ground heightfield empty exactly where the + // ground is, and birds would fly through the mountain. A big mesh + // DEGRADES here — it loses ray-accurate perches and keeps its obstacle + // cells, its ground height and a coarser set of sampled perches. + // + // THE HEIGHTFIELD IS RASTERISED, NOT POINT-SAMPLED, AND THE TWO ARE NOT + // INTERCHANGEABLE. The barycentric lattice below is sized against the + // OBSTACLE cell (2 m by default), while the heightfield is 128 columns + // across the scene — several times finer on anything smaller than a + // couple of hundred metres. Feeding the heightfield from the same points + // leaves most of its columns unwritten, and an unwritten column answers + // `sceneBounds().min.y`: the floor on top of a 6 m roof reads as ground + // level, the flock's ground force never engages, and birds descend + // straight through the building. Every column whose centre the triangle + // actually covers gets the plane's exact height instead — one lattice + // test per covered column, and the only number in this bake the flock + // treats as an absolute rather than a hint. + std::int64_t sampleTriangles(std::int64_t budget) { + + std::int64_t work = 0; + Vector3 a, b, c, p, n; + + while (work < budget && meshCursor_ < meshes_.size()) { + + MeshEntry& entry = meshes_[meshCursor_]; + + // Fetched once per mesh, not once per triangle: getAttribute() + // hashes a std::string, and doing that two million times turns a + // bake into a stall. + auto* position = entry.geometry->getAttribute("position"); + const auto* index = entry.geometry->getIndex(); + if (!position || !index) { + ++meshCursor_; + triCursor_ = 0; + ++work; + continue; + } + + const auto& indices = index->array(); + const auto vertCount = static_cast(entry.vertCount); + const bool emitCandidates = entry.triCount > params_.bvhMaxTriangles; + + while (work < budget && triCursor_ < entry.triCount) { + + const auto base = static_cast(triCursor_) * 3; + ++triCursor_; + ++sampleTriDone_; + + if (base + 2 >= indices.size()) { + ++work; + continue; + } + const unsigned int ia = indices[base]; + const unsigned int ib = indices[base + 1]; + const unsigned int ic = indices[base + 2]; + if (ia >= vertCount || ib >= vertCount || ic >= vertCount) { + ++work; + continue; + } + + fetch(*position, ia, a).applyMatrix4(entry.worldMatrix); + fetch(*position, ib, b).applyMatrix4(entry.worldMatrix); + fetch(*position, ic, c).applyMatrix4(entry.worldMatrix); + + if (!isFinite(a) || !isFinite(b) || !isFinite(c)) { + ++work; + continue; + } + + // Sample density follows the triangle's own cell footprint: + // a 40 m ground quad and a 3 cm leaf both end up marking the + // cells they actually cover, and neither burns more than + // (n+1)(n+2)/2 ≤ 153 samples doing it. + const float ex = std::max({a.x, b.x, c.x}) - std::min({a.x, b.x, c.x}); + const float ey = std::max({a.y, b.y, c.y}) - std::min({a.y, b.y, c.y}); + const float ez = std::max({a.z, b.z, c.z}) - std::min({a.z, b.z, c.z}); + const int span = static_cast(std::ceil(std::max({ex, ey, ez}) * invCell_)); + const int nSteps = std::clamp(span, 1, params_.maxSamplesPerTriangleAxis); + const float inv = 1.f / static_cast(nSteps); + + work += rasteriseHeight(a, b, c); + + bool slopeOk = false; + if (emitCandidates) { + // World-space cross product of already-transformed + // vertices, so it needs no normal matrix and no + // non-uniform-scale correction. + Vector3 e1, e2; + e1.subVectors(b, a); + e2.subVectors(c, a); + n.crossVectors(e1, e2); + const float len2 = n.lengthSq(); + if (len2 > 1e-20f) { + n.multiplyScalar(1.f / std::sqrt(len2)); + if (n.y < 0.f) n.negate(); + slopeOk = n.y > std::cos(params_.maxSlope); + } + } + + for (int i = 0; i <= nSteps; ++i) { + for (int j = 0; i + j <= nSteps; ++j) { + + const float u = static_cast(i) * inv; + const float v = static_cast(j) * inv; + const float w = 1.f - u - v; + + p.set(a.x * w + b.x * u + c.x * v, + a.y * w + b.y * u + c.y * v, + a.z * w + b.z * u + c.z * v); + + markOccupied(p); + if (slopeOk) offerCandidate(p, n); + + ++work; + } + } + } + + if (triCursor_ >= entry.triCount) { + ++meshCursor_; + triCursor_ = 0; + } + } + + if (meshCursor_ >= meshes_.size()) { + work += flushCandidates(); + phase_ = Phase::Chamfer; + } + + return work; + } + + static Vector3& fetch(const FloatBufferAttribute& attr, unsigned int i, Vector3& out) { + + const auto k = static_cast(i); + out.set(attr.getX(k), attr.getY(k), attr.getZ(k)); + return out; + } + + // Named isFinite, not finite: on glibc still declares a + // ::finite(double), and an unqualified call inside a template-heavy + // header is not the place to discover that. + [[nodiscard]] static bool isFinite(const Vector3& v) { + + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); + } + + void markOccupied(const Vector3& p) { + + int ix, iy, iz; + if (!cellOf(p.x, p.y, p.z, ix, iy, iz)) return; + dist_[cellIndex(ix, iy, iz)] = 0; + } + + [[nodiscard]] int columnX(float x) const { + + return static_cast(std::floor((x - bounds_.min().x) / heightCellX_)); + } + + [[nodiscard]] int columnZ(float z) const { + + return static_cast(std::floor((z - bounds_.min().z) / heightCellZ_)); + } + + void markHeightPoint(const Vector3& p) { + + if (height_.empty()) return; + + const int ix = columnX(p.x); + const int iz = columnZ(p.z); + if (ix < 0 || ix >= heightN_ || iz < 0 || iz >= heightN_) return; + + float& h = height_[static_cast(iz) * heightN_ + ix]; + if (p.y > h) h = p.y; + } + + // Conservative XZ rasterisation of one world-space triangle into the + // heightfield, charging one work unit per column tested. The cost is + // proportional to the footprint the triangle actually covers, so a + // tiled terrain pays for its columns exactly once no matter how it is + // tessellated, and a scene-spanning quad is bounded by heightGrid². + std::int64_t rasteriseHeight(const Vector3& a, const Vector3& b, const Vector3& c) { + + if (height_.empty()) return 0; + + int ix0 = columnX(std::min({a.x, b.x, c.x})); + int ix1 = columnX(std::max({a.x, b.x, c.x})); + int iz0 = columnZ(std::min({a.z, b.z, c.z})); + int iz1 = columnZ(std::max({a.z, b.z, c.z})); + if (ix1 < 0 || iz1 < 0 || ix0 >= heightN_ || iz0 >= heightN_) return 1; + + ix0 = std::max(ix0, 0); + iz0 = std::max(iz0, 0); + ix1 = std::min(ix1, heightN_ - 1); + iz1 = std::min(iz1, heightN_ - 1); + + const float det = (b.z - c.z) * (a.x - c.x) + (c.x - b.x) * (a.z - c.z); + + std::int64_t work = 0; + int touched = 0; + + if (std::abs(det) > 1e-12f) { + + const float invDet = 1.f / det; + + for (int iz = iz0; iz <= iz1; ++iz) { + const float pz = bounds_.min().z + (static_cast(iz) + 0.5f) * heightCellZ_; + for (int ix = ix0; ix <= ix1; ++ix) { + const float px = bounds_.min().x + (static_cast(ix) + 0.5f) * heightCellX_; + ++work; + + const float l1 = ((b.z - c.z) * (px - c.x) + (c.x - b.x) * (pz - c.z)) * invDet; + const float l2 = ((c.z - a.z) * (px - c.x) + (a.x - c.x) * (pz - c.z)) * invDet; + const float l3 = 1.f - l1 - l2; + if (l1 < -1e-5f || l2 < -1e-5f || l3 < -1e-5f) continue; + + const float y = l1 * a.y + l2 * b.y + l3 * c.y; + float& h = height_[static_cast(iz) * heightN_ + ix]; + if (y > h) h = y; + ++touched; + } + } + } + + if (touched == 0) { + // Edge-on in XZ, or smaller than a column: the triangle covers no + // column centre at all. Record its vertices instead — a wall's top + // edge is genuinely the highest thing in the column it stands in, + // and dropping it punches a hole in the floor exactly where a + // building is. + markHeightPoint(a); + markHeightPoint(b); + markHeightPoint(c); + work += 3; + } + + return work; + } + + // A sampled candidate from a mesh phase 4 will skip. Slope and thinning + // are settled here, in traversal order; headroom is not, because the + // occupancy field is only complete once every mesh has been sampled. + // The deferred half runs in flushCandidates(), still in insertion order, + // so the accepted set stays a pure function of the traversal. + void offerCandidate(const Vector3& p, const Vector3& n) { + + if (acceptedCount() >= params_.maxPerches) return; + if (!reserveThinCell(p)) return; + pending_.push_back(Candidate{p, n}); + } + + std::int64_t flushCandidates() { + + const auto work = static_cast(pending_.size()) + 1; + + for (const auto& candidate : pending_) { + if (!headroomClear(candidate.position)) continue; + pushSpot(candidate.position, candidate.normal); + } + pending_.clear(); + pending_.shrink_to_fit(); + + return work; + } + + // ── Phase 3 — the chamfer field ────────────────────────────────── + // + // Saturating L1 distance transform by repeated 6-neighbour relaxation, + // DOUBLE-BUFFERED so pass order cannot matter. In-place relaxation would + // propagate a distance several cells in a single sweep along whichever + // axis the loop happens to run, which makes the field depend on the + // iteration order and, worse, on where a step() happened to stop. + std::int64_t relaxChamfer(std::int64_t budget) { + + const auto total = static_cast(dist_.size()); + if (total == 0) { + phase_ = Phase::RayGrid; + return 1; + } + + if (chamferScratch_.size() != dist_.size()) { + chamferScratch_.assign(dist_.size(), 0); + } + + std::int64_t work = 0; + const auto sat = static_cast(saturation_); + const int planeStride = nx_ * ny_; + + while (work < budget && chamferPass_ < saturation_) { + + const auto remaining = static_cast(std::min(budget - work, total - chamferCell_)); + const int end = chamferCell_ + std::max(1, remaining); + const int stop = std::min(end, total); + + for (int c = chamferCell_; c < stop; ++c) { + + std::uint8_t best = dist_[c]; + if (best != 0) { + const int ix = c % nx_; + const int t = c / nx_; + const int iy = t % ny_; + const int iz = t / ny_; + + std::uint8_t m = sat; + if (ix > 0) m = std::min(m, dist_[c - 1]); + if (ix < nx_ - 1) m = std::min(m, dist_[c + 1]); + if (iy > 0) m = std::min(m, dist_[c - nx_]); + if (iy < ny_ - 1) m = std::min(m, dist_[c + nx_]); + if (iz > 0) m = std::min(m, dist_[c - planeStride]); + if (iz < nz_ - 1) m = std::min(m, dist_[c + planeStride]); + + best = static_cast(std::min(best, static_cast(m) + 1)); + } + chamferScratch_[c] = best; + } + + work += (stop - chamferCell_); + chamferCell_ = stop; + + if (chamferCell_ >= total) { + dist_.swap(chamferScratch_); + chamferCell_ = 0; + ++chamferPass_; + } + } + + if (chamferPass_ >= saturation_) { + chamferScratch_.clear(); + chamferScratch_.shrink_to_fit(); + phase_ = Phase::RayGrid; + } + + return std::max(work, 1); + } + + // ── Phase 4 — the BVH ray grid ─────────────────────────────────── + // + // A BVH BUILD IS ATOMIC AND CANNOT BE SPLIT. BVH::buildNode sorts the + // index array at every level with a comparator doing two random-access + // getMidpoint calls into a 36-byte-per-triangle array; at 40 000 + // triangles that is roughly 0.1 s of one frozen frame. bvhMaxTriangles + // therefore defaults to 40 000 and not to the quarter-million a + // "just build everything" design would use — and a mesh above the limit + // is not skipped, it is served by the phase 2 sampler instead. + std::int64_t castProbeGrid(std::int64_t budget) { + + std::int64_t work = 0; + + while (work < budget && rayMesh_ < meshes_.size()) { + + MeshEntry& entry = meshes_[rayMesh_]; + + if (!bvhReady_) { + if (entry.triCount > params_.bvhMaxTriangles || entry.worldBox.isEmpty()) { + ++rayMesh_; + ++work; + continue; + } + + // Deeper than the default cap of 10: at 40 000 triangles a + // depth-10 tree ends with ~40-triangle leaves, and the probe + // grid pays for every one of them. This changes cost only — + // raycast() returns the same closest hit at any depth. + int subdivisions = 10; + while ((1 << subdivisions) * 8 < entry.triCount && subdivisions < 20) ++subdivisions; + + bvh_.emplace(8, subdivisions); + bvh_->build(*entry.geometry); + work += entry.triCount; + + setUpProbeGrid(entry); + bvhReady_ = true; + probeCursor_ = 0; + continue; + } + + const int probeCount = probeNX_ * probeNZ_; + while (work < budget && probeCursor_ < probeCount) { + castProbe(entry, probeCursor_); + ++probeCursor_; + work += 20; + } + + if (probeCursor_ >= probeCount) { + // Destroyed the instant this mesh's rays are done, not at the + // end of the bake: nothing that outlives a step() may hold a + // BufferGeometry*, and BVH holds one (BVH.hpp:96). + bvh_.reset(); + bvhReady_ = false; + ++rayMesh_; + } + } + + if (rayMesh_ >= meshes_.size()) { + bvh_.reset(); + bvhReady_ = false; + phase_ = Phase::Finalise; + } + + return std::max(work, 1); + } + + void setUpProbeGrid(const MeshEntry& entry) { + + const Vector3 size = entry.worldBox.getSize(); + + probeNX_ = std::clamp(static_cast(std::floor(size.x / params_.probeSpacing)) + 1, + 1, params_.maxProbesPerAxis); + probeNZ_ = std::clamp(static_cast(std::floor(size.z / params_.probeSpacing)) + 1, + 1, params_.maxProbesPerAxis); + + probeStepX_ = probeNX_ > 1 ? size.x / static_cast(probeNX_ - 1) : 0.f; + probeStepZ_ = probeNZ_ > 1 ? size.z / static_cast(probeNZ_ - 1) : 0.f; + } + + void castProbe(const MeshEntry& entry, int k) { + + const int ix = k % probeNX_; + const int iz = k / probeNX_; + + const float x = probeNX_ > 1 + ? entry.worldBox.min().x + static_cast(ix) * probeStepX_ + : entry.worldBox.min().x + entry.worldBox.getSize().x * 0.5f; + const float z = probeNZ_ > 1 + ? entry.worldBox.min().z + static_cast(iz) * probeStepZ_ + : entry.worldBox.min().z + entry.worldBox.getSize().z * 0.5f; + + const Vector3 origin{x, entry.worldBox.max().y + 1.f, z}; + const Vector3 down{0, -1, 0}; + const float maxDistance = (entry.worldBox.max().y - entry.worldBox.min().y) + 2.f; + + Matrix4 inverse; + inverse.copy(entry.worldMatrix).invert(); + + Vector3 localOrigin, localDir; + localOrigin.copy(origin).applyMatrix4(inverse); + + // Directions carry no translation, so transform a point one unit along + // the ray instead; its length is how many local units a world unit is. + // (This is AcousticScene::closestHit, which is private — + // src/threepp/audio/Acoustics.cpp:165-204 — reimplemented verbatim.) + localDir.copy(origin).add(down).applyMatrix4(inverse).sub(localOrigin); + const float scale = localDir.length(); + if (scale < 1e-9f) return; + localDir.multiplyScalar(1.f / scale); + + // rayEps is 1e-4 and the effective range is (maxDistance - rayEps) + // (BVH.cpp:14, 291, 338): a probe started exactly on a surface + // legitimately misses. Offset the origin, as the acoustics probe does. + localOrigin.addScaledVector(localDir, 1e-4f); + + const auto hit = bvh_->raycast(Ray(localOrigin, localDir), maxDistance * scale); + if (!hit) return; + + Vector3 point = hit->point; + point.applyMatrix4(entry.worldMatrix); + + Matrix3 normalMatrix; + normalMatrix.getNormalMatrix(entry.worldMatrix); + Vector3 normal = hit->normal; + normal.applyNormalMatrix(normalMatrix); + + // BVH::RayHit::normal is ALREADY flipped toward the ray origin + // (BVH.hpp:47), unlike Intersection::face->normal, which is neither + // flipped nor in world space. Mixing the two conventions silently + // inverts half the perch set; this bake uses only the BVH one. The + // guard below exists solely for a mirrored (negative-determinant) + // world matrix, which the normal matrix can flip back over. + if (normal.y < 0.f) normal.negate(); + + if (!isFinite(point) || !isFinite(normal)) return; + + offerHit(point, normal); + } + + // ── Acceptance and thinning (§6.5) ─────────────────────────────── + void offerHit(const Vector3& p, const Vector3& n) { + + if (acceptedCount() >= params_.maxPerches) return; + if (!(n.y > std::cos(params_.maxSlope))) return; + if (!headroomClear(p)) return; + if (!reserveThinCell(p)) return; + + pushSpot(p, n); + } + + [[nodiscard]] int acceptedCount() const { + + return static_cast(spots_.size() + pending_.size()); + } + + void pushSpot(const Vector3& p, const Vector3& n) { + + PerchSpot spot; + spot.position = p; + spot.normal = n; + spot.walkable = n.y > std::cos(params_.walkableSlope); + spot.ground = (p.y - heightAt(p.x, p.z)) < params_.groundEpsilon; + spots_.push_back(spot); + } + + // HEADROOM MUST EXEMPT THE SPOT'S OWN CELL, AND THIS IS THE ONE PLACE THE + // OBVIOUS READING PRODUCES A BAKE WITH ZERO PERCHES IN IT. + // + // The cells are 2 m and the low headroom sample is 0.30 m up, so + // p + (0, headroomLow, 0) almost always lands in the SAME cell as the + // spot — the cell the surface itself occupies, and therefore always + // marked. Tested naively, every perch on every flat roof in the scene is + // rejected, the spot table comes back empty, nothing logs, and the bug + // surfaces days later as "my birds never land". A sample that resolves to + // the spot's own cell is treated as clear; the test then does what it was + // meant to do, which is reject spots under an overhang or a canopy. + [[nodiscard]] bool headroomClear(const Vector3& p) const { + + if (dist_.empty()) return true; + + int sx, sy, sz; + const bool inside = cellOf(p.x, p.y, p.z, sx, sy, sz); + + auto clear = [&](float dy) { + int ix, iy, iz; + if (!cellOf(p.x, p.y + dy, p.z, ix, iy, iz)) return true;// outside the grid ⇒ open sky + if (inside && ix == sx && iy == sy && iz == sz) return true; + return dist_[cellIndex(ix, iy, iz)] != 0; + }; + + return clear(params_.headroomLow) && clear(params_.headroomHigh); + } + + // Open-addressed, linearly probed, fixed capacity. Returns false when the + // lattice cell is already taken — an UNTHINNED grid puts birds at exact + // bake centres, evenly spaced and shoulder to shoulder on invisible + // lines, which is the single loudest giveaway that a surface was sampled + // by a program. + bool reserveThinCell(const Vector3& p) { + + if (thinSlots_.empty()) return true; + + const std::uint64_t key = detail::latticeKey(p, 1.f / params_.perchMinSeparation); + const std::uint64_t stored = key + 1; + + std::size_t slot = static_cast(detail::mix64(key)) & thinMask_; + for (std::size_t probe = 0; probe <= thinMask_; ++probe) { + if (thinSlots_[slot] == 0) { + thinSlots_[slot] = stored; + return true; + } + if (thinSlots_[slot] == stored) return false; + slot = (slot + 1) & thinMask_; + } + return false;// table full — the maxPerches cap makes this unreachable + } + + // ── Phase 5 — finalise ─────────────────────────────────────────── + std::int64_t finalise() { + + sortSpotsByMorton(); + buildSpotGrid(); + clearScratch(); + + phase_ = Phase::Done; + complete_ = true; + return 1; + } + + // Morton order over the quantised position, ties by insertion index + // (std::stable_sort gives the tie rule for free). Spots that are near + // each other in space end up near each other in the table, which is what + // makes the per-cell scan in querySpots cache-friendly — and it is a pure + // function of the accepted set, so it costs the determinism contract + // nothing. + void sortSpotsByMorton() { + + if (spots_.size() < 2) return; + + Box3 spotBox; + spotBox.makeEmpty(); + for (const auto& s : spots_) spotBox.expandByPoint(s.position); + + const Vector3 size = spotBox.getSize(); + const float sx = size.x > 1e-6f ? 2097151.f / size.x : 0.f; + const float sy = size.y > 1e-6f ? 2097151.f / size.y : 0.f; + const float sz = size.z > 1e-6f ? 2097151.f / size.z : 0.f; + + std::vector keys(spots_.size()); + for (std::size_t i = 0; i < spots_.size(); ++i) { + const Vector3& p = spots_[i].position; + const auto qx = static_cast(std::clamp((p.x - spotBox.min().x) * sx, 0.f, 2097151.f)); + const auto qy = static_cast(std::clamp((p.y - spotBox.min().y) * sy, 0.f, 2097151.f)); + const auto qz = static_cast(std::clamp((p.z - spotBox.min().z) * sz, 0.f, 2097151.f)); + keys[i] = detail::morton3(qx, qy, qz); + } + + std::vector order(spots_.size()); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), + [&](int lhs, int rhs) { return keys[lhs] < keys[rhs]; }); + + std::vector sorted; + sorted.reserve(spots_.size()); + for (const int i : order) sorted.push_back(spots_[static_cast(i)]); + spots_.swap(sorted); + } + + // ── Spot lookup grid ───────────────────────────────────────────── + // + // A CSR bucket grid rebuilt whenever the table changes. querySpots() is + // called a few times per bird per second, so it has to be allocation-free + // — which rules out building this lazily inside a const query. + void clearSpotGrid() { + + qStart_.clear(); + qStart_.shrink_to_fit(); + qIndex_.clear(); + qIndex_.shrink_to_fit(); + qn_ = 0; + qMin_.set(0, 0, 0); + qCell_.set(1, 1, 1); + } + + void buildSpotGrid() { + + clearSpotGrid(); + if (spots_.empty()) return; + + Box3 box; + box.makeEmpty(); + for (const auto& s : spots_) box.expandByPoint(s.position); + if (box.isEmpty()) return; + + const Vector3 size = box.getSize(); + qn_ = std::clamp(static_cast(std::cbrt(static_cast(spots_.size()))) + 1, 1, 32); + qMin_.copy(box.min()); + qCell_.set(std::max(size.x, 1e-3f) / static_cast(qn_), + std::max(size.y, 1e-3f) / static_cast(qn_), + std::max(size.z, 1e-3f) / static_cast(qn_)); + + const int cells = qn_ * qn_ * qn_; + qStart_.assign(static_cast(cells) + 1, 0); + qIndex_.assign(spots_.size(), 0); + + std::vector cellOfSpot(spots_.size(), 0); + for (std::size_t i = 0; i < spots_.size(); ++i) { + const Vector3& p = spots_[i].position; + const int ix = qClamp(p.x, 0); + const int iy = qClamp(p.y, 1); + const int iz = qClamp(p.z, 2); + const int c = (iz * qn_ + iy) * qn_ + ix; + cellOfSpot[i] = c; + ++qStart_[static_cast(c) + 1]; + } + for (int c = 0; c < cells; ++c) qStart_[static_cast(c) + 1] += qStart_[static_cast(c)]; + + std::vector cursor(qStart_.begin(), qStart_.end() - 1); + for (std::size_t i = 0; i < spots_.size(); ++i) { + qIndex_[static_cast(cursor[static_cast(cellOfSpot[i])]++)] = static_cast(i); + } + } + + [[nodiscard]] int qClamp(float v, int axis) const { + + const float base = axis == 0 ? qMin_.x : (axis == 1 ? qMin_.y : qMin_.z); + const float cell = axis == 0 ? qCell_.x : (axis == 1 ? qCell_.y : qCell_.z); + return std::clamp(static_cast(std::floor((v - base) / cell)), 0, qn_ - 1); + } + + // ── Scratch teardown ───────────────────────────────────────────── + // + // Everything here holds, directly or indirectly, a pointer into the host + // scene. After this runs the index is a set of plain world-space values, + // and nothing the host does to its scene can make a query misbehave. + void clearScratch() { + + bvh_.reset(); + bvhReady_ = false; + + meshes_.clear(); + meshes_.shrink_to_fit(); + pending_.clear(); + pending_.shrink_to_fit(); + thinSlots_.clear(); + thinSlots_.shrink_to_fit(); + thinMask_ = 0; + + meshCursor_ = 0; + triCursor_ = 0; + sampleTriDone_ = 0; + sampleTriTotal_ = 0; + chamferPass_ = 0; + chamferCell_ = 0; + rayMesh_ = 0; + probeCursor_ = 0; + probeNX_ = 0; + probeNZ_ = 0; + probeStepX_ = 0.f; + probeStepZ_ = 0.f; + } + }; + +}// namespace threepp::fauna + +#endif// THREEPP_EXTRAS_FAUNA_PERCHINDEX_HPP From 012de5334e5758a2cbb2f889f10a88846b99889b Mon Sep 17 00:00:00 2001 From: Lars Ivar Hatledal Date: Sat, 15 Aug 2026 21:14:59 +0200 Subject: [PATCH 2/4] vulkan: a mesh rebaked every frame stops draining the device for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skinned, tet, displaced and grass meshes have always deformed inside the frame command buffer. A PLAIN mesh whose attributes are rewritten every frame — Flock's merged bird mesh, a CPU trail, a host-driven soft body — fell to the occasional-edit path instead and paid, per dirty frame, one vkDeviceWaitIdle plus two one-shot submit+vkQueueWaitIdle pairs. Three full device drains to move 1.7k vertices, and the drains cost roughly a GPU frame each because that is what they wait for. The particle billboards' explicit "never flag them geomDirty, it would fire a per-frame vkDeviceWaitIdle" exclusion was a patch over the same hole. A BlasRecord dirty kDynamicGraduationStreak (3) frames in a row now graduates to perFrameDynamic for the rest of its life and takes recordDynamicGeomRefits instead: positions and normals are packed into a per-frames-in-flight staging ring at RECORD time — past that slot's fence, which is what makes the host write provably not race the GPU copy a still-in-flight frame issued from the same slot — then the vertex->prevVertex snapshot, the staging->vertex/normal copies and the batched BLAS refit are recorded into the frame cb with barriers. Zero submits, zero waits. Graduation is one-way; a topology change destroys the record and its replacement starts cold. refreshGeomBlasBatch and its drain remain for genuinely occasional edits, and only run when a non-graduated op needs them. No cross-frame WAR fence before the copies, on purpose. The prior frame may still be reading vertex/normal when they execute, but a barrier wide enough to cover every reader (deferred_shade's ray-query fetches are COMPUTE) also fences the previous frame's whole post chain: measured +6 ms/frame, handing back everything the drain removal bought. Every existing deformer already writes its BLAS buffers under exactly this exposure; this path matches their contract rather than inventing a stricter one it cannot afford. Auto-LOD stops churning on deforming geometry. A chain enqueued while a mesh is being edited is stale on arrival — drainLodResults drops it on the geomVersion mismatch, the dirty pass resets lodState, selection re-enqueues, forever — so a full attribute snapshot plus a background simplification were burned every frame and nothing was ever selectable. Selection now requires a quiet window (kLodDirtyQuietFrames, 8) since the last edit and skips graduated records outright. Measured on flock_demo --vulkan, 24 birds, RTX 4070, headless with presents suppressed, slopes taken at 600/1200/2400 frames so the teardown constant falls out: 5.11 -> 4.53 ms/frame, against a 4.40 ms GPU floor the new path now sits on (frame.0_fenceWait absorbs the rest, which is what healthy GPU-bound pipelining looks like). The win scales with GPU frame time, since that is what a drain waits for. Naive wall-clock A/B says the opposite and is wrong three times over: an occluded window's present paces the whole pipelined loop, a per-frame device drain accidentally bypasses that pacing, and a pipelined exit carries a ~4.3 s teardown constant an empty scene pays too. THREEPP_VULKAN_SUPPRESS_PRESENT only engages on a headless canvas. Verified: editor --selftest ALL PASS on GL and Vulkan; flock_demo --vulkan strict validation clean (exit 0); GL and Vulkan captures of the same deterministic frame agree. Co-Authored-By: Claude Fable 5 --- .../renderers/vulkan/VulkanCoreGeometry.cpp | 269 +++++++++++++++++- .../renderers/vulkan/VulkanCoreImpl.hpp | 22 ++ .../renderers/vulkan/VulkanCoreRecord.cpp | 9 + .../renderers/vulkan/VulkanCoreScene.cpp | 147 ++++++++-- .../renderers/vulkan/VulkanGeometryState.hpp | 37 +++ 5 files changed, 458 insertions(+), 26 deletions(-) diff --git a/src/threepp/renderers/vulkan/VulkanCoreGeometry.cpp b/src/threepp/renderers/vulkan/VulkanCoreGeometry.cpp index e869e88ce..5fc1d012c 100644 --- a/src/threepp/renderers/vulkan/VulkanCoreGeometry.cpp +++ b/src/threepp/renderers/vulkan/VulkanCoreGeometry.cpp @@ -123,14 +123,18 @@ std::unique_ptr VulkanRenderer::Impl::buildBla // directly as vertex / index buffers — no duplication, no extra // upload, and the raster prepass + RT shadow rays warm the same // cache lines. TRANSFER_SRC_BIT for displaced meshes that need - // to vkCmdCopyBuffer the current vertex into prev each frame. + // to vkCmdCopyBuffer the current vertex into prev each frame; + // TRANSFER_DST_BIT for graduated per-frame dynamic records whose + // new positions arrive by GPU copy from staging instead of a + // host memcpy (recordDynamicGeomRefits). const VkBufferUsageFlags geomUsage = VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT | - VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + VK_BUFFER_USAGE_TRANSFER_SRC_BIT | + VK_BUFFER_USAGE_TRANSFER_DST_BIT; auto rec = std::make_unique(); @@ -166,7 +170,8 @@ std::unique_ptr VulkanRenderer::Impl::buildBla ctx->allocator(), ctx->device(), nbBytes, VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | - VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | + VK_BUFFER_USAGE_TRANSFER_DST_BIT, VMA_MEMORY_USAGE_AUTO, VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT); uploadHostVisible(ctx->allocator(), rec->normal, nrmSrc, nbBytes); @@ -1084,6 +1089,264 @@ void VulkanRenderer::Impl::refreshGeomBlasBatch(const std::vector liveOps; + liveOps.reserve(pendingDynamicGeomRefits_.size()); + for (size_t k = 0; k < pendingDynamicGeomRefits_.size(); ++k) { + const auto& geom = *pendingDynamicGeomRefits_[k].geom; + auto* posAttr = geom.getAttribute("position"); + if (!posAttr || !geom.getAttribute("normal")) continue; + bool ok = true; + const auto& p = posAttr->array(); + for (size_t i = 0; i < p.size(); ++i) { + if (!std::isfinite(p[i])) { + std::cerr << "[VulkanRenderer] recordDynamicGeomRefits: skipping geom - " + << "position[" << i << "] is non-finite (" << p[i] << ")\n"; + ok = false; + break; + } + } + if (ok) liveOps.push_back(k); + } + + // Phase 1 — host: pack this frame's positions/normals into slot + // `currentFrame` of each record's staging ring. We are RECORDING, + // i.e. past the inFlight[currentFrame] fence wait, so this slot's + // previous consumer (frame currentFrame − kFramesInFlight) has + // fully executed its copies — the memcpy cannot race the GPU. + // That fence is the entire reason the staging ring exists: writing + // rec.vertex from the host directly is exactly the mid-flight + // mutation the drain-based path pays vkDeviceWaitIdle to avoid. + for (size_t k : liveOps) { + const auto& geom = *pendingDynamicGeomRefits_[k].geom; + auto& rec = *pendingDynamicGeomRefits_[k].rec; + auto* posAttr = geom.getAttribute("position"); + auto* nrmAttr = geom.getAttribute("normal"); + const VkDeviceSize slotOff = rec.dynStagingSlotBytes * currentFrame; + const VkDeviceSize posBytes = rec.vbBytes; + uploadHostVisible(ctx->allocator(), rec.dynStaging, + posAttr->array().data(), posBytes, slotOff); + if (rec.packedMask & 1u) { + const auto& nrm = nrmAttr->array(); + std::vector packed(rec.vertexCount); + for (uint32_t v = 0; v < rec.vertexCount; ++v) { + const auto [ox, oy] = octEncode(nrm[v * 3u + 0], nrm[v * 3u + 1], nrm[v * 3u + 2]); + packed[v] = packSnorm2x16(ox, oy); + } + uploadHostVisible(ctx->allocator(), rec.dynStaging, packed.data(), + packed.size() * sizeof(uint32_t), slotOff + posBytes); + } else { + uploadHostVisible(ctx->allocator(), rec.dynStaging, nrmAttr->array().data(), + nrmAttr->array().size() * sizeof(float), slotOff + posBytes); + } + // The whole array travels every frame on this path — consume + // updateRange exactly like the host-write path does, so "no + // range set later" keeps meaning "all of it" on both routes. + const_cast(posAttr)->updateRange.count = -1; + const_cast(nrmAttr)->updateRange.count = -1; + // Version bookkeeping here, not at enqueue: if a topology + // change elsewhere aborts to the structural rebuild after the + // enqueue, the stale version makes that rebuild re-admit this + // geometry from its current CPU data instead of trusting a + // buffer the cleared pending list never filled. + rec.geomVersion = geomVersionOf(geom); + } + + const bool anyRefit = !liveOps.empty(); + if (!anyRefit && pendingDynamicPrevResyncs_.empty()) { + pendingDynamicGeomRefits_.clear(); + return; + } + + // NO cross-frame WAR fence here, on purpose — and it was not an + // oversight the first time either. The prior frame may still be + // reading vertex/normal/prevVertex when these copies execute; a + // barrier wide enough to cover every reader (deferred_shade's + // ray-query fetches are COMPUTE, so the mask would have to fence + // compute) also fences the previous frame's entire post chain — + // measured +6 ms/frame, handing back everything the drain removal + // bought. The skinned/tet/displaced/grass deformers have always + // rewritten their BLAS vertex buffers here under exactly this + // exposure: the frame model (present-block at frame end, deforms + // recorded first) keeps the window closed in practice, and this + // path deliberately matches their contract rather than inventing + // a stricter one. + + // Phase 3 — motion snapshot: vertex → prevVertex. For refit ops + // that is frame N−1's positions (the change frame's motion base); + // for resync-only ops vertex already holds the settled positions, + // so prevVertex == vertex and motion collapses back to zero — the + // frame-cb twin of the prevVertexResyncPending pass. + for (size_t k : liveOps) { + auto& rec = *pendingDynamicGeomRefits_[k].rec; + if (rec.prevVertex.handle == VK_NULL_HANDLE) continue; + VkBufferCopy region{}; + region.size = rec.vbBytes; + vkCmdCopyBuffer(cb, rec.vertex.handle, rec.prevVertex.handle, 1, ®ion); + } + for (auto* rec : pendingDynamicPrevResyncs_) { + if (rec->prevVertex.handle == VK_NULL_HANDLE) continue; + VkBufferCopy region{}; + region.size = rec->vbBytes; + vkCmdCopyBuffer(cb, rec->vertex.handle, rec->prevVertex.handle, 1, ®ion); + } + + if (anyRefit) { + // Phase 4 — the snapshot READ vertex; the staging copy is about + // to WRITE it. Another execution-only WAR fence. + { + VkMemoryBarrier2 mb{}; + mb.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2; + mb.srcStageMask = VK_PIPELINE_STAGE_2_COPY_BIT; + mb.srcAccessMask = 0; + mb.dstStageMask = VK_PIPELINE_STAGE_2_COPY_BIT; + mb.dstAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT; + VkDependencyInfo dep{}; + dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO; + dep.memoryBarrierCount = 1; + dep.pMemoryBarriers = &mb; + vkCmdPipelineBarrier2(cb, &dep); + } + for (size_t k : liveOps) { + auto& rec = *pendingDynamicGeomRefits_[k].rec; + const VkDeviceSize slotOff = rec.dynStagingSlotBytes * currentFrame; + VkBufferCopy pr{}; + pr.srcOffset = slotOff; + pr.size = rec.vbBytes; + vkCmdCopyBuffer(cb, rec.dynStaging.handle, rec.vertex.handle, 1, &pr); + VkBufferCopy nr{}; + nr.srcOffset = slotOff + rec.vbBytes; + nr.size = rec.dynStagingSlotBytes - rec.vbBytes; + vkCmdCopyBuffer(cb, rec.dynStaging.handle, rec.normal.handle, 1, &nr); + } + } + + // Phase 5 — publish the copies: the BLAS refit reads vertex as + // build input, the raster prepass reads vertex/normal/prevVertex + // as vertex attributes, chit/probe fetch them as storage. + { + VkMemoryBarrier2 mb{}; + mb.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2; + mb.srcStageMask = VK_PIPELINE_STAGE_2_COPY_BIT; + mb.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT; + mb.dstStageMask = VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR | + VK_PIPELINE_STAGE_2_VERTEX_ATTRIBUTE_INPUT_BIT | + VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR | + VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; + mb.dstAccessMask = VK_ACCESS_2_ACCELERATION_STRUCTURE_READ_BIT_KHR | + VK_ACCESS_2_VERTEX_ATTRIBUTE_READ_BIT | + VK_ACCESS_2_SHADER_STORAGE_READ_BIT; + VkDependencyInfo dep{}; + dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO; + dep.memoryBarrierCount = 1; + dep.pMemoryBarriers = &mb; + vkCmdPipelineBarrier2(cb, &dep); + } + + if (anyRefit) { + // Phase 6 — batched BLAS refit, the recorded twin of + // refreshGeomBlasBatch's Phase D. The build-info structs are + // consumed at record time, so stack storage is enough here. + const uint32_t N = static_cast(liveOps.size()); + std::vector triDatas(N); + std::vector blasGeoms(N); + std::vector blasBuilds(N); + std::vector ranges(N); + std::vector rangePtrs(N); + for (uint32_t kk = 0; kk < N; ++kk) { + auto& rec = *pendingDynamicGeomRefits_[liveOps[kk]].rec; + const bool indexed = rec.indexCount != 0u; + const uint32_t primitiveCount = + (indexed ? rec.indexCount : rec.vertexCount) / 3u; + + triDatas[kk].sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_TRIANGLES_DATA_KHR; + triDatas[kk].vertexFormat = VK_FORMAT_R32G32B32_SFLOAT; + triDatas[kk].vertexData.deviceAddress = rec.vertex.address; + triDatas[kk].vertexStride = 3 * sizeof(float); + triDatas[kk].maxVertex = rec.vertexCount - 1; + if (indexed) { + triDatas[kk].indexType = (rec.packedMask & 8u) ? VK_INDEX_TYPE_UINT16 + : VK_INDEX_TYPE_UINT32; + triDatas[kk].indexData.deviceAddress = rec.index.address; + } else { + triDatas[kk].indexType = VK_INDEX_TYPE_NONE_KHR; + } + + blasGeoms[kk].sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR; + blasGeoms[kk].geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR; + blasGeoms[kk].geometry.triangles = triDatas[kk]; + blasGeoms[kk].flags = 0; + + const bool fullRebuild = + rec.blasRefitCounter >= BlasRecord::kBlasFullRebuildInterval; + rec.blasRefitCounter = fullRebuild ? 0u : (rec.blasRefitCounter + 1u); + + blasBuilds[kk].sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR; + blasBuilds[kk].type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR; + blasBuilds[kk].flags = VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_TRACE_BIT_KHR | + VK_BUILD_ACCELERATION_STRUCTURE_ALLOW_UPDATE_BIT_KHR; + blasBuilds[kk].mode = fullRebuild + ? VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR + : VK_BUILD_ACCELERATION_STRUCTURE_MODE_UPDATE_KHR; + blasBuilds[kk].geometryCount = 1; + blasBuilds[kk].pGeometries = &blasGeoms[kk]; + blasBuilds[kk].srcAccelerationStructure = fullRebuild ? VK_NULL_HANDLE : rec.as; + blasBuilds[kk].dstAccelerationStructure = rec.as; + + VkAccelerationStructureBuildSizesInfoKHR blasSizes{}; + blasSizes.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR; + ctx->rt().getAccelerationStructureBuildSizes( + ctx->device(), + VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR, + &blasBuilds[kk], &primitiveCount, &blasSizes); + // Scratch grows via retire(), never destroyBuffer: a still- + // in-flight frame's refit may be reading the old one. Cold + // path — graduation follows ≥3 drained refreshes, which + // already sized it. + if (rec.blasScratch.handle == VK_NULL_HANDLE || + rec.blasScratchSize < blasSizes.buildScratchSize) { + retire(std::move(rec.blasScratch)); + rec.blasScratch = createAsScratchBuffer( + ctx->allocator(), ctx->device(), blasSizes.buildScratchSize); + rec.blasScratchSize = blasSizes.buildScratchSize; + } + blasBuilds[kk].scratchData.deviceAddress = rec.blasScratch.address; + + ranges[kk].primitiveCount = primitiveCount; + rangePtrs[kk] = &ranges[kk]; + } + ctx->rt().cmdBuildAccelerationStructures(cb, N, blasBuilds.data(), rangePtrs.data()); + + // Phase 7 — AS write → AS read: the TLAS refit recorded later + // in recordDeformAndTlas consumes these BLASes this same frame. + { + VkMemoryBarrier2 mb{}; + mb.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2; + mb.srcStageMask = VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR; + mb.srcAccessMask = VK_ACCESS_2_ACCELERATION_STRUCTURE_WRITE_BIT_KHR; + mb.dstStageMask = VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR | + VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR; + mb.dstAccessMask = VK_ACCESS_2_ACCELERATION_STRUCTURE_READ_BIT_KHR; + VkDependencyInfo dep{}; + dep.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO; + dep.memoryBarrierCount = 1; + dep.pMemoryBarriers = &mb; + vkCmdPipelineBarrier2(cb, &dep); + } + } + + pendingDynamicGeomRefits_.clear(); + pendingDynamicPrevResyncs_.clear(); + } + void VulkanRenderer::Impl::refreshMorphedBlas(Mesh& mesh, MorphedMeshState& st) { cpuMorphBlend(mesh, st.blendedPositions, st.blendedNormals); if (st.blendedPositions.empty() || !st.blas) return; diff --git a/src/threepp/renderers/vulkan/VulkanCoreImpl.hpp b/src/threepp/renderers/vulkan/VulkanCoreImpl.hpp index cf344eb91..6059ac3b6 100644 --- a/src/threepp/renderers/vulkan/VulkanCoreImpl.hpp +++ b/src/threepp/renderers/vulkan/VulkanCoreImpl.hpp @@ -312,6 +312,18 @@ namespace threepp { // pattern as pendingSkinnedRebuilds_. Cleared at end of recordCommandBuffer. std::vector> pendingGrassDeforms_; + // Graduated per-frame dynamic plain meshes (BlasRecord::perFrameDynamic) + // whose attributes changed this frame — staging upload + GPU copy + + // batched BLAS refit recorded into the frame cb by + // recordDynamicGeomRefits, same pattern as pendingSkinnedRebuilds_. + // Cleared there. + std::vector pendingDynamicGeomRefits_; + // Graduated records whose first CLEAN frame follows a dirty run — + // recordDynamicGeomRefits copies vertex→prevVertex once so their + // motion vectors collapse back to zero (frame-cb twin of the + // prevVertexResyncPending pass). + std::vector pendingDynamicPrevResyncs_; + // DisplacedMesh (FFT water) deforms queued in ensureSceneBuilt and // recorded into the frame command buffer by recordCommandBuffer — the // same no-mid-frame-submit pattern as pendingGrassDeforms_. The float @@ -959,6 +971,7 @@ namespace threepp { destroyBuffer(ctx->allocator(), rec.color); destroyBuffer(ctx->allocator(), rec.prevVertex); destroyBuffer(ctx->allocator(), rec.blasScratch); + destroyBuffer(ctx->allocator(), rec.dynStaging); } // Cached CDF blob (16 floats per tri) reused across frames when no @@ -2550,6 +2563,15 @@ namespace threepp { // closest_hit reads of rec.vertex otherwise. void refreshGeomBlasBatch(const std::vector& ops); + // Frame-cb twin of refreshGeomBlasBatch for graduated records + // (BlasRecord::perFrameDynamic): CPU-packs new positions/normals into + // this frame's staging slot, then records vertex→prevVertex snapshot, + // staging→vertex/normal copies and the batched BLAS refit into `cb` + // with barriers — zero extra submits, zero waits. Also records the + // one-frame prevVertex re-sync for pendingDynamicPrevResyncs_. + // Consumes (clears) both pending lists. + void recordDynamicGeomRefits(VkCommandBuffer cb); + // ── Morph-target helpers ───────────────────────────────────────── static bool isMorphedMesh(const Mesh& m) { diff --git a/src/threepp/renderers/vulkan/VulkanCoreRecord.cpp b/src/threepp/renderers/vulkan/VulkanCoreRecord.cpp index cc450e683..8214b4980 100644 --- a/src/threepp/renderers/vulkan/VulkanCoreRecord.cpp +++ b/src/threepp/renderers/vulkan/VulkanCoreRecord.cpp @@ -43,6 +43,15 @@ void VulkanRenderer::Impl::updatePaneRegion() { } void VulkanRenderer::Impl::recordDeformAndTlas(VkCommandBuffer cb) { + // ── Graduated per-frame dynamic plain meshes ─────────────────── + // CPU deformers that rebake their vertices every frame (Flock's + // merged bird mesh) — staging upload + vertex/normal copies + + // batched BLAS refit, recorded like every other deformer here. + // The drain-based refreshGeomBlasBatch now only serves genuinely + // occasional edits. Internal barriers publish to the TLAS refit + // below and the raster/RT reads after it. + recordDynamicGeomRefits(cb); + // ── Skinned-mesh GPU pipeline ────────────────────────────────── // ensureSceneBuilt populated pendingSkinnedRebuilds_ with the // states whose bones changed this frame and uploaded the new diff --git a/src/threepp/renderers/vulkan/VulkanCoreScene.cpp b/src/threepp/renderers/vulkan/VulkanCoreScene.cpp index 1ccaa3cb6..b1707b8f8 100644 --- a/src/threepp/renderers/vulkan/VulkanCoreScene.cpp +++ b/src/threepp/renderers/vulkan/VulkanCoreScene.cpp @@ -936,7 +936,18 @@ void VulkanRenderer::Impl::ensureSceneBuilt(Object3D& scene, Camera& camera) { // buffer (selectLodGeom reports the indexed-ness). const uint32_t triCount = (rec.indexCount != 0u ? rec.indexCount : rec.vertexCount) / 3u; - const bool eligible = triCount >= 1024u; + // A chain enqueued while the geometry is being edited + // is guaranteed stale on arrival: drainLodResults drops + // it on the geomVersion mismatch, the dirty pass resets + // lodState, selection re-enqueues — a full attribute + // snapshot plus a wasted background simplification + // EVERY frame, forever (the Flock churn). Wait out a + // quiet window after the last edit, and never consider + // a graduated per-frame deformer at all. + const bool editQuiet = + !rec.perFrameDynamic && + (frameSerial_ - rec.lastDirtyFrame) > BlasRecord::kLodDirtyQuietFrames; + const bool eligible = triCount >= 1024u && editQuiet; if (eligible && rec.lodState == BlasRecord::LodState::None) { if ((lodIndexBytes_ + lodBlasBytes_) <= kLodByteBudget) { // The enqueue snapshots attribute data, so it needs @@ -1654,11 +1665,18 @@ void VulkanRenderer::Impl::ensureSceneBuilt(Object3D& scene, Camera& camera) { // everything device-wide before mutating shared BLAS // buffers — skinned / displaced / morphed paths above // submit on the same queue, so this one wait covers - // them too. - check(vkDeviceWaitIdle(ctx->device()), "vkDeviceWaitIdle (pre-BLAS-refresh)"); + // them too. Graduated per-frame dynamic records + // (BlasRecord::perFrameDynamic) never take that drain: + // their upload + refit records into the frame cb via + // recordDynamicGeomRefits, behind the fence that + // guarantees their staging slot is idle. So the wait + // only fires when an OCCASIONAL edit needs the + // host-write path — build the op lists first, decide + // after. bool topologyChanged = false; std::unordered_set refreshedGeoms; std::vector refreshOps; + std::vector lodChainDoomed; refreshOps.reserve(entries.size()); for (size_t i = 0; i < entries.size(); ++i) { if (!entryGeomDirty[i]) continue; @@ -1684,34 +1702,103 @@ void VulkanRenderer::Impl::ensureSceneBuilt(Object3D& scene, Camera& camera) { break; } - // An in-place vertex rewrite invalidates any auto-LOD - // chain: the level BLASes BAKE positions (a stale level - // would ray-trace the pre-edit shape) and the chain's - // error bounds measured the old surface. The device-wide - // drain above makes the destroy safe. lodState=None ⇒ - // the selection pass re-enqueues against the new - // geomVersion; selectLodGeom falls back to LOD0 for - // every consumer meanwhile. lodChangedThisFrame_ must be - // forced: selection already ran this frame and may have - // left en.lodLevel > 0 — the EFFECTIVE level changes to - // 0 right here, and without the flag the geomDescs GPU - // patch would skip while the TLAS falls back, leaving a - // stale per-level index address behind. - if (rec.lodState != BlasRecord::LodState::None) { - destroyBlasLodLevels(rec); - lodChangedThisFrame_ = true; + // Streak accounting for graduation: a record dirty + // kDynamicGraduationStreak frames in a row is a + // per-frame CPU deformer (Flock's merged birds, a + // rewritten trail), not an occasional edit, and + // moves to the frame-cb path for the rest of its + // life. + rec.dirtyStreak = (frameSerial_ - rec.lastDirtyFrame <= 1) + ? rec.dirtyStreak + 1u + : 1u; + rec.lastDirtyFrame = frameSerial_; + + auto* nrmAttr = entries[i].mesh->geometry()->getAttribute("normal"); + // The dynamic route needs normals (the same + // requirement refreshGeomBlasBatch enforces) and a + // record with no LOD chain. The quiet-window gate + // in the selection pass stops re-enqueues while a + // mesh deforms, so by graduation time the chain is + // gone — if one exists anyway, the drained branch + // below is the only place it can be destroyed + // safely, so the record stays there this frame. + const bool dynamicRoute = + nrmAttr && rec.lodState == BlasRecord::LodState::None && + (rec.perFrameDynamic || + rec.dirtyStreak >= BlasRecord::kDynamicGraduationStreak); + if (dynamicRoute) { + if (!rec.perFrameDynamic) { + // Graduate: allocate the staging ring, one + // slot per frame in flight, each holding + // positions then normals in the buffers' + // own (possibly packed) formats. + const VkDeviceSize nrmBytes = (rec.packedMask & 1u) + ? VkDeviceSize(rec.vertexCount) * sizeof(uint32_t) + : VkDeviceSize(nrmAttr->array().size()) * sizeof(float); + rec.dynStagingSlotBytes = rec.vbBytes + nrmBytes; + rec.dynStaging = createBuffer( + ctx->allocator(), ctx->device(), + rec.dynStagingSlotBytes * kFramesInFlight, + VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VMA_MEMORY_USAGE_AUTO, + VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT); + rec.perFrameDynamic = true; + // Settling now happens through the frame cb + // — the draining host-side resync pass must + // never touch this record again. + rec.prevVertexResyncPending = false; + std::cerr << "[VulkanRenderer] geometry (" + << rec.vertexCount << " verts) dirty " + << rec.dirtyStreak + << " frames in a row - graduated to the " + "per-frame dynamic path (frame-cb refit, " + "no drains)\n"; + } + rec.dynPrevResyncPending = true; + pendingDynamicGeomRefits_.push_back({geomKey, &rec}); + } else { + // An in-place vertex rewrite invalidates any auto-LOD + // chain: the level BLASes BAKE positions (a stale level + // would ray-trace the pre-edit shape) and the chain's + // error bounds measured the old surface. Destroyed + // past the device-wide drain below — in-flight TLASes + // may still reference a level BLAS. lodState=None ⇒ + // the selection pass re-enqueues against the new + // geomVersion; selectLodGeom falls back to LOD0 for + // every consumer meanwhile. + if (rec.lodState != BlasRecord::LodState::None) + lodChainDoomed.push_back(&rec); + refreshOps.push_back({geomKey, &rec}); } - refreshOps.push_back({entries[i].mesh->geometry().get(), &rec}); refreshedGeoms.insert(geomKey); } if (topologyChanged) { // Vertex/index count changed — can't reuse BLAS // buffers. Fall through to the full structural - // rebuild path below. + // rebuild path below, and drop any dynamic ops + // queued this pass: the rebuild destroys + re-admits + // their records from current CPU data (their + // geomVersion is only stamped at record time, so + // the version mismatch guarantees the re-admit). + pendingDynamicGeomRefits_.clear(); goto fullRebuild; } - refreshGeomBlasBatch(refreshOps); - for (const auto& op : refreshOps) geomRefreshedThisFrame.insert(op.geom); + if (!refreshOps.empty()) { + check(vkDeviceWaitIdle(ctx->device()), "vkDeviceWaitIdle (pre-BLAS-refresh)"); + for (BlasRecord* doomed : lodChainDoomed) { + // lodChangedThisFrame_ must be forced: selection + // already ran this frame and may have left + // en.lodLevel > 0 — the EFFECTIVE level changes + // to 0 right here, and without the flag the + // geomDescs GPU patch would skip while the TLAS + // falls back, leaving a stale per-level index + // address behind. + destroyBlasLodLevels(*doomed); + lodChangedThisFrame_ = true; + } + refreshGeomBlasBatch(refreshOps); + for (const auto& op : refreshOps) geomRefreshedThisFrame.insert(op.geom); + } } // ── prevVertex re-sync ────────────────────────────────── @@ -1731,6 +1818,20 @@ void VulkanRenderer::Impl::ensureSceneBuilt(Object3D& scene, Camera& camera) { std::vector resyncRecs; for (auto& [geomKey, recPtr] : blasCache) { BlasRecord* rec = recPtr.get(); + // Graduated records settle through the frame cb — a + // drain here would defeat the whole path. The first + // CLEAN frame after a dirty run records one + // vertex→prevVertex copy in recordDynamicGeomRefits + // and motion collapses to zero, same contract as + // the host-side pass below. + if (rec->perFrameDynamic) { + if (rec->dynPrevResyncPending && + rec->lastDirtyFrame != frameSerial_) { + pendingDynamicPrevResyncs_.push_back(rec); + rec->dynPrevResyncPending = false; + } + continue; + } if (!rec->prevVertexResyncPending) continue; // Re-snapshotted this frame → keep its change-frame // motion; settle on the next clean frame instead. diff --git a/src/threepp/renderers/vulkan/VulkanGeometryState.hpp b/src/threepp/renderers/vulkan/VulkanGeometryState.hpp index 7eacedc2e..ba997b281 100644 --- a/src/threepp/renderers/vulkan/VulkanGeometryState.hpp +++ b/src/threepp/renderers/vulkan/VulkanGeometryState.hpp @@ -113,6 +113,43 @@ namespace threepp::vulkan::impl { // (runtime-updated geometry stays noisy / visibly shakes). bool prevVertexResyncPending = false; + // ── Per-frame dynamic residency (graduated CPU deformers) ─── + // A plain mesh whose attributes are rewritten + needsUpdate()ed + // EVERY frame (Flock's merged bird mesh, CPU trails) used to pay + // the occasional-edit price every frame: one device-wide drain + // plus two submit+wait one-shots (refreshGeomBlasBatch). After + // kDynamicGraduationStreak consecutive dirty frames the record + // graduates to the residency the skinned/displaced/grass + // deformers already have — staging upload + GPU copy + BLAS + // refit recorded into the frame command buffer, zero drains + // (recordDynamicGeomRefits). Graduation is one-way for the + // record's lifetime; a topology change destroys the record and + // its replacement starts cold. + uint64_t lastDirtyFrame = 0;// frameSerial_ of the latest geom-dirty frame + uint32_t dirtyStreak = 0; // consecutive dirty frames ending at lastDirtyFrame + bool perFrameDynamic = false; + static constexpr uint32_t kDynamicGraduationStreak = 3; + // Auto-LOD stays out of a recently-edited geometry's way: a chain + // enqueued while the mesh deforms is guaranteed stale on arrival + // (drainLodResults drops it, selection re-enqueues, forever). + // Selection only considers a record this many frames after its + // last edit — and never a graduated one. + static constexpr uint64_t kLodDirtyQuietFrames = 8; + // Host-visible staging for the graduated path: kFramesInFlight + // slots of dynStagingSlotBytes (positions then normals, in the + // buffer's own — possibly packed — format). Slot `currentFrame` + // is written on the CPU at record time, i.e. past that slot's + // fence wait, so the write can never race the GPU copy a still- + // in-flight frame issued from the same slot. + Buffer dynStaging{}; + VkDeviceSize dynStagingSlotBytes = 0; + // The graduated path snapshots vertex→prevVertex on every dirty + // frame; the first CLEAN frame afterwards re-syncs prevVertex to + // the settled positions (same shake-forever failure mode as + // prevVertexResyncPending above) — recorded into the frame cb, + // never through the draining host-side resync pass. + bool dynPrevResyncPending = false; + // ── Automatic mesh LOD (setAutoLod) ───────────────────────── // One simplified INDEX buffer + its own static BLAS per chain // level, built beyond this record's own (LOD0) vertex/normal/uv/ From 762fcb14e8ef6d790f5bb2815617040b8215d19e Mon Sep 17 00:00:00 2001 From: Lars Ivar Hatledal Date: Sat, 15 Aug 2026 21:15:23 +0200 Subject: [PATCH 3/4] fauna: the flock's contracts are defended by CI, not by comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flock.hpp states three properties in prose and nothing enforced any of them. Flock_test does, cheaply enough (0.3 s) to sit in every run. Determinism is the headline claim and gets an EXACT float comparison, because "bit-identical for the same binary, the same seed and the same dt sequence" is what the banner promises and an epsilon would test a weaker promise. The replay drives a three-period dt cycle so dtSmoothed never settles and a mid-run startle so the disturbance path is covered, then a second flock on a different seed must DIVERGE — without that control the identity check would also pass for a flock pinned motionless at home. The perch bake's blocking and amortised forms must produce byte-equal tables, which is the whole reason PerchIndex budgets by work-unit count rather than milliseconds: a time budget would make a bird's trajectory depend on machine speed. PerchSpot's defaulted operator== takes that claim literally. The soak asserts a landing actually FIRES, which is the bug class --selftest was built around: a state machine that compiles, runs, produces no NaN and never once perches. One fixture lesson worth stating, because it cost an hour and is not a library bug: authored perches packed closer than about twice separationDistance, with half the flock committed at once, turn the final-metre capture into a shoving match no approach survives — zero landings in 40 s. The first rail put 8 spots 2 m apart in a line; the same 8 on a 14 m circle land immediately. Baked scenes scatter spots and never hit it; a designer authoring one railing through addPerch can. Flock also opts out of renderer auto-LOD. The whole mesh is rebaked in place every update(), so a chain would simplify a pose one frame from gone. The renderer now refuses to build one for a per-frame deformer anyway; this is the feature saying so itself, the way terrain tiles do. Co-Authored-By: Claude Fable 5 --- include/threepp/extras/fauna/Flock.hpp | 4 + tests/extras/CMakeLists.txt | 1 + tests/extras/Flock_test.cpp | 155 +++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 tests/extras/Flock_test.cpp diff --git a/include/threepp/extras/fauna/Flock.hpp b/include/threepp/extras/fauna/Flock.hpp index 7a4bcd3d2..95501de2d 100644 --- a/include/threepp/extras/fauna/Flock.hpp +++ b/include/threepp/extras/fauna/Flock.hpp @@ -244,6 +244,10 @@ namespace threepp { params_(sanitise(params)), tmpl_(fauna::makeBirdTemplate(params_.shape)) { + // The whole mesh is rebaked in place every update(), so any + // renderer-side LOD chain would simplify a pose that is one + // frame from gone. Self-managed detail, like terrain tiles. + autoLod = false; build(); } diff --git a/tests/extras/CMakeLists.txt b/tests/extras/CMakeLists.txt index 7b42ff08c..c7bc218ef 100644 --- a/tests/extras/CMakeLists.txt +++ b/tests/extras/CMakeLists.txt @@ -20,6 +20,7 @@ add_test_executable(EditorUndoRebind_test) add_test_executable(EditorVehicleConfig_test) add_test_executable(EditorVisionPlay_test) add_test_executable(EditorXacroArgs_test) +add_test_executable(Flock_test) add_test_executable(InverseKinematics_test) add_test_executable(PointCloud_test) add_test_executable(Sensor_test) diff --git a/tests/extras/Flock_test.cpp b/tests/extras/Flock_test.cpp new file mode 100644 index 000000000..7164ff9cd --- /dev/null +++ b/tests/extras/Flock_test.cpp @@ -0,0 +1,155 @@ +// Flock_test — the contracts Flock.hpp states in prose, defended by CI. +// +// The headline claim is DETERMINISM: "bit-identical for the same binary, the +// same seed, and the same dt sequence" (Flock.hpp banner). Until now that +// lived only as a comment and a demo-local --selftest nothing runs +// automatically. These cases are deliberately EXACT-equality on floats — the +// contract is bit-identity within one binary, so an epsilon would test a +// weaker promise than the one the header makes. +// +// flock_demo --selftest remains the richer behavioural soak (3600-step +// passes, startle containment against baked scenery); this file keeps the +// fast, scenery-light versions of the properties that must never regress. + +#include + +#include "threepp/extras/fauna/Flock.hpp" +#include "threepp/geometries/BoxGeometry.hpp" +#include "threepp/materials/MeshStandardMaterial.hpp" +#include "threepp/objects/Mesh.hpp" +#include "threepp/scenes/Scene.hpp" + +#include + +using namespace threepp; + +namespace { + + constexpr float kDt = 1.f / 60.f; + + Flock::Params testParams() { + Flock::Params p; + p.seed = 42u; + p.birdCount = 12; + p.home.set(0.f, 10.f, 0.f); + p.roamRadius = 30.f; + // Perch quickly so a short soak sees the whole state machine: + // Cruise → Approach → Flare → Perched → Launch. + p.perchIntervalMin = 1.f; + p.perchIntervalMax = 4.f; + p.restIntervalMin = 1.f; + p.restIntervalMax = 3.f; + return p; + } + + void addRailPerches(Flock& f) { + // Authored perches — no scene, no bake, no BVH. setPerches/addPerch is + // the designed escape hatch, which makes it the cheapest deterministic + // fixture the state machine can land on. SPREAD OUT on purpose: with + // 12 birds and a 0.55 committed cap, six birds approach at once, and + // spots packed closer than ~2× separationDistance turn the final-metre + // capture into a shoving match no approach survives. + for (int i = 0; i < 8; ++i) { + const float a = static_cast(i) * (math::TWO_PI / 8.f); + f.addPerch({14.f * std::cos(a), 2.f + 0.5f * static_cast(i % 3), + 14.f * std::sin(a)}, + {0.f, 1.f, 0.f}, true); + } + } + + // One shared script for the replay pair: identical dt sequence (three + // periods, so dtSmoothed never settles), identical mid-run startle. + void runScript(Flock& f) { + constexpr float dts[3] = {1.f / 60.f, 1.f / 45.f, 1.f / 90.f}; + for (int step = 0; step < 900; ++step) { + if (step == 400) f.startle({0.f, 10.f, 0.f}, 1e9f, 1.f); + f.update(dts[step % 3]); + } + } + + bool bitIdentical(const Flock& a, const Flock& b) { + if (a.birdCount() != b.birdCount()) return false; + for (int i = 0; i < a.birdCount(); ++i) { + const Vector3 &pa = a.birdPosition(i), &pb = b.birdPosition(i); + const Vector3 &va = a.birdVelocity(i), &vb = b.birdVelocity(i); + // Exact on purpose — see the file banner. + if (pa.x != pb.x || pa.y != pb.y || pa.z != pb.z) return false; + if (va.x != vb.x || va.y != vb.y || va.z != vb.z) return false; + if (a.stateOf(i) != b.stateOf(i)) return false; + } + return true; + } + +}// namespace + +TEST_CASE("Flock: same seed + same dt sequence replays bit-identically") { + + auto a = Flock::create(testParams()); + auto b = Flock::create(testParams()); + addRailPerches(*a); + addRailPerches(*b); + + runScript(*a); + runScript(*b); + + REQUIRE(bitIdentical(*a, *b)); + // A different seed must actually diverge, or the check above proves + // nothing (a flock pinned at home would also "replay" perfectly). + auto c = Flock::create([] { auto p = testParams(); p.seed = 43u; return p; }()); + addRailPerches(*c); + runScript(*c); + REQUIRE_FALSE(bitIdentical(*a, *c)); +} + +TEST_CASE("PerchIndex: blocking and amortised bakes produce identical tables") { + + Scene scene; + auto mat = MeshStandardMaterial::create(); + for (int i = 0; i < 3; ++i) { + auto box = Mesh::create(BoxGeometry::create(4.f, 2.f + i, 4.f), mat); + box->position.set(-8.f + 8.f * static_cast(i), 0.5f * (2.f + i), 0.f); + scene.add(box); + } + + fauna::PerchIndex blocking; + fauna::PerchIndex::Params pp; + blocking.bakeBlocking(scene, pp, nullptr, nullptr); + + fauna::PerchIndex amortised; + amortised.begin(scene, pp, nullptr, nullptr); + int steps = 0; + while (!amortised.step()) { + REQUIRE(++steps < 100000);// a bake that never completes is its own failure + } + + REQUIRE(blocking.spots().size() > 0); + // PerchSpot's defaulted operator== is exact float compare — the banner's + // "bit-identical perch tables" claim, taken literally. + REQUIRE(blocking.spots() == amortised.spots()); +} + +TEST_CASE("Flock: birds land, stay contained, and never NaN in a short soak") { + + auto flock = Flock::create(testParams()); + addRailPerches(*flock); + const auto& p = flock->params(); + + bool everPerched = false; + bool allFinite = true; + float worstRadius = 0.f; + for (int step = 0; step < 2400; ++step) { + flock->update(kDt); + for (int i = 0; i < flock->birdCount(); ++i) { + const Vector3& pos = flock->birdPosition(i); + allFinite = allFinite && std::isfinite(pos.x) && std::isfinite(pos.y) && + std::isfinite(pos.z); + everPerched = everPerched || flock->stateOf(i) == Flock::BirdState::Perched; + const float dx = pos.x - p.home.x, dz = pos.z - p.home.z; + worstRadius = std::max(worstRadius, std::sqrt(dx * dx + dz * dz)); + } + } + + REQUIRE(allFinite); + REQUIRE(everPerched);// the landing pipeline fired — the bug --selftest exists to catch + REQUIRE(worstRadius <= 1.5f * p.roamRadius); +} From c5258100e530a0537126135093007407a2af088e Mon Sep 17 00:00:00 2001 From: Lars Ivar Hatledal Date: Sat, 15 Aug 2026 21:15:48 +0200 Subject: [PATCH 4/4] editor: a flock is something you place, size and press Play on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add > Flock creates a Group carrying userData["flock"], and the node's POSITION is the territory's home — the transform gizmo is the authoring tool for where the birds live, the way a particle field's node is its emitter frame. The birds are never document nodes: FlockPlaySession builds one Flock per authored node at Play, adds it at the scene root (the simulation is world-space and the mesh wants an identity parent, which Flock.hpp states in capitals), starts an AMORTISED perch bake so a heavy scene costs a few frames of birds-not-landing-yet instead of a hitch on the button, and Stop restores a document that never saw them. A saved scene therefore carries eleven scalars, not 2,256 vertices of bird, and save/load/prefabs/.tpz work for free because the config rides the same userData channel Tree and Granular already round-trip. THE PERCH BAKE MUST NOT SEE THE EDITOR'S OWN CHROME, and this is the bug the wiring was written around. Gizmo handles, light markers and waypoint pucks are real meshes in the graph the bake traverses; heightAt reports the highest sampled surface in a column, so a marker hovering at altitude becomes the ground under it and the floor clamp launches the whole flock onto a phantom floor. Measured over the flat template scene: centroid y=12 -> y=430 within one second of the bake completing, which on Vulkan reads as the birds vanishing upward and on GL as them behaving strangely. FlockPlaySession::setMeshFilter carries the exclusion to Flock::setPerchFilter and EditorApp wires isEditorOnly. Any future session that bakes against the editor scene needs the same filter, so the selftest keeps a trap: after four seconds of play the birds must be under y=60 and above y=-5, bounds no honest cruise band reaches and only a poisoned bake crosses. Selecting a flock draws its extents through the existing spawn-box helper, which now serves three configs: the roam edge and the 0.75x ring where the bounds force starts to bite, the same circle again at expected-ground level with a drop line and tick (a tick off the real floor is the one spatial fact a misplaced flock node gets wrong), the altitude band at +/- cruiseAltitude*altitudeSpread, and the wind arrow. altitudeSpread is surfaced in the inspector for that reason: it was in Flock::Params, it is the thickness of the band the helper draws, and 0 flies a plane that reads as a formation rather than a flock. The new node spawns at y=14, the default cruise altitude, so a fresh flock over a ground-at-origin scene reads as placed right. Verified: editor --selftest ALL PASS on GL and Vulkan (factory, config round trip, undo, helper, and the phantom-floor trap), EditorFlockConfig_test 15 assertions, Flock_test 8. Co-Authored-By: Claude Fable 5 --- apps/editor/EditorApp.cpp | 13 ++ apps/editor/EditorApp.hpp | 4 + apps/editor/EditorSelfTest.cpp | 96 +++++++++++++++ apps/editor/ParticleOverlay.cpp | 62 ++++++++-- apps/editor/panels/HierarchyPanel.cpp | 5 + apps/editor/panels/InspectorPanel.cpp | 53 ++++++++ include/threepp/extras/editor/FlockConfig.hpp | 88 ++++++++++++++ .../extras/editor/FlockPlaySession.hpp | 115 ++++++++++++++++++ .../threepp/extras/editor/ObjectFactory.hpp | 5 + src/CMakeLists.txt | 1 + src/threepp/extras/editor/FlockConfig.cpp | 89 ++++++++++++++ src/threepp/extras/editor/ObjectFactory.cpp | 17 +++ tests/extras/CMakeLists.txt | 1 + tests/extras/EditorFlockConfig_test.cpp | 97 +++++++++++++++ 14 files changed, 639 insertions(+), 7 deletions(-) create mode 100644 include/threepp/extras/editor/FlockConfig.hpp create mode 100644 include/threepp/extras/editor/FlockPlaySession.hpp create mode 100644 src/threepp/extras/editor/FlockConfig.cpp create mode 100644 tests/extras/EditorFlockConfig_test.cpp diff --git a/apps/editor/EditorApp.cpp b/apps/editor/EditorApp.cpp index 7fdeb26c2..ebdb32603 100644 --- a/apps/editor/EditorApp.cpp +++ b/apps/editor/EditorApp.cpp @@ -11,6 +11,7 @@ #include "threepp/extras/editor/SoundConfig.hpp" #include "threepp/extras/editor/GeneratorConfig.hpp" #include "threepp/extras/editor/MaterialTextureSlots.hpp" +#include "threepp/extras/editor/FlockPlaySession.hpp" #include "threepp/extras/editor/ParticleFieldPlaySession.hpp" #include "threepp/extras/editor/RobotConfig.hpp" #include "threepp/extras/editor/ScriptConfig.hpp" @@ -697,6 +698,18 @@ EditorApp::EditorApp(const Options& options) #endif play_.addSession(std::make_shared()); + // Ambient flocks. Dependency-free (no PhysX, no renderer coupling) and + // stateless between plays. The mesh filter is NOT optional here: the + // editor scene carries overlay meshes (gizmo handles, light markers, + // waypoint pucks) in the same graph the perch bake traverses, and a + // marker hovering at altitude reads as the highest surface in its column + // — the whole flock then climbs to a phantom floor. + { + auto flockSession = std::make_shared(); + flockSession->setMeshFilter( + [this](const Mesh& mesh) { return !document_.isEditorOnly(mesh); }); + play_.addSession(flockSession); + } #ifdef THREEPP_WITH_AUDIO // Sounds. Kept as a member for the status readout and the selftest. Its // listener rides the perspective viewport camera, which is the closest diff --git a/apps/editor/EditorApp.hpp b/apps/editor/EditorApp.hpp index 3ff02f33b..b5e7e3735 100644 --- a/apps/editor/EditorApp.hpp +++ b/apps/editor/EditorApp.hpp @@ -314,6 +314,10 @@ namespace threepp::editor { // grains only exist while playing, so this section is the chute and // nothing else. void drawGranularSection(Object3D& object); + // Shown for a group carrying FlockConfig. Authoring only — the birds + // exist while playing (FlockPlaySession), so this section is the + // territory and the species knobs and nothing else. + void drawFlockSection(Object3D& object); // `owner` is the object the material hangs off; the slot is identified // by (owner uuid, label) whenever it has to outlive the frame. void drawTextureSlot(const Object3D& owner, Material& material, const char* label, diff --git a/apps/editor/EditorSelfTest.cpp b/apps/editor/EditorSelfTest.cpp index ab5e40a53..9a5b95c1d 100644 --- a/apps/editor/EditorSelfTest.cpp +++ b/apps/editor/EditorSelfTest.cpp @@ -19,6 +19,7 @@ #include "threepp/extras/editor/AcousticSurfaceConfig.hpp" #include "threepp/extras/editor/AnimationConfig.hpp" #include "threepp/extras/editor/ArticulationConfig.hpp" +#include "threepp/extras/editor/FlockConfig.hpp" #include "threepp/extras/editor/GranularConfig.hpp" #include "threepp/extras/editor/JointConfig.hpp" #include "threepp/extras/editor/ParticleFieldConfig.hpp" @@ -4624,6 +4625,101 @@ int EditorApp::runSelfTest() { std::filesystem::remove(particlesPath, particlesEc); } + // Flocks. Authoring is a userData entry and the birds are renderer- and + // PhysX-free, so BOTH halves — the edit-mode round trip and the play-mode + // birds — run on every build. + { + auto created = ObjectFactory::createFlock(document_.scene()); + const auto flockUuid = created->uuid; + addObject(created, document_.scene(), "Add Flock"); + created.reset();// the command owns it now + step(); + + auto* flock = findByUuid(document_.scene(), flockUuid); + check(flock && FlockConfig::isFlock(*flock), + "the factory creates a flock node"); + check(flock && FlockConfig::read(*flock) == FlockConfig{}, + "carrying the default flock config"); + check(flock && flock->position.y > 1.f, + "lifted to cruising height - home is a loiter volume, not a floor mark"); + + selectObject(findByUuid(document_.scene(), flockUuid)); + step(); + check(particleHelper_ && particleHelper_->visible, + "selecting it draws the territory rings and the wind"); + selectObject(nullptr); + step(); + + // An inspector edit round-trips through the entry, undoably — and the + // helper follows it (its rebuild key carries the radius). + if (auto* live = findByUuid(document_.scene(), flockUuid)) { + auto* node = live; + const auto before = FlockConfig::read(*live).value_or(FlockConfig{}); + auto after = before; + after.roamRadius = 12.f; + after.birdCount = 24; + commands_.execute(makeProperty( + "Flock Roam Radius", "flock:" + live->uuid, + [node](const FlockConfig& value) { value.write(*node); }, + before, after)); + step(); + check(FlockConfig::read(*findByUuid(document_.scene(), flockUuid)) == after, + "an inspector edit round-trips through the entry"); + commands_.undo(); + step(); + check(FlockConfig::read(*findByUuid(document_.scene(), flockUuid)) == before, + "and undoes back to what it replaced"); + } + + const auto liveBirdMeshes = [&] { + std::size_t n = 0; + document_.scene().traverse([&](Object3D& o) { + if (o.type() == "Flock") ++n; + }); + return n; + }; + + check(liveBirdMeshes() == 0, "no birds exist in edit mode"); + startPlay(); + check(liveBirdMeshes() == 1, "play builds one Flock per authored node"); + stepFixed(240); + check(liveBirdMeshes() == 1, "and it survives four seconds of flight"); + // The phantom-floor trap. The editor scene carries overlay meshes + // (gizmo handles, light markers) in the graph the perch bake walks; + // without the session's editor-only filter, a marker at altitude + // becomes the highest surface in its column and the whole flock + // climbs to it (measured: y=430 over a flat template). Home is at + // 12 and the default cruise band tops out near 31, so 60 is not a + // tuning number — only a poisoned bake reaches it. + { + Flock* liveFlock = nullptr; + document_.scene().traverse([&](Object3D& o) { + if (auto* f = dynamic_cast(&o)) liveFlock = f; + }); + float maxY = -1e9f, minY = 1e9f; + if (liveFlock) { + for (int i = 0; i < liveFlock->birdCount(); ++i) { + maxY = std::max(maxY, liveFlock->birdPosition(i).y); + minY = std::min(minY, liveFlock->birdPosition(i).y); + } + } + check(liveFlock && maxY < 60.f, + "the birds hold the authored cruise band, not a helper-mesh ceiling"); + check(liveFlock && minY > -5.f, + "and none of them sank through the floor"); + } + stopPlay(); + step(); + check(liveBirdMeshes() == 0, + "stop restores a document that never saw the birds"); + + // Delete the node so later blocks meet the scene they always did. + if (auto* live = findByUuid(document_.scene(), flockUuid)) { + commands_.execute(std::make_unique(*live)); + step(); + } + } + // Granular chutes, EDIT MODE. Authoring is PhysX-free — the config is just // strings — so this runs on every build; whether grains actually pour is a // play-time question the physics blocks answer. diff --git a/apps/editor/ParticleOverlay.cpp b/apps/editor/ParticleOverlay.cpp index 3c3a15814..87fbf69f8 100644 --- a/apps/editor/ParticleOverlay.cpp +++ b/apps/editor/ParticleOverlay.cpp @@ -25,6 +25,7 @@ #include "EditorApp.hpp" #include "EditorTheme.hpp" +#include "threepp/extras/editor/FlockConfig.hpp" #include "threepp/extras/editor/GranularConfig.hpp" #include "threepp/extras/editor/ParticleFieldBuild.hpp" #include "threepp/extras/editor/ParticleFieldConfig.hpp" @@ -235,11 +236,17 @@ void EditorApp::syncParticleHelper() { // bury the scene, and the numbers are in the inspector either way. Drawn on // every backend — where the particles are born is an authoring fact, not a // rendering one, and on GL it is the only picture there is. + // + // The flock shares the helper: its territory is the same kind of authored + // extent as a spawn slab, and while playing the birds themselves are the + // picture, so edit mode is exactly when the circle earns its place. auto* selected = selection_.get(); const auto particles = selected ? ParticleFieldConfig::read(*selected) : std::nullopt; const auto granular = selected && !particles ? GranularConfig::read(*selected) : std::nullopt; + const auto flock = selected && !particles && !granular ? FlockConfig::read(*selected) + : std::nullopt; - if (!particles && !granular) { + if (!particles && !granular && !flock) { if (particleHelper_) { particleHelper_->removeFromParent(); particleHelper_.reset(); @@ -250,11 +257,16 @@ void EditorApp::syncParticleHelper() { // Keyed by uuid (a play/stop replaces the whole graph) plus the numbers the // picture is built from — a rebuild trigger, not a hash; the placement below - // runs every frame regardless. - const Vector3 extent = particles - ? particles->spawnHalfExtent - : Vector3(granular->emitExtentX, 0.f, granular->emitExtentZ); - Vector3 flight = particles ? particles->velocity : granular->emitVelocity; + // runs every frame regardless. The flock rides the same two slots: extent + // carries (roamRadius, cruiseAltitude, altitudeSpread), flight the wind. + const Vector3 extent = particles ? particles->spawnHalfExtent + : granular + ? Vector3(granular->emitExtentX, 0.f, granular->emitExtentZ) + : Vector3(flock->roamRadius, flock->cruiseAltitude, + flock->altitudeSpread); + Vector3 flight = particles ? particles->velocity + : granular ? granular->emitVelocity + : Vector3(flock->windX, 0.f, flock->windZ); if (particles) flight.add(particles->wind); char key[224]; @@ -286,12 +298,48 @@ void EditorApp::syncParticleHelper() { std::vector box, arrow; if (particles) { appendBox(box, extent); - } else { + } else if (granular) { // A chute has no vertical extent to draw: the pour mouth is a // rectangle in the node's own XZ plane. const Vector3 corners[4]{{-extent.x, 0.f, -extent.z}, {extent.x, 0.f, -extent.z}, {extent.x, 0.f, extent.z}, {-extent.x, 0.f, extent.z}}; for (int i = 0; i < 4; ++i) appendSegment(box, corners[i], corners[(i + 1) % 4]); + } else { + // The territory: the soft roam edge as a circle in the node's XZ + // plane (extent.x = roamRadius), a second ring at 0.75× where the + // bounds force starts to bite, and the SAME full circle again at + // expected-ground level (cruiseAltitude = extent.y below) — the + // loiter volume floats, but an editor camera looks down at a + // scene, and extents read against the ground it will be judged + // over. A drop line with a ground tick joins the two; the tick + // landing off the actual floor is the one spatial fact a + // misplaced flock node gets wrong. The band rings at + // ±cruiseAltitude·spread (extent.z) show the ALTITUDE BAND the + // birds hold: each bird prefers ground + cruiseAltitude·(1 ± + // spread), so with the ground where the tick claims, the flock + // flies between these two rings. + constexpr int kSegments = 48; + const auto appendRing = [&](float radius, float y) { + for (int i = 0; i < kSegments; ++i) { + const float a0 = static_cast(i) * math::TWO_PI / kSegments; + const float a1 = static_cast(i + 1) * math::TWO_PI / kSegments; + appendSegment(box, + {radius * std::cos(a0), y, radius * std::sin(a0)}, + {radius * std::cos(a1), y, radius * std::sin(a1)}); + } + }; + appendRing(extent.x, 0.f); + appendRing(extent.x * 0.75f, 0.f); + appendRing(extent.x, -extent.y); + const float band = extent.y * extent.z; + if (band > 0.01f) { + appendRing(extent.x * 0.9f, band); + appendRing(extent.x * 0.9f, -band); + } + appendSegment(box, {0.f, 0.f, 0.f}, {0.f, -extent.y, 0.f}); + const float tick = std::max(extent.x * 0.05f, 0.5f); + appendSegment(box, {-tick, -extent.y, 0.f}, {tick, -extent.y, 0.f}); + appendSegment(box, {0.f, -extent.y, -tick}, {0.f, -extent.y, tick}); } appendArrow(arrow, flight, arrowLength(extent)); diff --git a/apps/editor/panels/HierarchyPanel.cpp b/apps/editor/panels/HierarchyPanel.cpp index a5ab91410..e5709989a 100644 --- a/apps/editor/panels/HierarchyPanel.cpp +++ b/apps/editor/panels/HierarchyPanel.cpp @@ -93,6 +93,11 @@ void EditorApp::drawAddMenu(Object3D& parent) { addObject(ObjectFactory::createTree(document_.scene()), *target, "Add Tree"); }; } + if (ImGui::MenuItem("Flock")) { + deferred_ = [this, target] { + addObject(ObjectFactory::createFlock(document_.scene()), *target, "Add Flock"); + }; + } if (ImGui::MenuItem("Sound")) { deferred_ = [this, target] { addObject(ObjectFactory::createSound(document_.scene()), *target, "Add Sound"); diff --git a/apps/editor/panels/InspectorPanel.cpp b/apps/editor/panels/InspectorPanel.cpp index 333d8f116..4ccec4801 100644 --- a/apps/editor/panels/InspectorPanel.cpp +++ b/apps/editor/panels/InspectorPanel.cpp @@ -9,6 +9,7 @@ #include "threepp/extras/editor/AnimationConfig.hpp" #include "threepp/extras/editor/ArticulationConfig.hpp" #include "threepp/extras/editor/ConveyorConfig.hpp" +#include "threepp/extras/editor/FlockConfig.hpp" #include "threepp/extras/editor/GeneratorConfig.hpp" #include "threepp/extras/editor/GranularConfig.hpp" #include "threepp/extras/editor/JointConfig.hpp" @@ -280,6 +281,7 @@ void EditorApp::drawInspector() { drawAcousticsSection(*selected); drawTextSection(*selected); drawTreeSection(*selected); + drawFlockSection(*selected); drawScriptSection(*selected); drawPhysicsSection(*selected); drawVehicleSection(*selected); @@ -4073,6 +4075,57 @@ void EditorApp::drawGranularSection(Object3D& object) { } +// -------------------------------------------------------------------- flock + +void EditorApp::drawFlockSection(Object3D& object) { + + if (!FlockConfig::isFlock(object)) return; + if (!section("Flock")) return; + + using Config = FlockConfig; + + ConfigFields fields(commands_, object, Config::read(object).value_or(Config{}), + "flock:" + object.uuid, "Flock", + [this] { document_.setDirty(true); }); + + ImGui::PushItemWidth(-130 * contentScale_); + + ImGui::SeparatorText("Population"); + fields.dragInt("Seed", &Config::seed, 1.f, 0, 999999); + // 18 reads as "a place where birds live"; 200 reads as "a bird + // simulation" — the config header carries the full warning. + fields.dragInt("Birds", &Config::birdCount, 0.25f, 0, 256); + fields.dragFloat("Body Mass (kg)", &Config::massKg, 0.001f, 0.01f, 1.f, "%.3f"); + + ImGui::SeparatorText("Territory"); + fields.dragFloat("Roam Radius", &Config::roamRadius, 0.25f, 4.f, 500.f); + fields.dragFloat("Cruise Altitude", &Config::cruiseAltitude, 0.1f, 1.f, 200.f); + // The band the helper draws: each bird prefers ground + altitude × + // (1 ± spread). 0 flies a plane — a formation, not a flock. + fields.dragFloat("Altitude Spread", &Config::altitudeSpread, 0.005f, 0.f, 1.f); + fields.dragFloat("Cruise Speed", &Config::cruiseSpeed, 0.05f, 1.f, 40.f); + + ImGui::SeparatorText("Perching"); + fields.check("Perching", &Config::perching, "Enable Flock Perching", + "Disable Flock Perching"); + fields.dragFloat("Max Perched", &Config::maxPerchedFraction, 0.01f, 0.f, 1.f); + + ImGui::SeparatorText("Look"); + fields.check("Cast Shadow", &Config::castShadow, "Enable Flock Shadows", + "Disable Flock Shadows"); + fields.dragFloat("Wind X", &Config::windX, 0.01f, -10.f, 10.f); + fields.dragFloat("Wind Z", &Config::windZ, 0.01f, -10.f, 10.f); + + ImGui::PopItemWidth(); + + ImGui::TextColored(theme::muted(), "The node's position is the territory's home."); + ImGui::TextColored(theme::muted(), "Birds fly while playing; perches bake from the scene at Play."); + ImGui::TextColored(theme::muted(), "Stored in userData[\"flock\"]"); + + ImGui::TreePop(); +} + + // ------------------------------------------------------------------- sensor void EditorApp::drawSensorSection(Object3D& object) { diff --git a/include/threepp/extras/editor/FlockConfig.hpp b/include/threepp/extras/editor/FlockConfig.hpp new file mode 100644 index 000000000..8953e3e21 --- /dev/null +++ b/include/threepp/extras/editor/FlockConfig.hpp @@ -0,0 +1,88 @@ +// Ambient flock authoring, stored on the object itself. +// +// A flock is a Group carrying `userData["flock"]` — the curated subset of +// Flock::Params worth a slider. THE NODE'S POSITION IS THE TERRITORY'S HOME: +// like the particle field's emitter frame, the transform gizmo is the +// authoring tool for where the birds live, so `home` is never stored here. +// +// The birds themselves are never document nodes: FlockPlaySession builds a +// Flock from this entry when Play starts (baking perches against the scene as +// it stands), and the Stop snapshot restores a document that never saw them. +// A saved scene therefore carries a dozen scalars, not 2 256 vertices of bird. +// +// What makes a Group a flock is the presence of the entry — no enabled flag, +// and write() never erases, for TreeConfig's reason: the entry IS the flock. +// +// Storage is the flat `key=value;…` string the Config family shares. Every key +// is the FlockConfig field name; unknown keys are ignored on read so a +// document written by a newer editor still loads. + +#ifndef THREEPP_EDITOR_FLOCKCONFIG_HPP +#define THREEPP_EDITOR_FLOCKCONFIG_HPP + +#include "threepp/extras/fauna/Flock.hpp" + +#include +#include + +namespace threepp { + + class Object3D; + +}// namespace threepp + +namespace threepp::editor { + + struct FlockConfig { + + // ── Population ────────────────────────────────────────────────────── + int seed = 1337; + // 18 reads as "a place where birds live"; 200 reads as "a bird + // simulation" (Flock.hpp's own words). Clamped to [0, 256] downstream. + int birdCount = 18; + // Drives wingbeat frequency allometrically, and the bird's size with it. + float massKg = 0.078f; + + // ── Territory (home = the node's world position) ──────────────────── + float roamRadius = 42.f; + float cruiseAltitude = 14.f; + // ± fraction of cruiseAltitude, per bird — the thickness of the loose + // altitude band the flock flies in. 0 collapses it to a plane, which + // reads as a formation, not a flock. + float altitudeSpread = 0.35f; + float cruiseSpeed = 9.f; + + // ── Perching ──────────────────────────────────────────────────────── + bool perching = true; + float maxPerchedFraction = 0.55f; + + // ── Look ──────────────────────────────────────────────────────────── + bool castShadow = false; + float windX = 0.7f; + float windZ = 0.7f; + + static constexpr const char* userDataKey = "flock"; + + [[nodiscard]] std::string encode() const; + // Never nullopt: an empty or unparsable string decodes to the defaults. + [[nodiscard]] static std::optional decode(const std::string& text); + + // nullopt when the object carries no flock entry. + [[nodiscard]] static std::optional read(const Object3D& object); + // Always writes the entry — see the header note. + void write(Object3D& object) const; + static void erase(Object3D& object); + + [[nodiscard]] static bool isFlock(const Object3D& object); + + // The runtime parameter block: defaults everywhere this config has no + // opinion, `home` from the authored node's world position. Flock's own + // sanitise() owns the clamping. + [[nodiscard]] Flock::Params makeParams(const Vector3& home) const; + + bool operator==(const FlockConfig&) const = default; + }; + +}// namespace threepp::editor + +#endif//THREEPP_EDITOR_FLOCKCONFIG_HPP diff --git a/include/threepp/extras/editor/FlockPlaySession.hpp b/include/threepp/extras/editor/FlockPlaySession.hpp new file mode 100644 index 000000000..bd8d1821e --- /dev/null +++ b/include/threepp/extras/editor/FlockPlaySession.hpp @@ -0,0 +1,115 @@ +// The PlaySession that makes an authored flock FLY: one Flock per node +// carrying a FlockConfig, built when Play starts and ticked every frame. +// +// Header-only and dependency-free beyond threepp core — no PhysX, no +// renderer, no guard. The birds work identically on GL, Vulkan and headless, +// which is the Flock's own design contract (Flock.hpp's RENDERING note). +// +// The Flock goes to the SCENE ROOT, not under the authored node, for the +// granular session's reason verbatim: the simulation is world-space and the +// mesh wants an identity parent (Flock.hpp: "THE FLOCK NODE SHOULD STAY AT +// IDENTITY"). The authored node's transform contributes exactly one thing — +// its world position becomes the territory's `home`. The birds are scene +// content either way: the play snapshot was taken before start(), so Stop +// restores a document that never saw them. +// +// PERCHES BAKE AGAINST THE SCENE AS PLAY FINDS IT, amortised. bakePerches() +// starts the work-unit-budgeted bake and Flock::update() advances it, so a +// heavy scene costs a few frames of birds-not-landing-yet instead of a hitch +// on the Play button. Zero perches is a normal answer (a sky-only scene) and +// the flock simply flies — PerchIndex's own contract. + +#ifndef THREEPP_EDITOR_FLOCKPLAYSESSION_HPP +#define THREEPP_EDITOR_FLOCKPLAYSESSION_HPP + +#include "threepp/extras/editor/FlockConfig.hpp" +#include "threepp/extras/editor/PlaySession.hpp" + +#include "threepp/extras/fauna/Flock.hpp" + +#include "threepp/core/Object3D.hpp" +#include "threepp/math/Vector3.hpp" +#include "threepp/scenes/Scene.hpp" + +#include +#include +#include +#include +#include + +namespace threepp::editor { + + class FlockPlaySession: public PlaySession { + + public: + [[nodiscard]] std::string name() const override { return "Flock"; } + + // --- wiring (set once, before the first Play) ------------------------ + + // Which meshes the perch bake may sample. THE EDITOR MUST SET THIS to + // exclude its overlay meshes: gizmo handles, light markers and waypoint + // pucks live in the same scene graph the bake traverses, and a marker + // hovering at altitude becomes the "highest sampled surface" in its + // column — the whole flock then chases a phantom floor (measured: + // birds at y=430 over a flat template scene). Same contract as + // Flock::setPerchFilter, which is where it lands. + void setMeshFilter(std::function filter) { filter_ = std::move(filter); } + + void start(Scene& scene) override { + + scene_ = &scene; + + // A node placed this very frame has a stale matrixWorld until the + // next render; home must not read it one frame old. + scene.updateMatrixWorld(); + + // Collect first, add after — adding during traverse would walk the + // birds we are in the middle of creating. + struct Entry { + FlockConfig config; + Vector3 home; + }; + std::vector entries; + scene.traverse([&](Object3D& node) { + if (!node.visible) return;// a hidden flock node plays silent + const auto config = FlockConfig::read(node); + if (!config) return; + Vector3 home; + home.setFromMatrixPosition(*node.matrixWorld); + entries.push_back({*config, home}); + }); + + for (const auto& entry : entries) { + auto flock = Flock::create(entry.config.makeParams(entry.home)); + if (filter_) flock->setPerchFilter(filter_); + scene.add(flock); + // Amortised on purpose — see the header note. The flock + // excludes its own mesh from the bake itself. + flock->bakePerches(scene); + flocks_.push_back(std::move(flock)); + } + } + + void update(float dt) override { + + for (const auto& flock : flocks_) flock->update(dt); + } + + void stop() override { + + if (scene_) { + for (const auto& flock : flocks_) scene_->remove(*flock); + } + flocks_.clear(); + scene_ = nullptr; + } + + private: + Scene* scene_ = nullptr; + std::function filter_; + std::vector> flocks_; + }; + +}// namespace threepp::editor + +#endif//THREEPP_EDITOR_FLOCKPLAYSESSION_HPP diff --git a/include/threepp/extras/editor/ObjectFactory.hpp b/include/threepp/extras/editor/ObjectFactory.hpp index 819e1fbf1..041ee729d 100644 --- a/include/threepp/extras/editor/ObjectFactory.hpp +++ b/include/threepp/extras/editor/ObjectFactory.hpp @@ -108,6 +108,11 @@ namespace threepp::editor { // simulation that only exists while playing. static std::shared_ptr createGranular(const Object3D& root); + // A Group carrying a default FlockConfig. The node's position is the + // territory's home, so it is added lifted to cruising height; the + // birds themselves only exist while playing (see FlockPlaySession). + static std::shared_ptr createFlock(const Object3D& root); + // "Box" if free, else "Box 2", "Box 3", ... Matching is exact, so a // user-typed "Box copy" never blocks "Box". static std::string uniqueName(const Object3D& root, const std::string& base); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7945caeff..30e3ec87c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -524,6 +524,7 @@ set(sources "threepp/extras/editor/EditorCommands.cpp" "threepp/extras/editor/EditorSettings.cpp" "threepp/extras/editor/GeneratorConfig.cpp" + "threepp/extras/editor/FlockConfig.cpp" "threepp/extras/editor/GranularConfig.cpp" "threepp/extras/editor/JointConfig.cpp" "threepp/extras/editor/MaterialTextureSlots.cpp" diff --git a/src/threepp/extras/editor/FlockConfig.cpp b/src/threepp/extras/editor/FlockConfig.cpp new file mode 100644 index 000000000..82b09fe33 --- /dev/null +++ b/src/threepp/extras/editor/FlockConfig.cpp @@ -0,0 +1,89 @@ + +#include "threepp/extras/editor/FlockConfig.hpp" + +#include "threepp/extras/editor/detail/ConfigCodec.hpp" + +#include "threepp/core/Object3D.hpp" + +#include + +using namespace threepp; +using namespace threepp::editor; + + +std::string FlockConfig::encode() const { + + std::ostringstream out; + out << "seed=" << seed + << ";birdCount=" << birdCount + << ";massKg=" << codec::number(massKg) + << ";roamRadius=" << codec::number(roamRadius) + << ";cruiseAltitude=" << codec::number(cruiseAltitude) + << ";altitudeSpread=" << codec::number(altitudeSpread) + << ";cruiseSpeed=" << codec::number(cruiseSpeed) + << ";perching=" << (perching ? 1 : 0) + << ";maxPerchedFraction=" << codec::number(maxPerchedFraction) + << ";castShadow=" << (castShadow ? 1 : 0) + << ";windX=" << codec::number(windX) + << ";windZ=" << codec::number(windZ); + return out.str(); +} + +std::optional FlockConfig::decode(const std::string& text) { + + FlockConfig config; + codec::parsePairs(text, [&](std::string_view key, std::string_view value) { + if (key == "seed") config.seed = codec::toInt(value, config.seed); + else if (key == "birdCount") config.birdCount = codec::toInt(value, config.birdCount); + else if (key == "massKg") config.massKg = codec::toFloat(value, config.massKg); + else if (key == "roamRadius") config.roamRadius = codec::toFloat(value, config.roamRadius); + else if (key == "cruiseAltitude") config.cruiseAltitude = codec::toFloat(value, config.cruiseAltitude); + else if (key == "altitudeSpread") config.altitudeSpread = codec::toFloat(value, config.altitudeSpread); + else if (key == "cruiseSpeed") config.cruiseSpeed = codec::toFloat(value, config.cruiseSpeed); + else if (key == "perching") config.perching = codec::toBool(value, config.perching); + else if (key == "maxPerchedFraction") config.maxPerchedFraction = codec::toFloat(value, config.maxPerchedFraction); + else if (key == "castShadow") config.castShadow = codec::toBool(value, config.castShadow); + else if (key == "windX") config.windX = codec::toFloat(value, config.windX); + else if (key == "windZ") config.windZ = codec::toFloat(value, config.windZ); + // Unknown keys are ignored — a newer editor's document still loads. + }); + return config; +} + +std::optional FlockConfig::read(const Object3D& object) { + + return codec::readEntry(object, userDataKey); +} + +void FlockConfig::write(Object3D& object) const { + + object.userData[userDataKey] = encode(); +} + +void FlockConfig::erase(Object3D& object) { + + object.userData.erase(userDataKey); +} + +bool FlockConfig::isFlock(const Object3D& object) { + + return codec::hasEntry(object, userDataKey); +} + +Flock::Params FlockConfig::makeParams(const Vector3& home) const { + + Flock::Params p; + p.seed = static_cast(seed < 0 ? 0 : seed); + p.birdCount = birdCount; + p.massKg = massKg; + p.home = home; + p.roamRadius = roamRadius; + p.cruiseAltitude = cruiseAltitude; + p.altitudeSpread = altitudeSpread; + p.cruiseSpeed = cruiseSpeed; + p.perching = perching; + p.maxPerchedFraction = maxPerchedFraction; + p.birdsCastShadow = castShadow; + p.wind.set(windX, windZ); + return p; +} diff --git a/src/threepp/extras/editor/ObjectFactory.cpp b/src/threepp/extras/editor/ObjectFactory.cpp index c1a688175..6da1f5b13 100644 --- a/src/threepp/extras/editor/ObjectFactory.cpp +++ b/src/threepp/extras/editor/ObjectFactory.cpp @@ -2,6 +2,7 @@ #include "threepp/extras/editor/ObjectFactory.hpp" #include "threepp/extras/editor/ConveyorConfig.hpp" +#include "threepp/extras/editor/FlockConfig.hpp" #include "threepp/extras/editor/GranularConfig.hpp" #include "threepp/extras/editor/JointConfig.hpp" #include "threepp/extras/editor/ParticleFieldConfig.hpp" @@ -472,6 +473,22 @@ std::shared_ptr ObjectFactory::createGranular(const Object3D& root) { return granular; } +std::shared_ptr ObjectFactory::createFlock(const Object3D& root) { + + auto flock = Group::create(); + flock->name = uniqueName(root, "Flock"); + FlockConfig{}.write(*flock); + + // The node's position is the territory's home — a loiter volume, not a + // floor marker. At the origin the birds would orbit through the ground. + // Exactly the default cruiseAltitude, so the helper's ground tick lands + // on y=0 — a fresh flock over a ground-at-origin scene reads as placed + // right, and a moved one shows its expectation. + flock->position.y = 14.f; + + return flock; +} + std::shared_ptr ObjectFactory::createSplinePoint(const Object3D& spline) { auto point = Object3D::create(); diff --git a/tests/extras/CMakeLists.txt b/tests/extras/CMakeLists.txt index c7bc218ef..efe6e6432 100644 --- a/tests/extras/CMakeLists.txt +++ b/tests/extras/CMakeLists.txt @@ -3,6 +3,7 @@ add_test_executable(EditorAnimation_test) add_test_executable(EditorArticulationConfig_test) add_test_executable(EditorCommands_test) add_test_executable(EditorConveyorConfig_test) +add_test_executable(EditorFlockConfig_test) add_test_executable(EditorDocument_test) add_test_executable(EditorJointConfig_test) add_test_executable(EditorLights_test) diff --git a/tests/extras/EditorFlockConfig_test.cpp b/tests/extras/EditorFlockConfig_test.cpp new file mode 100644 index 000000000..4718ba096 --- /dev/null +++ b/tests/extras/EditorFlockConfig_test.cpp @@ -0,0 +1,97 @@ +// EditorFlockConfig_test — the flock's editor wiring, renderer-free. +// +// Three seams: the userData round trip (what a saved document carries), the +// factory (what "Add ▸ Flock" creates), and the play session (birds exist +// exactly while playing, and actually fly). The Flock itself is covered by +// Flock_test; here only the editor plumbing around it is on trial. + +#include + +#include "threepp/extras/editor/FlockConfig.hpp" +#include "threepp/extras/editor/FlockPlaySession.hpp" +#include "threepp/extras/editor/ObjectFactory.hpp" + +#include "threepp/objects/Group.hpp" +#include "threepp/scenes/Scene.hpp" + +#include + +using namespace threepp; +using namespace threepp::editor; + +TEST_CASE("FlockConfig: userData round trip preserves every field") { + + FlockConfig config; + config.seed = 7; + config.birdCount = 31; + config.massKg = 0.12f; + config.roamRadius = 25.f; + config.cruiseAltitude = 9.f; + config.altitudeSpread = 0.2f; + config.cruiseSpeed = 11.f; + config.perching = false; + config.maxPerchedFraction = 0.4f; + config.castShadow = true; + config.windX = -1.25f; + config.windZ = 0.5f; + + auto node = Group::create(); + REQUIRE_FALSE(FlockConfig::isFlock(*node)); + + config.write(*node); + REQUIRE(FlockConfig::isFlock(*node)); + REQUIRE(FlockConfig::read(*node) == config); + + FlockConfig::erase(*node); + REQUIRE_FALSE(FlockConfig::isFlock(*node)); +} + +TEST_CASE("FlockConfig: makeParams carries the node's home and the config's knobs") { + + FlockConfig config; + config.birdCount = 5; + config.roamRadius = 20.f; + + const auto params = config.makeParams({3.f, 12.f, -4.f}); + REQUIRE(params.home == Vector3{3.f, 12.f, -4.f}); + REQUIRE(params.birdCount == 5); + REQUIRE(params.roamRadius == 20.f); +} + +TEST_CASE("FlockPlaySession: birds exist exactly while playing, and fly") { + + Scene scene; + auto node = ObjectFactory::createFlock(scene); + REQUIRE(FlockConfig::isFlock(*node)); + REQUIRE(node->name == "Flock"); + scene.add(node); + + const auto countFlocks = [&] { + int n = 0; + scene.traverse([&](Object3D& o) { + if (o.type() == "Flock") ++n; + }); + return n; + }; + + FlockPlaySession session; + REQUIRE(countFlocks() == 0); + + session.start(scene); + REQUIRE(countFlocks() == 1); + + // Find the session's flock and watch a bird move. + Flock* flock = nullptr; + scene.traverse([&](Object3D& o) { + if (auto* f = dynamic_cast(&o)) flock = f; + }); + REQUIRE(flock != nullptr); + const Vector3 before = flock->birdPosition(0); + for (int i = 0; i < 60; ++i) session.update(1.f / 60.f); + const Vector3 after = flock->birdPosition(0); + REQUIRE(before.distanceTo(after) > 0.1f); + REQUIRE(std::isfinite(after.x)); + + session.stop(); + REQUIRE(countFlocks() == 0); +}