Source code for peng3d.model

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#  model.py
#
#  Copyright 2017 notna <notna@apparat.org>
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 2 of the License, or
#  (at your option) any later version.
#
#  This program 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 General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#  MA 02110-1301, USA.
#
#

__all__ = [
    "grouper",
    "calcSphereCoordinates",
    "v_magnitude",
    "v_normalize",
    "Material",
    "Bone",
    "RootBone",
    "Region",
    "Animation",
    "JSONModelGroup",
    "JSONRegionGroup",
    "Model",
]

import time

import math
from math import sqrt, cos, sin

try:
    from itertools import zip_longest
except ImportError:
    pass  # probably on readthedocs

try:
    import pyglet
    from pyglet.gl import *
except ImportError:
    pass  # probably headless


[docs]def grouper(iterable, n, fillvalue=None): """ Allows for iteration over multiple elements of a iterable at once. ``iterable`` may be any iterable, its values will be returned. Note that this may be iterated over more than once. ``n`` is the size of each group. May be any positive integer. ``fillvalue`` is optionally used to fill any groups that do not have enough items, for example if the length of the iterable is not divisible by n. Example:: >>> for i in grouper("foobarbaz",2,fillvalue=" "): ... print(i) fo ob ar ba z # Note the extra space after the z """ args = [iter(iterable)] * n return zip_longest(*args, fillvalue=fillvalue)
[docs]def calcSphereCoordinates(pos, radius, rot): """ Calculates the Cartesian coordinates from spherical coordinates. ``pos`` is a simple offset to offset the result with. ``radius`` is the radius of the input. ``rot`` is a 2-tuple of ``(azimuth,polar)`` angles. Angles are given in degrees. Most directions in this game use the same convention. The azimuth ranges from 0 to 360 degrees with 0 degrees pointing directly to the x-axis. The polar angle ranges from -90 to 90 with -90 degrees pointing straight down and 90 degrees straight up. A visualization of the angles required is given in the source code of this function. """ # Input angles should be in degrees, as in the rest of the game # E.g. phi=inclination and theta=azimuth # phi is yrad # Look from above # (Z goes positive towards you) # # Y- Z- # | / # | / "far" # |/ # X- ------+-------> X+ # /| yrad | # "near" / |<-----+ # / | "polar angle" # Z+ Y+ # theta is xrad # Look from above # (Z goes positive towards you) # # Y- Z- # | / # | / "far" # |/ # X- ------+-------> X+ # /| xrad | # "near" /<-------+ # / | "azimuth angle" # Z+ Y+ # Based on http://stackoverflow.com/questions/39647735/calculation-of-spherical-coordinates # https://en.wikipedia.org/wiki/Spherical_coordinate_system # http://stackoverflow.com/questions/25404613/converting-spherical-coordinates-to-cartesian?rq=1 phi, theta = rot phi += 90 # very important, took me four days of head-scratching to figure out phi, theta = math.radians(phi), math.radians(theta) x = pos[0] + radius * math.sin(phi) * math.cos(theta) y = pos[1] + radius * math.sin(phi) * math.sin(theta) z = pos[2] + radius * math.cos(phi) return x, y, z
[docs]def v_magnitude(v): """ Simple vector helper function returning the length of a vector. ``v`` may be any vector, with any number of dimensions """ return math.sqrt(sum(v[i] * v[i] for i in range(len(v))))
[docs]def v_normalize(v): """ Normalizes the given vector. The vector given may have any number of dimensions. """ vmag = v_magnitude(v) return [v[i] / vmag for i in range(len(v))]
[docs]class Material(object): """ Object that describes a single material of a model. This object stores all relevant data and caches. Note that this object is only created once for each model and shared between all rendered instances of it. See :py:class:`Model` for more information about the model system. """ def __init__(self, rsrcMgr, name, matdata): self.rsrcMgr = rsrcMgr self.name = name self.matdata = matdata self.texname = matdata.get("tex", "peng3d:missingtexture") self.texcat = matdata.get("texcat", "entity") if self.texcat not in self.rsrcMgr.categories: self.rsrcMgr.addCategory(self.texcat) @property def target(self): """ Read-only property storing the OpenGL constant representing the target of the texture of this material. Commonly :py:const:`GL_TEXTURE_2D` or :py:const:`GL_TEXTURE_3D`\\ . Used in texture manipulation and activation, e.g. ``glEnable(material.target)``\\ . """ return self.rsrcMgr.getTex(self.texname, self.texcat)[0] @property def id(self): """ Read-only property storing the numerical ID of the texture of this material. Used to manipulate the texture behind this material. Commonly used in binding the texture: ``glBindTexture(material.target,material.id)``\\ . """ return self.rsrcMgr.getTex(self.texname, self.texcat)[1] @property def tex_coords(self): """ Read-only property storing the texture coordinates to use when drawing with this texture. Should not be used directly, see :py:meth:`transformTexCoords()`\\ . Enables substitution of pyglet :py:class:`pyglet.graphics.Texture` objects with Materials in many places, e.g. in :py:class:`pyglet.graphics.TextureGroup`\\ . """ return self.rsrcMgr.getTex(self.texname, self.texcat)[2] @property def texdata(self): """ Read-only property equivalent to a 3-tuple containing :py:attr:`target`\\ , :py:attr:`id` and :py:attr:`tex_coords`\\ . Should be faster than getting each value directly. Useful if all of these values are needed. """ return self.rsrcMgr.getTex(self, self.texname, self.texcat)
[docs] def transformTexCoords(self, data, texcoords, dims=2): """ Transforms the given texture coordinates using the internal texture coordinates. Currently, the dimensionality of the input texture coordinates must always be 2 and the output is 3-dimensional with the last coordinate always being zero. The given texture coordinates are fitted to the internal texture coordinates. Note that values higher than 1 or lower than 0 may result in unexpected visual glitches. The length of the given texture coordinates should be divisible by the dimensionality. """ assert dims == 2 # TODO out = [] origcoords = self.tex_coords min_u, min_v = origcoords[0], origcoords[1] max_u, max_v = origcoords[6], origcoords[7] diff_u, diff_v = max_u - min_u, max_v - min_v itexcoords = iter(texcoords) for u, v in zip( itexcoords, itexcoords ): # Based on http://stackoverflow.com/a/5389547/3490549 out_u = min_u + (diff_u * u) out_v = min_v + (diff_v * v) out.extend((out_u, out_v, 0)) return out # output dimensionality is 3, e.g. t3f
[docs]class Bone(object): """ Object that represents a single bone of a model. This object stores all relevant data and caches. Note that this object is only created once for each model and shared between all rendered instances of it. Actual bone rotation and length is stored per entity and not per model allowing for different bone rotations for multiple entities using the same model. See :py:class:`Model` for more information about the model system. """ def __init__(self, rsrcMgr, name, bonedata): self.rsrcMgr = rsrcMgr self.name = name self.bonedata = bonedata self.regions = {} self.child_bones = {} self.parent = None self.start_rot = bonedata.get("start_rot", [0, 0]) self.blength = bonedata.get("length", 1.0)
[docs] def ensureBones(self, data): """ Helper method ensuring per-entity bone data has been properly initialized. Should be called at the start of every method accessing per-entity data. ``data`` is the entity to check in dictionary form. """ if "_bones" not in data: data["_bones"] = {} if self.name not in data["_bones"]: data["_bones"][self.name] = { "rot": self.start_rot[:], "length": self.blength, }
[docs] def setRot(self, data, rot): """ Sets the rotation of this bone on the given entity. ``data`` is the entity to modify in dictionary form. ``rot`` is the rotation of the bone in the format used in :py:func:`calcSphereCoordinates()`\\ . """ self.ensureBones(data) rot = rot[0] % 360, max(-90, min(90, rot[1])) data["_bones"][self.name]["rot"] = rot
[docs] def getRot(self, data): """ Returns the rotation of this bone in the given entity. ``data`` is the entity to query in dictionary form. """ self.ensureBones(data) return data["_bones"][self.name]["rot"]
[docs] def setLength(self, data, blength): """ Sets the length of this bone on the given entity. ``data`` is the entity to modify in dictionary form. ``blength`` is the new length of the bone. """ self.ensureBones(data) data["_bones"][self.name]["length"] = blength
[docs] def getLength(self, data): """ Returns the length of this bone in the given entity. ``data`` is the entity to query in dictionary form. """ self.ensureBones(data) return data["_bones"][self.name]["length"]
# No properties to access rot/length directly as they are entity-dependent
[docs] def setParent(self, parent): """ Sets the parent of this bone for all entities. Note that this method must be called before many other methods to ensure internal state has been initialized. This method also registers this bone as a child of its parent. """ self.parent = parent self.parent.child_bones[self.name] = self
[docs] def setRotate(self, data): """ Sets the OpenGL state required for proper drawing of the model. Mostly rotates and translates the camera. It is important to call :py:meth:`unsetRotate()` after calling this method to properly unset state and avoid OpenGL errors. """ self.parent.setRotate(data) glPushMatrix() x, y = self.getRot(data) pivot = self.getPivotPoint(data) px, py, pz = pivot # ppx,ppy,ppz = self.parent.getPivotPoint(data) # rot_vec = px-ppx,py-ppy,pz-ppz # print([px,py,pz],[ppx,ppy,ppz],rot_vec,[x,y],self.getLength(data)) # norm = v_normalize(rot_vec,self.name) if v_magnitude(rot_vec)==0 else [0,1,0] # prevents a zero divison error normx = [ 0, 1, 0, ] # currently fixed to the up vector, should be changed in the future for proper rotation that is not along the y or z axis normy = [0, 0, 1] # offset = -px,-py,-pz glTranslatef(px, py, pz) # -offset[0],-offset[1],-offset[2]) glRotatef(x - self.start_rot[0], normx[0], normx[1], normx[2]) glRotatef(y - self.start_rot[1], normy[0], normy[1], normy[2]) glTranslatef(-px, -py, -pz) # offset[0],offset[1],offset[2])
[docs] def unsetRotate(self, data): """ Unsets the OpenGL state that was set before calling :py:meth:`setRotate()`\\ . Note that this method may cause various OpenGL errors if called without :py:meth:`setRotate()` having been called. """ glPopMatrix() self.parent.unsetRotate(data)
[docs] def getPivotPoint(self, data): """ Returns the point this bone pivots around on the given entity. This method works recursively by calling its parent and then adding its own offset. The resulting coordinate is relative to the entity, not the world. """ ppos = self.parent.getPivotPoint(data) rot = self.parent.getRot(data) length = self.parent.getLength(data) out = calcSphereCoordinates(ppos, length, rot) return out
[docs] def transformVertices(self, data, vertices, dims=3): """ Currently unused method that transforms the given vertices according to the rotation of the bone. Currently just returns the vertices unmodified, will be implemented in the future. """ assert dims == 3 # vertices are not transformed directly, rather they are rotated dynamically via glRotate return vertices
[docs] def addRegion(self, region): """ Register a vertex Region as a dependent of this bone. ``region`` must be an instance of :py:class:`Region`\\ . """ self.regions[region.name] = region
[docs]class RootBone(Bone): """ Special bone that represents the root of a entity. This bone is immutable and cannot be rotated or otherwise modified. """
[docs] def getPivotPoint(self, data): return [0, 0, 0]
getPivotPoint.__noautodoc__ = True
[docs] def getLength(self, data): return 0
getLength.__noautodoc__ = True
[docs] def setRotate(self, data): pass # no rotation as the root bone is always pointed straight up
setRotate.__noautodoc__ = True
[docs] def unsetRotate(self, data): pass # no unset as there is no set
unsetRotate.__noautodoc__ = True
[docs]class Region(object): """ Object that represents a vertex region of a model. A vertex region is associated with a specific bone of the same model it is associated with. It has a list of vertices and optionally texture coordinates. The texture coordinates are transformed using the material it is associated with. Most regions will use quads as their primitive type, but it is also possible to use triangles, lines and points. To use quads as the geometry type, specify either ``quads``\\ , ``quad`` or ``GL_QUADS`` as its ``geometry_type``\\ . To use triangles as the geometry type, specify either ``tris``\\ , ``triangles``\\ , ``triangle`` or ``GL_TRIANGLES`` as its ``geometry_type``\\ . To use lines as the geometry type, specify either ``lines``\\ , ``line`` or ``GL_LINES`` as its ``geometry_type``\\ . To use points as the geometry type, specify either ``points``\\ , ``point``\\ , ``dots``\\ , ``dot`` or ``GL_POINTS`` as its ``geometry_type``\\ . Note that the number of vertices must be divisible by the number of vertices required per primitive, e.g. 4 for quads, 3 for triangles, 2 for lines and 1 for points. Additionally, the number of vertices and texture coordinate pairs must also match. If any of these conditions are not fulfilled, a :py:exc:`ValueError` will be raised. """ def __init__(self, rsrcMgr, name, regdata): self.rsrcMgr = rsrcMgr self.name = name self.regdata = regdata self.material = None self.bone = None self.dims = 3 self.tex_dims = 2 gtype = regdata.get("geometry_type", "quads") if gtype in ["quads", "quad", "GL_QUADS"]: self.geometry_type = GL_QUADS # points per primitive ppp = 4 elif gtype in ["tris", "triangles", "triangle", "GL_TRIANGLES"]: self.geometry_type = GL_TRIANGLES ppp = 3 elif gtype in ["lines", "line", "GL_LINES"]: self.geometry_type = GL_LINES ppp = 2 elif gtype in ["points", "point", "dots", "dot", "GL_POINTS"]: self.geometry_type = GL_POINTS ppp = 1 else: raise ValueError("Invalid Geometry type %s" % gtype) self.vertices = regdata.get("vertices", []) if len(self.vertices) % self.dims != 0: raise ValueError("Vertices must be in x,y,z groups") elif (len(self.vertices) / self.dims) % ppp != 0: raise ValueError( "Invalid amount of x,y,z groups, must be integer-divisible by %s" % ppp ) if "tex_coords" in regdata: self.enable_tex = True self.tex_coords = regdata.get("tex_coords", []) if len(self.tex_coords) / self.tex_dims != len(self.vertices) / self.dims: raise ValueError("Non-Matching amount of vertices and tex coords") elif len(self.tex_coords) % self.tex_dims != 0: raise ValueError("Tex coords must be in u,v pairs") elif (len(self.tex_coords) / self.tex_dims) % ppp != 0: raise ValueError( "Invalid amount of u,v pairs, must be integer-divisible by %s" % ppp ) else: self.enable_tex = False self.tex_coords = [0] * (self.tex_dims * (len(self.vertices) // self.dims))
[docs] def getVertices(self, data): """ Returns the vertices of this region already transformed and ready-to-use. Internally uses :py:meth:`Bone.transformVertices()`\\ . """ return self.bone.transformVertices(data, self.vertices, self.dims)
[docs] def getGeometryType(self, data): """ Returns the OpenGL constant representing the type of primitives used by this region. May be one of :py:data:`GL_QUADS`\\ , :py:data:`GL_TRIANGLES`\\ , :py:data:`GL_LINES` or :py:data:`GL_POINTS`\\ . """ return self.geometry_type
[docs] def getTexCoords(self, data): """ Returns the texture coordinates, if any, to accompany the vertices of this region already transformed. Note that it is recommended to check the :py:attr:`enable_tex` flag first. Internally uses :py:meth:`Material.transformTexCoords()`\\ . """ return self.material.transformTexCoords(data, self.tex_coords, self.tex_dims)
[docs] def getTexInfo(self, data): """ Returns informations about the texture of this region. Internally uses :py:attr:`Material.texdata`\\ , exact specification available there. """ return self.material.texdata
[docs]class Animation(object): """ Object that represents an animation of a model. Animations can be either static or animated using keyframes. See :py:class:`Model` for more information. """ def __init__(self, rsrcMgr, name, anidata): self.rsrcMgr = rsrcMgr self.name = name self.anidata = anidata self.bones = {} self.atype = anidata.get("type", "bone" if "keyframes" in anidata else "static") self.default_jt = anidata.get("default_jumptype", "animate") self.entity_defaults = {} self.entity_template = {} if self.atype == "static": self.start_frame = {"bones": anidata.get("bones")} elif self.atype == "keyframes": self.kps = anidata.get("keyframespersecond", 60) self.anilength = anidata.get("length", self.kps) self.lframes_per_bone = {} # for length-setting keyframes self.rframes_per_bone = {} # for rotating keyframes minframe = None for frame, keyframe in anidata.get("keyframes", {}).items(): frame = int(frame) if minframe is None or frame < minframe: minframe = frame for bname, bone in keyframe.get("bones", {}).items(): if "rot" in bone: if bname not in self.rframes_per_bone: self.rframes_per_bone[bname] = {} self.rframes_per_bone[bname][frame] = bone["rot"] if "length" in bone: if bname not in self.lframes_per_bone: self.lframes_per_bone[bname] = {} self.lframes_per_bone[bname][frame] = bone["length"] self.start_frame = ( anidata["keyframes"][str(minframe)] if minframe is not None else {"bones": {}} ) self.start_frame["frame"] = minframe if minframe is not None else 0 else: raise ValueError( "Invalid animation type %s for animation %s" % (self.atype, name) )
[docs] def setBones(self, bones): """ Sets the internal dictionary of bones in the parent model. Must be a dictionary, else errors may appear later on. """ self.bones = bones
[docs] def startAnimation(self, data, jumptype): """ Callback that is called to initialize this animation on a specific actor. Internally sets the ``_anidata`` key of the given dict ``data``\\ . ``jumptype`` is either ``jump`` or ``animate`` to define how to switch to this animation. """ data["_anidata"] = {} adata = data["_anidata"] adata["keyframe"] = 0 adata["last_tick"] = time.time() adata["jumptype"] = jumptype adata["phase"] = "transition"
[docs] def tickEntity(self, data): """ Callback that should be called regularly to update the animation. It is recommended to call this method about 60 times a second for smooth animations. Irregular calling of this method will be automatically adjusted. This method sets all the bones in the given actor to the next state of the animation. Note that :py:meth:`startAnimation()` must have been called before calling this method. """ adata = data["_anidata"] if adata.get("anitype", self.name) != self.name: return # incorrectly called dt = time.time() - adata.get("last_tick", time.time()) adata["last_tick"] = time.time() if adata.get("phase", "transition") == "transition": # If transitioning to this animation if adata.get("jumptype", self.default_jt) == "jump": # Jumping to the animation, e.g. set all bones to the start frame for bone, dat in self.start_frame.get("bones", {}).items(): if "rot" in dat: self.bones[bone].setRot(data, dat["rot"]) if "length" in dat: self.bones[bone].setLength(data, dat["length"]) adata["phase"] = "animation" if self.atype == "keyframes": adata["from_frame"] = self.start_frame.get("frame", 0) elif adata.get("jumptype", self.default_jt) == "animate": raise NotImplementedError( "Animation update phase transition type animate not yet implemented" ) # Not yet working if "transition_frame" not in adata: adata["transition_frame"] = 0 tlen = self.anidata.get( "transition_length", self.anidata.get("length", 60) / 3 ) tspeed = self.anidata.get( "transition_speed", self.anidata.get("keyframespersecond", 60) ) framediff = int( dt / (1.0 / tspeed) ) # Number of frames that have passed since the last update overhang = dt - ( framediff * (1.0 / tspeed) ) # time that has passed but is not enough for a full frame adata[ "last_tick" ] -= overhang # causes the next frame to include the overhang from this frame if framediff == 0: return # optimization that saves time frame1 = adata["transition_frame"] adata["transition_frame"] += framediff if adata["transition_frame"] > tlen: adata["phase"] = "animation" if self.atype == "keyframes": adata["from_frame"] = self.start_frame.get("frame", 0) return frame2 = adata["transition_frame"] if "frombones" not in adata: frombones = {} for bname, bone in self.bones.items(): frombones[bname] = { "rot": bone.getRot(data), "length": bone.getLength(data), } adata["frombones"] = frombones if "tobones" not in adata: tobones = {} for bname, bone in self.start_frame["bones"].items(): tobones[bname] = bone adata["tobones"] = tobones frombones = adata["frombones"] tobones = adata["tobones"] from_frame = 0 to_frame = tlen for bname, bone in self.bones.items(): # rot if bname not in frombones or bname not in tobones: continue from_rot = frombones[bname]["rot"] to_rot = tobones[bname]["rot"] from_x, from_y = from_rot to_x, to_y = to_rot delta_per_frame_x = (to_x - from_x) / (to_frame - from_frame) delta_per_frame_y = (to_y - from_y) / (to_frame - from_frame) delta_x = delta_per_frame_x * (frame2 - frame1) delta_y = delta_per_frame_y * (frame2 - frame1) rot = bone.getRot(data) new_rot = [rot[0] + delta_x, rot[1] + delta_y] bone.setRot(data, new_rot) # length # Not yet implemented elif adata["phase"] == "animation": # If animating if self.atype == "static": pass # Should not need any updates as the transition will set the bones elif self.atype == "keyframes": framediff = int( dt / (1.0 / self.kps) ) # Number of frames that have passed since the last update overhang = dt - ( framediff * (1.0 / self.kps) ) # time that has passed but is not enough for a full frame adata[ "last_tick" ] -= overhang # causes the next frame to include the overhang from this frame if framediff == 0: return # optimization that saves time frame1 = adata["keyframe"] adata["keyframe"] += framediff if adata["keyframe"] > self.anilength: repeat = True adata["keyframe"] %= self.anilength else: repeat = False frame2 = adata["keyframe"] if repeat: frame1 = frame2 frame2 += 1 for bname, bone in self.bones.items(): # Rot if bname in self.rframes_per_bone: from_frame = None # adata["from_frame"] to_frame = None for framenum, rot in self.rframes_per_bone[bname].items(): if ( from_frame is None or framenum > from_frame ) and framenum <= frame1: # from_frame is the largest frame number that is before the starting point from_frame = framenum if ( to_frame is None or framenum < to_frame ) and framenum >= frame2: # to_frame is the smallest frame number that is after the end point to_frame = framenum if from_frame is None or to_frame is None: raise ValueError( "Invalid frames for bone %s in animation %s" % (bname, self.name) ) if from_frame == to_frame or repeat: if self.anidata.get("repeat", "jump") == "jump": for b, dat in self.start_frame.get("bones", {}).items(): if "rot" in dat: self.bones[b].setRot(data, dat["rot"]) if "length" in dat: self.bones[b].setLength(data, dat["length"]) elif self.anidata.get("repeat", "jump") == "animate": from_frame = adata["to_frame"] adata["from_frame"] = from_frame adata["to_frame"] = to_frame from_rot = self.rframes_per_bone[bname][from_frame] to_rot = self.rframes_per_bone[bname][to_frame] from_x, from_y = from_rot to_x, to_y = to_rot if self.anidata.get("interpolation", "linear") == "linear": delta_per_frame_x = (to_x - from_x) / ( to_frame - from_frame ) delta_per_frame_y = (to_y - from_y) / ( to_frame - from_frame ) delta_x = delta_per_frame_x * (frame2 - frame1) delta_y = delta_per_frame_y * (frame2 - frame1) rot = bone.getRot(data) new_rot = [rot[0] + delta_x, rot[1] + delta_y] elif self.anidata["interpolation"] == "jump": new_rot = to_x, to_y else: raise ValueError( "Invalid interpolation method '%s' for animation %s" % (self.anidata["interpolation"], self.name) ) bone.setRot(data, new_rot) # Length if bname in self.lframes_per_bone: # Not yet implemented pass else: # Should not be possible, but still raise ValueError( "Invalid animation type %s for animation %s during tick" % (self.atype, self.name) )
[docs]class JSONModelGroup(pyglet.graphics.Group): """ Pyglet group that sets the state required by a specific actor. This group should always be set during any draw operations for the assigned actor. This can either be done by setting it as the group of a vertex list, the parent group of a group of a vertex list or manually calling :py:meth:`set_state()` and :py:meth:`unset_state()`\\ . """ def __init__(self, model, data, obj, parent=None): super(JSONModelGroup, self).__init__(parent) self.model = model self.data = data self.obj = obj
[docs] def set_state(self): """ Sets the state required for this actor. Currently translates the matrix to the position of the actor. """ x, y, z = self.obj.pos glTranslatef(x, y, z)
[docs] def unset_state(self): """ Resets the state required for this actor to the default state. Currently resets the matrix to its previous translation. """ x, y, z = self.obj.pos glTranslatef(-x, -y, -z)
def __hash__(self): # Internal method to allow unification of multiple groups of the same actor return hash((self.obj,)) def __eq__(self, other): return ( self.__class__ is other.__class__ and self.obj.pos == other.obj.pos and self.parent == other.parent )
[docs]class JSONRegionGroup(pyglet.graphics.Group): """ Pyglet group that manages the state required by a specific vertex region of an actor. This group and the associated :py:class:`JSONModelGroup` should always be set during any draw operation for the assigned region. See :py:class:`JSONModelGroup` for more information about how to do this. """ def __init__(self, model, data, region, parent=None): super(JSONRegionGroup, self).__init__(parent) self.model = model self.data = data self.region = region
[docs] def set_state(self): """ Sets the state required for this vertex region. Currently binds and enables the texture of the material of the region. """ glEnable(self.region.material.target) glBindTexture(self.region.material.target, self.region.material.id) self.region.bone.setRotate(self.data)
[docs] def unset_state(self): """ Resets the state required for this actor to the default state. Currently only disables the target of the texture of the material, it may still be bound. """ glDisable(self.region.material.target) self.region.bone.unsetRotate(self.data)
def __hash__(self): return hash((self.region.material.target, self.region.material.id, self.parent)) def __eq__(self, other): return ( self.__class__ is other.__class__ and self.region.material.target == other.region.material.target and self.region.material.id == other.region.material.id and self.region.bone is other.region.bone and self.region.bone.getRot(self.data) == other.region.bone.getRot(other.data) and self.region.bone.getLength(self.data) == other.region.bone.getLength(other.data) and self.parent == other.parent # TODO: include parent bones )
[docs]class Model(object): """ Object that represents the model of an actor. Note that this object is not bound to an actor but rather to a collection of materials, bones, vertex regions and animations. A single instance of this class may be used by multiple actors at the same time. See :py:meth:`Actor.setModel()` for more information. A test model is available at ``assets/peng3d/model/test.json`` and a demo program using it under ``test_model.py``\\ . .. todo:: Document the format of .json model files. """ def __init__(self, peng, rsrcMgr, name): self.peng = peng self.rsrcMgr = rsrcMgr self.name = name self.modeldata = self.rsrcMgr.getModelData(name)
[docs] def ensureModelData(self, obj): """ Ensures that the given ``obj`` has been initialized to be used with this model. If the object is found to not be initialized, it will be initialized. """ if not hasattr(obj, "_modeldata"): self.create(obj, cache=True) if "_modelcache" not in obj._modeldata: # Assume all initialization is missing, simply reinitialize self.create(obj, cache=True)
[docs] def create(self, obj, cache=False): """ Initializes per-actor data on the given object for this model. If ``cache`` is set to True, the entity will not be redrawn after initialization. Note that this method may set several attributes on the given object, most of them starting with underscores. During initialization of vertex regions, several vertex lists will be created. If the given object has an attribute called ``batch3d`` it will be used, else it will be created. If the batch already existed, the :py:meth:`draw()` method will do nothing, else it will draw the batch. Memory leaks may occur if this is called more than once on the same object without calling :py:meth:`cleanup()` first. """ obj._modeldata = {} data = obj._modeldata data["_model"] = self data["_modelcache"] = {} moddata = data["_modelcache"] # Model group, for pos of entity moddata["group"] = JSONModelGroup(self, data, obj) modgroup = moddata["group"] if not hasattr(obj, "batch3d"): obj.batch3d = pyglet.graphics.Batch() data["_manual_render"] = True # Vlists/Regions moddata["vlists"] = {} for name, region in self.modeldata["regions"].items(): v = region.getVertices(data) vlistlen = int(len(v) / region.dims) if region.enable_tex: vlist = obj.batch3d.add( vlistlen, region.getGeometryType(data), JSONRegionGroup(self, data, region, modgroup), "v3f/static", "t3f/static", ) else: vlist = obj.batch3d.add( vlistlen, region.getGeometryType(data), modgroup, "v3f/static", ) moddata["vlists"][name] = vlist self.setAnimation( obj, self.modeldata["default_animation"].name, transition="jump" ) self.data = data if not cache: self.redraw(obj)
[docs] def cleanup(self, obj): """ Cleans up any left over data structures, including vertex lists that reside in GPU memory. Behaviour is undefined if it is attempted to use this model with the same object without calling :py:meth:`create()` first. It is very important to call this method manually during deletion as this will delete references to data objects stored in global variables of third-party modules. """ if not hasattr(obj, "_modeldata"): return # already not initialized data = obj._modeldata if "_modelcache" in data: moddata = data["_modelcache"] if "vlists" in moddata: for vlist in list(moddata["vlists"].keys()): del moddata["vlists"][vlist] del moddata["vlists"] if "_bones" in data: for bone in list(data["_bones"].keys()): del data["_bones"][bone] del data["_bones"] if "_anidata" in data: adata = data["_anidata"] if "_schedfunc" in adata: pyglet.clock.unschedule(adata["_schedfunc"]) if data.get("_manual_render", False): del obj.batch3d # Removes open vertex lists and other caches if "_modelcache" not in data: return moddata = data["_modelcache"] if "vlist" in moddata: moddata["vlist"].delete() # Free up the graphics memory if "group" in moddata: del moddata["group"] del data["_modelcache"], moddata
[docs] def redraw(self, obj): """ Redraws the model of the given object. Note that currently this method probably won't change any data since all movement and animation is done through pyglet groups. """ self.ensureModelData(obj) data = obj._modeldata vlists = data["_modelcache"]["vlists"] for name, region in self.modeldata["regions"].items(): vlists[name].vertices = region.getVertices(data) if region.enable_tex: vlists[name].tex_coords = region.getTexCoords(data)
[docs] def draw(self, obj): """ Actually draws the model of the given object to the render target. Note that if the batch used for this object already existed, drawing will be skipped as the batch should be drawn by the owner of it. """ self.ensureModelData(obj) data = obj._modeldata if data.get("_manual_render", False): obj.batch3d.draw()
[docs] def remove(self, obj): """ Called if the actor is removed from the world. Can be extended for custom features, currently calls :py:meth:`cleanup()`\\ . """ # Can be extended, but the base class should always be called self.cleanup(obj)
[docs] def setAnimation(self, obj, animation, transition=None, force=False): """ Sets the animation to be used by the object. See :py:meth:`Actor.setAnimation()` for more information. """ self.ensureModelData(obj) data = obj._modeldata # Validity check if animation not in self.modeldata["animations"]: raise ValueError( "There is no animation of name '%s' for model '%s'" % (animation, self.modelname) ) if data.get("_anidata", {}).get("anitype", None) == animation and not force: return # animation is already running # Cache the obj to improve readability anim = self.modeldata["animations"][animation] # Set to default if not set if transition is None: transition = anim.default_jt # Notify the animation to allow it to initialize itself anim.startAnimation(data, transition) # initialize animation data if "_anidata" not in data: data["_anidata"] = {} adata = data["_anidata"] adata["anitype"] = animation if "_schedfunc" in adata: # unschedule the old animation, if any # prevents clashing and crashes pyglet.clock.unschedule(adata["_schedfunc"]) # Schedule the animation function def schedfunc(*args): # This function is defined locally to create a closure # The closure stores the local variables, e.g. anim and data even after the parent function has finished # Note that this may also prevent the garbage collection of any objects defined in the parent scope anim.tickEntity(data) # register the function to pyglet pyglet.clock.schedule_interval( schedfunc, 1.0 / (anim.kps if anim.atype == "keyframes" else 60) ) # save it for later for de-initialization adata["_schedfunc"] = schedfunc