#3578·mujoco

`smoothnormal="false"` is silently ignored for meshes loaded via a decoder plugin (e.g. STL)

Author: bd-gpasternakCreated Sep 14, 2026Updated Sep 17, 2026
Labelsbug

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

  1. 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)).
  2. Load it via a plain <mesh file="..."> reference (no usernormal supplied in the MJCF itself — the point is that a decoder plugin fills in normals, not the user).
  3. Inspect the compiled mjModel::mesh_normal / mesh_facenormal for 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):

cpp
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:

cpp
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 LoadFromDecoder still run the smoothnormal hard-edge pass over decoder-supplied normals (i.e. make MakeNormal's early-return conditional on more than just "normals are non-empty"), or
  • Document explicitly that smoothnormal only 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 usernormal in 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

  1. Load the model below.
  2. Run the code below.
  3. 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
xml

<mujoco>
  <worldbody>
    <light pos="0 0 1"/>
    <geom type="sphere" size="1" rgba="1 0 0 1"/>
  </worldbody>
</mujoco>

Code required for reproduction

python
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