Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Skyline.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
<Project Path="src/Skyline.Render/Skyline.Render.csproj" />
<Project Path="src/Skyline.Interaction/Skyline.Interaction.csproj" />
<Project Path="src/Skyline.Interaction.Ui/Skyline.Interaction.Ui.csproj" />
<Project Path="src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Skyline.Tests/Skyline.Tests.csproj" />
<Project Path="tests/Skyline.Gpu.Tests/Skyline.Gpu.Tests.csproj" />
<Project Path="tests/Skyline.Render.Tests/Skyline.Render.Tests.csproj" />
<Project Path="tests/Skyline.Interaction.Tests/Skyline.Interaction.Tests.csproj" />
<Project Path="tests/Skyline.Interaction.Gpu.Tests/Skyline.Interaction.Gpu.Tests.csproj" />
<Project Path="tests/Skyline.WindowedTests/Skyline.WindowedTests.csproj" />
</Folder>
</Solution>
101 changes: 101 additions & 0 deletions src/Skyline.Interaction.Gpu/GpuTransfer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: Apache-2.0
using Silk.NET.WebGPU;

namespace Skyline.Interaction.Gpu;

/// <summary>
/// A GPU surface offered for transfer: a wgpu texture handle plus the metadata
/// a taker needs to bind it. The source owns the texture and keeps it alive for
/// the life of the offer; the taker must read or copy it before the offer
/// expires or is revoked. The handle never round-trips through the processor —
/// that is the whole point of the GPU bridge.
/// </summary>
public readonly record struct GpuSurfaceHandle(
nint Texture, TextureFormat Format, uint Width, uint Height);

/// <summary>
/// One offered GPU surface, with provenance and policy — the GPU twin of
/// <see cref="TransferOffer"/>. Provenance and policy come from the same
/// <see cref="Skyline.Interaction"/> model, so a surface transfer answers the
/// same who/how-far/how-long questions a text transfer does.
/// </summary>
public sealed record GpuTransferOffer(
string Id, GpuSurfaceHandle Surface, Provenance Provenance, TransferPolicy Policy);

/// <summary>
/// The GPU transfer seam: offer a surface, take one by id (subject to its
/// policy), revoke, or list the live ones. Mirrors <see cref="ITransferBroker"/>
/// for GPU handles instead of string payloads.
/// </summary>
public interface IGpuTransferBroker
{
GpuTransferOffer Offer(GpuSurfaceHandle surface, Provenance provenance, TransferPolicy? policy = null);
GpuTransferOffer? Take(string id, Actor taker);
bool Revoke(string id);
IReadOnlyList<GpuTransferOffer> List();
}

/// <summary>
/// The default in-process GPU transfer broker. Same lifetime and locality rules
/// as the text broker: it holds offers until they expire or are revoked, denies
/// a remote taker an offer its policy keeps local, and prunes lapsed offers
/// against an injected <see cref="TimeProvider"/>.
/// </summary>
public sealed class InProcessGpuTransferBroker(TimeProvider? time = null) : IGpuTransferBroker
{
private readonly TimeProvider _time = time ?? TimeProvider.System;
private readonly Lock _gate = new();
private readonly List<GpuTransferOffer> _offers = [];

public GpuTransferOffer Offer(GpuSurfaceHandle surface, Provenance provenance, TransferPolicy? policy = null)
{
var offer = new GpuTransferOffer(
Guid.NewGuid().ToString("n"), surface, provenance, policy ?? TransferPolicy.Default);
lock (_gate)
{
_offers.Add(offer);
}
return offer;
}

public GpuTransferOffer? Take(string id, Actor taker)
{
lock (_gate)
{
PruneLocked();
var offer = _offers.Find(o => o.Id == id);
if (offer is null)
{
return null;
}
if (taker.Locality == ActorLocality.Remote && !offer.Policy.AllowRemote)
{
return null;
}
return offer;
}
}

public bool Revoke(string id)
{
lock (_gate)
{
return _offers.RemoveAll(o => o.Id == id) > 0;
}
}

public IReadOnlyList<GpuTransferOffer> List()
{
lock (_gate)
{
PruneLocked();
return _offers.ToArray();
}
}

private void PruneLocked()
{
var now = _time.GetUtcNow();
_offers.RemoveAll(o => o.Policy.ExpiresAt is { } expiresAt && expiresAt <= now);
}
}
10 changes: 10 additions & 0 deletions src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Skyline.Interaction.Gpu</RootNamespace>
<Description>Optional GPU bridge for the Skyline interaction tier: a wgpu-handle transfer representation so a surface copy never round-trips to the processor. Pairs Skyline.Interaction's transfer model with Skyline.Gpu.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Skyline.Interaction\Skyline.Interaction.csproj" />
<ProjectReference Include="..\Skyline.Gpu\Skyline.Gpu.csproj" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions tests/Skyline.Interaction.Gpu.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Microsoft.VisualStudio.TestTools.UnitTesting;
98 changes: 98 additions & 0 deletions tests/Skyline.Interaction.Gpu.Tests/GpuTransferTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Silk.NET.WebGPU;

