Sprite sheets, atlases, game UI, time of day, weather presets, headless captures

- BillboardInstance: per-instance uv sub-rect, feet anchor, turn, dithered alpha, flash;
  BillboardBatch::sharedTexture; Texture createEmpty/update/cellUV/cellAnchor/nearest
- SpriteAtlas: fixed-cell runtime atlas with gutters
- hd2d::ui: Octopath-style panels, fonts (Cinzel, EB Garamond), dialogue box with
  typewriter + portraits + choices, menus, nameplates, toasts, ImGui theme
- DayCycle (dawn/day/dusk/night keyframes, sun + moon arcs), Weather overcast/mist/
  lightning, night-aware presets, stars and moon in the sky
- Hidden-window offscreen captures (Window visible=false, Renderer output FBO,
  OffscreenTarget); --screenshot never opens a window
- Camera::worldToScreen; CMake usable via add_subdirectory (HD2D_BUILD_DEMO,
  HD2D_ENGINE_ASSET_DIR)
This commit is contained in:
Gabrieal Jimmy
2026-10-02 15:05:23 -05:00
parent 16c03afd16
commit 83a1f16806
44 changed files with 2835 additions and 226 deletions

View File

@@ -7,17 +7,29 @@
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.
// One upright pixel-art sprite. `pos` is where the feet (pivot) row touches
// the ground; sprites face the camera around the Y axis. 64 bytes, read by
// billboard.vert as four vec4 attributes (keep both in sync).
//
// A default-initialised instance draws the whole texture, planted by its
// lowest opaque row. Set `uv` to draw one frame of a sprite sheet / atlas
// cell (see Texture::cellUV, SpriteAtlas::uv) and `anchor` to that frame's
// feet row (Texture::cellAnchor, SpriteAtlas::anchor).
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)
float sway = 0.0f; // wind / player-push response (0 = rigid)
glm::vec2 size{1.0f}; // world size of the `uv` sub-rect quad
float flip = 0.0f; // 1 = mirror horizontally (inside the sub-rect)
float tint = 1.0f; // brightness multiplier (breaks up repetition)
glm::vec4 uv{0, 0, 1, 1}; // texture sub-rect (minU, minV, maxU, maxV), GL orientation (v = 0 is the image bottom)
float anchor = -1.0f; // texture-space v of the feet (pivot) row; < 0 = use tex().opaqueBounds().y
float turn = 0.0f; // extra yaw (radians) away from camera-facing; color pass only, shadow cards stay sun-facing
float alpha = 1.0f; // 0..1 fade, dithered discard in color and shadow passes (no blending)
float flash = 0.0f; // 0..1 blend toward bright emissive white (hit / capture flash)
};
// Instanced batch of billboards sharing one texture.
// Instanced batch of billboards sharing one texture: its own `texture`, or
// `sharedTexture` (e.g. a SpriteAtlas page shared by many batches) when set.
class BillboardBatch {
public:
BillboardBatch() = default;
@@ -29,12 +41,20 @@ public:
void upload(); // call after editing `instances`
void draw() const;
// World size that keeps the texture at `pixelsPerUnit` texel density.
// Texture the renderer binds: `sharedTexture` if set, else `texture`.
const Texture& tex() const { return sharedTexture ? *sharedTexture : texture; }
// World size that keeps the own `texture` at `pixelsPerUnit` texel density.
glm::vec2 sizeForDensity(float pixelsPerUnit) const {
return glm::vec2(texture.size()) / pixelsPerUnit;
}
// World size of one `cellPixels` sheet frame / atlas cell at `pixelsPerUnit`.
static glm::vec2 cellSize(glm::ivec2 cellPixels, float pixelsPerUnit) {
return glm::vec2(cellPixels) / pixelsPerUnit;
}
Texture texture;
const Texture* sharedTexture = nullptr; // not owned; must outlive the batch's use
std::vector<BillboardInstance> instances;
bool castShadows = true;
bool inReflections = true;

View File

@@ -19,6 +19,10 @@ struct Camera {
glm::vec3 right() const; // horizontal right vector
glm::mat4 view() const;
glm::mat4 projection(float aspect) const;
// Projects `world` to window pixels (top-left origin) of a `viewportPx`
// viewport, using view() and projection(viewportPx.x / viewportPx.y).
// Returns false (outPx untouched) when the point is behind the camera.
bool worldToScreen(const glm::vec3& world, glm::vec2 viewportPx, glm::vec2& outPx) const;
};
} // namespace hd2d

View File

@@ -0,0 +1,44 @@
#pragma once
#include "hd2d/Weather.h"
#include <glm/glm.hpp>
#include <vector>
namespace hd2d {
// Time of day: turns an hour into a complete clear-weather WeatherLook by
// smoothly interpolating tweakable keyframes, and moves the directional
// light along a sun arc by day and a moon arc by night (so shadows always
// work). Feed the result to Weather::setClearLook() every frame; the hour is
// usually driven externally (e.g. by a game server).
class DayCycle {
public:
struct Keyframe {
float hour; // [0, 24)
const char* name; // for tuning UIs
WeatherLook look; // sunAzimuth / sunElevation / daylight are ignored (computed from the hour)
};
DayCycle(); // default keys: late night, dawn, morning, noon, afternoon, dusk, night
WeatherLook look(float hour) const; // any hour, wraps to [0, 24)
float daylight(float hour) const; // 0 night .. 1 day (sun height above the horizon)
float night(float hour) const; // 0 day .. 1 night (stars + moon visibility)
// Direction of the directional light: x = azimuth, y = elevation
// (radians, see Sun); the sun between sunrise and sunset, else the moon.
glm::vec2 lightAngles(float hour) const;
bool moonUp(float hour) const;
std::vector<Keyframe> keys; // sorted by hour; interpolation wraps around midnight
float sunrise = 5.0f, sunset = 19.0f;
float sunAzimuth = -0.6f; // azimuth at solar noon
float sunSweep = 2.6f; // azimuth travelled from sunrise to sunset
float sunMaxElevation = 1.05f;
float moonAzimuth = 0.45f; // azimuth at the middle of the night
float moonSweep = 1.6f;
float moonMaxElevation = 0.62f;
float horizonFade = 0.12f; // light elevation (radians) over which it fades in
float minElevation = 0.05f; // light never goes lower (keeps shadows stable)
};
} // namespace hd2d

View File

@@ -6,6 +6,7 @@ class Window;
struct RenderSettings;
struct Scene;
class Weather;
class DayCycle;
// Dear ImGui integration plus a ready-made tuning panel for the renderer.
class DebugUI {
@@ -19,8 +20,9 @@ public:
// 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);
// Rain / overcast / fog / lightning, transition time and the weather
// presets; with `hour` an hour slider, with `day` its keyframes.
static void weatherPanel(Weather& weather, DayCycle* day = nullptr, float* hour = nullptr);
private:
bool initialized_ = false;

View File

@@ -54,7 +54,8 @@ public:
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)
float ambientFade = 1.0f; // scales fireflies + motes (Weather lowers it in rain / mist)
float fireflyGlow = 1.0f; // firefly brightness + light (WeatherLook::fireflyGlow, raised at night)
private:
struct Particle {

View File

@@ -88,6 +88,15 @@ public:
bool reloadShaders();
void render(const Scene& scene, const Camera& camera, float time);
// Framebuffer the final tonemap pass writes to (0 = the window's
// backbuffer). render() leaves it bound with a full-size viewport, so UI
// drawn afterwards (DebugUI, ImGui) lands in it too. It must be at least
// the size given to init()/resize() and use a non-sRGB RGBA8 color
// attachment like the default framebuffer (the tonemap shader
// gamma-encodes itself); OffscreenTarget in Screenshot.h does this.
void setOutputFramebuffer(GLuint fbo) { outputFbo_ = fbo; }
GLuint outputFramebuffer() const { return outputFbo_; }
RenderSettings settings;
float gpuFrameMs() const { return gpuMs_; }
@@ -95,6 +104,7 @@ private:
struct Impl;
std::unique_ptr<Impl> impl_;
float gpuMs_ = 0.0f;
GLuint outputFbo_ = 0;
};
} // namespace hd2d

View File

@@ -31,6 +31,8 @@ struct Sky {
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
float night = 0.0f; // 0..1: star field + moon disc at the sun direction (the sun acts as the moon)
float clouds = 0.0f; // 0..1: extra cloud cover (overcast); rain also covers the sky
};
// Everything the renderer draws. Pointers are non-owning; null entries skip.

View File

@@ -1,9 +1,36 @@
#pragma once
#include "hd2d/GL.h"
#include "hd2d/RenderTarget.h"
#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);
// Writes color attachment 0 of `fbo` as PNG; `fbo` = 0 reads the window's
// backbuffer (call before swapBuffers).
bool saveScreenshot(const std::string& path, int width, int height, GLuint fbo = 0);
// Window-independent render output for headless captures. Color is plain
// RGBA8 (not sRGB) plus a depth/stencil buffer, matching the default
// framebuffer, so captures are pixel-identical to on-screen frames.
//
// hd2d::Window window; window.create(w, h, "capture", false, false, /*visible*/ false);
// hd2d::OffscreenTarget out; out.create(window.width(), window.height());
// renderer.setOutputFramebuffer(out.fbo());
// renderer.render(scene, cam, time); // + any ImGui / UI drawing
// out.save("shot.png");
class OffscreenTarget {
public:
bool create(int width, int height);
void resize(int width, int height); // reallocates only when the size changes
GLuint fbo() const { return target_.fbo(); }
GLuint color() const { return target_.color(); }
int width() const { return target_.width(); }
int height() const { return target_.height(); }
bool save(const std::string& path) const { return saveScreenshot(path, width(), height(), fbo()); }
private:
RenderTarget target_;
};
} // namespace hd2d

View File

@@ -0,0 +1,56 @@
#pragma once
#include "hd2d/Texture.h"
#include <glm/glm.hpp>
#include <vector>
namespace hd2d {
// Fixed-cell dynamic atlas on one Texture: images of up to `cellPixels` are
// packed into a cols x rows grid at runtime (e.g. sprites generated or
// downloaded while the game runs) so many billboards share a texture. Use
// with `BillboardBatch::sharedTexture = &atlas.texture()` and per-instance
// `uv = atlas.uv(slot)`, `anchor = atlas.anchor(slot)`.
//
// Every cell keeps its own 1-px transparent border (gutter), so sharp
// bilinear sampling and the first mip levels never bleed between cells.
// Images are placed bottom-centered in their cell; images larger than the
// cell are cropped to it (keeping the bottom rows and the horizontal center).
class SpriteAtlas {
public:
// Allocates a transparent texture of cols x rows cells. Clears all slots.
bool init(glm::ivec2 cellPixels, int cols, int rows);
int add(const Image& img); // first free slot, or -1 when full
void set(int slot, const Image& img); // replace (or fill) a slot
void remove(int slot); // clear a slot and free it
// BillboardInstance::uv of the whole cell (cellPixels, gutter excluded).
glm::vec4 uv(int slot) const;
// BillboardInstance::anchor: v of the lowest opaque (alpha >= 128) row of
// the image in `slot`; the cell bottom if it has no opaque pixels.
float anchor(int slot) const;
glm::ivec2 imageSize(int slot) const; // size placed in the cell (after cropping)
bool inUse(int slot) const;
const Texture& texture() const { return texture_; }
glm::ivec2 cellPixels() const { return cell_; }
int capacity() const { return cols_ * rows_; }
int used() const { return used_; }
private:
struct Slot {
bool used = false;
glm::ivec2 size{0};
int feetRow = 0; // lowest opaque row, in cell pixels from the cell bottom
};
glm::ivec2 origin(int slot) const; // GL-orientation pixel of the cell's bottom-left (inside the gutter)
bool valid(int slot) const { return slot >= 0 && slot < capacity(); }
Texture texture_;
glm::ivec2 cell_{0};
int cols_ = 0, rows_ = 0, used_ = 0;
std::vector<Slot> slots_;
};
} // namespace hd2d

View File

@@ -19,9 +19,14 @@ 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);
// Replaces the colors of fully transparent pixels with their nearest opaque
// neighbour (loadImage/decodeImage already do this). Call it on images you
// compose yourself so filtering never bleeds dark fringes into edges.
void bleedTransparentColors(Image& img);
// sRGB pixel-art texture. Sampled with "sharp bilinear" in shaders, so it uses
// linear filtering with mipmaps rather than GL_NEAREST.
// linear filtering with mipmaps rather than GL_NEAREST. `nearest` = GL_NEAREST
// without mips instead (crisp ImGui previews of pixel art).
class Texture {
public:
Texture() = default;
@@ -31,8 +36,14 @@ public:
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);
bool load(const std::string& path, bool repeat = false, bool nearest = false);
bool create(const Image& image, bool repeat = false, float anisotropy = 1.0f, bool nearest = false);
// Fully transparent w x h texture (sRGB RGBA8, mipmapped, clamped) to fill with update().
bool createEmpty(int w, int h);
// Uploads `img` with its bottom-left pixel at (x, y) (GL orientation,
// y = 0 is the bottom row) and regenerates the mips. Does not change
// opaqueBounds(), which always describes the image given to create().
void update(int x, int y, const Image& img);
void bind(GLuint unit) const { glBindTextureUnit(unit, id_); }
GLuint id() const { return id_; }
@@ -41,9 +52,16 @@ public:
// Billboards use minY to plant the visible feet on the ground.
glm::vec4 opaqueBounds() const { return opaqueBounds_; }
// Grid sprite sheets authored top-down (row 0 = top row of the PNG), with
// `cell` x `cell` pixel frames. cellUV is a BillboardInstance::uv rect;
// cellAnchor is a BillboardInstance::anchor: the v of the bottom edge of
// pixel row `feetRowFromCellTop` (0 = the cell's top row) of that cell.
static glm::vec4 cellUV(glm::ivec2 texSize, glm::ivec2 cell, int col, int rowFromTop);
static float cellAnchor(glm::ivec2 texSize, glm::ivec2 cell, int rowFromTop, int feetRowFromCellTop);
private:
GLuint id_ = 0;
int width_ = 0, height_ = 0;
int width_ = 0, height_ = 0, levels_ = 0;
glm::vec4 opaqueBounds_{0, 0, 1, 1};
};

