"""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