small-projects/lighting/ray-lighting3.py

261 lines
8.5 KiB
Python
Raw Normal View History

2025-04-05 23:26:40 +00:00
import pygame
import sys
import math
import random
# -----------------------------
# Pygame and world initialization
# -----------------------------
pygame.init()
SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600
BLOCK_SIZE = 40
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("2D Voxel Game with Persistent Pixel Lighting")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 20)
# -----------------------------
# Global Options
# -----------------------------
debug_mode = False # Toggle full debug overlay (F3)
num_rays = 360 # Number of primary rays to cast
max_distance = 1000 # Maximum distance for a ray if nothing is hit
ray_intersect_count = 0 # Global counter (resets each frame)
# -----------------------------
# Voxel World Generation (each block gets a random color)
# -----------------------------
def generate_blocks():
blocks = []
cols = SCREEN_WIDTH // BLOCK_SIZE
rows = SCREEN_HEIGHT // BLOCK_SIZE
for i in range(cols):
for j in range(rows):
# 20% chance that this grid cell is a solid block.
if random.random() < 0.2:
rect = pygame.Rect(i * BLOCK_SIZE, j * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE)
color = (random.randint(50, 255), random.randint(50, 255), random.randint(50, 255))
blocks.append((rect, color))
return blocks
blocks = generate_blocks()
# -----------------------------
# BVH Data Structures and Build
# -----------------------------
class BVHNode:
def __init__(self, bbox, left=None, right=None, block=None):
self.bbox = bbox # (min_x, min_y, max_x, max_y)
self.left = left
self.right = right
self.block = block # For leaf nodes, store the block (tuple: (pygame.Rect, color))
def rect_to_bbox(block):
rect = block[0] if isinstance(block, tuple) else block
return (rect.left, rect.top, rect.right, rect.bottom)
def union_bbox(b1, b2):
x1 = min(b1[0], b2[0])
y1 = min(b1[1], b2[1])
x2 = max(b1[2], b2[2])
y2 = max(b1[3], b2[3])
return (x1, y1, x2, y2)
def build_bvh(block_list):
if not block_list:
return None
if len(block_list) == 1:
return BVHNode(rect_to_bbox(block_list[0]), block=block_list[0])
block_list.sort(key=lambda block: block[0].centerx)
mid = len(block_list) // 2
left = build_bvh(block_list[:mid])
right = build_bvh(block_list[mid:])
if left and right:
bbox = union_bbox(left.bbox, right.bbox)
elif left:
bbox = left.bbox
else:
bbox = right.bbox
return BVHNode(bbox, left, right)
bvh_root = build_bvh(blocks)
# -----------------------------
# BVH Statistics Functions
# -----------------------------
def get_bvh_stats(node):
if node is None:
return (0, 0)
if node.block is not None:
return (1, 1)
left_count, left_depth = get_bvh_stats(node.left)
right_count, right_depth = get_bvh_stats(node.right)
total = 1 + left_count + right_count
depth = 1 + max(left_depth, right_depth)
return (total, depth)
# -----------------------------
# Ray-AABB Intersection (Slab Method)
# -----------------------------
def ray_intersect_aabb(origin, direction, bbox):
global ray_intersect_count
ray_intersect_count += 1
tmin = -math.inf
tmax = math.inf
ox, oy = origin
dx, dy = direction
# X slab
if dx != 0:
tx1 = (bbox[0] - ox) / dx
tx2 = (bbox[2] - ox) / dx
tmin = max(tmin, min(tx1, tx2))
tmax = min(tmax, max(tx1, tx2))
else:
if not (bbox[0] <= ox <= bbox[2]):
return None
# Y slab
if dy != 0:
ty1 = (bbox[1] - oy) / dy
ty2 = (bbox[3] - oy) / dy
tmin = max(tmin, min(ty1, ty2))
tmax = min(tmax, max(ty1, ty2))
else:
if not (bbox[1] <= oy <= bbox[3]):
return None
if tmax >= tmin and tmax >= 0:
return tmin if tmin >= 0 else tmax
return None
# -----------------------------
# BVH Ray Casting
# -----------------------------
def ray_cast_bvh(node, origin, direction):
if node is None:
return None, None
t_bbox = ray_intersect_aabb(origin, direction, node.bbox)
if t_bbox is None:
return None, None
if node.block is not None:
t_hit = ray_intersect_aabb(origin, direction, rect_to_bbox(node.block))
if t_hit is not None:
return t_hit, node.block
else:
return None, None
t_left, block_left = ray_cast_bvh(node.left, origin, direction)
t_right, block_right = ray_cast_bvh(node.right, origin, direction)
if t_left is not None and t_right is not None:
return (t_left, block_left) if t_left < t_right else (t_right, block_right)
elif t_left is not None:
return t_left, block_left
elif t_right is not None:
return t_right, block_right
return None, None
# -----------------------------
# Single Ray Casting (No Bouncing)
# -----------------------------
def cast_ray_single(origin, direction):
"""
Cast a single ray from origin in the given direction.
If the ray collides with a block within max_distance, return (hit_point, block_color).
Otherwise, return None.
"""
t, hit_block = ray_cast_bvh(bvh_root, origin, direction)
if t is None or t > max_distance:
return None
hit_point = (origin[0] + direction[0] * t,
origin[1] + direction[1] * t)
block_color = hit_block[1] if isinstance(hit_block, tuple) else (255, 255, 255)
return (hit_point, block_color)
# -----------------------------
# Persistent Pixel Cache Update
# -----------------------------
# Create a persistent light map surface once. We fill it with black initially.
persistent_light_map = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
persistent_light_map.fill((0, 0, 0))
def update_pixel_cache(light_pos):
"""For each ray cast from light_pos, update the persistent_light_map with the collision pixel and its color."""
for angle in range(0, 360, max(1, 360 // num_rays)):
rad = math.radians(angle)
direction = (math.cos(rad), math.sin(rad))
result = cast_ray_single(light_pos, direction)
if result is not None:
hit_point, hit_color = result
# Set the pixel on the persistent_light_map at the collision coordinate.
persistent_light_map.set_at((int(hit_point[0]), int(hit_point[1])), hit_color)
# -----------------------------
# Advanced Debug Menu (No Background)
# -----------------------------
def draw_debug_menu(surface, fps):
bvh_node_count, bvh_depth = get_bvh_stats(bvh_root)
debug_lines = [
"DEBUG MODE ACTIVE",
f"FPS: {fps:.1f}",
f"Blocks: {len(blocks)}",
f"BVH Nodes: {bvh_node_count}",
f"BVH Depth: {bvh_depth}",
f"Light Source: {light_source}",
f"Rays Cast: {num_rays}",
f"Ray Intersection Tests (this frame): {ray_intersect_count}",
f"Avg Tests per Ray: {ray_intersect_count / num_rays:.2f}",
"Toggles: F3 - Debug, R - Regenerate World"
]
y_offset = 10
for line in debug_lines:
text_surf = font.render(line, True, (255, 255, 255))
surface.blit(text_surf, (10, y_offset))
y_offset += text_surf.get_height() + 2
# -----------------------------
# Main Game Loop
# -----------------------------
light_source = (SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2) # Starting light source position
running = True
while running:
ray_intersect_count = 0 # Reset each frame
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_F3:
debug_mode = not debug_mode
elif event.key == pygame.K_r:
blocks = generate_blocks()
bvh_root = build_bvh(blocks)
elif event.type == pygame.MOUSEMOTION:
light_source = event.pos
# We don't clear the persistent_light_map so that it accumulates.
# Optionally, you could add a slight fade if desired.
# Update persistent_light_map with new collision pixels for the current light source.
update_pixel_cache(light_source)
# Draw the persistent light map on the main screen.
screen.fill((30, 30, 30))
screen.blit(persistent_light_map, (0, 0))
# Draw the light source for reference.
pygame.draw.circle(screen, (255, 255, 0), light_source, 5)
if debug_mode:
fps = clock.get_fps()
draw_debug_menu(screen, fps)
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit()