Skip to content

Commit 4a7f0b7

Browse files
committed
Added option to render a texture (e.g. Sensor output) as main image in the viewer
1 parent 05ca05a commit 4a7f0b7

10 files changed

Lines changed: 560 additions & 19 deletions
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add `ViewerGL.log_main_image()` to display a logged image (e.g. a sensor output
2+
texture) as the main viewer surface for a frame, skipping the 3D scene render
3+
while keeping the UI available.

docs/guide/visualization.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,19 @@ Warp array on the viewer device:
187187
# Returns a wp.array with shape (height, width, 3), dtype wp.uint8
188188
frame = viewer.get_frame()
189189
190+
**Main image display:**
191+
192+
:meth:`~newton.viewer.ViewerGL.log_main_image` logs an image and displays it as
193+
the main viewer surface for the current frame. While a main image is logged,
194+
:class:`~newton.viewer.ViewerGL` skips the normal 3D scene render and draws that
195+
image directly to the window, with the UI still available on top. If a later
196+
frame does not call ``log_main_image()``, the viewer returns to normal 3D scene
197+
rendering for that frame:
198+
199+
.. code-block:: python
200+
201+
viewer.log_main_image("sensor", sensor_rgba)
202+
190203
**Custom UI panels:**
191204

192205
:meth:`~newton.viewer.ViewerGL.register_ui_callback` adds custom imgui UI elements to the viewer.

newton/_src/viewer/gl/image_logger.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,19 @@ class LoggedImage:
326326
window_initialized: bool = False
327327

328328

