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

@@ -16,10 +16,10 @@
<Content Remove="Assets\MeshNode.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentIcons.WinUI" Version="2.1.324" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.6" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<PackageReference Include="FluentIcons.WinUI" Version="2.1.326" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
</ItemGroup>

View File

@@ -6,9 +6,16 @@ namespace Ghost.Editor.Core.SceneGraph;
public sealed partial class EntityNode : SceneGraphNode
{
private readonly Entity _entity;
public Entity Entity
{
get;
}
public Entity Entity => _entity;
public EntityNode(World world, Entity entity, string name)
: base(world, name)
{
Entity = entity;
}
public override IconSource? CreateIcon()
{
@@ -27,19 +34,4 @@ public sealed partial class EntityNode : SceneGraphNode
{
throw new NotImplementedException();
}
public override DataTemplate GetSceneHierarchyTemplate()
{
var template = @"
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:Key=""EntityTemplate"" x:DataType=""sg:SceneGraphNode"">
<TreeViewItem AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}"" ItemsSource=""{x:Bind Children, Mode=OneWay}"">
<StackPanel Margin=""10,0"" Orientation=""Horizontal"">
<FontIcon FontSize=""14"" Glyph=""&#xF158;"" />
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" />
</StackPanel>
</TreeViewItem>
</DataTemplate>";
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
}
}

View File

