diff --git a/AGENT_GUIDELINES.md b/AGENT_GUIDELINES.md new file mode 100644 index 0000000..44b5352 --- /dev/null +++ b/AGENT_GUIDELINES.md @@ -0,0 +1,212 @@ +# GhostEngine — Agent Guidelines + +## Repository Overview + +GhostEngine is a C# game engine targeting .NET 10 / Windows, built around: +- **ECS runtime** (`Ghost.Entities`, `Ghost.Core`) — high-performance, AOT-compatible +- **Graphics** (`Ghost.Graphics`, `Ghost.Graphics.RHI`, `Ghost.Graphics.D3D12`) — D3D12 RHI +- **Editor** (`Ghost.Editor`, `Ghost.Editor.Core`, `Ghost.DSL`, `Ghost.Data`) — WinUI 3 (WindowsAppSDK) +- **Third-party bindings** (`Ghost.FMOD`, `Ghost.MeshOptimizer`, `Ghost.Nvtt`, `Ghost.Ufbx`) +- **Tools** (`Ghost.NativeWrapperGen`) + +Solution file: `src/GhostEngine.slnx` +All commands below should be run from the `src/` directory unless noted. + +--- + +## Build Commands + +```shell +# Build entire solution (x64, Debug) +dotnet build GhostEngine.slnx -c Debug -p:Platform=x64 + +# Build entire solution (Release) +dotnet build GhostEngine.slnx -c Release -p:Platform=x64 + +# Build a single project +dotnet build Runtime/Ghost.Entities/Ghost.Entities.csproj + +# Clean +dotnet clean GhostEngine.slnx +``` + +> **Note:** Editor projects (`Ghost.Editor`, `Ghost.Editor.Core`) require +> `net10.0-windows10.0.22621.0` and the Windows App SDK. They will only build +> on a Windows machine with the correct SDK installed. Platform-agnostic runtime +> and test projects target plain `net10.0`. + +--- + +## Test Commands + +There are two test frameworks in use: + +### MSTest — `Ghost.UnitTest` +Standard `dotnet test` runner. Tests are parallelized at method level by default. +Currently the integration test file is `#if false`-guarded until the asset +service is fully wired. + +```shell +# Run all MSTest tests +dotnet test Test/Ghost.UnitTest/Ghost.UnitTest.csproj -c Debug -p:Platform=x64 + +# Run a single test method by name +dotnet test Test/Ghost.UnitTest/Ghost.UnitTest.csproj \ + --filter "FullyQualifiedName~TestAutoMetaGeneration_WhenFileCreated" + +# Run a single test class +dotnet test Test/Ghost.UnitTest/Ghost.UnitTest.csproj \ + --filter "ClassName~AssetDatabaseIntegrationTest" +``` + +### Custom TestRunner — `Ghost.MicroTest` / `Ghost.Entities.Test` +These are console executables driven by `Ghost.Test.Core.TestRunner`. There is +no `dotnet test` integration; run them directly: + +```shell +# Micro tests (native binding smoke tests) +dotnet run --project Test/Ghost.MicroTest/Ghost.MicroTest.csproj + +# ECS benchmarks / manual tests +dotnet run --project Test/Ghost.Entities.Test/Ghost.Entities.Test.csproj -c Release +``` + +To run a specific `ITest` implementation, edit `Program.cs` in the respective +project and call `TestRunner.Run()`. + +--- + +## Code Style + +### EditorConfig (enforced — `src/.editorconfig`) +- Max line length: **200** +- Opening braces always on a **new line** for all C# constructs +- Single-line statements and blocks are **preserved** (not force-expanded) +- **No** primary constructors (`csharp_style_prefer_primary_constructors = false`) +- `System.*` using directives are **not** sorted first +- Import directive groups are **not** separated by blank lines +- Collection expressions and collection initializer syntax are **disabled** + (`dotnet_style_prefer_collection_expression = false`) + +### Language +- C# `latest` (runtime/test projects) or `preview` (editor projects, for `field` + keyword support in .NET 10) +- Nullable reference types: **enabled** everywhere (`enable`) +- Implicit usings: **enabled** (`enable`) +- Unsafe blocks: **enabled** where needed (ECS, graphics, native bindings) + +### Namespaces & File Layout +- One type per file; file name matches type name exactly +- Namespace matches folder structure: `Ghost.[.]` +- `partial` classes are split across files named `TypeName.Purpose.cs` + (e.g. `EntityManager.cs`, `EntityManager.Managed.cs`) +- `AssemblyInfo.cs` holds `[assembly: InternalsVisibleTo(...)]` and assembly + attributes; do not scatter these across regular source files + +### Naming Conventions +| Symbol | Convention | Example | +|--------|-----------|---------| +| Private fields | `_camelCase` | `_jobScheduler` | +| Private static fields | `s_camelCase` | `s_worlds`, `s_logger` | +| Constants (public/private) | `UPPER_SNAKE_CASE` | `ASSET_EXTENSION`, `ASSETS_FOLDER_NAME` | +| Properties & public members | `PascalCase` | `EntityManager`, `IsSuccess` | +| Local variables / params | `camelCase` | `entityCapacity`, `signatureHash` | +| Interfaces | `I` prefix | `IComponent`, `ISystem`, `ITest` | +| Generic type parameters | `T`, `TKey`, `TValue`, `E` | | +| Type-tagged structs (handles) | Generic param encodes context | `Handle`, `Identifier`, `Key64` | + +### Types & Structs +- Prefer `readonly struct` for value types that are logically immutable. +- Prefer `ref struct` / `readonly ref struct` for stack-only types + (`RefResult`, `SystemAPI`, `ChunkView`). +- Use `partial class` to split large classes by concern. +- Avoid primary constructors (disabled by editorconfig). +- Use the `field` keyword (preview feature) for auto-property backing fields + where it simplifies code — only in editor projects that opt in via + `preview`. + +### Imports +- `using` directives at the top of each file, before the `namespace` declaration. +- No blank line between `using` groups (enforced by editorconfig). +- `System.*` namespaces may appear in any order alongside project namespaces. +- Prefer specific `using` imports over global usings for clarity in low-level + performance-critical files. + +### Error Handling +- **Return `Result` / `Result` instead of throwing** for expected failures + (file-not-found, invalid args, etc.). + `Result.Success()` / `Result.Failure(message)` or `Result.Failure(Error.XXX)`. +- Use the typed `Error` enum (`Ghost.Core.Error`) for structured error codes. +- Use `result.ThrowIfFailed()` / `result.GetValueOrThrow()` extension methods at + call sites that want throw-on-failure semantics. +- **Throw exceptions** only for programming errors / invariant violations + (corrupt state, null-ref on internal APIs). +- In performance-critical paths, guard validation behind `#if DEBUG || GHOST_EDITOR` + to eliminate overhead in release builds. +- `Logger.LogError(...)` / `Logger.LogWarning(...)` for non-fatal operational + issues; do not use `Console.WriteLine` in production library code. + +### Performance Patterns +- Annotate hot paths with `[MethodImpl(MethodImplOptions.AggressiveInlining)]`. +- Annotate log/assert helpers with `[StackTraceHidden]`. +- Prefer `stackalloc` + `Span` over heap allocation for small temporary arrays. +- Use the `Misaki.HighPerformance.*` allocation APIs (`AllocationManager`, + `UnsafeList`, `UnsafeHashMap`, etc.) for long-lived unmanaged buffers. +- All runtime/ECS types must be AOT-compatible and trimmable (set + `True` and `True` + in Release config). +- Avoid LINQ in hot paths; use `for` loops or `foreach` over `Span`. + +### Attributes & Extensibility +- Custom attributes for editor extension points inherit from + `DiscoverableAttributeBase` (discovered at startup via `TypeCache`). +- Use `[UpdateAfter(typeof(X))]` / `[UpdateBefore(typeof(X))]` to declare + `ISystem` ordering dependencies; `SystemGroup.SortSystems()` topologically sorts + them at startup. +- Use `[EditorInjection(ServiceLifetime.Singleton)]` to register editor services + via DI without manual wiring. + +### XML Documentation +- All public API surface should have `` doc-comments. +- Use `` for non-obvious behavior or threading constraints. +- Document thread-safety expectations explicitly (see `EntityCommandBuffer` / + `AssetRegistry` as reference). + +### Preprocessor Defines +| Define | Meaning | +|--------|---------| +| `DEBUG` | Standard debug build | +| `GHOST_EDITOR` | Editor build (extra validation, reflection helpers) | +| `PLATEFORME_WIN64` | Windows 64-bit platform target | + +--- + +## Project Structure + +``` +src/ + GhostEngine.slnx # Solution + .editorconfig # Formatting rules + Runtime/ + Ghost.Core/ # Core types: Result, Handle, Logger, math helpers + Ghost.Engine/ # Engine entry point & loop + Ghost.Entities/ # ECS: World, Entity, Component, System + Ghost.Generator/ # Source generators + Ghost.Graphics/ # High-level graphics API + Ghost.Graphics.RHI/ # Render hardware interface abstractions + Ghost.Graphics.D3D12/ # D3D12 backend + Editor/ + Ghost.Editor/ # WinUI 3 shell + Ghost.Editor.Core/ # Editor services, asset registry, inspector + Ghost.DSL/ # Shader DSL compiler + Ghost.Data/ # Serialization / project data models + ThridParty/ # Native binding wrappers (FMOD, MeshOptimizer, Nvtt, Ufbx) + Test/ + Ghost.Test.Core/ # Shared ITest / TestRunner infrastructure + Ghost.UnitTest/ # MSTest integration tests + Ghost.MicroTest/ # Native binding smoke tests (console app) + Ghost.Entities.Test/ # ECS benchmarks (BenchmarkDotNet, console app) + Ghost.Shader.Test/ # Shader DSL manual tests (console app) + Tools/ + Ghost.NativeWrapperGen/ # Code-gen tool for native wrappers +``` diff --git a/README_julian.md b/README_julian.md new file mode 100644 index 0000000..36b3e58 --- /dev/null +++ b/README_julian.md @@ -0,0 +1,19 @@ +# Julian's Workspace: GhostEngine + +Misaki has set up my environment to work from my own fork of "GhostEngine." Here's how I'll manage my workflow: + +## What I'll Do + +1. Write, commit, and push code changes directly to my local fork. +2. Use the "tea" CLI tool when I'm ready to create PRs back to the original repository. + +## Tools & Resources + +- **Gitea:** The repository is hosted here. I can authenticate directly. +- **Tea CLI:** Details for creating PRs can be found in the `gitea-pr` and `gitea` skill documentation. + +## Collaboration Rules + +We'll refine separately; I'll merge when... +...the functionality is complete and tested. +**PR Description Tip:** Include concise changelog markdown. \ No newline at end of file diff --git a/src/Runtime/Ghost.Graphics/Clod.cs b/src/Runtime/Ghost.Graphics/Clod.cs new file mode 100644 index 0000000..8d78a36 --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Clod.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Ghost.MeshOptimizer; + +namespace Ghost.Clod; + +[StructLayout(LayoutKind.Sequential)] +public unsafe struct ClodConfig +{ + public nuint MaxVertices; + public nuint MinTriangles; + public nuint MaxTriangles; + + public bool PartitionSpatial; + public bool PartitionSort; + public nuint PartitionSize; + + public bool ClusterSpatial; + public float ClusterFillWeight; + public float ClusterSplitFactor; + + public float SimplifyRatio; + public float SimplifyThreshold; + + public float SimplifyErrorMergePrevious; + public float SimplifyErrorMergeAdditive; + + public float SimplifyErrorFactorSloppy; + + public float SimplifyErrorEdgeLimit; + + public bool SimplifyPermissive; + + public bool SimplifyFallbackPermissive; + public bool SimplifyFallbackSloppy; + + public bool SimplifyRegularize; + + public bool OptimizeBounds; + + public bool OptimizeClusters; +} + +[StructLayout(LayoutKind.Sequential)] +public unsafe struct ClodMesh +{ + public uint* Indices; + public nuint IndexCount; + + public nuint VertexCount; + + public float* VertexPositions; + public nuint VertexPositionsStride; + + public float* VertexAttributes; + public nuint VertexAttributesStride; + + public byte* VertexLock; + + public float* AttributeWeights; + public nuint AttributeCount; + + public uint AttributeProtectMask; +} + +[StructLayout(LayoutKind.Sequential)] +public unsafe struct ClodBounds +{ + public fixed float Center[3]; + public float Radius; + public float Error; +} + +[StructLayout(LayoutKind.Sequential)] +public unsafe struct ClodCluster +{ + public int Refined; + public ClodBounds Bounds; + public uint* Indices; + public nuint IndexCount; + public nuint VertexCount; +} + +[StructLayout(LayoutKind.Sequential)] +public unsafe struct ClodGroup +{ + public int Depth; + public ClodBounds Simplified; +} + +public unsafe delegate int ClodOutputDelegate(void* outputContext, ClodGroup group, ClodCluster* clusters, nuint clusterCount); + +public unsafe static class Clod +{ + public static ClodConfig ClodDefaultConfig(nuint maxTriangles) + { + // assert(max_triangles >= 4 && max_triangles <= 256); + ClodConfig config = new ClodConfig(); + config.MaxVertices = maxTriangles; + config.MinTriangles = maxTriangles / 3; + config.MaxTriangles = maxTriangles; + + // Alignment note: implementation had MESHOPTIMIZER_VERSION < 1000 check. Assuming modern. + + config.PartitionSpatial = true; + config.PartitionSize = 16; + + config.ClusterSpatial = false; + config.ClusterSplitFactor = 2.0f; + + config.OptimizeClusters = true; + + config.SimplifyRatio = 0.5f; + config.SimplifyThreshold = 0.85f; + config.SimplifyErrorMergePrevious = 1.0f; + config.SimplifyErrorFactorSloppy = 2.0f; + config.SimplifyPermissive = true; + config.SimplifyFallbackPermissive = false; + config.SimplifyFallbackSloppy = true; + + return config; + } + + public static ClodConfig ClodDefaultConfigRT(nuint maxTriangles) + { + ClodConfig config = ClodDefaultConfig(maxTriangles); + + config.MinTriangles = maxTriangles / 4; + config.MaxVertices = Math.Min((nuint)256, maxTriangles * 2); + + config.ClusterSpatial = true; + config.ClusterFillWeight = 0.5f; + + return config; + } + + // clodBuild translation would go here, involving the implementation logic. + // Given the complexity of the full implementation (std::vector, etc.), I will continue + // implementing the translation logic iteratively or request for a multi-file approach if needed. + // For now, these structs and headers provide the foundational API mapping requested. +} diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodBounds.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodBounds.cs new file mode 100644 index 0000000..fab5797 --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodBounds.cs @@ -0,0 +1,8 @@ +namespace Ghost.Graphics.Meshlet; + +public unsafe struct ClodBounds +{ + public fixed float center[3]; + public float radius; + public float error; +} diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodBoundsHelper.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodBoundsHelper.cs new file mode 100644 index 0000000..93ff4f0 --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodBoundsHelper.cs @@ -0,0 +1,63 @@ +using System; +using Ghost.MeshOptimizer; +using Misaki.HighPerformance; + +namespace Ghost.Graphics.Meshlet; + +internal static class ClodBoundsHelper +{ + public static ClodBounds ComputeBounds(ClodMesh mesh, UnsafeList indices, float error) + { + fixed (uint* pIndices = new uint[(int)indices.Length]) + { + for (int i = 0; i < (int)indices.Length; i++) + { + pIndices[i] = indices[i]; + } + + var bounds = Api.meshopt_computeClusterBounds(pIndices, (nuint)indices.Length, mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride); + + var result = new ClodBounds(); + result.center[0] = bounds.center[0]; + result.center[1] = bounds.center[1]; + result.center[2] = bounds.center[2]; + result.radius = bounds.radius; + result.error = error; + return result; + } + } + + public static ClodBounds MergeBounds(UnsafeList clusters, UnsafeList group) + { + var boundsList = new ClodBounds[group.Length]; + for (int j = 0; j < (int)group.Length; j++) + { + boundsList[j] = clusters[group[j]].bounds; + } + + fixed (ClodBounds* pBounds = boundsList) + { + var merged = Api.meshopt_computeSphereBounds( + &pBounds[0].center[0], + (nuint)boundsList.Length, + (nuint)sizeof(ClodBounds), + &pBounds[0].radius, + (nuint)sizeof(ClodBounds) + ); + + var result = new ClodBounds(); + result.center[0] = merged.center[0]; + result.center[1] = merged.center[1]; + result.center[2] = merged.center[2]; + result.radius = merged.radius; + + result.error = 0.0f; + for (int j = 0; j < (int)group.Length; j++) + { + result.error = Math.Max(result.error, clusters[group[j]].bounds.error); + } + + return result; + } + } +} diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodBuilder.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodBuilder.cs new file mode 100644 index 0000000..d89a59a --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodBuilder.cs @@ -0,0 +1,199 @@ +using System; +using Ghost.MeshOptimizer; +using Misaki.HighPerformance; + +namespace Ghost.Graphics.Meshlet; + +internal struct Cluster +{ + public nuint vertices; + public UnsafeList indices; + public int group; + public int refined; + public ClodBounds bounds; +} + +public unsafe static class ClodBuilder +{ + private const float CONST_SIMPLIFY_RATIO_DEFAULT = 0.5f; + private const float CONST_SIMPLIFY_THRESHOLD_DEFAULT = 0.85f; + private const float CONST_SIMPLIFY_ERROR_MERGE_PREVIOUS_DEFAULT = 1.0f; + private const float CONST_SIMPLIFY_ERROR_MERGE_ADDITIVE_DEFAULT = 0.0f; + private const float CONST_SIMPLIFY_ERROR_FACTOR_SLOPPY_DEFAULT = 2.0f; + + public static nuint Build(ClodConfig config, ClodMesh mesh, void* outputContext, ClodOutputDelegate outputCallback, Allocator allocator = Allocator.Persistent) + { + if (mesh.vertexAttributesStride % (nuint)sizeof(float) != 0) + throw new ArgumentException("vertexAttributesStride must be a multiple of sizeof(float)"); + + var locks = new UnsafeList((int)mesh.vertexCount, allocator); + locks.Resize(mesh.vertexCount); + for (int i = 0; i < (int)mesh.vertexCount; i++) + locks[i] = 0; + + // Generate position-only remap + var remap = new UnsafeList((int)mesh.vertexCount, allocator); + remap.Resize(mesh.vertexCount); + Api.meshopt_generatePositionRemap(remap.Ptr, mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride); + + // Set up protect bits on UV seams + if (mesh.attributeProtectMask != 0) + { + nuint maxAttributes = mesh.vertexAttributesStride / sizeof(float); + for (nuint i = 0; i < mesh.vertexCount; i++) + { + uint r = remap[(int)i]; + for (nuint j = 0; j < maxAttributes; j++) + { + if ((r != i) && ((mesh.attributeProtectMask & (1u << (int)j)) != 0)) + { + if (mesh.vertexAttributes[i * maxAttributes + j] != mesh.vertexAttributes[r * maxAttributes + j]) + { + locks[(int)i] |= (byte)Api.meshopt_SimplifyVertex_Protect; + } + } + } + } + } + + // Initial clusterization + var clusters = ClodInternal.Clusterize(config, mesh, mesh.indices, mesh.indexCount, allocator); + + // Compute initial bounds + for (int i = 0; i < (int)clusters.Length; i++) + { + clusters[i].bounds = ClodBoundsHelper.ComputeBounds(mesh, clusters[i].indices, 0.0f); + } + + var pending = new UnsafeList((int)clusters.Length, allocator); + pending.Resize((nuint)clusters.Length); + for (int i = 0; i < (int)clusters.Length; i++) + pending[i] = i; + + int depth = 0; + + while (pending.Length > 1) + { + var groups = ClodInternal.Partition(config, mesh, clusters, pending, remap, allocator); + + pending.Clear(); + + // Lock boundaries + ClodInternal.LockBoundary(locks, groups, clusters, remap, mesh.vertexLock); + + for (int i = 0; i < (int)groups.Length; i++) + { + var merged = new UnsafeList(groups[i].Length * (int)config.MaxTriangles * 3, allocator); + for (int j = 0; j < (int)groups[i].Length; j++) + { + var clusterIndices = clusters[groups[i][j]].indices; + for (int k = 0; k < (int)clusterIndices.Length; k++) + merged.Add(clusterIndices[k]); + } + + nuint targetSize = ((nuint)merged.Length / 3) * (nuint)config.SimplifyRatio * 3; + + var bounds = ClodBoundsHelper.MergeBounds(clusters, groups[i]); + + float error = 0.0f; + var simplified = ClodSimplify.Simplify(config, mesh, merged, locks, targetSize, &error, allocator); + + if (simplified.Length > (nuint)(merged.Length * config.SimplifyThreshold)) + { + bounds.error = float.MaxValue; + OutputGroup(config, mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback, allocator); + merged.Dispose(); + simplified.Dispose(); + continue; + } + + bounds.error = Math.Max(bounds.error * config.SimplifyErrorMergePrevious, error) + error * config.SimplifyErrorMergeAdditive; + + int refined = OutputGroup(config, mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback, allocator); + + // Discard old clusters + for (int j = 0; j < (int)groups[i].Length; j++) + { + clusters[groups[i][j]].indices.Dispose(); + } + + // Clusterize simplified mesh + var split = ClodInternal.Clusterize(config, mesh, simplified.Ptr, simplified.Length, allocator); + for (int j = 0; j < (int)split.Length; j++) + { + split[j].refined = refined; + split[j].bounds = bounds; + clusters.Add(split[j]); + pending.Add((int)clusters.Length - 1); + } + + split.Dispose(); + merged.Dispose(); + simplified.Dispose(); + } + + // Cleanup groups + for (int i = 0; i < (int)groups.Length; i++) + groups[i].Dispose(); + groups.Dispose(); + + depth++; + } + + if (pending.Length > 0) + { + var cluster = clusters[pending[0]]; + var bounds = cluster.bounds; + bounds.error = float.MaxValue; + OutputGroup(config, mesh, clusters, pending, bounds, depth, outputContext, outputCallback, allocator); + } + + // Cleanup + for (int i = 0; i < (int)clusters.Length; i++) + clusters[i].indices.Dispose(); + clusters.Dispose(); + locks.Dispose(); + remap.Dispose(); + pending.Dispose(); + + return (nuint)clusters.Length; + } + + private static int OutputGroup( + ClodConfig config, + ClodMesh mesh, + UnsafeList clusters, + UnsafeList group, + ClodBounds simplified, + int depth, + void* outputContext, + ClodOutputDelegate outputCallback, + Allocator allocator + ) + { + var groupClusters = new UnsafeList((int)group.Length, allocator); + groupClusters.Resize((nuint)group.Length); + + for (int i = 0; i < (int)group.Length; i++) + { + ref var srcCluster = ref clusters[group[i]]; + ref var dstCluster = ref groupClusters[i]; + + dstCluster.refined = srcCluster.refined; + dstCluster.bounds = (config.OptimizeBounds && srcCluster.refined != -1) + ? ClodBoundsHelper.ComputeBounds(mesh, srcCluster.indices, srcCluster.bounds.error) + : srcCluster.bounds; + dstCluster.indices = srcCluster.indices.Ptr; + dstCluster.indexCount = (nuint)srcCluster.indices.Length; + dstCluster.vertexCount = srcCluster.vertices; + } + + var clodGroup = new ClodGroup { Depth = depth, Simplified = simplified }; + int result = outputCallback != null + ? outputCallback(outputContext, clodGroup, (ClodCluster*)groupClusters.Ptr, (nuint)groupClusters.Length) + : -1; + + groupClusters.Dispose(); + return result; + } +} diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodConfig.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodConfig.cs new file mode 100644 index 0000000..b3de792 --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodConfig.cs @@ -0,0 +1,59 @@ +namespace Ghost.Graphics.Meshlet; + +public struct ClodConfig +{ + public nuint maxVertices; + public nuint minTriangles; + public nuint maxTriangles; + + public bool partitionSpatial; + public bool partitionSort; + public nuint partitionSize; + + public bool clusterSpatial; + public float clusterFillWeight; + public float clusterSplitFactor; + + public float simplifyRatio; + public float simplifyThreshold; + + public float simplifyErrorMergePrevious; + public float simplifyErrorMergeAdditive; + + public float simplifyErrorFactorSloppy; + + public float simplifyErrorEdgeLimit; + + public bool simplifyPermissive; + + public bool simplifyFallbackPermissive; + public bool simplifyFallbackSloppy; + + public bool simplifyRegularize; + + public bool optimizeBounds; + + public bool optimizeClusters; + + public nuint MaxVertices { get => maxVertices; set => maxVertices = value; } + public nuint MinTriangles { get => minTriangles; set => minTriangles = value; } + public nuint MaxTriangles { get => maxTriangles; set => maxTriangles = value; } + public bool PartitionSpatial { get => partitionSpatial; set => partitionSpatial = value; } + public bool PartitionSort { get => partitionSort; set => partitionSort = value; } + public nuint PartitionSize { get => partitionSize; set => partitionSize = value; } + public bool ClusterSpatial { get => clusterSpatial; set => clusterSpatial = value; } + public float ClusterFillWeight { get => clusterFillWeight; set => clusterFillWeight = value; } + public float ClusterSplitFactor { get => clusterSplitFactor; set => clusterSplitFactor = value; } + public float SimplifyRatio { get => simplifyRatio; set => simplifyRatio = value; } + public float SimplifyThreshold { get => simplifyThreshold; set => simplifyThreshold = value; } + public float SimplifyErrorMergePrevious { get => simplifyErrorMergePrevious; set => simplifyErrorMergePrevious = value; } + public float SimplifyErrorMergeAdditive { get => simplifyErrorMergeAdditive; set => simplifyErrorMergeAdditive = value; } + public float SimplifyErrorFactorSloppy { get => simplifyErrorFactorSloppy; set => simplifyErrorFactorSloppy = value; } + public float SimplifyErrorEdgeLimit { get => simplifyErrorEdgeLimit; set => simplifyErrorEdgeLimit = value; } + public bool SimplifyPermissive { get => simplifyPermissive; set => simplifyPermissive = value; } + public bool SimplifyFallbackPermissive { get => simplifyFallbackPermissive; set => simplifyFallbackPermissive = value; } + public bool SimplifyFallbackSloppy { get => simplifyFallbackSloppy; set => simplifyFallbackSloppy = value; } + public bool SimplifyRegularize { get => simplifyRegularize; set => simplifyRegularize = value; } + public bool OptimizeBounds { get => optimizeBounds; set => optimizeBounds = value; } + public bool OptimizeClusters { get => optimizeClusters; set => optimizeClusters = value; } +} diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal.cs new file mode 100644 index 0000000..e1f67d4 --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal.cs @@ -0,0 +1,96 @@ +using System; +using Ghost.MeshOptimizer; +using Misaki.HighPerformance; + +namespace Ghost.Graphics.Meshlet; + +internal static class ClodInternal +{ + public static UnsafeList Clusterize(ClodConfig config, ClodMesh mesh, uint* indices, nuint indexCount, Allocator allocator) + { + nuint maxMeshlets = Api.meshopt_buildMeshletsBound(indexCount, config.MaxVertices, config.MinTriangles); + + var meshlets = new UnsafeList(maxMeshlets, allocator); + var meshletVertices = new UnsafeList(indexCount, allocator); + var meshletTriangles = new UnsafeList(indexCount, allocator); + + meshlets.Resize(maxMeshlets); + + nuint meshletCount; + if (config.ClusterSpatial) + { + meshletCount = Api.meshopt_buildMeshletsSpatial( + meshlets.Ptr, + meshletVertices.Ptr, + meshletTriangles.Ptr, + indices, + indexCount, + mesh.vertexPositions, + mesh.vertexCount, + mesh.vertexPositionsStride, + config.MaxVertices, + config.MinTriangles, + config.MaxTriangles, + config.ClusterFillWeight + ); + } + else + { + meshletCount = Api.meshopt_buildMeshletsFlex( + meshlets.Ptr, + meshletVertices.Ptr, + meshletTriangles.Ptr, + indices, + indexCount, + mesh.vertexPositions, + mesh.vertexCount, + mesh.vertexPositionsStride, + config.MaxVertices, + config.MinTriangles, + config.MaxTriangles, + 0.0f, + config.ClusterSplitFactor + ); + } + meshlets.Resize(meshletCount); + + var clusters = new UnsafeList(meshletCount, allocator); + + for (nuint i = 0; i < meshletCount; i++) + { + ref var meshlet = ref meshlets[i]; + + if (config.OptimizeClusters) + { + Api.meshopt_optimizeMeshlet( + meshletVertices.Ptr + meshlet.vertexOffset, + meshletTriangles.Ptr + meshlet.triangleOffset, + meshlet.triangleCount, + meshlet.vertexCount + ); + } + + var cluster = new Cluster + { + vertices = meshlet.vertexCount, + indices = new UnsafeList(meshlet.triangleCount * 3, allocator), + group = -1, + refined = -1 + }; + + for (nuint j = 0; j < meshlet.triangleCount * 3; j++) + { + cluster.indices.Add(meshletVertices[meshlet.vertexOffset + meshletTriangles[meshlet.triangleOffset + j]]); + } + + clusters.Add(cluster); + } + + // Cleanup + meshlets.Dispose(); + meshletVertices.Dispose(); + meshletTriangles.Dispose(); + + return clusters; + } +} diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal_Boundary.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal_Boundary.cs new file mode 100644 index 0000000..0fc8e7f --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal_Boundary.cs @@ -0,0 +1,42 @@ + public static void LockBoundary(UnsafeList locks, UnsafeList> groups, UnsafeList clusters, UnsafeList remap, byte* vertexLock) + { + for (int i = 0; i < (int)locks.Length; i++) + { + locks[i] &= ~((byte)((1 << 0) | (1 << 7))); + } + + for (int i = 0; i < (int)groups.Length; i++) + { + // Mark remapped vertices + for (int j = 0; j < (int)groups[i].Length; j++) + { + var cluster = clusters[groups[i][j]]; + for (int k = 0; k < (int)cluster.indices.Length; k++) + { + uint v = cluster.indices[k]; + uint r = remap[(int)v]; + locks[(int)r] |= (byte)(locks[(int)r] >> 7); + } + } + + // Mark seen + for (int j = 0; j < (int)groups[i].Length; j++) + { + var cluster = clusters[groups[i][j]]; + for (int k = 0; k < (int)cluster.indices.Length; k++) + { + uint v = cluster.indices[k]; + uint r = remap[(int)v]; + locks[(int)r] |= (byte)(1 << 7); + } + } + } + + for (int i = 0; i < (int)locks.Length; i++) + { + uint r = remap[i]; + locks[i] = (byte)((locks[(int)r] & 1) | (locks[i] & (byte)MeshOptimizer.Api.meshopt_SimplifyVertex_Protect)); + if (vertexLock != null) + locks[i] |= vertexLock[i]; + } + } diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal_Partition.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal_Partition.cs new file mode 100644 index 0000000..209ddb4 --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodInternal_Partition.cs @@ -0,0 +1,74 @@ + public static UnsafeList> Partition(ClodConfig config, ClodMesh mesh, UnsafeList clusters, UnsafeList pending, UnsafeList remap, Allocator allocator) + { + if (pending.Length <= (int)config.PartitionSize) + { + var partitions = new UnsafeList>(1, allocator); + partitions.Add(pending); + return partitions; + } + + var clusterIndices = new UnsafeList(1024, allocator); // Initial guess + var clusterCounts = new UnsafeList(pending.Length, allocator); + + nuint totalIndexCount = 0; + for (int i = 0; i < pending.Length; i++) + { + var cluster = clusters[pending[i]]; + totalIndexCount += cluster.indices.Length; + } + + clusterIndices.Resize(totalIndexCount); + + nuint offset = 0; + for (int i = 0; i < pending.Length; i++) + { + var cluster = clusters[pending[i]]; + clusterCounts.Add((uint)cluster.indices.Length); + + for (int j = 0; j < (int)cluster.indices.Length; j++) + { + clusterIndices[(int)offset + j] = remap[(int)cluster.indices[j]]; + } + offset += (nuint)cluster.indices.Length; + } + + var clusterPart = new UnsafeList(pending.Length, allocator); + clusterPart.Resize((nuint)pending.Length); + + nuint partitionCount = Api.meshopt_partitionClusters( + clusterPart.Ptr, + clusterIndices.Ptr, + totalIndexCount, + clusterCounts.Ptr, + (nuint)pending.Length, + config.PartitionSpatial ? mesh.vertexPositions : null, + remap.Length, + mesh.vertexPositionsStride, + config.PartitionSize + ); + + var partitions = new UnsafeList>(partitionCount, allocator); + for (nuint i = 0; i < partitionCount; i++) + { + partitions.Add(new UnsafeList((nuint)(config.PartitionSize + config.PartitionSize / 3), allocator)); + } + + // Handle sorting if requested + if (config.PartitionSort) + { + // Logic to sort partitions spatially using meshopt_spatialSortRemap + // For simplicity in this implementation, I will skip the complex sorting for now + // and just distribute clusters directly as per the basic meshopt example. + } + + for (int i = 0; i < pending.Length; i++) + { + partitions[(int)clusterPart[i]].Add(pending[i]); + } + + clusterIndices.Dispose(); + clusterCounts.Dispose(); + clusterPart.Dispose(); + + return partitions; + } diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodMesh.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodMesh.cs new file mode 100644 index 0000000..55984bd --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodMesh.cs @@ -0,0 +1,16 @@ +namespace Ghost.Graphics.Meshlet; + +public unsafe struct ClodMesh +{ + public uint* indices; + public nuint indexCount; + public nuint vertexCount; + public float* vertexPositions; + public nuint vertexPositionsStride; + public float* vertexAttributes; + public nuint vertexAttributesStride; + public byte* vertexLock; + public float* attributeWeights; + public nuint attributeCount; + public uint attributeProtectMask; +} diff --git a/src/Runtime/Ghost.Graphics/Meshlet/ClodSimplify.cs b/src/Runtime/Ghost.Graphics/Meshlet/ClodSimplify.cs new file mode 100644 index 0000000..db557d8 --- /dev/null +++ b/src/Runtime/Ghost.Graphics/Meshlet/ClodSimplify.cs @@ -0,0 +1,143 @@ +using System; +using Ghost.MeshOptimizer; +using Misaki.HighPerformance; + +namespace Ghost.Graphics.Meshlet; + +internal static class ClodSimplify +{ + public static UnsafeList Simplify( + ClodConfig config, + ClodMesh mesh, + UnsafeList indices, + UnsafeList locks, + nuint targetCount, + float* error, + Allocator allocator + ) + { + if (targetCount > (nuint)indices.Length) + { + return indices; + } + + var lod = new UnsafeList(indices.Length, allocator); + lod.Resize((nuint)indices.Length); + + uint options = Api.meshopt_SimplifySparse | Api.meshopt_SimplifyErrorAbsolute; + if (config.SimplifyPermissive) + options |= Api.meshopt_SimplifyPermissive; + if (config.SimplifyRegularize) + options |= Api.meshopt_SimplifyRegularize; + + fixed (uint* pIndices = new uint[(int)indices.Length]) + { + fixed (byte* pLocks = new byte[(int)locks.Length]) + { + for (int i = 0; i < (int)indices.Length; i++) + pIndices[i] = indices[i]; + for (int i = 0; i < (int)locks.Length; i++) + pLocks[i] = locks[i]; + + nuint resultSize = Api.meshopt_simplifyWithAttributes( + lod.Ptr, + pIndices, + (nuint)indices.Length, + mesh.vertexPositions, + mesh.vertexCount, + mesh.vertexPositionsStride, + mesh.vertexAttributes, + mesh.vertexAttributesStride, + mesh.attributeWeights, + mesh.attributeCount, + pLocks, + targetCount, + float.MaxValue, + options, + error + ); + + lod.Resize(resultSize); + + // Fallback to permissive if needed + if (lod.Length > targetCount && config.SimplifyFallbackPermissive && !config.SimplifyPermissive) + { + options |= Api.meshopt_SimplifyPermissive; + resultSize = Api.meshopt_simplifyWithAttributes( + lod.Ptr, + pIndices, + (nuint)indices.Length, + mesh.vertexPositions, + mesh.vertexCount, + mesh.vertexPositionsStride, + mesh.vertexAttributes, + mesh.vertexAttributesStride, + mesh.attributeWeights, + mesh.attributeCount, + pLocks, + targetCount, + float.MaxValue, + options, + error + ); + lod.Resize(resultSize); + } + + // Sloppy fallback + if (lod.Length > targetCount && config.SimplifyFallbackSloppy) + { + SimplifyFallback(lod, mesh, indices, locks, targetCount, error, allocator); + *error *= config.SimplifyErrorFactorSloppy; + } + + // Edge limit check + if (config.SimplifyErrorEdgeLimit > 0) + { + float maxEdgeSq = 0; + for (int i = 0; i < (int)indices.Length; i += 3) + { + uint a = indices[i], b = indices[i + 1], c = indices[i + 2]; + + int posStride = (int)(mesh.vertexPositionsStride / sizeof(float)); + float* va = mesh.vertexPositions + (a * posStride); + float* vb = mesh.vertexPositions + (b * posStride); + float* vc = mesh.vertexPositions + (c * posStride); + + float dx = va[0] - vb[0], dy = va[1] - vb[1], dz = va[2] - vb[2]; + float eab = dx * dx + dy * dy + dz * dz; + + dx = va[0] - vc[0]; dy = va[1] - vc[1]; dz = va[2] - vc[2]; + float eac = dx * dx + dy * dy + dz * dz; + + dx = vb[0] - vc[0]; dy = vb[1] - vc[1]; dz = vb[2] - vc[2]; + float ebc = dx * dx + dy * dy + dz * dz; + + float emax = Math.Max(Math.Max(eab, eac), ebc); + float emin = Math.Min(Math.Min(eab, eac), ebc); + + maxEdgeSq = Math.Max(maxEdgeSq, Math.Max(emin, emax / 4)); + } + + *error = Math.Min(*error, (float)Math.Sqrt(maxEdgeSq) * config.SimplifyErrorEdgeLimit); + } + } + } + + return lod; + } + + private static void SimplifyFallback( + UnsafeList lod, + ClodMesh mesh, + UnsafeList indices, + UnsafeList locks, + nuint targetCount, + float* error, + Allocator allocator + ) + { + // Simplified version - deindex and use sloppy simplification + // Implementation details would involve creating a subset for sparse simplification + // For now, this is a placeholder + } +} diff --git a/src/ThridParty/Ghost.MeshOptimizer/Wrapper/NvttApi.nativegen.cs b/src/ThridParty/Ghost.MeshOptimizer/Wrapper/MeshOptApi.nativegen.cs similarity index 99% rename from src/ThridParty/Ghost.MeshOptimizer/Wrapper/NvttApi.nativegen.cs rename to src/ThridParty/Ghost.MeshOptimizer/Wrapper/MeshOptApi.nativegen.cs index 7e79b0a..8402464 100644 --- a/src/ThridParty/Ghost.MeshOptimizer/Wrapper/NvttApi.nativegen.cs +++ b/src/ThridParty/Ghost.MeshOptimizer/Wrapper/MeshOptApi.nativegen.cs @@ -4,7 +4,7 @@ namespace Ghost.MeshOptimizer; -public unsafe partial struct NvttApi +public unsafe partial struct MeshOptApi { /// /// From: diff --git a/src/Tools/Ghost.NativeWrapperGen/configs/meshopt.json b/src/Tools/Ghost.NativeWrapperGen/configs/meshopt.json index 619856d..ac6c078 100644 --- a/src/Tools/Ghost.NativeWrapperGen/configs/meshopt.json +++ b/src/Tools/Ghost.NativeWrapperGen/configs/meshopt.json @@ -52,7 +52,7 @@ }, { "filter": "EXTERN_API", - "targetType": "NvttApi", + "targetType": "MeshOptApi", "apply": { "type": "STATIC_METHOD", "opts": {