329+
@dataclass(frozen=True)
330+
class LoggedImageTexture:
331+
"""Texture metadata needed to draw a logged image atlas."""
332+
333+
texture_id: int
334+
texture_width: int
335+
texture_height: int
336+
tile_count: int
337+
tile_width: int
338+
tile_height: int
339+
atlas_cols: int
340+
341+
329342
class ImageLogger:
330343
"""Owns GL resources for images logged via :meth:`~newton.viewer.ViewerBase.log_image`.
331344
@@ -408,16 +421,20 @@ def log(self, name: str, image: wp.array[Any] | np.ndarray) -> None:
408421
entry.atlas_cols, entry.atlas_rows = atlas_cols, atlas_rows
409422
entry.tile_aspect = h / w
410423

411-
def draw(self) -> None:
424+
def draw(self, *, hidden_name: str | None = None) -> None:
412425
"""Draw the selected image window (if any).
413426
414427
Called once per frame inside the viewer's ImGui frame block.
415428
At most one window is visible at a time; selection is driven by
416429
:meth:`draw_controls`.
430+
431+
Args:
432+
hidden_name: Optional selected image name to suppress, used when
433+
that image is already drawn as the main viewer surface.
417434
"""
418435
from imgui_bundle import imgui
419436

420-
if self._selected is None:
437+
if self._selected is None or self._selected == hidden_name:
421438
return
422439
entry = self._images.get(self._selected)
423440
if entry is None or entry.n == 0 or entry.tex_id == 0:
@@ -524,6 +541,29 @@ def draw_controls(self) -> None:
524541
if changed:
525542
self._selected = None if new_idx == 0 else names[new_idx - 1]
526543

544+
def get_texture(self, name: str) -> LoggedImageTexture | None:
545+
"""Return texture metadata for a logged image.
546+
547+
Args:
548+
name: Image name previously passed to :meth:`log`.
549+
550+
Returns:
551+
Texture metadata for the packed image atlas, or ``None`` when the
552+
image has not been logged or has no live GL texture yet.
553+
"""
554+
entry = self._images.get(name)
555+
if entry is None or entry.tex_id == 0 or entry.tex_w <= 0 or entry.tex_h <= 0:
556+
return None
557+
return LoggedImageTexture(
558+
texture_id=entry.tex_id,
559+
texture_width=entry.tex_w,
560+
texture_height=entry.tex_h,
561+
tile_count=entry.n,
562+
tile_width=entry.w,
563+
tile_height=entry.h,
564+
atlas_cols=entry.atlas_cols,
565+
)
566+
527567
def clear(self) -> None:
528568
"""Destroy all GL resources. Idempotent."""
529569
for entry in list(self._images.values()):

newton/_src/viewer/gl/opengl.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,27 @@ def check_gl_error():
5353
print(f"Called from: {''.join(stack[-2:-1])}")
5454

5555

56+
def _texture_tile_uv_rect(
57+
tile_index: int,
58+
tile_width: int,
59+
tile_height: int,
60+
texture_width: int,
61+
texture_height: int,
62+
atlas_cols: int,
63+
) -> tuple[float, float, float, float]:
64+
"""Return a V-flipped UV rect for a row-major image atlas tile."""
65+
atlas_cols = max(1, int(atlas_cols))
66+
atlas_row, atlas_col = divmod(tile_index, atlas_cols)
67+
u_step = tile_width / float(texture_width)
68+
v_step = tile_height / float(texture_height)
69+
return (
70+
atlas_col * u_step,
71+
(atlas_row + 1) * v_step,
72+
(atlas_col + 1) * u_step,
73+
atlas_row * v_step,
74+
)
75+
76+
5677
def _upload_texture_from_file(gl, texture_image: np.ndarray) -> int:
5778
image = normalize_texture(
5879
texture_image,
@@ -1334,6 +1355,108 @@ def render(self, camera, objects, lines=None, wireframe_shapes=None, arrows=None
13341355
err = gl.glGetError()
13351356
assert err == gl.GL_NO_ERROR, hex(err)
13361357

1358+
def render_texture(
1359+
self,
1360+
texture_id: int | None,
1361+
texture_width: int,
1362+
texture_height: int,
1363+
*,
1364+
tile_count: int = 1,
1365+
tile_width: int | None = None,
1366+
tile_height: int | None = None,
1367+
atlas_cols: int = 1,
1368+
spacing_px: float = 2.0,
1369+
clear_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0),
1370+
):
1371+
"""Draw a texture directly to the window without rendering the 3D scene.
1372+
1373+
Args:
1374+
texture_id: OpenGL texture id to draw, or ``None`` to only clear.
1375+
texture_width: Source texture width in pixels.
1376+
texture_height: Source texture height in pixels.
1377+
tile_count: Number of source tiles packed into the texture atlas.
1378+
tile_width: Width of each atlas tile in pixels. Defaults to
1379+
``texture_width`` for single-image textures.
1380+
tile_height: Height of each atlas tile in pixels. Defaults to
1381+
``texture_height`` for single-image textures.
1382+
atlas_cols: Number of atlas columns used to pack the source tiles.
1383+
spacing_px: Spacing between displayed tiles in pixels.
1384+
clear_color: Window clear color.
1385+
"""
1386+
gl = RendererGL.gl
1387+
self._make_current()
1388+
1389+
screen_w = max(int(self._screen_width), 1)
1390+
screen_h = max(int(self._screen_height), 1)
1391+
1392+
gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
1393+
gl.glClearColor(*clear_color)
1394+
gl.glDisable(gl.GL_DEPTH_TEST)
1395+
gl.glDepthMask(True)
1396+
gl.glDisable(gl.GL_BLEND)
1397+
gl.glViewport(0, 0, screen_w, screen_h)
1398+
gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
1399+
gl.glDepthMask(False)
1400+
1401+
if texture_id is None or texture_id == 0 or texture_width <= 0 or texture_height <= 0:
1402+
gl.glDepthMask(True)
1403+
return
1404+
1405+
from .image_logger import compute_grid_layout # noqa: PLC0415
1406+
1407+
tile_count = max(1, int(tile_count))
1408+
tile_width = int(tile_width or texture_width)
1409+
tile_height = int(tile_height or texture_height)
1410+
atlas_cols = max(1, int(atlas_cols))
1411+
spacing_px = max(0.0, float(spacing_px if tile_count > 1 else 0.0))
1412+
1413+
rows, cols, cell_w, cell_h = compute_grid_layout(
1414+
tile_count,
1415+
tile_height / float(max(tile_width, 1)),
1416+
float(screen_w),
1417+
float(screen_h),
1418+
spacing_x=spacing_px,
1419+
spacing_y=spacing_px,
1420+
)
1421+
grid_w = cols * cell_w + max(0, cols - 1) * spacing_px
1422+
grid_h = rows * cell_h + max(0, rows - 1) * spacing_px
1423+
origin_x = (screen_w - grid_w) * 0.5
1424+
origin_y = (screen_h - grid_h) * 0.5
1425+
1426+
gl.glActiveTexture(gl.GL_TEXTURE0)
1427+
gl.glBindTexture(gl.GL_TEXTURE_2D, int(texture_id))
1428+
with self._frame_shader:
1429+
for i in range(tile_count):
1430+
display_row, display_col = divmod(i, cols)
1431+
draw_x = max(0, int(round(origin_x + display_col * (cell_w + spacing_px))))
1432+
draw_y = max(
1433+
0,
1434+
int(round(origin_y + (rows - 1 - display_row) * (cell_h + spacing_px))),
1435+
)
1436+
draw_w = max(1, int(round(cell_w)))
1437+
draw_h = max(1, int(round(cell_h)))
1438+
uv_rect = _texture_tile_uv_rect(
1439+
i,
1440+
tile_width,
1441+
tile_height,
1442+
texture_width,
1443+
texture_height,
1444+
atlas_cols,
1445+
)
1446+
1447+
gl.glViewport(draw_x, draw_y, draw_w, draw_h)
1448+
self._frame_shader.update(0, uv_rect)
1449+
gl.glBindVertexArray(self._frame_vao)
1450+
gl.glDrawElements(gl.GL_TRIANGLES, len(self._frame_indices), gl.GL_UNSIGNED_INT, None)
1451+
gl.glBindVertexArray(0)
1452+
gl.glBindTexture(gl.GL_TEXTURE_2D, 0)
1453+
1454+
gl.glViewport(0, 0, screen_w, screen_h)
1455+
gl.glDepthMask(True)
1456+
1457+
err = gl.glGetError()
1458+
assert err == gl.GL_NO_ERROR, hex(err)
1459+
13371460
def present(self):
13381461
if not self.headless:
13391462
if self._dwm_flush is not None and self.window._interval:

newton/_src/viewer/gl/shaders.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -461,9 +461,11 @@
461461
out vec4 FragColor;
462462
463463
uniform sampler2D texture_sampler;
464+
uniform vec4 uv_rect;
464465
465466
void main() {
466-
FragColor = texture(texture_sampler, TexCoord);
467+
vec2 uv = mix(uv_rect.xy, uv_rect.zw, TexCoord);
468+
FragColor = texture(texture_sampler, uv);
467469
}
468470
"""
469471

