google-deepmind / google-deepmind/mujoco

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

Open
#3,578 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
C++
Stars
15.2k
Forks
1.8k
Avg merge
10d 16h
Merged PRs (30d)
25

Description

### Intro

Hi!

I am a engineer at Boston Dynamics, I use MuJoCo for making dancing robots.

### Summary

`` (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 `` 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




```

### 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

- [x] I searched the [latest documentation](https://mujoco.readthedocs.io/en/latest/overview.html) thoroughly before posting.
- [x] I searched previous [Issues](https://github.com/google-deepmind/mujoco/issues) and [Discussions](https://github.com/google-deepmind/mujoco/discussions), I am certain this has not been raised before.

Contributor guide

Open the contributing guide

Research direction

Start in src/user/user_mesh.cc, following mjCMesh::LoadFromDecoder and mjCMesh::MakeNormal; compare this with the draw-time handling in src/render/classic/render_context.c and inspect mesh_normal and mesh_facenormal for the supplied 400-triangle STL. Reproduce with smoothnormal="false" and confirm that adjacent flat facets retain distinct normals without relying on renderer-specific correction.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, python
Domain
computer-graphics
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.