Skip to content

Commit 22b8199

Browse files
committed
More bashing on rendering performance
1 parent e2dfc47 commit 22b8199

3 files changed

Lines changed: 110 additions & 30 deletions

File tree

RadegastVeles/Rendering/GlViewportControl.cs

Lines changed: 94 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,8 @@ private static void ReleasePatchGate(SemaphoreSlim gate, int count = 1)
392392
// Set when a texture patch upgraded a legacy face to the alpha pass; triggers a single
393393
// RebuildSceneFlatLists() at the end of ApplyTexturePatches so the face moves draw lists.
394394
private bool _alphaReclassNeeded;
395+
// Frame counter for the periodic [SceneLoad] pipeline telemetry log.
396+
private int _loadTelemetryFrame;
395397
// Submission patches that arrived before UploadSubmission ran; retried for up to ~30 s.
396398
private readonly List<(SceneTexturePatch patch, int retriesLeft)> _deferredSubmissionPatches = new();
397399

@@ -453,15 +455,15 @@ private static void ReleasePatchGate(SemaphoreSlim gate, int count = 1)
453455

454456
/// <summary>
455457
/// Enables instanced batching (<see cref="GlInstanceDrawer"/>) for opaque faces that
456-
/// share a deduplicated mesh. Defaults to <c>false</c>: scene-object mesh dedup only
457-
/// started producing shared meshes once VerticesLength survived face translation
458-
/// (before that, dedup hashes covered pool garbage and never matched), and the very
459-
/// first real workload — linksets of identical prims — renders exploded geometry, so
460-
/// the instanced path has a latent defect that was never exercised. The per-face
461-
/// uniform path is proven correct; re-enable this only once the instanced path is
462-
/// validated against a dense identical-prim scene.
458+
/// share a deduplicated mesh. The 2026-07-11 "exploded geometry" regression that
459+
/// forced this off was root-caused to WriteInstanceData double-transposing the
460+
/// per-instance matrices (the raw System.Numerics bytes already read as the correct
461+
/// column-vector matrix on the GL side, matching the uniform path); with that fixed,
462+
/// the instanced path is back on. History: the defect was latent because mesh dedup
463+
/// never produced shared meshes until VerticesLength survived face translation, so
464+
/// the path first ran against real workloads only after that fix.
463465
/// </summary>
464-
public bool InstancingEnabled { get; set; } = false;
466+
public bool InstancingEnabled { get; set; } = true;
465467

466468
/// <summary>Number of scene object submissions waiting to be uploaded to the GPU this frame. Zero-cost snapshot.</summary>
467469
public int PendingUploadCount => _pendingSceneObjects.Count;
@@ -936,10 +938,13 @@ private void GlRenderCore(GlInterface gl, int fb)
936938
UploadSubmission(pending);
937939

