This commit is contained in:
Gabrieal Jimmy
2026-10-02 12:42:00 -05:00
commit 2c99305fb0
145 changed files with 6525 additions and 0 deletions

398
demo/DemoScene.cpp Normal file
View File

@@ -0,0 +1,398 @@
#include "DemoScene.h"
#include "hd2d/Log.h"
#include <algorithm>
#include <cmath>
using namespace hd2d;
namespace {
constexpr int kW = 64, kD = 64;
constexpr float kWaterLevel = -0.2f;
constexpr float kPixelsPerUnit = 32.0f; // world texel density shared by all pixel art
enum Mat : uint8_t { Grass = 0, Meadow = 1, Path = 2, Sand = 3, Stone = 4, Ridge = 5 };
// Terrain texture array layers (see tools/assets.json for the sources).
const char* kLayers[] = {
"ground_grass_0", "ground_grass_1", "ground_grass_2", "ground_grass_3", // 0-3 plain grass
"ground_grass_4", "ground_grass_5", "ground_grass_8", // 4-6 flowers / clover
"ground_grass_12", "ground_grass_13", // 7-8 dark meadow grass
"ground_extra_1", // 9 earth path
"ground_extra_4", "ground_extra_5", // 10-11 earth cliff
"ground_misc_4", "ground_misc_5", // 12-13 mossy stone cliff
"ground_extra_8", "ground_extra_9", // 14-15 sand
"ground_misc_12", "ground_misc_13", // 16-17 cobblestone
};
float ridgeLow(float x) { return 16.0f + 2.5f * std::sin(x * 0.21f) + 1.5f * std::sin(x * 0.53f + 1.3f); }
float ridgeHigh(float x) { return 8.0f + 2.0f * std::sin(x * 0.17f + 2.0f) + 1.2f * std::sin(x * 0.61f); }
// Distance from a wobbly blob's center, normalized so the edge sits at `radius`.
float blob(glm::vec2 p, glm::vec2 c, float radius, float seed) {
glm::vec2 d = p - c;
float a = std::atan2(d.y, d.x);
float wobble = 1.0f + 0.16f * std::sin(a * 3.0f + seed) + 0.08f * std::sin(a * 5.0f + seed * 2.3f);
return glm::length(d) / (radius * wobble);
}
const glm::vec2 kPondCenter{22.0f, 36.0f};
const glm::vec2 kClearing{33.5f, 44.5f}; // kept free of trees and rocks
const glm::vec2 kStart{13.0f, 44.0f}; // player start: tall grass beside the pond
struct Patch {
glm::vec2 c;
float r;
};
const Patch kTallGrass[] = {{{12, 47}, 5.5f}, {{46, 28}, 4.2f}, {{29, 52}, 3.2f}, {{54, 52}, 4.5f}, {{20, 12}, 3.5f}};
// Stone bridge across the pond along +X. Must match BRIDGE_* and deck_z() in
// tools/blender_scenery.py.
const glm::vec2 kBridgeCenter = kPondCenter;
constexpr float kBridgeLen = 17.0f, kBridgeWidth = 2.6f, kBridgeRise = 0.7f, kBridgeWall = 0.28f;
float bridgeDeck(float localX) {
float u = std::clamp(2.0f * localX / kBridgeLen, -1.0f, 1.0f);
return 0.05f + kBridgeRise * (1.0f - u * u);
}
// Inside the corridor around the bridge (kept free of props), with a margin.
bool nearBridge(glm::vec2 p, float margin) {
glm::vec2 d = glm::abs(p - kBridgeCenter);
return d.x < kBridgeLen * 0.5f + margin && d.y < kBridgeWidth * 0.5f + margin;
}
bool onBridgeDeck(glm::vec2 p) {
glm::vec2 d = glm::abs(p - kBridgeCenter);
return d.x <= kBridgeLen * 0.5f + 0.2f && d.y < kBridgeWidth * 0.5f - kBridgeWall - 0.15f;
}
} // namespace
bool DemoScene::load(const std::string& assetDir) {
const std::string tex = assetDir + "/textures/";
std::vector<std::string> layers;
for (const char* l : kLayers) layers.push_back(tex + l + ".png");
if (!terrain_.textures.load(layers)) return false;
struct {
BillboardBatch* batch;
const char* file;
} sprites[] = {{&tallGrass_, "tall_grass.png"}, {&tufts_, "grass_tuft.png"}, {&flowers_, "flowers.png"}};
for (auto& s : sprites)
if (!s.batch->loadTexture(tex + s.file)) return false;
if (!player_.loadTexture(assetDir + "/sprites/test_sprite.png")) return false;
// 3D scenery (built by tools/blender_scenery.py).
if (!models_.load(assetDir + "/models/scenery.glb")) return false;
for (auto& [batch, name] : {std::pair{&oaks_[0], "oak_a"}, {&oaks_[1], "oak_b"}, {&pines_[0], "pine_a"},
{&pines_[1], "pine_b"}, {&bushes_[0], "bush_a"}, {&bushes_[1], "bush_b"},
{&rocks_[0], "rock_a"}, {&rocks_[1], "rock_b"}}) {
batch->model = models_.get(name);
if (!batch->model) return false;
}
bridge_.model = models_.get("bridge");
if (!bridge_.model) return false;
bridge_.instances = {{{kBridgeCenter.x, 0.0f, kBridgeCenter.y}, 0.0f, glm::vec3(1.0f), 1.0f}};
bridge_.upload();
// Fires burn in the braziers on the bridge's entrance pillars.
for (const auto& [name, local] : bridge_.model->sockets)
if (name.rfind("fire", 0) == 0) particles_.addFire(bridge_.instances[0].toWorld(local), 0.9f);
// Contact occlusion under scenery (tree bases, bushes, rocks).
for (ModelBatch& b : oaks_) b.groundOcclusion = 1.0f;
for (ModelBatch& b : pines_) b.groundOcclusion = 1.0f;
for (ModelBatch& b : bushes_) b.groundOcclusion = 0.8f;
for (ModelBatch& b : rocks_) b.groundOcclusion = 0.7f;
tufts_.inReflections = false; // tiny, never visible in the pond
flowers_.inReflections = false;
player_.emissive = 0.06f;
buildTerrain();
scatterProps();
std::vector<ModelBatch*> occluders;
for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_})
for (ModelBatch& b : *group) occluders.push_back(&b);
groundAO_.build({0.0f, 0.0f}, {float(kW), float(kD)}, occluders);
// Pond water plane, slightly larger than the basin.
water_.build(kPondCenter - glm::vec2(9.0f), kPondCenter + glm::vec2(9.0f), kWaterLevel);
water_.buildShoreField(terrain_);
particles_.addField(ParticleSystem::Kind::Firefly, {4, 0.2f, 26}, {58, 1.8f, 58}, 45);
particles_.addField(ParticleSystem::Kind::Mote, {6, 0.0f, 4}, {58, 7.0f, 60}, 650);
scene.terrain = &terrain_;
scene.water = &water_;
scene.particles = &particles_;
scene.billboards = {&tallGrass_, &tufts_, &flowers_, &player_};
scene.models.clear();
for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_})
for (ModelBatch& b : *group) scene.models.push_back(&b);
scene.models.push_back(&bridge_);
scene.groundOcclusion = &groundAO_;
scene.volumetricLights = int(particles_.fires().size());
scene.interactorRadius = 1.0f;
scene.blobShadowRadius = 0.55f;
player_.instances.resize(1);
placePlayer(kStart);
return true;
}
void DemoScene::buildTerrain() {
Terrain& t = terrain_;
t.init(kW, kD, 0.5f);
t.baseLevel = -8;
// topFirst, topCount, accentFirst, accentCount, accentChance, sideFirst, sideCount, lipLayer
t.materials[Grass] = {0, 4, 4, 3, 0.10f, 10, 2, 0};
t.materials[Meadow] = {7, 2, 0, 4, 0.15f, 10, 2, 7};
t.materials[Path] = {9, 1, 0, 0, 0.0f, 10, 2, -1};
t.materials[Sand] = {14, 2, 0, 0, 0.0f, 14, 2, -1};
t.materials[Stone] = {16, 2, 0, 0, 0.0f, 12, 2, -1};
t.materials[Ridge] = {0, 4, 7, 2, 0.2f, 12, 2, 0};
tallGrassMask_.assign(size_t(kW) * kD, 0);
for (int z = 0; z < kD; ++z)
for (int x = 0; x < kW; ++x) {
glm::vec2 p(float(x) + 0.5f, float(z) + 0.5f);
int level = 0;
uint8_t mat = Grass;
if (p.y < ridgeLow(p.x)) level = 2, mat = Ridge;
if (p.y < ridgeHigh(p.x)) level = 4, mat = Ridge;
float pond = blob(p, kPondCenter, 6.3f, 0.7f);
if (pond < 1.17f) mat = Sand; // beach ring
if (pond < 1.0f) level = -1; // shallow shelf
if (pond < 0.72f) level = -3; // deep basin
if (blob(p, {47.0f, 40.0f}, 3.6f, 2.1f) < 1.0f) level = 1; // small mound
if (level >= 0 && mat != Sand)
for (const Patch& tg : kTallGrass)
if (blob(p, tg.c, tg.r, tg.c.x) < 1.0f) {
mat = Meadow;
tallGrassMask_[size_t(z) * kW + x] = 1;
}
t.setLevel(x, z, level);
t.setMaterial(x, z, mat);
}
// Winding earth path from the south edge up to the ridge stairs.
const glm::vec2 path[] = {{33, 64}, {33, 56}, {36, 48}, {40, 40}, {41.5f, 31}, {40.5f, 24}, {40.5f, 2}};
for (size_t i = 0; i + 1 < std::size(path); ++i) {
glm::vec2 a = path[i], b = path[i + 1];
int steps = int(glm::length(b - a) * 4.0f);
for (int s = 0; s <= steps; ++s) {
glm::vec2 q = glm::mix(a, b, float(s) / float(std::max(steps, 1)));
for (int dz = -2; dz <= 2; ++dz)
for (int dx = -2; dx <= 2; ++dx) {
int x = int(std::floor(q.x)) + dx, z = int(std::floor(q.y)) + dz;
if (!t.inBounds(x, z) || t.level(x, z) < 0) continue;
if (glm::length(glm::vec2(float(x) + 0.5f, float(z) + 0.5f) - q) > 1.25f) continue;
t.setMaterial(x, z, Path);
tallGrassMask_[size_t(z) * kW + x] = 0;
}
}
}
// Cobblestone stairs where the path climbs each ridge (one level per step).
for (int x = 39; x <= 41; ++x) {
int zLow = int(std::ceil(ridgeLow(float(x) + 0.5f) - 0.5f)); // first level-0 tile south of ridge
int zHigh = int(std::ceil(ridgeHigh(float(x) + 0.5f) - 0.5f)); // first level-2 tile south of top
for (int z = zHigh; z < zLow; ++z) t.setMaterial(x, z, Path);
t.setLevel(x, zLow, 1);
t.setMaterial(x, zLow, Stone);
t.setLevel(x, zHigh, 3);
t.setMaterial(x, zHigh, Stone);
}
t.build();
}
bool DemoScene::occupied(glm::vec2 p, float r) const {
for (const Obstacle& o : obstacles_)
if (glm::length(o.pos - p) < o.radius + r) return true;
return false;
}
void DemoScene::scatterProps() {
std::uniform_real_distribution<float> u(0.0f, 1.0f);
const Terrain& t = terrain_;
auto tint = [&] { return 0.86f + 0.22f * u(rng_); };
auto groundOk = [&](int x, int z) {
uint8_t m = t.material(x, z);
return t.level(x, z) >= 0 && m != Path && m != Sand && m != Stone;
};
// Trees: dense forest band at the diorama border, sparse elsewhere.
for (int z = 0; z < kD; ++z)
for (int x = 0; x < kW; ++x) {
if (!groundOk(x, z) || tallGrassMask_[size_t(z) * kW + x]) continue;
int edge = std::min({x, z, kW - 1 - x, kD - 1 - z});
float chance = edge < 6 ? 0.45f - float(edge) * 0.05f : (t.level(x, z) >= 4 ? 0.14f : 0.012f);
if (u(rng_) > chance) continue;
glm::vec2 p(float(x) + 0.2f + 0.6f * u(rng_), float(z) + 0.2f + 0.6f * u(rng_));
if (glm::length(p - kClearing) < 5.0f || nearBridge(p, 2.5f) || occupied(p, 1.3f)) continue;
if (blob(p, kPondCenter, 6.3f, 0.7f) < 1.35f) continue;
bool pine = u(rng_) < (t.level(x, z) >= 2 || edge < 6 ? 0.55f : 0.25f);
auto& group = pine ? pines_ : oaks_;
float scale = (pine ? 0.75f : 0.8f) + 0.3f * u(rng_);
float squash = 0.92f + 0.16f * u(rng_); // breaks up silhouettes of repeated models
group[u(rng_) < 0.5f ? 0 : 1].instances.push_back(
{{p.x, t.heightAt(p.x, p.y), p.y}, u(rng_) * 6.2832f, glm::vec3(scale, scale * squash, scale), tint()});
obstacles_.push_back({p, 0.45f * scale});
}
// Bushes and rocks (rocks gather around the pond).
for (int z = 1; z < kD - 1; ++z)
for (int x = 1; x < kW - 1; ++x) {
glm::vec2 p(float(x) + 0.5f + 0.4f * (u(rng_) - 0.5f), float(z) + 0.5f + 0.4f * (u(rng_) - 0.5f));
if (t.level(x, z) < 0 || t.material(x, z) == Path || t.material(x, z) == Stone) continue;
if (glm::length(p - kClearing) < 3.0f || nearBridge(p, 1.0f) || occupied(p, 0.9f)) continue;
float pond = blob(p, kPondCenter, 6.3f, 0.7f);
bool nearPond = pond > 1.0f && pond < 1.3f;
float r = u(rng_);
if (r < (nearPond ? 0.12f : 0.008f)) {
float s = 0.8f + 0.5f * u(rng_);
rocks_[u(rng_) < 0.5f ? 0 : 1].instances.push_back(
{{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, u(rng_) * 6.2832f,
glm::vec3(s, s * (0.8f + 0.4f * u(rng_)), s), tint()});
obstacles_.push_back({p, 0.42f * s});
} else if (groundOk(x, z) && !tallGrassMask_[size_t(z) * kW + x] && r < (nearPond ? 0.17f : 0.03f)) {
float s = 0.8f + 0.45f * u(rng_);
bushes_[u(rng_) < 0.5f ? 0 : 1].instances.push_back(
{{p.x, t.heightAt(p.x, p.y) - 0.03f, p.y}, u(rng_) * 6.2832f, glm::vec3(s), tint()});
obstacles_.push_back({p, 0.5f * s});
}
}
// Pokemon-style tall grass: dense overlapping rows of swaying clumps.
for (int z = 0; z < kD; ++z)
for (int x = 0; x < kW; ++x) {
if (!tallGrassMask_[size_t(z) * kW + x]) continue;
for (int row = 0; row < 2; ++row)
for (int col = 0; col < 2; ++col) {
glm::vec2 p(float(x) + 0.25f + 0.5f * float(col) + 0.15f * (u(rng_) - 0.5f),
float(z) + 0.3f + 0.5f * float(row) + 0.1f * (u(rng_) - 0.5f));
if (nearBridge(p, 0.5f)) continue;
float s = 1.0f + 0.2f * u(rng_);
tallGrass_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.03f, p.y}, 1.0f,
glm::vec2(1.1f, 1.1f) * s, u(rng_) < 0.5f ? 1.0f : 0.0f,
tint()});
}
}
// Short grass tufts and flowers sprinkled across open grass.
for (int z = 0; z < kD; ++z)
for (int x = 0; x < kW; ++x) {
if (!groundOk(x, z) || tallGrassMask_[size_t(z) * kW + x]) continue;
// Clustered, darker tufts read as texture variation instead of stickers.
float cluster = 0.5f + 0.5f * std::sin(float(x) * 0.35f + 1.7f) * std::sin(float(z) * 0.29f + 0.4f);
int n = int(u(rng_) * (0.3f + 2.6f * cluster * cluster));
for (int i = 0; i < n; ++i) {
glm::vec2 p(float(x) + u(rng_), float(z) + u(rng_));
if (nearBridge(p, 0.3f)) continue;
float s = 0.6f + 0.45f * u(rng_);
tufts_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, 1.0f,
tufts_.sizeForDensity(kPixelsPerUnit) * s, u(rng_) < 0.5f ? 1.0f : 0.0f,
0.62f + 0.18f * u(rng_)});
}
if (u(rng_) < 0.07f) {
glm::vec2 p(float(x) + u(rng_), float(z) + u(rng_));
if (nearBridge(p, 0.3f)) continue;
flowers_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, 0.8f,
flowers_.sizeForDensity(kPixelsPerUnit) * (0.7f + 0.3f * u(rng_)),
u(rng_) < 0.5f ? 1.0f : 0.0f, 1.0f});
}
}
for (BillboardBatch* b : {&tallGrass_, &tufts_, &flowers_}) b->upload();
auto count = [](const std::array<ModelBatch, 2>& g) { return g[0].instances.size() + g[1].instances.size(); };
for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_})
for (ModelBatch& b : *group) b.upload();
HD2D_LOG("props: %zu oaks, %zu pines, %zu bushes, %zu rocks, %zu tall grass, %zu tufts, %zu flowers",
count(oaks_), count(pines_), count(bushes_), count(rocks_), tallGrass_.instances.size(),
tufts_.instances.size(), flowers_.instances.size());
}
bool DemoScene::walkable(glm::vec2 from, glm::vec2 to) const {
const Terrain& t = terrain_;
if (to.x < 1.0f || to.y < 1.0f || to.x > float(kW - 1) || to.y > float(kD - 1)) return false;
bool deckTo = onBridgeDeck(to), deckFrom = onBridgeDeck(from);
// Parapets and entrance pillars block the sides of the deck; the ends stay open.
glm::vec2 rel = glm::abs(to - kBridgeCenter);
if (!deckTo && nearBridge(to, 0.6f) && rel.x < kBridgeLen * 0.5f + 0.3f &&
rel.y >= kBridgeWidth * 0.5f - kBridgeWall - 0.15f)
return false;
if (!deckTo) {
int fl = t.level(int(std::floor(from.x)), int(std::floor(from.y)));
int tl = t.level(int(std::floor(to.x)), int(std::floor(to.y)));
if (tl < 0) return false; // pond
if (!deckFrom && (tl > fl + 1 || tl < fl - 2)) return false; // step up one level, drop at most two
}
for (const Obstacle& o : obstacles_) {
float r = o.radius + 0.28f;
if (glm::length(o.pos - to) < r && glm::length(o.pos - to) < glm::length(o.pos - from)) return false;
}
return true;
}
float DemoScene::groundHeight(glm::vec2 p) const {
return onBridgeDeck(p) ? bridgeDeck(p.x - kBridgeCenter.x) : terrain_.heightAt(p.x, p.y);
}
void DemoScene::placePlayer(glm::vec2 xz) { playerPos = glm::vec3(xz.x, groundHeight(xz), xz.y); }
void DemoScene::update(float dt, float time, glm::vec2 move, const Camera& camera) {
glm::vec3 right = camera.right();
glm::vec3 fwd(right.z, 0.0f, -right.x); // horizontal camera forward
glm::vec3 dir = right * move.x + fwd * move.y;
float amount = std::min(glm::length(dir), 1.0f);
bool moving = amount > 0.01f;
if (moving) {
dir = glm::normalize(dir) * amount;
glm::vec2 from(playerPos.x, playerPos.z);
glm::vec2 step = glm::vec2(dir.x, dir.z) * playerSpeed_ * dt;
// Axis-separated resolution lets the player slide along obstacles.
glm::vec2 p = from;
if (walkable(p, p + glm::vec2(step.x, 0))) p.x += step.x;
if (walkable(p, p + glm::vec2(0, step.y))) p.y += step.y;
playerPos.x = p.x;
playerPos.z = p.y;
if (std::abs(move.x) > 0.1f) facing_ = move.x > 0.0f ? 1.0f : 0.0f; // sprite looks left by default
walkPhase_ += dt * 13.0f;
} else {
walkPhase_ = 0.0f;
}
float ground = groundHeight({playerPos.x, playerPos.z});
playerPos.y += (ground - playerPos.y) * std::min(1.0f, dt * 14.0f);
int tx = int(std::floor(playerPos.x)), tz = int(std::floor(playerPos.z));
bool inTallGrass = terrain_.inBounds(tx, tz) && tallGrassMask_[size_t(tz) * kW + tx];
stepTimer_ -= dt;
if (moving && stepTimer_ <= 0.0f) {
stepTimer_ = inTallGrass ? 0.12f : 0.25f;
glm::vec3 feet(playerPos.x, ground + 0.05f, playerPos.z);
if (inTallGrass) particles_.burst(feet + glm::vec3(0, 0.3f, 0), 3, {0.20f, 0.42f, 0.10f}, 1.6f, 0.05f, 0.7f);
else particles_.burst(feet, 2, {0.32f, 0.27f, 0.20f}, 0.7f, 0.06f, 0.5f);
}
float hop = moving ? std::abs(std::sin(walkPhase_)) * 0.09f : 0.0f;
BillboardInstance& pi = player_.instances[0];
pi.pos = glm::vec3(playerPos.x, playerPos.y + hop - 0.02f, playerPos.z);
pi.size = player_.sizeForDensity(4.0f * kPixelsPerUnit); // art is 4x upscaled
pi.flip = facing_;
pi.tint = 1.0f;
player_.upload();
particles_.update(dt, time);
scene.lights.clear();
particles_.collectLights(scene.lights, 12); // fires first, then fireflies
scene.interactor = playerPos;
scene.blobShadowPos = glm::vec3(playerPos.x, ground, playerPos.z);
scene.focusPoint = playerPos + glm::vec3(0, 0.6f, 0);
}