Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/editor/EditorApp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -697,6 +698,18 @@ EditorApp::EditorApp(const Options& options)
#endif

play_.addSession(std::make_shared<AnimationPlaySession>());
// 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<FlockPlaySession>();
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
Expand Down
4 changes: 4 additions & 0 deletions apps/editor/EditorApp.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions apps/editor/EditorSelfTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<FlockConfig>(
"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<Flock*>(&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<RemoveObjectCommand>(*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.
Expand Down
62 changes: 55 additions & 7 deletions apps/editor/ParticleOverlay.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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();
Expand All @@ -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];
Expand Down Expand Up @@ -286,12 +298,48 @@ void EditorApp::syncParticleHelper() {
std::vector<float> 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<float>(i) * math::TWO_PI / kSegments;
const float a1 = static_cast<float>(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));

Expand Down
5 changes: 5 additions & 0 deletions apps/editor/panels/HierarchyPanel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
53 changes: 53 additions & 0 deletions apps/editor/panels/InspectorPanel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -280,6 +281,7 @@ void EditorApp::drawInspector() {
drawAcousticsSection(*selected);
drawTextSection(*selected);
drawTreeSection(*selected);
drawFlockSection(*selected);
drawScriptSection(*selected);
drawPhysicsSection(*selected);
drawVehicleSection(*selected);
Expand Down Expand Up @@ -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<Config> 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) {
Expand Down
1 change: 1 addition & 0 deletions examples/extras/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions examples/extras/fauna/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
add_example(NAME "flock_demo" LINK_IMGUI)
Loading
Loading