DEV Community

Cover image for 25 Million Gaussians: Loading Detail While You Explore
有点质量
有点质量

Posted on AI-assisted

25 Million Gaussians: Loading Detail While You Explore

The model we were integrating came as a PLY file of about 1.76 GB, containing 25,842,067 Gaussians. We previously wrote about the stalls and blur we investigated once it was rendering. This time, I want to take a step back: what does the browser receive first, and how does it fill in more detail as the view changes?

For the close-up test, we set a selection budget of 2.5 million Gaussians. The source contained more than 25 million, while the current selection contained only a few million. We had not simply deleted the rest. Making that work required preparation before the browser ever opened the model.

Overview of a public quarry example, showing stepped terrain and haul roads

Figure 1. The images in this article show the Gemmingen quarry example from our public demo, published by diddlkiller and rendered through the SuperSplat viewer. Original scene. The 25.84 million source Gaussians and loading measurements discussed below belong to a different project model. These images illustrate the appearance of a scene.

Prepare data that can be used in parts

We did not simply split the original PLY into small files and tell the browser to read them in order. The order of records in a file does not tell a viewer what matters from a particular position, or which details can wait.

We first used Spark's conversion tools on the server to generate levels of detail, or LOD, and organize them into paged RAD assets for our native WebGPU viewer. Coarse nodes summarize an area, while nodes below them retain finer detail. The viewer can start with a coarse representation and refine the regions that matter for the current view.

The converted main model contained 581 pages, totaling about 722 MB. That is the size of the complete main asset, not the amount that must be downloaded to open one camera view. The original PLY still had to be downloaded and converted in full as part of this workflow.

Another number is easy to misread: the resulting tree contained about 38.04 million nodes, more than the source model's 25.84 million Gaussians. The extra nodes represent different levels of detail; they are not all Gaussians that need to be drawn at the same time. Source count, total tree-node count, and current selection count describe three different things.

Flow from the original PLY through LOD conversion to view-dependent page selection and rendering updates in the browser

Figure 2. The data flow used in this integration. The upper half happens before the viewer opens; the lower half continues as the view changes. Source count, tree-node count, and selection budget refer to different stages.

Show a coarse view, then prepare more detail

When opening the asset, the viewer first reads the page directory to find the data, then fetches the page containing the root nodes. It can display the coarse data already available without waiting for all 581 pages.

The directory records each page's location and length. Pages can live in a single file, with HTTP Range requests retrieving specific byte ranges, or in separate RADC files. We used separate chunks in this case. The viewer fetches the required file and checks the response length, so a request intended to read one page cannot silently consume an entire large file.

Camera position, orientation, canvas size, and the count budget then guide selection. A distant region that occupies few pixels can use a coarser representation. As the camera moves closer, finer nodes become useful, and selection identifies the pages that are still missing.

We request those pages, decode them, and select again. New data may unlock another level of refinement, so this proceeds in rounds. Once the current selection has the data it needs, we pack it for the GPU, upload it, and update rendering. A completed network request does not, by itself, put more detail on screen.

While the next set of data is being prepared, the current resources remain available for drawing. The replacement takes over only when it is fully ready. Camera interaction and preparation can therefore continue without clearing the scene and rebuilding it from an empty frame.

In this test, 2.5 million was the limit for the current selection. That selection can contain nodes from several detail levels; it is not the first 2.5 million records from the original file. A different camera position changes which details are worth keeping. Pages no longer involved in the selection can be released and fetched again later if needed.

Detail view of the public quarry example, showing rock faces, ramps, and terrain

Figure 3. A view of the same public quarry example, showing rock faces, ramps, and haul roads. When viewed up close, these features occupy more screen space, which is why the viewer needs to choose detail according to the view.

A short piece of pseudocode shows the steps between data arriving and the rendered image changing:

let selected = null, ready = false;
for (let round = 0; round <= pageCount; round++) {
  const cut = await select(view, budget);
  selected = cut.data ?? selected;
  const missing = cut.wantedPages.filter(id => !resident.has(id));
  if (!missing.length) { ready = true; break; }
  for (const id of missing) {
    await fetchAndDecodePage(id);
    resident.add(id);
  }
}
if (!ready) throw new Error("LOD selection did not converge");
if (!selected) return;
const next = await buildPass(selected);
if (signal.aborted) { next.dispose(); return; }
const old = pass;
pass = next;
old?.dispose();
Enter fullscreen mode Exit fullscreen mode

This pseudocode omits scheduling and error handling. buildPass includes packing and preparing GPU resources. After each round of fetching, selection runs again until no pages are missing. Exceeding a round limit based on the page count raises an error rather than waiting indefinitely. The old resources keep drawing during selection, requests, and uploads; they are replaced only when the new resources are complete. If preparation fails, the old resources remain, and the unfinished update is kept for a retry.

