#include "DemoScene.h" #include "hd2d/Log.h" #include #include 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 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_.occluderFade = false; // low; the camera sees over it 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; for (ModelBatch& b : rocks_) b.occluderFade = false; for (ModelBatch& b : bushes_) b.occluderFade = false; tufts_.inReflections = false; // tiny, never visible in the pond flowers_.inReflections = false; player_.emissive = 0.06f; buildTerrain(); scatterProps(); std::vector 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 !spriteTest || buildSpriteTest(assetDir); } namespace { // Lowest row (from the bottom) with alpha >= 128, or 0 if none. int lowestOpaqueRow(const Image& img) { for (int y = 0; y < img.height; ++y) for (int x = 0; x < img.width; ++x) if (img.rgba[(size_t(y) * img.width + x) * 4 + 3] >= 128) return y; return 0; } // Copies `src` into `dst` with its bottom-left pixel at (x, y). void blit(Image& dst, const Image& src, int x, int y) { for (int r = 0; r < src.height; ++r) std::copy_n(&src.rgba[size_t(r) * src.width * 4], size_t(src.width) * 4, &dst.rgba[(size_t(y + r) * dst.width + x) * 4]); } } // namespace bool DemoScene::buildSpriteTest(const std::string& assetDir) { Image big, flowers, tuft; if (!loadImage(assetDir + "/sprites/test_sprite.png", big) || !loadImage(assetDir + "/textures/flowers.png", flowers) || !loadImage(assetDir + "/textures/grass_tuft.png", tuft)) return false; // The test sprite is 4x upscaled 64 px art: take one pixel per 4x4 block. Image hero; hero.width = big.width / 4; hero.height = big.height / 4; hero.rgba.resize(size_t(hero.width) * hero.height * 4); for (int y = 0; y < hero.height; ++y) for (int x = 0; x < hero.width; ++x) std::copy_n(&big.rgba[((size_t(y) * 4 + 2) * big.width + x * 4 + 2) * 4], 4, &hero.rgba[(size_t(y) * hero.width + x) * 4]); // Runtime atlas: add, then exercise remove/re-add so slots get reused. if (!atlas_.init({64, 64}, 4, 2)) return false; atlasSlots_[0] = atlas_.add(hero); int scratch = atlas_.add(big); // cropped to the cell atlas_.remove(scratch); atlasSlots_[1] = atlas_.add(flowers); // reuses the freed slot atlasSlots_[2] = atlas_.add(tuft); HD2D_LOG("sprite test atlas: %d/%d slots used", atlas_.used(), atlas_.capacity()); atlasSprites_.sharedTexture = &atlas_.texture(); atlasSprites_.instances.resize(6); // 2x1 sprite sheet (authored top-down, one row): hero | flowers. Image sheet; sheet.width = 128; sheet.height = 64; sheet.rgba.assign(size_t(sheet.width) * sheet.height * 4, 0); blit(sheet, hero, 0, 0); blit(sheet, flowers, 64 + (64 - flowers.width) / 2, 0); bleedTransparentColors(sheet); if (!sheetSprites_.texture.create(sheet)) return false; sheetFeetRows_[0] = 63 - lowestOpaqueRow(hero); sheetFeetRows_[1] = 63 - lowestOpaqueRow(flowers); sheetSprites_.instances.resize(1); atlasSprites_.emissive = sheetSprites_.emissive = player_.emissive; scene.billboards.push_back(&atlasSprites_); scene.billboards.push_back(&sheetSprites_); return true; } void DemoScene::updateSpriteTest(float time, const Camera& camera) { glm::vec3 right = camera.right(); glm::vec3 fwd(right.z, 0.0f, -right.x); // horizontal camera forward const glm::vec2 size = BillboardBatch::cellSize(atlas_.cellPixels(), kPixelsPerUnit); labels.clear(); auto place = [&](BillboardInstance& bi, int index, const char* label) { glm::vec3 p = playerPos - fwd * 1.8f + right * (float(index) - 3.0f); // row in front of the player bi = BillboardInstance{}; bi.pos = glm::vec3(p.x, groundHeight({p.x, p.z}), p.z); bi.size = size; labels.push_back({bi.pos + glm::vec3(0, size.y + 0.15f, 0), label}); }; auto useSlot = [&](BillboardInstance& bi, int slot) { bi.uv = atlas_.uv(slot); bi.anchor = atlas_.anchor(slot); }; auto& a = atlasSprites_.instances; const char* names[] = {"atlas", "flip", "turn", "alpha", "flash", "frames"}; for (int i = 0; i < 6; ++i) { place(a[i], i, names[i]); useSlot(a[i], atlasSlots_[0]); } a[1].flip = 1.0f; a[2].turn = 0.8f + 0.4f * std::sin(time * 1.5f); a[3].alpha = 0.5f + 0.3f * std::sin(time * 2.0f); a[4].flash = 0.5f + 0.5f * std::sin(time * 5.0f); useSlot(a[5], atlasSlots_[int(time / 0.4f) % 3]); atlasSprites_.upload(); BillboardInstance& s = sheetSprites_.instances[0]; place(s, 6, "sheet"); int frame = int(time / 0.5f) % 2; glm::ivec2 texSize = sheetSprites_.texture.size(); s.uv = Texture::cellUV(texSize, {64, 64}, frame, 0); s.anchor = Texture::cellAnchor(texSize, {64, 64}, 0, sheetFeetRows_[frame]); sheetSprites_.upload(); } 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 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& 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); if (spriteTest) updateSpriteTest(time, camera); }