namespace Skyline.Interaction.Gpu.Tests;

[TestClass]
public class GpuTransferTests
{
private sealed class FixedTime(DateTimeOffset now) : TimeProvider
{
public DateTimeOffset Now { get; set; } = now;

public override DateTimeOffset GetUtcNow() => Now;
}

private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly Actor Local = new("u", "User", ActorKind.Human, ActorLocality.Local);
private static readonly Actor Remote = new("r", "Remote", ActorKind.Human, ActorLocality.Remote);
private static readonly GpuSurfaceHandle Surface = new(0xABCD, TextureFormat.Rgba8Unorm, 64, 48);

private static Provenance Prov() => new(Local, T0);

[TestMethod]
public void SurfaceHandleCarriesFields()
{
Assert.AreEqual((nint)0xABCD, Surface.Texture);
Assert.AreEqual(TextureFormat.Rgba8Unorm, Surface.Format);
Assert.AreEqual(64u, Surface.Width);
Assert.AreEqual(48u, Surface.Height);
}

[TestMethod]
public void OfferIsListedAndTakeable()
{
var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
var offer = broker.Offer(Surface, Prov());

Assert.AreEqual(TransferPolicy.Default, offer.Policy);
Assert.AreEqual(Surface, offer.Surface);
CollectionAssert.AreEqual(new[] { offer }, broker.List().ToArray());

var taken = broker.Take(offer.Id, Local);
Assert.IsNotNull(taken);
Assert.AreEqual(offer.Id, taken!.Id);
}

[TestMethod]
public void TakeUnknownIdReturnsNull()
{
var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
Assert.IsNull(broker.Take("missing", Local));
}

[TestMethod]
public void RemoteTakerIsDeniedALocalOnlyOffer()
{
var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
var offer = broker.Offer(Surface, Prov()); // Default policy: local only
Assert.IsNull(broker.Take(offer.Id, Remote));
}

[TestMethod]
public void RemoteTakerIsAllowedWhenThePolicyAllowsIt()
{
var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
var policy = new TransferPolicy(TransferScope.Session, AllowRemote: true);
var offer = broker.Offer(Surface, Prov(), policy);
var taken = broker.Take(offer.Id, Remote);
Assert.IsNotNull(taken);
Assert.AreEqual(offer.Id, taken!.Id);
}

[TestMethod]
public void RevokeRemovesAnOffer()
{
var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
var offer = broker.Offer(Surface, Prov());
Assert.IsTrue(broker.Revoke(offer.Id));
Assert.IsFalse(broker.Revoke(offer.Id)); // already gone
Assert.AreEqual(0, broker.List().Count);
}

[TestMethod]
public void ExpiredOffersArePrunedAndLiveOnesSurvive()
{
var clock = new FixedTime(T0);
var broker = new InProcessGpuTransferBroker(clock);
var expiring = broker.Offer(Surface, Prov(),
new TransferPolicy(TransferScope.Session, AllowRemote: false, ExpiresAt: T0.AddSeconds(10)));
var permanent = broker.Offer(Surface, Prov()); // no expiry

clock.Now = T0.AddSeconds(11);

Assert.IsNull(broker.Take(expiring.Id, Local), "an expired offer is pruned before a take");
var live = broker.List();
Assert.AreEqual(1, live.Count);
Assert.AreEqual(permanent.Id, live[0].Id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Skyline.Interaction.Gpu\Skyline.Interaction.Gpu.csproj" />
<ProjectReference Include="..\..\src\Skyline.Interaction\Skyline.Interaction.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="MSTest" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion tools/cover.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ dotnet reportgenerator \
-reports:"coverage/unit/*/coverage.cobertura.xml;coverage/windowed.cobertura.xml" \
-targetdir:coverage/report \
"-reporttypes:TextSummary" \
"-assemblyfilters:+Skyline;+Skyline.Gpu;+Skyline.Render;+Skyline.Interaction;+Skyline.Interaction.Ui"
"-assemblyfilters:+Skyline;+Skyline.Gpu;+Skyline.Render;+Skyline.Interaction;+Skyline.Interaction.Ui;+Skyline.Interaction.Gpu"

cat coverage/report/Summary.txt