voxel example

This commit is contained in:
John McCardle 2026-02-05 10:49:31 -05:00
commit 3e6b6a5847
10 changed files with 1691 additions and 2 deletions

598
src/3d/PyVoxelGrid.cpp Normal file
View file

@ -0,0 +1,598 @@
// PyVoxelGrid.cpp - Python bindings for VoxelGrid implementation
// Part of McRogueFace 3D Extension - Milestone 9
#include "PyVoxelGrid.h"
#include "../McRFPy_API.h"
#include "../PyColor.h"
#include <sstream>
// =============================================================================
// Python type interface
// =============================================================================
PyObject* PyVoxelGrid::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) {
PyVoxelGridObject* self = (PyVoxelGridObject*)type->tp_alloc(type, 0);
if (self) {
self->data = nullptr; // Will be initialized in init
self->weakreflist = nullptr;
}
return (PyObject*)self;
}
int PyVoxelGrid::init(PyVoxelGridObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"size", "cell_size", nullptr};
PyObject* size_obj = nullptr;
float cell_size = 1.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast<char**>(kwlist),
&size_obj, &cell_size)) {
return -1;
}
// Parse size tuple
if (!PyTuple_Check(size_obj) && !PyList_Check(size_obj)) {
PyErr_SetString(PyExc_TypeError, "size must be a tuple or list of 3 integers");
return -1;
}
if (PySequence_Size(size_obj) != 3) {
PyErr_SetString(PyExc_ValueError, "size must have exactly 3 elements (width, height, depth)");
return -1;
}
int width = 0, height = 0, depth = 0;
PyObject* w_obj = PySequence_GetItem(size_obj, 0);
PyObject* h_obj = PySequence_GetItem(size_obj, 1);
PyObject* d_obj = PySequence_GetItem(size_obj, 2);
bool valid = true;
if (PyLong_Check(w_obj)) width = (int)PyLong_AsLong(w_obj); else valid = false;
if (PyLong_Check(h_obj)) height = (int)PyLong_AsLong(h_obj); else valid = false;
if (PyLong_Check(d_obj)) depth = (int)PyLong_AsLong(d_obj); else valid = false;
Py_DECREF(w_obj);
Py_DECREF(h_obj);
Py_DECREF(d_obj);
if (!valid) {
PyErr_SetString(PyExc_TypeError, "size elements must be integers");
return -1;
}
if (width <= 0 || height <= 0 || depth <= 0) {
PyErr_SetString(PyExc_ValueError, "size dimensions must be positive");
return -1;
}
if (cell_size <= 0.0f) {
PyErr_SetString(PyExc_ValueError, "cell_size must be positive");
return -1;
}
// Create the VoxelGrid
try {
self->data = std::make_shared<mcrf::VoxelGrid>(width, height, depth, cell_size);
} catch (const std::exception& e) {
PyErr_SetString(PyExc_RuntimeError, e.what());
return -1;
}
return 0;
}
void PyVoxelGrid::dealloc(PyVoxelGridObject* self) {
PyObject_GC_UnTrack(self);
if (self->weakreflist != nullptr) {
PyObject_ClearWeakRefs((PyObject*)self);
}
self->data.reset();
Py_TYPE(self)->tp_free((PyObject*)self);
}
PyObject* PyVoxelGrid::repr(PyObject* obj) {
PyVoxelGridObject* self = (PyVoxelGridObject*)obj;
if (!self->data) {
return PyUnicode_FromString("<VoxelGrid (uninitialized)>");
}
std::ostringstream oss;
oss << "<VoxelGrid " << self->data->width() << "x"
<< self->data->height() << "x" << self->data->depth()
<< " cells=" << self->data->totalVoxels()
<< " materials=" << self->data->materialCount()
<< " non_air=" << self->data->countNonAir() << ">";
return PyUnicode_FromString(oss.str().c_str());
}
// =============================================================================
// Properties - dimensions (read-only)
// =============================================================================
PyObject* PyVoxelGrid::get_size(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return Py_BuildValue("(iii)", self->data->width(), self->data->height(), self->data->depth());
}
PyObject* PyVoxelGrid::get_width(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyLong_FromLong(self->data->width());
}
PyObject* PyVoxelGrid::get_height(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyLong_FromLong(self->data->height());
}
PyObject* PyVoxelGrid::get_depth(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyLong_FromLong(self->data->depth());
}
PyObject* PyVoxelGrid::get_cell_size(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyFloat_FromDouble(self->data->cellSize());
}
PyObject* PyVoxelGrid::get_material_count(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyLong_FromSize_t(self->data->materialCount());
}
// =============================================================================
// Properties - transform (read-write)
// =============================================================================
PyObject* PyVoxelGrid::get_offset(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
mcrf::vec3 offset = self->data->getOffset();
return Py_BuildValue("(fff)", offset.x, offset.y, offset.z);
}
int PyVoxelGrid::set_offset(PyVoxelGridObject* self, PyObject* value, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return -1;
}
if (!PyTuple_Check(value) && !PyList_Check(value)) {
PyErr_SetString(PyExc_TypeError, "offset must be a tuple or list of 3 floats");
return -1;
}
if (PySequence_Size(value) != 3) {
PyErr_SetString(PyExc_ValueError, "offset must have exactly 3 elements (x, y, z)");
return -1;
}
float x = 0, y = 0, z = 0;
PyObject* x_obj = PySequence_GetItem(value, 0);
PyObject* y_obj = PySequence_GetItem(value, 1);
PyObject* z_obj = PySequence_GetItem(value, 2);
bool valid = true;
if (PyNumber_Check(x_obj)) x = (float)PyFloat_AsDouble(PyNumber_Float(x_obj)); else valid = false;
if (PyNumber_Check(y_obj)) y = (float)PyFloat_AsDouble(PyNumber_Float(y_obj)); else valid = false;
if (PyNumber_Check(z_obj)) z = (float)PyFloat_AsDouble(PyNumber_Float(z_obj)); else valid = false;
Py_DECREF(x_obj);
Py_DECREF(y_obj);
Py_DECREF(z_obj);
if (!valid) {
PyErr_SetString(PyExc_TypeError, "offset elements must be numbers");
return -1;
}
self->data->setOffset(x, y, z);
return 0;
}
PyObject* PyVoxelGrid::get_rotation(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyFloat_FromDouble(self->data->getRotation());
}
int PyVoxelGrid::set_rotation(PyVoxelGridObject* self, PyObject* value, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return -1;
}
if (!PyNumber_Check(value)) {
PyErr_SetString(PyExc_TypeError, "rotation must be a number");
return -1;
}
float rotation = (float)PyFloat_AsDouble(PyNumber_Float(value));
self->data->setRotation(rotation);
return 0;
}
// =============================================================================
// Voxel access methods
// =============================================================================
PyObject* PyVoxelGrid::get(PyVoxelGridObject* self, PyObject* args) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
int x, y, z;
if (!PyArg_ParseTuple(args, "iii", &x, &y, &z)) {
return nullptr;
}
// Bounds check with warning (returns 0 for out-of-bounds, like C++ API)
if (!self->data->isValid(x, y, z)) {
// Return 0 (air) for out-of-bounds, matching C++ behavior
return PyLong_FromLong(0);
}
return PyLong_FromLong(self->data->get(x, y, z));
}
PyObject* PyVoxelGrid::set(PyVoxelGridObject* self, PyObject* args) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
int x, y, z, material;
if (!PyArg_ParseTuple(args, "iiii", &x, &y, &z, &material)) {
return nullptr;
}
if (material < 0 || material > 255) {
PyErr_SetString(PyExc_ValueError, "material must be 0-255");
return nullptr;
}
// Bounds check - silently ignore out-of-bounds, like C++ API
self->data->set(x, y, z, static_cast<uint8_t>(material));
Py_RETURN_NONE;
}
// =============================================================================
// Material methods
// =============================================================================
PyObject* PyVoxelGrid::add_material(PyVoxelGridObject* self, PyObject* args, PyObject* kwds) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
static const char* kwlist[] = {"name", "color", "sprite_index", "transparent", "path_cost", nullptr};
const char* name = nullptr;
PyObject* color_obj = nullptr;
int sprite_index = -1;
int transparent = 0; // Python bool maps to int
float path_cost = 1.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|Oipf", const_cast<char**>(kwlist),
&name, &color_obj, &sprite_index, &transparent, &path_cost)) {
return nullptr;
}
// Default color is white
sf::Color color = sf::Color::White;
// Parse color if provided
if (color_obj && color_obj != Py_None) {
color = PyColor::fromPy(color_obj);
if (PyErr_Occurred()) {
return nullptr;
}
}
try {
uint8_t id = self->data->addMaterial(name, color, sprite_index, transparent != 0, path_cost);
return PyLong_FromLong(id);
} catch (const std::exception& e) {
PyErr_SetString(PyExc_RuntimeError, e.what());
return nullptr;
}
}
PyObject* PyVoxelGrid::get_material(PyVoxelGridObject* self, PyObject* args) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
int id;
if (!PyArg_ParseTuple(args, "i", &id)) {
return nullptr;
}
if (id < 0 || id > 255) {
PyErr_SetString(PyExc_ValueError, "material id must be 0-255");
return nullptr;
}
const mcrf::VoxelMaterial& mat = self->data->getMaterial(static_cast<uint8_t>(id));
// Create color object
PyObject* color_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color");
if (!color_type) {
return nullptr;
}
PyObject* color_obj = PyObject_Call(color_type,
Py_BuildValue("(iiii)", mat.color.r, mat.color.g, mat.color.b, mat.color.a),
nullptr);
Py_DECREF(color_type);
if (!color_obj) {
return nullptr;
}
// Build result dict
PyObject* result = Py_BuildValue("{s:s, s:N, s:i, s:O, s:f}",
"name", mat.name.c_str(),
"color", color_obj,
"sprite_index", mat.spriteIndex,
"transparent", mat.transparent ? Py_True : Py_False,
"path_cost", mat.pathCost);
return result;
}
// =============================================================================
// Bulk operations
// =============================================================================
PyObject* PyVoxelGrid::fill(PyVoxelGridObject* self, PyObject* args) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
int material;
if (!PyArg_ParseTuple(args, "i", &material)) {
return nullptr;
}
if (material < 0 || material > 255) {
PyErr_SetString(PyExc_ValueError, "material must be 0-255");
return nullptr;
}
self->data->fill(static_cast<uint8_t>(material));
Py_RETURN_NONE;
}
PyObject* PyVoxelGrid::clear(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
self->data->clear();
Py_RETURN_NONE;
}
PyObject* PyVoxelGrid::fill_box(PyVoxelGridObject* self, PyObject* args) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
PyObject* min_obj = nullptr;
PyObject* max_obj = nullptr;
int material;
if (!PyArg_ParseTuple(args, "OOi", &min_obj, &max_obj, &material)) {
return nullptr;
}
if (material < 0 || material > 255) {
PyErr_SetString(PyExc_ValueError, "material must be 0-255");
return nullptr;
}
// Parse min tuple (x0, y0, z0)
if (!PyTuple_Check(min_obj) && !PyList_Check(min_obj)) {
PyErr_SetString(PyExc_TypeError, "min_coord must be a tuple or list of 3 integers");
return nullptr;
}
if (PySequence_Size(min_obj) != 3) {
PyErr_SetString(PyExc_ValueError, "min_coord must have exactly 3 elements");
return nullptr;
}
// Parse max tuple (x1, y1, z1)
if (!PyTuple_Check(max_obj) && !PyList_Check(max_obj)) {
PyErr_SetString(PyExc_TypeError, "max_coord must be a tuple or list of 3 integers");
return nullptr;
}
if (PySequence_Size(max_obj) != 3) {
PyErr_SetString(PyExc_ValueError, "max_coord must have exactly 3 elements");
return nullptr;
}
int x0, y0, z0, x1, y1, z1;
PyObject* items[6];
items[0] = PySequence_GetItem(min_obj, 0);
items[1] = PySequence_GetItem(min_obj, 1);
items[2] = PySequence_GetItem(min_obj, 2);
items[3] = PySequence_GetItem(max_obj, 0);
items[4] = PySequence_GetItem(max_obj, 1);
items[5] = PySequence_GetItem(max_obj, 2);
bool valid = true;
if (PyLong_Check(items[0])) x0 = (int)PyLong_AsLong(items[0]); else valid = false;
if (PyLong_Check(items[1])) y0 = (int)PyLong_AsLong(items[1]); else valid = false;
if (PyLong_Check(items[2])) z0 = (int)PyLong_AsLong(items[2]); else valid = false;
if (PyLong_Check(items[3])) x1 = (int)PyLong_AsLong(items[3]); else valid = false;
if (PyLong_Check(items[4])) y1 = (int)PyLong_AsLong(items[4]); else valid = false;
if (PyLong_Check(items[5])) z1 = (int)PyLong_AsLong(items[5]); else valid = false;
for (int i = 0; i < 6; i++) Py_DECREF(items[i]);
if (!valid) {
PyErr_SetString(PyExc_TypeError, "coordinate elements must be integers");
return nullptr;
}
self->data->fillBox(x0, y0, z0, x1, y1, z1, static_cast<uint8_t>(material));
Py_RETURN_NONE;
}
// =============================================================================
// Mesh caching methods (Milestone 10)
// =============================================================================
PyObject* PyVoxelGrid::get_vertex_count(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyLong_FromSize_t(self->data->vertexCount());
}
PyObject* PyVoxelGrid::rebuild_mesh(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
self->data->rebuildMesh();
Py_RETURN_NONE;
}
// =============================================================================
// Statistics
// =============================================================================
PyObject* PyVoxelGrid::count_non_air(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyLong_FromSize_t(self->data->countNonAir());
}
PyObject* PyVoxelGrid::count_material(PyVoxelGridObject* self, PyObject* args) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
int material;
if (!PyArg_ParseTuple(args, "i", &material)) {
return nullptr;
}
if (material < 0 || material > 255) {
PyErr_SetString(PyExc_ValueError, "material must be 0-255");
return nullptr;
}
return PyLong_FromSize_t(self->data->countMaterial(static_cast<uint8_t>(material)));
}
// =============================================================================
// Method definitions
// =============================================================================
PyMethodDef PyVoxelGrid::methods[] = {
{"get", (PyCFunction)get, METH_VARARGS,
"get(x, y, z) -> int\n\n"
"Get the material ID at integer coordinates.\n\n"
"Returns 0 (air) for out-of-bounds coordinates."},
{"set", (PyCFunction)set, METH_VARARGS,
"set(x, y, z, material) -> None\n\n"
"Set the material ID at integer coordinates.\n\n"
"Out-of-bounds coordinates are silently ignored."},
{"add_material", (PyCFunction)add_material, METH_VARARGS | METH_KEYWORDS,
"add_material(name, color=Color(255,255,255), sprite_index=-1, transparent=False, path_cost=1.0) -> int\n\n"
"Add a new material to the palette. Returns the material ID (1-indexed).\n\n"
"Material 0 is always air (implicit, never stored in palette).\n"
"Maximum 255 materials can be added."},
{"get_material", (PyCFunction)get_material, METH_VARARGS,
"get_material(id) -> dict\n\n"
"Get material properties by ID.\n\n"
"Returns dict with keys: name, color, sprite_index, transparent, path_cost.\n"
"ID 0 returns the implicit air material."},
{"fill", (PyCFunction)fill, METH_VARARGS,
"fill(material) -> None\n\n"
"Fill the entire grid with the specified material ID."},
{"fill_box", (PyCFunction)fill_box, METH_VARARGS,
"fill_box(min_coord, max_coord, material) -> None\n\n"
"Fill a rectangular region with the specified material.\n\n"
"Args:\n"
" min_coord: (x0, y0, z0) - minimum corner (inclusive)\n"
" max_coord: (x1, y1, z1) - maximum corner (inclusive)\n"
" material: material ID (0-255)\n\n"
"Coordinates are clamped to grid bounds."},
{"clear", (PyCFunction)clear, METH_NOARGS,
"clear() -> None\n\n"
"Clear the grid (fill with air, material 0)."},
{"rebuild_mesh", (PyCFunction)rebuild_mesh, METH_NOARGS,
"rebuild_mesh() -> None\n\n"
"Force immediate mesh rebuild for rendering."},
{"count_non_air", (PyCFunction)count_non_air, METH_NOARGS,
"count_non_air() -> int\n\n"
"Count the number of non-air voxels in the grid."},
{"count_material", (PyCFunction)count_material, METH_VARARGS,
"count_material(material) -> int\n\n"
"Count the number of voxels with the specified material ID."},
{nullptr} // Sentinel
};
// =============================================================================
// Property definitions
// =============================================================================
PyGetSetDef PyVoxelGrid::getsetters[] = {
{"size", (getter)get_size, nullptr,
"Dimensions (width, height, depth) of the grid. Read-only.", nullptr},
{"width", (getter)get_width, nullptr,
"Grid width (X dimension). Read-only.", nullptr},
{"height", (getter)get_height, nullptr,
"Grid height (Y dimension). Read-only.", nullptr},
{"depth", (getter)get_depth, nullptr,
"Grid depth (Z dimension). Read-only.", nullptr},
{"cell_size", (getter)get_cell_size, nullptr,
"World units per voxel. Read-only.", nullptr},
{"material_count", (getter)get_material_count, nullptr,
"Number of materials in the palette. Read-only.", nullptr},
{"vertex_count", (getter)get_vertex_count, nullptr,
"Number of vertices after mesh generation. Read-only.", nullptr},
{"offset", (getter)get_offset, (setter)set_offset,
"World-space position (x, y, z) of the grid origin.", nullptr},
{"rotation", (getter)get_rotation, (setter)set_rotation,
"Y-axis rotation in degrees.", nullptr},
{nullptr} // Sentinel
};

