← Projects
From Reconstruction to Visual Twin Part 2 of 3

Building SplatStage: From Edited Gaussians to OpenUSD

My Gaussian editor could remove a million splats, show the cleaned result and save a new PLY. The OpenUSD export still contained the original scene.

The editor was working. The exporter was working. The product was wrong.

The cause was a boundary left over from reconstruction. The existing exporter read the trainer’s checkpoint, while editing happened later against the exported PLY. No amount of work in the editor could affect an exporter that never read its result.

That seam became the center of SplatStage, the application I built after ReconStudio. ReconStudio produces a Gaussian reconstruction and OpenUSD asset from camera media. SplatStage starts with the PLY and lets a user inspect and edit it without destroying earlier versions. It exports the selected version directly into an OpenUSD Gaussian particle field, then composes that capture with replaceable engineered USD assets.

Part 1 explains why I call the result a visual-ready twin foundation rather than a finished digital twin. This article covers how I built it. Part 3 puts the whole workflow under load with a 5.8-million-Gaussian Garden scene.

Why this became a separate application

At first I considered extending ReconStudio. The two tools touch the same data, but their work has a different shape.

ReconstructionEditing and composition
mostly sequentialdeliberately nonlinear
camera media is the sourcea completed PLY is the source
expensive stages run for tens of minutesmany operations complete in seconds or a few minutes
success produces an assetsuccess produces versions, decisions and a stage
a job usually has one terminal output seta user may branch, undo, compare and return later

ReconStudio is a job pipeline, while SplatStage is a stateful workbench; combining them would have made the reconstruction job responsible for a long-lived editing history and made each editor experiment look like another pipeline run.

I kept the handoff ordinary: a PLY file and its evidence. That also makes SplatStage useful for Gaussian splats created by another reconstruction tool.

The workflow has seven visible steps

The interface is organized around the decisions needed to turn a reconstruction into a composed stage:

  1. Asset: open the PLY, verify its layout and create the initial project version.
  2. Inspect: calculate robust bounds, opacity statistics and shape diagnostics before editing.
  3. Clean: preview and apply filters for floaters, elongated Gaussians, transparency or density.
  4. Remove: define an intentional region, count the affected Gaussians and create another version.
  5. Export & scale: establish up, optionally calibrate a known distance, and write the selected version as OpenUSD.
  6. Compose: add existing USD assets as references and adjust their transforms.
  7. The twin: reopen the composed USD stage, inspect its structure and package its dependencies.

The step rail shows the current stage of the work. The version tree and recorded operations keep the earlier choices available for inspection. The numbered list is tidy, but it hides how much state each screen has to hold. The walkthrough below shows what I wanted a user to know before moving from one stage to the next.

These are not mockups. The captures come from an earlier Garden pass through the working application; Part 3 reports the final measured run, so the intermediate counts in these screens are not presented as the final benchmark. I kept the controls, evidence and viewport together because the experience is part of the build. Open any capture to inspect it at full size.

1. Asset: know what is entering the project

The first screen is an inventory rather than a generic upload box. Before importing anything, I can see that the selected Garden PLY contains 5.83 million Gaussians, uses spherical-harmonic degree 3 and occupies 1,447 MB. That matters when the same folder contains small test scenes, benchmark assets and several copies of a large reconstruction.

Import creates version zero inside the project and leaves the source file alone. It also records the declared PLY property layout. From this point forward, SplatStage works against named project versions rather than an anonymous file that might be overwritten outside the application.

SplatStage Asset step showing the seven-stage rail and an asset inventory with the Garden PLY selected at 5.83 million Gaussians, spherical-harmonic degree 3 and 1,447 MB.
Asset. The first decision is which source becomes the immutable starting version. Count, representation and file size are visible before import.

2. Inspect: put measurements beside the scene

The inspection screen combines the full scene with robust bounds, opacity statistics and a distribution of Gaussian shapes. I did not want those numbers hidden in a report that a user reads before or after looking at the scene. They need to be interpreted together.

