diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 6ec2a4655..0baac227c 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -300,6 +300,8 @@ class GlbProps: """Whether to receive shadows. If True, receives shadows normally. If False, no shadows. If a float (0-1), shadows are rendered with a fixed opacity regardless of lighting conditions. """ + opacity: Optional[float] = None + """Opacity of the mesh. None means opaque.""" scale: Union[float, Tuple[float, float, float]] = 1.0 """A scale for resizing the GLB asset. A single float for uniform scaling or a tuple of (x, y, z) for per-axis scaling.""" @@ -934,6 +936,10 @@ class BatchedGlbProps(_BatchedMeshExtraProps): """Whether or not to cast shadows.""" receive_shadow: bool """Whether or not to receive shadows.""" + opacity: Optional[float] = None + """Opacity of the mesh. None means opaque.""" + batched_opacities: Optional[npt.NDArray[np.float32]] = None + """Per-instance opacity multipliers, shape (N,). Multiplied with global opacity.""" scale: Union[float, Tuple[float, float, float]] = 1.0 """Scale of the batched GLB. A single float for uniform scaling or a tuple of (x, y, z) for per-axis scaling.""" diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index d876e8ae1..654905187 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -673,6 +673,7 @@ def add_glb( wxyz: tuple[float, float, float, float] | np.ndarray = (1.0, 0.0, 0.0, 0.0), position: tuple[float, float, float] | np.ndarray = (0.0, 0.0, 0.0), visible: bool = True, + opacity: float | None = None, cast_shadow: bool = True, receive_shadow: bool | float = True, ) -> GlbHandle: @@ -693,6 +694,7 @@ def add_glb( wxyz: Quaternion rotation to parent frame from local frame (R_pl). position: Translation to parent frame from local frame (t_pl). visible: Whether or not this scene node is initially visible. + opacity: Opacity of the mesh. None means opaque. cast_shadow: Whether this node should cast shadows. receive_shadow: Whether this node should receive shadows. If True, receives shadows normally. If False, no shadows. If a float @@ -708,6 +710,7 @@ def add_glb( glb_data=glb_data, cast_shadow=cast_shadow, receive_shadow=receive_shadow, + opacity=opacity, scale=scale, ), ) @@ -1660,6 +1663,7 @@ def add_mesh_trimesh( wxyz: tuple[float, float, float, float] | np.ndarray = (1.0, 0.0, 0.0, 0.0), position: tuple[float, float, float] | np.ndarray = (0.0, 0.0, 0.0), visible: bool = True, + opacity: float | None = None, cast_shadow: bool = True, receive_shadow: bool | float = True, ) -> GlbHandle: @@ -1674,6 +1678,7 @@ def add_mesh_trimesh( wxyz: Quaternion rotation to parent frame from local frame (R_pl). position: Translation to parent frame from local frame (t_pl). visible: Whether or not this scene node is initially visible. + opacity: Opacity of the mesh. None means opaque. cast_shadow: Whether this mesh should cast shadows. receive_shadow: Whether this mesh should receive shadows. If True, receives shadows normally. If False, no shadows. If a float @@ -1694,6 +1699,7 @@ def add_mesh_trimesh( wxyz=wxyz, position=position, visible=visible, + opacity=opacity, cast_shadow=cast_shadow, receive_shadow=receive_shadow, ) @@ -1827,7 +1833,9 @@ def add_batched_meshes_trimesh( batched_positions: tuple[tuple[float, float, float], ...] | np.ndarray, *, batched_scales: tuple[float, ...] | np.ndarray | None = None, + batched_opacities: tuple[float, ...] | np.ndarray | None = None, lod: Literal["auto", "off"] | tuple[tuple[float, float], ...] = "auto", + opacity: float | None = None, cast_shadow: bool = True, receive_shadow: bool = True, scale: float | tuple[float, float, float] = 1.0, @@ -1851,7 +1859,11 @@ def add_batched_meshes_trimesh( batched_wxyzs: Float array of shape (N, 4) for orientations. batched_positions: Float array of shape (N, 3) for positions. batched_scales: Float array of shape (N,) for uniform scales or (N,3) for per-axis (XYZ) scales. None means scale of 1.0. + batched_opacities: Per-instance opacity multipliers, shape (N,). Each value is + multiplied with the global opacity parameter. None means all instances use + the global opacity. lod: LOD settings, either "off", "auto", or a tuple of (distance, ratio) pairs. + opacity: Opacity of the meshes. None means opaque. cast_shadow: Whether these meshes should cast shadows. receive_shadow: Whether these meshes should receive shadows. wxyz: Quaternion rotation to parent frame from local frame (R_pl). @@ -1872,6 +1884,10 @@ def add_batched_meshes_trimesh( batched_scales = np.asarray(batched_scales).astype(np.float32) assert batched_scales.shape in ((num_instances,), (num_instances, 3)) + if batched_opacities is not None: + batched_opacities = np.asarray(batched_opacities).astype(np.float32) + assert batched_opacities.shape == (num_instances,) + with io.BytesIO() as data_buffer: mesh.export(data_buffer, file_type="glb") glb_data = data_buffer.getvalue() @@ -1883,6 +1899,8 @@ def add_batched_meshes_trimesh( batched_positions=batched_positions.astype(np.float32), batched_scales=batched_scales, lod=lod, + opacity=opacity, + batched_opacities=batched_opacities, cast_shadow=cast_shadow, receive_shadow=receive_shadow, scale=scale, @@ -1899,7 +1917,9 @@ def add_batched_glb( batched_positions: tuple[tuple[float, float, float], ...] | np.ndarray, *, batched_scales: tuple[float, ...] | np.ndarray | None = None, + batched_opacities: tuple[float, ...] | np.ndarray | None = None, lod: Literal["auto", "off"] | tuple[tuple[float, float], ...] = "auto", + opacity: float | None = None, cast_shadow: bool = True, receive_shadow: bool = True, scale: float | tuple[float, float, float] = 1.0, @@ -1923,7 +1943,11 @@ def add_batched_glb( batched_wxyzs: Float array of shape (N, 4) for orientations. batched_positions: Float array of shape (N, 3) for positions. batched_scales: Float array of shape (N,) for uniform scales or (N,3) for per-axis (XYZ) scales. None means scale of 1.0. + batched_opacities: Per-instance opacity multipliers, shape (N,). Each value is + multiplied with the global opacity parameter. None means all instances use + the global opacity. lod: LOD settings, either "off", "auto", or a tuple of (distance, ratio) pairs. + opacity: Opacity of the meshes. None means opaque. cast_shadow: Whether these GLB assets should cast shadows. receive_shadow: Whether these GLB assets should receive shadows. scale: Scale of the batched GLB. A single float for uniform @@ -1946,6 +1970,10 @@ def add_batched_glb( batched_scales = np.asarray(batched_scales).astype(np.float32) assert batched_scales.shape in ((num_instances,), (num_instances, 3)) + if batched_opacities is not None: + batched_opacities = np.asarray(batched_opacities).astype(np.float32) + assert batched_opacities.shape == (num_instances,) + message = _messages.BatchedGlbMessage( name=name, props=_messages.BatchedGlbProps( @@ -1954,6 +1982,8 @@ def add_batched_glb( batched_positions=batched_positions.astype(np.float32), batched_scales=batched_scales, lod=lod, + opacity=opacity, + batched_opacities=batched_opacities, cast_shadow=cast_shadow, receive_shadow=receive_shadow, scale=scale, diff --git a/src/viser/client/package-lock.json b/src/viser/client/package-lock.json index fa8acd984..a4ae0467e 100644 --- a/src/viser/client/package-lock.json +++ b/src/viser/client/package-lock.json @@ -1075,18 +1075,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2924,14 +2912,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/bvh.js": { "version": "0.0.13", "resolved": "https://registry.npmjs.org/bvh.js/-/bvh.js-0.0.13.tgz", @@ -4918,18 +4898,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8243,29 +8211,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -8519,34 +8464,6 @@ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "license": "MIT" }, - "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/src/viser/client/src/WebsocketMessages.ts b/src/viser/client/src/WebsocketMessages.ts index ea809a185..793e93438 100644 --- a/src/viser/client/src/WebsocketMessages.ts +++ b/src/viser/client/src/WebsocketMessages.ts @@ -33,6 +33,7 @@ export interface GlbMessage { glb_data: Uint8Array; cast_shadow: boolean; receive_shadow: boolean | number; + opacity: number | null; scale: number | [number, number, number]; }; } @@ -387,6 +388,8 @@ export interface BatchedGlbMessage { glb_data: Uint8Array; cast_shadow: boolean; receive_shadow: boolean; + opacity: number | null; + batched_opacities: Float32Array | null; scale: number | [number, number, number]; }; } diff --git a/src/viser/client/src/mesh/BatchedGlbAsset.tsx b/src/viser/client/src/mesh/BatchedGlbAsset.tsx index 96e10567d..eccf58423 100644 --- a/src/viser/client/src/mesh/BatchedGlbAsset.tsx +++ b/src/viser/client/src/mesh/BatchedGlbAsset.tsx @@ -69,8 +69,8 @@ export const BatchedGlbAsset = React.forwardRef< batched_wxyzs={message.props.batched_wxyzs} batched_scales={message.props.batched_scales} batched_colors={null} - opacity={null} - batched_opacities={null} + opacity={message.props.opacity} + batched_opacities={message.props.batched_opacities} lod={message.props.lod} cast_shadow={message.props.cast_shadow} receive_shadow={message.props.receive_shadow} diff --git a/src/viser/client/src/mesh/BatchedMeshBase.tsx b/src/viser/client/src/mesh/BatchedMeshBase.tsx index 8ca8242f1..581a8fcf0 100644 --- a/src/viser/client/src/mesh/BatchedMeshBase.tsx +++ b/src/viser/client/src/mesh/BatchedMeshBase.tsx @@ -107,6 +107,20 @@ function createLODs( return { geometries, materials }; } +/** Collect all materials from a mesh and its LOD levels. */ +function getAllMaterials(mesh: InstancedMesh2): THREE.Material[] { + const out: THREE.Material[] = []; + const push = (m: THREE.Material | THREE.Material[]) => { + if (Array.isArray(m)) out.push(...m); + else out.push(m); + }; + push(mesh.material); + if (mesh.LODinfo && mesh.LODinfo.objects) { + mesh.LODinfo.objects.forEach((obj) => push(obj.material)); + } + return out; +} + /** * Shared base component for batched mesh rendering * @@ -354,9 +368,22 @@ export const BatchedMeshBase = React.forwardRef< } }, [props.batched_colors, mesh]); + // Apply global opacity to the material when no per-instance opacities are set. + React.useEffect(() => { + if (mesh === null || props.batched_opacities !== null) return; + + const opacity = props.opacity ?? 1.0; + const transparent = opacity < 1.0; + + for (const mat of getAllMaterials(mesh)) { + mat.opacity = opacity; + mat.transparent = transparent; + mat.depthWrite = !transparent; + mat.needsUpdate = true; + } + }, [props.opacity, props.batched_opacities, mesh]); + // Update per-instance opacity when batched_opacities is provided. - // When only global opacity is set (no batched_opacities), it's handled - // by the material's opacity property directly - more efficient. React.useEffect(() => { if (mesh === null || props.batched_opacities === null) return; @@ -370,6 +397,12 @@ export const BatchedMeshBase = React.forwardRef< return; } + for (const mat of getAllMaterials(mesh)) { + mat.transparent = true; + mat.depthWrite = false; + mat.needsUpdate = true; + } + const opacityView = new DataView( props.batched_opacities.buffer, props.batched_opacities.byteOffset, diff --git a/src/viser/client/src/mesh/SingleGlbAsset.tsx b/src/viser/client/src/mesh/SingleGlbAsset.tsx index 7f64048b8..2bc5cbba7 100644 --- a/src/viser/client/src/mesh/SingleGlbAsset.tsx +++ b/src/viser/client/src/mesh/SingleGlbAsset.tsx @@ -33,6 +33,28 @@ export const SingleGlbAsset = React.forwardRef< }); }, [gltf, message.props.cast_shadow, message.props.receive_shadow]); + // Apply opacity to all materials in the GLB. + React.useEffect(() => { + if (!gltf) return; + + const opacity = message.props.opacity ?? 1.0; + const transparent = opacity < 1.0; + + gltf.scene.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + const materials = Array.isArray(obj.material) + ? obj.material + : [obj.material]; + for (const mat of materials) { + mat.opacity = opacity; + mat.transparent = transparent; + mat.depthWrite = !transparent; + mat.needsUpdate = true; + } + } + }); + }, [gltf, message.props.opacity]); + // Update animations on each frame if mixer exists. useFrame((_, delta: number) => { mixerRef.current?.update(delta);