@@ -0,0 +1,521 @@
# SceneGraph Implementation Plan
## Summary of Design Decisions
| Decision | Choice |
|----------|--------|
| Hierarchy data model | Linked-list (`parent`/`firstChild`/`nextSibling`), efficient for ECS |
| Editor ↔ World relationship | Mirror — editor world holds ECS entities, SceneGraph reflects them as observable nodes |
| Transform representation | `LocalToWorld` matrix only (no separate PRS components) |
| Implementation order | Data structures → HierarchySystem → Mirror bridge → UI → Serialization |
---
## Phase 1: Fix SceneGraph Data Structures
**Goal:** Make `EntityNode`, `SceneNode`, and `SceneGraphNode` correct, complete, and constructible.
### 1.1 Enhance `SceneGraphNode`
**File:** `SceneGraphNode.cs`
- Add a `World` property so every node knows which world it belongs to.
- Make the constructor accept `World` and `string name`.
- Keep `ObservableObject` for MVVM binding.
- Keep `Children` as `ObservableCollection<SceneGraphNode>`.
```csharp
public abstract partial class SceneGraphNode : ObservableObject, IInspectable
{
public World World { get; }
[ObservableProperty]
public partial string Name { get; set; }
public ObservableCollection<SceneGraphNode> Children { get; } = new();
protected SceneGraphNode(World world, string name)
{
World = world;
Name = name;
}
// ... existing abstract members
}
```
### 1.2 Enhance `SceneNode`
**File:** `SceneNode.cs`
- Add a `Scene` field referencing the runtime `Scene` struct.
- Add a constructor: `SceneNode(World world, Scene scene, string name)`.
- Remove `XamlReader.Load()` template generation (Phase 4 will move to XAML resources).
```csharp
public sealed partial class SceneNode : SceneGraphNode
{
public Scene Scene { get; }
public SceneNode(World world, Scene scene, string name)
: base(world, name)
{
Scene = scene;
}
// icon, header, inspector, template
}
```
### 1.3 Enhance `EntityNode`
**File:** `EntityNode.cs`
- Add a constructor: `EntityNode(World world, Entity entity, string name)`.
- Remove `XamlReader.Load()` template.
- Entity reference is read-only after construction (immutable node identity).
```csharp
public sealed partial class EntityNode : SceneGraphNode
{
public Entity Entity { get; }
public EntityNode(World world, Entity entity, string name)
: base(world, name)
{
Entity = entity;
}
// icon, header, inspector, template
}
```
### 1.4 Add `SceneGraphBuilder`
**New file:** `SceneGraphBuilder.cs`
Used for the **initial full build** of the scene graph from an ECS `World`. After initial construction, incremental updates are handled by `SceneGraphSyncService` (Phase 3).
**Algorithm:**
1. Query all entities with `SceneID` component.
2. Group entities by `SceneID.scene.ID`.
3. For each scene group:
- Create a `SceneNode` (name comes from editor metadata, not from runtime).
- Walk the `Hierarchy` component linked-list to build the tree:
- Root entities are those with `Hierarchy.parent == Entity.Invalid`.
- For each root, create an `EntityNode` and recursively visit `firstChild``nextSibling`.
- Entity names come from a naming system (either a component like `EntityName`, or editor-side metadata stored on the node).
4. Return `ObservableCollection<SceneGraphNode>` (the list of root scenes).
**Name resolution strategy:**
Names are stored directly on `EntityNode.Name`. During initial build, all entities get default names. During incremental sync, existing nodes (and their user-assigned names) are preserved because nodes are matched by `Entity` identity rather than recreated. See Phase 1.5 and 3.2 for details.
### 1.5 Entity Names (Editor-Only)
**No runtime component.** Entity names have no purpose in the runtime ECS. Names live purely on the editor side, stored directly on `EntityNode.Name`.
**Strategy:** Entity names persist naturally across syncs because `EntityNode` instances survive — the sync is incremental, not a full rebuild (see Phase 3.2). Names are read directly from `EntityNode.Name` for inspector display and serialization.
- New entities get a default name (e.g., `"Entity"`).
- User-renamed entities keep their name because the node instance is preserved.
- Destroyed entities have their node removed (names disappear with them).
- No dictionary, no cross-world identity concerns, no runtime footprint.
---
## Phase 2: HierarchySystem (Runtime)
**Goal:** Maintain the `Hierarchy` component's linked-list invariants.
**New file:** `Runtime/Ghost.Engine/Systems/HierarchySystem.cs`
### 2.1 Operations
The system exposes static helper methods (or instance methods on `EntityManager`) for hierarchy mutation. All mutations use `EntityCommandBuffer` internally to avoid structural changes mid-iteration.
| Operation | Description |
|-----------|-------------|
| `SetParent(child, parent)` | Links `child` as a child of `parent`. Updates `parent`, `firstChild`, `nextSibling` on both sides. |
| `RemoveParent(child)` | Unlinks `child` from its current parent. It becomes a root entity. |
| `DestroyEntity(entity)` | Also unlinks from parent, reparents children to grandparent (or makes them roots). |
### 2.2 `SetParent(Entity child, Entity parent)`
**Preconditions:**
- `child.IsValid` and `parent.IsValid`.
- Both entities exist in the world.
- Both have the `Hierarchy` component.
- `child != parent` (no self-parenting).
- `parent` is not a descendant of `child` (no cycles).
**Algorithm:**
1. If `child` already has a parent, call `RemoveParent(child)` first.
2. Get `ref childHierarchy`, `ref parentHierarchy`.
3. Set `childHierarchy.parent = parent`.
4. Set `childHierarchy.nextSibling = parentHierarchy.firstChild`.
5. Set `parentHierarchy.firstChild = child`.
### 2.3 `RemoveParent(Entity child)`
**Preconditions:** `child` has the `Hierarchy` component and has a parent.
**Algorithm:**
1. Get `ref childHierarchy`. Let `parent_entity = childHierarchy.parent`.
2. Get `ref parentHierarchy`.
3. Walk the sibling chain of `parentHierarchy.firstChild` to find and unlink `child`:
- If `firstChild == child`: set `firstChild = childHierarchy.nextSibling`.
- Else: walk `prev → current → nextSibling`, set `prev.nextSibling = childHierarchy.nextSibling`.
4. Set `childHierarchy.parent = Entity.Invalid`.
5. Set `childHierarchy.nextSibling = Entity.Invalid`.
### 2.4 Entity Destruction Cleanup
When an entity is destroyed via `EntityManager.DestroyEntity()`:
1. Get its `Hierarchy` component.
2. Call `RemoveParent(entity)`.
3. **Cascade destroy all children:** Walk `firstChild``nextSibling` and recursively destroy every descendant entity. Destroy children before destroying the parent to ensure correct cleanup order.
4. When saving/loading, this means saving an entity implies saving its entire subtree. Deleting an entity implies deleting its entire subtree.
### 2.5 Validation
Each method validates invariants in `DEBUG`/`GHOST_EDITOR` builds:
- No entity appears as its own ancestor (cycle detection via ancestor walk).
- `firstChild`/`parent`/`nextSibling` references are all valid entities or `Entity.Invalid`.
- No dangling references.
### 2.6 `HierarchySystem` as an `ISystem`
The system does NOT run every frame automatically. Parent/child mutations are explicit (called from editor commands or loading code). The system's `Update()` is a no-op. It exists as an `ISystem` so it can:
- Register its existence in the system graph.
- Be queried via `World.SystemManager`.
- Hold references to necessary queries.
Alternatively: make it a static utility class that takes `World` as a parameter. **Recommendation:** static utility class (`HierarchyUtility`) to avoid entity archetype cost for a no-op system. The `Hierarchy` component itself drives the tree shape.
---
## Phase 3: Mirror Bridge (Editor ↔ ECS sync)
**Goal:** Keep the `SceneGraphNode` tree in the editor synchronized with the ECS `World`.
### 3.1 `EditorWorldService`
**New file:** `Editor/Ghost.Editor.Core/Services/EditorWorldService.cs`
- Registers as a singleton via `[EditorInjection(ServiceLifetime.Singleton)]`.
- Creates the editor `World` on startup.
- Holds the `World` reference.
- Disposes the world on editor shutdown.
```csharp
[EditorInjection(ServiceLifetime.Singleton)]
public class EditorWorldService : IDisposable
{
public World EditorWorld { get; }
public EditorWorldService()
{
EditorWorld = World.Create(entityCapacity: 1024);
}
public void Dispose()
{
World.Destroy(EditorWorld.ID);
}
}
```
### 3.2 Scene Graph Sync Strategy
**Incremental sync via polling.** Full rebuild is avoided — existing nodes are matched by `Entity` identity and preserved across syncs. This keeps names, selection state, and expanded/collapsed state intact.
**Algorithm (runs on timer, e.g., every 100ms):**
1. Check `EditorWorld.Version` — if unchanged, skip.
2. Query all entities with `SceneID` component from the editor world.
3. Group by `SceneID.scene.ID`.
4. **For each scene group:**
- Find or create the `SceneNode` in `RootNodes` (match by `Scene.ID`).
- Walk the `Hierarchy` linked-list of roots.
- **For each root entity:** find existing `EntityNode` in the tree by `Entity` identity. If found, keep the node (name, expansion, selection intact). If not found, create a new `EntityNode` with default name.
- **Recurse** into `firstChild``nextSibling`, matching existing nodes at each level.
5. **Remove stale nodes:** Any `EntityNode` not matched in step 4 is destroyed (entity no longer exists in world). Remove these nodes (and their subtrees) from the tree.
6. **Update hierarchy links:** If a matched entity's parent changed, move the `EntityNode` to its new parent's `Children` collection.
**Key benefits of incremental sync:**
- `EntityNode.Name` persists naturally — node instances survive.
- No dictionary, no name map.
- Selection and tree expansion state survive across syncs.
- Only affected subtrees change — minimal UI churn.
### 3.3 Incremental Sync Implementation
**New file:** `Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs`
```csharp
[EditorInjection(ServiceLifetime.Singleton)]
public class SceneGraphSyncService
{
private readonly EditorWorldService _worldService;
private uint _lastSyncedVersion;
public ObservableCollection<SceneGraphNode> RootNodes { get; } = new();
public void Tick()
{
var currentVersion = _worldService.EditorWorld.Version;
if (currentVersion == _lastSyncedVersion)
return;
_lastSyncedVersion = currentVersion;
SyncScenesAndEntities(_worldService.EditorWorld);
}
private void SyncScenesAndEntities(World world)
{
// 1. Query all entities with SceneID
// 2. Group by scene ID
// 3. For each scene: match/create SceneNode, walk Hierarchy linked-list,
// match/create EntityNode, remove stale nodes, update Children links
}
}
```
### 3.4 Selection → Inspector Wiring
Already partially wired:
- `InspectorService.SetSelected(IInspectable, source)` exists.
- `SceneGraphNode : IInspectable` exists.
- Clicking a `TreeViewItem` sets selection → fires event → calls `SetSelected`.
**To complete:**
- In `Hierarchy.xaml.cs`, handle `TreeView.ItemInvoked` or selection changed.
- Call `InspectorService.SetSelected(node, this)`.
---
## Phase 4: TreeView UI
**Goal:** Replace `ListView` placeholder with a functional `TreeView` bound to the scene graph.
### 4.1 Replace `ListView` with `TreeView`
**File:** `Hierarchy.xaml`
Changes:
- Replace `<ListView>` with `<TreeView>`.
- Bind `ItemsSource` to `RootNodes` from `SceneGraphSyncService`.
- Use a `DataTemplateSelector` that picks `SceneNode.GetSceneHierarchyTemplate()` or `EntityNode.GetSceneHierarchyTemplate()` based on node type.
- Move templates to XAML resources (static `DataTemplate` in `Page.Resources` or a `ResourceDictionary`) instead of `XamlReader.Load()`.
**Template selection:**
```xml
<TreeView ItemsSource="{x:Bind ViewModel.RootNodes, Mode=OneWay}">
<TreeView.ItemTemplateSelector>
<local:SceneGraphTemplateSelector />
</TreeView.ItemTemplateSelector>
</TreeView>
```
### 4.2 Move Templates to XAML Resources
Create a `ResourceDictionary` (or put in `App.xaml` / `Hierarchy.xaml.Resources`):
```xml
<DataTemplate x:Key="SceneNodeTemplate" x:DataType="sg:SceneNode">
<TreeViewItem AutomationProperties.Name="{x:Bind Name, Mode=OneWay}"
IsExpanded="True"
ItemsSource="{x:Bind Children, Mode=OneWay}">
<StackPanel Orientation="Horizontal">
<FontIcon FontSize="14" Glyph="&#xF156;"/>
<TextBlock Margin="10,0,0,0" Text="{x:Bind Name, Mode=OneWay}"/>
</StackPanel>
</TreeViewItem>
</DataTemplate>
<DataTemplate x:Key="EntityNodeTemplate" x:DataType="sg:EntityNode">
<TreeViewItem AutomationProperties.Name="{x:Bind Name, Mode=OneWay}"
ItemsSource="{x:Bind Children, Mode=OneWay}">
<StackPanel Orientation="Horizontal">
<FontIcon FontSize="14" Glyph="&#xF158;"/>
<TextBlock Margin="5,0,0,0" Text="{x:Bind Name, Mode=OneWay}"/>
</StackPanel>
</TreeViewItem>
</DataTemplate>
```
### 4.3 DataTemplateSelector
**New file:** `Editor/Ghost.Editor/Views/Controls/SceneGraphTemplateSelector.cs`
```csharp
public class SceneGraphTemplateSelector : DataTemplateSelector
{
public DataTemplate SceneNodeTemplate { get; set; }
public DataTemplate EntityNodeTemplate { get; set; }
protected override DataTemplate SelectTemplateCore(object item)
{
return item switch
{
SceneNode => SceneNodeTemplate,
EntityNode => EntityNodeTemplate,
_ => base.SelectTemplateCore(item)
};
}
}
```
### 4.4 Remove `GetSceneHierarchyTemplate()` from Node Classes
Once templates are in XAML resources (Phase 4.2), the `GetSceneHierarchyTemplate()` method on `SceneGraphNode` is no longer needed. Remove it from the abstract class and both subclasses. Do NOT remove it in Phase 1 — the method is still referenced until the XAML templates are ready.
### 4.5 Context Menu Support
Add `TreeViewItem.ContextFlyout` or right-click handling for:
- **Scene-level:** Create Entity, Rename Scene, Unload Scene, Save Scene.
- **Entity-level:** Create Child Entity, Delete Entity, Duplicate Entity, Rename Entity.
Create entity commands go through `EntityCommandBuffer` on the editor world.
### 4.6 Search/Filter
Wire the search `TextBox` to filter the TreeView:
- On text change, iterate `RootNodes` recursively.
- If a node or any descendant matches, show it. Otherwise collapse/hide.
- WinUI `TreeView` doesn't have built-in filtering, so implement a filtered copy of the tree or use `Visibility` toggling.
### 4.7 Drag & Drop Reparenting (Stretch Goal)
- Allow dragging an `EntityNode` onto another `EntityNode` to reparent.
- Uses WinUI drag-and-drop APIs.
- Calls `HierarchyUtility.SetParent()` on the editor world.
- Scene graph refreshes via sync.
---
## Phase 5: Serialization (JSON Editor / Binary Runtime)
### 5.1 File Local ID Scheme
When serializing a scene, entities are ordered (index in the list = file-local ID). All `Entity` references within components are serialized as file-local IDs, not global entity IDs.
**Rationale:** Global entity IDs are unpredictable across loads. File-local IDs are deterministic (they are just the list index) and remapped on load.
### 5.2 Serialization Format
**JSON (Editor) — in `Ghost.Editor.Core`:**
```json
{
"name": "MainScene",
"entities": [
{
"components": {
"Ghost.Engine.Components.Hierarchy": {
"parent": -1,
"firstChild": 1,
"nextSibling": -1
},
"Ghost.Engine.Components.LocalToWorld": {
"matrix": { "m00": 1.0, ... }
}
}
},
{
"components": { ... }
}
]
}
```
- Entity index in the array = file-local ID.
- `Entity` references (`parent`, `firstChild`, `nextSibling`) stored as `int` file-local IDs. `-1` = `Entity.Invalid`.
- Component types stored by their stable `FullName` string.
- Uses `System.Text.Json` with reflection (allowed in editor).
**Binary (Runtime) — in `Ghost.Engine`:**
- MemoryPack serialization of the same structure.
- Must be AOT-compatible (use source-generated formatters).
- Components are blittable, so MemoryPack handles them efficiently.
### 5.3 Save Algorithm
1. Get all entities with `SceneID == targetScene` via `SceneManager.GetSceneEntities()`.
2. Sort entities in a deterministic order (e.g., by hierarchy depth-first traversal for deterministic output).
3. Create a `fileLocalID → Entity` map (list index → entity).
4. Create a reverse `Entity → fileLocalID` map.
5. For each entity, serialize all components.
6. For any component field of type `Entity`, replace with the file-local ID (using the reverse map).
7. For `ManagedEntityRef` / script data, serialize via MemoryPack.
8. Write to file.
### 5.4 Load Algorithm
1. Deserialize JSON/binary → list of entity component data.
2. Allocate all entities in the target `World` (no components yet, or minimal archetype).
3. Build `fileLocalID → Entity` map (list index → new global entity).
4. For each entity:
- Add its components to the entity.
- For any `Entity`-typed field, look up the file-local ID in the map and replace with the new global entity.
5. Call `HierarchyUtility` to validate/repair hierarchy invariants if needed.
6. Return the count of loaded entities.
### 5.5 References to Other Scenes
Per the architecture plan, cross-scene references are not supported. If a component references an entity from another scene:
- On save: log a warning and serialize as `-1` (invalid).
- On load: the reference will be `Entity.Invalid`.
### 5.6 File Naming
Scene files use the `.gscene` extension (`g` = GhostEngine):
- `Assets/Scenes/{SceneName}.gscene.json` (editor JSON)
- `Assets/Scenes/{SceneName}.gscene` (runtime binary)
**Scene name resolution:**
- The scene's name derives from the file name (minus extension). E.g., `MyScene.gscene` → name is `"MyScene"`.
- For unsaved/new scenes: default name is `"NewScene"`.
- `SceneNode.Name` is set from the file name on load, and used as the save target on save.
---
## Component Checklist
| Phase | Task | File(s) |
|-------|------|---------|
| **1.1** | Enhance `SceneGraphNode` — add `World`, constructor | `SceneGraphNode.cs` |
| **1.2** | Enhance `SceneNode` — add `Scene` field, constructor | `SceneNode.cs` |
| **1.3** | Enhance `EntityNode` — add constructor, make usable | `EntityNode.cs` |
| **1.4** | Add `SceneGraphBuilder` | New: `SceneGraphBuilder.cs` |
| **1.5** | Entity name strategy (editor-only, no component) | See Section 1.4 |
| **2** | Add `HierarchyUtility` static class | New: `Runtime/.../HierarchyUtility.cs` |
| **3.1** | Add `EditorWorldService` | New: `Editor/.../EditorWorldService.cs` |
| **3.2-3** | Add `SceneGraphSyncService` | New: `Editor/.../SceneGraphSyncService.cs` |
| **4.1** | Replace `ListView` with `TreeView` in XAML | `Hierarchy.xaml` |
| **4.2** | Move templates to XAML resources | `Hierarchy.xaml` |
| **4.3** | Add `SceneGraphTemplateSelector` | New: `Views/Controls/SceneGraphTemplateSelector.cs` |
| **4.4** | Remove `GetSceneHierarchyTemplate()` methods | `SceneGraphNode.cs`, `.cs`, `.cs` |
| **4.5** | Context menu (create/delete entity) | `Hierarchy.xaml.cs` |
| **4.6** | Search/filter | `Hierarchy.xaml.cs` |
| **4.7** | Drag-drop reparenting (stretch) | `Hierarchy.xaml.cs` |
| **5.1-5** | Scene save/load with ID remapping | New files in `Serialization/` |
| **5.5** | MemoryPack source-gen formatters | `Ghost.Engine` |
---
## Resolved Questions
| # | Question | Decision |
|---|----------|----------|
| 1 | Entity name storage | **Editor-only** — names live on `EntityNode.Name` directly. Persist across syncs via incremental matching by `Entity` identity (no dictionary needed). No runtime component. |
| 2 | Orphan behavior on parent destroy | **Cascade destroy** all children recursively. |
| 3 | Remove `GetSceneHierarchyTemplate()` timing | **Phase 4** — keep until XAML templates are in place. |
| 4 | Scene name persistence | **File name** — name = file name minus `.gscene` extension. Unsaved scenes default to `"NewScene"`. |

