From b85f22578938c961aa0f3bc4fedda70530f1c923 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 4 Feb 2026 20:47:51 -0500 Subject: [PATCH] billboards --- src/3d/Billboard.cpp | 596 ++++++++++++++++++ src/3d/Billboard.h | 229 +++++++ src/3d/MeshLayer.cpp | 197 ++++-- src/3d/MeshLayer.h | 91 ++- src/3d/Viewport3D.cpp | 320 +++++++++- src/3d/Viewport3D.h | 26 + src/McRFPy_API.cpp | 3 + src/PyTexture.cpp | 17 +- src/PyTexture.h | 3 + tests/demo/screens/billboard_building_demo.py | 314 +++++++++ 10 files changed, 1750 insertions(+), 46 deletions(-) create mode 100644 src/3d/Billboard.cpp create mode 100644 src/3d/Billboard.h create mode 100644 tests/demo/screens/billboard_building_demo.py diff --git a/src/3d/Billboard.cpp b/src/3d/Billboard.cpp new file mode 100644 index 0000000..d7bd3bf --- /dev/null +++ b/src/3d/Billboard.cpp @@ -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 +#include + +// GL headers based on backend +#if defined(MCRF_SDL2) + #ifdef __EMSCRIPTEN__ + #include + #else + #include + #include + #endif + #define MCRF_HAS_GL 1 +#elif !defined(MCRF_HEADLESS) + #include + #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 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) { + sf::Texture::bind(sfTexture); + + // 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(offsetof(MeshVertex, position))); + + glEnableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD); + glVertexAttribPointer(Shader3D::ATTRIB_TEXCOORD, 2, GL_FLOAT, GL_FALSE, + stride, reinterpret_cast(offsetof(MeshVertex, texcoord))); + + glEnableVertexAttribArray(Shader3D::ATTRIB_NORMAL); + glVertexAttribPointer(Shader3D::ATTRIB_NORMAL, 3, GL_FLOAT, GL_FALSE, + stride, reinterpret_cast(offsetof(MeshVertex, normal))); + + glEnableVertexAttribArray(Shader3D::ATTRIB_COLOR); + glVertexAttribPointer(Shader3D::ATTRIB_COLOR, 4, GL_FLOAT, GL_FALSE, + stride, reinterpret_cast(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) { + sf::Texture::bind(nullptr); + } + + // 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(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(PyFloat_AsDouble(PyTuple_GetItem(posObj, 0))); + float y = static_cast(PyFloat_AsDouble(PyTuple_GetItem(posObj, 1))); + float z = static_cast(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(""); + } + + 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), "", + 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 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(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(PyFloat_AsDouble(PyTuple_GetItem(value, 0))); + float y = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 1))); + float z = static_cast(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(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(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(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(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 diff --git a/src/3d/Billboard.h b/src/3d/Billboard.h new file mode 100644 index 0000000..f837baa --- /dev/null +++ b/src/3d/Billboard.h @@ -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 + +// 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 { +public: + // Python integration + PyObject* self = nullptr; + uint64_t serial_number = 0; + + Billboard(); + Billboard(std::shared_ptr 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 getTexture() const { return texture_; } + void setTexture(std::shared_ptr 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 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 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(std::make_shared()); + self->weakreflist = nullptr; + } + return (PyObject*)self; + } +}; + +} // namespace mcrfpydef diff --git a/src/3d/MeshLayer.cpp b/src/3d/MeshLayer.cpp index faabcb1..7314a14 100644 --- a/src/3d/MeshLayer.cpp +++ b/src/3d/MeshLayer.cpp @@ -1,8 +1,11 @@ // MeshLayer.cpp - Static 3D geometry layer implementation #include "MeshLayer.h" +#include "Model3D.h" +#include "Viewport3D.h" #include "Shader3D.h" #include "../platform/GLContext.h" +#include // GL headers based on backend #if defined(MCRF_SDL2) @@ -377,53 +380,57 @@ void MeshLayer::uploadToGPU() { // 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 - if (!gl::isGLReady() || vertices_.empty()) { + if (!gl::isGLReady()) { return; } - // Upload to GPU if needed - if (dirty_ || vbo_ == 0) { - uploadToGPU(); + // Render terrain geometry if present + if (!vertices_.empty()) { + // Upload to GPU if needed + if (dirty_ || vbo_ == 0) { + uploadToGPU(); + } + + if (vbo_ != 0) { + // Bind VBO + glBindBuffer(GL_ARRAY_BUFFER, vbo_); + + // Vertex format: pos(3) + texcoord(2) + normal(3) + color(4) = 12 floats = 48 bytes + int stride = sizeof(MeshVertex); + + // Set up vertex attributes + glEnableVertexAttribArray(Shader3D::ATTRIB_POSITION); + glVertexAttribPointer(Shader3D::ATTRIB_POSITION, 3, GL_FLOAT, GL_FALSE, + stride, reinterpret_cast(offsetof(MeshVertex, position))); + + glEnableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD); + glVertexAttribPointer(Shader3D::ATTRIB_TEXCOORD, 2, GL_FLOAT, GL_FALSE, + stride, reinterpret_cast(offsetof(MeshVertex, texcoord))); + + glEnableVertexAttribArray(Shader3D::ATTRIB_NORMAL); + glVertexAttribPointer(Shader3D::ATTRIB_NORMAL, 3, GL_FLOAT, GL_FALSE, + stride, reinterpret_cast(offsetof(MeshVertex, normal))); + + glEnableVertexAttribArray(Shader3D::ATTRIB_COLOR); + glVertexAttribPointer(Shader3D::ATTRIB_COLOR, 4, GL_FLOAT, GL_FALSE, + stride, reinterpret_cast(offsetof(MeshVertex, color))); + + // Draw triangles + glDrawArrays(GL_TRIANGLES, 0, static_cast(vertices_.size())); + + // Cleanup + glDisableVertexAttribArray(Shader3D::ATTRIB_POSITION); + glDisableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD); + glDisableVertexAttribArray(Shader3D::ATTRIB_NORMAL); + glDisableVertexAttribArray(Shader3D::ATTRIB_COLOR); + glBindBuffer(GL_ARRAY_BUFFER, 0); + } } - if (vbo_ == 0) { - return; - } - - // Bind VBO - glBindBuffer(GL_ARRAY_BUFFER, vbo_); - - // Vertex format: pos(3) + texcoord(2) + normal(3) + color(4) = 12 floats = 48 bytes - int stride = sizeof(MeshVertex); - - // Set up vertex attributes - glEnableVertexAttribArray(Shader3D::ATTRIB_POSITION); - glVertexAttribPointer(Shader3D::ATTRIB_POSITION, 3, GL_FLOAT, GL_FALSE, - stride, reinterpret_cast(offsetof(MeshVertex, position))); - - glEnableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD); - glVertexAttribPointer(Shader3D::ATTRIB_TEXCOORD, 2, GL_FLOAT, GL_FALSE, - stride, reinterpret_cast(offsetof(MeshVertex, texcoord))); - - glEnableVertexAttribArray(Shader3D::ATTRIB_NORMAL); - glVertexAttribPointer(Shader3D::ATTRIB_NORMAL, 3, GL_FLOAT, GL_FALSE, - stride, reinterpret_cast(offsetof(MeshVertex, normal))); - - glEnableVertexAttribArray(Shader3D::ATTRIB_COLOR); - glVertexAttribPointer(Shader3D::ATTRIB_COLOR, 4, GL_FLOAT, GL_FALSE, - stride, reinterpret_cast(offsetof(MeshVertex, color))); - - // Draw triangles - glDrawArrays(GL_TRIANGLES, 0, static_cast(vertices_.size())); - - // Cleanup - glDisableVertexAttribArray(Shader3D::ATTRIB_POSITION); - glDisableVertexAttribArray(Shader3D::ATTRIB_TEXCOORD); - glDisableVertexAttribArray(Shader3D::ATTRIB_NORMAL); - glDisableVertexAttribArray(Shader3D::ATTRIB_COLOR); - glBindBuffer(GL_ARRAY_BUFFER, 0); + // Render mesh instances + renderMeshInstances(shader, view, projection); #endif } @@ -446,6 +453,114 @@ vec3 MeshLayer::computeFaceNormal(const vec3& v0, const vec3& v1, const vec3& v2 return edge1.cross(edge2).normalized(); } +// ============================================================================= +// Mesh Instances +// ============================================================================= + +size_t MeshLayer::addMesh(std::shared_ptr 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 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(std::ceil(extentX / cellSize))); + int footprintD = std::max(1, static_cast(std::ceil(extentZ / cellSize))); + + // Calculate grid position (center the footprint on the world position) + int gridX = static_cast(std::floor(worldPos.x / cellSize - footprintW * 0.5f)); + int gridZ = static_cast(std::floor(worldPos.z / cellSize - footprintD * 0.5f)); + + // Place blocking cells + placeBlocking(gridX, gridZ, footprintW, footprintD, walkable, transparent); +} + +// ============================================================================= +// Private Helpers +// ============================================================================= + void MeshLayer::computeVertexNormals() { // For terrain mesh, we can average normals at shared positions // This is a simplified approach - works well for regular grids diff --git a/src/3d/MeshLayer.h b/src/3d/MeshLayer.h index f098f0f..55e86ca 100644 --- a/src/3d/MeshLayer.h +++ b/src/3d/MeshLayer.h @@ -10,6 +10,12 @@ #include #include // For TCOD_heightmap_t +// Forward declarations +namespace mcrf { +class Viewport3D; +class Model3D; +} + namespace mcrf { // ============================================================================= @@ -49,6 +55,25 @@ struct TextureRange { : minHeight(min), maxHeight(max), spriteIndex(index) {} }; +// ============================================================================= +// MeshInstance - Instance of a Model3D placed in the world +// ============================================================================= + +struct MeshInstance { + std::shared_ptr 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 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 // ============================================================================= @@ -115,6 +140,60 @@ public: /// Clear all geometry 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 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 model, const vec3& worldPos, + float rotation, bool walkable = false, bool transparent = false); + // ========================================================================= // GPU Upload and Rendering // ========================================================================= @@ -124,10 +203,11 @@ public: void uploadToGPU(); /// Render this layer + /// @param shader Shader program handle (for mesh instances) /// @param model Model transformation matrix /// @param view View 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) mat4 getModelMatrix() const { return modelMatrix_; } @@ -164,10 +244,19 @@ private: // Transform mat4 modelMatrix_ = mat4::identity(); + // Mesh instances (Model3D placements) + std::vector meshInstances_; + + // Parent viewport for collision helpers + Viewport3D* viewport_ = nullptr; + // Helper methods void cleanupGPU(); vec3 computeFaceNormal(const vec3& v0, const vec3& v1, const vec3& v2); void computeVertexNormals(); + + // Render mesh instances + void renderMeshInstances(unsigned int shader, const mat4& view, const mat4& projection); }; } // namespace mcrf diff --git a/src/3d/Viewport3D.cpp b/src/3d/Viewport3D.cpp index 9a5a211..a5261b0 100644 --- a/src/3d/Viewport3D.cpp +++ b/src/3d/Viewport3D.cpp @@ -5,6 +5,8 @@ #include "MeshLayer.h" #include "Entity3D.h" #include "EntityCollection3D.h" +#include "Billboard.h" +#include "Model3D.h" #include "../platform/GLContext.h" #include "PyVector.h" #include "PyColor.h" @@ -42,6 +44,7 @@ namespace mcrf { Viewport3D::Viewport3D() : size_(320.0f, 240.0f) , entities_(std::make_shared>>()) + , billboards_(std::make_shared>>()) { position = sf::Vector2f(0, 0); camera_.setAspect(size_.x / size_.y); @@ -50,6 +53,7 @@ Viewport3D::Viewport3D() Viewport3D::Viewport3D(float x, float y, float width, float height) : size_(width, height) , entities_(std::make_shared>>()) + , billboards_(std::make_shared>>()) { position = sf::Vector2f(x, y); camera_.setAspect(size_.x / size_.y); @@ -195,6 +199,7 @@ std::shared_ptr Viewport3D::addLayer(const std::string& name, int zIn // Create new layer auto layer = std::make_shared(name, zIndex); + layer->setViewport(this); // Allow layer to mark cells as blocking meshLayers_.push_back(layer); // Disable test cube when layers are added @@ -462,6 +467,60 @@ void Viewport3D::renderEntities(const mat4& view, const mat4& proj) { #endif } +// ============================================================================= +// Billboard Management +// ============================================================================= + +void Viewport3D::addBillboard(std::shared_ptr bb) { + if (billboards_ && bb) { + billboards_->push_back(bb); + } +} + +void Viewport3D::removeBillboard(Billboard* bb) { + if (!billboards_ || !bb) return; + auto it = std::find_if(billboards_->begin(), billboards_->end(), + [bb](const std::shared_ptr& p) { return p.get() == bb; }); + if (it != billboards_->end()) { + billboards_->erase(it); + } +} + +void Viewport3D::clearBillboards() { + if (billboards_) { + billboards_->clear(); + } +} + +void Viewport3D::renderBillboards(const mat4& view, const mat4& proj) { +#ifdef MCRF_HAS_GL + if (!billboards_ || billboards_->empty() || !shader_ || !shader_->isValid()) return; + + shader_->bind(); + unsigned int shaderProgram = shader_->getProgram(); + vec3 cameraPos = camera_.getPosition(); + + // Enable blending for transparency + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + // Disable depth write but keep depth test for proper ordering + glDepthMask(GL_FALSE); + + for (auto& billboard : *billboards_) { + if (billboard && billboard->isVisible()) { + billboard->render(shaderProgram, view, proj, cameraPos); + } + } + + // Restore depth writing + glDepthMask(GL_TRUE); + glDisable(GL_BLEND); + + shader_->unbind(); +#endif +} + // ============================================================================= // FBO Management // ============================================================================= @@ -626,12 +685,13 @@ void Viewport3D::renderMeshLayers() { shader_->setUniform("u_has_texture", false); // Render each layer + unsigned int shaderProgram = shader_->getProgram(); for (auto* layer : sortedLayers) { // Set model matrix for this layer shader_->setUniform("u_model", layer->getModelMatrix()); - // Render the layer's geometry - layer->render(layer->getModelMatrix(), view, projection); + // Render the layer's geometry (terrain + mesh instances) + layer->render(shaderProgram, layer->getModelMatrix(), view, projection); } shader_->unbind(); @@ -673,6 +733,9 @@ void Viewport3D::render3DContent() { mat4 projection = camera_.getProjectionMatrix(); renderEntities(view, projection); + // Render billboards (after opaque geometry for proper transparency) + renderBillboards(view, projection); + // Render test cube if enabled (disabled when layers are added) if (renderTestCube_ && shader_ && shader_->isValid() && testVBO_ != 0) { shader_->bind(); @@ -1795,6 +1858,206 @@ static PyObject* Viewport3D_is_in_fov(PyViewport3DObject* self, PyObject* args) return PyBool_FromLong(self->data->isInFOV(x, z)); } +// ============================================================================= +// Mesh Instance Methods (Milestone 6) +// ============================================================================= + +static PyObject* Viewport3D_add_mesh(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"layer_name", "model", "pos", "rotation", "scale", NULL}; + + const char* layerName = nullptr; + PyObject* modelObj = nullptr; + PyObject* posObj = nullptr; + float rotation = 0.0f; + float scale = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOO|ff", const_cast(kwlist), + &layerName, &modelObj, &posObj, &rotation, &scale)) { + return NULL; + } + + // Validate model + if (!PyObject_IsInstance(modelObj, (PyObject*)&mcrfpydef::PyModel3DType)) { + PyErr_SetString(PyExc_TypeError, "model must be a Model3D object"); + return NULL; + } + PyModel3DObject* modelPy = (PyModel3DObject*)modelObj; + if (!modelPy->data) { + PyErr_SetString(PyExc_ValueError, "model is invalid"); + return NULL; + } + + // Parse position + if (!PyTuple_Check(posObj) || PyTuple_Size(posObj) < 3) { + PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y, z)"); + return NULL; + } + float px = static_cast(PyFloat_AsDouble(PyTuple_GetItem(posObj, 0))); + float py = static_cast(PyFloat_AsDouble(PyTuple_GetItem(posObj, 1))); + float pz = static_cast(PyFloat_AsDouble(PyTuple_GetItem(posObj, 2))); + if (PyErr_Occurred()) return NULL; + + // Get or create layer + auto layer = self->data->getLayer(layerName); + if (!layer) { + layer = self->data->addLayer(layerName, 0); + } + + // Add mesh instance + size_t index = layer->addMesh(modelPy->data, vec3(px, py, pz), rotation, vec3(scale, scale, scale)); + + return PyLong_FromSize_t(index); +} + +static PyObject* Viewport3D_place_blocking(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"grid_pos", "footprint", "walkable", "transparent", NULL}; + + PyObject* gridPosObj = nullptr; + PyObject* footprintObj = nullptr; + int walkable = 0; // Default: not walkable + int transparent = 0; // Default: not transparent + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|pp", const_cast(kwlist), + &gridPosObj, &footprintObj, &walkable, &transparent)) { + return NULL; + } + + // Parse grid_pos + if (!PyTuple_Check(gridPosObj) || PyTuple_Size(gridPosObj) < 2) { + PyErr_SetString(PyExc_TypeError, "grid_pos must be a tuple of (x, z)"); + return NULL; + } + int gridX = static_cast(PyLong_AsLong(PyTuple_GetItem(gridPosObj, 0))); + int gridZ = static_cast(PyLong_AsLong(PyTuple_GetItem(gridPosObj, 1))); + if (PyErr_Occurred()) return NULL; + + // Parse footprint + if (!PyTuple_Check(footprintObj) || PyTuple_Size(footprintObj) < 2) { + PyErr_SetString(PyExc_TypeError, "footprint must be a tuple of (width, depth)"); + return NULL; + } + int footW = static_cast(PyLong_AsLong(PyTuple_GetItem(footprintObj, 0))); + int footD = static_cast(PyLong_AsLong(PyTuple_GetItem(footprintObj, 1))); + if (PyErr_Occurred()) return NULL; + + // Mark cells + for (int dz = 0; dz < footD; dz++) { + for (int dx = 0; dx < footW; dx++) { + int cx = gridX + dx; + int cz = gridZ + dz; + if (self->data->isValidCell(cx, cz)) { + VoxelPoint& cell = self->data->at(cx, cz); + cell.walkable = walkable != 0; + cell.transparent = transparent != 0; + self->data->syncTCODCell(cx, cz); + } + } + } + + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_clear_meshes(PyViewport3DObject* self, PyObject* args) { + const char* layerName = nullptr; + + if (!PyArg_ParseTuple(args, "s", &layerName)) { + return NULL; + } + + auto layer = self->data->getLayer(layerName); + if (!layer) { + PyErr_SetString(PyExc_ValueError, "Layer not found"); + return NULL; + } + + layer->clearMeshes(); + Py_RETURN_NONE; +} + +// ============================================================================= +// Billboard Management Methods +// ============================================================================= + +static PyObject* Viewport3D_add_billboard(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"billboard", NULL}; + + PyObject* billboardObj = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast(kwlist), &billboardObj)) { + return NULL; + } + + // Check if it's a Billboard object + if (!PyObject_IsInstance(billboardObj, (PyObject*)&mcrfpydef::PyBillboardType)) { + PyErr_SetString(PyExc_TypeError, "Expected a Billboard object"); + return NULL; + } + + PyBillboardObject* bbObj = (PyBillboardObject*)billboardObj; + if (!bbObj->data) { + PyErr_SetString(PyExc_ValueError, "Invalid Billboard object"); + return NULL; + } + + self->data->addBillboard(bbObj->data); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_remove_billboard(PyViewport3DObject* self, PyObject* args) { + PyObject* billboardObj = nullptr; + + if (!PyArg_ParseTuple(args, "O", &billboardObj)) { + return NULL; + } + + if (!PyObject_IsInstance(billboardObj, (PyObject*)&mcrfpydef::PyBillboardType)) { + PyErr_SetString(PyExc_TypeError, "Expected a Billboard object"); + return NULL; + } + + PyBillboardObject* bbObj = (PyBillboardObject*)billboardObj; + if (bbObj->data) { + self->data->removeBillboard(bbObj->data.get()); + } + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_clear_billboards(PyViewport3DObject* self, PyObject* args) { + self->data->clearBillboards(); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_get_billboard(PyViewport3DObject* self, PyObject* args) { + int index = 0; + + if (!PyArg_ParseTuple(args, "i", &index)) { + return NULL; + } + + auto billboards = self->data->getBillboards(); + if (index < 0 || index >= static_cast(billboards->size())) { + PyErr_SetString(PyExc_IndexError, "Billboard index out of range"); + return NULL; + } + + auto bb = (*billboards)[index]; + + // Create Python wrapper for billboard + auto type = &mcrfpydef::PyBillboardType; + auto obj = (PyBillboardObject*)type->tp_alloc(type, 0); + if (!obj) return NULL; + + obj->data = bb; + obj->weakreflist = nullptr; + + return (PyObject*)obj; +} + +static PyObject* Viewport3D_billboard_count(PyViewport3DObject* self, PyObject* args) { + auto billboards = self->data->getBillboards(); + return PyLong_FromLong(static_cast(billboards->size())); +} + } // namespace mcrf // Methods array - outside namespace but PyObjectType still in scope via typedef @@ -1903,5 +2166,58 @@ PyMethodDef Viewport3D_methods[] = { " z: Z coordinate\n\n" "Returns:\n" " True if the cell is visible"}, + + // Mesh instance methods (Milestone 6) + {"add_mesh", (PyCFunction)mcrf::Viewport3D_add_mesh, METH_VARARGS | METH_KEYWORDS, + "add_mesh(layer_name, model, pos, rotation=0, scale=1.0) -> int\n\n" + "Add a Model3D instance to a layer at the specified position.\n\n" + "Args:\n" + " layer_name: Name of layer to add mesh to (created if needed)\n" + " model: Model3D object to place\n" + " pos: World position as (x, y, z) tuple\n" + " rotation: Y-axis rotation in degrees\n" + " scale: Uniform scale factor\n\n" + "Returns:\n" + " Index of the mesh instance"}, + {"place_blocking", (PyCFunction)mcrf::Viewport3D_place_blocking, METH_VARARGS | METH_KEYWORDS, + "place_blocking(grid_pos, footprint, walkable=False, transparent=False)\n\n" + "Mark grid cells as blocking for pathfinding and FOV.\n\n" + "Args:\n" + " grid_pos: Top-left grid position as (x, z) tuple\n" + " footprint: Size in cells as (width, depth) tuple\n" + " walkable: Whether cells should be walkable (default: False)\n" + " transparent: Whether cells should be transparent (default: False)"}, + {"clear_meshes", (PyCFunction)mcrf::Viewport3D_clear_meshes, METH_VARARGS, + "clear_meshes(layer_name)\n\n" + "Clear all mesh instances from a layer.\n\n" + "Args:\n" + " layer_name: Name of layer to clear"}, + + // Billboard methods (Milestone 6) + {"add_billboard", (PyCFunction)mcrf::Viewport3D_add_billboard, METH_VARARGS | METH_KEYWORDS, + "add_billboard(billboard)\n\n" + "Add a Billboard to the viewport.\n\n" + "Args:\n" + " billboard: Billboard object to add"}, + {"remove_billboard", (PyCFunction)mcrf::Viewport3D_remove_billboard, METH_VARARGS, + "remove_billboard(billboard)\n\n" + "Remove a Billboard from the viewport.\n\n" + "Args:\n" + " billboard: Billboard object to remove"}, + {"clear_billboards", (PyCFunction)mcrf::Viewport3D_clear_billboards, METH_NOARGS, + "clear_billboards()\n\n" + "Remove all billboards from the viewport."}, + {"get_billboard", (PyCFunction)mcrf::Viewport3D_get_billboard, METH_VARARGS, + "get_billboard(index) -> Billboard\n\n" + "Get a Billboard by index.\n\n" + "Args:\n" + " index: Index of the billboard\n\n" + "Returns:\n" + " Billboard object"}, + {"billboard_count", (PyCFunction)mcrf::Viewport3D_billboard_count, METH_NOARGS, + "billboard_count() -> int\n\n" + "Get the number of billboards.\n\n" + "Returns:\n" + " Number of billboards in the viewport"}, {NULL} // Sentinel }; diff --git a/src/3d/Viewport3D.h b/src/3d/Viewport3D.h index ab7d5b2..80fe16a 100644 --- a/src/3d/Viewport3D.h +++ b/src/3d/Viewport3D.h @@ -30,6 +30,7 @@ namespace mcrf { class Viewport3D; class Shader3D; class MeshLayer; +class Billboard; } // namespace mcrf @@ -190,6 +191,28 @@ public: /// Render all entities void renderEntities(const mat4& view, const mat4& proj); + // ========================================================================= + // Billboard Management + // ========================================================================= + + /// Get the billboard list + std::shared_ptr>> getBillboards() { return billboards_; } + + /// Add a billboard + void addBillboard(std::shared_ptr 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); + // Background color void setBackgroundColor(const sf::Color& color) { bgColor_ = color; } sf::Color getBackgroundColor() const { return bgColor_; } @@ -276,6 +299,9 @@ private: // Entity3D storage std::shared_ptr>> entities_; + // Billboard storage + std::shared_ptr>> billboards_; + // Shader for PS1-style rendering std::unique_ptr shader_; diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 70f836e..c6b0ad2 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -35,6 +35,7 @@ #include "3d/Entity3D.h" // 3D game entities #include "3d/EntityCollection3D.h" // Entity3D collection #include "3d/Model3D.h" // 3D model resource +#include "3d/Billboard.h" // Billboard sprites #include "McRogueFaceVersion.h" #include "GameEngine.h" // ImGui is only available for SFML builds @@ -441,6 +442,7 @@ PyObject* PyInit_mcrfpy() /*3D entities*/ &mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType, &mcrfpydef::PyEntityCollection3DIterType, &mcrfpydef::PyModel3DType, + &mcrfpydef::PyBillboardType, /*grid layers (#147)*/ &PyColorLayerType, &PyTileLayerType, @@ -561,6 +563,7 @@ PyObject* PyInit_mcrfpy() PyViewport3DType.tp_weaklistoffset = offsetof(PyViewport3DObject, 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 if (PyLock::init() < 0) { diff --git a/src/PyTexture.cpp b/src/PyTexture.cpp index 2bea741..7f4d256 100644 --- a/src/PyTexture.cpp +++ b/src/PyTexture.cpp @@ -67,16 +67,29 @@ sf::Sprite PyTexture::sprite(int index, sf::Vector2f pos, sf::Vector2f s) PyObject* PyTexture::pyObject() { 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); + Py_DECREF(type); // GetAttrString returns new reference + + if (!obj) { + return NULL; + } 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(shared_from_this()); } catch (std::bad_weak_ptr& e) { std::cout << "Bad weak ptr: shared_from_this() failed in PyTexture::pyObject(); did you create a PyTexture outside of std::make_shared? enjoy your segfault, soon!" << std::endl; + Py_DECREF(obj); + PyErr_SetString(PyExc_RuntimeError, "PyTexture was not created with std::make_shared"); + return NULL; } - // 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; } diff --git a/src/PyTexture.h b/src/PyTexture.h index b2375c8..fa5befb 100644 --- a/src/PyTexture.h +++ b/src/PyTexture.h @@ -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)); 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(); static PyObject* repr(PyObject*); static Py_hash_t hash(PyObject*); diff --git a/tests/demo/screens/billboard_building_demo.py b/tests/demo/screens/billboard_building_demo.py new file mode 100644 index 0000000..8b74efd --- /dev/null +++ b/tests/demo/screens/billboard_building_demo.py @@ -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")