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

View File

@@ -0,0 +1,49 @@
#pragma once
#include "hd2d/GL.h"
#include "hd2d/Texture.h"
#include <glm/glm.hpp>
#include <vector>
namespace hd2d {
// One upright pixel-art sprite. `pos` is where the lowest opaque pixel row
// touches the ground; sprites always face the camera around the Y axis.
struct BillboardInstance {
glm::vec3 pos{0.0f};
float sway = 0.0f; // wind / player-push response (0 = rigid)
glm::vec2 size{1.0f}; // world units of the full texture quad
float flip = 0.0f; // 1 = mirror horizontally
float tint = 1.0f; // brightness multiplier (breaks up repetition)
};
// Instanced batch of billboards sharing one texture.
class BillboardBatch {
public:
BillboardBatch() = default;
BillboardBatch(const BillboardBatch&) = delete;
BillboardBatch& operator=(const BillboardBatch&) = delete;
~BillboardBatch();
bool loadTexture(const std::string& path) { return texture.load(path); }
void upload(); // call after editing `instances`
void draw() const;
// World size that keeps the texture at `pixelsPerUnit` texel density.
glm::vec2 sizeForDensity(float pixelsPerUnit) const {
return glm::vec2(texture.size()) / pixelsPerUnit;
}
Texture texture;
std::vector<BillboardInstance> instances;
bool castShadows = true;
bool inReflections = true;
float emissive = 0.0f; // unlit contribution, for glowing sprites
private:
GLuint vao_ = 0, vbo_ = 0;
size_t capacity_ = 0;
GLsizei count_ = 0;
};
} // namespace hd2d

View File

@@ -0,0 +1,24 @@
#pragma once
#include <glm/glm.hpp>
namespace hd2d {
// Orbit camera looking at `target`. HD-2D scenes use a high, narrow-FOV
// (near telephoto) camera tilted down so the world reads like a diorama.
struct Camera {
glm::vec3 target{0.0f};
float yaw = 0.0f; // radians; 0 looks toward -Z
float pitch = 0.56f; // radians below horizon
float distance = 17.0f;
float fovY = 0.52f; // radians (~30 deg)
float nearPlane = 0.5f;
float farPlane = 250.0f;
glm::vec3 forward() const; // unit vector camera -> target
glm::vec3 position() const { return target - forward() * distance; }
glm::vec3 right() const; // horizontal right vector
glm::mat4 view() const;
glm::mat4 projection(float aspect) const;
};
} // namespace hd2d

View File

@@ -0,0 +1,29 @@
#pragma once
namespace hd2d {
class Window;
struct RenderSettings;
struct Scene;
class Weather;
// Dear ImGui integration plus a ready-made tuning panel for the renderer.
class DebugUI {
public:
bool init(Window& window);
void shutdown();
void beginFrame();
void endFrame();
bool wantsMouse() const;
bool wantsKeyboard() const;
// Tweak panel for every look parameter (sun, sky, fog, AO, water, post).
static void renderSettingsPanel(RenderSettings& settings, Scene& scene, float gpuMs);
// Rain amount, transition time and the storm preset.
static void weatherPanel(Weather& weather);
private:
bool initialized_ = false;
};
} // namespace hd2d

4
engine/include/hd2d/GL.h Normal file
View File

@@ -0,0 +1,4 @@
#pragma once
// Single include point for OpenGL. libepoxy resolves entry points for both
// GLX (X11) and EGL (Wayland) contexts, so no loader init call is needed.
#include <epoxy/gl.h>

View File

@@ -0,0 +1,35 @@
#pragma once
#include "hd2d/GL.h"
#include <glm/glm.hpp>
#include <vector>
namespace hd2d {
class ModelBatch;
// Baked contact ambient occlusion on the ground under 3D scenery: a dark,
// tight core at a tree trunk / bush / rock base plus a wide, soft canopy
// shadow under tall models. Sampled by terrain, sprite and model shaders, so
// grass tufts and bushes growing at a tree's foot darken too. Rebuild after
// moving instances.
class GroundOcclusion {
public:
GroundOcclusion() = default;
GroundOcclusion(const GroundOcclusion&) = delete;
GroundOcclusion& operator=(const GroundOcclusion&) = delete;
~GroundOcclusion();
// Covers the rectangle [min, max] (world xz). Uses every batch whose
// `groundOcclusion` strength is > 0.
void build(glm::vec2 min, glm::vec2 max, const std::vector<ModelBatch*>& batches, int texelsPerUnit = 8);
GLuint texture() const { return tex_; }
glm::vec4 rect() const { return rect_; } // matches uGroundAO in common.glsl
private:
GLuint tex_ = 0;
glm::vec4 rect_{0.0f};
};
} // namespace hd2d

View File

@@ -0,0 +1,5 @@
#pragma once
#include <cstdio>
#define HD2D_LOG(fmt, ...) std::fprintf(stdout, "[hd2d] " fmt "\n" __VA_OPT__(, ) __VA_ARGS__)
#define HD2D_ERR(fmt, ...) std::fprintf(stderr, "[hd2d:error] " fmt "\n" __VA_OPT__(, ) __VA_ARGS__)

106
engine/include/hd2d/Model.h Normal file
View File

@@ -0,0 +1,106 @@
#pragma once
#include "hd2d/GL.h"
#include "hd2d/Texture.h"
#include <glm/glm.hpp>
#include <cmath>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
namespace hd2d {
// Material of a glTF primitive. Materials whose name starts with "foliage"
// get alpha testing, two-sided wrap lighting and sun translucency.
struct ModelMaterial {
std::string name;
const Texture* texture = nullptr; // base color (sRGB); null = white
glm::vec4 baseColor{1.0f};
bool alphaMask = false;
float alphaCutoff = 0.5f;
bool doubleSided = false;
bool foliage = false;
glm::vec3 emissive{0.0f}; // linear HDR (factor x strength)
const Texture* emissiveTexture = nullptr; // multiplies `emissive`; null = white
};
struct ModelPrimitive {
GLuint vbo = 0, ibo = 0;
GLsizei indexCount = 0;
const ModelMaterial* material = nullptr; // owned by the ModelLibrary
};
// Static mesh made of primitives (one per material). Vertex layout: position,
// normal, uv, color (rgb = tint/AO, a = wind weight). Built from one root
// node of a glTF file with the node's world transform baked in.
struct Model {
std::string name;
std::vector<ModelPrimitive> primitives;
glm::vec3 boundsMin{0.0f}, boundsMax{0.0f};
// Named attachment points from child empties of the glTF node (model space),
// e.g. "fire_0" on the bridge braziers.
std::unordered_map<std::string, glm::vec3> sockets;
};
// Loads a .glb/.gltf once (textures shared by all its models) and exposes each
// root node with a mesh as a named Model.
class ModelLibrary {
public:
ModelLibrary() = default;
ModelLibrary(const ModelLibrary&) = delete;
ModelLibrary& operator=(const ModelLibrary&) = delete;
~ModelLibrary();
bool load(const std::string& path);
const Model* get(const std::string& name) const;
private:
std::vector<std::unique_ptr<Texture>> textures_;
std::vector<std::unique_ptr<ModelMaterial>> materials_;
std::unordered_map<std::string, std::unique_ptr<Model>> models_;
};
// Per-instance placement: rotation about +Y, non-uniform scale, brightness tint.
struct ModelInstance {
glm::vec3 pos{0.0f};
float yaw = 0.0f;
glm::vec3 scale{1.0f};
float tint = 1.0f;
glm::vec3 toWorld(const glm::vec3& local) const { // matches mesh.vert
glm::vec3 p = local * scale;
float c = std::cos(yaw), s = std::sin(yaw);
return pos + glm::vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z);
}
};
// Instanced draw of one model.
class ModelBatch {
public:
explicit ModelBatch(const Model* model = nullptr) : model(model) {}
ModelBatch(const ModelBatch&) = delete;
ModelBatch& operator=(const ModelBatch&) = delete;
~ModelBatch();
void upload(); // call after editing `instances`
// Draws primitive `i` (the renderer binds its material first).
void drawPrimitive(size_t i) const;
const Model* model = nullptr;
std::vector<ModelInstance> instances;
bool castShadows = true;
bool inReflections = true;
float windScale = 1.0f; // multiplies the per-vertex wind weight
// Strength of the contact shadow baked into the ground under each instance
// (see GroundOcclusion); 0 disables. Its size follows the model's footprint.
float groundOcclusion = 0.0f;
private:
std::vector<GLuint> vaos_;
GLuint instanceVbo_ = 0;
size_t capacity_ = 0;
GLsizei count_ = 0;
};
} // namespace hd2d

View File

@@ -0,0 +1,85 @@
#pragma once
#include "hd2d/GL.h"
#include <glm/glm.hpp>
#include <random>
#include <vector>
namespace hd2d {
struct PointLight;
// High-resolution (non pixel-art) particles: ambient fields (fireflies, dust),
// fire emitters (flames, embers, smoke, glow + flickering light) and bursts.
class ParticleSystem {
public:
// Must match the kind ids in particles.vert / particles.frag.
enum class Kind : int {
Firefly = 0, // blinking emissive glow, wanders near the ground
Mote = 1, // dust speck only visible where sunlight hits it (god rays)
Spark = 2, // ballistic burst particle (footstep dust, rain splashes, ...)
Flame = 3, // additive tongue of fire, eroded by noise as it ages
Ember = 4, // tiny hot spark drifting up on turbulent air
Smoke = 5, // alpha-blended puff rising above a fire
Glow = 6, // large soft halo around a fire (one per emitter per frame)
};
struct Field { // region that keeps a constant population of ambient particles
Kind kind;
glm::vec3 min, max;
int count;
};
struct Fire {
glm::vec3 pos; // base of the flames
float scale = 1.0f; // overall size / intensity
float flameAccum = 0.0f, emberAccum = 0.0f, smokeAccum = 0.0f;
float flicker = 1.0f; // current light flicker multiplier
float phase = 0.0f;
};
~ParticleSystem();
void addField(Kind kind, glm::vec3 min, glm::vec3 max, int count);
int addFire(glm::vec3 pos, float scale = 1.0f);
void burst(glm::vec3 pos, int count, glm::vec3 color, float speed, float size, float life);
void update(float dt, float time);
void draw() const;
// Appends fire lights first, then up to `maxFireflies` firefly lights.
void collectLights(std::vector<PointLight>& out, int maxFireflies) const;
const std::vector<Fire>& fires() const { return fires_; }
glm::vec3 fireflyColor{2.6f, 3.2f, 0.9f};
glm::vec3 moteColor{1.0f, 0.95f, 0.8f};
float fireflyLightRadius = 2.2f;
glm::vec3 fireLightColor{4.0f, 1.75f, 0.55f};
float fireLightRadius = 7.0f;
glm::vec3 wind{0.0f}; // world-space drift applied to smoke and embers
float ambientFade = 1.0f; // scales fireflies + motes (Weather lowers it in rain)
private:
struct Particle {
glm::vec3 pos, vel;
glm::vec3 color;
float size, age, life, phase;
Kind kind;
int field; // ambient field index, or -1
int fire; // owning fire, or -1
};
struct GpuParticle {
glm::vec4 posSize;
glm::vec4 color; // rgb, a = alpha
glm::vec4 params; // x = kind, y = age / life, z = random seed
};
void spawnInField(Particle& p, int fieldIndex, bool randomAge);
void spawnFire(const Fire& f, int fireIndex, Kind kind);
std::vector<Field> fields_;
std::vector<Fire> fires_;
std::vector<Particle> particles_;
std::vector<GpuParticle> gpu_;
std::mt19937 rng_{1337};
GLuint vao_ = 0, vbo_ = 0;
size_t capacity_ = 0;
};
} // namespace hd2d

View File

@@ -0,0 +1,40 @@
#pragma once
#include "hd2d/GL.h"
#include <initializer_list>
#include <vector>
namespace hd2d {
// Creates an immutable 2D texture with clamp-to-edge sampling.
GLuint createTexture2D(GLenum internalFormat, int w, int h, GLenum filter = GL_LINEAR, int levels = 1);
// Framebuffer owning its color attachments. Depth can be owned (depthFormat)
// or borrowed from another target (sharedDepth) so passes can depth-test
// against an earlier pass without copying.
class RenderTarget {
public:
RenderTarget() = default;
RenderTarget(const RenderTarget&) = delete;
RenderTarget& operator=(const RenderTarget&) = delete;
~RenderTarget() { destroy(); }
void create(int w, int h, std::initializer_list<GLenum> colorFormats, GLenum depthFormat = 0,
GLuint sharedDepth = 0, GLenum filter = GL_LINEAR);
void destroy();
void bind() const; // binds FBO and sets viewport
GLuint fbo() const { return fbo_; }
GLuint color(int i = 0) const { return colors_[i]; }
GLuint depth() const { return depth_; }
int width() const { return w_; }
int height() const { return h_; }
private:
GLuint fbo_ = 0, depth_ = 0;
bool ownsDepth_ = false;
std::vector<GLuint> colors_;
int w_ = 0, h_ = 0;
};
} // namespace hd2d

View File

@@ -0,0 +1,100 @@
#pragma once
#include "hd2d/Camera.h"
#include "hd2d/RenderTarget.h"
#include "hd2d/Scene.h"
#include "hd2d/Shader.h"
#include <glm/glm.hpp>
#include <memory>
#include <string>
namespace hd2d {
// All tunable look parameters of the HD-2D pipeline.
struct RenderSettings {
// Shadows
int shadowMapSize = 4096;
float shadowExtent = 34.0f; // half-size of the sun's ortho frustum, world units
float shadowSoftness = 1.5f; // PCF radius in texels
// Dappled canopy light ("cookie" projected along the sun direction)
float cookieStrength = 0.55f;
float cookieScale = 0.13f;
float cookieSpeed = 0.05f;
float cookieCoverage = 0.48f; // fraction of light blocked
// God ray beams: the same implied canopy, sparser, applied inside the fog
float shaftStrength = 0.9f; // 1 = sun scatters only inside beams
float shaftCoverage = 0.62f; // higher = fewer, narrower beams
float shaftScale = 0.45f; // beam pattern frequency (larger = thinner beams)
// Volumetric fog + god rays
bool volumetrics = true;
glm::vec3 fogColor{0.30f, 0.36f, 0.45f}; // ambient fog in-scatter tint
float fogDensity = 0.008f;
float fogHeightFalloff = 0.15f;
float fogBaseHeight = 0.0f;
float fogNoise = 0.6f;
float fogAnisotropy = 0.62f; // Henyey-Greenstein g (forward scattering)
float godRayIntensity = 4.5f;
float fogMaxDistance = 90.0f;
int fogSteps = 56;
// Wind for foliage
glm::vec2 windDirection{0.8f, 0.35f};
float windStrength = 0.06f;
float windGustSpeed = 1.2f;
// Depth of field (tilt-shift miniature look)
bool dof = true;
float focusRange = 6.0f; // fully sharp half-width around the focus distance
float focusTransition = 12.0f; // distance over which blur ramps up
float dofMaxRadius = 9.0f; // max blur radius in half-res pixels
float tiltShift = 0.35f; // extra blur toward screen top/bottom
// Bloom
bool bloom = true;
float bloomStrength = 0.075f;
float bloomRadius = 1.0f;
// Color grading
float exposure = 1.0f;
float contrast = 1.08f;
float saturation = 1.12f;
glm::vec3 lift{0.012f, 0.010f, 0.025f}; // added to shadows
glm::vec3 gain{1.04f, 1.00f, 0.93f}; // multiplied into highlights
float vignette = 0.42f;
float chromaticAberration = 0.0015f;
float grain = 0.025f;
bool reflections = true;
bool occluderFade = true; // dither away 3D scenery between the camera and the player
// Screen-space ambient occlusion (contact darkening everywhere; the baked
// GroundOcclusion handles the large soft shade under trees)
bool ssao = true;
bool groundOcclusion = true; // use Scene::groundOcclusion (baked contact AO)
float ssaoRadius = 0.7f; // world units
float ssaoIntensity = 1.6f;
int maxRainDrops = 18000; // streaks drawn at Scene::rain = 1
};
class Renderer {
public:
Renderer();
~Renderer();
bool init(const std::string& shaderDir, int width, int height);
void resize(int width, int height);
bool reloadShaders();
void render(const Scene& scene, const Camera& camera, float time);
RenderSettings settings;
float gpuFrameMs() const { return gpuMs_; }
private:
struct Impl;
std::unique_ptr<Impl> impl_;
float gpuMs_ = 0.0f;
};
} // namespace hd2d