View File

@@ -0,0 +1,156 @@
using Ghost.Engine.Components;
using Ghost.Engine.Core;
using Ghost.Entities;
namespace Ghost.Editor.Core.SceneGraph;
public static class SceneGraphBuilder
{
public static List<SceneNode> Build(World world)
{
var sceneNodes = new List<SceneNode>();
var sceneEntities = GroupEntitiesByScene(world);
foreach (var (scene, entities) in sceneEntities)
{
var sceneName = GetDefaultSceneName(scene);
var sceneNode = new SceneNode(world, scene, sceneName);
BuildEntityTree(entities, sceneNode);
sceneNodes.Add(sceneNode);
}
return sceneNodes;
}
private static Dictionary<Scene, List<Entity>> GroupEntitiesByScene(World world)
{
var sceneMap = new Dictionary<Scene, List<Entity>>();
var queryID = new QueryBuilder().WithAll<SceneID>().Build(world);
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
foreach (var chunk in query.GetChunkIterator())
{
var entities = chunk.GetEntities();
var sceneIDs = chunk.GetComponentData<SceneID>();
for (var i = 0; i < chunk.EntityCount; i++)
{
var s = sceneIDs[i].scene;
if (!s.IsValid)
{
continue;
}
if (!sceneMap.TryGetValue(s, out var list))
{
list = new List<Entity>();
sceneMap[s] = list;
}
list.Add(entities[i]);
}
}
return sceneMap;
}
private static void BuildEntityTree(List<Entity> entities, SceneGraphNode parentNode)
{
var entitySet = new HashSet<Entity>(entities);
var childrenByParent = new Dictionary<Entity, List<Entity>>();
var roots = new List<Entity>();
foreach (var entity in entities)
{
Hierarchy hierarchy = default;
var hasHierarchy = TryGetHierarchyComponent(parentNode.World, entity, ref hierarchy);
if (hasHierarchy && hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
{
if (!childrenByParent.TryGetValue(hierarchy.parent, out var list))
{
list = new List<Entity>();
childrenByParent[hierarchy.parent] = list;
}
list.Add(entity);
}
else
{
roots.Add(entity);
}
}
foreach (var rootEntity in roots)
{
var entityNode = new EntityNode(parentNode.World, rootEntity, "Entity");
parentNode.Children.Add(entityNode);
BuildSubtree(entityNode, childrenByParent);
}
}
private static void BuildSubtree(EntityNode parentNode, Dictionary<Entity, List<Entity>> childrenByParent)
{
if (!childrenByParent.TryGetValue(parentNode.Entity, out var childList))
{
return;
}
Hierarchy parentHierarchy = default;
if (!TryGetHierarchyComponent(parentNode.World, parentNode.Entity, ref parentHierarchy))
{
foreach (var childEntity in childList)
{
var childNode = new EntityNode(parentNode.World, childEntity, "Entity");
parentNode.Children.Add(childNode);
BuildSubtree(childNode, childrenByParent);
}
return;
}
var sibling = parentHierarchy.firstChild;
while (sibling.IsValid)
{
if (childList.Contains(sibling))
{
var childNode = new EntityNode(parentNode.World, sibling, "Entity");
parentNode.Children.Add(childNode);
BuildSubtree(childNode, childrenByParent);
}
Hierarchy siblingHierarchy = default;
if (!TryGetHierarchyComponent(parentNode.World, sibling, ref siblingHierarchy))
{
break;
}
sibling = siblingHierarchy.nextSibling;
}
}
private static unsafe bool TryGetHierarchyComponent(World world, Entity entity, ref Hierarchy hierarchy)
{
var location = world.EntityManager.GetEntityLocation(entity);
if (!location.IsSuccess)
{
return false;
}
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.Value.archetypeID);
var hierarchyID = ComponentTypeID<Hierarchy>.Value;
var pData = archetype.GetComponentData(location.Value.chunkIndex, location.Value.rowIndex, hierarchyID);
if (pData == null)
{
return false;
}
hierarchy = *(Hierarchy*)pData;
return true;
}
private static string GetDefaultSceneName(Scene scene)
{
return $"NewScene ({scene.ID})";
}
}

