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 # Thin G-buffer: whether this target carries the second colour # attachment. Mirrors the main HDR target's ``engine.gbuffer_active`` so the # shared forward pipelines (rebuilt with a second colour output when SSR/SSGI # activate the G-buffer) stay render-pass-compatible when they draw the scene # into this offscreen target. False (single attachment) on the default path. self._gbuffer: bool = False # 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 def _want_gbuffer(self) -> bool: return bool(getattr(self._engine, "gbuffer_active", False))
[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 self._build_target(register=True) 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 self._width = width self._height = height self._build_target(register=False)
[docs] def ensure_gbuffer(self) -> None: """Recreate the target if the engine's thin-G-buffer activation changed. SSR/SSGI toggling the G-buffer rebuilds the forward pipelines with a second colour output; a single-attachment offscreen pass would then be render-pass-incompatible with them (a hard crash when SSR is combined with a live RenderView / reflection probe). Called from the manager's prepare phase (before any offscreen draw is recorded), it rebuilds the target to match, preserving the bindless slot. A no-op when the state already matches, which is the common path (feature off, or already activated at create time).""" if self._target is None or self._want_gbuffer() == self._gbuffer: return self._build_target(register=False)
def _build_target(self, *, register: bool) -> None: """(Re)create the render target + Draw2DPass at the current size and G-buffer state. When *register* is False the existing bindless slot is rebound to the new colour view (resize / G-buffer toggle); otherwise a fresh slot is registered.""" old_slot = self._texture_id 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 # Match the HDR target's render pass exactly (colour[+gbuffer]+depth) so the # forward renderer's pipelines are render-pass-compatible when drawing the # scene into this offscreen target. samplable_depth=True mirrors the HDR setup. self._gbuffer = self._want_gbuffer() self._target = RenderTarget( self._engine.ctx.device, self._engine.ctx.physical_device, self._width, self._height, colour_format=self._colour_format, use_depth=True, samplable_depth=True, queue=self._engine.ctx.graphics_queue, command_pool=self._engine.ctx.command_pool, gbuffer=self._gbuffer, ) if register or old_slot < 0: self._texture_id = self._engine.register_texture(self._target.colour_view) else: self._engine.update_texture(old_slot, 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) # Compile the 2D pipelines against the COLOUR-ONLY overlay pass: that is the # pass ``render_draw2d`` actually records into (LOAD_OP_LOAD, no depth), and # it is unaffected by the thin G-buffer's second colour attachment on the 3D # ``render_pass`` (which would otherwise mismatch the 2D blend state). self._draw2d_pass.setup( render_pass=self._target.overlay_render_pass, extent=(self._width, self._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))] # G-buffer attachment (index 1) clears to zero, matching begin_hdr_pass. if rt.gbuffer_view is not None: clear_values.append(vk.VkClearValue(color=vk.VkClearColorValue(float32=[0.0, 0.0, 0.0, 0.0]))) clear_values.append(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, ) rt.begin_frame_barrier(cmd) # cross-frame WAW: shared target reused across FRAMES_IN_FLIGHT 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, ops: list) -> None: """Render pre-extracted Draw2D ops 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 ops 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, ops=ops) vk.vkCmdEndRenderPass(cmd)
[docs] @property def texture_id(self) -> int: """Bindless texture index for Draw2D, or -1 if not created.""" return self._texture_id
[docs] @property def width(self) -> int: return self._width
[docs] @property def height(self) -> int: return self._height
[docs] @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