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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@
|
|||||||
# Build output
|
# Build output
|
||||||
build/
|
build/
|
||||||
build-*/
|
build-*/
|
||||||
|
build_*/
|
||||||
cmake-build-*/
|
cmake-build-*/
|
||||||
out/
|
out/
|
||||||
compile_commands.json
|
compile_commands.json
|
||||||
|
|||||||
21
AGENTS.md
21
AGENTS.md
@@ -8,20 +8,26 @@ before making any sprite, texture or model**.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cmake -S . -B build -G Ninja && ninja -C build
|
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 \
|
./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
|
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.
|
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.
|
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
|
## Map
|
||||||
|
|
||||||
| Path | What |
|
| 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 |
|
| `assets/shaders/` | GLSL; `common.glsl` holds the shared `Frame` UBO and lighting helpers |
|
||||||
| `demo/` | meadow scene: terrain layout, prop scattering, bridge, fires, player movement |
|
| `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`) |
|
| `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
|
- `FrameUBO` in `engine/src/Renderer.cpp` must mirror `Frame` in
|
||||||
`assets/shaders/common.glsl` exactly (std140, vec4/mat4 only); update the
|
`assets/shaders/common.glsl` exactly (std140, vec4/mat4 only); update the
|
||||||
`static_assert` size when adding fields.
|
`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.
|
- `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
|
- Use GL 4.5 DSA calls (`glCreate*`, `glNamed*`, `glTexture*`) like the rest
|
||||||
of the code. Shaders hot-reload with F5.
|
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
|
- Generated files (`assets/textures/*`, `assets/models/*`) come from scripts in
|
||||||
`tools/`; change the script, not the output.
|
`tools/`; change the script, not the output.
|
||||||
- PixelLab calls cost credits: check `--balance`, reuse `tile_id`s, and do not
|
- PixelLab calls cost credits: check `--balance`, reuse `tile_id`s, and do not
|
||||||
|
|||||||
@@ -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)
|
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 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
if(PROJECT_IS_TOP_LEVEL)
|
||||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE)
|
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||||
|
set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE)
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
include(FetchContent)
|
include(FetchContent)
|
||||||
@@ -14,21 +24,27 @@ include(FetchContent)
|
|||||||
# System packages are preferred; anything missing is fetched.
|
# System packages are preferred; anything missing is fetched.
|
||||||
find_package(OpenGL REQUIRED)
|
find_package(OpenGL REQUIRED)
|
||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy)
|
if(NOT TARGET PkgConfig::EPOXY)
|
||||||
|
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)
|
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
find_package(glm QUIET)
|
if(NOT TARGET glfw)
|
||||||
if(NOT glm_FOUND)
|
find_package(glfw3 3.3 QUIET)
|
||||||
FetchContent_Declare(glm GIT_REPOSITORY https://github.com/g-truc/glm.git GIT_TAG 1.0.1)
|
if(NOT glfw3_FOUND)
|
||||||
FetchContent_MakeAvailable(glm)
|
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()
|
endif()
|
||||||
|
|
||||||
find_path(STB_INCLUDE_DIR stb_image.h PATH_SUFFIXES stb)
|
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})
|
set(CGLTF_INCLUDE_DIR ${cgltf_SOURCE_DIR})
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git GIT_TAG v1.91.9b)
|
if(NOT TARGET imgui) # a parent project may provide its own
|
||||||
FetchContent_MakeAvailable(imgui)
|
FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git GIT_TAG v1.91.9b)
|
||||||
add_library(imgui STATIC
|
FetchContent_MakeAvailable(imgui)
|
||||||
${imgui_SOURCE_DIR}/imgui.cpp
|
add_library(imgui STATIC
|
||||||
${imgui_SOURCE_DIR}/imgui_draw.cpp
|
${imgui_SOURCE_DIR}/imgui.cpp
|
||||||
${imgui_SOURCE_DIR}/imgui_tables.cpp
|
${imgui_SOURCE_DIR}/imgui_draw.cpp
|
||||||
${imgui_SOURCE_DIR}/imgui_widgets.cpp
|
${imgui_SOURCE_DIR}/imgui_tables.cpp
|
||||||
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
|
${imgui_SOURCE_DIR}/imgui_widgets.cpp
|
||||||
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp)
|
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
|
||||||
target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends)
|
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp)
|
||||||
target_link_libraries(imgui PUBLIC glfw OpenGL::GL)
|
target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends)
|
||||||
|
target_link_libraries(imgui PUBLIC glfw OpenGL::GL)
|
||||||
|
endif()
|
||||||
|
|
||||||
# --- Engine -----------------------------------------------------------------
|
# --- Engine -----------------------------------------------------------------
|
||||||
file(GLOB_RECURSE HD2D_ENGINE_SOURCES CONFIGURE_DEPENDS engine/src/*.cpp)
|
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 PUBLIC engine/include)
|
||||||
target_include_directories(hd2d SYSTEM PRIVATE ${STB_INCLUDE_DIR} ${CGLTF_INCLUDE_DIR}) # silence third-party warnings
|
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_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)
|
if(MSVC)
|
||||||
target_compile_options(hd2d PRIVATE /W4)
|
target_compile_options(hd2d PRIVATE /W4)
|
||||||
else()
|
else()
|
||||||
@@ -71,7 +91,9 @@ else()
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
# --- Demo -------------------------------------------------------------------
|
# --- Demo -------------------------------------------------------------------
|
||||||
add_executable(hd2d_demo demo/main.cpp demo/DemoScene.cpp)
|
if(HD2D_BUILD_DEMO)
|
||||||
target_link_libraries(hd2d_demo PRIVATE hd2d)
|
add_executable(hd2d_demo demo/main.cpp demo/DemoScene.cpp)
|
||||||
# Assets/shaders are loaded relative to this directory at runtime.
|
target_link_libraries(hd2d_demo PRIVATE hd2d)
|
||||||
target_compile_definitions(hd2d_demo PRIVATE HD2D_ASSET_DIR="${CMAKE_SOURCE_DIR}/assets")
|
# 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
162
README.md
@@ -11,6 +11,7 @@ high-resolution 3D effects.
|
|||||||
|
|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
## What makes HD-2D look like HD-2D
|
## 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 |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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),
|
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),
|
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
|
## 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
|
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
|
```sh
|
||||||
# Arch / CachyOS
|
# 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
|
The asset path is baked in at configure time (`HD2D_ASSET_DIR`), so the demo can
|
||||||
be run from any working directory.
|
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
|
## Demo controls
|
||||||
|
|
||||||
| Input | Action |
|
| Input | Action |
|
||||||
@@ -65,8 +81,12 @@ be run from any working directory.
|
|||||||
| Mouse wheel | Zoom |
|
| Mouse wheel | Zoom |
|
||||||
| F1 | Toggle the tuning panels (sun, sky, AO, fog, god rays, water, DoF, bloom, grading, weather) |
|
| F1 | Toggle the tuning panels (sun, sky, AO, fog, god rays, water, DoF, bloom, grading, weather) |
|
||||||
| R | Toggle heavy rain (5 s transition) |
|
| 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 |
|
| F5 | Hot-reload shaders |
|
||||||
| F12 | Screenshot to `screenshots/` |
|
| F12 | Screenshot to `screenshots/` |
|
||||||
|
| T | Toggle the game UI showcase (Enter / Space advance the dialogue, Up / Down pick a choice) |
|
||||||
| Esc | Quit |
|
| Esc | Quit |
|
||||||
|
|
||||||
Scriptable captures (useful for look-dev and regression checks):
|
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
|
--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
|
`volumetrics dof bloom reflections occluderFade ssao groundAO ssaoRadius
|
||||||
ssaoIntensity fogDensity fogHeightFalloff godRays shaftStrength shaftCoverage
|
ssaoIntensity fogDensity fogHeightFalloff godRays shaftStrength shaftCoverage
|
||||||
shaftScale cookieStrength exposure bloomStrength tiltShift focusRange`.
|
shaftScale cookieStrength exposure bloomStrength tiltShift focusRange`.
|
||||||
@@ -88,6 +132,7 @@ engine/include/hd2d/ public headers
|
|||||||
engine/src/ engine implementation
|
engine/src/ engine implementation
|
||||||
assets/shaders/ GLSL (common.glsl = shared UBO + lighting helpers)
|
assets/shaders/ GLSL (common.glsl = shared UBO + lighting helpers)
|
||||||
assets/textures/ PixelLab-generated pixel art (terrain tiles, grass, flowers)
|
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/sprites/ character sprites
|
||||||
assets/models/ scenery.glb (trees, bushes, rocks, bridge) + src/ painted textures
|
assets/models/ scenery.glb (trees, bushes, rocks, bridge) + src/ painted textures
|
||||||
demo/ meadow demo scene + main loop
|
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.instances.push_back({{10, 0, 10}, 0.0f, hero.sizeForDensity(32.0f)});
|
||||||
hero.upload();
|
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::ModelLibrary models; models.load("assets/models/scenery.glb");
|
||||||
hd2d::ModelBatch oaks(models.get("oak_a"));
|
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});
|
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;
|
hd2d::Scene scene;
|
||||||
scene.terrain = &terrain;
|
scene.terrain = &terrain;
|
||||||
scene.billboards = {&hero};
|
scene.billboards = {&hero, &critters};
|
||||||
scene.models = {&oaks};
|
scene.models = {&oaks};
|
||||||
|
|
||||||
hd2d::GroundOcclusion contactAO; // dark contact shadow at tree bases
|
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 +
|
scattering, bridge walking, fires from model sockets, contact AO, water +
|
||||||
shore field, particles, weather, player movement and collision.
|
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
|
## 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
|
The full guide to making sprites, terrain tiles, high-res textures and 3D
|
||||||
models (scale, conventions, PixelLab manifest fields, Blender helpers, how to
|
models (scale, conventions, PixelLab manifest fields, Blender helpers, how to
|
||||||
check results) is in [`docs/ASSET_GUIDE.md`](docs/ASSET_GUIDE.md). Summary:
|
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
|
## Roadmap
|
||||||
|
|
||||||
The demo covers the rendering core. Natural next steps for a full engine:
|
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
|
animation playback + 4/8-direction facing on top of the sheet/atlas frames, a scene/level file format and
|
||||||
editor, more weather (snow, thunderstorms with lightning, wind-blown leaves),
|
editor, more weather (snow, wind-blown leaves), cascaded shadows for large maps, audio, and an
|
||||||
time-of-day presets, cascaded shadows for large maps, audio, and an
|
|
||||||
entity/component layer for gameplay.
|
entity/component layer for gameplay.
|
||||||
|
|||||||
93
assets/fonts/Cinzel-OFL.txt
Normal file
93
assets/fonts/Cinzel-OFL.txt
Normal 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
BIN
assets/fonts/Cinzel.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/CinzelDecorative-Bold.ttf
Normal file
BIN
assets/fonts/CinzelDecorative-Bold.ttf
Normal file
Binary file not shown.
93
assets/fonts/CinzelDecorative-OFL.txt
Normal file
93
assets/fonts/CinzelDecorative-OFL.txt
Normal 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.
|
||||||
93
assets/fonts/EBGaramond-OFL.txt
Normal file
93
assets/fonts/EBGaramond-OFL.txt
Normal 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
BIN
assets/fonts/EBGaramond.ttf
Normal file
Binary file not shown.
@@ -8,6 +8,7 @@ in VS {
|
|||||||
vec3 pivot;
|
vec3 pivot;
|
||||||
vec2 local;
|
vec2 local;
|
||||||
float tint;
|
float tint;
|
||||||
|
flat vec2 fx; // alpha, flash
|
||||||
} v;
|
} v;
|
||||||
|
|
||||||
layout(binding = 0) uniform sampler2D uTex;
|
layout(binding = 0) uniform sampler2D uTex;
|
||||||
@@ -15,10 +16,19 @@ uniform int uPass;
|
|||||||
uniform float uEmissive;
|
uniform float uEmissive;
|
||||||
|
|
||||||
layout(location = 0) out vec4 fragColor;
|
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() {
|
void main() {
|
||||||
vec4 c = sharpSample(uTex, v.uv);
|
vec4 c = sharpSample(uTex, v.uv);
|
||||||
if (c.a < 0.5) discard;
|
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;
|
if (uPass == 1) return;
|
||||||
|
|
||||||
vec3 up = vec3(0.0, 1.0, 0.0);
|
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;
|
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 *= 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);
|
fragColor = vec4(col, 1.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#version 450 core
|
#version 450 core
|
||||||
#include "common.glsl"
|
#include "common.glsl"
|
||||||
|
|
||||||
|
// Per-instance attributes; must match BillboardInstance (Billboard.h).
|
||||||
layout(location = 0) in vec4 iPosSway; // pivot xyz, sway
|
layout(location = 0) in vec4 iPosSway; // pivot xyz, sway
|
||||||
layout(location = 1) in vec4 iSizeFlipTint; // size xy, flip, tint
|
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 vec4 uOpaque; // opaque pixel bounds in uv (minX, minY, maxX, maxY)
|
||||||
uniform int uPass; // 0 = color, 1 = shadow
|
uniform int uPass; // 0 = color, 1 = shadow
|
||||||
@@ -13,22 +16,34 @@ out VS {
|
|||||||
vec3 pivot;
|
vec3 pivot;
|
||||||
vec2 local; // (horizontal offset, height above pivot) in world units
|
vec2 local; // (horizontal offset, height above pivot) in world units
|
||||||
float tint;
|
float tint;
|
||||||
|
flat vec2 fx; // alpha, flash
|
||||||
} o;
|
} o;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec2 corner = vec2(float(gl_VertexID & 1), float(gl_VertexID >> 1));
|
vec2 corner = vec2(float(gl_VertexID & 1), float(gl_VertexID >> 1));
|
||||||
vec2 size = iSizeFlipTint.xy;
|
vec2 size = iSizeFlipTint.xy;
|
||||||
vec3 pivot = iPosSway.xyz;
|
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 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.
|
// Shadow cards face the sun so silhouettes are never edge-on to the light;
|
||||||
vec3 right = uPass == 1 ? uShadowRight.xyz : uBillboardRight.xyz;
|
// `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);
|
vec3 wp = pivot + right * lx + vec3(0.0, ly, 0.0);
|
||||||
|
|
||||||
float sway = iPosSway.w;
|
float sway = iPosSway.w;
|
||||||
if (sway > 0.0) {
|
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);
|
float k = clamp(ly / visibleHeight, 0.0, 1.0);
|
||||||
k *= k;
|
k *= k;
|
||||||
float ph = dot(pivot.xz, vec2(0.37, 0.61));
|
float ph = dot(pivot.xz, vec2(0.37, 0.61));
|
||||||
@@ -46,13 +61,14 @@ void main() {
|
|||||||
wp.y -= push * k * sway * 0.3 * visibleHeight;
|
wp.y -= push * k * sway * 0.3 * visibleHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec2 uv = corner;
|
vec2 c = corner;
|
||||||
if (iSizeFlipTint.z > 0.5) uv.x = 1.0 - uv.x;
|
if (iSizeFlipTint.z > 0.5) c.x = 1.0 - c.x; // mirror inside the sub-rect
|
||||||
o.uv = uv;
|
o.uv = mix(rect.xy, rect.zw, c);
|
||||||
o.worldPos = wp;
|
o.worldPos = wp;
|
||||||
o.pivot = pivot;
|
o.pivot = pivot;
|
||||||
o.local = vec2(lx, ly);
|
o.local = vec2(lx, ly);
|
||||||
o.tint = iSizeFlipTint.w;
|
o.tint = iSizeFlipTint.w;
|
||||||
|
o.fx = iAnchorFx.zw;
|
||||||
gl_Position = uViewProj * vec4(wp, 1.0);
|
gl_Position = uViewProj * vec4(wp, 1.0);
|
||||||
gl_ClipDistance[0] = dot(vec4(wp, 1.0), uClipPlane);
|
gl_ClipDistance[0] = dot(vec4(wp, 1.0), uClipPlane);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ layout(std140, binding = 0) uniform Frame {
|
|||||||
vec4 uWaterParams; // level, caustics intensity, has water, unused
|
vec4 uWaterParams; // level, caustics intensity, has water, unused
|
||||||
vec4 uGroundAO; // world -> uv: minX, minZ, 1/sizeX, 1/sizeZ (z = 0: disabled)
|
vec4 uGroundAO; // world -> uv: minX, minZ, 1/sizeX, 1/sizeZ (z = 0: disabled)
|
||||||
vec4 uWeather; // rain intensity, surface wetness, rain fall speed, unused
|
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;
|
ivec4 uLightCount;
|
||||||
vec4 uLightPos[16]; // xyz, w = radius
|
vec4 uLightPos[16]; // xyz, w = radius
|
||||||
vec4 uLightColor[16];
|
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);
|
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) {
|
vec3 skyColor(vec3 dir) {
|
||||||
float h = dir.y;
|
float h = dir.y;
|
||||||
vec3 c = mix(uSkyHorizon.rgb, uSkyZenith.rgb, pow(clamp(h, 0.0, 1.0), 0.55));
|
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));
|
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 sd = max(dot(dir, uSunDir.xyz), 0.0);
|
||||||
|
float night = uSkyParams.x;
|
||||||
if (h > 0.0) {
|
if (h > 0.0) {
|
||||||
vec2 cp = dir.xz / (h + 0.12) * 1.3 + uTime * vec2(0.012, 0.004);
|
// Clouds catch the sun by day; at night they are faintly moonlit.
|
||||||
float cl = smoothstep(0.5 - 0.45 * uWeather.x, 0.85 - 0.3 * uWeather.x, fbm2(cp)); // overcast when raining
|
vec3 lit = mix(uSunColor.rgb * (0.75 + 0.6 * sd),
|
||||||
c = mix(c, uSunColor.rgb * (0.75 + 0.6 * sd), cl * 0.55 * smoothstep(0.0, 0.2, h));
|
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;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,65 @@
|
|||||||
in vec2 vUV;
|
in vec2 vUV;
|
||||||
layout(location = 0) out vec4 fragColor;
|
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() {
|
void main() {
|
||||||
vec3 far = worldFromDepth(vUV, 1.0);
|
vec3 far = worldFromDepth(vUV, 1.0);
|
||||||
vec3 dir = normalize(far - uCamPos.xyz);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,9 +141,111 @@ bool DemoScene::load(const std::string& assetDir) {
|
|||||||
|
|
||||||
player_.instances.resize(1);
|
player_.instances.resize(1);
|
||||||
placePlayer(kStart);
|
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;
|
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() {
|
void DemoScene::buildTerrain() {
|
||||||
Terrain& t = terrain_;
|
Terrain& t = terrain_;
|
||||||
t.init(kW, kD, 0.5f);
|
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.interactor = playerPos;
|
||||||
scene.blobShadowPos = glm::vec3(playerPos.x, ground, playerPos.z);
|
scene.blobShadowPos = glm::vec3(playerPos.x, ground, playerPos.z);
|
||||||
scene.focusPoint = playerPos + glm::vec3(0, 0.6f, 0);
|
scene.focusPoint = playerPos + glm::vec3(0, 0.6f, 0);
|
||||||
|
if (spriteTest) updateSpriteTest(time, camera);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "hd2d/Camera.h"
|
#include "hd2d/Camera.h"
|
||||||
#include "hd2d/Scene.h"
|
#include "hd2d/Scene.h"
|
||||||
|
#include "hd2d/SpriteAtlas.h"
|
||||||
#include "hd2d/Weather.h"
|
#include "hd2d/Weather.h"
|
||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
@@ -21,11 +22,24 @@ public:
|
|||||||
glm::vec3 playerPos{0.0f};
|
glm::vec3 playerPos{0.0f};
|
||||||
hd2d::Weather weather; // call weather.update() each frame before rendering
|
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:
|
private:
|
||||||
struct Obstacle {
|
struct Obstacle {
|
||||||
glm::vec2 pos;
|
glm::vec2 pos;
|
||||||
float radius;
|
float radius;
|
||||||
};
|
};
|
||||||
|
bool buildSpriteTest(const std::string& assetDir);
|
||||||
|
void updateSpriteTest(float time, const hd2d::Camera& camera);
|
||||||
void buildTerrain();
|
void buildTerrain();
|
||||||
void scatterProps();
|
void scatterProps();
|
||||||
bool walkable(glm::vec2 from, glm::vec2 to) const;
|
bool walkable(glm::vec2 from, glm::vec2 to) const;
|
||||||
@@ -45,6 +59,11 @@ private:
|
|||||||
std::mt19937 rng_{42};
|
std::mt19937 rng_{42};
|
||||||
float playerSpeed_ = 4.2f;
|
float playerSpeed_ = 4.2f;
|
||||||
float walkPhase_ = 0.0f;
|
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 stepTimer_ = 0.0f;
|
||||||
float facing_ = 0.0f;
|
float facing_ = 0.0f;
|
||||||
};
|
};
|
||||||
|
|||||||
222
demo/main.cpp
222
demo/main.cpp
@@ -1,18 +1,26 @@
|
|||||||
// HD-2D engine demo: meadow diorama with a WASD-controlled sprite.
|
// HD-2D engine demo: meadow diorama with a WASD-controlled sprite.
|
||||||
//
|
//
|
||||||
// Controls: WASD/arrows move, Q/E rotate camera, right-drag orbit, wheel zoom,
|
// 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:
|
// Captures run hidden: --screenshot implies a hidden, input-ignoring window
|
||||||
// hd2d_demo --screenshot out.png [--frames 120] [--move x,y] [--pos x,z]
|
// 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]
|
// [--sun azimuthDeg,elevationDeg] [--yaw deg] [--dist units] [--size WxH] [--ui]
|
||||||
// [--set name=value ...] e.g. --set godRays=4 --set dof=0
|
// [--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 "DemoScene.h"
|
||||||
|
#include "hd2d/DayCycle.h"
|
||||||
#include "hd2d/DebugUI.h"
|
#include "hd2d/DebugUI.h"
|
||||||
#include "hd2d/Log.h"
|
#include "hd2d/Log.h"
|
||||||
#include "hd2d/Renderer.h"
|
#include "hd2d/Renderer.h"
|
||||||
#include "hd2d/Screenshot.h"
|
#include "hd2d/Screenshot.h"
|
||||||
|
#include "hd2d/UI.h"
|
||||||
#include "hd2d/Window.h"
|
#include "hd2d/Window.h"
|
||||||
|
|
||||||
#include <GLFW/glfw3.h>
|
#include <GLFW/glfw3.h>
|
||||||
@@ -38,10 +46,11 @@ struct Options {
|
|||||||
std::string screenshot;
|
std::string screenshot;
|
||||||
int frames = 120;
|
int frames = 120;
|
||||||
glm::vec2 move{0.0f};
|
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
|
std::vector<std::pair<std::string, float>> settings; // --set name=value
|
||||||
glm::vec2 pos{0.0f}, sun{0.0f};
|
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;
|
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], "--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], "--dist")) o.dist = float(std::atof(next()));
|
||||||
else if (!std::strcmp(argv[i], "--ui")) o.ui = true;
|
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], "--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")) {
|
else if (!std::strcmp(argv[i], "--set")) {
|
||||||
std::string kv = next();
|
std::string kv = next();
|
||||||
auto eq = kv.find('=');
|
auto eq = kv.find('=');
|
||||||
@@ -111,16 +128,124 @@ std::string timestampedScreenshotPath() {
|
|||||||
return buf;
|
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.
|
// Owns every GL object so they are destroyed while the context still exists.
|
||||||
int run(hd2d::Window& window, const Options& opt) {
|
int run(hd2d::Window& window, const Options& opt) {
|
||||||
const bool capture = !opt.screenshot.empty();
|
const bool capture = !opt.screenshot.empty();
|
||||||
|
|
||||||
hd2d::Renderer renderer;
|
hd2d::Renderer renderer;
|
||||||
if (!renderer.init(std::string(HD2D_ASSET_DIR) + "/shaders", window.width(), window.height())) return 1;
|
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)
|
for (const auto& [name, value] : opt.settings)
|
||||||
if (!applySetting(renderer.settings, name, value)) HD2D_ERR("unknown setting '%s'", name.c_str());
|
if (!applySetting(renderer.settings, name, value)) HD2D_ERR("unknown setting '%s'", name.c_str());
|
||||||
|
|
||||||
DemoScene demo;
|
DemoScene demo;
|
||||||
|
demo.spriteTest = opt.spriteTest;
|
||||||
if (!demo.load(HD2D_ASSET_DIR)) {
|
if (!demo.load(HD2D_ASSET_DIR)) {
|
||||||
HD2D_ERR("failed to load demo assets from %s", HD2D_ASSET_DIR);
|
HD2D_ERR("failed to load demo assets from %s", HD2D_ASSET_DIR);
|
||||||
return 1;
|
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.azimuth = glm::radians(opt.sun.x);
|
||||||
demo.scene.sun.elevation = glm::radians(opt.sun.y);
|
demo.scene.sun.elevation = glm::radians(opt.sun.y);
|
||||||
}
|
}
|
||||||
if (opt.rain > 0.0f) {
|
// Weather targets; captures start already at them.
|
||||||
demo.weather.setRain(opt.rain);
|
demo.weather.setRain(opt.rain);
|
||||||
demo.weather.snap(); // captures start already raining
|
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;
|
hd2d::DebugUI ui;
|
||||||
if (!ui.init(window)) return 1;
|
if (!ui.init(window)) return 1;
|
||||||
bool showUI = capture ? opt.ui : true;
|
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;
|
hd2d::Camera cam;
|
||||||
if (opt.hasYaw) cam.yaw = glm::radians(opt.yaw);
|
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;
|
if (opt.dist > 0.0f) cam.distance = opt.dist;
|
||||||
cam.target = demo.playerPos + glm::vec3(0, 0.7f, 0);
|
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
|
if (capture) dt = 1.0f / 60.0f; // deterministic captures
|
||||||
time += dt;
|
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;
|
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_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_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;
|
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_F1)) showUI = !showUI;
|
||||||
if (window.keyPressed(GLFW_KEY_F5)) renderer.reloadShaders();
|
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_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_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);
|
cam.distance = std::clamp(cam.distance * std::pow(0.9f, window.scrollDelta()), 8.0f, 45.0f);
|
||||||
if (window.mouseDown(GLFW_MOUSE_BUTTON_RIGHT)) {
|
if (window.mouseDown(GLFW_MOUSE_BUTTON_RIGHT)) {
|
||||||
glm::vec2 d = window.mouseDelta();
|
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);
|
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));
|
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);
|
demo.weather.update(dt, demo.scene, renderer.settings);
|
||||||
renderer.render(demo.scene, cam, time);
|
renderer.render(demo.scene, cam, time);
|
||||||
|
|
||||||
if (showUI) {
|
if (showUI || opt.spriteTest || showcase.active) {
|
||||||
ui.beginFrame();
|
ui.beginFrame();
|
||||||
hd2d::DebugUI::renderSettingsPanel(renderer.settings, demo.scene, renderer.gpuFrameMs());
|
if (showUI) {
|
||||||
hd2d::DebugUI::weatherPanel(demo.weather);
|
hd2d::DebugUI::renderSettingsPanel(renderer.settings, demo.scene, renderer.gpuFrameMs());
|
||||||
ImGui::SetNextWindowPos(ImVec2(float(window.width()) - 12.0f, 12.0f), ImGuiCond_FirstUseEver,
|
const float prevHour = hour;
|
||||||
ImVec2(1.0f, 0.0f));
|
hd2d::DebugUI::weatherPanel(demo.weather, &dayCycle, &hour);
|
||||||
ImGui::SetNextWindowBgAlpha(0.5f);
|
if (hour != prevHour) useDayCycle = true;
|
||||||
ImGui::Begin("Controls", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar);
|
ImGui::SetNextWindowPos(ImVec2(float(window.width()) - 12.0f, 12.0f), ImGuiCond_FirstUseEver,
|
||||||
ImGui::TextUnformatted("WASD move | Q/E rotate | RMB orbit | wheel zoom");
|
ImVec2(1.0f, 0.0f));
|
||||||
ImGui::TextUnformatted("R toggle rain | F1 panel | F5 reload shaders | F12 screenshot");
|
ImGui::SetNextWindowBgAlpha(0.5f);
|
||||||
ImGui::Text("player %.1f %.1f %.1f", demo.playerPos.x, demo.playerPos.y, demo.playerPos.z);
|
ImGui::Begin("Controls", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar);
|
||||||
ImGui::End();
|
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();
|
ui.endFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +374,7 @@ int run(hd2d::Window& window, const Options& opt) {
|
|||||||
if (!capture && window.keyPressed(GLFW_KEY_F12))
|
if (!capture && window.keyPressed(GLFW_KEY_F12))
|
||||||
hd2d::saveScreenshot(timestampedScreenshotPath(), window.width(), window.height());
|
hd2d::saveScreenshot(timestampedScreenshotPath(), window.width(), window.height());
|
||||||
if (capture && frame >= opt.frames) {
|
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,
|
HD2D_LOG("player at %.2f %.2f %.2f, GPU %.2f ms", demo.playerPos.x, demo.playerPos.y, demo.playerPos.z,
|
||||||
renderer.gpuFrameMs());
|
renderer.gpuFrameMs());
|
||||||
break;
|
break;
|
||||||
@@ -222,8 +390,10 @@ int run(hd2d::Window& window, const Options& opt) {
|
|||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
Options opt = parseArgs(argc, argv);
|
Options opt = parseArgs(argc, argv);
|
||||||
hd2d::Window window;
|
hd2d::Window window;
|
||||||
// Captures use a fixed-size (floating) window so screenshots are reproducible.
|
// 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;
|
// 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);
|
int rc = run(window, opt);
|
||||||
window.destroy();
|
window.destroy();
|
||||||
return rc;
|
return rc;
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ so any documented endpoint parameter can be used.
|
|||||||
|
|
||||||
- **Feet on the lowest opaque row.** Billboards are planted by the lowest
|
- **Feet on the lowest opaque row.** Billboards are planted by the lowest
|
||||||
pixel with alpha ≥ 128 (`Texture::opaqueBounds`), so empty space below the
|
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
|
- **No baked ground shadows.** The engine casts real shadows and draws a
|
||||||
contact blob under the player. PixelLab sometimes paints a grey ellipse under
|
contact blob under the player. PixelLab sometimes paints a grey ellipse under
|
||||||
objects; regenerate or erase it (alpha 0) before use.
|
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
|
- Sprite: `BillboardBatch b; b.loadTexture(path);` then push
|
||||||
`BillboardInstance{pos, sway, size, flip, tint}` and `b.upload()`.
|
`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
|
- 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
|
layers must be the same size) and reference its index from a
|
||||||
`TerrainMaterial`.
|
`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
|
PixelLab can make consistent characters with 4/8 directions and animations
|
||||||
(`POST /create-character-v3`, `/characters/animations`, then
|
(`POST /create-character-v3`, `/characters/animations`, then
|
||||||
`GET /characters/{id}/zip` or `/spritesheet`). To support them, add a new
|
`GET /characters/{id}/zip` or `/spritesheet`). `tools/pixellab.py` does not
|
||||||
`kind` to `tools/pixellab.py` (submit, poll `GET /background-jobs/{id}` every
|
download them yet: add a new `kind` (submit, poll `GET /background-jobs/{id}`
|
||||||
5–10 s until `completed`, download) and a sprite-sheet animation path to
|
every 5–10 s until `completed`, download) and play the frames through the
|
||||||
`BillboardBatch` (UV rect per frame). Keep the 32 px/unit density.
|
sprite-sheet path above. Keep the 32 px/unit density.
|
||||||
|
|
||||||
## 4. High-resolution textures for 3D models
|
## 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
|
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 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 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
|
- `--pos` puts the player (and camera target) at world X,Z; `--yaw` and
|
||||||
`--dist` orbit the camera; `--set dof=0` removes blur for inspection.
|
`--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,
|
- 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.
|
- 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
BIN
docs/ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -7,17 +7,29 @@
|
|||||||
|
|
||||||
namespace hd2d {
|
namespace hd2d {
|
||||||
|
|
||||||
// One upright pixel-art sprite. `pos` is where the lowest opaque pixel row
|
// One upright pixel-art sprite. `pos` is where the feet (pivot) row touches
|
||||||
// touches the ground; sprites always face the camera around the Y axis.
|
// 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 {
|
struct BillboardInstance {
|
||||||
glm::vec3 pos{0.0f};
|
glm::vec3 pos{0.0f};
|
||||||
float sway = 0.0f; // wind / player-push response (0 = rigid)
|
float sway = 0.0f; // wind / player-push response (0 = rigid)
|
||||||
glm::vec2 size{1.0f}; // world units of the full texture quad
|
glm::vec2 size{1.0f}; // world size of the `uv` sub-rect quad
|
||||||
float flip = 0.0f; // 1 = mirror horizontally
|
float flip = 0.0f; // 1 = mirror horizontally (inside the sub-rect)
|
||||||
float tint = 1.0f; // brightness multiplier (breaks up repetition)
|
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 {
|
class BillboardBatch {
|
||||||
public:
|
public:
|
||||||
BillboardBatch() = default;
|
BillboardBatch() = default;
|
||||||
@@ -29,12 +41,20 @@ public:
|
|||||||
void upload(); // call after editing `instances`
|
void upload(); // call after editing `instances`
|
||||||
void draw() const;
|
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 {
|
glm::vec2 sizeForDensity(float pixelsPerUnit) const {
|
||||||
return glm::vec2(texture.size()) / pixelsPerUnit;
|
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;
|
Texture texture;
|
||||||
|
const Texture* sharedTexture = nullptr; // not owned; must outlive the batch's use
|
||||||
std::vector<BillboardInstance> instances;
|
std::vector<BillboardInstance> instances;
|
||||||
bool castShadows = true;
|
bool castShadows = true;
|
||||||
bool inReflections = true;
|
bool inReflections = true;
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ struct Camera {
|
|||||||
glm::vec3 right() const; // horizontal right vector
|
glm::vec3 right() const; // horizontal right vector
|
||||||
glm::mat4 view() const;
|
glm::mat4 view() const;
|
||||||
glm::mat4 projection(float aspect) 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
|
} // namespace hd2d
|
||||||
|
|||||||
44
engine/include/hd2d/DayCycle.h
Normal file
44
engine/include/hd2d/DayCycle.h
Normal 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
|
||||||
@@ -6,6 +6,7 @@ class Window;
|
|||||||
struct RenderSettings;
|
struct RenderSettings;
|
||||||
struct Scene;
|
struct Scene;
|
||||||
class Weather;
|
class Weather;
|
||||||
|
class DayCycle;
|
||||||
|
|
||||||
// Dear ImGui integration plus a ready-made tuning panel for the renderer.
|
// Dear ImGui integration plus a ready-made tuning panel for the renderer.
|
||||||
class DebugUI {
|
class DebugUI {
|
||||||
@@ -19,8 +20,9 @@ public:
|
|||||||
|
|
||||||
// Tweak panel for every look parameter (sun, sky, fog, AO, water, post).
|
// Tweak panel for every look parameter (sun, sky, fog, AO, water, post).
|
||||||
static void renderSettingsPanel(RenderSettings& settings, Scene& scene, float gpuMs);
|
static void renderSettingsPanel(RenderSettings& settings, Scene& scene, float gpuMs);
|
||||||
// Rain amount, transition time and the storm preset.
|
// Rain / overcast / fog / lightning, transition time and the weather
|
||||||
static void weatherPanel(Weather& weather);
|
// presets; with `hour` an hour slider, with `day` its keyframes.
|
||||||
|
static void weatherPanel(Weather& weather, DayCycle* day = nullptr, float* hour = nullptr);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool initialized_ = false;
|
bool initialized_ = false;
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ public:
|
|||||||
glm::vec3 fireLightColor{4.0f, 1.75f, 0.55f};
|
glm::vec3 fireLightColor{4.0f, 1.75f, 0.55f};
|
||||||
float fireLightRadius = 7.0f;
|
float fireLightRadius = 7.0f;
|
||||||
glm::vec3 wind{0.0f}; // world-space drift applied to smoke and embers
|
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:
|
private:
|
||||||
struct Particle {
|
struct Particle {
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ public:
|
|||||||
bool reloadShaders();
|
bool reloadShaders();
|
||||||
void render(const Scene& scene, const Camera& camera, float time);
|
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;
|
RenderSettings settings;
|
||||||
float gpuFrameMs() const { return gpuMs_; }
|
float gpuFrameMs() const { return gpuMs_; }
|
||||||
|
|
||||||
@@ -95,6 +104,7 @@ private:
|
|||||||
struct Impl;
|
struct Impl;
|
||||||
std::unique_ptr<Impl> impl_;
|
std::unique_ptr<Impl> impl_;
|
||||||
float gpuMs_ = 0.0f;
|
float gpuMs_ = 0.0f;
|
||||||
|
GLuint outputFbo_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace hd2d
|
} // namespace hd2d
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ struct Sky {
|
|||||||
glm::vec3 ground{0.20f, 0.24f, 0.14f}; // bounce light from below
|
glm::vec3 ground{0.20f, 0.24f, 0.14f}; // bounce light from below
|
||||||
float ambient = 0.55f;
|
float ambient = 0.55f;
|
||||||
float fill = 0.35f; // light from the camera side onto faces turned toward the viewer
|
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.
|
// Everything the renderer draws. Pointers are non-owning; null entries skip.
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include "hd2d/GL.h"
|
||||||
|
#include "hd2d/RenderTarget.h"
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace hd2d {
|
namespace hd2d {
|
||||||
|
|
||||||
// Reads the current backbuffer (before swap) and writes it as PNG.
|
// Writes color attachment 0 of `fbo` as PNG; `fbo` = 0 reads the window's
|
||||||
bool saveScreenshot(const std::string& path, int width, int height);
|
// 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
|
} // namespace hd2d
|
||||||
|
|||||||
56
engine/include/hd2d/SpriteAtlas.h
Normal file
56
engine/include/hd2d/SpriteAtlas.h
Normal 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
|
||||||
@@ -19,9 +19,14 @@ bool loadImage(const std::string& path, Image& out);
|
|||||||
// Decodes PNG bytes from memory. glTF images use flipY = false (their UV
|
// Decodes PNG bytes from memory. glTF images use flipY = false (their UV
|
||||||
// origin is top-left, which matches GL when rows are uploaded unflipped).
|
// 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);
|
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
|
// 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 {
|
class Texture {
|
||||||
public:
|
public:
|
||||||
Texture() = default;
|
Texture() = default;
|
||||||
@@ -31,8 +36,14 @@ public:
|
|||||||
Texture& operator=(Texture&& o) noexcept;
|
Texture& operator=(Texture&& o) noexcept;
|
||||||
~Texture();
|
~Texture();
|
||||||
|
|
||||||
bool load(const std::string& path, bool repeat = false);
|
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 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_); }
|
void bind(GLuint unit) const { glBindTextureUnit(unit, id_); }
|
||||||
|
|
||||||
GLuint id() const { return id_; }
|
GLuint id() const { return id_; }
|
||||||
@@ -41,9 +52,16 @@ public:
|
|||||||
// Billboards use minY to plant the visible feet on the ground.
|
// Billboards use minY to plant the visible feet on the ground.
|
||||||
glm::vec4 opaqueBounds() const { return opaqueBounds_; }
|
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:
|
private:
|
||||||
GLuint id_ = 0;
|
GLuint id_ = 0;
|
||||||
int width_ = 0, height_ = 0;
|
int width_ = 0, height_ = 0, levels_ = 0;
|
||||||
glm::vec4 opaqueBounds_{0, 0, 1, 1};
|
glm::vec4 opaqueBounds_{0, 0, 1, 1};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
162
engine/include/hd2d/UI.h
Normal file
162
engine/include/hd2d/UI.h
Normal 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
|
||||||
@@ -8,46 +8,87 @@ namespace hd2d {
|
|||||||
|
|
||||||
struct RenderSettings;
|
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 {
|
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;
|
float sunIntensity = 0.0f;
|
||||||
glm::vec3 sunColor{1.0f};
|
glm::vec3 sunColor{1.0f};
|
||||||
glm::vec3 zenith{0.5f}, horizon{0.5f}, ground{0.2f};
|
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};
|
glm::vec3 fogColor{0.5f};
|
||||||
float fogDensity = 0.0f, fogHeightFalloff = 0.1f, godRays = 0.0f, shaftStrength = 0.0f, cookieStrength = 0.0f;
|
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 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
|
// Linear blend of every field (t = 0: a, t = 1: b).
|
||||||
// scene + settings while it is not raining, so manual tuning is kept) toward
|
WeatherLook mixLooks(const WeatherLook& a, const WeatherLook& b, float t);
|
||||||
// a storm preset, and drives rain intensity, wetness, ground splashes and the
|
|
||||||
// fading of ambient particles.
|
// 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 {
|
class Weather {
|
||||||
public:
|
public:
|
||||||
Weather();
|
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 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 targetRain() const { return targetRain_; }
|
||||||
|
float targetOvercast() const { return targetOvercast_; }
|
||||||
|
float targetFog() const { return targetFog_; }
|
||||||
|
bool lightning() const { return lightning_; }
|
||||||
float rain() const { return rain_; }
|
float rain() const { return rain_; }
|
||||||
// Jumps straight to the target (no transition), e.g. for screenshots.
|
float overcast() const { return overcast_; }
|
||||||
void snap() { rain_ = targetRain_; wetness_ = targetRain_; }
|
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);
|
void update(float dt, Scene& scene, RenderSettings& settings);
|
||||||
|
|
||||||
WeatherLook storm; // look at full rain intensity
|
// Presets at full daylight. Time-of-day fields (sun direction, night,
|
||||||
float transitionSeconds = 5.0f; // clear <-> storm fade time
|
// 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 splashesPerSecond = 500.0f;
|
||||||
float splashRadius = 12.0f; // around Scene::focusPoint
|
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:
|
private:
|
||||||
static WeatherLook capture(const Scene& scene, const RenderSettings& settings);
|
|
||||||
static void apply(const WeatherLook& look, Scene& scene, 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_;
|
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 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};
|
std::mt19937 rng_{99};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,27 @@ namespace hd2d {
|
|||||||
class Window {
|
class Window {
|
||||||
public:
|
public:
|
||||||
// Non-resizable windows float on tiling WMs, keeping an exact size.
|
// 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();
|
void destroy();
|
||||||
|
|
||||||
// Polls events and refreshes per-frame input state. Returns false once the
|
// Polls events and refreshes per-frame input state. Returns false once the
|
||||||
// window should close.
|
// window should close.
|
||||||
bool pollEvents();
|
bool pollEvents();
|
||||||
void swapBuffers();
|
void swapBuffers();
|
||||||
|
bool visible() const { return visible_; }
|
||||||
|
|
||||||
bool keyDown(int glfwKey) const;
|
bool keyDown(int glfwKey) const;
|
||||||
bool keyPressed(int glfwKey) const; // went down this frame
|
bool keyPressed(int glfwKey) const; // went down this frame
|
||||||
bool mouseDown(int glfwButton) const;
|
bool mouseDown(int glfwButton) const;
|
||||||
glm::vec2 mouseDelta() const { return mouseDelta_; }
|
glm::vec2 mouseDelta() const { return visible_ ? mouseDelta_ : glm::vec2(0.0f); }
|
||||||
float scrollDelta() const { return scrollDelta_; }
|
float scrollDelta() const { return visible_ ? scrollDelta_ : 0.0f; }
|
||||||
|
|
||||||
int width() const { return width_; }
|
int width() const { return width_; }
|
||||||
int height() const { return height_; }
|
int height() const { return height_; }
|
||||||
@@ -38,6 +46,7 @@ private:
|
|||||||
GLFWwindow* window_ = nullptr;
|
GLFWwindow* window_ = nullptr;
|
||||||
int width_ = 0, height_ = 0;
|
int width_ = 0, height_ = 0;
|
||||||
bool resized_ = false;
|
bool resized_ = false;
|
||||||
|
bool visible_ = true;
|
||||||
bool keys_[512] = {};
|
bool keys_[512] = {};
|
||||||
bool prevKeys_[512] = {};
|
bool prevKeys_[512] = {};
|
||||||
glm::vec2 lastMouse_{0.0f};
|
glm::vec2 lastMouse_{0.0f};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
namespace hd2d {
|
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() {
|
BillboardBatch::~BillboardBatch() {
|
||||||
if (vbo_) glDeleteBuffers(1, &vbo_);
|
if (vbo_) glDeleteBuffers(1, &vbo_);
|
||||||
@@ -16,12 +16,14 @@ void BillboardBatch::upload() {
|
|||||||
glCreateVertexArrays(1, &vao_);
|
glCreateVertexArrays(1, &vao_);
|
||||||
glCreateBuffers(1, &vbo_);
|
glCreateBuffers(1, &vbo_);
|
||||||
glVertexArrayBindingDivisor(vao_, 0, 1);
|
glVertexArrayBindingDivisor(vao_, 0, 1);
|
||||||
glEnableVertexArrayAttrib(vao_, 0);
|
// Four vec4 attributes: pos+sway, size+flip+tint, uv rect, anchor+turn+alpha+flash.
|
||||||
glVertexArrayAttribFormat(vao_, 0, 4, GL_FLOAT, GL_FALSE, offsetof(BillboardInstance, pos));
|
const GLuint offsets[] = {GLuint(offsetof(BillboardInstance, pos)), GLuint(offsetof(BillboardInstance, size)),
|
||||||
glVertexArrayAttribBinding(vao_, 0, 0);
|
GLuint(offsetof(BillboardInstance, uv)), GLuint(offsetof(BillboardInstance, anchor))};
|
||||||
glEnableVertexArrayAttrib(vao_, 1);
|
for (GLuint a = 0; a < 4; ++a) {
|
||||||
glVertexArrayAttribFormat(vao_, 1, 4, GL_FLOAT, GL_FALSE, offsetof(BillboardInstance, size));
|
glEnableVertexArrayAttrib(vao_, a);
|
||||||
glVertexArrayAttribBinding(vao_, 1, 0);
|
glVertexArrayAttribFormat(vao_, a, 4, GL_FLOAT, GL_FALSE, offsets[a]);
|
||||||
|
glVertexArrayAttribBinding(vao_, a, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
count_ = GLsizei(instances.size());
|
count_ = GLsizei(instances.size());
|
||||||
if (instances.size() > capacity_) {
|
if (instances.size() > capacity_) {
|
||||||
|
|||||||
@@ -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); }
|
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
|
} // namespace hd2d
|
||||||
|
|||||||
175
engine/src/DayCycle.cpp
Normal file
175
engine/src/DayCycle.cpp
Normal 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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "hd2d/DebugUI.h"
|
#include "hd2d/DebugUI.h"
|
||||||
|
|
||||||
|
#include "hd2d/DayCycle.h"
|
||||||
#include "hd2d/Renderer.h"
|
#include "hd2d/Renderer.h"
|
||||||
#include "hd2d/Weather.h"
|
#include "hd2d/Weather.h"
|
||||||
#include "hd2d/Window.h"
|
#include "hd2d/Window.h"
|
||||||
@@ -138,24 +139,77 @@ void DebugUI::renderSettingsPanel(RenderSettings& s, Scene& scene, float gpuMs)
|
|||||||
ImGui::End();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
|
|
||||||
void DebugUI::weatherPanel(Weather& w) {
|
void DebugUI::weatherPanel(Weather& w, DayCycle* day, float* hour) {
|
||||||
ImGui::SetNextWindowPos(ImVec2(364, 12), ImGuiCond_FirstUseEver);
|
ImGui::SetNextWindowPos(ImVec2(364, 12), ImGuiCond_FirstUseEver);
|
||||||
ImGui::Begin("Weather", nullptr, ImGuiWindowFlags_AlwaysAutoResize);
|
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();
|
float target = w.targetRain();
|
||||||
if (ImGui::SliderFloat("Rain", &target, 0.0f, 1.0f)) w.setRain(target);
|
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);
|
ImGui::SliderFloat("Transition (s)", &w.transitionSeconds, 0.1f, 20.0f);
|
||||||
if (ImGui::TreeNode("Storm look")) {
|
ImGui::SliderFloat("Night preset brightness", &w.nightBrightness, 0.0f, 1.0f);
|
||||||
WeatherLook& l = w.storm;
|
auto presetUI = [](const char* label, WeatherLook& l) {
|
||||||
ImGui::SliderFloat("Sun intensity##st", &l.sunIntensity, 0.0f, 4.0f);
|
if (!ImGui::TreeNode(label)) return;
|
||||||
ImGui::ColorEdit3("Zenith##st", glm::value_ptr(l.zenith));
|
ImGui::SliderFloat("Sun intensity", &l.sunIntensity, 0.0f, 4.0f);
|
||||||
ImGui::ColorEdit3("Horizon##st", glm::value_ptr(l.horizon));
|
ImGui::ColorEdit3("Sun color", glm::value_ptr(l.sunColor));
|
||||||
ImGui::SliderFloat("Ambient##st", &l.ambient, 0.0f, 2.0f);
|
ImGui::ColorEdit3("Zenith", glm::value_ptr(l.zenith));
|
||||||
ImGui::ColorEdit3("Fog color##st", glm::value_ptr(l.fogColor));
|
ImGui::ColorEdit3("Horizon", glm::value_ptr(l.horizon));
|
||||||
ImGui::SliderFloat("Fog density##st", &l.fogDensity, 0.0f, 0.1f, "%.4f");
|
ImGui::SliderFloat("Ambient", &l.ambient, 0.0f, 2.0f);
|
||||||
ImGui::SliderFloat("Wind##st", &l.windStrength, 0.0f, 0.4f);
|
ImGui::ColorEdit3("Fog color", glm::value_ptr(l.fogColor));
|
||||||
ImGui::SliderFloat("Gust speed##st", &l.windGustSpeed, 0.0f, 6.0f);
|
ImGui::SliderFloat("Fog density", &l.fogDensity, 0.0f, 0.2f, "%.4f");
|
||||||
ImGui::SliderFloat("Splashes / s", &w.splashesPerSecond, 0.0f, 2000.0f);
|
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::TreePop();
|
||||||
}
|
}
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ void ParticleSystem::update(float dt, float time) {
|
|||||||
p.pos += wander * (0.35f * dt);
|
p.pos += wander * (0.35f * dt);
|
||||||
float blink = 0.5f + 0.5f * std::sin(t * 2.3f);
|
float blink = 0.5f + 0.5f * std::sin(t * 2.3f);
|
||||||
alpha *= (0.15f + 0.85f * blink * blink * blink) * ambientFade;
|
alpha *= (0.15f + 0.85f * blink * blink * blink) * ambientFade;
|
||||||
|
color *= fireflyGlow;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Kind::Mote: {
|
case Kind::Mote: {
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ struct FrameUBO {
|
|||||||
glm::mat4 view, proj, viewProj, invViewProj, lightViewProj;
|
glm::mat4 view, proj, viewProj, invViewProj, lightViewProj;
|
||||||
glm::vec4 camPos, camParams, screen, sunDir, sunColor, skyZenith, skyHorizon, skyGround, fogColor,
|
glm::vec4 camPos, camParams, screen, sunDir, sunColor, skyZenith, skyHorizon, skyGround, fogColor,
|
||||||
fogParams, fogParams2, wind, interactor, blob, clipPlane, billboardRight, shadowRight, cookie,
|
fogParams, fogParams2, wind, interactor, blob, clipPlane, billboardRight, shadowRight, cookie,
|
||||||
shadowParams, shaft, waterParams, groundAO, weather;
|
shadowParams, shaft, waterParams, groundAO, weather, skyParams;
|
||||||
glm::ivec4 lightCount;
|
glm::ivec4 lightCount;
|
||||||
glm::vec4 lightPos[kMaxLights];
|
glm::vec4 lightPos[kMaxLights];
|
||||||
glm::vec4 lightColor[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
|
} // namespace
|
||||||
|
|
||||||
@@ -174,9 +174,10 @@ struct Renderer::Impl {
|
|||||||
if (!b) continue;
|
if (!b) continue;
|
||||||
if (pass == 1 && !b->castShadows) continue;
|
if (pass == 1 && !b->castShadows) continue;
|
||||||
if (reflectionPass && !b->inReflections) 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);
|
billboard.set("uEmissive", b->emissive);
|
||||||
b->texture.bind(0);
|
tex.bind(0);
|
||||||
b->draw();
|
b->draw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +316,7 @@ void Renderer::render(const Scene& scene, const Camera& cam, float time) {
|
|||||||
: glm::vec4(-1e9f, 0, 0, 0);
|
: glm::vec4(-1e9f, 0, 0, 0);
|
||||||
f.groundAO = scene.groundOcclusion && S.groundOcclusion ? scene.groundOcclusion->rect() : glm::vec4(0.0f);
|
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.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);
|
glBindTextureUnit(9, scene.groundOcclusion ? scene.groundOcclusion->texture() : I.whiteTex);
|
||||||
int nl = std::min<int>(int(scene.lights.size()), kMaxLights);
|
int nl = std::min<int>(int(scene.lights.size()), kMaxLights);
|
||||||
f.lightCount = glm::ivec4(nl, std::clamp(scene.volumetricLights, 0, nl), 0, 0);
|
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);
|
glDisable(GL_BLEND);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 9. Tonemap + grade to the backbuffer --------------------------------
|
// ---- 9. Tonemap + grade to the output (backbuffer or caller FBO) --------
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
glBindFramebuffer(GL_FRAMEBUFFER, outputFbo_);
|
||||||
glViewport(0, 0, w, h);
|
glViewport(0, 0, w, h);
|
||||||
Shader& tm = I.tonemap;
|
Shader& tm = I.tonemap;
|
||||||
tm.use();
|
tm.use();
|
||||||
|
|||||||
@@ -11,13 +11,16 @@
|
|||||||
|
|
||||||
namespace hd2d {
|
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);
|
std::vector<unsigned char> px(size_t(width) * height * 3);
|
||||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
|
GLint prevRead = 0;
|
||||||
glReadBuffer(GL_BACK);
|
glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevRead);
|
||||||
|
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
|
||||||
|
glReadBuffer(fbo ? GL_COLOR_ATTACHMENT0 : GL_BACK);
|
||||||
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
||||||
glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, px.data());
|
glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, px.data());
|
||||||
glPixelStorei(GL_PACK_ALIGNMENT, 4);
|
glPixelStorei(GL_PACK_ALIGNMENT, 4);
|
||||||
|
glBindFramebuffer(GL_READ_FRAMEBUFFER, GLuint(prevRead));
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
auto parent = std::filesystem::path(path).parent_path();
|
auto parent = std::filesystem::path(path).parent_path();
|
||||||
if (!parent.empty()) std::filesystem::create_directories(parent, ec);
|
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;
|
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
|
} // namespace hd2d
|
||||||
|
|||||||
104
engine/src/SpriteAtlas.cpp
Normal file
104
engine/src/SpriteAtlas.cpp
Normal 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
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
namespace hd2d {
|
namespace hd2d {
|
||||||
|
|
||||||
static void bleedTransparentColors(Image& img) {
|
void bleedTransparentColors(Image& img) {
|
||||||
const int w = img.width, h = img.height;
|
const int w = img.width, h = img.height;
|
||||||
std::vector<unsigned char> known(size_t(w) * h);
|
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;
|
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_);
|
std::swap(id_, o.id_);
|
||||||
width_ = o.width_;
|
width_ = o.width_;
|
||||||
height_ = o.height_;
|
height_ = o.height_;
|
||||||
|
levels_ = o.levels_;
|
||||||
opaqueBounds_ = o.opaqueBounds_;
|
opaqueBounds_ = o.opaqueBounds_;
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
@@ -79,12 +80,27 @@ Texture::~Texture() {
|
|||||||
if (id_) glDeleteTextures(1, &id_);
|
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;
|
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;
|
width_ = img.width;
|
||||||
height_ = img.height;
|
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_,
|
opaqueBounds_ = {float(minX) / width_, float(minY) / height_, float(maxX + 1) / width_,
|
||||||
float(maxY + 1) / height_};
|
float(maxY + 1) / height_};
|
||||||
|
|
||||||
if (id_) glDeleteTextures(1, &id_);
|
levels_ = nearest ? 1 : mipLevels(width_, height_);
|
||||||
glCreateTextures(GL_TEXTURE_2D, 1, &id_);
|
id_ = allocate(id_, width_, height_, levels_, repeat, nearest, anisotropy);
|
||||||
glTextureStorage2D(id_, mipLevels(width_, height_), GL_SRGB8_ALPHA8, width_, height_);
|
|
||||||
glTextureSubImage2D(id_, 0, 0, 0, width_, height_, GL_RGBA, GL_UNSIGNED_BYTE, img.rgba.data());
|
glTextureSubImage2D(id_, 0, 0, 0, width_, height_, GL_RGBA, GL_UNSIGNED_BYTE, img.rgba.data());
|
||||||
glGenerateTextureMipmap(id_);
|
if (levels_ > 1) glGenerateTextureMipmap(id_);
|
||||||
glTextureParameteri(id_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
|
|
||||||
glTextureParameteri(id_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
||||||
GLint wrap = repeat ? GL_REPEAT : GL_CLAMP_TO_EDGE;
|
|
||||||
glTextureParameteri(id_, GL_TEXTURE_WRAP_S, wrap);
|
|
||||||
glTextureParameteri(id_, GL_TEXTURE_WRAP_T, wrap);
|
|
||||||
if (anisotropy > 1.0f) glTextureParameterf(id_, GL_TEXTURE_MAX_ANISOTROPY, anisotropy);
|
|
||||||
return true;
|
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() {
|
TextureArray::~TextureArray() {
|
||||||
if (id_) glDeleteTextures(1, &id_);
|
if (id_) glDeleteTextures(1, &id_);
|
||||||
}
|
}
|
||||||
|
|||||||
746
engine/src/UI.cpp
Normal file
746
engine/src/UI.cpp
Normal 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
|
||||||
@@ -16,103 +16,259 @@ Weather::Weather() {
|
|||||||
storm.horizon = {0.42f, 0.45f, 0.50f};
|
storm.horizon = {0.42f, 0.45f, 0.50f};
|
||||||
storm.ground = {0.13f, 0.15f, 0.13f};
|
storm.ground = {0.13f, 0.15f, 0.13f};
|
||||||
storm.ambient = 0.62f;
|
storm.ambient = 0.62f;
|
||||||
|
storm.clouds = 1.0f;
|
||||||
storm.fogColor = {0.30f, 0.33f, 0.38f};
|
storm.fogColor = {0.30f, 0.33f, 0.38f};
|
||||||
storm.fogDensity = 0.03f;
|
storm.fogDensity = 0.03f;
|
||||||
storm.fogHeightFalloff = 0.22f;
|
storm.fogHeightFalloff = 0.22f;
|
||||||
storm.godRays = 0.6f;
|
storm.godRays = 0.6f;
|
||||||
storm.shaftStrength = 0.2f;
|
storm.shaftStrength = 0.2f;
|
||||||
storm.cookieStrength = 0.0f;
|
storm.cookieStrength = 0.0f;
|
||||||
|
storm.shadowSoftness = 3.0f;
|
||||||
storm.windStrength = 0.2f;
|
storm.windStrength = 0.2f;
|
||||||
storm.windGustSpeed = 3.0f;
|
storm.windGustSpeed = 3.0f;
|
||||||
storm.bloomStrength = 0.06f;
|
storm.bloomStrength = 0.06f;
|
||||||
storm.saturation = 0.92f;
|
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 mixLooks(const WeatherLook& a, const WeatherLook& b, float t) {
|
||||||
WeatherLook l;
|
|
||||||
l.sunIntensity = s.sun.intensity;
|
|
||||||
l.sunColor = s.sun.color;
|
|
||||||
l.zenith = s.sky.zenith;
|
|
||||||
l.horizon = s.sky.horizon;
|
|
||||||
l.ground = s.sky.ground;
|
|
||||||
l.ambient = s.sky.ambient;
|
|
||||||
l.fogColor = r.fogColor;
|
|
||||||
l.fogDensity = r.fogDensity;
|
|
||||||
l.fogHeightFalloff = r.fogHeightFalloff;
|
|
||||||
l.godRays = r.godRayIntensity;
|
|
||||||
l.shaftStrength = r.shaftStrength;
|
|
||||||
l.cookieStrength = r.cookieStrength;
|
|
||||||
l.windStrength = r.windStrength;
|
|
||||||
l.windGustSpeed = r.windGustSpeed;
|
|
||||||
l.bloomStrength = r.bloomStrength;
|
|
||||||
l.saturation = r.saturation;
|
|
||||||
return l;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Weather::apply(const WeatherLook& l, Scene& s, RenderSettings& r) {
|
|
||||||
s.sun.intensity = l.sunIntensity;
|
|
||||||
s.sun.color = l.sunColor;
|
|
||||||
s.sky.zenith = l.zenith;
|
|
||||||
s.sky.horizon = l.horizon;
|
|
||||||
s.sky.ground = l.ground;
|
|
||||||
s.sky.ambient = l.ambient;
|
|
||||||
r.fogColor = l.fogColor;
|
|
||||||
r.fogDensity = l.fogDensity;
|
|
||||||
r.fogHeightFalloff = l.fogHeightFalloff;
|
|
||||||
r.godRayIntensity = l.godRays;
|
|
||||||
r.shaftStrength = l.shaftStrength;
|
|
||||||
r.cookieStrength = l.cookieStrength;
|
|
||||||
r.windStrength = l.windStrength;
|
|
||||||
r.windGustSpeed = l.windGustSpeed;
|
|
||||||
r.bloomStrength = l.bloomStrength;
|
|
||||||
r.saturation = l.saturation;
|
|
||||||
}
|
|
||||||
|
|
||||||
static WeatherLook mixLook(const WeatherLook& a, const WeatherLook& b, float t) {
|
|
||||||
WeatherLook o;
|
WeatherLook o;
|
||||||
auto m = [t](auto x, auto y) { return x + (y - x) * t; };
|
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.sunIntensity = m(a.sunIntensity, b.sunIntensity);
|
||||||
o.sunColor = m(a.sunColor, b.sunColor);
|
o.sunColor = m(a.sunColor, b.sunColor);
|
||||||
o.zenith = m(a.zenith, b.zenith);
|
o.zenith = m(a.zenith, b.zenith);
|
||||||
o.horizon = m(a.horizon, b.horizon);
|
o.horizon = m(a.horizon, b.horizon);
|
||||||
o.ground = m(a.ground, b.ground);
|
o.ground = m(a.ground, b.ground);
|
||||||
o.ambient = m(a.ambient, b.ambient);
|
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.fogColor = m(a.fogColor, b.fogColor);
|
||||||
o.fogDensity = m(a.fogDensity, b.fogDensity);
|
o.fogDensity = m(a.fogDensity, b.fogDensity);
|
||||||
o.fogHeightFalloff = m(a.fogHeightFalloff, b.fogHeightFalloff);
|
o.fogHeightFalloff = m(a.fogHeightFalloff, b.fogHeightFalloff);
|
||||||
o.godRays = m(a.godRays, b.godRays);
|
o.godRays = m(a.godRays, b.godRays);
|
||||||
o.shaftStrength = m(a.shaftStrength, b.shaftStrength);
|
o.shaftStrength = m(a.shaftStrength, b.shaftStrength);
|
||||||
o.cookieStrength = m(a.cookieStrength, b.cookieStrength);
|
o.cookieStrength = m(a.cookieStrength, b.cookieStrength);
|
||||||
|
o.shadowSoftness = m(a.shadowSoftness, b.shadowSoftness);
|
||||||
o.windStrength = m(a.windStrength, b.windStrength);
|
o.windStrength = m(a.windStrength, b.windStrength);
|
||||||
o.windGustSpeed = m(a.windGustSpeed, b.windGustSpeed);
|
o.windGustSpeed = m(a.windGustSpeed, b.windGustSpeed);
|
||||||
o.bloomStrength = m(a.bloomStrength, b.bloomStrength);
|
o.bloomStrength = m(a.bloomStrength, b.bloomStrength);
|
||||||
o.saturation = m(a.saturation, b.saturation);
|
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;
|
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) {
|
void Weather::update(float dt, Scene& scene, RenderSettings& settings) {
|
||||||
// While fully clear, keep tracking the live look so UI tweaks survive.
|
// Without an external clear look, keep tracking the live look while fully
|
||||||
if (rain_ <= 0.0f && targetRain_ <= 0.0f) {
|
// clear so UI tweaks survive.
|
||||||
clear_ = capture(scene, settings);
|
if (!externalClear_ && (clear() || !haveClear_)) {
|
||||||
haveClear_ = true;
|
|
||||||
} else if (!haveClear_) {
|
|
||||||
clear_ = capture(scene, settings);
|
clear_ = capture(scene, settings);
|
||||||
haveClear_ = true;
|
haveClear_ = true;
|
||||||
}
|
}
|
||||||
float step = dt / std::max(transitionSeconds, 1e-3f);
|
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.
|
// Surfaces soak quickly and dry slowly.
|
||||||
float wetRate = wetness_ < rain_ ? step * 1.5f : step * 0.3f;
|
wetness_ = approach(wetness_, rain_, wetness_ < rain_ ? step * 1.5f : step * 0.3f);
|
||||||
wetness_ = wetness_ < rain_ ? std::min(rain_, wetness_ + wetRate) : std::max(rain_, wetness_ - wetRate);
|
|
||||||
|
|
||||||
float k = rain_ * rain_ * (3.0f - 2.0f * rain_); // smoothstep
|
// Lightning: random double flashes while it pours.
|
||||||
if (rain_ > 0.0f || targetRain_ > 0.0f) apply(mixLook(clear_, storm, k), scene, settings);
|
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.rain = rain_;
|
||||||
scene.wetness = wetness_;
|
scene.wetness = wetness_;
|
||||||
|
|
||||||
if (scene.particles) {
|
if (scene.particles) {
|
||||||
ParticleSystem& ps = *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 wd = glm::length(settings.windDirection) > 1e-4f ? glm::normalize(settings.windDirection)
|
||||||
: glm::vec2(1, 0);
|
: glm::vec2(1, 0);
|
||||||
ps.wind = glm::vec3(wd.x, 0.0f, wd.y) * settings.windStrength * 10.0f;
|
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).
|
// Ground splashes around the focus point (water gets ripples instead).
|
||||||
splashAccum_ += splashesPerSecond * rain_ * dt;
|
splashAccum_ += splashesPerSecond * rain_ * dt;
|
||||||
std::uniform_real_distribution<float> u(-1.0f, 1.0f);
|
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) {
|
while (splashAccum_ >= 1.0f) {
|
||||||
splashAccum_ -= 1.0f;
|
splashAccum_ -= 1.0f;
|
||||||
glm::vec2 p = glm::vec2(scene.focusPoint.x, scene.focusPoint.z) + glm::vec2(u(rng_), u(rng_)) * splashRadius;
|
glm::vec2 p = glm::vec2(scene.focusPoint.x, scene.focusPoint.z) + glm::vec2(u(rng_), u(rng_)) * splashRadius;
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ static void APIENTRY glDebugCallback(GLenum, GLenum type, GLuint, GLenum severit
|
|||||||
HD2D_ERR("GL: %s", message);
|
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); });
|
glfwSetErrorCallback([](int code, const char* msg) { HD2D_ERR("GLFW %d: %s", code, msg); });
|
||||||
if (!glfwInit()) return false;
|
if (!glfwInit()) return false;
|
||||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
|
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_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
|
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
|
||||||
glfwWindowHint(GLFW_RESIZABLE, resizable ? GLFW_TRUE : GLFW_FALSE);
|
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
|
#ifndef NDEBUG
|
||||||
glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GLFW_TRUE);
|
glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GLFW_TRUE);
|
||||||
#endif
|
#endif
|
||||||
@@ -34,7 +38,7 @@ bool Window::create(int width, int height, const std::string& title, bool vsync,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
glfwMakeContextCurrent(window_);
|
glfwMakeContextCurrent(window_);
|
||||||
glfwSwapInterval(vsync ? 1 : 0);
|
glfwSwapInterval(vsync && visible ? 1 : 0);
|
||||||
glfwSetWindowUserPointer(window_, this);
|
glfwSetWindowUserPointer(window_, this);
|
||||||
glfwSetFramebufferSizeCallback(window_, onFramebufferSize);
|
glfwSetFramebufferSizeCallback(window_, onFramebufferSize);
|
||||||
glfwSetScrollCallback(window_, onScroll);
|
glfwSetScrollCallback(window_, onScroll);
|
||||||
@@ -75,11 +79,13 @@ bool Window::pollEvents() {
|
|||||||
return !glfwWindowShouldClose(window_);
|
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::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::setTitle(const std::string& title) { glfwSetWindowTitle(window_, title.c_str()); }
|
||||||
void Window::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); }
|
void Window::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user