From e572269eac5e1728a38a5230a97afd068f99e2ad Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 4 Feb 2026 14:51:31 -0500 Subject: [PATCH] Terrain mesh, vertex color from heightmaps --- src/3d/MeshLayer.cpp | 535 +++++++++++++++++++++++++++++ src/3d/MeshLayer.h | 173 ++++++++++ src/3d/Viewport3D.cpp | 336 +++++++++++++++++- src/3d/Viewport3D.h | 39 ++- tests/demo/screens/terrain_demo.py | 320 +++++++++++++++++ 5 files changed, 1400 insertions(+), 3 deletions(-) create mode 100644 src/3d/MeshLayer.cpp create mode 100644 src/3d/MeshLayer.h create mode 100644 tests/demo/screens/terrain_demo.py diff --git a/src/3d/MeshLayer.cpp b/src/3d/MeshLayer.cpp new file mode 100644 index 0000000..faabcb1 --- /dev/null +++ b/src/3d/MeshLayer.cpp @@ -0,0 +1,535 @@ +// MeshLayer.cpp - Static 3D geometry layer implementation + +#include "MeshLayer.h" +#include "Shader3D.h" +#include "../platform/GLContext.h" + +// 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 { + +// ============================================================================= +// Constructor / Destructor +// ============================================================================= + +MeshLayer::MeshLayer() + : name_("unnamed") + , zIndex_(0) + , visible_(true) +{} + +MeshLayer::MeshLayer(const std::string& name, int zIndex) + : name_(name) + , zIndex_(zIndex) + , visible_(true) +{} + +MeshLayer::~MeshLayer() { + cleanupGPU(); +} + +MeshLayer::MeshLayer(MeshLayer&& other) noexcept + : name_(std::move(other.name_)) + , zIndex_(other.zIndex_) + , visible_(other.visible_) + , vertices_(std::move(other.vertices_)) + , heightData_(std::move(other.heightData_)) + , heightmapWidth_(other.heightmapWidth_) + , heightmapHeight_(other.heightmapHeight_) + , vbo_(other.vbo_) + , dirty_(other.dirty_) + , texture_(other.texture_) + , tilesPerRow_(other.tilesPerRow_) + , tilesPerCol_(other.tilesPerCol_) + , modelMatrix_(other.modelMatrix_) +{ + other.vbo_ = 0; // Prevent cleanup in moved-from object +} + +MeshLayer& MeshLayer::operator=(MeshLayer&& other) noexcept { + if (this != &other) { + cleanupGPU(); + + name_ = std::move(other.name_); + zIndex_ = other.zIndex_; + visible_ = other.visible_; + vertices_ = std::move(other.vertices_); + heightData_ = std::move(other.heightData_); + heightmapWidth_ = other.heightmapWidth_; + heightmapHeight_ = other.heightmapHeight_; + vbo_ = other.vbo_; + dirty_ = other.dirty_; + texture_ = other.texture_; + tilesPerRow_ = other.tilesPerRow_; + tilesPerCol_ = other.tilesPerCol_; + modelMatrix_ = other.modelMatrix_; + + other.vbo_ = 0; + } + return *this; +} + +// ============================================================================= +// Configuration +// ============================================================================= + +void MeshLayer::setSpriteSheetLayout(int tilesPerRow, int tilesPerCol) { + tilesPerRow_ = tilesPerRow > 0 ? tilesPerRow : 1; + tilesPerCol_ = tilesPerCol > 0 ? tilesPerCol : 1; +} + +// ============================================================================= +// Mesh Generation - HeightMap +// ============================================================================= + +void MeshLayer::buildFromHeightmap(TCOD_heightmap_t* heightmap, float yScale, float cellSize) { + if (!heightmap || heightmap->w < 2 || heightmap->h < 2) { + return; + } + + int w = heightmap->w; + int h = heightmap->h; + + // Store heightmap dimensions and data for later texture range application + heightmapWidth_ = w; + heightmapHeight_ = h; + heightData_.resize(w * h); + for (int i = 0; i < w * h; i++) { + heightData_[i] = heightmap->values[i]; + } + + // Calculate grid vertices + // For an NxM heightmap, we create (N-1)×(M-1) quads = 2×(N-1)×(M-1) triangles + int numQuadsX = w - 1; + int numQuadsZ = h - 1; + int numTriangles = numQuadsX * numQuadsZ * 2; + int numVertices = numTriangles * 3; + + vertices_.clear(); + vertices_.reserve(numVertices); + + // Generate triangles for each quad + for (int z = 0; z < numQuadsZ; z++) { + for (int x = 0; x < numQuadsX; x++) { + // Get heights at quad corners (in heightmap, z is row index, x is column) + float h00 = heightmap->values[z * w + x] * yScale; + float h10 = heightmap->values[z * w + (x + 1)] * yScale; + float h01 = heightmap->values[(z + 1) * w + x] * yScale; + float h11 = heightmap->values[(z + 1) * w + (x + 1)] * yScale; + + // World positions + vec3 p00(x * cellSize, h00, z * cellSize); + vec3 p10((x + 1) * cellSize, h10, z * cellSize); + vec3 p01(x * cellSize, h01, (z + 1) * cellSize); + vec3 p11((x + 1) * cellSize, h11, (z + 1) * cellSize); + + // UVs (tiled across terrain, will be adjusted by applyTextureRanges) + vec2 uv00(static_cast(x), static_cast(z)); + vec2 uv10(static_cast(x + 1), static_cast(z)); + vec2 uv01(static_cast(x), static_cast(z + 1)); + vec2 uv11(static_cast(x + 1), static_cast(z + 1)); + + // Default color (white, will be modulated by texture) + vec4 color(1.0f, 1.0f, 1.0f, 1.0f); + + // Triangle 1: p00 -> p01 -> p10 (counter-clockwise from above) + // This ensures the normal points UP (+Y) for proper backface culling + vec3 n1 = computeFaceNormal(p00, p01, p10); + vertices_.emplace_back(p00, uv00, n1, color); + vertices_.emplace_back(p01, uv01, n1, color); + vertices_.emplace_back(p10, uv10, n1, color); + + // Triangle 2: p10 -> p01 -> p11 (counter-clockwise from above) + vec3 n2 = computeFaceNormal(p10, p01, p11); + vertices_.emplace_back(p10, uv10, n2, color); + vertices_.emplace_back(p01, uv01, n2, color); + vertices_.emplace_back(p11, uv11, n2, color); + } + } + + // Compute smooth vertex normals (average adjacent face normals) + computeVertexNormals(); + + dirty_ = true; +} + +// ============================================================================= +// Mesh Generation - Plane +// ============================================================================= + +void MeshLayer::buildPlane(float width, float depth, float y) { + vertices_.clear(); + vertices_.reserve(6); // 2 triangles + + // Clear height data (plane has no height variation) + heightData_.clear(); + heightmapWidth_ = 0; + heightmapHeight_ = 0; + + float halfW = width * 0.5f; + float halfD = depth * 0.5f; + + vec3 p00(-halfW, y, -halfD); + vec3 p10(halfW, y, -halfD); + vec3 p01(-halfW, y, halfD); + vec3 p11(halfW, y, halfD); + + vec3 normal(0, 1, 0); // Facing up + vec4 color(1, 1, 1, 1); + + // Triangle 1 + vertices_.emplace_back(p00, vec2(0, 0), normal, color); + vertices_.emplace_back(p10, vec2(1, 0), normal, color); + vertices_.emplace_back(p01, vec2(0, 1), normal, color); + + // Triangle 2 + vertices_.emplace_back(p10, vec2(1, 0), normal, color); + vertices_.emplace_back(p11, vec2(1, 1), normal, color); + vertices_.emplace_back(p01, vec2(0, 1), normal, color); + + dirty_ = true; +} + +// ============================================================================= +// Texture Ranges +// ============================================================================= + +void MeshLayer::applyTextureRanges(const std::vector& ranges) { + if (ranges.empty() || heightData_.empty() || vertices_.empty()) { + return; + } + + // Calculate tile UV size + float tileU = 1.0f / tilesPerRow_; + float tileV = 1.0f / tilesPerCol_; + + // For each vertex, find its height and apply the appropriate texture + // Vertices are stored as triangles, 6 per quad (2 triangles × 3 vertices) + int numQuadsX = heightmapWidth_ - 1; + int numQuadsZ = heightmapHeight_ - 1; + + for (int z = 0; z < numQuadsZ; z++) { + for (int x = 0; x < numQuadsX; x++) { + int quadIndex = z * numQuadsX + x; + int baseVertex = quadIndex * 6; + + // Get heights at quad corners (normalized 0-1) + float h00 = heightData_[z * heightmapWidth_ + x]; + float h10 = heightData_[z * heightmapWidth_ + (x + 1)]; + float h01 = heightData_[(z + 1) * heightmapWidth_ + x]; + float h11 = heightData_[(z + 1) * heightmapWidth_ + (x + 1)]; + + // Use average height to select texture + float avgHeight = (h00 + h10 + h01 + h11) * 0.25f; + + // Find matching range + int spriteIndex = 0; + for (const auto& range : ranges) { + if (avgHeight >= range.minHeight && avgHeight <= range.maxHeight) { + spriteIndex = range.spriteIndex; + break; + } + } + + // Calculate sprite UV offset in sprite sheet + int tileX = spriteIndex % tilesPerRow_; + int tileY = spriteIndex / tilesPerRow_; + float uOffset = tileX * tileU; + float vOffset = tileY * tileV; + + // Update UVs for all 6 vertices of this quad + // Triangle 1: p00, p10, p01 + if (baseVertex + 5 < static_cast(vertices_.size())) { + // Local UV within quad (0-1) scaled to tile size and offset + vertices_[baseVertex + 0].texcoord = vec2(uOffset, vOffset); + vertices_[baseVertex + 1].texcoord = vec2(uOffset + tileU, vOffset); + vertices_[baseVertex + 2].texcoord = vec2(uOffset, vOffset + tileV); + + // Triangle 2: p10, p11, p01 + vertices_[baseVertex + 3].texcoord = vec2(uOffset + tileU, vOffset); + vertices_[baseVertex + 4].texcoord = vec2(uOffset + tileU, vOffset + tileV); + vertices_[baseVertex + 5].texcoord = vec2(uOffset, vOffset + tileV); + } + } + } + + dirty_ = true; +} + +// ============================================================================= +// Color Map +// ============================================================================= + +void MeshLayer::applyColorMap(TCOD_heightmap_t* rMap, TCOD_heightmap_t* gMap, TCOD_heightmap_t* bMap) { + if (!rMap || !gMap || !bMap) { + return; + } + + if (vertices_.empty() || heightmapWidth_ < 2 || heightmapHeight_ < 2) { + return; + } + + // Verify color maps match terrain dimensions + if (rMap->w != heightmapWidth_ || rMap->h != heightmapHeight_ || + gMap->w != heightmapWidth_ || gMap->h != heightmapHeight_ || + bMap->w != heightmapWidth_ || bMap->h != heightmapHeight_) { + return; // Dimension mismatch + } + + int numQuadsX = heightmapWidth_ - 1; + int numQuadsZ = heightmapHeight_ - 1; + + for (int z = 0; z < numQuadsZ; z++) { + for (int x = 0; x < numQuadsX; x++) { + int quadIndex = z * numQuadsX + x; + int baseVertex = quadIndex * 6; + + if (baseVertex + 5 >= static_cast(vertices_.size())) { + continue; + } + + // Sample RGB at each corner of the quad + // Corner indices in heightmap + int idx00 = z * heightmapWidth_ + x; + int idx10 = z * heightmapWidth_ + (x + 1); + int idx01 = (z + 1) * heightmapWidth_ + x; + int idx11 = (z + 1) * heightmapWidth_ + (x + 1); + + // Build colors for each corner (clamped to 0-1) + auto clamp01 = [](float v) { return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v); }; + + vec4 c00(clamp01(rMap->values[idx00]), clamp01(gMap->values[idx00]), clamp01(bMap->values[idx00]), 1.0f); + vec4 c10(clamp01(rMap->values[idx10]), clamp01(gMap->values[idx10]), clamp01(bMap->values[idx10]), 1.0f); + vec4 c01(clamp01(rMap->values[idx01]), clamp01(gMap->values[idx01]), clamp01(bMap->values[idx01]), 1.0f); + vec4 c11(clamp01(rMap->values[idx11]), clamp01(gMap->values[idx11]), clamp01(bMap->values[idx11]), 1.0f); + + // Triangle 1: p00, p01, p10 (vertices 0, 1, 2) + vertices_[baseVertex + 0].color = c00; + vertices_[baseVertex + 1].color = c01; + vertices_[baseVertex + 2].color = c10; + + // Triangle 2: p10, p01, p11 (vertices 3, 4, 5) + vertices_[baseVertex + 3].color = c10; + vertices_[baseVertex + 4].color = c01; + vertices_[baseVertex + 5].color = c11; + } + } + + dirty_ = true; +} + +// ============================================================================= +// Clear +// ============================================================================= + +void MeshLayer::clear() { + vertices_.clear(); + heightData_.clear(); + heightmapWidth_ = 0; + heightmapHeight_ = 0; + dirty_ = true; +} + +// ============================================================================= +// GPU Upload +// ============================================================================= + +void MeshLayer::uploadToGPU() { +#ifdef MCRF_HAS_GL + if (!gl::isGLReady()) { + return; + } + + // Create VBO if needed + if (vbo_ == 0) { + glGenBuffers(1, &vbo_); + } + + // Upload vertex data + glBindBuffer(GL_ARRAY_BUFFER, vbo_); + if (!vertices_.empty()) { + glBufferData(GL_ARRAY_BUFFER, + vertices_.size() * sizeof(MeshVertex), + vertices_.data(), + GL_STATIC_DRAW); + } else { + glBufferData(GL_ARRAY_BUFFER, 0, nullptr, GL_STATIC_DRAW); + } + glBindBuffer(GL_ARRAY_BUFFER, 0); + + dirty_ = false; +#endif +} + +// ============================================================================= +// Rendering +// ============================================================================= + +void MeshLayer::render(const mat4& model, const mat4& view, const mat4& projection) { +#ifdef MCRF_HAS_GL + if (!gl::isGLReady() || vertices_.empty()) { + return; + } + + // Upload to GPU if needed + if (dirty_ || vbo_ == 0) { + uploadToGPU(); + } + + 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); +#endif +} + +// ============================================================================= +// Private Helpers +// ============================================================================= + +void MeshLayer::cleanupGPU() { +#ifdef MCRF_HAS_GL + if (vbo_ != 0 && gl::isGLReady()) { + glDeleteBuffers(1, &vbo_); + vbo_ = 0; + } +#endif +} + +vec3 MeshLayer::computeFaceNormal(const vec3& v0, const vec3& v1, const vec3& v2) { + vec3 edge1 = v1 - v0; + vec3 edge2 = v2 - v0; + return edge1.cross(edge2).normalized(); +} + +void MeshLayer::computeVertexNormals() { + // For terrain mesh, we can average normals at shared positions + // This is a simplified approach - works well for regular grids + + if (vertices_.empty() || heightmapWidth_ < 2 || heightmapHeight_ < 2) { + return; + } + + // Create a grid of accumulated normals for each heightmap point + std::vector accumulatedNormals(heightmapWidth_ * heightmapHeight_, vec3(0, 0, 0)); + std::vector normalCounts(heightmapWidth_ * heightmapHeight_, 0); + + // Each quad contributes to its 4 corners + int numQuadsX = heightmapWidth_ - 1; + int numQuadsZ = heightmapHeight_ - 1; + + for (int z = 0; z < numQuadsZ; z++) { + for (int x = 0; x < numQuadsX; x++) { + int quadIndex = z * numQuadsX + x; + int baseVertex = quadIndex * 6; + + // Get face normals from the two triangles + if (baseVertex + 5 < static_cast(vertices_.size())) { + vec3 n1 = vertices_[baseVertex].normal; + vec3 n2 = vertices_[baseVertex + 3].normal; + + // Corners: (x,z), (x+1,z), (x,z+1), (x+1,z+1) + int idx00 = z * heightmapWidth_ + x; + int idx10 = z * heightmapWidth_ + (x + 1); + int idx01 = (z + 1) * heightmapWidth_ + x; + int idx11 = (z + 1) * heightmapWidth_ + (x + 1); + + // Triangle 1 (p00, p01, p10) contributes n1 to those corners + accumulatedNormals[idx00] += n1; + normalCounts[idx00]++; + accumulatedNormals[idx01] += n1; + normalCounts[idx01]++; + accumulatedNormals[idx10] += n1; + normalCounts[idx10]++; + + // Triangle 2 (p10, p01, p11) contributes n2 to those corners + accumulatedNormals[idx10] += n2; + normalCounts[idx10]++; + accumulatedNormals[idx01] += n2; + normalCounts[idx01]++; + accumulatedNormals[idx11] += n2; + normalCounts[idx11]++; + } + } + } + + // Normalize accumulated normals + for (size_t i = 0; i < accumulatedNormals.size(); i++) { + if (normalCounts[i] > 0) { + accumulatedNormals[i] = accumulatedNormals[i].normalized(); + } else { + accumulatedNormals[i] = vec3(0, 1, 0); // Default up + } + } + + // Apply averaged normals back to vertices + for (int z = 0; z < numQuadsZ; z++) { + for (int x = 0; x < numQuadsX; x++) { + int quadIndex = z * numQuadsX + x; + int baseVertex = quadIndex * 6; + + int idx00 = z * heightmapWidth_ + x; + int idx10 = z * heightmapWidth_ + (x + 1); + int idx01 = (z + 1) * heightmapWidth_ + x; + int idx11 = (z + 1) * heightmapWidth_ + (x + 1); + + if (baseVertex + 5 < static_cast(vertices_.size())) { + // Triangle 1: p00, p01, p10 + vertices_[baseVertex + 0].normal = accumulatedNormals[idx00]; + vertices_[baseVertex + 1].normal = accumulatedNormals[idx01]; + vertices_[baseVertex + 2].normal = accumulatedNormals[idx10]; + + // Triangle 2: p10, p01, p11 + vertices_[baseVertex + 3].normal = accumulatedNormals[idx10]; + vertices_[baseVertex + 4].normal = accumulatedNormals[idx01]; + vertices_[baseVertex + 5].normal = accumulatedNormals[idx11]; + } + } + } +} + +} // namespace mcrf diff --git a/src/3d/MeshLayer.h b/src/3d/MeshLayer.h new file mode 100644 index 0000000..f098f0f --- /dev/null +++ b/src/3d/MeshLayer.h @@ -0,0 +1,173 @@ +// MeshLayer.h - Static 3D geometry layer for Viewport3D +// Supports terrain generation from HeightMap and height-based texture mapping + +#pragma once + +#include "Common.h" +#include "Math3D.h" +#include +#include +#include +#include // For TCOD_heightmap_t + +namespace mcrf { + +// ============================================================================= +// MeshVertex - Vertex format matching Viewport3D's shader attributes +// ============================================================================= + +struct MeshVertex { + vec3 position; // 12 bytes + vec2 texcoord; // 8 bytes + vec3 normal; // 12 bytes + vec4 color; // 16 bytes (RGBA as floats 0-1) + // Total: 48 bytes per vertex + + MeshVertex() + : position(0, 0, 0) + , texcoord(0, 0) + , normal(0, 1, 0) + , color(1, 1, 1, 1) + {} + + MeshVertex(const vec3& pos, const vec2& uv, const vec3& norm, const vec4& col) + : position(pos), texcoord(uv), normal(norm), color(col) + {} +}; + +// ============================================================================= +// TextureRange - Height-based texture selection from sprite sheet +// ============================================================================= + +struct TextureRange { + float minHeight; // Minimum normalized height (0-1) + float maxHeight; // Maximum normalized height (0-1) + int spriteIndex; // Index into sprite sheet + + TextureRange() : minHeight(0), maxHeight(1), spriteIndex(0) {} + TextureRange(float min, float max, int index) + : minHeight(min), maxHeight(max), spriteIndex(index) {} +}; + +// ============================================================================= +// MeshLayer - Container for static 3D geometry +// ============================================================================= + +class MeshLayer { +public: + MeshLayer(); + MeshLayer(const std::string& name, int zIndex = 0); + ~MeshLayer(); + + // No copy, allow move + MeshLayer(const MeshLayer&) = delete; + MeshLayer& operator=(const MeshLayer&) = delete; + MeshLayer(MeshLayer&& other) noexcept; + MeshLayer& operator=(MeshLayer&& other) noexcept; + + // ========================================================================= + // Core Properties + // ========================================================================= + + const std::string& getName() const { return name_; } + void setName(const std::string& name) { name_ = name; } + + int getZIndex() const { return zIndex_; } + void setZIndex(int z) { zIndex_ = z; } + + bool isVisible() const { return visible_; } + void setVisible(bool v) { visible_ = v; } + + // Texture (sprite sheet for height-based mapping) + void setTexture(sf::Texture* tex) { texture_ = tex; } + sf::Texture* getTexture() const { return texture_; } + + // Sprite sheet configuration (for texture ranges) + void setSpriteSheetLayout(int tilesPerRow, int tilesPerCol); + + // ========================================================================= + // Mesh Generation + // ========================================================================= + + /// Build terrain mesh from HeightMap + /// @param heightmap libtcod heightmap pointer + /// @param yScale Vertical exaggeration factor + /// @param cellSize World-space size of each grid cell + void buildFromHeightmap(TCOD_heightmap_t* heightmap, float yScale, float cellSize); + + /// Build a flat plane (for floors, water, etc.) + /// @param width World-space width (X axis) + /// @param depth World-space depth (Z axis) + /// @param y World-space height + void buildPlane(float width, float depth, float y = 0.0f); + + /// Apply height-based texture ranges + /// Updates vertex UVs based on stored height data + void applyTextureRanges(const std::vector& ranges); + + /// Apply per-vertex colors from RGB heightmaps + /// Each heightmap provides one color channel (values 0-1 map to intensity) + /// @param rMap Red channel heightmap (must match terrain dimensions) + /// @param gMap Green channel heightmap + /// @param bMap Blue channel heightmap + void applyColorMap(TCOD_heightmap_t* rMap, TCOD_heightmap_t* gMap, TCOD_heightmap_t* bMap); + + /// Clear all geometry + void clear(); + + // ========================================================================= + // GPU Upload and Rendering + // ========================================================================= + + /// Upload vertex data to GPU + /// Call after modifying vertices or when dirty_ flag is set + void uploadToGPU(); + + /// Render this layer + /// @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); + + /// Get model matrix (identity by default, override for positioned layers) + mat4 getModelMatrix() const { return modelMatrix_; } + void setModelMatrix(const mat4& m) { modelMatrix_ = m; } + + // ========================================================================= + // Statistics + // ========================================================================= + + size_t getVertexCount() const { return vertices_.size(); } + bool isDirty() const { return dirty_; } + +private: + // Identity + std::string name_; + int zIndex_ = 0; + bool visible_ = true; + + // Geometry data (CPU side) + std::vector vertices_; + std::vector heightData_; // Original heights for texture range re-application + int heightmapWidth_ = 0; + int heightmapHeight_ = 0; + + // GPU resources + unsigned int vbo_ = 0; + bool dirty_ = false; // Needs GPU re-upload + + // Texture + sf::Texture* texture_ = nullptr; // Not owned + int tilesPerRow_ = 1; + int tilesPerCol_ = 1; + + // Transform + mat4 modelMatrix_ = mat4::identity(); + + // Helper methods + void cleanupGPU(); + vec3 computeFaceNormal(const vec3& v0, const vec3& v1, const vec3& v2); + void computeVertexNormals(); +}; + +} // namespace mcrf diff --git a/src/3d/Viewport3D.cpp b/src/3d/Viewport3D.cpp index c275ec6..0762768 100644 --- a/src/3d/Viewport3D.cpp +++ b/src/3d/Viewport3D.cpp @@ -2,6 +2,7 @@ #include "Viewport3D.h" #include "Shader3D.h" +#include "MeshLayer.h" #include "../platform/GLContext.h" #include "PyVector.h" #include "PyColor.h" @@ -9,8 +10,11 @@ #include "McRFPy_Doc.h" #include "PythonObjectCache.h" #include "McRFPy_API.h" +#include "PyHeightMap.h" #include #include +#include +#include // Include appropriate GL headers based on backend #if defined(MCRF_SDL2) @@ -154,6 +158,58 @@ void Viewport3D::setFogRange(float nearDist, float farDist) { fogFar_ = farDist; } +// ============================================================================= +// Camera Helpers +// ============================================================================= + +void Viewport3D::orbitCamera(float angle, float distance, float height) { + float x = std::cos(angle) * distance; + float z = std::sin(angle) * distance; + camera_.setPosition(vec3(x, height, z)); + camera_.setTarget(vec3(0, 0, 0)); +} + +// ============================================================================= +// Mesh Layer Management +// ============================================================================= + +std::shared_ptr Viewport3D::addLayer(const std::string& name, int zIndex) { + // Check if layer with this name already exists + for (auto& layer : meshLayers_) { + if (layer->getName() == name) { + return layer; // Return existing layer + } + } + + // Create new layer + auto layer = std::make_shared(name, zIndex); + meshLayers_.push_back(layer); + + // Disable test cube when layers are added + renderTestCube_ = false; + + return layer; +} + +std::shared_ptr Viewport3D::getLayer(const std::string& name) { + for (auto& layer : meshLayers_) { + if (layer->getName() == name) { + return layer; + } + } + return nullptr; +} + +bool Viewport3D::removeLayer(const std::string& name) { + for (auto it = meshLayers_.begin(); it != meshLayers_.end(); ++it) { + if ((*it)->getName() == name) { + meshLayers_.erase(it); + return true; + } + } + return false; +} + // ============================================================================= // FBO Management // ============================================================================= @@ -270,6 +326,66 @@ void Viewport3D::cleanupTestGeometry() { // 3D Rendering // ============================================================================= +void Viewport3D::renderMeshLayers() { +#ifdef MCRF_HAS_GL + if (meshLayers_.empty() || !shader_ || !shader_->isValid()) { + return; + } + + // Sort layers by z_index (lower = rendered first) + std::vector sortedLayers; + sortedLayers.reserve(meshLayers_.size()); + for (auto& layer : meshLayers_) { + if (layer && layer->isVisible()) { + sortedLayers.push_back(layer.get()); + } + } + std::sort(sortedLayers.begin(), sortedLayers.end(), + [](const MeshLayer* a, const MeshLayer* b) { + return a->getZIndex() < b->getZIndex(); + }); + + shader_->bind(); + + // Set up view and projection matrices (same for all layers) + mat4 view = camera_.getViewMatrix(); + mat4 projection = camera_.getProjectionMatrix(); + + shader_->setUniform("u_view", view); + shader_->setUniform("u_projection", projection); + + // PS1 effect uniforms + shader_->setUniform("u_resolution", vec2(static_cast(internalWidth_), + static_cast(internalHeight_))); + shader_->setUniform("u_enable_snap", vertexSnapEnabled_); + shader_->setUniform("u_enable_dither", ditheringEnabled_); + + // Lighting + vec3 lightDir = vec3(0.5f, -0.7f, 0.5f).normalized(); + shader_->setUniform("u_light_dir", lightDir); + shader_->setUniform("u_ambient", vec3(0.3f, 0.3f, 0.3f)); + + // Fog + shader_->setUniform("u_fog_start", fogNear_); + shader_->setUniform("u_fog_end", fogFar_); + shader_->setUniform("u_fog_color", fogColor_); + + // For now, no textures on terrain (use vertex colors) + shader_->setUniform("u_has_texture", false); + + // Render each layer + 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); + } + + shader_->unbind(); +#endif +} + void Viewport3D::render3DContent() { // GL not available in current backend - skip 3D rendering if (!gl::isGLReady() || fbo_ == 0) { @@ -297,8 +413,11 @@ void Viewport3D::render3DContent() { // Update test rotation for spinning geometry testRotation_ += 0.02f; - // Render test cube if shader and geometry are ready - if (shader_ && shader_->isValid() && testVBO_ != 0) { + // Render mesh layers first (terrain, etc.) - sorted by z_index + renderMeshLayers(); + + // Render test cube if enabled (disabled when layers are added) + if (renderTestCube_ && shader_ && shader_->isValid() && testVBO_ != 0) { shader_->bind(); // Set up matrices @@ -947,6 +1066,178 @@ int Viewport3D::init(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { return 0; } +// ============================================================================= +// Python Methods for Layer Management +// ============================================================================= + +static PyObject* Viewport3D_add_layer(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"name", "z_index", NULL}; + const char* name = nullptr; + int z_index = 0; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|i", const_cast(kwlist), &name, &z_index)) { + return NULL; + } + + auto layer = self->data->addLayer(name, z_index); + if (!layer) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create layer"); + return NULL; + } + + // Return a dictionary with layer info (simple approach) + // TODO: Create proper PyMeshLayer type for full API + return Py_BuildValue("{s:s, s:i, s:i, s:n}", + "name", layer->getName().c_str(), + "z_index", layer->getZIndex(), + "vertex_count", static_cast(layer->getVertexCount()), + "layer_ptr", reinterpret_cast(layer.get())); +} + +static PyObject* Viewport3D_get_layer(PyViewport3DObject* self, PyObject* args) { + const char* name = nullptr; + if (!PyArg_ParseTuple(args, "s", &name)) { + return NULL; + } + + auto layer = self->data->getLayer(name); + if (!layer) { + Py_RETURN_NONE; + } + + return Py_BuildValue("{s:s, s:i, s:i, s:n}", + "name", layer->getName().c_str(), + "z_index", layer->getZIndex(), + "vertex_count", static_cast(layer->getVertexCount()), + "layer_ptr", reinterpret_cast(layer.get())); +} + +static PyObject* Viewport3D_remove_layer(PyViewport3DObject* self, PyObject* args) { + const char* name = nullptr; + if (!PyArg_ParseTuple(args, "s", &name)) { + return NULL; + } + + bool removed = self->data->removeLayer(name); + return PyBool_FromLong(removed); +} + +static PyObject* Viewport3D_orbit_camera(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"angle", "distance", "height", NULL}; + float angle = 0.0f; + float distance = 10.0f; + float height = 5.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|fff", const_cast(kwlist), + &angle, &distance, &height)) { + return NULL; + } + + self->data->orbitCamera(angle, distance, height); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_build_terrain(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"layer_name", "heightmap", "y_scale", "cell_size", NULL}; + const char* layer_name = nullptr; + PyObject* heightmap_obj = nullptr; + float y_scale = 1.0f; + float cell_size = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sO|ff", const_cast(kwlist), + &layer_name, &heightmap_obj, &y_scale, &cell_size)) { + return NULL; + } + + // Get or create the layer + auto layer = self->data->getLayer(layer_name); + if (!layer) { + layer = self->data->addLayer(layer_name, 0); + } + + // Check if heightmap_obj is a PyHeightMapObject + // Get the HeightMap type from the module + PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap"); + if (!heightmap_type) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found"); + return NULL; + } + + if (!PyObject_IsInstance(heightmap_obj, heightmap_type)) { + Py_DECREF(heightmap_type); + PyErr_SetString(PyExc_TypeError, "heightmap must be a HeightMap object"); + return NULL; + } + Py_DECREF(heightmap_type); + + // Get the TCOD heightmap pointer from the Python object + PyHeightMapObject* hm = reinterpret_cast(heightmap_obj); + if (!hm->heightmap) { + PyErr_SetString(PyExc_ValueError, "HeightMap has no data"); + return NULL; + } + + // Build the terrain mesh + layer->buildFromHeightmap(hm->heightmap, y_scale, cell_size); + + return Py_BuildValue("i", static_cast(layer->getVertexCount())); +} + +static PyObject* Viewport3D_apply_terrain_colors(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"layer_name", "r_map", "g_map", "b_map", NULL}; + const char* layer_name = nullptr; + PyObject* r_obj = nullptr; + PyObject* g_obj = nullptr; + PyObject* b_obj = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOOO", const_cast(kwlist), + &layer_name, &r_obj, &g_obj, &b_obj)) { + return NULL; + } + + // Get the layer + auto layer = self->data->getLayer(layer_name); + if (!layer) { + PyErr_Format(PyExc_ValueError, "Layer '%s' not found", layer_name); + return NULL; + } + + // Validate all three are HeightMap objects + PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap"); + if (!heightmap_type) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found"); + return NULL; + } + + if (!PyObject_IsInstance(r_obj, heightmap_type) || + !PyObject_IsInstance(g_obj, heightmap_type) || + !PyObject_IsInstance(b_obj, heightmap_type)) { + Py_DECREF(heightmap_type); + PyErr_SetString(PyExc_TypeError, "r_map, g_map, and b_map must all be HeightMap objects"); + return NULL; + } + Py_DECREF(heightmap_type); + + // Get the TCOD heightmap pointers + PyHeightMapObject* r_hm = reinterpret_cast(r_obj); + PyHeightMapObject* g_hm = reinterpret_cast(g_obj); + PyHeightMapObject* b_hm = reinterpret_cast(b_obj); + + if (!r_hm->heightmap || !g_hm->heightmap || !b_hm->heightmap) { + PyErr_SetString(PyExc_ValueError, "One or more HeightMap objects have no data"); + return NULL; + } + + // Apply the color map + layer->applyColorMap(r_hm->heightmap, g_hm->heightmap, b_hm->heightmap); + + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_layer_count(PyViewport3DObject* self, PyObject* Py_UNUSED(args)) { + return PyLong_FromSize_t(self->data->getLayerCount()); +} + } // namespace mcrf // Methods array - outside namespace but PyObjectType still in scope via typedef @@ -954,5 +1245,46 @@ typedef PyViewport3DObject PyObjectType; PyMethodDef Viewport3D_methods[] = { UIDRAWABLE_METHODS, + {"add_layer", (PyCFunction)mcrf::Viewport3D_add_layer, METH_VARARGS | METH_KEYWORDS, + "add_layer(name, z_index=0) -> dict\n\n" + "Add a new mesh layer to the viewport.\n\n" + "Args:\n" + " name: Unique identifier for the layer\n" + " z_index: Render order (lower = rendered first)"}, + {"get_layer", (PyCFunction)mcrf::Viewport3D_get_layer, METH_VARARGS, + "get_layer(name) -> dict or None\n\n" + "Get a layer by name."}, + {"remove_layer", (PyCFunction)mcrf::Viewport3D_remove_layer, METH_VARARGS, + "remove_layer(name) -> bool\n\n" + "Remove a layer by name. Returns True if found and removed."}, + {"orbit_camera", (PyCFunction)mcrf::Viewport3D_orbit_camera, METH_VARARGS | METH_KEYWORDS, + "orbit_camera(angle=0, distance=10, height=5)\n\n" + "Position camera to orbit around origin.\n\n" + "Args:\n" + " angle: Orbit angle in radians\n" + " distance: Distance from origin\n" + " height: Camera height above XZ plane"}, + {"build_terrain", (PyCFunction)mcrf::Viewport3D_build_terrain, METH_VARARGS | METH_KEYWORDS, + "build_terrain(layer_name, heightmap, y_scale=1.0, cell_size=1.0) -> int\n\n" + "Build terrain mesh from HeightMap on specified layer.\n\n" + "Args:\n" + " layer_name: Name of layer to build terrain on (created if doesn't exist)\n" + " heightmap: HeightMap object with height data\n" + " y_scale: Vertical exaggeration factor\n" + " cell_size: World-space size of each grid cell\n\n" + "Returns:\n" + " Number of vertices in the generated mesh"}, + {"apply_terrain_colors", (PyCFunction)mcrf::Viewport3D_apply_terrain_colors, METH_VARARGS | METH_KEYWORDS, + "apply_terrain_colors(layer_name, r_map, g_map, b_map)\n\n" + "Apply per-vertex colors to terrain from RGB HeightMaps.\n\n" + "Args:\n" + " layer_name: Name of terrain layer to colorize\n" + " r_map: HeightMap for red channel (0-1 values)\n" + " g_map: HeightMap for green channel (0-1 values)\n" + " b_map: HeightMap for blue channel (0-1 values)\n\n" + "All HeightMaps must match the terrain's original dimensions."}, + {"layer_count", (PyCFunction)mcrf::Viewport3D_layer_count, METH_NOARGS, + "layer_count() -> int\n\n" + "Get the number of mesh layers."}, {NULL} // Sentinel }; diff --git a/src/3d/Viewport3D.h b/src/3d/Viewport3D.h index c9931e6..ab6b5b9 100644 --- a/src/3d/Viewport3D.h +++ b/src/3d/Viewport3D.h @@ -12,12 +12,15 @@ #include "Math3D.h" #include "Camera3D.h" #include +#include +#include namespace mcrf { // Forward declarations class Viewport3D; class Shader3D; +class MeshLayer; } // namespace mcrf @@ -69,6 +72,33 @@ public: vec3 getCameraPosition() const { return camera_.getPosition(); } vec3 getCameraTarget() const { return camera_.getTarget(); } + // Camera orbit helper for demos + void orbitCamera(float angle, float distance, float height); + + // ========================================================================= + // Mesh Layer Management + // ========================================================================= + + /// Add a new mesh layer + /// @param name Unique identifier for the layer + /// @param zIndex Render order (lower = rendered first, behind higher values) + /// @return Pointer to the new layer (owned by Viewport3D) + std::shared_ptr addLayer(const std::string& name, int zIndex = 0); + + /// Get a layer by name + /// @return Pointer to layer, or nullptr if not found + std::shared_ptr getLayer(const std::string& name); + + /// Remove a layer by name + /// @return true if layer was found and removed + bool removeLayer(const std::string& name); + + /// Get all layers (read-only) + const std::vector>& getLayers() const { return meshLayers_; } + + /// Get number of layers + size_t getLayerCount() const { return meshLayers_.size(); } + // Background color void setBackgroundColor(const sf::Color& color) { bgColor_ = color; } sf::Color getBackgroundColor() const { return bgColor_; } @@ -137,8 +167,12 @@ private: float fogNear_ = 10.0f; float fogFar_ = 100.0f; - // Render test geometry (temporary until Entity3D/MeshLayer added) + // Render test geometry (temporary, will be replaced by layers) float testRotation_ = 0.0f; + bool renderTestCube_ = true; // Set to false when layers are added + + // Mesh layers for terrain, static geometry + std::vector> meshLayers_; // Shader for PS1-style rendering std::unique_ptr shader_; @@ -162,6 +196,9 @@ private: // Render 3D content to FBO void render3DContent(); + // Render all mesh layers + void renderMeshLayers(); + // Blit FBO to screen void blitToScreen(sf::Vector2f offset, sf::RenderTarget& target); }; diff --git a/tests/demo/screens/terrain_demo.py b/tests/demo/screens/terrain_demo.py new file mode 100644 index 0000000..161248b --- /dev/null +++ b/tests/demo/screens/terrain_demo.py @@ -0,0 +1,320 @@ +# terrain_demo.py - Visual demo of terrain system +# Shows procedurally generated 3D terrain using HeightMap + Viewport3D + +import mcrfpy +import sys +import math + +# Create demo scene +scene = mcrfpy.Scene("terrain_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="Terrain System Demo - HeightMap to 3D Mesh", 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=(30.0, 20.0, 30.0), + camera_target=(20.0, 0.0, 20.0), + bg_color=mcrfpy.Color(100, 150, 200) # Sky blue background +) +scene.children.append(viewport) + +# Generate terrain using HeightMap +print("Generating terrain heightmap...") +hm = mcrfpy.HeightMap((40, 40)) + +# Use midpoint displacement for natural-looking terrain +hm.mid_point_displacement(0.5, seed=42) +hm.normalize(0.0, 1.0) + +# Optional: Add some erosion for more realistic terrain +hm.rain_erosion(drops=1000, erosion=0.08, sedimentation=0.04, seed=42) +hm.normalize(0.0, 1.0) + +# Build terrain mesh from heightmap +print("Building terrain mesh...") +vertex_count = viewport.build_terrain( + layer_name="terrain", + heightmap=hm, + y_scale=8.0, # Vertical exaggeration + cell_size=1.0 # World-space grid cell size +) +print(f"Terrain built with {vertex_count} vertices") + +# Create color maps for terrain (decoupled from height) +# This demonstrates using separate HeightMaps for R, G, B channels +print("Creating terrain color maps...") +r_map = mcrfpy.HeightMap((40, 40)) +g_map = mcrfpy.HeightMap((40, 40)) +b_map = mcrfpy.HeightMap((40, 40)) + +# Generate a "moisture" map using different noise +moisture = mcrfpy.HeightMap((40, 40)) +moisture.mid_point_displacement(0.6, seed=999) +moisture.normalize(0.0, 1.0) + +# Color based on height + moisture combination: +# Low + wet = water blue, Low + dry = sand yellow +# High + wet = grass green, High + dry = rock brown/snow white +for y in range(40): + for x in range(40): + h = hm[x, y] # Height (0-1) + m = moisture[x, y] # Moisture (0-1) + + if h < 0.3: # Low elevation + if m > 0.5: # Wet = water + r_map[x, y] = 0.2 + g_map[x, y] = 0.4 + b_map[x, y] = 0.8 + else: # Dry = sand + r_map[x, y] = 0.9 + g_map[x, y] = 0.8 + b_map[x, y] = 0.5 + elif h < 0.6: # Mid elevation + if m > 0.4: # Wet = grass + r_map[x, y] = 0.2 + h * 0.3 + g_map[x, y] = 0.5 + m * 0.3 + b_map[x, y] = 0.1 + else: # Dry = dirt + r_map[x, y] = 0.5 + g_map[x, y] = 0.35 + b_map[x, y] = 0.2 + else: # High elevation + if h > 0.85: # Snow caps + r_map[x, y] = 0.95 + g_map[x, y] = 0.95 + b_map[x, y] = 1.0 + else: # Rock + r_map[x, y] = 0.5 + g_map[x, y] = 0.45 + b_map[x, y] = 0.4 + +# Apply colors to terrain +viewport.apply_terrain_colors("terrain", r_map, g_map, b_map) +print("Terrain colors applied") + +# Info panel on the right +info_panel = mcrfpy.Frame(pos=(670, 60), size=(330, 450), + fill_color=mcrfpy.Color(30, 30, 40), + outline_color=mcrfpy.Color(80, 80, 100), + outline=2.0) +scene.children.append(info_panel) + +# Panel title +panel_title = mcrfpy.Caption(text="Terrain Properties", pos=(690, 70)) +panel_title.fill_color = mcrfpy.Color(200, 200, 255) +scene.children.append(panel_title) + +# Property labels +props = [ + ("HeightMap Size:", f"{hm.size[0]}x{hm.size[1]}"), + ("Vertex Count:", f"{vertex_count}"), + ("Y Scale:", "8.0"), + ("Cell Size:", "1.0"), + ("", ""), + ("Generation:", ""), + (" Algorithm:", "Midpoint Displacement"), + (" Roughness:", "0.5"), + (" Erosion:", "1000 drops"), + ("", ""), + ("Layer Count:", f"{viewport.layer_count()}"), +] + +y_offset = 100 +for label, value in props: + if label: + cap = mcrfpy.Caption(text=f"{label} {value}", pos=(690, y_offset)) + cap.fill_color = mcrfpy.Color(180, 180, 200) + scene.children.append(cap) + y_offset += 22 + +# Instructions at bottom +instructions = mcrfpy.Caption( + text="[Space] Orbit | [C] Colors | [1-4] PS1 effects | [+/-] Height | [ESC] Quit", + pos=(20, 530) +) +instructions.fill_color = mcrfpy.Color(150, 150, 150) +scene.children.append(instructions) + +# Status line +status = mcrfpy.Caption(text="Status: Terrain rendering with PS1 effects", pos=(20, 555)) +status.fill_color = mcrfpy.Color(100, 200, 100) +scene.children.append(status) + +# Animation state +animation_time = [0.0] +camera_orbit = [True] +terrain_height = [8.0] +colors_enabled = [True] + +# White color map for "no colors" mode +white_r = mcrfpy.HeightMap((40, 40)) +white_g = mcrfpy.HeightMap((40, 40)) +white_b = mcrfpy.HeightMap((40, 40)) +white_r.fill(1.0) +white_g.fill(1.0) +white_b.fill(1.0) + +# Camera orbit animation +def update_camera(timer, runtime): + animation_time[0] += runtime / 1000.0 + + if camera_orbit[0]: + # Orbit camera around terrain center + angle = animation_time[0] * 0.3 # Slow rotation + radius = 35.0 + center_x = 20.0 + center_z = 20.0 + height = 15.0 + math.sin(animation_time[0] * 0.2) * 5.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, 2.0, center_z) + +# Key handler +def on_key(key, state): + if state != mcrfpy.InputState.PRESSED: + return + + # Toggle PS1 effects with number keys + if key == mcrfpy.Key.NUM_1: + viewport.enable_vertex_snap = not viewport.enable_vertex_snap + status.text = f"Vertex Snap: {'ON' if viewport.enable_vertex_snap else 'OFF'}" + elif key == mcrfpy.Key.NUM_2: + viewport.enable_affine = not viewport.enable_affine + status.text = f"Affine Mapping: {'ON' if viewport.enable_affine else 'OFF'}" + elif key == mcrfpy.Key.NUM_3: + viewport.enable_dither = not viewport.enable_dither + status.text = f"Dithering: {'ON' if viewport.enable_dither else 'OFF'}" + elif key == mcrfpy.Key.NUM_4: + viewport.enable_fog = not viewport.enable_fog + status.text = f"Fog: {'ON' if viewport.enable_fog else 'OFF'}" + + # Toggle terrain colors + elif key == mcrfpy.Key.C: + colors_enabled[0] = not colors_enabled[0] + if colors_enabled[0]: + viewport.apply_terrain_colors("terrain", r_map, g_map, b_map) + status.text = "Terrain colors: ON (height + moisture)" + else: + viewport.apply_terrain_colors("terrain", white_r, white_g, white_b) + status.text = "Terrain colors: OFF (white)" + + # Camera controls + elif key == mcrfpy.Key.SPACE: + camera_orbit[0] = not camera_orbit[0] + status.text = f"Camera orbit: {'ON' if camera_orbit[0] else 'OFF (WASD/QE to move)'}" + + # Height adjustment + elif key == mcrfpy.Key.EQUAL: # + key + terrain_height[0] += 1.0 + rebuild_terrain() + elif key == mcrfpy.Key.HYPHEN: # - key + terrain_height[0] = max(1.0, terrain_height[0] - 1.0) + rebuild_terrain() + + elif key == mcrfpy.Key.ESCAPE: + mcrfpy.exit() + + # Manual camera movement (when orbit is off) + if not camera_orbit[0]: + pos = list(viewport.camera_pos) + target = list(viewport.camera_target) + speed = 2.0 + + if key == mcrfpy.Key.W: + # Move forward (toward target) + dx = target[0] - pos[0] + dz = target[2] - pos[2] + length = math.sqrt(dx*dx + dz*dz) + if length > 0.01: + pos[0] += (dx / length) * speed + pos[2] += (dz / length) * speed + target[0] += (dx / length) * speed + target[2] += (dz / length) * speed + elif key == mcrfpy.Key.S: + # Move backward + dx = target[0] - pos[0] + dz = target[2] - pos[2] + length = math.sqrt(dx*dx + dz*dz) + if length > 0.01: + pos[0] -= (dx / length) * speed + pos[2] -= (dz / length) * speed + target[0] -= (dx / length) * speed + target[2] -= (dz / length) * speed + elif key == mcrfpy.Key.A: + # Strafe left + dx = target[0] - pos[0] + dz = target[2] - pos[2] + # Perpendicular direction + pos[0] -= dz / math.sqrt(dx*dx + dz*dz) * speed + pos[2] += dx / math.sqrt(dx*dx + dz*dz) * speed + target[0] -= dz / math.sqrt(dx*dx + dz*dz) * speed + target[2] += dx / math.sqrt(dx*dx + dz*dz) * speed + elif key == mcrfpy.Key.D: + # Strafe right + dx = target[0] - pos[0] + dz = target[2] - pos[2] + pos[0] += dz / math.sqrt(dx*dx + dz*dz) * speed + pos[2] -= dx / math.sqrt(dx*dx + dz*dz) * speed + target[0] += dz / math.sqrt(dx*dx + dz*dz) * speed + target[2] -= dx / math.sqrt(dx*dx + dz*dz) * speed + elif key == mcrfpy.Key.Q: + # Move down + pos[1] -= speed + elif key == mcrfpy.Key.E: + # Move up + pos[1] += speed + + viewport.camera_pos = tuple(pos) + viewport.camera_target = tuple(target) + status.text = f"Camera: ({pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f})" + +def rebuild_terrain(): + """Rebuild terrain with new height scale""" + global vertex_count + vertex_count = viewport.build_terrain( + layer_name="terrain", + heightmap=hm, + y_scale=terrain_height[0], + cell_size=1.0 + ) + # Reapply colors after rebuild + if colors_enabled[0]: + viewport.apply_terrain_colors("terrain", r_map, g_map, b_map) + else: + viewport.apply_terrain_colors("terrain", white_r, white_g, white_b) + status.text = f"Terrain rebuilt: height scale = {terrain_height[0]}" + +# Set up scene +scene.on_key = on_key + +# Create timer for camera animation +timer = mcrfpy.Timer("camera_update", update_camera, 16) # ~60fps + +# Activate scene +mcrfpy.current_scene = scene + +print() +print("Terrain Demo loaded!") +print("A 40x40 heightmap has been converted to 3D terrain mesh.") +print("Terrain colored using separate R/G/B HeightMaps based on height + moisture.") +print() +print("Controls:") +print(" [C] Toggle terrain colors") +print(" [1-4] Toggle PS1 effects") +print(" [Space] Toggle camera orbit") +print(" [+/-] Adjust terrain height scale") +print(" [ESC] Quit")