162
engine/include/hd2d/UI.h Normal file
View File

@@ -0,0 +1,162 @@
#pragma once
// HD-2D style game UI (Octopath Traveler / Triangle Strategy look) drawn with
// Dear ImGui draw lists: translucent navy panels with a gold double border and
// corner gems, serif text with a soft shadow, name plates, menus, progress
// bars, a typewriter dialogue box with choices, and toast notifications.
//
// Everything draws into an ImDrawList (usually ImGui::GetForegroundDrawList()),
// so no ImGui windows are needed. Sizes are in pixels at a 1080 px tall
// viewport; multiply by scaleFor(viewport) (the widgets taking `scale` or a
// viewport do this for you).
//
// Setup: DebugUI::init(), then ui::loadFonts(dir, ui::scaleFor(viewport))
// before the first ImGui frame (or between frames to rebake after a resize).
#include <imgui.h>
#include <string>
#include <string_view>
#include <vector>
namespace hd2d::ui {
// Game fonts. `body` dialogue/menu text (serif), `title` large display face,
// `name` speaker / name plates (small caps serif), `small` captions and toasts.
// ImGui's default font stays the default so debug panels are unaffected.
struct Fonts {
ImFont* body = nullptr;
ImFont* title = nullptr;
ImFont* name = nullptr;
ImFont* small = nullptr;
};
// Nominal font sizes (px at 1080p). Fonts are baked at size * pixelScale so
// text drawn at size * scaleFor(viewport) is pixel-exact.
inline constexpr float kBodySize = 34.0f;
inline constexpr float kTitleSize = 96.0f;
inline constexpr float kNameSize = 28.0f;
inline constexpr float kSmallSize = 26.0f;
// Loads the fonts from `fontDir` (assets/fonts) baked for `pixelScale` and
// rebuilds the ImGui font atlas. Requires an ImGui context (DebugUI::init)
// and must be called outside an ImGui frame. Returns false if a font file is
// missing (the missing slots fall back to the default font).
bool loadFonts(const std::string& fontDir, float pixelScale = 1.0f);
const Fonts& fonts();
// Styles standard ImGui widgets (windows, buttons, input text, sliders, combo,
// checkboxes, tabs, scrollbars) in the same navy + gold palette.
void applyTheme();
// UI scale for a viewport in pixels: 1.0 at 1080 px tall.
float scaleFor(ImVec2 viewport);
// Shared palette.
inline constexpr ImU32 kGold = IM_COL32(222, 189, 120, 255);
inline constexpr ImU32 kGoldDim = IM_COL32(150, 122, 74, 255);
inline constexpr ImU32 kTextColor = IM_COL32(246, 240, 226, 255);
inline constexpr ImU32 kTextMuted = IM_COL32(184, 178, 164, 255);
// `alpha` is the background opacity; `tint` modulates the border/ornament
// colour and its alpha fades the whole panel (shadow, background, border).
// `rounding` is in 1080p pixels; `scale` 0 = scaleFor(ImGui display size).
struct PanelStyle {
float alpha = 0.88f;
bool ornate = true;
float rounding = 6.0f;
ImU32 tint = IM_COL32(255, 255, 255, 255);
float scale = 0.0f;
};
void panel(ImDrawList* dl, ImVec2 min, ImVec2 max, const PanelStyle& style = {});
// Text with a 1px dark drop shadow. `font` may be null (current ImGui font).
void text(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, ImU32 color, std::string_view s, bool shadow = true);
// Word-wrapped variant of text(); wrapWidth <= 0 disables wrapping.
void textWrapped(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, ImU32 color, std::string_view s,
float wrapWidth, bool shadow = true);
ImVec2 measure(ImFont* font, float size, std::string_view s, float wrapWidth = 0.0f);
void textCentered(ImDrawList* dl, ImFont* font, float size, ImVec2 center, ImU32 color, std::string_view s,
bool shadow = true);
// Big glowing gold title with flourish rules underneath (title screens).
void titleText(ImDrawList* dl, ImVec2 center, std::string_view s, float size);
// Small pill label whose pointer tip sits on `anchorPx` (e.g. a projected
// head position). `accent` colours the border and pointer.
void nameplate(ImDrawList* dl, ImVec2 anchorPx, std::string_view s, ImU32 accent, float scale = 1.0f);
// Vertical option list in a panel with an animated cursor on `selected`.
void menu(ImDrawList* dl, ImVec2 center, const std::vector<std::string>& items, int selected,
std::string_view title = {}, float scale = 1.0f);
// Same as menu(), placed so that `pivot` (0..1 of the panel size) lands on `pos`.
void menuAt(ImDrawList* dl, ImVec2 pos, ImVec2 pivot, const std::vector<std::string>& items, int selected,
std::string_view title = {}, float scale = 1.0f);
// Panel size menu()/menuAt() will use.
ImVec2 menuSize(const std::vector<std::string>& items, std::string_view title = {}, float scale = 1.0f);
// Recessed bar filled to t (0..1) with a vertical gradient of `color`.
void progressBar(ImDrawList* dl, ImVec2 min, ImVec2 max, float t, ImU32 color);
// Small blinking/bobbing gem pointing down ("more text" indicator); `time` drives the animation.
void advanceIndicator(ImDrawList* dl, ImVec2 center, float scale, float time);
struct DialogueLine {
std::string speaker; // empty = no name plate
std::string text; // UTF-8, word-wrapped to the box
ImTextureID portrait = 0; // optional, drawn square on the left inside the box
ImVec2 uv0{0, 0}, uv1{1, 1};
};
// Bottom-centre typewriter dialogue box. Typical loop:
// if (confirmPressed && box.advance()) handle(box.choice());
// box.update(dt); box.draw(viewport);
class DialogueBox {
public:
void start(std::vector<DialogueLine> lines, std::vector<std::string> choices = {}); // choices shown with the last line
bool isOpen() const { return open_; }
void update(float dt);
bool advance(); // reveal the rest of the line, else next line; returns true when the conversation closed
void moveChoice(int delta);
int choice() const { return chosen_; } // chosen index once closed with choices, else -1
void close();
void draw(ImVec2 viewport); // draws into ImGui::GetForegroundDrawList()
bool lineRevealed() const; // current line fully shown
bool choicesVisible() const; // last line revealed and choices pending
float charsPerSecond = 48.0f;
float sentencePause = 0.22f; // extra seconds after . ! ? and an ellipsis
float clausePause = 0.09f; // extra seconds after , ; : and dashes
private:
void stepChar();
std::vector<DialogueLine> lines_;
std::vector<std::string> choices_;
size_t line_ = 0;
size_t revealed_ = 0; // bytes of the current line shown (always on a UTF-8 boundary)
float budget_ = 0.0f; // seconds banked for the typewriter
float time_ = 0.0f;
int selected_ = 0;
int chosen_ = -1;
bool open_ = false;
};
// Stacked notification banners at the top centre that slide in and fade out.
class Toasts {
public:
void push(std::string text, ImU32 accent = IM_COL32(230, 190, 90, 255), float seconds = 3.5f);
void update(float dt);
void draw(ImVec2 viewport); // draws into ImGui::GetForegroundDrawList()
void clear() { items_.clear(); }
private:
struct Item {
std::string text;
ImU32 accent;
float seconds, age = 0.0f;
};
std::vector<Item> items_;
};
} // namespace hd2d::ui

View File

@@ -8,46 +8,87 @@ namespace hd2d {
struct RenderSettings;
// Everything the weather changes about the look, so it can be blended.
// Everything time of day and weather change about the look, so it can be
// blended. One look fully describes the lighting of a frame.
struct WeatherLook {
// Directional light: the sun by day, the moon at night (shadows follow it).
float sunAzimuth = -0.6f, sunElevation = 0.66f; // radians, see Sun
float sunIntensity = 0.0f;
glm::vec3 sunColor{1.0f};
glm::vec3 zenith{0.5f}, horizon{0.5f}, ground{0.2f};
float ambient = 0.5f;
float ambient = 0.5f, fill = 0.35f;
float night = 0.0f; // 0..1: star field + moon disc (Sky::night)
float clouds = 0.0f; // 0..1: extra sky cloud cover (Sky::clouds)
float daylight = 1.0f; // 0 night .. 1 day: scales weather presets (a storm at night is dark)
glm::vec3 fogColor{0.5f};
float fogDensity = 0.0f, fogHeightFalloff = 0.1f, godRays = 0.0f, shaftStrength = 0.0f, cookieStrength = 0.0f;
float shadowSoftness = 1.5f;
float windStrength = 0.0f, windGustSpeed = 0.0f;
float bloomStrength = 0.0f, saturation = 1.0f;
float bloomStrength = 0.0f, saturation = 1.0f, exposure = 1.0f;
glm::vec3 lift{0.0f}; // grading lift (shadow tint)
glm::vec3 gain{1.0f}; // grading gain (highlight tint)
float fireflyGlow = 1.0f; // ParticleSystem::fireflyGlow (raise at night)
};
// 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.
// Linear blend of every field (t = 0: a, t = 1: b).
WeatherLook mixLooks(const WeatherLook& a, const WeatherLook& b, float t);
// Blends the scene from its clear-weather look toward overcast, fog (mist)
// and storm presets, and drives rain intensity, wetness, ground splashes,
// lightning and the fading of ambient particles.
//
// The clear look is either captured from the current scene + settings while
// the weather is fully clear (so manual tuning is kept), or supplied every
// frame with setClearLook() (e.g. from a DayCycle). Presets are authored at
// full daylight and darkened by the clear look's `daylight`.
class Weather {
public:
Weather();
// Replaces the captured clear look (call every frame, e.g. with
// DayCycle::look(hour)). Once called, the scene is no longer captured.
void setClearLook(const WeatherLook& look);
void setRain(float target) { targetRain_ = glm::clamp(target, 0.0f, 1.0f); }
void setOvercast(float target) { targetOvercast_ = glm::clamp(target, 0.0f, 1.0f); }
void setFog(float target) { targetFog_ = glm::clamp(target, 0.0f, 1.0f); }
void setLightning(bool enabled) { lightning_ = enabled; } // occasional flashes while raining
float targetRain() const { return targetRain_; }
float targetOvercast() const { return targetOvercast_; }
float targetFog() const { return targetFog_; }
bool lightning() const { return lightning_; }
float rain() const { return rain_; }
// Jumps straight to the target (no transition), e.g. for screenshots.
void snap() { rain_ = targetRain_; wetness_ = targetRain_; }
float overcast() const { return overcast_; }
float fog() const { return fog_; }
// Jumps straight to every target (no transition), e.g. for screenshots.
void snap();
void update(float dt, Scene& scene, RenderSettings& settings);
WeatherLook storm; // look at full rain intensity
float transitionSeconds = 5.0f; // clear <-> storm fade time
// Presets at full daylight. Time-of-day fields (sun direction, night,
// daylight, exposure, lift, gain, fill, fireflies) always come from the clear look.
WeatherLook storm; // full rain intensity
WeatherLook overcastLook; // grey sky, weak sun, soft shadows
WeatherLook mist; // thick low fog (morning mist)
float nightBrightness = 0.3f; // preset brightness at daylight = 0
float transitionSeconds = 5.0f; // fade time for every target
float splashesPerSecond = 500.0f;
float splashRadius = 12.0f; // around Scene::focusPoint
float lightningInterval = 7.0f; // mean seconds between flashes at full rain
// The look currently set on a scene + settings (e.g. a hand-tuned noon).
static WeatherLook capture(const Scene& scene, const RenderSettings& settings);
private:
static WeatherLook capture(const Scene& scene, const RenderSettings& settings);
static void apply(const WeatherLook& look, Scene& scene, RenderSettings& settings);
WeatherLook relative(const WeatherLook& preset, float hueKeep) const;
bool clear() const;
WeatherLook clear_;
bool haveClear_ = false;
bool haveClear_ = false, externalClear_ = false, lightning_ = false;
float rain_ = 0.0f, targetRain_ = 0.0f, wetness_ = 0.0f, splashAccum_ = 0.0f;
float overcast_ = 0.0f, targetOvercast_ = 0.0f, fog_ = 0.0f, targetFog_ = 0.0f;
float flash_ = 0.0f, flashAge_ = 1e3f, nextFlash_ = 3.0f;
std::mt19937 rng_{99};
};

View File

@@ -10,19 +10,27 @@ namespace hd2d {
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);
// `visible = false` creates a hidden, never-focused window that only
// provides the GL context (headless captures: render into an
// OffscreenTarget, see Screenshot.h). Hidden windows ignore vsync,
// swapBuffers() is a no-op (unmapped Wayland surfaces never get frame
// callbacks, so a real swap could block forever) and every input query
// reports nothing, so the user's keyboard/mouse cannot affect them.
bool create(int width, int height, const std::string& title, bool vsync = true, bool resizable = true,
bool visible = true);
void destroy();
// Polls events and refreshes per-frame input state. Returns false once the
// window should close.
bool pollEvents();
void swapBuffers();
bool visible() const { return visible_; }
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_; }
glm::vec2 mouseDelta() const { return visible_ ? mouseDelta_ : glm::vec2(0.0f); }
float scrollDelta() const { return visible_ ? scrollDelta_ : 0.0f; }
int width() const { return width_; }
int height() const { return height_; }
@@ -38,6 +46,7 @@ private:
GLFWwindow* window_ = nullptr;
int width_ = 0, height_ = 0;
bool resized_ = false;
bool visible_ = true;
bool keys_[512] = {};
bool prevKeys_[512] = {};
glm::vec2 lastMouse_{0.0f};

