Source code for simvx.graphics.renderer.scene_renderer

"""Scene content rendering: opaque, transparent, and skinned geometry passes."""

import logging
from typing import TYPE_CHECKING, Any

import numpy as np
import vulkan as vk

from ..gpu.memory import upload_numpy
from ..types import INDIRECT_DRAW_DTYPE, TRANSFORM_DTYPE, MeshHandle

if TYPE_CHECKING:
    from .forward import Renderer
    from .mesh_registry import MeshRegistry

__all__ = ["SceneContentRenderer"]

log = logging.getLogger(__name__)


[docs] class SceneContentRenderer: """Handles rendering of 3D scene content: skybox, opaque, transparent, skinned, particles, debug.""" def __init__(self, renderer: Renderer) -> None: self._r = renderer # Per-frame frustum visibility cache, keyed by viewport id. Each entry is # (vp_matrix, visible_indices) where visible_indices is a frozenset of # original instance indices passing the viewport's frustum. The frustum is # extracted and the scene culled ONCE per camera per frame, then reused # across the opaque, double-sided, and transparent passes. The cached # vp_matrix is the validity token: a changed camera (or TAA jitter) misses # and recomputes, so behaviour is identical to per-pass culling. Keyed by # (sru_id, viewport_id) so offscreen SRUs sharing viewport_id==0 with the # main scene never read each other's visibility set. self._vis_cache: dict[tuple[int, int], tuple[np.ndarray, frozenset[int]]] = {} # Two-phase occlusion-cull plans stashed by two_phase_select and consumed # by _render_viewport in the same frame. Keyed by pass kind ("opaque" / # "double"); each value is (batch, group_ranges) where group_ranges is # [(mesh_handle, batch_offset, count)] referring to the dedicated culled # occlusion indirect batch. Cleared each frame in render_scene_content. # kind -> (batch, group_ranges, indirect_memory captured at build time). self._occ_plans: dict[str, tuple[Any, list[tuple[MeshHandle, int, int]], Any]] = {} # vp_id the occlusion prepass built its plans for (the primary viewport). # Only that viewport draws from the culled batches; others fall through. self._occ_primary_vp_id: int = -1 # Frustum-visible instance count submitted to the most recent cull dispatch # (pre-cull total). Paired with Renderer._last_drawn_instance_count (post- # cull drawn) so consumers can report drawn / total / culled. Set each # frame by two_phase_select; 0 when occlusion is inactive. self._last_pre_cull_count: int = 0 # Stashed by two_phase_select for two_phase_cull (same frame): the # UNJITTERED view-projection + the Hi-Z base extent / mip count. self._occ_view_proj: np.ndarray | None = None self._occ_base_extent: tuple[int, int] = (0, 0) self._occ_mip_count: int = 0 # Diagnostics: per-frame phase-1 (set A) command count, captured in # two_phase_select by reading vis_prev. phase-2 survivors = drawn - phase1. self._last_phase1_count: int = 0 # hdr_output push-constant value for the SRU currently being recorded # (set per render_scene_content call). 1 = leave linear HDR for post-proc / # offscreen, 0 = tone-map in the fragment shader. self._hdr_output: int = 0 # Render-scale viewport ratio for the pass currently being recorded: # the main HDR pass rasterises at the HDR chain's # internal extent, so its viewport/scissor rects scale by internal/output. # Identity (the literal (1.0, 1.0)) for every offscreen SRU and whenever # render_scale is 1.0, which keeps the default path byte-identical. self._vp_scale: tuple[float, float] = (1.0, 1.0) def _set_viewport_scissor(self, cmd: Any, viewport: Any) -> None: """Set viewport + scissor from ``viewport``'s rect x the active render scale. One shared implementation for the opaque / double-sided / transparent / skinned / MultiMesh / velocity draws. ``_vp_scale`` is identity outside a scaled main HDR pass, reproducing the original rects exactly. """ sx, sy = self._vp_scale x, y = viewport.x * sx, viewport.y * sy w, h = viewport.width * sx, viewport.height * sy vk.vkCmdSetViewport( cmd, 0, 1, [vk.VkViewport(x=float(x), y=float(y), width=float(w), height=float(h), minDepth=0.0, maxDepth=1.0)], ) vk.vkCmdSetScissor( cmd, 0, 1, [ vk.VkRect2D( offset=vk.VkOffset2D(x=int(round(x)), y=int(round(y))), extent=vk.VkExtent2D(width=int(round(w)), height=int(round(h))), ) ], ) def _hdr_pc_bytes(self) -> bytes: """4-byte ``uint hdr_output`` tail appended to the mesh push-constant block.""" return np.uint32(self._hdr_output).tobytes() def _visible_indices(self, vp_id: int, viewport: Any) -> frozenset[int]: """Frustum-visible original instance indices for ``viewport``'s camera. Computes (and caches) the visibility of every clamped scene instance against the viewport frustum once per camera per frame, vectorized over all instances. Subsequent passes for the same viewport reuse the result. """ r = self._r key = (r._sru_id, vp_id) vp_matrix = viewport.camera_proj @ viewport.camera_view cached = self._vis_cache.get(key) if cached is not None and np.array_equal(cached[0], vp_matrix): return cached[1] r._frustum.extract_from_matrix(vp_matrix) clamped = r._instances[: r._max_objects] # Candidate indices visible to this viewport (vid == vp_id or shared vid 0). candidates = [i for i, (_mh, _xf, _m, vid, _rl) in enumerate(clamped) if vid == vp_id or vid == 0] if not candidates: visible = frozenset() self._vis_cache[key] = (vp_matrix.copy(), visible) return visible n = len(candidates) transforms_arr = np.empty((n, 4, 4), dtype=np.float32) base_radii = np.empty(n, dtype=np.float32) for k, idx in enumerate(candidates): mh, xf, _m, _vid, _rl = clamped[idx] transforms_arr[k] = xf if xf.shape == (4, 4) else xf.T base_radii[k] = mh.bounding_radius centers = transforms_arr[:, :3, 3] # (N, 3) positions # Max column norm of upper-left 3x3 = max scale factor. col_norms_sq = np.sum(transforms_arr[:, :3, :3] ** 2, axis=1) # (N, 3) max_scale = np.sqrt(np.max(col_norms_sq, axis=1)) # (N,) radii = base_radii * max_scale vis_mask = r._frustum.cull_spheres(centers, radii) visible = frozenset(candidates[k] for k in np.nonzero(vis_mask)[0]) self._vis_cache[key] = (vp_matrix.copy(), visible) return visible
[docs] def render_scene_content(self, cmd: Any, hdr_output: int = 0) -> None: """Render all 3D content: skybox, opaque geometry, skinned meshes, transparent geometry, particles, debug. ``hdr_output`` is forwarded to the mesh push constant (1 = leave linear HDR for post-processing / offscreen targets, 0 = tone-map to the swapchain in the fragment shader). It is supplied per scene-render unit so a single primary command buffer can mix tonemapped and HDR targets without any shadow-SSBO read-modify-write. """ self._hdr_output = int(hdr_output) # Scene colour/depth split guard (D1/A7): both a screen-reading transparent # draw and the water pass want the copy, but it must happen exactly once # per SRU record. Reset here, set by the first _maybe_split_for_scene_read. self._split_done = False r = self._r e = r._engine registry = e.mesh_registry all_viewports = r.viewport_manager.viewports # Render scale: the main HDR pass (hdr_output=1, the # only caller that passes 1) rasterises at the HDR chain's internal # extent; the tonemap blit upscales to the swapchain. Offscreen SRUs # (hdr_output=0) render at their target's native size and keep the # engine extent + identity scale exactly as before. At render_scale 1.0 # the internal extent equals the output extent, so this is byte-identical. if self._hdr_output == 1: self._vp_scale = r._main_view_scale() pp = r._post_process rt = pp.hdr_target if (pp is not None and pp.enabled) else None extent = (rt.width, rt.height) if rt is not None else e.extent else: self._vp_scale = (1.0, 1.0) extent = e.extent # Start a fresh per-frame visibility cache: cull once per camera, reuse # across opaque/double-sided/transparent passes. Entries are keyed by # viewport id and validated against the live VP matrix, so a jittered # (TAA) or moved camera correctly recomputes. self._vis_cache.clear() # Render skybox first (behind everything) if r._skybox_pass and all_viewports: _, vp = all_viewports[0] r._skybox_pass.render(cmd, vp.camera_view, vp.camera_proj, extent) # Render grid overlay (after skybox, before geometry) if r._grid_pass and r._grid_pass.enabled and all_viewports: _, vp = all_viewports[0] r._grid_pass.render(cmd, vp.camera_view, vp.camera_proj, extent) # Render tilemap layers (2D, behind 3D geometry). The shader expects a # combined view-projection matrix that transforms world-space tile # corners directly to clip space, so pre-multiply here. With a 3D camera that # is its view-proj; in a 2D-only scene (Camera2D, no 3D viewport) the tiles # ride the SAME world->screen->NDC canvas_transform sprites + highlights use, # so tile, highlight and sprite spaces agree under pan/zoom. tilemap_subs = r.tilemap_layers() if r._tilemap_pass and tilemap_subs: if all_viewports: _, vp = all_viewports[0] tile_view = vp.camera_proj @ vp.camera_view elif r._camera2d_affine is not None: tile_view = r._overlay_renderer._camera2d_view_proj(*r._camera2d_affine) else: tile_view = None if tile_view is not None: r._tilemap_pass.render(cmd, tile_view, extent, submissions=tilemap_subs) # Render 3D geometry if any per-instance instances OR MultiMesh fast-path # blocks are present. Blocks don't populate ``_instances`` (that's the whole # point), so the gate must include them or a MultiMesh-only scene renders # nothing. if r._instances or r._multimesh_draws: viewports = all_viewports if not viewports: # Output-extent rect, like every adapter-created viewport: the # draw-time ``_vp_scale`` maps it onto the pass extent. w, h = e.extent from ..scene.camera import Camera cam = Camera(aspect=w / h) viewports = [(0, _default_viewport(w, h, cam))] transparent: list[tuple[Any, np.ndarray, int, int, int]] = [] if r._instances: # Split instances by alpha mode and double-sided flag. Only split the # clamped list to avoid SSBO out-of-bounds reads. from .transparency import split_instances clamped = r._instances[: r._max_objects] opaque, double_sided, transparent = split_instances(clamped, r._materials) # Render opaque geometry first (standard forward pipeline) if opaque: for vp_id, viewport in viewports: self._render_viewport(cmd, vp_id, viewport, registry, opaque) # Render double-sided opaque (no backface culling, full depth write) if double_sided and r._nocull_pipeline: for vp_id, viewport in viewports: self._render_viewport( cmd, vp_id, viewport, registry, double_sided, pipeline_override=r._nocull_pipeline, layout_override=r._nocull_pipeline_layout, ) # MultiMesh fast-path blocks: one instanced indirect command per # contiguous run, drawn with the opaque / double-sided pipeline. if r._multimesh_draws: self._render_multimesh_blocks(cmd, viewports, registry) # Render skinned meshes (before transparent, as they are typically opaque) if r._skinned_instances and r._skinned_pipeline: for vp_id, viewport in viewports: self._render_skinned(cmd, vp_id, viewport, registry) # Render transparent geometry last, back-to-front sorted. When a # screen-reading material is present, split the HDR # pass here: end it, copy scene colour/depth into sampleable textures, # re-begin the LOAD-op pass, then draw transparent so its shader can # sample what is behind it (set0 b14/b15). if transparent and r._transparent_pipeline: self._maybe_split_for_scene_read(cmd) for vp_id, viewport in viewports: self._render_transparent_viewport(cmd, vp_id, viewport, registry, transparent) else: # No instances and no blocks: still render skinned meshes if any if r._skinned_instances and r._skinned_pipeline: if all_viewports: for vp_id, viewport in all_viewports: self._render_skinned(cmd, vp_id, viewport, registry) # Render water surfaces: a dedicated transparent pass drawn # AFTER the scene colour/depth copy so its Gerstner grid can refract + # depth-fade against what is behind it. Only fires on the main HDR pass # (hdr_output == 1, where the split is valid) with a submitted surface; # otherwise nothing renders and no split happened (byte-identical). wp = r._water_pass if wp is not None and wp.has_surfaces and self._hdr_output == 1 and all_viewports: self._maybe_split_for_scene_read(cmd) _, vp = all_viewports[0] view_proj = vp.camera_proj @ vp.camera_view cam_pos = np.linalg.inv(vp.camera_view)[:3, 3].astype(np.float32) wp.render( cmd, view_proj, cam_pos, 0.0, 0.0, has_skybox=bool(getattr(r, "_ibl_enabled", False)), extent=extent, ssbo_set=r._buffers.ssbo_set, ) # Render FFT ocean surfaces: same transparent-slot contract as # water (drawn after the scene colour/depth copy so it refracts what is # behind it), but displaced from the FFT map uploaded in pre_render. Only # on the main HDR pass; byte-identical when the scene holds no ocean. oc = r._ocean_pass if oc is not None and oc.has_surfaces and self._hdr_output == 1 and all_viewports: self._maybe_split_for_scene_read(cmd) _, vp = all_viewports[0] view_proj = vp.camera_proj @ vp.camera_view cam_pos = np.linalg.inv(vp.camera_view)[:3, 3].astype(np.float32) oc.render( cmd, view_proj, cam_pos, 0.0, 0.0, has_skybox=bool(getattr(r, "_ibl_enabled", False)), extent=extent, ssbo_set=r._buffers.ssbo_set, ) # Render custom ShaderMaterial-backed meshes (unified-ABI custom pipeline). if r._shader_material_submissions and all_viewports: self._render_shader_materials(cmd, all_viewports[0][1], registry, extent) # Render particles (CPU and/or GPU-compute-driven) if r._particle_pass and (r._particle_submissions or r._gpu_particle_submissions): r._overlay_renderer.render_particles(cmd, extent) # Render depth-tested billboards (Sprite3D images + Text3D glyphs). # Depth-tested (no depth write) so they occlude / are occluded by # the 3D geometry just rendered above; camera basis from inv(view). if r._billboard2d_pass and r._billboard_submissions and all_viewports: _, vp = all_viewports[0] view_proj = vp.camera_proj @ vp.camera_view view_inv = np.linalg.inv(vp.camera_view) cam_right = view_inv[:3, 0].astype(np.float32) cam_up = view_inv[:3, 1].astype(np.float32) text_pass = r._text_pass px_range = float(text_pass.px_range) if text_pass is not None else 4.0 data = np.concatenate(r._billboard_submissions) # Use the viewport's own extent, not the engine swapchain extent: an # offscreen SRU (SubViewport / probe face) renders into a target whose # size is the viewport's, and the billboard pass sets viewport+scissor # from this value. On the main pass vp size == e.extent (identical). # Scale by the active render-scale ratio (identity outside a scaled # main HDR pass) so billboard pixel sizes match the pass raster size. sx, sy = self._vp_scale bb_extent = (int(round(vp.width * sx)), int(round(vp.height * sy))) if vp.width and vp.height else extent r._billboard2d_pass.render(cmd, data, view_proj, cam_right, cam_up, bb_extent, px_range) # Render debug lines r._overlay_renderer.render_debug_lines(cmd, extent) # Render gizmo overlay (always on top) if r._gizmo_pass and r._gizmo_render_data: r._gizmo_pass.render(cmd, r._gizmo_render_data, extent)
def _maybe_split_for_scene_read(self, cmd: Any) -> None: """End / copy / re-begin the HDR pass for a screen-reading transparent draw. No-op unless a screen-reading material is active this frame AND we are recording the HDR pass (``hdr_output == 1``, so ending + re-beginning the HDR render pass is valid). When nothing declares the need the frame records exactly as before (byte-identical gate). Records inside the same primary command buffer, so it works in immediate AND pipelined modes. """ r = self._r if self._split_done: return if not (self._hdr_output == 1 and r._scene_read_this_frame and r._scene_copy is not None): return pp = r._post_process if pp is None or not pp.enabled or pp.hdr_target is None: return if getattr(pp.hdr_target, "reload_render_pass", None) is None: return pp.end_hdr_pass(cmd) r._scene_copy.capture(cmd, pp.hdr_target, r._buffers.ssbo_sets) pp.rebegin_hdr_pass(cmd) self._split_done = True def _render_shader_materials( self, cmd: Any, viewport: Any, registry: MeshRegistry, extent: tuple[int, int] ) -> None: """Draw custom ShaderMaterial submissions via the unified-ABI custom pipeline. Lazily creates the per-renderer :class:`ShaderMaterialManager`. The manager owns the shared camera UBO (group0) + transforms SSBO (group1) and binds a per-material UBO (group2). View/proj come from the primary viewport; the manager transposes to column-major at upload. """ from ..materials.custom_shader import ShaderMaterialManager r = self._r e = r._engine if r._shader_material_manager is None: r._shader_material_manager = ShaderMaterialManager() r._shader_material_manager.draw( cmd, r._shader_material_submissions, e.ctx.device, e.ctx.physical_device, r._passes.pipeline_render_pass(), extent, viewport.camera_view, viewport.camera_proj, registry, frame_globals_buf=r._buffers.frame_globals_buf, gbuffer=bool(getattr(e, "gbuffer_active", False)), )
[docs] def two_phase_select(self, cmd: Any, viewports: Any, vis_prev: Any, vis_next: Any, vis_prev_mem: Any) -> None: """Two-phase PHASE 1: build the final batches + seed set A (GPU selection). Runs in pre_render, OUTSIDE any render pass, BEFORE the camera proj is TAA-jittered. Builds each pass's draw commands (the frustum-visible instances, ``first_instance`` == transform slot) into its dedicated occlusion indirect batch, then dispatches the phase-1 selection compute: it seeds the batch to set A only (instances visible last frame) and seeds vis_next = A. The depth prepass then draws the A-only batch; phase 2 patches in set-B survivors. PRIMARY viewport only. """ r = self._r hiz = r._hiz_pass occ = r._occlusion_pass # Read back LAST frame's drawn counts before rebuilding this frame's plans # (the previous frame's phase-2 dispatch has since executed on the GPU). if self._occ_plans: self.read_occlusion_telemetry() self._vis_cache.clear() self._occ_plans.clear() vp_id, viewport = viewports[0] self._occ_primary_vp_id = vp_id from .transparency import split_instances clamped = r._instances[: r._max_objects] opaque, double_sided, _transparent = split_instances(clamped, r._materials) # UNJITTERED view-projection (row-major numpy) for the cull shader. view_proj = viewport.camera_proj @ viewport.camera_view self._occ_view_proj = view_proj self._occ_base_extent = hiz.base_extent self._occ_mip_count = hiz.mip_count drawn_total = 0 passes = ( ("opaque", opaque, r._occ_batch_opaque), ("double", double_sided, r._occ_batch_double), ) for kind, subset, batch in passes: if batch is None: continue batch.reset() group_ranges: list[tuple[Any, int, int]] = [] if subset: vis = self._visible_indices(vp_id, viewport) visible = [(mh, oi) for mh, _xf, _m, _vid, oi in subset if oi in vis] if visible: # One command per instance: the cull compute zeroes instance_count # per object, so this batch must NOT coalesce instances into runs. group_ranges = self._build_batch(visible, batch, coalesce=False) # NOTE: MultiMesh blocks are NOT added to the occlusion batch because the # existing cull shader sets instance_count to 0 or 1 (not 0 or N) when # patching; a per-block coarse cull would require shader changes. MM blocks # are drawn by _render_multimesh_blocks with CPU-side frustum cull only. if not group_ranges: continue batch.upload() occ.dispatch( cmd, phase=1, skip_cull=False, indirect_buffer=batch.indirect_buffer, draw_count=batch.draw_count, transform_buf=r._buffers.transform_buf, aabb_buf=r._buffers.aabb_buf, hiz_view=hiz.sampled_view, hiz_sampler=hiz.sampler, vis_prev_buf=vis_prev, vis_next_buf=vis_next, parity=r._vis_parity, view_proj=view_proj, base_extent=self._occ_base_extent, mip_count=self._occ_mip_count, max_objects=r._max_objects, host_barrier=True, ) # Capture THIS frame's ringed indirect memory: telemetry is read a # frame later, when batch.indirect_memory would point at the next slot. self._occ_plans[kind] = (batch, group_ranges, batch.indirect_memory) drawn_total += batch.draw_count # Pre-cull (frustum-visible) command count for telemetry. The drawn count # is read back from the patched batch after phase 2 has executed. self._last_pre_cull_count = drawn_total # Diagnostics: count set A (vis_prev==1) among this frame's drawn slots. # vis_prev is host-visible and holds the PREVIOUS frame's drawn set, stable # at this point. phase-2 survivors are then (drawn - phase1). phase1 = 0 if drawn_total and vis_prev_mem is not None: size = r._max_objects * 4 ptr = vk.vkMapMemory(r._engine.ctx.device, vis_prev_mem, 0, size, 0) raw = bytes(ptr) if isinstance(ptr, vk.ffi.buffer) else bytes(vk.ffi.buffer(ptr, size)) vk.vkUnmapMemory(r._engine.ctx.device, vis_prev_mem) vis = np.frombuffer(raw, dtype=np.uint32, count=r._max_objects) for batch, _gr, _mem in self._occ_plans.values(): n = batch.draw_count slots = batch._commands["first_instance"][:n] phase1 += int(vis[slots].sum()) self._last_phase1_count = phase1
[docs] def render_depth_prepass(self, cmd: Any, depth_prepass: Any, view_proj_T: Any) -> None: """Draw set A (the phase-1-seeded final batches) depth-only into scratch depth. Called from inside ``DepthPrepass.render``'s render pass. Binds the depth-only pipeline + the shared SSBO set, pushes the camera VP, and issues the same per-mesh-group indirect draws ``_render_viewport`` would, but the instance_count has been seeded to set A only (B held at 0 -> drawn nothing). """ r = self._r registry = r._engine.mesh_registry # Size the viewport from the SCRATCH target, not the engine extent: they # are equal for the main pass, but a per-view occlusion bundle # draws its prepass into a view-sized scratch depth. w, h = depth_prepass.extent vk_vp = vk.VkViewport(x=0.0, y=0.0, width=float(w), height=float(h), 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=w, height=h)) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) for kind, pipeline in ( ("opaque", depth_prepass.pipeline), ("double", depth_prepass.pipeline_double), ): plan = self._occ_plans.get(kind) if plan is None: continue batch, group_ranges, _mem = plan vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, depth_prepass.pipeline_layout, 0, 1, [r._ssbo_set], 0, None, ) depth_prepass.push_view_proj(cmd, view_proj_T) self._draw_groups(cmd, batch, group_ranges, registry)
[docs] def two_phase_cull(self, cmd: Any, viewports: Any, vis_prev: Any, vis_next: Any, skip_cull: bool) -> None: """Two-phase PHASE 2: cull set B against the fresh Hi-Z; patch the final batch. For each occlusion batch: barrier the depth prepass's INDIRECT read against the compute write, then dispatch phase 2. Set A is kept unconditionally; set-B candidates run the conservative occlusion test (or all kept when ``skip_cull``, i.e. the pyramid was just built this frame and is the first). vis_next is updated to the final drawn set. """ r = self._r hiz = r._hiz_pass occ = r._occlusion_pass for kind in ("opaque", "double"): plan = self._occ_plans.get(kind) if plan is None: continue batch, _group_ranges, _mem = plan # The depth prepass read this buffer as an indirect command; make that # read complete before the phase-2 compute overwrites instance_count. pre_barrier = vk.VkBufferMemoryBarrier( srcAccessMask=vk.VK_ACCESS_INDIRECT_COMMAND_READ_BIT, dstAccessMask=vk.VK_ACCESS_SHADER_WRITE_BIT, srcQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, dstQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, buffer=batch.indirect_buffer, offset=0, size=batch.draw_count * INDIRECT_DRAW_DTYPE.itemsize, ) vk.vkCmdPipelineBarrier( cmd, vk.VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, vk.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, None, 1, [pre_barrier], 0, None, ) occ.dispatch( cmd, phase=2, skip_cull=skip_cull, indirect_buffer=batch.indirect_buffer, draw_count=batch.draw_count, transform_buf=r._buffers.transform_buf, aabb_buf=r._buffers.aabb_buf, hiz_view=hiz.sampled_view, hiz_sampler=hiz.sampler, vis_prev_buf=vis_prev, vis_next_buf=vis_next, parity=r._vis_parity, view_proj=self._occ_view_proj, base_extent=self._occ_base_extent, mip_count=self._occ_mip_count, max_objects=r._max_objects, host_barrier=False, )
[docs] def read_occlusion_telemetry(self, *, wait: bool = False) -> int: """Sum instance_count over the culled occlusion indirect commands. Reads the HOST_VISIBLE indirect memory directly (no TRANSFER copy) and stores the total on ``Renderer._last_drawn_instance_count``. The cull dispatch must have COMPLETED on the GPU for the result to be meaningful; pass ``wait=True`` to ``vkDeviceWaitIdle`` first (test/telemetry use). The default (no wait) is for the start-of-prepass read of a prior frame. """ r = self._r if wait: vk.vkDeviceWaitIdle(r._engine.ctx.device) total = 0 for batch, _group_ranges, indirect_memory in self._occ_plans.values(): n = batch.draw_count if n == 0: continue size = n * INDIRECT_DRAW_DTYPE.itemsize # Read the ring slot the plan was built in (captured at build time), not # the live current-frame slot: telemetry is read a frame later. ptr = vk.vkMapMemory(batch.device, indirect_memory, 0, size, 0) raw = bytes(ptr) if isinstance(ptr, vk.ffi.buffer) else bytes(vk.ffi.buffer(ptr, size)) vk.vkUnmapMemory(batch.device, indirect_memory) cmds = np.frombuffer(raw, dtype=INDIRECT_DRAW_DTYPE, count=n) total += int(cmds["instance_count"].sum()) r._last_drawn_instance_count = total return total
[docs] @property def last_phase1_count(self) -> int: """Phase-1 (set A) command count of the most recent select (diagnostics).""" return self._last_phase1_count
[docs] def render_velocity(self, cmd: Any, velocity_pass: Any) -> None: """Draw opaque + double-sided opaque instances into the velocity target. Reuses the opaque cull/group/indirect path with the velocity pipeline, layout, and descriptor set (no texture set, no push constants: the velocity vertex shader reads cur/prev model SSBOs + a VP uniform). Skinned meshes, transparent geometry, particles and skybox are intentionally skipped here: per-object velocity covers opaque mesh instances only. MultiMesh blocks are also drawn (one instanced run per block) so that moving MultiMesh nodes produce correct per-object motion vectors. """ r = self._r viewports = r.viewport_manager.viewports if not viewports: return # The velocity target is sized to the HDR chain's internal extent, so # its viewport rects take the same render-scale ratio as the main pass # (identity at render_scale 1.0). Set explicitly: this entry point does # not pass through render_scene_content. self._vp_scale = r._main_view_scale() # Velocity uses the unjittered (restored) camera proj, so cull against it # afresh rather than reuse the main pass's (possibly jittered) cache. self._vis_cache.clear() if r._instances: from .transparency import split_instances clamped = r._instances[: r._max_objects] opaque, double_sided, _transparent = split_instances(clamped, r._materials) if opaque: for vp_id, viewport in viewports: self._render_viewport( cmd, vp_id, viewport, r._engine.mesh_registry, opaque, velocity_pass=velocity_pass ) if double_sided: for vp_id, viewport in viewports: self._render_viewport( cmd, vp_id, viewport, r._engine.mesh_registry, double_sided, pipeline_override=velocity_pass.pipeline_double, velocity_pass=velocity_pass, ) # MultiMesh blocks: one instanced run per block in the velocity target. if r._multimesh_draws: self._render_multimesh_velocity(cmd, viewports, r._engine.mesh_registry, velocity_pass)
def _render_multimesh_velocity(self, cmd: Any, viewports: list, registry: Any, velocity_pass: Any) -> None: """Draw MultiMesh blocks into the velocity target using the velocity pipeline. Frustum-culls each block per viewport (same coarse sphere cull as the main pass) then issues one instanced draw per visible block. The velocity shader reads cur/prev model SSBOs by gl_InstanceIndex, which for a block first_instance=base equals the absolute SSBO slot -- matching the upload. """ r = self._r batch = r._velocity_batch if batch is None: return for vp_id, viewport in viewports: vp_matrix = viewport.camera_proj @ viewport.camera_view r._frustum.extract_from_matrix(vp_matrix) opaque_groups: list = [] double_groups: list = [] for draw in r._multimesh_draws: mesh_handle, base, count, b_vid, double_sided, centres, radii = draw[:7] if b_vid != vp_id and b_vid != 0: continue mask = r._frustum.cull_spheres(centres, radii) vis = np.nonzero(mask)[0] if vis.size == 0: continue slots = vis.astype(np.uint32) + np.uint32(base) offset, ncmds = batch.add_instanced_runs(mesh_handle.index_count, slots) (double_groups if double_sided else opaque_groups).append((mesh_handle, offset, ncmds)) if not opaque_groups and not double_groups: continue batch.upload() self._set_viewport_scissor(cmd, viewport) for groups, pipeline in ( (opaque_groups, velocity_pass.pipeline), (double_groups, velocity_pass.pipeline_double), ): if not groups or pipeline is None: continue vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, velocity_pass.pipeline_layout, 0, 1, [velocity_pass.desc_set], 0, None, ) self._draw_groups(cmd, batch, groups, registry) def _render_viewport( self, cmd: Any, vp_id: int, viewport: Any, registry: MeshRegistry, filtered: list[tuple[Any, np.ndarray, int, int, int]] | None = None, pipeline_override: Any = None, layout_override: Any = None, velocity_pass: Any = None, ) -> None: """Render opaque instances visible in a single viewport. Transforms are already in the SSBO (uploaded once in pre_render). We use the ORIGINAL instance indices so shadow and main passes reference the same SSBO slots: no CPU-overwrites-before-GPU-execute. If ``filtered`` is provided, only those instances (with their original indices) are considered; otherwise all self._r._instances are used. When ``velocity_pass`` is given, the draw uses the velocity pipeline + its single descriptor set (set 0 = cur/prev models + VP uniform) and skips the texture set + push constants: it writes the per-object motion vector. """ r = self._r # Reuse the per-frame, per-camera visibility set (frustum extracted and # scene culled once per viewport, shared across opaque/double-sided/ # transparent passes). Each pass keeps its own subset's original indices. vis = self._visible_indices(vp_id, viewport) if not vis: return # (mesh_handle, original_index) for visible instances in this pass's subset. if filtered is not None: source: list[tuple[MeshHandle, int]] = [(mh, oi) for mh, _xf, _m, _vid, oi in filtered] else: source = [(mh, i) for i, (mh, _xf, _m, _vid, _rl) in enumerate(r._instances[: r._max_objects])] visible = [(mh, idx) for mh, idx in source if idx in vis] if not visible: return # Set viewport/scissor (render-scale aware) self._set_viewport_scissor(cmd, viewport) # Bind pipeline (allow override for double-sided / velocity rendering) if velocity_pass is not None: active_pipeline = pipeline_override or velocity_pass.pipeline active_layout = velocity_pass.pipeline_layout else: active_pipeline = pipeline_override or r._pipeline active_layout = layout_override or r._pipeline_layout vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, active_pipeline) # Bind descriptors. Velocity: only set 0 (cur/prev models + VP uniform), # no texture set, no push constants (matrices come via the uniform). if velocity_pass is not None: vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, active_layout, 0, 1, [velocity_pass.desc_set], 0, None, ) else: vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, active_layout, 0, 1, [r._ssbo_set], 0, None, ) tex_ds = r._engine.texture_descriptor_set if tex_ds: vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, active_layout, 1, 1, [tex_ds], 0, None, ) # Push constants (view + proj + hdr_output) # NOTE: GLM matrices are row-major, but GLSL expects column-major. # Transpose before sending to GPU. view_transposed = np.ascontiguousarray(viewport.camera_view.T) proj_transposed = np.ascontiguousarray(viewport.camera_proj.T) pc_data = view_transposed.tobytes() + proj_transposed.tobytes() + self._hdr_pc_bytes() r._engine.push_constants(cmd, active_layout, pc_data) # O3 occlusion cull: if the prepass already built + culled this pass's # commands into a dedicated occlusion batch (PRIMARY viewport only, opaque # or double-sided), draw from THAT (culled) batch instead of rebuilding # into the shared r._batch. Non-primary viewports have no plan and fall # through to the normal un-culled build below. The culled # instance_count==0 commands draw nothing. pass_kind = None if velocity_pass is not None else ("opaque" if pipeline_override is None else "double") plan = self._occ_plans.get(pass_kind) if pass_kind is not None and vp_id == self._occ_primary_vp_id else None if plan is not None: occ_batch, group_ranges, _mem = plan self._draw_groups_tangent_split(cmd, occ_batch, group_ranges, registry, pass_kind) return # Build ALL draw commands into a batch, tracking group offsets. The # velocity pass uses its OWN indirect batch: it records after the forward # draws but executes in the same submission, and the forward draws still # read r._batch at GPU-execution time -- reusing it would corrupt them. # The batch is reset ONCE per frame in begin_frame, not here: each SRU # (and each viewport) APPENDS its draws at the running offset so multiple # SRUs sharing this batch in one command buffer never clobber each other. batch = r._velocity_batch if velocity_pass is not None else r._batch group_ranges = self._build_batch(visible, batch) batch.upload() if pass_kind is None: # Velocity pass: position/normal fetch only, no tangent variant. self._draw_groups(cmd, batch, group_ranges, registry) else: self._draw_groups_tangent_split(cmd, batch, group_ranges, registry, pass_kind) def _build_batch( self, visible: list[tuple[MeshHandle, int]], batch: Any, *, coalesce: bool = True, ) -> list[tuple[MeshHandle, int, int]]: """Group visible instances by mesh and add draws to ``batch``. Returns ``[(mesh_handle, batch_offset, command_count)]``. Does NOT upload (caller uploads once so the same builder serves the in-pass draw and the O3 prepass that culls before uploading the indirect buffer to the GPU). With ``coalesce`` (default), runs of consecutive SSBO slots sharing a mesh collapse into a single instanced indirect command, so a MultiMesh of N instances is one draw instead of N. The occlusion prepass passes ``coalesce=False`` because its cull compute zeroes ``instance_count`` per command and so needs exactly one command per instance. """ r = self._r base = r._first_instance_base mesh_groups: dict[int, list[int]] = {} for mesh_handle, orig_idx in visible: mesh_groups.setdefault(mesh_handle.id, []).append(orig_idx) group_ranges: list[tuple[MeshHandle, int, int]] = [] for _mesh_id, orig_indices in mesh_groups.items(): mesh_handle = r._instances[orig_indices[0]][0] # first_instance is the absolute SSBO slot: local index + this SRU's slice base. slots = np.asarray(orig_indices, dtype=np.uint32) if base: slots = slots + np.uint32(base) if coalesce: batch_offset, count = batch.add_instanced_runs(mesh_handle.index_count, slots) else: batch_offset = batch.add_draws(mesh_handle.index_count, slots) count = len(orig_indices) group_ranges.append((mesh_handle, batch_offset, count)) return group_ranges def _draw_groups( self, cmd: Any, batch: Any, group_ranges: list[tuple[MeshHandle, int, int]], registry: MeshRegistry, *, bind_extras: bool = False, ) -> None: """Issue the per-mesh-group indirect draws for an already-built batch. Binds the position + shading streams (bindings 0/1). Serves the forward, velocity, and depth-prepass draws: pipelines that consume only binding 0 (depth prepass) simply ignore the extra binding. With ``bind_extras`` (tangent pipeline variants) the extras stream rides binding 2; callers only set it for groups whose mesh carries the stream. """ for mesh_handle, batch_offset, count in group_ranges: bufs = registry.get_buffers(mesh_handle) if bind_extras: vk.vkCmdBindVertexBuffers(cmd, 0, 3, [bufs.position, bufs.shading, bufs.extras], [0, 0, 0]) else: vk.vkCmdBindVertexBuffers(cmd, 0, 2, [bufs.position, bufs.shading], [0, 0]) vk.vkCmdBindIndexBuffer(cmd, bufs.index, 0, vk.VK_INDEX_TYPE_UINT32) batch.draw_range(cmd, batch_offset, count) def _draw_groups_tangent_split( self, cmd: Any, batch: Any, group_ranges: list[tuple[MeshHandle, int, int]], registry: MeshRegistry, kind: str, ) -> None: """Draw ``group_ranges``, routing tangent meshes through the tangent pipeline. Meshes whose extras stream carries real tangents (``registry.tangent_mesh_ids``) draw with the HAS_TANGENTS pipeline variant of ``kind`` ("opaque"/"double") with the extras stream bound; everything else keeps the already-bound base pipeline. The tangent layouts share the base set layouts and push ranges, so bound descriptor sets and push constants stay valid across the pipeline switch. Zero-cost when unused: with no tangent meshes registered this is one falsy set check, then the historical single call. """ tangent_ids = registry.tangent_mesh_ids if not tangent_ids: self._draw_groups(cmd, batch, group_ranges, registry) return base_groups = [g for g in group_ranges if g[0].id not in tangent_ids] tan_groups = [g for g in group_ranges if g[0].id in tangent_ids] if base_groups: self._draw_groups(cmd, batch, base_groups, registry) if tan_groups: tan_opaque, tan_nocull, _tan_transparent = self._r._pipelines.tangent_pipelines() pipeline = tan_nocull if kind == "double" else tan_opaque vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline) self._draw_groups(cmd, batch, tan_groups, registry, bind_extras=True) def _render_multimesh_blocks(self, cmd: Any, viewports: list[tuple[int, Any]], registry: MeshRegistry) -> None: """Draw the eligible MultiMesh blocks: one instanced indirect command per run. Each block's transforms are already in the SSBO (uploaded vectorized in ``_upload_multimesh_blocks``). Here we frustum-cull each block per viewport (vectorized over the block's precomputed world centres + radii), coalesce the visible contiguous slot runs into instanced commands, and draw with the opaque or double-sided pipeline. Shadow, TAA, and occlusion passes are handled independently (see shadow_pass, velocity_pass, two_phase_select). GPU-level per-block occlusion cull is deferred (requires shader changes). """ r = self._r batch = r._multimesh_batch if batch is None: return for vp_id, viewport in viewports: vp_matrix = viewport.camera_proj @ viewport.camera_view r._frustum.extract_from_matrix(vp_matrix) opaque_groups: list[tuple[MeshHandle, int, int]] = [] double_groups: list[tuple[MeshHandle, int, int]] = [] for draw in r._multimesh_draws: mesh_handle, base, _count, b_vid, double_sided, centres, radii = draw[:7] if b_vid != vp_id and b_vid != 0: continue mask = r._frustum.cull_spheres(centres, radii) vis = np.nonzero(mask)[0] if vis.size == 0: continue slots = vis.astype(np.uint32) + np.uint32(base) offset, ncmds = batch.add_instanced_runs(mesh_handle.index_count, slots) (double_groups if double_sided else opaque_groups).append((mesh_handle, offset, ncmds)) if not opaque_groups and not double_groups: continue batch.upload() self._set_viewport_scissor(cmd, viewport) view_transposed = np.ascontiguousarray(viewport.camera_view.T) proj_transposed = np.ascontiguousarray(viewport.camera_proj.T) pc_data = view_transposed.tobytes() + proj_transposed.tobytes() + self._hdr_pc_bytes() tex_ds = r._engine.texture_descriptor_set for groups, pipeline, layout, kind in ( (opaque_groups, r._pipeline, r._pipeline_layout, "opaque"), (double_groups, r._nocull_pipeline, r._nocull_pipeline_layout, "double"), ): if not groups or pipeline is None: continue vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, layout, 0, 1, [r._ssbo_set], 0, None ) if tex_ds: vk.vkCmdBindDescriptorSets(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, layout, 1, 1, [tex_ds], 0, None) r._engine.push_constants(cmd, layout, pc_data) self._draw_groups_tangent_split(cmd, batch, groups, registry, kind) def _render_transparent_viewport( self, cmd: Any, vp_id: int, viewport: Any, registry: MeshRegistry, transparent: list[tuple[Any, np.ndarray, int, int, int]], ) -> None: """Render transparent instances in back-to-front order with alpha blending. Uses the transparent pipeline (depth test on, depth write off, alpha blend). Each transparent object is drawn individually in sorted order to ensure correct blending: multi-draw indirect batching is not used here. """ r = self._r from .transparency import extract_camera_position, sort_transparent # Frustum cull: reuse the per-frame, per-camera visibility set (vectorized # cull_spheres, shared with the opaque/double-sided passes). Membership in # ``vis`` already encodes both the viewport filter (vid == vp_id or 0) and # the frustum test, so we only filter the transparent subset here. vis = self._visible_indices(vp_id, viewport) if not vis: return visible = [inst for inst in transparent if inst[4] in vis] if not visible: return # Sort back-to-front camera_pos = extract_camera_position(viewport.camera_view) visible = sort_transparent(visible, camera_pos) # Set viewport/scissor (render-scale aware) self._set_viewport_scissor(cmd, viewport) # Bind transparent pipeline vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, r._transparent_pipeline) # Bind descriptors vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, r._transparent_pipeline_layout, 0, 1, [r._ssbo_set], 0, None, ) tex_ds = r._engine.texture_descriptor_set if tex_ds: vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, r._transparent_pipeline_layout, 1, 1, [tex_ds], 0, None, ) # Push constants (view + proj + hdr_output) view_transposed = np.ascontiguousarray(viewport.camera_view.T) proj_transposed = np.ascontiguousarray(viewport.camera_proj.T) pc_data = view_transposed.tobytes() + proj_transposed.tobytes() + self._hdr_pc_bytes() r._engine.push_constants(cmd, r._transparent_pipeline_layout, pc_data) # Draw each transparent object individually in sorted order # Use separate indirect buffer to avoid overwriting opaque draw commands # Reset once per frame (begin_frame), not here: SRUs append at the running # offset so transparent draws from multiple SRUs in one cmd never clobber. draw_entries: list[tuple[MeshHandle, int, int]] = [] # (handle, batch_offset, orig_idx) base = r._first_instance_base for mesh_handle, _transform, _mat_id, _vp_id, orig_idx in visible: offset = r._transparent_batch.draw_count r._transparent_batch.add_draw( index_count=mesh_handle.index_count, instance_count=1, first_instance=base + orig_idx, ) draw_entries.append((mesh_handle, offset, orig_idx)) r._transparent_batch.upload() # Draw each entry individually (one draw per object to preserve sort order). # Tangent meshes switch to the tangent transparent # pipeline in-order (blending forbids regrouping); the tangent layout # shares the base set layouts + push ranges so the bound descriptor sets # and push constants stay valid across the switch. With no tangent meshes # registered the loop body is the historical two-buffer bind. tangent_ids = registry.tangent_mesh_ids tan_transparent = None if tangent_ids and any(mh.id in tangent_ids for mh, _off, _oi in draw_entries): tan_transparent = r._pipelines.tangent_pipelines()[2] using_tangent = False for mesh_handle, batch_offset, _ in draw_entries: want_tangent = tan_transparent is not None and mesh_handle.id in tangent_ids if want_tangent != using_tangent: vk.vkCmdBindPipeline( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, tan_transparent if want_tangent else r._transparent_pipeline, ) using_tangent = want_tangent bufs = registry.get_buffers(mesh_handle) if want_tangent: vk.vkCmdBindVertexBuffers(cmd, 0, 3, [bufs.position, bufs.shading, bufs.extras], [0, 0, 0]) else: vk.vkCmdBindVertexBuffers(cmd, 0, 2, [bufs.position, bufs.shading], [0, 0]) vk.vkCmdBindIndexBuffer(cmd, bufs.index, 0, vk.VK_INDEX_TYPE_UINT32) r._transparent_batch.draw_range(cmd, batch_offset, 1) def _render_skinned( self, cmd: Any, vp_id: int, viewport: Any, registry: MeshRegistry, ) -> None: """Render all skinned mesh instances.""" r = self._r if not r._skinned_instances: return e = r._engine # Build transform SSBO for skinned instances at offset after opaque instances # to avoid overwriting opaque transform data needed by later passes. The # whole block is offset by this SRU's slice base so an offscreen target's # skinned instances land in its own slice. Use clamped opaque count to match # what _upload_transforms actually wrote. base = r._first_instance_base n_opaque = min(len(r._instances), max(0, r._max_objects - base)) n_skinned = len(r._skinned_instances) if base + n_opaque + n_skinned > r._max_objects: log.warning( "Skinned + opaque instances (%d) exceed max_objects (%d), clamping", n_opaque + n_skinned, r._max_objects, ) n_skinned = max(0, r._max_objects - base - n_opaque) # Clamp the number of skinned instances so the concatenated joint palette # fits the joint SSBO; any overflow schedules a grow for the next frame # boundary (mirrors the transform arena + the web _ensureBoneBuf), so a # large/many-skeleton frame never writes past the buffer. The dropped # skeletons render one frame later once the buffer has grown. joint_counts = [len(joints) for (_a, _b, _c, joints) in r._skinned_instances[:n_skinned]] total_joints_needed = sum(joint_counts) r._buffers.request_joint_capacity(total_joints_needed) cap = r._buffers.max_joints cumulative = 0 joints_fit = n_skinned for j, c in enumerate(joint_counts): if cumulative + c > cap: joints_fit = j break cumulative += c n_skinned = joints_fit if n_skinned == 0: return # Concatenate every rendered instance's joint matrices into one global # buffer and record each instance's base offset, which rides in the # transform struct so the skinned vertex shader indexes its own skeleton # slice (joint_matrices[bone_offset + joint]). This keeps multiple # skinned skeletons isolated within the shared joint SSBO. transforms = np.zeros(n_skinned, dtype=TRANSFORM_DTYPE) all_joints: list[np.ndarray] = [] joint_cursor = 0 for i, (_mesh_handle, transform, material_id, joints) in enumerate(r._skinned_instances[:n_skinned]): if not transform.flags["C_CONTIGUOUS"]: transform = np.ascontiguousarray(transform) model_mat = transform if transform.shape == (4, 4) else transform.T model_mat_transposed = np.ascontiguousarray(model_mat.T) transforms[i]["model"] = model_mat_transposed model_3x3 = model_mat[:3, :3] try: inv_model_3x3 = np.linalg.inv(model_3x3).T normal_mat = np.eye(4, dtype=np.float32) normal_mat[:3, :3] = inv_model_3x3 transforms[i]["normal_mat"] = np.ascontiguousarray(normal_mat.T) except np.linalg.LinAlgError: transforms[i]["normal_mat"] = model_mat_transposed transforms[i]["material_index"] = material_id transforms[i]["bone_offset"] = joint_cursor # Transpose each joint matrix for column-major GLSL. all_joints.append(np.array([j.T for j in joints], dtype=np.float32)) joint_cursor += len(joints) # Upload at byte offset past this SRU's opaque transforms (within its # slice). Use upload_numpy -- the same canonical map/cast/memmove/unmap # as the joint upload below -- because ffi.memmove needs a cdata source, # not the raw ``ctypes.data`` int (which raised TypeError here). offset_bytes = (base + n_opaque) * TRANSFORM_DTYPE.itemsize upload_numpy(e.ctx.device, r._transform_mem, transforms, byte_offset=offset_bytes) if all_joints: joint_data = np.concatenate(all_joints) upload_numpy(e.ctx.device, r._joint_mem, joint_data) # Set viewport/scissor (render-scale aware) self._set_viewport_scissor(cmd, viewport) # Bind skinned pipeline vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, r._skinned_pipeline) # Bind descriptor sets: set 0=SSBOs, set 1=textures, set 2=joints vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, r._skinned_pipeline_layout, 0, 1, [r._ssbo_set], 0, None, ) tex_ds = e.texture_descriptor_set if tex_ds: vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, r._skinned_pipeline_layout, 1, 1, [tex_ds], 0, None, ) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, r._skinned_pipeline_layout, 2, 1, [r._joint_set], 0, None, ) # Push constants (view + proj + hdr_output) view_transposed = np.ascontiguousarray(viewport.camera_view.T) proj_transposed = np.ascontiguousarray(viewport.camera_proj.T) pc_data = view_transposed.tobytes() + proj_transposed.tobytes() + self._hdr_pc_bytes() e.push_constants(cmd, r._skinned_pipeline_layout, pc_data) # Build all skinned draw commands, then upload once. Append at the running # batch offset (no reset): r._batch is reset once per frame in begin_frame # and shared with the opaque pass, whose draws were recorded earlier in the # same command buffer and must not be clobbered. draw_range uses each # group's own absolute offset. skinned_base = base + n_opaque # absolute slot of the first skinned instance skinned_groups: dict[int, list[tuple[int, int]]] = {} # mesh_id -> [(batch_offset, instance_idx)] for i, (mesh_handle, _, _, _) in enumerate(r._skinned_instances[:n_skinned]): offset = r._batch.draw_count r._batch.add_draw( index_count=mesh_handle.index_count, instance_count=1, first_instance=skinned_base + i, ) skinned_groups.setdefault(mesh_handle.id, []).append((offset, i)) r._batch.upload() # Draw each mesh group with correct buffers: position + shading at # bindings 0/1 and the skin stream (joints/weights) at binding 3. for _mesh_id, entries in skinned_groups.items(): mesh_handle = r._skinned_instances[entries[0][1]][0] bufs = registry.get_buffers(mesh_handle) if bufs.skin is None: # Registered without a skin stream (e.g. cached via the static # path): the skinned pipeline cannot draw it. log.warning("Skinned instance of mesh %d has no skin stream; skipping", mesh_handle.id) continue vk.vkCmdBindVertexBuffers(cmd, 0, 2, [bufs.position, bufs.shading], [0, 0]) vk.vkCmdBindVertexBuffers(cmd, 3, 1, [bufs.skin], [0]) vk.vkCmdBindIndexBuffer(cmd, bufs.index, 0, vk.VK_INDEX_TYPE_UINT32) # Draw contiguous range for this group first_offset = entries[0][0] r._batch.draw_range(cmd, first_offset, len(entries))
def _default_viewport(width: int, height: int, camera: Any) -> Any: """Create a default viewport from camera for the full framebuffer.""" from ..types import Viewport return Viewport( x=0, y=0, width=width, height=height, camera_view=camera.view_matrix, camera_proj=camera.projection_matrix, )