For example, the screen reports many disc-like Gaussians. That is descriptive, not an automatic diagnosis: surface-aligned discs can be a healthy part of a reconstruction. The elongated tail is a useful candidate for closer inspection, but a large number beside a label is not permission to delete it. This stage helps define a selection; it does not pretend that one preset can understand every scene.

SplatStage Inspect step with immutable version zero, Gaussian diagnostics, an elongation histogram and the Garden scene in the live viewport.
Inspect. Version zero, the diagnostic summary, shape distribution and live scene stay in one view. The numbers describe the asset before an edit changes it.

3. Clean: make a statistical edit traceable

Once I understand the distribution, the Clean step exposes the available cleanup and compression operations. A preview first calculates how many Gaussians match the current predicate. Apply then creates a child version and adds a durable entry to the activity record; it does not replace the parent.

In the pass shown here, isolating the connected core removed 1,990,933 Gaussians, or 34.1 percent of the source, and reduced the PLY from 1,447 MB to 953 MB. Those figures sit beside the cleaned viewport because the result needs both forms of evidence. The image shows whether the scene still looks coherent, while the operation record shows what actually happened to the data.

SplatStage Clean step showing cleanup and compression operations, the cleaned Garden scene, and an activity log recording the isolate-core result.
Clean. The operation catalogue, output and activity record share the screen. The selected predicate and the resulting version remain inspectable after the operation completes.

4. Remove: separate deliberate editing from cleanup

Clean handles statistical structure. Remove handles an intentional region in the scene. The user can position a box, cylinder or plane, combine that geometry with data predicates and count the affected Gaussians before committing the operation. The comparison panel keeps the source and current versions visible while the live viewport shows the proposed region.

I kept this step explicit because the application does not know that a group of Gaussians represents a table, a person or background clutter. The user supplies that intent. Removing the selection also exposes whatever the original camera views failed to reconstruct behind it; the application records the edit but does not claim to have inpainted a missing surface.

SplatStage Remove step comparing the original and cleaned versions while showing region-selection controls and a live Garden preview before an intentional removal.
Remove. Comparison, region controls and preview count make an intentional removal visible before Apply creates another immutable version.

5. Export and scale: keep uncertainty visible

This screen carries two decisions that are easy to collapse into one: orientation and metric scale. A fitted plane can establish which way is up. A known-length measurement can establish how relative PLY coordinates relate to real distance. One does not prove the other.

The warning in the capture is deliberate: scene scale is still unknown. SplatStage can record the two points and known distance needed to derive it, but it does not silently treat one reconstruction unit as one meter. The same screen makes the selected version the input to direct ParticleField export, then creates the small wrapper stage that references the capture. Making that input visible was important after I found the earlier exporter reading a reconstruction checkpoint instead of the edited PLY.

SplatStage Export and scale step showing an explicit unknown-scale warning, known-length measurement controls, direct selected-version export and wrapper-stage creation beside the Garden viewport.
Export & scale. Unverified scale remains visible while the selected edit version moves through direct OpenUSD export and into a reference-based wrapper stage.

6. Compose: keep engineered assets replaceable

The composition menu contains assets that already exist as USD or mesh content: a beacon, pallet, crate, forklift, robot, vehicle and simple people, among others. SplatStage is not a native STEP or CAD importer. An asset may have originated in CAD, but it has to cross that translation boundary before this application references it.

The edited splat remains one layer and each added asset remains another. Placement controls change the referenced part’s transform without rewriting the large Gaussian field. The simple stand-ins helped expose an unresolved issue rather than conceal it: the people looked oversized while metric calibration was still open. That was useful feedback from the composed view, not something to correct by eye and forget.

SplatStage Compose step with a menu of CAD-derived and mesh assets, placement controls and the edited Garden splat in the source-labelled live viewport.
Compose. Existing USD and mesh assets are placed as separate references. The viewport identifies the edited PLY as its source while composition is still in progress.

7. The twin: read the authored stage back

The final step changes the source of the evidence. Instead of rendering the working PLY again, USD mode opens the wrapper stage, follows the capture reference and reads the Gaussian particle field back for the viewport. The scene graph shows the capture and added assets beside the version lineage that produced them.