View File

@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ghost.Editor.Core.Contracts;
using Ghost.Entities;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Collections.ObjectModel;
@@ -14,14 +15,23 @@ public abstract partial class SceneGraphNode : ObservableObject, IInspectable
get; set;
}
public World World
{
get;
}
public ObservableCollection<SceneGraphNode> Children
{
get;
} = new();
protected SceneGraphNode(World world, string name)
{
World = world;
Name = name;
}
public abstract IconSource? CreateIcon();
public abstract UIElement? CreateHeader();
public abstract UIElement? CreateInspector();
public abstract DataTemplate GetSceneHierarchyTemplate();
}

View File

@@ -1,3 +1,5 @@
using Ghost.Engine.Core;
using Ghost.Entities;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -5,6 +7,17 @@ namespace Ghost.Editor.Core.SceneGraph;
public sealed partial class SceneNode : SceneGraphNode
{
public Scene Scene
{
get;
}
public SceneNode(World world, Scene scene, string name)
: base(world, name)
{
Scene = scene;
}
public override IconSource? CreateIcon()
{
return new FontIconSource
@@ -13,7 +26,6 @@ public sealed partial class SceneNode : SceneGraphNode
};
}
// TODO: Implement custom header and inspector UI for the SceneNode
public override UIElement? CreateHeader()
{
return null;
@@ -23,23 +35,4 @@ public sealed partial class SceneNode : SceneGraphNode
{
return null;
}
public override DataTemplate GetSceneHierarchyTemplate()
{
var template = @"
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:DataType=""sg:SceneGraphNode"">
<TreeViewItem
AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}""
Background=""{ThemeResource ControlSolidFillColorDefaultBrush}""
IsExpanded=""True""
ItemsSource=""{ x:Bind Children, Mode=OneWay}"" >
<StackPanel Orientation=""Horizontal"" >
<FontIcon FontSize=""14"" Glyph=""&#xF156;""/>
<TextBlock Margin=""10,0"" Text=""{ x:Bind Name, Mode=OneWay}""/>
</StackPanel>
</TreeViewItem>
</DataTemplate>";
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
}
}