125
src/3d/PyVoxelGrid.h Normal file
View file

@ -0,0 +1,125 @@
// PyVoxelGrid.h - Python bindings for VoxelGrid
// Part of McRogueFace 3D Extension - Milestone 9
#pragma once
#include "../Common.h"
#include "Python.h"
#include "VoxelGrid.h"
#include <memory>
// Forward declaration
class PyVoxelGrid;
// =============================================================================
// Python object structure
// =============================================================================
typedef struct PyVoxelGridObject {
PyObject_HEAD
std::shared_ptr<mcrf::VoxelGrid> data;
PyObject* weakreflist;
} PyVoxelGridObject;
// =============================================================================
// Python binding class
// =============================================================================
class PyVoxelGrid {
public:
// Python type interface
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
static int init(PyVoxelGridObject* self, PyObject* args, PyObject* kwds);
static void dealloc(PyVoxelGridObject* self);
static PyObject* repr(PyObject* obj);
// Properties - dimensions (read-only)
static PyObject* get_size(PyVoxelGridObject* self, void* closure);
static PyObject* get_width(PyVoxelGridObject* self, void* closure);
static PyObject* get_height(PyVoxelGridObject* self, void* closure);
static PyObject* get_depth(PyVoxelGridObject* self, void* closure);
static PyObject* get_cell_size(PyVoxelGridObject* self, void* closure);
static PyObject* get_material_count(PyVoxelGridObject* self, void* closure);
// Properties - transform (read-write)
static PyObject* get_offset(PyVoxelGridObject* self, void* closure);
static int set_offset(PyVoxelGridObject* self, PyObject* value, void* closure);
static PyObject* get_rotation(PyVoxelGridObject* self, void* closure);
static int set_rotation(PyVoxelGridObject* self, PyObject* value, void* closure);
// Voxel access methods
static PyObject* get(PyVoxelGridObject* self, PyObject* args);
static PyObject* set(PyVoxelGridObject* self, PyObject* args);
// Material methods
static PyObject* add_material(PyVoxelGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* get_material(PyVoxelGridObject* self, PyObject* args);
// Bulk operations
static PyObject* fill(PyVoxelGridObject* self, PyObject* args);
static PyObject* fill_box(PyVoxelGridObject* self, PyObject* args);
static PyObject* clear(PyVoxelGridObject* self, PyObject* Py_UNUSED(args));
// Mesh caching (Milestone 10)
static PyObject* get_vertex_count(PyVoxelGridObject* self, void* closure);
static PyObject* rebuild_mesh(PyVoxelGridObject* self, PyObject* Py_UNUSED(args));
// Statistics
static PyObject* count_non_air(PyVoxelGridObject* self, PyObject* Py_UNUSED(args));
static PyObject* count_material(PyVoxelGridObject* self, PyObject* args);
// Type registration
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
};
// =============================================================================
// Python type definition
// =============================================================================
namespace mcrfpydef {
inline PyTypeObject PyVoxelGridType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.VoxelGrid",
.tp_basicsize = sizeof(PyVoxelGridObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyVoxelGrid::dealloc,
.tp_repr = PyVoxelGrid::repr,
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,
.tp_doc = PyDoc_STR(
"VoxelGrid(size: tuple[int, int, int], cell_size: float = 1.0)\n\n"
"A dense 3D grid of voxel material IDs with a material palette.\n\n"
"VoxelGrids provide volumetric storage for 3D structures like buildings,\n"
"caves, and dungeon walls. Each cell stores a uint8 material ID (0-255),\n"
"where 0 is always air.\n\n"
"Args:\n"
" size: (width, height, depth) dimensions. Immutable after creation.\n"
" cell_size: World units per voxel. Default 1.0.\n\n"
"Properties:\n"
" size (tuple, read-only): Grid dimensions as (width, height, depth)\n"
" width, height, depth (int, read-only): Individual dimensions\n"
" cell_size (float, read-only): World units per voxel\n"
" offset (tuple): World-space position (x, y, z)\n"
" rotation (float): Y-axis rotation in degrees\n"
" material_count (int, read-only): Number of defined materials\n\n"
"Example:\n"
" voxels = mcrfpy.VoxelGrid(size=(16, 8, 16), cell_size=1.0)\n"
" stone = voxels.add_material('stone', color=mcrfpy.Color(128, 128, 128))\n"
" voxels.set(5, 0, 5, stone)\n"
" assert voxels.get(5, 0, 5) == stone\n"
" print(f'Non-air voxels: {voxels.count_non_air()}')"
),
.tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int {
return 0;
},
.tp_clear = [](PyObject* self) -> int {
return 0;
},
.tp_weaklistoffset = offsetof(PyVoxelGridObject, weakreflist),
.tp_methods = nullptr, // Set before PyType_Ready
.tp_getset = nullptr, // Set before PyType_Ready
.tp_init = (initproc)PyVoxelGrid::init,
.tp_new = PyVoxelGrid::pynew,
};
} // namespace mcrfpydef

View file

@ -7,6 +7,8 @@
#include "EntityCollection3D.h"
#include "Billboard.h"
#include "Model3D.h"
#include "VoxelGrid.h"
#include "PyVoxelGrid.h"
#include "../platform/GLContext.h"
#include "PyVector.h"
#include "PyColor.h"
@ -62,6 +64,15 @@ Viewport3D::Viewport3D(float x, float y, float width, float height)
Viewport3D::~Viewport3D() {
cleanupTestGeometry();
cleanupFBO();
// Clean up voxel VBO (Milestone 10)
#ifdef MCRF_HAS_GL
if (voxelVBO_ != 0) {
glDeleteBuffers(1, &voxelVBO_);
voxelVBO_ = 0;
}
#endif
if (tcodMap_) {
delete tcodMap_;
tcodMap_ = nullptr;
@ -836,6 +847,130 @@ void Viewport3D::renderMeshLayers() {
#endif
}
// =============================================================================
// Voxel Layer Management (Milestone 10)
// =============================================================================
void Viewport3D::addVoxelLayer(std::shared_ptr<VoxelGrid> grid, int zIndex) {
if (!grid) return;
voxelLayers_.push_back({grid, zIndex});
// Disable test cube when real content is added
renderTestCube_ = false;
}
bool Viewport3D::removeVoxelLayer(std::shared_ptr<VoxelGrid> grid) {
if (!grid) return false;
auto it = std::find_if(voxelLayers_.begin(), voxelLayers_.end(),
[&grid](const auto& pair) { return pair.first == grid; });
if (it != voxelLayers_.end()) {
voxelLayers_.erase(it);
return true;
}
return false;
}
void Viewport3D::renderVoxelLayers(const mat4& view, const mat4& proj) {
#ifdef MCRF_HAS_GL
if (voxelLayers_.empty() || !shader_ || !shader_->isValid()) {
return;
}
// Sort layers by z_index (lower = rendered first)
std::vector<std::pair<VoxelGrid*, int>> sortedLayers;
sortedLayers.reserve(voxelLayers_.size());
for (auto& pair : voxelLayers_) {
if (pair.first) {
sortedLayers.push_back({pair.first.get(), pair.second});
}
}
std::sort(sortedLayers.begin(), sortedLayers.end(),
[](const auto& a, const auto& b) { return a.second < b.second; });
shader_->bind();
// Set up view and projection matrices
shader_->setUniform("u_view", view);
shader_->setUniform("u_projection", proj);
// PS1 effect uniforms
shader_->setUniform("u_resolution", vec2(static_cast<float>(internalWidth_),
static_cast<float>(internalHeight_)));
shader_->setUniform("u_enable_snap", vertexSnapEnabled_);
shader_->setUniform("u_enable_dither", ditheringEnabled_);
// Lighting
vec3 lightDir = vec3(0.5f, -0.7f, 0.5f).normalized();
shader_->setUniform("u_light_dir", lightDir);
shader_->setUniform("u_ambient", vec3(0.3f, 0.3f, 0.3f));
// Fog
shader_->setUniform("u_fog_start", fogNear_);
shader_->setUniform("u_fog_end", fogFar_);
shader_->setUniform("u_fog_color", fogColor_);
// No texture for voxels (use vertex colors)
shader_->setUniform("u_has_texture", false);
// Create VBO if needed
if (voxelVBO_ == 0) {
glGenBuffers(1, &voxelVBO_);
}
// Render each voxel grid
for (auto& pair : sortedLayers) {
VoxelGrid* grid = pair.first;
// Get vertices (triggers rebuild if dirty)
const std::vector<MeshVertex>& vertices = grid->getVertices();
if (vertices.empty()) continue;
// Set model matrix for this grid
shader_->setUniform("u_model", grid->getModelMatrix());
// Upload vertices to VBO
glBindBuffer(GL_ARRAY_BUFFER, voxelVBO_);
glBufferData(GL_ARRAY_BUFFER,
vertices.size() * sizeof(MeshVertex),
vertices.data(),
GL_DYNAMIC_DRAW);
// Set up vertex attributes (same as MeshLayer)
size_t stride = sizeof(MeshVertex);
// Position
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)offsetof(MeshVertex, position));
// TexCoord
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, stride, (void*)offsetof(MeshVertex, texcoord));
// Normal
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride, (void*)offsetof(MeshVertex, normal));
// Color
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, stride, (void*)offsetof(MeshVertex, color));
// Draw
glDrawArrays(GL_TRIANGLES, 0, static_cast<GLsizei>(vertices.size()));
// Cleanup
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);
glDisableVertexAttribArray(3);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}
shader_->unbind();
#endif
}
void Viewport3D::render3DContent() {
// GL not available in current backend - skip 3D rendering
if (!gl::isGLReady() || fbo_ == 0) {
@ -879,9 +1014,12 @@ void Viewport3D::render3DContent() {
// Render mesh layers first (terrain, etc.) - sorted by z_index
renderMeshLayers();
// Render entities
// Render voxel layers (Milestone 10)
mat4 view = camera_.getViewMatrix();
mat4 projection = camera_.getProjectionMatrix();
renderVoxelLayers(view, projection);
// Render entities
renderEntities(view, projection);
// Render billboards (after opaque geometry for proper transparency)
@ -2262,6 +2400,81 @@ static PyObject* Viewport3D_follow(PyViewport3DObject* self, PyObject* args, PyO
Py_RETURN_NONE;
}
// =============================================================================
// Voxel Layer Methods (Milestone 10)
// =============================================================================
static PyObject* Viewport3D_add_voxel_layer(PyViewport3DObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"voxel_grid", "z_index", NULL};
PyObject* voxel_grid_obj = nullptr;
int z_index = 0;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|i", const_cast<char**>(kwlist),
&voxel_grid_obj, &z_index)) {
return NULL;
}
// Check if it's a VoxelGrid object
PyTypeObject* voxelGridType = (PyTypeObject*)PyObject_GetAttrString(
McRFPy_API::mcrf_module, "VoxelGrid");
if (!voxelGridType) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid type not found");
return NULL;
}
if (!PyObject_IsInstance(voxel_grid_obj, (PyObject*)voxelGridType)) {
Py_DECREF(voxelGridType);
PyErr_SetString(PyExc_TypeError, "voxel_grid must be a VoxelGrid object");
return NULL;
}
Py_DECREF(voxelGridType);
PyVoxelGridObject* vg = (PyVoxelGridObject*)voxel_grid_obj;
if (!vg->data) {
PyErr_SetString(PyExc_ValueError, "VoxelGrid not initialized");
return NULL;
}
self->data->addVoxelLayer(vg->data, z_index);
Py_RETURN_NONE;
}
static PyObject* Viewport3D_remove_voxel_layer(PyViewport3DObject* self, PyObject* args) {
PyObject* voxel_grid_obj = nullptr;
if (!PyArg_ParseTuple(args, "O", &voxel_grid_obj)) {
return NULL;
}
// Check if it's a VoxelGrid object
PyTypeObject* voxelGridType = (PyTypeObject*)PyObject_GetAttrString(
McRFPy_API::mcrf_module, "VoxelGrid");
if (!voxelGridType) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid type not found");
return NULL;
}
if (!PyObject_IsInstance(voxel_grid_obj, (PyObject*)voxelGridType)) {
Py_DECREF(voxelGridType);
PyErr_SetString(PyExc_TypeError, "voxel_grid must be a VoxelGrid object");
return NULL;
}
Py_DECREF(voxelGridType);
PyVoxelGridObject* vg = (PyVoxelGridObject*)voxel_grid_obj;
if (!vg->data) {
PyErr_SetString(PyExc_ValueError, "VoxelGrid not initialized");
return NULL;
}
bool removed = self->data->removeVoxelLayer(vg->data);
return PyBool_FromLong(removed);
}
static PyObject* Viewport3D_voxel_layer_count(PyViewport3DObject* self, PyObject* Py_UNUSED(args)) {
return PyLong_FromSize_t(self->data->getVoxelLayerCount());
}
} // namespace mcrf
// Methods array - outside namespace but PyObjectType still in scope via typedef
@ -2441,5 +2654,25 @@ PyMethodDef Viewport3D_methods[] = {
" distance: Distance behind entity\n"
" height: Camera height above entity\n"
" smoothing: Interpolation factor (0-1). 1 = instant, lower = smoother"},
// Voxel layer methods (Milestone 10)
{"add_voxel_layer", (PyCFunction)mcrf::Viewport3D_add_voxel_layer, METH_VARARGS | METH_KEYWORDS,
"add_voxel_layer(voxel_grid, z_index=0)\n\n"
"Add a VoxelGrid as a renderable layer.\n\n"
"Args:\n"
" voxel_grid: VoxelGrid object to render\n"
" z_index: Render order (lower = rendered first)"},
{"remove_voxel_layer", (PyCFunction)mcrf::Viewport3D_remove_voxel_layer, METH_VARARGS,
"remove_voxel_layer(voxel_grid) -> bool\n\n"
"Remove a VoxelGrid layer from the viewport.\n\n"
"Args:\n"
" voxel_grid: VoxelGrid object to remove\n\n"
"Returns:\n"
" True if the layer was found and removed"},
{"voxel_layer_count", (PyCFunction)mcrf::Viewport3D_voxel_layer_count, METH_NOARGS,
"voxel_layer_count() -> int\n\n"
"Get the number of voxel layers.\n\n"
"Returns:\n"
" Number of voxel layers in the viewport"},
{NULL} // Sentinel
};