View File

@@ -4,7 +4,7 @@
namespace hd2d {
static_assert(sizeof(BillboardInstance) == 32, "instance layout must match billboard.vert");
static_assert(sizeof(BillboardInstance) == 64, "instance layout must match billboard.vert");
BillboardBatch::~BillboardBatch() {
if (vbo_) glDeleteBuffers(1, &vbo_);
@@ -16,12 +16,14 @@ void BillboardBatch::upload() {
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);
// Four vec4 attributes: pos+sway, size+flip+tint, uv rect, anchor+turn+alpha+flash.
const GLuint offsets[] = {GLuint(offsetof(BillboardInstance, pos)), GLuint(offsetof(BillboardInstance, size)),
GLuint(offsetof(BillboardInstance, uv)), GLuint(offsetof(BillboardInstance, anchor))};
for (GLuint a = 0; a < 4; ++a) {
glEnableVertexArrayAttrib(vao_, a);
glVertexArrayAttribFormat(vao_, a, 4, GL_FLOAT, GL_FALSE, offsets[a]);
glVertexArrayAttribBinding(vao_, a, 0);
}
}
count_ = GLsizei(instances.size());
if (instances.size() > capacity_) {

View File

@@ -16,4 +16,13 @@ glm::mat4 Camera::view() const { return glm::lookAt(position(), target, glm::vec
glm::mat4 Camera::projection(float aspect) const { return glm::perspective(fovY, aspect, nearPlane, farPlane); }
bool Camera::worldToScreen(const glm::vec3& world, glm::vec2 viewportPx, glm::vec2& outPx) const {
if (viewportPx.x <= 0.0f || viewportPx.y <= 0.0f) return false;
glm::vec4 clip = projection(viewportPx.x / viewportPx.y) * view() * glm::vec4(world, 1.0f);
if (clip.w <= 1e-6f) return false;
glm::vec2 ndc = glm::vec2(clip) / clip.w;
outPx = glm::vec2((ndc.x * 0.5f + 0.5f) * viewportPx.x, (0.5f - ndc.y * 0.5f) * viewportPx.y);
return true;
}
} // namespace hd2d

175
engine/src/DayCycle.cpp Normal file
View File

@@ -0,0 +1,175 @@
#include "hd2d/DayCycle.h"
#include "hd2d/Renderer.h"
#include <algorithm>
#include <cmath>
namespace hd2d {
namespace {
constexpr float kPi = 3.14159265f;
float wrapHour(float h) {
h = std::fmod(h, 24.0f);
return h < 0.0f ? h + 24.0f : h;
}
float smooth(float e0, float e1, float x) {
float t = std::clamp((x - e0) / (e1 - e0), 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t);
}
} // namespace
DayCycle::DayCycle() {
// Noon is the engine's default look; the other keys are derived from it.
const WeatherLook noon = Weather::capture(Scene{}, RenderSettings{});
// Night: dim cool moonlight, deep blue sky and shadows, desaturated
// greens; warm fires and fireflies carry the scene.
WeatherLook night = noon;
night.sunColor = {0.34f, 0.50f, 1.0f};
night.sunIntensity = 0.45f;
night.zenith = {0.010f, 0.022f, 0.075f};
night.horizon = {0.030f, 0.060f, 0.180f};
night.ground = {0.012f, 0.020f, 0.045f};
night.ambient = 0.9f;
night.fill = 0.2f;
night.night = 1.0f;
night.fogColor = {0.025f, 0.045f, 0.11f};
night.fogDensity = 0.01f;
night.godRays = 0.5f;
night.shaftStrength = 0.7f;
night.cookieStrength = 0.35f;
night.bloomStrength = 0.12f;
night.saturation = 0.75f;
night.exposure = 1.2f;
night.lift = {0.004f, 0.012f, 0.045f};
night.gain = {0.94f, 0.97f, 1.08f};
night.fireflyGlow = 1.5f;
// Dawn: low warm pink sun, lilac sky, rosy haze.
WeatherLook dawn = noon;
dawn.sunColor = {1.0f, 0.62f, 0.45f};
dawn.sunIntensity = 2.6f;
dawn.zenith = {0.30f, 0.36f, 0.62f};
dawn.horizon = {1.0f, 0.68f, 0.66f};
dawn.ground = {0.18f, 0.17f, 0.17f};
dawn.ambient = 0.6f;
dawn.night = 0.12f;
dawn.fogColor = {0.55f, 0.42f, 0.50f};
dawn.fogDensity = 0.012f;
dawn.godRays = 3.5f;
dawn.bloomStrength = 0.09f;
dawn.saturation = 1.0f;
dawn.exposure = 1.1f;
dawn.lift = {0.025f, 0.015f, 0.035f};
dawn.gain = {1.06f, 0.96f, 0.98f};
dawn.fireflyGlow = 1.4f;
// Morning: fresh, slightly cool, crisp.
WeatherLook morning = noon;
morning.sunColor = {1.0f, 0.90f, 0.76f};
morning.sunIntensity = 2.9f;
morning.zenith = {0.24f, 0.44f, 0.80f};
morning.horizon = {0.74f, 0.79f, 0.82f};
morning.fogColor = {0.32f, 0.38f, 0.47f};
morning.fogDensity = 0.01f;
morning.godRays = 5.0f;
morning.fireflyGlow = 0.8f;
// Afternoon: warmer, golden.
WeatherLook afternoon = noon;
afternoon.sunColor = {1.0f, 0.80f, 0.56f};
afternoon.sunIntensity = 3.0f;
afternoon.zenith = {0.22f, 0.40f, 0.74f};
afternoon.horizon = {0.80f, 0.76f, 0.68f};
afternoon.ground = {0.22f, 0.23f, 0.13f};
afternoon.fogColor = {0.36f, 0.36f, 0.40f};
afternoon.saturation = 1.14f;
afternoon.lift = {0.016f, 0.010f, 0.022f};
// Dusk: deep orange sun under a purple sky, violet shadows.
WeatherLook dusk = noon;
dusk.sunColor = {1.0f, 0.45f, 0.20f};
dusk.sunIntensity = 3.0f;
dusk.zenith = {0.18f, 0.14f, 0.38f};
dusk.horizon = {0.95f, 0.45f, 0.35f};
dusk.ground = {0.16f, 0.10f, 0.14f};
dusk.ambient = 0.65f;
dusk.fill = 0.3f;
dusk.night = 0.1f;
dusk.fogColor = {0.35f, 0.20f, 0.30f};
dusk.fogDensity = 0.008f;
dusk.godRays = 2.2f;
dusk.bloomStrength = 0.1f;
dusk.saturation = 1.1f;
dusk.exposure = 1.0f;
dusk.lift = {0.030f, 0.010f, 0.045f};
dusk.gain = {1.06f, 0.95f, 0.92f};
dusk.fireflyGlow = 1.6f;
keys = {
{3.5f, "late night", night}, {6.0f, "dawn", dawn}, {9.0f, "morning", morning}, {12.5f, "noon", noon},
{15.5f, "afternoon", afternoon}, {18.0f, "dusk", dusk}, {20.5f, "night", night},
};
}
bool DayCycle::moonUp(float hour) const {
float h = wrapHour(hour);
return h < sunrise || h >= sunset;
}
// Raw (unclamped) azimuth / elevation of the active light.
static glm::vec2 rawAngles(const DayCycle& d, float h) {
if (!d.moonUp(h)) {
float t = (h - d.sunrise) / std::max(d.sunset - d.sunrise, 1e-3f);
return {d.sunAzimuth + (t - 0.5f) * d.sunSweep, d.sunMaxElevation * std::sin(kPi * t)};
}
float len = std::max(24.0f - (d.sunset - d.sunrise), 1e-3f);
float t = wrapHour(h - d.sunset) / len;
return {d.moonAzimuth + (t - 0.5f) * d.moonSweep, d.moonMaxElevation * std::sin(kPi * t)};
}
glm::vec2 DayCycle::lightAngles(float hour) const {
glm::vec2 a = rawAngles(*this, wrapHour(hour));
a.y = std::max(a.y, minElevation);
return a;
}
float DayCycle::daylight(float hour) const {
float h = wrapHour(hour);
if (moonUp(h)) return 0.0f;
return smooth(0.0f, 0.4f, rawAngles(*this, h).y);
}
static WeatherLook interpolate(const std::vector<DayCycle::Keyframe>& keys, float h) {
if (keys.empty()) return {};
if (keys.size() == 1) return keys[0].look;
// Last key at or before h (wrapping to the final key before the first).
size_t i = keys.size() - 1;
for (size_t k = 0; k < keys.size(); ++k)
if (keys[k].hour <= h) i = k;
size_t j = (i + 1) % keys.size();
float span = wrapHour(keys[j].hour - keys[i].hour);
float t = span > 1e-4f ? wrapHour(h - keys[i].hour) / span : 0.0f;
t = std::clamp(t, 0.0f, 1.0f);
return mixLooks(keys[i].look, keys[j].look, t * t * (3.0f - 2.0f * t));
}
float DayCycle::night(float hour) const { return interpolate(keys, wrapHour(hour)).night; }
WeatherLook DayCycle::look(float hour) const {
float h = wrapHour(hour);
WeatherLook l = interpolate(keys, h);
glm::vec2 raw = rawAngles(*this, h);
l.sunAzimuth = raw.x;
l.sunElevation = std::max(raw.y, minElevation);
l.sunIntensity *= smooth(0.0f, horizonFade, raw.y); // light sinks below the horizon
l.daylight = daylight(h);
return l;
}
} // namespace hd2d

View File

@@ -1,5 +1,6 @@
#include "hd2d/DebugUI.h"
#include "hd2d/DayCycle.h"
#include "hd2d/Renderer.h"
#include "hd2d/Weather.h"
#include "hd2d/Window.h"
@@ -138,24 +139,77 @@ void DebugUI::renderSettingsPanel(RenderSettings& s, Scene& scene, float gpuMs)
ImGui::End();
}
void DebugUI::weatherPanel(Weather& w) {
void DebugUI::weatherPanel(Weather& w, DayCycle* day, float* hour) {
ImGui::SetNextWindowPos(ImVec2(364, 12), ImGuiCond_FirstUseEver);
ImGui::Begin("Weather", nullptr, ImGuiWindowFlags_AlwaysAutoResize);
if (hour) {
ImGui::SliderFloat("Hour", hour, 0.0f, 24.0f, "%.2f");
if (day)
ImGui::Text("daylight %.2f | night %.2f | %s", day->daylight(*hour), day->night(*hour),
day->moonUp(*hour) ? "moon" : "sun");
}
float target = w.targetRain();
if (ImGui::SliderFloat("Rain", &target, 0.0f, 1.0f)) w.setRain(target);
ImGui::Text("current %.2f (R toggles)", w.rain());
target = w.targetOvercast();
if (ImGui::SliderFloat("Overcast", &target, 0.0f, 1.0f)) w.setOvercast(target);
target = w.targetFog();
if (ImGui::SliderFloat("Fog", &target, 0.0f, 1.0f)) w.setFog(target);
bool lightning = w.lightning();
if (ImGui::Checkbox("Lightning (while raining)", &lightning)) w.setLightning(lightning);
ImGui::Text("current rain %.2f overcast %.2f fog %.2f", w.rain(), w.overcast(), w.fog());
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::SliderFloat("Night preset brightness", &w.nightBrightness, 0.0f, 1.0f);
auto presetUI = [](const char* label, WeatherLook& l) {
if (!ImGui::TreeNode(label)) return;
ImGui::SliderFloat("Sun intensity", &l.sunIntensity, 0.0f, 4.0f);
ImGui::ColorEdit3("Sun color", glm::value_ptr(l.sunColor));
ImGui::ColorEdit3("Zenith", glm::value_ptr(l.zenith));
ImGui::ColorEdit3("Horizon", glm::value_ptr(l.horizon));
ImGui::SliderFloat("Ambient", &l.ambient, 0.0f, 2.0f);
ImGui::ColorEdit3("Fog color", glm::value_ptr(l.fogColor));
ImGui::SliderFloat("Fog density", &l.fogDensity, 0.0f, 0.2f, "%.4f");
ImGui::SliderFloat("Fog falloff", &l.fogHeightFalloff, 0.0f, 1.5f);
ImGui::SliderFloat("God rays", &l.godRays, 0.0f, 6.0f);
ImGui::SliderFloat("Shadow softness", &l.shadowSoftness, 0.0f, 6.0f);
ImGui::SliderFloat("Wind", &l.windStrength, 0.0f, 0.4f);
ImGui::SliderFloat("Gust speed", &l.windGustSpeed, 0.0f, 6.0f);
ImGui::TreePop();
};
presetUI("Storm look", w.storm);
presetUI("Overcast look", w.overcastLook);
presetUI("Mist look", w.mist);
ImGui::SliderFloat("Splashes / s", &w.splashesPerSecond, 0.0f, 2000.0f);
if (day && ImGui::TreeNode("Day keyframes")) {
ImGui::SliderFloat("Sunrise", &day->sunrise, 3.0f, 9.0f);
ImGui::SliderFloat("Sunset", &day->sunset, 15.0f, 21.0f);
ImGui::SliderAngle("Moon azimuth", &day->moonAzimuth, -180.0f, 180.0f);
ImGui::SliderAngle("Moon max elevation", &day->moonMaxElevation, 5.0f, 89.0f);
for (DayCycle::Keyframe& k : day->keys) {
ImGui::PushID(&k);
if (ImGui::TreeNode(k.name)) {
WeatherLook& l = k.look;
ImGui::SliderFloat("Hour", &k.hour, 0.0f, 24.0f);
ImGui::ColorEdit3("Sun / moon color", glm::value_ptr(l.sunColor));
ImGui::SliderFloat("Sun / moon intensity", &l.sunIntensity, 0.0f, 4.0f);
ImGui::ColorEdit3("Zenith", glm::value_ptr(l.zenith));
ImGui::ColorEdit3("Horizon", glm::value_ptr(l.horizon));
ImGui::ColorEdit3("Ground bounce", glm::value_ptr(l.ground));
ImGui::SliderFloat("Ambient", &l.ambient, 0.0f, 2.0f);
ImGui::SliderFloat("Camera fill", &l.fill, 0.0f, 1.5f);
ImGui::ColorEdit3("Fog color", glm::value_ptr(l.fogColor));
ImGui::SliderFloat("Fog density", &l.fogDensity, 0.0f, 0.1f, "%.4f");
ImGui::SliderFloat("God rays", &l.godRays, 0.0f, 6.0f);
ImGui::SliderFloat("Bloom", &l.bloomStrength, 0.0f, 0.4f);
ImGui::SliderFloat("Saturation", &l.saturation, 0.0f, 2.0f);
ImGui::SliderFloat("Exposure", &l.exposure, 0.1f, 4.0f);
ImGui::ColorEdit3("Lift", glm::value_ptr(l.lift));
ImGui::ColorEdit3("Gain", glm::value_ptr(l.gain));
ImGui::SliderFloat("Night (stars, moon)", &l.night, 0.0f, 1.0f);
ImGui::SliderFloat("Firefly glow", &l.fireflyGlow, 0.0f, 5.0f);
ImGui::TreePop();
}
ImGui::PopID();
}
ImGui::TreePop();
}
ImGui::End();

View File

@@ -157,6 +157,7 @@ void ParticleSystem::update(float dt, float time) {
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;
color *= fireflyGlow;
break;
}
case Kind::Mote: {

View File

@@ -24,12 +24,12 @@ 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;
shadowParams, shaft, waterParams, groundAO, weather, skyParams;
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");
static_assert(sizeof(FrameUBO) == 5 * 64 + 24 * 16 + 16 + 2 * kMaxLights * 16, "FrameUBO must be tightly packed");
} // namespace
@@ -174,9 +174,10 @@ struct Renderer::Impl {
if (!b) continue;
if (pass == 1 && !b->castShadows) continue;
if (reflectionPass && !b->inReflections) continue;
billboard.set("uOpaque", b->texture.opaqueBounds());
const Texture& tex = b->tex();
billboard.set("uOpaque", tex.opaqueBounds());
billboard.set("uEmissive", b->emissive);
b->texture.bind(0);
tex.bind(0);
b->draw();
}
}
@@ -315,6 +316,7 @@ void Renderer::render(const Scene& scene, const Camera& cam, float time) {
: 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);
f.skyParams = glm::vec4(std::clamp(scene.sky.night, 0.0f, 1.0f), std::clamp(scene.sky.clouds, 0.0f, 1.0f), 0, 0);
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);
@@ -557,8 +559,8 @@ void Renderer::render(const Scene& scene, const Camera& cam, float time) {
glDisable(GL_BLEND);
}
// ---- 9. Tonemap + grade to the backbuffer --------------------------------
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// ---- 9. Tonemap + grade to the output (backbuffer or caller FBO) --------
glBindFramebuffer(GL_FRAMEBUFFER, outputFbo_);
glViewport(0, 0, w, h);
Shader& tm = I.tonemap;
tm.use();

View File

@@ -11,13 +11,16 @@
namespace hd2d {
bool saveScreenshot(const std::string& path, int width, int height) {
bool saveScreenshot(const std::string& path, int width, int height, GLuint fbo) {
std::vector<unsigned char> px(size_t(width) * height * 3);
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
glReadBuffer(GL_BACK);
GLint prevRead = 0;
glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevRead);
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
glReadBuffer(fbo ? GL_COLOR_ATTACHMENT0 : GL_BACK);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, px.data());
glPixelStorei(GL_PACK_ALIGNMENT, 4);
glBindFramebuffer(GL_READ_FRAMEBUFFER, GLuint(prevRead));
std::error_code ec;
auto parent = std::filesystem::path(path).parent_path();
if (!parent.empty()) std::filesystem::create_directories(parent, ec);
@@ -28,4 +31,17 @@ bool saveScreenshot(const std::string& path, int width, int height) {
return ok;
}
bool OffscreenTarget::create(int width, int height) {
if (width <= 0 || height <= 0) {
HD2D_ERR("OffscreenTarget: invalid size %dx%d", width, height);
return false;
}
target_.create(width, height, {GL_RGBA8}, GL_DEPTH24_STENCIL8, 0, GL_NEAREST);
return glCheckNamedFramebufferStatus(target_.fbo(), GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
}
void OffscreenTarget::resize(int width, int height) {
if (width != target_.width() || height != target_.height()) create(width, height);
}
} // namespace hd2d

104
engine/src/SpriteAtlas.cpp Normal file
View File

@@ -0,0 +1,104 @@
#include "hd2d/SpriteAtlas.h"
#include "hd2d/Log.h"
#include <algorithm>
#include <cstring>
namespace hd2d {
namespace {
constexpr int kGutter = 1; // transparent border around every cell
}
bool SpriteAtlas::init(glm::ivec2 cellPixels, int cols, int rows) {
if (cellPixels.x <= 0 || cellPixels.y <= 0 || cols <= 0 || rows <= 0) {
HD2D_ERR("SpriteAtlas::init: invalid layout %dx%d cells of %dx%d px", cols, rows, cellPixels.x, cellPixels.y);
return false;
}
cell_ = cellPixels;
cols_ = cols;
rows_ = rows;
used_ = 0;
slots_.assign(size_t(cols) * rows, Slot{});
glm::ivec2 stride = cell_ + 2 * kGutter;
return texture_.createEmpty(cols * stride.x, rows * stride.y);
}
glm::ivec2 SpriteAtlas::origin(int slot) const {
glm::ivec2 stride = cell_ + 2 * kGutter;
int col = slot % cols_, rowFromTop = slot / cols_; // slot 0 = top-left, like a sheet read top-down
return {col * stride.x + kGutter, texture_.size().y - (rowFromTop + 1) * stride.y + kGutter};
}
int SpriteAtlas::add(const Image& img) {
for (int s = 0; s < capacity(); ++s)
if (!slots_[s].used) {
set(s, img);
return s;
}
return -1;
}
void SpriteAtlas::set(int slot, const Image& img) {
if (!valid(slot) || img.width < 0 || img.height < 0 || img.rgba.size() < size_t(img.width) * img.height * 4) {
HD2D_ERR("SpriteAtlas::set: invalid slot %d (capacity %d) or image", slot, capacity());
return;
}
// Compose the cell plus its gutter ring, image bottom-centered and cropped.
const int w = std::min(img.width, cell_.x), h = std::min(img.height, cell_.y);
const int srcX = (img.width - w) / 2; // crop the sides evenly, keep the bottom rows
const int dstX = kGutter + (cell_.x - w) / 2, dstY = kGutter;
Image block;
block.width = cell_.x + 2 * kGutter;
block.height = cell_.y + 2 * kGutter;
block.rgba.assign(size_t(block.width) * block.height * 4, 0);
int feetRow = -1;
for (int y = 0; y < h; ++y) {
const unsigned char* src = &img.rgba[(size_t(y) * img.width + srcX) * 4];
unsigned char* dst = &block.rgba[(size_t(dstY + y) * block.width + dstX) * 4];
std::memcpy(dst, src, size_t(w) * 4);
if (feetRow < 0)
for (int x = 0; x < w; ++x)
if (src[x * 4 + 3] >= 128) {
feetRow = y;
break;
}
}
bleedTransparentColors(block); // padding takes edge colors: no dark fringes
Slot& s = slots_[slot];
if (!s.used) ++used_;
s.used = true;
s.size = {w, h};
s.feetRow = std::max(feetRow, 0);
texture_.update(origin(slot).x - kGutter, origin(slot).y - kGutter, block);
}
void SpriteAtlas::remove(int slot) {
if (!valid(slot) || !slots_[slot].used) return;
Image clear;
clear.width = cell_.x + 2 * kGutter;
clear.height = cell_.y + 2 * kGutter;
clear.rgba.assign(size_t(clear.width) * clear.height * 4, 0);
texture_.update(origin(slot).x - kGutter, origin(slot).y - kGutter, clear);
slots_[slot] = Slot{};
--used_;
}
glm::vec4 SpriteAtlas::uv(int slot) const {
if (!valid(slot)) return {0, 0, 0, 0};
glm::vec2 ts(texture_.size()), o(origin(slot));
return {o.x / ts.x, o.y / ts.y, (o.x + float(cell_.x)) / ts.x, (o.y + float(cell_.y)) / ts.y};
}
float SpriteAtlas::anchor(int slot) const {
if (!valid(slot)) return 0.0f;
return float(origin(slot).y + slots_[slot].feetRow) / float(texture_.size().y);
}
glm::ivec2 SpriteAtlas::imageSize(int slot) const { return valid(slot) ? slots_[slot].size : glm::ivec2(0); }
bool SpriteAtlas::inUse(int slot) const { return valid(slot) && slots_[slot].used; }
} // namespace hd2d

View File

@@ -11,7 +11,7 @@
namespace hd2d {
static void bleedTransparentColors(Image& img) {
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;
@@ -71,6 +71,7 @@ Texture& Texture::operator=(Texture&& o) noexcept {
std::swap(id_, o.id_);
width_ = o.width_;
height_ = o.height_;
levels_ = o.levels_;
opaqueBounds_ = o.opaqueBounds_;
return *this;
}
@@ -79,12 +80,27 @@ Texture::~Texture() {
if (id_) glDeleteTextures(1, &id_);
}
bool Texture::load(const std::string& path, bool repeat) {
bool Texture::load(const std::string& path, bool repeat, bool nearest) {
Image img;
return loadImage(path, img) && create(img, repeat);
return loadImage(path, img) && create(img, repeat, 1.0f, nearest);
}
bool Texture::create(const Image& img, bool repeat, float anisotropy) {
// (Re)allocates immutable storage and sets sampling state.
static GLuint allocate(GLuint old, int w, int h, int levels, bool repeat, bool nearest, float anisotropy) {
if (old) glDeleteTextures(1, &old);
GLuint id = 0;
glCreateTextures(GL_TEXTURE_2D, 1, &id);
glTextureStorage2D(id, levels, GL_SRGB8_ALPHA8, w, h);
glTextureParameteri(id, GL_TEXTURE_MIN_FILTER, nearest ? GL_NEAREST : GL_LINEAR_MIPMAP_LINEAR);
glTextureParameteri(id, GL_TEXTURE_MAG_FILTER, nearest ? GL_NEAREST : 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 id;
}
bool Texture::create(const Image& img, bool repeat, float anisotropy, bool nearest) {
width_ = img.width;
height_ = img.height;
@@ -99,20 +115,50 @@ bool Texture::create(const Image& img, bool repeat, float anisotropy) {
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_);
levels_ = nearest ? 1 : mipLevels(width_, height_);
id_ = allocate(id_, width_, height_, levels_, repeat, nearest, anisotropy);
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);
if (levels_ > 1) glGenerateTextureMipmap(id_);
return true;
}
bool Texture::createEmpty(int w, int h) {
if (w <= 0 || h <= 0) {
HD2D_ERR("Texture::createEmpty: invalid size %dx%d", w, h);
return false;
}
width_ = w;
height_ = h;
opaqueBounds_ = {0, 0, 1, 1};
levels_ = mipLevels(w, h);
id_ = allocate(id_, w, h, levels_, false, false, 1.0f);
for (int l = 0; l < levels_; ++l) glClearTexImage(id_, l, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); // transparent black
return true;
}
void Texture::update(int x, int y, const Image& img) {
if (!id_ || x < 0 || y < 0 || x + img.width > width_ || y + img.height > height_ ||
img.rgba.size() < size_t(img.width) * img.height * 4) {
HD2D_ERR("Texture::update: %dx%d image at (%d, %d) does not fit %dx%d texture", img.width, img.height, x, y,
width_, height_);
return;
}
if (img.width == 0 || img.height == 0) return;
glTextureSubImage2D(id_, 0, x, y, img.width, img.height, GL_RGBA, GL_UNSIGNED_BYTE, img.rgba.data());
if (levels_ > 1) glGenerateTextureMipmap(id_);
}
glm::vec4 Texture::cellUV(glm::ivec2 texSize, glm::ivec2 cell, int col, int rowFromTop) {
glm::vec2 ts(texSize);
float x0 = float(col * cell.x), y0 = float(texSize.y - (rowFromTop + 1) * cell.y); // GL bottom row
return {x0 / ts.x, y0 / ts.y, (x0 + float(cell.x)) / ts.x, (y0 + float(cell.y)) / ts.y};
}
float Texture::cellAnchor(glm::ivec2 texSize, glm::ivec2 cell, int rowFromTop, int feetRowFromCellTop) {
int rowFromImageTop = rowFromTop * cell.y + feetRowFromCellTop;
return float(texSize.y - (rowFromImageTop + 1)) / float(texSize.y); // bottom edge of that pixel row
}
TextureArray::~TextureArray() {
if (id_) glDeleteTextures(1, &id_);
}

746
engine/src/UI.cpp Normal file
View File

@@ -0,0 +1,746 @@
#define IMGUI_DEFINE_MATH_OPERATORS // before the first imgui.h include
#include "hd2d/UI.h"
#include "hd2d/Log.h"
#include <imgui_impl_opengl3.h>
#include <imgui_internal.h>
#include <algorithm>
#include <cfloat>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <filesystem>
namespace hd2d::ui {
namespace {
Fonts g_fonts;
// ---- colour helpers ---------------------------------------------------------
float alphaOf(ImU32 c) { return float((c >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f; }
ImU32 withAlpha(ImU32 c, float a) {
int na = int(std::clamp(alphaOf(c) * a, 0.0f, 1.0f) * 255.0f + 0.5f);
return (c & ~IM_COL32_A_MASK) | (ImU32(na) << IM_COL32_A_SHIFT);
}
ImU32 rgba(int r, int g, int b, float a) { return IM_COL32(r, g, b, int(std::clamp(a, 0.0f, 1.0f) * 255.0f + 0.5f)); }
// Component-wise multiply (rgb and alpha).
ImU32 modulate(ImU32 c, ImU32 t) {
ImVec4 a = ImGui::ColorConvertU32ToFloat4(c), b = ImGui::ColorConvertU32ToFloat4(t);
return ImGui::ColorConvertFloat4ToU32(ImVec4(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w));
}
ImU32 mix(ImU32 c0, ImU32 c1, float t) {
ImVec4 a = ImGui::ColorConvertU32ToFloat4(c0), b = ImGui::ColorConvertU32ToFloat4(c1);
return ImGui::ColorConvertFloat4ToU32(
ImVec4(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t));
}
ImVec2 snap(ImVec2 p) { return {std::floor(p.x + 0.5f), std::floor(p.y + 0.5f)}; }
ImFont* orDefault(ImFont* f) { return f ? f : ImGui::GetFont(); }
float displayScale() { return scaleFor(ImGui::GetIO().DisplaySize); }
// Fills whatever `draw` adds with a vertical gradient (vertex alpha is kept).
template <class F>
void verticalGradient(ImDrawList* dl, float y0, float y1, ImU32 top, ImU32 bottom, F&& draw) {
int v0 = dl->VtxBuffer.Size;
draw();
ImGui::ShadeVertsLinearColorGradientKeepAlpha(dl, v0, dl->VtxBuffer.Size, ImVec2(0, y0), ImVec2(0, y1), top,
bottom);
}
// Horizontal line fading out towards both ends (or only towards the right).
void fadeLine(ImDrawList* dl, ImVec2 a, ImVec2 b, float thickness, ImU32 col, bool bothEnds = true) {
ImU32 clear = withAlpha(col, 0.0f);
float y0 = a.y - thickness * 0.5f, y1 = a.y + thickness * 0.5f;
if (bothEnds) {
float mid = (a.x + b.x) * 0.5f;
dl->AddRectFilledMultiColor(ImVec2(a.x, y0), ImVec2(mid, y1), clear, col, col, clear);
dl->AddRectFilledMultiColor(ImVec2(mid, y0), ImVec2(b.x, y1), col, clear, clear, col);
} else {
dl->AddRectFilledMultiColor(ImVec2(a.x, y0), ImVec2(b.x, y1), col, clear, clear, col);
}
}
// Diamond gem: gold body, lighter upper-left facet, dark outline.
void gem(ImDrawList* dl, ImVec2 c, float r, ImU32 body, ImU32 light, float alpha) {
ImVec2 t(c.x, c.y - r), rt(c.x + r, c.y), b(c.x, c.y + r), l(c.x - r, c.y);
dl->AddQuadFilled(t, rt, b, l, withAlpha(IM_COL32(10, 10, 16, 255), 0.75f * alpha));
float ri = std::max(1.0f, r - 1.2f);
ImVec2 ti(c.x, c.y - ri), rti(c.x + ri, c.y), bi(c.x, c.y + ri), li(c.x - ri, c.y);
dl->AddQuadFilled(ti, rti, bi, li, withAlpha(body, alpha));
dl->AddTriangleFilled(li, ti, c, withAlpha(light, alpha));
}
// ---- text layout ---------------------------------------------------------------
struct Span {
const char* b;
const char* e;
};
int utf8Len(unsigned char c) { return c < 0x80 ? 1 : (c >> 5) == 0x6 ? 2 : (c >> 4) == 0xE ? 3 : (c >> 3) == 0x1E ? 4 : 1; }
// Splits `[b, e)` into lines: hard breaks on '\n', word wrap at `wrap` px (<= 0: none).
void layoutLines(ImFont* font, float size, const char* b, const char* e, float wrap, std::vector<Span>& out) {
out.clear();
const float scale = size / font->FontSize;
const char* p = b;
for (;;) {
const char* nl = static_cast<const char*>(std::memchr(p, '\n', size_t(e - p)));
const char* pe = nl ? nl : e;
const char* s = p;
if (s == pe) out.push_back({s, s});
while (s < pe) {
const char* eol = wrap > 0.0f ? font->CalcWordWrapPositionA(scale, s, pe, wrap) : pe;
if (eol <= s) eol = std::min(pe, s + utf8Len(static_cast<unsigned char>(*s)));
const char* le = eol;
while (le > s && (le[-1] == ' ' || le[-1] == '\t')) --le;
out.push_back({s, le});
s = eol;
while (s < pe && (*s == ' ' || *s == '\t')) ++s;
}
if (!nl) break;
p = nl + 1;
}
}
float lineAdvance(float size) { return std::round(size * 1.06f); }
void drawSpan(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, ImU32 color, const char* b, const char* e,
bool shadow) {
if (b >= e) return;
pos = snap(pos);
if (shadow) {
float o = std::max(1.0f, std::round(size / 36.0f));
float a = alphaOf(color);
// Soft halo for contrast on busy backgrounds plus the crisp 1px offset shadow.
dl->AddText(font, size, ImVec2(pos.x + o, pos.y + o * 2.0f), rgba(0, 0, 0, 0.30f * a), b, e);
dl->AddText(font, size, ImVec2(pos.x + o, pos.y + o), rgba(4, 4, 10, 0.85f * a), b, e);
}
dl->AddText(font, size, pos, color, b, e);
}
// ---- option list (menu + dialogue choices) ----------------------------------------
struct ListMetrics {
float s, size, titleSize, rowH, padX, padY, cursorW, titleH;
ImVec2 panel;
};
ListMetrics listMetrics(const std::vector<std::string>& items, std::string_view title, float scale) {
ListMetrics m;
m.s = scale;
m.size = std::round(kBodySize * 0.9f * scale);
m.titleSize = std::round(kNameSize * scale);
m.rowH = std::round(m.size * 1.3f);
m.padX = std::round(26.0f * scale);
m.padY = std::round(18.0f * scale);
m.cursorW = std::round(30.0f * scale);
m.titleH = title.empty() ? 0.0f : std::round(m.titleSize + 22.0f * scale);
ImFont* body = orDefault(g_fonts.body);
float w = 0.0f;
for (const std::string& it : items) w = std::max(w, measure(body, m.size, it).x);
if (!title.empty()) w = std::max(w, measure(orDefault(g_fonts.name), m.titleSize, title).x - m.cursorW);
m.panel.x = std::round(std::max(w + m.cursorW + m.padX * 2.0f + 8.0f * scale, 200.0f * scale));
m.panel.y = std::round(m.padY * 2.0f + m.titleH + m.rowH * float(items.size()));
return m;
}
void drawList(ImDrawList* dl, ImVec2 min, const ListMetrics& m, const std::vector<std::string>& items, int selected,
std::string_view title) {
const float s = m.s;
ImVec2 max = min + m.panel;
PanelStyle ps;
ps.scale = s;
panel(dl, min, max, ps);
float y = min.y + m.padY;
if (!title.empty()) {
textCentered(dl, g_fonts.name, m.titleSize, ImVec2((min.x + max.x) * 0.5f, y + m.titleSize * 0.5f), kGold,
title);
float ly = std::round(y + m.titleSize + 10.0f * s) + 0.5f;
fadeLine(dl, ImVec2(min.x + 14.0f * s, ly), ImVec2(max.x - 14.0f * s, ly), std::max(1.0f, s), kGoldDim);
y += m.titleH;
}
const float t = float(ImGui::GetTime());
ImFont* body = orDefault(g_fonts.body);
for (int i = 0; i < int(items.size()); ++i) {
ImVec2 r0(min.x + 8.0f * s, y), r1(max.x - 8.0f * s, y + m.rowH);
bool sel = i == selected;
float textY = y + (m.rowH - m.size) * 0.5f;
if (sel) {
ImU32 hi = IM_COL32(222, 189, 120, 70), clear = IM_COL32(222, 189, 120, 0);
dl->AddRectFilledMultiColor(r0, r1, hi, clear, clear, hi);
fadeLine(dl, ImVec2(r0.x, r0.y + 0.5f), ImVec2(r1.x, r0.y + 0.5f), 1.0f, IM_COL32(222, 189, 120, 110),
false);
fadeLine(dl, ImVec2(r0.x, r1.y - 0.5f), ImVec2(r1.x, r1.y - 0.5f), 1.0f, IM_COL32(222, 189, 120, 110),
false);
// Right-pointing gold cursor that bobs gently.
float bob = std::sin(t * 6.0f) * 2.5f * s;
float cx = min.x + m.padX + 4.0f * s + bob, cy = y + m.rowH * 0.5f, h = 8.0f * s;
ImVec2 a(cx - h * 0.6f, cy - h), b(cx + h * 0.9f, cy), c(cx - h * 0.6f, cy + h);
dl->AddTriangleFilled(a + ImVec2(1, 1.5f) * s, b + ImVec2(1, 1.5f) * s, c + ImVec2(1, 1.5f) * s,
IM_COL32(0, 0, 0, 150));
dl->AddTriangleFilled(a, b, c, kGold);
dl->AddTriangleFilled(a, b, ImVec2(cx - h * 0.6f, cy), IM_COL32(255, 238, 190, 255));
}
drawSpan(dl, body, m.size, ImVec2(min.x + m.padX + m.cursorW, textY), sel ? IM_COL32(255, 248, 230, 255) : kTextMuted,
items[size_t(i)].data(), items[size_t(i)].data() + items[size_t(i)].size(), true);
y += m.rowH;
}
}
bool isSentenceEnd(const char* p, size_t len) {
if (len == 1) return *p == '.' || *p == '!' || *p == '?';
return len == 3 && !std::memcmp(p, "\xE2\x80\xA6", 3); // ellipsis
}
bool isClauseEnd(const char* p, size_t len) {
if (len == 1) return *p == ',' || *p == ';' || *p == ':';
return len == 3 && (!std::memcmp(p, "\xE2\x80\x94", 3) || !std::memcmp(p, "\xE2\x80\x93", 3)); // em/en dash
}
} // namespace
// ---- fonts / theme -------------------------------------------------------------------
bool loadFonts(const std::string& fontDir, float pixelScale) {
static const ImWchar kRanges[] = {
0x0020, 0x00FF, // Basic Latin + Latin-1
0x0100, 0x017F, // Latin Extended-A
0x2010, 0x2027, // dashes, quotes, bullets, ellipsis
0x2030, 0x203A, // per mille, guillemets
0x20AC, 0x20AC, // euro
0x2122, 0x2122, // trade mark
0,
};
ImGuiIO& io = ImGui::GetIO();
ImFontAtlas* atlas = io.Fonts;
if (io.BackendRendererUserData) ImGui_ImplOpenGL3_DestroyFontsTexture();
atlas->Clear();
atlas->AddFontDefault(); // stays io.FontDefault (first font) for debug panels
io.FontDefault = nullptr;
g_fonts = {};
pixelScale = std::max(pixelScale, 0.25f);
bool ok = true;
auto add = [&](const char* file, float size, float multiply) -> ImFont* {
std::string path = fontDir + "/" + file;
if (!std::filesystem::exists(path)) {
HD2D_ERR("ui: missing font %s", path.c_str());
ok = false;
return nullptr;
}
ImFontConfig cfg;
cfg.OversampleH = 2;
cfg.OversampleV = 1;
cfg.RasterizerMultiply = multiply;
std::snprintf(cfg.Name, sizeof cfg.Name, "%s %.0fpx", file, size * pixelScale);
return atlas->AddFontFromFileTTF(path.c_str(), std::round(size * pixelScale), &cfg, kRanges);
};
g_fonts.body = add("EBGaramond.ttf", kBodySize, 1.15f);
g_fonts.small = add("EBGaramond.ttf", kSmallSize, 1.2f);
g_fonts.name = add("Cinzel.ttf", kNameSize, 1.1f);
g_fonts.title = add("CinzelDecorative-Bold.ttf", kTitleSize, 1.0f);
// The backend re-uploads the atlas on the next NewFrame().
return ok;
}
const Fonts& fonts() { return g_fonts; }
float scaleFor(ImVec2 viewport) { return viewport.y > 0.0f ? viewport.y / 1080.0f : 1.0f; }
void applyTheme() {
ImGuiStyle& st = ImGui::GetStyle();
st.WindowRounding = 6.0f;
st.ChildRounding = 4.0f;
st.FrameRounding = 4.0f;
st.PopupRounding = 4.0f;
st.GrabRounding = 3.0f;
st.TabRounding = 4.0f;
st.ScrollbarRounding = 6.0f;
st.WindowBorderSize = 1.0f;
st.FrameBorderSize = 1.0f;
st.PopupBorderSize = 1.0f;
st.WindowPadding = ImVec2(12, 10);
st.FramePadding = ImVec2(8, 4);
st.ItemSpacing = ImVec2(8, 6);
st.WindowTitleAlign = ImVec2(0.5f, 0.5f);
auto c = [](int r, int g, int b, float a = 1.0f) { return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a); };
ImVec4* k = st.Colors;
const ImVec4 gold = c(222, 189, 120), goldDim = c(150, 122, 74);
k[ImGuiCol_Text] = c(246, 240, 226);
k[ImGuiCol_TextDisabled] = c(140, 134, 120);
k[ImGuiCol_WindowBg] = c(14, 17, 30, 0.92f);
k[ImGuiCol_ChildBg] = c(0, 0, 0, 0.0f);
k[ImGuiCol_PopupBg] = c(12, 14, 26, 0.96f);
k[ImGuiCol_Border] = c(150, 122, 74, 0.70f);
k[ImGuiCol_BorderShadow] = c(0, 0, 0, 0.0f);
k[ImGuiCol_FrameBg] = c(28, 32, 52, 0.85f);
k[ImGuiCol_FrameBgHovered] = c(44, 48, 72, 0.90f);
k[ImGuiCol_FrameBgActive] = c(58, 56, 70, 1.0f);
k[ImGuiCol_TitleBg] = c(20, 22, 36, 0.95f);
k[ImGuiCol_TitleBgActive] = c(46, 38, 26, 0.98f);
k[ImGuiCol_TitleBgCollapsed] = c(14, 17, 30, 0.75f);
k[ImGuiCol_MenuBarBg] = c(20, 22, 36, 1.0f);
k[ImGuiCol_ScrollbarBg] = c(8, 10, 18, 0.6f);
k[ImGuiCol_ScrollbarGrab] = c(110, 92, 60, 0.8f);
k[ImGuiCol_ScrollbarGrabHovered] = goldDim;
k[ImGuiCol_ScrollbarGrabActive] = gold;
k[ImGuiCol_CheckMark] = gold;
k[ImGuiCol_SliderGrab] = goldDim;
k[ImGuiCol_SliderGrabActive] = gold;
k[ImGuiCol_Button] = c(40, 42, 62, 0.90f);
k[ImGuiCol_ButtonHovered] = c(96, 78, 46, 0.95f);
k[ImGuiCol_ButtonActive] = c(150, 122, 74, 1.0f);
k[ImGuiCol_Header] = c(96, 78, 46, 0.55f);
k[ImGuiCol_HeaderHovered] = c(120, 98, 58, 0.75f);
k[ImGuiCol_HeaderActive] = c(150, 122, 74, 0.90f);
k[ImGuiCol_Separator] = c(150, 122, 74, 0.55f);
k[ImGuiCol_SeparatorHovered] = goldDim;
k[ImGuiCol_SeparatorActive] = gold;
k[ImGuiCol_ResizeGrip] = c(150, 122, 74, 0.25f);
k[ImGuiCol_ResizeGripHovered] = c(150, 122, 74, 0.65f);
k[ImGuiCol_ResizeGripActive] = gold;
k[ImGuiCol_Tab] = c(30, 32, 50, 0.90f);
k[ImGuiCol_TabHovered] = c(120, 98, 58, 0.85f);
k[ImGuiCol_TabSelected] = c(86, 70, 42, 1.0f);
k[ImGuiCol_TabSelectedOverline] = gold;
k[ImGuiCol_TabDimmed] = c(20, 22, 36, 0.90f);
k[ImGuiCol_TabDimmedSelected] = c(52, 44, 32, 1.0f);
k[ImGuiCol_PlotLines] = goldDim;
k[ImGuiCol_PlotLinesHovered] = gold;
k[ImGuiCol_PlotHistogram] = goldDim;
k[ImGuiCol_PlotHistogramHovered] = gold;
k[ImGuiCol_TableHeaderBg] = c(30, 32, 50, 1.0f);
k[ImGuiCol_TableBorderStrong] = c(150, 122, 74, 0.6f);
k[ImGuiCol_TableBorderLight] = c(150, 122, 74, 0.3f);
k[ImGuiCol_TableRowBg] = c(0, 0, 0, 0.0f);
k[ImGuiCol_TableRowBgAlt] = c(255, 255, 255, 0.03f);
k[ImGuiCol_TextSelectedBg] = c(150, 122, 74, 0.45f);
k[ImGuiCol_DragDropTarget] = gold;
k[ImGuiCol_NavCursor] = gold;
k[ImGuiCol_NavWindowingHighlight] = c(255, 255, 255, 0.7f);
k[ImGuiCol_NavWindowingDimBg] = c(0, 0, 0, 0.4f);
k[ImGuiCol_ModalWindowDimBg] = c(0, 0, 0, 0.55f);
}
// ---- primitives ------------------------------------------------------------------------
void panel(ImDrawList* dl, ImVec2 min, ImVec2 max, const PanelStyle& style) {
const float s = style.scale > 0.0f ? style.scale : displayScale();
const float fade = alphaOf(style.tint);
if (fade <= 0.0f) return;
min = snap(min);
max = snap(max);
const float r = style.rounding * s;
// Soft drop shadow: stacked, expanding translucent rounded rects.
const ImVec2 off(0.0f, std::round(5.0f * s));
const float spread = 14.0f * s;
for (int i = 8; i >= 1; --i) {
float e = spread * float(i) / 8.0f;
dl->AddRectFilled(min + off - ImVec2(e, e), max + off + ImVec2(e, e), rgba(0, 0, 0, 0.045f * fade), r + e);
}
// Navy -> near-black vertical gradient body.
verticalGradient(dl, min.y, max.y, IM_COL32(32, 39, 66, 255), IM_COL32(8, 10, 21, 255),
[&] { dl->AddRectFilled(min, max, rgba(255, 255, 255, style.alpha * fade), r); });
// Faint top sheen.
fadeLine(dl, ImVec2(min.x + r, min.y + 2.0f * s), ImVec2(max.x - r, min.y + 2.0f * s), std::max(1.0f, s),
rgba(255, 255, 255, 0.10f * fade));
// Thin warm-gold double border.
const ImU32 gold = withAlpha(modulate(kGold, style.tint | IM_COL32_A_MASK), fade);
const ImU32 goldDim = withAlpha(modulate(kGoldDim, style.tint | IM_COL32_A_MASK), 0.85f * fade);
const float outer = std::max(1.0f, std::round(1.5f * s));
const float inset = std::round(5.0f * s);
const ImVec2 h(0.5f, 0.5f);
dl->AddRect(min + h, max - h, gold, r, 0, outer);
dl->AddRect(min + ImVec2(inset, inset) + h, max - ImVec2(inset, inset) - h, goldDim, std::max(0.0f, r - inset), 0,
std::max(1.0f, s));
if (!style.ornate) return;
// Corner gems sitting across both border lines, bright L-brackets along the
// inner rule, plus a small gem centred on the top and bottom rules.
const float g = 6.5f * s, arm = std::round(24.0f * s), c = std::round(inset * 0.5f) + 0.5f;
const ImU32 light = withAlpha(IM_COL32(255, 240, 200, 255), fade);
const ImVec2 corners[4] = {{min.x + c, min.y + c}, {max.x - c, min.y + c}, {max.x - c, max.y - c}, {min.x + c, max.y - c}};
const ImVec2 dirs[4] = {{1, 1}, {-1, 1}, {-1, -1}, {1, -1}};
const float lt = std::max(1.0f, std::round(2.0f * s));
for (int i = 0; i < 4; ++i) {
ImVec2 p = corners[i], d = dirs[i];
ImVec2 q(p.x + d.x * (inset - c + 1.0f), p.y + d.y * (inset - c + 1.0f)); // on the inner rule
dl->AddLine(ImVec2(q.x + d.x * g * 0.6f, q.y), ImVec2(q.x + d.x * arm, q.y), gold, lt);
dl->AddLine(ImVec2(q.x, q.y + d.y * g * 0.6f), ImVec2(q.x, q.y + d.y * arm), gold, lt);
dl->AddCircleFilled(ImVec2(q.x + d.x * (arm + 3.0f * s), q.y), 1.6f * s, gold);
dl->AddCircleFilled(ImVec2(q.x, q.y + d.y * (arm + 3.0f * s)), 1.6f * s, gold);
gem(dl, p, g, gold, light, 1.0f);
}
if (max.x - min.x > 240.0f * s) {
float cx = std::round((min.x + max.x) * 0.5f);
gem(dl, ImVec2(cx, min.y + 0.5f), 3.5f * s, gold, light, 1.0f);
gem(dl, ImVec2(cx, max.y - 0.5f), 3.5f * s, gold, light, 1.0f);
}
}
void text(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, ImU32 color, std::string_view s, bool shadow) {
drawSpan(dl, orDefault(font), size, pos, color, s.data(), s.data() + s.size(), shadow);
}
void textWrapped(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, ImU32 color, std::string_view s,
float wrapWidth, bool shadow) {
font = orDefault(font);
static thread_local std::vector<Span> lines;
layoutLines(font, size, s.data(), s.data() + s.size(), wrapWidth, lines);
for (const Span& l : lines) {
drawSpan(dl, font, size, pos, color, l.b, l.e, shadow);
pos.y += lineAdvance(size);
}
}
ImVec2 measure(ImFont* font, float size, std::string_view s, float wrapWidth) {
font = orDefault(font);
if (wrapWidth <= 0.0f) return font->CalcTextSizeA(size, FLT_MAX, 0.0f, s.data(), s.data() + s.size());
static thread_local std::vector<Span> lines;
layoutLines(font, size, s.data(), s.data() + s.size(), wrapWidth, lines);
float w = 0.0f;
for (const Span& l : lines) w = std::max(w, font->CalcTextSizeA(size, FLT_MAX, 0.0f, l.b, l.e).x);
return ImVec2(w, lineAdvance(size) * float(lines.size() - 1) + size);
}
void textCentered(ImDrawList* dl, ImFont* font, float size, ImVec2 center, ImU32 color, std::string_view s,
bool shadow) {
ImVec2 sz = measure(font, size, s);
text(dl, font, size, ImVec2(center.x - sz.x * 0.5f, center.y - sz.y * 0.5f), color, s, shadow);
}
void titleText(ImDrawList* dl, ImVec2 center, std::string_view s, float size) {
ImFont* font = orDefault(g_fonts.title);
const char* b = s.data();
const char* e = b + s.size();
ImVec2 sz = font->CalcTextSizeA(size, FLT_MAX, 0.0f, b, e);
ImVec2 pos = snap(ImVec2(center.x - sz.x * 0.5f, center.y - sz.y * 0.5f));
const float u = size / 96.0f;
// Soft dark halo (legible on bright scenery), then a warm glow around the glyphs.
for (int ring = 0; ring < 3; ++ring) {
float rad = (ring == 0 ? 10.0f : ring == 1 ? 6.0f : 3.5f) * u;
ImU32 col = ring == 0 ? IM_COL32(0, 0, 0, 22) : ring == 1 ? IM_COL32(10, 6, 0, 34) : IM_COL32(255, 200, 110, 46);
for (int i = 0; i < 12; ++i) {
float a = float(i) * 3.14159265f / 6.0f;
dl->AddText(font, size, pos + ImVec2(std::cos(a), std::sin(a)) * rad, col, b, e);
}
}
// Dark outline + drop shadow keep the letterforms crisp against the glow.
for (int i = 0; i < 8; ++i) {
float a = float(i) * 3.14159265f / 4.0f;
dl->AddText(font, size, pos + ImVec2(std::cos(a), std::sin(a)) * (2.0f * u), IM_COL32(20, 12, 4, 200), b, e);
}
dl->AddText(font, size, pos + ImVec2(0.0f, 4.0f * u), IM_COL32(0, 0, 0, 150), b, e);
// Gold face: pale highlight at the top to deep gold at the bottom.
verticalGradient(dl, pos.y + sz.y * 0.2f, pos.y + sz.y * 0.85f, IM_COL32(255, 246, 214, 255),
IM_COL32(200, 142, 52, 255), [&] { dl->AddText(font, size, pos, IM_COL32_WHITE, b, e); });
// Flourish rule underneath: fading gold lines with a centre gem, on a dark underlay.
float y = std::round(pos.y + sz.y * 0.95f) + 0.5f;
float half = sz.x * 0.5f + 40.0f * u;
float cx = center.x;
const float lt = std::max(1.0f, std::round(2.0f * u));
for (int pass = 0; pass < 2; ++pass) {
ImU32 col = pass ? kGold : IM_COL32(0, 0, 0, 110);
ImU32 clear = withAlpha(col, 0.0f);
float t = pass ? lt : lt + 2.0f * std::max(1.0f, u);
dl->AddRectFilledMultiColor(ImVec2(cx - half, y - t * 0.5f), ImVec2(cx - 12.0f * u, y + t * 0.5f), clear, col,
col, clear);
fadeLine(dl, ImVec2(cx + 12.0f * u, y), ImVec2(cx + half, y), t, col, false);
}
gem(dl, ImVec2(cx, y), 8.0f * u, kGold, IM_COL32(255, 240, 200, 255), 1.0f);
gem(dl, ImVec2(cx - 24.0f * u, y), 4.0f * u, kGold, IM_COL32(255, 240, 200, 255), 1.0f);
gem(dl, ImVec2(cx + 24.0f * u, y), 4.0f * u, kGold, IM_COL32(255, 240, 200, 255), 1.0f);
}
void nameplate(ImDrawList* dl, ImVec2 anchor, std::string_view s, ImU32 accent, float scale) {
ImFont* font = orDefault(g_fonts.name);
const float size = std::round(kNameSize * 0.82f * scale);
ImVec2 ts = measure(font, size, s);
const float padX = std::round(16.0f * scale), padY = std::round(6.0f * scale), tip = std::round(8.0f * scale);
ImVec2 sz(std::round(ts.x + padX * 2.0f), std::round(size + padY * 2.0f));
ImVec2 min = snap(ImVec2(anchor.x - sz.x * 0.5f, anchor.y - tip - sz.y));
ImVec2 max = min + sz;
const float r = sz.y * 0.5f;
dl->AddRectFilled(min + ImVec2(0, 3.0f * scale) - ImVec2(2, 2) * scale, max + ImVec2(0, 3.0f * scale) + ImVec2(2, 2) * scale,
IM_COL32(0, 0, 0, 70), r + 2.0f * scale);
// Pointer below the pill.
ImVec2 t0(anchor.x - tip, max.y - 1.0f), t1(anchor.x + tip, max.y - 1.0f), t2(anchor.x, anchor.y);
dl->AddTriangleFilled(t0 - ImVec2(1.5f, 0) * scale, t1 + ImVec2(1.5f, 0) * scale, t2 + ImVec2(0, 2.0f) * scale, accent);
dl->AddTriangleFilled(t0 + ImVec2(1.5f, 0) * scale, t1 - ImVec2(1.5f, 0) * scale, t2 - ImVec2(0, 1.0f) * scale,
IM_COL32(10, 12, 24, 235));
verticalGradient(dl, min.y, max.y, IM_COL32(34, 40, 66, 255), IM_COL32(10, 12, 24, 255),
[&] { dl->AddRectFilled(min, max, IM_COL32(255, 255, 255, 230), r); });
dl->AddRect(min + ImVec2(0.5f, 0.5f), max - ImVec2(0.5f, 0.5f), accent, r, 0, std::max(1.0f, std::round(1.5f * scale)));
text(dl, font, size, ImVec2(min.x + padX, min.y + padY - 1.0f * scale), kTextColor, s);
}
ImVec2 menuSize(const std::vector<std::string>& items, std::string_view title, float scale) {
return listMetrics(items, title, scale).panel;
}
void menuAt(ImDrawList* dl, ImVec2 pos, ImVec2 pivot, const std::vector<std::string>& items, int selected,
std::string_view title, float scale) {
ListMetrics m = listMetrics(items, title, scale);
drawList(dl, ImVec2(pos.x - m.panel.x * pivot.x, pos.y - m.panel.y * pivot.y), m, items, selected, title);
}
void menu(ImDrawList* dl, ImVec2 center, const std::vector<std::string>& items, int selected, std::string_view title,
float scale) {
menuAt(dl, center, ImVec2(0.5f, 0.5f), items, selected, title, scale);
}
void progressBar(ImDrawList* dl, ImVec2 min, ImVec2 max, float t, ImU32 color) {
min = snap(min);
max = snap(max);
const float h = max.y - min.y, r = h * 0.5f;
dl->AddRectFilled(min, max, IM_COL32(6, 8, 14, 225), r);
const float in = std::max(1.0f, std::round(h * 0.18f));
ImVec2 f0(min.x + in, min.y + in), f1(max.x - in, max.y - in);
float w = (f1.x - f0.x) * std::clamp(t, 0.0f, 1.0f);
if (w > 0.5f) {
f1.x = f0.x + w;
float fr = std::min(r - in, w * 0.5f);
verticalGradient(dl, f0.y, f1.y, mix(color, IM_COL32_WHITE, 0.45f), mix(color, IM_COL32_BLACK, 0.25f),
[&] { dl->AddRectFilled(f0, f1, IM_COL32(255, 255, 255, int(alphaOf(color) * 255.0f)), fr); });
float hy = f0.y + std::max(1.0f, (f1.y - f0.y) * 0.25f);
if (w > 2.0f * fr)
dl->AddLine(ImVec2(f0.x + fr, hy), ImVec2(f1.x - fr, hy), IM_COL32(255, 255, 255, 70), 1.0f);
}
dl->AddRect(min + ImVec2(0.5f, 0.5f), max - ImVec2(0.5f, 0.5f), withAlpha(kGoldDim, 0.9f), r, 0, 1.0f);
}
void advanceIndicator(ImDrawList* dl, ImVec2 c, float scale, float time) {
const float bob = std::sin(time * 4.5f) * 2.5f * scale;
const float a = 0.6f + 0.4f * std::cos(time * 4.5f);
const float w = 8.0f * scale, h = 10.0f * scale;
c.y += bob;
ImVec2 l(c.x - w, c.y - h * 0.4f), r(c.x + w, c.y - h * 0.4f), b(c.x, c.y + h * 0.6f), top(c.x, c.y - h * 0.9f);
ImVec2 sh(1.0f * scale, 2.0f * scale);
dl->AddQuadFilled(top + sh, r + sh, b + sh, l + sh, rgba(0, 0, 0, 0.55f * a));
dl->AddQuadFilled(top, r, b, l, withAlpha(kGold, a));
dl->AddTriangleFilled(l, top, ImVec2(c.x, c.y - h * 0.4f), withAlpha(IM_COL32(255, 244, 210, 255), a));
dl->AddTriangleFilled(ImVec2(c.x, c.y - h * 0.4f), r, b, withAlpha(IM_COL32(170, 128, 60, 255), a));
}
// ---- DialogueBox ---------------------------------------------------------------------------
void DialogueBox::start(std::vector<DialogueLine> lines, std::vector<std::string> choices) {
lines_ = std::move(lines);
if (lines_.empty()) lines_.emplace_back();
choices_ = std::move(choices);
line_ = 0;
revealed_ = 0;
budget_ = 0.0f;
selected_ = 0;
chosen_ = -1;
open_ = true;
}
bool DialogueBox::lineRevealed() const { return !open_ || revealed_ >= lines_[line_].text.size(); }
bool DialogueBox::choicesVisible() const {
return open_ && !choices_.empty() && line_ + 1 == lines_.size() && lineRevealed();
}
void DialogueBox::stepChar() {
const std::string& t = lines_[line_].text;
const char* p = t.data() + revealed_;
size_t len = std::min(size_t(utf8Len(static_cast<unsigned char>(*p))), t.size() - revealed_);
// Swallow malformed continuation bytes so we always land on a boundary.
while (revealed_ + len < t.size() && (static_cast<unsigned char>(t[revealed_ + len]) & 0xC0) == 0x80) ++len;
revealed_ += len;
bool atBreak = revealed_ >= t.size() || t[revealed_] == ' ' || t[revealed_] == '\n' || t[revealed_] == '"' ||
t[revealed_] == '\'' || t[revealed_] == ')';
if (atBreak && revealed_ < t.size()) {
if (isSentenceEnd(p, len)) budget_ -= sentencePause;
else if (isClauseEnd(p, len)) budget_ -= clausePause;
}
}
void DialogueBox::update(float dt) {
if (!open_) return;
time_ += dt;
if (lineRevealed()) {
budget_ = 0.0f;
return;
}
budget_ += dt;
const float cost = 1.0f / std::max(1.0f, charsPerSecond);
while (!lineRevealed() && budget_ >= cost) {
budget_ -= cost;
stepChar();
}
}
bool DialogueBox::advance() {
if (!open_) return false;
if (!lineRevealed()) {
revealed_ = lines_[line_].text.size();
return false;
}
if (line_ + 1 < lines_.size()) {
++line_;
revealed_ = 0;
budget_ = 0.0f;
return false;
}
chosen_ = choices_.empty() ? -1 : selected_;
open_ = false;
return true;
}
void DialogueBox::moveChoice(int delta) {
if (!choicesVisible()) return;
int n = int(choices_.size());
selected_ = ((selected_ + delta) % n + n) % n;
}
void DialogueBox::close() {
open_ = false;
chosen_ = -1;
}
void DialogueBox::draw(ImVec2 viewport) {
if (!open_) return;
ImDrawList* dl = ImGui::GetForegroundDrawList();
const DialogueLine& line = lines_[line_];
const float s = scaleFor(viewport);
ImFont* body = orDefault(g_fonts.body);
const float size = std::round(kBodySize * s);
const float lh = lineAdvance(size);
const float padX = std::round(48.0f * s), padY = std::round(30.0f * s);
const float boxW = std::round(std::min(viewport.x * 0.70f, viewport.y * 1.6f));
float textX = padX, textW = boxW - padX * 2.0f;
const float minTextH = lh * 2.0f + size; // room for three lines
float portrait = 0.0f;
if (line.portrait) {
portrait = std::round(minTextH + padY * 2.0f - 28.0f * s);
textX = std::round(20.0f * s + portrait + 30.0f * s);
textW = boxW - textX - padX;
}
static thread_local std::vector<Span> spans;
const char* tb = line.text.data();
const char* te = tb + line.text.size();
layoutLines(body, size, tb, te, textW, spans);
const float textH = std::max(minTextH, lh * float(spans.size() - 1) + size);
const float boxH = std::round(textH + padY * 2.0f);
const ImVec2 min = snap(ImVec2((viewport.x - boxW) * 0.5f, viewport.y - std::round(40.0f * s) - boxH));
const ImVec2 max = min + ImVec2(boxW, boxH);
PanelStyle ps;
ps.scale = s;
panel(dl, min, max, ps);
if (line.portrait) {
ImVec2 p0 = snap(ImVec2(min.x + 20.0f * s, min.y + (boxH - portrait) * 0.5f)), p1 = p0 + ImVec2(portrait, portrait);
dl->AddRectFilled(p0, p1, IM_COL32(4, 6, 12, 200), 3.0f * s);
dl->AddImage(line.portrait, p0, p1, line.uv0, line.uv1);
dl->AddRect(p0 - ImVec2(0.5f, 0.5f), p1 + ImVec2(0.5f, 0.5f), withAlpha(kGoldDim, 0.9f), 3.0f * s, 0,
std::max(1.0f, s));
}
// Typewriter: every line was wrapped from the full text, so words never jump.
const char* shown = tb + revealed_;
ImVec2 pos(min.x + textX, min.y + padY);
for (const Span& sp : spans) {
if (sp.b >= shown) break;
drawSpan(dl, body, size, pos, kTextColor, sp.b, std::min(sp.e, shown), true);
pos.y += lh;
}
// Gold speaker name plate straddling the top-left edge.
if (!line.speaker.empty()) {
ImFont* nf = orDefault(g_fonts.name);
const float ns = std::round(kNameSize * s);
ImVec2 ts = measure(nf, ns, line.speaker);
ImVec2 sz(std::round(ts.x + 44.0f * s), std::round(ns + 16.0f * s));
ImVec2 n0 = snap(ImVec2(min.x + 46.0f * s, min.y - sz.y * 0.62f)), n1 = n0 + sz;
const float r = 4.0f * s;
for (int i = 4; i >= 1; --i) {
float e = 2.0f * s * float(i);
dl->AddRectFilled(n0 - ImVec2(e, e) + ImVec2(0, 3.0f * s), n1 + ImVec2(e, e) + ImVec2(0, 3.0f * s),
IM_COL32(0, 0, 0, 22), r + e);
}
verticalGradient(dl, n0.y, n1.y, IM_COL32(246, 222, 160, 255), IM_COL32(170, 124, 56, 255),
[&] { dl->AddRectFilled(n0, n1, IM_COL32_WHITE, r); });
dl->AddRect(n0 + ImVec2(0.5f, 0.5f), n1 - ImVec2(0.5f, 0.5f), IM_COL32(92, 64, 26, 255), r, 0, std::max(1.0f, s));
float in = std::round(3.0f * s);
dl->AddRect(n0 + ImVec2(in, in) + ImVec2(0.5f, 0.5f), n1 - ImVec2(in, in) - ImVec2(0.5f, 0.5f),
IM_COL32(255, 244, 210, 150), std::max(0.0f, r - in), 0, 1.0f);
ImVec2 tp = snap(ImVec2(n0.x + (sz.x - ts.x) * 0.5f, n0.y + (sz.y - ts.y) * 0.5f));
const char* sb = line.speaker.data();
const char* se = sb + line.speaker.size();
dl->AddText(nf, ns, tp + ImVec2(0, 1.0f), IM_COL32(255, 248, 225, 140), sb, se); // engraved highlight
dl->AddText(nf, ns, tp, IM_COL32(40, 26, 10, 255), sb, se);
}
if (choicesVisible()) {
menuAt(dl, ImVec2(max.x - 28.0f * s, min.y - 14.0f * s), ImVec2(1.0f, 1.0f), choices_, selected_, {}, s * 0.9f);
} else if (lineRevealed()) {
advanceIndicator(dl, ImVec2(max.x - 34.0f * s, max.y - 26.0f * s), s, time_);
}
}
// ---- Toasts -------------------------------------------------------------------------------
void Toasts::push(std::string text, ImU32 accent, float seconds) {
items_.push_back({std::move(text), accent, std::max(seconds, 0.6f)});
if (items_.size() > 6) items_.erase(items_.begin());
}
void Toasts::update(float dt) {
for (Item& it : items_) it.age += dt;
std::erase_if(items_, [](const Item& it) { return it.age >= it.seconds; });
}
void Toasts::draw(ImVec2 viewport) {
if (items_.empty()) return;
ImDrawList* dl = ImGui::GetForegroundDrawList();
const float s = scaleFor(viewport);
ImFont* font = orDefault(g_fonts.small);
const float size = std::round(kSmallSize * s);
const float padX = std::round(28.0f * s), padY = std::round(12.0f * s), gemW = std::round(22.0f * s);
float y = std::round(64.0f * s);
for (const Item& it : items_) {
float in = std::clamp(it.age / 0.25f, 0.0f, 1.0f), out = std::clamp((it.seconds - it.age) / 0.5f, 0.0f, 1.0f);
float a = std::min(in, out);
in = 1.0f - (1.0f - in) * (1.0f - in);
ImVec2 ts = measure(font, size, it.text);
ImVec2 sz(std::round(ts.x + padX * 2.0f + gemW), std::round(size + padY * 2.0f));
ImVec2 min = snap(ImVec2((viewport.x - sz.x) * 0.5f, y - (1.0f - in) * 14.0f * s)), max = min + sz;
PanelStyle ps;
ps.scale = s;
ps.ornate = false;
ps.rounding = 4.0f;
ps.alpha = 0.85f;
ps.tint = withAlpha(IM_COL32_WHITE, a);
panel(dl, min, max, ps);
gem(dl, ImVec2(min.x + padX + 4.0f * s, (min.y + max.y) * 0.5f), 6.0f * s, it.accent,
mix(it.accent, IM_COL32_WHITE, 0.55f), a);
fadeLine(dl, ImVec2(min.x + 10.0f * s, max.y - 7.0f * s), ImVec2(max.x - 10.0f * s, max.y - 7.0f * s),
std::max(1.0f, s), withAlpha(it.accent, 0.7f * a));
text(dl, font, size, ImVec2(min.x + padX + gemW, min.y + padY - 1.0f * s), withAlpha(kTextColor, a), it.text);
y += std::round((sz.y + 10.0f * s) * std::max(in, 0.0f));
}
}
} // namespace hd2d::ui

View File

@@ -16,103 +16,259 @@ Weather::Weather() {
storm.horizon = {0.42f, 0.45f, 0.50f};
storm.ground = {0.13f, 0.15f, 0.13f};
storm.ambient = 0.62f;
storm.clouds = 1.0f;
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.shadowSoftness = 3.0f;
storm.windStrength = 0.2f;
storm.windGustSpeed = 3.0f;
storm.bloomStrength = 0.06f;
storm.saturation = 0.92f;
// Dry overcast: flat grey-white sky, weak diffuse sun with soft faint
// shadows, muted colours.
overcastLook.sunIntensity = 0.35f;
overcastLook.sunColor = {0.88f, 0.89f, 0.92f};
overcastLook.zenith = {0.56f, 0.59f, 0.63f};
overcastLook.horizon = {0.72f, 0.73f, 0.75f};
overcastLook.ground = {0.18f, 0.20f, 0.17f};
overcastLook.ambient = 1.1f;
overcastLook.clouds = 1.0f;
overcastLook.fogColor = {0.46f, 0.48f, 0.52f};
overcastLook.fogDensity = 0.016f;
overcastLook.fogHeightFalloff = 0.15f;
overcastLook.godRays = 0.8f;
overcastLook.shaftStrength = 0.3f;
overcastLook.cookieStrength = 0.0f;
overcastLook.shadowSoftness = 5.0f;
overcastLook.windStrength = 0.1f;
overcastLook.windGustSpeed = 1.8f;
overcastLook.bloomStrength = 0.05f;
overcastLook.saturation = 0.85f;
// Morning mist: thick fog hugging the ground, sun still through it, few
// god rays (the beams would drown in the dense fog), calm air.
mist.sunIntensity = 2.4f;
mist.sunColor = {1.0f, 0.92f, 0.82f};
mist.zenith = {0.50f, 0.58f, 0.70f};
mist.horizon = {0.80f, 0.82f, 0.84f};
mist.ground = {0.20f, 0.22f, 0.18f};
mist.ambient = 0.75f;
mist.clouds = 0.3f;
mist.fogColor = {0.70f, 0.72f, 0.76f};
mist.fogDensity = 0.07f;
mist.fogHeightFalloff = 0.5f;
mist.godRays = 1.5f;
mist.shaftStrength = 0.5f;
mist.cookieStrength = 0.35f;
mist.shadowSoftness = 2.5f;
mist.windStrength = 0.03f;
mist.windGustSpeed = 0.8f;
mist.bloomStrength = 0.09f;
mist.saturation = 0.9f;
}
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 mixLooks(const WeatherLook& a, const WeatherLook& b, float t) {
WeatherLook o;
auto m = [t](auto x, auto y) { return x + (y - x) * t; };
// Angles: blend along the shorter arc.
float da = std::remainder(b.sunAzimuth - a.sunAzimuth, 6.2831853f);
o.sunAzimuth = a.sunAzimuth + da * t;
o.sunElevation = m(a.sunElevation, b.sunElevation);
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.fill = m(a.fill, b.fill);
o.night = m(a.night, b.night);
o.clouds = m(a.clouds, b.clouds);
o.daylight = m(a.daylight, b.daylight);
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.shadowSoftness = m(a.shadowSoftness, b.shadowSoftness);
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);
o.exposure = m(a.exposure, b.exposure);
o.lift = m(a.lift, b.lift);
o.gain = m(a.gain, b.gain);
o.fireflyGlow = m(a.fireflyGlow, b.fireflyGlow);
return o;
}
WeatherLook Weather::capture(const Scene& s, const RenderSettings& r) {
WeatherLook l;
l.sunAzimuth = s.sun.azimuth;
l.sunElevation = s.sun.elevation;
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.fill = s.sky.fill;
l.night = s.sky.night;
l.clouds = s.sky.clouds;
l.daylight = 1.0f;
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.shadowSoftness = r.shadowSoftness;
l.windStrength = r.windStrength;
l.windGustSpeed = r.windGustSpeed;
l.bloomStrength = r.bloomStrength;
l.saturation = r.saturation;
l.exposure = r.exposure;
l.lift = r.lift;
l.gain = r.gain;
l.fireflyGlow = s.particles ? s.particles->fireflyGlow : 1.0f;
return l;
}
void Weather::apply(const WeatherLook& l, Scene& s, RenderSettings& r) {
s.sun.azimuth = l.sunAzimuth;
s.sun.elevation = l.sunElevation;
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;
s.sky.fill = l.fill;
s.sky.night = l.night;
s.sky.clouds = l.clouds;
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.shadowSoftness = l.shadowSoftness;
r.windStrength = l.windStrength;
r.windGustSpeed = l.windGustSpeed;
r.bloomStrength = l.bloomStrength;
r.saturation = l.saturation;
r.exposure = l.exposure;
r.lift = l.lift;
r.gain = l.gain;
if (s.particles) s.particles->fireflyGlow = l.fireflyGlow;
}
void Weather::setClearLook(const WeatherLook& look) {
clear_ = look;
haveClear_ = externalClear_ = true;
}
void Weather::snap() {
rain_ = wetness_ = targetRain_;
overcast_ = targetOvercast_;
fog_ = targetFog_;
}
bool Weather::clear() const {
return rain_ <= 0.0f && targetRain_ <= 0.0f && overcast_ <= 0.0f && targetOvercast_ <= 0.0f && fog_ <= 0.0f &&
targetFog_ <= 0.0f && flash_ <= 0.0f;
}
// A preset placed in the clear look's time of day: darkened by its daylight,
// tinted toward its sky hue by `hueKeep`, with the time-of-day fields copied.
WeatherLook Weather::relative(const WeatherLook& preset, float hueKeep) const {
const WeatherLook& c = clear_;
const float b = glm::mix(nightBrightness, 1.0f, glm::clamp(c.daylight, 0.0f, 1.0f));
WeatherLook o = preset;
// The sun / moon fades faster than the sky (no moon glitter under storm
// clouds), and weather never makes it brighter than the clear look.
o.sunIntensity = std::min(preset.sunIntensity * b * b, c.sunIntensity);
o.sunColor = glm::mix(preset.sunColor, c.sunColor, hueKeep);
o.ambient = glm::mix(preset.ambient, c.ambient, hueKeep);
o.zenith = glm::mix(preset.zenith * b, c.zenith, hueKeep);
o.horizon = glm::mix(preset.horizon * b, c.horizon, hueKeep);
o.ground = glm::mix(preset.ground * b, c.ground, hueKeep);
o.fogColor = glm::mix(preset.fogColor * b, c.horizon, hueKeep);
o.sunAzimuth = c.sunAzimuth;
o.sunElevation = c.sunElevation;
o.fill = c.fill;
o.night = c.night;
o.daylight = c.daylight;
o.exposure = c.exposure;
o.lift = c.lift;
o.gain = c.gain;
o.fireflyGlow = c.fireflyGlow;
return o;
}
static float smooth01(float x) { return x * x * (3.0f - 2.0f * x); }
static float approach(float v, float target, float step) {
return v < target ? std::min(target, v + step) : std::max(target, v - step);
}
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_) {
// Without an external clear look, keep tracking the live look while fully
// clear so UI tweaks survive.
if (!externalClear_ && (clear() || !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);
rain_ = approach(rain_, targetRain_, step);
overcast_ = approach(overcast_, targetOvercast_, step);
fog_ = approach(fog_, targetFog_, 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);
wetness_ = approach(wetness_, rain_, wetness_ < rain_ ? step * 1.5f : step * 0.3f);
float k = rain_ * rain_ * (3.0f - 2.0f * rain_); // smoothstep
if (rain_ > 0.0f || targetRain_ > 0.0f) apply(mixLook(clear_, storm, k), scene, settings);
// Lightning: random double flashes while it pours.
flashAge_ += dt;
if (lightning_ && rain_ > 0.5f) {
nextFlash_ -= dt * rain_;
if (nextFlash_ <= 0.0f) {
std::exponential_distribution<float> gap(1.0f / std::max(lightningInterval, 0.5f));
nextFlash_ = 1.5f + gap(rng_);
flashAge_ = 0.0f;
}
}
// Two sharp pulses (0 and 0.18 s) with a quick decay.
auto pulse = [](float t) { return t < 0.0f ? 0.0f : std::exp(-t * 18.0f); };
flash_ = flashAge_ < 1.0f ? std::min(1.0f, pulse(flashAge_) + 0.7f * pulse(flashAge_ - 0.18f)) : 0.0f;
float kr = smooth01(rain_), ko = smooth01(overcast_), kf = smooth01(fog_);
if (externalClear_ || !clear()) {
const float night = 1.0f - glm::clamp(clear_.daylight, 0.0f, 1.0f);
WeatherLook l = clear_;
if (ko > 0.0f) l = mixLooks(l, relative(overcastLook, 0.5f * night), ko);
if (kf > 0.0f) l = mixLooks(l, relative(mist, 0.45f + 0.3f * night), kf);
if (kr > 0.0f) l = mixLooks(l, relative(storm, 0.5f * night), kr);
if (flash_ > 0.0f) {
const glm::vec3 bolt(0.75f, 0.8f, 1.0f);
l.sunIntensity += 7.0f * flash_;
l.sunColor = glm::mix(l.sunColor, bolt, flash_);
l.zenith += bolt * 1.4f * flash_;
l.horizon += bolt * 1.6f * flash_;
l.fogColor += bolt * 0.8f * flash_;
l.ambient += 0.6f * flash_;
}
apply(l, scene, settings);
}
scene.rain = rain_;
scene.wetness = wetness_;
if (scene.particles) {
ParticleSystem& ps = *scene.particles;
ps.ambientFade = 1.0f - k;
ps.ambientFade = (1.0f - kr) * (1.0f - 0.6f * kf);
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;
@@ -120,7 +276,9 @@ void Weather::update(float dt, Scene& scene, RenderSettings& settings) {
// 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;
// Splash highlight dims with the sky (no bright specks at night).
float lit = glm::clamp(scene.sky.horizon.g / 0.45f, 0.0f, 1.0f);
glm::vec3 color = (scene.sky.horizon * 0.8f + glm::vec3(0.15f * lit)) * 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;

View File

@@ -17,7 +17,8 @@ static void APIENTRY glDebugCallback(GLenum, GLenum type, GLuint, GLenum severit
HD2D_ERR("GL: %s", message);
}
bool Window::create(int width, int height, const std::string& title, bool vsync, bool resizable) {
bool Window::create(int width, int height, const std::string& title, bool vsync, bool resizable, bool visible) {
visible_ = visible;
glfwSetErrorCallback([](int code, const char* msg) { HD2D_ERR("GLFW %d: %s", code, msg); });
if (!glfwInit()) return false;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
@@ -25,6 +26,9 @@ bool Window::create(int width, int height, const std::string& title, bool vsync,
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
glfwWindowHint(GLFW_RESIZABLE, resizable ? GLFW_TRUE : GLFW_FALSE);
glfwWindowHint(GLFW_VISIBLE, visible ? GLFW_TRUE : GLFW_FALSE);
glfwWindowHint(GLFW_FOCUSED, visible ? GLFW_TRUE : GLFW_FALSE);
glfwWindowHint(GLFW_FOCUS_ON_SHOW, visible ? GLFW_TRUE : GLFW_FALSE);
#ifndef NDEBUG
glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GLFW_TRUE);
#endif
@@ -34,7 +38,7 @@ bool Window::create(int width, int height, const std::string& title, bool vsync,
return false;
}
glfwMakeContextCurrent(window_);
glfwSwapInterval(vsync ? 1 : 0);
glfwSwapInterval(vsync && visible ? 1 : 0);
glfwSetWindowUserPointer(window_, this);
glfwSetFramebufferSizeCallback(window_, onFramebufferSize);
glfwSetScrollCallback(window_, onScroll);
@@ -75,11 +79,13 @@ bool Window::pollEvents() {
return !glfwWindowShouldClose(window_);
}
void Window::swapBuffers() { glfwSwapBuffers(window_); }
void Window::swapBuffers() {
if (visible_) glfwSwapBuffers(window_);
}
bool Window::keyDown(int key) const { return key >= 0 && key < 512 && keys_[key]; }
bool Window::keyDown(int key) const { return visible_ && 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; }
bool Window::mouseDown(int button) const { return visible_ && glfwGetMouseButton(window_, button) == GLFW_PRESS; }
void Window::setTitle(const std::string& title) { glfwSetWindowTitle(window_, title.c_str()); }
void Window::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); }