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