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

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
# Build output
build/
build-*/
build_*/
cmake-build-*/
out/
compile_commands.json

View File

@@ -8,20 +8,26 @@ before making any sprite, texture or model**.
```sh
cmake -S . -B build -G Ninja && ninja -C build
./build/hd2d_demo # interactive (WASD, R = rain, F1 = panels)
./build/hd2d_demo # interactive (WASD, R/O/G = rain/overcast/fog, [ ] = hour, F1 = panels)
./build/hd2d_demo --screenshot out.png --frames 90 \
--pos 25,43.6 --dist 10 --yaw 30 --rain 1 --set dof=0 # scripted capture
--pos 25,43.6 --dist 10 --yaw 30 --rain 1 --set dof=0 # scripted capture (hidden)
```
There is no test suite; verification is visual. After any rendering, asset or
gameplay change, capture screenshots of the affected area and look at them.
Use a Debug build (`-DCMAKE_BUILD_TYPE=Debug`) to get OpenGL errors printed.
Captures run hidden: `--screenshot` implies a hidden window + offscreen target
(`Window::create(..., visible = false)`, `OffscreenTarget`,
`Renderer::setOutputFramebuffer`) and ignores keyboard/mouse, so they never pop
up on or react to the user's desktop. Add `--show` only if you really need to
watch a capture. New capture paths in any app must keep this behaviour.
## Map
| Path | What |
| --- | --- |
| `engine/include/hd2d/`, `engine/src/` | engine library (`Renderer`, `Terrain`, `BillboardBatch`, `ModelLibrary`/`ModelBatch`, `Water`, `ParticleSystem`, `Weather`, `GroundOcclusion`, `DebugUI`) |
| `engine/include/hd2d/`, `engine/src/` | engine library (`Renderer`, `Terrain`, `BillboardBatch`, `SpriteAtlas`, `ModelLibrary`/`ModelBatch`, `Water`, `ParticleSystem`, `Weather`, `DayCycle`, `GroundOcclusion`, `DebugUI`) |
| `assets/shaders/` | GLSL; `common.glsl` holds the shared `Frame` UBO and lighting helpers |
| `demo/` | meadow scene: terrain layout, prop scattering, bridge, fires, player movement |
| `tools/pixellab.py` + `tools/assets.json` | pixel-art generation (PixelLab API, key in `.env`) |
@@ -33,10 +39,17 @@ Use a Debug build (`-DCMAKE_BUILD_TYPE=Debug`) to get OpenGL errors printed.
- `FrameUBO` in `engine/src/Renderer.cpp` must mirror `Frame` in
`assets/shaders/common.glsl` exactly (std140, vec4/mat4 only); update the
`static_assert` size when adding fields.
- `BillboardInstance` (`Billboard.h`, 64 bytes = four vec4s) must match the
attributes in `billboard.vert` and the VAO in `Billboard.cpp`; keep new
fields in vec4-sized groups, update the `static_assert`, and keep the
defaults rendering exactly like the old whole-texture sprite.
- `pixelart.glsl` uses derivatives: include it only in fragment shaders.
- Game UI is `hd2d::ui` (`UI.h`): ImDrawList only, sizes in 1080p px × `scaleFor(viewport)`, OFL fonts in `assets/fonts/`; verify with `--ui-demo` captures (`--frames 120` mid-reveal, `420` revealed).
- Use GL 4.5 DSA calls (`glCreate*`, `glNamed*`, `glTexture*`) like the rest
of the code. Shaders hot-reload with F5.
- Shaders and assets load from `HD2D_ASSET_DIR` (set by CMake to `assets/`).
- Shaders and assets load from `HD2D_ASSET_DIR` (demo, set by CMake to
`assets/`); the `hd2d` target exports `HD2D_ENGINE_ASSET_DIR` for parent
projects that `add_subdirectory` the engine (demo off by default there).
- Generated files (`assets/textures/*`, `assets/models/*`) come from scripts in
`tools/`; change the script, not the output.
- PixelLab calls cost credits: check `--balance`, reuse `tile_id`s, and do not

View File

