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/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/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/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..95501de2d --- /dev/null +++ b/include/threepp/extras/fauna/Flock.hpp @@ -0,0 +1,2898 @@ +// 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)) { + + // 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(); + } + + // 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 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/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/ diff --git a/tests/extras/CMakeLists.txt b/tests/extras/CMakeLists.txt index 7b42ff08c..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) @@ -20,6 +21,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/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); +} 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); +}