[Bug]: A non-uniform node scale in a .glb tilts the imported vertex normals off the surface

Author: kevin9327Created Sep 10, 2026Updated Sep 10, 2026

Bug Description

A glTF node may carry a non-uniform scale, and exporters use it routinely (a crate stretched into a plank, a cylinder squashed into a disc). When Genesis imports such a node, the geometry is scaled correctly but the shading normals are not: they end up tilted away from the surface they describe, so the imported mesh renders as if lit from the wrong direction.

The scaled triangle and its imported normals disagree with each other, which makes the defect checkable without appealing to any external renderer: if the authored normals are the triangle's own geometric normal, the imported ones must still be perpendicular to the imported triangle.

genesis/utils/mesh.py::apply_transform maps the normals through the linear part of the node transform:

python
rot_mat = transform[:3, :3]
if np.abs(3.0 - np.trace(rot_mat)) > gs.EPS**2:  # has rotation or scaling
    transformed_normals = normals @ rot_mat

A normal is a covector, so it maps through the inverse transpose of that matrix, not through the matrix itself. The two agree for a rotation and for a uniform scale (the renormalisation below absorbs the factor), and differ as soon as the scaling is non-uniform. Re-normalising afterwards fixes the length but cannot recover the direction.

The trace test adds a second way to miss it: a scale whose components sum to 3, (2.0, 0.5, 0.5) for instance, has trace exactly 3, so the whole branch is skipped and the normals are carried through untouched.

Steps to Reproduce

Self-contained, no external assets. It writes a one-triangle GLB whose authored normals are the triangle's own geometric normal, puts a non-uniform scale on the node, and compares the imported normals against the geometric normal of the imported triangle.

python
import tempfile
from pathlib import Path

import numpy as np
import pygltflib

import genesis as gs
from genesis.utils.gltf import parse_mesh_glb

POSITIONS = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32)


def unit_face_normal(verts):
    normal = np.cross(verts[1] - verts[0], verts[2] - verts[0])
    return normal / np.linalg.norm(normal)


def write_glb(path, node_scale):
    normals = np.tile(unit_face_normal(POSITIONS), (3, 1)).astype(np.float32)
    blob = b""
    buffer_views = []
    for data in (POSITIONS.tobytes(), normals.tobytes()):
        buffer_views.append(pygltflib.BufferView(buffer=0, byteOffset=len(blob), byteLength=len(data)))
        blob += data
    gltf = pygltflib.GLTF2(
        scene=0,
        scenes=[pygltflib.Scene(nodes=[0])],
        nodes=[pygltflib.Node(mesh=0, scale=list(node_scale))],
        meshes=[
            pygltflib.Mesh(
                primitives=[pygltflib.Primitive(attributes=pygltflib.Attributes(POSITION=0, NORMAL=1), mode=4)]
            )
        ],
        accessors=[
            pygltflib.Accessor(
                bufferView=0,
                componentType=pygltflib.FLOAT,
                count=3,
                type="VEC3",
                min=POSITIONS.min(axis=0).tolist(),
                max=POSITIONS.max(axis=0).tolist(),
            ),
            pygltflib.Accessor(bufferView=1, componentType=pygltflib.FLOAT, count=3, type="VEC3"),
        ],
        bufferViews=buffer_views,
        buffers=[pygltflib.Buffer(byteLength=len(blob))],
    )
    gltf.set_binary_blob(blob)
    gltf.save_binary(str(path))


gs.init(backend=gs.cpu, logging_level="error")
with tempfile.TemporaryDirectory() as directory:
    for node_scale in ((1.0, 1.0, 0.25), (2.0, 0.5, 0.5), (0.5, 0.5, 0.5)):
        path = Path(directory) / "scaled_node.glb"
        write_glb(path, node_scale)
        (mesh,) = parse_mesh_glb(
            str(path), group_by_material=False, scale=None, is_mesh_zup=True, surface=gs.surfaces.Default()
        )
        verts = np.asarray(mesh.trimesh.vertices)
        expected = unit_face_normal(verts)
        parsed = np.asarray(mesh.trimesh.vertex_normals)[0]
        print(f"node scale {node_scale}")
        print(f"  imported triangle          : {verts.tolist()}")
        print(f"  its geometric face normal  : {expected}")
        print(f"  imported vertex normal     : {parsed}")
        print(f"  they differ by             : {np.abs(parsed - expected).max():.3f}")

Expected Behavior

Every imported normal stays perpendicular to the imported triangle, whatever the node scale:

node scale (1.0, 1.0, 0.25)
  imported triangle          : [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.25]]
  its geometric face normal  : [0.23570226 0.23570226 0.94280904]
  imported vertex normal     : [0.23570228 0.23570228 0.9428091 ]
  they differ by             : 0.000

Screenshots/Videos

Relevant log output

bash
node scale (1.0, 1.0, 0.25)
  imported triangle          : [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.25]]
  its geometric face normal  : [0.23570226 0.23570226 0.94280904]
  imported vertex normal     : [0.69631064 0.69631064 0.17407766]
  they differ by             : 0.769

node scale (2.0, 0.5, 0.5)
  imported triangle          : [[2.0, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.5]]
  its geometric face normal  : [0.17407766 0.69631062 0.69631062]
  imported vertex normal     : [0.57735026 0.57735026 0.57735026]
  they differ by             : 0.403

node scale (0.5, 0.5, 0.5)
  imported triangle          : [[0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.5]]
  its geometric face normal  : [0.57735027 0.57735027 0.57735027]
  imported vertex normal     : [0.57735032 0.57735032 0.57735032]
  they differ by             : 0.000

The first case is 60.5 degrees off, the second 25.2 degrees. A uniform scale is unaffected.

Environment

  • OS: Windows 11 24H2
  • GPU/CPU: N/A - CPU backend, the defect is in the parser and is backend independent
  • GPU-driver version: N/A
  • CUDA / CUDA-toolkit version: N/A

Release version or Commit ID

1.4.0, commit b3c6c73a7a671fc486df5d69c9f481a91b1d57b6

Additional Context

apply_transform is called only from parse_mesh_glb, so the blast radius is the glTF importer. Happy to open a PR mapping the normals through the cofactor matrix of the linear part, which is the inverse transpose up to a factor the renormalisation drops, and adding a regression case to tests/parsers/test_mesh.py.

Source: Genesis-Embodied-AI/genesis-world