Source code for simvx.graphics.renderer.billboard2d_pass

"""Billboard2DPass: depth-tested camera-facing billboards (Sprite3D + Text3D).

The 2D-in-3D mechanism #2 of design §5.2 (P5b): a sibling of :class:`ParticlePass`
that draws ``Sprite3D`` images and ``Text3D`` MSDF glyph runs as camera-facing
quads INSIDE ``render_scene_content`` (the HDR 3D pass), depth-tested against the
3D geometry (``depth_test=True``, ``depth_write=False``, alpha blend) so a
billboard correctly occludes / is occluded by meshes.

GPU-driven like the particle / tilemap passes: every billboard instance is one
row of an SSBO (anchor, plane offset, half-size, uv rect, colour, bindless
texture slot, ``is_msdf`` flag); one ``vkCmdDraw`` of ``6 * N`` shader-generated
verts covers them all. The camera basis (``camera_right`` / ``camera_up`` from
``inv(view)``) and ``view_proj`` ride a push constant; the bindless texture array
is bound at set 1 (the same array tilemap / mesh sample), and the fragment shader
branches on ``is_msdf`` to decode Sprite3D textures linearly and Text3D glyphs via
the median-MSDF path (pixel-identical to ``text.frag`` / ``ui2d.frag``).
"""

import logging
from typing import Any

import numpy as np
import vulkan as vk

from ..gpu.memory import create_buffer, upload_numpy
from ..gpu.pipeline import PipelineSpec, build_pipeline
from .billboard2d_data import (
    BILLBOARD_DTYPE,
    FLAG_IS_MSDF,
    build_sprite3d_row,
    build_text3d_rows,
)
from .pass_helpers import load_shader_modules

__all__ = [
    "BILLBOARD_DTYPE",
    "FLAG_IS_MSDF",
    "Billboard2DPass",
    "build_sprite3d_row",
    "build_text3d_rows",
]

log = logging.getLogger(__name__)

MAX_BILLBOARDS = 20_000  # quads/frame across all Sprite3D + Text3D glyphs