View file

@ -31,6 +31,7 @@ class Viewport3D;
class Shader3D;
class MeshLayer;
class Billboard;
class VoxelGrid;
} // namespace mcrf
@ -226,6 +227,29 @@ public:
/// Render all billboards
void renderBillboards(const mat4& view, const mat4& proj);
// =========================================================================
// VoxelGrid Layer Management (Milestone 10)
// =========================================================================
/// Add a voxel layer to the viewport
/// @param grid The VoxelGrid to add
/// @param zIndex Render order (lower = rendered first, behind higher values)
void addVoxelLayer(std::shared_ptr<VoxelGrid> grid, int zIndex = 0);
/// Remove a voxel layer from the viewport
/// @param grid The VoxelGrid to remove
/// @return true if the layer was found and removed
bool removeVoxelLayer(std::shared_ptr<VoxelGrid> grid);
/// Get all voxel layers (read-only)
const std::vector<std::pair<std::shared_ptr<VoxelGrid>, int>>& getVoxelLayers() const { return voxelLayers_; }
/// Get number of voxel layers
size_t getVoxelLayerCount() const { return voxelLayers_.size(); }
/// Render all voxel layers
void renderVoxelLayers(const mat4& view, const mat4& proj);
// Background color
void setBackgroundColor(const sf::Color& color) { bgColor_ = color; }
sf::Color getBackgroundColor() const { return bgColor_; }
@ -319,6 +343,11 @@ private:
// Billboard storage
std::shared_ptr<std::vector<std::shared_ptr<Billboard>>> billboards_;
// Voxel layer storage (Milestone 10)
// Pairs of (VoxelGrid, z_index) for render ordering
std::vector<std::pair<std::shared_ptr<VoxelGrid>, int>> voxelLayers_;
unsigned int voxelVBO_ = 0; // Shared VBO for voxel rendering
// Shader for PS1-style rendering
std::unique_ptr<Shader3D> shader_;
std::unique_ptr<Shader3D> skinnedShader_; // For skeletal animation

