Source code for simvx.graphics.renderer.draw2d_pass

"""2D drawing pass: renders Draw2D geometry using ui.vert + draw2d_fill.frag, plus MSDF text.

Fill fragments are modulated by the ``Light2DPass`` accumulation texture when
any ``PointLight2D`` is present in the scene (multiply blend with an ambient
floor). With no lights submitted, the composite is a bypass and fills render
with their unlit vertex colour.
"""

import logging
from typing import Any

import numpy as np
import vulkan as vk

from ..types import SHADER_DIR
from ..draw2d_vertex import UI_VERTEX_DTYPE
from ..gpu.memory import create_buffer, upload_numpy
from .pass_helpers import create_sampler_descriptor_pool

__all__ = ["Draw2DPass"]

log = logging.getLogger(__name__)

# Pre-allocate buffers: sized for full terminal rendering (100x30 terminal
# with bitmap font can generate ~100K verts for character pixels + backgrounds)
MAX_FILL_VERTS = 131072   # 128K verts (4 MB)
MAX_LINE_VERTS = 32768    # 32K verts (1 MB)
VERTEX_STRIDE = 32        # pos(vec2) + uv(vec2) + colour(vec4)
MAX_FILL_INDICES = 196608  # 192K indices (768 KB)
MAX_TEXT_VERTS = 32768
MAX_TEXT_INDICES = 49152
MAX_TEX_VERTS = 16384
MAX_TEX_INDICES = 24576

# Fill push constant layout: vec2 screen_size + vec2 pad + vec4 ambient + ivec4 flags = 48 bytes
FILL_PUSH_SIZE = 48
# Ambient floor applied to unlit regions when any PointLight2D is submitted.
# The live value comes from Light2DPass.ambient_light_2d (synced from
# WorldEnvironment.ambient_light_2d); this is only the fallback used when no
# Light2DPass is wired (e.g. a sub-viewport Draw2DPass without lighting).
_DEFAULT_AMBIENT = (0.2, 0.2, 0.2, 1.0)

