# view.py - maintain view on game world # Copyright (C) 2019 Nguyễn Gia Phong # # This file is part of Axuy # # Axuy is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Axuy is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with Axuy. If not, see . __doc__ = 'Axuy module for map class' from collections import deque from configparser import ConfigParser from os.path import join as pathjoin, pathsep from math import degrees, log2, radians from random import randint from re import IGNORECASE, match from statistics import mean from typing import Tuple from warnings import warn import glfw import moderngl import numpy as np from appdirs import AppDirs from PIL import Image from pyrr import Matrix44 from .pico import TETRAVERTICES, OCTOVERTICES, SHARD_LIFE, Picobot from .misc import abspath, color, mirror CONTROL_ALIASES = (('Move left', 'left'), ('Move right', 'right'), ('Move forward', 'forward'), ('Move backward', 'backward'), ('Primary', '1st'), ('Secondary', '2nd')) MOUSE_PATTERN = 'MOUSE_BUTTON_[1-{}]'.format(glfw.MOUSE_BUTTON_LAST + 1) INVALID_CONTROL_ERR = '{}: {} is not recognized as a valid control key' GLFW_VER_WARN = 'Your GLFW version appear to be lower than 3.3, '\ 'which might cause stuttering camera rotation.' ZMIN, ZMAX = -1.0, 1.0 CONWAY = 1.303577269034 ABRTN_MAX = 0.42069 QUAD = np.float32([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]).tobytes() TETRAINDECIES = np.int32([0, 1, 2, 3, 1, 2, 0, 3, 2, 0, 3, 1]) OCTOINDECIES = np.int32([0, 1, 2, 0, 1, 3, 4, 0, 2, 4, 0, 3, 2, 1, 5, 3, 1, 5, 2, 5, 4, 3, 5, 4]) with open(abspath('shaders/map.vert')) as f: MAP_VERTEX = f.read() with open(abspath('shaders/map.frag')) as f: MAP_FRAGMENT = f.read() with open(abspath('shaders/pico.vert')) as f: PICO_VERTEX = f.read() with open(abspath('shaders/pico.geom')) as f: PICO_GEOMETRY = f.read() with open(abspath('shaders/pico.frag')) as f: PICO_FRAGMENT = f.read() with open(abspath('shaders/tex.vert')) as f: TEX_VERTEX = f.read() with open(abspath('shaders/sat.frag')) as f: SAT_FRAGMENT = f.read() with open(abspath('shaders/gaussh.vert')) as f: GAUSSH_VERTEX = f.read() with open(abspath('shaders/gaussv.vert')) as f: GAUSSV_VERTEX = f.read() with open(abspath('shaders/gauss.frag')) as f: GAUSS_FRAGMENT = f.read() with open(abspath('shaders/comb.frag')) as f: COMBINE_FRAGMENT = f.read() class ConfigReader: """Object reading and processing command-line arguments and INI configuration file for Axuy. Attributes ---------- config : ConfigParser INI configuration file parser. host : str Host to bind the peer to. port : int Port to bind the peer to. seeder : str Address of the peer that created the map. size : Tuple[int, int] GLFW window resolution. vsync : bool Vertical synchronization. zmlvl : float Zoom level. key, mouse : Dict[str, int] Input control. mouspeed : float Relative camera rotational speed. zmspeed : float Zoom speed, in scroll steps per zoom range. """ def __init__(self): dirs = AppDirs(appname='axuy', appauthor=False, multipath=True) parents = dirs.site_config_dir.split(pathsep) parents.append(dirs.user_config_dir) filenames = [pathjoin(parent, 'settings.ini') for parent in parents] self.config = ConfigParser() self.config.read(abspath('settings.ini')) # default configuration self.config.read(filenames) # Fallback to None when attribute is missing def __getattr__(self, name): return None @property def seeder(self) -> Tuple[str, int]: """Seeder address.""" return self._seed @seeder.setter def seeder(self, value): host, port = value.split(':') self._seed = host, int(port) @property def fov(self) -> float: """Horizontal field of view in degrees.""" if self.zmlvl is None: return None return degrees(2 ** self.zmlvl) @fov.setter def fov(self, value): rad = radians(value) if rad < 0.5: warn('Too narrow FOV, falling back to the minimal value.') self.zmlvl = -1.0 return elif rad > 2: warn('Too wide FOV, falling back to the maximal value.') self.zmlvl = 1.0 return self.zmlvl = log2(rad) @property def mouspeed(self) -> float: """Relative mouse speed.""" # Standard to radians per inch for a 800 DPI mouse, at FOV of 60 return self._mouspeed / 800 @mouspeed.setter def mouspeed(self, value): self._mouspeed = value def parse(self): """Parse configurations.""" self.size = (self.config.getint('Graphics', 'Screen width'), self.config.getint('Graphics', 'Screen height')) self.vsync = self.config.getboolean('Graphics', 'V-sync') self.fov = self.config.getfloat('Graphics', 'FOV') self.host = self.config.get('Peer', 'Host') self.port = self.config.getint('Peer', 'Port') self.mouspeed = self.config.getfloat('Control', 'Mouse speed') self.zmspeed = self.config.getfloat('Control', 'Zoom speed') self.key, self.mouse = {}, {} for cmd, alias in CONTROL_ALIASES: i = self.config.get('Control', cmd) if match(MOUSE_PATTERN, i, flags=IGNORECASE): self.mouse[alias] = getattr(glfw, i.upper()) continue try: self.key[alias] = getattr(glfw, 'KEY_{}'.format(i.upper())) except AttributeError: raise ValueError(INVALID_CONTROL_ERR.format(cmd, i)) def read_args(self, arguments): """Read and parse a argparse.ArgumentParser.Namespace.""" for option in ('size', 'vsync', 'fov', 'mouspeed', 'zmspeed', 'host', 'port', 'seeder'): value = getattr(arguments, option) if value is not None: setattr(self, option, value) class View: """World map and camera placement. Parameters ---------- address : Tuple[str, int] IP address (host, port). camera : Picobot Protagonist whose view is the camera. space : np.ndarray of shape (12, 12, 9) of bools 3D array of occupied space. size : Tuple[int, int] GLFW window resolution. vsync : bool Vertical synchronization. ctl : Dict[str, int] Input control. Attributes ---------- addr : Tuple[str, int] IP address (host, port). space : np.ndarray of shape (12, 12, 9) of bools 3D array of occupied space. camera : Picobot Protagonist whose view is the camera. picos : Dict[Tuple[str, int], Picobot] Enemies characters. colors : Dict[Tuple[str, int], str] Color names of enemies. window : GLFW window zmlvl : float Zoom level (from ZMIN to ZMAX). zmspeed : float Scroll steps per zoom range. mouspeed : float Relative camera rotational speed. context : moderngl.Context OpenGL context from which ModernGL objects are created. maprog : moderngl.Program Processed executable code in GLSL for map rendering. mapva : moderngl.VertexArray Vertex data of the map. prog : moderngl.Program Processed executable code in GLSL for rendering picobots and their shards. pva : moderngl.VertexArray Vertex data of picobots. sva : moderngl.VertexArray Vertex data of shards. pfilter : moderngl.VertexArray Vertex data for filtering highly saturated colors. gaussh, gaussv : moderngl.Program Processed executable code in GLSL for Gaussian blur. gausshva, gaussvva : moderngl.VertexArray Vertex data for Gaussian blur. edge : moderngl.Program Processed executable code in GLSL for final combination of the bloom effect with additional chromatic aberration and barrel distortion. combine : moderngl.VertexArray Vertex data for final combination of the bloom effect. fb, ping, pong : moderngl.Framebuffer Frame buffers for bloom-effect post-processing. last_time : float timestamp in seconds of the previous frame. fpses : Deque[float, ...] FPS during the last 5 seconds to display the average. """ def __init__(self, address, camera, space, config): # Create GLFW window if not glfw.init(): raise RuntimeError('Failed to initialize GLFW') glfw.window_hint(glfw.CLIENT_API, glfw.OPENGL_API) glfw.window_hint(glfw.CONTEXT_CREATION_API, glfw.NATIVE_CONTEXT_API) glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True) width, height = config.size self.window = glfw.create_window( width, height, 'axuy@{}:{}'.format(*address), None, None) if not self.window: glfw.terminate() raise RuntimeError('Failed to create GLFW window') self.key, self.mouse = config.key, config.mouse self.fpses = deque() # Attributes for event-handling self.addr, self.camera, self.space = address, camera, space self.picos, self.colors = {address: camera}, {address: randint(0, 5)} self.last_time = glfw.get_time() # Window's rendering and event-handling configuration glfw.set_window_icon(self.window, 1, Image.open(abspath('icon.png'))) glfw.make_context_current(self.window) glfw.swap_interval(config.vsync) glfw.set_window_size_callback(self.window, self.resize) glfw.set_input_mode(self.window, glfw.CURSOR, glfw.CURSOR_DISABLED) glfw.set_input_mode(self.window, glfw.STICKY_KEYS, True) self.mouspeed = config.mouspeed glfw.set_cursor_pos_callback(self.window, self.look) self.zmspeed, self.zmlvl = config.zmspeed, config.zmlvl glfw.set_scroll_callback(self.window, self.zoom) glfw.set_mouse_button_callback(self.window, self.shoot) try: if glfw.raw_mouse_motion_supported(): glfw.set_input_mode(self.window, glfw.RAW_MOUSE_MOTION, True) except AttributeError: warn(GLFW_VER_WARN, category=RuntimeWarning) # Create OpenGL context self.context = context = moderngl.create_context() context.enable_only(moderngl.DEPTH_TEST) # GLSL program and vertex array for map rendering self.maprog = context.program(vertex_shader=MAP_VERTEX, fragment_shader=MAP_FRAGMENT) mapvb = context.buffer(mirror(space).tobytes()) self.mapva = context.simple_vertex_array(self.maprog, mapvb, 'in_vert') # GLSL programs and vertex arrays for picos and shards rendering pvb = [(context.buffer(TETRAVERTICES.tobytes()), '3f', 'in_vert')] pib = context.buffer(TETRAINDECIES.tobytes()) svb = [(context.buffer(OCTOVERTICES.tobytes()), '3f', 'in_vert')] sib = context.buffer(OCTOINDECIES.tobytes()) self.prog = context.program(vertex_shader=PICO_VERTEX, geometry_shader=PICO_GEOMETRY, fragment_shader=PICO_FRAGMENT) self.pva = context.vertex_array(self.prog, pvb, pib) self.sva = context.vertex_array(self.prog, svb, sib) self.pfilter = context.simple_vertex_array( context.program(vertex_shader=TEX_VERTEX, fragment_shader=SAT_FRAGMENT), context.buffer(QUAD), 'in_vert') self.gaussh = context.program(vertex_shader=GAUSSH_VERTEX, fragment_shader=GAUSS_FRAGMENT) self.gaussh['width'].value = 256 self.gausshva = context.simple_vertex_array( self.gaussh, context.buffer(QUAD), 'in_vert') self.gaussv = context.program(vertex_shader=GAUSSV_VERTEX, fragment_shader=GAUSS_FRAGMENT) self.gaussv['height'].value = 256 * height / width self.gaussvva = context.simple_vertex_array( self.gaussv, context.buffer(QUAD), 'in_vert') self.edge = context.program(vertex_shader=TEX_VERTEX, fragment_shader=COMBINE_FRAGMENT) self.edge['la'].value = 0 self.edge['tex'].value = 1 self.combine = context.simple_vertex_array( self.edge, context.buffer(QUAD), 'in_vert') size, table = (width, height), (256, height * 256 // width) self.fb = context.framebuffer(context.texture(size, 4), context.depth_renderbuffer(size)) self.fb.color_attachments[0].use(1) self.ping = context.framebuffer(context.texture(table, 3)) self.pong = context.framebuffer(context.texture(table, 3)) def resize(self, window, width, height): """Update viewport on resize.""" context = self.context context.viewport = 0, 0, width, height self.gaussv['height'].value = 256 * height / width self.fb.depth_attachment.release() for fb in (self.fb, self.ping, self.pong): for texture in fb.color_attachments: texture.release() fb.release() size, table = (width, height), (256, height * 256 // width) self.fb = context.framebuffer(context.texture(size, 4), context.depth_renderbuffer(size)) self.fb.color_attachments[0].use(1) self.ping = context.framebuffer(context.texture(table, 3)) self.pong = context.framebuffer(context.texture(table, 3)) def look(self, window, xpos, ypos): """Look according to cursor position. Present as a callback for GLFW CursorPos event. """ center = np.array(glfw.get_window_size(window)) / 2 glfw.set_cursor_pos(window, *center) self.camera.rotate(*((center - [xpos, ypos]) * self.rotspeed)) def zoom(self, window, xoffset, yoffset): """Adjust FOV according to vertical scroll.""" self.zmlvl += yoffset * 2 / self.zmspeed self.zmlvl = max(self.zmlvl, ZMIN) self.zmlvl = min(self.zmlvl, ZMAX) def shoot(self, window, button, action, mods): """Shoot on click. Present as a callback for GLFW MouseButton event. """ if action == glfw.PRESS: if button == self.mouse['1st']: self.camera.shoot() elif button == self.mouse['2nd']: self.camera.shoot(backward=True) @property def width(self) -> int: """Viewport width.""" return self.context.viewport[2] @property def height(self) -> int: """Viewport height.""" return self.context.viewport[3] @property def health(self) -> float: """Camera relative health point.""" return self.camera.health @property def pos(self) -> np.float32: """Camera position in a NumPy array.""" return self.camera.pos @property def postr(self) -> str: """Pretty camera position representation.""" return '[{:4.1f} {:4.1f} {:3.1f}]'.format(*self.camera.pos) @property def right(self) -> np.float32: """Camera right direction.""" return self.camera.rot[0] @property def upward(self) -> np.float32: """Camera upward direction.""" return self.camera.rot[1] @property def forward(self) -> np.float32: """Camera forward direction.""" return self.camera.rot[2] @property def is_running(self) -> bool: """GLFW window status.""" return not glfw.window_should_close(self.window) @property def fov(self) -> float: """Horizontal field of view in degrees.""" return degrees(2 ** self.zmlvl) @property def rotspeed(self) -> float: """Camera rotational speed, calculated from FOV and mouse speed.""" return 2**self.zmlvl * self.mouspeed @property def visibility(self) -> np.float32: """Camera visibility.""" return np.float32(3240 / (self.fov + 240)) @property def fps(self) -> float: """Currently rendered frames per second.""" return self.camera.fps @fps.setter def fps(self, fps): self.camera.fps = fps self.fpses.appendleft(fps) @property def fpstr(self) -> str: """Pretty string for displaying average FPS.""" # Average over 5 seconds, like how glxgears do it, but less efficient while len(self.fpses) > mean(self.fpses) * 5 > 0: self.fpses.pop() return '{} fps'.format(round(mean(self.fpses))) def is_pressed(self, *keys) -> bool: """Return whether given keys are pressed.""" return any(glfw.get_key(self.window, k) == glfw.PRESS for k in keys) def prender(self, obj, va, col, bright): """Render the obj and its images in bounded 3D space.""" rotation = Matrix44.from_matrix33(obj.rot).astype(np.float32).tobytes() position = obj.pos.astype(np.float32).tobytes() self.prog['rot'].write(rotation) self.prog['pos'].write(position) self.prog['color'].write(color(col, bright).tobytes()) va.render(moderngl.TRIANGLES) def render_pico(self, pico): """Render pico and its images in bounded 3D space.""" self.prender(pico, self.pva, self.colors[pico.addr], pico.health) def render_shard(self, shard): """Render shard and its images in bounded 3D space.""" self.prender(shard, self.sva, self.colors[shard.addr], shard.power/SHARD_LIFE) def add_pico(self, address): """Add picobot from given address.""" self.picos[address] = Picobot(address, self.space) self.colors[address] = randint(0, 5) def render(self): """Render the scene before post-processing.""" visibility = self.visibility projection = Matrix44.perspective_projection( self.fov, self.width/self.height, 3E-3, visibility) view = Matrix44.look_at(self.pos, self.pos + self.forward, self.upward) vp = (view @ projection).astype(np.float32).tobytes() # Render map self.maprog['visibility'].value = visibility self.maprog['mvp'].write(vp) self.mapva.render(moderngl.TRIANGLES) # Render picos and shards self.prog['visibility'].value = visibility self.prog['camera'].write(self.pos.tobytes()) self.prog['vp'].write(vp) picos = list(self.picos.values()) for pico in picos: shards = {} for index, shard in pico.shards.items(): shard.update(self.fps, picos) if not shard.power: continue self.render_shard(shard) shards[index] = shard pico.shards = shards if pico is not self.camera: self.render_pico(pico) def draw(self): """Render and post-process.""" # Render to framebuffer self.fb.use() self.fb.clear() self.render() self.fb.color_attachments[0].use() self.ping.use() self.ping.clear() self.pfilter.render(moderngl.TRIANGLES) self.ping.color_attachments[0].use() # Gaussian blur self.pong.use() self.pong.clear() self.gausshva.render(moderngl.TRIANGLES) self.pong.color_attachments[0].use() self.ping.use() self.ping.clear() self.gaussvva.render(moderngl.TRIANGLES) self.ping.color_attachments[0].use() # Combine for glow effect, chromatic aberration and barrel distortion self.context.screen.use() self.context.clear() if self.camera.dead: abrtn = ABRTN_MAX else: abrtn = min(ABRTN_MAX, (self.fov*self.health) ** -CONWAY) self.edge['abrtn'].value = abrtn self.edge['zoom'].value = (self.zmlvl + 1.0) / 100 self.combine.render(moderngl.TRIANGLES) glfw.swap_buffers(self.window) def update(self): """Handle input, update GLSL programs and render the map.""" # Update instantaneous FPS next_time = glfw.get_time() self.fps = 1 / (next_time-self.last_time) self.last_time = next_time # Character movements right, upward, forward = 0, 0, 0 if self.is_pressed(self.key['forward']): forward += 1 if self.is_pressed(self.key['backward']): forward -= 1 if self.is_pressed(self.key['left']): right -= 1 if self.is_pressed(self.key['right']): right += 1 self.camera.update(right, upward, forward) self.draw() glfw.set_window_title(self.window, '{} - axuy@{}:{} ({})'.format( self.postr, *self.addr, self.fpstr)) glfw.poll_events() def close(self): """Close window.""" glfw.terminate()