Skip to content

Commit

Permalink
Merge pull request #373 from Hoikas/game_gui
Browse files Browse the repository at this point in the history
Experiemental: Game GUIs
  • Loading branch information
Hoikas authored Sep 17, 2023
2 parents d37720c + bc81e8c commit 2e43e0c
Show file tree
Hide file tree
Showing 24 changed files with 1,320 additions and 43 deletions.
10 changes: 10 additions & 0 deletions korman/exporter/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .decal import DecalConverter
from . import explosions
from .etlight import LightBaker
from .gui import GuiConverter
from .image import ImageCache
from .locman import LocalizationConverter
from . import logger
Expand All @@ -45,6 +46,7 @@
class Exporter:

if TYPE_CHECKING:
_objects: List[bpy.types.Object] = ...
actors: Set[str] = ...
want_node_trees: defaultdict[Set[str]] = ...
report: logger._ExportLogger = ...
Expand All @@ -60,6 +62,7 @@ class Exporter:
locman: LocalizationConverter = ...
decal: DecalConverter = ...
oven: LightBaker = ...
gui: GuiConverter

def __init__(self, op):
self._op = op # Blender export operator
Expand All @@ -83,6 +86,7 @@ def run(self):
self.locman = LocalizationConverter(self)
self.decal = DecalConverter(self)
self.oven = LightBaker(mesh=self.mesh, report=self.report)
self.gui = GuiConverter(self)

# Step 0.8: Init the progress mgr
self.mesh.add_progress_presteps(self.report)
Expand Down Expand Up @@ -371,6 +375,12 @@ def _export_referenced_node_trees(self):
tree.export(self, bo, so)
inc_progress()

def get_objects(self, page: Optional[str]) -> Iterator[bpy.types.Object]:
yield from filter(
lambda x: x.plasma_object.page == page,
self._objects
)

def _harvest_actors(self):
self.report.progress_advance()
self.report.progress_range = len(self._objects) + len(bpy.data.textures)
Expand Down
225 changes: 225 additions & 0 deletions korman/exporter/gui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
# This file is part of Korman.
#
# Korman 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 3 of the License, or
# (at your option) any later version.
#
# Korman 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 Korman. If not, see <http://www.gnu.org/licenses/>.

from __future__ import annotations

import bpy
import mathutils

from contextlib import contextmanager, ExitStack
import itertools
import math
from PyHSPlasma import *
from typing import *
import weakref

from .explosions import ExportError
from .. import helpers
from . import utils

if TYPE_CHECKING:
from .convert import Exporter
from .logger import _ExportLogger as ExportLogger


class Clipping(NamedTuple):
hither: float
yonder: float


class PostEffectModMatrices(NamedTuple):
c2w: hsMatrix44
w2c: hsMatrix44


class GuiConverter:

if TYPE_CHECKING:
_parent: weakref.ref[Exporter] = ...

def __init__(self, parent: Optional[Exporter] = None):
self._parent = weakref.ref(parent) if parent is not None else None

# Go ahead and prepare the GUI transparent material for future use.
if parent is not None:
self._transp_material = parent.exit_stack.enter_context(
helpers.TemporaryObject(
bpy.data.materials.new("GUITransparent"),
bpy.data.materials.remove
)
)
self._transp_material.diffuse_color = mathutils.Vector((1.0, 1.0, 0.0))
self._transp_material.use_mist = False

# Cyan's transparent GUI materials just set an opacity of 0%
tex_slot = self._transp_material.texture_slots.add()
tex_slot.texture = parent.exit_stack.enter_context(
helpers.TemporaryObject(
bpy.data.textures.new("AutoTransparentLayer", "NONE"),
bpy.data.textures.remove
)
)
tex_slot.texture.plasma_layer.opacity = 0.0
else:
self._transp_material = None

def calc_camera_matrix(
self,
scene: bpy.types.Scene,
objects: Sequence[bpy.types.Object],
fov: float,
scale: float = 0.75
) -> mathutils.Matrix:
if not objects:
raise ExportError("No objects specified for GUI Camera generation.")

# Generally, GUIs are flat planes. However, we are not Cyan, so artists cannot walk down
# the hallway to get smacked on the knuckles by programmers. This means that they might
# give us some three dimensional crap as a GUI. Therefore, to come up with a camera matrix,
# we'll use the average area-weighted inverse normal of all the polygons they give us. That
# way, the camera *always* should face the GUI as would be expected.
remove_mesh = bpy.data.meshes.remove
avg_normal = mathutils.Vector()
for i in objects:
mesh = i.to_mesh(bpy.context.scene, True, "RENDER", calc_tessface=False)
with helpers.TemporaryObject(mesh, remove_mesh):
utils.transform_mesh(mesh, i.matrix_world)
for polygon in mesh.polygons:
avg_normal += (polygon.normal * polygon.area)
avg_normal.normalize()
avg_normal *= -1.0

