From d880b403d3b86d793c8dcab151efd04b678f861d Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Mon, 20 Jul 2026 12:47:38 +0200 Subject: [PATCH] Decouple FEM render geometry from simulation geometry. FEM entities expose their morph sub-meshes as visual geoms (FEMVisGeom), each pairing a render mesh carrying its own surface and UVs with the map from its vertices to the simulated vertices. The simulation operates on a single welded copy of the sub-mesh vertices, tetrahedralized for volumetric materials, while the rasterizer and raytracer draw the authored surfaces and update them per visual geom. The FEM solver drops its render-only topology and UV fields, and the normal render pass gains double-sided support shared between offscreen and viewer renders. Co-authored-by: Zhehuan Chen --- genesis/assets/meshes/trashbag_rope.glb | Bin 0 -> 109544 bytes genesis/engine/couplers/sap_coupler.py | 2 +- genesis/engine/entities/fem_entity.py | 146 ++++++------------ genesis/engine/entities/particle_entity.py | 10 +- genesis/engine/materials/FEM/base.py | 11 +- genesis/engine/mesh.py | 9 +- genesis/engine/scene.py | 7 +- genesis/engine/solvers/fem_solver.py | 70 ++------- genesis/ext/pyrender/offscreen.py | 20 +-- genesis/ext/pyrender/renderer.py | 7 +- genesis/ext/pyrender/shader_program.py | 19 +++ genesis/ext/pyrender/shaders/mesh_normal.vert | 18 ++- .../shaders/mesh_normal_double_sided.geom | 27 ++++ genesis/ext/pyrender/viewer.py | 19 +-- genesis/utils/element.py | 43 +----- genesis/utils/mesh.py | 41 +++++ genesis/vis/rasterizer_context.py | 70 ++++----- genesis/vis/raytracer.py | 42 ++--- tests/deformable/test_fem.py | 75 +++++---- tests/rendering/test_offscreen.py | 73 ++++++++- tests/utils.py | 2 +- 21 files changed, 370 insertions(+), 341 deletions(-) create mode 100644 genesis/assets/meshes/trashbag_rope.glb create mode 100644 genesis/ext/pyrender/shaders/mesh_normal_double_sided.geom diff --git a/genesis/assets/meshes/trashbag_rope.glb b/genesis/assets/meshes/trashbag_rope.glb new file mode 100644 index 0000000000000000000000000000000000000000..a3696435c12df38ee799a5365e1f9136dd49973b GIT binary patch literal 109544 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%0AS9vA(5%tii|@{!6`a?w{ac5~9^cJ%A& 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 i|C+G(u{s};%YF5+&t5v 1: - gs.raise_exception("Mesh file with multiple sub-meshes are not supported.") - else: - self._vmesh = self._vmesh[0] + meshes = gs.Mesh.from_morph_surface(self.morph, self.surface) + if len(meshes) > 1: + gs.raise_exception("Mesh file with multiple sub-meshes are not supported.") + self._vmesh = meshes[0] self._surface = self._vmesh.surface else: diff --git a/genesis/engine/materials/FEM/base.py b/genesis/engine/materials/FEM/base.py index 9b43ac5e58..4816daeee3 100644 --- a/genesis/engine/materials/FEM/base.py +++ b/genesis/engine/materials/FEM/base.py @@ -89,18 +89,21 @@ def _build_noop(self, fem_solver): def _pre_compute_noop(self, J, F, i_e, i_b): pass + # The noop dispatch funcs below return a zero material response: they are compiled into the solver kernels for + # materials whose physics lives outside the FEM solver (e.g. Cloth is simulated by the IPC coupler), and + # quadrants funcs cannot raise. @qd.func def _update_stress_noop(self, mu, lam, J, F, actu, m_dir): - raise NotImplementedError + return qd.Matrix.zero(gs.qd_float, 3, 3) @qd.func def _compute_energy_gradient_hessian_noop(self, mu, lam, J, F, actu, m_dir, i_e, i_b, hessian_field): - raise NotImplementedError + pass @qd.func def _compute_energy_gradient_noop(self, mu, lam, J, F, actu, m_dir, i_e, i_b): - raise NotImplementedError + pass @qd.func def _compute_energy_noop(self, mu, lam, J, F, actu, m_dir, i_e, i_b): - raise NotImplementedError + pass diff --git a/genesis/engine/mesh.py b/genesis/engine/mesh.py index c1ddafdc05..ee6437021e 100644 --- a/genesis/engine/mesh.py +++ b/genesis/engine/mesh.py @@ -512,11 +512,12 @@ def from_attrs( ) @classmethod - def from_morph_surface(cls, morph, surface=None) -> "list[gs.Mesh] | gs.Mesh": + def from_morph_surface(cls, morph, surface=None) -> "list[gs.Mesh]": """ - Create a genesis.Mesh from morph and surface options. + Create genesis.Mesh objects from morph and surface options. - If the morph is a Mesh morph (morphs.Mesh), it could contain multiple sub-meshes, so we return a list. + A list is always returned: a Mesh morph (morphs.Mesh) may contain multiple sub-meshes, while primitive + morphs yield a single mesh. """ if isinstance(morph, gs.options.morphs.Mesh): if morph.is_format(gs.options.morphs.MESH_FORMATS): @@ -545,7 +546,7 @@ def from_morph_surface(cls, morph, surface=None) -> "list[gs.Mesh] | gs.Mesh": else: gs.raise_exception(f"Morph {morph} not supported by this method.") - return cls.from_trimesh(tmesh, surface=surface) + return [cls.from_trimesh(tmesh, surface=surface)] def set_color(self, color): """ diff --git a/genesis/engine/scene.py b/genesis/engine/scene.py index 6d2a0e4609..23f50d0cae 100644 --- a/genesis/engine/scene.py +++ b/genesis/engine/scene.py @@ -592,8 +592,11 @@ def add_mesh_light( if not isinstance(morph, (gs.morphs.Primitive, gs.morphs.Mesh)): gs.raise_exception("Light morph only supports `gs.morphs.Primitive` or `gs.morphs.Mesh`.") - mesh = gs.Mesh.from_morph_surface(morph, gs.surfaces.Plastic(smooth=False)) - self._visualizer.add_mesh_light(mesh, color, intensity, morph.pos, morph.quat, revert_dir, double_sided, cutoff) + meshes = gs.Mesh.from_morph_surface(morph, gs.surfaces.Plastic(smooth=False)) + for mesh in meshes: + self._visualizer.add_mesh_light( + mesh, color, intensity, morph.pos, morph.quat, revert_dir, double_sided, cutoff + ) @gs.assert_unbuilt def add_light( diff --git a/genesis/engine/solvers/fem_solver.py b/genesis/engine/solvers/fem_solver.py index f3f51436eb..83f87b710c 100644 --- a/genesis/engine/solvers/fem_solver.py +++ b/genesis/engine/solvers/fem_solver.py @@ -218,35 +218,22 @@ def init_surface_fields(self): active=gs.qd_bool, ) - # for rendering (this is more of a surface) - surface_state_render_v = qd.types.struct( - vertices=gs.qd_vec3, + # environment-offset vertex positions for rendering + vert_state_render = qd.types.struct( + pos=gs.qd_vec3, ) - surface_state_render_f = qd.types.struct( - indices=gs.qd_int, - ) - - # construct field self.surface = surface_state.field( shape=(n_surfaces_max), needs_grad=False, layout=qd.Layout.SOA, ) - self.surface_render_v = surface_state_render_v.field( + self.verts_render = vert_state_render.field( shape=(n_vertices_max, self._B), needs_grad=False, layout=qd.Layout.SOA, ) - self.surface_render_f = surface_state_render_f.field( - shape=(n_surfaces_max * 3), - needs_grad=False, - layout=qd.Layout.SOA, - ) - - # UV coordinates for rendering (per-vertex UVs, initialized to zeros) - self.surface_render_uvs = qd.field(dtype=gs.qd_vec2, shape=(max(n_vertices_max, 1),), needs_grad=False) def _init_surface_info(self): self.vertices_on_surface = qd.field(dtype=gs.qd_bool, shape=(self.n_vertices,)) @@ -1103,12 +1090,9 @@ def get_state(self, f): return state def get_state_render(self, f): - self.get_state_render_kernel(f) - vertices = self.surface_render_v.vertices - indices = self.surface_render_f.indices - uvs = self.surface_render_uvs - - return vertices, indices, uvs + """Refresh and return the environment-offset vertex positions field, with shape (n_vertices, B).""" + self._kernel_get_state_render(f) + return self.verts_render.pos def get_forces(self): """ @@ -1139,7 +1123,6 @@ def _kernel_add_elements( elems: qd.types.ndarray(), tri2v: qd.types.ndarray(), tri2el: qd.types.ndarray(), - uvs: qd.types.ndarray(), ): n_verts_local = verts.shape[0] for i_v, i_b in qd.ndrange(n_verts_local, self._B): @@ -1148,12 +1131,6 @@ def _kernel_add_elements( self.elements_v[f, i_global, i_b].pos[j] = verts[i_v, j] self.elements_v[f, i_global, i_b].vel = qd.Vector.zero(gs.qd_float, 3) - # Copy UVs to solver field (skip if no UVs provided) - n_uvs = uvs.shape[0] - for i_v in range(n_uvs): - i_global = i_v + v_start - self.surface_render_uvs[i_global] = qd.Vector([uvs[i_v, 0], uvs[i_v, 1]]) - for i_v in range(n_verts_local): i_global = i_v + v_start self.elements_v_info[i_global].mass = 0.0 @@ -1211,21 +1188,20 @@ def _kernel_add_elements( self.surface[i_global].active = True @qd.kernel - def _kernel_add_cloth_for_rendering( + def _kernel_add_cloth( self, f: qd.i32, - n_surfaces: qd.i32, v_start: qd.i32, s_start: qd.i32, verts: qd.types.ndarray(), tri2v: qd.types.ndarray(), - uvs: qd.types.ndarray(), ): """ - Add cloth vertices and surfaces for rendering only (no physics computation). - Cloth is simulated by IPC, but needs to be in FEM solver's rendering pipeline. + Add cloth vertices and surface triangles to the solver, for position tracking and coupling only. + + Cloth elements and mass are owned by the IPC coupler, so the vertex info holds placeholder values and each + surface triangle references itself as element. """ - # Add vertices for rendering n_verts_local = verts.shape[0] for i_v, i_b in qd.ndrange(n_verts_local, self._B): i_global = i_v + v_start @@ -1233,25 +1209,16 @@ def _kernel_add_cloth_for_rendering( self.elements_v[f, i_global, i_b].pos[j] = verts[i_v, j] self.elements_v[f, i_global, i_b].vel = qd.Vector.zero(gs.qd_float, 3) - # Copy UVs to solver field (skip if no UVs provided) - n_uvs = uvs.shape[0] - for i_v in range(n_uvs): - i_global = i_v + v_start - self.surface_render_uvs[i_global] = qd.Vector([uvs[i_v, 0], uvs[i_v, 1]]) - - # Initialize vertex info (mass will be managed by IPC, set to dummy value) for i_v in range(n_verts_local): i_global = i_v + v_start - self.elements_v_info[i_global].mass = 1.0 # Dummy value, not used for cloth + self.elements_v_info[i_global].mass = 1.0 self.elements_v_info[i_global].mass_over_dt2 = 0.0 self.elements_v_info[i_global].friction_mu = 0.0 - # Add surface triangles for rendering - for i_s in range(n_surfaces): + for i_s in range(tri2v.shape[0]): i_global = i_s + s_start for j in qd.static(range(3)): self.surface[i_global].tri2v[j] = tri2v[i_s, j] + v_start - # For cloth, tri2el points to itself (no tetrahedral element) self.surface[i_global].tri2el = i_global self.surface[i_global].active = True @@ -1397,16 +1364,11 @@ def _kernel_get_state( active[i_b, i_e] = self.elements_el_ng[f, i_e, i_b].active @qd.kernel - def get_state_render_kernel(self, f: qd.i32): + def _kernel_get_state_render(self, f: qd.i32): for i_v, i_b in qd.ndrange(self.n_vertices, self._B): for j in qd.static(range(3)): pos_j = qd.cast(self.elements_v[f, i_v, i_b].pos[j], qd.f32) - self.surface_render_v[i_v, i_b].vertices[j] = pos_j + self.envs_offset[i_b][j] - - # Fill triangle indices (flat array, 3 ints per triangle) - for i_s in range(self.n_surfaces): - for j in qd.static(range(3)): - self.surface_render_f[i_s * 3 + j].indices = qd.cast(self.surface[i_s].tri2v[j], qd.i32) + self.verts_render[i_v, i_b].pos[j] = pos_j + self.envs_offset[i_b][j] @qd.kernel def _kernel_set_state( diff --git a/genesis/ext/pyrender/offscreen.py b/genesis/ext/pyrender/offscreen.py index ac70a7a63d..29dc555e17 100644 --- a/genesis/ext/pyrender/offscreen.py +++ b/genesis/ext/pyrender/offscreen.py @@ -10,10 +10,6 @@ import genesis as gs from .constants import RenderFlags -from .shader_program import ShaderProgram - - -MODULE_DIR = os.path.dirname(__file__) class OffscreenRenderer(object): @@ -205,22 +201,8 @@ def render( retval = () if normal: - - class CustomShaderCache: - def __init__(self): - self.program = None - - def get_program(self, vertex_shader, fragment_shader, geometry_shader=None, defines=None): - if self.program is None: - self.program = ShaderProgram( - os.path.join(MODULE_DIR, "shaders/mesh_normal.vert"), - os.path.join(MODULE_DIR, "shaders/mesh_normal.frag"), - defines=defines, - ) - return self.program - old_cache = renderer._program_cache - renderer._program_cache = CustomShaderCache() + renderer._program_cache = renderer._normal_program_cache flags = RenderFlags.FLAT | RenderFlags.OFFSCREEN if env_separate_rigid: diff --git a/genesis/ext/pyrender/renderer.py b/genesis/ext/pyrender/renderer.py index fb7e0888d8..7a3dcb9534 100644 --- a/genesis/ext/pyrender/renderer.py +++ b/genesis/ext/pyrender/renderer.py @@ -25,7 +25,7 @@ from .font import FontCache from .light import DirectionalLight, PointLight, SpotLight from .material import MetallicRoughnessMaterial, SpecularGlossinessMaterial -from .shader_program import ShaderProgramCache +from .shader_program import NormalShaderCache, ShaderProgramCache from .utils import format_color_vector from .texture import Texture @@ -78,6 +78,7 @@ def __init__(self, viewport_width, viewport_height, jit, point_size=1.0): # Shader Program Cache self._program_cache = ShaderProgramCache() + self._normal_program_cache = NormalShaderCache() self._font_cache = FontCache() self._meshes = set() self._mesh_textures = set() @@ -319,6 +320,7 @@ def delete(self): """Free all allocated OpenGL resources.""" # Free shaders self._program_cache.clear() + self._normal_program_cache.clear() # Free fonts self._font_cache.clear() @@ -849,6 +851,8 @@ def _get_primitive_program(self, primitive, flags, program_flags): elif flags & RenderFlags.FLAT: vertex_shader = "flat.vert" fragment_shader = "flat.frag" + if primitive.double_sided: + defines["DOUBLE_SIDED"] = 1 elif flags & RenderFlags.SEG: vertex_shader = "segmentation.vert" fragment_shader = "segmentation.frag" @@ -1150,4 +1154,5 @@ def _resize_image(self, value, antialias): def reload_program(self): self._program_cache.clear() + self._normal_program_cache.clear() self.jit.program_id.clear() diff --git a/genesis/ext/pyrender/shader_program.py b/genesis/ext/pyrender/shader_program.py index 6a33492652..80a4e593ba 100644 --- a/genesis/ext/pyrender/shader_program.py +++ b/genesis/ext/pyrender/shader_program.py @@ -73,6 +73,25 @@ def clear(self): self._program_cache.clear() +class NormalShaderCache(ShaderProgramCache): + """A program cache that draws every primitive with the surface-normal shader. + + Swapped in place of the regular program cache for normal-channel render passes. Double-sided primitives are + drawn through a geometry shader emitting both faces. Programs are keyed on the primitive-specific defines + (buffer attribute locations, double-sidedness), so primitives with different buffer layouts each get a program + compiled with the correct attribute bindings. + """ + + def get_program(self, vertex_shader, fragment_shader, geometry_shader=None, defines=None): + is_double_sided = defines is not None and "DOUBLE_SIDED" in defines + return super().get_program( + "mesh_normal.vert", + "mesh_normal.frag", + geometry_shader="mesh_normal_double_sided.geom" if is_double_sided else None, + defines=defines, + ) + + class ShaderProgram(object): """A thin wrapper about OpenGL shader programs that supports easy creation, binding, and uniform-setting. diff --git a/genesis/ext/pyrender/shaders/mesh_normal.vert b/genesis/ext/pyrender/shaders/mesh_normal.vert index e471621cfb..db160d907f 100644 --- a/genesis/ext/pyrender/shaders/mesh_normal.vert +++ b/genesis/ext/pyrender/shaders/mesh_normal.vert @@ -11,14 +11,24 @@ uniform mat4 V; uniform mat4 P; // Outputs -out vec3 frag_position; -out vec3 frag_normal; +#ifdef DOUBLE_SIDED + out vec3 v_frag_position; + out vec3 v_frag_normal; +#else + out vec3 frag_position; + out vec3 frag_normal; +#endif void main() { gl_Position = P * V * M * inst_m * vec4(position, 1); - frag_position = vec3(M * inst_m * vec4(position, 1.0)); - mat4 N = transpose(inverse(M * inst_m)); + +#ifdef DOUBLE_SIDED + v_frag_position = vec3(M * inst_m * vec4(position, 1.0)); + v_frag_normal = normalize(vec3(N * vec4(normal, 0.0))); +#else + frag_position = vec3(M * inst_m * vec4(position, 1.0)); frag_normal = normalize(vec3(N * vec4(normal, 0.0))); +#endif } \ No newline at end of file diff --git a/genesis/ext/pyrender/shaders/mesh_normal_double_sided.geom b/genesis/ext/pyrender/shaders/mesh_normal_double_sided.geom new file mode 100644 index 0000000000..a2a4575583 --- /dev/null +++ b/genesis/ext/pyrender/shaders/mesh_normal_double_sided.geom @@ -0,0 +1,27 @@ +#version 330 core +layout (triangles) in; +layout (triangle_strip, max_vertices=6) out; + +in vec3 v_frag_position[]; +in vec3 v_frag_normal[]; +out vec3 frag_position; +out vec3 frag_normal; + +void emit_vertex(int i, bool reversed) { + gl_Position = gl_in[i].gl_Position; + frag_position = v_frag_position[i]; + frag_normal = reversed ? -v_frag_normal[i] : v_frag_normal[i]; + EmitVertex(); +} + +void main() { + emit_vertex(0, false); + emit_vertex(1, false); + emit_vertex(2, false); + EndPrimitive(); + + emit_vertex(0, true); + emit_vertex(2, true); + emit_vertex(1, true); + EndPrimitive(); +} diff --git a/genesis/ext/pyrender/viewer.py b/genesis/ext/pyrender/viewer.py index 15cd3e12b7..302f52f935 100644 --- a/genesis/ext/pyrender/viewer.py +++ b/genesis/ext/pyrender/viewer.py @@ -53,14 +53,11 @@ from .light import DirectionalLight from .node import Node from .renderer import Renderer -from .shader_program import ShaderProgram from .trackball import Trackball if TYPE_CHECKING: from genesis.vis.rasterizer_context import RasterizerContext -MODULE_DIR = os.path.dirname(__file__) - HELP_TEXT_KEY = Key.I HELP_TEXT_KEYBIND_NAME = "toggle_instructions" @@ -1149,22 +1146,8 @@ def _render(self, camera_node=None, renderer=None, normal=False): retval = () if normal: - - class CustomShaderCache: - def __init__(self): - self.program = None - - def get_program(self, vertex_shader, fragment_shader, geometry_shader=None, defines=None): - if self.program is None: - self.program = ShaderProgram( - os.path.join(MODULE_DIR, "shaders/mesh_normal.vert"), - os.path.join(MODULE_DIR, "shaders/mesh_normal.frag"), - defines=defines, - ) - return self.program - old_cache = renderer._program_cache - renderer._program_cache = CustomShaderCache() + renderer._program_cache = renderer._normal_program_cache flags = RenderFlags.FLAT | RenderFlags.OFFSCREEN if self.render_flags["env_separate_rigid"]: diff --git a/genesis/utils/element.py b/genesis/utils/element.py index fcef95ca10..f167ff4a7b 100644 --- a/genesis/utils/element.py +++ b/genesis/utils/element.py @@ -2,7 +2,6 @@ import pickle as pkl import numpy as np -import trimesh import igl import genesis as gs @@ -10,32 +9,8 @@ from . import mesh as mu -def box_to_elements(pos=(0, 0, 0), size=(1, 1, 1), tet_cfg=dict()): - trimesh_obj = trimesh.creation.box(extents=size) - trimesh_obj.vertices += np.array(pos) - verts, elems = mu.tetrahedralize_mesh(trimesh_obj, tet_cfg) - - return verts, elems - - -def sphere_to_elements(pos=(0, 0, 0), radius=0.5, tet_cfg=dict()): - trimesh_obj = trimesh.creation.icosphere(subdivisions=3, radius=1.0) - trimesh_obj.vertices *= np.array(radius) - trimesh_obj.vertices += np.array(pos) - verts, elems = mu.tetrahedralize_mesh(trimesh_obj, tet_cfg) - - return verts, elems - - -def cylinder_to_elements(): - raise NotImplementedError - - -def mesh_to_elements(file, pos=(0, 0, 0), scale=1.0, tet_cfg=dict()): - mesh = mu.load_mesh(file) - - mesh.vertices = mesh.vertices * scale - +def mesh_to_elements(mesh, tet_cfg=dict()): + """Tetrahedralize a surface trimesh, with the result cached on disk keyed on vertices, faces and configuration.""" # compute file name via hashing for caching tet_file_path = mu.get_tet_path(mesh.vertices, mesh.faces, tet_cfg) @@ -58,19 +33,7 @@ def mesh_to_elements(file, pos=(0, 0, 0), scale=1.0, tet_cfg=dict()): with open(tet_file_path, "wb") as tet_file: pkl.dump((verts, elems), tet_file) - verts += np.array(pos) - - # Build full UV array - uvs = None - if isinstance(mesh.visual, trimesh.visual.texture.TextureVisuals) and mesh.visual.uv is not None: - # Extract UVs from mesh before tetrahedralization. - # Note that 'tetgen' preserves original vertices at start of output array. - uvs_orig = mesh.visual.uv.astype(gs.np_float, copy=False) - - # Original vertices get their UVs, interior vertices get zeros - uvs = np.pad(uvs_orig, ((0, len(verts) - len(mesh.vertices)), (0, 0))) - - return verts, elems, uvs + return verts, elems def split_all_surface_tets(verts, elems): diff --git a/genesis/utils/mesh.py b/genesis/utils/mesh.py index a1882c649a..4202a17839 100644 --- a/genesis/utils/mesh.py +++ b/genesis/utils/mesh.py @@ -1328,6 +1328,47 @@ def make_tetgen_switches(cfg): return "".join(flags) +def merge_submeshes(verts_list, faces_list): + """Concatenate sub-meshes into a single mesh, welding vertices that share a position. + + Welding covers both duplicated vertices within a sub-mesh (e.g. texture-seam splits) and vertices shared across + sub-meshes, so that downstream consumers (e.g. tetrahedralization) see one connected surface. + + Parameters + ---------- + verts_list : list of np.ndarray + Per-sub-mesh vertex arrays with shape (n_verts, 3). + faces_list : list of np.ndarray + Per-sub-mesh face arrays with shape (n_faces, 3), indexing into the matching vertex array. + + Returns + ------- + verts : np.ndarray + Welded vertices, ordered by first occurrence in the concatenated input. + faces : np.ndarray + Concatenated faces, re-indexed against the welded vertices. + verts_maps : list of np.ndarray + For each sub-mesh, the map from its local vertex indices to indices into the welded vertices. + """ + sub_offsets = (0, *np.cumsum([len(verts) for verts in verts_list])) + stacked_verts = np.concatenate(verts_list, axis=0) + stacked_faces = np.concatenate([faces + offset for faces, offset in zip(faces_list, sub_offsets)], axis=0) + + # Weld by quantized position: co-located vertices are authored with identical coordinates, so a tight absolute + # quantum absorbs sub-nanometer parsing noise while keeping genuinely distinct vertices separate. + quantized = np.round(stacked_verts * 1e8).astype(np.int64) + _, unique_idx, remap = np.unique(quantized, axis=0, return_index=True, return_inverse=True) + # np.unique orders groups by quantized key; rank the first-occurrence indices to restore input order instead. + rank = np.empty(len(unique_idx), dtype=unique_idx.dtype) + rank[np.argsort(unique_idx)] = np.arange(len(unique_idx)) + remap = rank[remap] + + verts = stacked_verts[np.sort(unique_idx)] + faces = remap[stacked_faces] + verts_maps = [remap[start:end] for start, end in zip(sub_offsets, sub_offsets[1:])] + return verts, faces, verts_maps + + def tetrahedralize_mesh(mesh, tet_cfg): tet = tetgen.TetGen(mesh.vertices.astype(np.float64, copy=False), mesh.faces.astype(np.int32, copy=False)) diff --git a/genesis/vis/rasterizer_context.py b/genesis/vis/rasterizer_context.py index 03c2a407a0..6ff7330292 100644 --- a/genesis/vis/rasterizer_context.py +++ b/genesis/vis/rasterizer_context.py @@ -881,51 +881,51 @@ def update_pbd(self): def on_fem(self): if self.sim.fem_solver.is_active: - vertices_qd, triangles_qd, uvs_qd = self.sim.fem_solver.get_state_render(self.sim.cur_substep_local) - vertices_all = qd_to_numpy(vertices_qd) - triangles_all = qd_to_numpy(triangles_qd).reshape((-1, 3)) - uvs_all = qd_to_numpy(uvs_qd) + vertices_all = qd_to_numpy( + self.sim.fem_solver.get_state_render(self.sim.cur_substep_local), + self.rendered_envs_idx, + transpose=True, + ) for fem_entity in self.sim.fem_solver.entities: - if fem_entity.surface.vis_mode == "visual": - triangles = ( - triangles_all[fem_entity.s_start : (fem_entity.s_start + fem_entity.n_surfaces)] - - fem_entity.v_start + if fem_entity.surface.vis_mode != "visual": + continue + + sim_verts = vertices_all[:, fem_entity.v_start : fem_entity.v_start + fem_entity.n_vertices] + for i_g, vgeom in enumerate(fem_entity.vgeoms): + vmesh = vgeom.vmesh + visual = mu.surface_uvs_to_trimesh_visual( + vmesh.surface, uvs=vmesh.uvs, n_verts=len(vgeom.sim_verts_idx) ) - for idx in self.rendered_envs_idx: - vertices = vertices_all[fem_entity.v_start : fem_entity.v_start + fem_entity.n_vertices, idx] - uvs = uvs_all[fem_entity.v_start : fem_entity.v_start + fem_entity.n_vertices] - # Select only vertices used in surface triangles, then reindex triangles against the new vertex list - surf_idx, inv = np.unique(triangles.flat, return_inverse=True) - triangles_reindexed = inv.reshape(triangles.shape) - vertices = vertices[surf_idx] - uvs = uvs[surf_idx] - - mesh = trimesh.Trimesh(vertices, triangles_reindexed, process=False) - mesh.visual = mu.surface_uvs_to_trimesh_visual( - fem_entity.surface, uvs=uvs, n_verts=fem_entity.n_surface_vertices - ) - self.add_static_node( - fem_entity, - pyrender.Mesh.from_trimesh( - mesh, smooth=fem_entity.surface.smooth, double_sided=fem_entity.surface.double_sided - ), - i_b=idx, + seg_key = (fem_entity.idx, i_g) if self.segmentation_level == "geom" else fem_entity.idx + for env_i, i_b in enumerate(self.rendered_envs_idx): + mesh = trimesh.Trimesh(sim_verts[env_i, vgeom.sim_verts_idx], vmesh.faces, process=False) + mesh.visual = visual + node = pyrender.Mesh.from_trimesh( + mesh, smooth=vmesh.surface.smooth, double_sided=vmesh.surface.double_sided ) + static_node = self.add_node(node) + self.static_nodes[(i_b, vmesh.uid)] = static_node + self.create_node_seg(seg_key, static_node) def update_fem(self): if self.sim.fem_solver.is_active: - vertices_all, triangles_all, _uvs = self.sim.fem_solver.get_state_render(self.sim.cur_substep_local) - vertices_all = vertices_all.to_numpy(dtype=gs.np_float) - triangles_all = triangles_all.to_numpy(dtype=gs.np_int).reshape((-1, 3)) + vertices_all = qd_to_numpy( + self.sim.fem_solver.get_state_render(self.sim.cur_substep_local), + self.rendered_envs_idx, + transpose=True, + ) for fem_entity in self.sim.fem_solver.entities: - if fem_entity.surface.vis_mode == "visual": - for idx in self.rendered_envs_idx: - vertices = vertices_all[fem_entity.v_start : fem_entity.v_start + fem_entity.n_vertices, idx] + if fem_entity.surface.vis_mode != "visual": + continue - node = self.static_nodes[(idx, fem_entity.uid)] - update_data = self._scene.reorder_vertices(node, vertices) + sim_verts = vertices_all[:, fem_entity.v_start : fem_entity.v_start + fem_entity.n_vertices] + for vgeom in fem_entity.vgeoms: + for env_i, i_b in enumerate(self.rendered_envs_idx): + node = self.static_nodes[(i_b, vgeom.vmesh.uid)] + render_verts = sim_verts[env_i, vgeom.sim_verts_idx].astype(np.float32, copy=False) + update_data = self._scene.reorder_vertices(node, render_verts) self.jit.update_buffer(node, "pos", update_data) normal_data = self.jit.update_normal(node, update_data) if normal_data is not None: diff --git a/genesis/vis/raytracer.py b/genesis/vis/raytracer.py index 729a4278e0..728c2d000a 100644 --- a/genesis/vis/raytracer.py +++ b/genesis/vis/raytracer.py @@ -253,6 +253,9 @@ def build(self, scene): if isinstance(entity, entities.RigidEntity): for geom in entity.geoms: self.add_surface(str(geom.uid), geom.surface) + elif isinstance(entity, entities.FEMEntity): + for vgeom in entity.vgeoms: + self.add_surface(str(vgeom.vmesh.uid), vgeom.vmesh.surface) else: self.add_surface(str(entity.uid), entity.surface) @@ -334,7 +337,8 @@ def build(self, scene): if self.sim.fem_solver.is_active: for fem_entity in self.sim.fem_solver.entities: if fem_entity.surface.vis_mode == "visual": - self.add_deformable(str(fem_entity.uid)) + for vgeom in fem_entity.vgeoms: + self.add_deformable(str(vgeom.vmesh.uid)) def get_transform(self, matrix): if matrix is None: @@ -781,27 +785,29 @@ def update_scene(self, force_render: bool = False): # FEM entities if self.sim.fem_solver.is_active: - vertices_all, triangles_all, uvs_qd = self.sim.fem_solver.get_state_render(self.sim.cur_substep_local) - vertices_all = vertices_all.to_numpy()[:, self.rendered_envs_idx[0]] - triangles_all = triangles_all.to_numpy().reshape((-1, 3)) - uvs_all = uvs_qd.to_numpy() + vertices_all = miscu.qd_to_numpy( + self.sim.fem_solver.get_state_render(self.sim.cur_substep_local), + self.rendered_envs_idx[0], + keepdim=False, + transpose=True, + ) for fem_entity in self.sim.fem_solver.entities: - if fem_entity.surface.vis_mode == "visual": - vertices = vertices_all[fem_entity.v_start : fem_entity.v_start + fem_entity.n_vertices] - triangles = ( - triangles_all[fem_entity.s_start : (fem_entity.s_start + fem_entity.n_surfaces)] - - fem_entity.v_start - ) - vertex_normals = trimesh.Trimesh(vertices=vertices, faces=triangles, process=False).vertex_normals - uvs = uvs_all[fem_entity.v_start : fem_entity.v_start + fem_entity.n_vertices] - + if fem_entity.surface.vis_mode != "visual": + continue + + sim_verts = vertices_all[fem_entity.v_start : fem_entity.v_start + fem_entity.n_vertices] + for vgeom in fem_entity.vgeoms: + render_verts = sim_verts[vgeom.sim_verts_idx] + vertex_normals = trimesh.Trimesh( + vertices=render_verts, faces=vgeom.vmesh.faces, process=False + ).vertex_normals self.update_deformable( - str(fem_entity.uid), - vertices, - triangles, + str(vgeom.vmesh.uid), + render_verts, + vgeom.vmesh.faces, vertex_normals, - uvs, + np.array([]) if vgeom.vmesh.uvs is None else vgeom.vmesh.uvs, ) # Flush the update buffer. diff --git a/tests/deformable/test_fem.py b/tests/deformable/test_fem.py index d4869a03a2..ccb6d8e691 100644 --- a/tests/deformable/test_fem.py +++ b/tests/deformable/test_fem.py @@ -14,8 +14,8 @@ @pytest.mark.required def test_interior_tetrahedralized_vertex(cube_verts_and_faces, box_obj_path, show_viewer): # A small maxvolume introduces internal vertices during tetrahedralization: all surface vertices must - # still lie exactly on the original quad faces, and the visualizer's mesh triangles must match the FEM - # entity's surface triangles. + # still lie exactly on the original quad faces, and the visualizer's mesh vertices must track the + # simulated vertices through the visual geom's sim_verts_idx. verts, faces = cube_verts_and_faces scene = gs.Scene( @@ -84,39 +84,46 @@ def _point_on_surface(p, verts, faces, tol=1e-6): f"Surface vertex index {idx} with coordinate {p} does not lie on any original face" ) - # Verify whether surface faces in the visualizer mesh matches the surface faces of the FEM entity - static_nodes = scene.visualizer.context.static_nodes - fem_node_mesh = static_nodes[(0, fem.uid)].mesh - - (fem_node_primitive,) = fem_node_mesh.primitives - fem_node_vertices = fem_node_primitive.positions - fem_node_faces = fem_node_primitive.indices - if fem_node_faces is None: - fem_node_faces = np.arange(fem_node_vertices.shape[0]).reshape(-1, 3) - - def _make_triangle_set(verts, faces, tol=4): - """ - Return a hashable, order-independent representation of a given set of triangle faces. - - Rounds each vertex coordinate to the given tolerance, sorts vertices within each triangle, - and returns all triangles as a sorted tuple, eliminating any dependence on vertex or face order. - """ - tri_set = set() - for tri in faces: - coords = [tuple(round(float(coord), tol) for coord in verts[i]) for i in tri] - tri_set.add(tuple(sorted(coords))) - return tuple(sorted(tri_set)) - - # Triangles of FEM entity - entity_tris = _make_triangle_set(vertices, fem.surface_triangles) - - # Triangles of visualizer - viz_tris = _make_triangle_set(np.asarray(fem_node_vertices), np.asarray(fem_node_faces)) - - assert entity_tris == viz_tris, ( - "FEM entity surface triangles and visualizer mesh triangles do not match.\n" - f"Differences: {set(entity_tris) ^ set(viz_tris)}" + # The visualizer draws the input surface, whose vertices track the simulated vertices through the visual + # geom's sim_verts_idx, while surface_triangles reflects the refined tetrahedral boundary: the two meshes + # differ in granularity, so the invariant to check is per-vertex tracking. + (vgeom,) = fem.vgeoms + (fem_node_primitive,) = scene.visualizer.context.static_nodes[(0, vgeom.vmesh.uid)].mesh.primitives + viz_verts = fem_node_primitive.positions + assert vgeom.sim_verts_idx.shape == (viz_verts.shape[0],) + assert_allclose(viz_verts, vertices[vgeom.sim_verts_idx], tol=1e-5) + + +@pytest.mark.required +def test_multi_submesh_render_decoupling(show_viewer): + # Cloth material keeps the entity out of the tetrahedralization path, exercising the welded surface as + # simulation elements. The GLB asset holds 2 sub-meshes (bag and channel) with distinct materials. + scene = gs.Scene( + show_viewer=show_viewer, ) + fem = scene.add_entity( + morph=gs.morphs.Mesh( + file="meshes/trashbag_rope.glb", + group_by_material=True, + ), + material=gs.materials.FEM.Cloth(), + ) + scene.build() + + assert len(fem.vgeoms) == 2 + # Welding across visual geoms must keep strictly fewer simulated vertices than render vertices. + n_render_verts = sum(len(vgeom.vmesh.verts) for vgeom in fem.vgeoms) + assert fem.n_vertices < n_render_verts + vertices = tensor_to_array(fem.get_state().pos[0]) + for vgeom in fem.vgeoms: + assert vgeom.sim_verts_idx.shape == (len(vgeom.vmesh.verts),) + assert vgeom.sim_verts_idx.min() >= 0 and vgeom.sim_verts_idx.max() < fem.n_vertices + # Mapped simulated vertices must land exactly on the render vertices they stand for. + assert_allclose(vertices[vgeom.sim_verts_idx], vgeom.vmesh.verts, tol=1e-5) + + # The rasterizer must register one node per visual geom and environment. + static_nodes = scene.visualizer.context.static_nodes + assert all((0, vgeom.vmesh.uid) in static_nodes for vgeom in fem.vgeoms) @pytest.mark.required diff --git a/tests/rendering/test_offscreen.py b/tests/rendering/test_offscreen.py index 1e6e385ee5..6d935b99f6 100644 --- a/tests/rendering/test_offscreen.py +++ b/tests/rendering/test_offscreen.py @@ -768,10 +768,14 @@ def test_segmentation_map(segmentation_level, particle_mode, renderer_type, rend ) scene.build() - # Segmentation count: background(1) + URDF links/entity + duck materials. - # Rigid and Kinematic ducks use add_rigid_node (tuple keys at link/geom level), - # other ducks use add_static_node (int keys). The URDF has 2 visual links. + # Segmentation count: background(1) + URDF links/entity + duck materials. Rigid and Kinematic ducks use + # add_rigid_node (tuple keys at link/geom level), other ducks use add_static_node (int keys). The URDF has + # 2 visual links. FEM entities use per-visual-geom tuple keys (entity.idx, i_g) only at "geom" level; at "link" + # and "entity" levels they fall back to a plain int entity.idx. n_rigid_like = sum(isinstance(m, gs.materials.Kinematic) for m, _ in materials) + n_fem_geom = ( + sum(isinstance(m, gs.materials.FEM.Elastic) for m, _ in materials) if segmentation_level == "geom" else 0 + ) seg_num = len(materials) + (2 if segmentation_level == "entity" else 3) idx_dict = scene.segmentation_idx_dict assert len(idx_dict) == seg_num @@ -779,8 +783,9 @@ def test_segmentation_map(segmentation_level, particle_mode, renderer_type, rend for seg_key in idx_dict.values(): if isinstance(seg_key, tuple): comp_key += 1 - # At entity level no tuple keys; at link/geom level: 2 URDF links + rigid-like ducks - assert comp_key == (0 if segmentation_level == "entity" else 2 + n_rigid_like) + # At entity level no tuple keys; at link level: 2 URDF links + rigid-like ducks; at geom level additionally + # one tuple per FEM sub-mesh. + assert comp_key == (0 if segmentation_level == "entity" else 2 + n_rigid_like + n_fem_geom) for i in range(2): scene.step() @@ -789,6 +794,64 @@ def test_segmentation_map(segmentation_level, particle_mode, renderer_type, rend assert_equal(np.sort(np.unique(seg.flat)), np.arange(0, seg_num)) +@pytest.mark.required +@pytest.mark.parametrize("renderer_type", [RENDERER_TYPE.RASTERIZER, RENDERER_TYPE.RAYTRACER]) +def test_multi_submesh_fem_render(renderer_type, renderer, show_viewer, png_snapshot): + CAM_RES = (256, 256) + + scene = gs.Scene( + vis_options=gs.options.VisOptions( + segmentation_level="geom", + # Disable shadows systematically for Rasterizer because they are forcibly disabled on CPU backend anyway + shadow=(renderer_type != RENDERER_TYPE.RASTERIZER), + ), + renderer=renderer, + show_viewer=show_viewer, + show_FPS=False, + ) + # The scene has no ground plane: it serves no purpose here, and its out-of-frustum vertices would be + # misrasterized by the Apple Software Renderer. + fem = scene.add_entity( + morph=gs.morphs.Mesh( + file="meshes/trashbag_rope.glb", + group_by_material=True, + ), + material=gs.materials.FEM.Cloth(), + ) + camera = scene.add_camera( + res=CAM_RES, + pos=(0.6, 0.6, 0.55), + lookat=(0.0, 0.0, 0.32), + fov=45, + GUI=show_viewer, + denoise=False, + ) + scene.build() + + # Move the entity after build so rendering exercises the per-sub-mesh vertex update path. + fem.set_position([0.0, 0.0, 0.2]) + + if renderer_type == RENDERER_TYPE.RAYTRACER: + # Relax pixel matching because RayTracer is not deterministic between different hardware + # (eg RTX6000 vs H100), even without denoiser. + png_snapshot.extension._std_err_threshold = 3.0 + png_snapshot.extension._blurred_kernel_size = 3 + # RayTracer only supports the RGB channel; depth, segmentation and normal are rasterizer-only. + rgb, *_ = camera.render(rgb=True) + assert rgb_array_to_png_bytes(rgb) == png_snapshot + else: + if scene.visualizer.is_software: + # Software renderers add per-pixel sampling jitter on the speckled bag texture and along silhouette + # edges; blur it away and relax the threshold so the comparison still catches structural differences. + png_snapshot.extension._std_err_threshold = 3.0 + png_snapshot.extension._blurred_kernel_size = 3 + rgb, depth, seg, normal = camera.render(rgb=True, depth=True, segmentation=True, colorize_seg=True, normal=True) + assert rgb_array_to_png_bytes(rgb) == png_snapshot + assert rgb_array_to_png_bytes(as_grayscale_image(tensor_to_array(depth))) == png_snapshot + assert rgb_array_to_png_bytes(seg) == png_snapshot + assert rgb_array_to_png_bytes(normal) == png_snapshot + + @pytest.mark.required @pytest.mark.parametrize( "renderer_type", diff --git a/tests/utils.py b/tests/utils.py index 170da9077c..419168c986 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -44,7 +44,7 @@ DEFAULT_BRANCH_NAME = "main" HUGGINGFACE_ASSETS_REVISION = "990a727788f11e34ad006c69bf769303b20cb11c" -HUGGINGFACE_SNAPSHOT_REVISION = "d8e1eac38320a5507ea5b851d18431b1aba836dc" +HUGGINGFACE_SNAPSHOT_REVISION = "11a81c48367a18aedf0a3bed460b69c7ba7ccd4e" MESH_EXTENSIONS = (".mtl", *MESH_FORMATS, *GLTF_FORMATS, *USD_FORMATS) IMAGE_EXTENSIONS = (".png", ".jpg")