[docs] class Billboard2DPass: """Depth-tested billboard pass for Sprite3D images and Text3D glyph runs.""" def __init__(self, engine: Any): self._engine = engine self._pipeline: Any = None self._pipeline_layout: Any = None self._vert_module: Any = None self._frag_module: Any = None self._ssbo_layout: Any = None self._ssbo_pool: Any = None self._ssbo_set: Any = None self._buf: Any = None self._mem: Any = None self._ready = False
[docs] def setup(self, render_pass: Any = None) -> None: e = self._engine device = e.ctx.device phys = e.ctx.physical_device buf_size = MAX_BILLBOARDS * BILLBOARD_DTYPE.itemsize self._buf, self._mem = create_buffer( device, phys, buf_size, vk.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, vk.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | vk.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, ) from ..gpu.descriptors import ( allocate_descriptor_set, create_descriptor_pool, create_ssbo_layout, write_ssbo_descriptor, ) self._ssbo_layout = create_ssbo_layout(device, binding_count=1) self._ssbo_pool = create_descriptor_pool(device, max_sets=1) self._ssbo_set = allocate_descriptor_set(device, self._ssbo_pool, self._ssbo_layout) write_ssbo_descriptor(device, self._ssbo_set, 0, self._buf, buf_size) self._vert_module, self._frag_module = load_shader_modules( device, e.shader_dir, "billboard2d.vert", "billboard2d.frag", ) self._create_pipeline(device, render_pass or e.render_pass, e.extent) self._ready = True log.debug("Billboard2D pass initialized (max %d billboards)", MAX_BILLBOARDS)
[docs] def rebuild_pipeline(self, render_pass: Any) -> None: """Recreate the pipeline against a different render pass (e.g. HDR).""" if not self._ready: return device = self._engine.ctx.device if self._pipeline: vk.vkDestroyPipeline(device, self._pipeline, None) if self._pipeline_layout: vk.vkDestroyPipelineLayout(device, self._pipeline_layout, None) self._create_pipeline(device, render_pass, self._engine.extent)
def _create_pipeline(self, device: Any, render_pass: Any, extent: tuple[int, int]) -> None: """Depth-tested (LESS_OR_EQUAL, no write) alpha-blend billboard pipeline. Set 0: billboard SSBO. Set 1: engine bindless texture array. Geometry is shader-generated (6 verts/billboard), so ``vertex_stride=0`` and ``cull_mode=NONE`` (camera-facing quads). ``depth_write=False`` so the translucent billboards blend over geometry without writing depth (same as ParticlePass), while ``depth_test`` makes them occluded by nearer meshes. """ spec = PipelineSpec( name="billboard2d", topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, vertex_stride=0, cull_mode=vk.VK_CULL_MODE_NONE, depth_test=True, depth_write=False, depth_compare=vk.VK_COMPARE_OP_LESS_OR_EQUAL, blend="alpha", dst_alpha_factor=vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, set_layouts=(self._ssbo_layout, self._engine.texture_descriptor_layout), push_size=96, # mat4 view_proj + vec3 right + px_range + vec3 up + pad push_stages=vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, ) self._pipeline, self._pipeline_layout = build_pipeline( device, spec, render_pass, extent, vert_module=self._vert_module, frag_module=self._frag_module, )
[docs] def render( self, cmd: Any, billboards: np.ndarray, view_proj: np.ndarray, camera_right: np.ndarray, camera_up: np.ndarray, extent: tuple[int, int], px_range: float = 4.0, ) -> None: """Record the billboard draw (one draw covers every instance).""" if not self._ready or len(billboards) == 0: return count = min(len(billboards), MAX_BILLBOARDS) if count < len(billboards): log.warning("Billboard2D overflow: %d (max %d)", len(billboards), MAX_BILLBOARDS) upload_numpy(self._engine.ctx.device, self._mem, billboards[:count]) # Push: mat4 view_proj(64) + vec3 right + px_range(16) + vec3 up + pad(16) = 96 pc = np.zeros(24, dtype=np.float32) pc[:16] = view_proj.T.ravel() # transpose for column-major GLSL pc[16:19] = camera_right pc[19] = px_range pc[20:23] = camera_up pc_bytes = pc.tobytes() cbuf = vk.ffi.new("char[]", pc_bytes) vk_vp = vk.VkViewport( x=0.0, y=0.0, width=float(extent[0]), height=float(extent[1]), minDepth=0.0, maxDepth=1.0, ) vk.vkCmdSetViewport(cmd, 0, 1, [vk_vp]) scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=extent[0], height=extent[1]), ) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline_layout, 0, 1, [self._ssbo_set], 0, None, ) tex_ds = self._engine.texture_descriptor_set if tex_ds: vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline_layout, 1, 1, [tex_ds], 0, None, ) vk._vulkan.lib.vkCmdPushConstants( cmd, self._pipeline_layout, vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, 0, len(pc_bytes), cbuf, ) vk.vkCmdDraw(cmd, count * 6, 1, 0, 0)
[docs] def cleanup(self) -> None: if not self._ready: return device = self._engine.ctx.device for obj, fn in [ (self._pipeline, vk.vkDestroyPipeline), (self._pipeline_layout, vk.vkDestroyPipelineLayout), (self._vert_module, vk.vkDestroyShaderModule), (self._frag_module, vk.vkDestroyShaderModule), (self._ssbo_layout, vk.vkDestroyDescriptorSetLayout), (self._ssbo_pool, vk.vkDestroyDescriptorPool), ]: if obj: fn(device, obj, None) if self._buf: vk.vkDestroyBuffer(device, self._buf, None) if self._mem: vk.vkFreeMemory(device, self._mem, None) self._ready = False