Move Ymir simulation to SoA CultCache state
This commit is contained in:
parent
131b6019bb
commit
0fec5ad37a
9 changed files with 476 additions and 101 deletions
Binary file not shown.
|
|
@ -36,6 +36,11 @@ The MVP is deliberately small:
|
|||
- radial fields for Aetheria-style gravity wells
|
||||
- semi-implicit Euler integration
|
||||
- circle collision detection and simple impulse response
|
||||
- SoA world buffers as the simulation authority
|
||||
- CultCache MessagePack persistence for `gamecult.ymir.world_state.v0`, using
|
||||
the directory store to avoid rewriting cold records
|
||||
- CultMath `BatchMath` SIMD kernels for radial field acceleration and
|
||||
semi-implicit Euler integration
|
||||
- deterministic contact events
|
||||
- CLI smoke commands
|
||||
- local HTTP daemon with `/simulate/step`
|
||||
|
|
|
|||
|
|
@ -10,11 +10,16 @@ and future Verse surfaces.
|
|||
|
||||
The MVP has one pure core and one daemon:
|
||||
|
||||
- `Ymir.Core` receives a `YmirWorld` and a fixed `deltaTime`.
|
||||
- The core computes radial field acceleration, integrates dynamic bodies, tests
|
||||
circle overlaps, resolves simple collision response, and emits contact events.
|
||||
- `Ymir.Core` receives a `YmirSoAWorld` and a fixed `deltaTime`.
|
||||
- Compatibility callers may still submit a `YmirWorld`, but it is immediately
|
||||
lowered into SoA buffers before simulation.
|
||||
- The core computes radial field acceleration with CultMath `BatchMath`, uses
|
||||
vectorized semi-implicit Euler integration over SoA arrays, tests circle
|
||||
overlaps, resolves simple collision response, and emits contact events.
|
||||
- `ymir-daemon` publishes provider/operator state and exposes the core through a
|
||||
small HTTP compatibility surface.
|
||||
- `YmirWorldStateDocument` persists the same SoA world as
|
||||
`gamecult.ymir.world_state.v0` through CultCache MessagePack.
|
||||
|
||||
## Owner
|
||||
|
||||
|
|
@ -23,8 +28,9 @@ physics state and which contacts occurred.
|
|||
|
||||
## Inputs
|
||||
|
||||
- body id, position, velocity, radius, mass, restitution, static flag
|
||||
- radial field id, position, strength, radius
|
||||
- SoA body columns: id, position x/y, velocity x/y, radius, mass, dynamic mask,
|
||||
restitution
|
||||
- SoA radial field columns: id, position x/y, strength, radius
|
||||
- fixed `deltaTime`
|
||||
|
||||
## Outputs
|
||||
|
|
@ -35,6 +41,7 @@ physics state and which contacts occurred.
|
|||
|
||||
## Derived State
|
||||
|
||||
- `YmirWorld` DTO records are compatibility views over `YmirSoAWorld`.
|
||||
- HTTP health, provider advertisement, operator state, and Eve surface are
|
||||
derived compatibility views.
|
||||
- Unity GameObjects are derived render/adapter state.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
<ProjectReference Include="..\..\..\CultMath\src\CultMath\CultMath.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,82 +9,67 @@ public sealed class YmirSimulator
|
|||
private const float MinimumDistance = 0.0001f;
|
||||
|
||||
public SimulationStepResult Step(SimulationStepRequest request)
|
||||
{
|
||||
var result = Step(new SoASimulationStepRequest(
|
||||
request.DeltaTime,
|
||||
YmirSoAWorld.FromWorld(request.World)));
|
||||
|
||||
return new SimulationStepResult(result.World.ToWorld(), result.Contacts);
|
||||
}
|
||||
|
||||
public SoASimulationStepResult Step(SoASimulationStepRequest request)
|
||||
{
|
||||
if (request.DeltaTime <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "DeltaTime must be positive.");
|
||||
}
|
||||
|
||||
var bodies = request.World.Bodies
|
||||
.OrderBy(body => body.Id, StringComparer.Ordinal)
|
||||
.Select(body => Integrate(body, request.World.Fields, request.DeltaTime))
|
||||
.ToList();
|
||||
var world = request.World.Clone();
|
||||
world.Validate();
|
||||
|
||||
var accelerationX = new float[world.BodyCount];
|
||||
var accelerationY = new float[world.BodyCount];
|
||||
for (var i = 0; i < world.FieldCount; i++)
|
||||
{
|
||||
BatchMath.AddRadialFalloffAcceleration2D(
|
||||
world.PositionX,
|
||||
world.PositionY,
|
||||
world.FieldPositionX[i],
|
||||
world.FieldPositionY[i],
|
||||
world.FieldStrength[i],
|
||||
world.FieldRadius[i],
|
||||
accelerationX,
|
||||
accelerationY);
|
||||
}
|
||||
|
||||
BatchMath.IntegrateSemiImplicitEuler2D(
|
||||
request.DeltaTime,
|
||||
world.DynamicMask,
|
||||
world.PositionX,
|
||||
world.PositionY,
|
||||
world.VelocityX,
|
||||
world.VelocityY,
|
||||
accelerationX,
|
||||
accelerationY);
|
||||
|
||||
var contacts = new List<ContactEvent>();
|
||||
ResolveCircleContacts(bodies, contacts);
|
||||
ResolveCircleContacts(world, contacts);
|
||||
|
||||
return new SimulationStepResult(
|
||||
request.World with
|
||||
{
|
||||
Time = request.World.Time + request.DeltaTime,
|
||||
Bodies = bodies
|
||||
},
|
||||
world.Time += request.DeltaTime;
|
||||
return new SoASimulationStepResult(
|
||||
world,
|
||||
contacts);
|
||||
}
|
||||
|
||||
private static PhysicsBody Integrate(PhysicsBody body, IReadOnlyList<RadialField> fields, float deltaTime)
|
||||
private static void ResolveCircleContacts(YmirSoAWorld world, List<ContactEvent> contacts)
|
||||
{
|
||||
ValidateBody(body);
|
||||
|
||||
if (body.IsStatic)
|
||||
for (var i = 0; i < world.BodyCount; i++)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
var position = body.Position.ToCultMath();
|
||||
var velocity = body.Velocity.ToCultMath();
|
||||
var acceleration = float2(0.0f);
|
||||
|
||||
foreach (var field in fields.OrderBy(field => field.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (field.Radius <= 0.0f || field.Strength == 0.0f)
|
||||
for (var j = i + 1; j < world.BodyCount; j++)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var offset = field.Position.ToCultMath() - position;
|
||||
var distance = max(length(offset), MinimumDistance);
|
||||
if (distance > field.Radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var direction = offset / distance;
|
||||
var falloff = saturate(1.0f - distance / field.Radius);
|
||||
acceleration += direction * field.Strength * falloff;
|
||||
}
|
||||
|
||||
velocity += acceleration * deltaTime;
|
||||
position += velocity * deltaTime;
|
||||
|
||||
return body with
|
||||
{
|
||||
Position = Vec2.FromCultMath(position),
|
||||
Velocity = Vec2.FromCultMath(velocity)
|
||||
};
|
||||
}
|
||||
|
||||
private static void ResolveCircleContacts(List<PhysicsBody> bodies, List<ContactEvent> contacts)
|
||||
{
|
||||
for (var i = 0; i < bodies.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < bodies.Count; j++)
|
||||
{
|
||||
var left = bodies[i];
|
||||
var right = bodies[j];
|
||||
var delta = right.Position.ToCultMath() - left.Position.ToCultMath();
|
||||
var delta = world.PositionAt(j) - world.PositionAt(i);
|
||||
var distance = length(delta);
|
||||
var combinedRadius = left.Radius + right.Radius;
|
||||
var combinedRadius = world.Radius[i] + world.Radius[j];
|
||||
var penetration = combinedRadius - distance;
|
||||
|
||||
if (penetration <= 0.0f)
|
||||
|
|
@ -93,34 +78,33 @@ public sealed class YmirSimulator
|
|||
}
|
||||
|
||||
var normal = distance <= MinimumDistance ? float2(1.0f, 0.0f) : delta / distance;
|
||||
var point = left.Position.ToCultMath() + normal * (left.Radius - penetration * 0.5f);
|
||||
var relativeVelocity = right.Velocity.ToCultMath() - left.Velocity.ToCultMath();
|
||||
var point = world.PositionAt(i) + normal * (world.Radius[i] - penetration * 0.5f);
|
||||
var relativeVelocity = world.VelocityAt(j) - world.VelocityAt(i);
|
||||
var relativeSpeed = dot(relativeVelocity, normal);
|
||||
|
||||
contacts.Add(new ContactEvent(
|
||||
left.Id,
|
||||
right.Id,
|
||||
world.BodyIds[i],
|
||||
world.BodyIds[j],
|
||||
Vec2.FromCultMath(point),
|
||||
Vec2.FromCultMath(normal),
|
||||
penetration,
|
||||
relativeSpeed));
|
||||
|
||||
ResolveImpulse(ref left, ref right, normal, penetration, relativeSpeed);
|
||||
bodies[i] = left;
|
||||
bodies[j] = right;
|
||||
ResolveImpulse(world, i, j, normal, penetration, relativeSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResolveImpulse(
|
||||
ref PhysicsBody left,
|
||||
ref PhysicsBody right,
|
||||
YmirSoAWorld world,
|
||||
int left,
|
||||
int right,
|
||||
float2 normal,
|
||||
float penetration,
|
||||
float relativeSpeed)
|
||||
{
|
||||
var leftInvMass = left.IsStatic ? 0.0f : 1.0f / max(left.Mass, MinimumMass);
|
||||
var rightInvMass = right.IsStatic ? 0.0f : 1.0f / max(right.Mass, MinimumMass);
|
||||
var leftInvMass = world.DynamicMask[left] <= 0.0f ? 0.0f : 1.0f / max(world.Mass[left], MinimumMass);
|
||||
var rightInvMass = world.DynamicMask[right] <= 0.0f ? 0.0f : 1.0f / max(world.Mass[right], MinimumMass);
|
||||
var invMassSum = leftInvMass + rightInvMass;
|
||||
if (invMassSum <= 0.0f)
|
||||
{
|
||||
|
|
@ -128,14 +112,14 @@ public sealed class YmirSimulator
|
|||
}
|
||||
|
||||
var correction = normal * (penetration / invMassSum);
|
||||
if (!left.IsStatic)
|
||||
if (leftInvMass > 0.0f)
|
||||
{
|
||||
left = left with { Position = Vec2.FromCultMath(left.Position.ToCultMath() - correction * leftInvMass) };
|
||||
world.SetPosition(left, world.PositionAt(left) - correction * leftInvMass);
|
||||
}
|
||||
|
||||
if (!right.IsStatic)
|
||||
if (rightInvMass > 0.0f)
|
||||
{
|
||||
right = right with { Position = Vec2.FromCultMath(right.Position.ToCultMath() + correction * rightInvMass) };
|
||||
world.SetPosition(right, world.PositionAt(right) + correction * rightInvMass);
|
||||
}
|
||||
|
||||
if (relativeSpeed >= 0.0f)
|
||||
|
|
@ -143,36 +127,18 @@ public sealed class YmirSimulator
|
|||
return;
|
||||
}
|
||||
|
||||
var restitution = min(left.Restitution, right.Restitution);
|
||||
var restitution = min(world.Restitution[left], world.Restitution[right]);
|
||||
var impulseMagnitude = -(1.0f + restitution) * relativeSpeed / invMassSum;
|
||||
var impulse = normal * impulseMagnitude;
|
||||
|
||||
if (!left.IsStatic)
|
||||
if (leftInvMass > 0.0f)
|
||||
{
|
||||
left = left with { Velocity = Vec2.FromCultMath(left.Velocity.ToCultMath() - impulse * leftInvMass) };
|
||||
world.SetVelocity(left, world.VelocityAt(left) - impulse * leftInvMass);
|
||||
}
|
||||
|
||||
if (!right.IsStatic)
|
||||
if (rightInvMass > 0.0f)
|
||||
{
|
||||
right = right with { Velocity = Vec2.FromCultMath(right.Velocity.ToCultMath() + impulse * rightInvMass) };
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBody(PhysicsBody body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Id))
|
||||
{
|
||||
throw new ArgumentException("Physics bodies must have stable ids.");
|
||||
}
|
||||
|
||||
if (body.Radius <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(body), "Body radius must be positive.");
|
||||
}
|
||||
|
||||
if (!body.IsStatic && body.Mass <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(body), "Dynamic body mass must be positive.");
|
||||
world.SetVelocity(right, world.VelocityAt(right) + impulse * rightInvMass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
src/Ymir.Core/YmirSoAWorld.cs
Normal file
174
src/Ymir.Core/YmirSoAWorld.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
using CultMath;
|
||||
|
||||
namespace Ymir.Core;
|
||||
|
||||
public sealed class YmirSoAWorld
|
||||
{
|
||||
public float Time { get; set; }
|
||||
public string[] BodyIds { get; set; } = [];
|
||||
public float[] PositionX { get; set; } = [];
|
||||
public float[] PositionY { get; set; } = [];
|
||||
public float[] VelocityX { get; set; } = [];
|
||||
public float[] VelocityY { get; set; } = [];
|
||||
public float[] Radius { get; set; } = [];
|
||||
public float[] Mass { get; set; } = [];
|
||||
public float[] DynamicMask { get; set; } = [];
|
||||
public float[] Restitution { get; set; } = [];
|
||||
public string[] FieldIds { get; set; } = [];
|
||||
public float[] FieldPositionX { get; set; } = [];
|
||||
public float[] FieldPositionY { get; set; } = [];
|
||||
public float[] FieldStrength { get; set; } = [];
|
||||
public float[] FieldRadius { get; set; } = [];
|
||||
|
||||
public int BodyCount => BodyIds.Length;
|
||||
|
||||
public int FieldCount => FieldIds.Length;
|
||||
|
||||
public static YmirSoAWorld FromWorld(YmirWorld world)
|
||||
{
|
||||
var bodies = world.Bodies.OrderBy(body => body.Id, StringComparer.Ordinal).ToArray();
|
||||
var fields = world.Fields.OrderBy(field => field.Id, StringComparer.Ordinal).ToArray();
|
||||
|
||||
return new YmirSoAWorld
|
||||
{
|
||||
Time = world.Time,
|
||||
BodyIds = bodies.Select(body => body.Id).ToArray(),
|
||||
PositionX = bodies.Select(body => body.Position.X).ToArray(),
|
||||
PositionY = bodies.Select(body => body.Position.Y).ToArray(),
|
||||
VelocityX = bodies.Select(body => body.Velocity.X).ToArray(),
|
||||
VelocityY = bodies.Select(body => body.Velocity.Y).ToArray(),
|
||||
Radius = bodies.Select(body => body.Radius).ToArray(),
|
||||
Mass = bodies.Select(body => body.Mass).ToArray(),
|
||||
DynamicMask = bodies.Select(body => body.IsStatic ? 0.0f : 1.0f).ToArray(),
|
||||
Restitution = bodies.Select(body => body.Restitution).ToArray(),
|
||||
FieldIds = fields.Select(field => field.Id).ToArray(),
|
||||
FieldPositionX = fields.Select(field => field.Position.X).ToArray(),
|
||||
FieldPositionY = fields.Select(field => field.Position.Y).ToArray(),
|
||||
FieldStrength = fields.Select(field => field.Strength).ToArray(),
|
||||
FieldRadius = fields.Select(field => field.Radius).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
public YmirWorld ToWorld() => new(
|
||||
Time,
|
||||
Enumerable.Range(0, BodyCount)
|
||||
.Select(i => new PhysicsBody(
|
||||
BodyIds[i],
|
||||
new Vec2(PositionX[i], PositionY[i]),
|
||||
new Vec2(VelocityX[i], VelocityY[i]),
|
||||
Radius[i],
|
||||
Mass[i],
|
||||
DynamicMask[i] <= 0.0f,
|
||||
Restitution[i]))
|
||||
.ToArray(),
|
||||
Enumerable.Range(0, FieldCount)
|
||||
.Select(i => new RadialField(
|
||||
FieldIds[i],
|
||||
new Vec2(FieldPositionX[i], FieldPositionY[i]),
|
||||
FieldStrength[i],
|
||||
FieldRadius[i]))
|
||||
.ToArray());
|
||||
|
||||
public YmirSoAWorld Clone() => new()
|
||||
{
|
||||
Time = Time,
|
||||
BodyIds = (string[])BodyIds.Clone(),
|
||||
PositionX = (float[])PositionX.Clone(),
|
||||
PositionY = (float[])PositionY.Clone(),
|
||||
VelocityX = (float[])VelocityX.Clone(),
|
||||
VelocityY = (float[])VelocityY.Clone(),
|
||||
Radius = (float[])Radius.Clone(),
|
||||
Mass = (float[])Mass.Clone(),
|
||||
DynamicMask = (float[])DynamicMask.Clone(),
|
||||
Restitution = (float[])Restitution.Clone(),
|
||||
FieldIds = (string[])FieldIds.Clone(),
|
||||
FieldPositionX = (float[])FieldPositionX.Clone(),
|
||||
FieldPositionY = (float[])FieldPositionY.Clone(),
|
||||
FieldStrength = (float[])FieldStrength.Clone(),
|
||||
FieldRadius = (float[])FieldRadius.Clone()
|
||||
};
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
ValidateBodyLengths();
|
||||
ValidateFieldLengths();
|
||||
|
||||
for (var i = 0; i < BodyCount; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BodyIds[i]))
|
||||
{
|
||||
throw new ArgumentException("Ymir bodies must have stable ids.");
|
||||
}
|
||||
|
||||
if (Radius[i] <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(Radius), "Body radius must be positive.");
|
||||
}
|
||||
|
||||
if (DynamicMask[i] > 0.0f && Mass[i] <= 0.0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(Mass), "Dynamic body mass must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float2 PositionAt(int index) => new(PositionX[index], PositionY[index]);
|
||||
|
||||
public float2 VelocityAt(int index) => new(VelocityX[index], VelocityY[index]);
|
||||
|
||||
public void SetPosition(int index, float2 value)
|
||||
{
|
||||
PositionX[index] = value.x;
|
||||
PositionY[index] = value.y;
|
||||
}
|
||||
|
||||
public void SetVelocity(int index, float2 value)
|
||||
{
|
||||
VelocityX[index] = value.x;
|
||||
VelocityY[index] = value.y;
|
||||
}
|
||||
|
||||
private void ValidateBodyLengths()
|
||||
{
|
||||
var expected = BodyIds.Length;
|
||||
foreach (var length in new[]
|
||||
{
|
||||
PositionX.Length,
|
||||
PositionY.Length,
|
||||
VelocityX.Length,
|
||||
VelocityY.Length,
|
||||
Radius.Length,
|
||||
Mass.Length,
|
||||
DynamicMask.Length,
|
||||
Restitution.Length
|
||||
})
|
||||
{
|
||||
if (length != expected)
|
||||
{
|
||||
throw new ArgumentException("Ymir body SoA arrays must have equal length.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateFieldLengths()
|
||||
{
|
||||
var expected = FieldIds.Length;
|
||||
foreach (var length in new[]
|
||||
{
|
||||
FieldPositionX.Length,
|
||||
FieldPositionY.Length,
|
||||
FieldStrength.Length,
|
||||
FieldRadius.Length
|
||||
})
|
||||
{
|
||||
if (length != expected)
|
||||
{
|
||||
throw new ArgumentException("Ymir field SoA arrays must have equal length.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SoASimulationStepRequest(float DeltaTime, YmirSoAWorld World);
|
||||
|
||||
public sealed record SoASimulationStepResult(YmirSoAWorld World, IReadOnlyList<ContactEvent> Contacts);
|
||||
130
src/Ymir.Core/YmirWorldStateDocument.cs
Normal file
130
src/Ymir.Core/YmirWorldStateDocument.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
|
||||
namespace Ymir.Core;
|
||||
|
||||
[CultDocument("gamecult.ymir.world_state", "gamecult.ymir.world_state.v0")]
|
||||
[MessagePackObject]
|
||||
public sealed class YmirWorldStateDocument
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string WorldId { get; set; } = "default";
|
||||
|
||||
[Key(1)]
|
||||
public float Time { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
public string[] BodyIds { get; set; } = [];
|
||||
|
||||
[Key(3)]
|
||||
public float[] PositionX { get; set; } = [];
|
||||
|
||||
[Key(4)]
|
||||
public float[] PositionY { get; set; } = [];
|
||||
|
||||
[Key(5)]
|
||||
public float[] VelocityX { get; set; } = [];
|
||||
|
||||
[Key(6)]
|
||||
public float[] VelocityY { get; set; } = [];
|
||||
|
||||
[Key(7)]
|
||||
public float[] Radius { get; set; } = [];
|
||||
|
||||
[Key(8)]
|
||||
public float[] Mass { get; set; } = [];
|
||||
|
||||
[Key(9)]
|
||||
public float[] DynamicMask { get; set; } = [];
|
||||
|
||||
[Key(10)]
|
||||
public float[] Restitution { get; set; } = [];
|
||||
|
||||
[Key(11)]
|
||||
public string[] FieldIds { get; set; } = [];
|
||||
|
||||
[Key(12)]
|
||||
public float[] FieldPositionX { get; set; } = [];
|
||||
|
||||
[Key(13)]
|
||||
public float[] FieldPositionY { get; set; } = [];
|
||||
|
||||
[Key(14)]
|
||||
public float[] FieldStrength { get; set; } = [];
|
||||
|
||||
[Key(15)]
|
||||
public float[] FieldRadius { get; set; } = [];
|
||||
|
||||
public static YmirWorldStateDocument FromSoA(string worldId, YmirSoAWorld world) => new()
|
||||
{
|
||||
WorldId = worldId,
|
||||
Time = world.Time,
|
||||
BodyIds = (string[])world.BodyIds.Clone(),
|
||||
PositionX = (float[])world.PositionX.Clone(),
|
||||
PositionY = (float[])world.PositionY.Clone(),
|
||||
VelocityX = (float[])world.VelocityX.Clone(),
|
||||
VelocityY = (float[])world.VelocityY.Clone(),
|
||||
Radius = (float[])world.Radius.Clone(),
|
||||
Mass = (float[])world.Mass.Clone(),
|
||||
DynamicMask = (float[])world.DynamicMask.Clone(),
|
||||
Restitution = (float[])world.Restitution.Clone(),
|
||||
FieldIds = (string[])world.FieldIds.Clone(),
|
||||
FieldPositionX = (float[])world.FieldPositionX.Clone(),
|
||||
FieldPositionY = (float[])world.FieldPositionY.Clone(),
|
||||
FieldStrength = (float[])world.FieldStrength.Clone(),
|
||||
FieldRadius = (float[])world.FieldRadius.Clone()
|
||||
};
|
||||
|
||||
public YmirSoAWorld ToSoA() => new()
|
||||
{
|
||||
Time = Time,
|
||||
BodyIds = (string[])BodyIds.Clone(),
|
||||
PositionX = (float[])PositionX.Clone(),
|
||||
PositionY = (float[])PositionY.Clone(),
|
||||
VelocityX = (float[])VelocityX.Clone(),
|
||||
VelocityY = (float[])VelocityY.Clone(),
|
||||
Radius = (float[])Radius.Clone(),
|
||||
Mass = (float[])Mass.Clone(),
|
||||
DynamicMask = (float[])DynamicMask.Clone(),
|
||||
Restitution = (float[])Restitution.Clone(),
|
||||
FieldIds = (string[])FieldIds.Clone(),
|
||||
FieldPositionX = (float[])FieldPositionX.Clone(),
|
||||
FieldPositionY = (float[])FieldPositionY.Clone(),
|
||||
FieldStrength = (float[])FieldStrength.Clone(),
|
||||
FieldRadius = (float[])FieldRadius.Clone()
|
||||
};
|
||||
}
|
||||
|
||||
public static class YmirCultCacheStore
|
||||
{
|
||||
public static async Task SaveWorldAsync(string path, string worldId, YmirSoAWorld world)
|
||||
{
|
||||
var cache = await CultCacheMessagePack.OpenAsync(
|
||||
path,
|
||||
new CultCacheOpenOptions
|
||||
{
|
||||
UseDirectoryStore = true,
|
||||
FlushOnDispose = true,
|
||||
StoreFlushOnDispose = true
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
using (cache)
|
||||
{
|
||||
await cache.UpsertAsync(
|
||||
YmirWorldStateDocument.FromSoA(worldId, world),
|
||||
new CultRecordHandle<YmirWorldStateDocument>(new CultRecordKey($"ymir:world:{worldId}")))
|
||||
.ConfigureAwait(false);
|
||||
await cache.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<YmirSoAWorld?> LoadWorldAsync(string path, string worldId)
|
||||
{
|
||||
using var cache = await CultCacheMessagePack.OpenAsync(
|
||||
path,
|
||||
new CultCacheOpenOptions { UseDirectoryStore = true }).ConfigureAwait(false);
|
||||
return cache.Get<YmirWorldStateDocument>(new CultRecordKey($"ymir:world:{worldId}"))?.ToSoA();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CultMath;
|
||||
using Ymir.Core;
|
||||
|
||||
namespace Ymir.Daemon;
|
||||
|
|
@ -27,6 +28,9 @@ internal static class Program
|
|||
case "step-sample":
|
||||
WriteJson(new YmirSimulator().Step(SampleRequest()));
|
||||
return 0;
|
||||
case "save-sample":
|
||||
await SaveSampleAsync(ValueAfter(args, "--path") ?? ".ymir/worlds.cc");
|
||||
return 0;
|
||||
case "step":
|
||||
return RunStep(args);
|
||||
case "serve":
|
||||
|
|
@ -143,7 +147,8 @@ internal static class Program
|
|||
"body integration",
|
||||
"field acceleration",
|
||||
"contact generation",
|
||||
"collision query semantics"
|
||||
"collision query semantics",
|
||||
"SoA CultCache world-state persistence"
|
||||
},
|
||||
doesNotOwn = new[]
|
||||
{
|
||||
|
|
@ -170,6 +175,8 @@ internal static class Program
|
|||
status = "mvp",
|
||||
stateOwner = "Ymir.Core step result; JSON is compatibility witness only",
|
||||
numericSubstrate = "CultMath",
|
||||
batchKernel = $"CultMath.BatchMath lanes={BatchMath.LaneCount}, hardwareAccelerated={BatchMath.IsHardwareAccelerated}",
|
||||
persistence = "CultCache MessagePack directory store for gamecult.ymir.world_state.v0",
|
||||
updatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
|
|
@ -190,6 +197,8 @@ internal static class Program
|
|||
new { type = "stat", label = "Status", value = "mvp" },
|
||||
new { type = "stat", label = "Owner", value = "physics simulation truth" },
|
||||
new { type = "stat", label = "Math", value = "CultMath" },
|
||||
new { type = "stat", label = "Batch lanes", value = BatchMath.LaneCount.ToString() },
|
||||
new { type = "stat", label = "Persistence", value = "CultCache SoA world_state.v0" },
|
||||
new { type = "route", label = "Step", value = "POST /simulate/step" }
|
||||
}
|
||||
}
|
||||
|
|
@ -210,6 +219,23 @@ internal static class Program
|
|||
new RadialField("gravity-well", new Vec2(0.0f, 1.0f), 2.0f, 8.0f)
|
||||
}));
|
||||
|
||||
private static async Task SaveSampleAsync(string path)
|
||||
{
|
||||
var result = new YmirSimulator().Step(new SoASimulationStepRequest(
|
||||
SampleRequest().DeltaTime,
|
||||
YmirSoAWorld.FromWorld(SampleRequest().World)));
|
||||
await YmirCultCacheStore.SaveWorldAsync(path, "sample", result.World);
|
||||
WriteJson(new
|
||||
{
|
||||
ok = true,
|
||||
path,
|
||||
record = "ymir:world:sample",
|
||||
schema = "gamecult.ymir.world_state.v0",
|
||||
bodyCount = result.World.BodyCount,
|
||||
fieldCount = result.World.FieldCount
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task WriteResponseAsync(HttpListenerContext context, object value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||
|
|
@ -249,6 +275,7 @@ internal static class Program
|
|||
provider
|
||||
operator-state
|
||||
step-sample
|
||||
save-sample [--path .ymir/worlds.cc]
|
||||
step --request <path>
|
||||
serve [--port 8877]
|
||||
""");
|
||||
|
|
|
|||
|
|
@ -75,6 +75,70 @@ public sealed class YmirSimulatorTests
|
|||
Assert.Equal("zeta", contact.BodyB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SoAStepUsesBufferAsSimulationAuthority()
|
||||
{
|
||||
var simulator = new YmirSimulator();
|
||||
var world = new YmirSoAWorld
|
||||
{
|
||||
BodyIds = ["ship", "anchor"],
|
||||
PositionX = [0.0f, 10.0f],
|
||||
PositionY = [0.0f, 0.0f],
|
||||
VelocityX = [1.0f, 0.0f],
|
||||
VelocityY = [0.0f, 0.0f],
|
||||
Radius = [0.5f, 0.5f],
|
||||
Mass = [1.0f, 1.0f],
|
||||
DynamicMask = [1.0f, 0.0f],
|
||||
Restitution = [0.2f, 0.2f],
|
||||
FieldIds = ["well"],
|
||||
FieldPositionX = [10.0f],
|
||||
FieldPositionY = [0.0f],
|
||||
FieldStrength = [10.0f],
|
||||
FieldRadius = [20.0f]
|
||||
};
|
||||
|
||||
var result = simulator.Step(new SoASimulationStepRequest(1.0f, world));
|
||||
|
||||
Assert.True(result.World.PositionX[0] > 1.0f);
|
||||
Assert.Equal(10.0f, result.World.PositionX[1], precision: 5);
|
||||
Assert.Equal(1.0f, result.World.Time, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CultCacheStoreRoundTripsSoAWorldState()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ymir-tests-{Guid.NewGuid():N}.cc");
|
||||
var recordsPath = $"{path}.records";
|
||||
|
||||
try
|
||||
{
|
||||
var world = YmirSoAWorld.FromWorld(new YmirWorld(
|
||||
2.0f,
|
||||
new[] { new PhysicsBody("ship", new Vec2(1.0f, 2.0f), new Vec2(3.0f, 4.0f), 0.5f, 1.0f) },
|
||||
new[] { new RadialField("well", new Vec2(5.0f, 6.0f), 7.0f, 8.0f) }));
|
||||
|
||||
await YmirCultCacheStore.SaveWorldAsync(path, "test", world);
|
||||
var loaded = await YmirCultCacheStore.LoadWorldAsync(path, "test");
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(["ship"], loaded!.BodyIds);
|
||||
Assert.Equal(1.0f, loaded.PositionX[0], precision: 5);
|
||||
Assert.Equal(7.0f, loaded.FieldStrength[0], precision: 5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
if (Directory.Exists(recordsPath))
|
||||
{
|
||||
Directory.Delete(recordsPath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SimulationStepResult Step(params PhysicsBody[] bodies)
|
||||
{
|
||||
var simulator = new YmirSimulator();
|
||||
|
|
|
|||
Loading…
Reference in a new issue