That readback catches failures a polished preview can hide. A stale export or broken reference can look fine in Live mode and disappear here. What this screen establishes is narrower than a simulation-ready twin: the edited capture survived export, the wrapper resolves and the scene remains layered and inspectable. Colliders, behavior, operational connections and verified metric scale are later work.

SplatStage final step showing version lineage, USD viewport mode, the OpenUSD scene graph and the composed Garden scene read back from the wrapper stage.
The twin. Version lineage, the OpenUSD scene graph and USD readback appear together. The screen makes the source of the final view explicit.

One project record, two loops and an export seam

The local application uses a FastAPI service and a browser interface. I kept persistence on the filesystem instead of adding a database, so the project record, immutable PLY versions, operation lineage, exports, parts and package manifests all live beside one another on disk.

I used that file layout to separate editing from composition:

SplatStage architecture with an edit loop, export seam and composition loop. Immutable PLY versions flow through selection and edit operations into a direct PLY-to-OpenUSD Gaussian ParticleField exporter. A wrapper scene references the capture and separate engineered assets. Four viewport modes identify whether they show a browser proxy, server Gaussian render, OpenUSD readback or geometry diagnostic.
The selected PLY is the contract between editing and export. The wrapper stage then composes the exported capture with replaceable OpenUSD assets.

The test host was bigger than the app required

I ran the Garden walkthrough from a browser on my Mac, connected through an SSH tunnel to a temporary remote Linux host. I captured the host configuration on 21 August 2026 before releasing it. That record corrected an assumption I had nearly written into the project notes: the application used an L40S, but the L40S was not running the entire application.

The deployed SplatStage architecture used for the Garden run. A browser on a Mac connects through an SSH tunnel to FastAPI on a remote Ubuntu host. The browser GPU draws the WebGL navigation proxy. NumPy and memory-mapped PLY files provide the CPU editing path. usd-core authors and reads OpenUSD, while Torch and gsplat optionally use the L40S for the full server render. The filesystem retains versions, lineage, exports, packages and snapshots.
The live viewport used the browser's GPU. Editing and diagnostics followed the CPU path; OpenUSD authoring used usd-core; only the full server render used Torch, gsplat and the L40S.
Captured layerTested configurationWhat used it
HostUbuntu 24.04.4 LTS, x86_64remote service and files
CPU and memoryAMD EPYC-Genoa, 16 vCPU, 62 GB RAMimport, diagnostics, selection, edits and projection
Disk at capture242 GB root, 136 GB freecomplete PLY version copies, USD exports and packages
GPUNVIDIA L40S, 46,068 MiB; driver 595.80optional full-quality server Gaussian render
Application interpreterPython 3.11.16service process, using the existing 3DGRUT virtual environment
Core web and data stackFastAPI 0.141.1, Uvicorn 0.52.3, NumPy 2.4.6API, operations and data access
OpenUSD pathusd-core 26.8ParticleField authoring, stage inspection and readback
GPU render pathTorch 2.8.0+cu128, gsplat 1.5.3server Render mode

The environment name was misleading. 3DGRUT is the NVIDIA-developed library in this workflow, from NVIDIA’s Spatial Intelligence Lab. ReconStudio uses it for training and checkpoint-to-OpenUSD export, and SplatStage retains an optional endpoint for the same exporter. The edited Garden path did not call it: Garden arrived as an INRIA pretrained PLY, and SplatStage wrote v2 through its own PLY-to-ParticleField adapter using usd-core.

The optional server Render mode used gsplat, a Nerfstudio and multi-institution open-source project with NVIDIA among its contributors; I therefore do not call the library itself an NVIDIA library. OpenUSD, usd-core, PyTorch, FastAPI, Uvicorn and NumPy are also upstream projects. NVIDIA supplied the L40S, driver and CUDA runtime for the optional GPU render. For this demonstration, the existing 3DGRUT environment happened to supply pxr, Torch and gsplat alongside the ordinary SplatStage dependencies. A fresh CPU host can still support editing and the browser viewport, but OpenUSD export needs usd-core; Render mode additionally needs Torch, gsplat and a CUDA GPU.

