Skip to content

Commit e5cc054

Browse files
authored
Render Gaussian splat shapes in ViewerViser (#4018)
1 parent 22bc4a7 commit e5cc054

2 files changed

Lines changed: 122 additions & 0 deletions

File tree

changelog/2099.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Render `newton.Gaussian` splat shapes in `ViewerViser`. `log_gaussian()` was previously a no-op there, so Gaussian splat assets silently vanished when viewing with `--viewer viser`. Local-space centers, covariances, colors, and opacities are uploaded to viser's native Gaussian splat renderer once per asset and cached; only the node's position and orientation are updated on subsequent frames.

newton/_src/viewer/viewer_viser.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class ViewerViser(ViewerBase):
3737
"""
3838

3939
_viser_module = None
40+
_SH_C0 = 0.28209479177387814
4041

4142
@classmethod
4243
def _get_viser(cls):
@@ -150,6 +151,7 @@ def __init__(
150151
self._meshes = {}
151152
self._instances = {}
152153
self._scene_handles = {} # Track viser scene node handles
154+
self._gaussian_splats = {} # Track cached Gaussian splat upload keys, by name
153155
self._line_segment_counts = {}
154156
self._line_versions = {}
155157

@@ -194,6 +196,16 @@ def clear_model(self):
194196
self._remove_plane_handles(plane_name)
195197
self._plane_meshes = {name: value for name, value in self._plane_meshes.items() if not owns(name)}
196198

199+
for gaussian_name in list(getattr(self, "_gaussian_splats", {}).keys()):
200+
if owns(gaussian_name):
201+
handle = self._scene_handles.pop(gaussian_name, None)
202+
if handle is not None:
203+
try:
204+
handle.remove()
205+
except Exception:
206+
pass
207+
self._gaussian_splats.pop(gaussian_name, None)
208+
197209
for name, handle in list(getattr(self, "_scene_handles", {}).items()):
198210
if not owns(name):
199211
continue
@@ -204,6 +216,7 @@ def clear_model(self):
204216
self._scene_handles.pop(name, None)
205217
self._instances.pop(name, None)
206218
self._meshes.pop(name, None)
219+
self._gaussian_splats.pop(name, None)
207220
self._line_segment_counts.pop(name, None)
208221
self._line_versions.pop(name, None)
209222

@@ -675,6 +688,7 @@ def log_mesh(
675688
self._scene_handles[name].remove()
676689
except Exception:
677690
pass
691+
del self._scene_handles[name]
678692

679693
if hidden:
680694
return
@@ -715,6 +729,25 @@ def _quats_xyzw_to_wxyz(quats_xyzw: np.ndarray) -> np.ndarray:
715729
quats_wxyz[:, 3] = quats_xyzw[:, 2]
716730
return quats_wxyz[0] if was_1d else quats_wxyz
717731

732+
@staticmethod
733+
def _quats_xyzw_to_rotmats(quats_xyzw: np.ndarray) -> np.ndarray:
734+
"""Convert a batch of XYZW quaternions to (N, 3, 3) rotation matrices."""
735+
quats_xyzw = np.asarray(quats_xyzw, dtype=np.float32)
736+
quats_xyzw = quats_xyzw / np.maximum(np.linalg.norm(quats_xyzw, axis=1, keepdims=True), 1e-12)
737+
x, y, z, w = quats_xyzw[:, 0], quats_xyzw[:, 1], quats_xyzw[:, 2], quats_xyzw[:, 3]
738+
n = quats_xyzw.shape[0]
739+
rot = np.empty((n, 3, 3), dtype=np.float32)
740+
rot[:, 0, 0] = 1.0 - 2.0 * (y * y + z * z)
741+
rot[:, 0, 1] = 2.0 * (x * y - w * z)
742+
rot[:, 0, 2] = 2.0 * (x * z + w * y)
743+
rot[:, 1, 0] = 2.0 * (x * y + w * z)
744+
rot[:, 1, 1] = 1.0 - 2.0 * (x * x + z * z)
745+
rot[:, 1, 2] = 2.0 * (y * z - w * x)
746+
rot[:, 2, 0] = 2.0 * (x * z - w * y)
747+
rot[:, 2, 1] = 2.0 * (y * z + w * x)
748+
rot[:, 2, 2] = 1.0 - 2.0 * (x * x + y * y)
749+
return rot
750+
718751
def _remove_plane_handles(self, name: str):
719752
"""Remove any plane-grid handles associated with an instance batch."""
720753
handle = self._plane_handles.pop(name, None)
@@ -1265,6 +1298,7 @@ def log_points(
12651298
self._scene_handles[name].remove()
12661299
except Exception:
12671300
pass
1301+
del self._scene_handles[name]
12681302

12691303
if hidden:
12701304
return
@@ -1313,6 +1347,93 @@ def log_points(
13131347
)
13141348
self._scene_handles[name] = handle
13151349

1350+
@override
1351+
def log_gaussian(
1352+
self,
1353+
name: str,
1354+
gaussian: newton.Gaussian | None,
1355+
xform: wp.transformf | None = None,
1356+
hidden: bool = False,
1357+
):
1358+
"""
1359+
Log a :class:`newton.Gaussian` splat asset using viser's native Gaussian renderer.
1360+
1361+
Note: viser's ``add_gaussian_splats`` is marked experimental upstream and its
1362+
API may change or be removed in a future viser release.
1363+
1364+
Args:
1365+
name: Unique path/name for the Gaussian splat asset.
1366+
gaussian: The :class:`newton.Gaussian` asset to visualize, with centers and
1367+
per-axis scales in meters [m]. ``None`` removes any existing asset at ``name``.
1368+
xform: Optional world-space transform applied to the splat asset; its
1369+
translation component is in meters [m].
1370+
hidden: Whether the splat asset should be hidden.
1371+
"""
1372+
name = self._qualify(name)
1373+
1374+
if gaussian is None or gaussian.count == 0:
1375+
if name in self._scene_handles:
1376+
try:
1377+
self._scene_handles[name].remove()
1378+
except Exception:
1379+
pass
1380+
self._scene_handles.pop(name, None)
1381+
self._gaussian_splats.pop(name, None)
1382+
return
1383+
1384+
if hidden:
1385+
if name in self._scene_handles:
1386+
self._scene_handles[name].visible = False
1387+
return
1388+
1389+
cached = self._gaussian_splats.get(name)
1390+
1391+
if (
1392+
cached is None
1393+
or cached["gaussian"] is not gaussian
1394+
or cached["count"] != gaussian.count
1395+
or self._scene_handles.get(name) is not cached["handle"]
1396+
):
1397+
centers = self._to_numpy(gaussian.positions).astype(np.float32)
1398+
rotations_xyzw = self._to_numpy(gaussian.rotations).astype(np.float32)
1399+
scales = self._to_numpy(gaussian.scales).astype(np.float32)
1400+
opacities = self._to_numpy(gaussian.opacities).astype(np.float32).reshape(-1, 1)
1401+
1402+
# Local-space covariance per Gaussian: Sigma = R * diag(scale^2) * R^T
1403+
rot_mats = self._quats_xyzw_to_rotmats(rotations_xyzw)
1404+
scale_sq = scales * scales
1405+
covariances = np.einsum("nij,nj,nkj->nik", rot_mats, scale_sq, rot_mats).astype(np.float32)
1406+
1407+
sh_coeffs = self._to_numpy(gaussian.sh_coeffs)
1408+
if sh_coeffs is not None and sh_coeffs.shape[1] >= 3:
1409+
rgbs = np.clip(self._SH_C0 * sh_coeffs[:, :3] + 0.5, 0.0, 1.0).astype(np.float32)
1410+
else:
1411+
rgbs = np.ones((gaussian.count, 3), dtype=np.float32)
1412+
1413+
if cached is not None and name in self._scene_handles:
1414+
try:
1415+
self._scene_handles[name].remove()
1416+
except Exception:
1417+
pass
1418+
1419+
handle = self._call_scene_method(
1420+
self._server.scene.add_gaussian_splats,
1421+
name=name,
1422+
centers=centers,
1423+
covariances=covariances,
1424+
rgbs=rgbs,
1425+
opacities=opacities,
1426+
)
1427+
self._scene_handles[name] = handle
1428+
self._gaussian_splats[name] = {"gaussian": gaussian, "count": gaussian.count, "handle": handle}
1429+
1430+
handle = self._scene_handles[name]
1431+
handle.visible = True
1432+
if xform is not None:
1433+
xform_np = np.asarray(xform, dtype=np.float32)
1434+
handle.position = xform_np[:3]
1435+
handle.wxyz = self._quats_xyzw_to_wxyz(xform_np[3:7])
1436+
13161437
@override
13171438
def log_array(self, name: str, array: wp.array[Any] | np.ndarray):
13181439
"""Viser viewer does not visualize generic arrays.

0 commit comments

Comments
 (0)