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:
@@ -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>
|
||||
|
||||
@@ -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="""" />
|
||||
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" />
|
||||
</StackPanel>
|
||||
</TreeViewItem>
|
||||
</DataTemplate>";
|
||||
|
||||
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=""/>
|
||||
<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=""/>
|
||||
<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"`. |
|
||||
156
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs
Normal file
156
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs
Normal 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})";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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=""""/>
|
||||
<TextBlock Margin=""10,0"" Text=""{ x:Bind Name, Mode=OneWay}""/>
|
||||
</StackPanel>
|
||||
</TreeViewItem>
|
||||
</DataTemplate>";
|
||||
|
||||
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
||||
}
|
||||
}
|
||||
54
src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs
Normal file
54
src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
224
src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs
Normal file
224
src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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="" />
|
||||
<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="" />
|
||||
<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="" />
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
220
src/Runtime/Ghost.Engine/HierarchyUtility.cs
Normal file
220
src/Runtime/Ghost.Engine/HierarchyUtility.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
286
src/Test/Ghost.UnitTest/HierarchyUtilityTests.cs
Normal file
286
src/Test/Ghost.UnitTest/HierarchyUtilityTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
174
src/Test/Ghost.UnitTest/SceneGraphBuilderTests.cs
Normal file
174
src/Test/Ghost.UnitTest/SceneGraphBuilderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user