Disk matters sooner than GPU memory in the current design. Every edit materializes another complete PLY instead of a delta. The Garden asset occupied about 3.3 GB across v0, v1 and v2, and the captured host held 62 GB across 22 assets and their versions. For this implementation, I would size storage from the expected number of assets and versions rather than from one source file.

The current build exposes seven edit operations and fourteen composable selection predicates. The count is less important than the separation between the two:

selection:  outside robust bounds
operation:  isolate the core

selection:  inside a user-placed cylinder
operation:  crop the selection

selection:  low opacity AND large scale
operation:  prune selected Gaussians

A selection answers which Gaussians. An operation answers what to do with them. Boolean predicates let a geometric region combine with opacity, percentile, size or shape tests without creating a bespoke endpoint for every editing recipe.

I made the API calculate the selection count before applying an operation. That preview is a safety mechanism, especially when one operation can remove hundreds of thousands of records. Each committed edit writes a new PLY and lineage entry without modifying its parent, so undo means selecting an earlier version rather than trying to reverse a destructive calculation.

For large files, the service memory-maps the PLY fields and processes them in chunks. The browser receives a sampled proxy for interactive navigation, while export and server rendering continue to use the full selected version. In the Garden project, the proxy contained 4.43 million of the original 5.83 million Gaussians. It was still large, but it avoided pretending that a sampled viewport was the full asset.

The export seam had to become explicit

The first exporter path called NVIDIA’s 3DGRUT. It transformed a reconstruction checkpoint into the OpenUSD representation produced at the end of training. That was appropriate inside ReconStudio and unusable after PLY editing.

I briefly tried to rebuild the checkpoint state from a PLY. The file sizes made the danger look smaller than it was: the PLY record and exported field both consumed roughly the expected number of bytes per Gaussian. Matching width did not prove matching field order, data type, quaternion convention or spherical-harmonic layout.

The decisive evidence came from the OpenUSD schema. ParticleField3DGaussianSplat describes the fields and their types. The edited PLY already carried the positions, scales, rotations, opacity and spherical-harmonic coefficients I needed. The reliable path was a direct writer whose contract was:

selected PLY
  → parse declared PLY properties
  → map each property to the schema field and type
  → author ParticleField3DGaussianSplat
  → reopen the package and verify count, fields and stage structure
A field-by-field mapping from the selected INRIA Gaussian PLY to an OpenUSD ParticleField. Positions pass through, log scales are exponentiated, opacity logits are converted with a sigmoid, quaternions are normalized, and spherical-harmonic coefficients use calibrated ordering. The writer reads attribute types from the ParticleField3DGaussianSplat schema and verifies count, typed readback, round trip and stage reopen.
The adapter owns the representation-specific conventions. The schema owns the authored types, and the checks reconnect the resulting ParticleField to the selected edit version.

That decision did two things at once: it made the selected edit version the actual input to export, and it isolated any representation-specific mapping in one small boundary.

I also added a round-trip check: export PLY to USDZ, read the particle field back to PLY, and export it again. A round trip cannot prove that the scene is visually correct, but it can expose count changes, missing arrays and incompatible field layouts before a user reaches the final stage.

I could test the editor and exporter separately and still produce the wrong scene. The assertion I needed was end to end: the named PLY version selected in the application goes in, and the same record count must come out of the particle field.

Orientation and scale are evidence, not toggles

After cleanup, I designed the next step to establish a stage frame rather than merely set two metadata fields.

In the Garden run, I selected points on a surface that should be horizontal. SplatStage fitted a plane, let me flip its normal and calculated the transform that rotates it toward the stage up axis. The recorded final normal was approximately [0.038, -0.612, -0.790] after I flipped the initial fit.

For scale, I built a two-point measurement that records the scene distance, entered real distance and derived scale factor. I left that step for a later run while I finished the structural path, so the stage declared a unit convention while the project marked metric calibration as unverified.

This is intentionally stricter than a scene-setting control. upAxis = "Z" and metersPerUnit = 1 are OpenUSD metadata. They describe how downstream tools should interpret the stage. The operation record explains why the capture transform deserves to be interpreted that way.

Composition stays layered

