Merge pull request 'feat: implement ClusterLOD C# bindings in Ghost.Graphics.Meshlet' (#2) from Julian/GhostEngine:develop into develop

Reviewed-on: #2
Reviewed-by: Misaki <misaki_39@outlook.com>
This commit was merged in pull request #2.
This commit is contained in:
2026-03-17 03:52:12 +00:00
14 changed files with 852 additions and 2 deletions

212
AGENT.md Normal file
View File

@@ -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<YourTestClass>()`.
---
## 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 (`<Nullable>enable</Nullable>`)
- Implicit usings: **enabled** (`<ImplicitUsings>enable</ImplicitUsings>`)
- 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.<Module>[.<SubFolder>]`
- `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<T>`, `Identifier<T>`, `Key64<T>` |
### Types & Structs
- Prefer `readonly struct` for value types that are logically immutable.
- Prefer `ref struct` / `readonly ref struct` for stack-only types
(`RefResult<T,E>`, `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
`<langversion>preview</langversion>`.
### 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<T>` 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<T>` over heap allocation for small temporary arrays.
- Use the `Misaki.HighPerformance.*` allocation APIs (`AllocationManager`,
`UnsafeList<T>`, `UnsafeHashMap<T,V>`, etc.) for long-lived unmanaged buffers.
- All runtime/ECS types must be AOT-compatible and trimmable (set
`<IsAotCompatible>True</IsAotCompatible>` and `<IsTrimmable>True</IsTrimmable>`
in Release config).
- Avoid LINQ in hot paths; use `for` loops or `foreach` over `Span<T>`.
### 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 `<summary>` doc-comments.
- Use `<remarks>` 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
```

19
README_julian.md Normal file
View File

@@ -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.

View File

@@ -30,6 +30,7 @@
<ProjectReference Include="..\..\Editor\Ghost.DSL\Ghost.DSL.csproj" /> <ProjectReference Include="..\..\Editor\Ghost.DSL\Ghost.DSL.csproj" />
<ProjectReference Include="..\Ghost.Graphics.D3D12\Ghost.Graphics.D3D12.csproj" /> <ProjectReference Include="..\Ghost.Graphics.D3D12\Ghost.Graphics.D3D12.csproj" />
<ProjectReference Include="..\Ghost.Graphics.RHI\Ghost.Graphics.RHI.csproj" /> <ProjectReference Include="..\Ghost.Graphics.RHI\Ghost.Graphics.RHI.csproj" />
<ProjectReference Include="..\..\ThridParty\Ghost.MeshOptimizer\Ghost.MeshOptimizer.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,10 @@
using System.Numerics;
namespace Ghost.Graphics.Meshlet;
public struct ClodBounds
{
public Vector3 center;
public float radius;
public float error;
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Numerics;
using Ghost.MeshOptimizer;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
namespace Ghost.Graphics.Meshlet;
internal static class ClodBoundsHelper
{
public static unsafe ClodBounds ComputeBounds(ClodMesh mesh, UnsafeList<uint> indices, float error)
{
var bounds = MeshOptApi.ComputeClusterBounds((uint*)indices.GetUnsafePtr(), (nuint)indices.Count, mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride);
return new ClodBounds
{
center = new Vector3(bounds.center[0], bounds.center[1], bounds.center[2]),
radius = bounds.radius,
error = error
};
}
public static unsafe ClodBounds MergeBounds(UnsafeList<Cluster> clusters, UnsafeList<int> group)
{
var boundsList = new UnsafeList<ClodBounds>(group.Count, Allocator.Temp);
for (int j = 0; j < group.Count; j++)
boundsList.Add(clusters[group[j]].bounds);
var merged = MeshOptApi.ComputeSphereBounds(
(float*)boundsList.GetUnsafePtr(),
(nuint)group.Count,
(nuint)sizeof(ClodBounds),
(float*)boundsList.GetUnsafePtr() + 3,
(nuint)sizeof(ClodBounds)
);
float maxError = 0.0f;
for (int j = 0; j < group.Count; j++)
maxError = Math.Max(maxError, clusters[group[j]].bounds.error);
boundsList.Dispose();
return new ClodBounds
{
center = new Vector3(merged.center[0], merged.center[1], merged.center[2]),
radius = merged.radius,
error = maxError
};
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Diagnostics;
using Ghost.MeshOptimizer;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
namespace Ghost.Graphics.Meshlet;
internal struct Cluster
{
public nuint vertices;
public UnsafeList<uint> indices;
public int group;
public int refined;
public ClodBounds bounds;
}
public unsafe static class ClodBuilder
{
public static nuint Build(ClodConfig config, ClodMesh mesh, void* outputContext, ClodOutputDelegate outputCallback)
{
Debug.Assert(mesh.vertexAttributesStride % (nuint)sizeof(float) == 0, "vertexAttributesStride must be a multiple of sizeof(float)");
var locks = new UnsafeList<byte>((int)mesh.vertexCount, Allocator.Temp);
locks.AsSpan().Fill(0);
var remap = new UnsafeList<uint>((int)mesh.vertexCount, Allocator.Temp);
remap.Resize((int)mesh.vertexCount);
MeshOptApi.GeneratePositionRemap((uint*)remap.GetUnsafePtr(), mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride);
if (mesh.attributeProtectMask != 0)
{
nuint maxAttributes = mesh.vertexAttributesStride / sizeof(float);
for (nuint i = 0; i < mesh.vertexCount; i++)
{
uint r = ((uint*)remap.GetUnsafePtr())[(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])
{
((byte*)locks.GetUnsafePtr())[(int)i] |= (byte)(Api.meshopt_SimplifyVertex_Protect & 0xFF);
}
}
}
}
}
var clusters = ClodInternal.Clusterize(config, mesh, mesh.indices, mesh.indexCount, Allocator.Persistent);
for (int i = 0; i < clusters.Count; i++)
{
clusters[i].bounds = ClodBoundsHelper.ComputeBounds(mesh, clusters[i].indices, 0.0f);
}
var pending = new UnsafeList<int>(clusters.Count, Allocator.Temp);
for (int i = 0; i < clusters.Count; i++)
pending.Add(i);
int depth = 0;
while (pending.Count > 1)
{
var groups = ClodPartition.Partition(config, mesh, clusters, pending, remap, Allocator.Temp);
pending.Clear();
ClodBoundary.LockBoundary(locks, groups, clusters, remap, mesh.vertexLock);
for (int i = 0; i < groups.Count; i++)
{
var merged = new UnsafeList<uint>(groups[i].Count * (int)config.maxTriangles * 3, Allocator.Temp);
for (int j = 0; j < groups[i].Count; j++)
{
var clusterIndices = clusters[groups[i][j]].indices;
for (int k = 0; k < clusterIndices.Count; k++)
merged.Add(clusterIndices[k]);
}
nuint targetSize = ((nuint)merged.Count / 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);
if ((nuint)simplified.Count > (nuint)(merged.Count * config.simplifyThreshold))
{
bounds.error = float.MaxValue;
OutputGroup(config, mesh, clusters, groups[i], bounds, depth, outputContext, outputCallback);
merged.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);
for (int j = 0; j < groups[i].Count; j++)
clusters[groups[i][j]].indices.Dispose();
var split = ClodInternal.Clusterize(config, mesh, (uint*)simplified.GetUnsafePtr(), (nuint)simplified.Count, Allocator.Persistent);
for (int j = 0; j < split.Count; j++)
{
split[j].refined = refined;
split[j].bounds = bounds;
clusters.Add(split[j]);
pending.Add(clusters.Count - 1);
}
split.Dispose();
merged.Dispose();
}
for (int i = 0; i < groups.Count; i++)
groups[i].Dispose();
groups.Dispose();
depth++;
}
if (pending.Count > 0)
{
var bounds = clusters[pending[0]].bounds;
bounds.error = float.MaxValue;
OutputGroup(config, mesh, clusters, pending, bounds, depth, outputContext, outputCallback);
}
nuint finalClusterCount = (nuint)clusters.Count;
for (int i = 0; i < clusters.Count; i++)
clusters[i].indices.Dispose();
clusters.Dispose();
locks.Dispose();
remap.Dispose();
pending.Dispose();
return finalClusterCount;
}
private static int OutputGroup(
ClodConfig config,
ClodMesh mesh,
UnsafeList<Cluster> clusters,
UnsafeList<int> group,
ClodBounds simplified,
int depth,
void* outputContext,
ClodOutputDelegate outputCallback
)
{
var groupClusters = new UnsafeList<ClodCluster>(group.Count, Allocator.Temp);
for (int i = 0; i < group.Count; i++)
{
ref var srcCluster = ref clusters[group[i]];
groupClusters.Add(new ClodCluster
{
refined = srcCluster.refined,
bounds = (config.optimizeBounds && srcCluster.refined != -1)
? ClodBoundsHelper.ComputeBounds(mesh, srcCluster.indices, srcCluster.bounds.error)
: srcCluster.bounds,
indices = (uint*)srcCluster.indices.GetUnsafePtr(),
indexCount = (nuint)srcCluster.indices.Count,
vertexCount = srcCluster.vertices
});
}
var clodGroup = new ClodGroup { depth = depth, simplified = simplified };
int result = outputCallback != null
? outputCallback(outputContext, clodGroup, (ClodCluster*)groupClusters.GetUnsafePtr(), (nuint)groupClusters.Count)
: -1;
groupClusters.Dispose();
return result;
}
}

View File

@@ -0,0 +1,37 @@
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;
}

View File

@@ -0,0 +1,83 @@
using System;
using Ghost.MeshOptimizer;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
namespace Ghost.Graphics.Meshlet;
internal static class ClodInternal
{
public static unsafe UnsafeList<Cluster> Clusterize(ClodConfig config, ClodMesh mesh, uint* indices, nuint indexCount, Allocator allocator)
{
nuint maxMeshlets = MeshOptApi.BuildMeshletsBound(indexCount, config.maxVertices, config.minTriangles);
var meshlets = new UnsafeList<meshopt_Meshlet>((int)maxMeshlets, Allocator.Temp);
meshlets.Resize((int)maxMeshlets);
var meshletVertices = new UnsafeList<uint>((int)indexCount, Allocator.Temp);
meshletVertices.Resize((int)indexCount);
var meshletTriangles = new UnsafeList<byte>((int)indexCount, Allocator.Temp);
meshletTriangles.Resize((int)indexCount);
meshopt_Meshlet* pMeshlets = (meshopt_Meshlet*)meshlets.GetUnsafePtr();
uint* pMeshletVertices = (uint*)meshletVertices.GetUnsafePtr();
byte* pMeshletTriangles = (byte*)meshletTriangles.GetUnsafePtr();
nuint meshletCount;
if (config.clusterSpatial)
{
meshletCount = pMeshlets[0].BuildsSpatial(
pMeshletVertices, pMeshletTriangles,
indices, indexCount,
mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride,
config.maxVertices, config.minTriangles, config.maxTriangles,
config.clusterFillWeight
);
}
else
{
meshletCount = pMeshlets[0].BuildsFlex(
pMeshletVertices, pMeshletTriangles,
indices, indexCount,
mesh.vertexPositions, mesh.vertexCount, mesh.vertexPositionsStride,
config.maxVertices, config.minTriangles, config.maxTriangles,
0.0f, config.clusterSplitFactor
);
}
var clusters = new UnsafeList<Cluster>((int)meshletCount, allocator);
for (nuint i = 0; i < meshletCount; i++)
{
ref var meshlet = ref pMeshlets[i];
if (config.optimizeClusters)
{
MeshOptApi.OptimizeMeshlet(
pMeshletVertices + meshlet.vertex_offset,
pMeshletTriangles + meshlet.triangle_offset,
meshlet.triangle_count,
meshlet.vertex_count
);
}
var cluster = new Cluster
{
vertices = meshlet.vertex_count,
indices = new UnsafeList<uint>((int)(meshlet.triangle_count * 3), allocator),
group = -1,
refined = -1
};
for (nuint j = 0; j < meshlet.triangle_count * 3; j++)
cluster.indices.Add(pMeshletVertices[meshlet.vertex_offset + pMeshletTriangles[meshlet.triangle_offset + j]]);
clusters.Add(cluster);
}
meshlets.Dispose();
meshletVertices.Dispose();
meshletTriangles.Dispose();
return clusters;
}
}

View File

@@ -0,0 +1,48 @@
using Ghost.MeshOptimizer;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
namespace Ghost.Graphics.Meshlet;
internal static class ClodBoundary
{
public static unsafe void LockBoundary(UnsafeList<byte> locks, UnsafeList<UnsafeList<int>> groups, UnsafeList<Cluster> clusters, UnsafeList<uint> remap, byte* vertexLock)
{
byte* pLocks = (byte*)locks.GetUnsafePtr();
uint* pRemap = (uint*)remap.GetUnsafePtr();
for (int i = 0; i < locks.Count; i++)
pLocks[i] = unchecked((byte)(pLocks[i] & ~((1 << 0) | (1 << 7))));
for (int i = 0; i < groups.Count; i++)
{
for (int j = 0; j < groups[i].Count; j++)
{
var cluster = clusters[groups[i][j]];
for (int k = 0; k < cluster.indices.Count; k++)
{
uint r = pRemap[(int)cluster.indices[k]];
pLocks[r] |= (byte)(pLocks[r] >> 7);
}
}
for (int j = 0; j < groups[i].Count; j++)
{
var cluster = clusters[groups[i][j]];
for (int k = 0; k < cluster.indices.Count; k++)
{
uint r = pRemap[(int)cluster.indices[k]];
pLocks[r] |= (byte)(1 << 7);
}
}
}
for (int i = 0; i < locks.Count; i++)
{
uint r = pRemap[i];
pLocks[i] = (byte)((pLocks[r] & 1) | (pLocks[i] & (byte)(Api.meshopt_SimplifyVertex_Protect & 0xFF)));
if (vertexLock != null)
pLocks[i] |= vertexLock[i];
}
}
}

View File

@@ -0,0 +1,64 @@
using System;
using Ghost.MeshOptimizer;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
namespace Ghost.Graphics.Meshlet;
internal static class ClodPartition
{
public static unsafe UnsafeList<UnsafeList<int>> Partition(ClodConfig config, ClodMesh mesh, UnsafeList<Cluster> clusters, UnsafeList<int> pending, UnsafeList<uint> remap, Allocator allocator)
{
if (pending.Count <= (int)config.partitionSize)
{
var single = new UnsafeList<UnsafeList<int>>(1, allocator);
single.Add(pending);
return single;
}
nuint totalIndexCount = 0;
for (int i = 0; i < pending.Count; i++)
totalIndexCount += (nuint)clusters[pending[i]].indices.Count;
var clusterIndices = new UnsafeList<uint>((int)totalIndexCount, Allocator.Temp);
var clusterCounts = new UnsafeList<uint>(pending.Count, Allocator.Temp);
nuint offset = 0;
for (int i = 0; i < pending.Count; i++)
{
var cluster = clusters[pending[i]];
clusterCounts.Add((uint)cluster.indices.Count);
for (int j = 0; j < cluster.indices.Count; j++)
clusterIndices.Add(((uint*)remap.GetUnsafePtr())[(int)cluster.indices[j]]);
offset += (nuint)cluster.indices.Count;
}
var clusterPart = new UnsafeList<uint>(pending.Count, Allocator.Temp);
clusterPart.Resize(pending.Count);
nuint partitionCount = MeshOptApi.PartitionClusters(
(uint*)clusterPart.GetUnsafePtr(),
(uint*)clusterIndices.GetUnsafePtr(),
totalIndexCount,
(uint*)clusterCounts.GetUnsafePtr(),
(nuint)pending.Count,
config.partitionSpatial ? mesh.vertexPositions : null,
(nuint)remap.Count,
mesh.vertexPositionsStride,
config.partitionSize
);
var partitions = new UnsafeList<UnsafeList<int>>((int)partitionCount, allocator);
for (nuint i = 0; i < partitionCount; i++)
partitions.Add(new UnsafeList<int>((int)(config.partitionSize + config.partitionSize / 3), allocator));
for (int i = 0; i < pending.Count; i++)
partitions[(int)((uint*)clusterPart.GetUnsafePtr())[i]].Add(pending[i]);
clusterIndices.Dispose();
clusterCounts.Dispose();
clusterPart.Dispose();
return partitions;
}
}

View File

@@ -0,0 +1,43 @@
using System;
using Ghost.MeshOptimizer;
using Misaki.HighPerformance.LowLevel.Collections;
using Misaki.HighPerformance.LowLevel;
using Misaki.HighPerformance.LowLevel.Buffer;
namespace Ghost.Graphics.Meshlet;
public unsafe struct ClodMesh
{
public float* vertexPositions;
public nuint vertexCount;
public nuint vertexPositionsStride;
public float* vertexAttributes;
public nuint vertexAttributesStride;
public float* attributeWeights;
public nuint attributeCount;
public uint* indices;
public nuint indexCount;
public byte* vertexLock;
public uint attributeProtectMask;
}
public struct ClodGroup
{
public int depth;
public ClodBounds simplified;
}
public unsafe struct ClodCluster
{
public int refined;
public ClodBounds bounds;
public uint* indices;
public nuint indexCount;
public nuint vertexCount;
}
public unsafe delegate int ClodOutputDelegate(void* context, ClodGroup group, ClodCluster* clusters, nuint clusterCount);

View File

@@ -0,0 +1,109 @@
using System;
using Ghost.MeshOptimizer;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Collections;
namespace Ghost.Graphics.Meshlet;
internal static class ClodSimplify
{
public static unsafe UnsafeList<uint> Simplify(
ClodConfig config,
ClodMesh mesh,
UnsafeList<uint> indices,
UnsafeList<byte> locks,
nuint targetCount,
float* error
)
{
if (targetCount >= (nuint)indices.Count)
return indices;
var lod = new UnsafeList<uint>(indices.Count, Allocator.Temp);
lod.Resize(indices.Count);
uint options = (uint)(Api.meshopt_SimplifySparse | Api.meshopt_SimplifyErrorAbsolute);
if (config.simplifyPermissive)
options |= (uint)Api.meshopt_SimplifyPermissive;
if (config.simplifyRegularize)
options |= (uint)Api.meshopt_SimplifyRegularize;
nuint resultSize = MeshOptApi.SimplifyWithAttributes(
(uint*)lod.GetUnsafePtr(),
(uint*)indices.GetUnsafePtr(),
(nuint)indices.Count,
mesh.vertexPositions,
mesh.vertexCount,
mesh.vertexPositionsStride,
mesh.vertexAttributes,
mesh.vertexAttributesStride,
mesh.attributeWeights,
mesh.attributeCount,
(byte*)locks.GetUnsafePtr(),
targetCount,
float.MaxValue,
options,
error
);
lod.Resize((int)resultSize);
if ((nuint)lod.Count > targetCount && config.simplifyFallbackPermissive && !config.simplifyPermissive)
{
options |= (uint)Api.meshopt_SimplifyPermissive;
resultSize = MeshOptApi.SimplifyWithAttributes(
(uint*)lod.GetUnsafePtr(),
(uint*)indices.GetUnsafePtr(),
(nuint)indices.Count,
mesh.vertexPositions,
mesh.vertexCount,
mesh.vertexPositionsStride,
mesh.vertexAttributes,
mesh.vertexAttributesStride,
mesh.attributeWeights,
mesh.attributeCount,
(byte*)locks.GetUnsafePtr(),
targetCount,
float.MaxValue,
options,
error
);
lod.Resize((int)resultSize);
}
if ((nuint)lod.Count > targetCount && config.simplifyFallbackSloppy)
{
*error *= config.simplifyErrorFactorSloppy;
}
if (config.simplifyErrorEdgeLimit > 0)
{
float maxEdgeSq = 0;
uint* pIdx = (uint*)indices.GetUnsafePtr();
int posStride = (int)(mesh.vertexPositionsStride / sizeof(float));
for (int i = 0; i < indices.Count; i += 3)
{
uint a = pIdx[i], b = pIdx[i + 1], c = pIdx[i + 2];
float* va = mesh.vertexPositions + (a * (uint)posStride);
float* vb = mesh.vertexPositions + (b * (uint)posStride);
float* vc = mesh.vertexPositions + (c * (uint)posStride);
float dx, dy, dz;
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;
}
}

View File

@@ -4,7 +4,7 @@
namespace Ghost.MeshOptimizer; namespace Ghost.MeshOptimizer;
public unsafe partial struct NvttApi public unsafe partial struct MeshOptApi
{ {
/// <summary> /// <summary>
/// From: <see cref="Api.meshopt_generateVertexRemap(uint*, uint*, nuint, void*, nuint, nuint)" /> /// From: <see cref="Api.meshopt_generateVertexRemap(uint*, uint*, nuint, void*, nuint, nuint)" />

View File

@@ -52,7 +52,7 @@
}, },
{ {
"filter": "EXTERN_API", "filter": "EXTERN_API",
"targetType": "NvttApi", "targetType": "MeshOptApi",
"apply": { "apply": {
"type": "STATIC_METHOD", "type": "STATIC_METHOD",
"opts": { "opts": {