Skip to content

Commit 11ae68e

Browse files
raymondyfeiRaymond Fei
andauthored
fix(Gsplat): Sort Worker Message Starvation in GaussianSplattingMesh (BabylonJS#18554)
When a scene contains a Gsplat alongside geometry with other expensive rendering passes (e.g., IBL shadows), the splat's depth sorting can feel sluggish, where splats appear in the wrong order for up to 2 seconds after the camera moves or a transformation is applied, even though the sort itself finishes almost immediately. Sometimes the sorting even stops working for more than 10 secs. ### Reason The sort worker posts its result back to the main thread as a regular-priority browser task. The browser's scheduler deprioritizes regular tasks whenever a higher-priority frame request is pending. When rendering passes are heavy enough to consume most of the frame budget, the render loop continuously reschedules the next frame, starving the worker's message handler. Therefore, the sort result may sit in the task queue undelivered for an arbitrarily long time. ### Fix We now install a `customAnimationFrameRequester` in `GaussianSplattingMeshBase` that inserts a `setTimeout(0)` before each frame request. This yields control back to the browser's event loop, giving the scheduler an opportunity to deliver pending tasks, including the sort result, before the next frame begins. The wrapper is ref-counted, so it only exists while at least one `GaussianSplattingMesh` is alive in the scene; there is no overhead for scenes that don't use Gaussian splatting. Note: If something else has already claimed `customAnimationFrameRequester` (such as an immersive WebXR session), we skip installation. For example, WebXR is not affected by this issue, where its frame provider already yields to the task queue between frames by design (refer to [`xr_frame_provider.cc`](https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/xr/xr_frame_provider.cc), [crbug.com/701444](https://crbug.com/701444).). For other modules claiming the `customAnimationFrameRequester` in the future, they should be responsible for yielding to the task queue between frames. --------- Co-authored-by: Raymond Fei <yfei@adobe.com>
1 parent a8ede5f commit 11ae68e

1 file changed

Lines changed: 73 additions & 0 deletions

File tree

packages/dev/core/src/Meshes/GaussianSplatting/gaussianSplattingMeshBase.pure.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { EngineStore } from "core/Engines/engineStore";
2424
import { type Camera } from "core/Cameras/camera.pure";
2525
import { ImportMeshAsync } from "core/Loading/sceneLoader";
2626
import { type INative } from "core/Engines/Native/nativeInterfaces";
27+
import { type AbstractEngine } from "core/Engines/abstractEngine.pure";
28+
import { type ICustomAnimationFrameRequester } from "core/Misc/customAnimationFrameRequester";
2729

2830
// eslint-disable-next-line @typescript-eslint/naming-convention
2931
declare const _native: INative;
@@ -381,6 +383,75 @@ export interface PLYHeader {
381383
shBuffer: ArrayBuffer | null;
382384
}
383385

386+
// Inter-frame task-queue yield
387+
// Depth-sort results arrive via worker postMessage, which is a regular-priority
388+
// task. At high refresh rates the rAF loop can leave almost no time for the
389+
// regular task queue, starving sort results for hundreds of milliseconds.
390+
// Installing a customAnimationFrameRequester that inserts a setTimeout(0) before
391+
// each rAF forces the event loop to drain regular tasks between frames.
392+
// The wrapper is ref-counted so it is installed when the first GSplat mesh is
393+
// created and removed when the last one is disposed.
394+
395+
interface IGsInterFrameYieldRequester extends ICustomAnimationFrameRequester {
396+
_gsInterFrameYield: true;
397+
_refCount: number;
398+
}
399+
400+
function _AcquireGsInterFrameYield(engine: AbstractEngine): void {
401+
// Browser-only optimization: wraps the global requestAnimationFrame, which
402+
// doesn't exist on Babylon Native. Skip it there so the engine uses its
403+
// default frame scheduling.
404+
if (IsNative) {
405+
return;
406+
}
407+
const existing = engine.customAnimationFrameRequester as IGsInterFrameYieldRequester | null;
408+
if (existing?._gsInterFrameYield) {
409+
existing._refCount++;
410+
return;
411+
}
412+
if (existing) {
413+
// Slot is owned by another requester. Don't interfere.
414+
return;
415+
}
416+
let _timeoutId = 0;
417+
let _innerRafId = 0;
418+
const wrapper: IGsInterFrameYieldRequester = {
419+
_gsInterFrameYield: true,
420+
_refCount: 1,
421+
requestAnimationFrame: (callback: Function): number => {
422+
// Insert a setTimeout(0) to yield to the regular task queue (worker
423+
// postMessage results, input events) before scheduling the next animation
424+
// frame. Without this, a continuous rAF loop at high refresh rates leaves
425+
// almost no time for regular tasks, starving worker messages for hundreds of ms.
426+
_timeoutId = setTimeout(() => {
427+
_innerRafId = requestAnimationFrame(callback as FrameRequestCallback);
428+
}, 0) as unknown as number;
429+
return _timeoutId;
430+
},
431+
cancelAnimationFrame: (_id: number) => {
432+
clearTimeout(_timeoutId);
433+
if (_innerRafId > 0) {
434+
cancelAnimationFrame(_innerRafId);
435+
}
436+
_timeoutId = 0;
437+
_innerRafId = 0;
438+
},
439+
};
440+
engine.customAnimationFrameRequester = wrapper;
441+
}
442+
443+
// Counterpart to _AcquireGsInterFrameYield. Call once per mesh on dispose.
444+
function _ReleaseGsInterFrameYield(engine: AbstractEngine): void {
445+
const existing = engine.customAnimationFrameRequester as IGsInterFrameYieldRequester | null;
446+
if (!existing?._gsInterFrameYield) {
447+
return;
448+
}
449+
existing._refCount--;
450+
if (existing._refCount === 0) {
451+
engine.customAnimationFrameRequester = null;
452+
}
453+
}
454+
384455
/**
385456
* Base class for Gaussian Splatting meshes. Contains all single-cloud rendering logic.
386457
* @internal Use GaussianSplattingMesh instead; this class is an internal implementation detail.
@@ -843,6 +914,7 @@ export class GaussianSplattingMeshBase extends Mesh {
843914
this.setEnabled(false);
844915
// webGL2 and webGPU support for RG texture with float16 is fine. not webGL1
845916
this._useRGBACovariants = !this.getEngine().isWebGPU && this.getEngine().version === 1.0;
917+
_AcquireGsInterFrameYield(this.getEngine());
846918

847919
this._keepInRam = keepInRam;
848920
if (url) {
@@ -2017,6 +2089,7 @@ export class GaussianSplattingMeshBase extends Mesh {
20172089
// They can still be used as runtime source buffers by a compound mesh that retained
20182090
// this mesh's data before disposal.
20192091

2092+
_ReleaseGsInterFrameYield(this.getEngine());
20202093
this._worker?.terminate();
20212094
this._worker = null;
20222095

0 commit comments

Comments
 (0)