diff --git a/.gitignore b/.gitignore index 14240d5..acc17fe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Build output build/ build-*/ +build_*/ cmake-build-*/ out/ compile_commands.json diff --git a/AGENTS.md b/AGENTS.md index 90cc0b5..27d1849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,20 +8,26 @@ before making any sprite, texture or model**. ```sh cmake -S . -B build -G Ninja && ninja -C build -./build/hd2d_demo # interactive (WASD, R = rain, F1 = panels) +./build/hd2d_demo # interactive (WASD, R/O/G = rain/overcast/fog, [ ] = hour, F1 = panels) ./build/hd2d_demo --screenshot out.png --frames 90 \ - --pos 25,43.6 --dist 10 --yaw 30 --rain 1 --set dof=0 # scripted capture + --pos 25,43.6 --dist 10 --yaw 30 --rain 1 --set dof=0 # scripted capture (hidden) ``` There is no test suite; verification is visual. After any rendering, asset or gameplay change, capture screenshots of the affected area and look at them. Use a Debug build (`-DCMAKE_BUILD_TYPE=Debug`) to get OpenGL errors printed. +Captures run hidden: `--screenshot` implies a hidden window + offscreen target +(`Window::create(..., visible = false)`, `OffscreenTarget`, +`Renderer::setOutputFramebuffer`) and ignores keyboard/mouse, so they never pop +up on or react to the user's desktop. Add `--show` only if you really need to +watch a capture. New capture paths in any app must keep this behaviour. + ## Map | Path | What | | --- | --- | -| `engine/include/hd2d/`, `engine/src/` | engine library (`Renderer`, `Terrain`, `BillboardBatch`, `ModelLibrary`/`ModelBatch`, `Water`, `ParticleSystem`, `Weather`, `GroundOcclusion`, `DebugUI`) | +| `engine/include/hd2d/`, `engine/src/` | engine library (`Renderer`, `Terrain`, `BillboardBatch`, `SpriteAtlas`, `ModelLibrary`/`ModelBatch`, `Water`, `ParticleSystem`, `Weather`, `DayCycle`, `GroundOcclusion`, `DebugUI`) | | `assets/shaders/` | GLSL; `common.glsl` holds the shared `Frame` UBO and lighting helpers | | `demo/` | meadow scene: terrain layout, prop scattering, bridge, fires, player movement | | `tools/pixellab.py` + `tools/assets.json` | pixel-art generation (PixelLab API, key in `.env`) | @@ -33,10 +39,17 @@ Use a Debug build (`-DCMAKE_BUILD_TYPE=Debug`) to get OpenGL errors printed. - `FrameUBO` in `engine/src/Renderer.cpp` must mirror `Frame` in `assets/shaders/common.glsl` exactly (std140, vec4/mat4 only); update the `static_assert` size when adding fields. +- `BillboardInstance` (`Billboard.h`, 64 bytes = four vec4s) must match the + attributes in `billboard.vert` and the VAO in `Billboard.cpp`; keep new + fields in vec4-sized groups, update the `static_assert`, and keep the + defaults rendering exactly like the old whole-texture sprite. - `pixelart.glsl` uses derivatives: include it only in fragment shaders. +- Game UI is `hd2d::ui` (`UI.h`): ImDrawList only, sizes in 1080p px × `scaleFor(viewport)`, OFL fonts in `assets/fonts/`; verify with `--ui-demo` captures (`--frames 120` mid-reveal, `420` revealed). - Use GL 4.5 DSA calls (`glCreate*`, `glNamed*`, `glTexture*`) like the rest of the code. Shaders hot-reload with F5. -- Shaders and assets load from `HD2D_ASSET_DIR` (set by CMake to `assets/`). +- Shaders and assets load from `HD2D_ASSET_DIR` (demo, set by CMake to + `assets/`); the `hd2d` target exports `HD2D_ENGINE_ASSET_DIR` for parent + projects that `add_subdirectory` the engine (demo off by default there). - Generated files (`assets/textures/*`, `assets/models/*`) come from scripts in `tools/`; change the script, not the output. - PixelLab calls cost credits: check `--balance`, reuse `tile_id`s, and do not diff --git a/CMakeLists.txt b/CMakeLists.txt index b993305..59bdd86 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,21 @@ -cmake_minimum_required(VERSION 3.20) +cmake_minimum_required(VERSION 3.21) # PROJECT_IS_TOP_LEVEL project(hd2d LANGUAGES C CXX) +# Usable standalone or from a parent project via +# `add_subdirectory(path/to/Vibe-HD-2D hd2d)`; link `hd2d` and load engine +# shaders from `HD2D_ENGINE_ASSET_DIR` (compile definition and cache variable). +# The engine provides the `imgui` target (Dear ImGui + GLFW/OpenGL3 +# backends) unless the parent already defines one. +option(HD2D_BUILD_DEMO "Build the demo" ${PROJECT_IS_TOP_LEVEL}) +set(HD2D_ENGINE_ASSET_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets" CACHE INTERNAL "Engine shaders and assets") + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE) +if(PROJECT_IS_TOP_LEVEL) + set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE) + endif() endif() include(FetchContent) @@ -14,21 +24,27 @@ include(FetchContent) # System packages are preferred; anything missing is fetched. find_package(OpenGL REQUIRED) find_package(PkgConfig REQUIRED) -pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy) - -find_package(glfw3 3.3 QUIET) -if(NOT glfw3_FOUND) - set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE) - set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE) - set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) - FetchContent_Declare(glfw GIT_REPOSITORY https://github.com/glfw/glfw.git GIT_TAG 3.4) - FetchContent_MakeAvailable(glfw) +if(NOT TARGET PkgConfig::EPOXY) + pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy) endif() -find_package(glm QUIET) -if(NOT glm_FOUND) - FetchContent_Declare(glm GIT_REPOSITORY https://github.com/g-truc/glm.git GIT_TAG 1.0.1) - FetchContent_MakeAvailable(glm) +if(NOT TARGET glfw) + find_package(glfw3 3.3 QUIET) + if(NOT glfw3_FOUND) + set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE) + set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + FetchContent_Declare(glfw GIT_REPOSITORY https://github.com/glfw/glfw.git GIT_TAG 3.4) + FetchContent_MakeAvailable(glfw) + endif() +endif() + +if(NOT TARGET glm::glm) + find_package(glm QUIET) + if(NOT glm_FOUND) + FetchContent_Declare(glm GIT_REPOSITORY https://github.com/g-truc/glm.git GIT_TAG 1.0.1) + FetchContent_MakeAvailable(glm) + endif() endif() find_path(STB_INCLUDE_DIR stb_image.h PATH_SUFFIXES stb) @@ -45,17 +61,19 @@ if(NOT CGLTF_INCLUDE_DIR) set(CGLTF_INCLUDE_DIR ${cgltf_SOURCE_DIR}) endif() -FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git GIT_TAG v1.91.9b) -FetchContent_MakeAvailable(imgui) -add_library(imgui STATIC - ${imgui_SOURCE_DIR}/imgui.cpp - ${imgui_SOURCE_DIR}/imgui_draw.cpp - ${imgui_SOURCE_DIR}/imgui_tables.cpp - ${imgui_SOURCE_DIR}/imgui_widgets.cpp - ${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp - ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp) -target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends) -target_link_libraries(imgui PUBLIC glfw OpenGL::GL) +if(NOT TARGET imgui) # a parent project may provide its own + FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git GIT_TAG v1.91.9b) + FetchContent_MakeAvailable(imgui) + add_library(imgui STATIC + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp) + target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends) + target_link_libraries(imgui PUBLIC glfw OpenGL::GL) +endif() # --- Engine ----------------------------------------------------------------- file(GLOB_RECURSE HD2D_ENGINE_SOURCES CONFIGURE_DEPENDS engine/src/*.cpp) @@ -63,7 +81,9 @@ add_library(hd2d STATIC ${HD2D_ENGINE_SOURCES}) target_include_directories(hd2d PUBLIC engine/include) target_include_directories(hd2d SYSTEM PRIVATE ${STB_INCLUDE_DIR} ${CGLTF_INCLUDE_DIR}) # silence third-party warnings target_link_libraries(hd2d PUBLIC glfw glm::glm PkgConfig::EPOXY imgui OpenGL::GL) -target_compile_definitions(hd2d PUBLIC GLM_FORCE_RADIANS GLM_ENABLE_EXPERIMENTAL) +target_compile_features(hd2d PUBLIC cxx_std_20) +target_compile_definitions(hd2d PUBLIC GLM_FORCE_RADIANS GLM_ENABLE_EXPERIMENTAL + HD2D_ENGINE_ASSET_DIR="${HD2D_ENGINE_ASSET_DIR}") if(MSVC) target_compile_options(hd2d PRIVATE /W4) else() @@ -71,7 +91,9 @@ else() endif() # --- Demo ------------------------------------------------------------------- -add_executable(hd2d_demo demo/main.cpp demo/DemoScene.cpp) -target_link_libraries(hd2d_demo PRIVATE hd2d) -# Assets/shaders are loaded relative to this directory at runtime. -target_compile_definitions(hd2d_demo PRIVATE HD2D_ASSET_DIR="${CMAKE_SOURCE_DIR}/assets") +if(HD2D_BUILD_DEMO) + add_executable(hd2d_demo demo/main.cpp demo/DemoScene.cpp) + target_link_libraries(hd2d_demo PRIVATE hd2d) + # Assets/shaders are loaded relative to this directory at runtime. + target_compile_definitions(hd2d_demo PRIVATE HD2D_ASSET_DIR="${HD2D_ENGINE_ASSET_DIR}") +endif() diff --git a/README.md b/README.md index 13827ce..c183dc5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ high-resolution 3D effects. ![demo](docs/screenshot.png) ![heavy rain](docs/rain.png) +![game UI](docs/ui.png) ## What makes HD-2D look like HD-2D @@ -18,7 +19,7 @@ high-resolution 3D effects. | --- | --- | | 3D world built from pixel-art textures | Tile-based terraced terrain (`Terrain`) with pixel-art material layers, organically jittered material borders at texel granularity, grass overhangs on cliffs, baked corner AO | | Crisp pixels at any distance | "Sharp bilinear" sampling (`pixelart.glsl`): hard texel edges when magnified, antialiased + mipmapped when minified (no `GL_NEAREST` shimmer) | -| Upright 2D sprites in 3D | Characters and small props are camera-facing (Y-axis) billboards (`BillboardBatch`), planted by their lowest opaque pixel row | +| Upright 2D sprites in 3D | Characters and small props are camera-facing (Y-axis) billboards (`BillboardBatch`), planted by their lowest opaque pixel row (or a per-frame feet row); per instance: sprite-sheet / atlas sub-rect (`SpriteAtlas`, `Texture::cellUV`), flip, paper-turn yaw, dithered alpha fade and hit flash | | 3D scenery with painted textures | Trees, bushes, rocks and the stone bridge are instanced glTF models (`ModelLibrary`, `ModelBatch`) built in Blender: leaf-card canopies with alpha-tested 1K leaf textures, rounded custom normals for soft stylized shading, sun translucency, wind sway from per-vertex weights, emissive materials (glowing coals) | | Grounded objects | Two kinds of ambient occlusion: half-res SSAO (depth-reconstructed normals, depth-aware blur, multiplied into the opaque scene before water) and a baked **ground contact occlusion** map (`GroundOcclusion`) that puts a dark core at every trunk / bush / rock base plus a soft canopy shade, applied to terrain, sprites and models near the ground | | Nothing hides the player | Tall scenery (tree canopies) between the camera and the player is dithered away around the sprite, only above the sprite's head; low props opt out per batch (`ModelBatch::occluderFade = false`, used for the bridge, rocks and bushes) | @@ -31,7 +32,9 @@ high-resolution 3D effects. | Living foliage | Instanced swaying grass with wind gusts; tall grass and bushes bend and rustle away from the player (vertex shader, Pokémon-style patches); leaf flutter scales with wind strength | | High-res particles | Fireflies (also feed point lights) and dust motes that only glow inside sun shafts | | Fire | Brazier fires (`ParticleSystem::addFire`): a compact, bowl-filling mass of nearly opaque, noise-eroded flame licks shaded per pixel from a hot yellow core through orange to red tips (drawn oldest-first so the core sits on top), turbulent embers, lit smoke puffs, a glow halo, and a flickering point light that also scatters into the fog | -| Weather | `Weather` blends the clear look into a storm preset (weak grey sun, overcast sky, thick low fog, gusty wind): procedural world-space rain streaks (up to 18k, one draw call), ground splashes, raindrop ripple rings on the water, wet darker glossy surfaces, ambient fireflies fade out | +| Weather | `Weather` blends the clear look toward overcast (grey sky, weak sun, soft shadows), mist (thick low fog) and storm presets, each with its own 0..1 target and smooth transition: procedural world-space rain streaks (up to 18k, one draw call), ground splashes, raindrop ripple rings on the water, wet darker glossy surfaces, optional lightning flashes, ambient fireflies fade out | +| Time of day | `DayCycle` turns an hour into a complete look from tweakable keyframes (night, dawn, morning, noon, afternoon, dusk): sun arc by day, a dim cool moon as the shadow-casting light at night, deep blue nights with a twinkling star field and moon disc (also in water reflections), orange / purple dusks, brighter fireflies after dark; weather presets darken with it | +| Game UI | `hd2d::ui` (Octopath / Triangle Strategy look): translucent navy panels with a gold double border and corner gems, high-res serif text (Cinzel, EB Garamond) with a soft shadow, a typewriter dialogue box with gold name plate, portrait and choices, menus, name plates, toasts, progress bars and a matching Dear ImGui theme | References: Unreal Engine's [Octopath Traveler spotlight](https://www.unrealengine.com/spotlights/octopath-traveler-s-hd-2d-art-style-and-story-make-for-a-jrpg-dream-come-true), the [Octopath Traveler II interview](https://www.unrealengine.com/developer-interviews/octopath-traveler-ii-builds-a-bigger-bolder-world-in-its-stunning-hd-2d-style), @@ -40,9 +43,9 @@ and [The Verge on HD-2D's evolution](https://www.theverge.com/entertainment/8400 ## Building -Requirements: CMake ≥ 3.20, a C++20 compiler, OpenGL 4.5, `libepoxy`. +Requirements: CMake ≥ 3.21, a C++20 compiler, OpenGL 4.5, `libepoxy`. GLFW, GLM, stb and cgltf are used from the system when found and fetched -otherwise; Dear ImGui is always fetched. +otherwise; Dear ImGui is fetched unless a parent project provides `imgui`. ```sh # Arch / CachyOS @@ -56,6 +59,19 @@ ninja -C build The asset path is baked in at configure time (`HD2D_ASSET_DIR`), so the demo can be run from any working directory. +To use the engine from another CMake project: + +```cmake +add_subdirectory(path/to/Vibe-HD-2D hd2d) # HD2D_BUILD_DEMO defaults OFF here +target_link_libraries(my_game PRIVATE hd2d) +# Engine shaders: renderer.init(std::string(HD2D_ENGINE_ASSET_DIR) + "/shaders", ...) +``` + +`hd2d` publicly defines `HD2D_ENGINE_ASSET_DIR` (also a CMake cache variable) +pointing at the engine's `assets/`. The engine provides the `imgui` target +(Dear ImGui + GLFW/OpenGL3 backends) unless the parent defines one first; +`glfw` and `glm::glm` targets from the parent are reused too. + ## Demo controls | Input | Action | @@ -65,8 +81,12 @@ be run from any working directory. | Mouse wheel | Zoom | | F1 | Toggle the tuning panels (sun, sky, AO, fog, god rays, water, DoF, bloom, grading, weather) | | R | Toggle heavy rain (5 s transition) | +| O / G | Toggle overcast / morning mist | +| L | Toggle lightning (flashes while raining) | +| [ / ] | Time of day −/+ 1 hour (switches the demo to `DayCycle`) | | F5 | Hot-reload shaders | | F12 | Screenshot to `screenshots/` | +| T | Toggle the game UI showcase (Enter / Space advance the dialogue, Up / Down pick a choice) | | Esc | Quit | Scriptable captures (useful for look-dev and regression checks): @@ -76,7 +96,31 @@ Scriptable captures (useful for look-dev and regression checks): --sun -35,38 --yaw 20 --dist 12 --move 1,0 --rain 1 --set godRays=6 --set dof=0 ``` -`--rain 0..1` starts the capture already raining. `--set` accepts: +Captures run hidden: `--screenshot` implies a hidden window (no toplevel is +ever created, so nothing pops up or takes focus) rendering into an offscreen +target, and all keyboard / mouse input is ignored, so captures can run while +you work on the same desktop. `--show` opts back into a visible capture +window (input still ignored). Hidden and visible captures match to within +1/255 on a handful of pixels (driver rounding of the window surface). + +Any app can capture headlessly the same way: + +```cpp +window.create(w, h, "capture", false, false, /*visible*/ false); // swapBuffers no-op, input reports nothing +hd2d::OffscreenTarget out; out.create(window.width(), window.height()); // RGBA8 + depth/stencil +renderer.setOutputFramebuffer(out.fbo()); // tonemap writes here and leaves it bound for ImGui / ui::* +renderer.render(scene, cam, time); /* ... UI ... */ +out.save("shot.png"); // = hd2d::saveScreenshot(path, w, h, out.fbo()) +``` + +`--rain 0..1`, `--overcast 0..1` and `--fog 0..1` start the capture already +at that weather (`--lightning` enables flashes); `--hour H` drives the look +from `DayCycle` (without it the scene keeps its hand-tuned look); `--pitch deg` +sets the camera tilt (low values show the sky). `--sprite-test` adds a +labelled row of atlas / sprite-sheet billboards (sub-rect frames, flip, turn, +alpha dither, flash) in front of the player. `--ui-demo` opens the game UI +showcase (same as T): `--frames 120` captures the dialogue mid-reveal, +`--frames 420` fully revealed with its choices. `--set` accepts: `volumetrics dof bloom reflections occluderFade ssao groundAO ssaoRadius ssaoIntensity fogDensity fogHeightFalloff godRays shaftStrength shaftCoverage shaftScale cookieStrength exposure bloomStrength tiltShift focusRange`. @@ -88,6 +132,7 @@ engine/include/hd2d/ public headers engine/src/ engine implementation assets/shaders/ GLSL (common.glsl = shared UBO + lighting helpers) assets/textures/ PixelLab-generated pixel art (terrain tiles, grass, flowers) +assets/fonts/ OFL fonts for hd2d::ui (Cinzel, Cinzel Decorative, EB Garamond) + licenses assets/sprites/ character sprites assets/models/ scenery.glb (trees, bushes, rocks, bridge) + src/ painted textures demo/ meadow demo scene + main loop @@ -129,6 +174,16 @@ hd2d::BillboardBatch hero; hero.loadTexture("assets/sprites/hero.png"); hero.instances.push_back({{10, 0, 10}, 0.0f, hero.sizeForDensity(32.0f)}); hero.upload(); +// Sprite sheets / runtime atlases: one sub-rect (`uv`) + feet row (`anchor`) +// per instance; `flip`, `turn`, `alpha` (dithered) and `flash` per instance too. +hd2d::SpriteAtlas atlas; atlas.init({64, 64}, 8, 8); +hd2d::BillboardBatch critters; critters.sharedTexture = &atlas.texture(); +int slot = atlas.add(image); // hd2d::Image, e.g. from hd2d::loadImage +hd2d::BillboardInstance c; +c.pos = {12, 0, 9}; c.size = hd2d::BillboardBatch::cellSize({64, 64}, 32.0f); +c.uv = atlas.uv(slot); c.anchor = atlas.anchor(slot); +critters.instances.push_back(c); critters.upload(); + hd2d::ModelLibrary models; models.load("assets/models/scenery.glb"); hd2d::ModelBatch oaks(models.get("oak_a")); oaks.instances.push_back({{14, 0, 12}, /*yaw*/ 0.7f, /*scale*/ glm::vec3(1.0f), /*tint*/ 1.0f}); @@ -136,7 +191,7 @@ oaks.upload(); hd2d::Scene scene; scene.terrain = &terrain; -scene.billboards = {&hero}; +scene.billboards = {&hero, &critters}; scene.models = {&oaks}; hd2d::GroundOcclusion contactAO; // dark contact shadow at tree bases @@ -168,8 +223,98 @@ while (window.pollEvents()) { scattering, bridge walking, fires from model sockets, contact AO, water + shore field, particles, weather, player movement and collision. +### Time of day and weather (`hd2d/DayCycle.h`, `hd2d/Weather.h`) + +Both are meant to be driven externally, e.g. by a game server that sends the +in-game `hour` and the current weather. `DayCycle::look(hour)` returns a +`WeatherLook` (sun / moon direction, intensity and color, sky colors, ambient, +fog, god rays, bloom, exposure / saturation / lift / gain, star + moon +visibility, firefly glow); `Weather` blends it toward its presets and applies +the result to the `Scene` and `RenderSettings`. + +```cpp +hd2d::DayCycle day; // default keyframes; tweak day.keys[i].look, sunrise, sunset, moon arc +hd2d::Weather weather; + +void onServerState(float hour, const std::string& kind) { // e.g. "clear", "overcast", "fog", "rain", "storm" + serverHour = hour; // [0, 24); interpolate between updates if you like + weather.setRain(kind == "rain" || kind == "storm" ? 1.0f : 0.0f); + weather.setOvercast(kind == "overcast" ? 1.0f : 0.0f); + weather.setFog(kind == "fog" ? 1.0f : 0.0f); + weather.setLightning(kind == "storm"); +} +// once after joining: weather.snap(); (skip the transition) + +// every frame, before renderer.render(): +weather.setClearLook(day.look(serverHour)); +weather.update(dt, scene, renderer.settings); +``` + +| API | Notes | +| --- | --- | +| `DayCycle::look(hour)` | Smooth (smoothstep) interpolation between `keys` (wraps at midnight); the light follows a sun arc between `sunrise` and `sunset` and a moon arc otherwise, fading out near the horizon so the sun → moon switch is invisible | +| `DayCycle::daylight(hour)` / `night(hour)` | 0..1 helpers: sun height (0 at night) and star / moon visibility, e.g. for gameplay (spawn tables, NPC schedules) | +| `Weather::setClearLook(look)` | Replaces the clear look every frame; without it `Weather` captures the scene's own look while clear (manual tuning survives) | +| `setRain` / `setOvercast` / `setFog` (0..1) | Targets reached over `transitionSeconds`; `snap()` jumps to all of them | +| `setLightning(bool)` | Random double flashes (sky, sun, fog) while rain > 0.5, about every `lightningInterval` s | +| `storm`, `overcastLook`, `mist` | Presets authored at full daylight; darkened by the clear look's `daylight` toward `nightBrightness` and tinted toward its sky at night, so a midnight storm is dark blue-grey, not a bright grey sky. Sun direction, exposure, grading and fireflies always come from the clear look | +| `ParticleSystem::fireflyGlow` | Firefly brightness + light; set from `WeatherLook::fireflyGlow` (the default night keys raise it). `ambientFade` still fades fireflies and motes in rain / mist | + +Night look: the "sun" becomes the moon (direction, color, intensity), so +shadows keep working; `Sky::night` turns on the star field and moon disc in +`sky.frag` (hidden by clouds, `Sky::clouds` / rain). Point lights (fires, +fireflies) are absolute HDR, so they read strongly once the ambient drops. +With `DayCycle` driving the look, the sun / sky / fog / grading sliders of the +renderer panel are overwritten every frame: tune the keyframes instead (F1 → +Weather → Day keyframes). + +### Game UI (`hd2d/UI.h`) + +`hd2d::ui` draws an HD-2D JRPG interface with Dear ImGui draw lists (no ImGui +windows needed), so it composites over the final frame like the debug panels. +All sizes are authored for a 1080 px tall viewport and scaled by +`ui::scaleFor(viewport)`. + +```cpp +hd2d::DebugUI imgui; imgui.init(window); // creates the ImGui context +namespace ui = hd2d::ui; +ImVec2 vp(window.width(), window.height()); +ui::loadFonts(std::string(HD2D_ENGINE_ASSET_DIR) + "/fonts", ui::scaleFor(vp)); // before the first frame +ui::applyTheme(); // optional: restyle standard ImGui widgets + +ui::DialogueBox box; +box.start({{"Warden", "Welcome, traveller!", portraitTexId}}, {"Yes", "No"}); +ui::Toasts toasts; toasts.push("Field guide updated"); + +// every frame, between imgui.beginFrame() and imgui.endFrame(): +if (confirmPressed && box.advance()) onChoice(box.choice()); // -1 without choices +if (upPressed) box.moveChoice(-1); +box.update(dt); box.draw(vp); +toasts.update(dt); toasts.draw(vp); +ImDrawList* dl = ImGui::GetForegroundDrawList(); +ui::menu(dl, {vp.x * 0.15f, vp.y * 0.4f}, {"Items", "Party", "Quit"}, selected, "Menu", ui::scaleFor(vp)); +ui::nameplate(dl, headPx, "Traveller", ui::kGold, ui::scaleFor(vp)); // headPx from Camera::worldToScreen +``` + +| Piece | Notes | +| --- | --- | +| `loadFonts(dir, pixelScale)` | Bakes `body` / `small` (EB Garamond), `name` (Cinzel) and `title` (Cinzel Decorative Bold) at `kBodySize`… × `pixelScale` so text is pixel-exact; ImGui's default font stays the default for debug panels. Call outside a frame; call again after a resize to rebake | +| `panel`, `PanelStyle` | Navy → near-black gradient, soft drop shadow, gold double border, corner gems + brackets (`ornate`), `tint` alpha fades everything | +| `text`, `textWrapped`, `textCentered`, `measure` | Pixel-snapped text with a 1 px dark shadow (plus a faint halo); null font = current ImGui font | +| `titleText` | Gold gradient logo with dark halo, warm glow, outline and a gem flourish rule | +| `nameplate` | Pill label whose pointer tip sits on a screen point | +| `menu`, `menuAt`, `menuSize` | Option list panel with a gold highlight band and bobbing cursor | +| `progressBar` | Recessed rounded track with a glossy gradient fill | +| `DialogueBox` | Bottom centre, 70 % width, word-wrapped from the full line so words never jump, UTF-8 typewriter at `charsPerSecond` with `sentencePause` / `clausePause`, optional portrait (`ImTextureID` + UVs; load pixel art with `Texture::load(path, false, true)` for crisp nearest filtering), gold speaker plate, blinking gem when revealed, choices above the right end | +| `Toasts` | Banners stacked at the top centre; slide in, fade out after `seconds` | + ## Assets +Fonts in `assets/fonts/` come from [google/fonts](https://github.com/google/fonts) +under the SIL Open Font License (`*-OFL.txt` next to each font; files renamed +from `Family[wght].ttf`). Variable fonts are rasterised at their default +(Regular) instance. + The full guide to making sprites, terrain tiles, high-res textures and 3D models (scale, conventions, PixelLab manifest fields, Blender helpers, how to check results) is in [`docs/ASSET_GUIDE.md`](docs/ASSET_GUIDE.md). Summary: @@ -227,7 +372,6 @@ upscaled 64 px art, so it is rendered at 128 px per unit. ## Roadmap The demo covers the rendering core. Natural next steps for a full engine: -sprite-sheet animation + 4/8-direction facing, a scene/level file format and -editor, more weather (snow, thunderstorms with lightning, wind-blown leaves), -time-of-day presets, cascaded shadows for large maps, audio, and an +animation playback + 4/8-direction facing on top of the sheet/atlas frames, a scene/level file format and +editor, more weather (snow, wind-blown leaves), cascaded shadows for large maps, audio, and an entity/component layer for gameplay. diff --git a/assets/fonts/Cinzel-OFL.txt b/assets/fonts/Cinzel-OFL.txt new file mode 100644 index 0000000..afdfd14 --- /dev/null +++ b/assets/fonts/Cinzel-OFL.txt @@ -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. diff --git a/assets/fonts/Cinzel.ttf b/assets/fonts/Cinzel.ttf new file mode 100644 index 0000000..d218a0b Binary files /dev/null and b/assets/fonts/Cinzel.ttf differ diff --git a/assets/fonts/CinzelDecorative-Bold.ttf b/assets/fonts/CinzelDecorative-Bold.ttf new file mode 100644 index 0000000..7bb0f35 Binary files /dev/null and b/assets/fonts/CinzelDecorative-Bold.ttf differ diff --git a/assets/fonts/CinzelDecorative-OFL.txt b/assets/fonts/CinzelDecorative-OFL.txt new file mode 100644 index 0000000..efe1168 --- /dev/null +++ b/assets/fonts/CinzelDecorative-OFL.txt @@ -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. diff --git a/assets/fonts/EBGaramond-OFL.txt b/assets/fonts/EBGaramond-OFL.txt new file mode 100644 index 0000000..c1ec5e1 --- /dev/null +++ b/assets/fonts/EBGaramond-OFL.txt @@ -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. diff --git a/assets/fonts/EBGaramond.ttf b/assets/fonts/EBGaramond.ttf new file mode 100644 index 0000000..52d34b8 Binary files /dev/null and b/assets/fonts/EBGaramond.ttf differ diff --git a/assets/shaders/billboard.frag b/assets/shaders/billboard.frag index f7c18d3..f6f689e 100644 --- a/assets/shaders/billboard.frag +++ b/assets/shaders/billboard.frag @@ -8,6 +8,7 @@ in VS { vec3 pivot; vec2 local; float tint; + flat vec2 fx; // alpha, flash } v; layout(binding = 0) uniform sampler2D uTex; @@ -15,10 +16,19 @@ uniform int uPass; uniform float uEmissive; layout(location = 0) out vec4 fragColor; +const float kFlashWhite = 4.0; // linear HDR white the flash blends toward (blooms) + +// 4x4 ordered (Bayer) threshold in (0, 1): alpha 1 keeps every pixel, 0 none. +float bayer4(vec2 p) { + ivec2 i = ivec2(p) & 3; + const float m[16] = float[](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5); + return (m[i.y * 4 + i.x] + 0.5) / 16.0; +} void main() { vec4 c = sharpSample(uTex, v.uv); if (c.a < 0.5) discard; + if (v.fx.x < 1.0 && v.fx.x < bayer4(gl_FragCoord.xy)) discard; // dithered fade, shadows included if (uPass == 1) return; vec3 up = vec3(0.0, 1.0, 0.0); @@ -48,5 +58,6 @@ void main() { float d = length(v.pivot.xz - uBlob.xz) / uBlob.w; col *= 1.0 - 0.35 * (1.0 - smoothstep(0.3, 1.0, d)) * (1.0 - clamp(v.local.y / 0.6, 0.0, 1.0)); } + col = mix(col, vec3(kFlashWhite), clamp(v.fx.y, 0.0, 1.0)); // hit / capture flash fragColor = vec4(col, 1.0); } diff --git a/assets/shaders/billboard.vert b/assets/shaders/billboard.vert index fdf6a87..8df4ff2 100644 --- a/assets/shaders/billboard.vert +++ b/assets/shaders/billboard.vert @@ -1,8 +1,11 @@ #version 450 core #include "common.glsl" +// Per-instance attributes; must match BillboardInstance (Billboard.h). layout(location = 0) in vec4 iPosSway; // pivot xyz, sway layout(location = 1) in vec4 iSizeFlipTint; // size xy, flip, tint +layout(location = 2) in vec4 iRect; // uv sub-rect (minU, minV, maxU, maxV) +layout(location = 3) in vec4 iAnchorFx; // anchor v (< 0 = uOpaque.y), turn, alpha, flash uniform vec4 uOpaque; // opaque pixel bounds in uv (minX, minY, maxX, maxY) uniform int uPass; // 0 = color, 1 = shadow @@ -13,22 +16,34 @@ out VS { vec3 pivot; vec2 local; // (horizontal offset, height above pivot) in world units float tint; + flat vec2 fx; // alpha, flash } o; void main() { vec2 corner = vec2(float(gl_VertexID & 1), float(gl_VertexID >> 1)); vec2 size = iSizeFlipTint.xy; vec3 pivot = iPosSway.xyz; + vec4 rect = iRect; + float rectH = max(rect.w - rect.y, 1e-6); + float anchor = iAnchorFx.x < 0.0 ? uOpaque.y : iAnchorFx.x; + float anchorLocal = (anchor - rect.y) / rectH; float lx = (corner.x - 0.5) * size.x; - float ly = (corner.y - uOpaque.y) * size.y; // lowest opaque row sits on the pivot + float ly = (corner.y - anchorLocal) * size.y; // the feet row sits on the pivot - // Shadow cards face the sun so silhouettes are never edge-on to the light. - vec3 right = uPass == 1 ? uShadowRight.xyz : uBillboardRight.xyz; + // Shadow cards face the sun so silhouettes are never edge-on to the light; + // `turn` yaws color cards away from camera-facing (paper turn). + vec3 right = uBillboardRight.xyz; + if (iAnchorFx.y != 0.0) { + float ct = cos(iAnchorFx.y), st = sin(iAnchorFx.y); + right = vec3(right.x * ct - right.z * st, 0.0, right.x * st + right.z * ct); + } + if (uPass == 1) right = uShadowRight.xyz; vec3 wp = pivot + right * lx + vec3(0.0, ly, 0.0); float sway = iPosSway.w; if (sway > 0.0) { - float visibleHeight = max(size.y * (uOpaque.w - uOpaque.y), 1e-3); + // Feet to the top of the rect (or of the opaque pixels, if lower). + float visibleHeight = max(size.y * (min(uOpaque.w, rect.w) - anchor) / rectH, 1e-3); float k = clamp(ly / visibleHeight, 0.0, 1.0); k *= k; float ph = dot(pivot.xz, vec2(0.37, 0.61)); @@ -46,13 +61,14 @@ void main() { wp.y -= push * k * sway * 0.3 * visibleHeight; } - vec2 uv = corner; - if (iSizeFlipTint.z > 0.5) uv.x = 1.0 - uv.x; - o.uv = uv; + vec2 c = corner; + if (iSizeFlipTint.z > 0.5) c.x = 1.0 - c.x; // mirror inside the sub-rect + o.uv = mix(rect.xy, rect.zw, c); o.worldPos = wp; o.pivot = pivot; o.local = vec2(lx, ly); o.tint = iSizeFlipTint.w; + o.fx = iAnchorFx.zw; gl_Position = uViewProj * vec4(wp, 1.0); gl_ClipDistance[0] = dot(vec4(wp, 1.0), uClipPlane); } diff --git a/assets/shaders/common.glsl b/assets/shaders/common.glsl index 3c5a0eb..e6ef6ed 100644 --- a/assets/shaders/common.glsl +++ b/assets/shaders/common.glsl @@ -28,6 +28,7 @@ layout(std140, binding = 0) uniform Frame { vec4 uWaterParams; // level, caustics intensity, has water, unused vec4 uGroundAO; // world -> uv: minX, minZ, 1/sizeX, 1/sizeZ (z = 0: disabled) vec4 uWeather; // rain intensity, surface wetness, rain fall speed, unused + vec4 uSkyParams; // night (stars + moon disc at uSunDir), extra cloud cover, unused, unused ivec4 uLightCount; vec4 uLightPos[16]; // xyz, w = radius vec4 uLightColor[16]; @@ -168,18 +169,30 @@ vec3 wetSurface(vec3 albedo, vec3 n, vec3 V, float vis, out vec3 sheen) { return albedo * mix(1.0, 0.68, uWeather.y); } +// Cloud layer opacity toward `dir` (0 at and below the horizon). Rain and +// uSkyParams.y (overcast) thicken it. +float skyClouds(vec3 dir) { + if (dir.y <= 0.0) return 0.0; + float cover = max(uWeather.x, uSkyParams.y); + vec2 cp = dir.xz / (dir.y + 0.12) * 1.3 + uTime * vec2(0.012, 0.004); + return smoothstep(0.5 - 0.45 * cover, 0.85 - 0.3 * cover, fbm2(cp)) * 0.55 * smoothstep(0.0, 0.2, dir.y); +} vec3 skyColor(vec3 dir) { float h = dir.y; vec3 c = mix(uSkyHorizon.rgb, uSkyZenith.rgb, pow(clamp(h, 0.0, 1.0), 0.55)); if (h < 0.0) c = mix(uSkyHorizon.rgb, uSkyGround.rgb, clamp(-h * 3.0, 0.0, 1.0)); float sd = max(dot(dir, uSunDir.xyz), 0.0); + float night = uSkyParams.x; if (h > 0.0) { - vec2 cp = dir.xz / (h + 0.12) * 1.3 + uTime * vec2(0.012, 0.004); - float cl = smoothstep(0.5 - 0.45 * uWeather.x, 0.85 - 0.3 * uWeather.x, fbm2(cp)); // overcast when raining - c = mix(c, uSunColor.rgb * (0.75 + 0.6 * sd), cl * 0.55 * smoothstep(0.0, 0.2, h)); + // Clouds catch the sun by day; at night they are faintly moonlit. + vec3 lit = mix(uSunColor.rgb * (0.75 + 0.6 * sd), + uSkyHorizon.rgb * 1.6 + uSunColor.rgb * uSunDir.w * (0.02 + 0.3 * pow(sd, 8.0)), night); + c = mix(c, lit, skyClouds(dir)); } - c += uSunColor.rgb * (pow(sd, 900.0) * 40.0 + pow(sd, 10.0) * 0.3); + // Sun disc + halo (the moon replaces it at night, overcast hides it). + float sunVis = (1.0 - night) * (1.0 - uSkyParams.y) * clamp(uSunDir.w * 2.0, 0.0, 1.0); + c += uSunColor.rgb * (pow(sd, 900.0) * 40.0 + pow(sd, 10.0) * 0.3) * sunVis; return c; } diff --git a/assets/shaders/sky.frag b/assets/shaders/sky.frag index 81cd81f..608275d 100644 --- a/assets/shaders/sky.frag +++ b/assets/shaders/sky.frag @@ -4,8 +4,65 @@ in vec2 vUV; layout(location = 0) out vec4 fragColor; +// Star field: at most one star per cell of a grid around the view sphere, +// anti-aliased against the pixel footprint (also works in the half-res +// reflection), twinkling, mostly faint with a few bright ones that bloom. +// `px` = angular pixel footprint (radians), computed in uniform control flow. +vec3 stars(vec3 dir, float px) { + const float grid = 150.0; + vec3 q = dir * grid; + vec3 cell = floor(q); + float h = hash13(cell); + if (h < 0.955) return vec3(0.0); + vec3 jitter = vec3(hash13(cell + 11.3), hash13(cell + 27.1), hash13(cell + 3.7)) - 0.5; + vec3 center = normalize(cell + 0.5 + jitter * 0.7) * grid; // on the sphere + float d = length(q - center); + float size = 0.10 + 0.12 * hash13(cell + 5.9); + float r = max(size, px * grid * 0.6); + float s = exp(-d * d / (r * r)) * (size * size) / (r * r); + float bright = 0.35 + 3.5 * pow(hash13(cell + 41.7), 6.0); + float twinkle = 0.7 + 0.3 * sin(uTime * (1.5 + 4.0 * hash13(cell + 9.2)) + h * 400.0); + vec3 tint = mix(vec3(0.70, 0.80, 1.0), vec3(1.0, 0.86, 0.70), hash13(cell + 17.5)); + return tint * s * bright * twinkle; +} + +// Moon disc at the light direction (the sun acts as the moon at night): +// soft maria, limb darkening and a two-lobe halo. rgb = disc colour, +// a = disc coverage. +vec4 moon(vec3 dir, float px, out vec3 halo) { + const float R = 0.04; // angular radius (stylised, larger than real) + vec3 L = uSunDir.xyz; + vec3 T = normalize(cross(L, abs(L.y) > 0.99 ? vec3(1, 0, 0) : vec3(0, 1, 0))); + vec3 B = cross(T, L); + vec3 rel = dir - L * dot(dir, L); + float ang = length(rel); // sin of the angle to the moon centre + float front = step(0.0, dot(dir, L)); + vec3 moonCol = mix(vec3(1.0, 0.98, 0.94), uSunColor.rgb, 0.25); + halo = moonCol * front * (exp(-ang / R * 1.3) * 0.35 + exp(-ang / (R * 7.0)) * 0.07); + vec2 uv = vec2(dot(rel, T), dot(rel, B)) / R; + float r = length(uv); + float aa = max(px / R, 1e-3) * 1.5; + float disc = (1.0 - smoothstep(1.0 - aa, 1.0, r)) * front; + if (disc <= 0.0) return vec4(0.0); + float maria = vnoise(uv * 1.8 + 3.0) * 0.6 + vnoise(uv * 4.5 + 7.0) * 0.4; + float shade = mix(0.68, 1.0, smoothstep(0.35, 0.65, maria)) * (1.0 - 0.25 * r * r); + return vec4(moonCol * 1.5 * shade, disc); +} + void main() { vec3 far = worldFromDepth(vUV, 1.0); vec3 dir = normalize(far - uCamPos.xyz); - fragColor = vec4(skyColor(dir), 1.0); + float px = length(fwidth(dir)); + vec3 c = skyColor(dir); + float night = uSkyParams.x; + if (night > 0.0 && dir.y > 0.0) { + float cover = max(uWeather.x, uSkyParams.y); + float open = (1.0 - cover) * (1.0 - skyClouds(dir) / 0.55); + c += stars(dir, px) * night * open * smoothstep(0.0, 0.2, dir.y); + vec3 halo; + vec4 disc = moon(dir, px, halo); + c = mix(c, disc.rgb, disc.a * night * open); + c += halo * night * (1.0 - 0.7 * cover); + } + fragColor = vec4(c, 1.0); } diff --git a/demo/DemoScene.cpp b/demo/DemoScene.cpp index 7881686..c8bd43b 100644 --- a/demo/DemoScene.cpp +++ b/demo/DemoScene.cpp @@ -141,9 +141,111 @@ bool DemoScene::load(const std::string& assetDir) { player_.instances.resize(1); placePlayer(kStart); + return !spriteTest || buildSpriteTest(assetDir); +} + +namespace { + +// Lowest row (from the bottom) with alpha >= 128, or 0 if none. +int lowestOpaqueRow(const Image& img) { + for (int y = 0; y < img.height; ++y) + for (int x = 0; x < img.width; ++x) + if (img.rgba[(size_t(y) * img.width + x) * 4 + 3] >= 128) return y; + return 0; +} + +// Copies `src` into `dst` with its bottom-left pixel at (x, y). +void blit(Image& dst, const Image& src, int x, int y) { + for (int r = 0; r < src.height; ++r) + std::copy_n(&src.rgba[size_t(r) * src.width * 4], size_t(src.width) * 4, + &dst.rgba[(size_t(y + r) * dst.width + x) * 4]); +} + +} // namespace + +bool DemoScene::buildSpriteTest(const std::string& assetDir) { + Image big, flowers, tuft; + if (!loadImage(assetDir + "/sprites/test_sprite.png", big) || !loadImage(assetDir + "/textures/flowers.png", flowers) || + !loadImage(assetDir + "/textures/grass_tuft.png", tuft)) + return false; + // The test sprite is 4x upscaled 64 px art: take one pixel per 4x4 block. + Image hero; + hero.width = big.width / 4; + hero.height = big.height / 4; + hero.rgba.resize(size_t(hero.width) * hero.height * 4); + for (int y = 0; y < hero.height; ++y) + for (int x = 0; x < hero.width; ++x) + std::copy_n(&big.rgba[((size_t(y) * 4 + 2) * big.width + x * 4 + 2) * 4], 4, + &hero.rgba[(size_t(y) * hero.width + x) * 4]); + + // Runtime atlas: add, then exercise remove/re-add so slots get reused. + if (!atlas_.init({64, 64}, 4, 2)) return false; + atlasSlots_[0] = atlas_.add(hero); + int scratch = atlas_.add(big); // cropped to the cell + atlas_.remove(scratch); + atlasSlots_[1] = atlas_.add(flowers); // reuses the freed slot + atlasSlots_[2] = atlas_.add(tuft); + HD2D_LOG("sprite test atlas: %d/%d slots used", atlas_.used(), atlas_.capacity()); + atlasSprites_.sharedTexture = &atlas_.texture(); + atlasSprites_.instances.resize(6); + + // 2x1 sprite sheet (authored top-down, one row): hero | flowers. + Image sheet; + sheet.width = 128; + sheet.height = 64; + sheet.rgba.assign(size_t(sheet.width) * sheet.height * 4, 0); + blit(sheet, hero, 0, 0); + blit(sheet, flowers, 64 + (64 - flowers.width) / 2, 0); + bleedTransparentColors(sheet); + if (!sheetSprites_.texture.create(sheet)) return false; + sheetFeetRows_[0] = 63 - lowestOpaqueRow(hero); + sheetFeetRows_[1] = 63 - lowestOpaqueRow(flowers); + sheetSprites_.instances.resize(1); + + atlasSprites_.emissive = sheetSprites_.emissive = player_.emissive; + scene.billboards.push_back(&atlasSprites_); + scene.billboards.push_back(&sheetSprites_); return true; } +void DemoScene::updateSpriteTest(float time, const Camera& camera) { + glm::vec3 right = camera.right(); + glm::vec3 fwd(right.z, 0.0f, -right.x); // horizontal camera forward + const glm::vec2 size = BillboardBatch::cellSize(atlas_.cellPixels(), kPixelsPerUnit); + labels.clear(); + auto place = [&](BillboardInstance& bi, int index, const char* label) { + glm::vec3 p = playerPos - fwd * 1.8f + right * (float(index) - 3.0f); // row in front of the player + bi = BillboardInstance{}; + bi.pos = glm::vec3(p.x, groundHeight({p.x, p.z}), p.z); + bi.size = size; + labels.push_back({bi.pos + glm::vec3(0, size.y + 0.15f, 0), label}); + }; + auto useSlot = [&](BillboardInstance& bi, int slot) { + bi.uv = atlas_.uv(slot); + bi.anchor = atlas_.anchor(slot); + }; + auto& a = atlasSprites_.instances; + const char* names[] = {"atlas", "flip", "turn", "alpha", "flash", "frames"}; + for (int i = 0; i < 6; ++i) { + place(a[i], i, names[i]); + useSlot(a[i], atlasSlots_[0]); + } + a[1].flip = 1.0f; + a[2].turn = 0.8f + 0.4f * std::sin(time * 1.5f); + a[3].alpha = 0.5f + 0.3f * std::sin(time * 2.0f); + a[4].flash = 0.5f + 0.5f * std::sin(time * 5.0f); + useSlot(a[5], atlasSlots_[int(time / 0.4f) % 3]); + atlasSprites_.upload(); + + BillboardInstance& s = sheetSprites_.instances[0]; + place(s, 6, "sheet"); + int frame = int(time / 0.5f) % 2; + glm::ivec2 texSize = sheetSprites_.texture.size(); + s.uv = Texture::cellUV(texSize, {64, 64}, frame, 0); + s.anchor = Texture::cellAnchor(texSize, {64, 64}, 0, sheetFeetRows_[frame]); + sheetSprites_.upload(); +} + void DemoScene::buildTerrain() { Terrain& t = terrain_; t.init(kW, kD, 0.5f); @@ -398,4 +500,5 @@ void DemoScene::update(float dt, float time, glm::vec2 move, const Camera& camer scene.interactor = playerPos; scene.blobShadowPos = glm::vec3(playerPos.x, ground, playerPos.z); scene.focusPoint = playerPos + glm::vec3(0, 0.6f, 0); + if (spriteTest) updateSpriteTest(time, camera); } diff --git a/demo/DemoScene.h b/demo/DemoScene.h index 7ed73fd..734e2f6 100644 --- a/demo/DemoScene.h +++ b/demo/DemoScene.h @@ -1,6 +1,7 @@ #pragma once #include "hd2d/Camera.h" #include "hd2d/Scene.h" +#include "hd2d/SpriteAtlas.h" #include "hd2d/Weather.h" #include @@ -21,11 +22,24 @@ public: glm::vec3 playerPos{0.0f}; hd2d::Weather weather; // call weather.update() each frame before rendering + // Billboard feature test (`--sprite-test`; set before load()): a row of + // atlas / sprite-sheet sprites behind the player exercising sub-rect + // frames, flip, turn, alpha dither and flash. Labels are world-space + // points (sprite tops) for the caller to draw. + bool spriteTest = false; + struct Label { + glm::vec3 pos; + const char* text; + }; + std::vector