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.""" """Tests for MuJoCo Python rendering."""
import os
from absl.testing import absltest from absl.testing import absltest
import mujoco import mujoco
import numpy as np import numpy as np
@@ -104,6 +106,34 @@ class MuJoCoRenderTest(absltest.TestCase):
context.free() context.free()
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): def test_mjrrect_repr(self):
rect = mujoco.MjrRect(1, 2, 3, 4) rect = mujoco.MjrRect(1, 2, 3, 4)
rect_repr = repr(rect) 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]; int w = m->tex_width[texid];
float plane[4]; 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 // 2D texture
if (m->tex_type[texid] == mjTEXTURE_2D) { if (m->tex_type[texid] == mjTEXTURE_2D) {
// OpenGL settings // OpenGL settings
@@ -1390,11 +1400,9 @@ void mjr_uploadTexture(const mjModel* m, const mjrContext* con, int texid) {
if (m->tex_nchannel[texid] == 3) { if (m->tex_nchannel[texid] == 3) {
type = GL_RGB; type = GL_RGB;
internaltype = (m->tex_colorspace[texid] == mjCOLORSPACE_SRGB) ? GL_SRGB8_EXT : 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; type = GL_RGBA;
internaltype = (m->tex_colorspace[texid] == mjCOLORSPACE_SRGB) ? GL_SRGB8_ALPHA8_EXT : 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], glTexImage2D(GL_TEXTURE_2D, 0, internaltype, m->tex_width[texid],