[Bug]: glTF alphaCutoff is never read, so a MASK material imports as BLEND

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

Bug Description

A glTF material with alphaMode: "MASK" asks for a hard cutout: every texel is either fully opaque or fully transparent, split at alphaCutoff (0.5 by default). It is what foliage, fences, grates and decals are authored with. Genesis imports such a material as if it were BLEND, keeping the authored alpha ramp, so the soft edge of the ramp survives instead of being cut away and the cutout renders with a translucent fringe.

genesis/utils/gltf.py::parse_glb_material never reads material.alphaCutoff:

python
alpha_cutoff = None
...
alpha_cutoff = mu.adjust_alpha_cutoff(alpha_cutoff, alpha_modes[material.alphaMode])

adjust_alpha_cutoff returns its argument for MASK, so alpha_cutoff stays None all the way to opacity_texture.apply_cutoff(alpha_cutoff), which returns immediately on None. The MASK branch of adjust_alpha_cutoff therefore never does anything, and MASK and BLEND import identically. OPAQUE is handled (it resolves to a cutoff of 0.0, which forces every texel opaque).

The USD parser reads the same quantity from the shader and applies it, in genesis/utils/usd/usd_material.py:

python
alpha_cutoff = get_input_attribute_value(shader, "opacityThreshold", "value")[0]
opacity_texture.apply_cutoff(alpha_cutoff)

so the two importers answer the same question differently.

Steps to Reproduce

Self-contained, no external assets. It writes a textured triangle whose base color texture is one row of pixels with a rising alpha ramp, and imports it under each alpha mode.

python
import io
import tempfile
from pathlib import Path

import numpy as np
import pygltflib
from PIL import Image

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

# One row of pixels whose alpha ramps up, the shape a foliage or fence cutout has along its edge.
ALPHA = np.array([[0, 64, 128, 192, 255]], dtype=np.uint8)
POSITIONS = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], dtype=np.float32)
UVS = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], dtype=np.float32)


def write_glb(path, alpha_mode, alpha_cutoff):
    rgba = np.full((1, ALPHA.shape[1], 4), 255, dtype=np.uint8)
    rgba[..., 3] = ALPHA
    buffer = io.BytesIO()
    Image.fromarray(rgba, mode="RGBA").save(buffer, format="PNG")

    blob = b""
    buffer_views = []
    for data in (buffer.getvalue(), POSITIONS.tobytes(), UVS.tobytes()):
        blob += b"\x00" * ((4 - len(blob) % 4) % 4)
        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)],
        meshes=[
            pygltflib.Mesh(
                name="cutout",
                primitives=[
                    pygltflib.Primitive(attributes=pygltflib.Attributes(POSITION=0, TEXCOORD_0=1), material=0)
                ],
            )
        ],
        materials=[
            pygltflib.Material(
                pbrMetallicRoughness=pygltflib.PbrMetallicRoughness(
                    baseColorTexture=pygltflib.TextureInfo(index=0, texCoord=0)
                ),
                alphaMode=alpha_mode,
                alphaCutoff=alpha_cutoff,
            )
        ],
        textures=[pygltflib.Texture(source=0)],
        images=[pygltflib.Image(bufferView=0, mimeType="image/png")],
        accessors=[
            pygltflib.Accessor(
                bufferView=1,
                componentType=pygltflib.FLOAT,
                count=3,
                type="VEC3",
                min=POSITIONS.min(axis=0).tolist(),
                max=POSITIONS.max(axis=0).tolist(),
            ),
            pygltflib.Accessor(bufferView=2, componentType=pygltflib.FLOAT, count=3, type="VEC2"),
        ],
        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")
print("authored alpha:", ALPHA.ravel().tolist())
with tempfile.TemporaryDirectory() as directory:
    for alpha_mode, alpha_cutoff in (("MASK", 0.6), ("MASK", 0.3), ("BLEND", 0.5), ("OPAQUE", 0.5)):
        path = Path(directory) / "cutout.glb"
        write_glb(path, alpha_mode, alpha_cutoff)
        (mesh,) = parse_mesh_glb(
            str(path), group_by_material=False, scale=None, is_mesh_zup=True, surface=gs.surfaces.Default()
        )
        opacity = mesh.surface.opacity_texture
        print(
            f"alphaMode={alpha_mode:6s} alphaCutoff={alpha_cutoff} -> "
            f"{np.asarray(opacity.image_array).ravel().tolist()}"
        )

Expected Behavior

A MASK material splits the authored alpha at its cutoff, 0.6 * 255 = 153 and 0.3 * 255 = 76.5:

authored alpha: [0, 64, 128, 192, 255]
alphaMode=MASK   alphaCutoff=0.6 -> [0, 0, 0, 255, 255]
alphaMode=MASK   alphaCutoff=0.3 -> [0, 0, 255, 255, 255]
alphaMode=BLEND  alphaCutoff=0.5 -> [0, 64, 128, 192, 255]
alphaMode=OPAQUE alphaCutoff=0.5 -> [255, 255, 255, 255, 255]

Screenshots/Videos

Relevant log output

bash
authored alpha: [0, 64, 128, 192, 255]
alphaMode=MASK   alphaCutoff=0.6 -> [0, 64, 128, 192, 255]
alphaMode=MASK   alphaCutoff=0.3 -> [0, 64, 128, 192, 255]
alphaMode=BLEND  alphaCutoff=0.5 -> [0, 64, 128, 192, 255]
alphaMode=OPAQUE alphaCutoff=0.5 -> [255, 255, 255, 255, 255]

The two MASK rows are the authored ramp, and both match the BLEND row.

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

Happy to open a PR reading material.alphaCutoff into the existing adjust_alpha_cutoff call and adding a regression case to tests/parsers/test_mesh.py.

Source: Genesis-Embodied-AI/genesis-world