View File

@@ -0,0 +1,60 @@
#pragma once
#include "hd2d/Billboard.h"
#include "hd2d/GroundOcclusion.h"
#include "hd2d/Model.h"
#include "hd2d/Particles.h"
#include "hd2d/Terrain.h"
#include "hd2d/Water.h"
#include <glm/glm.hpp>
#include <vector>
namespace hd2d {
struct PointLight {
glm::vec3 position{0.0f};
float radius = 3.0f;
glm::vec3 color{1.0f}; // linear HDR
};
struct Sun {
float azimuth = -0.6f; // radians, rotation around Y (0 = toward -Z, the default view direction)
float elevation = 0.66f; // radians above horizon
glm::vec3 color{1.0f, 0.86f, 0.66f};
float intensity = 3.2f;
glm::vec3 direction() const; // unit vector pointing toward the sun
};
struct Sky {
glm::vec3 zenith{0.22f, 0.42f, 0.78f};
glm::vec3 horizon{0.70f, 0.76f, 0.80f};
glm::vec3 ground{0.20f, 0.24f, 0.14f}; // bounce light from below
float ambient = 0.55f;
float fill = 0.35f; // light from the camera side onto faces turned toward the viewer
};
// Everything the renderer draws. Pointers are non-owning; null entries skip.
struct Scene {
Terrain* terrain = nullptr;
Water* water = nullptr;
ParticleSystem* particles = nullptr;
std::vector<BillboardBatch*> billboards; // pixel-art sprites (characters, small props)
std::vector<ModelBatch*> models; // 3D scenery (trees, bushes, rocks, bridge)
const GroundOcclusion* groundOcclusion = nullptr; // baked contact AO under scenery
std::vector<PointLight> lights;
int volumetricLights = 0; // the first N lights also scatter in the fog (e.g. fires)
Sun sun;
Sky sky;
glm::vec3 focusPoint{0.0f}; // depth-of-field focus (usually the player)
glm::vec3 interactor{0.0f}; // pushes foliage aside (usually the player)
float interactorRadius = 1.1f;
glm::vec3 blobShadowPos{0.0f}; // soft contact shadow under the player
float blobShadowRadius = 0.0f; // 0 disables
// Precipitation, usually driven by Weather.
float rain = 0.0f; // 0..1: rain streaks, water ripples, overcast sky
float wetness = 0.0f; // 0..1: darker, glossy surfaces (lags behind `rain`)
float rainSpeed = 15.0f; // drop fall speed (world units / s)
};
} // namespace hd2d

View File

@@ -0,0 +1,9 @@
#pragma once
#include <string>
namespace hd2d {
// Reads the current backbuffer (before swap) and writes it as PNG.
bool saveScreenshot(const std::string& path, int width, int height);
} // namespace hd2d

View File

@@ -0,0 +1,42 @@
#pragma once
#include "hd2d/GL.h"
#include <glm/glm.hpp>
#include <string>
#include <unordered_map>
namespace hd2d {
// GLSL program loaded from files. Supports `#include "file.glsl"` (resolved
// relative to the including file) and hot reload: a failed reload keeps the
// previous working program.
class Shader {
public:
Shader() = default;
Shader(const Shader&) = delete;
Shader& operator=(const Shader&) = delete;
~Shader();
bool load(const std::string& vertPath, const std::string& fragPath);
bool reload();
void use() const { glUseProgram(program_); }
GLuint id() const { return program_; }
void set(const char* name, int v);
void set(const char* name, float v);
void set(const char* name, const glm::vec2& v);
void set(const char* name, const glm::vec3& v);
void set(const char* name, const glm::vec4& v);
void set(const char* name, const glm::ivec2& v);
void set(const char* name, const glm::ivec4& v);
void set(const char* name, const glm::mat4& v);
private:
GLint location(const char* name);
GLuint program_ = 0;
std::string vertPath_, fragPath_;
std::unordered_map<std::string, GLint> locations_;
};
} // namespace hd2d

View File

@@ -0,0 +1,61 @@
#pragma once
#include "hd2d/GL.h"
#include "hd2d/Texture.h"
#include <array>
#include <cstdint>
#include <glm/glm.hpp>
#include <vector>
namespace hd2d {
// How a material id maps onto layers of the terrain texture array.
struct TerrainMaterial {
int topFirst = 0, topCount = 1; // layers picked per tile for top faces
int accentFirst = 0, accentCount = 0; // occasional variation layers
float accentChance = 0.0f; // probability a tile uses an accent layer
int sideFirst = 0, sideCount = 1; // layers for cliff faces below this tile
int lipLayer = -1; // layer for an overhang strip atop cliff faces (-1 = none)
};
// Tile-based diorama terrain: every tile has an integer height level and a
// material. Top faces are drawn with organically jittered material borders
// at pixel granularity; height differences produce textured cliff faces.
class Terrain {
public:
static constexpr int kMaxMaterials = 8;
void init(int width, int depth, float levelHeight = 0.5f);
int width() const { return w_; }
int depth() const { return d_; }
float levelHeight() const { return levelHeight_; }
bool inBounds(int x, int z) const { return x >= 0 && z >= 0 && x < w_ && z < d_; }
int level(int x, int z) const;
uint8_t material(int x, int z) const;
void setLevel(int x, int z, int level);
void setMaterial(int x, int z, uint8_t mat);
float heightAt(float x, float z) const; // top surface height of tile under (x, z)
// Cliff faces at the map border extend down to this level (diorama base).
int baseLevel = -6;
std::array<TerrainMaterial, kMaxMaterials> materials{};
// (Re)builds GPU mesh + material map after edits.
void build();
void draw() const;
void bindMaterialMap(GLuint unit) const { glBindTextureUnit(unit, matTex_); }
TextureArray textures; // pixel-art material layers, all same size
private:
int w_ = 0, d_ = 0;
float levelHeight_ = 0.5f;
std::vector<int> levels_;
std::vector<uint8_t> mats_;
GLuint vao_ = 0, vbo_ = 0, ibo_ = 0, matTex_ = 0;
GLsizei indexCount_ = 0;
};
} // namespace hd2d

View File

@@ -0,0 +1,68 @@
#pragma once
#include "hd2d/GL.h"
#include <glm/glm.hpp>
#include <string>
#include <vector>
namespace hd2d {
struct Image {
int width = 0, height = 0;
std::vector<unsigned char> rgba; // row 0 = bottom (GL convention)
};
// Loads an RGBA8 image flipped to GL orientation. Colors of fully transparent
// pixels are replaced with their nearest opaque neighbour so that filtering
// never bleeds dark fringes into sprite edges.
bool loadImage(const std::string& path, Image& out);
// Decodes PNG bytes from memory. glTF images use flipY = false (their UV
// origin is top-left, which matches GL when rows are uploaded unflipped).
bool decodeImage(const unsigned char* data, size_t size, Image& out, bool flipY);
// sRGB pixel-art texture. Sampled with "sharp bilinear" in shaders, so it uses
// linear filtering with mipmaps rather than GL_NEAREST.
class Texture {
public:
Texture() = default;
Texture(const Texture&) = delete;
Texture& operator=(const Texture&) = delete;
Texture(Texture&& o) noexcept { *this = std::move(o); }
Texture& operator=(Texture&& o) noexcept;
~Texture();
bool load(const std::string& path, bool repeat = false);
bool create(const Image& image, bool repeat = false, float anisotropy = 1.0f);
void bind(GLuint unit) const { glBindTextureUnit(unit, id_); }
GLuint id() const { return id_; }
glm::ivec2 size() const { return {width_, height_}; }
// UV-space rectangle (minX, minY, maxX, maxY) of pixels with alpha >= 128.
// Billboards use minY to plant the visible feet on the ground.
glm::vec4 opaqueBounds() const { return opaqueBounds_; }
private:
GLuint id_ = 0;
int width_ = 0, height_ = 0;
glm::vec4 opaqueBounds_{0, 0, 1, 1};
};
// 2D array of equally sized sRGB layers (terrain materials).
class TextureArray {
public:
TextureArray() = default;
TextureArray(const TextureArray&) = delete;
TextureArray& operator=(const TextureArray&) = delete;
~TextureArray();
bool load(const std::vector<std::string>& paths);
void bind(GLuint unit) const { glBindTextureUnit(unit, id_); }
glm::ivec2 size() const { return {width_, height_}; }
int layers() const { return layers_; }
private:
GLuint id_ = 0;
int width_ = 0, height_ = 0, layers_ = 0;
};
} // namespace hd2d

View File

@@ -0,0 +1,50 @@
#pragma once
#include "hd2d/GL.h"
#include <glm/glm.hpp>
namespace hd2d {
class Terrain;
// Planar water body: reflective, refractive, depth-absorbing surface with
// procedural waves, sun glints and shoreline foam. Rendered at full
// resolution (not pixelated) on purpose, like HD-2D's water.
class Water {
public:
~Water();
// Builds a tessellated grid covering [minXZ, maxXZ] at height `level`.
void build(glm::vec2 minXZ, glm::vec2 maxXZ, float level, float cellSize = 0.25f);
// Distance-to-shore field (world units) over the water bounds, from every
// terrain tile whose top is above the water. Drives foam and ripples.
void buildShoreField(const Terrain& terrain, int texelsPerUnit = 8);
void draw() const;
float level() const { return level_; }
GLuint shoreTexture() const { return shoreTex_; }
// (minX, minZ, 1/sizeX, 1/sizeZ): maps world xz to shore texture uv.
glm::vec4 shoreRect() const { return {min_.x, min_.y, 1.0f / (max_.x - min_.x), 1.0f / (max_.y - min_.y)}; }
// Look parameters (tweakable at runtime).
glm::vec3 shallowColor{0.03f, 0.40f, 0.62f};
glm::vec3 deepColor{0.005f, 0.10f, 0.30f};
glm::vec3 absorption{1.6f, 0.5f, 0.22f}; // per unit of light path, per channel
float clarity = 0.5f; // how much of the bottom stays visible
float waveScale = 0.9f;
float waveStrength = 1.2f;
float waveSpeed = 0.35f;
float refraction = 0.035f;
float reflectionDistort = 0.05f;
float specular = 6.0f;
float glitter = 1.0f; // twinkling point sparkles
float foamWidth = 0.45f; // world units from the shore
float foamIntensity = 0.85f;
float causticsIntensity = 1.4f;
private:
GLuint vao_ = 0, vbo_ = 0, ibo_ = 0, shoreTex_ = 0;
GLsizei indexCount_ = 0;
float level_ = 0.0f;
glm::vec2 min_{0.0f}, max_{1.0f};
};
} // namespace hd2d

View File

@@ -0,0 +1,54 @@
#pragma once
#include "hd2d/Scene.h"
#include <glm/glm.hpp>
#include <random>
namespace hd2d {
struct RenderSettings;
// Everything the weather changes about the look, so it can be blended.
struct WeatherLook {
float sunIntensity = 0.0f;
glm::vec3 sunColor{1.0f};
glm::vec3 zenith{0.5f}, horizon{0.5f}, ground{0.2f};
float ambient = 0.5f;
glm::vec3 fogColor{0.5f};
float fogDensity = 0.0f, fogHeightFalloff = 0.1f, godRays = 0.0f, shaftStrength = 0.0f, cookieStrength = 0.0f;
float windStrength = 0.0f, windGustSpeed = 0.0f;
float bloomStrength = 0.0f, saturation = 1.0f;
};
// Blends the scene from its clear-weather look (captured from the current
// scene + settings while it is not raining, so manual tuning is kept) toward
// a storm preset, and drives rain intensity, wetness, ground splashes and the
// fading of ambient particles.
class Weather {
public:
Weather();
void setRain(float target) { targetRain_ = glm::clamp(target, 0.0f, 1.0f); }
float targetRain() const { return targetRain_; }
float rain() const { return rain_; }
// Jumps straight to the target (no transition), e.g. for screenshots.
void snap() { rain_ = targetRain_; wetness_ = targetRain_; }
void update(float dt, Scene& scene, RenderSettings& settings);
WeatherLook storm; // look at full rain intensity
float transitionSeconds = 5.0f; // clear <-> storm fade time
float splashesPerSecond = 500.0f;
float splashRadius = 12.0f; // around Scene::focusPoint
private:
static WeatherLook capture(const Scene& scene, const RenderSettings& settings);
static void apply(const WeatherLook& look, Scene& scene, RenderSettings& settings);
WeatherLook clear_;
bool haveClear_ = false;
float rain_ = 0.0f, targetRain_ = 0.0f, wetness_ = 0.0f, splashAccum_ = 0.0f;
std::mt19937 rng_{99};
};
} // namespace hd2d

View File

@@ -0,0 +1,49 @@
#pragma once
#include <glm/vec2.hpp>
#include <string>
struct GLFWwindow;
namespace hd2d {
// GLFW window + OpenGL 4.5 core context with simple polled input.
class Window {
public:
// Non-resizable windows float on tiling WMs, keeping an exact size.
bool create(int width, int height, const std::string& title, bool vsync = true, bool resizable = true);
void destroy();
// Polls events and refreshes per-frame input state. Returns false once the
// window should close.
bool pollEvents();
void swapBuffers();
bool keyDown(int glfwKey) const;
bool keyPressed(int glfwKey) const; // went down this frame
bool mouseDown(int glfwButton) const;
glm::vec2 mouseDelta() const { return mouseDelta_; }
float scrollDelta() const { return scrollDelta_; }
int width() const { return width_; }
int height() const { return height_; }
bool resized() const { return resized_; }
GLFWwindow* handle() const { return window_; }
void setTitle(const std::string& title);
void requestClose();
private:
static void onFramebufferSize(GLFWwindow* w, int width, int height);
static void onScroll(GLFWwindow* w, double dx, double dy);
GLFWwindow* window_ = nullptr;
int width_ = 0, height_ = 0;
bool resized_ = false;
bool keys_[512] = {};
bool prevKeys_[512] = {};
glm::vec2 lastMouse_{0.0f};
glm::vec2 mouseDelta_{0.0f};
float scrollAccum_ = 0.0f;
float scrollDelta_ = 0.0f;
};
} // namespace hd2d

42
engine/src/Billboard.cpp Normal file
View File

@@ -0,0 +1,42 @@
#include "hd2d/Billboard.h"
#include <cstddef>
namespace hd2d {
static_assert(sizeof(BillboardInstance) == 32, "instance layout must match billboard.vert");
BillboardBatch::~BillboardBatch() {
if (vbo_) glDeleteBuffers(1, &vbo_);
if (vao_) glDeleteVertexArrays(1, &vao_);
}
void BillboardBatch::upload() {
if (!vao_) {
glCreateVertexArrays(1, &vao_);
glCreateBuffers(1, &vbo_);
glVertexArrayBindingDivisor(vao_, 0, 1);
glEnableVertexArrayAttrib(vao_, 0);
glVertexArrayAttribFormat(vao_, 0, 4, GL_FLOAT, GL_FALSE, offsetof(BillboardInstance, pos));
glVertexArrayAttribBinding(vao_, 0, 0);
glEnableVertexArrayAttrib(vao_, 1);
glVertexArrayAttribFormat(vao_, 1, 4, GL_FLOAT, GL_FALSE, offsetof(BillboardInstance, size));
glVertexArrayAttribBinding(vao_, 1, 0);
}
count_ = GLsizei(instances.size());
if (instances.size() > capacity_) {
capacity_ = instances.size();
glNamedBufferData(vbo_, GLsizeiptr(capacity_ * sizeof(BillboardInstance)), nullptr, GL_DYNAMIC_DRAW);
glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, sizeof(BillboardInstance));
}
if (count_)
glNamedBufferSubData(vbo_, 0, GLsizeiptr(count_ * sizeof(BillboardInstance)), instances.data());
}
void BillboardBatch::draw() const {
if (!count_) return;
glBindVertexArray(vao_);
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, count_);
}
} // namespace hd2d

19
engine/src/Camera.cpp Normal file
View File

@@ -0,0 +1,19 @@
#include "hd2d/Camera.h"
#include <cmath>
#include <glm/gtc/matrix_transform.hpp>
namespace hd2d {
glm::vec3 Camera::forward() const {
float cp = std::cos(pitch);
return glm::normalize(glm::vec3(std::sin(yaw) * cp, -std::sin(pitch), -std::cos(yaw) * cp));
}
glm::vec3 Camera::right() const { return glm::vec3(std::cos(yaw), 0.0f, std::sin(yaw)); }
glm::mat4 Camera::view() const { return glm::lookAt(position(), target, glm::vec3(0, 1, 0)); }
glm::mat4 Camera::projection(float aspect) const { return glm::perspective(fovY, aspect, nearPlane, farPlane); }
} // namespace hd2d