The count budget is not a memory cap. Downloaded pages, decoded data, resources being prepared, and resources currently in use all occupy memory. Limiting the drawn count does not remove the need to manage their residency and release.

Loading ended, but refinement had not

Early in the integration, a state flag misled us. Loading had become false, so the test assumed refinement was complete, checked the image, and failed.

The root nodes were ready, but the next camera-driven refinement was still queued. An idle downloader did not mean all the work for the current view had finished.

We added queued LOD updates, selection in progress, and resource rebuilding to the busy state. Our checks also followed the work further: had the selected data actually reached rendering, and had the image changed accordingly?

Refinement remains busy while updates are queued, pages are selected and fetched, or resources are rebuilt; committed counts change only after the new pass takes over

Figure 4. A coarse view may already be visible while refinement is still queued. Loading state needs to include that follow-up work. Completing selection also does not mean the new resources have reached rendering.

We therefore distinguish between having an initial image and finishing refinement for the current view. Moving the camera creates another round of demand. In a streaming viewer, a statement that loading is complete needs to say which view it applies to.

After a failure, state must not get ahead of the image

Breaking loading into stages also creates several places where it can fail. A page may have arrived and selection may have changed when a later request fails. Or the CPU may have prepared new data, but building the GPU resources fails.

We encountered a case where the decoder remembered a new selection while rendering still used the old data. On retry, asking only whether the selection had changed could return no change. The selected data would then never be sent back to the rendering side.

A retry after failure must therefore be able to retrieve the current selection again, and unfinished updates must remain pending. The displayed count must describe data successfully handed over for rendering, not data still being prepared.

The following pseudocode puts the decoder and rendering states together. refine represents the selection-and-fetch loop above. forceOutput tells it to return data even if the selection has not changed:

try {
  let selected;
  try {
    selected = await refine(view, budget, { forceOutput });
    forceOutput = false;
  } catch (error) {
    forceOutput = true;
    throw error;
  }
  if (selected) { data = selected; dirty = true; }
  if (!dirty) return;
  const next = await buildPass(data);
  if (signal.aborted) { next.dispose(); return; }
  const old = pass;
  pass = next;
  old?.dispose();
  committedCount = data.count;
  dirty = false;
} catch {
  if (!signal.aborted) scheduleRetry(view);
}
Enter fullscreen mode Exit fullscreen mode

data retains the selected data, and dirty records that it has not yet been committed. Even if the next selection is unchanged, that rebuild still needs to finish. committedCount changes only after the new pass replaces the old one. Forcing output restores the data; scheduling a retry makes another attempt happen. Both are necessary.

If the user leaves the scene during this work, any late result must also be released when the task is cancelled.

These measures may not make the first load faster. They determine whether the viewer can still function after repeated scene changes and failed attempts.

After a two-minute stall, check whether the bytes arrived

When we switched to the complete model, the first remote test stalled again. The scene was ready, but waiting for refinement timed out after 120 seconds. There were no GPU or page errors, and only a small number of page requests had been issued.

Further investigation showed that the private tunnel used by the test was very slow. An independent copy measured about 34.5 KB per second. A response header reporting a file's size tells us how many bytes to expect, not how many the browser has received.

To see whether data is still arriving, we can count the bytes read from the response body. This standalone observation snippet uses browser APIs:

const response = await fetch(pageUrl, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error("Missing response body");
const reader = response.body.getReader();
let receivedBytes = 0;
try {
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    receivedBytes += value.byteLength;
    console.log({ receivedBytes });
  }
} finally {
  await reader.cancel().catch(() => {});
  reader.releaseLock();
}
Enter fullscreen mode Exit fullscreen mode

receivedBytes measures only the response body read so far. A chunk returned by read() is not a RAD page, and receiving it does not mean it has been decoded or displayed. Selection and upload still need to be checked separately.

We did not relax the refinement timeout or reduce the point count. Instead, we transferred the complete derived assets to the local machine, verified that the bytes matched, and reran the test with the same parameters. Overview, close-up, visibility, and resource-release checks passed in a real browser, giving us evidence to distinguish the earlier wait from a rendering failure.

That changed how we thought about loading while viewing. Paging lets the browser use a subset of the data first. LOD lets the current view represent a scene with fewer nodes. Neither delivers missing pages on behalf of the network or does the GPU's rendering work. The complete model could be viewed up close in that run, but interaction performance still needed further work.

When a large model takes too long to become detailed, we now follow the actual sequence: have the required pages arrived, has selection finished, and has the new rendering data taken over? Those questions give us a more concrete next step than repeatedly changing the point budget.

Top comments (0)