"""Infinite ground-plane grid overlay for 3D editor viewports.
Draws an anti-aliased XZ grid using a fragment shader SDF approach.
Major lines every 10 units, minor lines every 1 unit, with distance fade.
X-axis coloured red, Z-axis coloured blue (Godot convention).
"""
import logging
from typing import Any
import numpy as np
import vulkan as vk
from ..gpu.pipeline import PipelineSpec, build_pipeline, create_shader_module
from ..materials.shader_compiler import compile_shader
__all__ = ["GridPass"]
log = logging.getLogger(__name__)
[docs]
class GridPass:
"""Renders an infinite XZ ground-plane grid behind scene geometry.
Uses a fullscreen quad with ray-plane intersection in the fragment shader
to produce anti-aliased grid lines with proper depth for occlusion.
"""
def __init__(self, engine: Any):
self._engine = engine
self._pipeline: Any = None
self._pipeline_layout: Any = None
self._vert_module: Any = None
self._frag_module: Any = None
self._ready = False
self.enabled = False # Editor enables explicitly; games should not show the grid
[docs]
def setup(self) -> None:
"""Initialize grid pipeline and shaders."""
e = self._engine
device = e.ctx.device
# Compile shaders
shader_dir = e.shader_dir
vert_spv = compile_shader(shader_dir / "grid.vert")
frag_spv = compile_shader(shader_dir / "grid.frag")
self._vert_module = create_shader_module(device, vert_spv)
self._frag_module = create_shader_module(device, frag_spv)
# Create pipeline
self._create_pipeline(device, e.render_pass, e.extent)
self._ready = True
log.debug("Grid pass initialized")
[docs]
def rebuild_pipeline(self, render_pass: Any) -> None:
"""Recreate the grid pipeline against a different render pass (e.g. HDR).
The grid draws inside the active scene pass; with post-processing on
that is the HDR offscreen pass, whose format differs from the swapchain
pass the pipeline was first compiled against
(VUID-vkCmdDraw-renderPass-02684).
"""
if not self._ready:
return
device = self._engine.ctx.device
if self._pipeline:
vk.vkDestroyPipeline(device, self._pipeline, None)
if self._pipeline_layout:
vk.vkDestroyPipelineLayout(device, self._pipeline_layout, None)
self._create_pipeline(device, render_pass, self._engine.extent)
def _create_pipeline(self, device: Any, render_pass: Any, extent: tuple[int, int]) -> None:
"""Create grid pipeline with alpha blend, depth test, and depth write.
Declares its fixed-function state via :class:`PipelineSpec` and defers
all cffi sub-struct plumbing (and lifetime management) to
:func:`build_pipeline`. The shaders are compiled at runtime
(``compile_shader``), so the pre-created modules are passed directly
rather than via SPIR-V paths in the spec.
``front_face`` is left at the spec default (``COUNTER_CLOCKWISE``); the
hand-rolled pipeline never set it (raw-struct default 0 == CCW) and
culling is ``NONE``, so the value is inert and the render is unchanged.
"""
spec = PipelineSpec(
name="grid",
topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST,
vertex_stride=0, # fullscreen quad (6 verts) generated in the shader
cull_mode=vk.VK_CULL_MODE_NONE,
depth_test=True,
depth_write=True, # grid writes depth for occlusion
depth_compare=vk.VK_COMPARE_OP_LESS_OR_EQUAL,
blend="alpha",
dst_alpha_factor=vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
push_size=128, # mat4 view + mat4 proj
push_stages=vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT,
# Thin G-buffer: share the HDR pass's second attachment, mask writes.
attachment_count=(2 if self._engine.gbuffer_active else 1),
)
self._pipeline, self._pipeline_layout = build_pipeline(
device, spec, render_pass, extent,
vert_module=self._vert_module, frag_module=self._frag_module,
)
[docs]
def render(self, cmd: Any, view_matrix: np.ndarray, proj_matrix: np.ndarray, extent: tuple[int, int]) -> None:
"""Render infinite grid. Call after skybox but before scene geometry."""
if not self._ready:
return
# Set viewport/scissor
vk_viewport = vk.VkViewport(
x=0.0,
y=0.0,
width=float(extent[0]),
height=float(extent[1]),
minDepth=0.0,
maxDepth=1.0,
)
vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport])
scissor = vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0),
extent=vk.VkExtent2D(width=extent[0], height=extent[1]),
)
vk.vkCmdSetScissor(cmd, 0, 1, [scissor])
# Bind pipeline
vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline)
# Push view + proj matrices (transposed for column-major GLSL)
view_t = np.ascontiguousarray(view_matrix.T)
proj_t = np.ascontiguousarray(proj_matrix.T)
pc_data = view_t.tobytes() + proj_t.tobytes()
ffi = vk.ffi
cbuf = ffi.new("char[]", pc_data)
vk._vulkan.lib.vkCmdPushConstants(
cmd,
self._pipeline_layout,
vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT,
0,
128,
cbuf,
)
# Draw fullscreen quad (6 vertices, no vertex buffer)
vk.vkCmdDraw(cmd, 6, 1, 0, 0)
[docs]
def cleanup(self) -> None:
"""Release GPU resources."""
if not self._ready:
return
device = self._engine.ctx.device
if self._pipeline:
vk.vkDestroyPipeline(device, self._pipeline, None)
if self._pipeline_layout:
vk.vkDestroyPipelineLayout(device, self._pipeline_layout, None)
if self._vert_module:
vk.vkDestroyShaderModule(device, self._vert_module, None)
if self._frag_module:
vk.vkDestroyShaderModule(device, self._frag_module, None)
self._ready = False