# From the inverse area weighted normal we found above, get the rotation from the up axis
# (that is to say, the +Z axis) and create our rotation matrix.
axis = mathutils.Vector((avg_normal.x, avg_normal.y, 0.0))
axis.normalize()
angle = math.acos(avg_normal.z)
mat = mathutils.Matrix.Rotation(angle, 3, axis)

# Now, we know the rotation of the camera. Great! What we need to do now is ensure that all
# of the objects in question fit within the view of a 4:3 camera rotated as above. Blender
# helpfully provides us with the localspace bounding boxes of all the objects and an API to
# fit points into the camera view.
with ExitStack() as stack:
stack.enter_context(self.generate_camera_render_settings(scene))

# Create a TEMPORARY camera object so we can use a certain Blender API.
camera = stack.enter_context(utils.temporary_camera_object(scene, "GUICameraTemplate"))
camera.matrix_world = mat.to_4x4()
camera.data.angle = fov
camera.data.lens_unit = "FOV"

# Get all of the bounding points and make sure they all fit inside the camera's view frame.
bound_boxes = [
obj.matrix_world * mathutils.Vector(bbox)
for obj in objects for bbox in obj.bound_box
]
co, _ = camera.camera_fit_coords(
scene,
# bound_box is a list of vectors of each corner of all the objects' bounding boxes;
# however, Blender's API wants a sequence of individual channel positions. Therefore,
# we need to flatten the vectors.
list(itertools.chain.from_iterable(bound_boxes))
)

# This generates a list of 6 faces per bounding box, which we then flatten out and pass
# into the BVHTree constructor. This is to calculate the distance from the camera to the
# "entire GUI" - which we can then use to apply the scale given to us.
if scale != 1.0:
bvh = mathutils.bvhtree.BVHTree.FromPolygons(
bound_boxes,
list(itertools.chain.from_iterable(
[(i + 0, i + 1, i + 5, i + 4),
(i + 1, i + 2, i + 5, i + 6),
(i + 3, i + 2, i + 6, i + 7),
(i + 0, i + 1, i + 2, i + 3),
(i + 0, i + 3, i + 7, i + 4),
(i + 4, i + 5, i + 6, i + 7),
] for i in range(0, len(bound_boxes), 8)
))
)
loc, normal, index, distance = bvh.find_nearest(co)
co += normal * distance * (scale - 1.0)

# ...
mat.resize_4x4()
mat.translation = co
return mat

def calc_clipping(
self,
pose: mathutils.Matrix,
scene: bpy.types.Scene,
objects: Sequence[bpy.types.Object],
fov: float
) -> Clipping:
with ExitStack() as stack:
stack.enter_context(self.generate_camera_render_settings(scene))
camera = stack.enter_context(utils.temporary_camera_object(scene, "GUICameraTemplate"))
camera.matrix_world = pose
camera.data.angle = fov
camera.data.lens_unit = "FOV"

# Determine the camera plane's normal so we can do a distance check against the
# bounding boxes of the objects shown in the GUI.
view_frame = [i * pose for i in camera.data.view_frame(scene)]
cam_plane = mathutils.geometry.normal(view_frame)
bound_boxes = (
obj.matrix_world * mathutils.Vector(bbox)
for obj in objects for bbox in obj.bound_box
)
pos = pose.to_translation()
bounds_dists = [
abs(mathutils.geometry.distance_point_to_plane(i, pos, cam_plane))
for i in bound_boxes
]

# Offset them by some epsilon to ensure the objects are rendered.
hither, yonder = min(bounds_dists), max(bounds_dists)
if yonder - 0.5 < hither:
hither -= 0.25
yonder += 0.25
return Clipping(hither, yonder)

def convert_post_effect_matrices(self, camera_matrix: mathutils.Matrix) -> PostEffectModMatrices:
# PostEffectMod matrices face *away* from the GUI... For some reason.
# See plPostEffectMod::SetWorldToCamera()
c2w = utils.matrix44(camera_matrix)
w2c = utils.matrix44(camera_matrix.inverted())
for i in range(4):
c2w[i, 2] *= -1.0
w2c[2, i] *= -1.0
return PostEffectModMatrices(c2w, w2c)

@contextmanager
def generate_camera_render_settings(self, scene: bpy.types.Scene) -> Iterator[None]:
# Set the render info to basically TV NTSC 4:3, which will set Blender's camera
# viewport up as a 4:3 thingy to match Plasma.
with helpers.GoodNeighbor() as toggle:
toggle.track(scene.render, "resolution_x", 720)
toggle.track(scene.render, "resolution_y", 486)
toggle.track(scene.render, "pixel_aspect_x", 10.0)
toggle.track(scene.render, "pixel_aspect_y", 11.0)
yield

@property
def _report(self) -> ExportLogger:
return self._parent().report

@property
def transparent_material(self) -> bpy.types.Material:
assert self._transp_material is not None
return self._transp_material

