Compare commits
8 commits
8636e766f8
...
992ea781cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 992ea781cb | |||
| 3e6b6a5847 | |||
| 7ebca63db3 | |||
| f2ccdff499 | |||
| 7e8efe82ec | |||
| cc027a2517 | |||
| b85f225789 | |||
| 544c44ca31 |
36 changed files with 17974 additions and 74 deletions
596
src/3d/Billboard.cpp
Normal file
596
src/3d/Billboard.cpp
Normal file
|
|
@ -0,0 +1,596 @@
|
||||||
|
// Billboard.cpp - Camera-facing 3D sprite implementation
|
||||||
|
|
||||||
|
#include "Billboard.h"
|
||||||
|
#include "Shader3D.h"
|
||||||
|
#include "MeshLayer.h" // For MeshVertex
|
||||||
|
#include "../platform/GLContext.h"
|
||||||
|
#include "../PyTexture.h"
|
||||||
|
#include "../PyTypeCache.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
// GL headers based on backend
|
||||||
|
#if defined(MCRF_SDL2)
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
#include <GLES2/gl2.h>
|
||||||
|
#else
|
||||||
|
#include <GL/gl.h>
|
||||||
|
#include <GL/glext.h>
|
||||||
|
#endif
|
||||||
|
#define MCRF_HAS_GL 1
|
||||||
|
#elif !defined(MCRF_HEADLESS)
|
||||||
|
#include <glad/glad.h>
|
||||||
|
#define MCRF_HAS_GL 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace mcrf {
|
||||||
|
|
||||||
|
// Static members
|
||||||
|
unsigned int Billboard::sharedVBO_ = 0;
|
||||||
|
unsigned int Billboard::sharedEBO_ = 0;
|
||||||
|
bool Billboard::geometryInitialized_ = false;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constructor / Destructor
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
Billboard::Billboard()
|
||||||
|
: texture_()
|
||||||
|
, spriteIndex_(0)
|
||||||
|
, position_(0, 0, 0)
|
||||||
|
, scale_(1.0f)
|
||||||
|
, facing_(BillboardFacing::CameraY)
|
||||||
|
, theta_(0.0f)
|
||||||
|
, phi_(0.0f)
|
||||||
|
, opacity_(1.0f)
|
||||||
|
, visible_(true)
|
||||||
|
, tilesPerRow_(1)
|
||||||
|
, tilesPerCol_(1)
|
||||||
|
{}
|
||||||
|
|
||||||
|
Billboard::Billboard(std::shared_ptr<PyTexture> texture, int spriteIndex, const vec3& pos,
|
||||||
|
float scale, BillboardFacing facing)
|
||||||
|
: texture_(texture)
|
||||||
|
, spriteIndex_(spriteIndex)
|
||||||
|
, position_(pos)
|
||||||
|
, scale_(scale)
|
||||||
|
, facing_(facing)
|
||||||
|
, theta_(0.0f)
|
||||||
|
, phi_(0.0f)
|
||||||
|
, opacity_(1.0f)
|
||||||
|
, visible_(true)
|
||||||
|
, tilesPerRow_(1)
|
||||||
|
, tilesPerCol_(1)
|
||||||
|
{}
|
||||||
|
|
||||||
|
Billboard::~Billboard() {
|
||||||
|
// Texture is not owned, don't delete it
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Configuration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void Billboard::setSpriteSheetLayout(int tilesPerRow, int tilesPerCol) {
|
||||||
|
tilesPerRow_ = tilesPerRow > 0 ? tilesPerRow : 1;
|
||||||
|
tilesPerCol_ = tilesPerCol > 0 ? tilesPerCol : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Static Geometry Management
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void Billboard::initSharedGeometry() {
|
||||||
|
#ifdef MCRF_HAS_GL
|
||||||
|
if (geometryInitialized_ || !gl::isGLReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unit quad centered at origin, facing +Z
|
||||||
|
// Vertices: position (3) + texcoord (2) + normal (3) + color (4) = 12 floats
|
||||||
|
MeshVertex vertices[4] = {
|
||||||
|
// Bottom-left
|
||||||
|
MeshVertex(vec3(-0.5f, -0.5f, 0.0f), vec2(0, 1), vec3(0, 0, 1), vec4(1, 1, 1, 1)),
|
||||||
|
// Bottom-right
|
||||||
|
MeshVertex(vec3( 0.5f, -0.5f, 0.0f), vec2(1, 1), vec3(0, 0, 1), vec4(1, 1, 1, 1)),
|
||||||
|
// Top-right
|
||||||
|
MeshVertex(vec3( 0.5f, 0.5f, 0.0f), vec2(1, 0), vec3(0, 0, 1), vec4(1, 1, 1, 1)),
|
||||||
|
// Top-left
|
||||||
|
MeshVertex(vec3(-0.5f, 0.5f, 0.0f), vec2(0, 0), vec3(0, 0, 1), vec4(1, 1, 1, 1)),
|
||||||
|
};
|
||||||
|
|
||||||
|
unsigned short indices[6] = {
|
||||||
|
0, 1, 2, // First triangle
|
||||||
|
2, 3, 0 // Second triangle
|
||||||
|
};
|
||||||
|
|
||||||
|
glGenBuffers(1, &sharedVBO_);
|
||||||
|
glBindBuffer(GL_ARRAY_BUFFER, sharedVBO_);
|
||||||
|
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
|
||||||
|
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||||
|
|
||||||
|
glGenBuffers(1, &sharedEBO_);
|
||||||
|
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sharedEBO_);
|
||||||
|
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
|
||||||
|
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
|
||||||
|
|
||||||
|
geometryInitialized_ = true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Billboard::cleanupSharedGeometry() {
|
||||||
|
#ifdef MCRF_HAS_GL
|
||||||
|
if (sharedVBO_ != 0 && gl::isGLReady()) {
|
||||||
|
glDeleteBuffers(1, &sharedVBO_);
|
||||||
|
sharedVBO_ = 0;
|
||||||
|
}
|
||||||
|
if (sharedEBO_ != 0 && gl::isGLReady()) {
|
||||||
|
glDeleteBuffers(1, &sharedEBO_);
|
||||||
|
sharedEBO_ = 0;
|
||||||
|
}
|
||||||
|
geometryInitialized_ = false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Rendering
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mat4 Billboard::computeModelMatrix(const vec3& cameraPos, const mat4& view) {
|
||||||
|
mat4 model = mat4::identity();
|
||||||
|
|
||||||
|
// First translate to world position
|
||||||
|
model = model * mat4::translate(position_);
|
||||||
|
|
||||||
|
// Apply rotation based on facing mode
|
||||||
|
switch (facing_) {
|
||||||
|
case BillboardFacing::Camera: {
|
||||||
|
// Full rotation to face camera
|
||||||
|
// Extract camera right and up vectors from view matrix
|
||||||
|
vec3 right(view.m[0], view.m[4], view.m[8]);
|
||||||
|
vec3 up(view.m[1], view.m[5], view.m[9]);
|
||||||
|
|
||||||
|
// Build rotation matrix that makes the quad face the camera
|
||||||
|
// The quad is initially facing +Z, we need to rotate it
|
||||||
|
mat4 rotation;
|
||||||
|
rotation.m[0] = right.x; rotation.m[4] = up.x; rotation.m[8] = -view.m[2]; rotation.m[12] = 0;
|
||||||
|
rotation.m[1] = right.y; rotation.m[5] = up.y; rotation.m[9] = -view.m[6]; rotation.m[13] = 0;
|
||||||
|
rotation.m[2] = right.z; rotation.m[6] = up.z; rotation.m[10] = -view.m[10]; rotation.m[14] = 0;
|
||||||
|
rotation.m[3] = 0; rotation.m[7] = 0; rotation.m[11] = 0; rotation.m[15] = 1;
|
||||||
|
|
||||||
|
model = model * rotation;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case BillboardFacing::CameraY: {
|
||||||
|
// Only Y-axis rotation to face camera (stays upright)
|
||||||
|
vec3 toCamera(cameraPos.x - position_.x, 0, cameraPos.z - position_.z);
|
||||||
|
float length = std::sqrt(toCamera.x * toCamera.x + toCamera.z * toCamera.z);
|
||||||
|
if (length > 0.001f) {
|
||||||
|
float angle = std::atan2(toCamera.x, toCamera.z);
|
||||||
|
model = model * mat4::rotateY(angle);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case BillboardFacing::Fixed: {
|
||||||
|
// Use theta (Y rotation) and phi (X tilt)
|
||||||
|
model = model * mat4::rotateY(theta_);
|
||||||
|
model = model * mat4::rotateX(phi_);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply scale
|
||||||
|
model = model * mat4::scale(vec3(scale_, scale_, scale_));
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Billboard::render(unsigned int shader, const mat4& view, const mat4& projection,
|
||||||
|
const vec3& cameraPos) {
|
||||||
|
#ifdef MCRF_HAS_GL
|
||||||
|
if (!visible_ || !gl::isGLReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize shared geometry if needed
|
||||||
|
if (!geometryInitialized_) {
|
||||||
|
initSharedGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedVBO_ == 0 || sharedEBO_ == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute model matrix
|
||||||
|
mat4 model = computeModelMatrix(cameraPos, view);
|
||||||
|
|
||||||
|
// Set model matrix uniform
|
||||||
|
int modelLoc = glGetUniformLocation(shader, "u_model");
|
||||||
|
if (modelLoc >= 0) {
|
||||||
|
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, model.m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set entity color uniform (for tinting/opacity)
|
||||||
|
int colorLoc = glGetUniformLocation(shader, "u_entityColor");
|
||||||
|
if (colorLoc >= 0) {
|
||||||
|
glUniform4f(colorLoc, 1.0f, 1.0f, 1.0f, opacity_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle texture
|
||||||
|
bool hasTexture = (texture_ != nullptr);
|
||||||
|
int hasTexLoc = glGetUniformLocation(shader, "u_has_texture");
|
||||||
|
if (hasTexLoc >= 0) {
|
||||||
|
glUniform1i(hasTexLoc, hasTexture ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTexture) {
|
||||||
|
// Bind texture using PyTexture's underlying sf::Texture
|
||||||
|
const sf::Texture* sfTexture = texture_->getSFMLTexture();
|
||||||
|
if (sfTexture) {
|
||||||
|
glBindTexture(GL_TEXTURE_2D, sfTexture->getNativeHandle());
|
||||||
|
|
||||||
|
// Use PyTexture's sprite sheet configuration
|
||||||
|
int sheetW = texture_->sprite_width > 0 ? texture_->sprite_width : 1;
|
||||||
|
int sheetH = texture_->sprite_height > 0 ? texture_->sprite_height : 1;
|
||||||
|
sf::Vector2u texSize = sfTexture->getSize();
|
||||||
|
int tilesPerRow = texSize.x / sheetW;
|
||||||
|
int tilesPerCol = texSize.y / sheetH;
|
||||||
|
if (tilesPerRow < 1) tilesPerRow = 1;
|
||||||
|
if (tilesPerCol < 1) tilesPerCol = 1;
|
||||||
|
|
||||||
|
// Calculate sprite UV offset
|
||||||
|
float tileU = 1.0f / tilesPerRow;
|
||||||
|
float tileV = 1.0f / tilesPerCol;
|
||||||
|
int tileX = spriteIndex_ % tilesPerRow;
|
||||||
|
int tileY = spriteIndex_ / tilesPerRow;
|
||||||
|
|
||||||
|
// Set UV offset/scale uniforms if available
|
||||||
|
int uvOffsetLoc = glGetUniformLocation(shader, "u_uv_offset");
|
||||||
|
int uvScaleLoc = glGetUniformLocation(shader, "u_uv_scale");
|
||||||
|
if (uvOffsetLoc >= 0) {
|
||||||
|
glUniform2f(uvOffsetLoc, tileX * tileU, tileY * tileV);
|
||||||
|
}
|
||||||
|
if (uvScaleLoc >= 0) {
|
||||||
|
glUniform2f(uvScaleLoc, tileU, tileV);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind VBO
|
||||||
|
glBindBuffer(GL_ARRAY_BUFFER, sharedVBO_);
|
||||||
|
|
||||||
|
// Set up vertex attributes
|
||||||
|
int stride = sizeof(MeshVertex);
|
||||||
|
|
||||||
|
glEnableVertexAttribArray(Shader3D::ATTRIB_POSITION);
|
||||||
|
glVertexAttribPointer(Shader3D::ATTRIB_POSITION, 3, GL_FLOAT, GL_FALSE,
|
||||||
|
stride, reinterpret_cast<void*>(offsetof(MeshVertex, position)));
|
||||||
|
|
||||||
|
glEnableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD);
|
||||||
|
glVertexAttribPointer(Shader3D::ATTRIB_TEXCOORD, 2, GL_FLOAT, GL_FALSE,
|
||||||
|
stride, reinterpret_cast<void*>(offsetof(MeshVertex, texcoord)));
|
||||||
|
|
||||||
|
glEnableVertexAttribArray(Shader3D::ATTRIB_NORMAL);
|
||||||
|
glVertexAttribPointer(Shader3D::ATTRIB_NORMAL, 3, GL_FLOAT, GL_FALSE,
|
||||||
|
stride, reinterpret_cast<void*>(offsetof(MeshVertex, normal)));
|
||||||
|
|
||||||
|
glEnableVertexAttribArray(Shader3D::ATTRIB_COLOR);
|
||||||
|
glVertexAttribPointer(Shader3D::ATTRIB_COLOR, 4, GL_FLOAT, GL_FALSE,
|
||||||
|
stride, reinterpret_cast<void*>(offsetof(MeshVertex, color)));
|
||||||
|
|
||||||
|
// Bind EBO and draw
|
||||||
|
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sharedEBO_);
|
||||||
|
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, 0);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
glDisableVertexAttribArray(Shader3D::ATTRIB_POSITION);
|
||||||
|
glDisableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD);
|
||||||
|
glDisableVertexAttribArray(Shader3D::ATTRIB_NORMAL);
|
||||||
|
glDisableVertexAttribArray(Shader3D::ATTRIB_COLOR);
|
||||||
|
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||||
|
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
|
||||||
|
|
||||||
|
// Unbind texture
|
||||||
|
if (hasTexture) {
|
||||||
|
glBindTexture(GL_TEXTURE_2D, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset UV uniforms
|
||||||
|
int uvOffsetLoc = glGetUniformLocation(shader, "u_uv_offset");
|
||||||
|
int uvScaleLoc = glGetUniformLocation(shader, "u_uv_scale");
|
||||||
|
if (uvOffsetLoc >= 0) {
|
||||||
|
glUniform2f(uvOffsetLoc, 0.0f, 0.0f);
|
||||||
|
}
|
||||||
|
if (uvScaleLoc >= 0) {
|
||||||
|
glUniform2f(uvScaleLoc, 1.0f, 1.0f);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Python API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
PyGetSetDef Billboard::getsetters[] = {
|
||||||
|
{"texture", Billboard::get_texture, Billboard::set_texture,
|
||||||
|
"Sprite sheet texture (Texture or None)", NULL},
|
||||||
|
{"sprite_index", Billboard::get_sprite_index, Billboard::set_sprite_index,
|
||||||
|
"Index into sprite sheet (int)", NULL},
|
||||||
|
{"pos", Billboard::get_pos, Billboard::set_pos,
|
||||||
|
"World position as (x, y, z) tuple", NULL},
|
||||||
|
{"scale", Billboard::get_scale, Billboard::set_scale,
|
||||||
|
"Uniform scale factor (float)", NULL},
|
||||||
|
{"facing", Billboard::get_facing, Billboard::set_facing,
|
||||||
|
"Facing mode: 'camera', 'camera_y', or 'fixed' (str)", NULL},
|
||||||
|
{"theta", Billboard::get_theta, Billboard::set_theta,
|
||||||
|
"Horizontal rotation for 'fixed' mode in radians (float)", NULL},
|
||||||
|
{"phi", Billboard::get_phi, Billboard::set_phi,
|
||||||
|
"Vertical tilt for 'fixed' mode in radians (float)", NULL},
|
||||||
|
{"opacity", Billboard::get_opacity, Billboard::set_opacity,
|
||||||
|
"Opacity from 0.0 (transparent) to 1.0 (opaque) (float)", NULL},
|
||||||
|
{"visible", Billboard::get_visible, Billboard::set_visible,
|
||||||
|
"Visibility state (bool)", NULL},
|
||||||
|
{NULL}
|
||||||
|
};
|
||||||
|
|
||||||
|
int Billboard::init(PyObject* self, PyObject* args, PyObject* kwds) {
|
||||||
|
PyBillboardObject* selfObj = (PyBillboardObject*)self;
|
||||||
|
|
||||||
|
static const char* kwlist[] = {"texture", "sprite_index", "pos", "scale", "facing", "opacity", "visible", NULL};
|
||||||
|
|
||||||
|
PyObject* textureObj = nullptr;
|
||||||
|
int spriteIndex = 0;
|
||||||
|
PyObject* posObj = nullptr;
|
||||||
|
float scale = 1.0f;
|
||||||
|
const char* facingStr = "camera_y";
|
||||||
|
float opacity = 1.0f;
|
||||||
|
int visible = 1; // Default to True
|
||||||
|
|
||||||
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OiOfsfp", const_cast<char**>(kwlist),
|
||||||
|
&textureObj, &spriteIndex, &posObj, &scale, &facingStr, &opacity, &visible)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle texture
|
||||||
|
if (textureObj && textureObj != Py_None) {
|
||||||
|
PyTypeObject* textureType = PyTypeCache::Texture();
|
||||||
|
if (textureType && PyObject_IsInstance(textureObj, (PyObject*)textureType)) {
|
||||||
|
PyTextureObject* texPy = (PyTextureObject*)textureObj;
|
||||||
|
if (texPy->data) {
|
||||||
|
selfObj->data->setTexture(texPy->data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "texture must be a Texture object or None");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selfObj->data->setSpriteIndex(spriteIndex);
|
||||||
|
selfObj->data->setScale(scale);
|
||||||
|
|
||||||
|
// Handle position
|
||||||
|
if (posObj && posObj != Py_None) {
|
||||||
|
if (PyTuple_Check(posObj) && PyTuple_Size(posObj) >= 3) {
|
||||||
|
float x = static_cast<float>(PyFloat_AsDouble(PyTuple_GetItem(posObj, 0)));
|
||||||
|
float y = static_cast<float>(PyFloat_AsDouble(PyTuple_GetItem(posObj, 1)));
|
||||||
|
float z = static_cast<float>(PyFloat_AsDouble(PyTuple_GetItem(posObj, 2)));
|
||||||
|
if (!PyErr_Occurred()) {
|
||||||
|
selfObj->data->setPosition(vec3(x, y, z));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y, z)");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle facing mode
|
||||||
|
std::string facing(facingStr);
|
||||||
|
if (facing == "camera") {
|
||||||
|
selfObj->data->setFacing(BillboardFacing::Camera);
|
||||||
|
} else if (facing == "camera_y") {
|
||||||
|
selfObj->data->setFacing(BillboardFacing::CameraY);
|
||||||
|
} else if (facing == "fixed") {
|
||||||
|
selfObj->data->setFacing(BillboardFacing::Fixed);
|
||||||
|
} else {
|
||||||
|
PyErr_SetString(PyExc_ValueError, "facing must be 'camera', 'camera_y', or 'fixed'");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply opacity and visibility
|
||||||
|
selfObj->data->setOpacity(opacity);
|
||||||
|
selfObj->data->setVisible(visible != 0);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::repr(PyObject* self) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
if (!obj->data) {
|
||||||
|
return PyUnicode_FromString("<Billboard (invalid)>");
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 pos = obj->data->getPosition();
|
||||||
|
const char* facingStr = "unknown";
|
||||||
|
switch (obj->data->getFacing()) {
|
||||||
|
case BillboardFacing::Camera: facingStr = "camera"; break;
|
||||||
|
case BillboardFacing::CameraY: facingStr = "camera_y"; break;
|
||||||
|
case BillboardFacing::Fixed: facingStr = "fixed"; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PyUnicode_FromFormat doesn't support %f, so use snprintf
|
||||||
|
char buffer[256];
|
||||||
|
snprintf(buffer, sizeof(buffer), "<Billboard pos=(%.2f, %.2f, %.2f) scale=%.2f facing='%s'>",
|
||||||
|
pos.x, pos.y, pos.z, obj->data->getScale(), facingStr);
|
||||||
|
return PyUnicode_FromString(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property implementations
|
||||||
|
PyObject* Billboard::get_texture(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
std::shared_ptr<PyTexture> tex = obj->data->getTexture();
|
||||||
|
if (!tex) {
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
// Return the PyTexture's Python object
|
||||||
|
return tex->pyObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_texture(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
if (value == Py_None) {
|
||||||
|
obj->data->setTexture(nullptr);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Use PyTypeCache to get properly initialized type object
|
||||||
|
PyTypeObject* textureType = PyTypeCache::Texture();
|
||||||
|
if (!textureType) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Texture type not initialized");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (PyObject_IsInstance(value, (PyObject*)textureType)) {
|
||||||
|
PyTextureObject* texPy = (PyTextureObject*)value;
|
||||||
|
if (texPy->data) {
|
||||||
|
obj->data->setTexture(texPy->data);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PyErr_SetString(PyExc_TypeError, "texture must be a Texture object or None");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::get_sprite_index(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
return PyLong_FromLong(obj->data->getSpriteIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_sprite_index(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
if (!PyLong_Check(value)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "sprite_index must be an integer");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
obj->data->setSpriteIndex(static_cast<int>(PyLong_AsLong(value)));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::get_pos(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
vec3 pos = obj->data->getPosition();
|
||||||
|
return Py_BuildValue("(fff)", pos.x, pos.y, pos.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_pos(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
if (!PyTuple_Check(value) || PyTuple_Size(value) < 3) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y, z)");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
float x = static_cast<float>(PyFloat_AsDouble(PyTuple_GetItem(value, 0)));
|
||||||
|
float y = static_cast<float>(PyFloat_AsDouble(PyTuple_GetItem(value, 1)));
|
||||||
|
float z = static_cast<float>(PyFloat_AsDouble(PyTuple_GetItem(value, 2)));
|
||||||
|
if (PyErr_Occurred()) return -1;
|
||||||
|
obj->data->setPosition(vec3(x, y, z));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::get_scale(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
return PyFloat_FromDouble(obj->data->getScale());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_scale(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
float scale = static_cast<float>(PyFloat_AsDouble(value));
|
||||||
|
if (PyErr_Occurred()) return -1;
|
||||||
|
obj->data->setScale(scale);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::get_facing(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
switch (obj->data->getFacing()) {
|
||||||
|
case BillboardFacing::Camera: return PyUnicode_FromString("camera");
|
||||||
|
case BillboardFacing::CameraY: return PyUnicode_FromString("camera_y");
|
||||||
|
case BillboardFacing::Fixed: return PyUnicode_FromString("fixed");
|
||||||
|
}
|
||||||
|
return PyUnicode_FromString("unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_facing(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
if (!PyUnicode_Check(value)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "facing must be a string");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const char* str = PyUnicode_AsUTF8(value);
|
||||||
|
std::string facing(str);
|
||||||
|
if (facing == "camera") {
|
||||||
|
obj->data->setFacing(BillboardFacing::Camera);
|
||||||
|
} else if (facing == "camera_y") {
|
||||||
|
obj->data->setFacing(BillboardFacing::CameraY);
|
||||||
|
} else if (facing == "fixed") {
|
||||||
|
obj->data->setFacing(BillboardFacing::Fixed);
|
||||||
|
} else {
|
||||||
|
PyErr_SetString(PyExc_ValueError, "facing must be 'camera', 'camera_y', or 'fixed'");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::get_theta(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
return PyFloat_FromDouble(obj->data->getTheta());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_theta(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
float theta = static_cast<float>(PyFloat_AsDouble(value));
|
||||||
|
if (PyErr_Occurred()) return -1;
|
||||||
|
obj->data->setTheta(theta);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::get_phi(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
return PyFloat_FromDouble(obj->data->getPhi());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_phi(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
float phi = static_cast<float>(PyFloat_AsDouble(value));
|
||||||
|
if (PyErr_Occurred()) return -1;
|
||||||
|
obj->data->setPhi(phi);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::get_opacity(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
return PyFloat_FromDouble(obj->data->getOpacity());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_opacity(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
float opacity = static_cast<float>(PyFloat_AsDouble(value));
|
||||||
|
if (PyErr_Occurred()) return -1;
|
||||||
|
obj->data->setOpacity(opacity);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Billboard::get_visible(PyObject* self, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
return PyBool_FromLong(obj->data->isVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Billboard::set_visible(PyObject* self, PyObject* value, void* closure) {
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
if (!PyBool_Check(value)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "visible must be a boolean");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
obj->data->setVisible(value == Py_True);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mcrf
|
||||||
229
src/3d/Billboard.h
Normal file
229
src/3d/Billboard.h
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
// Billboard.h - Camera-facing 3D sprite for McRogueFace
|
||||||
|
// Supports camera-facing rotation modes for trees, items, particles, etc.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Common.h"
|
||||||
|
#include "Math3D.h"
|
||||||
|
#include "Python.h"
|
||||||
|
#include "structmember.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
// Forward declaration
|
||||||
|
class PyTexture;
|
||||||
|
|
||||||
|
namespace mcrf {
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BillboardFacing - Billboard rotation mode
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum class BillboardFacing {
|
||||||
|
Camera, // Full rotation to always face camera
|
||||||
|
CameraY, // Only Y-axis rotation (stays upright)
|
||||||
|
Fixed // No automatic rotation, uses theta/phi angles
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Billboard - Camera-facing 3D sprite
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
class Billboard : public std::enable_shared_from_this<Billboard> {
|
||||||
|
public:
|
||||||
|
// Python integration
|
||||||
|
PyObject* self = nullptr;
|
||||||
|
uint64_t serial_number = 0;
|
||||||
|
|
||||||
|
Billboard();
|
||||||
|
Billboard(std::shared_ptr<PyTexture> texture, int spriteIndex, const vec3& pos,
|
||||||
|
float scale = 1.0f, BillboardFacing facing = BillboardFacing::CameraY);
|
||||||
|
~Billboard();
|
||||||
|
|
||||||
|
// No copy, allow move
|
||||||
|
Billboard(const Billboard&) = delete;
|
||||||
|
Billboard& operator=(const Billboard&) = delete;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Properties
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
std::shared_ptr<PyTexture> getTexture() const { return texture_; }
|
||||||
|
void setTexture(std::shared_ptr<PyTexture> tex) { texture_ = tex; }
|
||||||
|
|
||||||
|
int getSpriteIndex() const { return spriteIndex_; }
|
||||||
|
void setSpriteIndex(int idx) { spriteIndex_ = idx; }
|
||||||
|
|
||||||
|
vec3 getPosition() const { return position_; }
|
||||||
|
void setPosition(const vec3& pos) { position_ = pos; }
|
||||||
|
|
||||||
|
float getScale() const { return scale_; }
|
||||||
|
void setScale(float s) { scale_ = s; }
|
||||||
|
|
||||||
|
BillboardFacing getFacing() const { return facing_; }
|
||||||
|
void setFacing(BillboardFacing f) { facing_ = f; }
|
||||||
|
|
||||||
|
// Fixed facing angles (radians)
|
||||||
|
float getTheta() const { return theta_; }
|
||||||
|
void setTheta(float t) { theta_ = t; }
|
||||||
|
|
||||||
|
float getPhi() const { return phi_; }
|
||||||
|
void setPhi(float p) { phi_ = p; }
|
||||||
|
|
||||||
|
float getOpacity() const { return opacity_; }
|
||||||
|
void setOpacity(float o) { opacity_ = o < 0 ? 0 : (o > 1 ? 1 : o); }
|
||||||
|
|
||||||
|
bool isVisible() const { return visible_; }
|
||||||
|
void setVisible(bool v) { visible_ = v; }
|
||||||
|
|
||||||
|
// Sprite sheet configuration
|
||||||
|
void setSpriteSheetLayout(int tilesPerRow, int tilesPerCol);
|
||||||
|
int getTilesPerRow() const { return tilesPerRow_; }
|
||||||
|
int getTilesPerCol() const { return tilesPerCol_; }
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Rendering
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Render the billboard
|
||||||
|
/// @param shader Shader program handle
|
||||||
|
/// @param view View matrix
|
||||||
|
/// @param projection Projection matrix
|
||||||
|
/// @param cameraPos Camera world position (for facing computation)
|
||||||
|
void render(unsigned int shader, const mat4& view, const mat4& projection,
|
||||||
|
const vec3& cameraPos);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Static Initialization
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Initialize shared quad geometry (call once at startup)
|
||||||
|
static void initSharedGeometry();
|
||||||
|
|
||||||
|
/// Cleanup shared geometry (call at shutdown)
|
||||||
|
static void cleanupSharedGeometry();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Python API
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
static int init(PyObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
static PyObject* repr(PyObject* self);
|
||||||
|
|
||||||
|
static PyObject* get_texture(PyObject* self, void* closure);
|
||||||
|
static int set_texture(PyObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_sprite_index(PyObject* self, void* closure);
|
||||||
|
static int set_sprite_index(PyObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_pos(PyObject* self, void* closure);
|
||||||
|
static int set_pos(PyObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_scale(PyObject* self, void* closure);
|
||||||
|
static int set_scale(PyObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_facing(PyObject* self, void* closure);
|
||||||
|
static int set_facing(PyObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_theta(PyObject* self, void* closure);
|
||||||
|
static int set_theta(PyObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_phi(PyObject* self, void* closure);
|
||||||
|
static int set_phi(PyObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_opacity(PyObject* self, void* closure);
|
||||||
|
static int set_opacity(PyObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_visible(PyObject* self, void* closure);
|
||||||
|
static int set_visible(PyObject* self, PyObject* value, void* closure);
|
||||||
|
|
||||||
|
static PyGetSetDef getsetters[];
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::shared_ptr<PyTexture> texture_; // Texture wrapper
|
||||||
|
int spriteIndex_ = 0;
|
||||||
|
vec3 position_;
|
||||||
|
float scale_ = 1.0f;
|
||||||
|
BillboardFacing facing_ = BillboardFacing::CameraY;
|
||||||
|
float theta_ = 0.0f; // Horizontal rotation for Fixed mode
|
||||||
|
float phi_ = 0.0f; // Vertical tilt for Fixed mode
|
||||||
|
float opacity_ = 1.0f;
|
||||||
|
bool visible_ = true;
|
||||||
|
|
||||||
|
// Sprite sheet configuration
|
||||||
|
int tilesPerRow_ = 1;
|
||||||
|
int tilesPerCol_ = 1;
|
||||||
|
|
||||||
|
// Shared quad geometry (one VBO for all billboards)
|
||||||
|
static unsigned int sharedVBO_;
|
||||||
|
static unsigned int sharedEBO_;
|
||||||
|
static bool geometryInitialized_;
|
||||||
|
|
||||||
|
// Compute billboard model matrix based on facing mode
|
||||||
|
mat4 computeModelMatrix(const vec3& cameraPos, const mat4& view);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mcrf
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Python type definition
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
typedef struct PyBillboardObject {
|
||||||
|
PyObject_HEAD
|
||||||
|
std::shared_ptr<mcrf::Billboard> data;
|
||||||
|
PyObject* weakreflist;
|
||||||
|
} PyBillboardObject;
|
||||||
|
|
||||||
|
namespace mcrfpydef {
|
||||||
|
|
||||||
|
inline PyTypeObject PyBillboardType = {
|
||||||
|
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||||
|
.tp_name = "mcrfpy.Billboard",
|
||||||
|
.tp_basicsize = sizeof(PyBillboardObject),
|
||||||
|
.tp_itemsize = 0,
|
||||||
|
.tp_dealloc = (destructor)[](PyObject* self)
|
||||||
|
{
|
||||||
|
PyBillboardObject* obj = (PyBillboardObject*)self;
|
||||||
|
PyObject_GC_UnTrack(self);
|
||||||
|
if (obj->weakreflist != NULL) {
|
||||||
|
PyObject_ClearWeakRefs(self);
|
||||||
|
}
|
||||||
|
obj->data.reset();
|
||||||
|
Py_TYPE(self)->tp_free(self);
|
||||||
|
},
|
||||||
|
.tp_repr = mcrf::Billboard::repr,
|
||||||
|
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,
|
||||||
|
.tp_doc = PyDoc_STR(
|
||||||
|
"Billboard(texture=None, sprite_index=0, pos=(0,0,0), scale=1.0, facing='camera_y')\n\n"
|
||||||
|
"A camera-facing 3D sprite for trees, items, particles, etc.\n\n"
|
||||||
|
"Args:\n"
|
||||||
|
" texture (Texture, optional): Sprite sheet texture. Default: None\n"
|
||||||
|
" sprite_index (int): Index into sprite sheet. Default: 0\n"
|
||||||
|
" pos (tuple): World position (x, y, z). Default: (0, 0, 0)\n"
|
||||||
|
" scale (float): Uniform scale factor. Default: 1.0\n"
|
||||||
|
" facing (str): Facing mode - 'camera', 'camera_y', or 'fixed'. Default: 'camera_y'\n\n"
|
||||||
|
"Properties:\n"
|
||||||
|
" texture (Texture): Sprite sheet texture\n"
|
||||||
|
" sprite_index (int): Index into sprite sheet\n"
|
||||||
|
" pos (tuple): World position (x, y, z)\n"
|
||||||
|
" scale (float): Uniform scale factor\n"
|
||||||
|
" facing (str): Facing mode - 'camera', 'camera_y', or 'fixed'\n"
|
||||||
|
" theta (float): Horizontal rotation for 'fixed' mode (radians)\n"
|
||||||
|
" phi (float): Vertical tilt for 'fixed' mode (radians)\n"
|
||||||
|
" opacity (float): Opacity 0.0 (transparent) to 1.0 (opaque)\n"
|
||||||
|
" visible (bool): Visibility state"
|
||||||
|
),
|
||||||
|
.tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
.tp_clear = [](PyObject* self) -> int {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
.tp_getset = mcrf::Billboard::getsetters,
|
||||||
|
.tp_init = mcrf::Billboard::init,
|
||||||
|
.tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject*
|
||||||
|
{
|
||||||
|
PyBillboardObject* self = (PyBillboardObject*)type->tp_alloc(type, 0);
|
||||||
|
if (self) {
|
||||||
|
// Use placement new to properly construct the shared_ptr
|
||||||
|
// tp_alloc zeroes memory but doesn't call C++ constructors
|
||||||
|
new (&self->data) std::shared_ptr<mcrf::Billboard>(std::make_shared<mcrf::Billboard>());
|
||||||
|
self->weakreflist = nullptr;
|
||||||
|
}
|
||||||
|
return (PyObject*)self;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mcrfpydef
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
#include "Entity3D.h"
|
#include "Entity3D.h"
|
||||||
#include "Viewport3D.h"
|
#include "Viewport3D.h"
|
||||||
#include "VoxelPoint.h"
|
#include "VoxelPoint.h"
|
||||||
|
#include "Model3D.h"
|
||||||
|
#include "Shader3D.h"
|
||||||
#include "PyVector.h"
|
#include "PyVector.h"
|
||||||
#include "PyColor.h"
|
#include "PyColor.h"
|
||||||
#include "PythonObjectCache.h"
|
#include "PythonObjectCache.h"
|
||||||
|
|
@ -54,6 +56,10 @@ Entity3D::~Entity3D()
|
||||||
{
|
{
|
||||||
// Cleanup cube geometry when last entity is destroyed?
|
// Cleanup cube geometry when last entity is destroyed?
|
||||||
// For now, leave it - it's shared static data
|
// For now, leave it - it's shared static data
|
||||||
|
|
||||||
|
// Clean up Python animation callback
|
||||||
|
Py_XDECREF(py_anim_callback_);
|
||||||
|
py_anim_callback_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -281,8 +287,8 @@ void Entity3D::processNextMove()
|
||||||
|
|
||||||
void Entity3D::update(float dt)
|
void Entity3D::update(float dt)
|
||||||
{
|
{
|
||||||
if (!is_animating_) return;
|
// Update movement animation
|
||||||
|
if (is_animating_) {
|
||||||
move_progress_ += dt * move_speed_;
|
move_progress_ += dt * move_speed_;
|
||||||
|
|
||||||
if (move_progress_ >= 1.0f) {
|
if (move_progress_ >= 1.0f) {
|
||||||
|
|
@ -300,6 +306,10 @@ void Entity3D::update(float dt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update skeletal animation
|
||||||
|
updateAnimation(dt);
|
||||||
|
}
|
||||||
|
|
||||||
bool Entity3D::setProperty(const std::string& name, float value)
|
bool Entity3D::setProperty(const std::string& name, float value)
|
||||||
{
|
{
|
||||||
if (name == "x" || name == "world_x") {
|
if (name == "x" || name == "world_x") {
|
||||||
|
|
@ -384,6 +394,111 @@ bool Entity3D::hasProperty(const std::string& name) const
|
||||||
name == "sprite_index" || name == "visible";
|
name == "sprite_index" || name == "visible";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Skeletal Animation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void Entity3D::setAnimClip(const std::string& name)
|
||||||
|
{
|
||||||
|
if (anim_clip_ == name) return;
|
||||||
|
|
||||||
|
anim_clip_ = name;
|
||||||
|
anim_time_ = 0.0f;
|
||||||
|
anim_paused_ = false;
|
||||||
|
|
||||||
|
// Initialize bone matrices if model has skeleton
|
||||||
|
if (model_ && model_->hasSkeleton()) {
|
||||||
|
size_t bone_count = model_->getBoneCount();
|
||||||
|
bone_matrices_.resize(bone_count);
|
||||||
|
for (auto& m : bone_matrices_) {
|
||||||
|
m = mat4::identity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Entity3D::updateAnimation(float dt)
|
||||||
|
{
|
||||||
|
// Handle auto-animate (play walk/idle based on movement state)
|
||||||
|
if (auto_animate_ && model_ && model_->hasSkeleton()) {
|
||||||
|
bool currently_moving = isMoving();
|
||||||
|
if (currently_moving != was_moving_) {
|
||||||
|
was_moving_ = currently_moving;
|
||||||
|
if (currently_moving) {
|
||||||
|
// Started moving - play walk clip
|
||||||
|
if (model_->findClip(walk_clip_)) {
|
||||||
|
setAnimClip(walk_clip_);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Stopped moving - play idle clip
|
||||||
|
if (model_->findClip(idle_clip_)) {
|
||||||
|
setAnimClip(idle_clip_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early out if no model, no skeleton, or no animation
|
||||||
|
if (!model_ || !model_->hasSkeleton()) return;
|
||||||
|
if (anim_clip_.empty() || anim_paused_) return;
|
||||||
|
|
||||||
|
const AnimationClip* clip = model_->findClip(anim_clip_);
|
||||||
|
if (!clip) return;
|
||||||
|
|
||||||
|
// Advance time
|
||||||
|
anim_time_ += dt * anim_speed_;
|
||||||
|
|
||||||
|
// Handle loop/completion
|
||||||
|
if (anim_time_ >= clip->duration) {
|
||||||
|
if (anim_loop_) {
|
||||||
|
anim_time_ = std::fmod(anim_time_, clip->duration);
|
||||||
|
} else {
|
||||||
|
anim_time_ = clip->duration;
|
||||||
|
anim_paused_ = true;
|
||||||
|
|
||||||
|
// Fire callback
|
||||||
|
if (on_anim_complete_) {
|
||||||
|
on_anim_complete_(this, anim_clip_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire Python callback
|
||||||
|
if (py_anim_callback_) {
|
||||||
|
PyObject* result = PyObject_CallFunction(py_anim_callback_, "(Os)",
|
||||||
|
self, anim_clip_.c_str());
|
||||||
|
if (result) {
|
||||||
|
Py_DECREF(result);
|
||||||
|
} else {
|
||||||
|
PyErr_Print();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample animation
|
||||||
|
const Skeleton& skeleton = model_->getSkeleton();
|
||||||
|
const std::vector<mat4>& default_transforms = model_->getDefaultBoneTransforms();
|
||||||
|
|
||||||
|
std::vector<mat4> local_transforms;
|
||||||
|
clip->sample(anim_time_, skeleton.bones.size(), default_transforms, local_transforms);
|
||||||
|
|
||||||
|
// Compute global transforms
|
||||||
|
std::vector<mat4> global_transforms;
|
||||||
|
skeleton.computeGlobalTransforms(local_transforms, global_transforms);
|
||||||
|
|
||||||
|
// Compute final bone matrices (global * inverse_bind)
|
||||||
|
skeleton.computeBoneMatrices(global_transforms, bone_matrices_);
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::getAnimFrame() const
|
||||||
|
{
|
||||||
|
if (!model_ || !model_->hasSkeleton()) return 0;
|
||||||
|
|
||||||
|
const AnimationClip* clip = model_->findClip(anim_clip_);
|
||||||
|
if (!clip || clip->duration <= 0) return 0;
|
||||||
|
|
||||||
|
// Approximate frame at 30fps
|
||||||
|
return static_cast<int>(anim_time_ * 30.0f);
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Rendering
|
// Rendering
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -467,6 +582,30 @@ void Entity3D::render(const mat4& view, const mat4& proj, unsigned int shader)
|
||||||
{
|
{
|
||||||
if (!visible_) return;
|
if (!visible_) return;
|
||||||
|
|
||||||
|
// Set entity color uniform (used by Model3D and placeholder)
|
||||||
|
int colorLoc = glGetUniformLocation(shader, "u_entityColor");
|
||||||
|
if (colorLoc >= 0) {
|
||||||
|
glUniform4f(colorLoc,
|
||||||
|
color_.r / 255.0f,
|
||||||
|
color_.g / 255.0f,
|
||||||
|
color_.b / 255.0f,
|
||||||
|
color_.a / 255.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a model, use it
|
||||||
|
if (model_) {
|
||||||
|
mat4 model = getModelMatrix();
|
||||||
|
|
||||||
|
// Use skinned rendering if model has skeleton and we have bone matrices
|
||||||
|
if (model_->hasSkeleton() && !bone_matrices_.empty()) {
|
||||||
|
model_->renderSkinned(shader, model, view, proj, bone_matrices_);
|
||||||
|
} else {
|
||||||
|
model_->render(shader, model, view, proj);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, fall back to placeholder cube
|
||||||
// Initialize cube geometry if needed
|
// Initialize cube geometry if needed
|
||||||
if (!cubeInitialized_) {
|
if (!cubeInitialized_) {
|
||||||
initCubeGeometry();
|
initCubeGeometry();
|
||||||
|
|
@ -479,17 +618,9 @@ void Entity3D::render(const mat4& view, const mat4& proj, unsigned int shader)
|
||||||
// Get uniform locations (assuming shader is already bound)
|
// Get uniform locations (assuming shader is already bound)
|
||||||
int mvpLoc = glGetUniformLocation(shader, "u_mvp");
|
int mvpLoc = glGetUniformLocation(shader, "u_mvp");
|
||||||
int modelLoc = glGetUniformLocation(shader, "u_model");
|
int modelLoc = glGetUniformLocation(shader, "u_model");
|
||||||
int colorLoc = glGetUniformLocation(shader, "u_entityColor");
|
|
||||||
|
|
||||||
if (mvpLoc >= 0) glUniformMatrix4fv(mvpLoc, 1, GL_FALSE, mvp.data());
|
if (mvpLoc >= 0) glUniformMatrix4fv(mvpLoc, 1, GL_FALSE, mvp.data());
|
||||||
if (modelLoc >= 0) glUniformMatrix4fv(modelLoc, 1, GL_FALSE, model.data());
|
if (modelLoc >= 0) glUniformMatrix4fv(modelLoc, 1, GL_FALSE, model.data());
|
||||||
if (colorLoc >= 0) {
|
|
||||||
glUniform4f(colorLoc,
|
|
||||||
color_.r / 255.0f,
|
|
||||||
color_.g / 255.0f,
|
|
||||||
color_.b / 255.0f,
|
|
||||||
color_.a / 255.0f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind VBO and set up attributes
|
// Bind VBO and set up attributes
|
||||||
glBindBuffer(GL_ARRAY_BUFFER, cubeVBO_);
|
glBindBuffer(GL_ARRAY_BUFFER, cubeVBO_);
|
||||||
|
|
@ -715,6 +846,182 @@ PyObject* Entity3D::get_viewport(PyEntity3DObject* self, void* closure)
|
||||||
Py_RETURN_NONE;
|
Py_RETURN_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_model(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
auto model = self->data->getModel();
|
||||||
|
if (!model) {
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Python Model3D object wrapping the shared_ptr
|
||||||
|
PyTypeObject* type = &mcrfpydef::PyModel3DType;
|
||||||
|
PyModel3DObject* obj = (PyModel3DObject*)type->tp_alloc(type, 0);
|
||||||
|
if (!obj) return NULL;
|
||||||
|
|
||||||
|
obj->data = model;
|
||||||
|
obj->weakreflist = nullptr;
|
||||||
|
|
||||||
|
return (PyObject*)obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_model(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
if (value == Py_None) {
|
||||||
|
self->data->setModel(nullptr);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyModel3DType)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "model must be a Model3D or None");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyModel3DObject* model_obj = (PyModel3DObject*)value;
|
||||||
|
self->data->setModel(model_obj->data);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation property getters/setters
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_anim_clip(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyUnicode_FromString(self->data->getAnimClip().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_anim_clip(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
if (!PyUnicode_Check(value)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "anim_clip must be a string");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
self->data->setAnimClip(PyUnicode_AsUTF8(value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_anim_time(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyFloat_FromDouble(self->data->getAnimTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_anim_time(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
if (!PyNumber_Check(value)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "anim_time must be a number");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
self->data->setAnimTime((float)PyFloat_AsDouble(value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_anim_speed(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyFloat_FromDouble(self->data->getAnimSpeed());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_anim_speed(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
if (!PyNumber_Check(value)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "anim_speed must be a number");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
self->data->setAnimSpeed((float)PyFloat_AsDouble(value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_anim_loop(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyBool_FromLong(self->data->getAnimLoop() ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_anim_loop(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
self->data->setAnimLoop(PyObject_IsTrue(value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_anim_paused(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyBool_FromLong(self->data->getAnimPaused() ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_anim_paused(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
self->data->setAnimPaused(PyObject_IsTrue(value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_anim_frame(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyLong_FromLong(self->data->getAnimFrame());
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_on_anim_complete(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
if (self->data->py_anim_callback_) {
|
||||||
|
Py_INCREF(self->data->py_anim_callback_);
|
||||||
|
return self->data->py_anim_callback_;
|
||||||
|
}
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_on_anim_complete(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
// Clear existing callback
|
||||||
|
Py_XDECREF(self->data->py_anim_callback_);
|
||||||
|
|
||||||
|
if (value == Py_None) {
|
||||||
|
self->data->py_anim_callback_ = nullptr;
|
||||||
|
} else if (PyCallable_Check(value)) {
|
||||||
|
Py_INCREF(value);
|
||||||
|
self->data->py_anim_callback_ = value;
|
||||||
|
} else {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "on_anim_complete must be callable or None");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_auto_animate(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyBool_FromLong(self->data->getAutoAnimate() ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_auto_animate(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
self->data->setAutoAnimate(PyObject_IsTrue(value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_walk_clip(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyUnicode_FromString(self->data->getWalkClip().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_walk_clip(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
if (!PyUnicode_Check(value)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "walk_clip must be a string");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
self->data->setWalkClip(PyUnicode_AsUTF8(value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_idle_clip(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyUnicode_FromString(self->data->getIdleClip().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
int Entity3D::set_idle_clip(PyEntity3DObject* self, PyObject* value, void* closure)
|
||||||
|
{
|
||||||
|
if (!PyUnicode_Check(value)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "idle_clip must be a string");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
self->data->setIdleClip(PyUnicode_AsUTF8(value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
|
|
||||||
PyObject* Entity3D::py_path_to(PyEntity3DObject* self, PyObject* args, PyObject* kwds)
|
PyObject* Entity3D::py_path_to(PyEntity3DObject* self, PyObject* args, PyObject* kwds)
|
||||||
|
|
@ -814,6 +1121,47 @@ PyObject* Entity3D::py_animate(PyEntity3DObject* self, PyObject* args, PyObject*
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::py_follow_path(PyEntity3DObject* self, PyObject* args)
|
||||||
|
{
|
||||||
|
PyObject* path_list;
|
||||||
|
if (!PyArg_ParseTuple(args, "O", &path_list)) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PyList_Check(path_list)) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "follow_path() requires a list of (x, z) tuples");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<int, int>> path;
|
||||||
|
Py_ssize_t len = PyList_Size(path_list);
|
||||||
|
for (Py_ssize_t i = 0; i < len; ++i) {
|
||||||
|
PyObject* item = PyList_GetItem(path_list, i);
|
||||||
|
if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "Each path element must be (x, z) tuple");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
int x = static_cast<int>(PyLong_AsLong(PyTuple_GetItem(item, 0)));
|
||||||
|
int z = static_cast<int>(PyLong_AsLong(PyTuple_GetItem(item, 1)));
|
||||||
|
if (PyErr_Occurred()) return NULL;
|
||||||
|
path.emplace_back(x, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
self->data->followPath(path);
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::py_clear_path(PyEntity3DObject* self, PyObject* args)
|
||||||
|
{
|
||||||
|
self->data->clearPath();
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* Entity3D::get_is_moving(PyEntity3DObject* self, void* closure)
|
||||||
|
{
|
||||||
|
return PyBool_FromLong(self->data->isMoving() ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Method and GetSet tables
|
// Method and GetSet tables
|
||||||
|
|
||||||
PyMethodDef Entity3D::methods[] = {
|
PyMethodDef Entity3D::methods[] = {
|
||||||
|
|
@ -834,6 +1182,14 @@ PyMethodDef Entity3D::methods[] = {
|
||||||
{"animate", (PyCFunction)Entity3D::py_animate, METH_VARARGS | METH_KEYWORDS,
|
{"animate", (PyCFunction)Entity3D::py_animate, METH_VARARGS | METH_KEYWORDS,
|
||||||
"animate(property, target, duration, easing=None, callback=None)\n\n"
|
"animate(property, target, duration, easing=None, callback=None)\n\n"
|
||||||
"Animate a property over time. (Not yet implemented)"},
|
"Animate a property over time. (Not yet implemented)"},
|
||||||
|
{"follow_path", (PyCFunction)Entity3D::py_follow_path, METH_VARARGS,
|
||||||
|
"follow_path(path)\n\n"
|
||||||
|
"Queue path positions for smooth movement.\n\n"
|
||||||
|
"Args:\n"
|
||||||
|
" path: List of (x, z) tuples (as returned by path_to())"},
|
||||||
|
{"clear_path", (PyCFunction)Entity3D::py_clear_path, METH_NOARGS,
|
||||||
|
"clear_path()\n\n"
|
||||||
|
"Clear the movement queue and stop at current position."},
|
||||||
{NULL} // Sentinel
|
{NULL} // Sentinel
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -854,6 +1210,33 @@ PyGetSetDef Entity3D::getsetters[] = {
|
||||||
"Entity render color.", NULL},
|
"Entity render color.", NULL},
|
||||||
{"viewport", (getter)Entity3D::get_viewport, NULL,
|
{"viewport", (getter)Entity3D::get_viewport, NULL,
|
||||||
"Owning Viewport3D (read-only).", NULL},
|
"Owning Viewport3D (read-only).", NULL},
|
||||||
|
{"model", (getter)Entity3D::get_model, (setter)Entity3D::set_model,
|
||||||
|
"3D model (Model3D). If None, uses placeholder cube.", NULL},
|
||||||
|
|
||||||
|
// Animation properties
|
||||||
|
{"anim_clip", (getter)Entity3D::get_anim_clip, (setter)Entity3D::set_anim_clip,
|
||||||
|
"Current animation clip name. Set to play an animation.", NULL},
|
||||||
|
{"anim_time", (getter)Entity3D::get_anim_time, (setter)Entity3D::set_anim_time,
|
||||||
|
"Current time position in animation (seconds).", NULL},
|
||||||
|
{"anim_speed", (getter)Entity3D::get_anim_speed, (setter)Entity3D::set_anim_speed,
|
||||||
|
"Animation playback speed multiplier. 1.0 = normal speed.", NULL},
|
||||||
|
{"anim_loop", (getter)Entity3D::get_anim_loop, (setter)Entity3D::set_anim_loop,
|
||||||
|
"Whether animation loops when it reaches the end.", NULL},
|
||||||
|
{"anim_paused", (getter)Entity3D::get_anim_paused, (setter)Entity3D::set_anim_paused,
|
||||||
|
"Whether animation playback is paused.", NULL},
|
||||||
|
{"anim_frame", (getter)Entity3D::get_anim_frame, NULL,
|
||||||
|
"Current animation frame number (read-only, approximate at 30fps).", NULL},
|
||||||
|
{"on_anim_complete", (getter)Entity3D::get_on_anim_complete, (setter)Entity3D::set_on_anim_complete,
|
||||||
|
"Callback(entity, clip_name) when non-looping animation ends.", NULL},
|
||||||
|
{"auto_animate", (getter)Entity3D::get_auto_animate, (setter)Entity3D::set_auto_animate,
|
||||||
|
"Enable auto-play of walk/idle clips based on movement.", NULL},
|
||||||
|
{"walk_clip", (getter)Entity3D::get_walk_clip, (setter)Entity3D::set_walk_clip,
|
||||||
|
"Animation clip to play when entity is moving.", NULL},
|
||||||
|
{"idle_clip", (getter)Entity3D::get_idle_clip, (setter)Entity3D::set_idle_clip,
|
||||||
|
"Animation clip to play when entity is stationary.", NULL},
|
||||||
|
{"is_moving", (getter)Entity3D::get_is_moving, NULL,
|
||||||
|
"Whether entity is currently moving (read-only).", NULL},
|
||||||
|
|
||||||
{NULL} // Sentinel
|
{NULL} // Sentinel
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@
|
||||||
#include <queue>
|
#include <queue>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
namespace mcrf {
|
namespace mcrf {
|
||||||
|
|
||||||
// Forward declarations
|
// Forward declarations
|
||||||
class Viewport3D;
|
class Viewport3D;
|
||||||
|
class Model3D;
|
||||||
|
|
||||||
} // namespace mcrf
|
} // namespace mcrf
|
||||||
|
|
||||||
|
|
@ -94,6 +96,10 @@ public:
|
||||||
int getSpriteIndex() const { return sprite_index_; }
|
int getSpriteIndex() const { return sprite_index_; }
|
||||||
void setSpriteIndex(int idx) { sprite_index_ = idx; }
|
void setSpriteIndex(int idx) { sprite_index_ = idx; }
|
||||||
|
|
||||||
|
// 3D model (if null, uses placeholder cube)
|
||||||
|
std::shared_ptr<Model3D> getModel() const { return model_; }
|
||||||
|
void setModel(std::shared_ptr<Model3D> m) { model_ = m; }
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Viewport Integration
|
// Viewport Integration
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -153,6 +159,57 @@ public:
|
||||||
bool getProperty(const std::string& name, float& value) const;
|
bool getProperty(const std::string& name, float& value) const;
|
||||||
bool hasProperty(const std::string& name) const;
|
bool hasProperty(const std::string& name) const;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Skeletal Animation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Get current animation clip name
|
||||||
|
const std::string& getAnimClip() const { return anim_clip_; }
|
||||||
|
|
||||||
|
/// Set animation clip by name (starts playing)
|
||||||
|
void setAnimClip(const std::string& name);
|
||||||
|
|
||||||
|
/// Get/set animation time (position in clip)
|
||||||
|
float getAnimTime() const { return anim_time_; }
|
||||||
|
void setAnimTime(float t) { anim_time_ = t; }
|
||||||
|
|
||||||
|
/// Get/set playback speed (1.0 = normal)
|
||||||
|
float getAnimSpeed() const { return anim_speed_; }
|
||||||
|
void setAnimSpeed(float s) { anim_speed_ = s; }
|
||||||
|
|
||||||
|
/// Get/set looping state
|
||||||
|
bool getAnimLoop() const { return anim_loop_; }
|
||||||
|
void setAnimLoop(bool l) { anim_loop_ = l; }
|
||||||
|
|
||||||
|
/// Get/set pause state
|
||||||
|
bool getAnimPaused() const { return anim_paused_; }
|
||||||
|
void setAnimPaused(bool p) { anim_paused_ = p; }
|
||||||
|
|
||||||
|
/// Get current animation frame (approximate)
|
||||||
|
int getAnimFrame() const;
|
||||||
|
|
||||||
|
/// Update skeletal animation (call before render)
|
||||||
|
void updateAnimation(float dt);
|
||||||
|
|
||||||
|
/// Get computed bone matrices for shader
|
||||||
|
const std::vector<mat4>& getBoneMatrices() const { return bone_matrices_; }
|
||||||
|
|
||||||
|
/// Animation complete callback type
|
||||||
|
using AnimCompleteCallback = std::function<void(Entity3D*, const std::string&)>;
|
||||||
|
|
||||||
|
/// Set animation complete callback
|
||||||
|
void setOnAnimComplete(AnimCompleteCallback cb) { on_anim_complete_ = cb; }
|
||||||
|
|
||||||
|
/// Auto-animate settings (play walk/idle based on movement)
|
||||||
|
bool getAutoAnimate() const { return auto_animate_; }
|
||||||
|
void setAutoAnimate(bool a) { auto_animate_ = a; }
|
||||||
|
|
||||||
|
const std::string& getWalkClip() const { return walk_clip_; }
|
||||||
|
void setWalkClip(const std::string& c) { walk_clip_ = c; }
|
||||||
|
|
||||||
|
const std::string& getIdleClip() const { return idle_clip_; }
|
||||||
|
void setIdleClip(const std::string& c) { idle_clip_ = c; }
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Rendering
|
// Rendering
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -185,6 +242,29 @@ public:
|
||||||
static PyObject* get_color(PyEntity3DObject* self, void* closure);
|
static PyObject* get_color(PyEntity3DObject* self, void* closure);
|
||||||
static int set_color(PyEntity3DObject* self, PyObject* value, void* closure);
|
static int set_color(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
static PyObject* get_viewport(PyEntity3DObject* self, void* closure);
|
static PyObject* get_viewport(PyEntity3DObject* self, void* closure);
|
||||||
|
static PyObject* get_model(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_model(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
|
||||||
|
// Animation property getters/setters
|
||||||
|
static PyObject* get_anim_clip(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_anim_clip(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_anim_time(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_anim_time(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_anim_speed(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_anim_speed(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_anim_loop(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_anim_loop(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_anim_paused(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_anim_paused(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_anim_frame(PyEntity3DObject* self, void* closure);
|
||||||
|
static PyObject* get_on_anim_complete(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_on_anim_complete(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_auto_animate(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_auto_animate(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_walk_clip(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_walk_clip(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
static PyObject* get_idle_clip(PyEntity3DObject* self, void* closure);
|
||||||
|
static int set_idle_clip(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
static PyObject* py_path_to(PyEntity3DObject* self, PyObject* args, PyObject* kwds);
|
static PyObject* py_path_to(PyEntity3DObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
|
@ -192,6 +272,9 @@ public:
|
||||||
static PyObject* py_at(PyEntity3DObject* self, PyObject* args, PyObject* kwds);
|
static PyObject* py_at(PyEntity3DObject* self, PyObject* args, PyObject* kwds);
|
||||||
static PyObject* py_update_visibility(PyEntity3DObject* self, PyObject* args);
|
static PyObject* py_update_visibility(PyEntity3DObject* self, PyObject* args);
|
||||||
static PyObject* py_animate(PyEntity3DObject* self, PyObject* args, PyObject* kwds);
|
static PyObject* py_animate(PyEntity3DObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
static PyObject* py_follow_path(PyEntity3DObject* self, PyObject* args);
|
||||||
|
static PyObject* py_clear_path(PyEntity3DObject* self, PyObject* args);
|
||||||
|
static PyObject* get_is_moving(PyEntity3DObject* self, void* closure);
|
||||||
|
|
||||||
static PyMethodDef methods[];
|
static PyMethodDef methods[];
|
||||||
static PyGetSetDef getsetters[];
|
static PyGetSetDef getsetters[];
|
||||||
|
|
@ -217,6 +300,7 @@ private:
|
||||||
bool visible_ = true;
|
bool visible_ = true;
|
||||||
sf::Color color_ = sf::Color(200, 100, 50); // Default orange
|
sf::Color color_ = sf::Color(200, 100, 50); // Default orange
|
||||||
int sprite_index_ = 0;
|
int sprite_index_ = 0;
|
||||||
|
std::shared_ptr<Model3D> model_; // 3D model (null = placeholder cube)
|
||||||
|
|
||||||
// Viewport (weak reference to avoid cycles)
|
// Viewport (weak reference to avoid cycles)
|
||||||
std::weak_ptr<Viewport3D> viewport_;
|
std::weak_ptr<Viewport3D> viewport_;
|
||||||
|
|
@ -232,6 +316,24 @@ private:
|
||||||
float move_speed_ = 5.0f; // Cells per second
|
float move_speed_ = 5.0f; // Cells per second
|
||||||
vec3 move_start_pos_;
|
vec3 move_start_pos_;
|
||||||
|
|
||||||
|
// Skeletal animation state
|
||||||
|
std::string anim_clip_; // Current animation clip name
|
||||||
|
float anim_time_ = 0.0f; // Current time in animation
|
||||||
|
float anim_speed_ = 1.0f; // Playback speed multiplier
|
||||||
|
bool anim_loop_ = true; // Loop animation
|
||||||
|
bool anim_paused_ = false; // Pause playback
|
||||||
|
std::vector<mat4> bone_matrices_; // Computed bone matrices for shader
|
||||||
|
AnimCompleteCallback on_anim_complete_; // Callback when animation ends
|
||||||
|
|
||||||
|
// Auto-animate state
|
||||||
|
bool auto_animate_ = true; // Auto-play walk/idle based on movement
|
||||||
|
std::string walk_clip_ = "walk"; // Clip to play when moving
|
||||||
|
std::string idle_clip_ = "idle"; // Clip to play when stopped
|
||||||
|
bool was_moving_ = false; // Track movement state for auto-animate
|
||||||
|
|
||||||
|
// Python callback for animation complete
|
||||||
|
PyObject* py_anim_callback_ = nullptr;
|
||||||
|
|
||||||
// Helper to initialize voxel state
|
// Helper to initialize voxel state
|
||||||
void initVoxelState() const;
|
void initVoxelState() const;
|
||||||
|
|
||||||
|
|
|
||||||
110
src/3d/Math3D.h
110
src/3d/Math3D.h
|
|
@ -610,6 +610,116 @@ struct quat {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Frustum - View frustum for culling
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct Plane {
|
||||||
|
vec3 normal;
|
||||||
|
float distance;
|
||||||
|
|
||||||
|
Plane() : normal(0, 1, 0), distance(0) {}
|
||||||
|
Plane(const vec3& n, float d) : normal(n), distance(d) {}
|
||||||
|
|
||||||
|
// Distance from plane to point (positive = in front, negative = behind)
|
||||||
|
float distanceToPoint(const vec3& point) const {
|
||||||
|
return normal.dot(point) + distance;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Frustum {
|
||||||
|
// Six planes: left, right, bottom, top, near, far
|
||||||
|
Plane planes[6];
|
||||||
|
|
||||||
|
// Extract frustum planes from view-projection matrix
|
||||||
|
// Uses Gribb/Hartmann method
|
||||||
|
void extractFromMatrix(const mat4& viewProj) {
|
||||||
|
const float* m = viewProj.m;
|
||||||
|
|
||||||
|
// Left plane
|
||||||
|
planes[0].normal.x = m[3] + m[0];
|
||||||
|
planes[0].normal.y = m[7] + m[4];
|
||||||
|
planes[0].normal.z = m[11] + m[8];
|
||||||
|
planes[0].distance = m[15] + m[12];
|
||||||
|
|
||||||
|
// Right plane
|
||||||
|
planes[1].normal.x = m[3] - m[0];
|
||||||
|
planes[1].normal.y = m[7] - m[4];
|
||||||
|
planes[1].normal.z = m[11] - m[8];
|
||||||
|
planes[1].distance = m[15] - m[12];
|
||||||
|
|
||||||
|
// Bottom plane
|
||||||
|
planes[2].normal.x = m[3] + m[1];
|
||||||
|
planes[2].normal.y = m[7] + m[5];
|
||||||
|
planes[2].normal.z = m[11] + m[9];
|
||||||
|
planes[2].distance = m[15] + m[13];
|
||||||
|
|
||||||
|
// Top plane
|
||||||
|
planes[3].normal.x = m[3] - m[1];
|
||||||
|
planes[3].normal.y = m[7] - m[5];
|
||||||
|
planes[3].normal.z = m[11] - m[9];
|
||||||
|
planes[3].distance = m[15] - m[13];
|
||||||
|
|
||||||
|
// Near plane
|
||||||
|
planes[4].normal.x = m[3] + m[2];
|
||||||
|
planes[4].normal.y = m[7] + m[6];
|
||||||
|
planes[4].normal.z = m[11] + m[10];
|
||||||
|
planes[4].distance = m[15] + m[14];
|
||||||
|
|
||||||
|
// Far plane
|
||||||
|
planes[5].normal.x = m[3] - m[2];
|
||||||
|
planes[5].normal.y = m[7] - m[6];
|
||||||
|
planes[5].normal.z = m[11] - m[10];
|
||||||
|
planes[5].distance = m[15] - m[14];
|
||||||
|
|
||||||
|
// Normalize all planes
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
float len = planes[i].normal.length();
|
||||||
|
if (len > 0.0001f) {
|
||||||
|
planes[i].normal = planes[i].normal / len;
|
||||||
|
planes[i].distance /= len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if a point is inside the frustum
|
||||||
|
bool containsPoint(const vec3& point) const {
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
if (planes[i].distanceToPoint(point) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if a sphere intersects or is inside the frustum
|
||||||
|
bool containsSphere(const vec3& center, float radius) const {
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
if (planes[i].distanceToPoint(center) < -radius) {
|
||||||
|
return false; // Sphere is completely behind this plane
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if an axis-aligned bounding box intersects the frustum
|
||||||
|
bool containsAABB(const vec3& min, const vec3& max) const {
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
// Find the positive vertex (furthest along plane normal)
|
||||||
|
vec3 pVertex;
|
||||||
|
pVertex.x = (planes[i].normal.x >= 0) ? max.x : min.x;
|
||||||
|
pVertex.y = (planes[i].normal.y >= 0) ? max.y : min.y;
|
||||||
|
pVertex.z = (planes[i].normal.z >= 0) ? max.z : min.z;
|
||||||
|
|
||||||
|
// If positive vertex is behind plane, box is outside
|
||||||
|
if (planes[i].distanceToPoint(pVertex) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Utility constants and functions
|
// Utility constants and functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
// MeshLayer.cpp - Static 3D geometry layer implementation
|
// MeshLayer.cpp - Static 3D geometry layer implementation
|
||||||
|
|
||||||
#include "MeshLayer.h"
|
#include "MeshLayer.h"
|
||||||
|
#include "Model3D.h"
|
||||||
|
#include "Viewport3D.h"
|
||||||
#include "Shader3D.h"
|
#include "Shader3D.h"
|
||||||
#include "../platform/GLContext.h"
|
#include "../platform/GLContext.h"
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
// GL headers based on backend
|
// GL headers based on backend
|
||||||
#if defined(MCRF_SDL2)
|
#if defined(MCRF_SDL2)
|
||||||
|
|
@ -377,21 +380,20 @@ void MeshLayer::uploadToGPU() {
|
||||||
// Rendering
|
// Rendering
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
void MeshLayer::render(const mat4& model, const mat4& view, const mat4& projection) {
|
void MeshLayer::render(unsigned int shader, const mat4& model, const mat4& view, const mat4& projection) {
|
||||||
#ifdef MCRF_HAS_GL
|
#ifdef MCRF_HAS_GL
|
||||||
if (!gl::isGLReady() || vertices_.empty()) {
|
if (!gl::isGLReady()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render terrain geometry if present
|
||||||
|
if (!vertices_.empty()) {
|
||||||
// Upload to GPU if needed
|
// Upload to GPU if needed
|
||||||
if (dirty_ || vbo_ == 0) {
|
if (dirty_ || vbo_ == 0) {
|
||||||
uploadToGPU();
|
uploadToGPU();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vbo_ == 0) {
|
if (vbo_ != 0) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind VBO
|
// Bind VBO
|
||||||
glBindBuffer(GL_ARRAY_BUFFER, vbo_);
|
glBindBuffer(GL_ARRAY_BUFFER, vbo_);
|
||||||
|
|
||||||
|
|
@ -424,6 +426,11 @@ void MeshLayer::render(const mat4& model, const mat4& view, const mat4& projecti
|
||||||
glDisableVertexAttribArray(Shader3D::ATTRIB_NORMAL);
|
glDisableVertexAttribArray(Shader3D::ATTRIB_NORMAL);
|
||||||
glDisableVertexAttribArray(Shader3D::ATTRIB_COLOR);
|
glDisableVertexAttribArray(Shader3D::ATTRIB_COLOR);
|
||||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render mesh instances
|
||||||
|
renderMeshInstances(shader, view, projection);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,6 +453,114 @@ vec3 MeshLayer::computeFaceNormal(const vec3& v0, const vec3& v1, const vec3& v2
|
||||||
return edge1.cross(edge2).normalized();
|
return edge1.cross(edge2).normalized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mesh Instances
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
size_t MeshLayer::addMesh(std::shared_ptr<Model3D> model, const vec3& pos,
|
||||||
|
float rotation, const vec3& scale) {
|
||||||
|
MeshInstance instance(model, pos, rotation, scale);
|
||||||
|
meshInstances_.push_back(std::move(instance));
|
||||||
|
return meshInstances_.size() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MeshLayer::removeMesh(size_t index) {
|
||||||
|
if (index < meshInstances_.size()) {
|
||||||
|
meshInstances_.erase(meshInstances_.begin() + index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MeshLayer::clearMeshes() {
|
||||||
|
meshInstances_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshInstance* MeshLayer::getMeshInstance(size_t index) {
|
||||||
|
if (index < meshInstances_.size()) {
|
||||||
|
return &meshInstances_[index];
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MeshInstance* MeshLayer::getMeshInstance(size_t index) const {
|
||||||
|
if (index < meshInstances_.size()) {
|
||||||
|
return &meshInstances_[index];
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MeshLayer::renderMeshInstances(unsigned int shader, const mat4& view, const mat4& projection) {
|
||||||
|
#ifdef MCRF_HAS_GL
|
||||||
|
if (!gl::isGLReady() || meshInstances_.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& inst : meshInstances_) {
|
||||||
|
if (!inst.model) continue;
|
||||||
|
|
||||||
|
// Build model matrix: translate * rotateY * scale
|
||||||
|
mat4 model = mat4::identity();
|
||||||
|
model = model * mat4::translate(inst.position);
|
||||||
|
model = model * mat4::rotateY(inst.rotation * 3.14159265f / 180.0f);
|
||||||
|
model = model * mat4::scale(inst.scale);
|
||||||
|
|
||||||
|
// Render the model
|
||||||
|
inst.model->render(shader, model, view, projection);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Collision Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void MeshLayer::placeBlocking(int gridX, int gridZ, int footprintW, int footprintD,
|
||||||
|
bool walkable, bool transparent) {
|
||||||
|
if (!viewport_) return;
|
||||||
|
|
||||||
|
for (int dz = 0; dz < footprintD; dz++) {
|
||||||
|
for (int dx = 0; dx < footprintW; dx++) {
|
||||||
|
int cx = gridX + dx;
|
||||||
|
int cz = gridZ + dz;
|
||||||
|
if (viewport_->isValidCell(cx, cz)) {
|
||||||
|
VoxelPoint& cell = viewport_->at(cx, cz);
|
||||||
|
cell.walkable = walkable;
|
||||||
|
cell.transparent = transparent;
|
||||||
|
viewport_->syncTCODCell(cx, cz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MeshLayer::placeBlockingAuto(std::shared_ptr<Model3D> model, const vec3& worldPos,
|
||||||
|
float rotation, bool walkable, bool transparent) {
|
||||||
|
if (!viewport_ || !model) return;
|
||||||
|
|
||||||
|
float cellSize = viewport_->getCellSize();
|
||||||
|
if (cellSize <= 0) cellSize = 1.0f;
|
||||||
|
|
||||||
|
// Get model bounds
|
||||||
|
auto [minBounds, maxBounds] = model->getBounds();
|
||||||
|
|
||||||
|
// Calculate world-space extents (ignoring rotation for simplicity)
|
||||||
|
float extentX = (maxBounds.x - minBounds.x);
|
||||||
|
float extentZ = (maxBounds.z - minBounds.z);
|
||||||
|
|
||||||
|
// Calculate footprint in cells (always at least 1x1)
|
||||||
|
int footprintW = std::max(1, static_cast<int>(std::ceil(extentX / cellSize)));
|
||||||
|
int footprintD = std::max(1, static_cast<int>(std::ceil(extentZ / cellSize)));
|
||||||
|
|
||||||
|
// Calculate grid position (center the footprint on the world position)
|
||||||
|
int gridX = static_cast<int>(std::floor(worldPos.x / cellSize - footprintW * 0.5f));
|
||||||
|
int gridZ = static_cast<int>(std::floor(worldPos.z / cellSize - footprintD * 0.5f));
|
||||||
|
|
||||||
|
// Place blocking cells
|
||||||
|
placeBlocking(gridX, gridZ, footprintW, footprintD, walkable, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
void MeshLayer::computeVertexNormals() {
|
void MeshLayer::computeVertexNormals() {
|
||||||
// For terrain mesh, we can average normals at shared positions
|
// For terrain mesh, we can average normals at shared positions
|
||||||
// This is a simplified approach - works well for regular grids
|
// This is a simplified approach - works well for regular grids
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <libtcod.h> // For TCOD_heightmap_t
|
#include <libtcod.h> // For TCOD_heightmap_t
|
||||||
|
|
||||||
|
// Forward declarations
|
||||||
|
namespace mcrf {
|
||||||
|
class Viewport3D;
|
||||||
|
class Model3D;
|
||||||
|
}
|
||||||
|
|
||||||
namespace mcrf {
|
namespace mcrf {
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -49,6 +55,25 @@ struct TextureRange {
|
||||||
: minHeight(min), maxHeight(max), spriteIndex(index) {}
|
: minHeight(min), maxHeight(max), spriteIndex(index) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MeshInstance - Instance of a Model3D placed in the world
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct MeshInstance {
|
||||||
|
std::shared_ptr<Model3D> model; // The model to render
|
||||||
|
vec3 position; // World position
|
||||||
|
float rotation = 0.0f; // Y-axis rotation in degrees
|
||||||
|
vec3 scale = vec3(1.0f, 1.0f, 1.0f); // Scale factors
|
||||||
|
|
||||||
|
MeshInstance()
|
||||||
|
: model(nullptr), position(0, 0, 0), rotation(0.0f), scale(1.0f, 1.0f, 1.0f)
|
||||||
|
{}
|
||||||
|
|
||||||
|
MeshInstance(std::shared_ptr<Model3D> m, const vec3& pos, float rot = 0.0f, const vec3& s = vec3(1, 1, 1))
|
||||||
|
: model(m), position(pos), rotation(rot), scale(s)
|
||||||
|
{}
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MeshLayer - Container for static 3D geometry
|
// MeshLayer - Container for static 3D geometry
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -115,6 +140,60 @@ public:
|
||||||
/// Clear all geometry
|
/// Clear all geometry
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Mesh Instances (Model3D placement)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Add a Model3D instance at a world position
|
||||||
|
/// @param model The Model3D to render
|
||||||
|
/// @param pos World position
|
||||||
|
/// @param rotation Y-axis rotation in degrees
|
||||||
|
/// @param scale Scale factor (uniform or per-axis)
|
||||||
|
/// @return Instance index for later removal
|
||||||
|
size_t addMesh(std::shared_ptr<Model3D> model, const vec3& pos,
|
||||||
|
float rotation = 0.0f, const vec3& scale = vec3(1, 1, 1));
|
||||||
|
|
||||||
|
/// Remove a mesh instance by index
|
||||||
|
/// @param index Index returned by addMesh()
|
||||||
|
void removeMesh(size_t index);
|
||||||
|
|
||||||
|
/// Clear all mesh instances
|
||||||
|
void clearMeshes();
|
||||||
|
|
||||||
|
/// Get number of mesh instances
|
||||||
|
size_t getMeshInstanceCount() const { return meshInstances_.size(); }
|
||||||
|
|
||||||
|
/// Get mesh instance by index (for Python access)
|
||||||
|
MeshInstance* getMeshInstance(size_t index);
|
||||||
|
const MeshInstance* getMeshInstance(size_t index) const;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Collision Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Set parent viewport (for collision marking)
|
||||||
|
void setViewport(Viewport3D* vp) { viewport_ = vp; }
|
||||||
|
Viewport3D* getViewport() const { return viewport_; }
|
||||||
|
|
||||||
|
/// Mark grid cells as blocking for pathfinding/FOV
|
||||||
|
/// @param gridX Grid X coordinate (top-left of footprint)
|
||||||
|
/// @param gridZ Grid Z coordinate (top-left of footprint)
|
||||||
|
/// @param footprintW Footprint width in cells
|
||||||
|
/// @param footprintD Footprint depth in cells
|
||||||
|
/// @param walkable Set cells walkable state
|
||||||
|
/// @param transparent Set cells transparent state
|
||||||
|
void placeBlocking(int gridX, int gridZ, int footprintW, int footprintD,
|
||||||
|
bool walkable = false, bool transparent = false);
|
||||||
|
|
||||||
|
/// Auto-detect footprint from model bounds and place blocking
|
||||||
|
/// @param model The model to compute footprint from
|
||||||
|
/// @param worldPos World position of the model
|
||||||
|
/// @param rotation Y-axis rotation of the model
|
||||||
|
/// @param walkable Set cells walkable state
|
||||||
|
/// @param transparent Set cells transparent state
|
||||||
|
void placeBlockingAuto(std::shared_ptr<Model3D> model, const vec3& worldPos,
|
||||||
|
float rotation, bool walkable = false, bool transparent = false);
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// GPU Upload and Rendering
|
// GPU Upload and Rendering
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -124,10 +203,11 @@ public:
|
||||||
void uploadToGPU();
|
void uploadToGPU();
|
||||||
|
|
||||||
/// Render this layer
|
/// Render this layer
|
||||||
|
/// @param shader Shader program handle (for mesh instances)
|
||||||
/// @param model Model transformation matrix
|
/// @param model Model transformation matrix
|
||||||
/// @param view View matrix from camera
|
/// @param view View matrix from camera
|
||||||
/// @param projection Projection matrix from camera
|
/// @param projection Projection matrix from camera
|
||||||
void render(const mat4& model, const mat4& view, const mat4& projection);
|
void render(unsigned int shader, const mat4& model, const mat4& view, const mat4& projection);
|
||||||
|
|
||||||
/// Get model matrix (identity by default, override for positioned layers)
|
/// Get model matrix (identity by default, override for positioned layers)
|
||||||
mat4 getModelMatrix() const { return modelMatrix_; }
|
mat4 getModelMatrix() const { return modelMatrix_; }
|
||||||
|
|
@ -164,10 +244,19 @@ private:
|
||||||
// Transform
|
// Transform
|
||||||
mat4 modelMatrix_ = mat4::identity();
|
mat4 modelMatrix_ = mat4::identity();
|
||||||
|
|
||||||
|
// Mesh instances (Model3D placements)
|
||||||
|
std::vector<MeshInstance> meshInstances_;
|
||||||
|
|
||||||
|
// Parent viewport for collision helpers
|
||||||
|
Viewport3D* viewport_ = nullptr;
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
void cleanupGPU();
|
void cleanupGPU();
|
||||||
vec3 computeFaceNormal(const vec3& v0, const vec3& v1, const vec3& v2);
|
vec3 computeFaceNormal(const vec3& v0, const vec3& v1, const vec3& v2);
|
||||||
void computeVertexNormals();
|
void computeVertexNormals();
|
||||||
|
|
||||||
|
// Render mesh instances
|
||||||
|
void renderMeshInstances(unsigned int shader, const mat4& view, const mat4& projection);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace mcrf
|
} // namespace mcrf
|
||||||
|
|
|
||||||
1451
src/3d/Model3D.cpp
Normal file
1451
src/3d/Model3D.cpp
Normal file
File diff suppressed because it is too large
Load diff
431
src/3d/Model3D.h
Normal file
431
src/3d/Model3D.h
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
// Model3D.h - 3D model resource for McRogueFace
|
||||||
|
// Supports loading from glTF 2.0 (.glb) files and procedural primitives
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Common.h"
|
||||||
|
#include "Math3D.h"
|
||||||
|
#include "MeshLayer.h" // For MeshVertex
|
||||||
|
#include "Python.h"
|
||||||
|
#include "structmember.h"
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mcrf {
|
||||||
|
|
||||||
|
// Forward declarations
|
||||||
|
class Shader3D;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Bone - Single bone in a skeleton
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct Bone {
|
||||||
|
std::string name;
|
||||||
|
int parent_index = -1; // -1 for root bones
|
||||||
|
mat4 inverse_bind_matrix; // Transforms from model space to bone space
|
||||||
|
mat4 local_transform; // Default local transform (rest pose)
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Skeleton - Bone hierarchy for skeletal animation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct Skeleton {
|
||||||
|
std::vector<Bone> bones;
|
||||||
|
std::vector<int> root_bones; // Indices of bones with parent_index == -1
|
||||||
|
|
||||||
|
/// Find bone by name, returns -1 if not found
|
||||||
|
int findBone(const std::string& name) const {
|
||||||
|
for (size_t i = 0; i < bones.size(); i++) {
|
||||||
|
if (bones[i].name == name) return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute global (model-space) transforms for all bones
|
||||||
|
void computeGlobalTransforms(const std::vector<mat4>& local_transforms,
|
||||||
|
std::vector<mat4>& global_out) const {
|
||||||
|
global_out.resize(bones.size());
|
||||||
|
for (size_t i = 0; i < bones.size(); i++) {
|
||||||
|
if (bones[i].parent_index < 0) {
|
||||||
|
global_out[i] = local_transforms[i];
|
||||||
|
} else {
|
||||||
|
global_out[i] = global_out[bones[i].parent_index] * local_transforms[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute final bone matrices for shader (global * inverse_bind)
|
||||||
|
void computeBoneMatrices(const std::vector<mat4>& global_transforms,
|
||||||
|
std::vector<mat4>& matrices_out) const {
|
||||||
|
matrices_out.resize(bones.size());
|
||||||
|
for (size_t i = 0; i < bones.size(); i++) {
|
||||||
|
matrices_out[i] = global_transforms[i] * bones[i].inverse_bind_matrix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AnimationChannel - Animates a single property of a single bone
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct AnimationChannel {
|
||||||
|
int bone_index = -1;
|
||||||
|
|
||||||
|
enum class Path {
|
||||||
|
Translation,
|
||||||
|
Rotation,
|
||||||
|
Scale
|
||||||
|
} path = Path::Translation;
|
||||||
|
|
||||||
|
// Keyframe times (shared for all values in this channel)
|
||||||
|
std::vector<float> times;
|
||||||
|
|
||||||
|
// Keyframe values (only one of these is populated based on path)
|
||||||
|
std::vector<vec3> translations;
|
||||||
|
std::vector<quat> rotations;
|
||||||
|
std::vector<vec3> scales;
|
||||||
|
|
||||||
|
/// Sample the channel at a given time, returning the interpolated transform component
|
||||||
|
/// For Translation/Scale: writes to trans_out
|
||||||
|
/// For Rotation: writes to rot_out
|
||||||
|
void sample(float time, vec3& trans_out, quat& rot_out, vec3& scale_out) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AnimationClip - Named animation containing multiple channels
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct AnimationClip {
|
||||||
|
std::string name;
|
||||||
|
float duration = 0.0f;
|
||||||
|
std::vector<AnimationChannel> channels;
|
||||||
|
|
||||||
|
/// Sample the animation at a given time, producing bone local transforms
|
||||||
|
/// @param time Current time in the animation
|
||||||
|
/// @param num_bones Total number of bones (for output sizing)
|
||||||
|
/// @param default_transforms Default local transforms for bones without animation
|
||||||
|
/// @param local_out Output: interpolated local transforms for each bone
|
||||||
|
void sample(float time, size_t num_bones,
|
||||||
|
const std::vector<mat4>& default_transforms,
|
||||||
|
std::vector<mat4>& local_out) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SkinnedVertex - Vertex with bone weights for skeletal animation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct SkinnedVertex {
|
||||||
|
vec3 position;
|
||||||
|
vec2 texcoord;
|
||||||
|
vec3 normal;
|
||||||
|
vec4 color;
|
||||||
|
vec4 bone_ids; // Up to 4 bone indices (as floats for GLES2 compatibility)
|
||||||
|
vec4 bone_weights; // Corresponding weights (should sum to 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SkinnedMesh - Submesh with skinning data
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct SkinnedMesh {
|
||||||
|
unsigned int vbo = 0;
|
||||||
|
unsigned int ebo = 0;
|
||||||
|
int vertex_count = 0;
|
||||||
|
int index_count = 0;
|
||||||
|
int material_index = -1;
|
||||||
|
bool is_skinned = false; // True if this mesh has bone weights
|
||||||
|
|
||||||
|
SkinnedMesh() = default;
|
||||||
|
~SkinnedMesh() = default;
|
||||||
|
|
||||||
|
SkinnedMesh(const SkinnedMesh&) = delete;
|
||||||
|
SkinnedMesh& operator=(const SkinnedMesh&) = delete;
|
||||||
|
SkinnedMesh(SkinnedMesh&& other) noexcept;
|
||||||
|
SkinnedMesh& operator=(SkinnedMesh&& other) noexcept;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ModelMesh - Single submesh within a Model3D (legacy non-skinned)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct ModelMesh {
|
||||||
|
unsigned int vbo = 0; // Vertex buffer object
|
||||||
|
unsigned int ebo = 0; // Element (index) buffer object
|
||||||
|
int vertex_count = 0; // Number of vertices
|
||||||
|
int index_count = 0; // Number of indices (0 if non-indexed)
|
||||||
|
int material_index = -1; // Index into materials array (-1 = no material)
|
||||||
|
|
||||||
|
ModelMesh() = default;
|
||||||
|
~ModelMesh() = default;
|
||||||
|
|
||||||
|
// Move only
|
||||||
|
ModelMesh(const ModelMesh&) = delete;
|
||||||
|
ModelMesh& operator=(const ModelMesh&) = delete;
|
||||||
|
ModelMesh(ModelMesh&& other) noexcept;
|
||||||
|
ModelMesh& operator=(ModelMesh&& other) noexcept;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Model3D - 3D model resource
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
class Model3D : public std::enable_shared_from_this<Model3D> {
|
||||||
|
public:
|
||||||
|
// Python integration
|
||||||
|
PyObject* self = nullptr;
|
||||||
|
uint64_t serial_number = 0;
|
||||||
|
|
||||||
|
Model3D();
|
||||||
|
~Model3D();
|
||||||
|
|
||||||
|
// No copy, allow move
|
||||||
|
Model3D(const Model3D&) = delete;
|
||||||
|
Model3D& operator=(const Model3D&) = delete;
|
||||||
|
Model3D(Model3D&& other) noexcept;
|
||||||
|
Model3D& operator=(Model3D&& other) noexcept;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Loading
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Load model from glTF 2.0 binary file (.glb)
|
||||||
|
/// @param path Path to the .glb file
|
||||||
|
/// @return Shared pointer to loaded model, or nullptr on failure
|
||||||
|
static std::shared_ptr<Model3D> load(const std::string& path);
|
||||||
|
|
||||||
|
/// Get last error message from load()
|
||||||
|
static const std::string& getLastError() { return lastError_; }
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Procedural Primitives
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Create a unit cube (1x1x1 centered at origin)
|
||||||
|
static std::shared_ptr<Model3D> cube(float size = 1.0f);
|
||||||
|
|
||||||
|
/// Create a flat plane
|
||||||
|
/// @param width Size along X axis
|
||||||
|
/// @param depth Size along Z axis
|
||||||
|
/// @param segments Subdivisions (1 = single quad)
|
||||||
|
static std::shared_ptr<Model3D> plane(float width = 1.0f, float depth = 1.0f, int segments = 1);
|
||||||
|
|
||||||
|
/// Create a UV sphere
|
||||||
|
/// @param radius Sphere radius
|
||||||
|
/// @param segments Horizontal segments (longitude)
|
||||||
|
/// @param rings Vertical rings (latitude)
|
||||||
|
static std::shared_ptr<Model3D> sphere(float radius = 0.5f, int segments = 16, int rings = 12);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Model Information
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Get model name (from file or "primitive")
|
||||||
|
const std::string& getName() const { return name_; }
|
||||||
|
void setName(const std::string& n) { name_ = n; }
|
||||||
|
|
||||||
|
/// Get total vertex count across all meshes
|
||||||
|
int getVertexCount() const;
|
||||||
|
|
||||||
|
/// Get total triangle count across all meshes
|
||||||
|
int getTriangleCount() const;
|
||||||
|
|
||||||
|
/// Get axis-aligned bounding box
|
||||||
|
/// @return Pair of (min, max) corners
|
||||||
|
std::pair<vec3, vec3> getBounds() const { return {bounds_min_, bounds_max_}; }
|
||||||
|
|
||||||
|
/// Check if model has skeletal animation data
|
||||||
|
bool hasSkeleton() const { return has_skeleton_; }
|
||||||
|
|
||||||
|
/// Get number of submeshes (regular + skinned)
|
||||||
|
size_t getMeshCount() const { return meshes_.size() + skinned_meshes_.size(); }
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Skeleton & Animation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Get skeleton (may be empty if no skeleton)
|
||||||
|
const Skeleton& getSkeleton() const { return skeleton_; }
|
||||||
|
|
||||||
|
/// Get number of bones
|
||||||
|
size_t getBoneCount() const { return skeleton_.bones.size(); }
|
||||||
|
|
||||||
|
/// Get animation clips
|
||||||
|
const std::vector<AnimationClip>& getAnimationClips() const { return animation_clips_; }
|
||||||
|
|
||||||
|
/// Get animation clip names
|
||||||
|
std::vector<std::string> getAnimationClipNames() const {
|
||||||
|
std::vector<std::string> names;
|
||||||
|
for (const auto& clip : animation_clips_) {
|
||||||
|
names.push_back(clip.name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find animation clip by name (returns nullptr if not found)
|
||||||
|
const AnimationClip* findClip(const std::string& name) const {
|
||||||
|
for (const auto& clip : animation_clips_) {
|
||||||
|
if (clip.name == name) return &clip;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get default bone transforms (rest pose)
|
||||||
|
const std::vector<mat4>& getDefaultBoneTransforms() const { return default_bone_transforms_; }
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Rendering
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Render all meshes
|
||||||
|
/// @param shader Shader program handle (already bound)
|
||||||
|
/// @param model Model transformation matrix
|
||||||
|
/// @param view View matrix
|
||||||
|
/// @param projection Projection matrix
|
||||||
|
void render(unsigned int shader, const mat4& model, const mat4& view, const mat4& projection);
|
||||||
|
|
||||||
|
/// Render with skeletal animation
|
||||||
|
/// @param shader Shader program handle (already bound, should be skinned shader)
|
||||||
|
/// @param model Model transformation matrix
|
||||||
|
/// @param view View matrix
|
||||||
|
/// @param projection Projection matrix
|
||||||
|
/// @param bone_matrices Final bone matrices (global * inverse_bind)
|
||||||
|
void renderSkinned(unsigned int shader, const mat4& model, const mat4& view,
|
||||||
|
const mat4& projection, const std::vector<mat4>& bone_matrices);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Python API
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
static int init(PyObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
static PyObject* repr(PyObject* self);
|
||||||
|
|
||||||
|
// Class methods (static constructors)
|
||||||
|
static PyObject* py_cube(PyObject* cls, PyObject* args, PyObject* kwds);
|
||||||
|
static PyObject* py_plane(PyObject* cls, PyObject* args, PyObject* kwds);
|
||||||
|
static PyObject* py_sphere(PyObject* cls, PyObject* args, PyObject* kwds);
|
||||||
|
|
||||||
|
// Property getters
|
||||||
|
static PyObject* get_vertex_count(PyObject* self, void* closure);
|
||||||
|
static PyObject* get_triangle_count(PyObject* self, void* closure);
|
||||||
|
static PyObject* get_has_skeleton(PyObject* self, void* closure);
|
||||||
|
static PyObject* get_bounds(PyObject* self, void* closure);
|
||||||
|
static PyObject* get_name(PyObject* self, void* closure);
|
||||||
|
static PyObject* get_mesh_count(PyObject* self, void* closure);
|
||||||
|
static PyObject* get_bone_count(PyObject* self, void* closure);
|
||||||
|
static PyObject* get_animation_clips(PyObject* self, void* closure);
|
||||||
|
|
||||||
|
static PyMethodDef methods[];
|
||||||
|
static PyGetSetDef getsetters[];
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Model data
|
||||||
|
std::string name_;
|
||||||
|
std::vector<ModelMesh> meshes_;
|
||||||
|
std::vector<SkinnedMesh> skinned_meshes_; // Skinned meshes with bone weights
|
||||||
|
|
||||||
|
// Bounds
|
||||||
|
vec3 bounds_min_ = vec3(0, 0, 0);
|
||||||
|
vec3 bounds_max_ = vec3(0, 0, 0);
|
||||||
|
|
||||||
|
// Skeletal animation data
|
||||||
|
bool has_skeleton_ = false;
|
||||||
|
Skeleton skeleton_;
|
||||||
|
std::vector<AnimationClip> animation_clips_;
|
||||||
|
std::vector<mat4> default_bone_transforms_; // Rest pose local transforms
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
static std::string lastError_;
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
void cleanupGPU();
|
||||||
|
void computeBounds(const std::vector<MeshVertex>& vertices);
|
||||||
|
|
||||||
|
/// Create VBO/EBO from vertex and index data
|
||||||
|
/// @return ModelMesh with GPU resources allocated
|
||||||
|
static ModelMesh createMesh(const std::vector<MeshVertex>& vertices,
|
||||||
|
const std::vector<uint32_t>& indices);
|
||||||
|
|
||||||
|
/// Create VBO/EBO from skinned vertex and index data
|
||||||
|
static SkinnedMesh createSkinnedMesh(const std::vector<SkinnedVertex>& vertices,
|
||||||
|
const std::vector<uint32_t>& indices);
|
||||||
|
|
||||||
|
// glTF loading helpers
|
||||||
|
void loadSkeleton(void* cgltf_data); // void* to avoid header dependency
|
||||||
|
void loadAnimations(void* cgltf_data);
|
||||||
|
int findJointIndex(void* cgltf_skin, void* node);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mcrf
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Python type definition
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
typedef struct PyModel3DObject {
|
||||||
|
PyObject_HEAD
|
||||||
|
std::shared_ptr<mcrf::Model3D> data;
|
||||||
|
PyObject* weakreflist;
|
||||||
|
} PyModel3DObject;
|
||||||
|
|
||||||
|
namespace mcrfpydef {
|
||||||
|
|
||||||
|
inline PyTypeObject PyModel3DType = {
|
||||||
|
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||||
|
.tp_name = "mcrfpy.Model3D",
|
||||||
|
.tp_basicsize = sizeof(PyModel3DObject),
|
||||||
|
.tp_itemsize = 0,
|
||||||
|
.tp_dealloc = (destructor)[](PyObject* self)
|
||||||
|
{
|
||||||
|
PyModel3DObject* obj = (PyModel3DObject*)self;
|
||||||
|
PyObject_GC_UnTrack(self);
|
||||||
|
if (obj->weakreflist != NULL) {
|
||||||
|
PyObject_ClearWeakRefs(self);
|
||||||
|
}
|
||||||
|
obj->data.reset();
|
||||||
|
Py_TYPE(self)->tp_free(self);
|
||||||
|
},
|
||||||
|
.tp_repr = mcrf::Model3D::repr,
|
||||||
|
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,
|
||||||
|
.tp_doc = PyDoc_STR(
|
||||||
|
"Model3D(path=None)\n\n"
|
||||||
|
"A 3D model resource that can be rendered by Entity3D.\n\n"
|
||||||
|
"Args:\n"
|
||||||
|
" path (str, optional): Path to .glb file to load. If None, creates empty model.\n\n"
|
||||||
|
"Class Methods:\n"
|
||||||
|
" cube(size=1.0) -> Model3D: Create a unit cube\n"
|
||||||
|
" plane(width=1.0, depth=1.0, segments=1) -> Model3D: Create a flat plane\n"
|
||||||
|
" sphere(radius=0.5, segments=16, rings=12) -> Model3D: Create a UV sphere\n\n"
|
||||||
|
"Properties:\n"
|
||||||
|
" name (str, read-only): Model name\n"
|
||||||
|
" vertex_count (int, read-only): Total vertices across all meshes\n"
|
||||||
|
" triangle_count (int, read-only): Total triangles across all meshes\n"
|
||||||
|
" has_skeleton (bool, read-only): Whether model has skeletal animation data\n"
|
||||||
|
" bounds (tuple, read-only): AABB as ((min_x, min_y, min_z), (max_x, max_y, max_z))\n"
|
||||||
|
" mesh_count (int, read-only): Number of submeshes\n"
|
||||||
|
" bone_count (int, read-only): Number of bones in skeleton\n"
|
||||||
|
" animation_clips (list, read-only): List of animation clip names"
|
||||||
|
),
|
||||||
|
.tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
.tp_clear = [](PyObject* self) -> int {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
.tp_methods = mcrf::Model3D::methods,
|
||||||
|
.tp_getset = mcrf::Model3D::getsetters,
|
||||||
|
.tp_init = mcrf::Model3D::init,
|
||||||
|
.tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject*
|
||||||
|
{
|
||||||
|
PyModel3DObject* self = (PyModel3DObject*)type->tp_alloc(type, 0);
|
||||||
|
if (self) {
|
||||||
|
self->data = std::make_shared<mcrf::Model3D>();
|
||||||
|
self->weakreflist = nullptr;
|
||||||
|
}
|
||||||
|
return (PyObject*)self;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mcrfpydef
|
||||||
1271
src/3d/PyVoxelGrid.cpp
Normal file
1271
src/3d/PyVoxelGrid.cpp
Normal file
File diff suppressed because it is too large
Load diff
179
src/3d/PyVoxelGrid.h
Normal file
179
src/3d/PyVoxelGrid.h
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// PyVoxelGrid.h - Python bindings for VoxelGrid
|
||||||
|
// Part of McRogueFace 3D Extension - Milestones 9, 11
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../Common.h"
|
||||||
|
#include "Python.h"
|
||||||
|
#include "VoxelGrid.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Python object structures
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
typedef struct PyVoxelGridObject {
|
||||||
|
PyObject_HEAD
|
||||||
|
std::shared_ptr<mcrf::VoxelGrid> data;
|
||||||
|
PyObject* weakreflist;
|
||||||
|
} PyVoxelGridObject;
|
||||||
|
|
||||||
|
typedef struct PyVoxelRegionObject {
|
||||||
|
PyObject_HEAD
|
||||||
|
std::shared_ptr<mcrf::VoxelRegion> data;
|
||||||
|
} PyVoxelRegionObject;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Python binding classes
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Properties - mesh generation (Milestone 13)
|
||||||
|
static PyObject* get_greedy_meshing(PyVoxelGridObject* self, void* closure);
|
||||||
|
static int set_greedy_meshing(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));
|
||||||
|
|
||||||
|
// Bulk operations - Milestone 11
|
||||||
|
static PyObject* fill_box_hollow(PyVoxelGridObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
static PyObject* fill_sphere(PyVoxelGridObject* self, PyObject* args);
|
||||||
|
static PyObject* fill_cylinder(PyVoxelGridObject* self, PyObject* args);
|
||||||
|
static PyObject* fill_noise(PyVoxelGridObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
|
||||||
|
// Copy/paste operations - Milestone 11
|
||||||
|
static PyObject* copy_region(PyVoxelGridObject* self, PyObject* args);
|
||||||
|
static PyObject* paste_region(PyVoxelGridObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
|
||||||
|
// Mesh caching (Milestone 10)
|
||||||
|
static PyObject* get_vertex_count(PyVoxelGridObject* self, void* closure);
|
||||||
|
static PyObject* rebuild_mesh(PyVoxelGridObject* self, PyObject* Py_UNUSED(args));
|
||||||
|
|
||||||
|
// Serialization (Milestone 14)
|
||||||
|
static PyObject* save(PyVoxelGridObject* self, PyObject* args);
|
||||||
|
static PyObject* load(PyVoxelGridObject* self, PyObject* args);
|
||||||
|
static PyObject* to_bytes(PyVoxelGridObject* self, PyObject* Py_UNUSED(args));
|
||||||
|
static PyObject* from_bytes(PyVoxelGridObject* self, PyObject* 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[];
|
||||||
|
};
|
||||||
|
|
||||||
|
class PyVoxelRegion {
|
||||||
|
public:
|
||||||
|
static void dealloc(PyVoxelRegionObject* self);
|
||||||
|
static PyObject* repr(PyObject* obj);
|
||||||
|
static PyObject* get_size(PyVoxelRegionObject* self, void* closure);
|
||||||
|
static PyObject* get_width(PyVoxelRegionObject* self, void* closure);
|
||||||
|
static PyObject* get_height(PyVoxelRegionObject* self, void* closure);
|
||||||
|
static PyObject* get_depth(PyVoxelRegionObject* self, void* closure);
|
||||||
|
|
||||||
|
static PyGetSetDef getsetters[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Python type definitions (in mcrfpydef namespace)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
inline PyTypeObject PyVoxelRegionType = {
|
||||||
|
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||||
|
.tp_name = "mcrfpy.VoxelRegion",
|
||||||
|
.tp_basicsize = sizeof(PyVoxelRegionObject),
|
||||||
|
.tp_itemsize = 0,
|
||||||
|
.tp_dealloc = (destructor)PyVoxelRegion::dealloc,
|
||||||
|
.tp_repr = PyVoxelRegion::repr,
|
||||||
|
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||||
|
.tp_doc = PyDoc_STR(
|
||||||
|
"VoxelRegion - Portable voxel data for copy/paste operations.\n\n"
|
||||||
|
"Created by VoxelGrid.copy_region(), used with paste_region().\n"
|
||||||
|
"Cannot be instantiated directly.\n\n"
|
||||||
|
"Properties:\n"
|
||||||
|
" size (tuple, read-only): Dimensions as (width, height, depth)\n"
|
||||||
|
" width, height, depth (int, read-only): Individual dimensions"
|
||||||
|
),
|
||||||
|
.tp_getset = nullptr, // Set before PyType_Ready
|
||||||
|
.tp_new = nullptr, // Cannot instantiate directly
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mcrfpydef
|
||||||
|
|
@ -245,6 +245,223 @@ void main() {
|
||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Skinned Vertex Shaders (for skeletal animation)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const char* PS1_SKINNED_VERTEX_ES2 = R"(
|
||||||
|
// PS1-style skinned vertex shader for OpenGL ES 2.0 / WebGL 1.0
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
uniform mat4 u_model;
|
||||||
|
uniform mat4 u_view;
|
||||||
|
uniform mat4 u_projection;
|
||||||
|
uniform mat4 u_bones[32];
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
uniform bool u_enable_snap;
|
||||||
|
uniform float u_fog_start;
|
||||||
|
uniform float u_fog_end;
|
||||||
|
uniform vec3 u_light_dir;
|
||||||
|
uniform vec3 u_ambient;
|
||||||
|
|
||||||
|
attribute vec3 a_position;
|
||||||
|
attribute vec2 a_texcoord;
|
||||||
|
attribute vec3 a_normal;
|
||||||
|
attribute vec4 a_color;
|
||||||
|
attribute vec4 a_bone_ids;
|
||||||
|
attribute vec4 a_bone_weights;
|
||||||
|
|
||||||
|
varying vec4 v_color;
|
||||||
|
varying vec2 v_texcoord;
|
||||||
|
varying float v_w;
|
||||||
|
varying float v_fog;
|
||||||
|
|
||||||
|
mat4 getBoneMatrix(int index) {
|
||||||
|
if (index < 8) {
|
||||||
|
if (index < 4) {
|
||||||
|
if (index < 2) {
|
||||||
|
if (index == 0) return u_bones[0];
|
||||||
|
else return u_bones[1];
|
||||||
|
} else {
|
||||||
|
if (index == 2) return u_bones[2];
|
||||||
|
else return u_bones[3];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 6) {
|
||||||
|
if (index == 4) return u_bones[4];
|
||||||
|
else return u_bones[5];
|
||||||
|
} else {
|
||||||
|
if (index == 6) return u_bones[6];
|
||||||
|
else return u_bones[7];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (index < 16) {
|
||||||
|
if (index < 12) {
|
||||||
|
if (index < 10) {
|
||||||
|
if (index == 8) return u_bones[8];
|
||||||
|
else return u_bones[9];
|
||||||
|
} else {
|
||||||
|
if (index == 10) return u_bones[10];
|
||||||
|
else return u_bones[11];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 14) {
|
||||||
|
if (index == 12) return u_bones[12];
|
||||||
|
else return u_bones[13];
|
||||||
|
} else {
|
||||||
|
if (index == 14) return u_bones[14];
|
||||||
|
else return u_bones[15];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (index < 24) {
|
||||||
|
if (index < 20) {
|
||||||
|
if (index < 18) {
|
||||||
|
if (index == 16) return u_bones[16];
|
||||||
|
else return u_bones[17];
|
||||||
|
} else {
|
||||||
|
if (index == 18) return u_bones[18];
|
||||||
|
else return u_bones[19];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 22) {
|
||||||
|
if (index == 20) return u_bones[20];
|
||||||
|
else return u_bones[21];
|
||||||
|
} else {
|
||||||
|
if (index == 22) return u_bones[22];
|
||||||
|
else return u_bones[23];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 28) {
|
||||||
|
if (index < 26) {
|
||||||
|
if (index == 24) return u_bones[24];
|
||||||
|
else return u_bones[25];
|
||||||
|
} else {
|
||||||
|
if (index == 26) return u_bones[26];
|
||||||
|
else return u_bones[27];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 30) {
|
||||||
|
if (index == 28) return u_bones[28];
|
||||||
|
else return u_bones[29];
|
||||||
|
} else {
|
||||||
|
if (index == 30) return u_bones[30];
|
||||||
|
else return u_bones[31];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mat4(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
int b0 = int(a_bone_ids.x);
|
||||||
|
int b1 = int(a_bone_ids.y);
|
||||||
|
int b2 = int(a_bone_ids.z);
|
||||||
|
int b3 = int(a_bone_ids.w);
|
||||||
|
|
||||||
|
mat4 skin_matrix =
|
||||||
|
getBoneMatrix(b0) * a_bone_weights.x +
|
||||||
|
getBoneMatrix(b1) * a_bone_weights.y +
|
||||||
|
getBoneMatrix(b2) * a_bone_weights.z +
|
||||||
|
getBoneMatrix(b3) * a_bone_weights.w;
|
||||||
|
|
||||||
|
vec4 skinned_pos = skin_matrix * vec4(a_position, 1.0);
|
||||||
|
vec3 skinned_normal = mat3(skin_matrix[0].xyz, skin_matrix[1].xyz, skin_matrix[2].xyz) * a_normal;
|
||||||
|
|
||||||
|
vec4 worldPos = u_model * skinned_pos;
|
||||||
|
vec4 viewPos = u_view * worldPos;
|
||||||
|
vec4 clipPos = u_projection * viewPos;
|
||||||
|
|
||||||
|
if (u_enable_snap) {
|
||||||
|
vec4 ndc = clipPos;
|
||||||
|
ndc.xyz /= ndc.w;
|
||||||
|
vec2 grid = u_resolution * 0.5;
|
||||||
|
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
|
||||||
|
ndc.xyz *= clipPos.w;
|
||||||
|
clipPos = ndc;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_Position = clipPos;
|
||||||
|
|
||||||
|
vec3 worldNormal = mat3(u_model[0].xyz, u_model[1].xyz, u_model[2].xyz) * skinned_normal;
|
||||||
|
worldNormal = normalize(worldNormal);
|
||||||
|
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
|
||||||
|
vec3 lighting = u_ambient + vec3(diffuse);
|
||||||
|
v_color = vec4(a_color.rgb * lighting, a_color.a);
|
||||||
|
|
||||||
|
v_texcoord = a_texcoord * clipPos.w;
|
||||||
|
v_w = clipPos.w;
|
||||||
|
|
||||||
|
float depth = -viewPos.z;
|
||||||
|
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
|
||||||
|
}
|
||||||
|
)";
|
||||||
|
|
||||||
|
const char* PS1_SKINNED_VERTEX = R"(
|
||||||
|
#version 150 core
|
||||||
|
|
||||||
|
uniform mat4 u_model;
|
||||||
|
uniform mat4 u_view;
|
||||||
|
uniform mat4 u_projection;
|
||||||
|
uniform mat4 u_bones[64];
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
uniform bool u_enable_snap;
|
||||||
|
uniform float u_fog_start;
|
||||||
|
uniform float u_fog_end;
|
||||||
|
uniform vec3 u_light_dir;
|
||||||
|
uniform vec3 u_ambient;
|
||||||
|
|
||||||
|
in vec3 a_position;
|
||||||
|
in vec2 a_texcoord;
|
||||||
|
in vec3 a_normal;
|
||||||
|
in vec4 a_color;
|
||||||
|
in vec4 a_bone_ids;
|
||||||
|
in vec4 a_bone_weights;
|
||||||
|
|
||||||
|
out vec4 v_color;
|
||||||
|
noperspective out vec2 v_texcoord;
|
||||||
|
out float v_fog;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
ivec4 bone_ids = ivec4(a_bone_ids);
|
||||||
|
|
||||||
|
mat4 skin_matrix =
|
||||||
|
u_bones[bone_ids.x] * a_bone_weights.x +
|
||||||
|
u_bones[bone_ids.y] * a_bone_weights.y +
|
||||||
|
u_bones[bone_ids.z] * a_bone_weights.z +
|
||||||
|
u_bones[bone_ids.w] * a_bone_weights.w;
|
||||||
|
|
||||||
|
vec4 skinned_pos = skin_matrix * vec4(a_position, 1.0);
|
||||||
|
vec3 skinned_normal = mat3(skin_matrix) * a_normal;
|
||||||
|
|
||||||
|
vec4 worldPos = u_model * skinned_pos;
|
||||||
|
vec4 viewPos = u_view * worldPos;
|
||||||
|
vec4 clipPos = u_projection * viewPos;
|
||||||
|
|
||||||
|
if (u_enable_snap) {
|
||||||
|
vec4 ndc = clipPos;
|
||||||
|
ndc.xyz /= ndc.w;
|
||||||
|
vec2 grid = u_resolution * 0.5;
|
||||||
|
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
|
||||||
|
ndc.xyz *= clipPos.w;
|
||||||
|
clipPos = ndc;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_Position = clipPos;
|
||||||
|
|
||||||
|
vec3 worldNormal = mat3(u_model) * skinned_normal;
|
||||||
|
worldNormal = normalize(worldNormal);
|
||||||
|
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
|
||||||
|
vec3 lighting = u_ambient + vec3(diffuse);
|
||||||
|
v_color = vec4(a_color.rgb * lighting, a_color.a);
|
||||||
|
|
||||||
|
v_texcoord = a_texcoord;
|
||||||
|
|
||||||
|
float depth = -viewPos.z;
|
||||||
|
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
|
||||||
|
}
|
||||||
|
)";
|
||||||
|
|
||||||
} // namespace shaders
|
} // namespace shaders
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -274,6 +491,20 @@ bool Shader3D::loadPS1Shaders() {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Shader3D::loadPS1SkinnedShaders() {
|
||||||
|
#ifdef MCRF_HAS_GL
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
// Use GLES2 skinned shaders for Emscripten/WebGL
|
||||||
|
return load(shaders::PS1_SKINNED_VERTEX_ES2, shaders::PS1_FRAGMENT_ES2);
|
||||||
|
#else
|
||||||
|
// Use desktop GL 3.2+ skinned shaders
|
||||||
|
return load(shaders::PS1_SKINNED_VERTEX, shaders::PS1_FRAGMENT);
|
||||||
|
#endif
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
bool Shader3D::load(const char* vertexSource, const char* fragmentSource) {
|
bool Shader3D::load(const char* vertexSource, const char* fragmentSource) {
|
||||||
if (!gl::isGLReady()) {
|
if (!gl::isGLReady()) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ public:
|
||||||
// Automatically selects desktop vs ES2 shaders based on platform
|
// Automatically selects desktop vs ES2 shaders based on platform
|
||||||
bool loadPS1Shaders();
|
bool loadPS1Shaders();
|
||||||
|
|
||||||
|
// Load skinned (skeletal animation) shaders
|
||||||
|
bool loadPS1SkinnedShaders();
|
||||||
|
|
||||||
// Load from custom source strings
|
// Load from custom source strings
|
||||||
bool load(const char* vertexSource, const char* fragmentSource);
|
bool load(const char* vertexSource, const char* fragmentSource);
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,8 @@ namespace mcrf {
|
||||||
class Viewport3D;
|
class Viewport3D;
|
||||||
class Shader3D;
|
class Shader3D;
|
||||||
class MeshLayer;
|
class MeshLayer;
|
||||||
|
class Billboard;
|
||||||
|
class VoxelGrid;
|
||||||
|
|
||||||
} // namespace mcrf
|
} // namespace mcrf
|
||||||
|
|
||||||
|
|
@ -84,6 +86,19 @@ public:
|
||||||
// Camera orbit helper for demos
|
// Camera orbit helper for demos
|
||||||
void orbitCamera(float angle, float distance, float height);
|
void orbitCamera(float angle, float distance, float height);
|
||||||
|
|
||||||
|
/// Convert screen coordinates to world position via ray casting
|
||||||
|
/// @param screenX X position relative to viewport
|
||||||
|
/// @param screenY Y position relative to viewport
|
||||||
|
/// @return World position on Y=0 plane, or (-1,-1,-1) if no intersection
|
||||||
|
vec3 screenToWorld(float screenX, float screenY);
|
||||||
|
|
||||||
|
/// Position camera to follow an entity
|
||||||
|
/// @param entity Entity to follow
|
||||||
|
/// @param distance Distance behind entity
|
||||||
|
/// @param height Height above entity
|
||||||
|
/// @param smoothing Interpolation factor (0-1, where 1 = instant)
|
||||||
|
void followEntity(std::shared_ptr<Entity3D> entity, float distance, float height, float smoothing = 1.0f);
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Mesh Layer Management
|
// Mesh Layer Management
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -190,6 +205,68 @@ public:
|
||||||
/// Render all entities
|
/// Render all entities
|
||||||
void renderEntities(const mat4& view, const mat4& proj);
|
void renderEntities(const mat4& view, const mat4& proj);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Billboard Management
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Get the billboard list
|
||||||
|
std::shared_ptr<std::vector<std::shared_ptr<Billboard>>> getBillboards() { return billboards_; }
|
||||||
|
|
||||||
|
/// Add a billboard
|
||||||
|
void addBillboard(std::shared_ptr<Billboard> bb);
|
||||||
|
|
||||||
|
/// Remove a billboard by pointer
|
||||||
|
void removeBillboard(Billboard* bb);
|
||||||
|
|
||||||
|
/// Clear all billboards
|
||||||
|
void clearBillboards();
|
||||||
|
|
||||||
|
/// Get billboard count
|
||||||
|
size_t getBillboardCount() const { return billboards_ ? billboards_->size() : 0; }
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Voxel-to-Nav Projection (Milestone 12)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Project a single voxel grid to the navigation grid
|
||||||
|
/// @param grid The voxel grid to project
|
||||||
|
/// @param headroom Required air voxels above floor for walkability
|
||||||
|
void projectVoxelToNav(std::shared_ptr<VoxelGrid> grid, int headroom = 2);
|
||||||
|
|
||||||
|
/// Project all voxel layers to the navigation grid
|
||||||
|
/// @param headroom Required air voxels above floor for walkability
|
||||||
|
void projectAllVoxelsToNav(int headroom = 2);
|
||||||
|
|
||||||
|
/// Clear nav cells in a voxel grid's footprint (before re-projection)
|
||||||
|
/// @param grid The voxel grid whose footprint to clear
|
||||||
|
void clearVoxelNavRegion(std::shared_ptr<VoxelGrid> grid);
|
||||||
|
|
||||||
// Background color
|
// Background color
|
||||||
void setBackgroundColor(const sf::Color& color) { bgColor_ = color; }
|
void setBackgroundColor(const sf::Color& color) { bgColor_ = color; }
|
||||||
sf::Color getBackgroundColor() const { return bgColor_; }
|
sf::Color getBackgroundColor() const { return bgColor_; }
|
||||||
|
|
@ -262,6 +339,10 @@ private:
|
||||||
float testRotation_ = 0.0f;
|
float testRotation_ = 0.0f;
|
||||||
bool renderTestCube_ = true; // Set to false when layers are added
|
bool renderTestCube_ = true; // Set to false when layers are added
|
||||||
|
|
||||||
|
// Animation timing
|
||||||
|
float lastFrameTime_ = 0.0f;
|
||||||
|
bool firstFrame_ = true;
|
||||||
|
|
||||||
// Mesh layers for terrain, static geometry
|
// Mesh layers for terrain, static geometry
|
||||||
std::vector<std::shared_ptr<MeshLayer>> meshLayers_;
|
std::vector<std::shared_ptr<MeshLayer>> meshLayers_;
|
||||||
|
|
||||||
|
|
@ -276,8 +357,17 @@ private:
|
||||||
// Entity3D storage
|
// Entity3D storage
|
||||||
std::shared_ptr<std::list<std::shared_ptr<Entity3D>>> entities_;
|
std::shared_ptr<std::list<std::shared_ptr<Entity3D>>> entities_;
|
||||||
|
|
||||||
|
// 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
|
// Shader for PS1-style rendering
|
||||||
std::unique_ptr<Shader3D> shader_;
|
std::unique_ptr<Shader3D> shader_;
|
||||||
|
std::unique_ptr<Shader3D> skinnedShader_; // For skeletal animation
|
||||||
|
|
||||||
// Test geometry VBO (cube)
|
// Test geometry VBO (cube)
|
||||||
unsigned int testVBO_ = 0;
|
unsigned int testVBO_ = 0;
|
||||||
|
|
|
||||||
795
src/3d/VoxelGrid.cpp
Normal file
795
src/3d/VoxelGrid.cpp
Normal file
|
|
@ -0,0 +1,795 @@
|
||||||
|
// VoxelGrid.cpp - Dense 3D voxel array implementation
|
||||||
|
// Part of McRogueFace 3D Extension - Milestones 9-11
|
||||||
|
|
||||||
|
#include "VoxelGrid.h"
|
||||||
|
#include "VoxelMesher.h"
|
||||||
|
#include "MeshLayer.h" // For MeshVertex
|
||||||
|
#include <cmath>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring> // For memcpy, memcmp
|
||||||
|
#include <fstream> // For file I/O
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Bulk Operations - Milestone 11
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
void VoxelGrid::fillBoxHollow(int x0, int y0, int z0, int x1, int y1, int z1,
|
||||||
|
uint8_t material, int thickness) {
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Fill entire box with material
|
||||||
|
fillBox(x0, y0, z0, x1, y1, z1, material);
|
||||||
|
|
||||||
|
// Carve out interior (inset by thickness on all sides)
|
||||||
|
int ix0 = x0 + thickness;
|
||||||
|
int iy0 = y0 + thickness;
|
||||||
|
int iz0 = z0 + thickness;
|
||||||
|
int ix1 = x1 - thickness;
|
||||||
|
int iy1 = y1 - thickness;
|
||||||
|
int iz1 = z1 - thickness;
|
||||||
|
|
||||||
|
// Only carve if there's interior space
|
||||||
|
if (ix0 <= ix1 && iy0 <= iy1 && iz0 <= iz1) {
|
||||||
|
fillBox(ix0, iy0, iz0, ix1, iy1, iz1, 0); // Air
|
||||||
|
}
|
||||||
|
// meshDirty_ already set by fillBox calls
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoxelGrid::fillSphere(int cx, int cy, int cz, int radius, uint8_t material) {
|
||||||
|
int r2 = radius * radius;
|
||||||
|
|
||||||
|
for (int z = cz - radius; z <= cz + radius; z++) {
|
||||||
|
for (int y = cy - radius; y <= cy + radius; y++) {
|
||||||
|
for (int x = cx - radius; x <= cx + radius; x++) {
|
||||||
|
int dx = x - cx;
|
||||||
|
int dy = y - cy;
|
||||||
|
int dz = z - cz;
|
||||||
|
if (dx * dx + dy * dy + dz * dz <= r2) {
|
||||||
|
if (isValid(x, y, z)) {
|
||||||
|
data_[index(x, y, z)] = material;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meshDirty_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoxelGrid::fillCylinder(int cx, int cy, int cz, int radius, int height, uint8_t material) {
|
||||||
|
int r2 = radius * radius;
|
||||||
|
|
||||||
|
for (int y = cy; y < cy + height; y++) {
|
||||||
|
for (int z = cz - radius; z <= cz + radius; z++) {
|
||||||
|
for (int x = cx - radius; x <= cx + radius; x++) {
|
||||||
|
int dx = x - cx;
|
||||||
|
int dz = z - cz;
|
||||||
|
if (dx * dx + dz * dz <= r2) {
|
||||||
|
if (isValid(x, y, z)) {
|
||||||
|
data_[index(x, y, z)] = material;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meshDirty_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple 3D noise implementation (hash-based, similar to value noise)
|
||||||
|
namespace {
|
||||||
|
// Simple hash function for noise
|
||||||
|
inline unsigned int hash3D(int x, int y, int z, unsigned int seed) {
|
||||||
|
unsigned int h = seed;
|
||||||
|
h ^= static_cast<unsigned int>(x) * 374761393u;
|
||||||
|
h ^= static_cast<unsigned int>(y) * 668265263u;
|
||||||
|
h ^= static_cast<unsigned int>(z) * 2147483647u;
|
||||||
|
h = (h ^ (h >> 13)) * 1274126177u;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert hash to 0-1 float
|
||||||
|
inline float hashToFloat(unsigned int h) {
|
||||||
|
return static_cast<float>(h & 0xFFFFFF) / static_cast<float>(0xFFFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear interpolation
|
||||||
|
inline float lerp(float a, float b, float t) {
|
||||||
|
return a + t * (b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smoothstep for smoother interpolation
|
||||||
|
inline float smoothstep(float t) {
|
||||||
|
return t * t * (3.0f - 2.0f * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D value noise
|
||||||
|
float noise3D(float x, float y, float z, unsigned int seed) {
|
||||||
|
int xi = static_cast<int>(std::floor(x));
|
||||||
|
int yi = static_cast<int>(std::floor(y));
|
||||||
|
int zi = static_cast<int>(std::floor(z));
|
||||||
|
|
||||||
|
float xf = x - xi;
|
||||||
|
float yf = y - yi;
|
||||||
|
float zf = z - zi;
|
||||||
|
|
||||||
|
// Smoothstep the fractions
|
||||||
|
float u = smoothstep(xf);
|
||||||
|
float v = smoothstep(yf);
|
||||||
|
float w = smoothstep(zf);
|
||||||
|
|
||||||
|
// Hash corners of the unit cube
|
||||||
|
float c000 = hashToFloat(hash3D(xi, yi, zi, seed));
|
||||||
|
float c100 = hashToFloat(hash3D(xi + 1, yi, zi, seed));
|
||||||
|
float c010 = hashToFloat(hash3D(xi, yi + 1, zi, seed));
|
||||||
|
float c110 = hashToFloat(hash3D(xi + 1, yi + 1, zi, seed));
|
||||||
|
float c001 = hashToFloat(hash3D(xi, yi, zi + 1, seed));
|
||||||
|
float c101 = hashToFloat(hash3D(xi + 1, yi, zi + 1, seed));
|
||||||
|
float c011 = hashToFloat(hash3D(xi, yi + 1, zi + 1, seed));
|
||||||
|
float c111 = hashToFloat(hash3D(xi + 1, yi + 1, zi + 1, seed));
|
||||||
|
|
||||||
|
// Trilinear interpolation
|
||||||
|
float x00 = lerp(c000, c100, u);
|
||||||
|
float x10 = lerp(c010, c110, u);
|
||||||
|
float x01 = lerp(c001, c101, u);
|
||||||
|
float x11 = lerp(c011, c111, u);
|
||||||
|
|
||||||
|
float y0 = lerp(x00, x10, v);
|
||||||
|
float y1 = lerp(x01, x11, v);
|
||||||
|
|
||||||
|
return lerp(y0, y1, w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoxelGrid::fillNoise(int x0, int y0, int z0, int x1, int y1, int z1,
|
||||||
|
uint8_t material, float threshold, float scale, unsigned int seed) {
|
||||||
|
// Ensure proper ordering
|
||||||
|
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++) {
|
||||||
|
float n = noise3D(x * scale, y * scale, z * scale, seed);
|
||||||
|
if (n > threshold) {
|
||||||
|
data_[index(x, y, z)] = material;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meshDirty_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Copy/Paste Operations - Milestone 11
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
VoxelRegion VoxelGrid::copyRegion(int x0, int y0, int z0, int x1, int y1, int z1) const {
|
||||||
|
// Ensure proper ordering
|
||||||
|
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));
|
||||||
|
|
||||||
|
int rw = x1 - x0 + 1;
|
||||||
|
int rh = y1 - y0 + 1;
|
||||||
|
int rd = z1 - z0 + 1;
|
||||||
|
|
||||||
|
VoxelRegion region(rw, rh, rd);
|
||||||
|
|
||||||
|
for (int rz = 0; rz < rd; rz++) {
|
||||||
|
for (int ry = 0; ry < rh; ry++) {
|
||||||
|
for (int rx = 0; rx < rw; rx++) {
|
||||||
|
int sx = x0 + rx;
|
||||||
|
int sy = y0 + ry;
|
||||||
|
int sz = z0 + rz;
|
||||||
|
size_t ri = static_cast<size_t>(rz) * (rw * rh) +
|
||||||
|
static_cast<size_t>(ry) * rw + rx;
|
||||||
|
region.data[ri] = get(sx, sy, sz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoxelGrid::pasteRegion(const VoxelRegion& region, int x, int y, int z, bool skipAir) {
|
||||||
|
if (!region.isValid()) return;
|
||||||
|
|
||||||
|
for (int rz = 0; rz < region.depth; rz++) {
|
||||||
|
for (int ry = 0; ry < region.height; ry++) {
|
||||||
|
for (int rx = 0; rx < region.width; rx++) {
|
||||||
|
size_t ri = static_cast<size_t>(rz) * (region.width * region.height) +
|
||||||
|
static_cast<size_t>(ry) * region.width + rx;
|
||||||
|
uint8_t mat = region.data[ri];
|
||||||
|
|
||||||
|
if (skipAir && mat == 0) continue;
|
||||||
|
|
||||||
|
int dx = x + rx;
|
||||||
|
int dy = y + ry;
|
||||||
|
int dz = z + rz;
|
||||||
|
|
||||||
|
if (isValid(dx, dy, dz)) {
|
||||||
|
data_[index(dx, dy, dz)] = mat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meshDirty_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Navigation Projection - Milestone 12
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
VoxelGrid::NavInfo VoxelGrid::projectColumn(int x, int z, int headroom) const {
|
||||||
|
NavInfo info;
|
||||||
|
info.height = 0.0f;
|
||||||
|
info.walkable = false;
|
||||||
|
info.transparent = true;
|
||||||
|
info.pathCost = 1.0f;
|
||||||
|
|
||||||
|
// Out of bounds check
|
||||||
|
if (x < 0 || x >= width_ || z < 0 || z >= depth_) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan from top to bottom, find first solid with air above (floor)
|
||||||
|
int floorY = -1;
|
||||||
|
for (int y = height_ - 1; y >= 0; y--) {
|
||||||
|
uint8_t mat = get(x, y, z);
|
||||||
|
if (mat != 0) {
|
||||||
|
// Found solid - check if it's a floor (air above) or ceiling
|
||||||
|
bool hasAirAbove = (y == height_ - 1) || (get(x, y + 1, z) == 0);
|
||||||
|
if (hasAirAbove) {
|
||||||
|
floorY = y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (floorY >= 0) {
|
||||||
|
// Found a floor
|
||||||
|
info.height = (floorY + 1) * cellSize_; // Top of floor voxel
|
||||||
|
info.walkable = true;
|
||||||
|
|
||||||
|
// Check headroom (need enough air voxels above floor)
|
||||||
|
int airCount = 0;
|
||||||
|
for (int y = floorY + 1; y < height_; y++) {
|
||||||
|
if (get(x, y, z) == 0) {
|
||||||
|
airCount++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (airCount < headroom) {
|
||||||
|
info.walkable = false; // Can't fit entity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get path cost from floor material
|
||||||
|
uint8_t floorMat = get(x, floorY, z);
|
||||||
|
info.pathCost = getMaterial(floorMat).pathCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check transparency: any non-transparent solid in column blocks FOV
|
||||||
|
for (int y = 0; y < height_; y++) {
|
||||||
|
uint8_t mat = get(x, y, z);
|
||||||
|
if (mat != 0 && !getMaterial(mat).transparent) {
|
||||||
|
info.transparent = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mesh Caching (Milestone 10)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const std::vector<MeshVertex>& VoxelGrid::getVertices() const {
|
||||||
|
if (meshDirty_) {
|
||||||
|
rebuildMesh();
|
||||||
|
}
|
||||||
|
return cachedVertices_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoxelGrid::rebuildMesh() const {
|
||||||
|
cachedVertices_.clear();
|
||||||
|
if (greedyMeshing_) {
|
||||||
|
VoxelMesher::generateGreedyMesh(*this, cachedVertices_);
|
||||||
|
} else {
|
||||||
|
VoxelMesher::generateMesh(*this, cachedVertices_);
|
||||||
|
}
|
||||||
|
meshDirty_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Serialization - Milestone 14
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// File format:
|
||||||
|
// Magic "MCVG" (4 bytes)
|
||||||
|
// Version (1 byte) - currently 1
|
||||||
|
// Width, Height, Depth (3 x int32 = 12 bytes)
|
||||||
|
// Cell Size (float32 = 4 bytes)
|
||||||
|
// Material count (uint8 = 1 byte)
|
||||||
|
// For each material:
|
||||||
|
// Name length (uint16) + name bytes
|
||||||
|
// Color RGBA (4 bytes)
|
||||||
|
// Sprite index (int32)
|
||||||
|
// Transparent (uint8)
|
||||||
|
// Path cost (float32)
|
||||||
|
// Voxel data length (uint32)
|
||||||
|
// Voxel data: RLE encoded (run_length: uint8, material: uint8) pairs
|
||||||
|
// If run_length == 255, read extended_length: uint16 for longer runs
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
const char MAGIC[4] = {'M', 'C', 'V', 'G'};
|
||||||
|
const uint8_t FORMAT_VERSION = 1;
|
||||||
|
|
||||||
|
// Write helpers
|
||||||
|
void writeU8(std::vector<uint8_t>& buf, uint8_t v) {
|
||||||
|
buf.push_back(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeU16(std::vector<uint8_t>& buf, uint16_t v) {
|
||||||
|
buf.push_back(static_cast<uint8_t>(v & 0xFF));
|
||||||
|
buf.push_back(static_cast<uint8_t>((v >> 8) & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeI32(std::vector<uint8_t>& buf, int32_t v) {
|
||||||
|
buf.push_back(static_cast<uint8_t>(v & 0xFF));
|
||||||
|
buf.push_back(static_cast<uint8_t>((v >> 8) & 0xFF));
|
||||||
|
buf.push_back(static_cast<uint8_t>((v >> 16) & 0xFF));
|
||||||
|
buf.push_back(static_cast<uint8_t>((v >> 24) & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeU32(std::vector<uint8_t>& buf, uint32_t v) {
|
||||||
|
buf.push_back(static_cast<uint8_t>(v & 0xFF));
|
||||||
|
buf.push_back(static_cast<uint8_t>((v >> 8) & 0xFF));
|
||||||
|
buf.push_back(static_cast<uint8_t>((v >> 16) & 0xFF));
|
||||||
|
buf.push_back(static_cast<uint8_t>((v >> 24) & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeF32(std::vector<uint8_t>& buf, float v) {
|
||||||
|
static_assert(sizeof(float) == 4, "Expected 4-byte float");
|
||||||
|
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(&v);
|
||||||
|
buf.insert(buf.end(), bytes, bytes + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeString(std::vector<uint8_t>& buf, const std::string& s) {
|
||||||
|
uint16_t len = static_cast<uint16_t>(std::min(s.size(), size_t(65535)));
|
||||||
|
writeU16(buf, len);
|
||||||
|
buf.insert(buf.end(), s.begin(), s.begin() + len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read helpers
|
||||||
|
class Reader {
|
||||||
|
const uint8_t* data_;
|
||||||
|
size_t size_;
|
||||||
|
size_t pos_;
|
||||||
|
public:
|
||||||
|
Reader(const uint8_t* data, size_t size) : data_(data), size_(size), pos_(0) {}
|
||||||
|
|
||||||
|
bool hasBytes(size_t n) const { return pos_ + n <= size_; }
|
||||||
|
size_t position() const { return pos_; }
|
||||||
|
|
||||||
|
bool readU8(uint8_t& v) {
|
||||||
|
if (!hasBytes(1)) return false;
|
||||||
|
v = data_[pos_++];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readU16(uint16_t& v) {
|
||||||
|
if (!hasBytes(2)) return false;
|
||||||
|
v = static_cast<uint16_t>(data_[pos_]) |
|
||||||
|
(static_cast<uint16_t>(data_[pos_ + 1]) << 8);
|
||||||
|
pos_ += 2;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readI32(int32_t& v) {
|
||||||
|
if (!hasBytes(4)) return false;
|
||||||
|
v = static_cast<int32_t>(data_[pos_]) |
|
||||||
|
(static_cast<int32_t>(data_[pos_ + 1]) << 8) |
|
||||||
|
(static_cast<int32_t>(data_[pos_ + 2]) << 16) |
|
||||||
|
(static_cast<int32_t>(data_[pos_ + 3]) << 24);
|
||||||
|
pos_ += 4;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readU32(uint32_t& v) {
|
||||||
|
if (!hasBytes(4)) return false;
|
||||||
|
v = static_cast<uint32_t>(data_[pos_]) |
|
||||||
|
(static_cast<uint32_t>(data_[pos_ + 1]) << 8) |
|
||||||
|
(static_cast<uint32_t>(data_[pos_ + 2]) << 16) |
|
||||||
|
(static_cast<uint32_t>(data_[pos_ + 3]) << 24);
|
||||||
|
pos_ += 4;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readF32(float& v) {
|
||||||
|
if (!hasBytes(4)) return false;
|
||||||
|
static_assert(sizeof(float) == 4, "Expected 4-byte float");
|
||||||
|
std::memcpy(&v, data_ + pos_, 4);
|
||||||
|
pos_ += 4;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readString(std::string& s) {
|
||||||
|
uint16_t len;
|
||||||
|
if (!readU16(len)) return false;
|
||||||
|
if (!hasBytes(len)) return false;
|
||||||
|
s.assign(reinterpret_cast<const char*>(data_ + pos_), len);
|
||||||
|
pos_ += len;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readBytes(uint8_t* out, size_t n) {
|
||||||
|
if (!hasBytes(n)) return false;
|
||||||
|
std::memcpy(out, data_ + pos_, n);
|
||||||
|
pos_ += n;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// RLE encode voxel data
|
||||||
|
void rleEncode(const std::vector<uint8_t>& data, std::vector<uint8_t>& out) {
|
||||||
|
if (data.empty()) return;
|
||||||
|
|
||||||
|
size_t i = 0;
|
||||||
|
while (i < data.size()) {
|
||||||
|
uint8_t mat = data[i];
|
||||||
|
size_t runStart = i;
|
||||||
|
|
||||||
|
// Count consecutive same materials
|
||||||
|
while (i < data.size() && data[i] == mat && (i - runStart) < 65535 + 255) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t runLen = i - runStart;
|
||||||
|
|
||||||
|
if (runLen < 255) {
|
||||||
|
writeU8(out, static_cast<uint8_t>(runLen));
|
||||||
|
} else {
|
||||||
|
// Extended run: 255 marker + uint16 length
|
||||||
|
writeU8(out, 255);
|
||||||
|
writeU16(out, static_cast<uint16_t>(runLen - 255));
|
||||||
|
}
|
||||||
|
writeU8(out, mat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RLE decode voxel data
|
||||||
|
bool rleDecode(Reader& reader, std::vector<uint8_t>& data, size_t expectedSize) {
|
||||||
|
data.clear();
|
||||||
|
data.reserve(expectedSize);
|
||||||
|
|
||||||
|
while (data.size() < expectedSize) {
|
||||||
|
uint8_t runLen8;
|
||||||
|
if (!reader.readU8(runLen8)) return false;
|
||||||
|
|
||||||
|
size_t runLen = runLen8;
|
||||||
|
if (runLen8 == 255) {
|
||||||
|
uint16_t extLen;
|
||||||
|
if (!reader.readU16(extLen)) return false;
|
||||||
|
runLen = 255 + extLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t mat;
|
||||||
|
if (!reader.readU8(mat)) return false;
|
||||||
|
|
||||||
|
for (size_t j = 0; j < runLen && data.size() < expectedSize; j++) {
|
||||||
|
data.push_back(mat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.size() == expectedSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VoxelGrid::saveToBuffer(std::vector<uint8_t>& buffer) const {
|
||||||
|
buffer.clear();
|
||||||
|
buffer.reserve(1024 + data_.size()); // Rough estimate
|
||||||
|
|
||||||
|
// Magic
|
||||||
|
buffer.insert(buffer.end(), MAGIC, MAGIC + 4);
|
||||||
|
|
||||||
|
// Version
|
||||||
|
writeU8(buffer, FORMAT_VERSION);
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
writeI32(buffer, width_);
|
||||||
|
writeI32(buffer, height_);
|
||||||
|
writeI32(buffer, depth_);
|
||||||
|
|
||||||
|
// Cell size
|
||||||
|
writeF32(buffer, cellSize_);
|
||||||
|
|
||||||
|
// Materials
|
||||||
|
writeU8(buffer, static_cast<uint8_t>(materials_.size()));
|
||||||
|
for (const auto& mat : materials_) {
|
||||||
|
writeString(buffer, mat.name);
|
||||||
|
writeU8(buffer, mat.color.r);
|
||||||
|
writeU8(buffer, mat.color.g);
|
||||||
|
writeU8(buffer, mat.color.b);
|
||||||
|
writeU8(buffer, mat.color.a);
|
||||||
|
writeI32(buffer, mat.spriteIndex);
|
||||||
|
writeU8(buffer, mat.transparent ? 1 : 0);
|
||||||
|
writeF32(buffer, mat.pathCost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RLE encode voxel data
|
||||||
|
std::vector<uint8_t> rleData;
|
||||||
|
rleEncode(data_, rleData);
|
||||||
|
|
||||||
|
// Write RLE data length and data
|
||||||
|
writeU32(buffer, static_cast<uint32_t>(rleData.size()));
|
||||||
|
buffer.insert(buffer.end(), rleData.begin(), rleData.end());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VoxelGrid::loadFromBuffer(const uint8_t* data, size_t size) {
|
||||||
|
Reader reader(data, size);
|
||||||
|
|
||||||
|
// Check magic
|
||||||
|
uint8_t magic[4];
|
||||||
|
if (!reader.readBytes(magic, 4)) return false;
|
||||||
|
if (std::memcmp(magic, MAGIC, 4) != 0) return false;
|
||||||
|
|
||||||
|
// Check version
|
||||||
|
uint8_t version;
|
||||||
|
if (!reader.readU8(version)) return false;
|
||||||
|
if (version != FORMAT_VERSION) return false;
|
||||||
|
|
||||||
|
// Read dimensions
|
||||||
|
int32_t w, h, d;
|
||||||
|
if (!reader.readI32(w) || !reader.readI32(h) || !reader.readI32(d)) return false;
|
||||||
|
if (w <= 0 || h <= 0 || d <= 0) return false;
|
||||||
|
|
||||||
|
// Read cell size
|
||||||
|
float cs;
|
||||||
|
if (!reader.readF32(cs)) return false;
|
||||||
|
if (cs <= 0.0f) return false;
|
||||||
|
|
||||||
|
// Read materials
|
||||||
|
uint8_t matCount;
|
||||||
|
if (!reader.readU8(matCount)) return false;
|
||||||
|
|
||||||
|
std::vector<VoxelMaterial> newMaterials;
|
||||||
|
newMaterials.reserve(matCount);
|
||||||
|
for (uint8_t i = 0; i < matCount; i++) {
|
||||||
|
VoxelMaterial mat;
|
||||||
|
if (!reader.readString(mat.name)) return false;
|
||||||
|
|
||||||
|
uint8_t r, g, b, a;
|
||||||
|
if (!reader.readU8(r) || !reader.readU8(g) || !reader.readU8(b) || !reader.readU8(a))
|
||||||
|
return false;
|
||||||
|
mat.color = sf::Color(r, g, b, a);
|
||||||
|
|
||||||
|
int32_t sprite;
|
||||||
|
if (!reader.readI32(sprite)) return false;
|
||||||
|
mat.spriteIndex = sprite;
|
||||||
|
|
||||||
|
uint8_t transp;
|
||||||
|
if (!reader.readU8(transp)) return false;
|
||||||
|
mat.transparent = (transp != 0);
|
||||||
|
|
||||||
|
if (!reader.readF32(mat.pathCost)) return false;
|
||||||
|
|
||||||
|
newMaterials.push_back(mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read RLE data length
|
||||||
|
uint32_t rleLen;
|
||||||
|
if (!reader.readU32(rleLen)) return false;
|
||||||
|
|
||||||
|
// Decode voxel data
|
||||||
|
size_t expectedVoxels = static_cast<size_t>(w) * h * d;
|
||||||
|
std::vector<uint8_t> newData;
|
||||||
|
if (!rleDecode(reader, newData, expectedVoxels)) return false;
|
||||||
|
|
||||||
|
// Success - update the grid
|
||||||
|
width_ = w;
|
||||||
|
height_ = h;
|
||||||
|
depth_ = d;
|
||||||
|
cellSize_ = cs;
|
||||||
|
materials_ = std::move(newMaterials);
|
||||||
|
data_ = std::move(newData);
|
||||||
|
meshDirty_ = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VoxelGrid::save(const std::string& path) const {
|
||||||
|
std::vector<uint8_t> buffer;
|
||||||
|
if (!saveToBuffer(buffer)) return false;
|
||||||
|
|
||||||
|
std::ofstream file(path, std::ios::binary);
|
||||||
|
if (!file) return false;
|
||||||
|
|
||||||
|
file.write(reinterpret_cast<const char*>(buffer.data()), buffer.size());
|
||||||
|
return file.good();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VoxelGrid::load(const std::string& path) {
|
||||||
|
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||||
|
if (!file) return false;
|
||||||
|
|
||||||
|
std::streamsize size = file.tellg();
|
||||||
|
if (size <= 0) return false;
|
||||||
|
|
||||||
|
file.seekg(0, std::ios::beg);
|
||||||
|
|
||||||
|
std::vector<uint8_t> buffer(static_cast<size_t>(size));
|
||||||
|
if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) return false;
|
||||||
|
|
||||||
|
return loadFromBuffer(buffer.data(), buffer.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mcrf
|
||||||
194
src/3d/VoxelGrid.h
Normal file
194
src/3d/VoxelGrid.h
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
// VoxelGrid.h - Dense 3D voxel array with material palette
|
||||||
|
// Part of McRogueFace 3D Extension - Milestones 9-11
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../Common.h"
|
||||||
|
#include "Math3D.h"
|
||||||
|
#include "MeshLayer.h" // For MeshVertex (needed for std::vector<MeshVertex>)
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// VoxelRegion - Portable voxel data for copy/paste operations (Milestone 11)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct VoxelRegion {
|
||||||
|
int width, height, depth;
|
||||||
|
std::vector<uint8_t> data;
|
||||||
|
|
||||||
|
VoxelRegion() : width(0), height(0), depth(0) {}
|
||||||
|
VoxelRegion(int w, int h, int d) : width(w), height(h), depth(d),
|
||||||
|
data(static_cast<size_t>(w) * h * d, 0) {}
|
||||||
|
|
||||||
|
bool isValid() const { return width > 0 && height > 0 && depth > 0; }
|
||||||
|
size_t totalVoxels() const { return static_cast<size_t>(width) * height * depth; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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 (Milestones 10, 13)
|
||||||
|
mutable bool meshDirty_ = true;
|
||||||
|
mutable std::vector<MeshVertex> cachedVertices_;
|
||||||
|
bool greedyMeshing_ = false; // Use greedy meshing algorithm
|
||||||
|
|
||||||
|
// 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 - Basic
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Bulk operations - Milestone 11
|
||||||
|
void fillBoxHollow(int x0, int y0, int z0, int x1, int y1, int z1,
|
||||||
|
uint8_t material, int thickness = 1);
|
||||||
|
void fillSphere(int cx, int cy, int cz, int radius, uint8_t material);
|
||||||
|
void fillCylinder(int cx, int cy, int cz, int radius, int height, uint8_t material);
|
||||||
|
void fillNoise(int x0, int y0, int z0, int x1, int y1, int z1,
|
||||||
|
uint8_t material, float threshold = 0.5f,
|
||||||
|
float scale = 0.1f, unsigned int seed = 0);
|
||||||
|
|
||||||
|
// Copy/paste operations - Milestone 11
|
||||||
|
VoxelRegion copyRegion(int x0, int y0, int z0, int x1, int y1, int z1) const;
|
||||||
|
void pasteRegion(const VoxelRegion& region, int x, int y, int z, bool skipAir = true);
|
||||||
|
|
||||||
|
// Navigation projection - Milestone 12
|
||||||
|
struct NavInfo {
|
||||||
|
float height = 0.0f;
|
||||||
|
bool walkable = false;
|
||||||
|
bool transparent = true;
|
||||||
|
float pathCost = 1.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Project a single column to get navigation info
|
||||||
|
/// @param x X coordinate in voxel grid
|
||||||
|
/// @param z Z coordinate in voxel grid
|
||||||
|
/// @param headroom Required air voxels above floor (default 2)
|
||||||
|
/// @return Navigation info for this column
|
||||||
|
NavInfo projectColumn(int x, int z, int headroom = 2) const;
|
||||||
|
|
||||||
|
// 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 (Milestones 10, 13)
|
||||||
|
/// 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(); }
|
||||||
|
|
||||||
|
/// Enable/disable greedy meshing (Milestone 13)
|
||||||
|
/// Greedy meshing merges coplanar faces to reduce vertex count
|
||||||
|
void setGreedyMeshing(bool enabled) { greedyMeshing_ = enabled; markDirty(); }
|
||||||
|
bool isGreedyMeshingEnabled() const { return greedyMeshing_; }
|
||||||
|
|
||||||
|
// Memory info (for debugging)
|
||||||
|
size_t memoryUsageBytes() const {
|
||||||
|
return data_.size() + materials_.size() * sizeof(VoxelMaterial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialization (Milestone 14)
|
||||||
|
/// Save voxel grid to binary file
|
||||||
|
/// @param path File path to save to
|
||||||
|
/// @return true on success
|
||||||
|
bool save(const std::string& path) const;
|
||||||
|
|
||||||
|
/// Load voxel grid from binary file
|
||||||
|
/// @param path File path to load from
|
||||||
|
/// @return true on success
|
||||||
|
bool load(const std::string& path);
|
||||||
|
|
||||||
|
/// Save to memory buffer
|
||||||
|
/// @param buffer Output buffer (resized as needed)
|
||||||
|
/// @return true on success
|
||||||
|
bool saveToBuffer(std::vector<uint8_t>& buffer) const;
|
||||||
|
|
||||||
|
/// Load from memory buffer
|
||||||
|
/// @param data Buffer to load from
|
||||||
|
/// @param size Buffer size
|
||||||
|
/// @return true on success
|
||||||
|
bool loadFromBuffer(const uint8_t* data, size_t size);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mcrf
|
||||||
317
src/3d/VoxelMesher.cpp
Normal file
317
src/3d/VoxelMesher.cpp
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
// 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::emitQuad(std::vector<MeshVertex>& vertices,
|
||||||
|
const vec3& corner,
|
||||||
|
const vec3& uAxis,
|
||||||
|
const vec3& vAxis,
|
||||||
|
const vec3& normal,
|
||||||
|
const VoxelMaterial& material) {
|
||||||
|
// 4 corners of the quad
|
||||||
|
vec3 corners[4] = {
|
||||||
|
corner, // 0: origin
|
||||||
|
corner + uAxis, // 1: +U
|
||||||
|
corner + uAxis + vAxis, // 2: +U+V
|
||||||
|
corner + vAxis // 3: +V
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate UV based on quad size (for potential texture tiling)
|
||||||
|
float uLen = uAxis.length();
|
||||||
|
float vLen = vAxis.length();
|
||||||
|
vec2 uvs[4] = {
|
||||||
|
vec2(0, 0), // 0
|
||||||
|
vec2(uLen, 0), // 1
|
||||||
|
vec2(uLen, vLen), // 2
|
||||||
|
vec2(0, vLen) // 3
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color from material
|
||||||
|
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
|
||||||
|
// Triangle 1: 0-2-1
|
||||||
|
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
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoxelMesher::generateGreedyMesh(const VoxelGrid& grid, std::vector<MeshVertex>& outVertices) {
|
||||||
|
const float cs = grid.cellSize();
|
||||||
|
const int width = grid.width();
|
||||||
|
const int height = grid.height();
|
||||||
|
const int depth = grid.depth();
|
||||||
|
|
||||||
|
// Process each face direction
|
||||||
|
// Axis 0 = X, 1 = Y, 2 = Z
|
||||||
|
// Direction: +1 = positive, -1 = negative
|
||||||
|
|
||||||
|
for (int axis = 0; axis < 3; axis++) {
|
||||||
|
for (int dir = -1; dir <= 1; dir += 2) {
|
||||||
|
// Determine slice dimensions based on axis
|
||||||
|
int sliceW, sliceH, sliceCount;
|
||||||
|
if (axis == 0) { // X-axis: slices in YZ plane
|
||||||
|
sliceW = depth;
|
||||||
|
sliceH = height;
|
||||||
|
sliceCount = width;
|
||||||
|
} else if (axis == 1) { // Y-axis: slices in XZ plane
|
||||||
|
sliceW = width;
|
||||||
|
sliceH = depth;
|
||||||
|
sliceCount = height;
|
||||||
|
} else { // Z-axis: slices in XY plane
|
||||||
|
sliceW = width;
|
||||||
|
sliceH = height;
|
||||||
|
sliceCount = depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mask for this slice
|
||||||
|
std::vector<uint8_t> mask(sliceW * sliceH);
|
||||||
|
|
||||||
|
// Process each slice
|
||||||
|
for (int sliceIdx = 0; sliceIdx < sliceCount; sliceIdx++) {
|
||||||
|
// Fill mask with material IDs where faces should be generated
|
||||||
|
std::fill(mask.begin(), mask.end(), 0);
|
||||||
|
|
||||||
|
for (int v = 0; v < sliceH; v++) {
|
||||||
|
for (int u = 0; u < sliceW; u++) {
|
||||||
|
// Map (u, v, sliceIdx) to (x, y, z) based on axis
|
||||||
|
int x, y, z, nx, ny, nz;
|
||||||
|
if (axis == 0) {
|
||||||
|
x = sliceIdx; y = v; z = u;
|
||||||
|
nx = x + dir; ny = y; nz = z;
|
||||||
|
} else if (axis == 1) {
|
||||||
|
x = u; y = sliceIdx; z = v;
|
||||||
|
nx = x; ny = y + dir; nz = z;
|
||||||
|
} else {
|
||||||
|
x = u; y = v; z = sliceIdx;
|
||||||
|
nx = x; ny = y; nz = z + dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t mat = grid.get(x, y, z);
|
||||||
|
if (mat == 0) continue;
|
||||||
|
|
||||||
|
// Check if face should be generated
|
||||||
|
if (shouldGenerateFace(grid, x, y, z, nx, ny, nz)) {
|
||||||
|
mask[v * sliceW + u] = mat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Greedy rectangle merging
|
||||||
|
for (int v = 0; v < sliceH; v++) {
|
||||||
|
for (int u = 0; u < sliceW; ) {
|
||||||
|
uint8_t mat = mask[v * sliceW + u];
|
||||||
|
if (mat == 0) {
|
||||||
|
u++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find width of rectangle (extend along U)
|
||||||
|
int rectW = 1;
|
||||||
|
while (u + rectW < sliceW && mask[v * sliceW + u + rectW] == mat) {
|
||||||
|
rectW++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find height of rectangle (extend along V)
|
||||||
|
int rectH = 1;
|
||||||
|
bool canExtend = true;
|
||||||
|
while (canExtend && v + rectH < sliceH) {
|
||||||
|
// Check if entire row matches
|
||||||
|
for (int i = 0; i < rectW; i++) {
|
||||||
|
if (mask[(v + rectH) * sliceW + u + i] != mat) {
|
||||||
|
canExtend = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canExtend) rectH++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear mask for merged area
|
||||||
|
for (int dv = 0; dv < rectH; dv++) {
|
||||||
|
for (int du = 0; du < rectW; du++) {
|
||||||
|
mask[(v + dv) * sliceW + u + du] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit quad for this merged rectangle
|
||||||
|
const VoxelMaterial& material = grid.getMaterial(mat);
|
||||||
|
|
||||||
|
// Calculate corner and axes based on face direction
|
||||||
|
vec3 corner, uAxis, vAxis, normal;
|
||||||
|
|
||||||
|
if (axis == 0) { // X-facing
|
||||||
|
float faceX = (dir > 0) ? (sliceIdx + 1) * cs : sliceIdx * cs;
|
||||||
|
corner = vec3(faceX, v * cs, u * cs);
|
||||||
|
uAxis = vec3(0, 0, rectW * cs);
|
||||||
|
vAxis = vec3(0, rectH * cs, 0);
|
||||||
|
normal = vec3(static_cast<float>(dir), 0, 0);
|
||||||
|
// Flip winding for back faces
|
||||||
|
if (dir < 0) std::swap(uAxis, vAxis);
|
||||||
|
} else if (axis == 1) { // Y-facing
|
||||||
|
float faceY = (dir > 0) ? (sliceIdx + 1) * cs : sliceIdx * cs;
|
||||||
|
corner = vec3(u * cs, faceY, v * cs);
|
||||||
|
uAxis = vec3(rectW * cs, 0, 0);
|
||||||
|
vAxis = vec3(0, 0, rectH * cs);
|
||||||
|
normal = vec3(0, static_cast<float>(dir), 0);
|
||||||
|
if (dir < 0) std::swap(uAxis, vAxis);
|
||||||
|
} else { // Z-facing
|
||||||
|
float faceZ = (dir > 0) ? (sliceIdx + 1) * cs : sliceIdx * cs;
|
||||||
|
corner = vec3(u * cs, v * cs, faceZ);
|
||||||
|
uAxis = vec3(rectW * cs, 0, 0);
|
||||||
|
vAxis = vec3(0, rectH * cs, 0);
|
||||||
|
normal = vec3(0, 0, static_cast<float>(dir));
|
||||||
|
if (dir < 0) std::swap(uAxis, vAxis);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitQuad(outVertices, corner, uAxis, vAxis, normal, material);
|
||||||
|
|
||||||
|
u += rectW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
80
src/3d/VoxelMesher.h
Normal file
80
src/3d/VoxelMesher.h
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// VoxelMesher.h - Face-culled mesh generation for VoxelGrid
|
||||||
|
// Part of McRogueFace 3D Extension - Milestones 10, 13
|
||||||
|
#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 (simple per-voxel faces)
|
||||||
|
/// 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
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Generate mesh using greedy meshing algorithm (Milestone 13)
|
||||||
|
/// Merges coplanar faces of the same material into larger rectangles,
|
||||||
|
/// significantly reducing vertex count for uniform regions.
|
||||||
|
/// @param grid The VoxelGrid to generate mesh from
|
||||||
|
/// @param outVertices Output vector of vertices (appended to, not cleared)
|
||||||
|
static void generateGreedyMesh(
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Generate a rectangular face (2 triangles = 6 vertices)
|
||||||
|
/// Used by greedy meshing to emit merged quads
|
||||||
|
/// @param vertices Output vector to append vertices to
|
||||||
|
/// @param corner Base corner of the rectangle
|
||||||
|
/// @param uAxis Direction and length along U axis
|
||||||
|
/// @param vAxis Direction and length along V axis
|
||||||
|
/// @param normal Face normal direction
|
||||||
|
/// @param material Material for coloring
|
||||||
|
static void emitQuad(
|
||||||
|
std::vector<MeshVertex>& vertices,
|
||||||
|
const vec3& corner,
|
||||||
|
const vec3& uAxis,
|
||||||
|
const vec3& vAxis,
|
||||||
|
const vec3& normal,
|
||||||
|
const VoxelMaterial& material
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mcrf
|
||||||
7240
src/3d/cgltf.h
Normal file
7240
src/3d/cgltf.h
Normal file
File diff suppressed because it is too large
Load diff
6
src/3d/cgltf_impl.cpp
Normal file
6
src/3d/cgltf_impl.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// cgltf_impl.cpp - Implementation file for cgltf glTF loader
|
||||||
|
// This file defines CGLTF_IMPLEMENTATION to include the cgltf implementation
|
||||||
|
// exactly once in the project.
|
||||||
|
|
||||||
|
#define CGLTF_IMPLEMENTATION
|
||||||
|
#include "cgltf.h"
|
||||||
108
src/3d/shaders/ps1_skinned_vertex.glsl
Normal file
108
src/3d/shaders/ps1_skinned_vertex.glsl
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// PS1-style skinned vertex shader for OpenGL 3.2+
|
||||||
|
// Implements skeletal animation, vertex snapping, Gouraud shading, and fog
|
||||||
|
|
||||||
|
#version 150 core
|
||||||
|
|
||||||
|
// Uniforms - transform matrices
|
||||||
|
uniform mat4 u_model;
|
||||||
|
uniform mat4 u_view;
|
||||||
|
uniform mat4 u_projection;
|
||||||
|
|
||||||
|
// Uniforms - skeletal animation (max 64 bones)
|
||||||
|
uniform mat4 u_bones[64];
|
||||||
|
|
||||||
|
// Uniforms - PS1 effects
|
||||||
|
uniform vec2 u_resolution; // Internal render resolution for vertex snapping
|
||||||
|
uniform bool u_enable_snap; // Enable vertex snapping to pixel grid
|
||||||
|
uniform float u_fog_start; // Fog start distance
|
||||||
|
uniform float u_fog_end; // Fog end distance
|
||||||
|
|
||||||
|
// Uniforms - lighting
|
||||||
|
uniform vec3 u_light_dir; // Directional light direction (normalized)
|
||||||
|
uniform vec3 u_ambient; // Ambient light color
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
in vec3 a_position;
|
||||||
|
in vec2 a_texcoord;
|
||||||
|
in vec3 a_normal;
|
||||||
|
in vec4 a_color;
|
||||||
|
in vec4 a_bone_ids; // Up to 4 bone indices (as float for compatibility)
|
||||||
|
in vec4 a_bone_weights; // Corresponding weights
|
||||||
|
|
||||||
|
// Varyings - passed to fragment shader
|
||||||
|
out vec4 v_color; // Gouraud-shaded vertex color
|
||||||
|
noperspective out vec2 v_texcoord; // Texture coordinates (affine interpolation!)
|
||||||
|
out float v_fog; // Fog factor (0 = no fog, 1 = full fog)
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// =========================================================================
|
||||||
|
// Skeletal Animation: Vertex Skinning
|
||||||
|
// Transform vertex and normal by weighted bone matrices
|
||||||
|
// =========================================================================
|
||||||
|
ivec4 bone_ids = ivec4(a_bone_ids); // Convert to integer indices
|
||||||
|
|
||||||
|
// Compute skinned position and normal
|
||||||
|
mat4 skin_matrix =
|
||||||
|
u_bones[bone_ids.x] * a_bone_weights.x +
|
||||||
|
u_bones[bone_ids.y] * a_bone_weights.y +
|
||||||
|
u_bones[bone_ids.z] * a_bone_weights.z +
|
||||||
|
u_bones[bone_ids.w] * a_bone_weights.w;
|
||||||
|
|
||||||
|
vec4 skinned_pos = skin_matrix * vec4(a_position, 1.0);
|
||||||
|
vec3 skinned_normal = mat3(skin_matrix) * a_normal;
|
||||||
|
|
||||||
|
// Transform vertex to clip space
|
||||||
|
vec4 worldPos = u_model * skinned_pos;
|
||||||
|
vec4 viewPos = u_view * worldPos;
|
||||||
|
vec4 clipPos = u_projection * viewPos;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PS1 Effect: Vertex Snapping
|
||||||
|
// The PS1 had limited precision for vertex positions, causing vertices
|
||||||
|
// to "snap" to a grid, creating the characteristic jittery look.
|
||||||
|
// =========================================================================
|
||||||
|
if (u_enable_snap) {
|
||||||
|
// Convert to NDC
|
||||||
|
vec4 ndc = clipPos;
|
||||||
|
ndc.xyz /= ndc.w;
|
||||||
|
|
||||||
|
// Snap to pixel grid based on render resolution
|
||||||
|
vec2 grid = u_resolution * 0.5;
|
||||||
|
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
|
||||||
|
|
||||||
|
// Convert back to clip space
|
||||||
|
ndc.xyz *= clipPos.w;
|
||||||
|
clipPos = ndc;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_Position = clipPos;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PS1 Effect: Gouraud Shading
|
||||||
|
// Per-vertex lighting was used on PS1 due to hardware limitations.
|
||||||
|
// This creates characteristic flat-shaded polygons.
|
||||||
|
// =========================================================================
|
||||||
|
vec3 worldNormal = mat3(u_model) * skinned_normal;
|
||||||
|
worldNormal = normalize(worldNormal);
|
||||||
|
|
||||||
|
// Simple directional light + ambient
|
||||||
|
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
|
||||||
|
vec3 lighting = u_ambient + vec3(diffuse);
|
||||||
|
|
||||||
|
// Apply lighting to vertex color
|
||||||
|
v_color = vec4(a_color.rgb * lighting, a_color.a);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PS1 Effect: Affine Texture Mapping
|
||||||
|
// Using 'noperspective' qualifier disables perspective-correct interpolation
|
||||||
|
// This creates the characteristic texture warping on large polygons
|
||||||
|
// =========================================================================
|
||||||
|
v_texcoord = a_texcoord;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Fog Distance Calculation
|
||||||
|
// Calculate linear fog factor based on view-space depth
|
||||||
|
// =========================================================================
|
||||||
|
float depth = -viewPos.z; // View space depth (positive)
|
||||||
|
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
|
||||||
|
}
|
||||||
195
src/3d/shaders/ps1_skinned_vertex_es2.glsl
Normal file
195
src/3d/shaders/ps1_skinned_vertex_es2.glsl
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
// PS1-style skinned vertex shader for OpenGL ES 2.0 / WebGL 1.0
|
||||||
|
// Implements skeletal animation, vertex snapping, Gouraud shading, and fog
|
||||||
|
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
// Uniforms - transform matrices
|
||||||
|
uniform mat4 u_model;
|
||||||
|
uniform mat4 u_view;
|
||||||
|
uniform mat4 u_projection;
|
||||||
|
|
||||||
|
// Uniforms - skeletal animation (max 64 bones)
|
||||||
|
// GLES2 doesn't guarantee support for arrays > 128 vec4s in vertex shaders
|
||||||
|
// 64 bones * 4 vec4s = 256 vec4s, so we use 32 bones for safety
|
||||||
|
uniform mat4 u_bones[32];
|
||||||
|
|
||||||
|
// Uniforms - PS1 effects
|
||||||
|
uniform vec2 u_resolution; // Internal render resolution for vertex snapping
|
||||||
|
uniform bool u_enable_snap; // Enable vertex snapping to pixel grid
|
||||||
|
uniform float u_fog_start; // Fog start distance
|
||||||
|
uniform float u_fog_end; // Fog end distance
|
||||||
|
|
||||||
|
// Uniforms - lighting
|
||||||
|
uniform vec3 u_light_dir; // Directional light direction (normalized)
|
||||||
|
uniform vec3 u_ambient; // Ambient light color
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
attribute vec3 a_position;
|
||||||
|
attribute vec2 a_texcoord;
|
||||||
|
attribute vec3 a_normal;
|
||||||
|
attribute vec4 a_color;
|
||||||
|
attribute vec4 a_bone_ids; // Up to 4 bone indices (as floats)
|
||||||
|
attribute vec4 a_bone_weights; // Corresponding weights
|
||||||
|
|
||||||
|
// Varyings - passed to fragment shader
|
||||||
|
varying vec4 v_color; // Gouraud-shaded vertex color
|
||||||
|
varying vec2 v_texcoord; // Texture coordinates (multiplied by w for affine trick)
|
||||||
|
varying float v_w; // Clip space w for affine mapping restoration
|
||||||
|
varying float v_fog; // Fog factor (0 = no fog, 1 = full fog)
|
||||||
|
|
||||||
|
// Helper to get bone matrix by index (GLES2 doesn't support dynamic array indexing well)
|
||||||
|
mat4 getBoneMatrix(int index) {
|
||||||
|
// GLES2 workaround: use if-chain for dynamic indexing
|
||||||
|
if (index < 8) {
|
||||||
|
if (index < 4) {
|
||||||
|
if (index < 2) {
|
||||||
|
if (index == 0) return u_bones[0];
|
||||||
|
else return u_bones[1];
|
||||||
|
} else {
|
||||||
|
if (index == 2) return u_bones[2];
|
||||||
|
else return u_bones[3];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 6) {
|
||||||
|
if (index == 4) return u_bones[4];
|
||||||
|
else return u_bones[5];
|
||||||
|
} else {
|
||||||
|
if (index == 6) return u_bones[6];
|
||||||
|
else return u_bones[7];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (index < 16) {
|
||||||
|
if (index < 12) {
|
||||||
|
if (index < 10) {
|
||||||
|
if (index == 8) return u_bones[8];
|
||||||
|
else return u_bones[9];
|
||||||
|
} else {
|
||||||
|
if (index == 10) return u_bones[10];
|
||||||
|
else return u_bones[11];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 14) {
|
||||||
|
if (index == 12) return u_bones[12];
|
||||||
|
else return u_bones[13];
|
||||||
|
} else {
|
||||||
|
if (index == 14) return u_bones[14];
|
||||||
|
else return u_bones[15];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (index < 24) {
|
||||||
|
if (index < 20) {
|
||||||
|
if (index < 18) {
|
||||||
|
if (index == 16) return u_bones[16];
|
||||||
|
else return u_bones[17];
|
||||||
|
} else {
|
||||||
|
if (index == 18) return u_bones[18];
|
||||||
|
else return u_bones[19];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 22) {
|
||||||
|
if (index == 20) return u_bones[20];
|
||||||
|
else return u_bones[21];
|
||||||
|
} else {
|
||||||
|
if (index == 22) return u_bones[22];
|
||||||
|
else return u_bones[23];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 28) {
|
||||||
|
if (index < 26) {
|
||||||
|
if (index == 24) return u_bones[24];
|
||||||
|
else return u_bones[25];
|
||||||
|
} else {
|
||||||
|
if (index == 26) return u_bones[26];
|
||||||
|
else return u_bones[27];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (index < 30) {
|
||||||
|
if (index == 28) return u_bones[28];
|
||||||
|
else return u_bones[29];
|
||||||
|
} else {
|
||||||
|
if (index == 30) return u_bones[30];
|
||||||
|
else return u_bones[31];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mat4(1.0); // Identity fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// =========================================================================
|
||||||
|
// Skeletal Animation: Vertex Skinning
|
||||||
|
// Transform vertex and normal by weighted bone matrices
|
||||||
|
// =========================================================================
|
||||||
|
int b0 = int(a_bone_ids.x);
|
||||||
|
int b1 = int(a_bone_ids.y);
|
||||||
|
int b2 = int(a_bone_ids.z);
|
||||||
|
int b3 = int(a_bone_ids.w);
|
||||||
|
|
||||||
|
// Compute skinned position and normal
|
||||||
|
mat4 skin_matrix =
|
||||||
|
getBoneMatrix(b0) * a_bone_weights.x +
|
||||||
|
getBoneMatrix(b1) * a_bone_weights.y +
|
||||||
|
getBoneMatrix(b2) * a_bone_weights.z +
|
||||||
|
getBoneMatrix(b3) * a_bone_weights.w;
|
||||||
|
|
||||||
|
vec4 skinned_pos = skin_matrix * vec4(a_position, 1.0);
|
||||||
|
vec3 skinned_normal = mat3(skin_matrix[0].xyz, skin_matrix[1].xyz, skin_matrix[2].xyz) * a_normal;
|
||||||
|
|
||||||
|
// Transform vertex to clip space
|
||||||
|
vec4 worldPos = u_model * skinned_pos;
|
||||||
|
vec4 viewPos = u_view * worldPos;
|
||||||
|
vec4 clipPos = u_projection * viewPos;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PS1 Effect: Vertex Snapping
|
||||||
|
// The PS1 had limited precision for vertex positions, causing vertices
|
||||||
|
// to "snap" to a grid, creating the characteristic jittery look.
|
||||||
|
// =========================================================================
|
||||||
|
if (u_enable_snap) {
|
||||||
|
// Convert to NDC
|
||||||
|
vec4 ndc = clipPos;
|
||||||
|
ndc.xyz /= ndc.w;
|
||||||
|
|
||||||
|
// Snap to pixel grid based on render resolution
|
||||||
|
vec2 grid = u_resolution * 0.5;
|
||||||
|
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
|
||||||
|
|
||||||
|
// Convert back to clip space
|
||||||
|
ndc.xyz *= clipPos.w;
|
||||||
|
clipPos = ndc;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_Position = clipPos;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PS1 Effect: Gouraud Shading
|
||||||
|
// Per-vertex lighting was used on PS1 due to hardware limitations.
|
||||||
|
// This creates characteristic flat-shaded polygons.
|
||||||
|
// =========================================================================
|
||||||
|
vec3 worldNormal = mat3(u_model[0].xyz, u_model[1].xyz, u_model[2].xyz) * skinned_normal;
|
||||||
|
worldNormal = normalize(worldNormal);
|
||||||
|
|
||||||
|
// Simple directional light + ambient
|
||||||
|
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
|
||||||
|
vec3 lighting = u_ambient + vec3(diffuse);
|
||||||
|
|
||||||
|
// Apply lighting to vertex color
|
||||||
|
v_color = vec4(a_color.rgb * lighting, a_color.a);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PS1 Effect: Affine Texture Mapping Trick
|
||||||
|
// GLES2 doesn't have 'noperspective' interpolation, so we manually
|
||||||
|
// multiply texcoords by w here and divide by w in fragment shader.
|
||||||
|
// This creates the characteristic texture warping on large polygons.
|
||||||
|
// =========================================================================
|
||||||
|
v_texcoord = a_texcoord * clipPos.w;
|
||||||
|
v_w = clipPos.w;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Fog Distance Calculation
|
||||||
|
// Calculate linear fog factor based on view-space depth
|
||||||
|
// =========================================================================
|
||||||
|
float depth = -viewPos.z; // View space depth (positive)
|
||||||
|
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,9 @@
|
||||||
#include "3d/Viewport3D.h" // 3D rendering viewport
|
#include "3d/Viewport3D.h" // 3D rendering viewport
|
||||||
#include "3d/Entity3D.h" // 3D game entities
|
#include "3d/Entity3D.h" // 3D game entities
|
||||||
#include "3d/EntityCollection3D.h" // Entity3D collection
|
#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 "McRogueFaceVersion.h"
|
||||||
#include "GameEngine.h"
|
#include "GameEngine.h"
|
||||||
// ImGui is only available for SFML builds
|
// ImGui is only available for SFML builds
|
||||||
|
|
@ -439,7 +442,9 @@ PyObject* PyInit_mcrfpy()
|
||||||
|
|
||||||
/*3D entities*/
|
/*3D entities*/
|
||||||
&mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType,
|
&mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType,
|
||||||
&mcrfpydef::PyEntityCollection3DIterType,
|
&mcrfpydef::PyEntityCollection3DIterType, &mcrfpydef::PyModel3DType,
|
||||||
|
&mcrfpydef::PyBillboardType, &mcrfpydef::PyVoxelGridType,
|
||||||
|
&mcrfpydef::PyVoxelRegionType,
|
||||||
|
|
||||||
/*grid layers (#147)*/
|
/*grid layers (#147)*/
|
||||||
&PyColorLayerType, &PyTileLayerType,
|
&PyColorLayerType, &PyTileLayerType,
|
||||||
|
|
@ -536,6 +541,13 @@ PyObject* PyInit_mcrfpy()
|
||||||
mcrfpydef::PyNoiseSourceType.tp_methods = PyNoiseSource::methods;
|
mcrfpydef::PyNoiseSourceType.tp_methods = PyNoiseSource::methods;
|
||||||
mcrfpydef::PyNoiseSourceType.tp_getset = PyNoiseSource::getsetters;
|
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 PyVoxelRegionType getsetters (Milestone 11)
|
||||||
|
mcrfpydef::PyVoxelRegionType.tp_getset = PyVoxelRegion::getsetters;
|
||||||
|
|
||||||
// Set up PyShaderType methods and getsetters (#106)
|
// Set up PyShaderType methods and getsetters (#106)
|
||||||
mcrfpydef::PyShaderType.tp_methods = PyShader::methods;
|
mcrfpydef::PyShaderType.tp_methods = PyShader::methods;
|
||||||
mcrfpydef::PyShaderType.tp_getset = PyShader::getsetters;
|
mcrfpydef::PyShaderType.tp_getset = PyShader::getsetters;
|
||||||
|
|
@ -559,6 +571,8 @@ PyObject* PyInit_mcrfpy()
|
||||||
PyUIArcType.tp_weaklistoffset = offsetof(PyUIArcObject, weakreflist);
|
PyUIArcType.tp_weaklistoffset = offsetof(PyUIArcObject, weakreflist);
|
||||||
PyViewport3DType.tp_weaklistoffset = offsetof(PyViewport3DObject, weakreflist);
|
PyViewport3DType.tp_weaklistoffset = offsetof(PyViewport3DObject, weakreflist);
|
||||||
mcrfpydef::PyEntity3DType.tp_weaklistoffset = offsetof(PyEntity3DObject, weakreflist);
|
mcrfpydef::PyEntity3DType.tp_weaklistoffset = offsetof(PyEntity3DObject, weakreflist);
|
||||||
|
mcrfpydef::PyModel3DType.tp_weaklistoffset = offsetof(PyModel3DObject, weakreflist);
|
||||||
|
mcrfpydef::PyBillboardType.tp_weaklistoffset = offsetof(PyBillboardObject, weakreflist);
|
||||||
|
|
||||||
// #219 - Initialize PyLock context manager type
|
// #219 - Initialize PyLock context manager type
|
||||||
if (PyLock::init() < 0) {
|
if (PyLock::init() < 0) {
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,69 @@ PyObject* PyColor::pyObject()
|
||||||
|
|
||||||
sf::Color PyColor::fromPy(PyObject* obj)
|
sf::Color PyColor::fromPy(PyObject* obj)
|
||||||
{
|
{
|
||||||
|
// Handle None or NULL
|
||||||
|
if (!obj || obj == Py_None) {
|
||||||
|
return sf::Color::White;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's already a Color object
|
||||||
|
PyTypeObject* color_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color");
|
||||||
|
if (color_type) {
|
||||||
|
bool is_color = PyObject_TypeCheck(obj, color_type);
|
||||||
|
Py_DECREF(color_type);
|
||||||
|
if (is_color) {
|
||||||
PyColorObject* self = (PyColorObject*)obj;
|
PyColorObject* self = (PyColorObject*)obj;
|
||||||
return self->data;
|
return self->data;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tuple or list input
|
||||||
|
if (PyTuple_Check(obj) || PyList_Check(obj)) {
|
||||||
|
Py_ssize_t size = PySequence_Size(obj);
|
||||||
|
if (size < 3 || size > 4) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "Color tuple/list must have 3 or 4 elements (r, g, b[, a])");
|
||||||
|
return sf::Color::White;
|
||||||
|
}
|
||||||
|
|
||||||
|
int r = 255, g = 255, b = 255, a = 255;
|
||||||
|
|
||||||
|
PyObject* item0 = PySequence_GetItem(obj, 0);
|
||||||
|
PyObject* item1 = PySequence_GetItem(obj, 1);
|
||||||
|
PyObject* item2 = PySequence_GetItem(obj, 2);
|
||||||
|
|
||||||
|
if (PyLong_Check(item0)) r = (int)PyLong_AsLong(item0);
|
||||||
|
if (PyLong_Check(item1)) g = (int)PyLong_AsLong(item1);
|
||||||
|
if (PyLong_Check(item2)) b = (int)PyLong_AsLong(item2);
|
||||||
|
|
||||||
|
Py_DECREF(item0);
|
||||||
|
Py_DECREF(item1);
|
||||||
|
Py_DECREF(item2);
|
||||||
|
|
||||||
|
if (size == 4) {
|
||||||
|
PyObject* item3 = PySequence_GetItem(obj, 3);
|
||||||
|
if (PyLong_Check(item3)) a = (int)PyLong_AsLong(item3);
|
||||||
|
Py_DECREF(item3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp values
|
||||||
|
r = std::max(0, std::min(255, r));
|
||||||
|
g = std::max(0, std::min(255, g));
|
||||||
|
b = std::max(0, std::min(255, b));
|
||||||
|
a = std::max(0, std::min(255, a));
|
||||||
|
|
||||||
|
return sf::Color(r, g, b, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle integer (grayscale)
|
||||||
|
if (PyLong_Check(obj)) {
|
||||||
|
int v = std::max(0, std::min(255, (int)PyLong_AsLong(obj)));
|
||||||
|
return sf::Color(v, v, v, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown type - set error and return white
|
||||||
|
PyErr_SetString(PyExc_TypeError, "Color must be a Color object, tuple, list, or integer");
|
||||||
|
return sf::Color::White;
|
||||||
|
}
|
||||||
|
|
||||||
sf::Color PyColor::fromPy(PyColorObject* self)
|
sf::Color PyColor::fromPy(PyColorObject* self)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -67,16 +67,29 @@ sf::Sprite PyTexture::sprite(int index, sf::Vector2f pos, sf::Vector2f s)
|
||||||
PyObject* PyTexture::pyObject()
|
PyObject* PyTexture::pyObject()
|
||||||
{
|
{
|
||||||
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Texture");
|
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Texture");
|
||||||
|
if (!type) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Failed to get Texture type from module");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
PyObject* obj = PyTexture::pynew(type, Py_None, Py_None);
|
PyObject* obj = PyTexture::pynew(type, Py_None, Py_None);
|
||||||
|
Py_DECREF(type); // GetAttrString returns new reference
|
||||||
|
|
||||||
|
if (!obj) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
((PyTextureObject*)obj)->data = shared_from_this();
|
// 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)
|
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;
|
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;
|
||||||
}
|
}
|
||||||
// TODO - shared_from_this will raise an exception if the object does not have a shared pointer. Constructor should be made private; write a factory function
|
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ public:
|
||||||
sf::Sprite sprite(int index, sf::Vector2f pos = sf::Vector2f(0, 0), sf::Vector2f s = sf::Vector2f(1.0, 1.0));
|
sf::Sprite sprite(int index, sf::Vector2f pos = sf::Vector2f(0, 0), sf::Vector2f s = sf::Vector2f(1.0, 1.0));
|
||||||
int getSpriteCount() const { return sheet_width * sheet_height; }
|
int getSpriteCount() const { return sheet_width * sheet_height; }
|
||||||
|
|
||||||
|
// Get the underlying sf::Texture for 3D rendering
|
||||||
|
const sf::Texture* getSFMLTexture() const { return &texture; }
|
||||||
|
|
||||||
PyObject* pyObject();
|
PyObject* pyObject();
|
||||||
static PyObject* repr(PyObject*);
|
static PyObject* repr(PyObject*);
|
||||||
static Py_hash_t hash(PyObject*);
|
static Py_hash_t hash(PyObject*);
|
||||||
|
|
|
||||||
314
tests/demo/screens/billboard_building_demo.py
Normal file
314
tests/demo/screens/billboard_building_demo.py
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
# billboard_building_demo.py - Visual demo of Billboard and Mesh Instances
|
||||||
|
# Demonstrates camera-facing sprites and static mesh placement
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Create demo scene
|
||||||
|
scene = mcrfpy.Scene("billboard_building_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="Billboard & Building Demo - 3D Sprites and Static Meshes", 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, 450),
|
||||||
|
render_resolution=(320, 240), # PS1 resolution
|
||||||
|
fov=60.0,
|
||||||
|
camera_pos=(16.0, 10.0, 20.0),
|
||||||
|
camera_target=(8.0, 0.0, 8.0),
|
||||||
|
bg_color=mcrfpy.Color(80, 120, 180) # Sky blue background
|
||||||
|
)
|
||||||
|
scene.children.append(viewport)
|
||||||
|
|
||||||
|
# Set up the navigation grid
|
||||||
|
GRID_SIZE = 16
|
||||||
|
viewport.set_grid_size(GRID_SIZE, GRID_SIZE)
|
||||||
|
|
||||||
|
# Generate terrain
|
||||||
|
print("Generating terrain...")
|
||||||
|
hm = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
hm.mid_point_displacement(0.2, seed=456) # Gentle terrain
|
||||||
|
hm.normalize(0.0, 0.5) # Keep it low for placing objects
|
||||||
|
|
||||||
|
# Apply heightmap
|
||||||
|
viewport.apply_heightmap(hm, 2.0)
|
||||||
|
|
||||||
|
# Build terrain mesh
|
||||||
|
vertex_count = viewport.build_terrain(
|
||||||
|
layer_name="terrain",
|
||||||
|
heightmap=hm,
|
||||||
|
y_scale=2.0,
|
||||||
|
cell_size=1.0
|
||||||
|
)
|
||||||
|
print(f"Terrain built with {vertex_count} vertices")
|
||||||
|
|
||||||
|
# Create terrain colors (earthy tones)
|
||||||
|
r_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
g_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
b_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
|
||||||
|
for y in range(GRID_SIZE):
|
||||||
|
for x in range(GRID_SIZE):
|
||||||
|
h = hm[x, y]
|
||||||
|
# Earth/grass colors
|
||||||
|
r_map[x, y] = 0.25 + h * 0.2
|
||||||
|
g_map[x, y] = 0.35 + h * 0.25
|
||||||
|
b_map[x, y] = 0.15 + h * 0.1
|
||||||
|
|
||||||
|
viewport.apply_terrain_colors("terrain", r_map, g_map, b_map)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PART 1: Building Placement using Mesh Instances
|
||||||
|
# =============================================================================
|
||||||
|
print("Placing buildings...")
|
||||||
|
|
||||||
|
# Add a layer for buildings
|
||||||
|
viewport.add_layer("buildings", z_index=1)
|
||||||
|
|
||||||
|
# Create a simple building model (cube-like structure)
|
||||||
|
building_model = mcrfpy.Model3D()
|
||||||
|
|
||||||
|
# Place several buildings at different locations with transforms
|
||||||
|
building_positions = [
|
||||||
|
((2, 0, 2), 0, 1.5), # Position, rotation, scale
|
||||||
|
((12, 0, 2), 45, 1.2),
|
||||||
|
((4, 0, 12), 90, 1.0),
|
||||||
|
((10, 0, 10), 30, 1.8),
|
||||||
|
]
|
||||||
|
|
||||||
|
for pos, rotation, scale in building_positions:
|
||||||
|
idx = viewport.add_mesh("buildings", building_model, pos=pos, rotation=rotation, scale=scale)
|
||||||
|
print(f" Placed building {idx} at {pos}")
|
||||||
|
|
||||||
|
# Mark the footprint as blocking
|
||||||
|
gx, gz = int(pos[0]), int(pos[2])
|
||||||
|
footprint_size = max(1, int(scale))
|
||||||
|
viewport.place_blocking(grid_pos=(gx, gz), footprint=(footprint_size, footprint_size))
|
||||||
|
|
||||||
|
print(f"Placed {len(building_positions)} buildings")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PART 2: Billboard Sprites (camera-facing)
|
||||||
|
# =============================================================================
|
||||||
|
print("Creating billboards...")
|
||||||
|
|
||||||
|
# Create billboards for "trees" - camera_y mode (stays upright)
|
||||||
|
tree_positions = [
|
||||||
|
(3, 0, 5), (5, 0, 3), (6, 0, 8), (9, 0, 5),
|
||||||
|
(11, 0, 7), (7, 0, 11), (13, 0, 13), (1, 0, 9)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Note: Without actual textures, billboards will render as simple quads
|
||||||
|
# In a real game, you'd load a tree sprite texture
|
||||||
|
for i, pos in enumerate(tree_positions):
|
||||||
|
bb = mcrfpy.Billboard(
|
||||||
|
pos=pos,
|
||||||
|
scale=1.5,
|
||||||
|
facing="camera_y", # Stays upright, only rotates on Y axis
|
||||||
|
opacity=1.0
|
||||||
|
)
|
||||||
|
viewport.add_billboard(bb)
|
||||||
|
|
||||||
|
print(f" Created {len(tree_positions)} tree billboards (camera_y facing)")
|
||||||
|
|
||||||
|
# Create some particle-like billboards - full camera facing
|
||||||
|
particle_positions = [
|
||||||
|
(8, 3, 8), (8.5, 3.5, 8.2), (7.5, 3.2, 7.8), # Floating particles
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, pos in enumerate(particle_positions):
|
||||||
|
bb = mcrfpy.Billboard(
|
||||||
|
pos=pos,
|
||||||
|
scale=0.3,
|
||||||
|
facing="camera", # Full rotation to face camera
|
||||||
|
opacity=0.7
|
||||||
|
)
|
||||||
|
viewport.add_billboard(bb)
|
||||||
|
|
||||||
|
print(f" Created {len(particle_positions)} particle billboards (camera facing)")
|
||||||
|
|
||||||
|
# Create a fixed-orientation billboard (signpost)
|
||||||
|
signpost = mcrfpy.Billboard(
|
||||||
|
pos=(5, 1.5, 5),
|
||||||
|
scale=1.0,
|
||||||
|
facing="fixed", # Manual orientation
|
||||||
|
)
|
||||||
|
signpost.theta = math.pi / 4 # 45 degrees horizontal
|
||||||
|
signpost.phi = 0.0 # No vertical tilt
|
||||||
|
viewport.add_billboard(signpost)
|
||||||
|
|
||||||
|
print(f" Created 1 signpost billboard (fixed facing)")
|
||||||
|
print(f"Total billboards: {viewport.billboard_count()}")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Info Panel
|
||||||
|
# =============================================================================
|
||||||
|
info_panel = mcrfpy.Frame(pos=(670, 60), size=(330, 450),
|
||||||
|
fill_color=mcrfpy.Color(30, 30, 40),
|
||||||
|
outline_color=mcrfpy.Color(80, 80, 100),
|
||||||
|
outline=2.0)
|
||||||
|
scene.children.append(info_panel)
|
||||||
|
|
||||||
|
# Panel title
|
||||||
|
panel_title = mcrfpy.Caption(text="Billboard & Mesh Demo", pos=(690, 70))
|
||||||
|
panel_title.fill_color = mcrfpy.Color(200, 200, 255)
|
||||||
|
scene.children.append(panel_title)
|
||||||
|
|
||||||
|
# Billboard info
|
||||||
|
bb_info = [
|
||||||
|
("", ""),
|
||||||
|
("Billboard Modes:", ""),
|
||||||
|
(" camera", "Full rotation to face camera"),
|
||||||
|
(" camera_y", "Y-axis only (stays upright)"),
|
||||||
|
(" fixed", "Manual theta/phi angles"),
|
||||||
|
("", ""),
|
||||||
|
(f"Trees:", f"{len(tree_positions)} (camera_y)"),
|
||||||
|
(f"Particles:", f"{len(particle_positions)} (camera)"),
|
||||||
|
(f"Signpost:", "1 (fixed)"),
|
||||||
|
("", ""),
|
||||||
|
("Mesh Instances:", ""),
|
||||||
|
(f" Buildings:", f"{len(building_positions)}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
y_offset = 100
|
||||||
|
for label, value in bb_info:
|
||||||
|
if label or value:
|
||||||
|
text = f"{label} {value}" if value else label
|
||||||
|
cap = mcrfpy.Caption(text=text, pos=(690, y_offset))
|
||||||
|
cap.fill_color = mcrfpy.Color(150, 150, 170)
|
||||||
|
scene.children.append(cap)
|
||||||
|
y_offset += 22
|
||||||
|
|
||||||
|
# Dynamic camera info
|
||||||
|
camera_label = mcrfpy.Caption(text="Camera: Following...", pos=(690, y_offset + 20))
|
||||||
|
camera_label.fill_color = mcrfpy.Color(180, 180, 200)
|
||||||
|
scene.children.append(camera_label)
|
||||||
|
|
||||||
|
# Instructions at bottom
|
||||||
|
instructions = mcrfpy.Caption(
|
||||||
|
text="[Space] Toggle orbit | [1-3] Change billboard mode | [C] Clear buildings | [ESC] Quit",
|
||||||
|
pos=(20, 530)
|
||||||
|
)
|
||||||
|
instructions.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
|
scene.children.append(instructions)
|
||||||
|
|
||||||
|
# Status line
|
||||||
|
status = mcrfpy.Caption(text="Status: Billboard & Building demo loaded", pos=(20, 555))
|
||||||
|
status.fill_color = mcrfpy.Color(100, 200, 100)
|
||||||
|
scene.children.append(status)
|
||||||
|
|
||||||
|
# Animation state
|
||||||
|
animation_time = [0.0]
|
||||||
|
camera_orbit = [True]
|
||||||
|
|
||||||
|
# Update function
|
||||||
|
def update(timer, runtime):
|
||||||
|
animation_time[0] += runtime / 1000.0
|
||||||
|
|
||||||
|
# Camera orbit
|
||||||
|
if camera_orbit[0]:
|
||||||
|
angle = animation_time[0] * 0.3
|
||||||
|
radius = 18.0
|
||||||
|
center_x = 8.0
|
||||||
|
center_z = 8.0
|
||||||
|
height = 10.0 + math.sin(animation_time[0] * 0.2) * 2.0
|
||||||
|
|
||||||
|
x = center_x + math.cos(angle) * radius
|
||||||
|
z = center_z + math.sin(angle) * radius
|
||||||
|
|
||||||
|
viewport.camera_pos = (x, height, z)
|
||||||
|
viewport.camera_target = (center_x, 1.0, center_z)
|
||||||
|
|
||||||
|
camera_label.text = f"Camera: Orbit ({x:.1f}, {height:.1f}, {z:.1f})"
|
||||||
|
|
||||||
|
# Animate particle billboards (bobbing up and down)
|
||||||
|
bb_count = viewport.billboard_count()
|
||||||
|
if bb_count > len(tree_positions):
|
||||||
|
particle_start = len(tree_positions)
|
||||||
|
for i in range(particle_start, particle_start + len(particle_positions)):
|
||||||
|
if i < bb_count:
|
||||||
|
bb = viewport.get_billboard(i)
|
||||||
|
pos = bb.pos
|
||||||
|
new_y = 3.0 + math.sin(animation_time[0] * 2.0 + i * 0.5) * 0.5
|
||||||
|
bb.pos = (pos[0], new_y, pos[2])
|
||||||
|
|
||||||
|
# Key handler
|
||||||
|
def on_key(key, state):
|
||||||
|
if state != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == mcrfpy.Key.SPACE:
|
||||||
|
camera_orbit[0] = not camera_orbit[0]
|
||||||
|
status.text = f"Camera orbit: {'ON' if camera_orbit[0] else 'OFF'}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.NUM_1:
|
||||||
|
# Change all tree billboards to "camera" mode
|
||||||
|
for i in range(len(tree_positions)):
|
||||||
|
viewport.get_billboard(i).facing = "camera"
|
||||||
|
status.text = "Trees now use 'camera' facing (full rotation)"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.NUM_2:
|
||||||
|
# Change all tree billboards to "camera_y" mode
|
||||||
|
for i in range(len(tree_positions)):
|
||||||
|
viewport.get_billboard(i).facing = "camera_y"
|
||||||
|
status.text = "Trees now use 'camera_y' facing (upright)"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.NUM_3:
|
||||||
|
# Change all tree billboards to "fixed" mode
|
||||||
|
for i in range(len(tree_positions)):
|
||||||
|
bb = viewport.get_billboard(i)
|
||||||
|
bb.facing = "fixed"
|
||||||
|
bb.theta = i * 0.5 # Different angles
|
||||||
|
status.text = "Trees now use 'fixed' facing (manual angles)"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.C:
|
||||||
|
viewport.clear_meshes("buildings")
|
||||||
|
status.text = "Cleared all buildings from layer"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.O:
|
||||||
|
# Adjust tree opacity
|
||||||
|
for i in range(len(tree_positions)):
|
||||||
|
bb = viewport.get_billboard(i)
|
||||||
|
bb.opacity = 0.5 if bb.opacity > 0.7 else 1.0
|
||||||
|
status.text = f"Tree opacity toggled"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.V:
|
||||||
|
# Toggle tree visibility
|
||||||
|
for i in range(len(tree_positions)):
|
||||||
|
bb = viewport.get_billboard(i)
|
||||||
|
bb.visible = not bb.visible
|
||||||
|
status.text = f"Tree visibility toggled"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.ESCAPE:
|
||||||
|
mcrfpy.exit()
|
||||||
|
|
||||||
|
# Set up scene
|
||||||
|
scene.on_key = on_key
|
||||||
|
|
||||||
|
# Create timer for updates
|
||||||
|
timer = mcrfpy.Timer("billboard_update", update, 16) # ~60fps
|
||||||
|
|
||||||
|
# Activate scene
|
||||||
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Billboard & Building Demo loaded!")
|
||||||
|
print()
|
||||||
|
print("Controls:")
|
||||||
|
print(" [Space] Toggle camera orbit")
|
||||||
|
print(" [1] Trees -> 'camera' facing")
|
||||||
|
print(" [2] Trees -> 'camera_y' facing (default)")
|
||||||
|
print(" [3] Trees -> 'fixed' facing")
|
||||||
|
print(" [O] Toggle tree opacity")
|
||||||
|
print(" [V] Toggle tree visibility")
|
||||||
|
print(" [C] Clear buildings")
|
||||||
|
print(" [ESC] Quit")
|
||||||
462
tests/demo/screens/integration_demo.py
Normal file
462
tests/demo/screens/integration_demo.py
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
# integration_demo.py - Milestone 8 Integration Demo
|
||||||
|
# Showcases all 3D features: terrain, entities, pathfinding, FOV, billboards, UI, input
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
|
||||||
|
DEMO_NAME = "3D Integration Demo"
|
||||||
|
DEMO_DESCRIPTION = """Complete 3D demo with terrain, player, NPC, FOV, and UI overlay.
|
||||||
|
|
||||||
|
Controls:
|
||||||
|
Arrow keys: Move player
|
||||||
|
Click: Move to clicked position
|
||||||
|
ESC: Quit
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create the main scene
|
||||||
|
scene = mcrfpy.Scene("integration_demo")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants
|
||||||
|
# =============================================================================
|
||||||
|
GRID_WIDTH = 32
|
||||||
|
GRID_DEPTH = 32
|
||||||
|
CELL_SIZE = 1.0
|
||||||
|
TERRAIN_Y_SCALE = 3.0
|
||||||
|
FOV_RADIUS = 10
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 3D Viewport
|
||||||
|
# =============================================================================
|
||||||
|
viewport = mcrfpy.Viewport3D(
|
||||||
|
pos=(10, 10),
|
||||||
|
size=(700, 550),
|
||||||
|
render_resolution=(350, 275),
|
||||||
|
fov=60.0,
|
||||||
|
camera_pos=(16.0, 15.0, 25.0),
|
||||||
|
camera_target=(16.0, 0.0, 16.0),
|
||||||
|
bg_color=mcrfpy.Color(40, 60, 100)
|
||||||
|
)
|
||||||
|
viewport.enable_fog = True
|
||||||
|
viewport.fog_near = 10.0
|
||||||
|
viewport.fog_far = 40.0
|
||||||
|
viewport.fog_color = mcrfpy.Color(40, 60, 100)
|
||||||
|
scene.children.append(viewport)
|
||||||
|
|
||||||
|
# Set up navigation grid
|
||||||
|
viewport.set_grid_size(GRID_WIDTH, GRID_DEPTH)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Terrain Generation
|
||||||
|
# =============================================================================
|
||||||
|
print("Generating terrain...")
|
||||||
|
|
||||||
|
# Create heightmap with hills
|
||||||
|
hm = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH))
|
||||||
|
hm.mid_point_displacement(roughness=0.5)
|
||||||
|
hm.normalize(0.0, 1.0)
|
||||||
|
|
||||||
|
# Build terrain mesh
|
||||||
|
viewport.build_terrain(
|
||||||
|
layer_name="terrain",
|
||||||
|
heightmap=hm,
|
||||||
|
y_scale=TERRAIN_Y_SCALE,
|
||||||
|
cell_size=CELL_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply heightmap to navigation grid
|
||||||
|
viewport.apply_heightmap(hm, TERRAIN_Y_SCALE)
|
||||||
|
|
||||||
|
# Mark steep slopes and water as unwalkable
|
||||||
|
viewport.apply_threshold(hm, 0.0, 0.12, False) # Low areas = water (unwalkable)
|
||||||
|
viewport.set_slope_cost(0.4, 2.0)
|
||||||
|
|
||||||
|
# Create base terrain colors (green/brown based on height)
|
||||||
|
r_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH))
|
||||||
|
g_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH))
|
||||||
|
b_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH))
|
||||||
|
|
||||||
|
# Storage for base colors (for FOV dimming)
|
||||||
|
base_colors = []
|
||||||
|
|
||||||
|
for z in range(GRID_DEPTH):
|
||||||
|
row = []
|
||||||
|
for x in range(GRID_WIDTH):
|
||||||
|
h = hm[x, z]
|
||||||
|
if h < 0.12: # Water
|
||||||
|
r, g, b = 0.1, 0.2, 0.4
|
||||||
|
elif h < 0.25: # Sand/beach
|
||||||
|
r, g, b = 0.6, 0.5, 0.3
|
||||||
|
elif h < 0.6: # Grass
|
||||||
|
r, g, b = 0.2 + random.random() * 0.1, 0.4 + random.random() * 0.15, 0.15
|
||||||
|
else: # Rock/mountain
|
||||||
|
r, g, b = 0.4, 0.35, 0.3
|
||||||
|
|
||||||
|
r_map[x, z] = r
|
||||||
|
g_map[x, z] = g
|
||||||
|
b_map[x, z] = b
|
||||||
|
row.append((r, g, b))
|
||||||
|
base_colors.append(row)
|
||||||
|
|
||||||
|
viewport.apply_terrain_colors("terrain", r_map, g_map, b_map)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Find walkable starting positions
|
||||||
|
# =============================================================================
|
||||||
|
def find_walkable_pos():
|
||||||
|
"""Find a random walkable position"""
|
||||||
|
for _ in range(100):
|
||||||
|
x = random.randint(2, GRID_WIDTH - 3)
|
||||||
|
z = random.randint(2, GRID_DEPTH - 3)
|
||||||
|
cell = viewport.at(x, z)
|
||||||
|
if cell.walkable:
|
||||||
|
return (x, z)
|
||||||
|
return (GRID_WIDTH // 2, GRID_DEPTH // 2)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Player Entity
|
||||||
|
# =============================================================================
|
||||||
|
player_start = find_walkable_pos()
|
||||||
|
player = mcrfpy.Entity3D(pos=player_start, scale=0.8, color=mcrfpy.Color(50, 150, 255))
|
||||||
|
viewport.entities.append(player)
|
||||||
|
print(f"Player at {player_start}")
|
||||||
|
|
||||||
|
# Track discovered cells
|
||||||
|
discovered = set()
|
||||||
|
discovered.add(player_start)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# NPC Entity with Patrol AI
|
||||||
|
# =============================================================================
|
||||||
|
npc_start = find_walkable_pos()
|
||||||
|
while abs(npc_start[0] - player_start[0]) < 5 and abs(npc_start[1] - player_start[1]) < 5:
|
||||||
|
npc_start = find_walkable_pos()
|
||||||
|
|
||||||
|
npc = mcrfpy.Entity3D(pos=npc_start, scale=0.7, color=mcrfpy.Color(255, 100, 100))
|
||||||
|
viewport.entities.append(npc)
|
||||||
|
print(f"NPC at {npc_start}")
|
||||||
|
|
||||||
|
# NPC patrol system
|
||||||
|
class NPCController:
|
||||||
|
def __init__(self, entity, waypoints):
|
||||||
|
self.entity = entity
|
||||||
|
self.waypoints = waypoints
|
||||||
|
self.current_wp = 0
|
||||||
|
self.path = []
|
||||||
|
self.path_index = 0
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
if self.entity.is_moving:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If we have a path, follow it
|
||||||
|
if self.path_index < len(self.path):
|
||||||
|
next_pos = self.path[self.path_index]
|
||||||
|
self.entity.pos = next_pos
|
||||||
|
self.path_index += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reached waypoint, go to next
|
||||||
|
self.current_wp = (self.current_wp + 1) % len(self.waypoints)
|
||||||
|
target = self.waypoints[self.current_wp]
|
||||||
|
|
||||||
|
# Compute path to next waypoint
|
||||||
|
self.path = self.entity.path_to(target[0], target[1])
|
||||||
|
self.path_index = 0
|
||||||
|
|
||||||
|
# Create patrol waypoints
|
||||||
|
npc_waypoints = []
|
||||||
|
for _ in range(4):
|
||||||
|
wp = find_walkable_pos()
|
||||||
|
npc_waypoints.append(wp)
|
||||||
|
|
||||||
|
npc_controller = NPCController(npc, npc_waypoints)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FOV Visualization
|
||||||
|
# =============================================================================
|
||||||
|
def update_fov_colors():
|
||||||
|
"""Update terrain colors based on FOV"""
|
||||||
|
# Compute FOV from player position
|
||||||
|
visible_cells = viewport.compute_fov((player.pos[0], player.pos[1]), FOV_RADIUS)
|
||||||
|
visible_set = set((c[0], c[1]) for c in visible_cells)
|
||||||
|
|
||||||
|
# Update discovered
|
||||||
|
discovered.update(visible_set)
|
||||||
|
|
||||||
|
# Update terrain colors
|
||||||
|
r_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH))
|
||||||
|
g_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH))
|
||||||
|
b_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH))
|
||||||
|
|
||||||
|
for z in range(GRID_DEPTH):
|
||||||
|
for x in range(GRID_WIDTH):
|
||||||
|
base_r, base_g, base_b = base_colors[z][x]
|
||||||
|
|
||||||
|
if (x, z) in visible_set:
|
||||||
|
# Fully visible
|
||||||
|
r_map[x, z] = base_r
|
||||||
|
g_map[x, z] = base_g
|
||||||
|
b_map[x, z] = base_b
|
||||||
|
elif (x, z) in discovered:
|
||||||
|
# Discovered but not visible - dim
|
||||||
|
r_map[x, z] = base_r * 0.4
|
||||||
|
g_map[x, z] = base_g * 0.4
|
||||||
|
b_map[x, z] = base_b * 0.4
|
||||||
|
else:
|
||||||
|
# Never seen - very dark
|
||||||
|
r_map[x, z] = base_r * 0.1
|
||||||
|
g_map[x, z] = base_g * 0.1
|
||||||
|
b_map[x, z] = base_b * 0.1
|
||||||
|
|
||||||
|
viewport.apply_terrain_colors("terrain", r_map, g_map, b_map)
|
||||||
|
|
||||||
|
# Initial FOV update
|
||||||
|
update_fov_colors()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# UI Overlay
|
||||||
|
# =============================================================================
|
||||||
|
ui_frame = mcrfpy.Frame(
|
||||||
|
pos=(720, 10),
|
||||||
|
size=(260, 200),
|
||||||
|
fill_color=mcrfpy.Color(20, 20, 30, 220),
|
||||||
|
outline_color=mcrfpy.Color(80, 80, 120),
|
||||||
|
outline=2.0
|
||||||
|
)
|
||||||
|
scene.children.append(ui_frame)
|
||||||
|
|
||||||
|
title_label = mcrfpy.Caption(text="3D Integration Demo", pos=(740, 20))
|
||||||
|
title_label.fill_color = mcrfpy.Color(255, 255, 150)
|
||||||
|
scene.children.append(title_label)
|
||||||
|
|
||||||
|
status_label = mcrfpy.Caption(text="Status: Idle", pos=(740, 50))
|
||||||
|
status_label.fill_color = mcrfpy.Color(150, 255, 150)
|
||||||
|
scene.children.append(status_label)
|
||||||
|
|
||||||
|
player_pos_label = mcrfpy.Caption(text="Player: (0, 0)", pos=(740, 75))
|
||||||
|
player_pos_label.fill_color = mcrfpy.Color(100, 200, 255)
|
||||||
|
scene.children.append(player_pos_label)
|
||||||
|
|
||||||
|
npc_pos_label = mcrfpy.Caption(text="NPC: (0, 0)", pos=(740, 100))
|
||||||
|
npc_pos_label.fill_color = mcrfpy.Color(255, 150, 150)
|
||||||
|
scene.children.append(npc_pos_label)
|
||||||
|
|
||||||
|
fps_label = mcrfpy.Caption(text="FPS: --", pos=(740, 125))
|
||||||
|
fps_label.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
|
scene.children.append(fps_label)
|
||||||
|
|
||||||
|
discovered_label = mcrfpy.Caption(text="Discovered: 0", pos=(740, 150))
|
||||||
|
discovered_label.fill_color = mcrfpy.Color(180, 180, 100)
|
||||||
|
scene.children.append(discovered_label)
|
||||||
|
|
||||||
|
# Controls info
|
||||||
|
controls_frame = mcrfpy.Frame(
|
||||||
|
pos=(720, 220),
|
||||||
|
size=(260, 120),
|
||||||
|
fill_color=mcrfpy.Color(20, 20, 30, 200),
|
||||||
|
outline_color=mcrfpy.Color(60, 60, 80),
|
||||||
|
outline=1.0
|
||||||
|
)
|
||||||
|
scene.children.append(controls_frame)
|
||||||
|
|
||||||
|
ctrl_title = mcrfpy.Caption(text="Controls:", pos=(740, 230))
|
||||||
|
ctrl_title.fill_color = mcrfpy.Color(200, 200, 100)
|
||||||
|
scene.children.append(ctrl_title)
|
||||||
|
|
||||||
|
ctrl_lines = [
|
||||||
|
"Arrow keys: Move",
|
||||||
|
"Click: Pathfind",
|
||||||
|
"F: Toggle follow cam",
|
||||||
|
"ESC: Quit"
|
||||||
|
]
|
||||||
|
for i, line in enumerate(ctrl_lines):
|
||||||
|
cap = mcrfpy.Caption(text=line, pos=(740, 255 + i * 20))
|
||||||
|
cap.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
|
scene.children.append(cap)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Game State
|
||||||
|
# =============================================================================
|
||||||
|
follow_camera = True
|
||||||
|
frame_count = 0
|
||||||
|
fps_update_time = 0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Update Function
|
||||||
|
# =============================================================================
|
||||||
|
def game_update(timer, runtime):
|
||||||
|
global frame_count, fps_update_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate FPS
|
||||||
|
frame_count += 1
|
||||||
|
if runtime - fps_update_time >= 1000: # Update FPS every second
|
||||||
|
fps = frame_count
|
||||||
|
fps_label.text = f"FPS: {fps}"
|
||||||
|
frame_count = 0
|
||||||
|
fps_update_time = runtime
|
||||||
|
|
||||||
|
# Update NPC patrol
|
||||||
|
npc_controller.update()
|
||||||
|
|
||||||
|
# Update UI labels
|
||||||
|
px, pz = player.pos
|
||||||
|
player_pos_label.text = f"Player: ({px}, {pz})"
|
||||||
|
nx, nz = npc.pos
|
||||||
|
npc_pos_label.text = f"NPC: ({nx}, {nz})"
|
||||||
|
discovered_label.text = f"Discovered: {len(discovered)}"
|
||||||
|
|
||||||
|
# Camera follow
|
||||||
|
if follow_camera:
|
||||||
|
viewport.follow(player, distance=12.0, height=8.0, smoothing=0.1)
|
||||||
|
|
||||||
|
# Update status based on player state
|
||||||
|
if player.is_moving:
|
||||||
|
status_label.text = "Status: Moving"
|
||||||
|
status_label.fill_color = mcrfpy.Color(255, 255, 100)
|
||||||
|
else:
|
||||||
|
status_label.text = "Status: Idle"
|
||||||
|
status_label.fill_color = mcrfpy.Color(150, 255, 150)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Update error: {e}")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Input Handling
|
||||||
|
# =============================================================================
|
||||||
|
def try_move_player(dx, dz):
|
||||||
|
"""Try to move player in direction"""
|
||||||
|
new_x = player.pos[0] + dx
|
||||||
|
new_z = player.pos[1] + dz
|
||||||
|
|
||||||
|
if not viewport.is_in_fov(new_x, new_z):
|
||||||
|
# Allow moving into discovered cells even if not currently visible
|
||||||
|
if (new_x, new_z) not in discovered:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if new_x < 0 or new_x >= GRID_WIDTH or new_z < 0 or new_z >= GRID_DEPTH:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cell = viewport.at(new_x, new_z)
|
||||||
|
if not cell.walkable:
|
||||||
|
return False
|
||||||
|
|
||||||
|
player.pos = (new_x, new_z)
|
||||||
|
update_fov_colors()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def on_key(key, state):
|
||||||
|
global follow_camera
|
||||||
|
|
||||||
|
if state != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
|
if player.is_moving:
|
||||||
|
return # Don't accept input while moving
|
||||||
|
|
||||||
|
dx, dz = 0, 0
|
||||||
|
if key == mcrfpy.Key.UP:
|
||||||
|
dz = -1
|
||||||
|
elif key == mcrfpy.Key.DOWN:
|
||||||
|
dz = 1
|
||||||
|
elif key == mcrfpy.Key.LEFT:
|
||||||
|
dx = -1
|
||||||
|
elif key == mcrfpy.Key.RIGHT:
|
||||||
|
dx = 1
|
||||||
|
elif key == mcrfpy.Key.F:
|
||||||
|
follow_camera = not follow_camera
|
||||||
|
status_label.text = f"Camera: {'Follow' if follow_camera else 'Free'}"
|
||||||
|
return
|
||||||
|
elif key == mcrfpy.Key.ESCAPE:
|
||||||
|
mcrfpy.exit()
|
||||||
|
return
|
||||||
|
|
||||||
|
if dx != 0 or dz != 0:
|
||||||
|
try_move_player(dx, dz)
|
||||||
|
|
||||||
|
# Click-to-move handling
|
||||||
|
def on_click(pos, button, state):
|
||||||
|
if button != mcrfpy.MouseButton.LEFT or state != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
|
if player.is_moving:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Convert click position to viewport-relative coordinates
|
||||||
|
vp_x = pos.x - viewport.x
|
||||||
|
vp_y = pos.y - viewport.y
|
||||||
|
|
||||||
|
# Check if click is within viewport
|
||||||
|
if vp_x < 0 or vp_x >= viewport.w or vp_y < 0 or vp_y >= viewport.h:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Convert to world position
|
||||||
|
world_pos = viewport.screen_to_world(vp_x, vp_y)
|
||||||
|
if world_pos is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Convert to grid position
|
||||||
|
grid_x = int(world_pos[0] / CELL_SIZE)
|
||||||
|
grid_z = int(world_pos[2] / CELL_SIZE)
|
||||||
|
|
||||||
|
# Validate grid position
|
||||||
|
if grid_x < 0 or grid_x >= GRID_WIDTH or grid_z < 0 or grid_z >= GRID_DEPTH:
|
||||||
|
return
|
||||||
|
|
||||||
|
cell = viewport.at(grid_x, grid_z)
|
||||||
|
if not cell.walkable:
|
||||||
|
status_label.text = "Status: Can't walk there!"
|
||||||
|
status_label.fill_color = mcrfpy.Color(255, 100, 100)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find path
|
||||||
|
path = player.path_to(grid_x, grid_z)
|
||||||
|
if not path:
|
||||||
|
status_label.text = "Status: No path!"
|
||||||
|
status_label.fill_color = mcrfpy.Color(255, 100, 100)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Follow path (limited to FOV_RADIUS steps)
|
||||||
|
limited_path = path[:FOV_RADIUS]
|
||||||
|
player.follow_path(limited_path)
|
||||||
|
status_label.text = f"Status: Moving ({len(limited_path)} steps)"
|
||||||
|
status_label.fill_color = mcrfpy.Color(255, 255, 100)
|
||||||
|
|
||||||
|
# Schedule FOV update after movement completes
|
||||||
|
fov_update_timer = None
|
||||||
|
|
||||||
|
def update_fov_after_move(*args):
|
||||||
|
# Accept any number of args since timer may pass (runtime) or (timer, runtime)
|
||||||
|
nonlocal fov_update_timer
|
||||||
|
if not player.is_moving:
|
||||||
|
update_fov_colors()
|
||||||
|
if fov_update_timer:
|
||||||
|
fov_update_timer.stop()
|
||||||
|
|
||||||
|
fov_update_timer = mcrfpy.Timer("fov_update", update_fov_after_move, 100)
|
||||||
|
|
||||||
|
scene.on_key = on_key
|
||||||
|
viewport.on_click = on_click
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Start Game
|
||||||
|
# =============================================================================
|
||||||
|
timer = mcrfpy.Timer("game_update", game_update, 16) # ~60 FPS
|
||||||
|
|
||||||
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("3D Integration Demo Loaded!")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" Terrain: {GRID_WIDTH}x{GRID_DEPTH} cells")
|
||||||
|
print(f" Player starts at: {player_start}")
|
||||||
|
print(f" NPC patrolling {len(npc_waypoints)} waypoints")
|
||||||
|
print()
|
||||||
|
print("Controls:")
|
||||||
|
print(" Arrow keys: Move player")
|
||||||
|
print(" Click: Pathfind to location")
|
||||||
|
print(" F: Toggle camera follow")
|
||||||
|
print(" ESC: Quit")
|
||||||
|
print("=" * 60)
|
||||||
240
tests/demo/screens/model_loading_demo.py
Normal file
240
tests/demo/screens/model_loading_demo.py
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
# model_loading_demo.py - Visual demo of Model3D model loading
|
||||||
|
# Shows both procedural primitives and loaded .glb models
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Create demo scene
|
||||||
|
scene = mcrfpy.Scene("model_loading_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="Model3D Demo - Procedural & glTF Models", 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, 450),
|
||||||
|
# render_resolution=(320, 240), # PS1 resolution
|
||||||
|
render_resolution=(600,450),
|
||||||
|
fov=60.0,
|
||||||
|
camera_pos=(0.0, 3.0, 8.0),
|
||||||
|
camera_target=(0.0, 1.0, 0.0),
|
||||||
|
bg_color=mcrfpy.Color(30, 30, 50)
|
||||||
|
)
|
||||||
|
scene.children.append(viewport)
|
||||||
|
|
||||||
|
# Set up navigation grid
|
||||||
|
GRID_SIZE = 32
|
||||||
|
viewport.set_grid_size(GRID_SIZE, GRID_SIZE)
|
||||||
|
|
||||||
|
# Build a simple flat floor
|
||||||
|
hm = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
hm.normalize(0.0, 0.0)
|
||||||
|
viewport.apply_heightmap(hm, 0.0)
|
||||||
|
vertex_count = viewport.build_terrain(
|
||||||
|
layer_name="floor",
|
||||||
|
heightmap=hm,
|
||||||
|
y_scale=0.0,
|
||||||
|
cell_size=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply floor colors (checkerboard pattern)
|
||||||
|
r_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
g_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
b_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
|
||||||
|
for y in range(GRID_SIZE):
|
||||||
|
for x in range(GRID_SIZE):
|
||||||
|
checker = ((x + y) % 2) * 0.1 + 0.15
|
||||||
|
r_map[x, y] = checker
|
||||||
|
g_map[x, y] = checker
|
||||||
|
b_map[x, y] = checker + 0.05
|
||||||
|
|
||||||
|
viewport.apply_terrain_colors("floor", r_map, g_map, b_map)
|
||||||
|
|
||||||
|
# Create procedural models
|
||||||
|
print("Creating procedural models...")
|
||||||
|
cube_model = mcrfpy.Model3D.cube(1.0)
|
||||||
|
sphere_model = mcrfpy.Model3D.sphere(0.5, 12, 8)
|
||||||
|
|
||||||
|
# Try to load glTF models
|
||||||
|
loaded_models = {}
|
||||||
|
models_dir = "../assets/models"
|
||||||
|
if os.path.exists(models_dir):
|
||||||
|
for filename in ["Duck.glb", "Box.glb", "Lantern.glb", "WaterBottle.glb"]:
|
||||||
|
path = os.path.join(models_dir, filename)
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
model = mcrfpy.Model3D(path)
|
||||||
|
loaded_models[filename] = model
|
||||||
|
print(f"Loaded {filename}: {model.vertex_count} verts, {model.triangle_count} tris")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load {filename}: {e}")
|
||||||
|
|
||||||
|
# Create entities with different models
|
||||||
|
entities = []
|
||||||
|
|
||||||
|
# Row 1: Procedural primitives
|
||||||
|
entity_configs = [
|
||||||
|
((12, 16), cube_model, 1.0, mcrfpy.Color(255, 100, 100), "Cube"),
|
||||||
|
((16, 16), sphere_model, 1.0, mcrfpy.Color(100, 255, 100), "Sphere"),
|
||||||
|
((20, 16), None, 1.0, mcrfpy.Color(200, 200, 200), "Placeholder"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Row 2: Loaded glTF models (if available)
|
||||||
|
if "Duck.glb" in loaded_models:
|
||||||
|
# Duck is huge (~160 units), scale it down significantly
|
||||||
|
entity_configs.append(((14, 12), loaded_models["Duck.glb"], 0.006, mcrfpy.Color(255, 200, 50), "Duck"))
|
||||||
|
|
||||||
|
if "Box.glb" in loaded_models:
|
||||||
|
entity_configs.append(((16, 12), loaded_models["Box.glb"], 1.5, mcrfpy.Color(150, 100, 50), "Box (glb)"))
|
||||||
|
|
||||||
|
if "Lantern.glb" in loaded_models:
|
||||||
|
# Lantern is ~25 units tall
|
||||||
|
entity_configs.append(((18, 12), loaded_models["Lantern.glb"], 0.08, mcrfpy.Color(255, 200, 100), "Lantern"))
|
||||||
|
|
||||||
|
if "WaterBottle.glb" in loaded_models:
|
||||||
|
# WaterBottle is ~0.26 units tall
|
||||||
|
entity_configs.append(((20, 12), loaded_models["WaterBottle.glb"], 4.0, mcrfpy.Color(100, 150, 255), "Bottle"))
|
||||||
|
|
||||||
|
for pos, model, scale, color, name in entity_configs:
|
||||||
|
e = mcrfpy.Entity3D(pos=pos, scale=scale, color=color)
|
||||||
|
if model:
|
||||||
|
e.model = model
|
||||||
|
viewport.entities.append(e)
|
||||||
|
entities.append((e, name, model))
|
||||||
|
|
||||||
|
print(f"Created {len(entities)} entities")
|
||||||
|
|
||||||
|
# Info panel on the right
|
||||||
|
info_panel = mcrfpy.Frame(pos=(670, 60), size=(330, 450),
|
||||||
|
fill_color=mcrfpy.Color(30, 30, 40),
|
||||||
|
outline_color=mcrfpy.Color(80, 80, 100),
|
||||||
|
outline=2.0)
|
||||||
|
scene.children.append(info_panel)
|
||||||
|
|
||||||
|
# Panel title
|
||||||
|
panel_title = mcrfpy.Caption(text="Model Information", pos=(690, 70))
|
||||||
|
panel_title.fill_color = mcrfpy.Color(200, 200, 255)
|
||||||
|
scene.children.append(panel_title)
|
||||||
|
|
||||||
|
# Model info labels
|
||||||
|
y_offset = 100
|
||||||
|
for e, name, model in entities:
|
||||||
|
if model:
|
||||||
|
info = f"{name}: {model.vertex_count}v, {model.triangle_count}t"
|
||||||
|
else:
|
||||||
|
info = f"{name}: Placeholder (36v, 12t)"
|
||||||
|
label = mcrfpy.Caption(text=info, pos=(690, y_offset))
|
||||||
|
label.fill_color = e.color
|
||||||
|
scene.children.append(label)
|
||||||
|
y_offset += 22
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
y_offset += 10
|
||||||
|
sep = mcrfpy.Caption(text="--- glTF Support ---", pos=(690, y_offset))
|
||||||
|
sep.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
|
scene.children.append(sep)
|
||||||
|
y_offset += 22
|
||||||
|
|
||||||
|
# glTF info
|
||||||
|
gltf_info = [
|
||||||
|
"Format: glTF 2.0 (.glb, .gltf)",
|
||||||
|
"Library: cgltf (C99)",
|
||||||
|
f"Loaded models: {len(loaded_models)}",
|
||||||
|
]
|
||||||
|
for info in gltf_info:
|
||||||
|
label = mcrfpy.Caption(text=info, pos=(690, y_offset))
|
||||||
|
label.fill_color = mcrfpy.Color(150, 150, 170)
|
||||||
|
scene.children.append(label)
|
||||||
|
y_offset += 20
|
||||||
|
|
||||||
|
# Instructions at bottom
|
||||||
|
instructions = mcrfpy.Caption(
|
||||||
|
text="[Space] Toggle rotation | [1-3] Camera presets | [ESC] Quit",
|
||||||
|
pos=(20, 530)
|
||||||
|
)
|
||||||
|
instructions.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
|
scene.children.append(instructions)
|
||||||
|
|
||||||
|
# Status line
|
||||||
|
status = mcrfpy.Caption(text="Status: Showing procedural and glTF models", pos=(20, 555))
|
||||||
|
status.fill_color = mcrfpy.Color(100, 200, 100)
|
||||||
|
scene.children.append(status)
|
||||||
|
|
||||||
|
# Animation state
|
||||||
|
animation_time = [0.0]
|
||||||
|
rotate_entities = [True]
|
||||||
|
|
||||||
|
# Camera presets
|
||||||
|
camera_presets = [
|
||||||
|
((0.0, 5.0, 12.0), (0.0, 1.0, 0.0), "Front view"),
|
||||||
|
((12.0, 8.0, 0.0), (0.0, 1.0, 0.0), "Side view"),
|
||||||
|
((0.0, 15.0, 0.1), (0.0, 0.0, 0.0), "Top-down view"),
|
||||||
|
]
|
||||||
|
current_preset = [0]
|
||||||
|
|
||||||
|
# Update function
|
||||||
|
def update(timer, runtime):
|
||||||
|
animation_time[0] += runtime / 1000.0
|
||||||
|
|
||||||
|
if rotate_entities[0]:
|
||||||
|
for i, (e, name, model) in enumerate(entities):
|
||||||
|
e.rotation = (animation_time[0] * 30.0 + i * 45.0) % 360.0
|
||||||
|
|
||||||
|
# Key handler
|
||||||
|
def on_key(key, state):
|
||||||
|
if state != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == mcrfpy.Key.SPACE:
|
||||||
|
rotate_entities[0] = not rotate_entities[0]
|
||||||
|
status.text = f"Rotation: {'ON' if rotate_entities[0] else 'OFF'}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.NUM_1:
|
||||||
|
pos, target, name = camera_presets[0]
|
||||||
|
viewport.camera_pos = pos
|
||||||
|
viewport.camera_target = target
|
||||||
|
status.text = f"Camera: {name}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.NUM_2:
|
||||||
|
pos, target, name = camera_presets[1]
|
||||||
|
viewport.camera_pos = pos
|
||||||
|
viewport.camera_target = target
|
||||||
|
status.text = f"Camera: {name}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.NUM_3:
|
||||||
|
pos, target, name = camera_presets[2]
|
||||||
|
viewport.camera_pos = pos
|
||||||
|
viewport.camera_target = target
|
||||||
|
status.text = f"Camera: {name}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.ESCAPE:
|
||||||
|
mcrfpy.exit()
|
||||||
|
|
||||||
|
# Set up scene
|
||||||
|
scene.on_key = on_key
|
||||||
|
|
||||||
|
# Create timer for updates
|
||||||
|
timer = mcrfpy.Timer("model_update", update, 16)
|
||||||
|
|
||||||
|
# Activate scene
|
||||||
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Model3D Demo loaded!")
|
||||||
|
print(f"Procedural models: cube, sphere")
|
||||||
|
print(f"glTF models loaded: {list(loaded_models.keys())}")
|
||||||
|
print()
|
||||||
|
print("Controls:")
|
||||||
|
print(" [Space] Toggle rotation")
|
||||||
|
print(" [1-3] Camera presets")
|
||||||
|
print(" [ESC] Quit")
|
||||||
275
tests/demo/screens/skeletal_animation_demo.py
Normal file
275
tests/demo/screens/skeletal_animation_demo.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
# skeletal_animation_demo.py - 3D Skeletal Animation Demo Screen
|
||||||
|
# Demonstrates Entity3D animation with real animated glTF models
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
DEMO_NAME = "3D Skeletal Animation"
|
||||||
|
DEMO_DESCRIPTION = """Entity3D Animation API with real skeletal models"""
|
||||||
|
|
||||||
|
# Create demo scene
|
||||||
|
scene = mcrfpy.Scene("skeletal_animation_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="Skeletal Animation Demo", pos=(20, 10))
|
||||||
|
title.fill_color = mcrfpy.Color(255, 255, 100)
|
||||||
|
scene.children.append(title)
|
||||||
|
|
||||||
|
# Create the 3D viewport
|
||||||
|
viewport = mcrfpy.Viewport3D(
|
||||||
|
pos=(50, 50),
|
||||||
|
size=(600, 500),
|
||||||
|
render_resolution=(600, 500),
|
||||||
|
fov=60.0,
|
||||||
|
camera_pos=(0.0, 2.0, 5.0),
|
||||||
|
camera_target=(0.0, 1.0, 0.0),
|
||||||
|
bg_color=mcrfpy.Color(30, 30, 50)
|
||||||
|
)
|
||||||
|
scene.children.append(viewport)
|
||||||
|
|
||||||
|
# Set up navigation grid
|
||||||
|
GRID_SIZE = 16
|
||||||
|
viewport.set_grid_size(GRID_SIZE, GRID_SIZE)
|
||||||
|
|
||||||
|
# Build a simple flat floor
|
||||||
|
hm = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
hm.normalize(0.0, 0.0)
|
||||||
|
viewport.apply_heightmap(hm, 0.0)
|
||||||
|
viewport.build_terrain(
|
||||||
|
layer_name="floor",
|
||||||
|
heightmap=hm,
|
||||||
|
y_scale=0.0,
|
||||||
|
cell_size=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply floor colors (dark gray)
|
||||||
|
r_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
g_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
b_map = mcrfpy.HeightMap((GRID_SIZE, GRID_SIZE))
|
||||||
|
for y in range(GRID_SIZE):
|
||||||
|
for x in range(GRID_SIZE):
|
||||||
|
checker = ((x + y) % 2) * 0.1 + 0.15
|
||||||
|
r_map[x, y] = checker
|
||||||
|
g_map[x, y] = checker
|
||||||
|
b_map[x, y] = checker + 0.05
|
||||||
|
viewport.apply_terrain_colors("floor", r_map, g_map, b_map)
|
||||||
|
|
||||||
|
# Load animated models
|
||||||
|
animated_entity = None
|
||||||
|
model_info = "No animated model"
|
||||||
|
|
||||||
|
# Try to load CesiumMan (humanoid with walk animation)
|
||||||
|
try:
|
||||||
|
model = mcrfpy.Model3D("../assets/models/CesiumMan.glb")
|
||||||
|
if model.has_skeleton:
|
||||||
|
animated_entity = mcrfpy.Entity3D(pos=(8, 8), scale=1.0, color=mcrfpy.Color(200, 180, 150))
|
||||||
|
animated_entity.model = model
|
||||||
|
viewport.entities.append(animated_entity)
|
||||||
|
|
||||||
|
# Set up animation
|
||||||
|
clips = model.animation_clips
|
||||||
|
if clips:
|
||||||
|
animated_entity.anim_clip = clips[0]
|
||||||
|
animated_entity.anim_loop = True
|
||||||
|
animated_entity.anim_speed = 1.0
|
||||||
|
|
||||||
|
model_info = f"CesiumMan: {model.bone_count} bones, {model.vertex_count} verts"
|
||||||
|
print(f"Loaded {model_info}")
|
||||||
|
print(f"Animation clips: {clips}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load CesiumMan: {e}")
|
||||||
|
|
||||||
|
# Also try RiggedSimple as a second model
|
||||||
|
try:
|
||||||
|
model2 = mcrfpy.Model3D("../assets/models/RiggedSimple.glb")
|
||||||
|
if model2.has_skeleton:
|
||||||
|
entity2 = mcrfpy.Entity3D(pos=(10, 8), scale=0.5, color=mcrfpy.Color(100, 200, 255))
|
||||||
|
entity2.model = model2
|
||||||
|
viewport.entities.append(entity2)
|
||||||
|
|
||||||
|
clips = model2.animation_clips
|
||||||
|
if clips:
|
||||||
|
entity2.anim_clip = clips[0]
|
||||||
|
entity2.anim_loop = True
|
||||||
|
entity2.anim_speed = 1.5
|
||||||
|
|
||||||
|
print(f"Loaded RiggedSimple: {model2.bone_count} bones")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load RiggedSimple: {e}")
|
||||||
|
|
||||||
|
# Info panel on the right
|
||||||
|
info_panel = mcrfpy.Frame(pos=(670, 50), size=(330, 500),
|
||||||
|
fill_color=mcrfpy.Color(30, 30, 40),
|
||||||
|
outline_color=mcrfpy.Color(80, 80, 100),
|
||||||
|
outline=2.0)
|
||||||
|
scene.children.append(info_panel)
|
||||||
|
|
||||||
|
# Panel title
|
||||||
|
panel_title = mcrfpy.Caption(text="Animation Properties", pos=(690, 60))
|
||||||
|
panel_title.fill_color = mcrfpy.Color(200, 200, 255)
|
||||||
|
scene.children.append(panel_title)
|
||||||
|
|
||||||
|
# Status labels (will be updated by timer)
|
||||||
|
status_labels = []
|
||||||
|
y_offset = 90
|
||||||
|
|
||||||
|
label_texts = [
|
||||||
|
"Model: loading...",
|
||||||
|
"anim_clip: ",
|
||||||
|
"anim_time: 0.00",
|
||||||
|
"anim_speed: 1.00",
|
||||||
|
"anim_loop: True",
|
||||||
|
"anim_paused: False",
|
||||||
|
"anim_frame: 0",
|
||||||
|
]
|
||||||
|
|
||||||
|
for text in label_texts:
|
||||||
|
label = mcrfpy.Caption(text=text, pos=(690, y_offset))
|
||||||
|
label.fill_color = mcrfpy.Color(150, 200, 150)
|
||||||
|
scene.children.append(label)
|
||||||
|
status_labels.append(label)
|
||||||
|
y_offset += 25
|
||||||
|
|
||||||
|
# Set initial model info
|
||||||
|
status_labels[0].text = f"Model: {model_info}"
|
||||||
|
|
||||||
|
# Controls section
|
||||||
|
y_offset += 20
|
||||||
|
controls_title = mcrfpy.Caption(text="Controls:", pos=(690, y_offset))
|
||||||
|
controls_title.fill_color = mcrfpy.Color(255, 255, 200)
|
||||||
|
scene.children.append(controls_title)
|
||||||
|
y_offset += 25
|
||||||
|
|
||||||
|
controls = [
|
||||||
|
"[SPACE] Toggle pause",
|
||||||
|
"[L] Toggle loop",
|
||||||
|
"[+/-] Adjust speed",
|
||||||
|
"[R] Reset time",
|
||||||
|
"[1-3] Camera presets",
|
||||||
|
]
|
||||||
|
|
||||||
|
for ctrl in controls:
|
||||||
|
cap = mcrfpy.Caption(text=ctrl, pos=(690, y_offset))
|
||||||
|
cap.fill_color = mcrfpy.Color(180, 180, 150)
|
||||||
|
scene.children.append(cap)
|
||||||
|
y_offset += 20
|
||||||
|
|
||||||
|
# Auto-animate section
|
||||||
|
y_offset += 20
|
||||||
|
auto_title = mcrfpy.Caption(text="Auto-Animate:", pos=(690, y_offset))
|
||||||
|
auto_title.fill_color = mcrfpy.Color(255, 200, 200)
|
||||||
|
scene.children.append(auto_title)
|
||||||
|
y_offset += 25
|
||||||
|
|
||||||
|
auto_labels = []
|
||||||
|
auto_texts = [
|
||||||
|
"auto_animate: True",
|
||||||
|
"walk_clip: 'walk'",
|
||||||
|
"idle_clip: 'idle'",
|
||||||
|
]
|
||||||
|
|
||||||
|
for text in auto_texts:
|
||||||
|
cap = mcrfpy.Caption(text=text, pos=(690, y_offset))
|
||||||
|
cap.fill_color = mcrfpy.Color(180, 160, 160)
|
||||||
|
scene.children.append(cap)
|
||||||
|
auto_labels.append(cap)
|
||||||
|
y_offset += 20
|
||||||
|
|
||||||
|
# Instructions at bottom
|
||||||
|
status = mcrfpy.Caption(text="Status: Animation playing", pos=(20, 570))
|
||||||
|
status.fill_color = mcrfpy.Color(100, 200, 100)
|
||||||
|
scene.children.append(status)
|
||||||
|
|
||||||
|
# Camera presets
|
||||||
|
camera_presets = [
|
||||||
|
((0.0, 2.0, 5.0), (0.0, 1.0, 0.0), "Front view"),
|
||||||
|
((5.0, 3.0, 0.0), (0.0, 1.0, 0.0), "Side view"),
|
||||||
|
((0.0, 6.0, 0.1), (0.0, 0.0, 0.0), "Top-down view"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Update function - updates display and entity rotation
|
||||||
|
def update(timer, runtime):
|
||||||
|
if animated_entity:
|
||||||
|
# Update status display
|
||||||
|
status_labels[1].text = f"anim_clip: '{animated_entity.anim_clip}'"
|
||||||
|
status_labels[2].text = f"anim_time: {animated_entity.anim_time:.2f}"
|
||||||
|
status_labels[3].text = f"anim_speed: {animated_entity.anim_speed:.2f}"
|
||||||
|
status_labels[4].text = f"anim_loop: {animated_entity.anim_loop}"
|
||||||
|
status_labels[5].text = f"anim_paused: {animated_entity.anim_paused}"
|
||||||
|
status_labels[6].text = f"anim_frame: {animated_entity.anim_frame}"
|
||||||
|
|
||||||
|
auto_labels[0].text = f"auto_animate: {animated_entity.auto_animate}"
|
||||||
|
auto_labels[1].text = f"walk_clip: '{animated_entity.walk_clip}'"
|
||||||
|
auto_labels[2].text = f"idle_clip: '{animated_entity.idle_clip}'"
|
||||||
|
|
||||||
|
# Key handler
|
||||||
|
def on_key(key, state):
|
||||||
|
if state != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
|
if animated_entity:
|
||||||
|
if key == mcrfpy.Key.SPACE:
|
||||||
|
animated_entity.anim_paused = not animated_entity.anim_paused
|
||||||
|
status.text = f"Status: {'Paused' if animated_entity.anim_paused else 'Playing'}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.L:
|
||||||
|
animated_entity.anim_loop = not animated_entity.anim_loop
|
||||||
|
status.text = f"Status: Loop {'ON' if animated_entity.anim_loop else 'OFF'}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.EQUAL or key == mcrfpy.Key.ADD:
|
||||||
|
animated_entity.anim_speed = min(animated_entity.anim_speed + 0.25, 4.0)
|
||||||
|
status.text = f"Status: Speed {animated_entity.anim_speed:.2f}x"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.HYPHEN or key == mcrfpy.Key.SUBTRACT:
|
||||||
|
animated_entity.anim_speed = max(animated_entity.anim_speed - 0.25, 0.0)
|
||||||
|
status.text = f"Status: Speed {animated_entity.anim_speed:.2f}x"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.R:
|
||||||
|
animated_entity.anim_time = 0.0
|
||||||
|
status.text = "Status: Animation reset"
|
||||||
|
|
||||||
|
# Camera presets
|
||||||
|
if key == mcrfpy.Key.NUM_1:
|
||||||
|
pos, target, name = camera_presets[0]
|
||||||
|
viewport.camera_pos = pos
|
||||||
|
viewport.camera_target = target
|
||||||
|
status.text = f"Camera: {name}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.NUM_2:
|
||||||
|
pos, target, name = camera_presets[1]
|
||||||
|
viewport.camera_pos = pos
|
||||||
|
viewport.camera_target = target
|
||||||
|
status.text = f"Camera: {name}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.NUM_3:
|
||||||
|
pos, target, name = camera_presets[2]
|
||||||
|
viewport.camera_pos = pos
|
||||||
|
viewport.camera_target = target
|
||||||
|
status.text = f"Camera: {name}"
|
||||||
|
|
||||||
|
elif key == mcrfpy.Key.ESCAPE:
|
||||||
|
mcrfpy.exit()
|
||||||
|
|
||||||
|
# Set up scene
|
||||||
|
scene.on_key = on_key
|
||||||
|
|
||||||
|
# Create timer for updates
|
||||||
|
timer = mcrfpy.Timer("anim_update", update, 16)
|
||||||
|
|
||||||
|
# Activate scene
|
||||||
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Skeletal Animation Demo loaded!")
|
||||||
|
print("Controls:")
|
||||||
|
print(" [Space] Toggle pause")
|
||||||
|
print(" [L] Toggle loop")
|
||||||
|
print(" [+/-] Adjust speed")
|
||||||
|
print(" [R] Reset time")
|
||||||
|
print(" [1-3] Camera presets")
|
||||||
|
print(" [ESC] Quit")
|
||||||
263
tests/demo/screens/voxel_core_demo.py
Normal file
263
tests/demo/screens/voxel_core_demo.py
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"""VoxelGrid Core Demo (Milestone 9)
|
||||||
|
|
||||||
|
Demonstrates the VoxelGrid data structure without rendering.
|
||||||
|
This is a "console demo" that creates VoxelGrids, defines materials,
|
||||||
|
places voxel patterns, and displays statistics.
|
||||||
|
|
||||||
|
Note: Visual rendering comes in Milestone 10 (VoxelMeshing).
|
||||||
|
"""
|
||||||
|
import mcrfpy
|
||||||
|
from mcrfpy import Color
|
||||||
|
|
||||||
|
def format_bytes(bytes_val):
|
||||||
|
"""Format bytes as human-readable string"""
|
||||||
|
if bytes_val < 1024:
|
||||||
|
return f"{bytes_val} B"
|
||||||
|
elif bytes_val < 1024 * 1024:
|
||||||
|
return f"{bytes_val / 1024:.1f} KB"
|
||||||
|
else:
|
||||||
|
return f"{bytes_val / (1024 * 1024):.1f} MB"
|
||||||
|
|
||||||
|
def print_header(title):
|
||||||
|
"""Print a formatted header"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f" {title}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
def print_grid_stats(vg, name="VoxelGrid"):
|
||||||
|
"""Print statistics for a VoxelGrid"""
|
||||||
|
print(f"\n {name}:")
|
||||||
|
print(f" Dimensions: {vg.width} x {vg.height} x {vg.depth}")
|
||||||
|
print(f" Total voxels: {vg.width * vg.height * vg.depth:,}")
|
||||||
|
print(f" Cell size: {vg.cell_size} units")
|
||||||
|
print(f" Materials: {vg.material_count}")
|
||||||
|
print(f" Non-air voxels: {vg.count_non_air():,}")
|
||||||
|
print(f" Memory estimate: {format_bytes(vg.width * vg.height * vg.depth)}")
|
||||||
|
print(f" Offset: {vg.offset}")
|
||||||
|
print(f" Rotation: {vg.rotation} deg")
|
||||||
|
|
||||||
|
def demo_basic_creation():
|
||||||
|
"""Demonstrate basic VoxelGrid creation"""
|
||||||
|
print_header("1. Basic VoxelGrid Creation")
|
||||||
|
|
||||||
|
# Create various sizes
|
||||||
|
small = mcrfpy.VoxelGrid(size=(8, 4, 8))
|
||||||
|
medium = mcrfpy.VoxelGrid(size=(16, 8, 16), cell_size=1.0)
|
||||||
|
large = mcrfpy.VoxelGrid(size=(32, 16, 32), cell_size=0.5)
|
||||||
|
|
||||||
|
print_grid_stats(small, "Small (8x4x8)")
|
||||||
|
print_grid_stats(medium, "Medium (16x8x16)")
|
||||||
|
print_grid_stats(large, "Large (32x16x32, 0.5 cell size)")
|
||||||
|
|
||||||
|
def demo_material_palette():
|
||||||
|
"""Demonstrate material palette system"""
|
||||||
|
print_header("2. Material Palette System")
|
||||||
|
|
||||||
|
vg = mcrfpy.VoxelGrid(size=(16, 8, 16))
|
||||||
|
|
||||||
|
# Define a palette of building materials
|
||||||
|
materials = {}
|
||||||
|
materials['stone'] = vg.add_material("stone", color=Color(128, 128, 128))
|
||||||
|
materials['brick'] = vg.add_material("brick", color=Color(165, 42, 42))
|
||||||
|
materials['wood'] = vg.add_material("wood", color=Color(139, 90, 43))
|
||||||
|
materials['glass'] = vg.add_material("glass",
|
||||||
|
color=Color(200, 220, 255, 128),
|
||||||
|
transparent=True,
|
||||||
|
path_cost=1.0)
|
||||||
|
materials['metal'] = vg.add_material("metal",
|
||||||
|
color=Color(180, 180, 190),
|
||||||
|
path_cost=0.8)
|
||||||
|
materials['grass'] = vg.add_material("grass", color=Color(60, 150, 60))
|
||||||
|
|
||||||
|
print(f"\n Defined {vg.material_count} materials:")
|
||||||
|
print(f" ID 0: air (implicit, always transparent)")
|
||||||
|
|
||||||
|
for name, mat_id in materials.items():
|
||||||
|
mat = vg.get_material(mat_id)
|
||||||
|
c = mat['color']
|
||||||
|
props = []
|
||||||
|
if mat['transparent']:
|
||||||
|
props.append("transparent")
|
||||||
|
if mat['path_cost'] != 1.0:
|
||||||
|
props.append(f"cost={mat['path_cost']}")
|
||||||
|
props_str = f" ({', '.join(props)})" if props else ""
|
||||||
|
print(f" ID {mat_id}: {name} RGB({c.r},{c.g},{c.b},{c.a}){props_str}")
|
||||||
|
|
||||||
|
return vg, materials
|
||||||
|
|
||||||
|
def demo_voxel_placement():
|
||||||
|
"""Demonstrate voxel placement patterns"""
|
||||||
|
print_header("3. Voxel Placement Patterns")
|
||||||
|
|
||||||
|
vg, materials = demo_material_palette()
|
||||||
|
stone = materials['stone']
|
||||||
|
brick = materials['brick']
|
||||||
|
wood = materials['wood']
|
||||||
|
|
||||||
|
# Pattern 1: Solid cube
|
||||||
|
print("\n Pattern: Solid 4x4x4 cube at origin")
|
||||||
|
for z in range(4):
|
||||||
|
for y in range(4):
|
||||||
|
for x in range(4):
|
||||||
|
vg.set(x, y, z, stone)
|
||||||
|
print(f" Placed {vg.count_material(stone)} stone voxels")
|
||||||
|
|
||||||
|
# Pattern 2: Checkerboard floor
|
||||||
|
print("\n Pattern: Checkerboard floor at y=0, x=6-14, z=0-8")
|
||||||
|
for z in range(8):
|
||||||
|
for x in range(6, 14):
|
||||||
|
mat = stone if (x + z) % 2 == 0 else brick
|
||||||
|
vg.set(x, 0, z, mat)
|
||||||
|
print(f" Stone: {vg.count_material(stone)}, Brick: {vg.count_material(brick)}")
|
||||||
|
|
||||||
|
# Pattern 3: Hollow cube (walls only)
|
||||||
|
print("\n Pattern: Hollow cube frame 4x4x4 at x=10, z=10")
|
||||||
|
for x in range(4):
|
||||||
|
for y in range(4):
|
||||||
|
for z in range(4):
|
||||||
|
# Only place on edges
|
||||||
|
on_edge_x = (x == 0 or x == 3)
|
||||||
|
on_edge_y = (y == 0 or y == 3)
|
||||||
|
on_edge_z = (z == 0 or z == 3)
|
||||||
|
if sum([on_edge_x, on_edge_y, on_edge_z]) >= 2:
|
||||||
|
vg.set(10 + x, y, 10 + z, wood)
|
||||||
|
print(f" Wood voxels: {vg.count_material(wood)}")
|
||||||
|
|
||||||
|
print_grid_stats(vg, "After patterns")
|
||||||
|
|
||||||
|
# Material breakdown
|
||||||
|
print("\n Material breakdown:")
|
||||||
|
print(f" Air: {vg.count_material(0):,} ({100 * vg.count_material(0) / (16*8*16):.1f}%)")
|
||||||
|
print(f" Stone: {vg.count_material(stone):,}")
|
||||||
|
print(f" Brick: {vg.count_material(brick):,}")
|
||||||
|
print(f" Wood: {vg.count_material(wood):,}")
|
||||||
|
|
||||||
|
def demo_bulk_operations():
|
||||||
|
"""Demonstrate bulk fill and clear operations"""
|
||||||
|
print_header("4. Bulk Operations")
|
||||||
|
|
||||||
|
vg = mcrfpy.VoxelGrid(size=(32, 8, 32))
|
||||||
|
total = 32 * 8 * 32
|
||||||
|
|
||||||
|
stone = vg.add_material("stone", color=Color(128, 128, 128))
|
||||||
|
|
||||||
|
print(f"\n Grid: 32x8x32 = {total:,} voxels")
|
||||||
|
|
||||||
|
# Fill
|
||||||
|
vg.fill(stone)
|
||||||
|
print(f" After fill(stone): {vg.count_non_air():,} non-air")
|
||||||
|
|
||||||
|
# Clear
|
||||||
|
vg.clear()
|
||||||
|
print(f" After clear(): {vg.count_non_air():,} non-air")
|
||||||
|
|
||||||
|
def demo_transforms():
|
||||||
|
"""Demonstrate transform properties"""
|
||||||
|
print_header("5. Transform Properties")
|
||||||
|
|
||||||
|
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
|
||||||
|
|
||||||
|
print(f"\n Default state:")
|
||||||
|
print(f" Offset: {vg.offset}")
|
||||||
|
print(f" Rotation: {vg.rotation} deg")
|
||||||
|
|
||||||
|
# Position for a building
|
||||||
|
vg.offset = (100.0, 0.0, 50.0)
|
||||||
|
vg.rotation = 45.0
|
||||||
|
|
||||||
|
print(f"\n After positioning:")
|
||||||
|
print(f" Offset: {vg.offset}")
|
||||||
|
print(f" Rotation: {vg.rotation} deg")
|
||||||
|
|
||||||
|
# Multiple buildings with different transforms
|
||||||
|
print("\n Example: Village layout with 3 buildings")
|
||||||
|
buildings = []
|
||||||
|
positions = [(0, 0, 0), (20, 0, 0), (10, 0, 15)]
|
||||||
|
rotations = [0, 90, 45]
|
||||||
|
|
||||||
|
for i, (pos, rot) in enumerate(zip(positions, rotations)):
|
||||||
|
b = mcrfpy.VoxelGrid(size=(8, 6, 8))
|
||||||
|
b.offset = pos
|
||||||
|
b.rotation = rot
|
||||||
|
buildings.append(b)
|
||||||
|
print(f" Building {i+1}: offset={pos}, rotation={rot} deg")
|
||||||
|
|
||||||
|
def demo_edge_cases():
|
||||||
|
"""Test edge cases and limits"""
|
||||||
|
print_header("6. Edge Cases and Limits")
|
||||||
|
|
||||||
|
# Maximum practical size
|
||||||
|
print("\n Testing large grid (64x64x64)...")
|
||||||
|
large = mcrfpy.VoxelGrid(size=(64, 64, 64))
|
||||||
|
mat = large.add_material("test", color=Color(128, 128, 128))
|
||||||
|
large.fill(mat)
|
||||||
|
print(f" Created and filled: {large.count_non_air():,} voxels")
|
||||||
|
large.clear()
|
||||||
|
print(f" Cleared: {large.count_non_air()} voxels")
|
||||||
|
|
||||||
|
# Bounds checking
|
||||||
|
print("\n Bounds checking (should not crash):")
|
||||||
|
small = mcrfpy.VoxelGrid(size=(4, 4, 4))
|
||||||
|
test_mat = small.add_material("test", color=Color(255, 0, 0))
|
||||||
|
small.set(-1, 0, 0, test_mat)
|
||||||
|
small.set(100, 0, 0, test_mat)
|
||||||
|
print(f" Out-of-bounds get(-1,0,0): {small.get(-1, 0, 0)} (expected 0)")
|
||||||
|
print(f" Out-of-bounds get(100,0,0): {small.get(100, 0, 0)} (expected 0)")
|
||||||
|
|
||||||
|
# Material palette capacity
|
||||||
|
print("\n Material palette capacity test:")
|
||||||
|
full_vg = mcrfpy.VoxelGrid(size=(4, 4, 4))
|
||||||
|
for i in range(255):
|
||||||
|
full_vg.add_material(f"mat_{i}", color=Color(i, i, i))
|
||||||
|
print(f" Added 255 materials: count = {full_vg.material_count}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
full_vg.add_material("overflow", color=Color(255, 255, 255))
|
||||||
|
print(" ERROR: Should have raised exception!")
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f" 256th material correctly rejected: {e}")
|
||||||
|
|
||||||
|
def demo_memory_usage():
|
||||||
|
"""Show memory usage for various grid sizes"""
|
||||||
|
print_header("7. Memory Usage Estimates")
|
||||||
|
|
||||||
|
sizes = [
|
||||||
|
(8, 8, 8),
|
||||||
|
(16, 8, 16),
|
||||||
|
(32, 16, 32),
|
||||||
|
(64, 32, 64),
|
||||||
|
(80, 16, 45), # Example dungeon size
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n Size Voxels Memory")
|
||||||
|
print(" " + "-" * 40)
|
||||||
|
|
||||||
|
for w, h, d in sizes:
|
||||||
|
voxels = w * h * d
|
||||||
|
memory = voxels # 1 byte per voxel
|
||||||
|
print(f" {w:3}x{h:3}x{d:3} {voxels:>10,} {format_bytes(memory):>10}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run all demos"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" VOXELGRID CORE DEMO (Milestone 9)")
|
||||||
|
print(" Dense 3D Voxel Array with Material Palette")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
demo_basic_creation()
|
||||||
|
demo_material_palette()
|
||||||
|
demo_voxel_placement()
|
||||||
|
demo_bulk_operations()
|
||||||
|
demo_transforms()
|
||||||
|
demo_edge_cases()
|
||||||
|
demo_memory_usage()
|
||||||
|
|
||||||
|
print_header("Demo Complete!")
|
||||||
|
print("\n Next milestone (10): Voxel Mesh Generation")
|
||||||
|
print(" The VoxelGrid data will be converted to renderable 3D meshes.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
main()
|
||||||
|
sys.exit(0)
|
||||||
273
tests/demo/screens/voxel_dungeon_demo.py
Normal file
273
tests/demo/screens/voxel_dungeon_demo.py
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
# voxel_dungeon_demo.py - Procedural dungeon demonstrating bulk voxel operations
|
||||||
|
# Milestone 11: Bulk Operations and Building Primitives
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
|
||||||
|
# Create demo scene
|
||||||
|
scene = mcrfpy.Scene("voxel_dungeon_demo")
|
||||||
|
|
||||||
|
# Dark background
|
||||||
|
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(20, 20, 30))
|
||||||
|
scene.children.append(bg)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title = mcrfpy.Caption(text="Voxel Dungeon Demo - Bulk Operations (Milestone 11)", 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=(620, 520),
|
||||||
|
render_resolution=(400, 320),
|
||||||
|
fov=60.0,
|
||||||
|
camera_pos=(40.0, 30.0, 40.0),
|
||||||
|
camera_target=(16.0, 4.0, 16.0),
|
||||||
|
bg_color=mcrfpy.Color(30, 30, 40) # Dark atmosphere
|
||||||
|
)
|
||||||
|
scene.children.append(viewport)
|
||||||
|
|
||||||
|
# Global voxel grid reference
|
||||||
|
voxels = None
|
||||||
|
seed = 42
|
||||||
|
|
||||||
|
def generate_dungeon(dungeon_seed=42):
|
||||||
|
"""Generate a procedural dungeon showcasing all bulk operations"""
|
||||||
|
global voxels, seed
|
||||||
|
seed = dungeon_seed
|
||||||
|
random.seed(seed)
|
||||||
|
|
||||||
|
# Create voxel grid for dungeon
|
||||||
|
print(f"Generating dungeon (seed={seed})...")
|
||||||
|
voxels = mcrfpy.VoxelGrid(size=(32, 12, 32), cell_size=1.0)
|
||||||
|
|
||||||
|
# Define materials
|
||||||
|
STONE_WALL = voxels.add_material("stone_wall", color=mcrfpy.Color(80, 80, 90))
|
||||||
|
STONE_FLOOR = voxels.add_material("stone_floor", color=mcrfpy.Color(100, 95, 90))
|
||||||
|
MOSS = voxels.add_material("moss", color=mcrfpy.Color(40, 80, 40))
|
||||||
|
WATER = voxels.add_material("water", color=mcrfpy.Color(40, 80, 160, 180), transparent=True)
|
||||||
|
PILLAR = voxels.add_material("pillar", color=mcrfpy.Color(120, 110, 100))
|
||||||
|
GOLD = voxels.add_material("gold", color=mcrfpy.Color(255, 215, 0))
|
||||||
|
|
||||||
|
print(f"Defined {voxels.material_count} materials")
|
||||||
|
|
||||||
|
# 1. Main room using fill_box_hollow
|
||||||
|
print("Building main room with fill_box_hollow...")
|
||||||
|
voxels.fill_box_hollow((2, 0, 2), (29, 10, 29), STONE_WALL, thickness=1)
|
||||||
|
|
||||||
|
# 2. Floor with slight variation using fill_box
|
||||||
|
voxels.fill_box((3, 0, 3), (28, 0, 28), STONE_FLOOR)
|
||||||
|
|
||||||
|
# 3. Spherical alcoves carved into walls using fill_sphere
|
||||||
|
print("Carving alcoves with fill_sphere...")
|
||||||
|
alcove_positions = [
|
||||||
|
(2, 5, 16), # West wall
|
||||||
|
(29, 5, 16), # East wall
|
||||||
|
(16, 5, 2), # North wall
|
||||||
|
(16, 5, 29), # South wall
|
||||||
|
]
|
||||||
|
for pos in alcove_positions:
|
||||||
|
voxels.fill_sphere(pos, 3, 0) # Carve out (air)
|
||||||
|
|
||||||
|
# 4. Small decorative spheres (gold orbs in alcoves)
|
||||||
|
print("Adding gold orbs in alcoves...")
|
||||||
|
for i, pos in enumerate(alcove_positions):
|
||||||
|
# Offset inward so orb is visible
|
||||||
|
ox, oy, oz = pos
|
||||||
|
if ox < 10:
|
||||||
|
ox += 2
|
||||||
|
elif ox > 20:
|
||||||
|
ox -= 2
|
||||||
|
if oz < 10:
|
||||||
|
oz += 2
|
||||||
|
elif oz > 20:
|
||||||
|
oz -= 2
|
||||||
|
voxels.fill_sphere((ox, oy - 1, oz), 1, GOLD)
|
||||||
|
|
||||||
|
# 5. Support pillars using fill_cylinder
|
||||||
|
print("Building pillars with fill_cylinder...")
|
||||||
|
pillar_positions = [
|
||||||
|
(8, 1, 8), (8, 1, 24),
|
||||||
|
(24, 1, 8), (24, 1, 24),
|
||||||
|
(16, 1, 8), (16, 1, 24),
|
||||||
|
(8, 1, 16), (24, 1, 16),
|
||||||
|
]
|
||||||
|
for px, py, pz in pillar_positions:
|
||||||
|
voxels.fill_cylinder((px, py, pz), 1, 9, PILLAR)
|
||||||
|
|
||||||
|
# 6. Moss patches using fill_noise
|
||||||
|
print("Adding moss patches with fill_noise...")
|
||||||
|
voxels.fill_noise((3, 1, 3), (28, 1, 28), MOSS, threshold=0.65, scale=0.15, seed=seed)
|
||||||
|
|
||||||
|
# 7. Central water pool
|
||||||
|
print("Creating water pool...")
|
||||||
|
voxels.fill_box((12, 0, 12), (20, 0, 20), 0) # Carve depression
|
||||||
|
voxels.fill_box((12, 0, 12), (20, 0, 20), WATER)
|
||||||
|
|
||||||
|
# 8. Copy a pillar as prefab and paste variations
|
||||||
|
print("Creating prefab from pillar and pasting copies...")
|
||||||
|
pillar_prefab = voxels.copy_region((8, 1, 8), (9, 9, 9))
|
||||||
|
print(f" Pillar prefab: {pillar_prefab.size}")
|
||||||
|
|
||||||
|
# Paste smaller pillars at corners (offset from main room)
|
||||||
|
corner_positions = [(4, 1, 4), (4, 1, 27), (27, 1, 4), (27, 1, 27)]
|
||||||
|
for cx, cy, cz in corner_positions:
|
||||||
|
voxels.paste_region(pillar_prefab, (cx, cy, cz), skip_air=True)
|
||||||
|
|
||||||
|
# Build mesh
|
||||||
|
voxels.rebuild_mesh()
|
||||||
|
|
||||||
|
print(f"\nDungeon generated:")
|
||||||
|
print(f" Non-air voxels: {voxels.count_non_air()}")
|
||||||
|
print(f" Vertices: {voxels.vertex_count}")
|
||||||
|
print(f" Faces: {voxels.vertex_count // 6}")
|
||||||
|
|
||||||
|
# Add to viewport
|
||||||
|
# First remove old layer if exists
|
||||||
|
if viewport.voxel_layer_count() > 0:
|
||||||
|
pass # Can't easily remove, so we regenerate the whole viewport
|
||||||
|
viewport.add_voxel_layer(voxels, z_index=0)
|
||||||
|
|
||||||
|
return voxels
|
||||||
|
|
||||||
|
# Generate initial dungeon
|
||||||
|
generate_dungeon(42)
|
||||||
|
|
||||||
|
# Create info panel
|
||||||
|
info_frame = mcrfpy.Frame(pos=(690, 60), size=(300, 280), fill_color=mcrfpy.Color(40, 40, 60, 220))
|
||||||
|
scene.children.append(info_frame)
|
||||||
|
|
||||||
|
info_title = mcrfpy.Caption(text="Dungeon Stats", pos=(700, 70))
|
||||||
|
info_title.fill_color = mcrfpy.Color(255, 255, 100)
|
||||||
|
scene.children.append(info_title)
|
||||||
|
|
||||||
|
def update_stats():
|
||||||
|
global stats_caption
|
||||||
|
stats_text = f"""Grid: {voxels.width}x{voxels.height}x{voxels.depth}
|
||||||
|
Total cells: {voxels.width * voxels.height * voxels.depth}
|
||||||
|
Non-air: {voxels.count_non_air()}
|
||||||
|
Materials: {voxels.material_count}
|
||||||
|
|
||||||
|
Mesh Stats:
|
||||||
|
Vertices: {voxels.vertex_count}
|
||||||
|
Faces: {voxels.vertex_count // 6}
|
||||||
|
|
||||||
|
Seed: {seed}
|
||||||
|
|
||||||
|
Operations Used:
|
||||||
|
- fill_box_hollow (walls)
|
||||||
|
- fill_sphere (alcoves)
|
||||||
|
- fill_cylinder (pillars)
|
||||||
|
- fill_noise (moss)
|
||||||
|
- copy/paste (prefabs)"""
|
||||||
|
stats_caption.text = stats_text
|
||||||
|
|
||||||
|
stats_caption = mcrfpy.Caption(text="", pos=(700, 100))
|
||||||
|
stats_caption.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
|
scene.children.append(stats_caption)
|
||||||
|
update_stats()
|
||||||
|
|
||||||
|
# Controls panel
|
||||||
|
controls_frame = mcrfpy.Frame(pos=(690, 360), size=(300, 180), fill_color=mcrfpy.Color(40, 40, 60, 220))
|
||||||
|
scene.children.append(controls_frame)
|
||||||
|
|
||||||
|
controls_title = mcrfpy.Caption(text="Controls", pos=(700, 370))
|
||||||
|
controls_title.fill_color = mcrfpy.Color(255, 255, 100)
|
||||||
|
scene.children.append(controls_title)
|
||||||
|
|
||||||
|
controls_text = """R - Regenerate dungeon (new seed)
|
||||||
|
1-4 - Camera presets
|
||||||
|
+/- - Zoom in/out
|
||||||
|
SPACE - Reset camera
|
||||||
|
ESC - Exit demo"""
|
||||||
|
|
||||||
|
controls = mcrfpy.Caption(text=controls_text, pos=(700, 400))
|
||||||
|
controls.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
|
scene.children.append(controls)
|
||||||
|
|
||||||
|
# Camera animation state
|
||||||
|
rotation_enabled = False
|
||||||
|
camera_distance = 50.0
|
||||||
|
camera_angle = 45.0 # degrees
|
||||||
|
camera_height = 30.0
|
||||||
|
|
||||||
|
camera_presets = [
|
||||||
|
(40.0, 30.0, 40.0, 16.0, 4.0, 16.0), # Default diagonal
|
||||||
|
(16.0, 30.0, 50.0, 16.0, 4.0, 16.0), # Front view
|
||||||
|
(50.0, 30.0, 16.0, 16.0, 4.0, 16.0), # Side view
|
||||||
|
(16.0, 50.0, 16.0, 16.0, 4.0, 16.0), # Top-down
|
||||||
|
]
|
||||||
|
|
||||||
|
def rotate_camera(timer_name, runtime):
|
||||||
|
"""Timer callback for camera rotation"""
|
||||||
|
global camera_angle, rotation_enabled
|
||||||
|
if rotation_enabled:
|
||||||
|
camera_angle += 0.5
|
||||||
|
if camera_angle >= 360.0:
|
||||||
|
camera_angle = 0.0
|
||||||
|
rad = camera_angle * math.pi / 180.0
|
||||||
|
x = 16.0 + camera_distance * math.cos(rad)
|
||||||
|
z = 16.0 + camera_distance * math.sin(rad)
|
||||||
|
viewport.camera_pos = (x, camera_height, z)
|
||||||
|
|
||||||
|
# Set up rotation timer
|
||||||
|
timer = mcrfpy.Timer("rotate_cam", rotate_camera, 33)
|
||||||
|
|
||||||
|
def handle_key(key, action):
|
||||||
|
"""Keyboard handler"""
|
||||||
|
global rotation_enabled, seed, camera_distance, camera_height
|
||||||
|
if action != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == mcrfpy.Key.R:
|
||||||
|
seed = random.randint(1, 99999)
|
||||||
|
generate_dungeon(seed)
|
||||||
|
update_stats()
|
||||||
|
print(f"Regenerated dungeon with seed {seed}")
|
||||||
|
elif key == mcrfpy.Key.NUM_1:
|
||||||
|
viewport.camera_pos = camera_presets[0][:3]
|
||||||
|
viewport.camera_target = camera_presets[0][3:]
|
||||||
|
rotation_enabled = False
|
||||||
|
elif key == mcrfpy.Key.NUM_2:
|
||||||
|
viewport.camera_pos = camera_presets[1][:3]
|
||||||
|
viewport.camera_target = camera_presets[1][3:]
|
||||||
|
rotation_enabled = False
|
||||||
|
elif key == mcrfpy.Key.NUM_3:
|
||||||
|
viewport.camera_pos = camera_presets[2][:3]
|
||||||
|
viewport.camera_target = camera_presets[2][3:]
|
||||||
|
rotation_enabled = False
|
||||||
|
elif key == mcrfpy.Key.NUM_4:
|
||||||
|
viewport.camera_pos = camera_presets[3][:3]
|
||||||
|
viewport.camera_target = camera_presets[3][3:]
|
||||||
|
rotation_enabled = False
|
||||||
|
elif key == mcrfpy.Key.SPACE:
|
||||||
|
rotation_enabled = not rotation_enabled
|
||||||
|
print(f"Camera rotation: {'ON' if rotation_enabled else 'OFF'}")
|
||||||
|
elif key == mcrfpy.Key.EQUALS or key == mcrfpy.Key.ADD:
|
||||||
|
camera_distance = max(20.0, camera_distance - 5.0)
|
||||||
|
camera_height = max(15.0, camera_height - 2.0)
|
||||||
|
elif key == mcrfpy.Key.DASH or key == mcrfpy.Key.SUBTRACT:
|
||||||
|
camera_distance = min(80.0, camera_distance + 5.0)
|
||||||
|
camera_height = min(50.0, camera_height + 2.0)
|
||||||
|
elif key == mcrfpy.Key.ESCAPE:
|
||||||
|
print("Exiting demo...")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
scene.on_key = handle_key
|
||||||
|
|
||||||
|
# Activate the scene
|
||||||
|
mcrfpy.current_scene = scene
|
||||||
|
print("\nVoxel Dungeon Demo ready!")
|
||||||
|
print("Press SPACE to toggle camera rotation, R to regenerate")
|
||||||
|
|
||||||
|
# Main entry point for --exec mode
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("\n=== Voxel Dungeon 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")
|
||||||
218
tests/demo/screens/voxel_meshing_demo.py
Normal file
218
tests/demo/screens/voxel_meshing_demo.py
Normal 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")
|
||||||
250
tests/demo/screens/voxel_navigation_demo.py
Normal file
250
tests/demo/screens/voxel_navigation_demo.py
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Visual Demo: Milestone 12 - VoxelGrid Navigation Projection
|
||||||
|
|
||||||
|
Demonstrates projection of 3D voxel terrain to 2D navigation grid for pathfinding.
|
||||||
|
Shows:
|
||||||
|
1. Voxel dungeon with multiple levels
|
||||||
|
2. Navigation grid projection (walkable/unwalkable areas)
|
||||||
|
3. A* pathfinding through the projected terrain
|
||||||
|
4. FOV computation from voxel transparency
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
|
||||||
|
def create_demo_scene():
|
||||||
|
"""Create the navigation projection demo scene"""
|
||||||
|
|
||||||
|
scene = mcrfpy.Scene("voxel_nav_demo")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Create a small dungeon-style voxel grid
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
vg = mcrfpy.VoxelGrid((16, 8, 16), cell_size=1.0)
|
||||||
|
|
||||||
|
# Add materials
|
||||||
|
floor_mat = vg.add_material("floor", (100, 80, 60)) # Brown floor
|
||||||
|
wall_mat = vg.add_material("wall", (80, 80, 90), transparent=False) # Gray walls
|
||||||
|
pillar_mat = vg.add_material("pillar", (60, 60, 70), transparent=False) # Dark pillars
|
||||||
|
glass_mat = vg.add_material("glass", (150, 200, 255), transparent=True) # Transparent glass
|
||||||
|
water_mat = vg.add_material("water", (50, 100, 200), transparent=True, path_cost=3.0) # Slow water
|
||||||
|
|
||||||
|
# Create floor
|
||||||
|
vg.fill_box((0, 0, 0), (15, 0, 15), floor_mat)
|
||||||
|
|
||||||
|
# Create outer walls
|
||||||
|
vg.fill_box((0, 1, 0), (15, 4, 0), wall_mat) # North wall
|
||||||
|
vg.fill_box((0, 1, 15), (15, 4, 15), wall_mat) # South wall
|
||||||
|
vg.fill_box((0, 1, 0), (0, 4, 15), wall_mat) # West wall
|
||||||
|
vg.fill_box((15, 1, 0), (15, 4, 15), wall_mat) # East wall
|
||||||
|
|
||||||
|
# Interior walls creating rooms
|
||||||
|
vg.fill_box((5, 1, 0), (5, 4, 10), wall_mat) # Vertical wall
|
||||||
|
vg.fill_box((10, 1, 5), (15, 4, 5), wall_mat) # Horizontal wall
|
||||||
|
|
||||||
|
# Doorways (carve holes)
|
||||||
|
vg.fill_box((5, 1, 3), (5, 2, 4), 0) # Door in vertical wall
|
||||||
|
vg.fill_box((12, 1, 5), (13, 2, 5), 0) # Door in horizontal wall
|
||||||
|
|
||||||
|
# Central pillars
|
||||||
|
vg.fill_box((8, 1, 8), (8, 4, 8), pillar_mat)
|
||||||
|
vg.fill_box((8, 1, 12), (8, 4, 12), pillar_mat)
|
||||||
|
|
||||||
|
# Water pool in one corner (slow movement)
|
||||||
|
vg.fill_box((1, 0, 11), (3, 0, 14), water_mat)
|
||||||
|
|
||||||
|
# Glass window
|
||||||
|
vg.fill_box((10, 2, 5), (11, 3, 5), glass_mat)
|
||||||
|
|
||||||
|
# Raised platform in one area (height variation)
|
||||||
|
vg.fill_box((12, 1, 8), (14, 1, 13), floor_mat) # Platform at y=1
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Create Viewport3D with navigation grid
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
viewport = mcrfpy.Viewport3D(pos=(10, 10), size=(600, 400))
|
||||||
|
viewport.set_grid_size(16, 16)
|
||||||
|
viewport.cell_size = 1.0
|
||||||
|
|
||||||
|
# Configure camera for top-down view
|
||||||
|
viewport.camera_pos = (8, 15, 20)
|
||||||
|
viewport.camera_target = (8, 0, 8)
|
||||||
|
|
||||||
|
# Add voxel layer
|
||||||
|
viewport.add_voxel_layer(vg, z_index=0)
|
||||||
|
|
||||||
|
# Project voxels to navigation grid with headroom=2 (entity needs 2 voxels height)
|
||||||
|
viewport.project_voxel_to_nav(vg, headroom=2)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Info panel
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
info_frame = mcrfpy.Frame(pos=(620, 10), size=(250, 400))
|
||||||
|
info_frame.fill_color = mcrfpy.Color(30, 30, 40, 220)
|
||||||
|
info_frame.outline_color = mcrfpy.Color(100, 100, 120)
|
||||||
|
info_frame.outline = 2.0
|
||||||
|
|
||||||
|
title = mcrfpy.Caption(text="Nav Projection Demo", pos=(10, 10))
|
||||||
|
title.fill_color = mcrfpy.Color(255, 255, 100)
|
||||||
|
|
||||||
|
desc = mcrfpy.Caption(text="Voxels projected to\n2D nav grid", pos=(10, 35))
|
||||||
|
desc.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
|
|
||||||
|
info1 = mcrfpy.Caption(text="Grid: 16x16 cells", pos=(10, 75))
|
||||||
|
info1.fill_color = mcrfpy.Color(150, 200, 255)
|
||||||
|
|
||||||
|
info2 = mcrfpy.Caption(text="Headroom: 2 voxels", pos=(10, 95))
|
||||||
|
info2.fill_color = mcrfpy.Color(150, 200, 255)
|
||||||
|
|
||||||
|
# Count walkable cells
|
||||||
|
walkable_count = 0
|
||||||
|
for x in range(16):
|
||||||
|
for z in range(16):
|
||||||
|
cell = viewport.at(x, z)
|
||||||
|
if cell.walkable:
|
||||||
|
walkable_count += 1
|
||||||
|
|
||||||
|
info3 = mcrfpy.Caption(text=f"Walkable: {walkable_count}/256", pos=(10, 115))
|
||||||
|
info3.fill_color = mcrfpy.Color(100, 255, 100)
|
||||||
|
|
||||||
|
# Find path example
|
||||||
|
path = viewport.find_path((1, 1), (13, 13))
|
||||||
|
info4 = mcrfpy.Caption(text=f"Path length: {len(path)}", pos=(10, 135))
|
||||||
|
info4.fill_color = mcrfpy.Color(255, 200, 100)
|
||||||
|
|
||||||
|
# FOV example
|
||||||
|
fov = viewport.compute_fov((8, 8), 10)
|
||||||
|
info5 = mcrfpy.Caption(text=f"FOV cells: {len(fov)}", pos=(10, 155))
|
||||||
|
info5.fill_color = mcrfpy.Color(200, 150, 255)
|
||||||
|
|
||||||
|
# Legend
|
||||||
|
legend_title = mcrfpy.Caption(text="Materials:", pos=(10, 185))
|
||||||
|
legend_title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
|
|
||||||
|
leg1 = mcrfpy.Caption(text=" Floor (walkable)", pos=(10, 205))
|
||||||
|
leg1.fill_color = mcrfpy.Color(100, 80, 60)
|
||||||
|
|
||||||
|
leg2 = mcrfpy.Caption(text=" Wall (blocking)", pos=(10, 225))
|
||||||
|
leg2.fill_color = mcrfpy.Color(80, 80, 90)
|
||||||
|
|
||||||
|
leg3 = mcrfpy.Caption(text=" Water (slow)", pos=(10, 245))
|
||||||
|
leg3.fill_color = mcrfpy.Color(50, 100, 200)
|
||||||
|
|
||||||
|
leg4 = mcrfpy.Caption(text=" Glass (see-through)", pos=(10, 265))
|
||||||
|
leg4.fill_color = mcrfpy.Color(150, 200, 255)
|
||||||
|
|
||||||
|
controls = mcrfpy.Caption(text="[Space] Recompute FOV\n[P] Show path\n[Q] Quit", pos=(10, 300))
|
||||||
|
controls.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
|
|
||||||
|
info_frame.children.extend([
|
||||||
|
title, desc, info1, info2, info3, info4, info5,
|
||||||
|
legend_title, leg1, leg2, leg3, leg4, controls
|
||||||
|
])
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Status bar
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
status_frame = mcrfpy.Frame(pos=(10, 420), size=(860, 50))
|
||||||
|
status_frame.fill_color = mcrfpy.Color(20, 20, 30, 220)
|
||||||
|
status_frame.outline_color = mcrfpy.Color(80, 80, 100)
|
||||||
|
status_frame.outline = 1.0
|
||||||
|
|
||||||
|
status_text = mcrfpy.Caption(
|
||||||
|
text="Milestone 12: VoxelGrid Navigation Projection - Project 3D voxels to 2D pathfinding grid",
|
||||||
|
pos=(10, 15)
|
||||||
|
)
|
||||||
|
status_text.fill_color = mcrfpy.Color(180, 180, 200)
|
||||||
|
status_frame.children.append(status_text)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Add elements to scene
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
scene.children.extend([viewport, info_frame, status_frame])
|
||||||
|
|
||||||
|
# Store references for interaction (using module-level globals)
|
||||||
|
global demo_viewport, demo_voxelgrid, demo_path, demo_fov_origin
|
||||||
|
demo_viewport = viewport
|
||||||
|
demo_voxelgrid = vg
|
||||||
|
demo_path = path
|
||||||
|
demo_fov_origin = (8, 8)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Keyboard handler
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def on_key(key, state):
|
||||||
|
global demo_fov_origin
|
||||||
|
if state != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == mcrfpy.Key.Q or key == mcrfpy.Key.ESCAPE:
|
||||||
|
# Exit
|
||||||
|
sys.exit(0)
|
||||||
|
elif key == mcrfpy.Key.SPACE:
|
||||||
|
# Recompute FOV from different origin
|
||||||
|
ox, oz = demo_fov_origin
|
||||||
|
ox = (ox + 3) % 14 + 1
|
||||||
|
oz = (oz + 5) % 14 + 1
|
||||||
|
demo_fov_origin = (ox, oz)
|
||||||
|
fov = demo_viewport.compute_fov((ox, oz), 8)
|
||||||
|
info5.text = f"FOV from ({ox},{oz}): {len(fov)}"
|
||||||
|
elif key == mcrfpy.Key.P:
|
||||||
|
# Show path info
|
||||||
|
print(f"Path from (1,1) to (13,13): {len(demo_path)} steps")
|
||||||
|
for i, (px, pz) in enumerate(demo_path[:10]):
|
||||||
|
cell = demo_viewport.at(px, pz)
|
||||||
|
print(f" Step {i}: ({px},{pz}) h={cell.height:.1f} cost={cell.cost:.1f}")
|
||||||
|
if len(demo_path) > 10:
|
||||||
|
print(f" ... and {len(demo_path) - 10} more steps")
|
||||||
|
|
||||||
|
scene.on_key = on_key
|
||||||
|
|
||||||
|
return scene
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
print("=== Milestone 12: VoxelGrid Navigation Projection Demo ===")
|
||||||
|
print()
|
||||||
|
print("This demo shows how 3D voxel terrain is projected to a 2D")
|
||||||
|
print("navigation grid for pathfinding and FOV calculations.")
|
||||||
|
print()
|
||||||
|
print("The projection scans each column from top to bottom, finding")
|
||||||
|
print("the topmost walkable floor with adequate headroom.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
scene = create_demo_scene()
|
||||||
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
|
# Print nav grid summary
|
||||||
|
grid_w, grid_d = demo_viewport.grid_size
|
||||||
|
print("Navigation grid summary:")
|
||||||
|
print(f" Grid size: {grid_w}x{grid_d}")
|
||||||
|
|
||||||
|
# Count by walkability and transparency
|
||||||
|
walkable = 0
|
||||||
|
blocking = 0
|
||||||
|
transparent = 0
|
||||||
|
for x in range(grid_w):
|
||||||
|
for z in range(grid_d):
|
||||||
|
cell = demo_viewport.at(x, z)
|
||||||
|
if cell.walkable:
|
||||||
|
walkable += 1
|
||||||
|
else:
|
||||||
|
blocking += 1
|
||||||
|
if cell.transparent:
|
||||||
|
transparent += 1
|
||||||
|
|
||||||
|
print(f" Walkable cells: {walkable}")
|
||||||
|
print(f" Blocking cells: {blocking}")
|
||||||
|
print(f" Transparent cells: {transparent}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
sys.exit(0)
|
||||||
314
tests/demo/screens/voxel_serialization_demo.py
Normal file
314
tests/demo/screens/voxel_serialization_demo.py
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
"""Voxel Serialization Demo - Milestone 14
|
||||||
|
|
||||||
|
Demonstrates save/load functionality for VoxelGrid, including:
|
||||||
|
- Saving to file with .mcvg format
|
||||||
|
- Loading from file
|
||||||
|
- Serialization to bytes (for network/custom storage)
|
||||||
|
- RLE compression effectiveness
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
def create_demo_scene():
|
||||||
|
"""Create a scene demonstrating voxel serialization."""
|
||||||
|
scene = mcrfpy.Scene("voxel_serialization_demo")
|
||||||
|
ui = scene.children
|
||||||
|
|
||||||
|
# Dark background
|
||||||
|
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(20, 20, 30))
|
||||||
|
ui.append(bg)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title = mcrfpy.Caption(text="Milestone 14: VoxelGrid Serialization",
|
||||||
|
pos=(30, 20))
|
||||||
|
title.font_size = 28
|
||||||
|
title.fill_color = (255, 220, 100)
|
||||||
|
ui.append(title)
|
||||||
|
|
||||||
|
# Create demo VoxelGrid with interesting structure
|
||||||
|
grid = mcrfpy.VoxelGrid((16, 16, 16), cell_size=1.0)
|
||||||
|
|
||||||
|
# Add materials
|
||||||
|
stone = grid.add_material("stone", (100, 100, 110))
|
||||||
|
wood = grid.add_material("wood", (139, 90, 43))
|
||||||
|
glass = grid.add_material("glass", (180, 200, 220, 100), transparent=True)
|
||||||
|
gold = grid.add_material("gold", (255, 215, 0))
|
||||||
|
|
||||||
|
# Build a small structure
|
||||||
|
grid.fill_box((0, 0, 0), (15, 0, 15), stone) # Floor
|
||||||
|
grid.fill_box((0, 1, 0), (0, 4, 15), stone) # Wall 1
|
||||||
|
grid.fill_box((15, 1, 0), (15, 4, 15), stone) # Wall 2
|
||||||
|
grid.fill_box((0, 1, 0), (15, 4, 0), stone) # Wall 3
|
||||||
|
grid.fill_box((0, 1, 15), (15, 4, 15), stone) # Wall 4
|
||||||
|
|
||||||
|
# Windows (clear some wall, add glass)
|
||||||
|
grid.fill_box((6, 2, 0), (10, 3, 0), 0) # Clear for window
|
||||||
|
grid.fill_box((6, 2, 0), (10, 3, 0), glass) # Add glass
|
||||||
|
|
||||||
|
# Pillars
|
||||||
|
grid.fill_box((4, 1, 4), (4, 3, 4), wood)
|
||||||
|
grid.fill_box((12, 1, 4), (12, 3, 4), wood)
|
||||||
|
grid.fill_box((4, 1, 12), (4, 3, 12), wood)
|
||||||
|
grid.fill_box((12, 1, 12), (12, 3, 12), wood)
|
||||||
|
|
||||||
|
# Gold decorations
|
||||||
|
grid.set(8, 1, 8, gold)
|
||||||
|
grid.set(7, 1, 8, gold)
|
||||||
|
grid.set(9, 1, 8, gold)
|
||||||
|
grid.set(8, 1, 7, gold)
|
||||||
|
grid.set(8, 1, 9, gold)
|
||||||
|
|
||||||
|
# Get original stats
|
||||||
|
original_voxels = grid.count_non_air()
|
||||||
|
original_materials = grid.material_count
|
||||||
|
|
||||||
|
# === Test save/load to file ===
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.mcvg', delete=False) as f:
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
save_success = grid.save(temp_path)
|
||||||
|
file_size = os.path.getsize(temp_path) if save_success else 0
|
||||||
|
|
||||||
|
# Load into new grid
|
||||||
|
loaded_grid = mcrfpy.VoxelGrid((1, 1, 1))
|
||||||
|
load_success = loaded_grid.load(temp_path)
|
||||||
|
os.unlink(temp_path) # Clean up
|
||||||
|
|
||||||
|
loaded_voxels = loaded_grid.count_non_air() if load_success else 0
|
||||||
|
loaded_materials = loaded_grid.material_count if load_success else 0
|
||||||
|
|
||||||
|
# === Test to_bytes/from_bytes ===
|
||||||
|
data_bytes = grid.to_bytes()
|
||||||
|
bytes_size = len(data_bytes)
|
||||||
|
|
||||||
|
bytes_grid = mcrfpy.VoxelGrid((1, 1, 1))
|
||||||
|
bytes_success = bytes_grid.from_bytes(data_bytes)
|
||||||
|
bytes_voxels = bytes_grid.count_non_air() if bytes_success else 0
|
||||||
|
|
||||||
|
# === Calculate compression ===
|
||||||
|
raw_size = 16 * 16 * 16 # Uncompressed voxel data
|
||||||
|
compression_ratio = raw_size / bytes_size if bytes_size > 0 else 0
|
||||||
|
|
||||||
|
# Display information
|
||||||
|
y_pos = 80
|
||||||
|
|
||||||
|
# Original Grid Info
|
||||||
|
info1 = mcrfpy.Caption(text="Original VoxelGrid:",
|
||||||
|
pos=(30, y_pos))
|
||||||
|
info1.font_size = 20
|
||||||
|
info1.fill_color = (100, 200, 255)
|
||||||
|
ui.append(info1)
|
||||||
|
y_pos += 30
|
||||||
|
|
||||||
|
for line in [
|
||||||
|
f" Dimensions: 16x16x16 = 4096 voxels",
|
||||||
|
f" Non-air voxels: {original_voxels}",
|
||||||
|
f" Materials defined: {original_materials}",
|
||||||
|
f" Structure: Walled room with pillars, windows, gold decor"
|
||||||
|
]:
|
||||||
|
cap = mcrfpy.Caption(text=line, pos=(30, y_pos))
|
||||||
|
cap.font_size = 16
|
||||||
|
cap.fill_color = (200, 200, 210)
|
||||||
|
ui.append(cap)
|
||||||
|
y_pos += 22
|
||||||
|
|
||||||
|
y_pos += 20
|
||||||
|
|
||||||
|
# File Save/Load Results
|
||||||
|
info2 = mcrfpy.Caption(text="File Serialization (.mcvg):",
|
||||||
|
pos=(30, y_pos))
|
||||||
|
info2.font_size = 20
|
||||||
|
info2.fill_color = (100, 255, 150)
|
||||||
|
ui.append(info2)
|
||||||
|
y_pos += 30
|
||||||
|
|
||||||
|
save_status = "SUCCESS" if save_success else "FAILED"
|
||||||
|
load_status = "SUCCESS" if load_success else "FAILED"
|
||||||
|
match_status = "MATCH" if loaded_voxels == original_voxels else "MISMATCH"
|
||||||
|
|
||||||
|
for line in [
|
||||||
|
f" Save to file: {save_status}",
|
||||||
|
f" File size: {file_size} bytes",
|
||||||
|
f" Load from file: {load_status}",
|
||||||
|
f" Loaded voxels: {loaded_voxels} ({match_status})",
|
||||||
|
f" Loaded materials: {loaded_materials}"
|
||||||
|
]:
|
||||||
|
color = (150, 255, 150) if "SUCCESS" in line or "MATCH" in line else (200, 200, 210)
|
||||||
|
if "FAILED" in line or "MISMATCH" in line:
|
||||||
|
color = (255, 100, 100)
|
||||||
|
cap = mcrfpy.Caption(text=line, pos=(30, y_pos))
|
||||||
|
cap.font_size = 16
|
||||||
|
cap.fill_color = color
|
||||||
|
ui.append(cap)
|
||||||
|
y_pos += 22
|
||||||
|
|
||||||
|
y_pos += 20
|
||||||
|
|
||||||
|
# Bytes Serialization Results
|
||||||
|
info3 = mcrfpy.Caption(text="Memory Serialization (to_bytes/from_bytes):",
|
||||||
|
pos=(30, y_pos))
|
||||||
|
info3.font_size = 20
|
||||||
|
info3.fill_color = (255, 200, 100)
|
||||||
|
ui.append(info3)
|
||||||
|
y_pos += 30
|
||||||
|
|
||||||
|
bytes_status = "SUCCESS" if bytes_success else "FAILED"
|
||||||
|
bytes_match = "MATCH" if bytes_voxels == original_voxels else "MISMATCH"
|
||||||
|
|
||||||
|
for line in [
|
||||||
|
f" Serialized size: {bytes_size} bytes",
|
||||||
|
f" Raw voxel data: {raw_size} bytes",
|
||||||
|
f" Compression ratio: {compression_ratio:.1f}x",
|
||||||
|
f" from_bytes(): {bytes_status}",
|
||||||
|
f" Restored voxels: {bytes_voxels} ({bytes_match})"
|
||||||
|
]:
|
||||||
|
color = (200, 200, 210)
|
||||||
|
if "SUCCESS" in line or "MATCH" in line:
|
||||||
|
color = (150, 255, 150)
|
||||||
|
cap = mcrfpy.Caption(text=line, pos=(30, y_pos))
|
||||||
|
cap.font_size = 16
|
||||||
|
cap.fill_color = color
|
||||||
|
ui.append(cap)
|
||||||
|
y_pos += 22
|
||||||
|
|
||||||
|
y_pos += 20
|
||||||
|
|
||||||
|
# RLE Compression Demo
|
||||||
|
info4 = mcrfpy.Caption(text="RLE Compression Effectiveness:",
|
||||||
|
pos=(30, y_pos))
|
||||||
|
info4.font_size = 20
|
||||||
|
info4.fill_color = (200, 150, 255)
|
||||||
|
ui.append(info4)
|
||||||
|
y_pos += 30
|
||||||
|
|
||||||
|
# Create uniform grid for compression test
|
||||||
|
uniform_grid = mcrfpy.VoxelGrid((32, 32, 32))
|
||||||
|
uniform_mat = uniform_grid.add_material("solid", (128, 128, 128))
|
||||||
|
uniform_grid.fill(uniform_mat)
|
||||||
|
uniform_bytes = uniform_grid.to_bytes()
|
||||||
|
uniform_raw = 32 * 32 * 32
|
||||||
|
uniform_ratio = uniform_raw / len(uniform_bytes)
|
||||||
|
|
||||||
|
for line in [
|
||||||
|
f" Uniform 32x32x32 filled grid:",
|
||||||
|
f" Raw: {uniform_raw} bytes",
|
||||||
|
f" Compressed: {len(uniform_bytes)} bytes",
|
||||||
|
f" Compression: {uniform_ratio:.0f}x",
|
||||||
|
f" ",
|
||||||
|
f" RLE excels at runs of identical values."
|
||||||
|
]:
|
||||||
|
cap = mcrfpy.Caption(text=line, pos=(30, y_pos))
|
||||||
|
cap.font_size = 16
|
||||||
|
cap.fill_color = (200, 180, 220)
|
||||||
|
ui.append(cap)
|
||||||
|
y_pos += 22
|
||||||
|
|
||||||
|
y_pos += 30
|
||||||
|
|
||||||
|
# File Format Info
|
||||||
|
info5 = mcrfpy.Caption(text="File Format (.mcvg):",
|
||||||
|
pos=(30, y_pos))
|
||||||
|
info5.font_size = 20
|
||||||
|
info5.fill_color = (255, 150, 200)
|
||||||
|
ui.append(info5)
|
||||||
|
y_pos += 30
|
||||||
|
|
||||||
|
for line in [
|
||||||
|
" Header: Magic 'MCVG' + version + dimensions + cell_size",
|
||||||
|
" Materials: name, color (RGBA), sprite_index, transparent, path_cost",
|
||||||
|
" Voxel data: RLE-encoded material IDs",
|
||||||
|
" ",
|
||||||
|
" Note: Transform (offset, rotation) is runtime state, not serialized"
|
||||||
|
]:
|
||||||
|
cap = mcrfpy.Caption(text=line, pos=(30, y_pos))
|
||||||
|
cap.font_size = 14
|
||||||
|
cap.fill_color = (200, 180, 200)
|
||||||
|
ui.append(cap)
|
||||||
|
y_pos += 20
|
||||||
|
|
||||||
|
# API Reference on right side
|
||||||
|
y_ref = 80
|
||||||
|
x_ref = 550
|
||||||
|
|
||||||
|
api_title = mcrfpy.Caption(text="Python API:", pos=(x_ref, y_ref))
|
||||||
|
api_title.font_size = 20
|
||||||
|
api_title.fill_color = (150, 200, 255)
|
||||||
|
ui.append(api_title)
|
||||||
|
y_ref += 35
|
||||||
|
|
||||||
|
for line in [
|
||||||
|
"# Save to file",
|
||||||
|
"success = grid.save('world.mcvg')",
|
||||||
|
"",
|
||||||
|
"# Load from file",
|
||||||
|
"grid = VoxelGrid((1,1,1))",
|
||||||
|
"success = grid.load('world.mcvg')",
|
||||||
|
"",
|
||||||
|
"# Save to bytes",
|
||||||
|
"data = grid.to_bytes()",
|
||||||
|
"",
|
||||||
|
"# Load from bytes",
|
||||||
|
"success = grid.from_bytes(data)",
|
||||||
|
"",
|
||||||
|
"# Network example:",
|
||||||
|
"# send_to_server(grid.to_bytes())",
|
||||||
|
"# data = recv_from_server()",
|
||||||
|
"# grid.from_bytes(data)"
|
||||||
|
]:
|
||||||
|
cap = mcrfpy.Caption(text=line, pos=(x_ref, y_ref))
|
||||||
|
cap.font_size = 14
|
||||||
|
if line.startswith("#"):
|
||||||
|
cap.fill_color = (100, 150, 100)
|
||||||
|
elif "=" in line or "(" in line:
|
||||||
|
cap.fill_color = (255, 220, 150)
|
||||||
|
else:
|
||||||
|
cap.fill_color = (180, 180, 180)
|
||||||
|
ui.append(cap)
|
||||||
|
y_ref += 18
|
||||||
|
|
||||||
|
return scene
|
||||||
|
|
||||||
|
|
||||||
|
# Run demonstration
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
# Create and activate the scene
|
||||||
|
scene = create_demo_scene()
|
||||||
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
|
# When run directly, print summary and exit for headless testing
|
||||||
|
print("\n=== Voxel Serialization Demo (Milestone 14) ===\n")
|
||||||
|
|
||||||
|
# Run a quick verification
|
||||||
|
grid = mcrfpy.VoxelGrid((8, 8, 8))
|
||||||
|
mat = grid.add_material("test", (100, 100, 100))
|
||||||
|
grid.fill_box((0, 0, 0), (7, 0, 7), mat)
|
||||||
|
|
||||||
|
print(f"Created 8x8x8 grid with {grid.count_non_air()} non-air voxels")
|
||||||
|
|
||||||
|
# Test to_bytes
|
||||||
|
data = grid.to_bytes()
|
||||||
|
print(f"Serialized to {len(data)} bytes")
|
||||||
|
|
||||||
|
# Test from_bytes
|
||||||
|
grid2 = mcrfpy.VoxelGrid((1, 1, 1))
|
||||||
|
success = grid2.from_bytes(data)
|
||||||
|
print(f"from_bytes(): {'SUCCESS' if success else 'FAILED'}")
|
||||||
|
print(f"Restored size: {grid2.size}")
|
||||||
|
print(f"Restored voxels: {grid2.count_non_air()}")
|
||||||
|
|
||||||
|
# Compression test
|
||||||
|
big_grid = mcrfpy.VoxelGrid((32, 32, 32))
|
||||||
|
big_mat = big_grid.add_material("solid", (128, 128, 128))
|
||||||
|
big_grid.fill(big_mat)
|
||||||
|
big_data = big_grid.to_bytes()
|
||||||
|
raw_size = 32 * 32 * 32
|
||||||
|
print(f"\nCompression test (32x32x32 uniform):")
|
||||||
|
print(f" Raw: {raw_size} bytes")
|
||||||
|
print(f" Compressed: {len(big_data)} bytes")
|
||||||
|
print(f" Ratio: {raw_size / len(big_data):.0f}x")
|
||||||
|
|
||||||
|
print("\n=== Demo complete ===")
|
||||||
|
sys.exit(0)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue