Mi2dRPGamEng/engine/render/renderer.py

211 lines
9.7 KiB
Python

from __future__ import annotations
import math
from collections import defaultdict
import numpy as np
import wgpu
from engine.camera import OrthoCamera
from engine.character import Character
from engine.defs import DefRegistry
from engine.map import Map, rotate_point
from engine.placeholder_textures import DEPTH_SHADOW_TEXTURE
from engine.render.pipeline import QuadPipeline
from engine.render.texture import TextureCache
from engine.tile import TILE_LAYERS
from engine.world import World
CLEAR_COLOR = {"r": 0.05, "g": 0.05, "b": 0.08, "a": 1.0}
CHARACTER_SLOT_ORDER = ("head", "torso", "left_arm", "right_arm", "left_leg", "right_leg")
MAX_DEPTH_SHADOW_LAYERS = 4 # caps how dark a very deep gap gets - see visible_z_and_depth
def visible_z_and_depth(m: Map, x: int, y: int, reference_z: int) -> tuple[int | None, int]:
"""Which z-level is actually visible looking straight down column (x, y) from
`reference_z` (the camera/player's own level), and how many levels below reference_z it
is - 0 if it's exactly at reference_z, >0 if the view had to look through open air (a gap
with nothing generated there - see Tile.has_any_layer) to find real ground.
This is what makes height visible top-down, Dwarf-Fortress-style: your own level renders
normally, and looking into a lower area through a gap shows it progressively darker the
deeper it is (see Renderer._draw_depth_shadow). Returns (None, 0) if there's a genuine void
all the way down - nothing generated in this column at or below reference_z at all.
"""
if not m.in_bounds(x, y, reference_z):
return None, 0
if m.get_tile(x, y, reference_z).has_any_layer():
return reference_z, 0
for z in range(math.floor(reference_z) - 1, -1, -1):
if m.get_tile(x, y, z).has_any_layer():
return z, reference_z - z
return None, 0
class Renderer:
def __init__(self, device, pipeline: QuadPipeline, texture_cache: TextureCache, registry: DefRegistry):
self.device = device
self.pipeline = pipeline
self.texture_cache = texture_cache
self.registry = registry
self._texture_bind_groups: dict[str, object] = {}
self.ui_camera_buffer, self.ui_camera_bind_group = pipeline.create_camera_binding()
def _bind_group_for(self, relative_path: str):
if relative_path not in self._texture_bind_groups:
view = self.texture_cache.get_view(relative_path)
self._texture_bind_groups[relative_path] = self.pipeline.make_texture_bind_group(view)
return self._texture_bind_groups[relative_path]
def render_frame(
self, context, world: World, camera: OrthoCamera, ui_instances: dict[str, list[float]] | None = None
) -> None:
scale, offset = camera.scale_offset()
self.pipeline.update_camera(scale, offset)
buckets: dict[str, list[float]] = defaultdict(list)
if world.player is not None and world.player.position is not None:
active_map = world.maps[world.player.position.map_id]
reference_z = math.floor(world.player.position.z)
self._draw_map(active_map, (0, 0, 0), 0, world, buckets, reference_z)
# Screen-space overlay (menus, HUD) - a second camera transform (pixel space, not tile
# space) sharing the same buffer/instance mechanics as world content. See
# engine/render/ui_draw.py for how callers build `ui_instances`.
ui_scale = (2.0 / max(camera.viewport_w, 1), -2.0 / max(camera.viewport_h, 1))
ui_offset = (-1.0, 1.0)
self.pipeline.write_camera(self.ui_camera_buffer, ui_scale, ui_offset)
texture = context.get_current_texture()
view = texture.create_view()
encoder = self.device.create_command_encoder()
pass_encoder = encoder.begin_render_pass(
color_attachments=[
{
"view": view,
"clear_value": CLEAR_COLOR,
"load_op": wgpu.LoadOp.clear,
"store_op": wgpu.StoreOp.store,
}
]
)
pass_encoder.set_pipeline(self.pipeline.pipeline)
pass_encoder.set_bind_group(0, self.pipeline.camera_bind_group)
pass_encoder.set_vertex_buffer(0, self.pipeline.quad_vertex_buffer)
pass_encoder.set_index_buffer(self.pipeline.quad_index_buffer, wgpu.IndexFormat.uint16)
for relative_path, flat_instances in buckets.items():
instances = np.array(flat_instances, dtype=np.float32).reshape(-1, 4)
instance_buffer = self.pipeline.make_instance_buffer(instances)
pass_encoder.set_bind_group(1, self._bind_group_for(relative_path))
pass_encoder.set_vertex_buffer(1, instance_buffer)
pass_encoder.draw_indexed(self.pipeline.quad_index_count, len(instances))
if ui_instances:
pass_encoder.set_bind_group(0, self.ui_camera_bind_group)
for relative_path, flat_instances in ui_instances.items():
if not flat_instances:
continue
instances = np.array(flat_instances, dtype=np.float32).reshape(-1, 4)
instance_buffer = self.pipeline.make_instance_buffer(instances)
pass_encoder.set_bind_group(1, self._bind_group_for(relative_path))
pass_encoder.set_vertex_buffer(1, instance_buffer)
pass_encoder.draw_indexed(self.pipeline.quad_index_count, len(instances))
pass_encoder.end()
self.device.queue.submit([encoder.finish()])
def _draw_map(
self,
m: Map,
offset: tuple[int, int, int],
rotation: int,
world: World,
buckets: dict[str, list[float]],
reference_z: int = 0,
) -> None:
# Only the map the player is actually standing on gets the single-visible-level,
# depth-shaded treatment - visible_embeddings already means this is the only map ever
# reached by this recursion in practice (see its own docstring), but a fallback that
# just draws every z-level stacked keeps this correct if that ever changes.
is_players_map = (
world.player is not None and world.player.position is not None and world.player.position.map_id == m.map_id
)
if is_players_map:
for y in range(m.height):
for x in range(m.width):
z, depth = visible_z_and_depth(m, x, y, reference_z)
if z is None:
continue
self._draw_tile_layers(m, x, y, z, offset, rotation, buckets)
if depth > 0:
self._draw_depth_shadow(m, x, y, offset, rotation, depth, buckets)
else:
for z in range(m.depth):
for y in range(m.height):
for x in range(m.width):
self._draw_tile_layers(m, x, y, z, offset, rotation, buckets)
for entity in world.entities_on_map(m.map_id):
if isinstance(entity, Character):
self._draw_character(entity, offset, rotation, m.width, m.height, buckets)
for embedding in world.visible_embeddings(m):
arx, ary = rotate_point(embedding.anchor[0], embedding.anchor[1], m.width, m.height, rotation)
child_offset = (offset[0] + arx, offset[1] + ary, offset[2] + embedding.anchor[2])
child_rotation = (rotation + embedding.rotation) % 4
self._draw_map(embedding.child_map, child_offset, child_rotation, world, buckets, reference_z)
def _draw_tile_layers(
self, m: Map, x: int, y: int, z: int, offset: tuple[int, int, int], rotation: int, buckets: dict[str, list[float]]
) -> None:
tile = m.get_tile(x, y, z)
for layer in TILE_LAYERS:
def_id = tile.layer_id(layer)
if def_id is None:
continue
layer_def = self.registry.get_tile_layer_def(layer, def_id)
if layer_def is None or not layer_def.texture:
continue
rx, ry = rotate_point(x, y, m.width, m.height, rotation)
buckets[layer_def.texture].extend((float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0))
def _draw_depth_shadow(
self, m: Map, x: int, y: int, offset: tuple[int, int, int], rotation: int, depth: int, buckets: dict[str, list[float]]
) -> None:
"""Stacks `depth` (capped) copies of the same translucent-black overlay on one tile -
alpha blending compounds them, so more copies == darker, without any shader/pipeline
changes (see engine/placeholder_textures.py::DEPTH_SHADOW_TEXTURE).
"""
rx, ry = rotate_point(x, y, m.width, m.height, rotation)
position = (float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0)
for _ in range(min(depth, MAX_DEPTH_SHADOW_LAYERS)):
buckets[DEPTH_SHADOW_TEXTURE].extend(position)
def _draw_character(
self,
character: Character,
offset: tuple[int, int, int],
rotation: int,
map_width: int,
map_height: int,
buckets: dict[str, list[float]],
) -> None:
assert character.position is not None
rx, ry = rotate_point(character.position.x, character.position.y, map_width, map_height, rotation)
base_x = offset[0] + rx
base_y = offset[1] + ry
resolved = character.resolved_body_parts()
row_h = 1.0 / len(CHARACTER_SLOT_ORDER)
for i, slot in enumerate(CHARACTER_SLOT_ORDER):
part = resolved.get(slot)
if part is None:
continue
part_def = self.registry.get_body_part_def(part.part_def_id)
if not part_def.texture:
continue
buckets[part_def.texture].extend(
(base_x + 0.1, base_y + i * row_h + row_h * 0.05, 0.8, row_h * 0.9)
)