axuy/axuy/display.py

428 lines
16 KiB
Python

# graphical display of the game world using GLFW and ModernGL
# 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 <https://www.gnu.org/licenses/>.
__doc__ = 'Axuy graphical display using GLFW and ModernGL'
__all__ = ['DispConfig', 'Display']
from abc import abstractmethod
from collections import deque
from math import degrees, log2, radians
from random import randint
from statistics import mean
from warnings import warn
import glfw
import moderngl
import numpy as np
from PIL import Image
from pyrr import matrix44
from .misc import abspath, color, mirror
from .peer import Peer, PeerConfig
from .pico import OCTOVERTICES, SHARD_LIFE, TETRAVERTICES
CONWAY = 1.303577269034
ABRTN_MAX = 0.42069
QUAD = np.float32([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1])
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 DispConfig(PeerConfig):
"""Graphical display configurations.
Attributes
----------
size : Tuple[int, int]
GLFW window resolution.
vsync : bool
Vertical synchronization.
zmlvl : float
Zoom level.
"""
def __init__(self) -> None:
PeerConfig.__init__(self)
x, y = self.size
self.options.add_argument(
'--size', type=int, nargs=2, metavar=('X', 'Y'),
help=f'the desired screen size (fallback: {x}x{y})')
self.options.add_argument(
'--vsync', action='store_true', default=None,
help=f'enable vertical synchronization (fallback: {self.vsync})')
self.options.add_argument(
'--no-vsync', action='store_false', dest='vsync',
help='disable vertical synchronization')
self.options.add_argument(
'--fov', type=float, metavar='DEGREES',
help=f'horizontal field of view (fallback: {round(self.fov)})')
@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)
def fallback(self) -> None:
"""Parse fallback configurations."""
PeerConfig.fallback(self)
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')
def read(self, arguments):
"""Read and parse a argparse.ArgumentParser.Namespace."""
PeerConfig.read(self, arguments)
for option in 'size', 'vsync', 'fov':
value = getattr(arguments, option)
if value is not None: setattr(self, option, value)
class Display(Peer):
"""World map and camera placement.
Parameters
----------
config : DispConfig
Display configurations.
Attributes
----------
camera : Pico
Protagonist whose view is the camera.
colors : Dict[Tuple[str, int], str]
Color names of enemies.
window : GLFW window
zmlvl : float
Zoom level (from ZMIN to ZMAX).
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 picos and their shards.
pva : moderngl.VertexArray
Vertex data of picos.
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.
fpses : Deque[float]
FPS during the last 5 seconds to display the average.
"""
def __init__(self, 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)
Peer.__init__(self, config)
self.camera, self.colors = self.pico, {self.addr: randint(0, 5)}
width, height = config.size
self.zmlvl = config.zmlvl
self.window = glfw.create_window(
width, height, 'axuy@{}:{}'.format(*self.addr), None, None)
if not self.window:
glfw.terminate()
raise RuntimeError('Failed to create GLFW window')
self.fpses = deque()
# 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)
# Create OpenGL context
self.context = context = moderngl.create_context()
context.enable_only(context.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(self.space))
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), '3f', 'in_vert')]
pib = context.buffer(TETRAINDECIES)
svb = [(context.buffer(OCTOVERTICES), '3f', 'in_vert')]
sib = context.buffer(OCTOINDECIES)
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)
quad_buffer = context.buffer(QUAD)
self.pfilter = context.simple_vertex_array(
context.program(vertex_shader=TEX_VERTEX,
fragment_shader=SAT_FRAGMENT),
quad_buffer, '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, quad_buffer, '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, quad_buffer, '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, quad_buffer, '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))
@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.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 visibility(self) -> np.float32:
"""Camera visibility."""
return np.float32(3240 / (self.fov + 240))
@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 f'{round(mean(self.fpses))} fps'
def get_time(self) -> float:
"""Return the current time in seconds."""
return glfw.get_time()
def prender(self, obj, va, col, bright):
"""Render the obj and its images in bounded 3D space."""
self.prog['rot'].write(
matrix44.create_from_matrix33(obj.rot, dtype=np.float32))
self.prog['pos'].write(obj.pos)
self.prog['color'].write(color(col, bright))
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 pico from given address."""
Peer.add_pico(self, address)
self.colors[address] = randint(0, 5)
def render(self) -> None:
"""Render the scene before post-processing."""
visibility = self.visibility
projection = matrix44.create_perspective_projection(
self.fov, self.width/self.height, 3E-3, visibility,
dtype=np.float32)
view = matrix44.create_look_at(
self.pos, self.pos+self.forward, self.upward, dtype=np.float32)
vp = view @ projection
# 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)
self.prog['vp'].write(vp)
for pico in self.picos.values():
for shard in pico.shards.values(): self.render_shard(shard)
if pico is not self.camera: self.render_pico(pico)
def update(self) -> None:
"""Update and render the map."""
# Update states
Peer.update(self)
self.fpses.appendleft(self.fps)
# 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)
glfw.set_window_title(self.window, '{} - axuy@{}:{} ({})'.format(
self.postr, *self.addr, self.fpstr))
@abstractmethod
def control(self) -> None:
"""Poll resizing and closing events."""
glfw.poll_events()
def __exit__(self, exc_type, exc_value, traceback):
Peer.__exit__(self, exc_type, exc_value, traceback)
glfw.terminate()