[Bug]: A non-uniform node scale in a .glb tilts the imported vertex normals off the surface
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:
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_matA 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.
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.000Screenshots/Videos
Relevant log output
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.000The 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