View File

@@ -0,0 +1,54 @@
using Ghost.Editor.Core.SceneGraph;
using Ghost.Entities;
using Ghost.Engine.Core;
using System.Collections.ObjectModel;
namespace Ghost.Editor.Core.Services;
[EditorInjection(EditorInjectionAttribute.ServiceLifetime.Singleton, typeof(EditorWorldService))]
public class EditorWorldService : IDisposable
{
private const int DEFAULT_ENTITY_CAPACITY = 1024;
public World EditorWorld
{
get;
}
public ObservableCollection<SceneNode> RootNodes
{
get;
} = new();
public EditorWorldService()
{
EditorWorld = World.Create(entityCapacity: DEFAULT_ENTITY_CAPACITY);
CreateDefaultScene();
RebuildSceneGraph();
}
public void CreateDefaultScene()
{
var scene = SceneManager.CreateScene();
var entity = EditorWorld.EntityManager.CreateEntity();
EditorWorld.EntityManager.AddComponent(entity, new Ghost.Engine.Components.SceneID
{
scene = scene
});
}
public void RebuildSceneGraph()
{
RootNodes.Clear();
var sceneNodes = SceneGraphBuilder.Build(EditorWorld);
foreach (var node in sceneNodes)
{
RootNodes.Add(node);
}
}
public void Dispose()
{
World.Destroy(EditorWorld.ID);
}
}