171
src/3d/VoxelGrid.cpp Normal file
View file

@ -0,0 +1,171 @@
// VoxelGrid.cpp - Dense 3D voxel array implementation
// Part of McRogueFace 3D Extension - Milestones 9-10
#include "VoxelGrid.h"
#include "VoxelMesher.h"
#include "MeshLayer.h" // For MeshVertex
namespace mcrf {
// Static air material for out-of-bounds or ID=0 queries
static VoxelMaterial airMaterial{"air", sf::Color::Transparent, -1, true, 0.0f};
// =============================================================================
// Constructor
// =============================================================================
VoxelGrid::VoxelGrid(int w, int h, int d, float cellSize)
: width_(w), height_(h), depth_(d), cellSize_(cellSize),
offset_(0, 0, 0), rotation_(0.0f)
{
if (w <= 0 || h <= 0 || d <= 0) {
throw std::invalid_argument("VoxelGrid dimensions must be positive");
}
if (cellSize <= 0.0f) {
throw std::invalid_argument("VoxelGrid cell size must be positive");
}
// Allocate dense array, initialized to air (0)
size_t totalSize = static_cast<size_t>(w) * h * d;
data_.resize(totalSize, 0);
}
// =============================================================================
// Per-voxel access
// =============================================================================
bool VoxelGrid::isValid(int x, int y, int z) const {
return x >= 0 && x < width_ &&
y >= 0 && y < height_ &&
z >= 0 && z < depth_;
}
uint8_t VoxelGrid::get(int x, int y, int z) const {
if (!isValid(x, y, z)) {
return 0; // Out of bounds returns air
}
return data_[index(x, y, z)];
}
void VoxelGrid::set(int x, int y, int z, uint8_t material) {
if (!isValid(x, y, z)) {
return; // Out of bounds is no-op
}
data_[index(x, y, z)] = material;
meshDirty_ = true;
}
// =============================================================================
// Material palette
// =============================================================================
uint8_t VoxelGrid::addMaterial(const VoxelMaterial& mat) {
if (materials_.size() >= 255) {
throw std::runtime_error("Material palette full (max 255 materials)");
}
materials_.push_back(mat);
return static_cast<uint8_t>(materials_.size()); // 1-indexed
}
uint8_t VoxelGrid::addMaterial(const std::string& name, sf::Color color,
int spriteIndex, bool transparent, float pathCost) {
return addMaterial(VoxelMaterial(name, color, spriteIndex, transparent, pathCost));
}
const VoxelMaterial& VoxelGrid::getMaterial(uint8_t id) const {
if (id == 0 || id > materials_.size()) {
return airMaterial;
}
return materials_[id - 1]; // 1-indexed, so ID 1 = materials_[0]
}
// =============================================================================
// Bulk operations
// =============================================================================
void VoxelGrid::fill(uint8_t material) {
std::fill(data_.begin(), data_.end(), material);
meshDirty_ = true;
}
// =============================================================================
// Transform
// =============================================================================
mat4 VoxelGrid::getModelMatrix() const {
// Apply translation first, then rotation around Y axis
mat4 translation = mat4::translate(offset_);
mat4 rotation = mat4::rotateY(rotation_ * DEG_TO_RAD);
return translation * rotation;
}
// =============================================================================
// Statistics
// =============================================================================
size_t VoxelGrid::countNonAir() const {
size_t count = 0;
for (uint8_t v : data_) {
if (v != 0) {
count++;
}
}
return count;
}
size_t VoxelGrid::countMaterial(uint8_t material) const {
size_t count = 0;
for (uint8_t v : data_) {
if (v == material) {
count++;
}
}
return count;
}
// =============================================================================
// fillBox (Milestone 10)
// =============================================================================
void VoxelGrid::fillBox(int x0, int y0, int z0, int x1, int y1, int z1, uint8_t material) {
// Ensure proper ordering (min to max)
if (x0 > x1) std::swap(x0, x1);
if (y0 > y1) std::swap(y0, y1);
if (z0 > z1) std::swap(z0, z1);
// Clamp to valid range
x0 = std::max(0, std::min(x0, width_ - 1));
x1 = std::max(0, std::min(x1, width_ - 1));
y0 = std::max(0, std::min(y0, height_ - 1));
y1 = std::max(0, std::min(y1, height_ - 1));
z0 = std::max(0, std::min(z0, depth_ - 1));
z1 = std::max(0, std::min(z1, depth_ - 1));
for (int z = z0; z <= z1; z++) {
for (int y = y0; y <= y1; y++) {
for (int x = x0; x <= x1; x++) {
data_[index(x, y, z)] = material;
}
}
}
meshDirty_ = true;
}
// =============================================================================
// Mesh Caching (Milestone 10)
// =============================================================================
const std::vector<MeshVertex>& VoxelGrid::getVertices() const {
if (meshDirty_) {
rebuildMesh();
}
return cachedVertices_;
}
void VoxelGrid::rebuildMesh() const {
cachedVertices_.clear();
VoxelMesher::generateMesh(*this, cachedVertices_);
meshDirty_ = false;
}
} // namespace mcrf

