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