View File

@@ -0,0 +1,224 @@
using Ghost.Editor.Core.SceneGraph;
using Ghost.Engine;
using Ghost.Engine.Components;
using Ghost.Engine.Core;
using Ghost.Entities;
namespace Ghost.Editor.Core.Services;
[EditorInjection(EditorInjectionAttribute.ServiceLifetime.Singleton, typeof(SceneGraphSyncService))]
public class SceneGraphSyncService
{
private readonly EditorWorldService _worldService;
private uint _lastSyncedVersion;
public SceneGraphSyncService(EditorWorldService worldService)
{
_worldService = worldService;
}
public bool Tick()
{
var currentVersion = _worldService.EditorWorld.Version;
if (currentVersion == _lastSyncedVersion)
{
return false;
}
_lastSyncedVersion = currentVersion;
SyncScenesAndEntities(_worldService.EditorWorld);
return true;
}
private void SyncScenesAndEntities(World world)
{
var sceneEntities = GroupEntitiesByScene(world);
foreach (var (scene, entities) in sceneEntities)
{
var sceneNode = FindOrCreateSceneNode(world, scene, _worldService.RootNodes);
SyncEntityTree(sceneNode, entities);
RemoveStaleEntityNodes(sceneNode, entities);
}
var activeScenes = new HashSet<Scene>(sceneEntities.Keys);
RemoveStaleSceneNodes(_worldService.RootNodes, activeScenes);
}
private static SceneNode FindOrCreateSceneNode(World world, Scene scene, System.Collections.ObjectModel.ObservableCollection<SceneNode> rootNodes)
{
foreach (var existing in rootNodes)
{
if (existing.Scene == scene)
{
return existing;
}
}
var sceneName = $"NewScene ({scene.ID})";
var newSceneNode = new SceneNode(world, scene, sceneName);
rootNodes.Add(newSceneNode);
return newSceneNode;
}
private void SyncEntityTree(SceneGraphNode parentNode, List<Entity> entities)
{
var entitySet = new HashSet<Entity>(entities);
var children = new Dictionary<Entity, List<Entity>>();
var roots = new List<Entity>();
foreach (var entity in entities)
{
Hierarchy hierarchy = default;
var hasHierarchy = TryGetHierarchyComponent(parentNode.World, entity, ref hierarchy);
if (hasHierarchy && hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
{
if (!children.TryGetValue(hierarchy.parent, out var list))
{
list = new List<Entity>();
children[hierarchy.parent] = list;
}
list.Add(entity);
}
else
{
roots.Add(entity);
}
}
SyncExistingNodes(parentNode, roots, children);
}
private static void SyncExistingNodes(SceneGraphNode parentNode, List<Entity> roots, Dictionary<Entity, List<Entity>> children)
{
var existingNodeMap = new Dictionary<Entity, EntityNode>();
for (var i = parentNode.Children.Count - 1; i >= 0; i--)
{
if (parentNode.Children[i] is EntityNode entityNode)
{
if (!roots.Contains(entityNode.Entity) && !children.ContainsKey(entityNode.Entity))
{
parentNode.Children.RemoveAt(i);
}
else
{
existingNodeMap[entityNode.Entity] = entityNode;
}
}
}
for (var i = 0; i < roots.Count; i++)
{
var entity = roots[i];
if (existingNodeMap.TryGetValue(entity, out var existingNode))
{
existingNodeMap.Remove(entity);
if (i >= parentNode.Children.Count || parentNode.Children[i] != existingNode)
{
parentNode.Children.Remove(existingNode);
parentNode.Children.Insert(i, existingNode);
}
}
else
{
var newNode = new EntityNode(parentNode.World, entity, "Entity");
parentNode.Children.Insert(i, newNode);
existingNode = newNode;
}
if (children.TryGetValue(entity, out var childList))
{
SyncExistingNodes(existingNode, childList, children);
}
else
{
for (var j = existingNode.Children.Count - 1; j >= 0; j--)
{
if (existingNode.Children[j] is EntityNode)
{
existingNode.Children.RemoveAt(j);
}
}
}
}
}
private static void RemoveStaleEntityNodes(SceneGraphNode parentNode, List<Entity> entities)
{
var entitySet = new HashSet<Entity>(entities);
for (var i = parentNode.Children.Count - 1; i >= 0; i--)
{
if (parentNode.Children[i] is EntityNode entityNode && !entitySet.Contains(entityNode.Entity))
{
parentNode.Children.RemoveAt(i);
}
}
}
private static void RemoveStaleSceneNodes(System.Collections.ObjectModel.ObservableCollection<SceneNode> rootNodes, HashSet<Scene> activeScenes)
{
for (var i = rootNodes.Count - 1; i >= 0; i--)
{
if (!activeScenes.Contains(rootNodes[i].Scene))
{
rootNodes.RemoveAt(i);
}
}
}
private static Dictionary<Scene, List<Entity>> GroupEntitiesByScene(World world)
{
var sceneMap = new Dictionary<Scene, List<Entity>>();
var queryID = new QueryBuilder().WithAll<SceneID>().Build(world);
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
foreach (var chunk in query.GetChunkIterator())
{
var entities = chunk.GetEntities();
var sceneIDs = chunk.GetComponentData<SceneID>();
for (var i = 0; i < chunk.EntityCount; i++)
{
var s = sceneIDs[i].scene;
if (!s.IsValid)
{
continue;
}
if (!sceneMap.TryGetValue(s, out var list))
{
list = new List<Entity>();
sceneMap[s] = list;
}
list.Add(entities[i]);
}
}
return sceneMap;
}
private static unsafe bool TryGetHierarchyComponent(World world, Entity entity, ref Hierarchy hierarchy)
{
var location = world.EntityManager.GetEntityLocation(entity);
if (!location.IsSuccess)
{
return false;
}
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.Value.archetypeID);
var hierarchyID = ComponentTypeID<Hierarchy>.Value;
var pData = archetype.GetComponentData(location.Value.chunkIndex, location.Value.rowIndex, hierarchyID);
if (pData == null)
{
return false;
}
hierarchy = *(Hierarchy*)pData;
return true;
}
}