@@ -696,11 +698,13 @@ def __init__(self, gl):
696698
# Get uniform locations
697699
with self:
698700
self.loc_texture = self._get_uniform_location("texture_sampler")
701+
self.loc_uv_rect = self._get_uniform_location("uv_rect")
699702

700-
def update(self, texture_unit: int = 0):
701-
"""Update texture uniform."""
703+
def update(self, texture_unit: int = 0, uv_rect: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)):
704+
"""Update the texture unit and UV sub-rect uniforms."""
702705
with self:
703706
self._gl.glUniform1i(self.loc_texture, texture_unit)
707+
self._gl.glUniform4f(self.loc_uv_rect, *uv_rect)
704708

705709

706710
wireframe_vertex_shader = """

newton/_src/viewer/viewer_gl.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ def __init__(
246246
sidebar_width_px=self._sidebar_width_fb_px(),
247247
dpi_scale=self._dpi_scale(),
248248
)
249+
self._main_image_name: str | None = None
249250

250251
fb_w, fb_h = self.renderer.window.get_framebuffer_size()
251252
self.camera = Camera(width=fb_w, height=fb_h, up_axis="Z")
@@ -280,7 +281,9 @@ def __init__(
280281
# Register GL-specific rendering options (sky, shadows, wireframe, colors)
281282
self.gui.register_ui_callback(self._ui_populate_rendering_panel, position="rendering")
282283
# Draw image-logger floating windows outside the sidebar window.
283-
self.gui.register_ui_callback(lambda _imgui: self._image_logger.draw(), position="free")
284+
self.gui.register_ui_callback(
285+
lambda _imgui: self._image_logger.draw(hidden_name=self._main_image_name), position="free"
286+
)
284287
# Top-level Layers panel (visible only when multiple layers exist).
285288
self.gui.register_ui_callback(self._ui_populate_layers_panel, position="panel")
286289

@@ -576,6 +579,8 @@ def _filter_destroy(d: dict) -> dict:
576579

577580
if getattr(self, "_image_logger", None) is not None:
578581
self._image_logger.clear_matching(owns)
582+
if owns(getattr(self, "_main_image_name", "") or ""):
583+
self._main_image_name = None
579584

580585
# Drop example-registered side/free UI callbacks (panel/stats/rendering persist).
581586
if getattr(self, "gui", None) is not None:
@@ -1496,6 +1501,25 @@ def log_image(self, name: str, image: wp.array[Any] | np.ndarray) -> None:
14961501
name = self._qualify(name)
14971502
self._image_logger.log(name, image)
14981503

1504+
def log_main_image(self, name: str, image: wp.array[Any] | np.ndarray) -> None:
1505+
"""Log an image and display it as the main viewer surface for this frame.
1506+
1507+
When a main image is logged for a frame, :class:`ViewerGL` skips the
1508+
normal 3D scene render and draws the image texture directly to the
1509+
window. If no main image is logged before a later :meth:`end_frame`,
1510+
the viewer returns to the normal 3D scene render for that frame.
1511+
1512+
Args:
1513+
name: Stable identifier for the image.
1514+
image: Image array. See :meth:`log_image` for accepted shapes and
1515+
dtypes.
1516+
"""
1517+
if not isinstance(name, str) or not name:
1518+
raise ValueError("main image name must be a non-empty string")
1519+
name = self._qualify(name)
1520+
self._image_logger.log(name, image)
1521+
self._main_image_name = name
1522+
14991523
@override
15001524
def log_scalar(
15011525
self,
@@ -1729,17 +1753,36 @@ def _update(self):
17291753
if self.wind is not None:
17301754
self.wind.update(dt)
17311755

1732-
# If the window was closed during event processing, skip rendering
1733-
if self.renderer.has_exit():
1734-
return
1735-
1736-
# Render the scene and present it
1737-
self.renderer.render(self.camera, self.objects, self.lines, self.wireframe_shapes, self.arrows)
1756+
try:
1757+
# If the window was closed during event processing, skip rendering
1758+
if self.renderer.has_exit():
1759+
return
1760+
1761+
# Render either the selected logged image or the 3D scene, then present it.
1762+
main_image_name = self._main_image_name
1763+
if main_image_name is not None:
1764+
texture = self._image_logger.get_texture(main_image_name)
1765+
if texture is None:
1766+
self.renderer.render_texture(None, 0, 0)
1767+
else:
1768+
self.renderer.render_texture(
1769+
texture.texture_id,
1770+
texture.texture_width,
1771+
texture.texture_height,
1772+
tile_count=texture.tile_count,
1773+
tile_width=texture.tile_width,
1774+
tile_height=texture.tile_height,
1775+
atlas_cols=texture.atlas_cols,
1776+
)
1777+
else:
1778+
self.renderer.render(self.camera, self.objects, self.lines, self.wireframe_shapes, self.arrows)
17381779

1739-
if self.gui:
1740-
self.gui.render_frame(update_fps=True)
1780+
if self.gui:
1781+
self.gui.render_frame(update_fps=True)
17411782

1742-
self.renderer.present()
1783+
self.renderer.present()
1784+
finally:
1785+
self._main_image_name = None
17431786

17441787
def get_frame(self, target_image: wp.array | None = None, render_ui: bool = False) -> wp.array:
17451788
"""

0 commit comments

Comments
 (0)