`smoothnormal="false"` is silently ignored for meshes loaded via a decoder plugin (e.g. STL)
Intro
Hi!
I am a engineer at Boston Dynamics, I use MuJoCo for making dancing robots.
Summary
<mesh smoothnormal="false"> (the default) is documented as excluding large-angle
faces from vertex-normal averaging, so hard edges stay sharp. For meshes loaded
through mjCMesh::LoadFromDecoder (which covers the built-in STL path), this has
no effect: the decoder supplies its own usernormal array, mjCMesh::MakeNormal()
early-returns because normal data is already present, and smoothnormal is never
consulted. The result is that a perfectly flat-faced STL mesh can render with
visibly smoothed/rounded-looking shading, indistinguishable from
smoothnormal="true", with no warning that the attribute did nothing.
Repro
- Any STL mesh whose facets don't share indexed vertices one-for-one with a
flat-shading-preserving importer — in our case, a simple box model
(
30kg_box_1.stl, 400 triangles) where the raw STL facet normals are exactly flat per face (e.g. 88 triangles all carrying facet normal(1,0,0)). - Load it via a plain
<mesh file="...">reference (nousernormalsupplied in the MJCF itself — the point is that a decoder plugin fills in normals, not the user). - Inspect the compiled
mjModel::mesh_normal/mesh_facenormalfor that mesh.
Expected: with smoothnormal="false" (the default), adjacent facets meeting
at the box's ~90° edges keep distinct normals; the flat panels render as a single
uniform color under Lambertian shading.
Actual: normals vary continuously across nearly the entire surface — in our
case 392 of 400 triangles ended up with 3 differently-normal-ed corners, well
beyond the mesh's actual small edge bevels. The panels that are geometrically
flat (confirmed against the raw STL facet data) get Gouraud-interpolated smooth
shading as if smoothnormal="true" had been set.
Root cause
mjCMesh::LoadFromDecoder (src/user/user_mesh.cc):
mjsMesh* src_mesh = mjs_asMesh(elem);
if (src_mesh) {
normal_.assign(src_mesh->usernormal->begin(), src_mesh->usernormal->end());
...
}This unconditionally takes whatever normals the decoder produced. Then in
mjCMesh::MakeNormal:
void mjCMesh::MakeNormal(const double* dvert) {
// only if normal data is missing
if (!normal_.empty()) {
return;
}
...
// remove large-angle faces
if (!smoothnormal) {
...
}Because normal_ is already populated by the decoder, this function returns
before the smoothnormal hard-edge logic ever runs. smoothnormal is silently
inert whenever a mesh's normals originate from a decoder rather than from
MuJoCo's own from-scratch computation — with no error, warning, or documentation
of the interaction.
We worked around this downstream (in our own renderer, not MuJoCo) by
replicating the same after-the-fact correction MuJoCo's classic OpenGL backend
already applies at draw time for untextured meshes
(src/render/classic/render_context.c, mjr_uploadMesh): recompute the flat
per-triangle normal and discard a per-vertex normal that deviates more than
~37° (dot < 0.8) from it. That this correction already exists — but only in
one specific renderer's draw path, and not in the compiler where
smoothnormal is meant to be authoritative — suggests the compiler-side
behavior is the actual bug, and the renderer-side patch is a workaround for it,
not the intended fix.
Suggested fix
Either:
- Have
LoadFromDecoderstill run thesmoothnormalhard-edge pass over decoder-supplied normals (i.e. makeMakeNormal's early-return conditional on more than just "normals are non-empty"), or - Document explicitly that
smoothnormalonly applies when MuJoCo computes normals itself, and have the STL/decoder path either respect the flag or at minimum warn when discarding it.
Environment
- MuJoCo compiler (
src/user/user_mesh.cc,src/user/user_objects.h) - Repro asset: 400-triangle binary STL, no
usernormalin the MJCF (decoder-supplied)
My setup
mujoco 3.11.0, c++, customr renderer, ubuntu.
What's happening? What did you expect?
Setting rgba="1 0 0 1" renders as green, I expected it to render as red.
Here is a screen-shot showing a green sphere:
Steps for reproduction
- Load the model below.
- Run the code below.
- See green sphere (should be red).
Minimal model for reproduction
If you encountered the issue in a complex model, please simplify it as much as possible (while still reproducing the issue).
minimal XML
<mujoco>
<worldbody>
<light pos="0 0 1"/>
<geom type="sphere" size="1" rgba="1 0 0 1"/>
</worldbody>
</mujoco>
Code required for reproduction
import mujoco
import mediapy as media
model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)
with mujoco.Renderer(model) as renderer:
mujoco.mj_forward(model, data)
renderer.update_scene(data)
media.show_image(renderer.render())Confirmations
- I searched the latest documentation thoroughly before posting.
- I searched previous Issues and Discussions, I am certain this has not been raised before.
Source: google-deepmind/mujoco