View File

@@ -38,10 +38,10 @@
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" Version="8.2.251219" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<PackageReference Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.1" />
<PackageReference Include="WinUIEx" Version="2.9.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -5,9 +5,40 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Ghost.Editor.Views.Controls"
xmlns:sg="using:Ghost.Editor.Core.SceneGraph"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<UserControl.Resources>
<local:SceneGraphTemplateSelector
x:Key="SceneGraphTemplateSelector"
SceneNodeTemplate="{StaticResource SceneNodeTemplate}"
EntityNodeTemplate="{StaticResource EntityNodeTemplate}" />
<DataTemplate x:Key="SceneNodeTemplate" x:DataType="sg:SceneNode">
<TreeViewItem
AutomationProperties.Name="{x:Bind Name, Mode=OneWay}"
IsExpanded="True"
ItemsSource="{x:Bind Children, Mode=OneWay}">
<StackPanel Orientation="Horizontal">
<FontIcon FontSize="14" Glyph="&#xF156;" />
<TextBlock Margin="10,0,0,0" Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</TreeViewItem>
</DataTemplate>
<DataTemplate x:Key="EntityNodeTemplate" x:DataType="sg:EntityNode">
<TreeViewItem
AutomationProperties.Name="{x:Bind Name, Mode=OneWay}"
ItemsSource="{x:Bind Children, Mode=OneWay}">
<StackPanel Orientation="Horizontal">
<FontIcon FontSize="14" Glyph="&#xF158;" />
<TextBlock Margin="5,0,0,0" Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</TreeViewItem>
</DataTemplate>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -50,19 +81,17 @@
Margin="0,0,4,0"
FontSize="{StaticResource ToolbarFontIconFontSize}"
Glyph="&#xE721;" />
<TextBox Grid.Column="1" PlaceholderText="Sreach item..." />
<TextBox Grid.Column="1" PlaceholderText="Search item..." />
</Grid>
<Border Margin="-8,8,-4,-4" Style="{StaticResource HorizontalStrongDivider}" />
</StackPanel>
<ListView Grid.Row="1" Padding="4,2,0,2">
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
<ListViewItem Content="Test" />
</ListView>
<TreeView
x:Name="SceneTreeView"
Grid.Row="1"
Padding="4,2,0,2"
ItemTemplateSelector="{StaticResource SceneGraphTemplateSelector}"
SelectionMode="Single" />
</Grid>
</UserControl>

View File

@@ -1,14 +1,62 @@
using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.Services;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Controls;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Ghost.Editor.Views.Controls;
public sealed partial class Hierarchy : UserControl
{
private readonly SceneGraphSyncService _syncService;
private readonly IInspectorService _inspectorService;
private readonly EditorWorldService _worldService;
private DispatcherQueueTimer? _syncTimer;
public Hierarchy()
{
InitializeComponent();
_syncService = App.GetService<SceneGraphSyncService>();
_inspectorService = App.GetService<IInspectorService>();
_worldService = App.GetService<EditorWorldService>();
SceneTreeView.ItemsSource = _worldService.RootNodes;
SceneTreeView.ItemInvoked += OnTreeViewItemInvoked;
SceneTreeView.SelectionChanged += OnTreeViewSelectionChanged;
_syncTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
_syncTimer.Interval = TimeSpan.FromMilliseconds(100);
_syncTimer.Tick += OnSyncTick;
_syncTimer.Start();
Unloaded += OnUnloaded;
}
private void OnSyncTick(DispatcherQueueTimer sender, object args)
{
if (_syncService.Tick())
{
}
}
private void OnTreeViewItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args)
{
if (args.InvokedItem is IInspectable inspectable)
{
_inspectorService.SetSelected(inspectable, this);
}
}
private void OnTreeViewSelectionChanged(object sender, TreeViewSelectionChangedEventArgs args)
{
}
private void OnUnloaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
_syncTimer?.Stop();
SceneTreeView.ItemInvoked -= OnTreeViewItemInvoked;
SceneTreeView.SelectionChanged -= OnTreeViewSelectionChanged;
Unloaded -= OnUnloaded;
}
}

View File

@@ -0,0 +1,35 @@
using Ghost.Editor.Core.SceneGraph;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Views.Controls;
public partial class SceneGraphTemplateSelector : DataTemplateSelector
{
public DataTemplate? SceneNodeTemplate
{
get; set;
}
public DataTemplate? EntityNodeTemplate
{
get; set;
}
protected override DataTemplate SelectTemplateCore(object item)
{
var result = item switch
{
SceneNode => SceneNodeTemplate,
EntityNode => EntityNodeTemplate,
_ => base.SelectTemplateCore(item)
};
return result!;
}
protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
{
return SelectTemplateCore(item);
}
}

View File

@@ -11,6 +11,10 @@
<IsTrimmable>True</IsTrimmable>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug_Editor'">
<DefineConstants>$(DefineConstants);MHP_ENABLE_STACKTRACE</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Misaki.HighPerformance" Version="1.0.9" />
<PackageReference Include="Misaki.HighPerformance.Jobs" Version="3.1.6" />

View File

