Source code for simvx.graphics.renderer.game_viewport

"""Game Viewport Renderer — offscreen render target for in-editor game preview.

Renders the game scene through the forward renderer into an offscreen
RenderTarget, then exposes it as a bindless texture for Draw2D to display
in the editor's viewport panel.

Follows the same pattern as PostProcessPass: render to offscreen target in
pre_render, sample the result as a texture in the main pass.

Usage:
    gvp = GameViewportRenderer(engine)
    gvp.create(width, height)

    # In pre_render (before main render pass):
    gvp.begin_pass(cmd)
    scene_renderer.render_scene_content(cmd)
    gvp.end_pass(cmd)

    # In Draw2D (inside main render pass):
    Draw2DTexture.draw_texture(gvp.texture_id, x, y, w, h)

    # Cleanup:
    gvp.destroy()
"""

import logging
from typing import Any

import vulkan as vk

from .draw2d_pass import Draw2DPass
from .render_target import RenderTarget

log = logging.getLogger(__name__)

__all__ = ["GameViewportRenderer"]

[docs] class GameViewportRenderer: """Manages an offscreen render target for rendering the game scene. The rendered result is registered as a bindless texture so Draw2D can display it as a textured quad in the editor viewport panel. Also owns a ``Draw2DPass`` compiled against the offscreen render pass, allowing game tree 2D overlays (Labels, Panels, custom ``draw()`` calls) to be rendered into the same target as the 3D scene. """ def __init__(self, engine: Any) -> None: self._engine = engine self._target: RenderTarget | None = None self._draw2d_pass: Draw2DPass | None = None self._texture_id: int = -1 self._width: int = 0 self._height: int = 0 # Match the HDR target format for render-pass compatibility with the # forward renderer's pipelines (built against the HDR offscreen pass). self._colour_format = vk.VK_FORMAT_R16G16B16A16_SFLOAT
[docs] def create(self, width: int, height: int) -> None: """Create the offscreen render target and register its texture. Uses R16G16B16A16_SFLOAT to match the HDR render pass format, ensuring pipeline compatibility with the 3D pipelines (which are compiled against the HDR offscreen render pass). """ if width < 1 or height < 1: return self._width = width self._height = height device = self._engine.ctx.device phys = self._engine.ctx.physical_device # Match the HDR target's render pass exactly so the forward renderer's # pipelines (built against the HDR pass) are render-pass-compatible # when used to draw into this offscreen target. samplable_depth=True # mirrors post_process.py's HDR target setup. self._target = RenderTarget( device, phys, width, height, colour_format=self._colour_format, use_depth=True, samplable_depth=True, ) # Register the colour attachment as a bindless texture self._texture_id = self._engine.register_texture(self._target.colour_view) # Create a Draw2DPass compiled against this target's render pass. # Share the main renderer's TextPass so the same MSDF atlas + pipeline # is used for text — without it, Labels, Buttons text, and any # draw_text calls render as empty (transparent) quads. text_pass = getattr(self._engine.renderer, "_text_pass", None) self._draw2d_pass = Draw2DPass(self._engine, text_pass=text_pass) self._draw2d_pass.setup( render_pass=self._target.render_pass, extent=(width, height), ) log.debug("GameViewportRenderer created %dx%d, texture_id=%d", width, height, self._texture_id)
[docs] def resize(self, width: int, height: int) -> None: """Recreate the render target at a new size. Preserves the bindless texture slot across resizes: the slot id the editor handed out stays stable, only the backing image view changes. Draw2D tickets that captured the old id keep working. """ if width == self._width and height == self._height: return if width < 1 or height < 1: return old_slot = self._texture_id # Tear down GPU resources but hold the slot. vk.vkDeviceWaitIdle(self._engine.ctx.device) if self._draw2d_pass is not None: self._draw2d_pass.cleanup() self._draw2d_pass = None if self._target is not None: self._target.destroy() self._target = None # Recreate the target and rebind the same slot to the new view. self._width = width self._height = height self._target = RenderTarget( self._engine.ctx.device, self._engine.ctx.physical_device, width, height, colour_format=self._colour_format, use_depth=True, samplable_depth=True, ) if old_slot >= 0: self._engine.update_texture(old_slot, self._target.colour_view) else: self._texture_id = self._engine.register_texture(self._target.colour_view) text_pass = getattr(self._engine.renderer, "_text_pass", None) self._draw2d_pass = Draw2DPass(self._engine, text_pass=text_pass) self._draw2d_pass.setup( render_pass=self._target.render_pass, extent=(width, height), )
[docs] def begin_pass(self, cmd: Any) -> None: """Begin the offscreen render pass (call before scene rendering).""" rt = self._target if rt is None: return cc = getattr(self._engine, "clear_colour", [0.0, 0.0, 0.0, 1.0]) clear_values = [ vk.VkClearValue(color=vk.VkClearColorValue(float32=cc)), vk.VkClearValue(depthStencil=vk.VkClearDepthStencilValue(depth=1.0, stencil=0)), ] rp_begin = vk.VkRenderPassBeginInfo( renderPass=rt.render_pass, framebuffer=rt.framebuffer, renderArea=vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=rt.width, height=rt.height), ), clearValueCount=len(clear_values), pClearValues=clear_values, ) vk.vkCmdBeginRenderPass(cmd, rp_begin, vk.VK_SUBPASS_CONTENTS_INLINE) # Set viewport and scissor to match render target dimensions vk.vkCmdSetViewport(cmd, 0, 1, [vk.VkViewport( x=0.0, y=0.0, width=float(rt.width), height=float(rt.height), minDepth=0.0, maxDepth=1.0, )]) vk.vkCmdSetScissor(cmd, 0, 1, [vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=rt.width, height=rt.height), )])
[docs] def end_pass(self, cmd: Any) -> None: """End the offscreen render pass.""" if self._target is None: return vk.vkCmdEndRenderPass(cmd)
[docs] def render_draw2d(self, cmd: Any, batches: list) -> None: """Render pre-extracted Draw2D batches into the offscreen target. Call AFTER end_pass (3D content) to overlay 2D game content. Begins its own render pass with LOAD_OP_LOAD to preserve 3D content. """ rt = self._target if rt is None or not batches or self._draw2d_pass is None: return # Begin a new render pass that loads (preserves) existing colour content rp_begin = vk.VkRenderPassBeginInfo( renderPass=rt.overlay_render_pass, framebuffer=rt.framebuffer, renderArea=vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=rt.width, height=rt.height), ), clearValueCount=0, pClearValues=None, ) vk.vkCmdBeginRenderPass(cmd, rp_begin, vk.VK_SUBPASS_CONTENTS_INLINE) vk.vkCmdSetViewport(cmd, 0, 1, [vk.VkViewport( x=0.0, y=0.0, width=float(rt.width), height=float(rt.height), minDepth=0.0, maxDepth=1.0, )]) vk.vkCmdSetScissor(cmd, 0, 1, [vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=rt.width, height=rt.height), )]) self._draw2d_pass.render(cmd, rt.width, rt.height, batches=batches) vk.vkCmdEndRenderPass(cmd)
@property def texture_id(self) -> int: """Bindless texture index for Draw2D, or -1 if not created.""" return self._texture_id @property def width(self) -> int: return self._width @property def height(self) -> int: return self._height @property def ready(self) -> bool: """True when the render target is created and ready for rendering.""" return self._target is not None and self._texture_id >= 0
[docs] def destroy(self) -> None: """Release all GPU resources.""" if self._target is None and self._draw2d_pass is None: return # Wait for any in-flight frames to finish using these resources before # destroying them. Without this, the validation layer reports framebuffer/ # image/view references as still bound to pending command buffers. vk.vkDeviceWaitIdle(self._engine.ctx.device) if self._draw2d_pass is not None: self._draw2d_pass.cleanup() self._draw2d_pass = None if self._target is not None: self._target.destroy() self._target = None # Release the bindless slot so future register_texture() calls # can reuse it. Without this, each create/destroy leaks one slot. if self._texture_id >= 0: self._engine.unregister_texture(self._texture_id) self._texture_id = -1 self._width = 0 self._height = 0