BerylliumFabricSimulator 0.9.1
dotnet add package BerylliumFabricSimulator --version 0.9.1
NuGet\Install-Package BerylliumFabricSimulator -Version 0.9.1
<PackageReference Include="BerylliumFabricSimulator" Version="0.9.1" />
<PackageVersion Include="BerylliumFabricSimulator" Version="0.9.1" />
<PackageReference Include="BerylliumFabricSimulator" />
paket add BerylliumFabricSimulator --version 0.9.1
#r "nuget: BerylliumFabricSimulator, 0.9.1"
#:package BerylliumFabricSimulator@0.9.1
#addin nuget:?package=BerylliumFabricSimulator&version=0.9.1
#tool nuget:?package=BerylliumFabricSimulator&version=0.9.1
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
Originand rebuild. - No back-face culling. Both sides show the moment the cloth folds. Use
RasterizerState.CullNoneand flip the normal towards the camera in the pixel shader. - The normal is the solver's.
FabricParticle.Normalis 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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0-windows7.0 is compatible. |
-
net10.0-windows7.0
- BerylliumMath (>= 0.1.0)
- MonoGame.Framework.Compute.WindowsDX.NoMemoryLeak (>= 3.8.3.1)
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 |