@@ -1,11 +1,21 @@
cmake_minimum_required(VERSION 3.20)
cmake_minimum_required(VERSION 3.21) # PROJECT_IS_TOP_LEVEL
project(hd2d LANGUAGES C CXX)
# Usable standalone or from a parent project via
# `add_subdirectory(path/to/Vibe-HD-2D hd2d)`; link `hd2d` and load engine
# shaders from `HD2D_ENGINE_ASSET_DIR` (compile definition and cache variable).
# The engine provides the `imgui` target (Dear ImGui + GLFW/OpenGL3
# backends) unless the parent already defines one.
option(HD2D_BUILD_DEMO "Build the demo" ${PROJECT_IS_TOP_LEVEL})
set(HD2D_ENGINE_ASSET_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets" CACHE INTERNAL "Engine shaders and assets")
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE)
if(PROJECT_IS_TOP_LEVEL)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE)
endif()
endif()
include(FetchContent)
@@ -14,21 +24,27 @@ include(FetchContent)
# System packages are preferred; anything missing is fetched.
find_package(OpenGL REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy)
find_package(glfw3 3.3 QUIET)
if(NOT glfw3_FOUND)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
FetchContent_Declare(glfw GIT_REPOSITORY https://github.com/glfw/glfw.git GIT_TAG 3.4)
FetchContent_MakeAvailable(glfw)
if(NOT TARGET PkgConfig::EPOXY)
pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy)
endif()
find_package(glm QUIET)
if(NOT glm_FOUND)
FetchContent_Declare(glm GIT_REPOSITORY https://github.com/g-truc/glm.git GIT_TAG 1.0.1)
FetchContent_MakeAvailable(glm)
if(NOT TARGET glfw)
find_package(glfw3 3.3 QUIET)
if(NOT glfw3_FOUND)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
FetchContent_Declare(glfw GIT_REPOSITORY https://github.com/glfw/glfw.git GIT_TAG 3.4)
FetchContent_MakeAvailable(glfw)
endif()
endif()
if(NOT TARGET glm::glm)
find_package(glm QUIET)
if(NOT glm_FOUND)
FetchContent_Declare(glm GIT_REPOSITORY https://github.com/g-truc/glm.git GIT_TAG 1.0.1)
FetchContent_MakeAvailable(glm)
endif()
endif()
find_path(STB_INCLUDE_DIR stb_image.h PATH_SUFFIXES stb)
@@ -45,17 +61,19 @@ if(NOT CGLTF_INCLUDE_DIR)
set(CGLTF_INCLUDE_DIR ${cgltf_SOURCE_DIR})
endif()
FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git GIT_TAG v1.91.9b)
FetchContent_MakeAvailable(imgui)
add_library(imgui STATIC
${imgui_SOURCE_DIR}/imgui.cpp
${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp
${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp)
target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends)
target_link_libraries(imgui PUBLIC glfw OpenGL::GL)
if(NOT TARGET imgui) # a parent project may provide its own
FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git GIT_TAG v1.91.9b)
FetchContent_MakeAvailable(imgui)
add_library(imgui STATIC
${imgui_SOURCE_DIR}/imgui.cpp
${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp
${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp)
target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends)
target_link_libraries(imgui PUBLIC glfw OpenGL::GL)
endif()
# --- Engine -----------------------------------------------------------------
file(GLOB_RECURSE HD2D_ENGINE_SOURCES CONFIGURE_DEPENDS engine/src/*.cpp)
@@ -63,7 +81,9 @@ add_library(hd2d STATIC ${HD2D_ENGINE_SOURCES})
target_include_directories(hd2d PUBLIC engine/include)
target_include_directories(hd2d SYSTEM PRIVATE ${STB_INCLUDE_DIR} ${CGLTF_INCLUDE_DIR}) # silence third-party warnings
target_link_libraries(hd2d PUBLIC glfw glm::glm PkgConfig::EPOXY imgui OpenGL::GL)
target_compile_definitions(hd2d PUBLIC GLM_FORCE_RADIANS GLM_ENABLE_EXPERIMENTAL)
target_compile_features(hd2d PUBLIC cxx_std_20)
target_compile_definitions(hd2d PUBLIC GLM_FORCE_RADIANS GLM_ENABLE_EXPERIMENTAL
HD2D_ENGINE_ASSET_DIR="${HD2D_ENGINE_ASSET_DIR}")
if(MSVC)
target_compile_options(hd2d PRIVATE /W4)
else()
@@ -71,7 +91,9 @@ else()
endif()
# --- Demo -------------------------------------------------------------------
add_executable(hd2d_demo demo/main.cpp demo/DemoScene.cpp)
target_link_libraries(hd2d_demo PRIVATE hd2d)
# Assets/shaders are loaded relative to this directory at runtime.
target_compile_definitions(hd2d_demo PRIVATE HD2D_ASSET_DIR="${CMAKE_SOURCE_DIR}/assets")
if(HD2D_BUILD_DEMO)
add_executable(hd2d_demo demo/main.cpp demo/DemoScene.cpp)
target_link_libraries(hd2d_demo PRIVATE hd2d)
# Assets/shaders are loaded relative to this directory at runtime.
target_compile_definitions(hd2d_demo PRIVATE HD2D_ASSET_DIR="${HD2D_ENGINE_ASSET_DIR}")
endif()

162
README.md
View File

@@ -11,6 +11,7 @@ high-resolution 3D effects.
![demo](docs/screenshot.png)
![heavy rain](docs/rain.png)
![game UI](docs/ui.png)
## What makes HD-2D look like HD-2D
@@ -18,7 +19,7 @@ high-resolution 3D effects.
| --- | --- |
| 3D world built from pixel-art textures | Tile-based terraced terrain (`Terrain`) with pixel-art material layers, organically jittered material borders at texel granularity, grass overhangs on cliffs, baked corner AO |
| Crisp pixels at any distance | "Sharp bilinear" sampling (`pixelart.glsl`): hard texel edges when magnified, antialiased + mipmapped when minified (no `GL_NEAREST` shimmer) |
| Upright 2D sprites in 3D | Characters and small props are camera-facing (Y-axis) billboards (`BillboardBatch`), planted by their lowest opaque pixel row |
| Upright 2D sprites in 3D | Characters and small props are camera-facing (Y-axis) billboards (`BillboardBatch`), planted by their lowest opaque pixel row (or a per-frame feet row); per instance: sprite-sheet / atlas sub-rect (`SpriteAtlas`, `Texture::cellUV`), flip, paper-turn yaw, dithered alpha fade and hit flash |
| 3D scenery with painted textures | Trees, bushes, rocks and the stone bridge are instanced glTF models (`ModelLibrary`, `ModelBatch`) built in Blender: leaf-card canopies with alpha-tested 1K leaf textures, rounded custom normals for soft stylized shading, sun translucency, wind sway from per-vertex weights, emissive materials (glowing coals) |
| Grounded objects | Two kinds of ambient occlusion: half-res SSAO (depth-reconstructed normals, depth-aware blur, multiplied into the opaque scene before water) and a baked **ground contact occlusion** map (`GroundOcclusion`) that puts a dark core at every trunk / bush / rock base plus a soft canopy shade, applied to terrain, sprites and models near the ground |
| Nothing hides the player | Tall scenery (tree canopies) between the camera and the player is dithered away around the sprite, only above the sprite's head; low props opt out per batch (`ModelBatch::occluderFade = false`, used for the bridge, rocks and bushes) |
@@ -31,7 +32,9 @@ high-resolution 3D effects.
| Living foliage | Instanced swaying grass with wind gusts; tall grass and bushes bend and rustle away from the player (vertex shader, Pokémon-style patches); leaf flutter scales with wind strength |
| High-res particles | Fireflies (also feed point lights) and dust motes that only glow inside sun shafts |
| Fire | Brazier fires (`ParticleSystem::addFire`): a compact, bowl-filling mass of nearly opaque, noise-eroded flame licks shaded per pixel from a hot yellow core through orange to red tips (drawn oldest-first so the core sits on top), turbulent embers, lit smoke puffs, a glow halo, and a flickering point light that also scatters into the fog |
| Weather | `Weather` blends the clear look into a storm preset (weak grey sun, overcast sky, thick low fog, gusty wind): procedural world-space rain streaks (up to 18k, one draw call), ground splashes, raindrop ripple rings on the water, wet darker glossy surfaces, ambient fireflies fade out |
| Weather | `Weather` blends the clear look toward overcast (grey sky, weak sun, soft shadows), mist (thick low fog) and storm presets, each with its own 0..1 target and smooth transition: procedural world-space rain streaks (up to 18k, one draw call), ground splashes, raindrop ripple rings on the water, wet darker glossy surfaces, optional lightning flashes, ambient fireflies fade out |
| Time of day | `DayCycle` turns an hour into a complete look from tweakable keyframes (night, dawn, morning, noon, afternoon, dusk): sun arc by day, a dim cool moon as the shadow-casting light at night, deep blue nights with a twinkling star field and moon disc (also in water reflections), orange / purple dusks, brighter fireflies after dark; weather presets darken with it |
| Game UI | `hd2d::ui` (Octopath / Triangle Strategy look): translucent navy panels with a gold double border and corner gems, high-res serif text (Cinzel, EB Garamond) with a soft shadow, a typewriter dialogue box with gold name plate, portrait and choices, menus, name plates, toasts, progress bars and a matching Dear ImGui theme |
References: Unreal Engine's [Octopath Traveler spotlight](https://www.unrealengine.com/spotlights/octopath-traveler-s-hd-2d-art-style-and-story-make-for-a-jrpg-dream-come-true),
the [Octopath Traveler II interview](https://www.unrealengine.com/developer-interviews/octopath-traveler-ii-builds-a-bigger-bolder-world-in-its-stunning-hd-2d-style),
@@ -40,9 +43,9 @@ and [The Verge on HD-2D's evolution](https://www.theverge.com/entertainment/8400
## Building
Requirements: CMake ≥ 3.20, a C++20 compiler, OpenGL 4.5, `libepoxy`.
Requirements: CMake ≥ 3.21, a C++20 compiler, OpenGL 4.5, `libepoxy`.
GLFW, GLM, stb and cgltf are used from the system when found and fetched
otherwise; Dear ImGui is always fetched.
otherwise; Dear ImGui is fetched unless a parent project provides `imgui`.
```sh
# Arch / CachyOS
@@ -56,6 +59,19 @@ ninja -C build
The asset path is baked in at configure time (`HD2D_ASSET_DIR`), so the demo can
be run from any working directory.
To use the engine from another CMake project:
```cmake
add_subdirectory(path/to/Vibe-HD-2D hd2d) # HD2D_BUILD_DEMO defaults OFF here
target_link_libraries(my_game PRIVATE hd2d)
# Engine shaders: renderer.init(std::string(HD2D_ENGINE_ASSET_DIR) + "/shaders", ...)
```
`hd2d` publicly defines `HD2D_ENGINE_ASSET_DIR` (also a CMake cache variable)
pointing at the engine's `assets/`. The engine provides the `imgui` target
(Dear ImGui + GLFW/OpenGL3 backends) unless the parent defines one first;
`glfw` and `glm::glm` targets from the parent are reused too.
## Demo controls
| Input | Action |
@@ -65,8 +81,12 @@ be run from any working directory.
| Mouse wheel | Zoom |
| F1 | Toggle the tuning panels (sun, sky, AO, fog, god rays, water, DoF, bloom, grading, weather) |
| R | Toggle heavy rain (5 s transition) |
| O / G | Toggle overcast / morning mist |
| L | Toggle lightning (flashes while raining) |
| [ / ] | Time of day −/+ 1 hour (switches the demo to `DayCycle`) |
| F5 | Hot-reload shaders |
| F12 | Screenshot to `screenshots/` |
| T | Toggle the game UI showcase (Enter / Space advance the dialogue, Up / Down pick a choice) |
| Esc | Quit |
Scriptable captures (useful for look-dev and regression checks):
@@ -76,7 +96,31 @@ Scriptable captures (useful for look-dev and regression checks):
--sun -35,38 --yaw 20 --dist 12 --move 1,0 --rain 1 --set godRays=6 --set dof=0
```
`--rain 0..1` starts the capture already raining. `--set` accepts:
Captures run hidden: `--screenshot` implies a hidden window (no toplevel is
ever created, so nothing pops up or takes focus) rendering into an offscreen
target, and all keyboard / mouse input is ignored, so captures can run while
you work on the same desktop. `--show` opts back into a visible capture
window (input still ignored). Hidden and visible captures match to within
1/255 on a handful of pixels (driver rounding of the window surface).
Any app can capture headlessly the same way:
```cpp
window.create(w, h, "capture", false, false, /*visible*/ false); // swapBuffers no-op, input reports nothing
hd2d::OffscreenTarget out; out.create(window.width(), window.height()); // RGBA8 + depth/stencil
renderer.setOutputFramebuffer(out.fbo()); // tonemap writes here and leaves it bound for ImGui / ui::*
renderer.render(scene, cam, time); /* ... UI ... */
out.save("shot.png"); // = hd2d::saveScreenshot(path, w, h, out.fbo())
```
`--rain 0..1`, `--overcast 0..1` and `--fog 0..1` start the capture already
at that weather (`--lightning` enables flashes); `--hour H` drives the look
from `DayCycle` (without it the scene keeps its hand-tuned look); `--pitch deg`
sets the camera tilt (low values show the sky). `--sprite-test` adds a
labelled row of atlas / sprite-sheet billboards (sub-rect frames, flip, turn,
alpha dither, flash) in front of the player. `--ui-demo` opens the game UI
showcase (same as T): `--frames 120` captures the dialogue mid-reveal,
`--frames 420` fully revealed with its choices. `--set` accepts:
`volumetrics dof bloom reflections occluderFade ssao groundAO ssaoRadius
ssaoIntensity fogDensity fogHeightFalloff godRays shaftStrength shaftCoverage
shaftScale cookieStrength exposure bloomStrength tiltShift focusRange`.
@@ -88,6 +132,7 @@ engine/include/hd2d/ public headers
engine/src/ engine implementation
assets/shaders/ GLSL (common.glsl = shared UBO + lighting helpers)
assets/textures/ PixelLab-generated pixel art (terrain tiles, grass, flowers)
assets/fonts/ OFL fonts for hd2d::ui (Cinzel, Cinzel Decorative, EB Garamond) + licenses
assets/sprites/ character sprites
assets/models/ scenery.glb (trees, bushes, rocks, bridge) + src/ painted textures
demo/ meadow demo scene + main loop
@@ -129,6 +174,16 @@ hd2d::BillboardBatch hero; hero.loadTexture("assets/sprites/hero.png");
hero.instances.push_back({{10, 0, 10}, 0.0f, hero.sizeForDensity(32.0f)});
hero.upload();
// Sprite sheets / runtime atlases: one sub-rect (`uv`) + feet row (`anchor`)
// per instance; `flip`, `turn`, `alpha` (dithered) and `flash` per instance too.
hd2d::SpriteAtlas atlas; atlas.init({64, 64}, 8, 8);
hd2d::BillboardBatch critters; critters.sharedTexture = &atlas.texture();
int slot = atlas.add(image); // hd2d::Image, e.g. from hd2d::loadImage
hd2d::BillboardInstance c;
c.pos = {12, 0, 9}; c.size = hd2d::BillboardBatch::cellSize({64, 64}, 32.0f);
c.uv = atlas.uv(slot); c.anchor = atlas.anchor(slot);
critters.instances.push_back(c); critters.upload();
hd2d::ModelLibrary models; models.load("assets/models/scenery.glb");
hd2d::ModelBatch oaks(models.get("oak_a"));
oaks.instances.push_back({{14, 0, 12}, /*yaw*/ 0.7f, /*scale*/ glm::vec3(1.0f), /*tint*/ 1.0f});
@@ -136,7 +191,7 @@ oaks.upload();
hd2d::Scene scene;
scene.terrain = &terrain;
scene.billboards = {&hero};
scene.billboards = {&hero, &critters};
scene.models = {&oaks};
hd2d::GroundOcclusion contactAO; // dark contact shadow at tree bases
@@ -168,8 +223,98 @@ while (window.pollEvents()) {
scattering, bridge walking, fires from model sockets, contact AO, water +
shore field, particles, weather, player movement and collision.
### Time of day and weather (`hd2d/DayCycle.h`, `hd2d/Weather.h`)
Both are meant to be driven externally, e.g. by a game server that sends the
in-game `hour` and the current weather. `DayCycle::look(hour)` returns a
`WeatherLook` (sun / moon direction, intensity and color, sky colors, ambient,
fog, god rays, bloom, exposure / saturation / lift / gain, star + moon
visibility, firefly glow); `Weather` blends it toward its presets and applies
the result to the `Scene` and `RenderSettings`.
```cpp
hd2d::DayCycle day; // default keyframes; tweak day.keys[i].look, sunrise, sunset, moon arc
hd2d::Weather weather;
void onServerState(float hour, const std::string& kind) { // e.g. "clear", "overcast", "fog", "rain", "storm"
serverHour = hour; // [0, 24); interpolate between updates if you like
weather.setRain(kind == "rain" || kind == "storm" ? 1.0f : 0.0f);
weather.setOvercast(kind == "overcast" ? 1.0f : 0.0f);
weather.setFog(kind == "fog" ? 1.0f : 0.0f);
weather.setLightning(kind == "storm");
}
// once after joining: weather.snap(); (skip the transition)
// every frame, before renderer.render():
weather.setClearLook(day.look(serverHour));
weather.update(dt, scene, renderer.settings);
```
| API | Notes |
| --- | --- |
| `DayCycle::look(hour)` | Smooth (smoothstep) interpolation between `keys` (wraps at midnight); the light follows a sun arc between `sunrise` and `sunset` and a moon arc otherwise, fading out near the horizon so the sun → moon switch is invisible |
| `DayCycle::daylight(hour)` / `night(hour)` | 0..1 helpers: sun height (0 at night) and star / moon visibility, e.g. for gameplay (spawn tables, NPC schedules) |
| `Weather::setClearLook(look)` | Replaces the clear look every frame; without it `Weather` captures the scene's own look while clear (manual tuning survives) |
| `setRain` / `setOvercast` / `setFog` (0..1) | Targets reached over `transitionSeconds`; `snap()` jumps to all of them |
| `setLightning(bool)` | Random double flashes (sky, sun, fog) while rain > 0.5, about every `lightningInterval` s |
| `storm`, `overcastLook`, `mist` | Presets authored at full daylight; darkened by the clear look's `daylight` toward `nightBrightness` and tinted toward its sky at night, so a midnight storm is dark blue-grey, not a bright grey sky. Sun direction, exposure, grading and fireflies always come from the clear look |
| `ParticleSystem::fireflyGlow` | Firefly brightness + light; set from `WeatherLook::fireflyGlow` (the default night keys raise it). `ambientFade` still fades fireflies and motes in rain / mist |
Night look: the "sun" becomes the moon (direction, color, intensity), so
shadows keep working; `Sky::night` turns on the star field and moon disc in
`sky.frag` (hidden by clouds, `Sky::clouds` / rain). Point lights (fires,
fireflies) are absolute HDR, so they read strongly once the ambient drops.
With `DayCycle` driving the look, the sun / sky / fog / grading sliders of the
renderer panel are overwritten every frame: tune the keyframes instead (F1 →
Weather → Day keyframes).
### Game UI (`hd2d/UI.h`)
`hd2d::ui` draws an HD-2D JRPG interface with Dear ImGui draw lists (no ImGui
windows needed), so it composites over the final frame like the debug panels.
All sizes are authored for a 1080 px tall viewport and scaled by
`ui::scaleFor(viewport)`.
```cpp
hd2d::DebugUI imgui; imgui.init(window); // creates the ImGui context
namespace ui = hd2d::ui;
ImVec2 vp(window.width(), window.height());
ui::loadFonts(std::string(HD2D_ENGINE_ASSET_DIR) + "/fonts", ui::scaleFor(vp)); // before the first frame
ui::applyTheme(); // optional: restyle standard ImGui widgets
ui::DialogueBox box;
box.start({{"Warden", "Welcome, traveller!", portraitTexId}}, {"Yes", "No"});
ui::Toasts toasts; toasts.push("Field guide updated");
// every frame, between imgui.beginFrame() and imgui.endFrame():
if (confirmPressed && box.advance()) onChoice(box.choice()); // -1 without choices
if (upPressed) box.moveChoice(-1);
box.update(dt); box.draw(vp);
toasts.update(dt); toasts.draw(vp);
ImDrawList* dl = ImGui::GetForegroundDrawList();
ui::menu(dl, {vp.x * 0.15f, vp.y * 0.4f}, {"Items", "Party", "Quit"}, selected, "Menu", ui::scaleFor(vp));
ui::nameplate(dl, headPx, "Traveller", ui::kGold, ui::scaleFor(vp)); // headPx from Camera::worldToScreen
```
| Piece | Notes |
| --- | --- |
| `loadFonts(dir, pixelScale)` | Bakes `body` / `small` (EB Garamond), `name` (Cinzel) and `title` (Cinzel Decorative Bold) at `kBodySize`… × `pixelScale` so text is pixel-exact; ImGui's default font stays the default for debug panels. Call outside a frame; call again after a resize to rebake |
| `panel`, `PanelStyle` | Navy → near-black gradient, soft drop shadow, gold double border, corner gems + brackets (`ornate`), `tint` alpha fades everything |
| `text`, `textWrapped`, `textCentered`, `measure` | Pixel-snapped text with a 1 px dark shadow (plus a faint halo); null font = current ImGui font |
| `titleText` | Gold gradient logo with dark halo, warm glow, outline and a gem flourish rule |
| `nameplate` | Pill label whose pointer tip sits on a screen point |
| `menu`, `menuAt`, `menuSize` | Option list panel with a gold highlight band and bobbing cursor |
| `progressBar` | Recessed rounded track with a glossy gradient fill |
| `DialogueBox` | Bottom centre, 70 % width, word-wrapped from the full line so words never jump, UTF-8 typewriter at `charsPerSecond` with `sentencePause` / `clausePause`, optional portrait (`ImTextureID` + UVs; load pixel art with `Texture::load(path, false, true)` for crisp nearest filtering), gold speaker plate, blinking gem when revealed, choices above the right end |
| `Toasts` | Banners stacked at the top centre; slide in, fade out after `seconds` |
## Assets
Fonts in `assets/fonts/` come from [google/fonts](https://github.com/google/fonts)
under the SIL Open Font License (`*-OFL.txt` next to each font; files renamed
from `Family[wght].ttf`). Variable fonts are rasterised at their default
(Regular) instance.
The full guide to making sprites, terrain tiles, high-res textures and 3D
models (scale, conventions, PixelLab manifest fields, Blender helpers, how to
check results) is in [`docs/ASSET_GUIDE.md`](docs/ASSET_GUIDE.md). Summary:
@@ -227,7 +372,6 @@ upscaled 64 px art, so it is rendered at 128 px per unit.
## Roadmap
The demo covers the rendering core. Natural next steps for a full engine:
sprite-sheet animation + 4/8-direction facing, a scene/level file format and
editor, more weather (snow, thunderstorms with lightning, wind-blown leaves),
time-of-day presets, cascaded shadows for large maps, audio, and an
animation playback + 4/8-direction facing on top of the sheet/atlas frames, a scene/level file format and
editor, more weather (snow, wind-blown leaves), cascaded shadows for large maps, audio, and an
entity/component layer for gameplay.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
assets/fonts/Cinzel.ttf Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright (c) 2012 Natanael Gama (info@ndiscovered.com), with Reserved Font Name 'Cinzel'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
assets/fonts/EBGaramond.ttf Normal file

Binary file not shown.

View File

@@ -8,6 +8,7 @@ in VS {
vec3 pivot;
vec2 local;
float tint;
flat vec2 fx; // alpha, flash
} v;
layout(binding = 0) uniform sampler2D uTex;
@@ -15,10 +16,19 @@ uniform int uPass;
uniform float uEmissive;
layout(location = 0) out vec4 fragColor;
const float kFlashWhite = 4.0; // linear HDR white the flash blends toward (blooms)
// 4x4 ordered (Bayer) threshold in (0, 1): alpha 1 keeps every pixel, 0 none.
float bayer4(vec2 p) {
ivec2 i = ivec2(p) & 3;
const float m[16] = float[](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);
return (m[i.y * 4 + i.x] + 0.5) / 16.0;
}
void main() {
vec4 c = sharpSample(uTex, v.uv);
if (c.a < 0.5) discard;
if (v.fx.x < 1.0 && v.fx.x < bayer4(gl_FragCoord.xy)) discard; // dithered fade, shadows included
if (uPass == 1) return;
vec3 up = vec3(0.0, 1.0, 0.0);
@@ -48,5 +58,6 @@ void main() {
float d = length(v.pivot.xz - uBlob.xz) / uBlob.w;
col *= 1.0 - 0.35 * (1.0 - smoothstep(0.3, 1.0, d)) * (1.0 - clamp(v.local.y / 0.6, 0.0, 1.0));
}
col = mix(col, vec3(kFlashWhite), clamp(v.fx.y, 0.0, 1.0)); // hit / capture flash
fragColor = vec4(col, 1.0);
}

View File

@@ -1,8 +1,11 @@
#version 450 core
#include "common.glsl"
// Per-instance attributes; must match BillboardInstance (Billboard.h).
layout(location = 0) in vec4 iPosSway; // pivot xyz, sway
layout(location = 1) in vec4 iSizeFlipTint; // size xy, flip, tint
layout(location = 2) in vec4 iRect; // uv sub-rect (minU, minV, maxU, maxV)
layout(location = 3) in vec4 iAnchorFx; // anchor v (< 0 = uOpaque.y), turn, alpha, flash
uniform vec4 uOpaque; // opaque pixel bounds in uv (minX, minY, maxX, maxY)
uniform int uPass; // 0 = color, 1 = shadow
@@ -13,22 +16,34 @@ out VS {
vec3 pivot;
vec2 local; // (horizontal offset, height above pivot) in world units
float tint;
flat vec2 fx; // alpha, flash
} o;
void main() {
vec2 corner = vec2(float(gl_VertexID & 1), float(gl_VertexID >> 1));
vec2 size = iSizeFlipTint.xy;
vec3 pivot = iPosSway.xyz;
vec4 rect = iRect;
float rectH = max(rect.w - rect.y, 1e-6);
float anchor = iAnchorFx.x < 0.0 ? uOpaque.y : iAnchorFx.x;
float anchorLocal = (anchor - rect.y) / rectH;
float lx = (corner.x - 0.5) * size.x;
float ly = (corner.y - uOpaque.y) * size.y; // lowest opaque row sits on the pivot
float ly = (corner.y - anchorLocal) * size.y; // the feet row sits on the pivot
// Shadow cards face the sun so silhouettes are never edge-on to the light.
vec3 right = uPass == 1 ? uShadowRight.xyz : uBillboardRight.xyz;
// Shadow cards face the sun so silhouettes are never edge-on to the light;
// `turn` yaws color cards away from camera-facing (paper turn).
vec3 right = uBillboardRight.xyz;
if (iAnchorFx.y != 0.0) {
float ct = cos(iAnchorFx.y), st = sin(iAnchorFx.y);
right = vec3(right.x * ct - right.z * st, 0.0, right.x * st + right.z * ct);
}
if (uPass == 1) right = uShadowRight.xyz;
vec3 wp = pivot + right * lx + vec3(0.0, ly, 0.0);
float sway = iPosSway.w;
if (sway > 0.0) {
float visibleHeight = max(size.y * (uOpaque.w - uOpaque.y), 1e-3);
// Feet to the top of the rect (or of the opaque pixels, if lower).
float visibleHeight = max(size.y * (min(uOpaque.w, rect.w) - anchor) / rectH, 1e-3);
float k = clamp(ly / visibleHeight, 0.0, 1.0);
k *= k;
float ph = dot(pivot.xz, vec2(0.37, 0.61));
@@ -46,13 +61,14 @@ void main() {
wp.y -= push * k * sway * 0.3 * visibleHeight;
}
vec2 uv = corner;
if (iSizeFlipTint.z > 0.5) uv.x = 1.0 - uv.x;
o.uv = uv;
vec2 c = corner;
if (iSizeFlipTint.z > 0.5) c.x = 1.0 - c.x; // mirror inside the sub-rect
o.uv = mix(rect.xy, rect.zw, c);
o.worldPos = wp;
o.pivot = pivot;
o.local = vec2(lx, ly);
o.tint = iSizeFlipTint.w;
o.fx = iAnchorFx.zw;
gl_Position = uViewProj * vec4(wp, 1.0);
gl_ClipDistance[0] = dot(vec4(wp, 1.0), uClipPlane);
}

View File

@@ -28,6 +28,7 @@ layout(std140, binding = 0) uniform Frame {
vec4 uWaterParams; // level, caustics intensity, has water, unused
vec4 uGroundAO; // world -> uv: minX, minZ, 1/sizeX, 1/sizeZ (z = 0: disabled)
vec4 uWeather; // rain intensity, surface wetness, rain fall speed, unused
vec4 uSkyParams; // night (stars + moon disc at uSunDir), extra cloud cover, unused, unused
ivec4 uLightCount;
vec4 uLightPos[16]; // xyz, w = radius
vec4 uLightColor[16];
@@ -168,18 +169,30 @@ vec3 wetSurface(vec3 albedo, vec3 n, vec3 V, float vis, out vec3 sheen) {
return albedo * mix(1.0, 0.68, uWeather.y);
}
// Cloud layer opacity toward `dir` (0 at and below the horizon). Rain and
// uSkyParams.y (overcast) thicken it.
float skyClouds(vec3 dir) {
if (dir.y <= 0.0) return 0.0;
float cover = max(uWeather.x, uSkyParams.y);
vec2 cp = dir.xz / (dir.y + 0.12) * 1.3 + uTime * vec2(0.012, 0.004);
return smoothstep(0.5 - 0.45 * cover, 0.85 - 0.3 * cover, fbm2(cp)) * 0.55 * smoothstep(0.0, 0.2, dir.y);
}
vec3 skyColor(vec3 dir) {
float h = dir.y;
vec3 c = mix(uSkyHorizon.rgb, uSkyZenith.rgb, pow(clamp(h, 0.0, 1.0), 0.55));
if (h < 0.0) c = mix(uSkyHorizon.rgb, uSkyGround.rgb, clamp(-h * 3.0, 0.0, 1.0));
float sd = max(dot(dir, uSunDir.xyz), 0.0);
float night = uSkyParams.x;
if (h > 0.0) {
vec2 cp = dir.xz / (h + 0.12) * 1.3 + uTime * vec2(0.012, 0.004);
float cl = smoothstep(0.5 - 0.45 * uWeather.x, 0.85 - 0.3 * uWeather.x, fbm2(cp)); // overcast when raining
c = mix(c, uSunColor.rgb * (0.75 + 0.6 * sd), cl * 0.55 * smoothstep(0.0, 0.2, h));
// Clouds catch the sun by day; at night they are faintly moonlit.
vec3 lit = mix(uSunColor.rgb * (0.75 + 0.6 * sd),
uSkyHorizon.rgb * 1.6 + uSunColor.rgb * uSunDir.w * (0.02 + 0.3 * pow(sd, 8.0)), night);
c = mix(c, lit, skyClouds(dir));
}
c += uSunColor.rgb * (pow(sd, 900.0) * 40.0 + pow(sd, 10.0) * 0.3);
// Sun disc + halo (the moon replaces it at night, overcast hides it).
float sunVis = (1.0 - night) * (1.0 - uSkyParams.y) * clamp(uSunDir.w * 2.0, 0.0, 1.0);
c += uSunColor.rgb * (pow(sd, 900.0) * 40.0 + pow(sd, 10.0) * 0.3) * sunVis;
return c;
}

View File

@@ -4,8 +4,65 @@
in vec2 vUV;
layout(location = 0) out vec4 fragColor;
// Star field: at most one star per cell of a grid around the view sphere,
// anti-aliased against the pixel footprint (also works in the half-res
// reflection), twinkling, mostly faint with a few bright ones that bloom.
// `px` = angular pixel footprint (radians), computed in uniform control flow.
vec3 stars(vec3 dir, float px) {
const float grid = 150.0;
vec3 q = dir * grid;
vec3 cell = floor(q);
float h = hash13(cell);
if (h < 0.955) return vec3(0.0);
vec3 jitter = vec3(hash13(cell + 11.3), hash13(cell + 27.1), hash13(cell + 3.7)) - 0.5;
vec3 center = normalize(cell + 0.5 + jitter * 0.7) * grid; // on the sphere
float d = length(q - center);
float size = 0.10 + 0.12 * hash13(cell + 5.9);
float r = max(size, px * grid * 0.6);
float s = exp(-d * d / (r * r)) * (size * size) / (r * r);
float bright = 0.35 + 3.5 * pow(hash13(cell + 41.7), 6.0);
float twinkle = 0.7 + 0.3 * sin(uTime * (1.5 + 4.0 * hash13(cell + 9.2)) + h * 400.0);
vec3 tint = mix(vec3(0.70, 0.80, 1.0), vec3(1.0, 0.86, 0.70), hash13(cell + 17.5));
return tint * s * bright * twinkle;
}
// Moon disc at the light direction (the sun acts as the moon at night):
// soft maria, limb darkening and a two-lobe halo. rgb = disc colour,
// a = disc coverage.
vec4 moon(vec3 dir, float px, out vec3 halo) {
const float R = 0.04; // angular radius (stylised, larger than real)
vec3 L = uSunDir.xyz;
vec3 T = normalize(cross(L, abs(L.y) > 0.99 ? vec3(1, 0, 0) : vec3(0, 1, 0)));
vec3 B = cross(T, L);
vec3 rel = dir - L * dot(dir, L);
float ang = length(rel); // sin of the angle to the moon centre
float front = step(0.0, dot(dir, L));
vec3 moonCol = mix(vec3(1.0, 0.98, 0.94), uSunColor.rgb, 0.25);
halo = moonCol * front * (exp(-ang / R * 1.3) * 0.35 + exp(-ang / (R * 7.0)) * 0.07);
vec2 uv = vec2(dot(rel, T), dot(rel, B)) / R;
float r = length(uv);
float aa = max(px / R, 1e-3) * 1.5;
float disc = (1.0 - smoothstep(1.0 - aa, 1.0, r)) * front;
if (disc <= 0.0) return vec4(0.0);
float maria = vnoise(uv * 1.8 + 3.0) * 0.6 + vnoise(uv * 4.5 + 7.0) * 0.4;
float shade = mix(0.68, 1.0, smoothstep(0.35, 0.65, maria)) * (1.0 - 0.25 * r * r);
return vec4(moonCol * 1.5 * shade, disc);
}
void main() {
vec3 far = worldFromDepth(vUV, 1.0);
vec3 dir = normalize(far - uCamPos.xyz);
fragColor = vec4(skyColor(dir), 1.0);
float px = length(fwidth(dir));
vec3 c = skyColor(dir);
float night = uSkyParams.x;
if (night > 0.0 && dir.y > 0.0) {
float cover = max(uWeather.x, uSkyParams.y);
float open = (1.0 - cover) * (1.0 - skyClouds(dir) / 0.55);
c += stars(dir, px) * night * open * smoothstep(0.0, 0.2, dir.y);
vec3 halo;
vec4 disc = moon(dir, px, halo);
c = mix(c, disc.rgb, disc.a * night * open);
c += halo * night * (1.0 - 0.7 * cover);
}
fragColor = vec4(c, 1.0);
}

View File

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

View File

@@ -1,6 +1,7 @@
#pragma once
#include "hd2d/Camera.h"
#include "hd2d/Scene.h"
#include "hd2d/SpriteAtlas.h"
#include "hd2d/Weather.h"
#include <array>
@@ -21,11 +22,24 @@ public:
glm::vec3 playerPos{0.0f};
hd2d::Weather weather; // call weather.update() each frame before rendering
// Billboard feature test (`--sprite-test`; set before load()): a row of
// atlas / sprite-sheet sprites behind the player exercising sub-rect
// frames, flip, turn, alpha dither and flash. Labels are world-space
// points (sprite tops) for the caller to draw.
bool spriteTest = false;
struct Label {
glm::vec3 pos;
const char* text;
};
std::vector<Label> labels;
private:
struct Obstacle {
glm::vec2 pos;
float radius;
};
bool buildSpriteTest(const std::string& assetDir);
void updateSpriteTest(float time, const hd2d::Camera& camera);
void buildTerrain();
void scatterProps();
bool walkable(glm::vec2 from, glm::vec2 to) const;
@@ -45,6 +59,11 @@ private:
std::mt19937 rng_{42};
float playerSpeed_ = 4.2f;
float walkPhase_ = 0.0f;
hd2d::SpriteAtlas atlas_; // sprite test: runtime-packed cells
hd2d::BillboardBatch atlasSprites_; // uses atlas_.texture() via sharedTexture
hd2d::BillboardBatch sheetSprites_; // own texture: a 2x1 sheet read with Texture::cellUV
int atlasSlots_[3] = {-1, -1, -1}; // hero, flowers, tuft
int sheetFeetRows_[2] = {0, 0}; // per sheet cell, from the cell top
float stepTimer_ = 0.0f;
float facing_ = 0.0f;
};

View File

@@ -1,18 +1,26 @@
// HD-2D engine demo: meadow diorama with a WASD-controlled sprite.
//
// Controls: WASD/arrows move, Q/E rotate camera, right-drag orbit, wheel zoom,
// F1 toggle tuning panel, F5 hot-reload shaders, F12 screenshot, Esc quit.
// R rain, O overcast, G fog, L lightning, [ / ] time of day -/+ 1 h,
// F1 toggle tuning panel, F5 hot-reload shaders, F12 screenshot, T game UI showcase, Esc quit.
//
// Headless-ish capture for testing:
// hd2d_demo --screenshot out.png [--frames 120] [--move x,y] [--pos x,z]
// Captures run hidden: --screenshot implies a hidden, input-ignoring window
// rendering into an offscreen target (--show opts back into a visible window;
// input is still ignored):
// hd2d_demo --screenshot out.png [--frames 120] [--move x,y] [--pos x,z] [--show]
// [--sun azimuthDeg,elevationDeg] [--yaw deg] [--dist units] [--size WxH] [--ui]
// [--set name=value ...] e.g. --set godRays=4 --set dof=0
// [--hour H] [--rain X] [--overcast X] [--fog X] [--lightning] [--pitch deg]
// [--sprite-test] billboard feature row (atlas, flip, turn, alpha, flash, frames)
// [--ui-demo] game UI showcase (also key T): dialogue, choices, toasts, menu, nameplate
#include "DemoScene.h"
#include "hd2d/DayCycle.h"
#include "hd2d/DebugUI.h"
#include "hd2d/Log.h"
#include "hd2d/Renderer.h"
#include "hd2d/Screenshot.h"
#include "hd2d/UI.h"
#include "hd2d/Window.h"
#include <GLFW/glfw3.h>
@@ -38,10 +46,11 @@ struct Options {
std::string screenshot;
int frames = 120;
glm::vec2 move{0.0f};
bool hasPos = false, hasSun = false, hasYaw = false, ui = false;
bool hasPos = false, hasSun = false, hasYaw = false, ui = false, spriteTest = false, uiDemo = false, show = false;
bool hasHour = false, hasPitch = false, lightning = false;
std::vector<std::pair<std::string, float>> settings; // --set name=value
glm::vec2 pos{0.0f}, sun{0.0f};
float yaw = 0.0f, dist = 0.0f, rain = 0.0f;
float yaw = 0.0f, pitch = 0.0f, dist = 0.0f, rain = 0.0f, overcast = 0.0f, fog = 0.0f, hour = 12.0f;
int width = 1600, height = 900;
};
@@ -63,7 +72,15 @@ Options parseArgs(int argc, char** argv) {
else if (!std::strcmp(argv[i], "--yaw")) o.hasYaw = true, o.yaw = float(std::atof(next()));
else if (!std::strcmp(argv[i], "--dist")) o.dist = float(std::atof(next()));
else if (!std::strcmp(argv[i], "--ui")) o.ui = true;
else if (!std::strcmp(argv[i], "--show")) o.show = true;
else if (!std::strcmp(argv[i], "--sprite-test")) o.spriteTest = true;
else if (!std::strcmp(argv[i], "--ui-demo")) o.uiDemo = true;
else if (!std::strcmp(argv[i], "--rain")) o.rain = float(std::atof(next()));
else if (!std::strcmp(argv[i], "--overcast")) o.overcast = float(std::atof(next()));
else if (!std::strcmp(argv[i], "--fog")) o.fog = float(std::atof(next()));
else if (!std::strcmp(argv[i], "--lightning")) o.lightning = true;
else if (!std::strcmp(argv[i], "--hour")) o.hasHour = true, o.hour = float(std::atof(next()));
else if (!std::strcmp(argv[i], "--pitch")) o.hasPitch = true, o.pitch = float(std::atof(next()));
else if (!std::strcmp(argv[i], "--set")) {
std::string kv = next();
auto eq = kv.find('=');
@@ -111,16 +128,124 @@ std::string timestampedScreenshotPath() {
return buf;
}
// Game UI showcase (key T or --ui-demo): typewriter dialogue with a portrait
// and choices, toasts, a menu, a status panel, a title, a themed ImGui window
// and a nameplate above the player. The ImGui theme is restored on exit.
struct UiShowcase {
bool active = false;
hd2d::ui::DialogueBox dialogue;
hd2d::ui::Toasts toasts;
hd2d::Texture portrait;
ImGuiStyle savedStyle;
float volume = 0.7f; // state for the themed "Options" window
bool subtitles = true;
int textSpeed = 1;
char playerName[32] = "Traveller";
void open() {
if (!active) savedStyle = ImGui::GetStyle();
hd2d::ui::applyTheme();
active = true;
glm::vec4 b = portrait.opaqueBounds(); // square crop of the sprite's head and shoulders
float side = b.z - b.x;
dialogue.start({{"Warden Elise",
"Ah, a new face! Welcome to the meadow, traveller \xE2\x80\x94 the tall grass to the east hides "
"all manner of curious creatures, and the caf\xC3\xA9 by the bridge sells the finest bait\xE2\x80\xA6 "
"Will you take a field guide with you?",
ImTextureID(portrait.id()), ImVec2(b.x, b.y), ImVec2(b.z, b.y + side)}},
{"Yes, gladly.", "Not today.", "Tell me more first."});
toasts.push("Field guide updated", IM_COL32(230, 190, 90, 255), 9.0f);
toasts.push("New area: Sunlit Meadow", IM_COL32(120, 200, 160, 255), 9.0f);
}
void close() {
dialogue.close();
toasts.clear();
ImGui::GetStyle() = savedStyle;
active = false;
}
// Enter/Space advance, Up/Down pick a choice; Enter reopens a closed dialogue.
void input(hd2d::Window& w) {
if (dialogue.isOpen()) {
if (w.keyPressed(GLFW_KEY_UP) || w.keyPressed(GLFW_KEY_W)) dialogue.moveChoice(-1);
if (w.keyPressed(GLFW_KEY_DOWN) || w.keyPressed(GLFW_KEY_S)) dialogue.moveChoice(1);
if ((w.keyPressed(GLFW_KEY_ENTER) || w.keyPressed(GLFW_KEY_SPACE)) && dialogue.advance()) {
static const char* answers[] = {"Received a field guide", "Maybe next time", "Ask again with T"};
int c = dialogue.choice();
if (c >= 0) toasts.push(answers[c]);
}
} else if (w.keyPressed(GLFW_KEY_ENTER)) {
open();
}
}
void draw(glm::vec2 viewport, const hd2d::Camera& cam, glm::vec3 playerPos, float dt) {
namespace ui = hd2d::ui;
const ImVec2 vp(viewport.x, viewport.y);
const float s = ui::scaleFor(vp);
ImDrawList* dl = ImGui::GetForegroundDrawList();
dialogue.update(dt);
toasts.update(dt);
ui::titleText(dl, ImVec2(vp.x * 0.5f, vp.y * 0.25f), "Meadow Tales", ui::kTitleSize * s);
ui::menu(dl, ImVec2(vp.x * 0.13f, vp.y * 0.42f), {"Items", "Party", "Field Guide", "Settings", "Quit"}, 2,
"Menu", s);
// Status panel with progress bars (top right).
ImVec2 p0(vp.x - 380.0f * s - 40.0f * s, 40.0f * s), p1(vp.x - 40.0f * s, 40.0f * s + 150.0f * s);
ui::panel(dl, p0, p1);
ui::text(dl, ui::fonts().name, ui::kNameSize * s, ImVec2(p0.x + 28.0f * s, p0.y + 20.0f * s), ui::kGold,
"Traveller");
ui::text(dl, ui::fonts().small, ui::kSmallSize * s, ImVec2(p1.x - 110.0f * s, p0.y + 22.0f * s),
ui::kTextMuted, "Lv. 12");
const char* labels[] = {"HP", "SP"};
const ImU32 colors[] = {IM_COL32(110, 190, 90, 255), IM_COL32(80, 150, 230, 255)};
const float values[] = {0.82f, 0.45f};
for (int i = 0; i < 2; ++i) {
float y = p0.y + (68.0f + 38.0f * float(i)) * s;
ui::text(dl, ui::fonts().small, ui::kSmallSize * s, ImVec2(p0.x + 28.0f * s, y - 4.0f * s), ui::kTextColor,
labels[i]);
ui::progressBar(dl, ImVec2(p0.x + 80.0f * s, y + 2.0f * s), ImVec2(p1.x - 28.0f * s, y + 16.0f * s),
values[i], colors[i]);
}
glm::vec2 head;
if (cam.worldToScreen(playerPos + glm::vec3(0.0f, 1.45f, 0.0f), viewport, head))
ui::nameplate(dl, ImVec2(head.x, head.y), "Traveller", ui::kGold, s);
// Standard widgets with applyTheme() and the game's small font.
ImGui::PushFont(ui::fonts().small);
ImGui::SetNextWindowPos(ImVec2(vp.x - 40.0f * s, vp.y * 0.36f), ImGuiCond_Always, ImVec2(1.0f, 0.0f));
ImGui::Begin("Options", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings);
ImGui::PushItemWidth(200.0f * s);
ImGui::SliderFloat("Volume", &volume, 0.0f, 1.0f);
ImGui::Checkbox("Subtitles", &subtitles);
ImGui::Combo("Text speed", &textSpeed, "Slow\0Normal\0Fast\0");
ImGui::InputText("Name", playerName, sizeof playerName);
ImGui::Button("Apply");
ImGui::PopItemWidth();
ImGui::End();
ImGui::PopFont();
dialogue.draw(vp);
toasts.draw(vp);
}
};
// Owns every GL object so they are destroyed while the context still exists.
int run(hd2d::Window& window, const Options& opt) {
const bool capture = !opt.screenshot.empty();
hd2d::Renderer renderer;
if (!renderer.init(std::string(HD2D_ASSET_DIR) + "/shaders", window.width(), window.height())) return 1;
hd2d::OffscreenTarget offscreen; // hidden captures render here instead of the backbuffer
if (!window.visible()) {
if (!offscreen.create(window.width(), window.height())) return 1;
renderer.setOutputFramebuffer(offscreen.fbo());
}
for (const auto& [name, value] : opt.settings)
if (!applySetting(renderer.settings, name, value)) HD2D_ERR("unknown setting '%s'", name.c_str());
DemoScene demo;
demo.spriteTest = opt.spriteTest;
if (!demo.load(HD2D_ASSET_DIR)) {
HD2D_ERR("failed to load demo assets from %s", HD2D_ASSET_DIR);
return 1;
@@ -130,17 +255,31 @@ int run(hd2d::Window& window, const Options& opt) {
demo.scene.sun.azimuth = glm::radians(opt.sun.x);
demo.scene.sun.elevation = glm::radians(opt.sun.y);
}
if (opt.rain > 0.0f) {
demo.weather.setRain(opt.rain);
demo.weather.snap(); // captures start already raining
}
// Weather targets; captures start already at them.
demo.weather.setRain(opt.rain);
demo.weather.setOvercast(opt.overcast);
demo.weather.setFog(opt.fog);
demo.weather.setLightning(opt.lightning);
demo.weather.snap();
// Time of day: off (the scene's own look) until --hour, [ / ] or the hour slider.
hd2d::DayCycle dayCycle;
bool useDayCycle = opt.hasHour;
float hour = opt.hasHour ? opt.hour : 12.0f;
hd2d::DebugUI ui;
if (!ui.init(window)) return 1;
bool showUI = capture ? opt.ui : true;
if (capture) ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouse | ImGuiConfigFlags_NoKeyboard;
// Game UI fonts (ImGui's default font stays the default for the panels above).
const auto viewportOf = [&] { return ImVec2(float(window.width()), float(window.height())); };
hd2d::ui::loadFonts(std::string(HD2D_ASSET_DIR) + "/fonts", hd2d::ui::scaleFor(viewportOf()));
UiShowcase showcase;
showcase.portrait.load(std::string(HD2D_ASSET_DIR) + "/sprites/test_sprite.png", false, true);
if (opt.uiDemo) showcase.open();
hd2d::Camera cam;
if (opt.hasYaw) cam.yaw = glm::radians(opt.yaw);
if (opt.hasPitch) cam.pitch = glm::radians(opt.pitch);
if (opt.dist > 0.0f) cam.distance = opt.dist;
cam.target = demo.playerPos + glm::vec3(0, 0.7f, 0);
@@ -156,10 +295,14 @@ int run(hd2d::Window& window, const Options& opt) {
if (capture) dt = 1.0f / 60.0f; // deterministic captures
time += dt;
if (window.resized()) renderer.resize(window.width(), window.height());
if (window.resized()) {
renderer.resize(window.width(), window.height());
if (offscreen.fbo()) offscreen.resize(window.width(), window.height());
hd2d::ui::loadFonts(std::string(HD2D_ASSET_DIR) + "/fonts", hd2d::ui::scaleFor(viewportOf()));
}
glm::vec2 move = opt.move;
if (!ui.wantsKeyboard()) {
if (!capture && !ui.wantsKeyboard()) { // captures ignore the user's input
if (window.keyDown(GLFW_KEY_W) || window.keyDown(GLFW_KEY_UP)) move.y += 1.0f;
if (window.keyDown(GLFW_KEY_S) || window.keyDown(GLFW_KEY_DOWN)) move.y -= 1.0f;
if (window.keyDown(GLFW_KEY_D) || window.keyDown(GLFW_KEY_RIGHT)) move.x += 1.0f;
@@ -169,9 +312,18 @@ int run(hd2d::Window& window, const Options& opt) {
if (window.keyPressed(GLFW_KEY_F1)) showUI = !showUI;
if (window.keyPressed(GLFW_KEY_F5)) renderer.reloadShaders();
if (window.keyPressed(GLFW_KEY_R)) demo.weather.setRain(demo.weather.targetRain() > 0.0f ? 0.0f : 1.0f);
if (window.keyPressed(GLFW_KEY_O))
demo.weather.setOvercast(demo.weather.targetOvercast() > 0.0f ? 0.0f : 1.0f);
if (window.keyPressed(GLFW_KEY_G)) demo.weather.setFog(demo.weather.targetFog() > 0.0f ? 0.0f : 1.0f);
if (window.keyPressed(GLFW_KEY_L)) demo.weather.setLightning(!demo.weather.lightning());
if (window.keyPressed(GLFW_KEY_LEFT_BRACKET)) hour = std::fmod(hour + 23.0f, 24.0f), useDayCycle = true;
if (window.keyPressed(GLFW_KEY_RIGHT_BRACKET)) hour = std::fmod(hour + 1.0f, 24.0f), useDayCycle = true;
if (window.keyPressed(GLFW_KEY_ESCAPE)) window.requestClose();
if (window.keyPressed(GLFW_KEY_T)) showcase.active ? showcase.close() : showcase.open();
if (showcase.active) showcase.input(window);
if (showcase.dialogue.isOpen()) move = opt.move; // no walking while talking
}
if (!ui.wantsMouse()) {
if (!capture && !ui.wantsMouse()) {
cam.distance = std::clamp(cam.distance * std::pow(0.9f, window.scrollDelta()), 8.0f, 45.0f);
if (window.mouseDown(GLFW_MOUSE_BUTTON_RIGHT)) {
glm::vec2 d = window.mouseDelta();
@@ -184,21 +336,37 @@ int run(hd2d::Window& window, const Options& opt) {
glm::vec3 desired = demo.playerPos + glm::vec3(0, 0.7f, 0);
cam.target += (desired - cam.target) * (capture ? 1.0f : std::min(1.0f, dt * 6.0f));
if (useDayCycle) demo.weather.setClearLook(dayCycle.look(hour));
demo.weather.update(dt, demo.scene, renderer.settings);
renderer.render(demo.scene, cam, time);
if (showUI) {
if (showUI || opt.spriteTest || showcase.active) {
ui.beginFrame();
hd2d::DebugUI::renderSettingsPanel(renderer.settings, demo.scene, renderer.gpuFrameMs());
hd2d::DebugUI::weatherPanel(demo.weather);
ImGui::SetNextWindowPos(ImVec2(float(window.width()) - 12.0f, 12.0f), ImGuiCond_FirstUseEver,
ImVec2(1.0f, 0.0f));
ImGui::SetNextWindowBgAlpha(0.5f);
ImGui::Begin("Controls", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar);
ImGui::TextUnformatted("WASD move | Q/E rotate | RMB orbit | wheel zoom");
ImGui::TextUnformatted("R toggle rain | F1 panel | F5 reload shaders | F12 screenshot");
ImGui::Text("player %.1f %.1f %.1f", demo.playerPos.x, demo.playerPos.y, demo.playerPos.z);
ImGui::End();
if (showUI) {
hd2d::DebugUI::renderSettingsPanel(renderer.settings, demo.scene, renderer.gpuFrameMs());
const float prevHour = hour;
hd2d::DebugUI::weatherPanel(demo.weather, &dayCycle, &hour);
if (hour != prevHour) useDayCycle = true;
ImGui::SetNextWindowPos(ImVec2(float(window.width()) - 12.0f, 12.0f), ImGuiCond_FirstUseEver,
ImVec2(1.0f, 0.0f));
ImGui::SetNextWindowBgAlpha(0.5f);
ImGui::Begin("Controls", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar);
ImGui::TextUnformatted("WASD move | Q/E rotate | RMB orbit | wheel zoom");
ImGui::TextUnformatted("R rain | O overcast | G fog | L lightning | [ ] hour -/+");
ImGui::TextUnformatted("F1 panel | F5 reload shaders | F12 screenshot");
ImGui::Text("player %.1f %.1f %.1f", demo.playerPos.x, demo.playerPos.y, demo.playerPos.z);
ImGui::End();
}
// Sprite test captions, projected with Camera::worldToScreen.
ImDrawList* dl = ImGui::GetForegroundDrawList();
const glm::vec2 viewport(float(window.width()), float(window.height()));
for (const DemoScene::Label& l : demo.labels) {
glm::vec2 px;
if (!cam.worldToScreen(l.pos, viewport, px)) continue;
ImVec2 ts = ImGui::CalcTextSize(l.text);
dl->AddText(ImVec2(px.x - ts.x * 0.5f, px.y - ts.y), IM_COL32(255, 255, 255, 255), l.text);
}
if (showcase.active) showcase.draw(viewport, cam, demo.playerPos, dt);
ui.endFrame();
}
@@ -206,7 +374,7 @@ int run(hd2d::Window& window, const Options& opt) {
if (!capture && window.keyPressed(GLFW_KEY_F12))
hd2d::saveScreenshot(timestampedScreenshotPath(), window.width(), window.height());
if (capture && frame >= opt.frames) {
hd2d::saveScreenshot(opt.screenshot, window.width(), window.height());
hd2d::saveScreenshot(opt.screenshot, window.width(), window.height(), renderer.outputFramebuffer());
HD2D_LOG("player at %.2f %.2f %.2f, GPU %.2f ms", demo.playerPos.x, demo.playerPos.y, demo.playerPos.z,
renderer.gpuFrameMs());
break;
@@ -222,8 +390,10 @@ int run(hd2d::Window& window, const Options& opt) {
int main(int argc, char** argv) {
Options opt = parseArgs(argc, argv);
hd2d::Window window;
// Captures use a fixed-size (floating) window so screenshots are reproducible.
if (!window.create(opt.width, opt.height, "HD-2D Engine Demo", true, opt.screenshot.empty())) return 1;
// Captures use a fixed-size (floating) window so screenshots are reproducible,
// hidden unless --show so they never pop up or take the user's input.
const bool capture = !opt.screenshot.empty();
if (!window.create(opt.width, opt.height, "HD-2D Engine Demo", true, !capture, !capture || opt.show)) return 1;
int rc = run(window, opt);
window.destroy();
return rc;

View File

@@ -104,7 +104,8 @@ so any documented endpoint parameter can be used.
- **Feet on the lowest opaque row.** Billboards are planted by the lowest
pixel with alpha ≥ 128 (`Texture::opaqueBounds`), so empty space below the
feet is fine but stray pixels below them make the sprite float.
feet is fine but stray pixels below them make the sprite float. Sheet
frames and atlas cells use a per-instance `anchor` instead (below).
- **No baked ground shadows.** The engine casts real shadows and draws a
contact blob under the player. PixelLab sometimes paints a grey ellipse under
objects; regenerate or erase it (alpha 0) before use.
@@ -115,19 +116,55 @@ so any documented endpoint parameter can be used.
- Sprite: `BillboardBatch b; b.loadTexture(path);` then push
`BillboardInstance{pos, sway, size, flip, tint}` and `b.upload()`.
`sway` 0 = rigid, 1 = grass-like wind and player push.
`sway` 0 = rigid, 1 = grass-like wind and player push. The remaining
fields default to "whole texture, no effects": `uv` (sub-rect), `anchor`
(feet row v), `turn` (extra yaw, radians; paper-turn / facing hints, shadow
cards stay sun-facing), `alpha` (0..1 fade, dithered in color and shadow
passes; there is no blending) and `flash` (0..1 toward bright white for hit
or capture flashes).
- Terrain layer: add the file stem to `kLayers` in `demo/DemoScene.cpp` (all
layers must be the same size) and reference its index from a
`TerrainMaterial`.
### Characters and animation (not wired up yet)
### Sprite sheets and atlases
One instance draws one sub-rect of its batch's texture: `uv` = (minU, minV,
maxU, maxV) in GL orientation (v = 0 is the image **bottom**), `size` = world
size of that rect (`BillboardBatch::cellSize(cellPixels, 32)`), and `anchor`
= v of the frame's feet row (`anchor < 0` falls back to the texture's lowest
opaque row, which is wrong for any frame but the bottom row). `flip`
mirrors inside the rect.
- **Grid sheets** (PNG authored top-down, equal cells, no spacing):
`uv = Texture::cellUV(tex.size(), cell, col, rowFromTop)` and
`anchor = Texture::cellAnchor(tex.size(), cell, rowFromTop, feetRowFromCellTop)`,
where `feetRowFromCellTop` is the pixel row (0 = top of the cell) the feet
stand on. Keep the feet on the same row in every frame of an animation or
the sprite bobs. Leave at least one transparent pixel between a frame's
art and its cell edge, otherwise filtering (and minified mips) bleeds the
neighbour frame in.
- **Runtime atlas** (`hd2d::SpriteAtlas`, for sprites created or downloaded
while the game runs): `atlas.init({64, 64}, cols, rows)`, `int slot =
atlas.add(image)` (bottom-centered in its cell, cropped to the cell, -1
when full), `set` / `remove` to replace or free a slot. Every cell has its
own 1 px transparent gutter. Share the page with
`batch.sharedTexture = &atlas.texture()` and use `uv = atlas.uv(slot)`,
`anchor = atlas.anchor(slot)` (the image's lowest opaque row). Images you
compose on the CPU should go through `bleedTransparentColors` (the atlas
and `loadImage` already do).
- `Texture::create/load(..., nearest = true)` makes a `GL_NEAREST`, mip-less
copy for crisp ImGui previews; billboards should keep the default.
- `hd2d_demo --sprite-test` shows a labelled row of atlas / sheet sprites
(flip, turn, alpha, flash, frame cycling) in front of the player.
### Characters and animation
PixelLab can make consistent characters with 4/8 directions and animations
(`POST /create-character-v3`, `/characters/animations`, then
`GET /characters/{id}/zip` or `/spritesheet`). To support them, add a new
`kind` to `tools/pixellab.py` (submit, poll `GET /background-jobs/{id}` every
5–10 s until `completed`, download) and a sprite-sheet animation path to
`BillboardBatch` (UV rect per frame). Keep the 32 px/unit density.
`GET /characters/{id}/zip` or `/spritesheet`). `tools/pixellab.py` does not
download them yet: add a new `kind` (submit, poll `GET /background-jobs/{id}`
every 5–10 s until `completed`, download) and play the frames through the
sprite-sheet path above. Keep the 32 px/unit density.
## 4. High-resolution textures for 3D models
@@ -244,10 +281,19 @@ Always look at the result; don't assume it looks right.
cmake -S . -B build -G Ninja && ninja -C build
./build/hd2d_demo --screenshot out.png --frames 90 --pos X,Z --dist 8 --yaw 30 --set dof=0
./build/hd2d_demo --screenshot rain.png --frames 90 --pos X,Z --rain 1
./build/hd2d_demo --screenshot night.png --frames 90 --pos X,Z --hour 23 # also try --hour 6 / 18
./build/hd2d_demo --screenshot ui.png --frames 420 --ui-demo # game UI (hd2d::ui)
```
- `--pos` puts the player (and camera target) at world X,Z; `--yaw` and
`--dist` orbit the camera; `--set dof=0` removes blur for inspection.
- Check every new model from at least two opposite yaws, in rain (wet look,
wind) and near water (reflection).
wind), at night (`--hour 23`: moonlight + fire light, emissives) and near
water (reflection).
- Check sprites at default zoom next to the player for scale and pixel size.
- UI fonts (`assets/fonts/`, loaded by `hd2d::ui::loadFonts`): OFL only, with
the font's `OFL.txt` saved as `<Family>-OFL.txt`; never modify or re-instance
a font with a Reserved Font Name (Cinzel). Dear ImGui's stb_truetype renders
a variable font's default instance, so choose fonts whose default weight
reads well. Check UI changes with `--ui-demo` at `--frames 120` (mid-reveal)
and `--frames 420`, at `--size 1920x1080` as well as the default size.

BIN
docs/ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

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); }