billboards

This commit is contained in:
John McCardle 2026-02-04 20:47:51 -05:00
commit b85f225789
10 changed files with 1750 additions and 46 deletions

596
src/3d/Billboard.cpp Normal file
View 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) {
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<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) {
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<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
View 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

View file

@ -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 <cmath>
// 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<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)));
// Draw triangles
glDrawArrays(GL_TRIANGLES, 0, static_cast<int>(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<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)));
// Draw triangles
glDrawArrays(GL_TRIANGLES, 0, static_cast<int>(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<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() {
// For terrain mesh, we can average normals at shared positions
// This is a simplified approach - works well for regular grids

View file

@ -10,6 +10,12 @@
#include <vector>
#include <libtcod.h> // 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<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
// =============================================================================
@ -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<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
// =========================================================================
@ -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<MeshInstance> 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

View file

@ -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<std::list<std::shared_ptr<Entity3D>>>())
, billboards_(std::make_shared<std::vector<std::shared_ptr<Billboard>>>())
{
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<std::list<std::shared_ptr<Entity3D>>>())
, billboards_(std::make_shared<std::vector<std::shared_ptr<Billboard>>>())
{
position = sf::Vector2f(x, y);
camera_.setAspect(size_.x / size_.y);
@ -195,6 +199,7 @@ std::shared_ptr<MeshLayer> Viewport3D::addLayer(const std::string& name, int zIn
// Create new layer
auto layer = std::make_shared<MeshLayer>(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<Billboard> 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<Billboard>& 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<char**>(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<float>(PyFloat_AsDouble(PyTuple_GetItem(posObj, 0)));
float py = static_cast<float>(PyFloat_AsDouble(PyTuple_GetItem(posObj, 1)));
float pz = static_cast<float>(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<char**>(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<int>(PyLong_AsLong(PyTuple_GetItem(gridPosObj, 0)));
int gridZ = static_cast<int>(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<int>(PyLong_AsLong(PyTuple_GetItem(footprintObj, 0)));
int footD = static_cast<int>(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<char**>(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<int>(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<long>(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
};

View file

@ -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<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);
// 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<std::list<std::shared_ptr<Entity3D>>> entities_;
// Billboard storage
std::shared_ptr<std::vector<std::shared_ptr<Billboard>>> billboards_;
// Shader for PS1-style rendering
std::unique_ptr<Shader3D> shader_;

View file

@ -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) {

View file

@ -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<PyTexture>(shared_from_this());
}
catch (std::bad_weak_ptr& e)
{
std::cout << "Bad weak ptr: shared_from_this() failed in PyTexture::pyObject(); did you create a PyTexture outside of std::make_shared? enjoy your segfault, soon!" << std::endl;
Py_DECREF(obj);
PyErr_SetString(PyExc_RuntimeError, "PyTexture was not created with std::make_shared");
return NULL;
}
// 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;
}

View file

@ -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*);

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