@@ -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
0 commit comments