121
src/3d/VoxelGrid.h Normal file
View file

@ -0,0 +1,121 @@
// VoxelGrid.h - Dense 3D voxel array with material palette
// Part of McRogueFace 3D Extension - Milestones 9-10
#pragma once
#include "../Common.h"
#include "Math3D.h"
#include "MeshLayer.h" // For MeshVertex (needed for std::vector<MeshVertex>)
#include <vector>
#include <string>
#include <stdexcept>
namespace mcrf {
// =============================================================================
// VoxelMaterial - Properties for a voxel material type
// =============================================================================
struct VoxelMaterial {
std::string name;
sf::Color color; // Fallback solid color
int spriteIndex = -1; // Texture atlas index (-1 = use color)
bool transparent = false; // For FOV projection and face culling
float pathCost = 1.0f; // Navigation cost multiplier (0 = impassable)
VoxelMaterial() : name("unnamed"), color(sf::Color::White) {}
VoxelMaterial(const std::string& n, sf::Color c, int sprite = -1,
bool transp = false, float cost = 1.0f)
: name(n), color(c), spriteIndex(sprite), transparent(transp), pathCost(cost) {}
};
// =============================================================================
// VoxelGrid - Dense 3D array of material IDs
// =============================================================================
class VoxelGrid {
private:
int width_, height_, depth_;
float cellSize_;
std::vector<uint8_t> data_; // Material ID per cell (0 = air)
std::vector<VoxelMaterial> materials_;
// Transform
vec3 offset_;
float rotation_ = 0.0f; // Y-axis only, degrees
// Mesh caching (Milestone 10)
mutable bool meshDirty_ = true;
mutable std::vector<MeshVertex> cachedVertices_;
// Index calculation (row-major: X varies fastest, then Y, then Z)
inline size_t index(int x, int y, int z) const {
return static_cast<size_t>(z) * (width_ * height_) +
static_cast<size_t>(y) * width_ +
static_cast<size_t>(x);
}
public:
// Constructor
VoxelGrid(int w, int h, int d, float cellSize = 1.0f);
// Dimensions (read-only)
int width() const { return width_; }
int height() const { return height_; }
int depth() const { return depth_; }
float cellSize() const { return cellSize_; }
size_t totalVoxels() const { return static_cast<size_t>(width_) * height_ * depth_; }
// Per-voxel access
uint8_t get(int x, int y, int z) const;
void set(int x, int y, int z, uint8_t material);
bool isValid(int x, int y, int z) const;
// Material palette
// Returns 1-indexed material ID (0 = air, always implicit)
uint8_t addMaterial(const VoxelMaterial& mat);
uint8_t addMaterial(const std::string& name, sf::Color color,
int spriteIndex = -1, bool transparent = false,
float pathCost = 1.0f);
const VoxelMaterial& getMaterial(uint8_t id) const;
size_t materialCount() const { return materials_.size(); }
// Bulk operations
void fill(uint8_t material);
void clear() { fill(0); }
void fillBox(int x0, int y0, int z0, int x1, int y1, int z1, uint8_t material);
// Transform
void setOffset(const vec3& offset) { offset_ = offset; }
void setOffset(float x, float y, float z) { offset_ = vec3(x, y, z); }
vec3 getOffset() const { return offset_; }
void setRotation(float degrees) { rotation_ = degrees; }
float getRotation() const { return rotation_; }
mat4 getModelMatrix() const;
// Statistics
size_t countNonAir() const;
size_t countMaterial(uint8_t material) const;
// Mesh caching (Milestone 10)
/// Mark mesh as needing rebuild (called automatically by set/fill operations)
void markDirty() { meshDirty_ = true; }
/// Check if mesh needs rebuild
bool isMeshDirty() const { return meshDirty_; }
/// Get vertices for rendering (rebuilds mesh if dirty)
const std::vector<MeshVertex>& getVertices() const;
/// Force immediate mesh rebuild
void rebuildMesh() const;
/// Get vertex count after mesh generation
size_t vertexCount() const { return cachedVertices_.size(); }
// Memory info (for debugging)
size_t memoryUsageBytes() const {
return data_.size() + materials_.size() * sizeof(VoxelMaterial);
}
};
} // namespace mcrf