[docs] class Draw2DPass: """GPU pass that renders 2D fills (triangles), lines, and MSDF text from Draw2D buffers. Text rendering shares the TextPass's pipeline, descriptor set, and atlas: only the text vertex/index buffers are owned here (needed for per-batch scissor clipping). """ __slots__ = ( "_engine", "_text_pass", "_light2d_pass", "_fill_pipeline", "_fill_pipeline_layout", "_fill_pipelines", "_fill_desc_layout", "_fill_desc_pool", "_fill_desc_set", "_fill_desc_view", "_line_pipeline", "_line_pipeline_layout", "_vert_module", "_frag_module", "_line_frag_module", "_fill_vb", "_fill_vb_mem", "_fill_ib", "_fill_ib_mem", "_line_vb", "_line_vb_mem", "_text_vb", "_text_vb_mem", "_text_ib", "_text_ib_mem", "_tex_pipeline", "_tex_pipeline_layout", "_tex_frag_module", "_tex_pipelines", "_tex_vb", "_tex_vb_mem", "_tex_ib", "_tex_ib_mem", "_ready", "last_frame_draw_count", "_render_pass", "_extent", ) def __init__(self, engine: Any, text_pass: Any = None, light2d_pass: Any = None): for slot in self.__slots__: object.__setattr__(self, slot, None) self._engine = engine self._text_pass = text_pass self._light2d_pass = light2d_pass self._ready = False self.last_frame_draw_count = 0
[docs] def setup(self, render_pass: Any = None, extent: tuple[int, int] | None = None) -> None: """Create pipelines and allocate GPU buffers. Args: render_pass: Vulkan render pass to compile pipelines against. Defaults to the engine's main (swapchain) render pass. extent: Framebuffer extent (width, height). Defaults to engine extent. """ e = self._engine device = e.ctx.device phys = e.ctx.physical_device rp = render_pass or e.render_pass ext = extent or e.extent # Remember the target pass + extent so the TEXT branch can ask the shared # TextPass for an MSDF pipeline compiled against THIS pass (RTT-2D into an # R16F SubViewport target needs a format-matched text pipeline, design §5.3). self._render_pass = rp self._extent = ext # Compile shaders: fill uses draw2d_fill.frag (modulated by Light2D accum), # lines use the plain solid-colour frag (UI overlays are not lit). from ..gpu.pipeline import ( UI_VERTEX_ATTRS, UI_VERTEX_STRIDE, PipelineSpec, build_pipeline, create_shader_module, ) from ..materials.shader_compiler import compile_shader self._vert_module = create_shader_module(device, compile_shader(SHADER_DIR / "ui.vert")) self._frag_module = create_shader_module(device, compile_shader(SHADER_DIR / "draw2d_fill.frag")) self._line_frag_module = create_shader_module( device, compile_shader(SHADER_DIR / "ui_solid.frag"), ) # Fill pipeline uses set 0 = light accumulation sampler2D from # Light2DPass. The shader bypasses the texture sample entirely when the # has_lights push-constant flag is 0, so we only need a valid view # bound to keep Vulkan validation happy; Light2DPass.setup pre- # transitions its RT to SHADER_READ_ONLY_OPTIMAL for that reason. self._fill_desc_layout = _create_fill_descriptor_layout(device) self._fill_desc_pool, desc_sets = create_sampler_descriptor_pool( device, self._fill_desc_layout, ) self._fill_desc_set = desc_sets[0] if self._light2d_pass is not None: view = self._light2d_pass.get_light_texture_view() sampler = self._light2d_pass.get_light_sampler() _write_fill_descriptor(device, self._fill_desc_set, view, sampler) self._fill_desc_view = view # All three Draw2D pipelines share: UI vertex layout, alpha blend (with # dst-alpha = 1-srcAlpha so framebuffer alpha is preserved), no depth, # no culling, dynamic viewport + scissor. They differ only in topology, # push-constant size, and descriptor set layouts. # Fill pipeline (triangle topology): light-accum sampler at set 0, 48B push. # One pipeline per blend mode (alpha default, plus additive + multiply # for draw_rect/draw_polygon blend=). The ordered-ops batching splits # runs on a blend change, so each run binds the matching pipeline. self._fill_pipelines = {} for _blend in ("alpha", "add", "multiply"): fill_spec = PipelineSpec( name=f"draw2d_fill_{_blend}", topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, vertex_stride=UI_VERTEX_STRIDE, vertex_attrs=UI_VERTEX_ATTRS, cull_mode=vk.VK_CULL_MODE_NONE, depth_test=False, depth_write=False, blend=_blend, dst_alpha_factor=vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, set_layouts=(self._fill_desc_layout,), push_size=FILL_PUSH_SIZE, ) self._fill_pipelines[_blend] = build_pipeline( device, fill_spec, rp, ext, vert_module=self._vert_module, frag_module=self._frag_module, ) # Alpha is the canonical entry point reused by the kind-bind path. self._fill_pipeline, self._fill_pipeline_layout = self._fill_pipelines["alpha"] # Line pipeline (line topology): no descriptor set, 8-byte push (vec2 screen_size). line_spec = PipelineSpec( name="draw2d_line", topology=vk.VK_PRIMITIVE_TOPOLOGY_LINE_LIST, vertex_stride=UI_VERTEX_STRIDE, vertex_attrs=UI_VERTEX_ATTRS, cull_mode=vk.VK_CULL_MODE_NONE, depth_test=False, depth_write=False, blend="alpha", dst_alpha_factor=vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, push_size=8, # vec2 screen_size ) self._line_pipeline, self._line_pipeline_layout = build_pipeline( device, line_spec, rp, ext, vert_module=self._vert_module, frag_module=self._line_frag_module, ) # Allocate host-visible buffers host_flags = ( vk.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | vk.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ) self._fill_vb, self._fill_vb_mem = create_buffer( device, phys, MAX_FILL_VERTS * VERTEX_STRIDE, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_flags, ) self._fill_ib, self._fill_ib_mem = create_buffer( device, phys, MAX_FILL_INDICES * 4, vk.VK_BUFFER_USAGE_INDEX_BUFFER_BIT, host_flags, ) self._line_vb, self._line_vb_mem = create_buffer( device, phys, MAX_LINE_VERTS * VERTEX_STRIDE, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_flags, ) # Text vertex/index buffers (geometry assembled per-batch for scissor clipping) self._text_vb, self._text_vb_mem = create_buffer( device, phys, MAX_TEXT_VERTS * VERTEX_STRIDE, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_flags, ) self._text_ib, self._text_ib_mem = create_buffer( device, phys, MAX_TEXT_INDICES * 4, vk.VK_BUFFER_USAGE_INDEX_BUFFER_BIT, host_flags, ) # Textured quad pipeline: TRIANGLE_LIST, bindless sampler2D[] at set 0, # 12-byte push (vec2 screen_size + int texture_id). tex_frag_spv = compile_shader(SHADER_DIR / "ui.frag") self._tex_frag_module = create_shader_module(device, tex_frag_spv) tex_layout = e.texture_descriptor_layout self._tex_pipelines = {} for _blend in ("alpha", "add", "multiply"): tex_spec = PipelineSpec( name=f"draw2d_textured_{_blend}", topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, vertex_stride=UI_VERTEX_STRIDE, vertex_attrs=UI_VERTEX_ATTRS, cull_mode=vk.VK_CULL_MODE_NONE, depth_test=False, depth_write=False, blend=_blend, dst_alpha_factor=vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, set_layouts=(tex_layout,), push_size=12, # vec2 screen_size + int texture_id ) self._tex_pipelines[_blend] = build_pipeline( device, tex_spec, rp, ext, vert_module=self._vert_module, frag_module=self._tex_frag_module, ) self._tex_pipeline, self._tex_pipeline_layout = self._tex_pipelines["alpha"] self._tex_vb, self._tex_vb_mem = create_buffer( device, phys, MAX_TEX_VERTS * VERTEX_STRIDE, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_flags, ) self._tex_ib, self._tex_ib_mem = create_buffer( device, phys, MAX_TEX_INDICES * 4, vk.VK_BUFFER_USAGE_INDEX_BUFFER_BIT, host_flags, ) self._ready = True
[docs] def render(self, cmd: Any, width: int, height: int, ui_width: int = 0, ui_height: int = 0, ops: list | None = None) -> None: """Render 2D geometry in submission order. Walks ``Draw2D._ops`` (or the provided ``ops`` list, e.g. an isolated play-mode tree), coalesces adjacent same-(kind, clip, tex_id) ops into one GPU draw, and emits draws in submission order, so the order in which ``draw_rect`` / ``draw_text`` / ``draw_texture`` calls happen in ``on_draw`` is the order they hit the framebuffer. Args: ops: Pre-extracted op list. If None, pulls from Draw2D singleton. """ if not self._ready: self.last_frame_draw_count = 0 return from ..draw2d import Draw2D from ..draw2d_ops import OpKind if ops is None: ops = Draw2D._ops if not ops: self.last_frame_draw_count = 0 return device = self._engine.ctx.device # UI coordinates may differ from framebuffer pixels (HiDPI / window vs framebuffer) uw = ui_width or width uh = ui_height or height screen = np.array([uw, uh], dtype=np.float32) # Refresh the light-accumulation descriptor if Light2DPass recreated its RT # (happens on window resize). Rare, so waitIdle is acceptable here. if self._light2d_pass is not None: current_view = self._light2d_pass.get_light_texture_view() if current_view != self._fill_desc_view: vk.vkDeviceWaitIdle(device) _write_fill_descriptor( device, self._fill_desc_set, current_view, self._light2d_pass.get_light_sampler(), ) self._fill_desc_view = current_view # Build fill push constants: vec2 screen + vec2 pad + vec4 ambient + ivec4(has_lights, 0, 0, 0) # The ambient floor is the WorldEnvironment-synced value on the light # pass (falling back to the dark-neutral default when no pass is wired). has_lights = 1 if ( self._light2d_pass is not None and self._light2d_pass.has_lights ) else 0 ambient = ( self._light2d_pass.ambient_light_2d if self._light2d_pass is not None else _DEFAULT_AMBIENT ) fill_push = _build_fill_push(uw, uh, ambient, has_lights) vk_viewport = vk.VkViewport( x=0.0, y=0.0, width=float(width), height=float(height), minDepth=0.0, maxDepth=1.0, ) full_scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=width, height=height), ) # Pass 1: coalesce ops into runs by (kind, clip, tex_id). Each run is # one GPU draw. Accumulate raw vertex tuples + integer indices into # per-kind staging lists; one numpy conversion happens per kind at # upload time (Pass 2): not per op. kind_verts: dict[int, list[tuple]] = {k: [] for k in (0, 1, 2, 3)} kind_indices: dict[int, list[int]] = {k: [] for k in (0, 1, 2, 3)} kind_vert_cursor = [0, 0, 0, 0] kind_idx_cursor = [0, 0, 0, 0] # (kind, clip, tex_id, blend, vert_start, vert_count, idx_start, idx_count) draws: list[tuple[int, tuple | None, int, str, int, int, int, int]] = [] sentinel = object() run_kind: int | None = None run_clip: Any = sentinel run_tex = -1 run_blend = "alpha" run_vert_start = 0 run_idx_start = 0 run_vert_count = 0 run_idx_count = 0 run_local_vert_off = 0 for op in ops: k = int(op.kind) # FILL and TEX honour the per-op blend mode; LINE/TEXT are always # alpha, so a blend change must not split their runs. op_blend = op.blend if k in (OpKind.FILL, OpKind.TEX) else "alpha" same_run = ( k == run_kind and op.clip == run_clip and op_blend == run_blend and (k != OpKind.TEX or op.tex_id == run_tex) ) if not same_run: if run_kind is not None and (run_vert_count or run_idx_count): draws.append(( run_kind, run_clip, run_tex, run_blend, run_vert_start, run_vert_count, run_idx_start, run_idx_count, )) run_kind = k run_clip = op.clip run_tex = op.tex_id run_blend = op_blend run_vert_start = kind_vert_cursor[k] run_idx_start = kind_idx_cursor[k] run_vert_count = 0 run_idx_count = 0 run_local_vert_off = 0 nv = len(op.verts) kind_verts[k].extend(op.verts) kind_vert_cursor[k] += nv run_vert_count += nv if op.indices is not None: # Indices in the per-op payload are local to the op (start at 0). # Within a run we offset them by the run-local vertex count so the # concatenated index stream is valid relative to ``run_vert_start``, # which becomes vkCmdDrawIndexed's vertexOffset. if run_local_vert_off: kind_indices[k].extend(i + run_local_vert_off for i in op.indices) else: kind_indices[k].extend(op.indices) kind_idx_cursor[k] += len(op.indices) run_idx_count += len(op.indices) run_local_vert_off += nv if run_kind is not None and (run_vert_count or run_idx_count): draws.append(( run_kind, run_clip, run_tex, run_blend, run_vert_start, run_vert_count, run_idx_start, run_idx_count, )) if not draws: self.last_frame_draw_count = 0 return # Pass 2: upload each kind's concatenated buffer once (clamped to capacity). # The numpy conversion happens here, not per-op. def _upload(k: int, vb_mem: Any, ib_mem: Any | None, max_v: int, max_i: int | None) -> None: verts = kind_verts[k] if not verts: return arr = np.asarray(verts, dtype=np.float32).reshape(-1, 8) if arr.shape[0] > max_v: log.warning("Draw2D overflow (kind %d): %d verts (max %d)", k, arr.shape[0], max_v) arr = arr[:max_v] v = np.empty(arr.shape[0], dtype=UI_VERTEX_DTYPE) v["position"] = arr[:, :2] v["uv"] = arr[:, 2:4] v["colour"] = arr[:, 4:8] upload_numpy(device, vb_mem, v) if ib_mem is not None and kind_indices[k]: i = np.asarray(kind_indices[k], dtype=np.uint32) if max_i is not None and len(i) > max_i: i = i[:max_i] upload_numpy(device, ib_mem, i) _upload(OpKind.FILL, self._fill_vb_mem, self._fill_ib_mem, MAX_FILL_VERTS, MAX_FILL_INDICES) _upload(OpKind.LINE, self._line_vb_mem, None, MAX_LINE_VERTS, None) _upload(OpKind.TEXT, self._text_vb_mem, self._text_ib_mem, MAX_TEXT_VERTS, MAX_TEXT_INDICES) _upload(OpKind.TEX, self._tex_vb_mem, self._tex_ib_mem, MAX_TEX_VERTS, MAX_TEX_INDICES) # Pass 3: issue draws in submission order. Rebind pipeline only when # the kind transitions; reset scissor only when the clip transitions; # update tex_id push-constant only when the bound texture changes. clip_sx = width / uw if uw > 0 else 1.0 clip_sy = height / uh if uh > 0 else 1.0 last_kind = -1 last_blend = "" last_clip: Any = sentinel tex_desc = self._engine.texture_descriptor_set if self._tex_pipeline else None draw_count = 0 for kind, clip, tex_id, blend, vert_off, vert_count, idx_off, idx_count in draws: # Skip text runs when no atlas is ready (mirrors prior behaviour) if kind == OpKind.TEXT and not (self._text_pass and self._text_pass.atlas_version > 0): continue if kind == OpKind.TEX and not tex_desc: continue # Pipeline bind on kind OR blend transition (FILL/TEX have a # pipeline per blend mode; the layout is identical across blends so # the descriptor/push binds below stay valid). if kind != last_kind or blend != last_blend: if kind == OpKind.FILL: fill_pipe = self._fill_pipelines[blend][0] vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, fill_pipe) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._fill_pipeline_layout, 0, 1, [self._fill_desc_set], 0, None, ) self._engine.push_constants(cmd, self._fill_pipeline_layout, fill_push) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._fill_vb], [0]) vk.vkCmdBindIndexBuffer(cmd, self._fill_ib, 0, vk.VK_INDEX_TYPE_UINT32) elif kind == OpKind.LINE: vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._line_pipeline) self._engine.push_constants(cmd, self._line_pipeline_layout, screen.tobytes()) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._line_vb], [0]) elif kind == OpKind.TEXT: tp = self._text_pass # Use the MSDF pipeline compiled against THIS pass: the default # one targets the swapchain (sRGB); an offscreen RTT target is # R16F (incompatible). ``pipeline_for`` returns the default for # the main pass, a format-matched one for a SubViewport target. text_pipe = tp.pipeline_for(self._render_pass, self._extent) vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, text_pipe) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, tp.pipeline_layout, 0, 1, [tp.descriptor_set], 0, None, ) text_pc = np.array([uw, uh, tp.px_range], dtype=np.float32) self._engine.push_constants(cmd, tp.pipeline_layout, text_pc.tobytes()) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._text_vb], [0]) vk.vkCmdBindIndexBuffer(cmd, self._text_ib, 0, vk.VK_INDEX_TYPE_UINT32) elif kind == OpKind.TEX: tex_pipe = self._tex_pipelines[blend][0] vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, tex_pipe) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._tex_pipeline_layout, 0, 1, [tex_desc], 0, None, ) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._tex_vb], [0]) vk.vkCmdBindIndexBuffer(cmd, self._tex_ib, 0, vk.VK_INDEX_TYPE_UINT32) # Viewport must be set after every pipeline bind (dynamic state) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) last_clip = sentinel # force scissor refresh after pipeline bind last_kind = kind last_blend = blend # Scissor on clip transition if clip != last_clip: if clip is not None: scissor = vk.VkRect2D( offset=vk.VkOffset2D( x=int(clip[0] * clip_sx), y=int(clip[1] * clip_sy), ), extent=vk.VkExtent2D( width=int(clip[2] * clip_sx), height=int(clip[3] * clip_sy), ), ) else: scissor = full_scissor vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) last_clip = clip # Per-texture push constants for TEX (tex_id changes within a TEX # pipeline binding because the run boundary already accounts for # tex_id transitions: each TEX draw has one tex_id). if kind == OpKind.TEX: tex_pc = np.array([uw, uh], dtype=np.float32).tobytes() \ + np.array([tex_id], dtype=np.int32).tobytes() self._engine.push_constants(cmd, self._tex_pipeline_layout, tex_pc) # Issue draw if kind == OpKind.LINE: vk.vkCmdDraw(cmd, vert_count, 1, vert_off, 0) else: vk.vkCmdDrawIndexed(cmd, idx_count, 1, idx_off, vert_off, 0) draw_count += 1 self.last_frame_draw_count = draw_count
# Draw2D._reset() is called at start of frame in app.py, before tree.render()
[docs] def cleanup(self) -> None: if not self._ready: return device = self._engine.ctx.device # Per-blend FILL/TEX pipelines each own a distinct pipeline + layout. for pipe, layout in (self._fill_pipelines or {}).values(): vk.vkDestroyPipeline(device, pipe, None) vk.vkDestroyPipelineLayout(device, layout, None) for pipe, layout in (self._tex_pipelines or {}).values(): vk.vkDestroyPipeline(device, pipe, None) vk.vkDestroyPipelineLayout(device, layout, None) for obj, fn in [ (self._line_pipeline, vk.vkDestroyPipeline), (self._line_pipeline_layout, vk.vkDestroyPipelineLayout), (self._vert_module, vk.vkDestroyShaderModule), (self._frag_module, vk.vkDestroyShaderModule), (self._line_frag_module, vk.vkDestroyShaderModule), (self._tex_frag_module, vk.vkDestroyShaderModule), (self._fill_vb, vk.vkDestroyBuffer), (self._fill_ib, vk.vkDestroyBuffer), (self._line_vb, vk.vkDestroyBuffer), (self._text_vb, vk.vkDestroyBuffer), (self._text_ib, vk.vkDestroyBuffer), (self._tex_vb, vk.vkDestroyBuffer), (self._tex_ib, vk.vkDestroyBuffer), (self._fill_desc_pool, vk.vkDestroyDescriptorPool), (self._fill_desc_layout, vk.vkDestroyDescriptorSetLayout), ]: if obj: fn(device, obj, None) for mem in [ self._fill_vb_mem, self._fill_ib_mem, self._line_vb_mem, self._text_vb_mem, self._text_ib_mem, self._tex_vb_mem, self._tex_ib_mem, ]: if mem: vk.vkFreeMemory(device, mem, None) self._ready = False
def _create_fill_descriptor_layout(device: Any) -> Any: """Set 0 = single fragment-stage sampler2D (the Light2DPass accumulation RT).""" binding = vk.VkDescriptorSetLayoutBinding( binding=0, descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, descriptorCount=1, stageFlags=vk.VK_SHADER_STAGE_FRAGMENT_BIT, ) return vk.vkCreateDescriptorSetLayout(device, vk.VkDescriptorSetLayoutCreateInfo( bindingCount=1, pBindings=[binding], ), None) def _write_fill_descriptor(device: Any, desc_set: Any, view: Any, sampler: Any) -> None: """Point the fill descriptor set at the given sampler+view (both must be non-null).""" image_info = vk.VkDescriptorImageInfo( sampler=sampler, imageView=view, imageLayout=vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, ) vk.vkUpdateDescriptorSets(device, 1, [vk.VkWriteDescriptorSet( dstSet=desc_set, dstBinding=0, dstArrayElement=0, descriptorCount=1, descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, pImageInfo=[image_info], )], 0, None) def _build_fill_push( screen_w: float, screen_h: float, ambient: tuple[float, float, float, float], has_lights: int, ) -> bytes: """Pack the draw2d_fill.frag push-constant block (48 bytes). Layout matches Draw2DPush in draw2d_fill.frag: vec2 screen_size (8) + vec2 _pad (8) + vec4 ambient (16) + ivec4 flags (16) """ header = np.array([screen_w, screen_h, 0.0, 0.0, *ambient], dtype=np.float32) flags = np.array([has_lights, 0, 0, 0], dtype=np.int32) return header.tobytes() + flags.tobytes()