google-deepmind / google-deepmind/mujoco
`smoothnormal="false"` is silently ignored for meshes loaded via a decoder plugin (e.g. STL)
- 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
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