UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

1,563 lines 59.5 kB
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { math } from "../../core/math/math.js";
import { Mat4 } from "../../core/math/mat4.js";
import { Vec3 } from "../../core/math/vec3.js";
import { GraphNode } from "../graph-node.js";
import { GSplatInfo } from "./gsplat-info.js";
import { GSplatUnifiedSorter } from "./gsplat-unified-sorter.js";
import { GSplatWorkBuffer } from "./gsplat-work-buffer.js";
import { GSplatQuadRenderer } from "./gsplat-quad-renderer.js";
import { GSplatHybridRenderer } from "./gsplat-hybrid-renderer.js";
import { GSplatComputeLocalRenderer } from "./gsplat-compute-local-renderer.js";
import { GSplatProjector } from "./gsplat-projector.js";
import { GSplatOctreeInstance } from "./gsplat-octree-instance.js";
import { GSplatOctreeResource } from "./gsplat-octree.resource.js";
import { GSplatWorldState } from "./gsplat-world-state.js";
import { GSplatPlacementStateTracker } from "./gsplat-placement-state-tracker.js";
import { GSplatIntervalCompaction } from "./gsplat-interval-compaction.js";
import { ComputeRadixSort } from "../graphics/radix-sort/compute-radix-sort.js";
import { Debug } from "../../core/debug.js";
import { BoundingBox } from "../../core/shape/bounding-box.js";
import {
  GSPLAT_RENDERER_RASTER_CPU_SORT,
  GSPLAT_RENDERER_RASTER_GPU_SORT,
  GSPLAT_RENDERER_COMPUTE,
  GSPLAT_DEBUG_LOD,
  GSPLAT_DEBUG_SH_UPDATE,
  GSPLAT_DEBUG_AABBS
} from "../constants.js";
import { Color } from "../../core/math/color.js";
import { GSplatBudgetBalancer } from "./gsplat-budget-balancer.js";
import { BlockAllocator } from "../../core/block-allocator.js";
import { ALPHA_VISIBILITY_THRESHOLD } from "./constants.js";
const cameraPosition = new Vec3();
const cameraDirection = new Vec3();
const translation = new Vec3();
const _tempVec3 = new Vec3();
const invModelMat = new Mat4();
const NO_SORT_INDIRECT_INFO = new Uint32Array([0, 0, 0, 0]);
const tempNonOctreePlacements = /* @__PURE__ */ new Set();
const tempOctreePlacements = /* @__PURE__ */ new Set();
const _updatedSplats = [];
const _splatsWithSH = [];
const _changedColorAllocIds = /* @__PURE__ */ new Set();
const _cameraDeltas = { translationDelta: 0 };
const _localCamPos = new Vec3();
const _closestPt = new Vec3();
const tempOctreesTicked = /* @__PURE__ */ new Set();
const _queuedSplats = /* @__PURE__ */ new Set();
const _lodColorsRaw = [
  [1, 0, 0],
  // red
  [0, 1, 0],
  // green
  [0, 0, 1],
  // blue
  [1, 1, 0],
  // yellow
  [1, 0, 1],
  // magenta
  [0, 1, 1],
  // cyan
  [1, 0.5, 0],
  // orange
  [0.5, 0, 1]
  // purple
];
const _lodColors = [
  new Color(1, 0, 0),
  new Color(0, 1, 0),
  new Color(0, 0, 1),
  new Color(1, 1, 0),
  new Color(1, 0, 1),
  new Color(0, 1, 1),
  new Color(1, 0.5, 0),
  new Color(0.5, 0, 1)
];
let _randomColorRaw = null;
class GSplatManager {
  /**
   * @param {GraphicsDevice} device - The graphics device.
   * @param {GSplatDirector} director - The director.
   * @param {Layer} layer - The layer.
   * @param {GraphNode} cameraNode - The camera node.
   */
  constructor(device, director, layer, cameraNode) {
    /** @type {GraphicsDevice} */
    __publicField(this, "device");
    /** @type {GraphNode} */
    __publicField(this, "node", new GraphNode("GSplatManager"));
    /** @type {GSplatWorkBuffer} */
    __publicField(this, "workBuffer");
    /** @type {GSplatRenderer} */
    __publicField(this, "renderer");
    /**
     * A map of versioned world states, keyed by version.
     *
     * @type {Map<number, GSplatWorldState>}
     */
    __publicField(this, "worldStates", /* @__PURE__ */ new Map());
    /**
     * The version of the last world state.
     */
    __publicField(this, "lastWorldStateVersion", 0);
    /**
     * The currently active renderer mode. Starts as undefined so the first
     * prepareRendererMode() call always creates the appropriate resources.
     *
     * @type {number|undefined}
     */
    __publicField(this, "activeRenderer");
    /**
     * When true, {@link updateWorldState} must rebuild the splat set.
     *
     * @type {boolean}
     * @private
     */
    __publicField(this, "_worldStateDirty", false);
    /**
     * CPU-based sorter (when not using GPU sorting).
     *
     * @type {GSplatUnifiedSorter|null}
     */
    __publicField(this, "cpuSorter", null);
    /**
     * GPU-based radix sorter (raster GPU sort path).
     *
     * @type {ComputeRadixSort|null}
     */
    __publicField(this, "gpuSorter", null);
    /**
     * Interval-based GPU compaction (raster GPU sort / compute paths).
     *
     * @type {GSplatIntervalCompaction|null}
     */
    __publicField(this, "intervalCompaction", null);
    /**
     * Compute projector + sort keys for {@link GSPLAT_RENDERER_RASTER_GPU_SORT}.
     *
     * @type {GSplatProjector|null}
     */
    __publicField(this, "projector", null);
    /**
     * Indirect draw slot index for the current frame (-1 when not using indirect draw).
     */
    __publicField(this, "indirectDrawSlot", -1);
    /**
     * Indirect dispatch slot for raster GPU sort (projector + radix) and compute paths.
     * The compute local renderer builds its own indirect args in private buffers
     * and does not use these slots.
     */
    __publicField(this, "indirectDispatchSlot", -1);
    /**
     * Total intervals from the last interval compaction dispatch. Needed for
     * writeIndirectArgs to index into the prefix sum buffer for visible count.
     */
    __publicField(this, "lastCompactedNumIntervals", 0);
    /** @type {number} */
    __publicField(this, "sortedVersion", 0);
    /**
     * When true, suppresses ready=true in frame:ready until a fullUpdate cycle runs.
     * Only set when octreeInstances exist and params change (dirty).
     *
     * @private
     */
    __publicField(this, "_awaitingLodUpdate", false);
    /**
     * Cached work buffer format version for detecting extra stream changes.
     *
     * @private
     */
    __publicField(this, "_workBufferFormatVersion", -1);
    /**
     * Flag set when the work buffer needs a full rebuild due to format changes.
     *
     * @private
     */
    __publicField(this, "_workBufferRebuildRequired", false);
    /**
     * Number of blocks uploaded to the work buffer this frame.
     */
    __publicField(this, "bufferCopyUploaded", 0);
    /**
     * Total number of blocks in the work buffer this frame.
     */
    __publicField(this, "bufferCopyTotal", 0);
    /**
     * Tracks placement state changes (format version, modifier hash, numSplats, centersVersion).
     *
     * @type {GSplatPlacementStateTracker}
     * @private
     */
    __publicField(this, "_stateTracker", new GSplatPlacementStateTracker());
    /**
     * Tracks last seen centersVersion per resource ID for detecting centers updates.
     *
     * @type {Map<number, number>}
     * @private
     */
    __publicField(this, "_centersVersions", /* @__PURE__ */ new Map());
    /** @type {number} */
    __publicField(this, "framesTillFullUpdate", 0);
    /** @type {Vec3} */
    __publicField(this, "lastLodCameraPos", new Vec3(Infinity, Infinity, Infinity));
    /** @type {Vec3} */
    __publicField(this, "lastLodCameraFwd", new Vec3(Infinity, Infinity, Infinity));
    /** @type {number} */
    __publicField(this, "lastLodCameraFov", -1);
    /** @type {Vec3} */
    __publicField(this, "lastSortCameraPos", new Vec3(Infinity, Infinity, Infinity));
    /** @type {Vec3} */
    __publicField(this, "lastSortCameraFwd", new Vec3(Infinity, Infinity, Infinity));
    /** @type {Vec3} */
    __publicField(this, "lastCullingCameraFwd", new Vec3(Infinity, Infinity, Infinity));
    /** @type {Mat4} */
    __publicField(this, "lastCullingProjMat", new Mat4());
    /** @type {boolean} */
    __publicField(this, "sortNeeded", true);
    /**
     * Budget balancer for global splat budget enforcement.
     *
     * @type {GSplatBudgetBalancer}
     * @private
     */
    __publicField(this, "_budgetBalancer", new GSplatBudgetBalancer());
    /**
     * Dynamic scale factor applied to LOD parameters during budget enforcement. Shifts all
     * LOD boundaries uniformly to bring the initial estimate closer to the budget target,
     * reducing balancer work. Applied directly to lodBaseDistance and gently to lodMultiplier.
     * Values > 1 push boundaries outward (more splats), values < 1 pull them inward
     * (fewer splats).
     *
     * @private
     */
    __publicField(this, "_budgetScale", 1);
    /**
     * Persistent block allocator for work buffer pixel allocations. Grows on demand.
     *
     * @type {BlockAllocator}
     * @private
     */
    __publicField(this, "_allocator");
    /**
     * Maps allocId (from GSplatPlacement) to the corresponding MemBlock in the allocator.
     * Shared with GSplatWorldState constructors which mutate it during diff.
     *
     * @type {Map<number, MemBlock>}
     * @private
     */
    __publicField(this, "_allocationMap", /* @__PURE__ */ new Map());
    /** @type {Vec3} */
    __publicField(this, "lastColorUpdateCameraPos", new Vec3(Infinity, Infinity, Infinity));
    /** @type {GraphNode} */
    __publicField(this, "cameraNode");
    /** @type {Scene} */
    __publicField(this, "scene");
    /**
     * Layer placements, only non-octree placements are included.
     *
     * @type {GSplatPlacement[]}
     */
    __publicField(this, "layerPlacements", []);
    /** @type {boolean} */
    __publicField(this, "layerPlacementsDirty", false);
    /**
     * True when placements have been added or removed since the last world state was created.
     * Triggers a full work buffer rebuild so boundsBaseIndex stays consistent.
     *
     * @private
     */
    __publicField(this, "_placementSetChanged", false);
    /** @type {Map<GSplatPlacement, GSplatOctreeInstance>} */
    __publicField(this, "octreeInstances", /* @__PURE__ */ new Map());
    /**
     * Octree instances scheduled for destruction. We collect their releases and destroy them
     * when creating the next world state
     *
     * @type {GSplatOctreeInstance[]}
     */
    __publicField(this, "octreeInstancesToDestroy", []);
    /**
     * Flag set when new octree instances are added, to trigger immediate LOD evaluation.
     */
    __publicField(this, "hasNewOctreeInstances", false);
    /**
     * Bitmask flags controlling which render passes this manager participates in.
     *
     * @type {number|undefined}
     */
    __publicField(this, "renderMode");
    this.device = device;
    this.scene = director.scene;
    this.director = director;
    this.cameraNode = cameraNode;
    const allocatorGrowMultiplier = 1.15;
    const budget = this.scene.gsplat.splatBudget;
    this._allocator = new BlockAllocator(budget > 0 ? Math.ceil(budget * allocatorGrowMultiplier) : 0, allocatorGrowMultiplier);
    this.workBuffer = new GSplatWorkBuffer(device, this.scene.gsplat.format);
    this.layer = layer;
    this._createRenderer(this.scene.gsplat.currentRenderer);
    this._workBufferFormatVersion = this.workBuffer.format.extraStreamsVersion;
  }
  destroy() {
    this._destroyed = true;
    for (const [, worldState] of this.worldStates) {
      for (const splat of worldState.splats) {
        splat.resource.decRefCount();
      }
      worldState.destroy();
    }
    this.worldStates.clear();
    for (const [, instance] of this.octreeInstances) {
      instance.destroy();
    }
    this.octreeInstances.clear();
    for (const instance of this.octreeInstancesToDestroy) {
      instance.destroy();
    }
    this.octreeInstancesToDestroy.length = 0;
    this.destroyGpuSorting();
    this.destroyCpuSorting();
    this.workBuffer.destroy();
    this.renderer.destroy();
  }
  /**
   * Destroys GPU sorting resources (radix sorter, projector, compaction).
   *
   * @private
   */
  destroyGpuSorting() {
    this.gpuSorter?.destroy();
    this.gpuSorter = null;
    this.projector?.destroy();
    this.projector = null;
    const useCpuSort = false;
    this.renderer.setCpuSortedRendering();
    this.destroyIntervalCompaction(useCpuSort);
  }
  /**
   * Destroys interval compaction resources.
   *
   * @param {boolean} [useCpuSort] - Whether to switch the renderer to CPU-sorted mode.
   * @private
   */
  destroyIntervalCompaction(useCpuSort = true) {
    if (this.intervalCompaction) {
      if (useCpuSort) {
        this.renderer.setCpuSortedRendering();
      }
      this.intervalCompaction.destroy();
      this.intervalCompaction = null;
    }
  }
  /**
   * Destroys CPU sorting resources (worker-based sorter).
   *
   * @private
   */
  destroyCpuSorting() {
    this.cpuSorter?.destroy();
    this.cpuSorter = null;
  }
  /**
   * GPU radix sort + projector for hybrid raster (no separate sort-key compute pass).
   *
   * @private
   */
  initHybridSorting() {
    if (!this.gpuSorter) {
      this.gpuSorter = new ComputeRadixSort(this.device, { indirect: true });
    }
    if (!this.projector) {
      this.projector = new GSplatProjector(this.device);
    }
  }
  /**
   * Creates the CPU sorter and prepares it for the current world state. Disables any
   * GPU-side indirect draw and hides the mesh until the first sort result arrives.
   *
   * @private
   */
  initCpuSorting() {
    if (!this.cpuSorter) {
      this.cpuSorter = this.createSorter();
    }
    const currentState = this.worldStates.get(this.sortedVersion);
    if (currentState) {
      currentState.sortParametersSet = false;
      currentState.sortedBefore = false;
      this.cpuSorter.updateCentersForSplats(currentState.splats);
    }
    this.renderer.setCpuSortedRendering();
  }
  get material() {
    return this.renderer.material;
  }
  /**
   * Dispatches a renderer-specific pick pipeline and returns the configured pick mesh instance.
   * The local compute renderer renders to pick textures; the hybrid renderer refreshes its
   * shared projector/sort buffers for the picker camera and returns a transient pick mesh.
   *
   * @param {object} camera - The camera.
   * @param {number} width - Pick target width.
   * @param {number} height - Pick target height.
   * @returns {import('../mesh-instance.js').MeshInstance|null} The pick mesh instance, or null.
   */
  prepareForPicking(camera, width, height) {
    if (this.activeRenderer === GSPLAT_RENDERER_RASTER_GPU_SORT) {
      const sortedState = this.worldStates.get(this.sortedVersion);
      if (!sortedState?.sortedBefore || !camera.node) return null;
      const sortedIndices = this.sortGpuHybridForCamera(
        sortedState,
        camera.node,
        width,
        height,
        Math.max(ALPHA_VISIBILITY_THRESHOLD, this.scene.gsplat.alphaClip),
        !!this.workBuffer.format.getStream("pcId")
      );
      if (!sortedIndices) return null;
      const proj = (
        /** @type {GSplatProjector} */
        this.projector
      );
      const ic = (
        /** @type {GSplatIntervalCompaction} */
        this.intervalCompaction
      );
      return (
        /** @type {GSplatHybridRenderer} */
        /** @type {unknown} */
        this.renderer.prepareForPicking(
          this.indirectDrawSlot,
          sortedIndices,
          /** @type {StorageBuffer} */
          proj.projCache,
          /** @type {StorageBuffer} */
          ic.numSplatsBuffer,
          this.scene.gsplat.alphaClip,
          this.scene.gsplat.alphaClipForward,
          camera.node
        )
      );
    }
    if (this.activeRenderer !== GSPLAT_RENDERER_COMPUTE) return null;
    const localRenderer = (
      /** @type {any} */
      this.renderer
    );
    return localRenderer.dispatchPick(camera, width, height);
  }
  /**
   * Creates the CPU sorter (Web Worker based).
   *
   * @returns {GSplatUnifiedSorter} The created sorter.
   */
  createSorter() {
    const sorter = new GSplatUnifiedSorter(this.scene);
    sorter.on("sorted", (count, version, orderData) => {
      this.onSorted(count, version, orderData);
    });
    return sorter;
  }
  /**
   * Sets the render mode for this manager and its renderer.
   *
   * @param {number} renderMode - Bitmask flags controlling render passes (GSPLAT_FORWARD, GSPLAT_SHADOW, or both).
   * @ignore
   */
  setRenderMode(renderMode) {
    this.renderMode = renderMode;
    this.renderer.setRenderMode(renderMode);
  }
  /**
   * True when frustum culling can run (bounds data available).
   *
   * @type {boolean}
   * @private
   */
  get canCull() {
    return this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT && this.workBuffer.frustumCuller.totalBoundsEntries > 0;
  }
  /**
   * Creates the renderer and sort resources for the given mode. Used at init time.
   *
   * @param {number} mode - The GSPLAT_RENDERER_* constant.
   * @private
   */
  _createRenderer(mode) {
    if (mode === GSPLAT_RENDERER_COMPUTE) {
      this.renderer = new GSplatComputeLocalRenderer(this.device, this.node, this.cameraNode, this.layer, this.workBuffer);
    } else if (mode === GSPLAT_RENDERER_RASTER_GPU_SORT) {
      this.renderer = new GSplatHybridRenderer(this.device, this.node, this.cameraNode, this.layer, this.workBuffer);
      this.initHybridSorting();
    } else {
      this.renderer = new GSplatQuadRenderer(this.device, this.node, this.cameraNode, this.layer, this.workBuffer);
      this.initCpuSorting();
    }
    this.activeRenderer = mode;
  }
  /**
   * Checks whether the resolved renderer mode has changed and transitions to the new mode.
   * Handles renderer mode transitions (CPU raster, hybrid, compute)
   * (quad <-> compute).
   *
   * @private
   */
  prepareRendererMode() {
    const requested = this.scene.gsplat.currentRenderer;
    if (requested === this.activeRenderer) return;
    this._worldStateDirty = true;
    this.destroyGpuSorting();
    this.destroyCpuSorting();
    this.renderer.destroy();
    this._createRenderer(requested);
    this.renderer.setRenderMode(this.renderMode);
    this._workBufferRebuildRequired = true;
    this.sortNeeded = true;
  }
  /**
   * Supply the manager with the placements to use. This is used to update the manager when the
   * layer's placements have changed, called infrequently.
   *
   * @param {GSplatPlacement[]} placements - The placements to reconcile with.
   */
  reconcile(placements) {
    tempNonOctreePlacements.clear();
    for (const p of placements) {
      if (p.resource instanceof GSplatOctreeResource) {
        if (!this.octreeInstances.has(p)) {
          this.octreeInstances.set(p, new GSplatOctreeInstance(this.device, p.resource.octree, p));
          this.hasNewOctreeInstances = true;
        }
        tempOctreePlacements.add(p);
      } else {
        tempNonOctreePlacements.add(p);
      }
    }
    for (const [placement, inst] of this.octreeInstances) {
      if (!tempOctreePlacements.has(placement)) {
        this.octreeInstances.delete(placement);
        this.layerPlacementsDirty = true;
        this._placementSetChanged = true;
        this.octreeInstancesToDestroy.push(inst);
      }
    }
    this.layerPlacementsDirty || (this.layerPlacementsDirty = this.layerPlacements.length !== tempNonOctreePlacements.size);
    if (!this.layerPlacementsDirty) {
      for (let i = 0; i < this.layerPlacements.length; i++) {
        const existing = this.layerPlacements[i];
        if (!tempNonOctreePlacements.has(existing)) {
          this.layerPlacementsDirty = true;
          break;
        }
      }
    }
    this._placementSetChanged || (this._placementSetChanged = this.layerPlacementsDirty);
    this.layerPlacements.length = 0;
    for (const p of tempNonOctreePlacements) {
      this.layerPlacements.push(p);
    }
    tempNonOctreePlacements.clear();
    tempOctreePlacements.clear();
  }
  updateWorldState() {
    let stateChanged = this._stateTracker.hasChanges(this.layerPlacements);
    for (const [, inst] of this.octreeInstances) {
      if (this._stateTracker.hasChanges(inst.activePlacements)) {
        stateChanged = true;
      }
    }
    const placementsChanged = this.layerPlacementsDirty;
    const worldChanged = placementsChanged || stateChanged || this.worldStates.size === 0 || this._worldStateDirty;
    if (worldChanged) {
      this.lastWorldStateVersion++;
      const splats = [];
      for (const p of this.layerPlacements) {
        if (this.cpuSorter && !p.resource.hasCenters) {
          Debug.warnOnce(`Skipping gsplat resource id ${p.resource.id} on the CPU sorting path \u2014 no centers buffer. See Scene#gsplatCentersEnabled.`);
          continue;
        }
        p.ensureInstanceStreams(this.device);
        const splatInfo = new GSplatInfo(this.device, p.resource, p, p.consumeRenderDirty.bind(p));
        splats.push(splatInfo);
      }
      for (const [, inst] of this.octreeInstances) {
        inst.activePlacements.forEach((p) => {
          if (p.resource) {
            const leafResource = (
              /** @type {GSplatResourceBase} */
              p.resource
            );
            if (this.cpuSorter && !leafResource.hasCenters) {
              Debug.warnOnce(`Skipping gsplat resource id ${leafResource.id} on the CPU sorting path \u2014 no centers buffer. See Scene#gsplatCentersEnabled.`);
              return;
            }
            p.ensureInstanceStreams(this.device);
            const octreeNodes = p.intervals.size > 0 ? inst.octree.nodes : null;
            const nodeInfos = octreeNodes ? inst.nodeInfos : null;
            const splatInfo = new GSplatInfo(this.device, p.resource, p, p.consumeRenderDirty.bind(p), octreeNodes, nodeInfos);
            splats.push(splatInfo);
          }
        });
      }
      if (this.cpuSorter) {
        for (const splat of splats) {
          const resource = splat.resource;
          const lastVersion = this._centersVersions.get(resource.id);
          if (lastVersion !== resource.centersVersion) {
            this._centersVersions.set(resource.id, resource.centersVersion);
            this.cpuSorter.setCenters(resource.id, null);
            this.cpuSorter.setCenters(resource.id, resource.centers);
          }
        }
      }
      this.cpuSorter?.updateCentersForSplats(splats);
      const newState = new GSplatWorldState(
        this.device,
        this.lastWorldStateVersion,
        splats,
        this._allocator,
        this._allocationMap
      );
      for (const splat of newState.splats) {
        splat.resource.incRefCount();
      }
      for (const [, inst] of this.octreeInstances) {
        if (inst.removedCandidates && inst.removedCandidates.size) {
          for (const fileIndex of inst.removedCandidates) {
            newState.pendingReleases.push([inst.octree, fileIndex]);
          }
          inst.removedCandidates.clear();
        }
      }
      if (this.octreeInstancesToDestroy.length) {
        for (const inst of this.octreeInstancesToDestroy) {
          if (inst.removedCandidates && inst.removedCandidates.size) {
            for (const fileIndex of inst.removedCandidates) {
              newState.pendingReleases.push([inst.octree, fileIndex]);
            }
            inst.removedCandidates.clear();
          }
          const toRelease = inst.getFileDecrements();
          for (const fileIndex of toRelease) {
            newState.pendingReleases.push([inst.octree, fileIndex]);
          }
          inst.destroy(true);
        }
        this.octreeInstancesToDestroy.length = 0;
      }
      if (this._placementSetChanged) {
        newState.fullRebuild = true;
      }
      this.worldStates.set(this.lastWorldStateVersion, newState);
      this.layerPlacementsDirty = false;
      this._placementSetChanged = false;
      this._worldStateDirty = false;
      this.sortNeeded = true;
    }
  }
  onSorted(count, version, orderData) {
    this.cleanupOldWorldStates(version);
    this.sortedVersion = version;
    const worldState = this.worldStates.get(version);
    Debug.assert(worldState, `World state with version ${version} not found`);
    if (worldState) {
      if (!worldState.sortedBefore) {
        worldState.sortedBefore = true;
        this.rebuildWorkBuffer(worldState, count);
      }
      this.workBuffer.setOrderData(orderData);
      this.renderer.setOrderData();
    }
  }
  /**
   * Rebuilds the work buffer for a world state on its first sort.
   * Resizes buffer, renders changed splats, syncs transforms, and handles pending releases.
   *
   * @param {GSplatWorldState} worldState - The world state to rebuild for.
   * @param {number} count - The number of splats.
   * @param {boolean} [forceFullRebuild] - Force rendering all splats (e.g. format change).
   */
  rebuildWorkBuffer(worldState, count, forceFullRebuild = false) {
    const textureSize = worldState.textureSize;
    if (textureSize !== this.workBuffer.textureSize) {
      this.workBuffer.resize(textureSize);
    }
    if (this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT) {
      this.workBuffer.frustumCuller.updateBoundsData(worldState.boundsGroups);
      this.workBuffer.frustumCuller.updateTransformsData(worldState.boundsGroups);
    }
    const renderAll = forceFullRebuild || worldState.fullRebuild;
    const splatsToRender = renderAll ? worldState.splats : worldState.needsUpload;
    const changedAllocIds = renderAll ? null : worldState.needsUploadIds;
    if (splatsToRender.length > 0) {
      const totalBlocks = this._allocationMap.size;
      const uploadBlocks = renderAll ? totalBlocks : worldState.needsUploadIds.size;
      this.bufferCopyUploaded += uploadBlocks;
      this.bufferCopyTotal = totalBlocks;
      this.workBuffer.render(splatsToRender, this.cameraNode, this.getDebugColors(), changedAllocIds);
    }
    for (let i = 0; i < worldState.splats.length; i++) {
      worldState.splats[i].update();
    }
    this.updateColorCameraTracking();
    if (worldState.pendingReleases && worldState.pendingReleases.length) {
      const cooldownTicks = this.scene.gsplat.cooldownTicks;
      for (const [octree, fileIndex] of worldState.pendingReleases) {
        octree.decRefCount(fileIndex, cooldownTicks);
      }
      worldState.pendingReleases.length = 0;
    }
    this.renderer.update(count, textureSize);
  }
  /**
   * Cleans up old world states between the last sorted version and the new version.
   * Merges upload requirements from skipped states into the active state, then
   * decrements ref counts and destroys old states.
   *
   * @param {number} newVersion - The new version to clean up to.
   */
  cleanupOldWorldStates(newVersion) {
    const activeState = (
      /** @type {GSplatWorldState} */
      /** @type {unknown} */
      this.worldStates.get(newVersion)
    );
    if (!activeState.fullRebuild) {
      for (let v = this.sortedVersion + 1; v < newVersion; v++) {
        if (this.worldStates.get(v)?.fullRebuild) {
          activeState.fullRebuild = true;
          break;
        }
      }
    }
    if (!activeState.fullRebuild) {
      const activeIds = activeState.needsUploadIds;
      const lookup = activeState.allocIdToSplat;
      for (let v = this.sortedVersion + 1; v < newVersion; v++) {
        const oldState = this.worldStates.get(v);
        if (oldState) {
          for (const allocId of oldState.needsUploadIds) {
            if (!activeIds.has(allocId)) {
              activeIds.add(allocId);
              const splat = lookup.get(allocId);
              if (splat && !_queuedSplats.has(splat)) {
                activeState.needsUpload.push(splat);
                _queuedSplats.add(splat);
              }
            }
          }
        }
      }
      _queuedSplats.clear();
    }
    for (let v = this.sortedVersion; v < newVersion; v++) {
      const oldState = this.worldStates.get(v);
      if (oldState) {
        for (const splat of oldState.splats) {
          splat.resource.decRefCount();
        }
        this.worldStates.delete(v);
        oldState.destroy();
      }
    }
  }
  /**
   * Applies incremental work buffer updates for splats that have changed.
   * Detects transform changes and color update thresholds, then batch renders updates.
   * Sets sortNeeded = true when splats move.
   *
   * @param {GSplatWorldState} state - The world state to update.
   */
  applyWorkBufferUpdates(state) {
    const { colorUpdateAngle } = this.scene.gsplat;
    const ratio = Math.tan(colorUpdateAngle * math.DEG_TO_RAD);
    const cameraPos = this.cameraNode.getPosition();
    const { translationDelta } = this.calculateColorCameraDeltas();
    const hasCameraMovement = translationDelta > 0;
    let uploadedBlocks = 0;
    state.splats.forEach((splat) => {
      if (splat.update()) {
        _updatedSplats.push(splat);
        uploadedBlocks += splat.intervalAllocIds.length;
        if (splat.nodeInfos) {
          for (const ni of splat.intervalNodeIndices) {
            splat.nodeInfos[ni].colorAccumulatedTranslation = 0;
          }
        } else {
          splat.colorAccumulatedTranslation = 0;
        }
        this.sortNeeded = true;
      } else if (hasCameraMovement && splat.hasSphericalHarmonics) {
        _splatsWithSH.push(splat);
        if (splat.nodeInfos) {
          const nodeIndices = splat.intervalNodeIndices;
          for (let j = 0; j < nodeIndices.length; j++) {
            const nodeInfo = splat.nodeInfos[nodeIndices[j]];
            nodeInfo.colorAccumulatedTranslation += translationDelta;
            const threshold = ratio * Math.max(1, nodeInfo.worldDistance);
            if (nodeInfo.colorAccumulatedTranslation >= threshold) {
              _changedColorAllocIds.add(splat.intervalAllocIds[j]);
              nodeInfo.colorAccumulatedTranslation = 0;
              uploadedBlocks++;
            }
          }
        } else {
          splat.colorAccumulatedTranslation += translationDelta;
          invModelMat.copy(splat.node.getWorldTransform()).invert();
          invModelMat.transformPoint(cameraPos, _localCamPos);
          splat.aabb.closestPoint(_localCamPos, _closestPt);
          const dist = _localCamPos.distance(_closestPt) * splat.node.getWorldTransform().getScale().x;
          const threshold = ratio * Math.max(1, dist);
          if (splat.colorAccumulatedTranslation >= threshold) {
            _changedColorAllocIds.add(splat.allocId);
            uploadedBlocks += splat.intervalAllocIds.length;
            splat.colorAccumulatedTranslation = 0;
          }
        }
      }
    });
    this.bufferCopyUploaded += uploadedBlocks;
    this.bufferCopyTotal = this._allocationMap.size;
    if (_updatedSplats.length > 0) {
      this.workBuffer.render(_updatedSplats, this.cameraNode, this.getDebugColors());
      _updatedSplats.length = 0;
    }
    if (_changedColorAllocIds.size > 0) {
      this.workBuffer.renderColor(
        _splatsWithSH,
        this.cameraNode,
        this.getDebugColors(),
        _changedColorAllocIds
      );
      _changedColorAllocIds.clear();
    }
    _splatsWithSH.length = 0;
  }
  /**
   * Tests if the camera has moved or rotated enough to require LOD update.
   *
   * @returns {boolean} True if camera moved/rotated over thresholds, otherwise false.
   */
  testCameraMovedForLod() {
    const distanceThreshold = this.scene.gsplat.lodUpdateDistance;
    const currentCameraPos = this.cameraNode.getPosition();
    const cameraMoved = this.lastLodCameraPos.distance(currentCameraPos) > distanceThreshold;
    if (cameraMoved) {
      return true;
    }
    let cameraRotated = false;
    const lodUpdateAngleDeg = this.scene.gsplat.lodUpdateAngle;
    if (lodUpdateAngleDeg > 0) {
      if (Number.isFinite(this.lastLodCameraFwd.x)) {
        const currentCameraFwd = this.cameraNode.forward;
        const dot = Math.min(1, Math.max(-1, this.lastLodCameraFwd.dot(currentCameraFwd)));
        const angle = Math.acos(dot);
        const rotThreshold = lodUpdateAngleDeg * math.DEG_TO_RAD;
        cameraRotated = angle > rotThreshold;
      } else {
        cameraRotated = true;
      }
    }
    const currentFov = this.cameraNode.camera.fov;
    const fovChanged = this.lastLodCameraFov < 0 || Math.abs(currentFov - this.lastLodCameraFov) > this.lastLodCameraFov * 0.02;
    return cameraMoved || cameraRotated || fovChanged;
  }
  /**
   * Tests if the camera has moved enough to require re-sorting.
   * - For radial sorting: only position matters (rotation doesn't affect sort order)
   * - For directional sorting: only forward direction matters (position doesn't affect sort order)
   *
   * @returns {boolean} True if camera moved enough to require re-sorting, otherwise false.
   */
  testCameraMovedForSort() {
    const epsilon = 1e-3;
    if (this.scene.gsplat.radialSorting) {
      const currentCameraPos = this.cameraNode.getPosition();
      return this.lastSortCameraPos.distance(currentCameraPos) > epsilon;
    }
    if (Number.isFinite(this.lastSortCameraFwd.x)) {
      const currentCameraFwd = this.cameraNode.forward;
      const dot = Math.min(1, Math.max(-1, this.lastSortCameraFwd.dot(currentCameraFwd)));
      return Math.acos(dot) > epsilon;
    }
    return true;
  }
  /**
   * Tests if the camera frustum has changed since the last sort or compaction. Checks both
   * projection matrix and camera rotation. Used to trigger re-culling/compaction independently of
   * sort-key changes.
   *
   * @returns {boolean} True if the frustum changed.
   */
  testFrustumChanged() {
    const epsilon = 1e-3;
    if (!this.lastCullingProjMat.equals(this.cameraNode.camera.projectionMatrix)) {
      return true;
    }
    const currentCameraFwd = this.cameraNode.forward;
    const dot = Math.min(1, Math.max(-1, this.lastCullingCameraFwd.dot(currentCameraFwd)));
    return Math.acos(dot) > epsilon;
  }
  /**
   * Updates the camera tracking state for color accumulation calculations.
   * Called after any render that updates colors (full or color-only).
   */
  updateColorCameraTracking() {
    this.lastColorUpdateCameraPos.copy(this.cameraNode.getPosition());
  }
  /**
   * Determines the colorization mode for rendering based on debug flags.
   *
   * @returns {Array<number[]>|undefined} Color array for debug visualization, or undefined for normal rendering
   */
  getDebugColors() {
    const debug = this.scene.gsplat.debug;
    if (debug === GSPLAT_DEBUG_SH_UPDATE) {
      _randomColorRaw ?? (_randomColorRaw = []);
      const r = Math.random();
      const g = Math.random();
      const b = Math.random();
      for (let i = 0; i < _lodColorsRaw.length; i++) {
        _randomColorRaw[i] ?? (_randomColorRaw[i] = [0, 0, 0]);
        _randomColorRaw[i][0] = r;
        _randomColorRaw[i][1] = g;
        _randomColorRaw[i][2] = b;
      }
      return _randomColorRaw;
    } else if (debug === GSPLAT_DEBUG_LOD) {
      return _lodColorsRaw;
    }
    return void 0;
  }
  /**
   * Calculates camera translation delta since last color update.
   * Updates and returns the shared _cameraDeltas object.
   *
   * @returns {{ translationDelta: number }} Shared camera movement deltas object
   */
  calculateColorCameraDeltas() {
    _cameraDeltas.translationDelta = 0;
    if (isFinite(this.lastColorUpdateCameraPos.x)) {
      const currentCameraPos = this.cameraNode.getPosition();
      _cameraDeltas.translationDelta = this.lastColorUpdateCameraPos.distance(currentCameraPos);
    }
    return _cameraDeltas;
  }
  /**
   * Fires the frame:ready event with current sorting and loading state.
   */
  fireFrameReadyEvent() {
    const ready = this.sortedVersion === this.lastWorldStateVersion && !this._awaitingLodUpdate;
    let loadingCount = 0;
    for (const [, inst] of this.octreeInstances) {
      loadingCount += inst.pendingLoadCount;
    }
    this.director.eventHandler.fire("frame:ready", this.cameraNode.camera, this.renderer.layer, ready, loadingCount);
  }
  /**
   * Computes max world-space distance across all octree instances. Used for sqrt-based bucket
   * distribution in budget balancing. Non-octree placements are excluded since they have fixed
   * splat counts and don't participate in LOD-based budget balancing.
   *
   * @returns {number} Maximum world-space distance, minimum 1 to avoid division by zero.
   * @private
   */
  computeGlobalMaxDistance() {
    let maxDist = 0;
    cameraPosition.copy(this.cameraNode.getPosition());
    for (const [, inst] of this.octreeInstances) {
      const worldTransform = inst.placement.node.getWorldTransform();
      const aabb = inst.placement.aabb;
      worldTransform.transformPoint(aabb.center, _tempVec3);
      const scale = worldTransform.getScale().x;
      const dist = _tempVec3.distance(cameraPosition) + aabb.halfExtents.length() * scale;
      if (dist > maxDist) maxDist = dist;
    }
    return Math.max(maxDist, 1);
  }
  /**
   * Enforces global splat budget across all octree instances using phased approach.
   *
   * @param {number} budget - Target splat budget from GSplatParams.splatBudget.
   * @private
   */
  _enforceBudget(budget) {
    const textureWidth = this.workBuffer.textureSize;
    let fixedSplats = 0;
    let paddingEstimate = 0;
    for (const p of this.layerPlacements) {
      const resource = (
        /** @type {GSplatResourceBase} */
        p.resource
      );
      if (resource) {
        const numSplats = resource.numSplats ?? 0;
        fixedSplats += numSplats;
        paddingEstimate += (textureWidth - numSplats % textureWidth) % textureWidth;
      }
    }
    const octreeBudget = Math.max(1, budget - fixedSplats);
    const globalMaxDistance = this.computeGlobalMaxDistance();
    let totalOptimalSplats = 0;
    for (const [, inst] of this.octreeInstances) {
      totalOptimalSplats += inst.evaluateOptimalLods(this.cameraNode, this.scene.gsplat, this._budgetScale, globalMaxDistance);
      for (const placement of inst.activePlacements) {
        const resource = (
          /** @type {GSplatResourceBase} */
          placement.resource
        );
        const numSplats = resource?.numSplats ?? 0;
        paddingEstimate += (textureWidth - numSplats % textureWidth) % textureWidth;
      }
    }
    const adjustedBudget = Math.max(1, octreeBudget - paddingEstimate);
    if (totalOptimalSplats > 0) {
      const ratio = totalOptimalSplats / adjustedBudget;
      const budgetScaleDeadZone = 0.4;
      const budgetScaleBlendRate = 0.3;
      if (ratio > 1 + budgetScaleDeadZone || ratio < 1 - budgetScaleDeadZone) {
        const invCorrection = 1 / Math.sqrt(ratio);
        this._budgetScale *= 1 + (invCorrection - 1) * budgetScaleBlendRate;
        this._budgetScale = Math.max(0.01, Math.min(this._budgetScale, 100));
      }
    }
    this._budgetBalancer.balance(this.octreeInstances, adjustedBudget);
    for (const [, inst] of this.octreeInstances) {
      const maxLod = inst.octree.lodLevels - 1;
      inst.applyLodChanges(maxLod, this.scene.gsplat);
    }
  }
  /**
   * Detects if the work buffer format has been replaced (e.g. dataFormat changed) and
   * recreates the work buffer if needed.
   *
   * @private
   */
  handleFormatChange() {
    const currentFormat = this.scene.gsplat.format;
    if (this.workBuffer.format !== currentFormat) {
      this.workBuffer.destroy();
      this.workBuffer = new GSplatWorkBuffer(this.device, currentFormat);
      this.renderer.setDataSource(this.workBuffer);
      this._workBufferFormatVersion = this.workBuffer.format.extraStreamsVersion;
      this._workBufferRebuildRequired = true;
      this.sortNeeded = true;
    }
  }
  update() {
    this.bufferCopyUploaded = 0;
    this.bufferCopyTotal = 0;
    this.handleFormatChange();
    const wbFormatVersion = this.workBuffer.format.extraStreamsVersion;
    if (this._workBufferFormatVersion !== wbFormatVersion) {
      this._workBufferFormatVersion = wbFormatVersion;
      this.workBuffer.syncWithFormat();
      this._workBufferRebuildRequired = true;
      this.sortNeeded = true;
    }
    this.prepareRendererMode();
    if (this.cpuSorter) {
      this.cpuSorter.applyPendingSorted();
    }
    const sorterAvailable = this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT || this.cpuSorter && this.cpuSorter.jobsInFlight < 3;
    let fullUpdate = false;
    this.framesTillFullUpdate--;
    if (this.framesTillFullUpdate <= 0) {
      this.framesTillFullUpdate = 10;
      if (sorterAvailable) {
        fullUpdate = true;
      }
    }
    const hasNewInstances = this.hasNewOctreeInstances && sorterAvailable;
    if (hasNewInstances) this.hasNewOctreeInstances = false;
    let anyInstanceNeedsLodUpdate = false;
    let anyOctreeMoved = false;
    let cameraMovedOrRotatedForLod = false;
    if (fullUpdate) {
      for (const [, inst] of this.octreeInstances) {
        const isDirty = inst.update();
        this.layerPlacementsDirty || (this.layerPlacementsDirty = isDirty);
        this._placementSetChanged || (this._placementSetChanged = inst.consumePlacementSetChanged());
        const instNeeds = inst.consumeNeedsLodUpdate();
        anyInstanceNeedsLodUpdate || (anyInstanceNeedsLodUpdate = instNeeds);
      }
      Debug.call(() => {
        const sortedState2 = this.worldStates.get(this.sortedVersion);
        if (sortedState2) {
          for (const splat of sortedState2.splats) {
            if (!splat.resource) {
              Debug.warn(`GSplatManager: Resource reference is null but still referenced in world state ${sortedState2.version}`);
            }
          }
        }
      });
      const threshold = this.scene.gsplat.lodUpdateDistance;
      for (const [, inst] of this.octreeInstances) {
        const moved = inst.testMoved(threshold);
        anyOctreeMoved || (anyOctreeMoved = moved);
      }
      cameraMovedOrRotatedForLod = this.testCameraMovedForLod();
      this._awaitingLodUpdate = false;
    }
    if (this.testCameraMovedForSort()) {
      this.sortNeeded = true;
    }
    if (this.intervalCompaction && !this.sortNeeded && this.testFrustumChanged()) {
      this.lastCullingCameraFwd.copy(this.cameraNode.forward);
      this.lastCullingProjMat.copy(this.cameraNode.camera.projectionMatrix);
      this.sortNeeded = true;
    }
    Debug.call(() => {
      for (const [, inst] of this.octreeInstances) {
        inst.debugRender(this.scene);
      }
    });
    if (this.scene.gsplat.dirty) {
      this.layerPlacementsDirty = true;
      this.renderer.updateOverdrawMode(this.scene.gsplat);
      this._workBufferRebuildRequired = true;
      this.sortNeeded = true;
      if (this.octreeInstances.size > 0) {
        this._awaitingLodUpdate = true;
      }
    }
    if (cameraMovedOrRotatedForLod || anyOctreeMoved || this.scene.gsplat.dirty || anyInstanceNeedsLodUpdate || hasNewInstances) {
      for (const [, inst] of this.octreeInstances) {
        inst.updateMoved();
      }
      const cameraNode = this.cameraNode;
      this.lastLodCameraPos.copy(cameraNode.getPosition());
      this.lastLodCameraFwd.copy(cameraNode.forward);
      this.lastLodCameraFov = cameraNode.camera.fov;
      const budget = this.scene.gsplat.splatBudget;
      if (budget > 0) {
        this._enforceBudget(budget);
      } else {
        this._budgetScale = 1;
        for (const [, inst] of this.octreeInstances) {
          inst.updateLod(this.cameraNode, this.scene.gsplat);
        }
      }
    }
    this.updateWorldState();
    const lastState = this.worldStates.get(this.lastWorldStateVersion);
    if (lastState) {
      Debug.call(() => {
        if (this.scene.gsplat.debug === GSPLAT_DEBUG_AABBS) {
          const tempAabb = new BoundingBox();
          const scene = this.scene;
          lastState.splats.forEach((splat) => {
            tempAabb.setFromTransformedAabb(splat.aabb, splat.node.getWorldTransform());
            scene.immediate.drawWireAlignedBox(tempAabb.getMin(), tempAabb.getMax(), _lodColors[splat.lodIndex], true, scene.defaultDrawLayer);
          });
        }
      });
      if (this.cpuSorter && !lastState.sortParametersSet) {
        lastState.sortParametersSet = true;
        const payload = this.prepareSortParameters(lastState);
        this.cpuSorter.setSortParameters(payload);
      }
    }
    const sortedState = this.worldStates.get(this.sortedVersion);
    if (sortedState?.sortedBefore) {
      if (this._workBufferRebuildRequired) {
        const count = sortedState.totalActiveSplats;
        this.rebuildWorkBuffer(sortedState, count, true);
        this._workBufferRebuildRequired = false;
        this.renderer.setOrderData();
        if (this.intervalCompaction) {
          this.intervalCompaction._uploadedVersion = -1;
        }
      } else {
        this.applyWorkBufferUpdates(sortedState);
      }
    }
    let gpuSortedThisFrame = false;
    if (this.sortNeeded && lastState) {
      if (this.activeRenderer === GSPLAT_RENDERER_COMPUTE) {
        this.compactGpu(lastState);
        gpuSortedThisFrame = true;
      } else if (this.activeRenderer === GSPLAT_RENDERER_RASTER_GPU_SORT) {
        this.sortGpuHybrid(lastState);
        gpuSortedThisFrame = true;
      } else {
        this.sortCpu(lastState);
      }
      this.sortNeeded = false;
      this.lastSortCameraPos.copy(this.cameraNode.getPosition());
      this.lastSortCameraFwd.copy(this.cameraNode.forward);
      this.lastCullingCameraFwd.copy(this.cameraNode.forward);
      this.lastCullingProjMat.copy(this.cameraNode.camera.projectionMatrix);
    }
    if (this.activeRenderer === GSPLAT_RENDERER_RASTER_GPU_SORT && lastState && !gpuSortedThisFrame) {
      this.sortGpuHybrid(lastState);
      gpuSortedThisFrame = true;
    }
    if (sortedState?.sortedBefore) {
      this.updateColorCameraTracking();
    }
    if (this.octreeInstances.size) {
      const cooldownTicks = this.scene.gsplat.cooldownTicks;
      for (const [, inst] of this.octreeInstances) {
        const octree = inst.octree;
        if (!tempOctreesTicked.has(octree)) {
          tempOctreesTicked.add(octree);
          octree.updateCooldownTick(cooldownTicks);
        }
      }
      tempOctreesTicked.clear();
    }
    this.fireFrameReadyEvent();
    if (this.scene.gsplat.dirty) {
      for (const [, inst] of this.octreeInstances) {
        inst.needsLodUpdate = true;
      }
    }
    const fogParams = this.scene.gsplat.useFog ? this.cameraNode.camera.fogParams ?? this.scene.fog : null;
    this.renderer.frameUpdate(this.scene.gsplat, this.scene.exposure, fogParams);
    return sortedState ? sortedState.totalActiveSplats : 0;
  }
  /**
   * Hybrid GPU path: interval compaction, projector (keys + proj cache), indirect radix sort
   * over projector keys (indices are dense in projCache), then hybrid raster bindings.
   *
   * @param {GSplatWorldState} worldState - The world state to sort.
   */
  sortGpuHybrid(worldState) {
    const cam = this.cameraNode.camera;
    const sceneCam = cam.camera;
    const rt = cam.renderTarget;
    const rect = cam.rect;
    const xrView = sceneCam.xr?.session ? sceneCam.xr.views.list[0] : null;
    const viewportWidth = Math.floor((xrView ? xrView.viewport.z : rt ? rt.width : this.device.width) * rect.z);
    const viewportHeight = Math.floor((xrView ? xrView.viewport.w : rt ? rt.height : this.device.height) * rect.w);
    const sortedIndices = this.sortGpuHybridForCamera(
      worldState,
      this.cameraNode,
      viewportWidth,
      viewportHeight,
      Math.max(ALPHA_VISIBILITY_THRESHOLD, this.scene.gsplat.alphaClipForward),
      false
    );
    if (sortedIndices) {
      this.applyGpuSortResults(sortedIndices);
    }
  }
  /**
   * Runs the shared hybrid projector + indirect radix sort path for a specific camera.
   *
   * @param {GSplatWorldState} worldState - The world state to sort.
   * @param {GraphNode} cameraNode - Camera node used for projection and sort keys.
   * @param {number} viewportWidth - Projection viewport width in pixels.
   * @param {number} viewportHeight - Projection viewport height in pixels.
   * @param {number} alphaClip - Projector producer alpha threshold.
   * @param {boolean} pickMode - Whether projector writes pcId into the cache.
   * @returns {StorageBuffer|null} The sorted cache indices, or null if no work was dispatched.
   * @private
   */
  sortGpuHybridForCamera(worldState, cameraNode, viewportWidth, viewportHeight, alphaClip, pickMode) {
    const gpuSorter = this.gpuSorter;
    const projector = this.projector;
    Debug.assert(gpuSorter && projector, "Hybrid GPU sort not initialized");
    if (!gpuSorter || !projector) return null;
    const elementCount = worldState.totalActiveSplats;
    if (elementCount === 0) return null;
    if (!this.intervalCompaction) {
      this.intervalCompaction = new GSplatIntervalCompaction(this.device);
    }
    if (!worldState.sortedBefore) {
      worldState.sortedBefore = true;
      this.cleanupOldWorldStates(worldState.version);
      this.sortedVersion = worldState.version;
      this.rebuildWorkBuffer(worldState, elementCount);
    }
    this.intervalCompaction.uploadIntervals(worldState);
    if (this.canCull) {
      const state = this.worldStates.get(this.sortedVersion);
      if (state) {
        this._runFrustumCulling(state, cameraNode);
      }
    }
    const fisheyeProj = this.renderer.fisheyeProj;
    const numIntervals = worldState.totalIntervals;
    const totalActiveSplats = worldState.totalActiveSplats;
    this.intervalCompaction.dispatchCompact(this.workBuffer.frustumCuller, numIntervals, totalActiveSplats, fisheyeProj.enabled);
    this.allocateAndWriteIntervalIndirectArgs(numIntervals);
    const ic = (
      /** @type {GSplatIntervalCompaction} */
      this.intervalCompaction
    );
    const compactedSplatIds = ic.compactedSplatIds;
    const gsplat = this.scene.gsplat;
    const numBits = Math.max(10, Math.min(20, Math.round(Math.log2(elementCount / 4))));
    const radixBits = gpuSorter.radixBits;
    const roundedNumBits = Math.ceil(numBits / radixBits) * radixBits;
    const { minDist, maxDist } = this.computeDistanceRange(worldState, cameraNode);
    const sortIndirectInfo = gpuSorter.prepareIndirect();
    projector.dispatch({
      workBuffer: this.workBuffer,
      cameraNode,
      compactedSplatIds: (
        /** @type {StorageBuffer} */
        compactedSplatIds
      ),
      sortElementCountBuffer: (
        /** @type {StorageBuffer} */
        ic.sortElementCountBuffer
      ),
      totalCapacity: elementCount,
      radialSort: gsplat.radialSorting,
      numBits: roundedNumBits,
      minDist,
      maxDist,
      alphaClip,
      minPixelSize: gsplat.minPixelSize * 0.5,
      minContribution: gsplat.minContribution,
      viewportWidth,
      viewportHeight,
      flipY: !!cameraNode.camera.renderTarget?.flipY,
      pickMode,
      fisheyeProj,
      antiAlias: gsplat.antiAlias
    });
    projector.writeIndirectArgs(
      this.indirectDrawSlot,
      this.indirectDispatchSlot + 1,
      /** @type {StorageBuffer} */
      ic.numSplatsBuffer,
      /** @type {StorageBuffer} */
      ic.sortElementCountBuffer,
      sortIndirectInfo
    );
    return gpuSorter.sortIndirect(
      /** @type {StorageBuffer} */
      projector.sortKeys,
      elementCount,
      roundedNumBits,
      this.indirectDispatchSlot + 1,
      /** @type {StorageBuffer} */
      ic.sortElementCountBuffer,
      void 0,
      false,
      true
      // destructiveKeys: projector overwrites sortKeys each frame before the sort
    );
  }
  /**
   * Runs frustum culling and interval compaction on the GPU, then passes the compacted
   * splat ID buffer directly to the local compute renderer (no key generation or radix sort).
   *
   * @param {GSplatWorldState} worldState - The world state to compact.
   * @private
   */
  compactGpu(worldState) {
    if (!this.intervalCompaction) {
      this.intervalCompaction = new GSplatIntervalCompaction(this.device);
    }
    const elementCount = worldState.totalActiveSplats;
    if (elementCount === 0) return;
    if (!worldState.sortedBefore) {
      worldState.sortedBefore = true;
      this.cleanupOldWorldStates(worldState.version);
      this.sortedVersion = worldState.version;
      this.rebuildWorkBuffer(worldState, elementCount);
    }
    this.intervalCompaction.uploadIntervals(worldState);
    if (this.canCull) {
      const state = this.worldStates.get(this.sortedVersion);
      if (state) {
        this._runFrustumCulling(state);
      }
    }
    const numIntervals = worldState.totalIntervals;
    const totalActiveSplats = worldState.totalActiveSplats;
    this.intervalCompaction.dispatchCompact(this.workBuffer.frustumCuller, numIntervals, totalActiveSplats, this.renderer.fisheyeProj.enabled);
    this.allocateAndWriteIntervalIndirectArgs(numIntervals);
    const ic = (
      /** @type {GSplatIntervalCompaction} */
      this.intervalCompaction
    );
    const localRenderer = (
      /** @type {any} */
      this.renderer
    );
    localRenderer.setCompactedData(
      /** @type {StorageBuffer} */
      ic.compactedSplatIds,
      /** @type {StorageBuffer} */
      ic.sortElementCountBuffer,
      worldState.textureSize,
      totalActiveSplats
    );
  }
  /**
   * Allocates per-frame indirect draw and dispatch slots and runs writeIndirectArgs
   * for interval compaction.
   *
   * @param {number} numIntervals - Total interval count (index into prefix sum for visible count).
   * @private
   */
  allocateAndWriteIntervalIndirectArgs(numIntervals) {
    const gpuSorter = (
      /** @type {ComputeRadixSort | null} */
      this.gpuSorter
    );
    const sortInfo = gpuSorter ? gpuSorter.prepareIndirect() : NO_SORT_INDIRECT_INFO;
    const sortSlotCount = sortInfo[0];
    this.indirectDrawSlot = this.device.getIndirectDrawSlot(1);
    this.indirectDispatchSlot = this.device.getIndirectDispatchSlot(1 + sortSlotCount);
    const ic = (
      /** @type {GSplatIntervalCompaction} */
      this.intervalCompaction
    );
    ic.writeIndirectArgs(this.indirectDrawSlot, this.indirectDispatchSlot, numIntervals, sortInfo);
    this.lastCompactedNumIntervals = numIntervals;
  }
  /**
   * Applies hybrid GPU sort results to the renderer with indirect draw from interval compaction.
   *
   * @param {StorageBuffer} sortedIndices - Buffer containing sorted splat IDs.
   * @private
   */
  applyGpuSortResults(sortedIndices) {
    const proj = (
      /** @type {GSplatProjector} */
      this.projector
    );
    const ic = (
      /** @type {GSplatIntervalCompaction} */
      this.intervalCompaction
    );
    /** @type {unknown} */
    this.renderer.setHybridSortedRendering(
      this.indirectDrawSlot,
      sortedIndices,
      /** @type {StorageBuffer} */
      proj.projCache,
      /** @type {StorageBuffer} */
      ic.numSplatsBuffer
    );
  }
  /**
   * Prepares frustum culling data: updates the GPU transform buffers and computes
   * frustum planes from the camera. The actual culling test runs inline in the
   * interval compaction compute shader.
   *
   * @param {GSplatWorldState} worldState - The world state whose splats provide transforms.
   * @param {GraphNode} [cameraNode] - Camera node to cull against.
   * @private
   */
  _runFrustumCulling(worldState, cameraNode = this.cameraNode) {
    this.workBuffer.frustumCuller.updateTransformsData(worldState.boundsGroups);
    const cam = cameraNode.camera;
    this.workBuffer.frustumCuller.computeFrustumPlanes(cam.projectionMatrix, cam.viewMatrix);
    const gsplat = this.scene.gsplat;
    const fp = this.renderer.fisheyeProj;
    fp.update(gsplat.fisheye, cam.fov, cam.projectionMatrix);
    if (fp.enabled) {
      this.workBuffer.frustumCuller.setFisheyeData(
        cameraNode.getPosition(),
        cameraNode.forward,
        fp.maxTheta
      );
    }
  }
  /**
   * Computes the min/max effective distances for the current world state.
   *
   * @param {GSplatWorldState} worldState - The world state.
   * @param {GraphNode} [cameraNode] - Camera node to measure distances from.
   * @returns {{minDist: number, maxDist: number}} The distance range.
   */
  computeDistanceRange(worldState, cameraNode = this.cameraNode) {
    const cameraMat = cameraNode.getWorldTransform();
    cameraMat.getTranslation(cameraPosition);
    cameraMat.getZ(cameraDirection).normalize();
    const radialSort = this.scene.gsplat.radialSorting;
    let minDist = radialSort ? 0 : Infinity;
    let maxDist = radialSort ? 0 : -Infinity;
    for (const splat of worldState.splats) {
      const modelMat = splat.node.getWorldTransform();
      const aabbMin = splat.aabb.getMin();
      const aabbMax = splat.aabb.getMax();
      for (let i = 0; i < 8; i++) {
        _tempVec3.x = i & 1 ? aabbMax.x : aabbMin.x;
        _tempVec3.y = i & 2 ? aabbMax.y : aabbMin.y;
        _tempVec3.z = i & 4 ? aabbMax.z : aabbMin.z;
        modelMat.transformPoint(_tempVec3, _tempVec3);
        if (radialSort) {
          const dist = _tempVec3.distance(cameraPosition);
          if (dist > maxDist) maxDist = dist;
        } else {
          const dist = _tempVec3.sub(cameraPosition).dot(cameraDirection);
          if (dist < minDist) minDist = dist;
          if (dist > maxDist) maxDist = dist;
        }
      }
    }
    if (maxDist === 0 || maxDist === -Infinity) {
      return { minDist: 0, maxDist: 1 };
    }
    return { minDist, maxDist };
  }
  /**
   * Sorts the splats using CPU worker (asynchronous).
   *
   * @param {GSplatWorldState} lastState - The last world state.
   */
  sortCpu(lastState) {
    Debug.assert(this.cpuSorter, "CPU sorter not initialized");
    if (!this.cpuSorter) return;
    const cameraNode = this.cameraNode;
    const cameraMat = cameraNode.getWorldTransform();
    cameraMat.getTranslation(cameraPosition);
    cameraMat.getZ(cameraDirection).normalize();
    const sorterRequest = [];
    lastState.splats.forEach((splat) => {
      const modelMat = splat.node.getWorldTransform();
      invModelMat.copy(modelMat).invert();
      const uniformScale = modelMat.getScale().x;
      const transformedDirection = invModelMat.transformVector(cameraDirection).normalize();
      const transformedPosition = invModelMat.transformPoint(cameraPosition);
      modelMat.getTranslation(translation);
      const offset = translation.sub(cameraPosition).dot(cameraDirection);
      const aabbMin = splat.aabb.getMin();
      const aabbMax = splat.aabb.getMax();
      sorterRequest.push({
        transformedDirection,
        transformedPosition,
        offset,
        scale: uniformScale,
        modelMat: modelMat.data.slice(),
        aabbMin: [aabbMin.x, aabbMin.y, aabbMin.z],
        aabbMax: [aabbMax.x, aabbMax.y, aabbMax.z]
      });
    });
    this.cpuSorter.setSortParams(sorterRequest, this.scene.gsplat.radialSorting);
  }
  /**
   * Prepares sort parameters data for the sorter worker.
   *
   * @param {GSplatWorldState} worldState - The world state containing all needed data.
   * @returns {object} - Data for sorter worker.
   */
  prepareSortParameters(worldState) {
    return {
      command: "intervals",
      textureSize: worldState.textureSize,
      totalActiveSplats: worldState.totalActiveSplats,
      version: worldState.version,
      ids: worldState.splats.map((splat) => splat.resource.id),
      pixelOffsets: worldState.splats.map((splat) => splat.intervalOffsets),
      // TODO: consider storing this in typed array and transfer it to sorter worker
      intervals: worldState.splats.map((splat) => splat.intervals)
    };
  }
}
export {
  GSplatManager
};