Source code for simvx.graphics.renderer.scene_renderer

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

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

import numpy as np
import vulkan as vk

from .._types import TRANSFORM_DTYPE, MeshHandle
from ..gpu.memory import upload_numpy

if TYPE_CHECKING:
    from .forward import ForwardRenderer
    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: ForwardRenderer) -> None: self._r = renderer
[docs] def render_scene_content(self, cmd: Any) -> None: """Render all 3D content: skybox, opaque geometry, skinned meshes, transparent geometry, particles, debug.""" r = self._r e = r._engine registry = e.mesh_registry all_viewports = r.viewport_manager.get_all() # 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, e.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, e.extent) # Render tilemap layers (2D, behind 3D geometry) if r._tilemap_pass and r._tilemap_pass._submissions and all_viewports: _, vp = all_viewports[0] r._tilemap_pass.render(cmd, vp.camera_view, e.extent) # Render 3D geometry if any instances — split opaque/transparent if r._instances: viewports = all_viewports if not viewports: w, h = e.extent from ..scene.camera import Camera cam = Camera(aspect=w / h) viewports = [(0, _default_viewport(w, h, cam))] # 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, ) # 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 if transparent and r._transparent_pipeline: for vp_id, viewport in viewports: self._render_transparent_viewport(cmd, vp_id, viewport, registry, transparent) else: # No instances — 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 particles if r._particle_pass and r._particle_submissions: r._overlay_renderer.render_particles(cmd, e.extent) # Render debug lines r._overlay_renderer.render_debug_lines(cmd, e.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, e.extent)
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, ) -> 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. """ r = self._r # Frustum cull — keep original indices into self._r._instances vp_matrix = viewport.camera_proj @ viewport.camera_view r._frustum.extract_from_matrix(vp_matrix) # (mesh_handle, original_index) for visible instances — vectorized culling visible: list[tuple[MeshHandle, int]] = [] if filtered is not None: source = [(mh, xf, vp_id_i, oi) for mh, xf, _m, vp_id_i, oi in filtered] else: source = [(mh, xf, vp_id_i, i) for i, (mh, xf, _m, vp_id_i) in enumerate(r._instances)] if not source: return # Filter by viewport candidates = [(mh, xf, idx) for mh, xf, vid, idx in source if vid == vp_id or vid == 0] if not candidates: return n = len(candidates) # Extract positions and compute scaled bounding radii — vectorized transforms_arr = np.empty((n, 4, 4), dtype=np.float32) base_radii = np.empty(n, dtype=np.float32) for i, (mh, xf, _) in enumerate(candidates): transforms_arr[i] = xf if xf.shape == (4, 4) else xf.T base_radii[i] = 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 # Vectorized frustum test vis_mask = r._frustum.cull_spheres(centers, radii) visible = [(candidates[i][0], candidates[i][2]) for i in np.nonzero(vis_mask)[0]] if not visible: return # Set viewport/scissor vk_viewport = vk.VkViewport( x=float(viewport.x), y=float(viewport.y), width=float(viewport.width), height=float(viewport.height), minDepth=0.0, maxDepth=1.0, ) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=viewport.x, y=viewport.y), extent=vk.VkExtent2D(width=viewport.width, height=viewport.height), ) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) # Bind pipeline (allow override for double-sided rendering) 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 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) # 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() r._engine.push_constants(cmd, active_layout, pc_data) # Group by mesh type — values are original instance indices (into SSBO) mesh_groups: dict[int, list[int]] = {} for mesh_handle, orig_idx in visible: mesh_groups.setdefault(mesh_handle.id, []).append(orig_idx) # Build ALL draw commands into a single batch, tracking group offsets r._batch.reset() group_ranges: list[tuple[MeshHandle, int, int]] = [] # (handle, batch_offset, count) for _mesh_id, orig_indices in mesh_groups.items(): mesh_handle = r._instances[orig_indices[0]][0] batch_offset = r._batch.add_draws(mesh_handle.index_count, orig_indices) group_ranges.append((mesh_handle, batch_offset, len(orig_indices))) # Single upload of all commands r._batch.upload() # Draw each group with correct vertex/index buffers and buffer offset for mesh_handle, batch_offset, count in group_ranges: vb, ib = registry.get_buffers(mesh_handle) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [vb], [0]) vk.vkCmdBindIndexBuffer(cmd, ib, 0, vk.VK_INDEX_TYPE_UINT32) r._batch.draw_range(cmd, batch_offset, count) 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 vp_matrix = viewport.camera_proj @ viewport.camera_view r._frustum.extract_from_matrix(vp_matrix) visible = [] for mesh_handle, transform, mat_id, inst_vp_id, orig_idx in transparent: if inst_vp_id != vp_id and inst_vp_id != 0: continue position = transform[:3, 3] if transform.shape == (4, 4) else transform[3, :3] if r._frustum.test_sphere(position, mesh_handle.bounding_radius): visible.append((mesh_handle, transform, mat_id, inst_vp_id, orig_idx)) 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 vk_viewport = vk.VkViewport( x=float(viewport.x), y=float(viewport.y), width=float(viewport.width), height=float(viewport.height), minDepth=0.0, maxDepth=1.0, ) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=viewport.x, y=viewport.y), extent=vk.VkExtent2D(width=viewport.width, height=viewport.height), ) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) # 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) view_transposed = np.ascontiguousarray(viewport.camera_view.T) proj_transposed = np.ascontiguousarray(viewport.camera_proj.T) pc_data = view_transposed.tobytes() + proj_transposed.tobytes() 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 r._transparent_batch.reset() draw_entries: list[tuple[MeshHandle, int, int]] = [] # (handle, batch_offset, orig_idx) 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=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) for mesh_handle, batch_offset, _ in draw_entries: vb, ib = registry.get_buffers(mesh_handle) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [vb], [0]) vk.vkCmdBindIndexBuffer(cmd, ib, 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. # Use clamped opaque count to match what _upload_transforms actually wrote. n_opaque = min(len(r._instances), r._max_objects) n_skinned = len(r._skinned_instances) if 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 - n_opaque) transforms = np.zeros(n_skinned, dtype=TRANSFORM_DTYPE) for i, (_mesh_handle, transform, material_id, _) 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 # Upload at byte offset past opaque transforms offset_bytes = n_opaque * TRANSFORM_DTYPE.itemsize ffi = vk.ffi ptr = vk.vkMapMemory(e.ctx.device, r._transform_mem, offset_bytes, transforms.nbytes, 0) ffi.memmove(ptr, transforms.ctypes.data, transforms.nbytes) vk.vkUnmapMemory(e.ctx.device, r._transform_mem) # Upload joint matrices — concatenate all instances' joints all_joints = [] joint_offsets = [] offset = 0 for _, _, _, joints in r._skinned_instances: joint_offsets.append(offset) # Transpose each joint matrix for column-major GLSL transposed = np.array([j.T for j in joints], dtype=np.float32) all_joints.append(transposed) offset += len(joints) if all_joints: joint_data = np.concatenate(all_joints) upload_numpy(e.ctx.device, r._joint_mem, joint_data) # Set viewport/scissor vk_viewport = vk.VkViewport( x=float(viewport.x), y=float(viewport.y), width=float(viewport.width), height=float(viewport.height), minDepth=0.0, maxDepth=1.0, ) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=viewport.x, y=viewport.y), extent=vk.VkExtent2D(width=viewport.width, height=viewport.height), ) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) # 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) view_transposed = np.ascontiguousarray(viewport.camera_view.T) proj_transposed = np.ascontiguousarray(viewport.camera_proj.T) pc_data = view_transposed.tobytes() + proj_transposed.tobytes() e.push_constants(cmd, r._skinned_pipeline_layout, pc_data) # Build all skinned draw commands, then upload once r._batch.reset() skinned_groups: dict[int, list[tuple[int, int]]] = {} # mesh_id -> [(batch_offset, instance_idx)] for i, (mesh_handle, _, _, _) in enumerate(r._skinned_instances): offset = r._batch.draw_count r._batch.add_draw( index_count=mesh_handle.index_count, instance_count=1, first_instance=i, ) skinned_groups.setdefault(mesh_handle.id, []).append((offset, i)) r._batch.upload() # Draw each mesh group with correct buffers for _mesh_id, entries in skinned_groups.items(): mesh_handle = r._skinned_instances[entries[0][1]][0] vb, ib = registry.get_buffers(mesh_handle) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [vb], [0]) vk.vkCmdBindIndexBuffer(cmd, ib, 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, )