render: skip textures the classic renderer can't upload

KTX textures crash the classic OpenGL renderer and the Python viewer with
"Number of channels not supported: 1": mjCTexture::LoadKTX stores the file as
an opaque single-channel blob for the Filament renderer, and mjr_uploadTexture
aborts via mju_error because the classic path only handles 3- or 4-channel
textures.

Skip textures the classic renderer can't upload (nchannel not 3 or 4) with a
warning instead of aborting, so the model still loads and the other textures
render. LoadKTX and the Filament path are unchanged. The guard also covers the
cube/skybox path, which previously over-read a single-channel blob as RGB.

Add a render_test.py regression test that builds a model with a KTX texture and
constructs an MjrContext; it aborts before this change and passes after.

Addresses #3343.
This commit is contained in:
Anas
2026-06-20 11:05:31 -04:00
parent a8eaccd2b6
commit ce025f0ec3
2 changed files with 41 additions and 3 deletions
+30
View File
@@ -14,6 +14,8 @@
# ==============================================================================
"""Tests for MuJoCo Python rendering."""
import os
from absl.testing import absltest
import mujoco
import numpy as np
@@ -104,6 +106,34 @@ class MuJoCoRenderTest(absltest.TestCase):
context.free()
context.free()
def test_ktx_texture_does_not_crash_classic_renderer(self):
# KTX textures are stored as opaque single-channel blobs for the Filament
# renderer; mjCTexture::LoadKTX does not decode them. The classic renderer
# must skip such textures with a warning rather than aborting in
# mjr_uploadTexture (https://github.com/google-deepmind/mujoco/issues/3343).
ktx_path = os.path.join(self.create_tempdir().full_path, 'texture.ktx')
with open(ktx_path, 'wb') as f:
# LoadKTX stores the raw payload as a 1-channel blob without decoding, so
# the contents do not matter; start with the KTX 2.0 identifier for realism.
f.write(b'\xabKTX 20\xbb\r\n\x1a\n' + bytes(64))
model = mujoco.MjModel.from_xml_string(f"""
<mujoco>
<asset>
<texture name="ktx" type="2d" file="{ktx_path}"/>
<material name="mat" texture="ktx"/>
</asset>
<worldbody>
<geom type="plane" size="1 1 .1" material="mat"/>
</worldbody>
</mujoco>""")
self.assertEqual(model.tex_nchannel[0], 1)
# Building the render context uploads all textures; before the fix this
# aborted with "Number of channels not supported: 1". It must not raise now.
context = mujoco.MjrContext(model, mujoco.mjtFontScale.mjFONTSCALE_150)
context.free()
def test_mjrrect_repr(self):
rect = mujoco.MjrRect(1, 2, 3, 4)
rect_repr = repr(rect)
+11 -3
View File
@@ -1365,6 +1365,16 @@ void mjr_uploadTexture(const mjModel* m, const mjrContext* con, int texid) {
int w = m->tex_width[texid];
float plane[4];
// the classic renderer only uploads 3- (RGB) or 4-channel (RGBA) textures. KTX
// and other encoded textures are single-channel blobs for the Filament renderer
// (see LoadKTX); skip them with a warning instead of aborting (issue #3343).
if (m->tex_nchannel[texid] != 3 && m->tex_nchannel[texid] != 4) {
mju_warning("texture %d: classic renderer can't upload %d-channel data, "
"skipping (KTX/encoded textures need the Filament renderer)",
texid, m->tex_nchannel[texid]);
return;
}
// 2D texture
if (m->tex_type[texid] == mjTEXTURE_2D) {
// OpenGL settings
@@ -1390,11 +1400,9 @@ void mjr_uploadTexture(const mjModel* m, const mjrContext* con, int texid) {
if (m->tex_nchannel[texid] == 3) {
type = GL_RGB;
internaltype = (m->tex_colorspace[texid] == mjCOLORSPACE_SRGB) ? GL_SRGB8_EXT : GL_RGB;
} else if (m->tex_nchannel[texid] == 4) {
} else { // 4 channels, guaranteed by the channel check at the top of the function
type = GL_RGBA;
internaltype = (m->tex_colorspace[texid] == mjCOLORSPACE_SRGB) ? GL_SRGB8_ALPHA8_EXT : GL_RGBA;
} else {
mju_error("Number of channels not supported: %d", m->tex_nchannel[texid]);
}
glTexImage2D(GL_TEXTURE_2D, 0, internaltype, m->tex_width[texid],