136
src/3d/VoxelMesher.cpp Normal file
View file

@ -0,0 +1,136 @@
// VoxelMesher.cpp - Face-culled mesh generation for VoxelGrid
// Part of McRogueFace 3D Extension - Milestone 10
#include "VoxelMesher.h"
#include <cmath>
namespace mcrf {
void VoxelMesher::generateMesh(const VoxelGrid& grid, std::vector<MeshVertex>& outVertices) {
const float cs = grid.cellSize();
for (int z = 0; z < grid.depth(); z++) {
for (int y = 0; y < grid.height(); y++) {
for (int x = 0; x < grid.width(); x++) {
uint8_t mat = grid.get(x, y, z);
if (mat == 0) continue; // Skip air
const VoxelMaterial& material = grid.getMaterial(mat);
// Voxel center in local space
vec3 center((x + 0.5f) * cs, (y + 0.5f) * cs, (z + 0.5f) * cs);
// Check each face direction
// +X face
if (shouldGenerateFace(grid, x, y, z, x + 1, y, z)) {
emitFace(outVertices, center, vec3(1, 0, 0), cs, material);
}
// -X face
if (shouldGenerateFace(grid, x, y, z, x - 1, y, z)) {
emitFace(outVertices, center, vec3(-1, 0, 0), cs, material);
}
// +Y face (top)
if (shouldGenerateFace(grid, x, y, z, x, y + 1, z)) {
emitFace(outVertices, center, vec3(0, 1, 0), cs, material);
}
// -Y face (bottom)
if (shouldGenerateFace(grid, x, y, z, x, y - 1, z)) {
emitFace(outVertices, center, vec3(0, -1, 0), cs, material);
}
// +Z face
if (shouldGenerateFace(grid, x, y, z, x, y, z + 1)) {
emitFace(outVertices, center, vec3(0, 0, 1), cs, material);
}
// -Z face
if (shouldGenerateFace(grid, x, y, z, x, y, z - 1)) {
emitFace(outVertices, center, vec3(0, 0, -1), cs, material);
}
}
}
}
}
bool VoxelMesher::shouldGenerateFace(const VoxelGrid& grid,
int x, int y, int z,
int nx, int ny, int nz) {
// Out of bounds = air, so generate face
if (!grid.isValid(nx, ny, nz)) {
return true;
}
uint8_t neighbor = grid.get(nx, ny, nz);
// Air neighbor = generate face
if (neighbor == 0) {
return true;
}
// Check if neighbor is transparent
// Transparent materials allow faces to be visible behind them
return grid.getMaterial(neighbor).transparent;
}
void VoxelMesher::emitFace(std::vector<MeshVertex>& vertices,
const vec3& center,
const vec3& normal,
float size,
const VoxelMaterial& material) {
// Calculate face corners based on normal direction
vec3 up, right;
if (std::abs(normal.y) > 0.5f) {
// Horizontal face (floor/ceiling)
// For +Y (top), we want the face to look correct from above
// For -Y (bottom), we want it to look correct from below
up = vec3(0, 0, normal.y); // Z direction based on face direction
right = vec3(1, 0, 0); // Always X axis for horizontal faces
} else if (std::abs(normal.x) > 0.5f) {
// X-facing wall
up = vec3(0, 1, 0); // Y axis is up
right = vec3(0, 0, normal.x); // Z direction based on face direction
} else {
// Z-facing wall
up = vec3(0, 1, 0); // Y axis is up
right = vec3(-normal.z, 0, 0); // X direction based on face direction
}
float halfSize = size * 0.5f;
vec3 faceCenter = center + normal * halfSize;
// 4 corners of the face
vec3 corners[4] = {
faceCenter - right * halfSize - up * halfSize, // Bottom-left
faceCenter + right * halfSize - up * halfSize, // Bottom-right
faceCenter + right * halfSize + up * halfSize, // Top-right
faceCenter - right * halfSize + up * halfSize // Top-left
};
// UV coordinates (solid color or single sprite tile)
vec2 uvs[4] = {
vec2(0, 0), // Bottom-left
vec2(1, 0), // Bottom-right
vec2(1, 1), // Top-right
vec2(0, 1) // Top-left
};
// Color from material (convert 0-255 to 0-1)
vec4 color(
material.color.r / 255.0f,
material.color.g / 255.0f,
material.color.b / 255.0f,
material.color.a / 255.0f
);
// Emit 2 triangles (6 vertices) - CCW winding for OpenGL front faces
// Triangle 1: 0-2-1 (bottom-left, top-right, bottom-right) - CCW
vertices.push_back(MeshVertex(corners[0], uvs[0], normal, color));
vertices.push_back(MeshVertex(corners[2], uvs[2], normal, color));
vertices.push_back(MeshVertex(corners[1], uvs[1], normal, color));
// Triangle 2: 0-3-2 (bottom-left, top-left, top-right) - CCW
vertices.push_back(MeshVertex(corners[0], uvs[0], normal, color));
vertices.push_back(MeshVertex(corners[3], uvs[3], normal, color));
vertices.push_back(MeshVertex(corners[2], uvs[2], normal, color));
}
} // namespace mcrf

