Source code for simvx.graphics.renderer.view_occlusion

"""Per-view two-phase Hi-Z occlusion culling for offscreen views (RM-B6).

Extra views (``SubViewport`` / ``RenderView``) render as scene-render units
(SRUs) through :meth:`SceneAdapter.render_to_target`, outside the main pass's
occlusion machinery. Setting ``use_occlusion=True`` on the node gives such a
view its own two-phase Hi-Z island: a scratch depth prepass + Hi-Z pyramid
sized to the VIEW's target, its own cull compute pass, indirect batches, and
visibility ping-pong buffers, driven by the exact
``Renderer._two_phase_occlusion`` sequence the main pass uses. During the
recording the renderer's occlusion attributes are swapped to this bundle (the
same save / swap / restore isolation ``render_to_target`` applies to the
submission lists), so the main pass's own occlusion state is untouched and the
default (flag-off) frame is byte-identical.

Scope (documented deviations, both engine decisions carried by RM-B6):

- Desktop only. The web renderer's Hi-Z cull is single-phase (TODO.md,
  "Web two-phase Hi-Z occlusion") and has no per-view hook yet; the flag is a
  no-op on web.
- Pipelined (P2) render mode defers per-view occlusion exactly like
  reflection-probe capture (``render_packet.py``, "State intentionally NOT
  snapshotted"): the view renders un-culled and the owning manager logs a
  one-time warning. Immediate (synchronous) mode is the supported path.
"""

from __future__ import annotations

import logging
from typing import Any

import numpy as np
import vulkan as vk

from ..types import INDIRECT_DRAW_DTYPE

log = logging.getLogger(__name__)

__all__ = ["ViewOcclusion", "view_occlusion_for"]