4 changes: 2 additions & 2 deletions korman/exporter/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,8 @@ def find_create_object(
return self.add_object(pl=pClass, name=name, bl=bl, so=so)
return key.object

def find_object(self, pClass: Type[KeyedT], bl=None, name=None, so=None) -> Optional[KeyedT]:
key = self.find_key(pClass, bl, name, so)
def find_object(self, pClass: Type[KeyedT], bl=None, name=None, so=None, loc=None) -> Optional[KeyedT]:
key = self.find_key(pClass, bl, name, so, loc)
if key is not None:
return key.object
return None
Expand Down
31 changes: 31 additions & 0 deletions korman/exporter/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

from PyHSPlasma import *

from ..import helpers

def affine_parts(xform):
# Decompose the matrix into the 90s-era 3ds max affine parts sillyness
# All that's missing now is something like "(c) 1998 HeadSpin" oh wait...
Expand Down Expand Up @@ -106,6 +108,20 @@ def release(self) -> bpy.types.Object:
return self._obj


def create_empty_object(name: str, owner_object: Optional[bpy.types.Object] = None) -> bpy.types.Object:
empty_object = bpy.data.objects.new(name, None)
if owner_object is not None:
empty_object.plasma_object.enabled = owner_object.plasma_object.enabled
empty_object.plasma_object.page = owner_object.plasma_object.page
bpy.context.scene.objects.link(empty_object)
return empty_object

def create_camera_object(name: str) -> bpy.types.Object:
cam_data = bpy.data.cameras.new(name)
cam_obj = bpy.data.objects.new(name, cam_data)
bpy.context.scene.objects.link(cam_obj)
return cam_obj

def create_cube_region(name: str, size: float, owner_object: bpy.types.Object) -> bpy.types.Object:
"""Create a cube shaped region object"""
region_object = BMeshObject(name)
Expand Down Expand Up @@ -136,6 +152,21 @@ def pre_export_optional_cube_region(source, attr: str, name: str, size: float, o
# contextlib.contextmanager requires for us to yield. Sad.
yield

@contextmanager
def temporary_camera_object(scene: bpy.types.Scene, name: str) -> bpy.types.Object:
try:
cam_data = bpy.data.cameras.new(name)
cam_obj = bpy.data.objects.new(name, cam_data)
scene.objects.link(cam_obj)
yield cam_obj
finally:
cam_obj = locals().get("cam_obj")
if cam_obj is not None:
bpy.data.objects.remove(cam_obj)
cam_data = locals().get("cam_data")
if cam_data is not None:
bpy.data.cameras.remove(cam_data)

@contextmanager
def temporary_mesh_object(source : bpy.types.Object) -> bpy.types.Object:
"""Creates a temporary mesh object from a nonmesh object that will only exist for the duration
Expand Down
13 changes: 13 additions & 0 deletions korman/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,16 @@ def find_modifier(bo, modid):
# if they give us the wrong modid, it is a bug and an AttributeError
return getattr(bo.plasma_modifiers, modid)
return None

def get_page_type(page: str) -> str:
all_pages = bpy.context.scene.world.plasma_age.pages
if page:
page_type = next((i.page_type for i in all_pages if i.name == page), None)
if page_type is None:
raise LookupError(page)
return page_type
else:
# A falsey page name is likely a request for the default page, so look for Page ID 0.
# If it doesn't exist, that's an implicit default page (a "room" type).
page_type = next((i.page_type for i in all_pages if i.seq_suffix == 0), None)
return page_type if page_type is not None else "room"
11 changes: 9 additions & 2 deletions korman/nodes/node_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,8 @@ class PlasmaAttribObjectNode(idprops.IDPropObjectMixin, PlasmaAttribNodeBase, bp
bl_label = "Object Attribute"

pl_attrib = ("ptAttribSceneobject", "ptAttribSceneobjectList", "ptAttribAnimation",
"ptAttribSwimCurrent", "ptAttribWaveSet", "ptAttribGrassShader")
"ptAttribSwimCurrent", "ptAttribWaveSet", "ptAttribGrassShader",
"ptAttribGUIDialog")

target_object = PointerProperty(name="Object",
description="Object containing the required data",
Expand Down Expand Up @@ -781,7 +782,13 @@ def get_key(self, exporter, so):
return None
return [exporter.mgr.find_create_key(plGrassShaderMod, so=ref_so, name=i.name)
for i in exporter.mesh.material.get_materials(bo)]

elif attrib == "ptAttribGUIDialog":
gui_dialog = bo.plasma_modifiers.gui_dialog
if not gui_dialog.enabled:
self.raise_error(f"GUI Dialog modifier not enabled on '{self.object_name}'")
dialog_mod = exporter.mgr.find_create_object(pfGUIDialogMod, so=ref_so, bl=bo)
dialog_mod.procReceiver = attrib.node.get_key(exporter, so)
return dialog_mod.key

@classmethod
def _idprop_mapping(cls):
Expand Down
1 change: 1 addition & 0 deletions korman/operators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with Korman. If not, see <http://www.gnu.org/licenses/>.

from . import op_camera as camera
from . import op_export as exporter
from . import op_image as image
from . import op_lightmap as lightmap
Expand Down
Loading

0 comments on commit 2e43e0c

Please sign in to comment.