From 151c31f2e99912a921012382c91d584314ef7a3b Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 13 Dec 2018 18:33:57 -0500 Subject: [PATCH] Implement cubemap generate operator This operator takes a file as an argument and builds a cubemap from it. Valid options are to supply the output from Plasma's Graphics.Renderer.GrabCubeMap console command. The operator will find the other five files and generate a cubemap with the faces saved by Plasma. Otherwise, any arbitrary image can be supplied. If the filenames do not fit the expected format, any missing faces will be replaced by the face specified in the file selector. This will generally result in a cubemap with six identical faces. --- korlib/korlib.h | 1 + korlib/texture.cpp | 51 +++++--- korman/exporter/material.py | 10 +- korman/helpers.py | 2 +- korman/korlib/texture.py | 13 +- korman/operators/__init__.py | 1 + korman/operators/op_image.py | 237 +++++++++++++++++++++++++++++++++++ korman/ui/ui_texture.py | 27 ++-- 8 files changed, 301 insertions(+), 41 deletions(-) create mode 100644 korman/operators/op_image.py diff --git a/korlib/korlib.h b/korlib/korlib.h index 99a8ba8..6c42d8f 100644 --- a/korlib/korlib.h +++ b/korlib/korlib.h @@ -29,6 +29,7 @@ class PyObjectRef { PyObject* m_object; public: + PyObjectRef() : m_object() { } PyObjectRef(PyObject* o) : m_object(o) { } ~PyObjectRef() { Py_XDECREF(m_object); } diff --git a/korlib/texture.cpp b/korlib/texture.cpp index 8030f87..d7ae9c8 100644 --- a/korlib/texture.cpp +++ b/korlib/texture.cpp @@ -63,10 +63,12 @@ static void _flip_image(size_t width, size_t dataSize, uint8_t* data) { } static inline bool _get_float(PyObject* source, const char* attr, float& result) { - PyObjectRef pyfloat = PyObject_GetAttrString(source, attr); - if (pyfloat) { - result = (float)PyFloat_AsDouble(pyfloat); - return PyErr_Occurred() == NULL; + if (source) { + PyObjectRef pyfloat = PyObject_GetAttrString(source, attr); + if (pyfloat) { + result = (float)PyFloat_AsDouble(pyfloat); + return PyErr_Occurred() == NULL; + } } return false; } @@ -231,20 +233,27 @@ static PyObject* pyGLTexture_new(PyTypeObject* type, PyObject* args, PyObject* k } static int pyGLTexture___init__(pyGLTexture* self, PyObject* args, PyObject* kwds) { - static char* kwlist[] = { _pycs("texkey"), _pycs("bgra"), _pycs("fast"), NULL }; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|bb", kwlist, &self->m_textureKey, + static char* kwlist[] = { _pycs("texkey"), _pycs("image"), _pycs("bgra"), _pycs("fast"), NULL }; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OObb", kwlist, &self->m_textureKey, &self->m_blenderImage, &self->m_bgra, &self->m_imageInverted)) { - PyErr_SetString(PyExc_TypeError, "expected a korman.exporter.material._Texture"); + PyErr_SetString(PyExc_TypeError, "expected a korman.exporter.material._Texture or a bpy.types.Image"); + return -1; + } + if (!self->m_blenderImage && !self->m_textureKey) { + PyErr_SetString(PyExc_TypeError, "expected a korman.exporter.material._Texture or a bpy.types.Image"); return -1; } - self->m_blenderImage = PyObject_GetAttrString(self->m_textureKey, "image"); + + Py_XINCREF(self->m_blenderImage); + Py_XINCREF(self->m_textureKey); + if (!self->m_blenderImage) { + self->m_blenderImage = PyObject_GetAttrString(self->m_textureKey, "image"); + } if (!self->m_blenderImage) { PyErr_SetString(PyExc_RuntimeError, "Could not fetch Blender Image"); return -1; } - Py_INCREF(self->m_textureKey); - // Done! return 0; } @@ -358,7 +367,9 @@ static int _generate_detail_map(pyGLTexture* self, uint8_t* buf, size_t bufsz, G float alpha; if (_generate_detail_alpha(self, level, &alpha) != 0) return -1; - PyObjectRef pydetail_blend = PyObject_GetAttrString(self->m_textureKey, "detail_blend"); + PyObjectRef pydetail_blend; + if (self->m_textureKey) + pydetail_blend = PyObject_GetAttrString(self->m_textureKey, "detail_blend"); if (!pydetail_blend) return -1; @@ -436,14 +447,16 @@ static PyObject* pyGLTexture_get_level_data(pyGLTexture* self, PyObject* args, P } // Detail blend - PyObjectRef is_detail_map = PyObject_GetAttrString(self->m_textureKey, "is_detail_map"); - if (PyLong_AsLong(is_detail_map) != 0) { - _ensure_copy_bytes(self->m_imageData, data); - uint8_t* buf = reinterpret_cast(PyBytes_AS_STRING(data)); - if (_generate_detail_map(self, buf, bufsz, level) != 0) { - PyErr_SetString(PyExc_RuntimeError, "error while baking detail map"); - Py_DECREF(data); - return NULL; + if (self->m_textureKey) { + PyObjectRef is_detail_map = PyObject_GetAttrString(self->m_textureKey, "is_detail_map"); + if (PyLong_AsLong(is_detail_map) != 0) { + _ensure_copy_bytes(self->m_imageData, data); + uint8_t* buf = reinterpret_cast(PyBytes_AS_STRING(data)); + if (_generate_detail_map(self, buf, bufsz, level) != 0) { + PyErr_SetString(PyExc_RuntimeError, "error while baking detail map"); + Py_DECREF(data); + return NULL; + } } } diff --git a/korman/exporter/material.py b/korman/exporter/material.py index 1f7117f..c211b61 100644 --- a/korman/exporter/material.py +++ b/korman/exporter/material.py @@ -28,8 +28,8 @@ _MAX_STENCILS = 6 # Blender cube map mega image to libHSPlasma plCubicEnvironmap faces mapping... # See https://blender.stackexchange.com/questions/46891/how-to-render-an-environment-to-a-cube-map-in-cycles -_BLENDER_CUBE_MAP = ("leftFace", "backFace", "rightFace", - "bottomFace", "topFace", "frontFace") +BLENDER_CUBE_MAP = ("leftFace", "backFace", "rightFace", + "bottomFace", "topFace", "frontFace") class _Texture: _DETAIL_BLEND = { @@ -810,7 +810,7 @@ class MaterialConverter: if key.is_cube_map: assert len(data) == 6 texture = plCubicEnvironmap(name) - for face_name, face_data in zip(_BLENDER_CUBE_MAP, data): + for face_name, face_data in zip(BLENDER_CUBE_MAP, data): for i in range(numLevels): mipmap.setLevel(i, face_data[i]) setattr(texture, face_name, mipmap) @@ -866,7 +866,7 @@ class MaterialConverter: # According to my profiling, it takes roughly 0.7 seconds to process a # cube map whose faces are 1024x1024 (3072x2048 total). Maybe a later # commit will move this into korlib. We'll see. - face_num = len(_BLENDER_CUBE_MAP) + face_num = len(BLENDER_CUBE_MAP) face_images = [None] * face_num for i in range(face_num): col_id = i if i < 3 else i - 3 @@ -884,7 +884,7 @@ class MaterialConverter: # Now that we have our six faces, we'll toss them into the GLTexture helper # to generate mipmaps, if needed... - for i, face_name in enumerate(_BLENDER_CUBE_MAP): + for i, face_name in enumerate(BLENDER_CUBE_MAP): glimage = GLTexture(key) glimage.image_data = fWidth, fHeight, face_images[i] eWidth, eHeight = glimage.size_pot diff --git a/korman/helpers.py b/korman/helpers.py index 63e3105..9e16819 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -39,7 +39,7 @@ class TemporaryObject: self._remove_func = remove_func def __enter__(self): - return self + return self._obj def __exit__(self, type, value, traceback): self._remove_func(self._obj) diff --git a/korman/korlib/texture.py b/korman/korlib/texture.py index e2d62af..41a6f55 100644 --- a/korman/korlib/texture.py +++ b/korman/korlib/texture.py @@ -96,15 +96,16 @@ def scale_image(buf, srcW, srcH, dstW, dstH): class GLTexture: - def __init__(self, texkey=None, bgra=False, fast=False): + def __init__(self, texkey=None, image=None, bgra=False, fast=False): + assert texkey or image self._texkey = texkey + if texkey is not None: + self._blimg = texkey.image + if image is not None: + self._blimg = image self._image_inverted = fast self._bgra = bgra - @property - def _blimg(self): - return self._texkey.image - def __enter__(self): """Loads the image data using OpenGL""" @@ -186,7 +187,7 @@ class GLTexture: buf = self._invert_image(eWidth, eHeight, buf) # If this is a detail map, then we need to bake that per-level here. - if self._texkey.is_detail_map: + if self._texkey is not None and self._texkey.is_detail_map: detail_blend = self._texkey.detail_blend if detail_blend == TEX_DETAIL_ALPHA: self._make_detail_map_alpha(buf, level) diff --git a/korman/operators/__init__.py b/korman/operators/__init__.py index 006053c..7ff172a 100644 --- a/korman/operators/__init__.py +++ b/korman/operators/__init__.py @@ -14,6 +14,7 @@ # along with Korman. If not, see . from . import op_export as exporter +from . import op_image as image from . import op_lightmap as lightmap from . import op_mesh as mesh from . import op_modifier as modifier diff --git a/korman/operators/op_image.py b/korman/operators/op_image.py new file mode 100644 index 0000000..0dee5bb --- /dev/null +++ b/korman/operators/op_image.py @@ -0,0 +1,237 @@ +# 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 . + +import bpy +from bpy.props import * +from pathlib import Path + +from ..helpers import TemporaryObject, ensure_power_of_two +from ..korlib import ConsoleToggler, GLTexture, scale_image +from ..exporter.explosions import * +from ..exporter.logger import ExportProgressLogger +from ..exporter.material import BLENDER_CUBE_MAP + +# These are some filename suffixes that we will check to match for the cubemap faces +_CUBE_FACES = { + "leftFace": "LF", + "backFace": "BK", + "rightFace": "RT", + "bottomFace": "DN", + "topFace": "UP", + "frontFace": "FR", +} + +class ImageOperator: + @classmethod + def poll(cls, context): + return context.scene.render.engine == "PLASMA_GAME" + + +class PlasmaBuildCubeMapOperator(ImageOperator, bpy.types.Operator): + bl_idname = "image.plasma_build_cube_map" + bl_label = "Build Cubemap" + bl_description = "Builds a Blender cubemap from six images" + + overwrite_existing = BoolProperty(name="Check Existing", + description="Checks for an existing image and overwrites it", + default=True, + options=set()) + filepath = StringProperty(subtype="FILE_PATH") + require_cube = BoolProperty(name="Require Square Faces", + description="Resize cubemap faces to be square if they are not", + default=True, + options=set()) + texture_name = StringProperty(name="Texture", + description="Environment Map Texture to stuff this into", + default="", + options={"HIDDEN"}) + + def __init__(self): + self._report = ExportProgressLogger() + self._report.progress_add_step("Finding Face Images") + self._report.progress_add_step("Loading Face Images") + self._report.progress_add_step("Scaling Face Images") + self._report.progress_add_step("Generating Cube Map") + + def execute(self, context): + with ConsoleToggler(True) as _: + try: + self._execute() + except ExportError as error: + self.report({"ERROR"}, str(error)) + return {"CANCELLED"} + else: + return {"FINISHED"} + + def _execute(self): + self._report.progress_start("BUILDING CUBE MAP") + if not Path(self.filepath).is_file(): + raise ExportError("No cube image found at '{}'".format(self.filepath)) + + # Figure out the paths for the six cube faces. We will use the original file + # only if a face is missing... + face_image_paths = self._find_cube_files(self.filepath) + + # If no images were loaded, that means we will want to generate a cube map + # with the single face provided by the image in filepath. Otherwise, we'll + # use the found faces (and the provided path if any are missing...) + face_data = list(self._load_all_image_data(face_image_paths, self.filepath)) + face_widths, face_heights, face_data = zip(*face_data) + + # All widths and heights must be the same... so, if needed, scale the stupid images. + width, height, face_data = self._scale_images(face_widths, face_heights, face_data) + + # Now generate the stoopid cube map + image_name = Path(self.filepath).name + idx = image_name.rfind('_') + if idx != -1: + suffix = image_name[idx+1:idx+3] + if suffix in _CUBE_FACES.values(): + image_name = image_name[:idx] + image_name[idx+3:] + cubemap_image = self._generate_cube_map(image_name, width, height, face_data) + + # If a texture was provided, we can assign this generated cube map to it... + if self.texture_name: + texture = bpy.data.textures[self.texture_name] + texture.environment_map.source = "IMAGE_FILE" + texture.image = cubemap_image + + self._report.progress_end() + return {"FINISHED"} + + def _find_cube_files(self, filepath): + self._report.progress_advance() + self._report.progress_range = len(BLENDER_CUBE_MAP) + self._report.msg("Searching for cubemap faces...") + + idx = filepath.rfind('_') + if idx != -1: + files = [] + for key in BLENDER_CUBE_MAP: + suffix = _CUBE_FACES[key] + face_path = filepath[:idx+1] + suffix + filepath[idx+3:] + face_name = key[:-4].upper() + if Path(face_path).is_file(): + self._report.msg("Found face '{}': {}", face_name, face_path, indent=1) + files.append(face_path) + else: + self._report.warn("Using default face data for face '{}'", face_name, indent=1) + files.append(None) + self._report.progress_increment() + return tuple(files) + + def _generate_cube_map(self, req_name, face_width, face_height, face_data): + self._report.progress_advance() + self._report.msg("Generating cubemap image...") + + # If a texture was provided, we should check to see if as have an image we can replace... + image = bpy.data.textures[self.texture_name].image if self.texture_name else None + + # Init our image + image_width = face_width * 3 + image_height = face_height * 2 + if image is not None and self.overwrite_existing: + image.source = "GENERATED" + image.generated_width = image_width + image.generated_height = image_height + else: + image = bpy.data.images.new(req_name, image_width, image_height, True) + image_datasz = image_width * image_height * 4 + image_data = bytearray(image_datasz) + face_num = len(BLENDER_CUBE_MAP) + + # This is the inverse of the operation found in MaterialConverter._finalize_cube_map + for i in range(face_num): + col_id = i if i < 3 else i - 3 + row_start = 0 if i < 3 else face_height + row_end = face_height if i < 3 else image_height + + # TIL: Blender's coordinate system has its origin in the lower left, while Plasma's + # is in the upper right. We could do some fancy flipping stuff, but there are already + # mitigations in code for that. So, we disabled the GLTexture's flipping helper and + # will just swap the locations of the images in the list. le wout. + j = i + 3 if i < 3 else i - 3 + for row_current in range(row_start, row_end, 1): + src_start_idx = (row_current - row_start) * face_width * 4 + src_end_idx = src_start_idx + (face_width * 4) + dst_start_idx = (row_current * image_width * 4) + (col_id * face_width * 4) + dst_end_idx = dst_start_idx + (face_width * 4) + image_data[dst_start_idx:dst_end_idx] = face_data[j][src_start_idx:src_end_idx] + + # FFFUUUUU... Blender wants a list of floats + pixels = [None] * image_datasz + for i in range(image_datasz): + pixels[i] = image_data[i] / 255 + + # Obligatory remark: "Blender sucks" + image.pixels = pixels + image.update() + image.pack(True) + return image + + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + def _load_all_image_data(self, face_image_paths, default_image_path): + self._report.progress_advance() + self._report.progress_range = len(BLENDER_CUBE_MAP) + self._report.msg("Loading cubemap faces...") + + default_data = None + for image_path in face_image_paths: + if image_path is None: + if default_data is None: + default_data = self._load_single_image_data(default_image_path) + yield default_data + else: + yield self._load_single_image_data(image_path) + self._report.progress_increment() + + def _load_single_image_data(self, filepath): + images = bpy.data.images + with TemporaryObject(images.load(filepath), images.remove) as blimage: + with GLTexture(image=blimage, fast=True) as glimage: + return glimage.image_data + + def _scale_images(self, face_widths, face_heights, face_data): + self._report.progress_advance() + self._report.progress_range = len(BLENDER_CUBE_MAP) + self._report.msg("Checking cubemap face dimensions...") + + # Take the smallest face width and get its POT variety (so we don't rescale on export) + min_width = ensure_power_of_two(min(face_widths)) + min_height = ensure_power_of_two(min(face_heights)) + + # They're called CUBEmaps, dingus... + if self.require_cube: + dimension = min(min_width, min_height) + min_width, min_height = dimension, dimension + + # Insert grumbling here about tuples being immutable... + result_data = list(face_data) + + for i in range(len(BLENDER_CUBE_MAP)): + face_width, face_height = face_widths[i], face_heights[i] + if face_width != min_width or face_height != min_height: + face_name = BLENDER_CUBE_MAP[i][:-4].upper() + self._report.msg("Resizing face '{}' from {}x{} to {}x{}", face_name, + face_width, face_height, min_width, min_height, + indent=1) + result_data[i] = scale_image(face_data[i], face_width, face_height, + min_width, min_height) + self._report.progress_increment() + return min_width, min_height, tuple(result_data) diff --git a/korman/ui/ui_texture.py b/korman/ui/ui_texture.py index 7ae39ac..3420ca5 100644 --- a/korman/ui/ui_texture.py +++ b/korman/ui/ui_texture.py @@ -37,18 +37,25 @@ class PlasmaEnvMapPanel(TextureButtonsPanel, bpy.types.Panel): return False def draw(self, context): - layer_props = context.texture.plasma_layer + texture = context.texture + layer_props, envmap = texture.plasma_layer, texture.environment_map layout = self.layout - layout.prop(layer_props, "envmap_color") - layout.separator() - - layout.label("Visibility Sets:") - ui_list.draw_list(layout, "VisRegionListUI", "texture", layer_props, - "vis_regions", "active_region_index", rows=2, maxrows=3) - rgns = layer_props.vis_regions - if layer_props.vis_regions: - layout.prop(rgns[layer_props.active_region_index], "control_region") + if envmap.source in {"ANIMATED", "STATIC"}: + layout.prop(layer_props, "envmap_color") + layout.separator() + + layout.label("Visibility Sets:") + ui_list.draw_list(layout, "VisRegionListUI", "texture", layer_props, + "vis_regions", "active_region_index", rows=2, maxrows=3) + rgns = layer_props.vis_regions + if layer_props.vis_regions: + layout.prop(rgns[layer_props.active_region_index], "control_region") + elif envmap.source == "IMAGE_FILE": + op = layout.operator("image.plasma_build_cube_map", + text="Build Cubemap from Cube Faces", + icon="MATCUBE") + op.texture_name = context.texture.name class PlasmaLayerPanel(TextureButtonsPanel, bpy.types.Panel):