feat: implement scene graph system with ECS hierarchy support

Add hierarchical scene graph for editor with TreeView UI, runtime
HierarchyUtility for parent/child linked-list management, and
incremental sync between editor world and scene graph nodes.

- SceneGraphNode/SceneNode/EntityNode with World, Scene, Entity refs
- SceneGraphBuilder — construct tree from ECS World queries
- HierarchyUtility — SetParent, RemoveParent, IsAncestor, cascade destroy
- EditorWorldService + SceneGraphSyncService — editor world lifecycle & incremental sync
- Hierarchy.xaml — TreeView with DataTemplate + SceneGraphTemplateSelector
- 25 unit tests covering hierarchy ops and scene graph building
This commit is contained in:
2026-05-10 00:14:55 +09:00
parent e2bc68d359
commit 2ea3c509b0
19 changed files with 1841 additions and 62 deletions

View File

@@ -12,8 +12,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.6" />
<PackageReference Include="MSTest" Version="4.2.1" />
<PackageReference Include="MSTest" Version="4.2.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,286 @@
using Ghost.Core;
using Ghost.Engine;
using Ghost.Engine.Components;
using Ghost.Entities;
using Misaki.HighPerformance.LowLevel.Buffer;
namespace Ghost.UnitTest;
[TestClass]
[DoNotParallelize]
public class HierarchyUtilityTests
{
private World _world = null!;
[TestInitialize]
public void Setup()
{
AllocationManager.Initialize();
_world = World.Create(entityCapacity: 64);
}
[TestCleanup]
public void Cleanup()
{
World.Destroy(_world.ID);
AllocationManager.Dispose();
}
private Entity CreateHierarchyEntity()
{
var entity = _world.EntityManager.CreateEntity();
_world.EntityManager.AddComponent(entity, new Hierarchy
{
parent = Entity.Invalid,
firstChild = Entity.Invalid,
nextSibling = Entity.Invalid
});
return entity;
}
[TestMethod]
public void SetParent_ChildBecomesChildOfParent()
{
var parent = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
var result = HierarchyUtility.SetParent(_world, child, parent);
Assert.AreEqual(Error.None, result);
ref var childHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child);
Assert.AreEqual(parent, childHierarchy.parent);
ref var parentHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent);
Assert.AreEqual(child, parentHierarchy.firstChild);
}
[TestMethod]
public void SetParent_SecondChildBecomesSibling()
{
var parent = CreateHierarchyEntity();
var child1 = CreateHierarchyEntity();
var child2 = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child1, parent);
HierarchyUtility.SetParent(_world, child2, parent);
ref var parentHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent);
Assert.AreEqual(child2, parentHierarchy.firstChild);
ref var child2Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child2);
Assert.AreEqual(child1, child2Hierarchy.nextSibling);
ref var child1Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child1);
Assert.AreEqual(Entity.Invalid, child1Hierarchy.nextSibling);
}
[TestMethod]
public void SetParent_ReparentFromOneParentToAnother()
{
var parent1 = CreateHierarchyEntity();
var parent2 = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child, parent1);
HierarchyUtility.SetParent(_world, child, parent2);
ref var childHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child);
Assert.AreEqual(parent2, childHierarchy.parent);
ref var parent1Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent1);
Assert.AreEqual(Entity.Invalid, parent1Hierarchy.firstChild);
ref var parent2Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent2);
Assert.AreEqual(child, parent2Hierarchy.firstChild);
}
[TestMethod]
public void SetParent_SelfParentingIsRejected()
{
var entity = CreateHierarchyEntity();
var result = HierarchyUtility.SetParent(_world, entity, entity);
Assert.AreEqual(Error.InvalidArgument, result);
}
[TestMethod]
public void SetParent_CycleIsRejected()
{
var parent = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child, parent);
var result = HierarchyUtility.SetParent(_world, parent, child);
Assert.AreEqual(Error.InvalidArgument, result);
Assert.AreEqual(parent, _world.EntityManager.GetComponent<Hierarchy>(child).parent);
}
[TestMethod]
public void SetParent_GrandchildCycleIsRejected()
{
var grandparent = CreateHierarchyEntity();
var parent = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, parent, grandparent);
HierarchyUtility.SetParent(_world, child, parent);
var result = HierarchyUtility.SetParent(_world, grandparent, child);
Assert.AreEqual(Error.InvalidArgument, result);
}
[TestMethod]
public void SetParent_EntityWithoutHierarchyComponentReturnsNotFound()
{
var parent = CreateHierarchyEntity();
var child = _world.EntityManager.CreateEntity();
var result = HierarchyUtility.SetParent(_world, child, parent);
Assert.AreEqual(Error.NotFound, result);
}
[TestMethod]
public void RemoveParent_UnlinksChildFromParent()
{
var parent = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child, parent);
var result = HierarchyUtility.RemoveParent(_world, child);
Assert.AreEqual(Error.None, result);
ref var childHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child);
Assert.AreEqual(Entity.Invalid, childHierarchy.parent);
Assert.AreEqual(Entity.Invalid, childHierarchy.nextSibling);
ref var parentHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent);
Assert.AreEqual(Entity.Invalid, parentHierarchy.firstChild);
}
[TestMethod]
public void RemoveParent_MiddleChildMaintainsSiblingChain()
{
var parent = CreateHierarchyEntity();
var child1 = CreateHierarchyEntity();
var child2 = CreateHierarchyEntity();
var child3 = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child1, parent);
HierarchyUtility.SetParent(_world, child2, parent);
HierarchyUtility.SetParent(_world, child3, parent);
HierarchyUtility.RemoveParent(_world, child2);
ref var child3Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child3);
Assert.AreEqual(child1, child3Hierarchy.nextSibling);
ref var child2Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child2);
Assert.AreEqual(Entity.Invalid, child2Hierarchy.parent);
Assert.AreEqual(Entity.Invalid, child2Hierarchy.nextSibling);
}
[TestMethod]
public void RemoveParent_WhenNoParentReturnsNone()
{
var entity = CreateHierarchyEntity();
var result = HierarchyUtility.RemoveParent(_world, entity);
Assert.AreEqual(Error.None, result);
}
[TestMethod]
public void IsAncestor_ReturnsTrueForDirectParent()
{
var parent = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child, parent);
Assert.IsTrue(HierarchyUtility.IsAncestor(_world, child, parent));
}
[TestMethod]
public void IsAncestor_ReturnsTrueForGrandparent()
{
var grandparent = CreateHierarchyEntity();
var parent = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, parent, grandparent);
HierarchyUtility.SetParent(_world, child, parent);
Assert.IsTrue(HierarchyUtility.IsAncestor(_world, child, grandparent));
}
[TestMethod]
public void IsAncestor_ReturnsFalseForUnrelatedEntity()
{
var entity1 = CreateHierarchyEntity();
var entity2 = CreateHierarchyEntity();
Assert.IsFalse(HierarchyUtility.IsAncestor(_world, entity1, entity2));
}
[TestMethod]
public void IsAncestor_ReturnsFalseForChild()
{
var parent = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child, parent);
Assert.IsFalse(HierarchyUtility.IsAncestor(_world, parent, child));
}
[TestMethod]
public void DestroyEntityWithChildren_CascadeDestroysAllChildren()
{
var parent = CreateHierarchyEntity();
var child1 = CreateHierarchyEntity();
var child2 = CreateHierarchyEntity();
var grandchild = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child1, parent);
HierarchyUtility.SetParent(_world, child2, parent);
HierarchyUtility.SetParent(_world, grandchild, child1);
HierarchyUtility.DestroyEntityWithChildren(_world, parent);
Assert.IsFalse(_world.EntityManager.Exists(parent));
Assert.IsFalse(_world.EntityManager.Exists(child1));
Assert.IsFalse(_world.EntityManager.Exists(child2));
Assert.IsFalse(_world.EntityManager.Exists(grandchild));
}
[TestMethod]
public void DestroyEntityWithChildren_DoesNotAffectUnrelatedEntities()
{
var parent = CreateHierarchyEntity();
var child = CreateHierarchyEntity();
var unrelated = CreateHierarchyEntity();
HierarchyUtility.SetParent(_world, child, parent);
HierarchyUtility.DestroyEntityWithChildren(_world, parent);
Assert.IsTrue(_world.EntityManager.Exists(unrelated));
}
[TestMethod]
public void RemoveEntity_DestroysSingleEntity()
{
var entity = CreateHierarchyEntity();
var result = HierarchyUtility.RemoveEntity(_world, entity);
Assert.AreEqual(Error.None, result);
Assert.IsFalse(_world.EntityManager.Exists(entity));
}
}