@@ -0,0 +1,220 @@
using Ghost.Core;
using Ghost.Entities;
using System.Runtime.CompilerServices;
namespace Ghost.Engine;
public static class HierarchyUtility
{
public static Error SetParent(World world, Entity child, Entity parent)
{
if (!child.IsValid)
{
return Error.InvalidArgument;
}
if (!parent.IsValid)
{
return Error.InvalidArgument;
}
if (child == parent)
{
return Error.InvalidArgument;
}
if (!world.EntityManager.HasComponent<Components.Hierarchy>(child))
{
return Error.NotFound;
}
if (!world.EntityManager.HasComponent<Components.Hierarchy>(parent))
{
return Error.NotFound;
}
if (IsAncestor(world, parent, child))
{
return Error.InvalidArgument;
}
ref var childHierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(child);
if (Unsafe.IsNullRef(ref childHierarchy))
{
return Error.NotFound;
}
if (childHierarchy.parent.IsValid)
{
RemoveParent(world, child);
}
ref var parentHierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(parent);
if (Unsafe.IsNullRef(ref parentHierarchy))
{
return Error.NotFound;
}
childHierarchy.parent = parent;
childHierarchy.nextSibling = parentHierarchy.firstChild;
parentHierarchy.firstChild = child;
return Error.None;
}
public static Error RemoveParent(World world, Entity child)
{
if (!child.IsValid)
{
return Error.InvalidArgument;
}
if (!world.EntityManager.HasComponent<Components.Hierarchy>(child))
{
return Error.NotFound;
}
ref var childHierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(child);
if (Unsafe.IsNullRef(ref childHierarchy))
{
return Error.NotFound;
}
var parent = childHierarchy.parent;
if (!parent.IsValid)
{
return Error.None;
}
ref var parentHierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(parent);
if (Unsafe.IsNullRef(ref parentHierarchy))
{
childHierarchy.parent = Entity.Invalid;
childHierarchy.nextSibling = Entity.Invalid;
return Error.None;
}
var prev = Entity.Invalid;
var current = parentHierarchy.firstChild;
while (current.IsValid)
{
ref var currentHierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(current);
if (Unsafe.IsNullRef(ref currentHierarchy))
{
break;
}
if (current == child)
{
if (prev.IsValid)
{
ref var prevHierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(prev);
prevHierarchy.nextSibling = childHierarchy.nextSibling;
}
else
{
parentHierarchy.firstChild = childHierarchy.nextSibling;
}
break;
}
prev = current;
current = currentHierarchy.nextSibling;
}
childHierarchy.parent = Entity.Invalid;
childHierarchy.nextSibling = Entity.Invalid;
return Error.None;
}
public static void DestroyEntityWithChildren(World world, Entity entity)
{
if (!entity.IsValid)
{
return;
}
if (world.EntityManager.HasComponent<Components.Hierarchy>(entity))
{
ref var hierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(entity);
if (!Unsafe.IsNullRef(ref hierarchy))
{
var child = hierarchy.firstChild;
while (child.IsValid)
{
ref var childHierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(child);
var next = childHierarchy.nextSibling;
DestroyEntityWithChildren(world, child);
child = next;
}
RemoveParent(world, entity);
}
}
world.EntityManager.DestroyEntity(entity);
}
public static bool IsAncestor(World world, Entity entity, Entity potentialAncestor)
{
if (!entity.IsValid || !potentialAncestor.IsValid)
{
return false;
}
if (!world.EntityManager.HasComponent<Components.Hierarchy>(entity))
{
return false;
}
ref var hierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(entity);
if (Unsafe.IsNullRef(ref hierarchy))
{
return false;
}
var current = hierarchy.parent;
while (current.IsValid)
{
if (current == potentialAncestor)
{
return true;
}
if (!world.EntityManager.HasComponent<Components.Hierarchy>(current))
{
break;
}
ref var currentHierarchy = ref world.EntityManager.GetComponent<Components.Hierarchy>(current);
if (Unsafe.IsNullRef(ref currentHierarchy))
{
break;
}
current = currentHierarchy.parent;
}
return false;
}
public static Error RemoveEntity(World world, Entity entity)
{
if (!entity.IsValid)
{
return Error.InvalidArgument;
}
if (world.EntityManager.HasComponent<Components.Hierarchy>(entity))
{
RemoveParent(world, entity);
}
world.EntityManager.DestroyEntity(entity);
return Error.None;
}
}

View File

@@ -125,11 +125,15 @@ internal unsafe struct Chunk : IDisposable
public Chunk(int bufferSize, int capacity, int componentCount, uint globalVersion)
{
_data = new UnsafeArray<byte>(bufferSize, AllocationHandle.Persistent, AllocationOption.Clear);
_versions = new UnsafeArray<uint>(componentCount, AllocationHandle.Persistent);
_capacity = capacity;
_count = 0;
_versions.AsSpan().Fill(globalVersion);
if (componentCount > 0)
{
_versions = new UnsafeArray<uint>(componentCount, AllocationHandle.Persistent);
_versions.AsSpan().Fill(globalVersion);
}
_structuralVersion = globalVersion;
}

View File

@@ -332,6 +332,11 @@ public abstract class SystemGroup : ISystem
public void Initialize(ref readonly SystemAPI systemAPI)
{
if (_systems.Count == 0)
{
return;
}
ThrowIfNotSorted();
foreach (var system in _sortedSystems!)
@@ -342,6 +347,11 @@ public abstract class SystemGroup : ISystem
public void Update(ref readonly SystemAPI systemAPI)
{
if (_systems.Count == 0)
{
return;
}
ThrowIfNotSorted();
foreach (var system in _sortedSystems!)
@@ -352,6 +362,11 @@ public abstract class SystemGroup : ISystem
public void Cleanup(ref readonly SystemAPI systemAPI)
{
if (_systems.Count == 0)
{
return;
}
ThrowIfNotSorted();
foreach (var system in _sortedSystems!)
@@ -399,6 +414,11 @@ public sealed class SystemManager : IDisposable
internal void InitializeAll(TimeData timeData)
{
if (_systems.Count == 0)
{
return;
}
var systemAPI = new SystemAPI
{
Time = timeData,
@@ -413,6 +433,11 @@ public sealed class SystemManager : IDisposable
internal void UpdateAll(TimeData timeData)
{
if (_systems.Count == 0)
{
return;
}
var systemAPI = new SystemAPI
{
Time = timeData,
@@ -427,6 +452,11 @@ public sealed class SystemManager : IDisposable
internal void CleanupAll(TimeData timeData)
{
if (_systems.Count == 0)
{
return;
}
var systemAPI = new SystemAPI
{
Time = timeData,

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);
}
}