164
engine/src/DebugUI.cpp Normal file
View File

@@ -0,0 +1,164 @@
#include "hd2d/DebugUI.h"
#include "hd2d/Renderer.h"
#include "hd2d/Weather.h"
#include "hd2d/Window.h"
#include <glm/gtc/type_ptr.hpp>
#include <imgui.h>
#include <imgui_impl_glfw.h>
#include <imgui_impl_opengl3.h>
namespace hd2d {
bool DebugUI::init(Window& window) {
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui::GetIO().IniFilename = nullptr;
ImGui::StyleColorsDark();
ImGui::GetStyle().WindowRounding = 4.0f;
if (!ImGui_ImplGlfw_InitForOpenGL(window.handle(), true)) return false;
if (!ImGui_ImplOpenGL3_Init("#version 450")) return false;
initialized_ = true;
return true;
}
void DebugUI::shutdown() {
if (!initialized_) return;
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
initialized_ = false;
}
void DebugUI::beginFrame() {
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
}
void DebugUI::endFrame() {
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
}
bool DebugUI::wantsMouse() const { return initialized_ && ImGui::GetIO().WantCaptureMouse; }
bool DebugUI::wantsKeyboard() const { return initialized_ && ImGui::GetIO().WantCaptureKeyboard; }
void DebugUI::renderSettingsPanel(RenderSettings& s, Scene& scene, float gpuMs) {
ImGui::SetNextWindowPos(ImVec2(12, 12), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(340, 640), ImGuiCond_FirstUseEver);
ImGui::Begin("HD-2D Renderer");
ImGui::Text("%.1f fps | GPU %.2f ms", ImGui::GetIO().Framerate, gpuMs);
ImGui::Checkbox("Fade scenery hiding the player", &s.occluderFade);
if (ImGui::CollapsingHeader("Sun & Sky", ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::SliderAngle("Azimuth", &scene.sun.azimuth, -180.0f, 180.0f);
ImGui::SliderAngle("Elevation", &scene.sun.elevation, 3.0f, 89.0f);
ImGui::ColorEdit3("Sun color", glm::value_ptr(scene.sun.color));
ImGui::SliderFloat("Sun intensity", &scene.sun.intensity, 0.0f, 10.0f);
ImGui::ColorEdit3("Zenith", glm::value_ptr(scene.sky.zenith));
ImGui::ColorEdit3("Horizon", glm::value_ptr(scene.sky.horizon));
ImGui::ColorEdit3("Ground bounce", glm::value_ptr(scene.sky.ground));
ImGui::SliderFloat("Ambient", &scene.sky.ambient, 0.0f, 2.0f);
ImGui::SliderFloat("Camera fill", &scene.sky.fill, 0.0f, 1.5f);
ImGui::SliderFloat("Shadow softness", &s.shadowSoftness, 0.0f, 4.0f);
}
if (ImGui::CollapsingHeader("Canopy light (cookie)")) {
ImGui::SliderFloat("Strength##ck", &s.cookieStrength, 0.0f, 1.0f);
ImGui::SliderFloat("Scale##ck", &s.cookieScale, 0.02f, 0.5f);
ImGui::SliderFloat("Coverage##ck", &s.cookieCoverage, 0.2f, 0.8f);
ImGui::SliderFloat("Drift speed##ck", &s.cookieSpeed, 0.0f, 0.3f);
}
if (ImGui::CollapsingHeader("Ambient occlusion")) {
ImGui::Checkbox("SSAO", &s.ssao);
ImGui::SameLine();
ImGui::Checkbox("Ground contact AO", &s.groundOcclusion);
ImGui::SliderFloat("Radius##ao", &s.ssaoRadius, 0.1f, 3.0f);
ImGui::SliderFloat("Intensity##ao", &s.ssaoIntensity, 0.0f, 3.0f);
ImGui::TextDisabled("Ground contact AO is baked per ModelBatch");
}
if (ImGui::CollapsingHeader("Fog & God rays", ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Checkbox("Volumetrics", &s.volumetrics);
ImGui::ColorEdit3("Fog color", glm::value_ptr(s.fogColor));
ImGui::SliderFloat("Density", &s.fogDensity, 0.0f, 0.2f, "%.4f");
ImGui::SliderFloat("Height falloff", &s.fogHeightFalloff, 0.0f, 1.5f);
ImGui::SliderFloat("Base height", &s.fogBaseHeight, -5.0f, 10.0f);
ImGui::SliderFloat("Noise", &s.fogNoise, 0.0f, 1.0f);
ImGui::SliderFloat("Anisotropy", &s.fogAnisotropy, 0.0f, 0.95f);
ImGui::SliderFloat("Shaft strength", &s.shaftStrength, 0.0f, 1.0f);
ImGui::SliderFloat("Shaft coverage", &s.shaftCoverage, 0.2f, 0.8f);
ImGui::SliderFloat("Shaft scale", &s.shaftScale, 0.05f, 1.5f);
ImGui::SliderFloat("God ray intensity", &s.godRayIntensity, 0.0f, 6.0f);
ImGui::SliderInt("Steps", &s.fogSteps, 8, 128);
}
if (scene.water && ImGui::CollapsingHeader("Water")) {
Water& w = *scene.water;
ImGui::Checkbox("Reflections", &s.reflections);
ImGui::ColorEdit3("Shallow", glm::value_ptr(w.shallowColor));
ImGui::ColorEdit3("Deep", glm::value_ptr(w.deepColor));
ImGui::SliderFloat3("Absorption", glm::value_ptr(w.absorption), 0.0f, 2.0f);
ImGui::SliderFloat("Clarity", &w.clarity, 0.05f, 2.0f);
ImGui::SliderFloat("Wave scale", &w.waveScale, 0.1f, 4.0f);
ImGui::SliderFloat("Wave strength", &w.waveStrength, 0.0f, 2.0f);
ImGui::SliderFloat("Wave speed", &w.waveSpeed, 0.0f, 2.0f);
ImGui::SliderFloat("Refraction", &w.refraction, 0.0f, 0.1f);
ImGui::SliderFloat("Reflection distort", &w.reflectionDistort, 0.0f, 0.1f);
ImGui::SliderFloat("Glitter", &w.glitter, 0.0f, 4.0f);
ImGui::SliderFloat("Specular", &w.specular, 0.0f, 20.0f);
ImGui::SliderFloat("Foam width", &w.foamWidth, 0.0f, 1.0f);
ImGui::SliderFloat("Foam intensity", &w.foamIntensity, 0.0f, 1.0f);
ImGui::SliderFloat("Caustics", &w.causticsIntensity, 0.0f, 4.0f);
}
if (ImGui::CollapsingHeader("Wind")) {
ImGui::SliderFloat2("Direction", glm::value_ptr(s.windDirection), -1.0f, 1.0f);
ImGui::SliderFloat("Strength##wind", &s.windStrength, 0.0f, 0.3f);
ImGui::SliderFloat("Gust speed", &s.windGustSpeed, 0.0f, 4.0f);
}
if (ImGui::CollapsingHeader("Depth of field", ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Checkbox("Enabled##dof", &s.dof);
ImGui::SliderFloat("Focus range", &s.focusRange, 0.0f, 20.0f);
ImGui::SliderFloat("Transition", &s.focusTransition, 0.1f, 30.0f);
ImGui::SliderFloat("Max radius", &s.dofMaxRadius, 1.0f, 20.0f);
ImGui::SliderFloat("Tilt-shift", &s.tiltShift, 0.0f, 1.0f);
}
if (ImGui::CollapsingHeader("Bloom & Grade")) {
ImGui::Checkbox("Bloom", &s.bloom);
ImGui::SliderFloat("Bloom strength", &s.bloomStrength, 0.0f, 0.4f);
ImGui::SliderFloat("Bloom radius", &s.bloomRadius, 0.5f, 3.0f);
ImGui::SliderFloat("Exposure", &s.exposure, 0.1f, 4.0f);
ImGui::SliderFloat("Contrast", &s.contrast, 0.5f, 1.6f);
ImGui::SliderFloat("Saturation", &s.saturation, 0.0f, 2.0f);
ImGui::ColorEdit3("Lift", glm::value_ptr(s.lift));
ImGui::ColorEdit3("Gain", glm::value_ptr(s.gain));
ImGui::SliderFloat("Vignette", &s.vignette, 0.0f, 1.0f);
ImGui::SliderFloat("Chromatic ab.", &s.chromaticAberration, 0.0f, 0.01f, "%.4f");
ImGui::SliderFloat("Grain", &s.grain, 0.0f, 0.1f);
}
ImGui::End();
}
void DebugUI::weatherPanel(Weather& w) {
ImGui::SetNextWindowPos(ImVec2(364, 12), ImGuiCond_FirstUseEver);
ImGui::Begin("Weather", nullptr, ImGuiWindowFlags_AlwaysAutoResize);
float target = w.targetRain();
if (ImGui::SliderFloat("Rain", &target, 0.0f, 1.0f)) w.setRain(target);
ImGui::Text("current %.2f (R toggles)", w.rain());
ImGui::SliderFloat("Transition (s)", &w.transitionSeconds, 0.1f, 20.0f);
if (ImGui::TreeNode("Storm look")) {
WeatherLook& l = w.storm;
ImGui::SliderFloat("Sun intensity##st", &l.sunIntensity, 0.0f, 4.0f);
ImGui::ColorEdit3("Zenith##st", glm::value_ptr(l.zenith));
ImGui::ColorEdit3("Horizon##st", glm::value_ptr(l.horizon));
ImGui::SliderFloat("Ambient##st", &l.ambient, 0.0f, 2.0f);
ImGui::ColorEdit3("Fog color##st", glm::value_ptr(l.fogColor));
ImGui::SliderFloat("Fog density##st", &l.fogDensity, 0.0f, 0.1f, "%.4f");
ImGui::SliderFloat("Wind##st", &l.windStrength, 0.0f, 0.4f);
ImGui::SliderFloat("Gust speed##st", &l.windGustSpeed, 0.0f, 6.0f);
ImGui::SliderFloat("Splashes / s", &w.splashesPerSecond, 0.0f, 2000.0f);
ImGui::TreePop();
}
ImGui::End();
}
} // namespace hd2d

View File

@@ -0,0 +1,57 @@
#include "hd2d/GroundOcclusion.h"
#include "hd2d/Model.h"
#include <algorithm>
#include <cmath>
namespace hd2d {
GroundOcclusion::~GroundOcclusion() {
if (tex_) glDeleteTextures(1, &tex_);
}
void GroundOcclusion::build(glm::vec2 min, glm::vec2 max, const std::vector<ModelBatch*>& batches,
int texelsPerUnit) {
const int w = std::max(1, int(std::ceil((max.x - min.x) * float(texelsPerUnit))));
const int h = std::max(1, int(std::ceil((max.y - min.y) * float(texelsPerUnit))));
std::vector<float> occ(size_t(w) * h, 1.0f);
const float tpu = float(texelsPerUnit);
for (const ModelBatch* b : batches) {
if (!b || !b->model || b->groundOcclusion <= 0.0f) continue;
const glm::vec3 ext = b->model->boundsMax - b->model->boundsMin;
for (const ModelInstance& in : b->instances) {
float footprint = 0.5f * std::max(ext.x * in.scale.x, ext.z * in.scale.z);
float height = b->model->boundsMax.y * in.scale.y;
bool tall = height > 1.6f * footprint; // trees: trunk contact + canopy shade
float rCore = tall ? 0.15f * footprint + 0.45f : 0.8f * footprint;
float rWide = tall ? 0.85f * footprint : 1.3f * footprint;
float kCore = 0.85f * b->groundOcclusion, kWide = (tall ? 0.4f : 0.3f) * b->groundOcclusion;
float reach = std::max(rCore, rWide) * 2.2f;
int x0 = std::max(0, int((in.pos.x - reach - min.x) * tpu));
int x1 = std::min(w - 1, int((in.pos.x + reach - min.x) * tpu));
int z0 = std::max(0, int((in.pos.z - reach - min.y) * tpu));
int z1 = std::min(h - 1, int((in.pos.z + reach - min.y) * tpu));
for (int z = z0; z <= z1; ++z)
for (int x = x0; x <= x1; ++x) {
glm::vec2 p(min.x + (float(x) + 0.5f) / tpu, min.y + (float(z) + 0.5f) / tpu);
float d = glm::length(p - glm::vec2(in.pos.x, in.pos.z));
float a = kCore * std::exp(-(d * d) / (rCore * rCore)) + kWide * std::exp(-(d * d) / (rWide * rWide));
occ[size_t(z) * w + x] *= 1.0f - std::min(a, 0.9f);
}
}
}
if (tex_) glDeleteTextures(1, &tex_);
glCreateTextures(GL_TEXTURE_2D, 1, &tex_);
glTextureStorage2D(tex_, 1, GL_R16F, w, h);
glTextureSubImage2D(tex_, 0, 0, 0, w, h, GL_RED, GL_FLOAT, occ.data());
glTextureParameteri(tex_, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTextureParameteri(tex_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTextureParameteri(tex_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTextureParameteri(tex_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
rect_ = glm::vec4(min.x, min.y, 1.0f / (max.x - min.x), 1.0f / (max.y - min.y));
}
} // namespace hd2d

221
engine/src/Model.cpp Normal file
View File

@@ -0,0 +1,221 @@
#include "hd2d/Model.h"
#include "hd2d/Log.h"
#include <algorithm>
#include <cstddef>
#include <cstring>
#include <glm/gtc/type_ptr.hpp>
#define CGLTF_IMPLEMENTATION
#include <cgltf.h>
namespace hd2d {
namespace {
struct Vertex {
glm::vec3 pos;
glm::vec3 normal;
glm::vec2 uv;
glm::vec4 color;
};
void readAttr(const cgltf_accessor* acc, size_t comps, std::vector<float>& out) {
out.assign(acc->count * comps, 0.0f);
for (size_t i = 0; i < acc->count; ++i) cgltf_accessor_read_float(acc, i, &out[i * comps], comps);
}
} // namespace
ModelLibrary::~ModelLibrary() {
for (auto& [name, m] : models_)
for (ModelPrimitive& p : m->primitives) {
glDeleteBuffers(1, &p.vbo);
glDeleteBuffers(1, &p.ibo);
}
}
bool ModelLibrary::load(const std::string& path) {
cgltf_options options{};
cgltf_data* data = nullptr;
if (cgltf_parse_file(&options, path.c_str(), &data) != cgltf_result_success ||
cgltf_load_buffers(&options, data, path.c_str()) != cgltf_result_success) {
HD2D_ERR("cannot load model file '%s'", path.c_str());
if (data) cgltf_free(data);
return false;
}
// Images -> textures (decoded once, shared by every material).
std::vector<const Texture*> imageTex(data->images_count, nullptr);
for (size_t i = 0; i < data->images_count; ++i) {
const cgltf_image& im = data->images[i];
if (!im.buffer_view) continue; // external URIs are not used by our pipeline
const auto* bytes = static_cast<const unsigned char*>(im.buffer_view->buffer->data) + im.buffer_view->offset;
Image img;
if (!decodeImage(bytes, im.buffer_view->size, img, false)) continue;
auto tex = std::make_unique<Texture>();
tex->create(img, true, 8.0f);
imageTex[i] = tex.get();
textures_.push_back(std::move(tex));
}
size_t matBase = materials_.size(); // this file's materials start here
for (size_t i = 0; i < data->materials_count; ++i) {
const cgltf_material& m = data->materials[i];
ModelMaterial mm;
mm.name = m.name ? m.name : "";
mm.foliage = mm.name.rfind("foliage", 0) == 0;
mm.doubleSided = m.double_sided || mm.foliage;
mm.alphaMask = m.alpha_mode == cgltf_alpha_mode_mask;
mm.alphaCutoff = m.alpha_cutoff > 0.0f ? m.alpha_cutoff : 0.5f;
if (m.has_pbr_metallic_roughness) {
const auto& pbr = m.pbr_metallic_roughness;
mm.baseColor = glm::make_vec4(pbr.base_color_factor);
if (pbr.base_color_texture.texture && pbr.base_color_texture.texture->image)
mm.texture = imageTex[size_t(pbr.base_color_texture.texture->image - data->images)];
}
float strength = m.has_emissive_strength ? m.emissive_strength.emissive_strength : 1.0f;
mm.emissive = glm::make_vec3(m.emissive_factor) * strength;
if (m.emissive_texture.texture && m.emissive_texture.texture->image)
mm.emissiveTexture = imageTex[size_t(m.emissive_texture.texture->image - data->images)];
materials_.push_back(std::make_unique<ModelMaterial>(mm));
}
if (materials_.size() == matBase) materials_.push_back(std::make_unique<ModelMaterial>()); // default
for (size_t n = 0; n < data->nodes_count; ++n) {
const cgltf_node& node = data->nodes[n];
if (node.parent || !node.mesh || !node.name) continue;
float world[16];
cgltf_node_transform_world(&node, world);
glm::mat4 M = glm::make_mat4(world);
glm::mat3 N = glm::transpose(glm::inverse(glm::mat3(M)));
auto model = std::make_unique<Model>();
model->name = node.name;
model->boundsMin = glm::vec3(1e30f);
model->boundsMax = glm::vec3(-1e30f);
for (size_t c = 0; c < node.children_count; ++c) {
const cgltf_node* child = node.children[c];
if (child->mesh || !child->name) continue;
float cw[16];
cgltf_node_transform_world(child, cw);
model->sockets[child->name] = glm::vec3(cw[12], cw[13], cw[14]);
}
for (size_t pi = 0; pi < node.mesh->primitives_count; ++pi) {
const cgltf_primitive& prim = node.mesh->primitives[pi];
if (prim.type != cgltf_primitive_type_triangles) continue;
std::vector<float> pos, nrm, uv, col;
for (size_t a = 0; a < prim.attributes_count; ++a) {
const cgltf_attribute& at = prim.attributes[a];
if (at.type == cgltf_attribute_type_position) readAttr(at.data, 3, pos);
else if (at.type == cgltf_attribute_type_normal) readAttr(at.data, 3, nrm);
else if (at.type == cgltf_attribute_type_texcoord && at.index == 0) readAttr(at.data, 2, uv);
else if (at.type == cgltf_attribute_type_color && at.index == 0)
readAttr(at.data, cgltf_num_components(at.data->type), col);
}
size_t count = pos.size() / 3;
if (!count) continue;
size_t colComps = count ? col.size() / count : 0;
std::vector<Vertex> verts(count);
for (size_t v = 0; v < count; ++v) {
Vertex& o = verts[v];
o.pos = glm::vec3(M * glm::vec4(pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2], 1.0f));
o.normal = nrm.empty() ? glm::vec3(0, 1, 0)
: glm::normalize(N * glm::vec3(nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2]));
o.uv = uv.empty() ? glm::vec2(0.0f) : glm::vec2(uv[v * 2], uv[v * 2 + 1]);
o.color = glm::vec4(1.0f, 1.0f, 1.0f, 0.0f); // no color: untinted and rigid
if (colComps >= 3) o.color = glm::vec4(col[v * colComps], col[v * colComps + 1], col[v * colComps + 2],
colComps == 4 ? col[v * colComps + 3] : 0.0f);
model->boundsMin = glm::min(model->boundsMin, o.pos);
model->boundsMax = glm::max(model->boundsMax, o.pos);
}
std::vector<uint32_t> idx;
if (prim.indices) {
idx.resize(prim.indices->count);
for (size_t i = 0; i < idx.size(); ++i) idx[i] = uint32_t(cgltf_accessor_read_index(prim.indices, i));
} else {
idx.resize(count);
for (size_t i = 0; i < count; ++i) idx[i] = uint32_t(i);
}
ModelPrimitive mp;
mp.material = materials_[prim.material ? matBase + size_t(prim.material - data->materials) : matBase].get();
mp.indexCount = GLsizei(idx.size());
glCreateBuffers(1, &mp.vbo);
glNamedBufferStorage(mp.vbo, GLsizeiptr(verts.size() * sizeof(Vertex)), verts.data(), 0);
glCreateBuffers(1, &mp.ibo);
glNamedBufferStorage(mp.ibo, GLsizeiptr(idx.size() * sizeof(uint32_t)), idx.data(), 0);
model->primitives.push_back(mp);
}
// Opaque first, then alpha-tested: fewer overdraw costs from early depth rejection.
std::stable_sort(model->primitives.begin(), model->primitives.end(), [&](const auto& a, const auto& b) {
return !a.material->alphaMask && b.material->alphaMask;
});
models_[model->name] = std::move(model);
}
HD2D_LOG("loaded %zu models, %zu textures from %s", models_.size(), textures_.size(), path.c_str());
cgltf_free(data);
return true;
}
const Model* ModelLibrary::get(const std::string& name) const {
auto it = models_.find(name);
if (it == models_.end()) {
HD2D_ERR("model '%s' not found", name.c_str());
return nullptr;
}
return it->second.get();
}
static_assert(sizeof(ModelInstance) == 32, "instance layout must match mesh.vert");
ModelBatch::~ModelBatch() {
if (!vaos_.empty()) glDeleteVertexArrays(GLsizei(vaos_.size()), vaos_.data());
if (instanceVbo_) glDeleteBuffers(1, &instanceVbo_);
}
void ModelBatch::upload() {
if (!model) return;
if (!instanceVbo_) glCreateBuffers(1, &instanceVbo_);
count_ = GLsizei(instances.size());
if (instances.size() > capacity_) {
capacity_ = instances.size();
glNamedBufferData(instanceVbo_, GLsizeiptr(capacity_ * sizeof(ModelInstance)), nullptr, GL_DYNAMIC_DRAW);
}
if (count_)
glNamedBufferSubData(instanceVbo_, 0, GLsizeiptr(count_ * sizeof(ModelInstance)), instances.data());
if (vaos_.empty()) {
vaos_.resize(model->primitives.size());
glCreateVertexArrays(GLsizei(vaos_.size()), vaos_.data());
for (size_t i = 0; i < vaos_.size(); ++i) {
GLuint vao = vaos_[i];
const ModelPrimitive& p = model->primitives[i];
glVertexArrayVertexBuffer(vao, 0, p.vbo, 0, sizeof(Vertex));
glVertexArrayElementBuffer(vao, p.ibo);
const GLuint offsets[4] = {offsetof(Vertex, pos), offsetof(Vertex, normal), offsetof(Vertex, uv),
offsetof(Vertex, color)};
const GLint sizes[4] = {3, 3, 2, 4};
for (GLuint a = 0; a < 4; ++a) {
glEnableVertexArrayAttrib(vao, a);
glVertexArrayAttribFormat(vao, a, sizes[a], GL_FLOAT, GL_FALSE, offsets[a]);
glVertexArrayAttribBinding(vao, a, 0);
}
glVertexArrayBindingDivisor(vao, 1, 1);
for (GLuint a = 4; a < 6; ++a) {
glEnableVertexArrayAttrib(vao, a);
glVertexArrayAttribFormat(vao, a, 4, GL_FLOAT, GL_FALSE, (a - 4) * 16);
glVertexArrayAttribBinding(vao, a, 1);
}
}
}
for (GLuint vao : vaos_) glVertexArrayVertexBuffer(vao, 1, instanceVbo_, 0, sizeof(ModelInstance));
}
void ModelBatch::drawPrimitive(size_t i) const {
if (!count_) return;
glBindVertexArray(vaos_[i]);
glDrawElementsInstanced(GL_TRIANGLES, model->primitives[i].indexCount, GL_UNSIGNED_INT, nullptr, count_);
}
} // namespace hd2d

262
engine/src/Particles.cpp Normal file
View File

@@ -0,0 +1,262 @@
#include "hd2d/Particles.h"
#include "hd2d/Scene.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace hd2d {
namespace {
glm::vec3 flameColor(float t) {
// Yellow core -> orange -> deep red, in linear HDR so the core blooms like
// HD-2D's torches. Kept saturated: overlapping tongues add up quickly.
const glm::vec3 c0(4.0f, 2.3f, 0.6f), c1(3.6f, 1.0f, 0.15f), c2(1.8f, 0.26f, 0.04f), c3(0.3f, 0.03f, 0.01f);
if (t < 0.25f) return glm::mix(c0, c1, t / 0.25f);
if (t < 0.6f) return glm::mix(c1, c2, (t - 0.25f) / 0.35f);
return glm::mix(c2, c3, (t - 0.6f) / 0.4f);
}
} // namespace
ParticleSystem::~ParticleSystem() {
if (vbo_) glDeleteBuffers(1, &vbo_);
if (vao_) glDeleteVertexArrays(1, &vao_);
}
void ParticleSystem::addField(Kind kind, glm::vec3 min, glm::vec3 max, int count) {
fields_.push_back({kind, min, max, count});
int fi = int(fields_.size()) - 1;
for (int i = 0; i < count; ++i) {
Particle p{};
spawnInField(p, fi, true);
particles_.push_back(p);
}
}
int ParticleSystem::addFire(glm::vec3 pos, float scale) {
std::uniform_real_distribution<float> u(0.0f, 100.0f);
Fire f;
f.pos = pos;
f.scale = scale;
f.phase = u(rng_);
fires_.push_back(f);
return int(fires_.size()) - 1;
}
void ParticleSystem::spawnInField(Particle& p, int fi, bool randomAge) {
std::uniform_real_distribution<float> u(0.0f, 1.0f);
const Field& f = fields_[fi];
p.kind = f.kind;
p.field = fi;
p.fire = -1;
p.pos = f.min + (f.max - f.min) * glm::vec3(u(rng_), u(rng_), u(rng_));
p.vel = glm::vec3(0.0f);
p.phase = u(rng_) * 100.0f;
if (f.kind == Kind::Firefly) {
p.life = 7.0f + 6.0f * u(rng_);
p.size = 0.07f + 0.04f * u(rng_);
p.color = fireflyColor;
} else {
p.life = 9.0f + 8.0f * u(rng_);
p.size = 0.025f + 0.025f * u(rng_);
p.color = moteColor;
}
p.age = randomAge ? u(rng_) * p.life : 0.0f;
}
void ParticleSystem::spawnFire(const Fire& f, int fireIndex, Kind kind) {
std::uniform_real_distribution<float> u(0.0f, 1.0f);
std::uniform_real_distribution<float> s(-1.0f, 1.0f);
const float k = f.scale;
Particle p{};
p.kind = kind;
p.field = -1;
p.fire = fireIndex;
p.phase = u(rng_) * 100.0f;
p.age = 0.0f;
float a = u(rng_) * 6.2832f;
if (kind == Kind::Flame) {
float r = 0.2f * k * std::sqrt(u(rng_));
p.pos = f.pos + glm::vec3(std::cos(a) * r, u(rng_) * 0.05f * k, std::sin(a) * r);
p.vel = glm::vec3(s(rng_) * 0.15f, 1.3f + 0.8f * u(rng_), s(rng_) * 0.15f) * std::sqrt(k);
p.life = 0.5f + 0.4f * u(rng_);
p.size = 0.42f * k * (0.8f + 0.4f * u(rng_));
} else if (kind == Kind::Ember) {
float r = 0.15f * k * u(rng_);
p.pos = f.pos + glm::vec3(std::cos(a) * r, 0.1f * k, std::sin(a) * r);
p.vel = glm::vec3(s(rng_) * 0.5f, 1.2f + 1.2f * u(rng_), s(rng_) * 0.5f);
p.life = 1.0f + 1.2f * u(rng_);
p.size = 0.022f + 0.014f * u(rng_);
} else { // Smoke
p.pos = f.pos + glm::vec3(s(rng_) * 0.08f, 0.55f * k, s(rng_) * 0.08f);
p.vel = glm::vec3(s(rng_) * 0.1f, 0.5f + 0.3f * u(rng_), s(rng_) * 0.1f);
p.life = 2.5f + 1.5f * u(rng_);
p.size = 0.25f * k;
}
particles_.push_back(p);
}
void ParticleSystem::burst(glm::vec3 pos, int count, glm::vec3 color, float speed, float size, float life) {
std::uniform_real_distribution<float> u(-1.0f, 1.0f);
for (int i = 0; i < count; ++i) {
Particle p{};
p.kind = Kind::Spark;
p.field = -1;
p.fire = -1;
p.pos = pos;
p.vel = glm::vec3(u(rng_), 0.6f + 0.4f * std::abs(u(rng_)), u(rng_)) * speed;
p.color = color;
p.size = size * (0.7f + 0.3f * u(rng_));
p.life = life * (0.7f + 0.3f * std::abs(u(rng_)));
p.age = 0.0f;
p.phase = 0.0f;
particles_.push_back(p);
}
}
void ParticleSystem::update(float dt, float time) {
gpu_.clear();
// Fire emitters: spawn, flicker, and one big glow sprite each.
for (size_t i = 0; i < fires_.size(); ++i) {
Fire& f = fires_[i];
float t = time + f.phase;
f.flicker = 0.82f + 0.1f * std::sin(t * 11.0f) + 0.08f * std::sin(t * 23.7f + 1.3f) + 0.06f * std::sin(t * 5.3f);
auto emit = [&](float& acc, float rate, Kind kind) {
acc += rate * dt;
while (acc >= 1.0f) {
acc -= 1.0f;
spawnFire(f, int(i), kind);
}
};
emit(f.flameAccum, 45.0f * f.scale, Kind::Flame);
emit(f.emberAccum, 14.0f * f.scale, Kind::Ember);
emit(f.smokeAccum, 6.0f * f.scale, Kind::Smoke);
glm::vec3 glow = glm::vec3(1.0f, 0.45f, 0.13f) * 0.9f * f.flicker;
gpu_.push_back({glm::vec4(f.pos + glm::vec3(0, 0.35f * f.scale, 0), 1.1f * f.scale * f.flicker),
glm::vec4(glow, 1.0f), glm::vec4(float(int(Kind::Glow)), 0, 0, 0)});
}
for (size_t i = 0; i < particles_.size();) {
Particle& p = particles_[i];
p.age += dt;
if (p.age >= p.life) {
if (p.field >= 0) {
spawnInField(p, p.field, false);
} else {
particles_[i] = particles_.back();
particles_.pop_back();
continue;
}
}
float t = time + p.phase;
float lifeT = p.age / p.life;
float fade = std::min(1.0f, std::min(p.age, p.life - p.age) / 1.5f);
float alpha = fade;
float size = p.size;
glm::vec3 color = p.color;
switch (p.kind) {
case Kind::Firefly: {
// Smooth wandering flight with a slow hovering bob.
glm::vec3 wander(std::sin(t * 0.7f) + std::sin(t * 1.31f + 2.0f) * 0.5f,
std::sin(t * 1.1f + 1.0f) * 0.6f,
std::cos(t * 0.53f) + std::cos(t * 1.7f + 4.0f) * 0.5f);
p.pos += wander * (0.35f * dt);
float blink = 0.5f + 0.5f * std::sin(t * 2.3f);
alpha *= (0.15f + 0.85f * blink * blink * blink) * ambientFade;
break;
}
case Kind::Mote: {
glm::vec3 drift(std::sin(t * 0.21f) * 0.12f, std::sin(t * 0.37f) * 0.05f - 0.02f,
std::cos(t * 0.17f) * 0.12f);
p.pos += drift * dt;
alpha *= ambientFade;
break;
}
case Kind::Spark:
p.vel.y -= 6.0f * dt;
p.vel *= std::exp(-3.0f * dt);
p.pos += p.vel * dt;
alpha = 1.0f - lifeT;
break;
case Kind::Flame: {
const Fire& f = fires_[p.fire];
p.vel.y += 1.6f * dt; // buoyancy
p.vel.x += (std::sin(t * 9.0f) * 0.9f - (p.pos.x - f.pos.x) * 5.0f) * dt; // flicker + taper
p.vel.z += (std::cos(t * 7.3f) * 0.9f - (p.pos.z - f.pos.z) * 5.0f) * dt;
p.pos += p.vel * dt;
color = flameColor(lifeT) * 0.22f; // tongues overlap a lot; keep the sum orange, not white
alpha = std::min(1.0f, lifeT / 0.08f) * std::pow(1.0f - lifeT, 1.3f);
size = p.size * (0.75f + 0.5f * std::sin(3.1416f * std::min(lifeT * 1.2f, 1.0f))) * (1.0f - 0.5f * lifeT);
break;
}
case Kind::Ember: {
p.vel += (glm::vec3(std::sin(t * 3.0f + p.phase * 7.0f), 0.3f, std::cos(t * 2.6f + p.phase * 5.0f)) * 1.8f +
wind * 0.6f) * dt;
p.vel *= std::exp(-0.8f * dt);
p.pos += p.vel * dt;
float blink = 0.6f + 0.4f * std::sin(t * 17.0f);
color = glm::vec3(6.0f, 2.2f, 0.5f) * blink * (1.0f - 0.6f * lifeT);
alpha = 1.0f - lifeT;
break;
}
case Kind::Smoke:
p.vel += (wind * 0.6f + glm::vec3(0, 0.1f, 0)) * dt;
p.pos += p.vel * dt;
size = p.size * (1.0f + 2.5f * lifeT);
color = glm::vec3(0.10f, 0.095f, 0.09f);
alpha = 0.32f * std::min(1.0f, lifeT / 0.15f) * (1.0f - lifeT);
break;
case Kind::Glow:
break;
}
if (p.field >= 0) { // keep ambient particles inside their field
const Field& f = fields_[p.field];
p.pos = glm::clamp(p.pos, f.min, f.max);
}
float seed = p.phase - std::floor(p.phase);
gpu_.push_back({glm::vec4(p.pos, size), glm::vec4(color, alpha), glm::vec4(float(int(p.kind)), lifeT, seed, 0)});
++i;
}
if (!vao_) {
glCreateVertexArrays(1, &vao_);
glCreateBuffers(1, &vbo_);
glVertexArrayBindingDivisor(vao_, 0, 1);
for (GLuint a = 0; a < 3; ++a) {
glEnableVertexArrayAttrib(vao_, a);
glVertexArrayAttribFormat(vao_, a, 4, GL_FLOAT, GL_FALSE, GLuint(a * sizeof(glm::vec4)));
glVertexArrayAttribBinding(vao_, a, 0);
}
}
if (gpu_.size() > capacity_) {
capacity_ = gpu_.size() * 2;
glNamedBufferData(vbo_, GLsizeiptr(capacity_ * sizeof(GpuParticle)), nullptr, GL_STREAM_DRAW);
glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, sizeof(GpuParticle));
}
if (!gpu_.empty())
glNamedBufferSubData(vbo_, 0, GLsizeiptr(gpu_.size() * sizeof(GpuParticle)), gpu_.data());
}
void ParticleSystem::draw() const {
if (gpu_.empty()) return;
glBindVertexArray(vao_);
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, GLsizei(gpu_.size()));
}
void ParticleSystem::collectLights(std::vector<PointLight>& out, int maxFireflies) const {
for (const Fire& f : fires_)
out.push_back({f.pos + glm::vec3(0, 0.4f * f.scale, 0), fireLightRadius * f.scale, fireLightColor * f.flicker});
int n = 0;
for (const GpuParticle& g : gpu_) {
if (n >= maxFireflies) break;
if (int(g.params.x) != int(Kind::Firefly) || g.color.a < 0.02f) continue;
out.push_back({glm::vec3(g.posSize), fireflyLightRadius, glm::vec3(g.color) * g.color.a * 0.35f});
++n;
}
}
} // namespace hd2d

View File

@@ -0,0 +1,61 @@
#include "hd2d/RenderTarget.h"
#include "hd2d/Log.h"
namespace hd2d {
GLuint createTexture2D(GLenum internalFormat, int w, int h, GLenum filter, int levels) {
GLuint t;
glCreateTextures(GL_TEXTURE_2D, 1, &t);
glTextureStorage2D(t, levels, internalFormat, w, h);
GLenum minFilter = filter;
if (levels > 1) minFilter = filter == GL_LINEAR ? GL_LINEAR_MIPMAP_NEAREST : GL_NEAREST_MIPMAP_NEAREST;
glTextureParameteri(t, GL_TEXTURE_MIN_FILTER, minFilter);
glTextureParameteri(t, GL_TEXTURE_MAG_FILTER, filter);
glTextureParameteri(t, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTextureParameteri(t, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
return t;
}
void RenderTarget::create(int w, int h, std::initializer_list<GLenum> colorFormats, GLenum depthFormat,
GLuint sharedDepth, GLenum filter) {
destroy();
w_ = w;
h_ = h;
glCreateFramebuffers(1, &fbo_);
std::vector<GLenum> drawBuffers;
for (GLenum fmt : colorFormats) {
GLuint t = createTexture2D(fmt, w, h, filter);
glNamedFramebufferTexture(fbo_, GLenum(GL_COLOR_ATTACHMENT0 + colors_.size()), t, 0);
drawBuffers.push_back(GLenum(GL_COLOR_ATTACHMENT0 + colors_.size()));
colors_.push_back(t);
}
if (depthFormat) {
depth_ = createTexture2D(depthFormat, w, h, GL_NEAREST);
ownsDepth_ = true;
} else if (sharedDepth) {
depth_ = sharedDepth;
ownsDepth_ = false;
}
if (depth_) glNamedFramebufferTexture(fbo_, GL_DEPTH_ATTACHMENT, depth_, 0);
if (drawBuffers.empty()) glNamedFramebufferDrawBuffer(fbo_, GL_NONE);
else glNamedFramebufferDrawBuffers(fbo_, GLsizei(drawBuffers.size()), drawBuffers.data());
GLenum status = glCheckNamedFramebufferStatus(fbo_, GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) HD2D_ERR("framebuffer incomplete: 0x%x", status);
}
void RenderTarget::destroy() {
if (!colors_.empty()) glDeleteTextures(GLsizei(colors_.size()), colors_.data());
colors_.clear();
if (depth_ && ownsDepth_) glDeleteTextures(1, &depth_);
depth_ = 0;
if (fbo_) glDeleteFramebuffers(1, &fbo_);
fbo_ = 0;
}
void RenderTarget::bind() const {
glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
glViewport(0, 0, w_, h_);
}
} // namespace hd2d

584
engine/src/Renderer.cpp Normal file
View File

@@ -0,0 +1,584 @@
#include "hd2d/Renderer.h"
#include "hd2d/Log.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <glm/gtc/matrix_transform.hpp>
namespace hd2d {
glm::vec3 Sun::direction() const {
float ce = std::cos(elevation);
return glm::normalize(glm::vec3(ce * std::sin(azimuth), std::sin(elevation), -ce * std::cos(azimuth)));
}
namespace {
constexpr int kMaxLights = 16;
constexpr int kBloomMips = 6;
// Mirrors `Frame` in assets/shaders/common.glsl (std140: only mat4/vec4/ivec4).
struct FrameUBO {
glm::mat4 view, proj, viewProj, invViewProj, lightViewProj;
glm::vec4 camPos, camParams, screen, sunDir, sunColor, skyZenith, skyHorizon, skyGround, fogColor,
fogParams, fogParams2, wind, interactor, blob, clipPlane, billboardRight, shadowRight, cookie,
shadowParams, shaft, waterParams, groundAO, weather;
glm::ivec4 lightCount;
glm::vec4 lightPos[kMaxLights];
glm::vec4 lightColor[kMaxLights];
};
static_assert(sizeof(FrameUBO) == 5 * 64 + 23 * 16 + 16 + 2 * kMaxLights * 16, "FrameUBO must be tightly packed");
} // namespace
struct Renderer::Impl {
std::string dir;
int w = 0, h = 0;
const RenderSettings* settings = nullptr;
Shader terrain, terrainDepth, billboard, mesh, meshDepth, water, sky, volumetric, fogComposite, particles,
dofPrep, dofBlur, dofComposite, bloomDown, bloomUp, tonemap, ssao, ssaoBlur, ssaoApply, rainStreaks;
RenderTarget shadow, reflection, hdr, post, sceneCopy, depthCopy, volume, dofHalf, dofBlurred, dofOut, ssaoRaw,
ssaoBlurred;
RenderTarget bloom[kBloomMips];
GLuint postDepthFbo = 0; // post color + hdr depth, for depth-tested particles
int shadowSize = 0;
GLuint ubo = 0, emptyVao = 0, whiteTex = 0;
GLuint timers[2] = {};
bool timerUsed[2] = {};
int timerIdx = 0;
FrameUBO frame{};
~Impl() {
if (ubo) glDeleteBuffers(1, &ubo);
if (emptyVao) glDeleteVertexArrays(1, &emptyVao);
if (whiteTex) glDeleteTextures(1, &whiteTex);
if (timers[0]) glDeleteQueries(2, timers);
if (postDepthFbo) glDeleteFramebuffers(1, &postDepthFbo);
}
std::string path(const char* name) const { return dir + "/" + name; }
bool loadShaders() {
bool ok = true;
auto ld = [&](Shader& s, const char* vs, const char* fs) { ok &= s.load(path(vs), path(fs)); };
ld(terrain, "terrain.vert", "terrain.frag");
ld(terrainDepth, "terrain.vert", "depth.frag");
ld(billboard, "billboard.vert", "billboard.frag");
ld(mesh, "mesh.vert", "mesh.frag");
ld(meshDepth, "mesh.vert", "mesh_depth.frag");
ld(water, "water.vert", "water.frag");
ld(sky, "fullscreen.vert", "sky.frag");
ld(volumetric, "fullscreen.vert", "volumetric.frag");
ld(fogComposite, "fullscreen.vert", "fog_composite.frag");
ld(particles, "particles.vert", "particles.frag");
ld(dofPrep, "fullscreen.vert", "dof_prep.frag");
ld(dofBlur, "fullscreen.vert", "dof_blur.frag");
ld(dofComposite, "fullscreen.vert", "dof_composite.frag");
ld(bloomDown, "fullscreen.vert", "bloom_down.frag");
ld(bloomUp, "fullscreen.vert", "bloom_up.frag");
ld(tonemap, "fullscreen.vert", "tonemap.frag");
ld(ssao, "fullscreen.vert", "ssao.frag");
ld(ssaoBlur, "fullscreen.vert", "ssao_blur.frag");
ld(ssaoApply, "fullscreen.vert", "ssao_apply.frag");
ld(rainStreaks, "rain.vert", "rain.frag");
return ok;
}
bool reloadAll() {
Shader* all[] = {&terrain, &terrainDepth, &billboard, &mesh, &meshDepth, &water,
&sky, &volumetric, &fogComposite, &particles, &dofPrep, &dofBlur,
&dofComposite, &bloomDown, &bloomUp, &tonemap, &ssao, &ssaoBlur,
&ssaoApply, &rainStreaks};
bool ok = true;
for (Shader* s : all) ok &= s->reload();
return ok;
}
void createShadow(int size) {
shadow.create(size, size, {}, GL_DEPTH_COMPONENT32F);
GLuint d = shadow.depth();
glTextureParameteri(d, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);
glTextureParameteri(d, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);
glTextureParameteri(d, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTextureParameteri(d, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
shadowSize = size;
}
void createTargets(int width, int height) {
w = std::max(1, width);
h = std::max(1, height);
int hw = std::max(1, w / 2), hh = std::max(1, h / 2);
hdr.create(w, h, {GL_RGBA16F}, GL_DEPTH_COMPONENT32F);
post.create(w, h, {GL_RGBA16F});
sceneCopy.create(w, h, {GL_RGBA16F});
depthCopy.create(w, h, {}, GL_DEPTH_COMPONENT32F);
reflection.create(hw, hh, {GL_RGBA16F}, GL_DEPTH_COMPONENT32F);
volume.create(hw, hh, {GL_RGBA16F});
dofHalf.create(hw, hh, {GL_RGBA16F});
dofBlurred.create(hw, hh, {GL_RGBA16F});
ssaoRaw.create(hw, hh, {GL_R8});
ssaoBlurred.create(hw, hh, {GL_R8});
dofOut.create(w, h, {GL_RGBA16F});
for (int i = 0; i < kBloomMips; ++i)
bloom[i].create(std::max(1, w >> (i + 1)), std::max(1, h >> (i + 1)), {GL_R11F_G11F_B10F});
if (postDepthFbo) glDeleteFramebuffers(1, &postDepthFbo);
glCreateFramebuffers(1, &postDepthFbo);
glNamedFramebufferTexture(postDepthFbo, GL_COLOR_ATTACHMENT0, post.color(), 0);
glNamedFramebufferTexture(postDepthFbo, GL_DEPTH_ATTACHMENT, hdr.depth(), 0);
}
void setCamera(const glm::mat4& view, const glm::mat4& proj, const glm::vec3& camPos, int tw, int th) {
frame.view = view;
frame.proj = proj;
frame.viewProj = proj * view;
frame.invViewProj = glm::inverse(frame.viewProj);
frame.camPos = glm::vec4(camPos, frame.camPos.w);
frame.screen = glm::vec4(float(tw), float(th), 1.0f / float(tw), 1.0f / float(th));
}
void upload() { glNamedBufferSubData(ubo, 0, sizeof(FrameUBO), &frame); }
void fullscreen() {
glBindVertexArray(emptyVao);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
void drawTerrain(const Scene& scene, Shader& s) {
if (!scene.terrain) return;
const Terrain& t = *scene.terrain;
s.use();
s.set("uMapSize", glm::ivec2(t.width(), t.depth()));
char name[32];
for (int m = 0; m < Terrain::kMaxMaterials; ++m) {
const TerrainMaterial& mat = t.materials[m];
std::snprintf(name, sizeof name, "uMatTop[%d]", m);
s.set(name, glm::ivec4(mat.topFirst, mat.topCount, mat.accentFirst, mat.accentCount));
std::snprintf(name, sizeof name, "uMatAccent[%d]", m);
s.set(name, mat.accentChance);
}
t.textures.bind(0);
t.bindMaterialMap(1);
glEnable(GL_CULL_FACE);
t.draw();
glDisable(GL_CULL_FACE);
}
void drawBillboards(const Scene& scene, int pass, bool reflectionPass) {
billboard.use();
billboard.set("uPass", pass);
for (const BillboardBatch* b : scene.billboards) {
if (!b) continue;
if (pass == 1 && !b->castShadows) continue;
if (reflectionPass && !b->inReflections) continue;
billboard.set("uOpaque", b->texture.opaqueBounds());
billboard.set("uEmissive", b->emissive);
b->texture.bind(0);
b->draw();
}
}
// pass 0 = color, 1 = shadow depth.
void drawModels(const Scene& scene, int pass, bool reflectionPass) {
Shader& s = pass == 1 ? meshDepth : mesh;
s.use();
if (pass == 0) s.set("uOccluderFade", reflectionPass || !settings->occluderFade ? 0 : 1);
for (const ModelBatch* b : scene.models) {
if (!b || !b->model) continue;
if (pass == 1 && !b->castShadows) continue;
if (reflectionPass && !b->inReflections) continue;
s.set("uWindScale", b->windScale);
for (size_t i = 0; i < b->model->primitives.size(); ++i) {
const ModelMaterial& m = *b->model->primitives[i].material;
s.set("uAlphaMask", m.alphaMask ? 1 : 0);
s.set("uCutoff", m.alphaCutoff);
if (pass == 0) {
s.set("uBaseColor", m.baseColor);
s.set("uFoliage", m.foliage ? 1 : 0);
s.set("uEmissive", m.emissive);
glBindTextureUnit(1, m.emissiveTexture ? m.emissiveTexture->id() : whiteTex);
}
if (m.texture) m.texture->bind(0);
else glBindTextureUnit(0, whiteTex);
if (m.doubleSided || pass == 1) glDisable(GL_CULL_FACE); // shadows: both faces close leaks
else glEnable(GL_CULL_FACE);
b->drawPrimitive(i);
}
}
glDisable(GL_CULL_FACE);
}
};
Renderer::Renderer() : impl_(std::make_unique<Impl>()) {}
Renderer::~Renderer() = default;
bool Renderer::init(const std::string& shaderDir, int width, int height) {
Impl& I = *impl_;
I.dir = shaderDir;
I.settings = &settings;
if (!I.loadShaders()) return false;
glCreateBuffers(1, &I.ubo);
glNamedBufferData(I.ubo, sizeof(FrameUBO), nullptr, GL_DYNAMIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, 0, I.ubo);
glCreateVertexArrays(1, &I.emptyVao);
{
const unsigned char white[4] = {255, 255, 255, 255};
glCreateTextures(GL_TEXTURE_2D, 1, &I.whiteTex);
glTextureStorage2D(I.whiteTex, 1, GL_RGBA8, 1, 1);
glTextureSubImage2D(I.whiteTex, 0, 0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, white);
}
glCreateQueries(GL_TIME_ELAPSED, 2, I.timers);
I.createTargets(width, height);
I.createShadow(settings.shadowMapSize);
return true;
}
void Renderer::resize(int width, int height) {
if (width > 0 && height > 0) impl_->createTargets(width, height);
}
bool Renderer::reloadShaders() {
bool ok = impl_->reloadAll();
HD2D_LOG("shader reload %s", ok ? "ok" : "had errors (kept previous programs)");
return ok;
}
void Renderer::render(const Scene& scene, const Camera& cam, float time) {
Impl& I = *impl_;
const RenderSettings& S = settings;
FrameUBO& f = I.frame;
const int w = I.w, h = I.h, hw = I.volume.width(), hh = I.volume.height();
// GPU timing (read the query from two frames ago to avoid stalls).
int qi = I.timerIdx;
if (I.timerUsed[qi]) {
GLuint64 ns = 0;
glGetQueryObjectui64v(I.timers[qi], GL_QUERY_RESULT, &ns);
gpuMs_ = float(double(ns) * 1e-6);
}
glBeginQuery(GL_TIME_ELAPSED, I.timers[qi]);
if (S.shadowMapSize != I.shadowSize) I.createShadow(S.shadowMapSize);
const float aspect = float(w) / float(h);
const glm::mat4 view = cam.view(), proj = cam.projection(aspect);
const glm::vec3 camPos = cam.position();
const glm::vec3 sunDir = scene.sun.direction();
// Sun shadow frustum follows the camera target, snapped to texels so
// shadows don't shimmer while moving.
const float E = S.shadowExtent;
glm::vec3 up = std::abs(sunDir.y) > 0.99f ? glm::vec3(0, 0, 1) : glm::vec3(0, 1, 0);
glm::mat4 lightView = glm::lookAt(cam.target + sunDir * 80.0f, cam.target, up);
glm::mat4 lightProj = glm::ortho(-E, E, -E, E, 1.0f, 200.0f);
{
glm::vec4 o = lightProj * lightView * glm::vec4(0, 0, 0, 1);
float half = float(I.shadowSize) * 0.5f;
glm::vec2 p = glm::vec2(o) * half;
glm::vec2 off = (glm::round(p) - p) / half;
lightProj[3][0] += off.x;
lightProj[3][1] += off.y;
}
const glm::mat4 lightVP = lightProj * lightView;
glm::vec3 shadowRight(-sunDir.z, 0.0f, sunDir.x);
if (glm::length(shadowRight) < 1e-3f) shadowRight = cam.right();
shadowRight = glm::normalize(shadowRight);
if (glm::dot(shadowRight, cam.right()) < 0.0f) shadowRight = -shadowRight;
f.lightViewProj = lightVP;
f.camPos.w = time;
f.camParams = glm::vec4(cam.nearPlane, cam.farPlane, glm::length(scene.focusPoint - camPos), 0.0f);
f.sunDir = glm::vec4(sunDir, scene.sun.intensity);
f.sunColor = glm::vec4(scene.sun.color, 0.0f);
f.skyZenith = glm::vec4(scene.sky.zenith, scene.sky.ambient);
f.skyHorizon = glm::vec4(scene.sky.horizon, scene.sky.fill);
f.skyGround = glm::vec4(scene.sky.ground, 0.0f);
f.fogColor = glm::vec4(S.fogColor, S.volumetrics ? S.fogDensity : 0.0f);
f.fogParams = glm::vec4(S.fogHeightFalloff, S.fogBaseHeight, S.fogNoise, S.fogAnisotropy);
f.fogParams2 = glm::vec4(S.godRayIntensity, S.fogMaxDistance, float(std::max(4, S.fogSteps)), 0.0f);
glm::vec2 wd = glm::length(S.windDirection) > 1e-4f ? glm::normalize(S.windDirection) : glm::vec2(1, 0);
f.wind = glm::vec4(wd, S.windStrength, S.windGustSpeed);
f.interactor = glm::vec4(scene.interactor, scene.interactorRadius);
f.blob = glm::vec4(scene.blobShadowPos, scene.blobShadowRadius);
f.clipPlane = glm::vec4(0, 0, 0, 1);
f.billboardRight = glm::vec4(cam.right(), 0.0f);
f.shadowRight = glm::vec4(shadowRight, 0.0f);
f.cookie = glm::vec4(S.cookieStrength, S.cookieScale, S.cookieSpeed, S.cookieCoverage);
f.shadowParams = glm::vec4(1.0f / float(I.shadowSize), S.shadowSoftness, 0, 0);
f.shaft = glm::vec4(S.shaftStrength, S.shaftCoverage, S.shaftScale, 0);
f.waterParams = scene.water ? glm::vec4(scene.water->level(), scene.water->causticsIntensity, 1, 0)
: glm::vec4(-1e9f, 0, 0, 0);
f.groundAO = scene.groundOcclusion && S.groundOcclusion ? scene.groundOcclusion->rect() : glm::vec4(0.0f);
f.weather = glm::vec4(scene.rain, scene.wetness, scene.rainSpeed, 0.0f);
glBindTextureUnit(9, scene.groundOcclusion ? scene.groundOcclusion->texture() : I.whiteTex);
int nl = std::min<int>(int(scene.lights.size()), kMaxLights);
f.lightCount = glm::ivec4(nl, std::clamp(scene.volumetricLights, 0, nl), 0, 0);
for (int i = 0; i < nl; ++i) {
f.lightPos[i] = glm::vec4(scene.lights[i].position, scene.lights[i].radius);
f.lightColor[i] = glm::vec4(scene.lights[i].color, 0.0f);
}
glDisable(GL_BLEND);
glDisable(GL_CULL_FACE);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glDepthMask(GL_TRUE);
// ---- 1. Sun shadow map --------------------------------------------------
glBindTextureUnit(8, 0);
I.setCamera(lightView, lightProj, cam.target + sunDir * 80.0f, I.shadowSize, I.shadowSize);
I.upload();
I.shadow.bind();
glClear(GL_DEPTH_BUFFER_BIT);
glEnable(GL_POLYGON_OFFSET_FILL);
glPolygonOffset(1.5f, 3.0f);
if (scene.terrain) {
I.terrainDepth.use();
scene.terrain->draw(); // both faces: closes light leaks at cliff edges
}
I.drawBillboards(scene, 1, false);
I.drawModels(scene, 1, false);
glDisable(GL_POLYGON_OFFSET_FILL);
glBindTextureUnit(8, I.shadow.depth());
// ---- 2. Planar reflection (half res, mirrored about the water plane) ---
const bool doReflection = scene.water && S.reflections;
if (doReflection) {
const float L = scene.water->level();
glm::mat4 R = glm::translate(glm::mat4(1.0f), glm::vec3(0, 2.0f * L, 0)) *
glm::scale(glm::mat4(1.0f), glm::vec3(1, -1, 1));
I.setCamera(view * R, proj, glm::vec3(camPos.x, 2.0f * L - camPos.y, camPos.z), hw, hh);
f.clipPlane = glm::vec4(0, 1, 0, -(L - 0.05f));
I.upload();
I.reflection.bind();
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_CLIP_DISTANCE0);
glFrontFace(GL_CW); // mirroring flips winding
glDisable(GL_DEPTH_TEST);
I.sky.use();
I.fullscreen();
glEnable(GL_DEPTH_TEST);
I.drawTerrain(scene, I.terrain);
I.drawBillboards(scene, 0, true);
I.drawModels(scene, 0, true);
glFrontFace(GL_CCW);
glDisable(GL_CLIP_DISTANCE0);
f.clipPlane = glm::vec4(0, 0, 0, 1);
}
// ---- 3. Main HDR scene ----------------------------------------------
I.setCamera(view, proj, camPos, w, h);
I.upload();
I.hdr.bind();
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glDisable(GL_DEPTH_TEST);
I.sky.use();
I.fullscreen();
glEnable(GL_DEPTH_TEST);
I.drawTerrain(scene, I.terrain);
I.drawModels(scene, 0, false); // opaque models first: early-z for the sprites behind them
I.drawBillboards(scene, 0, false);
// ---- 3b. SSAO (half res) multiplied into the opaque scene ---------------
// Runs before water so the shore and refracted bottom get AO but the water
// surface does not.
if (S.ssao) {
glDisable(GL_DEPTH_TEST);
glm::vec2 halfTexel(1.0f / float(hw), 1.0f / float(hh));
I.ssaoRaw.bind();
I.ssao.use();
I.ssao.set("uRadius", S.ssaoRadius);
I.ssao.set("uIntensity", S.ssaoIntensity);
glBindTextureUnit(0, I.hdr.depth());
I.fullscreen();
I.ssaoBlur.use();
for (int pass = 0; pass < 2; ++pass) { // separable depth-aware blur
(pass == 0 ? I.ssaoBlurred : I.ssaoRaw).bind();
I.ssaoBlur.set("uDir", pass == 0 ? glm::vec2(halfTexel.x, 0) : glm::vec2(0, halfTexel.y));
glBindTextureUnit(0, pass == 0 ? I.ssaoRaw.color() : I.ssaoBlurred.color());
glBindTextureUnit(1, I.hdr.depth());
I.fullscreen();
}
// color *= ao, via blending (no extra copy of the HDR target).
glBindFramebuffer(GL_FRAMEBUFFER, I.hdr.fbo());
glViewport(0, 0, w, h);
glDepthMask(GL_FALSE);
glEnable(GL_BLEND);
glBlendFunc(GL_DST_COLOR, GL_ZERO);
I.ssaoApply.use();
I.ssaoApply.set("uAoTexel", halfTexel);
glBindTextureUnit(0, I.ssaoRaw.color());
glBindTextureUnit(1, I.hdr.depth());
I.fullscreen();
glDisable(GL_BLEND);
glDepthMask(GL_TRUE);
glEnable(GL_DEPTH_TEST);
}
// ---- 4. Water (reads copies of opaque color + depth) -------------------
if (scene.water) {
glBlitNamedFramebuffer(I.hdr.fbo(), I.sceneCopy.fbo(), 0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST);
glBlitNamedFramebuffer(I.hdr.fbo(), I.depthCopy.fbo(), 0, 0, w, h, 0, 0, w, h, GL_DEPTH_BUFFER_BIT, GL_NEAREST);
const Water& wt = *scene.water;
Shader& s = I.water;
s.use();
s.set("uShallow", wt.shallowColor);
s.set("uDeep", wt.deepColor);
s.set("uAbsorb", wt.absorption);
s.set("uClarity", wt.clarity);
s.set("uWaveScale", wt.waveScale);
s.set("uWaveStrength", wt.waveStrength);
s.set("uWaveSpeed", wt.waveSpeed);
s.set("uRefraction", wt.refraction);
s.set("uReflDistort", wt.reflectionDistort);
s.set("uSpecular", wt.specular);
s.set("uFoamWidth", wt.foamWidth);
s.set("uFoamIntensity", wt.foamIntensity);
s.set("uGlitter", wt.glitter);
s.set("uShoreRect", wt.shoreRect());
s.set("uHasReflection", doReflection ? 1 : 0);
glBindTextureUnit(0, I.reflection.color());
glBindTextureUnit(1, I.sceneCopy.color());
glBindTextureUnit(2, I.depthCopy.depth());
glBindTextureUnit(3, wt.shoreTexture());
I.hdr.bind();
wt.draw();
}
glDisable(GL_DEPTH_TEST);
// ---- 5. Volumetric fog / god rays (half res) ---------------------------
if (S.volumetrics) {
I.volume.bind();
I.volumetric.use();
glBindTextureUnit(0, I.hdr.depth());
I.fullscreen();
}
I.post.bind();
I.fogComposite.use();
I.fogComposite.set("uVolumeTexel", glm::vec2(1.0f / float(hw), 1.0f / float(hh)));
I.fogComposite.set("uEnabled", S.volumetrics ? 1 : 0);
glBindTextureUnit(0, I.hdr.color());
glBindTextureUnit(1, I.hdr.depth());
glBindTextureUnit(2, I.volume.color());
I.fullscreen();
// ---- 5b. Rain streaks (procedural, depth tested) -------------------------
if (scene.rain > 0.001f && S.maxRainDrops > 0) {
glBindFramebuffer(GL_FRAMEBUFFER, I.postDepthFbo);
glViewport(0, 0, w, h);
glEnable(GL_DEPTH_TEST);
glDepthMask(GL_FALSE);
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
I.rainStreaks.use();
I.rainStreaks.set("uRainCenter", cam.target);
I.rainStreaks.set("uRainBox", glm::vec2(16.0f, 14.0f));
glBindVertexArray(I.emptyVao);
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, GLsizei(float(S.maxRainDrops) * scene.rain));
glDisable(GL_BLEND);
glDepthMask(GL_TRUE);
glDisable(GL_DEPTH_TEST);
}
// ---- 6. Particles (depth tested, no depth writes) ----------------------
if (scene.particles) {
glBindFramebuffer(GL_FRAMEBUFFER, I.postDepthFbo);
glViewport(0, 0, w, h);
glEnable(GL_DEPTH_TEST);
glDepthMask(GL_FALSE);
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
I.particles.use();
scene.particles->draw();
glDisable(GL_BLEND);
glDepthMask(GL_TRUE);
glDisable(GL_DEPTH_TEST);
}
// ---- 7. Depth of field ---------------------------------------------------
GLuint finalHdr = I.post.color();
if (S.dof) {
glm::vec4 dofParams(f.camParams.z, S.focusRange, S.focusTransition, S.dofMaxRadius);
I.dofHalf.bind();
I.dofPrep.use();
I.dofPrep.set("uDof", dofParams);
I.dofPrep.set("uTiltShift", S.tiltShift);
glBindTextureUnit(0, I.post.color());
glBindTextureUnit(1, I.hdr.depth());
I.fullscreen();
I.dofBlurred.bind();
I.dofBlur.use();
I.dofBlur.set("uMaxRadius", S.dofMaxRadius);
I.dofBlur.set("uTexel", glm::vec2(1.0f / float(hw), 1.0f / float(hh)));
glBindTextureUnit(0, I.dofHalf.color());
I.fullscreen();
I.dofOut.bind();
I.dofComposite.use();
I.dofComposite.set("uDof", dofParams);
I.dofComposite.set("uTiltShift", S.tiltShift);
glBindTextureUnit(0, I.post.color());
glBindTextureUnit(1, I.dofBlurred.color());
glBindTextureUnit(2, I.hdr.depth());
I.fullscreen();
finalHdr = I.dofOut.color();
}
// ---- 8. Bloom ------------------------------------------------------------
if (S.bloom) {
GLuint src = finalHdr;
glm::vec2 srcSize(w, h);
I.bloomDown.use();
for (int i = 0; i < kBloomMips; ++i) {
I.bloom[i].bind();
I.bloomDown.set("uSrcTexel", 1.0f / srcSize);
I.bloomDown.set("uKaris", i == 0 ? 1 : 0);
glBindTextureUnit(0, src);
I.fullscreen();
src = I.bloom[i].color();
srcSize = glm::vec2(I.bloom[i].width(), I.bloom[i].height());
}
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE);
I.bloomUp.use();
I.bloomUp.set("uRadius", S.bloomRadius);
for (int i = kBloomMips - 1; i > 0; --i) {
I.bloom[i - 1].bind();
I.bloomUp.set("uSrcTexel", glm::vec2(1.0f / float(I.bloom[i].width()), 1.0f / float(I.bloom[i].height())));
glBindTextureUnit(0, I.bloom[i].color());
I.fullscreen();
}
glDisable(GL_BLEND);
}
// ---- 9. Tonemap + grade to the backbuffer --------------------------------
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glViewport(0, 0, w, h);
Shader& tm = I.tonemap;
tm.use();
tm.set("uBloomStrength", S.bloom ? S.bloomStrength : 0.0f);
tm.set("uBloomNorm", 1.0f / float(kBloomMips)); // upsample chain sums every mip
tm.set("uExposure", S.exposure);
tm.set("uContrast", S.contrast);
tm.set("uSaturation", S.saturation);
tm.set("uLift", S.lift);
tm.set("uGain", S.gain);
tm.set("uVignette", S.vignette);
tm.set("uChromatic", S.chromaticAberration);
tm.set("uGrain", S.grain);
tm.set("uTimeSeed", std::fmod(time * 61.0f, 1000.0f));
glBindTextureUnit(0, finalHdr);
glBindTextureUnit(1, I.bloom[0].color());
I.fullscreen();
glEndQuery(GL_TIME_ELAPSED);
I.timerUsed[qi] = true;
I.timerIdx = 1 - qi;
}
} // namespace hd2d

31
engine/src/Screenshot.cpp Normal file
View File

@@ -0,0 +1,31 @@
#include "hd2d/Screenshot.h"
#include "hd2d/GL.h"
#include "hd2d/Log.h"
#include <filesystem>
#include <vector>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <stb_image_write.h>
namespace hd2d {
bool saveScreenshot(const std::string& path, int width, int height) {
std::vector<unsigned char> px(size_t(width) * height * 3);
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
glReadBuffer(GL_BACK);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, px.data());
glPixelStorei(GL_PACK_ALIGNMENT, 4);
std::error_code ec;
auto parent = std::filesystem::path(path).parent_path();
if (!parent.empty()) std::filesystem::create_directories(parent, ec);
stbi_flip_vertically_on_write(1);
bool ok = stbi_write_png(path.c_str(), width, height, 3, px.data(), width * 3) != 0;
if (ok) HD2D_LOG("screenshot saved: %s", path.c_str());
else HD2D_ERR("failed to write screenshot '%s'", path.c_str());
return ok;
}
} // namespace hd2d

131
engine/src/Shader.cpp Normal file
View File

@@ -0,0 +1,131 @@
#include "hd2d/Shader.h"
#include "hd2d/Log.h"
#include <filesystem>
#include <fstream>
#include <glm/gtc/type_ptr.hpp>
#include <sstream>
#include <unordered_set>
namespace hd2d {
namespace fs = std::filesystem;
static bool readFile(const fs::path& path, std::string& out) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::stringstream ss;
ss << f.rdbuf();
out = ss.str();
return true;
}
// Expands #include "x" lines recursively; each file is included at most once.
static bool preprocess(const fs::path& path, std::string& out, std::unordered_set<std::string>& seen) {
std::string src;
if (!readFile(path, src)) {
HD2D_ERR("cannot read shader '%s'", path.string().c_str());
return false;
}
std::istringstream in(src);
std::string line;
while (std::getline(in, line)) {
auto pos = line.find("#include");
if (pos != std::string::npos && line.find_first_not_of(" \t") == pos) {
auto a = line.find('"', pos), b = line.rfind('"');
if (a == std::string::npos || b <= a) {
HD2D_ERR("%s: malformed #include", path.string().c_str());
return false;
}
fs::path inc = path.parent_path() / line.substr(a + 1, b - a - 1);
std::string key = fs::weakly_canonical(inc).string();
if (seen.insert(key).second && !preprocess(inc, out, seen)) return false;
out += "\n";
continue;
}
out += line;
out += '\n';
}
return true;
}
static GLuint compile(GLenum type, const std::string& path) {
std::string src;
std::unordered_set<std::string> seen;
if (!preprocess(path, src, seen)) return 0;
GLuint s = glCreateShader(type);
const char* p = src.c_str();
glShaderSource(s, 1, &p, nullptr);
glCompileShader(s);
GLint ok = 0;
glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
if (!ok) {
char log[8192];
glGetShaderInfoLog(s, sizeof log, nullptr, log);
HD2D_ERR("compile '%s':\n%s", path.c_str(), log);
glDeleteShader(s);
return 0;
}
return s;
}
Shader::~Shader() {
if (program_) glDeleteProgram(program_);
}
bool Shader::load(const std::string& vertPath, const std::string& fragPath) {
vertPath_ = vertPath;
fragPath_ = fragPath;
return reload();
}
bool Shader::reload() {
GLuint vs = compile(GL_VERTEX_SHADER, vertPath_);
GLuint fs = compile(GL_FRAGMENT_SHADER, fragPath_);
if (!vs || !fs) {
if (vs) glDeleteShader(vs);
if (fs) glDeleteShader(fs);
return false;
}
GLuint prog = glCreateProgram();
glAttachShader(prog, vs);
glAttachShader(prog, fs);
glLinkProgram(prog);
glDeleteShader(vs);
glDeleteShader(fs);
GLint ok = 0;
glGetProgramiv(prog, GL_LINK_STATUS, &ok);
if (!ok) {
char log[8192];
glGetProgramInfoLog(prog, sizeof log, nullptr, log);
HD2D_ERR("link '%s' + '%s':\n%s", vertPath_.c_str(), fragPath_.c_str(), log);
glDeleteProgram(prog);
return false;
}
if (program_) glDeleteProgram(program_);
program_ = prog;
locations_.clear();
return true;
}
GLint Shader::location(const char* name) {
auto it = locations_.find(name);
if (it != locations_.end()) return it->second;
GLint loc = glGetUniformLocation(program_, name);
locations_.emplace(name, loc);
return loc;
}
void Shader::set(const char* n, int v) { glProgramUniform1i(program_, location(n), v); }
void Shader::set(const char* n, float v) { glProgramUniform1f(program_, location(n), v); }
void Shader::set(const char* n, const glm::vec2& v) { glProgramUniform2fv(program_, location(n), 1, glm::value_ptr(v)); }
void Shader::set(const char* n, const glm::vec3& v) { glProgramUniform3fv(program_, location(n), 1, glm::value_ptr(v)); }
void Shader::set(const char* n, const glm::vec4& v) { glProgramUniform4fv(program_, location(n), 1, glm::value_ptr(v)); }
void Shader::set(const char* n, const glm::ivec2& v) { glProgramUniform2iv(program_, location(n), 1, glm::value_ptr(v)); }
void Shader::set(const char* n, const glm::mat4& v) {
glProgramUniformMatrix4fv(program_, location(n), 1, GL_FALSE, glm::value_ptr(v));
}
void Shader::set(const char* n, const glm::ivec4& v) { glProgramUniform4iv(program_, location(n), 1, glm::value_ptr(v)); }
} // namespace hd2d

150
engine/src/Terrain.cpp Normal file
View File

@@ -0,0 +1,150 @@
#include "hd2d/Terrain.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace hd2d {
namespace {
struct Vertex {
glm::vec3 pos;
glm::vec3 normal;
glm::vec2 uv;
float layer; // texture layer (side faces only; top faces resolve in shader)
float ao;
float kind; // 0 = top, 1 = side, 2 + n = side with an overhang lip from layer n
};
uint32_t hash3(int x, int y, int z) {
uint32_t h = uint32_t(x) * 73856093u ^ uint32_t(y) * 19349663u ^ uint32_t(z) * 83492791u;
h ^= h >> 13;
h *= 0x5bd1e995u;
h ^= h >> 15;
return h;
}
} // namespace
void Terrain::init(int width, int depth, float levelHeight) {
w_ = width;
d_ = depth;
levelHeight_ = levelHeight;
levels_.assign(size_t(w_) * d_, 0);
mats_.assign(size_t(w_) * d_, 0);
}
int Terrain::level(int x, int z) const { return inBounds(x, z) ? levels_[size_t(z) * w_ + x] : baseLevel; }
uint8_t Terrain::material(int x, int z) const { return inBounds(x, z) ? mats_[size_t(z) * w_ + x] : 0; }
void Terrain::setLevel(int x, int z, int l) {
if (inBounds(x, z)) levels_[size_t(z) * w_ + x] = l;
}
void Terrain::setMaterial(int x, int z, uint8_t m) {
if (inBounds(x, z)) mats_[size_t(z) * w_ + x] = m;
}
float Terrain::heightAt(float x, float z) const {
return float(level(int(std::floor(x)), int(std::floor(z)))) * levelHeight_;
}
void Terrain::build() {
std::vector<Vertex> verts;
std::vector<uint32_t> idx;
verts.reserve(size_t(w_) * d_ * 8);
auto quad = [&](const Vertex& a, const Vertex& b, const Vertex& c, const Vertex& d, bool flip) {
uint32_t base = uint32_t(verts.size());
verts.insert(verts.end(), {a, b, c, d});
// Flip the diagonal so AO interpolates without anisotropy artifacts.
if (flip) idx.insert(idx.end(), {base + 1, base + 2, base + 3, base + 1, base + 3, base + 0});
else idx.insert(idx.end(), {base, base + 1, base + 2, base, base + 2, base + 3});
};
const int dirs[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int z = 0; z < d_; ++z)
for (int x = 0; x < w_; ++x) {
const int L = level(x, z);
const float top = float(L) * levelHeight_;
const TerrainMaterial& mat = materials[std::min<int>(material(x, z), kMaxMaterials - 1)];
// Top face with per-corner ambient occlusion from higher neighbours.
auto higher = [&](int nx, int nz) { return inBounds(nx, nz) && level(nx, nz) > L ? 1 : 0; };
auto cornerAO = [&](int cx, int cz) {
int sx = cx ? 1 : -1, sz = cz ? 1 : -1;
int s1 = higher(x + sx, z), s2 = higher(x, z + sz), c = higher(x + sx, z + sz);
int ao = (s1 && s2) ? 0 : 3 - (s1 + s2 + c);
return 0.45f + 0.55f * float(ao) / 3.0f;
};
Vertex v0{{x, top, z}, {0, 1, 0}, {x, z}, 0, cornerAO(0, 0), 0};
Vertex v1{{x, top, z + 1}, {0, 1, 0}, {x, z + 1}, 0, cornerAO(0, 1), 0};
Vertex v2{{x + 1, top, z + 1}, {0, 1, 0}, {x + 1, z + 1}, 0, cornerAO(1, 1), 0};
Vertex v3{{x + 1, top, z}, {0, 1, 0}, {x + 1, z}, 0, cornerAO(1, 0), 0};
quad(v0, v1, v2, v3, v0.ao + v2.ao < v1.ao + v3.ao);
// Cliff faces toward lower neighbours (or the diorama base at the border).
for (int dIdx = 0; dIdx < 4; ++dIdx) {
int nx = x + dirs[dIdx][0], nz = z + dirs[dIdx][1];
int nl = level(nx, nz);
if (nl >= L) continue;
float bottom = float(nl) * levelHeight_;
glm::vec3 n(float(dirs[dIdx][0]), 0, float(dirs[dIdx][1]));
glm::vec3 bl, br;
switch (dIdx) {
case 0: bl = {x + 1, bottom, z + 1}, br = {x + 1, bottom, z}; break;
case 1: bl = {x, bottom, z}, br = {x, bottom, z + 1}; break;
case 2: bl = {x, bottom, z + 1}, br = {x + 1, bottom, z + 1}; break;
default: bl = {x + 1, bottom, z}, br = {x, bottom, z}; break;
}
glm::vec3 rightDir = glm::normalize(br - bl);
float uL = glm::dot(bl, rightDir), uR = glm::dot(br, rightDir);
float layer = float(mat.sideFirst + int(hash3(x, z, dIdx) % uint32_t(std::max(1, mat.sideCount))));
float kind = mat.lipLayer >= 0 ? 2.0f + float(mat.lipLayer) : 1.0f;
float height = top - bottom;
bool groundBelow = inBounds(nx, nz);
float aoBottom = groundBelow ? 0.55f : 0.8f;
Vertex a{bl, n, {uL, height}, layer, aoBottom, kind};
Vertex b{br, n, {uR, height}, layer, aoBottom, kind};
Vertex c{{br.x, top, br.z}, n, {uR, 0}, layer, 1.0f, kind};
Vertex d{{bl.x, top, bl.z}, n, {uL, 0}, layer, 1.0f, kind};
quad(a, b, c, d, false);
}
}
if (!vao_) {
glCreateVertexArrays(1, &vao_);
glCreateBuffers(1, &vbo_);
glCreateBuffers(1, &ibo_);
}
glNamedBufferData(vbo_, GLsizeiptr(verts.size() * sizeof(Vertex)), verts.data(), GL_STATIC_DRAW);
glNamedBufferData(ibo_, GLsizeiptr(idx.size() * sizeof(uint32_t)), idx.data(), GL_STATIC_DRAW);
indexCount_ = GLsizei(idx.size());
glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, sizeof(Vertex));
glVertexArrayElementBuffer(vao_, ibo_);
auto attr = [&](GLuint loc, GLint n, size_t offset) {
glEnableVertexArrayAttrib(vao_, loc);
glVertexArrayAttribFormat(vao_, loc, n, GL_FLOAT, GL_FALSE, GLuint(offset));
glVertexArrayAttribBinding(vao_, loc, 0);
};
attr(0, 3, offsetof(Vertex, pos));
attr(1, 3, offsetof(Vertex, normal));
attr(2, 2, offsetof(Vertex, uv));
attr(3, 3, offsetof(Vertex, layer)); // layer, ao, kind
if (matTex_) glDeleteTextures(1, &matTex_);
glCreateTextures(GL_TEXTURE_2D, 1, &matTex_);
glTextureStorage2D(matTex_, 1, GL_R8UI, w_, d_);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTextureSubImage2D(matTex_, 0, 0, 0, w_, d_, GL_RED_INTEGER, GL_UNSIGNED_BYTE, mats_.data());
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glTextureParameteri(matTex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTextureParameteri(matTex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
}
void Terrain::draw() const {
glBindVertexArray(vao_);
glDrawElements(GL_TRIANGLES, indexCount_, GL_UNSIGNED_INT, nullptr);
}
} // namespace hd2d

146
engine/src/Texture.cpp Normal file
View File

@@ -0,0 +1,146 @@
#include "hd2d/Texture.h"
#include "hd2d/Log.h"
#include <algorithm>
#include <cmath>
#define STB_IMAGE_IMPLEMENTATION
#define STBI_ONLY_PNG
#include <stb_image.h>
namespace hd2d {
static void bleedTransparentColors(Image& img) {
const int w = img.width, h = img.height;
std::vector<unsigned char> known(size_t(w) * h);
for (int i = 0; i < w * h; ++i) known[i] = img.rgba[i * 4 + 3] > 0;
// A few dilation rounds are enough to cover any filter footprint.
for (int pass = 0; pass < 8; ++pass) {
std::vector<unsigned char> next = known;
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x) {
int i = y * w + x;
if (known[i]) continue;
int r = 0, g = 0, b = 0, n = 0;
for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx) {
int nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
int j = ny * w + nx;
if (!known[j]) continue;
r += img.rgba[j * 4], g += img.rgba[j * 4 + 1], b += img.rgba[j * 4 + 2], ++n;
}
if (n) {
img.rgba[i * 4] = (unsigned char)(r / n);
img.rgba[i * 4 + 1] = (unsigned char)(g / n);
img.rgba[i * 4 + 2] = (unsigned char)(b / n);
next[i] = 1;
}
}
known.swap(next);
}
}
static bool finishImage(unsigned char* data, Image& out, const char* what) {
if (!data) {
HD2D_ERR("cannot load image '%s': %s", what, stbi_failure_reason());
return false;
}
out.rgba.assign(data, data + size_t(out.width) * out.height * 4);
stbi_image_free(data);
bleedTransparentColors(out);
return true;
}
bool loadImage(const std::string& path, Image& out) {
stbi_set_flip_vertically_on_load(1);
int n = 0;
return finishImage(stbi_load(path.c_str(), &out.width, &out.height, &n, 4), out, path.c_str());
}
bool decodeImage(const unsigned char* data, size_t size, Image& out, bool flipY) {
stbi_set_flip_vertically_on_load(flipY ? 1 : 0);
int n = 0;
return finishImage(stbi_load_from_memory(data, int(size), &out.width, &out.height, &n, 4), out, "<memory>");
}
static int mipLevels(int w, int h) { return 1 + int(std::floor(std::log2(float(std::max(w, h))))); }
Texture& Texture::operator=(Texture&& o) noexcept {
std::swap(id_, o.id_);
width_ = o.width_;
height_ = o.height_;
opaqueBounds_ = o.opaqueBounds_;
return *this;
}
Texture::~Texture() {
if (id_) glDeleteTextures(1, &id_);
}
bool Texture::load(const std::string& path, bool repeat) {
Image img;
return loadImage(path, img) && create(img, repeat);
}
bool Texture::create(const Image& img, bool repeat, float anisotropy) {
width_ = img.width;
height_ = img.height;
int minX = width_, minY = height_, maxX = -1, maxY = -1;
for (int y = 0; y < height_; ++y)
for (int x = 0; x < width_; ++x)
if (img.rgba[(size_t(y) * width_ + x) * 4 + 3] >= 128) {
minX = std::min(minX, x), maxX = std::max(maxX, x);
minY = std::min(minY, y), maxY = std::max(maxY, y);
}
if (maxX >= 0)
opaqueBounds_ = {float(minX) / width_, float(minY) / height_, float(maxX + 1) / width_,
float(maxY + 1) / height_};
if (id_) glDeleteTextures(1, &id_);
glCreateTextures(GL_TEXTURE_2D, 1, &id_);
glTextureStorage2D(id_, mipLevels(width_, height_), GL_SRGB8_ALPHA8, width_, height_);
glTextureSubImage2D(id_, 0, 0, 0, width_, height_, GL_RGBA, GL_UNSIGNED_BYTE, img.rgba.data());
glGenerateTextureMipmap(id_);
glTextureParameteri(id_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTextureParameteri(id_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
GLint wrap = repeat ? GL_REPEAT : GL_CLAMP_TO_EDGE;
glTextureParameteri(id_, GL_TEXTURE_WRAP_S, wrap);
glTextureParameteri(id_, GL_TEXTURE_WRAP_T, wrap);
if (anisotropy > 1.0f) glTextureParameterf(id_, GL_TEXTURE_MAX_ANISOTROPY, anisotropy);
return true;
}
TextureArray::~TextureArray() {
if (id_) glDeleteTextures(1, &id_);
}
bool TextureArray::load(const std::vector<std::string>& paths) {
std::vector<Image> images(paths.size());
for (size_t i = 0; i < paths.size(); ++i) {
if (!loadImage(paths[i], images[i])) return false;
if (images[i].width != images[0].width || images[i].height != images[0].height) {
HD2D_ERR("texture array layer '%s' size mismatch", paths[i].c_str());
return false;
}
}
width_ = images[0].width;
height_ = images[0].height;
layers_ = int(images.size());
if (id_) glDeleteTextures(1, &id_);
glCreateTextures(GL_TEXTURE_2D_ARRAY, 1, &id_);
glTextureStorage3D(id_, mipLevels(width_, height_), GL_SRGB8_ALPHA8, width_, height_, layers_);
for (int i = 0; i < layers_; ++i)
glTextureSubImage3D(id_, 0, 0, 0, i, width_, height_, 1, GL_RGBA, GL_UNSIGNED_BYTE, images[i].rgba.data());
glGenerateTextureMipmap(id_);
glTextureParameteri(id_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTextureParameteri(id_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTextureParameteri(id_, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTextureParameteri(id_, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTextureParameterf(id_, GL_TEXTURE_MAX_ANISOTROPY, 8.0f);
return true;
}
} // namespace hd2d

94
engine/src/Water.cpp Normal file
View File

@@ -0,0 +1,94 @@
#include "hd2d/Water.h"
#include "hd2d/Terrain.h"
#include <algorithm>
#include <cmath>
#include <vector>
namespace hd2d {
Water::~Water() {
if (vbo_) glDeleteBuffers(1, &vbo_);
if (ibo_) glDeleteBuffers(1, &ibo_);
if (vao_) glDeleteVertexArrays(1, &vao_);
if (shoreTex_) glDeleteTextures(1, &shoreTex_);
}
void Water::buildShoreField(const Terrain& terrain, int texelsPerUnit) {
constexpr float kMaxDist = 4.0f;
int tw = std::max(1, int(std::ceil((max_.x - min_.x) * float(texelsPerUnit))));
int th = std::max(1, int(std::ceil((max_.y - min_.y) * float(texelsPerUnit))));
// Land tiles (top above the water) that can influence the field.
std::vector<glm::vec2> land;
int x0 = int(std::floor(min_.x - kMaxDist)), x1 = int(std::ceil(max_.x + kMaxDist));
int z0 = int(std::floor(min_.y - kMaxDist)), z1 = int(std::ceil(max_.y + kMaxDist));
for (int z = z0; z <= z1; ++z)
for (int x = x0; x <= x1; ++x)
if (terrain.inBounds(x, z) && float(terrain.level(x, z)) * terrain.levelHeight() > level_)
land.emplace_back(float(x), float(z));
std::vector<float> dist(size_t(tw) * th, kMaxDist);
for (int j = 0; j < th; ++j)
for (int i = 0; i < tw; ++i) {
glm::vec2 p(min_.x + (float(i) + 0.5f) / float(texelsPerUnit), min_.y + (float(j) + 0.5f) / float(texelsPerUnit));
float best = kMaxDist;
for (const glm::vec2& t : land) { // distance to the tile's unit square
glm::vec2 d = glm::max(glm::max(t - p, p - (t + 1.0f)), glm::vec2(0.0f));
best = std::min(best, glm::length(d));
}
dist[size_t(j) * tw + i] = best;
}
if (shoreTex_) glDeleteTextures(1, &shoreTex_);
glCreateTextures(GL_TEXTURE_2D, 1, &shoreTex_);
glTextureStorage2D(shoreTex_, 1, GL_R16F, tw, th);
glTextureSubImage2D(shoreTex_, 0, 0, 0, tw, th, GL_RED, GL_FLOAT, dist.data());
glTextureParameteri(shoreTex_, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTextureParameteri(shoreTex_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTextureParameteri(shoreTex_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTextureParameteri(shoreTex_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
void Water::build(glm::vec2 minXZ, glm::vec2 maxXZ, float level, float cellSize) {
level_ = level;
min_ = minXZ;
max_ = maxXZ;
int nx = std::max(1, int(std::ceil((maxXZ.x - minXZ.x) / cellSize)));
int nz = std::max(1, int(std::ceil((maxXZ.y - minXZ.y) / cellSize)));
std::vector<glm::vec3> verts;
verts.reserve(size_t(nx + 1) * (nz + 1));
for (int z = 0; z <= nz; ++z)
for (int x = 0; x <= nx; ++x)
verts.emplace_back(minXZ.x + (maxXZ.x - minXZ.x) * float(x) / float(nx), level,
minXZ.y + (maxXZ.y - minXZ.y) * float(z) / float(nz));
std::vector<uint32_t> idx;
idx.reserve(size_t(nx) * nz * 6);
for (int z = 0; z < nz; ++z)
for (int x = 0; x < nx; ++x) {
uint32_t a = uint32_t(z * (nx + 1) + x), b = a + 1, c = a + uint32_t(nx + 1), d = c + 1;
idx.insert(idx.end(), {a, c, d, a, d, b}); // CCW seen from above
}
if (!vao_) {
glCreateVertexArrays(1, &vao_);
glCreateBuffers(1, &vbo_);
glCreateBuffers(1, &ibo_);
}
glNamedBufferData(vbo_, GLsizeiptr(verts.size() * sizeof(glm::vec3)), verts.data(), GL_STATIC_DRAW);
glNamedBufferData(ibo_, GLsizeiptr(idx.size() * sizeof(uint32_t)), idx.data(), GL_STATIC_DRAW);
indexCount_ = GLsizei(idx.size());
glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, sizeof(glm::vec3));
glVertexArrayElementBuffer(vao_, ibo_);
glEnableVertexArrayAttrib(vao_, 0);
glVertexArrayAttribFormat(vao_, 0, 3, GL_FLOAT, GL_FALSE, 0);
glVertexArrayAttribBinding(vao_, 0, 0);
}
void Water::draw() const {
glBindVertexArray(vao_);
glDrawElements(GL_TRIANGLES, indexCount_, GL_UNSIGNED_INT, nullptr);
}
} // namespace hd2d

135
engine/src/Weather.cpp Normal file
View File

@@ -0,0 +1,135 @@
#include "hd2d/Weather.h"
#include "hd2d/Renderer.h"
#include <algorithm>
#include <cmath>
namespace hd2d {
Weather::Weather() {
// Heavy overcast rain: weak grey sun, flat cool ambient, thick low fog,
// almost no god rays, strong gusty wind.
storm.sunIntensity = 0.7f;
storm.sunColor = {0.78f, 0.82f, 0.9f};
storm.zenith = {0.30f, 0.34f, 0.40f};
storm.horizon = {0.42f, 0.45f, 0.50f};
storm.ground = {0.13f, 0.15f, 0.13f};
storm.ambient = 0.62f;
storm.fogColor = {0.30f, 0.33f, 0.38f};
storm.fogDensity = 0.03f;
storm.fogHeightFalloff = 0.22f;
storm.godRays = 0.6f;
storm.shaftStrength = 0.2f;
storm.cookieStrength = 0.0f;
storm.windStrength = 0.2f;
storm.windGustSpeed = 3.0f;
storm.bloomStrength = 0.06f;
storm.saturation = 0.82f;
}
WeatherLook Weather::capture(const Scene& s, const RenderSettings& r) {
WeatherLook l;
l.sunIntensity = s.sun.intensity;
l.sunColor = s.sun.color;
l.zenith = s.sky.zenith;
l.horizon = s.sky.horizon;
l.ground = s.sky.ground;
l.ambient = s.sky.ambient;
l.fogColor = r.fogColor;
l.fogDensity = r.fogDensity;
l.fogHeightFalloff = r.fogHeightFalloff;
l.godRays = r.godRayIntensity;
l.shaftStrength = r.shaftStrength;
l.cookieStrength = r.cookieStrength;
l.windStrength = r.windStrength;
l.windGustSpeed = r.windGustSpeed;
l.bloomStrength = r.bloomStrength;
l.saturation = r.saturation;
return l;
}
void Weather::apply(const WeatherLook& l, Scene& s, RenderSettings& r) {
s.sun.intensity = l.sunIntensity;
s.sun.color = l.sunColor;
s.sky.zenith = l.zenith;
s.sky.horizon = l.horizon;
s.sky.ground = l.ground;
s.sky.ambient = l.ambient;
r.fogColor = l.fogColor;
r.fogDensity = l.fogDensity;
r.fogHeightFalloff = l.fogHeightFalloff;
r.godRayIntensity = l.godRays;
r.shaftStrength = l.shaftStrength;
r.cookieStrength = l.cookieStrength;
r.windStrength = l.windStrength;
r.windGustSpeed = l.windGustSpeed;
r.bloomStrength = l.bloomStrength;
r.saturation = l.saturation;
}
static WeatherLook mixLook(const WeatherLook& a, const WeatherLook& b, float t) {
WeatherLook o;
auto m = [t](auto x, auto y) { return x + (y - x) * t; };
o.sunIntensity = m(a.sunIntensity, b.sunIntensity);
o.sunColor = m(a.sunColor, b.sunColor);
o.zenith = m(a.zenith, b.zenith);
o.horizon = m(a.horizon, b.horizon);
o.ground = m(a.ground, b.ground);
o.ambient = m(a.ambient, b.ambient);
o.fogColor = m(a.fogColor, b.fogColor);
o.fogDensity = m(a.fogDensity, b.fogDensity);
o.fogHeightFalloff = m(a.fogHeightFalloff, b.fogHeightFalloff);
o.godRays = m(a.godRays, b.godRays);
o.shaftStrength = m(a.shaftStrength, b.shaftStrength);
o.cookieStrength = m(a.cookieStrength, b.cookieStrength);
o.windStrength = m(a.windStrength, b.windStrength);
o.windGustSpeed = m(a.windGustSpeed, b.windGustSpeed);
o.bloomStrength = m(a.bloomStrength, b.bloomStrength);
o.saturation = m(a.saturation, b.saturation);
return o;
}
void Weather::update(float dt, Scene& scene, RenderSettings& settings) {
// While fully clear, keep tracking the live look so UI tweaks survive.
if (rain_ <= 0.0f && targetRain_ <= 0.0f) {
clear_ = capture(scene, settings);
haveClear_ = true;
} else if (!haveClear_) {
clear_ = capture(scene, settings);
haveClear_ = true;
}
float step = dt / std::max(transitionSeconds, 1e-3f);
rain_ = rain_ < targetRain_ ? std::min(targetRain_, rain_ + step) : std::max(targetRain_, rain_ - step);
// Surfaces soak quickly and dry slowly.
float wetRate = wetness_ < rain_ ? step * 1.5f : step * 0.3f;
wetness_ = wetness_ < rain_ ? std::min(rain_, wetness_ + wetRate) : std::max(rain_, wetness_ - wetRate);
float k = rain_ * rain_ * (3.0f - 2.0f * rain_); // smoothstep
if (rain_ > 0.0f || targetRain_ > 0.0f) apply(mixLook(clear_, storm, k), scene, settings);
scene.rain = rain_;
scene.wetness = wetness_;
if (scene.particles) {
ParticleSystem& ps = *scene.particles;
ps.ambientFade = 1.0f - k;
glm::vec2 wd = glm::length(settings.windDirection) > 1e-4f ? glm::normalize(settings.windDirection)
: glm::vec2(1, 0);
ps.wind = glm::vec3(wd.x, 0.0f, wd.y) * settings.windStrength * 10.0f;
// Ground splashes around the focus point (water gets ripples instead).
splashAccum_ += splashesPerSecond * rain_ * dt;
std::uniform_real_distribution<float> u(-1.0f, 1.0f);
glm::vec3 color = (scene.sky.horizon * 0.8f + glm::vec3(0.15f)) * scene.sky.ambient * 1.6f;
while (splashAccum_ >= 1.0f) {
splashAccum_ -= 1.0f;
glm::vec2 p = glm::vec2(scene.focusPoint.x, scene.focusPoint.z) + glm::vec2(u(rng_), u(rng_)) * splashRadius;
if (!scene.terrain) continue;
float y = scene.terrain->heightAt(p.x, p.y);
if (scene.water && y < scene.water->level()) continue;
ps.burst({p.x, y + 0.02f, p.y}, 2, color, 0.9f, 0.022f, 0.28f);
}
}
}
} // namespace hd2d

97
engine/src/Window.cpp Normal file
View File

@@ -0,0 +1,97 @@
#include "hd2d/Window.h"
#include "hd2d/GL.h"
#include "hd2d/Log.h"
#include <GLFW/glfw3.h>
#include <algorithm>
#include <iterator>
namespace hd2d {
static void APIENTRY glDebugCallback(GLenum, GLenum type, GLuint, GLenum severity, GLsizei,
const GLchar* message, const void*) {
if (severity == GL_DEBUG_SEVERITY_NOTIFICATION) return;
if (type == GL_DEBUG_TYPE_ERROR || severity == GL_DEBUG_SEVERITY_HIGH)
HD2D_ERR("GL: %s", message);
}
bool Window::create(int width, int height, const std::string& title, bool vsync, bool resizable) {
glfwSetErrorCallback([](int code, const char* msg) { HD2D_ERR("GLFW %d: %s", code, msg); });
if (!glfwInit()) return false;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
glfwWindowHint(GLFW_RESIZABLE, resizable ? GLFW_TRUE : GLFW_FALSE);
#ifndef NDEBUG
glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GLFW_TRUE);
#endif
window_ = glfwCreateWindow(width, height, title.c_str(), nullptr, nullptr);
if (!window_) {
glfwTerminate();
return false;
}
glfwMakeContextCurrent(window_);
glfwSwapInterval(vsync ? 1 : 0);
glfwSetWindowUserPointer(window_, this);
glfwSetFramebufferSizeCallback(window_, onFramebufferSize);
glfwSetScrollCallback(window_, onScroll);
glfwGetFramebufferSize(window_, &width_, &height_);
if (epoxy_has_gl_extension("GL_KHR_debug") || epoxy_gl_version() >= 43) {
glEnable(GL_DEBUG_OUTPUT);
glEnable(GL_DEBUG_OUTPUT_SYNCHRONOUS);
glDebugMessageCallback(glDebugCallback, nullptr);
}
HD2D_LOG("OpenGL %s | %s", glGetString(GL_VERSION), glGetString(GL_RENDERER));
double mx, my;
glfwGetCursorPos(window_, &mx, &my);
lastMouse_ = {float(mx), float(my)};
return true;
}
void Window::destroy() {
if (window_) glfwDestroyWindow(window_);
window_ = nullptr;
glfwTerminate();
}
bool Window::pollEvents() {
resized_ = false;
std::copy(std::begin(keys_), std::end(keys_), std::begin(prevKeys_));
glfwPollEvents();
for (int k = GLFW_KEY_SPACE; k <= GLFW_KEY_LAST && k < 512; ++k)
keys_[k] = glfwGetKey(window_, k) == GLFW_PRESS;
double mx, my;
glfwGetCursorPos(window_, &mx, &my);
glm::vec2 m{float(mx), float(my)};
mouseDelta_ = m - lastMouse_;
lastMouse_ = m;
scrollDelta_ = scrollAccum_;
scrollAccum_ = 0.0f;
return !glfwWindowShouldClose(window_);
}
void Window::swapBuffers() { glfwSwapBuffers(window_); }
bool Window::keyDown(int key) const { return key >= 0 && key < 512 && keys_[key]; }
bool Window::keyPressed(int key) const { return keyDown(key) && !prevKeys_[key]; }
bool Window::mouseDown(int button) const { return glfwGetMouseButton(window_, button) == GLFW_PRESS; }
void Window::setTitle(const std::string& title) { glfwSetWindowTitle(window_, title.c_str()); }
void Window::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); }
void Window::onFramebufferSize(GLFWwindow* w, int width, int height) {
auto* self = static_cast<Window*>(glfwGetWindowUserPointer(w));
self->width_ = width;
self->height_ = height;
self->resized_ = true;
}
void Window::onScroll(GLFWwindow* w, double, double dy) {
static_cast<Window*>(glfwGetWindowUserPointer(w))->scrollAccum_ += float(dy);
}
} // namespace hd2d