All 27 PyTypeObject declarations in namespace mcrfpydef headers changed from `static` to `inline` (C++17), ensuring a single global instance across translation units. This fixes the root cause of stale-type-pointer segfaults where only the McRFPy_API.cpp copy was PyType_Ready'd. Replaced ~20 PyTypeCache call sites and 2 PyRAII::PyTypeRef lookups with direct &mcrfpydef::Type references. Deleted PyTypeCache.h/.cpp, PyObjectUtils.h, and PyRAII.h (all were workarounds for the static bug). 228/228 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
497 lines
19 KiB
C++
497 lines
19 KiB
C++
#include "PyTexture.h"
|
|
#include "McRFPy_API.h"
|
|
#include "McRFPy_Doc.h"
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
|
|
PyTexture::PyTexture(std::string filename, int sprite_w, int sprite_h)
|
|
: source(filename), sprite_width(sprite_w), sprite_height(sprite_h), sheet_width(0), sheet_height(0)
|
|
{
|
|
texture = sf::Texture();
|
|
if (!texture.loadFromFile(source)) {
|
|
// Failed to load texture - leave sheet dimensions as 0
|
|
// This will be checked in init()
|
|
return;
|
|
}
|
|
texture.setSmooth(false); // Disable smoothing for pixel art
|
|
auto size = texture.getSize();
|
|
sheet_width = (size.x / sprite_width);
|
|
sheet_height = (size.y / sprite_height);
|
|
if (size.x % sprite_width != 0 || size.y % sprite_height != 0)
|
|
{
|
|
std::cout << "Warning: Texture `" << source << "` is not an even number of sprite widths or heights across." << std::endl
|
|
<< "Sprite size given was " << sprite_w << "x" << sprite_h << "px but the file has a resolution of " << sheet_width << "x" << sheet_height << "px." << std::endl;
|
|
}
|
|
}
|
|
|
|
// #144: Factory method to create texture from rendered content (snapshot)
|
|
std::shared_ptr<PyTexture> PyTexture::from_rendered(sf::RenderTexture& render_tex)
|
|
{
|
|
// Use a custom shared_ptr construction to access private default constructor
|
|
struct MakeSharedEnabler : public PyTexture {
|
|
MakeSharedEnabler() : PyTexture() {}
|
|
};
|
|
auto ptex = std::make_shared<MakeSharedEnabler>();
|
|
|
|
// Copy the rendered texture data
|
|
ptex->texture = render_tex.getTexture();
|
|
ptex->texture.setSmooth(false); // Maintain pixel art aesthetic
|
|
|
|
// Set source to indicate this is a snapshot
|
|
ptex->source = "<snapshot>";
|
|
|
|
// Treat entire texture as single sprite
|
|
auto size = ptex->texture.getSize();
|
|
ptex->sprite_width = size.x;
|
|
ptex->sprite_height = size.y;
|
|
ptex->sheet_width = 1;
|
|
ptex->sheet_height = 1;
|
|
|
|
return ptex;
|
|
}
|
|
|
|
// Factory method to create texture from an sf::Image (for LDtk flip-baked atlases)
|
|
std::shared_ptr<PyTexture> PyTexture::from_image(
|
|
const sf::Image& img, int sprite_w, int sprite_h,
|
|
const std::string& name)
|
|
{
|
|
struct MakeSharedEnabler : public PyTexture {
|
|
MakeSharedEnabler() : PyTexture() {}
|
|
};
|
|
auto ptex = std::make_shared<MakeSharedEnabler>();
|
|
|
|
ptex->texture.loadFromImage(img);
|
|
ptex->texture.setSmooth(false);
|
|
|
|
ptex->source = name;
|
|
ptex->sprite_width = sprite_w;
|
|
ptex->sprite_height = sprite_h;
|
|
|
|
auto size = ptex->texture.getSize();
|
|
ptex->sheet_width = (sprite_w > 0) ? (size.x / sprite_w) : 0;
|
|
ptex->sheet_height = (sprite_h > 0) ? (size.y / sprite_h) : 0;
|
|
|
|
return ptex;
|
|
}
|
|
|
|
sf::Sprite PyTexture::sprite(int index, sf::Vector2f pos, sf::Vector2f s)
|
|
{
|
|
// Protect against division by zero if texture failed to load
|
|
if (sheet_width == 0 || sheet_height == 0) {
|
|
// Return an empty sprite
|
|
return sf::Sprite();
|
|
}
|
|
|
|
int tx = index % sheet_width, ty = index / sheet_width;
|
|
auto ir = sf::IntRect(tx * sprite_width, ty * sprite_height, sprite_width, sprite_height);
|
|
auto sprite = sf::Sprite(texture, ir);
|
|
sprite.setPosition(pos);
|
|
sprite.setScale(s);
|
|
return sprite;
|
|
}
|
|
|
|
PyObject* PyTexture::pyObject()
|
|
{
|
|
PyTypeObject* type = &mcrfpydef::PyTextureType;
|
|
PyObject* obj = PyTexture::pynew(type, Py_None, Py_None);
|
|
|
|
if (!obj) {
|
|
return NULL;
|
|
}
|
|
|
|
try {
|
|
// Use placement new to properly construct the shared_ptr
|
|
// tp_alloc zeroes memory but doesn't call C++ constructors
|
|
new (&((PyTextureObject*)obj)->data) std::shared_ptr<PyTexture>(shared_from_this());
|
|
}
|
|
catch (std::bad_weak_ptr& e)
|
|
{
|
|
std::cout << "Bad weak ptr: shared_from_this() failed in PyTexture::pyObject(); did you create a PyTexture outside of std::make_shared? enjoy your segfault, soon!" << std::endl;
|
|
Py_DECREF(obj);
|
|
PyErr_SetString(PyExc_RuntimeError, "PyTexture was not created with std::make_shared");
|
|
return NULL;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
PyObject* PyTexture::repr(PyObject* obj)
|
|
{
|
|
PyTextureObject* self = (PyTextureObject*)obj;
|
|
std::ostringstream ss;
|
|
if (!self->data)
|
|
{
|
|
ss << "<Texture [invalid internal object]>";
|
|
std::string repr_str = ss.str();
|
|
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
|
|
}
|
|
auto& ptex = *(self->data);
|
|
ss << "<Texture " << ptex.sheet_height << " rows, " << ptex.sheet_width << " columns; " << ptex.sprite_width << "x" << ptex.sprite_height << "px sprites. source='" << ptex.source << "'>";
|
|
std::string repr_str = ss.str();
|
|
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
|
|
}
|
|
|
|
Py_hash_t PyTexture::hash(PyObject* obj)
|
|
{
|
|
auto self = (PyTextureObject*)obj;
|
|
return reinterpret_cast<Py_hash_t>(self->data.get());
|
|
}
|
|
|
|
int PyTexture::init(PyTextureObject* self, PyObject* args, PyObject* kwds)
|
|
{
|
|
static const char* keywords[] = { "filename", "sprite_width", "sprite_height", nullptr };
|
|
char* filename;
|
|
int sprite_width, sprite_height;
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "sii", const_cast<char**>(keywords), &filename, &sprite_width, &sprite_height))
|
|
return -1;
|
|
|
|
// Create the texture object
|
|
self->data = std::make_shared<PyTexture>(filename, sprite_width, sprite_height);
|
|
|
|
// Check if the texture failed to load (sheet dimensions will be 0)
|
|
if (self->data->sheet_width == 0 || self->data->sheet_height == 0) {
|
|
PyErr_Format(PyExc_IOError, "Failed to load texture from file: %s", filename);
|
|
return -1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
PyObject* PyTexture::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds)
|
|
{
|
|
return (PyObject*)type->tp_alloc(type, 0);
|
|
}
|
|
|
|
PyObject* PyTexture::get_sprite_width(PyTextureObject* self, void* closure)
|
|
{
|
|
return PyLong_FromLong(self->data->sprite_width);
|
|
}
|
|
|
|
PyObject* PyTexture::get_sprite_height(PyTextureObject* self, void* closure)
|
|
{
|
|
return PyLong_FromLong(self->data->sprite_height);
|
|
}
|
|
|
|
PyObject* PyTexture::get_sheet_width(PyTextureObject* self, void* closure)
|
|
{
|
|
return PyLong_FromLong(self->data->sheet_width);
|
|
}
|
|
|
|
PyObject* PyTexture::get_sheet_height(PyTextureObject* self, void* closure)
|
|
{
|
|
return PyLong_FromLong(self->data->sheet_height);
|
|
}
|
|
|
|
PyObject* PyTexture::get_sprite_count(PyTextureObject* self, void* closure)
|
|
{
|
|
return PyLong_FromLong(self->data->getSpriteCount());
|
|
}
|
|
|
|
PyObject* PyTexture::get_source(PyTextureObject* self, void* closure)
|
|
{
|
|
return PyUnicode_FromString(self->data->source.c_str());
|
|
}
|
|
|
|
PyGetSetDef PyTexture::getsetters[] = {
|
|
{"sprite_width", (getter)PyTexture::get_sprite_width, NULL,
|
|
MCRF_PROPERTY(sprite_width, "Width of each sprite in pixels (int, read-only). Specified during texture initialization."), NULL},
|
|
{"sprite_height", (getter)PyTexture::get_sprite_height, NULL,
|
|
MCRF_PROPERTY(sprite_height, "Height of each sprite in pixels (int, read-only). Specified during texture initialization."), NULL},
|
|
{"sheet_width", (getter)PyTexture::get_sheet_width, NULL,
|
|
MCRF_PROPERTY(sheet_width, "Number of sprite columns in the texture sheet (int, read-only). Calculated as texture_width / sprite_width."), NULL},
|
|
{"sheet_height", (getter)PyTexture::get_sheet_height, NULL,
|
|
MCRF_PROPERTY(sheet_height, "Number of sprite rows in the texture sheet (int, read-only). Calculated as texture_height / sprite_height."), NULL},
|
|
{"sprite_count", (getter)PyTexture::get_sprite_count, NULL,
|
|
MCRF_PROPERTY(sprite_count, "Total number of sprites in the texture sheet (int, read-only). Equals sheet_width * sheet_height."), NULL},
|
|
{"source", (getter)PyTexture::get_source, NULL,
|
|
MCRF_PROPERTY(source, "Source filename path (str, read-only). The path used to load this texture."), NULL},
|
|
{NULL} // Sentinel
|
|
};
|
|
|
|
// ============================================================================
|
|
// Texture.from_bytes(data, width, height, sprite_w, sprite_h, name) classmethod
|
|
// ============================================================================
|
|
PyObject* PyTexture::from_bytes(PyObject* cls, PyObject* args, PyObject* kwds)
|
|
{
|
|
static const char* keywords[] = {"data", "width", "height", "sprite_width", "sprite_height", "name", nullptr};
|
|
Py_buffer buf;
|
|
int width, height, sprite_w, sprite_h;
|
|
const char* name = "<generated>";
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "y*iiii|s",
|
|
const_cast<char**>(keywords),
|
|
&buf, &width, &height, &sprite_w, &sprite_h, &name))
|
|
return NULL;
|
|
|
|
Py_ssize_t expected = (Py_ssize_t)width * height * 4;
|
|
if (buf.len != expected) {
|
|
PyBuffer_Release(&buf);
|
|
PyErr_Format(PyExc_ValueError,
|
|
"Expected %zd bytes (width=%d * height=%d * 4), got %zd",
|
|
expected, width, height, buf.len);
|
|
return NULL;
|
|
}
|
|
|
|
sf::Image img;
|
|
img.create(width, height, (const sf::Uint8*)buf.buf);
|
|
PyBuffer_Release(&buf);
|
|
|
|
auto ptex = PyTexture::from_image(img, sprite_w, sprite_h, name);
|
|
return ptex->pyObject();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Texture.composite(layers, sprite_w, sprite_h, name) classmethod
|
|
// ============================================================================
|
|
PyObject* PyTexture::composite(PyObject* cls, PyObject* args, PyObject* kwds)
|
|
{
|
|
static const char* keywords[] = {"layers", "sprite_width", "sprite_height", "name", nullptr};
|
|
PyObject* layers_list;
|
|
int sprite_w, sprite_h;
|
|
const char* name = "<composite>";
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "Oii|s",
|
|
const_cast<char**>(keywords),
|
|
&layers_list, &sprite_w, &sprite_h, &name))
|
|
return NULL;
|
|
|
|
if (!PyList_Check(layers_list)) {
|
|
PyErr_SetString(PyExc_TypeError, "layers must be a list of Texture objects");
|
|
return NULL;
|
|
}
|
|
|
|
Py_ssize_t count = PyList_Size(layers_list);
|
|
if (count == 0) {
|
|
PyErr_SetString(PyExc_ValueError, "layers list must not be empty");
|
|
return NULL;
|
|
}
|
|
|
|
// Validate all elements are Texture objects and collect images
|
|
std::vector<sf::Image> images;
|
|
unsigned int tex_w = 0, tex_h = 0;
|
|
|
|
PyTypeObject* texture_type = &mcrfpydef::PyTextureType;
|
|
|
|
for (Py_ssize_t i = 0; i < count; i++) {
|
|
PyObject* item = PyList_GetItem(layers_list, i);
|
|
if (!PyObject_IsInstance(item, (PyObject*)texture_type)) {
|
|
PyErr_Format(PyExc_TypeError,
|
|
"layers[%zd] is not a Texture object", i);
|
|
return NULL;
|
|
}
|
|
auto& ptex = ((PyTextureObject*)item)->data;
|
|
if (!ptex) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"layers[%zd] has invalid internal data", i);
|
|
return NULL;
|
|
}
|
|
|
|
sf::Image img = ptex->texture.copyToImage();
|
|
auto size = img.getSize();
|
|
|
|
if (i == 0) {
|
|
tex_w = size.x;
|
|
tex_h = size.y;
|
|
} else if (size.x != tex_w || size.y != tex_h) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"All layers must have same dimensions. "
|
|
"Layer 0 is %ux%u, layer %zd is %ux%u",
|
|
tex_w, tex_h, i, size.x, size.y);
|
|
return NULL;
|
|
}
|
|
images.push_back(std::move(img));
|
|
}
|
|
|
|
// Alpha-composite all layers bottom-to-top
|
|
sf::Image result;
|
|
result.create(tex_w, tex_h, sf::Color::Transparent);
|
|
|
|
for (unsigned int y = 0; y < tex_h; y++) {
|
|
for (unsigned int x = 0; x < tex_w; x++) {
|
|
// Start with first layer
|
|
sf::Color dst = images[0].getPixel(x, y);
|
|
|
|
// Composite each subsequent layer on top
|
|
for (size_t i = 1; i < images.size(); i++) {
|
|
sf::Color src = images[i].getPixel(x, y);
|
|
if (src.a == 0) continue;
|
|
if (src.a == 255 || dst.a == 0) {
|
|
dst = src;
|
|
continue;
|
|
}
|
|
|
|
// Standard alpha compositing (Porter-Duff "over")
|
|
float sa = src.a / 255.0f;
|
|
float da = dst.a / 255.0f;
|
|
float out_a = sa + da * (1.0f - sa);
|
|
|
|
if (out_a > 0.0f) {
|
|
dst.r = (sf::Uint8)((src.r * sa + dst.r * da * (1.0f - sa)) / out_a);
|
|
dst.g = (sf::Uint8)((src.g * sa + dst.g * da * (1.0f - sa)) / out_a);
|
|
dst.b = (sf::Uint8)((src.b * sa + dst.b * da * (1.0f - sa)) / out_a);
|
|
dst.a = (sf::Uint8)(out_a * 255.0f);
|
|
}
|
|
}
|
|
result.setPixel(x, y, dst);
|
|
}
|
|
}
|
|
|
|
auto ptex = PyTexture::from_image(result, sprite_w, sprite_h, name);
|
|
return ptex->pyObject();
|
|
}
|
|
|
|
// ============================================================================
|
|
// HSL conversion helpers (internal)
|
|
// ============================================================================
|
|
namespace {
|
|
|
|
struct HSL {
|
|
float h, s, l;
|
|
};
|
|
|
|
HSL rgb_to_hsl(sf::Uint8 r, sf::Uint8 g, sf::Uint8 b)
|
|
{
|
|
float rf = r / 255.0f, gf = g / 255.0f, bf = b / 255.0f;
|
|
float mx = std::max({rf, gf, bf});
|
|
float mn = std::min({rf, gf, bf});
|
|
float l = (mx + mn) / 2.0f;
|
|
|
|
if (mx == mn) return {0.0f, 0.0f, l};
|
|
|
|
float d = mx - mn;
|
|
float s = (l > 0.5f) ? d / (2.0f - mx - mn) : d / (mx + mn);
|
|
float h;
|
|
if (mx == rf) {
|
|
h = (gf - bf) / d + (gf < bf ? 6.0f : 0.0f);
|
|
} else if (mx == gf) {
|
|
h = (bf - rf) / d + 2.0f;
|
|
} else {
|
|
h = (rf - gf) / d + 4.0f;
|
|
}
|
|
h *= 60.0f;
|
|
return {h, s, l};
|
|
}
|
|
|
|
static float hue_to_rgb(float p, float q, float t)
|
|
{
|
|
if (t < 0.0f) t += 1.0f;
|
|
if (t > 1.0f) t -= 1.0f;
|
|
if (t < 1.0f/6.0f) return p + (q - p) * 6.0f * t;
|
|
if (t < 1.0f/2.0f) return q;
|
|
if (t < 2.0f/3.0f) return p + (q - p) * (2.0f/3.0f - t) * 6.0f;
|
|
return p;
|
|
}
|
|
|
|
sf::Color hsl_to_rgb(float h, float s, float l, sf::Uint8 a)
|
|
{
|
|
if (s <= 0.0f) {
|
|
sf::Uint8 v = (sf::Uint8)(l * 255.0f);
|
|
return sf::Color(v, v, v, a);
|
|
}
|
|
|
|
float hn = h / 360.0f;
|
|
float q = (l < 0.5f) ? l * (1.0f + s) : l + s - l * s;
|
|
float p = 2.0f * l - q;
|
|
|
|
float r = hue_to_rgb(p, q, hn + 1.0f/3.0f);
|
|
float g = hue_to_rgb(p, q, hn);
|
|
float b = hue_to_rgb(p, q, hn - 1.0f/3.0f);
|
|
|
|
return sf::Color(
|
|
(sf::Uint8)(r * 255.0f),
|
|
(sf::Uint8)(g * 255.0f),
|
|
(sf::Uint8)(b * 255.0f),
|
|
a);
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
// ============================================================================
|
|
// texture.hsl_shift(hue_shift, sat_shift, lit_shift) instance method
|
|
// ============================================================================
|
|
PyObject* PyTexture::hsl_shift(PyTextureObject* self, PyObject* args, PyObject* kwds)
|
|
{
|
|
static const char* keywords[] = {"hue_shift", "sat_shift", "lit_shift", nullptr};
|
|
float hue_shift, sat_shift = 0.0f, lit_shift = 0.0f;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "f|ff",
|
|
const_cast<char**>(keywords),
|
|
&hue_shift, &sat_shift, &lit_shift))
|
|
return NULL;
|
|
|
|
if (!self->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Texture has invalid internal data");
|
|
return NULL;
|
|
}
|
|
|
|
sf::Image img = self->data->texture.copyToImage();
|
|
auto size = img.getSize();
|
|
|
|
for (unsigned int y = 0; y < size.y; y++) {
|
|
for (unsigned int x = 0; x < size.x; x++) {
|
|
sf::Color px = img.getPixel(x, y);
|
|
if (px.a == 0) continue; // skip transparent
|
|
|
|
HSL hsl = rgb_to_hsl(px.r, px.g, px.b);
|
|
|
|
// Apply shifts
|
|
hsl.h = std::fmod(hsl.h + hue_shift, 360.0f);
|
|
if (hsl.h < 0.0f) hsl.h += 360.0f;
|
|
|
|
hsl.s = std::clamp(hsl.s + sat_shift, 0.0f, 1.0f);
|
|
hsl.l = std::clamp(hsl.l + lit_shift, 0.0f, 1.0f);
|
|
|
|
img.setPixel(x, y, hsl_to_rgb(hsl.h, hsl.s, hsl.l, px.a));
|
|
}
|
|
}
|
|
|
|
auto ptex = PyTexture::from_image(img,
|
|
self->data->sprite_width, self->data->sprite_height,
|
|
self->data->source + "+hsl");
|
|
return ptex->pyObject();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Methods table
|
|
// ============================================================================
|
|
PyMethodDef PyTexture::methods[] = {
|
|
{"from_bytes", (PyCFunction)PyTexture::from_bytes, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
|
|
MCRF_METHOD(Texture, from_bytes,
|
|
MCRF_SIG("(data: bytes, width: int, height: int, sprite_width: int, sprite_height: int, name: str = '<generated>')", "Texture"),
|
|
MCRF_DESC("Create a Texture from raw RGBA pixel data."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("data", "Raw RGBA bytes (length must equal width * height * 4)")
|
|
MCRF_ARG("width", "Image width in pixels")
|
|
MCRF_ARG("height", "Image height in pixels")
|
|
MCRF_ARG("sprite_width", "Width of each sprite cell")
|
|
MCRF_ARG("sprite_height", "Height of each sprite cell")
|
|
MCRF_ARG("name", "Optional name for the texture (default: '<generated>')")
|
|
MCRF_RETURNS("Texture: New texture containing the pixel data")
|
|
MCRF_RAISES("ValueError", "If data length does not match width * height * 4")
|
|
MCRF_NOTE("This is a class method. Useful for procedurally generated textures.")
|
|
)},
|
|
{"composite", (PyCFunction)PyTexture::composite, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
|
|
MCRF_METHOD(Texture, composite,
|
|
MCRF_SIG("(layers: list[Texture], sprite_width: int, sprite_height: int, name: str = '<composite>')", "Texture"),
|
|
MCRF_DESC("Alpha-composite multiple texture layers into a single texture."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("layers", "List of Texture objects, composited bottom-to-top")
|
|
MCRF_ARG("sprite_width", "Width of each sprite cell in the result")
|
|
MCRF_ARG("sprite_height", "Height of each sprite cell in the result")
|
|
MCRF_ARG("name", "Optional name for the composite texture")
|
|
MCRF_RETURNS("Texture: New texture with all layers composited")
|
|
MCRF_RAISES("ValueError", "If layers have different dimensions or list is empty")
|
|
MCRF_NOTE("This is a class method. Uses Porter-Duff 'over' alpha compositing.")
|
|
)},
|
|
{"hsl_shift", (PyCFunction)PyTexture::hsl_shift, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(Texture, hsl_shift,
|
|
MCRF_SIG("(hue_shift: float, sat_shift: float = 0.0, lit_shift: float = 0.0)", "Texture"),
|
|
MCRF_DESC("Create a new texture with HSL color adjustments applied."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("hue_shift", "Hue rotation in degrees [0.0, 360.0)")
|
|
MCRF_ARG("sat_shift", "Saturation adjustment [-1.0, 1.0] (default 0.0)")
|
|
MCRF_ARG("lit_shift", "Lightness adjustment [-1.0, 1.0] (default 0.0)")
|
|
MCRF_RETURNS("Texture: New texture with color-shifted pixels")
|
|
MCRF_NOTE("Preserves alpha channel. Skips fully transparent pixels.")
|
|
)},
|
|
{NULL} // Sentinel
|
|
};
|