Reuse existing visual material in randomization - #395
Conversation
Design for randomize_visual_material to reuse the object's existing dexsim-parsed material (instance-swap + pre-created textures) instead of creating a new material each time. Preserves asset original appearance (three-tier: original/library/solid) and removes per-reset material creation and clean_materials overhead. Old behavior retained behind a fallback_to_new flag. Co-Authored-By: Claude <noreply@anthropic.com>
7-task TDD plan for instance-swap + pre-created-texture reuse path in randomize_visual_material, with fallback_to_new legacy preservation. Co-Authored-By: Claude <noreply@anthropic.com>
Pre-flight fix: solid/library tier logic was duplicated between rigid and articulation paths. Helpers now take link_name and share _apply_inst. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…_material_inst Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…_material_inst Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
… swap Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ure_key Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR refactors randomize_visual_material to reuse dexsim-parsed per-object materials (via instance swapping) instead of creating and replacing a new material each reset, adds a three-tier texture selection policy (original/library/solid) with configurable probabilities, and keeps the prior behavior behind a fallback_to_new flag. It also introduces supporting sim APIs (ReuseSegmentState, “get existing material” helpers) and adds mocked unit tests to validate the new code paths.
Changes:
- Extend
VisualMaterialInst.set_base_color_texture(...)to accept a pre-created dexsimTextureobject (skip re-upload). - Add
ReuseSegmentStateplusget_existing_visual_material(...)/apply_render_material_inst(...)helpers onRigidObjectandArticulation. - Implement reuse-mode init/call paths in
randomize_visual_material, including tier-probability resolution and sim-level texture object caching; add extensive mocked tests.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
embodichain/lab/gym/envs/managers/randomization/visual.py |
Implements reuse-mode initialization and per-reset swapping, tier sampling, and cached texture objects; retains legacy path behind flag. |
embodichain/lab/sim/material.py |
Adds texture_obj support for base-color maps and introduces ReuseSegmentState. |
embodichain/lab/sim/objects/rigid_object.py |
Adds helpers to capture existing segment materials and swap MaterialInst back onto render bodies. |
embodichain/lab/sim/objects/articulation.py |
Adds link-aware equivalents of existing-material capture and render-body instance swapping. |
embodichain/lab/sim/__init__.py |
Exports ReuseSegmentState and defines an explicit package __all__. |
tests/gym/envs/managers/test_randomize_visual_material.py |
Adds characterization and reuse-mode tests (mocked dexsim) for legacy/reuse/compat scenarios. |
tests/sim/test_material_texture_obj.py |
Tests texture_obj path binds Texture without upload. |
tests/sim/objects/test_rigid_object_reuse_material.py |
Tests rigid-object reuse-state building and render-body swapping. |
tests/sim/objects/test_articulation_reuse_material.py |
Tests articulation reuse-state building per link and swapping per link segment. |
docs/superpowers/specs/2026-07-14-reuse-existing-visual-material-design.md |
Design write-up for the reuse-material approach and constraints. |
docs/superpowers/plans/2026-07-14-reuse-existing-visual-material.md |
Detailed implementation plan and checklist for the feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| logger.log_warning( | ||
| f"randomize_visual_material: reuse-existing-material unavailable for " | ||
| f"'{self.entity_cfg.uid}' ({e}); falling back to new-material path." | ||
| ) | ||
| self._new_mode = False |
| else: | ||
| for reuse_i in range(num_reuse): | ||
| _apply(reuse_i, int(env_ids[reuse_i])) | ||
|
|
| seg = _seg(0, MagicMock(name="orig"), MagicMock(name="tmpl")) | ||
| obj.get_existing_visual_material = MagicMock(return_value=[[seg]]) | ||
| cfg = _make_cfg({"entity_cfg": SceneEntityCfg(uid="obj")}) |
| functor(env, torch.arange(env.num_envs), entity_cfg=SceneEntityCfg(uid="obj")) | ||
|
|
||
| env.sim.env.clean_materials.assert_not_called() | ||
| obj.apply_render_material_inst.assert_called() |
| texture_path: str = None, | ||
| texture_data: torch.Tensor | None = None, | ||
| texture_obj=None, | ||
| ) -> None: |
| if texture_path is not None: | ||
| self.base_color_texture = texture_path | ||
| inst = self._mat.get_inst(self.uid) | ||
| inst.set_base_color_map(texture_path) | ||
| elif texture_obj is not None: |
| if self._new_mode: | ||
| return self._call_reuse( | ||
| env, | ||
| env_ids, | ||
| base_color_range, | ||
| metallic_range, | ||
| roughness_range, | ||
| ior_range, | ||
| ) | ||
| clean = bool(self._fallback_to_new) | ||
| return self._call_legacy( | ||
| env, | ||
| env_ids, | ||
| random_texture_prob, | ||
| base_color_range, | ||
| metallic_range, | ||
| roughness_range, | ||
| ior_range, | ||
| clean=clean, | ||
| ) |
| self._texture_key = ( | ||
| os.path.basename(texture_path) if texture_path is not None else "" | ||
| ) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
embodichain/lab/sim/material.py:306
- set_base_color_texture() bypasses the VisualMaterialInst.mat property and always calls self._mat.get_inst(self.uid). That ignores the _mat_inst path introduced by from_existing(), so updates may be applied to a looked-up/new instance instead of the wrapped existing MaterialInst.
if texture_path is not None:
self.base_color_texture = texture_path
inst = self._mat.get_inst(self.uid)
inst.set_base_color_map(texture_path)
elif texture_obj is not None:
self.base_color_texture = texture_obj
inst = self._mat.get_inst(self.uid)
inst.set_base_color_map(texture_obj)
embodichain/lab/sim/objects/rigid_object.py:956
- get_existing_visual_material() assumes get_render_body() is always non-None and will raise an AttributeError on render_body.get_mesh_count() otherwise. The docstring says this method raises ValueError for invalid reuse state; render-body absence should be handled explicitly and raised as ValueError (so callers like randomize_visual_material can degrade cleanly).
for env_idx in local_env_ids:
render_body = self._entities[env_idx].get_render_body()
mesh_count = render_body.get_mesh_count()
segments: List[ReuseSegmentState] = []
| def _init_legacy(self, env: EmbodiedEnv) -> None: | ||
| """Legacy init: create a new material and replace the object's material.""" | ||
| if self.entity_cfg.uid == "default_plane": | ||
| pass | ||
|
|
||
| else: | ||
| # TODO: we may need to get the default material instance from the asset itself. | ||
| mat: VisualMaterial = env.sim.create_visual_material( | ||
| cfg=VisualMaterialCfg( |
…com/DexForce/EmbodiChain into feat/reuse-existing-visual-material
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
embodichain/lab/sim/material.py:306
VisualMaterialInst.set_base_color_texture()bypasses thematproperty (and therefore_mat_instfromfrom_existing) by callingself._mat.get_inst(self.uid)directly. This can cause updates to target a different handle than the wrapped existing instance. Useself.matconsistently here.
if texture_path is not None:
self.base_color_texture = texture_path
inst = self._mat.get_inst(self.uid)
inst.set_base_color_map(texture_path)
elif texture_obj is not None:
self.base_color_texture = texture_obj
inst = self._mat.get_inst(self.uid)
inst.set_base_color_map(texture_obj)
| # Init properties with default values | ||
| self.base_color = [0.5, 0.5, 0.5, 1.0] |
| def _apply_solid_props(self, working_inst, plan, idx) -> None: | ||
| texture_idx = torch.randint(0, len(self._solid_textures), (1,)).item() | ||
| working_inst.set_base_color([1.0, 1.0, 1.0, 1.0]) | ||
| working_inst.set_metallic(0.0) | ||
| working_inst.set_roughness(0.7) | ||
| working_inst.set_base_color_texture( | ||
| texture_obj=self._solid_textures[texture_idx] | ||
| ) | ||
|
|
| p_solid: float | None = None, | ||
| solid_texture_count: int = 32, | ||
| shared: bool | None = None, | ||
| ): |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
embodichain/lab/sim/material.py:303
- In
set_base_color_texture(), the texture-path branch usesself._mat.get_inst(self.uid)which bypasses_mat_instfor wrappers created viafrom_existing(). This can cause texture changes to target a different instance than the one attached to the asset. Useself.mathere.
if texture_path is not None:
self.base_color_texture = texture_path
inst = self._mat.get_inst(self.uid)
inst.set_base_color_map(texture_path)
elif texture_obj is not None:
embodichain/lab/sim/material.py:306
- In
set_base_color_texture(), thetexture_objbranch usesself._mat.get_inst(self.uid)which bypasses_mat_instfor wrappers created viafrom_existing(). Useself.matso the pre-created dexsim Texture is bound onto the actual wrapped instance.
elif texture_obj is not None:
self.base_color_texture = texture_obj
inst = self._mat.get_inst(self.uid)
inst.set_base_color_map(texture_obj)
embodichain/lab/sim/material.py:310
- In
set_base_color_texture(), thetexture_databranch usesself._mat.get_inst(self.uid), which bypasses_mat_instforfrom_existing()wrappers. Useself.matso the uploaded texture is applied to the wrapped instance.
elif texture_data is not None:
self.base_color_texture = texture_data
inst = self._mat.get_inst(self.uid)
embodichain/lab/gym/envs/managers/randomization/visual.py:872
clean_materials()is now only called whenfallback_to_new=True. When reuse init fails and the functor degrades to the legacy path,clean_materials()will not run, which can leak per-call uploaded textures/material instances (the legacy path uploads textures viaset_base_color_texture(texture_data=...)). Consider cleaning whenever the legacy path is used (exceptdefault_plane).
clean = bool(self._fallback_to_new)
return self._call_legacy(
env,
env_ids,
random_texture_prob,
embodichain/lab/sim/material.py:246
VisualMaterialInst.from_existing()introduces_mat_instandmatproperty correctly returns it, but several setters still callself._mat.get_inst(self.uid). That bypasses the wrapped existing instance, so calls likeset_roughness()/set_metallic()may not affect the material instance actually attached to the asset. These setters should useself.matconsistently (asset_base_color()already does).
@property
def mat(self) -> MaterialInst:
existing_inst = getattr(self, "_mat_inst", None)
if existing_inst is not None:
return existing_inst
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 24 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
embodichain/lab/gym/envs/managers/randomization/visual.py:1125
- In legacy mode,
set_visual_material()is called on every functor invocation for the selected envs. ForRigidObject/Articulation,set_visual_material()creates new dexsim material instances each time, which is expensive and can leak/accumulate instances; the material assignment was already done during_init_legacy()and the call should only fetch existing instances for the selected envs.
selected_env_ids = [int(env_idx) for env_idx in env_ids]
if isinstance(self.entity, RigidObject):
self.entity.set_visual_material(self._legacy_mat, env_ids=selected_env_ids)
self._mat_insts = self.entity.get_visual_material_inst(
env_ids=selected_env_ids
)
elif isinstance(self.entity, Articulation):
self.entity.set_visual_material(
self._legacy_mat,
env_ids=selected_env_ids,
link_names=self.entity_cfg.link_names,
)
self._mat_insts = self.entity.get_visual_material_inst(
env_ids=selected_env_ids,
link_names=self.entity_cfg.link_names,
)
embodichain/lab/gym/envs/managers/randomization/visual.py:1008
- The solid tier currently hard-codes metallic=0.0 and roughness=0.7 and never applies
metallic_range/roughness_range/ior_rangefrom the sampled plan. This makes user-provided ranges ineffective whenever the solid tier is selected (including when library textures are missing and probability is folded into solid).
def _apply_solid_props(self, working_inst, plan, idx) -> None:
texture_idx = torch.randint(0, len(self._solid_textures), (1,)).item()
working_inst.set_base_color([1.0, 1.0, 1.0, 1.0])
working_inst.set_metallic(0.0)
working_inst.set_roughness(0.7)
working_inst.set_base_color_texture(
texture_obj=self._solid_textures[texture_idx]
)
embodichain/lab/sim/material.py:317
VisualMaterialInst.set_base_color_texture()usesself._mat.get_inst(self.uid)even when this wrapper was created viafrom_existing()with a concrete dexsimMaterialInst. That bypasses thematproperty (which correctly returns the existing instance) and can apply updates to the wrong instance if name lookup doesn’t resolve to the wrapped handle.
if texture_path is not None:
self.base_color_texture = texture_path
inst = self._mat.get_inst(self.uid)
inst.set_base_color_map(texture_path)
elif texture_obj is not None:
self.base_color_texture = texture_obj
inst = self._mat.get_inst(self.uid)
inst.set_base_color_map(texture_obj)
elif texture_data is not None:
self.base_color_texture = texture_data
inst = self._mat.get_inst(self.uid)
# TODO: Optimize texture creation method.
world = dexsim.default_world()
env = world.get_env()
color_texture = env.create_color_texture(
texture_data.cpu().numpy(), has_alpha=True
)
inst.set_base_color_map(color_texture)
embodichain/lab/sim/material.py:214
VisualMaterialCfgdefaultsroughnessto 0.7, butVisualMaterialInstinitializesself.roughnessto 0.5. This makes the wrapper’s stored defaults inconsistent with the config defaults and the docs (and can confuse callers relying on default roughness).
# Init properties with default values
self.base_color = [0.5, 0.5, 0.5, 1.0]
self.metallic = 0.0
self.roughness = 0.5
self.emissive = [0.0, 0.0, 0.0]
Description
This PR updates visual-material handling so simulation assets retain the material instances parsed by dexsim and visual randomization can work from those existing materials instead of replacing them with a newly created
VisualMaterialtemplate.For rigid objects and articulations, the reuse path keeps each original per-segment
MaterialInstimmutable and creates a working instance from the existing material template. Randomization then switches render-body bindings between the original instance and the working instance. This preserves the asset's authored appearance while avoiding repeated material creation, texture upload, and globalclean_materials()calls.Key changes
get_existing_visual_material()andapply_render_material_inst()for rigid objects and articulations.VisualMaterialInst.set_base_color_texture()to bind a pre-created dexsimTexturedirectly.randomize_visual_materialaround three selectable tiers:original: reattach the asset's original material instances.library: bind a preloaded library texture to the working instance.solid: bind a color from a bounded, pre-created texture palette.p_original,p_library,p_solid,solid_texture_count, andsharedconfiguration.env_ids, multi-segment render bodies, and independent per-link tier sampling for articulations.fallback_to_new=Trueas the explicit legacy create-and-replace path; reuse failures also fall back automatically.default_planerandomization in place without cleaning all materials.Compatibility
This is a non-breaking enhancement. Existing configurations that only use
random_texture_probcontinue to work: when allp_*values are omitted, the probabilities resolve top_original=0,p_library=random_texture_prob, andp_solid=1-random_texture_prob. If no texture library is loaded, the library probability is folded into the solid tier.Dependencies: none; this uses existing dexsim material and texture APIs.
Type of change
Screenshots
N/A — this changes simulation material lifecycle and randomization behavior rather than UI.
Validation
black --check .— 460 files unchanged.Checklist
black .command to format the code base.