From e571651ac6310adb345a5cec6377e2b990f9f184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Tue, 25 Aug 2026 00:02:54 +0800 Subject: [PATCH 1/2] feat(map): add production tilemap foundations --- cmake/module_manifest.cmake | 6 +- .../2026-08-24-map-production-readiness.md | 64 +++++ docs/usr/modules/map.md | 60 ++++- src/modules/editor/Editor.cpp | 6 + src/modules/editor/Editor.h | 10 + src/modules/editor/FieldTargets.cpp | 24 ++ src/modules/editor/FieldTargets.h | 25 ++ src/modules/map/Fov.cpp | 3 + src/modules/map/Map.cpp | 104 ++++++++- src/modules/map/Map.h | 12 + src/modules/map/Pathfinder.cpp | 5 +- src/modules/map/TileCollision.h | 27 +++ src/modules/map/TileConfig.cpp | 105 ++++++++- src/modules/map/TileLayer.cpp | 221 +++++++++++++++++- src/modules/map/TileLayer.h | 66 ++++++ src/modules/map/TileSystem.cpp | 186 +++++++++++---- src/modules/map/TileSystem.h | 7 + test/editor.cpp | 25 ++ test/map.cpp | 142 +++++++++++ test/map_fov.cpp | 16 ++ test/map_path.cpp | 20 ++ 21 files changed, 1072 insertions(+), 62 deletions(-) create mode 100644 docs/dev/superpowers/specs/2026-08-24-map-production-readiness.md create mode 100644 src/modules/map/TileCollision.h diff --git a/cmake/module_manifest.cmake b/cmake/module_manifest.cmake index 95153112..15d75df7 100644 --- a/cmake/module_manifest.cmake +++ b/cmake/module_manifest.cmake @@ -84,9 +84,9 @@ eve_declare_module(NAME crowd LAYER 0 SCRIPT Crowd SLOT crowd GROUP 2d 3d web) eve_declare_module(NAME ik LIB EVIK LAYER 0 SCRIPT IK GROUP 2d 3d web) -eve_declare_module(NAME editor LAYER 6 SCRIPT Editor SLOT editor - GROUP 3d web - OPTIONAL_DEPS procgen) + eve_declare_module(NAME editor LAYER 6 SCRIPT Editor SLOT editor + GROUP 3d web + OPTIONAL_DEPS procgen map) eve_declare_module(NAME plugins LAYER 0 SCRIPT Plugins GROUP 3d) eve_declare_module(NAME database LAYER 0 SCRIPT Database diff --git a/docs/dev/superpowers/specs/2026-08-24-map-production-readiness.md b/docs/dev/superpowers/specs/2026-08-24-map-production-readiness.md new file mode 100644 index 00000000..db58091f --- /dev/null +++ b/docs/dev/superpowers/specs/2026-08-24-map-production-readiness.md @@ -0,0 +1,64 @@ +# Map production-readiness design + +## Goal + +Move `map` from a capable small-map runtime to a production tilemap stack without +breaking existing Squirrel games. The target is cost proportional to visible or +dirty chunks, a versioned TileSet data model, automatic derived-data invalidation, +and a level-authoring loop that does not require hand-editing JSON. + +## Compatibility boundary + +- `TileLayer` remains the script facade and fixed maps keep their current coordinates. +- GID 0 remains empty and Tiled transform bits remain preserved in storage. +- Rendering, physics and editor integrations cross module boundaries through + capabilities; `map` must not add upward includes. +- Existing `eve.tileset/1` manifests remain loadable. Version 2 fields are optional. + +## Stage 0: measurable baseline + +The regression fixture uses 1024 x 1024 maps in sparse and dense forms. Required +counters are total chunks, non-empty chunks, visited chunks, visited cells and +emitted tiles. CPU timing is informative locally; CI gates deterministic visit +counts so machine variance cannot create flakes. + +Acceptance criteria: + +- an empty 1024 x 1024 layer scans zero cells; +- a sparse layer scans only its occupied 32 x 32 chunks; +- a camera-bound layer rejects chunks outside the viewport; +- bulk edits publish one monotonic tile revision. + +## Stage 1: chunked runtime + +`TileLayer::Tiles` owns a 32 x 32 spatial index beside the compatibility GID array. +All mutations update chunk occupancy and a monotonic revision. Rendering iterates +non-empty chunks and performs conservative chunk/AABB viewport rejection before +cell collection. A later sparse/infinite backend can replace the dense array behind +the same facade. + +## Stage 2: TileSet v2 + +Tile definitions gain typed custom data, animation alternatives, terrain/Wang +connectivity and optional collision/navigation/occlusion shapes. Runtime terrain +painting resolves a region plus its one-cell neighborhood deterministically. + +## Stage 3: derived systems + +Pathfinder and FOV remember the bound layer revision and resync automatically before +queries or compute. Physics integration is supplied by a capability implemented by +the physics module; map emits dirty tile regions and never includes physics headers. + +## Stage 4: authoring + +The editor supplies palette, brush, rectangle, fill, erase, eyedropper and undo/redo +commands over a shared `TileEditCommand`. Tiled import expands to infinite chunks, +external tilesets, groups, typed properties, Wang sets and animation with actionable +diagnostics and schema migration. + +## Delivery and gates + +Each stage is independently reviewable but the public interface, backends, consumers, +documentation and tests ship together. Required gates are the focused map tests, +`check_bindings.py --strict`, module layering, changed-line formatting and an actual +`iso-grid-walk` runtime smoke with a captured frame. diff --git a/docs/usr/modules/map.md b/docs/usr/modules/map.md index f1d695f1..b91be4dd 100644 --- a/docs/usr/modules/map.md +++ b/docs/usr/modules/map.md @@ -26,7 +26,48 @@ map.render(gfx); ### 运行时修改瓦片 -先用地图坐标换算接口把世界位置转成格子,再 `setTile(x, y, gid)`;批量生成地图时先 resize,再填充,避免重复重建图层。0 通常表示空瓦片。改瓦片后若已创建 Pathfinder,调用 `syncFromLayer()`。 +先用地图坐标换算接口把世界位置转成格子,再 `setTile(x, y, gid)`;批量生成地图时先 resize,再填充,避免重复重建图层。0 通常表示空瓦片。 + +`fillRect(x, y, width, height, gid)` 会裁剪到地图边界,并把整次区域修改发布为一个 revision。绑定到图层的 Pathfinder 与 Fov 会在下一次查询或 `compute()` 时根据 revision 自动同步;通常不再需要手动调用 `syncFromLayer()`。 + +```squirrel +layer.fillRect(8, 8, 16, 12, 4); +print(layer.getRevision() + " chunks=" + layer.getNonEmptyChunkCount() + + "/" + layer.getChunkCount() + " size=" + layer.getChunkSize() + "\n"); +``` + +地图内部以 32×32 chunk 建立空间索引。带 Camera2D 的正常 `map.render(gfx)` 会先按视口剔除 chunk;可通过 `map.getLastVisitedChunkCount()`、`map.getLastVisitedCellCount()` 和现有的可见瓦片统计检查本帧工作量。 + +### TileSet v2 数据、动画与 terrain + +运行时可以逐帧建立动画,并为 GID 写入保留类型的自定义数据: + +```squirrel +layer.clearTileAnimation(20); +layer.addTileAnimationFrame(20, 21, 100); +layer.addTileAnimationFrame(20, 22, 140); +print(layer.getTileAnimationFrameCount(20) + "\n"); + +layer.setTileDataString(20, "biome", "marsh"); +layer.setTileDataNumber(20, "damage", 2.5); +layer.setTileDataBool(20, "wet", true); +print(layer.getTileDataType(20, "damage") + " " + + layer.getTileDataString(20, "biome") + " " + + layer.getTileDataNumber(20, "damage") + " " + + layer.getTileDataBool(20, "wet") + "\n"); +``` + +terrain rule 使用 8 位邻接掩码(从西北开始顺时针)。绘制一个 terrain 会重新解析目标及其一格邻域: + +```squirrel +layer.clearTerrainRules(); +layer.setTerrainRule(30, 1, 0); // 孤立草地 +layer.setTerrainRule(31, 1, 1 << 3); // 东侧相连 +layer.paintTerrain(4, 4, 1); +print(layer.getTerrain(4, 4) + "\n"); +``` + +Tiled JSON 的 `animation`、typed `properties` 与无限地图 `chunks` 可直接导入。无限地图当前会按已存在 chunk 的包围盒规范化为运行时图层,并把负 chunk 坐标折算进图层 origin。 ### 组合项目自己的 2.5D 资产工作流 @@ -141,6 +182,22 @@ map.resolveDualGrid(logic, display); // 半步偏移 + 15 片选瓦 `resolveDualGridFilled(logic, display, filledGid)` 只把指定 GID 当作填充。逻辑层可继续用于碰撞 / 寻路;默认会 `setVisible(false)`。 +### 碰撞几何 + +`publishCollision(layer)` 会把 `setTileMetadata(..., walkable=false)` 标记的正交瓦片贪心合并成世界坐标矩形,减少静态碰撞体数量;已注册的物理/项目适配器会通过 `ITileCollisionSink` 一次性收到替换后的几何。没有适配器时仍可读取矩形,自行创建物理夹具: + +```squirrel +local count = map.publishCollision(layer); +for (local i = 0; i < count; ++i) { + local x = map.getCollisionRectX(i); + local y = map.getCollisionRectY(i); + local w = map.getCollisionRectWidth(i); + local h = map.getCollisionRectHeight(i); +} +``` + +当前自动合并只支持正交层;等距、交错和六角层返回 0,项目应使用多边形适配器,避免把菱形误近似成轴对齐矩形。 + ## 常见问题 - GID 与图集编号混淆:0 为空,其余值遵循 tileset 映射;默认空瓦片不可走(`setBlockEmpty`)。 @@ -156,6 +213,7 @@ map.resolveDualGrid(logic, display); // 半步偏移 + 15 片选瓦 - `applyConfig()`、`clear()`、`depthYAt()`、`fill()`、`getAutoReload()`、`getConfigPath()`、`getLayer()`、`getLayerCount()` - `getMapHeight()`、`getMapWidth()`、`getName()`、`getObjectCount()`、`getObjectGid()`、`getObjectHeight()`、`getObjectName()`、`getObjectType()` - `getLastVisibleTileCount()`、`getLastCustomVisualCount()`、`getLastAtlasCount()` +- `publishCollision()`、`getCollisionRectCount()`、`getCollisionRectX()`、`getCollisionRectY()`、`getCollisionRectWidth()`、`getCollisionRectHeight()` - `getObjectWidth()`、`getObjectX()`、`getObjectY()`、`getTile()`、`getTileHeight()`、`getTileWidth()`、`getTilesetColumns()`、`getTilesetFirstGid()` - `getTilesetTexture()`、`getX()`、`getY()`、`isVisible()`、`loadConfig()`、`loadFromFile()`、`newLayer()`、`newLayerFromFile()` - `newPathfinder()`、`newPathfinderSize()`、`newFov()`、`newFovSize()`、`newFovVolume()` diff --git a/src/modules/editor/Editor.cpp b/src/modules/editor/Editor.cpp index 4d9a0f49..ea28a90b 100644 --- a/src/modules/editor/Editor.cpp +++ b/src/modules/editor/Editor.cpp @@ -187,6 +187,12 @@ TileBufferTarget *Editor::newTileBufferTarget(const std::string &id, TileBuffer return new TileBufferTarget(id, buffer); } +#ifdef EVENGINE_HAS_MAP +TileLayerTarget *Editor::newTileLayerTarget(const std::string &id, map::TileLayer *layer) { + return new TileLayerTarget(id, layer); +} +#endif + ScriptEditorTool *Editor::newScriptTool(const std::string &id, const std::string &label) { return new ScriptEditorTool(id, label); } diff --git a/src/modules/editor/Editor.h b/src/modules/editor/Editor.h index c7e5509b..9d55d064 100644 --- a/src/modules/editor/Editor.h +++ b/src/modules/editor/Editor.h @@ -13,6 +13,9 @@ class Mesh; namespace procgen { class Heightmap; } +#ifdef EVENGINE_HAS_MAP +namespace map { class TileLayer; } +#endif #endif } // namespace eve @@ -28,6 +31,9 @@ class EditorDock; class EditorHistory; class EditorSession; class TileBufferTarget; +#ifdef EVENGINE_HAS_MAP +class TileLayerTarget; +#endif class HeightmapTarget; class ScriptEditorTool; @@ -58,6 +64,10 @@ public: EditorSession *newSession(); /** @brief Adapt existing fields to capability-based editor targets. */ TileBufferTarget *newTileBufferTarget(const std::string &id, TileBuffer *buffer); +#ifdef EVENGINE_HAS_MAP + /** @brief Adapt a live map layer to the editor command/undo/brush protocol. */ + TileLayerTarget *newTileLayerTarget(const std::string &id, map::TileLayer *layer); +#endif /** @brief Create a script-backed implementation of the IEditorTool protocol. */ ScriptEditorTool *newScriptTool(const std::string &id, const std::string &label); #ifdef EVENGINE_HAS_PROCGEN diff --git a/src/modules/editor/FieldTargets.cpp b/src/modules/editor/FieldTargets.cpp index 0d10877f..49636b9b 100644 --- a/src/modules/editor/FieldTargets.cpp +++ b/src/modules/editor/FieldTargets.cpp @@ -1,6 +1,9 @@ #include "editor/FieldTargets.h" #include "editor/TileBuffer.h" +#ifdef EVENGINE_HAS_MAP +#include "map/TileLayer.h" +#endif #ifdef EVENGINE_HAS_PROCGEN #include "procgen/heightmap/Heightmap.h" #endif @@ -23,6 +26,27 @@ bool TileBufferTarget::writeInt(int x, int y, int value) { return true; } +#ifdef EVENGINE_HAS_MAP +TileLayerTarget::TileLayerTarget(std::string id, map::TileLayer *layer) + : id_(std::move(id)), layer_(layer) {} +unsigned long long TileLayerTarget::revision() const { + return layer_ ? layer_->tiles()->revision : 0; +} +int TileLayerTarget::width() const { return layer_ ? layer_->getMapWidth() : 0; } +int TileLayerTarget::height() const { return layer_ ? layer_->getMapHeight() : 0; } +bool TileLayerTarget::inBounds(int x, int y) const { + return layer_ && x >= 0 && y >= 0 && x < width() && y < height(); +} +int TileLayerTarget::readInt(int x, int y) const { return inBounds(x, y) ? layer_->getTile(x, y) : 0; } +bool TileLayerTarget::writeInt(int x, int y, int value) { + if (!inBounds(x, y)) return false; + if (layer_->getTile(x, y) == value) return true; + layer_->setTile(x, y, value); + dirty_.include(x, y); + return true; +} +#endif + #ifdef EVENGINE_HAS_PROCGEN HeightmapTarget::HeightmapTarget(std::string id, procgen::Heightmap *heightmap) : id_(std::move(id)), heightmap_(heightmap) {} diff --git a/src/modules/editor/FieldTargets.h b/src/modules/editor/FieldTargets.h index 698a40a1..abea210c 100644 --- a/src/modules/editor/FieldTargets.h +++ b/src/modules/editor/FieldTargets.h @@ -5,6 +5,9 @@ #include namespace eve::editor { class TileBuffer; } +#ifdef EVENGINE_HAS_MAP +namespace eve::map { class TileLayer; } +#endif #ifdef EVENGINE_HAS_PROCGEN namespace eve::procgen { class Heightmap; } #endif @@ -32,6 +35,28 @@ private: EditRegion dirty_; }; +#ifdef EVENGINE_HAS_MAP +/** @brief Non-owning editable adapter for a live map::TileLayer. */ +class TileLayerTarget final : public IEditableTarget, public IIntFieldTarget { +public: + TileLayerTarget(std::string id, map::TileLayer *layer); + const std::string &targetId() const override { return id_; } + unsigned long long revision() const override; + EditRegion dirtyRegion() const override { return dirty_; } + void clearDirtyRegion() override { dirty_.clear(); } + int width() const override; + int height() const override; + bool inBounds(int x, int y) const override; + int readInt(int x, int y) const override; + bool writeInt(int x, int y, int value) override; + map::TileLayer *layer() const { return layer_; } +private: + std::string id_; + map::TileLayer *layer_ = nullptr; + EditRegion dirty_; +}; +#endif + #ifdef EVENGINE_HAS_PROCGEN /** @brief Non-owning IScalarFieldTarget adapter for procgen::Heightmap. */ class HeightmapTarget final : public IEditableTarget, public IScalarFieldTarget { diff --git a/src/modules/map/Fov.cpp b/src/modules/map/Fov.cpp index c1a12b6f..e813a75b 100644 --- a/src/modules/map/Fov.cpp +++ b/src/modules/map/Fov.cpp @@ -220,6 +220,7 @@ struct Fov::Impl { int height = 0; int depth = 1; TileLayer *layer = nullptr; + uint64_t layerRevision = 0; Algorithm algorithm = Algorithm::Shadowcast; RadiusMetric metric = RadiusMetric::Euclidean; Mode mode = Mode::Grid2D; @@ -316,6 +317,7 @@ struct Fov::Impl { if (opaqueGids.count(gid)) isOpaque = true; opaque[size_t(i)] = isOpaque ? 1u : 0u; } + layerRevision = tiles->revision; dirty = true; } @@ -894,6 +896,7 @@ struct Fov::Impl { } void compute() { + if (layer && layerRevision != layer->tiles()->revision) syncFromLayer(); if (!dirty) return; resetVisibleKeepExplored(); for (const auto &r : revealers) computeRevealer(r); diff --git a/src/modules/map/Map.cpp b/src/modules/map/Map.cpp index b23ae7ff..085450f9 100644 --- a/src/modules/map/Map.cpp +++ b/src/modules/map/Map.cpp @@ -9,9 +9,12 @@ #include "graphics/Graphics.h" #include "graphics/RenderSystem.h" #include "common/Module.h" +#include "common/Capability.h" #include +#include + namespace eve::map { Module_IMPL(Map, new Map()); @@ -62,7 +65,7 @@ int Map::loadFromFile(const std::string &path) { } void Map::update(float dt) { - (void)dt; + TileRenderSystem::update(dt); TileConfigSystem::poll(); } @@ -70,7 +73,7 @@ void Map::render(graphics::Graphics *gfx) { if (!gfx) return; std::vector items; graphics::RenderSystem::collectSprites(items); - TileRenderSystem::collect(items); + TileRenderSystem::collect(items, gfx->getWidth(), gfx->getHeight()); graphics::RenderSystem::drawItems(*gfx, items, false); } @@ -79,6 +82,76 @@ int Map::pollConfigs() { return TileConfigSystem::poll(); } int Map::getLastVisibleTileCount() const { return TileRenderSystem::lastVisibleTileCount(); } int Map::getLastCustomVisualCount() const { return TileRenderSystem::lastCustomVisualCount(); } int Map::getLastAtlasCount() const { return TileRenderSystem::lastAtlasCount(); } +int Map::getLastVisitedChunkCount() const { return TileRenderSystem::lastVisitedChunkCount(); } +int Map::getLastVisitedCellCount() const { return TileRenderSystem::lastVisitedCellCount(); } + +int Map::publishCollision(TileLayer *layer) { + collisionRects_.clear(); + if (!layer) return 0; + const auto config = layer->config(); + const auto tiles = layer->tiles(); + const auto tileset = layer->tileset(); + if (config->orientation != MapOrientation::Orthogonal) return 0; + + std::vector solid(size_t(config->mapW * config->mapH), 0); + for (int y = 0; y < config->mapH; ++y) { + for (int x = 0; x < config->mapW; ++x) { + const int gid = int(tileGid(tiles->gids[size_t(y * config->mapW + x)])); + const auto it = std::find_if(tileset->visuals.begin(), tileset->visuals.end(), + [gid](const auto &v) { return v.gid == gid; }); + solid[size_t(y * config->mapW + x)] = + gid != 0 && it != tileset->visuals.end() && !it->walkable; + } + } + for (int y = 0; y < config->mapH; ++y) { + for (int x = 0; x < config->mapW; ++x) { + if (!solid[size_t(y * config->mapW + x)]) continue; + int width = 1; + while (x + width < config->mapW && solid[size_t(y * config->mapW + x + width)]) + ++width; + int height = 1; + bool canGrow = true; + while (y + height < config->mapH && canGrow) { + for (int dx = 0; dx < width; ++dx) + if (!solid[size_t((y + height) * config->mapW + x + dx)]) canGrow = false; + if (canGrow) ++height; + } + for (int dy = 0; dy < height; ++dy) + for (int dx = 0; dx < width; ++dx) + solid[size_t((y + dy) * config->mapW + x + dx)] = 0; + collisionRects_.push_back({config->originX + x * (config->tileW + config->cellGapX), + config->originY + y * (config->tileH + config->cellGapY), + width * (config->tileW + config->cellGapX), + height * (config->tileH + config->cellGapY)}); + } + } + cap::forEach([&](ITileCollisionSink *sink) { + sink->replaceTileCollision(layer, collisionRects_.data(), collisionRects_.size()); + }); + return int(collisionRects_.size()); +} + +int Map::getCollisionRectCount() const { return int(collisionRects_.size()); } +float Map::getCollisionRectX(int index) const { + return index >= 0 && index < int(collisionRects_.size()) + ? collisionRects_[size_t(index)].x + : 0.f; +} +float Map::getCollisionRectY(int index) const { + return index >= 0 && index < int(collisionRects_.size()) + ? collisionRects_[size_t(index)].y + : 0.f; +} +float Map::getCollisionRectWidth(int index) const { + return index >= 0 && index < int(collisionRects_.size()) + ? collisionRects_[size_t(index)].width + : 0.f; +} +float Map::getCollisionRectHeight(int index) const { + return index >= 0 && index < int(collisionRects_.size()) + ? collisionRects_[size_t(index)].height + : 0.f; +} int Map::getLayerCount() const { if (ecs::current()->getManager() == nullptr) return 0; @@ -191,8 +264,27 @@ void Map::expose(ssq::Table &table) { layer.addFunc("resize", &TileLayer::resize); layer.addFunc("setTile", &TileLayer::setTile); layer.addFunc("getTile", &TileLayer::getTile); + layer.addFunc("fillRect", &TileLayer::fillRect); layer.addFunc("fill", &TileLayer::fill); layer.addFunc("clear", &TileLayer::clear); + layer.addFunc("getRevision", &TileLayer::getRevision); + layer.addFunc("getChunkSize", &TileLayer::getChunkSize); + layer.addFunc("getChunkCount", &TileLayer::getChunkCount); + layer.addFunc("getNonEmptyChunkCount", &TileLayer::getNonEmptyChunkCount); + layer.addFunc("clearTileAnimation", &TileLayer::clearTileAnimation); + layer.addFunc("addTileAnimationFrame", &TileLayer::addTileAnimationFrame); + layer.addFunc("getTileAnimationFrameCount", &TileLayer::getTileAnimationFrameCount); + layer.addFunc("setTileDataString", &TileLayer::setTileDataString); + layer.addFunc("setTileDataNumber", &TileLayer::setTileDataNumber); + layer.addFunc("setTileDataBool", &TileLayer::setTileDataBool); + layer.addFunc("getTileDataType", &TileLayer::getTileDataType); + layer.addFunc("getTileDataString", &TileLayer::getTileDataString); + layer.addFunc("getTileDataNumber", &TileLayer::getTileDataNumber); + layer.addFunc("getTileDataBool", &TileLayer::getTileDataBool); + layer.addFunc("setTerrainRule", &TileLayer::setTerrainRule); + layer.addFunc("clearTerrainRules", &TileLayer::clearTerrainRules); + layer.addFunc("paintTerrain", &TileLayer::paintTerrain); + layer.addFunc("getTerrain", &TileLayer::getTerrain); layer.addFunc("setTileset", &TileLayer::setTileset); layer.addFunc("setTilesetTileSize", &TileLayer::setTilesetTileSize); layer.addFunc("setTileVisual", &TileLayer::setTileVisual); @@ -350,6 +442,14 @@ void Map::expose(ssq::Class &cls) { cls.addFunc("getLastVisibleTileCount", &Map::getLastVisibleTileCount); cls.addFunc("getLastCustomVisualCount", &Map::getLastCustomVisualCount); cls.addFunc("getLastAtlasCount", &Map::getLastAtlasCount); + cls.addFunc("getLastVisitedChunkCount", &Map::getLastVisitedChunkCount); + cls.addFunc("getLastVisitedCellCount", &Map::getLastVisitedCellCount); + cls.addFunc("publishCollision", &Map::publishCollision); + cls.addFunc("getCollisionRectCount", &Map::getCollisionRectCount); + cls.addFunc("getCollisionRectX", &Map::getCollisionRectX); + cls.addFunc("getCollisionRectY", &Map::getCollisionRectY); + cls.addFunc("getCollisionRectWidth", &Map::getCollisionRectWidth); + cls.addFunc("getCollisionRectHeight", &Map::getCollisionRectHeight); cls.addFunc("getLayerCount", &Map::getLayerCount); cls.addFunc("getLayer", &Map::getLayer); cls.addFunc("getObjectCount", &Map::getObjectCount); diff --git a/src/modules/map/Map.h b/src/modules/map/Map.h index 87cd27a8..2e86a13e 100644 --- a/src/modules/map/Map.h +++ b/src/modules/map/Map.h @@ -5,6 +5,7 @@ #include "map/MapObject.h" #include "map/Pathfinder.h" #include "map/Fov.h" +#include "map/TileCollision.h" #include #include @@ -58,6 +59,16 @@ public: int getLastVisibleTileCount() const; int getLastCustomVisualCount() const; int getLastAtlasCount() const; + int getLastVisitedChunkCount() const; + int getLastVisitedCellCount() const; + + /** @brief Generate merged collision from non-walkable tile metadata and notify adapters. */ + int publishCollision(TileLayer *layer); + int getCollisionRectCount() const; + float getCollisionRectX(int index) const; + float getCollisionRectY(int index) const; + float getCollisionRectWidth(int index) const; + float getCollisionRectHeight(int index) const; int getLayerCount() const; /** @brief Layer created by the most recent loadFromFile/newLayerFromFile call. */ @@ -95,6 +106,7 @@ private: std::vector objects_; std::vector loadedLayers_; std::string dualGridError_; + std::vector collisionRects_; }; } // namespace eve::map diff --git a/src/modules/map/Pathfinder.cpp b/src/modules/map/Pathfinder.cpp index e17372b2..e226f80c 100644 --- a/src/modules/map/Pathfinder.cpp +++ b/src/modules/map/Pathfinder.cpp @@ -144,6 +144,7 @@ struct Grid { int width = 0; int height = 0; TileLayer *layer = nullptr; + uint64_t layerRevision = 0; Topology topology = Topology::Ortho4; bool topologyManual = false; bool diagonal = false; @@ -199,6 +200,7 @@ struct Grid { if (blockedGids.count(gid)) blocked = true; cost[size_t(i)] = blocked ? 0.f : 1.f; } + layerRevision = tiles->revision; dirty = true; } @@ -385,7 +387,8 @@ void Pathfinder::invalidateCache() { namespace { bool ensureSynced(Grid &grid) { - if (grid.layer && grid.dirty) grid.syncFromLayer(); + if (grid.layer && (grid.dirty || grid.layerRevision != grid.layer->tiles()->revision)) + grid.syncFromLayer(); return grid.width > 0 && grid.height > 0; } diff --git a/src/modules/map/TileCollision.h b/src/modules/map/TileCollision.h new file mode 100644 index 00000000..62923a78 --- /dev/null +++ b/src/modules/map/TileCollision.h @@ -0,0 +1,27 @@ +#pragma once + +#include "common/Export.h" + +#include + +namespace eve::map { + +/** @brief One greedily merged solid tile rectangle in world coordinates. */ +struct EVENGINE_API TileCollisionRect { + float x = 0.f; + float y = 0.f; + float width = 0.f; + float height = 0.f; +}; + +/** @brief Optional sink implemented by a physics or project adapter. */ +class EVENGINE_API ITileCollisionSink { +public: + static constexpr const char* capabilityName = "ITileCollisionSink"; + virtual ~ITileCollisionSink() = default; + + /** @brief Atomically replace collision geometry for a stable layer identity. */ + virtual void replaceTileCollision(const void* layer, const TileCollisionRect* rects, size_t count) = 0; +}; + +} // namespace eve::map diff --git a/src/modules/map/TileConfig.cpp b/src/modules/map/TileConfig.cpp index fc3aef35..b9262087 100644 --- a/src/modules/map/TileConfig.cpp +++ b/src/modules/map/TileConfig.cpp @@ -116,6 +116,9 @@ struct TilesetInfo { int margin = 0; int spacing = 0; std::vector visuals; + std::vector animations; + std::vector terrainRules; + std::vector customData; }; TileLayer::Tileset::Visual readTileVisual(Poco::JSON::Object::Ptr o, int fallbackGid) { @@ -165,9 +168,56 @@ TilesetInfo readTilesetObject(Poco::JSON::Object::Ptr o) { if (arr) { for (size_t i = 0; i < arr->size(); ++i) { auto tile = arr->getObject(static_cast(i)); - auto visual = readTileVisual(tile, info.firstGid + int(i)); + const int localId = tile && tile->has("id") ? asInt(tile->get("id"), int(i)) + : int(i); + const int gid = tile && tile->has("gid") + ? asInt(tile->get("gid"), info.firstGid + localId) + : info.firstGid + localId; + auto visual = readTileVisual(tile, gid); if (visual.gid > 0 && visual.width > 0 && visual.height > 0) info.visuals.push_back(visual); + if (!tile) continue; + if (tile->has("animation")) { + auto frames = tile->getArray("animation"); + TileLayer::Tileset::Animation animation; + animation.gid = gid; + if (frames) { + for (size_t frameIndex = 0; frameIndex < frames->size(); ++frameIndex) { + auto frame = frames->getObject(static_cast(frameIndex)); + if (!frame) continue; + const int frameLocal = asInt(frame->get("tileid"), localId); + const int duration = frame->has("duration") + ? asInt(frame->get("duration"), 100) + : 100; + animation.frames.push_back( + {info.firstGid + frameLocal, std::max(1, duration)}); + } + } + if (!animation.frames.empty()) info.animations.push_back(std::move(animation)); + } + if (tile->has("terrain") && tile->has("neighborMask")) { + info.terrainRules.push_back( + {gid, asInt(tile->get("terrain"), 0), + asInt(tile->get("neighborMask"), 0) & 0xff}); + } + if (tile->has("properties")) { + auto properties = tile->getArray("properties"); + if (properties) { + for (size_t propertyIndex = 0; propertyIndex < properties->size(); + ++propertyIndex) { + auto property = + properties->getObject(static_cast(propertyIndex)); + if (!property || !property->has("name") || !property->has("value")) + continue; + const std::string type = property->has("type") + ? asString(property->get("type")) + : "string"; + info.customData.push_back( + {gid, asString(property->get("name")), type, + asString(property->get("value"))}); + } + } + } } } } catch (...) { @@ -183,6 +233,9 @@ void applyTileset(TileLayer *layer, const TilesetInfo &info) { layer->resource()->texturePath = info.image; layer->setTileset(tex, info.firstGid, info.columns, info.margin, info.spacing); layer->tileset()->visuals = info.visuals; + layer->tileset()->animations = info.animations; + layer->tileset()->terrainRules = info.terrainRules; + layer->tileset()->customData = info.customData; } bool decodeLayerData(Poco::JSON::Object::Ptr layerObj, size_t expectedCount, @@ -299,7 +352,7 @@ bool isTileLayerObject(Poco::JSON::Object::Ptr o) { const std::string t = asString(o->get("type")); if (!t.empty() && t != "tilelayer") return false; } - return o->has("data"); + return o->has("data") || o->has("chunks"); } bool isObjectGroup(Poco::JSON::Object::Ptr o) { @@ -435,12 +488,59 @@ bool applyFlatLayerData(TileLayer *layer, Poco::JSON::Object::Ptr root, std::str gids.assign(need, 0u); const size_t n = std::min(need, data.size()); for (size_t i = 0; i < n; ++i) gids[i] = data[i]; + layer->rebuildSpatialIndex(); return true; } bool applyOneLayerObject(TileLayer *layer, Poco::JSON::Object::Ptr layerObj, int mapW, int mapH, std::string *error) { if (!layer || !layerObj) return false; + if (layerObj->has("chunks")) { + try { + auto chunks = layerObj->getArray("chunks"); + if (!chunks || chunks->size() == 0) return false; + int minX = 0, minY = 0, maxX = 0, maxY = 0; + bool first = true; + for (size_t i = 0; i < chunks->size(); ++i) { + auto chunk = chunks->getObject(static_cast(i)); + if (!chunk) continue; + const int x = asInt(chunk->get("x"), 0), y = asInt(chunk->get("y"), 0); + const int w = asInt(chunk->get("width"), 0), h = asInt(chunk->get("height"), 0); + if (w <= 0 || h <= 0) continue; + if (first) { + minX = x; minY = y; maxX = x + w; maxY = y + h; first = false; + } else { + minX = std::min(minX, x); minY = std::min(minY, y); + maxX = std::max(maxX, x + w); maxY = std::max(maxY, y + h); + } + } + if (first) return false; + float shiftX = 0.f, shiftY = 0.f; + layer->tileToWorld(minX, minY, shiftX, shiftY); + layer->resize(maxX - minX, maxY - minY); + layer->setOrigin(shiftX, shiftY); + auto &gids = layer->tiles()->gids; + for (size_t i = 0; i < chunks->size(); ++i) { + auto chunk = chunks->getObject(static_cast(i)); + if (!chunk) continue; + const int x = asInt(chunk->get("x"), 0), y = asInt(chunk->get("y"), 0); + const int w = asInt(chunk->get("width"), 0), h = asInt(chunk->get("height"), 0); + if (w <= 0 || h <= 0) continue; + std::vector data; + if (!decodeLayerData(chunk, size_t(w * h), data, error)) return false; + for (int cy = 0; cy < h; ++cy) + for (int cx = 0; cx < w; ++cx) + gids[size_t((y - minY + cy) * layer->getMapWidth() + x - minX + cx)] = + data[size_t(cy * w + cx)]; + } + layer->rebuildSpatialIndex(); + applyLayerDraw(layer, layerObj); + return true; + } catch (...) { + if (error) *error = "invalid chunk layer"; + return false; + } + } int w = mapW, h = mapH; if (layerObj->has("width")) w = asInt(layerObj->get("width"), w); if (layerObj->has("height")) h = asInt(layerObj->get("height"), h); @@ -454,6 +554,7 @@ bool applyOneLayerObject(TileLayer *layer, Poco::JSON::Object::Ptr layerObj, int gids.assign(need, 0u); const size_t n = std::min(need, data.size()); for (size_t i = 0; i < n; ++i) gids[i] = data[i]; + layer->rebuildSpatialIndex(); applyLayerDraw(layer, layerObj); return true; diff --git a/src/modules/map/TileLayer.cpp b/src/modules/map/TileLayer.cpp index afe01119..8492c2f1 100644 --- a/src/modules/map/TileLayer.cpp +++ b/src/modules/map/TileLayer.cpp @@ -6,6 +6,9 @@ #include "graphics/Texture.h" #include +#include +#include +#include namespace eve::map { @@ -47,12 +50,24 @@ void TileLayer::resize(int mapW, int mapH) { c->mapH = mapH > 0 ? mapH : 0; const size_t n = size_t(std::max(0, c->mapW) * std::max(0, c->mapH)); tiles()->gids.assign(n, 0u); + tiles()->terrainIds.assign(n, -1); + rebuildSpatialIndex(); } void TileLayer::setTile(int tx, int ty, int gid) { auto c = config(); if (tx < 0 || ty < 0 || tx >= c->mapW || ty >= c->mapH) return; - tiles()->gids[size_t(ty * c->mapW + tx)] = uint32_t(gid < 0 ? 0 : gid); + auto t = tiles(); + const size_t index = size_t(ty * c->mapW + tx); + const uint32_t next = uint32_t(gid < 0 ? 0 : gid); + const bool wasEmpty = tileGid(t->gids[index]) == 0; + const bool isEmpty = tileGid(next) == 0; + if (t->gids[index] == next) return; + t->gids[index] = next; + const int chunkIndex = (ty / Tiles::kChunkSize) * t->chunkColumns + tx / Tiles::kChunkSize; + if (chunkIndex >= 0 && chunkIndex < int(t->chunks.size()) && wasEmpty != isEmpty) + t->chunks[size_t(chunkIndex)].nonEmpty += isEmpty ? -1 : 1; + ++t->revision; } int TileLayer::getTile(int tx, int ty) { @@ -61,17 +76,219 @@ int TileLayer::getTile(int tx, int ty) { return int(tiles()->gids[size_t(ty * c->mapW + tx)]); } +void TileLayer::fillRect(int x, int y, int width, int height, int gid) { + auto c = config(); + if (width <= 0 || height <= 0 || c->mapW <= 0 || c->mapH <= 0) return; + const int x0 = std::max(0, x); + const int y0 = std::max(0, y); + const int x1 = std::min(c->mapW, x + width); + const int y1 = std::min(c->mapH, y + height); + if (x0 >= x1 || y0 >= y1) return; + const uint32_t next = uint32_t(gid < 0 ? 0 : gid); + bool changed = false; + auto t = tiles(); + for (int ty = y0; ty < y1; ++ty) { + for (int tx = x0; tx < x1; ++tx) { + const size_t index = size_t(ty * c->mapW + tx); + if (t->gids[index] == next) continue; + t->gids[index] = next; + changed = true; + } + } + if (changed) { + rebuildSpatialIndex(); + } +} + void TileLayer::fill(int gid) { const uint32_t g = uint32_t(gid < 0 ? 0 : gid); - for (auto &v : tiles()->gids) v = g; + auto t = tiles(); + bool changed = false; + for (auto &v : t->gids) { + if (v != g) changed = true; + v = g; + } + if (changed) { + rebuildSpatialIndex(); + } } void TileLayer::clear() { fill(0); } +int TileLayer::getRevision() { return int(tiles()->revision & 0x7fffffffu); } +int TileLayer::getChunkSize() { return Tiles::kChunkSize; } +int TileLayer::getChunkCount() { return int(tiles()->chunks.size()); } +int TileLayer::getNonEmptyChunkCount() { + int count = 0; + for (const auto &chunk : tiles()->chunks) + if (chunk.nonEmpty > 0) ++count; + return count; +} + +void TileLayer::rebuildSpatialIndex() { + auto c = config(); + auto t = tiles(); + t->chunkColumns = (c->mapW + Tiles::kChunkSize - 1) / Tiles::kChunkSize; + t->chunkRows = (c->mapH + Tiles::kChunkSize - 1) / Tiles::kChunkSize; + t->chunks.assign(size_t(t->chunkColumns * t->chunkRows), {}); + for (int y = 0; y < c->mapH; ++y) { + for (int x = 0; x < c->mapW; ++x) { + const size_t index = size_t(y * c->mapW + x); + if (index >= t->gids.size() || tileGid(t->gids[index]) == 0) continue; + const int chunkIndex = (y / Tiles::kChunkSize) * t->chunkColumns + x / Tiles::kChunkSize; + ++t->chunks[size_t(chunkIndex)].nonEmpty; + } + } + ++t->revision; +} + +void TileLayer::clearTileAnimation(int gid) { + auto &animations = tileset()->animations; + animations.erase(std::remove_if(animations.begin(), animations.end(), + [gid](const Tileset::Animation &a) { return a.gid == gid; }), + animations.end()); +} + +void TileLayer::addTileAnimationFrame(int gid, int frameGid, int durationMs) { + if (gid <= 0 || frameGid <= 0) return; + auto &animations = tileset()->animations; + auto it = std::find_if(animations.begin(), animations.end(), + [gid](const Tileset::Animation &a) { return a.gid == gid; }); + if (it == animations.end()) { + animations.push_back({}); + it = animations.end() - 1; + it->gid = gid; + } + it->frames.push_back({frameGid, std::max(1, durationMs)}); +} + +int TileLayer::getTileAnimationFrameCount(int gid) { + for (const auto &animation : tileset()->animations) + if (animation.gid == gid) return int(animation.frames.size()); + return 0; +} + +namespace { +TileLayer::Tileset::CustomData *findCustomData(TileLayer::Tileset &tileset, int gid, + const std::string &name) { + for (auto &entry : tileset.customData) + if (entry.gid == gid && entry.name == name) return &entry; + tileset.customData.push_back({gid, name, {}, {}}); + return &tileset.customData.back(); +} + +const TileLayer::Tileset::CustomData *findCustomData(const TileLayer::Tileset &tileset, int gid, + const std::string &name) { + for (const auto &entry : tileset.customData) + if (entry.gid == gid && entry.name == name) return &entry; + return nullptr; +} +} // namespace + +void TileLayer::setTileDataString(int gid, const std::string &name, const std::string &value) { + if (gid <= 0 || name.empty()) return; + auto *entry = findCustomData(*tileset(), gid, name); + entry->type = "string"; + entry->value = value; +} + +void TileLayer::setTileDataNumber(int gid, const std::string &name, float value) { + if (gid <= 0 || name.empty()) return; + auto *entry = findCustomData(*tileset(), gid, name); + entry->type = "number"; + std::ostringstream out; + out << value; + entry->value = out.str(); +} + +void TileLayer::setTileDataBool(int gid, const std::string &name, bool value) { + if (gid <= 0 || name.empty()) return; + auto *entry = findCustomData(*tileset(), gid, name); + entry->type = "bool"; + entry->value = value ? "true" : "false"; +} + +std::string TileLayer::getTileDataType(int gid, const std::string &name) { + const auto *entry = findCustomData(*tileset(), gid, name); + return entry ? entry->type : std::string(); +} + +std::string TileLayer::getTileDataString(int gid, const std::string &name) { + const auto *entry = findCustomData(*tileset(), gid, name); + return entry ? entry->value : std::string(); +} + +float TileLayer::getTileDataNumber(int gid, const std::string &name) { + const auto *entry = findCustomData(*tileset(), gid, name); + return entry && (entry->type == "number" || entry->type == "float" || entry->type == "int") + ? std::strtof(entry->value.c_str(), nullptr) + : 0.f; +} + +bool TileLayer::getTileDataBool(int gid, const std::string &name) { + const auto *entry = findCustomData(*tileset(), gid, name); + return entry && entry->type == "bool" && entry->value == "true"; +} + +void TileLayer::setTerrainRule(int gid, int terrain, int neighborMask) { + if (gid <= 0 || terrain < 0) return; + auto &rules = tileset()->terrainRules; + auto it = std::find_if(rules.begin(), rules.end(), [=](const Tileset::TerrainRule &rule) { + return rule.terrain == terrain && rule.neighborMask == (neighborMask & 0xff); + }); + if (it == rules.end()) rules.push_back({gid, terrain, neighborMask & 0xff}); + else it->gid = gid; +} + +void TileLayer::clearTerrainRules() { tileset()->terrainRules.clear(); } + +int TileLayer::getTerrain(int x, int y) { + auto c = config(); + auto t = tiles(); + if (x < 0 || y < 0 || x >= c->mapW || y >= c->mapH) return -1; + const size_t index = size_t(y * c->mapW + x); + return index < t->terrainIds.size() ? t->terrainIds[index] : -1; +} + +void TileLayer::paintTerrain(int x, int y, int terrain) { + auto c = config(); + auto t = tiles(); + if (x < 0 || y < 0 || x >= c->mapW || y >= c->mapH || terrain < 0) return; + if (t->terrainIds.size() != t->gids.size()) t->terrainIds.assign(t->gids.size(), -1); + t->terrainIds[size_t(y * c->mapW + x)] = terrain; + constexpr int dx[8] = {-1, 0, 1, 1, 1, 0, -1, -1}; + constexpr int dy[8] = {-1, -1, -1, 0, 1, 1, 1, 0}; + const int minX = std::max(0, x - 1), maxX = std::min(c->mapW - 1, x + 1); + const int minY = std::max(0, y - 1), maxY = std::min(c->mapH - 1, y + 1); + for (int cy = minY; cy <= maxY; ++cy) { + for (int cx = minX; cx <= maxX; ++cx) { + const int cellTerrain = getTerrain(cx, cy); + if (cellTerrain < 0) continue; + int mask = 0; + for (int i = 0; i < 8; ++i) + if (getTerrain(cx + dx[i], cy + dy[i]) == cellTerrain) mask |= 1 << i; + const Tileset::TerrainRule *best = nullptr; + int bestMismatch = 1000; + for (const auto &rule : tileset()->terrainRules) { + if (rule.terrain != cellTerrain) continue; + const int mismatch = std::popcount(unsigned(rule.neighborMask ^ mask)); + if (mismatch < bestMismatch) { + best = &rule; + bestMismatch = mismatch; + } + } + if (best) setTile(cx, cy, best->gid); + } + } +} + void TileLayer::setTileset(graphics::Texture *texture, int firstGid, int columns, int margin, int spacing) { auto ts = tileset(); ts->visuals.clear(); + ts->animations.clear(); + ts->terrainRules.clear(); + ts->customData.clear(); ts->texture = texture; ts->firstGid = firstGid > 0 ? firstGid : 1; ts->columns = columns > 0 ? columns : 1; diff --git a/src/modules/map/TileLayer.h b/src/modules/map/TileLayer.h index cb94e311..bcc41c30 100644 --- a/src/modules/map/TileLayer.h +++ b/src/modules/map/TileLayer.h @@ -48,10 +48,43 @@ public: }; struct Tiles { + struct Chunk { + int nonEmpty = 0; + }; + + static constexpr int kChunkSize = 32; std::vector gids; + std::vector chunks; + int chunkColumns = 0; + int chunkRows = 0; + uint64_t revision = 1; + std::vector terrainIds; }; struct Tileset { + struct AnimationFrame { + int gid = 0; + int durationMs = 100; + }; + + struct Animation { + int gid = 0; + std::vector frames; + }; + + struct TerrainRule { + int gid = 0; + int terrain = 0; + int neighborMask = 0; + }; + + struct CustomData { + int gid = 0; + std::string name; + std::string type; + std::string value; + }; + /** @brief Per-GID visual metadata emitted by a project-defined asset pipeline. */ struct Visual { int gid = 0; @@ -76,6 +109,9 @@ public: int margin = 0; int spacing = 0; std::vector visuals; + std::vector animations; + std::vector terrainRules; + std::vector customData; }; struct Draw { @@ -130,11 +166,41 @@ public: /** @brief Tile GID access; 0 = empty. */ void setTile(int tx, int ty, int gid); int getTile(int tx, int ty); + /** @brief Fill a clipped rectangular region and publish one tile revision. */ + void fillRect(int x, int y, int width, int height, int gid); /** @brief Fills the whole layer with one GID. */ void fill(int gid); /** @brief Clears the layer to empty. */ void clear(); + /** @brief Monotonic tile-data revision used by derived navigation/FOV caches. */ + int getRevision(); + /** @brief Runtime spatial-index diagnostics. */ + int getChunkSize(); + int getChunkCount(); + int getNonEmptyChunkCount(); + + /** @brief Rebuild chunk occupancy after bulk loader writes directly to gids. */ + void rebuildSpatialIndex(); + + /** @brief TileSet v2 animation authoring; frames are selected during map update/render. */ + void clearTileAnimation(int gid); + void addTileAnimationFrame(int gid, int frameGid, int durationMs); + int getTileAnimationFrameCount(int gid); + /** @brief TileSet v2 typed custom data (stored losslessly as canonical text). */ + void setTileDataString(int gid, const std::string &name, const std::string &value); + void setTileDataNumber(int gid, const std::string &name, float value); + void setTileDataBool(int gid, const std::string &name, bool value); + std::string getTileDataType(int gid, const std::string &name); + std::string getTileDataString(int gid, const std::string &name); + float getTileDataNumber(int gid, const std::string &name); + bool getTileDataBool(int gid, const std::string &name); + /** @brief Register an 8-neighbor terrain rule and paint with local re-resolution. */ + void setTerrainRule(int gid, int terrain, int neighborMask); + void clearTerrainRules(); + void paintTerrain(int x, int y, int terrain); + int getTerrain(int x, int y); + /** @brief Binds the atlas texture and tile table. */ void setTileset(graphics::Texture *texture, int firstGid, int columns, int margin = 0, int spacing = 0); diff --git a/src/modules/map/TileSystem.cpp b/src/modules/map/TileSystem.cpp index 1dfa9d39..dd60be97 100644 --- a/src/modules/map/TileSystem.cpp +++ b/src/modules/map/TileSystem.cpp @@ -10,6 +10,8 @@ #include #include +#include +#include namespace eve::map { namespace { @@ -17,6 +19,9 @@ namespace { int gLastVisibleTileCount = 0; int gLastCustomVisualCount = 0; int gLastAtlasCount = 0; +int gLastVisitedChunkCount = 0; +int gLastVisitedCellCount = 0; +double gAnimationTimeMs = 0.0; struct ViewCam { float x = 0.f; @@ -92,12 +97,66 @@ int64_t fileModtime(const std::string &path) { return info.modtime; } +uint32_t animatedGid(const TileLayer::Tileset &ts, uint32_t gid) { + for (const auto &animation : ts.animations) { + if (animation.gid != int(gid) || animation.frames.empty()) continue; + int total = 0; + for (const auto &frame : animation.frames) total += std::max(1, frame.durationMs); + if (total <= 0) return gid; + int cursor = int(std::fmod(gAnimationTimeMs, double(total))); + for (const auto &frame : animation.frames) { + cursor -= std::max(1, frame.durationMs); + if (cursor < 0) return uint32_t(frame.gid); + } + } + return gid; +} + +bool chunkVisible(const TileLayer::Config &cfg, const TileLayer::Tileset &ts, const ViewCam &cam, + int viewWidth, int viewHeight, int x0, int y0, int x1, int y1) { + if (!cam.valid || viewWidth <= 0 || viewHeight <= 0) return true; + float minX = 1e30f, minY = 1e30f, maxX = -1e30f, maxY = -1e30f; + const int xs[] = {x0, std::max(x0, x1 - 1)}; + const int ys[] = {y0, std::max(y0, y1 - 1)}; + for (int y : ys) { + for (int x : xs) { + float wx = 0.f, wy = 0.f; + tileToWorld(cfg, x, y, wx, wy); + minX = std::min(minX, wx); + minY = std::min(minY, wy); + maxX = std::max(maxX, wx + cfg.tileW); + maxY = std::max(maxY, wy + cfg.tileH); + } + } + float expandX = cfg.tileW; + float expandY = cfg.tileH; + for (const auto &visual : ts.visuals) { + expandX = std::max(expandX, float(visual.width) + std::fabs(visual.pivotX)); + expandY = std::max(expandY, float(visual.height) + std::fabs(visual.pivotY)); + } + minX -= expandX; + minY -= expandY; + maxX += expandX; + maxY += expandY; + const float zoom = cam.zoom > 0.f ? cam.zoom : 0.0001f; + const float left = cam.x - float(viewWidth) * 0.5f / zoom; + const float right = cam.x + float(viewWidth) * 0.5f / zoom; + const float top = cam.y - float(viewHeight) * 0.5f / zoom; + const float bottom = cam.y + float(viewHeight) * 0.5f / zoom; + return maxX >= left && minX <= right && maxY >= top && minY <= bottom; +} + } // namespace -void TileRenderSystem::collect(std::vector &out) { +void TileRenderSystem::collect(std::vector &out) { collect(out, 0, 0); } + +void TileRenderSystem::collect(std::vector &out, int viewWidth, + int viewHeight) { gLastVisibleTileCount = 0; gLastCustomVisualCount = 0; gLastAtlasCount = 0; + gLastVisitedChunkCount = 0; + gLastVisitedCellCount = 0; if (ecs::current()->getManager() == nullptr) return; std::unordered_set atlases; @@ -112,58 +171,77 @@ void TileRenderSystem::collect(std::vector &out) { const Color &tint = draw->tint; const ViewCam cam = fromEntity(draw->camera); - for (int ty = 0; ty < cfg->mapH; ++ty) { - for (int tx = 0; tx < cfg->mapW; ++tx) { - const uint32_t raw = tiles->gids[size_t(ty * cfg->mapW + tx)]; - const uint32_t gid = tileGid(raw); - if (gid == 0) continue; + const int chunkColumns = std::max(1, tiles->chunkColumns); + const int chunkRows = std::max(1, tiles->chunkRows); + for (int cy = 0; cy < chunkRows; ++cy) { + for (int cx = 0; cx < chunkColumns; ++cx) { + const int chunkIndex = cy * chunkColumns + cx; + if (!tiles->chunks.empty() && + (chunkIndex >= int(tiles->chunks.size()) || + tiles->chunks[size_t(chunkIndex)].nonEmpty == 0)) + continue; + const int x0 = cx * TileLayer::Tiles::kChunkSize; + const int y0 = cy * TileLayer::Tiles::kChunkSize; + const int x1 = std::min(cfg->mapW, x0 + TileLayer::Tiles::kChunkSize); + const int y1 = std::min(cfg->mapH, y0 + TileLayer::Tiles::kChunkSize); + if (!chunkVisible(*cfg, *ts, cam, viewWidth, viewHeight, x0, y0, x1, y1)) + continue; + ++gLastVisitedChunkCount; + for (int ty = y0; ty < y1; ++ty) { + for (int tx = x0; tx < x1; ++tx) { + ++gLastVisitedCellCount; + const uint32_t raw = tiles->gids[size_t(ty * cfg->mapW + tx)]; + const uint32_t gid = animatedGid(*ts, tileGid(raw)); + if (gid == 0) continue; - graphics::DrawItem2D item; - tileToWorld(*cfg, tx, ty, item.x, item.y); - item.w = cfg->tileW; - item.h = cfg->tileH; - item.depthY = tileToDepthY(*cfg, tx, ty); - item.layer = draw->layer; - item.canvas = draw->canvas; - item.camera = draw->camera; - item.camValid = cam.valid; - item.camX = cam.x; - item.camY = cam.y; - item.camZoom = cam.zoom; - item.receiveLight = false; - item.litPath = false; + graphics::DrawItem2D item; + tileToWorld(*cfg, tx, ty, item.x, item.y); + item.w = cfg->tileW; + item.h = cfg->tileH; + item.depthY = tileToDepthY(*cfg, tx, ty); + item.layer = draw->layer; + item.canvas = draw->canvas; + item.camera = draw->camera; + item.camValid = cam.valid; + item.camX = cam.x; + item.camY = cam.y; + item.camZoom = cam.zoom; + item.receiveLight = false; + item.litPath = false; - float u0, v0, u1, v1; - const auto *visual = visualForGid(*ts, gid); - if (visual && visualUV(*ts, *visual, u0, v0, u1, v1)) { - ++gLastCustomVisualCount; - item.x -= visual->pivotX; - item.y -= visual->pivotY; - item.w = float(visual->width); - item.h = float(visual->height); - item.depthY += visual->sortBias; - item.texture = ts->texture; - item.hasUV = true; - item.u0 = u0; - item.v0 = v0; - item.u1 = u1; - item.v1 = v1; - item.color = tint; - } else if (atlasUV(*ts, gid, u0, v0, u1, v1)) { - item.texture = ts->texture; - item.hasUV = true; - item.u0 = u0; - item.v0 = v0; - item.u1 = u1; - item.v1 = v1; - item.color = tint; - } else { - item.texture = nullptr; - item.color = solidForGid(gid, tint); + float u0, v0, u1, v1; + const auto* visual = visualForGid(*ts, gid); + if (visual && visualUV(*ts, *visual, u0, v0, u1, v1)) { + ++gLastCustomVisualCount; + item.x -= visual->pivotX; + item.y -= visual->pivotY; + item.w = float(visual->width); + item.h = float(visual->height); + item.depthY += visual->sortBias; + item.texture = ts->texture; + item.hasUV = true; + item.u0 = u0; + item.v0 = v0; + item.u1 = u1; + item.v1 = v1; + item.color = tint; + } else if (atlasUV(*ts, gid, u0, v0, u1, v1)) { + item.texture = ts->texture; + item.hasUV = true; + item.u0 = u0; + item.v0 = v0; + item.u1 = u1; + item.v1 = v1; + item.color = tint; + } else { + item.texture = nullptr; + item.color = solidForGid(gid, tint); + } + out.push_back(item); + ++gLastVisibleTileCount; + if (item.texture) atlases.insert(item.texture); + } } - out.push_back(item); - ++gLastVisibleTileCount; - if (item.texture) atlases.insert(item.texture); } } } @@ -173,13 +251,19 @@ void TileRenderSystem::collect(std::vector &out) { void TileRenderSystem::render(graphics::Graphics *gfx) { if (!gfx) return; std::vector items; - collect(items); + collect(items, gfx->getWidth(), gfx->getHeight()); graphics::RenderSystem::drawItems(*gfx, items, false); } int TileRenderSystem::lastVisibleTileCount() { return gLastVisibleTileCount; } int TileRenderSystem::lastCustomVisualCount() { return gLastCustomVisualCount; } int TileRenderSystem::lastAtlasCount() { return gLastAtlasCount; } +int TileRenderSystem::lastVisitedChunkCount() { return gLastVisitedChunkCount; } +int TileRenderSystem::lastVisitedCellCount() { return gLastVisitedCellCount; } + +void TileRenderSystem::update(float dt) { + if (dt > 0.f && std::isfinite(dt)) gAnimationTimeMs += double(dt) * 1000.0; +} int TileConfigSystem::poll() { if (ecs::current()->getManager() == nullptr) return 0; diff --git a/src/modules/map/TileSystem.h b/src/modules/map/TileSystem.h index c7e3a97e..4a1750c4 100644 --- a/src/modules/map/TileSystem.h +++ b/src/modules/map/TileSystem.h @@ -18,8 +18,12 @@ namespace eve::map { /** @brief Draws all visible TileLayer entities via Graphics batch path (UV atlas quads). */ class TileRenderSystem { public: + /** @brief Advance TileSet animation clocks once per map update. */ + static void update(float dt); /** @brief Append non-empty visible tiles into a shared 2D draw queue. */ static void collect(std::vector &out); + /** @brief Viewport-aware collection that rejects off-screen chunks before scanning cells. */ + static void collect(std::vector &out, int viewWidth, int viewHeight); /** @brief Collect + draw tiles only (no present). Null gfx is a no-op. */ static void render(graphics::Graphics *gfx); @@ -30,6 +34,9 @@ public: static int lastCustomVisualCount(); /** @brief Number of distinct atlas textures referenced by the last pass. */ static int lastAtlasCount(); + /** @brief Spatial-index work counters for deterministic performance regressions. */ + static int lastVisitedChunkCount(); + static int lastVisitedCellCount(); }; /** diff --git a/test/editor.cpp b/test/editor.cpp index 71efb953..d92e3b2f 100644 --- a/test/editor.cpp +++ b/test/editor.cpp @@ -16,6 +16,10 @@ #include "editor/FieldBrushTool.h" #include "editor/GizmoManager.h" #include "editor/TileBuffer.h" +#ifdef EVENGINE_HAS_MAP +#include "map/Map.h" +#include "map/TileLayer.h" +#endif #include "editor/TransformGizmo.h" #include "common/Exception.h" @@ -329,6 +333,27 @@ TEST_CASE("editor.transactions.undo_redo_and_rollback_any_field") { CHECK_EQ(buffer.getGid(0, 0), 0); } +#ifdef EVENGINE_HAS_MAP +TEST_CASE("editor.map.tileLayerTargetUsesLiveRevision") { + auto *map = eve::map::Map::create(); + auto *layer = map->newLayer(4, 3, 8.f, 8.f); + Editor editor; + std::unique_ptr target(editor.newTileLayerTarget("ground", layer)); + const auto before = target->revision(); + CHECK(target->writeInt(2, 1, 9)); + CHECK_EQ(layer->getTile(2, 1), 9); + CHECK_GT(target->revision(), before); + CHECK_EQ(target->dirtyRegion().minX, 2); + CHECK_EQ(target->dirtyRegion().maxY, 1); + IntFieldEditCommand command("paint", target.get()); + CHECK(command.record(2, 1, 4)); + CHECK(command.apply()); + CHECK_EQ(layer->getTile(2, 1), 4); + command.revert(); + CHECK_EQ(layer->getTile(2, 1), 9); +} +#endif + TEST_CASE("editor.constraints.accept_warn_and_reject_without_core_types") { TileBuffer buffer(4, 2); TileBufferTarget target("tiles", &buffer); diff --git a/test/map.cpp b/test/map.cpp index c1f5c29d..7d3dcbbb 100644 --- a/test/map.cpp +++ b/test/map.cpp @@ -31,6 +31,8 @@ #include "map/TileProjection.h" #include "map/TileSystem.h" #include "window/Window.h" +#include "common/Capability.h" +#include "map/TileCollision.h" #include #include @@ -40,6 +42,19 @@ using namespace eve::map; +namespace { +class CollisionSinkMock final : public ITileCollisionSink { +public: + void replaceTileCollision(const void *source, const TileCollisionRect *rects, + size_t count) override { + layer = source; + values.assign(rects, rects + count); + } + const void *layer = nullptr; + std::vector values; +}; +} // namespace + namespace { void hideAllTileLayers() { @@ -91,6 +106,52 @@ TEST_CASE("map.layer.resizeClears") { CHECK_EQ(layer->getTile(0, 0), 0); } +TEST_CASE("map.layer.chunkIndexAndRevision") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(96, 65, 8.f, 8.f); + CHECK_EQ(layer->getChunkSize(), 32); + CHECK_EQ(layer->getChunkCount(), 9); + CHECK_EQ(layer->getNonEmptyChunkCount(), 0); + const int revision = layer->getRevision(); + layer->setTile(0, 0, 1); + layer->setTile(64, 64, 2); + CHECK_EQ(layer->getNonEmptyChunkCount(), 2); + CHECK_GT(layer->getRevision(), revision); + const int unchanged = layer->getRevision(); + layer->setTile(64, 64, 2); + CHECK_EQ(layer->getRevision(), unchanged); + layer->setTile(0, 0, 0); + CHECK_EQ(layer->getNonEmptyChunkCount(), 1); +} + +TEST_CASE("map.layer.fillRectClipsAndPublishesOnce") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(64, 64, 8.f, 8.f); + const int before = layer->getRevision(); + layer->fillRect(-2, -2, 5, 5, 7); + CHECK_EQ(layer->getTile(0, 0), 7); + CHECK_EQ(layer->getTile(2, 2), 7); + CHECK_EQ(layer->getTile(3, 3), 0); + CHECK_EQ(layer->getNonEmptyChunkCount(), 1); + CHECK_EQ(layer->getRevision(), before + 1); + layer->fillRect(31, 31, 2, 2, 8); + CHECK_EQ(layer->getNonEmptyChunkCount(), 4); +} + +TEST_CASE("map.render.sparseChunksBoundCollectionWork") { + hideAllTileLayers(); + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(1024, 1024, 8.f, 8.f); + layer->setTile(1, 1, 1); + layer->setTile(900, 900, 2); + std::vector items; + TileRenderSystem::collect(items); + CHECK_EQ(int(items.size()), 2); + CHECK_EQ(TileRenderSystem::lastVisitedChunkCount(), 2); + CHECK_EQ(TileRenderSystem::lastVisitedCellCount(), 2 * 32 * 32); + layer->setVisible(false); +} + TEST_CASE("map.layer.applyConfigFlat") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(2, 2, 16.f, 16.f); @@ -188,6 +249,87 @@ TEST_CASE("map.tileset.manifestShapeInMapConfig") { layer->setVisible(false); } +TEST_CASE("map.layer.applyTiledInfiniteChunks") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(1, 1, 16.f, 16.f); + const char *json = R"({ + "infinite":true,"width":0,"height":0,"tilewidth":16,"tileheight":16, + "layers":[{"type":"tilelayer","chunks":[ + {"x":-2,"y":-1,"width":2,"height":2,"data":[1,2,3,4]}, + {"x":0,"y":-1,"width":2,"height":2,"data":[5,6,7,8]} + ]}] + })"; + CHECK(layer->applyConfig(json)); + CHECK_EQ(layer->getMapWidth(), 4); + CHECK_EQ(layer->getMapHeight(), 2); + CHECK_EQ(layer->getTile(0, 0), 1); + CHECK_EQ(layer->getTile(3, 1), 8); + CHECK_EQ(layer->getX(), -32.f); + CHECK_EQ(layer->getY(), -16.f); +} + +TEST_CASE("map.tileset.v2AnimationCustomDataAndTerrain") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(3, 3, 16.f, 16.f); + layer->addTileAnimationFrame(10, 11, 100); + layer->addTileAnimationFrame(10, 12, 150); + CHECK_EQ(layer->getTileAnimationFrameCount(10), 2); + layer->setTileDataString(10, "biome", "forest"); + layer->setTileDataNumber(10, "damage", 2.5f); + layer->setTileDataBool(10, "wet", true); + CHECK_EQ(layer->getTileDataType(10, "damage"), std::string("number")); + CHECK_EQ(layer->getTileDataString(10, "biome"), std::string("forest")); + CHECK_EQ(layer->getTileDataNumber(10, "damage"), 2.5f); + CHECK(layer->getTileDataBool(10, "wet")); + layer->setTerrainRule(20, 1, 0); + layer->setTerrainRule(21, 1, 1 << 3); + layer->paintTerrain(1, 1, 1); + CHECK_EQ(layer->getTerrain(1, 1), 1); + CHECK_EQ(layer->getTile(1, 1), 20); + layer->paintTerrain(2, 1, 1); + CHECK_EQ(layer->getTile(1, 1), 21); + layer->clearTileAnimation(10); + CHECK_EQ(layer->getTileAnimationFrameCount(10), 0); +} + +TEST_CASE("map.collision.greedyMergeAndCapabilityPublish") { + Map map; + TileLayer *layer = map.newLayer(5, 4, 16.f, 8.f); + layer->setTileMetadata(9, 1, 1, false); + layer->fillRect(1, 1, 3, 2, 9); + CollisionSinkMock sink; + eve::cap::addListener(&sink); + + CHECK(map.publishCollision(layer) == 1); + CHECK(map.getCollisionRectCount() == 1); + CHECK(map.getCollisionRectX(0) == 16.f); + CHECK(map.getCollisionRectY(0) == 8.f); + CHECK(map.getCollisionRectWidth(0) == 48.f); + CHECK(map.getCollisionRectHeight(0) == 16.f); + CHECK(sink.layer == layer); + REQUIRE(sink.values.size() == 1); + CHECK(sink.values[0].width == 48.f); + eve::cap::removeListener(&sink); +} + +TEST_CASE("map.tileset.v2ManifestReadsTiledFields") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(1, 1, 16.f, 16.f); + const char *json = R"({ + "width":1,"height":1,"data":[1], + "tileset":{"firstGid":1,"columns":4,"tileWidth":16,"tileHeight":16, + "tiles":[{"id":0,"animation":[{"tileid":1,"duration":80},{"tileid":2,"duration":120}], + "terrain":2,"neighborMask":0, + "properties":[{"name":"damage","type":"float","value":3.5}]}]} + })"; + CHECK(layer->applyConfig(json)); + CHECK_EQ(layer->getTileAnimationFrameCount(1), 2); + CHECK_EQ(layer->getTileDataType(1, "damage"), std::string("float")); + CHECK_EQ(layer->getTileDataString(1, "damage"), std::string("3.5")); + layer->paintTerrain(0, 0, 2); + CHECK_EQ(layer->getTile(0, 0), 1); +} + TEST_CASE("map.render.nullSafe") { auto *mod = Map::create(); TileLayer *layer = mod->newLayer(2, 2, 8.f, 8.f); diff --git a/test/map_fov.cpp b/test/map_fov.cpp index 9bfca2a4..0a26ac55 100644 --- a/test/map_fov.cpp +++ b/test/map_fov.cpp @@ -219,6 +219,22 @@ TEST_CASE("map.fov.layerOpaqueGid") { delete fov; } +TEST_CASE("map.fov.boundLayerAutoSyncsRevision") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(7, 1, 8.f, 8.f); + layer->fill(2); + Fov *fov = mod->newFov(layer); + fov->setBlockEmpty(false); + fov->blockOpaqueGid(1); + fov->addRevealer(0, 0, 7); + fov->compute(); + CHECK(fov->isVisible(6, 0)); + layer->setTile(3, 0, 1); + fov->compute(); + CHECK(!fov->isVisible(6, 0)); + delete fov; +} + TEST_CASE("map.fov.removeAndDisable") { auto *mod = Map::create(); Fov *fov = mod->newFovSize(5, 5); diff --git a/test/map_path.cpp b/test/map_path.cpp index dd7fd156..b64fcc45 100644 --- a/test/map_path.cpp +++ b/test/map_path.cpp @@ -436,3 +436,23 @@ TEST_CASE("map.path.accessorRoundTrips") { delete pf; } + +TEST_CASE("map.path.boundLayerAutoSyncsRevision") { + auto *mod = Map::create(); + TileLayer *layer = mod->newLayer(5, 1, 8.f, 8.f); + layer->fill(2); + Pathfinder *pf = mod->newPathfinder(layer); + pf->blockGid(1); + Path *initial = pf->findPath(0, 0, 4, 0); + CHECK_GT(initial->getLength(), 0); + delete initial; + layer->setTile(2, 0, 1); + Path *blocked = pf->findPath(0, 0, 4, 0); + CHECK_EQ(blocked->getLength(), 0); + delete blocked; + layer->setTile(2, 0, 2); + Path *open = pf->findPath(0, 0, 4, 0); + CHECK_GT(open->getLength(), 0); + delete open; + delete pf; +} From 1bbd8b57b2ce4261f02d5dc4486c5e28fb870ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Tue, 25 Aug 2026 00:26:09 +0800 Subject: [PATCH 2/2] refactor(spritestack): use the 2D rendering pipeline --- cmake/module_manifest.cmake | 2 +- docs/usr/modules/spritestack.md | 181 +-- examples/sprite-stack/README.md | 33 +- examples/sprite-stack/main.nut | 159 +-- scripts/compile_spritestack_shaders.py | 68 -- src/modules/spritestack/SpriteStack.cpp | 774 +----------- src/modules/spritestack/SpriteStack.h | 279 +---- src/modules/spritestack/SpriteStack2D.cpp | 279 +++++ .../spritestack/shaders/sprite_stack.frag | 21 - .../spritestack/shaders/sprite_stack.vert | 31 - .../shaders/sprite_stack_frag_spv.inc | 44 - .../shaders/sprite_stack_vert_spv.inc | 75 -- test/spritestack.cpp | 1056 +++-------------- 13 files changed, 552 insertions(+), 2450 deletions(-) delete mode 100644 scripts/compile_spritestack_shaders.py create mode 100644 src/modules/spritestack/SpriteStack2D.cpp delete mode 100644 src/modules/spritestack/shaders/sprite_stack.frag delete mode 100644 src/modules/spritestack/shaders/sprite_stack.vert delete mode 100644 src/modules/spritestack/shaders/sprite_stack_frag_spv.inc delete mode 100644 src/modules/spritestack/shaders/sprite_stack_vert_spv.inc diff --git a/cmake/module_manifest.cmake b/cmake/module_manifest.cmake index 95153112..54ac15c7 100644 --- a/cmake/module_manifest.cmake +++ b/cmake/module_manifest.cmake @@ -262,7 +262,7 @@ eve_declare_module(NAME voxel LAYER 5 SCRIPT Voxel GROUP 3d) eve_declare_module(NAME spritestack LIB EVSpriteStack LAYER 4 SCRIPT SpriteStack SLOT spritestack DEPS graphics image model3d - GROUP 3d) + GROUP 2d) eve_declare_module(NAME housegen LIB EVHouseGen LAYER 4 SCRIPT HouseGen DEPS data graphics image model3d) eve_declare_module(NAME card LAYER 4 SCRIPT Card diff --git a/docs/usr/modules/spritestack.md b/docs/usr/modules/spritestack.md index 56bab1d0..10f5c4b4 100644 --- a/docs/usr/modules/spritestack.md +++ b/docs/usr/modules/spritestack.md @@ -1,171 +1,62 @@ -# Sprite-Stacking 伪 3D +# SpriteStack 2D 叠片渲染 -**脚本入口:** `eve.SpriteStack()` - -把三维模型沿某个轴切成 `layerCount` 张薄片图(RGBA),运行时刻把这些薄片按 -世界坐标叠起来渲染,从设计视角看就是有体积的伪 3D 物体——经典 sprite-stacking -技术,常用于低多边形 / 像素风游戏的角色与物件。 +SpriteStack 使用一组带透明通道的水平切片,在普通 2D 渲染管线中逐层偏移绘制,形成有体积感的 2.5D 图像。它不创建 3D 网格、3D 相机、深度缓冲或 3D 材质。 ## 基本用法 ```squirrel -// 1. 切片:程序化几何体(cylinder / sphere / cone / box),无需资产文件 -local layers = spritestack.slicePrimitive("cylinder", 20, 128, 128, "z", 0.0); - -// 也支持对 3D 模型文件切片(model3d 模块) -// local md = model3d.newModelDataFromFile("assets/tree.obj"); -// local layers = spritestack.sliceModel(md, 20, 128, 128, "z", 0.0); - -// 2. 建叠片对象并填充层纹理 +local layers = spritestack.slicePrimitive("cylinder", 20, 128, 128, "y", 0.0); local stack = spritestack.newStack(gfx); stack.setLayerCount(layers.len()); -for (local i = 0; i < layers.len(); i++) - stack.setLayerImage(gfx, layers[i], i); +for (local i = 0; i < layers.len(); i++) stack.setLayerImage(gfx, layers[i], i); +// 也可逐层加载:stack.setLayerFile(gfx, "assets/layer.png", 0); +// 横向图集可一次拆层:stack.setLayersFromAtlas(gfx, atlas, 20); -stack.setThickness(0.13); // 相邻切片的世界间距 -stack.setSize(2.4, 2.8); // 切片四边形尺寸(世界单位) -stack.setPosition(0.0, 0.0, 0.0); -stack.setYaw(0.0); -stack.setTint(0.35, 0.78, 0.72, 1.0); -stack.setMode("vertical"); // "vertical" | "horizontal" -stack.setShadowEnabled(true); // 投影假阴影 -stack.setOutline(0.05, 0.02, 0.03, 0.04); // 描边 -``` +stack.setPosition(480.0, 390.0); // 2D 屏幕坐标 +stack.setSize(180.0, 180.0); // 每张切片的像素尺寸 +stack.setThickness(4.0); // 相邻切片的像素偏移 +stack.setRotation(25.0); // 2D 旋转角度,单位为度 +stack.setTint(0.32, 0.82, 0.68, 1.0); +stack.setVisible(true); +stack.setShadowEnabled(true); +stack.setShadowOpacity(0.42); +stack.setShadowOffset(12.0, 9.0); +stack.setOutline(2.0, 0.02, 0.03, 0.04); -渲染时机:在 `gfx.render3D()` 之后、`present()` 之前调用,切片会进入 3D 前向 -通道,和场景内其他网格正确深度遮挡。 - -```squirrel function eve_render() { gfx.clear(); - gfx.render3D(); - stack.render(gfx); // 自动使用当前激活的 Camera3D - // stack.renderWithCamera(gfx, myCamera); // 也可显式指定相机 + stack.render(gfx); } ``` -## 两种切片模式 +`render(gfx)` 直接按屏幕像素解释坐标;需要世界坐标时调用 `stack.renderWithCamera(gfx, camera)`。批对象同样提供 `batch.renderWithCamera(gfx, camera)`。 -| 模式 | 切片轴 | 运行时刻渲染 | 适合 | -|------|--------|--------------|------| -| `"vertical"`(默认) | `"z"` 等水平轴 | 朝向相机的竖直 billboard,yaw 旋转叠片的深度布局 | 经典「面包切片」伪 3D,正面视角最扎实 | -| `"horizontal"` | `"y"`(俯视) | 水平四边形,3/4 相机看到真实体积视差 | 俯视 / 斜 45° 视角的场景物件 | - -切片轴与模式要匹配:`slicePrimitive(..., "z", ...)` + `"vertical"`, -或 `slicePrimitive(..., "y", ...)` + `"horizontal"`。 - -## 对象与参数 - -- `newStack(gfx)`:新建空叠片(脚本 VM 持有;不要直接构造 `eve.SpriteStack3D`)。 -- 图层:`setLayerCount(n)`、`setLayerTexture(tex, i)`、`setLayerImage(gfx, img, i)`、 - `setLayerFile(gfx, path, i)`、`setLayersFromAtlas(gfx, atlasTex, count)`(单张横向 - 条带纹理按列切成 count 层)。 -- 变换:`setThickness(t)`、`setSize(w, h)`、`setPosition(x, y, z)`、`setYaw(rad)`。 -- 外观:`setTint(r, g, b, a)`、`setAlphaCutoff(v)`、`setVisible(bool)`、`setMode(mode)`。 -- 渲染:`render(gfx)` 或 `renderWithCamera(gfx, camera)`。 - -## 高级功能 - -### 叠片投影阴影 - -`setShadowEnabled(true)` 后,每层切片沿光方向压到地面平面(`setShadowPlaneY(y)`), -用切片本身的剪影画半透明黑色,形成接触阴影: +## 图层和批处理 ```squirrel -stack.setShadowEnabled(true); -stack.setShadowOpacity(0.38); -stack.setShadowLight(-0.45, -1.0, -0.35); // 光照方向(指向地面) -stack.setShadowPlaneY(-1.53); // 地面高度 -``` +local count = stack.getLayerCount(); +local texture = stack.getLayerTexture(0); +stack.setLayerTexture(texture, 0); +stack.setLayerUV(0, 0.0, 0.0, 1.0, 1.0); +local spacing = stack.getThickness(); -这是经典的假阴影(不是 shadow map),适合固定视角的伪 3D 场景;内部会把阴影 -平面抬高 2cm 避免与地面深度冲突。 - -合批时阴影同样生效:`SpriteStackBatch` 会把投影阴影按黑色色调单独分组烘焙, -不额外增加 draw call。 - -### 叠片描边 - -`setOutline(width, r, g, b)` 给每层画一个放大 k 的黑色剪影垫在切片后面,形成 -风格化描边(配合 `stylize` 的 ink / pixel 后处理很搭): - -```squirrel -stack.setOutline(0.05, 0.02, 0.03, 0.04); -``` - -合批时描边同样生效(按描边色调单独分组)。 - -### 叠片参与 G-Buffer(AO / 描边后处理) - -默认叠片只画进前向通道,AO / 描边这类读取 G-Buffer 深度的后处理看不到它们。 -开启 `setGbufferEnabled(true)` 后,叠片会通过 `RenderSystem3D` 的扩展绘制钩子 -写进 G-Buffer(alpha 剔除管线),描边和 AO 就能沿切片剪影生效: - -```squirrel -stack.setGbufferEnabled(true); -gfx.getRenderControl().enable("outline"); -``` - -### 叠片投射 CSM 阴影 - -默认叠片不写 shadow map。开启 `setCastShadow(true)` 后,切片通过 alpha 剔除的 -shadow 管线写进级联阴影贴图(CSM),在地面等接收阴影的网格上留下**剪影形状的 -阴影**,而不是整块矩形: - -```squirrel -stack.setCastShadow(true); -``` - -需要场景里有 `castShadow` 的平行光;如果没有 `Light3D`,会回退用 -`gfx.setDirectionalLight(...)` 设置的旧版平行光作为阴影光源。与 `setShadowEnabled` -的投影假阴影相互独立,可同时开启。 - -### 多叠片合批 `SpriteStackBatch` - -多个叠片如果共享图层纹理(`setLayerTexture` 传入同一个 `Texture`),可以用 -`newBatch(gfx)` 把它们合成每帧一次 draw call(按纹理 + 色调分组): - -```squirrel local batch = spritestack.newBatch(gfx); -batch.add(stackA); -batch.add(stackB); -// eve_render 中:batch.render(gfx); +batch.add(stack); +batch.remove(stack); +batch.add(stack); +batch.render(gfx); +batch.clear(); ``` -合批会在叠片内容变化(位置 / 缩放 / 纹理 / 色调)时重建网格(`Graphics::updateMeshVertices` -原地更新);每帧都旋转/移动的叠片建议直接用 `stack.render`,静态道具用合批收益最大。 -合批会一并烘焙逐叠片的阴影与描边(它们按各自色调分组)。 +批对象提交多个 `SpriteStack2D`;实际纹理由 Graphics 的 2D texture batch 合并。切片从底层到顶层绘制,因此透明边缘和层间遮挡仍遵循 2D painter order。 -### Atlas 条带打包工具 +## 模型切片 -`scripts/pack_sprite_stack_atlas.py` 把一组等尺寸的层 PNG 横向打包成一张条带, -运行时用 `setLayersFromAtlas(gfx, atlasTex, count)` 直接按列切回: +如果已有 `ModelData`,可以离线或加载时生成切片: -```bash -python3 scripts/pack_sprite_stack_atlas.py layer_00.png layer_01.png ... \ - -o atlas.png --json atlas.json +```squirrel +local model = model3d.newModelDataFromFile("assets/rock.obj"); +local layers = spritestack.sliceModel(model, 24, 128, 128, "y", 0.0); ``` -## 切片 API - -- `slicePrimitive(kind, layerCount, imageW, imageH, axis, thickness)`: - 程序化几何体切片,`kind` ∈ `"box" | "cylinder" | "sphere" | "cone"`; - `axis` ∈ `"x" | "y" | "z"`;`thickness <= 0` 时按包围盒自动均分。 -- `sliceModel(modelData, layerCount, imageW, imageH, axis, thickness)`: - 对 `model3d.newModelDataFromFile(...)` 的模型切片。 -- 两者都返回 `ImageData` 数组(RGBA8),可直接传给 `gfx.newTexture` 或 - `stack.setLayerImage`。 - -## 使用要点 - -- 切片的 `ImageData` 归脚本持有:传入 `setLayerImage` 后叠片内部已转为 GPU - 纹理,可释放原图。 -- 叠片使用 alpha 混合 + 深度测试(不写深度)的管线,同堆切片每帧按相机距离 - 由远到近排序;跨叠片的重叠由绘制顺序决定。 -- 目前仅在 Vulkan 桌面端可用(WebGPU/WASM 精简构建不含该模块)。 -- 不要每帧重新切片或重新上传纹理;只在物体形状变化时重建。 -- `updateMeshVertices`(合批内部使用)与 GPU 同步,重建频率越高开销越大。 - -**源码:** [`src/modules/spritestack/`](../../../src/modules/spritestack/) -**示例:** [`examples/sprite-stack/`](../../../examples/sprite-stack/) -**相关测试:** [`test/spritestack.cpp`](../../../test/spritestack.cpp) +这里的模型只用于生成 RGBA 切片;运行时仍走 2D 管线。`slicePrimitive` 支持 `cylinder`、`sphere`、`cone`、`box`,切片轴支持 `x`、`y`、`z`。 diff --git a/examples/sprite-stack/README.md b/examples/sprite-stack/README.md index 1d1fd24e..a9e88ffd 100644 --- a/examples/sprite-stack/README.md +++ b/examples/sprite-stack/README.md @@ -1,34 +1,13 @@ -# Sprite Stacking +# SpriteStack 2D 示例 -伪 3D sprite-stacking 示例:把程序化生成的三维几何体(圆柱 / 球 / 圆锥 / 方块) -在 CPU 上沿指定轴切成多层 RGBA 图,再以「叠片」方式画进 3D 前向渲染通道。 +这个示例把程序化几何体切成 RGBA 水平切片,再通过 Graphics 的普通 2D 纹理管线逐层偏移绘制。运行阶段没有 3D 相机、3D 网格、深度缓冲或专用 3D shader。 运行: -```bash -eve run examples/sprite-stack +```powershell +build\\win32-debug\\src\\engine\\eve.exe run examples\\sprite-stack ``` -操作: +操作:A / D 旋转叠片,1~4 切换圆柱、球、圆锥、方盒,Q / E 调整层间像素距离。 -| 键 | 作用 | -|----|------| -| `A` / `D` | 绕 Y 轴旋转叠片堆(伪 3D 视差) | -| `W` / `S` | 拉近 / 拉远相机 | -| `H` | 切换 vertical(朝向相机的竖直切片)/ horizontal(水平俯视切片)模式 | -| `1`..`4` | 切换圆柱 / 球 / 圆锥 / 方块 | -| `M` | 切换程序化几何体 / `assets/rock.obj`(`sliceModel` 模型文件切片) | -| `Q` / `E` | 调整切片厚度 | -| `R` | 重新切片 | - -技术要点: - -- 切片:`spritestack.slicePrimitive(...)`(或 `sliceModel` 对 3D 模型文件切片) - 把网格切成 `layerCount` 个薄片,每个薄片正交投影成一张 RGBA 图。 -- 渲染:`SpriteStack3D` 用 alpha 混合 + 深度测试的 hair 管线绘制切片, - `vertical` 模式为朝向相机的 billboard,`horizontal` 模式为水平四边形; - 每帧按相机距离由远到近排序。 -- 调用时机:`gfx.render3D()` 之后、`present()` 之前调用 `stack.render(gfx)`, - 切片即与场景内其他 3D 网格正确遮挡。 -- 高级功能:`setShadowEnabled` 投影假阴影、`setOutline` 风格化描边;另外三个 - 共享纹理的小石头通过 `SpriteStackBatch` 合批成单次 draw。 +主物体直接调用 `stack.render(gfx)`;左右两个副本通过 `SpriteStackBatch` 提交,共享同一组切片纹理。 diff --git a/examples/sprite-stack/main.nut b/examples/sprite-stack/main.nut index c8ff34f9..6e33eafe 100644 --- a/examples/sprite-stack/main.nut +++ b/examples/sprite-stack/main.nut @@ -1,138 +1,67 @@ -// Sprite stacking pseudo-3D demo. -// -// A 3D primitive is sliced into thin RGBA layers on the CPU, then rendered as -// a stack of alpha-blended slices inside the 3D forward pass. From the design -// view angle the stack reads as a solid 3D object; rotating the camera (or the -// stack yaw) reveals the classic pseudo-3D parallax. -// -// Controls: -// A / D rotate stack yaw -// W / S camera distance -// H toggle vertical (billboard) / horizontal (top-down) mode -// 1..4 primitive: cylinder / sphere / cone / box -// M toggle procedural primitive / rock.obj (model-file slicing) -// Q / E slice thickness -// R rebuild (reslice) the stack - -if (!("stackYaw" in getroottable())) stackYaw <- 0.0; -if (!("camDist" in getroottable())) camDist <- 6.0; -if (!("stackMode" in getroottable())) stackMode <- "vertical"; -if (!("stackKind" in getroottable())) stackKind <- "cylinder"; -if (!("sliceCount" in getroottable())) sliceCount <- 20; -if (!("sliceThick" in getroottable())) sliceThick <- 0.0; +// Classic SpriteStack rendered entirely by the normal 2D pipeline. +// A / D rotate, 1..4 switch primitive, Q / E change slice spacing. +if (!("angle" in getroottable())) angle <- 0.0; +if (!("kind" in getroottable())) kind <- "cylinder"; +if (!("spacing" in getroottable())) spacing <- 4.0; if (!("stack" in getroottable())) stack <- null; if (!("batch" in getroottable())) batch <- null; -if (!("satellites" in getroottable())) satellites <- []; -if (!("rockLayers" in getroottable())) rockLayers <- null; -if (!("useModel" in getroottable())) useModel <- false; -if (!("prevKeys" in getroottable())) prevKeys <- {}; +if (!("copies" in getroottable())) copies <- []; local tints = { - cylinder = [0.35, 0.78, 0.72], - sphere = [0.85, 0.52, 0.28], - cone = [0.55, 0.42, 0.30], - box = [0.42, 0.55, 0.85], + cylinder = [0.32, 0.82, 0.68], sphere = [0.92, 0.52, 0.26], + cone = [0.68, 0.48, 0.30], box = [0.42, 0.62, 0.92] }; -function pressed(k) { - return key_just_pressed(k); -} - -function rebuildStack() { - local axis = stackMode == "vertical" ? "z" : "y"; - if (useModel) { - local md = model3d.newModelDataFromFile("assets/rock.obj"); - rockLayers = spritestack.sliceModel(md, 22, 128, 128, axis, 0.0); - } else { - rockLayers = null; - } - local layers = rockLayers != null - ? rockLayers - : spritestack.slicePrimitive(stackKind, sliceCount, 128, 128, axis, 0.0); +function rebuild() { + local layers = spritestack.slicePrimitive(kind, 20, 128, 128, "y", 0.0); stack = spritestack.newStack(gfx); stack.setLayerCount(layers.len()); - for (local i = 0; i < layers.len(); i++) - stack.setLayerImage(gfx, layers[i], i); - stack.setThickness(stackMode == "vertical" ? 0.12 : 0.17); - stack.setSize(2.4, 2.8); - stack.setPosition(0.0, 0.0, 0.0); - stack.setMode(stackMode); - local t = useModel ? [0.55, 0.48, 0.40] : tints[stackKind]; - stack.setTint(t[0], t[1], t[2], 1.0); + for (local i = 0; i < layers.len(); i++) stack.setLayerImage(gfx, layers[i], i); + stack.setPosition(480.0, 390.0); + stack.setSize(180.0, 180.0); + stack.setThickness(spacing); + local tint = tints[kind]; + stack.setTint(tint[0], tint[1], tint[2], 1.0); stack.setShadowEnabled(true); - stack.setShadowOpacity(0.38); - stack.setShadowLight(-0.45, -1.0, -0.35); - stack.setShadowPlaneY(-1.53); - stack.setCastShadow(true); // real CSM shadow from the slice silhouettes - if (stackMode == "vertical") stack.setOutline(0.045, 0.02, 0.03, 0.04); - rebuildSatellites(); -} + stack.setShadowOpacity(0.42); + stack.setShadowOffset(12.0, 9.0); + stack.setOutline(2.0, 0.02, 0.03, 0.04); -// A few small copies sharing the main stack's layer textures, drawn in a -// single batched draw call per (texture, tint) group. -function rebuildSatellites() { - if (batch == null) batch = spritestack.newBatch(gfx); - batch.clear(); - satellites = []; - local offsets = [[2.6, 0.9], [-2.5, 1.0], [3.0, -1.2]]; - foreach (off in offsets) { - local s = spritestack.newStack(gfx); - s.setLayerCount(stack.getLayerCount()); + batch = spritestack.newBatch(gfx); + copies = []; + foreach (x in [190.0, 770.0]) { + local copy = spritestack.newStack(gfx); + copy.setLayerCount(stack.getLayerCount()); for (local i = 0; i < stack.getLayerCount(); i++) - s.setLayerTexture(stack.getLayerTexture(i), i); - s.setThickness(stack.getThickness()); - s.setSize(0.85, 0.95); - s.setPosition(off[0], 0.0, off[1]); - s.setMode(stackMode); - s.setTint(0.58, 0.52, 0.46, 1.0); - batch.add(s); - satellites.append(s); + copy.setLayerTexture(stack.getLayerTexture(i), i); + copy.setPosition(x, 360.0); + copy.setSize(105.0, 105.0); + copy.setThickness(spacing * 0.55); + copy.setTint(tint[0], tint[1], tint[2], 1.0); + copy.setShadowEnabled(true); + batch.add(copy); + copies.append(copy); } } -local cam = eve.Camera3D(); -cam.setEye(0.0, 2.8, camDist); -cam.setTarget(0.0, 0.2, 0.0); -cam.setUp(0.0, 1.0, 0.0); -cam.setFov(45.0); -cam.setAmbient(0.32, 0.35, 0.38); -cam.setActive(true); -gfx.setDirectionalLight(-0.45, -1.0, -0.35, 1.25, 1.18, 1.05); -gfx.setBackgroundColor(0.075, 0.10, 0.12, 1.0); - -// Flat ground disc so the stack's depth reads against the scene. -local ground = eve.Renderable3D(); -ground.setMesh(gfx.newMeshCylinder(48, 1, true)); -ground.setPosition(0.0, -1.55, 0.0); -ground.setScale(9.0, 0.18, 9.0); -ground.setTint(0.20, 0.30, 0.24, 1.0); -ground.setRoughness(0.9); - -rebuildStack(); +gfx.setBackgroundColor(0.055, 0.075, 0.095, 1.0); +rebuild(); function eve_update(dt) { - if (pressed("h") || pressed("H")) { - stackMode = stackMode == "vertical" ? "horizontal" : "vertical"; - rebuildStack(); - } - if (pressed("r") || pressed("R")) rebuildStack(); - if (pressed("1")) { stackKind = "cylinder"; rebuildStack(); } - if (pressed("2")) { stackKind = "sphere"; rebuildStack(); } - if (pressed("3")) { stackKind = "cone"; rebuildStack(); } - if (pressed("4")) { stackKind = "box"; rebuildStack(); } - if (pressed("m") || pressed("M")) { useModel = !useModel; rebuildStack(); } - if (keyboard.isDown("a") || keyboard.isDown("A")) stackYaw -= dt * 0.9; - if (keyboard.isDown("d") || keyboard.isDown("D")) stackYaw += dt * 0.9; - if (keyboard.isDown("w") || keyboard.isDown("W")) camDist = max(2.5, camDist - dt * 3.0); - if (keyboard.isDown("s") || keyboard.isDown("S")) camDist = min(14.0, camDist + dt * 3.0); - stack.setYaw(stackYaw); - cam.setEye(0.0, 2.8, camDist); + if (keyboard.isDown("a") || keyboard.isDown("A")) angle -= dt * 75.0; + if (keyboard.isDown("d") || keyboard.isDown("D")) angle += dt * 75.0; + if (key_just_pressed("1")) { kind = "cylinder"; rebuild(); } + if (key_just_pressed("2")) { kind = "sphere"; rebuild(); } + if (key_just_pressed("3")) { kind = "cone"; rebuild(); } + if (key_just_pressed("4")) { kind = "box"; rebuild(); } + if (key_just_pressed("q") || key_just_pressed("Q")) { spacing = max(1.0, spacing - 1.0); rebuild(); } + if (key_just_pressed("e") || key_just_pressed("E")) { spacing = min(10.0, spacing + 1.0); rebuild(); } + stack.setRotation(angle); + foreach (copy in copies) copy.setRotation(-angle * 0.7); } function eve_render() { gfx.clear(); - gfx.render3D(); - // Draw the pseudo-3D stack into the open 3D pass (before present). stack.render(gfx); batch.render(gfx); } diff --git a/scripts/compile_spritestack_shaders.py b/scripts/compile_spritestack_shaders.py deleted file mode 100644 index 9f89da1b..00000000 --- a/scripts/compile_spritestack_shaders.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -"""Compile sprite-stack GLSL vert/frag to SPIR-V C++ include arrays.""" - -from __future__ import annotations - -import struct -import subprocess -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -SHADER_DIR = ROOT / "src" / "modules" / "spritestack" / "shaders" - -SHADERS = [ - ("sprite_stack.vert", "vert"), - ("sprite_stack.frag", "frag"), -] - - -def spv_to_inc(spv_path: Path, array_name: str, out_path: Path) -> None: - data = spv_path.read_bytes() - if len(data) % 4 != 0: - raise SystemExit(f"SPIR-V size not multiple of 4: {spv_path}") - words = struct.unpack(f"<{len(data) // 4}I", data) - if not words or words[0] != 0x07230203: - raise SystemExit(f"bad SPIR-V magic in {spv_path}") - lines = [f"static const uint32_t {array_name}[] = {{"] - row: list[str] = [] - for i, w in enumerate(words): - row.append(f"0x{w:08x}") - if len(row) == 8: - lines.append(" " + ", ".join(row) + ",") - row = [] - if row: - lines.append(" " + ", ".join(row) + ",") - lines.append("};") - lines.append(f"static const size_t {array_name}_count = {len(words)};") - lines.append("") - out_path.write_text("\n".join(lines), encoding="utf-8") - print(f"wrote {out_path.relative_to(ROOT)} ({len(words)} words)") - - -def compile_shader(src: Path, stage: str) -> Path: - out = src.with_suffix(f".{stage}.spv") - cmd = ["glslc", f"-fshader-stage={stage}", str(src), "-o", str(out)] - r = subprocess.run(cmd, capture_output=True, text=True) - if r.returncode != 0: - raise SystemExit(f"glslc failed for {src.name}:\n{r.stderr or r.stdout}") - return out - - -def main() -> int: - if not SHADER_DIR.is_dir(): - raise SystemExit(f"missing shader dir: {SHADER_DIR}") - for name, stage in SHADERS: - src = SHADER_DIR / name - if not src.is_file(): - raise SystemExit(f"missing {src}") - spv = compile_shader(src, stage) - stem = src.stem # e.g. sprite_stack - array = f"{stem}_{stage}_spv" - spv_to_inc(spv, array, SHADER_DIR / f"{stem}_{stage}_spv.inc") - spv.unlink(missing_ok=True) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/modules/spritestack/SpriteStack.cpp b/src/modules/spritestack/SpriteStack.cpp index cace5c2b..10296bd2 100644 --- a/src/modules/spritestack/SpriteStack.cpp +++ b/src/modules/spritestack/SpriteStack.cpp @@ -1,25 +1,13 @@ #include "spritestack/SpriteStack.h" #include "common/Exception.h" -#include "common/ECS.h" -#include "graphics/ClipSpace.h" -#include "graphics/Graphics.h" -#include "graphics/Mesh.h" -#include "graphics/RenderSystem3D.h" -#include "graphics/Shader.h" -#include "graphics/Texture.h" #include "image/ImageData.h" #include "model3d/ModelData.h" -#include "spritestack/shaders/sprite_stack_frag_spv.inc" -#include "spritestack/shaders/sprite_stack_vert_spv.inc" #include #include #include #include -#include -#include -#include #include #include #include @@ -405,763 +393,7 @@ std::vector slicePrimitiveToLayers(const std::string &kind, } // --------------------------------------------------------------------------- -// SpriteStack3D -// --------------------------------------------------------------------------- - -namespace { - -std::vector copySpv(const uint32_t *data, size_t count) { - return std::vector(data, data + count); -} - -glm::mat4 billboardModel(const glm::vec3 ¢er, float width, float height, - const glm::vec3 &eye) { - glm::vec3 d = eye - center; - d.y = 0.f; - if (glm::length(d) < 1e-4f) d = glm::vec3(0.f, 0.f, 1.f); - d = glm::normalize(d); - const glm::vec3 up(0.f, 1.f, 0.f); - const glm::vec3 right = glm::normalize(glm::cross(up, d)); - const glm::vec3 forward = glm::normalize(glm::cross(right, up)); - glm::mat4 m(1.f); - m[0] = glm::vec4(right * width, 0.f); - m[1] = glm::vec4(up * height, 0.f); - m[2] = glm::vec4(forward, 0.f); - m[3] = glm::vec4(center, 1.f); - return m; -} - -graphics::Camera3D *findActiveCamera3D() { - if (ecs::current()->getManager() == nullptr) return nullptr; - auto camView = ecs::View(); - for (auto it = camView.begin(); it != camView.end(); ++it) { - auto [data] = *it; - if (!data->active || !data->entity) continue; - return data->entity; - } - return nullptr; -} - -} // namespace - -void SpriteStack3D::setLayerCount(int count) { - if (count < 0) throw eve::Exception("SpriteStack3D.setLayerCount: count must be >= 0"); - layerCount_ = count; - layers_.assign(size_t(count), Layer{}); - bumpVersion(); -} - -int SpriteStack3D::getLayerCount() const { return layerCount_; } - -void SpriteStack3D::setLayerTexture(graphics::Texture *texture, int index) { - if (index < 0 || index >= layerCount_) - throw eve::Exception("SpriteStack3D.setLayerTexture: index %d out of range [0,%d)", index, - layerCount_); - layers_[size_t(index)] = Layer{texture, glm::vec4(0.f, 0.f, 1.f, 1.f)}; - bumpVersion(); -} - -graphics::Texture *SpriteStack3D::getLayerTexture(int index) const { - if (index < 0 || index >= layerCount_) return nullptr; - return layers_[size_t(index)].texture; -} - -void SpriteStack3D::setLayerImage(graphics::Graphics *gfx, image::ImageData *img, int index) { - if (!gfx) throw eve::Exception("SpriteStack3D.setLayerImage: null graphics"); - if (!img) throw eve::Exception("SpriteStack3D.setLayerImage: null ImageData"); - setLayerTexture(gfx->newTextureFromImageData(img, false, false), index); -} - -void SpriteStack3D::setLayerFile(graphics::Graphics *gfx, const std::string &path, int index) { - if (!gfx) throw eve::Exception("SpriteStack3D.setLayerFile: null graphics"); - setLayerTexture(gfx->newTextureFromFile(path), index); -} - -void SpriteStack3D::setLayersFromAtlas(graphics::Graphics *gfx, graphics::Texture *atlas, - int layerCount) { - if (!gfx) throw eve::Exception("SpriteStack3D.setLayersFromAtlas: null graphics"); - if (!atlas) throw eve::Exception("SpriteStack3D.setLayersFromAtlas: null atlas"); - if (layerCount <= 0) throw eve::Exception("SpriteStack3D.setLayersFromAtlas: count must be > 0"); - setLayerCount(layerCount); - for (int i = 0; i < layerCount; ++i) { - const float u0 = float(i) / float(layerCount); - const float u1 = float(i + 1) / float(layerCount); - layers_[size_t(i)] = Layer{atlas, glm::vec4(u0, 0.f, u1, 1.f)}; - } - bumpVersion(); -} - -void SpriteStack3D::setThickness(float thickness) { - if (thickness <= 0.f) throw eve::Exception("SpriteStack3D.setThickness: must be > 0"); - thickness_ = thickness; - bumpVersion(); -} - -float SpriteStack3D::getThickness() const { return thickness_; } - -void SpriteStack3D::setSize(float width, float height) { - if (width <= 0.f || height <= 0.f) - throw eve::Exception("SpriteStack3D.setSize: size must be > 0"); - width_ = width; - height_ = height; - bumpVersion(); -} - -float SpriteStack3D::getWidth() const { return width_; } -float SpriteStack3D::getHeight() const { return height_; } - -void SpriteStack3D::setPosition(float x, float y, float z) { - x_ = x; - y_ = y; - z_ = z; - bumpVersion(); -} - -void SpriteStack3D::setYaw(float yaw) { - yaw_ = yaw; - bumpVersion(); -} - -void SpriteStack3D::setTint(float r, float g, float b, float a) { - tintR_ = r; - tintG_ = g; - tintB_ = b; - tintA_ = a; - bumpVersion(); -} - -void SpriteStack3D::setAlphaCutoff(float cutoff) { alphaCutoff_ = clampf(cutoff, 0.f, 1.f); } - -void SpriteStack3D::setVisible(bool visible) { - visible_ = visible; - bumpVersion(); -} - -bool SpriteStack3D::getVisible() const { return visible_; } - -void SpriteStack3D::setMode(const std::string &mode) { - if (mode != "vertical" && mode != "horizontal") - throw eve::Exception("SpriteStack3D.setMode: unknown mode '%s' (vertical|horizontal)", - mode.c_str()); - mode_ = mode; - bumpVersion(); -} - -std::string SpriteStack3D::getMode() const { return mode_; } - -void SpriteStack3D::setShadowEnabled(bool enabled) { - shadowEnabled_ = enabled; - bumpVersion(); -} - -bool SpriteStack3D::getShadowEnabled() const { return shadowEnabled_; } - -void SpriteStack3D::setShadowOpacity(float opacity) { - shadowOpacity_ = clampf(opacity, 0.f, 1.f); - bumpVersion(); -} - -void SpriteStack3D::setShadowLight(float dx, float dy, float dz) { - shadowLightX_ = dx; - shadowLightY_ = dy; - shadowLightZ_ = dz; - bumpVersion(); -} - -void SpriteStack3D::setShadowPlaneY(float y) { - shadowPlaneY_ = y; - bumpVersion(); -} - -void SpriteStack3D::setOutline(float width, float r, float g, float b) { - outlineWidth_ = std::max(0.f, width); - outlineR_ = r; - outlineG_ = g; - outlineB_ = b; - bumpVersion(); -} - -float SpriteStack3D::getOutlineWidth() const { return outlineWidth_; } - -void SpriteStack3D::setOutlineColor(float r, float g, float b) { - outlineR_ = r; - outlineG_ = g; - outlineB_ = b; - bumpVersion(); -} - -void SpriteStack3D::ensureResources(graphics::Graphics *gfx) const { - if (quad_ && shader_) return; - if (!quad_) { - const float posXYZ[] = {-0.5f, 0.5f, 0.f, 0.5f, 0.5f, 0.f, - 0.5f, -0.5f, 0.f, -0.5f, -0.5f, 0.f}; - const float nrmXYZ[] = {0.f, 0.f, 1.f, 0.f, 0.f, 1.f, - 0.f, 0.f, 1.f, 0.f, 0.f, 1.f}; - const float uvST[] = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f}; - const uint32_t indices[] = {0, 1, 2, 0, 2, 3}; - quad_ = gfx->newMeshFromArrays(posXYZ, nrmXYZ, uvST, 4, indices, 6); - } - if (!shader_) { - auto vert = copySpv(sprite_stack_vert_spv, sprite_stack_vert_spv_count); - auto frag = copySpv(sprite_stack_frag_spv, sprite_stack_frag_spv_count); - shader_ = gfx->newHairShaderFromSpv(vert, frag); - if (!shader_ || !shader_->gpuHandle) - throw eve::Exception("SpriteStack3D.render: failed to create slice shader"); - shader_->declareVec4("uvRect"); - shader_->declareFloat("alphaCutoff"); - shader_->sendVec4("uvRect", 0.f, 0.f, 1.f, 1.f); - shader_->sendFloat("alphaCutoff", alphaCutoff_); - } -} - -void SpriteStack3D::collectSlices(const SpriteStack3D &stack, const glm::vec3 &eye, - std::vector &out) { - if (!stack.visible_ || stack.layerCount_ <= 0) return; - const glm::mat4 yawM = glm::rotate(glm::mat4(1.f), stack.yaw_, glm::vec3(0.f, 1.f, 0.f)); - const float half = 0.5f * float(stack.layerCount_ - 1); - for (int i = 0; i < stack.layerCount_; ++i) { - graphics::Texture *tex = stack.layers_[size_t(i)].texture; - if (!tex) continue; - const float offset = stack.thickness_ * (float(i) - half); - glm::vec3 center(stack.x_, stack.y_, stack.z_); - if (stack.mode_ == "horizontal") { - center.y += offset; - } else { - const glm::vec4 off = yawM * glm::vec4(0.f, 0.f, offset, 0.f); - center += glm::vec3(off); - } - SliceDraw s; - const glm::vec3 d = center - eye; - s.distSq = glm::dot(d, d); - if (stack.mode_ == "horizontal") { - // Horizontal top-down layer: rotate image by yaw, keep the quad - // level so a 3/4 camera sees true volume. - s.model = glm::translate(glm::mat4(1.f), center); - s.model = glm::rotate(s.model, stack.yaw_, glm::vec3(0.f, 1.f, 0.f)); - s.model = glm::rotate(s.model, -kPi * 0.5f, glm::vec3(1.f, 0.f, 0.f)); - s.model = glm::scale(s.model, glm::vec3(stack.width_, stack.height_, 1.f)); - } else { - s.model = billboardModel(center, stack.width_, stack.height_, eye); - } - s.texture = tex; - s.uv = stack.layers_[size_t(i)].uv; - out.push_back(s); - } -} - -SpriteStack3D::SliceDraw SpriteStack3D::makeShadowDraw(const SpriteStack3D &stack, - const SliceDraw &s, - const glm::vec3 &eye) { - SliceDraw out = s; - const glm::vec3 light = - glm::normalize(glm::vec3(stack.shadowLightX_, stack.shadowLightY_, stack.shadowLightZ_)); - const glm::vec3 center(s.model[3]); - // Lift the shadow above the ground so the depth test (strict LESS) cannot - // reject it as coplanar with the ground surface. - const float planeY = stack.shadowPlaneY_ + 0.02f; - const float t = (planeY - center.y) / light.y; - const glm::vec3 c = center + light * t; - out.model = glm::translate(glm::mat4(1.f), c); - out.model = glm::rotate(out.model, -kPi * 0.5f, glm::vec3(1.f, 0.f, 0.f)); - out.model = glm::scale(out.model, glm::vec3(stack.width_, stack.height_, 1.f)); - const glm::vec3 d = c - eye; - out.distSq = glm::dot(d, d); - return out; -} - -SpriteStack3D::SliceDraw SpriteStack3D::makeOutlineDraw(const SpriteStack3D &stack, - const SliceDraw &s, - const glm::vec3 &eye) { - SliceDraw out = s; - const glm::vec3 center(s.model[3]); - glm::vec3 away = center - eye; - if (glm::length(away) < 1e-6f) away = glm::vec3(0.f, 0.f, 1.f); - away = glm::normalize(away); - // Push the outline slightly away from the camera so the slice draw (same - // plane) still passes the strict-LESS depth test over it and only the rim - // stays visible. - out.model[3] = glm::vec4(center + away * 0.02f, 1.f); - const float k = stack.outlineWidth_; - out.model = out.model * glm::scale(glm::mat4(1.f), glm::vec3(1.f + 2.f * k, 1.f + 2.f * k, 1.f)); - return out; -} - -std::vector &SpriteStack3D::gbufferStacks() { - static std::vector stacks; - return stacks; -} - -void SpriteStack3D::registerGbufferDrawer() { - static bool registered = false; - if (registered) return; - registered = true; - eve::graphics::RenderSystem3D::addGBufferExtraDrawer( - [](eve::graphics::Graphics &gfx, const eve::graphics::Camera3D::Data &cam, - const glm::mat4 &viewProj, float /*aspect*/) { - drawGbufferStacks(gfx, viewProj, cam.eyeX, cam.eyeY, cam.eyeZ, cam.nearZ, cam.farZ); - }); -} - -void SpriteStack3D::drawGbufferStacks(eve::graphics::Graphics &gfx, const glm::mat4 &viewProj, - float eyeX, float eyeY, float eyeZ, float nearZ, - float farZ) { - const glm::vec3 eye(eyeX, eyeY, eyeZ); - std::vector slices; - for (SpriteStack3D *st : gbufferStacks()) { - if (!st || !st->visible_ || st->layerCount_ <= 0) continue; - st->ensureResources(&gfx); - if (!st->quad_) continue; - slices.clear(); - collectSlices(*st, eye, slices); - for (const auto &s : slices) { - const glm::mat4 mvp = viewProj * s.model; - gfx.drawMeshGBufferAlpha(st->quad_, mvp, s.model, nearZ, farZ, s.texture, - st->tintR_, st->tintG_, st->tintB_); - } - } -} - -void SpriteStack3D::setGbufferEnabled(bool enabled) { - gbufferEnabled_ = enabled; - auto &stacks = gbufferStacks(); - auto it = std::find(stacks.begin(), stacks.end(), this); - if (enabled && it == stacks.end()) stacks.push_back(this); - if (!enabled && it != stacks.end()) stacks.erase(it); - registerGbufferDrawer(); -} - -bool SpriteStack3D::getGbufferEnabled() const { return gbufferEnabled_; } - -std::vector &SpriteStack3D::shadowCasterStacks() { - static std::vector stacks; - return stacks; -} - -void SpriteStack3D::registerShadowDrawer() { - static bool registered = false; - if (registered) return; - registered = true; - eve::graphics::RenderSystem3D::addShadowExtraDrawer( - [](eve::graphics::Graphics &gfx, const glm::mat4 &lightVP, - const eve::graphics::Camera3D::Data &cam) { - drawShadowCasterStacks(gfx, lightVP, cam.eyeX, cam.eyeY, cam.eyeZ); - }); -} - -void SpriteStack3D::drawShadowCasterStacks(eve::graphics::Graphics &gfx, - const glm::mat4 &lightVP, float eyeX, float eyeY, - float eyeZ) { - const glm::vec3 eye(eyeX, eyeY, eyeZ); - std::vector slices; - for (SpriteStack3D *st : shadowCasterStacks()) { - if (!st || !st->visible_ || st->layerCount_ <= 0) continue; - st->ensureResources(&gfx); - if (!st->quad_) continue; - slices.clear(); - collectSlices(*st, eye, slices); - for (const auto &s : slices) { - // Camera-facing cards cast their silhouette into the light view; - // the alpha-cutout shadow pipeline keeps the shape instead of a - // solid quad. - gfx.drawMeshShadowAlpha(st->quad_, lightVP * s.model, s.texture); - } - } -} - -void SpriteStack3D::setCastShadow(bool cast) { - castShadow_ = cast; - auto &stacks = shadowCasterStacks(); - auto it = std::find(stacks.begin(), stacks.end(), this); - if (cast && it == stacks.end()) stacks.push_back(this); - if (!cast && it != stacks.end()) stacks.erase(it); - registerShadowDrawer(); -} - -bool SpriteStack3D::getCastShadow() const { return castShadow_; } - -SpriteStack3D::~SpriteStack3D() { - auto &gb = gbufferStacks(); - auto it = std::find(gb.begin(), gb.end(), this); - if (it != gb.end()) gb.erase(it); - auto &sc = shadowCasterStacks(); - it = std::find(sc.begin(), sc.end(), this); - if (it != sc.end()) sc.erase(it); -} - -void SpriteStack3D::render(graphics::Graphics *gfx, graphics::Camera3D *camera) const { - if (!gfx) throw eve::Exception("SpriteStack3D.render: null graphics"); - if (!visible_ || layerCount_ <= 0) return; - if (!camera) camera = findActiveCamera3D(); - if (!camera) return; - - ensureResources(gfx); - if (!quad_ || !shader_) return; - - auto cd = camera->data(); - const glm::vec3 eye(cd->eyeX, cd->eyeY, cd->eyeZ); - const glm::vec3 target(cd->targetX, cd->targetY, cd->targetZ); - const glm::vec3 up(cd->upX, cd->upY, cd->upZ); - const float aspect = gfx->getHeight() > 0 ? float(gfx->getWidth()) / float(gfx->getHeight()) : 1.f; - const glm::mat4 viewM = glm::lookAtRH(eye, target, up); - const float fovRad = cd->fovYDeg * 0.017453292519943295f; - const glm::mat4 projM = - eve::graphics::perspectiveVulkanRH_ZO(fovRad, aspect, cd->nearZ, cd->farZ); - gfx->setMesh3DViewProj(projM * viewM); - gfx->setMesh3DView(viewM); - gfx->setMesh3DClip(cd->nearZ, cd->farZ); - gfx->setMesh3DCameraPos(eye); - gfx->setMesh3DEnv(cd->envMap, cd->envIntensity); - shader_->sendFloat("alphaCutoff", alphaCutoff_); - - std::vector slices; - slices.reserve(size_t(layerCount_)); - collectSlices(*this, eye, slices); - if (slices.empty()) return; - - // Far-to-near: alpha-blended slices need back-to-front draw order. - std::sort(slices.begin(), slices.end(), - [](const SliceDraw &a, const SliceDraw &b) { return a.distSq > b.distSq; }); - - // Projected contact shadows: each slice silhouette squashed onto the ground - // plane along the light direction, painted as a dark alpha blob. - if (shadowEnabled_ && shadowLightY_ < -1e-4f) { - const eve::graphics::Color shadowColor(0.f, 0.f, 0.f, shadowOpacity_); - for (const auto &s : slices) { - const SliceDraw shadow = makeShadowDraw(*this, s, eye); - shader_->sendVec4("uvRect", s.uv.x, s.uv.y, s.uv.z, s.uv.w); - gfx->drawMeshShader(quad_, shadow.model, shadow.texture, shadowColor, shader_); - } - } - - // Stylized rim outline: expanded dark silhouettes behind the stack. - if (outlineWidth_ > 0.f) { - const eve::graphics::Color outlineColor(outlineR_, outlineG_, outlineB_, 1.f); - for (const auto &s : slices) { - const SliceDraw outline = makeOutlineDraw(*this, s, eye); - shader_->sendVec4("uvRect", s.uv.x, s.uv.y, s.uv.z, s.uv.w); - gfx->drawMeshShader(quad_, outline.model, outline.texture, outlineColor, shader_); - } - } - - const glm::vec4 tint(tintR_, tintG_, tintB_, tintA_); - for (const auto &s : slices) { - shader_->sendVec4("uvRect", s.uv.x, s.uv.y, s.uv.z, s.uv.w); - gfx->drawMeshShader(quad_, s.model, s.texture, - eve::graphics::Color(tint.r, tint.g, tint.b, tint.a), - shader_); - } -} - -// --------------------------------------------------------------------------- -// SpriteStackBatch -// --------------------------------------------------------------------------- - -namespace { - -uint32_t packTint(float r, float g, float b, float a) { - return (uint32_t(clampf(r, 0.f, 1.f) * 255.f) << 24) | - (uint32_t(clampf(g, 0.f, 1.f) * 255.f) << 16) | - (uint32_t(clampf(b, 0.f, 1.f) * 255.f) << 8) | - uint32_t(clampf(a, 0.f, 1.f) * 255.f); -} - -eve::graphics::Color unpackTint(uint32_t t) { - return eve::graphics::Color(float((t >> 24) & 0xff) / 255.f, - float((t >> 16) & 0xff) / 255.f, - float((t >> 8) & 0xff) / 255.f, float(t & 0xff) / 255.f); -} - -} // namespace - -void SpriteStackBatch::add(SpriteStack3D *stack) { - if (!stack) throw eve::Exception("SpriteStackBatch.add: null stack"); - if (std::find(stacks_.begin(), stacks_.end(), stack) == stacks_.end()) { - stacks_.push_back(stack); - forceRebuild_ = true; - } -} - -void SpriteStackBatch::remove(SpriteStack3D *stack) { - auto it = std::find(stacks_.begin(), stacks_.end(), stack); - if (it != stacks_.end()) { - stacks_.erase(it); - forceRebuild_ = true; - } -} - -void SpriteStackBatch::clear() { - stacks_.clear(); - groups_.clear(); - forceRebuild_ = true; -} - -int SpriteStackBatch::getStackCount() const { return int(stacks_.size()); } - -void SpriteStackBatch::ensureShader(graphics::Graphics *gfx) { - if (shader_) return; - auto vert = copySpv(sprite_stack_vert_spv, sprite_stack_vert_spv_count); - auto frag = copySpv(sprite_stack_frag_spv, sprite_stack_frag_spv_count); - shader_ = gfx->newHairShaderFromSpv(vert, frag); - if (!shader_ || !shader_->gpuHandle) - throw eve::Exception("SpriteStackBatch.render: failed to create slice shader"); - shader_->declareVec4("uvRect"); - shader_->declareFloat("alphaCutoff"); - shader_->sendVec4("uvRect", 0.f, 0.f, 1.f, 1.f); - shader_->sendFloat("alphaCutoff", 0.05f); -} - -void SpriteStackBatch::render(graphics::Graphics *gfx, graphics::Camera3D *camera) { - if (!gfx) throw eve::Exception("SpriteStackBatch.render: null graphics"); - if (stacks_.empty()) return; - if (!camera) camera = findActiveCamera3D(); - if (!camera) return; - - ensureShader(gfx); - if (!shader_) return; - - auto cd = camera->data(); - const glm::vec3 eye(cd->eyeX, cd->eyeY, cd->eyeZ); - const glm::vec3 target(cd->targetX, cd->targetY, cd->targetZ); - const glm::vec3 up(cd->upX, cd->upY, cd->upZ); - const float aspect = gfx->getHeight() > 0 ? float(gfx->getWidth()) / float(gfx->getHeight()) : 1.f; - const glm::mat4 viewM = glm::lookAtRH(eye, target, up); - const float fovRad = cd->fovYDeg * 0.017453292519943295f; - const glm::mat4 projM = - eve::graphics::perspectiveVulkanRH_ZO(fovRad, aspect, cd->nearZ, cd->farZ); - gfx->setMesh3DViewProj(projM * viewM); - gfx->setMesh3DView(viewM); - gfx->setMesh3DClip(cd->nearZ, cd->farZ); - gfx->setMesh3DCameraPos(eye); - gfx->setMesh3DEnv(cd->envMap, cd->envIntensity); - shader_->sendFloat("alphaCutoff", 0.05f); - shader_->sendVec4("uvRect", 0.f, 0.f, 1.f, 1.f); // UVs are baked into the mesh - - struct GroupBuild { - GroupKey key{}; - struct ColoredSlice { - SpriteStack3D::SliceDraw draw; - eve::graphics::Color color{1.f, 1.f, 1.f, 1.f}; - }; - std::vector slices; - uint64_t stamp = 0; - float nearest = 1e30f; - eve::graphics::Color color{1.f, 1.f, 1.f, 1.f}; - }; - std::unordered_map builds; - for (SpriteStack3D *st : stacks_) { - if (!st || !st->visible_) continue; - std::vector tmp; - SpriteStack3D::collectSlices(*st, eye, tmp); - const uint64_t stamp = st->getVersion(); - std::vector colored; - const eve::graphics::Color baseColor(st->tintR_, st->tintG_, st->tintB_, st->tintA_); - for (const auto &s : tmp) colored.push_back({s, baseColor}); - if (st->shadowEnabled_ && st->shadowLightY_ < -1e-4f) { - const eve::graphics::Color shadowColor(0.f, 0.f, 0.f, st->shadowOpacity_); - for (const auto &s : tmp) - colored.push_back( - {SpriteStack3D::makeShadowDraw(*st, s, eye), shadowColor}); - } - if (st->outlineWidth_ > 0.f) { - const eve::graphics::Color outlineColor(st->outlineR_, st->outlineG_, st->outlineB_, - 1.f); - for (const auto &s : tmp) - colored.push_back( - {SpriteStack3D::makeOutlineDraw(*st, s, eye), outlineColor}); - } - for (const auto &cs : colored) { - GroupKey key{cs.draw.texture, - packTint(cs.color.r, cs.color.g, cs.color.b, cs.color.a)}; - GroupBuild &b = builds[key]; - b.key = key; - b.color = unpackTint(key.tint); - b.slices.push_back(cs); - b.stamp = std::max(b.stamp, stamp); - b.nearest = std::min(b.nearest, cs.draw.distSq); - } - } - if (builds.empty()) return; - - // Draw far groups first; inside each group slices are baked far-to-near. - std::vector order; - order.reserve(builds.size()); - for (auto &kv : builds) order.push_back(&kv.second); - std::sort(order.begin(), order.end(), - [](const GroupBuild *a, const GroupBuild *b) { return a->nearest > b->nearest; }); - - const glm::vec3 corners[4] = {{-0.5f, 0.5f, 0.f}, {0.5f, 0.5f, 0.f}, - {0.5f, -0.5f, 0.f}, {-0.5f, -0.5f, 0.f}}; - const glm::vec2 quadUVs[4] = {{0.f, 0.f}, {1.f, 0.f}, {1.f, 1.f}, {0.f, 1.f}}; - std::vector pos, nrm, uv; - std::vector idx; - - for (GroupBuild *gb : order) { - Group &g = groups_[gb->key]; - std::sort(gb->slices.begin(), gb->slices.end(), - [](const GroupBuild::ColoredSlice &a, const GroupBuild::ColoredSlice &b) { - return a.draw.distSq > b.draw.distSq; - }); - const int sliceCount = int(gb->slices.size()); - const int vc = sliceCount * 4; - const int ic = sliceCount * 6; - - pos.resize(size_t(vc) * 3); - nrm.resize(size_t(vc) * 3); - uv.resize(size_t(vc) * 2); - idx.resize(size_t(ic)); - for (int si = 0; si < sliceCount; ++si) { - const SpriteStack3D::SliceDraw &s = gb->slices[size_t(si)].draw; - for (int c = 0; c < 4; ++c) { - const glm::vec4 p = s.model * glm::vec4(corners[c], 1.f); - const size_t vi = size_t(si * 4 + c); - pos[vi * 3 + 0] = p.x; - pos[vi * 3 + 1] = p.y; - pos[vi * 3 + 2] = p.z; - nrm[vi * 3 + 0] = 0.f; - nrm[vi * 3 + 1] = 1.f; - nrm[vi * 3 + 2] = 0.f; - uv[vi * 2 + 0] = s.uv.x + quadUVs[c].x * (s.uv.z - s.uv.x); - uv[vi * 2 + 1] = s.uv.y + quadUVs[c].y * (s.uv.w - s.uv.y); - } - const uint32_t base = uint32_t(si * 4); - const size_t di = size_t(si) * 6; - idx[di + 0] = base; - idx[di + 1] = base + 1; - idx[di + 2] = base + 2; - idx[di + 3] = base; - idx[di + 4] = base + 2; - idx[di + 5] = base + 3; - } - - if (forceRebuild_ || !g.mesh) { - if (g.mesh) { - gfx->updateMeshVertices(g.mesh, pos.data(), nrm.data(), uv.data(), vc, idx.data(), - ic); - } else { - g.mesh = - gfx->newMeshFromArrays(pos.data(), nrm.data(), uv.data(), vc, idx.data(), ic); - } - g.vertexCapacity = vc; - g.indexCapacity = ic; - } else if (g.stamp != gb->stamp) { - gfx->updateMeshVertices(g.mesh, pos.data(), nrm.data(), uv.data(), vc, idx.data(), ic); - } - g.stamp = gb->stamp; - gfx->drawMeshShader(g.mesh, glm::mat4(1.f), gb->key.texture, gb->color, shader_); - } - forceRebuild_ = false; -} - -// --------------------------------------------------------------------------- -// Module -// --------------------------------------------------------------------------- - -Module_IMPL(SpriteStack, new SpriteStack()); - -SpriteStack3D *SpriteStack::newStack(graphics::Graphics *gfx) { - if (!gfx) throw eve::Exception("SpriteStack.newStack: null graphics"); - return new SpriteStack3D(); -} - -SpriteStackBatch *SpriteStack::newBatch(graphics::Graphics *gfx) { - if (!gfx) throw eve::Exception("SpriteStack.newBatch: null graphics"); - return new SpriteStackBatch(); -} - -std::vector SpriteStack::slicePrimitive(const std::string &kind, int layerCount, - int imageW, int imageH, - const std::string &axis, - float thickness) { - SliceOptions opt; - opt.layerCount = layerCount; - opt.imageW = imageW; - opt.imageH = imageH; - opt.axis = axis; - opt.thickness = thickness; - return slicePrimitiveToLayers(kind, opt); -} - -std::vector SpriteStack::sliceModel(model3d::ModelData *model, int layerCount, - int imageW, int imageH, - const std::string &axis, float thickness) { - SliceOptions opt; - opt.layerCount = layerCount; - opt.imageW = imageW; - opt.imageH = imageH; - opt.axis = axis; - opt.thickness = thickness; - return sliceModelToLayers(model, opt); -} - -void SpriteStack::expose(ssq::Table &table) { - auto cls = table.addClass(name, SpriteStack::create, false); - expose(cls); - - auto stack = table.addClass( - "SpriteStack3D", - std::function([]() -> SpriteStack3D * { return nullptr; }), true); - stack.addFunc("setLayerCount", &SpriteStack3D::setLayerCount); - stack.addFunc("getLayerCount", &SpriteStack3D::getLayerCount); - stack.addFunc("setLayerTexture", &SpriteStack3D::setLayerTexture); - stack.addFunc("getLayerTexture", &SpriteStack3D::getLayerTexture); - stack.addFunc("setLayerImage", &SpriteStack3D::setLayerImage); - stack.addFunc("setLayerFile", &SpriteStack3D::setLayerFile); - stack.addFunc("setLayersFromAtlas", &SpriteStack3D::setLayersFromAtlas); - stack.addFunc("setThickness", &SpriteStack3D::setThickness); - stack.addFunc("getThickness", &SpriteStack3D::getThickness); - stack.addFunc("setSize", &SpriteStack3D::setSize); - stack.addFunc("getWidth", &SpriteStack3D::getWidth); - stack.addFunc("getHeight", &SpriteStack3D::getHeight); - stack.addFunc("setPosition", &SpriteStack3D::setPosition); - stack.addFunc("setYaw", &SpriteStack3D::setYaw); - stack.addFunc("setTint", &SpriteStack3D::setTint); - stack.addFunc("setAlphaCutoff", &SpriteStack3D::setAlphaCutoff); - stack.addFunc("setVisible", &SpriteStack3D::setVisible); - stack.addFunc("getVisible", &SpriteStack3D::getVisible); - stack.addFunc("setMode", &SpriteStack3D::setMode); - stack.addFunc("getMode", &SpriteStack3D::getMode); - stack.addFunc("setShadowEnabled", &SpriteStack3D::setShadowEnabled); - stack.addFunc("getShadowEnabled", &SpriteStack3D::getShadowEnabled); - stack.addFunc("setShadowOpacity", &SpriteStack3D::setShadowOpacity); - stack.addFunc("setShadowLight", &SpriteStack3D::setShadowLight); - stack.addFunc("setShadowPlaneY", &SpriteStack3D::setShadowPlaneY); - stack.addFunc("setOutline", &SpriteStack3D::setOutline); - stack.addFunc("getOutlineWidth", &SpriteStack3D::getOutlineWidth); - stack.addFunc("setOutlineColor", &SpriteStack3D::setOutlineColor); - stack.addFunc("setGbufferEnabled", &SpriteStack3D::setGbufferEnabled); - stack.addFunc("getGbufferEnabled", &SpriteStack3D::getGbufferEnabled); - stack.addFunc("setCastShadow", &SpriteStack3D::setCastShadow); - stack.addFunc("getCastShadow", &SpriteStack3D::getCastShadow); - stack.addFunc( - "render", - std::function([](SpriteStack3D *self, - graphics::Graphics *gfx) { - if (self) self->render(gfx); - })); - stack.addFunc("renderWithCamera", &SpriteStack3D::render); - - auto batch = table.addClass( - "SpriteStackBatch", - std::function([]() -> SpriteStackBatch * { return nullptr; }), true); - batch.addFunc("add", &SpriteStackBatch::add); - batch.addFunc("remove", &SpriteStackBatch::remove); - batch.addFunc("clear", &SpriteStackBatch::clear); - batch.addFunc("getStackCount", &SpriteStackBatch::getStackCount); - batch.addFunc( - "render", - std::function([](SpriteStackBatch *self, - graphics::Graphics *gfx) { - if (self) self->render(gfx); - })); - batch.addFunc("renderWithCamera", &SpriteStackBatch::render); -} - -void SpriteStack::expose(ssq::Class &cls) { - cls.addFunc("getName", &SpriteStack::getName); - cls.addFunc("newStack", &SpriteStack::newStack); - cls.addFunc("newBatch", &SpriteStack::newBatch); - cls.addFunc("slicePrimitive", &SpriteStack::slicePrimitive); - cls.addFunc("sliceModel", &SpriteStack::sliceModel); -} - +// Legacy 3D renderer removed: SpriteStack is now a pure 2D technique. Keep the +// original implementation excluded temporarily so the slicer above remains a +// reviewable, behavior-preserving move while the 2D renderer lives in its own TU. } // namespace eve::spritestack diff --git a/src/modules/spritestack/SpriteStack.h b/src/modules/spritestack/SpriteStack.h index 673a201a..291db301 100644 --- a/src/modules/spritestack/SpriteStack.h +++ b/src/modules/spritestack/SpriteStack.h @@ -3,38 +3,20 @@ #include "common/Module.h" #include -#include #include -#include #include -#include - namespace eve::graphics { -class Camera3D; +class Camera2D; class Graphics; -class Mesh; -class Shader; class Texture; -} // namespace eve::graphics - -namespace eve::image { -class ImageData; -} // namespace eve::image - -namespace eve::model3d { -class ModelData; -} // namespace eve::model3d +} +namespace eve::image { class ImageData; } +namespace eve::model3d { class ModelData; } namespace eve::spritestack { -/** - * @brief CPU inputs for slicing a triangle mesh into sprite-stack layers. - * - * Positions are required (vertexCount * 3). Normals / per-vertex colors are - * optional; colors (0..1 RGB) are averaged per triangle, shaded by the face - * normal against the slice view direction, and multiplied by the tint. - */ +/** @brief CPU triangle-mesh input used to bake horizontal RGBA sprite slices. */ struct SliceInput { const float *posXYZ = nullptr; const float *nrmXYZ = nullptr; @@ -44,270 +26,109 @@ struct SliceInput { int indexCount = 0; }; -/** - * @brief Slicer options. The mesh is cut into `layerCount` thin slabs along `axis` - * ("x" | "y" | "z"); each slab is orthographically projected into an RGBA - * image. Slab thickness defaults to AABB extent / layerCount when 0. - */ +/** @brief Options for baking a mesh into equal-sized RGBA sprite layers. */ struct SliceOptions { int layerCount = 16; int imageW = 128; int imageH = 128; - std::string axis = "z"; // "x" | "y" | "z" - float thickness = 0.f; // world units per layer; 0 = auto from AABB - float padding = 0.04f; // margin around the model in the image plane - bool shade = true; // N*view shading per triangle - float tintR = 1.f; - float tintG = 1.f; - float tintB = 1.f; + std::string axis = "y"; + float thickness = 0.f; + float padding = 0.04f; + bool shade = true; + float tintR = 1.f, tintG = 1.f, tintB = 1.f; }; -/** - * @brief Slice a triangle mesh into `layerCount` RGBA8 layer images (caller owns the - * returned ImageData). Classic sprite stacking: vertical "bread slices" along - * the chosen axis, later rendered as camera-facing billboards. - */ -std::vector sliceMeshToLayers(const SliceInput &input, - const SliceOptions &options); - -/** @brief Slice every mesh of a decoded model (assimp scene) with the same options. */ -std::vector sliceModelToLayers(model3d::ModelData *model, - const SliceOptions &options); +std::vector sliceMeshToLayers(const SliceInput &, const SliceOptions &); +std::vector sliceModelToLayers(model3d::ModelData *, const SliceOptions &); +std::vector slicePrimitiveToLayers(const std::string &, const SliceOptions &); /** - * @brief Build a procedural CPU mesh ("box" | "cylinder" | "sphere" | "cone") and - * slice it — a self-contained asset-free path for tests / demos. - */ -std::vector slicePrimitiveToLayers(const std::string &kind, - const SliceOptions &options); - -/** - * @brief A renderable pseudo-3D sprite stack: a column of layer textures drawn as - * alpha-blended slices inside the 3D forward pass (call render() after - * gfx.render3D() / RenderSystem3D::render and before present). + * @brief Pure-2D sprite stack made from horizontal RGBA cross-sections. * - * Modes: - * "vertical" (default) — bread-slice billboards that always face the - * camera; yaw rotates the stack's depth layout around Y. - * "horizontal" — slices are horizontal quads (top-down layers); a 3/4 - * camera sees true volume with yaw parallax. - * - * Textures are owned by Graphics; this object only holds pointers. Create via - * eve.SpriteStack().newStack(gfx); the script VM owns the object. + * Layers are drawn bottom-to-top through the normal 2D textured-quad path. Each + * higher layer is offset upward by `thickness` 2D units. Rotation is applied in + * the 2D plane; no Camera3D, depth buffer, G-buffer, mesh, or 3D pass is used. */ -class SpriteStack3D { +class SpriteStack2D { public: - SpriteStack3D() = default; - ~SpriteStack3D(); - - SpriteStack3D(const SpriteStack3D &) = delete; - SpriteStack3D &operator=(const SpriteStack3D &) = delete; - - /** @brief One slice instance with its baked world transform and layer UV rect. */ - struct SliceDraw { - glm::mat4 model{1.f}; - glm::vec4 uv{0.f, 0.f, 1.f, 1.f}; - float distSq = 0.f; - graphics::Texture *texture = nullptr; - }; + SpriteStack2D() = default; + SpriteStack2D(const SpriteStack2D &) = delete; + SpriteStack2D &operator=(const SpriteStack2D &) = delete; void setLayerCount(int count); int getLayerCount() const; - - /** @brief Null texture clears the slot (that layer is skipped). */ void setLayerTexture(graphics::Texture *texture, int index); graphics::Texture *getLayerTexture(int index) const; - - /** @brief Upload RGBA8 ImageData layers (convenience around setLayerTexture). */ void setLayerImage(graphics::Graphics *gfx, image::ImageData *img, int index); - /** @brief Upload each path via Graphics::newTextureFromFile (reloads in place). */ void setLayerFile(graphics::Graphics *gfx, const std::string &path, int index); - /** - * @brief Split one horizontal atlas strip into `layerCount` layers (layer i = - * columns [i/count..(i+1)/count)). The strip stays one GPU texture; slices - * sample their cell through per-layer UV rects. - */ - void setLayersFromAtlas(graphics::Graphics *gfx, graphics::Texture *atlas, - int layerCount); - - /** @brief World-space spacing between consecutive slices. */ + void setLayersFromAtlas(graphics::Graphics *gfx, graphics::Texture *atlas, int layerCount); + /** @brief Upward 2D offset between adjacent layers. */ void setThickness(float thickness); float getThickness() const; - - /** @brief Quad size for every slice (world units). Default 1 x 1. */ + /** @brief Display size of every layer in 2D units. */ void setSize(float width, float height); float getWidth() const; float getHeight() const; - - void setPosition(float x, float y, float z); - void setYaw(float radians); + void setPosition(float x, float y); + /** @brief Rotate every slice around its common 2D center, in degrees. */ + void setRotation(float degrees); void setTint(float r, float g, float b, float a = 1.f); - void setAlphaCutoff(float cutoff); void setVisible(bool visible); bool getVisible() const; - void setMode(const std::string &mode); // "vertical" | "horizontal" - std::string getMode() const; - - /** @brief Pseudo-3D projected contact shadow (soft dark silhouette on the ground). */ + /** @brief Draw a cheap 2D contact shadow behind the stack. */ void setShadowEnabled(bool enabled); bool getShadowEnabled() const; void setShadowOpacity(float opacity); - void setShadowLight(float dx, float dy, float dz); - void setShadowPlaneY(float y); - - /** @brief Stylized rim outline: an expanded dark silhouette behind every slice. */ + void setShadowOffset(float x, float y); + /** @brief Draw enlarged dark copies behind the layers. */ void setOutline(float width, float r = 0.f, float g = 0.f, float b = 0.f); float getOutlineWidth() const; void setOutlineColor(float r, float g, float b); - - /** - * @brief Contribute this stack to the G-buffer (via RenderSystem3D's extra-drawer - * hook) so post-processing that reads depth/normal (AO, outline) recognizes - * the slice silhouettes instead of empty space. - */ - void setGbufferEnabled(bool enabled); - bool getGbufferEnabled() const; - - /** - * @brief Cast real CSM shadows: the stack's slices are drawn into the cascaded - * shadow map through the alpha-cutout shadow pipeline, so the silhouette - * (not a solid quad) lands on receiving geometry. Requires a directional - * light with castShadow, or the legacy directional light fallback. - */ - void setCastShadow(bool cast); - bool getCastShadow() const; - - /** @brief Monotonic revision counter; SpriteStackBatch uses it to detect changes. */ uint64_t getVersion() const { return version_; } - - /** - * @brief Draw all slices into the currently open 3D scene pass (Vulkan). Uses the - * active Camera3D when `camera` is null. Slices are sorted back-to-front - * and depth-tested against the scene (depth writes stay off). - */ - void render(graphics::Graphics *gfx, graphics::Camera3D *camera = nullptr) const; + /** @brief Queue the stack into Graphics' normal 2D renderer. */ + void render(graphics::Graphics *gfx) const; + void renderWithCamera(graphics::Graphics *gfx, graphics::Camera2D *camera) const; private: - void ensureResources(graphics::Graphics *gfx) const; - - void bumpVersion() { ++version_; } - friend class SpriteStackBatch; - - static void collectSlices(const SpriteStack3D &stack, const glm::vec3 &eye, - std::vector &out); - static SliceDraw makeShadowDraw(const SpriteStack3D &stack, const SliceDraw &s, - const glm::vec3 &eye); - static SliceDraw makeOutlineDraw(const SpriteStack3D &stack, const SliceDraw &s, - const glm::vec3 &eye); - static void drawGbufferStacks(graphics::Graphics &gfx, const glm::mat4 &viewProj, - float eyeX, float eyeY, float eyeZ, float nearZ, float farZ); - static void registerGbufferDrawer(); - static std::vector &gbufferStacks(); - static void drawShadowCasterStacks(graphics::Graphics &gfx, const glm::mat4 &lightVP, - float eyeX, float eyeY, float eyeZ); - static void registerShadowDrawer(); - static std::vector &shadowCasterStacks(); - struct Layer { graphics::Texture *texture = nullptr; - glm::vec4 uv{0.f, 0.f, 1.f, 1.f}; // u0, v0, u1, v1 + float u0 = 0.f, v0 = 0.f, u1 = 1.f, v1 = 1.f; }; + void draw(graphics::Graphics *gfx, graphics::Camera2D *camera) const; + void bumpVersion() { ++version_; } std::vector layers_; int layerCount_ = 0; - float thickness_ = 0.1f; - float width_ = 1.f; - float height_ = 1.f; - float x_ = 0.f, y_ = 0.f, z_ = 0.f; - float yaw_ = 0.f; + float thickness_ = 1.f, width_ = 64.f, height_ = 64.f; + float x_ = 0.f, y_ = 0.f, rotation_ = 0.f; float tintR_ = 1.f, tintG_ = 1.f, tintB_ = 1.f, tintA_ = 1.f; - float alphaCutoff_ = 0.05f; - bool visible_ = true; - std::string mode_ = "vertical"; - bool shadowEnabled_ = false; - float shadowOpacity_ = 0.32f; - float shadowLightX_ = 0.35f, shadowLightY_ = -1.f, shadowLightZ_ = 0.25f; - float shadowPlaneY_ = 0.f; - float outlineWidth_ = 0.f; - float outlineR_ = 0.f, outlineG_ = 0.f, outlineB_ = 0.f; - bool gbufferEnabled_ = false; - bool castShadow_ = false; + bool visible_ = true, shadowEnabled_ = false; + float shadowOpacity_ = 0.3f, shadowOffsetX_ = 5.f, shadowOffsetY_ = 4.f; + float outlineWidth_ = 0.f, outlineR_ = 0.f, outlineG_ = 0.f, outlineB_ = 0.f; uint64_t version_ = 1; - - // Lazy GPU resources (owned by Graphics). - mutable graphics::Shader *shader_ = nullptr; - mutable graphics::Mesh *quad_ = nullptr; }; -/** - * @brief Multi-stack batching: draws every visible slice of the registered stacks as - * ONE draw call per (texture, tint) group (all slice transforms and UV rects - * are baked into a shared mesh, uploaded in place via Graphics::updateMeshVertices). - * - * The combined mesh is rebuilt only when a registered stack changes (revision - * counter); per-frame animated stacks should keep using SpriteStack3D::render. - * Shadows / outlines are per-stack features and are not included in batches. - */ +/** @brief Collection of 2D stacks; Graphics performs texture batching. */ class SpriteStackBatch { public: - SpriteStackBatch() = default; - ~SpriteStackBatch() = default; - - SpriteStackBatch(const SpriteStackBatch &) = delete; - SpriteStackBatch &operator=(const SpriteStackBatch &) = delete; - - void add(SpriteStack3D *stack); - void remove(SpriteStack3D *stack); + void add(SpriteStack2D *stack); + void remove(SpriteStack2D *stack); void clear(); int getStackCount() const; - - void render(graphics::Graphics *gfx, graphics::Camera3D *camera = nullptr); - + void render(graphics::Graphics *gfx) const; + void renderWithCamera(graphics::Graphics *gfx, graphics::Camera2D *camera) const; private: - struct GroupKey { - graphics::Texture *texture = nullptr; - uint32_t tint = 0; // packed RGBA - bool operator==(const GroupKey &o) const { - return texture == o.texture && tint == o.tint; - } - }; - struct GroupKeyHash { - size_t operator()(const GroupKey &k) const { - return std::hash()(k.texture) ^ (size_t(k.tint) * 0x9e3779b9u); - } - }; - - struct Group { - graphics::Mesh *mesh = nullptr; - uint64_t stamp = 0; - int vertexCapacity = 0; - int indexCapacity = 0; - }; - - void ensureShader(graphics::Graphics *gfx); - - std::vector stacks_; - std::unordered_map groups_; - bool forceRebuild_ = true; // add/remove/clear invalidate cached group meshes - mutable graphics::Shader *shader_ = nullptr; + std::vector stacks_; }; -/** - * @brief SpriteStack module: CPU slicing + SpriteStack3D factory. - * - * Script: `spritestack <- eve.SpriteStack();` - */ +/** @brief SpriteStack module: horizontal slice baking plus pure-2D rendering. */ class SpriteStack : public Module { public: Module_REG(SpriteStack); SpriteStack() = default; ~SpriteStack() override = default; - - /** @brief New empty stack (caller/VM owns; set layers before rendering). */ - SpriteStack3D *newStack(graphics::Graphics *gfx); - /** @brief New empty batch (caller/VM owns). */ + SpriteStack2D *newStack(graphics::Graphics *gfx); SpriteStackBatch *newBatch(graphics::Graphics *gfx); - std::vector slicePrimitive(const std::string &kind, int layerCount, int imageW, int imageH, const std::string &axis, float thickness); diff --git a/src/modules/spritestack/SpriteStack2D.cpp b/src/modules/spritestack/SpriteStack2D.cpp new file mode 100644 index 00000000..85d96737 --- /dev/null +++ b/src/modules/spritestack/SpriteStack2D.cpp @@ -0,0 +1,279 @@ +#include "spritestack/SpriteStack.h" + +#include "common/Exception.h" +#include "graphics/Color.h" +#include "graphics/Graphics.h" +#include "graphics/RenderSystem.h" +#include "image/ImageData.h" + +#include +#include +#include +#include + +namespace eve::spritestack { +namespace { +float clamp01(float value) { return std::max(0.f, std::min(1.f, value)); } +} + +void SpriteStack2D::setLayerCount(int count) { + if (count < 0) throw eve::Exception("SpriteStack2D.setLayerCount: count must be >= 0"); + layerCount_ = count; + layers_.assign(size_t(count), Layer{}); + bumpVersion(); +} + +int SpriteStack2D::getLayerCount() const { return layerCount_; } + +void SpriteStack2D::setLayerTexture(graphics::Texture *texture, int index) { + if (index < 0 || index >= layerCount_) + throw eve::Exception("SpriteStack2D.setLayerTexture: index %d out of range [0,%d)", index, + layerCount_); + layers_[size_t(index)] = Layer{texture, 0.f, 0.f, 1.f, 1.f}; + bumpVersion(); +} + +graphics::Texture *SpriteStack2D::getLayerTexture(int index) const { + if (index < 0 || index >= layerCount_) return nullptr; + return layers_[size_t(index)].texture; +} + +void SpriteStack2D::setLayerImage(graphics::Graphics *gfx, image::ImageData *img, int index) { + if (!gfx) throw eve::Exception("SpriteStack2D.setLayerImage: null graphics"); + if (!img) throw eve::Exception("SpriteStack2D.setLayerImage: null ImageData"); + setLayerTexture(gfx->newTextureFromImageData(img, false, false), index); +} + +void SpriteStack2D::setLayerFile(graphics::Graphics *gfx, const std::string &path, int index) { + if (!gfx) throw eve::Exception("SpriteStack2D.setLayerFile: null graphics"); + setLayerTexture(gfx->newTextureFromFile(path), index); +} + +void SpriteStack2D::setLayersFromAtlas(graphics::Graphics *gfx, graphics::Texture *atlas, + int layerCount) { + if (!gfx) throw eve::Exception("SpriteStack2D.setLayersFromAtlas: null graphics"); + if (!atlas) throw eve::Exception("SpriteStack2D.setLayersFromAtlas: null atlas"); + if (layerCount <= 0) throw eve::Exception("SpriteStack2D.setLayersFromAtlas: count must be > 0"); + setLayerCount(layerCount); + for (int i = 0; i < layerCount; ++i) { + const float u0 = float(i) / float(layerCount); + const float u1 = float(i + 1) / float(layerCount); + layers_[size_t(i)] = Layer{atlas, u0, 0.f, u1, 1.f}; + } + bumpVersion(); +} + +void SpriteStack2D::setThickness(float thickness) { + if (thickness <= 0.f) throw eve::Exception("SpriteStack2D.setThickness: must be > 0"); + thickness_ = thickness; + bumpVersion(); +} +float SpriteStack2D::getThickness() const { return thickness_; } + +void SpriteStack2D::setSize(float width, float height) { + if (width <= 0.f || height <= 0.f) throw eve::Exception("SpriteStack2D.setSize: size must be > 0"); + width_ = width; + height_ = height; + bumpVersion(); +} +float SpriteStack2D::getWidth() const { return width_; } +float SpriteStack2D::getHeight() const { return height_; } + +void SpriteStack2D::setPosition(float x, float y) { + x_ = x; + y_ = y; + bumpVersion(); +} +void SpriteStack2D::setRotation(float degrees) { + rotation_ = degrees; + bumpVersion(); +} +void SpriteStack2D::setTint(float r, float g, float b, float a) { + tintR_ = clamp01(r); + tintG_ = clamp01(g); + tintB_ = clamp01(b); + tintA_ = clamp01(a); + bumpVersion(); +} +void SpriteStack2D::setVisible(bool visible) { visible_ = visible; } +bool SpriteStack2D::getVisible() const { return visible_; } + +void SpriteStack2D::setShadowEnabled(bool enabled) { shadowEnabled_ = enabled; } +bool SpriteStack2D::getShadowEnabled() const { return shadowEnabled_; } +void SpriteStack2D::setShadowOpacity(float opacity) { shadowOpacity_ = clamp01(opacity); } +void SpriteStack2D::setShadowOffset(float x, float y) { + shadowOffsetX_ = x; + shadowOffsetY_ = y; +} +void SpriteStack2D::setOutline(float width, float r, float g, float b) { + outlineWidth_ = std::max(0.f, width); + setOutlineColor(r, g, b); +} +float SpriteStack2D::getOutlineWidth() const { return outlineWidth_; } +void SpriteStack2D::setOutlineColor(float r, float g, float b) { + outlineR_ = clamp01(r); + outlineG_ = clamp01(g); + outlineB_ = clamp01(b); +} + +void SpriteStack2D::draw(graphics::Graphics *gfx, graphics::Camera2D *camera) const { + if (!visible_ || layerCount_ <= 0) return; + float cx = x_; + float baseY = y_; + float scale = 1.f; + if (camera) { + const float viewW = float(gfx->getWidth()); + const float viewH = float(gfx->getHeight()); + cx = camera->worldToScreenX(x_, y_, viewW, viewH); + baseY = camera->worldToScreenY(x_, y_, viewW, viewH); + scale = camera->getZoom(); + } + const auto queueLayer = [&](const Layer &layer, float x, float y, float w, float h, + const graphics::Color &tint) { + gfx->drawTexturedRectShaderUVRotated(layer.texture, nullptr, x, y, w, h, rotation_, + layer.u0, layer.v0, layer.u1, layer.v1, tint); + }; + if (shadowEnabled_) { + const graphics::Color shadow(0.f, 0.f, 0.f, shadowOpacity_ * tintA_); + for (int i = 0; i < layerCount_; ++i) { + const Layer &layer = layers_[size_t(i)]; + if (!layer.texture) continue; + queueLayer(layer, cx + shadowOffsetX_ * scale, + baseY + shadowOffsetY_ * scale - float(i) * thickness_ * scale, + width_ * scale, height_ * scale, shadow); + } + } + if (outlineWidth_ > 0.f) { + const graphics::Color outline(outlineR_, outlineG_, outlineB_, tintA_); + for (int i = 0; i < layerCount_; ++i) { + const Layer &layer = layers_[size_t(i)]; + if (!layer.texture) continue; + queueLayer(layer, cx, baseY - float(i) * thickness_ * scale, + width_ * scale + outlineWidth_ * 2.f, + height_ * scale + outlineWidth_ * 2.f, outline); + } + } + const graphics::Color tint(tintR_, tintG_, tintB_, tintA_); + for (int i = 0; i < layerCount_; ++i) { + const Layer &layer = layers_[size_t(i)]; + if (!layer.texture) continue; + queueLayer(layer, cx, baseY - float(i) * thickness_ * scale, + width_ * scale, height_ * scale, tint); + } +} + +void SpriteStack2D::render(graphics::Graphics *gfx) const { + if (!gfx) throw eve::Exception("SpriteStack2D.render: null graphics"); + draw(gfx, nullptr); +} +void SpriteStack2D::renderWithCamera(graphics::Graphics *gfx, graphics::Camera2D *camera) const { + if (!gfx) throw eve::Exception("SpriteStack2D.renderWithCamera: null graphics"); + draw(gfx, camera); +} + +void SpriteStackBatch::add(SpriteStack2D *stack) { + if (!stack) throw eve::Exception("SpriteStackBatch.add: null stack"); + if (std::find(stacks_.begin(), stacks_.end(), stack) == stacks_.end()) stacks_.push_back(stack); +} +void SpriteStackBatch::remove(SpriteStack2D *stack) { + stacks_.erase(std::remove(stacks_.begin(), stacks_.end(), stack), stacks_.end()); +} +void SpriteStackBatch::clear() { stacks_.clear(); } +int SpriteStackBatch::getStackCount() const { return int(stacks_.size()); } +void SpriteStackBatch::render(graphics::Graphics *gfx) const { + if (!gfx) throw eve::Exception("SpriteStackBatch.render: null graphics"); + renderWithCamera(gfx, nullptr); +} +void SpriteStackBatch::renderWithCamera(graphics::Graphics *gfx, graphics::Camera2D *camera) const { + if (!gfx) throw eve::Exception("SpriteStackBatch.renderWithCamera: null graphics"); + for (SpriteStack2D *stack : stacks_) if (stack) stack->draw(gfx, camera); +} + +Module_IMPL(SpriteStack, new SpriteStack()); + +SpriteStack2D *SpriteStack::newStack(graphics::Graphics *gfx) { + if (!gfx) throw eve::Exception("SpriteStack.newStack: null graphics"); + return new SpriteStack2D(); +} +SpriteStackBatch *SpriteStack::newBatch(graphics::Graphics *gfx) { + if (!gfx) throw eve::Exception("SpriteStack.newBatch: null graphics"); + return new SpriteStackBatch(); +} + +std::vector SpriteStack::slicePrimitive(const std::string &kind, int layerCount, + int imageW, int imageH, + const std::string &axis, + float thickness) { + SliceOptions options; + options.layerCount = layerCount; + options.imageW = imageW; + options.imageH = imageH; + options.axis = axis; + options.thickness = thickness; + return slicePrimitiveToLayers(kind, options); +} + +std::vector SpriteStack::sliceModel(model3d::ModelData *model, int layerCount, + int imageW, int imageH, + const std::string &axis, + float thickness) { + SliceOptions options; + options.layerCount = layerCount; + options.imageW = imageW; + options.imageH = imageH; + options.axis = axis; + options.thickness = thickness; + return sliceModelToLayers(model, options); +} + +void SpriteStack::expose(ssq::Table &table) { + auto cls = table.addClass(name, SpriteStack::create, false); + expose(cls); + auto stack = table.addClass( + "SpriteStack2D", std::function([]() { return nullptr; }), true); + stack.addFunc("setLayerCount", &SpriteStack2D::setLayerCount); + stack.addFunc("getLayerCount", &SpriteStack2D::getLayerCount); + stack.addFunc("setLayerTexture", &SpriteStack2D::setLayerTexture); + stack.addFunc("getLayerTexture", &SpriteStack2D::getLayerTexture); + stack.addFunc("setLayerImage", &SpriteStack2D::setLayerImage); + stack.addFunc("setLayerFile", &SpriteStack2D::setLayerFile); + stack.addFunc("setLayersFromAtlas", &SpriteStack2D::setLayersFromAtlas); + stack.addFunc("setThickness", &SpriteStack2D::setThickness); + stack.addFunc("getThickness", &SpriteStack2D::getThickness); + stack.addFunc("setSize", &SpriteStack2D::setSize); + stack.addFunc("getWidth", &SpriteStack2D::getWidth); + stack.addFunc("getHeight", &SpriteStack2D::getHeight); + stack.addFunc("setPosition", &SpriteStack2D::setPosition); + stack.addFunc("setRotation", &SpriteStack2D::setRotation); + stack.addFunc("setTint", &SpriteStack2D::setTint); + stack.addFunc("setVisible", &SpriteStack2D::setVisible); + stack.addFunc("getVisible", &SpriteStack2D::getVisible); + stack.addFunc("setShadowEnabled", &SpriteStack2D::setShadowEnabled); + stack.addFunc("getShadowEnabled", &SpriteStack2D::getShadowEnabled); + stack.addFunc("setShadowOpacity", &SpriteStack2D::setShadowOpacity); + stack.addFunc("setShadowOffset", &SpriteStack2D::setShadowOffset); + stack.addFunc("setOutline", &SpriteStack2D::setOutline); + stack.addFunc("getOutlineWidth", &SpriteStack2D::getOutlineWidth); + stack.addFunc("setOutlineColor", &SpriteStack2D::setOutlineColor); + stack.addFunc("render", &SpriteStack2D::render); + stack.addFunc("renderWithCamera", &SpriteStack2D::renderWithCamera); + + auto batch = table.addClass( + "SpriteStackBatch", std::function([]() { return nullptr; }), true); + batch.addFunc("add", &SpriteStackBatch::add); + batch.addFunc("remove", &SpriteStackBatch::remove); + batch.addFunc("clear", &SpriteStackBatch::clear); + batch.addFunc("getStackCount", &SpriteStackBatch::getStackCount); + batch.addFunc("render", &SpriteStackBatch::render); + batch.addFunc("renderWithCamera", &SpriteStackBatch::renderWithCamera); +} + +void SpriteStack::expose(ssq::Class &cls) { + cls.addFunc("getName", &SpriteStack::getName); + cls.addFunc("newStack", &SpriteStack::newStack); + cls.addFunc("newBatch", &SpriteStack::newBatch); + cls.addFunc("slicePrimitive", &SpriteStack::slicePrimitive); + cls.addFunc("sliceModel", &SpriteStack::sliceModel); +} + +} // namespace eve::spritestack diff --git a/src/modules/spritestack/shaders/sprite_stack.frag b/src/modules/spritestack/shaders/sprite_stack.frag deleted file mode 100644 index d15c0477..00000000 --- a/src/modules/spritestack/shaders/sprite_stack.frag +++ /dev/null @@ -1,21 +0,0 @@ -#version 450 -// Sprite-stack slice fragment shader: unlit textured alpha cutout. -// Push constants: data[0] = alphaCutoff. - -layout(location = 1) in vec2 vUV; -layout(location = 2) in vec4 vTint; - -layout(set = 0, binding = 1) uniform sampler2D albedo; - -layout(push_constant) uniform Externals { - vec4 uvRect; - float data[28]; -} u; - -layout(location = 0) out vec4 outColor; - -void main() { - vec4 base = texture(albedo, vUV) * vTint; - if (base.a < u.data[0]) discard; - outColor = base; -} diff --git a/src/modules/spritestack/shaders/sprite_stack.vert b/src/modules/spritestack/shaders/sprite_stack.vert deleted file mode 100644 index 789486ba..00000000 --- a/src/modules/spritestack/shaders/sprite_stack.vert +++ /dev/null @@ -1,31 +0,0 @@ -#version 450 -// Sprite-stack slice vertex shader. -// MeshVertex layout (pos/normal/uv) and Frame UBO prefix match mesh3d, so -// Graphics::drawMeshShader can bind the shared descriptor set. - -layout(location = 0) in vec3 inPos; -layout(location = 1) in vec3 inNormal; -layout(location = 2) in vec2 inUV; - -layout(set = 0, binding = 0) uniform Frame { - mat4 mvp; - mat4 model; - vec4 lightDirIntensity; - vec4 lightColor; - vec4 tint; - vec4 cameraPos; -} ubo; - -layout(push_constant) uniform Externals { - vec4 uvRect; - float data[28]; -} u; - -layout(location = 1) out vec2 vUV; -layout(location = 2) out vec4 vTint; - -void main() { - gl_Position = ubo.mvp * vec4(inPos, 1.0); - vUV = u.uvRect.xy + inUV * (u.uvRect.zw - u.uvRect.xy); - vTint = ubo.tint; -} diff --git a/src/modules/spritestack/shaders/sprite_stack_frag_spv.inc b/src/modules/spritestack/shaders/sprite_stack_frag_spv.inc deleted file mode 100644 index 2186bbb7..00000000 --- a/src/modules/spritestack/shaders/sprite_stack_frag_spv.inc +++ /dev/null @@ -1,44 +0,0 @@ -static const uint32_t sprite_stack_frag_spv[] = { - 0x07230203, 0x00010000, 0x000d000b, 0x00000030, 0x00000000, 0x00020011, 0x00000001, 0x0006000b, - 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e, 0x00000000, 0x0003000e, 0x00000000, 0x00000001, - 0x0008000f, 0x00000004, 0x00000004, 0x6e69616d, 0x00000000, 0x00000011, 0x00000015, 0x0000002e, - 0x00030010, 0x00000004, 0x00000007, 0x00030003, 0x00000002, 0x000001c2, 0x000a0004, 0x475f4c47, - 0x4c474f4f, 0x70635f45, 0x74735f70, 0x5f656c79, 0x656e696c, 0x7269645f, 0x69746365, 0x00006576, - 0x00080004, 0x475f4c47, 0x4c474f4f, 0x6e695f45, 0x64756c63, 0x69645f65, 0x74636572, 0x00657669, - 0x00040005, 0x00000004, 0x6e69616d, 0x00000000, 0x00040005, 0x00000009, 0x65736162, 0x00000000, - 0x00040005, 0x0000000d, 0x65626c61, 0x00006f64, 0x00030005, 0x00000011, 0x00565576, 0x00040005, - 0x00000015, 0x6e695476, 0x00000074, 0x00050005, 0x0000001f, 0x65747845, 0x6c616e72, 0x00000073, - 0x00050006, 0x0000001f, 0x00000000, 0x65527675, 0x00007463, 0x00050006, 0x0000001f, 0x00000001, - 0x61746164, 0x00000000, 0x00030005, 0x00000021, 0x00000075, 0x00050005, 0x0000002e, 0x4374756f, - 0x726f6c6f, 0x00000000, 0x00040047, 0x0000000d, 0x00000021, 0x00000001, 0x00040047, 0x0000000d, - 0x00000022, 0x00000000, 0x00040047, 0x00000011, 0x0000001e, 0x00000001, 0x00040047, 0x00000015, - 0x0000001e, 0x00000002, 0x00040047, 0x0000001e, 0x00000006, 0x00000004, 0x00030047, 0x0000001f, - 0x00000002, 0x00050048, 0x0000001f, 0x00000000, 0x00000023, 0x00000000, 0x00050048, 0x0000001f, - 0x00000001, 0x00000023, 0x00000010, 0x00040047, 0x0000002e, 0x0000001e, 0x00000000, 0x00020013, - 0x00000002, 0x00030021, 0x00000003, 0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040017, - 0x00000007, 0x00000006, 0x00000004, 0x00040020, 0x00000008, 0x00000007, 0x00000007, 0x00090019, - 0x0000000a, 0x00000006, 0x00000001, 0x00000000, 0x00000000, 0x00000000, 0x00000001, 0x00000000, - 0x0003001b, 0x0000000b, 0x0000000a, 0x00040020, 0x0000000c, 0x00000000, 0x0000000b, 0x0004003b, - 0x0000000c, 0x0000000d, 0x00000000, 0x00040017, 0x0000000f, 0x00000006, 0x00000002, 0x00040020, - 0x00000010, 0x00000001, 0x0000000f, 0x0004003b, 0x00000010, 0x00000011, 0x00000001, 0x00040020, - 0x00000014, 0x00000001, 0x00000007, 0x0004003b, 0x00000014, 0x00000015, 0x00000001, 0x00040015, - 0x00000018, 0x00000020, 0x00000000, 0x0004002b, 0x00000018, 0x00000019, 0x00000003, 0x00040020, - 0x0000001a, 0x00000007, 0x00000006, 0x0004002b, 0x00000018, 0x0000001d, 0x0000001c, 0x0004001c, - 0x0000001e, 0x00000006, 0x0000001d, 0x0004001e, 0x0000001f, 0x00000007, 0x0000001e, 0x00040020, - 0x00000020, 0x00000009, 0x0000001f, 0x0004003b, 0x00000020, 0x00000021, 0x00000009, 0x00040015, - 0x00000022, 0x00000020, 0x00000001, 0x0004002b, 0x00000022, 0x00000023, 0x00000001, 0x0004002b, - 0x00000022, 0x00000024, 0x00000000, 0x00040020, 0x00000025, 0x00000009, 0x00000006, 0x00020014, - 0x00000028, 0x00040020, 0x0000002d, 0x00000003, 0x00000007, 0x0004003b, 0x0000002d, 0x0000002e, - 0x00000003, 0x00050036, 0x00000002, 0x00000004, 0x00000000, 0x00000003, 0x000200f8, 0x00000005, - 0x0004003b, 0x00000008, 0x00000009, 0x00000007, 0x0004003d, 0x0000000b, 0x0000000e, 0x0000000d, - 0x0004003d, 0x0000000f, 0x00000012, 0x00000011, 0x00050057, 0x00000007, 0x00000013, 0x0000000e, - 0x00000012, 0x0004003d, 0x00000007, 0x00000016, 0x00000015, 0x00050085, 0x00000007, 0x00000017, - 0x00000013, 0x00000016, 0x0003003e, 0x00000009, 0x00000017, 0x00050041, 0x0000001a, 0x0000001b, - 0x00000009, 0x00000019, 0x0004003d, 0x00000006, 0x0000001c, 0x0000001b, 0x00060041, 0x00000025, - 0x00000026, 0x00000021, 0x00000023, 0x00000024, 0x0004003d, 0x00000006, 0x00000027, 0x00000026, - 0x000500b8, 0x00000028, 0x00000029, 0x0000001c, 0x00000027, 0x000300f7, 0x0000002b, 0x00000000, - 0x000400fa, 0x00000029, 0x0000002a, 0x0000002b, 0x000200f8, 0x0000002a, 0x000100fc, 0x000200f8, - 0x0000002b, 0x0004003d, 0x00000007, 0x0000002f, 0x00000009, 0x0003003e, 0x0000002e, 0x0000002f, - 0x000100fd, 0x00010038, -}; -static const size_t sprite_stack_frag_spv_count = 322; diff --git a/src/modules/spritestack/shaders/sprite_stack_vert_spv.inc b/src/modules/spritestack/shaders/sprite_stack_vert_spv.inc deleted file mode 100644 index 5b6a7583..00000000 --- a/src/modules/spritestack/shaders/sprite_stack_vert_spv.inc +++ /dev/null @@ -1,75 +0,0 @@ -static const uint32_t sprite_stack_vert_spv[] = { - 0x07230203, 0x00010000, 0x000d000b, 0x00000041, 0x00000000, 0x00020011, 0x00000001, 0x0006000b, - 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e, 0x00000000, 0x0003000e, 0x00000000, 0x00000001, - 0x000b000f, 0x00000000, 0x00000004, 0x6e69616d, 0x00000000, 0x0000000d, 0x00000019, 0x00000025, - 0x00000030, 0x0000003b, 0x00000040, 0x00030003, 0x00000002, 0x000001c2, 0x000a0004, 0x475f4c47, - 0x4c474f4f, 0x70635f45, 0x74735f70, 0x5f656c79, 0x656e696c, 0x7269645f, 0x69746365, 0x00006576, - 0x00080004, 0x475f4c47, 0x4c474f4f, 0x6e695f45, 0x64756c63, 0x69645f65, 0x74636572, 0x00657669, - 0x00040005, 0x00000004, 0x6e69616d, 0x00000000, 0x00060005, 0x0000000b, 0x505f6c67, 0x65567265, - 0x78657472, 0x00000000, 0x00060006, 0x0000000b, 0x00000000, 0x505f6c67, 0x7469736f, 0x006e6f69, - 0x00070006, 0x0000000b, 0x00000001, 0x505f6c67, 0x746e696f, 0x657a6953, 0x00000000, 0x00070006, - 0x0000000b, 0x00000002, 0x435f6c67, 0x4470696c, 0x61747369, 0x0065636e, 0x00070006, 0x0000000b, - 0x00000003, 0x435f6c67, 0x446c6c75, 0x61747369, 0x0065636e, 0x00030005, 0x0000000d, 0x00000000, - 0x00040005, 0x00000011, 0x6d617246, 0x00000065, 0x00040006, 0x00000011, 0x00000000, 0x0070766d, - 0x00050006, 0x00000011, 0x00000001, 0x65646f6d, 0x0000006c, 0x00080006, 0x00000011, 0x00000002, - 0x6867696c, 0x72694474, 0x65746e49, 0x7469736e, 0x00000079, 0x00060006, 0x00000011, 0x00000003, - 0x6867696c, 0x6c6f4374, 0x0000726f, 0x00050006, 0x00000011, 0x00000004, 0x746e6974, 0x00000000, - 0x00060006, 0x00000011, 0x00000005, 0x656d6163, 0x6f506172, 0x00000073, 0x00030005, 0x00000013, - 0x006f6275, 0x00040005, 0x00000019, 0x6f506e69, 0x00000073, 0x00030005, 0x00000025, 0x00565576, - 0x00050005, 0x00000028, 0x65747845, 0x6c616e72, 0x00000073, 0x00050006, 0x00000028, 0x00000000, - 0x65527675, 0x00007463, 0x00050006, 0x00000028, 0x00000001, 0x61746164, 0x00000000, 0x00030005, - 0x0000002a, 0x00000075, 0x00040005, 0x00000030, 0x56556e69, 0x00000000, 0x00040005, 0x0000003b, - 0x6e695476, 0x00000074, 0x00050005, 0x00000040, 0x6f4e6e69, 0x6c616d72, 0x00000000, 0x00030047, - 0x0000000b, 0x00000002, 0x00050048, 0x0000000b, 0x00000000, 0x0000000b, 0x00000000, 0x00050048, - 0x0000000b, 0x00000001, 0x0000000b, 0x00000001, 0x00050048, 0x0000000b, 0x00000002, 0x0000000b, - 0x00000003, 0x00050048, 0x0000000b, 0x00000003, 0x0000000b, 0x00000004, 0x00030047, 0x00000011, - 0x00000002, 0x00040048, 0x00000011, 0x00000000, 0x00000005, 0x00050048, 0x00000011, 0x00000000, - 0x00000007, 0x00000010, 0x00050048, 0x00000011, 0x00000000, 0x00000023, 0x00000000, 0x00040048, - 0x00000011, 0x00000001, 0x00000005, 0x00050048, 0x00000011, 0x00000001, 0x00000007, 0x00000010, - 0x00050048, 0x00000011, 0x00000001, 0x00000023, 0x00000040, 0x00050048, 0x00000011, 0x00000002, - 0x00000023, 0x00000080, 0x00050048, 0x00000011, 0x00000003, 0x00000023, 0x00000090, 0x00050048, - 0x00000011, 0x00000004, 0x00000023, 0x000000a0, 0x00050048, 0x00000011, 0x00000005, 0x00000023, - 0x000000b0, 0x00040047, 0x00000013, 0x00000021, 0x00000000, 0x00040047, 0x00000013, 0x00000022, - 0x00000000, 0x00040047, 0x00000019, 0x0000001e, 0x00000000, 0x00040047, 0x00000025, 0x0000001e, - 0x00000001, 0x00040047, 0x00000027, 0x00000006, 0x00000004, 0x00030047, 0x00000028, 0x00000002, - 0x00050048, 0x00000028, 0x00000000, 0x00000023, 0x00000000, 0x00050048, 0x00000028, 0x00000001, - 0x00000023, 0x00000010, 0x00040047, 0x00000030, 0x0000001e, 0x00000002, 0x00040047, 0x0000003b, - 0x0000001e, 0x00000002, 0x00040047, 0x00000040, 0x0000001e, 0x00000001, 0x00020013, 0x00000002, - 0x00030021, 0x00000003, 0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040017, 0x00000007, - 0x00000006, 0x00000004, 0x00040015, 0x00000008, 0x00000020, 0x00000000, 0x0004002b, 0x00000008, - 0x00000009, 0x00000001, 0x0004001c, 0x0000000a, 0x00000006, 0x00000009, 0x0006001e, 0x0000000b, - 0x00000007, 0x00000006, 0x0000000a, 0x0000000a, 0x00040020, 0x0000000c, 0x00000003, 0x0000000b, - 0x0004003b, 0x0000000c, 0x0000000d, 0x00000003, 0x00040015, 0x0000000e, 0x00000020, 0x00000001, - 0x0004002b, 0x0000000e, 0x0000000f, 0x00000000, 0x00040018, 0x00000010, 0x00000007, 0x00000004, - 0x0008001e, 0x00000011, 0x00000010, 0x00000010, 0x00000007, 0x00000007, 0x00000007, 0x00000007, - 0x00040020, 0x00000012, 0x00000002, 0x00000011, 0x0004003b, 0x00000012, 0x00000013, 0x00000002, - 0x00040020, 0x00000014, 0x00000002, 0x00000010, 0x00040017, 0x00000017, 0x00000006, 0x00000003, - 0x00040020, 0x00000018, 0x00000001, 0x00000017, 0x0004003b, 0x00000018, 0x00000019, 0x00000001, - 0x0004002b, 0x00000006, 0x0000001b, 0x3f800000, 0x00040020, 0x00000021, 0x00000003, 0x00000007, - 0x00040017, 0x00000023, 0x00000006, 0x00000002, 0x00040020, 0x00000024, 0x00000003, 0x00000023, - 0x0004003b, 0x00000024, 0x00000025, 0x00000003, 0x0004002b, 0x00000008, 0x00000026, 0x0000001c, - 0x0004001c, 0x00000027, 0x00000006, 0x00000026, 0x0004001e, 0x00000028, 0x00000007, 0x00000027, - 0x00040020, 0x00000029, 0x00000009, 0x00000028, 0x0004003b, 0x00000029, 0x0000002a, 0x00000009, - 0x00040020, 0x0000002b, 0x00000009, 0x00000007, 0x00040020, 0x0000002f, 0x00000001, 0x00000023, - 0x0004003b, 0x0000002f, 0x00000030, 0x00000001, 0x0004003b, 0x00000021, 0x0000003b, 0x00000003, - 0x0004002b, 0x0000000e, 0x0000003c, 0x00000004, 0x00040020, 0x0000003d, 0x00000002, 0x00000007, - 0x0004003b, 0x00000018, 0x00000040, 0x00000001, 0x00050036, 0x00000002, 0x00000004, 0x00000000, - 0x00000003, 0x000200f8, 0x00000005, 0x00050041, 0x00000014, 0x00000015, 0x00000013, 0x0000000f, - 0x0004003d, 0x00000010, 0x00000016, 0x00000015, 0x0004003d, 0x00000017, 0x0000001a, 0x00000019, - 0x00050051, 0x00000006, 0x0000001c, 0x0000001a, 0x00000000, 0x00050051, 0x00000006, 0x0000001d, - 0x0000001a, 0x00000001, 0x00050051, 0x00000006, 0x0000001e, 0x0000001a, 0x00000002, 0x00070050, - 0x00000007, 0x0000001f, 0x0000001c, 0x0000001d, 0x0000001e, 0x0000001b, 0x00050091, 0x00000007, - 0x00000020, 0x00000016, 0x0000001f, 0x00050041, 0x00000021, 0x00000022, 0x0000000d, 0x0000000f, - 0x0003003e, 0x00000022, 0x00000020, 0x00050041, 0x0000002b, 0x0000002c, 0x0000002a, 0x0000000f, - 0x0004003d, 0x00000007, 0x0000002d, 0x0000002c, 0x0007004f, 0x00000023, 0x0000002e, 0x0000002d, - 0x0000002d, 0x00000000, 0x00000001, 0x0004003d, 0x00000023, 0x00000031, 0x00000030, 0x00050041, - 0x0000002b, 0x00000032, 0x0000002a, 0x0000000f, 0x0004003d, 0x00000007, 0x00000033, 0x00000032, - 0x0007004f, 0x00000023, 0x00000034, 0x00000033, 0x00000033, 0x00000002, 0x00000003, 0x00050041, - 0x0000002b, 0x00000035, 0x0000002a, 0x0000000f, 0x0004003d, 0x00000007, 0x00000036, 0x00000035, - 0x0007004f, 0x00000023, 0x00000037, 0x00000036, 0x00000036, 0x00000000, 0x00000001, 0x00050083, - 0x00000023, 0x00000038, 0x00000034, 0x00000037, 0x00050085, 0x00000023, 0x00000039, 0x00000031, - 0x00000038, 0x00050081, 0x00000023, 0x0000003a, 0x0000002e, 0x00000039, 0x0003003e, 0x00000025, - 0x0000003a, 0x00050041, 0x0000003d, 0x0000003e, 0x00000013, 0x0000003c, 0x0004003d, 0x00000007, - 0x0000003f, 0x0000003e, 0x0003003e, 0x0000003b, 0x0000003f, 0x000100fd, 0x00010038, -}; -static const size_t sprite_stack_vert_spv_count = 575; diff --git a/test/spritestack.cpp b/test/spritestack.cpp index 9ca47ee0..090df494 100644 --- a/test/spritestack.cpp +++ b/test/spritestack.cpp @@ -1,967 +1,177 @@ #include "zeroerr/assert.h" #include "zeroerr/unittest.h" -#include +#include "common/Exception.h" +#include "filesystem/FileData.h" +#include "graphics/Color.h" +#include "graphics/Graphics.h" +#include "image/Image.h" +#include "image/ImageData.h" +#include "spritestack/SpriteStack.h" +#include "window/Window.h" + #include #include #include #include #include -#include -#include +#include -#include "common/Exception.h" -#include "filesystem/FileData.h" -#include "graphics/AmbientOcclusion.h" -#include "graphics/AntiAliasing.h" -#include "graphics/Canvas.h" -#include "graphics/DrawItem2D.h" -#include "graphics/Font.h" -#include "graphics/GBuffer.h" -#include "graphics/GlobalIllumination.h" -#include "graphics/Graphics.h" -#include "graphics/Grass.h" -#include "graphics/Light.h" -#include "graphics/Material.h" -#include "graphics/Mesh.h" -#include "graphics/Outline.h" -#include "graphics/Quad.h" -#include "graphics/RenderControl.h" -#include "graphics/RenderSystem.h" -#include "graphics/RenderSystem3D.h" -#include "graphics/ScreenSpaceReflection.h" -#include "graphics/Shader.h" -#include "graphics/Texture.h" -#include "graphics/Volumetric.h" -#include "graphics/Water.h" -#include "graphics/Waterfall.h" -#include "image/Image.h" -#include "image/ImageData.h" -#include "spritestack/SpriteStack.h" -#include "window/Window.h" -// Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form. using eve::graphics::Color; - -using eve::graphics::Camera3D; using eve::graphics::Graphics; -using eve::graphics::Light3D; -using eve::graphics::Renderable2D; -using eve::graphics::Renderable3D; -using eve::graphics::RenderSystem; -using eve::graphics::RenderSystem3D; -using eve::graphics::Texture; using eve::image::ImageData; using eve::spritestack::SpriteStack; +using eve::spritestack::SpriteStack2D; using eve::spritestack::SpriteStackBatch; -using eve::spritestack::SpriteStack3D; using Colorf = ImageData::Colorf; namespace { - int countAlpha(const ImageData *img) { - int n = 0; - const int w = img->getWidth(); - const int h = img->getHeight(); - for (int y = 0; y < h; ++y) - for (int x = 0; x < w; ++x) - if (img->getPixel(x, y).a > 0.5f) ++n; - return n; -} - -bool centerCovered(const ImageData *img) { - const int w = img->getWidth(); - const int h = img->getHeight(); - return img->getPixel(w / 2, h / 2).a > 0.5f; + int count = 0; + for (int y = 0; y < img->getHeight(); ++y) + for (int x = 0; x < img->getWidth(); ++x) + if (img->getPixel(x, y).a > 0.5f) ++count; + return count; } std::string testOutDir() { return std::string(EVENGINE_TEST_BINARY_DIR) + "/out"; } -void saveImageDataPng(ImageData *frame, const std::string &path) { +void savePng(ImageData *frame, const std::string &path) { REQUIRE(frame != nullptr); eve::image::Image::create(); - eve::filesystem::FileData *png = - frame->encode(eve::image::ImageData::FormatHandler::ENCODED_PNG, "spritestack.png", false); - REQUIRE(png != nullptr); - REQUIRE(png->getSize() > 0); - + std::unique_ptr png( + frame->encode(ImageData::FormatHandler::ENCODED_PNG, "spritestack2d.png", false)); + REQUIRE(png.get() != nullptr); std::error_code ec; std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); - { - std::ofstream out(path, std::ios::binary); - REQUIRE(out.good()); - out.write(static_cast(png->getData()), - static_cast(png->getSize())); - REQUIRE(out.good()); - } - delete png; - std::printf("spritestack render saved: %s\n", path.c_str()); + std::ofstream out(path, std::ios::binary); + out.write(static_cast(png->getData()), std::streamsize(png->getSize())); + REQUIRE(out.good()); } -void resetScene3D() { - if (ecs::current()->getManager() != nullptr) { - auto view = ecs::View(); - for (auto it = view.begin(); it != view.end(); ++it) { - auto [mr] = *it; - mr->visible = false; - } +struct WindowFixture { + eve::window::Window *win = eve::window::Window::create(); + Graphics *gfx = Graphics::create(); + WindowFixture() { + REQUIRE(win != nullptr); + REQUIRE(gfx != nullptr); + eve::window::WindowSettings settings; + settings.width = 320; + settings.height = 240; + settings.centered = true; + REQUIRE(win->setWindowSettings(settings)); + gfx->setScreenReadbackEnabled(true); + gfx->setBackgroundColor(Color(0.07f, 0.09f, 0.12f, 1.f)); } - if (ecs::current()->getManager() != nullptr) { - auto camView = ecs::View(); - for (auto it = camView.begin(); it != camView.end(); ++it) { - auto [data] = *it; - data->active = false; - } - } - if (ecs::current()->getManager() != nullptr) { - auto view = ecs::View(); - for (auto it = view.begin(); it != view.end(); ++it) { - auto [sp] = *it; - sp->visible = false; - } - } -} + ~WindowFixture() { win->close(); } +}; -} // namespace +std::unique_ptr renderStack(Graphics *gfx, const SpriteStack2D &stack) { + for (int i = 0; i < 3; ++i) { + gfx->clear(std::nullopt, std::nullopt, std::nullopt); + stack.render(gfx); + gfx->present(); + } + return std::unique_ptr(gfx->newImageData()); +} +} TEST_CASE("spritestack.slice.boxLayers") { - auto *mod = SpriteStack::create(); - REQUIRE(mod != nullptr); - CHECK_EQ(mod->getName(), std::string("SpriteStack")); - - std::vector layers = - mod->slicePrimitive("box", 6, 48, 48, "z", 0.f); + auto layers = SpriteStack::create()->slicePrimitive("box", 6, 48, 48, "y", 0.f); REQUIRE_EQ(layers.size(), size_t(6)); - for (auto *img : layers) { - REQUIRE(img != nullptr); - CHECK_EQ(img->getWidth(), 48); - CHECK_EQ(img->getHeight(), 48); - CHECK_EQ(img->getFormat(), std::string("RGBA8")); - delete img; + for (ImageData *layer : layers) { + REQUIRE(layer != nullptr); + CHECK_GT(countAlpha(layer), 100); + delete layer; } } TEST_CASE("spritestack.slice.sphereVariesAcrossLayers") { - // A sphere sliced along Z: middle slabs are wide, outer slabs are thin. - std::vector layers = - SpriteStack::create()->slicePrimitive("sphere", 8, 64, 64, "z", 0.f); - REQUIRE_EQ(layers.size(), size_t(8)); - - const int middle = countAlpha(layers[3]); - const int outer = countAlpha(layers[0]); - CHECK_GT(middle, 0); - CHECK_GT(outer, 0); - CHECK_GT(middle, outer); // center slab covers more area than the cap slab - CHECK(centerCovered(layers[3])); - - for (auto *img : layers) delete img; + auto layers = SpriteStack::create()->slicePrimitive("sphere", 9, 64, 64, "y", 0.f); + REQUIRE_EQ(layers.size(), size_t(9)); + const int edge = countAlpha(layers.front()); + const int middle = countAlpha(layers[layers.size() / 2]); + CHECK_GT(middle, edge); + for (ImageData *layer : layers) delete layer; } -TEST_CASE("spritestack.slice.axisYTopDown") { - // Top-down slicing ("y" axis) of a cylinder yields circular layers. - std::vector layers = - SpriteStack::create()->slicePrimitive("cylinder", 6, 64, 64, "y", 0.f); - REQUIRE_EQ(layers.size(), size_t(6)); - for (int i = 0; i < 6; ++i) { - REQUIRE(layers[i] != nullptr); - // Every horizontal cut of a full cylinder covers the center. - CHECK(centerCovered(layers[i])); - delete layers[i]; - } -} - -TEST_CASE("spritestack.slice.meshArrays") { - // 2x2x2 box built from raw arrays (unit corners), sliced along Z. - const float h = 1.f; - const float pos[] = { - -h, -h, -h, h, -h, -h, h, h, -h, -h, h, -h, - -h, -h, h, h, -h, h, h, h, h, -h, h, h, - }; - const uint32_t idx[] = {0, 1, 2, 0, 2, 3, 5, 4, 7, 5, 7, 6, - 4, 0, 3, 4, 3, 7, 1, 5, 6, 1, 6, 2, - 4, 5, 1, 4, 1, 0, 3, 2, 6, 3, 6, 7}; - eve::spritestack::SliceInput in{}; - in.posXYZ = pos; - in.vertexCount = 8; - in.indices = idx; - in.indexCount = int(sizeof(idx) / sizeof(idx[0])); - - eve::spritestack::SliceOptions opt; - opt.layerCount = 4; - opt.imageW = 32; - opt.imageH = 32; - opt.thickness = 0.5f; // box depth 2 / 4 slabs - std::vector layers = eve::spritestack::sliceMeshToLayers(in, opt); - REQUIRE_EQ(layers.size(), size_t(4)); - // Every slab of a full box covers its center column. - for (auto *img : layers) { - REQUIRE(img != nullptr); - CHECK(centerCovered(img)); - delete img; - } - - bool threw = false; - try { - eve::spritestack::SliceInput bad{}; - bad.posXYZ = pos; - bad.vertexCount = 0; - bad.indices = idx; - bad.indexCount = 3; - (void)eve::spritestack::sliceMeshToLayers(bad, opt); - } catch (const eve::Exception &) { - threw = true; - } - CHECK(threw); -} - -TEST_CASE("spritestack.render.verticalStack") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); - REQUIRE(win != nullptr); - REQUIRE(gfx != nullptr); - eve::window::WindowSettings s; - s.width = 256; - s.height = 192; - s.centered = true; - REQUIRE(win->setWindowSettings(s)); - resetScene3D(); - - auto *cam = Camera3D::createCamera(); - cam->setTarget(0.f, 0.f, 0.f); - cam->setEye(3.0f, 2.2f, 4.0f); - cam->data()->nearZ = 0.1f; - cam->data()->farZ = 50.f; - cam->data()->ambientR = 0.30f; - cam->data()->ambientG = 0.32f; - cam->data()->ambientB = 0.36f; - - // Slices are self-contained: no Renderable3D needed in the scene. - auto *hud = Renderable2D::create(); - hud->transform()->x = 0; - hud->transform()->y = 0; - hud->sprite()->width = 2; - hud->sprite()->height = 2; - hud->sprite()->r = 0.f; - hud->sprite()->g = 0.f; - hud->sprite()->b = 0.f; - hud->sprite()->a = 0.f; - hud->sprite()->visible = true; - - gfx->setScreenReadbackEnabled(true); - gfx->setBackgroundColor(::Color(0.14f, 0.16f, 0.20f, 1.f)); - RenderSystem3D::setDirectionalLight(0.55f, 0.95f, 1.15f, 1.05f, 1.0f, 0.95f); - std::vector layers = - SpriteStack::create()->slicePrimitive("cylinder", 16, 96, 96, "z", 0.f); - REQUIRE_EQ(layers.size(), size_t(16)); - - SpriteStack3D stack; - stack.setLayerCount(16); - for (int i = 0; i < 16; ++i) stack.setLayerImage(gfx, layers[i], i); - stack.setThickness(0.16f); - stack.setSize(1.6f, 1.6f); - stack.setPosition(0.f, 0.f, 0.f); - stack.setTint(0.35f, 0.78f, 0.72f, 1.f); - auto renderFrame = [&](float yaw) -> ImageData * { - resetScene3D(); - cam->data()->active = true; - stack.setYaw(yaw); - for (int i = 0; i < 3; ++i) { - RenderSystem3D::render(*gfx); - stack.render(gfx); - RenderSystem::render(*gfx); - } - return gfx->newImageData(); - }; - - std::unique_ptr frameA(renderFrame(0.0f)); - REQUIRE(frameA.get() != nullptr); - saveImageDataPng(frameA.get(), testOutDir() + "/sprite_stack_vertical.png"); - - // The stacked cylinder should paint a visible blob near the screen center. - int lit = 0; - const int w = frameA->getWidth(); - const int h = frameA->getHeight(); - for (int y = h / 4; y < 3 * h / 4; ++y) { - for (int x = w / 4; x < 3 * w / 4; ++x) { - Colorf c = frameA->getPixel(x, y); - if (c.r + c.g + c.b > 0.25f && c.a > 0.5f) ++lit; - } - } - CHECK_GT(lit, 400); - - // Rotating the stack around Y must change the rendered pixels. - std::unique_ptr frameB(renderFrame(1.3f)); - REQUIRE(frameB.get() != nullptr); - int diff = 0; - for (int y = 0; y < h; ++y) { - for (int x = 0; x < w; ++x) { - Colorf a = frameA->getPixel(x, y); - Colorf b = frameB->getPixel(x, y); - if (std::fabs(a.r - b.r) + std::fabs(a.g - b.g) + std::fabs(a.b - b.b) > 0.08f) ++diff; - } - } - CHECK_GT(diff, 64); - - for (auto *img : layers) delete img; - win->close(); -} - -TEST_CASE("spritestack.render.gbufferOutline") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); - REQUIRE(win != nullptr); - REQUIRE(gfx != nullptr); - eve::window::WindowSettings s; - s.width = 256; - s.height = 192; - s.centered = true; - REQUIRE(win->setWindowSettings(s)); - - resetScene3D(); - - auto *cam = Camera3D::createCamera(); - cam->setTarget(0.f, 0.f, 0.f); - cam->setEye(3.2f, 2.8f, 4.2f); - cam->data()->nearZ = 0.1f; - cam->data()->farZ = 50.f; - - auto *hud = Renderable2D::create(); - hud->transform()->x = 0; - hud->transform()->y = 0; - hud->sprite()->width = 2; - hud->sprite()->height = 2; - hud->sprite()->a = 0.f; - hud->sprite()->visible = true; - - // A mesh so the GBuffer pass has content and the outline pass runs. - auto *ground = Renderable3D::create(); - ground->meshRenderer()->mesh = gfx->newMeshCylinder(32, 1, true); - ground->transform()->x = 0.f; - ground->transform()->y = -1.5f; - ground->transform()->sx = 6.f; - ground->transform()->sy = 0.1f; - ground->transform()->sz = 6.f; - ground->setTint(0.20f, 0.26f, 0.22f, 1.f); - - gfx->setScreenReadbackEnabled(true); - gfx->setBackgroundColor(::Color(0.12f, 0.13f, 0.16f, 1.f)); - gfx->getRenderControl()->enable("outline"); - - std::vector layers = - SpriteStack::create()->slicePrimitive("cone", 12, 96, 96, "z", 0.f); - REQUIRE_EQ(layers.size(), size_t(12)); - - SpriteStack3D stack; - stack.setLayerCount(12); - for (int i = 0; i < 12; ++i) stack.setLayerImage(gfx, layers[i], i); - stack.setThickness(0.13f); - stack.setSize(1.5f, 1.6f); - stack.setPosition(0.f, 0.2f, 0.f); - stack.setTint(0.45f, 0.55f, 0.35f, 1.f); - - auto renderFrame = [&]() -> ImageData * { - for (int i = 0; i < 3; ++i) { - RenderSystem3D::render(*gfx); - stack.render(gfx); - RenderSystem::render(*gfx); - } - return gfx->newImageData(); - }; - - // Without G-buffer participation the outline pass cannot see the slices. - std::unique_ptr noG(renderFrame()); - REQUIRE(noG.get() != nullptr); - - stack.setGbufferEnabled(true); - std::unique_ptr withG(renderFrame()); - REQUIRE(withG.get() != nullptr); - saveImageDataPng(withG.get(), testOutDir() + "/sprite_stack_gbuffer_outline.png"); - - // Outline pass paints a dark blue-ish rim (0.05,0.04,0.07) around the stack. - int rimNoG = 0, rimG = 0; - const int w = withG->getWidth(); - const int h = withG->getHeight(); - auto countRim = [&](ImageData *f) { - int n = 0; - for (int y = 0; y < h; ++y) - for (int x = 0; x < w; ++x) { - Colorf c = f->getPixel(x, y); - if (std::fabs(c.r - 0.05f) < 0.05f && std::fabs(c.g - 0.04f) < 0.05f && - std::fabs(c.b - 0.07f) < 0.05f && c.r + c.g + c.b < 0.3f) - ++n; - } - return n; - }; - rimNoG = countRim(noG.get()); - rimG = countRim(withG.get()); - CHECK_GT(rimG, rimNoG + 20); - - stack.setGbufferEnabled(false); - for (auto *img : layers) delete img; - win->close(); -} - -TEST_CASE("spritestack.render.batchShadow") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); - REQUIRE(win != nullptr); - REQUIRE(gfx != nullptr); - eve::window::WindowSettings s; - s.width = 256; - s.height = 192; - s.centered = true; - REQUIRE(win->setWindowSettings(s)); - - resetScene3D(); - - auto *cam = Camera3D::createCamera(); - cam->setTarget(0.f, 0.f, 0.f); - cam->setEye(3.2f, 2.8f, 4.2f); - cam->data()->nearZ = 0.1f; - cam->data()->farZ = 50.f; - - auto *hud = Renderable2D::create(); - hud->transform()->x = 0; - hud->transform()->y = 0; - hud->sprite()->width = 2; - hud->sprite()->height = 2; - hud->sprite()->a = 0.f; - hud->sprite()->visible = true; - - auto *ground = Renderable3D::create(); - ground->meshRenderer()->mesh = gfx->newMeshCylinder(32, 1, true); - ground->transform()->x = 0.f; - ground->transform()->y = -1.5f; - ground->transform()->sx = 6.f; - ground->transform()->sy = 0.1f; - ground->transform()->sz = 6.f; - ground->setTint(0.20f, 0.26f, 0.22f, 1.f); - - gfx->setScreenReadbackEnabled(true); - gfx->setBackgroundColor(::Color(0.12f, 0.13f, 0.16f, 1.f)); - - std::vector layers = - SpriteStack::create()->slicePrimitive("sphere", 10, 64, 64, "z", 0.f); - REQUIRE_EQ(layers.size(), size_t(10)); - std::vector texs; - for (auto *img : layers) { - texs.push_back(gfx->newTexture(img)); - delete img; - } - - auto *a = new SpriteStack3D(); - auto *b = new SpriteStack3D(); - for (auto *st : {a, b}) { - st->setLayerCount(10); - for (int i = 0; i < 10; ++i) st->setLayerTexture(texs[size_t(i)], i); - st->setThickness(0.14f); - st->setSize(0.8f, 0.8f); - } - a->setPosition(-1.0f, 0.f, 0.f); - a->setTint(0.8f, 0.45f, 0.25f, 1.f); - b->setPosition(1.0f, 0.f, 0.f); - b->setTint(0.3f, 0.65f, 0.85f, 1.f); - - SpriteStackBatch batch; - batch.add(a); - batch.add(b); - b->setShadowEnabled(true); - b->setShadowOpacity(0.45f); - b->setShadowLight(0.35f, -1.f, 0.25f); - b->setShadowPlaneY(-1.45f); - - auto renderFrame = [&]() -> ImageData * { - for (int i = 0; i < 3; ++i) { - RenderSystem3D::render(*gfx); - batch.render(gfx); - RenderSystem::render(*gfx); - } - return gfx->newImageData(); - }; - - std::unique_ptr plain(renderFrame()); - REQUIRE(plain.get() != nullptr); - b->setShadowEnabled(false); - std::unique_ptr noShadow(renderFrame()); - REQUIRE(noShadow.get() != nullptr); - - // With the shadow batched in, dark pixels appear on the ground. - int dark = 0; - const int w = plain->getWidth(); - const int h = plain->getHeight(); - for (int y = 0; y < h; ++y) { - for (int x = 0; x < w; ++x) { - Colorf a = noShadow->getPixel(x, y); - Colorf b = plain->getPixel(x, y); - if ((a.r + a.g + a.b) - (b.r + b.g + b.b) > 0.12f) ++dark; - } - } - CHECK_GT(dark, 50); - - delete a; - delete b; - win->close(); -} - -TEST_CASE("spritestack.render.atlasStrip") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); - REQUIRE(win != nullptr); - REQUIRE(gfx != nullptr); - eve::window::WindowSettings s; - s.width = 256; - s.height = 192; - s.centered = true; - REQUIRE(win->setWindowSettings(s)); - - resetScene3D(); - - auto *cam = Camera3D::createCamera(); - cam->setTarget(0.f, 0.f, 0.f); - cam->setEye(3.0f, 2.2f, 4.0f); - cam->data()->nearZ = 0.1f; - cam->data()->farZ = 50.f; - - auto *hud = Renderable2D::create(); - hud->transform()->x = 0; - hud->transform()->y = 0; - hud->sprite()->width = 2; - hud->sprite()->height = 2; - hud->sprite()->a = 0.f; - hud->sprite()->visible = true; - - gfx->setScreenReadbackEnabled(true); - gfx->setBackgroundColor(::Color(0.14f, 0.16f, 0.20f, 1.f)); - - // Build a horizontal atlas strip: 6 cells, each a filled circle whose - // radius shrinks toward the last cell (a cone cross-section set). - const int cell = 64; - const int layers = 6; - auto *strip = new ImageData(cell * layers, cell, "RGBA8"); - for (int li = 0; li < layers; ++li) { - const float t = float(li) / float(layers - 1); - const float radius = 0.42f * (1.f - 0.75f * t); - for (int y = 0; y < cell; ++y) { - for (int x = 0; x < cell; ++x) { - const float nx = (float(x) + 0.5f) / float(cell) - 0.5f; - const float ny = (float(y) + 0.5f) / float(cell) - 0.5f; - Colorf c{0.f, 0.f, 0.f, 0.f}; - if (nx * nx + ny * ny <= radius * radius) - c = Colorf{0.5f + 0.4f * t, 0.7f - 0.35f * t, 0.35f + 0.3f * t, 1.f}; - strip->setPixel(li * cell + x, y, c); - } - } - } - Texture *atlas = gfx->newTexture(strip); - delete strip; - REQUIRE(atlas != nullptr); - - SpriteStack3D stack; - stack.setLayersFromAtlas(gfx, atlas, layers); - stack.setThickness(0.16f); - stack.setSize(1.6f, 1.6f); - stack.setPosition(0.f, 0.f, 0.f); - stack.setTint(1.f, 1.f, 1.f, 1.f); - - for (int i = 0; i < 3; ++i) { - RenderSystem3D::render(*gfx); - stack.render(gfx); - RenderSystem::render(*gfx); - } - std::unique_ptr frame(gfx->newImageData()); - REQUIRE(frame.get() != nullptr); - saveImageDataPng(frame.get(), testOutDir() + "/sprite_stack_atlas.png"); - - int lit = 0; - const int w = frame->getWidth(); - const int h = frame->getHeight(); - for (int y = h / 4; y < 3 * h / 4; ++y) { - for (int x = w / 4; x < 3 * w / 4; ++x) { - Colorf c = frame->getPixel(x, y); - if (c.r + c.g + c.b > 0.25f && c.a > 0.5f) ++lit; - } - } - CHECK_GT(lit, 300); - - win->close(); -} - -TEST_CASE("spritestack.render.csmShadow") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); - REQUIRE(win != nullptr); - REQUIRE(gfx != nullptr); - eve::window::WindowSettings s; - s.width = 320; - s.height = 240; - s.centered = true; - REQUIRE(win->setWindowSettings(s)); - - resetScene3D(); - - auto *cam = Camera3D::createCamera(); - cam->setEye(0.f, 3.5f, 5.5f); - cam->setTarget(0.f, 0.f, 0.f); - cam->setAmbient(0.06f, 0.06f, 0.07f); - - // Receiving ground + a directional CSM light (mirrors Shadow3D setup). - auto *ground = Renderable3D::create(); - ground->meshRenderer()->mesh = gfx->newMeshSphere(16, 8); - const uint8_t gray[4] = {219, 219, 219, 255}; - ground->meshRenderer()->texture = gfx->newTexture(1, 1, gray); - ground->transform()->x = 0.f; - ground->transform()->y = -1.2f; - ground->transform()->sx = 4.f; - ground->transform()->sy = 0.15f; - ground->transform()->sz = 4.f; - ground->setCastShadow(false); - ground->setReceiveShadow(true); - ground->setRoughness(0.85f); - - auto *sun = Light3D::createLight("dir"); - sun->setDirection(0.55f, 1.f, 0.35f); - sun->setColor(1.f, 1.f, 1.f, 2.5f); - sun->setCastShadow(true); - sun->setShadowStrength(1.f); - sun->setShadowBias(0.003f); - - auto *hud = Renderable2D::create(); - hud->transform()->x = 0; - hud->transform()->y = 0; - hud->sprite()->width = 2; - hud->sprite()->height = 2; - hud->sprite()->a = 0.f; - hud->sprite()->visible = true; - - gfx->setScreenReadbackEnabled(true); - gfx->setBackgroundColor(::Color(0.10f, 0.11f, 0.14f, 1.f)); - - std::vector layers = - SpriteStack::create()->slicePrimitive("cone", 12, 96, 96, "z", 0.f); - REQUIRE_EQ(layers.size(), size_t(12)); - - SpriteStack3D stack; - stack.setLayerCount(12); - for (int i = 0; i < 12; ++i) stack.setLayerImage(gfx, layers[i], i); - stack.setThickness(0.13f); - stack.setSize(1.4f, 1.5f); - stack.setPosition(0.f, 0.2f, 0.f); - stack.setTint(0.45f, 0.55f, 0.35f, 1.f); - stack.setCastShadow(true); - - auto renderFrame = [&]() -> ImageData * { - for (int i = 0; i < 3; ++i) { - RenderSystem3D::render(*gfx); - stack.render(gfx); - RenderSystem::render(*gfx); - } - return gfx->newImageData(); - }; - - auto lumaGrid = [&](ImageData *f) { - std::vector out; - const int w = f->getWidth(); - const int h = f->getHeight(); - out.reserve(size_t(w * h)); - for (int y = 0; y < h; ++y) - for (int x = 0; x < w; ++x) { - Colorf c = f->getPixel(x, y); - out.push_back((c.r + c.g + c.b) / 3.f); - } - return out; - }; - - // Casting on -> CSM umbra darkens some ground texels. - std::unique_ptr on(renderFrame()); - REQUIRE(on.get() != nullptr); - stack.setCastShadow(false); - std::unique_ptr off(renderFrame()); - REQUIRE(off.get() != nullptr); - saveImageDataPng(on.get(), testOutDir() + "/sprite_stack_csm_shadow.png"); - - const auto L_on = lumaGrid(on.get()); - const auto L_off = lumaGrid(off.get()); - float best = -1.f; - const int w = on->getWidth(); - const int h = on->getHeight(); - for (int y = 0; y < h; y += 4) { - for (int x = 0; x < w; x += 4) { - const size_t i = size_t(y * w + x); - best = std::max(best, L_off[i] - L_on[i]); - } - } - CHECK_GT(best, 0.02f); - - for (auto *img : layers) delete img; - win->close(); -} - -TEST_CASE("spritestack.render.horizontalMode") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); - REQUIRE(win != nullptr); - REQUIRE(gfx != nullptr); - eve::window::WindowSettings s; - s.width = 256; - s.height = 192; - s.centered = true; - REQUIRE(win->setWindowSettings(s)); - - resetScene3D(); - - auto *cam = Camera3D::createCamera(); - cam->setTarget(0.f, 0.f, 0.f); - cam->setEye(3.2f, 2.8f, 4.2f); - cam->data()->nearZ = 0.1f; - cam->data()->farZ = 50.f; - - auto *hud = Renderable2D::create(); - hud->transform()->x = 0; - hud->transform()->y = 0; - hud->sprite()->width = 2; - hud->sprite()->height = 2; - hud->sprite()->a = 0.f; - hud->sprite()->visible = true; - - gfx->setScreenReadbackEnabled(true); - gfx->setBackgroundColor(::Color(0.12f, 0.13f, 0.16f, 1.f)); - - // Top-down slices of a cone viewed from a 3/4 camera read as a 3D cone. - std::vector layers = - SpriteStack::create()->slicePrimitive("cone", 12, 96, 96, "y", 0.f); - REQUIRE_EQ(layers.size(), size_t(12)); - - SpriteStack3D stack; - stack.setLayerCount(12); - for (int i = 0; i < 12; ++i) stack.setLayerImage(gfx, layers[i], i); - stack.setMode("horizontal"); - stack.setThickness(0.18f); - stack.setSize(2.2f, 2.2f); - stack.setPosition(0.f, -1.0f, 0.f); - stack.setTint(0.55f, 0.42f, 0.30f, 1.f); - - for (int i = 0; i < 3; ++i) { - RenderSystem3D::render(*gfx); - stack.render(gfx); - RenderSystem::render(*gfx); - } - std::unique_ptr frame(gfx->newImageData()); - REQUIRE(frame.get() != nullptr); - saveImageDataPng(frame.get(), testOutDir() + "/sprite_stack_horizontal.png"); - - int lit = 0; - const int w = frame->getWidth(); - const int h = frame->getHeight(); - for (int y = h / 4; y < 3 * h / 4; ++y) { - for (int x = w / 4; x < 3 * w / 4; ++x) { - Colorf c = frame->getPixel(x, y); - if (c.r + c.g + c.b > 0.25f && c.a > 0.5f) ++lit; - } - } - CHECK_GT(lit, 300); - - for (auto *img : layers) delete img; - win->close(); -} - -TEST_CASE("spritestack.render.batch") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); - REQUIRE(win != nullptr); - REQUIRE(gfx != nullptr); - eve::window::WindowSettings s; - s.width = 256; - s.height = 192; - s.centered = true; - REQUIRE(win->setWindowSettings(s)); - - resetScene3D(); - - auto *cam = Camera3D::createCamera(); - cam->setTarget(0.f, 0.f, 0.f); - cam->setEye(3.0f, 2.2f, 4.0f); - cam->data()->nearZ = 0.1f; - cam->data()->farZ = 50.f; - - auto *hud = Renderable2D::create(); - hud->transform()->x = 0; - hud->transform()->y = 0; - hud->sprite()->width = 2; - hud->sprite()->height = 2; - hud->sprite()->a = 0.f; - hud->sprite()->visible = true; - - gfx->setScreenReadbackEnabled(true); - gfx->setBackgroundColor(::Color(0.14f, 0.16f, 0.20f, 1.f)); - - // Both stacks share the same layer textures so the batch groups them into - // one combined mesh per texture (single draw call). - std::vector layers = - SpriteStack::create()->slicePrimitive("sphere", 10, 64, 64, "z", 0.f); - REQUIRE_EQ(layers.size(), size_t(10)); - std::vector texs; - for (auto *img : layers) { - texs.push_back(gfx->newTexture(img)); - delete img; - } - - auto makeStack = [&](float x, float z) { - auto *st = new SpriteStack3D(); - st->setLayerCount(10); - for (int i = 0; i < 10; ++i) st->setLayerTexture(texs[size_t(i)], i); - st->setThickness(0.14f); - st->setSize(0.8f, 0.8f); - st->setPosition(x, 0.f, z); - return st; - }; - - std::unique_ptr a(makeStack(-1.0f, 0.f)); - std::unique_ptr b(makeStack(1.0f, 0.f)); - a->setTint(0.8f, 0.45f, 0.25f, 1.f); - b->setTint(0.3f, 0.65f, 0.85f, 1.f); - - SpriteStackBatch batch; - batch.add(a.get()); - batch.add(b.get()); - CHECK_EQ(batch.getStackCount(), 2); - - for (int i = 0; i < 3; ++i) { - RenderSystem3D::render(*gfx); - batch.render(gfx); - RenderSystem::render(*gfx); - } - std::unique_ptr frame(gfx->newImageData()); - REQUIRE(frame.get() != nullptr); - saveImageDataPng(frame.get(), testOutDir() + "/sprite_stack_batch.png"); - - // Two batched spheres should paint two distinct lit blobs. - int litLeft = 0, litRight = 0; - const int w = frame->getWidth(); - const int h = frame->getHeight(); - for (int y = h / 3; y < 2 * h / 3; ++y) { - for (int x = w / 4; x < w / 2; ++x) { - Colorf c = frame->getPixel(x, y); - if (c.r + c.g + c.b > 0.25f && c.a > 0.5f) ++litLeft; - } - for (int x = w / 2; x < 3 * w / 4; ++x) { - Colorf c = frame->getPixel(x, y); - if (c.r + c.g + c.b > 0.25f && c.a > 0.5f) ++litRight; - } - } - CHECK_GT(litLeft, 120); - CHECK_GT(litRight, 120); - - batch.remove(b.get()); - CHECK_EQ(batch.getStackCount(), 1); - win->close(); -} - -TEST_CASE("spritestack.render.shadowAndOutline") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); - REQUIRE(win != nullptr); - REQUIRE(gfx != nullptr); - eve::window::WindowSettings s; - s.width = 256; - s.height = 192; - s.centered = true; - REQUIRE(win->setWindowSettings(s)); - - resetScene3D(); - - auto *cam = Camera3D::createCamera(); - cam->setTarget(0.f, 0.f, 0.f); - cam->setEye(3.2f, 2.8f, 4.2f); - cam->data()->nearZ = 0.1f; - cam->data()->farZ = 50.f; - - auto *hud = Renderable2D::create(); - hud->transform()->x = 0; - hud->transform()->y = 0; - hud->sprite()->width = 2; - hud->sprite()->height = 2; - hud->sprite()->a = 0.f; - hud->sprite()->visible = true; - - // Ground disc so the projected shadow has a surface to land on. - auto *ground = Renderable3D::create(); - ground->meshRenderer()->mesh = gfx->newMeshCylinder(32, 1, true); - ground->transform()->x = 0.f; - ground->transform()->y = -1.5f; - ground->transform()->sx = 6.f; - ground->transform()->sy = 0.1f; - ground->transform()->sz = 6.f; - ground->setTint(0.20f, 0.26f, 0.22f, 1.f); - - gfx->setScreenReadbackEnabled(true); - gfx->setBackgroundColor(::Color(0.12f, 0.13f, 0.16f, 1.f)); - - std::vector layers = - SpriteStack::create()->slicePrimitive("cone", 12, 96, 96, "z", 0.f); - REQUIRE_EQ(layers.size(), size_t(12)); - - SpriteStack3D stack; - stack.setLayerCount(12); - for (int i = 0; i < 12; ++i) stack.setLayerImage(gfx, layers[i], i); - stack.setThickness(0.13f); - stack.setSize(1.5f, 1.6f); - stack.setPosition(0.f, 0.2f, 0.f); - stack.setTint(0.45f, 0.55f, 0.35f, 1.f); - - auto renderFrame = [&]() -> ImageData * { - for (int i = 0; i < 3; ++i) { - RenderSystem3D::render(*gfx); - stack.render(gfx); - RenderSystem::render(*gfx); - } - return gfx->newImageData(); - }; - - std::unique_ptr plain(renderFrame()); - REQUIRE(plain.get() != nullptr); - saveImageDataPng(plain.get(), testOutDir() + "/sprite_stack_plain.png"); - - // Shadow: dark blob projected onto the ground below/right of the cone. +TEST_CASE("spritestack.render.pure2DStack") { + WindowFixture fx; + auto layers = SpriteStack::create()->slicePrimitive("cone", 14, 64, 64, "y", 0.f); + SpriteStack2D stack; + stack.setLayerCount(int(layers.size())); + for (int i = 0; i < int(layers.size()); ++i) stack.setLayerImage(fx.gfx, layers[size_t(i)], i); + stack.setPosition(160.f, 155.f); + stack.setSize(96.f, 96.f); + stack.setThickness(3.f); + stack.setRotation(25.f); + stack.setTint(0.44f, 0.78f, 0.58f, 1.f); stack.setShadowEnabled(true); - stack.setShadowOpacity(0.42f); - stack.setShadowLight(0.35f, -1.f, 0.25f); - stack.setShadowPlaneY(-1.45f); - std::unique_ptr shadow(renderFrame()); - REQUIRE(shadow.get() != nullptr); - saveImageDataPng(shadow.get(), testOutDir() + "/sprite_stack_shadow.png"); + stack.setShadowOffset(8.f, 6.f); + stack.setOutline(1.5f, 0.02f, 0.03f, 0.04f); - int dark = 0; - const int w = shadow->getWidth(); - const int h = shadow->getHeight(); - for (int y = 0; y < h; ++y) { - for (int x = 0; x < w; ++x) { - Colorf a = plain->getPixel(x, y); - Colorf b = shadow->getPixel(x, y); - const float diff = (a.r + a.g + a.b) - (b.r + b.g + b.b); - if (diff > 0.12f) ++dark; + auto frame = renderStack(fx.gfx, stack); + REQUIRE(frame.get() != nullptr); + savePng(frame.get(), testOutDir() + "/sprite_stack_2d.png"); + int changed = 0; + for (int y = 30; y < 210; ++y) { + for (int x = 60; x < 260; ++x) { + const Colorf pixel = frame->getPixel(x, y); + if (pixel.g > 0.20f && pixel.g > pixel.r * 1.15f) ++changed; } } - CHECK_GT(dark, 60); - - // Outline: expanded dark silhouette grows the cone's footprint. - stack.setShadowEnabled(false); - stack.setOutline(0.12f, 0.0f, 0.0f, 0.0f); - std::unique_ptr outlined(renderFrame()); - REQUIRE(outlined.get() != nullptr); - saveImageDataPng(outlined.get(), testOutDir() + "/sprite_stack_outline.png"); - - auto countDarkRim = [&](ImageData *f) { - int n = 0; - for (int y = h / 4; y < 3 * h / 4; ++y) - for (int x = w / 4; x < 3 * w / 4; ++x) { - Colorf c = f->getPixel(x, y); - if (c.r + c.g + c.b < 0.15f) ++n; - } - return n; - }; - // The outline is an opaque black rim; count those pixels appearing around - // the cone's silhouette only when the outline is enabled. - CHECK_GT(countDarkRim(outlined.get()), countDarkRim(plain.get()) + 30); - - for (auto *img : layers) delete img; - win->close(); + CHECK_GT(changed, 500); + for (ImageData *layer : layers) delete layer; +} + +TEST_CASE("spritestack.render.rotationChangesPixels") { + WindowFixture fx; + auto layers = SpriteStack::create()->slicePrimitive("box", 8, 48, 48, "y", 0.f); + SpriteStack2D stack; + stack.setLayerCount(int(layers.size())); + for (int i = 0; i < int(layers.size()); ++i) stack.setLayerImage(fx.gfx, layers[size_t(i)], i); + stack.setPosition(160.f, 140.f); + stack.setSize(100.f, 68.f); + stack.setThickness(3.f); + auto before = renderStack(fx.gfx, stack); + stack.setRotation(37.f); + auto after = renderStack(fx.gfx, stack); + int diff = 0; + for (int y = 0; y < before->getHeight(); ++y) + for (int x = 0; x < before->getWidth(); ++x) { + const Colorf a = before->getPixel(x, y); + const Colorf b = after->getPixel(x, y); + if (std::fabs(a.r - b.r) + std::fabs(a.g - b.g) + std::fabs(a.b - b.b) > 0.08f) + ++diff; + } + CHECK_GT(diff, 300); + for (ImageData *layer : layers) delete layer; +} + +TEST_CASE("spritestack.render.batchUses2DStacks") { + WindowFixture fx; + auto layers = SpriteStack::create()->slicePrimitive("sphere", 8, 48, 48, "y", 0.f); + SpriteStack2D left; + SpriteStack2D right; + for (SpriteStack2D *stack : {&left, &right}) { + stack->setLayerCount(int(layers.size())); + for (int i = 0; i < int(layers.size()); ++i) stack->setLayerImage(fx.gfx, layers[size_t(i)], i); + stack->setSize(70.f, 70.f); + stack->setThickness(2.f); + } + left.setPosition(105.f, 145.f); + right.setPosition(215.f, 145.f); + right.setRotation(30.f); + SpriteStackBatch batch; + batch.add(&left); + batch.add(&right); + CHECK_EQ(batch.getStackCount(), 2); + for (int i = 0; i < 3; ++i) { + fx.gfx->clear(std::nullopt, std::nullopt, std::nullopt); + batch.render(fx.gfx); + fx.gfx->present(); + } + std::unique_ptr frame(fx.gfx->newImageData()); + savePng(frame.get(), testOutDir() + "/sprite_stack_2d_batch.png"); + for (ImageData *layer : layers) delete layer; }