[docs] class ViewOcclusion: """One offscreen view's private two-phase Hi-Z occlusion state. Owns the view-sized scratch :class:`~.depth_prepass.DepthPrepass` + :class:`~.hiz_pass.HiZPass` pair (created eagerly, the sizes differ from the main pass's) and harvests the size-agnostic pieces (cull compute pass, indirect batches, visibility ping-pong buffers) from the renderer's own lazy ``_ensure_*`` helpers on the first recorded frame. Recreated whole when the view's target size or the renderer's instance capacity changes (both rare: resizes are debounced, capacity growth happens at a frame boundary after a device-idle reallocation). """ def __init__(self, engine: Any) -> None: self._engine = engine self._depth_prepass: Any = None self._hiz: Any = None self._occ_pass: Any = None self._batch_opaque: Any = None self._batch_double: Any = None self._vis_buffers: list[tuple[Any, Any]] = [] self._parity: int = 0 self._hiz_built_once: bool = False self._structure_version: int = -1 self._size: tuple[int, int] = (0, 0) self._capacity: int = 0 # Main-pass scene-renderer plan state saved across record()..finish(). self._saved: tuple | None = None # Telemetry from the most recent select: frustum-visible command count # submitted to this view's cull, and the plan (batch, ranges) pairs the # post-execution drawn count is read back from. self.last_pre_cull_count: int = 0 self._last_plans: list[tuple[Any, Any]] = [] # ------------------------------------------------------------------ record
[docs] def record(self, cmd: Any, renderer: Any, width: int, height: int) -> None: """Record this view's two-phase occlusion cull into ``cmd``. Must run while the SRU's state is bound (instances / viewport / slice base installed by ``render_to_target``) and OUTSIDE any render pass (compute is illegal inside one), i.e. between the SRU's transform upload and ``target.begin_pass``. Leaves the scene renderer's per-frame occlusion plans pointing at THIS view's culled batches so the SRU's ``render_scene_content`` consumes them; :meth:`finish` must be called after the SRU render to restore the main pass's plan state. """ r = renderer sr = r._scene_renderer self._ensure_view_passes(r, int(width), int(height)) # Save the scene renderer's per-frame plan state (it belongs to the # main pass; two_phase_select mutates the dict in place, so copy). self._saved = ( dict(sr._occ_plans), sr._occ_primary_vp_id, sr._occ_view_proj, sr._occ_base_extent, sr._occ_mip_count, sr._last_pre_cull_count, sr._last_phase1_count, ) saved_r = ( r._depth_prepass, r._passes.hiz_pass, r._occlusion_pass, r._occ_batch_opaque, r._occ_batch_double, r._vis_buffers, r._vis_parity, r._hiz_built_once, r._occ_structure_version, ) r._depth_prepass = self._depth_prepass r._passes.hiz_pass = self._hiz r._occlusion_pass = self._occ_pass r._occ_batch_opaque = self._batch_opaque r._occ_batch_double = self._batch_double r._vis_buffers = self._vis_buffers r._vis_parity = self._parity r._hiz_built_once = self._hiz_built_once r._occ_structure_version = self._structure_version try: # No timestamp pool: the phase labels are per-frame-unique and # belong to the main pass's occlusion island. r._two_phase_occlusion(cmd, r.viewport_manager.viewports, None) finally: # Harvest the lazily-created cull pass / batches / vis buffers and # the advanced parity back into the bundle, then restore the # renderer's own occlusion machinery. self._occ_pass = r._occlusion_pass self._batch_opaque = r._occ_batch_opaque self._batch_double = r._occ_batch_double self._vis_buffers = r._vis_buffers self._parity = r._vis_parity self._hiz_built_once = r._hiz_built_once self._structure_version = r._occ_structure_version ( r._depth_prepass, r._passes.hiz_pass, r._occlusion_pass, r._occ_batch_opaque, r._occ_batch_double, r._vis_buffers, r._vis_parity, r._hiz_built_once, r._occ_structure_version, ) = saved_r self.last_pre_cull_count = int(sr._last_pre_cull_count) self._last_plans = list(sr._occ_plans.values())
[docs] def finish(self, renderer: Any) -> None: """Restore the main pass's plan state after the SRU render consumed ours.""" if self._saved is None: return sr = renderer._scene_renderer plans, primary, view_proj, base_extent, mips, pre_cull, phase1 = self._saved self._saved = None sr._occ_plans.clear() sr._occ_plans.update(plans) sr._occ_primary_vp_id = primary sr._occ_view_proj = view_proj sr._occ_base_extent = base_extent sr._occ_mip_count = mips sr._last_pre_cull_count = pre_cull sr._last_phase1_count = phase1
# --------------------------------------------------------------- telemetry
[docs] def read_drawn(self, *, wait: bool = False) -> int: """Sum ``instance_count`` over this view's culled indirect commands. Mirrors ``SceneContentRenderer.read_occlusion_telemetry`` for the view's own batches. Pass ``wait=True`` (test / end-of-run use) to ``vkDeviceWaitIdle`` first so the phase-2 dispatch has executed. """ if wait: vk.vkDeviceWaitIdle(self._engine.ctx.device) total = 0 for batch, _group_ranges in self._last_plans: n = batch.draw_count if n == 0: continue size = n * INDIRECT_DRAW_DTYPE.itemsize ptr = vk.vkMapMemory(batch.device, batch.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, batch.indirect_memory) cmds = np.frombuffer(raw, dtype=INDIRECT_DRAW_DTYPE, count=n) total += int(cmds["instance_count"].sum()) return total
# ------------------------------------------------------------------ set-up def _ensure_view_passes(self, renderer: Any, width: int, height: int) -> None: """(Re)create the view-sized depth prepass + Hi-Z pyramid. The size-agnostic pieces (cull pass, batches, vis buffers) are created by the renderer's ``_ensure_*`` during the first :meth:`record` and harvested; only the two view-sized passes need explicit creation. A size or capacity change tears the whole bundle down first: capacity growth reallocated the transform/AABB SSBOs the cached descriptor sets reference, and the vis buffers are ``uint32[capacity]``. """ cap = int(renderer._max_objects) if self._depth_prepass is not None and (self._size != (width, height) or self._capacity != cap): self.destroy() if self._depth_prepass is not None: return from .depth_prepass import DepthPrepass from .hiz_pass import HiZPass dp = DepthPrepass(self._engine) dp.setup(width, height, renderer._buffers.ssbo_layout) hiz = HiZPass(self._engine) hiz.setup(width, height, dp.depth_view, dp.depth_image) self._depth_prepass = dp self._hiz = hiz self._size = (width, height) self._capacity = cap log.debug("ViewOcclusion created (%dx%d, capacity %d)", width, height, cap)
[docs] def destroy(self) -> None: """Release all GPU resources (view reaped, resized, or app shutdown).""" if self._depth_prepass is None and self._occ_pass is None: return device = self._engine.ctx.device vk.vkDeviceWaitIdle(device) if self._hiz is not None: self._hiz.cleanup() self._hiz = None if self._depth_prepass is not None: self._depth_prepass.cleanup() self._depth_prepass = None if self._occ_pass is not None: self._occ_pass.cleanup() self._occ_pass = None for batch in (self._batch_opaque, self._batch_double): if batch is not None: batch.destroy() self._batch_opaque = None self._batch_double = None for buf, mem in self._vis_buffers: if buf: vk.vkDestroyBuffer(device, buf, None) if mem: vk.vkFreeMemory(device, mem, None) self._vis_buffers = [] self._parity = 0 self._hiz_built_once = False self._structure_version = -1 self._size = (0, 0) self._capacity = 0 self.last_pre_cull_count = 0 self._last_plans = []
[docs] def view_occlusion_for(store: dict[int, ViewOcclusion], node: Any, engine: Any) -> ViewOcclusion | None: """Return (creating on demand) the node's occlusion bundle, or None. The one gate both offscreen-view managers use: with ``use_occlusion`` False (the default) this is a single ``getattr`` plus, at most, the destruction of a bundle whose flag was just toggled off, so the common path stays zero-cost. ``store`` is the manager's ``id(node) -> bundle`` dict; reaping on node exit stays the manager's responsibility. """ key = id(node) if not getattr(node, "use_occlusion", False): stale = store.pop(key, None) if stale is not None: stale.destroy() return None bundle = store.get(key) if bundle is None: bundle = store[key] = ViewOcclusion(engine) return bundle