View File

@@ -0,0 +1,174 @@
using Ghost.Editor.Core.SceneGraph;
using Ghost.Engine;
using Ghost.Engine.Components;
using Ghost.Engine.Core;
using Ghost.Entities;
using Misaki.HighPerformance.LowLevel.Buffer;
namespace Ghost.UnitTest;
[TestClass]
[DoNotParallelize]
public class SceneGraphBuilderTests
{
private World _world = null!;
[TestInitialize]
public void Setup()
{
AllocationManager.Initialize();
_world = World.Create(entityCapacity: 64);
}
[TestCleanup]
public void Cleanup()
{
World.Destroy(_world.ID);
AllocationManager.Dispose();
}
private Entity CreateEntityWithScene(Scene scene)
{
var entity = _world.EntityManager.CreateEntity();
_world.EntityManager.AddComponent(entity, new SceneID { scene = scene });
return entity;
}
private Entity CreateEntityWithSceneAndHierarchy(Scene scene, Entity parent)
{
var entity = _world.EntityManager.CreateEntity();
_world.EntityManager.AddComponent(entity, new SceneID { scene = scene });
_world.EntityManager.AddComponent(entity, new Hierarchy
{
parent = Entity.Invalid,
firstChild = Entity.Invalid,
nextSibling = Entity.Invalid
});
if (parent.IsValid)
{
HierarchyUtility.SetParent(_world, entity, parent);
}
return entity;
}
[TestMethod]
public void Build_EmptyWorldReturnsEmptyList()
{
var nodes = SceneGraphBuilder.Build(_world);
Assert.AreEqual(0, nodes.Count);
}
[TestMethod]
public void Build_OneSceneOneEntity_CreatesSceneNodeWithEntityChild()
{
var scene = SceneManager.CreateScene();
CreateEntityWithScene(scene);
var nodes = SceneGraphBuilder.Build(_world);
Assert.AreEqual(1, nodes.Count);
var sceneNode = nodes[0];
Assert.AreEqual(scene, sceneNode.Scene);
Assert.AreEqual(1, sceneNode.Children.Count);
Assert.IsInstanceOfType<EntityNode>(sceneNode.Children[0]);
}
[TestMethod]
public void Build_MultipleScenes_CreatesMultipleSceneNodes()
{
var scene1 = SceneManager.CreateScene();
var scene2 = SceneManager.CreateScene();
CreateEntityWithScene(scene1);
CreateEntityWithScene(scene2);
var nodes = SceneGraphBuilder.Build(_world);
Assert.AreEqual(2, nodes.Count);
}
[TestMethod]
public void Build_HierarchicalEntities_CreatesNestedEntityNodes()
{
var scene = SceneManager.CreateScene();
var rootEntity = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid);
var childEntity = CreateEntityWithSceneAndHierarchy(scene, rootEntity);
var nodes = SceneGraphBuilder.Build(_world);
Assert.AreEqual(1, nodes.Count);
var sceneNode = nodes[0];
Assert.AreEqual(1, sceneNode.Children.Count);
var rootNode = (EntityNode)sceneNode.Children[0];
Assert.AreEqual(rootEntity, rootNode.Entity);
Assert.AreEqual(1, rootNode.Children.Count);
var childNode = (EntityNode)rootNode.Children[0];
Assert.AreEqual(childEntity, childNode.Entity);
}
[TestMethod]
public void Build_EntitiesWithoutHierarchy_AreFlatChildren()
{
var scene = SceneManager.CreateScene();
CreateEntityWithScene(scene);
CreateEntityWithScene(scene);
var nodes = SceneGraphBuilder.Build(_world);
Assert.AreEqual(1, nodes.Count);
var sceneNode = nodes[0];
Assert.AreEqual(2, sceneNode.Children.Count);
}
[TestMethod]
public void Build_SiblingOrder_PreservesChildOrder()
{
var scene = SceneManager.CreateScene();
var parent = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid);
var child1 = CreateEntityWithSceneAndHierarchy(scene, parent);
var child2 = CreateEntityWithSceneAndHierarchy(scene, parent);
var nodes = SceneGraphBuilder.Build(_world);
var rootNode = (EntityNode)nodes[0].Children[0];
Assert.AreEqual(2, rootNode.Children.Count);
Assert.AreEqual(child2, ((EntityNode)rootNode.Children[0]).Entity);
Assert.AreEqual(child1, ((EntityNode)rootNode.Children[1]).Entity);
}
[TestMethod]
public void Build_DeepHierarchy_BuildsFullTree()
{
var scene = SceneManager.CreateScene();
var level1 = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid);
var level2 = CreateEntityWithSceneAndHierarchy(scene, level1);
var level3 = CreateEntityWithSceneAndHierarchy(scene, level2);
var nodes = SceneGraphBuilder.Build(_world);
var n1 = (EntityNode)nodes[0].Children[0];
Assert.AreEqual(1, n1.Children.Count);
var n2 = (EntityNode)n1.Children[0];
Assert.AreEqual(1, n2.Children.Count);
var n3 = (EntityNode)n2.Children[0];
Assert.AreEqual(level3, n3.Entity);
}
[TestMethod]
public void Build_InvalidSceneEntitiesAreExcluded()
{
var entity = _world.EntityManager.CreateEntity();
_world.EntityManager.AddComponent(entity, new SceneID { scene = Scene.Invalid });
var nodes = SceneGraphBuilder.Build(_world);
Assert.AreEqual(0, nodes.Count);
}
}