938940
// Process scene-object layer updates (additive on top of the base submission).
939-
// Cap uploads per frame so a large burst (e.g. scene entry / teleport) is spread
940-
// across many frames instead of stalling the GL thread. Each upload can involve
941-
// several glTexImage2D calls, so even 5 per frame can take ~10 ms on a mid-range GPU.
942-
const int MaxSceneUploadsPerFrame = 5;
941+
// Time-budget uploads per frame so a large burst (e.g. scene entry / teleport) is
942+
// spread across frames without capping throughput artificially: a fixed count cap
943+
// (formerly 5/frame) throttled scene loading to ~150 objects/sec at 30 fps even
944+
// when the uploads were cheap single prims, while a heavy mesh linkset could still
945+
// blow the frame at count 1. The budget always admits at least one upload per
946+
// frame so progress is guaranteed.
947+
const double MaxSceneUploadMillis = 6.0;
943948
if (_initError == null)
944949
{
945950
if (_pendingClearScene)
@@ -948,14 +953,17 @@ private void GlRenderCore(GlInterface gl, int fb)
948953
FreeSceneObjectResources();
949954
}
950955
bool sceneListDirty = false;
951-
int uploadsThisFrame = 0;
952956
if (!_pendingSceneObjects.IsEmpty)
953957
{
954-
// Snapshot at most MaxSceneUploadsPerFrame keys so the foreach is bounded.
958+
long uploadStart = System.Diagnostics.Stopwatch.GetTimestamp();
959+
double ticksPerMs = System.Diagnostics.Stopwatch.Frequency / 1000.0;
960+
int uploadsThisFrame = 0;
955961
// ConcurrentDictionary.Keys is a snapshot enumerable; TryRemove is safe mid-loop.
956962
foreach (var key in _pendingSceneObjects.Keys)
957963
{
958-
if (uploadsThisFrame >= MaxSceneUploadsPerFrame) break;
964+
if (uploadsThisFrame > 0 &&
965+
(System.Diagnostics.Stopwatch.GetTimestamp() - uploadStart) / ticksPerMs >= MaxSceneUploadMillis)
966+
break;
959967
if (!_pendingSceneObjects.TryRemove(key, out var sub)) continue;
960968
if (sub == null)
961969
RemoveSceneObjectGpuNoRebuild(key);
@@ -970,6 +978,23 @@ private void GlRenderCore(GlInterface gl, int fb)
970978
_core.RequestNextFrameRendering();
971979
if (sceneListDirty)
972980
RebuildSceneFlatLists();
981+
982+
// Periodic scene-load pipeline telemetry (~every 5 s at 30 fps) while work is
983+
// pending, so "loading feels slow" is diagnosable from Veles.log: it shows
984+
// which stage is deep — GPU upload queue vs texture patches vs deferrals —
985+
// and whether the decoded-mesh cache is earning its keep.
986+
if (++_loadTelemetryFrame >= 150)
987+
{
988+
_loadTelemetryFrame = 0;
989+
int up = _pendingSceneObjects.Count;
990+
int qp = _pendingTexturePatches.Count;
991+
int dp = _deferredPatches.Count;
992+
if (up > 0 || qp > 50 || dp > 50)
993+
LibreMetaverse.Logger.Debug(
994+
$"[SceneLoad] pendingUploads={up} queuedPatches={qp} deferredPatches={dp} " +
995+
$"sceneFaces={_sceneOpaque.Count + _sceneAlpha.Count} " +
996+
$"meshCache={PrimMeshBuilder.MeshCacheHits}h/{PrimMeshBuilder.MeshCacheMisses}m");
997+
}
973998
}
974999

9751000
// Apply per-face vertex updates queued by the animation thread.
@@ -2933,6 +2958,22 @@ public void PatchSceneObjectTexture(SceneTexturePatch patch, CancellationToken c
29332958
// waiting for the GL thread to drain an available slot.
29342959
// Ownership of patch.Bitmap transfers to the viewport only after Enqueue succeeds.
29352960
// Dispose it here if we exit early so the caller is never responsible for cleanup.
2961+
// NEVER block on the gate from the UI thread: ApplyTexturePatches — the only permit
2962+
// producer — runs on that same thread, so a blocking Wait here can never be
2963+
// satisfied and freezes the whole app. Reachable when a Progress<T> constructed
2964+
// with the Avalonia SynchronizationContext delivers a patch callback to the
2965+
// dispatcher. Hop to the pool and apply back-pressure there instead.
2966+
if (Avalonia.Threading.Dispatcher.UIThread.CheckAccess())
2967+
{
2968+
var deferred = patch;
2969+
_ = System.Threading.Tasks.Task.Run(() =>
2970+
{
2971+
try { PatchSceneObjectTexture(deferred, ct); }
2972+
catch (OperationCanceledException) { /* bitmap already disposed inside */ }
2973+
});
2974+
return;
2975+
}
2976+
29362977
// Capture the gate into a local: GlDeinit swaps the field for a fresh instance,
29372978
// and Wait/Release must operate on the same object.
29382979
var gate = _texturePatchGate;
@@ -3148,17 +3189,37 @@ private void ApplyTexturePatches()
31483189
_deferredPatches.AddRange(stillDeferred);
31493190
}
31503191

3151-
// Drain from the incoming queue using remaining budget.
3152-
while (budget > 0 && _pendingTexturePatches.TryDequeue(out var patch))
3192+
// Drain the incoming queue every frame, releasing one gate permit per dequeued
3193+
// patch, so permits keep flowing even when the apply budget is spent: producers
3194+
// block on the gate, and during a scene-load burst the deferred backlog can
3195+
// consume the entire budget for many consecutive frames — leaving patches in the
3196+
// queue starved the gate and stalled every texture-streaming thread on Wait.
3197+
// Bounded to TexturePatchQueueDepth (the gate's own capacity) per frame rather
3198+
// than looping until the queue is momentarily empty: releasing a permit here can
3199+
// immediately wake a waiting producer, which re-enqueues before this loop's next
3200+
// TryDequeue check, so an unbounded "drain completely" loop can chase a fast
3201+
// producer burst (e.g. mesh-cache hits during scene load) and hold the GL thread
3202+
// for the whole burst instead of one frame — the exact freeze this queue depth
3203+
// was meant to bound. Dequeuing is cheap; only TryApplyTexturePatch's GL upload
3204+
// costs real time, and that still respects the budget — over-budget patches are
3205+
// parked in _deferredPatches, where over-budget work lives anyway. Any remainder
3206+
// left in the queue is picked up next frame via the re-request below.
3207+
int drained = 0;
3208+
while (drained < TexturePatchQueueDepth && _pendingTexturePatches.TryDequeue(out var patch))
31533209
{
3154-
budget--;
3210+
drained++;
31553211
ReleasePatchGate(_texturePatchGate); // a slot is now free; wake any waiting producer
3156-
if (!TryApplyTexturePatch(patch))
3212+
if (budget > 0 && TryApplyTexturePatch(patch))
3213+
{
3214+
budget--;
3215+
}
3216+
else
31573217
{
3158-
// Scene object not yet uploaded — defer and retry each frame. The budget is
3159-
// generous (~30 s at 30 Hz): with the disk cache, textures often decode long
3160-
// before their object's mesh build finishes (login bursts, avatar wearable
3161-
// fetches), and a dropped patch leaves the face permanently untextured.
3218+
// Out of budget, or scene object not yet uploaded — defer and retry each
3219+
// frame. The retry allowance is generous (~30 s at 30 Hz): with the disk
3220+
// cache, textures often decode long before their object's mesh build
3221+
// finishes (login bursts, avatar wearable fetches), and a dropped patch
3222+
// leaves the face permanently untextured.
31623223
_deferredPatches.Add((patch, 900));
31633224
}
31643225
}
@@ -3846,21 +3907,25 @@ private void DrawFaces(
38463907
}
38473908

38483909
// Writes one instance's data into buf starting at instanceIdx * GlInstanceDrawer.InstanceFloats.
3849-
// Matrices are transposed from System.Numerics row-major to GL column-major order.
38503910
private static unsafe void WriteInstanceData(
38513911
float[] buf, int instanceIdx, PrimRenderFace face, ref Matrix4x4 view, ref Matrix4x4 proj)
38523912
{
38533913
int @base = instanceIdx * GlInstanceDrawer.InstanceFloats;
38543914
var mv = face.Transform * view;
38553915
var mvp = mv * proj;
38563916

3857-
// Column-major upload: System.Numerics is row-major, so transpose before copying raw bytes.
3858-
var mvpT = Matrix4x4.Transpose(mvp);
3859-
var mvT = Matrix4x4.Transpose(mv);
3917+
// Copy the System.Numerics matrices RAW — no transpose. SN stores row-major with
3918+
// row-vector convention; GL reads attribute mat4 columns from consecutive vec4s,
3919+
// so the raw bytes arrive as the transposed (column-vector) matrix, which is
3920+
// exactly what prim.vert's `aInstMvp * vec4(pos,1)` needs. This mirrors the
3921+
// uniform path (GlShader.Set uploads raw with transpose:false). Transposing here
3922+
// fed the shader the row-vector matrix — translation in the bottom row made w
3923+
// position-dependent and exploded instanced geometry into fans (2026-07-11
3924+
// instancing regression, root cause).
38603925
var mvpSpan = System.Runtime.InteropServices.MemoryMarshal.CreateReadOnlySpan(
3861-
ref System.Runtime.CompilerServices.Unsafe.As<Matrix4x4, float>(ref mvpT), 16);
3926+
ref System.Runtime.CompilerServices.Unsafe.As<Matrix4x4, float>(ref mvp), 16);
38623927
var mvSpan = System.Runtime.InteropServices.MemoryMarshal.CreateReadOnlySpan(
3863-
ref System.Runtime.CompilerServices.Unsafe.As<Matrix4x4, float>(ref mvT), 16);
3928+
ref System.Runtime.CompilerServices.Unsafe.As<Matrix4x4, float>(ref mv), 16);
38643929
mvpSpan.CopyTo(buf.AsSpan(@base, 16));
38653930
mvSpan.CopyTo( buf.AsSpan(@base + 16, 16));
38663931