53
src/3d/VoxelMesher.h Normal file
View file

@ -0,0 +1,53 @@
// VoxelMesher.h - Face-culled mesh generation for VoxelGrid
// Part of McRogueFace 3D Extension - Milestone 10
#pragma once
#include "VoxelGrid.h"
#include "MeshLayer.h" // For MeshVertex
#include <vector>
namespace mcrf {
// =============================================================================
// VoxelMesher - Static class for generating triangle meshes from VoxelGrid
// =============================================================================
class VoxelMesher {
public:
/// Generate face-culled mesh from voxel data
/// Output vertices in local space (model matrix applies world transform)
/// @param grid The VoxelGrid to generate mesh from
/// @param outVertices Output vector of vertices (appended to, not cleared)
static void generateMesh(
const VoxelGrid& grid,
std::vector<MeshVertex>& outVertices
);
private:
/// Check if face should be generated (neighbor is air or transparent)
/// @param grid The VoxelGrid
/// @param x, y, z Current voxel position
/// @param nx, ny, nz Neighbor voxel position
/// @return true if face should be generated
static bool shouldGenerateFace(
const VoxelGrid& grid,
int x, int y, int z,
int nx, int ny, int nz
);
/// Generate a single face (2 triangles = 6 vertices)
/// @param vertices Output vector to append vertices to
/// @param center Center of the voxel
/// @param normal Face normal direction
/// @param size Voxel cell size
/// @param material Material for coloring
static void emitFace(
std::vector<MeshVertex>& vertices,
const vec3& center,
const vec3& normal,
float size,
const VoxelMaterial& material
);
};
} // namespace mcrf

View file

@ -36,6 +36,7 @@
#include "3d/EntityCollection3D.h" // Entity3D collection
#include "3d/Model3D.h" // 3D model resource
#include "3d/Billboard.h" // Billboard sprites
#include "3d/PyVoxelGrid.h" // Voxel grid for 3D structures (Milestone 9)
#include "McRogueFaceVersion.h"
#include "GameEngine.h"
// ImGui is only available for SFML builds
@ -442,7 +443,7 @@ PyObject* PyInit_mcrfpy()
/*3D entities*/
&mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType,
&mcrfpydef::PyEntityCollection3DIterType, &mcrfpydef::PyModel3DType,
&mcrfpydef::PyBillboardType,
&mcrfpydef::PyBillboardType, &mcrfpydef::PyVoxelGridType,
/*grid layers (#147)*/
&PyColorLayerType, &PyTileLayerType,
@ -539,6 +540,10 @@ PyObject* PyInit_mcrfpy()
mcrfpydef::PyNoiseSourceType.tp_methods = PyNoiseSource::methods;
mcrfpydef::PyNoiseSourceType.tp_getset = PyNoiseSource::getsetters;
// Set up PyVoxelGridType methods and getsetters (Milestone 9)
mcrfpydef::PyVoxelGridType.tp_methods = PyVoxelGrid::methods;
mcrfpydef::PyVoxelGridType.tp_getset = PyVoxelGrid::getsetters;
// Set up PyShaderType methods and getsetters (#106)
mcrfpydef::PyShaderType.tp_methods = PyShader::methods;
mcrfpydef::PyShaderType.tp_getset = PyShader::getsetters;

View file

