BerylliumFabricSimulator 0.9.1

dotnet add package BerylliumFabricSimulator --version 0.9.1
                    
NuGet\Install-Package BerylliumFabricSimulator -Version 0.9.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="BerylliumFabricSimulator" Version="0.9.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="BerylliumFabricSimulator" Version="0.9.1" />
                    
Directory.Packages.props
<PackageReference Include="BerylliumFabricSimulator" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add BerylliumFabricSimulator --version 0.9.1
                    
#r "nuget: BerylliumFabricSimulator, 0.9.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package BerylliumFabricSimulator@0.9.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=BerylliumFabricSimulator&version=0.9.1
                    
Install as a Cake Addin
#tool nuget:?package=BerylliumFabricSimulator&version=0.9.1
                    
Install as a Cake Tool

BerylliumFabricSimulator

GPU cloth solver for MonoGame. Positions live in a structured buffer and never come back to the CPU: a compute shader integrates them, relaxes the constraints and computes the normals, and stops there. You draw them.

  • Verlet integration on a fixed step, with Jacobi relaxation
  • Structural, shearing and bending constraints; bending yields plastically, so creases stay
  • Long-range attachments, so a wide pinned sheet does not stretch like rubber
  • Sphere and ground colliders with friction
  • Self-collision through a spatial hash rebuilt every step
  • Wind as an air velocity with procedural gusting, and drag evaluated against it
  • A rest deadband, so cloth settles instead of twitching on solver noise
  • Physically-scaled: areal density, not particle mass, so the sheet behaves the same at any tessellation

No rendering. There is no light, no material, no draw call and no opinion about your pipeline — the package ships the compute shader and the particle layout, and the sheet is yours to shade. See Drawing it below.

Requirements

Windows, net10.0-windows, and the compute-capable MonoGame fork — this package depends on MonoGame.Framework.Compute.WindowsDX.NoMemoryLeak. Stock MonoGame has no compute shaders or structured buffers, so it will not work. Your GraphicsDeviceManager must use GraphicsProfile.HiDef.

You also need the fork's content builder, because the solver ships as HLSL and something has to compile it:

dotnet add package MonoGame.Content.Builder.Task.Compute
dotnet new tool-manifest
dotnet tool install dotnet-mgcb-compute

That is not an extra imposition so much as a prerequisite you already have: your own cloth shader reads a StructuredBuffer, which stock MGCB cannot compile and stock MonoGame cannot bind, so the fork's builder is in your project either way. Building one more .fx costs about a second. Without it the build stops with BFS0001 rather than failing later at Content.Load.

Install

dotnet add package BerylliumFabricSimulator

On the first build the package copies its shader sources into your content folder and builds them alongside your own content:

Content/BerylliumFabricSimulator.mgcb           its own response file, beside whatever .mgcb you already have
Content/Effects/Fabric/FabricCompute.fx         the solver
Content/Effects/Fabric/*.fxh                    the layout, shared with your shader — see Drawing it

They land in your tree rather than in the package cache because the builder writes its output next to the .mgcb that named it, and a package folder is shared between every project on the machine. Worth a .gitignore line; Content/bin/ and Content/obj/ want one anyway.

The result is Content/Effects/Fabric/FabricCompute.xnb in your output, which is FabricEffect.DefaultEffectPath for a ContentManager rooted at Content. Two response files in one content folder do not collide: the builder keys its output folder on the .mgcb's name and its link folder on the containing folder.

Set BerylliumFabricContentDir if your content root is not Content\, and BerylliumFabricProvideShaderSource=false to have the package take no part in your content build and compile FabricCompute.fx yourself — from BerylliumFabricShaderSourceDir, which names where the package keeps its own copy, for a Copy target of your own.

That switch stops the copying and nothing else. The builder finds .mgcb files by scanning your content folder, so whatever an earlier build put there goes on being compiled as though nothing had changed — and if you have meanwhile added FabricCompute.fx to a .mgcb of your own, two content builds are racing for one output path. You get BFS0002 saying so rather than finding out later.

Deleting them is a separate, deliberate step, because by the time you turn the switch off the copy in your tree may be one you have edited, and a build that removed it as a side effect would throw that away silently:

dotnet build -t:BerylliumFabricRemoveShaderSource

That deletes the files the package copied, by name, and takes the Effects/Fabric folder only once nothing else is left in it — so anything of your own sharing that tree is untouched. Run it as often as you like; it does nothing when there is nothing to remove.

Why source rather than a compiled shader. A compiled effect carries an MGFX version the runtime checks and rejects out of hand, so a blob would be welded to one build of one fork, and the message it fails with says nothing about this package. It would also be opaque: GroupSize, SpatialHashMaxPerCell and the feature set are all things a host might reasonably want to change.

A working sheet

Three files, on top of the packages above. Everything after this section is detail on what they do.

Program.cs:

using var game = new ClothGame();
game.Run();

ClothGame.cs:

using BerylliumFabricSimulator.Simulation;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;

public class ClothGame : Game
{
    private FabricSettings _settings;
    private FabricEffect _solver;
    private Fabric _fabric;

    private Effect _cloth;
    private IndexBuffer _indices;
    private VertexBuffer _vertices;
    private Matrix _viewProjection;
    private Vector3 _cameraPosition;

    public ClothGame()
    {
        // Compute needs HiDef. Reach has no compute stage to compile the solver into.
        _ = new GraphicsDeviceManager(this) { GraphicsProfile = GraphicsProfile.HiDef };
        Content.RootDirectory = "Content";
    }

    protected override void LoadContent()
    {
        _settings = new FabricSettings
        {
            WidthInParticles = 96,
            HeightInParticles = 64,
            RestLength = 0.06f,
            Wind = new Vector3(0.0f, 0.0f, -6.0f)
        };

        _solver = new FabricEffect(GraphicsDevice, Content);
        _fabric = new Fabric(_solver, _settings);

        // Layout properties are read at Rebuild, not in the constructor.
        _fabric.Origin = new Vector3(0.0f, 3.0f, 0.0f);
        _fabric.PinMode = FabricPinMode.TopEdge;
        _fabric.Rebuild();

        // Two triangles per cell, indexing the particle buffer directly. Thirty-two
        // bit because sixteen overflows silently past 65,536 particles.
        var indices = _fabric.CreateTriangleIndices();

        _indices = new IndexBuffer(GraphicsDevice, IndexElementSize.ThirtyTwoBits, indices.Length, BufferUsage.WriteOnly);
        _indices.SetData(indices);

        // A draw call needs a vertex buffer bound, but nothing reads it: the shader
        // takes everything it draws from the particle buffer, by vertex id. One float
        // per vertex is the whole cost of satisfying that - no struct, no IVertexType.
        var vertexLayout = new VertexDeclaration(
            new VertexElement(0, VertexElementFormat.Single, VertexElementUsage.Position, 0));

        _vertices = new VertexBuffer(GraphicsDevice, vertexLayout, _fabric.ParticleCount, BufferUsage.WriteOnly);

        _cloth = Content.Load<Effect>("Effects/Cloth");

        _cameraPosition = new Vector3(0.0f, 3.0f, 7.0f);

        _viewProjection =
            Matrix.CreateLookAt(_cameraPosition, new Vector3(0.0f, 2.6f, 0.0f), Vector3.Up) *
            Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, GraphicsDevice.Viewport.AspectRatio, 0.1f, 100.0f);
    }

    protected override void Update(GameTime gameTime)
    {
        _fabric.Update((float)gameTime.ElapsedGameTime.TotalSeconds);

        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);

        // Both sides of the sheet show the moment it folds.
        GraphicsDevice.RasterizerState = RasterizerState.CullNone;
        GraphicsDevice.DepthStencilState = DepthStencilState.Default;

        // Every frame. The solver swaps its buffers as it steps, so a binding kept
        // from last frame names the scratch one about half the time.
        _cloth.Parameters["FabricParticles"].SetValue(_solver.ParticleBuffer);
        _cloth.Parameters["FabricWidthInParticles"].SetValue(_settings.WidthInParticles);
        _cloth.Parameters["FabricHeightInParticles"].SetValue(_settings.HeightInParticles);

        // Identity world matrix: the particles are already in world space.
        _cloth.Parameters["WorldViewProjection"].SetValue(_viewProjection);

        // The pixel shader turns the solver normal towards the camera before lighting.
        _cloth.Parameters["CameraPosition"].SetValue(_cameraPosition);

        GraphicsDevice.SetVertexBuffer(_vertices);
        GraphicsDevice.Indices = _indices;

        foreach (var pass in _cloth.CurrentTechnique.Passes)
        {
            pass.Apply();
            GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _fabric.TriangleCount);
        }

        base.Draw(gameTime);
    }

    protected override void UnloadContent() => _solver.Dispose();
}

Content/Effects/Cloth.fx — yours, not the package's. The #include resolves because the package put the header next door, at Content/Effects/Fabric:

#include "Fabric/FabricParticleBuffer.fxh"

float4x4 WorldViewProjection;
float3 CameraPosition;

struct VSOutput
{
    float4 Position      : SV_POSITION;
    float3 WorldPosition : TEXCOORD1;
    float3 Normal        : NORMAL0;
    float2 TexCoord      : TEXCOORD0;
};

VSOutput VS(float unused : POSITION0, uint vertexId : SV_VertexID)
{
    FabricParticle p = FabricParticles[vertexId];

    VSOutput output;

    output.Position = mul(float4(p.Position, 1.0), WorldViewProjection);
    output.WorldPosition = p.Position;                      // already world space
    output.Normal = p.Normal;                               // the solver's, not the triangle's
    output.TexCoord = FabricTextureCoordinates(vertexId);

    return output;
}

float4 PS(VSOutput input) : SV_TARGET
{
    float3 normal = normalize(input.Normal);
    float3 toCamera = normalize(CameraPosition - input.WorldPosition);

    // Culling is off, so half the sheet presents its back face while the solver's
    // normal points one way only. Turn it towards the camera before lighting, or
    // the far side of every fold shades as though lit from behind.
    if (dot(normal, toCamera) < 0.0)
        normal = -normal;

    float lambert = saturate(dot(normal, normalize(float3(0.3, 0.8, 0.6))));

    return float4(float3(0.85, 0.25, 0.30) * (0.25 + 0.75 * lambert), 1.0);
}

technique ClothTechnique
{
    pass MainPass
    {
        VertexShader = compile vs_5_0 VS();
        PixelShader = compile ps_5_0 PS();
    }
}

And Content/Content.mgcb, your own content build, with Cloth.fx in it — the solver has its own alongside, which the package wrote.

That gives a 96 x 64 sheet pinned along its top edge, falling under gravity and blowing away from the camera, lit off the solver's own normals. From there: Knobs for the tunables, Placement, attitude and custom pins for where the sheet hangs and what holds it, Diagnostics for the measurements that settle what a screenshot cannot.

Simulate

using BerylliumFabricSimulator.Simulation;

// LoadContent
_settings = new FabricSettings
{
    WidthInParticles = 96,
    HeightInParticles = 64,
    RestLength = 0.06f,
    Wind = new Vector3(0.0f, 0.0f, -6.0f)
};

_effect = new FabricEffect(GraphicsDevice, Content);   // FabricEffect.DefaultEffectPath
_fabric = new Fabric(_effect, _settings);

_fabric.Origin = new Vector3(0.0f, 3.0f, 0.0f);
_fabric.PinMode = FabricPinMode.TopEdge;
_fabric.PinGather = 0.6f;

_fabric.Rebuild();   // the layout properties are read here, not in the constructor

// Update
_fabric.Update((float)gameTime.ElapsedGameTime.TotalSeconds);

Fabric owns the grid; FabricEffect owns the GPU buffers and the step loop; FabricSettings is a plain object you mutate in place — the solver re-uploads it every frame, so a knob changed mid-flight takes effect on the next step. The layout properties on Fabric are different: they are read when the grid is built, so set them and call Rebuild(). The constructor builds once with the defaults, which is why the properties above cannot go in an object initializer.

Placement, attitude and custom pins

Origin is the middle of the sheet, on both axes and in both orientations, so placement does not depend on size: change the resolution, the rest length or a pleat angle and the sheet stays where you put it. Orientation picks the plane — Vertical spans X and Y, Horizontal spans X and Z. A 96 × 64 sheet at rest length 0.06, vertical, with Origin at (0, 3, 0), spans x −2.85…2.85 and y 1.11…4.89.

Extent is (count - 1) × RestLength per axis, exposed as Width and Height. To place a sheet by its suspended edge rather than its middle, subtract half of Height.

Rotation then tilts it to any attitude, pivoting about Origin, so it turns in place:

_fabric.Rotation = Quaternion.CreateFromYawPitchRoll(yaw, pitch, roll);

That is a starting attitude only. Gravity, wind and the colliders all stay in world space, so a tilted sheet still falls down Y and still lands on the ground plane; what holds a tilt is the pins, which rotate with everything else and never move. It is safe at any angle because a rigid rotation preserves every distance and dihedral angle, so the rest lengths, anchor distances and rest angles seeded from the built shape stay valid and the first step has nothing to resolve. Texture coordinates are index-space, so neither the orientation nor the rotation touches them.

Pins are the union of the PinMode preset and PinnedIndices, a mutable set of flat grid indices — so PinMode.None plus explicit indices is a fully custom set:

_fabric.PinMode = FabricPinMode.None;
_fabric.PinnedIndices.Add(_fabric.ParticleIndex(column: 0, row: 47));
_fabric.PinnedIndices.Add(_fabric.ParticleIndex(column: 32, row: 24));   // interior is fine
_fabric.Rebuild();

Any particle can be pinned, interior ones included: the long-range attachment metric is a multi-source shortest path through the weave, so it seeds from wherever the pins actually are. ParticleIndex(column, row) does the row-major arithmetic and range-checks it, because a transposed row and column produces a valid index almost every time.

Pins may move. Each particle stores the index of its nearest pin rather than that pin's position, so the attachment resolves against wherever the pin is on the step it is read, and a host that animates a pinned edge — write the new positions with UpdateParticles — needs nothing refreshed. The path length behind it is a property of the weave, not of space, so it survives any amount of pin motion; only changing which particles are pinned invalidates it, and RefreshAnchors() is what recomputes that without resetting the sheet.

Two build-time features stay off when the sheet is rotated or pinned away from its top row, because both assume an upright sheet above a horizontal floor: the surplus-cloth drop path (UsedDropPath), and the PinGather taper.

Drawing it

The solver leaves the particle state on the GPU. To draw the cloth, fetch from that buffer in your own vertex shader, indexed by SV_VertexID — no vertex stream, no readback.

Shader. The headers are already in your content tree — the package copied them there, next to the solver it also copied, at Content/Effects/Fabric. Include FabricParticleBuffer.fxh: it brings the particle layout, the grid dimensions, the texture-coordinate convention and the FabricParticles binding, so you declare nothing yourself. The path is relative to your own .fx, because that is how MGCB resolves #include; the one below assumes yours sits in Content/Effects.

#include "Fabric/FabricParticleBuffer.fxh"

float4x4 WorldViewProjection;

VSOutput VS(float unused : POSITION0, uint vertexId : SV_VertexID)
{
    FabricParticle p = FabricParticles[vertexId];

    VSOutput output;
    output.Position = mul(float4(p.Position, 1.0), WorldViewProjection);
    output.Normal = p.Normal;
    output.TextureCoordinates = FabricTextureCoordinates(vertexId);

    return output;
}

(FabricParticleBuffer.fxh pulls in FabricParticle.fxh, which the solver also uses and which declares no bindings. Include that one directly only if you want the struct without the read binding.)

Those headers are a copy, and a copy survives the upgrade that changes what it describes. FabricParticle.fxh carries #define FabricShaderContractVersion, and FabricEffect checks it against FabricParticle.ShaderContractVersion when it loads — throwing if the two disagree, and equally if the define is absent, since a header old enough to predate the contract is older than the assembly by at least the release that introduced it. Bumped for any change of shape: a field added, removed, reordered or resized, a grid parameter renamed, the texture coordinates laid out differently.

It is the check the byte count cannot be. Swap two fields of the record and both sides still measure sixty bytes, so the cloth simply reads one field as another — which presents as a solver that has gone wrong rather than a header left behind. Best effort, though: it walks up from the executable looking for Content/Effects/Fabric, and a deployment that kept only the compiled shader has nothing to compare against and is not failed for it.

Host. Bind the buffer and the grid dimensions every frame — the solver swaps its front and back buffers on every pass of every step, so a cached reference names the scratch buffer about half the time. _cloth here is your effect, the one that included the header; the buffer comes from the solver:

// Effect _cloth = Content.Load<Effect>("Effects/Cloth");   your shader
_cloth.Parameters["FabricParticles"].SetValue(_fabric.Effect.ParticleBuffer);
_cloth.Parameters["FabricWidthInParticles"].SetValue(_settings.WidthInParticles);
_cloth.Parameters["FabricHeightInParticles"].SetValue(_settings.HeightInParticles);

Setting these on the solver's own shader does nothing: it binds FabricParticlesInput and FabricParticlesOutput and declares no FabricParticles at all, so the parameter is simply absent there. FabricEffect.Shader reaches it if you need it for something else.

Topology. Fabric.CreateTriangleIndices() returns the triangle list — two per grid cell, indexing the particle buffer directly. Upload it to a 32-bit IndexBuffer after each Rebuild() (16-bit overflows silently past 65,536 particles). A draw call still needs a bound vertex buffer, but nothing reads it, so one float per vertex is enough.

Three things the shader has to respect:

  • World space. Particles are simulated in world space. Draw them with an identity world matrix — gravity, wind and collider positions are all world vectors, so a world matrix that moved the mesh would put the visible cloth somewhere other than where the solver thinks it is. To move the sheet, change Origin and rebuild.
  • No back-face culling. Both sides show the moment the cloth folds. Use RasterizerState.CullNone and flip the normal towards the camera in the pixel shader.
  • The normal is the solver's. FabricParticle.Normal is computed on the GPU each step with the same accumulation the CPU seeds at build time; do not derive your own from the triangle, or shading will disagree with the physics along every crease.

Knobs

Every tunable is a documented property; the XML docs ship with the package, so IntelliSense carries the guidance, including where two knobs are a pair rather than independent.

FabricSettings
Grid WidthInParticles, HeightInParticles, RestLength
Solver TimeStep, RelaxationIterations, MaxStepsPerFrame, Stiffness, CompressionSlack, Damping, UseShearingConstraints
Bending UseBendingConstraints, BendingStiffness, BendingIterations, BendingPlasticYieldDegrees, BendingPlasticRate
Attachment UseLongRangeAttachments
Physics Gravity, ArealDensity, DragCoefficient, AirDensity, Wind, WindTurbulence
Rest UseRestThreshold, RestSpeedThreshold
Colliders UseSphereCollider, SphereColliderCenter, SphereColliderRadius, UseGroundCollider, GroundColliderHeight, ColliderFriction
Self-collision UseSelfCollision, SelfCollisionThicknessRatio, SelfCollisionStiffness, SelfCollisionFriction, ContactIterations
Fabric (read at Rebuild())
Placement Origin, Orientation, Rotation
Pins PinMode, PinnedIndices, PinGather
Pleating PleatPeriod, PleatAcrossHeight, PleatAngleDegrees

Derived, read-only: ShearingRestLength, ParticleMass, SelfCollisionThickness, MinimumCompressionSlackForContacts. ResetToDefaults() puts every tunable back, which is what you want between presets — a knob one preset sets and the next does not would otherwise leak across.

Cost

ContactIterations and BendingIterations are the two that decide the frame time on a large sheet: they cap how many of the relaxation iterations also solve contacts and bending. Contacts cost a 27-bucket neighbourhood search per particle whether or not anything is near it, and bending is twelve dihedral solves per particle. On a 102,400-particle sheet, going from one contact iteration to four cost 182 fps down to 27. Zero means every iteration.

Diagnostics

FabricEffect.LastStepCount, DispatchesPerStep, IsSaturated (the solver hit MaxStepsPerFrame and shed the backlog), Time, TotalStepCount, and SampleRestingFraction() — how much of the sheet is inside the rest deadband, which is a GPU readback, so sample it a few times a second, not every frame. ReadBack() returns the particle array for the checks below and your own, at the cost of a full stall — take one array and run everything you want over it rather than reading the sheet back once per question.

BerylliumFabricSimulator.Diagnostics holds the two measurements that settle what a screenshot cannot. PenetrationCheck.Run(particles, width, thickness) counts pairs of non-weave-neighbour particles closer together than the cloth is thick, and separately the ones overlapping by more than a quarter of it — which is what "the cloth is not passing through itself" actually means. CreaseCheck.Run(particles, width, height) compares the live dihedral angles against the rest angles the plasticity model has set, which is what tells a sheet folded because something presses on it from a sheet folded because it wants to be. Both take the array from ReadBack(), both re-derive everything from positions, and both allocate freely: they are diagnostics, not frame work. NumericChecks.IsFinite is the guard they share, and the one to reach for first in a check of your own — a diverged solver fills the buffer with NaN, and a statistic that averaged one is worth less than no statistic at all.

PenetrationCheck deliberately does not use the solver's spatial hash. It builds its own, unbounded and exact-keyed, because a check sharing the thing it checks agrees with it by construction: a bucket the solver overflowed is a pair it never tested, and a check reading the same buckets would not test the pair either. If you extend it, keep that separation.

SampleWorstStepMotion() reports how close the sheet has come to outrunning its own collision tests: PeakRatio, the furthest single-step move as a multiple of the collision thickness, and FastParticleSteps, how many particle-steps went past one. Contacts are tested where particles ended up, not along the path they took, so past one a layer can cross another with nothing — not the solver, not PenetrationCheck — able to see it. The margin is a property of your grid rather than of the solver: thickness is a fraction of the spacing, so refining the sheet lowers the bar while gravity leaves the speeds alone. Check it at the resolution you ship; if it reads above one, halving TimeStep is the cheap fix and roughly halves the number.

SampleSpatialHashOverflow() reports what self-collision lost: DroppedEntriesPerStep, particles the hash could not file because their bucket was full, and PeakOccupancy, the deepest bucket seen since the sheet was built — zero until one overflows, and otherwise what SpatialHashMaxPerCell would have to grow to. A dropped entry is a contact the solver never saw, and it leaves no trace in the positions, so this is the one thing a check of your own on the particle array cannot reconstruct: it separates cloth that interpenetrates because the contact response was too weak from cloth that interpenetrates because the neighbour was never in the bucket. It reads the same buffer as SampleRestingFraction() and is fetched at most once per step, so polling both together costs one stall rather than two.

Product Compatible and additional computed target framework versions.
.NET net10.0-windows7.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.9.1 104 8/26/2026