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):