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