SplatStage writes a wrapper scene.usda rather than flattening the capture and every added asset into one opaque file. The structure keeps three responsibilities separate:

/World/Capture   reference to the exported Gaussian USDZ
/World/Assets    references to added USD assets
/World/Physics   reserved scope for authored physical layers

I kept this build’s import boundary at existing OpenUSD assets. They may be CAD-derived, but SplatStage is not a native STEP or CAD importer. In the Garden demonstration I used two person stand-ins and a beacon, all already expressed as USD. The value being tested was placement and composition, not CAD translation.

Each reference remains independently replaceable. I could adjust a part transform or swap its source without rewriting the 888.5 MB Gaussian capture. The wrapper stage is small enough to inspect directly, and the package builder collects the referenced files with relative paths.

I chose an inspectable folder and zip as the delivery shape for this working project. That keeps the wrapper, capture and parts visible and replaceable. OpenUSD’s USDZ specification supports packaged assets, including packages that contain other packages; the folder/zip choice here is a project decision about inspectability, not a format prohibition.

The packager reopens its copied stage after rewriting the references and reports anything missing. That proves the delivered dependency graph is self-contained on the authoring host. I have not yet opened it in a second OpenUSD runtime, so cross-runtime support remains a separate claim.

A viewport needs to say what it is showing

SplatStage has four viewport modes because “the scene” can refer to different data and different renderers:

ModeSourceRendererWhat it proves
Liveselected PLY, sampledbrowser WebGLthe editing interaction is responsive
Renderselected PLY, fullserver Gaussian rendererthe full working splat produces the expected view
USDcomposed scene.usda, sampled after readbackbrowser WebGLthe final view comes from the exported stage, not the working PLY
Geometryselected PLYorthographic point/shape diagnosticthe selection and scene extent make geometric sense

That label is part of the evidence. Without it, a final-looking image could still be a preview of pre-export data. The USD mode opens the wrapper stage, follows the capture reference, reads the particle field and then builds its browser sample. It is not an external renderer, but it catches a class of broken-reference and stale-export failures the live view cannot.

The current end-to-end experience

The recording below condenses the four-part Garden run into 82 seconds. It follows the actual interface from inspection through statistical cleanup, intentional object removal, OpenUSD export, orientation, asset placement and final USD readback.

An 82-second cut from four source recordings. The viewport moves from the working PLY to the composed OpenUSD readback; the editing and export operations are the real Garden project.

Long operations return an accepted response with an operation ID. The server keeps their in-flight status in memory, and the browser polls it. A page refresh can reconnect while the server process remains alive. When an operation finishes, its durable result lands in the version lineage or export files on disk. A server restart would lose the transient progress record, though completed artifacts would remain. That is adequate for this single-user workbench and is not a substitute for a durable job queue.

Each visible step can also write a self-contained HTML evidence snapshot. It embeds the current render rather than linking to it, presents the important state as a readable table, retains the cumulative activity log, and stores the same state as JSON in a snapshot-state script block. The file fetches nothing, can be opened after the application has stopped, and can be printed to PDF.

A screenshot answers what did the viewport look like? The snapshot also answers which asset and version was open, what produced the view, how much data was drawn, and what stage state existed at that moment?

Packaging gets its own record. The manifest lists the root stage, dependencies, file sizes and missing references. Portability is checked by reopening the moved stage and resolving the files it names.

Where this build stops

SplatStage carries one selected edit version through ParticleField export, layered composition, readback and dependency-complete packaging. It does not establish metric scale, physics, live operational state or support in another runtime. Part 1 owns that readiness boundary; this build only records which side of it each artifact reached.

The code is at pr9868/splatstage. It is an experimental alpha: local-only, no authentication, and it expects you to validate any exported stage in your own OpenUSD application rather than trusting mine.

In Part 3, I examine the Garden run in detail: the 34% cleanup, the object-removal scar, the uncalibrated scale, the 25-prim final stage, the dependency-complete package and the checks that caught the most convincing mistakes.

Disclaimer: The views and opinions expressed in this account are those of my own and do not represent those of my employer, NVIDIA.

← All projects