@ -0,0 +1,218 @@
# voxel_meshing_demo.py - Visual demo of VoxelGrid mesh rendering
# Shows voxel building rendered in Viewport3D with PS1 effects
import mcrfpy
import sys
import math
# Create demo scene
scene = mcrfpy.Scene("voxel_meshing_demo")
# Dark background frame
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(15, 15, 25))
scene.children.append(bg)
# Title
title = mcrfpy.Caption(text="VoxelGrid Meshing Demo - Face-Culled 3D Voxels", pos=(20, 10))
title.fill_color = mcrfpy.Color(255, 255, 255)
scene.children.append(title)
# Create the 3D viewport
viewport = mcrfpy.Viewport3D(
pos=(50, 60),
size=(600, 500),
render_resolution=(320, 240), # PS1 resolution
fov=60.0,
camera_pos=(20.0, 15.0, 20.0),
camera_target=(4.0, 2.0, 4.0),
bg_color=mcrfpy.Color(50, 70, 100) # Sky color
)
scene.children.append(viewport)
# Create voxel grid for building
print("Creating voxel building...")
voxels = mcrfpy.VoxelGrid(size=(12, 8, 12), cell_size=1.0)
# Define materials
STONE = voxels.add_material("stone", color=mcrfpy.Color(128, 128, 128))
BRICK = voxels.add_material("brick", color=mcrfpy.Color(165, 82, 42))
WOOD = voxels.add_material("wood", color=mcrfpy.Color(139, 90, 43))
GLASS = voxels.add_material("glass", color=mcrfpy.Color(180, 220, 255, 180), transparent=True)
GRASS = voxels.add_material("grass", color=mcrfpy.Color(60, 150, 60))
print(f"Defined {voxels.material_count} materials")
# Build a simple house structure
# Ground/foundation
voxels.fill_box((0, 0, 0), (11, 0, 11), GRASS)
# Floor
voxels.fill_box((1, 1, 1), (10, 1, 10), STONE)
# Walls
# Front wall (Z=1)
voxels.fill_box((1, 2, 1), (10, 5, 1), BRICK)
# Back wall (Z=10)
voxels.fill_box((1, 2, 10), (10, 5, 10), BRICK)
# Left wall (X=1)
voxels.fill_box((1, 2, 1), (1, 5, 10), BRICK)
# Right wall (X=10)
voxels.fill_box((10, 2, 1), (10, 5, 10), BRICK)
# Door opening (front wall)
voxels.fill_box((4, 2, 1), (6, 4, 1), 0) # Clear door opening
# Windows
# Front windows (beside door)
voxels.fill_box((2, 3, 1), (3, 4, 1), GLASS)
voxels.fill_box((8, 3, 1), (9, 4, 1), GLASS)
# Side windows
voxels.fill_box((1, 3, 4), (1, 4, 5), GLASS)
voxels.fill_box((1, 3, 7), (1, 4, 8), GLASS)
voxels.fill_box((10, 3, 4), (10, 4, 5), GLASS)
voxels.fill_box((10, 3, 7), (10, 4, 8), GLASS)
# Ceiling
voxels.fill_box((1, 6, 1), (10, 6, 10), WOOD)
# Simple roof (peaked)
voxels.fill_box((0, 7, 0), (11, 7, 11), WOOD)
voxels.fill_box((1, 8, 1), (10, 8, 10), WOOD)
voxels.fill_box((2, 9, 2), (9, 9, 9), WOOD)
voxels.fill_box((3, 10, 3), (8, 10, 8), WOOD)
voxels.fill_box((4, 11, 4), (7, 11, 7), WOOD)
# Build the mesh
voxels.rebuild_mesh()
print(f"Built voxel house:")
print(f" Non-air voxels: {voxels.count_non_air()}")
print(f" Vertices: {voxels.vertex_count}")
print(f" Faces: {voxels.vertex_count // 6}")
# Position the building
voxels.offset = (0.0, 0.0, 0.0)
voxels.rotation = 0.0
# Add to viewport
viewport.add_voxel_layer(voxels, z_index=0)
print(f"Added voxel layer to viewport (count: {viewport.voxel_layer_count()})")
# Create info panel
info_frame = mcrfpy.Frame(pos=(680, 60), size=(300, 250), fill_color=mcrfpy.Color(40, 40, 60, 200))
scene.children.append(info_frame)
info_title = mcrfpy.Caption(text="Building Stats", pos=(690, 70))
info_title.fill_color = mcrfpy.Color(255, 255, 100)
scene.children.append(info_title)
stats_text = f"""Grid: {voxels.width}x{voxels.height}x{voxels.depth}
Total voxels: {voxels.width * voxels.height * voxels.depth}
Non-air: {voxels.count_non_air()}
Materials: {voxels.material_count}
Vertices: {voxels.vertex_count}
Faces: {voxels.vertex_count // 6}
Without culling would be:
{voxels.count_non_air() * 36} vertices
({100 - (voxels.vertex_count / (voxels.count_non_air() * 36) * 100):.0f}% reduction)"""
stats = mcrfpy.Caption(text=stats_text, pos=(690, 100))
stats.fill_color = mcrfpy.Color(200, 200, 200)
scene.children.append(stats)
# Controls info
controls_frame = mcrfpy.Frame(pos=(680, 330), size=(300, 180), fill_color=mcrfpy.Color(40, 40, 60, 200))
scene.children.append(controls_frame)
controls_title = mcrfpy.Caption(text="Controls", pos=(690, 340))
controls_title.fill_color = mcrfpy.Color(255, 255, 100)
scene.children.append(controls_title)
controls_text = """R - Toggle rotation
1-5 - Change camera angle
SPACE - Reset camera
ESC - Exit demo"""
controls = mcrfpy.Caption(text=controls_text, pos=(690, 370))
controls.fill_color = mcrfpy.Color(200, 200, 200)
scene.children.append(controls)
# Animation state
rotation_enabled = False
current_angle = 0.0
camera_angles = [
(20.0, 15.0, 20.0), # Default - diagonal view
(0.0, 15.0, 25.0), # Front view
(25.0, 15.0, 0.0), # Side view
(5.5, 25.0, 5.5), # Top-down view
(5.5, 3.0, 20.0), # Low angle
]
current_camera = 0
def rotate_building(timer, runtime):
"""Timer callback for building rotation"""
global current_angle, rotation_enabled
if rotation_enabled:
current_angle += 1.0
if current_angle >= 360.0:
current_angle = 0.0
voxels.rotation = current_angle
# Set up rotation timer
timer = mcrfpy.Timer("rotate", rotate_building, 33) # ~30 FPS
def handle_key(key, action):
"""Keyboard handler"""
global rotation_enabled, current_camera
if action != mcrfpy.InputState.PRESSED:
return
if key == mcrfpy.Key.R:
rotation_enabled = not rotation_enabled
print(f"Rotation: {'ON' if rotation_enabled else 'OFF'}")
elif key == mcrfpy.Key.NUM_1:
current_camera = 0
viewport.camera_pos = camera_angles[0]
print("Camera: Default diagonal")
elif key == mcrfpy.Key.NUM_2:
current_camera = 1
viewport.camera_pos = camera_angles[1]
print("Camera: Front view")
elif key == mcrfpy.Key.NUM_3:
current_camera = 2
viewport.camera_pos = camera_angles[2]
print("Camera: Side view")
elif key == mcrfpy.Key.NUM_4:
current_camera = 3
viewport.camera_pos = camera_angles[3]
print("Camera: Top-down view")
elif key == mcrfpy.Key.NUM_5:
current_camera = 4
viewport.camera_pos = camera_angles[4]
print("Camera: Low angle")
elif key == mcrfpy.Key.SPACE:
current_camera = 0
voxels.rotation = 0.0
viewport.camera_pos = camera_angles[0]
print("Camera: Reset")
elif key == mcrfpy.Key.ESCAPE:
print("Exiting demo...")
sys.exit(0)
scene.on_key = handle_key
# Activate the scene
mcrfpy.current_scene = scene
print("Voxel Meshing Demo ready! Press R to toggle rotation.")
# Main entry point for --exec mode
if __name__ == "__main__":
# Demo is set up, print summary
print("\n=== Voxel Meshing Demo Summary ===")
print(f"Grid size: {voxels.width}x{voxels.height}x{voxels.depth}")
print(f"Non-air voxels: {voxels.count_non_air()}")
print(f"Generated vertices: {voxels.vertex_count}")
print(f"Rendered faces: {voxels.vertex_count // 6}")
print("===================================\n")