Browse Source

Merge pull request #125 from Hoikas/envmap

Environment Map Improvements
pull/133/head
Adam Johnson 6 years ago committed by GitHub
parent
commit
070db331fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      korlib/korlib.h
  2. 1
      korlib/module.cpp
  3. 437
      korlib/texture.cpp
  4. 2
      korlib/texture.h
  5. 4
      korman/exporter/convert.py
  6. 63
      korman/exporter/image.py
  7. 285
      korman/exporter/material.py
  8. 2
      korman/helpers.py
  9. 221
      korman/korlib/texture.py
  10. 1
      korman/operators/__init__.py
  11. 7
      korman/operators/op_export.py
  12. 237
      korman/operators/op_image.py
  13. 19
      korman/operators/op_toolbox.py
  14. 27
      korman/ui/ui_texture.py
  15. 6
      korman/ui/ui_toolbox.py
  16. 1
      korman/ui/ui_world.py

2
korlib/korlib.h

@ -23,11 +23,13 @@
#include <Python.h>
#define _pycs(x) const_cast<char*>(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); }

1
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 },
};

437
korlib/texture.cpp

@ -26,22 +26,167 @@
#include <GL/gl.h>
#include <PRP/Surface/plMipmap.h>
#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<typename T>
static T _ensure_power_of_two(T value) {
return static_cast<T>(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.
// "<Deledrius> 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<float>(srcW) / static_cast<float>(dstW);
float scaleY = static_cast<float>(srcH) / static_cast<float>(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<ssize_t>(srcY - filterH),
static_cast<ssize_t>(0));
ssize_t srcY_end = std::min(static_cast<ssize_t>(srcY + filterH),
static_cast<ssize_t>(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<ssize_t>(srcX - filterW),
static_cast<ssize_t>(0));
ssize_t srcX_end = std::min(static_cast<ssize_t>(srcX + filterW),
static_cast<ssize_t>(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<float>(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<uint8_t>(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<uint8_t*>(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<GLvoid*>(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<uint8_t*>(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.
// "<Deledrius> 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<GLvoid*>(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<uint8_t*>(PyBytes_AsString(data)); // AS_STRING :(
uint8_t* srcBuf = reinterpret_cast<uint8_t*>(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<uint8_t*>(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<uint8_t*>(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;
}
};

2
korlib/texture.h

@ -21,6 +21,8 @@
extern "C" {
PyObject* scale_image(PyObject*, PyObject*, PyObject*);
extern PyTypeObject pyGLTexture_Type;
PyObject* Init_pyGLTexture_Type();

4
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

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

285
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

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

221
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 <http://www.gnu.org/licenses/>.
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)

1
korman/operators/__init__.py

@ -14,6 +14,7 @@
# along with Korman. If not, see <http://www.gnu.org/licenses/>.
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

7
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"}}),

237
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 <http://www.gnu.org/licenses/>.
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)

19
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"

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

6
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")

1
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")

Loading…
Cancel
Save