Skip to main content

mujoco_rs/
renderer.rs

1//! Module related to implementation of the [`MjRenderer`].
2use crate::wrappers::mj_model::traits::ModelType;
3use crate::wrappers::mj_visualization::MjvScene;
4use crate::wrappers::mj_rendering::MjrContext;
5
6#[cfg(target_os = "linux")]
7use crate::renderer::egl::GlStateEgl;
8
9use crate::vis_common::{sync_geoms, flip_image_vertically, write_png};
10use crate::builder_setters;
11use crate::prelude::*;
12
13use bitflags::bitflags;
14#[cfg(all(feature = "renderer-winit-fallback", target_os = "linux"))]
15use log::warn;
16use log::debug;
17
18use std::io::{self, BufWriter, Write};
19use std::fmt::Display;
20use std::error::Error;
21use std::num::NonZero;
22use std::path::Path;
23use std::fs::File;
24
25/// Exported `png` crate for convenience.
26pub use png;
27
28/// Scale factor for converting normalized [0..1] depth to u16.
29const DEPTH_U16_SCALE: f32 = u16::MAX as f32;
30
31#[cfg(feature = "renderer-winit-fallback")]
32mod universal;
33
34#[cfg(feature = "renderer-winit-fallback")]
35use universal::GlStateWinit;
36
37#[cfg(target_os = "linux")]
38mod egl;
39
40
41/// Default headroom of internal visual-only geom slots, added on top of the model's `ngeom`.
42const EXTRA_INTERNAL_VISUAL_GEOMS: u32 = 100;
43
44/// Compute the depth near/far clip planes in metres from the model.
45fn model_near_far(model: &MjModel) -> (f32, f32) {
46    let map = &model.vis().map;
47    let extent = model.stat().extent as f32;
48    (map.znear * extent, map.zfar * extent)
49}
50
51
52/// GlState enum wrapper. By default, headless implementation will be used
53/// when supported. Only on failure will an invisible winit window be used.
54#[derive(Debug)]
55#[allow(clippy::large_enum_variant)]
56pub(crate) enum GlState {
57    #[cfg(feature = "renderer-winit-fallback")] Winit(GlStateWinit),
58    #[cfg(target_os = "linux")] Egl(egl::GlStateEgl),
59}
60
61impl GlState {
62    /// Creates a new [`GlState`], which by default tries to use
63    /// an offscreen implementation. As a fallback, winit will be used.
64    pub(crate) fn new(width: NonZero<u32>, height: NonZero<u32>) -> Result<Self, RendererError> {
65        #[cfg(target_os = "linux")]
66        #[allow(unused_variables)]
67        let egl_err = match GlStateEgl::new(width, height) {
68            Ok(egl_state) => return Ok(Self::Egl(egl_state)),
69            Err(e) => e,
70        };
71
72        #[cfg(feature = "renderer-winit-fallback")]
73        match GlStateWinit::new(width, height) {
74            Ok(winit_state) => {
75                #[cfg(target_os = "linux")]
76                warn!("offscreen rendering is not available ({egl_err}), using an invisible window");
77                return Ok(Self::Winit(winit_state));
78            },
79            #[cfg(not(target_os = "linux"))]
80            Err(e) => {
81                return Err(e);
82            },
83
84            #[cfg(target_os = "linux")]
85            _ => {}
86        }
87
88        #[cfg(target_os = "linux")]
89        Err(RendererError::GlutinError(egl_err))
90    }
91
92    /// Makes the internal OpenGL context current for the calling thread.
93    pub(crate) fn make_current(&self) -> glutin::error::Result<()> {
94        match self {
95            #[cfg(target_os = "linux")]
96            Self::Egl(egl_state) => egl_state.make_current(),
97            #[cfg(feature = "renderer-winit-fallback")]
98            Self::Winit(winit_state) => winit_state.make_current()
99        }
100    }
101}
102
103
104/// A builder for [`MjRenderer`].
105#[derive(Debug, Clone)]
106pub struct MjRendererBuilder {
107    width: u32,
108    height: u32,
109    num_visual_internal_geom: u32,
110    num_visual_user_geom: u32,
111    rgb: bool,
112    depth: bool,
113    png_compression: png::Compression,
114    font_scale: MjtFontScale,
115    camera: MjvCamera,
116    opts: MjvOption,
117}
118
119impl MjRendererBuilder {
120    /// Create a builder with default configuration.
121    /// Defaults are:
122    /// - `width` and `height`: `0`. When *both* stay `0`, the model's `offwidth` and `offheight`
123    ///   (visual/global settings) are used; setting only one of them gives
124    ///   [`RendererError::ZeroDimension`],
125    /// - `num_visual_internal_geom`: 100,
126    /// - `num_visual_user_geom`: 0,
127    /// - `rgb`: true,
128    /// - `depth`: false,
129    /// - `png_compression`: [`png::Compression::NoCompression`] (fastest, largest files),
130    /// - `font_scale`: [`MjtFontScale::mjFONTSCALE_100`],
131    /// - `camera`: [`MjvCamera::default()`] (MuJoCo's `mjv_defaultCamera`),
132    /// - `opts`: [`MjvOption::default()`] (MuJoCo's `mjv_defaultOption`).
133    pub fn new() -> Self {
134        Self {
135            width: 0, height: 0,
136            num_visual_internal_geom: EXTRA_INTERNAL_VISUAL_GEOMS, num_visual_user_geom: 0,
137            rgb: true, depth: false, png_compression: png::Compression::NoCompression,
138            font_scale: MjtFontScale::mjFONTSCALE_100,
139            camera: MjvCamera::default(), opts: MjvOption::default(),
140        }
141    }
142
143    builder_setters! {
144        width: u32; "
145image width.
146
147<div class=\"warning\">
148
149The width must be less or equal to the offscreen buffer width,
150which can be configured at the top of the model's XML like so:
151
152```xml
153<visual>
154    <global offwidth=\"1920\" .../>
155</visual>
156```
157
158</div>";
159
160        height: u32; "\
161image height.
162
163<div class=\"warning\">
164
165The height must be less or equal to the offscreen buffer height,
166which can be configured at the top of the model's XML like so:
167
168```xml
169<visual>
170    <global offheight=\"1080\" .../>
171</visual>
172```
173
174</div>";
175
176        num_visual_internal_geom: u32; "\
177            maximum number of additional visual-only internal geoms to allocate for.
178            Note that the total number of geoms in the internal scene will be
179            `model.ngeom` + `num_visual_internal_geom` + `num_visual_user_geom`.";
180
181        num_visual_user_geom: u32;      "maximum number of additional visual-only user geoms (drawn by the user).";
182        rgb: bool;                      "RGB rendering enabled (true) or disabled (false).";
183        depth: bool;                    "depth rendering enabled (true) or disabled (false).";
184        png_compression: png::Compression; "PNG compression level used by [`MjRenderer::save_rgb`] and [`MjRenderer::save_depth`].";
185        font_scale: MjtFontScale;       "font scale of drawn text (with [MjrContext]).";
186        camera: MjvCamera;              "camera used for drawing.";
187        opts: MjvOption;                "visualization options.";
188    }
189
190    /// Builds a [`MjRenderer`].
191    ///
192    /// This creates the rendering context, which can stop the process; see [`MjrContext::new`].
193    ///
194    /// # Returns
195    /// On success, returns [`Ok`] variant containing the [`MjRenderer`].
196    /// # Errors
197    /// - [`RendererError::ZeroDimension`] if only one of `width` and `height` is zero, or if both
198    ///   are zero and the model's `offwidth` or `offheight` is zero.
199    /// - [`RendererError::GlutinError`] if OpenGL initialization fails.
200    /// - [`RendererError::EventLoopError`] if the event loop fails to initialize, and
201    ///   [`RendererError::GlInitFailed`] if the fallback window initialization fails (feature
202    ///   `renderer-winit-fallback`, targets other than Linux).
203    ///
204    /// # Panics
205    /// Panics if the GL display reports no framebuffer configuration for the fallback window
206    /// (feature `renderer-winit-fallback`).
207    pub fn build(self, model: &MjModel) -> Result<MjRenderer, RendererError> {
208        // Assume model's maximum should be used
209        let mut height = self.height;
210        let mut width = self.width;
211        if width == 0 && height == 0 {
212            let global = &model.vis().global;
213            height = global.offheight as u32;
214            width = global.offwidth as u32;
215        }
216
217        let gl_state = GlState::new(
218            NonZero::new(width).ok_or(RendererError::ZeroDimension)?,
219            NonZero::new(height).ok_or(RendererError::ZeroDimension)?,
220        )?;
221
222        // Initialize the rendering context to render to the offscreen buffer.
223        // SAFETY: gl_state was just created above, establishing a current GL context.
224        let mut context = unsafe { MjrContext::new(model) };
225        context.offscreen();
226        context.change_font(self.font_scale);
227
228        let extra_geom = self.num_visual_internal_geom as usize + self.num_visual_user_geom as usize;
229        let (near, far) = model_near_far(model);
230
231        // The 3D scene for visualization
232        let scene = MjvScene::new(
233            model,
234            model.ffi().ngeom as usize + extra_geom
235        );
236
237        let user_scene = MjvScene::new(
238            model,
239            self.num_visual_user_geom as usize
240        );
241
242        // Construct the renderer and create allocated buffers.
243        let renderer = MjRenderer {
244            scene, user_scene, context, camera: self.camera, option: self.opts,
245            flags: RendererFlags::empty(), rgb: None, depth: None,
246            width: width as usize, height: height as usize, gl_state,
247            png_compression: self.png_compression, font_scale: self.font_scale,
248            near, far, extra_geom,
249        }   // These require special care
250            .with_rgb_rendering(self.rgb)
251            .with_depth_rendering(self.depth);
252
253        debug!("created an offscreen renderer of {width}x{height}");
254        Ok(renderer)
255    }
256}
257
258
259/// Delegates to [`MjRendererBuilder::new`].
260impl Default for MjRendererBuilder {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266/// A renderer for rendering 3D scenes.
267/// By default, RGB rendering is enabled and depth rendering is disabled.
268#[derive(Debug)]
269pub struct MjRenderer {
270    scene: MjvScene,
271    user_scene: MjvScene,
272    context: MjrContext,
273
274    /* OpenGL */
275    gl_state: GlState,
276
277    /* Configuration */
278    camera: MjvCamera,
279    option: MjvOption,
280    flags: RendererFlags,
281    png_compression: png::Compression,
282    font_scale: MjtFontScale,
283
284    /* Cached from the current model */
285    // Depth near/far clip planes in metres, derived from model.vis().map and model.stat().
286    near: f32,
287    far: f32,
288    // Scene capacity headroom beyond ngeom: preserved across model switches.
289    extra_geom: usize,
290
291    /* Storage */
292    // Use Box to allow less space to be used
293    // when rgb or depth rendering is disabled
294    rgb: Option<Box<[u8]>>,
295    depth: Option<Box<[f32]>>,
296
297    width: usize,
298    height: usize,
299}
300
301impl MjRenderer {
302    /// Construct a new renderer.
303    /// The `max_user_geom` parameter
304    /// defines how much space will be allocated for additional, user-defined visual-only geoms.
305    /// It can thus be set to 0 if no additional geoms will be drawn by the user.
306    /// # Scene allocation
307    /// The renderer uses two scenes:
308    /// - the internal scene: used by the renderer to draw the model's state.
309    /// - the user scene: used by the user to add additional geoms to the internal scene
310    ///
311    /// The **internal scene** allocates the amount of space needed to fit every pre-existing
312    /// model geom + user visual-only geoms + additional visual-only geoms that aren't from the user (e.g., tendons).
313    /// By default, the renderer reserves 100 extra geom slots for drawing the additional visual-only geoms.
314    /// If that is not enough or it is too much, you can construct [`MjRenderer`] via its builder
315    /// ([`MjRenderer::builder`]), which allows more configuration.
316    ///
317    /// <div class="warning">
318    ///
319    /// Parameters `width` and `height` must be less or equal to the offscreen buffer size,
320    /// which can be configured at the top of the model's XML like so:
321    ///
322    /// ```xml
323    /// <visual>
324    ///    <global offwidth="1920" offheight="1080"/>
325    /// </visual>
326    /// ```
327    ///
328    /// </div>
329    /// # Returns
330    /// On success, returns [`Ok`] variant containing the [`MjRenderer`].
331    /// # Errors
332    /// - [`RendererError::ZeroDimension`] if only one of `width` and `height` is zero, or if both
333    ///   are zero and the model's `offwidth` or `offheight` is zero.
334    /// - [`RendererError::GlutinError`] if OpenGL initialization fails. On Linux this also reports
335    ///   a failed winit fallback, because the initializer keeps the EGL error and drops the
336    ///   fallback's own error.
337    /// - [`RendererError::EventLoopError`] if the event loop fails to initialize, and
338    ///   [`RendererError::GlInitFailed`] if the fallback window initialization fails (feature
339    ///   `renderer-winit-fallback`, targets other than Linux).
340    ///
341    /// # Panics
342    /// Panics if the GL display reports no framebuffer configuration for the fallback window
343    /// (feature `renderer-winit-fallback`).
344    pub fn new(model: &MjModel, width: usize, height: usize, max_user_geom: usize) -> Result<Self, RendererError> {
345        // Saturate instead of truncating: `as u32` would turn an over-large value into a small
346        // valid one, so the renderer would silently use dimensions the caller never asked for.
347        MjRendererBuilder::new()
348            .width(width.try_into().unwrap_or(u32::MAX))
349            .height(height.try_into().unwrap_or(u32::MAX))
350            .num_visual_user_geom(max_user_geom.try_into().unwrap_or(u32::MAX))
351            .build(model)
352    }
353
354    /// Create a [`MjRendererBuilder`] to configure [`MjRenderer`].
355    pub fn builder() -> MjRendererBuilder {
356        MjRendererBuilder::new()
357    }
358
359    /// Return an immutable reference to the internal scene.
360    pub fn scene(&self) -> &MjvScene {
361        &self.scene
362    }
363
364    /// Return an immutable reference to a user scene for drawing custom visual-only geoms.
365    pub fn user_scene(&self) -> &MjvScene {
366        &self.user_scene
367    }
368
369    /// Return a mutable reference to a user scene for drawing custom visual-only geoms.
370    pub fn user_scene_mut(&mut self) -> &mut MjvScene {
371        &mut self.user_scene
372    }
373
374    /// Return an immutable reference to visualization options.
375    pub fn opts(&self) -> &MjvOption {
376        &self.option
377    }
378
379    /// Return a mutable reference to visualization options.
380    pub fn opts_mut(&mut self) -> &mut MjvOption {
381        &mut self.option
382    }
383
384    /// Return an immutable reference to the camera.
385    pub fn camera(&self) -> &MjvCamera {
386        &self.camera
387    }
388
389    /// Return a mutable reference to the camera.
390    pub fn camera_mut(&mut self) -> &mut MjvCamera {
391        &mut self.camera
392    }
393
394    /// Check if RGB rendering is enabled.
395    pub fn rgb_enabled(&self) -> bool {
396        self.flags.contains(RendererFlags::RENDER_RGB)
397    }
398
399    /// Check if depth rendering is enabled.
400    pub fn depth_enabled(&self) -> bool {
401        self.flags.contains(RendererFlags::RENDER_DEPTH)
402    }
403
404    /// Sets the font scale.
405    ///
406    /// # Errors
407    /// Returns [`RendererError::GlutinError`] if the OpenGL context cannot be made current.
408    pub fn set_font_scale(&mut self, font_scale: MjtFontScale) -> Result<(), RendererError> {
409        self.gl_state.make_current().map_err(RendererError::GlutinError)?;
410        self.font_scale = font_scale;
411        self.context.change_font(font_scale);
412        Ok(())
413    }
414
415    /// Update the visualization options.
416    pub fn set_opts(&mut self, options: MjvOption) {
417        self.option = options;
418    }
419
420    fn update_x_from(
421        &self,
422        model: &MjModel,
423        id: usize,
424        upload: fn(&MjrContext, &MjModel, usize) -> Result<(), crate::error::MjrContextError>,
425    ) -> Result<(), RendererError>
426    {
427        self.prepare_upload(model)?;
428        upload(&self.context, model, id)?;
429        Ok(())
430    }
431
432    fn prepare_upload(&self, model: &MjModel) -> Result<(), RendererError> {
433        // An upload reads only the asset arrays of `model` and writes only the context.
434        if !self.context.is_asset_compatible_with_model(model) {
435            return Err(RendererError::IncompatibleModel);
436        }
437        self.gl_state.make_current().map_err(RendererError::GlutinError)
438    }
439
440    fn update_all_from_impl(
441        &self,
442        model: &MjModel,
443        n: usize,
444        upload: fn(&MjrContext, &MjModel, usize) -> Result<(), crate::error::MjrContextError>,
445    ) -> Result<(), RendererError>
446    {
447        self.prepare_upload(model)?;
448        for id in 0..n {
449            upload(&self.context, model, id)?;
450        }
451        Ok(())
452    }
453
454    /// Re-uploads the texture with `texture_id` from `model` to the GPU immediately.
455    ///
456    /// # Errors
457    /// - [`RendererError::IncompatibleModel`] if the mesh, heightfield and texture part of the
458    ///   layout of `model` differs from that of the rendering context.
459    /// - [`RendererError::ContextError`] if `texture_id >= model.ntex()`.
460    /// - [`RendererError::GlutinError`] if the OpenGL context cannot be made current.
461    pub fn update_texture_from(&self, model: &MjModel, texture_id: usize) -> Result<(), RendererError> {
462        self.update_x_from(model, texture_id, MjrContext::upload_texture)
463    }
464
465    /// Re-uploads all textures from `model` to the GPU immediately.
466    ///
467    /// # Errors
468    /// - [`RendererError::IncompatibleModel`] if the mesh, heightfield and texture part of the
469    ///   layout of `model` differs from that of the rendering context.
470    /// - [`RendererError::GlutinError`] if the OpenGL context cannot be made current.
471    pub fn update_textures_from(&self, model: &MjModel) -> Result<(), RendererError> {
472        self.update_all_from_impl(model, model.ntex() as usize, MjrContext::upload_texture)
473    }
474
475    /// Re-uploads the mesh with `mesh_id` from `model` to the GPU immediately.
476    ///
477    /// All data arrays read by `mjr_uploadMesh` are copied: vertex positions
478    /// (`mesh_vert`), per-vertex normals (`mesh_normal`), UV texture coordinates
479    /// (`mesh_texcoord`), face-vertex indices (`mesh_face`), face-normal indices
480    /// (`mesh_facenormal`), face-texcoord indices (`mesh_facetexcoord`), and convex
481    /// hull graph data (`mesh_graph`).
482    ///
483    /// # Errors
484    /// - [`RendererError::IncompatibleModel`] if the mesh, heightfield and texture part of the
485    ///   layout of `model` differs from that of the rendering context.
486    /// - [`RendererError::ContextError`] if `mesh_id >= model.nmesh()`.
487    /// - [`RendererError::GlutinError`] if the OpenGL context cannot be made current.
488    pub fn update_mesh_from(&self, model: &MjModel, mesh_id: usize) -> Result<(), RendererError> {
489        self.update_x_from(model, mesh_id, MjrContext::upload_mesh)
490    }
491
492    /// Re-uploads all meshes from `model` to the GPU immediately.
493    ///
494    /// All data arrays read by `mjr_uploadMesh` are bulk-uploaded: vertex positions
495    /// (`mesh_vert`), per-vertex normals (`mesh_normal`), UV texture coordinates
496    /// (`mesh_texcoord`), face-vertex indices (`mesh_face`), face-normal indices
497    /// (`mesh_facenormal`), face-texcoord indices (`mesh_facetexcoord`), and convex
498    /// hull graph data (`mesh_graph`).
499    ///
500    /// # Errors
501    /// - [`RendererError::IncompatibleModel`] if the mesh, heightfield and texture part of the
502    ///   layout of `model` differs from that of the rendering context.
503    /// - [`RendererError::GlutinError`] if the OpenGL context cannot be made current.
504    pub fn update_meshes_from(&self, model: &MjModel) -> Result<(), RendererError> {
505        self.update_all_from_impl(model, model.nmesh() as usize, MjrContext::upload_mesh)
506    }
507
508    /// Re-uploads the heightfield with `hfield_id` from `model` to the GPU immediately.
509    ///
510    /// # Errors
511    /// - [`RendererError::IncompatibleModel`] if the mesh, heightfield and texture part of the
512    ///   layout of `model` differs from that of the rendering context.
513    /// - [`RendererError::ContextError`] if `hfield_id >= model.nhfield()`.
514    /// - [`RendererError::GlutinError`] if the OpenGL context cannot be made current.
515    pub fn update_hfield_from(&self, model: &MjModel, hfield_id: usize) -> Result<(), RendererError> {
516        self.update_x_from(model, hfield_id, MjrContext::upload_hfield)
517    }
518
519    /// Re-uploads all heightfields from `model` to the GPU immediately.
520    ///
521    /// # Errors
522    /// - [`RendererError::IncompatibleModel`] if the mesh, heightfield and texture part of the
523    ///   layout of `model` differs from that of the rendering context.
524    /// - [`RendererError::GlutinError`] if the OpenGL context cannot be made current.
525    pub fn update_hfields_from(&self, model: &MjModel) -> Result<(), RendererError> {
526        self.update_all_from_impl(model, model.nhfield() as usize, MjrContext::upload_hfield)
527    }
528
529    /// Set the camera used for rendering.
530    pub fn set_camera(&mut self, camera: MjvCamera)  {
531        self.camera = camera;
532    }
533
534    /// Enables/disables RGB rendering.
535    pub fn set_rgb_rendering(&mut self, enable: bool) {
536        self.flags.set(RendererFlags::RENDER_RGB, enable);
537        self.rgb = if enable { Some(vec![0; 3 * self.width * self.height].into_boxed_slice()) } else { None } ;
538    }
539
540    /// Enables/disables depth rendering.
541    pub fn set_depth_rendering(&mut self, enable: bool) {
542        self.flags.set(RendererFlags::RENDER_DEPTH, enable);
543        self.depth = if enable { Some(vec![0.0; self.width * self.height].into_boxed_slice()) } else { None } ;
544    }
545
546    /// Sets the font scale. To be used on construction.
547    ///
548    /// # Errors
549    /// Returns [`RendererError::GlutinError`] if the OpenGL context cannot be made current.
550    pub fn with_font_scale(mut self, font_scale: MjtFontScale) -> Result<Self, RendererError> {
551        self.set_font_scale(font_scale)?;
552        Ok(self)
553    }
554
555    /// Update the visualization options and return `self`. To be used on construction.
556    pub fn with_opts(mut self, options: MjvOption) -> Self {
557        self.set_opts(options);
558        self
559    }
560
561    /// Set the camera used for rendering. To be used on construction.
562    pub fn with_camera(mut self, camera: MjvCamera) -> Self  {
563        self.set_camera(camera);
564        self
565    }
566
567    /// Enables/disables RGB rendering. To be used on construction.
568    pub fn with_rgb_rendering(mut self, enable: bool) -> Self {
569        self.set_rgb_rendering(enable);
570        self
571    }
572
573    /// Enables/disables depth rendering. To be used on construction.
574    pub fn with_depth_rendering(mut self, enable: bool) -> Self {
575        self.set_depth_rendering(enable);
576        self
577    }
578
579    /// Set the PNG compression level used by [`MjRenderer::save_rgb`] and [`MjRenderer::save_depth`].
580    pub fn set_png_compression(&mut self, compression: png::Compression) {
581        self.png_compression = compression;
582    }
583
584    /// Set the PNG compression level. To be used on construction.
585    pub fn with_png_compression(mut self, compression: png::Compression) -> Self {
586        self.set_png_compression(compression);
587        self
588    }
589
590    /// Update the scene with new data from data.
591    /// When `data`'s model is not compatible with the renderer's scene or rendering context, the
592    /// internal scene, the user scene and the rendering context are recreated for the new model.
593    /// Recreating the context can stop the process; see [`MjrContext::new`].
594    ///
595    /// # Errors
596    /// - [`RendererError::GlutinError`] if the OpenGL context could not be made current
597    ///   (only when the [`MjModel`] in `data` is incompatible with the internal [`MjvScene`] or
598    ///   [`MjrContext`]).
599    ///
600    /// # Panics
601    /// Panics if the renderer's camera is a fixed camera whose `fixedcamid` is out of range for
602    /// `data`'s model.
603    pub fn sync_data<M: ModelType>(&mut self, data: &mut MjData<M>) -> Result<(), RendererError> {
604        if !(
605            self.scene.is_compatible_with_model(data.model()) &&
606            self.context.is_compatible_with_model(data.model())
607        ) {
608            /* Model changed: preserve the extra-geom headroom and user-geom
609             * capacity, only substitute the per-model ngeom base count. */
610            // Ensure the GL context is current before dropping old GPU resources
611            // (MjrContext::drop calls mjr_freeContext) and creating new ones.
612            self.gl_state.make_current().map_err(RendererError::GlutinError)?;
613
614            let user_geom_cap = self.user_scene.maxgeom() as usize;
615            let new_ngeom = data.model().ffi().ngeom as usize;
616            self.scene = MjvScene::new(data.model(), new_ngeom + self.extra_geom);
617            self.user_scene = MjvScene::new(data.model(), user_geom_cap);
618            (self.near, self.far) = model_near_far(data.model());
619
620            // Recreate the rendering context so that GPU resources (textures,
621            // meshes, heightfields, skins) match the new model.
622            // SAFETY: the GL context was made current above.
623            self.context = unsafe { MjrContext::new(data.model()) };
624            self.context.offscreen();
625            self.context.change_font(self.font_scale);
626        }
627
628        self.scene.update(data, &self.option, &MjvPerturb::default(), &mut self.camera);
629        Ok(())
630    }
631
632    /// Return a flattened RGB image of the scene.
633    pub fn rgb_flat(&self) -> Option<&[u8]> {
634        self.rgb.as_deref()
635    }
636
637    /// Return an RGB image of the scene. This method accepts two generic parameters <WIDTH, HEIGHT>
638    /// that define the shape of the output slice.
639    ///
640    /// # Panics
641    /// - If `WIDTH * HEIGHT` doesn't match the rendered pixel count.
642    /// - If RGB rendering is disabled.
643    ///
644    /// Use [`MjRenderer::try_rgb`] for a fallible alternative.
645    pub fn rgb<const WIDTH: usize, const HEIGHT: usize>(&self) -> &[[[u8; 3]; WIDTH]; HEIGHT] {
646        self.try_rgb::<WIDTH, HEIGHT>().unwrap()
647    }
648
649    /// Fallible version of [`MjRenderer::rgb`].
650    /// # Returns
651    /// On success, returns [`Ok`] variant containing the rendered RGB image.
652    /// # Errors
653    /// - [`RendererError::DimensionMismatch`] if `WIDTH * HEIGHT` doesn't match the rendered pixel count.
654    /// - [`RendererError::RgbDisabled`] if RGB rendering is disabled.
655    pub fn try_rgb<const WIDTH: usize, const HEIGHT: usize>(&self) -> Result<&[[[u8; 3]; WIDTH]; HEIGHT], RendererError> {
656        if let Some(flat) = self.rgb_flat() {
657            bytemuck::try_from_bytes(flat)
658                .map_err(|_| RendererError::DimensionMismatch)
659        }
660        else {
661            Err(RendererError::RgbDisabled)
662        }
663    }
664
665    /// Return a flattened depth image of the scene: the distance from the camera in metres,
666    /// between the near and far clip planes (`vis().map.znear * stat().extent` and
667    /// `vis().map.zfar * stat().extent`).
668    pub fn depth_flat(&self) -> Option<&[f32]> {
669        self.depth.as_deref()
670    }
671
672    /// Return a depth image of the scene. This method accepts two generic parameters <WIDTH, HEIGHT>
673    /// that define the shape of the output slice.
674    ///
675    /// # Panics
676    /// - If `WIDTH * HEIGHT` doesn't match the rendered pixel count. Only the product is checked,
677    ///   so swapped dimensions pass and return a sheared image.
678    /// - If depth rendering is disabled.
679    ///
680    /// Use [`MjRenderer::try_depth`] for a fallible alternative.
681    pub fn depth<const WIDTH: usize, const HEIGHT: usize>(&self) -> &[[f32; WIDTH]; HEIGHT] {
682        self.try_depth::<WIDTH, HEIGHT>().unwrap()
683    }
684
685    /// Fallible version of [`MjRenderer::depth`].
686    /// # Returns
687    /// On success, returns [`Ok`] variant containing the rendered depth image.
688    /// # Errors
689    /// - [`RendererError::DimensionMismatch`] if `WIDTH * HEIGHT` doesn't match the rendered pixel
690    ///   count (only the product is checked).
691    /// - [`RendererError::DepthDisabled`] if depth rendering is disabled.
692    pub fn try_depth<const WIDTH: usize, const HEIGHT: usize>(&self) -> Result<&[[f32; WIDTH]; HEIGHT], RendererError> {
693        if let Some(flat) = self.depth_flat() {
694            let bytes: &[u8] = bytemuck::cast_slice(flat);
695            bytemuck::try_from_bytes(bytes)
696                .map_err(|_| RendererError::DimensionMismatch)
697        }
698        else {
699            Err(RendererError::DepthDisabled)
700        }
701    }
702
703    /// Save an RGB image of the scene to a path.
704    /// # Returns
705    /// `Ok(())` on success.
706    /// # Errors
707    /// - [`RendererError::RgbDisabled`] when RGB rendering is disabled.
708    /// - [`RendererError::IoError`] if a file I/O operation fails.
709    pub fn save_rgb<T: AsRef<Path>>(&self, path: T) -> Result<(), RendererError> {
710        if let Some(rgb) = &self.rgb {
711            write_png(
712                path,
713                rgb,
714                self.width as u32,
715                self.height as u32,
716                png::ColorType::Rgb,
717                png::BitDepth::Eight,
718                self.png_compression
719            )?;
720            Ok(())
721        }
722        else {
723            Err(RendererError::RgbDisabled)
724        }
725    }
726
727    /// Save a depth image of the scene to a path. The image is 16-bit PNG, which
728    /// can be converted into depth (distance) data by dividing the grayscale values by
729    /// 65535.0 and applying inverse normalization: `depth = min + (grayscale / 65535.0) * (max - min)`.
730    ///
731    /// If `normalize` is `true`, then the data is normalized with per-frame min-max normalization.
732    /// When `normalize` is `false`, the depth values are mapped using the model's camera
733    /// near/far clip planes as the range, providing a fixed (frame-independent) mapping.
734    ///
735    /// Use of [`MjRenderer::save_depth_raw`] is recommended if performance is critical, as
736    /// it skips PNG encoding and also saves the true depth values directly.
737    /// # Returns
738    /// An [`Ok`]`((min, max))` is returned, where min and max represent the normalization parameters.
739    /// # Errors
740    /// - [`RendererError::DepthDisabled`] when depth rendering is disabled.
741    /// - [`RendererError::IoError`] if a file I/O operation fails.
742    pub fn save_depth<T: AsRef<Path>>(&self, path: T, normalize: bool) -> Result<(f32, f32), RendererError> {
743        if let Some(depth) = &self.depth {
744            let (norm, min, max) =
745            if normalize {
746                let max = depth.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
747                let min = depth.iter().cloned().fold(f32::INFINITY, f32::min);
748                let range = max - min;
749                if range == 0.0 {
750                    (vec![0u8; depth.len() * 2].into_boxed_slice(), min, max)
751                } else {
752                    (depth.iter().flat_map(|&x| (((x - min) / range * DEPTH_U16_SCALE).clamp(0.0, DEPTH_U16_SCALE) as u16).to_be_bytes()).collect::<Box<_>>(), min, max)
753                }
754            }
755            else {
756                // Use model's camera near/far clip planes as the fixed normalization range.
757                // After linearization (in render()), depth values are in meters within [near, far].
758                let near = self.near;
759                let far = self.far;
760                (depth.iter().flat_map(|&x| (((x - near) / (far - near) * DEPTH_U16_SCALE).clamp(0.0, DEPTH_U16_SCALE) as u16).to_be_bytes()).collect::<Box<_>>(), near, far)
761            };
762
763            write_png(
764                path,
765                &norm,
766                self.width as u32,
767                self.height as u32,
768                png::ColorType::Grayscale,
769                png::BitDepth::Sixteen,
770                self.png_compression
771            )?;
772            Ok((min, max))
773        }
774        else {
775            Err(RendererError::DepthDisabled)
776        }
777    }
778
779    /// Save the raw depth data to the `path`. The data is encoded
780    /// as a sequence of bytes, where groups of four represent a single f32 value
781    /// in native byte order.
782    /// # Returns
783    /// `Ok(())` on success.
784    /// # Errors
785    /// - [`RendererError::DepthDisabled`] when depth rendering is disabled.
786    /// - [`RendererError::IoError`] if a file I/O operation fails.
787    pub fn save_depth_raw<T: AsRef<Path>>(&self, path: T) -> Result<(), RendererError> {
788        if let Some(depth) = &self.depth {
789            let file = File::create(path.as_ref())?;
790            let mut writer = BufWriter::new(file);
791
792            let bytes: &[u8] = bytemuck::cast_slice(depth);
793            writer.write_all(bytes)?;
794            Ok(())
795        }
796        else {
797            Err(RendererError::DepthDisabled)
798        }
799    }
800
801    /// Draws the scene to internal arrays.
802    /// Use [`MjRenderer::rgb`] or [`MjRenderer::depth`] to obtain the rendered image.
803    ///
804    /// # Note
805    /// MuJoCo reports an error and stops the process when the scene holds geoms but
806    /// [`MjRenderer::sync_data`] was never called, which leaves `frustum_near` at zero.
807    ///
808    /// # Errors
809    /// - [`RendererError::GlutinError`] if the OpenGL context could not be made current.
810    /// - [`RendererError::SceneError`] if the user-scene sync overflows the geom buffer.
811    /// - [`RendererError::ContextError`] if reading pixels from the framebuffer fails.
812    pub fn render(&mut self) -> Result<(), RendererError> {
813        /* Sync user scene geoms into the main scene before rendering */
814        sync_geoms(&self.user_scene, &mut self.scene).map_err(RendererError::SceneError)?;
815
816        self.gl_state.make_current().map_err(RendererError::GlutinError)?;
817        let vp = MjrRectangle::new(0, 0, self.width as i32, self.height as i32);
818        self.scene.render(&vp, &self.context);
819
820        /* Fully flatten everything */
821        let flat_rgb = self.rgb.as_deref_mut();
822        let flat_depth = self.depth.as_deref_mut();
823
824        /* Read to whatever is enabled */
825        self.context.read_pixels(
826            flat_rgb,
827            flat_depth,
828            &vp
829        ).map_err(RendererError::ContextError)?;
830
831        /* Flip the read pixels vertically, as OpenGL reads bottom-up */
832        if let Some(rgb) = self.rgb.as_deref_mut() {
833            flip_image_vertically(rgb, self.height, self.width * 3);
834        }
835
836        /* Make depth values be the actual distance in meters and flip them vertically */
837        if let Some(depth) = self.depth.as_deref_mut() {
838            flip_image_vertically(depth, self.height, self.width);
839
840            let near = self.near;
841            let far = self.far;
842            for value in depth {
843                *value = near / (1.0 - *value * (1.0 - near / far));
844            }
845        }
846
847        Ok(())
848    }
849}
850
851/// Errors that can occur during renderer operations.
852#[derive(Debug)]
853#[non_exhaustive]
854pub enum RendererError {
855    /// The event loop failed to initialize.
856    #[cfg(feature = "renderer-winit-fallback")]
857    EventLoopError(winit::error::EventLoopError),
858    /// A glutin operation failed.
859    GlutinError(glutin::error::Error),
860    /// The supplied width or height was zero; the offscreen GL surface requires positive
861    /// dimensions.
862    ZeroDimension,
863    /// OpenGL / window initialization failed.
864    #[cfg(feature = "renderer-winit-fallback")]
865    GlInitFailed(crate::error::GlInitError),
866    /// RGB rendering was not enabled.
867    RgbDisabled,
868    /// Depth rendering was not enabled.
869    DepthDisabled,
870    /// The requested `WIDTH`/`HEIGHT` do not match the renderer's dimensions.
871    DimensionMismatch,
872    /// An I/O error occurred (e.g. while saving to a file).
873    IoError(io::Error),
874    /// A scene operation failed (e.g. user-scene sync overflowed the geom buffer).
875    SceneError(crate::error::MjSceneError),
876    /// A rendering-context operation failed (e.g. an asset or aux-buffer ID is out of range).
877    ContextError(crate::error::MjrContextError),
878    /// The model is not compatible with the renderer's scene or rendering context.
879    /// Call [`MjRenderer::sync_data`] first.
880    IncompatibleModel,
881}
882
883/// Formats a human-readable description of the renderer error.
884impl Display for RendererError {
885    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
886        match self {
887            #[cfg(feature = "renderer-winit-fallback")]
888            Self::EventLoopError(_) => write!(f, "event loop failed to initialize"),
889            Self::GlutinError(_) => write!(f, "glutin operation failed"),
890            Self::ZeroDimension => write!(f, "renderer width and height must both be greater than zero"),
891            #[cfg(feature = "renderer-winit-fallback")]
892            Self::GlInitFailed(_) => write!(f, "GL initialization failed"),
893            Self::RgbDisabled => write!(f, "RGB rendering is not enabled (renderer.with_rgb_rendering(true))"),
894            Self::DepthDisabled => write!(f, "depth rendering is not enabled (renderer.with_depth_rendering(true))"),
895            Self::DimensionMismatch => write!(f, "the input width and height don't match the renderer's configuration"),
896            Self::IoError(_) => write!(f, "I/O operation failed"),
897            Self::SceneError(_) => write!(f, "scene operation failed"),
898            Self::ContextError(_) => write!(f, "rendering context operation failed"),
899            Self::IncompatibleModel => write!(f, "the model is not compatible with the scene or the rendering context: call sync_data first"),
900        }
901    }
902}
903
904/// Provides the underlying error source, if any.
905impl Error for RendererError {
906    fn source(&self) -> Option<&(dyn Error + 'static)> {
907        match self {
908            #[cfg(feature = "renderer-winit-fallback")]
909            Self::EventLoopError(e) => Some(e),
910            #[cfg(feature = "renderer-winit-fallback")]
911            Self::GlInitFailed(e) => Some(e),
912            Self::GlutinError(e) => Some(e),
913            Self::IoError(e) => Some(e),
914            Self::SceneError(e) => Some(e),
915            Self::ContextError(e) => Some(e),
916            _ => None,
917        }
918    }
919}
920
921/// Converts an [`io::Error`] into [`RendererError::IoError`].
922impl From<io::Error> for RendererError {
923    fn from(e: io::Error) -> Self {
924        Self::IoError(e)
925    }
926}
927
928/// Converts a [`png::EncodingError`] into [`RendererError::IoError`].
929impl From<png::EncodingError> for RendererError {
930    fn from(e: png::EncodingError) -> Self {
931        Self::IoError(io::Error::from(e))
932    }
933}
934
935/// Converts an [`MjSceneError`] into [`RendererError::SceneError`].
936impl From<crate::error::MjSceneError> for RendererError {
937    fn from(e: crate::error::MjSceneError) -> Self {
938        Self::SceneError(e)
939    }
940}
941
942/// Converts an [`crate::error::MjrContextError`] into [`RendererError::ContextError`].
943impl From<crate::error::MjrContextError> for RendererError {
944    fn from(e: crate::error::MjrContextError) -> Self {
945        Self::ContextError(e)
946    }
947}
948
949/// Converts a [`GlInitError`] into [`RendererError::GlInitFailed`].
950#[cfg(feature = "renderer-winit-fallback")]
951impl From<crate::error::GlInitError> for RendererError {
952    fn from(e: crate::error::GlInitError) -> Self {
953        Self::GlInitFailed(e)
954    }
955}
956
957bitflags! {
958    /// Flags that enable features of the renderer.
959    #[derive(Debug)]
960    struct RendererFlags: u8 {
961        const RENDER_RGB = 1 << 0;
962        const RENDER_DEPTH = 1 << 1;
963    }
964}
965
966
967
968/// Makes the OpenGL context current, when it can, before GPU resources are freed.
969impl Drop for MjRenderer {
970    fn drop(&mut self) {
971        // Ensure the GL context is current before the implicit field drops
972        // (MjrContext's Drop calls mjr_freeContext which requires an active GL context).
973        let _ = self.gl_state.make_current();
974    }
975}
976
977/*
978** Don't run any tests as OpenGL hates if anything
979** runs outside the main thread.
980*/
981
982#[cfg(test)]
983mod test {
984    use crate::assert_relative_eq;
985
986    use super::*;
987
988    const MODEL: &str = stringify!(
989        <mujoco>
990
991        <visual>
992            <global offwidth="1280" offheight="720"/>
993        </visual>
994
995        <worldbody>
996            <geom name="floor" type="plane" size="10 10 1" euler="0 0 0"/>
997            <geom type="box" size="1 10 10" pos="-1 0 0" euler="0 0 0"/>
998
999            <camera name="depth_test" euler="90 90 0" pos="2.25 0 1"/>
1000
1001        </worldbody>
1002        </mujoco>
1003    );
1004
1005    /// Depth calculation test.
1006    /// This is only run on Linux due to EGL requirements (winit cannot be used on multiple threads).
1007    #[test]
1008    #[cfg(target_os = "linux")]
1009    fn test_depth() {
1010        let model = MjModel::from_xml_string(MODEL).expect("could not load the model");
1011        let mut data = MjData::new(&model);
1012        data.step();
1013
1014        let mut renderer = MjRenderer::builder()
1015            .rgb(false)
1016            .depth(true)
1017            .camera(MjvCamera::new_fixed(model.name_to_id(MjtObjBase::mjOBJ_CAMERA, "depth_test").unwrap()))
1018            .build(&model)
1019            .unwrap();
1020
1021        renderer.sync_data(&mut data).unwrap();
1022        renderer.render().unwrap();
1023        let min = renderer.depth_flat().unwrap().iter().fold(f32::INFINITY, |a , &b| a.min(b));
1024        let max = renderer.depth_flat().unwrap().iter().fold(f32::NEG_INFINITY, |a , &b| a.max(b));
1025
1026        assert_relative_eq!(min, max, epsilon = 1e-4);
1027        assert_relative_eq!(min, 2.25, epsilon = 1e-4);
1028    }
1029
1030    /// An upload accepts a model whose moved vertex changes the mesh hull, and refuses a model
1031    /// with another mesh count.
1032    /// This is only run on Linux due to EGL requirements (winit cannot be used on multiple threads).
1033    #[test]
1034    #[cfg(target_os = "linux")]
1035    fn test_upload_gate() {
1036        let xml = |vertex: &str, extra: &str| format!("<mujoco><asset><mesh name='m' vertex='0 0 0  1 0 0  \
1037            0 1 0  0 0 1  {vertex}  .5 .5 -.3'/>{extra}</asset><worldbody><geom type='mesh' mesh='m'/>\
1038            </worldbody></mujoco>");
1039        let base = MjModel::from_xml_string(&xml("1 1 1", "")).unwrap();
1040        let moved = MjModel::from_xml_string(&xml("1.2 1.1 1", "")).unwrap();
1041        let extra = MjModel::from_xml_string(&xml("1 1 1", "<mesh name='n' vertex='0 0 0  1 0 0  0 1 0  0 0 1'/>"))
1042            .unwrap();
1043        assert_ne!(base.mesh_graph(), moved.mesh_graph(), "the move must change the hull");
1044
1045        let renderer = MjRenderer::builder().build(&base).unwrap();
1046        renderer.update_mesh_from(&moved, 0).unwrap();
1047        renderer.update_meshes_from(&moved).unwrap();
1048        assert!(matches!(renderer.update_mesh_from(&extra, 0), Err(RendererError::IncompatibleModel)));
1049    }
1050
1051    /// This is only run on Linux due to EGL requirements (winit cannot be used on multiple threads).
1052    #[test]
1053    #[cfg(target_os = "linux")]
1054    #[should_panic(expected = "ASCII")]
1055    fn test_overlay_non_ascii_panics() {
1056        let model = MjModel::from_xml_string(MODEL).unwrap();
1057        let mut renderer = MjRenderer::builder().build(&model).unwrap();
1058        renderer.context.overlay(
1059            MjtFont::mjFONT_NORMAL, MjtGridPos::mjGRID_TOPLEFT, MjrRectangle::default(), "\u{e9}", None
1060        );
1061    }
1062
1063    /// Decode a PNG file and return the raw pixel bytes.
1064    #[cfg(target_os = "linux")]
1065    fn decode_png_pixels(path: &std::path::Path) -> Vec<u8> {
1066        let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
1067        let mut reader = decoder.read_info().unwrap();
1068        let mut buf = vec![0u8; reader.output_buffer_size().unwrap()];
1069        reader.next_frame(&mut buf).unwrap();
1070        buf
1071    }
1072
1073    /// Verify that all compression levels produce identical pixel data.
1074    /// This is only run on Linux due to EGL requirements (winit cannot be used on multiple threads).
1075    #[test]
1076    #[cfg(target_os = "linux")]
1077    fn test_png_compression_lossless() {
1078        let model = MjModel::from_xml_string(MODEL).expect("could not load the model");
1079        let mut data = MjData::new(&model);
1080        data.step();
1081
1082        let mut renderer = MjRenderer::builder()
1083            .rgb(true)
1084            .depth(false)
1085            .build(&model)
1086            .unwrap();
1087
1088        renderer.sync_data(&mut data).unwrap();
1089        renderer.render().unwrap();
1090
1091        let tmp = std::env::temp_dir();
1092        let path_none = tmp.join("mujoco_rs_test_none.png");
1093        let path_fast = tmp.join("mujoco_rs_test_fast.png");
1094        let path_high = tmp.join("mujoco_rs_test_high.png");
1095
1096        renderer.save_rgb(&path_none).unwrap();
1097        renderer.set_png_compression(png::Compression::Fast);
1098        renderer.save_rgb(&path_fast).unwrap();
1099        renderer.set_png_compression(png::Compression::High);
1100        renderer.save_rgb(&path_high).unwrap();
1101
1102        let pixels_none = decode_png_pixels(&path_none);
1103        let pixels_fast = decode_png_pixels(&path_fast);
1104        let pixels_high = decode_png_pixels(&path_high);
1105
1106        assert_eq!(pixels_none, pixels_fast, "Fast compression must produce identical pixels");
1107        assert_eq!(pixels_none, pixels_high, "High compression must produce identical pixels");
1108
1109        let _ = std::fs::remove_file(&path_none);
1110        let _ = std::fs::remove_file(&path_fast);
1111        let _ = std::fs::remove_file(&path_high);
1112    }
1113}