Build Fensalir Perlines demo

This commit is contained in:
Meta Vens 2026-05-31 00:38:24 +01:00
commit 2d386ed588
11 changed files with 1097 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
bin/
obj/
artifacts/
.vs/
.idea/
*.user
*.suo
*.log
TestResults/

18
Perlines.sln Normal file
View file

@ -0,0 +1,18 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Perlines", "src\Perlines\Perlines.csproj", "{9DD844F7-477F-43C0-8A97-D93D0FD2A62E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9DD844F7-477F-43C0-8A97-D93D0FD2A62E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9DD844F7-477F-43C0-8A97-D93D0FD2A62E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9DD844F7-477F-43C0-8A97-D93D0FD2A62E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9DD844F7-477F-43C0-8A97-D93D0FD2A62E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

43
README.md Normal file
View file

@ -0,0 +1,43 @@
# Perlines
Perlines is the 2012 Aethervis trail idea rebuilt as a Fensalir client.
The Unity version owned trails through `LineRenderer` and `TrailRenderer`
objects. This version owns only the signal: a procedural audio frame is sent
through an FFT, adaptively whitened per band, written into a rolling Float32
field resource, and lowered through Fensalir's TubeField path. The renderer owns
visibility, reservoir reuse, bloom, and final reconstruction.
## Run
From `E:\Projects\Fensalir`:
```powershell
.\scripts\dev-reload.ps1 -ClientProject E:\Projects\Perlines\src\Perlines\Perlines.csproj
```
Headless smoke test from Fensalir:
```powershell
.\scripts\dev-reload.ps1 -Headless -ClientProject E:\Projects\Perlines\src\Perlines\Perlines.csproj
```
Headless PNG capture from this repo:
```powershell
E:\Projects\Perlines\scripts\capture-headless.ps1
```
## Live Model
- Perlines owns procedural audio/FFT/whitening and field-resource uploads.
- Fensalir owns the Win32 host, D3D12 renderer, TubeField compute/render
lowering, shared reservoir, and presentation denoising.
- The rolling spectrum is a below-native field evidence source, not a private
line renderer.
- The spline lanes are assigned to notes in the detected key instead of
arbitrary spectrum bins.
- Each rendered spline is one note lane evolving over the rolling time window.
- A Fensalir spline presentation turns that reservoir into the old radial
Perlines trail storm with a divergence-free curl-noise flow, while TubeField
evidence remains available to the shared reservoir path.

61
docs/system-map.md Normal file
View file

@ -0,0 +1,61 @@
# Perlines System Map
## Objective
Perlines rebuilds the 2012 Aethervis trail demo as a Fensalir client whose
visible form is driven by a key-aware, adaptively whitened FFT reservoir.
The real outcome is not a port of Unity `LineRenderer` state. The outcome is a
below-native spectral field that Fensalir can lower through the same TubeField,
reservoir, denoising, and presentation machinery used by the engine.
## Authority Map
- Owner: `PerlinesSignalProcessor` owns audio synthesis, FFT whitening, key
detection, and projection from frequency bins into musical scale-note lanes.
- Inputs: frame time and delta time from the Fensalir runtime loop.
- Outputs: one normalized Float32 column of scale-note energy per frame,
transposed into note-owned TubeField splines for rendering.
- Derived state: detected key, flux, tonal center, and energy are diagnostic
readouts derived from the same spectral pass.
- Forbidden writers: render passes, UI controls, and tube lowerings do not
decide spectral content. They only consume the field evidence.
- Shared path: visible splines, TubeField evidence, headless captures, and UI
telemetry all read the same rolling note reservoir and version counter.
- Deletion line: Unity trail ownership, arbitrary band-to-spline assignment,
and per-object procedural line state are dead. The renderer owns visibility.
## Pipeline
1. Procedural audio is synthesized as a test signal.
2. A Hann-windowed FFT measures the current frame.
3. Each FFT bin is divided by a smoothed envelope, producing adaptive whitening
so persistent loud bands stop monopolizing the field.
4. Whitened bins accumulate into chroma energy.
5. Major/minor key templates with hysteresis choose the current key.
6. The detected key defines 70 scale-note lanes across octaves.
7. Each lane samples nearby whitened FFT bins for the note frequency.
8. The rolling note history is transposed into one contiguous curve per note
lane.
9. Perlines uploads that compact 70 by 96 Float32 field resource.
10. Fensalir lowers each note lane through `AquariumFieldTubeSplineLowering`.
11. A Fensalir spline presentation reads the same note history and gives each
lane a radial path advected by `cross(position, d/dxyz noise4(position,t))`,
giving the presentation a divergence-free curl-noise style flow without
restoring Unity object ownership.
12. The D3D12 TubeField path feeds the shared reservoir and final presentation.
## Invariants
- Musical lanes are keyed scale notes, not arbitrary FFT bins.
- Each TubeField spline belongs to one detected-key note lane.
- Perlines uploads a compact 70 by 96 Float32 resource, not a native-resolution
buffer.
- The showy dance splines are derived from the same note reservoir. They do not
own signal truth.
- The rolling spectrum is a field-evidence resource, not private renderer
geometry.
- Fensalir owns D3D12 buffers, TubeField dispatch, reservoir reuse, bloom, and
final reconstruction.
- Headless verification must exercise the same client assembly and TubeField
lowering path as interactive runs.

View file

@ -0,0 +1,104 @@
param(
[string]$OutputPath,
[int]$Width = 1280,
[int]$Height = 720,
[int]$ReadyFrames = 24,
[double]$FieldReservoirScale = 0.5,
[double]$FieldReservoirSpatialReuseBudget = 0.5,
[int]$RetainSlots = 4,
[int]$TimeoutSeconds = 180
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$fensalirRoot = "E:\Projects\Fensalir"
$engineProject = Join-Path $fensalirRoot "src\Aquarium.Engine\Aquarium.Engine.csproj"
$clientProject = Join-Path $repoRoot "src\Perlines\Perlines.csproj"
$devRoot = Join-Path $repoRoot "artifacts\headless"
$slotRoot = Join-Path $devRoot "slots"
$slotPath = Join-Path $slotRoot ("perlines-capture-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + [guid]::NewGuid().ToString("N").Substring(0, 8))
$cachePath = Join-Path $devRoot "cultcache\aquarium-client.msgpack"
$stdoutLog = Join-Path $devRoot "capture.out.log"
$stderrLog = Join-Path $devRoot "capture.err.log"
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$OutputPath = Join-Path $devRoot ("perlines-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".png")
}
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
New-Item -ItemType Directory -Force -Path $slotPath | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path $cachePath) | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path $OutputPath) | Out-Null
Write-Host "Building Perlines capture slot:"
Write-Host " $slotPath"
dotnet build $engineProject -c Debug -o $slotPath /p:UseAppHost=true
if ($LASTEXITCODE -ne 0) {
throw "Engine build failed with exit code $LASTEXITCODE."
}
dotnet build $clientProject -c Debug -o $slotPath /p:UseAppHost=false
if ($LASTEXITCODE -ne 0) {
throw "Perlines client build failed with exit code $LASTEXITCODE."
}
$exePath = Join-Path $slotPath "Aquarium.Engine.exe"
$clientAssembly = Join-Path $slotPath "Perlines.dll"
$arguments = @(
"--headless",
"--headless-width", $Width,
"--headless-height", $Height,
"--client-assembly", $clientAssembly,
"--cache", $cachePath,
"--shader-source", (Join-Path $slotPath "Render\Shaders\D3D12HeightField.hlsl"),
"--capture-frame", $OutputPath,
"--field-reservoir-scale", $FieldReservoirScale,
"--field-reservoir-spatial-reuse-budget", $FieldReservoirSpatialReuseBudget
)
$previousReadyFrames = $env:AQUARIUM_HEADLESS_READY_FRAMES
$env:AQUARIUM_HEADLESS_READY_FRAMES = [string][Math]::Max(1, $ReadyFrames)
try {
$process = Start-Process -FilePath $exePath -ArgumentList $arguments -NoNewWindow -PassThru -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog
if (-not $process.WaitForExit([Math]::Max(1, $TimeoutSeconds) * 1000)) {
$process.Kill($true)
$tail = if (Test-Path $stderrLog) { Get-Content -Path $stderrLog -Tail 80 } else { "<stderr missing>" }
throw "Perlines capture timed out after $TimeoutSeconds seconds.`n$($tail -join [Environment]::NewLine)"
}
$process.WaitForExit()
$process.Refresh()
$exitCode = $process.ExitCode
if ($null -eq $exitCode -and (Test-Path $OutputPath)) {
$exitCode = 0
}
if ($exitCode -ne 0) {
$tail = if (Test-Path $stderrLog) { Get-Content -Path $stderrLog -Tail 80 } else { "<stderr missing>" }
throw "Perlines capture failed with exit code $exitCode.`n$($tail -join [Environment]::NewLine)"
}
}
finally {
$env:AQUARIUM_HEADLESS_READY_FRAMES = $previousReadyFrames
}
if (-not (Test-Path $OutputPath)) {
throw "Perlines capture did not produce expected PNG: $OutputPath"
}
if ($RetainSlots -gt 0) {
Get-ChildItem -Path $slotRoot -Directory -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $RetainSlots |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host "Perlines frame captured:"
Write-Host " $OutputPath"
Write-Host "Logs:"
Write-Host " $stdoutLog"
Write-Host " $stderrLog"

16
scripts/run-dev.ps1 Normal file
View file

@ -0,0 +1,16 @@
param(
[switch]$Headless
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$fensalir = "E:\Projects\Fensalir"
$client = "E:\Projects\Perlines\src\Perlines\Perlines.csproj"
if ($Headless) {
& (Join-Path $fensalir "scripts\dev-reload.ps1") -Headless -ClientProject $client
}
else {
& (Join-Path $fensalir "scripts\dev-reload.ps1") -ClientProject $client
}

81
src/Perlines/Perlin4D.cs Normal file
View file

@ -0,0 +1,81 @@
using System.Numerics;
namespace Perlines;
internal sealed class Perlin4D
{
private readonly int[] permutation = new int[512];
public Perlin4D(int seed)
{
var random = new Random(seed);
for (var index = 0; index < 256; index++)
{
permutation[index] = index;
}
for (var index = 255; index > 0; index--)
{
var swap = random.Next(index + 1);
(permutation[index], permutation[swap]) = (permutation[swap], permutation[index]);
}
for (var index = 0; index < 256; index++)
{
permutation[index + 256] = permutation[index];
}
}
public Vector3 Derivative(Vector4 point)
{
var x0 = (int)MathF.Floor(point.X);
var y0 = (int)MathF.Floor(point.Y);
var z0 = (int)MathF.Floor(point.Z);
var w0 = (int)MathF.Floor(point.W);
var x = point.X - x0;
var y = point.Y - y0;
var z = point.Z - z0;
var w = point.W - w0;
var fade = new Vector4(Fade(x), Fade(y), Fade(z), Fade(w));
var fadeDerivative = new Vector4(FadeDerivative(x), FadeDerivative(y), FadeDerivative(z), FadeDerivative(w));
var derivative = Vector3.Zero;
for (var cornerX = 0; cornerX <= 1; cornerX++)
for (var cornerY = 0; cornerY <= 1; cornerY++)
for (var cornerZ = 0; cornerZ <= 1; cornerZ++)
for (var cornerW = 0; cornerW <= 1; cornerW++)
{
var distance = new Vector4(x - cornerX, y - cornerY, z - cornerZ, w - cornerW);
var gradient = Gradient(x0 + cornerX, y0 + cornerY, z0 + cornerZ, w0 + cornerW);
var contribution = Vector4.Dot(gradient, distance);
var wx = cornerX == 0 ? 1.0f - fade.X : fade.X;
var wy = cornerY == 0 ? 1.0f - fade.Y : fade.Y;
var wz = cornerZ == 0 ? 1.0f - fade.Z : fade.Z;
var ww = cornerW == 0 ? 1.0f - fade.W : fade.W;
var dwx = cornerX == 0 ? -fadeDerivative.X : fadeDerivative.X;
var dwy = cornerY == 0 ? -fadeDerivative.Y : fadeDerivative.Y;
var dwz = cornerZ == 0 ? -fadeDerivative.Z : fadeDerivative.Z;
derivative.X += (dwx * wy * wz * ww * contribution) + (wx * wy * wz * ww * gradient.X);
derivative.Y += (wx * dwy * wz * ww * contribution) + (wx * wy * wz * ww * gradient.Y);
derivative.Z += (wx * wy * dwz * ww * contribution) + (wx * wy * wz * ww * gradient.Z);
}
return derivative;
}
private Vector4 Gradient(int x, int y, int z, int w)
{
var hash = permutation[(x + permutation[(y + permutation[(z + permutation[w & 255]) & 255]) & 255]) & 255];
var gx = ((hash & 1) == 0 ? 1.0f : -1.0f) * (1.0f + ((hash >> 4) & 1) * 0.5f);
var gy = ((hash & 2) == 0 ? 1.0f : -1.0f) * (1.0f + ((hash >> 5) & 1) * 0.5f);
var gz = ((hash & 4) == 0 ? 1.0f : -1.0f) * (1.0f + ((hash >> 6) & 1) * 0.5f);
var gw = ((hash & 8) == 0 ? 1.0f : -1.0f) * (1.0f + ((hash >> 7) & 1) * 0.5f);
return Vector4.Normalize(new Vector4(gx, gy, gz, gw));
}
private static float Fade(float t) => t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f);
private static float FadeDerivative(float t) => 30.0f * t * t * (t - 1.0f) * (t - 1.0f);
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>Perlines</AssemblyName>
<RootNamespace>Perlines</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="E:\Projects\Fensalir\src\Aquarium.Engine.Contracts\Aquarium.Engine.Contracts.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,439 @@
using System.Numerics;
using Aquarium.Engine;
using Aquarium.Engine.Audio;
using Aquarium.Engine.Input;
using Aquarium.Engine.Render;
using Aquarium.Engine.Ui;
namespace Perlines;
public sealed class PerlinesRuntime : IAquariumRuntime
{
private const int FftSize = 2048;
private const int NoteLaneCount = 70;
private const int HistoryColumns = 96;
private const string DomainKey = "perlines:domain:whitened-fft";
private const string ResourceKey = "resource:perlines:whitened-fft";
private const string ClaimKey = "claim:perlines:whitened-fft:tube";
private const string CandidateKey = "candidate:perlines:whitened-fft:tube";
private const string ProducerKey = "perlines:adaptive-whitener";
private readonly PerlinesSignalProcessor signal = new(FftSize, NoteLaneCount, sampleRate: 48000.0f);
private readonly SmoothNoise danceNoise = new(0xDA7A);
private readonly Perlin4D flowNoise = new(0xC012);
private readonly float[] rollingNotes = new float[NoteLaneCount * HistoryColumns];
private readonly float[] tubeSamples = new float[NoteLaneCount * HistoryColumns];
private readonly float[] uploadColumn = new float[NoteLaneCount];
private float timeSeconds;
private float previousTimeSeconds;
private float cameraYaw;
private float cameraPitch = 0.24f;
private float cameraDistance = 7.8f;
private int rollingColumn;
private ulong resourceVersion = 1;
private bool autoOrbit = true;
private bool freezeSignal;
public AquariumRuntimeOptions Options { get; private set; }
public GraphicsSettings GraphicsSettings { get; set; } = new(
RenderDebugMode: 0,
SceneExposure: 0.92f,
BloomIntensity: 0.82f,
BloomVeilIntensity: 0.0f,
FieldReservoirMode: GraphicsSettings.FieldReservoirModeNativeDomain,
FieldReservoirScale: 0.5f,
FieldReservoirSpatialReuseBudget: 0.1f);
public AquariumRenderPlan RenderPlan { get; } = CreateRenderPlan();
public AquariumUiDocument Ui { get; }
public AquariumAudioDocument Audio { get; } = new();
public AquariumSynthDocument Synth { get; } = AquariumSynthDocument.Empty;
public AquariumFrame Frame
{
get
{
var target = new Vector3(0.0f, 0.28f, -0.35f);
return new AquariumFrame(
new ViewFrame(Vector2.Zero, 7.5f),
ComposeCamera(target),
target,
timeSeconds,
Vector2.Zero,
BuildScene());
}
}
public PerlinesRuntime()
{
Ui = new AquariumUiDocument()
.Panel("Perlines", 18.0f, 82.0f, 390.0f, panel =>
{
panel.Section("Adaptive FFT Tube Field");
panel.Toggle("Auto Orbit", () => autoOrbit, value => autoOrbit = value);
panel.Toggle("Freeze Signal", () => freezeSignal, value => freezeSignal = value);
panel.Slider("Camera Distance", () => cameraDistance, value => cameraDistance = Math.Clamp(value, 4.5f, 13.0f), 4.5f, 13.0f, "0.00");
panel.Readout("FFT", () => $"{FftSize} samples / {NoteLaneCount} scale-note lanes / v{resourceVersion}");
panel.Readout("Key", () => signal.DetectedKeyName);
panel.Readout("Whitening", () => $"flux {signal.LastFlux:0.000} / tonal center {signal.LastCentroid:0.000} / energy {signal.LastEnergy:0.000}");
panel.Readout("Renderer", () => "Fensalir TubeField + shared reservoir + final reconstruction");
})
.Command("perlines", _ => $"Perlines: {NoteLaneCount} key-mapped scale-note lanes, {signal.DetectedKeyName}, rolling column {rollingColumn}, v{resourceVersion}.", "Report Perlines signal state.");
}
public AquariumFrame ComposeFrame(AquariumFrame frame, AquariumFrameInput input)
{
return frame;
}
public void Start()
{
Console.WriteLine("Perlines adaptive FFT tube field booted.");
}
public void Update(float deltaSeconds, InputState input)
{
previousTimeSeconds = timeSeconds;
var safeDelta = Math.Max(deltaSeconds, 0.0f);
timeSeconds += safeDelta;
if (!freezeSignal)
{
signal.Advance(timeSeconds, safeDelta, uploadColumn);
rollingColumn = (rollingColumn + 1) % HistoryColumns;
Array.Copy(uploadColumn, 0, rollingNotes, rollingColumn * NoteLaneCount, NoteLaneCount);
resourceVersion++;
}
if (autoOrbit)
{
cameraYaw += safeDelta * 0.045f;
}
if (input.IsKeyDown(KeyCode.A))
{
cameraYaw -= safeDelta * 0.85f;
autoOrbit = false;
}
if (input.IsKeyDown(KeyCode.D))
{
cameraYaw += safeDelta * 0.85f;
autoOrbit = false;
}
if (input.IsKeyDown(KeyCode.W))
{
cameraPitch += safeDelta * 0.36f;
}
if (input.IsKeyDown(KeyCode.S))
{
cameraPitch -= safeDelta * 0.36f;
}
if (MathF.Abs(input.WheelDelta) > 0.0f)
{
cameraDistance = Math.Clamp(cameraDistance - input.WheelDelta * MathF.Max(cameraDistance * 0.09f, 0.05f), 4.5f, 13.0f);
}
if (input.LeftMouseDown && input.MouseDelta != Vector2.Zero)
{
cameraYaw -= input.MouseDelta.X * 0.006f;
cameraPitch += input.MouseDelta.Y * 0.0035f;
autoOrbit = false;
}
cameraPitch = Math.Clamp(cameraPitch, 0.06f, 0.62f);
}
public void FlushState()
{
}
public void Dispose()
{
}
internal void SetOptions(AquariumRuntimeOptions options)
{
Options = options;
if (options.Headless)
{
autoOrbit = false;
cameraYaw = 0.76f;
cameraPitch = 0.34f;
cameraDistance = 7.8f;
for (var frame = 0; frame < HistoryColumns; frame++)
{
timeSeconds += 1.0f / 60.0f;
signal.Advance(timeSeconds, 1.0f / 60.0f, uploadColumn);
rollingColumn = frame % HistoryColumns;
Array.Copy(uploadColumn, 0, rollingNotes, rollingColumn * NoteLaneCount, NoteLaneCount);
resourceVersion++;
}
previousTimeSeconds = timeSeconds - (1.0f / 60.0f);
}
}
private AquariumSceneState BuildScene()
{
var timestampNs = (long)(timeSeconds * 1_000_000_000.0f);
var support = new AquariumFieldSupport(
Center: new Vector3(0.0f, 0.9f, 0.0f),
Radius: new Vector3(4.8f, 2.2f, 3.2f),
LocalFrame: Matrix4x4.Identity,
ConservativeRadius: 5.9f,
ProjectedError: 1.0f / 128.0f,
Curvature: 0.2f + signal.LastFlux,
TemporalUncertainty: 0.016f);
var proposal = new AquariumFieldProposalPolicy(
AquariumFieldProposalKind.SensorObservation,
SourcePdf: 1.0f,
TargetContribution: Math.Max(0.1f, 2.5f + signal.LastEnergy * 6.0f),
RepresentedCandidateCount: NoteLaneCount,
Seed: (uint)(resourceVersion & uint.MaxValue));
var guide = AquariumFieldGuide.Valid(Math.Clamp(0.68f + signal.LastFlux * 0.3f, 0.1f, 1.0f), 1.0f / 60.0f);
var resource = new AquariumFieldResourceDeclaration(
ResourceKey,
AquariumFieldResourceKind.StructuredBuffer,
AquariumFieldResourceResidency.GpuResident,
AquariumFieldShaderAccess.ShaderResource,
"Float32",
Width: HistoryColumns,
Height: NoteLaneCount,
DepthOrCount: tubeSamples.Length,
StrideBytes: sizeof(float),
ValidFromNs: Math.Max(0, timestampNs - 50_000_000),
ValidUntilNs: timestampNs + 100_000_000,
Version: resourceVersion,
NativeHandle: IntPtr.Zero,
NativeHandleKind: "perlines-rolling-float32");
var frame = new AquariumFieldEvidenceFrame
{
AccumulationWindowSeconds = 0.45f,
PresentationDelaySeconds = 0.0f,
Domains =
[
new AquariumFieldDomain(
DomainKey,
"",
AquariumFieldDomainKind.RollingBuffer,
Matrix4x4.Identity,
Matrix4x4.Identity,
new Vector3(-3.8f, -0.15f, -2.6f),
new Vector3(3.8f, 2.7f, 2.6f),
new Vector3(0.0f, 0.0f, HistoryColumns),
"Perlines")
],
Resources = [resource, AquariumBuiltInFieldResources.BlackbodyRamp(resourceVersion)],
ResourceUploads = BuildResourceUploads(),
Claims =
[
new AquariumFieldClaim(
ClaimKey,
DomainKey,
ProducerKey,
AquariumFieldLayer.Form,
AquariumFieldEncoding.Tube,
support,
proposal,
ResourceKey,
timestampNs,
guide.Confidence)
],
Candidates =
[
new AquariumFieldCandidate(
CandidateKey,
ClaimKey,
AquariumFieldLayer.Form,
AquariumFieldEncoding.Tube,
proposal,
guide)
],
TubeSplineLowerings =
[
new AquariumFieldTubeSplineLowering(
"lowering:perlines:fft:tube",
ClaimKey,
ResourceKey,
Width: HistoryColumns,
Height: NoteLaneCount,
StrideBytes: sizeof(float),
FirstColumn: 0,
ColumnCount: NoteLaneCount,
ColumnStride: 1,
RollingModulo: 0,
RollingOffset: 0,
Origin: new Vector3(-3.4f, -0.72f, -2.15f),
AxisStep: new Vector3(6.8f / Math.Max(1, HistoryColumns - 1), 0.0f, 0.0f),
ColumnStep: new Vector3(0.0f, 0.018f, 4.3f / Math.Max(1, NoteLaneCount - 1)),
AmplitudePower: 1.38f,
AmplitudeScale: 1.75f,
NormalizeMin: 0.0f,
NormalizeMax: 1.0f,
BaseRadius: 0.014f,
RadiusScale: 0.052f,
Alpha: 0.92f,
Feather: 0.32f,
RampTexturePath: "",
RampResourceKey: AquariumBuiltInFieldResources.BlackbodyRampResourceKey,
EmissionScale: 9.2f,
CatmullRomSubdivisions: 4)
],
};
return new AquariumSceneState
{
TraceHeightFieldSurface = false,
UseStudioBackground = false,
SdfLights =
[
new AquariumSdfLight(new Vector4(-3.2f, -3.8f, 4.2f, 6.0f), new Vector4(0.26f, 0.46f, 0.95f, 12.0f)),
new AquariumSdfLight(new Vector4(3.6f, 2.8f, 3.4f, 4.0f), new Vector4(1.0f, 0.36f, 0.18f, 5.8f)),
],
FieldEvidenceFrame = frame,
SplineFrame = BuildDanceSplineFrame(),
};
}
private AquariumSplineFrame BuildDanceSplineFrame()
{
var splines = new List<AquariumSpline3D>(NoteLaneCount);
var style = new AquariumSplineStyle(
radius: 0.012f,
emission: 10.0f,
alpha: 0.94f,
normalExponent: 0.42f,
feather: 0.08f);
const int pointCount = 56;
for (var note = 0; note < NoteLaneCount; note++)
{
var vertices = new AquariumSplineVertex[pointCount];
var note01 = note / Math.Max(1.0f, NoteLaneCount - 1.0f);
var baseAngle = note01 * MathF.Tau * 3.0f + note * 0.37f;
var lanePhase = timeSeconds * (0.22f + note01 * 0.18f) + note * 11.13f;
var laneHue = note % 7;
var radial = Vector3.Normalize(new Vector3(MathF.Cos(baseAngle), 0.18f * MathF.Sin(baseAngle * 1.7f), MathF.Sin(baseAngle)));
var position = radial * (0.18f + note01 * 0.4f);
for (var point = 0; point < pointCount; point++)
{
var t = point / Math.Max(1.0f, pointCount - 1.0f);
var historyIndex = Math.Clamp((int)MathF.Round(t * (HistoryColumns - 1)), 0, HistoryColumns - 1);
var physicalColumn = (rollingColumn + 1 + historyIndex) % HistoryColumns;
var energy = rollingNotes[physicalColumn * NoteLaneCount + note];
var lift = MathF.Pow(Math.Clamp(energy, 0.0f, 1.0f), 0.55f);
var flare = MathF.Pow(lift, 1.7f);
var derivative = flowNoise.Derivative(new Vector4(
position.X * 0.58f + note * 0.013f,
position.Y * 0.72f + note01 * 2.0f,
position.Z * 0.58f - note * 0.017f,
timeSeconds * 0.22f + t * 1.8f));
var curl = Vector3.Cross(position, derivative);
if (curl.LengthSquared() > 0.0001f)
{
curl = Vector3.Normalize(curl);
}
var pulse = lift * (0.18f + energy * 0.42f);
var drift = 0.026f + lift * 0.12f;
var jitter = danceNoise.Fractal(note * 1.7f + point * 0.23f + timeSeconds * 0.5f, 2.1f) * 0.025f * (0.2f + lift);
position += radial * (drift + pulse) + curl * (0.055f + lift * 0.20f) + Vector3.UnitY * jitter;
vertices[point] = new AquariumSplineVertex(position, HotColor(flare, laneHue));
}
splines.Add(new AquariumSpline3D($"perlines:dance:{note:00}", vertices, style, CatmullRomSubdivisions: 5));
}
return new AquariumSplineFrame { Splines = splines };
}
private static Vector4 HotColor(float energy, int laneHue)
{
var e = Math.Clamp(energy, 0.0f, 1.0f);
var red = Math.Clamp(2.1f + e * 3.4f, 0.0f, 6.0f);
var green = Math.Clamp(0.02f + e * 0.85f, 0.0f, 1.6f);
var blue = Math.Clamp(e * e * 0.18f, 0.0f, 0.45f);
if (laneHue == 0)
{
green += 1.35f;
blue += 0.08f;
}
else if (laneHue == 4)
{
blue += 1.2f;
red *= 0.72f;
}
return new Vector4(red, green, blue, Math.Clamp(0.22f + e * 0.78f, 0.0f, 1.0f));
}
private AquariumFieldResourceUpload[] BuildResourceUploads()
{
RefreshTubeSamples();
return
[
new AquariumFieldResourceUpload
{
ResourceKey = ResourceKey,
Version = resourceVersion,
ElementOffset = 0,
Float32Data = tubeSamples.ToArray(),
}
];
}
private void RefreshTubeSamples()
{
for (var note = 0; note < NoteLaneCount; note++)
{
var noteOffset = note * HistoryColumns;
for (var age = 0; age < HistoryColumns; age++)
{
var physicalColumn = (rollingColumn + 1 + age) % HistoryColumns;
tubeSamples[noteOffset + age] = rollingNotes[physicalColumn * NoteLaneCount + note];
}
}
}
private Vector3 ComposeCamera(Vector3 target)
{
var horizontal = MathF.Cos(cameraPitch) * cameraDistance;
return new Vector3(
MathF.Sin(cameraYaw) * horizontal,
-MathF.Cos(cameraYaw) * horizontal,
target.Z + MathF.Sin(cameraPitch) * cameraDistance);
}
private static AquariumRenderPlan CreateRenderPlan()
{
var app = new AquariumApp();
var scene = app.RenderTargets.Hdr("scene");
app.Cameras.Perspective("main");
app.Graph.Pass("scene").Fullscreen();
app.Features.Bloom(scene.Color);
app.Features.Presentation(scene.Color);
app.Features.DirectWriteOverlay();
app.Debug.View("Scene", scene.Color);
return app.Plan;
}
}
public sealed class PerlinesRuntimeFactory : IAquariumRuntimeFactory
{
public IAquariumRuntime Create(AquariumRuntimeOptions options)
{
var runtime = new PerlinesRuntime();
runtime.SetOptions(options);
return runtime;
}
}

View file

@ -0,0 +1,255 @@
using System.Numerics;
namespace Perlines;
internal sealed class PerlinesSignalProcessor
{
private static readonly string[] NoteNames = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"];
private static readonly int[] MajorScale = [0, 2, 4, 5, 7, 9, 11];
private static readonly int[] MinorScale = [0, 2, 3, 5, 7, 8, 10];
private static readonly float[] MajorTemplate = [6.35f, 2.23f, 3.48f, 2.33f, 4.38f, 4.09f, 2.52f, 5.19f, 2.39f, 3.66f, 2.29f, 2.88f];
private static readonly float[] MinorTemplate = [6.33f, 2.68f, 3.52f, 5.38f, 2.60f, 3.53f, 2.54f, 4.75f, 3.98f, 2.69f, 3.34f, 3.17f];
private readonly int fftSize;
private readonly int laneCount;
private readonly float sampleRate;
private readonly int binCount;
private readonly float[] window;
private readonly float[] envelope;
private readonly float[] whitenedBins;
private readonly float[] chroma;
private readonly float[] smoothedChroma;
private readonly float[] previousLanes;
private readonly Complex[] spectrum;
private readonly SmoothNoise noise = new(0x5EED);
private float phaseA;
private float phaseB;
private float phaseC;
private int detectedRoot = 9;
private bool detectedMinor;
public float LastEnergy { get; private set; }
public float LastFlux { get; private set; }
public float LastCentroid { get; private set; }
public string DetectedKeyName => $"{NoteNames[detectedRoot]} {(detectedMinor ? "minor" : "major")}";
public PerlinesSignalProcessor(int fftSize, int laneCount, float sampleRate)
{
if (!BitOperations.IsPow2(fftSize))
{
throw new ArgumentException("FFT size must be a power of two.", nameof(fftSize));
}
this.fftSize = fftSize;
this.laneCount = laneCount;
this.sampleRate = sampleRate;
binCount = fftSize / 2;
window = new float[fftSize];
envelope = new float[binCount];
whitenedBins = new float[binCount];
chroma = new float[12];
smoothedChroma = new float[12];
previousLanes = new float[laneCount];
spectrum = new Complex[fftSize];
Array.Fill(envelope, 0.08f);
for (var index = 0; index < fftSize; index++)
{
window[index] = 0.5f - 0.5f * MathF.Cos(MathF.Tau * index / Math.Max(1, fftSize - 1));
}
}
public void Advance(float timeSeconds, float deltaSeconds, Span<float> output)
{
if (output.Length < laneCount)
{
throw new ArgumentException("Output span is smaller than the configured lane count.", nameof(output));
}
FillSignal(timeSeconds);
Fft(spectrum);
WhitenBins(Math.Max(deltaSeconds, 1.0f / 240.0f));
DetectKey();
ProjectScaleLanes(output);
}
private void FillSignal(float timeSeconds)
{
var rootMidi = 45 + ((detectedRoot + 3) % 12);
var baseFrequency = MidiToFrequency(rootMidi) * (1.0f + 0.035f * noise.Fractal(timeSeconds * 0.041f, 2.1f));
var formant = 0.5f + 0.5f * noise.Fractal(timeSeconds * 0.071f + 17.0f, 3.4f);
var pulse = MathF.Pow(0.5f + 0.5f * MathF.Sin(timeSeconds * 1.7f + noise.Fractal(timeSeconds * 0.19f, 4.0f) * 2.0f), 3.0f);
for (var index = 0; index < fftSize; index++)
{
var t = index / sampleRate;
var localTime = timeSeconds + t;
var fm = 1.0f + 0.028f * noise.Fractal(localTime * 0.8f, 8.0f);
phaseA += MathF.Tau * baseFrequency * fm / sampleRate;
phaseB += MathF.Tau * (baseFrequency * (1.5f + formant * 0.5f)) / sampleRate;
phaseC += MathF.Tau * (baseFrequency * (2.0f + formant)) / sampleRate;
var sample =
MathF.Sin(phaseA) * 0.50f +
MathF.Sin(phaseB + MathF.Sin(phaseA) * 0.45f) * 0.30f +
MathF.Sin(phaseC + phaseB * 0.12f) * (0.12f + pulse * 0.24f) +
noise.Fractal(localTime * 4.0f, 11.0f) * 0.03f;
spectrum[index] = new Complex(sample * window[index], 0.0f);
}
}
private void WhitenBins(float safeDelta)
{
Array.Clear(chroma);
for (var bin = 1; bin < binCount; bin++)
{
var frequency = bin * sampleRate / fftSize;
var magnitude = (float)spectrum[bin].Magnitude;
var attack = 1.0f - MathF.Exp(-safeDelta * 22.0f);
var release = 1.0f - MathF.Exp(-safeDelta * 2.8f);
var coefficient = magnitude > envelope[bin] ? attack : release;
envelope[bin] += (magnitude - envelope[bin]) * coefficient;
var whitened = magnitude / Math.Max(envelope[bin], 0.0001f);
var shaped = Math.Clamp(0.5f + MathF.Tanh((whitened - 0.62f) * 1.25f) * 0.5f, 0.0f, 1.0f);
whitenedBins[bin] = MathF.Pow(shaped, 1.12f);
if (frequency >= 48.0f && frequency <= 6000.0f)
{
var midi = 69.0f + 12.0f * MathF.Log2(frequency / 440.0f);
var chromaIndex = PositiveModulo((int)MathF.Round(midi), 12);
var octaveWeight = 1.0f - Math.Clamp(MathF.Abs(midi - 67.0f) / 60.0f, 0.0f, 0.75f);
chroma[chromaIndex] += whitenedBins[bin] * octaveWeight;
}
}
for (var note = 0; note < 12; note++)
{
smoothedChroma[note] = smoothedChroma[note] * 0.92f + chroma[note] * 0.08f;
}
}
private void DetectKey()
{
var bestRoot = detectedRoot;
var bestMinor = detectedMinor;
var bestScore = float.NegativeInfinity;
for (var root = 0; root < 12; root++)
{
var majorScore = KeyScore(root, MajorTemplate);
if (majorScore > bestScore)
{
bestScore = majorScore;
bestRoot = root;
bestMinor = false;
}
var minorScore = KeyScore(root, MinorTemplate);
if (minorScore > bestScore)
{
bestScore = minorScore;
bestRoot = root;
bestMinor = true;
}
}
var currentScore = KeyScore(detectedRoot, detectedMinor ? MinorTemplate : MajorTemplate);
if (bestScore > currentScore * 1.035f + 0.02f)
{
detectedRoot = bestRoot;
detectedMinor = bestMinor;
}
}
private float KeyScore(int root, float[] template)
{
var score = 0.0f;
for (var offset = 0; offset < 12; offset++)
{
score += smoothedChroma[(root + offset) % 12] * template[offset];
}
return score;
}
private void ProjectScaleLanes(Span<float> output)
{
var scale = detectedMinor ? MinorScale : MajorScale;
var baseMidi = 36 + detectedRoot;
var energy = 0.0f;
var flux = 0.0f;
var centroid = 0.0f;
for (var lane = 0; lane < laneCount; lane++)
{
var octave = lane / scale.Length;
var degree = lane % scale.Length;
var midi = baseMidi + octave * 12 + scale[degree];
var frequency = MidiToFrequency(midi);
var bin = Math.Clamp((int)MathF.Round(frequency * fftSize / sampleRate), 1, binCount - 2);
var value =
whitenedBins[bin] * 0.56f +
whitenedBins[bin - 1] * 0.22f +
whitenedBins[bin + 1] * 0.22f;
var scaleDegreeAccent = degree is 0 or 2 or 4 ? 1.0f : 0.82f;
var shaped = Math.Clamp(MathF.Pow(value * scaleDegreeAccent, 1.06f), 0.0f, 1.0f);
output[lane] = shaped;
energy += shaped;
flux += Math.Max(0.0f, shaped - previousLanes[lane]);
centroid += shaped * (lane / Math.Max(1.0f, laneCount - 1.0f));
previousLanes[lane] = shaped;
}
LastEnergy = energy / laneCount;
LastFlux = flux / laneCount;
LastCentroid = centroid / Math.Max(energy, 0.0001f);
}
private static float MidiToFrequency(int midi) =>
440.0f * MathF.Pow(2.0f, (midi - 69.0f) / 12.0f);
private static int PositiveModulo(int value, int modulo)
{
var result = value % modulo;
return result < 0 ? result + modulo : result;
}
private static void Fft(Complex[] values)
{
var n = values.Length;
var j = 0;
for (var i = 1; i < n; i++)
{
var bit = n >> 1;
for (; (j & bit) != 0; bit >>= 1)
{
j ^= bit;
}
j ^= bit;
if (i < j)
{
(values[i], values[j]) = (values[j], values[i]);
}
}
for (var length = 2; length <= n; length <<= 1)
{
var angle = -MathF.Tau / length;
var wLength = new Complex(MathF.Cos(angle), MathF.Sin(angle));
for (var i = 0; i < n; i += length)
{
var w = Complex.One;
for (var k = 0; k < length / 2; k++)
{
var u = values[i + k];
var v = values[i + k + length / 2] * w;
values[i + k] = u + v;
values[i + k + length / 2] = u - v;
w *= wLength;
}
}
}
}
}

View file

@ -0,0 +1,54 @@
namespace Perlines;
internal sealed class SmoothNoise
{
private readonly float[] values = new float[512];
private readonly int[] permutation = new int[512];
public SmoothNoise(int seed)
{
var random = new Random(seed);
for (var index = 0; index < 256; index++)
{
values[index] = (float)(random.NextDouble() * 2.0 - 1.0);
permutation[index] = index;
}
for (var index = 255; index > 0; index--)
{
var swap = random.Next(index + 1);
(permutation[index], permutation[swap]) = (permutation[swap], permutation[index]);
}
for (var index = 0; index < 256; index++)
{
values[index + 256] = values[index];
permutation[index + 256] = permutation[index];
}
}
public float Fractal(float x, float lacunarity)
{
var amplitude = 0.5f;
var frequency = 1.0f;
var value = 0.0f;
for (var octave = 0; octave < 5; octave++)
{
value += Noise(x * frequency) * amplitude;
frequency *= lacunarity;
amplitude *= 0.5f;
}
return value;
}
private float Noise(float x)
{
var floor = (int)MathF.Floor(x);
var t = x - floor;
var a = values[permutation[floor & 255]];
var b = values[permutation[(floor + 1) & 255]];
var s = t * t * (3.0f - 2.0f * t);
return a + (b - a) * s;
}
}