[Bug]: glTF alphaCutoff is never read, so a MASK material imports as BLEND
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:
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:
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.
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
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