diff --git a/korlib/korlib.h b/korlib/korlib.h index b59e4a2..6c42d8f 100644 --- a/korlib/korlib.h +++ b/korlib/korlib.h @@ -23,11 +23,13 @@ #include #define _pycs(x) const_cast(x) +#define arrsize(a) (sizeof(a) / sizeof((a)[0])) class PyObjectRef { PyObject* m_object; public: + PyObjectRef() : m_object() { } PyObjectRef(PyObject* o) : m_object(o) { } ~PyObjectRef() { Py_XDECREF(m_object); } diff --git a/korlib/module.cpp b/korlib/module.cpp index 4f6d257..4d6a6d8 100644 --- a/korlib/module.cpp +++ b/korlib/module.cpp @@ -23,6 +23,7 @@ extern "C" { static PyMethodDef korlib_Methods[] = { { _pycs("create_bump_LUT"), (PyCFunction)create_bump_LUT, METH_VARARGS, NULL }, { _pycs("inspect_vorbisfile"), (PyCFunction)inspect_vorbisfile, METH_VARARGS, NULL }, + { _pycs("scale_image"), (PyCFunction)scale_image, METH_KEYWORDS | METH_VARARGS, NULL }, { NULL, NULL, 0, NULL }, }; diff --git a/korlib/texture.cpp b/korlib/texture.cpp index 049c94e..d7ae9c8 100644 --- a/korlib/texture.cpp +++ b/korlib/texture.cpp @@ -26,22 +26,167 @@ #include #include -#ifndef GL_GENERATE_MIPMAP -# define GL_GENERATE_MIPMAP 0x8191 -#endif // GL_GENERATE_MIPMAP - #define TEXTARGET_TEXTURE_2D 0 +// =============================================================================================== + +static inline void _ensure_copy_bytes(PyObject* parent, PyObject*& data) { + // PyBytes objects are immutable and ought not to be changed once they are returned to Python + // code. Therefore, this tests to see if the given bytes object is the same as one we're holding. + // If so, a new copy is constructed seamlessly. + if (parent == data) { + Py_ssize_t size; + char* buf; + PyBytes_AsStringAndSize(parent, &buf, &size); + data = PyBytes_FromStringAndSize(buf, size); + Py_DECREF(parent); + } +} + +template +static T _ensure_power_of_two(T value) { + return static_cast(std::pow(2, std::floor(std::log2(value)))); +} + +static void _flip_image(size_t width, size_t dataSize, uint8_t* data) { + // OpenGL returns a flipped image, so we must reflip it. + size_t row_stride = width * 4; + uint8_t* sptr = data; + uint8_t* eptr = data + (dataSize - row_stride); + uint8_t* temp = new uint8_t[row_stride]; + do { + memcpy(temp, sptr, row_stride); + memcpy(sptr, eptr, row_stride); + memcpy(eptr, temp, row_stride); + } while ((sptr += row_stride) < (eptr -= row_stride)); + delete[] temp; +} + 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; } -extern "C" { +static inline int _get_num_levels(size_t width, size_t height) { + int num_levels = (int)std::floor(std::log2(std::max((float)width, (float)height))) + 1; + + // Major Workaround Ahoy + // There is a bug in Cyan's level size algorithm that causes it to not allocate enough memory + // for the color block in certain mipmaps. I personally have encountered an access violation on + // 1x1 DXT5 mip levels -- the code only allocates an alpha block and not a color block. Paradox + // reports that if any dimension is smaller than 4px in a mip level, OpenGL doesn't like Cyan generated + // data. So, we're going to lop off the last two mip levels, which should be 1px and 2px as the smallest. + // This bug is basically unfixable without crazy hacks because of the way Plasma reads in texture data. + // " I feel like any texture at a 1x1 level is essentially academic. I mean, JPEG/DXT + // doesn't even compress that, and what is it? Just the average color of the whole + // texture in a single pixel?" + // :) + return std::max(num_levels - 2, 2); +} + +static void _scale_image(const uint8_t* srcBuf, const size_t srcW, const size_t srcH, + uint8_t* dstBuf, const size_t dstW, const size_t dstH) { + float scaleX = static_cast(srcW) / static_cast(dstW); + float scaleY = static_cast(srcH) / static_cast(dstH); + float filterW = std::max(scaleX, 1.f); + float filterH = std::max(scaleY, 1.f); + size_t srcRowspan = srcW * sizeof(uint32_t); + size_t dstIdx = 0; + + for (size_t dstY = 0; dstY < dstH; ++dstY) { + float srcY = dstY * scaleY; + ssize_t srcY_start = std::max(static_cast(srcY - filterH), + static_cast(0)); + ssize_t srcY_end = std::min(static_cast(srcY + filterH), + static_cast(srcH - 1)); + + float weightsY[16]; + for (ssize_t i = srcY_start; i <= srcY_end && i - srcY_start < arrsize(weightsY); ++i) + weightsY[i - srcY_start] = 1.f - std::abs((i - srcY) / filterH); + + for (size_t dstX = 0; dstX < dstW; ++dstX) { + float srcX = dstX * scaleX; + ssize_t srcX_start = std::max(static_cast(srcX - filterW), + static_cast(0)); + ssize_t srcX_end = std::min(static_cast(srcX + filterW), + static_cast(srcW - 1)); + + float weightsX[16]; + for (ssize_t i = srcX_start; i <= srcX_end && i - srcX_start < arrsize(weightsX); ++i) + weightsX[i - srcX_start] = 1.f - std::abs((i - srcX) / filterW); + + float accum_color[] = { 0.f, 0.f, 0.f, 0.f }; + float weight_total = 0.f; + for (size_t i = srcY_start; i <= srcY_end; ++i) { + float weightY; + if (i - srcY_start < arrsize(weightsY)) + weightY = weightsY[i - srcY_start]; + else + weightY = 1.f - std::abs((i - srcY) / filterH); + + if (weightY <= 0.f) + continue; + + size_t srcIdx = ((i * srcRowspan) + (srcX_start * sizeof(uint32_t))); + for (size_t j = srcX_start; j <= srcX_end; ++j, srcIdx += sizeof(uint32_t)) { + float weightX; + if (j - srcX_start < arrsize(weightsX)) + weightX = weightsX[j - srcX_start]; + else + weightX = 1.f - std::abs((j - srcX) / filterW); + float weight = weightX * weightY; + + if (weight > 0.f) { + for (size_t k = 0; k < sizeof(uint32_t); ++k) + accum_color[k] += (static_cast(srcBuf[srcIdx+k]) / 255.f) * weight; + weight_total += weight; + } + } + } + + for (size_t k = 0; k < sizeof(uint32_t); ++k) + accum_color[k] *= 1.f / weight_total; + + // Whew. + for (size_t k = 0; k < sizeof(uint32_t); ++k) + dstBuf[dstIdx+k] = static_cast(accum_color[k] * 255.f); + dstIdx += sizeof(uint32_t); + } + } +} + +// =============================================================================================== + +PyObject* scale_image(PyObject*, PyObject* args, PyObject* kwargs) { + static char* kwlist[] = { _pycs("buf"), _pycs("srcW"), _pycs("srcH"), + _pycs("dstW"), _pycs("dstH"), NULL }; + const uint8_t* srcBuf; + int srcBufSz; + uint32_t srcW, srcH, dstW, dstH; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "y#IIII", kwlist, &srcBuf, &srcBufSz, &srcW, &srcH, &dstW, &dstH)) { + PyErr_SetString(PyExc_TypeError, "scale_image expects a bytes object, int, int, int int"); + return NULL; + } + + int expectedBufSz = srcW * srcH * sizeof(uint32_t); + if (srcBufSz != expectedBufSz) { + PyErr_Format(PyExc_ValueError, "buf size (%i bytes) incorrect (expected: %i bytes)", srcBufSz, expectedBufSz); + return NULL; + } + + PyObject* dst = PyBytes_FromStringAndSize(NULL, dstW * dstH * sizeof(uint32_t)); + uint8_t* dstBuf = reinterpret_cast(PyBytes_AS_STRING(dst)); + _scale_image(srcBuf, srcW, srcH, dstBuf, dstW, dstH); + return dst; +} + +// =============================================================================================== enum { TEX_DETAIL_ALPHA = 0, @@ -53,10 +198,11 @@ typedef struct { PyObject_HEAD PyObject* m_blenderImage; PyObject* m_textureKey; - bool m_ownIt; - GLint m_prevImage; - bool m_changedState; - GLint m_mipmapState; + PyObject* m_imageData; + GLint m_width; + GLint m_height; + bool m_bgra; + bool m_imageInverted; } pyGLTexture; typedef struct { @@ -65,9 +211,12 @@ typedef struct { bool fPyOwned; } pyMipmap; +// =============================================================================================== + static void pyGLTexture_dealloc(pyGLTexture* self) { - Py_XDECREF(self->m_textureKey); - Py_XDECREF(self->m_blenderImage); + Py_CLEAR(self->m_textureKey); + Py_CLEAR(self->m_blenderImage); + Py_CLEAR(self->m_imageData); Py_TYPE(self)->tp_free((PyObject*)self); } @@ -75,26 +224,36 @@ static PyObject* pyGLTexture_new(PyTypeObject* type, PyObject* args, PyObject* k pyGLTexture* self = (pyGLTexture*)type->tp_alloc(type, 0); self->m_blenderImage = NULL; self->m_textureKey = NULL; - self->m_ownIt = false; - self->m_prevImage = 0; - self->m_changedState = false; - self->m_mipmapState = 0; + self->m_imageData = NULL; + self->m_width = 0; + self->m_height = 0; + self->m_bgra = false; + self->m_imageInverted = false; return (PyObject*)self; } static int pyGLTexture___init__(pyGLTexture* self, PyObject* args, PyObject* kwds) { - if (!PyArg_ParseTuple(args, "O", &self->m_textureKey)) { - PyErr_SetString(PyExc_TypeError, "expected a korman.exporter.material._Texture"); + 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 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; } @@ -115,12 +274,13 @@ static PyObject* pyGLTexture__enter__(pyGLTexture* self) { return NULL; } - glGetIntegerv(GL_TEXTURE_BINDING_2D, &self->m_prevImage); + GLint prevImage; + glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevImage); GLuint image_bindcode = PyLong_AsUnsignedLong(bindcode); - self->m_ownIt = image_bindcode == 0; + bool ownit = image_bindcode == 0; // Load image into GL - if (self->m_ownIt) { + if (ownit) { PyObjectRef new_bind = PyObject_CallMethod(self->m_blenderImage, "gl_load", NULL); if (!PyLong_Check(new_bind)) { PyErr_SetString(PyExc_TypeError, "gl_load() did not return a long"); @@ -144,64 +304,39 @@ static PyObject* pyGLTexture__enter__(pyGLTexture* self) { } // Set image as current in GL - if (self->m_prevImage != image_bindcode) { - self->m_changedState = true; + bool changedState = prevImage != image_bindcode; + if (changedState) glBindTexture(GL_TEXTURE_2D, image_bindcode); - } - // Misc GL state - glGetTexParameteriv(GL_TEXTURE_2D, GL_GENERATE_MIPMAP, &self->m_mipmapState); + // Now we can load the image data... + glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &self->m_width); + glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &self->m_height); + + size_t bufsz = self->m_width * self->m_height * sizeof(uint32_t); + self->m_imageData = PyBytes_FromStringAndSize(NULL, bufsz); + char* imbuf = PyBytes_AS_STRING(self->m_imageData); + GLint fmt = self->m_bgra ? GL_BGRA_EXT : GL_RGBA; + glGetTexImage(GL_TEXTURE_2D, 0, fmt, GL_UNSIGNED_BYTE, reinterpret_cast(imbuf)); + + // OpenGL returns image data flipped upside down. We'll flip it to be correct, if requested. + if (!self->m_imageInverted) + _flip_image(self->m_width, bufsz, reinterpret_cast(imbuf)); + + // If we had to play with ourse^H^H^H^H^Hblender's image state, let's reset it + if (changedState) + glBindTexture(GL_TEXTURE_2D, prevImage); + if (ownit) + PyObjectRef result = PyObject_CallMethod(self->m_blenderImage, "gl_free", NULL); Py_INCREF(self); return (PyObject*)self; } static PyObject* pyGLTexture__exit__(pyGLTexture* self, PyObject*) { - // We don't care about the args here - glTexParameteri(GL_TEXTURE_2D, GL_GENERATE_MIPMAP, self->m_mipmapState); - if (self->m_changedState) - glBindTexture(GL_TEXTURE_2D, self->m_prevImage); - Py_RETURN_NONE; -} - -static PyObject* pyGLTexture_generate_mipmap(pyGLTexture* self) { - glTexParameteri(GL_TEXTURE_2D, GL_GENERATE_MIPMAP, 1); + Py_CLEAR(self->m_imageData); Py_RETURN_NONE; } -struct _LevelData -{ - GLint m_width; - GLint m_height; - uint8_t* m_data; - size_t m_dataSize; - - _LevelData(GLint w, GLint h, uint8_t* ptr, size_t sz) - : m_width(w), m_height(h), m_data(ptr), m_dataSize(sz) - { } -}; - -static inline int _get_num_levels(pyGLTexture* self) { - PyObjectRef size = PyObject_GetAttrString(self->m_blenderImage, "size"); - float width = (float)PyFloat_AsDouble(PySequence_GetItem(size, 0)); - float height = (float)PyFloat_AsDouble(PySequence_GetItem(size, 1)); - - int num_levels = (int)std::floor(std::log2(std::max(width, height))) + 1; - - // Major Workaround Ahoy - // There is a bug in Cyan's level size algorithm that causes it to not allocate enough memory - // for the color block in certain mipmaps. I personally have encountered an access violation on - // 1x1 DXT5 mip levels -- the code only allocates an alpha block and not a color block. Paradox - // reports that if any dimension is smaller than 4px in a mip level, OpenGL doesn't like Cyan generated - // data. So, we're going to lop off the last two mip levels, which should be 1px and 2px as the smallest. - // This bug is basically unfixable without crazy hacks because of the way Plasma reads in texture data. - // " I feel like any texture at a 1x1 level is essentially academic. I mean, JPEG/DXT - // doesn't even compress that, and what is it? Just the average color of the whole - // texture in a single pixel?" - // :) - return std::max(num_levels - 2, 2); -} - static int _generate_detail_alpha(pyGLTexture* self, GLint level, float* result) { float dropoff_start, dropoff_stop, detail_max, detail_min; if (!_get_float(self->m_textureKey, "detail_fade_start", dropoff_start)) @@ -214,9 +349,9 @@ static int _generate_detail_alpha(pyGLTexture* self, GLint level, float* result) return -1; dropoff_start /= 100.f; - dropoff_start *= _get_num_levels(self); + dropoff_start *= _get_num_levels(self->m_width, self->m_height); dropoff_stop /= 100.f; - dropoff_stop *= _get_num_levels(self); + dropoff_stop *= _get_num_levels(self->m_width, self->m_height); detail_max /= 100.f; detail_min /= 100.f; @@ -232,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; @@ -265,102 +402,133 @@ static int _generate_detail_map(pyGLTexture* self, uint8_t* buf, size_t bufsz, G return 0; } -static _LevelData _get_level_data(pyGLTexture* self, GLint level, bool bgra, PyObject* report) { - GLint width, height; - glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_WIDTH, &width); - glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_HEIGHT, &height); - GLenum fmt = bgra ? GL_BGRA_EXT : GL_RGBA; - - // Print out the debug message - if (report && report != Py_None) { - PyObjectRef msg_func = PyObject_GetAttrString(report, "msg"); - PyObjectRef args = Py_BuildValue("siii", "Level #{}: {}x{}", level, width, height); - PyObjectRef kwargs = Py_BuildValue("{s:i}", "indent", 2); - PyObjectRef result = PyObject_Call(msg_func, args, kwargs); - } - - size_t bufsz; - bufsz = (width * height * 4); - uint8_t* buf = new uint8_t[bufsz]; - glGetTexImage(GL_TEXTURE_2D, level, fmt, GL_UNSIGNED_BYTE, reinterpret_cast(buf)); - return _LevelData(width, height, buf, bufsz); -} - static PyObject* pyGLTexture_get_level_data(pyGLTexture* self, PyObject* args, PyObject* kwargs) { - static char* kwlist[] = { _pycs("level"), _pycs("calc_alpha"), _pycs("bgra"), - _pycs("report"), _pycs("fast"), NULL }; + static char* kwlist[] = { _pycs("level"), _pycs("calc_alpha"), _pycs("report"), + _pycs("indent"), _pycs("fast"), NULL }; GLint level = 0; bool calc_alpha = false; - bool bgra = false; PyObject* report = nullptr; + int indent = 2; bool fast = false; - if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ibbOb", kwlist, &level, &calc_alpha, &bgra, &report, &fast)) { - PyErr_SetString(PyExc_TypeError, "get_level_data expects an optional int, bool, bool, obejct, bool"); + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ibOib", kwlist, &level, &calc_alpha, &report, &indent, &fast)) { + PyErr_SetString(PyExc_TypeError, "get_level_data expects an optional int, bool, obejct, int, bool"); return NULL; } - _LevelData data = _get_level_data(self, level, bgra, report); - if (fast) - return PyBytes_FromStringAndSize((char*)data.m_data, data.m_dataSize); + // We only ever want to return POT images for use in Plasma + auto eWidth = _ensure_power_of_two(self->m_width) >> level; + auto eHeight = _ensure_power_of_two(self->m_height) >> level; + bool is_og = eWidth == self->m_width && eHeight == self->m_height; + size_t bufsz = eWidth * eHeight * sizeof(uint32_t); - // OpenGL returns a flipped image, so we must reflip it. - size_t row_stride = data.m_width * 4; - uint8_t* sptr = data.m_data; - uint8_t* eptr = data.m_data + (data.m_dataSize - row_stride); - uint8_t* temp = new uint8_t[row_stride]; - do { - memcpy(temp, sptr, row_stride); - memcpy(sptr, eptr, row_stride); - memcpy(eptr, temp, row_stride); - } while ((sptr += row_stride) < (eptr -= row_stride)); - delete[] temp; + // Print out the debug message + if (report && report != Py_None) { + PyObjectRef msg_func = PyObject_GetAttrString(report, "msg"); + PyObjectRef args = Py_BuildValue("siii", "Level #{}: {}x{}", level, eWidth, eHeight); + PyObjectRef kwargs = Py_BuildValue("{s:i}", "indent", indent); + PyObjectRef result = PyObject_Call(msg_func, args, kwargs); + } + + PyObject* data; + if (is_og) { + Py_INCREF(self->m_imageData); + data = self->m_imageData; + } else { + data = PyBytes_FromStringAndSize(NULL, bufsz); + uint8_t* dstBuf = reinterpret_cast(PyBytes_AsString(data)); // AS_STRING :( + uint8_t* srcBuf = reinterpret_cast(PyBytes_AsString(self->m_imageData)); + _scale_image(srcBuf, self->m_width, self->m_height, dstBuf, eWidth, eHeight); + } + + // Make sure the level data is not flipped upside down... + if (self->m_imageInverted && !fast) { + _ensure_copy_bytes(self->m_blenderImage, data); + _flip_image(eWidth, bufsz, reinterpret_cast(PyBytes_AS_STRING(data))); + } // Detail blend - PyObjectRef is_detail_map = PyObject_GetAttrString(self->m_textureKey, "is_detail_map"); - if (PyLong_AsLong(is_detail_map) != 0) { - if (_generate_detail_map(self, data.m_data, data.m_dataSize, level) != 0) { - delete[] data.m_data; - PyErr_SetString(PyExc_RuntimeError, "error while baking detail map"); - 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; + } } } if (calc_alpha) { - for (size_t i = 0; i < data.m_dataSize; i += 4) - data.m_data[i + 3] = (data.m_data[i + 0] + data.m_data[i + 1] + data.m_data[i + 2]) / 3; + _ensure_copy_bytes(self->m_imageData, data); + char* buf = PyBytes_AS_STRING(data); + for (size_t i = 0; i < bufsz; i += 4) + buf[i + 3] = (buf[i + 0] + buf[i + 1] + buf[i + 2]) / 3; } - return PyBytes_FromStringAndSize((char*)data.m_data, data.m_dataSize); + return data; } static PyMethodDef pyGLTexture_Methods[] = { { _pycs("__enter__"), (PyCFunction)pyGLTexture__enter__, METH_NOARGS, NULL }, { _pycs("__exit__"), (PyCFunction)pyGLTexture__exit__, METH_VARARGS, NULL }, - { _pycs("generate_mipmap"), (PyCFunction)pyGLTexture_generate_mipmap, METH_NOARGS, NULL }, { _pycs("get_level_data"), (PyCFunction)pyGLTexture_get_level_data, METH_KEYWORDS | METH_VARARGS, NULL }, { NULL, NULL, 0, NULL } }; static PyObject* pyGLTexture_get_has_alpha(pyGLTexture* self, void*) { - _LevelData data = _get_level_data(self, 0, false, nullptr); - for (size_t i = 3; i < data.m_dataSize; i += 4) { - if (data.m_data[i] != 255) { - delete[] data.m_data; + char* data = PyBytes_AsString(self->m_imageData); + size_t bufsz = self->m_width * self->m_height * sizeof(uint32_t); + for (size_t i = 3; i < bufsz; i += 4) { + if (data[i] != 255) { return PyBool_FromLong(1); } } - delete[] data.m_data; return PyBool_FromLong(0); } +static PyObject* pyGLTexture_get_image_data(pyGLTexture* self, void*) { + Py_XINCREF(self->m_imageData); + return Py_BuildValue("iiO", self->m_width, self->m_height, self->m_imageData); +} + +static int pyGLTexture_set_image_data(pyGLTexture* self, PyObject* value, void*) { + PyObject* data; + // Requesting a Bytes object "S" instead of a buffer "y#" so we can just increment the reference + // count on a buffer that already exists, instead of doing a memcpy. + if (!PyArg_ParseTuple(value, "iiS", &self->m_width, &self->m_height, &data)) { + PyErr_SetString(PyExc_TypeError, "image_data should be a sequence of int, int, bytes"); + return -1; + } + + Py_XDECREF(self->m_imageData); + Py_XINCREF(data); + self->m_imageData = data; + return 0; +} + static PyObject* pyGLTexture_get_num_levels(pyGLTexture* self, void*) { - return PyLong_FromLong(_get_num_levels(self)); + return PyLong_FromLong(_get_num_levels(self->m_width, self->m_height)); +} + +static PyObject* pyGLTexture_get_size_npot(pyGLTexture* self, void*) { + return Py_BuildValue("ii", self->m_width, self->m_height); +} + +static PyObject* pyGLTexture_get_size_pot(pyGLTexture* self, void*) { + size_t width = _ensure_power_of_two(self->m_width); + size_t height = _ensure_power_of_two(self->m_height); + return Py_BuildValue("ii", width, height); } static PyGetSetDef pyGLTexture_GetSet[] = { { _pycs("has_alpha"), (getter)pyGLTexture_get_has_alpha, NULL, NULL, NULL }, + { _pycs("image_data"), (getter)pyGLTexture_get_image_data, (setter)pyGLTexture_set_image_data, NULL, NULL }, { _pycs("num_levels"), (getter)pyGLTexture_get_num_levels, NULL, NULL, NULL }, + { _pycs("size_npot"), (getter)pyGLTexture_get_size_npot, NULL, NULL, NULL }, + { _pycs("size_pot"), (getter)pyGLTexture_get_size_pot, NULL, NULL, NULL }, { NULL, NULL, NULL, NULL, NULL } }; @@ -429,6 +597,3 @@ PyObject* Init_pyGLTexture_Type() { Py_INCREF(&pyGLTexture_Type); return (PyObject*)&pyGLTexture_Type; } - -}; - diff --git a/korlib/texture.h b/korlib/texture.h index 0ff42ad..99dff81 100644 --- a/korlib/texture.h +++ b/korlib/texture.h @@ -21,6 +21,8 @@ extern "C" { +PyObject* scale_image(PyObject*, PyObject*, PyObject*); + extern PyTypeObject pyGLTexture_Type; PyObject* Init_pyGLTexture_Type(); diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 05e05dd..7dc9ae8 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -350,6 +350,10 @@ class Exporter: self.mgr.save_age(Path(self._op.filepath)) self.image.save() + @property + def envmap_method(self): + return bpy.context.scene.world.plasma_age.envmap_method + @property def texcache_path(self): age = bpy.context.scene.world.plasma_age diff --git a/korman/exporter/image.py b/korman/exporter/image.py index dc396f7..926ba88 100644 --- a/korman/exporter/image.py +++ b/korman/exporter/image.py @@ -46,6 +46,8 @@ class _EntryBits(enum.IntEnum): source_size = 4 export_size = 5 last_export = 6 + image_count = 7 + tag_string = 8 class _CachedImage: @@ -59,6 +61,8 @@ class _CachedImage: self.compression = None self.export_time = None self.modify_time = None + self.image_count = 1 + self.tag = None def __str__(self): return self.name @@ -71,10 +75,10 @@ class ImageCache: self._read_stream = hsFileStream() self._stream_handles = 0 - def add_texture(self, texture, num_levels, export_size, compression, data): - image = texture.image + def add_texture(self, texture, num_levels, export_size, compression, images): + image, tag = texture.image, texture.tag image_name = str(texture) - key = (image_name, compression) + key = (image_name, tag, compression) ex_method, im_method = self._exporter().texcache_method, image.plasma_image.texcache_method method = set((ex_method, im_method)) if texture.ephemeral or "skip" in method: @@ -89,7 +93,9 @@ class ImageCache: image.compression = compression image.source_size = texture.image.size image.export_size = export_size - image.image_data = data + image.image_data = images + image.image_count = len(images) + image.tag = tag self._images[key] = image def _compact(self): @@ -111,7 +117,7 @@ class ImageCache: self._read_stream.close() def get_from_texture(self, texture, compression): - bl_image = texture.image + bl_image, tag = texture.image, texture.tag # If the texture is ephemeral (eg a lightmap) or has been marked "rebuild" or "skip" # in the UI, we don't want anything from the cache. In the first two cases, we never @@ -121,7 +127,7 @@ class ImageCache: if method != {"use"} or texture.ephemeral: return None - key = (str(texture), compression) + key = (str(texture), tag, compression) cached_image = self._images.get(key) if cached_image is None: return None @@ -199,17 +205,25 @@ class ImageCache: # between iterations, so we'd best bookkeep the position pos = stream.pos - for i in range(image.mip_levels): + def _read_image_mips(): + for _ in range(image.mip_levels): + nonlocal pos + if stream.pos != pos: + stream.seek(pos) + assert stream.read(4) == _MIP_MAGICK + + # this should only ever be image data... + # store your flags somewhere else! + size = stream.readInt() + data = stream.read(size) + pos = stream.pos + yield data + + for _ in range(image.image_count): if stream.pos != pos: stream.seek(pos) - assert stream.read(4) == _MIP_MAGICK - - # this should only ever be image data... - # store your flags somewhere else! - size = stream.readInt() - data = stream.read(size) - pos = stream.pos - yield data + yield tuple(_read_image_mips()) + def _read_index(self, index_pos, stream): stream.seek(index_pos) @@ -250,9 +264,14 @@ class ImageCache: image.export_size = (stream.readInt(), stream.readInt()) if flags[_EntryBits.last_export]: image.export_time = stream.readDouble() + if flags[_EntryBits.image_count]: + image.image_count = stream.readInt() + if flags[_EntryBits.tag_string]: + # tags should not contain user data, so we will use a latin_1 backed string + image.tag = stream.readSafeStr() # do we need to check for duplicate images? - self._images[(image.name, image.compression)] = image + self._images[(image.name, image.tag, image.compression)] = image @property def _report(self): @@ -300,9 +319,10 @@ class ImageCache: flags.write(stream) for i in image.image_data: - stream.write(_MIP_MAGICK) - stream.writeInt(len(i)) - stream.write(i) + for j in i: + stream.write(_MIP_MAGICK) + stream.writeInt(len(j)) + stream.write(j) def _write_index(self, stream): flags = hsBitVector() @@ -327,6 +347,8 @@ class ImageCache: flags[_EntryBits.source_size] = True flags[_EntryBits.export_size] = True flags[_EntryBits.last_export] = True + flags[_EntryBits.image_count] = True + flags[_EntryBits.tag_string] = image.tag is not None stream.write(_ENTRY_MAGICK) flags.write(stream) @@ -339,3 +361,6 @@ class ImageCache: stream.writeInt(image.export_size[0]) stream.writeInt(image.export_size[1]) stream.writeDouble(time.time()) + stream.writeInt(image.image_count) + if image.tag is not None: + stream.writeSafeStr(image.tag) diff --git a/korman/exporter/material.py b/korman/exporter/material.py index e85ec51..c211b61 100644 --- a/korman/exporter/material.py +++ b/korman/exporter/material.py @@ -26,6 +26,11 @@ from . import utils _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") + class _Texture: _DETAIL_BLEND = { TEX_DETAIL_ALPHA: "AL", @@ -40,7 +45,7 @@ class _Texture: if texture is not None: if image is None: image = texture.image - self.calc_alpha = texture.use_calculate_alpha + self.calc_alpha = getattr(texture, "use_calculate_alpha", False) self.mipmap = texture.use_mipmap else: self.layer = kwargs.get("layer") @@ -58,6 +63,7 @@ class _Texture: self.calc_alpha = False self.use_alpha = True self.allowed_formats = {"DDS"} + self.is_cube_map = False else: self.is_detail_map = False use_alpha = kwargs.get("use_alpha") @@ -70,6 +76,7 @@ class _Texture: self.use_alpha = use_alpha self.allowed_formats = kwargs.get("allowed_formats", {"DDS"} if self.mipmap else {"PNG", "JPG"}) + self.is_cube_map = kwargs.get("is_cube_map", False) # Basic format sanity if self.mipmap: @@ -84,13 +91,14 @@ class _Texture: self.extension = kwargs.get("extension", self.auto_ext) self.ephemeral = kwargs.get("ephemeral", False) self.image = image + self.tag = kwargs.get("tag", None) def __eq__(self, other): if not isinstance(other, _Texture): return False # Yeah, the string name is a unique identifier. So shoot me. - if str(self) == str(other): + if str(self) == str(other) and self.tag == other.tag: self._update(other) return True return False @@ -139,13 +147,41 @@ class MaterialConverter: "transformCtl": self._export_layer_transform_animation, } + def _can_export_texslot(self, slot): + if slot is None or not slot.use: + return False + texture = slot.texture + if texture is None or texture.type not in self._tex_exporters: + return False + + # Per-texture type rules + if texture.type == "ENVIRONMENT_MAP": + envmap = texture.environment_map + # If this is a static, image based cube map, then we will allow it + # to be exported anyway. Note that as of the writing of this code, + # that is kind of pointless because CEMs are not yet implemented... + if envmap.source == "IMAGE_FILE": + return True + + # Now for the ruelz + method, ver = self._exporter().envmap_method, self._mgr.getVer() + if method == "skip": + return False + elif method == "dcm2dem": + return True + elif method == "perengine": + return (ver >= pvMoul and envmap.mapping == "PLANE") or envmap.mapping == "CUBE" + else: + raise NotImplementedError(method) + else: + return True + def export_material(self, bo, bm): """Exports a Blender Material as an hsGMaterial""" self._report.msg("Exporting Material '{}'", bm.name, indent=1) hsgmat = self._mgr.add_object(hsGMaterial, name=bm.name, bl=bo) - slots = [(idx, slot) for idx, slot in enumerate(bm.texture_slots) if slot is not None and slot.use \ - and slot.texture is not None and slot.texture.type in self._tex_exporters] + slots = [(idx, slot) for idx, slot in enumerate(bm.texture_slots) if self._can_export_texslot(slot)] # There is a major difference in how Blender and Plasma handle stencils. # In Blender, the stencil is on top and applies to every layer below is. In Plasma, the stencil @@ -453,18 +489,44 @@ class MaterialConverter: texture = slot.texture bl_env = texture.environment_map if bl_env.source in {"STATIC", "ANIMATED"}: + # NOTE: It is assumed that if we arrive here, we are at lease dcm2dem on the + # environment map export method. You're welcome! if bl_env.mapping == "PLANE" and self._mgr.getVer() >= pvMoul: pl_env = plDynamicCamMap else: pl_env = plDynamicEnvMap pl_env = self.export_dynamic_env(bo, layer, texture, pl_env) + elif bl_env.source == "IMAGE_FILE": + pl_env = self.export_cubic_env(bo, layer, texture) else: - # We should really export a CubicEnvMap here, but we have a good setup for DynamicEnvMaps - # that create themselves when the explorer links in, so really... who cares about CEMs? - self._exporter().report.warn("IMAGE EnvironmentMaps are not supported. '{}' will not be exported!".format(layer.key.name)) - pl_env = None + raise NotImplementedError(bl_env.source) layer.state.shadeFlags |= hsGMatState.kShadeEnvironMap - layer.texture = pl_env.key + if pl_env is not None: + layer.texture = pl_env.key + + def export_cubic_env(self, bo, layer, texture): + width, height = texture.image.size + + # Sanity check: the image here should be 3x2 faces, so we should not have any + # dam remainder... + if width % 3 != 0: + raise ExportError("CubeMap '{}' width must be a multiple of 3".format(image.name)) + if height % 2 != 0: + raise ExportError("CubeMap '{}' height must be a multiple of 2".format(image.name)) + + # According to PlasmaMAX, we don't give a rip about UVs... + layer.UVWSrc = plLayerInterface.kUVWReflect + layer.state.miscFlags |= hsGMatState.kMiscUseReflectionXform + + # Well, this is kind of sad... + # Back before the texture cache existed, all the image work was offloaded + # to a big "finalize" save step to prevent races. The texture cache would + # prevent that as well, so we could theoretically slice-and-dice the single + # image here... but... meh. Offloading taim. + self.export_prepared_image(texture=texture, owner=layer, indent=3, + use_alpha=False, mipmap=True, allowed_formats={"DDS"}, + is_cube_map=True, tag="cubemap") + def export_dynamic_env(self, bo, layer, texture, pl_class): # To protect the user from themselves, let's check to make sure that a DEM/DCM matching this @@ -662,6 +724,10 @@ class MaterialConverter: - indent: (optional) indentation level for log messages default: 2 - ephemeral: (optional) never cache this image + - tag: (optional) an optional identifier hint that allows multiple images with the + same name to coexist in the cache + - is_cube_map: (optional) indicates the provided image contains six cube faces + that must be split into six separate images for Plasma """ owner = kwargs.pop("owner", None) indent = kwargs.pop("indent", 2) @@ -690,12 +756,10 @@ class MaterialConverter: for key, owners in self._pending.items(): name = str(key) - self._report.msg("\n[Mipmap '{}']", name) + pClassName = "CubicEnvironmap" if key.is_cube_map else "Mipmap" + self._report.msg("\n[{} '{}']", pClassName, name) image = key.image - oWidth, oHeight = image.size - if oWidth == 0 and oHeight == 0: - raise ExportError("Image '{}' could not be loaded.".format(image.name)) # Now we try to use the pile of hints we were given to figure out what format to use allowed_formats = key.allowed_formats @@ -717,47 +781,13 @@ class MaterialConverter: cached_image = texcache.get_from_texture(key, compression) if cached_image is None: - eWidth = helpers.ensure_power_of_two(oWidth) - eHeight = helpers.ensure_power_of_two(oHeight) - if (eWidth != oWidth) or (eHeight != oHeight): - self._report.msg("Image is not a POT ({}x{}) resizing to {}x{}", - oWidth, oHeight, eWidth, eHeight, indent=1) - self._resize_image(image, eWidth, eHeight) - - # Grab the image data from OpenGL and stuff it into the plBitmap - helper = GLTexture(key) - with helper as glimage: - if compression == plBitmap.kDirectXCompression: - numLevels = glimage.num_levels - self._report.msg("Generating mip levels", indent=1) - glimage.generate_mipmap() - else: - numLevels = 1 - self._report.msg("Compressing single level", indent=1) - - # Non-DXT images are BGRA in Plasma - fmt = compression != plBitmap.kDirectXCompression - - # Hold the uncompressed level data for now. We may have to make multiple copies of - # this mipmap for per-page textures :( - data = [] - for i in range(numLevels): - data.append(glimage.get_level_data(i, key.calc_alpha, fmt, report=self._report)) - - # Be a good citizen and reset the Blender Image to pre-futzing state - image.reload() - - # If this is a DXT-compressed mipmap, we need to use a temporary mipmap - # to do the compression. We'll then steal the data from it. - if compression == plBitmap.kDirectXCompression: - mipmap = plMipmap(name=name, width=eWidth, height=eHeight, numLevels=numLevels, - compType=compression, format=plBitmap.kRGB8888, dxtLevel=dxt) - for i in range(numLevels): - mipmap.CompressImage(i, data[i]) - data[i] = mipmap.getLevel(i) - texcache.add_texture(key, numLevels, (eWidth, eHeight), compression, data) + if key.is_cube_map: + numLevels, width, height, data = self._finalize_cube_map(key, image, name, compression, dxt) + else: + numLevels, width, height, data = self._finalize_single_image(key, image, name, compression, dxt) + texcache.add_texture(key, numLevels, (width, height), compression, data) else: - eWidth, eHeight = cached_image.export_size + width, height = cached_image.export_size data = cached_image.image_data numLevels = cached_image.mip_levels @@ -771,28 +801,148 @@ class MaterialConverter: self._report.msg("[{} '{}']", owner.ClassName()[2:], owner_key.name, indent=2) page = mgr.get_textures_page(owner_key) # Layer's page or Textures.prp - # If we haven't created this plMipmap in the page (either layer's page or Textures.prp), + # If we haven't created this texture in the page (either layer's page or Textures.prp), # then we need to do that and stuff the level data. This is a little tedious, but we # need to be careful to manage our resources correctly if page not in pages: - mipmap = plMipmap(name=name, width=eWidth, height=eHeight, numLevels=numLevels, + mipmap = plMipmap(name=name, width=width, height=height, numLevels=numLevels, compType=compression, format=plBitmap.kRGB8888, dxtLevel=dxt) - for i, buf in enumerate(data): - mipmap.setLevel(i, buf) - mgr.AddObject(page, mipmap) - pages[page] = mipmap + if key.is_cube_map: + assert len(data) == 6 + texture = plCubicEnvironmap(name) + 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) + else: + assert len(data) == 1 + for i in range(numLevels): + mipmap.setLevel(i, data[0][i]) + texture = mipmap + + mgr.AddObject(page, texture) + pages[page] = texture else: - mipmap = pages[page] + texture = pages[page] + # The object that references this image can be either a layer (will appear + # in the 3d world) or an image library (will appear in a journal or in another + # dynamic manner in game) if isinstance(owner, plLayerInterface): - owner.texture = mipmap.key + owner.texture = texture.key elif isinstance(owner, plImageLibMod): - owner.addImage(mipmap.key) + owner.addImage(texture.key) else: raise RuntimeError(owner.ClassName()) inc_progress() + def _finalize_cube_map(self, key, image, name, compression, dxt): + oWidth, oHeight = image.size + if oWidth == 0 and oHeight == 0: + raise ExportError("Image '{}' could not be loaded.".format(image.name)) + + # Non-DXT images are BGRA in Plasma + bgra = compression != plBitmap.kDirectXCompression + + # Grab the cube map data from OpenGL and prepare to begin... + with GLTexture(key, bgra=bgra) as glimage: + cWidth, cHeight, data = glimage.image_data + + # On some platforms, Blender will be "helpful" and scale the image to a POT. + # That's great, but we have 3 faces as a width, which will certainly be NPOT + # in the case of POT faces. So, we will scale the image AGAIN, if Blender did + # something funky. + if oWidth != cWidth or oHeight != cHeight: + self._report.warn("Image was resized by Blender to ({}x{})--resizing the resize to ({}x{})", + cWidth, cHeight, oWidth, oHeight, indent=1) + data = scale_image(data, cWidth, cHeight, oWidth, oHeight) + + # Face dimensions + fWidth, fHeight = oWidth // 3, oHeight // 2 + + # Copy each of the six faces into a separate image buffer. + # NOTE: At present, I am well pleased with the speed of this functionality. + # 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_images = [None] * face_num + for i in range(face_num): + col_id = i if i < 3 else i - 3 + row_start = 0 if i < 3 else fHeight + row_end = fHeight if i < 3 else oHeight + + face_data = bytearray(fWidth * fHeight * 4) + for row_current in range(row_start, row_end, 1): + src_start_idx = (row_current * oWidth * 4) + (col_id * fWidth * 4) + src_end_idx = src_start_idx + (fWidth * 4) + dst_start_idx = (row_current - row_start) * fWidth * 4 + dst_end_idx = dst_start_idx + (fWidth * 4) + face_data[dst_start_idx:dst_end_idx] = data[src_start_idx:src_end_idx] + face_images[i] = bytes(face_data) + + # 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): + glimage = GLTexture(key) + glimage.image_data = fWidth, fHeight, face_images[i] + eWidth, eHeight = glimage.size_pot + name = face_name[:-4].upper() + if compression == plBitmap.kDirectXCompression: + numLevels = glimage.num_levels + self._report.msg("Generating mip levels for cube face '{}'", name, indent=1) + + # If we're compressing this mofo, we'll need a temporary mipmap to do that here... + mipmap = plMipmap(name=name, width=eWidth, height=eHeight, numLevels=numLevels, + compType=compression, format=plBitmap.kRGB8888, dxtLevel=dxt) + else: + numLevels = 1 + self._report.msg("Compressing single level for cube face '{}'", name, indent=1) + + face_images[i] = [None] * numLevels + for j in range(numLevels): + level_data = glimage.get_level_data(j, key.calc_alpha, report=self._report) + if compression == plBitmap.kDirectXCompression: + mipmap.CompressImage(j, level_data) + level_data = mipmap.getLevel(j) + face_images[i][j] = level_data + return numLevels, eWidth, eHeight, face_images + + def _finalize_single_image(self, key, image, name, compression, dxt): + oWidth, oHeight = image.size + if oWidth == 0 and oHeight == 0: + raise ExportError("Image '{}' could not be loaded.".format(image.name)) + + # Non-DXT images are BGRA in Plasma + bgra = compression != plBitmap.kDirectXCompression + + # Grab the image data from OpenGL and stuff it into the plBitmap + with GLTexture(key, bgra=bgra) as glimage: + eWidth, eHeight = glimage.size_pot + if compression == plBitmap.kDirectXCompression: + numLevels = glimage.num_levels + self._report.msg("Generating mip levels", indent=1) + + # If this is a DXT-compressed mipmap, we need to use a temporary mipmap + # to do the compression. We'll then steal the data from it. + mipmap = plMipmap(name=name, width=eWidth, height=eHeight, numLevels=numLevels, + compType=compression, format=plBitmap.kRGB8888, dxtLevel=dxt) + else: + numLevels = 1 + self._report.msg("Compressing single level", indent=1) + + # Hold the uncompressed level data for now. We may have to make multiple copies of + # this mipmap for per-page textures :( + data = [None] * numLevels + for i in range(numLevels): + level_data = glimage.get_level_data(i, key.calc_alpha, report=self._report) + if compression == plBitmap.kDirectXCompression: + mipmap.CompressImage(i, level_data) + level_data = mipmap.getLevel(i) + data[i] = level_data + return numLevels, eWidth, eHeight, [data,] + def get_materials(self, bo): return self._obj2mat.get(bo, []) @@ -849,15 +999,6 @@ class MaterialConverter: def _report(self): return self._exporter().report - def _resize_image(self, image, width, height): - image.scale(width, height) - image.update() - - # If the image is already loaded into OpenGL, we need to refresh it to get the scaling. - if image.bindcode[0] != 0: - image.gl_free() - image.gl_load() - def _test_image_alpha(self, image): """Tests to see if this image has any alpha data""" @@ -873,7 +1014,7 @@ class MaterialConverter: else: # Using bpy.types.Image.pixels is VERY VERY VERY slow... key = _Texture(image=image) - with GLTexture(key) as glimage: + with GLTexture(key, fast=True) as glimage: result = glimage.has_alpha self._alphatest[image] = result 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 10d9388..4fa0881 100644 --- a/korman/korlib/texture.py +++ b/korman/korlib/texture.py @@ -13,12 +13,13 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +import array import bgl +from ..helpers import ensure_power_of_two import math from PyHSPlasma import plBitmap # BGL doesn't know about this as of Blender 2.74 -bgl.GL_GENERATE_MIPMAP = 0x8191 bgl.GL_BGRA = 0x80E1 # Some texture generation flags @@ -26,35 +27,122 @@ TEX_DETAIL_ALPHA = 0 TEX_DETAIL_ADD = 1 TEX_DETAIL_MULTIPLY = 2 +def scale_image(buf, srcW, srcH, dstW, dstH): + """Scales an RGBA image using the algorithm from CWE's plMipmap::ScaleNicely""" + dst, dst_idx = bytearray(dstW * dstH * 4), 0 + scaleX, scaleY = (srcW / dstW), (srcH / dstH) + filterW, filterH = max(scaleX, 1.0), max(scaleY, 1.0) + + src_rowspan = srcW * 4 + weightsY = array.array("f", [0.0] * 16) + weightsX = array.array("f", [0.0] * 16) + + # I hope you're in no particular hurry... + for dstY in range(dstH): + srcY = dstY * scaleY + srcY_start = int(max(srcY - filterH, 0)) + srcY_end = int(min(srcY + filterH, srcH - 1)) + + #weightsY = { i - srcY_start: 1.0 - abs(i - srcY) / scaleY \ + # for i in range(srcY_start, srcY_end+1, 1) if i - srcY_start < 16 } + for i in range(16): + idx = i + srcY_start + if idx > srcY_end: + break + weightsY[i] = 1.0 - abs(idx - srcY) / filterH + + for dstX in range(dstW): + srcX = dstX * scaleX + srcX_start = int(max(srcX - filterW, 0)) + srcX_end = int(min(srcX + filterW, srcW - 1)) + + #weightsX = { i - srcX_start: 1.0 - abs(i - srcX) / scaleX \ + # for i in range(srcX_start, srcX_end+1, 1) if i - srcX_start < 16 } + for i in range(16): + idx = i + srcX_start + if idx > srcX_end: + break + weightsX[i] = 1.0 - abs(idx - srcX) / filterW + + accum_color = [0.0, 0.0, 0.0, 0.0] + weight_total = 0.0 + for i in range(srcY_start, srcY_end+1, 1): + weightY_idx = i - srcY_start + weightY = weightsY[weightY_idx] if weightY_idx < 16 else 1.0 - abs(i - srcY) / filterH + weightY = 1.0 - abs(i - srcY) / filterH + + src_idx = (i * src_rowspan) + (srcX_start * 4) + for j in range(srcX_start, srcX_end+1, 1): + weightX_idx = j - srcX_start + weightX = weightsX[weightX_idx] if weightX_idx < 16 else 1.0 - abs(j - srcX) / filterW + weight = weightY * weightX + + if weight > 0.0: + # According to profiling, a list comprehension here doubles the execution time of this + # function. I know this function is supposed to be slow, but dayum... I've unrolled it + # to avoid all the extra allocations. + for k in range(4): + accum_color[k] = accum_color[k] + buf[src_idx+k] * weight + weight_total += weight + src_idx += 4 + + weight_total = max(weight_total, 0.0001) + for i in range(4): + accum_color[i] = int(accum_color[i] * (1.0 / weight_total)) + dst[dst_idx:dst_idx+4] = accum_color + dst_idx += 4 + + return bytes(dst) + + class GLTexture: - def __init__(self, texkey=None): + def __init__(self, texkey=None, image=None, bgra=False, fast=False): + assert texkey or image self._texkey = texkey - self._ownit = (self._blimg.bindcode[0] == 0) - - @property - def _blimg(self): - return self._texkey.image + if texkey is not None: + self._blimg = texkey.image + if image is not None: + self._blimg = image + self._image_inverted = fast + self._bgra = bgra def __enter__(self): - """Sets the Blender Image as the active OpenGL texture""" - if self._ownit: + """Loads the image data using OpenGL""" + + # Set image active in OpenGL + ownit = self._blimg.bindcode[0] == 0 + if ownit: if self._blimg.gl_load() != 0: raise RuntimeError("failed to load image") - - self._previous_texture = self._get_integer(bgl.GL_TEXTURE_BINDING_2D) - self._changed_state = (self._previous_texture != self._blimg.bindcode[0]) - if self._changed_state: + previous_texture = self._get_integer(bgl.GL_TEXTURE_BINDING_2D) + changed_state = (previous_texture != self._blimg.bindcode[0]) + if changed_state: bgl.glBindTexture(bgl.GL_TEXTURE_2D, self._blimg.bindcode[0]) + + # Grab the image data + self._width = self._get_tex_param(bgl.GL_TEXTURE_WIDTH, 0) + self._height = self._get_tex_param(bgl.GL_TEXTURE_HEIGHT, 0) + size = self._width * self._height * 4 + buf = bgl.Buffer(bgl.GL_BYTE, size) + fmt = bgl.GL_BGRA if self._bgra else bgl.GL_RGBA + bgl.glGetTexImage(bgl.GL_TEXTURE_2D, 0, fmt, bgl.GL_UNSIGNED_BYTE, buf) + + # OpenGL returns the images upside down, so we're going to rotate it in memory. + # ... But only if requested... :) + if self._image_inverted: + self._image_data = bytes(buf) + else: + self._image_data = self._invert_image(self._width, self._height, buf) + + # Restore previous OpenGL state + if changed_state: + bgl.glBindTexture(bgl.GL_TEXTURE_2D, previous_texture) + if ownit: + self._blimg.gl_free() return self def __exit__(self, type, value, traceback): - mipmap_state = getattr(self, "_mipmap_state", None) - if mipmap_state is not None: - bgl.glTexParameteri(bgl.GL_TEXTURE_2D, bgl.GL_GENERATE_MIPMAP, mipmap_state) - if self._changed_state: - bgl.glBindTexture(bgl.GL_TEXTURE_2D, self._previous_texture) - if self._ownit: - self._blimg.gl_free() + del self._image_data @property def _detail_falloff(self): @@ -64,57 +152,55 @@ class GLTexture: self._texkey.detail_opacity_start / 100.0, self._texkey.detail_opacity_stop / 100.0) - def generate_mipmap(self): - """Generates all mip levels for this texture""" - self._mipmap_state = self._get_tex_param(bgl.GL_GENERATE_MIPMAP) - - # Note that this is a very old feature from OpenGL 1.x -- it's new enough that Windows (and - # Blender apparently) don't support it natively and yet old enough that it was thrown away - # in OpenGL 3.0. The new way is glGenerateMipmap, but Blender likes oldgl, so we don't have that - # function available to us in BGL. I don't want to deal with loading the GL dll in ctypes on - # many platforms right now (or context headaches). If someone wants to fix this, be my guest! - # It will simplify our state tracking a bit. - bgl.glTexParameteri(bgl.GL_TEXTURE_2D, bgl.GL_GENERATE_MIPMAP, 1) - - def get_level_data(self, level=0, calc_alpha=False, bgra=False, report=None, fast=False): + def get_level_data(self, level=0, calc_alpha=False, report=None, indent=2, fast=False): """Gets the uncompressed pixel data for a requested mip level, optionally calculating the alpha channel from the image color data """ - width = self._get_tex_param(bgl.GL_TEXTURE_WIDTH, level) - height = self._get_tex_param(bgl.GL_TEXTURE_HEIGHT, level) + + # Previously, we would leave the texture bound in OpenGL and use it to do the mipmapping, using + # old, deprecated OpenGL features. With the introduction of plCubicEnvironmap support to Korman, + # we wind up needing to get an NPOT image from OpenGL. Unfortunately, Blender will sometimes scale + # images to be POT _before_ loading them into OpenGL. Therefore, we now use OpenGL to grab the first + # level, then scale down to the new level from there. + oWidth, oHeight = self.size_npot + eWidth = ensure_power_of_two(oWidth) >> level + eHeight = ensure_power_of_two(oHeight) >> level + if report is not None: - report.msg("Level #{}: {}x{}", level, width, height, indent=2) + report.msg("Level #{}: {}x{}", level, eWidth, eHeight, indent=indent) - # Grab the image data - size = width * height * 4 - buf = bgl.Buffer(bgl.GL_BYTE, size) - fmt = bgl.GL_BGRA if bgra else bgl.GL_RGBA - bgl.glGetTexImage(bgl.GL_TEXTURE_2D, level, fmt, bgl.GL_UNSIGNED_BYTE, buf); + # Scale, if needed... + if oWidth != eWidth or oHeight != eHeight: + buf = _scale_image(self._image_data, oWidth, oHeight, eWidth, eHeight) + else: + buf = self._image_data + + # Some operations, like alpha testing, don't care about the fact that OpenGL flips + # the images in memory. Give an opportunity to bail here... if fast: - return bytes(buf) + return self._image_data + else: + buf = bytearray(self._image_data) - # OpenGL returns the images upside down, so we're going to rotate it in memory. - finalBuf = bytearray(size) - row_stride = width * 4 - for i in range(height): - src, dst = i * row_stride, (height - (i+1)) * row_stride - finalBuf[dst:dst+row_stride] = buf[src:src+row_stride] + + if self._image_inverted: + 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(finalBuf, level) + self._make_detail_map_alpha(buf, level) elif detail_blend == TEX_DETAIL_ADD: - self._make_detail_map_alpha(finalBuf, level) + self._make_detail_map_alpha(buf, level) elif detail_blend == TEX_DETAIL_MULTIPLY: - self._make_detail_map_mult(finalBuf, level) + self._make_detail_map_mult(buf, level) # Do we need to calculate the alpha component? if calc_alpha: for i in range(0, size, 4): - finalBuf[i+3] = int(sum(finalBuf[i:i+3]) / 3) - return bytes(finalBuf) + buf[i+3] = int(sum(buf[i:i+3]) / 3) + return bytes(buf) def _get_detail_alpha(self, level, dropoff_start, dropoff_stop, detail_max, detail_min): alpha = (level - dropoff_start) * (detail_min - detail_max) / (dropoff_stop - dropoff_start) + detail_max @@ -138,12 +224,27 @@ class GLTexture: @property def has_alpha(self): - data = self.get_level_data(report=None, fast=True) + data = self._image_data for i in range(3, len(data), 4): if data[i] != 255: return True return False + def _get_image_data(self): + return (self._width, self._height, self._image_data) + def _set_image_data(self, value): + self._width, self._height, self._image_data = value + image_data = property(_get_image_data, _set_image_data) + + def _invert_image(self, width, height, buf): + size = width * height * 4 + finalBuf = bytearray(size) + row_stride = width * 4 + for i in range(height): + src, dst = i * row_stride, (height - (i+1)) * row_stride + finalBuf[dst:dst+row_stride] = buf[src:src+row_stride] + return bytes(finalBuf) + def _make_detail_map_add(self, data, level): dropoff_start, dropoff_stop, detail_max, detail_min = self._detail_falloff alpha = self._get_detail_alpha(level, dropoff_start, dropoff_stop, detail_max, detail_min) @@ -167,7 +268,7 @@ class GLTexture: @property def num_levels(self): - numLevels = math.floor(math.log(max(self._blimg.size), 2)) + 1 + numLevels = math.floor(math.log(max(self.size_npot), 2)) + 1 # Major Workaround Ahoy # There is a bug in Cyan's level size algorithm that causes it to not allocate enough memory @@ -181,3 +282,11 @@ class GLTexture: # texture in a single pixel?" # :) return max(numLevels - 2, 2) + + @property + def size_npot(self): + return self._width, self._height + + @property + def size_pot(self): + return ensure_power_of_two(self._width), ensure_power_of_two(self._height) 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_export.py b/korman/operators/op_export.py index d137410..3a098f9 100644 --- a/korman/operators/op_export.py +++ b/korman/operators/op_export.py @@ -64,6 +64,13 @@ class ExportOperator(bpy.types.Operator): ("force_lightmap", "Force Lightmap Bake", "All static lighting is baked as lightmaps (slower export)")], "default": "bake"}), + "envmap_method": (EnumProperty, {"name": "Environment Maps", + "description": "Environment Map Settings", + "items": [("skip", "Don't Export EnvMaps", "Environment Maps are not exported"), + ("dcm2dem", "Downgrade Planar EnvMaps", "When the engine doesn't support them, Planar Environment Maps are downgraded to Cube Maps"), + ("perengine", "Export Supported EnvMaps", "Only environment maps supported by the selected game engine are exported")], + "default": "dcm2dem"}), + "export_active": (BoolProperty, {"name": "INTERNAL: Export currently running", "default": False, "options": {"SKIP_SAVE"}}), diff --git a/korman/operators/op_image.py b/korman/operators/op_image.py new file mode 100644 index 0000000..09ba99b --- /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 we 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/operators/op_toolbox.py b/korman/operators/op_toolbox.py index 8df708a..a1dda66 100644 --- a/korman/operators/op_toolbox.py +++ b/korman/operators/op_toolbox.py @@ -102,6 +102,25 @@ class PlasmaToggleAllPlasmaObjectsOperator(ToolboxOperator, bpy.types.Operator): return {"FINISHED"} +class PlasmaToggleEnvironmentMapsOperator(ToolboxOperator, bpy.types.Operator): + bl_idname = "texture.plasma_toggle_environment_maps" + bl_label = "Toggle Environment Maps" + bl_description = "Changes the state of all Environment Maps" + + enable = BoolProperty(name="Enable", description="Enable Environment Maps") + + def execute(self, context): + enable = self.enable + for material in bpy.data.materials: + for slot in material.texture_slots: + if slot is None: + continue + if slot.texture.type == "ENVIRONMENT_MAP": + slot.use = enable + return {"FINISHED"} + + + class PlasmaTogglePlasmaObjectsOperator(ToolboxOperator, bpy.types.Operator): bl_idname = "object.plasma_toggle_selected_objects" bl_label = "Toggle Plasma Objects" 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): diff --git a/korman/ui/ui_toolbox.py b/korman/ui/ui_toolbox.py index a73bc93..34117d4 100644 --- a/korman/ui/ui_toolbox.py +++ b/korman/ui/ui_toolbox.py @@ -41,7 +41,11 @@ class PlasmaToolboxPanel(ToolboxPanel, bpy.types.Panel): disable_all = col.operator("object.plasma_toggle_all_objects", icon="OBJECT_DATA", text="Disable All") disable_all.enable = False + col.label("Textures:") + col.operator("texture.plasma_enable_all_textures", icon="TEXTURE", text="Enable All") + col.operator("texture.plasma_toggle_environment_maps", icon="IMAGE_RGB", text="Enable All EnvMaps").enable = True + col.operator("texture.plasma_toggle_environment_maps", icon="IMAGE_RGB_ALPHA", text="Disable All EnvMaps").enable = False + col.label("Convert:") col.operator("object.plasma_convert_plasma_objects", icon="OBJECT_DATA", text="Plasma Objects") - col.operator("texture.plasma_enable_all_textures", icon="TEXTURE") col.operator("texture.plasma_convert_layer_opacities", icon="IMAGE_RGB_ALPHA", text="Layer Opacities") diff --git a/korman/ui/ui_world.py b/korman/ui/ui_world.py index 1400280..c5580c4 100644 --- a/korman/ui/ui_world.py +++ b/korman/ui/ui_world.py @@ -143,6 +143,7 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel): col.prop(age, "use_texture_page") layout.separator() + layout.prop(age, "envmap_method") layout.prop(age, "lighting_method") layout.prop(age, "texcache_method")