RadegastVeles/Rendering/PrimMeshBuilder.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ public static long MeshCacheMaxVertices
9393
// the stored Lazy ever executes, so exactly one download runs per asset id.
9494
private static readonly ConcurrentDictionary<UUID, Lazy<Task<FacetedMesh?>>> MeshFetchInflight = new();
9595

96+
private static long _meshCacheHits, _meshCacheMisses;
97+
/// <summary>Decoded-mesh cache hits since process start (telemetry).</summary>
98+
public static long MeshCacheHits => Interlocked.Read(ref _meshCacheHits);
99+
/// <summary>Decoded-mesh cache misses since process start (telemetry).</summary>
100+
public static long MeshCacheMisses => Interlocked.Read(ref _meshCacheMisses);
101+
96102
private static bool TryGetCachedMesh(UUID assetId, out FacetedMesh mesh)
97103
{
98104
lock (MeshCacheLock)
@@ -102,9 +108,11 @@ private static bool TryGetCachedMesh(UUID assetId, out FacetedMesh mesh)
102108
MeshCacheLru.Remove(node);
103109
MeshCacheLru.AddFirst(node);
104110
mesh = node.Value.Mesh;
111+
Interlocked.Increment(ref _meshCacheHits);
105112
return true;
106113
}
107114
}
115+
Interlocked.Increment(ref _meshCacheMisses);
108116
mesh = null!;
109117
return false;
110118
}

RadegastVeles/Rendering/SceneBuildScheduler.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,14 @@ private void TryDrain()
190190
_queue.RemoveAt(idx);
191191
}
192192

193-
_ = RunAsync(factory);
193+
// Always run the factory on the thread pool — never inline on the Enqueue caller.
194+
// TryDrain is reachable from arbitrary threads (LMV network events, timer callbacks,
195+
// and UI-thread seeding via Dispatcher.Post), and an async factory executes
196+
// synchronously up to its first await. Inline execution ran tessellation on those
197+
// threads and, worse, let Progress<T> instances constructed inside a build capture
198+
// the Avalonia UI SynchronizationContext — which routed texture-patch callbacks to
199+
// the UI thread, where the patch gate's blocking Wait deadlocked the dispatcher.
200+
_ = Task.Run(() => RunAsync(factory));
194201
}
195202

196203
private async Task RunAsync(Func<CancellationToken, Task> factory)

0 commit comments

Comments
 (0)